Models From First Principles 04: HRM — Hierarchical Reasoning With Fast and Slow Recurrent State

Page content

HRM — Hierarchical Reasoning With Fast and Slow Recurrent State

The previous models in this series were mostly one-pass models.

MR.Q took two embeddings and produced one score.

EBT kept the same basic structure but added several heads.

SICQL made those heads explicit components.

The architecture grew, but the shape of the computation was still familiar:

input
encoder
representation
heads
outputs

HRM changes the question.

Instead of asking:

What can we predict from one representation?

we ask:

What if the representation itself is allowed to evolve through repeated computation?

That is the architectural jump in this post.

We are going to build a small hierarchical recurrent model with two coupled latent states:

  • a low-level state that updates frequently;
  • a high-level state that updates less frequently.

The low-level state performs several fine-grained updates before the high-level state is allowed to change.

Conceptually:

                 input
             input projector
        ┌────────────────────┐
        │ low-level updates  │
        │                    │
        │ zL → zL → zL → zL │
        └─────────┬──────────┘
             high-level update
             zH → zH'
                  └──────────────┐
                          repeat cycle

This looks more sophisticated than the previous models.

But our rule for this series is unchanged:

If the architecture looks complicated, keep decomposing it.

By the time we reach the bottom, HRM will turn out to be a small number of ordinary PyTorch operations repeated according to a schedule.


1. What changed from SICQL?

SICQL gave us a useful composition:

context + candidate
      encoder
       zsa
  ┌─────┼─────┐
  ↓     ↓     ↓
  Q     V   Policy

The obvious limitation is that zsa is computed once.

The model has one opportunity to transform its input before the heads make their predictions.

That may be enough.

And this is important:

recurrence is not automatically better.

If a feed-forward model solves the task, recurrence can simply add latency, optimization difficulty and more failure modes.

So HRM should not begin with the claim:

repeated computation is reasoning.

Instead we begin with a testable hypothesis:

some tasks may benefit from allowing the latent representation to be updated repeatedly before prediction.

That is much more precise.


2. The smallest possible recurrent model

Before building a hierarchy, build one recurrent state.

Suppose the input is:

x.shape == [B, D]

and we maintain a hidden state:

z.shape == [B, H]

A recurrent model repeatedly applies a function:

z_next = f(x, z_previous)

That is recurrence.

Nothing mystical has happened.

A minimal PyTorch version could be:

import torch
import torch.nn as nn


class SimpleRecurrentState(nn.Module):
    def __init__(self, input_dim, hidden_dim):
        super().__init__()
        self.update = nn.Linear(input_dim + hidden_dim, hidden_dim)

    def forward(self, x, z):
        combined = torch.cat([x, z], dim=-1)
        return torch.tanh(self.update(combined))

Then:

B = 8
D = 128
H = 64

x = torch.randn(B, D)
z = torch.zeros(B, H)

cell = SimpleRecurrentState(D, H)

for _ in range(4):
    z = cell(x, z)

The same parameters are reused on every step.

That is a key distinction from simply stacking four different layers.

A deep feed-forward stack might be:

Layer1 → Layer2 → Layer3 → Layer4

A recurrent computation is:

Cell → Cell → Cell → Cell

with the same cell parameters reused.


3. Why use a GRUCell?

The simple tanh recurrence above can work, but recurrent optimization has well-known difficulties.

The hidden state needs to decide:

  • what to preserve;
  • what to overwrite;
  • how much new information to incorporate.

A gated recurrent unit gives the model learned gates for this state update.

PyTorch exposes the basic recurrent operation directly:

nn.GRUCell(input_size, hidden_size)

A GRUCell consumes:

current input
+
previous hidden state

and returns:

next hidden state

So our recurrent block can become extremely small:

class RecurrentBlock(nn.Module):
    def __init__(self, input_dim, hidden_dim):
        super().__init__()
        self.cell = nn.GRUCell(input_dim, hidden_dim)

    def forward(self, z_prev, x):
        return self.cell(x, z_prev)

Again:

advanced recurrent block
     GRUCell

We can continue descending inside the GRU if we want to understand reset/update gates, but for the architecture-level discussion this is already a useful boundary.


4. Add normalization

Repeated state updates can produce unstable activation scales.

One simple stabilization mechanism is RMS normalization.

RMSNorm does not subtract the mean like LayerNorm.

It rescales a vector according to its root-mean-square magnitude.

For a vector x:

rms(x) = sqrt(mean(x²) + ε)

and:

normalized = x / rms(x)

with a learned per-feature scale.

A compact implementation is:

class RMSNorm(nn.Module):
    def __init__(self, dim, eps=1e-6):
        super().__init__()
        self.eps = eps
        self.weight = nn.Parameter(torch.ones(dim))

    def forward(self, x):
        dtype = x.dtype
        xf = x.float()
        normed = xf * torch.rsqrt(
            xf.pow(2).mean(dim=-1, keepdim=True) + self.eps
        )
        return normed.to(dtype) * self.weight

