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

+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] + "…"