masterwork 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.
masterwork/line.py ADDED
@@ -0,0 +1,399 @@
1
+ """The line: one run, every gate, no step left to memory.
2
+
3
+ Producing a candidate is mechanical, and each mechanical step here has been
4
+ skipped by hand at least once. Every skip produced a number that looked real
5
+ and was not: an unverified deployed copy, a grid quietly short of cells, a
6
+ threshold inside its own noise, a negative result never written down.
7
+
8
+ The line runs the stages in order and stops at the first gate that holds:
9
+
10
+ seal -> generate -> completeness -> judge -> gate -> persist
11
+
12
+ Holding is a state, not a failure. Some steps need an act from outside —
13
+ labels from a judge that is deliberately not this house, a signature on a
14
+ band the checker cannot verify. The line stops there, says exactly what is
15
+ missing, writes the record, and exits in a way that says "waiting", not
16
+ "broken". Rerun it when the outside act is done and it picks up.
17
+
18
+ Two rules the line will not bend:
19
+
20
+ * It cannot edit its own gate. Thresholds are read, never written. A loop
21
+ permitted to move its threshold optimises the threshold, not the work.
22
+ * It persists on failure too. A line that records only its successes
23
+ teaches the next run a false history — and the most useful results so
24
+ far have been the negative ones.
25
+
26
+ Workshop-specific work (generation, judging) arrives as commands in the run
27
+ spec. The gates are the line's own, so what counts as a complete grid or a
28
+ valid threshold does not vary by who is running it.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import json
34
+ import os
35
+ import subprocess
36
+ import sys
37
+ import time
38
+
39
+ from masterwork import cells as cells_gate
40
+ from masterwork import gate as gate_check
41
+ from masterwork import measure
42
+ from masterwork import retain
43
+ from masterwork import seal as seal_gate
44
+
45
+
46
+ def _run(command: str, log_path: str | None) -> tuple[int, str]:
47
+ """Run a workshop command unbuffered, streaming to a log the operator can tail."""
48
+ env = dict(os.environ, PYTHONUNBUFFERED="1")
49
+ if log_path:
50
+ with open(log_path, "w") as log:
51
+ rc = subprocess.call(["bash", "-lc", command], stdout=log,
52
+ stderr=subprocess.STDOUT, env=env)
53
+ tail = "".join(open(log_path, encoding="utf-8", errors="replace")
54
+ .readlines()[-15:])
55
+ return rc, tail
56
+ p = subprocess.run(["bash", "-lc", command], capture_output=True, text=True, env=env)
57
+ return p.returncode, (p.stdout + p.stderr)[-2000:]
58
+
59
+
60
+ def _missing_labels(cfg: dict) -> tuple[list[str], dict]:
61
+ """Cells whose label is absent, empty, or describes a different cell.
62
+
63
+ Three ways a label fails to be a label, and the first is the least
64
+ dangerous. A file that exists but says nothing looks answered. A file left
65
+ over from an earlier run looks answered *and* carries a verdict: the cells
66
+ were regenerated, the names did not change, and the labels describe
67
+ transcripts that no longer exist. So a label carries the hash of the cell
68
+ it judged, and a label that does not match the cell on disk is missing.
69
+
70
+ Returns the problems and what the labelling can vouch for.
71
+ """
72
+ import glob as _glob
73
+ pattern = cfg.get("expect")
74
+ if not pattern:
75
+ return [], {}
76
+ key = cfg.get("key", "label")
77
+ missing: list[str] = []
78
+ seen: dict = {"labelled": 0, "labellers": set(), "rubrics": set(),
79
+ "relabelled": False}
80
+ for cell in sorted(_glob.glob(cfg["cells"])) if cfg.get("cells") else []:
81
+ name = os.path.splitext(os.path.basename(cell))[0]
82
+ path = pattern.replace("{cell}", name)
83
+ if not os.path.exists(path):
84
+ missing.append(f"{name}: no label file ({path})")
85
+ continue
86
+ try:
87
+ d = json.load(open(path, encoding="utf-8"))
88
+ except Exception as e:
89
+ missing.append(f"{name}: label file unreadable ({e})")
90
+ continue
91
+ value = d.get(key) if isinstance(d, dict) else None
92
+ if value in (None, "", []):
93
+ missing.append(f"{name}: label file present but empty")
94
+ continue
95
+ stamped = d.get("cell_sha256")
96
+ if stamped:
97
+ import hashlib as _h
98
+ actual = _h.sha256(open(cell, "rb").read()).hexdigest()[:16]
99
+ if actual != stamped:
100
+ missing.append(f"{name}: label was written for a different "
101
+ f"version of this cell ({stamped} != {actual})")
102
+ continue
103
+ seen["labelled"] += 1
104
+ if d.get("labeller"):
105
+ seen["labellers"].add(d["labeller"])
106
+ if d.get("rubric_sha256"):
107
+ seen["rubrics"].add(d["rubric_sha256"])
108
+ seen["relabelled"] = seen["relabelled"] or bool(d.get("relabelled"))
109
+ seen["labellers"] = sorted(seen["labellers"])
110
+ seen["rubrics"] = sorted(seen["rubrics"])
111
+ return missing, seen
112
+
113
+
114
+ class Line:
115
+ def __init__(self, spec: dict, out_dir: str):
116
+ self.spec = spec
117
+ self.out_dir = out_dir
118
+ self.record: dict = {"name": spec.get("name", "unnamed"),
119
+ "started": time.strftime("%Y-%m-%d %H:%M:%S"),
120
+ "stages": []}
121
+ os.makedirs(out_dir, exist_ok=True)
122
+
123
+ def stage(self, name: str, ok: bool, detail) -> bool:
124
+ self.record["stages"].append({"stage": name, "ok": bool(ok), "detail": detail})
125
+ mark = "ok " if ok else "HELD"
126
+ print(f"[{mark}] {name}")
127
+ for line in (detail if isinstance(detail, list) else [detail]):
128
+ if line:
129
+ print(f" {line}")
130
+ return ok
131
+
132
+ def persist(self, verdict: str) -> str:
133
+ # Declarations are meant to be deliberate acts, but a spec gets copied
134
+ # and the escape hatch becomes the habit. They ride on the verdict
135
+ # itself, where a reader cannot skip past them.
136
+ declared = [k for k in ("allow_self_judged", "allow_nonstandard")
137
+ if (self.spec.get("judge", {}).get("journeyman") or {}).get(k)]
138
+ if (self.spec.get("cells") or {}).get("allow_missing"):
139
+ declared.append("allow_missing")
140
+ if (self.record.get("labelling") or {}).get("relabelled"):
141
+ declared.append("relabelled")
142
+ if declared:
143
+ verdict = f"{verdict} (declared: {', '.join(sorted(declared))})"
144
+ self.record["declared"] = declared
145
+ self.record["verdict"] = verdict
146
+ # Heavy things stay here and are disposable; the record stays light.
147
+ # Reported, never deleted: what to remove is not the line's call.
148
+ self.record["bytes"] = retain.dir_size(self.out_dir)
149
+ for note in retain.check(self.out_dir, self.spec.get("max_run_bytes",
150
+ 50_000_000)):
151
+ print(f"[retention] {note}")
152
+ self.record["finished"] = time.strftime("%Y-%m-%d %H:%M:%S")
153
+ path = os.path.join(self.out_dir, "run.json")
154
+ json.dump(self.record, open(path, "w", encoding="utf-8"),
155
+ ensure_ascii=False, indent=1)
156
+ print(f"\nverdict {verdict} · record {path}")
157
+ return path
158
+
159
+ # A run that cannot say what it decides is the expensive kind of run: it
160
+ # produces numbers, and what the numbers were for gets decided afterwards,
161
+ # by whoever reads them. The gates below catch instruments; this one
162
+ # catches the operator, which is the failure the instruments cannot see.
163
+ PURPOSE = ("question", "decides", "axis_kind", "owner")
164
+
165
+ def run(self) -> int:
166
+ s = self.spec
167
+ purpose = s.get("purpose") or {}
168
+ missing = [k for k in self.PURPOSE if not purpose.get(k)]
169
+ if missing:
170
+ self.stage("purpose", False, [
171
+ f"missing: {', '.join(missing)}",
172
+ "question: what is being asked · decides: what changes on each "
173
+ "outcome · axis_kind: work | diagnostic · owner: whose call the "
174
+ "decision is",
175
+ "a run whose purpose is written after the numbers arrive is a "
176
+ "run whose purpose the numbers chose"])
177
+ self.persist("HELD_WITHOUT_PURPOSE")
178
+ return 4
179
+ if purpose["axis_kind"] not in ("work", "diagnostic"):
180
+ self.stage("purpose", False, [
181
+ f"axis_kind is {purpose['axis_kind']!r}; it must be 'work' or "
182
+ f"'diagnostic'. A diagnostic axis may not carry an acceptance "
183
+ f"verdict — resemblance to a description is not the work."])
184
+ self.persist("HELD_WITHOUT_PURPOSE")
185
+ return 4
186
+ self.record["purpose"] = purpose
187
+ self.stage("purpose", True, [f"{purpose['question']}",
188
+ f"decides: {purpose['decides']}",
189
+ f"axis: {purpose['axis_kind']} · "
190
+ f"owner: {purpose['owner']}"])
191
+
192
+ if "seal" in s:
193
+ c = s["seal"]
194
+ profile = (json.load(open(c["profile"], encoding="utf-8"))
195
+ if c.get("profile") else None)
196
+ problems = seal_gate.verify(c["identity"], profile, c.get("corpus"),
197
+ c.get("script"), c.get("deployed"))
198
+ if not self.stage("seal", not problems, problems or "candidate is reproducible"):
199
+ self.persist("HELD_AT_SEAL")
200
+ return 1
201
+
202
+ if "generate" in s:
203
+ c = s["generate"]
204
+ log = os.path.join(self.out_dir, "generate.log")
205
+ print(f" running; follow with: tail -f {log}")
206
+ rc, tail = _run(c["command"], log)
207
+ if not self.stage("generate", rc == 0, tail if rc else f"log {log}"):
208
+ self.persist("FAILED_IN_GENERATION")
209
+ return 1
210
+
211
+ if "cells" in s:
212
+ c = s["cells"]
213
+ expected = c.get("expected")
214
+ found, broken, absent = cells_gate.inspect(
215
+ c["pattern"], expected, c.get("min_steps", 2),
216
+ not c.get("closing_optional", False),
217
+ c.get("transcript_key", "messages"),
218
+ c.get("closing_key", "final_text"))
219
+ if not found:
220
+ self.stage("completeness", False, [
221
+ f"no cells matched {c['pattern']!r}",
222
+ "an empty grid is not a complete one — a mistyped pattern "
223
+ "reaches this gate looking like a finished run"])
224
+ self.persist("HELD_AT_COMPLETENESS")
225
+ return 1
226
+ want = len(expected) if expected else len(found)
227
+ short = want - (len(found) - len(broken))
228
+ allowed = c.get("allow_missing", 0)
229
+ detail = [f"{len(found)} found · {len(found)-len(broken)} complete · {want} expected"]
230
+ detail += [f"INCOMPLETE {x.name}: {why}" for x, why in broken]
231
+ detail += [f"ABSENT {n}" for n in absent]
232
+ if short > 0 and short <= allowed:
233
+ detail.append(f"proceeding {short} short — allowed explicitly; "
234
+ f"missing cells are not a random sample")
235
+ if not self.stage("completeness", short <= allowed, detail):
236
+ self.persist("HELD_AT_COMPLETENESS")
237
+ return 1
238
+
239
+ if "label" in s:
240
+ c = s["label"]
241
+ missing, seen = _missing_labels(c)
242
+ # The command runs when there is something to label, not every
243
+ # time the line runs. A line that relabels on every rerun would
244
+ # walk into its own refusal against replacing labels — and worse,
245
+ # would teach the operator to keep the override on permanently.
246
+ if missing and c.get("command"):
247
+ log = os.path.join(self.out_dir, "label.log")
248
+ print(f" {len(missing)} unlabelled; running the labeller, "
249
+ f"follow with: tail -f {log}")
250
+ rc, tail = _run(c["command"], log)
251
+ if not self.stage("label", rc == 0, tail if rc else f"log {log}"):
252
+ self.persist("FAILED_IN_LABELLING")
253
+ return 1
254
+ missing, seen = _missing_labels(c)
255
+ if missing:
256
+ self.stage("label", False,
257
+ [f"{len(missing)} cell(s) still unlabelled",
258
+ "labelling is deliberately outside this house — "
259
+ "the line waits rather than guessing"]
260
+ + [f" {m}" for m in missing[:20]])
261
+ self.record["labelling"] = seen
262
+ self.persist("HELD_FOR_LABELLING")
263
+ return 3
264
+ if seen:
265
+ # A stage that says nothing when it passes drops the stamps.
266
+ # Who labelled, against which rubric, and whether these labels
267
+ # replaced earlier ones all travel with the number.
268
+ self.record["labelling"] = seen
269
+ self.stage("label", True,
270
+ [f"{seen['labelled']} labelled by "
271
+ f"{', '.join(seen['labellers']) or 'unnamed'} · "
272
+ f"rubric {', '.join(seen['rubrics']) or 'unstamped'}"]
273
+ + (["these labels replaced earlier ones"]
274
+ if seen["relabelled"] else []))
275
+
276
+ if "judge" in s:
277
+ c = s["judge"]
278
+ log = os.path.join(self.out_dir, "judge.log")
279
+ print(f" running; follow with: tail -f {log}")
280
+ if "journeyman" in c:
281
+ cfg = c["journeyman"]
282
+ jm_version, why = measure.tool(cfg)
283
+ if not self.stage("judge is installed", why is None,
284
+ why or f"{jm_version}"):
285
+ self.persist("HELD_WITHOUT_JUDGE")
286
+ return 1
287
+ self.record["judge_tool"] = jm_version
288
+ rc, summary, tail = measure.run(cfg, log)
289
+ if not self.stage("judge", rc == 0 and summary is not None,
290
+ tail if rc or not summary else
291
+ [f"report {summary['report']}",
292
+ " · ".join(f"{k} {v}" for k, v in summary["axes"].items())]):
293
+ self.persist("FAILED_IN_JUDGING")
294
+ return 1
295
+ self.record["measurement"] = summary
296
+ # The benchmark's own warnings are gates here, not footnotes.
297
+ bad = measure.problems(summary, cfg)
298
+ if not self.stage("measurement is comparable", not bad, bad or
299
+ "judged by a separate endpoint, standard scenes"):
300
+ self.persist("HELD_AT_MEASUREMENT")
301
+ return 1
302
+ else:
303
+ rc, tail = _run(c["command"], log)
304
+ if not self.stage("judge", rc == 0, tail if rc else f"log {log}"):
305
+ self.persist("FAILED_IN_JUDGING")
306
+ return 1
307
+
308
+ if "gate" in s and purpose["axis_kind"] == "diagnostic":
309
+ self.stage("gate", True, ["skipped: this run was declared "
310
+ "diagnostic, so no acceptance verdict "
311
+ "is drawn from it"])
312
+ self.persist("DIAGNOSTIC")
313
+ return 0
314
+
315
+ if "gate" in s:
316
+ path = s["gate"]["file"]
317
+ measured = (self.record.get("measurement") or {}).get("axes") or {}
318
+ incumbent = s["gate"].get("incumbent_axes")
319
+ text = open(path, encoding="utf-8").read()
320
+ results, detail, verdicts = [], [], []
321
+ for title, body in gate_check.sections(text):
322
+ if not (gate_check.DECIDES.search(body)
323
+ or gate_check.field(body, "band-command")):
324
+ continue
325
+ verdict, notes = gate_check.check_section(body)
326
+ results.append(verdict)
327
+ detail.append(f"[{verdict}] {title}: " + "; ".join(n for n in notes if n))
328
+ if verdict in ("PASS", "UNBOUND") and measured:
329
+ applied, why = gate_check.evaluate_section(body, measured, incumbent)
330
+ verdicts.append((title, applied))
331
+ detail.append(f" -> {applied}: " + "; ".join(why))
332
+ bad = [v for v in results if v == "FAIL"]
333
+ unsure = [v for v in results if v in ("UNVERIFIABLE", "UNBOUND")]
334
+ if not self.stage("gate", not bad, detail):
335
+ self.persist("HELD_AT_GATE")
336
+ return 1
337
+ if unsure:
338
+ self.persist("NEEDS_SIGNATURE")
339
+ return 2
340
+ # Last, because a void gate and an unbound rule are more specific
341
+ # complaints. A rule that names an axis and met no measurement was
342
+ # never applied: the section validates and prints PASS, which reads
343
+ # as "the gate held", and the run used to fall through to COMPLETE
344
+ # with rc 0 carrying no verdict at all.
345
+ named = [t2 for t2, body in gate_check.sections(text)
346
+ if gate_check.field(body, "measure")]
347
+ if named and not measured:
348
+ self.stage("gate applied", False, [
349
+ f"{len(named)} rule(s) name an axis and nothing measured it: "
350
+ + ", ".join(named),
351
+ "judge with the benchmark, or declare the run diagnostic — "
352
+ "a gate that was checked is not a gate that was applied"])
353
+ self.persist("HELD_WITHOUT_MEASUREMENT")
354
+ return 3
355
+ if verdicts:
356
+ self.record["axis_verdicts"] = [
357
+ {"gate": t2, "verdict": v} for t2, v in verdicts]
358
+ if any(v == "REJECT" for _t, v in verdicts):
359
+ self.persist("REJECTED")
360
+ return 1
361
+ if all(v == "ACCEPT" for _t, v in verdicts):
362
+ self.persist("ACCEPTED")
363
+ return 0
364
+ self.persist("UNRESOLVED")
365
+ return 0
366
+
367
+ self.persist("COMPLETE")
368
+ return 0
369
+
370
+
371
+ def main(argv=None) -> int:
372
+ ap = argparse.ArgumentParser(prog="masterwork line", description="run one campaign through the line")
373
+ ap.add_argument("spec", help="run spec (JSON)")
374
+ ap.add_argument("--out", default="runs", help="where the run record is written")
375
+ a = ap.parse_args(argv)
376
+ # The line's whole manner is to say what is missing and stop. A traceback
377
+ # here would be the first thing a new reader sees, on the first thing a
378
+ # new reader gets wrong — a mistyped path — and it would say the opposite
379
+ # of everything downstream of it.
380
+ if not os.path.exists(a.spec):
381
+ print(f"HELD: no run spec at {a.spec}\n"
382
+ f" a spec is JSON; docs/run-spec.md documents every field, "
383
+ f"and examples/held_at_gate.py writes a working one")
384
+ return 4
385
+ try:
386
+ spec = json.load(open(a.spec, encoding="utf-8"))
387
+ except json.JSONDecodeError as e:
388
+ print(f"HELD: {a.spec} is not valid JSON — {e}")
389
+ return 4
390
+ if not isinstance(spec, dict):
391
+ print(f"HELD: {a.spec} holds a {type(spec).__name__}; a run spec is an object")
392
+ return 4
393
+ out = os.path.join(a.out, spec.get("name", "run"))
394
+ return Line(spec, out).run()
395
+
396
+
397
+ if __name__ == "__main__":
398
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
399
+ sys.exit(main())
masterwork/measure.py ADDED
@@ -0,0 +1,219 @@
1
+ """Measurement is journeyman's job; this is the seam that hands the piece over.
2
+
3
+ The maker does not grade his own piece. That is the whole reason this
4
+ repository and the benchmark are separate, and it only means something if
5
+ the line actually submits the work rather than scoring it here.
6
+
7
+ So the judge stage shells out to the `journeyman` CLI and reads its
8
+ report.json back. Shelling out, not importing: the dependency stays one
9
+ way and optional, the line keeps its standard-library-only footprint, and a
10
+ workshop that has not installed the benchmark gets a clear refusal instead
11
+ of an import error at a random moment.
12
+
13
+ One guard comes free with the contract. journeyman marks a run
14
+ `self_judged` when the agent endpoint also served as the judge — its own
15
+ way of saying the score is not comparable. A gate applied to such a score
16
+ would be the maker grading himself with extra steps, so the line refuses it
17
+ unless the spec says out loud that this run is a dev run.
18
+
19
+ What the line will not do is require anyone to buy a judge. No provider is
20
+ named anywhere in this repository, and a separate judge can be a second
21
+ local model swapped in after the agent phase — time rather than money. The
22
+ requirement is not a separate endpoint; it is that the comparability stamp
23
+ travels with the claim and cannot be removed by whoever quotes the number.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import hashlib
28
+ import json
29
+ import os
30
+ import shutil
31
+ import subprocess
32
+ import time
33
+ import urllib.error
34
+ import urllib.request
35
+
36
+
37
+ def base_endpoint(url: str) -> str:
38
+ """The benchmark appends /v1/chat/completions itself.
39
+
40
+ Handing it a URL that already ends in /v1 yields /v1/v1/... and every cell
41
+ comes back 404 — invalid, not wrong, so the run completes and reports
42
+ nothing. Costs a full battery to notice, so it is normalised here.
43
+ """
44
+ url = url.rstrip("/")
45
+ for tail in ("/v1/chat/completions", "/chat/completions", "/v1"):
46
+ if url.endswith(tail):
47
+ return url[: -len(tail)]
48
+ return url
49
+
50
+
51
+ def build_command(cfg: dict) -> list[str]:
52
+ """Translate a run spec's journeyman block into a CLI invocation."""
53
+ cmd = [cfg.get("executable", "journeyman"), "run",
54
+ "--endpoint", base_endpoint(cfg["endpoint"])]
55
+ optional = {
56
+ "model": "--model", "api_key": "--api-key",
57
+ "judge_model": "--judge-model",
58
+ "judge_api_key": "--judge-api-key",
59
+ "judge_params_file": "--judge-params-file",
60
+ "scenes": "--scenes", "system_file": "--system-file",
61
+ "params_file": "--params-file", "seeds": "--seeds",
62
+ "runs_dir": "--runs-dir",
63
+ }
64
+ for key, flag in optional.items():
65
+ if cfg.get(key) is not None:
66
+ cmd += [flag, str(cfg[key])]
67
+ if cfg.get("judge_endpoint"):
68
+ cmd += ["--judge", base_endpoint(cfg["judge_endpoint"])]
69
+ return cmd
70
+
71
+
72
+ MIN_JUDGE = "0.2.1"
73
+
74
+
75
+ def tool(cfg: dict) -> tuple[str | None, str | None]:
76
+ """Find the judge binary and record which one it was.
77
+
78
+ masterwork does not measure; it hands the piece to journeyman and
79
+ carries the verdict. So journeyman is not an optional convenience — a
80
+ line without it cannot finish, and one that finished must be able to
81
+ say which build produced the number.
82
+
83
+ Without this the missing binary surfaced as `bash: journeyman: command
84
+ not found` and a stage failure with exit 127 — a shell message standing
85
+ in for the one thing this line promises to say plainly.
86
+
87
+ Returns (version, None) when it is there, or (None, reason).
88
+ """
89
+ exe = cfg.get("executable", "journeyman")
90
+ path = shutil.which(exe)
91
+ if path is None:
92
+ return None, (
93
+ f"{exe} is not on PATH — masterwork hands the piece to journeyman "
94
+ f"for measurement and cannot score it itself. Install it "
95
+ f"(`pipx install journeyman-bench`) or point the run spec's "
96
+ f"judge.journeyman.executable at the binary.")
97
+ try:
98
+ p = subprocess.run([path, "--version"], capture_output=True,
99
+ text=True, timeout=30)
100
+ except OSError as e:
101
+ return None, f"{path} could not be run: {e}"
102
+ version = (p.stdout or p.stderr).strip().splitlines()
103
+ if p.returncode != 0 or not version:
104
+ return None, (
105
+ f"{path} does not answer --version (exit {p.returncode}); "
106
+ f"masterwork needs journeyman >= {MIN_JUDGE}, which is where the "
107
+ f"flag landed. A judge that cannot identify itself cannot stamp a "
108
+ f"verdict — the number would travel without saying what produced "
109
+ f"it. Upgrade with `pipx upgrade journeyman-bench`.")
110
+ return version[0], None
111
+
112
+
113
+ def reachable(endpoint: str, timeout: float = 8.0) -> str | None:
114
+ """Ask the endpoint for its models before spending a battery on it.
115
+
116
+ A wrong host or port does not crash the benchmark: every cell comes back
117
+ invalid and the run completes with an empty report. Ten seconds here
118
+ saves the hour it takes to notice that.
119
+ """
120
+ url = base_endpoint(endpoint) + "/v1/models"
121
+ try:
122
+ with urllib.request.urlopen(url, timeout=timeout) as r:
123
+ if r.status >= 400:
124
+ return f"endpoint answered {r.status} at {url}"
125
+ return None
126
+ except urllib.error.HTTPError as e:
127
+ return f"endpoint answered {e.code} at {url}"
128
+ except Exception as e:
129
+ return f"endpoint unreachable at {url}: {e}"
130
+
131
+
132
+ def newest_report(runs_dir: str) -> str | None:
133
+ found = []
134
+ for root, _dirs, files in os.walk(runs_dir):
135
+ if "report.json" in files:
136
+ p = os.path.join(root, "report.json")
137
+ found.append((os.path.getmtime(p), p))
138
+ return max(found)[1] if found else None
139
+
140
+
141
+ def read_report(path: str) -> dict:
142
+ d = json.load(open(path, encoding="utf-8"))
143
+ axes = d.get("axes") or {}
144
+ return {
145
+ "report": path,
146
+ "axes": {k: v.get("score") for k, v in axes.items()} if isinstance(axes, dict) else {},
147
+ "n": {k: v.get("n") for k, v in axes.items()} if isinstance(axes, dict) else {},
148
+ "self_judged": bool(d.get("self_judged")),
149
+ "nonstandard": d.get("nonstandard"),
150
+ "invalid_cells": d.get("invalid_cells"),
151
+ "seal": d.get("seal"),
152
+ }
153
+
154
+
155
+ def problems(summary: dict, cfg: dict) -> list[str]:
156
+ out = []
157
+ # Did the benchmark actually wear the piece? --system-file is optional
158
+ # over there, so a spec that loses the line measures the bare model and
159
+ # the report looks entirely normal. The benchmark stamps the system text
160
+ # it used; compare it with what we sealed.
161
+ want = cfg.get("system_file")
162
+ if want and os.path.exists(want):
163
+ seen = ((summary.get("seal") or {}).get("agent_system_md5") or "")
164
+ ours = hashlib.md5(open(want, "rb").read()).hexdigest()
165
+ if not seen:
166
+ out.append("the report carries no agent system hash — the benchmark "
167
+ "ran the bare model, not the candidate")
168
+ elif not (ours.startswith(seen) or seen.startswith(ours)):
169
+ out.append(f"the benchmark measured a different piece: report says "
170
+ f"{seen}, the sealed candidate is {ours[:len(seen) or 12]}")
171
+ if summary["self_judged"] and not cfg.get("allow_self_judged"):
172
+ out.append("journeyman marked this run self_judged — the agent endpoint "
173
+ "also served as judge, so the score is not comparable and no "
174
+ "gate may be applied to it. Either judge from a separate "
175
+ "endpoint — a second local model swapped in after the agent "
176
+ "phase counts, no purchase needed — or declare "
177
+ "allow_self_judged, which keeps the not-comparable stamp on "
178
+ "the record.")
179
+ if summary["nonstandard"] and not cfg.get("allow_nonstandard"):
180
+ out.append(f"non-standard scene set ({summary['nonstandard']}) — not "
181
+ f"comparable with standard runs. Declare allow_nonstandard for "
182
+ f"a development pass; the stamp stays on the record either way.")
183
+ invalid = summary.get("invalid_cells")
184
+ if invalid:
185
+ out.append(f"journeyman reported invalid cells: {invalid}")
186
+ if not summary["axes"]:
187
+ out.append("report carries no axes — nothing was measured")
188
+ return out
189
+
190
+
191
+ def run(cfg: dict, log_path: str | None = None) -> tuple[int, dict | None, str]:
192
+ """Run the benchmark and read its report. Returns (rc, summary, tail)."""
193
+ if not cfg.get("skip_preflight"):
194
+ unreachable = reachable(cfg["endpoint"])
195
+ if unreachable:
196
+ return 1, None, unreachable
197
+ started = time.time()
198
+ cmd = build_command(cfg)
199
+ env = dict(os.environ, PYTHONUNBUFFERED="1")
200
+ if log_path:
201
+ with open(log_path, "w") as log:
202
+ rc = subprocess.call(cmd, stdout=log, stderr=subprocess.STDOUT, env=env)
203
+ tail = "".join(open(log_path, encoding="utf-8", errors="replace")
204
+ .readlines()[-15:])
205
+ else:
206
+ p = subprocess.run(cmd, capture_output=True, text=True, env=env)
207
+ rc, tail = p.returncode, (p.stdout + p.stderr)[-2000:]
208
+ if rc != 0:
209
+ return rc, None, tail
210
+ report = cfg.get("report") or newest_report(cfg.get("runs_dir", "runs"))
211
+ if not report or not os.path.exists(report):
212
+ return 1, None, "benchmark finished but no report.json was found"
213
+ # A reused runs directory hands back yesterday's numbers when today's run
214
+ # produced nothing. Same shape, same fields, wrong day.
215
+ if not cfg.get("report") and os.path.getmtime(report) < started - 1:
216
+ return 1, None, (f"the newest report under {cfg.get('runs_dir')} predates "
217
+ f"this run ({report}) — this run wrote none, and reading "
218
+ f"the old one would report a different day's numbers")
219
+ return 0, read_report(report), tail