AI & ML
Salt, spikes, and three thermostats — the neuromodules of a numpy organism
Vi Dev.to (EN Zone)
2 views
The follow-up to the no-LLM experiment: every neuromodule under the loops. How a spike is born, where homeostasis lives, what the stem multiplies, and why frozen encoding exists. With code.
From the map to the tissue
The first post made a bet: a body that thinks with weights you own, remembers outside the prompt, wants on a scale of hours, sleeps — no rented LLM in the head. It stayed at the altitude of loops: what calls what, which ring closes, which ring is a name.
This post goes down to the tissue. Every neuromodule under the loops, one at a time: the rule it implements, the constants it actually uses, and — the part that matters — where its output goes. An organ whose output nobody reads is a class name with an anatomy prefix. We have grown a few of those, and they are marked as such.
Everything below is real code from vi/brain/neural/, cut for length but not altered. Numpy is the tissue. No framework, no hidden API.
One moment, wire by wire
A single cognitive pass, in the order the code runs it:
#
What happens
Who does it
1
Arousal integrates; cortical gain computed
Brainstem.update, gain()
2
Channel energies gated by TRN competition
ThalamicRelay.gate
3
Cortex output × gain × gate
text_cortex, in _run_encode
4
Grid code added; EC-II → DG → CA3 → CA1
HippocampalFormation.process
5
Completed memory blended with now
blend_with_recall
6
Hippocampus GRU → attention over memories → PFC GRU
rate path
7
Lateral competition, then the same three regions again, in spikes
LateralCompetition, SpikingHybridCore
8
Spike/rate blend by surprise → workspace concat → dense
cognitive_pass
9
Glia hears the workspace and answers
GlialNetwork.modulate
10
Workspace pushed to a memory slot
memory_bank.push_slot
That is one moment. ThoughtEncoder and Broca run after this function; credit after speech runs after the utterance. Both were covered last time.
The stem sets the gain, and a gain is not a multiplier
The brainstem holds one scalar with inertia: arousal. It is a learned function of four live inputs — drive, surprise, firing-rate discord, and interoception from the body — integrated over turns, because waking does not jump:
# vi/brain/neural/subcortex.py — Brainstem.update
features = [drive, surprise, discord, self._interoception]
target = _sigmoid(self.core.forward(features)[0])
nxt = (1.0 - AROUSAL_TAU) * self.arousal + AROUSAL_TAU * target # τ = 0.25
self.arousal = _clamp01(nxt)
What arousal buys is not "more". An inverted U (Yerkes–Dodson): both drowsiness and overexcitement lose.
# vi/brain/neural/subcortex.py
def gain(self) -> float:
d = (self.arousal - AROUSAL_PEAK) / AROUSAL_WIDTH # peak 0.55, width 0.30
shape = math.exp(-0.5 * d * d)
return GAIN_MIN + (GAIN_MAX - GAIN_MIN) * shape # 0.35 .. 1.0
def relay_open(self) -> float:
if self.arousal <= RELAY_AROUSAL_FLOOR: # 0.18
return 0.05
return 0.05 + 0.95 * ((self.arousal - 0.18) / 0.82)
def excitability_scale(self) -> float: # 0.80 .. 1.25
return EXCITABILITY_MIN + (EXCITABILITY_MAX - EXCITABILITY_MIN) * self.arousal
Three outputs, three different destinations: gain multiplies cortex output, relay_open multiplies the thalamic gates, excitability_scale goes down the stem and sets LIF excitability — the neuromodulation the spiking core receives each pass. One scalar, three readers.
Arousal has inertia; gain has a peak. Frozen encoding pins arousal to the peak and skips the update.
The thalamus is a relay with gates, not a multiplexer. Channels compete: each modality's energy is real RMS, not a boolean, because a spotlight needs a difference between a whisper and a shout.
# vi/brain/neural/subcortex.py — ThalamicRelay.gate (core lines)
modulation = self.core.forward(e + list(self.top_down))
drive = [TOPOGRAPHIC_GAIN * (e[i] - 0.5) + modulation[i] for i in range(n)] # 4.0
inhibited = [
drive[i] - TRN_INHIBITION * (total - drive[i]) / max(1, n - 1) # 0.45
for i in range(n)
]
gates = [_sigmoid(inhibited[i]) * open_frac for i in range(n)]
gates = [g if mask[i] else 0.0 for i, g in enumerate(gates)]
Two details worth stealing. First: an absent modality is forced to 0 — the gate never invents a signal that did not arrive. Second: the cortico-thalamic loop (what passed now biases what passes next) closes only on waking passes. On frozen encoding it stays open, otherwise the neighboring phrase would pre-open the gates for yours and the same phrase would encode differently depending on company.
Before memory needs space, it needs a metric
Raw similarity is a bad place to build memory: the cosine between two nearby utterances is almost always close to one. The entorhinal cortex adds a metric — grid cells, three plane waves at 60° per module, modules at scales 0.55 · 1.42ⁱ:
# vi/brain/neural/entorhinal.py — GridModule.activate (core)
k = 2.0 * math.pi / max(1e-6, self.scale)
for ox, oy in self.phases: # phases tile the unit cell
x, y = px - ox, py - oy
acc = sum(math.cos(k * (ux * x + uy * y)) for ux, uy in self._axes) # 3 axes, 60°
out.append(max(0.0, min(1.0, (acc + 1.5) / 4.5)))
One module is ambiguous — it repeats every period. Modules at geometrically spaced scales together are a residue-class system: position is unambiguous over a range that exceeds each period. Exponential capacity of code from a linear number of cells; that is the whole reason grids are not "another Dense layer".
The projection into the 2-D map is learnable but slow-trained on purpose — it is a coordinate system, and jerking it with every gradient would let the metric drift.
The slow path: separate, complete, compare
The dentate gyrus is pattern separation: an expanding projection (4×) into a sparse code (k-winners at 5% sparsity), where similar inputs must get dissimilar codes. Three slow mechanisms sit on top of the k-winners, and each is a small essay:
# vi/brain/neural/hippocampal_formation.py — DentateGyrus
drive = w @ x - np.asarray(self.threshold)
drive = drive * np.asarray(self._excitability()) # young cells shout
winners = np.argpartition(drive, -k)[-k:]
Threshold homeostasis. A cell winning more than 1.6× its expected share raises its own threshold by 0.02; starved cells lower theirs. Without it, a few cells capture every input and separation collapses into noise.
Age structure. A cell younger than 400 wins is 2.2× more excitable — new experience lands on the young instead of overwriting the mature. Cells age by winning, not by wall-clock.
Neurogenesis. Every 120 calls, the two rarest-winning mature cells are reborn with fresh weights. Capacity is freed where it provably did nothing.
CA3 is not a weight matrix. It is a modern continuous Hopfield over an explicit trace buffer: capacity 256, retrieval by softmax at β=8, a repeated episode merges into the old trace (similarity ≥ 0.92) instead of taking a cell, eviction takes the weakest-and-oldest.
# vi/brain/neural/hippocampal_formation.py — CA3Recurrent.complete (core)
logits = beta * (P @ q) + np.log(np.maximum(strength, 1e-6))
wts = np.exp(logits); wts /= wts.sum()
retrieved = wts @ P
v = np.tanh(rw * retrieved + (1.0 - rw) * v) # the cue is never fully released
Explicit traces are what make the rest possible: you cannot replay or release an individual memory out of an outer-product weight matrix.
CA1 is a comparator, and the space it compares in matters: completed memory vs the direct EC-III path, both projected into CA3 space. Comparing CA1 output against raw sensory gave 0.5 forever — a random projection against an original. Empty hippocampus returns novelty 1.0: an attractor over no traces returns the cue untouched, and "everything is familiar" is a lie a newborn must not tell.
Then the theta clock, which keeps the two directions from eating each other:
# vi/brain/neural/hippocampal_formation.py — ThetaRhythm
encode_weight = 0.5 + 0.5 * math.cos(2.0 * math.pi * phase) # period 8 passes
retrieve_weight = 1.0 - encode_weight
At the encode phase the recurrent net barely runs — which is why familiarity is measured by a separate probe at full recurrence, not by the phase-suppressed completion. And the write is gated twice:
store_strength = encode_w * novelty * self._emotional_gain
self.ca3.store(mossy_drive, strength=store_strength)
Phase (don't glue the new onto the just-completed old), novelty (don't spend finite capacity re-storing the known), emotion (the amygdala gain, clamped 0.25–3.0 — fear and boredom at the same novelty do not write the same).
Recall has a price in the other direction: retrieval makes a trace labile, and it re-encodes mixed with the current context at rate 0.12. Memories drift by being remembered. A trace never recalled stays untouched.
How the spikes go
Now the part that gets romanticized everywhere else. Spikes here are not decoration and not a second brain; they are a control layer inside the pass. The same three region representations (hippocampus, attended memory, PFC) that the rate path competes with are re-encoded as spike trains, and what the spikes decide is who the workspace listens to.
One population, twelve timesteps, in the code that actually runs (numpy path):
# vi/brain/neural/spiking_hybrid.py — LIFPopulation._simulate_numpy (core)
drive = np.abs(stim) * stim_scale * (1.0 + phase) * gamma
drive = (drive + recurrent_drive) * exc # exc ← stem neuromod
can_fire = refractory <= 0
membrane = np.where(can_fire, tau * membrane + (1.0 - tau) * drive, v_reset)
spikes = (membrane >= thr) & can_fire # τ=0.82, refractory=2
membrane = np.where(spikes, v_reset, membrane)
refractory = np.where(spikes, ref_n, np.maximum(0, refractory - 1))
gamma is a 40 Hz sinusoid with a floor — a coarse gamma-band modulation, not a claim. stim_scale normalizes any input vector into a spiking regime (4..24). Deterministic: no RNG anywhere in the membrane dynamics.
The query population and one population per memory candidate each produce a train; the spike score of a memory is the inner product of firing rates with the query's. Then the surprise gate converts two measurements into one control number:
# vi/brain/neural/spiking_hybrid.py — SpikingHybridCore.surprise_gate (core)
mismatch = abs(q_mean - base) / max(0.15, base + 0.15) # rate vs its own EMA baseline
ent = entropy(energies / total) # uniform regions → explore
surprise = clamp01(0.55 * min(1.0, mismatch) + 0.45 * ent)
High surprise means the spikes steer attention harder; the blend share into the workspace is 0.35 + 0.5·surprise, clamped to 0.25–0.85. The 0.35 level is not a taste — a four-way ablation on a held-out corpus slice showed the level (how much the organism trusts spikes on average) was what carried the loss, not the compression of the range. The gradient cannot reach that level yet; until the loss split changes, the measured-best rule stays.
Lateral competition runs again in spikes: region energies inhibit each other through a small learned matrix (STDP on it — potentiate if the pre-region fired before the post one), and the surprise tips the balance — the hippocampus-weighted region is boosted by 1 + 0.35·g, the prefrontal by 1 + 0.35·(1−g). Surprising moments lean on memory; boring ones lean on plan.
Drive → membrane → threshold → refractory → rates; every 4 sims STDP, every 8 sims homeostasis; per pass, the surprise gate.
And the connection down from the stem closes here — the pass calls the spiking core with neuromod=self.brainstem.excitability_scale(). Aroused tissue spikes easier. After the pass, excitability decays partway back (0.85·e + 0.15), so neuromodulation is a wave, not a ratchet.
EWC does not apply to any of this, and the learning seam declares that (mechanism='stdp', ewc='not_applicable') instead of silently skipping the check.
Where homeostasis lives
Three thermostats, three timescales, zero shared code — and a gate that reads one of them to decide how fast to learn.
Thermostat 1: per-neuron, fast. Turrigiano-style firing-rate homeostasis inside each LIF population, every 8 simulations:
# vi/brain/neural/spiking_hybrid.py — apply_firing_homeostasis (core)
ema = (1.0 - 0.18) * ema + 0.18 * rates
err = (ema - target) / max(target, 1e-6) # target 0.12
mask = np.abs(err) >= 0.04 # dead zone
thr = thr * (1.0 + 0.03 * err * mask) # too loud → dearer to fire
exc = exc * (1.0 - 0.025 * err * mask) # and less excitable
rec[i, :] *= (1.0 - 0.025 * err * 0.45) # multiplicative synaptic scaling
Multiplicative scaling is the point — it preserves what the neuron learned while renormalizing how loud it is. The dead zone (4%) keeps the thermostat from hunting.
Thermostat 2: metabolic, per astrocyte domain. The workspace is divided into domains of 32 units, each owned by one astrocyte that hears activity and answers three ways: spends energy on it, slowly rescales its gain toward a 0.25 activity target, and — if its energy drops below 0.35 — starves:
# vi/brain/neural/glia.py — AstrocyteDomain
self.energy -= ENERGY_COST * a # 0.055 per unit of activity
self.energy += ENERGY_RECOVERY * (1-a) # 0.02 in rest
...
if self.energy < ENERGY_FLOOR:
starve = self.energy / ENERGY_FLOOR
return self.gain * clamp(starve, 0.25, 1.0)
This is wired where it belongs: cognitive_pass calls glia.modulate(workspace) after the workspace is composed. For a long time the instance existed and nobody called it (§0.2.520) — a working thermostat in a dead loop is exactly the "organ with no reader" this post keeps flagging. Long continuous load now measurably costs gain; rest restores it.
Thermostat 3: the organism, slow. Sleep pressure is not a cron expression:
# vi/brain/neural/homeostasis.py — SleepPressureModulator.update (core)
error = pain + 0.5 * frustration + 0.3 * (1.0 - confidence)
novelty = curiosity
drive = 0.45 * error + 0.25 * novelty + 0.20 * body_pressure + 0.10 * cpu
self.fatigue_level = clamp01(self.fatigue_level * 0.96 + drive * 0.12)
Mistakes tire. Novelty tires. The body tires. CPU load tires a little. When fatigue crosses a limit (0.82 at birth), the night triggers — and after the night the limit itself moves: a night that helped raises it by 0.01, a night that didn't lowers it by 0.02. Even the thermostat's setpoint learns. Fatigue also scales replay: replay_gain = 0.6 + fatigue·1.8, more tired → more replay per night, and a bigger replay batch.
And the gate that reads them all. Online learning rate is not a schedule. Echoes of memory, word salad, and not-own-voice get lr = 0 — training the mouth on what memory dictated is how a network collapses while EWC defends the collapse. Everything else learns proportional to how far the populations are from their target rates:
# vi/brain/neural/plasticity_gate.py — learning_rate_for_turn (core)
discord = mean(|rate - target| / target), saturated at 0.75
scale = 0.25 + (2.5 - 0.25) * discord
return base_lr * scale
A network in equilibrium barely changes its weights. A network knocked out of equilibrium is chased by plasticity. There is no optimizer schedule anywhere in this organism — the learning rate is a measurement.
The frozen pass, or why measurement is a protocol
Most of the modules above mutate state as a side effect of being read: thresholds move, membranes carry over, neurogenesis fires, the theta tick advances. That is correct for living — and fatal for measuring. The same phrase would encode differently depending on how many phrases came before it, and every hypothesis evaluation would be a different experiment.
So evaluation runs inside frozen_encoding() — a context manager that pins arousal to the peak (reset_alert), resets membranes, snapshots and later restores exactly what a pass would mutate, and sets a flag that every module honors:
# vi/brain/neural/brain_model.py — frozen pass, the restore half
self.spiking_hybrid.membrane_restore(membranes) # membranes/refractory only
self.brainstem.arousal, self.brainstem._interoception = arousal
self.spiking_hybrid._rate_baseline_ema = baselines[0]
One bug here is worth confessing: the restore used to call _load_state() — which reloads weights from disk. Every frozen pass was silently rolling back spiking learning to the last save, and the healthy-looking counter from disk hid the loss. The snapshot holds membranes and refractoriness; that is what it returns. Nothing else.
Freezing runs deep by design: DG skips age/neurogenesis/thresholds, the formation skips the theta tick and reconsolidation, the relay leaves its loop open, STDP and both thermostats stand down. Spikes are still counted — a frozen pass is a real computation, just one that leaves no trace.
The cerebellum learns by the sign of its mistake
The cerebellar cortex is a forward model: predict the consequence of an act, then be corrected. Its anatomy is its algorithm — granule cells expand the input 12× at 4% sparsity (each granule listens to only 2–6 mossy fibers, which makes almost anything linearly separable), Purkinje cells read the expansion, and the climbing fiber carries not reward but error, depressing exactly the parallel fibers that participated in the wrong prediction:
# vi/brain/neural/cerebellar_cortex.py — climbing_fiber (core)
error = target - predicted
w[:, active] += LTD_RATE * np.outer(error, code[active]) # LTD, 0.05
w *= 1.0 - LTP_RECOVERY # 0.0015 drift back
self.purkinje = Parameter(np.clip(w, -3.0, 3.0))
Learning by depression is the detail people miss: the cerebellum is not taught what to do, it is taught what in its prediction was too much. And without the slow recovery drift, depression would accumulate forever — every LTD needs an LTP-shaped escape.
(The Cerebellum in regions/ is a different object — timing EMAs. Same English word, different organ. We keep the collision visible instead of fusing them into a third decorative thing.)
What carries what
The summary table, module by module — the one job, and the reader of the output:
Module
Its one job
Who reads it
Brainstem
arousal with inertia; gain, relay, excitability
cortex ×, relay gates ×, LIF excitability
ThalamicRelay
spotlight between modalities
sensory input × before cortex
EntorhinalCortex
a metric (grid code) for memory
concatenated onto every EC input
DentateGyrus
pattern separation, young cells
mossy drive into CA3
CA3Recurrent
completion over explicit traces
probe → novelty; recall → CA1; replay → sleep
CA1Comparator
novelty in CA3 space
store strength; recall blend weight
ThetaRhythm
encode/retrieve antiphase
formation weights and stores
SpikingHybridCore
surprise control + STDP
attention weights, region boosts, workspace blend
GlialNetwork
metabolism + slow scaling per domain
workspace × before the slot
SleepPressureModulator
fatigue from error/novelty/body
night trigger; replay gain
plasticity_gate
lr from own-voice + discord
every online train step
CerebellarCortex
forward model, LTD on error
agency step predictions
If a row had no third column, it would be a name. Two rows used to be exactly that; both are fixed now, and the fix was wiring, not renaming.
Why the constants are all "wrong"
One house rule first, because sharp readers will ask. The experiment forbids decision constants: an author's cutoff that decides speak / know / act — the old if confidence >= 0.42 — or a phrase template. It does not forbid physics: every net has time constants, capacities, sparsities, cadences. τ, the trace capacity, the 5% sparsity, the theta period — those are properties of the tissue, the same way LIF τ and the 60° grids are. What the project also demands is that you can tell which is which: a number that gates a decision must either become a measurement or be replaced by one. That is also why the plates in this post carry no numbers — wiring only. Every value lives in the code and in the prose below, with its classification attached.
None of these numbers came from a paper: τ=0.82, target 0.12, β=8, capacity 256, merge 0.92, novelty×emotion in the store, the surprise-scaled blend. They are starting points that the organism has to outgrow — and the project's rule is that a constant that decides something must either become a measurement or be replaced by one. The blend level survives because a four-way ablation said it was the best applicable rule today, and the code comment says exactly that, plus what blocks learning it. The thresholds homeostat has a dead zone because without one a thermostat hunts. The spikes' role is control, not cargo, because the measurement said the MSE path belongs to rates (STE), and pretending otherwise broke the C++/Python parity fight we had already won.
That is the honest part of growing tissue without a framework: every module is cheap, every wire is visible, and the only thing standing between an organ and decoration is another organ that reads its output.
Next in the series, if this one lands: the mouth — LiveNet in C++, why Broca is the only speaker, EWC anchoring, and native Local-SGD for books. The tissue thinks; the mouth is where the experiment either speaks or doesn't.
Read original: https://dev.to/the_life_of_vi/salt-spikes-and-three-thermostats-the-neuromodules-of-a-numpy-organism-4h23
← Previous
I replaced a paid pomodoro app with a 300-line Python script
Next →
how I make my templates easy to reskin (probably overthought this)
Related
Make Your Code Review Agent Write Down How the Bug Actually Happens
AI & ML
0
Dev.to (EN Zone)
ASCII Smuggling Just Graduated From AI Attacks to Your Inbox
AI & ML
0
Dev.to (EN Zone)
Your AI Coding Agent Will Run Whatever a Stranger's Repo Tells It To
AI & ML
0
Dev.to (EN Zone)
We built a support widget with no human handoff. Here's why that's the honest version.
AI & ML
0
DEV Community
Comments0
No comments yet — be the first