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/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""The line: seal → generate → completeness → label → judge → gate → persist.
|
|
2
|
+
|
|
3
|
+
The version lives here and nowhere else. It is pre-release: nothing has
|
|
4
|
+
been published, and the number exists so the tool can answer for itself
|
|
5
|
+
rather than so a package index can order it.
|
|
6
|
+
"""
|
|
7
|
+
__version__ = "0.0.1"
|
masterwork/__main__.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""One door into the line.
|
|
3
|
+
|
|
4
|
+
Every stage was reachable only as `python3 -m masterwork <module>`, which
|
|
5
|
+
asks a reader to know four module paths that are not discoverable from
|
|
6
|
+
each other, and printed the interpreter's own path in its usage line —
|
|
7
|
+
a different string on every machine, and not a command anyone would type.
|
|
8
|
+
|
|
9
|
+
python3 -m masterwork # what this is, and its commands
|
|
10
|
+
python3 -m masterwork gate gates/ # any stage, by name
|
|
11
|
+
python3 -m masterwork --version
|
|
12
|
+
|
|
13
|
+
Each command is the stage's own parser, unchanged; this only routes to it
|
|
14
|
+
and gives it a name a person could have typed.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import importlib
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
from masterwork import __version__
|
|
23
|
+
|
|
24
|
+
NAME = "MASTERWORK"
|
|
25
|
+
RIGHT = "character production line"
|
|
26
|
+
TAGLINE = "makes the piece; it does not score it"
|
|
27
|
+
PAD = 2
|
|
28
|
+
GAP = 4 # least space between the name and the words on its right
|
|
29
|
+
|
|
30
|
+
# command -> (module, one-line help). The help is the stage's own
|
|
31
|
+
# description, so the two cannot say different things.
|
|
32
|
+
COMMANDS = {
|
|
33
|
+
"line": ("masterwork.line", "run one campaign through the line"),
|
|
34
|
+
"campaign": ("masterwork.campaign", "run a campaign: repeats, band, comparison"),
|
|
35
|
+
"ceremony": ("masterwork.ceremony", "hold a sitting and seal the piece"),
|
|
36
|
+
"gate": ("masterwork.gate", "check frozen gate files"),
|
|
37
|
+
"cells": ("masterwork.cells", "completeness gate for a battery"),
|
|
38
|
+
"seal": ("masterwork.seal", "verify a candidate's seal"),
|
|
39
|
+
"pairs": ("masterwork.pairs", "cut training data from scored runs"),
|
|
40
|
+
"retain": ("masterwork.retain", "run-directory retention"),
|
|
41
|
+
"label": ("masterwork.blind_label", "blind-label cells for a judged axis"),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def banner(version: str) -> str:
|
|
46
|
+
"""The four box lines, all of equal display width.
|
|
47
|
+
|
|
48
|
+
Built from its content rather than written out, because a hand-drawn
|
|
49
|
+
box drifts and nobody looks at the first thing a user sees.
|
|
50
|
+
"""
|
|
51
|
+
left = f"{NAME} v{version}"
|
|
52
|
+
width = max(len(left) + GAP + len(RIGHT), len(TAGLINE))
|
|
53
|
+
inner = width + 2 * PAD
|
|
54
|
+
space = " " * PAD
|
|
55
|
+
return "\n".join([
|
|
56
|
+
"┌" + "─" * inner + "┐",
|
|
57
|
+
"│" + space + left + " " * (width - len(left) - len(RIGHT))
|
|
58
|
+
+ RIGHT + space + "│",
|
|
59
|
+
"│" + space + TAGLINE + " " * (width - len(TAGLINE)) + space + "│",
|
|
60
|
+
"└" + "─" * inner + "┘",
|
|
61
|
+
])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
65
|
+
ap = argparse.ArgumentParser(
|
|
66
|
+
prog="masterwork",
|
|
67
|
+
description="the line that makes a character; journeyman scores it")
|
|
68
|
+
ap.add_argument("--version", action="version",
|
|
69
|
+
version=f"masterwork {__version__}")
|
|
70
|
+
ap.add_argument("-q", "--quiet", action="store_true",
|
|
71
|
+
help="no banner; results, warnings and errors still print")
|
|
72
|
+
sub = ap.add_subparsers(dest="cmd", required=False)
|
|
73
|
+
for name, (_, blurb) in COMMANDS.items():
|
|
74
|
+
sub.add_parser(name, help=blurb, add_help=False)
|
|
75
|
+
return ap
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main(argv=None) -> int:
|
|
79
|
+
"""Route to a stage, or introduce the line when asked for nothing.
|
|
80
|
+
|
|
81
|
+
Chrome goes to stderr once a stage is running, so a stage's real output
|
|
82
|
+
can be piped without the mark in it. The bare call is the exception:
|
|
83
|
+
there the mark and the command list *are* the answer, so they go to
|
|
84
|
+
stdout with exit 0.
|
|
85
|
+
"""
|
|
86
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
87
|
+
ap = build_parser()
|
|
88
|
+
|
|
89
|
+
at = next((i for i, a in enumerate(argv) if not a.startswith("-")), None)
|
|
90
|
+
before = argv if at is None else argv[:at]
|
|
91
|
+
quiet = "-q" in before or "--quiet" in before
|
|
92
|
+
|
|
93
|
+
if at is None:
|
|
94
|
+
ap.parse_args(argv) # --version / -h exit here
|
|
95
|
+
if not quiet:
|
|
96
|
+
print(banner(__version__))
|
|
97
|
+
ap.print_help()
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
cmd = argv[at]
|
|
101
|
+
if cmd not in COMMANDS:
|
|
102
|
+
ap.parse_args(argv) # argparse names the unknown command, exit 2
|
|
103
|
+
return 2
|
|
104
|
+
|
|
105
|
+
if not quiet:
|
|
106
|
+
print(banner(__version__), file=sys.stderr)
|
|
107
|
+
|
|
108
|
+
module = importlib.import_module(COMMANDS[cmd][0])
|
|
109
|
+
# The stage's parser takes its name from argv[0]; give it one a person
|
|
110
|
+
# could have typed instead of the interpreter's path.
|
|
111
|
+
sys.argv = [f"masterwork {cmd}"] + argv[at + 1:]
|
|
112
|
+
return module.main() or 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
sys.exit(main())
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Blind labelling: the labeller sees the exchange and nothing that identifies it.
|
|
3
|
+
|
|
4
|
+
Some axes cannot be counted. Whether an answer named its own uncertainty, or
|
|
5
|
+
quietly answered about a different thing than the one asked, is a judgement,
|
|
6
|
+
and a judgement made by whoever hoped for a particular result is not evidence.
|
|
7
|
+
The line therefore stops at `HELD_FOR_LABELLING` rather than guessing. This is
|
|
8
|
+
the instrument that answers the hold.
|
|
9
|
+
|
|
10
|
+
Blindness here is mechanical, not a promise:
|
|
11
|
+
|
|
12
|
+
* **Whitelist, never blacklist.** Only fields the rubric names by path reach
|
|
13
|
+
the prompt. An arm name, a seed, an expectation cannot leak by being
|
|
14
|
+
forgotten, because nothing leaves the record unless it was asked for.
|
|
15
|
+
* **The filename is a leak.** Cell files are called things like
|
|
16
|
+
`candidate_s4242.json`, so the file is renamed to a blind id before it is
|
|
17
|
+
shown. Checking the whole name back out of the prompt is not enough: what
|
|
18
|
+
identifies an arm is the *part* of the name that differs between cells. So
|
|
19
|
+
the grid's names are split into words and digits, the ones that some cells
|
|
20
|
+
carry and others do not are the telling ones, and a prompt containing one
|
|
21
|
+
of its own cell's telling words is refused rather than sent. A word every
|
|
22
|
+
cell shares — the scene, usually — groups nothing and is left alone.
|
|
23
|
+
The backstop is built from the names of the files in the grid, so it has
|
|
24
|
+
nothing to work with when cells are named neutrally — `cell_01`,
|
|
25
|
+
`cell_02`. There, name the arms yourself with `--arm-words`. The whitelist
|
|
26
|
+
is the guard; this is the net under it.
|
|
27
|
+
* **The order is shuffled by a declared seed.** Cells otherwise arrive
|
|
28
|
+
grouped by arm and the labeller reads the grouping.
|
|
29
|
+
* **The key is written to a separate file** and never read by `label`.
|
|
30
|
+
`reveal` opens it afterwards and joins the labels back to the cells. If
|
|
31
|
+
the labeller is itself an agent with a filesystem, put the key somewhere
|
|
32
|
+
it cannot reach with `--key`: "another file in the same directory" is a
|
|
33
|
+
weaker separation than the word suggests.
|
|
34
|
+
* **Labelling twice is not free.** Relabelling after seeing which arm won is
|
|
35
|
+
the failure this whole dance exists to prevent, so overwriting a label the
|
|
36
|
+
line already has takes `--relabel`, and the flag lands on every label
|
|
37
|
+
written. The refusal sits in `reveal`, on the path the line reads, because
|
|
38
|
+
a guard on the working directory guards nothing: that directory is a
|
|
39
|
+
string the operator types, and one different character starts a clean run.
|
|
40
|
+
|
|
41
|
+
Each label carries the hash of the cell it judged, so a label left over from
|
|
42
|
+
an earlier run is caught rather than counted. Nothing here can tell whether
|
|
43
|
+
`--labeller` and `--generated-by` are truthful — they are two strings someone
|
|
44
|
+
typed, and a house determined to grade its own piece can type two names. What
|
|
45
|
+
this refuses is the accident, and what it makes is a record.
|
|
46
|
+
|
|
47
|
+
Two things it deliberately does not do. It has no model client: the labeller
|
|
48
|
+
is a command that reads a prompt on stdin and writes an answer on stdout, so
|
|
49
|
+
the instrument is the same whoever is judging. And it never invents a verdict
|
|
50
|
+
— an answer that will not parse after its retries is written as `null`, which
|
|
51
|
+
the line reads as an unlabelled cell and holds on. An unparseable label that
|
|
52
|
+
became a category would be a number nobody measured.
|
|
53
|
+
|
|
54
|
+
masterwork/blind_label.py label --cells 'runs/x/cells/*.json' --rubric r.json \
|
|
55
|
+
--command 'my-judge' --out runs/x/labels --blind-seed 20260829 \
|
|
56
|
+
--labeller some-model --generated-by the-candidate
|
|
57
|
+
masterwork/blind_label.py reveal --out runs/x/labels --to 'runs/x/labels/{cell}.json'
|
|
58
|
+
"""
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import argparse
|
|
62
|
+
import glob
|
|
63
|
+
import hashlib
|
|
64
|
+
import json
|
|
65
|
+
import os
|
|
66
|
+
import random
|
|
67
|
+
import re
|
|
68
|
+
import subprocess
|
|
69
|
+
import sys
|
|
70
|
+
|
|
71
|
+
FENCE = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.S)
|
|
72
|
+
BARE = re.compile(r"(\{.*\})", re.S)
|
|
73
|
+
WORD = re.compile(r"[A-Za-z]+|\d+")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def telling(stems: list[str]) -> dict[str, set[str]]:
|
|
77
|
+
"""Per cell, the words of its name that not every cell in the grid shares.
|
|
78
|
+
|
|
79
|
+
`candidate_s4242` and `incumbent_s4242` differ in the arm and agree on the
|
|
80
|
+
seed, so `candidate` tells you which arm a cell is and `4242` tells you
|
|
81
|
+
nothing. Words of one character are dropped: they collide with prose.
|
|
82
|
+
"""
|
|
83
|
+
words = {s: {w.lower() for w in WORD.findall(s) if len(w) > 1} for s in stems}
|
|
84
|
+
# With one cell there is nothing to share, so every word still tells.
|
|
85
|
+
shared = set.intersection(*words.values()) if len(words) > 1 else set()
|
|
86
|
+
return {s: w - shared for s, w in words.items()}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def dig(record, path: str):
|
|
90
|
+
"""Follow a dotted path into a record. Integers index lists."""
|
|
91
|
+
cur = record
|
|
92
|
+
for part in path.split("."):
|
|
93
|
+
if isinstance(cur, list):
|
|
94
|
+
cur = cur[int(part)]
|
|
95
|
+
else:
|
|
96
|
+
cur = cur[part]
|
|
97
|
+
return cur
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def render(rubric: dict, record: dict) -> str:
|
|
101
|
+
"""Build the prompt from named fields only — this is where blindness lives."""
|
|
102
|
+
values = {name: dig(record, path) for name, path in rubric["fields"].items()}
|
|
103
|
+
values["verdicts"] = " | ".join(rubric["verdicts"])
|
|
104
|
+
return rubric["prompt"].format(**values)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def ask(command: str, prompt: str, timeout: int) -> str:
|
|
108
|
+
p = subprocess.run(["bash", "-lc", command], input=prompt, text=True,
|
|
109
|
+
capture_output=True, timeout=timeout,
|
|
110
|
+
env=dict(os.environ, PYTHONUNBUFFERED="1"))
|
|
111
|
+
if p.returncode != 0:
|
|
112
|
+
raise RuntimeError(f"labeller exited {p.returncode}: {p.stderr[-400:]}")
|
|
113
|
+
return p.stdout
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def parse(text: str, rubric: dict):
|
|
117
|
+
"""Return (verdict, why) or (None, reason) — never a guess."""
|
|
118
|
+
for pattern in (FENCE, BARE):
|
|
119
|
+
found = pattern.findall(text)
|
|
120
|
+
if not found:
|
|
121
|
+
continue
|
|
122
|
+
try:
|
|
123
|
+
obj = json.loads(found[-1])
|
|
124
|
+
except Exception:
|
|
125
|
+
continue
|
|
126
|
+
verdict = obj.get(rubric["axis"])
|
|
127
|
+
if verdict in rubric["verdicts"]:
|
|
128
|
+
return verdict, str(obj.get("why", ""))[:200]
|
|
129
|
+
return None, f"verdict {verdict!r} is outside the rubric"
|
|
130
|
+
return None, "no JSON object in the answer"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def label(argv=None) -> int:
|
|
134
|
+
ap = argparse.ArgumentParser(prog="masterwork label", description="label cells blind")
|
|
135
|
+
ap.add_argument("--cells", required=True, help="glob of per-cell records (quoted)")
|
|
136
|
+
ap.add_argument("--rubric", required=True, help="axis, verdicts, fields, prompt")
|
|
137
|
+
ap.add_argument("--command", required=True,
|
|
138
|
+
help="labeller: reads the prompt on stdin, writes the answer on stdout")
|
|
139
|
+
ap.add_argument("--out", required=True, help="directory for the blind labels")
|
|
140
|
+
ap.add_argument("--key", help="where the blind-id -> cell map goes "
|
|
141
|
+
"(default <out>/key.json); put it out of the "
|
|
142
|
+
"labeller's reach if the labeller has one")
|
|
143
|
+
ap.add_argument("--blind-seed", type=int, required=True,
|
|
144
|
+
help="shuffles the order; recorded, so the shuffle is reproducible")
|
|
145
|
+
ap.add_argument("--labeller", required=True, help="who is judging")
|
|
146
|
+
ap.add_argument("--generated-by", required=True, help="who produced the cells")
|
|
147
|
+
ap.add_argument("--arm-words", default="",
|
|
148
|
+
help="comma-separated words that name the arms. The "
|
|
149
|
+
"automatic backstop is built from cell filenames, so "
|
|
150
|
+
"name the arms here whenever the files do not")
|
|
151
|
+
ap.add_argument("--tries", type=int, default=3)
|
|
152
|
+
ap.add_argument("--timeout", type=int, default=600)
|
|
153
|
+
ap.add_argument("--relabel", action="store_true",
|
|
154
|
+
help="overwrite an existing labelling — recorded on every label")
|
|
155
|
+
a = ap.parse_args(argv)
|
|
156
|
+
|
|
157
|
+
# journeyman calls this self_judged and refuses to compare such a score.
|
|
158
|
+
# The same fact holds one stage earlier: a house labelling its own output
|
|
159
|
+
# is grading its own piece with extra steps.
|
|
160
|
+
if a.labeller.strip() == a.generated_by.strip():
|
|
161
|
+
print(f"HELD: labeller and maker are both {a.labeller!r}. A judgement of "
|
|
162
|
+
f"your own output is not evidence; name a separate labeller.")
|
|
163
|
+
return 1
|
|
164
|
+
|
|
165
|
+
rubric = json.load(open(a.rubric, encoding="utf-8"))
|
|
166
|
+
for key in ("axis", "verdicts", "fields", "prompt"):
|
|
167
|
+
if not rubric.get(key):
|
|
168
|
+
print(f"HELD: rubric has no {key!r}")
|
|
169
|
+
return 1
|
|
170
|
+
digest = hashlib.sha256(open(a.rubric, "rb").read()).hexdigest()[:16]
|
|
171
|
+
|
|
172
|
+
paths = sorted(glob.glob(a.cells))
|
|
173
|
+
if not paths:
|
|
174
|
+
print(f"HELD: no cells matched {a.cells}")
|
|
175
|
+
return 1
|
|
176
|
+
|
|
177
|
+
stems = [os.path.splitext(os.path.basename(x))[0] for x in paths]
|
|
178
|
+
# Labels are joined back to cells by file stem, here and in the line's own
|
|
179
|
+
# missing-label check. Two cells sharing one stem — the same grid run per
|
|
180
|
+
# arm into separate directories — would quietly overwrite each other's
|
|
181
|
+
# label, and the arm that lost would be scored with the other one's.
|
|
182
|
+
clashes = sorted({n for n in stems if stems.count(n) > 1})
|
|
183
|
+
if clashes:
|
|
184
|
+
print(f"HELD: {len(clashes)} cell name(s) appear more than once: "
|
|
185
|
+
f"{', '.join(clashes[:5])}. Labels are joined back by name, so "
|
|
186
|
+
f"the duplicates would overwrite each other. Name cells for the "
|
|
187
|
+
f"arm as well as the case.")
|
|
188
|
+
return 1
|
|
189
|
+
|
|
190
|
+
os.makedirs(a.out, exist_ok=True)
|
|
191
|
+
labels_path = os.path.join(a.out, "labels.json")
|
|
192
|
+
key_path = a.key or os.path.join(a.out, "key.json")
|
|
193
|
+
if os.path.exists(labels_path) and not a.relabel:
|
|
194
|
+
print(f"HELD: {labels_path} exists. Labelling again after the key is open "
|
|
195
|
+
f"is how a result gets chosen; pass --relabel to say you meant it. "
|
|
196
|
+
f"(The binding refusal is in `reveal`, on the path the line reads.)")
|
|
197
|
+
return 1
|
|
198
|
+
|
|
199
|
+
tells = telling(stems)
|
|
200
|
+
every_tell = set().union(*tells.values()) if tells else set()
|
|
201
|
+
every_tell |= {w.strip().lower() for w in a.arm_words.split(",") if w.strip()}
|
|
202
|
+
|
|
203
|
+
order = list(paths)
|
|
204
|
+
random.Random(a.blind_seed).shuffle(order)
|
|
205
|
+
blind = [(f"C{i:03d}", p) for i, p in enumerate(order, 1)]
|
|
206
|
+
|
|
207
|
+
# The key is written first and never read again here: nothing downstream of
|
|
208
|
+
# this line knows which arm any cell belongs to.
|
|
209
|
+
os.makedirs(os.path.dirname(key_path) or ".", exist_ok=True)
|
|
210
|
+
json.dump({bid: path for bid, path in blind}, open(key_path, "w"), indent=1)
|
|
211
|
+
|
|
212
|
+
out, unparsed = [], 0
|
|
213
|
+
for i, (bid, path) in enumerate(blind, 1):
|
|
214
|
+
raw = open(path, "rb").read()
|
|
215
|
+
record = json.loads(raw.decode("utf-8"))
|
|
216
|
+
stem = os.path.splitext(os.path.basename(path))[0]
|
|
217
|
+
try:
|
|
218
|
+
prompt = render(rubric, record)
|
|
219
|
+
except Exception as e:
|
|
220
|
+
print(f"HELD: {stem} has no field the rubric names ({e})")
|
|
221
|
+
return 1
|
|
222
|
+
# Any arm's telling word, not only this cell's: a prompt that names
|
|
223
|
+
# the arm it is *not* groups the grid just as well.
|
|
224
|
+
leaked = sorted(w for w in every_tell | {stem} if w in prompt.lower())
|
|
225
|
+
if leaked:
|
|
226
|
+
print(f"HELD: the prompt for {bid} contains {', '.join(leaked)} — part "
|
|
227
|
+
f"of the cell name {stem!r}. A named field is carrying the "
|
|
228
|
+
f"identity the blinding removes.")
|
|
229
|
+
return 1
|
|
230
|
+
|
|
231
|
+
verdict, why = None, "not attempted"
|
|
232
|
+
for _ in range(max(1, a.tries)):
|
|
233
|
+
try:
|
|
234
|
+
verdict, why = parse(ask(a.command, prompt, a.timeout), rubric)
|
|
235
|
+
except Exception as e:
|
|
236
|
+
verdict, why = None, repr(e)[:200]
|
|
237
|
+
if verdict:
|
|
238
|
+
break
|
|
239
|
+
if verdict is None:
|
|
240
|
+
unparsed += 1
|
|
241
|
+
out.append({"blind_id": bid, rubric["axis"]: verdict, "why": why,
|
|
242
|
+
"cell_sha256": hashlib.sha256(raw).hexdigest()[:16]})
|
|
243
|
+
print(f"[{i:>3}/{len(blind)}] {bid} -> {verdict or 'UNPARSED'}", flush=True)
|
|
244
|
+
|
|
245
|
+
json.dump({"axis": rubric["axis"], "rubric_sha256": digest,
|
|
246
|
+
"blind_seed": a.blind_seed, "labeller": a.labeller,
|
|
247
|
+
"generated_by": a.generated_by, "relabelled": bool(a.relabel),
|
|
248
|
+
"labels": out}, open(labels_path, "w"), ensure_ascii=False, indent=1)
|
|
249
|
+
|
|
250
|
+
print(f"\n{len(out)} labelled · {unparsed} unparsed · {labels_path}")
|
|
251
|
+
if unparsed:
|
|
252
|
+
# Not an error: the line reads a null label as an unlabelled cell and
|
|
253
|
+
# holds. Dropping it here would move the denominator instead.
|
|
254
|
+
print(f"{unparsed} cell(s) carry no verdict. They stay in the grid as "
|
|
255
|
+
f"null and the line will hold on them.")
|
|
256
|
+
return 0
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def reveal(argv=None) -> int:
|
|
260
|
+
ap = argparse.ArgumentParser(prog="masterwork label reveal", description="open the key and join labels to cells")
|
|
261
|
+
ap.add_argument("--out", required=True, help="directory written by `label`")
|
|
262
|
+
ap.add_argument("--key", help="the key file, if it was not left in <out>")
|
|
263
|
+
ap.add_argument("--to", required=True,
|
|
264
|
+
help="per-cell label path, with {cell} for the cell name")
|
|
265
|
+
ap.add_argument("--relabel", action="store_true",
|
|
266
|
+
help="replace labels the line already has — recorded on "
|
|
267
|
+
"every label written, and it reaches the verdict")
|
|
268
|
+
a = ap.parse_args(argv)
|
|
269
|
+
|
|
270
|
+
data = json.load(open(os.path.join(a.out, "labels.json"), encoding="utf-8"))
|
|
271
|
+
key = json.load(open(a.key or os.path.join(a.out, "key.json"), encoding="utf-8"))
|
|
272
|
+
axis = data["axis"]
|
|
273
|
+
|
|
274
|
+
# The refusal lives here rather than in `label`, because this is the path
|
|
275
|
+
# the line reads and the spec fixes. A guard on the working directory is
|
|
276
|
+
# routed around by typing a different directory.
|
|
277
|
+
standing = [a.to.replace("{cell}", os.path.splitext(os.path.basename(p))[0])
|
|
278
|
+
for p in key.values()]
|
|
279
|
+
already = [p for p in standing if os.path.exists(p)]
|
|
280
|
+
if already and not a.relabel:
|
|
281
|
+
print(f"HELD: {len(already)} of these cells already carry a label "
|
|
282
|
+
f"({already[0]}). Replacing a label the line has already read is "
|
|
283
|
+
f"how a result gets chosen; pass --relabel to say you meant it.")
|
|
284
|
+
return 1
|
|
285
|
+
relabelled = bool(a.relabel or data["relabelled"])
|
|
286
|
+
|
|
287
|
+
written = 0
|
|
288
|
+
for entry in data["labels"]:
|
|
289
|
+
path = key.get(entry["blind_id"])
|
|
290
|
+
if path is None:
|
|
291
|
+
print(f"HELD: {entry['blind_id']} is in the labels and not in the key")
|
|
292
|
+
return 1
|
|
293
|
+
cell = os.path.splitext(os.path.basename(path))[0]
|
|
294
|
+
dest = a.to.replace("{cell}", cell)
|
|
295
|
+
os.makedirs(os.path.dirname(dest) or ".", exist_ok=True)
|
|
296
|
+
json.dump({"cell": cell, "label": entry[axis], "axis": axis,
|
|
297
|
+
"why": entry["why"], "labeller": data["labeller"],
|
|
298
|
+
"rubric_sha256": data["rubric_sha256"],
|
|
299
|
+
"cell_sha256": entry.get("cell_sha256"),
|
|
300
|
+
"relabelled": relabelled},
|
|
301
|
+
open(dest, "w"), ensure_ascii=False, indent=1)
|
|
302
|
+
written += 1
|
|
303
|
+
|
|
304
|
+
counts: dict = {}
|
|
305
|
+
for entry in data["labels"]:
|
|
306
|
+
counts[entry[axis]] = counts.get(entry[axis], 0) + 1
|
|
307
|
+
print(f"{written} label(s) written · " +
|
|
308
|
+
" · ".join(f"{k}={v}" for k, v in sorted(counts.items(), key=str)))
|
|
309
|
+
if relabelled:
|
|
310
|
+
print("these labels replaced ones already in place — the line carries "
|
|
311
|
+
"that onto the verdict")
|
|
312
|
+
return 0
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def main(argv=None) -> int:
|
|
316
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
317
|
+
if not argv:
|
|
318
|
+
# A bare call is a reader asking what this is, not a mistake.
|
|
319
|
+
print(__doc__)
|
|
320
|
+
return 0
|
|
321
|
+
if argv[0] not in ("label", "reveal"):
|
|
322
|
+
print(f"masterwork label: no verb {argv[0]!r} — "
|
|
323
|
+
"expected 'label' or 'reveal'", file=sys.stderr)
|
|
324
|
+
return 2
|
|
325
|
+
return (label if argv[0] == "label" else reveal)(argv[1:])
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
if __name__ == "__main__":
|
|
329
|
+
sys.exit(main())
|
masterwork/campaign.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""A campaign: one arm repeated, or two arms compared — and what it costs.
|
|
2
|
+
|
|
3
|
+
The line runs one arm once. Almost nothing worth deciding is one run of one
|
|
4
|
+
arm: a band is the same arm repeated with nothing changed but sampling, and
|
|
5
|
+
a gate is two arms compared against that band. Both were being assembled by
|
|
6
|
+
hand — a shell loop, then a script to average, then the gate run separately
|
|
7
|
+
— and every hand step is a place for the numbers to be joined wrongly.
|
|
8
|
+
|
|
9
|
+
Money is a first-class stage here rather than a footnote. A campaign
|
|
10
|
+
projects its cost before spending anything, using a measured per-repeat cost
|
|
11
|
+
where one exists instead of a guess, and it stops when the next repeat would
|
|
12
|
+
cross the ceiling. Stopping mid-campaign with a recorded partial result is
|
|
13
|
+
recoverable; discovering the ceiling after the fact is not.
|
|
14
|
+
|
|
15
|
+
The band it computes is the spread of an arm's own repeats. That number then
|
|
16
|
+
has to sit *below* the gate's threshold — which the gate checker verifies
|
|
17
|
+
independently, since a campaign that measured its own band and then chose a
|
|
18
|
+
threshold under it would be marking its own paper.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import statistics
|
|
26
|
+
import sys
|
|
27
|
+
|
|
28
|
+
from masterwork import line as line_mod
|
|
29
|
+
|
|
30
|
+
PUAN_KEYS = ("axes",)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def arm_scores(records: list[dict]) -> dict[str, list[float]]:
|
|
34
|
+
"""Per-axis score from each repeat, in order."""
|
|
35
|
+
out: dict[str, list[float]] = {}
|
|
36
|
+
for r in records:
|
|
37
|
+
axes = ((r.get("measurement") or {}).get("axes")) or {}
|
|
38
|
+
for a, v in axes.items():
|
|
39
|
+
if v is None:
|
|
40
|
+
continue
|
|
41
|
+
out.setdefault(a, []).append(float(v))
|
|
42
|
+
return out
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def band(scores: dict[str, list[float]]) -> dict[str, float]:
|
|
46
|
+
"""Spread of an arm across its own repeats: what moved when nothing did."""
|
|
47
|
+
return {a: round(max(v) - min(v), 4) for a, v in scores.items() if len(v) > 1}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def spent(records: list[dict]) -> float:
|
|
51
|
+
total = 0.0
|
|
52
|
+
for r in records:
|
|
53
|
+
m = (r.get("measurement") or {})
|
|
54
|
+
total += float((m.get("judge_cost") or {}).get("cost") or 0.0)
|
|
55
|
+
return round(total, 6)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def project(per_repeat: float | None, repeats: int) -> tuple[float | None, str]:
|
|
59
|
+
if per_repeat is None:
|
|
60
|
+
return None, ("no measured cost per repeat — the first repeat measures it; "
|
|
61
|
+
"a projection from a price list is a guess, and published "
|
|
62
|
+
"prices move without notice")
|
|
63
|
+
return round(per_repeat * repeats, 4), "projected from a measured repeat"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Campaign:
|
|
67
|
+
def __init__(self, spec: dict, out_dir: str):
|
|
68
|
+
self.spec, self.out_dir = spec, out_dir
|
|
69
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
70
|
+
self.record = {"name": spec.get("name", "campaign"), "arms": {},
|
|
71
|
+
"purpose": spec.get("purpose"), "budget": spec.get("budget")}
|
|
72
|
+
|
|
73
|
+
def _say(self, msg):
|
|
74
|
+
print(msg, flush=True)
|
|
75
|
+
|
|
76
|
+
def run_arm(self, arm: str, arm_spec: dict, repeats: int,
|
|
77
|
+
ceiling: float | None) -> tuple[list[dict], str | None]:
|
|
78
|
+
records, per_repeat = [], self.spec.get("budget", {}).get("cost_per_repeat")
|
|
79
|
+
for i in range(repeats):
|
|
80
|
+
done = spent(records)
|
|
81
|
+
if ceiling is not None and per_repeat and done + per_repeat > ceiling:
|
|
82
|
+
return records, (f"stopping before repeat {i+1} of {arm}: "
|
|
83
|
+
f"{done:.4f} spent, next repeat ~{per_repeat:.4f}, "
|
|
84
|
+
f"ceiling {ceiling:.4f}")
|
|
85
|
+
self._say(f"[{arm}] repeat {i+1}/{repeats}")
|
|
86
|
+
out = os.path.join(self.out_dir, f"{arm}-{i+1}")
|
|
87
|
+
rc = line_mod.Line(dict(arm_spec, name=f"{arm}-{i+1}"), out).run()
|
|
88
|
+
rec = json.load(open(os.path.join(out, "run.json")))
|
|
89
|
+
records.append(rec)
|
|
90
|
+
if rc not in (0,):
|
|
91
|
+
return records, f"{arm} repeat {i+1} ended {rec.get('verdict')}"
|
|
92
|
+
if per_repeat is None:
|
|
93
|
+
per_repeat = spent(records) or None
|
|
94
|
+
if per_repeat:
|
|
95
|
+
self._say(f" measured cost per repeat: {per_repeat:.4f}")
|
|
96
|
+
return records, None
|
|
97
|
+
|
|
98
|
+
def run(self) -> int:
|
|
99
|
+
s = self.spec
|
|
100
|
+
repeats = int(s.get("repeats", 3))
|
|
101
|
+
ceiling = (s.get("budget") or {}).get("max_usd")
|
|
102
|
+
proj, why = project((s.get("budget") or {}).get("cost_per_repeat"),
|
|
103
|
+
repeats * len(s["arms"]))
|
|
104
|
+
self._say(f"projection: {proj if proj is not None else '?'} — {why}")
|
|
105
|
+
if proj is not None and ceiling is not None and proj > ceiling:
|
|
106
|
+
self._say(f"HELD: projected {proj} over ceiling {ceiling}. Raise the "
|
|
107
|
+
f"ceiling deliberately or cut repeats — not both quietly.")
|
|
108
|
+
self.record["verdict"] = "HELD_AT_BUDGET"
|
|
109
|
+
return self.persist()
|
|
110
|
+
|
|
111
|
+
for arm, arm_spec in s["arms"].items():
|
|
112
|
+
records, stop = self.run_arm(arm, arm_spec, repeats, ceiling)
|
|
113
|
+
sc = arm_scores(records)
|
|
114
|
+
self.record["arms"][arm] = {
|
|
115
|
+
"repeats_done": len(records), "scores": sc, "band": band(sc),
|
|
116
|
+
"mean": {a: round(statistics.mean(v), 4) for a, v in sc.items()},
|
|
117
|
+
"spent": spent(records), "stopped": stop}
|
|
118
|
+
if stop:
|
|
119
|
+
self._say(f"[{arm}] {stop}")
|
|
120
|
+
self.record["verdict"] = "HELD_AT_BUDGET" if "ceiling" in stop \
|
|
121
|
+
else "INCOMPLETE"
|
|
122
|
+
return self.persist()
|
|
123
|
+
|
|
124
|
+
self.compare()
|
|
125
|
+
self.record["verdict"] = self.record.get("verdict", "COMPLETE")
|
|
126
|
+
return self.persist()
|
|
127
|
+
|
|
128
|
+
def compare(self):
|
|
129
|
+
arms = list(self.record["arms"])
|
|
130
|
+
if len(arms) != 2:
|
|
131
|
+
return
|
|
132
|
+
a, b = arms
|
|
133
|
+
A, B = self.record["arms"][a], self.record["arms"][b]
|
|
134
|
+
diff = {}
|
|
135
|
+
for axis in sorted(set(A["mean"]) & set(B["mean"])):
|
|
136
|
+
d = round(B["mean"][axis] - A["mean"][axis], 4)
|
|
137
|
+
widest = max(A["band"].get(axis, 0.0), B["band"].get(axis, 0.0))
|
|
138
|
+
diff[axis] = {"difference": d, "band": widest,
|
|
139
|
+
"resolvable": abs(d) > widest}
|
|
140
|
+
self.record["comparison"] = {"incumbent": a, "candidate": b, "axes": diff}
|
|
141
|
+
self._say(f"\n{'axis':<24}{'difference':>12}{'band':>8} resolvable")
|
|
142
|
+
for axis, v in diff.items():
|
|
143
|
+
self._say(f"{axis:<24}{v['difference']:>+12.4f}{v['band']:>8.2f}"
|
|
144
|
+
f" {'yes' if v['resolvable'] else 'no'}")
|
|
145
|
+
|
|
146
|
+
def persist(self) -> int:
|
|
147
|
+
total = sum(a.get("spent", 0.0) for a in self.record["arms"].values())
|
|
148
|
+
self.record["spent_total"] = round(total, 6)
|
|
149
|
+
p = os.path.join(self.out_dir, "campaign.json")
|
|
150
|
+
json.dump(self.record, open(p, "w", encoding="utf-8"),
|
|
151
|
+
ensure_ascii=False, indent=1)
|
|
152
|
+
self._say(f"\nverdict {self.record['verdict']} · spent {total:.4f} · {p}")
|
|
153
|
+
return 0 if self.record["verdict"] == "COMPLETE" else 1
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def main(argv=None) -> int:
|
|
157
|
+
ap = argparse.ArgumentParser(prog="masterwork campaign", description="run a campaign: repeats, band, comparison")
|
|
158
|
+
ap.add_argument("spec")
|
|
159
|
+
ap.add_argument("--out", default="runs")
|
|
160
|
+
a = ap.parse_args(argv)
|
|
161
|
+
if not os.path.exists(a.spec):
|
|
162
|
+
print(f"HELD: no campaign spec at {a.spec} — see docs/campaigns.md")
|
|
163
|
+
return 4
|
|
164
|
+
try:
|
|
165
|
+
spec = json.load(open(a.spec, encoding="utf-8"))
|
|
166
|
+
except json.JSONDecodeError as e:
|
|
167
|
+
print(f"HELD: {a.spec} is not valid JSON — {e}")
|
|
168
|
+
return 4
|
|
169
|
+
return Campaign(spec, os.path.join(a.out, spec.get("name", "campaign"))).run()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
if __name__ == "__main__":
|
|
173
|
+
sys.exit(main())
|