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/__init__.py +7 -0
- masterwork/__main__.py +116 -0
- masterwork/blind_label.py +329 -0
- masterwork/campaign.py +173 -0
- masterwork/cells.py +140 -0
- masterwork/ceremony.py +205 -0
- masterwork/gate.py +258 -0
- masterwork/line.py +399 -0
- masterwork/measure.py +219 -0
- masterwork/pairs.py +252 -0
- masterwork/retain.py +117 -0
- masterwork/rubric-example.json +9 -0
- masterwork/seal.py +216 -0
- masterwork-0.0.1.dist-info/METADATA +309 -0
- masterwork-0.0.1.dist-info/RECORD +19 -0
- masterwork-0.0.1.dist-info/WHEEL +5 -0
- masterwork-0.0.1.dist-info/entry_points.txt +2 -0
- masterwork-0.0.1.dist-info/licenses/LICENSE +202 -0
- masterwork-0.0.1.dist-info/top_level.txt +1 -0
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
|