Now our recurrent block becomes:

class RecurrentBlock(nn.Module):
    def __init__(self, input_dim, hidden_dim):
        super().__init__()
        self.cell = nn.GRUCell(input_dim, hidden_dim)
        self.norm = RMSNorm(hidden_dim)

    def forward(self, z_prev, x):
        z_next = self.cell(x, z_prev)
        return self.norm(z_next)

So one major HRM component is simply:

input + previous state
       GRUCell
       RMSNorm
      next state

5. The hierarchy

Now we add the architectural idea that makes HRM interesting.

Instead of one recurrent state, create two:

zL = low-level state
zH = high-level state

The low-level state updates many times.

The high-level state updates once per low-level cycle.

For example:

cycle 1
    L step 1
    L step 2
    L step 3
    L step 4
    H step 1

cycle 2
    L step 1
    L step 2
    L step 3
    L step 4
    H step 2

If there are:

N = number of high-level cycles
T = number of low-level steps per cycle

then the low-level module executes approximately:

N × T

updates while the high-level module executes:

N

updates.

That gives us two computational timescales.


6. Why call them fast and slow?

Not because one runs on a faster processor.

They operate at different update frequencies.

The low-level state receives repeated opportunities to process the current projected input in the context of the current high-level state.

The high-level state changes only after the low-level loop has completed.

So we can think of:

zL = frequently changing latent state
zH = slowly changing latent state

This is an architectural interpretation.

Do not automatically translate it into psychological claims such as:

zL = intuition
zH = conscious reasoning

unless experiments justify those interpretations.

The code only guarantees two differently scheduled hidden states.


7. Project the input first

Our input embedding might be large:

x.shape == [B, 2048]

We probably do not want every recurrent operation to work directly in 2048 dimensions.

So first project the input into a smaller hidden space:

class InputProjector(nn.Module):
    def __init__(self, input_dim, hidden_dim, dropout=0.1):
        super().__init__()
        self.proj = nn.Linear(input_dim, hidden_dim)
        self.norm = RMSNorm(hidden_dim)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        return self.norm(self.dropout(self.proj(x)))

If:

input_dim = 2048
h_dim = 256

then:

[B, 2048]
    ↓ Linear
[B, 256]
    ↓ RMSNorm
[B, 256]

Call the projected input:


8. Building the low-level module

The low-level module should see:

  1. the projected input ;
  2. the current high-level state zH.

If both have dimension h_dim, concatenate them:

l_input = torch.cat([x_tilde, zH], dim=-1)

So:

x̃  [B, H]
zH [B, H]

concat
[B, 2H]

The low-level recurrent block therefore has:

low = RecurrentBlock(
    input_dim=2 * h_dim,
    hidden_dim=l_dim,
)

Its update is:

zL = low(zL, l_input)

Repeated T times.


9. Building the high-level module

After the low-level loop finishes, the high-level module receives:

final low-level state zL
+
previous high-level state zH

So:

h_input = torch.cat([zL, zH], dim=-1)

If:

zL: [B, L]
zH: [B, H]

then:

h_input: [B, L + H]

The high-level recurrent module is:

high = RecurrentBlock(
    input_dim=l_dim + h_dim,
    hidden_dim=h_dim,
)

and one high-level update is:

zH = high(zH, h_input)

That is the entire hierarchy.


10. The complete rollout

Now write the schedule explicitly.

def rollout(x_tilde, low, high, n_cycles, t_steps, l_dim, h_dim):
    B = x_tilde.size(0)
    device = x_tilde.device

    zL = torch.zeros(B, l_dim, device=device)
    zH = torch.zeros(B, h_dim, device=device)

    for _ in range(n_cycles):
        for _ in range(t_steps):
            l_input = torch.cat([x_tilde, zH], dim=-1)
            zL = low(zL, l_input)

        h_input = torch.cat([zL, zH], dim=-1)
        zH = high(zH, h_input)

    return zL, zH

Read it slowly.

There is no hidden magic.

The hierarchy is mostly the loop structure.

This is one of the central lessons of the series:

architecture is often not a new primitive. It is a new composition of familiar primitives.


11. Put it inside a model

Now package the components.

