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:
1 parent
bcdbdd35a3
commit
cb96c847dd
4 files changed
+162
-13
No files matched your search
+5
-1
@@ -20,8 +20,12 @@
|
|||||||
- `environment.yml`:需联网时的精确复现方案
|
- `environment.yml`:需联网时的精确复现方案
|
||||||
- `pyproject.toml`:核心依赖只 4 个,其余走 extras
|
- `pyproject.toml`:核心依赖只 4 个,其余走 extras
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `core.Result`:补上 `__repr__` 与 `summary()` 实现(L0 第 1 项落地)。
|
||||||
|
`repr` 紧凑单行且不打印 `data` 内容;`summary()` 输出可直接贴进论文的 Markdown 表格。
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
- 所有函数体**尚未实现**(统一 `raise NotImplementedError` + `TODO(L0/L1/v0.2)` 标记)。
|
- 除 `core.Result` 外,其余函数体**尚未实现**(统一 `raise NotImplementedError` + `TODO(L0/L1/v0.2)` 标记)。
|
||||||
这是刻意的:先定接口,再按 `docs/03-roadmap.md` 的 L0 → L1 顺序填实现。
|
这是刻意的:先定接口,再按 `docs/03-roadmap.md` 的 L0 → L1 顺序填实现。
|
||||||
- 已知缺口:`SALib`(Sobol/Morris)、`pymoo`、`pyright` 未安装(本机离线)。
|
- 已知缺口:`SALib`(Sobol/Morris)、`pymoo`、`pyright` 未安装(本机离线)。
|
||||||
|
|
||||||
|
|||||||
@@ -58,18 +58,32 @@
|
|||||||
- [x] 推送通道:公网 22 被防火墙过滤 → 改用 Tailscale(`100.115.86.48:22`),
|
- [x] 推送通道:公网 22 被防火墙过滤 → 改用 Tailscale(`100.115.86.48:22`),
|
||||||
`~/.ssh/config` 加了一条域名别名,remote 里仍写域名
|
`~/.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` |
|
| 检查 | 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 项 |
|
| `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` | 跑通 | — |
|
| `scripts/doctor.ps1` | 跑通 | — |
|
||||||
| SALib / pymoo / pyright | 缺失 | 已装 |
|
| SALib / pymoo / pyright | 缺失 | 已装 |
|
||||||
|
|
||||||
- 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(与两套环境一致)
|
||||||
- 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 语法解析
|
- 三个 `.ps1` 通过 PowerShell AST 语法解析
|
||||||
|
|
||||||
### ⚠️ 需要注意 / 遗留
|
### ⚠️ 需要注意 / 遗留
|
||||||
@@ -79,7 +93,8 @@
|
|||||||
只差 Python 补丁号(3.13.9 vs 3.13.15)。详见 `docs/04-environment.md` §5。
|
只差 Python 补丁号(3.13.9 vs 3.13.15)。详见 `docs/04-environment.md` §5。
|
||||||
- [x] ~~Gitea 远程尚未配置~~ → **已配置并推送完成**,见上面 D 节与决策 19/20。
|
- [x] ~~Gitea 远程尚未配置~~ → **已配置并推送完成**,见上面 D 节与决策 19/20。
|
||||||
note:仓库当前是 **Public**(匿名 `git ls-remote` 可读),要改私有去 Gitea 仓库设置。
|
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` 在沙箱模式下**完全不可用**
|
- [ ] 本机 `exec_command` 在沙箱模式下**完全不可用**
|
||||||
(`helper_unknown_error: setup refresh had errors`),只有提权
|
(`helper_unknown_error: setup refresh had errors`),只有提权
|
||||||
(`require_escalated`)模式可用。沙箱模式只允许写 `D:\MCM&ICM_Toolkit`。
|
(`require_escalated`)模式可用。沙箱模式只允许写 `D:\MCM&ICM_Toolkit`。
|
||||||
@@ -114,11 +129,13 @@
|
|||||||
| 18 | `core.autocrlf=false`(仓库级) | 配合 `.gitattributes` 的 LF 策略,避免 Windows 上 checkout 又转成 CRLF |
|
| 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`,换机只改一处 |
|
| 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`) |
|
| 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`
|
1. ~~接入 Gitea~~ **已完成**。以后推送前先确认 Tailscale 在线,然后 `git push`
|
||||||
(细节与换机步骤见 `docs/05-git-workflow.md` §3)。
|
(细节与换机步骤见 `docs/05-git-workflow.md` §3)。
|
||||||
2. 按 `docs/03-roadmap.md` 的 **L0(12 个函数)** 顺序填实现,先写测试再写代码。
|
2. 继续 L0,**下一个:`core/random.py` 的 `get_rng` / `set_seed`**(先写测试再写代码)。
|
||||||
3. 每写完一个函数,在 `examples/` 里加一行最小用法,并更新本文件。
|
3. 每写完一个函数,在 `examples/` 里加一行最小用法,并更新本文件。
|
||||||
4. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。
|
4. L0 跑通后,找一道真题(建议 2025 C)走完整流程,反向沉淀函数。
|
||||||
@@ -6,9 +6,13 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
# 单个值在摘要里的最大字符数,超了就截断。
|
||||||
|
_MAX_TEXT = 60
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Result:
|
class Result:
|
||||||
@@ -29,6 +33,8 @@ class Result:
|
|||||||
message:
|
message:
|
||||||
给人看的一句话说明,可直接抄进论文的"方法说明"。
|
给人看的一句话说明,可直接抄进论文的"方法说明"。
|
||||||
|
|
||||||
|
两个出口::meth:`summary` 给论文用(Markdown 表格),`repr` 给调试用(紧凑单行)。
|
||||||
|
|
||||||
Examples
|
Examples
|
||||||
--------
|
--------
|
||||||
>>> r = Result("demo", params={"alpha": 0.5}, metrics={"r2": 0.9})
|
>>> r = Result("demo", params={"alpha": 0.5}, metrics={"r2": 0.9})
|
||||||
@@ -44,8 +50,92 @@ class Result:
|
|||||||
message: str = ""
|
message: str = ""
|
||||||
extras: dict[str, Any] = field(default_factory=dict)
|
extras: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
# TODO(L0): 实现 __repr__,打印成"方法名 + 指标表"的紧凑形式,
|
def __repr__(self) -> str:
|
||||||
# 方便在 notebook 里直接肉眼检查、或复制进论文草稿。
|
"""紧凑的单行摘要,方便在 notebook / 调试器里一眼确认结果。
|
||||||
# TODO(L0): 实现 summary() -> str,返回可直接贴进论文的 Markdown 表格。
|
|
||||||
def __repr__(self) -> str: # pragma: no cover - 待实现
|
`data` 只给类型和规模,**绝不**整个打印——模拟轨迹动辄十万个数,
|
||||||
raise NotImplementedError
|
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
@@ -1,6 +1,6 @@
|
|||||||
"""core 包测试。
|
"""core 包测试。
|
||||||
|
|
||||||
TODO(L0):实现后去掉 skip 标记。
|
TODO(L0):TestRandom / TestValidate 实现后去掉 skip 标记(Result 已完成)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -61,9 +61,47 @@ class TestValidate:
|
|||||||
check_square_matrix([[1, 2, 3], [4, 5, 6]])
|
check_square_matrix([[1, 2, 3], [4, 5, 6]])
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip(reason="未实现:见 docs/03-roadmap.md L0")
|
|
||||||
def test_result_repr_is_readable():
|
def test_result_repr_is_readable():
|
||||||
from mcm_toolkit.core import Result
|
from mcm_toolkit.core import Result
|
||||||
|
|
||||||
text = repr(Result("demo", params={"a": 1}, metrics={"r2": 0.9}))
|
text = repr(Result("demo", params={"a": 1}, metrics={"r2": 0.9}))
|
||||||
|
# 只断言关键信息在场,不断言完整字符串——否则以后调格式会让测试集体变红
|
||||||
assert "demo" in text and "r2" in text
|
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("> 样本太少")
|
||||||
Reference in new issue
Block a user