6A small DeBERTa-v3 encoder fine-tuned on the gate-labelled corpus, calibrated.
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.
19Four commands, in the order a run uses them::
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
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.
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.
46Run the trainer from the subproject root with the harness interpreter, with
47``HF_HOME`` and ``HF_HUB_OFFLINE=1`` set in the shell::
49 <harness>\\python.exe bench/argv_policy/baselines/encoder_classifier.py train
50 --backbone microsoft/deberta-v3-xsmall --out <scratch>/bench_models/xsmall-7
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.
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.
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.
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:
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.
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.
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.
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.
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
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.
153 L2 - command lifecycle (training start and finish, files written)
154 L3 - per-epoch validation loss, per-case-set agreement, fitted temperature
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.
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)
180from __future__
import annotations
189from dataclasses
import dataclass
190from pathlib
import Path, PurePath
192from oc_diagnostics
import _dbg
194_dbg(
"OC_CHAT.MAIN",
"module",
"loaded", 4)
197LABELS: tuple[str, ...] = (
"allow",
"confirm",
"deny")
207PERMISSIVENESS: tuple[str, ...] = (
"deny",
"confirm",
"allow")
218DEFAULT_COST_WEIGHT = 0.0
223DECISION_CHOICES: tuple[str, ...] = (
"argmax",
"min-cost")
224DEFAULT_DECISION =
"argmax"
233STOP_METRIC_CHOICES: tuple[str, ...] = (
"ce",
"cost")
234DEFAULT_STOP_METRIC =
"ce"
241CONFIDENT_THRESHOLDS: tuple[float, ...] = (0.9, 0.99)
249DEFAULT_MAX_LENGTH = 64
251DEFAULT_LEARNING_RATE = 3e-5
252DEFAULT_MAX_EPOCHS = 4
253DEFAULT_WARMUP_SHARE = 0.1
264DEVICE_CHOICES: tuple[str, ...] = (
"auto",
"cpu",
"cuda")
265DEFAULT_DEVICE =
"auto"
270VOCAB_PRUNE_FILE =
"vocab_prune.json"
278DEFAULT_VOCAB_RESERVE = 2000
283DEFAULT_CACHE_ROOT = Path(
r"G:\Temp\claude\g--PythonApps-option-c\harness\bench_cache")
287DEFAULT_SUBSET_ROWS = 1000
288DEFAULT_SUBSET_SEED = 294
299DEFAULT_BUDGET_SECONDS = 3600.0
302EARLY_STOP_MIN_DELTA = 1e-4
311DEFAULT_MODEL_ROOT = Path(
312 r"G:\Temp\claude\g--PythonApps-option-c\harness\bench_models"
318DEFAULT_TOKENIZER_DIR = Path(
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"
327DEFAULT_HF_HOME = Path(
r"G:\Temp\claude\g--PythonApps-option-c\harness\hf_home")
331LISTING_PROGRAMS: frozenset[str] = frozenset({
"ls",
"dir"})
334RECURSIVE_OPTION =
"R"
343 """Read a JSON-lines file into a list of rows."""
345 text = Path(path).read_text(encoding=
"utf-8")
346 return [json.loads(line)
for line
in text.splitlines()
if line.strip()]
350 """Every command in the two case sets, which no training row may repeat."""
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)
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.
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.
373 leaked = [row[
"command"]
for row
in rows
if row[
"command"]
in held_out]
376 f
"{name}: {len(leaked)} held-out case(s) present in the corpus, "
377 f
"first is {ascii(leaked[0])}"
379 _dbg(
"OC_CHAT.MAIN", func, f
"{name}: {len(rows)} rows", 3)
384 """How many rows carry each verdict."""
386 return {label: sum(1
for row
in rows
if row[
"truth"] == label)
for label
in LABELS}
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.
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.
403 order = list(range(len(rows)))
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]])
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*."""
423 encoded = tokenizer(list(commands), truncation=
True,
424 max_length=max_length)
425 return [list(ids)
for ids
in encoded[
"input_ids"]]
429 """Which tensors this tokenizer emits, asked of it rather than assumed."""
431 return tuple(tokenizer([
"ls"]).keys())
435 """How many ids the tokenizer can emit, counting any added tokens."""
438 return len(tokenizer)
440 return int(tokenizer.vocab_size)
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.
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.
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.
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}
469 seen = occurring | specials
471 reserved: list[int] = []
472 for token_id
in range(full):
473 if len(reserved) >= reserve:
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)
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.
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.
495 kept_set = {int(value)
for value
in kept}
498 for ids
in tokenise(commands, tokenizer, max_length):
501 covered += int(int(value)
in kept_set)
502 return covered / total
if total
else 1.0
506 fallback: int = DEFAULT_MAX_LENGTH) -> int:
507 """*requested* if it never truncates *commands*, else *fallback*.
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.
516 func =
"resolve_max_length"
517 longest = max((len(ids)
for ids
in tokenise(commands, tokenizer, fallback)), default=0)
518 if longest <= requested:
520 _dbg(
"OC_CHAT.MAIN", func,
521 f
"longest command is {longest} tokens, over {requested}; using {fallback}", 2)
525def cache_key(corpus_path: Path, tokenizer_digest: str, max_length: int) -> str:
526 """The cache identity of one tokenised split.
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.
533 material = f
"{file_digest(corpus_path)}:{tokenizer_digest}:{max_length}"
534 return hashlib.sha256(material.encode(
"utf-8")).hexdigest()[:32]
538 """The tokenised split cached under *key*, or ``None`` when it is absent."""
540 func =
"load_cached_ids"
541 path = Path(cache_root) / f
"ids_{key}.json"
542 if not path.is_file():
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]
550 """Write a tokenised split into the cache and return where it landed."""
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)
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.
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.
569 lookup = [unknown_id] * size
570 for old, new
in remap.items():
576 seed: int = DEFAULT_SUBSET_SEED) -> list[dict[str, str]]:
577 """A fixed *size*-row sample of *rows*, the same one for every run.
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.
584 if size <= 0
or size >= len(rows):
586 order = list(range(len(rows)))
587 random.Random(seed).shuffle(order)
588 return [rows[index]
for index
in sorted(order[:size])]
596def probabilities(logits: list[float], temperature: float = 1.0) -> dict[str, float]:
597 """The temperature-scaled softmax of one logit row, keyed by verdict."""
599 scaled = [value / temperature
for value
in logits]
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)}
606def brier(probs: dict[str, float], truth: str) -> float:
607 """The multi-class Brier score of one forecast (``score.brier_row``).
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.
614 return sum((probs.get(label, 0.0) - (1.0
if label == truth
else 0.0)) ** 2
619 logits: list[list[float]],
622 weights: list[float] |
None =
None,
624 """Mean NLL of *logits* divided by *temperature* against *labels*.
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.
635 for index, row
in enumerate(logits):
636 weight = 1.0
if weights
is None else weights[index]
639 scaled = [value / temperature
for value
in row]
641 normaliser = top + math.log(sum(math.exp(value - top)
for value
in scaled))
642 total += weight * (normaliser - scaled[labels[index]])
644 return total / mass
if mass
else 0.0
648 logits: list[list[float]],
650 weights: list[float] |
None =
None,
654 """Fit one temperature by minimising NLL, by golden-section search.
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.
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)
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)
676 left, probe_a, value_a = probe_a, probe_b, value_b
677 probe_b = left + ratio * (right - left)
679 fitted = math.exp((left + right) / 2.0)
680 _dbg(
"OC_CHAT.MAIN", func, f
"temperature {fitted:.4f} over {len(logits)} rows", 3)
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."""
689 labels = [LABELS.index(row[
"truth"])
for row
in rows]
692 hits = sum(max(probs, key=
lambda label: probs[label]) == row[
"truth"]
693 for probs, row
in zip(before, rows))
695 "validation_rows": len(rows),
696 "temperature": 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),
712def cost_matrix(alpha: float = DEFAULT_ALPHA) -> list[list[float]]:
713 """Plan 298 P1: ``COST[true][pred]``, both axes in :data:`LABELS` order.
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.
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.
730 ranks = {label: PERMISSIVENESS.index(label)
for label
in LABELS}
732 [0.0
if pred == true
else (alpha
if ranks[pred] > ranks[true]
else 1.0)
736 _dbg(
"OC_CHAT.MAIN", func, f
"alpha {alpha}", 4)
741 costs: list[list[float]]) -> float:
742 """What predicting *pred* costs in expectation under the forecast *probs*.
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.
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)))
755 """Plan 298 P2: the verdict with the lowest expected cost under *probs*.
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.
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.
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.
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.
783 if decision
not in DECISION_CHOICES:
785 f
"{func}: decision must be one of {DECISION_CHOICES}, not {decision!r}"
787 if decision ==
"argmax":
788 return max(probs, key=
lambda label: probs[label])
793 costs: list[list[float]]) -> float:
794 """Plan 298 P3's training term, written out in plain Python.
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.
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.
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)
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.
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.
828 func =
"stopping_value"
829 if metric
not in STOP_METRIC_CHOICES:
831 f
"{func}: stop metric must be one of {STOP_METRIC_CHOICES}, not {metric!r}"
833 key =
"validation_loss" if metric ==
"ce" else "validation_cost"
834 return float(record[key])
838 pairs: list[tuple[float, float]],
839 metric: str = DEFAULT_STOP_METRIC,
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.
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.
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.
863 func =
"select_best_epoch"
864 if metric
not in STOP_METRIC_CHOICES:
866 f
"{func}: stop metric must be one of {STOP_METRIC_CHOICES}, not {metric!r}"
868 index = STOP_METRIC_CHOICES.index(metric)
869 best_value = float(
"inf")
873 stopped_early =
False
874 for epoch, pair
in enumerate(pairs, 1):
876 value = float(pair[index])
877 if value < best_value - min_delta:
879 best_value, best_epoch = value, epoch
882 if stale >= patience:
885 _dbg(
"OC_CHAT.MAIN", func,
886 f
"{metric}: best epoch {best_epoch} of {epochs_run} run", 4)
888 "best_epoch": best_epoch,
889 "best_value": best_value,
890 "epochs_run": epochs_run,
891 "stopped_early": stopped_early,
896 """The stopping metric *metric* is not, which is what the record compares."""
898 func =
"other_stop_metric"
899 if metric
not in STOP_METRIC_CHOICES:
901 f
"{func}: stop metric must be one of {STOP_METRIC_CHOICES}, not {metric!r}"
903 return next(choice
for choice
in STOP_METRIC_CHOICES
if choice != metric)
907 """Whether predicting *pred* where the gate said *truth* grants MORE permission.
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.
918 ranks = {label: PERMISSIVENESS.index(label)
for label
in PERMISSIVENESS}
919 if pred
not in ranks:
921 return ranks[pred] > ranks.get(truth, 0)
925 rows: list[dict[str, object]], thresholds: tuple[float, ...] = CONFIDENT_THRESHOLDS
927 """Plan 300 P2: how many permission-granting errors were made CONFIDENTLY.
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
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.
943 func =
"confident_error_counts"
944 counts = {
"granted_more": 0}
945 for threshold
in thresholds:
946 counts[f
"granted_more_at_{threshold:g}"] = 0
950 counts[
"granted_more"] += 1
951 probs = row.get(
"probs")
952 if not isinstance(probs, dict)
or not probs:
954 top = max(float(value)
for value
in probs.values())
955 for threshold
in thresholds:
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)
969 """The program name the gate would read out of *token*."""
971 name = PurePath(token).name.lower()
972 return name[:-4]
if name.endswith(
".exe")
else name
976 """Whether *token* is a short-option cluster such as ``-la``."""
978 return len(token) > 1
and token[0] ==
"-" and token[1] !=
"-" and token[1:].isalnum()
982 """*command* with the recursive listing option removed, or ``None``.
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.
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.
997 tokens = command.split()
1002 for token
in tokens[1:]:
1005 remainder = token[1:].replace(RECURSIVE_OPTION,
"")
1007 kept.append(f
"-{remainder}")
1010 return " ".join(kept)
if matched
else None
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.
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.
1025 func =
"relabel_recursive_allow"
1026 relabelled: list[dict[str, str]] = []
1030 if stripped
is None:
1031 relabelled.append(dict(row))
1033 verdict, reason = classify(stripped)
1034 if verdict != row[
"truth"]:
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
1045 """A ``command -> (verdict, reason)`` callable backed by the real gate.
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.
1053 def classify(command: str) -> tuple[str, str]:
1054 """Classify one command against the unmodified gate."""
1056 decision = classify_command(command, sandbox_root)
1057 return decision.decision, decision.reason
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.
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.
1084 "command": case[
"command"],
1085 "truth": case[
"truth"],
1086 "slice": case.get(
"slice",
""),
1087 "pred":
decide(probs, decision, costs),
1090 "reason": case.get(
"reason",
""),
1091 "logits": [round(value, 6)
for value
in logits],
1099 rows: list[dict[str, object]],
1100 provenance: dict[str, object],
1102 """Write one result file the shared scorer can read."""
1104 func =
"write_result_file"
1107 "condition": condition,
1109 "provenance": provenance,
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)
1117 """The SHA-256 of one file, which is how a trained run is identified."""
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()
1127 """The SHA-256 of the saved weights, whichever format they were saved in."""
1129 for name
in (
"model.safetensors",
"pytorch_model.bin"):
1130 candidate = Path(model_dir) / name
1131 if candidate.is_file():
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.
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.
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
1163@dataclass(frozen=True)
1165 """Everything one training run is pinned by."""
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
1187 warmup_steps: int = 0
1188 alpha: float = DEFAULT_ALPHA
1189 cost_weight: float = DEFAULT_COST_WEIGHT
1190 stop_metric: str = DEFAULT_STOP_METRIC
1194 """Import torch and transformers, or say which interpreter is needed."""
1198 from transformers
import AutoModelForSequenceClassification, AutoTokenizer
1199 except ImportError
as exc:
1201 "encoder_classifier: train/calibrate/evaluate need torch and transformers; "
1202 "run this script with the scratch harness interpreter (see the module docstring)"
1204 return torch, AutoTokenizer, AutoModelForSequenceClassification
1208 """The device a run will actually use, or a refusal saying why it cannot.
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.
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":
1224 available = bool(torch.cuda.is_available())
1225 if requested ==
"cuda" and not available:
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)"
1232 device =
"cuda" if available
else "cpu"
1233 _dbg(
"OC_CHAT.MAIN", func, f
"{requested} -> {device}", 2)
1238 """Peak bytes torch allocated on *device* since the last reset, or ``0``.
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.
1245 if device !=
"cuda":
1248 return int(torch.cuda.max_memory_allocated())
1252 """A one-line description of *device* for a log line and a result file."""
1254 if device !=
"cuda":
1255 return f
"CPU, {DEFAULT_THREADS} threads"
1257 name = torch.cuda.get_device_name(0)
1258 return f
"CUDA, {name}, torch {getattr(torch, '__version__', '?')}"
1261def _to_device(encoded: object, device: str) -> dict[str, object]:
1262 """Every tensor of a tokenizer's output, moved onto *device*."""
1264 return {key: value.to(device)
for key, value
in encoded.items()}
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.
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.
1275 return torch.tensor(
remap_lookup(remap, unknown_id, size),
1276 dtype=torch.long, device=device)
1280 """Carry a batch of full-vocabulary ids through a :func:`remap_table`."""
1282 return table[input_ids]
1286 """A :func:`cost_matrix` as a float tensor on *device*, built once per run."""
1289 return torch.tensor(costs, dtype=torch.float32, device=device)
1293 """Plan 298 P3's training term on tensors: the batch's mean expected cost.
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.
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.
1306 probs = torch.softmax(logits.float(), dim=-1)
1307 return (probs * table[targets]).sum(dim=-1).mean()
1311 """Replace the model's word embeddings with the *kept* rows only.
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.
1319 func =
"slice_embeddings"
1321 embeddings = model.get_input_embeddings()
1322 weight = embeddings.weight.data
1323 before = int(weight.shape[0])
1324 index = torch.tensor(kept, dtype=torch.long, device=weight.device)
1325 sliced = torch.nn.Embedding(len(kept), int(weight.shape[1]), padding_idx=pad_row)
1326 sliced.weight.data = weight.index_select(0, index).clone()
1327 model.set_input_embeddings(sliced)
1328 model.config.vocab_size = len(kept)
1329 _dbg(
"OC_CHAT.MAIN", func, f
"{before} rows sliced to {len(kept)}", 2)
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.
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.
1343 width = max(len(row)
for row
in rows)
1344 input_ids = torch.full((len(rows), width), pad_id, dtype=torch.long)
1345 mask = torch.zeros((len(rows), width), dtype=torch.long)
1346 for position, row
in enumerate(rows):
1347 input_ids[position, :len(row)] = torch.tensor(row, dtype=torch.long)
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)
1356 """The lookup table for a model trained with a pruned vocabulary, or ``None``.
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.
1365 func =
"_saved_remap_table"
1366 path = Path(model_dir) / VOCAB_PRUNE_FILE
1367 if not path.is_file():
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)
1377 """Load the converted DeBERTa-v3 fast tokenizer from a local directory."""
1380 return auto_tokenizer.from_pretrained(str(tokenizer_dir))
1386 commands: list[str],
1389 device: str =
"cpu",
1390 table: object |
None =
None,
1391) -> list[list[float]]:
1392 """Logits for every command, in batches, in evaluation mode.
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.
1401 rows: list[list[float]] = []
1402 with torch.no_grad():
1403 for start
in range(0, len(commands), batch):
1404 chunk = commands[start:start + batch]
1405 encoded = tokenizer(chunk, padding=
True, truncation=
True,
1406 max_length=max_length, return_tensors=
"pt")
1408 if table
is not None:
1410 output = model(**encoded)
1411 rows.extend(output.logits.float().tolist())
1416 model_dir: Path, rows: list[dict[str, str]], tokenizer: object,
1417 max_length: int, batch: int, device: str, table: object |
None,
1419 """The WHOLE validation split's cross-entropy at the saved best checkpoint.
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.
1428 func =
"score_full_validation"
1430 model = auto_model.from_pretrained(str(model_dir))
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]
1437 _dbg(
"OC_CHAT.MAIN", func, f
"full validation loss {loss:.4f} over {len(rows)} rows", 3)
1441def train(config: TrainConfig) -> dict[str, object]:
1442 """Fine-tune one backbone on the corpus, early-stopped on a watched metric.
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.
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.
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.
1475 torch.set_num_threads(config.threads)
1478 config.tokenizer_dir, config.backbone, config.hf_home
1482 f
"train: tokenizer at {config.tokenizer_dir} does not carry the "
1483 f
"vocabulary of {config.backbone} (spm.model digest {digest[:16]})"
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)
1495 random.seed(config.seed)
1496 torch.manual_seed(config.seed)
1497 if device ==
"cuda":
1498 torch.cuda.manual_seed_all(config.seed)
1500 model = auto_model.from_pretrained(
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)},
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]
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)
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)]
1518 pad_row = remap[int(tokenizer.pad_token_id)]
1520 model.config.pad_token_id = pad_row
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},
1529 print(f
"train: vocabulary pruned {before} -> {len(kept)} rows "
1530 f
"(reserve {config.vocab_reserve})", flush=
True)
1533 if device ==
"cuda":
1534 torch.cuda.reset_peak_memory_stats()
1535 parameters = sum(parameter.numel()
for parameter
in model.parameters())
1536 optimiser = torch.optim.AdamW(model.parameters(), lr=config.learning_rate)
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)
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)
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)
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:
1555 f
"{func}: stop metric must be one of {STOP_METRIC_CHOICES}, "
1556 f
"not {config.stop_metric!r}"
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)
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)
1565 pad_id = int(tokenizer.pad_token_id)
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)
1573 if rows_ids
is None:
1574 origin =
"tokenizer"
1575 rows_ids =
tokenise([row[
"command"]
for row
in training], tokenizer, max_length)
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)
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",
1587 log_lines: list[str] = []
1588 epoch_records: list[dict[str, float | int]] = []
1589 rng = random.Random(config.seed)
1590 started = time.perf_counter()
1592 best_value = float(
"inf")
1593 best_loss = float(
"inf")
1594 best_cost = float(
"inf")
1597 full_validation_loss = float(
"nan")
1598 stop_reason =
"max-epochs"
1600 def write_record(reason: str) -> dict[str, object]:
1601 """Write the training record and log as they stand, and return it.
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.
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,
1629 "patience": config.patience,
1630 "alpha": config.alpha,
1631 "cost_weight": config.cost_weight,
1632 "stop_metric": config.stop_metric,
1635 [(float(entry[
"validation_loss"]), float(entry[
"validation_cost"]))
1636 for entry
in epoch_records],
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,
1653 "torch": getattr(torch,
"__version__",
"?"),
1655 Path(config.out_dir).joinpath(
"training.json").write_text(
1656 json.dumps(record, indent=1, sort_keys=
True) +
"\n", encoding=
"utf-8"
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",
1671 for epoch
in range(1, config.max_epochs + 1):
1673 epoch_started = time.perf_counter()
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],
1678 padding=
True, truncation=
True,
1679 max_length=max_length, return_tensors=
"pt")
1682 encoded =
_collate([cached_ids[row[
"command"]]
for row
in batch_rows],
1683 pad_id, extra_keys, device)
1684 if table
is not None:
1686 targets = torch.tensor(
1687 [LABELS.index(row[
"truth"])
for row
in batch_rows], dtype=torch.long,
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)
1695 if cost_weights
is not None:
1697 output.logits, targets, cost_weights
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",
1709 train_loss = running / steps_per_epoch
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]
1716 hits = sum(max(probs, key=
lambda label: probs[label])
1717 == subset[position][
"truth"]
for position, probs
in enumerate(forecasts))
1719 [[probs[label]
for label
in LABELS]
for probs
in forecasts], indexes, costs
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,
1734 _dbg(
"OC_CHAT.MAIN", func, line, 3)
1737 improved = watched < best_value - EARLY_STOP_MIN_DELTA
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))
1744 log_lines.append(f
"epoch {epoch}: checkpoint saved (best so far)")
1745 write_record(
"in-progress")
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}")
1755 elapsed = time.perf_counter() - started
1756 if epoch < config.max_epochs
and elapsed + epoch_seconds > config.budget_seconds:
1757 stop_reason =
"budget"
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"
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)
1775 write_record(stop_reason)
1778 if best_epoch
and len(subset) != len(validation):
1780 config.out_dir, validation, tokenizer, max_length, config.batch, device, table
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)
1787 record = write_record(stop_reason)
1788 train_seconds = float(record[
"train_seconds"])
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)
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.
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.
1814 torch.set_num_threads(threads)
1817 validation =
load_split(corpus_dir,
"corpus_val.jsonl", held_out)
1819 model = auto_model.from_pretrained(str(model_dir))
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]
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"
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]}),
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)
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.
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.
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.
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.
1877 torch.set_num_threads(threads)
1879 training_record = json.loads(
1880 Path(model_dir).joinpath(
"training.json").read_text(encoding=
"utf-8")
1882 calibration = json.loads(
1883 Path(model_dir).joinpath(
"calibration.json").read_text(encoding=
"utf-8")
1885 temperature = float(calibration[
"temperature"])
1886 cases = json.loads(Path(cases_path).read_text(encoding=
"utf-8"))
1888 model = auto_model.from_pretrained(str(model_dir))
1894 rows: list[dict[str, object]] = []
1895 with torch.no_grad():
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],
1900 padding=
True, truncation=
True,
1901 max_length=max_length, return_tensors=
"pt")
1903 if table
is not None:
1905 logits = model(**encoded).logits.float()
1906 if device ==
"cuda":
1907 torch.cuda.synchronize()
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,
1912 hits = sum(row[
"pred"] == row[
"truth"]
for row
in rows)
1915 "date": time.strftime(
"%Y-%m-%d"),
1916 "route": (f
"{describe_device(device)}, fine-tuned encoder, batch {batch}"
1918 else f
"CPU, {threads} threads, fine-tuned encoder, batch {batch}"),
1919 "case_file": Path(cases_path).name,
1920 "model_dir": str(model_dir),
1922 "max_length": max_length,
1925 "temperature": temperature,
1926 "decision": decision,
1928 "confident_errors": confident,
1929 "validation_brier_before": calibration[
"brier_before"],
1930 "validation_brier_after": calibration[
"brier_after"],
1931 "validation_accuracy": calibration[
"validation_accuracy"],
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]
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)}
1953def relabel(bench_dir: Path, out_dir: Path) -> dict[str, object]:
1954 """Write the policy-change probe's relabelled copy of the corpus.
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.
1967 Path(out_dir).mkdir(parents=
True, exist_ok=
True)
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 "
1974 "bench_dir": str(bench_dir),
1977 for name
in (
"corpus.jsonl",
"corpus_val.jsonl"):
1981 Path(out_dir).joinpath(name).write_text(
1982 "".join(json.dumps(row, sort_keys=
True) +
"\n" for row
in relabelled),
1985 report[
"splits"][name] = {
1986 "rows": len(rows),
"matched": matched,
"verdicts_moved": changed,
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"))
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] = {
2001 "matched": sum(1
for case
in cases
2003 "verdicts_moved": changed_cases,
2004 "moved_commands": moved,
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"
2011 _dbg(
"OC_CHAT.MAIN", func, f
"relabelled corpus written to {out_dir}", 2)
2021 """``bench/argv_policy`` -- the directory holding the corpus and the cases."""
2023 return Path(__file__).resolve().parent.parent
2027 """Construct the CLI argument parser."""
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.",
2033 subparsers = parser.add_subparsers(dest=
"command", required=
True)
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,
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.")
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)
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,
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).")
2108 relabeller = subparsers.add_parser(
2109 "relabel", help=
"Write the policy-change probe's relabelled corpus copy."
2111 relabeller.add_argument(
"--out", required=
True, help=
"Directory to write the copy into.")
2112 relabeller.add_argument(
"--bench-dir", default=
None)
2116def main(argv: list[str] |
None =
None) -> int:
2117 """CLI entry point: train, calibrate, evaluate, or relabel."""
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,
2135 elif args.command ==
"calibrate":
2136 calibrate(Path(args.model), corpus_dir, args.batch, args.max_length, args.threads,
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)
2143 relabel(bench_dir, Path(args.out))
2144 _dbg(
"OC_CHAT.MAIN", func, f
"{args.command}: exit 0", 2)
2148if __name__ ==
"__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)
str file_digest(Path path)