Future-byte auxiliary head — implementation review (2026-09-30)
Lane: queue v2 item 5 (.bulba/goal.md), the cheap auxiliary objective that
would sit before the JEPA retune. Worktree wt/future-byte, off a071ecd.
CPU/ndarray only; the GPU is running A/B arms.
Status: implemented, all gates green, A/B registered and NOT run.
What was built
Section titled “What was built”| head | one LinearLike (dense nn::Linear, bias) d_model -> vocab, untied from lm_head |
| gates | 6 in future_byte.rs (see below) |
| readout | h = norm(out_acc), the T-averaged loop readout — the SAME tensor the main head reads |
| loss | gathered-CE, -mean(log_softmax(head(h))[label]) over the valid positions |
| label | targets[q + k], i.e. the byte k+1 positions after the one the main CE predicts |
| valid positions | n = t - k - 1, mean over them, no wraparound, no invented padding |
| config | aux_fb_weight (default 0.0, OFF) and aux_fb_horizon (default 2, >= 1, 0 refused) |
| weight sharing with DSpark | none — no W1, no draft window, no acceptance head, no shared tensor |
| seam | probe::FUTURE_BYTE (ran) / probe::FUTURE_BYTE_ASKED (asked), printed fb=<ran>/<asked> on the eval line |
| A/B | row 1b in docs/protocols/AB-PROTOCOL.md |
Files: crates/dormouse-core/src/future_byte.rs (new, 6 tests),
aux.rs (+1 field, +1 None, zero signature churn), model.rs (+2 skip
fields, +1 construction line, +1 branch in aux_loss),
config/schema.rs, config/override.rs, config/validation.rs,
probe.rs, lib.rs, configs/small.toml,
crates/dormouse-train/src/lib.rs (4 lines: one let, one format arg),
docs/protocols/AB-PROTOCOL.md.
The two shape decisions, in the two lines they deserve
Section titled “The two shape decisions, in the two lines they deserve”Untied. 2404.19737 trains a separate output head per lookahead horizon
(§3), and a tied head would put the future-byte gradient into lm_head — the
arm’s whole claim is that it shapes the BACKBONE through the hidden state
without spending the main head’s capacity. Tying is also the checkpoint-risky
choice: an untied head is a new field a new weight can bring into being; a
tied one silently changes the meaning of the existing one.
T-averaged readout, not the per-iteration states. One [b,t,d] tensor,
shared with the main head, so the A/B measures “an extra CE on a future byte”
and nothing else. The per-iteration states ([T,b,t,d]) are a genuinely
different mechanism (they would let the head see how the loop’s answer
refined) and a later arm; they are also the 4D-tensor class sm_120 crashes on
(AGENTS.md §2.2), which is a second reason not to smuggle them in here.
The label arithmetic, and why t - k - 1 and not t - k
Section titled “The label arithmetic, and why t - k - 1 and not t - k”targets is the one-byte-shifted next-byte sequence, built by the trainer as
bytes[1..] ++ [bytes[0]] (dormouse-train/src/lib.rs:1117) — so
targets[t-1] is a wraparound: the first byte of the batch again, which
the model at position t-1 has no reason to predict. The head at position q
is supervised on targets[q+k], and the largest index it may read is t-2:
n = t - k - 1 valid positions, q = 0 .. n-1labels = targets[:, k .. t-1] (width t-1-k = n)n = t - k would put the wraparound byte into the objective as a real label —
one position in 510 at seq_len 512, i.e. 0.2% of the term, which is exactly
the size of defect nobody sees in a loss curve. The -1 is the whole point of
the count, and the gate below enumerates the index set rather than sampling it.
The main CE does include the wraparound position (it is not this lane’s term
and not this lane’s file); the difference is called out here so nobody reads
n = t - k - 1 as a fix to the main loss.
The gates (all demonstrated red → green)
Section titled “The gates (all demonstrated red → green)”Every mutation below was applied to the tree, run, and reverted. Magnitudes are
the observed ones on the d_model 8, t 12, vocab 256, k 2 fixture.
| gate | red mutation | red output | green |
|---|---|---|---|
future_byte_loss_uses_only_shifted_targets |
labels targets[:, 0..t-1-k] (unshifted — the DSpark bug, reproduced) |
targets[0] must not be read at all (bits 1085651374 vs 1085530779) |
labels in k..=t-2 move the loss by 1.67e-2 .. 1.84e-1; the 3 entries outside move it by 0, bitwise |
| same | labels targets[:, k+1..t] (wraparound leak) |
targets[2] IS a label at k=2 but the loss ignored it |
as above |
future_byte_loss_is_a_function_of_input_and_logits_only |
unshifted labels | targets[0] is not a label at k=2 but it moved the returned aux: 0.5925034 -> 0.5998127 (7.3e-3) |
weight is the exact multiplier; non-future labels bitwise-equal; future label moves it; consumed input moves it; the EMA teacher does not appear in it |
| same | wraparound leak | targets[11] is not a label at k=2 but it moved the returned aux: 0.5582603 -> 0.57073104 (1.25e-2) |
as above |
| same | if false && fb in model.rs (the arm not called) |
fb_weight > 0 with labels must return an aux term |
as above |
future_byte_loss_matches_an_f64_host_reference_at_the_length_boundary |
unshifted labels | rel 2.01e-3 | rel 4.96e-8 (5.80598879 device vs 5.80598908 host, n = 6) |
| same | wraparound leak | rel 5.83e-2 | as above |
| same | weight transposed in the host reference (this gate’s own first draft) | rel 6.52e-3 — looked like noise, was a transposition | as above; the weight layout is now asserted ([d_input, d_output]) |
the_gradient_reaches_the_backbone_and_never_the_lm_head |
tie the head to &self.lm_head |
the future-byte term reached lm_head |
head 2/2 params with a gradient, loop_block 10/27 with a non-zero one, lm_head 0/2 — and None, not zero, i.e. not on the subgraph at all |
zero_weight_adds_no_parameters_and_the_main_loss_is_untouched |
if false && fb |
weight > 0 must return a real term |
aux.fb is None at 0.0 and the parameter set is the head’s 144 params and nothing else on the fixture (256*8+16); the main CE is bitwise identical with the arm on and muted; both counters move exactly once and not at all when muted |
an_off_arm_record_refuses_to_load_into_an_on_arm_model |
(a try_load_record that swallows the error) |
would load silently | refused, naming the tensors: Missing tensors: ["aux.fb.inner.Dense.weight", "aux.fb.inner.Dense.bias"] |
Two notes on magnitudes, because “the gate separates three cases with margin” is a claim that can rot:
- The invariance bound is
to_bitsequality for skipped positions, and> 1e-6for read ones. The observed read-sensitivity is 1.7e-2, five orders above the bound, so the gate is not a coin flip on a noisy fixture. - The f64 tolerance is
1e-5relative, set from the arithmetic (f32 overd = 8accumulations plus a log-sum-exp over 256 classes is ~1e-7 here) and not fitted to the observed4.96e-8— it has ~200x margin. The transposition above is the reason it is relative and not absolute: an absolute bound on a loss of 5.8 would have had to be loosened to 0.04 to let that bug through, or tightened until a legitimate f32 rounding failed.
Zero-weight identity, checked
Section titled “Zero-weight identity, checked”The claim is “off by default and checkpoint-compatible exactly like
use_gr/dspark-off”. What is actually asserted:
DormouseConfig::default().aux_fb_weight == 0.0,aux_fb_horizon == 2.AuxHeads::fbisNoneat weight 0 andSomeabove it — the head is not built, so there are no parameters to carry and none in the record.- The parameter count difference between an off model and an on model is
exactly
vocab*d_model + vocaband nothing else. On the fixture that is 144; atsmall’s widths it is256*768 + 256 = 197 120, asserted as arithmetic. - With the arm on and then muted on the same model (so the parameter draw
is identical), the main CE
recis bitwise identical. This is the claim the A/B depends on: row 1b is “the arm plus a term”, not “a different loop”. - The full suite is green with the arm off everywhere, including
tests/preset_exec.rs(11 passed — the measured preset parameter counts are unchanged, which is the practical consequence of (2)) andtests/ckpt_roundtrip.rs(2 passed — an off-arm checkpoint still saves, loads and re-saves, so the newOptionfield did not disturb the burnpack format).
The existing aux tests’ values are unchanged: the whole suite is green,
aux.rs’s 8 tests and dspark_oracle’s included, and their bodies are
untouched except for the one field added to the struct they construct through
AuxHeads::new, whose signature did not change.
What is COUNTED rather than asserted
Section titled “What is COUNTED rather than asserted”fb=<ran>/<asked> on the eval line, in the style of fused kda= and
norm=. Two halves, because they can disagree:
askedis bumped inmodel::aux_lossat the branch the config opened.ranis bumped insidefuture_byte_loss, only whenn > 0.
So fb=0/<n> is the real defect shape: the arm was opened every step and
produced no term, which is a horizon at or past the sequence length. That is a
config that trains for 2 000 steps with a healthy loss curve and no objective.
One thing about the counter that a reader must know: it counts the
TRAINING forwards, not the eval’s own. The eval forward passes no labels
(targets = None is what keeps the held-out graph out of the tape), so it
cannot run this arm, and an eval-local count would read 0/0 forever and
look like a broken head. This is the opposite of engram=<rows>/<arms>, which
IS the eval’s own forwards, and the asymmetry is in the code comment at the
println!.
n = 0 itself returns an exact zero rather than panicking: a short sequence is
a legal input, and the counter pair is what makes the “horizon longer than
every sequence” case visible. A horizon at or past max_seq_len is not
refused by config::validate on purpose — a short-sequence sweep is a
legitimate thing to want, and the honest response to it is a counted zero, not
a startup failure that cannot be worked around from the config layer.
The loud refusal
Section titled “The loud refusal”aux_fb_horizon = 0 is refused by config::validate with the arithmetic in
the message. It is the subtle one: targets[q + 0] is the byte the MAIN CE
already predicts, so the arm would train, descend, cost a step, and be a second
CE on the next byte through an independent parameter set — the most expensive
way to buy nothing. aux_fb_weight < 0 is refused as a sign flip. Both halves
are pinned by aux_fb_horizon_zero_is_refused, which also asserts the legal
range (1, 4, and a horizon past max_seq_len) still validates, so the
check cannot be mistaken for a narrowing one.
Honest labels on the claims
Section titled “Honest labels on the claims”- “our adaptation of arXiv 2404.19737’s aux heads to a byte-level AR model”
is the design’s label, and it is the only honest one. The external reference
is that paper’s per-horizon untied heads with plain CE. The paper does not
prescribe a 256-symbol byte vocabulary, a weight-shared recurrent block, a
T-averaged readout, a 197 120-parameter dense head, or
k = 2. The claim reused from it is the mechanism-level one — lookahead prediction is what produces the induction-head gains reported at 1-30M — and that has not been measured here at all. No number in this document is a result. - “verified” appears nowhere about this mechanism. The gates verify the arithmetic and the wiring, against this file and against the burn/torch conventions (the f64 host reference is our own, derived from the head’s own parameters read back off the device).
- The A/B row’s cost column is unknown, like every other row in that table:
the queue’s per-arm cost has not been re-costed since the attention
backward was fixed (AB-PROTOCOL,
docs/protocols/AB-PROTOCOL.md).
Conflicts expected with the other aux lane
Section titled “Conflicts expected with the other aux lane”The whole aux.rs footprint is one field, one None, one comment:
pub fb: Option<crate::param::LinearLike>, // in `struct AuxHeads`fb: None, // in `AuxHeads::new`AuxHeads::new’s signature is unchanged, deliberately: it is called from
aux.rs’s own tests, from dspark_oracle.rs, and from model.rs, and adding
a fifth argument would have put a conflict on every one of those lines and
meant nothing to a DSpark reader. The head is attached in DormouseModel::new,
which is the only place that knows the weight.
Consequences for whoever merges:
- If the other lane adds a field to
AuxHeadsand its own constructor lines, the conflict is one region (the struct andnew). Keep BOTH heads — they share nothing, and neither is a DSpark weight. - The
Optionpattern is the same oneLoopBlock::gralready uses, so if the other lane also wants a conditional head,Option<...>inAuxHeadsis now a precedent rather than an invention. probe::N_ARMSmoved 12 → 14 andNAMESgrew two entries. Any test that iteratesprobe::counts()sees two more zero rows;preset_exec.rs’sarm(...)helper is index-based, so it is unaffected.- If the other lane also renumbers
probe, the two changes collide in the same constant block. Land one, re-base the other; the NAMES strings are whatassert_eq!messages print, so a swap changes only the labels.
Follow-ups this lane did NOT do (named, not done)
Section titled “Follow-ups this lane did NOT do (named, not done)”- Horizon 4 is a queue row, not a field. One head, one horizon, per the external reviewer’s instruction.
- Per-iteration readout (
[T,b,t,d]) as a variant — a different mechanism, and the 4D-tensor class sm_120 crashes on. - No typed CLI flag.
--set aux_fb_weight=0.1already reaches the config (a--setkey is added for both fields), andtrain/src/lib.rsis hot with A/B arms launching from it. Add--aux-fb-weightwhen a preset needs it. - No GPU run and no step-time cost. The A/B is registered, not run.
The mixed checkpoint direction was untested.CLOSED while writing this file. The one direction that could have been silent — an off-arm record loaded into a model built withaux_fb_weight > 0— is refused, and the refusal NAMES the missing tensors:Record validation error: Missing tensors: ["aux.fb.inner.Dense.weight", "aux.fb.inner.Dense.bias"]. That isan_off_arm_record_refuses_to_load_into_an_on_arm_model, and it is the loud outcome ADR-0011 asks for: a resume cannot silently grow a head the checkpoint never carried, so the arm’s first 2 000 steps are never measuring a head the operator did not intend. The three directions covered are off→off (green, the ordinary case andtests/ckpt_roundtrip.rs), on→on (the ordinary case, same test), and off→on (refused, above). on→off — loading an armed checkpoint into an off-arm model — was not tested, and it is expected to be refused by the same mechanism; it is a resume that the A/B will not perform (the arm stays on for the whole run), and it is one line if anyone wants it closed.