fix(solve): 同步第三轮入口口径(docstring + 测试 + lint)

用户第三轮改了 5 处口径:非负校验带容差、initial 收 np.integer、paths 赋值挪进 try、
metrics 换成 terminal_distribution、mm1 放开 lambda=0 并收 numpy 标量。三处算法本体仍正确
(探针复测:终点分布 {0: 0.6644, 1: 0.3356}、lambda=0 给 L=Lq=Wq=0 且 W=1/mu、data is metrics 已为 False)。

Changed(Codex 只做同步,未改口径)
- docstring:markov_simulate 的 Returns 与"遗留"段、markov_steady_state 的 Raises
  (TypeError / ValueError 分工)、mm1_metrics 的 Parameters / Returns / Raises。
- tests/test_solve.py:同步 5 条受影响的测试,新增 2 条(terminal_distribution 只列出现过的状态、
  lambda=0 的空系统)。test_solve.py 64 条。
- ruff:B905(zip 缺 strict)+ 2 条空白 -> 0;rng 之后的双空行 -> 1。
- STATUS.md:决策 56 / 57 / 58 更新到"只剩 5 条小口径";「下一步」第八轮;
  新增「第八轮备忘:models 与 decision 各是干什么的」(函数清单 / 使用顺序 / 建议开工顺序)。
- CHANGELOG 与 docs/dev/personal-pitfalls.md 易错点 30(口径一改,docstring / 测试 / STATUS 同时过期)。

校验:pytest 204 passed / 28 skipped(232 收集);ruff 0;pyright 0
This commit is contained in:
guzhujushi committed 2026-10-10 09:31:30 +08:00
1 parent 953e988983
commit 9cb295a362
5 files changed
+190 -107

No files matched your search

