File size: 21,653 Bytes
81cf6e8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
"""Per-loop-step trace contract between the model and the metrics layer.

This module is the *interface* between `pretrain.loopmoe` (which produces raw
tensors) and `pretrain.metrics` (which turns them into numbers). It deliberately
imports **nothing** from torchtitan or from any trainer, so that:

  * the metrics layer can be developed and tested without a trainer, and
  * swapping the training backend (see HANDOVER §4.1' item 1) touches nothing here.

Division of labour (agreed with infra-metrics, 2026-08-11):

    the model / trainer  ->  *when* to collect and *how* to persist
    the metrics layer    ->  *what* to collect and *how* to compute it

Hence the model hands over **raw tensors, never reduced scalars**. A norm
computed in two places is two definitions of that norm; the project has already
paid for that mistake once (HANDOVER §4.4: "same-name metrics have several
non-equivalent definitions"). The single definition lives in the metrics layer.

Tensor lifetime contract (IMPORTANT)
------------------------------------
`TracePoint` tensors are ``detach()``-ed **views of live activations**, not
copies. Passing them costs ~0 extra memory, which is why the model can afford to
hand over all R x L residual tensors instead of subsampling layers. The price is
a rule the consumer MUST obey:

  1. The collector callback is **synchronous**. When it returns, the tensors are
     considered dead.
  2. The collector MUST NOT store a TracePoint (or any tensor inside one) in any
     container that outlives the call -- no ``self.foo = point``, no appending to
     a list, no closure capture.
  3. Anything needed across steps must first be reduced to a Python scalar.

Violating this pins whole activation graphs in memory and turns a constant-memory
probe into a leak that grows with training length. `assert_trace_released` below
turns that failure into a loud test failure instead of a slow OOM at step 40k.
"""

from __future__ import annotations

import random
import weakref
from dataclasses import dataclass
from typing import Any, Protocol, runtime_checkable

import torch
import torch.nn.functional as F
from torch import Tensor

__all__ = [
    "ACTIVE_TRACE_SINK",
    "ROUTER_LOGITS_ARE_PRESOFTMAX",
    "LOOP_AXIS_PROVENANCE",
    "TracePoint",
    "LossComponents",
    "TraceMeta",
    "TraceSink",
    "LoopTraceCollector",
    "NullCollector",
    "run_probe_forward",
    "assert_trace_released",
]


# --- semantic markers -------------------------------------------------------
#
# HANDOVER §4.3: the project has been burned by mislabelling sigmoid/logits,
# which produced results that "looked entirely reasonable but were all wrong".
# The collector schema has a `router_logits_are_presoftmax` field that pairs with
# this constant; it is a constant rather than a runtime flag because the model
# has exactly one behaviour and a flag that can only take one value is a lie
# waiting to happen.

ROUTER_LOGITS_ARE_PRESOFTMAX: bool = True
"""`TracePoint.router_logits` is the raw router linear output (pre-softmax)."""

LOOP_AXIS_PROVENANCE: str = "hook_call_index"
"""How `TracePoint.loop_step` was determined.

The value means the loop index was threaded down from the Python `for` loop that
drives the stack -- a per-module call counter. It was **not** recovered by
reshaping a flattened layer axis, which silently yields transposed semantics
(HANDOVER §4.3).

**The exact string matters.** It is not a description; it is a value the
diagnostics pipeline validates. `looped_diag/collect/schema.py` accepts only::

    allowed = ("hook_call_index", "hook_call_index+external")

and raises otherwise (schema.py:398-401), with `probe_*.py` scripts asserting the
same. An earlier draft of this constant read ``"explicit_call_counter"`` -- the
same claim in different words, and it would have made every §4.7 ingestion of our
traces raise. Do not "improve" the wording.
"""


