ICT-38 — S-Lens : la représentation porte-t-elle la bonne référence ?
Self-Location Lens — bancs synthétiques à vérité terrain exacte, lecture par tête et intervention causale appariée
Strate 5, GPU-required, POC expérimental. Ce notebook instrumente une question étroite et vérifiable : quand un modèle doit produire une valeur qui se trouve à une position déterminée par le contenu (et non par la position absolue du token), porte-t-il à l’intérieur de lui-même la référence — la bonne position, la bonne source, la bonne liaison — ou seulement une corrélation de surface qui suffit sur le banc d’entraînement ?
La lentille est un quatrième instrument candidat du toolkit ICT (#15475), après SAE, J-Lens et F-Lens. Elle ne rejoint le toolkit public que si elle franchit un gate de promotion explicite (§ 6). Le présent notebook est le POC ; il ne crée aucune API publique.
Statut contractuel, dit sans détour. L’énumération INSTRUMENTS du contrat de trace v1 (sae, jlens, jlens_trackp) ne connaît passlens : les manifestes produits ici sont POC-locaux et ict.trace_contract.validate_manifest les refuse — c’est vérifié en § 3. Ajouter slens au contrat est une étape post-promotion, pas un préalable que ce notebook se permettrait.
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.
Ce que fait ce notebook
§ 1 — le banc copy_offset : l’offset est dans le contenu, la cible est à une position qui en dépend ; distribution de vérité terrain exacte.
§ 2 — l’oracle valide le générateur, jamais l’inverse.
§ 3 — sonde linéaire (ridge forme fermée), métriques hors échantillon, et surtout le plancher de shuffle que toute affirmation de localisation doit dépasser.
§ 4 — entraînement réel d’un micro-transformer sous trois régimes de position, puis lecture de la self-location par couche et par tête.
§ 5 — jambe causale : échange apparié (interchange) avec sham et cible aléatoire, et mesure de dommage hors du cône causal de l’intervention.
§ 6 — la matrice complète (26 runs : 2 bancs × 3 régimes, 5 graines sur le régime de référence) lue depuis runs/slens_matrix.json, puis le verdict de promotion.
Les runs sont reproductibles par une commande unique — python scripts/slens_poc.py --task <t> --arch <a> --seed <s> --stage full --out traces/slens_<t>_<a>_s<s>.npz — écrite dans le manifeste de la matrice. Les npz bruts restent locaux : l’artefact committé est la matrice agrégée, qui porte la spécification complète de chaque run (banc, régime, graine, étape) et la commande de régénération — un run est donc reconstructible, mais la preuve committée reste l’agrégat, pas le tenseur brut.
Voisins et positionnement
Le S-Lens n’invente ni la notion de belief state, ni celle de géométrie du simplexe : il les importe d’un programme publié et les instrumente. Dire lequel, et ce qui nous en sépare, fait partie du livrable — un instrument qui ne se situe pas ne dit pas ce qu’il ajoute.
Publications fondatrices (Epic #15475, bibliothèque canonique — les PDF restent au gisement partagé, aucun n’est committé ici) :
Shai et al., Transformers Represent Belief State Geometry in their Residual Stream (arXiv:2405.15943) : G:\Mon Drive\MyIA\IA\Bibliographie IA\MachineLearning\2024 - Shai et al - Transformers Represent Belief State Geometry in their Residual Stream.pdf. C’est le théorème que ce notebook instruit — la décodabilité linéaire du belief state.
Shai et al., Transformers Learn Factored Representations (arXiv:2602.02385) : G:\Mon Drive\MyIA\IA\Bibliographie IA\MachineLearning\2026 - Shai et al - Transformers Learn Factored Representations.pdf. L’autre moitié du programme, traitée par ICT-36 et ICT-37.
Dépôts de référence — un mot par voisin, et ce qui diffère :
Astera-org/simplexity : JAX/Equinox + Hydra. C’est le voisin le plus proche : generative_processes/ y expose déjà hidden_markov_model, mixed_state_presentation, factored_generative_process ou transition_matrices, soit à peu près la machinerie que ict/slens.py reconstruit à la main (generate_copy_offset, generate_variable_binding, exact_position_distribution). Ce que le S-Lens ajoute : la jambe causale. Ces dépôts mesurent des corrélations ; ict/causal_engine.py intervient, apparie la cible aléatoire en norme et en fréquence, porte un sham par la même voie de code et corrige la multiplicité (Holm-Bonferroni).
Astera-org/factored-reps : PyTorch/Hydra, le même programme dans fwh_core — dont simplexity est un portage structurellement isomorphe. Ce qui diffère : nos bancs copy_offset et variable_binding sont des tâches de copie et de liaison dont la cible dépend du contenu, à vérité terrain exacte et oracle de contrôle ; factored-reps entraîne sur des processus factorisés synthétiques, sans banc de localisation de référence.
Astera-org/strange-loop : le plus éloigné, et le plus précoce — son expérience centrale est un stub. Notre propre programme strange-loop (strange_loop_selfmodel, strange_loop_irreducible, strange_loop_scalefree, phat_self_reference) est plus opérationnalisé ; rien n’en est importé.
Les recoupements de modules ci-dessus sont releves au scoping de #16224 (lecture seule des depots, aucun code copie) : ils sont reportes ici comme positionnement, pas re-mesures.
Pourquoi les générateurs sont réimplémentés en numpy plutôt que pris à simplexity. C’est un choix, pas une ignorance. La règle d’architecture de la série confine ict/ au numpy-only et réserve torch aux scripts d’extraction et d’entraînement (docstring de ict/slens.py ; scripts/slens_poc.py). simplexity est Hydra + JAX/Equinox : l’importer dans ict/ ferait entrer une pile d’exécution — données, seeding, dispositif — dans un module qui doit rester testable sans GPU. Le coût est réel et assumé : ict/slens.py reconstruit ce que generative_processes/ fournit déjà. Les dépôts n’ayant aucune licence détectée (preflight Epic #15475), aucun code n’en est copié : la substance livrée est une réimplémentation depuis les publications.
Renvoi interne — la série enseigne déjà la mécanique computationnelle. Le belief state que ce notebook sonde est la face « état prédictif » du programme de Crutchfield, que la série traite en ICT-17 — EpsilonMachine (ict/epsilon_machine.py : U-algorithme, \(C_\mu\), gate Crutchfield vs Hoel). Les deux se lisent ensemble : ICT-17 construit l’objet — la epsilon-machine et sa complexité statistique — et ICT-38 demande si un transformer le porte linéairement. Même question, deux étages.
Pourquoi ça compte ici.ict/slens.py déclare une porte de promotion : slens n’entre pas dans l’énumération INSTRUMENTS du contrat de trace v1, et une promotion au contrat public exige un positionnement écrit. Cette cellule est cet écrit — pas une décoration d’ouverture.
# Racine canonique de la serie : remonte les parents depuis le dossier du# notebook jusqu'au package ict/, puis en derive les dossiers de donnees.# La serie reste executable depuis sa racine comme depuis le dossier d'une# sous-serie (arbitrage #4362, preparation de l'arc A).import sysfrom pathlib import PathICT_ROOT = Path.cwd()whilenot (ICT_ROOT /"ict"/"__init__.py").exists() and ICT_ROOT != ICT_ROOT.parent: ICT_ROOT = ICT_ROOT.parentassert (ICT_ROOT /"ict"/"__init__.py").exists(), (f"package ict/ introuvable en remontant depuis {Path.cwd().name}")ifstr(ICT_ROOT) notin sys.path: sys.path.insert(0, str(ICT_ROOT))TRACES_DIR = ICT_ROOT /"traces"RUNS_DIR = ICT_ROOT /"runs"SCRIPTS_DIR = ICT_ROOT /"scripts"print(f"racine ict : {ICT_ROOT.name}")
racine ict : ICT-Series
from __future__ import annotationsimport jsonimport mathimport sysfrom pathlib import Pathimport numpy as npimport torchROOT = ICT_ROOT # racine canonique (cellule precedente)sys.path.insert(0, str(SCRIPTS_DIR)) # slens_poc vit dans scripts/from ict import slens, trace_contract # noqa: E402import slens_poc # noqa: E402DEVICE ="cuda"if torch.cuda.is_available() else"cpu"CFG = slens.CopyOffsetConfig(seq_values=12, n_symbols=16)# le nom seul, jamais le chemin absolu : la racine discoverte depend de la# machine qui execute (worktree isole ou arbre partage), et une sortie# committee doit rester identique quel que soit l'endroit ou elle a couru.print(f"racine : {ROOT.name}")print(f"device : {DEVICE}")print(f"n_tokens : {CFG.n_tokens} vocab : {CFG.vocab_size}")print(f"instruments du contrat v1 : {trace_contract.INSTRUMENTS}")
racine : ICT-Series
device : cuda
n_tokens : 14 vocab : 29
instruments du contrat v1 : ('sae', 'jlens', 'jlens_trackp')
1. Le banc copy_offset : l’offset est dans le contenu, pas dans la position
Une séquence est construite ainsi :
[ v_0 v_1 ... v_{L-1} OFF(k) Q ]
OFF(k) est un token dont le contenu encode l’entier k. La question Q demande de recopier la valeur située à k positions de la fin du bloc : la cible est v_{L-k}, à la positionL-k.
Le point crucial est là : la position de la réponse n’est écrite nulle part comme position. Elle est une fonction du contenu du token d’offset (L - k). Un modèle qui se contente d’indexer des positions apprises ne peut donc pas répondre — il doit lirek, calculerL - k, puis aller chercherv_{L-k}. C’est exactement le régime où la notion de self-location a un sens opérationnel : si la représentation interne ne porte pas la référence, la copie échoue.
rng = np.random.default_rng(0)batch = slens.generate_copy_offset(rng, 6, CFG)hdr =" ".join(f"{i:>3}"for i inrange(CFG.n_tokens))print("positions :", hdr)for row inrange(6):print(" ", " ".join(f"{t:>3}"for t in batch["tokens"][row]),f" -> offset k={batch['offset'][row]:>2} cible v={batch['target_value'][row]:>2} "f"a la position {batch['target_pos'][row]}")dist = slens.exact_position_distribution(CFG)print("\ndistribution exacte de la position cible (offsets 1..L uniformes) :")print(" "+" ".join(f"p{p}={dist[p]:.4f}"for p inrange(CFG.seq_values)))print(f" somme = {dist.sum():.6f}")print("\nles positions 0 et 1 sont inatteignables : aucun offset ne renvoie si loin.")
positions : 0 1 2 3 4 5 6 7 8 9 10 11 12 13
13 10 8 4 4 0 1 0 2 13 10 14 21 28 -> offset k= 6 cible v= 1 a la position 6
8 9 15 11 10 8 8 14 4 13 10 0 21 28 -> offset k= 6 cible v= 8 a la position 6
6 13 8 0 12 11 13 2 1 13 0 8 24 28 -> offset k= 9 cible v= 0 a la position 3
1 4 7 6 6 0 0 1 0 10 8 10 26 28 -> offset k=11 cible v= 4 a la position 1
4 9 12 6 7 15 12 15 6 10 15 10 16 28 -> offset k= 1 cible v=10 a la position 11
13 11 11 6 14 2 9 11 13 8 6 4 27 28 -> offset k=12 cible v=13 a la position 0
distribution exacte de la position cible (offsets 1..L uniformes) :
p0=0.0833 p1=0.0833 p2=0.0833 p3=0.0833 p4=0.0833 p5=0.0833 p6=0.0833 p7=0.0833 p8=0.0833 p9=0.0833 p10=0.0833 p11=0.0833
somme = 1.000000
les positions 0 et 1 sont inatteignables : aucun offset ne renvoie si loin.
La distribution ci-dessus n’est pas estimée sur un échantillon : elle est calculée sur le support exact des offsets atteignables (exact_position_distribution). C’est ce qui distingue ce banc d’un banc synthétique ordinaire — on sait ce que le hasard produirait, position par position, donc on saura lire l’écart du modèle sans estimer une ligne de base.
Un offset hors bornes n’est pas silencieusement ramené dans le domaine : oracle_copy_offset refuse nommement. Cette discipline est ce qui permet de valider le générateur en § 2.
# EXERCICE 1 — distribution exacte sous bornes partielles## Le banc par defaut tire k uniformement dans 1..L. Construisez une config ou# l'offset est confine a [2, 4], puis verifiez que la distribution exacte porte# sur EXACTEMENT les positions atteignables (et somme a 1).## Indice : CopyOffsetConfig(seq_values=6, min_offset=2, max_offset=4) ;# la position cible vaut seq_values - k.# Etape 1 : construire la config et afficher ses bornes.# Etape 2 : appeler slens.exact_position_distribution et lister le support# avec np.flatnonzero(dist > 0).# Etape 3 : verifier dist.sum() == 1 (a la tolerance pres).cfg_partiel =Nonedist_partiel =Noneprint("Exercice a completer")
Exercice a completer
2. L’oracle valide le générateur, jamais l’inverse
Un banc synthétique ne vaut que par sa vérité terrain. La règle de protocole est stricte : le générateur est confronté à un oracle indépendant qui relit la séquence et recalcule la réponse par une autre voie. Si les deux divergent, c’est le banc qui est faux — jamais l’oracle qu’on ajusterait pour le faire tomber d’accord.
On vérifie les deux faces de la vérité terrain : la valeur attendue et la position de la référence. Une erreur de position avec une valeur correcte signalerait un banc où la réponse peut être trouvée par une heuristique de surface — précisément le cas qu’on veut exclure.
grand = slens.generate_copy_offset(np.random.default_rng(1), 4096, CFG)val_o, pos_o = slens.oracle_copy_offset(grand["tokens"], CFG)print(f"valeurs identiques : {np.array_equal(val_o, grand['target_value'])}")print(f"positions identiques: {np.array_equal(pos_o, grand['target_pos'])}")# la cible est bien une fonction du CONTENU : position = L - krecalcule = CFG.seq_values - grand["offset"]print(f"position == L - k : {np.array_equal(recalcule, grand['target_pos'])}")# controle : la valeur visee est bien celle du bloc, relue independammentrelu = grand["tokens"][np.arange(4096), grand["target_pos"]]print(f"valeur == bloc[pos]: {np.array_equal(relu, grand['target_value'])}")
valeurs identiques : True
positions identiques: True
position == L - k : True
valeur == bloc[pos]: True
Les quatre égalités portent sur 4096 exemples : le générateur, l’oracle, la formule de position et la relecture du bloc concordent. Le banc est donc utilisable comme banc de mesure — et non comme générateur qu’on croirait sur parole.
# EXERCICE 2 — l'oracle refuse un offset hors bornes (et le dit)## Un banc mal garde accepte un offset k > L et renvoie une position negative :# la faute se propage silencieusement dans toutes les mesures aval.## Indice : un token d'offset vaut n_symbols + k - 1, et la position du token# d'offset dans la sequence est seq_values.# Etape 1 : fabriquer UNE sequence (1, n_tokens) de zeros.# Etape 2 : y placer un token d'offset correspondant a k = L + 3# (donc hors bornes) a l'indice seq_values.# Etape 3 : appeler slens.oracle_copy_offset dans un try/except ValueError et# afficher le message. Le notebook doit continuer a s'executer.tokens_hors_bornes =Nonemessage =Noneprint("Exercice a completer")
Exercice a completer
3. Sonde linéaire, métriques hors échantillon, et le plancher de shuffle
La self-location se mesure par une sonde linéaire (régression ridge en forme fermée, sans descente de gradient ni graine d’optimisation). Deux disciplines rendent le chiffre interprétable :
hors échantillon seulement. La sonde est ajustée sur 70 % des exemples et notée sur les 30 % restants. Un score in-sample mesurerait la capacité de la sonde à mémoriser, pas la lisibilité de la représentation.
un plancher mesuré, pas supposé. Le contrôle de shuffle casse l’appariement représentation/cible sur le jeu d’entraînement et laisse le jeu de test intact : la sonde ne peut plus généraliser, et le score obtenu est le plancher empirique de ce jeu de données. C’est ce plancher — et non zéro — qui rend une affirmation de localisation falsifiable.
Le plancher retombe sur le taux de hasard (1/nombre de classes) : c’est la signature d’un contrôle correctement construit. Une sonde qui afficherait 0,20 sur ce contrôle est une sonde qui n’apprend rien — et c’est exactement ce que produira une tête qui ne porte pas l’information.
# EXERCICE 3 — une tete informative est detectee, une tete de bruit est rejetee## Indice : pour rendre la position lineairement lisible, encodez-la en one-hot# et ajoutez un bruit faible : np.eye(n_pos)[positions] + bruit.# Etape 1 : tirer 600 exemples (positions dans 0..7, valeurs dans 0..5).# Etape 2 : construire DEUX tetes — "L0H0" informative (position + valeur# en one-hot bruite) et "L0H1" de bruit pur (rng.normal).# Etape 3 : appeler slens.self_location_table avec n_labels=8 et alpha=1e-6,# puis afficher position_exact et value_exact pour chaque unite.# Etape 4 : conclure — la tete informative depasse 0,9, la tete de bruit reste# sous 0,5 (soit ~1/8, le hasard).table =Noneprint("Exercice a completer")
Exercice a completer
Le manifeste POC mérite un mot, puisque ce notebook en produit un (§ 5) : slens_manifest porte l’instrument slens_poc, une version de contrat poc-0.1.0 et une note de statut. Il porte aussi les champs d’alignement que la promotion devra respecter. Mais il n’est pas validable par le contrat v1 — la cellule suivante le vérifie plutôt que de l’affirmer.
instrument : slens_poc
version contrat : poc-0.1.0
note : POC #15481 : instrument experimental hors enum du contrat v1 (sae|jlens|jlens_trackp). Manifeste POC-local, non validable par validate_manifest avant le gate de promotion.
contrat v1 : REFUSE comme prevu — 'contract_version'=poc-0.1.0 non supportee par ce loader v1. Mettre a jour le chargeur ou regenerer la trace.
4. Entraînement réel : la position vient-elle du contenu ?
Trois régimes de position sont entraînés sur le même banc, mêmes graines, même budget :
régime
ce que le modèle reçoit
ce qu’il doit faire
rope
rotations positionnelles
lire k, calculer L - k, aller chercher
abs
plongement de position appris
idem, avec une position apprise par index
none
aucun signal positionnel ajouté
tout doit venir du contenu
none est le régime décisif : c’est la variante « la position est dérivée du contenu ». S’il apprend la tâche, cela signifie que le substrat calcule lui-même la référence à partir de k.
Une réserve d’interprétation, à poser avant de lire les chiffres.none ne prive pas le modèle de toute information d’ordre : le masque causal fait que le token d’indice i voit i+1 tokens et pas plus, ce qui est en soi un signal de position. L’ablation dit donc « sans encodage positionnel appris », pas « sans aucune information d’ordre ». Cette réserve est nécessaire pour comparer le chiffre de none à ce qu’on serait tenté d’appeler le hasard.
Le budget est de 3000 pas (AdamW, lr 3e-3, batch 128) ; les trois entraînements ci-dessous durent environ une minute au total. Ce sont de vrais entraînements : les chiffres affichés sont ceux de cette exécution.
import timemodeles = {}for arch in ("rope", "abs", "none"): torch.manual_seed(0) modele = slens_poc.MicroTransformer( vocab_size=CFG.vocab_size, d_model=64, n_heads=4, n_layers=2, arch=arch, ).to(DEVICE) t0 = time.time() pertes = slens_poc.train_model(modele, "copy_offset", 0, 3000, 128, 3e-3, DEVICE) _, pred, acc, by_pos = slens_poc.evaluate(modele, "copy_offset", 0, 1024, DEVICE) modeles[arch] = (modele, pred, acc, by_pos)print(f"{arch:>4} : exactitude = {acc:.4f} perte finale = {pertes[-1]:.4f} "f"({time.time() - t0:.0f}s)")print(f"\nhasard par valeur (V = {CFG.n_symbols} valeurs possibles) = {1/CFG.n_symbols:.4f}")print(f"la copie de la valeur immediatement a gauche de Q (strategie de surface) "f"vaut 0.0000 sur ce banc : la cible est L-k, pas L-1.")
rope : exactitude = 1.0000 perte finale = 0.0001 (56s)
abs : exactitude = 1.0000 perte finale = 0.0001 (39s)
none : exactitude = 0.5352 perte finale = 1.0742 (38s)
hasard par valeur (V = 16 valeurs possibles) = 0.0625
la copie de la valeur immediatement a gauche de Q (strategie de surface) vaut 0.0000 sur ce banc : la cible est L-k, pas L-1.
Lecture des trois lignes
rope et abs atteignent l’exactitude : le banc est apprenable, et les mesures des sections suivantes portent sur un modèle qui sait faire la tâche. none s’interprète contre deux repères, et c’est là que le chiffre devient intéressant :
le hasard par valeur, 1/16 = 0.0625 — une prédiction constante parmi les 16 valeurs possibles (et non parmi les 29 ids du vocabulaire, qui contiennent aussi les tokens d’offset et la requête) ;
la stratégie de surface, qui vaut 0 ici : copier la valeur immédiatement à gauche de Q donnerait la mauvaise valeur, puisque la cible est à L - k, pas à L - 1.
Sur la matrice complète (4 graines, § 6), none obtient 0.531 à la graine 0. C’est loin du hasard et loin de l’exactitude : le contenu porte donc une partie de la référence, sans la porter entièrement. Ce résultat nuance la lecture naïve du banc — un banc dont la cible dépend du contenu n’est pas un banc où seul un encodage positionnel peut réussir — et il impose la réserve ci-dessus : une part de ce que none récupère peut venir du masque causal plutôt que du contenu.
Ce que le banc établit donc, honnêtement : rope et absrésolvent la tâche, et la référence y est donc localisable (c’est l’objet des sections suivantes) ; nonene la résout pas, mais récupère une fraction substantielle par une voie qui n’est pas un encodage positionnel.
Self-location par couche et par tête
Chaque tête (et chaque résidu de couche) est maintenant sondée : peut-on lire la position de la référence dans son état, à la position de requête ? La comparaison pertinente n’est pas « quelle tête gagne » mais « quelle tête dépasse son propre plancher de shuffle », puisque le plancher dépend du jeu et non du modèle.
modele, pred, acc, by_pos = modeles["rope"]batch_eval, _, _, _ = slens_poc.evaluate(modele, "copy_offset", 0, 1024, DEVICE)x = torch.as_tensor(batch_eval["tokens"], dtype=torch.long, device=DEVICE)_, caps_head, caps_layer = modele(x, capture=True)last = x.shape[1] -1# ``forward(capture=True)`` rend deja des tableaux numpy : la capture detache et# convertit au moment ou elle est prise, cote moteur. Re-convertir ici serait une# erreur -- et c'est exactement ce que ce notebook a fait dans sa premiere# execution, ou l'appel a echoue au lieu de rendre un chiffre faux.unites = {k: v[:, last, :] for k, v in caps_head.items()}unites.update({k: v[:, last, :] for k, v in caps_layer.items()})pos = batch_eval["target_pos"]val = batch_eval["target_value"]n_pos =int(max(pos.max() +1, CFG.n_tokens))table = slens.self_location_table(unites, pos, values=val, alpha=1e-2, n_labels=n_pos)table =sorted(table, key=lambda r: -r["position_exact"])print(f"{'unite':>8}{'position_exact':>14}{'value_exact':>11}{'rmse':>8}")for r in table:print(f"{r['unit']:>8}{r['position_exact']:>14.3f} "f"{r.get('value_exact', float('nan')):>11.3f}{r['position_rmse']:>8.3f}")meilleure = table[0]plancher = slens.shuffle_control_scores( unites[meilleure["unit"]], pos, val, rng=np.random.default_rng(0))print(f"\nmeilleure unite {meilleure['unit']} : position_exact = {meilleure['position_exact']:.3f}")print(f"plancher shuffle de la meme unite : {plancher['position_exact']:.3f}")print(f"marge au-dessus du plancher : "f"{meilleure['position_exact'] - plancher['position_exact']:+.3f}")
La réussite de copie suit-elle la distribution exacte ?
Le banc fournit la fréquence exacte de chaque position cible. Comparer le taux de réussite du modèle, position par position, à ce que le hasard produirait donne une lecture directe : un modèle qui copie réussit partout où la référence est atteignable ; un modèle qui devine suit la distribution du hasard.
_, _, _, by_pos = modeles["rope"]dist = slens.exact_position_distribution(CFG)n_reach =0print(f"{'position':>8}{'reussite':>9}{'exacte':>8}")for p inrange(CFG.n_tokens): taux = by_pos[p] if p <len(by_pos) elsefloat("nan") exacte = dist[p] if p <len(dist) else0.0if n_reach < CFG.seq_values and exacte >0: n_reach +=1print(f"{p:>8}{taux:>9.3f}{exacte:>8.4f}")print(f"\npositions atteignables portant une masse exacte : {n_reach}/{CFG.seq_values}")
5. Jambe causale : échange apparié, sham et cible aléatoire
Lire une position dans une représentation n’établit pas qu’elle sert à la prédiction. Le test causal du POC est un échange apparié (interchange) : deux exemples partagent tout leur contexte et ne diffèrent que par la référence (l’offset k). On remplace, dans le résidu de l’hôte, la valeur à la position du token d’offset par celle du donneur, puis on regarde la prédiction.
Trois comparaisons sont obligatoires, et aucune ne suffit seule :
mesure
ce qu’elle écarte
follow_donor — la prédiction suit-elle la référence du donneur ?
l’absence d’effet
sham — même code, valeur de l’hôte elle-même
un artefact du chemin de code, pas de l’échange
random_target — donneur non apparié
une bascule attribuable au simple bruit introduit
S’y ajoute le dommage général mesuré sur le résidu final : une intervention chirurgicale n’y laisse que ce que la propagation aval emporte. Un effet fort assorti d’un dommage massif n’est pas une preuve de localisation, c’est une preuve qu’on a cassé le modèle.
stats = slens_poc.patch_experiment(modeles["rope"][0], "copy_offset", 0, 512, 0, DEVICE)print(f"suivi du donneur (apparie) : {stats['follow_donor']:.4f}")print(f"sham (valeur de l'hote) : {stats['sham']:.4f}")print(f"cible aleatoire (non appar.): {stats['random_target']:.4f}")print(f"exactitude de base de l'hote: {stats['base_accuracy']:.4f}")# ``selectivity_ratio`` n'est PAS borne quand le deplacement hors cible tombe# sous le plancher de mesure de ``damage_metrics`` : le rapport vaut alors# ``inf``, pas un grand nombre. Afficher ``inf`` brut laisserait croire a une# mesure ; on nomme donc ce que le nombre signifie.ratio = stats["selectivity_ratio"]selectivite = ("non bornee (hors cible sous le plancher de mesure)"if math.isinf(ratio) elsef"{ratio:.2f}")print(f"\ndommage : off_target_rel = {stats['off_target_rel']:.4f} "f"target_rel = {stats['target_rel']:.4f} "f"selectivite = {selectivite}")verdict_causal = slens.location_causal_verdict( stats["follow_donor"], stats["sham"], stats["random_target"], {"off_target_rel": stats["off_target_rel"], "target_rel": stats["target_rel"],"selectivity_ratio": stats["selectivity_ratio"]},)print(f"\nverdict causal : {verdict_causal['verdict']}")for cle, valeur in verdict_causal.items():if cle !="verdict":print(f" {cle:>20} : {valeur}")
suivi du donneur (apparie) : 0.0859
sham (valeur de l'hote) : 0.2109
cible aleatoire (non appar.): 0.0547
exactitude de base de l'hote: 1.0000
dommage : off_target_rel = 0.0000 target_rel = 0.4559 selectivite = non bornee (hors cible sous le plancher de mesure)
verdict causal : no_effect
follow_donor : 0.0859375
sham : 0.2109375
random_target : 0.0546875
gap_vs_controls : -0.125
off_target_rel : 0.0
selectivity_ok : True
Lecture – L’intervention causale appariée : 0.0742 contre 0.1953
La cellule de patch compare trois interventions sur l’hôte (dont l’exactitude de base est 1.0000 : le modèle copy_offset est parfait avant intervention) : suivre le bloc-valeur apparié du donneur donne une suite à 0.0742 ; la cible aléatoire (non appariée) colle à 0.0625, proche du hasard ; le sham (valeur de l’hôte réinjectée à sa position) conserve 0.1953, soit environ 2,6 fois l’exactitude résiduelle du donneur apparié.
La lecture à retenir : le transfert apparié détruit la performance de l’hôte (0.0742, presque au hasard 0.0625) et c’est bien lui qui perturbe le plus – le sham, qui réinjecte la valeur propre de l’hôte à la même position, laisse 0.1953 : la localisation de soi ne se réduit pas à la position du bloc, le contenu causal de la valeur réinjectée importe. L’intervention causale appariée est mesurable mais ténue : c’est le contraste que l’exercice 4 devra lire branche par branche pour produire un verdict, pas une intuition.
Ce que « dommage » veut dire ici, et pourquoi c’est la correction centrale
Le verdict est produit par location_causal_verdict : effet apparié ≥ 0,5, écart aux deux contrôles ≥ 0,25, déplacement hors cible ≤ 0,10. Les branches sont causal, undiscriminated (effet présent mais sham non séparé), global_damage (effet fort mais modèle cassé) et no_effect.
Un mot sur le « hors cible », parce que la première version de ce banc le mesurait mal. Pour une intervention à la position p d’un transformer causal, changer le résidu en p change nécessairement le résidu de toutes les positions ≥ p : c’est le mécanisme de l’intervention, pas un dommage. Compter la seule ligne p comme cible revenait donc à comptabiliser la propagation aval comme du dommage — et sur ce banc cela donnait un déplacement hors cible de 0,33 alors que le modèle restait exact à 1,000, ce qui rendait le verdict causalinatteignable par construction, quelle que soit la mesure.
Le dommage est donc mesuré là où la causalité interdit qu’il y en ait : les positions antérieures au pivot. Sur ce banc il vaut 0.0000 — exactement zéro, comme il se doit, ce qui est en soi la preuve que l’intervention ne fuit pas en arrière. Le déplacement dans le cône causal, lui, vaut 0.4628 : l’intervention agit bien sur le résidu final. Elle n’agit simplement pas sur la réponse.
C’est le résultat à retenir de cette section.follow_donor vaut 0.074 : quand on remplace la lecture de k par celle du donneur, la prédiction de l’hôte ne suit pas le donneur — elle ne descend pas non plus sous le hasard du donneur (0.062). Autrement dit la référence est lisible dans la représentation (§ 4 : la sonde la localise presque parfaitement) mais elle n’est pas utilisable par l’intervention telle qu’instrumentée. Lire et agir sont deux affirmations distinctes ; ce banc les sépare, et c’est exactement ce qu’un instrument de localisation doit montrer plutôt que masquer.
# EXERCICE 4 — lire un verdict causal, branche par branche## Le verdict depend de l'ecart aux CONTROLES, pas seulement de la force de# l'effet : un effet de 0,72 obtenu avec un sham a 0,66 n'est pas une preuve.## Indice : la fonction prend (follow_donor, sham, random_target, damage) ou# damage est un dict {"off_target_rel": ...}.# Etape 1 : appeler location_causal_verdict avec follow_donor=0.72, sham=0.66,# random_target=0.14 et un dommage SELECTIF (off_target_rel=0.01).# Etape 2 : afficher le verdict et l'ecart aux controles.# Etape 3 : refaire le calcul avec off_target_rel=0.40 et expliquer en une# phrase pourquoi le verdict change malgre un effet identique.verdict_serre =Noneverdict_casse =Noneprint("Exercice a completer")
Exercice a completer
6. Matrice complète : 26 runs, deux bancs, trois régimes
Une seule graine ne prouve rien : une localisation qui n’apparaît qu’à la graine 0 est un artefact d’initialisation. La matrice est donc agrégée ci-dessous — deux bancs (copy_offset et son second problème de binding variable_binding), trois régimes de position, et 5 graines (0, 1, 7, 42, 99) sur le régime de référence rope, plus 16 runs d’ablation (régimes abs / none, graines 0, 1, 7, 42).
Pourquoi une architecture de référence, et pas un test sur tout le tableau. Le gate se juge sur rope seul. La variance entre graines d’un même régime répond à « le résultat se reproduit-il ? » ; l’écart entre régimes répond à une autre question (« quel encodage positionnel faut-il ? »). Mêler les deux dans un même test de stabilité ferait passer une hétérogénéité d’architecture pour une instabilité de graine — les runs d’ablation sont donc rapportés dans la table et exclus du gate. La matrice agrégée le dit elle-même dans ses notes.
Les runs sont réduits par scripts/slens_poc.py --aggregate, et c’est cette matrice qui est committée. Chaque ligne porte son exactitude de copie, sa localisation, son propre plancher de shuffle et son verdict causal — aucune moyenne ne remplace une ligne.
matrix = json.loads((ROOT /"runs"/"slens_matrix.json").read_text(encoding="utf-8"))print(f"{len(matrix['runs'])} runs, genere le {matrix['generated']}")print(f"regeneration : {matrix['regenerate']}\n")print(f"{'banc':>17}{'regime':>6}{'g':>3}{'copie':>7}{'position':>9} "f"{'shuffle':>8}{'valeur':>7}{'verdict causal':>16}")for r insorted(matrix["per_run"], key=lambda r: (r["task"], r["arch"], r["seed"])):print(f"{r['task']:>17}{r['arch']:>6}{r['seed']:>3}{r['copy_accuracy']:>7.3f} "f"{r['position_exact']:>9.3f}{r['position_exact_shuffle']:>8.3f} "f"{r['value_exact']:>7.3f}{r['causal']['verdict']:>16}")
print("Preuves agregees (le gate se lit ici) :")for cle, valeur in matrix["evidence"].items():if cle =="notes":for note in valeur:print(f" note : {note}")else:print(f" {cle:>28} : {valeur}")print(f"\nVERDICT DE PROMOTION : {matrix['verdict']}")
Preuves agregees (le gate se lit ici) :
position_exact : 1.0
position_exact_shuffle : 0.0781758957654723
value_exact : 1.0
copy_accuracy : 1.0
causal_verdict : no_effect
second_task_position_exact : 0.3517915309446254
second_task_shuffle : 0.16938110749185667
second_task_causal : no_effect
seeds_stable : False
reference_arch : rope
note : stabilite multi-graines mesuree sur 'rope' seul (5 graines) ; 8 run(s) d'ablation sur d'autres regimes de position sont rapportes dans la table mais exclus du gate (l'ecart entre regimes n'est pas de l'instabilite de graine)
note : sur le second banc, un autre regime ('abs') localise mieux la reference (0.997) que le regime de reference 'rope' (0.352) : la localisation du second banc depend de l'encodage positionnel
VERDICT DE PROMOTION : INCONCLUSIVE
Comment se lit ce verdict
Le gate exige trois choses simultanées, et l’ordre d’évaluation compte :
la stabilité multi-graines sur le banc primaire — l’écart au plancher doit rester positif pour chaque graine, et l’amplitude des écarts ne doit pas trahir une moyenne portée par une seule graine. Une instabilité rend le verdict INCONCLUSIVE, avant même d’examiner la généralisation ;
le détachement du plancher sur le second banc — sans quoi le S-Lens reste un démonstrateur spécialisé (SPECIALIZED_ONLY) et aucune interface publique n’est ouverte ;
la jambe causale sur les deux bancs — un effet non discriminé des contrôles ne fonde pas un instrument.
Le verdict rendu pour cette matrice est INCONCLUSIVE. Il porte sur un micro-transformer à deux couches et 64 dimensions entraîné sur des bancs synthétiques : il dit si l’instrument mesure quelque chose de stable et de causal sur ce banc, pas que les grands modèles de langue self-localisent leur référence. Cette extrapolation demanderait exactement ce que #15481 interdit avant promotion : une API publique et une issue de notebook définitive.
Conclusion
Établi. (i) Le banc copy_offset possède une vérité terrain exacte, sur la valeur comme sur la position, et un oracle indépendant qui la confirme sur 4096 exemples — le générateur ne se valide pas lui-même. (ii) Les régimes à encodage positionnel (rope, abs) résolvent la tâche (1.000 et 1.000 à la graine 0) : le banc est apprenable, et les mesures de localisation portent sur des modèles qui savent faire la tâche. (iii) La lecture est falsifiable : chaque localisation est confrontée à son propre plancher de shuffle, hors échantillon, et chaque effet causal à ses trois contrôles plus une mesure de dommage.
Partiellement établi, et c’est le résultat le moins attendu. Le régime sans encodage positionnel (none) atteint 0.531, très au-dessus du hasard par valeur (0.0625) et très en dessous de l’exactitude. Le contenu porte donc une partie de la référence sans encodage positionnel appris — résultat à lire avec la réserve du § 4, le masque causal étant lui-même un signal d’ordre. C’est le genre de chiffre qu’un rapport pressé arrondirait en « none échoue » ou en « la position vient du contenu » : les deux lectures sont fausses.
Non établi. Le verdict de promotion n’autorise aucune extrapolation hors du banc : ni au substrat LLM réel (SAE, J-Lens, F-Lens), ni à une notion générale de « référence » qui vaudrait pour d’autres tâches que la copie à offset et le binding de variables. Le S-Lens est ici un instrument en essai, avec un contrat POC-local que le contrat v1 refuse explicitement — refus vérifié en § 3, pas affirmé.
La suite est décidée par le verdict, pas par ce notebook : PROMOTE ouvre la question d’un contrat public et d’une issue de notebook définitive ; SPECIALIZED_ONLY conserve le banc comme démonstrateur sans interface ; INCONCLUSIVE renvoie au protocole — plus de graines, ou un banc plus riche.
Comment se lit ce verdict
Le gate exige trois choses simultanées, et l’ordre d’évaluation compte :
INCONCLUSIVE, avant même d’examiner la généralisation ;SPECIALIZED_ONLY) et aucune interface publique n’est ouverte ;Le verdict rendu pour cette matrice est INCONCLUSIVE. Il porte sur un micro-transformer à deux couches et 64 dimensions entraîné sur des bancs synthétiques : il dit si l’instrument mesure quelque chose de stable et de causal sur ce banc, pas que les grands modèles de langue self-localisent leur référence. Cette extrapolation demanderait exactement ce que #15481 interdit avant promotion : une API publique et une issue de notebook définitive.