# Architecture This document describes the model: the pointer readout, the three question types, the training objective and the design decisions behind them. It ends with the repository layout. Paths are relative to the repository root unless they are links. The published `strands_decider` package (inference) lives under `src/strands_decider/`; the training code lives under `training/`. An inference module path such as `infer.py` is relative to `src/strands_decider/`; a training module path such as `data/teacher.py` is relative to `training/`. The scripts under `data/generators/` and `data/checks/` are relative to the repository root. ## The architecture ``` ┌─────────────────────────────────────────┐ gen (discarded) │ strands-decider │ ┌────────────┐ │ one logit per option │ │ output │ │ ▲ │ └────────────┘ │ ┌──────┴───────┐ │ ┌────────────┐ │ │ pointer head │ │ │ LLM torso │ ─► │ └──────▲───────┘ │ └────────────┘ │ ┌────────┬──┴─────┬──────────┐ │ ┌────────────┐ │ h(opt 1) h(opt 2) h(opt 3) h(answer)│ │ input │ │ └────────┴────────┴──────────┘ │ └────────────┘ │ ┌────────────┐ │ │ │ LLM torso │ │ │ └────────────┘ │ │ ┌────────────┐ │ │ │ input │ │ │ └────────────┘ │ └─────────────────────────────────────────┘ ``` Take a pretrained decoder LLM, **discard its language-modelling head**, and score each option by comparing the question's answer position against that option's own hidden state. One forward pass, no generation, no decoding loop. A request renders to a single prompt: ``` Help! My payouts have been failing for 3 days. Select exactly one option. Which team should handle this? 1. billing — payments, invoices, payouts 2. technical — bugs, outages, API errors 3. sales — pricing and upgrades ``` The torso runs once. The readout then takes the hidden state at `` as a query and the hidden state at the **last token of each option's line** as keys — that token has just read its whole option under causal attention — and scores one against the others: ```python logit_k = / sqrt(dim) ``` Softmax over those `K` logits is the answer. That is the entire inference path. **An option's score depends on what it says, not where it sits.** There is no per-option parameter anywhere in the model, so nothing can learn that "the first option is usually the right one", and nothing caps how many options a question may carry. The same weights answer a two-option yes/no and a thirty-option routing question, with label sets never seen in training, because the prompt alone establishes what each option means. Three consequences worth stating plainly: - **Runtime-defined labels.** Options are read from the request, not baked into the weights. No retraining to add a category. - **No option ceiling.** The schema's 255 is the only limit. - **Order sensitivity is small but not zero.** Reversing the option list moves the distribution by ~0.016, because options still attend to earlier options under causal attention. Eliminating it entirely needs per-option attention isolation, which is not implemented here. The readout is ~1M parameters (`dim=256`); rank-16 LoRA adapters on the torso's projections are the only other thing trained. The v14 checkpoint is ~88 MB — adapter, readout and tokenizer — and the base weights are fetched from HuggingFace at load. **The torso.** v13 to v19 run Qwen3.5-2B-Base (1.9B parameters), a hybrid: 18 of its 24 layers are Gated DeltaNet — linear attention carrying a recurrent state — and 6 are full attention. LoRA covers the projections of both kinds (`lora_targets`). v7 and earlier ran Qwen3-1.7B-Base, all full attention. The architecture is the same either way; what the hybrid changes is serving ([Asking many questions is nearly free](inference.md#asking-many-questions-is-nearly-free)) and the training environment ([Setup](../training/README.md#setup)). ### One mechanism, three primitives All three question types are the same masked softmax read back differently: | Primitive | Options | Returns | | ---------- | ------------ | ---------------------------------------------- | | **Noul** | 2 (false/true) | `P(true)` | | **Choice** | N options | argmax + per-option probabilities + confidence | | **Score** | L levels (2–10, ordered) | expected value `Σ i·pᵢ` + distribution + confidence | **Confidence is derived, not predicted.** For `choice` it is the normalised max-probability: ``` confidence = (N · p_max − 1) / (N − 1) ``` Uniform → 0, one-hot → 1, and crucially *independent of N*, so one routing threshold means the same thing for a 3-option and a 10-option question. Noul has no confidence field because with two outcomes the probability already is the uncertainty. #### The routing convention These documents use the example bands of the public Jev API documentation ([Confidence](https://docs.typesafe.ai/confidence)). At 0.9 or above, act without a check. From 0.5 to 0.9, confirm first. Below 0.5, send the answer to a person. That documentation says that the right thresholds depend on the consequences of each action. Measure them on your own traffic before you rely on them ([Summary](../evaluation/results.md#summary) gives the measured bands). **`score` uses a different measure**, because max-probability reads *spread* as *doubt* — and on an ordered scale, mass on adjacent levels is precision, not confusion. A distribution split between levels 2 and 3 is confidently saying "about 2.5". So score confidence is normalised standard deviation: ``` sigma = sqrt( sum_i p_i * (i - mean)^2 ) confidence = 1 - sigma / sigma_max ``` This also fixes an ordering bug: max-probability ranks a bimodal distribution (mass at both ends) *above* a uniform one, when bimodal is the worst case for an ordinal — the returned expected value falls exactly where the model thinks the answer is not. The floor is corrected for ordinal smoothing. Training deliberately puts `eps` of each target on neighbouring levels, so a perfectly fitted model still shows sigma ~ sqrt(eps). Without that correction a 3-level score capped at 0.68 confidence, making the ">= 0.9 act automatically" band of the [routing convention](#the-routing-convention) unreachable by construction. ### Training The loss has three parts: - **Cross-entropy on the gold label**, with 10% of a `score` target's mass moved to adjacent levels: being one level off is a smaller error than being four off. - **KL to the frozen torso's own readout** (weight 0.3): the same torso with its adapter disabled, reading the option-number tokens it would emit after ``. It keeps the model near what pretraining already knew, for one extra forward pass and no extra weights. - **KL to a frozen teacher** (weight 1.0), on the multi-step rows only: Qwen3.5-4B, read without any training ([What the torso knows untrained](../research/history.md#what-the-torso-knows-untrained)), its option distributions computed once and stored (`data/teacher.py`). It is used only where it is measurably better than the student: distilled on short classification, where it is not, it moved nothing ([v12](../research/history.md#v12-distilling-a-frozen-qwen35-4b)). v14's corpus is 100,449 rows from 21 public classification tasks — topic, intent, sentiment, entailment, ordinal ratings, yes/no, rule chaining — plus 12,909 multi-step rows from ContractNLI, MuSiQue and BoardgameQA ([Data sources and licences](../data/README.md#data-sources-and-licences)). The v19 reference recipe adds 3,815 generated document questions and 6,166 answer-adequacy rows. One detail matters more than any of the loss terms: > **Option order is re-shuffled on every example, every epoch, and the label is > remapped to follow.** Without that, the heads quietly memorise "slot 0 tends to be the positive class" and genericity is lost. With it, the only strategy that survives gradient descent is actually reading the option text. Score questions are the exception — their slots are ordinal, so they are only ever *reversed* (rubric and label together), never permuted. ## Design decisions worth knowing **Causal attention, read at the right tokens.** The note sketching this architecture likened it to BERT, and a bidirectional encoder would pool better in principle. But converting a causal LLM to bidirectional attention requires substantial retraining to recover what you break. Keeping attention causal uses the pretrained weights exactly as they were trained, which matters when the adaptation budget is one 3090. Under causal attention the `` position has read the whole prompt and each option's last token has read that option, so they are sound positions to read. **Head in fp32.** The torso runs bf16; the head is tiny and a low-precision classifier is a needless source of calibration error. **Ordinal smoothing for scores.** Being one level off is a much smaller error than being four off, and plain cross-entropy cannot express that. Score targets put 10% of their mass on adjacent levels. **Teacher targets only where the teacher is better.** Distillation transfers the teacher's behaviour on the prompts it is given. On short classification the frozen 4B was no better than the student, and distilling there moved nothing on JevBench (v12); on multi-step documents it was far better, and there it helped (v14). Measure the teacher against the student on the prompts first. **Remove the shortcuts the data allows.** The model learns whatever cheaply predicts the label: one fixed question per task taught it to ignore questions (v11), and ContractNLI's per-claim priors would have taught it to skip the contract. Balance them away, keep minimal pairs together (MuSiQue's answerable and unanswerable versions), and when the student beats its teacher on a converted dataset, test for artefacts of the conversion before believing it (v14). **Drop, never truncate.** A prompt cut through its option list would score an option from a neighbour's representation. Training and evaluation drop rows over the window; at serving time the question's tokens are reserved first and the state is cut from the front (`_fit` in `infer.py`). **Hybrid torsos share the prefix too.** A Gated DeltaNet layer carries convolution and recurrent states instead of keys and values. The cache fork (`_expand_cache` in `infer.py`) repeats those along with the keys and values, into new layer objects, dicts and tensors — the layer updates its states and flags in place, so a fork sharing them would corrupt the prefix — following decider-2b's `shared_prefix.py`. A layer holding any other tensor raises `UnforkableCache`, and the engine falls back to exact batched encoding rather than guess whether it has a batch dimension. ## Layout ``` src/strands_decider/ -- published package (inference only) schema.py wire types; the confidence formula prompting.py state/question rendering; slot <-> option binding modeling.py LLM torso (Qwen3 or hybrid Qwen3.5) + pointer (or slot) readout; masked softmax; KL reference; is_hybrid infer.py serving engine; shared-state cache, including hybrid torsos mps_kernels.py Gated DeltaNet chunk rule for Apple-silicon serving (no fla on macOS) cpu_kernels.py the Gated DeltaNet depthwise causal conv for CPU inference mlx_engine.py serving with the torso on MLX (--device mlx); the engine otherwise infer.py's server.py FastAPI, POST /v1/systemone cli.py the strands-decider command (ask / serve / info) hf_export.py a checkpoint as a Hugging Face model folder (safetensors, card, manifest) naming.py release naming helpers (used by hf_export and release scripts) vision.py Qwen3.5 vision-tower engine (`serve --vision`, `ask --image`) training/ -- not published; run as `python -m training.` train.py LoRA + readout training loop (`python -m training.train`) evaluate.py accuracy, ECE, NLL, MAE; temperature fitting; calib/test split (`python -m training.evaluate eval|calibrate`) soup.py average same-recipe checkpoints (`python -m training.soup`) distributed.py multi-GPU training under torchrun: the 1-GPU steps, shared across ranks data/ corpus building and inspection (`python -m training.data …`) format.py Example, JSONL io, stratified and held-out-task splits recipes.py HF dataset -> training examples collate.py option shuffling, tokenisation, ordinal targets, option positions sampling.py length-grouped batching synth.py v9 stated-rule generators (not used by the current recipes) synth_pairs.py v10 minimal-pair generators (not used by the current recipes) question_transforms.py v11 question-varied rows (not used by the current recipes) teacher.py frozen-teacher distributions, SemIf's reading (v12, v14) multistep.py ContractNLI / MuSiQue / BoardgameQA / HotpotQA converters (v14) policy.py ShARC / ConditionalQA converters (v15, not used by the current recipe) generated.py generated document questions -> balanced training and eval files (v16); paraphrases attached to built files (v20) replay.py a checkpoint's own distributions as a teacher file (v17) adequacy.py HelpSteer2 -> answer-adequacy rows and evaluation set (v19) catchall.py "other" / "none of these" options on the corpus's choice rows (v20) distill.py teacher distributions only where the teacher agrees with gold (v20) shards.py one labelling process per GPU, and the merge configs/ train.yaml (the v19 recipe, WSL2), train-parent.yaml (its parent, v14's recipe), train-v7.yaml (v7, Windows) experiments/ -- v11a ... v20 (pre-registered runs) training/ README.md -- setup, the recipe step by step, several GPUs, hardware notes recipe.sh -- v19 end to end under WSL2: corpus, downloads, multi-step rows, generated and adequacy rows, teacher labels, the parent, replay labels, train, calibrate, eval; and the steps that retrain v20 (catchall, distill) run_recipe.sh -- recipe.sh on one multi-GPU host, timed per stage, with row-count checks and an optional S3 copy recipe_v7.sh -- v7 end to end on Windows (recipe.sh reuses its corpus build) aws/ -- the recipe on one 8-GPU EC2 host over SSM, as run for v17 and v19: README.md, scripts/ (bucket, host, code sync, teardown), image/ (host setup) evaluation/ README.md -- how the model is measured, and the results; multistep_eval.py (v14's evaluation sets), pair_eval.py (v20's paired evals), question_sensitivity.py (does the answer follow the question?), calibrate_mix.py (the v19 recalibration experiment), pair_accuracy.py (v10's minimal pairs), bench_local.py (latency by state length and question count, any device), device_parity.py (one checkpoint's answers on several devices, against the first); jevbench/ -- jevbench.sh (the external benchmark on a served checkpoint), paired.py (McNemar against a recorded run), jevbench_cold_warm.py (each task twice: first request against repeat) data/ README.md -- what is committed, what is downloaded, and the licences; SHA256SUMS -- the sha256 of the committed synthetic files, the built corpora and the raw downloads, which recipe.sh checks before it uses them; synthetic/ -- the exact synthetic training and evaluation files and model-produced labels the recipes use; everything else that the recipe writes under data/ is built from public downloads and not committed; generators/ -- llm_client.py (the generators' chat client: OpenRouter, Amazon Bedrock, Bedrock Mantle), gen_documents_openrouter.py (v16), gen_adequacy_openrouter.py (v19), gen_paraphrases_openrouter.py and gen_flips_openrouter.py (v20), gen_documents.py (the Nova pilot), and their exports gen_v16/, gen_weak/, gen_adequacy/, gen_paraphrases/, gen_flips/, gen_mixed_pilot/, gen_pilot_qwen/ (verifier answers included); checks/ -- verify_synth_labels.py, verify_synth_pairs.py (re-derive the v9/v10 corpora's labels from the rendered documents) examples/ strands/ -- a Strands Agents intervention that gates a tool call on two noul questions (tool_call_intervention.py, with _client.py) research/ README.md -- the preregistration practice and the version table; preregistrations/ -- PREREGISTRATION-v9.md ... -v20.md (and -v19-seed1, -v19-calmix), predictions fixed before each run, outcomes appended, and README.md (the path map: the files are frozen and name old paths); history.md -- the experiment history; generations.md -- the dated generation-by-generation ledger; data/ -- per-case evidence (JevBench tasks and results, latencies); figures/, and research/scripts/ that collect the data and draw the figures tests/ pytest only: test_core (no GPU), test_server (stubbed), test_gpu_smoke, test_sampling, test_question_transforms, test_teacher, test_cpu_kernels, test_multistep, test_hybrid, test_policy, test_generated, test_prefix_cache, test_adequacy, test_mps_kernels, test_engine_dtype, test_catchall, test_distill, test_hf_export, test_checkpoint_load, test_cli_build, test_data_identity, test_configs, test_run_recipe, test_llm_client, test_cli_exit, test_ddp (`-m distributed` for the multi-GPU runs) docs/ architecture.md -- this document; inference.md -- serving a checkpoint: install, ask, serve, the HTTP API, a Mac .github/ workflows/tests.yml -- the CPU tests; workflows/generators-live-test.yml and .github/scripts/check_generator_rows.py -- one live batch per generator backend ```