@dataclass(frozen=True)
class TracePoint:
    """Raw per-(loop_step, layer) tensors. See the lifetime contract above.

    All tensors are detached views; none require grad. Shapes use
    B=batch, S=sequence, E=num_experts, k=num_active, d=d_model.
    """

    block: str
    """Which part of the network produced this row: "head", "loop" or "tail".

    Head and tail are single MoE layers run once, outside the loop (ablation recipe
    2026-09-12 section 2.1). `loop_step` and `layer_idx` describe a position inside the
    shared stack and are meaningless for them; they are recorded as 0/0 and must not be
    read as coordinates unless `block == "loop"`.
    """

    unrolled_pos: int
    """Position in the unrolled network, 0-based: THE depth coordinate.

    head = 0, the loop's layers run 1 .. num_stacks*num_layers_in_stack in execution
    order, tail = 1 + num_stacks*num_layers_in_stack (and correspondingly higher when a
    configuration has more than one head/tail layer). This is the sink's key, so it is
    also the only ordering that is guaranteed unique: sorting by (loop_step, layer_idx)
    puts head, tail and the loop's first layer on top of each other.
    """

    loop_step: int
    """Which pass through the shared stack, 0-based. Range: [0, num_stacks).

    Only meaningful when `block == "loop"`; 0 for head and tail.
    """

    layer_idx: int
    """Which physical layer inside the stack, 0-based. Range: [0, num_layers_in_stack).

    Only meaningful when `block == "loop"`; 0 for head and tail.
    """

    num_experts: int
    """E for THIS layer, taken from the router tensor's own shape.

    Per row, not per model, because head/tail layers keep 8 experts whatever the loop
    block uses (ablation recipe 2.1) -- S4 gives the loop block 64. A consumer that reads
    the model-level `TraceMeta.num_experts` and applies it to a head row computes, for
    example, L2 >= 1 - 8/64 = 0.875 and reads a perfectly healthy head layer as heavily
    collapsed. Nothing raises: the number is in range and looks plausible, and head/tail
    behaviour is exactly what the ablation is there to measure.
    """

    top_k: int
    """k for THIS layer, from the selection tensor's own shape. Same reasoning as above."""

    router_logits: Tensor
    """[B, S, E] raw router output, pre-softmax. See ROUTER_LOGITS_ARE_PRESOFTMAX."""

    router_probs: Tensor
    """[B, S, E] softmax over **all** E experts (not renormalised over top-k)."""

    topk_idx: Tensor
    """[B, S, k] indices of the selected experts."""

    topk_weights: Tensor
    """[B, S, k] combine weights: top-k of `router_probs`, renormalised to sum to 1.

    This is the quantity the MoE combine step already computes; exposing it adds
    no arithmetic.
    """

    residual: Tensor
    """[B, S, d] the block's output tensor, with **no reduction applied**.

    Semantically this is the diagnostics repo's KIND_RESIDUAL / CAPTURE_BLOCK_OUTPUT:
    the return value of the decoder block's forward. The metrics layer derives
    both the within-step and the step-boundary norms from this one tensor, so
    that both come from a single definition.
    """


@dataclass(frozen=True)
class LossComponents:
    """Per-step loss breakdown. Cheap enough to emit on *every* step.

    HANDOVER §4.4 calls the aux losses the "blood-pressure monitor" and asks for
    them every step. The *factors* are included alongside the values because
    milestone M3.5 has to be able to reconstruct, from the logs alone, which
    coefficient was in force at any step (HANDOVER §3.3'').
    """

    task_loss: float
    """Cross-entropy on next-token prediction, before any aux term."""

    lb_loss: float
    """Switch-style load-balancing loss, E * sum_i f_i * p_i, averaged over all
    unrolled layers (num_stacks * num_layers_in_stack)."""

    lb_loss_factor: float
    """Coefficient multiplying `lb_loss` in `total_loss`."""

    z_loss: float
    """Router z-loss, mean((logsumexp logits)^2), averaged the same way."""

    z_loss_factor: float
    """Coefficient multiplying `z_loss` in `total_loss`."""

    total_loss: float
    """task_loss + lb_loss_factor * lb_loss + z_loss_factor * z_loss."""


