dfm-agent 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. dfm/__init__.py +10 -0
  2. dfm/benchmark/__init__.py +25 -0
  3. dfm/benchmark/runner.py +379 -0
  4. dfm/benchmark/tasks.py +385 -0
  5. dfm/ci/__init__.py +14 -0
  6. dfm/ci/logs.py +77 -0
  7. dfm/ci/workflows.py +123 -0
  8. dfm/cli/__init__.py +1 -0
  9. dfm/cli/app.py +117 -0
  10. dfm/cli/ask_cmd.py +64 -0
  11. dfm/cli/benchmark_cmd.py +104 -0
  12. dfm/cli/ci_cmd.py +64 -0
  13. dfm/cli/config_cmd.py +77 -0
  14. dfm/cli/diff_cmd.py +49 -0
  15. dfm/cli/doctor_cmd.py +44 -0
  16. dfm/cli/github_cmd.py +255 -0
  17. dfm/cli/logs_cmd.py +95 -0
  18. dfm/cli/memory_cmd.py +79 -0
  19. dfm/cli/models_cmd.py +184 -0
  20. dfm/cli/processes_cmd.py +69 -0
  21. dfm/cli/repo_cmd.py +104 -0
  22. dfm/cli/review_cmd.py +130 -0
  23. dfm/cli/services_cmd.py +89 -0
  24. dfm/cli/sessions_cmd.py +78 -0
  25. dfm/cli/setup_cmd.py +46 -0
  26. dfm/cli/status_cmd.py +116 -0
  27. dfm/cli/work_cmd.py +160 -0
  28. dfm/config/__init__.py +17 -0
  29. dfm/config/loader.py +235 -0
  30. dfm/config/model.py +119 -0
  31. dfm/config/paths.py +90 -0
  32. dfm/config/writer.py +77 -0
  33. dfm/context/__init__.py +12 -0
  34. dfm/context/engine.py +400 -0
  35. dfm/docsgen.py +172 -0
  36. dfm/doctor/__init__.py +5 -0
  37. dfm/doctor/checks.py +385 -0
  38. dfm/execution/__init__.py +19 -0
  39. dfm/execution/environment.py +274 -0
  40. dfm/integrations/__init__.py +1 -0
  41. dfm/integrations/base.py +11 -0
  42. dfm/integrations/github/__init__.py +25 -0
  43. dfm/integrations/github/client.py +412 -0
  44. dfm/integrations/reflex/__init__.py +5 -0
  45. dfm/integrations/reflex/provider.py +221 -0
  46. dfm/integrations/repocanon/__init__.py +33 -0
  47. dfm/integrations/repocanon/provider.py +423 -0
  48. dfm/integrations/repokg/__init__.py +37 -0
  49. dfm/integrations/repokg/provider.py +314 -0
  50. dfm/integrations/repokg/structure.py +167 -0
  51. dfm/lsp/__init__.py +5 -0
  52. dfm/lsp/client.py +527 -0
  53. dfm/models/__init__.py +33 -0
  54. dfm/models/apple/__init__.py +5 -0
  55. dfm/models/apple/fm.py +248 -0
  56. dfm/models/base.py +127 -0
  57. dfm/models/mlx/__init__.py +6 -0
  58. dfm/models/mlx/discovery.py +225 -0
  59. dfm/models/mlx/evaluate.py +125 -0
  60. dfm/models/mlx/model.py +377 -0
  61. dfm/models/mlx/text.py +210 -0
  62. dfm/models/mlx/worker.py +519 -0
  63. dfm/models/pool.py +170 -0
  64. dfm/models/resources.py +238 -0
  65. dfm/models/schema.py +78 -0
  66. dfm/models/structured.py +113 -0
  67. dfm/models/tasks.py +65 -0
  68. dfm/models/thermal.py +58 -0
  69. dfm/observability/__init__.py +6 -0
  70. dfm/observability/logs.py +55 -0
  71. dfm/observability/metrics.py +160 -0
  72. dfm/orchestration/__init__.py +1 -0
  73. dfm/orchestration/agent.py +887 -0
  74. dfm/orchestration/ask.py +96 -0
  75. dfm/orchestration/ci.py +183 -0
  76. dfm/orchestration/github.py +340 -0
  77. dfm/orchestration/implement.py +117 -0
  78. dfm/orchestration/plan.py +243 -0
  79. dfm/orchestration/resume.py +150 -0
  80. dfm/orchestration/review.py +541 -0
  81. dfm/orchestration/runtime.py +246 -0
  82. dfm/orchestration/security_review.py +422 -0
  83. dfm/orchestration/setup.py +214 -0
  84. dfm/policy/__init__.py +35 -0
  85. dfm/policy/engine.py +273 -0
  86. dfm/policy/network.py +211 -0
  87. dfm/policy/types.py +104 -0
  88. dfm/processes/__init__.py +35 -0
  89. dfm/processes/manager.py +613 -0
  90. dfm/provenance/__init__.py +5 -0
  91. dfm/provenance/item.py +140 -0
  92. dfm/py.typed +0 -0
  93. dfm/recovery/__init__.py +5 -0
  94. dfm/recovery/checkpoint.py +198 -0
  95. dfm/recovery/reconcile.py +97 -0
  96. dfm/repository/__init__.py +1 -0
  97. dfm/repository/freshness.py +220 -0
  98. dfm/repository/intelligence.py +123 -0
  99. dfm/routing/__init__.py +23 -0
  100. dfm/routing/evidence.py +111 -0
  101. dfm/routing/router.py +288 -0
  102. dfm/routing/routes.py +13 -0
  103. dfm/secrets/__init__.py +29 -0
  104. dfm/secrets/redact.py +422 -0
  105. dfm/security/__init__.py +1 -0
  106. dfm/security/checklists/cairo.toml +17 -0
  107. dfm/security/checklists/ci.toml +41 -0
  108. dfm/security/checklists/generic.toml +43 -0
  109. dfm/security/checklists/go.toml +41 -0
  110. dfm/security/checklists/infra.toml +49 -0
  111. dfm/security/checklists/javascript.toml +82 -0
  112. dfm/security/checklists/python.toml +82 -0
  113. dfm/security/checklists/rust.toml +26 -0
  114. dfm/security/checklists/solidity.toml +49 -0
  115. dfm/security/injection.py +34 -0
  116. dfm/security/sandbox.py +169 -0
  117. dfm/services/__init__.py +14 -0
  118. dfm/services/catalog.py +174 -0
  119. dfm/services/manager.py +361 -0
  120. dfm/sessions/__init__.py +29 -0
  121. dfm/sessions/protocol.py +183 -0
  122. dfm/sessions/store.py +309 -0
  123. dfm/tools/__init__.py +1 -0
  124. dfm/tools/blockchain/__init__.py +19 -0
  125. dfm/tools/blockchain/chains.py +188 -0
  126. dfm/tools/docker/__init__.py +5 -0
  127. dfm/tools/docker/engine.py +158 -0
  128. dfm/tools/errors.py +5 -0
  129. dfm/tools/filesystem/__init__.py +35 -0
  130. dfm/tools/filesystem/kinds.py +209 -0
  131. dfm/tools/filesystem/patching.py +261 -0
  132. dfm/tools/filesystem/tools.py +448 -0
  133. dfm/tools/generated.py +231 -0
  134. dfm/tools/git/__init__.py +28 -0
  135. dfm/tools/git/guard.py +212 -0
  136. dfm/tools/git/hardening.py +30 -0
  137. dfm/tools/git/state.py +83 -0
  138. dfm/tools/git/tools.py +277 -0
  139. dfm/tools/git/worktree.py +120 -0
  140. dfm/tools/github/__init__.py +1 -0
  141. dfm/tools/kubernetes/__init__.py +5 -0
  142. dfm/tools/kubernetes/context.py +72 -0
  143. dfm/tools/languages/__init__.py +53 -0
  144. dfm/tools/languages/base.py +131 -0
  145. dfm/tools/languages/cairo.py +146 -0
  146. dfm/tools/languages/go.py +155 -0
  147. dfm/tools/languages/python.py +237 -0
  148. dfm/tools/languages/rust.py +157 -0
  149. dfm/tools/languages/solidity.py +156 -0
  150. dfm/tools/languages/swift.py +148 -0
  151. dfm/tools/languages/typescript.py +275 -0
  152. dfm/tools/logs.py +366 -0
  153. dfm/tools/packages.py +360 -0
  154. dfm/tools/results.py +945 -0
  155. dfm/tools/shell/__init__.py +1 -0
  156. dfm/tools/shell/classify.py +308 -0
  157. dfm/tools/shell/rules.py +2518 -0
  158. dfm/tools/shell/runner.py +170 -0
  159. dfm_agent-0.0.1.dist-info/METADATA +112 -0
  160. dfm_agent-0.0.1.dist-info/RECORD +163 -0
  161. dfm_agent-0.0.1.dist-info/WHEEL +4 -0
  162. dfm_agent-0.0.1.dist-info/entry_points.txt +2 -0
  163. dfm_agent-0.0.1.dist-info/licenses/LICENSE +21 -0
