fix(solve): random_walk 的 start 校验恢复后收尾(文案/空格/测试)

两条口径由用户第二轮改定,Codex 顺手补工程侧。

- 用户:恢复 start 的入口类型校验;n_steps/n_paths 注解放宽为 int | np.integer
- Codex:start 报错文案"必须是整数" -> "必须是数值"(这条校验本来就收 float,原文案自相矛盾),
  类型名统一 type(x).__name__;n_paths 默认值补空格;docstring 的 start 说明与 Raises 同步
- tests:start="a" 恢复断言 TypeError;补回 np.int64 的 n_steps/n_paths;solve 段 42 条
- 验证:209 收集 / 178 passed / 31 skipped / 0 xfail,ruff 0,pyright 0
- docs:STATUS 决策 55(方阵歧义两条路)+ 验证计数;CHANGELOG
This commit is contained in:
guzhujushi committed 2026-10-05 18:01:40 +08:00
1 parent 6fca82e3fc
commit cd6f613568
4 files changed
+54 -21

No files matched your search

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