@dataclass(frozen=True)
class TraceMeta:
    """Shape/provenance context so a collector never has to infer structure."""

    num_stacks: int
    """R: how many times the shared stack is called (loop count)."""

    num_layers_in_stack: int
    """L: physical layers per stack."""

    num_experts: int
    """E: experts per MoE layer."""

    num_active: int
    """k: experts activated per token."""

    router_logits_are_presoftmax: bool = ROUTER_LOGITS_ARE_PRESOFTMAX
    loop_axis_provenance: str = LOOP_AXIS_PROVENANCE


# A sink is just the dict the model fills in during a traced forward. The
# trainer creates one, passes it to the model, hands it to the collector, and
# drops it -- so no hook registration/deregistration dance, and no state living
# on the modules between steps.
TraceSink = dict[int, TracePoint]
"""Keyed by ``unrolled_pos`` -- explicit, never shape-derived.

It was keyed by ``(loop_step, layer_idx)`` until the head/tail layers arrived (ablation
recipe 2026-09-12). Those run outside the loop, so they have no meaningful value for
either coordinate; recording them as 0/0 -- which is what they are -- would have put
head, the loop's very first layer, and tail on the same key, and a dict assignment does
not complain. Two of the three rows would have vanished with nothing in the output
saying so. `unrolled_pos` is the depth coordinate Research defined, and keying on it
makes the key unique by construction rather than by a sentinel convention that a later
reader has to know about.
"""


class _ActiveTraceSink:
    """The sink the blocks write into, reached as a module-level object.

    Why this exists rather than passing the dict down as a keyword argument:
    `fully_shard` is applied per block (`src/pretrain/train_spec.py`), and FSDP2
    repacks kwargs across that boundary -- a dict passed by keyword arrives as a
    fresh copy at every sharded block. The blocks were writing faithfully into
    copies that were then discarded, so multi-GPU runs produced no router rows
    at all while every intermediate layer looked correct. Diagnosed 2026-09-05
    by printing `id()` on both sides: identical single-process, different at
    every block under two ranks.

    A module-level object does not cross that boundary, which is exactly why
    `AUX_LOSS_STATE` never had the problem -- the aux losses travel up through
    return values and are published to a global. This is the same pattern.

    Process-local by construction: each rank has its own interpreter and so its
    own instance, which is what makes the collected values rank-local. That is a
    property to label in the output, not to hide -- see `aggregation_scope` in
    the router rows.
    """

    def __init__(self) -> None:
        self.sink: TraceSink | None = None

    def arm(self) -> TraceSink:
        """Start collecting; returns the dict that will be filled."""
        self.sink = {}
        return self.sink

    def disarm(self) -> TraceSink | None:
        """Stop collecting and hand back whatever was gathered."""
        sink, self.sink = self.sink, None
        return sink


ACTIVE_TRACE_SINK = _ActiveTraceSink()


@runtime_checkable
class LoopTraceCollector(Protocol):
    """What the metrics layer implements; what the training loop calls."""

    def on_metrics_step(
        self, step: int, loss: LossComponents, grad_norm: float | None = None
    ) -> dict[str, float]:
        """Called on **every** optimizer step. Returns flat scalars to log.

        `grad_norm` is passed only on the steps where the training loop
        already has it as a host float; on all other steps it is `None` and
        the field is omitted from the row rather than guessed at.
        """
        ...

    def on_trace_step(
        self, step: int, sink: TraceSink, meta: TraceMeta
    ) -> dict[str, float]:
        """Called every N steps with the raw tensors.

        MUST be synchronous and MUST NOT retain any tensor from `sink`
        (see the lifetime contract at the top of this module).
        """
        ...

    def on_probe_step(
        self, step: int, sink: TraceSink, meta: TraceMeta, per_token_loss: Tensor
    ) -> dict[str, float]:
        """Called every M steps with a forward over the fixed probe corpus.

        Deliberately a separate method rather than `on_trace_step` with a
        `source="probe"` flag: probe data answers different questions
        (cross-loop-step overlap, repetition-stratified loss) and lands in a
        different file, and a flag is something a downstream analysis can forget
        to filter on.

        `sink` is structurally identical to `on_trace_step`'s. `per_token_loss`
        is [B, S] and obeys the same no-retention contract.
        """
        ...


