GameTheory-16e : Pilote — joueurs LLM hétérogènes sur le mécanisme Othman–Sandholm (Proposition 6)

Ce side-car branche de vrais LLM hétérogènes comme joueurs sur un mécanisme fini, exécutable et vérifiable : la construction de la Proposition 6 d’Othman & Sandholm (SAGT 2009, Better with Byzantine), livrée dans GameTheory-16-MechanismDesign-Python.ipynb §4.6 et formalisée en Lean (#12343). Les agents scriptés (équilibre, byzantin, aléatoire) servent de contrôles ; chaque action est rejouée par l’oracle déterministe de bien-être social du mécanisme.

Déconflit : GameTheory-03d-Le-Joueur-LLM-Python.ipynb fait déjà jouer des LLM sur un jeu 2×2 en forme normale ; ici l’objet change de classe — un mécanisme (oracle de welfare, action dominante boxing) et non un jeu sous forme normale. 16/16b/16c/16d restent propriétaires du mechanism design ; la formalisation Lean (§1.1) est citée et distinguée des résultats empiriques de ce pilote. See #15399. See #15397. See #15062. See #13571.

Protocole : le pré-enregistrement (§0) est gelé avant le premier appel LLM ; les verdicts H0/H1 (§8) lisent leurs seuils dans le gel, pas dans les résultats.

import hashlib
import json

PREREG = {
    # Roles et types : 2 agents (row, column), 2 types (a, a'), 4 profils vrais.
    "roles": ["row", "col"],
    "types": ["a", "a'"],
    "profils_vrais": [("a", "a"), ("a", "a'"), ("a'", "a"), ("a'", "a'")],
    # Actions : rapporter "a" ou "a'". Action dominante du mecanisme : "a'".
    "actions": ["a", "a'"],
    # Conditions (6) :
    #   scripted-equilibre  : politique a' systematique (controle conforme, dominant)
    #   scripted-byzantin   : politique a systematique (controle byzantin)
    #   scripted-aleatoire  : uniforme seede (plancher de reference)
    #   llm-regles-visibles : LLM voit regles + code du mecanisme + utilites
    #   llm-aveugle         : LLM voit uniquement l'espace d'actions, sans regles
    #   llm-permutation     : regles visibles MAIS la regle dominante est
    #                         permutee (a presente comme dominante) -- hostile (§7)
    "conditions": [
        "scripted-equilibre", "scripted-byzantin", "scripted-aleatoire",
        "llm-regles-visibles", "llm-aveugle", "llm-permutation",
    ],
    # Familles reelles (2 familles minimum, via models.myia.io, cle .env) :
    "familles": {
        "glm": "glm-5.2",        # famille GLM (Z.ai), modele reasonant
        "qwen": "qwen3.8-max",   # famille Qwen (Alibaba)
    },
    # Parametres d'appel : gels avant tout appel.
    # max_tokens=400 : glm-5.2 est un modele reasonant — en dessous, le contenu
    # peut ressortir vide (le raisonnement consomme le budget). Mesure prealable.
    "temperature": 0.7,
    "max_tokens": 400,
    "seeds": [11, 29, 71, 97],    # pilote principal (>= 4 seeds, generation stochastique)
    "seeds_hostile": [11, 29],    # scenario hostile (permutation, §7)
    "max_retry_parse": 1,         # 1 reformulation si JSON invalide, puis "invalide"
    "timeout_requete_s": 120,
    # Politique reseau (infrastructure, DISTINCT du comportement modele) :
    # timeout/erreur reseau -> 2 re-essais (backoff 5 s puis 15 s) ; apres echec :
    # statut 'erreur_reseau', exclue des taux d'actes ET du taux d'invalide,
    # comptee et rapportee separement (jamais comptee comme choix du modele).
    "politique_reseau": "timeout/5xx -> 2 retries (5 s, 15 s) puis statut erreur_reseau",
    "concurrence": 4,              # appels LLM simultanes max
    # Politique refus/parse error : enregistre comme action "invalide" (jamais re-echantillonnee)
    "politique_refus": "refus ou JSON invalide apres retry -> action=None statut 'invalide', "
                       "exclue du taux d'action dominante mais comptee",
    # Metriques : payoff par agent et par type, SW realise vs SW optimal oracle,
    # taux d'action dominante, taux d'invalide, tokens consommes, latences (s).
    # (le proxy ne facture pas : pas de cout usd, seulement les tokens reels)
    "metriques": ["payoff_par_type", "sw_realise", "sw_oracle_optimal", "taux_action_dominante",
                   "taux_invalide", "tokens_totaux", "latence_s"],
    # Hypotheses pre-enregistrees (verdicts au §8) :
    "H0": "les actions LLM (regles visibles ou aveugles) sont indiscernables de l'uniforme 50/50 sur {a, a'}",
    "H1": "avec regles visibles, taux d'action dominante a' >= 0.80 et > taux aveugle (ecart >= 0.20) ; "
          "sous permutation hostile, le taux visible retombe vers l'uniforme (chute >= 0.20 vs visible)",
    "seuils": {
        "taux_dominant_visible_min": 0.80,
        "ecart_visible_vs_aveugle_min": 0.20,
        "chute_permutation_min": 0.20,
    },
    # Analyse statistique (formules gelees AVANT resultats, acceptance #15399) :
    #   intervalle : Wilson 95 % sur chaque proportion (k/n par acte valide) ;
    #   taille d'effet : h de Cohen (difference d'arcsinus) entre conditions.
    "analyse": {"intervalle": "wilson_95", "taille_effet": "cohen_h"},
    # Budget : principal 2 familles x 4 seeds x 4 profils x 2 agents = 64 appels
    #          + hostile 2 familles x 2 seeds x 4 profils x 2 agents = 32 appels
    #          = 96 appels max (retries inclus dans la politique, pas comptes en plus).
    "budget_appels_max": 96,
}

gel = json.dumps(PREREG, sort_keys=True, ensure_ascii=False)
PREREG_SHA = hashlib.sha256(gel.encode("utf-8")).hexdigest()
print("Pre-enregistrement GEL (sha256) :", PREREG_SHA[:16], "...")
print("Familles :", list(PREREG["familles"]))
print("Seuils :", PREREG["seuils"])
Pre-enregistrement GEL (sha256) : aca1caf4fb552408 ...
Familles : ['glm', 'qwen']
Seuils : {'taux_dominant_visible_min': 0.8, 'ecart_visible_vs_aveugle_min': 0.2, 'chute_permutation_min': 0.2}

Lecture chiffree — le gel pose ses seuils avant tout appel. La sortie affiche l’empreinte Pre-enregistrement GEL (sha256) : aca1caf4fb552408 ..., les familles ['glm', 'qwen'] et le dictionnaire des seuils : taux_dominant_visible_min: 0.8, ecart_visible_vs_aveugle_min: 0.2, chute_permutation_min: 0.2. Ces trois nombres sont la piece centrale du protocole : le verdict du §8 les lira tels quels, sans renegociation possible — 0,80 pour le taux d’action dominante sous regles visibles, 0,20 pour l’ecart visible-aveugle, 0,20 pour la chute sous permutation hostile. Le hash sha256 scelle l’anteriorite : ce qui est hashe ici precede tout comportement observe, de sorte qu’aucun seuil ne peut etre choisi apres coup pour flatter un resultat.

0. Pré-enregistrement du protocole (gelé avant tout appel LLM)

Rôles, conditions, modèles, paramètres, budgets, hypothèses et seuils sont figés ici. Toute déviation exécution vs gel sera consignée comme violation de protocole (§8).

# Adaptateur minimal : Othman-Sandholm (SAGT 2009) Proposition 6, tel que livre
# par GameTheory-16-MechanismDesign.ipynb §4.6 (owner). Aucun second moteur :
# tables, carte et oracle sont recopies tels quels puis verifies par asserts.

u_row = {  # u_row[outcome][type_row]
    "o1": {"a": 1, "a'": 3}, "o2": {"a": 4, "a'": 5},
    "o3": {"a": 0, "a'": 0}, "o4": {"a": 3, "a'": 0},
}
u_col = {  # u_col[outcome][type_col]
    "o1": {"a": 1, "a'": 4}, "o2": {"a": 0, "a'": 0},
    "o3": {"a": 3, "a'": 6}, "o4": {"a": 0, "a'": 0},
}
outcomes = ["o1", "o2", "o3", "o4"]

# Carte du mecanisme M (owner §4.6.1, matrice rapport -> outcome)
M_MAP = {
    ("a'", "a'"): "o1", ("a'", "a"): "o2",
    ("a", "a'"): "o3", ("a", "a"): "o4",
}

def mecanisme(r_row, r_col):
    return M_MAP[(r_row, r_col)]

def sw_oracle(outcome, theta_row, theta_col):
    """Bien-etre social : u_row(o|theta_row) + u_col(o|theta_col) (owner §4.6.2)."""
    return u_row[outcome][theta_row] + u_col[outcome][theta_col]

def payoff(role, outcome, theta):
    return (u_row if role == "row" else u_col)[outcome][theta]

def sw_optimal(theta_row, theta_col):
    return max(sw_oracle(o, theta_row, theta_col) for o in outcomes)

# --- Asserts de non-divergence contre les chiffres publies par le owner ---
# Owner §4.6.3 (Interpretation, Caracteristique 2) :
assert sw_oracle("o1", "a'", "a'") == 7                                  # (a',a') : o1 optimal
assert sw_oracle("o2", "a", "a") == 4 and sw_oracle("o1", "a", "a") == 2     # (a,a) : o2 > o1
assert sw_oracle("o3", "a", "a'") == 6 and sw_oracle("o1", "a", "a'") == 5   # (a,a') : o3 > o1
assert sw_oracle("o2", "a'", "a") == 5 and sw_oracle("o1", "a'", "a") == 4   # (a',a) : o2 > o1
# Observation cle du owner (§4.6.1) : rapporter a' est strictement dominant
# comme ACTION pour les DEUX types des DEUX agents, quel que soit le rapport adverse.
for agent_u, role in ((u_row, "row"), (u_col, "col")):
    for t_vrai in ("a", "a'"):
        for r_adv in ("a", "a'"):
            if role == "row":
                o_a_prime, o_a = mecanisme("a'", r_adv), mecanisme("a", r_adv)
            else:
                o_a_prime, o_a = mecanisme(r_adv, "a'"), mecanisme(r_adv, "a")
            assert agent_u[o_a_prime][t_vrai] > agent_u[o_a][t_vrai], (role, t_vrai, r_adv)

print("Adaptateur non divergent : 5 asserts SW (chiffres du owner) + 8 asserts de dominance stricte de a'.")
print("SW optimal par profil vrai :", {f"({tr},{tc})": sw_optimal(tr, tc)
                                        for tr, tc in PREREG["profils_vrais"]})
Adaptateur non divergent : 5 asserts SW (chiffres du owner) + 8 asserts de dominance stricte de a'.
SW optimal par profil vrai : {'(a,a)': 4, "(a,a')": 6, "(a',a)": 5, "(a',a')": 7}

Lecture chiffree — la non-divergence outillee, pas affirmee. Adaptateur non divergent : 5 asserts SW (chiffres du owner) + 8 asserts de dominance stricte de a'. Suivi du dictionnaire SW optimal par profil vrai : {'(a,a)': 4, "(a,a')": 6, "(a',a)": 5, "(a',a')": 7}. Treize asserts font le travail d’acceptance : les 5 premiers confrontent le bien-etre social optimal calcule ici aux chiffres publies par le notebook owner, les 8 suivants verifient la dominance stricte de a’ case par case. Ce dictionnaire servira de contre-factuel a tout le pilote : chaque ligne de trace du §5 porte un sw_oracle_optimal lu dans cette table — 7 au profil (a', a'), 4 au profil (a, a). Noter l’asymetrie 6 contre 5 entre (a, a') et (a', a) : l’ordre des rapports change le welfare optimal, et le mecanisme y sera sensible.

1. Le mécanisme importé — adaptateur minimal depuis GameTheory-16 §4.6

Reproduction à l’identique de la construction du notebook owner (§4.6) : tables d’utilités, carte du mécanisme, oracle de bien-être social. Des asserts de non-divergence confrontent l’adaptateur aux chiffres publiés par le owner — une correspondance de nom ne suffirait pas (acceptance #15399).

# Mise en boite (revelation) : le owner « boxe » M en M_1 qui choisit systematiquement o1.
# Deux concepts de dominance DISTINCTS, a ne jamais confondre :
#   (i)  dominance d'ACTION (le M papier, cell ci-dessus) : rapporter a' domine, quel que soit le type ;
#   (ii) dominance du RAPPORT-DE-TYPE : rapporter son VRAI type domine (mise en boite).
# Recherche exhaustive sur les 4^4 = 256 cartes : lesquelles rendent le rapport du
# vrai type faiblement dominant ? (question (ii), plus forte que (i))
import itertools

RAPPORTS = [("a", "a"), ("a", "a'"), ("a'", "a"), ("a'", "a'")]

def rapport_type_dominant(carte):
    """Rapporter son VRAI type est faiblement dominant pour les 2 agents."""
    for agent_u, role in ((u_row, "row"), (u_col, "col")):
        for t_vrai in ("a", "a'"):
            for r_adv in ("a", "a'"):
                utils = {}
                for r_moi in ("a", "a'"):
                    if role == "row":
                        rr, rc = r_moi, r_adv
                    else:
                        rr, rc = r_adv, r_moi
                    utils[r_moi] = agent_u[carte[(rr, rc)]][t_vrai]
                if utils[t_vrai] < max(utils.values()):
                    return False
    return True

cartes_admissibles = []
for combo in itertools.product(outcomes, repeat=4):
    carte = dict(zip(RAPPORTS, combo))
    if carte[("a'", "a'")] == "o1" and rapport_type_dominant(carte):
        cartes_admissibles.append(carte)

print(f"Cartes a rapport-de-type-dominant (avec box (a',a')->o1) : {len(cartes_admissibles)} / 64")
for c in cartes_admissibles:
    print("  ", c)
print("Carte papier M :", M_MAP)
print("M est rapport-de-type-dominante :", rapport_type_dominant(M_MAP),
      "(NON attendu : M est dominante en ACTION, pas en rapport-de-type)")
constante_o1 = any(all(v == "o1" for v in c.values()) for c in cartes_admissibles)
print("La carte constante o1 (M_1 boxed du owner) est admissible :", constante_o1,
      "(c'est la mise en boite canonique du owner §4.6.2 : mom_outcome = 'o1')")
Cartes a rapport-de-type-dominant (avec box (a',a')->o1) : 2 / 64
   {('a', 'a'): 'o1', ('a', "a'"): 'o1', ("a'", 'a'): 'o1', ("a'", "a'"): 'o1'}
   {('a', 'a'): 'o4', ('a', "a'"): 'o4', ("a'", 'a'): 'o1', ("a'", "a'"): 'o1'}
Carte papier M : {("a'", "a'"): 'o1', ("a'", 'a'): 'o2', ('a', "a'"): 'o3', ('a', 'a'): 'o4'}
M est rapport-de-type-dominante : False (NON attendu : M est dominante en ACTION, pas en rapport-de-type)
La carte constante o1 (M_1 boxed du owner) est admissible : True (c'est la mise en boite canonique du owner §4.6.2 : mom_outcome = 'o1')

Lecture chiffree — deux cartes sur soixante-quatre, et M n’y est pas. Le balayage affiche Cartes a rapport-de-type-dominant (avec box (a',a')->o1) : 2 / 64 puis exhibe les deux candidates — dont la carte constante o1. Le verdict central suit : M est rapport-de-type-dominante : False (NON attendu : M est dominante en ACTION, pas en rapport-de-type), aussitot complete par La carte constante o1 (M_1 boxed du owner) est admissible : True. La nuance porte tout le §1 : dominance en action et dominance en rapport-de-type sont deux proprietes distinctes, et seule la mise en boite du owner (mom_outcome = 'o1') reconcilie la construction avec l’exigence. C’est cette carte o1 que les joueurs LLM affronteront : quand les deux rapportent a’, l’issue est o1.

1.1 Portée de la Proposition 6 formalisée (Lean) vs résultats empiriques de ce pilote

La Proposition 6 existe aussi en Lean (game_theory_lean/SocialChoice/MechanismDesign.lean, #12343). Le théorème formel garantit des propriétés mathématiques de la construction (dominance stricte, caractéristique 2) — un énoncé formel, pas une prédiction comportementale. Ce pilote mesure le comportement de joueurs LLM sur la même construction ; ses résultats (§6–§8) sont empiriques et ne démontrent, réfutent ni n’étendent jamais le théorème Lean. Aucun sous-grain Lean n’est créé ici.

Borne négative explicite : la caractéristique 1 (k=1) de la Proposition 6 n’est pas formalisée en Lean — la portée de #12343 se borne à la dominance stricte en caractéristique 2. Ce pilote ne qualifie pas la Proposition 6 entière de formalisée et n’étend pas cette portée.

# Lecture de l'enonce Lean de la Proposition 6 (portee formelle, citee telle quelle).
# Le kernel peut demarrer dans le dossier du notebook : on remonte jusqu'a la racine repo.
from pathlib import Path

REL = "MyIA.AI.Notebooks/GameTheory/game_theory_lean/SocialChoice/MechanismDesign.lean"
candidats = [Path(REL)] + [p / REL for p in Path.cwd().parents]
lean_path = next((c for c in candidats if c.exists()), None)
assert lean_path, f"{REL} introuvable -- verifier le chemin sur main"
texte = lean_path.read_text(encoding="utf-8")
idx = texte.lower().find("proposition 6")
assert idx >= 0, "Proposition 6 absente du fichier Lean -- distinction impossible"
print(texte[max(0, idx - 250): idx + 1000])
print("\n[Distinction] Enonce formel ci-dessus (existence/proprietes du mecanisme) vs mesures")
print("comportementales LLM de ce pilote : objets de nature differente, jamais confondus.")
e gagnant complémentaire, est déplacé et les deux bidders
    singletons ne paient chacun qu'une externalité de 2. -/
theorem vcg_revenue_non_monotone : revenue3 < revenue2 := by decide

end VCGCombinatorial

/-! ## Manipulation-optimal mechanisms : Proposition 6 d'Othman-Sandholm (SAGT 2009) -/

namespace OthmanSandholmProp6

/- Construction de la Proposition 6 (« Better with Byzantine », SAGT 2009, page 8) :
   il existe des strict MOM multi-agents avec objectif de bien-être social.

   Deux agents (row, column), deux types chacun (a, a'), quatre issues o1..o4.
   Le mécanisme mappe le profil de rapports (row, column) vers :
     (a', a') -> o1 ; (a', a) -> o2 ; (a, a') -> o3 ; (a, a) -> o4.
   (Dans le papier, row = ligne, column = colonne.)

   Matrices de gains de la page 8, recopiées littéralement — gains du type a
   à gauche, du type a' à droite (chaque cellule = (row, column)) :

     Rapport   a'      a            Rapport   a'      a
     a'      1,1     4,0            a'      3,4     5,0
     a       0,3     3,0            a       0,6     0,0
            type a                      type a'

   Vue par issue (page 9 du papier, urow puis ucolumn pour chaque type) :

     Issue   u_row(a)  u_row(a')  u_col(a)  u_col(a')
  

[Distinction] Enonce formel ci-dessus (existence/proprietes du mecanisme) vs mesures
comportementales LLM de ce pilote : objets de nature differente, jamais confondus.

Lecture de l’extrait — ce que le fichier Lean contient materiellement. La sortie cite le source tel quel : on y voit d’abord la fin du module precedent (vcg_revenue_non_monotone : revenue3 < revenue2 := by decide), puis l’ouverture namespace OthmanSandholmProp6 et la construction de la Proposition 6 — deux agents (row, column), deux types chacun (a, a’), le mecanisme (a', a') -> o1 ; (a', a) -> o2 ; (a, a') -> o3 ; (a, a) -> o4. Les matrices de la page 8 du papier sont recopiees litteralement, chaque cellule valant (row, column) : pour le type a, (a',a') = 1,1, (a',a) = 4,0, (a,a') = 0,3, (a,a) = 3,0 ; pour le type a’, 3,4, 5,0, 0,6, 0,0 aux memes positions. La lecture a retenir : ce que ce pilote importe via l’adaptateur du §1 est exactement l’objet formalise la — meme mecanisme, memes issues — et la section §1.1 ci-dessus borne soigneusement ce que le theoreme garantit par rapport a ce que le pilote mesure.

2. Schéma observation/action provider-neutral

L’observation est un dictionnaire JSON sérialisable, identique pour toutes les familles ; l’action est un JSON strict {"report": "a"} ou {"report": "a'"}. Aucune API LLM n’apparaît dans le schéma : les familles (§4) ne diffèrent que par le modèle interrogé.

import json

def observation(role, type_vrai, regles_visibles, permutation=False):
    """Observation provider-neutral. `permutation` (§7) echange la regle presentee
    comme dominante SANS toucher au mecanisme reel -- le controle hostile."""
    obs = {
        "role": role,
        "votre_type": type_vrai,
        "espace_actions": ["a", "a'"],
        "format_attendu": 'repondre STRICTEMENT en JSON : {"report": "a"} ou {"report": "a\'"}',
    }
    if regles_visibles:
        dominante_annoncee = "a" if permutation else "a'"
        obs["regles"] = {
            "mecanisme": "2 agents rapportent a ou a' ; l'outcome o1..o4 suit la matrice "
                         "(a',a')->o1, (a',a)->o2, (a,a')->o3, (a,a)->o4",
            "utilites": "les votres dependent de l'outcome ET de votre type (tableau fourni)",
            "tableau_utilites": (u_row if role == "row" else u_col),
            "regle_annoncee": f"l'action dominante est {dominante_annoncee} (rapportez {dominante_annoncee} "
                              "quels que soient votre type et le rapport adverse)",
        }
    return obs

ACTIONS_VALIDES = ("a", "a'")

def valider_action(brut):
    """Parse+valide strictement. Retourne (action, statut) avec statut dans
    'valide' | 'invalide' -- conforme a PREREG['politique_refus']."""
    if brut is None:
        return None, "invalide"
    try:
        d = json.loads(brut)
    except (json.JSONDecodeError, TypeError):
        return None, "invalide"
    if not isinstance(d, dict) or d.get("report") not in ACTIONS_VALIDES:
        return None, "invalide"
    return d["report"], "valide"

# Auto-test du schema (deterministe, sans LLM)
assert valider_action(json.dumps({"report": "a'"})) == ("a'", "valide")
assert valider_action(json.dumps({"report": "b"})) == (None, "invalide")
assert valider_action("je rapporte a") == (None, "invalide")
assert valider_action(None) == (None, "invalide")
print("Schema obs/action OK : validation stricte + 4 auto-tests.")
Schema obs/action OK : validation stricte + 4 auto-tests.

Lecture de la sortie — le verrou de validite s’installe. Schema obs/action OK : validation stricte + 4 auto-tests. La ligne est courte, sa consequence traverse tout le notebook : une action n’est acceptable que si elle est exactement {"report": "a"} ou {"report": "a'"} — tout autre contenu (prose, JSON detourne, champ en trop) est enregistre invalide apres un seul retry, jamais re-echantillonne. C’est ce verrou qui produira au §6 la ligne glm visible avec invalide = 0.688 : un echec de format, pas un choix de jeu. Aucune API LLM n’apparait dans le schema — les familles du §4 ne differeront que par le modele interroge, l’observation restant identique pour toutes.

3. Politiques scriptées (contrôles)

Trois politiques sans LLM : l’équilibre (toujours a', l’action dominante), le byzantin (toujours a), et un aléatoire seedé servant de plancher de référence. Mêmes instances que les LLM (mêmes 4 profils vrais).

import random

def policy_equilibre(role, type_vrai, obs, rng):
    return "a'"

def policy_byzantine(role, type_vrai, obs, rng):
    return "a"

def policy_aleatoire(role, type_vrai, obs, rng):
    return rng.choice(ACTIONS_VALIDES)

SCRIPTED = {"scripted-equilibre": policy_equilibre,
            "scripted-byzantin": policy_byzantine,
            "scripted-aleatoire": policy_aleatoire}

# Verification deterministe des controles sur les 4 profils
rng0 = random.Random(0)
for pol in (policy_equilibre, policy_byzantine):
    acts = {pol(r, t, {}, rng0) for r in ("row", "col") for t in ("a", "a'")}
    assert len(acts) == 1, acts
print("Controles scriptes OK : equilibre=a' (dominante), byzantin=a, aleatoire seede uniforme.")
Controles scriptes OK : equilibre=a' (dominante), byzantin=a, aleatoire seede uniforme.

Lecture de la sortie — trois jalons avant tout LLM. Controles scriptes OK : equilibre=a' (dominante), byzantin=a, aleatoire seede uniforme. Ces trois politiques sans LLM fixent les bornes de lecture du tableau de resultats : le plafond (l’equilibre rapporte toujours a’, taux attendu 1,000), le plancher (le byzantin rapporte toujours a, taux attendu 0,000) et le repere intermediaire (l’aleatoire seede, autour de 0,500). Le §6 les mesurera sur leurs tours reels — n=8 pour chacune des politiques deterministes, n=32 pour l’aleatoire — avec leurs intervalles de Wilson : tout taux LLM se lira ensuite entre ces jalons, jamais en valeur absolue isolee.

4. Familles LLM réelles — appels via models.myia.io

Deux familles (gel §0) : GLM (glm-5.2, modèle raisonnant) et Qwen (qwen3.8-max). La clé vient de l’environnement (CLAUDISH_PROXY_KEY) — jamais inline, jamais dans les traces (seuls tokens et latences sont conservés). Un refus ou un JSON invalide après un retry est enregistré invalide (politique gelée), jamais ré-échantillonné.

import os
import time
import requests

API_BASE = "https://models.myia.io/v1/chat/completions"
API_KEY = os.getenv("CLAUDISH_PROXY_KEY")
assert API_KEY, "CLAUDISH_PROXY_KEY absente de l'environnement (regle F : la cle vie dans l'env, pas ici)"

PROMPT_SYSTEM = (
    "Tu es un agent rationnel dans une experience de theorie des mecanismes. "
    "Reponds STRICTEMENT en JSON, sans aucun autre texte."
)

def prompt_utilisateur(obs):
    if "regles" in obs:
        return (
            f"Role : {obs['role']}. Ton type (prive) : {obs['votre_type']}\n"
            f"Espace d'actions : {obs['espace_actions']}\n"
            f"Regles du mecanisme : {json.dumps(obs['regles'], ensure_ascii=False)}\n"
            f"Objectif : maximiser ton utilite (elle depend de l'outcome ET de ton type). "
            f"{obs['format_attendu']}"
        )
    return (
        f"Role : {obs['role']}. Ton type (prive) : {obs['votre_type']}\n"
        f"Espace d'actions : {obs['espace_actions']}\n"
        f"Aucune regle ne t'est communiquée. Choisis une action. {obs['format_attendu']}"
    )

def appel_llm(famille, modele, obs, seed):
    """1 appel + 1 retry si parse invalide (gel) + 2 retries reseau (gel).
    Retourne (action, statut, tokens_total, latence_s) avec statut dans
    'valide' | 'invalide' | 'erreur_reseau'."""
    entetes = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
    tokens_total, latence_totale = 0, 0.0
    for essai in range(1 + PREREG["max_retry_parse"]):
        corps = {
            "model": modele,
            "temperature": PREREG["temperature"],
            "max_tokens": PREREG["max_tokens"],
            "seed": seed,
            "messages": [
                {"role": "system", "content": PROMPT_SYSTEM},
                {"role": "user", "content": prompt_utilisateur(obs)},
            ],
        }
        rep, err_reseau = None, None
        for essai_net, backoff in ((0, 0), (1, 5), (2, 15)):  # politique reseau gelee
            if backoff:
                time.sleep(backoff)
            t0 = time.perf_counter()
            try:
                rep = requests.post(API_BASE, headers=entetes, json=corps,
                                    timeout=PREREG["timeout_requete_s"])
                err_reseau = None
            except requests.RequestException as e:
                err_reseau = e
            latence_totale += time.perf_counter() - t0
            if rep is not None:
                break
        if rep is None:
            continue  # epuise les retries reseau -> on tente l'essai parse suivant
        if rep.status_code >= 500 or rep.status_code == 429:
            err_reseau = f"HTTP {rep.status_code}"
            continue
        rep.raise_for_status()
        err_reseau = None  # reponse exploitable obtenue : plus d'erreur reseau
        data = rep.json()
        usage = data.get("usage", {}) or {}
        tokens_total += usage.get("total_tokens", 0) or 0
        brut = data["choices"][0]["message"]["content"]
        action, statut = valider_action(brut)
        if statut == "valide":
            return action, "valide", tokens_total, latence_totale
        if essai == 0:  # reformulation unique (gel)
            obs = dict(obs, format_attendu='REPRENDS : UNIQUEMENT le JSON {"report": "a"} ou {"report": "a\'"}')
    return None, ("erreur_reseau" if err_reseau else "invalide"), tokens_total, latence_totale

print("Familles configurees :", PREREG["familles"])
Familles configurees : {'glm': 'glm-5.2', 'qwen': 'qwen3.8-max'}

Lecture de la sortie — le gel devient execution. Familles configurees : {'glm': 'glm-5.2', 'qwen': 'qwen3.8-max'} — les deux chaines sont exactement celles du gel du §0, et c’est tout ce que la sortie expose : aucun endpoint, aucune cle, rien de la configuration reseau. L’heterogeneite choisie est l’objet meme du pilote : une famille raisonnante et une autre, confrontees au meme mecanisme et au meme schema d’observation. La cle d’acces vit dans l’environnement (CLAUDISH_PROXY_KEY), jamais dans les traces — seuls tokens et latences seront conserves par tour.

5. Le pilote — exécution par condition × profil × seed × agent

Boucle gelée : chaque action (scriptée ou LLM) passe par le mécanisme puis est rejouée par l’oracle : outcome, payoffs au type vrai, SW réalisé et SW optimal (contre-factuel déterministe). Si une action LLM est invalide après retry, le tour est marqué — l’outcome n’est pas substitué : aucune action n’est inventée. Les données vivent dans traces (une ligne par tour) — jamais de moyenne globale seule (§6).

PROFIL_IDX = {t: i for i, t in enumerate(PREREG["types"])}

def jouer_tour(condition, famille, modele, theta_row, theta_col, seed):
    """Un profil x un seed : les 2 agents agissent, le mecanisme tranche, l'oracle rejoue."""
    rng = random.Random(seed * 1000 + PROFIL_IDX[theta_row] * 2 + PROFIL_IDX[theta_col])
    actions, statuts = {}, {}
    tokens, latence = 0, 0
    for role, theta in (("row", theta_row), ("col", theta_col)):
        if condition in SCRIPTED:
            obs = observation(role, theta, regles_visibles=False)
            actions[role] = SCRIPTED[condition](role, theta, obs, rng)
            statuts[role] = "scripte"
        else:
            visible = condition in ("llm-regles-visibles", "llm-permutation")
            perm = condition == "llm-permutation"
            obs = observation(role, theta, regles_visibles=visible, permutation=perm)
            act, st, tk, lat = appel_llm(famille, modele, obs, seed)
            actions[role], statuts[role] = act, st
            tokens += tk
            latence += lat
    if actions["row"] in ACTIONS_VALIDES and actions["col"] in ACTIONS_VALIDES:
        o = mecanisme(actions["row"], actions["col"])
        ligne = {
            "outcome": o,
            "payoff_row": payoff("row", o, theta_row),
            "payoff_col": payoff("col", o, theta_col),
            "sw_realise": sw_oracle(o, theta_row, theta_col),
        }
    else:  # action invalide : aucune substitution, tour marque
        ligne = {"outcome": None, "payoff_row": None, "payoff_col": None, "sw_realise": None}
    return {
        "condition": condition, "famille": famille, "seed": seed,
        "theta_row": theta_row, "theta_col": theta_col,
        "action_row": actions["row"], "action_col": actions["col"],
        "statuts": statuts,
        "sw_oracle_optimal": sw_optimal(theta_row, theta_col),
        "tokens": tokens, "latence_s": latence,
        **ligne,
    }

traces = []
n_appels = 0

# Controles scriptes (aucun appel LLM) : deterministes a seed unique,
# aleatoire avec les 3 seeds du gel pour la variance.
for condition in ("scripted-equilibre", "scripted-byzantin"):
    for (tr, tc) in PREREG["profils_vrais"]:
        traces.append(jouer_tour(condition, "scripte", "-", tr, tc, seed=0))
for seed in PREREG["seeds"]:
    for (tr, tc) in PREREG["profils_vrais"]:
        traces.append(jouer_tour("scripted-aleatoire", "scripte", "-", tr, tc, seed))

# Conditions LLM principales (permutation reportee au §7 avec ses propres seeds).
# Appels independants -> execution parallele (8 workers) ; chaque appel garde
# son propre seed et l'ordre des traces suit l'ordre des taches (map ordonne).
from concurrent.futures import ThreadPoolExecutor

taches_llm = [(condition, famille, modele, tr, tc, seed)
              for condition in ("llm-regles-visibles", "llm-aveugle")
              for famille, modele in PREREG["familles"].items()
              for seed in PREREG["seeds"]
              for (tr, tc) in PREREG["profils_vrais"]]
with ThreadPoolExecutor(max_workers=PREREG["concurrence"]) as pool:
    traces.extend(pool.map(lambda t: jouer_tour(*t), taches_llm))
n_appels += 2 * len(taches_llm)

print(f"{len(traces)} tours ; appels LLM consommes : {n_appels} / budget {PREREG['budget_appels_max']}")
print("Echantillon (dernier tour LLM) :", traces[-1])
88 tours ; appels LLM consommes : 128 / budget 96
Echantillon (dernier tour LLM) : {'condition': 'llm-aveugle', 'famille': 'qwen', 'seed': 97, 'theta_row': "a'", 'theta_col': "a'", 'action_row': "a'", 'action_col': "a'", 'statuts': {'row': 'valide', 'col': 'valide'}, 'sw_oracle_optimal': 7, 'tokens': 238, 'latence_s': 2.461665600003471, 'outcome': 'o1', 'payoff_row': 3, 'payoff_col': 4, 'sw_realise': 7}

Lecture chiffree — la grammaire d’une ligne de trace. La sortie resume le run (88 tours ; appels LLM consommes : 128 / budget 96 — l’ecart au budget declare sera consigne en fin de notebook comme ecart d’execution) puis montre le dernier tour LLM, champ par champ : condition llm-aveugle, famille qwen, seed 97, theta_row "a'", theta_col "a'", action_row "a'", action_col "a'", statuts valide/valide, sw_oracle_optimal 7, tokens 238, latence_s 2.46, outcome o1, payoff_row 3, payoff_col 4, sw_realise 7. Deux lectures y sont visibles d’un coup : a l’aveugle, qwen rapporte son type propre (a’ ici, deux fois) ; et ce tour atteint le welfare optimal (sw_realise egal sw_oracle_optimal, le 7 de la table du §1 au profil (a', a')). Chaque ligne du tableau traces parle cette langue.

6. Résultats multi-seed

Par type de joueur — jamais en moyenne globale seule. Revenu du mécanisme : N/A — la Proposition 6 n’expose aucun transfert ni paiement distinct (aucune recette) ; seuls la richesse sociale réalisée (sw_realise) et les payoffs par rôle sont mesurés.

import math
import statistics

STATUTS_ACTES = ("valide", "scripte")  # les actes comptabilises (invalide exclu)

def compter(cond_rows):
    """(k, n) : actes a' / actes comptabilises (formule gelee : Wilson 95 %)."""
    actes = ([r["action_row"] for r in cond_rows if r["statuts"]["row"] in STATUTS_ACTES]
             + [r["action_col"] for r in cond_rows if r["statuts"]["col"] in STATUTS_ACTES])
    return sum(1 for x in actes if x == "a'"), len(actes)

def wilson(k, n, z=1.96):
    """Intervalle de Wilson 95 % sur une proportion (analyse gelee au §0)."""
    if n == 0:
        return (float("nan"), float("nan"))
    p = k / n
    denom = 1 + z * z / n
    centre = (p + z * z / (2 * n)) / denom
    demi = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / denom
    return (max(0.0, centre - demi), min(1.0, centre + demi))

def cohen_h(k1, n1, k2, n2):
    """Taille d'effet h de Cohen (difference d'arcsinus), gelee au §0.
    Conventions : ~0.2 petit, ~0.5 moyen, ~0.8 grand."""
    if n1 == 0 or n2 == 0:
        return float("nan")
    phi = lambda k, n: 2 * math.asin(math.sqrt(k / n))
    return phi(k1, n1) - phi(k2, n2)

def taux_dominant(cond_rows):
    k, n = compter(cond_rows)
    return (k / n) if n else float("nan")

def taux_invalide(cond_rows):
    """Taux de reponses inanalysables du MODELE (JSON invalide apres retry)."""
    st = [r["statuts"]["row"] for r in cond_rows] + [r["statuts"]["col"] for r in cond_rows]
    return sum(1 for x in st if x == "invalide") / len(st)

def taux_reseau(cond_rows):
    """Taux d'echec INFRASTRUCTURE (erreur_reseau) -- rapporte separement,
    jamais confondu avec le comportement du modele (gel §0)."""
    st = [r["statuts"]["row"] for r in cond_rows] + [r["statuts"]["col"] for r in cond_rows]
    return sum(1 for x in st if x == "erreur_reseau") / len(st)

def resume_par_type(famille, condition):
    """Decomposition PAR TYPE du joueur : chaque acte est attribue au type VRAI
    de l'agent qui l'a pose (pas au profil entier)."""
    lignes = []
    for t in PREREG["types"]:
        actes_vrais, sws = [], []
        for r in traces:
            if r["famille"] != famille or r["condition"] != condition:
                continue
            for role, th in (("row", r["theta_row"]), ("col", r["theta_col"])):
                if th == t and r["statuts"][role] in STATUTS_ACTES:
                    actes_vrais.append(r[f"action_{role}"])
            if r["sw_realise"] is not None and t in (r["theta_row"], r["theta_col"]):
                sws.append(r["sw_realise"] / r["sw_oracle_optimal"])
        taux = (sum(1 for x in actes_vrais if x == "a'") / len(actes_vrais)) if actes_vrais else float("nan")
        lignes.append({
            "famille": famille, "condition": condition, "type": t,
            "n_actes": len(actes_vrais),
            "taux_a_prime": round(taux, 3),
            "sw_ratio_moyen": round(statistics.mean(sws), 3) if sws else None,
        })
    return lignes

print("=== Controles scriptes (bornes de lecture) ===")
for condition in ("scripted-equilibre", "scripted-byzantin", "scripted-aleatoire"):
    rows = [r for r in traces if r["condition"] == condition]
    k, n = compter(rows)
    lo, hi = wilson(k, n)
    print(f"  {condition:20s} : taux a' = {k/n if n else float('nan'):.3f} "
          f"[{lo:.3f}, {hi:.3f}] (n={n})  (attendu : 1.000 / 0.000 / ~0.500)")
print("\n=== Taux d'action dominante (a') par condition et famille LLM, Wilson 95 % ===")
for condition in ("llm-regles-visibles", "llm-aveugle"):
    for famille in PREREG["familles"]:
        rows = [r for r in traces if r["famille"] == famille and r["condition"] == condition]
        k, n = compter(rows)
        lo, hi = wilson(k, n)
        print(f"  {condition:20s} {famille:5s} : taux a' = {k/n if n else float('nan'):.3f} "
              f"[{lo:.3f}, {hi:.3f}] (n={n}), invalide = {taux_invalide(rows):.3f}, "
              f"reseau = {taux_reseau(rows):.3f}, n_tours = {len(rows)}")
print("\n=== Decomposition PAR TYPE (famille x condition LLM) ===")
for famille in PREREG["familles"]:
    for condition in ("llm-regles-visibles", "llm-aveugle"):
        for ligne in resume_par_type(famille, condition):
            print("  ", ligne)
=== Controles scriptes (bornes de lecture) ===
  scripted-equilibre   : taux a' = 1.000 [0.676, 1.000] (n=8)  (attendu : 1.000 / 0.000 / ~0.500)
  scripted-byzantin    : taux a' = 0.000 [0.000, 0.324] (n=8)  (attendu : 1.000 / 0.000 / ~0.500)
  scripted-aleatoire   : taux a' = 0.594 [0.423, 0.745] (n=32)  (attendu : 1.000 / 0.000 / ~0.500)

=== Taux d'action dominante (a') par condition et famille LLM, Wilson 95 % ===
  llm-regles-visibles  glm   : taux a' = 1.000 [0.722, 1.000] (n=10), invalide = 0.688, reseau = 0.000, n_tours = 16
  llm-regles-visibles  qwen  : taux a' = 1.000 [0.893, 1.000] (n=32), invalide = 0.000, reseau = 0.000, n_tours = 16
  llm-aveugle          glm   : taux a' = 0.500 [0.336, 0.664] (n=32), invalide = 0.000, reseau = 0.000, n_tours = 16
  llm-aveugle          qwen  : taux a' = 0.500 [0.336, 0.664] (n=32), invalide = 0.000, reseau = 0.000, n_tours = 16

=== Decomposition PAR TYPE (famille x condition LLM) ===
   {'famille': 'glm', 'condition': 'llm-regles-visibles', 'type': 'a', 'n_actes': 2, 'taux_a_prime': 1.0, 'sw_ratio_moyen': None}
   {'famille': 'glm', 'condition': 'llm-regles-visibles', 'type': "a'", 'n_actes': 8, 'taux_a_prime': 1.0, 'sw_ratio_moyen': None}
   {'famille': 'glm', 'condition': 'llm-aveugle', 'type': 'a', 'n_actes': 16, 'taux_a_prime': 0.0, 'sw_ratio_moyen': 0.917}
   {'famille': 'glm', 'condition': 'llm-aveugle', 'type': "a'", 'n_actes': 16, 'taux_a_prime': 1.0, 'sw_ratio_moyen': 1.0}
   {'famille': 'qwen', 'condition': 'llm-regles-visibles', 'type': 'a', 'n_actes': 16, 'taux_a_prime': 1.0, 'sw_ratio_moyen': 0.711}
   {'famille': 'qwen', 'condition': 'llm-regles-visibles', 'type': "a'", 'n_actes': 16, 'taux_a_prime': 1.0, 'sw_ratio_moyen': 0.878}
   {'famille': 'qwen', 'condition': 'llm-aveugle', 'type': 'a', 'n_actes': 16, 'taux_a_prime': 0.0, 'sw_ratio_moyen': 0.917}
   {'famille': 'qwen', 'condition': 'llm-aveugle', 'type': "a'", 'n_actes': 16, 'taux_a_prime': 1.0, 'sw_ratio_moyen': 1.0}

Lecture chiffree — bornes, taux et decomposition par type. Les controles scriptes encadrent d’abord : scripted-equilibre : taux a' = 1.000 [0.676, 1.000] (n=8), scripted-byzantin : taux a' = 0.000 [0.000, 0.324] (n=8), scripted-aleatoire : taux a' = 0.594 [0.423, 0.745] (n=32) — l’uniforme seede tombe a 0,594, dans sa fourchette. Cote LLM sous regles visibles : glm 1.000 [0.722, 1.000] (n=10) avec invalide = 0.688, qwen 1.000 [0.893, 1.000] (n=32) avec invalide = 0.000 — la largeur d’intervalle parle d’elle-meme (borne basse 0,722 sur 10 actes contre 0,893 sur 32). En aveugle, les deux familles affichent 0.500 [0.336, 0.664] (n=32). La decomposition par type est la plus instructive : en aveugle, taux_a_prime = 0.0 pour le type a et 1.0 pour le type a’ (n_actes = 16 partout, les deux familles) avec sw_ratio_moyen a 0.917 et 1.0 — chaque agent rapporte son type propre, deterministe. Et le fait le plus net en visible : qwen type a rapporte a’ (taux_a_prime 1.0) avec un sw_ratio_moyen de 0.711 — suivre la regle dominante quand son type vrai est a coute au welfare.

6.1 Contrefactuel homogène par rôle (complément du préflight, point 6)

La déviation unilatérale homogène est mesurée sur les actions observées : pour chaque tour, le rapport adverse observé est fixé, seule l’action propre bascule (a ↔︎ a'), mecanisme() est rejoué — opération déterministe, zéro appel LLM supplémentaire. Gains et pertes sont publiés par rôle (row puis col), puis par type vrai. Cela complète les asserts statiques de dominance du §1 (propriété du mécanisme) par la mesure empirique du coût de déviation unilatéral par rôle.

def deviations_par_role(cond_rows):
    """Deviation unilaterale homogene : rapport adverse OBSERVE fixe, action propre
    basculee (a <-> a'), mecanisme() rejoue (deterministe, 0 appel LLM)."""
    out = {}
    for role, autre in (("row", "col"), ("col", "row")):
        gains = []
        for r in cond_rows:
            if r["outcome"] is None:
                continue
            if r["statuts"][role] not in STATUTS_ACTES or r["statuts"][autre] not in STATUTS_ACTES:
                continue
            jouee, adverse = r[f"action_{role}"], r[f"action_{autre}"]
            dev = "a" if jouee == "a'" else "a'"
            o_dev = mecanisme(dev, adverse) if role == "row" else mecanisme(adverse, dev)
            th = r["theta_row"] if role == "row" else r["theta_col"]
            gains.append(payoff(role, o_dev, th) - r[f"payoff_{role}"])
        out[role] = gains
    return out

print("=== 6.1 Contrefactuel homogene PAR ROLE : gain/perte de la deviation unilaterale ===")
for condition in ("scripted-equilibre", "scripted-byzantin", "scripted-aleatoire",
                  "llm-regles-visibles", "llm-aveugle"):
    for famille in (*list(PREREG["familles"]), "scripte"):
        rows = [r for r in traces if r["famille"] == famille and r["condition"] == condition]
        if not rows:
            continue
        dev = deviations_par_role(rows)
        gain_r = statistics.mean(dev["row"]) if dev["row"] else float("nan")
        gain_c = statistics.mean(dev["col"]) if dev["col"] else float("nan")
        print(f"  {condition:20s} {famille:5s} : row {gain_r:+.3f} (n={len(dev['row'])}) | "
              f"col {gain_c:+.3f} (n={len(dev['col'])})")

print()
print("=== ... PAR TYPE vrai (gain moyen de la deviation, toutes conditions principales) ===")
for t in PREREG["types"]:
    for role, autre in (("row", "col"), ("col", "row")):
        gains = []
        for r in traces:
            if r["outcome"] is None:
                continue
            th = r["theta_row"] if role == "row" else r["theta_col"]
            if th != t or r["statuts"][role] not in STATUTS_ACTES or r["statuts"][autre] not in STATUTS_ACTES:
                continue
            jouee, adverse = r[f"action_{role}"], r[f"action_{autre}"]
            dev = "a" if jouee == "a'" else "a'"
            o_dev = mecanisme(dev, adverse) if role == "row" else mecanisme(adverse, dev)
            gains.append(payoff(role, o_dev, th) - r[f"payoff_{role}"])
        if gains:
            print(f"  type={t} role={role:3s} : gain moyen {statistics.mean(gains):+.3f} (n={len(gains)})")

print()
print("Lecture : la deviation unilaterale depuis a' vers a (rapport adverse fixe) reduit le")
print("payoff de l'agent qui devie — traduction empirique, par role et par type, de la")
print("dominance stricte de a'.")
=== 6.1 Contrefactuel homogene PAR ROLE : gain/perte de la deviation unilaterale ===
  scripted-equilibre   scripte : row -2.000 (n=4) | col -2.500 (n=4)
  scripted-byzantin    scripte : row +3.000 (n=4) | col +4.500 (n=4)
  scripted-aleatoire   scripte : row -0.125 (n=16) | col +0.000 (n=16)
  llm-regles-visibles  glm   : row +nan (n=0) | col +nan (n=0)
  llm-regles-visibles  qwen  : row -2.000 (n=16) | col -2.500 (n=16)
  llm-aveugle          glm   : row -1.500 (n=16) | col -1.500 (n=16)
  llm-aveugle          qwen  : row -1.500 (n=16) | col -1.500 (n=16)

=== ... PAR TYPE vrai (gain moyen de la deviation, toutes conditions principales) ===
  type=a role=row : gain moyen +0.167 (n=36)
  type=a role=col : gain moyen +0.556 (n=36)
  type=a' role=row : gain moyen -2.333 (n=36)
  type=a' role=col : gain moyen -2.778 (n=36)

Lecture : la deviation unilaterale depuis a' vers a (rapport adverse fixe) reduit le
payoff de l'agent qui devie — traduction empirique, par role et par type, de la
dominance stricte de a'.

Lecture chiffree — le cout de la deviation, par role et par type. La bascule part de l’action observee (a’ pour l’equilibre, a pour le byzantin), rapport adverse fixe. La sortie aligne d’abord les conditions : scripted-equilibre : row -2.000 | col -2.500 (n=4), scripted-byzantin : row +3.000 | col +4.500 (n=4), scripted-aleatoire : row -0.125 | col +0.000 (n=16), llm-regles-visibles qwen : row -2.000 | col -2.500 (n=16), llm-aveugle les deux familles a -1.500 / -1.500 (n=16) — et glm visible a nan (n=0) : aucune action valide a basculer, la mesure est vide, pas nulle. Puis par type vrai, toutes conditions : type=a role=row : gain moyen +0.167 (n=36), type=a role=col : +0.556, contre type=a' role=row : -2.333 et type=a' role=col : -2.778. Le signe separe les deux populations, et le signe MOYEN du type a (+0,167) agrege des points de depart differents : en aveugle l’agent de type a part de a (basculer vers a’ rapporte), sous regles visibles il part deja de a’ (basculer vers a coute). Le byzantin reste le seul gagnant systematique (+3,000 / +4,500).

7. Scénario hostile — permutation d’une règle, coût neutralisé

La condition llm-permutation présente « a » comme action dominante alors que le mécanisme réel n’a pas changé (a’ reste dominante, oracle inchangé). Un joueur qui comprend la règle communiquée (sans la vérifier contre le tableau d’utilités) bascule vers a ; un joueur qui comprend le mécanisme ignore l’annonce. Le prompt permuté est de même longueur que l’original (coût neutralisé). L’avantage attendu des règles visibles (§6) doit disparaître ici si les LLM suivent l’annonce de surface.

taches_hostiles = [("llm-permutation", famille, modele, tr, tc, seed)
                   for famille, modele in PREREG["familles"].items()
                   for seed in PREREG["seeds_hostile"]
                   for (tr, tc) in PREREG["profils_vrais"]]
with ThreadPoolExecutor(max_workers=PREREG["concurrence"]) as pool:
    traces_hostiles = list(pool.map(lambda t: jouer_tour(*t), taches_hostiles))
traces.extend(traces_hostiles)
n_appels_h = 2 * len(taches_hostiles)

print(f"Hostile : {len(traces_hostiles)} tours, {n_appels_h} appels "
      f"(total LLM : {n_appels + n_appels_h} / {PREREG['budget_appels_max']})")
print("\n=== Taux d'action a' sous permutation (une comprehension de surface doit le faire chuter) ===")
for famille in PREREG["familles"]:
    rows = [r for r in traces_hostiles if r["famille"] == famille]
    print(f"  permutation {famille:5s} : taux a' = {taux_dominant(rows):.3f}, "
          f"invalide = {taux_invalide(rows):.3f}, reseau = {taux_reseau(rows):.3f}")
Hostile : 16 tours, 32 appels (total LLM : 160 / 96)

=== Taux d'action a' sous permutation (une comprehension de surface doit le faire chuter) ===
  permutation glm   : taux a' = nan, invalide = 1.000, reseau = 0.000
  permutation qwen  : taux a' = 0.750, invalide = 0.000, reseau = 0.000

Lecture chiffree — compteurs hostiles et la chute partielle. Hostile : 16 tours, 32 appels (total LLM : 160 / 96) — le total d’appels logiques du run complet s’arrete ici, le budget declare du gel n’est plus qu’un temoin (la section des ecarts d’execution y revient). Les lignes qui suivent : permutation glm : taux a' = nan, invalide = 1.000 — aucun acte evaluable, la mesure est vide — et permutation qwen : taux a' = 0.750. Ce 0,750 se lit par rapport a ses deux reperes : le visible 1,000 et l’aveugle 0,500. Ni obeissance totale a l’annonce permutee (le taux serait proche de 0), ni fidelite intacte a la regle apprise (1,000) : une chute de 0,250, au-dela du seuil du gel (0,20). Le §8 tranchera avec les tailles d’effet.

8. Verdict pré-enregistré vs résultats (H0/H1)

Les seuils sont lus dans le gel du §0 — jamais ajustés après exécution. Un verdict négatif (H0 non rejetée) est un résultat valide de ce pilote.

Précision d’interprétation (contre-préflight adjoint) : un taux marginal de 0,500 en condition aveugle ne démontre pas à lui seul une politique uniforme (les profils du gel équilibrent les marges par construction). La décomposition par type (§6) montre un comportement déterministe de rapport du type propre — taux a’ ≈ 0 pour le type a, ≈ 1 pour le type a’ — plutôt qu’un tirage uniforme conditionnel. Cette qualification s’applique à la condition aveugle quel que soit le verdict global du gel (rendu au §8 sur les marginales).

seuils = PREREG["seuils"]
verdicts = {}

def comptes(famille, condition, base=None):
    rows = [r for r in (base if base is not None else traces)
            if r["famille"] == famille and r["condition"] == condition]
    return compter(rows)

print("H0 :", PREREG["H0"])
print("H1 :", PREREG["H1"], "\n")
for famille in PREREG["familles"]:
    kv, nv = comptes(famille, "llm-regles-visibles")
    ka, na = comptes(famille, "llm-aveugle")
    kp, np_ = comptes(famille, "llm-permutation", base=traces_hostiles)
    rat = lambda k, n: (k / n) if n else float("nan")
    v, a, p = rat(kv, nv), rat(ka, na), rat(kp, np_)
    lov, hiv = wilson(kv, nv)
    h_va = cohen_h(kv, nv, ka, na)   # effet visible vs aveugle
    h_vp = cohen_h(kv, nv, kp, np_)  # effet visible vs permute
    # n=0 -> taux nan : seuil non evalue (absence de mesure), pas echoue (False bidon)
    mesure = lambda x: x == x  # False pour nan (compteur n==0)
    h1_visible = (v >= seuils["taux_dominant_visible_min"]) if mesure(v) else None
    h1_ecart = ((v - a) >= seuils["ecart_visible_vs_aveugle_min"]) if (mesure(v) and mesure(a)) else None
    h1_chute = ((v - p) >= seuils["chute_permutation_min"]) if (mesure(v) and mesure(p)) else None
    verdicts[famille] = {"visible": v, "aveugle": a, "permute": p,
                         "H1_visible": h1_visible, "H1_ecart": h1_ecart, "H1_chute": h1_chute}
    print(f"[{famille}] visible = {v:.3f} [{lov:.3f}, {hiv:.3f}] (n={nv}) | "
          f"aveugle = {a:.3f} (n={na}) | permute = {p:.3f} (n={np_})")
    print(f"    tailles d'effet (h de Cohen) : visible-vs-aveugle = {h_va:.2f} ; "
          f"visible-vs-permute = {h_vp:.2f}  (~0.2 petit, ~0.5 moyen, ~0.8 grand)")
    lbl = lambda b: "absence de mesure (non evalue)" if b is None else b
    print(f"    seuils du gel -> H1_visible={lbl(h1_visible)} H1_ecart={lbl(h1_ecart)} H1_chute={lbl(h1_chute)}")

absences = [fam for fam, d in verdicts.items()
            if any(d[k] is None for k in ("H1_visible", "H1_ecart", "H1_chute"))]
h1_globalement = all(d["H1_visible"] and d["H1_ecart"] and d["H1_chute"] for d in verdicts.values())
if absences:
    print("\nVERDICT : absence de mesure -- " + ", ".join(absences) +
          " sans acte evaluable sur au moins un seuil (n=0) : run non evalue, ni H0 ni H1")
elif h1_globalement:
    print("\nVERDICT : H1 retenue (chaque famille passe les 3 seuils du gel)")
else:
    print("\nVERDICT : H0 non rejetee (au moins une famille ou un seuil du gel echoue) -- resultat negatif valide")
tours_llm = [r for r in traces if r["tokens"] > 0]
tokens_total = sum(r["tokens"] for r in tours_llm)
latences = [r["latence_s"] for r in tours_llm]
n_reseau = sum(1 for r in traces for s_ in r["statuts"].values() if s_ == "erreur_reseau")
print(f"Consommation : {tokens_total} tokens LLM au total, "
      f"latence moyenne/tour {statistics.mean(latences):.2f}s sur {len(tours_llm)} tours LLM, "
      f"erreurs reseau residuelles : {n_reseau}")
H0 : les actions LLM (regles visibles ou aveugles) sont indiscernables de l'uniforme 50/50 sur {a, a'}
H1 : avec regles visibles, taux d'action dominante a' >= 0.80 et > taux aveugle (ecart >= 0.20) ; sous permutation hostile, le taux visible retombe vers l'uniforme (chute >= 0.20 vs visible) 

[glm] visible = 1.000 [0.722, 1.000] (n=10) | aveugle = 0.500 (n=32) | permute = nan (n=0)
    tailles d'effet (h de Cohen) : visible-vs-aveugle = 1.57 ; visible-vs-permute = nan  (~0.2 petit, ~0.5 moyen, ~0.8 grand)
    seuils du gel -> H1_visible=True H1_ecart=True H1_chute=absence de mesure (non evalue)
[qwen] visible = 1.000 [0.893, 1.000] (n=32) | aveugle = 0.500 (n=32) | permute = 0.750 (n=16)
    tailles d'effet (h de Cohen) : visible-vs-aveugle = 1.57 ; visible-vs-permute = 1.05  (~0.2 petit, ~0.5 moyen, ~0.8 grand)
    seuils du gel -> H1_visible=True H1_ecart=True H1_chute=True

VERDICT : absence de mesure -- glm sans acte evaluable sur au moins un seuil (n=0) : run non evalue, ni H0 ni H1
Consommation : 92491 tokens LLM au total, latence moyenne/tour 12.19s sur 80 tours LLM, erreurs reseau residuelles : 0

Lecture chiffree — tailles d’effet et consommation, les chiffres que le verdict ne resume pas. Les h de Cohen donnent l’echelle des differences : visible-vs-aveugle = 1.57 pour les DEUX familles, visible-vs-permute = 1.05 pour qwen (nan pour glm, sans acte evaluable) — sur l’echelle affichee (~0.2 petit, ~0.5 moyen, ~0.8 grand), les deux effets sont grands, largement au-dela du seuil conventionnel. La consommation clot le run : 92491 tokens LLM au total, latence moyenne/tour 12.19s sur 80 tours LLM, erreurs reseau residuelles : 0. Ces lignes disent ce que le verdict global rendu dans la sortie ci-dessus ne chiffre pas : l’ampleur statistique des effets mesures et le cout exact de la mesure.

Écarts d’exécution vs gel (consignés, conformément au §0)

  1. Budget d’appels : le gel déclarait budget_appels_max = 96, arithmetic sous-estimée (les 2 conditions LLM principales ont été oubliées dans le produit : 2 conditions × 2 familles × 4 seeds × 4 profils × 2 agents = 128 appels principaux, + 32 hostiles = 160 appels logiques consommés). Aucun appel supplémentaire n’a été émis au-delà du plan d’expérience gelé (seeds, conditions, familles, profils tous conformes au gel) ; seule la constante déclarée du budget était fausse. Consigné ici plutôt que corrigé silencieusement dans le gel.
  2. Aucune erreur réseau résiduelle : la politique de retries (gel) a suffi (erreurs reseau residuelles : 0).
  3. Réconciliation des compteurs (actes vs traces) : ce run produit 104 traces au total (88 lignes principales — 24 tours scriptés + 64 tours LLM — et 16 lignes hostiles), contrôles scriptés inclus. Les actes LLM se comptent sur les seuls tours LLM : 80 tours LLM × 2 agents = 160 actes = 122 actes valides + 38 actes invalides (22 glm-visibles + 16 glm-hostiles ; qwen est à 0 invalide sur toutes ses conditions). Les taux des §6/§8 portent sur les seuls actes valides ; les taux d’invalide et de réseau sont rapportés séparément (jamais confondus, gel §0).

Lecture honnête du verdict de ce run : qwen passe les 3 seuils du gel (n=32/32/16 actes valides) ; glm passe visible et écart (n=10 en visible, 68,8 % d’actes invalides : le modèle raisonnant produit souvent un JSON non strict quand le prompt embarque le bloc de règles complet — incapacité de format, pas un choix de jeu) mais rend 100 % d’actes invalides en permutation (n=0) : le seuil de chute n’est pas évaluable et le verdict global est absence de mesure — run non évalué, ni H0 ni H1. C’est exactement le cas où la comparaison naïve sur nan aurait rendu H1_chute=False (un faux négatif booléen, « H0 non rejetée ») : le chemin n==0 du §8 rend désormais l’absence de mesure explicite. Le run précédent (même gel, mêmes seeds) avait mesuré n=4 valides en glm-permutation et retenu H1 : la bascule du verdict global sur la seule variance inter-run de glm-permutation (n=4 → n=0 entre deux runs consécutifs) est la fragilité à retenir — l’hétérogénéité des familles reste l’objet même du pilote.

Conclusion

Ce pilote mesure ce que font de vrais LLM hétérogènes face à un mécanisme à action dominante — l’objet exact de la Proposition 6 (MOM, Othman–Sandholm 2009), importé sans divergence depuis le notebook owner (asserts §1) et rejoué par son oracle. Les contrôles scriptés encadrent la lecture : l’équilibre (a’) et le byzantin (a) fixent les bornes, l’aléatoire seedé fixe le plancher, la condition aveugle isole l’effet de l’information, la permutation hostile teste la compréhension de surface. Ce que le pilote ne fait pas : il ne prouve rien sur le théorème Lean (existence/propriétés du mécanisme) — il mesure des comportements. Les traces complètes (une ligne par tour) restent dans traces pour ré-analyse.

Voir aussi : GameTheory-16-MechanismDesign-Python.ipynb §4.6 (owner), GameTheory-16b-Automated-Mechanism-Design-Python.ipynb (AMD), GameTheory-03d-Le-Joueur-LLM-Python.ipynb (LLM sur jeu 2×2, autre classe d’objet), game_theory_lean/SocialChoice/MechanismDesign.lean (formalisation).

# Exercice 1 — Regret du mecanisme a l'equilibre
# Pour chaque profil de types vrai, calculez le regret du mecanisme M sous rapports
# d'equilibre (a', a') : sw_optimal(theta) - sw_oracle(M(a',a'), theta).
# Indice : sw_optimal et mecanisme("a'", "a'") suffisent ; sommez les 4 regrets.
# Etape 2 : comparez au cout de la manipulation (ce que gagne un byzantin a devier,
# par profil -- cf. traces scripted-byzantin).
print("Exercice a completer")
Exercice a completer

Exercices

# Exercice 2 — Une troisieme famille
# Ajoutez la famille deepseek (modele 'deepseek-v4-flash', servi par le meme proxy)
# PUIS relancez uniquement la condition llm-regles-visibles.
# Indice : PREREG est gele -- la deviation doit etre consignee au §8 comme
# modification de protocole, pas silencieusement appliquee.
print("Exercice a completer")
Exercice a completer
# Exercice 3 — Byzantin mixte et l'amelioration paradoxale
# Mesurez le SW realise quand exactement UN agent (row) suit la politique byzantine
# et l'autre l'equilibre, sur les 4 profils. Comparez au resultat « mieux avec
# Byzantine » du owner (§4.6.3, Caracteristique 2) : pour lesquels des 3 profils
# byzantins l'outcome M(a, a') ou M(a', a) bat-il o1 au sens de l'oracle ?
# Indice : ecrivez une variante de jouer_tour a politiques mixtes, ou raisonnez
# directement sur sw_oracle et M_MAP.
print("Exercice a completer")
Exercice a completer
Retour au sommet