openjev-sdk 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
openjev/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ """Local option scoring and trainable candidate-decision heads.
2
+
3
+ Model backends are loaded only when used, so importing openjev does not initialize
4
+ an accelerator or download weights.
5
+ """
6
+ from .decision import Candidate, ChoiceTask
7
+ from .scorer import DEFAULT_MODEL, OptionScore, OptionScorer
8
+
9
+ __all__ = ["DEFAULT_MODEL", "OptionScore", "OptionScorer", "Candidate", "ChoiceTask",
10
+ "DecisionScorer", "extract_decision_features", "train_decision_head",
11
+ "TemperatureCalibrator", "run_decision_experiment"]
12
+
13
+
14
+ def __getattr__(name):
15
+ if name == 'TemperatureCalibrator':
16
+ from .decision_probability import TemperatureCalibrator
17
+ return TemperatureCalibrator
18
+ if name == 'run_decision_experiment':
19
+ from .decision_experiment import run_experiment
20
+ return run_experiment
21
+ aliases = {"DecisionScorer": "DecisionScorer",
22
+ "extract_decision_features": "extract_features",
23
+ "train_decision_head": "train_artifact"}
24
+ if name in aliases:
25
+ from . import decision_mlx
26
+ value = getattr(decision_mlx, aliases[name])
27
+ globals()[name] = value
28
+ return value
29
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
30
+
31
+
32
+ def __dir__():
33
+ return sorted(set(globals()) | set(__all__))
openjev/cli.py ADDED
@@ -0,0 +1,286 @@
1
+ """Command line entry points: score, eval, bench, check, serve, features, train, eval-head."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import random
7
+ import statistics
8
+ import sys
9
+ import time
10
+
11
+ from .scorer import DEFAULT_MODEL, NORMS, OptionScorer, iter_jsonl
12
+
13
+
14
+ def _add_common(p: argparse.ArgumentParser) -> None:
15
+ p.add_argument("--backend", choices=("auto", "mlx", "torch"), default="auto")
16
+ p.add_argument("--device", default="auto", help="auto, cpu, cuda, cuda:N, or mps (torch backend)")
17
+ p.add_argument("--model", default=DEFAULT_MODEL, help="local dir or HF repo id")
18
+ p.add_argument("--adapter", default=None, help="LoRA adapter dir to load on top of --model (e.g. adapters/chess-lora)")
19
+ p.add_argument("--batch-size", type=int, default=8, help="options per forward pass")
20
+ p.add_argument("--norm", choices=NORMS, default="mean",
21
+ help="mean: per-token log-prob; sum: total; pmi: sum minus unconditional")
22
+ p.add_argument("--chat", action="store_true",
23
+ help="wrap context in Gemma's chat template; options score as the reply")
24
+ p.add_argument("--sep", default="", help="string inserted between context and option")
25
+
26
+
27
+ def _scorer(args: argparse.Namespace) -> OptionScorer:
28
+ t = time.perf_counter()
29
+ s = OptionScorer(args.model, batch_size=args.batch_size, chat=args.chat, sep=args.sep, adapter_path=args.adapter, backend=args.backend, device=args.device)
30
+ print(f"loaded {args.model} in {time.perf_counter() - t:.1f}s", file=sys.stderr)
31
+ return s
32
+
33
+
34
+ def _read_options(path: str) -> list[str]:
35
+ with open(path) as f:
36
+ return [line.rstrip("\n") for line in f if line.strip()]
37
+
38
+
39
+ def cmd_score(args: argparse.Namespace) -> None:
40
+ if args.options_file:
41
+ args.option = _read_options(args.options_file) + args.option
42
+ if len(args.option) < 2:
43
+ sys.exit("need at least two options (--option ... or --options-file)")
44
+ scorer = _scorer(args)
45
+ result = scorer.score(args.context, args.option, norm=args.norm)
46
+ scorer.score(args.context, args.option, norm=args.norm) # warm second run for timing
47
+ payload = {
48
+ "context": args.context,
49
+ "norm": args.norm,
50
+ "timing": scorer.last_timing,
51
+ "options": [r.to_dict() for r in result],
52
+ }
53
+ if args.json:
54
+ print(json.dumps(payload, indent=2))
55
+ return
56
+ ranked = sorted(result, key=lambda r: -r.score)
57
+ for r in ranked:
58
+ print(f"{r.probability:7.3%} score={r.score:8.3f} sum={r.logprob_sum:8.3f} "
59
+ f"n={r.n_tokens:3d} {r.option!r}")
60
+ t = scorer.last_timing
61
+ print(f"[{t['context_tokens']} ctx tok, {t['option_tokens']} opt tok] "
62
+ f"prefill {t['prefill_s']*1000:.0f} ms, options {t['options_s']*1000:.0f} ms, "
63
+ f"total {t['total_s']*1000:.0f} ms", file=sys.stderr)
64
+
65
+
66
+ def cmd_eval(args: argparse.Namespace) -> None:
67
+ scorer = _scorer(args)
68
+ rows = list(iter_jsonl(args.data))
69
+ if args.limit:
70
+ rows = rows[: args.limit]
71
+ fixed = _read_options(args.fixed_options) if args.fixed_options else None
72
+ top1 = top3 = labelled = 0
73
+ lat: list[float] = []
74
+ for i, row in enumerate(rows):
75
+ options = row.get("options") or fixed
76
+ if not options:
77
+ sys.exit(f"row {i} has no 'options' and no --fixed-options given")
78
+ res = scorer.score(row["context"], options, norm=args.norm)
79
+ lat.append(scorer.last_timing["total_s"])
80
+ order = sorted(range(len(res)), key=lambda j: -res[j].score)
81
+ label = row.get("label")
82
+ if label is not None:
83
+ labelled += 1
84
+ top1 += order[0] == label
85
+ top3 += label in order[:3]
86
+ if args.verbose or label is None:
87
+ print(json.dumps({"i": i, "label": label, "pred": order[0], "pred_option": options[order[0]],
88
+ "probs": [round(r.probability, 4) for r in res]}))
89
+ n = len(rows)
90
+ print(json.dumps({
91
+ "examples": n,
92
+ "labelled": labelled,
93
+ "top1": top1 / labelled if labelled else None,
94
+ "top3": top3 / labelled if labelled else None,
95
+ "norm": args.norm,
96
+ "median_latency_s": statistics.median(lat),
97
+ "mean_latency_s": statistics.fmean(lat),
98
+ }, indent=2))
99
+
100
+
101
+ def _synthetic(scorer: OptionScorer, ctx_tokens: int, n_opts: int, opt_tokens: int, seed: int):
102
+ rng = random.Random(seed)
103
+ words = ("river stone cloud engine quiet market ledger signal orbit velvet "
104
+ "harbour lantern cipher meadow granite").split()
105
+ def text(n): # roughly n tokens of plain words
106
+ return " ".join(rng.choice(words) for _ in range(n))
107
+ context = text(ctx_tokens)
108
+ options = [text(opt_tokens) for _ in range(n_opts)]
109
+ return context, options
110
+
111
+
112
+ def cmd_bench(args: argparse.Namespace) -> None:
113
+ scorer = _scorer(args)
114
+ context, options = _synthetic(scorer, args.context_tokens, args.options, args.option_tokens, 0)
115
+ ctx_n = len(scorer.context_ids(context))
116
+ opt_n = sum(len(scorer.option_ids(o)) for o in options)
117
+ scorer.score(context, options, norm="mean") # warm-up
118
+ scorer.score_naive(context, options[:1])
119
+
120
+ cached = []
121
+ for _ in range(args.repeat):
122
+ scorer.score(context, options, norm="mean")
123
+ cached.append(scorer.last_timing["total_s"])
124
+ naive = []
125
+ for _ in range(args.repeat):
126
+ t = time.perf_counter()
127
+ scorer.score_naive(context, options)
128
+ naive.append(time.perf_counter() - t)
129
+ print(json.dumps({
130
+ "context_tokens": ctx_n,
131
+ "options": len(options),
132
+ "option_tokens_total": opt_n,
133
+ "batch_size": scorer.batch_size,
134
+ "prefix_cached_batched_s": {"median": statistics.median(cached), "min": min(cached)},
135
+ "naive_per_option_s": {"median": statistics.median(naive), "min": min(naive)},
136
+ "speedup": statistics.median(naive) / statistics.median(cached),
137
+ }, indent=2))
138
+
139
+
140
+ def cmd_check(args: argparse.Namespace) -> None:
141
+ """Verify the prefix-shared batched path against naive full re-encoding."""
142
+ scorer = _scorer(args)
143
+ cases = [
144
+ ("The capital of France is", [" Paris", " Berlin", " a city in Europe", " Lyon"]),
145
+ ("Q: What is 2 + 2?\nA:", [" 4", " 5", " four", " twenty-two"]),
146
+ ]
147
+ context, options = _synthetic(scorer, args.context_tokens, args.options, args.option_tokens, 1)
148
+ cases.append((context, options))
149
+ worst = 0.0
150
+ for ctx, opts in cases:
151
+ fast = [r.logprob_sum for r in scorer.score(ctx, opts, norm="sum")]
152
+ slow = scorer.score_naive(ctx, opts)
153
+ diffs = [abs(a - b) for a, b in zip(fast, slow)]
154
+ worst = max(worst, max(diffs))
155
+ rel = max(d / max(1e-6, abs(b)) for d, b in zip(diffs, slow))
156
+ print(f"ctx_tokens={len(scorer.context_ids(ctx)):4d} options={len(opts):2d} "
157
+ f"max_abs_diff={max(diffs):.4f} max_rel_diff={rel:.4%}")
158
+ for o, a, b in zip(opts, fast, slow):
159
+ print(f" cached={a:9.3f} naive={b:9.3f} {o[:40]!r}")
160
+ ok = worst < args.tol
161
+ print("OK" if ok else f"MISMATCH (worst abs diff {worst:.4f} > tol {args.tol})")
162
+ sys.exit(0 if ok else 1)
163
+
164
+
165
+ def cmd_serve(args: argparse.Namespace) -> None:
166
+ from .server import serve
167
+
168
+ serve(args.host, args.port, args.model, args.batch_size, backend=args.backend, device=args.device)
169
+
170
+
171
+ def cmd_features(args: argparse.Namespace) -> None:
172
+ import platform
173
+ if platform.system() != "Darwin" or platform.machine() != "arm64" or getattr(args, "backend", "auto") == "torch":
174
+ raise SystemExit("Feature extraction and head training/evaluation require MLX on Apple silicon. Scoring and serving support Windows/Linux via torch.")
175
+ from .features import extract_dataset
176
+
177
+ scorer = _scorer(args)
178
+ meta = extract_dataset(scorer, args.data, args.out, limit=args.limit, chat=args.chat, sep=args.sep,
179
+ contextual=args.contextual)
180
+ print(json.dumps(meta))
181
+
182
+
183
+ def cmd_train(args: argparse.Namespace) -> None:
184
+ import platform
185
+ if platform.system() != "Darwin" or platform.machine() != "arm64" or getattr(args, "backend", "auto") == "torch":
186
+ raise SystemExit("Feature extraction and head training/evaluation require MLX on Apple silicon. Scoring and serving support Windows/Linux via torch.")
187
+ from .train import train
188
+
189
+ print(json.dumps(train(args.train, args.validation, args.out, rank=args.rank, epochs=args.epochs,
190
+ batch_size=args.batch_size, lr=args.learning_rate, seed=args.seed)))
191
+
192
+
193
+ def cmd_eval_head(args: argparse.Namespace) -> None:
194
+ import platform
195
+ if platform.system() != "Darwin" or platform.machine() != "arm64" or getattr(args, "backend", "auto") == "torch":
196
+ raise SystemExit("Feature extraction and head training/evaluation require MLX on Apple silicon. Scoring and serving support Windows/Linux via torch.")
197
+ from .features import FeatureSet
198
+ from .head import AttentionHead
199
+ from .train import evaluate
200
+
201
+ head, cfg = AttentionHead.load(args.checkpoint)
202
+ fs = FeatureSet(args.features)
203
+ print(json.dumps({"model": evaluate(head, fs), "shuffled_context": evaluate(head, fs, shuffle_context=True),
204
+ "checkpoint": args.checkpoint, "rank": cfg["rank"]}, indent=2))
205
+
206
+
207
+ def main(argv: list[str] | None = None) -> None:
208
+ p = argparse.ArgumentParser(prog="openjev", description=__doc__)
209
+ sub = p.add_subparsers(dest="cmd", required=True)
210
+
211
+ s = sub.add_parser("score", help="score options for one context")
212
+ _add_common(s)
213
+ s.add_argument("--context", required=True)
214
+ s.add_argument("--option", action="append", default=[], help="repeat for each option")
215
+ s.add_argument("--options-file", help="text file with one predefined option per line")
216
+ s.add_argument("--json", action="store_true")
217
+ s.set_defaults(fn=cmd_score)
218
+
219
+ e = sub.add_parser("eval", help="top-k accuracy on jevlike-style JSONL {context, options, label}")
220
+ _add_common(e)
221
+ e.add_argument("data")
222
+ e.add_argument("--limit", type=int, default=0)
223
+ e.add_argument("--fixed-options", help="text file of options used for every row lacking an 'options' field")
224
+ e.add_argument("--verbose", action="store_true")
225
+ e.set_defaults(fn=cmd_eval)
226
+
227
+ for name, fn, helptext in (
228
+ ("bench", cmd_bench, "latency of prefix-cached batched scoring vs naive re-encoding"),
229
+ ("check", cmd_check, "verify cached batched scores match naive full-sequence scores"),
230
+ ):
231
+ b = sub.add_parser(name, help=helptext)
232
+ _add_common(b)
233
+ b.add_argument("--context-tokens", type=int, default=200)
234
+ b.add_argument("--options", type=int, default=8)
235
+ b.add_argument("--option-tokens", type=int, default=30)
236
+ b.add_argument("--repeat", type=int, default=5)
237
+ b.add_argument("--tol", type=float, default=0.5, help="check: max abs log-prob diff allowed")
238
+ b.set_defaults(fn=fn)
239
+
240
+ f = sub.add_parser("features", help="cache frozen-Gemma features for a JSONL file into an .npz")
241
+ _add_common(f)
242
+ f.add_argument("data")
243
+ f.add_argument("--out", required=True)
244
+ f.add_argument("--limit", type=int, default=0)
245
+ f.add_argument("--contextual", action="store_true",
246
+ help="encode options as continuations of the context (leaks the match into option features)")
247
+ f.set_defaults(fn=cmd_features)
248
+
249
+ tr = sub.add_parser("train", help="train the attention head on cached features")
250
+ tr.add_argument("train")
251
+ tr.add_argument("--validation", required=True)
252
+ tr.add_argument("--out", default="runs/head.safetensors")
253
+ tr.add_argument("--rank", type=int, default=256)
254
+ tr.add_argument("--epochs", type=int, default=8)
255
+ tr.add_argument("--batch-size", type=int, default=64)
256
+ tr.add_argument("--learning-rate", type=float, default=5e-4)
257
+ tr.add_argument("--seed", type=int, default=7)
258
+ tr.set_defaults(fn=cmd_train)
259
+
260
+ eh = sub.add_parser("eval-head", help="top-k, ECE and shuffled-context control for a trained head")
261
+ eh.add_argument("checkpoint")
262
+ eh.add_argument("features")
263
+ eh.set_defaults(fn=cmd_eval_head)
264
+
265
+ v = sub.add_parser("serve", help="HTTP server with the model loaded once (POST /score, /v1/systemone)")
266
+ v.add_argument("--model", default=DEFAULT_MODEL)
267
+ v.add_argument("--batch-size", type=int, default=8)
268
+ v.add_argument("--backend", choices=("auto", "mlx", "torch"), default="auto")
269
+ v.add_argument("--device", default="auto", help="auto, cpu, cuda, cuda:N, or mps")
270
+ v.add_argument("--host", default="127.0.0.1")
271
+ v.add_argument("--port", type=int, default=8000)
272
+ v.set_defaults(fn=cmd_serve)
273
+
274
+ from .decision_cli import register
275
+ register(sub)
276
+ from .tod.cli import register as register_tod
277
+ register_tod(sub)
278
+
279
+ args = p.parse_args(argv)
280
+ if len(getattr(args, "option", []) or []) == 1:
281
+ p.error("--option must be given at least twice")
282
+ args.fn(args)
283
+
284
+
285
+ if __name__ == "__main__":
286
+ main()
openjev/compute.py ADDED
@@ -0,0 +1,68 @@
1
+ """Where decision models run: MLX on Apple silicon, PyTorch everywhere else (CUDA, ROCm, MPS, CPU).
2
+
3
+ Features are cached as plain arrays and heads are saved as safetensors with the same
4
+ parameter names on both backends, so a head trained on one backend loads on the other.
5
+
6
+ Selection order: `select(...)` (the CLI's --backend/--device), then the OPENJEV_BACKEND and
7
+ OPENJEV_DEVICE environment variables, then auto: MLX on Apple silicon when it is installed,
8
+ otherwise PyTorch on the first CUDA/ROCm GPU, then MPS, then CPU.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import importlib
13
+ import importlib.metadata
14
+ import importlib.util
15
+ import os
16
+ import platform
17
+
18
+ BACKENDS = ('auto', 'mlx', 'torch')
19
+ _selected = {'backend': None, 'device': None}
20
+
21
+
22
+ def select(backend: str | None = None, device: str | None = None) -> None:
23
+ """Choose the backend (auto, mlx, torch) and PyTorch device (auto, cuda, cuda:N, mps, cpu)."""
24
+ if backend is not None and backend not in BACKENDS:
25
+ raise ValueError(f'backend must be one of {BACKENDS}')
26
+ _selected.update(backend=backend, device=device)
27
+
28
+
29
+ def backend() -> str:
30
+ name = _selected['backend'] or os.environ.get('OPENJEV_BACKEND') or 'auto'
31
+ if name not in BACKENDS:
32
+ raise ValueError(f'OPENJEV_BACKEND must be one of {BACKENDS}')
33
+ if name != 'auto':
34
+ return name
35
+ apple = platform.system() == 'Darwin' and platform.machine() == 'arm64'
36
+ return 'mlx' if apple and importlib.util.find_spec('mlx') else 'torch'
37
+
38
+
39
+ def device() -> str:
40
+ """The PyTorch device name (ignored by MLX, which always uses Metal)."""
41
+ name = _selected['device'] or os.environ.get('OPENJEV_DEVICE') or 'auto'
42
+ if name != 'auto':
43
+ return name
44
+ import torch
45
+ return 'cuda' if torch.cuda.is_available() else 'mps' if torch.backends.mps.is_available() else 'cpu'
46
+
47
+
48
+ def ops(name: str | None = None):
49
+ """The head module for a backend: AttentionHead, new_head, forward, Optimizer, snapshot, restore."""
50
+ name = name or backend()
51
+ if name == 'mlx':
52
+ if not importlib.util.find_spec('mlx'):
53
+ raise RuntimeError('MLX needs Apple silicon; use the torch backend (--backend torch or OPENJEV_BACKEND=torch)')
54
+ return importlib.import_module('openjev.head')
55
+ try:
56
+ return importlib.import_module('openjev.head_torch')
57
+ except ImportError as exc:
58
+ raise RuntimeError('The torch backend needs PyTorch: pip install torch') from exc
59
+
60
+
61
+ def versions(name: str | None = None) -> dict:
62
+ """Libraries whose versions change extracted features."""
63
+ name = name or backend()
64
+ packages = ('mlx', 'mlx-lm') if name == 'mlx' else ('torch',)
65
+ result = {'backend': name}
66
+ for package in (*packages, 'transformers', 'numpy'):
67
+ result[package] = importlib.metadata.version(package)
68
+ return result
openjev/decision.py ADDED
@@ -0,0 +1,231 @@
1
+ """Native choice contract and artifact identities. Safe to import without MLX."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import asdict, dataclass
5
+ import hashlib
6
+ import json
7
+ import math
8
+ from pathlib import Path
9
+
10
+ RECIPE = {
11
+ 'version': 1,
12
+ 'context_rendering': 'state-question-json-v1',
13
+ 'chat': False,
14
+ 'separator': '',
15
+ 'candidate_encoding': 'independent-text-after-bos',
16
+ 'candidate_pooling': 'masked-mean-float32-excluding-bos',
17
+ 'context_pooling': 'all-final-hidden-states',
18
+ 'storage_dtype': 'float16',
19
+ 'candidate_ids_encoded': False,
20
+ }
21
+
22
+
23
+ def canonical(value):
24
+ return json.dumps(value, sort_keys=True, ensure_ascii=False, allow_nan=False, separators=(',', ':'))
25
+
26
+
27
+ def digest(value):
28
+ return hashlib.sha256(canonical(value).encode()).hexdigest()
29
+
30
+
31
+ def file_digest(path):
32
+ h = hashlib.sha256()
33
+ with Path(path).open('rb') as stream:
34
+ for chunk in iter(lambda: stream.read(8 * 1024 * 1024), b''):
35
+ h.update(chunk)
36
+ return h.hexdigest()
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Candidate:
41
+ id: str
42
+ text: str
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class ChoiceTask:
47
+ id: str
48
+ state: object
49
+ question: str
50
+ candidates: tuple[Candidate, ...]
51
+ choice_id: str | None = None
52
+ group_id: str | None = None
53
+
54
+ @classmethod
55
+ def parse(cls, value, labelled=False):
56
+ if not isinstance(value, dict):
57
+ raise ValueError('task must be an object')
58
+ allowed = {'schema_version', 'id', 'state', 'question', 'candidates', 'target', 'group_id'}
59
+ if set(value) - allowed:
60
+ raise ValueError(f'unknown task fields: {sorted(set(value)-allowed)}')
61
+ if type(value.get('schema_version')) is not int or value['schema_version'] != 1:
62
+ raise ValueError('schema_version must be 1')
63
+ for name in ('id', 'question'):
64
+ if not isinstance(value.get(name), str) or not value[name].strip():
65
+ raise ValueError(f'{name} must be a nonempty string')
66
+ state = value.get('state')
67
+ if not isinstance(state, (str, dict, list)):
68
+ raise ValueError('state must be a string, object, or array')
69
+ canonical(state) # Reject non-JSON values and NaN/Infinity.
70
+ candidates = value.get('candidates')
71
+ if not isinstance(candidates, list) or not 2 <= len(candidates) <= 255:
72
+ raise ValueError('need 2..255 candidates')
73
+ parsed = []
74
+ for c in candidates:
75
+ if not isinstance(c, dict) or set(c) != {'id', 'text'}:
76
+ raise ValueError('each candidate must contain only id and text')
77
+ if any(not isinstance(c[k], str) or not c[k].strip() for k in ('id', 'text')):
78
+ raise ValueError('candidate id and text must be nonempty strings')
79
+ parsed.append(Candidate(**c))
80
+ if len({c.id for c in parsed}) != len(parsed):
81
+ raise ValueError('duplicate candidate IDs')
82
+ if len({c.text for c in parsed}) != len(parsed):
83
+ raise ValueError('duplicate candidate text cannot identify a unique choice')
84
+ target = value.get('target')
85
+ choice_id = None
86
+ if target is not None:
87
+ if not isinstance(target, dict) or set(target) != {'choice_id'}:
88
+ raise ValueError('target must contain only choice_id')
89
+ choice_id = target['choice_id']
90
+ if not isinstance(choice_id, str) or choice_id not in {c.id for c in parsed}:
91
+ raise ValueError('target choice_id is not in candidates')
92
+ if labelled and choice_id is None:
93
+ raise ValueError('a choice target is required')
94
+ group_id = value.get('group_id')
95
+ if group_id is not None and (not isinstance(group_id, str) or not group_id.strip()):
96
+ raise ValueError('group_id must be a nonempty string')
97
+ return cls(value['id'], state, value['question'], tuple(parsed), choice_id, group_id)
98
+
99
+ @property
100
+ def label(self):
101
+ if self.choice_id is None:
102
+ raise ValueError('task has no target')
103
+ return next(i for i, c in enumerate(self.candidates) if c.id == self.choice_id)
104
+
105
+ def to_dict(self):
106
+ value = dict(schema_version=1, id=self.id, state=self.state, question=self.question,
107
+ candidates=[asdict(c) for c in self.candidates])
108
+ if self.choice_id is not None:
109
+ value['target'] = {'choice_id': self.choice_id}
110
+ if self.group_id is not None:
111
+ value['group_id'] = self.group_id
112
+ return value
113
+
114
+ @property
115
+ def input_fingerprint(self):
116
+ # Order and IDs do not disguise a repeated semantic example across splits.
117
+ return digest(dict(state=self.state, question=self.question,
118
+ candidates=sorted(c.text for c in self.candidates)))
119
+
120
+
121
+ def render_context(task):
122
+ return 'State:\n' + canonical(task.state) + '\n\nQuestion:\n' + task.question
123
+
124
+
125
+ def load_tasks(path, labelled=False):
126
+ tasks, ids = [], set()
127
+ with Path(path).open() as stream:
128
+ for line_number, line in enumerate(stream, 1):
129
+ if not line.strip():
130
+ continue
131
+ try:
132
+ task = ChoiceTask.parse(json.loads(line), labelled)
133
+ if task.id in ids:
134
+ raise ValueError('duplicate task ID')
135
+ ids.add(task.id)
136
+ tasks.append(task)
137
+ except (ValueError, TypeError, KeyError) as exc:
138
+ raise ValueError(f'{path}:{line_number}: {exc}') from exc
139
+ if not tasks:
140
+ raise ValueError(f'{path}: empty dataset')
141
+ return tasks
142
+
143
+
144
+ def provenance(tasks):
145
+ return dict(ids=[t.id for t in tasks], inputs=[t.input_fingerprint for t in tasks],
146
+ groups=sorted({t.group_id for t in tasks if t.group_id is not None}))
147
+
148
+
149
+ def check_disjoint(a, b):
150
+ for key in ('ids', 'inputs', 'groups'):
151
+ if set(a[key]) & set(b[key]):
152
+ raise ValueError(f'dataset overlap in {key}; use independent splits')
153
+
154
+
155
+ def model_identity(model):
156
+ root = Path(model).resolve()
157
+ if not root.is_dir() or not (root/'config.json').is_file():
158
+ raise ValueError('Phase 1 requires a local MLX-compatible model directory with config.json')
159
+ files = sorted(p for p in root.rglob('*') if p.is_file() and
160
+ '.cache' not in p.relative_to(root).parts and
161
+ p.suffix in ('.json', '.safetensors', '.model', '.jinja', '.txt'))
162
+ if not any(p.suffix == '.safetensors' for p in files):
163
+ raise ValueError('model directory has no safetensors weights')
164
+ hashes = {p.relative_to(root).as_posix(): file_digest(p) for p in files}
165
+ return dict(path=str(root), files=hashes, fingerprint=digest(hashes))
166
+
167
+
168
+ def read_artifact(directory):
169
+ root = Path(directory)
170
+ manifest = json.loads((root/'manifest.json').read_text())
171
+ if manifest.get('schema_version') != 1 or manifest.get('strategy') != 'candidate_head':
172
+ raise ValueError('unsupported decision artifact')
173
+ if manifest.get('recipe') != RECIPE:
174
+ raise ValueError('unsupported feature/rendering recipe')
175
+ if manifest.get('head') != 'attention_head_v1':
176
+ raise ValueError('unsupported decision head')
177
+ for name in ('head.safetensors', 'head.json'):
178
+ if file_digest(root/name) != manifest['files'][name]:
179
+ raise ValueError(f'artifact integrity mismatch: {name}')
180
+ backbone = manifest['backbone']
181
+ signature = manifest['feature_signature']
182
+ config = json.loads((root/'head.json').read_text())
183
+ if (digest(backbone['files']) != backbone['fingerprint'] or
184
+ signature['model_fingerprint'] != backbone['fingerprint'] or
185
+ signature['recipe'] != RECIPE or signature['limits'] != manifest['limits'] or
186
+ config['hidden'] != signature['hidden'] or
187
+ config['features_meta']['signature_sha256'] != digest(signature)):
188
+ raise ValueError('inconsistent artifact feature contract')
189
+ return manifest
190
+
191
+
192
+ def choice_result(task, logits):
193
+ if len(logits) != len(task.candidates) or not all(math.isfinite(x) for x in logits):
194
+ raise ValueError('invalid decision utilities')
195
+ shifted = [v-max(logits) for v in logits]
196
+ z = sum(math.exp(v) for v in shifted)
197
+ options = [dict(id=c.id, utility=u, probability=math.exp(v)/z)
198
+ for c, u, v in zip(task.candidates, logits, shifted)]
199
+ # Stable ID tie-breaking is output mapping only; IDs never enter the encoder.
200
+ best = min(options, key=lambda o: (-o['utility'], o['id']))
201
+ return dict(id=task.id, strategy='candidate_head', choice_id=best['id'], candidates=options)
202
+
203
+
204
+ def summarize(tasks, results):
205
+ if not tasks or len(tasks) != len(results):
206
+ raise ValueError('expected nonempty aligned results')
207
+ hits = top3 = 0
208
+ nll = brier = 0.
209
+ bins = [[0, 0., 0.] for _ in range(10)]
210
+ for task, result in zip(tasks, results):
211
+ options = result['candidates']
212
+ gold = next(o for o in options if o['id'] == task.choice_id)
213
+ temperature = result.get('calibration', {}).get('temperature', 1.0)
214
+ scores = [o['utility']/temperature for o in options]
215
+ maximum = max(scores)
216
+ # Stable NLL even when a probability underflows.
217
+ nll += maximum + math.log(sum(math.exp(s-maximum) for s in scores)) - gold['utility']/temperature
218
+ hit = result['choice_id'] == task.choice_id
219
+ hits += hit
220
+ ordered = sorted(options, key=lambda o: (-o['utility'], o['id']))
221
+ top3 += task.choice_id in [o['id'] for o in ordered[:3]]
222
+ brier += sum((o['probability']-int(o['id'] == task.choice_id))**2 for o in options)
223
+ confidence = ordered[0]['probability']
224
+ bucket = bins[min(9, int(confidence*10))]
225
+ bucket[0] += 1
226
+ bucket[1] += confidence
227
+ bucket[2] += hit
228
+ n = len(tasks)
229
+ return dict(n=n, accuracy=hits/n, top3=top3/n, nll=nll/n, brier=brier/n,
230
+ ece=sum(abs(sc-sh) for count, sc, sh in bins)/n,
231
+ chance_accuracy=sum(1/len(t.candidates) for t in tasks)/n)