KNOT keeps ONNX Runtime and Candle as configurable inference engines. Training is a separate, optional research workflow: it must not add Python or training dependencies to the Rust serving binaries, and it must not make either runtime mandatory.
The first experiment is English typed decisions across multiple domains. It starts from the pinned Laya checkpoint, freezes its ModernBERT encoder, and trains only the decision head on CPU. It is a pipeline and compatibility smoke test, not a claim that KNOT has trained a better model.
All three KNOT primitives are represented, with these important distinctions:
| Primitive | Source | Label meaning and limitation |
|---|---|---|
choice |
MASSIVE English and BANKING77 | Direct intent labels, using the datasets' official test splits |
noul |
Deterministic derivation from MASSIVE domain labels | Whether an utterance belongs to its annotated domain versus a different domain. These are derived binary examples, not independently annotated out-of-scope examples |
score |
knot-authored synthetic smoke examples in training/data/score_smoke.jsonl |
Exercises score serialization, tokenization, loss, and evaluation only. It is not public data or a quality benchmark |
The MASSIVE and BANKING77 dataset cards declare CC BY 4.0. Their data is downloaded at immutable revisions when preparing a local run; source examples are not checked into this repository. Retain attribution and the source, revision, split, and derivation for each output row. Only English examples are used in this experiment. The synthetic score file is authored for this repository and covered by its Apache-2.0 license.
We did not find a non-social, ordered text-score dataset with a sufficiently
clear reuse license in the reviewed candidates. Synthetic score examples are
permitted only to smoke-test the pipeline. A broad-domain score quality claim
is blocked until a clearly licensed, genuinely ordered score dataset is
approved. Likewise, derived MASSIVE noul examples do not establish
out-of-scope detection quality.
training/prepare_data.py writes local JSONL files with one question per row.
Each row contains:
case_id,state,questions,expected: the Laya-compatible decision input and hard label.split:train,validation,calibration, ortest.source: dataset ID, immutable revision, declared license, original split, source-row identity, and derivation.languageandtags: slice/report metadata.
Dataset IDs are data-plane identifiers: the synthetic source is
knot-authored/score-smoke (revision knot-training-v1). These strings were
renamed from their original oio-* form when the product rename reached the
training data (2026-10-07, see ADR-007); corpora,
manifests, and feature caches prepared under the old IDs no longer validate
and must be regenerated or rebuilt.
The prepare step uses MASSIVE's official validation and test splits, carving
calibration rows from its training split. BANKING77 has no official validation
split, so validation and calibration are carved deterministically and
stratified from its official training split; its official test split remains
untouched. Derived binary examples stay with their original MASSIVE row's
split. Exact-text duplicates are assigned to the highest-priority split in
test, validation, calibration, train order, so lower-priority copies
are excluded and the official test inputs are never removed. Exclusion counts
are written to the manifest. The validator rejects remaining source-row or
exact-text overlap across splits, unknown license labels, invalid targets, and
missing primitive coverage in the training split.
Prepared files and run outputs live under ignored training/out/. The
validator, tests, and data preparation use only the Python standard library.
Preparation reads the versioned MASSIVE 1.1 archive and BANKING77 CSVs from a
pinned upstream Git commit; it does not execute remote dataset-loading code.
Checksums for the downloaded inputs and prepared splits are recorded in the
manifest. To prepare data, run:
python -m training.prepare_data --output-dir training/out/data
python -m training.validate_data training/out/dataThe prepared-data manifest records the fixed revisions and SHA-256 of each split. Do not publish the generated rows without checking the source license and attribution obligations for the intended distribution.
The smoke trainer uses the Laya reference implementation of the exact typed
head and an already-downloaded, locally pinned KNOT-compatible checkpoint. It
does not fetch weights. Install the optional training requirements and make a
clean Laya source checkout available through --laya-source:
python -m pip install -r training/requirements.txt
python -m training.train_frozen_head \
--data-dir training/out/data \
--model-dir /path/to/pinned/laya-checkpoint \
--laya-source /path/to/laya \
--output-dir training/out/frozen-head-smoke \
--max-train-per-label 1 \
--epochs 1Use the Laya source revision pinned in training/train_frozen_head.py; the
script refuses a different checkout rather than silently changing the head
implementation.
The encoder is held in evaluation mode with gradients disabled. The head uses supervised cross-entropy over the existing typed option markers. The default selection cap is deliberately small and deterministic. The output is an experimental weights file and a run manifest, not a deployable model: calibration has not been refit and final-test evaluation is not part of this smoke command. Never replace a release checkpoint with this output.
The run manifest records the selected source IDs, seeds, split hashes, base
checkpoint path, and trainable parameter count. Step loss is printed during
training, and each epoch's weights are saved (experimental-model-epochN)
with a validation evaluation recorded in the manifest, so the epoch used for
any later test comparison is a validation choice, never a test choice. A later quality experiment
must separately define and freeze model-selection, calibration, and test
protocols; compare against the unchanged base checkpoint; report per-primitive
metrics and confidence calibration; and pass both ONNX and Candle parity gates
from the same canonical weights before any deployment consideration.
Stage one freezes the encoder, so its output for a case never changes. Reusing it is what makes CPU training affordable: without the cache, every optimizer step re-runs the encoder (tens of CPU-hours per epoch); with the cache, an epoch only runs the two head layers.
Build it once per checkpoint/data combination:
python -m training.build_feature_cache \
--data-dir training/out/data \
--model-dir /path/to/pinned/laya-checkpoint \
--laya-source /path/to/laya \
--output-dir training/out/features \
--split train --split validation --split calibration --split testHow it is guarded:
- Contents: encoder outputs only — captured before the head's type
embedding — stored per case as a flat binary plus a JSON index keyed by
case_id.--dtype float16is the default; storage scales withcases × sequence length × hidden size(about 0.68 MB per case at the current ~330-token sequences and hidden size 1024). - Provenance: base weights SHA-256, Laya revision, and data-manifest SHA-256 are recorded in the index. A cache built from different weights, source revision, or prepared data is rejected as stale instead of being used. Because the trainer refuses to run with any encoder parameter unfrozen, the cached encoder and the live encoder are always the same encoder.
- Verification: after writing a split, the builder compares cached-feature
logits against the live forward pass on 64 cases and refuses to publish a
cache that predicts a different option. Trainers and evaluators re-verify on
every run with
--verify-cases(default 64,0disables). A mismatch means rebuild with--dtype float32; the error says so rather than continuing with disagreeing paths.
Use it wherever the encoder would otherwise run:
python -m training.train_frozen_head \
... --feature-cache training/out/features
python -m training.evaluate_checkpoint \
... --feature-cache training/out/featuresBoth record the cache directory and verification summary alongside their metrics, so a report produced from cached features stays identifiable, and a missing or partial cache is an error — never a silent fallback to live encoder runs.
The frozen-head smoke run proves the plumbing. The first quality experiment has to prove a held-out gain against the unchanged base checkpoint. These gates are declared before any full-corpus training run. Changing a threshold after seeing test numbers invalidates the experiment; thresholds change only through an explicit revision of this document that says so.
- Baseline first. Evaluate the unchanged base checkpoint on the prepared
validationsplit withtraining/evaluate_checkpoint.pyand keep the JSON report. Every later comparison is made against that number, produced by the same evaluator on the same split. - Select on validation only. Epochs, learning rate, and checkpoint choice
come from
validationmetrics. Thecalibrationsplit is reserved for fitting confidence calibration after the recipe is frozen. Thetestsplit is evaluated exactly twice — base and trained — and only after the recipe, seed, and epoch count are frozen. - Report per primitive and per source. Aggregate accuracy can hide a regression in MASSIVE or BANKING77, so both slices are reported next to the overall numbers.
- Calibration is measured, not assumed. Brier score and 10-bin expected
calibration error come from the evaluator; any calibration refit on the
calibrationsplit is recorded separately and never fit on test.
A stage-one run counts as a held-out gain only if all of these hold on the
test split, base versus trained, from the same evaluator:
| Gate | Requirement |
|---|---|
G1 choice |
trained choice accuracy ≥ base choice accuracy + 0.01 absolute |
| G2 no source regression | neither MASSIVE nor BANKING77 choice accuracy falls more than 0.005 below its base value |
G3 noul |
trained noul Brier ≤ base Brier and trained noul ECE ≤ base ECE. Both may stay poor: the labels are derived, so this gate only forbids worsening |
G4 score |
reported for completeness, excluded from pass/fail — the labels are knot-authored synthetic |
| G5 protocol | test evaluated only after freezing; calibration fit only on calibration; both reports carry the split and weights hashes |
| G6 deployment | out of scope for stage one: deployability still requires ONNX and Candle parity from the same canonical weights |
If a gate fails, the experiment failed. Do not retune the thresholds to match the result; change the recipe — data volume, epochs, learning rate, what is unfrozen — and rerun.
python -m training.evaluate_checkpoint \
--data-dir training/out/data \
--model-dir /path/to/pinned/laya-checkpoint \
--laya-source /path/to/laya \
--split validation \
--batch-size 16 --threads 16 \
--output training/out/baseline-validation.jsonPass --weights path/to/experimental-model.safetensors to evaluate trained
weights on top of the same base checkpoint. The report records the evaluated
weights SHA-256, base weights SHA-256, data-manifest hash, Laya revision, batch
size, thread count, wall-clock seconds, and metrics: overall accuracy, then per
primitive count/accuracy/mean confidence/Brier/10-bin ECE (plus MAE for
score) and per-source accuracy. --split test reports carry a protocol note
reminding the caller of rule 2 above.
--max-cases N runs a fast smoke check; those reports are marked
smoke_limited and must never be quoted as results.
Produced from the unchanged pinned base checkpoint before any training run:
weights 891102d372688fc2…, data manifest 5add84afe7ff7932…, batch 16, 16
threads, 3,097 s wall for all 7,087 cases. That manifest hash predates the
2026-10-07 oio-* → knot-* source-ID rename: row content is otherwise
identical, so the metrics below still describe the current corpus, but a
freshly prepared corpus hashes differently.
| Slice | Cases | Accuracy | Mean confidence | Brier | ECE |
|---|---|---|---|---|---|
choice, all |
3,030 | 0.425 | 0.549 | 0.779 | 0.159 |
choice, MASSIVE |
2,028 | 0.453 | |||
choice, BANKING77 |
1,002 | 0.369 | |||
noul, derived |
4,056 | 0.689 | 0.937 | 0.544 | 0.247 |
score, synthetic |
1 | n=1, not meaningful | |||
| overall | 7,087 | 0.576 |
What this fixes for later comparisons:
- The base checkpoint reaches 0.425/0.369 choice accuracy on the two intent sources, so G1 and G2 have measurable headroom. These — not published dataset numbers — are what trained runs are compared against.
- The
scoreslice has exactly one validation case: the synthetic corpus is far too small to say anything about score quality, which is why G4 keeps score out of pass/fail and why real score supervision stays the blocker rather than a score threshold. noulshows 0.94 mean confidence against 0.69 accuracy (ECE 0.247): the base checkpoint is already overconfident on derived binary labels, so G3 forbidding any worsening is a real constraint, not a formality.- Forward-only throughput was 2.3 rows/s (7,087 rows in 3,097 s). Training adds backward passes, so a full 38,988-row epoch is on the order of ten or more CPU-hours on this class of host — see the cost note below.
The full JSON report — per-source slices, weights and data hashes, wall clock
— lives in the ignored training/out/baseline-validation.json. Reproduce it
with the command above; do not edit these numbers by hand.
Note on cost: the encoder is frozen but still evaluated on every training step, so a full-corpus CPU epoch is measured in hours. Record the selected data volume and wall-clock time in the run manifest so runs stay comparable, and treat a subset run as a declared subset — never report it as a full-corpus result.
Run after the recipe froze (2 epochs, lr 1e-4, full splits, seed 20260611):
the base checkpoint and the epoch-2 weights were evaluated exactly once each
on the held-out test split (12,003 rows) with the same evaluator and cached
features. Base weights 891102d37268…; trained weights 67b21817c119…
(experimental-model-epoch2.safetensors). Epoch 2 was selected on validation
(0.7206) over epoch 1 (0.6801), both above the 0.5764 baseline.
| Slice | Base | Trained (epoch 2) | Gate | Verdict |
|---|---|---|---|---|
choice, all (n=6,054) |
0.4096 | 0.4486 (+0.039) | G1 ≥ +0.01 | PASS |
choice, MASSIVE (n=2,974) |
0.4398 | 0.5020 | G2 no regression | PASS |
choice, BANKING77 (n=3,080) |
0.3805 | 0.3971 | G2 no regression | PASS |
noul accuracy (n=5,948) |
0.6817 | 0.8993 | — | — |
noul Brier |
0.5624 | 0.1475 | G3 ≤ base | PASS |
noul ECE |
0.2584 | 0.0069 | G3 ≤ base | PASS |
score (n=1) |
1.0 | 1.0 | G4 excluded | n/a |
| overall | 0.5445 | 0.6720 | — | — |
Verdict: stage one is a held-out gain — all gates pass. The trained head
adds 3.9 points of choice accuracy and, more strikingly, takes derived-noul
from overconfident (0.94 confidence at 0.69 accuracy) to calibrated
(Brier 0.1475, ECE 0.0069). score remains a single synthetic case and gates
nothing. Not claimed here: calibration refit, dual-runtime export/parity, or
deployability — those stay future work under G6.
The full JSON reports live in ignored training/out/test-base.json and
training/out/test-trained-epoch2.json. Do not edit these numbers by hand;
do not re-run test evaluation to shop for better thresholds.
(Written 2026-10-08, after the frozen test verdict above. Goal: serve the epoch-2 trained weights as a canonical checkpoint with the base checkpoint's guarantees — determinism (SPEC §8), dual-runtime parity, and evidence bound to a revision.)
0. Artifact reality check (done). training/out/ is gitignored: the
recorded run's artifacts — experimental-model-epoch2.safetensors
(sha256 67b21817…), test-base.json, test-trained-epoch2.json,
baseline-validation.json — are not on this machine. What survives:
the prepared splits + feature caches (provenance manifest 1f69a82e…,
weights 891102d3…, laya 4aa6761), the frozen recipe, and the recorded
numbers in this document. The run must therefore be reproduced first.
1. Recover the run (recipe frozen — no new choices).
Rerun python -m training.train_frozen_head with the recorded recipe
(2 epochs, lr 1e-4, seed 20260611, full 38,988-row corpus, frozen
ModernBERT encoder) on the surviving caches; ~2 × 10+ CPU-hours.
Then the weights-identity fork — decide BEFORE touching test:
- rerun sha256 equals
67b21817…→ artifacts recovered; the recorded test numbers attach to these exact weights; zero protocol questions → continue at step 2; - sha differs (cross-host torch non-determinism) → maintainer decision,
recorded as an explicit revision before any evaluation:
(i) one fresh
testevaluation of the rerun weights (the "exactly twice" rule already spent its two on artifacts that no longer exist — a revision must say so before results are seen), or (ii) deploy on validation evidence only (epoch-2 val 0.7206 vs base 0.5764) and mark the test-split gains as attached to the lost artifact. Recommendation: (i), gated on the revision landing first.
2. Materialize the checkpoint (ADR-005 layout).
Copy the pinned base dir; replace model.safetensors with the epoch-2
weights (the trainer saves a full state_dict, strict=True load —
encoder/tokenizer/config untouched); write the manifest (base hub rev
55cf4c4e…, base weights 891102d3…, trained 67b21817… or the rerun's
hash, data manifest 1f69a82e…, recipe + seed, laya 4aa6761) plus the
40-hex .cache/huggingface/download/*.metadata first line so health
revisions binds every evidence row to this checkpoint (keep the base dir
as english-base for reproductions). Deployment shape: directory swap
of english — router names are a fixed enum (english/multilingual/
typed-decisions), so a new name would need router changes; the swap
keeps routing, KNOT_DEFAULT_MODEL, and the #37 fallback semantics
untouched.
3. Torch↔ONNX (G6, ONNX half).
Export with the pinned laya toolchain (laya 4aa6761 + torch-cpu venv —
the parity-macos recipe: scripts/export_onnx.py over the materialized
dir → laya.onnx + .data). Two gates:
- per-row parity: regenerate the torch reference for the frozen
parity_englishcollated inputs under the trained weights, then run knot'sonnx_parityassertion (|ORT − torch| < 5e-3, masked ≤ −1e3 skipped) — same contract, new reference; - metric reproduction:
evaluate_checkpointagainst the exported dir onvalidationreproduces the recorded epoch-2 numbers (choice 0.7206, noul Brier/ECE) within float noise. Validation only —teststays spent.
4. Candle parity (G6, Candle half).
KNOT_RUNTIME=candle + KNOT_MODEL_DIR=<trained-dir> through knot's
candle tier (candle reads the same model.safetensors — literally "the
same canonical weights") with the same tolerance. Both runtimes green =
G6 discharged.
5. Golden replay for the new revision (ADR-005).
Record a replay set bound to the trained checkpoint's revision (the
existing golden_*/engine_* fixtures are base-bound — they encode
Laya parity and will fail against trained weights by design; they keep
gating the base dir in CI. The trained dir gets its own recorded responses
so SPEC §8 determinism has something to replay).
6. Rollout + evidence.
Swap the dir → restart :8123/:8124 + the VS Code MCP server → assert
health/revisions shows the new revision → knot's own suite run against
the trained dir (transport/limits/MCP + the new replay set; base fixtures
run against the base dir as CI does) → record CHANGELOG + this section's
verdict table. The jev-free differential harness stays informational
(divergence from hosted jev is expected once answers change).
Deferred (declared, not dropped): calibration refit on the
calibration split — a separate change (it moves confidences, so it needs
its own validation + abstention-impact note); dual-runtime export parity
for multilingual/typed-decisions (only english exists today).
Decisions (2026-10-08, taken before step 1 ran): weights-identity fork =
option (i) — if the rerun is not bit-identical to 67b21817…, an explicit
protocol revision (one fresh test evaluation of the rerun weights) lands
before any evaluation, numbers unseen; deployment shape = directory swap
of english, base preserved as english-base. Step 1 (the frozen-recipe
rerun) starts on these terms.
- Data and smoke pipeline: licensed adapters, provenance/split validation, and this frozen-head CPU run.
- Quality experiment: execute the gates above — baseline, frozen recipe,
held-out test — and obtain approved real
scoresupervision, which still blocks any score-quality claim. - Canonical artifact: version the training checkpoint and derive both
ONNX and Candle-compatible artifacts from the same weights; retain
KNOT_RUNTIME=onnx|candle. - Small student: only after stage two demonstrates a repeatable held-out gain, compare distillation into a smaller model compatible with both runtimes.
Full encoder fine-tuning, RLCD, teacher-generated labels, private/user data, unlicensed corpora, and training a model from scratch are out of scope for this first smoke experiment.
- MASSIVE: CC BY 4.0 dataset card, official 1.1 archive.
- BANKING77: CC BY 4.0 dataset card,
upstream Git commit
9d081458ff52e53cf7e848f414e6e9344e4e6696in PolyAI-LDN/task-specific-datasets. - Base checkpoint source: Laya's Apache-2.0 model card, pinned to the checkpoint already used by this workspace; provide that local checkpoint explicitly to the trainer rather than downloading a floating revision.