feat(core): 实现 Result 的 __repr__ 与 summary

L0 第 1 项落地。

- __repr__:单行紧凑;data 只给类型与规模(ndarray(100000)),
  绝不整数组打印;__repr__ 永不抛异常
- summary():可直接贴进论文的 Markdown 表格,
  行序 success → params.* → metrics.* → extras.*(按插入顺序,不排序),
  float 走 .6g,竖线转义,message 以引用行附在表后
- 新增 5 个模块级私有辅助:_fmt_scalar / _fmt_cell / _fmt_mapping /
  _describe_data / _truncate
- tests/test_core.py:去掉 test_result_repr_is_readable 的 skip,
  并补 3 条行为测试(默认值与失败路径与十万级数组、Markdown 结构、幂等)
- STATUS.md:L0 进度 1/12;新增决策 21(repr 不打印 data 内容)、
  22(summary 行序不排序);CHANGELOG.md 同步
This commit is contained in:
guzhujushi committed 2026-10-01 18:48:43 +08:00
1 parent bcdbdd35a3
commit cb96c847dd
4 files changed
+162 -13

No files matched your search

+5 -1
View File
@@ -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` 未安装(本机离线)。
+22 -5
View File
@@ -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)走完整流程,反向沉淀函数。
+95 -5
View File
@@ -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] + "…"
+40 -2
View File
@@ -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("> 样本太少")