File size: 23,435 Bytes
ba7ef34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Main manuscript tables and headline values, recomputed from the artifact.

Run this and diff against the draft. Validation-checkpoint epochs and epoch-matched
shares are emitted by the companion ``checkpoint_selection_audit.py``.

    python scripts/paper_numbers.py            # human-readable
    python scripts/paper_numbers.py --json     # machine-readable

Sources (all under ../drift_run unless noted):
    drift_out/drift_by_stage.csv       per (chain, layer, stage) drift, 3 inner products
    drift_out/drift_per_feature.csv    per-latent drift, density, persistence
    drift_out/base_feature_z.csv       per-latent logit-lens peakedness at the base SAE
    drift_out/null_replicates.csv      null-step replicates + real first transitions
    drift_out/null_trajectory_l18.csv  the originally reported 7-step null trajectory
    drift_out/null_trajectory_l18_seeds.csv  the same, 4 seeds (supersedes the above)
    null_chain_all/layer{L}_seed{S}/   per-layer null trajectories, 3 seeds each
    null_budget/                       epoch-budget sweep + budget-matched trajectories
    arch_null/arch_null_trajectory.csv TopK / BatchTopK / JumpReLU at matched recipe
    saes_local/, null_ctrl/, null_seeds/  SAE weights (for cosines and hashes)
    ../sae_rl/results/sae_checkpoint_metrics.csv    reconstruction fidelity
    ../sae_rl/results/task_performance.csv          GSM8K accuracy
    ../sae_rl/data/activations*/                    activation-set sizes

