diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aaf404..947ac8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -484,6 +484,17 @@ - `STATUS.md`:新增**决策 54** 与 `random_walk` 的 3 条新口径;4 条待办标记办结;验证计数更新为 177 / 31。 - `docs/dev/personal-pitfalls.md`:新增第 **26** 条(`except` 用到的变量在 `try` 里才赋值 —— 每条失败路径都炸)。 +### Changed +- `random_walk` 收口 2 条口径(用户第二轮改动):恢复 `start` 的入口类型校验(`isinstance(start, (int, float, np.number))`); + 签名注解放宽为 `n_steps: int | np.integer` / `n_paths: int | np.integer`(与 §4.7 及 `monte_carlo_integral` 的 `n` 对齐)。 +- `random_walk` 的 `start` 报错文案(Codex 顺手):原文"start必须是整数"与校验本身矛盾 —— 这条校验**收 float**, + 改成"start 必须是数值";类型名统一 `{type(start).__name__}`(全模块口径);`n_paths: int | np.integer= 1` 补空格。 +- `random_walk` 的 docstring 同步:`start` 的说明去掉"入口不校验类型",`Raises` 补回 `start` 的 `TypeError`。 +- `tests/test_solve.py`:`start="a"` 恢复断言 `TypeError`;补回 `np.int64` 的 `n_steps` / `n_paths`; + `solve` 段 41 → **42** 条,全量 **209 收集 / 178 passed / 31 skipped / 0 xfail**(ruff 0 / pyright 0)。 +- `STATUS.md`:新增**决策 55**(`n_paths == n_steps` 的"方阵歧义"留作已知边界,A 接受 / B 换契约两条路); + 3 条口径办结 2 条,验证计数更新为 178 / 31。 + ## [0.0.1] - 2026-10-01 ### Added diff --git a/STATUS.md b/STATUS.md index a595b2b..9f248d5 100644 --- a/STATUS.md +++ b/STATUS.md @@ -312,7 +312,7 @@ **5 种失败输入 5 个全炸** `UnboundLocalError`(本该返回 `success=False` 的 `Result`,变成把异常抛给调用方)。 按原意把 `call_params` 移到 `try` 之前(不动任何口径),实测失败路径全部恢复正常;新增一条"必然失败"的回归测试(易错点 26) - **测试:本轮再 +3 → 共 9 条**(新增"失败也必须返回 Result 且带 params"、NaN 判失败、`√t` 扩散); - `start="a"` 那条改为断言 `success is False`(因为用户删掉了 `start` 的入口类型校验,口径待定) + `start="a"` 那条恢复成 `pytest.raises(TypeError)`(用户恢复了入口校验,见下) - ⏳ **留给用户 3 条**(见"下一步"):`start` 的类型校验要不要恢复;`n_steps` / `n_paths` 签名注解仍是 `int` 却收 `np.int64`; `n_paths == n_steps` 时转置采样器仍无法被形状校验发现 @@ -322,7 +322,7 @@ | 检查 | venv | conda `mcm-toolkit` | |---|---|---| -| `pytest` | 见下(单列) | **177 passed, 31 skipped(0 xfail)** | +| `pytest` | 见下(单列) | **178 passed, 31 skipped(0 xfail)** | | `import mcm_toolkit` | 0.0.1 / 16 项 | 0.0.1 / 16 项 | | `ruff check` | All checks passed | **All checks passed**(0 error) | | `scripts/doctor.ps1` | 跑通 | — | @@ -332,8 +332,8 @@ (修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与 `docs/04-environment.md` 坑 7) - base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致) -- **208 条用例:177 passed / 31 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、 - `test_core.py` 23、`test_solve.py` 41(本轮 +3:失败路径 / NaN / `√t` 扩散)、`test_viz.py` 8、`test_decision.py` 7、 +- **209 条用例:178 passed / 31 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、 + `test_core.py` 23、`test_solve.py` 42(本轮 +1:`np.int64` 的 `n_steps` / `n_paths`)、`test_viz.py` 8、`test_decision.py` 7、 `test_stats.py` 53、`test_models.py` 5 - **`xfail` 再次清零**:`correlation_matrix` 的契约冲突已按方案 A 解决(决策 41),那条用例转正 - 上一轮那 7 条 xfail 已全部转正,决策 30 那套信号(XPASS → 摘 mark)跑完过一个完整闭环 @@ -489,6 +489,13 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向 ② 签名 `n_steps: int` / `n_paths: int` 与运行期收 `np.int64` 不一致(§4.7;`monte_carlo_integral` 的 `n` 已放宽); ③ `n_paths == n_steps` 时,返回转置形状的采样器仍会静默通过(形状校验天生发现不了,见易错点 25)。 +| 55 | `step_sampler` 的"方阵歧义"留作**已知边界**:`n_paths == n_steps` 时,返回转置形状的采样器无法被形状校验发现 | +形状校验(决策 54)对"方阵"天生失效 —— 转置 `(n_steps, n_paths)` 正好等于目标 `(n_paths, n_steps)`。 +实测确认:`n_paths != n_steps` 能拦住,相等时静默通过(易错点 25)。两条路(**用户拍板**): +**A 接受**:docstring 写明"方阵时转置的采样器察觉不到";**B 换契约**:`size` 传**整数** `n_paths * n_steps`、要求返回**一维**数组, +内部 `reshape(n_paths, n_steps)` —— 二维返回一律判失败(探针实测:转置二维与方阵二维都被拦下), +而且与 `monte_carlo` 的 `sampler` 契约(决策 48 的 `size=n`)统一,"monte_carlo 风格的采样器可直接复用"。 + ## 下一步 ### ⭐ 当前进度与待办(2026-10-05 第四轮) @@ -498,7 +505,7 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向 另补 docstring、+3 条测试(共 9 条)。 **接下来三件事** -1. `random_walk` 的 4 条待办已办结,新增 **3 条**(下面一节)——都是你的活。 +1. `random_walk` 的 4 条待办已办结,第二轮新增的 3 条又办结 2 条(下面一节)——只剩 1 条。 2. `ci_low` / `ci_high` 的语义确认一句(也是你的活),确认后 Codex 补 `docs/algorithms/monte_carlo.md` 卡片。 3. L0 最后一项 `save_figure`(A 档 = 工程,`viz/export.py`)—— **Codex 直接实现**,你只读。 @@ -514,17 +521,19 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向 - ~~④ `params` 少 `random_state` / `metrics` 抄了一遍~~ → 已补 `random_state`;`metrics` 换成 `mean_terminal` / `std_terminal` (终点位置的均值与标准差,`ddof=0`)—— 这才是"游走"该看的指标,测试里还顺手验了 `std ≈ √t`。 -### ⏳ random_walk 还有 3 条口径(新增,你的活) +### ✅ random_walk 新增的 3 条口径(2026-10-05 办结 2 条、剩 1 条) -1. **`start` 的入口类型校验被删了**:`start="a"` 现在走 `success=False`("could not convert string to float"), - 不再是入口的 `TypeError`。是有意改成"交给失败分支"(那 docstring 里我已如实写明"入口不校验类型"),还是顺手删的? - 要恢复的话,原来那行是 `if not isinstance(start, (int, float, np.number)): raise TypeError(...)`。 -2. **签名注解比行为窄**:`n_steps: int` / `n_paths: int`,但入口校验明明收 `np.integer`(实测 `np.int64` 能过)。 - 按 §4.7 应与 `monte_carlo_integral` 的 `n` 一样写成 `int | np.integer` —— 现在 pyright 会对老老实实按注解传参的 - 调用方报 `reportArgumentType`(我写测试时就被拦了一次,所以那条测试暂时撤了)。 -3. **`n_paths == n_steps` 时形状校验天生失效**:转置的采样器返回 `(n_steps, n_paths)`,正好等于 `(n_paths, n_steps)`, - 校验放行、结果静默转置(易错点 25)。要么接受这个边界,要么让 `step_sampler` 契约改成"返回 `(n_paths, n_steps)` 之外的形状即失败" - 之外的更强约束(比如显式要求 `size` 是 tuple 并校验每个维度)—— 你定。 +- ~~① `start` 的入口类型校验被删~~ → 已恢复(`isinstance(start, (int, float, np.number))`)。 + 我顺手把文案里的"必须是整数"改成"必须是数值"(这条校验本来就收 float,原文案自相矛盾), + 并把 `{type(start)}` 统一成 `{type(start).__name__}`(全模块的口径)。测试改回 `pytest.raises(TypeError)`。 +- ~~② 签名注解比行为窄~~ → 已改成 `n_steps: int | np.integer` / `n_paths: int | np.integer`, + 与 §4.7 及 `monte_carlo_integral` 的 `n` 对齐;`np.int64` 的测试也补回来了。 + +**还剩 1 条(你的活)** +1. **`n_paths == n_steps` 时转置形状仍会静默通过**:形状校验对"方阵"天生失效(转置正好等于目标形状)。 + 两个方向,任选(见"决策 55"):**A** 接受这个边界,在 docstring 写明"返回转置形状的采样器在方阵时无法察觉"; + **B** 把 `step_sampler` 的契约改成"`size` 是**整数** `n_paths * n_steps`、必须返回**一维**数组", + 内部再 `reshape(n_paths, n_steps)` —— 二维返回一律判失败,转置问题从根上消失,且与 `monte_carlo` 的 `sampler` 契约统一。 ### ✅ monte_carlo_probability 的 6 条待办(2026-10-05 全部办结) diff --git a/src/mcm_toolkit/solve/simulate.py b/src/mcm_toolkit/solve/simulate.py index 6e54103..d482363 100644 --- a/src/mcm_toolkit/solve/simulate.py +++ b/src/mcm_toolkit/solve/simulate.py @@ -335,8 +335,8 @@ def monte_carlo_integral( def random_walk( - n_steps: int, - n_paths: int = 1, + n_steps: int | np.integer, + n_paths: int | np.integer = 1, step_sampler: Callable[..., Any] | None = None, start: float = 0.0, random_state: int | None = None, @@ -354,7 +354,7 @@ def random_walk( 必须返回形状 `(n_paths, n_steps)` 的数组(契约同 `monte_carlo` 的 `sampler`,只是这里是二维)。 默认 ±1 等概率(对称随机游走)。 start: - 起点,写进每条路径的第 0 列(**入口不校验类型**,传非数值会落到失败分支)。 + 起点,写进每条路径的第 0 列。 random_state: 随机种子。`None` 时跟随 `core.set_seed` 的全局流,见 `core.random.get_rng`。 @@ -367,6 +367,7 @@ def random_walk( ------ TypeError: `n_steps` / `n_paths` 不是整数。 + `start` 不是数值。 ValueError: `n_steps` / `n_paths` 不是正整数。 @@ -397,6 +398,9 @@ def random_walk( if n_paths <= 0: raise ValueError(f"n_paths 必须是正整数,你传入了 {n_paths}") + if not isinstance(start, (int, float, np.number)): + raise TypeError(f"start 必须是数值,你传入了 {type(start).__name__}") + rng = get_rng(random_state) if step_sampler is None: @@ -425,7 +429,7 @@ def random_walk( paths[:, 0] = start paths[:, 1:] = start + cumsum - terminal_positions = paths[:, -1] # 取最后一列,得到 n_paths 个终点位置 + terminal_positions = paths[:, -1] metrics = { "mean_terminal": float(np.mean(terminal_positions)), "std_terminal": float(np.std(terminal_positions)) diff --git a/tests/test_solve.py b/tests/test_solve.py index a997c67..d254eb6 100644 --- a/tests/test_solve.py +++ b/tests/test_solve.py @@ -331,8 +331,8 @@ def test_random_walk_rejects_bad_arguments(): random_walk(n_steps=0) with pytest.raises(ValueError): random_walk(n_steps=10, n_paths=0) - r = random_walk(n_steps=10, start="a") # type: ignore[arg-type] # 就是要传错 - assert r.success is False # 口径待定:入口抛 TypeError 也可以,见 STATUS"下一步" + with pytest.raises(TypeError): + random_walk(n_steps=10, start="a") # type: ignore[arg-type] # 就是要传错 def test_random_walk_failure_returns_result_not_exception(): @@ -363,6 +363,15 @@ def test_random_walk_rejects_nan_steps(): assert "NaN" in r.message +def test_random_walk_accepts_numpy_integer(): + """§4.7:`np.int64` 不是 `int` 的子类,注解与运行期都要收(易错点 1)。""" + from mcm_toolkit.solve import random_walk + + r = random_walk(n_steps=np.int64(20), n_paths=np.int64(3), random_state=0) + assert r.success + assert r.data.shape == (3, 21) + + def test_random_walk_metrics_are_terminal_positions(): """终点位置的标准差按 √t 扩散(t = n_steps)—— 随机游走的核心性质。""" from mcm_toolkit.solve import random_walk