DEV Community

Cover image for Salt, spikes, and three thermostats — the neuromodules of a numpy organism
Vi
Vi

Posted on

Salt, spikes, and three thermostats — the neuromodules of a numpy organism

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Brainstem and the inverted-U

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)]
Enter fullscreen mode Exit fullscreen mode

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.

TRN gates

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)))
Enter fullscreen mode Exit fullscreen mode

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

DG–CA3–CA1

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:]
Enter fullscreen mode Exit fullscreen mode
  • 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.

Dentate gyrus internals

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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

LIF membrane

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))
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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.

The journey of one spike train

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.

Three homeostats

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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.

Astrocyte domain

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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

Granule → Purkinje → climbing fiber

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))
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)