All notable changes to OdyssNet will be documented in this file.
The format is based on Keep a Changelog.
-
The two experiment docstrings point at
docs/LIBRARY.mdinstead of repeating it.experiment_diffusion.pycarried seven benchmark tables thatdocs/LIBRARY.mdalready held verbatim, andexperiment_llm.pycarried four more; together they were 589 lines of module docstring in front of the code they describe, which is the case AGENTS.md rules out. The diffusion tables were duplicates and are gone; the LLM ones had no home, so the tokenizer comparison, the--sweep gapand--sweep coldstartresults, the--compilespeedups and the reference run moved into a new Language Modeling section indocs/LIBRARY.mdbefore the docstring was cut. Every figure is the one that was already published — nothing re-measured, nothing dropped. The docstrings keep the mechanism, the usage lines and the constraints a reader needs before touching the file. Verified with 370 pytest and both--mode smokeruns. -
Version and history references out of the code and the READMEs. "New in 3.0" / "New in 3.1.1" bullets, "the 2.x architecture" as a name for the no-attention arm, and inline "see CHANGELOG 2.6.4" pointers stated the project's history in files meant to describe it as it is. The claims and their numbers stay; only the dates leave. Same for a handful of comments that explained a line by narrating the bug it replaced.
-
The diffusion example walks the rectified-flow straight path by default,
--interpolant rf. Everything downstream of the schedule —q_sample, the targets, the parameterisation conversions, all five samplers, the cadence embedding — reads it throughalpha_barandsigma, so an interpolant is a table here and nothing else.x_u = (1-u) x_0 + u epsdivided by its own norm is exactlysqrt(ab) x_0 + sqrt(1-ab) epsatab = (1-u)²/((1-u)² + u²), withsigma = u/(1-u); both identities are checked in--mode smoke, and_step_eulerturned out to already be rectified-flow Euler written in sigma coordinates.That settles the parameterisation question a flow interpolant usually raises. The rank ceiling documented since 3.2 belongs to what the network is asked to output, not to the path it walks, so a velocity target would carry epsilon at full rank and hit the same ceiling
--predict epsdoes.x_0stays and the flow lives in the schedule, where it costs nothing.MNIST cannot answer which path is better: all four arms sit at 96–98% at K=4, and the
cosinearm's own Frechet distance moved 17.1 to 10.5 between seeds, wider than the gaps under test. CIFAR-10 separates them — 768 neurons, 10 minutes per arm at equal wall clock, two seeds, averaged over K=8 to 64 since K=4 is the degenerate end of every curve:arm frechet 42/123 fidelity 42/123 cosine 7.48 / 8.25 48.3 / 48.3 rf 7.22 / 6.13 48.0 / 49.1 logitnorm 8.03 / 7.30 39.2 / 37.9 rf_logitnorm 5.01 / 7.70 45.5 / 45.8rfgives the same conditioning fidelity as cosine with a better Frechet distance at both seeds.--interpolant cosinerestores the old schedule, and--sweep flowmatchis the grid.
-
--t-density logit_normal, off by default. It draws--k-range's interior stops from a logistic rather than uniformly, concentrating them where the image is decided rather than spreading them over an axis whose ends are nearly settled (Esser et al. 2024). It does what it claims — measured in the smoke test, the middle half of the schedule holds 52% of uniform stops and 73% of these — and it costs nine to eleven points of conditioning fidelity at both seeds, which is why it is off. The likely reason is a constraint this file has and that paper does not: here the training grid is the sampling grid, so thinning the noisy end leaves the sampler walking through timesteps training barely visited. Untested.rf_logitnormhas the best Frechet distance in the table on one seed and does not reproduce it on the other, which is the pattern--cadenceand--echo-cadenceboth showed. -
--echo-cadencere-measured on CIFAR-10, where it loses. 3.5.0 shipped it off on the grounds that the seeds disagreed; MNIST turned out not to separate the arms at all, the same way it could not separate the interpolants. On CIFAR-10 the E-axis span — the one axis the flag exists to improve — is 7.2 and 4.0 against a plain drawn E's 3.8 and 2.4, behind at both seeds, and 9.6 points of conditioning fidelity worse at one of them. The flag stays for the mechanism it demonstrates and the wording changes from "no separation" to "measured and lost".
- A checkpoint older than a config field was scored under today's default for it.
adopt_saved_archonly adopted fields the saved payload actually carried, so when--interpolantchanged default thebasecheckpoint — written before the field existed — sampled at 89.2% instead of its measured 91.2%, silently, since nothing about the state dict disagrees. Adopted fields now fall back to what the default was when the checkpoint could have been written, and the smoke test strips a field from a payload to prove it.
-
The diffusion example draws the thinking depth per batch as well,
--e-range 2,6by default. 3.4.0 made the number of denoising steps something the caller chooses; the depth spent between them was the other value baked into the weights. K decides which timesteps are visited and E decides how long the core thinks between them, so E can be drawn without touching the training distribution at all — nothing in the batch has to know which depth it will be run at, which is why the draw lives intrain_steprather thantrajectory_batch.MNIST, echo 4, 6 minutes per arm at equal wall clock, two seeds.
--mode flexgains an echo axis (--flex-e) and reports the two spans separately, since flexibility in K and in E are separate claims; the numbers below are the span of conditioning fidelity across nine step counts at E=4 and across six echo depths at K=16, lower being flatter:arm span 42 span 123 K axis / E K axis / E fixed 17.6 / 10.2 16.6 / 6.2 rand_k 10.8 / 8.0 7.2 / 5.2 rand_e 9.2 / 5.8 3.6 / 2.6 rand_ke 5.2 / 3.0 6.0 / 3.2 ecad 20.8 / 16.6 18.0 / 10.4 rand_e_ecad 8.8 / 2.8 6.4 / 3.2A drawn E beats the fixed control on both axes at both seeds and leaves the Frechet distance flat, which is the case
--k-rangewas made the default on. It also flattens the K axis while drawing only E, so the two are not independent knobs —rand_keis no better than either alone and its Frechet distance is worse everywhere (15.9–17.8 against 9.1–11.5).--sweep flexeis the grid;--e-range offrestores the fixed depth. Sweep arms now pin both ranges off wherever the axis under test is the other one,--sweep depthincluded, since a drawn K or E would average away the split those arms exist to compare. Existing checkpoints are unaffected andbasestill samples 91.2% / 9.341 at K=16.
-
--echo-cadence, off by default, which names a hole in the mechanism it cannot yet be shown to fix. A frame is injected once and repeated for every echo step of its run — byte for byte, eleven of eleven transitions identical undertorch.equal. So the core can count the steps it has taken and never the ones it has left: the last step of an E=2 run and the second step of an E=6 run are the same input to the same state, and a drawn E asks for the answer at a moment it has no way to see coming.The flag widens the frame axis to K*E, so
forwardresolvesratio = 1and each echo step carries its own vector: a sinusoidal embedding of the steps remaining and the fraction elapsed. The remaining count is left unnormalised, because "two steps left" has to mean the same thing whatever E is for a depth outside the training range to be readable. Measured on the mechanism rather than argued — run the same frame for two steps, built once for E=2 and once for E=6: without the signal the hidden states are bit-identical, with it they differ by 2.3e-1.It is off because the measurement does not support turning it on. Alone it is worse than the fixed control at both seeds, since at a fixed E the remaining-step signal is the same constant sequence every batch and pins the model to that depth harder rather than freeing it — the same shape of result
--cadencegave in 3.4.0, more sharply. Paired with a drawn E it leads the E axis at one seed (2.8 against 5.8) and trails at the other (3.2 against 2.6), which is not a separation. It also costs: K*E entries make the frame tensor E times larger, and the arm reached 18% fewer gradient steps in the same wall clock. Widening the input fixes a tensor shape, so it lives inARCH_FIELDS.The general form of this gap is a library one, not an example one: OdyssNet is a network whose depth is time, and nothing in its API lets a model be told how much time it has. That is worth fixing where every example can reach it rather than here.
- The diffusion example's
--carry independentarm stepped the optimizer mid-trajectory. The memoryless control issues K separate calls and told the trainer to accumulate overcfg.framesinstead of that K. Once--k-rangebecame the default in 3.4.0, K moved from 12 to 20 whilecfg.framesstayed 16, so the accumulation counter drifted: the step landed part-way through a trajectory and the reported loss was scaled by K/16. Only the control arm was affected — the defaulttrajectoryarm makes one call — but the memory sweep'sindependentrow was measured under it and needs re-measuring.
-
The diffusion example's step count is now a dial the caller turns, and
--k-range 12,20is the default that makes it one. Every other diffusion tool lets you choose the number of denoising steps at sampling time; this one could not, because the training grid is the sampling grid and the weights were fitted to a single cadence. On a fixed-grid checkpoint, conditioning fidelity falls monotonically as K rises — 97.2% at K=6 down to 68.8% at K=64 on MNIST, 77.6% to 32.6% at K=32 on CIFAR-10 — while the Frechet distance is best near the trained grid. The step count was part of the architecture rather than a parameter.--k-range LO,HIdraws K per batch over a random monotone grid, so the weights meet many cadences instead of one.--mode flexscores a checkpoint across step counts and--sweep flexktrains the arms against a fixed-grid control;--k-range offrestores the old behaviour. MNIST, 6 minutes per arm at equal wall clock, echo 2, 500 samples at each of nine step counts, fidelity at K=4/16/64 and the span across all nine, on two seeds:arm K=4 K=16 K=64 span 42 span 123 fixed 98.4 88.6 55.8 42.8 45.4 rand_k 97.4 92.2 82.6 14.8 10.4 cadence 99.2 93.0 68.0 31.2 41.6 rand_cadence 99.8 94.8 83.4 16.4 19.6The span is the result: randomising K cuts it three to four times on both seeds and holds the Frechet distance flat from K=12 to K=64, where the fixed arm's climbs from 9.3 to 15.7. It costs one to three points at K=4. Worth noting where the arms were trained — K is drawn from 12 to 20 while the flexibility reaches K=4 and K=64 either side of that range, so what is learned is that cadence is a quantity to read rather than the set of step counts seen. A narrow range is enough, which is why the default is narrow.
The val MSE column ranks
fixedfirst and disagrees with all of this. It is scored on the fixed--framesgrid, that arm's own training distribution and one cadence out of many for the others — the same grid-choice trap that inverted the memory sweep in 3.3.1. The sample columns decide.Existing checkpoints are unaffected: they carry
k_rangein their config,--mode sampleand--mode evaldo not use it, andbasestill samples 91.2% at K=16.
--cadence, off by default, which did not survive its second seed. It widens each frame with the log-sigma stride it is about to take and the fraction of the walk behind it, on the reasoning that a frame cannot infer its own stride until it has seen two of them — by which point the first prediction is made. It does lift conditioning fidelity at every step count, 93.0% against 89.0% at K=16 on the first seed. But its apparent flexibility gain, a span of 31.2 against the fixed arm's 42.8, came back at 41.6 against 45.4 on the second seed, which is no gain at all; and combined with--k-rangeit makes flexibility consistently worse (16.4 and 19.6 against 14.8 and 10.4) along with the Frechet distance everywhere. Telling the model the stride is evidently not what taught it to read the stride, and the two signals appear to interfere. The flag stays because the fidelity lift is real and reproduced; it is off because the rest is not. Widening the input fixes a tensor shape, so it lives inARCH_FIELDS.
-
Three more samplers in the diffusion example, and the stop placement as its own axis.
--samplergainseuler,euler_aanddpmpp_2mbesideddimandddpm, and--sigma-scheduleselectsuniformorkarrasindependently, so "DPM++ 2M Karras" is the two flags together the way the tools that popularised it keep them apart. Every sampler calls the model exactly once per denoising step, which is the constraint this architecture imposes rather than a simplification: a multistage solver evaluates the denoiser twice inside one step, and here a second call would advance the recurrent state a second time. Multistep solvers, whose history is their own previous output, compose with it cleanly.--mode benchscores a trained checkpoint across every sampler and placement over several seeds without training anything, which--mode sweepcannot do without re-training an arm per sampler for a choice made after the weights exist.Measured on MNIST
base, 500 samples, mean of seeds 42/123/54321, cfg 3.0:eulerreproducesddimto three decimals (9.522 Frechet either way), which is the arithmetic checking itself — at eta=0 both integrate the same probability-flow ODE in different coordinates. Two findings against expectation. The second-orderdpmpp_2mis behind the first-order solvers, by 1.6 points of fidelity on MNIST and 2.5 on CIFAR-10, because its correction assumes the denoiser is a pure function of(x_t, t)and this one carries its own history — the solver's memory is a second one built on an assumption the architecture breaks. Andkarrasloses by 20 to 50 points on both datasets, because it moves the stops onto timesteps a model trained on its own sampling grid has never seen. Both axes are kept and documented rather than hidden: the first is a real result about stateful denoisers, and the second should behave normally on a model trained across the full schedule.The default is unchanged at
ddim/uniform, so every published figure stands.
- The diffusion example's default guidance scale is 3.0, measured up from 2.0. Scored with
--mode sampleover 500 images on the MNISTbasecheckpoint, 3.0 leads 2.0 on both sample columns at all three seeds tried — 86.0% to 91.2% at seed 42, 87.4% to 93.0% at 123, 84.8% to 91.8% at 54321, with the Frechet distance improving alongside. Past 3.0 the two columns separate: 5.0 reaches 94.0% fidelity while the Frechet distance turns back to 10.9, which is guidance buying class-purity with variety, so the default stops where both still agree.--mode eval --tag baseconsequently reads 91.2% rather than 86.0%, batch sensitivity 90.8–92.8%, and carried-versus-wiped 91.4% against 53.6% — a wider gap than before, because guidance amplifies the memoryless arm's error too. The sweep tables are unchanged and still say they were measured at guidance 2.0.--etastays at 0.0: it helped CIFAR at cfg 5 and hurt it at cfg 3, and hurt MNIST at two seeds of three.
-
Validatorscored every arm on a shared-epsilon grid while the default trains on iid noise, so the val column measured the wrong thing. The grid was built with one epsilon expanded across all K frames regardless of--traj-noise. That istraj_noise_shared's own training distribution and a foreign one for every other arm, and it inflated the reported loss of any iid run: thecifarcheckpoint reads 0.0773 on the old grid and 0.0631 on the fixed one, against a training loss of 0.0629. The apparent val-above-train gap on every iid run was this mismatch, not overfitting. The grid is now always iid, which is the distribution that keeps the column comparable across arms — a shared-epsilon trajectory determinesx_0from any two frames, so scoring on it credits an inversion as though it were a denoiser.This reverses one claim.
traj_noise_shareddid not hold the best held-out loss; on the fixed grid it scores 0.1762 againsttrajectory's 0.0967, which is the inversion shortcut failing exactly as the theory predicted. Every val figure in the four sweep tables,README.md,README_TR.mdanddocs/LIBRARY.mdhas been re-measured from the saved checkpoints. The--sweep depthval column reverses order as well: coarser denoising grids make each timestep a harder prediction, sok32_e2leads on loss whilek4_e16still leads on conditioning fidelity at 95.0%.Conditioning fidelity and Frechet are unaffected — they come from
measure_samples(), which never callsValidator— soRANK_KEYorderings, the 86.0% headline, the carried-versus-wiped result and the choice ofiidas the default all stand as measured.--traj-noise iidremains the default on the sample columns, which are the ones that decide. -
The example read
extra_dataas a nested dict, butsave_checkpointmerges it into the top level.adopt_saved_archand the resume path both looked uppayload["extra_data"]["cfg"], which was always empty. Loading any checkpoint whose architecture differed from the CLI defaults therefore failed outright —--mode sample --tag <an attention run>died withUnexpected key(s) in state_dict: "attn.q_proj.weight"— and--resumealways restarted the step counter at zero withbest_valat infinity. Both now read the top level. -
--lrwas silently dropped on--resume.lris a parameter-group key, andtorch.optim.Optimizer.load_state_dictreplaces the group dicts wholesale, so the checkpoint's rate overwrote whatever the command line asked for;ChaosGrad.load_state_dictonly backfills keys that are missing, and this one never is. A resume with--lr 1e-2went on printing ChaosGrad's adaptive estimate and stepping at it.run_sessionnow passescfg.lrtoload_checkpoint, whoselrparameter already existed for exactly this. Zero-config resumes passNoneand keep the carried estimate. -
--frames,--echoand--timestepswere ignored on--resume.adopt_saved_archrestored every field inARCH_FIELDS, and those three sat in it despite fixing no tensor shape — they set which timesteps the grid visits and how many echo steps run between them. A resume asking for--frames 64 --echo 8silently trained at the checkpoint's 16x4. They move to a newGRID_FIELDS, which the checkpoint supplies only when the command line is quiet: name one and it is honoured, leave it unset and it follows the checkpoint rather than falling back to the dataclass default. Everything left inARCH_FIELDSdoes fix a shape and still always comes from the checkpoint — includingattn_window, which fixes none but decides the context the weights were trained against.
-
Image diffusion on the chaos core —
examples/advanced/experiment_diffusion.py. A class-conditional denoising diffusion model whose denoiser is one OdyssNet, with no UNet and no VAE. The mapping is native rather than bolted on: withpulse_mode=Falseand a(B, K, F)input run forK*Esteps,forwardresolvesratio = Eby itself, so one denoising timestep is one injected frame, theEecho steps between frames are the depth a UNet would spend on layers, and the hidden state, the attention cache and the plastic trace all cross every frame boundary. The whole reverse trajectory is a single differentiable forward pass, which makestrain_batch(..., full_sequence=True)against the trainer's defaultMSELossthe entire training call.vocab_size=[F_in, P]withvocab_mode='continuous'makes the model's ownprojandoutput_decoderthe encoder and decoder, and conditioning is fixed-basis — a sinusoidal clock and a class one-hot with a null slot for classifier-free guidance — so every learned parameter is inside OdyssNet. DDPM and DDIM samplers, classifier-free guidance, four ablation grids, a--mode smokeself-test and checkpointing through the library's own functions.The example ships a scoring instrument as well as a model: a small fixed convnet gives conditioning fidelity and a Frechet distance in its feature space. That distance is not FID — FID is defined against InceptionV3 — so it is named for what it is and compared only between arms of the same sweep. Its parameters are excluded from every count.
-
--predict x0is the default, because on this architecture the output rank decides what can be denoised at all. The answer is read offn_outneurons, so whatever the network emits is a rank-n_outview of aP-dimensional image. Epsilon is white noise — isotropic, full rank, incompressible — so a rank-192 view keeps192/784of its variance and pins the achievable MSE at 0.755 however long training runs. A 573k-parameter run measured 0.767: saturated, not undertrained, and no amount of training or width-at-fixed-n_outwould have moved it. Natural images are low rank, and the same 192 directions carry all but 3.4% of MNIST's variance.Measured at 700 steps, each target against its own do-nothing predictor, with the per-frame loss at the noisy and clean ends of the schedule:
target val MSE / trivial pure noise nearly clean x_0 13.3% 0.237 0.096 v 59.7% 0.236 0.998 eps 80.5% 0.787 0.896 vis x_0-like at hightand epsilon-like at lowt, so it inherits the problem over half the range — visible above as a loss that is fine at pure noise and at the bound when nearly clean.--sweep sizecarries epsilon arms at four widths, and they behave as the rank argument requires: always above the bound, monotone inn_out, closing on it with training, and sampling at chance throughout.
n_out |
bound 1 - n_out/P |
measured, 1200 steps |
|---|---|---|
| 96 | 0.878 | 0.900 |
| 144 | 0.816 | 0.852 |
| 192 | 0.755 | 0.806 (0.767 by 8.5k steps) |
| 288 | 0.633 | 0.710 |
-
The trajectory memory was measured against a matched memoryless control, and it holds.
independentruns the same frames with the same targets and the same gradient budget, issued as K separate calls so the denoiser starts each frame with nothing — which is what a UNet sampler does. 600 steps per arm, MNIST, seed 42, guidance 2.0, RTX 3060 Ti:arm val MSE conditioning fidelity Frechet params 4 attention heads 0.1575 71.8% 38.97 901,184 trajectory (default) 0.1550 78.4% 40.82 573,376 attention + plasticity 0.1518 73.2% 47.69 902,720 plasticity, temporal 0.1564 70.4% 65.79 574,912 shared-epsilon trajectory 0.1351 54.2% 76.85 573,376 independent(memoryless)0.2010 39.4% 83.10 573,376 Carrying the trajectory doubles conditioning fidelity and halves the Frechet distance at an identical parameter count.
The same claim falls out of a single checkpoint with no second training run.
--mode evalsamples one model twice, carried and wiped between denoising steps — identical weights, identical guidance, one line of difference at inference:same checkpoint, sampling only conditioning fidelity Frechet trajectory carried 85.6% 11.0 wiped each step 58.6% 22.7 That run also probes sampling-batch sensitivity, since the plastic buffer is a batch mean and a batch generated together would share one memory. With plasticity off — the default — there is nothing to share, and fidelity holds between 84.6% and 87.4% from batch 10 to batch 100.
-
--traj-noise iidis the default, on weaker evidence than the theory suggests. Deriving all K frames from one epsilon is the path a perfect DDIM sampler walks, which is why it looks like the right training distribution, and it is a shortcut: two frames of such a trajectory determinex_0by linear algebra, so the model can learn an inversion instead of a denoiser, and an inversion cannot follow it into sampling. The held-out loss behaves exactly as that predicts — the shared arm wins it at every budget measured. The sample penalty did not replicate: 54.2% conditioning fidelity againsttrajectory's 78.4% at seed 42, but 83.4% against 83.6% at seed 54321, both at equal wall clock. Soiidis the default because it has never been worse, not because the gap is settled, and--sweep memoryreports loss and Frechet separately so an arm that trades one for the other stays visible. -
Temporal depth beats denoising resolution at fixed compute.
--sweep depthholdsK*E = 64and varies the split, which is a question only this architecture can ask — on a UNet the denoising step count and the depth spent inside one step are different resources. MNIST, seed 54321, 3 min per arm, 573,376 parameters throughout:arm frames echo val MSE conditioning fidelity Frechet k32_e2 32 2 0.1104 73.0% 13.06 k16_e4 16 4 0.1058 83.6% 15.97 k8_e8 8 8 0.0986 88.0% 13.96 k4_e16 4 16 0.1001 95.0% 15.66 Fidelity climbs monotonically with echo depth while Frechet stays flat in no order, so what improves is the conditioning rather than the sample distribution collapsing. The deepest arm also samples in four denoising steps instead of thirty-two. The default stays 16 x 4: this is one seed at equal wall clock, with step counts spread 11% in the deepest arm's favour, and the ranked table crowns
k32_e2becauseRANK_KEYis Frechet and Frechet is the column that does not separate here. -
The x_0 width curve, for the per-parameter question.
--sweep size, same budget and seed: 221,152 params / 75.6% fidelity, 380,880 / 83.0%, 573,376 / 85.8%, 1,056,672 / 87.6%. Returns are positive and shallow — 4.8x the parameters buys twelve points.n_outscales with the width in this grid, so the curve mixes capacity with output rank; the epsilon arms alongside it move rank alone and stay at chance whatever the width. -
Attention is available on this task and is not the default. It leads on Frechet by 4.5% while behind on conditioning fidelity and on held-out loss for 57% more parameters; on one seed at 500 samples that is not a separation, and per parameter it is a loss. Plasticity is behind on every column. Both are also slow here: at equal wall clock rather than equal gradients the plastic arms reach roughly 6% of the plain arm's step count and attention roughly 28%, because the retained trace grows with the step count, the batch and the neuron count together.
-
BREAKING: the plastic trace is never assembled as a matrix, and
hebb_res='synapse'is gone with it. Every Hebbian write is an outer product and the recurrence only ever asks the trace forh @ L, so the trace is now kept as the(B, N)writes it is made of and contracted on demand — both that product and the row norms its RMSNorm needs. The persistent buffer is materialized once per call, already averaged over the batch, so checkpoints, neurogenesis and transplants see exactly what they saw before. Memory issteps x B x Ninstead ofsteps x B x N².At 1024 neurons, batch 8 and 96 echo steps the plastic path holds 109 MB where a matrix-held trace held 15.5 GB, or 3.4 GB with
gradient_checkpointing, and the step is faster than either. The shape that started this — 1024 neurons, batch 128, 96 steps,hebb_type='both'— asked for 649 GB and now asks for 1.8 GB.Per-element decay is what a streamed trace cannot carry:
r^thas to scale rows for(h * r^t) @ Cto remain a matrix product.hebb_resis therefore'global'or'neuron', and the per-synapse resolution is removed. It cost 2N² parameters and 3.1.0's own A/B found it did not earn them — 1,044 parameters to draw level with attention-only's 634. Checkpoints holdingt_hebb_factor/s_hebb_factorof shape(N, N)will not load; retrain at'neuron'.3.1.2's lever goes with it:
gradient_checkpointingwas made to reach the plastic trace because the trace was a matrix, and it no longer is. The flag still covers the step, which is all it now has to cover.The form was verified against the matrix one before that one was removed: in float64 the forward pass agreed to 4e-16 and every gradient to 1e-14 — the gain, both factor logits and both decay logits included — across
temporal/spatial/bothxglobal/neuronx 1, 2 and 5 steps, under pulse and continuous injection, from a cold and from a populated buffer. -
The novelty gate damps a write by the presynaptic row it lands on, not by the individual synapse.
1 / (1 + rms(W_eff[j,:]))in place of1 / (1 + |W_eff|). An elementwise gate is the one term in the update that does not factor through an outer product, so the two changes are one change. It is also the better gate: on the record task's shape — sequential patch classification, plasticity on and attention off, where 3.1.0's plasticity A/B was run — it leads on both seeds at 10 epochs.seed per synapse per row 123 85.15% / 1.0616 86.22% / 1.0469 42 86.15% / 1.0364 87.02% / 1.0313 It is carried as running sums updated once per write rather than read back out of the trace, which costs
O(B * N)and adds nothing to the graph, because the gate is detached. -
convergence_hive_mindandconvergence_skill_transferreproduce unchanged. Every hop at 1.000, the pooling contract at 2.98e-08 against a memory scale of 0.270, a bee built after the foraging at 1.000, thehebb_type=Noneablation at chance.
- Plasticity no longer breaks under AMP on the second call.
einsumis on autocast's half list whatever its inputs are, so the trace's constants were being built in half inside an AMP forward pass — which took down the LLM shape (vocab projection, attention, plasticity, batches through the trainer) as soon as the persistent buffer was no longer zero. The constants and the buffer write-back now carry the float32 guard the step already had.
- Three properties keep the streamed trace affordable, and a refactor should keep all three. The history lives in a preallocated buffer and every contraction runs over all of it behind a mask, so every step has the same shapes and
torch.compilecan trace them; the buffer is written functionally rather than in place, which is what autograd and Inductor both require of a tensor a graph has read. The read is recomputed in the backward pass rather than held — sound because the history is immutable once written, and the mask must count retired writes rather than the decay position, or the then-live write is counted twice on replay. And only the newest write carries gradient, the decay logit seeing one live factor, which is the same truncation a matrix-held trace applied to its own history.
-
gradient_checkpointingnow covers the plastic trace, which is the only thing in the graph big enough to matter. The Hebbian update lived outside the checkpointed region: the flag recomputed the step and kept every(B, N, N)intermediate the trace produced, so on a plastic run it bought nothing at all — 6.16 units ofB x N²per step with it on against 6.17 with it off. Reading the trace, taking the step and writing the trace back are now one checkpointed region (_step_and_learn), so what survives a step boundary is the trace that has to cross it and the rest is recomputed.Measured at 256 neurons, batch 8, 32 steps, in multiples of
B x N²retained per step:default gradient_checkpointing=Truehebb_type='temporal'6.3 1.2 hebb_type='both'8.3 2.4 The recompute costs ~70% more time on the plastic step (512 neurons, batch 8, 96 steps: 260 ms against 450 ms), which is the trade to make when memory is what binds. Off the flag nothing changed: outputs, every gradient —
hebb_norm.weight, the factor and decay logits included — and the persisted trace buffers are bit-identical to 3.1.1 acrosstemporal/spatial/bothxglobal/neuron/synapse, with and without attention. The retained-per-step ratio is the same at every neuron count, batch andhebb_res; it drifts a little below the table on long rollouts (6.1 and 1.1 at 128 steps).torch.compilebehaves as it did before, which includes a standing limitation now worth stating where flags are chosen rather than only here: Dynamo does not trace a checkpointed region, so--compileand--grad-ckptare alternatives, not a pair.This was never a leak: allocation was flat across optimizer steps. It is the price of the 3.1.0 repair — the correlation carries gradient, so the whole per-example trace is an activation, and there are
steps x Bof them. What was wrong is that the one lever the library offers against it did not reach it.
- A plasticity advisory in
experiment_llm.py, alongside the vocabulary and attention ones: what the trace will cost, printed before it costs it, and what batch fits with--grad-ckptand without. Plasticity is the one term in this model that scales with the batch and the step count, and raising the batch for throughput is the natural move that makes it unaffordable —--batch 128 --hebb bothon the default 1024-neuron core, 96 echo steps, needs 649 GB for the trace, or 124 GB with--grad-ckpt.
-
A hive mind on the pooled plastic trace —
examples/advanced/convergence_hive_mind.py. Eight bodies run the same core and never touch: separate hidden states, separate attention caches, separate forward passes. What is between them is the persistent Hebbian buffer, which holds the batch mean of the live per-example traces and is handed back to every row at the start of the next call. That pooling exists so checkpoints, neurogenesis and transplants see one(N, N)matrix; run a colony as the batch and the same two lines are a shared memory — linear, superposable, gradient-free, and living on the shared core rather than beside it.Each bee is shown one edge of a ring over 8 symbols, drawn fresh every episode so the answer cannot sit in the weights, and nothing else. Every private carrier is then wiped — hidden state zeroed, attention cache reset — so after the wipe no body holds anything of its own, and one query symbol is put to each. One echo step walks one edge, which makes the hop count a reading of temporal depth:
320 queries per column hop 0 hop 1 hop 2 hop 3 together, one memory 1.000 1.000 1.000 1.000 apart, one bee alone 1.000 0.297 0.134 0.094 together, memory blank 1.000 0.147 0.141 0.112 Hop 1 is an edge another body observed; hops 2 and 3 compose two and three edges held by different bodies, so the colony is not only recalling. Chance is 0.125, and the apart column is the same weights on the same inputs with the bees run one at a time instead of together — 0.297 at hop 1 rather than 0.125 because one query in eight happens to be the bee's own edge. 21,280 parameters, 1,000 optimizer steps, final loss 0.0009.
The controls close the remaining doors. Move one bee's edge and a different bee's answer follows it (1.000), while that same bee run alone answers bit-identically (1.000) — separate forward passes have no channel to leak through, so the isolation needs no argument. Install another colony's memory and the answers follow the installed ring (1.000), not the ring that was asked about (0.147): what carries is the content of the trace, not its presence. Running the colony as one batch and running every bee separately then averaging the memories afterwards agree to 3.0e-08 against a memory scale of 0.317, so the batch axis is doing no secret work — the pooling is an average of independent bodies, and it could as well happen over a network. A body built after the foraging and never run before answers at 1.000 on the colony's memory: the knowledge is not in any bee.
Only the reading is trained. The study pass runs under
torch.no_grad()in every epoch, so the write is the architecture's own plasticity and never receives a gradient; what training shapes is a query that resonates with whatever the fixed rule wrote. Two protocol properties are load-bearing and were measured, not chosen: the study pass is two steps, because a cold start makes the first state a near-one-hot of the injected symbol and the temporal correlation written on the next step therefore lands on that symbol's row; and every evaluation call passescurrent_stateexplicitly, becauseforwardre-runsreset_stateon a batch-size change and that would zero the very memory the call exists to read.A six-step write measures the cost of getting that wrong: single-edge recall survives it (0.994) and the composition does not (hop 2: 0.503, hop 3: 0.194). Two and three steps both hold.
The channel is the temporal path, measured rather than assumed — same protocol, 1,000 steps on CPU:
hop 1 hop 2 hop 3 hebb_type='temporal'(shipped), seeds 42, 7 and 1231.000 1.000 1.000 hebb_type='temporal', attention off1.000 1.000 1.000 hebb_type='both', seed 42 / seed 71.000 1.000 1.000 / 0.200 hebb_type='spatial'0.169 0.134 0.122 hebb_type=None0.125 — — An edge is directed and
h_prev ⊗ h_tis too, which is why the temporal path carries the colony.h_t ⊗ h_tbinds a state to itself, holds no direction, and alone never leaves chance; carried alongside the temporal path it does not add to it — with'both'the deepest hop came out 1.000 at one seed and 0.200 at another, where temporal alone repeated 1.000 at three, and the loss curve stops bouncing (final loss 0.0009 against 0.4339). Attention is not a channel on this task at all: the cache is per body and is wiped with the state before the query, and the colony measures identically with it off. That leaves exactly one thing between the bodies, which is what the apart and blank rows already said from the other side.How deep the composition goes. Trained to seven hops instead of three, the same colony holds 1.000 through hop 4 and then falls away — 0.884, 0.775, 0.228. One echo step is one edge, so that is a reading of how far the pooled memory can be walked, and it is the same number the architecture's own thesis predicts should be there: depth is temporal.
-
Attention could not be
torch.compiled, and compiling is the largest speedup this architecture has. Attention runs insidetorch.amp.autocast(enabled=False)so its cache holds one dtype and the softmax accumulates in float32. Undertorch.compilewith AMP, Inductor miscompiles that region — its dtype model reportsfloatfor a buffer it emits asfloat16, ando_proj's matmul dies onexpected mat1 and mat2 to have the same dtype, but got: struct c10::Half != float. A source-level.float()does not repair it; the same wrong model folds it away.attendandwritenow carry@torch._dynamo.disable. Running attention in the ambient precision instead, so the whole step traces, was implemented and rejected: on embedded MNIST with four heads it trailed the float32 path by a stable ~0.017 of loss from the fourth epoch on (0.8220 against 0.8043 at epoch 8, same seed and initial weights), and it was not faster where it counts (17.6 ms/batch fully traced in half against 17.4 with the graph break, plasticity and four heads on). What the break does cost: with attention as the only addition to a bare core there is little left to fuse, and compiling buys close to nothing (48.5 ms/batch against 45.4 eager). -
output_scalereceived exactly zero gradient on every classification-shaped task.OdyssNetTrainer._extract_outputsread the rawfinal_stateon the last-step path while the sequence path readall_states, and onlyall_statescarries the output scaling. Both paths now readall_states. -
Hebbian plasticity was near-inert, and each reason was a defect rather than a property of the idea. Four changes, no new parameters to set:
- A normalized, zero-initialized gain (
hebb_norm, +N parameters). The strength factor multiplied twice — once accumulating into the trace, once applying it — making the effective gainsigmoid(logit)², roughly 32x below the measured optimum, with nothing able to move it. The trace is now RMS-normalized and scaled by a learnable gain: the factor decides which synapses are plastic, the gain decides how much. The gain starts at zero and construction draws no RNG, so a plastic model and a plain one at the same seed share a core and agree at step zero —hebb_typeis a one-variable ablation. - A differentiable correlation. Two stop-gradients sat on this path: one hiding the correlation's dependence on the state, one truncating the trace's own history. Only the second costs memory and only the first costs capability, so the first is gone. On episodic recall that alone recovers three quarters of the gap for the same bytes (0.8321 → 0.9550).
- A novelty gate,
1 / (1 + |W_eff|): co-activation across an already-strong synapse says the weight fired the neuron, not that the pattern is new. Parameter-free and detached, so it opens no second-order path throughW. - A per-example trace. The live trace is
(B, N, N). Shared across a batch the associations average away and a batched forward computes a different function than the same examples run singly (9.5e-3 against 2.98e-7), so training never exercised the mechanism inference uses. The persistent buffer stays(N, N)and holds the batch mean, leaving checkpoints, neurogenesis padding and weight transplants untouched. Memory now grows assteps x B x N².
The claim is that plasticity works, not that it wins. The gain trains off zero to 3-4% of
W's scale where the old trace perturbedWby ~0.1% of its RMS and sat exactly on the no-plasticity control. Every controlled comparison since:- Sequential patch classification, attention off, one seed: ahead — 1.0088 / 87.99% at 480 parameters against 1.0548 / 87.41% at 430, epoch 31.
- The same task with four attention heads held fixed, two seeds: behind, consistently. Epoch 10, seed 42: 0.9642 / 88.92% best against attention-only's 0.9442 / 89.43%; seed 123: 0.9588 / 89.71% against 0.9356 / 90.22%. Both seeds agree in sign and magnitude (+0.02 of loss, -0.5 points for +50 parameters).
hebb_res='synapse'does not rescue it: 1,044 parameters to draw level with attention-only's 634. - Single-injection classification (embedded MNIST), one seed: slightly behind — 0.8311 / 92.36% against 0.8237 / 92.89% at epoch 8.
Plasticity earns its place where step T extends what step T-1 built and acts as overfit noise where each step handles an independent chunk. Attention occupies the same role on sequential classification and fills it better, so the two are alternatives more than complements.
- A normalized, zero-initialized gain (
-
Plasticity was slow for a reason unrelated to plasticity. Profiled on the record configuration (10 neurons, batch 32, 16 steps): 5,845 kernel launches per step for 20 ms of GPU work, 39% of CPU time inside
cudaLaunchKernel. Temporal and spatial now run on one stacked path axis, sohebb_type='both'costs one set of kernels instead of two (4,853 launches, 42.0 → 32.8 ms eager), and with the compile bug fixed the whole step fuses: plasticity adds 132% to the step eager and 26% compiled (baseline 13.8 → 6.4 ms,'both'32.1 → 8.1 ms,'both'with four heads 62.7 → 17.3 ms). The 1/N correlation scale now rides in the_offdiagbuffer, and W's rows and the memory vector are normalized in one call.
-
convergence_mnist_record,convergence_mnist_tinyandconvergence_mnist_reverse_recordrun temporal attention in place of Hebbian plasticity, on seed 123.convergence_skill_transferruns both mechanisms. Three choices behind those configurations:- Head geometry is sized to the core. Attention's projections scale with the neuron count, so the four heads that cost 204 parameters on record's 10-neuron core would cost 8,288 on tiny's 59 and take that example from 3,717 parameters to 12,005 — tripling the budget of a script whose whole question is what a fixed small budget can do. Tiny gets one head of width 4 (+952) instead.
- All three write to the cache every thinking step. On reverse-record this is required rather than preferred: the input is a single scalar spread over 21 thinking steps, so the token ratio is 21 and the default
attn_write='token'would leave exactly one cache entry, written at the last step, with nothing to attend to before it. On record, at one patch per step,'step'and'token'are the same thing. - Attention is what carries an early input to a later one in all three — the role plasticity held before, and measured better at it above.
-
Measured results after the change, 100 epochs each:
Example Parameters Result convergence_mnist_record634 (430 core + 204 attention) 89.89% final, 90.91% peak (epoch 68) convergence_mnist_tiny4,669 (3,717 core + 952 attention) 96.05%, reached at epoch 23 and held to the digit convergence_mnist_reverse_record728 final loss 0.6522 convergence_skill_transfer51,796 (6.7% transplanted) multiply: transplanted 0.016486 against scratch 0.028157 README figures follow these. The 480-parameter and 484-parameter headlines they replace were measured on configurations the scripts no longer run.
-
Decorative convergence plots removed from both READMEs, keeping only the reverse-record sample grid, which shows output rather than a curve. Charts of numbers that move need re-rendering on every change; the numbers themselves are in the tables.
-
CONTRIBUTING'sset_seed(42)rule now permits a documented deviation, which the three examples above use.
--compileinexperiment_llm.py. Compiles the boundforwardrather than wrapping the module, so state, KV caches, plastic buffers and checkpointing keep reaching the real object. Warmup costs a minute or two, longer with--hebband attention on.TestHebbianRepaircovers the four properties above: plasticity is an exact no-op at init, the gain receives gradient, a batched forward equals the same examples run singly, the two mechanisms stay distinct across the shared path axis, and everyhebb_type×hebb_rescombination trains.- A test pinning
hebb_norm.weightto a weight-decay-free optimizer family. It reachesmodulationby falling through the classifier rather than by name; a family with decay would pull a zero-initialized gain back to zero and switch plasticity off silently.
- Temporal attention: the state queries its own past, on the only axis this architecture has. A transformer stacks attention between layers; OdyssNet has none, so
attn_heads=4attaches it along time instead — at every thinking step the state issues one query over a cache of the states written before it, and the result is added to the same pre-activation signal the recurrence, the memory feedback and the input injection feed, so the step's activation and RMSNorm bound it exactly like everything else. Off by default (attn_heads=Nonebuilds no module, no parameters, no cache and no per-step cost, and leaves the state dict byte-identical to a 2.x model's). Switching it on changes nothing, on purpose.o_projis zero-initialized and the module is constructed after the core is initialized so it draws no RNG the core would otherwise have consumed: two models built at the same seed, one with attention and one without, have the sameWand produce the same output until training moves the attention weights (test_attention_is_a_no_op_at_init,test_core_weights_identical_across_the_switch). An ablation of attention therefore has one variable in it, and no known-good initialization story —resonant, edge of chaos — has to be re-validated to try it. The query length is always 1, because the core cannot be unrolled in parallel: step t's input is step t-1's output. A forward pass is a sequence of single-query attentions — a transformer's decode phase, never its prefill — so the score matrix is(B, H, 1, L), one row per key. There is nothing there for a fused flash kernel to tile away;F.scaled_dot_product_attentionsolves a problem this shape does not have. What is expensive is re-materializing K and V, so the carried history and the current call's writes are attended as separate segments and joined at the scores, under a single softmax that is exactly what one softmax over the concatenated keys would give (test_split_segments_match_one_softmax). - The branch is divided by the square root of its own width, which turned out to be the difference between attention helping and attention destroying the network.
o_projstarts at zero and every optimizer here is Adam-family, so its magnitude after k steps is set by the step size rather than by the gradient —|o_proj| ~ k·lrwhatever the width — and the contribution it produces grows assqrt(heads·head_dim)unless something stops it. Measured on multi-query associative recall at 128 neurons (2 pairs, 400 steps, chance 6.2%) with the division absent: no attention 54.6%, attention witho_projfrozen at zero 54.1% (confirming the module is inert as designed), 1 head × 16 → 54.6%, 4 heads × 64 → 5.9%. The wide branch had reached the same|o_proj|as the narrow one and, through four times the width, drowned the recurrence it was meant to assist; the failure was invisible to every knob that sounds relevant (attn_window=1collapsed too, so it was never about the history). With the division in place every width lands on the baseline — 54.1% to 56.5%, 8 heads × 64 included. Same reasoning as GPT-2's1/sqrt(2·n_layers)residual-branch initialization, with steps in place of layers. - A KV cache in two representations, switched automatically, tested to agree. Under
torch.no_grad()the cache is a preallocated(B, H_kv, window, D)ring written in place through a narrowed view — allocation-free decoding, which is the point of a KV cache. Under autograd, where in-place writes are illegal, the carry from previous calls is one frozen tensor (saved once for backward however many steps read it) plus a differentiable list of this call's writes. Both evict oldest-first atattn_window, by slicing rather than copying, and produce the same numbers at every window size tested (test_ring_and_segmented_paths_agree). Feeding tokens one at a time matches feeding them together to float tolerance (test_incremental_decode_matches_one_shot), so generation and scoring are the same computation. Two properties make the ring possible and are worth stating because they are what a transformer does not have here: every cached entry is strictly in the past (the write for a step happens after its read), so no causal mask is ever needed; and softmax over keys is permutation-invariant, so a wrapped ring never has to be reordered. Position lives inside each key instead — RoPE is applied at write time against the entry's absolute index, computed on demand in float64 rather than from a table, because a stateful stream reaches positions where float32 has already lost the phase of the fastest-rotating dimension. - Grouped-query attention, with
attn_kv_heads=1(multi-query) as the default. In a step-sequential model the cache, not the projection matrices, is the term that decides whether a batch fits, and sharing one KV head across the query heads divides it byattn_heads.attn_kv_heads=attn_headsgives classic multi-head. The grouped case needs no key repetition: the query's group axis plays the role a query-length axis would, and broadcasting over it is what sharing a KV head means. attn_writeandattn_read— the two knobs that actually set the cost.attn_write='token'(default) records one entry per input token, so extra thinking steps do not multiply the cache's length;'step'records every step, letting the model attend to its own intermediate reasoning at(gap+1)xthe cache.attn_read='step'(default) queries every step,'token'only on the step a token arrives — leaving the echo steps to run on state alone, exactly aspulse_modedoes with the input itself. The query, not the entry, is whatthink_gapmultiplies, which makesattn_readthe cheaper end of the trade whenever the gap is above 0 (~1.3x throughput atthink_gap=1) and a no-op at 0.model.reset_rows(mask): zeroes the hidden state and the attention history of selected batch rows, leaving the rest of the batch running. Staggered cold starts need both — a row whose state is zeroed while its KV history survives is neither cold nor warm, and the cold start being simulated would not be one. Zeroing the cache rows is sufficient and is why the ring needs no per-row bookkeeping: an all-zero key row spreads its softmax uniformly over all-zero values, so its attention output is exactly zero, which is what an empty cache gives it (test_reset_row_attends_to_nothing). The LLM harness now uses it in place of a masked fill onmodel.state.attentionparameter family in ChaosGrad, checked beforeprojections(whichq_proj/k_proj/v_proj/o_projwould otherwise match on the substring alone) and carrying the same weight decay as other connective structure. The QK-norm gains inside the same module go tomodulationinstead, where nothing decays them — they are not connective structure and their zero is not a neutral point.- A fifth
weight_initslot for the attention projections:['quiet', 'resonant', 'quiet', 'zero', 'quiet']. Appended rather than inserted, and shorter lists are still right-padded from the defaults, so every four-entry list written against 2.x means exactly what it meant. The slot covers query/key/value only — the output projection is always zero, since that is the property the whole "free to switch on" story rests on. - Neurogenesis grows the attention projections along their neuron-facing axis, with the same asymmetry the core uses: what the new neurons emit (the q/k/v input columns) starts as small noise so gradients can reach them, what they receive (the
o_projoutput rows) starts at zero so existing dynamics are undisturbed. Optimizer state is padded top-left like every other parameter, and the cache is dropped outright — every entry in it was written by projections that no longer have that shape. experiment_llm.pyexposes all of it on the command line —--attn-heads,--attn-kv-heads,--attn-head-dim,--attn-window,--attn-write,--attn-read,--attn-rope/--no-attn-rope,--attn-qk-norm/--no-attn-qk-norm,--attn-dropout— validated before the corpus is tokenized and CUDA is initialized, so a typo costs a second rather than a tokenization pass. Shape-bearing fields (heads,kv_heads,head_dim,qk_norm) are adopted on--resume; the rest change no tensor and stay yours to re-choose.attention_advisory()prints the head geometry, the parameter count and the keys and values the backward pass will keep before training starts, because that term is quadratic in the writes inside one truncated-BPTT window and is otherwise discovered as an OOM. A new--sweep attnpreset measures the whole thing against anoffarm that is the 2.x architecture exactly, and--mode smokegained three attention variants plus a second checkpoint round-trip, since attention adds six tensors that a resume must not silently drop.
- Attention is launch-bound, not compute-bound, and that is the headline cost. Profiled at 1024 neurons / batch 128 / chunk 48 /
think_gap=1on an RTX 3060 Ti: 24,884 kernel launches per training step, 605 ms of the 1.59 s CPU total insidecudaLaunchKernelagainst 96 ms of actual GPU work per step. One head costs the same as four (26,286 vs 27,175 tok/s), which is that regime's signature rather than a rounding error. Two fixes followed from the profile and are in the shipped code: attention runs with autocast disabled — a query of length 1 gains nothing from fp16, while the implicit casts around it cost more than the math, and the cache then has one dtype in training and inference instead of two — and the segments are joined at the scores instead of being attended separately and merged by their log-sum-exp afterwards. Together they took batch-128 attention from 18,376 to 27,175 tok/s (+48%) and cut peak memory from 0.83 to 0.76 GB. - Attention learns more per token and costs more per second; both halves are measured.
--sweep attnon TinyStories (1024 neurons, 2.6M params, vocab 2048,think_gap=1, batch 128, RTX 3060 Ti) at equal tokens — 600 steps, 3.69M tokens, same seed,offbeing the 2.x architecture exactly: mqa4 18.57 ppl, read_token 19.75, mha4 21.40, off 22.19. Every attention arm wins, the default four multi-query heads by 16%. At equal wall-clock — 2 min/arm — the ranking inverts because the arms no longer see the same data: off 14.55 ppl on 10.91M tokens, read_token 17.75 on 4.19M, mqa4 20.11 on 3.16M, no_rope 21.27, window32 23.47, mha4 27.38, write_step 32.16. Which of the two comparisons applies depends on whether a run is token-limited or time-limited; on a 2.6M-parameter core on a consumer GPU the clock binds, and the per-token advantage is the half that scales with hardware and core width. 0.0% collapsed cold starts in every arm, attention on or off. - Where it did not help: synthetic associative recall. The obvious hypothesis for what content addressing should buy is key/value recall, and on a multi-query recall probe (128 neurons, 16 keys, 16 values, one query at the end, chance 6.2%) attention does not deliver it at this scale. 4 pairs / 1500 steps: no attention 31.6%, multi-query 30.0%, multi-head 30.3%. 8 pairs / 2500 steps: no attention 17.0%, multi-query 11.6%, multi-head 10.9%. That probe is what caught the width defect above, so it is a sensitive instrument rather than a broken one; it simply does not show the win. No example script ships claiming otherwise. The measured advantage is the per-token one on language modeling, and it stays a per-token advantage until the hardware makes the per-step overhead disappear.
- The remaining overhead is per step, so batch amortizes it. Off vs on (4 heads, multi-query), same configuration: batch 64 — 46,160 vs 13,265 tok/s (3.48x); batch 128 — 91,803 vs 26,530 (3.46x); batch 256 — 162,014 vs 52,838 (3.07x); batch 512 — 280,870 vs 98,236 (2.86x). With
--attn-read tokenthe batch-512 figure is 125,388 tok/s (2.24x). Measured on a settled GPU: the same benchmark run immediately after 40 minutes of continuous load reported 55-166k tok/s on theoffrows, so those absolutes are this card's thermal state as much as this code's — the ratios survived it, the throughput did not.
- Version 3.0.0 across
odyssnet/__init__.py,pyproject.tomlandCITATION.cff.TemporalAttentionis exported from the package root for expert use; nothing in the 2.x API changed shape or default, and all 306 pre-existing tests pass untouched (55 new ones cover attention).
--resume-best: recover from a diverged run without editing files by hand.--resumereads<tag>_latest.pth, which is rewritten at every evaluation and again on interrupt, so a loss explosion overwrites it within one eval interval — and resuming then reloads the diverged model.<tag>_best.pthis written only inside theval_loss < best_valbranch, so it structurally cannot hold a diverged model; the recovery was always available but only ascopy _best.pth _latest.pthat a shell prompt, which is exactly the step someone loses a training run by not knowing. The flag implies--resumeand rewinds the step counter, per-row stream positions and optimizer state to that checkpoint, with the next evaluation repairing_latest. Architecture adoption reads the file the run will actually load, not_latest, or the rescue would rebuild from the wrong shapes and fail the strict load it exists to survive. It refuses to start when the tag has a_latestbut no_best: falling back to a fresh model there would overwrite the one surviving checkpoint at the first evaluation, which is the destruction the flag was invoked to avoid. With neither file present there is nothing to protect, so it starts fresh like--resumedoes. Verified end to end on a scratch tag: two checkpoints carrying distinct marker weights confirm--resume-bestloads_bestand plain--resumestill loads_latest, plus both branches of the missing-_bestcase.
--grad-persistenceexposes the trainer's "ghost gradients": a fraction of the previous step's gradient is added to the next one. The mechanics are worth knowing before using it — the carry is injected after AMP unscale and before clipping, then re-captured from the clipped result, so it is a bounded geometric series rather than something that can run away, and it stacks on top of ChaosGrad's own Adam-style momentum rather than replacing it. That makes it a second-order knob: measured over six identical steps the weights differ by ~9e-07, because D-adaptation renormalizes the step size and absorbs much of the change. Range is capped at the trainer's documented 0.0-0.9; at 1.0 the carry stops decaying, which is a divergence rather than a setting. One inherited behaviour is called out in--helpbecause it is surprising and would not be discovered otherwise:--resumecannot switch ghosting off. While the flag is left at 0,OdyssNetTrainer.load_state_dictrestores the checkpoint's stored fraction and re-populates its accumulated ghost buffer, so a run the user believes has ghosting disabled resumes with both the setting and a warm carry. Verified directly — resuming a 0.5 checkpoint with--grad-persistence 0came back at 0.5 with 9 ghost tensors reinstated. Library behaviour is left unchanged; the flag documents it, and the escape is a positive value or a new--tag.
--injection {pulse,continuous}exposespulse_mode, which the LLM harness had been fixing atpulsewithout ever saying so. It decides whether a token's embedding is injected only on the step it arrives — leaving the--think-gapecho steps to run on recurrent state alone — or is held across every step. Verified to be a no-op at--think-gap 0and to diverge increasingly above it, which makes it a knob about what the thinking steps actually think on, and therefore closer to this architecture's central claim than most of the flags that were already exposed. It is inARCH_FIELDSsoeval/genreproduce a checkpoint's setting, and deliberately not inRESUME_FIELDS: likethink_gapit is a forward-pass argument that cannot change the state dict, so pinning it on resume would remove a knob rather than prevent an error.--activation ENC,CORE,MEMand--weight-init ENC,CORE,MEM,GATE, both previously reachable only by editingCfg. The core's initialization strategy sets the initial spectral behaviour the whole architecture is built around, and thearchsweep never varied either. Both are validated against the model's accepted names — nine activations, thirteen init strategies — with arity checked, before the corpus is tokenized. Notenonemeans identity for--activationbut no gate at all for--gates; the help text says so, since the same word doing two things across neighbouring flags is exactly the kind of thing that costs an afternoon.--val-batchsets how many shards the validation split is cut into, which is also the number of independent cold starts each score averages. It changes which corpus positions get scored, and only some positions fall into the absorbing state documented under 2.6.2's known issues — so raise it to probe cold-start robustness and keep it fixed when comparing runs.
--gateson the command line. The three gate activations — input/output scaling, the core signal, and memory feedback — were reachable only from inside a sweep preset, so thearchsweep's best arm could be measured but not trained.none,sigmoid,identitybeat the default on both held-out loss and tokens consumed (2.6204 against 2.6495 at 25% fewer tokens), which made it the one configuration a user would most want and the one they could not select.nonedisables a gate outright and creates no parameter for it, so the flag changes the model's parameter set rather than only its behaviour; the values are validated against OdyssNet's accepted activations before the corpus is tokenized, so a typo costs a second instead of a tokenization pass and a CUDA init.
--tokenizer byte: one id per byte, 256 ids, nothing to train and nothing to pin. Chosen for a different reason than the other options rather than as a cheaper one. It is the only vocabulary under which a character is reliably its own token, which is what character-level work requires — a subword merge hides the units the model has to manipulate, so a model that has only ever seen100as a single id has learned nothing about the three digits inside it. It also makes the vocabulary table negligible: 0.20M parameters against the 1.05M core at 1024 neurons, where the default 2048-token BPE costs 1.57M and is the larger half of the model, leaving the chaos core as most of what is being measured. The price is sequence length — 1.0 byte/token against the trained BPE's 3.6 on TinyStories, so the same text is ~3.6x more timesteps and this architecture is latency-bound on sequential steps; it is a deliberate choice for tasks where the token boundary matters, not a general default. Decoding tolerates a window that begins partway through a multi-byte character, which is normal when scoring fixed-size token windows and must not raise.
--tokenizerin the LLM example: a ready-made tiktoken encoding, or the corpus-trained BPE.--tokenizer {gpt2,r50k_base,p50k_base,cl100k_base,o200k_base}skips the vocabulary-training pass entirely — nothing to train, nothing to pin, notokenizersdependency, and a 46 MB corpus tokenizes in about a second.--tokenizer bpe(the default) is unchanged. Both backends sit behind one smallTokenizerinterface (encode/encode_batch/decode/get_vocab_size/bos_id), so the cache builder, validator and generator never touch a backend directly. The tiktoken path usesencode_ordinary, notencode: the latter raises on text containing a special-token string such as<|endoftext|>, and a corpus is text rather than a prompt template.- The default stays a 2048-token in-domain BPE, and the reason is now measured rather than asserted. On a held-out 906 KB slice of
tinystories.txt, bytes per token: trained BPE 1024 → 3.079, 2048 → 3.601, 4096 → 3.908; tiktokengpt2(50,257) → 3.914,cl100k_base(100,277) → 4.146,o200k_base(200,019) → 4.190. A 4096-token corpus BPE therefore matches GPT-2's 50k vocabulary to within 0.2% at one twelfth the ids, because an in-domain vocabulary spends every merge on text the model will actually see. The industrial answer is a large vocabulary (Llama 3 128k, Qwen ~151k, Gemma 256k, and even Mistral/Phi-3/SmolLM at 32k–49k) but it does not transfer: those models are ≥2048 wide, so the table amortizes, whereas at this example's defaultscl100k_basebuys 6% more compression for 24× the embedding parameters — 2.6M parameters becomes 78M, of which 98% is lookup table, and thebatch × chunk × vocablogits tensor alone is 4.9 GB in fp32 at the documented batch 256 / chunk 48. The tiktoken path exists for the cases where that trade is the right one (a standard vocabulary to compare against, a larger core, or simply not wanting to wait for a training pass);--helpcarries the table. vocab_advisory()prints the parameter split (embedding table vs chaos core) at the top of every non-quiet run, warns when the logits tensor passes 0.5 GB with a concrete--batchsuggestion, and warns when the vocabulary table exceeds 90% of the parameters. Advisory, never a hard limit — a large table is the correct call on a large core, and it is the silent version of this trade that costs an afternoon.
--resumedestroyed the run it was asked to continue whenever an architecture flag was omitted.adopt_saved_archwas only wired intoeval/gen, so a resumed run rebuilt the model from CLI defaults:--mode train --tag v4k --vocab-size 4096followed by--mode train --tag v4k --resumereverted to vocab 2048, loaded the wrong token cache, failedload_checkpoint(strict=True)on the shape mismatch, and hitrun_session'sexcept Exception: starting fresh— which then trained a randomly initialized model and saved it over the trained checkpoint at the first validation, since--resumedeliberately bypassesguard_overwrite. Reproduced end to end, including the clobber. Resume now adopts the checkpoint's architecture before the corpus is loaded, exactly aseval/gendo (which also closes the same latent hole for--neurons,--n-inand--n-out, predating this release). It adoptsRESUME_FIELDS— the subset that decides whatbuild()allocates or which corpus is read — rather than all ofARCH_FIELDS: pinningthink_gap,activationandweight_inittoo would have quietly removed a knob, and continuing a run at a different temporal depth is a legitimate experiment here (--resume --cold-start-every 0is already documented as a mid-run change). The split is drawn on what can actually change the state dict, checked rather than assumed: all nine accepted activations are parameter-free (_build_activationreturns only stocknnmodules, so the state dict is identical for each),weight_initis dead once weights load, andthink_gapis a forward-pass argument — whilegatesdoes add or removecore_gate,input_gate,output_gateandmemory_gate, and is therefore adopted.--resume --think-gap 3is verified to keep gap 3 while still adopting the checkpoint's shape fields.eval/gencontinue to adopt everything, since there the goal is to reproduce how the checkpoint was scored.- A failed checkpoint load now stops instead of continuing. "Starting fresh" was never a graceful degradation on this path — the only way to reach it is a checkpoint that exists and will not load, and continuing past it overwrites that checkpoint with an untrained model. Loading the weights is now fatal on failure (
SystemExitnaming the error, offering--tag <name>_v2or an explicit--overwrite; verified to exit 1 and leave the file md5-identical). The guard is deliberately narrow: restoring the trainer's state runs outside it and only warns, because that payload is the piece most likely to gain or lose keys between versions — 2.6.3 fixed exactly that for the optimizer — and a failure there costs counters rather than weights, with nothing trained yet. Verified with a checkpoint whosetrainer_state_dictwas made unreadable: the run warns, keeps the loaded weights, step count andbest_val, and continues. --mode eval/--mode genloaded the corpus before adopting the checkpoint's architecture.adopt_saved_archran afterload_corpus, so a checkpoint trained at a non-default--vocab-sizewas scored against whatever corpus the command line happened to name — the tokenizer and vocabulary size decide which token cache is read. Pre-existing, and--tokenizerwould have widened it from "a forgotten flag" to "the wrong tokenizer decodes the output into fluent-looking garbage, silently". The adoption now happens before the corpus is touched;--mode gen --tag <t>with no flags at all correctly rebuilds a tiktoken checkpoint's tokenizer from the file.- A missing tokenizer file made an existing token cache unusable, and the example crashed on it.
data/token_cache/outlivesexamples/advanced/ckpt/, so cleaning the checkpoint directory left caches whose pinned BPE was gone;load_corpushit its cache branch, calledget_tokenizerwith nothing to train on, and raisedFileNotFoundError—--mode smokeat the defaults was dead on any machine in that state. The cache is now invalidated and rebuilt alongside a fresh tokenizer, with a message saying so. Retraining the tokenizer next to the old cache was tried first and is wrong: BPE training is deterministic given identical input (two runs in one process produce identical vocabularies), but nothing records what an existing cache's tokenizer was trained on, and measured against the cache indata/, retraining today's 2048 vocabulary re-encodes the validation half to 414,805 tokens where the cache holds 414,778 — close enough to look fine, different enough that every id is suspect. Rebuildingtinystories.txtat vocab 2048 under a freshly trained tokenizer yields 12,301,738 train / 414,805 val tokens against the previous cache's 12,301,242 / 414,778; the 2.6.2 reference run's exact figures are defined against the older pairing and are not reproducible without it. - The cache stamp now includes the tokenizer, so switching backends cannot read the previous one's ids. The trained-BPE stamp keeps its historical
<corpus>.v<size>.val<chars>form byte-for-byte — existing caches, and the numbers measured against them, stay valid — while tiktoken caches are named by encoding, which cannot collide withv<digits>. --vocab-sizenow defaults to a sentinel rather than to2048, so passing it alongside a tiktoken encoding says the flag is being ignored instead of quietly appearing to do something.Cfg.tokenizeris part ofARCH_FIELDSand round-trips through checkpoints.generate()no longer falls back to a hardcoded token id 0 when a prompt encodes to nothing; it asks the tokenizer (bos_id), because id 0 is<s>in the trained BPE and an ordinary byte token in a tiktoken encoding.
-
ChaosGrad's spike brake released itself while the loss was still climbing. The
brake_ceilingrelaxed geometrically toward 1.0 on everyreport_losscall, conditioned on nothing — observed on an LLM run (batch 1024,lr=None): the brake fired at step ~18,650, and by ~18,850 the run had its full step size back while the loss was still rising past 5.5; it then diverged to 100% collapsed cold starts. A slow climb is exactly the shape the release logic couldn't see: each step stayed under the 1.2× ratio test against the post-spike reseeded EWMA, so no new spike fired, and 200 quiet calls undid the brake mid-divergence. The pre-spike EWMA is now kept per-group as a recovery reference (brake_ref, round-trips throughstate_dict, carried through neurogenesis next tobrake_ceiling): the fast release only runs once the loss EWMA is back inside the reference band (brake_ratio-wide, floored at the loss stream's own σ so an asymptotically-approached reference can't hold the brake hostage), and while the loss remains elevated the ceiling merely crawls (0.9995/call, ~1400 calls to undo one hit) so a genuine regime shift — a loss that legitimately settles higher — eventually frees the step size instead of being pinned forever. Repeated spikes keep the lowest reference seen: a staircase divergence must recover to the original healthy level, not the previous stair. Replayed against the divergence-shaped stream (calm → spike → 200-call slow climb → recovery): old ceiling back at 1.0 within 100 climb calls, new ceiling held at ~0.52 through the climb and fully healed within 100 calls of actual recovery. Fixed-rate mode (lr=<float>) remains untouched by the brake, as documented. Scope, stated precisely: this removes the release-mid-climb mechanism that preceded the observed divergence — it has not yet been shown end-to-end that the held step is small enough to stop that particular run (anlr=Nonere-run at the failing config is the missing measurement), and the silent absorbing-state entry under Known issues (2.6.2) has healthy training loss throughout, so it remains out of any loss-watching brake's reach by construction. -
Loading an older checkpoint into a newer ChaosGrad silently dropped newly-added per-group keys.
torch.optim.Optimizer.load_state_dictreplaces the group dicts wholesale with the saved ones, so any key added todefaultsafter a checkpoint was written vanished on load and resurfaced as aKeyErrormid-training — the exact gap that shipped once before with the brake-config fields (2.6.1).load_state_dictis now overridden to backfill missing keys fromdefaults;rms0(computed at construction, deliberately not indefaults) is preserved from the pre-load groups so the traction anchor keeps pointing at the initial weight scale. -
OdyssNetTrainer's anomaly spike detector zeroed its variance estimate after every spike — the same degenerate-σ bug fixed in ChaosGrad's brake in 2.6.1 (a trivially-satisfied 3σ test for the next ~20 calls, spamming theanomaly_hook). Now retains the pre-spike variance, mirroringreport_loss. -
Switching a fixed-rate ChaosGrad history to adaptive mode crashed with a
KeyError. Fixed-rate mode never creates the estimator states/p0, so a checkpoint trained underlr=<float>whose groups were later set back tolr=Noneblew up on the first adaptive step. The estimator state is now lazily initialized at the switch, with the distance reference point at the current (warm) weights. -
experiment_llm.pyprintedlr autoon resumed runs that were actually in fixed-rate mode:load_checkpoint(lr=None)means "don't touch", so a fixed-rate checkpoint stayed fixed while the banner claimed auto — and since "auto" was also the CLI default, there was no way to say "actually return this checkpoint to the estimator" at all.--lrnow defaults tokeep(fresh runs use auto;--resumekeeps the checkpoint's stored mode), an explicit--lr automoves a fixed-rate checkpoint back to the online estimate (possible now that the fixed→adaptive switch above no longer crashes), and the banner reports the optimizer's actual mode, noting when the checkpoint's mode differs from the CLI's. -
experiment_llm.pycould not tokenize a corpus larger than roughly a tenth of available RAM. The cache builder read the whole file into a string, split it, re-joined it into blocks, and accumulated token ids in a Python list — boxed ints at ~36 bytes each. Measured ondata/wikisent2.txt(934 MB): the old path was killed at 46.4 GB resident and still climbing; the streaming replacement finishes in 84 s at 0.81 GB peak (417.5M tokens). The file is now read line-by-line in binary from a byte range, encoded in batches, and written straight through to a flatuint16file that is memory-mapped for training, so peak cost is one encode batch regardless of corpus size. Reading a byte range instead of a decoded string moves three boundary concerns into the caller's hands; the streaming rewrite got each of them wrong before it got them right, so they are recorded here as guidance rather than as discoveries. Line endings: binary reads preserve the CR of a CRLF file and a byte-level BPE makes every one its own token — one wasted token per line, ~5% oftinystories.txt, and a silent shift of every metric against a text-mode tokenization; newlines are normalized at the single decode site. Split alignment: searching raw bytes forb"\n\n"never matches CRLF (b"\r\n\r\n"), and three of the four corpora indata/are CRLF or have no blank lines, so a paragraph-aligned split lands mid-line and potentially mid-UTF-8; aligning on a bareb"\n"is correct under both conventions, whereas a preference-ordered separator list matched at the end of the search window onwikisent2.txtand produced an empty validation set. Budget:val_charsis capped at a fifth of the corpus so a small file plus the default budget cannot leave an empty training half. -
The token cache would have silently corrupted any vocabulary above 65535 ids. Ids were written as
uint16unconditionally, and numpy does not warn on an out-of-range cast — a tiktoken-scale vocabulary (o200k_baseis ~200k) would have wrapped around into plausible-looking garbage with no error anywhere in the pipeline. The element width is now chosen from the vocabulary size, recorded in the cache filename (a flat token file has no header, so readinguint32bytes asuint16is not detectable after the fact), and the ids actually produced are range-checked per block — special tokens are not always inside the nominal vocab size, so the declared size alone is not trustworthy.
- Token cache format is a flat
.bin(memory-mapped,uint16oruint32) rather than.npy. Pre-existing.npycaches are still read and take priority, so numbers already measured against one stay valid; delete the.npypair to re-tokenize under the new scheme. Ontinystories.txta fresh cache yields 12,301,242 train tokens against the old 12,294,433 (0.06%), and the reference checkpoint scores val loss 2.0791 (ppl 8.00) on it versus 2.0996 (ppl 8.16) on the original cache. Those two numbers are not comparable — same weights, different held-out split — and the published reference stays defined by the original cache.
cold_start_every(default 32) in the LLM example: during training each batch row's hidden state is independently zeroed with probability1/cold_start_every, giving an exponential spread of context lengths and keeping cold and warm contexts mixed in every batch. Without it the carried state is never reset, so gradient descent puts no pressure on cold-start behaviour at all. Measured at 1500 steps / batch 128 / same seed:cold_start_every=0collapsed 15.6% of held-out shards (pooled val loss 39.55, ppl 1e13) while its training loss read a healthy 2.689;cold_start_every=32collapsed 0.0% (pooled val loss 2.68, ppl 14.60) at training loss 2.706. The failure is removed at no measurable cost in fit quality.- Reference run for the rewritten LLM example (
--mode train --minutes 25 --batch 256, 2,625,280 params, RTX 3060 Ti): held-out loss 2.2009, ppl 9.03, bits/byte 0.8764, median cold start 2.3154, 0.0% collapsed cold starts, 226.15M tokens at 150,761 tok/s. Not a README-advertised metric — a reproducible baseline for the harness, quoted with the exact command that produces it. - Cold-start collapse is now a measured quantity.
examples/advanced/experiment_llm.pyreportsval_p50(median per-shard loss — what the model does on a typical cold start) andval_coldfail(fraction of shards that collapsed) alongside pooled loss/PPL/bits-per-byte. The pooled mean alone is unreadable when this failure mode is present: a handful of collapsed shards at ~240 nats swamp it and the run looks like a global divergence when most shards are healthy.
examples/advanced/experiment_llm.pyrewritten. Was an unconditional infinite training loop with a blockinginput()prompt on architecture mismatch, HuggingFace streaming (network-dependent, non-reproducible ordering,MAX_START_SKIPrandomness),exp(label_smoothed_loss)reported as "perplexity", and best-checkpoint selection on training loss over a streaming corpus. Now--mode {smoke,sweep,train,eval,gen}over a local uint16 memmap token cache, with a held-out split, unsmoothed-CE perplexity, and compute-matched sweep presets (gap,arch,batch,coldstart,optim,window). TBPTT chunk 5 → 48 tokens: at chunk 5 a 512-token batch spent 103 full ChaosGrad steps over the 4.2M-element core, so the optimizer cost more than the model did.- The LLM example is now zero-config (
lr=None, ChaosGrad's estimator) instead of the pinned1e-4it carried since 2.6.0;--lr <float>still selects fixed-rate mode. The reference run above was measured under the automatic estimate. CLAUDE.md's convention list, which named the LLM example among those keeping an explicit rate, is updated to match. Note the estimator pins at its traction cap (~3.9e-3) within ~300 steps and stays there for the whole run — theoptimsweep preset exists to compare that against pinned rates, but has not been run at a meaningful budget, so no claim is made that auto beats fixed here. experiment_llm.pyvalidation runs in fp32 with TF32 disabled — not because precision was ever implicated (AMP on/off and TF32 on/off were measured to agree to four decimals) but because eval throughput is irrelevant and a metric used to rank sweep arms should not move with kernel choice. What did make the score batch-size dependent — same weights scoring 2.6 at eval batch 8 and 39 at batch 32 — was shard layout: eval batch size determines how the val split is cut and therefore which corpus positions each shard starts from, and only some starting positions fall into the absorbing state described under Known issues.val_coldfailnow reports that directly instead of letting it distort the pooled mean.
OdyssNet.forwardallocated a throwaway CPU tensor per timestep (torch.tensor(t)) to pass the step index intocheckpoint.checkpoint. Withuse_reentrant=Falseplain ints are accepted; on an LLM-scale sequence that was one allocation pertokens x thinking_stepsiteration. Behaviour-identical.OdyssNet.forwardscaled the full(B, T, N)activity tensor byoutput_scale_vecbefore slicing the output neurons out of it, in vocab-projection mode. Now slices first and scales the slice — mathematically identical (output_scale_vecis 1.0 outsideoutput_pos), but on long sequences that tensor is the largest allocation in the forward pass and onlylen(output_ids)of its columns are ever read.
- The chaos core can develop a degenerate absorbing state that training loss cannot see. Measured on a 1024-neuron LM: after ~1500 steps with ChaosGrad's estimate pinned at its traction cap (3.93e-3), certain cold starts drive the hidden state into a regime producing ~240 nats/token which it never leaves, while training loss continued improving smoothly (2.67 nats). Per-row and single-row-batch replays confirm the rows are fully independent and the effect is reproducible per starting position, so this is dynamics, not numerics. Two mechanisms hide it: stateful TBPTT never re-enters the bad basin once training is on a benign trajectory, and ChaosGrad's spike brake watches the training loss stream, which stays smooth throughout — a blind spot in the brake's coverage worth noting for the next optimizer pass.
cold_start_everyis the example-level mitigation and is sufficient in every configuration measured so far, but it treats the symptom; the underlying dynamics are unchanged. - The LLM example's
--sweep gapverdict (gap=1best at equal wall-clock,gap=3/gap=5far behind) is a wall-clock result at a 1.3 min/arm budget, where the deeper arms only consumed 1.5-2.3M tokens againstgap=1's 5.68M. It does not establish that deeper thinking is worse per token — that needs a token-matched run, which the harness supports but which has not been done.
- ChaosGrad's loss-spike brake permanently scarred the estimator on ordinary noise. Diagnosed via instrumented probes on
convergence_mnist_record.py: the brake was firing every ~150-350 batches on nothing worse than normal per-batch classification variance (confirmed unrelated to Hebbian plasticity — fires more often withhebb_type=None), and each trigger permanently multipliedd_numerator/d_maxbybrake_factorwith no way back. By epoch 25 of a 3k-subset probe,effective_lrhad collapsed 500x and training had silently frozen — matching the ~83-87% accuracy plateau seen in full 100-epoch runs. Replaced the permanent mutation with a transientbrake_ceilingmultiplier applied only at the point of the actual parameter update, leaving the estimator's own bookkeeping untouched so it keeps learning the true scale while suppressed. The ceiling relaxes geometrically back toward 1.0 everyreport_losscall rather than on a fixed window: an isolated spike heals in tens of steps, a fast cascade (genuine divergence — the delayed-adder case this brake exists for) still compounds down. Also fixed a secondary bug where the post-spike variance reseed collapsed to 0.0, making the sigma test degenerate for ~20 calls after every fire. - The traction limit's
trust_ratioanchor selection was a hard cutoff sitting exactly on the library's own init scale._trust_capexcluded groups withrms0 < 1e-3or0.9 ≤ rms0 ≤ 1.1outright — butmicro_quiet_warm(one of OdyssNet's own bundled init strategies) initializes atstd=1e-3, the exact value of the floor. Measured onconvergence_mnist_record.py(which usesmicro_quiet_warm): the same seed produced a traction cap of 0.000254 on CPU vs 0.004266 on CUDA — a 16.8x difference from nothing but which side of the 1e-3 cutoff a group's RNG-drawnrms0happened to land on. In practice this washed out on our examples (both land below the floor on the CUDA runs the READMEs are measured on), but it meant the cap a library user got was silently discontinuous in a region their own init could easily sit in. Replaced the hard include/exclude with_anchor_weight(a smooth ramp over the same two boundaries) plus_trust_capblending each group toward the smallest already-fully-trusted group's own rms0 (not a fixed constant — an earlier version that blended toward a fixed reference passed every test but silently moved the cap on record.py itself, from 0.006 to 0.0025, by letting an excluded near-zero group's fallback undercut a real, smaller anchor elsewhere in the same model; caught by re-measuring the actual example configs before merging, not by the test suite). Same cap on every example already validated (confirmed by direct measurement on record.py's and the adder's actual param groups), continuous everywhere else. If literally every group is excluded (e.g. a lone all-zero-initialized parameter with no other family to anchor against), the cap is disabled rather than pinned to an arbitrary value — same as before. Neurogenesis.expand()rebuilt ChaosGrad viafrom_modelwithout forwardingbrake_factor(or the newly-exposedbrake_sigma/brake_ratio/brake_ema_alpha) — a user who customized the brake would silently get the defaults back the moment their network grew. Now forwarded from the pre-expansion param group.brake_sigma/brake_ratio/brake_ema_alphawere first exposed as plain instance attributes, whichtorch.optim.Optimizer.state_dict()doesn't serialize (onlyparam_groupsandstateare) — the same class of bug as theNeurogenesisone above, one mechanism over: a save/load round trip would have silently reverted a customized brake back to defaults. Moved intodefaultsso they ride the param groups likebrake_factoralready does.examples/advanced/convergence_skill_transfer.py's "Claim check" required the transplanted model's average loss across the whole run to beat scratch's — but the transplanted model's epoch-0 loss is inflated by the 93%-freshly-initialized new region, dragging the average up regardless of how well it ultimately converges. The check printed "No clear transfer win" on every run even when transplanted beat scratch on final loss and time-to-threshold by a wide margin. Now judges the claim by final loss and first-epoch-below-threshold instead.
brake_sigma,brake_ratio, andbrake_ema_alphaare now constructor parameters (previously hidden class constants a library user could not reach at all), defaults unchanged (3.0, 1.2, 0.05). The EWMA memory (brake_ema_alpha) in particular was tuned against OdyssNet's own ~16-32 batch-size examples; a user training at a very different batch size sees a differently-scaled loss-noise floor and can now retune it instead of silently inheriting ours.- The brake's loss-EWMA/variance now use a bias-correction-style warmup (
alpha = max(brake_ema_alpha, 1/(brake_step+1)), the same technique already used for Adam's own bias correction elsewhere in this file): the first ~20 calls after construction (or after a checkpoint reload) converge the estimate from every sample seen so far rather than committing to the steady-state window immediately. Verified this doesn't introduce spurious early spikes by logging(loss, ema, std, is_spike)for the delayed adder's first 50report_losscalls — one legitimate-looking fire, no cluster near the start. convergence_skill_transfer.py: retunedadd_epochs500→250,mul_epochs1500→500 — the shorter add-phase avoids overfitting the small model before transplant, giving a consistent, clear transfer win instead of a partial one.- Re-validated numbers across README.md/README_TR.md under the fixed brake: MNIST 98.62%→98.71%, MNIST Revive 98.54%→98.70%, MNIST Tiny 95.15%→95.58%, MNIST Scaled 97.38%→98.01%, MNIST (8k) Embed 94.08%→93.71% (within run-to-run noise), Skill Transfer speedup 3.0x→3.6x. Sine Wave/Latch/Stopwatch log excerpts refreshed to match current runs.
convergence_mnist_record.py: confirmed at full scale — previously froze around epoch 15-20 (~83-87% plateau, the exact symptom the brake fix targets), now trains cleanly through all 100 epochs, landing at 87.98% (peak 88.46%, epoch 86) zero-config. The README's 90.14% "WORLD RECORD" banner predates this optimizer entirely (different scheduler/preset pipeline, since removed) and is left as-is with an added status note — not a regression target, since the script itself changed too much for a like-for-like comparison.LRset toNone(zero-config).- Re-validated after the trust-cap and brake-warmup changes above: delayed adder (2000 epochs, no divergence, final loss ~0.00017),
convergence_mnist_record.py25-epoch diagnostic probe (d_maxflat at 0.060141,effective_lrsteady at 0.005958 with brake recovering after each dip — unchanged from before this fix), XOR seeds 42/123 (both solve zero-config), MNIST-3k/6-epoch probe (89.20%, byte-identical to the pre-change run), fullpytest tests/(297/297).
convergence_sine_wave.pyshows a late-training instability under the fixed brake: loss explodes from ~0.001 to 0.01-0.06 starting around epoch 7900 of a 10000-epoch run. Not yet root-caused.EPOCHSreduced 10000→6800 as a stopgap (avoids the window entirely) rather than a fix; worth the same instrumentation approach used to diagnose the record.py brake issue.
- ChaosGrad — OdyssNet's bespoke zero-config optimizer, rebuilt from first principles and now the default. Combines Adam-style per-synapse preconditioning with online distance adaptation (D-adaptation class estimator): no learning rate is required. Exported as
odyssnet.ChaosGrad. - Architecture-aware family policy: parameters are auto-classified into
chaos_core,memory_feedback,projections,plasticity, andmodulationfamilies. Weight decay applies only to connective structure; Hebbian logits and gates are never decayed. The chaos core's zero-diagonal constraint is enforced inside the optimizer step. - Anchored traction limit (
trust_ratio): the applied step scale is capped at a fraction of the network's initial weight scale, shielding tiny chaotic networks from distance-estimator overshoot (stock Prodigy could not solve the 9-parameter XOR example; ChaosGrad solves it zero-config with the step scale settling at the previously hand-tuned value). - Loss-spike brake (
brake_factor): on a statistical loss spike the distance estimate is scaled down and re-grows only if the landscape supports it, fixing late-training divergence on sharpening temporal tasks (delayed adder). The trainer feeds the loss stream automatically; custom loops can calloptimizer.report_loss(loss). trainer.get_diagnostics()now includes ChaosGrad health metrics under theoptimizerkey, andcurrent_lrreports the live step-scale estimate.
OdyssNetTrainerdefault optimizer is now ChaosGrad (lr=None→ automatic estimation). Passing an explicitlrselects ChaosGrad's fixed-rate mode (AdamW-equivalent updates under the family policy) instead of AdamW.- All convergence examples now run zero-config (no
lrargument). Precision-record examples (embed/record/reverse-record/LLM) keep their tuned rates via fixed-rate mode. Neurogenesis.expand()migrates ChaosGrad with fresh family grouping while preserving the step-scale estimate.convergence_mnist_embed.pymoved to zero-config (lrremoved) — matches its previously tuned fixed-rate result under ChaosGrad.convergence_adder.pyepochs raised 500 → 2000 to ride out the mid-training loss spike before the loss-spike brake settles the estimate back down.- Re-validated and refreshed the advertised numbers in README.md/README_TR.md for MNIST, MNIST Revive, MNIST Tiny/Scaled, MNIST (8k) Embed, Sine Wave, Adder, Latch, Stopwatch, Detective, and Skill Transfer under ChaosGrad v2.6 — most improved (e.g. MNIST Tiny 90.2% → 95.15%, MNIST Revive 97.8% → 98.54%). MNIST (Record) and MNIST Reverse (Generation) numbers are intentionally left untouched pending further optimizer work.
Neurogenesis.expand()silently copied shape-tracking optimizer state tensors (exp_avg,exp_avg_sq, ...) without resizing them, crashing the first optimizer step after expansion.- Examples with emoji output crashed on legacy Windows code pages (cp1254); affected scripts now reconfigure stdout to UTF-8.
prodigyoptdependency (Prodigy is superseded by ChaosGrad).
- Spatial Hebbian Plasticity: Introduced a co-activation learning mechanism (classic Hebbian) alongside the existing STDP-style learning.
hebb_modefunctionality (hebb_type):hebb_typeis now repurposed to act as the mechanism toggle:None(disabled),"temporal","spatial", or"both".hebb_res: Controls the structural resolution ("global","neuron","synapse"). Defaults to"neuron".
- BREAKING: Replaced single-path Hebbian parameters with path-specific prefixes (
t_hebb_factorands_hebb_factor, etc.). Existing checkpoints utilizinghebb_factorwill need to be loaded withstrict=Falseand re-trained, or manually patched, as we have prioritized a clean architecture over legacy support.
- Prodigy optimizer is now the default when
lr=None(the new default). Prodigy auto-calibrates the learning rate continuously — no manual LR tuning required. Requires theprodigyoptpackage (pip install prodigyopt), now listed as a core dependency.
OdyssNetTrainerdefaultlrchanged from1e-4toNone. Passinglr=None(default) activates Prodigy; passing an explicit float (e.g.lr=1e-4) still selects AdamW withweight_decay=0.01.
- Added
ODYSSNET_DISABLE_PLOTenvironment variable support toTrainingHistory.plot()to bypass interactive plotting during automated runs. - Updated
examples/test_all.pyto automatically setODYSSNET_DISABLE_PLOT=1before spawning sub-processes.
- Fixed bug in
save_checkpointwhereos.makedirscrashes if a bare filename is provided (e.g."model.pt") due to an empty directory string. - Fixed 5/6/7-space indentations across codebase to comply with strict 4-space PEP 8 guidelines.
- Cleaned up several unused imports (
torch.nn,Dataset,math) in advanced examples.
- Default learning rate in
OdyssNetTrainerchanged to1e-4(previously1e-3). - Centralized repetitive output-extraction and autocast resolution logic in
OdyssNetTrainerinto private helper methods (_extract_outputs,_get_autocast_ctx), standardizing logic. - Optimized optimizer state transferring logic in
neurogenesis.expand(), collapsing multiple loops.
- Removed
ChaosGradoptimizer — replaced with standardAdamWas default. - Removed
bitsandbytesdependency and allNO_BNBenvironment variable usage. - Removed
trigger_plateau_escape()from trainer (was ChaosGrad-specific). - Renamed
micro_quiet_8bitinit strategy tomicro_quiet_warm.
- Default optimizer is now
torch.optim.AdamW(lr=1e-3, weight_decay=0.01). - Diagonal zeroing of chaos core
Wmatrix is now enforced by the trainer. get_diagnostics()simplified — removed ChaosGrad-specific metrics.
- ChaosGrad v2.2 "The Learning Teacher": Zero-hyperparameter optimizer with Analytic Hypergradient Descent. All meta-parameters (LR, momentum, weight decay, centralization) are autonomously adapted per-parameter at each step.
- Heterogeneous Synaptic Plasticity (
hebb_type): Three resolution levels (global,neuron,synapse) for online Hebbian learning with fully differentiable logit parameters. - Parametric Gating: Configurable per-branch gates (
encoder_decoder,core,memory) withidentityandsigmoidmodes. - Label Smoothing: Integrated into trainer for classification tasks.
- Debug Mode (
debug=True): NaN/Inf diagnosis with per-operation forward-pass checks and automaticdetect_anomaly. - Enhanced Diagnostics: Both
ChaosGrad.get_diagnostics()andOdyssNetTrainer.get_diagnostics()now support adebugparameter.- ChaosGrad debug mode includes per-parameter statistics (min/max/std) for learning rate, beta, alpha, decay, per-group breakdowns, and step count statistics.
- Trainer debug mode includes gradient persistence tracking, anomaly detection state, loss tracking buffer info, AMP scaler state, and gradient statistics (norms/means).
- Training history plotting (
plot_history): Utility to visualize loss, learning rate, and custom metrics over training. pyproject.tomlfor standard Python packaging (pip install -e .).CONTRIBUTING.mdwith example standards, initialization protocols, and contributor checklist.LICENSEfile (MIT).CHANGELOG.md(this file).
- Removed legacy ChaosScheduler — ChaosGrad now handles scheduling at granular synaptic level.
- Renamed
PoC/toexamples/,PoC/experiments/toexamples/advanced/for open-source clarity. - Removed
ChaosGradConfig— ChaosGrad requires only a genesislr. - Removed
sys.path.appendhacks from all example scripts (usepip install -e .instead).