class MiniHRM(nn.Module):
    def __init__(
        self,
        input_dim=2048,
        h_dim=256,
        l_dim=128,
        n_cycles=4,
        t_steps=4,
        dropout=0.1,
    ):
        super().__init__()

        self.input_dim = input_dim
        self.h_dim = h_dim
        self.l_dim = l_dim
        self.n_cycles = n_cycles
        self.t_steps = t_steps

        self.input_projector = InputProjector(
            input_dim,
            h_dim,
            dropout=dropout,
        )

        self.low = RecurrentBlock(
            2 * h_dim,
            l_dim,
        )

        self.high = RecurrentBlock(
            l_dim + h_dim,
            h_dim,
        )

        self.final_norm = RMSNorm(h_dim)
        self.score_head = nn.Linear(h_dim, 1)

    def rollout(self, x_tilde):
        B = x_tilde.size(0)
        device = x_tilde.device

        zL = torch.zeros(B, self.l_dim, device=device)
        zH = torch.zeros(B, self.h_dim, device=device)

        for _ in range(self.n_cycles):
            for _ in range(self.t_steps):
                l_input = torch.cat([x_tilde, zH], dim=-1)
                zL = self.low(zL, l_input)

            h_input = torch.cat([zL, zH], dim=-1)
            zH = self.high(zH, h_input)

        return zL, self.final_norm(zH)

    def forward(self, x):
        x_tilde = self.input_projector(x)
        zL, zH = self.rollout(x_tilde)
        score_logit = self.score_head(zH).squeeze(-1)
        return score_logit

We have now built the core architecture.


12. Parameter reuse changes the compute/parameter relationship

A recurrent architecture can perform more computation without increasing its parameters proportionally.

Suppose a recurrent block has P parameters.

Running it once costs roughly one application of those parameters.

Running it sixteen times still uses P learned parameters.

But compute becomes much larger.

So:

parameter count != amount of computation

This distinction matters enormously when comparing architectures.

A model may be relatively small in parameters yet expensive in latency because it repeatedly applies those parameters.

For HRM, useful measurements include:

parameters
forward latency
number of recurrent updates
memory use
training throughput

Do not use parameter count alone as a proxy for cost.


13. Count the recurrent steps

For our schedule:

n_cycles = 4
t_steps = 4

we execute:

low-level updates = 4 × 4 = 16
high-level updates = 4

Total recurrent cell applications:

20

That means one prediction contains twenty recurrent updates before the final head.

Compare that against a feed-forward baseline.

This is exactly the kind of architectural cost that should be measured rather than inferred from the number of model parameters.


14. A feed-forward baseline is mandatory

Before claiming the hierarchy helps, build a baseline with roughly comparable hidden dimensions.

class FeedForwardBaseline(nn.Module):
    def __init__(self, input_dim=2048, hidden_dim=256):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(input_dim, hidden_dim),
            nn.ReLU(),
            nn.LayerNorm(hidden_dim),
            nn.Linear(hidden_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, 1),
        )

    def forward(self, x):
        return self.net(x).squeeze(-1)

Then compare:

validation performance
ranking performance
calibration
latency
memory
training stability

If the recurrent model does not beat the baseline on the metric that matters, the hierarchy may not be justified.

That is an architectural result too.


15. What does more recurrence buy us?

This is an experimental question.

Because our model can expose the number of cycles, we can test:

1 cycle
2 cycles
4 cycles
8 cycles

while keeping the trained weights fixed.

A useful interface is:

def rollout(self, x_tilde, max_cycles=None):
    cycles = self.n_cycles if max_cycles is None else max_cycles

Then evaluate score quality as a function of recurrent compute.

This gives us a compute curve:

quality
  │                •
  │           •
  │      •
  │   •
  └──────────────────→ cycles

Possible outcomes:

  1. more cycles improve performance;
  2. performance saturates quickly;
  3. performance becomes worse;
  4. different examples benefit from different numbers of cycles.

Those are much more informative than simply saying the model is “hierarchical”.


16. Track the trajectory

A recurrent model produces intermediate states.

Do not throw them away while debugging.

Record them.

def rollout_with_trace(self, x_tilde):
    B = x_tilde.size(0)
    device = x_tilde.device

    zL = torch.zeros(B, self.l_dim, device=device)
    zH = torch.zeros(B, self.h_dim, device=device)

    trace = []

    for cycle in range(self.n_cycles):
        for step in range(self.t_steps):
            l_input = torch.cat([x_tilde, zH], dim=-1)
            zL = self.low(zL, l_input)

            trace.append({
                "cycle": cycle,
                "step": step,
                "zL_norm": zL.norm(dim=-1).mean().item(),
                "zH_norm": zH.norm(dim=-1).mean().item(),
            })

        h_input = torch.cat([zL, zH], dim=-1)
        zH = self.high(zH, h_input)

    return zL, zH, trace

Now you can inspect whether the latent state is actually changing.


17. State-change diagnostics

A useful measurement is:

||z_t - z_(t-1)||

If this rapidly approaches zero, later recurrent steps may be doing almost nothing.

def state_delta(a, b):
    return (a - b).norm(dim=-1).mean().item()

Record:

cycle 0, step 0: 4.21
cycle 0, step 1: 1.37
cycle 0, step 2: 0.22
cycle 0, step 3: 0.03

That would suggest convergence within a cycle.

Perhaps four low-level steps are unnecessary.

Or perhaps convergence is exactly the behavior we want.

Either way, we now have evidence.


18. Cosine trajectory diagnostics

Norm change is not enough.

Two states can have similar magnitude while pointing in different directions.

Measure cosine similarity between successive states:

import torch.nn.functional as F