Sections needing SAE weights (direction, budget, trajectories, alignment, persistent
null) run on CPU by default and are skipped with a warning if the weights are absent,
so the script still works from the CSVs alone.
"""

import argparse
import glob
import json
import os
import pathlib
import re

import pandas as pd
from scipy.stats import spearmanr

DEV = "cpu"

HERE = pathlib.Path(__file__).resolve().parent


def _find(*parts):
    """Locate a data directory whether this paper tree sits inside sae_rl/ or beside it.

    The paper moved out of sae_rl/paper/ on 2026-08-20, so the data root is one
    level further away than it used to be. Try both rather than pinning either.
    """
    for base in (HERE.parent.parent, HERE.parent.parent / "sae_rl"):
        cand = base.joinpath(*parts)
        if cand.exists():
            return cand
    return HERE.parent.parent.joinpath(*parts)


DRIFT = _find("drift_run", "drift_out")
RESULTS = _find("sae_rl", "results")

ACC = "val-core/openai/gsm8k/acc/mean@1"
# Chains sharing a bit-identical base SAE, so cross-chain latent indices are
# comparable. Verified by hashing W_dec; see base_alignment().
ALIGNED = ["sae_sft", "sae_flexible", "sae_strict/k64"]


def _round(o, n=4):
    if isinstance(o, dict):
        return {k: _round(v, n) for k, v in o.items()}
    if isinstance(o, list):
        return [_round(v, n) for v in o]
    return round(o, n) if isinstance(o, float) else o


def artifact(drift):
    """Size of the artifact: SAEs, chains, layers."""
    return {
        "n_saes": int(len(drift)),
        "n_chains": int(drift.chain.nunique()),
        "layers": sorted(drift.layer.unique().tolist()),
        "chain_n_checkpoints": {c: int(g.stage.nunique())
                                for c, g in drift.groupby("chain")},
        "d_sae": int(drift.n_features.iloc[0]),
    }


def identity_vs_fidelity(drift, metrics):
    """Inherited-index alignment turns over while reconstruction stays flat."""
    out = {}
    fin = drift[(drift.chain == "sae_sft") & (drift.step == 348)]
    for _, r in fin.iterrows():
        out[f"L{int(r.layer)}"] = {
            "dec_cos_geom": r.dec_geom_all_cos_matched_mean,
            "dec_cos_readout": r.dec_readout_all_cos_matched_mean,
            "dec_cos_lens": r.dec_lens_all_cos_matched_mean,
            "tok_jaccard": r.tok_jaccard_matched_all,
            "frac_top1_identical": r.frac_identical_top1_all,
            "frac_top1_changed": 1 - r.frac_identical_top1_all,
        }
    f = metrics[(metrics.chain == "sft") & (metrics.layer == 18)]
    out["L18_fidelity_over_sft"] = {
        "nmse_min": f.nmse_recomputed.min(), "nmse_max": f.nmse_recomputed.max(),
        "frac_rec_min": f.frac_rec.min(), "frac_rec_max": f.frac_rec.max(),
        "dead_pct_max": f.dead_latents_pct.max(),
    }
    out["dead_pct_max_all_chains"] = metrics.dead_latents_pct.max()
    return out


def metric_agreement(drift):
    """The three inner products must agree, else the geometry story is metric-dependent."""
    cols = ["dec_geom_all_drift_norm", "dec_readout_all_drift_norm",
            "dec_lens_all_drift_norm"]
    d = drift[drift.step > 0].copy()
    d["spread"] = d[cols].max(axis=1) - d[cols].min(axis=1)
    per_cell = d.groupby(["chain", "layer"]).spread.max()
    return {
        "n_cells": int(len(per_cell)),
        "max_spread": per_cell.max(),
        "median_spread": per_cell.median(),
        "argmax_cell": "/".join(str(x) for x in per_cell.idxmax()),
    }


def null_step(reps):
    """How much of a real first transition a warm-started retraining reproduces."""
    nulls, out = {}, {}
    for L, g in reps[reps.kind == "nullstep"].groupby("layer"):
        nulls[L] = {"n": int(len(g)), "drift_mean": g.drift.mean(),
                    "drift_min": g.drift.min(), "drift_max": g.drift.max(),
                    "tokJ_mean": g.tok_jaccard.mean()}
    shares = []
    for _, r in reps[reps.kind == "real"].iterrows():
        n = nulls[r.layer]
        shares.append({
            "layer": int(r.layer), "chain": r.label,
            "real_drift": r.drift, "null_drift_mean": n["drift_mean"],
            "pct_mean": 100 * n["drift_mean"] / r.drift,
            "pct_lo": 100 * n["drift_min"] / r.drift,
            "pct_hi": 100 * n["drift_max"] / r.drift,
            # top-token turnover share: (1 - J_null) / (1 - J_real)
            "tok_pct": 100 * (1 - n["tokJ_mean"]) / (1 - r.tok_jaccard),
        })
    out["null_replicates"] = {f"L{k}": v for k, v in nulls.items()}
    out["share_of_real"] = shares
    # Headline range, excluding L12 (null replicates there are unstable: see
    # drift_min/drift_max) and excluding cells where the real transition drifts
    # *less* than the null step (pct > 100).
    core = [s for s in shares if s["layer"] != 12 and s["pct_mean"] <= 110]
    out["headline_pct_range"] = [min(s["pct_mean"] for s in core),
                                 max(s["pct_mean"] for s in core)]
    out["headline_tok_pct_range"] = [min(s["tok_pct"] for s in core),
                                     max(s["tok_pct"] for s in core)]
    out["exceeds_100"] = [f"{s['chain']}@L{s['layer']}={s['pct_mean']:.0f}%"
                          for s in shares if s["pct_mean"] > 110]
    return out


def null_trajectory(traj, drift):
    """A whole trajectory of retrainings, with the model held fixed throughout."""
    real = (drift[(drift.chain == "sae_sft") & (drift.layer == 18)]
            .sort_values("step").reset_index(drop=True))
    rows = []
    for i, r in traj.sort_values("null_step").iterrows():
        rr = real.iloc[i] if i < len(real) else None
        rows.append({
            "step_index": int(r.null_step),
            "null_dec_cos": r.dec_cos, "null_tok_jaccard": r.tok_jaccard,
            "real_step": int(rr.step) if rr is not None else None,
            "real_dec_cos": rr.dec_geom_all_cos_matched_mean if rr is not None else None,
            "real_tok_jaccard": rr.tok_jaccard_matched_all if rr is not None else None,
        })
    end = rows[-1]
    return {
        "per_step": rows,
        "final_null_cos": end["null_dec_cos"], "final_real_cos": end["real_dec_cos"],
        "final_null_tokJ": end["null_tok_jaccard"],
        "final_real_tokJ": end["real_tok_jaccard"],
        # Fraction of the real end-to-end drift reproduced with the model frozen.
        "pct_drift_reproduced": 100 * (1 - end["null_dec_cos"]) / (1 - end["real_dec_cos"]),
        "pct_tok_turnover_reproduced":
            100 * (1 - end["null_tok_jaccard"]) / (1 - end["real_tok_jaccard"]),
    }


def shuffled_control(drift, task):
    """A reward carrying no task information, at a matched optimiser step."""
    out = {"accuracy": {}}
    for c, g in task.groupby("chain"):
        g = g.sort_values("step")
        out["accuracy"][c] = {"start": g[ACC].iloc[0], "final": g[ACC].iloc[-1],
                              "max": g[ACC].max()}
    for step in (10, 30):
        p = (drift[drift.step == step]
             .pivot_table(index="chain", columns="layer",
                          values="dec_geom_all_drift_norm"))
        cell = {}
        for ref in ("sae_flexible", "sae_strict/k64"):
            ratios = (p.loc["sae_shuffled"] / p.loc[ref])
            cell[ref] = {f"L{int(k)}": v for k, v in ratios.items()}
        out[f"step{step}_shuffled_over_informative"] = cell
        out[f"step{step}_drift_norm"] = {
            ch: {f"L{int(k)}": v for k, v in row.items()} for ch, row in p.iterrows()}
    return out


def heterogeneity(per_feat, z):
    """Stability is not uniform, and our selection heuristic rides the tail."""
    d = per_feat.merge(z, on=["chain", "layer", "feature"])
    out = {"spearman_final_sft": {}, "selection_bias_final_sft": {}}
    for L in (6, 12, 18, 23):
        s = d[(d.chain == "sae_sft") & (d.layer == L) & (d.step == 348)]
        out["spearman_final_sft"][f"L{L}"] = {
            "cos_vs_density": spearmanr(s.dec_geom_cos, s.density_base).statistic,
            "cos_vs_peakedness": spearmanr(s.dec_geom_cos, s.logit_z).statistic,
            "tokJ_vs_density": spearmanr(s.tok_jaccard, s.density_base).statistic,
            "tokJ_vs_peakedness": spearmanr(s.tok_jaccard, s.logit_z).statistic,
        }
        # The sweep's own heuristic: frequency band, then top-n by peakedness.
        pool = s[(s.density_base > 0.05) & (s.density_base < 0.5)]
        sel = pool.nlargest(12, "logit_z")
        out["selection_bias_final_sft"][f"L{L}"] = {
            "n_pool": int(len(pool)),
            "sel_tokJ": sel.tok_jaccard.mean(), "pop_tokJ": s.tok_jaccard.mean(),
            "tokJ_ratio": sel.tok_jaccard.mean() / s.tok_jaccard.mean(),
            "sel_cos": sel.dec_geom_cos.mean(), "pop_cos": s.dec_geom_cos.mean(),
        }
    r = [v["tokJ_ratio"] for v in out["selection_bias_final_sft"].values()]
    out["tokJ_ratio_range"] = [min(r), max(r)]
    return out


def _torch():
    import torch
    return torch


def _wdec(path):
    """Decoder directions [d_sae, d_model] from any of the on-disk SAE formats."""
    import sys
    sys.path.insert(0, str(_find("sae_tuned_lens").parent))
    from sae_tuned_lens.sae_io import load_sae_full
    return load_sae_full(str(path))["W_dec"].float()


def _cos(A, B):
    return _torch().nn.functional.cosine_similarity(A, B, dim=-1)


ROOT = _find("drift_run")
SFT_SAES = _find("sae_rl", "checkpoints", "saes_sft")


def direction_of_drift():
    """Does the null step move the basis the same WAY, or only as far? (§4.5)

    A share of drift is a magnitude claim. If retraining and fine-tuning rotated
    latents by similar amounts into unrelated subspaces, the shares would hold while
    retraining explained none of the real chain's particular movement.
    """
    torch = _torch()
    base = _wdec(ROOT / "saes_local/sae_sft/sae_instruct_base_layer18.pt")
    out = {}
    cases = [
        ("one_retraining", ROOT / "null_ctrl/sae_instruct_base_layer18_nullstep.pt",
         ROOT / "saes_local/sae_sft/sae_sft_step29_layer18.pt"),
        ("seven_retrainings", ROOT / "null_chain/sae_nullchain7_layer18.pt",
         ROOT / "saes_local/sae_sft/sae_sft_step348_layer18.pt"),
    ]
    for label, npath, rpath in cases:
        if not (npath.exists() and rpath.exists()):
            out[label] = "weights absent"
            continue
        nul, real = _wdec(npath), _wdec(rpath)
        c_bn = _cos(base, nul).mean().item()
        c_br = _cos(base, real).mean().item()
        c_nr = _cos(nul, real).mean().item()
        n = base.shape[0]
        g = torch.Generator().manual_seed(0)
        perm = torch.randperm(n, generator=g)
        idx = torch.arange(n)
        perm[perm == idx] = (perm[perm == idx] + 1) % n
        out[label] = {
            "cos_base_null": c_bn, "cos_base_real": c_br,
            "cos_null_real_observed": c_nr,
            "cos_null_real_if_independent": c_bn * c_br,
            "random_pairing_floor": _cos(nul, real[perm]).mean().item(),
            "excess_over_independent": c_nr - c_bn * c_br,
        }
    return out


def activation_budgets():
    """Optimisation budget of the null step vs each chain's own transitions (§3.3)."""
    torch = _torch()
    act = _find("sae_rl", "data")
    out = {}
    for sub, stem in [("activations", "instruct_base_layer18_train.pt"),
                      ("activations", "ppo_step10_layer18_train.pt"),
                      ("activations_sft", "sft_step29_layer18_train.pt"),
                      ("activations_shuffled", "ppo_step10_layer18_train.pt"),
                      ("activations_kl0p025", "ppo_step10_layer18_train.pt")]:
        p = act / sub / stem
        if not p.exists():
            continue
        n = torch.load(p, map_location="cpu", mmap=True).shape[0]
        out[f"{sub}/{stem}"] = {"tokens": int(n), "batches_at_512": n // 512,
                                "adam_steps_20_epochs": (n // 512) * 20}
    base = out.get("activations/instruct_base_layer18_train.pt")
    sft = out.get("activations_sft/sft_step29_layer18_train.pt")
    if base and sft:
        out["null_step_shortfall_vs_sft_pct"] = 100 * (
            1 - base["adam_steps_20_epochs"] / sft["adam_steps_20_epochs"])
        out["epochs_to_match_sft"] = 20 * sft["batches_at_512"] / base["batches_at_512"]
    return out


def budget_sweep(drift):
    """Nominal budget sweep, interpreted as LR-horizon sensitivity (§4.4)."""
    real = drift[(drift.chain == "sae_sft") & (drift.layer == 18)].sort_values("step")
    real_drift = 1 - real.dec_geom_all_cos_matched_mean.iloc[1]
    base = ROOT / "saes_local/sae_sft/sae_instruct_base_layer18.pt"
    if not base.exists():
        return "weights absent"
    W0 = _wdec(base)
    rows = {}
    for p in sorted(glob.glob(str(ROOT / "null_budget/sae_instruct_base_layer18_e*_s*.pt"))):
        m = re.search(r"_e(\d+)_s(\d+)", p)
        rows.setdefault(int(m.group(1)), []).append(1 - _cos(W0, _wdec(p)).mean().item())
    out = {"real_first_transition_drift": real_drift, "by_epochs": {}}
    for E in sorted(rows):
        v = rows[E]
        out["by_epochs"][E] = {
            "adam_steps": E * 696, "n_seeds": len(v),
            "drift_mean": sum(v) / len(v), "drift_min": min(v), "drift_max": max(v),
            "pct_of_real": 100 * (sum(v) / len(v)) / real_drift,
        }
    return out


def trajectories_per_layer(drift):
    """Null trajectories against the real SFT chain, per layer, over seeds (§4.4)."""
    out = {}
    for L in (6, 12, 18, 23):
        base = ROOT / f"saes_local/sae_sft/sae_instruct_base_layer{L}.pt"
        if not base.exists():
            continue
        dirs = [d for d in sorted(glob.glob(str(ROOT / f"null_chain_all/layer{L}_seed*")))
                if os.path.exists(os.path.join(d, "DONE"))]
        if L == 18:  # the originally reported chain belongs to this population
            if (ROOT / "null_chain").exists():
                dirs = [str(ROOT / "null_chain")] + dirs
        if not dirs:
            continue
        W0 = _wdec(base)
        real = (drift[(drift.chain == "sae_sft") & (drift.layer == L)]
                .sort_values("step").reset_index(drop=True))
        rf = real.dec_geom_all_cos_matched_mean.iloc[-1]
        ends = []
        for d in dirs:
            ch = sorted(glob.glob(f"{d}/sae_nullchain*_layer{L}.pt"),
                        key=lambda q: int(re.search(r"nullchain(\d+)", q).group(1)))
            if len(ch) < len(real):
                continue
            ends.append(_cos(W0, _wdec(ch[len(real) - 1])).mean().item())
        if not ends:
            continue
        pct = [100 * (1 - e) / (1 - rf) for e in ends]
        out[f"L{L}"] = {
            "n_chains": len(ends), "real_end_cos": rf,
            "null_end_cos_mean": sum(ends) / len(ends),
            "null_end_cos_min": min(ends), "null_end_cos_max": max(ends),
            "pct_reproduced_mean": sum(pct) / len(pct),
            "pct_reproduced_min": min(pct), "pct_reproduced_max": max(pct),
        }
    return out


def persistent_null(drift, per_feat):
    """The null step restricted to evaluation-persistent latents (§4.3).

    The mask is based on firing density over shared frozen-base evaluation
    activations; it does not record whether a latent was resampled in training.

    Without this, a persistent-subset real drift gets divided by an all-latent null and
    the share is wrong.
    """
    torch = _torch()
    first = {"sae_sft": 29, "sae_flexible": 10, "sae_strict/k64": 10}
    out = {}
    for L in (6, 12, 18, 23):
        base = ROOT / f"saes_local/sae_sft/sae_instruct_base_layer{L}.pt"
        if not base.exists():
            continue
        W0 = _wdec(base)
        # Every seed on disk, not a fixed subset: the seed count is what makes the
        # published shares reproducible, and s10/s11 sort after s9 only numerically.
        paths = [ROOT / f"null_ctrl/sae_instruct_base_layer{L}_nullstep.pt"]
        paths += [pathlib.Path(p) for p in
                  sorted(glob.glob(str(ROOT / f"null_seeds/sae_instruct_base_layer{L}_null_s*.pt")),
                         key=lambda q: int(re.search(r"_null_s(\d+)", q).group(1)))]
        cs = [_cos(W0, _wdec(p)) for p in paths if p.exists()]
        if not cs:
            continue
        for ch, st in first.items():
            row = drift[(drift.chain == ch) & (drift.layer == L) & (drift.step == st)]
            m = per_feat[(per_feat.chain == ch) & (per_feat.layer == L) & (per_feat.step == st)]
            if not len(row) or not len(m):
                continue
            row = row.iloc[0]
            idx = torch.tensor(m.sort_values("feature").persistent.values.astype(bool))
            na = sum(1 - c.mean().item() for c in cs) / len(cs)
            npr = sum(1 - c[idx].mean().item() for c in cs) / len(cs)
            ra = 1 - row.dec_geom_all_cos_matched_mean
            rp = 1 - row.dec_geom_persist_cos_matched_mean
            out[f"L{L}/{ch}"] = {
                "n_persist": int(idx.sum()), "null_all": na, "null_persist": npr,
                "real_all": ra, "real_persist": rp,
                "pct_all": 100 * na / ra, "pct_persist": 100 * npr / rp,
            }
    return out


def architectures():
    """Does the confound depend on the SAE parameterisation? (Jacob's grid, L12.)"""
    p = ROOT / "arch_null/arch_null_trajectory.csv"
    if not p.exists():
        return "not run"
    a = pd.read_csv(p)
    last = a.null_step.max()
    return {r.arm: {"arch": r.arch, "final_dec_cos": r.dec_cos,
                    "final_tok_jaccard": r.tok_jaccard, "mean_l0": r.mean_l0,
                    "val_mse": r.val_mse, "steps": int(last)}
            for _, r in a[a.null_step == last].iterrows()}


def fidelity_detail(metrics, drift):
    """The Table 1 / §4.1 numbers the earlier version of this script did not emit."""
    fin = metrics[(metrics.chain == "sft")].copy()
    out = {"sft_final_nmse_by_layer": {}, "sft_mse_base_to_final": {},
           "persist_counts": {}, "layer23_nmse_all_runs": {}}
    for L in (6, 12, 18, 23):
        s = fin[fin.layer == L].sort_values("step")
        if not len(s):
            continue
        out["sft_final_nmse_by_layer"][f"L{L}"] = s.nmse_recomputed.iloc[-1]
        out["sft_mse_base_to_final"][f"L{L}"] = [s.mse.iloc[0], s.mse.iloc[-1]]
    n = drift.n_persist
    out["persist_counts"] = {"min": int(n.min()), "max": int(n.max()),
                             "d_sae": int(drift.n_features.iloc[0])}
    l23 = metrics[metrics.layer == 23]
    out["layer23_nmse_all_runs"] = {"min": l23.nmse_recomputed.min(),
                                    "max": l23.nmse_recomputed.max()}
    return out


def base_alignment():
    """Cross-chain latent indices are only comparable if the base SAE is shared."""
    import hashlib
    import sys
    sys.path.insert(0, str(_find("sae_tuned_lens").parent))
    from sae_tuned_lens.sae_io import load_sae_full

    root = _find("drift_run", "saes_local")
    chains = ["sae_sft", "sae_flexible", "sae_strict/k64", "sae_shuffled", "sae_kl0p025"]
    out = {}
    for L in (6, 12, 18, 23):
        hashes = {}
        for c in chains:
            p = root / c / f"sae_instruct_base_layer{L}.pt"
            if not p.exists():
                hashes[c] = None
                continue
            W = load_sae_full(str(p))["W_dec"].float().cpu().numpy()
            hashes[c] = hashlib.md5(W.tobytes()).hexdigest()[:12]
        ref = hashes["sae_sft"]
        out[f"L{L}"] = {"hashes": hashes,
                        "shares_base": [c for c, h in hashes.items() if h == ref],
                        "own_init": [c for c, h in hashes.items()
                                     if h is not None and h != ref]}
    always_own = [c for c in chains
                  if all(c in out[f"L{L}"]["own_init"] for L in (6, 12, 18, 23))]
    out["own_init_at_every_layer"] = always_own
    out["n_chains_own_init"] = len(always_own)
    return out


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--skip-weights", "--skip-alignment", dest="skip_weights",
                    action="store_true",
                    help="skip every section that loads SAE weights (hashes, drift "
                         "direction, budget sweep, per-layer trajectories, persistent null)")
    args = ap.parse_args()

    drift = pd.read_csv(DRIFT / "drift_by_stage.csv")
    per_feat = pd.read_csv(DRIFT / "drift_per_feature.csv")
    z = pd.read_csv(DRIFT / "base_feature_z.csv")
    reps = pd.read_csv(DRIFT / "null_replicates.csv")
    traj = pd.read_csv(DRIFT / "null_trajectory_l18.csv")
    metrics = pd.read_csv(RESULTS / "sae_checkpoint_metrics.csv")
    task = pd.read_csv(RESULTS / "task_performance.csv")

    report = {
        "artifact": artifact(drift),
        "identity_vs_fidelity": identity_vs_fidelity(drift, metrics),
        "fidelity_detail": fidelity_detail(metrics, drift),
        "metric_agreement": metric_agreement(drift),
        "null_step": null_step(reps),
        "null_trajectory_l18": null_trajectory(traj, drift),
        "shuffled_control": shuffled_control(drift, task),
        "heterogeneity": heterogeneity(per_feat, z),
        "activation_budgets": activation_budgets(),
        "architectures": architectures(),
    }
    if not args.skip_weights:
        report["direction_of_drift"] = direction_of_drift()
        report["budget_sweep"] = budget_sweep(drift)
        report["trajectories_per_layer"] = trajectories_per_layer(drift)
        report["persistent_null"] = persistent_null(drift, per_feat)
        report["base_alignment"] = base_alignment()

    report = _round(report)
    if args.json:
        print(json.dumps(report, indent=2, default=str))
    else:
        for section, body in report.items():
            print(f"\n{'=' * 70}\n{section}\n{'=' * 70}")
            print(json.dumps(body, indent=2, default=str))


if __name__ == "__main__":
    main()