ICT-15g – Exploitation empirique de la sensibilite (Huang 2019) sur substrats reels ICT-15c

See #7288 (MAJ 2026-07-19 : exploitation empirique sur N substrats reels) – Part of Epic #4588 (strate 5 : theorie fondatrice cross-substrat).

Re-exploitation focalisee du notebook ICT-15b-SensitivityCanonicity-Python (PR #7479/#7635/#7666/#7857) sur les 4 substrats ICT-15c (Gray-Scott/Axelrod/Grokking/May) avec memes parametres et state functions, pour rendre la conjecture Huang testable cote a cote avec les proxys ICT-15c/ICT-15d et fournir la matiere des dissociations cross-proxys (#7395).

La conjecture ICT-15b (enoncee AVANT les experiences, pre-enregistrement obligatoire)

Pour une fonction d’etat f : V -> {0, ..., m-1} sur le graphe de transition Markovien W = (P + P^T)/2 d’une trajectoire ICT, ou s_x(f) = |{y in V(x) : f(y) != f(x)}| :

s_max(f) >= sqrt(deg_proxy(f))

ou s_max(f) = max_x s_x(f) et deg_proxy(f) est le degre moyen du voisinage du graphe de transition (proxy par defaut : degre structurel moyen sur les noeuds visites (2E/V_visite, generalise la dimension n de l’hypercube Q_n qui est regulier ; cf #9771) ; remplacable par proxy_degree_fn).

Statut epistemique : la trajectoire ICT n’est pas une fonction booleenne statique sur l’hypercube {0,1}^n. Les hypotheses de Huang tombent :

Hypothese Huang 2019 Ce qui la remplace sur ICT
Hypercube Q_n (regulier, degre n) Graphe de transition Markovien W (irregulier, degre local variable)
Matrice de signes A avec A^2 = n Id Matrice antisymetrique J des courants nets (Schnakenberg 1976) + sign(W + J)
Degre polynomial deg(f) comme representant Degre moyen du voisinage deg_proxy(f) (heuristique conservatrice)
f : {0,1}^n -> {0,1} statique f : V -> {0,...,m-1} changeant avec la trajectoire

La conjecture est donc testable mais pas theorique : un verdict inconsistent sur plusieurs substrats serait un resultat (l’integration ICT exige une information irreductiblement globale, hors de portee d’un scalaire local). Verdict consistent = la transposition directe tient ; verdict inconclusive = trajectoire trop courte pour discriminer.

Substrats testes (alignes ICT-15c pour coherence cross-notebook)

Substrat Strate Source trajectoire State function f
Gray-Scott (binarise V>0.5) 5 ict.reaction_diffusion.GrayScott F=0.035, k=0.065 f(x) = x % 2 (parite du label de pixel binarise)
Axelrod (stabilite dominance) 5 ict.strategic_morphodynamics (replicator) f(x) = x (identite sur 0/1 stable/instable)
Grokking (compression crossover) 5 marche aleatoire biaisee (phase1=uniforme 4, phase2=etat 0) f(x) = 1 if x == 0 else 0 (phase compressee vs exploree)
May (grazing SDE bistable) 5 ict.bistable.GrazingModel r=1.0, c=1.5, SDE Euler-Maruyama f(x) = int(x >= 8) (regime haut vs bas de la biomasse)

Pont avec les autres strates ICT-15

  • ICT-15c (#7395, PR #9328) : verdict NOISE sur les memes substrats pour le proxy P2 spectral/sensitivity (3 proxys collapsent). ICT-15b est l’exploitation du proxy P1 (sensibilite locale seule).
  • ICT-15d (#7744, PR #9334) : verdict NOISE pour le proxy Cech sur les memes substrats (sections colineaires, instrument plafonne). ICT-15b contraste avec une conjecture binaire (consistent / inconsistent / inconclusive) plus discriminante.
  • #7395 meta-proxy obstruction detector (PR #7578) : c’est l’agregat cross-proxy dont ICT-15b est une brique.

Acceptance

  • Conjecture ecrite avant le test (pre-enregistrement dans cette cellule).
  • Verdict honnete par substrat : consistent / inconsistent / inconclusive. Pas de cosmetique.
  • 3 exercices C.1 (stubs pass / print / return None, jamais raise NotImplementedError).
  • Commit AVEC outputs (regle C.2).

Statut épistémique — Deux strates. (1) Le banc hérité (cellules 2 à 9 : conjecture s_max ≥ √deg_proxy sur graphes de transition markoviens) reste une transposition conjecturale, sans verdict : ses verdicts par substrat sont des repères d’instrument, pas des théorèmes. (2) Le banc appris #17977 (fin de notebook) livre un verdict FAIL aux critères déclarés (Δs_max, proxy de la quantité de l’issue) : 27/28 paires Δs_max = 0, 1 Δ > 0, 0 Δ < 0 ; le complément post-revue sur le ratio ne détecte pas non plus de déplacement (26 Δratio = 0, 2 > 0, 0 < 0, médiane 0,0). L’analyse suit un plan déclaré avec amendements de calibration documentés (graines de calibration disjointes), sans re-seuillage. Sur l’hypercube vrai, la borne de Huang est un théorème vérifié mécaniquement (0/118 violations). Reporté dans la matrice de dissociations, ligne ICT-15g.

# Imports et substrats pilotes (parametres + state functions alignes ICT-15c PR #9328)
import sys
from pathlib import Path

ICT_ROOT = Path('.').resolve()
sys.path.insert(0, str(ICT_ROOT))

import numpy as np
import pandas as pd

from ict import spectral as SP
from ict import sensitivity as SE
from ict import reaction_diffusion as RD
from ict import strategic_morphodynamics as SM
from ict import bistable as BS  # May (ICT-8)
from ict.sensitivity import huang_conjecture_test

np.random.seed(20260720)  # determinisme cross-execution (aligne ICT-15c)
print("Imports OK. Substrats pilotes : Gray-Scott/Axelrod/Grokking/May (alignes ICT-15c).")
Imports OK. Substrats pilotes : Gray-Scott/Axelrod/Grokking/May (alignes ICT-15c).
# Sanity check : huang_conjecture_test sur un substrat jouet (cycle biaise 4 etats)
# Gate obligatoire (pattern L934) : on verifie que l'instrument discrimine
# avant de l'appliquer aux 4 substrats reels. Si la conjecture passe ici
# sur un cas jouet, on a la baseline ; si elle echoue, on DOIT investiguer
# avant de tirer des conclusions sur les substrats reels.

# Cycle biaise 4 etats : on s'attend a un graphe fortement connecte,
# deg_proxy ~ 2, threshold ~ sqrt(2) ~ 1.41. f identite : s_max doit etre
# non-trivial (chaque voisin change la valeur).
toy_states = [0, 1, 0, 2, 3, 1, 0, 2, 3, 1, 0, 2] * 5  # 60 pas, cycle
toy_f = lambda x: x  # identite sur {0,1,2,3}
toy_verdict = huang_conjecture_test(toy_states, 4, toy_f)
print("=== Sanity check sur cycle biaise 4 etats (60 pas) ===")
for k, v in toy_verdict.items():
    print(f"  {k:>16} = {v}")

# Gate : on veut que l'instrument rende un verdict discriminant
# (pas 'inconclusive' systematique). Si 'inconclusive', soit la trajectoire
# est trop courte (< 2 * n_symbols = 8 transitions), soit n_obs < 2.
if toy_verdict['verdict'] == 'inconclusive':
    raise RuntimeError(
        "Sanity check FAIL : 'inconclusive' sur cycle 60-pas/4-symboles. "
        "Augmenter la longueur ou investiguer la gate de huang_conjecture_test."
    )
print(f"\nSanity check PASS : verdict = {toy_verdict['verdict']} (instrument discriminant).")
=== Sanity check sur cycle biaise 4 etats (60 pas) ===
             s_max = 2
         deg_proxy = 2.0
         threshold = 1.4142135623730951
             ratio = 1.414213562373095
     n_transitions = 59
         n_visited = 4
           verdict = consistent

Sanity check PASS : verdict = consistent (instrument discriminant).

Methodologie

Pour chaque substrat, on :

  1. Genere la trajectoire discrete selon les parametres ICT-15c (cell 1-4 ci-dessous).
  2. Choisit une state function f : V -> {0,...,m-1} discriminante (detaillee par substrat).
  3. Appelle huang_conjecture_test(states, n_symbols, f) qui retourne :
    • s_max (sensibilite maximale locale)
    • deg_proxy (degre structurel moyen sur les noeuds visites, par defaut ; cf #9771)
    • threshold = sqrt(deg_proxy)
    • verdict in {consistent, inconsistent, inconclusive}

Garde-fou : verdict='inconclusive' si la trajectoire a moins de 2 * n_symbols transitions observees ou moins de 2 noeuds visites (cf. huang_conjecture_test, regle heuristique).

Interpretation :

  • consistent : la transposition directe de Huang tient sur ce substrat – la sensibilite locale borne (au sens s_max >= sqrt(deg_proxy)) le proxy polynomial.
  • inconsistent : la transposition echoue – soit s_max < sqrt(deg_proxy) (le scalaire local ne borne pas le proxy), soit l’inegalite est violee.
  • inconclusive : la trajectoire est trop courte pour discriminer – ce n’est pas un verdict positif ou negatif, juste une borne methodologique.
# --- Substrat 1 : Gray-Scott (binarise V>0.5, 2 symboles) ---
# State function f(x) = x % 2 (parite du label binarise).
# On s'attend a un verdict `inconsistent` ou `inconclusive` : alphabet
# binaire tres pauvre, la fonction parite sur 2 symboles degenere.
gs = RD.GrayScott(F=0.035, k=0.065, Du=0.16, Dv=0.08, dt=1.0)
seed_rng = np.random.default_rng(20260720)
U_init, V_init = gs.seed(n=64, rng=seed_rng)
U_final, V_final, _ = gs.run(U_init, V_init, steps=800)

binary_grid = (V_final > 0.5).astype(int)
gray_scott_states = binary_grid.flatten().tolist()  # 64*64 = 4096 pixels
n_symbols_gs = 2

f_gs = lambda x: x % 2
verdict_gs = huang_conjecture_test(gray_scott_states, n_symbols_gs, f_gs)
print("=== Gray-Scott (binarise, V>0.5) ===")
for k, v in verdict_gs.items():
    print(f"  {k:>16} = {v}")
=== Gray-Scott (binarise, V>0.5) ===
             s_max = 0
         deg_proxy = 0.0
         threshold = 0.0
             ratio = inf
     n_transitions = 4095
         n_visited = 1
           verdict = inconclusive
# --- Substrat 2 : Axelrod (stabilite dominance, 2 symboles) ---
# State function f(x) = x (identite sur {0,1}). Alphabet binaire : la
# fonction identite est triviale. Verdict attendu : `inconclusive` si
# la trajectoire est trop courte, sinon `consistent` (la borne est triviale
# sur alphabet a 2 etats).
rng = np.random.default_rng(20260720)
strategies = SM.make_strategies(rng)
A = SM.payoff_matrix(strategies, n_rounds=200, n_reps=3, rng=rng)
n_strat = A.shape[0]
x0 = np.full(n_strat, 1.0 / n_strat)
traj = SM.replicator_trajectory(A, x0, n_steps=400)

dom_idx = np.argmax(traj, axis=1)
axelrod_states = [int(dom_idx[i] == dom_idx[i - 1]) for i in range(1, len(dom_idx))]
n_symbols_ax = 2

f_ax = lambda x: x  # identite sur {0,1}
verdict_ax = huang_conjecture_test(axelrod_states, n_symbols_ax, f_ax)
print("=== Axelrod (stabilite dominance) ===")
for k, v in verdict_ax.items():
    print(f"  {k:>16} = {v}")
=== Axelrod (stabilite dominance) ===
             s_max = 1
         deg_proxy = 1.0
         threshold = 1.0
             ratio = 1.0
     n_transitions = 399
         n_visited = 2
           verdict = consistent
# --- Substrat 3 : Grokking (compression crossover, 4 symboles) ---
# State function f(x) = 1 if x == 0 else 0 (phase compressee vs exploree).
# On cible le crossover : f=1 quand on est sur l'etat attracteur de la
# phase 2 (compression), f=0 sinon. Verdict attendu : `consistent` car la
# trajectoire discrimine les phases (l'etat 0 est tres visite en phase 2).
n_steps_g = 400
states_g = []
for t in range(n_steps_g):
    if t < 200:
        s = int(rng.integers(0, 4))
    else:
        if rng.random() < 0.90:
            s = 0
        else:
            s = int(rng.integers(1, 4))
    states_g.append(s)
grokking_states = states_g
n_symbols_gk = 4

f_gk = lambda x: 1 if x == 0 else 0
verdict_gk = huang_conjecture_test(grokking_states, n_symbols_gk, f_gk)
print("=== Grokking (compression crossover) ===")
for k, v in verdict_gk.items():
    print(f"  {k:>16} = {v}")
=== Grokking (compression crossover) ===
             s_max = 3
         deg_proxy = 3.0
         threshold = 1.7320508075688772
             ratio = 1.7320508075688774
     n_transitions = 399
         n_visited = 4
           verdict = consistent
# --- Substrat 4 : May (grazing SDE bistable, 16 symboles) ---
# State function f(x) = int(x >= 8) (regime haut si biomasse >= 8, bas sinon).
# On cible le saddle bistable : la biomasse oscille entre regime haut et
# bas, f=1 en haut, f=0 en bas. Verdict attendu : `consistent` car la
# trajectoire porte le saddle et la fonction bimodale discrimine.
gm = BS.GrazingModel(r=1.0, K=10.0, h=1.0)
xs_may = gm.simulate_sde(c=1.5, x0=8.0, sigma=0.05, dt=0.01,
                         T=2000, seed=20260720)
may_q = np.quantile(xs_may, np.linspace(0, 1, 17)[1:-1])
may_states = np.digitize(xs_may, may_q).tolist()
n_symbols_may = 16

# f identifie le regime bimodal : high si label de quantile >= 8 (moitie
# superieure), low sinon.
f_may = lambda x: int(x >= 8)
verdict_may = huang_conjecture_test(may_states, n_symbols_may, f_may)
print("=== May (grazing SDE bistable) ===")
for k, v in verdict_may.items():
    print(f"  {k:>16} = {v}")
=== May (grazing SDE bistable) ===
             s_max = 2
         deg_proxy = 2.625
         threshold = 1.620185174601965
             ratio = 1.2344267996967353
     n_transitions = 1999
         n_visited = 16
           verdict = consistent
# --- Synthese cross-substrat : tableau pandas + verdict global ---
verdicts = {
    "gray_scott": verdict_gs,
    "axelrod": verdict_ax,
    "grokking": verdict_gk,
    "may": verdict_may,
}
all_states = {
    "gray_scott": gray_scott_states,
    "axelrod": axelrod_states,
    "grokking": grokking_states,
    "may": may_states,
}
rows = []
for nom, v in verdicts.items():
    rows.append({
        "substrat": nom,
        "n_visited": v["n_visited"],
        "s_max": v["s_max"],
        "deg_proxy": round(v["deg_proxy"], 3),
        "threshold": round(v["threshold"], 3),
        "ratio": round(v["ratio"], 3),
        "verdict": v["verdict"],
    })
df = pd.DataFrame(rows)
print("=== Verdict cross-substrat ICT-15b (Huang conjecture exploitation) ===")
print(df.to_string(index=False))

# Agregation : combien de substrats sont `consistent` / `inconsistent` / `inconclusive` ?
counts = df["verdict"].value_counts().to_dict()
n_consistent = counts.get("consistent", 0)
n_inconsistent = counts.get("inconsistent", 0)
n_inconclusive = counts.get("inconclusive", 0)
print(f"\n=== Agregation ===")
print(f"  consistent   = {n_consistent}/4")
print(f"  inconsistent = {n_inconsistent}/4")
print(f"  inconclusive = {n_inconclusive}/4")
_conclusive = df[df["verdict"] != "inconclusive"]
_ratio_global = _conclusive["ratio"].mean() if len(_conclusive) else float("nan")
print(f"  ratio global = {_ratio_global:.3f} (moyenne des ratios sur verdicts conclus ; le ratio est sans signification pour un substrat inconclusive, ex. gray_scott n_visited=1)")
=== Verdict cross-substrat ICT-15b (Huang conjecture exploitation) ===
  substrat  n_visited  s_max  deg_proxy  threshold  ratio      verdict
gray_scott          1      0      0.000      0.000    inf inconclusive
   axelrod          2      1      1.000      1.000  1.000   consistent
  grokking          4      3      3.000      1.732  1.732   consistent
       may         16      2      2.625      1.620  1.234   consistent

=== Agregation ===
  consistent   = 3/4
  inconsistent = 0/4
  inconclusive = 1/4
  ratio global = 1.322 (moyenne des ratios sur verdicts conclus ; le ratio est sans signification pour un substrat inconclusive, ex. gray_scott n_visited=1)

Interpretation cross-substrat

Verdict honnete par substrat : voir le tableau ci-dessus. Trois cas de figure possibles :

  1. Majorite consistent : la transposition directe de Huang tient sur les substrats ICT reels, malgre la chute des hypotheses. Cela suggere que le degre moyen du voisinage est un proxy robuste pour le degre polynomial meme sur des graphes irreguliers – c’est un resultat positif sur la portee empirique de la borne.

  2. Majorite inconsistent : la transposition echoue. Cela confirme la conjecture epistemique initiale : l’integration ICT exige une information irreductiblement globale, que le scalaire local s_max ne capture pas. C’est un resultat au sens de la falsification ICT-15 – il dit quelque chose de la nature de Phi/F/K.

  3. Mix consistent/inconsistent/inconclusive : le verdict depend du substrat. C’est le cas le plus interessant pour #7395 meta-proxy obstruction : la dissociation entre substrats est la signature cross-substrat. ICT-15b seul ne tranche pas, mais sa matiere (le tableau ci-dessus) alimente directement le detecteur d’obstruction.

Comparaison ICT-15b vs ICT-15c/d :

  • ICT-15c (PR #9328) : verdict NOISE cross-substrat pour le proxy P2 (3 proxys spectral/sens_mean/sens_max collapsent).
  • ICT-15d (PR #9334) : verdict NOISE cross-substrat pour le proxy Cech (sections colineaires par construction, instrument plafonne).
  • ICT-15b (ce notebook) : conjecture binaire s_max >= sqrt(deg_proxy) – plus discriminante que les deux precedentes car elle donne un verdict par substrat et non un verdict agrege.

Si ICT-15b donne une majorite consistent, c’est un signal positif : la sensibilite locale borne le proxy polynomial meme hors de l’hypercube. Si majorite inconsistent, ICT-15b refute la transposition directe – mais cela ne refute pas l’intuition Huang en general, juste sa transposition litterale aux graphes de transition.

Banc appris (#17977) — où le ratio s_max / √deg sature-t-il sur de petits réseaux appris ?

See #17977 — Part of #4588, suite de #7288 (ICT-15b) et des réserves mesurées #9771 / #9764.

Le banc précédent (cellules 2 à 9) transpose Huang sur des graphes de transition markoviens — une transposition conjecturale : l’hypercube, la régularité du graphe et le degré polynomial y sont remplacés par des proxys. Ce statut est conservé pour ce banc legacy et pour la lecture ICT qui en découle. Le présent banc ajoute le chaînon demandé par #17977 : des réseaux booléens réellement appris, mesurés sur l’hypercube vrai — là où l’inégalité de Huang est un théorème (l’instrument est calibré par le théorème ; la question empirique porte ailleurs : la saturation du ratio et le déplacement par entraînement adversarial).

Question (#17977) : pour des fonctions booléennes apprises par de petits réseaux, où le ratio s_max / √deg sature-t-il ? L’entraînement adversarial déplace-t-il ce ratio, et dans quel sens ?

Plan d’analyse déclaré (critères d’échec explicites)

Les définitions, seuils, graines, cibles et prédiction ci-dessous constituent le plan d’analyse déclaré du banc : les critères d’échec (P1 réfutée → verdict FAIL) sont explicites et n’ont pas été re-seuillés après lecture des résultats. Les corrections d’instrument nécessaires à la mise au point sont documentées dans la section « Amendements de calibration » (graines de calibration {11, 22} disjointes des graines de verdict {0, 1, 7, 42}) ; l’assemblage du notebook ne fournit pas d’horodatage indépendant externe de ces étapes — la traçabilité repose sur cette documentation, pas sur une affirmation temporelle invérifiable.

Encodage pré-défini (réserve #9771 : le VRAI degré de Fourier, pas un proxy de masse) :

  • entrées b ∈ {0,1}^n, encodage x = 1 − 2b ∈ {−1,+1}^n ;
  • réseau g : R^n → R (MLP n → 16 (tanh) → 1 (linéaire)), fonction réalisée f̂(x) = +1 si g(x) ≥ 0, −1 sinon (table de vérité complète, déterministe) ;
  • coefficients de Fourier-Walsh f̂(S) = 2^{−n} Σ_x f(x) χ_S(x) avec χ_S(x) = Π_{i∈S} x_i ;
  • degré réel : deg(f) = max{|S| : |f̂(S)| > TOL} avec TOL = 1e-9 — les tables sont exactes, les coefficients sont des multiples de 2^{1−n} : la tolérance stricte est légitime ;
  • s_max exact sur l’hypercube Q_n : s_max(f) = max_x |{i : f(x ⊕ e_i) ≠ f(x)}| — c’est la définition de Huang, distincte de la sensibilité sur graphe de transition observé (ict.sensitivity.local_sensitivity, banc legacy ci-dessus). Les deux grandeurs sont nommées séparément et jamais mélangées.

Design :

Élément Valeur
tailles n ∈ {3, 4} (tables de 8 / 16 entrées, CPU borné)
cibles (labels contrôlés) OR_n, AND_n, MAJ_n (n=3 : ≥2 ; n=4 : ≥3), PAR_n, TRI_bal_n (table aléatoire équilibrée, graine 20260720 + n, imprimée dans la sortie)
graines de verdict {0, 1, 7, 42} (≥ 2 exigé) ; par graine : init du réseau et split 75 % train / 25 % holdout déterministes (rng séparés), identiques entre les deux régimes (paires appariées)
régimes normal = BCE équilibrée par classe sur les points train ; adversarial = BCE(train) + 0,5 × BCE du pire voisin Hamming-1 de chaque point train (adversaire discret exact à rayon 1, label du point propre ; λ = 0,5, cf. amendements)
budget 400 étapes Adam lr 0.05, full batch — budget FIXE, convergence non forcée (c’est l’écart de généralisation qui porte le signal)
garde anti-dégénérescence (régimes entraînés) entrée marquée degenerate si table réalisée constante OU train_acc < 0.9 ; exclue des verdicts, comptée honnêtement (réserve : éviter les réseaux constants vacuous) ; pour le contrôle untrained, seule la constance exclut (aucune exigence d’accuracy à 0 étape)

Contrôles :

  1. réseau non entraîné (même init que la paire, 0 étape) ;
  2. bande aléatoire appariée en degré : n=3 exhaustive (les 256 fonctions booléennes), n=4 échantillon de 3000 fonctions uniformes — distribution de référence du ratio par degré.

Prédiction falsifiable P1 et sa mesure :

  • P1 : l’entraînement adversarial réduit s_max de la fonction réalisée — critère déclaré sur Δs_max, proxy de la quantité de l’issue (le ratio s_max/√deg ; le complément Δratio est mesuré en fin de banc, section post-revue). CONFIRMÉE ssi (i) n_paires ≥ 8 (paires non-dégénérées des deux côtés), (ii) médiane(Δs_max) < 0, (iii) #{Δ < 0} > #{Δ > 0} (les égalités à 0 ne comptent pour aucun camp).
  • P2 (gate instrument — théorème, pas prédiction) : toute table réalisée non constante satisfait s_max ≥ √deg (Huang sur Q_n). Violation = bug instrument → verdict INVALID.
  • P3 (contrôle untrained) : médiane(s_max_norm) ≤ médiane(s_max_untrained) — l’entraînement sur labels contrôlés n’augmente pas la sensibilité médiane.
  • P4 (résolution / saturation, réserves #9771-#9764) : au moins 2 degrés distincts parmi les tables réalisées non-dégénérées des régimes entraînés, ET fraction des entrées entraînées (non-dégénérées) au plafond structurel s_max = n strictement inférieure à 3/4 (la mesure ne sature pas).

Verdict global (critères déclarés) :

Condition Verdict
P2 violée ou gates instrument en échec INVALID
P4 non tenue, ou n_paires < 8, ou fraction de dégénérés > 1/3 (régimes entraînés) INCONCLUSIVE
P1 confirmée (hors régime INCONCLUSIVE) PASS
P1 réfutée (médiane(Δ) ≥ 0 ou direction inversée) hors régime INCONCLUSIVE FAIL

Le verdict porte sur P1 (déplacement par l’adversarial, sens prédit : vers le bas) ; P3 et P4 sont des contrôles rapportés, pas des conditions du verdict.

Amendements de calibration (documentés ; graines de calibration {11, 22} disjointes des graines de verdict {0, 1, 7, 42})

Trois corrections d’instrument, appliquées en utilisant uniquement les graines {11, 22} — disjointes des graines de verdict — avec pour seul critère la santé de l’instrument (taux de dégénérés, train_acc du régime normal), pas le signal Δs_max ou Δratio. Les seuils de verdict (P1-P4, bornes, graines de verdict, garde anti-dégénérescence) sont restés inchangés du plan initial à l’analyse.

  1. Gradient BCE (v1 → v2) : la première version codait dL/dz = −y·σ(y·z) au lieu de −y·σ(−y·z) — un gradient de signe faut qui plafonnait train_acc vers 0,5-0,85 en rendant la confiance répulsive. Corrigé + gate optimiseur ajoutée (fit parfait de MAJ_3 sur table complète, graine 11).
  2. BCE équilibrée par classe (v2) : poids 1/(2·fréquence de classe) normalisés — sans elle, les cibles déséquilibrées (OR/AND : 2^n − 1 contre 1) convergent vers la constante vacuous, précisément ce que la garde anti-dégénérescence doit éviter de produire en masse.
  3. Poids adversarial λ = 0,5 (v3) : à rayon 1 sur l’hypercube, le pire voisin Hamming-1 d’un point porte souvent le label opposé (toujours, pour PAR) ; avec un poids 1/1, le terme adversarial contredit le terme propre au point de bloquer l’apprentissage — λ = 1 a été écarté à ce stade de calibration (graines disjointes {11, 22}), sans que le taux de dégénérés mesuré alors soit conservé dans une sortie committée. λ = 0,5 (poids à la TRADES) a été retenu : il conserve la pression de lissage local tout en laissant l’apprentissage converger (le gate optimiseur de la cellule instrument, exécuté dans le notebook, exige train_acc ≥ 0,9 en régime adversarial λ = 0,5). Grille λ testée : {0 (= normal), 0,5, 1} — aucune valeur intermédiaire entre 0,5 et 1 n’a été essayée.
# --- Banc #17977 : instrument -- degre de Fourier REEL + sensibilite hypercube exacte ---
# Reserve #9771 : le degre mesure ici est le VRAI degre de Fourier-Walsh de la table de
# verite complete, pas un proxy de masse de probabilite.
# Reserve #9764 : la saturation de la mesure est testee explicitement (gate P4) avant
# tout verdict. numpy pur, CPU borne, deterministe (rng explicites, aucun alea cache).
import itertools

TOL_FOURIER = 1e-9  # pre-enregistre : coefficients multiples de 2^{1-n}, tolerance stricte legitime


def hypercube_inputs(n):
    """Encodage pre-defini : b in {0,1}^n -> x = 1 - 2*b in {-1,+1}^n (ordre lexicographique)."""
    B = np.array(list(itertools.product([0, 1], repeat=n)), dtype=float)
    return 1.0 - 2.0 * B


def fourier_degree(table_pm1, n):
    """Degre de Fourier-Walsh REEL de f : {-1,+1}^n -> {-1,+1} (table sur l'hypercube ordonne).

    f_hat(S) = 2^{-n} * sum_x f(x) * chi_S(x),  chi_S(x) = prod_{i in S} x_i.
    deg(f) = max{ |S| : |f_hat(S)| > TOL_FOURIER }. Constante -> deg = 0.
    """
    X = hypercube_inputs(n)
    deg = 0
    for k in range(n + 1):
        for S in itertools.combinations(range(n), k):
            chi = np.prod(X[:, list(S)], axis=1) if S else np.ones(len(X))
            if abs(float(np.mean(table_pm1 * chi))) > TOL_FOURIER:
                deg = max(deg, k)
    return deg


def hypercube_sensitivity(table_pm1, n):
    """Sensibilite EXACTE s_x(f) sur l'hypercube Q_n (definition de Huang).

    Distincte de la sensibilite sur graphe de transition observe (banc legacy,
    ict.sensitivity.local_sensitivity) : ici le voisinage est TOUJOURS l'ensemble
    des n voisins Hamming-1 de l'hypercube, sans observation ni lissage.
    """
    X = hypercube_inputs(n)
    index = {tuple(x): i for i, x in enumerate(X)}
    s = np.zeros(len(X), dtype=int)
    for i, x in enumerate(X):
        flips = 0
        for j in range(n):
            x_nb = x.copy()
            x_nb[j] = -x_nb[j]
            flips += int(table_pm1[index[tuple(x_nb)]] != table_pm1[i])
        s[i] = flips
    return s


# --- Petit MLP numpy (CPU borne, deterministe) ---
class TinyMLP:
    """MLP n -> hidden(tanh) -> 1 (lineaire), gradient manuel, Adam externe."""

    def __init__(self, n, hidden=16, seed=None):
        r = np.random.default_rng(seed)
        self.W1 = r.uniform(-1.0, 1.0, size=(n, hidden)) / np.sqrt(n)
        self.b1 = np.zeros(hidden)
        self.W2 = r.uniform(-1.0, 1.0, size=hidden) / np.sqrt(hidden)
        self.b2 = np.zeros(1)
        self._X = None
        self._h = None

    def forward(self, X):
        self._X = X
        self._h = np.tanh(X @ self.W1 + self.b1)
        return self._h @ self.W2 + self.b2[0]

    def params(self):
        return [self.W1, self.b1, self.W2, self.b2]

    def backward(self, dlogit):
        gW2 = self._h.T @ dlogit
        gb2 = np.array([dlogit.sum()])
        dh = np.outer(dlogit, self.W2) * (1.0 - self._h ** 2)
        gW1 = self._X.T @ dh
        gb1 = dh.sum(axis=0)
        return [gW1, gb1, gW2, gb2]


def _sigmoid(v):
    return 0.5 * (1.0 + np.tanh(0.5 * v))  # stable


def bce_with_logits(z, y, w=None):
    """BCE moyenne avec logits, y in {-1,+1} : L = mean softplus(-y*z).

    w : poids optionnels par point (normalises, moyenne 1) -- ponderation equilibree
    par classe pour eviter que les cibles desequilibrees (OR/AND : 2^n - 1 contre 1)
    ne convergent vers la constante vacuous (amendement pre-execution v2).
    dL/dz_i = -y_i * sigma(-y_i*z_i) * w_i / m  (sigma(-u) = 1 - sigma(u)).
    """
    u = -y * z
    if w is None:
        w = np.ones_like(z)
    loss = float(np.mean(w * np.logaddexp(0.0, u)))
    dz = -(w * y * _sigmoid(u)) / len(z)
    return loss, dz


def _class_balance_weights(ytr):
    """Poids 1/(2*freq_classe), normalises a la moyenne 1 (ponderation equilibree)."""
    frac_pos = float(np.mean(ytr > 0))
    w = np.where(ytr > 0, 0.5 / max(frac_pos, 1e-9), 0.5 / max(1.0 - frac_pos, 1e-9))
    return w / w.mean()


def train_mlp(n, Xtr, ytr, *, regime, seed, steps=400, lr=0.05, lam=0.5):
    """Entraine un TinyMLP ; regime in {'normal', 'adversarial', 'untrained'}.

    normal      : L = BCE equilibree(g(Xtr), y).
    adversarial : L = BCE equilibree(g(Xtr), y) + lam * BCE equilibree(g(x_adv), y)
                  ou x_adv est le pire voisin Hamming-1 de chaque point train
                  (adversaire discret exact a rayon 1, label du point propre).
                  lam = 0.5 (amendement pre-execution v3) : a rayon 1 sur l'hypercube,
                  le pire voisin porte souvent le label oppose (toujours pour PAR) ;
                  lam = 1 a ete ecarte en calibration (graines disjointes 11/22) :
                  a ce poids le terme adversarial bloque l'apprentissage (taux de
                  degeneres non conserve dans une sortie committee) ; lam = 0.5
                  (poids a la TRADES) restaure l'apprentissage en conservant la
                  pression de lissage local.
    untrained   : reseau a l'initialisation (controle, 0 etape).
    """
    net = TinyMLP(n, seed=seed)
    if regime == 'untrained':
        return net
    w = _class_balance_weights(ytr)
    params = net.params()
    m_a = [np.zeros_like(p) for p in params]
    v_a = [np.zeros_like(p) for p in params]
    beta1, beta2, eps = 0.9, 0.999, 1e-8
    for t in range(1, steps + 1):
        z = net.forward(Xtr)
        _, dl = bce_with_logits(z, ytr, w)
        grads = net.backward(dl)
        if regime == 'adversarial':
            Xnb = np.repeat(Xtr[:, None, :], n, axis=1).copy()
            jj = np.arange(n)
            Xnb[:, jj, jj] *= -1.0  # Xnb[i, j] = Xtr[i] avec coord j basculee
            znb = net.forward(Xnb.reshape(-1, n)).reshape(len(Xtr), n)
            losses_nb = np.logaddexp(0.0, -ytr[:, None] * znb)
            worst = np.argmax(losses_nb, axis=1)
            X_adv = Xnb[np.arange(len(Xtr)), worst]
            z_adv = net.forward(X_adv)
            _, dl_adv = bce_with_logits(z_adv, ytr, w)
            g_adv = net.backward(dl_adv * lam)
            grads = [g + ga for g, ga in zip(grads, g_adv)]
        for k, (p, g) in enumerate(zip(params, grads)):
            m_a[k] = beta1 * m_a[k] + (1.0 - beta1) * g
            v_a[k] = beta2 * v_a[k] + (1.0 - beta2) * (g * g)
            mh = m_a[k] / (1.0 - beta1 ** t)
            vh = v_a[k] / (1.0 - beta2 ** t)
            p -= lr * mh / (np.sqrt(vh) + eps)
    return net


# --- Gates instrument : retrouver les valeurs de reference AVANT toute mesure (pattern L934) ---
def _ref_tables(n):
    B = np.array(list(itertools.product([0, 1], repeat=n)))
    s1 = B.sum(axis=1)
    return {
        f'dictateur_{n}': (B[:, 0] == 1).astype(int),
        f'OR_{n}': (s1 >= 1).astype(int),
        f'AND_{n}': (s1 == n).astype(int),
        f'MAJ_{n}': (s1 >= n // 2 + 1).astype(int),
        f'PAR_{n}': (s1 % 2).astype(int),
    }


# valeurs classiques, verifiees a la main. MAJ_3 : s_max = 2 (les points de poids 1 et 2
# ont exactement 2 voisins qui basculent la majorite ; 2 >= sqrt(3), Huang tient). Une
# premiere version de ce gate attendait s_max = 3 par erreur de calcul manuel -- c'est le
# gate qui a raison, correction apportee AVANT toute mesure du banc.
gates_attendus = {
    'dictateur_4': (1, 1),
    'OR_4': (4, 4),
    'MAJ_3': (3, 2),
    'PAR_4': (4, 4),
}
print('=== Gates instrument (valeurs de reference) ===')
all_gates_ok = True
for name, (deg_att, smax_att) in gates_attendus.items():
    n_g = int(name[-1])
    tab = 1.0 - 2.0 * _ref_tables(n_g)[name].astype(float)
    d_g = fourier_degree(tab, n_g)
    s_g = int(hypercube_sensitivity(tab, n_g).max())
    ok = (d_g == deg_att) and (s_g == smax_att)
    all_gates_ok &= ok
    print(f'  {name:>12} : deg={d_g} (attendu {deg_att}), s_max={s_g} (attendu {smax_att}) -> {"OK" if ok else "ECHEC"}')

# Verification EXHAUSTIVE du theoreme de Huang sur n=3 : les 256 fonctions booleennes
n_viol_huang = 0
for v in range(2 ** (2 ** 3)):
    tab = 1.0 - 2.0 * np.array([(v >> i) & 1 for i in range(8)], dtype=float)
    if np.all(tab == tab[0]):
        continue  # constante : deg = 0, inegalite triviale
    d_h = fourier_degree(tab, 3)
    s_h = int(hypercube_sensitivity(tab, 3).max())
    if s_h < np.sqrt(d_h) - 1e-12:
        n_viol_huang += 1
print(f'  theoreme Huang verifie exhaustivement sur les 256 fonctions de n=3 : {n_viol_huang} violation(s)')
all_gates_ok &= (n_viol_huang == 0)

# Gate optimiseur (amendements v2/v3) : le gradient corrige + ponderation equilibree
# doivent laisser le regime NORMAL fitter PARFAITEMENT une cible complete (graine de
# calibration 11, disjointe des graines de test), et le regime ADVERSARIAL lam=0.5
# doit rester apprenable sur une cible equilibree (train_acc >= 0.9).
_tab_maj3 = 1.0 - 2.0 * _ref_tables(3)['MAJ_3'].astype(float)
_X3 = hypercube_inputs(3)
_net_g = train_mlp(3, _X3, _tab_maj3, regime='normal', seed=2011, steps=600, lr=0.1)
_acc_norm = float(np.mean(np.where(_net_g.forward(_X3) >= 0, 1.0, -1.0) == _tab_maj3))
_net_a = train_mlp(3, _X3, _tab_maj3, regime='adversarial', seed=2011, steps=600, lr=0.1)
_acc_adv = float(np.mean(np.where(_net_a.forward(_X3) >= 0, 1.0, -1.0) == _tab_maj3))
print(f'  gate optimiseur (MAJ_3 complet, graine 11 disjointe) : train_acc normal = {_acc_norm:.3f}, '
      f'adversarial (lam=0.5) = {_acc_adv:.3f} (attendus 1.0 / >= 0.9)')
all_gates_ok &= (_acc_norm == 1.0) and (_acc_adv >= 0.9)

if not all_gates_ok:
    raise RuntimeError('Gates instrument en echec : ne pas mesurer avant correction.')
print('Gates instrument PASS : l instrument discrimine (deg et s_max exacts), respecte Huang '
      'sur n=3, et l optimiseur converge (normal ET adversarial lam=0.5).')
=== Gates instrument (valeurs de reference) ===
   dictateur_4 : deg=1 (attendu 1), s_max=1 (attendu 1) -> OK
          OR_4 : deg=4 (attendu 4), s_max=4 (attendu 4) -> OK
         MAJ_3 : deg=3 (attendu 3), s_max=2 (attendu 2) -> OK
         PAR_4 : deg=4 (attendu 4), s_max=4 (attendu 4) -> OK
  theoreme Huang verifie exhaustivement sur les 256 fonctions de n=3 : 0 violation(s)
  gate optimiseur (MAJ_3 complet, graine 11 disjointe) : train_acc normal = 1.000, adversarial (lam=0.5) = 1.000 (attendus 1.0 / >= 0.9)
Gates instrument PASS : l instrument discrimine (deg et s_max exacts), respecte Huang sur n=3, et l optimiseur converge (normal ET adversarial lam=0.5).
# --- Banc #17977 : execution (design gele, cf pre-enregistrement ci-dessus) ---
SEEDS_BENCH = [0, 1, 7, 42]
STEPS_BENCH, LR_BENCH, HIDDEN_BENCH = 400, 0.05, 16  # budget FIXE pre-enregistre

rows_17977 = []
for n_b in (3, 4):
    B = np.array(list(itertools.product([0, 1], repeat=n_b)))
    X_full = 1.0 - 2.0 * B.astype(float)  # (2^n, n)
    # Cibles : labels CONTROLES en encodage b (0/1), convertis en +-1 pour l entrainement
    targets_b = {
        f'OR_{n_b}': (B.sum(axis=1) >= 1).astype(int),
        f'AND_{n_b}': (B.sum(axis=1) == n_b).astype(int),
        f'MAJ_{n_b}': (B.sum(axis=1) >= n_b // 2 + 1).astype(int),
        f'PAR_{n_b}': (B.sum(axis=1) % 2).astype(int),
    }
    r_tgt = np.random.default_rng(20260720 + n_b)  # TRI_bal : table aleatoire equilibree, graine publiee
    tab_bal = np.array([1] * (2 ** (n_b - 1)) + [0] * (2 ** (n_b - 1)))
    r_tgt.shuffle(tab_bal)
    targets_b[f'TRI_bal_{n_b}'] = tab_bal
    print(f'--- n={n_b} : cible TRI_bal_{n_b} (labels controles) = {tab_bal.tolist()}')

    for tname, t01 in targets_b.items():
        y_full = 1.0 - 2.0 * t01.astype(float)  # +-1
        deg_t = fourier_degree(y_full, n_b)
        s_t = int(hypercube_sensitivity(y_full, n_b).max())
        rows_17977.append(dict(n=n_b, cible=tname, graine=-1, regime='cible',
                               train_acc=1.0, deg=deg_t, s_max=s_t,
                               ratio=(s_t / np.sqrt(deg_t)) if deg_t > 0 else float('nan'),
                               degenerate=False, plafond=(s_t == n_b)))
        for seed in SEEDS_BENCH:
            # Split deterministe par graine, IDENTIQUE entre regimes (paires appariees)
            r_split = np.random.default_rng(1000 + seed)
            perm = r_split.permutation(len(X_full))
            n_train = 3 * len(X_full) // 4  # 75 % train / 25 % holdout
            itr = perm[:n_train]
            Xtr, ytr = X_full[itr], y_full[itr]
            for regime in ('untrained', 'normal', 'adversarial'):
                net = train_mlp(n_b, Xtr, ytr, regime=regime, seed=2000 + seed,
                                steps=STEPS_BENCH, lr=LR_BENCH)
                table = np.where(net.forward(X_full) >= 0, 1.0, -1.0)
                constant = bool(np.all(table == table[0]))
                train_acc = float(np.mean(np.where(net.forward(Xtr) >= 0, 1.0, -1.0) == ytr))
                deg_r = fourier_degree(table, n_b)
                s_r = int(hypercube_sensitivity(table, n_b).max())
                # Garde pre-enregistree : regimes entraines -> constance OU train_acc < 0.9 ;
                # controle untrained -> seule la constance exclut.
                if regime == 'untrained':
                    degen = constant
                else:
                    degen = constant or (train_acc < 0.9)
                rows_17977.append(dict(n=n_b, cible=tname, graine=seed, regime=regime,
                                       train_acc=train_acc, deg=deg_r, s_max=s_r,
                                       ratio=(s_r / np.sqrt(deg_r)) if (deg_r > 0 and not constant) else float('nan'),
                                       degenerate=degen, plafond=(s_r == n_b)))

bench_17977 = pd.DataFrame(rows_17977)
print('\n=== Banc #17977 : mesure complete (cible = reference ; plafond = s_max == n) ===')
print(bench_17977.to_string(index=False))

n_trained = len(bench_17977[bench_17977['regime'].isin(['normal', 'adversarial'])])
n_degen = int(bench_17977.loc[bench_17977['regime'].isin(['normal', 'adversarial']), 'degenerate'].sum())
print(f'\nEntrees entrainees : {n_trained} ; degeneres (garde pre-enregistree) : {n_degen} '
      f'({n_degen / n_trained:.1%})')
--- n=3 : cible TRI_bal_3 (labels controles) = [1, 0, 1, 0, 0, 1, 0, 1]
--- n=4 : cible TRI_bal_4 (labels controles) = [0, 0, 1, 1, 1, 1, 0, 0, 1, 0, 1, 0, 0, 1, 1, 0]

=== Banc #17977 : mesure complete (cible = reference ; plafond = s_max == n) ===
 n     cible  graine      regime  train_acc  deg  s_max    ratio  degenerate  plafond
 3      OR_3      -1       cible   1.000000    3      3 1.732051       False     True
 3      OR_3       0   untrained   0.500000    3      2 1.154701       False    False
 3      OR_3       0      normal   1.000000    3      3 1.732051       False     True
 3      OR_3       0 adversarial   1.000000    3      3 1.732051       False     True
 3      OR_3       1   untrained   0.500000    1      1 1.000000       False    False
 3      OR_3       1      normal   1.000000    0      0      NaN        True    False
 3      OR_3       1 adversarial   1.000000    0      0      NaN        True    False
 3      OR_3       7   untrained   0.500000    3      2 1.154701       False    False
 3      OR_3       7      normal   1.000000    2      2 1.414214       False    False
 3      OR_3       7 adversarial   1.000000    0      0      NaN        True    False
 3      OR_3      42   untrained   0.500000    3      2 1.154701       False    False
 3      OR_3      42      normal   1.000000    3      2 1.154701       False    False
 3      OR_3      42 adversarial   0.833333    3      2 1.154701        True    False
 3     AND_3      -1       cible   1.000000    3      3 1.732051       False     True
 3     AND_3       0   untrained   0.666667    3      2 1.154701       False    False
 3     AND_3       0      normal   1.000000    2      2 1.414214       False    False
 3     AND_3       0 adversarial   1.000000    0      0      NaN        True    False
 3     AND_3       1   untrained   0.666667    1      1 1.000000       False    False
 3     AND_3       1      normal   1.000000    2      2 1.414214       False    False
 3     AND_3       1 adversarial   1.000000    3      3 1.732051       False     True
 3     AND_3       7   untrained   0.666667    3      2 1.154701       False    False
 3     AND_3       7      normal   1.000000    3      3 1.732051       False     True
 3     AND_3       7 adversarial   1.000000    3      3 1.732051       False     True
 3     AND_3      42   untrained   0.166667    3      2 1.154701       False    False
 3     AND_3      42      normal   1.000000    3      3 1.732051       False     True
 3     AND_3      42 adversarial   1.000000    3      3 1.732051       False     True
 3     MAJ_3      -1       cible   1.000000    3      2 1.154701       False    False
 3     MAJ_3       0   untrained   1.000000    3      2 1.154701       False    False
 3     MAJ_3       0      normal   1.000000    3      2 1.154701       False    False
 3     MAJ_3       0 adversarial   1.000000    3      2 1.154701       False    False
 3     MAJ_3       1   untrained   0.666667    1      1 1.000000       False    False
 3     MAJ_3       1      normal   1.000000    3      2 1.154701       False    False
 3     MAJ_3       1 adversarial   1.000000    3      2 1.154701       False    False
 3     MAJ_3       7   untrained   0.500000    3      2 1.154701       False    False
 3     MAJ_3       7      normal   1.000000    3      2 1.154701       False    False
 3     MAJ_3       7 adversarial   1.000000    3      2 1.154701       False    False
 3     MAJ_3      42   untrained   0.333333    3      2 1.154701       False    False
 3     MAJ_3      42      normal   1.000000    3      2 1.154701       False    False
 3     MAJ_3      42 adversarial   1.000000    3      2 1.154701       False    False
 3     PAR_3      -1       cible   1.000000    3      3 1.732051       False     True
 3     PAR_3       0   untrained   0.166667    3      2 1.154701       False    False
 3     PAR_3       0      normal   1.000000    3      3 1.732051       False     True
 3     PAR_3       0 adversarial   1.000000    3      3 1.732051       False     True
 3     PAR_3       1   untrained   0.500000    1      1 1.000000       False    False
 3     PAR_3       1      normal   1.000000    3      3 1.732051       False     True
 3     PAR_3       1 adversarial   1.000000    3      3 1.732051       False     True
 3     PAR_3       7   untrained   0.666667    3      2 1.154701       False    False
 3     PAR_3       7      normal   1.000000    3      3 1.732051       False     True
 3     PAR_3       7 adversarial   1.000000    3      3 1.732051       False     True
 3     PAR_3      42   untrained   0.333333    3      2 1.154701       False    False
 3     PAR_3      42      normal   1.000000    3      3 1.732051       False     True
 3     PAR_3      42 adversarial   1.000000    3      3 1.732051       False     True
 3 TRI_bal_3      -1       cible   1.000000    2      2 1.414214       False    False
 3 TRI_bal_3       0   untrained   0.333333    3      2 1.154701       False    False
 3 TRI_bal_3       0      normal   1.000000    2      2 1.414214       False    False
 3 TRI_bal_3       0 adversarial   1.000000    2      2 1.414214       False    False
 3 TRI_bal_3       1   untrained   0.500000    1      1 1.000000       False    False
 3 TRI_bal_3       1      normal   1.000000    3      3 1.732051       False     True
 3 TRI_bal_3       1 adversarial   1.000000    3      3 1.732051       False     True
 3 TRI_bal_3       7   untrained   0.666667    3      2 1.154701       False    False
 3 TRI_bal_3       7      normal   1.000000    2      2 1.414214       False    False
 3 TRI_bal_3       7 adversarial   1.000000    2      2 1.414214       False    False
 3 TRI_bal_3      42   untrained   0.500000    3      2 1.154701       False    False
 3 TRI_bal_3      42      normal   1.000000    2      2 1.414214       False    False
 3 TRI_bal_3      42 adversarial   0.833333    3      3 1.732051        True     True
 4      OR_4      -1       cible   1.000000    4      4 2.000000       False     True
 4      OR_4       0   untrained   0.500000    3      3 1.732051       False    False
 4      OR_4       0      normal   1.000000    3      3 1.732051       False    False
 4      OR_4       0 adversarial   1.000000    3      3 1.732051       False    False
 4      OR_4       1   untrained   0.500000    1      1 1.000000       False    False
 4      OR_4       1      normal   1.000000    0      0      NaN        True    False
 4      OR_4       1 adversarial   1.000000    0      0      NaN        True    False
 4      OR_4       7   untrained   0.583333    1      1 1.000000       False    False
 4      OR_4       7      normal   1.000000    0      0      NaN        True    False
 4      OR_4       7 adversarial   1.000000    0      0      NaN        True    False
 4      OR_4      42   untrained   0.666667    3      2 1.154701       False    False
 4      OR_4      42      normal   1.000000    3      3 1.732051       False    False
 4      OR_4      42 adversarial   0.750000    4      3 1.500000        True    False
 4     AND_4      -1       cible   1.000000    4      4 2.000000       False     True
 4     AND_4       0   untrained   0.583333    3      3 1.732051       False    False
 4     AND_4       0      normal   1.000000    0      0      NaN        True    False
 4     AND_4       0 adversarial   1.000000    0      0      NaN        True    False
 4     AND_4       1   untrained   0.416667    1      1 1.000000       False    False
 4     AND_4       1      normal   1.000000    3      3 1.732051       False    False
 4     AND_4       1 adversarial   0.833333    4      3 1.500000        True    False
 4     AND_4       7   untrained   0.416667    1      1 1.000000       False    False
 4     AND_4       7      normal   1.000000    0      0      NaN        True    False
 4     AND_4       7 adversarial   1.000000    0      0      NaN        True    False
 4     AND_4      42   untrained   0.500000    3      2 1.154701       False    False
 4     AND_4      42      normal   1.000000    3      3 1.732051       False    False
 4     AND_4      42 adversarial   0.833333    4      3 1.500000        True    False
 4     MAJ_4      -1       cible   1.000000    4      3 1.500000       False    False
 4     MAJ_4       0   untrained   0.833333    3      3 1.732051       False    False
 4     MAJ_4       0      normal   1.000000    4      3 1.500000       False    False
 4     MAJ_4       0 adversarial   1.000000    4      3 1.500000       False    False
 4     MAJ_4       1   untrained   0.333333    1      1 1.000000       False    False
 4     MAJ_4       1      normal   1.000000    4      3 1.500000       False    False
 4     MAJ_4       1 adversarial   1.000000    4      3 1.500000       False    False
 4     MAJ_4       7   untrained   0.250000    1      1 1.000000       False    False
 4     MAJ_4       7      normal   1.000000    4      3 1.500000       False    False
 4     MAJ_4       7 adversarial   1.000000    4      3 1.500000       False    False
 4     MAJ_4      42   untrained   0.416667    3      2 1.154701       False    False
 4     MAJ_4      42      normal   1.000000    4      3 1.500000       False    False
 4     MAJ_4      42 adversarial   1.000000    4      3 1.500000       False    False
 4     PAR_4      -1       cible   1.000000    4      4 2.000000       False     True
 4     PAR_4       0   untrained   0.583333    3      3 1.732051       False    False
 4     PAR_4       0      normal   1.000000    4      4 2.000000       False     True
 4     PAR_4       0 adversarial   1.000000    4      4 2.000000       False     True
 4     PAR_4       1   untrained   0.666667    1      1 1.000000       False    False
 4     PAR_4       1      normal   1.000000    4      4 2.000000       False     True
 4     PAR_4       1 adversarial   1.000000    4      4 2.000000       False     True
 4     PAR_4       7   untrained   0.500000    1      1 1.000000       False    False
 4     PAR_4       7      normal   1.000000    4      4 2.000000       False     True
 4     PAR_4       7 adversarial   1.000000    4      4 2.000000       False     True
 4     PAR_4      42   untrained   0.416667    3      2 1.154701       False    False
 4     PAR_4      42      normal   1.000000    4      4 2.000000       False     True
 4     PAR_4      42 adversarial   1.000000    4      4 2.000000       False     True
 4 TRI_bal_4      -1       cible   1.000000    4      4 2.000000       False     True
 4 TRI_bal_4       0   untrained   0.500000    3      3 1.732051       False    False
 4 TRI_bal_4       0      normal   1.000000    4      4 2.000000       False     True
 4 TRI_bal_4       0 adversarial   1.000000    4      4 2.000000       False     True
 4 TRI_bal_4       1   untrained   0.583333    1      1 1.000000       False    False
 4 TRI_bal_4       1      normal   1.000000    4      3 1.500000       False    False
 4 TRI_bal_4       1 adversarial   1.000000    4      3 1.500000       False    False
 4 TRI_bal_4       7   untrained   0.583333    1      1 1.000000       False    False
 4 TRI_bal_4       7      normal   1.000000    4      4 2.000000       False     True
 4 TRI_bal_4       7 adversarial   1.000000    3      4 2.309401       False     True
 4 TRI_bal_4      42   untrained   0.583333    3      2 1.154701       False    False
 4 TRI_bal_4      42      normal   1.000000    4      3 1.500000       False    False
 4 TRI_bal_4      42 adversarial   1.000000    4      3 1.500000       False    False

Entrees entrainees : 80 ; degeneres (garde pre-enregistree) : 17 (21.2%)
# --- Banc #17977 : carte de saturation + verdicts du plan DECLARE ---
# (analyse executee selon les seuils declares ci-dessus ; aucun re-seuillage post-hoc)

# Bande aleatoire appariee en degre : n=3 exhaustive (256 fonctions), n=4 echantillon 3000
rand_rows = []
for n_r in (3, 4):
    if n_r == 3:
        tables_rand = [1.0 - 2.0 * np.array([(v >> i) & 1 for i in range(8)], dtype=float)
                       for v in range(2 ** 8)]
    else:
        r_rand = np.random.default_rng(20260721)
        tables_rand = [1.0 - 2.0 * r_rand.integers(0, 2, 16).astype(float) for _ in range(3000)]
    for tab_r in tables_rand:
        if np.all(tab_r == tab_r[0]):
            continue
        d_r = fourier_degree(tab_r, n_r)
        s_r = int(hypercube_sensitivity(tab_r, n_r).max())
        rand_rows.append(dict(n=n_r, deg=d_r, ratio=s_r / np.sqrt(d_r)))
rand_band = pd.DataFrame(rand_rows)
print('=== Bande aleatoire appariee en degre (controle 2) : ratio par degre ===')
band_summary = rand_band.groupby(['n', 'deg'])['ratio'].agg(
    n='size', med='median', p5=lambda s: float(np.percentile(s, 5)),
    p95=lambda s: float(np.percentile(s, 95)), vmin='min', vmax='max')
print(band_summary.to_string())

# Entrees conclusives : non-degeneres, ratio defini, hors lignes 'cible' (reference)
conc_17977 = bench_17977[(~bench_17977['degenerate'])
                          & bench_17977['ratio'].notna()
                          & (bench_17977['regime'] != 'cible')]

# Carte de saturation : stats du ratio par (n, regime, deg) pour les tables realisees
print('\n=== Carte de saturation du ratio s_max / sqrt(deg) (tables realisees non-degeneres) ===')
map_17977 = conc_17977.groupby(['n', 'regime', 'deg']).agg(
    n_entrees=('ratio', 'size'),
    ratio_med=('ratio', 'median'),
    ratio_min=('ratio', 'min'),
    ratio_max=('ratio', 'max'),
    frac_plafond=('plafond', 'mean'))
print(map_17977.to_string())

# Position vs bande aleatoire appariee en degre (descriptif) : ratio dans [p5, p95] de sa bande ?
def _dans_bande(row):
    grp = rand_band[(rand_band['n'] == row['n']) & (rand_band['deg'] == row['deg'])]
    if grp.empty:
        return np.nan
    lo, hi = np.percentile(grp['ratio'], [5, 95])
    return bool(lo - 1e-12 <= row['ratio'] <= hi + 1e-12)

trained_mask = conc_17977['regime'].isin(['normal', 'adversarial'])
conc_trained = conc_17977[trained_mask].copy()
conc_trained['dans_bande'] = conc_trained.apply(_dans_bande, axis=1)
in_band = conc_trained.groupby('regime')['dans_bande'].mean()
print('\nFraction des tables entrainees dont le ratio tombe dans la bande aleatoire '
      'appariee en degre [p5, p95] :')
print(in_band.to_string())

# --- P1 : deplacement adversarial (paires appariees n x cible x graine) ---
pn = conc_17977[conc_17977['regime'] == 'normal'].set_index(['n', 'cible', 'graine'])['s_max']
pa = conc_17977[conc_17977['regime'] == 'adversarial'].set_index(['n', 'cible', 'graine'])['s_max']
common = pn.index.intersection(pa.index)
delta_smax = (pa.loc[common] - pn.loc[common]).sort_values()
n_pairs = len(delta_smax)
med_delta = float(delta_smax.median()) if n_pairs else float('nan')
n_neg, n_pos = int((delta_smax < 0).sum()), int((delta_smax > 0).sum())
p1_confirmee = bool(n_pairs >= 8 and med_delta < 0 and n_neg > n_pos)
print(f'\n--- P1 (declaree) : l adversarial reduit s_max ---')
print(f'  paires non-degeneres : {n_pairs} ; Delta = s_max_adv - s_max_norm : '
      f'{n_neg} negatives / {n_pairs - n_neg - n_pos} nulles / {n_pos} positives ; mediane = {med_delta}')
print(f'  P1 : {"CONFIRMEE" if p1_confirmee else "NON CONFIRMEE"}')

# --- P2 : gate theoreme sur TOUTES les tables realisees non constantes ---
non_const = bench_17977[(bench_17977['deg'] > 0) & (bench_17977['s_max'] >= 0)]
p2_ok = bool((non_const['s_max'] >= np.sqrt(non_const['deg']) - 1e-12).all())
n_p2_viol = int((non_const['s_max'] < np.sqrt(non_const['deg']) - 1e-12).sum())
print(f'--- P2 (theoreme, gate instrument) : {len(non_const)} tables non constantes, '
      f'{n_p2_viol} violation(s) de s_max >= sqrt(deg) -> {"OK" if p2_ok else "INVALID"}')

# --- P3 : controle untrained ---
med_norm = float(conc_17977[conc_17977['regime'] == 'normal']['s_max'].median())
med_untr = float(conc_17977[conc_17977['regime'] == 'untrained']['s_max'].median())
p3_confirmee = bool(med_norm <= med_untr)
print(f'--- P3 (controle untrained) : mediane s_max normal = {med_norm}, untrained = {med_untr} '
      f'-> {"CONFIRMEE" if p3_confirmee else "NON CONFIRMEE"} (l entrainement n augmente pas la sensibilite)')

# --- P4 : resolution + non-saturation (reserves #9771/#9764) ---
deg_distincts = conc_trained['deg'].nunique()
frac_plafond_glob = float(conc_trained['plafond'].mean())
p4_ok = bool(deg_distincts >= 2 and frac_plafond_glob < 0.75)
print(f'--- P4 (resolution/saturation) : degres distincts = {deg_distincts} (>= 2 requis), '
      f'fraction au plafond s_max = n : {frac_plafond_glob:.1%} (< 75 % requis) '
      f'-> {"CONFIRMEE" if p4_ok else "NON CONFIRMEE"}')

# --- Verdict global (criteres declares) ---
frac_degen = n_degen / n_trained
if not p2_ok:
    verdict_17977 = 'INVALID'
elif (not p4_ok) or n_pairs < 8 or frac_degen > 1 / 3:
    verdict_17977 = 'INCONCLUSIVE'
elif p1_confirmee:
    verdict_17977 = 'PASS'
else:
    verdict_17977 = 'FAIL'
print(f'\n=== VERDICT #17977 (criteres declares) : {verdict_17977} ===')
print(f'  fraction degeneree (entraines) : {frac_degen:.1%} (seuil INCONCLUSIVE : > 33,3 %)')
print('  Rappel des conditions : INVALID si P2 violee ; INCONCLUSIVE si P4 non tenue, '
      'n_paires < 8 ou degen > 1/3 ; PASS si P1 confirmee ; FAIL sinon (mediane >= 0).')

# --- Complement post-revue : Delta RATIO apparie (la quantite de l'issue #17977) ---
# P1 declaree porte sur Delta s_max, PROXY de la quantite de l'issue (le ratio).
# Ce bloc, ajoute apres revue, mesure la quantite elle-meme : lecture DESCRIPTIVE,
# sans critere declare -- aucun seuil du plan ne porte sur Delta ratio. Rappel :
# Delta s_max = 0 n'entraine PAS Delta ratio = 0 (le degre de la table realisee
# peut differer entre les deux regimes d'une meme paire).
rn_q = conc_17977[conc_17977['regime'] == 'normal'].set_index(['n', 'cible', 'graine'])
ra_q = conc_17977[conc_17977['regime'] == 'adversarial'].set_index(['n', 'cible', 'graine'])
common_q = rn_q.index.intersection(ra_q.index)
delta_ratio = (ra_q.loc[common_q, 'ratio'] - rn_q.loc[common_q, 'ratio']).sort_values()
n_neg_r, n_nul_r, n_pos_r = int((delta_ratio < 0).sum()), int((delta_ratio == 0).sum()), int((delta_ratio > 0).sum())
print('\n--- Complement post-revue : Delta ratio = ratio_adv - ratio_norm (memes paires qualifiees que P1) ---')
print('  paires : %d ; negatives = %d, nulles = %d, positives = %d ; mediane = %.6f'
      % (len(delta_ratio), n_neg_r, n_nul_r, n_pos_r, float(delta_ratio.median())))
for k_q, v_q in delta_ratio[delta_ratio != 0].items():
    s_n = int(rn_q.loc[k_q, 's_max']); s_a = int(ra_q.loc[k_q, 's_max'])
    d_n = int(rn_q.loc[k_q, 'deg']); d_a = int(ra_q.loc[k_q, 'deg'])
    print('    paire %s : Delta ratio = %+.6f (s_max %d->%d, deg %d->%d)'
          % (k_q, float(v_q), s_n, s_a, d_n, d_a))
print('  Lecture descriptive (hors verdict declare) : aucun deplacement negatif du ratio ;')
print('  les paires a s_max constant montrent que degre realise et ratio peuvent bouger seuls.')
=== Bande aleatoire appariee en degre (controle 2) : ratio par degre ===
          n       med        p5       p95      vmin      vmax
n deg                                                        
3 1       6  1.000000  1.000000  1.000000  1.000000  1.000000
  2      62  1.414214  1.414214  2.121320  1.414214  2.121320
  3     186  1.732051  1.154701  1.732051  1.154701  1.732051
4 2       9  1.414214  1.414214  1.414214  1.414214  1.414214
  3     564  1.732051  1.732051  2.309401  1.154701  2.309401
  4    2427  2.000000  1.500000  2.000000  1.000000  2.000000

=== Carte de saturation du ratio s_max / sqrt(deg) (tables realisees non-degeneres) ===
                   n_entrees  ratio_med  ratio_min  ratio_max  frac_plafond
n regime      deg                                                          
3 adversarial 2            2   1.414214   1.414214   1.414214      0.000000
              3           13   1.732051   1.154701   1.732051      0.692308
  normal      2            6   1.414214   1.414214   1.414214      0.000000
              3           13   1.732051   1.154701   1.732051      0.615385
  untrained   1            5   1.000000   1.000000   1.000000      0.000000
              3           15   1.154701   1.154701   1.154701      0.000000
4 adversarial 3            2   2.020726   1.732051   2.309401      0.500000
              4           11   1.500000   1.500000   2.000000      0.454545
  normal      3            4   1.732051   1.732051   1.732051      0.000000
              4           12   1.750000   1.500000   2.000000      0.500000
  untrained   1           10   1.000000   1.000000   1.000000      0.000000
              3           10   1.443376   1.154701   1.732051      0.000000

Fraction des tables entrainees dont le ratio tombe dans la bande aleatoire appariee en degre [p5, p95] :
regime
adversarial    1.0
normal         1.0

--- P1 (declaree) : l adversarial reduit s_max ---
  paires non-degeneres : 28 ; Delta = s_max_adv - s_max_norm : 0 negatives / 27 nulles / 1 positives ; mediane = 0.0
  P1 : NON CONFIRMEE
--- P2 (theoreme, gate instrument) : 118 tables non constantes, 0 violation(s) de s_max >= sqrt(deg) -> OK
--- P3 (controle untrained) : mediane s_max normal = 3.0, untrained = 2.0 -> NON CONFIRMEE (l entrainement n augmente pas la sensibilite)
--- P4 (resolution/saturation) : degres distincts = 3 (>= 2 requis), fraction au plafond s_max = n : 46.0% (< 75 % requis) -> CONFIRMEE

=== VERDICT #17977 (criteres declares) : FAIL ===
  fraction degeneree (entraines) : 21.2% (seuil INCONCLUSIVE : > 33,3 %)
  Rappel des conditions : INVALID si P2 violee ; INCONCLUSIVE si P4 non tenue, n_paires < 8 ou degen > 1/3 ; PASS si P1 confirmee ; FAIL sinon (mediane >= 0).

--- Complement post-revue : Delta ratio = ratio_adv - ratio_norm (memes paires qualifiees que P1) ---
  paires : 28 ; negatives = 0, nulles = 26, positives = 2 ; mediane = 0.000000
    paire (4, 'TRI_bal_4', 7) : Delta ratio = +0.309401 (s_max 4->4, deg 4->3)
    paire (3, 'AND_3', 1) : Delta ratio = +0.317837 (s_max 2->3, deg 2->3)
  Lecture descriptive (hors verdict declare) : aucun deplacement negatif du ratio ;
  les paires a s_max constant montrent que degre realise et ratio peuvent bouger seuls.

Interprétation du banc #17977 — FAIL du critère déclaré sur Δs_max

Deux quantités à distinguer. Le verdict FAIL porte sur P1, le critère d’analyse déclaré dans ce notebook : « l’entraînement adversarial réduit s_max ». Sur 28 paires appariées non dégénérées, 27 ont Δs_max = 0, une a Δs_max > 0, aucune n’a Δs_max < 0 ; la médiane est 0. Le commentaire de claim sur l’issue formulait toutefois l’hypothèse en termes de ratio s_max/√deg : P1 (sur Δs_max) n’en est qu’un proxy et ne suffit pas, seul, à répondre à cette formulation. Le complément calculé après revue mesure donc directement Δratio : 26 valeurs nulles, 2 positives, aucune négative, médiane 0. Sur (n=4, TRI_bal_4, graine 7), s_max reste 4 mais deg passe de 4 à 3 (Δratio = +0,309) ; sur (n=3, AND_3, graine 1), s_max passe de 2 à 3 et deg de 2 à 3 (Δratio = +0,318). Ces résultats ne montrent aucune baisse dans les paires qualifiées, mais ne constituent pas un test pré-enregistré du ratio. Δs_max nul n’implique ni même table ni même ratio.

Borne et portée de l’effet. P2 donne 0 violation sur les 118 tables réalisées non constantes ; le contrôle exhaustif n=3 donne également 0 violation sur 256 fonctions. La borne de Huang sur l’hypercube vrai n’est pas réfutée. λ = 1 a été écarté à la calibration (graines disjointes {11, 22}) : à ce poids le terme adversarial bloque l’apprentissage, et le taux de dégénérés mesuré alors n’est pas conservé dans une sortie committée ; à λ = 0,5, les paires qualifiées ne montrent aucune baisse de s_max ou du ratio. Seuls λ ∈ {0 ; 0,5 ; 1} ont été examinés ; les valeurs intermédiaires ne sont pas exclues. Le banc ne mesure ni la géométrie des logits ni les grands n : il ne peut expliquer mécaniquement l’absence de baisse ni la généraliser.

Carte de saturation. Les ratios mesurés des tables non constantes sont dans [1,0 ; 2,31]. Parmi les entrées entraînées non dégénérées, 46,0 % atteignent le plafond s_max = n ; trois degrés distincts sont représentés, ce qui satisfait le contrôle P4 déclaré. La bande de fonctions aléatoires appariées en degré recouvre 100 % des ratios appris aux quantiles [p5, p95] de l’échantillon retenu. C’est un chevauchement descriptif, pas une preuve d’indistinguabilité statistique.

Observation Valeur mesurée Lecture
ratio médian (n=4, deg=4, normal) 1,75 sous la médiane aléatoire appariée (2,0)
ratio médian (n=4, deg=4, adversarial) 1,50 également dans la bande témoin
médiane s_max : normal vs non entraîné 3,0 vs 2,0 P3 non confirmée ; la comparaison brute ne contrôle pas la distribution des degrés réalisés
dégénérés parmi les régimes entraînés 17/80 = 21,2 % exclus des paires qualifiées, mais comptés dans le garde

Les cas dégénérés sont surtout des cibles OR_4/AND_4 dont le point minoritaire tombe dans le holdout : le train ne contient alors qu’une classe, et une prédiction constante peut atteindre train_acc = 1 sans résoudre la cible complète. Le résultat reste limité aux hypercubes de 3 et 4 bits, au MLP de 16 neurones cachés, au split 75/25, à 400 étapes, aux quatre graines et aux cinq cibles étudiées. La transposition conjecturale du banc hérité, qui emploie un graphe de transitions observées plutôt que l’hypercube, conserve son statut distinct.

Exercice 1 – proxy_degree_fn custom : degre pondere par les courants nets

Le proxy par defaut deg_proxy (degre structurel moyen, non pondere) traite toutes les aretes egalement. Mais la matrice de courants nets J (Schnakenberg 1976) discrimine les aretes directionnelles : un fort courant net indique une transition irreversible (hors equilibre). On peut definir un degre pondere par les courants nets absolus :

deg_proxy_J(f) = mean_x ( sum_y W[x, y] * |J[x, y]| ) / mean_x ( sum_y W[x, y] )

Cela penalise les aretes a fort courant (irreversibles) et valorise les aretes reversibles. C’est un proxy plus restrictif : il borne le degre polynomial modulo l’irreversibilite. Si la conjecture ICT-15b tient avec ce proxy plus strict, c’est un signal plus fort que le proxy par defaut.

# Exercice 1 -- proxy_degree_fn pondere par les courants nets.
# TODO etudiant : implementer deg_proxy_J_fn(states, n_symbols) qui calcule
# la moyenne ponderee par |J[x, y]| sur les aretes du graphe de transition.
# Indice : utiliser SP.transition_graph pour W et SP.current_matrix pour J.
# Il faut d'abord obtenir P et pi (stationary) : SP.transition_graph
# utilise transition_matrix de ict.time_arrow ; pour pi, faire eig(P.T).

def deg_proxy_J_fn(states, n_symbols):
    # TODO etudiant : retourner un float = degre pondere par |J|.
    print("Exercice 1 a completer -- stub C.1")  # stub C.1
    return 1.0  # stub : retourne un degre par defaut pour permettre au test de continuer

# Test rapide : si l'etudiant implemente correctement, sur le substrat
# `grokking` (compression crossover), la conjecture devrait etre
# `consistent` avec ce proxy plus restrictif aussi (le crossover cree
# de forts courants nets sur la transition phase1->phase2).
verdict_gk_J = huang_conjecture_test(
    grokking_states, n_symbols_gk, f_gk, proxy_degree_fn=deg_proxy_J_fn
)
print("=== Exercice 1 : Grokking avec proxy_degree_fn pondere J ===")
print(f"  verdict = {verdict_gk_J['verdict']} (attendu : consistent)")
print(f"  deg_proxy_J = {verdict_gk_J['deg_proxy']}")
print(f"  threshold = {verdict_gk_J['threshold']:.3f}")
print(f"  ratio = {verdict_gk_J['ratio']:.3f}")
Exercice 1 a completer -- stub C.1
=== Exercice 1 : Grokking avec proxy_degree_fn pondere J ===
  verdict = consistent (attendu : consistent)
  deg_proxy_J = 1.0
  threshold = 1.000
  ratio = 3.000

Exercice 2 – f_multi : tester la conjecture pour plusieurs state functions par substrat

La conjecture ICT-15b depend crucialement du choix de f. Un verdict inconsistent sur un seul f ne signifie pas que la transposition echoue – cela peut signifier que cet f particulier n’est pas le bon representant. On peut tester la conjecture sur un panel de state functions par substrat et regarder la distribution des verdicts.

Par exemple, sur May (16 symboles), on peut tester :

  • f_0(x) = int(x >= 8) : bimodal haut/bas (deja teste)
  • f_1(x) = int(x >= 12) : quartile superieur vs reste
  • f_2(x) = int(x < 4) : quartile inferieur vs reste
  • f_3(x) = x % 2 : parite
  • f_4(x) = int(x % 4 == 0) : congruence mod 4

Si toutes donnent consistent, la transposition est robuste au choix de f. Si aucune ne donne consistent, elle est structurellement en echec sur ce substrat. Si le verdict depend de f, c’est le regime le plus interessant : le proxy polynomial n’est pas uniformement borne, ce qui suggere une dissociation entre localite de f et structure de W.

# Exercice 2 -- f_multi : tester la conjecture sur un panel de state functions
# pour le substrat May (16 symboles), et compter les verdicts.
# TODO etudiant : definir 5 state functions (f_0 a f_4) et les tester.
# Indice : f_0 est deja dans la cellule 7. Utiliser une boucle.

f_panel_may = [
    lambda x: int(x >= 8),   # bimodal haut/bas
    lambda x: int(x >= 12),  # quartile superieur
    lambda x: int(x < 4),    # quartile inferieur
    lambda x: x % 2,         # parite
    lambda x: int(x % 4 == 0),  # congruence mod 4
]
verdicts_may_panel = {}
for i, f in enumerate(f_panel_may):
    v = huang_conjecture_test(may_states, n_symbols_may, f)
    verdicts_may_panel[f"f_{i}"] = v['verdict']
    print(f"  f_{i} : verdict = {v['verdict']}, ratio = {v['ratio']:.3f}")

print("\n=== Distribution des verdicts sur le panel May ===")
for verdict, count in pd.Series(list(verdicts_may_panel.values())).value_counts().items():
    print(f"  {verdict:>13} : {count}/5")
  f_0 : verdict = consistent, ratio = 1.234
  f_1 : verdict = inconsistent, ratio = 0.617
  f_2 : verdict = inconsistent, ratio = 0.617
  f_3 : verdict = consistent, ratio = 1.234
  f_4 : verdict = consistent, ratio = 2.469

=== Distribution des verdicts sur le panel May ===
     consistent : 3/5
   inconsistent : 2/5

Exercice 3 – Comparaison ICT-15b vs ICT-15d (verdict de Cech)

ICT-15d (#7744, PR #9334) applique la cochaine de Cech ponderee aux memes quatre substrats : chaque proxy devient une section locale sur des fenetres contigues, et cech_obstruction_verdict rend NON_TRIVIAL, TRIVIAL ou INCONCLUSIVE selon que les sections se recollent ou non en une mesure globale unique.

Correction d’attribution. Une version anterieure de cet enonce pretait a ICT-15d un verdict NOISE et un « collapse SVD rang 1 » qui aurait touche les quatre substrats. Les deux affirmations sont a rejeter, mais pas pour la meme raison.

NOISE n’appartient pas au vocabulaire de cech_obstruction_verdict : c’est un verdict d’ICT-15c (ict.meta_proxy). L’attribution etait donc simplement fausse.

Le « rang 1 », lui, n’est pas une invention : il est faux la ou il etait attribue, et vrai ailleurs. Les rangs effectifs publies par ICT-15d valent 2 ou 3 – jamais 1. Mais la cellule ci-dessous, sur les trajectoires de ce notebook, mesure bel et bien un effondrement en rang 1 sur axelrod. L’ancien enonce generalisait donc a quatre substrats et au mauvais notebook un phenomene reel mais local. C’est la forme d’erreur la plus couteuse a detecter, parce qu’un lecteur qui verifie un seul cas peut tomber sur celui qui confirme.

Pourquoi la cellule recalcule au lieu de citer. Les substrats d’ICT-15d sont regeneres dans son notebook ; recopier ses verdicts ici comparerait deux instruments sur deux jeux de trajectoires differents, et la colonne MATCH/DIVERGE ne voudrait rien dire. La cellule ci-dessous applique donc le meme module (ict.cech_obstruction) aux trajectoires de ce notebook – celles-la memes sur lesquelles ICT-15b vient de se prononcer. Les deux instruments voient alors strictement la meme chose, ce qui est la seule condition sous laquelle un desaccord est informatif.

Question : les deux instruments s’accordent-ils substrat par substrat, et lorsqu’ils divergent, lequel des deux est le plus credible sur ce substrat-la ?

Deux lectures a departager par la mesure, pas par preference :

  • ICT-15b discrimine ou Cech renonce – le scalaire local simple (s_max) survit la ou la decomposition multi-dimensionnelle manque de matiere. Argument pour la canonicite de la sensibilite.
  • Les deux renoncent ensemble – la structure Markovienne sous-jacente est trop pauvre pour porter l’un ou l’autre. C’est alors un verdict sur le substrat, pas sur les instruments.

Un substrat degenere (peu d’etats visites, trajectoire quasi-constante) doit etre traite a part : un accord entre deux instruments qui n’ont rien a mesurer n’atteste rien.

Piege a ne pas manquer dans la sortie ci-dessous. Le ratio s2/s1 n’est pas un indicateur monotone du verdict : TRIVIAL y est atteint par deux routes opposees. Un ratio proche de 1 signale des valeurs singulieres equilibrees (aucune direction ne domine) ; un ratio proche de 0 signale au contraire un effondrement sur une seule direction. Les deux donnent TRIVIAL, parce que le verdict est porte par l’obstruction cob, pas par le ratio. Lire s2/s1 comme un score de « qualite » conduit donc a ranger ensemble deux situations qui n’ont rien de commun – et c’est exactement l’erreur que la correction d’attribution ci-dessus documente.

# Exercice 3 -- Comparaison ICT-15b (Huang) vs ICT-15d (Cech) sur LES MEMES trajectoires.
#
# Echafaudage fourni : on calcule le verdict de Cech avec le module reel
# `ict.cech_obstruction` (meme recette qu'ICT-15d : sections -> classe -> verdict).
# TODO etudiant : construire la table de comparaison et conclure (cf enonce ci-dessus).

from ict import cech_obstruction as CO

# Les trois proxys d'ICT-15d, reconstruits ici sur les modules deja importes (SP, SE).
def _spec_gap(states, n_symbols):
    return float(SP.spectral_summary(states, n_symbols)['spectral_gap'])

def _sens_mean(states, n_symbols):
    return float(SE.sensitivity_distribution(states, n_symbols, lambda x: x)['mean'])

def _sens_max(states, n_symbols):
    return float(SE.sensitivity_distribution(states, n_symbols, lambda x: x)['max'])

PROXIES_CECH = {"spectral_gap": _spec_gap, "sens_mean": _sens_mean, "sens_max": _sens_max}
N_SYMBOLS = {"gray_scott": n_symbols_gs, "axelrod": n_symbols_ax,
             "grokking": n_symbols_gk, "may": n_symbols_may}

cech_par_substrat = {}
print("=== Verdict de Cech recalcule sur les trajectoires de CE notebook ===")
for nom, states in all_states.items():
    n_total = len(states)
    window_size = max(2, n_total // 30)          # ~30 fenetres, comme ICT-15d
    sections = CO.proxy_sections(states, N_SYMBOLS[nom], window_size, PROXIES_CECH)
    rep = CO.cech_obstruction_class(sections)
    verdict = CO.cech_obstruction_verdict(rep)
    cech_par_substrat[nom] = {"verdict": verdict, "report": rep}
    print(f"  {nom:>10} : {verdict:>13} | s2/s1={rep['s2_over_s1']:.4f} "
          f"| rank={rep['effective_rank']} | cob={rep['mean_coboundary']:.4f} "
          f"| n_windows={rep['n_windows']}")

# TODO etudiant : table de comparaison ICT-15b vs ICT-15d.
#   Pour chaque substrat, mettre en regard verdicts[nom]['verdict'] (Huang, vocabulaire
#   consistent/inconsistent/inconclusive) et cech_par_substrat[nom]['verdict'] (Cech,
#   vocabulaire NON_TRIVIAL/TRIVIAL/INCONCLUSIVE). Les deux vocabulaires ne sont PAS
#   comparables terme a terme : c'est a vous de definir ce que « s'accorder » veut dire
#   ici, et de le justifier -- une comparaison de chaines brutes n'aurait aucun sens.
# TODO etudiant : conclure selon les deux lectures de l'enonce, en isolant les substrats
#   degeneres (regarder n_visited dans `verdicts[nom]` avant de conclure quoi que ce soit).
print("")
print("Exercice a completer : table de comparaison + conclusion.")
=== Verdict de Cech recalcule sur les trajectoires de CE notebook ===
  gray_scott :       TRIVIAL | s2/s1=1.0000 | rank=3 | cob=0.0000 | n_windows=30
     axelrod :       TRIVIAL | s2/s1=0.0000 | rank=1 | cob=0.0000 | n_windows=30
    grokking :   NON_TRIVIAL | s2/s1=0.6627 | rank=3 | cob=0.7767 | n_windows=30
         may :   NON_TRIVIAL | s2/s1=0.4081 | rank=3 | cob=0.5992 | n_windows=30

Exercice a completer : table de comparaison + conclusion.
Retour au sommet