def cosine_change(prev, cur):
    return F.cosine_similarity(prev, cur, dim=-1).mean().item()

Values near:

1.0

mean the state direction barely changes.

Lower values mean the representation is changing more substantially.

This gives another window into recurrent dynamics.


19. Track the maximum high-level state?

One design used by HRM-style architectures is to accumulate evidence over the high-level trajectory.

For example:

zH_max = torch.maximum(zH_max, zH)

on every high-level cycle.

Then the model has access to:

final high-level state
+
maximum activation over high-level trajectory

This is not the same as remembering the entire trajectory.

It is a simple summary statistic.

That distinction matters.

A max-reduced trajectory can preserve whether some feature became strongly active, but it discards:

  • ordering;
  • how long activation lasted;
  • oscillation;
  • intermediate sign changes.

So again:

evidence accumulation is a hypothesis about what summary of the trajectory is useful.


20. Add several diagnostic heads

Once we have a final high-level representation, we can reuse the lesson from EBT and SICQL.

One representation can support many small heads.

For example:

self.score_head = nn.Linear(h_dim, 1)
self.logvar_head = nn.Linear(h_dim, 1)
self.aux3_head = nn.Linear(h_dim, 3)
self.disagree_head = nn.Linear(h_dim, 1)
self.ood_head = nn.Linear(h_dim, 1)
self.temp_head = nn.Linear(h_dim, 1)
self.recon_head = nn.Linear(h_dim, h_dim)

Architecturally:

                     zH
      ┌───────┬───────┼────────┬────────┐
      ↓       ↓       ↓        ↓        ↓
    score   logvar   aux3   disagree   OOD
      └──────── temperature calibration

                      zH
                 reconstruction

This can look impressive on a diagram.

But remember the evidence boundary from the beginning of this series:

A head name does not establish what the head measures.

An ood_head is just a scalar neural network until training data and evaluation demonstrate out-of-distribution predictive validity.


21. A calibrated score head

Suppose the model predicts a raw quality logit:

score_logit = self.score_head(zH)

We can also predict a temperature:

tau_raw = self.temp_head(zH)

and force the temperature positive:

tau = 0.5 + 0.5 * F.softplus(tau_raw)

Then:

score = torch.sigmoid(score_logit / tau)

This gives each example a learned scaling of the logit.

But be precise.

A learned temperature head does not automatically mean the resulting probability is calibrated.

Calibration must be measured.

Useful metrics include:

Brier score
expected calibration error
reliability curves
negative log likelihood

22. Aleatoric uncertainty head

A common approach is to predict log variance:

log_var = self.logvar_head(zH)

The model can then use a heteroscedastic regression objective.

For target y, prediction mu, and log variance log_var:

def heteroscedastic_loss(mu, log_var, y):
    precision = torch.exp(-log_var)
    return 0.5 * (precision * (y - mu).pow(2) + log_var).mean()

This allows the model to represent examples as having different expected noise levels.

But again:

predicted variance

is not automatically equivalent to:

all forms of epistemic uncertainty

The training objective defines the operational meaning.


23. Three-way auxiliary classification

Another head can predict coarse classes:

bad
medium
good

with:

aux3_logits = self.aux3_head(zH)

and:

loss_aux3 = F.cross_entropy(aux3_logits, labels)

Why add an auxiliary classification task if we already have a scalar score?

Possible hypothesis:

coarse class supervision may encourage representations that separate qualitatively different response regions.

But the auxiliary head may also interfere with the main task.

Measure both possibilities.


24. Disagreement prediction

Suppose multiple judges evaluate the same candidate.

Then disagreement itself can become a target.

For example:

judge scores = [0.91, 0.89, 0.25, 0.22]

has high disagreement.

You can construct a target from score variance or another disagreement statistic and train:

disagree_logit = self.disagree_head(zH)

with an appropriate objective.

Now the head has an empirically defined meaning.

Without such a target, the name is just a name.


25. Reconstruction as a comprehension proxy

Another architecture idea is to ask the final state to reconstruct the projected input.

x_recon = self.recon_head(zH)

Then compare:

recon_sim = F.cosine_similarity(
    x_recon,
    x_tilde,
    dim=-1,
)

The intuition is:

if the recurrent state has preserved useful information about its input, reconstruction should remain possible.

This is reasonable as an auxiliary constraint.

But do not overclaim it.

High reconstruction similarity does not prove semantic comprehension.

It proves the final representation contains information useful for reconstructing the chosen target representation.

That wording is less exciting and much more accurate.


26. Consistency under perturbation

We can also perturb the hidden representation:

mask = (
    torch.rand_like(zH) < 0.1
).float()

zH_masked = zH * (1.0 - mask)

Then calculate similarity:

consistency_target = F.cosine_similarity(
    zH,
    zH_masked,
    dim=-1,
)

A consistency head can be trained to predict this robustness signal.

This is useful because it turns an abstract word—“consistency”—into a concrete experimental definition.

