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/cells.py ADDED
@@ -0,0 +1,140 @@
1
+ """The completeness gate: a run that lost cells is not a smaller run.
2
+
3
+ A battery is a grid — cases crossed with seeds. When some of those cells
4
+ come back empty, the tempting move is to average whatever arrived. That is
5
+ not a smaller sample of the same thing: cells fail for reasons that
6
+ correlate with what is being measured, so dropping them quietly moves the
7
+ number in a direction nobody chose.
8
+
9
+ Two failures this gate exists for, both observed:
10
+
11
+ * Eight cells vanished from a battery and the loss was noticed only by
12
+ counting by hand, after the verdict had been written.
13
+ * Cells that hit the harness round limit came back with a full transcript
14
+ and no closing text. Read as "empty" they looked like sloppy work; rerun
15
+ with a higher limit, every one of them closed correctly. They were the
16
+ longest, most careful runs in the battery — exactly the ones whose
17
+ removal flatters the score.
18
+
19
+ So the gate does not drop anything on its own. It reports what is missing
20
+ and stops. Proceeding without a full grid requires saying so out loud
21
+ (--allow-missing N), and the allowance is echoed into the report so it
22
+ survives into the record.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import glob
28
+ import json
29
+ import os
30
+ import sys
31
+ from dataclasses import dataclass
32
+
33
+
34
+ @dataclass
35
+ class Cell:
36
+ name: str
37
+ path: str
38
+ steps: int
39
+ closing: int
40
+ unreadable: str | None = None
41
+
42
+ def problem(self, min_steps: int, need_closing: bool) -> str | None:
43
+ # Unconditional: a file that would not parse is not a cell, whatever
44
+ # the thresholds are. With min_steps 0 and closing optional — a
45
+ # legitimate configuration — a truncated file used to count as complete.
46
+ if self.unreadable:
47
+ return f"could not be read ({self.unreadable})"
48
+ if self.steps < min_steps:
49
+ return f"transcript too short ({self.steps} < {min_steps})"
50
+ if need_closing and self.closing == 0:
51
+ return "no closing text — the run stopped before answering"
52
+ return None
53
+
54
+
55
+ def read_cell(path: str, transcript_key: str, closing_key: str) -> Cell:
56
+ name = os.path.splitext(os.path.basename(path))[0]
57
+ try:
58
+ d = json.load(open(path, encoding="utf-8"))
59
+ except Exception as e:
60
+ # Named the same way as a readable cell: the stem. Naming it with the
61
+ # extension made one corrupt file report as INCOMPLETE and ABSENT at
62
+ # once, which tells an operator two different stories about it.
63
+ return Cell(name, path, 0, 0, unreadable=f"{type(e).__name__}: {e}")
64
+ steps = len(d.get(transcript_key) or [])
65
+ closing = len((d.get(closing_key) or "").strip())
66
+ return Cell(name, path, steps, closing)
67
+
68
+
69
+ def inspect(pattern: str, expected: list[str] | None, min_steps: int,
70
+ need_closing: bool, transcript_key: str, closing_key: str):
71
+ """Cells found, the broken ones with why, and the expected ones absent.
72
+
73
+ A pattern that matches nothing is the caller's problem, not an empty grid:
74
+ with no expected list there is nothing to be short of, so zero cells used
75
+ to read as a complete grid and the gate passed. A mistyped pattern in a
76
+ spec is the ordinary way to arrive there.
77
+ """
78
+ cells = [read_cell(p, transcript_key, closing_key)
79
+ for p in sorted(glob.glob(pattern))]
80
+ by_name = {c.name: c for c in cells}
81
+ broken = [(c, c.problem(min_steps, need_closing)) for c in cells]
82
+ broken = [(c, why) for c, why in broken if why]
83
+ absent = [n for n in (expected or []) if n not in by_name]
84
+ return cells, broken, absent
85
+
86
+
87
+ def main(argv=None) -> int:
88
+ ap = argparse.ArgumentParser(prog="masterwork cells", description="completeness gate for a battery")
89
+ ap.add_argument("pattern", help="glob of per-cell record files")
90
+ ap.add_argument("--expect", help="file listing expected cell names, one per line")
91
+ ap.add_argument("--min-steps", type=int, default=2,
92
+ help="a transcript shorter than this did not run")
93
+ ap.add_argument("--no-closing-required", action="store_true",
94
+ help="accept cells with no closing text (say why in the record)")
95
+ ap.add_argument("--allow-missing", type=int, default=0,
96
+ help="proceed with this many cells short — an explicit, "
97
+ "recorded choice, never a default")
98
+ ap.add_argument("--transcript-key", default="messages")
99
+ ap.add_argument("--closing-key", default="final_text")
100
+ a = ap.parse_args(argv)
101
+
102
+ expected = None
103
+ if a.expect:
104
+ expected = [l.strip() for l in open(a.expect, encoding="utf-8")
105
+ if l.strip() and not l.startswith("#")]
106
+
107
+ cells, broken, absent = inspect(
108
+ a.pattern, expected, a.min_steps, not a.no_closing_required,
109
+ a.transcript_key, a.closing_key)
110
+
111
+ good = len(cells) - len(broken)
112
+ want = len(expected) if expected else len(cells)
113
+ print(f"cells found {len(cells)} · complete {good} · expected {want}")
114
+ for c, why in broken:
115
+ print(f" INCOMPLETE {c.name}: {why}")
116
+ for n in absent:
117
+ print(f" ABSENT {n}")
118
+
119
+ if not cells:
120
+ print(f"\nHELD: no cells matched {a.pattern!r}. An empty grid is not a "
121
+ f"complete one — check the pattern, and quote it so the shell "
122
+ f"does not expand it.")
123
+ return 1
124
+
125
+ short = want - good
126
+ if short <= 0:
127
+ print("grid complete")
128
+ return 0
129
+ if short <= a.allow_missing:
130
+ print(f"proceeding {short} cell(s) short — allowed explicitly "
131
+ f"(--allow-missing {a.allow_missing}); record this alongside the result, "
132
+ f"missing cells are not a random sample")
133
+ return 0
134
+ print(f"\nHELD: {short} cell(s) short of the grid. Fix the run, or state the "
135
+ f"allowance with --allow-missing and say why in the record.")
136
+ return 1
137
+
138
+
139
+ if __name__ == "__main__":
140
+ sys.exit(main())
masterwork/ceremony.py ADDED
@@ -0,0 +1,205 @@
1
+ """The sitting where the piece is made.
2
+
3
+ The model reads the teachings and is asked, one axis at a time, what it will
4
+ hold to. Its answers are its own; nothing is asserted at it and no earlier
5
+ commitment is in view. At the end it distils its own answers into one
6
+ standing text, and that text — not anything written for it — becomes the
7
+ identity it reads on every later request.
8
+
9
+ Four properties are the recipe rather than implementation detail, and each
10
+ was paid for:
11
+
12
+ * **Anchorless.** No previous commitment or name is anywhere in the
13
+ context. With one present the model copies it, and a copy is a role
14
+ being worn rather than a pattern re-derived. It also destroys the
15
+ measurement: you can no longer tell whether the teachings carried.
16
+ * **One session.** Every answer is an assistant turn in the same dialogue,
17
+ so the model is distilling something it actually said a moment ago, not
18
+ a text handed to it as if it were its own.
19
+ * **Order is shuffled by a seed.** Answers otherwise echo their
20
+ neighbours in the order they were asked.
21
+ * **The distillation is not a rewrite.** The closing turn says: these are
22
+ your words, remove repetition, join where they meet, drop nothing.
23
+ Asked to "write an identity" the model produces a description of one.
24
+
25
+ Everything that varies between workshops — the teachings, the questions,
26
+ the closing instruction, the sampling profile — is data. What lives here is
27
+ the shape of the sitting.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import hashlib
33
+ import json
34
+ import os
35
+ import random
36
+ import sys
37
+ import time
38
+ import urllib.request
39
+
40
+ # The sampling used for the sitting must match the sampling the piece will
41
+ # later be read under; a candidate made at one temperature and run at
42
+ # another is not the candidate that was measured.
43
+ DEFAULT_PARAMS = {"temperature": 0.15, "top_p": 0.9, "min_p": 0.05,
44
+ "presence_penalty": 0.1, "repeat_penalty": 1.05}
45
+
46
+
47
+ def ask(endpoint: str, messages: list[dict], model: str | None = None,
48
+ params: dict | None = None, max_tokens: int = 8000,
49
+ sampling_seed: int | None = None, thinking: bool = True,
50
+ timeout: int = 600, _attempt: int = 0) -> tuple[str, str]:
51
+ """One turn. Returns (text, reasoning).
52
+
53
+ An empty answer from a reasoning model usually means the thinking ate
54
+ the budget, not that it refused. Retrying with a larger budget is the
55
+ documented behaviour rather than a silent zero — a zero here would sail
56
+ through every later stage looking like an answer.
57
+ """
58
+ body = {"model": model or "default", "messages": messages,
59
+ "max_tokens": max_tokens,
60
+ "chat_template_kwargs": {"enable_thinking": thinking},
61
+ **(params if params is not None else DEFAULT_PARAMS)}
62
+ if sampling_seed is not None:
63
+ body["seed"] = sampling_seed
64
+ req = urllib.request.Request(
65
+ endpoint.rstrip("/") + "/v1/chat/completions",
66
+ json.dumps(body).encode(), {"Content-Type": "application/json"})
67
+ with urllib.request.urlopen(req, timeout=timeout) as r:
68
+ d = json.load(r)
69
+ msg = d["choices"][0]["message"]
70
+ text = (msg.get("content") or "").strip()
71
+ thought = msg.get("reasoning_content") or ""
72
+ if not text and _attempt < 3:
73
+ return ask(endpoint, messages, model, params, int(max_tokens * 1.6),
74
+ sampling_seed, thinking, timeout, _attempt + 1)
75
+ return text, thought
76
+
77
+
78
+ def _md5(text: str) -> str:
79
+ return hashlib.md5(text.encode()).hexdigest()
80
+
81
+
82
+ def hold(corpus: str, script: dict, endpoint: str, model: str | None = None,
83
+ params: dict | None = None, order_seed: int = 0,
84
+ sampling_seed: int | None = None, max_tokens: int = 8000,
85
+ report=lambda *_: None) -> dict:
86
+ """Run the sitting. Returns the transcript, the name and the standing text.
87
+
88
+ `script` carries the workshop's words: questions (label + text), the
89
+ closing appended to each, the name question and the distillation
90
+ question. Nothing here is embedded, so a house can change what it asks
91
+ without touching the shape of the asking.
92
+ """
93
+ questions = list(script["questions"])
94
+ random.Random(order_seed).shuffle(questions)
95
+ closing = script.get("closing", "")
96
+
97
+ messages: list[dict] = []
98
+ rounds = []
99
+ for i, q in enumerate(questions):
100
+ text = q["text"] + closing
101
+ if i == 0:
102
+ messages.append({"role": "user", "content": corpus + "\n\n" + text})
103
+ else:
104
+ messages.append({"role": "user", "content": text})
105
+ answer, thought = ask(endpoint, messages, model, params, max_tokens,
106
+ sampling_seed)
107
+ if not answer:
108
+ raise RuntimeError(f"empty answer at {q['label']} — the sitting is "
109
+ f"incomplete and must not be sealed")
110
+ messages.append({"role": "assistant", "content": answer})
111
+ rounds.append({"label": q["label"], "answer": answer, "thought": thought})
112
+ report(f" {q['label']} ({len(answer)} chars)")
113
+
114
+ messages.append({"role": "user", "content": script["name"]})
115
+ name_answer, _ = ask(endpoint, messages, model, params, max_tokens, sampling_seed)
116
+ messages.append({"role": "assistant", "content": name_answer})
117
+ name = ""
118
+ for line in name_answer.splitlines():
119
+ if line.strip().lower().startswith("name:"):
120
+ name = line.split(":", 1)[1].strip()
121
+ report(f" name: {name or '(not given in the asked shape)'}")
122
+
123
+ messages.append({"role": "user", "content": script["distil"]})
124
+ standing, _ = ask(endpoint, messages, model, params,
125
+ max(max_tokens, 12000), sampling_seed)
126
+ if not standing:
127
+ raise RuntimeError("empty standing text — nothing to seal")
128
+ report(f" standing text: {len(standing)} chars")
129
+
130
+ return {
131
+ "name": name,
132
+ "name_answer": name_answer,
133
+ "text": standing,
134
+ "rounds": rounds,
135
+ "order_seed": order_seed,
136
+ "sampling_seed": sampling_seed,
137
+ "corpus_hash": _md5(corpus),
138
+ "script_hash": _md5(json.dumps(script, sort_keys=True, ensure_ascii=False)),
139
+ "questions_asked": [q["label"] for q in questions],
140
+ "date": time.strftime("%Y-%m-%d"),
141
+ }
142
+
143
+
144
+ def seal_text(transcript: dict, script_hash: str | None = None) -> str:
145
+ """The piece plus its maker's mark, in the shape the seal reader expects.
146
+
147
+ Refuses to stamp a mark it cannot make again. Formatting an absent seed
148
+ into the header writes the word "None", which looks like a value.
149
+ """
150
+ for field in ("corpus_hash", "order_seed", "sampling_seed", "date"):
151
+ if transcript.get(field) in (None, ""):
152
+ raise ValueError(
153
+ f"cannot seal: {field} is unset. Two candidates from one corpus "
154
+ f"that differ only by sampling seed are different candidates, "
155
+ f"so a piece made without one cannot be made again.")
156
+ return (
157
+ "# masterwork seal\n"
158
+ f"# name: {transcript.get('name') or '(unnamed)'}\n"
159
+ f"# corpus hash: {transcript['corpus_hash']}\n"
160
+ f"# script hash: {script_hash or transcript['script_hash']}\n"
161
+ f"# question seed: {transcript['order_seed']} "
162
+ f"· sampling seed: {transcript.get('sampling_seed')}\n"
163
+ f"# date: {transcript['date']}\n\n"
164
+ + transcript["text"].strip() + "\n")
165
+
166
+
167
+ def main(argv=None) -> int:
168
+ ap = argparse.ArgumentParser(prog="masterwork ceremony", description="hold a sitting and seal the piece")
169
+ ap.add_argument("corpus", help="the teachings, as text")
170
+ ap.add_argument("script", help="JSON: questions, closing, name, distil")
171
+ ap.add_argument("--endpoint", required=True)
172
+ ap.add_argument("--model")
173
+ ap.add_argument("--params-file", help="JSON sampling profile; must match "
174
+ "the profile the piece is later read under")
175
+ ap.add_argument("--order-seed", type=int, default=0)
176
+ ap.add_argument("--sampling-seed", type=int,
177
+ help="two candidates from one corpus differing only by this "
178
+ "seed are different candidates, so it is recorded")
179
+ ap.add_argument("--max-tokens", type=int, default=8000)
180
+ ap.add_argument("--out", required=True, help="where to write the sealed piece")
181
+ ap.add_argument("--transcript", help="where to write the sitting itself")
182
+ a = ap.parse_args(argv)
183
+
184
+ corpus = open(a.corpus, encoding="utf-8").read()
185
+ script = json.load(open(a.script, encoding="utf-8"))
186
+ params = json.load(open(a.params_file, encoding="utf-8")) if a.params_file else None
187
+
188
+ print(f"sitting: {len(script['questions'])} questions, order seed "
189
+ f"{a.order_seed}, sampling seed {a.sampling_seed}", file=sys.stderr)
190
+ transcript = hold(corpus, script, a.endpoint, a.model, params,
191
+ a.order_seed, a.sampling_seed, a.max_tokens,
192
+ report=lambda m: print(m, file=sys.stderr, flush=True))
193
+
194
+ os.makedirs(os.path.dirname(os.path.abspath(a.out)), exist_ok=True)
195
+ with open(a.out, "w", encoding="utf-8") as f:
196
+ f.write(seal_text(transcript))
197
+ if a.transcript:
198
+ json.dump(transcript, open(a.transcript, "w", encoding="utf-8"),
199
+ ensure_ascii=False, indent=1)
200
+ print(f"sealed -> {a.out}", file=sys.stderr)
201
+ return 0
202
+
203
+
204
+ if __name__ == "__main__":
205
+ sys.exit(main())
masterwork/gate.py ADDED
@@ -0,0 +1,258 @@
1
+ """The frozen gate: a threshold is only a threshold if it clears the noise.
2
+
3
+ Before a run decides anything, the decision rule is written down and frozen.
4
+ Looking at results afterwards is fine; moving the rule is not. But a frozen
5
+ rule is still worthless if the threshold sits inside the arm's own run-to-run
6
+ spread — then the gate is not reading the effect, it is reading the sampling.
7
+
8
+ So a gate section must carry three lines, and this checker enforces all
9
+ three by executing the first one:
10
+
11
+ band-command: <a command that COMPUTES the spread>
12
+ band-value: <the spread it produces, with n>
13
+ threshold: <the decision threshold — must be above the band>
14
+
15
+ The reason it is three lines rather than a principle: written as a
16
+ principle, it gets read, agreed with, and skipped. A skip then costs
17
+ nothing and shows up nowhere. With three required lines, skipping means
18
+ actively leaving them blank, which is visible.
19
+
20
+ Two verdicts other than pass, both deliberate:
21
+
22
+ * A band-command that merely prints a number already written in its own
23
+ text is not a measurement, it is a quotation. That returns UNVERIFIABLE
24
+ (exit 2) — a human has to sign for it — rather than passing quietly.
25
+ * A section that produces a decision but carries none of the three lines
26
+ fails outright. A section that is deliberately not a gate says so with
27
+ `gate-skip: <reason>`, and the reason is printed, so exemptions cannot
28
+ hide as silence.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import glob
34
+ import os
35
+ import re
36
+ import subprocess
37
+ import sys
38
+
39
+ FIELDS = ("band-command", "band-value", "threshold")
40
+ # A section that names an axis is applied to the measured report as well as
41
+ # checked for validity. Without `measure`, a gate is only a written rule and
42
+ # a human still has to bind it to numbers — which is where a verdict quietly
43
+ # becomes an opinion.
44
+ APPLY = ("measure", "compare")
45
+ # Field names are data, like seal headers: a workshop writing its gates in
46
+ # another language passes --profile {canonical: [aliases]} rather than
47
+ # translating frozen documents, which would edit the record after the fact.
48
+ DEFAULT_FIELD_ALIASES: dict[str, list[str]] = {f: [f] for f in FIELDS}
49
+ SKIP = re.compile(r"^\s*gate-skip\s*:\s*(.+)$", re.M | re.I)
50
+ # A section that decides something: an explicit verdict word or a comparison.
51
+ DECIDES = re.compile(r"^.*(?:\b(?:accept|reject|veto|pass|fail)\b\s*:|>=|<=|≥|≤).*$",
52
+ re.M | re.I)
53
+ NUMBER = re.compile(r"-?\d+(?:[.,]\d+)?")
54
+ ABOVE = re.compile(r"\babove\b", re.I)
55
+ # "above" is a word, and words are dialect like field names. A workshop that
56
+ # writes its frozen gates in another language should configure the reader
57
+ # rather than translate documents that were frozen before a run.
58
+ ABOVE_WORDS = ("above",)
59
+
60
+
61
+ def numbers(text: str) -> list[float]:
62
+ return [float(m.group(0).replace(",", ".")) for m in NUMBER.finditer(text)]
63
+
64
+
65
+ def first_number(text: str):
66
+ n = numbers(text)
67
+ return n[0] if n else None
68
+
69
+
70
+ def field(body: str, name: str, aliases: dict | None = None):
71
+ for alias in (aliases or DEFAULT_FIELD_ALIASES).get(name, [name]):
72
+ m = re.search(rf"^\s*{re.escape(alias)}\s*:\s*(.+)$", body, re.M | re.I)
73
+ if m:
74
+ return m.group(1).strip()
75
+ return None
76
+
77
+
78
+ def sections(text: str):
79
+ for part in re.split(r"^##\s+", text, flags=re.M)[1:]:
80
+ title, _, body = part.partition("\n")
81
+ yield title.strip(), body
82
+
83
+
84
+ def _above(text: str, aliases: dict | None) -> bool:
85
+ words = (aliases or {}).get("above") or ABOVE_WORDS
86
+ return any(re.search(rf"\b{re.escape(w)}\b", text, re.I) for w in words)
87
+
88
+
89
+ def check_section(body: str, timeout: int = 60, aliases: dict | None = None):
90
+ """Return (verdict, notes). verdict: PASS | FAIL | UNVERIFIABLE | SKIP."""
91
+ skip = SKIP.search(body)
92
+ if skip:
93
+ return "SKIP", [f"declared not a gate: {skip.group(1).strip()}"]
94
+
95
+ missing = [f for f in FIELDS if field(body, f, aliases) is None]
96
+ if len(missing) == len(FIELDS):
97
+ line = DECIDES.search(body)
98
+ return "FAIL", ["decides something but no band was measured",
99
+ f"matched decision line: {line.group(0).strip()[:100]!r}"
100
+ if line else "",
101
+ "if this is not a gate, add `gate-skip: <reason>`"]
102
+ if missing:
103
+ return "FAIL", [f"missing: {', '.join(missing)} — the gate is void"]
104
+
105
+ command = field(body, "band-command", aliases)
106
+ written = first_number(field(body, "band-value", aliases) or "")
107
+ placement = field(body, "threshold", aliases) or ""
108
+
109
+ try:
110
+ p = subprocess.run(["bash", "-lc", command], capture_output=True,
111
+ text=True, timeout=timeout)
112
+ except subprocess.TimeoutExpired:
113
+ return "FAIL", [f"band-command did not finish in {timeout}s"]
114
+ if p.returncode != 0:
115
+ return "FAIL", [f"band-command failed (rc={p.returncode}): "
116
+ f"{(p.stderr or p.stdout).strip()[:200]}"]
117
+
118
+ produced = numbers(p.stdout)
119
+ if not produced or written is None:
120
+ return "UNVERIFIABLE", [f"band is not numeric; output was "
121
+ f"{p.stdout.strip()[:120]!r} — sign for it by hand"]
122
+
123
+ close = [x for x in produced if abs(x - written) <= max(0.02, 0.05 * abs(x))]
124
+ if not close:
125
+ return "FAIL", [f"band-value says {written} but the command produces "
126
+ f"{produced[:6]} — the band was written from memory"]
127
+
128
+ if any(abs(x - written) <= 1e-9 for x in numbers(command)):
129
+ return "UNVERIFIABLE", [f"band-command does not compute {written}, it "
130
+ f"restates it — a quotation, not a measurement"]
131
+
132
+ band = close[0]
133
+ if not _above(placement, aliases):
134
+ return "FAIL", [f"threshold does not claim to be above the band: {placement!r}"]
135
+ threshold = first_number(placement)
136
+ if threshold is None:
137
+ return "UNVERIFIABLE", [f"threshold is not numeric: {placement!r}"]
138
+ if threshold <= band:
139
+ return "FAIL", [f"threshold {threshold} <= band {band:.4f} — this gate "
140
+ f"reads sampling noise, not effect"]
141
+ sound = (f"band {band:.4f} (computed) · threshold {threshold} "
142
+ f"· margin {threshold - band:+.4f}")
143
+ # Sound rule, nothing to apply it to. Said here, at freeze time, because
144
+ # the alternative is learning it after the run — when a human binds the
145
+ # rule by hand and the verdict quietly becomes an opinion.
146
+ if not field(body, "measure", aliases):
147
+ return "UNBOUND", [sound, "no `measure:` field — this rule can never "
148
+ "touch a report; name the axis now, not later"]
149
+ return "PASS", [sound]
150
+
151
+
152
+ def evaluate_section(body: str, measured: dict, incumbent: dict | None = None,
153
+ aliases: dict | None = None):
154
+ """Apply a checked gate to measured axes. (verdict, notes).
155
+
156
+ verdict: ACCEPT | REJECT | UNRESOLVED | NOT_APPLIED
157
+
158
+ UNRESOLVED is a real answer and is kept distinct from "no difference".
159
+ A difference smaller than the threshold has not been shown to be absent;
160
+ it has been shown to be unresolvable at this size, and the note says how
161
+ many cells per arm it would take — otherwise the next reader turns a
162
+ silence into a finding.
163
+ """
164
+ axis = field(body, "measure", aliases)
165
+ if not axis:
166
+ return "NOT_APPLIED", ["no `measure:` field — the rule is written but "
167
+ "never bound to a number"]
168
+ if axis not in measured:
169
+ return "NOT_APPLIED", [f"axis {axis!r} is not in the report "
170
+ f"(has: {', '.join(sorted(measured)) or 'nothing'})"]
171
+
172
+ threshold = first_number(field(body, "threshold", aliases) or "")
173
+ band = first_number(field(body, "band-value", aliases) or "")
174
+ compare = (field(body, "compare", aliases) or "candidate").lower()
175
+ value = measured[axis]
176
+
177
+ if "incumbent" in compare:
178
+ if not incumbent or axis not in incumbent:
179
+ return "NOT_APPLIED", [f"comparison needs the incumbent's {axis}, "
180
+ f"which was not supplied"]
181
+ difference = value - incumbent[axis]
182
+ shown = (f"{axis}: candidate {value:.4f} − incumbent "
183
+ f"{incumbent[axis]:.4f} = {difference:+.4f}")
184
+ else:
185
+ difference = value
186
+ shown = f"{axis}: {value:.4f} (absolute)"
187
+
188
+ if threshold is None:
189
+ return "NOT_APPLIED", [shown, "threshold is not numeric"]
190
+ if difference >= threshold:
191
+ return "ACCEPT", [shown, f"clears +{threshold}"]
192
+ if difference <= -threshold:
193
+ return "REJECT", [shown, f"falls short by {threshold}"]
194
+
195
+ note = [shown, f"inside ±{threshold} — NOT resolvable at this size, "
196
+ f"which is not the same as no difference"]
197
+ if band and abs(difference) > 0:
198
+ # Band shrinks as 1/sqrt(n); say what size would settle this.
199
+ note.append(f"a difference of {abs(difference):.4f} needs a band below "
200
+ f"it: roughly {(band / abs(difference)) ** 2:.1f}x the cells "
201
+ f"used for this band")
202
+ return "UNRESOLVED", note
203
+
204
+
205
+ def main(argv=None) -> int:
206
+ ap = argparse.ArgumentParser(prog="masterwork gate", description="check frozen gate files")
207
+ ap.add_argument("paths", nargs="+", help="gate files, or a directory to walk")
208
+ ap.add_argument("--timeout", type=int, default=60)
209
+ ap.add_argument("--profile", help="JSON map {canonical field: [aliases]}")
210
+ a = ap.parse_args(argv)
211
+
212
+ aliases = dict(DEFAULT_FIELD_ALIASES)
213
+ if a.profile:
214
+ import json as _json
215
+ for k, v in _json.load(open(a.profile, encoding="utf-8")).items():
216
+ aliases[k] = list(v) + aliases.get(k, [])
217
+ files = []
218
+ for p in a.paths:
219
+ if os.path.isdir(p):
220
+ files.extend(sorted(glob.glob(os.path.join(p, "**", "*.md"),
221
+ recursive=True)))
222
+ elif os.path.exists(p):
223
+ files.append(p)
224
+ else:
225
+ print(f"HELD: no gate file or directory at {p}")
226
+ return 1
227
+ if not files:
228
+ print(f"HELD: no gate files under {', '.join(a.paths)} — a gate is a "
229
+ f"markdown file; gates/example.md is the smallest one")
230
+ return 1
231
+
232
+ tally: dict[str, int] = {}
233
+ for path in files:
234
+ text = open(path, encoding="utf-8").read()
235
+ printed = False
236
+ for title, body in sections(text):
237
+ if not (DECIDES.search(body) or field(body, "band-command", aliases)):
238
+ continue
239
+ verdict, notes = check_section(body, a.timeout, aliases)
240
+ if not printed:
241
+ print(f"--- {path}")
242
+ printed = True
243
+ print(f" [{verdict:<12}] {title}")
244
+ for n in notes:
245
+ if n:
246
+ print(f" {n}")
247
+ tally[verdict] = tally.get(verdict, 0) + 1
248
+
249
+ print("\n" + " · ".join(f"{v} {k}" for k, v in sorted(tally.items())) or "nothing to check")
250
+ if tally.get("FAIL"):
251
+ return 1
252
+ if tally.get("UNVERIFIABLE") or tally.get("UNBOUND"):
253
+ return 2
254
+ return 0
255
+
256
+
257
+ if __name__ == "__main__":
258
+ sys.exit(main())