fix(solve): monte_carlo_integral 入口收口 + 失败 message 不再被覆盖

三项待办由用户本人改定;Codex 只补 ruff / docstring / tests / 文档。

- bounds 形状校验:`[0, 1]` 从裸 IndexError 变成可读 ValueError(带形状提示);
  空 / 非 list-tuple / 参差不齐一并收口
- 失败 message:改成 if result.success / else 两支,success=False 时不再出现"成功"文案
- n 注解放宽为 int | np.integer;event 注解 Callable[..., bool] -> Callable[..., Any]
  (决策 50 的遗留清零;pyright 复测不再拒绝向量化事件函数)
- 工程侧:清掉 2 行 ruff W293;docstring 的 bounds / Raises 同步;
  非法 bounds 测试 1 类扩到 4 类,新增失败 message 语义与 np.int64 两条
- 验证:199 收集 / 168 passed / 31 skipped / 0 xfail,ruff 0,pyright 0
- docs:决策 52 + 2 条新口径(bounds 收不收 ndarray、if/else 三行重复);CHANGELOG
This commit is contained in:
guzhujushi committed 2026-10-05 16:04:39 +08:00
1 parent ed1f9f0c53
commit dcda080be4
4 files changed
+94 -30

No files matched your search

+18
View File
@@ -425,6 +425,24 @@
- `tmp/type_demo.py`(gitignored):用 pyright 证明 `Callable[..., bool]` 会**拒绝**一个注解良好的向量化 `event`
(`ndarray` 与 `bool` 不兼容),`Callable[..., Any]` 才收 —— 这是决策 50 那条遗留的实际代价。
### Fixed
- `monte_carlo_integral` 的 `bounds` 形状校验缺失(用户第二轮补齐):`bounds=[0, 1]` 从裸 `IndexError: too many indices`
变成可读 `ValueError`(带当前形状与"请检查是否漏了括号");`[]` / 非 `list`-`tuple` / 参差不齐的输入也一并收口。
- `monte_carlo_integral` 的失败 `message` 被包装层覆盖(用户第二轮改成 `if result.success:` / `else:` 两支):
`func` 返回标量时不再是 `success=False` + "成功"文案,而是"蒙特卡洛多重积分失败:维度 1,体积 1.0000,模拟 100 次。
蒙特卡洛模拟失败:TypeError - object of type 'float' has no len()"。
- 格式:`simulate.py` 两行"只有空格的行"(ruff W293 ×2,Codex 顺手补)。
### Changed
- `monte_carlo_probability` 的 `event` 注解 `Callable[..., bool]` → `Callable[..., Any]`(用户改,**决策 50 的遗留清零**):
pyright 实测原注解会**拒绝**一个注解良好的向量化事件函数(`ndarray` 与 `bool` 不兼容),运行期却一直收得下。
- `monte_carlo_integral` 的 `n` 注解放宽为 `int | np.integer`(与全模块对齐,易错点 1 的老口径)。
- `monte_carlo_integral` 的 docstring:`bounds` 入参说明与 `Raises` 同步到新入口校验(补"非空 `list`/`tuple`、形状 `(d, 2)`")。
- `tests/test_solve.py`:非法 `bounds` 从 1 类扩到 4 类(`[(1, 0)]` / `[0, 1]` / `[]` / 参差不齐),新增"失败 message 不许出现
'成功'"与 `np.int64` 两条;`solve` 段 30 → **32** 条,全量 **199 收集 / 168 passed / 31 skipped / 0 xfail**(ruff 0 / pyright 0)。
- `STATUS.md`:integral 的 3 条待办标记办结、概率函数的 6 条待办全部办结;新增**决策 52** 与 2 条新口径
(`bounds` 收不收 ndarray、`if`/`else` 里那三行重复);验证计数更新为 168 / 31。
## [0.0.1] - 2026-10-01
### Added
+36 -25
View File
@@ -22,9 +22,8 @@
`detect_outliers`、`normalize`/`standardize`、`describe`、`correlation_matrix`、`bootstrap_ci`、
`monte_carlo`(**用户写**,2026-10-04);另**提前落地**了 L1 的 `confidence_interval` / `t_test` /
`monte_carlo_probability`、`monte_carlo_integral`(**用户写**,2026-10-05)。剩余 1 项:`save_figure`(Codex 写)。
`monte_carlo` 的口径已拍板(决策 48);`ci_low`/`ci_high` 的语义、`monte_carlo_probability` 剩下的
1 条待办、`monte_carlo_integral` 的 3 条待办,都见"下一步"。
`monte_carlo` 的口径已拍板(决策 48);概率 / 积分两个函数的待办**均已办结**,
剩下 `ci_low`/`ci_high` 的语义与 `monte_carlo_integral` 的 2 条新口径,见"下一步"。
## 任务清单
### ✅ 已完成
@@ -290,15 +289,16 @@
- **测试:新增 4 条 + 摘掉 1 条 skip**(`solve` 段共 30 条)—— 1D 收敛到 1/3(n=2e5)、
**3D 抓体积符号**(奇数维)、**`[0,2]` 抓宽度**(偶数维藏不住)、2D + `name`/`dimension`/`bounds` 三个 params、
`(1, 0)` 抛 `ValueError`。三条"抓符号"用例就是照着上面那个"2D 掩盖效应"设计的
- ⏳ **留给用户 3 条**(见"下一步"):`result.message` 无条件覆盖(失败被写成"成功")、
`bounds` 形状没校验(`[0, 1]` 抛裸 `IndexError`)、`n: int` 与全模块 `int | np.integer` 不一致
- ✅ **3 条待办已由用户第二轮办结**(同日):`message` 改成 `if result.success:` / `else:` 两支(失败语义不再被覆盖);
入口补 `isinstance(bounds, (list, tuple))` + `ndim == 2 and shape[1] == 2` 两道(`[0, 1]` 从裸 `IndexError` 变成可读 `ValueError`);
`n` 注解放宽为 `int | np.integer`。⏳ 尚有 2 条新口径(`bounds` 收不收 ndarray、`if`/`else` 三行重复)见"下一步"
- [ ] 剩余 1 项:`save_figure`(A 档 = 工程,**Codex 直接写**)
### 验证结果
| 检查 | venv | conda `mcm-toolkit` |
|---|---|---|
| `pytest` | 见下(单列) | **166 passed, 31 skipped(0 xfail)** |
| `pytest` | 见下(单列) | **168 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` | 跑通 | — |
@@ -308,8 +308,8 @@
(修配置之前是 17 errors,全是"无法解析导入 numpy / pandas",见决策 29 与
`docs/04-environment.md` 坑 7)
- base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致)
- **197 条用例:166 passed / 31 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、
`test_core.py` 23、`test_solve.py` 30(上一轮 25,本轮摘 1 条 skip + 新增 4 条)、`test_viz.py` 8、`test_decision.py` 7、
- **199 条用例:168 passed / 31 skipped / 0 xfail**。收集数按文件:`test_data.py` 52、`test_api_surface.py` 20、
`test_core.py` 23、`test_solve.py` 32(本轮再 +2:非法 `bounds` 扩到 4 类 + 失败 message 语义 / `np.int64`)、`test_viz.py` 8、
`test_stats.py` 53、`test_models.py` 5
- **`xfail` 再次清零**:`correlation_matrix` 的契约冲突已按方案 A 解决(决策 41),那条用例转正
- 上一轮那 7 条 xfail 已全部转正,决策 30 那套信号(XPASS → 摘 mark)跑完过一个完整闭环
@@ -433,7 +433,7 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向
用户 2026-10-05 按"6 条待办"自行改定。Wilson 在 p 贴近 0 / 1 时不会退化成零宽:实测 p=1、n=1000 从 `(1.0, 1.0)`
变成 `(0.99617, 1.0)`;p=0 从"判失败"变成 `(0.0, 0.00383)`(原 Wald 是 0)。区间口径与
`statsmodels.proportion_confint(method="wilson")` 对齐,已加成对照测试。
**遗留**(2026-10-05 第二轮复核):`event: Callable[..., bool]` 注解与"吃整批、返回数组"不符(**待改**);
**遗留**(2026-10-05 第二轮复核,同日**已清**):~~`event: Callable[..., bool]` 注解与"吃整批、返回数组"不符~~ → 已改 `Callable[..., Any]`;
警告文案把 p≈1 也说成"小概率事件" —— **用户已于同日改掉**(现在两半都覆盖,见 CHANGELOG 本轮)。
| 51 | `monte_carlo_integral` 的 `data` = **n 个样本各自的贡献**(`func(X_i) × 体积`);积分估计值看 `metrics["mean"]` |
@@ -443,6 +443,13 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向
**留给用户 3 条**:① `result.message` 无条件覆盖(失败被写成"成功");② `bounds` 形状没校验(`[0, 1]` 抛裸 `IndexError`);
③ `n: int` 与全模块的 `int | np.integer` 不一致。
| 52 | `monte_carlo_integral` 的入口口径由**用户第二轮**改定:`message` 只在成功分支改写;`bounds` 必须是非空 `list`/`tuple` 且形状 `(d, 2)`;`n` 收 `int \| np.integer` |
用户 2026-10-05 按 Codex 报的 3 条自行改定。`message` 走 `if result.success:` / `else:`(失败分支保留失败语义并追加追溯信息:
维度 / 体积 / 模拟次数)—— 修掉"`success=False` + 一句成功文案"的自相矛盾。`bounds` 入口两道校验把
`[0, 1]` 从 `IndexError: too many indices` 变成带当前形状的可读 `ValueError`。遗留 2 条新口径:
① `isinstance(bounds, (list, tuple))` 把 `np.array([[0, 1]])` 也拒了,与"尽量传 ndarray"的偏好冲突;
② `if` / `else` 两支里 `name` / `params["bounds"]` / `params["dimension"]` 各写了一遍(易错点 20)。
## 下一步
### ⭐ 当前进度与待办(2026-10-05 第二轮)
@@ -451,7 +458,7 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向
(体积 `lows - highs` → `highs - lows`,见决策 51)—— **算法结构一行未动**。
**接下来三件事**
1. `monte_carlo_integral` 有 **3 条待办**(下面一节)——都是你的活。
1. `monte_carlo_integral` 的 3 条待办**已办结**;新增 2 条口径(下面一节)——都是你的活。
2. `ci_low` / `ci_high` 的语义确认一句(也是你的活),确认后 Codex 补 `docs/algorithms/monte_carlo.md` 卡片。
3. L0 最后一项 `save_figure`(A 档 = 工程,`viz/export.py`)—— **Codex 直接实现**,你只读。
@@ -459,7 +466,7 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向
它和刚写完的两个函数是**同一套骨架的第三次复用**(造批 → 一次算 → 汇总),新东西只有两处:
`np.cumsum` 把增量累成轨迹;`data` 的 shape 从 `(n,)` 变成 `(n_paths, n_steps + 1)` 的**二维**。
### ✅ monte_carlo_probability 的 6 条待办(2026-10-05 已办 5 条、剩 1 条)
### ✅ monte_carlo_probability 的 6 条待办(2026-10-05 全部办结)
- ~~① `p <= 1e-3` 判 `success=False`~~ → 改成 `warning_message`,仍返回区间(实测 p=0 → `(0.0, 0.00383)`)。
- ~~② Wald 在 p 靠近 1 时退化~~ → 换 **Wilson**(p=1、n=1000:`(1.0, 1.0)` → `(0.99617, 1.0)`)。
@@ -468,22 +475,26 @@ docstring 与 `test_describe_reports_expected_keys` 早就写死了这个方向
- ~~⑤ `metrics` 混 `np.float64`~~ → `float()` 统一(实测 4 个 key 全是 `float`)。
- ~~⑥ `n: int`~~ → 已改 `int | np.integer`。
**还剩 1 条(你的活)**
1. `event: Callable[..., bool]` 的注解与行为不符:实际必须吃**整批**参数、返回长度 `n` 的数组。
改法(一行):`Callable[..., bool]` → `Callable[..., Any]`,与同模块的 `sampler` / `simulator` 对齐。
pyright 实测:`Callable[..., bool]` 会**拒绝**一个注解良好的向量化 `event`(返回 `ndarray` 与 `bool` 不兼容),
`Callable[..., Any]` 才收得下 —— 这正是"注解与行为不符"的实际代价。
**~~还剩 1 条~~ → 已办(本轮)**
1. ~~`event: Callable[..., bool]` 的注解与行为不符~~ → 已改 `Callable[..., Any]`,与 `sampler` / `simulator` 对齐。
pyright 复测:向量化事件函数不再被拒(原注解会报 `reportArgumentType`)。**决策 50 的遗留清零。**
### ⏳ monte_carlo_integral 的 3 条待办(2026-10-05,你的活)
### ✅ monte_carlo_integral 的 3 条待办(2026-10-05 已办结)
1. **`result.message` 被无条件覆盖**:拿到 `result` 后直接 `result.message = "…成功…"`,内层 `success=False` 时也照写。
实测 `func` 返回标量时,对外是 `success=False` + **"蒙特卡洛多重积分成功"** —— 两个字段自相矛盾。
方向(你定):用 `if result.success:` 包住,或 message 只**追加**、不覆盖。
2. **`bounds` 形状没校验**:`bounds=[0, 1]`(1-D 扁平)在 `bounds_arr[:, 0]` 抛 `IndexError: too many indices`
—— 是裸异常,没包进 `Result`。方向:要求"每个元素都是二元组",入口给可读的 `ValueError`;
顺手也能和 `(1, 0)` 那条校验合并到一处写。
3. **`n: int = 10000` 与全模块不一致**:别的函数都写 `int | np.integer`(§4.7)。
运行期其实收 `np.int64`(`monte_carlo` 里放行了),只是注解更窄 —— 调用方按注解老实写,pyright 会对 `np.int64` 报错。
- ~~① `result.message` 被无条件覆盖~~ → 已改成 `if result.success:` / `else:` 两个分支;
失败时是"蒙特卡洛多重积分失败:维度 1,体积 1.0000,模拟 100 次。蒙特卡洛模拟失败:TypeError - ...",
`success` 与 `message` 不再打架。
- ~~② `bounds` 形状没校验~~ → 入口加了 `isinstance(bounds, (list, tuple))` 与 `ndim == 2 and shape[1] == 2` 两道;
`bounds=[0, 1]` 现在报"bounds 必须形如 [(lo, hi), ...],当前形状为 (2,),请检查是否漏了括号"。
- ~~③ `n: int`~~ → 已改 `int | np.integer`。
### ⏳ monte_carlo_integral 还有 2 条口径(新增,你的活)
1. **`bounds` 把 ndarray 也拒了**:`isinstance(bounds, (list, tuple))` 使 `np.array([[0.0, 1.0]])` 直接 `ValueError`。
这和你自己定的口径("以后我也尽量不传 list 和 tuple,尽量用 `pd.DataFrame` / `pd.Series` / `np.array`")冲突 ——
要不要放成"list / tuple / ndarray 都收"?(`np.asarray` 本来就能接住三者)
2. **`if` / `else` 两个分支里那三行写了两遍**:`result.name`、`params["bounds"]`、`params["dimension"]` 各写一次。
要改的话很简单:这三行提到 `if` 之前,只让 `message` 分两支 —— 正是易错点 20 说的"同一个信息写两遍"。
### ⏳ monte_carlo 只剩 1 个口径要你确认
+17 -4
View File
@@ -132,7 +132,7 @@ def monte_carlo(
def monte_carlo_probability(
event: Callable[..., bool],
event: Callable[..., Any],
sampler: Callable[..., Any],
n: int | np.integer = 10000,
confidence: float = 0.95,
@@ -246,7 +246,7 @@ def monte_carlo_probability(
def monte_carlo_integral(
func: Callable[..., Any],
bounds: Any,
n: int = 10000,
n: int | np.integer = 10000,
random_state: int | None = None,
) -> Any:
"""用蒙特卡洛求多重积分。
@@ -257,7 +257,7 @@ def monte_carlo_integral(
`f(X) -> 一批函数值`。`X` 是形状 `(n, d)` 的样本矩阵(一行一个样本),
返回长度 `n` 的数组(写成 `X[:, 0] ** 2` 这种逐行写法,不要写 Python 循环)。
bounds:
`[(lo, hi), ...]`,维度即积分重数,要求每个 `lo < hi`。
`[(lo, hi), ...]`(非空 list / tuple),维度即积分重数;要求每项都是二元组且 `lo < hi`。
n:
模拟次数,正整数。
random_state:
@@ -272,7 +272,7 @@ def monte_carlo_integral(
Raises
------
ValueError:
`bounds` 里出现 `lo >= hi`。
`bounds` 不是非空 list / tuple、形状不是 `(d, 2)`(例如漏括号写成 `[0, 1]`),或某个 `lo >= hi`。
Notes
-----
@@ -286,8 +286,14 @@ def monte_carlo_integral(
>>> abs(r.metrics["mean"] - 1 / 3) < 0.01 # ∫₀¹ x² dx = 1/3
True
"""
if not isinstance(bounds, (list, tuple)) or len(bounds) == 0:
raise ValueError("bounds 必须是非空的列表或元组")
bounds_arr = np.asarray(bounds)
if bounds_arr.ndim != 2 or bounds_arr.shape[1] != 2:
raise ValueError(f"bounds 必须形如 [(lo, hi), ...],当前形状为 {bounds_arr.shape},请检查是否漏了括号")
lows = bounds_arr[:, 0]
highs = bounds_arr[:, 1]
d = len(lows)
@@ -313,10 +319,17 @@ def monte_carlo_integral(
n=n,
random_state=random_state,
)
if result.success:
result.name = "monte_carlo_integral"
result.params["bounds"] = bounds
result.params["dimension"] = d
result.message = f"蒙特卡洛多重积分成功:维度 {d},体积 {volume:.4f},模拟 {n} 次。"
else:
result.name = "monte_carlo_integral"
result.params["bounds"] = bounds
result.params["dimension"] = d
result.message = f"蒙特卡洛多重积分失败:维度 {d},体积 {volume:.4f},模拟 {n} 次。{result.message}"
return result
+23 -1
View File
@@ -51,10 +51,32 @@ def test_monte_carlo_integral_2d_and_params():
def test_monte_carlo_integral_rejects_bad_bounds():
"""四类非法 bounds 都要在入口抛 ValueError:区间反了 / 漏括号 / 空 / 参差不齐。"""
from mcm_toolkit.solve import monte_carlo_integral
for bad in ([(1, 0)], [0, 1], [], [(0, 1), (0, 2, 3)]):
with pytest.raises(ValueError):
monte_carlo_integral(lambda X: X[:, 0], bounds=[(1, 0)], n=100, random_state=0)
monte_carlo_integral(lambda X: X[:, 0], bounds=bad, n=100, random_state=0)
def test_monte_carlo_integral_failure_message_stays_failure():
"""失败时 message 不能被包装层改写成"成功"(易错点 22)。"""
from mcm_toolkit.solve import monte_carlo_integral
r = monte_carlo_integral(lambda X: 1.0, bounds=[(0, 1)], n=100, random_state=0)
assert r.success is False
assert "成功" not in r.message
assert "失败" in r.message
assert r.params["dimension"] == 1 # 失败也要留下追溯信息
def test_monte_carlo_integral_accepts_numpy_integer_n():
"""§4.7:`np.int64` 不是 `int` 的子类,注解与运行期都要收(易错点 1)。"""
from mcm_toolkit.solve import monte_carlo_integral
r = monte_carlo_integral(lambda X: X[:, 0], bounds=[(0, 1)], n=np.int64(1000), random_state=0)
assert r.success
assert len(r.data) == 1000
def test_monte_carlo_probability_of_standard_normal():