The definition may or may not correspond to the kind of robustness your application cares about.

That is something you test.


27. Finite-difference sensitivity

A particularly useful diagnostic is to slightly perturb the input and see how much the score changes.

Create:

eps = 1e-3

x_eps = x + eps * F.normalize(
    torch.randn_like(x),
    dim=-1,
)

Run the model again:

score_eps = model(x_eps)

and estimate:

sensitivity = (
    (score_eps - score).abs() / eps
)

This is not a full Jacobian.

It is a directional finite-difference approximation.

But it gives a practical signal for local sensitivity.

The decomposition is again simple:

input
 ↓ perturb
input'
 ↓ model
score'
 ↓ compare
local sensitivity

28. A complete multi-head HRM

Here is a compact but complete version:

class HRM(nn.Module):
    def __init__(
        self,
        input_dim=2048,
        h_dim=256,
        l_dim=128,
        n_cycles=4,
        t_steps=4,
        dropout=0.1,
    ):
        super().__init__()

        self.h_dim = h_dim
        self.l_dim = l_dim
        self.n_cycles = n_cycles
        self.t_steps = t_steps

        self.input_projector = InputProjector(
            input_dim,
            h_dim,
            dropout,
        )

        self.low = RecurrentBlock(
            2 * h_dim,
            l_dim,
        )

        self.high = RecurrentBlock(
            l_dim + h_dim,
            h_dim,
        )

        self.final_norm = RMSNorm(h_dim)
        self.head_drop = nn.Dropout(dropout)

        self.score_head = nn.Linear(h_dim, 1)
        self.logvar_head = nn.Linear(h_dim, 1)
        self.aux3_head = nn.Linear(h_dim, 3)
        self.disagree_head = nn.Linear(h_dim, 1)
        self.ood_head = nn.Linear(h_dim, 1)
        self.temp_head = nn.Linear(h_dim, 1)
        self.recon_head = nn.Linear(h_dim, h_dim)

    def _rollout(self, x_tilde, max_cycles=None):
        B = x_tilde.size(0)
        device = x_tilde.device

        cycles = (
            self.n_cycles
            if max_cycles is None
            else min(max_cycles, self.n_cycles)
        )

        zL = torch.zeros(B, self.l_dim, device=device)
        zH = torch.zeros(B, self.h_dim, device=device)
        zH_max = torch.zeros_like(zH)

        for _ in range(cycles):
            for _ in range(self.t_steps):
                l_input = torch.cat([x_tilde, zH], dim=-1)
                zL = self.low(zL, l_input)

            h_input = torch.cat([zL, zH], dim=-1)
            zH = self.high(zH, h_input)
            zH_max = torch.maximum(zH_max, zH)

        zH = self.final_norm(zH)
        zH_max = self.final_norm(zH_max)

        return zL, zH, zH_max

    def forward(self, x, max_cycles=None):
        x_tilde = self.input_projector(x)
        zL, zH, zH_max = self._rollout(
            x_tilde,
            max_cycles=max_cycles,
        )

        h = self.head_drop(zH)

        score_logit = self.score_head(h)
        tau_raw = self.temp_head(h)
        tau = 0.5 + 0.5 * F.softplus(tau_raw)
        score = torch.sigmoid(score_logit / tau)

        return {
            "score": score.squeeze(-1),
            "score_logit": score_logit.squeeze(-1),
            "log_var": self.logvar_head(h).squeeze(-1),
            "aux3_logits": self.aux3_head(h),
            "disagree_logit": self.disagree_head(h).squeeze(-1),
            "ood_logit": self.ood_head(h).squeeze(-1),
            "temperature": tau.squeeze(-1),
            "reconstruction": self.recon_head(h),
            "zL": zL,
            "zH": zH,
            "zH_max": zH_max,
            "x_tilde": x_tilde,
        }

Now the entire architecture fits in one screen-sized mental model.


29. The tensor contracts

Always write the shape contracts down.

Suppose:

B = batch size
D = input dimension
H = high-level dimension
L = low-level dimension

Then:

x              [B, D]
x_tilde        [B, H]
zH             [B, H]
zL             [B, L]
l_input         [B, 2H]
h_input         [B, L + H]
score_logit     [B, 1]
aux3_logits     [B, 3]

These contracts are more useful than architecture names when debugging.


30. Add assertions

def assert_hrm_shapes(model, x, out):
    B = x.size(0)

    assert out["zH"].shape == (B, model.h_dim)
    assert out["zL"].shape == (B, model.l_dim)
    assert out["score"].shape == (B,)
    assert out["aux3_logits"].shape == (B, 3)

This is cheap protection against silent shape drift.


31. Batch size one remains dangerous

Avoid:

.squeeze()

for scalar heads.

If:

[B, 1]

and:

B = 1

then .squeeze() can produce a zero-dimensional tensor.

Prefer:

.squeeze(-1)

so:

[1, 1] → [1]

not:

[1, 1] → []

32. Device-safe state initialization