class NullCollector:
    """No-op collector, so training runs with metrics switched off."""

    def on_metrics_step(
        self, step: int, loss: LossComponents, grad_norm: float | None = None
    ) -> dict[str, float]:
        return {}

    def on_trace_step(
        self, step: int, sink: TraceSink, meta: TraceMeta
    ) -> dict[str, float]:
        return {}

    def on_probe_step(
        self, step: int, sink: TraceSink, meta: TraceMeta, per_token_loss: Tensor
    ) -> dict[str, float]:
        return {}


def _snapshot_rng() -> dict[str, Any]:
    """Capture every RNG stream a probe forward could disturb."""
    state: dict[str, Any] = {
        "python": random.getstate(),
        "torch": torch.get_rng_state(),
    }
    try:
        import numpy as np

        state["numpy"] = np.random.get_state()
    except ImportError:  # pragma: no cover - numpy is a hard dependency in practice
        pass
    if torch.cuda.is_available():
        state["cuda"] = torch.cuda.get_rng_state_all()
    return state


def _restore_rng(state: dict[str, Any]) -> None:
    random.setstate(state["python"])
    torch.set_rng_state(state["torch"])
    if "numpy" in state:
        import numpy as np

        np.random.set_state(state["numpy"])
    if "cuda" in state:
        torch.cuda.set_rng_state_all(state["cuda"])


def run_probe_forward(
    model: Any,
    input_ids: Tensor,
    *,
    collector: LoopTraceCollector,
    step: int,
) -> dict[str, float]:
    """Run the fixed probe corpus through the model and hand it to the collector.

    Three isolation properties, each of which exists because violating it would
    corrupt something silently:

    1. **No gradients, eval mode.** LT2 disabled probing outright because
       activation memory multiplies by the loop count (HANDOVER §3.4); at R=16
       this is 4x the pressure they measured, so a probe that built a graph would
       OOM at exactly the configurations we most want to measure.
    2. **RNG is restored on exit** (python / numpy / torch / cuda). Without this,
       whether a probe ran would change the training trajectory, and a resumed
       run would silently diverge from an uninterrupted one -- breaking the §4.6
       requirement that the two be statistically indistinguishable.
    3. **The training dataloader is never touched.** The probe corpus is a fixed
       tensor held separately, so probing does not advance the data position that
       checkpoints record.

    The model's training/eval mode is restored even if the collector raises.

    Returns whatever scalars the collector produced.
    """
    was_training = model.training
    rng_state = _snapshot_rng()
    sink: TraceSink = {}
    try:
        model.eval()
        with torch.no_grad():
            out = model(input_ids, trace_sink=sink)
            logits = out.logits if hasattr(out, "logits") else out

            # Next-token targets. The model does not shift internally (the
            # dataloader supplies pre-shifted labels during training), so the
            # shift is explicit here. The final position has no next token and is
            # masked out; cross_entropy returns 0.0 there.
            labels = input_ids.new_full(input_ids.shape, -100)
            labels[:, :-1] = input_ids[:, 1:]
            per_token_loss = F.cross_entropy(
                logits.transpose(1, 2).float(), labels,
                reduction="none", ignore_index=-100,
            )  # [B, S]

            meta = model.config.trace_meta
            return collector.on_probe_step(step, sink, meta, per_token_loss)
    finally:
        sink.clear()
        _restore_rng(rng_state)
        if was_training:
            model.train()