dfm/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """DFM — Developer Foundation Model: a local, privacy-first engineering agent."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("dfm-agent") # the distribution; the package and command are `dfm`
7
+ except PackageNotFoundError: # running from a source tree without an install
8
+ __version__ = "0.0.0+unknown"
9
+
10
+ __all__ = ["__version__"]
@@ -0,0 +1,25 @@
1
+ """`dfm benchmark`: ablation benchmarks over fixture tasks."""
2
+
3
+ from dfm.benchmark.runner import (
4
+ ABLATIONS,
5
+ ReferenceCoder,
6
+ RunResult,
7
+ run_suite,
8
+ run_task,
9
+ table,
10
+ write_results,
11
+ )
12
+ from dfm.benchmark.tasks import BY_ID, TASKS, BenchTask
13
+
14
+ __all__ = [
15
+ "ABLATIONS",
16
+ "BY_ID",
17
+ "TASKS",
18
+ "BenchTask",
19
+ "ReferenceCoder",
20
+ "RunResult",
21
+ "run_suite",
22
+ "run_task",
23
+ "table",
24
+ "write_results",
25
+ ]
@@ -0,0 +1,379 @@
1
+ """Run benchmark tasks under ablations and report.
2
+
3
+ for each task, ablation and repeat:
4
+ fresh git copy of the fixture → its check must FAIL first (else the task is invalid)
5
+ → DFM runs offline with the ablation's integrations switched off
6
+ → the task's own check runs again, sandboxed (the model's edits are untrusted code)
7
+ → score: success = check passes; unnecessary edits = changed lines outside the
8
+ expected files; plus DFM's session metrics (latency, tokens, context, memory, tools)
9
+
10
+ Results are JSON plus a markdown comparison table. Nothing uses the network.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import hashlib
17
+ import json
18
+ import shutil
19
+ import subprocess
20
+ import time
21
+ from collections.abc import AsyncIterator, Callable, Mapping, Sequence
22
+ from dataclasses import asdict, dataclass, field
23
+ from pathlib import Path
24
+ from typing import Any, cast
25
+
26
+ from dfm.benchmark.tasks import TASKS, BenchTask
27
+ from dfm.config import ConfigOverrides, DFMConfig, state_dirs
28
+ from dfm.execution import ExecutionEnvironment
29
+ from dfm.models.base import GenerateRequest, GenerateResult, Model, ModelHealth
30
+ from dfm.observability import load
31
+ from dfm.orchestration.ci import ci
32
+ from dfm.orchestration.implement import ImplementOptions, implement
33
+ from dfm.orchestration.runtime import Runtime
34
+ from dfm.policy import Policy
35
+ from dfm.processes import ProcessManager, ProcessRegistry
36
+ from dfm.security.sandbox import Sandbox
37
+ from dfm.tools.errors import ToolError
38
+ from dfm.tools.shell.runner import CommandRunner
39
+
40
+ _OFF = {
41
+ "integrations.repocanon.enabled": False,
42
+ "integrations.repokg.enabled": False,
43
+ "integrations.reflex.enabled": False,
44
+ "models.apple.enabled": False,
45
+ }
46
+ ABLATIONS: dict[str, dict[str, Any]] = {
47
+ "coder": dict(_OFF),
48
+ "+canon": {**_OFF, "integrations.repocanon.enabled": True},
49
+ "+kg": {**_OFF, "integrations.repocanon.enabled": True, "integrations.repokg.enabled": True},
50
+ "+reflex": {
51
+ **_OFF,
52
+ "integrations.repocanon.enabled": True,
53
+ "integrations.repokg.enabled": True,
54
+ "integrations.reflex.enabled": True,
55
+ },
56
+ "+apple": {}, # every integration on (as configured), Apple FM routing included
57
+ "full": {}, # the developer's configuration as it is
58
+ }
59
+
60
+
61
+ _NO_CODER = ("coding model failed", "no coding model")
62
+ _CANNOT_RUN = (
63
+ "command not found",
64
+ "could not choose a version",
65
+ "No such file or directory: '",
66
+ "check refused:",
67
+ )
68
+
69
+
70
+ class ReferenceCoder:
71
+ """Replays a task's reference solution: tests the harness and the fixtures, not a model."""
72
+
73
+ name = "reference"
74
+
75
+ def __init__(self, actions: Sequence[Mapping[str, Any]]) -> None:
76
+ self.actions = [dict(a) for a in actions]
77
+
78
+ async def healthcheck(self) -> ModelHealth:
79
+ return ModelHealth(self.name, True, "replay")
80
+
81
+ async def generate(self, request: GenerateRequest) -> GenerateResult:
82
+ step = self.actions.pop(0) if len(self.actions) > 1 else self.actions[0]
83
+ return GenerateResult(json.dumps(step), self.name, 0)
84
+
85
+ async def stream(self, request: GenerateRequest) -> AsyncIterator[str]:
86
+ yield ""
87
+
88
+
89
+ @dataclass
90
+ class RunResult:
91
+ task: str
92
+ ablation: str
93
+ repeat: int
94
+ status: str # pass | fail | skipped | invalid | error
95
+ detail: str = ""
96
+ dfm_status: str | None = None
97
+ changed_files: list[str] = field(default_factory=lambda: [])
98
+ expected_touched: bool = False
99
+ unnecessary_lines: int = 0
100
+ wall_ms: float = 0.0
101
+ tokens_in: int = 0
102
+ tokens_out: int = 0
103
+ context_chars: int = 0
104
+ memory_peak_mb: float = 0.0
105
+ tool_calls: int = 0
106
+ repairs: int = 0
107
+ steps: int = 0
108
+ models: list[str] = field(default_factory=lambda: []) # models that actually answered
109
+ seed: int = 0 # sampling seed: rerun a cell with the same seed to replay it
110
+
111
+
112
+ def _git(repo: Path, *args: str) -> str:
113
+ return subprocess.run( # noqa: S603
114
+ [ # noqa: S607
115
+ "git",
116
+ "-c",
117
+ "user.email=bench@dfm.invalid",
118
+ "-c",
119
+ "user.name=DFM bench",
120
+ "-c",
121
+ "commit.gpgsign=false",
122
+ *args,
123
+ ],
124
+ cwd=repo,
125
+ capture_output=True,
126
+ text=True,
127
+ check=True,
128
+ timeout=60,
129
+ ).stdout
130
+
131
+
132
+ def run_seed(task: str, ablation: str, repeat: int) -> int:
133
+ """A stable seed per cell, so the same command samples the same way."""
134
+ return int.from_bytes(hashlib.sha256(f"{task}/{ablation}/{repeat}".encode()).digest()[:4])
135
+
136
+
137
+ def materialize(task: BenchTask, root: Path) -> Path:
138
+ repo = root / "repo"
139
+ if repo.exists():
140
+ shutil.rmtree(repo)
141
+ for rel, content in task.files.items():
142
+ path = repo / rel
143
+ path.parent.mkdir(parents=True, exist_ok=True)
144
+ path.write_text(content)
145
+ if task.setup is not None:
146
+ task.setup(repo)
147
+ # build outputs and lockfiles toolchains create on first run are not the model's edits
148
+ (repo / ".gitignore").write_text(
149
+ "target/\nCargo.lock\n.pytest_cache/\n__pycache__/\nnode_modules/\nout/\ncache/\n"
150
+ )
151
+ _git(repo, "init", "-q", "-b", "main")
152
+ _git(repo, "add", ".")
153
+ _git(repo, "commit", "-qm", "fixture")
154
+ return repo
155
+
156
+
157
+ async def run_check(repo: Path, argv: Sequence[str], scratch: Path) -> tuple[bool, str]:
158
+ """The task's own check, sandboxed and offline, exactly like DFM runs tests."""
159
+ env = ExecutionEnvironment.create(
160
+ repo,
161
+ DFMConfig(),
162
+ session_id=f"check-{int(time.time() * 1000)}",
163
+ overrides=ConfigOverrides(offline=True),
164
+ )
165
+ runner = CommandRunner(
166
+ env,
167
+ ProcessManager(ProcessRegistry(scratch / "procs")),
168
+ Policy(DFMConfig()),
169
+ wrap=Sandbox().wrap,
170
+ proposed_by="benchmark",
171
+ )
172
+ try:
173
+ outcome = await runner.run(list(argv), timeout=900)
174
+ finally:
175
+ env.close()
176
+ if outcome.result is None:
177
+ return False, f"check refused: {outcome.verdicts[-1].reason}"
178
+ return outcome.result.exit_code == 0, (outcome.result.stdout + outcome.result.stderr)[-1500:]
179
+
180
+
181
+ def score_diff(repo: Path, expected: frozenset[str]) -> tuple[list[str], int, bool]:
182
+ """(changed files, changed lines outside `expected`, whether an expected file changed)."""
183
+ _git(repo, "add", "-A", "--intent-to-add")
184
+ changed: list[str] = []
185
+ outside = 0
186
+ for line in _git(repo, "diff", "--numstat", "HEAD").splitlines():
187
+ added, removed, path = line.split("\t", 2)
188
+ changed.append(path)
189
+ if path not in expected:
190
+ outside += (int(added) if added.isdigit() else 0) + (
191
+ int(removed) if removed.isdigit() else 0
192
+ )
193
+ return changed, outside, bool(expected & set(changed))
194
+
195
+
196
+ async def run_task(
197
+ task: BenchTask,
198
+ ablation: str,
199
+ *,
200
+ workdir: Path,
201
+ coder: Model | None = None,
202
+ repeat: int = 1,
203
+ max_steps: int = 20,
204
+ settings: Mapping[str, Any] | None = None,
205
+ ) -> RunResult:
206
+ result = RunResult(task.id, ablation, repeat, "error")
207
+ missing = [tool for tool in task.needs if shutil.which(tool) is None]
208
+ if missing:
209
+ result.status, result.detail = "skipped", f"needs {', '.join(missing)}"
210
+ return result
211
+ root = workdir / f"{task.id}-{ablation.lstrip('+')}-{repeat}"
212
+ root.mkdir(parents=True, exist_ok=True)
213
+ repo = materialize(task, root)
214
+ passed, baseline = await run_check(repo, task.check, root)
215
+ if passed:
216
+ result.status, result.detail = "invalid", "the check passes before any change"
217
+ return result
218
+ if any(sign in baseline for sign in _CANNOT_RUN):
219
+ # failing because the toolchain cannot run is not the task's failure: nothing to measure
220
+ result.status, result.detail = (
221
+ "skipped",
222
+ f"check cannot run here: {baseline.strip()[-160:]}",
223
+ )
224
+ return result
225
+ result.seed = run_seed(task.id, ablation, repeat)
226
+ overrides = ConfigOverrides(
227
+ offline=True,
228
+ settings={**(settings or {}), **ABLATIONS[ablation], "models.seed": result.seed},
229
+ )
230
+ rt = Runtime.create(
231
+ repo,
232
+ request=task.request,
233
+ mode="ci" if task.kind == "ci" else "implement",
234
+ overrides=overrides,
235
+ )
236
+ try:
237
+ if task.kind == "ci":
238
+ log = root / "ci.log"
239
+ log.write_text(task.ci_log or "")
240
+ outcome = (await ci(rt, log_path=log, coder=coder, max_steps=max_steps)).outcome
241
+ else:
242
+ outcome = (
243
+ await implement(
244
+ rt, task.request, ImplementOptions(coder=coder, max_steps=max_steps)
245
+ )
246
+ ).outcome
247
+ except ToolError as exc:
248
+ await rt.close("failed", str(exc))
249
+ result.detail = str(exc)
250
+ return result
251
+ await rt.close(outcome.session_status, outcome.summary)
252
+ result.dfm_status = outcome.status
253
+ metrics = load(rt.dirs.data / "metrics", rt.session.id) or {}
254
+ result.wall_ms = float(metrics.get("wall_ms", 0.0))
255
+ result.tokens_in = int(metrics.get("tokens", {}).get("in", 0))
256
+ result.tokens_out = int(metrics.get("tokens", {}).get("out", 0))
257
+ result.context_chars = int(metrics.get("context", {}).get("chars", 0))
258
+ result.memory_peak_mb = float(metrics.get("memory_peak_mb", 0.0))
259
+ result.tool_calls = int(metrics.get("tool_calls", {}).get("commands", 0))
260
+ result.repairs = int(metrics.get("repairs", 0))
261
+ result.steps = int(metrics.get("steps", 0))
262
+ points = cast(list[dict[str, Any]], metrics.get("points", []))
263
+ result.models = sorted({str(p["model"]) for p in points if p.get("model")})
264
+ result.changed_files, result.unnecessary_lines, result.expected_touched = score_diff(
265
+ repo, task.expected
266
+ )
267
+ passed, tail = await run_check(repo, task.check, root)
268
+ model_failed = outcome.summary.startswith(_NO_CODER)
269
+ if not passed and not result.changed_files and model_failed and not result.models:
270
+ # the coder never ran (no model, not enough memory): that measures the machine, not DFM.
271
+ # A model that answered with unusable output did run: that is a fail.
272
+ result.status, result.detail = "error", outcome.summary[:300]
273
+ return result
274
+ result.status = "pass" if passed else "fail"
275
+ result.detail = "" if passed else (outcome.summary if model_failed else tail)[-300:]
276
+ return result
277
+
278
+
279
+ async def run_suite(
280
+ tasks: Sequence[BenchTask] = TASKS,
281
+ ablations: Sequence[str] = ("coder", "full"),
282
+ *,
283
+ workdir: Path | None = None,
284
+ coder: Model | None = None,
285
+ reference: bool = False,
286
+ repeat: int = 1,
287
+ max_steps: int = 20,
288
+ settings: Mapping[str, Any] | None = None,
289
+ cooldown_s: float = 0.0,
290
+ ) -> list[RunResult]:
291
+ workdir = workdir or state_dirs().data / "benchmarks" / time.strftime("%Y%m%dT%H%M%S")
292
+ results: list[RunResult] = []
293
+ for task in tasks:
294
+ for ablation in ablations:
295
+ if ablation not in ABLATIONS:
296
+ raise ValueError(f"unknown ablation {ablation!r} (known: {', '.join(ABLATIONS)})")
297
+ for n in range(1, repeat + 1):
298
+ model = ReferenceCoder(task.reference) if reference else coder
299
+ results.append(
300
+ await run_task(
301
+ task,
302
+ ablation,
303
+ workdir=workdir,
304
+ coder=model,
305
+ repeat=n,
306
+ max_steps=max_steps,
307
+ settings=settings,
308
+ )
309
+ )
310
+ if cooldown_s > 0 and results[-1].models:
311
+ # back-to-back model runs keep the GPU at full load: let the laptop cool
312
+ await asyncio.sleep(cooldown_s)
313
+ return results
314
+
315
+
316
+ def table(results: Sequence[RunResult]) -> str:
317
+ """A markdown comparison: tasks by ablations, then per-ablation aggregates."""
318
+ ablations = list(dict.fromkeys(r.ablation for r in results))
319
+ tasks = list(dict.fromkeys(r.task for r in results))
320
+ cell: dict[tuple[str, str], list[RunResult]] = {}
321
+ for r in results:
322
+ cell.setdefault((r.task, r.ablation), []).append(r)
323
+ lines = ["| task | " + " | ".join(ablations) + " |", "|---|" + "---|" * len(ablations)]
324
+ for task in tasks:
325
+ row: list[str] = []
326
+ for ablation in ablations:
327
+ runs = cell.get((task, ablation), [])
328
+ if not runs or all(r.status == "skipped" for r in runs):
329
+ row.append("skip")
330
+ continue
331
+ if all(r.status in {"error", "invalid"} for r in runs):
332
+ row.append("err")
333
+ continue
334
+ ok = sum(r.status == "pass" for r in runs)
335
+ secs = sum(r.wall_ms for r in runs) / len(runs) / 1000
336
+ row.append(
337
+ f"{'✓' if ok == len(runs) else '✗' if ok == 0 else '~'} {ok}/{len(runs)} {secs:.0f}s"
338
+ )
339
+ lines.append(f"| {task} | " + " | ".join(row) + " |")
340
+ groups: list[list[RunResult]] = [
341
+ [r for r in results if r.ablation == ablation and r.status in {"pass", "fail"}]
342
+ for ablation in ablations
343
+ ]
344
+
345
+ def summary(label: str, fn: Callable[[list[RunResult]], str]) -> str:
346
+ return f"| **{label}** | " + " | ".join(fn(runs) if runs else "-" for runs in groups) + " |"
347
+
348
+ lines.append(summary("success", lambda rs: f"{sum(r.status == 'pass' for r in rs)}/{len(rs)}"))
349
+ lines.append(
350
+ summary("mean wall", lambda rs: f"{sum(r.wall_ms for r in rs) / len(rs) / 1000:.1f}s")
351
+ )
352
+ lines.append(
353
+ summary(
354
+ "tokens in/out",
355
+ lambda rs: f"{sum(r.tokens_in for r in rs)}/{sum(r.tokens_out for r in rs)}",
356
+ )
357
+ )
358
+ lines.append(
359
+ summary("context chars", lambda rs: f"{sum(r.context_chars for r in rs) // len(rs)}")
360
+ )
361
+ lines.append(summary("unnecessary lines", lambda rs: str(sum(r.unnecessary_lines for r in rs))))
362
+ lines.append(summary("repairs", lambda rs: str(sum(r.repairs for r in rs))))
363
+ lines.append(summary("peak MB", lambda rs: f"{max(r.memory_peak_mb for r in rs):.0f}"))
364
+ lines.append(
365
+ summary("models", lambda rs: ", ".join(sorted({m for r in rs for m in r.models})) or "-")
366
+ )
367
+ return "\n".join(lines)
368
+
369
+
370
+ def write_results(
371
+ results: Sequence[RunResult], out: Path, meta: Mapping[str, Any] | None = None
372
+ ) -> tuple[Path, Path]:
373
+ out.mkdir(parents=True, exist_ok=True)
374
+ data = {"meta": dict(meta or {}), "results": [asdict(r) for r in results]}
375
+ json_path = out / "results.json"
376
+ json_path.write_text(json.dumps(data, indent=1))
377
+ md_path = out / "results.md"
378
+ md_path.write_text(table(results) + "\n")
379
+ return json_path, md_path