From 6fca82e3fc5251f1182b182172c2371413254052 Mon Sep 17 00:00:00 2001 From: guzhujushi Date: Mon, 5 Oct 2026 17:24:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(solve):=20random=5Fwalk=20=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E8=B7=AF=E5=BE=84=E7=9A=84=20UnboundLocalError?= =?UTF-8?q?=EF=BC=9B=E7=AC=AC=E4=BA=8C=E8=BD=AE=E5=8F=A3=E5=BE=84=E6=94=B6?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 口径由用户第二轮改定;Codex 修一处真 bug + 工程侧。 - 真 bug:call_params 写在 try 里、却在 except 被引用,5 种失败输入 5 个全炸 UnboundLocalError (本该返回 success=False 的 Result,变成把异常抛给调用方)。按原意前移到 try 之前,未改口径; 失败时照样带上 n_steps / n_paths / start / random_state - 用户口径:n_steps/n_paths 拆 TypeError/ValueError;加 steps 形状与 NaN 两条校验; 默认步长改 rng.choice([-1.0, 1.0]);params 补 random_state;metrics 改 mean_terminal/std_terminal - 工程:docstring 同步(Returns 的 metrics、Raises 的分工、去掉已删的 start 校验);+3 条测试 (失败也必须返回带 params 的 Result、NaN 判失败、终点 std ~= sqrt(t)),solve 段 41 条 - 验证:208 收集 / 177 passed / 31 skipped / 0 xfail,ruff 0,pyright 0 - docs:STATUS 决策 54 + 3 条新口径;CHANGELOG;易错点 26 --- CHANGELOG.md | 18 +++++++++ STATUS.md | 66 ++++++++++++++++++++----------- docs/dev/personal-pitfalls.md | 18 +++++++++ src/mcm_toolkit/solve/simulate.py | 46 +++++++++++++++------ tests/test_solve.py | 49 ++++++++++++++++++++++- 5 files changed, 160 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 065e446..1aaf404 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -466,6 +466,24 @@ - `STATUS.md`:新增**决策 53** 与 `random_walk` 的 4 条待办;`monte_carlo_integral` 的 2 条新口径办结 1 条 (剩"第一道 `isinstance` 分支已冗余");「下一步」推进到 `markov_simulate` / `markov_steady_state`。 +### Fixed +- **`random_walk` 的失败路径全部炸成 `UnboundLocalError`**(真 bug,按决策 31 修):`call_params = {...}` 写在 `try:` 里, + 而 `except` 分支要引用它 —— 实测 5 种失败输入(形状不对 / 增量含 NaN / 采样器抛异常 / `start` 非数值 / 返回 1-D 数组) + **5 个全中**,本该返回 `success=False` 的 `Result`,变成把异常抛给调用方。按原意把 `call_params` 前移到 `try` 之前, + 未改任何口径;失败时照样带上 `n_steps` / `n_paths` / `start` / `random_state`。 + +### Changed +- `random_walk` 按用户第二轮口径改定(**决策 54**):`n_steps` / `n_paths` 拆成 `TypeError`(类型)/ `ValueError`(取值); + 新增 `steps.shape != (n_paths, n_steps)` 与 `np.isnan(steps).any()` 两条校验;默认步长改 `rng.choice([-1.0, 1.0], size=size)`; + `params` 补 `random_state`;`metrics` 由"抄 params"改成 `mean_terminal` / `std_terminal`(终点位置,`ddof=0`)。 +- `random_walk` 的 docstring 同步:`Returns` 写实际 metrics、`Raises` 改成"类型错 `TypeError` / 取值错 `ValueError`"、 + 去掉已不存在的 `start` 校验、`Notes` 记明形状与 NaN 都判失败。 +- `tests/test_solve.py`:+3 条("失败也必须返回带 params 的 Result"、NaN 判失败、终点 `std ≈ √t`), + `start="a"` 改为断言 `success is False`(口径待定);`solve` 段 38 → **41** 条, + 全量 **208 收集 / 177 passed / 31 skipped / 0 xfail**(ruff 0 / pyright 0)。 +- `STATUS.md`:新增**决策 54** 与 `random_walk` 的 3 条新口径;4 条待办标记办结;验证计数更新为 177 / 31。 +- `docs/dev/personal-pitfalls.md`:新增第 **26** 条(`except` 用到的变量在 `try` 里才赋值 —— 每条失败路径都炸)。 + ## [0.0.1] - 2026-10-01 ### Added diff --git a/STATUS.md b/STATUS.md index f7441f8..a595b2b 100644 --- a/STATUS.md +++ b/STATUS.md @@ -305,9 +305,16 @@ 并写清 `step_sampler` 收的是**二维** `size=(n_paths, n_steps)`);③ Examples 增加一条"步长恒为 ±1"的断言 - **测试:新增 6 条**(此前 `random_walk` **一条测试都没有**)—— 形状与 `start` 列、默认步长恒 ±1、 同种子可复现、自定义高斯采样器(带漂移)、3 类非法入参、docstring Example 真能跑 - - ⏳ **留给用户 4 条**(见"下一步"):非整数 `n_steps` 该抛 `TypeError` 而现抛 `ValueError`; - `steps` 形状没校验(`n_paths == n_steps` 时转置的采样器会**静默给出转置结果**);NaN 不判失败; - `params` 少了 `random_state`、`metrics` 只是把 `n_steps` / `n_paths` 抄了一遍 + - **用户第二轮(2026-10-05)按 4 条待办改定**:`n_steps` / `n_paths` 拆成 `TypeError`(类型)/ `ValueError`(取值); + 加 `steps.shape != (n_paths, n_steps)` 与 NaN 两条校验;默认采样器换成 `rng.choice([-1.0, 1.0], size=size)`; + `params` 补 `random_state`;`metrics` 由"抄 params"改成 `mean_terminal` / `std_terminal`(终点位置,`ddof=0`) + - **Codex 第二轮又抓到一处真 bug(决策 31)**:用户把 `call_params = {...}` 写进了 `try` 里,而 `except` 分支要引用它 —— + **5 种失败输入 5 个全炸** `UnboundLocalError`(本该返回 `success=False` 的 `Result`,变成把异常抛给调用方)。 + 按原意把 `call_params` 移到 `try` 之前(不动任何口径),实测失败路径全部恢复正常;新增一条"必然失败"的回归测试(易错点 26) + - **测试:本轮再 +3 → 共 9 条**(新增"失败也必须返回 Result 且带 params"、NaN 判失败、`√t` 扩散); + `start="a"` 那条改为断言 `success is False`(因为用户删掉了 `start` 的入口类型校验,口径待定) + - ⏳ **留给用户 3 条**(见"下一步"):`start` 的类型校验要不要恢复;`n_steps` / `n_paths` 签名注解仍是 `int` 却收 `np.int64`; + `n_paths == n_steps` 时转置采样器仍无法被形状校验发现 - [ ] 剩余 1 项:`save_figure`(A 档 = 工程,**Codex 直接写**) @@ -315,7 +322,7 @@ | 检查 | venv | conda `mcm-toolkit` | |---|---|---| -| `pytest` | 见下(单列) | **174 passed, 31 skipped(0 xfail)** | +| `pytest` | 见下(单列) | **177 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` | 跑通 | — | @@ -325,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(与两套环境一致) -- **205 条用例:174 passed / 31 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、 - `test_core.py` 23、`test_solve.py` 38(本轮 +6:`random_walk` 首次有测试)、`test_viz.py` 8、`test_decision.py` 7、 +- **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、 `test_stats.py` 53、`test_models.py` 5 - **`xfail` 再次清零**:`correlation_matrix` 的契约冲突已按方案 A 解决(决策 41),那条用例转正 - 上一轮那 7 条 xfail 已全部转正,决策 30 那套信号(XPASS → 摘 mark)跑完过一个完整闭环 @@ -475,16 +482,23 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向 `step_sampler` 的 `size` 是 `(n_paths, n_steps)` 而不是 `monte_carlo` 那样的一个整数 —— 契约已写进 docstring, **遗留 4 条待办**见"下一步"(`TypeError`/`ValueError` 分工、`steps` 形状校验、NaN 口径、`params` 缺 `random_state`)。 +| 54 | `random_walk` 第二轮口径由**用户**改定:`n_steps`/`n_paths` 类型错抛 `TypeError`、取值错抛 `ValueError`;加形状与 NaN 两条校验;默认步长改 `rng.choice([-1.0, 1.0])`;`params` 补 `random_state`;`metrics` 改 `mean_terminal`/`std_terminal` | +用户 2026-10-05 自行改定,Codex 只修了一处**纯 bug**:`call_params` 定义在 `try` 内、却被 `except` 引用, +导致**所有失败路径**抛 `UnboundLocalError`(实测 5/5 失败输入全中)。按原意前移到 `try` 之前,未改任何口径。 +**遗留 3 条**:① `start` 的入口类型校验被删(`start="a"` 从 `TypeError` 变成 `success=False`),是有意还是顺手删的? +② 签名 `n_steps: int` / `n_paths: int` 与运行期收 `np.int64` 不一致(§4.7;`monte_carlo_integral` 的 `n` 已放宽); +③ `n_paths == n_steps` 时,返回转置形状的采样器仍会静默通过(形状校验天生发现不了,见易错点 25)。 + ## 下一步 -### ⭐ 当前进度与待办(2026-10-05 第三轮) +### ⭐ 当前进度与待办(2026-10-05 第四轮) -**本轮:`random_walk` 已由用户写完第一版并审查通过**(L1,S 档)。Codex 改了一处**真 bug** -(默认采样器 `rng.integers(..., dtype=float)` 是 numpy 不支持的写法,默认路径 100% 失败,见决策 53), -另补 ruff / docstring / **6 条从无到有的测试**。 +**本轮:`random_walk` 第二版(用户按 4 条待办改定)已审查通过**。Codex 又抓到一处**真 bug**: +`call_params` 定义在 `try` 内、却在 `except` 里被引用,**所有失败路径**都抛 `UnboundLocalError`(易错点 26,见决策 54); +另补 docstring、+3 条测试(共 9 条)。 **接下来三件事** -1. `random_walk` 有 **4 条待办**(下面一节)——都是你的活。 +1. `random_walk` 的 4 条待办已办结,新增 **3 条**(下面一节)——都是你的活。 2. `ci_low` / `ci_high` 的语义确认一句(也是你的活),确认后 Codex 补 `docs/algorithms/monte_carlo.md` 卡片。 3. L0 最后一项 `save_figure`(A 档 = 工程,`viz/export.py`)—— **Codex 直接实现**,你只读。 @@ -492,19 +506,25 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向 两者是同一件事的两面:前者按转移矩阵**逐代抽状态**(`rng.choice` 的 `p=` 版),后者用**迭代**求平稳分布。 `markov_steady_state` 的 docstring 已写明口径:用迭代而不用特征分解,因为"收敛快不快"本身就是论文里要写的性质。 -### ⏳ random_walk 的 4 条待办(2026-10-05,你的活) +### ✅ random_walk 的 4 条待办(2026-10-05 已办结) -1. **非整数 `n_steps` 抛的是 `ValueError`,按 §4.7 该是 `TypeError`**:现在 `n_steps=2.5` / `n_steps=True` 都走 - `ValueError("必须是正整数")`。同模块的 `monte_carlo` / `monte_carlo_probability` 是"类型不对 → `TypeError`、 - 取值不对(`<= 0`)→ `ValueError`",这里要拆成两条。 -2. **`steps` 没有形状校验,会静默出错**:实测 `n_paths == n_steps` 时,一个返回**转置**形状的采样器 - 能拿到 `success=True` 和形状"正确"的 `data` —— 数字却是转置过来的(`n_paths != n_steps` 时才报错)。 - 方向:`steps.shape != (n_paths, n_steps)` 就判失败,和 `monte_carlo` 的 `len(data) != n` 一个套路。 -3. **NaN 不判失败**:`step_sampler` 返回全 NaN 时现在照样 `success=True`(决策 48 里 `monte_carlo` 是判失败的)—— - 两个函数的 NaN 口径要不要拉齐? -4. **`params` 少了 `random_state`**(另两个函数都记了,`Result.params` 的用途就是"能复现"); - 另外 `metrics={"n_steps", "n_paths"}` 只是把 `params` 里的两个数抄了一遍 —— 要么换成真正的指标 - (终点的均值 / 标准差、理论 `√t` 对比),要么直接去掉。 +- ~~① 非整数 `n_steps` 抛 `ValueError`~~ → 已拆成两条:类型错 `TypeError`、取值错(`<= 0`)`ValueError`,`n_paths` 同样处理。 +- ~~② `steps` 没有形状校验~~ → 已加 `steps.shape != (n_paths, n_steps)` 判失败(实测转置采样器现在能拦住)。 +- ~~③ NaN 不判失败~~ → 已加 `np.isnan(steps).any()`,与 `monte_carlo` 的 NaN 口径(决策 48)拉齐。 +- ~~④ `params` 少 `random_state` / `metrics` 抄了一遍~~ → 已补 `random_state`;`metrics` 换成 `mean_terminal` / `std_terminal` + (终点位置的均值与标准差,`ddof=0`)—— 这才是"游走"该看的指标,测试里还顺手验了 `std ≈ √t`。 + +### ⏳ random_walk 还有 3 条口径(新增,你的活) + +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 并校验每个维度)—— 你定。 ### ✅ monte_carlo_probability 的 6 条待办(2026-10-05 全部办结) diff --git a/docs/dev/personal-pitfalls.md b/docs/dev/personal-pitfalls.md index 30d578e..daa8832 100644 --- a/docs/dev/personal-pitfalls.md +++ b/docs/dev/personal-pitfalls.md @@ -47,6 +47,7 @@ | 23 | 体积/宽度这种"看着总为正"的量也会减反,维度奇偶还会把符号错误藏住 | 2026-10-05 | | 24 | 默认参数路径从没被执行过 —— 默认采样器用了库不接受的参数 | 2026-10-05 | | 25 | 两个维度相等时,转置的形状检查会静默通过 | 2026-10-05 | +| 26 | `except` 用到的变量在 `try` 里才赋值 —— 每条失败路径都炸 | 2026-10-05 | | 22 | 失败结果的 `message` 被包装层无条件覆盖 —— 对外永远报"成功" | 2026-10-05 | | 23 | 体积/宽度这种"看着总为正"的量也会减反,维度奇偶还会把符号错误藏住 | 2026-10-05 | @@ -435,3 +436,20 @@ 对外部传进来的批量数组显式校验 `shape == (n, d)`,别指望后面的运算替你把关。 - 首次:2026-10-05|复发:0 + + +### 26. `except` 用到的变量在 `try` 里才赋值 —— 每条失败路径都炸 + +- 现象:`random_walk` 把 `call_params = {...}` 写在 `try:` 里面,但 `except` 分支要 `params=call_params`。 + 于是**每一个失败路径**都变成 `UnboundLocalError: cannot access local variable 'call_params' where it is not associated with a value` + —— 实测 5 种失败输入(形状不对 / 增量含 NaN / 采样器抛异常 / `start` 非数值 / 返回 1-D 数组)**5 个全中**, + 本该返回 `success=False` 的 `Result`,变成了把异常直接抛给调用方。 +- 为什么会错:写的时候脑子里只过"成功路径",`except` 是照上一版的结构留下来的; + **异常分支只有真抛异常才会执行**,正常跑一遍、ruff、pyright 全都看不见(当时两个都 0 error)。 + 与第 24 条(默认路径没跑过)是同一类:**分支覆盖靠"我以为",不靠执行**。 + 还有一层:`params` 里那几个值(`n_steps` / `n_paths` / `start` / `random_state`)在 `try` 之前就已经定了, + 它们本来就**不属于**"可能失败的计算",放进去纯属位置错误。 +- 以后怎么防:① 写 `except` 时,逐个变量问"它是在 `try` 里才被赋值的吗"——**失败的返回要在 `try` 之前准备好**; + ② 每写一个 `try`,必须配一个**注定失败**的用例(`assert r.success is False`),专门喂 `except` 分支; + ③ 包装层的失败结果也要带 `params`,出问题才追得回去。 +- 首次:2026-10-05|复发:0 diff --git a/src/mcm_toolkit/solve/simulate.py b/src/mcm_toolkit/solve/simulate.py index 55636fe..6e54103 100644 --- a/src/mcm_toolkit/solve/simulate.py +++ b/src/mcm_toolkit/solve/simulate.py @@ -354,27 +354,29 @@ def random_walk( 必须返回形状 `(n_paths, n_steps)` 的数组(契约同 `monte_carlo` 的 `sampler`,只是这里是二维)。 默认 ±1 等概率(对称随机游走)。 start: - 起点,写进每条路径的第 0 列。 + 起点,写进每条路径的第 0 列(**入口不校验类型**,传非数值会落到失败分支)。 random_state: 随机种子。`None` 时跟随 `core.set_seed` 的全局流,见 `core.random.get_rng`。 Returns ------- `Result`:`data` 形状 `(n_paths, n_steps + 1)`,第 0 列恒为 `start`,之后是逐列累加出来的位置; - `metrics={"n_steps", "n_paths"}`。 + `metrics={"mean_terminal", "std_terminal"}`(终点的均值与标准差,`std` 用 `ddof=0`)。 Raises ------ + TypeError: + `n_steps` / `n_paths` 不是整数。 ValueError: `n_steps` / `n_paths` 不是正整数。 - TypeError: - `start` 不是数值。 Notes ----- 随机游走是"平方根扩散"的原型:位置的标准差随步数按 `√t` 增长,所以典型图形是 `±√t` 的喇叭口。 本函数只负责 `cumsum`;**建模差异全在 `step_sampler` 里** —— 换成 `rng.normal(0, 1, size)` 就是高斯游走, 换成带正漂移的增量就是有趋势的游走。 + `step_sampler` 返回的形状不等于 `(n_paths, n_steps)`、或增量里出现 NaN,都判 `success=False` + (NaN 口径与 `monte_carlo` 一致);其余异常同样统一走失败分支。 Examples -------- @@ -385,23 +387,37 @@ def random_walk( >>> bool(np.all(np.abs(np.diff(r.data, axis=1)) == 1)) # 默认步长恒为 1 True """ - if isinstance(n_steps, bool) or not isinstance(n_steps, (int, np.integer)) or n_steps <= 0: + if isinstance(n_steps, bool) or not isinstance(n_steps, (int, np.integer)): + raise TypeError(f"n_steps 必须是整数,你传入了 {type(n_steps).__name__}") + if n_steps <= 0: raise ValueError(f"n_steps 必须是正整数,你传入了 {n_steps}") - if isinstance(n_paths, bool) or not isinstance(n_paths, (int, np.integer)) or n_paths <= 0: + + if isinstance(n_paths, bool) or not isinstance(n_paths, (int, np.integer)): + raise TypeError(f"n_paths 必须是整数,你传入了 {type(n_paths).__name__}") + 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: def _default_step_sampler(rng, size): - return rng.integers(0, 2, size=size) * 2 - 1 + return rng.choice([-1.0, 1.0], size=size) step_sampler = _default_step_sampler + call_params = { + "n_steps": n_steps, + "n_paths": n_paths, + "start": start, + "random_state": random_state, + } + try: steps = np.asarray(step_sampler(rng, size=(n_paths, n_steps)), dtype=float) + if steps.shape != (n_paths, n_steps): + raise ValueError(f"step_sampler 返回形状必须为 ({n_paths}, {n_steps}),实际得到了 {steps.shape}") + if np.isnan(steps).any(): + raise ValueError("step_sampler 生成的增量数据中包含 NaN,请检查采样逻辑") cumsum = np.cumsum(steps, axis=1) @@ -409,10 +425,16 @@ def random_walk( paths[:, 0] = start paths[:, 1:] = start + cumsum + terminal_positions = paths[:, -1] # 取最后一列,得到 n_paths 个终点位置 + metrics = { + "mean_terminal": float(np.mean(terminal_positions)), + "std_terminal": float(np.std(terminal_positions)) + } + return Result( name="random_walk", - params={"n_steps": n_steps, "n_paths": n_paths, "start": start}, - metrics={"n_steps": n_steps, "n_paths": n_paths}, + params=call_params, + metrics=metrics, data=paths, success=True, message=f"随机游走模拟成功:生成了 {n_paths} 条路径,每条路径 {n_steps} 步。" @@ -420,7 +442,7 @@ def random_walk( except Exception as e: return Result( name="random_walk", - params={"n_steps": n_steps, "n_paths": n_paths, "start": start}, + params=call_params, metrics={}, data=None, success=False, diff --git a/tests/test_solve.py b/tests/test_solve.py index 84d05d1..a997c67 100644 --- a/tests/test_solve.py +++ b/tests/test_solve.py @@ -318,14 +318,59 @@ def test_random_walk_accepts_custom_step_sampler(): def test_random_walk_rejects_bad_arguments(): + """§4.7 的分工:类型不对抛 TypeError,取值不对(<= 0)抛 ValueError。""" from mcm_toolkit.solve import random_walk + with pytest.raises(TypeError): + random_walk(n_steps=2.5) # type: ignore[arg-type] # 就是要传错 + with pytest.raises(TypeError): + random_walk(n_steps=True) + with pytest.raises(TypeError): + random_walk(n_steps=10, n_paths=1.5) # type: ignore[arg-type] # 就是要传错 with pytest.raises(ValueError): random_walk(n_steps=0) with pytest.raises(ValueError): random_walk(n_steps=10, n_paths=0) - with pytest.raises(TypeError): - random_walk(n_steps=10, start="a") # type: ignore[arg-type] # 就是要传错 + r = random_walk(n_steps=10, start="a") # type: ignore[arg-type] # 就是要传错 + assert r.success is False # 口径待定:入口抛 TypeError 也可以,见 STATUS"下一步" + + +def test_random_walk_failure_returns_result_not_exception(): + """失败也要返回带 params 的 Result,不能把异常抛给调用方 —— 包装层引用未赋值变量那次就靠它拦住。""" + from mcm_toolkit.solve import random_walk + + def transposed(rng, size): + return rng.normal(0, 1, (size[1], size[0])) + + r = random_walk(n_steps=4, n_paths=6, step_sampler=transposed, random_state=0) + assert r.success is False + assert "形状" in r.message + assert r.params["n_paths"] == 6 # 失败也要留下追溯信息 + assert r.data is None + + +def test_random_walk_rejects_nan_steps(): + """增量含 NaN 判失败 —— 与 monte_carlo 的 NaN 口径(决策 48)一致。""" + from mcm_toolkit.solve import random_walk + + r = random_walk( + n_steps=4, + n_paths=2, + step_sampler=lambda rng, size: np.full(size, np.nan), + random_state=0, + ) + assert r.success is False + assert "NaN" in r.message + + +def test_random_walk_metrics_are_terminal_positions(): + """终点位置的标准差按 √t 扩散(t = n_steps)—— 随机游走的核心性质。""" + from mcm_toolkit.solve import random_walk + + n_steps = 100 + r = random_walk(n_steps=n_steps, n_paths=2000, start=5.0, random_state=0) + assert abs(r.metrics["mean_terminal"] - 5.0) < 5.0 # 对称游走,期望还在起点附近 + assert abs(r.metrics["std_terminal"] - np.sqrt(n_steps)) < 5.0 # √t 扩散 def test_random_walk_docstring_example_runs():