+73 -64
View File
@@ -26,7 +26,7 @@
剩余 1 项:`save_figure`(Codex 写)。
`monte_carlo` 的口径已拍板(决策 48);`monte_carlo_probability` / `random_walk` 的待办**均已办结**;
`markov_simulate`(决策 56)/ `markov_steady_state`(决策 57)/ `mm1_metrics`(决策 58)
**仍有一批入口口径待你拍板**(本轮各办结了一批,详见"下一步")。
的入口口径**已办掉大半**,各剩 1~2 条细枝末节(详见"下一步"第八轮)。
另 3 个小口径仍悬着:`ci_low`/`ci_high` 的语义、`monte_carlo_integral` 的第一道 `isinstance` 分支可塌成两行、
`random_state` 注解与 `std` 的 `ddof` 是否全项目对齐。
@@ -337,6 +337,11 @@
`np.int64` 可收、docstring Example 真能跑
- **第二轮(2026-10-09)**:用户补了 `np.any(arr < 0)` 非负校验 → 待办 ② 办结;
Codex 补一条"行和是 1 但含负元素"的回归测试
- **第三轮(2026-10-10,用户改定)**:非负校验改带容差 `-1e-12`(⑦ 办结);
`initial` 注解放宽到 `(int, np.integer)`(`np.int64` 已收);`paths` 的构造与首列赋值**挪进 `try`**(③ 办结,
长度不匹配/越界不再裸抛);`params` 的键 `n_steps` → `steps`(与参数名对齐)、`initial` 存成可 JSON 化的标量/列表;
`metrics` 去掉 `std_terminal`、换成 `terminal_distribution`(`{状态: 频率}` dict,⑤ 办结)
—— Codex 同步 docstring 与 3 条测试(含"只列出现过的状态"这条口径)
- ⏳ **余下 4 条**见"下一步 → markov_simulate 待你拍板"(全是入口口径与少考虑情况,决策 49)
- [x] `solve/simulate.py` 的 `markov_steady_state`(**L1,S 档,算法由用户本人写**,2026-10-09)
@@ -357,7 +362,9 @@
`success=converged`(选口径 B,④ 办结)、`tol` 拆成 `TypeError` / `ValueError` 且先判 `bool`(② 办结);
Codex 顺手修文案(`max_iter <= 0` 那条写成 "tol 必须是正数")与 `异常: ` → `异常:`,
并补 2 条测试(负元素矩阵、`max_iter=3` 未收敛走 `success=False`)
- ⏳ **余下 3 条 + 1 个新口子**见"下一步 → markov_steady_state 待你拍板"(决策 57)
- **第三轮(2026-10-10,用户改定)**:非负校验带容差 `-1e-12`(⑦ 办结);`max_iter` 类型错改抛 `TypeError`(⑤ 办结);
`tol` 加 `np.isnan` 拦截(③ 办结);非方阵改抛 `TypeError`
- ⏳ **只剩 2 条**(⑧ 未收敛带 `data`、非方阵的 `TypeError`/`ValueError` 与 `markov_simulate` 不一致)见"下一步"(决策 57)
- [x] `solve/simulate.py` 的 `mm1_metrics`(**L1,S 档,算法由用户本人写**,2026-10-09)
- 核心:闭式解 `ρ = λ/μ`、`L = ρ/(1-ρ)`、`Lq = ρ²/(1-ρ)`、`W = 1/(μ-λ)`、`Wq = W - 1/μ`;
@@ -374,7 +381,11 @@
- **测试:+6 条、摘掉 2 条 skip**(此前只有 2 条挂着的 skip)—— 闭式公式逐项对拍(含 `W = Wq + 1/μ`)、
`λ == μ` 与 `λ > μ` 都判失败且带"不稳定"文案、入口(0 / 负数 / `bool` / 非数值)、
`λ == μ` 不能靠 `ZeroDivisionError` 落到失败分支、docstring Example
- ⏳ **留给你的 4 条**见"下一步 → mm1_metrics 待你拍板"(决策 58)
- **第三轮(2026-10-10,用户改定)**:`arrival_rate` 放开到"非负"(`λ = 0` 空系统合法,① 办结)、
两个速率都收 numpy 标量(`np.number`,② 办结)、不稳定分支**保留 `rho`** 并写进 message(④ 办结)、
`data=metrics.copy()` 去掉同一个 dict 对象的别名(③ 部分办结:仍是 dict,只是不再别名)
—— Codex 同步 docstring 与 4 条测试
- ⏳ **只剩 1 条**(`data` 该不该是 dict)见"下一步 → mm1_metrics 待你拍板"(决策 58)
- [ ] 剩余 1 项:`save_figure`(A 档 = 工程,**Codex 直接写**)
@@ -382,7 +393,7 @@
| 检查 | venv | conda `mcm-toolkit` |
|---|---|---|
| `pytest` | 见下(单列) | **202 passed, 28 skipped(0 xfail)** |
| `pytest` | 见下(单列) | **204 passed, 28 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` | 跑通 | — |
@@ -392,8 +403,8 @@
(修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与
`docs/04-environment.md` 坑 7)
- base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致)
- **230 条用例:202 passed / 28 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、
`test_core.py` 23、`test_solve.py` 62(本轮 +4、再摘掉 2 条 skip)、`test_viz.py` 8、`test_decision.py` 7、
- **232 条用例:204 passed / 28 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、
`test_core.py` 23、`test_solve.py` 64(本轮再 +2)、`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)跑完过一个完整闭环
@@ -567,8 +578,11 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向
`np.int64(0)` 掉进数组分支、被 `as_array` 拒(与 `steps`/`n_paths` 收 `np.integer` 不一致,易错点 1 复发);
`True` 因 `isinstance(True, int)` 为真被当状态 1 **静默放行**;越界状态(如 `initial=5`、`s=2`)只在
第一轮索引时才炸、被 `except` 吞成 `success=False`。
**2026-10-09 更新**:② 已办结(用户补了 `np.any(arr < 0)`,含负元素的行随机矩阵现在入口直接报错);
① ③ ④ ⑤ 仍待拍板(A / B / C 三个选项见"下一步")。
**2026-10-09 更新**:② 已办结(用户补了 `np.any(arr < 0)`,含负元素的行随机矩阵现在入口直接报错)。
**2026-10-10 更新**:③ 办结(`paths` 的构造与首列赋值挪进 `try`,长度不匹配/越界不再裸抛);
④ 办结(越界现在被吞成 `success=False`,但仍没在入口拦);⑤ 办结(`std_terminal` → `terminal_distribution`);
② 升级为带容差 `-1e-12`;`params` 的 `n_steps` → `steps`、`initial` 存成可 JSON 化的值。
**只剩 ①**:`bool` 仍被当状态 1(`np.integer` 已收),以及"数组按状态数 vs 按路径数"两条口径的矛盾(A / B / C)。
| 57 | `markov_steady_state` 的口径:**4 条已办、3 条 + 2 个新口子仍待拍板**(Codex 只报选项,未代改,决策 49) |
**2026-10-09 用户第二轮办结**:① 补 `np.any(arr < 0)`(含负元素的行随机矩阵现在入口即报错,原先会溢出成 `nan`
@@ -579,6 +593,8 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向
⑦ **两把尺子宽严不一**——非负校验用精确 `arr < 0`,同一个函数里的行和校验却用宽容的 `np.allclose`,
实测 `[[1.0, -1e-16], [0.5, 0.5]]`(行和都是 1)会被判非法,建议改成 `< -1e-12` 这类容差;
⑧ 未收敛时 `success=False` 但 `data` 仍是未收敛向量(文件内唯一"失败却带 `data`"的分支)—— 保持还是 `data=None`?
**2026-10-10 更新**:③(`tol` 拦 NaN)、⑤(`max_iter` 类型错 → `TypeError`)、⑦(非负校验带容差)办结。
**只剩 2 条**:⑧(上面那条),以及新出现的"非方阵抛 `TypeError`,而 `markov_simulate` 的同一条校验抛 `ValueError`"不一致。
| 58 | `mm1_metrics` 的稳定性分界由 Codex 按**决策 31** 直接修(`<` → `<=`),另有 4 条口径待用户拍板 |
docstring 与既有测试写的都是 `λ >= μ` 不稳定,代码却写 `service_rate < arrival_rate` —— 于是 `λ == μ`
@@ -590,81 +606,74 @@ docstring 与既有测试写的都是 `λ >= μ` 不稳定,代码却写 `servi
整数参数收 `np.integer`,§4.7 有共识);③ `data=metrics` —— **同一个 dict 对象**(`r.data is r.metrics`),
且成功分支给 dict、失败分支给 `None`,与文件内"`data` 是数组"的习惯不同;
④ 失败分支的 `metrics={}` 会丢掉 `rho` 等已算出的量(可选保留)。
**2026-10-10 更新**:① ② ④ 办结(`λ = 0` 放开为合法空系统、两个速率收 `np.number`、失败分支报 `rho`);
③ 只办了一半(`data=metrics.copy()` 去掉了同一个 dict 对象的别名,但 `data` 仍是 dict、失败时仍是 `None`)—— 只剩这一条。
## 下一步
### ⭐ 当前进度与待办(2026-10-09 第七轮)
### ⭐ 当前进度与待办(2026-10-10 第八轮)
**本轮:你改了 `markov_simulate` / `markov_steady_state` 的入口,并写完 `mm1_metrics`** —— 三处算法本体都正确。
Codex 只做工程侧:`mm1_metrics` 修了一处**边界 bug**(`<` → `<=`,见决策 58)、ruff 6 条、4 处文案参数名抄错、
docstring 补 `Raises`、**+6 条测试 + 摘掉 2 条 skip**。
三连:`pytest` **202 passed / 28 skipped**、`ruff` 0、`pyright` 0。`solve` 层 L1 清单**已全部落地**。
**本轮:你按第七轮的清单改了 5 处口径** —— `markov_simulate`(非负校验带容差、收 `np.integer`、赋值挪进 `try`、
`metrics` 换成 `terminal_distribution`)、`markov_steady_state`(容差、`max_iter` 的 `TypeError`、
`tol` 拦 NaN)、`mm1_metrics`(`λ = 0` 放开、收 numpy 标量、失败分支报 `rho`、`data` 去别名)。
**三处算法本体仍然正确**(探针复测:终点分布 `{0: 0.6644, 1: 0.3356}` 对照 `[2/3, 1/3]`;
`λ = 0` 给 `L = Lq = Wq = 0`、`W = 1/μ`;`r.data is r.metrics` 已为 `False`)。
Codex 只同步 docstring + 6 条测试、清 ruff(`B905` 的 `zip` 缺 `strict` + 2 条空白)。
三连:`pytest` **204 passed / 28 skipped**、`ruff` 0、`pyright` 0。
**接下来三件事**
1. **三个函数的口径待你拍板**:`markov_simulate` 4 条(决策 56)、`markov_steady_state` 3+2 条(决策 57)、
`mm1_metrics` 4 条(决策 58)—— 全在下面三节。
1. 下面只剩 **5 条小口径**,基本都是"一个值 / 一个类型"级别的决定。
2. L0 最后一项 `save_figure`(A 档 = 工程,`viz/export.py`)—— **Codex 直接实现**,你只读。
3. 之后就往外层走:L1 只剩 `decision` / `models` / `stats` 的几条。
3. 之后往 `models` / `decision` 走(见下方第八轮备忘)。
### ⏳ markov_simulate 待你拍板(4 条)
### ⏳ 只剩这 5 条口径待你拍板(Codex 只报不改)
**① `initial` 的语义(最大的一条,三个选项)**
现在标量分支按**路径数**铺满、数组分支按**状态数**校验长度,两条口径互相矛盾(详见决策 56)。
- **A**:只收标量状态,另加 `initial_dist` 参数显式传"初始分布"(分布再抽一次,语义最干净,签名变长)
- **B**:只收长度 `n_paths` 的数组(标量走 `np.full` 铺满),`data` 首列就是给定的初始状态序列(与 `paths[:, 0]` 赋值天然一致)
- **C**:沿用现状,但把类型收成 `int | np.integer` + 先判 `bool`,把 `paths[:, 0] = ...` 挪进 `try`,docstring 写清"数组按路径数"
**① `markov_simulate` 的 `bool` 漏网**:`isinstance(True, (int, np.integer))` 为真 → `initial=True` 被当成状态 1。
实测现在**不再静默给错答案**(`np.full(n_paths, True)` 让后面 `arr[current_states]` 变成布尔掩码、崩掉后吞成 `success=False`),
但 message 是个看不懂的广播错误 —— 建议入口先判 `bool` → `TypeError`,把易错点 1 的最后一格补上。
~~**② 转移矩阵没查非负**~~ → **已办(2026-10-09)**:入口加了 `np.any(arr < 0)` → `ValueError`,
测试里钉了"行和是 1 但含负元素"这条。余下的一小问:这条校验是**精确**判负,见决策 57 的 ⑦(容差)。
**② `markov_simulate` 数组分支的长度口径**(决策 56 的 ①,仍是最大的那条):标量按**路径数**铺满、数组按**状态数**校验,
两条对不上(只有 `n_paths == s` 才通)。A / B / C 三个选项见决策 56。
**③ `paths[:, 0]` 赋值在 `try` 之外**:长度不匹配时裸抛 `ValueError`,与"出错统一 `success=False`"(决策 48)不一致。
**③ `markov_steady_state` 的 `data`**:`converged=False` → `success=False`,但 `data` 仍是未收敛向量
(文件内唯一"失败却带 `data`"的分支)。保持(B)还是 `data=None`(C)?
**④ 状态越界没在入口拦**:`initial=5`(`s=2`)第一轮才 `IndexError`,被吞成 `success=False`;
建议入口加 `0 <= initial < s`。
**④ 非方阵的异常类型两处不一致**:`markov_steady_state` 抛 `TypeError`、`markov_simulate` 抛 `ValueError`
(同一条"不是二维方阵"的校验)。numpy 自己对形状问题抛的是 `ValueError`;要统一成哪个?
**⑤ `std_terminal` 对分类状态的意义**:状态编号是标称量,均值和标准差只对"有序状态"(如排队人数)成立 ——
要不要把 `metrics` 换成状态频率(`terminal_freq`)?你定。
**⑤ `mm1_metrics` 的 `data`**:现在是 `metrics.copy()`(不再是同一个对象),但仍是 **dict**,失败分支是 `None`。
选项:**A** 保持;**B** `data=None`;**C** `data` 给一个 5 元素数组。
### ⏳ markov_steady_state 待你拍板(3 条 + 2 个新口子)
### 📦 第八轮备忘:`models` 与 `decision` 是干什么的
- ~~**① 转移矩阵没查非负**~~ → **已办(2026-10-09)**
- ~~**② `tol` 没查 `bool`**~~ → **已办**:`tol` 现在拆成 `TypeError`(类型 / `bool`)与 `ValueError`(取值)
- ~~**④ `converged=False` 仍 `success=True`**~~ → **已办**:选了**口径 B**,`success=converged`
- ~~**⑥ `params` 没记转移矩阵**~~ → **已办**:补了 `"transition": arr.tolist()`
这两个包**目前一行都没实现**(全部 `TODO` + `NotImplementedError`),是 `solve` 之后的两大块。分工完全不同:
**③ `tol` 没查 NaN**:`np.nan <= 0` 为假 → 穿过校验 → 白跑满 10000 次迭代才返回(结果本身对,纯浪费)。
建议与 `max_iter` 一起走"类型 → 有限性 → 取值"三件套(易错点 28)。
| | `models` —— 建模 | `decision` —— 评价决策 |
|---|---|---|
| 回答的问题 | "这条规律是什么?能不能预测?" | "这几个方案哪个好?" |
| 输入 | 特征 `X` + 目标 `y`(有监督)或只有 `X`(聚类/降维) | **决策矩阵**(行 = 方案、列 = 指标),通常无标签 |
| 输出 | 模型对象 + 预测值 + 指标;`compare_models` 出对比表 | 权重向量 + 排序表(`rank`) |
| 主要依赖 | sklearn / statsmodels(**薄封装**) | numpy 手写为主(S 档:熵权、AHP、GM(1,1)、TOPSIS) |
| 美赛场景 | 预测题(人口、销量、气温)、"要不要上复杂模型" | 中文赛 / ICM 的评价题(AHP、TOPSIS、灰色关联、熵权) |
| roadmap 时间盒 | 第 2–3 周 | 第 4 周 |
**⑤ `max_iter` 的类型错仍抛 `ValueError`**(`max_iter=100.0` / `max_iter=True`),
与文件内其它函数(决策 54:类型错 `TypeError`、取值错 `ValueError`)不一致 —— 要不要对齐?
`models`(19 个函数,5 个文件):`base.py` 已写好 `Model` 协议(`fit / predict / score` 三件套,已实现);
`metrics.py` 是 8 个指标 + `compare_models`(返回 DataFrame,可直接 `save_table(fmt="latex")`);
`regression.py` / `timeseries.py` / `ml.py` 全是"一行转发 + 参数校验 + `Result` 包装"。
注意两处:① `rmse` / `mae` / `r2_score` 的注释写的是 `TODO(L0)`,但 roadmap 的 L0 12 项里没有它们,
它们属于 L1 的 `models` 清单 —— 注释待统一;② `tests/test_models.py` 用的是**模块级** `pytestmark`,
违反决策 26(逐条 skip),开写时要拆开。
**⑦【新】非负校验的两把尺子**:`arr < 0` 是**精确**判断,而同一个函数里的行和校验用的是宽容的 `np.allclose`。
实测 `[[1.0, -1e-16], [0.5, 0.5]]`(行和都是 1.0,只是浮点归一化的残渣)会被判"含负元素"而报错。
建模时转移矩阵常常是从数据归一化出来的,这种 `-1e-17` 级残渣很常见 —— 建议改成 `np.any(arr < -1e-12)`
(或 `arr.min() < -1e-12`)这样的容差写法。`markov_simulate` 的同名校验也适用。
`decision`(8 个函数,4 个文件):`weighting.py`(等权 / 熵权 / CRITIC)、`ahp.py`(判断矩阵 + 一致性比率 `CR`)、
`topsis.py`、`grey.py`(灰色关联 + GM(1,1) 预测)。
**使用顺序是硬规矩**(`decision/__init__.py` 里写着):先 `normalize` 无量纲化 → 再声明方向 `benefit_columns`
→ 再 `entropy_weights` / `ahp_weights` → 最后 `topsis`。跳过第一步直接算权重是评价类题最常见的翻车方式。
S 档(要配函数卡片)三个:`entropy_weights` / `ahp_weights` / `gm11_fit_predict`,另有 `topsis` 也标了卡片。
永久不做:`dea_efficiency`(要解一串 LP、低频)、`fuzzy_comprehensive_evaluation`(隶属度函数没有通用形式)。
**⑧【新】未收敛时 `success=False` 但 `data` 仍是未收敛向量**:这是文件里唯一"失败却带 `data`"的分支
(其它失败分支一律 `data=None`)。调用方若只看 `data is not None` 就会拿到没收敛的结果 —— 保持(B)还是改 `data=None`(C)?
### ⏳ mm1_metrics 待你拍板(4 条)
**① `λ = 0` 被拒**:现在 `arrival_rate <= 0` → `ValueError`。数学上 `λ = 0` 是合法退化解
(没有顾客到达,`ρ = L = Lq = W = Wq = 0`)—— 要收还是继续拒?
**② numpy 标量全被拒**:`np.int64(2)` / `np.float32(2.0)` → `TypeError`。
文件内其它函数已按 §4.7 放宽(`random_walk` 的 `start` 收 `np.number`、计数参数收 `np.integer`),
这里还是 `(int, float)` —— 要不要对齐?(`np.float64` 本来就是 `float` 的子类,不受影响)
**③ `data=metrics`**:`data` 和 `metrics` 是**同一个 dict 对象**(实测 `r.data is r.metrics` 为 `True`),
而且成功分支给 dict、失败分支给 `None`,与文件内"`data` 是样本数组"的习惯不同。选项:**A** 保持;
**B** `data=None`(闭式解没有样本数组);**C** 只留 `metrics`、`data` 给一个 5 元素数组。
**④ 失败分支丢掉了已算出的量**:`λ >= μ` 时 `metrics={}`,但 `ρ = λ/μ` 其实算得出来
(`ρ >= 1` 本身就是"不稳定"的量化说明,论文里常要写)—— 要不要在失败分支里保留 `rho`?
**你下一个要写的**:`solve` 这层已经齐了(`mm1_metrics` 落地后 L1 清单走完)。
建议下一步挑 **`decision` / `models` 里你感兴趣的 L1 函数**开刀(E 节有清单),
或者先把上面三节的口径一次性拍完、Codex 批量收口 —— 两条路都行,你定。
**建议的开工顺序**:先 `models/metrics.py`(纯函数、最容易、`compare_models` 是"要不要上复杂模型"的裁判,
也是 roadmap §四里 `models` 的完成标志)→ 再 `fit_linear` / `fit_polynomial`(把 `Model` 协议跑通)
→ 然后 `decision` 的 `entropy_weights`(S 档、20 行、三个坑:方向 / 量纲 / 零值)→ `topsis` → `ahp` → `gm11`。
### ✅ random_walk 的 4 条待办(2026-10-05 已办结)