Skip to content

Commit 32a1b1f

Browse files
refactor(tracking): extract run-report writers into aet.tracking.reports
The four write_* serializers (run_record / summary_metrics / eval_report / metrics.json) move from EvalRunLogger into free functions in tracking/reports.py; the facade methods delegate. This reduces the facade's size and — more importantly — makes the report *shapes* testable in isolation (tests/test_reports.py). No behavior change: identical JSON output. Preferred over a mixin split, which wouldn't reduce the facade's real coupling to its backend state.
1 parent fd5d219 commit 32a1b1f

5 files changed

Lines changed: 135 additions & 84 deletions

File tree

AGENTS.md

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,8 @@ Data flow: `transcript.jsonl → import_transcript → RunTrajectory → (emit_t
5858
5. New public API is reachable from the docs (`mkdocs build --strict` stays green — it fails on a
5959
broken/renamed reference, which is the anti-drift backstop).
6060

61-
## Known follow-ups
62-
- `tracking/run_logger.py` (~1150 LOC, one **cohesive** `EvalRunLogger` facade). Big but single-
63-
responsibility, so it is not a blocker. A mixin split is **not** recommended — mixins don't reduce
64-
the real coupling (every method needs the facade's `self._local/_mlflow/_otel` state) and add MRO
65-
indirection. If trimming: extract the pure report-**writers** (`write_summary_metrics`,
66-
`write_eval_report`, `write_metrics_structured`, `write_run_record`) into a `tracking/reports.py`
67-
of free functions — low-risk, reduces size *and* coupling.
61+
## Notes
62+
- `tracking/run_logger.py` is one **cohesive** `EvalRunLogger` facade — big but single-responsibility.
63+
Its pure serialization now lives in `tracking/reports.py` (free functions the facade delegates to).
64+
A further mixin split is **not** recommended: mixins don't reduce the real coupling (every method
65+
needs the facade's `self._local/_mlflow/_otel` state) and only add MRO indirection.

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,9 @@ All notable changes to `aet` are recorded here.
115115
- **CLI de-godded**: `cli/main.py` (1379 LOC) split into a thin argparse table + `cli/_common.py` +
116116
`cli/commands/{lifecycle,reporting,trajectory}.py`. No behavior change.
117117
- **Logging**: tracking warnings emit via `logging` (silent by default) instead of raw `print`.
118+
- **Report writers extracted**: the run-report serialization (`run_record.json`,
119+
`summary_metrics.json`, `eval_report.json`, `metrics.json`) moved from `EvalRunLogger` into
120+
free functions in `tracking/reports.py` (the facade delegates) — testable in isolation.
118121

119122
### Docs
120123
- Rewritten `README.md` (the real record→plot / sandboxed-run surface), a root `AGENTS.md`

src/aet/tracking/reports.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Run-report writers — pure serialization, no backend/logger state.
2+
3+
These build the canonical JSON products of a run (`run_record.json`, `metrics/summary_metrics.json`,
4+
`eval_report.json`, `metrics.json`) from plain values + a target directory. Kept as free functions
5+
(not `EvalRunLogger` methods) so the report *shape* is testable and reusable in isolation, and the
6+
logger facade stays a thin delegator. See :class:`aet.tracking.run_logger.EvalRunLogger`'s
7+
``write_*`` methods.
8+
"""
9+
from __future__ import annotations
10+
11+
import json
12+
from datetime import datetime, timezone
13+
from pathlib import Path
14+
15+
16+
def _now() -> str:
17+
return datetime.now(tz=timezone.utc).isoformat()
18+
19+
20+
def _dump(path: Path, obj: dict) -> Path:
21+
path.parent.mkdir(parents=True, exist_ok=True)
22+
path.write_text(json.dumps(obj, indent=2, default=str))
23+
return path
24+
25+
26+
def write_run_record(run_path: Path, *, run_id: str, project: str, suite: str, target,
27+
method: str, seed: int, mode: str, extra: dict | None = None) -> Path:
28+
"""`run_record.json` at the run root — identity + provenance of the run."""
29+
record = {
30+
"schema_version": "1.1", "run_id": run_id, "project": project, "suite": suite,
31+
"target": target, "method": method, "seed": seed, "tracking_mode": mode,
32+
"created_at": _now(),
33+
}
34+
if extra:
35+
record.update(extra)
36+
return _dump(run_path / "run_record.json", record)
37+
38+
39+
def write_summary_metrics(run_path: Path, *, run_id: str, project: str, suite: str, method: str,
40+
seed: int, target, extra: dict | None = None) -> Path:
41+
"""`metrics/summary_metrics.json` — the headline metrics for `aet runs`/`compare`."""
42+
summary = {
43+
"run_id": run_id, "project": project, "suite": suite, "method": method, "seed": seed,
44+
"target": target, "recorded_at": _now(),
45+
}
46+
if extra:
47+
summary.update(extra)
48+
return _dump(run_path / "metrics" / "summary_metrics.json", summary)
49+
50+
51+
def write_eval_report(run_path: Path, *, run_id: str, tests: list[dict],
52+
contracts: list[dict] | None = None, assertions: list[dict] | None = None,
53+
coverage: list[dict] | None = None, extra: dict | None = None) -> Path:
54+
"""`eval_report.json` — per-test / per-contract / per-assertion / coverage results."""
55+
report = {
56+
"schema_version": "1.0", "run_id": run_id, "generated_at": _now(),
57+
"tests": tests, "contracts": contracts or [], "assertions": assertions or [],
58+
"coverage": coverage or [],
59+
}
60+
if extra:
61+
report.update(extra)
62+
return _dump(run_path / "eval_report.json", report)
63+
64+
65+
def write_metrics_structured(run_path: Path, *, run_id: str, cost: dict, quality: dict,
66+
process: dict, extra: dict | None = None) -> Path:
67+
"""`metrics.json` — the structured cost / quality / process breakdown."""
68+
metrics = {
69+
"schema_version": "1.0", "run_id": run_id, "generated_at": _now(),
70+
"cost": cost, "quality": quality, "process": process,
71+
}
72+
if extra:
73+
metrics.update(extra)
74+
return _dump(run_path / "metrics.json", metrics)

src/aet/tracking/run_logger.py

Lines changed: 19 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from pathlib import Path
77
from typing import Any
88

9+
from aet.tracking import reports
910
from aet.tracking.types import TrackingConfig, TRACKING_MODES
1011
from aet.tracking.local_backend import LocalBackend
1112
from aet.tracking.mlflow_backend import MLflowBackend
@@ -1007,48 +1008,18 @@ def log_human_override(
10071008
# Utility writers
10081009

10091010
def write_run_record(self, extra: dict | None = None) -> Path:
1010-
"""Write run_record.json to the run_path root. Returns the path."""
1011-
from datetime import datetime, timezone
1012-
run_path = self._config.run_path
1013-
run_path.mkdir(parents=True, exist_ok=True)
1014-
record: dict = {
1015-
"schema_version": "1.1",
1016-
"run_id": self._config.run_id,
1017-
"project": self._config.project,
1018-
"suite": self._config.suite,
1019-
"target": self._config.target,
1020-
"method": self._config.method,
1021-
"seed": self._config.seed,
1022-
"tracking_mode": self._config.mode,
1023-
"created_at": datetime.now(tz=timezone.utc).isoformat(),
1024-
}
1025-
if extra:
1026-
record.update(extra)
1027-
import json
1028-
path = run_path / "run_record.json"
1029-
path.write_text(json.dumps(record, indent=2, default=str))
1030-
return path
1011+
"""Write `run_record.json` at the run root (see :mod:`aet.tracking.reports`)."""
1012+
c = self._config
1013+
return reports.write_run_record(
1014+
c.run_path, run_id=c.run_id, project=c.project, suite=c.suite, target=c.target,
1015+
method=c.method, seed=c.seed, mode=c.mode, extra=extra)
10311016

10321017
def write_summary_metrics(self, extra: dict | None = None) -> Path:
1033-
"""Write metrics/summary_metrics.json. Returns the path."""
1034-
from datetime import datetime, timezone
1035-
import json
1036-
metrics_dir = self._config.run_path / "metrics"
1037-
metrics_dir.mkdir(parents=True, exist_ok=True)
1038-
summary: dict = {
1039-
"run_id": self._config.run_id,
1040-
"project": self._config.project,
1041-
"suite": self._config.suite,
1042-
"method": self._config.method,
1043-
"seed": self._config.seed,
1044-
"target": self._config.target,
1045-
"recorded_at": datetime.now(tz=timezone.utc).isoformat(),
1046-
}
1047-
if extra:
1048-
summary.update(extra)
1049-
path = metrics_dir / "summary_metrics.json"
1050-
path.write_text(json.dumps(summary, indent=2, default=str))
1051-
return path
1018+
"""Write `metrics/summary_metrics.json` (see :mod:`aet.tracking.reports`)."""
1019+
c = self._config
1020+
return reports.write_summary_metrics(
1021+
c.run_path, run_id=c.run_id, project=c.project, suite=c.suite, method=c.method,
1022+
seed=c.seed, target=c.target, extra=extra)
10521023

10531024
def write_eval_report(
10541025
self,
@@ -1058,25 +1029,10 @@ def write_eval_report(
10581029
coverage: list[dict] | None = None,
10591030
extra: dict | None = None,
10601031
) -> Path:
1061-
"""Write eval_report.json: per-test, per-contract, per-assertion, coverage."""
1062-
from datetime import datetime, timezone
1063-
run_path = self._config.run_path
1064-
run_path.mkdir(parents=True, exist_ok=True)
1065-
import json
1066-
report: dict = {
1067-
"schema_version": "1.0",
1068-
"run_id": self._config.run_id,
1069-
"generated_at": datetime.now(tz=timezone.utc).isoformat(),
1070-
"tests": tests,
1071-
"contracts": contracts or [],
1072-
"assertions": assertions or [],
1073-
"coverage": coverage or [],
1074-
}
1075-
if extra:
1076-
report.update(extra)
1077-
path = run_path / "eval_report.json"
1078-
path.write_text(json.dumps(report, indent=2, default=str))
1079-
return path
1032+
"""Write `eval_report.json` (see :mod:`aet.tracking.reports`)."""
1033+
return reports.write_eval_report(
1034+
self._config.run_path, run_id=self._config.run_id, tests=tests, contracts=contracts,
1035+
assertions=assertions, coverage=coverage, extra=extra)
10801036

10811037
def write_metrics_structured(
10821038
self,
@@ -1085,24 +1041,10 @@ def write_metrics_structured(
10851041
process: dict,
10861042
extra: dict | None = None,
10871043
) -> Path:
1088-
"""Write metrics.json with cost/quality/process structure per spec."""
1089-
from datetime import datetime, timezone
1090-
run_path = self._config.run_path
1091-
run_path.mkdir(parents=True, exist_ok=True)
1092-
import json
1093-
metrics: dict = {
1094-
"schema_version": "1.0",
1095-
"run_id": self._config.run_id,
1096-
"generated_at": datetime.now(tz=timezone.utc).isoformat(),
1097-
"cost": cost,
1098-
"quality": quality,
1099-
"process": process,
1100-
}
1101-
if extra:
1102-
metrics.update(extra)
1103-
path = run_path / "metrics.json"
1104-
path.write_text(json.dumps(metrics, indent=2, default=str))
1105-
return path
1044+
"""Write structured `metrics.json` (see :mod:`aet.tracking.reports`)."""
1045+
return reports.write_metrics_structured(
1046+
self._config.run_path, run_id=self._config.run_id, cost=cost, quality=quality,
1047+
process=process, extra=extra)
11061048

11071049
# ------------------------------------------------------------------
11081050
def finish(self, status: str, message: str | None = None) -> None:

tests/test_reports.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Run-report writers (aet.tracking.reports) — the report shapes, tested in isolation."""
2+
import json
3+
4+
from aet.tracking import reports
5+
6+
7+
def test_run_record_shape(tmp_path):
8+
p = reports.write_run_record(tmp_path, run_id="r1", project="proj", suite="s", target="t",
9+
method="m", seed=3, mode="local", extra={"repo_sha": "abc"})
10+
assert p == tmp_path / "run_record.json"
11+
d = json.loads(p.read_text())
12+
assert d["run_id"] == "r1" and d["seed"] == 3 and d["tracking_mode"] == "local"
13+
assert d["repo_sha"] == "abc" and "created_at" in d
14+
15+
16+
def test_summary_metrics_goes_under_metrics(tmp_path):
17+
p = reports.write_summary_metrics(tmp_path, run_id="r1", project="p", suite="s", method="m",
18+
seed=0, target="t", extra={"hw.functional_pass": 1})
19+
assert p == tmp_path / "metrics" / "summary_metrics.json"
20+
assert json.loads(p.read_text())["hw.functional_pass"] == 1
21+
22+
23+
def test_eval_report_defaults_empty_lists(tmp_path):
24+
p = reports.write_eval_report(tmp_path, run_id="r1", tests=[{"test": "t", "passed": True}])
25+
d = json.loads(p.read_text())
26+
assert d["tests"][0]["passed"] is True
27+
assert d["contracts"] == [] and d["assertions"] == [] and d["coverage"] == []
28+
29+
30+
def test_metrics_structured_sections(tmp_path):
31+
p = reports.write_metrics_structured(tmp_path, run_id="r1", cost={"usd": 1.0},
32+
quality={"pass": True}, process={"iters": 2})
33+
d = json.loads(p.read_text())
34+
assert d["cost"]["usd"] == 1.0 and d["quality"]["pass"] is True and d["process"]["iters"] == 2

0 commit comments

Comments
 (0)