A recurring bug in custom recurrent code is allocating the hidden state on CPU while the model input lives on CUDA.

Bad:

zH = torch.zeros(B, H)

Better:

zH = torch.zeros(
    B,
    H,
    device=x.device,
    dtype=x.dtype,
)

If mixed precision is involved, dtype alignment matters as well as device alignment.


33. Do not store a stale device string

It is tempting to do:

self.device = "cuda"

inside __init__.

Then later:

model.to("cpu")

and the stored string is wrong.

Prefer deriving device from the input or parameters:

device = x.device

or:

device = next(self.parameters()).device

This keeps state allocation aligned with the model’s real runtime location.


34. Gradient flow through repeated computation

Because recurrent parameters are reused, gradients from every recurrent application accumulate into the same parameters.

The computational graph looks conceptually like:

           W
       ↙   ↓   ↘
step 1  step 2  step 3
   \      |      /
      final loss

So deeper recurrence can produce different gradient behavior than a one-pass model.

Track gradient norms.

def gradient_report(model):
    for name, p in model.named_parameters():
        if p.grad is None:
            print(name, "NO GRAD")
        else:
            print(
                name,
                float(p.grad.norm()),
            )

Watch especially:

low.cell.*
high.cell.*
input_projector.*

35. Gradient clipping

Recurrent models can be sensitive to large gradients.

A practical safety mechanism is:

torch.nn.utils.clip_grad_norm_(
    model.parameters(),
    max_norm=1.0,
)

But clipping is not a substitute for understanding instability.

If gradients constantly exceed the clipping threshold, investigate:

  • learning rate;
  • recurrent depth;
  • initialization;
  • normalization;
  • loss scaling;
  • problematic data.

36. Prove parameters actually update

Before a long training run:

before = {
    name: p.detach().clone()
    for name, p in model.named_parameters()
}

optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()

for name, p in model.named_parameters():
    delta = (
        p.detach() - before[name]
    ).abs().max().item()

    print(name, delta)

If recurrent cells have gradients but do not change, inspect optimizer membership and learning rate.


37. Overfit a tiny batch

The debugging rule from the PyTorch series still applies.

Take perhaps:

8 examples

and train repeatedly on them.

If the model cannot substantially overfit a tiny deterministic batch, do not start tuning large-scale hyperparameters.

Possible causes include:

  • broken targets;
  • detached graph;
  • incorrect state initialization;
  • optimizer omission;
  • impossible objective;
  • dropout noise;
  • recurrent instability.

38. Train with a simple target first

Before training seven diagnostic heads simultaneously, verify the recurrent core with one target.

pred = model(x)["score_logit"]
loss = F.mse_loss(pred, target)

Only after this pipeline works should you introduce:

uncertainty loss
auxiliary classification
reconstruction
consistency
OOD

This isolates failures.

Architectural sophistication should be introduced incrementally.


39. Multi-task loss

Eventually we may use:

loss = (
    lambda_score * loss_score
    + lambda_var * loss_var
    + lambda_aux3 * loss_aux3
    + lambda_disagree * loss_disagree
    + lambda_recon * loss_recon
    + lambda_ood * loss_ood
)

Every coefficient matters.

Print the unweighted losses first.

Otherwise one task can dominate simply because its numerical scale is larger.


40. Measure per-task gradients

Shared recurrence means auxiliary tasks can change the same recurrent parameters.

Inspect their gradient directions.

For losses A and B:

gA = torch.autograd.grad(
    loss_A,
    model.high.parameters(),
    retain_graph=True,
    allow_unused=True,
)

gB = torch.autograd.grad(
    loss_B,
    model.high.parameters(),
    retain_graph=True,
    allow_unused=True,
)

Flatten and compare cosine similarity.

If it is negative, the tasks are pushing the shared recurrent core in opposing directions on that batch.

This does not prove multi-task learning is bad.

It tells you there is an optimization trade-off to investigate.


41. Ablate the hierarchy

A useful hierarchy ablation is not merely:

HRM vs no model

Compare:

feed-forward baseline
single recurrent state
hierarchical recurrent state

That tells you where gains appear.

For example:

FFN       accuracy 0.78
single RNN accuracy 0.79
HRM       accuracy 0.82

would be more informative than only reporting HRM.

But also compare cost:

FFN       0.4 ms
single RNN 1.1 ms
HRM       4.9 ms

Architecture is always a trade-off.


42. Ablate the number of low-level steps

Test:

T = 1
T = 2
T = 4
T = 8

while keeping high-level cycles fixed.

Question:

does repeated low-level computation add useful information?

If not, the hierarchy may be unnecessarily expensive.


43. Ablate the number of high-level cycles

Similarly test:

N = 1
N = 2
N = 4
N = 8

Plot:

quality vs cycles
latency vs cycles

The most useful architecture may not be the largest setting.


44. Randomize the hierarchy schedule

A stronger experiment asks whether the exact hierarchical schedule matters.

