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.
- mjolnir_cli/__init__.py +8 -0
- mjolnir_cli/__main__.py +4 -0
- mjolnir_cli/accuracy.py +333 -0
- mjolnir_cli/autoresearch.py +539 -0
- mjolnir_cli/benchy.py +475 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4/config.yaml +78 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DFLASH2/config.yaml +103 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DSPARK/config.yaml +102 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA2/config.yaml +84 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA4hd256/config.yaml +104 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR/config.yaml +86 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3/config.yaml +93 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DFLASH2/config.yaml +123 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DSPARK/config.yaml +124 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/.env +2 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/config.yaml +98 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/.env +4 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/config.yaml +98 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/.env +4 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/config.yaml +102 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/.env +3 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/config.yaml +98 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/draft_vocab.json +1 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/kv_scales.json +35197 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8_chat_template.jinja +456 -0
- mjolnir_cli/bundled/docker/litellm/docker-compose.yaml +113 -0
- mjolnir_cli/bundled/docker/litellm/litellm.env.example +51 -0
- mjolnir_cli/bundled/docker/litellm/litellm_config.template.example.yaml +192 -0
- mjolnir_cli/bundled/docker/perses/README.md +89 -0
- mjolnir_cli/bundled/docker/perses/dashboards/global-datasource.yaml +17 -0
- mjolnir_cli/bundled/docker/perses/dashboards/performance_statistics.yaml +771 -0
- mjolnir_cli/bundled/docker/perses/dashboards/project.yaml +13 -0
- mjolnir_cli/bundled/docker/perses/dashboards/query_statistics.yaml +399 -0
- mjolnir_cli/bundled/docker/perses/docker-compose.yaml +51 -0
- mjolnir_cli/bundled/docker/perses/perses.yml +16 -0
- mjolnir_cli/bundled/docker/perses/prometheus.template.yaml +20 -0
- mjolnir_cli/bundled/scripts/hw/setup-thor.sh +1287 -0
- mjolnir_cli/cachectl.py +146 -0
- mjolnir_cli/charts.py +1687 -0
- mjolnir_cli/cli.py +1480 -0
- mjolnir_cli/clidoc.py +170 -0
- mjolnir_cli/config.py +346 -0
- mjolnir_cli/dockerctl.py +533 -0
- mjolnir_cli/drivers/fa2_driver.py +89 -0
- mjolnir_cli/drivers/fi_driver.py +134 -0
- mjolnir_cli/e2e.py +302 -0
- mjolnir_cli/fonts/InterDisplay-Bold.ttf +0 -0
- mjolnir_cli/fonts/InterDisplay-Medium.ttf +0 -0
- mjolnir_cli/fonts/InterDisplay-Regular.ttf +0 -0
- mjolnir_cli/fonts/InterDisplay-SemiBold.ttf +0 -0
- mjolnir_cli/fonts/OFL.txt +92 -0
- mjolnir_cli/gate.py +233 -0
- mjolnir_cli/history.py +159 -0
- mjolnir_cli/interactive.py +383 -0
- mjolnir_cli/legs.py +109 -0
- mjolnir_cli/litellm.py +254 -0
- mjolnir_cli/perses.py +214 -0
- mjolnir_cli/picker.py +70 -0
- mjolnir_cli/readme.py +1020 -0
- mjolnir_cli/stack.py +340 -0
- mjolnir_cli/tasks.py +122 -0
- mjolnir_cli/theme.py +281 -0
- mjolnir_cli/thor.py +108 -0
- mjolnir_cli/verify/__init__.py +2 -0
- mjolnir_cli/verify/cli.py +816 -0
- mjolnir_cli/verify/docker.py +334 -0
- mjolnir_cli/verify/kvcache.py +180 -0
- mjolnir_cli/verify/legs.py +134 -0
- mjolnir_cli/verify/ncu.py +141 -0
- mjolnir_cli/verify/profile.py +312 -0
- mjolnir_cli/verify/report.py +177 -0
- mjolnir_cli/verify/results.py +157 -0
- mjolnir_cli/verify/spec.py +279 -0
- mjolnir_cli/verify/stages.py +817 -0
- mjolnir_cli-0.2.0.dist-info/METADATA +87 -0
- mjolnir_cli-0.2.0.dist-info/RECORD +80 -0
- mjolnir_cli-0.2.0.dist-info/WHEEL +4 -0
- mjolnir_cli-0.2.0.dist-info/entry_points.txt +2 -0
- mjolnir_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
- 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
|
+
"" 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]")
|