mjolnir-cli 0.2.0__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 (80) hide show
  1. mjolnir_cli/__init__.py +8 -0
  2. mjolnir_cli/__main__.py +4 -0
  3. mjolnir_cli/accuracy.py +333 -0
  4. mjolnir_cli/autoresearch.py +539 -0
  5. mjolnir_cli/benchy.py +475 -0
  6. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4/config.yaml +78 -0
  7. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DFLASH2/config.yaml +103 -0
  8. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DSPARK/config.yaml +102 -0
  9. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA2/config.yaml +84 -0
  10. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA4hd256/config.yaml +104 -0
  11. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR/config.yaml +86 -0
  12. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3/config.yaml +93 -0
  13. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DFLASH2/config.yaml +123 -0
  14. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DSPARK/config.yaml +124 -0
  15. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/.env +2 -0
  16. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/config.yaml +98 -0
  17. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/.env +4 -0
  18. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/config.yaml +98 -0
  19. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/.env +4 -0
  20. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/config.yaml +102 -0
  21. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/.env +3 -0
  22. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/config.yaml +98 -0
  23. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/draft_vocab.json +1 -0
  24. mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/kv_scales.json +35197 -0
  25. mjolnir_cli/bundled/configs/Qwen/Qwen3.8_chat_template.jinja +456 -0
  26. mjolnir_cli/bundled/docker/litellm/docker-compose.yaml +113 -0
  27. mjolnir_cli/bundled/docker/litellm/litellm.env.example +51 -0
  28. mjolnir_cli/bundled/docker/litellm/litellm_config.template.example.yaml +192 -0
  29. mjolnir_cli/bundled/docker/perses/README.md +89 -0
  30. mjolnir_cli/bundled/docker/perses/dashboards/global-datasource.yaml +17 -0
  31. mjolnir_cli/bundled/docker/perses/dashboards/performance_statistics.yaml +771 -0
  32. mjolnir_cli/bundled/docker/perses/dashboards/project.yaml +13 -0
  33. mjolnir_cli/bundled/docker/perses/dashboards/query_statistics.yaml +399 -0
  34. mjolnir_cli/bundled/docker/perses/docker-compose.yaml +51 -0
  35. mjolnir_cli/bundled/docker/perses/perses.yml +16 -0
  36. mjolnir_cli/bundled/docker/perses/prometheus.template.yaml +20 -0
  37. mjolnir_cli/bundled/scripts/hw/setup-thor.sh +1287 -0
  38. mjolnir_cli/cachectl.py +146 -0
  39. mjolnir_cli/charts.py +1687 -0
  40. mjolnir_cli/cli.py +1480 -0
  41. mjolnir_cli/clidoc.py +170 -0
  42. mjolnir_cli/config.py +346 -0
  43. mjolnir_cli/dockerctl.py +533 -0
  44. mjolnir_cli/drivers/fa2_driver.py +89 -0
  45. mjolnir_cli/drivers/fi_driver.py +134 -0
  46. mjolnir_cli/e2e.py +302 -0
  47. mjolnir_cli/fonts/InterDisplay-Bold.ttf +0 -0
  48. mjolnir_cli/fonts/InterDisplay-Medium.ttf +0 -0
  49. mjolnir_cli/fonts/InterDisplay-Regular.ttf +0 -0
  50. mjolnir_cli/fonts/InterDisplay-SemiBold.ttf +0 -0
  51. mjolnir_cli/fonts/OFL.txt +92 -0
  52. mjolnir_cli/gate.py +233 -0
  53. mjolnir_cli/history.py +159 -0
  54. mjolnir_cli/interactive.py +383 -0
  55. mjolnir_cli/legs.py +109 -0
  56. mjolnir_cli/litellm.py +254 -0
  57. mjolnir_cli/perses.py +214 -0
  58. mjolnir_cli/picker.py +70 -0
  59. mjolnir_cli/readme.py +1020 -0
  60. mjolnir_cli/stack.py +340 -0
  61. mjolnir_cli/tasks.py +122 -0
  62. mjolnir_cli/theme.py +281 -0
  63. mjolnir_cli/thor.py +108 -0
  64. mjolnir_cli/verify/__init__.py +2 -0
  65. mjolnir_cli/verify/cli.py +816 -0
  66. mjolnir_cli/verify/docker.py +334 -0
  67. mjolnir_cli/verify/kvcache.py +180 -0
  68. mjolnir_cli/verify/legs.py +134 -0
  69. mjolnir_cli/verify/ncu.py +141 -0
  70. mjolnir_cli/verify/profile.py +312 -0
  71. mjolnir_cli/verify/report.py +177 -0
  72. mjolnir_cli/verify/results.py +157 -0
  73. mjolnir_cli/verify/spec.py +279 -0
  74. mjolnir_cli/verify/stages.py +817 -0
  75. mjolnir_cli-0.2.0.dist-info/METADATA +87 -0
  76. mjolnir_cli-0.2.0.dist-info/RECORD +80 -0
  77. mjolnir_cli-0.2.0.dist-info/WHEEL +4 -0
  78. mjolnir_cli-0.2.0.dist-info/entry_points.txt +2 -0
  79. mjolnir_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
  80. mjolnir_cli-0.2.0.dist-info/licenses/NOTICE +64 -0