For example, compare:

4 low updates → 1 high update

against:

1 low → 1 high → repeat

or another compute-matched schedule.

If all schedules perform similarly, perhaps hierarchy is not the source of the improvement.

Maybe repeated compute alone is enough.

That is exactly the kind of alternative explanation we should test.


45. Compute-matched baselines

This is especially important.

Suppose HRM performs twenty recurrent operations.

A fair comparison should include a non-hierarchical model with a similar compute budget.

Otherwise:

HRM beats baseline

may simply mean:

more compute beats less compute

not:

hierarchical scheduling is beneficial

A good experiment separates these possibilities.


46. Parameter-matched baselines

The opposite comparison also matters.

Because recurrence reuses weights, HRM can execute many steps with relatively few parameters.

Compare against:

same parameter count

and:

same compute budget

These answer different questions.

Both are useful.


47. Does the high-level state actually differ from the low-level state?

A two-state architecture does not guarantee functional specialization.

We should test whether the states encode different information.

Possible probes:

train a linear probe on zL
train a linear probe on zH

for several targets.

If both states support exactly the same predictions equally well, specialization may be weak.

If different information is linearly accessible from each, that is evidence of representational differentiation.

Not proof of “fast thinking” and “slow thinking”—but actual measurable differentiation.


48. Probe each cycle

Do not only inspect final zH.

Save:

zH_1
zH_2
zH_3
zH_4

and train simple probes or evaluate the main head at each cycle.

Then we can ask:

When does useful predictive information emerge?

Perhaps:

cycle 1: 0.71
cycle 2: 0.79
cycle 3: 0.81
cycle 4: 0.81

That suggests the fourth cycle may be unnecessary.


49. Score convergence

Another simple diagnostic:

run the score head after every high-level cycle.

cycle 1: 0.62
cycle 2: 0.74
cycle 3: 0.76
cycle 4: 0.761

The score appears to converge.

That opens the door to adaptive computation.

Instead of always running four cycles, perhaps some examples can halt earlier.

But we should first demonstrate that score convergence is reliable.


50. Halting is another model

Suppose we want adaptive computation.

We might add:

self.halt_head = nn.Linear(h_dim, 1)

and predict:

halt_prob = torch.sigmoid(
    self.halt_head(zH)
)

Now we have another model inside the model.

It needs:

  • a target;
  • an objective;
  • evaluation;
  • a decision threshold.

Simply producing a halting probability does not mean the model knows when it has “finished reasoning.”

That semantic claim must be earned.


51. A simple convergence-based halting rule

Before training a halting head, test a deterministic rule.

For example:

if (score_cur - score_prev).abs().max() < 1e-3:
    break

or latent convergence:

if (zH_cur - zH_prev).norm(dim=-1).mean() < threshold:
    break

This is a useful baseline for any learned halting mechanism.

Never compare a learned controller only against “always run everything.”

Compare against simple heuristics too.


52. Evaluation should include compute-aware metrics

For recurrent models, report more than predictive quality.

A useful table might include:

model        quality   params   recurrent steps   latency
FFN          0.78      1.2M     0                 0.4 ms
single RNN   0.80      0.8M     16                2.9 ms
HRM          0.82      0.9M     20                4.6 ms

Now architecture choices become visible.


53. Checkpoint the schedule

A recurrent model checkpoint is not fully described by weights alone.

Store configuration:

checkpoint = {
    "model_state": model.state_dict(),
    "input_dim": model.input_dim,
    "h_dim": model.h_dim,
    "l_dim": model.l_dim,
    "n_cycles": model.n_cycles,
    "t_steps": model.t_steps,
}

Why?

Because changing:

n_cycles

changes the computation even though tensor shapes remain compatible.

Configuration is part of the model contract.


54. Reproducibility is harder with recurrence

Small implementation changes can alter repeated computation significantly.

Record:

  • seed;
  • architecture config;
  • dropout;
  • optimizer;
  • learning rate;
  • gradient clipping;
  • number of cycles;
  • low-level steps;
  • embedding version;
  • loss weights.

A recurrent architecture creates more places where hidden differences can accumulate.


55. The debugger view

When HRM produces a strange output, do not stare at the final score.

Trace the pipeline:

input finite?
projected input finite?
zL after each step finite?
zH after each cycle finite?
state norms stable?
state deltas meaningful?
head inputs finite?
head outputs finite?

This is exactly why the previous PyTorch debugging series matters.

Once an architecture becomes iterative, runtime evidence becomes even more valuable.


56. A reusable trajectory debugger

