diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c10771..ed4c15c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,12 @@ - `environment.yml`:需联网时的精确复现方案 - `pyproject.toml`:核心依赖只 4 个,其余走 extras +### Changed +- `core.Result`:补上 `__repr__` 与 `summary()` 实现(L0 第 1 项落地)。 + `repr` 紧凑单行且不打印 `data` 内容;`summary()` 输出可直接贴进论文的 Markdown 表格。 + ### Notes -- 所有函数体**尚未实现**(统一 `raise NotImplementedError` + `TODO(L0/L1/v0.2)` 标记)。 +- 除 `core.Result` 外,其余函数体**尚未实现**(统一 `raise NotImplementedError` + `TODO(L0/L1/v0.2)` 标记)。 这是刻意的:先定接口,再按 `docs/03-roadmap.md` 的 L0 → L1 顺序填实现。 - 已知缺口:`SALib`(Sobol/Morris)、`pymoo`、`pyright` 未安装(本机离线)。 diff --git a/STATUS.md b/STATUS.md index 64cb633..601be7e 100644 --- a/STATUS.md +++ b/STATUS.md @@ -58,18 +58,32 @@ - [x] 推送通道:公网 22 被防火墙过滤 → 改用 Tailscale(`100.115.86.48:22`), `~/.ssh/config` 加了一条域名别名,remote 里仍写域名 +**E. L0 实现进度(1/12)** +- [x] `core/result.py` 的 `Result` + - `__repr__`:单行紧凑;`data` 只给类型与规模,**绝不**整数组打印 + (十万级数组的 repr 实测仍 < 200 字符);`success` 恒在 + - `summary()`:Markdown 表格,可直接贴进论文; + 行序 `success` → `params.*` → `metrics.*` → `extras.*`; + float 走 `.6g`,`|` 转义,`message` 以引用行附在表后;幂等无副作用 + - 测试:`tests/test_core.py` 去掉 skip + 新增 3 条 + (默认值/失败路径/大数组、Markdown 结构、可重复调用) +- [ ] 剩余 11 项(13 个函数):`get_rng` `set_seed` `load_table` `clean_missing` + `detect_outliers` `normalize` `standardize` `describe` `correlation_matrix` + `bootstrap_ci` `monte_carlo` `save_figure` + ### 验证结果 | 检查 | venv | conda `mcm-toolkit` | |---|---|---| -| `pytest` | 20 passed, 49 skipped | 20 passed, 49 skipped | +| `pytest` | 24 passed, 48 skipped | 24 passed, 48 skipped | | `import mcm_toolkit` | 0.0.1 / 16 项 | 0.0.1 / 16 项 | -| `ruff check` | All checks passed | — | +| `ruff check` | All checks passed | All checks passed | | `scripts/doctor.ps1` | 跑通 | — | | SALib / pymoo / pyright | 缺失 | 已装 | - base 亦已复核:numpy 2.4.6 / scipy 1.18.1 / pandas 3.0.6(与两套环境一致) -- skip 的都是等实现的算法行为测试;通过的 20 条是 `tests/test_api_surface.py` 的公开 API 契约测试 +- skip 的都是等实现的算法行为测试;通过的 24 条 = 20 条公开 API 契约测试 + (`tests/test_api_surface.py`)+ 4 条 `Result` 行为测试(`tests/test_core.py`) - 三个 `.ps1` 通过 PowerShell AST 语法解析 ### ⚠️ 需要注意 / 遗留 @@ -79,7 +93,8 @@ 只差 Python 补丁号(3.13.9 vs 3.13.15)。详见 `docs/04-environment.md` §5。 - [x] ~~Gitea 远程尚未配置~~ → **已配置并推送完成**,见上面 D 节与决策 19/20。 note:仓库当前是 **Public**(匿名 `git ls-remote` 可读),要改私有去 Gitea 仓库设置。 -- [ ] 所有函数实现尚未开始(按 L0 → L1 顺序,见 `docs/03-roadmap.md`) +- [ ] L0 完成 1/12(`Result`),其余按 `docs/03-roadmap.md` 的 L0 → L1 顺序推进; + 详情见上面 E 节 - [ ] 本机 `exec_command` 在沙箱模式下**完全不可用** (`helper_unknown_error: setup refresh had errors`),只有提权 (`require_escalated`)模式可用。沙箱模式只允许写 `D:\MCM&ICM_Toolkit`。 @@ -114,11 +129,13 @@ | 18 | `core.autocrlf=false`(仓库级) | 配合 `.gitattributes` 的 LF 策略,避免 Windows 上 checkout 又转成 CRLF | | 19 | Gitea 的 SSH **走 Tailscale 内网**,不在公网开 22 | 实测公网 `8.145.36.108:22` 超时(服务器侧过滤,本机出站 22 没被封:github.com:22 可通),而 Tailscale `100.115.86.48:22` 可达且 `git@` 认证直接过 Gitea。remote 里仍写域名,域名→内网 IP 的映射放在 `~/.ssh/config`,换机只改一处 | | 20 | 首次推送用 `--force-with-lease` **覆盖** Gitea 的 `Initial commit` | 远端那个提交是建仓时自动初始化产生的,与本地无共同祖先,普通 push 必被拒。推送前已把其中的 LICENSE 与手写简介并入本地 `README.md`,原提交仍留在本机对象库(`1c683cf`) | +| 21 | `Result.__repr__` **不打印 `data` 内容**,只给类型与规模 | 模拟轨迹动辄十万个数,repr 一旦刷屏,traceback / 调试器 / notebook 输出就全废了。要看数据请直接取 `result.data` | +| 22 | `Result.summary()` 的行序 = 字典插入顺序,不排序 | 比赛时"想先看哪个指标"自己决定键的插入顺序即可,工具不该替人排序 | ## 下一步 1. ~~接入 Gitea~~ **已完成**。以后推送前先确认 Tailscale 在线,然后 `git push` (细节与换机步骤见 `docs/05-git-workflow.md` §3)。 -2. 按 `docs/03-roadmap.md` 的 **L0(12 个函数)** 顺序填实现,先写测试再写代码。 +2. 继续 L0,**下一个:`core/random.py` 的 `get_rng` / `set_seed`**(先写测试再写代码)。 3. 每写完一个函数,在 `examples/` 里加一行最小用法,并更新本文件。 4. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。 diff --git a/src/mcm_toolkit/core/result.py b/src/mcm_toolkit/core/result.py index b690379..ccc92fd 100644 --- a/src/mcm_toolkit/core/result.py +++ b/src/mcm_toolkit/core/result.py @@ -6,9 +6,13 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass, field from typing import Any +# 单个值在摘要里的最大字符数,超了就截断。 +_MAX_TEXT = 60 + @dataclass class Result: @@ -29,6 +33,8 @@ class Result: message: 给人看的一句话说明,可直接抄进论文的"方法说明"。 + 两个出口::meth:`summary` 给论文用(Markdown 表格),`repr` 给调试用(紧凑单行)。 + Examples -------- >>> r = Result("demo", params={"alpha": 0.5}, metrics={"r2": 0.9}) @@ -44,8 +50,92 @@ class Result: message: str = "" extras: dict[str, Any] = field(default_factory=dict) - # TODO(L0): 实现 __repr__,打印成"方法名 + 指标表"的紧凑形式, - # 方便在 notebook 里直接肉眼检查、或复制进论文草稿。 - # TODO(L0): 实现 summary() -> str,返回可直接贴进论文的 Markdown 表格。 - def __repr__(self) -> str: # pragma: no cover - 待实现 - raise NotImplementedError + def __repr__(self) -> str: + """紧凑的单行摘要,方便在 notebook / 调试器里一眼确认结果。 + + `data` 只给类型和规模,**绝不**整个打印——模拟轨迹动辄十万个数, + repr 一旦刷屏,traceback 和调试器就都没法看了。 + `__repr__` 还必须永不抛异常,否则报错信息本身会变成报错源。 + """ + parts = [f"success={self.success}"] + if self.params: + parts.append(f"params={_fmt_mapping(self.params)}") + if self.metrics: + parts.append(f"metrics={_fmt_mapping(self.metrics)}") + if self.data is not None: + parts.append(f"data={_describe_data(self.data)}") + if self.message: + parts.append(f"message={self.message!r}") + return f"Result({self.name}, {', '.join(parts)})" + + def summary(self) -> str: + """返回可直接贴进论文的 Markdown 表格。 + + 行顺序为 `success` → `params.*` → `metrics.*` → `extras.*`, + 按字典插入顺序输出(比赛时按自己想要的阅读顺序建字典即可)。 + `message` 非空时以引用行附在表后。 + + Examples + -------- + >>> print(Result("demo", metrics={"r2": 0.9}).summary()) + ### demo + + | 项 | 值 | + |---|---| + | success | True | + | metrics.r2 | 0.9 | + """ + rows: list[tuple[str, Any]] = [("success", self.success)] + for prefix, mapping in ( + ("params", self.params), + ("metrics", self.metrics), + ("extras", self.extras), + ): + for key, value in (mapping or {}).items(): + rows.append((f"{prefix}.{key}", value)) + + lines = [f"### {self.name}", "", "| 项 | 值 |", "|---|---|"] + lines += [f"| {key} | {_fmt_cell(value)} |" for key, value in rows] + if self.message: + lines += ["", f"> {self.message}"] + return "\n".join(lines) + + +def _fmt_scalar(value: Any) -> str: + """把一个值格式化成短、稳、论文友好的字符串。 + + `bool` 必须在 `int` 之前判断——`True` 同时是 `int` 的实例, + 顺序反了就会把 `True` 打印成 `1`。 + """ + if isinstance(value, bool): + return str(value) + if isinstance(value, float): + # 6 位有效数字:够论文用,又不会出现 0.30000000000000004。 + return f"{value:.6g}" + if isinstance(value, int): + return str(value) + # 其他类型先归一化空白(换行会把单行 repr 和 Markdown 表格都撑破),再截断。 + return _truncate(" ".join(str(value).split())) + + +def _fmt_cell(value: Any) -> str: + """Markdown 表格单元格:值里出现竖线会把表格顶散,必须转义。""" + return _fmt_scalar(value).replace("|", r"\|") + + +def _fmt_mapping(mapping: Mapping[str, Any]) -> str: + return "{" + ", ".join(f"{key}: {_fmt_scalar(value)}" for key, value in mapping.items()) + "}" + + +def _describe_data(data: Any) -> str: + """只描述 `data` 的类型与规模,不打印内容。""" + shape = getattr(data, "shape", None) + if shape is not None: # numpy 数组 / pandas DataFrame、Series + return f"{type(data).__name__}({', '.join(str(d) for d in shape)})" + if isinstance(data, str | bytes | list | tuple | set | frozenset | dict): + return f"{type(data).__name__}({len(data)})" + return type(data).__name__ + + +def _truncate(text: str, limit: int = _MAX_TEXT) -> str: + return text if len(text) <= limit else text[: limit - 1] + "…" diff --git a/tests/test_core.py b/tests/test_core.py index 7c8f5b1..125bccf 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,6 +1,6 @@ """core 包测试。 -TODO(L0):实现后去掉 skip 标记。 +TODO(L0):TestRandom / TestValidate 实现后去掉 skip 标记(Result 已完成)。 """ from __future__ import annotations @@ -61,9 +61,47 @@ class TestValidate: check_square_matrix([[1, 2, 3], [4, 5, 6]]) -@pytest.mark.skip(reason="未实现:见 docs/03-roadmap.md L0") def test_result_repr_is_readable(): from mcm_toolkit.core import Result text = repr(Result("demo", params={"a": 1}, metrics={"r2": 0.9})) + # 只断言关键信息在场,不断言完整字符串——否则以后调格式会让测试集体变红 assert "demo" in text and "r2" in text + + +def test_result_repr_stays_short_and_safe(): + """默认值、失败结果、十万级数组:repr 都不能崩,也不能刷屏。""" + from mcm_toolkit.core import Result + + assert "empty" in repr(Result("empty")) + + failed = repr(Result("boom", success=False, message="求解未收敛")) + assert "success=False" in failed + assert "求解未收敛" in failed + + noisy = repr(Result("mc", data=np.arange(100_000))) + assert "ndarray(100000)" in noisy + assert len(noisy) < 200, "data 不应被整个打印出来" + + +def test_result_summary_is_markdown_table(): + from mcm_toolkit.core import Result + + result = Result("topsis", params={"n": 3}, metrics={"best": 0.83}) + text = result.summary() + lines = text.splitlines() + + assert lines[0] == "### topsis" + assert lines[2] == "| 项 | 值 |" + assert "| success | True |" in lines + assert "| params.n | 3 |" in lines + assert "| metrics.best | 0.83 |" in lines + assert result.summary() == text, "summary 必须无副作用、可重复调用" + + +def test_result_summary_appends_message_on_failure(): + from mcm_toolkit.core import Result + + text = Result("gm11", success=False, message="样本太少").summary() + assert "| success | False |" in text + assert text.rstrip().endswith("> 样本太少")