@@ -0,0 +1,539 @@
1
+ """``mjolnir autoresearch`` — an autonomous kernel-research loop with a ledger.
2
+
3
+ After karpathy/autoresearch: an agent edits ONE file, runs a FIXED,
4
+ time-boxed evaluation, keeps the change if the metric improved and resets it
5
+ otherwise — forever, while every experiment lands on a ledger. Here:
6
+
7
+ * the editable file — ``src/mjolnir/mjolnir/t5_decode.py`` (the kernel);
8
+ * the fixed harness — ``mjolnir autoresearch evaluate``: the quick
9
+ correctness subset (``mjolnir verify correctness --quick``) + an ncu sweep
10
+ of FlashInfer vs Mjolnir (harness default) at every L;
11
+ * the metric — per L, FlashInfer time / Mjolnir time, both measured
12
+ in the SAME run (drift on the shared Thor cancels out); the score is their
13
+ geomean over L;
14
+ * the decision — made by this module, not the agent (:func:`judge`),
15
+ on the per-L ratios against the best kept row, not only on the geomean:
16
+ ``keep`` needs a geomean gain > ``--min-gain`` (2%) that does not rest on a
17
+ single L and no more than one L slower than the noise band; L where the
18
+ FlashInfer reference itself jumped are set aside; ``noisy`` when the
19
+ reference was unstable at too many L (re-evaluate the same commit),
20
+ ``crash`` if a stage failed to produce data.
21
+ * the ledger — ``research/ledger.jsonl`` (+ a ``ledger.tsv`` view):
22
+ one row per experiment with the hypothesis, the agents involved, the
23
+ sources (URLs / arXiv ids), the commit, the per-L numbers and the verify
24
+ run ids. Untracked while the loop runs (a discard is a ``git reset
25
+ --hard``, which must not rewind the notebook); ``mjolnir autoresearch
26
+ export`` snapshots it into ``research/runs/<tag>/`` for committing.
27
+
28
+ The loop itself is an OpenCode session (``mjolnir autoresearch run``)
29
+ driving ``.opencode/agent/autoresearch.md`` (the orchestrator; the helpers it delegates to are user-defined OpenCode agents)
30
+ → ``web-researcher`` / ``arxiv-researcher`` (ideas + sources) →
31
+ ``python-engineer`` (the change) → ``debugger`` (crashes, correctness
32
+ failures). The program they follow is ``research/program.md``.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import datetime as _dt
38
+ import json
39
+ import shutil
40
+ import subprocess
41
+ from pathlib import Path
42
+ from typing import Optional
43
+
44
+ import typer
45
+ from rich import box
46
+ from rich.console import Console
47
+ from rich.table import Table
48
+
49
+ from mjolnir_cli.config import load_layout
50
+ from mjolnir_cli.legs import KERNEL
51
+ from mjolnir_cli.verify import results
52
+
53
+ console = Console()
54
+ app = typer.Typer(
55
+ help="Autonomous kernel research: OpenCode agents propose, implement and test kernel changes; "
56
+ "every experiment is scored by a fixed harness and recorded on the ledger.",
57
+ no_args_is_help=True,
58
+ context_settings={"help_option_names": ["-h", "--help"]},
59
+ )
60
+
61
+ KERNEL_FILE = Path("src/mjolnir/mjolnir/t5_decode.py")
62
+ LEDGER_DIR = Path("research")
63
+ TSV_COLUMNS = ["id", "ts", "commit", "status", "score", "best_before", "correctness", "agent", "hypothesis", "reason"]
64
+
65
+
66
+ # ── ledger ──────────────────────────────────────────────────────────────────
67
+
68
+
69
+ def ledger_path(root: Path) -> Path:
70
+ return root / LEDGER_DIR / "ledger.jsonl"
71
+
72
+
73
+ def load_ledger(root: Path) -> list[dict]:
74
+ p = ledger_path(root)
75
+ if not p.exists():
76
+ return []
77
+ return [json.loads(line) for line in p.read_text().splitlines() if line.strip()]
78
+
79
+
80
+ def best_kept(rows: list[dict]) -> dict | None:
81
+ kept = [r for r in rows if r.get("status") in ("keep", "baseline") and r.get("score")]
82
+ return max(kept, key=lambda r: r["score"]) if kept else None
83
+
84
+
85
+ def append_ledger(root: Path, row: dict) -> None:
86
+ d = root / LEDGER_DIR
87
+ d.mkdir(parents=True, exist_ok=True)
88
+ with (d / "ledger.jsonl").open("a") as f:
89
+ f.write(json.dumps(row, sort_keys=False) + "\n")
90
+ tsv = d / "ledger.tsv"
91
+ new = not tsv.exists()
92
+ with tsv.open("a") as f:
93
+ if new:
94
+ f.write("\t".join(TSV_COLUMNS) + "\n")
95
+ vals = []
96
+ for c in TSV_COLUMNS:
97
+ v = row.get(c, "")
98
+ if isinstance(v, float):
99
+ v = f"{v:.4f}"
100
+ vals.append(str(v).replace("\t", " ").replace("\n", " "))
101
+ f.write("\t".join(vals) + "\n")
102
+
103
+
104
+ def decide(score: float | None, correct: bool, best: float | None, min_gain: float) -> str:
105
+ """The fixed keep/discard rule (the agent never grades its own work)."""
106
+ if score is None:
107
+ return "crash"
108
+ if not correct:
109
+ return "discard"
110
+ if best is None:
111
+ return "baseline"
112
+ return "keep" if score > best * (1 + min_gain) else "discard"
113
+
114
+
115
+ NOISE = 0.03 # a same-run ratio moves ±3% between runs (verify.stages.NOISE_PCT)
116
+ REF_JUMP = 0.15 # the FlashInfer leg moved more than this vs the best row's run: that L is suspect
117
+ MAX_SUSPECT = 2 # more suspect L than this and the measurement itself is rejected ("noisy")
118
+
119
+
120
+ def _gm(xs) -> float:
121
+ xs = list(xs)
122
+ return results.geomean(xs) if xs else 1.0
123
+
124
+
125
+ def _pct(x: float) -> str:
126
+ return f"{(x - 1) * 100:+.1f}%"
127
+
128
+
129
+ def _L(k) -> str:
130
+ k = int(k)
131
+ return f"{k // 1024}K" if k >= 1024 and k % 1024 == 0 else str(k)
132
+
133
+
134
+ def judge(row: dict, best: dict | None, min_gain: float = 0.02, noise: float = NOISE) -> tuple[str, str, dict]:
135
+ """The fixed keep/discard rule, per L. Returns (status, reason, rel) where
136
+ ``rel[L]`` = this row's FlashInfer÷Mjolnir ratio ÷ the best kept row's.
137
+
138
+ A single global geomean can be moved by one L — a FlashInfer outlier at
139
+ 256K once turned a no-op into "+8%" — so a keep needs all of:
140
+
141
+ 1. the geomean of ``rel`` over the trusted L beats ``1 + min_gain``;
142
+ 2. it still beats ``1 + min_gain / 2`` with the single best L left out
143
+ (the win does not rest on one context length);
144
+ 3. at most one L is slower than ``1 - noise`` (one is tolerated: 16K is
145
+ bimodal run to run).
146
+
147
+ An L is *suspect* — set aside — when the FlashInfer reference time moved
148
+ by more than ``REF_JUMP`` against the best row's run (the reference never
149
+ changes between experiments, so a jump there is noise in the measurement,
150
+ not a property of the kernel). More than ``MAX_SUSPECT`` suspect L and the
151
+ run is ``noisy``: nothing is decided, evaluate the same commit again.
152
+ """
153
+ score, correct = row.get("score"), row.get("correctness") == "pass"
154
+ if score is None:
155
+ return "crash", "no score (a stage failed)", {}
156
+ if not correct:
157
+ return "discard", "correctness failed", {}
158
+ if best is None:
159
+ return "baseline", "the starting kernel", {}
160
+ new, old = row.get("per_L") or {}, best.get("per_L") or {}
161
+ common = [L for L in new if L in old and old[L]]
162
+ if not common: # rows written before per-L judging: the plain geomean rule
163
+ st = decide(score, correct, best.get("score"), min_gain)
164
+ return st, f"geomean {_pct(score / best['score'])} (no per-L reference)", {}
165
+ rel = {L: new[L] / old[L] for L in common}
166
+ fi_new, fi_old = row.get("fi_us") or {}, best.get("fi_us") or {}
167
+ suspect = [L for L in common if L in fi_new and fi_old.get(L) and abs(fi_new[L] / fi_old[L] - 1) > REF_JUMP]
168
+ if len(suspect) > MAX_SUSPECT:
169
+ return "noisy", "FlashInfer reference unstable at " + ", ".join(_L(L) for L in suspect) + " — re-evaluate", rel
170
+ trusted = {L: v for L, v in rel.items() if L not in suspect}
171
+ note = f" (set aside, reference jumped: {', '.join(_L(L) for L in suspect)})" if suspect else ""
172
+ gain = _gm(trusted.values())
173
+ if gain <= 1 + min_gain:
174
+ return "discard", f"geomean {_pct(gain)} ≤ +{min_gain * 100:g}%{note}", rel
175
+ top = max(trusted, key=trusted.get)
176
+ loo = _gm(v for L, v in trusted.items() if L != top)
177
+ if loo <= 1 + min_gain / 2:
178
+ return (
179
+ "discard",
180
+ f"geomean {_pct(gain)} rests on {_L(top)} ({_pct(trusted[top])}): {_pct(loo)} without it{note}",
181
+ rel,
182
+ )
183
+ slower = sorted((L for L, v in trusted.items() if v < 1 - noise), key=int)
184
+ if len(slower) > 1:
185
+ return (
186
+ "discard",
187
+ f"geomean {_pct(gain)} but slower at " + ", ".join(f"{_L(L)} ({_pct(trusted[L])})" for L in slower) + note,
188
+ rel,
189
+ )
190
+ return "keep", f"geomean {_pct(gain)}, {_pct(loo)} without its best L ({_L(top)}){note}", rel
191
+
192
+
193
+ # ── git helpers ─────────────────────────────────────────────────────────────
194
+
195
+
196
+ def _git(root: Path, *args: str, check: bool = True) -> str:
197
+ p = subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True)
198
+ if check and p.returncode != 0:
199
+ raise RuntimeError(f"git {' '.join(args)}: {p.stderr.strip()}")
200
+ return p.stdout.strip()
201
+
202
+
203
+ def _root() -> Path:
204
+ return load_layout().require_repo("autoresearch")
205
+
206
+
207
+ # ── commands ────────────────────────────────────────────────────────────────
208
+
209
+
210
+ def _init(root: Path, tag: str, base: str, evaluate_baseline: bool, image: Optional[str]) -> int:
211
+ branch = f"autoresearch/{tag}"
212
+ if _git(root, "branch", "--list", branch):
213
+ console.print(f"[red]{branch} already exists — pick a fresh tag (a run never reuses a branch)[/red]")
214
+ return 4
215
+ if _git(root, "status", "--porcelain", "--", str(KERNEL_FILE)):
216
+ console.print(f"[red]{KERNEL_FILE} has uncommitted changes — commit or stash them first[/red]")
217
+ return 4
218
+ if load_ledger(root):
219
+ console.print(
220
+ "[red]research/ledger.jsonl holds a previous run — `mjolnir autoresearch export` it, then delete it[/red]"
221
+ )
222
+ return 4
223
+ _git(root, "checkout", "-b", branch, base)
224
+ console.print(f"[green]on {branch}[/green] (from {base})")
225
+ if evaluate_baseline:
226
+ return _evaluate(root, hypothesis="baseline: the starting kernel", agent="human", sources="", image=image)
227
+ return 0
228
+
229
+
230
+ @app.command()
231
+ def init(
232
+ tag: str = typer.Argument(..., help="run tag, e.g. oct11 — the branch is autoresearch/<tag>"),
233
+ base: str = typer.Option("main", "--base", help="branch to start from"),
234
+ evaluate_baseline: bool = typer.Option(True, "--baseline/--no-baseline", help="score the starting kernel now"),
235
+ image: Optional[str] = typer.Option(None, "--image"),
236
+ ):
237
+ """Create the research branch and score the starting kernel (the baseline row)."""
238
+ raise typer.Exit(_init(_root(), tag, base, evaluate_baseline, image))
239
+
240
+
241
+ def _evaluate(
242
+ root: Path,
243
+ hypothesis: str,
244
+ agent: str,
245
+ sources: str,
246
+ image: Optional[str],
247
+ min_gain: float = 0.02,
248
+ ls: Optional[str] = None,
249
+ iters: int = 50,
250
+ gate: bool = True,
251
+ skip_correctness: bool = False,
252
+ ) -> int:
253
+ from mjolnir_cli.verify.cli import PREFLIGHT_RC, execute
254
+ from mjolnir_cli.verify.stages import ALL_LS
255
+
256
+ rows = load_ledger(root)
257
+ best = best_kept(rows)
258
+ commit = _git(root, "rev-parse", "--short", "HEAD")
259
+ dirty = bool(_git(root, "status", "--porcelain", "--", str(KERNEL_FILE)))
260
+ if dirty:
261
+ console.print(
262
+ "[yellow]the kernel has uncommitted changes — commit the experiment first "
263
+ "(the ledger records commits)[/yellow]"
264
+ )
265
+ return 4
266
+ correct, corr_run = True, None
267
+ if not skip_correctness:
268
+ rc, doc = execute(
269
+ "correctness",
270
+ {"dtypes": "fp8 bf16", "match": "", "fail_fast": True, "timeout": 300.0, "quick": True},
271
+ {},
272
+ image=image,
273
+ ls=None,
274
+ iters=iters,
275
+ gate=gate,
276
+ dry_run=False,
277
+ timeout=300.0,
278
+ save=True,
279
+ plot=False,
280
+ )
281
+ if rc == PREFLIGHT_RC:
282
+ return rc # no docker / no image: an environment problem, not an experiment — no ledger row
283
+ correct = rc == 0
284
+ corr_run = doc["run_id"] if doc else None
285
+ score, per_L, sweep_run, fi_us, mj_us = None, {}, None, {}, {}
286
+ if correct:
287
+ rc, doc = execute(
288
+ "sweep",
289
+ {"legs": KERNEL["autoresearch.evaluate"]},
290
+ {}, # never a headline run
291
+ image=image,
292
+ ls=ls,
293
+ iters=iters,
294
+ gate=gate,
295
+ dry_run=False,
296
+ timeout=300.0,
297
+ save=True,
298
+ plot=False,
299
+ )
300
+ if doc:
301
+ by = results.series_by_id(doc)
302
+ if "fi" in by and "mjolnir" in by:
303
+ sp = results.speedup(by["fi"], by["mjolnir"])
304
+ want = set(int(x) for x in (ls or " ".join(map(str, ALL_LS))).replace(",", " ").split())
305
+ if sp and set(sp) >= want:
306
+ score = results.geomean(sp.values())
307
+ per_L = {str(k): round(v, 4) for k, v in sp.items()}
308
+ fi_us = {str(k): round(v, 2) for k, v in results.point_map(by["fi"]).items()}
309
+ mj_us = {str(k): round(v, 2) for k, v in results.point_map(by["mjolnir"]).items()}
310
+ sweep_run = doc["run_id"]
311
+ row = {
312
+ "id": len(rows) + 1,
313
+ "ts": _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds"),
314
+ "branch": _git(root, "rev-parse", "--abbrev-ref", "HEAD"),
315
+ "commit": commit,
316
+ "status": None,
317
+ "reason": None,
318
+ "score": round(score, 4) if score else None,
319
+ "best_before": best["score"] if best else None,
320
+ "min_gain": min_gain,
321
+ "correctness": "pass" if correct else "fail",
322
+ "per_L": per_L,
323
+ "fi_us": fi_us,
324
+ "mj_us": mj_us,
325
+ "agent": agent,
326
+ "hypothesis": hypothesis,
327
+ "sources": [s.strip() for s in sources.split(",") if s.strip()],
328
+ "verify_runs": {"correctness": corr_run, "sweep": sweep_run},
329
+ }
330
+ status, reason, rel = judge(row, best, min_gain)
331
+ row["status"], row["reason"] = status, reason
332
+ row["vs_best"] = {L: round(v, 4) for L, v in rel.items()}
333
+ append_ledger(root, row)
334
+ color = STATUS_COLORS[status]
335
+ console.print(
336
+ f"\n[bold {color}]{status.upper()}[/bold {color}] score {row['score']} "
337
+ f"(best kept {row['best_before']}) correctness {row['correctness']} commit {commit}\n {reason}"
338
+ )
339
+ if rel:
340
+ console.print(
341
+ " vs best, per L: "
342
+ + " ".join(f"{_L(L)} {_pct(v)}" for L, v in sorted(rel.items(), key=lambda kv: int(kv[0])))
343
+ )
344
+ if status in ("discard", "crash"):
345
+ console.print(f"[dim] revert: git reset --hard {best['commit'] if best else 'HEAD~1'}[/dim]")
346
+ elif status == "noisy":
347
+ console.print("[dim] the measurement was rejected, not the idea: evaluate the same commit again[/dim]")
348
+ _render_progress(root)
349
+ return 0
350
+
351
+
352
+ STATUS_COLORS = {"keep": "green", "baseline": "cyan", "discard": "yellow", "crash": "red", "noisy": "magenta"}
353
+ PROGRESS = LEDGER_DIR / "progress.png"
354
+
355
+
356
+ def _render_progress(root: Path, rows: list[dict] | None = None, out: Path | None = None) -> Path | None:
357
+ """research/progress.png (+ -dark): every experiment on one chart, the
358
+ kept staircase, and the per-L split vs the baseline. Best effort — a
359
+ missing matplotlib never fails an evaluation."""
360
+ rows = rows if rows is not None else load_ledger(root)
361
+ try:
362
+ from mjolnir_cli import charts, theme
363
+
364
+ plt = charts._plt()
365
+ out = out or root / PROGRESS
366
+ for mode in theme.MODES:
367
+ fig = charts.chart_autoresearch(rows, mode)
368
+ if fig is None:
369
+ return None
370
+ charts._save(fig, out.with_name(f"{out.stem}{mode.suffix}{out.suffix}"))
371
+ plt.close(fig)
372
+ return out
373
+ except Exception as e: # noqa: BLE001 — a chart must never cost a ledger row
374
+ console.print(f"[dim]progress chart skipped: {e!r}[/dim]")
375
+ return None
376
+
377
+
378
+ def latest_rows(root: Path) -> list[dict]:
379
+ """The live ledger, else the newest exported run (research/runs/<tag>/)."""
380
+ rows = load_ledger(root)
381
+ if rows:
382
+ return rows
383
+ runs = sorted((root / LEDGER_DIR / "runs").glob("*/ledger.jsonl"), key=lambda p: p.stat().st_mtime)
384
+ return [json.loads(x) for x in runs[-1].read_text().splitlines() if x.strip()] if runs else []
385
+
386
+
387
+ @app.command()
388
+ def evaluate(
389
+ hypothesis: str = typer.Option(..., "--hypothesis", "-m", help="what this experiment tries, one line"),
390
+ agent: str = typer.Option("autoresearch", "--agent", help="who proposed/implemented it"),
391
+ sources: str = typer.Option("", "--sources", help="comma-separated URLs / arXiv ids behind the idea"),
392
+ min_gain: float = typer.Option(0.02, "--min-gain", help="required geomean gain over the best kept score"),
393
+ ls: Optional[str] = typer.Option(None, "--ls", help="L values (default: all 8; a subset is never a keep)"),
394
+ iters: int = typer.Option(50, "--iters"),
395
+ gate: bool = typer.Option(True, "--gate/--no-gate"),
396
+ image: Optional[str] = typer.Option(None, "--image"),
397
+ ):
398
+ """Score the committed kernel: quick correctness + FlashInfer vs Mjolnir sweep;
399
+ append the result and the fixed keep/discard decision to the ledger."""
400
+ raise typer.Exit(_evaluate(_root(), hypothesis, agent, sources, image, min_gain, ls, iters, gate))
401
+
402
+
403
+ @app.command()
404
+ def ledger(
405
+ tail: int = typer.Option(20, "--tail"),
406
+ as_json: bool = typer.Option(False, "--json", "-j"),
407
+ ):
408
+ """Show the ledger (newest last)."""
409
+ rows = load_ledger(_root())
410
+ if as_json:
411
+ typer.echo(json.dumps(rows[-tail:], indent=2))
412
+ return
413
+ if not rows:
414
+ console.print("[yellow]empty ledger — start with: mjolnir autoresearch init <tag>[/yellow]")
415
+ return
416
+ best = best_kept(rows)
417
+ t = Table(box=box.SIMPLE_HEAD, pad_edge=False)
418
+ for c in ("#", "when", "commit", "status", "score", "agent", "hypothesis", "why"):
419
+ t.add_column(c, justify="right" if c in ("#", "score") else "left")
420
+ colors = STATUS_COLORS
421
+ for r in rows[-tail:]:
422
+ st = r.get("status", "")
423
+ mark = " ★" if best and r is best else ""
424
+ t.add_row(
425
+ str(r["id"]),
426
+ r["ts"][5:16].replace("T", " "),
427
+ r["commit"],
428
+ f"[{colors.get(st, 'white')}]{st}[/]",
429
+ f"{r['score']:.4f}{mark}" if r.get("score") else "—",
430
+ r.get("agent", ""),
431
+ r.get("hypothesis", "")[:60],
432
+ (r.get("reason") or "")[:60],
433
+ )
434
+ console.print(t)
435
+
436
+
437
+ @app.command()
438
+ def status():
439
+ """Best kept score, experiment counts, the current branch."""
440
+ root = _root()
441
+ rows = load_ledger(root)
442
+ best = best_kept(rows)
443
+ counts: dict[str, int] = {}
444
+ for r in rows:
445
+ counts[r["status"]] = counts.get(r["status"], 0) + 1
446
+ console.print(
447
+ f"branch {_git(root, 'rev-parse', '--abbrev-ref', 'HEAD')} · {len(rows)} experiments · "
448
+ + " · ".join(f"{k} {v}" for k, v in sorted(counts.items()))
449
+ )
450
+ if best:
451
+ console.print(
452
+ f"best: [bold]{best['score']:.4f}×[/bold] vs FlashInfer (geomean over L) at {best['commit']} "
453
+ f"— {best['hypothesis']}"
454
+ )
455
+
456
+
457
+ @app.command()
458
+ def chart():
459
+ """Re-render research/progress.png from the live ledger (or the newest exported run)."""
460
+ root = _root()
461
+ p = _render_progress(root, latest_rows(root))
462
+ console.print(f"[green]{p}[/green]" if p else "[yellow]no experiments yet[/yellow]")
463
+
464
+
465
+ @app.command()
466
+ def run(
467
+ tag: Optional[str] = typer.Option(None, "--tag", help="start a new run first (init <tag>)"),
468
+ model: Optional[str] = typer.Option(None, "--model", help="OpenCode model (provider/model) for the orchestrator"),
469
+ prompt: str = typer.Option(
470
+ "Read research/program.md and run the loop: setup is done when the ledger has a baseline row. "
471
+ "Never stop to ask; record every experiment with `mjolnir autoresearch evaluate`.",
472
+ "--prompt",
473
+ ),
474
+ ):
475
+ """Launch the loop: an OpenCode session with the `autoresearch` agent."""
476
+ root = _root()
477
+ if shutil.which("opencode") is None:
478
+ console.print("[red]opencode not found — install it: https://opencode.ai (npm i -g opencode-ai)[/red]")
479
+ raise typer.Exit(2)
480
+ if tag:
481
+ rc = _init(root, tag, "main", True, None)
482
+ if rc != 0:
483
+ raise typer.Exit(rc)
484
+ elif not load_ledger(root):
485
+ console.print("[yellow]no ledger yet — run: mjolnir autoresearch init <tag> (or pass --tag)[/yellow]")
486
+ raise typer.Exit(4)
487
+ cmd = ["opencode", "run", "--agent", "autoresearch"]
488
+ if model:
489
+ cmd += ["--model", model]
490
+ cmd += [prompt]
491
+ console.print(f"[dim]$ {' '.join(cmd)}[/dim]")
492
+ raise typer.Exit(subprocess.call(cmd, cwd=root))
493
+
494
+
495
+ @app.command()
496
+ def export(
497
+ tag: Optional[str] = typer.Option(None, "--tag", help="run tag (default: from the autoresearch/<tag> branch)"),
498
+ ):
499
+ """Snapshot the ledger into research/runs/<tag>/ (ledger.jsonl, ledger.tsv,
500
+ SUMMARY.md) — commit that to keep the run's notebook with the repo."""
501
+ root = _root()
502
+ rows = load_ledger(root)
503
+ if not rows:
504
+ console.print("[yellow]empty ledger[/yellow]")
505
+ raise typer.Exit(4)
506
+ branch = _git(root, "rev-parse", "--abbrev-ref", "HEAD")
507
+ tag = tag or (branch.split("/", 1)[1] if branch.startswith("autoresearch/") else branch)
508
+ out = root / LEDGER_DIR / "runs" / tag
509
+ out.mkdir(parents=True, exist_ok=True)
510
+ for name in ("ledger.jsonl", "ledger.tsv"):
511
+ src = root / LEDGER_DIR / name
512
+ if src.exists():
513
+ shutil.copy(src, out / name)
514
+ best = best_kept(rows)
515
+ base = next((r for r in rows if r["status"] == "baseline"), None)
516
+ counts: dict[str, int] = {}
517
+ for r in rows:
518
+ counts[r["status"]] = counts.get(r["status"], 0) + 1
519
+ lines = [
520
+ f"# autoresearch run `{tag}`",
521
+ "",
522
+ f"- experiments: {len(rows)} (" + ", ".join(f"{k} {v}" for k, v in sorted(counts.items())) + ")",
523
+ f"- baseline: {base['score']}× vs FlashInfer at `{base['commit']}`" if base and base.get("score") else "",
524
+ f"- best: **{best['score']}×** at `{best['commit']}` — {best['hypothesis']}" if best else "",
525
+ "",
526
+ "![progress](progress.png)" if _render_progress(root, rows, out / "progress.png") else "",
527
+ "",
528
+ "| # | commit | status | score | why | agent | hypothesis | sources |",
529
+ "|--:|---|---|--:|---|---|---|---|",
530
+ ]
531
+ for r in rows:
532
+ src = " ".join(f"[{i + 1}]({u})" if u.startswith("http") else u for i, u in enumerate(r.get("sources", [])))
533
+ lines.append(
534
+ f"| {r['id']} | `{r['commit']}` | {r['status']} | {r.get('score') or '—'} "
535
+ f"| {(r.get('reason') or '').replace('|', '/')} | {r.get('agent', '')} "
536
+ f"| {r.get('hypothesis', '').replace('|', '/')} | {src} |"
537
+ )
538
+ (out / "SUMMARY.md").write_text("\n".join(x for x in lines if x is not None) + "\n")
539
+ console.print(f"[green]exported {len(rows)} rows -> {out}[/green]")