@torch.no_grad()
def debug_hrm(model, x):
    x_tilde = model.input_projector(x)

    B = x.size(0)
    zL = torch.zeros(
        B,
        model.l_dim,
        device=x.device,
        dtype=x.dtype,
    )
    zH = torch.zeros(
        B,
        model.h_dim,
        device=x.device,
        dtype=x.dtype,
    )

    print("x", x.shape, torch.isfinite(x).all().item())
    print(
        "x_tilde",
        x_tilde.shape,
        float(x_tilde.norm(dim=-1).mean()),
    )

    for cycle in range(model.n_cycles):
        for step in range(model.t_steps):
            prev = zL.clone()
            l_input = torch.cat([x_tilde, zH], dim=-1)
            zL = model.low(zL, l_input)

            print(
                "L",
                cycle,
                step,
                "norm",
                float(zL.norm(dim=-1).mean()),
                "delta",
                float((zL - prev).norm(dim=-1).mean()),
            )

        prev_h = zH.clone()
        h_input = torch.cat([zL, zH], dim=-1)
        zH = model.high(zH, h_input)

        print(
            "H",
            cycle,
            "norm",
            float(zH.norm(dim=-1).mean()),
            "delta",
            float((zH - prev_h).norm(dim=-1).mean()),
        )

That tells us far more than:

model output looks wrong

57. The model inside HRM

Now step back.

What is HRM actually made of?

HRM
├── InputProjector
│   ├── Linear
│   ├── Dropout
│   └── RMSNorm
├── Low-level RecurrentBlock
│   ├── GRUCell
│   └── RMSNorm
├── High-level RecurrentBlock
│   ├── GRUCell
│   └── RMSNorm
├── rollout schedule
│   ├── low-level inner loop
│   └── high-level outer loop
└── prediction heads
    ├── score
    ├── variance
    ├── auxiliary class
    ├── disagreement
    ├── OOD
    ├── temperature
    └── reconstruction

That is the architecture.

The intimidating name has disappeared.


58. What is genuinely new here?

Compared with SICQL, the genuine architectural novelty is not the heads.

We already understand heads.

The new idea is:

reused recurrent computation
+
two latent states
+
different update frequencies

Everything else is composition around that core.

This is exactly how we should read unfamiliar models:

  1. identify the new mechanism;
  2. separate it from familiar supporting machinery;
  3. test whether the new mechanism matters.

59. What HRM does not prove

The architecture does not by itself prove:

reasoning
hierarchical cognition
uncertainty awareness
out-of-distribution awareness
comprehension
adaptive intelligence

It provides mechanisms that can be trained and tested for operational definitions of those concepts.

That distinction keeps our claims attached to evidence.


60. When might hierarchical recurrence be useful?

Possible cases include tasks where:

  • repeated refinement helps;
  • a compact parameter budget is desirable;
  • variable compute may be useful;
  • intermediate latent trajectories contain signal;
  • multiple auxiliary objectives can shape one recurrent representation.

But the architecture should earn its complexity against simpler baselines.


61. When might it be the wrong choice?

If:

  • latency is critical;
  • a shallow scorer already performs well;
  • the dataset is small enough that recurrence overfits;
  • intermediate computation does not improve predictions;
  • recurrent training is unstable;

then HRM may be an unnecessary complication.

“More sophisticated architecture” is not itself a goal.


62. The crucial experiment

If I had to choose one experiment for this architecture, it would be:

Does additional recurrent computation improve held-out performance after controlling for parameter count and compute?

That question cuts through most of the mythology.

Test:

feed-forward
single recurrent
hierarchical recurrent

under:

parameter-matched
compute-matched
latency-reported

conditions.

Then inspect performance as cycles increase.

That would tell us much more than the architecture diagram alone.


63. Where the architecture wants to go next

HRM gives us powerful machinery.

But it is not small.

It contains:

  • two recurrent systems;
  • multiple update scales;
  • many diagnostic heads;
  • repeated computation;
  • several auxiliary objectives.

That naturally creates the next question:

Can we preserve the useful idea of iterative latent refinement in a smaller, simpler architecture?

Instead of maintaining two recurrent states:

zL
zH

what if we maintain one latent state:

z

and repeatedly refine it using:

input + candidate + previous z

That takes us toward Tiny.

Conceptually:

HRM

x̃ + zH → zL → zL → zL
          zL + zH → zH
            repeat

becomes something closer to:

Tiny

x + y + z
  block
   z'
 residual update
 repeat

The hierarchy becomes recursion.

And the model becomes much easier to make parameter-efficient.

That is the next stage of Models From First Principles.


Final mental model

If you remember only one diagram from this post, remember this:

input embedding
   projection
      ├─────────────────────────────┐
      │                             │
      ▼                             │
[x̃ + zH] → low recurrent block     │
      ↓                             │
     zL                             │
      ↓ repeat T times              │
     zL                             │
      │                             │
      ▼                             │
[zL + zH] → high recurrent block    │
      ↓                             │
     zH ────────────────────────────┘
      │       repeat N cycles
 final representation
 ┌────┼───────────────┐
 ↓    ↓               ↓
score uncertainty diagnostics

HRM looks like a large model.

But once decomposed, it is mostly:

Linear
GRUCell
RMSNorm
concatenate
repeat
small heads

The architecture is in how those pieces are scheduled and connected.

That is the real lesson.