oc_chat
Loading...
Searching...
No Matches
encoder_classifier.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# bench/argv_policy/baselines/encoder_classifier.py
4
5"""
6A small DeBERTa-v3 encoder fine-tuned on the gate-labelled corpus, calibrated.
7
8Purpose
9-------
10The 2026-09-07 classifier note proposed discriminative encoder classifiers as
11gates: tens to a few hundred million parameters, CPU milliseconds, a typed
12verdict with a probability, trained on our own corpus. The 2026-09-17 session
13tested the hosted end of that idea and the zero-shot end of it; this module
14tests the proposal itself, which is a small encoder FINE-TUNED on the
15gate-labelled commands plan 288 generated. It trains, it calibrates its logits
16with one scalar, and it writes result files in the bench schema so the same
17scorer reads it beside Jev, DeepSeek and the token floor.
18
19Four commands, in the order a run uses them::
20
21 train --backbone <hf id> --out <dir> # fine-tune, early-stopped
22 calibrate --model <dir> # fit one temperature
23 evaluate --model <dir> --cases <file> # write a result file
24 relabel --out <dir> # the policy-change probe
25
26``train``, ``calibrate`` and ``evaluate`` need torch and transformers and run
27under a SCRATCH HARNESS venv; ``relabel`` needs the argv gate and runs under
28the PROJECT venv. That split is why torch and transformers are imported inside
29the functions that use them rather than at module scope: the tests in
30``tests/test_290_encoder.py`` and ``tests/test_294_speed.py`` exercise the
31loader, the temperature fit, the vocabulary pruning, the tensor cache, the
32relabel filter and the result writer in the project venv, where no torch
33exists, and an import at module scope would put every one of them out of reach.
34
35Two scratch venvs, and which one to use
36---------------------------------------
37Plan 290 ran on the CPU venv at ``...\\harness\\.venv``. Plan 294 added a second
38one at ``...\\harness_cuda`` carrying ``torch==2.14.*+cu126``, because the host's
39GTX 1060 is a Pascal card (compute capability 6.1) and **the CUDA 12.6 wheel of
40PyTorch 2.14 is the last prebuilt build that still carries ``sm_61`` kernels**:
41PyTorch dropped Pascal from its CUDA 12.8 and 12.9 wheels at release 2.8 and
42CUDA 13 cannot build them. Both venvs stay; the CPU one is what keeps every
43number plan 290 produced reproducible, and ``--device cpu`` reproduces a CPU run
44inside the CUDA venv as well.
45
46Run the trainer from the subproject root with the harness interpreter, with
47``HF_HOME`` and ``HF_HUB_OFFLINE=1`` set in the shell::
48
49 <harness>\\python.exe bench/argv_policy/baselines/encoder_classifier.py train
50 --backbone microsoft/deberta-v3-xsmall --out <scratch>/bench_models/xsmall-7
51
52Model directories, their checkpoints and the raw training logs live under
53:data:`DEFAULT_MODEL_ROOT` on ``G:`` and are never committed; only the result
54JSON and a copy of the training log go into the worktree.
55
56The tokenizer
57-------------
58``AutoTokenizer.from_pretrained("microsoft/deberta-v3-xsmall")`` cannot be
59loaded in the harness venv: the snapshot ships only ``spm.model``, and
60converting that to a fast tokenizer needs protobuf and sentencepiece, neither
61of which is installed (and installing is not this plan's to do). The
62``MoritzLaurer/deberta-v3-base-zeroshot-v2.0`` snapshot in the same cache
63ships an already-converted ``tokenizer.json``, and its ``spm.model`` is
64BYTE-IDENTICAL to both microsoft backbones' -- SHA-256
65``c679fbf93643d19aab7ee10c0b99e460bdbc02fedf34b92b05af343b4af586fd`` for all
66three. So the tokenizer is loaded from that directory and
67:func:`verify_tokenizer_vocab` re-checks the digest equality at every training
68run rather than trusting this paragraph.
69
70Pricing the direction of an error (plan 298)
71--------------------------------------------
72Cross-entropy prices an ``allow`` where the gate says ``deny`` exactly as it
73prices a ``deny`` where the gate says ``allow``. The bench does not: the column
74it is read by counts the errors that GRANT MORE PERMISSION than the gate, and
75the other kind costs a human one unnecessary confirmation. Plan 298 puts that
76asymmetry into the arithmetic as one cost matrix and two levers over it.
77
78``--alpha A`` sets the matrix: zero on the diagonal, ``A`` where the prediction
79is more permissive than the truth, ``1`` where it is less. Then:
80
81- ``evaluate --decision min-cost`` reads the verdict minimising expected cost
82 out of the calibrated probabilities. It retrains nothing and rewrites no
83 probability -- only the ``pred`` field moves -- so it costs one flag.
84- ``train --cost-weight B`` adds ``B`` times the expected cost under that same
85 matrix to the training loss.
86
87Both default to today's behaviour exactly, not approximately: ``--alpha 1.0``
88makes every off-diagonal cell ``1``, under which the min-cost verdict and the
89argmax verdict agree on every input including ties, and ``--cost-weight 0.0``
90removes the loss term altogether.
91
92Watching the same column the objective prices (plan 300)
93--------------------------------------------------------
94Plan 298 held the stopping rule fixed on validation cross-entropy so that the
95objective was the only variable, and measured what that cost: at
96``--cost-weight 3`` the run stopped at epoch 2 and checkpointed an undertrained
97model, because an objective that prices the direction moves AWAY from
98cross-entropy by construction and a rule watching cross-entropy reads that
99movement as a run that has stopped improving. The two pieces are supplied here.
100
101- ``train --stop-metric cost`` makes early stopping and checkpoint selection
102 watch the subset validation EXPECTED COST under the same ``--alpha`` matrix
103 the loss term prices with, rather than the subset cross-entropy. Both figures
104 are computed and logged every epoch whichever one stops the run, so an
105 after-the-fact reader can see the epoch the other metric would have kept --
106 and :func:`select_best_epoch` states it in the training record rather than
107 leaving it to be inferred from the log.
108- ``evaluate`` counts the PERMISSION-GRANTING errors a result file makes
109 confidently, at 0.9 and at 0.99 top probability, into its provenance block.
110 Plan 298 found that class by hand and it is the class the escalation
111 threshold cannot reach: an error the model is sure about is not escalated and
112 no decision rule over a calibrated distribution moves it. Counting it in the
113 file makes it a column of the table rather than an afternoon's grep.
114
115``--stop-metric ce`` is the default and is plan 290's rule unchanged, so an
116unflagged run is still the run every earlier tag on this bench was produced by;
117the counts are additive provenance and leave the row shape ``score.py`` reads
118untouched.
119
120Responsibilities
121----------------
122- :func:`held_out_commands` / :func:`load_split` / :func:`label_counts` --
123 the corpus loader, with the hand-case guard.
124- :func:`epoch_batches` -- one epoch of length-bucketed minibatches.
125- :func:`resolve_device` -- plan 294 P1's device selection.
126- :func:`prune_vocab` / :func:`token_coverage` -- plan 294 P2's vocabulary
127 pruning, in pure Python.
128- :func:`remap_lookup` / :func:`remap_table` -- the pruned id space.
129- :func:`cache_key` / :func:`load_cached_ids` / :func:`store_cached_ids` /
130 :func:`validation_subset` / :func:`resolve_max_length` -- plan 294 P3's
131 tokenised-id cache and subset validation.
132- :func:`fit_temperature` / :func:`mean_negative_log_likelihood` /
133 :func:`probabilities` / :func:`brier` -- calibration, in pure Python.
134- :func:`cost_matrix` / :func:`expected_cost_of` / :func:`min_cost_verdict` /
135 :func:`decide` / :func:`expected_cost` -- plan 298's direction-weighted cost
136 matrix, its decision rule and its loss term, in pure Python.
137- :func:`cost_tensor` / :func:`expected_cost_term` -- the same loss term on
138 tensors, which is what the training loop actually calls.
139- :func:`stopping_value` / :func:`select_best_epoch` -- plan 300 P1's
140 direction-aware stopping rule, in pure Python.
141- :func:`grants_more_permission` / :func:`confident_error_counts` -- plan 300
142 P2's confident permission-granting error count, in pure Python.
143- :func:`strip_recursive_option` / :func:`relabel_recursive_allow` /
144 :func:`gate_classifier` -- the policy-change probe's documented relabel.
145- :func:`result_row` / :func:`write_result_file` -- the plan-288 result schema.
146- :func:`train` / :func:`calibrate` / :func:`evaluate` -- the torch commands.
147- :func:`main` -- the CLI.
148
149Diagnostics
150-----------
151Domain: OC_CHAT.MAIN
152Levels:
153 L2 - command lifecycle (training start and finish, files written)
154 L3 - per-epoch validation loss, per-case-set agreement, fitted temperature
155 L4 - module load
156
157Contracts
158---------
159- Read-only with respect to the committed corpus, the case sets and the gate:
160 the policy-change probe writes a RELABELLED COPY under the scratch root and
161 never edits a committed file.
162- The relabelled labels come from the unmodified gate applied to a rewritten
163 command, never from a hand edit of a verdict.
164- Bitwise reproduction is not claimed (CPU thread scheduling reorders float
165 reductions); a run is IDENTIFIABLE instead -- every result file carries the
166 backbone, the seed, the epoch count, the training wall time, the parameter
167 count and the SHA-256 of the saved weights.
168- Nothing from either case set touches training or calibration.
169- Console output is ASCII only; commands are printed through :func:`ascii`.
170- No dependency enters any ``pyproject.toml``; torch and transformers are
171 imported lazily and only exist in the scratch harness venv.
172
173Dependencies
174------------
175- oc_diagnostics (structured debug logging via _dbg)
176- torch (LAZY, harness venv only: tensors, AdamW, the forward and backward pass)
177- transformers (LAZY, harness venv only: AutoTokenizer and AutoModelForSequenceClassification)
178- oc_chat.tools._argv_policy (LAZY, project venv only: the gate the relabel asks)
179"""
180from __future__ import annotations
181
182import argparse
183import hashlib
184import json
185import math
186import random
187import sys
188import time
189from dataclasses import dataclass
190from pathlib import Path, PurePath
191
192from oc_diagnostics import _dbg
193
194_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
195
196#: The three verdicts, in the column order of the classification head.
197LABELS: tuple[str, ...] = ("allow", "confirm", "deny")
198
199#: Plan 298 P1. The same three verdicts ordered by how much permission they
200#: grant, LEAST first. Pinned as a tuple rather than derived from
201#: :data:`LABELS`, because every figure this plan produces is read off this
202#: order and an order inferred from the head's column order would follow a
203#: reordering of the head without saying so. It states what ``score.py``'s
204#: ``PERMISSIVENESS`` states, and ``tests/test_298_direction.py`` asserts the
205#: two agree rather than letting the bench carry two opinions about which way
206#: is dangerous.
207PERMISSIVENESS: tuple[str, ...] = ("deny", "confirm", "allow")
208
209#: Plan 298 P1. How much more an error granting MORE permission costs than one
210#: granting less. ``1.0`` prices them the same, which is the matrix plain
211#: cross-entropy already implies, so it is the default and every number this
212#: bench carried before plan 298 reproduces under it.
213DEFAULT_ALPHA = 1.0
214
215#: Plan 298 P3. The weight on the expected-cost term added to the training
216#: loss. ``0.0`` removes the term entirely, so an unflagged training run is
217#: plan 295's training run.
218DEFAULT_COST_WEIGHT = 0.0
219
220#: Plan 298 P2. How a verdict is read out of a calibrated distribution.
221#: ``argmax`` is the rule every result file before this plan was written under;
222#: ``min-cost`` minimises expected cost under the cost matrix.
223DECISION_CHOICES: tuple[str, ...] = ("argmax", "min-cost")
224DEFAULT_DECISION = "argmax"
225
226#: Plan 300 P1. Which validation figure early stopping and checkpoint selection
227#: watch, in the order :func:`select_best_epoch` reads an ``(ce, cost)`` pair:
228#: ``ce`` is the subset validation cross-entropy plan 290's rule watches and is
229#: the default, so an unflagged run is the run every earlier tag was produced
230#: by; ``cost`` is the subset validation expected cost under the ``--alpha``
231#: matrix, which is the column a direction-weighted objective is actually
232#: trying to move.
233STOP_METRIC_CHOICES: tuple[str, ...] = ("ce", "cost")
234DEFAULT_STOP_METRIC = "ce"
235
236#: Plan 300 P2. The top-probability thresholds the permission-granting errors
237#: are counted at. 0.9 is ``score.py``'s own escalation threshold, so the count
238#: at 0.9 is exactly the set of dangerous errors escalation would NOT catch;
239#: 0.99 separates the merely confident from the ones plan 298 found at 0.999,
240#: which no decision rule over a calibrated distribution can reach.
241CONFIDENT_THRESHOLDS: tuple[float, ...] = (0.9, 0.99)
242
243#: The pinned seed. It fixes the classifier head's initialisation and the batch
244#: order, and it names the model directory, so two runs are distinguishable.
245DEFAULT_SEED = 7
246
247#: Plan 290 P2. Commands are short -- the corpus's median is 10 tokens and its
248#: longest is 44 -- so 64 is a cap that never truncates rather than a shape.
249DEFAULT_MAX_LENGTH = 64
250DEFAULT_BATCH = 32
251DEFAULT_LEARNING_RATE = 3e-5
252DEFAULT_MAX_EPOCHS = 4
253DEFAULT_WARMUP_SHARE = 0.1
254
255#: Threads are pinned so the reported CPU latency means something. Measured on
256#: this host: 8 threads is no slower than the 14 available (1.22 s against
257#: 1.28 s per training step), so pinning costs nothing.
258DEFAULT_THREADS = 8
259
260#: Plan 294 P1. ``auto`` takes the GPU when torch reports one and the CPU
261#: otherwise; ``cuda`` REFUSES rather than falling back, because a silent
262#: fallback turns a 90-second epoch into an 18-minute one and the only evidence
263#: is the wall clock.
264DEVICE_CHOICES: tuple[str, ...] = ("auto", "cpu", "cuda")
265DEFAULT_DEVICE = "auto"
266
267#: Plan 294 P2. Written beside the weights when and only when the embedding
268#: table was sliced, so ``calibrate`` and ``evaluate`` can tell which id space
269#: a saved model is indexed by without being told on the command line.
270VOCAB_PRUNE_FILE = "vocab_prune.json"
271
272#: Plan 294 P2. How many unseen vocabulary rows the pruning keeps anyway, so an
273#: unseen but plausible piece still has a row of its own rather than collapsing
274#: onto the unknown token. A SentencePiece vocabulary is emitted in descending
275#: unigram-score order after its control symbols, so "the most frequent ids that
276#: do not occur" is "the lowest-numbered ids that do not occur" and no frequency
277#: table has to be carried alongside the model.
278DEFAULT_VOCAB_RESERVE = 2000
279
280#: Plan 294 P3. Where the tokenised corpus is cached. Under the scratch root
281#: with the models, never in the worktree: it is rebuildable, it is megabytes,
282#: and ``R:`` refuses writes under host memory pressure.
283DEFAULT_CACHE_ROOT = Path(r"G:\Temp\claude\g--PythonApps-option-c\harness\bench_cache")
284
285#: Plan 294 P3. The per-epoch validation subset: how many rows, and the seed
286#: that fixes WHICH rows, so two runs validate on the same commands.
287DEFAULT_SUBSET_ROWS = 1000
288DEFAULT_SUBSET_SEED = 294
289
290#: Plan 294 P3. The corpus's longest command is 44 tokens, so 48 is a cap that
291#: still never truncates. :func:`resolve_max_length` re-measures rather than
292#: trusting this number, and falls back to :data:`DEFAULT_MAX_LENGTH` if a
293#: regenerated corpus ever exceeds it.
294FAST_MAX_LENGTH = 48
295
296#: Plan 290 P2's wall-time budget per backbone. Training stops at the end of
297#: the epoch after which another epoch would not fit, and the record says the
298#: stop was budget-bound rather than convergence-bound.
299DEFAULT_BUDGET_SECONDS = 3600.0
300
301#: How much better a validation cross-entropy has to be to count as better.
302EARLY_STOP_MIN_DELTA = 1e-4
303
304#: Batches are formed inside chunks of this many, sorted by length, so padding
305#: follows the commands in the chunk instead of the longest in the epoch.
306BUCKET_BATCHES = 50
307
308#: The scratch root every model directory, checkpoint and raw log lives under.
309#: ``R:`` refuses writes under host memory pressure and nothing here is
310#: committed, so neither the worktree nor the RAM disk is the place for it.
311DEFAULT_MODEL_ROOT = Path(
312 r"G:\Temp\claude\g--PythonApps-option-c\harness\bench_models"
313)
314
315#: The snapshot holding the converted DeBERTa-v3 fast tokenizer (see "The
316#: tokenizer" above). Its vocabulary is verified against the backbone's own
317#: ``spm.model`` by digest at every training run.
318DEFAULT_TOKENIZER_DIR = Path(
319 # OCT-LINT: disable=no_hardcoded_secrets reason="local HF snapshot path; the entropy is the 40-hex model revision id, not a credential (operator ruling 2026-09-17)"
320 r"G:\Temp\claude\g--PythonApps-option-c\harness\hf_home\hub"
321 r"\models--MoritzLaurer--deberta-v3-base-zeroshot-v2.0\snapshots"
322 r"\8e7e5af5983a0ddb1a5b45a38b129ab69e2258e8"
323)
324
325#: The HF cache the backbones were downloaded into, used to locate a backbone's
326#: ``spm.model`` for the digest check.
327DEFAULT_HF_HOME = Path(r"G:\Temp\claude\g--PythonApps-option-c\harness\hf_home")
328
329#: The programs whose option list the policy-change probe edits. The gate
330#: normalises a program the same way: basename, lowercased, ``.exe`` stripped.
331LISTING_PROGRAMS: frozenset[str] = frozenset({"ls", "dir"})
332
333#: The option the probe turns from unknown into allowed.
334RECURSIVE_OPTION = "R"
335
336
337# --------------------------------------------------------------------------
338# The corpus loader
339# --------------------------------------------------------------------------
340
341
342def read_jsonl(path: Path) -> list[dict[str, str]]:
343 """Read a JSON-lines file into a list of rows."""
344
345 text = Path(path).read_text(encoding="utf-8")
346 return [json.loads(line) for line in text.splitlines() if line.strip()]
347
348
349def held_out_commands(bench_dir: Path) -> frozenset[str]:
350 """Every command in the two case sets, which no training row may repeat."""
351
352 func = "held_out_commands"
353 commands: set[str] = set()
354 for name in ("cases.json", "cases_hard.json"):
355 path = Path(bench_dir) / name
356 for case in json.loads(path.read_text(encoding="utf-8")):
357 commands.add(case["command"])
358 _dbg("OC_CHAT.MAIN", func, f"{len(commands)} held-out commands", 3)
359 return frozenset(commands)
360
361
362def load_split(bench_dir: Path, name: str, held_out: frozenset[str]) -> list[dict[str, str]]:
363 """Load one corpus split, refusing any row that is a held-out case.
364
365 ``gen_corpus.py`` already drops the case sets at generation time, so this
366 guard should never fire -- which is exactly why it is worth asserting here:
367 a hand case that reached training would raise every agreement figure on
368 this bench and nothing downstream would look wrong.
369 """
370
371 func = "load_split"
372 rows = read_jsonl(Path(bench_dir) / name)
373 leaked = [row["command"] for row in rows if row["command"] in held_out]
374 if leaked:
375 raise ValueError(
376 f"{name}: {len(leaked)} held-out case(s) present in the corpus, "
377 f"first is {ascii(leaked[0])}"
378 )
379 _dbg("OC_CHAT.MAIN", func, f"{name}: {len(rows)} rows", 3)
380 return rows
381
382
383def label_counts(rows: list[dict[str, str]]) -> dict[str, int]:
384 """How many rows carry each verdict."""
385
386 return {label: sum(1 for row in rows if row["truth"] == label) for label in LABELS}
387
388
390 rows: list[dict[str, str]], batch: int, rng: random.Random
391) -> list[list[dict[str, str]]]:
392 """One epoch of minibatches: a permutation of *rows*, length-bucketed.
393
394 An epoch is a permutation, so the class balance a batch sequence sees is
395 the corpus's own and no class weighting is needed. Within a chunk of
396 :data:`BUCKET_BATCHES` batches the rows are sorted by command length before
397 being cut into batches, and the batch order is then shuffled: padding
398 follows the chunk rather than the longest command in the epoch, which on
399 this corpus is the difference between 20.6 and 17.1 minutes per epoch, and
400 the shuffle keeps the gradient sequence stochastic.
401 """
402
403 order = list(range(len(rows)))
404 rng.shuffle(order)
405 chunk_size = batch * BUCKET_BATCHES
406 batches: list[list[dict[str, str]]] = []
407 for start in range(0, len(order), chunk_size):
408 chunk = sorted(order[start:start + chunk_size], key=lambda index: len(rows[index]["command"]))
409 for offset in range(0, len(chunk), batch):
410 batches.append([rows[index] for index in chunk[offset:offset + batch]])
411 rng.shuffle(batches)
412 return batches
413
414
415# --------------------------------------------------------------------------
416# Plan 294 P2 and P3: pruning, caching and subset validation, in pure Python
417# --------------------------------------------------------------------------
418
419
420def tokenise(commands: list[str], tokenizer: object, max_length: int) -> list[list[int]]:
421 """The token ids of every command, unpadded, truncated at *max_length*."""
422
423 encoded = tokenizer(list(commands), truncation=True, # type: ignore[operator]
424 max_length=max_length)
425 return [list(ids) for ids in encoded["input_ids"]]
426
427
428def tokenise_keys(tokenizer: object) -> tuple[str, ...]:
429 """Which tensors this tokenizer emits, asked of it rather than assumed."""
430
431 return tuple(tokenizer(["ls"]).keys()) # type: ignore[operator]
432
433
434def full_vocab_size(tokenizer: object) -> int:
435 """How many ids the tokenizer can emit, counting any added tokens."""
436
437 try:
438 return len(tokenizer) # type: ignore[arg-type]
439 except TypeError: # pragma: no cover - stubs
440 return int(tokenizer.vocab_size) # type: ignore[attr-defined]
441
442
444 corpus: list[str],
445 tokenizer: object,
446 reserve: int = DEFAULT_VOCAB_RESERVE,
447 max_length: int = DEFAULT_MAX_LENGTH,
448) -> tuple[list[int], dict[int, int]]:
449 """Plan 294 P2: the vocabulary rows worth keeping, and the map onto them.
450
451 DeBERTa-v3-xsmall carries 70.8M parameters of which the 128,100-row
452 embedding table at width 384 is about 49M -- two thirds of the model
453 devoted to pieces this corpus never emits. The kept set is every id the
454 corpus actually produces, plus every special token, plus a reserve of the
455 most frequent ids that do NOT occur, so an unseen but plausible piece still
456 has a row of its own rather than collapsing onto the unknown token.
457
458 Returns the kept ids ASCENDING and a map from old id to its position in
459 that list. Ascending order is not cosmetic: it keeps the control symbols at
460 the front in their original relative order, so the padding id survives the
461 remap as itself and a saved config's ``pad_token_id`` stays true.
462 """
463
464 func = "prune_vocab"
465 occurring: set[int] = set()
466 for ids in tokenise(corpus, tokenizer, max_length):
467 occurring.update(int(value) for value in ids)
468 specials = {int(value) for value in tokenizer.all_special_ids} # type: ignore[attr-defined]
469 seen = occurring | specials
470 full = full_vocab_size(tokenizer)
471 reserved: list[int] = []
472 for token_id in range(full):
473 if len(reserved) >= reserve:
474 break
475 if token_id not in seen:
476 reserved.append(token_id)
477 kept = sorted(seen | set(reserved))
478 remap = {old: new for new, old in enumerate(kept)}
479 _dbg("OC_CHAT.MAIN", func,
480 f"{len(kept)} of {full} rows kept ({len(occurring)} seen, "
481 f"{len(reserved)} reserved)", 2)
482 return kept, remap
483
484
485def token_coverage(commands: list[str], tokenizer: object, kept: set[int] | list[int],
486 max_length: int = DEFAULT_MAX_LENGTH) -> float:
487 """The share of token OCCURRENCES in *commands* that a kept id covers.
488
489 Occurrences rather than distinct ids: one rare piece appearing once in one
490 hard case is not the same loss as a common piece appearing in a hundred,
491 and the figure that says whether pruning hurt the case sets is the one
492 weighted the way the case sets actually use the vocabulary.
493 """
494
495 kept_set = {int(value) for value in kept}
496 total = 0
497 covered = 0
498 for ids in tokenise(commands, tokenizer, max_length):
499 for value in ids:
500 total += 1
501 covered += int(int(value) in kept_set)
502 return covered / total if total else 1.0
503
504
505def resolve_max_length(commands: list[str], tokenizer: object, requested: int,
506 fallback: int = DEFAULT_MAX_LENGTH) -> int:
507 """*requested* if it never truncates *commands*, else *fallback*.
508
509 Plan 294 P3 shortens the sequence from 64 to 48 because the corpus's
510 longest command is 44 tokens. That is a measured fact about a committed
511 file rather than a property of the task, so it is re-measured here: a
512 regenerated corpus carrying a longer command silently loses its tail
513 otherwise, and a truncated command is a mislabelled training row.
514 """
515
516 func = "resolve_max_length"
517 longest = max((len(ids) for ids in tokenise(commands, tokenizer, fallback)), default=0)
518 if longest <= requested:
519 return requested
520 _dbg("OC_CHAT.MAIN", func,
521 f"longest command is {longest} tokens, over {requested}; using {fallback}", 2)
522 return fallback
523
524
525def cache_key(corpus_path: Path, tokenizer_digest: str, max_length: int) -> str:
526 """The cache identity of one tokenised split.
527
528 The corpus file's own SHA-256 is in the key, so a regenerated corpus
529 invalidates the cache instead of training the next run on the previous
530 corpus's ids -- which would be a silent wrong answer, not a crash.
531 """
532
533 material = f"{file_digest(corpus_path)}:{tokenizer_digest}:{max_length}"
534 return hashlib.sha256(material.encode("utf-8")).hexdigest()[:32]
535
536
537def load_cached_ids(cache_root: Path, key: str) -> list[list[int]] | None:
538 """The tokenised split cached under *key*, or ``None`` when it is absent."""
539
540 func = "load_cached_ids"
541 path = Path(cache_root) / f"ids_{key}.json"
542 if not path.is_file():
543 return None
544 rows = json.loads(path.read_text(encoding="utf-8"))
545 _dbg("OC_CHAT.MAIN", func, f"{len(rows)} rows from {path.name}", 3)
546 return [list(row) for row in rows]
547
548
549def store_cached_ids(cache_root: Path, key: str, rows: list[list[int]]) -> Path:
550 """Write a tokenised split into the cache and return where it landed."""
551
552 func = "store_cached_ids"
553 Path(cache_root).mkdir(parents=True, exist_ok=True)
554 path = Path(cache_root) / f"ids_{key}.json"
555 path.write_text(json.dumps(rows), encoding="utf-8")
556 _dbg("OC_CHAT.MAIN", func, f"{len(rows)} rows to {path.name}", 3)
557 return path
558
559
560def remap_lookup(remap: dict[int, int], unknown_id: int, size: int) -> list[int]:
561 """The old-id to new-id lookup as a plain list, unknown-filled.
562
563 Every id the pruning dropped points at the unknown token, which is the
564 pruning's whole contract for a piece the corpus never showed. Built here in
565 pure Python rather than inside the tensor constructor so the contract is
566 assertable in the project venv, where there is no torch.
567 """
568
569 lookup = [unknown_id] * size
570 for old, new in remap.items():
571 lookup[old] = new
572 return lookup
573
574
575def validation_subset(rows: list[dict[str, str]], size: int,
576 seed: int = DEFAULT_SUBSET_SEED) -> list[dict[str, str]]:
577 """A fixed *size*-row sample of *rows*, the same one for every run.
578
579 The sample is drawn from a pinned seed and then restored to corpus order,
580 so the subset a run validates on depends on the seed alone and not on the
581 order the shuffle happened to produce.
582 """
583
584 if size <= 0 or size >= len(rows):
585 return list(rows)
586 order = list(range(len(rows)))
587 random.Random(seed).shuffle(order)
588 return [rows[index] for index in sorted(order[:size])]
589
590
591# --------------------------------------------------------------------------
592# Calibration, in pure Python
593# --------------------------------------------------------------------------
594
595
596def probabilities(logits: list[float], temperature: float = 1.0) -> dict[str, float]:
597 """The temperature-scaled softmax of one logit row, keyed by verdict."""
598
599 scaled = [value / temperature for value in logits]
600 top = max(scaled)
601 exponentials = [math.exp(value - top) for value in scaled]
602 total = sum(exponentials)
603 return {label: value / total for label, value in zip(LABELS, exponentials)}
604
605
606def brier(probs: dict[str, float], truth: str) -> float:
607 """The multi-class Brier score of one forecast (``score.brier_row``).
608
609 Kept here rather than imported because ``score.py`` sits one directory up
610 and this module is run as a script from the harness venv; the arithmetic is
611 two lines and ``tests/test_290_encoder.py`` asserts the two agree.
612 """
613
614 return sum((probs.get(label, 0.0) - (1.0 if label == truth else 0.0)) ** 2
615 for label in LABELS)
616
617
619 logits: list[list[float]],
620 labels: list[int],
621 temperature: float,
622 weights: list[float] | None = None,
623) -> float:
624 """Mean NLL of *logits* divided by *temperature* against *labels*.
625
626 *weights* exist for the witness, not for the run: weighting each logit row
627 by the true probability of each class turns this into the EXPECTED NLL,
628 whose minimiser over the scalar is exactly the temperature the logits were
629 scaled by, which is what lets the test assert a known answer to two
630 decimals instead of a sampling interval.
631 """
632
633 total = 0.0
634 mass = 0.0
635 for index, row in enumerate(logits):
636 weight = 1.0 if weights is None else weights[index]
637 if weight == 0.0:
638 continue
639 scaled = [value / temperature for value in row]
640 top = max(scaled)
641 normaliser = top + math.log(sum(math.exp(value - top) for value in scaled))
642 total += weight * (normaliser - scaled[labels[index]])
643 mass += weight
644 return total / mass if mass else 0.0
645
646
648 logits: list[list[float]],
649 labels: list[int],
650 weights: list[float] | None = None,
651 low: float = 0.05,
652 high: float = 20.0,
653) -> float:
654 """Fit one temperature by minimising NLL, by golden-section search.
655
656 Plan 290 P3 asks for the negative log-likelihood to be minimised over one
657 scalar with the optimisation written out rather than called from a fitting
658 library, and W1 asks for a fit that runs in the project venv, where there
659 is no torch. A golden-section search over log-temperature satisfies both:
660 the objective is unimodal in log T, the search needs no derivative, and
661 eighty iterations close the bracket to about 1e-7.
662 """
663
664 func = "fit_temperature"
665 ratio = (math.sqrt(5.0) - 1.0) / 2.0
666 left, right = math.log(low), math.log(high)
667 probe_a, probe_b = right - ratio * (right - left), left + ratio * (right - left)
668 value_a = mean_negative_log_likelihood(logits, labels, math.exp(probe_a), weights)
669 value_b = mean_negative_log_likelihood(logits, labels, math.exp(probe_b), weights)
670 for _ in range(80):
671 if value_a <= value_b:
672 right, probe_b, value_b = probe_b, probe_a, value_a
673 probe_a = right - ratio * (right - left)
674 value_a = mean_negative_log_likelihood(logits, labels, math.exp(probe_a), weights)
675 else:
676 left, probe_a, value_a = probe_a, probe_b, value_b
677 probe_b = left + ratio * (right - left)
678 value_b = mean_negative_log_likelihood(logits, labels, math.exp(probe_b), weights)
679 fitted = math.exp((left + right) / 2.0)
680 _dbg("OC_CHAT.MAIN", func, f"temperature {fitted:.4f} over {len(logits)} rows", 3)
681 return fitted
682
683
685 logits: list[list[float]], rows: list[dict[str, str]], temperature: float
686) -> dict[str, float | int]:
687 """NLL, Brier and agreement on the validation split, before and after."""
688
689 labels = [LABELS.index(row["truth"]) for row in rows]
690 before = [probabilities(row, 1.0) for row in logits]
691 after = [probabilities(row, temperature) for row in logits]
692 hits = sum(max(probs, key=lambda label: probs[label]) == row["truth"]
693 for probs, row in zip(before, rows))
694 return {
695 "validation_rows": len(rows),
696 "temperature": temperature,
697 "nll_before": mean_negative_log_likelihood(logits, labels, 1.0),
698 "nll_after": mean_negative_log_likelihood(logits, labels, temperature),
699 "brier_before": sum(brier(probs, row["truth"])
700 for probs, row in zip(before, rows)) / len(rows),
701 "brier_after": sum(brier(probs, row["truth"])
702 for probs, row in zip(after, rows)) / len(rows),
703 "validation_accuracy": hits / len(rows),
704 }
705
706
707# --------------------------------------------------------------------------
708# Plan 298: pricing the direction of an error, in pure Python
709# --------------------------------------------------------------------------
710
711
712def cost_matrix(alpha: float = DEFAULT_ALPHA) -> list[list[float]]:
713 """Plan 298 P1: ``COST[true][pred]``, both axes in :data:`LABELS` order.
714
715 Zero on the diagonal; *alpha* in every cell where the prediction grants MORE
716 permission than the gate's verdict; ``1.0`` in every cell where it grants
717 less. At *alpha* ``1.0`` every off-diagonal cell is ``1.0``, which is the
718 matrix a plain cross-entropy objective and an argmax decision rule already
719 imply -- which is why that is the default and why it reproduces every number
720 on this bench rather than approximating it.
721
722 The asymmetry is the whole of this plan. An ``allow`` where the gate says
723 ``deny`` runs something the gate refused; a ``deny`` where the gate says
724 ``allow`` costs a human one unnecessary confirmation. Cross-entropy prices
725 those two mistakes identically. The gate does not, and neither does the
726 column this bench is read by.
727 """
728
729 func = "cost_matrix"
730 ranks = {label: PERMISSIVENESS.index(label) for label in LABELS}
731 matrix = [
732 [0.0 if pred == true else (alpha if ranks[pred] > ranks[true] else 1.0)
733 for pred in LABELS]
734 for true in LABELS
735 ]
736 _dbg("OC_CHAT.MAIN", func, f"alpha {alpha}", 4)
737 return matrix
738
739
740def expected_cost_of(probs: dict[str, float], pred: str,
741 costs: list[list[float]]) -> float:
742 """What predicting *pred* costs in expectation under the forecast *probs*.
743
744 ``sum_true p(true) * COST[true][pred]`` -- the quantity plan 298 P2's rule
745 minimises, named so a test can assert the arithmetic directly rather than
746 only through the verdict it happens to produce.
747 """
748
749 pred_index = LABELS.index(pred)
750 return sum(probs.get(LABELS[true_index], 0.0) * costs[true_index][pred_index]
751 for true_index in range(len(LABELS)))
752
753
754def min_cost_verdict(probs: dict[str, float], costs: list[list[float]]) -> str:
755 """Plan 298 P2: the verdict with the lowest expected cost under *probs*.
756
757 ``argmin_pred sum_true p(true) * COST[true][pred]``. This is the cheap
758 lever: it changes no weights and touches no probability, only which verdict
759 is read out of one, so a model already trained and calibrated can be made
760 more cautious for the price of a second pass over its own output.
761
762 A tie breaks towards the earlier :data:`LABELS` column, which is precisely
763 how ``max`` breaks an argmax tie over the same dict. That matters more than
764 it looks: it is what makes this rule agree with the argmax rule on EVERY
765 input at *alpha* ``1.0``, rather than on every input that happens not to be
766 tied, so the default really is the old behaviour and not merely close to it.
767 """
768
769 return min(LABELS, key=lambda pred: expected_cost_of(probs, pred, costs))
770
771
772def decide(probs: dict[str, float], decision: str = DEFAULT_DECISION,
773 costs: list[list[float]] | None = None) -> str:
774 """The verdict *decision* reads out of one calibrated distribution.
775
776 The two rules are kept behind one function because ``result_row`` must not
777 grow a branch: a result file differs under the two rules in its ``pred``
778 field and in nothing else, and that stays true more easily when the choice
779 is made in one place.
780 """
781
782 func = "decide"
783 if decision not in DECISION_CHOICES:
784 raise ValueError(
785 f"{func}: decision must be one of {DECISION_CHOICES}, not {decision!r}"
786 )
787 if decision == "argmax":
788 return max(probs, key=lambda label: probs[label])
789 return min_cost_verdict(probs, costs if costs is not None else cost_matrix())
790
791
792def expected_cost(prob_rows: list[list[float]], labels: list[int],
793 costs: list[list[float]]) -> float:
794 """Plan 298 P3's training term, written out in plain Python.
795
796 Per row ``sum_pred p[pred] * COST[true][pred]``, averaged over the batch so
797 the term lands on the same scale as the mean cross-entropy it is added to
798 and ``--cost-weight`` therefore means the same thing at any batch size.
799
800 This function is the DEFINITION and :func:`expected_cost_term` is the fast
801 copy of it; ``tests/test_298_direction.py`` asserts the two agree on a toy
802 batch, which is the only reason the tensor version can be trusted inside a
803 run whose per-step output nobody checks by hand.
804 """
805
806 if not prob_rows:
807 return 0.0
808 total = 0.0
809 for row, label in zip(prob_rows, labels):
810 total += sum(row[index] * costs[label][index] for index in range(len(LABELS)))
811 return total / len(prob_rows)
812
813
814# --------------------------------------------------------------------------
815# Plan 300: stopping on the column the objective prices, in pure Python
816# --------------------------------------------------------------------------
817
818
819def stopping_value(record: dict[str, float | int], metric: str = DEFAULT_STOP_METRIC) -> float:
820 """Plan 300 P1: the figure a run watching *metric* compares for one epoch.
821
822 One function rather than a conditional expression in two places, because the
823 training loop and :func:`select_best_epoch` have to agree about which number
824 is being watched or the record's "the other metric would have chosen epoch
825 N" line is measuring something no run would have done.
826 """
827
828 func = "stopping_value"
829 if metric not in STOP_METRIC_CHOICES:
830 raise ValueError(
831 f"{func}: stop metric must be one of {STOP_METRIC_CHOICES}, not {metric!r}"
832 )
833 key = "validation_loss" if metric == "ce" else "validation_cost"
834 return float(record[key])
835
836
838 pairs: list[tuple[float, float]],
839 metric: str = DEFAULT_STOP_METRIC,
840 patience: int = 1,
841 min_delta: float = EARLY_STOP_MIN_DELTA,
842) -> dict[str, float | int | bool]:
843 """Plan 300 P1: which epoch a run watching *metric* keeps, and when it stops.
844
845 *pairs* is one ``(cross-entropy, expected cost)`` per epoch in epoch order,
846 in :data:`STOP_METRIC_CHOICES` order, which is how *metric* selects the
847 element to compare. Returns the 1-based best epoch (``0`` when no epoch ever
848 improved), the value it scored, how many epochs the run would have got
849 through, and whether patience ended it.
850
851 This is the loop's rule written out as a function, and it earns its place
852 twice. It is what W1 asserts on hand-written sequences, where the answer is
853 known and a real run's is not. And it is what the training record calls on
854 at the end with the OTHER metric, so the plan-300 table can say which epoch
855 a run would have checkpointed had it watched the other column -- the one
856 figure that separates "the stopping rule moved the model" from "the run
857 happened to differ". Run over the epochs one run actually produced: a run
858 watching the other metric might have stopped earlier and never seen the
859 later pairs, so the alternate epoch is a reading of THIS run's curve, not a
860 prediction of a run nobody made.
861 """
862
863 func = "select_best_epoch"
864 if metric not in STOP_METRIC_CHOICES:
865 raise ValueError(
866 f"{func}: stop metric must be one of {STOP_METRIC_CHOICES}, not {metric!r}"
867 )
868 index = STOP_METRIC_CHOICES.index(metric)
869 best_value = float("inf")
870 best_epoch = 0
871 epochs_run = 0
872 stale = 0
873 stopped_early = False
874 for epoch, pair in enumerate(pairs, 1):
875 epochs_run = epoch
876 value = float(pair[index])
877 if value < best_value - min_delta:
878 stale = 0
879 best_value, best_epoch = value, epoch
880 continue
881 stale += 1
882 if stale >= patience:
883 stopped_early = True
884 break
885 _dbg("OC_CHAT.MAIN", func,
886 f"{metric}: best epoch {best_epoch} of {epochs_run} run", 4)
887 return {
888 "best_epoch": best_epoch,
889 "best_value": best_value,
890 "epochs_run": epochs_run,
891 "stopped_early": stopped_early,
892 }
893
894
895def other_stop_metric(metric: str) -> str:
896 """The stopping metric *metric* is not, which is what the record compares."""
897
898 func = "other_stop_metric"
899 if metric not in STOP_METRIC_CHOICES:
900 raise ValueError(
901 f"{func}: stop metric must be one of {STOP_METRIC_CHOICES}, not {metric!r}"
902 )
903 return next(choice for choice in STOP_METRIC_CHOICES if choice != metric)
904
905
906def grants_more_permission(truth: str, pred: str) -> bool:
907 """Whether predicting *pred* where the gate said *truth* grants MORE permission.
908
909 ``score.py``'s :func:`error_direction` in a boolean, and asserted equal to
910 it in ``tests/test_300_stopping.py`` rather than left as two opinions about
911 which way is dangerous. An unrecognised prediction counts as granting more,
912 for the reason the scorer gives: understating that column is the one
913 direction this bench must never round in.
914 """
915
916 if pred == truth:
917 return False
918 ranks = {label: PERMISSIVENESS.index(label) for label in PERMISSIVENESS}
919 if pred not in ranks:
920 return True
921 return ranks[pred] > ranks.get(truth, 0)
922
923
925 rows: list[dict[str, object]], thresholds: tuple[float, ...] = CONFIDENT_THRESHOLDS
926) -> dict[str, int]:
927 """Plan 300 P2: how many permission-granting errors were made CONFIDENTLY.
928
929 Returns the total, and the count at or above each threshold's top
930 probability. The class matters because it is the one nothing downstream can
931 catch: escalation fires BELOW its threshold, so an error at 0.99 is not
932 escalated; and a min-cost reading moves a verdict only where the
933 distribution leaves enough mass elsewhere to move, which at 0.999 it does
934 not. Plan 298 found five of ``small``'s thirteen at 0.9 or better and three
935 at 0.999 or better by hand; this makes it a number every result file
936 carries.
937
938 A row with no usable forecast counts towards the total and towards no
939 threshold, which is the same treatment ``score.py`` gives it in the Brier
940 column: a missing probability is not evidence of a confident mistake.
941 """
942
943 func = "confident_error_counts"
944 counts = {"granted_more": 0}
945 for threshold in thresholds:
946 counts[f"granted_more_at_{threshold:g}"] = 0
947 for row in rows:
948 if not grants_more_permission(str(row.get("truth", "")), str(row.get("pred", ""))):
949 continue
950 counts["granted_more"] += 1
951 probs = row.get("probs")
952 if not isinstance(probs, dict) or not probs:
953 continue
954 top = max(float(value) for value in probs.values())
955 for threshold in thresholds:
956 if top >= threshold:
957 counts[f"granted_more_at_{threshold:g}"] += 1
958 _dbg("OC_CHAT.MAIN", func,
959 f"{counts['granted_more']} permission-granting error(s) over {len(rows)} rows", 3)
960 return counts
961
962
963# --------------------------------------------------------------------------
964# The policy-change probe's relabel
965# --------------------------------------------------------------------------
966
967
968def _normalised_program(token: str) -> str:
969 """The program name the gate would read out of *token*."""
970
971 name = PurePath(token).name.lower()
972 return name[:-4] if name.endswith(".exe") else name
973
974
975def _is_short_cluster(token: str) -> bool:
976 """Whether *token* is a short-option cluster such as ``-la``."""
977
978 return len(token) > 1 and token[0] == "-" and token[1] != "-" and token[1:].isalnum()
979
980
981def strip_recursive_option(command: str) -> str | None:
982 """*command* with the recursive listing option removed, or ``None``.
983
984 This is the whole of the policy change the probe measures: ``-R`` becomes
985 an allowed option of the Tier-1 listing commands, and nothing else moves.
986 Because the option carries no other meaning to the gate, the verdict of a
987 command under the changed policy is the verdict the UNMODIFIED gate gives
988 the same command with ``-R`` removed -- which is how
989 :func:`relabel_recursive_allow` derives every new label without editing
990 ``_argv_policy.py`` and without hand-writing a verdict.
991
992 ``None`` when the command is not a listing command carrying an unquoted
993 ``-R``: a quoted ``'-R'`` is data, ``--reverse`` is a different option, and
994 ``python -R`` is a real option of a different program in a different tier.
995 """
996
997 tokens = command.split()
998 if not tokens or _normalised_program(tokens[0]) not in LISTING_PROGRAMS:
999 return None
1000 kept = [tokens[0]]
1001 matched = False
1002 for token in tokens[1:]:
1003 if _is_short_cluster(token) and RECURSIVE_OPTION in token[1:]:
1004 matched = True
1005 remainder = token[1:].replace(RECURSIVE_OPTION, "")
1006 if remainder:
1007 kept.append(f"-{remainder}")
1008 continue
1009 kept.append(token)
1010 return " ".join(kept) if matched else None
1011
1012
1014 rows: list[dict[str, str]], classify: object
1015) -> tuple[list[dict[str, str]], int]:
1016 """Relabel every matched row by asking the gate about the stripped command.
1017
1018 Returns the new rows and how many verdicts actually moved. A matched row
1019 whose verdict does not move is the interesting case and is kept, not
1020 dropped: ``ls -laR /etc`` is still a deny after ``-R`` becomes allowed,
1021 because the path is still outside the sandbox, and a blanket
1022 deny-to-allow rewrite would have taught the model the opposite.
1023 """
1024
1025 func = "relabel_recursive_allow"
1026 relabelled: list[dict[str, str]] = []
1027 changed = 0
1028 for row in rows:
1029 stripped = strip_recursive_option(row["command"])
1030 if stripped is None:
1031 relabelled.append(dict(row))
1032 continue
1033 verdict, reason = classify(stripped) # type: ignore[operator]
1034 if verdict != row["truth"]:
1035 changed += 1
1036 updated = dict(row)
1037 updated["truth"] = verdict
1038 updated["reason"] = reason
1039 relabelled.append(updated)
1040 _dbg("OC_CHAT.MAIN", func, f"{changed} of {len(rows)} verdicts moved", 3)
1041 return relabelled, changed
1042
1043
1044def gate_classifier(sandbox_root: Path) -> object:
1045 """A ``command -> (verdict, reason)`` callable backed by the real gate.
1046
1047 Imported lazily: the argv gate lives in the ``oc_chat`` package, which the
1048 scratch harness venv cannot import, and only ``relabel`` needs it.
1049 """
1050
1051 from oc_chat.tools._argv_policy import classify_command
1052
1053 def classify(command: str) -> tuple[str, str]:
1054 """Classify one command against the unmodified gate."""
1055
1056 decision = classify_command(command, sandbox_root)
1057 return decision.decision, decision.reason
1058
1059 return classify
1060
1061
1062# --------------------------------------------------------------------------
1063# The result schema
1064# --------------------------------------------------------------------------
1065
1066
1068 case: dict[str, str], logits: list[float], temperature: float, elapsed_ms: float,
1069 decision: str = DEFAULT_DECISION, costs: list[list[float]] | None = None,
1070) -> dict[str, object]:
1071 """One result row in the plan-288 schema, calibrated and timed.
1072
1073 *decision* is plan 298 P2's rule, defaulting to the argmax every result file
1074 written before that plan carries. Note what it does NOT touch: ``probs`` and
1075 ``logits`` are the model's own output either way, so two result files
1076 differing only in the rule differ only in ``pred``, and the Brier score and
1077 the reliability bands the scorer computes are identical across the pair.
1078 That is the property that lets the table read the rule's effect off the
1079 agreement and direction columns alone.
1080 """
1081
1082 probs = probabilities(logits, temperature)
1083 return {
1084 "command": case["command"],
1085 "truth": case["truth"],
1086 "slice": case.get("slice", ""),
1087 "pred": decide(probs, decision, costs),
1088 "probs": probs,
1089 "ms": elapsed_ms,
1090 "reason": case.get("reason", ""),
1091 "logits": [round(value, 6) for value in logits],
1092 }
1093
1094
1096 path: Path,
1097 model: str,
1098 condition: str,
1099 rows: list[dict[str, object]],
1100 provenance: dict[str, object],
1101) -> None:
1102 """Write one result file the shared scorer can read."""
1103
1104 func = "write_result_file"
1105 payload = {
1106 "model": model,
1107 "condition": condition,
1108 "results": rows,
1109 "provenance": provenance,
1110 }
1111 Path(path).parent.mkdir(parents=True, exist_ok=True)
1112 Path(path).write_text(json.dumps(payload, indent=1, sort_keys=True) + "\n", encoding="utf-8")
1113 _dbg("OC_CHAT.MAIN", func, f"wrote {Path(path).name} ({len(rows)} rows)", 2)
1114
1115
1116def file_digest(path: Path) -> str:
1117 """The SHA-256 of one file, which is how a trained run is identified."""
1118
1119 digest = hashlib.sha256()
1120 with Path(path).open("rb") as handle:
1121 for block in iter(lambda: handle.read(1 << 20), b""):
1122 digest.update(block)
1123 return digest.hexdigest()
1124
1125
1126def weights_digest(model_dir: Path) -> str:
1127 """The SHA-256 of the saved weights, whichever format they were saved in."""
1128
1129 for name in ("model.safetensors", "pytorch_model.bin"):
1130 candidate = Path(model_dir) / name
1131 if candidate.is_file():
1132 return file_digest(candidate)
1133 return ""
1134
1135
1137 tokenizer_dir: Path, backbone: str, hf_home: Path
1138) -> tuple[str, bool]:
1139 """Check the tokenizer's vocabulary is the backbone's own, by digest.
1140
1141 Returns the tokenizer's ``spm.model`` digest and whether a ``spm.model``
1142 found in the backbone's cache directory matches it. A mismatch means the
1143 tokenizer is the wrong one for the weights and training would be nonsense,
1144 so the caller refuses rather than warning.
1145 """
1146
1147 func = "verify_tokenizer_vocab"
1148 tokenizer_spm = Path(tokenizer_dir) / "spm.model"
1149 digest = file_digest(tokenizer_spm) if tokenizer_spm.is_file() else ""
1150 folder = "models--" + backbone.replace("/", "--")
1151 candidates = sorted((Path(hf_home) / "hub" / folder).glob("snapshots/*/spm.model"))
1152 matched = any(file_digest(candidate) == digest for candidate in candidates) if digest else False
1153 _dbg("OC_CHAT.MAIN", func,
1154 f"{backbone}: {len(candidates)} spm candidate(s), match {matched}", 3)
1155 return digest, matched
1156
1157
1158# --------------------------------------------------------------------------
1159# The torch commands
1160# --------------------------------------------------------------------------
1161
1162
1163@dataclass(frozen=True)
1165 """Everything one training run is pinned by."""
1166
1167 backbone: str
1168 corpus_dir: Path
1169 out_dir: Path
1170 tokenizer_dir: Path = DEFAULT_TOKENIZER_DIR
1171 hf_home: Path = DEFAULT_HF_HOME
1172 seed: int = DEFAULT_SEED
1173 max_epochs: int = DEFAULT_MAX_EPOCHS
1174 batch: int = DEFAULT_BATCH
1175 learning_rate: float = DEFAULT_LEARNING_RATE
1176 warmup_share: float = DEFAULT_WARMUP_SHARE
1177 max_length: int = DEFAULT_MAX_LENGTH
1178 threads: int = DEFAULT_THREADS
1179 budget_seconds: float = DEFAULT_BUDGET_SECONDS
1180 device: str = DEFAULT_DEVICE
1181 prune_vocab: bool = False
1182 vocab_reserve: int = DEFAULT_VOCAB_RESERVE
1183 cache_ids: bool = False
1184 cache_root: Path = DEFAULT_CACHE_ROOT
1185 val_subset: int = 0
1186 patience: int = 1
1187 warmup_steps: int = 0
1188 alpha: float = DEFAULT_ALPHA
1189 cost_weight: float = DEFAULT_COST_WEIGHT
1190 stop_metric: str = DEFAULT_STOP_METRIC
1191
1192
1193def _require_torch() -> tuple[object, object, object]:
1194 """Import torch and transformers, or say which interpreter is needed."""
1195
1196 try:
1197 import torch
1198 from transformers import AutoModelForSequenceClassification, AutoTokenizer
1199 except ImportError as exc: # pragma: no cover - env guard
1200 raise RuntimeError(
1201 "encoder_classifier: train/calibrate/evaluate need torch and transformers; "
1202 "run this script with the scratch harness interpreter (see the module docstring)"
1203 ) from exc
1204 return torch, AutoTokenizer, AutoModelForSequenceClassification
1205
1206
1207def resolve_device(requested: str = DEFAULT_DEVICE) -> str:
1208 """The device a run will actually use, or a refusal saying why it cannot.
1209
1210 ``auto`` is the default and reads the machine: CUDA when torch reports a
1211 usable device, the CPU otherwise. ``cuda`` is an ASSERTION rather than a
1212 preference -- if torch cannot see the card it raises, because the failure
1213 mode this guards against is a run that silently takes the CPU path and is
1214 read as a GPU measurement eighteen minutes later. ``cpu`` always succeeds
1215 and is how the CUDA venv reproduces a plan-290 CPU number.
1216 """
1217
1218 func = "resolve_device"
1219 if requested not in DEVICE_CHOICES:
1220 raise ValueError(f"{func}: device must be one of {DEVICE_CHOICES}, not {requested!r}")
1221 if requested == "cpu":
1222 return "cpu"
1223 torch, _, _ = _require_torch()
1224 available = bool(torch.cuda.is_available()) # type: ignore[attr-defined]
1225 if requested == "cuda" and not available:
1226 raise RuntimeError(
1227 # OCT-LINT: disable=no_hardcoded_secrets reason="error-message prose; the entropy is the dotted torch identifiers, not a credential (operator ruling 2026-09-18)"
1228 "resolve_device: --device cuda was asked for and torch.cuda.is_available() "
1229 "is False; this interpreter is the CPU venv, or the driver does not carry "
1230 "sm_61 (see the module docstring on the CUDA 12.6 deadline)"
1231 )
1232 device = "cuda" if available else "cpu"
1233 _dbg("OC_CHAT.MAIN", func, f"{requested} -> {device}", 2)
1234 return device
1235
1236
1237def peak_device_memory(device: str) -> int:
1238 """Peak bytes torch allocated on *device* since the last reset, or ``0``.
1239
1240 ``max_memory_allocated`` rather than ``nvidia-smi``: the card also carries
1241 the desktop and every browser on this host, so the only figure that means
1242 "what this training run needed" is torch's own allocator high-water mark.
1243 """
1244
1245 if device != "cuda":
1246 return 0
1247 torch, _, _ = _require_torch()
1248 return int(torch.cuda.max_memory_allocated()) # type: ignore[attr-defined]
1249
1250
1251def describe_device(device: str) -> str:
1252 """A one-line description of *device* for a log line and a result file."""
1253
1254 if device != "cuda":
1255 return f"CPU, {DEFAULT_THREADS} threads"
1256 torch, _, _ = _require_torch()
1257 name = torch.cuda.get_device_name(0) # type: ignore[attr-defined]
1258 return f"CUDA, {name}, torch {getattr(torch, '__version__', '?')}"
1259
1260
1261def _to_device(encoded: object, device: str) -> dict[str, object]:
1262 """Every tensor of a tokenizer's output, moved onto *device*."""
1263
1264 return {key: value.to(device) for key, value in encoded.items()} # type: ignore[attr-defined]
1265
1266
1267def remap_table(remap: dict[int, int], unknown_id: int, size: int, device: str) -> object:
1268 """:func:`remap_lookup` as a tensor on *device*, built once per run.
1269
1270 A dict lookup per token would cost more than the forward pass it feeds, so
1271 plan 294 P2's map is materialised as a tensor indexed by the old id.
1272 """
1273
1274 torch, _, _ = _require_torch()
1275 return torch.tensor(remap_lookup(remap, unknown_id, size), # type: ignore[attr-defined]
1276 dtype=torch.long, device=device)
1277
1278
1279def _remap_input_ids(input_ids: object, table: object) -> object:
1280 """Carry a batch of full-vocabulary ids through a :func:`remap_table`."""
1281
1282 return table[input_ids] # type: ignore[index]
1283
1284
1285def cost_tensor(costs: list[list[float]], device: str) -> object:
1286 """A :func:`cost_matrix` as a float tensor on *device*, built once per run."""
1287
1288 torch, _, _ = _require_torch()
1289 return torch.tensor(costs, dtype=torch.float32, device=device) # type: ignore[attr-defined]
1290
1291
1292def expected_cost_term(logits: object, targets: object, table: object) -> object:
1293 """Plan 298 P3's training term on tensors: the batch's mean expected cost.
1294
1295 ``softmax(logits)`` is the model's own forecast, so the term is
1296 differentiable in the logits and pushes probability mass off the verdicts
1297 the matrix prices highly -- not merely off the wrong ones, which is what
1298 cross-entropy alone does. ``table[targets]`` selects each row's true-verdict
1299 cost row, and that gather is the whole of the indexing.
1300
1301 :func:`expected_cost` is the definition this reproduces; the witness in
1302 ``tests/test_298_direction.py`` asserts the two agree on a toy batch.
1303 """
1304
1305 torch, _, _ = _require_torch()
1306 probs = torch.softmax(logits.float(), dim=-1) # type: ignore[attr-defined]
1307 return (probs * table[targets]).sum(dim=-1).mean() # type: ignore[index]
1308
1309
1310def slice_embeddings(model: object, kept: list[int], pad_row: int) -> int:
1311 """Replace the model's word embeddings with the *kept* rows only.
1312
1313 Returns how many rows the table held before. The classification head is a
1314 pooler and a linear layer, so nothing is tied to this table and slicing it
1315 is the whole of the change; ``config.vocab_size`` moves with it so that a
1316 ``save_pretrained`` round trip rebuilds the same shape.
1317 """
1318
1319 func = "slice_embeddings"
1320 torch, _, _ = _require_torch()
1321 embeddings = model.get_input_embeddings() # type: ignore[attr-defined]
1322 weight = embeddings.weight.data
1323 before = int(weight.shape[0])
1324 index = torch.tensor(kept, dtype=torch.long, device=weight.device) # type: ignore[attr-defined]
1325 sliced = torch.nn.Embedding(len(kept), int(weight.shape[1]), padding_idx=pad_row) # type: ignore[attr-defined]
1326 sliced.weight.data = weight.index_select(0, index).clone()
1327 model.set_input_embeddings(sliced) # type: ignore[attr-defined]
1328 model.config.vocab_size = len(kept) # type: ignore[attr-defined]
1329 _dbg("OC_CHAT.MAIN", func, f"{before} rows sliced to {len(kept)}", 2)
1330 return before
1331
1332
1333def _collate(rows: list[list[int]], pad_id: int, extra_keys: tuple[str, ...],
1334 device: str) -> dict[str, object]:
1335 """Pad cached ids into the tensors the model's forward pass expects.
1336
1337 *extra_keys* mirrors whatever the tokenizer itself emits beside the ids and
1338 the mask -- ``token_type_ids`` for this tokenizer -- as zeros, so a cached
1339 batch and a freshly tokenised one are the same call.
1340 """
1341
1342 torch, _, _ = _require_torch()
1343 width = max(len(row) for row in rows)
1344 input_ids = torch.full((len(rows), width), pad_id, dtype=torch.long) # type: ignore[attr-defined]
1345 mask = torch.zeros((len(rows), width), dtype=torch.long) # type: ignore[attr-defined]
1346 for position, row in enumerate(rows):
1347 input_ids[position, :len(row)] = torch.tensor(row, dtype=torch.long) # type: ignore[attr-defined]
1348 mask[position, :len(row)] = 1
1349 encoded = {"input_ids": input_ids, "attention_mask": mask}
1350 for key in extra_keys:
1351 encoded[key] = torch.zeros((len(rows), width), dtype=torch.long) # type: ignore[attr-defined]
1352 return _to_device(encoded, device)
1353
1354
1355def _saved_remap_table(model_dir: Path, device: str) -> object | None:
1356 """The lookup table for a model trained with a pruned vocabulary, or ``None``.
1357
1358 ``train`` writes :data:`VOCAB_PRUNE_FILE` beside the weights when and only
1359 when it pruned, so its presence is what tells ``calibrate`` and ``evaluate``
1360 that the ids a tokenizer hands them are not the ids this model's embedding
1361 table is indexed by. A model trained without pruning has no such file and
1362 every id goes through untouched.
1363 """
1364
1365 func = "_saved_remap_table"
1366 path = Path(model_dir) / VOCAB_PRUNE_FILE
1367 if not path.is_file():
1368 return None
1369 saved = json.loads(path.read_text(encoding="utf-8"))
1370 remap = {int(old): int(new) for old, new in saved["remap"].items()}
1371 _dbg("OC_CHAT.MAIN", func,
1372 f"{len(remap)} kept of {saved['full_vocab']} vocabulary rows", 3)
1373 return remap_table(remap, int(saved["unknown_id"]), int(saved["full_vocab"]), device)
1374
1375
1376def _load_tokenizer(tokenizer_dir: Path) -> object:
1377 """Load the converted DeBERTa-v3 fast tokenizer from a local directory."""
1378
1379 _, auto_tokenizer, _ = _require_torch()
1380 return auto_tokenizer.from_pretrained(str(tokenizer_dir)) # type: ignore[attr-defined]
1381
1382
1384 model: object,
1385 tokenizer: object,
1386 commands: list[str],
1387 max_length: int,
1388 batch: int,
1389 device: str = "cpu",
1390 table: object | None = None,
1391) -> list[list[float]]:
1392 """Logits for every command, in batches, in evaluation mode.
1393
1394 *table* is plan 294 P2's pruned-vocabulary lookup: when the model's
1395 embedding table has been sliced, the ids the tokenizer produces are the FULL
1396 vocabulary's and have to be carried through the map before they index the
1397 sliced table, or every id past the kept count is out of range.
1398 """
1399
1400 torch, _, _ = _require_torch()
1401 rows: list[list[float]] = []
1402 with torch.no_grad(): # type: ignore[attr-defined]
1403 for start in range(0, len(commands), batch):
1404 chunk = commands[start:start + batch]
1405 encoded = tokenizer(chunk, padding=True, truncation=True, # type: ignore[operator]
1406 max_length=max_length, return_tensors="pt")
1407 encoded = _to_device(encoded, device)
1408 if table is not None:
1409 encoded["input_ids"] = _remap_input_ids(encoded["input_ids"], table)
1410 output = model(**encoded) # type: ignore[operator]
1411 rows.extend(output.logits.float().tolist())
1412 return rows
1413
1414
1416 model_dir: Path, rows: list[dict[str, str]], tokenizer: object,
1417 max_length: int, batch: int, device: str, table: object | None,
1418) -> float:
1419 """The WHOLE validation split's cross-entropy at the saved best checkpoint.
1420
1421 Plan 294 P3 validates each epoch on a 1,000-row subset, which is a
1422 different number from plan 290's 3,000-row one and cannot be compared with
1423 it. The best checkpoint is therefore reloaded once at the end and scored on
1424 the full split, so the run still produces the figure the previous plan's
1425 log can be read against.
1426 """
1427
1428 func = "score_full_validation"
1429 _, _, auto_model = _require_torch()
1430 model = auto_model.from_pretrained(str(model_dir)) # type: ignore[attr-defined]
1431 model.to(device)
1432 model.eval()
1433 logits = _forward_logits(model, tokenizer, [row["command"] for row in rows],
1434 max_length, batch, device, table)
1435 indexes = [LABELS.index(row["truth"]) for row in rows]
1436 loss = mean_negative_log_likelihood(logits, indexes, 1.0)
1437 _dbg("OC_CHAT.MAIN", func, f"full validation loss {loss:.4f} over {len(rows)} rows", 3)
1438 return loss
1439
1440
1441def train(config: TrainConfig) -> dict[str, object]:
1442 """Fine-tune one backbone on the corpus, early-stopped on a watched metric.
1443
1444 Writes the best epoch's weights, the tokenizer and a training record into
1445 ``config.out_dir``, and returns the record. The stopping rule is plan 290
1446 P2's -- validation cross-entropy stops improving -- plus the budget guard
1447 P2's one-hour wall-time limit implies: when another epoch would not fit,
1448 the run stops at the end of the current one and the record says so, which
1449 is the opposite of quietly overrunning the budget.
1450
1451 Plan 298 P3 adds ``config.cost_weight`` times :func:`expected_cost_term` to
1452 the per-batch loss. Two deliberate limits on it. It is off at ``0.0``, so an
1453 unflagged run is still plan 295's run. And the STOPPING rule was left on
1454 plain validation cross-entropy even when the term was on: the recipe was
1455 being held constant so that the objective was the only variable, and moving
1456 the early-stopping criterion at the same time would have changed two things
1457 and measured their sum.
1458
1459 Plan 300 P1 supplies the second thing, as its own flag.
1460 ``config.stop_metric`` chooses which validation figure early stopping and
1461 checkpoint selection watch: ``"ce"`` is the rule above, unchanged and still
1462 the default, and ``"cost"`` watches the subset validation expected cost
1463 under the same ``config.alpha`` matrix -- the column a direction-weighted
1464 objective is trying to move, and the one plan 298 measured a run being
1465 checkpointed against. Both figures are computed every epoch whichever one
1466 stops the run, the epoch log carries both, and the record says which epoch
1467 the other metric would have kept. Nothing else moves: the full-split
1468 cross-entropy is still scored on the reloaded best checkpoint, and the
1469 temperature is still fitted by NLL afterwards, so the probabilities a
1470 reading consumes are calibrated the way every earlier tag's were.
1471 """
1472
1473 func = "train"
1474 torch, _, auto_model = _require_torch()
1475 torch.set_num_threads(config.threads) # type: ignore[attr-defined]
1476 device = resolve_device(config.device)
1477 digest, matched = verify_tokenizer_vocab(
1478 config.tokenizer_dir, config.backbone, config.hf_home
1479 )
1480 if not matched:
1481 raise RuntimeError(
1482 f"train: tokenizer at {config.tokenizer_dir} does not carry the "
1483 f"vocabulary of {config.backbone} (spm.model digest {digest[:16]})"
1484 )
1485 held_out = held_out_commands(config.corpus_dir)
1486 training = load_split(config.corpus_dir, "corpus.jsonl", held_out)
1487 validation = load_split(config.corpus_dir, "corpus_val.jsonl", held_out)
1488 print(f"train: {config.backbone} on {len(training)} rows, "
1489 f"{len(validation)} validation, batch {config.batch}, "
1490 f"lr {config.learning_rate}, up to {config.max_epochs} epochs, "
1491 f"{config.threads} threads", flush=True)
1492 print(f"train: device {device} ({ascii(describe_device(device))})", flush=True)
1493 print(f"train: label counts {label_counts(training)}", flush=True)
1494
1495 random.seed(config.seed)
1496 torch.manual_seed(config.seed) # type: ignore[attr-defined]
1497 if device == "cuda":
1498 torch.cuda.manual_seed_all(config.seed) # type: ignore[attr-defined]
1499 tokenizer = _load_tokenizer(config.tokenizer_dir)
1500 model = auto_model.from_pretrained( # type: ignore[attr-defined]
1501 config.backbone, num_labels=len(LABELS),
1502 id2label={index: label for index, label in enumerate(LABELS)},
1503 label2id={label: index for index, label in enumerate(LABELS)},
1504 )
1505 Path(config.out_dir).mkdir(parents=True, exist_ok=True)
1506 commands = [row["command"] for row in training] + [row["command"] for row in validation]
1507 max_length = resolve_max_length(commands, tokenizer, config.max_length)
1508 if max_length != config.max_length:
1509 print(f"train: sequence length {config.max_length} would truncate; "
1510 f"using {max_length}", flush=True)
1511
1512 full_vocab = int(model.get_input_embeddings().weight.shape[0])
1513 kept: list[int] = []
1514 table: object | None = None
1515 if config.prune_vocab:
1516 kept, remap = prune_vocab(commands, tokenizer, config.vocab_reserve, max_length)
1517 unknown_row = remap[int(tokenizer.unk_token_id)] # type: ignore[attr-defined]
1518 pad_row = remap[int(tokenizer.pad_token_id)] # type: ignore[attr-defined]
1519 before = slice_embeddings(model, kept, pad_row)
1520 model.config.pad_token_id = pad_row # type: ignore[attr-defined]
1521 table = remap_table(remap, unknown_row, full_vocab, device)
1522 Path(config.out_dir).joinpath(VOCAB_PRUNE_FILE).write_text(
1523 json.dumps({"kept": kept, "remap": {str(old): new for old, new in remap.items()},
1524 "unknown_id": unknown_row, "pad_id": pad_row,
1525 "full_vocab": full_vocab, "reserve": config.vocab_reserve},
1526 sort_keys=True),
1527 encoding="utf-8",
1528 )
1529 print(f"train: vocabulary pruned {before} -> {len(kept)} rows "
1530 f"(reserve {config.vocab_reserve})", flush=True)
1531
1532 model.to(device)
1533 if device == "cuda":
1534 torch.cuda.reset_peak_memory_stats() # type: ignore[attr-defined]
1535 parameters = sum(parameter.numel() for parameter in model.parameters())
1536 optimiser = torch.optim.AdamW(model.parameters(), lr=config.learning_rate) # type: ignore[attr-defined]
1537 steps_per_epoch = math.ceil(len(training) / config.batch)
1538 warmup_steps = config.warmup_steps or max(
1539 1, int(config.warmup_share * steps_per_epoch * config.max_epochs)
1540 )
1541 print(f"train: {parameters / 1e6:.1f}M parameters, {steps_per_epoch} steps per epoch, "
1542 f"{warmup_steps} warm-up steps", flush=True)
1543 _dbg("OC_CHAT.MAIN", func, f"{config.backbone}: {parameters} parameters", 2)
1544
1545 costs = cost_matrix(config.alpha)
1546 cost_weights = cost_tensor(costs, device) if config.cost_weight else None
1547 if cost_weights is None:
1548 print("train: objective cross-entropy (plan 295's; --cost-weight is 0)", flush=True)
1549 else:
1550 print(f"train: objective cross-entropy + {config.cost_weight} * expected cost "
1551 f"at alpha {config.alpha}; granting more permission costs "
1552 f"{config.alpha}x granting less", flush=True)
1553 if config.stop_metric not in STOP_METRIC_CHOICES:
1554 raise ValueError(
1555 f"{func}: stop metric must be one of {STOP_METRIC_CHOICES}, "
1556 f"not {config.stop_metric!r}"
1557 )
1558 if config.stop_metric == "ce":
1559 print("train: stopping on validation cross-entropy (plan 290's rule; "
1560 "--stop-metric is ce)", flush=True)
1561 else:
1562 print(f"train: stopping on validation expected cost at alpha {config.alpha} "
1563 f"(plan 300 P1); both figures are logged every epoch", flush=True)
1564
1565 pad_id = int(tokenizer.pad_token_id) # type: ignore[attr-defined]
1566 extra_keys = tuple(key for key in tokenise_keys(tokenizer)
1567 if key not in ("input_ids", "attention_mask"))
1568 cached_ids: dict[str, list[int]] | None = None
1569 if config.cache_ids:
1570 key = cache_key(Path(config.corpus_dir) / "corpus.jsonl", digest, max_length)
1571 rows_ids = load_cached_ids(config.cache_root, key)
1572 origin = "cache"
1573 if rows_ids is None:
1574 origin = "tokenizer"
1575 rows_ids = tokenise([row["command"] for row in training], tokenizer, max_length)
1576 store_cached_ids(config.cache_root, key, rows_ids)
1577 cached_ids = {row["command"]: ids for row, ids in zip(training, rows_ids)}
1578 print(f"train: {len(rows_ids)} training rows tokenised once from the {origin} "
1579 f"(key {key[:12]})", flush=True)
1580
1581 subset = validation_subset(validation, config.val_subset)
1582 if len(subset) != len(validation):
1583 print(f"train: validating each epoch on {len(subset)} of {len(validation)} rows "
1584 f"(seed {DEFAULT_SUBSET_SEED}); the full split is scored at the end",
1585 flush=True)
1586
1587 log_lines: list[str] = []
1588 epoch_records: list[dict[str, float | int]] = []
1589 rng = random.Random(config.seed)
1590 started = time.perf_counter()
1591 step = 0
1592 best_value = float("inf")
1593 best_loss = float("inf")
1594 best_cost = float("inf")
1595 best_epoch = 0
1596 stale = 0
1597 full_validation_loss = float("nan")
1598 stop_reason = "max-epochs"
1599
1600 def write_record(reason: str) -> dict[str, object]:
1601 """Write the training record and log as they stand, and return it.
1602
1603 Called at every improving checkpoint as well as at the end, because a
1604 run on this host can die mid-epoch: the first plan-290 run lost a
1605 54-minute record to a CPU allocation failure in epoch 3 while the
1606 epoch-2 weights sat complete on disk beside it. A record written with
1607 the weights it describes is the difference between a usable checkpoint
1608 and an unidentifiable one.
1609 """
1610
1611 record: dict[str, object] = {
1612 "backbone": config.backbone,
1613 "seed": config.seed,
1614 "epochs": best_epoch,
1615 "epochs_run": len(epoch_records),
1616 "max_epochs": config.max_epochs,
1617 "stop_reason": reason,
1618 "train_seconds": time.perf_counter() - started,
1619 "train_rows": len(training),
1620 "validation_rows": len(validation),
1621 "batch": config.batch,
1622 "learning_rate": config.learning_rate,
1623 "warmup_steps": warmup_steps,
1624 "max_length": max_length,
1625 "threads": config.threads,
1626 "device": device,
1627 "device_name": describe_device(device),
1628 "peak_device_bytes": peak_device_memory(device),
1629 "patience": config.patience,
1630 "alpha": config.alpha,
1631 "cost_weight": config.cost_weight,
1632 "stop_metric": config.stop_metric,
1633 "alternate_stop_metric": other_stop_metric(config.stop_metric),
1634 "alternate_best_epoch": select_best_epoch(
1635 [(float(entry["validation_loss"]), float(entry["validation_cost"]))
1636 for entry in epoch_records],
1637 other_stop_metric(config.stop_metric), config.patience,
1638 )["best_epoch"],
1639 "vocab_full": full_vocab,
1640 "vocab_kept": len(kept) if kept else full_vocab,
1641 "cached_ids": bool(config.cache_ids),
1642 "validation_subset": len(subset),
1643 "full_validation_loss": full_validation_loss,
1644 "parameters": parameters,
1645 "budget_seconds": config.budget_seconds,
1646 "corpus_dir": str(config.corpus_dir),
1647 "tokenizer_dir": str(config.tokenizer_dir),
1648 "tokenizer_spm_sha256": digest,
1649 "epoch_log": epoch_records,
1650 "best_validation_loss": best_loss,
1651 "best_validation_cost": best_cost,
1652 "weights_sha256": weights_digest(config.out_dir),
1653 "torch": getattr(torch, "__version__", "?"),
1654 }
1655 Path(config.out_dir).joinpath("training.json").write_text(
1656 json.dumps(record, indent=1, sort_keys=True) + "\n", encoding="utf-8"
1657 )
1658 header = (f"{config.backbone} seed {config.seed} batch {config.batch} "
1659 f"lr {config.learning_rate} threads {config.threads} "
1660 f"device {device} {parameters} parameters")
1661 Path(config.out_dir).joinpath("train.log").write_text(
1662 header + "\n" + "\n".join(log_lines)
1663 + f"\nstopped after epoch {len(epoch_records)} ({reason}); "
1664 f"best epoch {best_epoch}; "
1665 f"{record['train_seconds']:.0f}s total\n", # type: ignore[str-format]
1666 encoding="utf-8",
1667 )
1668 return record
1669
1670 try:
1671 for epoch in range(1, config.max_epochs + 1):
1672 model.train()
1673 epoch_started = time.perf_counter()
1674 running = 0.0
1675 for position, batch_rows in enumerate(epoch_batches(training, config.batch, rng), 1):
1676 if cached_ids is None:
1677 encoded = tokenizer([row["command"] for row in batch_rows], # type: ignore[operator]
1678 padding=True, truncation=True,
1679 max_length=max_length, return_tensors="pt")
1680 encoded = _to_device(encoded, device)
1681 else:
1682 encoded = _collate([cached_ids[row["command"]] for row in batch_rows],
1683 pad_id, extra_keys, device)
1684 if table is not None:
1685 encoded["input_ids"] = _remap_input_ids(encoded["input_ids"], table)
1686 targets = torch.tensor( # type: ignore[attr-defined]
1687 [LABELS.index(row["truth"]) for row in batch_rows], dtype=torch.long, # type: ignore[attr-defined]
1688 device=device,
1689 )
1690 step += 1
1691 for group in optimiser.param_groups:
1692 group["lr"] = config.learning_rate * min(1.0, step / warmup_steps)
1693 output = model(**encoded, labels=targets)
1694 loss = output.loss
1695 if cost_weights is not None:
1696 loss = loss + config.cost_weight * expected_cost_term( # type: ignore[operator]
1697 output.logits, targets, cost_weights
1698 )
1699 loss.backward()
1700 optimiser.step()
1701 optimiser.zero_grad(set_to_none=True)
1702 running += float(loss.item())
1703 if position % 50 == 0:
1704 elapsed = time.perf_counter() - epoch_started
1705 print(f" epoch {epoch} step {position}/{steps_per_epoch} "
1706 f"loss {running / position:.4f} "
1707 f"{elapsed:.0f}s elapsed, {elapsed / position * steps_per_epoch:.0f}s projected",
1708 flush=True)
1709 train_loss = running / steps_per_epoch
1710 model.eval()
1711 logits = _forward_logits(model, tokenizer, [row["command"] for row in subset],
1712 max_length, config.batch, device, table)
1713 indexes = [LABELS.index(row["truth"]) for row in subset]
1714 validation_loss = mean_negative_log_likelihood(logits, indexes, 1.0)
1715 forecasts = [probabilities(row) for row in logits]
1716 hits = sum(max(probs, key=lambda label: probs[label])
1717 == subset[position]["truth"] for position, probs in enumerate(forecasts))
1718 validation_cost = expected_cost(
1719 [[probs[label] for label in LABELS] for probs in forecasts], indexes, costs
1720 )
1721 epoch_seconds = time.perf_counter() - epoch_started
1722 line = (f"epoch {epoch}: train loss {train_loss:.4f} "
1723 f"validation loss {validation_loss:.4f} "
1724 f"validation cost {validation_cost:.4f} "
1725 f"validation accuracy {hits / len(subset):.4f} "
1726 f"{epoch_seconds:.0f}s")
1727 print(f"train: {line}", flush=True)
1728 log_lines.append(line)
1729 epoch_records.append({
1730 "epoch": epoch, "train_loss": train_loss, "validation_loss": validation_loss,
1731 "validation_cost": validation_cost,
1732 "validation_accuracy": hits / len(subset), "seconds": epoch_seconds,
1733 })
1734 _dbg("OC_CHAT.MAIN", func, line, 3)
1735
1736 watched = stopping_value(epoch_records[-1], config.stop_metric)
1737 improved = watched < best_value - EARLY_STOP_MIN_DELTA
1738 if improved:
1739 stale = 0
1740 best_value, best_epoch = watched, epoch
1741 best_loss, best_cost = validation_loss, validation_cost
1742 model.save_pretrained(str(config.out_dir))
1743 tokenizer.save_pretrained(str(config.out_dir)) # type: ignore[operator]
1744 log_lines.append(f"epoch {epoch}: checkpoint saved (best so far)")
1745 write_record("in-progress")
1746 else:
1747 stale += 1
1748 log_lines.append(f"epoch {epoch}: no improvement in {config.stop_metric} "
1749 f"({stale} of {config.patience} allowed)")
1750 if stale >= config.patience:
1751 stop_reason = "early-stop"
1752 log_lines.append(f"epoch {epoch}: stopping; best was epoch {best_epoch} "
1753 f"by {config.stop_metric}")
1754 break
1755 elapsed = time.perf_counter() - started
1756 if epoch < config.max_epochs and elapsed + epoch_seconds > config.budget_seconds:
1757 stop_reason = "budget"
1758 log_lines.append(
1759 f"epoch {epoch}: {elapsed:.0f}s elapsed and another epoch needs "
1760 f"about {epoch_seconds:.0f}s, which would pass the "
1761 f"{config.budget_seconds:.0f}s budget; stopping here"
1762 )
1763 break
1764
1765 except RuntimeError as exc:
1766 allocator = any(mark in str(exc) for mark in
1767 ("not enough memory", "DefaultCPUAllocator", "CUDA out of memory"))
1768 stop_reason = "memory" if allocator else "error"
1769 line = (f"epoch {len(epoch_records) + 1}: aborted after "
1770 f"{time.perf_counter() - started:.0f}s: {exc}")
1771 print(f"train: {line}", flush=True)
1772 log_lines.append(line)
1773 _dbg("OC_CHAT.MAIN", func, line, 2)
1774 if best_epoch == 0:
1775 write_record(stop_reason)
1776 raise
1777
1778 if best_epoch and len(subset) != len(validation):
1779 full_validation_loss = score_full_validation(
1780 config.out_dir, validation, tokenizer, max_length, config.batch, device, table
1781 )
1782 line = (f"full validation loss over {len(validation)} rows at the best "
1783 f"checkpoint: {full_validation_loss:.4f}")
1784 print(f"train: {line}", flush=True)
1785 log_lines.append(line)
1786
1787 record = write_record(stop_reason)
1788 train_seconds = float(record["train_seconds"]) # type: ignore[arg-type]
1789 print(f"train: best epoch {best_epoch} by {config.stop_metric} ({stop_reason}) "
1790 f"in {train_seconds:.0f}s -> {config.out_dir}", flush=True)
1791 print(f"train: validation cross-entropy {best_loss:.4f} and expected cost "
1792 f"{best_cost:.4f} at that epoch; watching "
1793 f"{record['alternate_stop_metric']} would have kept epoch "
1794 f"{record['alternate_best_epoch']}", flush=True)
1795 _dbg("OC_CHAT.MAIN", func, f"trained in {train_seconds:.0f}s, best epoch {best_epoch}", 2)
1796 return record
1797
1798
1800 model_dir: Path, corpus_dir: Path, batch: int = DEFAULT_BATCH,
1801 max_length: int = DEFAULT_MAX_LENGTH, threads: int = DEFAULT_THREADS,
1802 device: str = DEFAULT_DEVICE,
1803) -> dict[str, float | int]:
1804 """Fit one temperature on the validation split and record what it moved.
1805
1806 Plan 294 P3 validates on a 1,000-row subset DURING training, for speed; the
1807 temperature is still fitted on the whole 3,000-row split here, because a
1808 calibration scalar fitted on a third of the rows is a third as well
1809 determined and costs one forward pass to get right.
1810 """
1811
1812 func = "calibrate"
1813 torch, _, auto_model = _require_torch()
1814 torch.set_num_threads(threads) # type: ignore[attr-defined]
1815 device = resolve_device(device)
1816 held_out = held_out_commands(corpus_dir)
1817 validation = load_split(corpus_dir, "corpus_val.jsonl", held_out)
1818 tokenizer = _load_tokenizer(model_dir)
1819 model = auto_model.from_pretrained(str(model_dir)) # type: ignore[attr-defined]
1820 model.to(device)
1821 model.eval()
1822 table = _saved_remap_table(model_dir, device)
1823 started = time.perf_counter()
1824 logits = _forward_logits(model, tokenizer, [row["command"] for row in validation],
1825 max_length, batch, device, table)
1826 labels = [LABELS.index(row["truth"]) for row in validation]
1827 temperature = fit_temperature(logits, labels)
1828 report = calibration_report(logits, validation, temperature)
1829 report["forward_seconds"] = time.perf_counter() - started
1830 Path(model_dir).joinpath("calibration.json").write_text(
1831 json.dumps(report, indent=1, sort_keys=True) + "\n", encoding="utf-8"
1832 )
1833 Path(model_dir).joinpath("val_logits.json").write_text(
1834 json.dumps({"commands": [row["command"] for row in validation],
1835 "truth": [row["truth"] for row in validation],
1836 "logits": [[round(value, 6) for value in row] for row in logits]}),
1837 encoding="utf-8",
1838 )
1839 print(f"calibrate: temperature {temperature:.4f}; "
1840 f"validation NLL {report['nll_before']:.4f} -> {report['nll_after']:.4f}; "
1841 f"Brier {report['brier_before']:.4f} -> {report['brier_after']:.4f}; "
1842 f"accuracy {report['validation_accuracy']:.4f}")
1843 _dbg("OC_CHAT.MAIN", func, f"temperature {temperature:.4f}", 2)
1844 return report
1845
1846
1848 model_dir: Path, cases_path: Path, condition: str, out_path: Path,
1849 model_name: str, batch: int = 1, max_length: int = DEFAULT_MAX_LENGTH,
1850 threads: int = DEFAULT_THREADS, device: str = DEFAULT_DEVICE,
1851 decision: str = DEFAULT_DECISION, alpha: float = DEFAULT_ALPHA,
1852) -> dict[str, object]:
1853 """Predict a case set and write its result file, timing every decision.
1854
1855 At ``batch=1`` the clock runs around tokenise-plus-forward for one command,
1856 which is the per-decision latency a gate would pay. At ``batch>1`` the
1857 clock runs around the whole batch and the per-row figure is the amortised
1858 one, which is the throughput reading rather than the latency reading.
1859
1860 *decision* and *alpha* are plan 298 P2's rule. They are a property of the
1861 READING and not of the model, so both go into the provenance block: two
1862 result files from one checkpoint under two rules are otherwise
1863 indistinguishable except by their verdicts, and the scorer reports no field
1864 that would say which rule produced which.
1865
1866 Plan 300 P2 adds :func:`confident_error_counts` to that same block. It goes
1867 in the PROVENANCE and not into the rows on purpose: ``score.py`` reads the
1868 row shape and a new per-row field would change what every consumer of a
1869 result file sees, whereas a provenance key is additive and the scorer never
1870 looks at it. The counts are recomputable from the rows by anyone who wants
1871 them at another threshold, which is why the file carries the summary rather
1872 than a flag per row.
1873 """
1874
1875 func = "evaluate"
1876 torch, _, auto_model = _require_torch()
1877 torch.set_num_threads(threads) # type: ignore[attr-defined]
1878 device = resolve_device(device)
1879 training_record = json.loads(
1880 Path(model_dir).joinpath("training.json").read_text(encoding="utf-8")
1881 )
1882 calibration = json.loads(
1883 Path(model_dir).joinpath("calibration.json").read_text(encoding="utf-8")
1884 )
1885 temperature = float(calibration["temperature"])
1886 cases = json.loads(Path(cases_path).read_text(encoding="utf-8"))
1887 tokenizer = _load_tokenizer(model_dir)
1888 model = auto_model.from_pretrained(str(model_dir)) # type: ignore[attr-defined]
1889 model.to(device)
1890 model.eval()
1891 table = _saved_remap_table(model_dir, device)
1892
1893 costs = cost_matrix(alpha)
1894 rows: list[dict[str, object]] = []
1895 with torch.no_grad(): # type: ignore[attr-defined]
1896 for start in range(0, len(cases), batch):
1897 chunk = cases[start:start + batch]
1898 clock = time.perf_counter()
1899 encoded = tokenizer([case["command"] for case in chunk], # type: ignore[operator]
1900 padding=True, truncation=True,
1901 max_length=max_length, return_tensors="pt")
1902 encoded = _to_device(encoded, device)
1903 if table is not None:
1904 encoded["input_ids"] = _remap_input_ids(encoded["input_ids"], table)
1905 logits = model(**encoded).logits.float() # type: ignore[operator]
1906 if device == "cuda":
1907 torch.cuda.synchronize() # type: ignore[attr-defined]
1908 elapsed_ms = (time.perf_counter() - clock) * 1000.0 / len(chunk)
1909 for case, row in zip(chunk, logits.tolist()):
1910 rows.append(result_row(case, row, temperature, elapsed_ms,
1911 decision, costs))
1912 hits = sum(row["pred"] == row["truth"] for row in rows)
1913 confident = confident_error_counts(rows)
1914 provenance = {
1915 "date": time.strftime("%Y-%m-%d"),
1916 "route": (f"{describe_device(device)}, fine-tuned encoder, batch {batch}"
1917 if device == "cuda"
1918 else f"CPU, {threads} threads, fine-tuned encoder, batch {batch}"),
1919 "case_file": Path(cases_path).name,
1920 "model_dir": str(model_dir),
1921 "batch": batch,
1922 "max_length": max_length,
1923 "threads": threads,
1924 "device": device,
1925 "temperature": temperature,
1926 "decision": decision,
1927 "alpha": alpha,
1928 "confident_errors": confident,
1929 "validation_brier_before": calibration["brier_before"],
1930 "validation_brier_after": calibration["brier_after"],
1931 "validation_accuracy": calibration["validation_accuracy"],
1932 }
1933 for key in ("backbone", "seed", "epochs", "epochs_run", "stop_reason", "train_seconds",
1934 "train_rows", "learning_rate", "parameters", "weights_sha256",
1935 "tokenizer_spm_sha256", "corpus_dir", "torch"):
1936 provenance[key] = training_record[key]
1937 for key in ("device", "vocab_kept", "vocab_full", "cached_ids", "validation_subset",
1938 "alpha", "cost_weight", "stop_metric", "alternate_stop_metric",
1939 "alternate_best_epoch", "best_validation_loss", "best_validation_cost"):
1940 if key in training_record:
1941 provenance[f"train_{key}"] = training_record[key]
1942 write_result_file(out_path, model_name, condition, rows, provenance)
1943 print(f"evaluate: {condition:5} {hits}/{len(rows)} agreement "
1944 f"({decision}, alpha {alpha}) -> {Path(out_path).name}")
1945 print("evaluate: {} permission-granting error(s), {}".format(
1946 confident["granted_more"],
1947 ", ".join(f"{confident[f'granted_more_at_{threshold:g}']} at {threshold:g} or better"
1948 for threshold in CONFIDENT_THRESHOLDS)))
1949 _dbg("OC_CHAT.MAIN", func, f"{condition}: {hits}/{len(rows)} agreement", 3)
1950 return {"agreement": hits / len(rows), "rows": len(rows)}
1951
1952
1953def relabel(bench_dir: Path, out_dir: Path) -> dict[str, object]:
1954 """Write the policy-change probe's relabelled copy of the corpus.
1955
1956 The copy goes under *out_dir* -- never over a committed file -- and is a
1957 self-consistent bench corpus under the changed policy: both corpus splits
1958 and both case sets, each under its canonical name, so ``train
1959 --corpus-dir <out_dir>`` finds the held-out guard's case files and
1960 ``evaluate --cases <out_dir>/cases.json`` measures against the labels the
1961 CHANGED policy implies rather than the committed ones. A report beside
1962 them records the filter, the counts and the verdict movement.
1963 """
1964
1965 func = "relabel"
1966 classify = gate_classifier(Path(bench_dir).parent.parent)
1967 Path(out_dir).mkdir(parents=True, exist_ok=True)
1968 held_out = held_out_commands(bench_dir)
1969 report: dict[str, object] = {
1970 "filter": ("listing command (ls/dir, basename lowercased, .exe stripped) "
1971 "carrying an unquoted short-option cluster containing R; the new "
1972 "label is the unmodified gate's verdict for the same command with "
1973 "the R removed"),
1974 "bench_dir": str(bench_dir),
1975 "splits": {},
1976 }
1977 for name in ("corpus.jsonl", "corpus_val.jsonl"):
1978 rows = load_split(bench_dir, name, held_out)
1979 matched = sum(1 for row in rows if strip_recursive_option(row["command"]) is not None)
1980 relabelled, changed = relabel_recursive_allow(rows, classify)
1981 Path(out_dir).joinpath(name).write_text(
1982 "".join(json.dumps(row, sort_keys=True) + "\n" for row in relabelled),
1983 encoding="utf-8",
1984 )
1985 report["splits"][name] = { # type: ignore[index]
1986 "rows": len(rows), "matched": matched, "verdicts_moved": changed,
1987 "labels_before": label_counts(rows), "labels_after": label_counts(relabelled),
1988 }
1989 print(f"relabel: {name}: {matched} matched, {changed} verdicts moved, "
1990 f"labels {label_counts(relabelled)}", flush=True)
1991 report["cases"] = {}
1992 for name in ("cases.json", "cases_hard.json"):
1993 cases = json.loads(Path(bench_dir).joinpath(name).read_text(encoding="utf-8"))
1994 relabelled_cases, changed_cases = relabel_recursive_allow(cases, classify)
1995 target = Path(out_dir) / name
1996 target.write_text(json.dumps(relabelled_cases, indent=1) + "\n", encoding="utf-8")
1997 moved = [case["command"] for before, case in zip(cases, relabelled_cases)
1998 if before["truth"] != case["truth"]]
1999 report["cases"][name] = { # type: ignore[index]
2000 "rows": len(cases),
2001 "matched": sum(1 for case in cases
2002 if strip_recursive_option(case["command"]) is not None),
2003 "verdicts_moved": changed_cases,
2004 "moved_commands": moved,
2005 }
2006 print(f"relabel: {name}: {len(moved)} of {len(cases)} verdicts moved "
2007 f"-> {target.name}", flush=True)
2008 Path(out_dir).joinpath("relabel_report.json").write_text(
2009 json.dumps(report, indent=1, sort_keys=True) + "\n", encoding="utf-8"
2010 )
2011 _dbg("OC_CHAT.MAIN", func, f"relabelled corpus written to {out_dir}", 2)
2012 return report
2013
2014
2015# --------------------------------------------------------------------------
2016# The CLI
2017# --------------------------------------------------------------------------
2018
2019
2020def _bench_dir() -> Path:
2021 """``bench/argv_policy`` -- the directory holding the corpus and the cases."""
2022
2023 return Path(__file__).resolve().parent.parent
2024
2025
2026def _build_parser() -> argparse.ArgumentParser:
2027 """Construct the CLI argument parser."""
2028
2029 parser = argparse.ArgumentParser(
2030 prog="python bench/argv_policy/baselines/encoder_classifier.py",
2031 description="Fine-tune, calibrate and score a small encoder on the argv-policy bench.",
2032 )
2033 subparsers = parser.add_subparsers(dest="command", required=True)
2034
2035 trainer = subparsers.add_parser("train", help="Fine-tune one backbone.")
2036 trainer.add_argument("--backbone", required=True, help="Hugging Face model id.")
2037 trainer.add_argument("--out", required=True, help="Model directory to write.")
2038 trainer.add_argument("--corpus-dir", default=None,
2039 help="Where corpus.jsonl lives (default: the bench directory).")
2040 trainer.add_argument("--tokenizer-dir", default=str(DEFAULT_TOKENIZER_DIR),
2041 help="Directory holding the converted fast tokenizer.")
2042 trainer.add_argument("--seed", type=int, default=DEFAULT_SEED)
2043 trainer.add_argument("--epochs", type=int, default=DEFAULT_MAX_EPOCHS,
2044 help=f"Maximum epochs (default: {DEFAULT_MAX_EPOCHS}).")
2045 trainer.add_argument("--batch", type=int, default=DEFAULT_BATCH)
2046 trainer.add_argument("--learning-rate", type=float, default=DEFAULT_LEARNING_RATE)
2047 trainer.add_argument("--max-length", type=int, default=DEFAULT_MAX_LENGTH)
2048 trainer.add_argument("--threads", type=int, default=DEFAULT_THREADS)
2049 trainer.add_argument("--budget-seconds", type=float, default=DEFAULT_BUDGET_SECONDS)
2050 trainer.add_argument("--device", choices=DEVICE_CHOICES, default=DEFAULT_DEVICE,
2051 help="cuda refuses rather than falling back to the CPU.")
2052 trainer.add_argument("--prune-vocab", action="store_true",
2053 help="Plan 294 P2: slice the embedding table to the ids in use.")
2054 trainer.add_argument("--vocab-reserve", type=int, default=DEFAULT_VOCAB_RESERVE,
2055 help="Unseen vocabulary rows kept anyway (P2).")
2056 trainer.add_argument("--cache-ids", action="store_true",
2057 help="Plan 294 P3: tokenise the training split once, then reuse it.")
2058 trainer.add_argument("--cache-root", default=str(DEFAULT_CACHE_ROOT),
2059 help="Where the tokenised split is cached (never the worktree).")
2060 trainer.add_argument("--val-subset", type=int, default=0,
2061 help="Plan 294 P3: rows to validate on each epoch; 0 is the full split.")
2062 trainer.add_argument("--patience", type=int, default=1,
2063 help="Epochs without improvement before stopping (P4 uses 2).")
2064 trainer.add_argument("--warmup-steps", type=int, default=0,
2065 help="Pin the warm-up ramp; 0 derives it from --epochs as before.")
2066 trainer.add_argument("--alpha", type=float, default=DEFAULT_ALPHA,
2067 # OCT-LINT: disable=no_hardcoded_secrets reason="argparse help prose; the entropy is ordinary English, not a credential (operator ruling 2026-09-18)"
2068 help="Plan 298 P1: what an error granting MORE permission costs "
2069 "relative to one granting less (default: 1.0, symmetric).")
2070 trainer.add_argument("--cost-weight", type=float, default=DEFAULT_COST_WEIGHT,
2071 help="Plan 298 P3: weight on the expected-cost term added to the "
2072 "loss; 0.0 is plain cross-entropy (default).")
2073 trainer.add_argument("--stop-metric", choices=STOP_METRIC_CHOICES,
2074 default=DEFAULT_STOP_METRIC,
2075 help="Plan 300 P1: which validation figure early stopping and "
2076 "checkpoint selection watch; ce is plan 290's rule and the "
2077 "default, cost watches the expected cost under --alpha.")
2078
2079 calibrator = subparsers.add_parser("calibrate", help="Fit the temperature.")
2080 calibrator.add_argument("--model", required=True, help="Model directory from train.")
2081 calibrator.add_argument("--corpus-dir", default=None)
2082 calibrator.add_argument("--batch", type=int, default=DEFAULT_BATCH)
2083 calibrator.add_argument("--max-length", type=int, default=DEFAULT_MAX_LENGTH)
2084 calibrator.add_argument("--threads", type=int, default=DEFAULT_THREADS)
2085 calibrator.add_argument("--device", choices=DEVICE_CHOICES, default=DEFAULT_DEVICE)
2086
2087 evaluator = subparsers.add_parser("evaluate", help="Score a case set.")
2088 evaluator.add_argument("--model", required=True, help="Model directory from train.")
2089 evaluator.add_argument("--cases", required=True, help="Case file to predict.")
2090 evaluator.add_argument("--condition", required=True, help="Condition name for the file.")
2091 evaluator.add_argument("--model-name", required=True, help="Model name for the file.")
2092 evaluator.add_argument("--out", required=True, help="Result file to write.")
2093 evaluator.add_argument("--batch", type=int, default=1,
2094 help="1 for per-decision latency, 32 for throughput.")
2095 evaluator.add_argument("--max-length", type=int, default=DEFAULT_MAX_LENGTH)
2096 evaluator.add_argument("--threads", type=int, default=DEFAULT_THREADS)
2097 evaluator.add_argument("--device", choices=DEVICE_CHOICES, default=DEFAULT_DEVICE,
2098 help="cpu is the deployment latency reading.")
2099 evaluator.add_argument("--decision", choices=DECISION_CHOICES, default=DEFAULT_DECISION,
2100 help="Plan 298 P2: argmax is the rule every earlier result file "
2101 "carries; min-cost minimises expected cost under --alpha.")
2102 evaluator.add_argument("--alpha", type=float, default=DEFAULT_ALPHA,
2103 # OCT-LINT: disable=no_hardcoded_secrets reason="argparse help prose; the entropy is ordinary English, not a credential (operator ruling 2026-09-18)"
2104 help="Plan 298 P1: what an error granting MORE permission costs "
2105 "relative to one granting less (default: 1.0, symmetric, "
2106 "under which min-cost and argmax agree everywhere).")
2107
2108 relabeller = subparsers.add_parser(
2109 "relabel", help="Write the policy-change probe's relabelled corpus copy."
2110 )
2111 relabeller.add_argument("--out", required=True, help="Directory to write the copy into.")
2112 relabeller.add_argument("--bench-dir", default=None)
2113 return parser
2114
2115
2116def main(argv: list[str] | None = None) -> int:
2117 """CLI entry point: train, calibrate, evaluate, or relabel."""
2118
2119 func = "main"
2120 args = _build_parser().parse_args(argv)
2121 bench_dir = Path(getattr(args, "bench_dir", None) or _bench_dir())
2122 corpus_dir = Path(getattr(args, "corpus_dir", None) or _bench_dir())
2123 if args.command == "train":
2125 backbone=args.backbone, corpus_dir=corpus_dir, out_dir=Path(args.out),
2126 tokenizer_dir=Path(args.tokenizer_dir), seed=args.seed, max_epochs=args.epochs,
2127 batch=args.batch, learning_rate=args.learning_rate, max_length=args.max_length,
2128 threads=args.threads, budget_seconds=args.budget_seconds, device=args.device,
2129 prune_vocab=args.prune_vocab, vocab_reserve=args.vocab_reserve,
2130 cache_ids=args.cache_ids, cache_root=Path(args.cache_root),
2131 val_subset=args.val_subset, patience=args.patience,
2132 warmup_steps=args.warmup_steps, alpha=args.alpha,
2133 cost_weight=args.cost_weight, stop_metric=args.stop_metric,
2134 ))
2135 elif args.command == "calibrate":
2136 calibrate(Path(args.model), corpus_dir, args.batch, args.max_length, args.threads,
2137 args.device)
2138 elif args.command == "evaluate":
2139 evaluate(Path(args.model), Path(args.cases), args.condition, Path(args.out),
2140 args.model_name, args.batch, args.max_length, args.threads, args.device,
2141 args.decision, args.alpha)
2142 else:
2143 relabel(bench_dir, Path(args.out))
2144 _dbg("OC_CHAT.MAIN", func, f"{args.command}: exit 0", 2)
2145 return 0
2146
2147
2148if __name__ == "__main__":
2149 sys.exit(main())
object|None _saved_remap_table(Path model_dir, str device)
list[list[float]] cost_matrix(float alpha=DEFAULT_ALPHA)
object expected_cost_term(object logits, object targets, object table)
tuple[str,...] tokenise_keys(object tokenizer)
float token_coverage(list[str] commands, object tokenizer, set[int]|list[int] kept, int max_length=DEFAULT_MAX_LENGTH)
object gate_classifier(Path sandbox_root)
dict[str, object] train(TrainConfig config)
dict[str, float] probabilities(list[float] logits, float temperature=1.0)
list[list[float]] _forward_logits(object model, object tokenizer, list[str] commands, int max_length, int batch, str device="cpu", object|None table=None)
int peak_device_memory(str device)
str weights_digest(Path model_dir)
str describe_device(str device)
str resolve_device(str requested=DEFAULT_DEVICE)
object cost_tensor(list[list[float]] costs, str device)
int slice_embeddings(object model, list[int] kept, int pad_row)
dict[str, float|int|bool] select_best_epoch(list[tuple[float, float]] pairs, str metric=DEFAULT_STOP_METRIC, int patience=1, float min_delta=EARLY_STOP_MIN_DELTA)
str cache_key(Path corpus_path, str tokenizer_digest, int max_length)
float expected_cost(list[list[float]] prob_rows, list[int] labels, list[list[float]] costs)
dict[str, object] _collate(list[list[int]] rows, int pad_id, tuple[str,...] extra_keys, str device)
argparse.ArgumentParser _build_parser()
list[dict[str, str]] validation_subset(list[dict[str, str]] rows, int size, int seed=DEFAULT_SUBSET_SEED)
object _load_tokenizer(Path tokenizer_dir)
dict[str, float|int] calibration_report(list[list[float]] logits, list[dict[str, str]] rows, float temperature)
int resolve_max_length(list[str] commands, object tokenizer, int requested, int fallback=DEFAULT_MAX_LENGTH)
dict[str, int] label_counts(list[dict[str, str]] rows)
list[dict[str, str]] read_jsonl(Path path)
frozenset[str] held_out_commands(Path bench_dir)
dict[str, object] result_row(dict[str, str] case, list[float] logits, float temperature, float elapsed_ms, str decision=DEFAULT_DECISION, list[list[float]]|None costs=None)
dict[str, int] confident_error_counts(list[dict[str, object]] rows, tuple[float,...] thresholds=CONFIDENT_THRESHOLDS)
object _remap_input_ids(object input_ids, object table)
list[list[dict[str, str]]] epoch_batches(list[dict[str, str]] rows, int batch, random.Random rng)
float fit_temperature(list[list[float]] logits, list[int] labels, list[float]|None weights=None, float low=0.05, float high=20.0)
dict[str, object] relabel(Path bench_dir, Path out_dir)
float mean_negative_log_likelihood(list[list[float]] logits, list[int] labels, float temperature, list[float]|None weights=None)
str _normalised_program(str token)
tuple[object, object, object] _require_torch()
float score_full_validation(Path model_dir, list[dict[str, str]] rows, object tokenizer, int max_length, int batch, str device, object|None table)
float stopping_value(dict[str, float|int] record, str metric=DEFAULT_STOP_METRIC)
Path store_cached_ids(Path cache_root, str key, list[list[int]] rows)
tuple[list[int], dict[int, int]] prune_vocab(list[str] corpus, object tokenizer, int reserve=DEFAULT_VOCAB_RESERVE, int max_length=DEFAULT_MAX_LENGTH)
list[int] remap_lookup(dict[int, int] remap, int unknown_id, int size)
object remap_table(dict[int, int] remap, int unknown_id, int size, str device)
int full_vocab_size(object tokenizer)
tuple[list[dict[str, str]], int] relabel_recursive_allow(list[dict[str, str]] rows, object classify)
list[dict[str, str]] load_split(Path bench_dir, str name, frozenset[str] held_out)
str|None strip_recursive_option(str command)
bool _is_short_cluster(str token)
str decide(dict[str, float] probs, str decision=DEFAULT_DECISION, list[list[float]]|None costs=None)
dict[str, object] evaluate(Path model_dir, Path cases_path, str condition, Path out_path, str model_name, int batch=1, int max_length=DEFAULT_MAX_LENGTH, int threads=DEFAULT_THREADS, str device=DEFAULT_DEVICE, str decision=DEFAULT_DECISION, float alpha=DEFAULT_ALPHA)
bool grants_more_permission(str truth, str pred)
tuple[str, bool] verify_tokenizer_vocab(Path tokenizer_dir, str backbone, Path hf_home)
list[list[int]] tokenise(list[str] commands, object tokenizer, int max_length)
list[list[int]]|None load_cached_ids(Path cache_root, str key)
int main(list[str]|None argv=None)
float expected_cost_of(dict[str, float] probs, str pred, list[list[float]] costs)
str min_cost_verdict(dict[str, float] probs, list[list[float]] costs)
str other_stop_metric(str metric)
dict[str, float|int] calibrate(Path model_dir, Path corpus_dir, int batch=DEFAULT_BATCH, int max_length=DEFAULT_MAX_LENGTH, int threads=DEFAULT_THREADS, str device=DEFAULT_DEVICE)
dict[str, object] _to_device(object encoded, str device)
float brier(dict[str, float] probs, str truth)
None write_result_file(Path path, str model, str condition, list[dict[str, object]] rows, dict[str, object] provenance)