ShourenWSR commited on
Commit
0eb2556
·
verified ·
1 Parent(s): c551e4d

SYNKA 25691: verified loop_trace.py

Browse files
Files changed (1) hide show
  1. loop_trace.py +539 -0
loop_trace.py ADDED
@@ -0,0 +1,539 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-loop-step trace contract between the model and the metrics layer.
2
+
3
+ This module is the *interface* between `pretrain.loopmoe` (which produces raw
4
+ tensors) and `pretrain.metrics` (which turns them into numbers). It deliberately
5
+ imports **nothing** from torchtitan or from any trainer, so that:
6
+
7
+ * the metrics layer can be developed and tested without a trainer, and
8
+ * swapping the training backend (see HANDOVER §4.1' item 1) touches nothing here.
9
+
10
+ Division of labour (agreed with infra-metrics, 2026-08-11):
11
+
12
+ the model / trainer -> *when* to collect and *how* to persist
13
+ the metrics layer -> *what* to collect and *how* to compute it
14
+
15
+ Hence the model hands over **raw tensors, never reduced scalars**. A norm
16
+ computed in two places is two definitions of that norm; the project has already
17
+ paid for that mistake once (HANDOVER §4.4: "same-name metrics have several
18
+ non-equivalent definitions"). The single definition lives in the metrics layer.
19
+
20
+ Tensor lifetime contract (IMPORTANT)
21
+ ------------------------------------
22
+ `TracePoint` tensors are ``detach()``-ed **views of live activations**, not
23
+ copies. Passing them costs ~0 extra memory, which is why the model can afford to
24
+ hand over all R x L residual tensors instead of subsampling layers. The price is
25
+ a rule the consumer MUST obey:
26
+
27
+ 1. The collector callback is **synchronous**. When it returns, the tensors are
28
+ considered dead.
29
+ 2. The collector MUST NOT store a TracePoint (or any tensor inside one) in any
30
+ container that outlives the call -- no ``self.foo = point``, no appending to
31
+ a list, no closure capture.
32
+ 3. Anything needed across steps must first be reduced to a Python scalar.
33
+
34
+ Violating this pins whole activation graphs in memory and turns a constant-memory
35
+ probe into a leak that grows with training length. `assert_trace_released` below
36
+ turns that failure into a loud test failure instead of a slow OOM at step 40k.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import random
42
+ import weakref
43
+ from dataclasses import dataclass
44
+ from typing import Any, Protocol, runtime_checkable
45
+
46
+ import torch
47
+ import torch.nn.functional as F
48
+ from torch import Tensor
49
+
50
+ __all__ = [
51
+ "ACTIVE_TRACE_SINK",
52
+ "ROUTER_LOGITS_ARE_PRESOFTMAX",
53
+ "LOOP_AXIS_PROVENANCE",
54
+ "TracePoint",
55
+ "LossComponents",
56
+ "TraceMeta",
57
+ "TraceSink",
58
+ "LoopTraceCollector",
59
+ "NullCollector",
60
+ "run_probe_forward",
61
+ "assert_trace_released",
62
+ ]
63
+
64
+
65
+ # --- semantic markers -------------------------------------------------------
66
+ #
67
+ # HANDOVER §4.3: the project has been burned by mislabelling sigmoid/logits,
68
+ # which produced results that "looked entirely reasonable but were all wrong".
69
+ # The collector schema has a `router_logits_are_presoftmax` field that pairs with
70
+ # this constant; it is a constant rather than a runtime flag because the model
71
+ # has exactly one behaviour and a flag that can only take one value is a lie
72
+ # waiting to happen.
73
+
74
+ ROUTER_LOGITS_ARE_PRESOFTMAX: bool = True
75
+ """`TracePoint.router_logits` is the raw router linear output (pre-softmax)."""
76
+
77
+ LOOP_AXIS_PROVENANCE: str = "hook_call_index"
78
+ """How `TracePoint.loop_step` was determined.
79
+
80
+ The value means the loop index was threaded down from the Python `for` loop that
81
+ drives the stack -- a per-module call counter. It was **not** recovered by
82
+ reshaping a flattened layer axis, which silently yields transposed semantics
83
+ (HANDOVER §4.3).
84
+
85
+ **The exact string matters.** It is not a description; it is a value the
86
+ diagnostics pipeline validates. `looped_diag/collect/schema.py` accepts only::
87
+
88
+ allowed = ("hook_call_index", "hook_call_index+external")
89
+
90
+ and raises otherwise (schema.py:398-401), with `probe_*.py` scripts asserting the
91
+ same. An earlier draft of this constant read ``"explicit_call_counter"`` -- the
92
+ same claim in different words, and it would have made every §4.7 ingestion of our
93
+ traces raise. Do not "improve" the wording.
94
+ """
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class TracePoint:
99
+ """Raw per-(loop_step, layer) tensors. See the lifetime contract above.
100
+
101
+ All tensors are detached views; none require grad. Shapes use
102
+ B=batch, S=sequence, E=num_experts, k=num_active, d=d_model.
103
+ """
104
+
105
+ block: str
106
+ """Which part of the network produced this row: "head", "loop" or "tail".
107
+
108
+ Head and tail are single MoE layers run once, outside the loop (ablation recipe
109
+ 2026-09-12 section 2.1). `loop_step` and `layer_idx` describe a position inside the
110
+ shared stack and are meaningless for them; they are recorded as 0/0 and must not be
111
+ read as coordinates unless `block == "loop"`.
112
+ """
113
+
114
+ unrolled_pos: int
115
+ """Position in the unrolled network, 0-based: THE depth coordinate.
116
+
117
+ head = 0, the loop's layers run 1 .. num_stacks*num_layers_in_stack in execution
118
+ order, tail = 1 + num_stacks*num_layers_in_stack (and correspondingly higher when a
119
+ configuration has more than one head/tail layer). This is the sink's key, so it is
120
+ also the only ordering that is guaranteed unique: sorting by (loop_step, layer_idx)
121
+ puts head, tail and the loop's first layer on top of each other.
122
+ """
123
+
124
+ loop_step: int
125
+ """Which pass through the shared stack, 0-based. Range: [0, num_stacks).
126
+
127
+ Only meaningful when `block == "loop"`; 0 for head and tail.
128
+ """
129
+
130
+ layer_idx: int
131
+ """Which physical layer inside the stack, 0-based. Range: [0, num_layers_in_stack).
132
+
133
+ Only meaningful when `block == "loop"`; 0 for head and tail.
134
+ """
135
+
136
+ num_experts: int
137
+ """E for THIS layer, taken from the router tensor's own shape.
138
+
139
+ Per row, not per model, because head/tail layers keep 8 experts whatever the loop
140
+ block uses (ablation recipe 2.1) -- S4 gives the loop block 64. A consumer that reads
141
+ the model-level `TraceMeta.num_experts` and applies it to a head row computes, for
142
+ example, L2 >= 1 - 8/64 = 0.875 and reads a perfectly healthy head layer as heavily
143
+ collapsed. Nothing raises: the number is in range and looks plausible, and head/tail
144
+ behaviour is exactly what the ablation is there to measure.
145
+ """
146
+
147
+ top_k: int
148
+ """k for THIS layer, from the selection tensor's own shape. Same reasoning as above."""
149
+
150
+ router_logits: Tensor
151
+ """[B, S, E] raw router output, pre-softmax. See ROUTER_LOGITS_ARE_PRESOFTMAX."""
152
+
153
+ router_probs: Tensor
154
+ """[B, S, E] softmax over **all** E experts (not renormalised over top-k)."""
155
+
156
+ topk_idx: Tensor
157
+ """[B, S, k] indices of the selected experts."""
158
+
159
+ topk_weights: Tensor
160
+ """[B, S, k] combine weights: top-k of `router_probs`, renormalised to sum to 1.
161
+
162
+ This is the quantity the MoE combine step already computes; exposing it adds
163
+ no arithmetic.
164
+ """
165
+
166
+ residual: Tensor
167
+ """[B, S, d] the block's output tensor, with **no reduction applied**.
168
+
169
+ Semantically this is the diagnostics repo's KIND_RESIDUAL / CAPTURE_BLOCK_OUTPUT:
170
+ the return value of the decoder block's forward. The metrics layer derives
171
+ both the within-step and the step-boundary norms from this one tensor, so
172
+ that both come from a single definition.
173
+ """
174
+
175
+
176
+ @dataclass(frozen=True)
177
+ class LossComponents:
178
+ """Per-step loss breakdown. Cheap enough to emit on *every* step.
179
+
180
+ HANDOVER §4.4 calls the aux losses the "blood-pressure monitor" and asks for
181
+ them every step. The *factors* are included alongside the values because
182
+ milestone M3.5 has to be able to reconstruct, from the logs alone, which
183
+ coefficient was in force at any step (HANDOVER §3.3'').
184
+ """
185
+
186
+ task_loss: float
187
+ """Cross-entropy on next-token prediction, before any aux term."""
188
+
189
+ lb_loss: float
190
+ """Switch-style load-balancing loss, E * sum_i f_i * p_i, averaged over all
191
+ unrolled layers (num_stacks * num_layers_in_stack)."""
192
+
193
+ lb_loss_factor: float
194
+ """Coefficient multiplying `lb_loss` in `total_loss`."""
195
+
196
+ z_loss: float
197
+ """Router z-loss, mean((logsumexp logits)^2), averaged the same way."""
198
+
199
+ z_loss_factor: float
200
+ """Coefficient multiplying `z_loss` in `total_loss`."""
201
+
202
+ total_loss: float
203
+ """task_loss + lb_loss_factor * lb_loss + z_loss_factor * z_loss."""
204
+
205
+
206
+ @dataclass(frozen=True)
207
+ class TraceMeta:
208
+ """Shape/provenance context so a collector never has to infer structure."""
209
+
210
+ num_stacks: int
211
+ """R: how many times the shared stack is called (loop count)."""
212
+
213
+ num_layers_in_stack: int
214
+ """L: physical layers per stack."""
215
+
216
+ num_experts: int
217
+ """E: experts per MoE layer."""
218
+
219
+ num_active: int
220
+ """k: experts activated per token."""
221
+
222
+ router_logits_are_presoftmax: bool = ROUTER_LOGITS_ARE_PRESOFTMAX
223
+ loop_axis_provenance: str = LOOP_AXIS_PROVENANCE
224
+
225
+
226
+ # A sink is just the dict the model fills in during a traced forward. The
227
+ # trainer creates one, passes it to the model, hands it to the collector, and
228
+ # drops it -- so no hook registration/deregistration dance, and no state living
229
+ # on the modules between steps.
230
+ TraceSink = dict[int, TracePoint]
231
+ """Keyed by ``unrolled_pos`` -- explicit, never shape-derived.
232
+
233
+ It was keyed by ``(loop_step, layer_idx)`` until the head/tail layers arrived (ablation
234
+ recipe 2026-09-12). Those run outside the loop, so they have no meaningful value for
235
+ either coordinate; recording them as 0/0 -- which is what they are -- would have put
236
+ head, the loop's very first layer, and tail on the same key, and a dict assignment does
237
+ not complain. Two of the three rows would have vanished with nothing in the output
238
+ saying so. `unrolled_pos` is the depth coordinate Research defined, and keying on it
239
+ makes the key unique by construction rather than by a sentinel convention that a later
240
+ reader has to know about.
241
+ """
242
+
243
+
244
+ class _ActiveTraceSink:
245
+ """The sink the blocks write into, reached as a module-level object.
246
+
247
+ Why this exists rather than passing the dict down as a keyword argument:
248
+ `fully_shard` is applied per block (`src/pretrain/train_spec.py`), and FSDP2
249
+ repacks kwargs across that boundary -- a dict passed by keyword arrives as a
250
+ fresh copy at every sharded block. The blocks were writing faithfully into
251
+ copies that were then discarded, so multi-GPU runs produced no router rows
252
+ at all while every intermediate layer looked correct. Diagnosed 2026-09-05
253
+ by printing `id()` on both sides: identical single-process, different at
254
+ every block under two ranks.
255
+
256
+ A module-level object does not cross that boundary, which is exactly why
257
+ `AUX_LOSS_STATE` never had the problem -- the aux losses travel up through
258
+ return values and are published to a global. This is the same pattern.
259
+
260
+ Process-local by construction: each rank has its own interpreter and so its
261
+ own instance, which is what makes the collected values rank-local. That is a
262
+ property to label in the output, not to hide -- see `aggregation_scope` in
263
+ the router rows.
264
+ """
265
+
266
+ def __init__(self) -> None:
267
+ self.sink: TraceSink | None = None
268
+
269
+ def arm(self) -> TraceSink:
270
+ """Start collecting; returns the dict that will be filled."""
271
+ self.sink = {}
272
+ return self.sink
273
+
274
+ def disarm(self) -> TraceSink | None:
275
+ """Stop collecting and hand back whatever was gathered."""
276
+ sink, self.sink = self.sink, None
277
+ return sink
278
+
279
+
280
+ ACTIVE_TRACE_SINK = _ActiveTraceSink()
281
+
282
+
283
+ @runtime_checkable
284
+ class LoopTraceCollector(Protocol):
285
+ """What the metrics layer implements; what the training loop calls."""
286
+
287
+ def on_metrics_step(
288
+ self, step: int, loss: LossComponents, grad_norm: float | None = None
289
+ ) -> dict[str, float]:
290
+ """Called on **every** optimizer step. Returns flat scalars to log.
291
+
292
+ `grad_norm` is passed only on the steps where the training loop
293
+ already has it as a host float; on all other steps it is `None` and
294
+ the field is omitted from the row rather than guessed at.
295
+ """
296
+ ...
297
+
298
+ def on_trace_step(
299
+ self, step: int, sink: TraceSink, meta: TraceMeta
300
+ ) -> dict[str, float]:
301
+ """Called every N steps with the raw tensors.
302
+
303
+ MUST be synchronous and MUST NOT retain any tensor from `sink`
304
+ (see the lifetime contract at the top of this module).
305
+ """
306
+ ...
307
+
308
+ def on_probe_step(
309
+ self, step: int, sink: TraceSink, meta: TraceMeta, per_token_loss: Tensor
310
+ ) -> dict[str, float]:
311
+ """Called every M steps with a forward over the fixed probe corpus.
312
+
313
+ Deliberately a separate method rather than `on_trace_step` with a
314
+ `source="probe"` flag: probe data answers different questions
315
+ (cross-loop-step overlap, repetition-stratified loss) and lands in a
316
+ different file, and a flag is something a downstream analysis can forget
317
+ to filter on.
318
+
319
+ `sink` is structurally identical to `on_trace_step`'s. `per_token_loss`
320
+ is [B, S] and obeys the same no-retention contract.
321
+ """
322
+ ...
323
+
324
+
325
+ class NullCollector:
326
+ """No-op collector, so training runs with metrics switched off."""
327
+
328
+ def on_metrics_step(
329
+ self, step: int, loss: LossComponents, grad_norm: float | None = None
330
+ ) -> dict[str, float]:
331
+ return {}
332
+
333
+ def on_trace_step(
334
+ self, step: int, sink: TraceSink, meta: TraceMeta
335
+ ) -> dict[str, float]:
336
+ return {}
337
+
338
+ def on_probe_step(
339
+ self, step: int, sink: TraceSink, meta: TraceMeta, per_token_loss: Tensor
340
+ ) -> dict[str, float]:
341
+ return {}
342
+
343
+
344
+ def _snapshot_rng() -> dict[str, Any]:
345
+ """Capture every RNG stream a probe forward could disturb."""
346
+ state: dict[str, Any] = {
347
+ "python": random.getstate(),
348
+ "torch": torch.get_rng_state(),
349
+ }
350
+ try:
351
+ import numpy as np
352
+
353
+ state["numpy"] = np.random.get_state()
354
+ except ImportError: # pragma: no cover - numpy is a hard dependency in practice
355
+ pass
356
+ if torch.cuda.is_available():
357
+ state["cuda"] = torch.cuda.get_rng_state_all()
358
+ return state
359
+
360
+
361
+ def _restore_rng(state: dict[str, Any]) -> None:
362
+ random.setstate(state["python"])
363
+ torch.set_rng_state(state["torch"])
364
+ if "numpy" in state:
365
+ import numpy as np
366
+
367
+ np.random.set_state(state["numpy"])
368
+ if "cuda" in state:
369
+ torch.cuda.set_rng_state_all(state["cuda"])
370
+
371
+
372
+ def run_probe_forward(
373
+ model: Any,
374
+ input_ids: Tensor,
375
+ *,
376
+ collector: LoopTraceCollector,
377
+ step: int,
378
+ ) -> dict[str, float]:
379
+ """Run the fixed probe corpus through the model and hand it to the collector.
380
+
381
+ Three isolation properties, each of which exists because violating it would
382
+ corrupt something silently:
383
+
384
+ 1. **No gradients, eval mode.** LT2 disabled probing outright because
385
+ activation memory multiplies by the loop count (HANDOVER §3.4); at R=16
386
+ this is 4x the pressure they measured, so a probe that built a graph would
387
+ OOM at exactly the configurations we most want to measure.
388
+ 2. **RNG is restored on exit** (python / numpy / torch / cuda). Without this,
389
+ whether a probe ran would change the training trajectory, and a resumed
390
+ run would silently diverge from an uninterrupted one -- breaking the §4.6
391
+ requirement that the two be statistically indistinguishable.
392
+ 3. **The training dataloader is never touched.** The probe corpus is a fixed
393
+ tensor held separately, so probing does not advance the data position that
394
+ checkpoints record.
395
+
396
+ The model's training/eval mode is restored even if the collector raises.
397
+
398
+ Returns whatever scalars the collector produced.
399
+ """
400
+ was_training = model.training
401
+ rng_state = _snapshot_rng()
402
+ sink: TraceSink = {}
403
+ try:
404
+ model.eval()
405
+ with torch.no_grad():
406
+ out = model(input_ids, trace_sink=sink)
407
+ logits = out.logits if hasattr(out, "logits") else out
408
+
409
+ # Next-token targets. The model does not shift internally (the
410
+ # dataloader supplies pre-shifted labels during training), so the
411
+ # shift is explicit here. The final position has no next token and is
412
+ # masked out; cross_entropy returns 0.0 there.
413
+ labels = input_ids.new_full(input_ids.shape, -100)
414
+ labels[:, :-1] = input_ids[:, 1:]
415
+ per_token_loss = F.cross_entropy(
416
+ logits.transpose(1, 2).float(), labels,
417
+ reduction="none", ignore_index=-100,
418
+ ) # [B, S]
419
+
420
+ meta = model.config.trace_meta
421
+ return collector.on_probe_step(step, sink, meta, per_token_loss)
422
+ finally:
423
+ sink.clear()
424
+ _restore_rng(rng_state)
425
+ if was_training:
426
+ model.train()
427
+
428
+
429
+ def assert_trace_released(sink: TraceSink) -> None:
430
+ """Raise if a collector retained tensors from `sink` past its callback.
431
+
432
+ Call this immediately after the collector returns and after clearing local
433
+ references. It weak-references every tensor, drops the sink, and checks that
434
+ nothing kept them alive.
435
+
436
+ This is the enforcement half of the lifetime contract. It is meant to be
437
+ wired into the toy/CI run rather than the production hot path.
438
+ """
439
+ refs: list[weakref.ref] = []
440
+ for point in sink.values():
441
+ for tensor in (
442
+ point.router_logits,
443
+ point.router_probs,
444
+ point.topk_idx,
445
+ point.topk_weights,
446
+ point.residual,
447
+ ):
448
+ try:
449
+ refs.append(weakref.ref(tensor))
450
+ except TypeError: # pragma: no cover - torch tensors are weakref-able
451
+ continue
452
+ # After a `for` loop the loop variables stay bound in this frame, so `point`
453
+ # and `tensor` would still reference the *last* TracePoint when we collect --
454
+ # and this function would report itself as the leaker. Drop them explicitly.
455
+ point = tensor = None # noqa: F841 - rebinding to release references
456
+ sink.clear()
457
+
458
+ import gc
459
+
460
+ gc.collect()
461
+ leaked = sum(1 for ref in refs if ref() is not None)
462
+ if leaked:
463
+ raise RuntimeError(
464
+ f"{leaked}/{len(refs)} trace tensors are still alive after the collector "
465
+ "returned. A collector must not store TracePoint tensors beyond the "
466
+ "callback; reduce to scalars first. See the lifetime contract in "
467
+ "src/model/loop_trace.py."
468
+ )
469
+
470
+ def unrolled_position(loop_step: int, layer_idx: int, num_layers_in_stack: int) -> int:
471
+ """Depth coordinate of a loop-block layer: `loop_step * n_layers + layer_idx`.
472
+
473
+ THE definition of that arithmetic. It is needed in two places -- the legacy
474
+ checkpoint adapter in the cross-architecture probe, and the `loop_only`
475
+ branch of `cross_arch_v2_offline` that reads pre-head/tail artifacts -- and
476
+ the two must agree, because one writes the coordinate and the other reads it
477
+ back. Two copies of a formula that indexes into a dump do not fail loudly
478
+ when they drift; they silently address different cells.
479
+
480
+ Only meaningful for `block == "loop"`. Head and tail run once and are their
481
+ own physical layers, so their position comes from the network's order, not
482
+ from this arithmetic.
483
+ """
484
+ if num_layers_in_stack <= 0:
485
+ raise ValueError(
486
+ f"num_layers_in_stack must be positive, got {num_layers_in_stack}. "
487
+ "A zero or negative stack size collapses every pass onto the same "
488
+ "position, which reads as a valid coordinate."
489
+ )
490
+ return loop_step * num_layers_in_stack + layer_idx
491
+
492
+
493
+ def legacy_trace_point_factory(num_layers_in_stack: int):
494
+ """A `TracePoint` stand-in accepting the PRE-head/tail keyword set.
495
+
496
+ Checkpoints trained before the head/tail change bundle their own
497
+ `modeling_loop_lm.py`, which constructs `TracePoint(loop_step=..., layer_idx=...,
498
+ <tensors>)`. Those four fields are now required, so such a checkpoint cannot
499
+ be loaded at all -- the failure is a `TypeError` inside the bundled code,
500
+ raised before any forward pass completes.
501
+
502
+ The four fields are filled rather than defaulted, and deliberately NOT given
503
+ defaults on `TracePoint` itself: `num_experts` being required per cell is
504
+ what stops a head layer's balanced 8 experts being divided by the loop
505
+ block's 64, which would put a floor of 0.875 under `L2` and read as severe
506
+ collapse in every configuration. That protection is worth keeping for new
507
+ checkpoints even though old ones need this adapter.
508
+
509
+ A pre-head/tail model is all loop block by construction, so `block` is
510
+ `"loop"` for every row and the depth coordinate is the unrolled arithmetic.
511
+ """
512
+
513
+ # Bound HERE, not looked up inside `make`. The adapter is installed by
514
+ # REPLACING the module-level `TracePoint` name, so a lookup at call time
515
+ # would find this factory instead of the class -- the adapter would call
516
+ # itself. Capturing the class before installation is what makes the
517
+ # substitution safe.
518
+ cls = TracePoint
519
+
520
+ def make(**kwargs: Any) -> "TracePoint":
521
+ logits = kwargs.get("router_logits")
522
+ topk_idx = kwargs.get("topk_idx")
523
+ if logits is None or topk_idx is None:
524
+ raise ValueError(
525
+ "legacy TracePoint adapter needs router_logits and topk_idx to "
526
+ f"infer num_experts and top_k; got keys {sorted(kwargs)}"
527
+ )
528
+ return cls(
529
+ block="loop",
530
+ unrolled_pos=unrolled_position(
531
+ int(kwargs["loop_step"]), int(kwargs["layer_idx"]), num_layers_in_stack
532
+ ),
533
+ # Read off the tensors, which are the only source that exists here.
534
+ num_experts=int(logits.shape[-1]),
535
+ top_k=int(topk_idx.shape[-1]),
536
+ **kwargs,
537
+ )
538
+
539
+ return make