ICT-40a — Triangulation causale : SAE x J-Lens x F-Lens sur banc factorise

Pilote #15480 : le premier verdict causal multi-instrument du toolkit ICT, rendu sur un banc ou l’etat latent et ses facteurs sont connus, avant de passer a un LLM opaque. Le banc est le processus factorise Mess3 x RRXOR (ICT-34/#15478) : deux generateurs independants emettent en parallele, un petit transformer hookable est entraine en next-token sur leur flux tokenise joint, et les belief states exacts de chaque facteur sont calculables par filtration forward — le sol de verite existe a chaque position.

Trois instruments, trois lectures separees de la meme machine :

Instrument Ce qu’il lit Livrable ici
F-Lens belief ou l’etat est lineairement decodable (R2 held-out par couche) couches porteuses A et B
SAE quelles features latentes portent chaque facteur (AUC vs null apparie) feature A, features B
J-Lens ce que la couche cause dans la sortie (interchange -> propagation -> logits) profil d’effet par couche

Hypotheses pre-enregistrees (verbatim de l’issue, aucune ne se re-formule apres observation) :

Chaque hypothese recoit SUPPORTED / NOT_SUPPORTED / INCONCLUSIVE avec taille d’effet (mediane inter-seeds), IC bootstrap percentile et correction de Holm sur la famille {cible vs B, cible vs aleatoire}. Aucun score omnibus : l’accord et la dissociation entre instruments sont rapportes separement (acceptance #15480).

Architecture du pilote (#15475/#15479) : le pipeline analytique est numpy-only et vit dans le paquet ict/ ; la couche torch (modele + capture de panneaux + re-injection) est confinee dans scripts/ict_pilot_transformer.py. Ce notebook consomme les deux par leurs APIs testeess.

Statut épistémique — Sans verdict à ce jour : aucune ligne de la matrice de dissociations ne concerne ce notebook ; son statut épistémique sera porté par la matrice le cas échéant.

# Parametres geles du pilote : protocole pre-enregistre, aucune valeur ne se
# re-ajuste apres observation des resultats (acceptance #15480).
import json

SEEDS = (0, 1, 2, 3, 4, 5)       # 6 seeds : >= 4 exige, signflip exact atteignable
N_BENCH = 40_000                 # pas du banc par seed
SEQ_LEN, HOP = 64, 16            # fenetres du flux tokenise
STEPS = 3000                     # pas d'entrainement du transformer
TRAIN_FRAC = 0.7                 # decoupe SEQUENTIELLE gelee train/eval
GAP_WINDOWS = SEQ_LEN // HOP - 1 # fenetres sautees a la frontiere (hop < seq_len)
K_SAE, TOPK_SAE = 48, 8          # dictionnaire sparse : 48 features, top-k 8
SAE_LR = 0.02                    # pas du full-batch : 0.05 diverge sur le residual
N_SAE_TRAIN = 12_000             # lignes (sous-echantillon gele) du corpus SAE
N_NULL_SELECT = 200              # nulls des AUC de selectivite (permutations)
TOP_R_GEOM = 4                   # rang des bases factorielles (H4)
N_NULL_GEOM = 200                # nulls de l'overlap geometrique (H4)
DOSES = (0.25, 0.50, 0.75, 1.00) # fractions de suppression de la feature latente
N_EVAL_WINDOWS = 192             # fenetres eval par seed (F-Lens / SAE)
N_CAUSAL = 64                    # fenetres eval pour les bras causaux (cout borne)
N_JLENS = 64                     # fenetres eval pour le profil J-Lens
CONF_DONOR = 0.80                # confiance minimale pour un donneur J-Lens
JLENS_K = 8                      # dose fixee J-Lens : k coordonnees A, identiques a chaque couche (#16230)
JLENS_N_BANDS = 4                # axe positions : bandes contigues de SEQ_LEN // JLENS_N_BANDS
ALPHA = 0.05
LAYER_KEYS = ("L0_pre", "L0_post", "L1_pre", "L1_post")

print("Protocole gele :", json.dumps({"seeds": SEEDS, "n_bench": N_BENCH,
    "seq_len": SEQ_LEN, "hop": HOP, "steps": STEPS, "k_sae": K_SAE,
    "topk_sae": TOPK_SAE, "doses": DOSES, "jlens_k": JLENS_K,
    "jlens_n_bands": JLENS_N_BANDS, "n_null_select": N_NULL_SELECT,
    "n_null_geom": N_NULL_GEOM, "top_r_geom": TOP_R_GEOM}))
Protocole gele : {"seeds": [0, 1, 2, 3, 4, 5], "n_bench": 40000, "seq_len": 64, "hop": 16, "steps": 3000, "k_sae": 48, "topk_sae": 8, "doses": [0.25, 0.5, 0.75, 1.0], "jlens_k": 8, "jlens_n_bands": 4, "n_null_select": 200, "n_null_geom": 200, "top_r_geom": 4}
# Imports. Le paquet ict/ (numpy-only) se trouve dans le dossier de la serie ;
# le harnais torch vit dans scripts/ a la racine du depot (#15475/#15479).
import os
import sys
import time
import json
from pathlib import Path

import numpy as np

sys.path.insert(0, os.path.abspath("."))                          # paquet ict/
sys.path.insert(0, str(Path(".").resolve().parents[2] / "scripts"))  # harnais torch

import torch

from ict.bench_factorise import FactoredBench, Mess3Canonical, RRXOR
from ict.sae_dictionary import train_sae, factor_selectivity
from ict.causal_engine import (InterventionSpec, apply_intervention,
                               interchange_panels, random_target_matched,
                               sham_of, dose_response_specs,
                               damage_metrics, selectivity_verdict,
                               holm_adjust, artifact_sha256)
from ict.intervention_battery import run_battery, chain_verdict, dose_monotonicity
from ict_pilot_transformer import (TinyTransformer, TrainConfig,
                                   capture_panels, sequences_from_bench,
                                   tokenize_joint, train_tiny)

DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
print("device :", DEVICE, "| torch", torch.__version__,
      "| numpy", np.__version__)
device : cuda | torch 2.14.0+cu126 | numpy 2.4.2

1. Banc factorise et tokenisation

FactoredBench(Mess3Canonical, RRXOR) — banc conforme #16225/#16675 : le facteur A est le Mess3 canonique (Marzen & Crutchfield 2017 : 3 etats caches en cycle, emissions ternaires discretes non couplees a l’etat, le belief vit dans le 2-simplexe sans s’effondrer en Dirac), le facteur B le RRXOR conforme (Riechers & Crutchfield 2018 : machine Mealy a 5 etats causaux, contrainte de triplet, correlations par paires nulles — le processus que la version iid precedente ne pouvait pas produire). Les deux tournent en parallele, independants : sample fournit les observations jointes ET les etats caches des deux facteurs ; beliefs fournit la filtration forward exacte (produit des marginales, 3 x 5 = 15 dimensions).

Tokenisation jointe : l’alphabet est nativement categoriel — A dans {0, 1, 2} sur 3 bins geles (frontieres a mi-chemin des symboles : -0.5, 0.5, 1.5, 2.5 ; aucun parametre libre, aucune fuite sur l’echantillon), B binaire sur 2 tranches. Token = bin_a * 2 + b, alphabet 6.

bench = FactoredBench(factor_a=Mess3Canonical(), factor_b=RRXOR())
# Grille gelee categorielle : frontieres a mi-chemin des symboles ternaires.
GRID = np.array([-0.5, 0.5, 1.5, 2.5])
VOCAB = 2 * (len(GRID) - 1)

demo = bench.sample(20_000, seed_a=0, seed_b=9000)
demo_tokens = tokenize_joint(demo["obs_a"], demo["obs_b"], GRID)
demo_beliefs = bench.beliefs(demo["obs_a"], demo["obs_b"])
counts = np.bincount(demo_tokens, minlength=VOCAB)
print("grille :", GRID.round(3))
print("tokens :", demo_tokens.shape, "| counts min/max :",
      counts.min(), counts.max())
print("belief_a lignes somment a 1 :",
      np.allclose(demo_beliefs["belief_a"].sum(axis=1), 1.0))
print("belief_b lignes somment a 1 :",
      np.allclose(demo_beliefs["belief_b"].sum(axis=1), 1.0))
grille : [-0.5  0.5  1.5  2.5]
tokens : (20000,) | counts min/max : 3204 3404
belief_a lignes somment a 1 : True
belief_b lignes somment a 1 : True

2. Transformer hookable : entrainement multi-seed et capture des panneaux

TinyTransformer (2 blocs pre-LN, d_model=64) predit le token suivant. La capture distingue le residual AVANT la LayerNorm d’entree de chaque bloc (pre) et le residual APRES sortie du bloc (post) — la distinction pre/post est exigee par l’acceptance #15480.

Decoupe gelee : les fenetres (hop 16) se chevauchent, donc une permutation ferait fuiter des tokens a travers la frontiere. Le split est donc sequentiel : premieres 70 % de fenetres pour l’ajustement (probes, SAE, normes/frequences de corpus), dernieres 30 % pour la mesure, avec GAP_WINDOWS fenetres sautees a la frontiere — aucune fenetre evaluee ne partage un token avec une fenetre d’ajustement. Aucun aleatoire : le split est une fonction pure de la grille de fenetrage.

def window_stack(x):
    starts = range(0, len(x) - SEQ_LEN + 1, HOP)
    return np.stack([x[s: s + SEQ_LEN] for s in starts])

pilot = {}
t_start = time.time()
for seed in SEEDS:
    t0 = time.time()
    sample = bench.sample(N_BENCH, seed_a=seed, seed_b=seed + 9000)
    tokens = tokenize_joint(sample["obs_a"], sample["obs_b"], GRID)
    beliefs = bench.beliefs(sample["obs_a"], sample["obs_b"])
    model = TinyTransformer(vocab=VOCAB, seed=seed)
    tcfg = train_tiny(model, tokens[None, :], TrainConfig(steps=STEPS),
                      device=DEVICE)
    seqs = sequences_from_bench(tokens, seq_len=SEQ_LEN, hop=HOP)
    panels = capture_panels(model, seqs, seq_len=SEQ_LEN, device=DEVICE)
    n_win = len(seqs)
    n_tr = int(TRAIN_FRAC * n_win)
    tr = np.arange(0, n_tr - GAP_WINDOWS)
    ev = np.arange(n_tr, n_win)
    pilot[seed] = {
        "model": model, "seqs": seqs,
        "tr": tr, "ev": ev[:N_EVAL_WINDOWS],
        "belief_a": window_stack(beliefs["belief_a"]),
        "belief_b": window_stack(beliefs["belief_b"]),
        "states_a": window_stack(sample["states_a"]),
        "states_b": window_stack(sample["states_b"]),
        "vloss": tcfg.log[-1][2],
        **panels,
    }
    print(f"seed {seed} : {time.time() - t0:5.0f}s | vloss {tcfg.log[-1][2]:.3f}"
          f" | fenetres {n_win} (tr {len(tr)}, ev {len(ev)})")
print(f"Entrainement et capture termines en {time.time() - t_start:.0f}s.")
seed 0 :    67s | vloss 1.745 | fenetres 2497 (tr 1744, ev 750)
seed 1 :    59s | vloss 1.653 | fenetres 2497 (tr 1744, ev 750)
seed 2 :    61s | vloss 1.704 | fenetres 2497 (tr 1744, ev 750)
seed 3 :    65s | vloss 1.752 | fenetres 2497 (tr 1744, ev 750)
seed 4 :    61s | vloss 1.703 | fenetres 2497 (tr 1744, ev 750)
seed 5 :    65s | vloss 1.723 | fenetres 2497 (tr 1744, ev 750)
Entrainement et capture termines en 377s.

3. F-Lens belief : sondes lineaires R2 held-out par couche

Pour chaque couche (pre et post de chaque bloc) et chaque facteur, une sonde ridge multivariee predit le belief exact depuis le panneau residual. Le R2 held-out (fenetres eval, decoupe gelee) localise ou l’etat vit : la couche porteuse A et la couche porteuse B sont les argmax du R2 median inter-seeds. Ce sont les ancrages de H2 (l’effet J-Lens doit s’y concentrer) et le choix de couche du dictionnaire SAE.

Les parametres ajustes sont conserves : les memes sondes resservent, gelees, comme mesureurs du canal etat dans les bras causaux (avant/apres intervention).

def ridge_params(X_tr, Y_tr):
    Xm, Ym = X_tr.mean(0), Y_tr.mean(0)
    Xc = X_tr - Xm
    lam = 1e-2 * np.trace(Xc.T @ Xc) / Xc.shape[1]
    W = np.linalg.solve(Xc.T @ Xc + lam * np.eye(Xc.shape[1]),
                        Xc.T @ (Y_tr - Ym))
    return Xm, Ym, W

def probe_predict(par, X):
    Xm, Ym, W = par
    return (X - Xm) @ W + Ym

def r2_channels(Y_true, Y_pred):
    ss_res = ((Y_true - Y_pred) ** 2).sum(0)
    ss_tot = ((Y_true - Y_true.mean(0)) ** 2).sum(0)
    return 1 - ss_res / ss_tot

probes = {}   # probes[seed][couche][facteur] = (Xm, Ym, W)
flens_r2 = {} # flens_r2[seed]["couche/facteur"] = R2 moyen
for seed in SEEDS:
    d = pilot[seed]
    tr, ev = d["tr"], d["ev"]
    probes[seed] = {}
    flens_r2[seed] = {}
    for key in LAYER_KEYS:
        P = d[key]
        probes[seed][key] = {}
        for name in ("a", "b"):
            bl = d["belief_" + name]
            X_tr = P[tr].reshape(-1, P.shape[-1])
            Y_tr = bl[tr].reshape(-1, bl.shape[-1])
            par = ridge_params(X_tr, Y_tr)
            probes[seed][key][name] = par
            X_ev = P[ev].reshape(-1, P.shape[-1])
            Y_ev = bl[ev].reshape(-1, bl.shape[-1])
            r2 = r2_channels(Y_ev, probe_predict(par, X_ev))
            flens_r2[seed][f"{key}/{name}"] = float(np.mean(r2))

print(f"{'couche/facteur':<16}" + "".join(f"seed{s:<7}" for s in SEEDS) + "median")
for key in LAYER_KEYS:
    for name in ("a", "b"):
        vals = [flens_r2[s][f"{key}/{name}"] for s in SEEDS]
        print(f"{key + '/' + name:<16}"
              + "".join(f"{v:<9.3f}" for v in vals)
              + f"{np.median(vals):.3f}")

def med_r2(key, name):
    return float(np.median([flens_r2[s][f"{key}/{name}"] for s in SEEDS]))

LAYER_A = max(LAYER_KEYS, key=lambda k: med_r2(k, "a"))
LAYER_B = max(LAYER_KEYS, key=lambda k: med_r2(k, "b"))
print(f"\nCouche porteuse A : {LAYER_A} (R2 median {med_r2(LAYER_A, 'a'):.3f})")
print(f"Couche porteuse B : {LAYER_B} (R2 median {med_r2(LAYER_B, 'b'):.3f})")
couche/facteur  seed0      seed1      seed2      seed3      seed4      seed5      median
L0_pre/a        0.201    0.203    0.205    0.204    0.205    0.212    0.204
L0_pre/b        0.078    0.079    0.080    0.079    0.079    0.079    0.079
L0_post/a       0.590    0.588    0.524    0.494    0.585    0.611    0.586
L0_post/b       0.119    0.084    0.101    0.096    0.094    0.083    0.095
L1_pre/a        0.590    0.588    0.524    0.494    0.585    0.611    0.586
L1_pre/b        0.119    0.084    0.101    0.096    0.094    0.083    0.095
L1_post/a       0.742    0.765    0.768    0.753    0.784    0.718    0.759
L1_post/b       0.168    0.170    0.164    0.164    0.159    0.163    0.164

Couche porteuse A : L1_post (R2 median 0.759)
Couche porteuse B : L1_post (R2 median 0.164)

4. SAE : dictionnaire sparse et selectivite aux facteurs

Un TopKSae (48 features, k=8) est ajuste par seed sur les panneaux de la couche porteuse A, split train uniquement. La selectivite de chaque feature se mesure sur les codes des fenetres eval : AUC de Mann-Whitney contre le label one-vs-rest etat==0 de chaque facteur (convention de la batterie #15799), avec z-score contre 200 relabelisations appariees en effectifs.

  • feature A : plus grand ecart AUC_A - AUC_B (portee par A, pas par B) ;
  • features A/B (pour H4) : les 4 plus grands ecarts de chaque cote.
sae_art = {}
for seed in SEEDS:
    d = pilot[seed]
    P = d[LAYER_A]
    X_tr = P[d["tr"]].reshape(-1, P.shape[-1])
    # corpus SAE borne : sous-echantillon gele (le full-batch sur tout le split
    # serait ~4 min/seed) ; stats et normalisation gelees dessus
    rng_sae = np.random.default_rng(seed + 4242)
    sub = rng_sae.choice(len(X_tr), N_SAE_TRAIN, replace=False)
    X_sub = X_tr[sub]
    # standardisation gelee (stats du sous-echantillon train) : le residual
    # stream a une echelle bien plus grande que les panneaux belief pour
    # lesquels le SAE est calibre ; lr 0.02 (0.05 diverge, overflow mesure)
    mu_tr = X_sub.mean(axis=0)
    sd_tr = X_sub.std(axis=0) + 1e-8
    fit = train_sae((X_sub - mu_tr) / sd_tr, n_features=K_SAE, k=TOPK_SAE,
                    seed=seed, n_steps=400, lr=SAE_LR)
    sae = fit["sae"]
    ev = d["ev"]
    X_ev = P[ev].reshape(-1, P.shape[-1])
    z_ev = sae.encode((X_ev - mu_tr) / sd_tr)
    lab_a = d["states_a"][ev].reshape(-1) == 0
    lab_b = d["states_b"][ev].reshape(-1) == 0
    sel_a = factor_selectivity(z_ev, lab_a, n_null=N_NULL_SELECT, seed=seed)
    sel_b = factor_selectivity(z_ev, lab_b, n_null=N_NULL_SELECT, seed=seed)
    gap = sel_a["auc"] - sel_b["auc"]
    feats_a = np.argsort(gap)[::-1][:TOP_R_GEOM]
    feats_b = np.argsort(gap)[:TOP_R_GEOM]
    # normes / frequences de corpus pour l'appariement du controle aleatoire
    z_tr = sae.encode((X_sub - mu_tr) / sd_tr)
    norms = np.linalg.norm(z_tr, axis=0)
    freqs = (z_tr > 0).mean(axis=0)
    sae_art[seed] = {"sae": sae, "fit": fit, "gap": gap,
                     "sel_a": sel_a, "sel_b": sel_b,
                     "feats_a": feats_a, "feats_b": feats_b,
                     "f_A": int(feats_a[0]),
                     "z_norms": norms, "z_freqs": freqs,
                     "mu": mu_tr, "sd": sd_tr}
    fA = sae_art[seed]["f_A"]
    print(f"seed {seed} : fvu {fit['fvu']:.3f} | l0 {fit['l0']:.1f} | "
          f"feature A #{fA} AUC_A {sel_a['auc'][fA]:.3f} "
          f"AUC_B {sel_b['auc'][fA]:.3f} "
          f"(z_A {sel_a['z'][fA]:5.1f})")
seed 0 : fvu 0.116 | l0 8.0 | feature A #5 AUC_A 0.568 AUC_B 0.479 (z_A  16.6)
seed 1 : fvu 0.107 | l0 8.0 | feature A #7 AUC_A 0.639 AUC_B 0.506 (z_A  28.3)
seed 2 : fvu 0.115 | l0 8.0 | feature A #4 AUC_A 0.564 AUC_B 0.493 (z_A  17.7)
seed 3 : fvu 0.100 | l0 8.0 | feature A #31 AUC_A 0.565 AUC_B 0.494 (z_A  17.8)
seed 4 : fvu 0.099 | l0 8.0 | feature A #37 AUC_A 0.582 AUC_B 0.497 (z_A  20.0)
seed 5 : fvu 0.112 | l0 8.0 | feature A #7 AUC_A 0.625 AUC_B 0.502 (z_A  27.6)

5. Geometrie factorielle — H4

Primitives numpy-only recopiees d’ICT-36 (la lib factor_geometry.py n’est pas sur main, See #15943) : PCA ponderee, NC@p, overlap de bases, angle principal, et la distribution nulle d’overlap entre sous-espaces aleatoires apparies en dimension.

Test H4 : les colonnes du decodeur SAE des features A et B (top-4 de chaque cote, orthonormalisees par QR) forment deux bases d’un espace residual de dimension 64. L’overlap maximal entre les deux bases reelles doit etre inferieur au quantile 95 de l’overlap entre paires de sous-espaces aleatoires apparies : plus orthogonaux que le hasard = separation factorielle.

# Primitives recopiees d'ICT-36 (verbatim, renvoi #15943).
NC_THRESHOLDS = (80, 90, 95, 99)


def weighted_pca(activations, weights=None):
    X = np.asarray(activations, dtype=np.float64)
    N, D = X.shape
    if weights is None:
        w = np.ones(N, dtype=np.float64) / N
    else:
        w = np.asarray(weights, dtype=np.float64)
        w = w / w.sum()
    mu = (w[:, None] * X).sum(axis=0)
    Xc = X - mu
    C = (Xc.T * w) @ Xc
    eigvals, eigvecs = np.linalg.eigh(C)
    order = np.argsort(eigvals)[::-1]
    eigvals, eigvecs = eigvals[order], eigvecs[:, order]
    for j in range(eigvecs.shape[1]):
        if eigvecs[0, j] < 0:
            eigvecs[:, j] = -eigvecs[:, j]
    sv = np.sqrt(np.maximum(eigvals, 0.0)) * np.sqrt(N)
    total_var = eigvals.sum()
    evr = eigvals / total_var if total_var > 0 else np.zeros_like(eigvals)
    return mu, eigvecs, sv, evr


def nc_at(evr, thresholds=NC_THRESHOLDS):
    cum = np.cumsum(evr)
    out = {}
    for k in thresholds:
        idx = int(np.searchsorted(cum, k / 100.0) + 1)
        out[k] = min(idx, len(cum))
    return out


def basis_overlap(B1, B2):
    M = B1.T @ B2
    return np.abs(M)


def max_principal_angle(B1, B2):
    P = B2 @ B2.T
    Q = np.eye(B1.shape[0]) - P
    sin_max = np.linalg.norm(Q @ B1, ord=2)
    sin_max = min(max(sin_max, 0.0), 1.0)
    return float(np.degrees(np.arcsin(sin_max)))


def null_overlap_distribution(dim, dim_factor, n_nulls=100, seed=0):
    rng = np.random.default_rng(seed)
    nulls = []
    for _ in range(n_nulls):
        A = rng.standard_normal((dim, dim_factor))
        Q, _ = np.linalg.qr(A)
        nulls.append(float(np.abs(Q.T @ Q).max()))
    return np.array(nulls)


def null_pair_overlap(dim, dim_factor, n_nulls, seed):
    rng = np.random.default_rng(seed)
    out = []
    for _ in range(n_nulls):
        Q1, _ = np.linalg.qr(rng.standard_normal((dim, dim_factor)))
        Q2, _ = np.linalg.qr(rng.standard_normal((dim, dim_factor)))
        out.append(float(basis_overlap(Q1, Q2).max()))
    return np.array(out)


print("Primitives geometriques pretes (ICT-36, #15943).")
Primitives geometriques pretes (ICT-36, #15943).
# Test H4 : overlap reel des bases A/B vs distribution nulle appariee.
h4_rows = []
h4_sep = []
for seed in SEEDS:
    art = sae_art[seed]
    sae = art["sae"]
    D = sae.W_dec.shape[0]

    def qrbasis(feats):
        return np.linalg.qr(sae.W_dec[:, list(feats)])[0]

    Qa, Qb = qrbasis(art["feats_a"]), qrbasis(art["feats_b"])
    real_max = float(basis_overlap(Qa, Qb).max())
    nulls = null_pair_overlap(D, TOP_R_GEOM, N_NULL_GEOM, seed * 31 + 17)
    q95 = float(np.quantile(nulls, 0.95))
    # angle principal : lecture complementaire, meme direction de test
    angle = max_principal_angle(Qa, Qb)
    h4_sep.append(q95 - real_max)
    h4_rows.append((seed, real_max, q95, angle))
    print(f"seed {seed} : overlap reel {real_max:.3f} | null q95 {q95:.3f} | "
          f"ecart {q95 - real_max:+.3f} | angle principal {angle:5.1f} deg | "
          f"{'separe' if q95 > real_max else 'NON separe'}")

h4_verdict = chain_verdict(h4_sep, [0.0] * len(h4_sep), min_seeds=4)
print("\nH4 :", h4_verdict)
seed 0 : overlap reel 0.647 | null q95 0.360 | ecart -0.287 | angle principal  83.1 deg | NON separe
seed 1 : overlap reel 0.561 | null q95 0.368 | ecart -0.193 | angle principal  87.4 deg | NON separe
seed 2 : overlap reel 0.611 | null q95 0.366 | ecart -0.245 | angle principal  82.3 deg | NON separe
seed 3 : overlap reel 0.487 | null q95 0.379 | ecart -0.108 | angle principal  89.3 deg | NON separe
seed 4 : overlap reel 0.528 | null q95 0.376 | ecart -0.153 | angle principal  88.9 deg | NON separe
seed 5 : overlap reel 0.897 | null q95 0.341 | ecart -0.556 | angle principal  85.0 deg | NON separe

H4 : {'verdict': 'NOT_SUPPORTED', 'median_diff': -0.21885852004412584, 'ci95': (-0.4214826209398963, -0.13030175167681413), 'p_signflip': 1.0, 'n_seeds_positive': 0, 'n_seeds': 6}

6. J-Lens : readout local par couche, a dose fixee

Semantique explicite : l’effet J-Lens d’une couche i sur une fenetre w est la divergence KL (position finale, distribution next-token) entre le forward intact et le forward ou une sous-composante de taille fixee de l’etat de la couche i de w – JLENS_K coordonnees du belief A, les memes a chaque couche et a chaque bande de positions – est remplacee par celle d’une fenetre donneuse, via l’operation interchange du moteur causal (#15479) puis propagation jusqu’aux logits par forward_patched. C’est un contre-factuel d’etat, pas une correlation : la question est « que ferait la sortie si cette sous-composante de l’etat a la couche i etait celle d’une autre fenetre ? ».

Pourquoi une dose fixee et non le panneau entier (reparation #16230) : une interchange portant sur tout le panneau remplace l’etat entier par celui du donneur ; le modele recalcule alors, depuis n’importe quel point d’injection, la trajectoire complete du donneur, et les logits finaux ne dependent plus de la couche – le profil par couche est invariant par construction. L’execution precedente affichait quatre lignes identiques a quatre decimales, et son « median_diff » de 1,8e-08 etait le residu du lissage numerique du KL, pas un signal. A dose fixee, ce qui varie d’une cellule du profil a l’autre est le point d’injection – la couche, croisee avec des bandes de positions – et rien d’autre : la couche redevient audible. Deux cles de prelevement restent redondantes par construction (L0_post et L1_pre designent le meme residuel, cf la table F-Lens).

Deux bras de donneurs par couche : donneur A-different (belief A confiant de classe differente) et donneur B-different (configuration du belief B differente). Sur le banc iid precedent, le plafond de confiance du belief RRXOR etait borne a 0,50 a chaque pas – un bit du XOR brut reste toujours incertain – et une porte de confiance y etait structurellement vide pour B, ce que l’execution anterieure compensait en substituant silencieusement 0,0 a ce bras muet. Le RRXOR conforme (#16225, machine Mealy) concentre au contraire la croyance aux aretes deterministes (X_v emet v avec probabilite 1) ; le critere pre-enregistre – donneurs de configuration differente (argmax du belief), porte satisfiable pour les deux facteurs – est neanmoins conserve a l’identique : le protocole ne se re-ajuste pas apres observation des resultats (acceptance #15480). Les effectifs de chaque bras sont imprimes, et un bras vide ne produit plus un nombre.

H2 predit : l’effet A-cf culmine a la couche porteuse A du F-Lens, pas ailleurs ; et a la couche porteuse, l’effet A-cf excede l’effet B-cf (dissociation).

# Profil J-Lens a dose FIXEE (reparation #16230).
#
# Une `interchange` portant sur TOUTES les positions et TOUTES les
# dimensions remplace l'etat entier par celui du donneur : `forward_patched`
# propage alors, depuis n'importe quel point d'injection, la trajectoire
# complete du donneur, et les logits finaux ne dependent plus de la couche
# d'injection. Le profil par couche est invariant PAR CONSTRUCTION (sortie
# stockee de l'execution precedente : quatre lignes identiques a quatre
# decimales, median_diff 1,8e-08). La cible est donc une sous-composante de
# taille fixee : JLENS_K coordonnees du belief A, gelees une fois par seed
# depuis le probe F-Lens de la couche porteuse, identiques a chaque couche
# et a chaque bande, croisees avec des bandes contigues de positions.
from ict_jlens_layers import (component_coords, is_full_panel, kl_final,
                              layer_spread)

bandes = tuple(tuple(range(b * SEQ_LEN // JLENS_N_BANDS,
                           (b + 1) * SEQ_LEN // JLENS_N_BANDS))
               for b in range(JLENS_N_BANDS))
tout = tuple(range(SEQ_LEN))

jlens = {s: {} for s in SEEDS}          # jlens[seed][couche] = (A-cf, B-cf)
jlens_bandes = {s: {} for s in SEEDS}   # idem, par (couche, bande)
jlens_n = {s: {} for s in SEEDS}        # effectifs de donneurs par bras
JLENS_COORDS = {}                       # dose gelee par seed
t0 = time.time()
for seed in SEEDS:
    d = pilot[seed]
    model = d["model"]
    ev = d["ev"][:N_JLENS]
    seqs_ev = d["seqs"][ev]
    ba, bb = d["belief_a"][ev][:, -1], d["belief_b"][ev][:, -1]
    JLENS_COORDS[seed] = component_coords(
        probes[seed][LAYER_A]["a"][2], JLENS_K)
    coords = JLENS_COORDS[seed]
    assert not is_full_panel(tout, coords,
                             (SEQ_LEN, d[LAYER_A].shape[-1])), (
        "dose pleine : le profil par couche serait invariant (#16230)")
    refs = [model.forward_patched(torch.from_numpy(s[None]), {})
            for s in seqs_ev]
    # Critere de donneur B : le plafond de confiance du belief RRXOR est
    # 0,50 a chaque pas (mesure sur 6x40 000 tirages) -- la porte
    # conf > CONF_DONOR etait structuralement vide pour B et l'ancienne
    # cellule substituait 0,0 a ce bras muet. Le bras B retient des
    # donneurs de configuration differente (argmax du belief), porte
    # satisfiable ; le bras A garde sa porte de confiance d'origine.
    lab_a, conf_a = ba.argmax(-1), ba.max(-1)
    lab_b = bb.argmax(-1)
    elig_a = conf_a > CONF_DONOR
    elig_b = np.ones_like(elig_a)
    for key in LAYER_KEYS:
        P = d[key]
        li = int(key[1])
        kl_a, kl_b, n_a, n_b = [], [], 0, 0
        for bi, band in enumerate(bandes):
            acc_a, acc_b = [], []
            for i in range(len(ev)):
                for lab, elig, acc in ((lab_a, elig_a, acc_a),
                                       (lab_b, elig_b, acc_b)):
                    if not elig[i]:
                        continue
                    cand = np.where(elig & (lab != lab[i]))[0]
                    if len(cand) == 0:
                        continue
                    wd = int(cand[0])
                    spec = InterventionSpec(
                        operation="interchange", instrument="jlens",
                        layer=li, positions=band, features=coords,
                        tensor_space="residual", run=f"pilot-{seed}",
                        paired_run=f"donor-{wd}", seed=seed)
                    p_cf, _ = interchange_panels(P[ev[i]], P[ev[wd]], spec)
                    cf = model.forward_patched(
                        torch.from_numpy(seqs_ev[i][None]),
                        {key: torch.from_numpy(p_cf[None])})
                    acc.append(kl_final(refs[i], cf))
            jlens_bandes[seed][(key, bi)] = (
                float(np.mean(acc_a)) if acc_a else float("nan"),
                float(np.mean(acc_b)) if acc_b else float("nan"))
            n_a += len(acc_a)
            n_b += len(acc_b)
            kl_a += acc_a
            kl_b += acc_b
        jlens[seed][key] = (float(np.mean(kl_a)) if kl_a else float("nan"),
                            float(np.mean(kl_b)) if kl_b else float("nan"))
        jlens_n[seed][key] = (n_a, n_b)

print(f"profil J-Lens a dose fixee ({JLENS_K} coordonnees A x "
      f"{JLENS_N_BANDS} bandes) en {time.time() - t0:.0f}s")
print("coords A gelees par seed :", JLENS_COORDS)
print("effectifs donneurs A/B (seed 0) :", jlens_n[SEEDS[0]])
print(f"\n{'couche':<10}" + "".join(f"seed{s:<9}" for s in SEEDS)
      + "A-cf median")
for key in LAYER_KEYS:
    vals = [jlens[s][key][0] for s in SEEDS]
    print(f"{key:<10}" + "".join(f"{v:<10.4f}" for v in vals)
          + f"{np.median(vals):.4f}")
# Etendue inter-couches par bande (seed 0) : > 0 des que la couche est
# audible ; sous panneau entier elle vaut 0 a la tolerance pres (#16230).
spread = {f"bande{bi}": layer_spread({k: jlens_bandes[SEEDS[0]][(k, bi)][0]
                                      for k in LAYER_KEYS})
          for bi in range(JLENS_N_BANDS)}
print("etendue inter-couches par bande (seed 0) :",
      {k: round(v, 4) for k, v in spread.items()})

# H2, maillon couches : effet A-cf a la couche porteuse vs autres couches.
others = [k for k in LAYER_KEYS if k != LAYER_A]
h2_layer = chain_verdict(
    [jlens[s][LAYER_A][0] for s in SEEDS],
    [float(np.mean([jlens[s][k][0] for k in others])) for s in SEEDS],
    min_seeds=4)
# Dissociation : A-cf vs B-cf a la couche porteuse. Un bras vide ne se
# lit plus comme un effet nul : la comparaison est declaree non mesuree.
if all(jlens_n[s][LAYER_A][1] > 0 for s in SEEDS):
    h2_dissoc = chain_verdict(
        [jlens[s][LAYER_A][0] for s in SEEDS],
        [jlens[s][LAYER_A][1] for s in SEEDS], min_seeds=4)
    h2_p = holm_adjust([h2_layer["p_signflip"], h2_dissoc["p_signflip"]])
else:
    vides = [s for s in SEEDS if jlens_n[s][LAYER_A][1] == 0]
    h2_dissoc = {"verdict": "NOT_MEASURED", "p_signflip": float("nan"),
                 "bras_b_vide_sur": vides}
    h2_p = holm_adjust([h2_layer["p_signflip"], 1.0])
print("\nH2 couches (porteuse vs autres) :", h2_layer,
      "| p Holm", round(h2_p[0], 4))
print("H2 dissociation (A-cf vs B-cf a la porteuse) :", h2_dissoc,
      "| p Holm", round(h2_p[1], 4))
profil J-Lens a dose fixee (8 coordonnees A x 4 bandes) en 32s
coords A gelees par seed : {0: (11, 29, 36, 40, 50, 54, 61, 63), 1: (4, 8, 24, 48, 50, 51, 56, 57), 2: (2, 9, 18, 21, 27, 34, 49, 62), 3: (3, 8, 14, 21, 23, 25, 35, 40), 4: (0, 2, 14, 29, 46, 48, 55, 63), 5: (9, 10, 14, 16, 22, 25, 26, 45)}
effectifs donneurs A/B (seed 0) : {'L0_pre': (68, 256), 'L0_post': (68, 256), 'L1_pre': (68, 256), 'L1_post': (68, 256)}

couche    seed0        seed1        seed2        seed3        seed4        seed5        A-cf median
L0_pre    0.0076    0.0012    0.0040    0.0013    0.0031    0.0040    0.0035
L0_post   0.0111    0.0006    0.0034    0.0010    0.0018    0.0012    0.0015
L1_pre    0.0111    0.0006    0.0034    0.0010    0.0018    0.0012    0.0015
L1_post   0.0047    0.0014    0.0041    0.0019    0.0030    0.0047    0.0036
etendue inter-couches par bande (seed 0) : {'bande0': 0.0001, 'bande1': 0.0005, 'bande2': 0.0008, 'bande3': 0.0254}

H2 couches (porteuse vs autres) : {'verdict': 'INCONCLUSIVE', 'median_diff': 0.0006635604478011457, 'ci95': (-0.00234348164060316, 0.0017012708933385901), 'p_signflip': 0.203125, 'n_seeds_positive': 5, 'n_seeds': 6} | p Holm 0.4062
H2 dissociation (A-cf vs B-cf a la porteuse) : {'verdict': 'INCONCLUSIVE', 'median_diff': 0.000558040006460225, 'ci95': (-0.0007192672640464579, 0.002407711118895116), 'p_signflip': 0.25, 'n_seeds_positive': 4, 'n_seeds': 6} | p Holm 0.4062

7. Interventions SAE latentes sur le transformer — H1, H3

La chaine causale complete, testee (pas racontee) : intervention dans l’espace latent du SAE -> re-decodage du panneau residual -> re-injection torch -> trois canaux mesures separesment (etat / readout / comportement, EffectChannels #15475).

  • Operation : steer sur le code de la feature A, direction unitaire e_f, dose -lambda * z_barre_f ou lambda parcourt les doses pre-enregistrees (0.25 a 1.0) et z_barre_f est l’activation moyenne de train — a lambda = 1 la feature est moyennee a zero en moyenne.
  • Controles (moteur #15479) : sham_of (dose 0, meme voie de code) et random_target_matched (feature aleatoire appariee en norme ET frequence de corpus, bande 25 %). Comparaison off-target : le decodage de B sous la meme intervention.
  • Reference de decode : le panneau reconstruit decode(encode(panel)) SANS intervention — la comparaison etat est cond - recon, jamais cond - brut, pour ne pas mesurer l’erreur de reconstruction du SAE.
  • Comportement : entropie croisee next-token sur toutes les positions, et accuracies separees de la composante A (token // 2) et B (token % 2) du token predit.
def window_ce_acc(logits, y_tokens):
    # CE moyenne + accuracy des composantes A (//2) et B (%2) du token.
    # logits (1, T, V) : la position t predit t+1 -> positions 0..T-2 valides
    lg = logits[0, :-1]
    ce = torch.nn.functional.cross_entropy(
        lg, torch.from_numpy(y_tokens).to(lg.device))
    pred = lg.argmax(-1).cpu().numpy()
    acc_a = float((pred // 2 == y_tokens // 2).mean())
    acc_b = float((pred % 2 == y_tokens % 2).mean())
    return float(ce), acc_a, acc_b


def rmse_belief(par, panel, belief_win):
    pred = probe_predict(par, panel)
    return float(np.sqrt(np.mean((belief_win - pred) ** 2)))


causal = {}   # causal[seed][(condition, dose_index)] = dict de mesures
t0 = time.time()
for seed in SEEDS:
    d, art = pilot[seed], sae_art[seed]
    model, sae = d["model"], art["sae"]
    mu, sd = art["mu"], art["sd"]
    ev = d["ev"][:N_CAUSAL]
    par_a = probes[seed][LAYER_A]["a"]
    par_b = probes[seed][LAYER_A]["b"]
    f_A = art["f_A"]
    P = d[LAYER_A]

    def encode_panel(panel):
        return sae.encode((panel - mu) / sd)

    def decode_panel(z_codes):
        # retour a l'espace residual brut (celui des sondes et du forward)
        return sae.decode(z_codes) * sd + mu

    # activation moyenne par feature sur le split train (echelle des doses)
    zmean = encode_panel(P[d["tr"]].reshape(-1, P.shape[-1])).mean(axis=0)

    # direction dans l'espace des features cibles (len(direction)==len(features))
    base = InterventionSpec(
        operation="steer", instrument="sae", layer=int(LAYER_A[1]),
        positions=tuple(range(SEQ_LEN)), features=(f_A,),
        direction=(1.0,), tensor_space="sae_latent",
        run=f"pilot-{seed}", seed=seed)
    # controle aleatoire apparie en norme ET frequence de corpus. La feature
    # A (la plus selective) est souvent un outlier de norme : le moteur refuse
    # une bande vide — echelle de tolerance explicite, la tolerance atteinte
    # est imprimee (jamais d'appariement degrade silencieux).
    z_ref = encode_panel(P[d["tr"][0]])
    for tol in (0.25, 0.5, 1.0, 2.0):
        try:
            rand_spec = random_target_matched(
                z_ref, base, feature_norms=art["z_norms"],
                feature_freqs=art["z_freqs"], rel_tol=tol,
                rng=np.random.default_rng(seed + 777))
            print(f"seed {seed} : controle aleatoire apparie (rel_tol={tol})")
            break
        except ValueError:
            continue
    else:
        raise
    f_R = int(rand_spec.features[0])
    rand_base = InterventionSpec(
        operation="steer", instrument="sae", layer=int(LAYER_A[1]),
        positions=tuple(range(SEQ_LEN)), features=(f_R,),
        direction=(1.0,), tensor_space="sae_latent",
        run=f"pilot-{seed}", seed=seed, control_ref="random-matched")
    sham_spec = sham_of(base)

    familles = {
        "target": dose_response_specs(
            base, tuple(-lam * zmean[f_A] for lam in DOSES)),
        "random": dose_response_specs(
            rand_base, tuple(-lam * zmean[f_R] for lam in DOSES)),
        "sham": [sham_spec],
    }

    causal[seed] = {}
    for w_i, w in enumerate(ev):
        panel = d[LAYER_A][w]
        z = encode_panel(panel)
        recon = decode_panel(z)
        tok = torch.from_numpy(d["seqs"][w][None])
        y = d["seqs"][w][1:]
        logits_int = model.forward_patched(tok, {})
        logits_rec = model.forward_patched(
            tok, {LAYER_A: torch.from_numpy(recon[None])})
        ce0, aa0, ab0 = window_ce_acc(logits_int, y)
        ce_r, aa_r, ab_r = window_ce_acc(logits_rec, y)
        rm_a_r = rmse_belief(par_a, recon, d["belief_a"][w])
        rm_b_r = rmse_belief(par_b, recon, d["belief_b"][w])
        for cond, specs in familles.items():
            for di, spec in enumerate(specs):
                z_cond = apply_intervention(z, spec)
                panel_cond = decode_panel(z_cond)
                logits_cd = model.forward_patched(
                    tok, {LAYER_A: torch.from_numpy(panel_cond[None])})
                ce_c, aa_c, ab_c = window_ce_acc(logits_cd, y)
                dmg = damage_metrics(z, z_cond, spec)
                slot = causal[seed].setdefault((cond, di), {
                    "dd_a": [], "dd_b": [], "dce": [], "daa": [], "dab": [],
                    "dz_f": [], "sel": []})
                slot["dd_a"].append(
                    rmse_belief(par_a, panel_cond, d["belief_a"][w]) - rm_a_r)
                slot["dd_b"].append(
                    rmse_belief(par_b, panel_cond, d["belief_b"][w]) - rm_b_r)
                slot["dce"].append(ce_c - ce_r)
                slot["daa"].append(aa_c - aa_r)
                slot["dab"].append(ab_c - ab_r)
                slot["dz_f"].append(float(np.abs(z_cond[:, f_A] - z[:, f_A]).mean()))
                slot["sel"].append(selectivity_verdict(dmg))
    print(f"seed {seed} : bras causaux en {time.time() - t0:.0f}s cumules")

# agregation par seed : moyennes sur fenetres pour les metriques numeriques ;
# verdict de selectivite agrege par vote majoritaire (ce sont des strings)
agg = {s: {k: {m: float(np.mean(v)) for m, v in slot.items() if m != "sel"}
           for k, slot in causal[s].items()} for s in SEEDS}
for s in SEEDS:
    for k, slot in causal[s].items():
        v = slot["sel"]
        agg[s][k]["sel"] = max(set(v), key=v.count)
i_max = len(DOSES) - 1
print("\nManipulation (dz_f a dose max, cible vs sham) :")
print("  cible :", [round(agg[s][("target", i_max)]["dz_f"], 3) for s in SEEDS])
print("  sham  :", [round(agg[s][("sham", 0)]["dz_f"], 3) for s in SEEDS])
print("  verdict selectivite (cible, dose max) :",
      [agg[s][("target", i_max)]["sel"] for s in SEEDS])
seed 0 : controle aleatoire apparie (rel_tol=0.25)
seed 0 : bras causaux en 4s cumules
seed 1 : controle aleatoire apparie (rel_tol=0.25)
seed 1 : bras causaux en 8s cumules
seed 2 : controle aleatoire apparie (rel_tol=0.25)
seed 2 : bras causaux en 11s cumules
seed 3 : controle aleatoire apparie (rel_tol=0.25)
seed 3 : bras causaux en 15s cumules
seed 4 : controle aleatoire apparie (rel_tol=0.25)
seed 4 : bras causaux en 18s cumules
seed 5 : controle aleatoire apparie (rel_tol=0.25)
seed 5 : bras causaux en 22s cumules

Manipulation (dz_f a dose max, cible vs sham) :
  cible : [0.507, 0.655, 0.534, 0.775, 0.605, 0.47]
  sham  : [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
  verdict selectivite (cible, dose max) : ['selective', 'selective', 'selective', 'selective', 'selective', 'selective']
# --- H1 : degradation du decodage (canal etat), cible vs B vs aleatoire ---
h1_target = [agg[s][("target", i_max)]["dd_a"] for s in SEEDS]
h1_vs_b = [agg[s][("target", i_max)]["dd_b"] for s in SEEDS]
h1_vs_rand = [agg[s][("random", i_max)]["dd_a"] for s in SEEDS]
h1_link_b = chain_verdict(h1_target, h1_vs_b, min_seeds=4)
h1_link_r = chain_verdict(h1_target, h1_vs_rand, min_seeds=4)
h1_holm = holm_adjust([h1_link_b["p_signflip"], h1_link_r["p_signflip"]])
print("H1 cible-vs-B   :", h1_link_b, "| p Holm", round(h1_holm[0], 4))
print("H1 cible-vs-rand:", h1_link_r, "| p Holm", round(h1_holm[1], 4))

# --- H3 : selectivite comportementale + preservation de la perplexite ---
h3_selective = chain_verdict(
    [agg[s][("target", i_max)]["dab"] - agg[s][("target", i_max)]["daa"]
     for s in SEEDS], [0.0] * len(SEEDS), min_seeds=4)
h3_ce = chain_verdict(
    [agg[s][("target", i_max)]["dce"] for s in SEEDS],
    [agg[s][("random", i_max)]["dce"] for s in SEEDS], min_seeds=4)
print("\nH3 selectivite comportementale (dacc_B - dacc_A > 0) :", h3_selective)
print("H3 inflation de CE cible vs random (doit rester moderee) :", h3_ce)

# --- lien dose : la degradation de A croit avec lambda ---
dose_curves = [[agg[s][("target", di)]["dd_a"] for di in range(len(DOSES))]
               for s in SEEDS]
h3_dose = dose_monotonicity(dose_curves)
print("H3 lien dose (dd_a croissant le long des doses) :", h3_dose)
H1 cible-vs-B   : {'verdict': 'INCONCLUSIVE', 'median_diff': 0.004353176045454519, 'ci95': (-0.0007580820243607161, 0.008020278364470855), 'p_signflip': 0.0625, 'n_seeds_positive': 5, 'n_seeds': 6} | p Holm 0.125
H1 cible-vs-rand: {'verdict': 'INCONCLUSIVE', 'median_diff': 0.004013408493172286, 'ci95': (-0.0017247987271793365, 0.005891114592697877), 'p_signflip': 0.0625, 'n_seeds_positive': 4, 'n_seeds': 6} | p Holm 0.125

H3 selectivite comportementale (dacc_B - dacc_A > 0) : {'verdict': 'INCONCLUSIVE', 'median_diff': 0.0002480158730158704, 'ci95': (-0.005208333333333334, 0.0026041666666666717), 'p_signflip': 0.5, 'n_seeds_positive': 3, 'n_seeds': 6}
H3 inflation de CE cible vs random (doit rester moderee) : {'verdict': 'NOT_SUPPORTED', 'median_diff': -0.00015268754214048386, 'ci95': (-0.0008194483816623688, 0.0015483004972338676), 'p_signflip': 0.59375, 'n_seeds_positive': 3, 'n_seeds': 6}
H3 lien dose (dd_a croissant le long des doses) : {'verdict': 'SUPPORTED', 'n_seeds_monotone': 5, 'n_seeds': 6, 'frac': 0.8333333333333334}

8. Bras contraste : batterie belief-panel

La meme batterie d’interventions (#15799) tourne sur le substrat belief exact (panneau = belief joint 12-dim, sans transformer) : c’est le contraste de substrat de la serie (ICT-31). Les verdicts des deux substrats se rapportent separement — une divergence transformer-vs-belief est un resultat, pas un artefact a resorber.

t0 = time.time()
# n=2400 : garde de la batterie (>= 6 features elegibles z>=2, freq>=0.01)
# refuse a n=1200 sur le seed 3 (5 eligibles, mesure prealable)
battery = run_battery(bench, seeds=SEEDS, n=2400)
print(f"batterie belief-panel en {time.time() - t0:.0f}s")
for link, verdict in battery.verdicts.items():
    print(f"  {link:<28} {verdict}")
batterie belief-panel en 10s
  sae/etat                     {'verdict': 'NOT_SUPPORTED', 'median_diff': -0.13728508044952276, 'ci95': (-0.22223661967783132, -0.03416979847663586), 'p_signflip': 1.0, 'n_seeds_positive': 0, 'n_seeds': 6, 'vs_sham': {'verdict': 'INCONCLUSIVE', 'median_diff': 0.20468588701618268, 'ci95': (0.1420454667390078, 0.2848032174481222), 'p_signflip': 0.0625, 'n_seeds_positive': 6, 'n_seeds': 6}, 'vs_random': {'verdict': 'NOT_SUPPORTED', 'median_diff': -0.014779342402998497, 'ci95': (-0.1647109892928893, 0.15873940380579368), 'p_signflip': 0.6875, 'n_seeds_positive': 2, 'n_seeds': 6, 'p_holm': 1.0}, 'p_holm': 1.0}
  sae/comportement             {'verdict': 'NOT_SUPPORTED', 'median_diff': -0.1136775140998203, 'ci95': (-0.1667480057192023, -0.07018269287737397), 'p_signflip': 1.0, 'n_seeds_positive': 0, 'n_seeds': 6, 'vs_sham': {'verdict': 'INCONCLUSIVE', 'median_diff': 0.05717840009830033, 'ci95': (0.030414490591821358, 0.08422155661492306), 'p_signflip': 0.0625, 'n_seeds_positive': 6, 'n_seeds': 6}, 'vs_random': {'verdict': 'NOT_SUPPORTED', 'median_diff': -0.028664003596209325, 'ci95': (-0.11611073640234068, 0.07059229819655088), 'p_signflip': 0.75, 'n_seeds_positive': 2, 'n_seeds': 6, 'p_holm': 1.0}, 'p_holm': 1.0, 'fvu_median': 0.3320217297328515}
  sae/dose                     {'verdict': 'SUPPORTED', 'n_seeds_monotone': 6, 'n_seeds': 6, 'frac': 1.0}
  flens/etat                   {'verdict': 'INCONCLUSIVE', 'median_diff': 0.8031325363095194, 'ci95': (0.7157674902259978, 0.8543443918400835), 'p_signflip': 0.0625, 'n_seeds_positive': 6, 'n_seeds': 6, 'vs_sham': {'verdict': 'INCONCLUSIVE', 'median_diff': 0.8032482771130851, 'ci95': (0.7159842664715008, 0.8549668401644712), 'p_signflip': 0.0625, 'n_seeds_positive': 6, 'n_seeds': 6}, 'vs_random': {'verdict': 'INCONCLUSIVE', 'median_diff': 0.060812684543024575, 'ci95': (-0.14496399856629233, 0.11775057975442427), 'p_signflip': 0.34375, 'n_seeds_positive': 4, 'n_seeds': 6, 'p_holm': 1.0}, 'p_holm': 0.25}
  flens/comportement           {'verdict': 'NOT_SUPPORTED', 'median_diff': -0.23694156427930388, 'ci95': (-0.2486001137728133, -0.22108893160495796), 'p_signflip': 1.0, 'n_seeds_positive': 0, 'n_seeds': 6, 'vs_sham': {'verdict': 'INCONCLUSIVE', 'median_diff': 0.09281444746819112, 'ci95': (0.05376480844791945, 0.10939623764718706), 'p_signflip': 0.0625, 'n_seeds_positive': 6, 'n_seeds': 6}, 'vs_random': {'verdict': 'NOT_SUPPORTED', 'median_diff': -0.006743251382337445, 'ci95': (-0.039768363815453234, 0.027746927536078797), 'p_signflip': 0.6875, 'n_seeds_positive': 2, 'n_seeds': 6, 'p_holm': 1.0}, 'p_holm': 1.0, 'fvu_median': 0.4807254412221236}
  flens/dose                   {'verdict': 'SUPPORTED', 'n_seeds_monotone': 6, 'n_seeds': 6, 'frac': 1.0}

Exercices

Trois extensions borees, chacune falsifiable. Les stubs sont conformes C.1 : le notebook s’execute de bout en bout meme non complete.

# Exercice 1 : selection des features par ecart d'AUC.
# Etape 1 : centrage des AUC autour de 0.5 (chance).
# Etape 2 : tri par ecart decroissant A vs B.
# Etape 3 : renvoyer les k indices et leurs ecarts.
def top_gap_features(auc_a, auc_b, k):
    # TODO etudiant
    print("Exercice a completer : top_gap_features")
    return None


resultat_ex1 = top_gap_features(sae_art[SEEDS[0]]["sel_a"]["auc"],
                                sae_art[SEEDS[0]]["sel_b"]["auc"], 4)
Exercice a completer : top_gap_features
# Exercice 2 : overlap geometrique d'une paire de bases contre le null.
# Etape 1 : orthonormaliser les colonnes donnees (np.linalg.qr).
# Etape 2 : overlap maximal entre les deux bases.
# Etape 3 : quantile 95 du null apparie et verdict de separation.
def overlap_vs_null(w_dec, feats_x, feats_y, n_nulls, seed):
    # TODO etudiant
    print("Exercice a completer : overlap_vs_null")
    return None


resultat_ex2 = overlap_vs_null(sae_art[SEEDS[0]]["sae"].W_dec,
                               sae_art[SEEDS[0]]["feats_a"],
                               sae_art[SEEDS[0]]["feats_b"],
                               200, 0)
Exercice a completer : overlap_vs_null
# Exercice 3 : le versant positions de H2 — l'effet J-Lens par bande de
# positions doit suivre la confiance F-Lens (entropie du belief) des
# positions couvertes par la bande.
# Etape 1 : pour chaque bande b, KL entre forward intact et forward
#           contre-factuel (les JLENS_K coordonnees gelees de la section 6,
#           couche porteuse, positions = la bande b seule).
# Etape 2 : entropie moyenne du belief A sur les positions de chaque bande.
# Etape 3 : correlation de Spearman entre les deux profils de bandes.
def corr_position_effect(seed):
    # TODO etudiant
    print("Exercice a completer : corr_position_effect")
    return None


resultat_ex3 = corr_position_effect(SEEDS[0])
Exercice a completer : corr_position_effect

Verdict global, accord et dissociation, limites

Aucun score omnibus : chaque hypothese est tranchee par sa famille de comparaisons pre-enregistrees, chaque instrument rapporte son effet dans son canal, et les dissociations restent visibles. Le bras belief-panel (section 8) est un substrat de contraste, pas une repetition.

table = {
    "H1": {
        "cible_vs_B": {**h1_link_b, "p_holm": h1_holm[0]},
        "cible_vs_random": {**h1_link_r, "p_holm": h1_holm[1]},
    },
    "H2": {
        "couches_porteuse_vs_autres": {**h2_layer, "p_holm": h2_p[0]},
        "dissociation_A_vs_B": {**h2_dissoc, "p_holm": h2_p[1]},
    },
    "H3": {
        "selectivite_comportementale": h3_selective,
        "preservation_perplexite": h3_ce,
        "lien_dose": h3_dose,
    },
    "H4": {"separation_geometrique": h4_verdict},
}

def fmt_link(v):
    ci = v.get("ci95")
    ci_s = f"[{ci[0]:+.4f}, {ci[1]:+.4f}]" if ci else ""
    return (f"{v.get('verdict', '?'):<16}"
            f"med {v.get('median_diff', float('nan')):+.4f} {ci_s} "
            f"p {v.get('p_signflip', float('nan')):.4f}"
            + (f" (Holm {v['p_holm']:.4f})" if "p_holm" in v else ""))

print("=" * 76)
for hyp, fam in table.items():
    print(f"{hyp} :")
    for name, v in fam.items():
        if "p_signflip" in v:
            print(f"   {name:<32} {fmt_link(v)}")
        elif "n_seeds_monotone" in v:
            print(f"   {name:<32} {v['verdict']} "
                  f"({v['n_seeds_monotone']}/{v['n_seeds']} seeds monotones)")
        else:
            print(f"   {name:<32} {v}")
print("=" * 76)

# Accord / dissociation entre instruments, rapportes separesment (acceptance).
print("\nAccord et dissociation par instrument :")
print("  F-Lens  couches porteuses A/B :", LAYER_A, "/", LAYER_B)
print("  J-Lens  effet A-cf par couche (median) :",
      {k: round(float(np.median([jlens[s][k][0] for s in SEEDS])), 4)
       for k in LAYER_KEYS})
print("  SAE     manipulation dz_f (cible, dose max) vs sham :",
      [round(agg[s][("target", i_max)]["dz_f"], 3) for s in SEEDS], "/",
      [round(agg[s][("sham", 0)]["dz_f"], 3) for s in SEEDS])

# Artefact rejouable : config gelee + verdicts + empreinte d'un panneau.
artifact = {
    "notebook": "ICT-40-TriangulationCausale",
    "issue": 15480,
    "config": {"seeds": list(SEEDS), "n_bench": N_BENCH, "seq_len": SEQ_LEN,
               "hop": HOP, "steps": STEPS, "k_sae": K_SAE,
               "topk_sae": TOPK_SAE, "doses": list(DOSES), "jlens_k": JLENS_K,
               "layer_a": LAYER_A, "layer_b": LAYER_B},
    "vloss": {int(s): round(float(pilot[s]["vloss"]), 4) for s in SEEDS},
    "verdicts": {h: {n: (v["verdict"] if "verdict" in v else v)
                     for n, v in fam.items()} for h, fam in table.items()},
    "panel_sha256_seed0": artifact_sha256(pilot[SEEDS[0]][LAYER_A][
        pilot[SEEDS[0]]["ev"][0]]),
}
print("\nSortie de reference (extrait verbatim) :")
print(json.dumps(artifact, ensure_ascii=True)[:1200])
============================================================================
H1 :
   cible_vs_B                       INCONCLUSIVE    med +0.0044 [-0.0008, +0.0080] p 0.0625 (Holm 0.1250)
   cible_vs_random                  INCONCLUSIVE    med +0.0040 [-0.0017, +0.0059] p 0.0625 (Holm 0.1250)
H2 :
   couches_porteuse_vs_autres       INCONCLUSIVE    med +0.0007 [-0.0023, +0.0017] p 0.2031 (Holm 0.4062)
   dissociation_A_vs_B              INCONCLUSIVE    med +0.0006 [-0.0007, +0.0024] p 0.2500 (Holm 0.4062)
H3 :
   selectivite_comportementale      INCONCLUSIVE    med +0.0002 [-0.0052, +0.0026] p 0.5000
   preservation_perplexite          NOT_SUPPORTED   med -0.0002 [-0.0008, +0.0015] p 0.5938
   lien_dose                        SUPPORTED (5/6 seeds monotones)
H4 :
   separation_geometrique           NOT_SUPPORTED   med -0.2189 [-0.4215, -0.1303] p 1.0000
============================================================================

Accord et dissociation par instrument :
  F-Lens  couches porteuses A/B : L1_post / L1_post
  J-Lens  effet A-cf par couche (median) : {'L0_pre': 0.0035, 'L0_post': 0.0015, 'L1_pre': 0.0015, 'L1_post': 0.0036}
  SAE     manipulation dz_f (cible, dose max) vs sham : [0.507, 0.655, 0.534, 0.775, 0.605, 0.47] / [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]

Sortie de reference (extrait verbatim) :
{"notebook": "ICT-40-TriangulationCausale", "issue": 15480, "config": {"seeds": [0, 1, 2, 3, 4, 5], "n_bench": 40000, "seq_len": 64, "hop": 16, "steps": 3000, "k_sae": 48, "topk_sae": 8, "doses": [0.25, 0.5, 0.75, 1.0], "jlens_k": 8, "layer_a": "L1_post", "layer_b": "L1_post"}, "vloss": {"0": 1.7452, "1": 1.6529, "2": 1.7042, "3": 1.7517, "4": 1.7026, "5": 1.7234}, "verdicts": {"H1": {"cible_vs_B": "INCONCLUSIVE", "cible_vs_random": "INCONCLUSIVE"}, "H2": {"couches_porteuse_vs_autres": "INCONCLUSIVE", "dissociation_A_vs_B": "INCONCLUSIVE"}, "H3": {"selectivite_comportementale": "INCONCLUSIVE", "preservation_perplexite": "NOT_SUPPORTED", "lien_dose": "SUPPORTED"}, "H4": {"separation_geometrique": "NOT_SUPPORTED"}}, "panel_sha256_seed0": "9fbfb3650f0d9d4dc07186282a4fa2b610635109d61cea497940ba802d1574bd"}
Retour au sommet