def assert_trace_released(sink: TraceSink) -> None:
    """Raise if a collector retained tensors from `sink` past its callback.

    Call this immediately after the collector returns and after clearing local
    references. It weak-references every tensor, drops the sink, and checks that
    nothing kept them alive.

    This is the enforcement half of the lifetime contract. It is meant to be
    wired into the toy/CI run rather than the production hot path.
    """
    refs: list[weakref.ref] = []
    for point in sink.values():
        for tensor in (
            point.router_logits,
            point.router_probs,
            point.topk_idx,
            point.topk_weights,
            point.residual,
        ):
            try:
                refs.append(weakref.ref(tensor))
            except TypeError:  # pragma: no cover - torch tensors are weakref-able
                continue
    # After a `for` loop the loop variables stay bound in this frame, so `point`
    # and `tensor` would still reference the *last* TracePoint when we collect --
    # and this function would report itself as the leaker. Drop them explicitly.
    point = tensor = None  # noqa: F841 - rebinding to release references
    sink.clear()

    import gc

    gc.collect()
    leaked = sum(1 for ref in refs if ref() is not None)
    if leaked:
        raise RuntimeError(
            f"{leaked}/{len(refs)} trace tensors are still alive after the collector "
            "returned. A collector must not store TracePoint tensors beyond the "
            "callback; reduce to scalars first. See the lifetime contract in "
            "src/model/loop_trace.py."
        )

def unrolled_position(loop_step: int, layer_idx: int, num_layers_in_stack: int) -> int:
    """Depth coordinate of a loop-block layer: `loop_step * n_layers + layer_idx`.

    THE definition of that arithmetic. It is needed in two places -- the legacy
    checkpoint adapter in the cross-architecture probe, and the `loop_only`
    branch of `cross_arch_v2_offline` that reads pre-head/tail artifacts -- and
    the two must agree, because one writes the coordinate and the other reads it
    back. Two copies of a formula that indexes into a dump do not fail loudly
    when they drift; they silently address different cells.

    Only meaningful for `block == "loop"`. Head and tail run once and are their
    own physical layers, so their position comes from the network's order, not
    from this arithmetic.
    """
    if num_layers_in_stack <= 0:
        raise ValueError(
            f"num_layers_in_stack must be positive, got {num_layers_in_stack}. "
            "A zero or negative stack size collapses every pass onto the same "
            "position, which reads as a valid coordinate."
        )
    return loop_step * num_layers_in_stack + layer_idx


def legacy_trace_point_factory(num_layers_in_stack: int):
    """A `TracePoint` stand-in accepting the PRE-head/tail keyword set.

    Checkpoints trained before the head/tail change bundle their own
    `modeling_loop_lm.py`, which constructs `TracePoint(loop_step=..., layer_idx=...,
    <tensors>)`. Those four fields are now required, so such a checkpoint cannot
    be loaded at all -- the failure is a `TypeError` inside the bundled code,
    raised before any forward pass completes.

    The four fields are filled rather than defaulted, and deliberately NOT given
    defaults on `TracePoint` itself: `num_experts` being required per cell is
    what stops a head layer's balanced 8 experts being divided by the loop
    block's 64, which would put a floor of 0.875 under `L2` and read as severe
    collapse in every configuration. That protection is worth keeping for new
    checkpoints even though old ones need this adapter.

    A pre-head/tail model is all loop block by construction, so `block` is
    `"loop"` for every row and the depth coordinate is the unrolled arithmetic.
    """

    # Bound HERE, not looked up inside `make`. The adapter is installed by
    # REPLACING the module-level `TracePoint` name, so a lookup at call time
    # would find this factory instead of the class -- the adapter would call
    # itself. Capturing the class before installation is what makes the
    # substitution safe.
    cls = TracePoint

    def make(**kwargs: Any) -> "TracePoint":
        logits = kwargs.get("router_logits")
        topk_idx = kwargs.get("topk_idx")
        if logits is None or topk_idx is None:
            raise ValueError(
                "legacy TracePoint adapter needs router_logits and topk_idx to "
                f"infer num_experts and top_k; got keys {sorted(kwargs)}"
            )
        return cls(
            block="loop",
            unrolled_pos=unrolled_position(
                int(kwargs["loop_step"]), int(kwargs["layer_idx"]), num_layers_in_stack
            ),
            # Read off the tensors, which are the only source that exists here.
            num_experts=int(logits.shape[-1]),
            top_k=int(topk_idx.shape[-1]),
            **kwargs,
        )

    return make