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

+25
View File
@@ -588,6 +588,31 @@
非负校验的**容差**、未收敛却带 `data`)、`mm1_metrics` 4 条(`λ = 0`、numpy 标量、`data=metrics`、 非负校验的**容差**、未收敛却带 `data`)、`mm1_metrics` 4 条(`λ = 0`、numpy 标量、`data=metrics`、
失败分支丢 `rho`)。详见 `STATUS.md` 决策 56 / 57 / 58。 失败分支丢 `rho`)。详见 `STATUS.md` 决策 56 / 57 / 58。
### Added
- `tests/test_solve.py`:本轮再 +2(`markov_simulate` 的 `terminal_distribution` 口径、
`mm1_metrics` 的 `λ = 0` 空系统),并把受口径变更影响的 5 条测试同步到新行为。
### Changed
- `markov_simulate`:非负校验带容差 `-1e-12`(浮点归一化的 `-1e-16` 残渣不再被误判);
`initial` 收 `np.integer`;`paths` 构造与首列赋值挪进 `try`(长度不匹配 / 状态越界不再裸抛);
`params` 键 `n_steps` → `steps`、`initial` 存成可 JSON 化的标量 / 列表;
`metrics` 的 `std_terminal` → `terminal_distribution`(`{状态: 频率}`,只列出现过的状态)。
- `markov_steady_state`:非负校验带容差;`max_iter` 类型错改抛 `TypeError`;`tol` 增加 `np.isnan` 拦截;
非方阵改抛 `TypeError`。
- `mm1_metrics`:`arrival_rate` 放开为**非负**(`λ = 0` = 空系统,`L = Lq = Wq = 0`、`W = 1/μ` 取极限值);
两个速率收 `np.number`;不稳定分支保留 `rho` 并写进 message;`data=metrics.copy()`(去掉同一个 dict 对象的别名)。
- docstring 同步:`markov_simulate` 的 `Returns` 与"遗留"段、`markov_steady_state` 的 `Raises`(TypeError / ValueError 分工)、
`mm1_metrics` 的 `Parameters` / `Returns` / `Raises`。
- ruff:`B905`(`zip` 缺 `strict`)+ 2 条空白 → 0;`markov_simulate` 里 `rng` 之后的双空行 → 1。
- `STATUS.md`:决策 56 / 57 / 58 更新到"只剩 5 条小口径",「下一步」推进到第八轮,
并写入**第八轮备忘:`models` 与 `decision` 各是干什么的**(含两包的 19 / 8 个函数清单、使用顺序、建议开工顺序)。
- `docs/dev/personal-pitfalls.md`:新增易错点 30(口径一改,docstring / 测试 / STATUS 三处会同时过期)。
### Notes
- **只剩 5 条口径待拍板**(Codex 只报不改):`markov_simulate` 的 `bool` 漏网与 `initial` 的 A/B/C、
`markov_steady_state` 未收敛时是否带 `data`、非方阵的 `TypeError` / `ValueError` 不一致、
`mm1_metrics` 的 `data` 该不该是 dict。
## [0.0.1] - 2026-10-01 ## [0.0.1] - 2026-10-01
### Added ### Added
+73 -64
View File
@@ -26,7 +26,7 @@
剩余 1 项:`save_figure`(Codex 写)。 剩余 1 项:`save_figure`(Codex 写)。
`monte_carlo` 的口径已拍板(决策 48);`monte_carlo_probability` / `random_walk` 的待办**均已办结**; `monte_carlo` 的口径已拍板(决策 48);`monte_carlo_probability` / `random_walk` 的待办**均已办结**;
`markov_simulate`(决策 56)/ `markov_steady_state`(决策 57)/ `mm1_metrics`(决策 58) `markov_simulate`(决策 56)/ `markov_steady_state`(决策 57)/ `mm1_metrics`(决策 58)
**仍有一批入口口径待你拍板**(本轮各办结了一批,详见"下一步")。 的入口口径**已办掉大半**,各剩 1~2 条细枝末节(详见"下一步"第八轮)。
另 3 个小口径仍悬着:`ci_low`/`ci_high` 的语义、`monte_carlo_integral` 的第一道 `isinstance` 分支可塌成两行、 另 3 个小口径仍悬着:`ci_low`/`ci_high` 的语义、`monte_carlo_integral` 的第一道 `isinstance` 分支可塌成两行、
`random_state` 注解与 `std` 的 `ddof` 是否全项目对齐。 `random_state` 注解与 `std` 的 `ddof` 是否全项目对齐。
@@ -337,6 +337,11 @@
`np.int64` 可收、docstring Example 真能跑 `np.int64` 可收、docstring Example 真能跑
- **第二轮(2026-10-09)**:用户补了 `np.any(arr < 0)` 非负校验 → 待办 ② 办结; - **第二轮(2026-10-09)**:用户补了 `np.any(arr < 0)` 非负校验 → 待办 ② 办结;
Codex 补一条"行和是 1 但含负元素"的回归测试 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) - ⏳ **余下 4 条**见"下一步 → markov_simulate 待你拍板"(全是入口口径与少考虑情况,决策 49)
- [x] `solve/simulate.py` 的 `markov_steady_state`(**L1,S 档,算法由用户本人写**,2026-10-09) - [x] `solve/simulate.py` 的 `markov_steady_state`(**L1,S 档,算法由用户本人写**,2026-10-09)
@@ -357,7 +362,9 @@
`success=converged`(选口径 B,④ 办结)、`tol` 拆成 `TypeError` / `ValueError` 且先判 `bool`(② 办结); `success=converged`(选口径 B,④ 办结)、`tol` 拆成 `TypeError` / `ValueError` 且先判 `bool`(② 办结);
Codex 顺手修文案(`max_iter <= 0` 那条写成 "tol 必须是正数")与 `异常: ` → `异常:`, Codex 顺手修文案(`max_iter <= 0` 那条写成 "tol 必须是正数")与 `异常: ` → `异常:`,
并补 2 条测试(负元素矩阵、`max_iter=3` 未收敛走 `success=False`) 并补 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) - [x] `solve/simulate.py` 的 `mm1_metrics`(**L1,S 档,算法由用户本人写**,2026-10-09)
- 核心:闭式解 `ρ = λ/μ`、`L = ρ/(1-ρ)`、`Lq = ρ²/(1-ρ)`、`W = 1/(μ-λ)`、`Wq = W - 1/μ`; - 核心:闭式解 `ρ = λ/μ`、`L = ρ/(1-ρ)`、`Lq = ρ²/(1-ρ)`、`W = 1/(μ-λ)`、`Wq = W - 1/μ`;
@@ -374,7 +381,11 @@
- **测试:+6 条、摘掉 2 条 skip**(此前只有 2 条挂着的 skip)—— 闭式公式逐项对拍(含 `W = Wq + 1/μ`)、 - **测试:+6 条、摘掉 2 条 skip**(此前只有 2 条挂着的 skip)—— 闭式公式逐项对拍(含 `W = Wq + 1/μ`)、
`λ == μ` 与 `λ > μ` 都判失败且带"不稳定"文案、入口(0 / 负数 / `bool` / 非数值)、 `λ == μ` 与 `λ > μ` 都判失败且带"不稳定"文案、入口(0 / 负数 / `bool` / 非数值)、
`λ == μ` 不能靠 `ZeroDivisionError` 落到失败分支、docstring Example `λ == μ` 不能靠 `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 直接写**) - [ ] 剩余 1 项:`save_figure`(A 档 = 工程,**Codex 直接写**)
@@ -382,7 +393,7 @@
| 检查 | venv | conda `mcm-toolkit` | | 检查 | 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 项 | | `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` | 跑通 | — |
@@ -392,8 +403,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(与两套环境一致)
- **230 条用例:202 passed / 28 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、 - **232 条用例:204 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、 `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 `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)跑完过一个完整闭环
@@ -567,8 +578,11 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向
`np.int64(0)` 掉进数组分支、被 `as_array` 拒(与 `steps`/`n_paths` 收 `np.integer` 不一致,易错点 1 复发); `np.int64(0)` 掉进数组分支、被 `as_array` 拒(与 `steps`/`n_paths` 收 `np.integer` 不一致,易错点 1 复发);
`True` 因 `isinstance(True, int)` 为真被当状态 1 **静默放行**;越界状态(如 `initial=5`、`s=2`)只在 `True` 因 `isinstance(True, int)` 为真被当状态 1 **静默放行**;越界状态(如 `initial=5`、`s=2`)只在
第一轮索引时才炸、被 `except` 吞成 `success=False`。 第一轮索引时才炸、被 `except` 吞成 `success=False`。
**2026-10-09 更新**:② 已办结(用户补了 `np.any(arr < 0)`,含负元素的行随机矩阵现在入口直接报错); **2026-10-09 更新**:② 已办结(用户补了 `np.any(arr < 0)`,含负元素的行随机矩阵现在入口直接报错)。
① ③ ④ ⑤ 仍待拍板(A / B / C 三个选项见"下一步")。 **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) | | 57 | `markov_steady_state` 的口径:**4 条已办、3 条 + 2 个新口子仍待拍板**(Codex 只报选项,未代改,决策 49) |
**2026-10-09 用户第二轮办结**:① 补 `np.any(arr < 0)`(含负元素的行随机矩阵现在入口即报错,原先会溢出成 `nan` **2026-10-09 用户第二轮办结**:① 补 `np.any(arr < 0)`(含负元素的行随机矩阵现在入口即报错,原先会溢出成 `nan`
@@ -579,6 +593,8 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向
⑦ **两把尺子宽严不一**——非负校验用精确 `arr < 0`,同一个函数里的行和校验却用宽容的 `np.allclose`, ⑦ **两把尺子宽严不一**——非负校验用精确 `arr < 0`,同一个函数里的行和校验却用宽容的 `np.allclose`,
实测 `[[1.0, -1e-16], [0.5, 0.5]]`(行和都是 1)会被判非法,建议改成 `< -1e-12` 这类容差; 实测 `[[1.0, -1e-16], [0.5, 0.5]]`(行和都是 1)会被判非法,建议改成 `< -1e-12` 这类容差;
⑧ 未收敛时 `success=False` 但 `data` 仍是未收敛向量(文件内唯一"失败却带 `data`"的分支)—— 保持还是 `data=None`? ⑧ 未收敛时 `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 条口径待用户拍板 | | 58 | `mm1_metrics` 的稳定性分界由 Codex 按**决策 31** 直接修(`<` → `<=`),另有 4 条口径待用户拍板 |
docstring 与既有测试写的都是 `λ >= μ` 不稳定,代码却写 `service_rate < arrival_rate` —— 于是 `λ == μ` docstring 与既有测试写的都是 `λ >= μ` 不稳定,代码却写 `service_rate < arrival_rate` —— 于是 `λ == μ`
@@ -590,81 +606,74 @@ docstring 与既有测试写的都是 `λ >= μ` 不稳定,代码却写 `servi
整数参数收 `np.integer`,§4.7 有共识);③ `data=metrics` —— **同一个 dict 对象**(`r.data is r.metrics`), 整数参数收 `np.integer`,§4.7 有共识);③ `data=metrics` —— **同一个 dict 对象**(`r.data is r.metrics`),
且成功分支给 dict、失败分支给 `None`,与文件内"`data` 是数组"的习惯不同; 且成功分支给 dict、失败分支给 `None`,与文件内"`data` 是数组"的习惯不同;
④ 失败分支的 `metrics={}` 会丢掉 `rho` 等已算出的量(可选保留)。 ④ 失败分支的 `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`** —— 三处算法本体都正确。 **本轮:你按第七轮的清单改了 5 处口径** —— `markov_simulate`(非负校验带容差、收 `np.integer`、赋值挪进 `try`、
Codex 只做工程侧:`mm1_metrics` 修了一处**边界 bug**(`<` → `<=`,见决策 58)、ruff 6 条、4 处文案参数名抄错、 `metrics` 换成 `terminal_distribution`)、`markov_steady_state`(容差、`max_iter` 的 `TypeError`、
docstring 补 `Raises`、**+6 条测试 + 摘掉 2 条 skip**。 `tol` 拦 NaN)、`mm1_metrics`(`λ = 0` 放开、收 numpy 标量、失败分支报 `rho`、`data` 去别名)。
三连:`pytest` **202 passed / 28 skipped**、`ruff` 0、`pyright` 0。`solve` 层 L1 清单**已全部落地**。 **三处算法本体仍然正确**(探针复测:终点分布 `{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)、 1. 下面只剩 **5 条小口径**,基本都是"一个值 / 一个类型"级别的决定。
`mm1_metrics` 4 条(决策 58)—— 全在下面三节。
2. L0 最后一项 `save_figure`(A 档 = 工程,`viz/export.py`)—— **Codex 直接实现**,你只读。 2. L0 最后一项 `save_figure`(A 档 = 工程,`viz/export.py`)—— **Codex 直接实现**,你只读。
3. 之后就往外层走:L1 只剩 `decision` / `models` / `stats` 的几条。 3. 之后往 `models` / `decision` 走(见下方第八轮备忘)。
### ⏳ markov_simulate 待你拍板(4 条) ### ⏳ 只剩这 5 条口径待你拍板(Codex 只报不改)
**① `initial` 的语义(最大的一条,三个选项)** **① `markov_simulate` 的 `bool` 漏网**:`isinstance(True, (int, np.integer))` 为真 → `initial=True` 被当成状态 1。
现在标量分支按**路径数**铺满、数组分支按**状态数**校验长度,两条口径互相矛盾(详见决策 56)。 实测现在**不再静默给错答案**(`np.full(n_paths, True)` 让后面 `arr[current_states]` 变成布尔掩码、崩掉后吞成 `success=False`),
- **A**:只收标量状态,另加 `initial_dist` 参数显式传"初始分布"(分布再抽一次,语义最干净,签名变长) 但 message 是个看不懂的广播错误 —— 建议入口先判 `bool` → `TypeError`,把易错点 1 的最后一格补上。
- **B**:只收长度 `n_paths` 的数组(标量走 `np.full` 铺满),`data` 首列就是给定的初始状态序列(与 `paths[:, 0]` 赋值天然一致)
- **C**:沿用现状,但把类型收成 `int | np.integer` + 先判 `bool`,把 `paths[:, 0] = ...` 挪进 `try`,docstring 写清"数组按路径数"
~~**② 转移矩阵没查非负**~~ → **已办(2026-10-09)**:入口加了 `np.any(arr < 0)` → `ValueError`, **② `markov_simulate` 数组分支的长度口径**(决策 56 的 ①,仍是最大的那条):标量按**路径数**铺满、数组按**状态数**校验,
测试里钉了"行和是 1 但含负元素"这条。余下的一小问:这条校验是**精确**判负,见决策 57 的 ⑦(容差)。 两条对不上(只有 `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`; **④ 非方阵的异常类型两处不一致**:`markov_steady_state` 抛 `TypeError`、`markov_simulate` 抛 `ValueError`
建议入口加 `0 <= initial < s`。 (同一条"不是二维方阵"的校验)。numpy 自己对形状问题抛的是 `ValueError`;要统一成哪个?
**⑤ `std_terminal` 对分类状态的意义**:状态编号是标称量,均值和标准差只对"有序状态"(如排队人数)成立 —— **⑤ `mm1_metrics` 的 `data`**:现在是 `metrics.copy()`(不再是同一个对象),但仍是 **dict**,失败分支是 `None`。
要不要把 `metrics` 换成状态频率(`terminal_freq`)?你定。 选项:**A** 保持;**B** `data=None`;**C** `data` 给一个 5 元素数组。
### ⏳ markov_steady_state 待你拍板(3 条 + 2 个新口子) ### 📦 第八轮备忘:`models` 与 `decision` 是干什么的
- ~~**① 转移矩阵没查非负**~~ → **已办(2026-10-09)** 这两个包**目前一行都没实现**(全部 `TODO` + `NotImplementedError`),是 `solve` 之后的两大块。分工完全不同:
- ~~**② `tol` 没查 `bool`**~~ → **已办**:`tol` 现在拆成 `TypeError`(类型 / `bool`)与 `ValueError`(取值)
- ~~**④ `converged=False` 仍 `success=True`**~~ → **已办**:选了**口径 B**,`success=converged`
- ~~**⑥ `params` 没记转移矩阵**~~ → **已办**:补了 `"transition": arr.tolist()`
**③ `tol` 没查 NaN**:`np.nan <= 0` 为假 → 穿过校验 → 白跑满 10000 次迭代才返回(结果本身对,纯浪费)。 | | `models` —— 建模 | `decision` —— 评价决策 |
建议与 `max_iter` 一起走"类型 → 有限性 → 取值"三件套(易错点 28)。 |---|---|---|
| 回答的问题 | "这条规律是什么?能不能预测?" | "这几个方案哪个好?" |
| 输入 | 特征 `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`), `models`(19 个函数,5 个文件):`base.py` 已写好 `Model` 协议(`fit / predict / score` 三件套,已实现);
与文件内其它函数(决策 54:类型错 `TypeError`、取值错 `ValueError`)不一致 —— 要不要对齐? `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`。 `decision`(8 个函数,4 个文件):`weighting.py`(等权 / 熵权 / CRITIC)、`ahp.py`(判断矩阵 + 一致性比率 `CR`)、
实测 `[[1.0, -1e-16], [0.5, 0.5]]`(行和都是 1.0,只是浮点归一化的残渣)会被判"含负元素"而报错。 `topsis.py`、`grey.py`(灰色关联 + GM(1,1) 预测)。
建模时转移矩阵常常是从数据归一化出来的,这种 `-1e-17` 级残渣很常见 —— 建议改成 `np.any(arr < -1e-12)` **使用顺序是硬规矩**(`decision/__init__.py` 里写着):先 `normalize` 无量纲化 → 再声明方向 `benefit_columns`
(或 `arr.min() < -1e-12`)这样的容差写法。`markov_simulate` 的同名校验也适用。 → 再 `entropy_weights` / `ahp_weights` → 最后 `topsis`。跳过第一步直接算权重是评价类题最常见的翻车方式。
S 档(要配函数卡片)三个:`entropy_weights` / `ahp_weights` / `gm11_fit_predict`,另有 `topsis` 也标了卡片。
永久不做:`dea_efficiency`(要解一串 LP、低频)、`fuzzy_comprehensive_evaluation`(隶属度函数没有通用形式)。
**⑧【新】未收敛时 `success=False` 但 `data` 仍是未收敛向量**:这是文件里唯一"失败却带 `data`"的分支 **建议的开工顺序**:先 `models/metrics.py`(纯函数、最容易、`compare_models` 是"要不要上复杂模型"的裁判,
(其它失败分支一律 `data=None`)。调用方若只看 `data is not None` 就会拿到没收敛的结果 —— 保持(B)还是改 `data=None`(C)? 也是 roadmap §四里 `models` 的完成标志)→ 再 `fit_linear` / `fit_polynomial`(把 `Model` 协议跑通)
→ 然后 `decision` 的 `entropy_weights`(S 档、20 行、三个坑:方向 / 量纲 / 零值)→ `topsis` → `ahp` → `gm11`。
### ⏳ 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 批量收口 —— 两条路都行,你定。
### ✅ random_walk 的 4 条待办(2026-10-05 已办结) ### ✅ random_walk 的 4 条待办(2026-10-05 已办结)
+12
View File
@@ -506,3 +506,15 @@
写完回头对一眼"这几条的严格程度一致吗";② 阈值类比较(`<` / `<=`)**对着 docstring 读一遍**, 写完回头对一眼"这几条的严格程度一致吗";② 阈值类比较(`<` / `<=`)**对着 docstring 读一遍**,
特别是 == 落在哪边;③ 复制粘贴报错文案时,逐个把参数名念出来。 特别是 == 落在哪边;③ 复制粘贴报错文案时,逐个把参数名念出来。
- 首次:2026-10-09|复发:0 - 首次:2026-10-09|复发:0
### 30. 改了口径(返回值 / 键名 / 分支),下游三处会同时过期
- 现象:`markov_simulate` 这一轮把 `metrics` 从 `std_terminal` 换成 `terminal_distribution`、
`mm1_metrics` 的不稳定分支从 `metrics={}` 变成 `metrics={"rho"}`、`arrival_rate=0` 从"报错"变成"合法"。
代码本身没问题,但**同一轮的测试 5 条全红**(都在断言旧键名 / 旧分支),docstring 的 `Returns` 也还在写 `std_terminal`。
- 为什么会错:改的时候注意力全在"新行为对不对",**看不见的契约**(测试断言、docstring、STATUS 的口径表)没人提醒你。
这三处过期不会报错,只会"文档说 A、代码做 B",过几天自己都分不清哪个是准的。
- 以后怎么防:① 动返回值 / 键名 / 分支前,先 `rg` 一下这个名字在全仓的出现位置(`rg "std_terminal" `),
把"要一起改的地方"列出来再动手;② 改完立刻跑 `pytest` —— 红的测试就是"过期契约"的清单;
③ docstring 的 `Returns` / `Raises` 属于**契约的一部分**,和代码同一次改完,别留到"下次顺手"。
- 首次:2026-10-10|复发:0
+44 -34
View File
@@ -487,7 +487,8 @@ def markov_simulate(
Returns Returns
------- -------
`Result`:`data` 是形状 `(n_paths, steps + 1)` 的整数状态序列,第 0 列是初始状态; `Result`:`data` 是形状 `(n_paths, steps + 1)` 的整数状态序列,第 0 列是初始状态;
`metrics={"mean_terminal", "std_terminal"}`(终点状态的均值与标准差,`std` 用 `ddof=0`)。 `metrics={"mean_terminal", "terminal_distribution"}` —— 前者是终点状态的均值,
后者是 `{状态: 出现频率}` 的 dict(按终点统计,只含**实际出现过**的状态,没出现的状态没有 key)。
Raises Raises
------ ------
@@ -504,9 +505,10 @@ def markov_simulate(
`cum_probs[:, -1] = 1.0` 是为了压掉浮点累加误差,避免随机数落在 `[1 - 1e-16, 1)` 时索引越界。 `cum_probs[:, -1] = 1.0` 是为了压掉浮点累加误差,避免随机数落在 `[1 - 1e-16, 1)` 时索引越界。
终点分布随步数收敛到平稳分布:这既是本函数的自检手段,也是论文里值得写一句的性质。 终点分布随步数收敛到平稳分布:这既是本函数的自检手段,也是论文里值得写一句的性质。
遗留(决策 56 待定):`initial` 现在靠 `isinstance(initial, int)` 区分"标量状态 / 状态数组", 遗留(决策 56):`initial` 靠 `isinstance(initial, (int, np.integer))` 区分"标量状态 / 状态数组",
`np.int64`、`bool` 会掉进数组分支,"按状态数校验长度"与"按路径数写入"也互相矛盾。 `np.int64` 已收,但 `bool` 仍会被当成状态 1(`isinstance(True, int)` 为真);
口径定下来后,上面 `initial` 这一节与本段随之改写。 且数组分支"按**状态数**校验长度"与真正写进去时"要按**路径数**"仍互相矛盾 —— 只有 `n_paths == s` 才跑得通。
状态越界(如 `initial=5`、`s=2`)也要等第一轮索引才炸。口径定了这段随之改写。
Examples Examples
-------- --------
@@ -521,8 +523,8 @@ def markov_simulate(
arr, _ = as_array(transition) arr, _ = as_array(transition)
if arr.ndim != 2 or arr.shape[0] != arr.shape[1]: if arr.ndim != 2 or arr.shape[0] != arr.shape[1]:
raise ValueError(f"transition 必须是二维方阵,你传入的形状是 {arr.shape}") raise ValueError(f"transition 必须是二维方阵,你传入的形状是 {arr.shape}")
if np.any(arr < 0): if np.any(arr < -1e-12):
raise ValueError("transition 矩阵的元素必须是非负的(概率不能为负)") raise ValueError("transition 矩阵的元素必须是非负的")
if not np.allclose(arr.sum(axis=1), 1.0): if not np.allclose(arr.sum(axis=1), 1.0):
raise ValueError("transition 每一行的和必须是 1.0") raise ValueError("transition 每一行的和必须是 1.0")
s = arr.shape[0] s = arr.shape[0]
@@ -538,24 +540,25 @@ def markov_simulate(
rng = get_rng(random_state) rng = get_rng(random_state)
if isinstance(initial, int): if isinstance(initial, (int,np.integer)):
current_states = np.full(n_paths, initial) current_states = np.full(n_paths, initial)
initial_param = int(initial)
else: else:
current_states, _ = as_array(initial, ndim=1) current_states, _ = as_array(initial, ndim=1)
if len(current_states) != s: if len(current_states) != s:
raise TypeError(f"initial 的长度必须是 {s},而你传入的是 {len(current_states)}") raise TypeError(f"initial 的长度必须是 {s},而你传入的是 {len(current_states)}")
initial_param = current_states.tolist()
paths = np.zeros((n_paths, steps + 1), dtype=int)
paths[:, 0] = current_states
call_params = { call_params = {
"n_steps": steps, "steps": steps,
"n_paths": n_paths, "n_paths": n_paths,
"initial": initial, "initial": initial_param,
"random_state": random_state, "random_state": random_state
} }
try: try:
paths = np.zeros((n_paths, steps + 1), dtype=int)
paths[:, 0] = current_states
for t in range(steps): for t in range(steps):
probs = arr[current_states] probs = arr[current_states]
@@ -569,9 +572,12 @@ def markov_simulate(
current_states = next_states current_states = next_states
terminal_positions = paths[:, -1] terminal_positions = paths[:, -1]
unique, counts = np.unique(terminal_positions, return_counts=True)
terminal_dist = {int(state): float(count) / n_paths for state, count in zip(unique, counts, strict=True)}
metrics = { metrics = {
"mean_terminal": float(np.mean(terminal_positions)), "mean_terminal": float(np.mean(terminal_positions)),
"std_terminal": float(np.std(terminal_positions)), "terminal_distribution": terminal_dist
} }
return Result( return Result(
name="markov_simulate", name="markov_simulate",
@@ -612,9 +618,11 @@ def markov_steady_state(transition: Any, max_iter: int | np.integer = 10000, tol
Raises Raises
------ ------
TypeError:
`transition` 不是二维方阵;`max_iter` 不是整数;`tol` 不是数值。
ValueError: ValueError:
`transition` 不是二维方阵、或某一行和不等于 1.0; `transition` 含负元素、或某一行和不等于 1.0;
`max_iter` 不是正整数;`tol` 不是正数。 `max_iter` 不是正整数;`tol` 不是正数(NaN 也算非法)。
Notes Notes
----- -----
@@ -639,19 +647,19 @@ def markov_steady_state(transition: Any, max_iter: int | np.integer = 10000, tol
""" """
arr, _ = as_array(transition) arr, _ = as_array(transition)
if arr.ndim != 2 or arr.shape[0] != arr.shape[1]: if arr.ndim != 2 or arr.shape[0] != arr.shape[1]:
raise ValueError(f"transition 必须是二维方阵,你传入的形状是 {arr.shape}") raise TypeError(f"transition 必须是二维方阵,你传入的形状是 {arr.shape}")
if np.any(arr < 0): if np.any(arr < -1e-12):
raise ValueError("transition 矩阵的元素必须是非负的(概率不能为负)") raise ValueError("transition 矩阵的元素必须是非负的(概率不能为负)")
if not np.allclose(arr.sum(axis=1), 1.0): if not np.allclose(arr.sum(axis=1), 1.0):
raise ValueError("transition 每一行的和必须是 1.0") raise ValueError("transition 每一行的和必须是 1.0")
if isinstance(max_iter, bool) or not isinstance(max_iter, (int, np.integer)): if isinstance(max_iter, bool) or not isinstance(max_iter, (int, np.integer)):
raise ValueError(f"max_iter 必须是正整数,你传入了 {type(max_iter).__name__}") raise TypeError(f"max_iter 必须是正整数,你传入了 {type(max_iter).__name__}")
if max_iter <= 0: if max_iter <= 0:
raise ValueError(f"max_iter 必须是正整数,你传入了 {max_iter}") raise ValueError(f"max_iter 必须是正整数,你传入了 {max_iter}")
if not isinstance(tol, (int, float)) or isinstance(tol, bool): if not isinstance(tol, (int, float)) or isinstance(tol, bool):
raise TypeError(f"tol 必须是浮点数,你传入了 {type(tol).__name__}") raise TypeError(f"tol 必须是浮点数,你传入了 {type(tol).__name__}")
if tol <= 0: if tol <= 0 or np.isnan(tol):
raise ValueError(f"tol 必须是正数,你传入了 {tol}") raise ValueError(f"tol 必须是正数,你传入了 {tol}")
s = arr.shape[0] s = arr.shape[0]
@@ -706,21 +714,23 @@ def mm1_metrics(arrival_rate: float, service_rate: float) -> Any:
Parameters Parameters
---------- ----------
arrival_rate, service_rate: arrival_rate, service_rate:
到达率 λ 与服务率 μ。`λ >= μ` 时系统不稳定,队列长度无界—— 到达率 λ 与服务率 μ,都收 `int` / `float` / numpy 标量。
这种情况下函数返回 `success=False` 并说明原因,而不是返回 `inf`。 `λ` 允许为 0(空系统:`L = Lq = Wq = 0`,`W` 取极限值 `1/μ`);
`λ >= μ` 时系统不稳定、队列长度无界 —— 这种情况返回 `success=False` 并说明原因,而不是返回 `inf`。
Returns Returns
------- -------
`Result`:`metrics={"rho", "L", "Lq", "W", "Wq"}` `Result`:`metrics={"rho", "L", "Lq", "W", "Wq"}`
(系统利用率、系统内平均顾客数、队列长度、平均逗留时间、平均等待时间); (系统利用率、系统内平均顾客数、队列长度、平均逗留时间、平均等待时间);
`data` 与 `metrics` 同值(闭式解没有样本数组)。 `data` 与 `metrics` 同值(闭式解没有样本数组),但**不是同一个对象**。
不稳定时 `success=False`、`metrics={{"rho"}}`(只报利用率)、`data=None`。
Raises Raises
------ ------
TypeError: TypeError:
`arrival_rate` / `service_rate` 不是数值(`bool` 也判非法)。 `arrival_rate` / `service_rate` 不是数值(`bool` 也判非法)。
ValueError: ValueError:
`arrival_rate` / `service_rate` 不是正数。 `arrival_rate` 是负数;`service_rate` 不是正数。
Notes Notes
----- -----
@@ -734,27 +744,27 @@ def mm1_metrics(arrival_rate: float, service_rate: float) -> Any:
>>> round(r.metrics["rho"], 4) >>> round(r.metrics["rho"], 4)
0.6667 0.6667
""" """
if not isinstance(arrival_rate, (int, float)) or isinstance(arrival_rate, bool): if not isinstance(arrival_rate, (int, float, np.number)) or isinstance(arrival_rate, bool):
raise TypeError(f"arrival_rate 必须是浮点数,你传入了 {type(arrival_rate).__name__}") raise TypeError(f"arrival_rate 必须是浮点数,你传入了 {type(arrival_rate).__name__}")
if arrival_rate <= 0: if arrival_rate < 0:
raise ValueError(f"arrival_rate 必须是正数,你传入了 {arrival_rate}") raise ValueError(f"arrival_rate 必须是非负数,你传入了 {arrival_rate}")
if not isinstance(service_rate, (int, float)) or isinstance(service_rate, bool): if not isinstance(service_rate, (int, float, np.number)) or isinstance(service_rate, bool):
raise TypeError(f"service_rate 必须是浮点数,你传入了 {type(service_rate).__name__}") raise TypeError(f"service_rate 必须是浮点数,你传入了 {type(service_rate).__name__}")
if service_rate <= 0: if service_rate <= 0:
raise ValueError(f"service_rate 必须是正数,你传入了 {service_rate}") raise ValueError(f"service_rate 必须是正数,你传入了 {service_rate}")
call_params = {"arrival_rate": arrival_rate, "service_rate": service_rate} call_params = {"arrival_rate": float(arrival_rate), "service_rate": float(service_rate)}
if service_rate <= arrival_rate: if arrival_rate >= service_rate:
rho = arrival_rate / service_rate
return Result( return Result(
name="mm1_metrics", name="mm1_metrics",
params=call_params, params=call_params,
metrics={}, metrics={"rho": float(rho)},
data=None, data=None,
success=False, success=False,
message="系统不稳定:到达率 λ 大于等于服务率 μ,队列长度无界,无法计算闭式解。" message=f"系统不稳定:λ >= μ,队列无界。当前利用率 rho={rho:.4f}"
) )
try: try:
rho = arrival_rate / service_rate rho = arrival_rate / service_rate
L = rho / (1.0 - rho) # 系统内平均顾客数 L = rho / (1.0 - rho) # 系统内平均顾客数
@@ -775,7 +785,7 @@ def mm1_metrics(arrival_rate: float, service_rate: float) -> Any:
name="mm1_metrics", name="mm1_metrics",
params=call_params, params=call_params,
metrics=metrics, metrics=metrics,
data=metrics, data=metrics.copy(),
success=True, success=True,
message="M/M/1 排队系统指标计算成功。" message="M/M/1 排队系统指标计算成功。"
) )
+36 -9
View File
@@ -428,7 +428,7 @@ def test_markov_simulate_absorbing_states_never_move():
assert r.success assert r.success
assert np.all(r.data == 1) assert np.all(r.data == 1)
assert r.metrics["mean_terminal"] == 1.0 assert r.metrics["mean_terminal"] == 1.0
assert r.metrics["std_terminal"] == 0.0 assert r.metrics["terminal_distribution"] == {1: 1.0}
def test_markov_simulate_is_reproducible(): def test_markov_simulate_is_reproducible():
@@ -453,12 +453,25 @@ def test_markov_simulate_terminal_frequency_matches_stationary():
def test_markov_simulate_metrics_are_terminal_states(): def test_markov_simulate_metrics_are_terminal_states():
"""`metrics` 只看终点:均值 + `{状态: 频率}`(频率之和为 1、与逐条统计一致)。"""
from mcm_toolkit.solve import markov_simulate from mcm_toolkit.solve import markov_simulate
P = np.array([[0.9, 0.1], [0.2, 0.8]]) P = np.array([[0.9, 0.1], [0.2, 0.8]])
r = markov_simulate(P, initial=0, steps=50, n_paths=500, random_state=3) r = markov_simulate(P, initial=0, steps=50, n_paths=500, random_state=3)
assert r.metrics["mean_terminal"] == float(np.mean(r.data[:, -1])) terminal = r.data[:, -1]
assert r.metrics["std_terminal"] == float(np.std(r.data[:, -1])) dist = r.metrics["terminal_distribution"]
assert r.metrics["mean_terminal"] == float(np.mean(terminal))
assert np.isclose(sum(dist.values()), 1.0)
for state, freq in dist.items():
assert np.isclose(freq, float(np.mean(terminal == state)))
def test_markov_simulate_terminal_distribution_only_lists_seen_states():
"""只含实际出现过的状态(没出现的状态没有 key)—— docstring 已写明这条口径。"""
from mcm_toolkit.solve import markov_simulate
r = markov_simulate(np.eye(3), initial=2, steps=5, n_paths=4, random_state=0)
assert r.metrics["terminal_distribution"] == {2: 1.0}
def test_markov_simulate_rejects_bad_transition(): def test_markov_simulate_rejects_bad_transition():
@@ -568,8 +581,8 @@ def test_markov_steady_state_accepts_numpy_integer():
def test_markov_steady_state_rejects_bad_transition(): def test_markov_steady_state_rejects_bad_transition():
from mcm_toolkit.solve import markov_steady_state from mcm_toolkit.solve import markov_steady_state
with pytest.raises(ValueError): with pytest.raises(TypeError):
markov_steady_state(np.ones((2, 3)) / 3) markov_steady_state(np.ones((2, 3)) / 3) # 形状不对 → TypeError(口径见决策 57)
with pytest.raises(ValueError): with pytest.raises(ValueError):
markov_steady_state(np.array([[0.5, 0.4], [0.2, 0.8]])) markov_steady_state(np.array([[0.5, 0.4], [0.2, 0.8]]))
with pytest.raises(ValueError): with pytest.raises(ValueError):
@@ -650,19 +663,33 @@ def test_mm1_rejects_unstable_system():
equal = mm1_metrics(arrival_rate=3, service_rate=3) equal = mm1_metrics(arrival_rate=3, service_rate=3)
assert equal.success is False assert equal.success is False
assert "不稳定" in equal.message assert "不稳定" in equal.message
assert equal.metrics == {} assert equal.metrics == {"rho": 1.0} # 失败时也把 rho 报出来(决策 58 的 ④)
assert equal.data is None
assert mm1_metrics(arrival_rate=3, service_rate=2).success is False assert mm1_metrics(arrival_rate=3, service_rate=2).success is False
def test_mm1_allows_zero_arrival_rate_as_empty_system():
"""λ = 0 是合法退化解:空系统(L = Lq = Wq = 0,W 取极限值 1/μ)。"""
from mcm_toolkit.solve import mm1_metrics
r = mm1_metrics(arrival_rate=0, service_rate=3)
assert r.success
assert r.metrics["rho"] == 0.0
assert r.metrics["L"] == 0.0
assert r.metrics["Lq"] == 0.0
assert r.metrics["Wq"] == 0.0
assert np.isclose(r.metrics["W"], 1 / 3)
def test_mm1_rejects_bad_arguments(): def test_mm1_rejects_bad_arguments():
"""λ / μ 必须是正数:0、负数、bool、非数值都要在入口拦下。""" """λ 非负、μ 必须为正;bool 与非数值都要在入口拦下。"""
from mcm_toolkit.solve import mm1_metrics from mcm_toolkit.solve import mm1_metrics
with pytest.raises(ValueError):
mm1_metrics(arrival_rate=0, service_rate=3)
with pytest.raises(ValueError): with pytest.raises(ValueError):
mm1_metrics(arrival_rate=-1, service_rate=3) mm1_metrics(arrival_rate=-1, service_rate=3)
with pytest.raises(ValueError):
mm1_metrics(arrival_rate=2, service_rate=0)
with pytest.raises(TypeError): with pytest.raises(TypeError):
mm1_metrics(arrival_rate=True, service_rate=3) mm1_metrics(arrival_rate=True, service_rate=3)
with pytest.raises(TypeError): with pytest.raises(TypeError):