# Imports
import os
import numpy as np
from dataclasses import dataclass
from typing import Tuple, List, Dict
# Convention : OrdinalGame (R-G) aligne sur le notebook GameTheory-3 (cellule 5, 7).
# Plus le rang est GRAND, meilleure est l'issue. Vecteur indexe (CC, CD, DC, DD)
# pour Row (payoffs_R) et Col (payoffs_C). L'invariant __post_init__ garantit
# que chaque payoffs_X est une permutation stricte de (1, 2, 3, 4) -- propriete
# qui exclut mecaniquement les jeux a rangs repetes (hors tableau periodique R-G)
# et qui protege contre les fautes de frappe comme (1, 2, 2, 3).
@dataclass(frozen=True)
class OrdinalGame:
name: str
payoffs_R: Tuple[int, int, int, int] # rangs Row pour (CC, CD, DC, DD)
payoffs_C: Tuple[int, int, int, int] # rangs Col pour (CC, CD, DC, DD)
def __post_init__(self):
assert sorted(self.payoffs_R) == [1, 2, 3, 4], \
f"payoffs_R doit etre permutation de 1-4, got {self.payoffs_R}"
assert sorted(self.payoffs_C) == [1, 2, 3, 4], \
f"payoffs_C doit etre permutation de 1-4, got {self.payoffs_C}"
CLASSIC_GAMES = {
# Harmony : CC > CD > DC > DD. Rang_R = (4, 3, 2, 1), Rang_C = (4, 2, 3, 1)
# (Nash unique (C,C), ordre strict, symetrie CD<->DC transposee).
"Harmony": OrdinalGame("Harmony", (4, 3, 2, 1), (4, 2, 3, 1)),
# StagHunt : CC > DC > DD > CD. Rang_R = (4, 1, 3, 2), Rang_C = (4, 3, 1, 2)
# (Nash (C,C) et (D,D), ordre strict, symetrie transposee).
"StagHunt": OrdinalGame("StagHunt", (4, 1, 3, 2), (4, 3, 1, 2)),
# Dilemme (= Prisoner's Dilemma textbook) : DC > CC > DD > CD.
# Rang_R = (3, 1, 4, 2), Rang_C = (3, 4, 1, 2) (Nash unique (D,D), CC>DD).
"Dilemme": OrdinalGame("Dilemme", (3, 1, 4, 2), (3, 4, 1, 2)),
# Chicken : DC > CC > CD > DD. Rang_R = (3, 2, 4, 1), Rang_C = (3, 4, 2, 1)
# (Nash (C,D) et (D,C), ordre strict, symetrie transposee).
"Chicken": OrdinalGame("Chicken", (3, 2, 4, 1), (3, 4, 2, 1)),
# Coordination (= Pure Coordination) : CC > DD > DC > CD.
# Rang_R = (4, 1, 2, 3), Rang_C = (4, 2, 1, 3) (Nash (C,C) et (D,D)).
"Coordination": OrdinalGame("Coordination", (4, 1, 2, 3), (4, 2, 1, 3)),
# BattleSexes : DC > CD > CC > DD. Rang_R = (2, 3, 4, 1), Rang_C = (2, 4, 3, 1)
# (Nash (C,D) et (D,C), Row prefere (C,D) car CD = rang 3 > CC = rang 2,
# Col prefere (D,C) car DC = rang 4 > DD = rang 1 -- chaque joueur
# departage les deux Nash en sens inverse).
"BattleSexes": OrdinalGame("BattleSexes", (2, 3, 4, 1), (2, 4, 3, 1)),
}GameTheory-3c — Le joueur LLM dans le tableau périodique
Navigation : GameTheory-3 (chambres Robinson-Goforth) · GameTheory-03d-Le-Joueur-LLM-Python · GameTheory-3g (morphisme fini)
Grain : #12254 — DEEP/notebook-python sur le papier Playing Repeated Games with Large Language Models (Nature Human Behaviour, s41562-025-02172-y).
Sources lues firsthand : page article (2026-08-22) ; grammaire R-G des chambres/murs réutilisée de GameTheory-3 (cellule 5 OrdinalGame).
Kernel : python3 — pas d’appels réseau non gardés.
Hypothèse (lue du papier, reformulée)
Un joueur LLM (GPT-4 / Claude 2 / Llama 2 70B / text-davinci) joue à des jeux 2×2 répétés (matrice convertie en règles textuelles, température 0, réponse mono-token, historique concaténé). Trois apports :
- (a) Le paysage de performance du joueur varie selon la famille de jeu — fort en Dilemme (défection permanente après une seule défection), faible en coordination (Battle of the Sexes : il colle à son option préférée).
- (b) La dissociation prédire/agir : GPT-4 prédit correctement l’alternance et n’agit pas en conséquence.
- (c) Le SCoT (Social Chain-of-Thought — prédire le coup adverse avant de choisir) est une transmutation de Bruns : la consigne modifie la règle de décision sans modifier le jeu. Le papier rapporte qu’elle augmente la coordination des vrais LLMs ; E2bis chiffre ce qu’elle fait à notre joueur simulé de niveau 2 — et le résultat n’est pas celui qu’on attend.
Ce que le notebook mesure
Des cellules-mesures (E1-E4 + E2bis) ancrées sur les outputs commités :
- E1 — Placer le papier dans le tableau : les six familles mesurées (win-win, Dilemme, unfair, cyclique, biaisé, second-best) se placent-elles dans les chambres Robinson-Goforth ? Mapping RAPPORTÉ (le notebook dérive ; le mapping Robinson-Goforth ↔︎ papier est une dette reconnue §Sources).
- E2 — Le joueur LLM simulé : cinq modèles de joueur (
stickyparamétré par sa première action C ou D,alternating,noisyà ε fixé,best_response), tous exécutés par la même règlesimulate_player— le comportement émerge de la règle, round après round, il n’est jamais écrit en dur. Le branchement d’un vrai provider (protocole du papier : matrice → règles textuelles → température 0 → 1 token) est l’exercice 1, stub C.1 sans clé. - E2bis — SCoT (apport (c)) : le joueur de niveau 2 prédit le coup adverse (sa meilleure réponse à mon dernier coup) puis joue sa meilleure réponse à cette prédiction — effet chiffré sur le taux d’atteinte de Nash, avec et sans.
- E3 — Le swap en cours de partie : valeur ajoutée absente du papier — appliquer un swap au round k et mesurer qui suit le déplacement : le sticky (sa règle ne regarde pas le jeu), l’alternant (il regarde l’horloge), le bruité (il écoute, avec un tremblement), la BR (elle écoute parfaitement). Marche 1½ vers D4.
- E4 — Dissociation (b) : la prédiction (meilleure réponse au dernier coup adverse) diffère-t-elle de l’action jouée, mode par mode — mesure explicite, pas une affirmation.
Critère d’acceptation
- E1 rend un placement explicite avec statut (dérivé / RAPPORTÉ).
- E2/E2bis/E3/E4 produisent des taux mesurés, reproductibles (seeds fixés), sur sorties committées ; le vrai provider est l’exercice 1 (stub C.1 sans clé, c’est aussi un résultat reproductible).
- E4 exhibe la dissociation (ou son absence, qui est un résultat).
- C.1 : aucun
raise NotImplementedError; C.2 : cellules code avecexecution_count+ outputs réels (ou vides si stub non exécuté).
Dettes de vérification
- Le mapping six familles ↔︎ chambres/murs R-G n’est pas dérivé — alignement à établir dans E1 avant toute affirmation.
- Les résultats du papier sont ses résultats, sur ses modèles 2023-2024. Rejoués sur des modèles actuels, ils peuvent ne pas se reproduire — c’est ce que E2/E4 mesurent (cassettes + plafond).
- Coût et reproductibilité : appels réels = plafond et cassettes avant la lane ; sinon stub C.1 honnête.
Sources
- Mei et al., Playing Repeated Games with Large Language Models, Nature Human Behaviour (2025), s41562-025-02172-y — page lue 2026-08-22.
- Robinson & Goforth, The Topology of the 2x2 Games (2005) — implémentation dans
GameTheory-3cellule 5 (OrdinalGame). - Bruns, Austausch und Gerechtigkeit (1975) — notion de transmutation (information nouvelle vs déplacement), voir aussi GameTheory-3h (Loi III, transformations vs morphismes).
Lecture de la representation
Les jeux sont encodes en rangs ordonnes (4 = meilleur, 1 = pire pour le joueur considere). C’est la convention de Robinson-Goforth (GameTheory-3 cellule 5), invariante aux translations de payoff – ce qui compte est la structure des preferences, pas les valeurs cardinales.
Exemple Dilemme (3, 1, 4, 2) : pour le Row-player, la tentation (D,C) = rang 4 bat la cooperation (C,C) = rang 3 ; la recompense mutuelle (D,D) = rang 2 bat la defection unilaterale (C,D) = rang 1 (DD > CD au sens ordinal). Pour le Col-player, la defection unilaterale (C,D) = rang 4 bat tout.
Cette convention permet de tester la dissociation du joueur LLM sans bruit : si le joueur repond « D » en Dilemme, c’est la preference revelee ; si en BoS il repond toujours la meme option, c’est l’absence d’alternance.
Pourquoi cette convention pour E2 ?
Le papier (Mei et al.) utilise une représentation cardinale dans ses mesures de payoff cumulé. Mais l’apport scientifique — la dissociation prédire/agir — est invariant à la représentation : peu importe que (C,C) paie 8 ou 10, ce qui compte est que le joueur prédit correctement l’alternance en BoS et n’agit pas en conséquence.
On peut donc reproduire l’expérience (b) en ordinal, sans dépendance externe, et la dissociation reste visible : le joueur qui annonce « J’alterne C-D-C-D » et joue C-C-C-C.
L’apport (c) — SCoT comme transmutation — est aussi mesurable en ordinal : la consigne « prédis le coup adverse » modifie la règle de décision sans modifier le jeu (implémenté en E2bis, mode scot de simulate_player). Même grammaire, même test.
def best_response(g: OrdinalGame, player: str, opponent_action: str) -> str:
"""
Meilleure reponse (rang 4 = meilleur) du joueur `player` quand l'adversaire joue `opponent_action`.
"""
if player == "Row":
if opponent_action == "C":
r_C, r_D = g.payoffs_R[0], g.payoffs_R[2]
else:
r_C, r_D = g.payoffs_R[1], g.payoffs_R[3]
else: # Col
if opponent_action == "C":
r_C, r_D = g.payoffs_C[0], g.payoffs_C[1]
else:
r_C, r_D = g.payoffs_C[2], g.payoffs_C[3]
return "C" if r_C >= r_D else "D"
# Verification : BR coherente avec la litterature R-G
print("=== Best response par jeu ===")
for g_name in ["BattleSexes", "StagHunt", "Dilemme", "Chicken", "Harmony", "Coordination"]:
g = CLASSIC_GAMES[g_name]
br_row_C = best_response(g, "Row", "C")
br_row_D = best_response(g, "Row", "D")
br_col_C = best_response(g, "Col", "C")
br_col_D = best_response(g, "Col", "D")
print(f"{g_name:14s}: Row(C)={br_row_C} Row(D)={br_row_D} | Col(C)={br_col_C} Col(D)={br_col_D}")=== Best response par jeu ===
BattleSexes : Row(C)=D Row(D)=C | Col(C)=D Col(D)=C
StagHunt : Row(C)=C Row(D)=D | Col(C)=C Col(D)=D
Dilemme : Row(C)=D Row(D)=D | Col(C)=D Col(D)=D
Chicken : Row(C)=D Row(D)=C | Col(C)=D Col(D)=C
Harmony : Row(C)=C Row(D)=C | Col(C)=C Col(D)=C
Coordination : Row(C)=C Row(D)=D | Col(C)=C Col(D)=D
1. E1 — Placer le papier dans le tableau R-G
Le papier (Mei et al.) distingue six familles de jeux 2×2 mesurées :
- win-win (jeux à équilibre coopératif dominant, type Harmony)
- Dilemme (Prisoner’s Dilemma)
- unfair (jeux asymétriques type Battle of the Sexes où un joueur a un avantage structurel)
- cyclique (type Chicken — Rock-Paper-Scissors-like en 2×2)
- biaisé (jeux à dominance stricte)
- second-best (jeux où le Nash n’est pas Pareto-Optimal)
Mapping proposé (RAPPORTÉ, dette §Sources) :
| Famille papier | Chambre R-G probable | Mapping |
|---|---|---|
| win-win | Harmony + Coordination | DÉRIVÉ (Harmony a (C,C) Pareto-dominant) |
| Dilemme | Dilemme (strict) | DÉRIVÉ (match canonique : CC > DD) |
| unfair | BattleSexes | DÉRIVÉ (Nash (C,D) et (D,C), chaque joueur départage à l’inverse) |
| cyclique | Chicken | DÉRIVÉ (R-G “Rock-Paper-Scissors-like” en 2×2) |
| biaisé | jeux à stratégie dominante (subset de Dilemme+Chicken) | RAPPORTÉ — la définition “biaisé” du papier n’est pas dans R-G canonique |
| second-best | subset de StagHunt | DÉRIVÉ (StagHunt a (D,D) Nash mais (C,C) Pareto) |
Note importante — cyclicité de BattleSexes :
BattleSexes canonique admet deux Nash purs : (C,D) et (D,C). Pour encoder simultanément les deux Nash sans cycler sur le même rang, on choisit Row rang_R = (2, 3, 4, 1) (DC > CD > CC > DD, Row préfère (D,C)) et Col rang_C = (2, 4, 3, 1) (DC > DD > CD > CC, Col préfère (C,D)). L’ordre strict est préservé, et chaque joueur départage les deux Nash dans la direction qui maximise son payoff — c’est exactement l’essence du conflit de BoS.
Remarque invariante : la convention du notebook est 4 = meilleur, 1 = pire (alignée sur GameTheory-3 cellule 5), et chaque payoffs_X est une permutation stricte de (1, 2, 3, 4) — assertion __post_init__ qui protège mécaniquement contre les fautes de frappe (cf leçon ai-01 dans la review PR #12295).
# E1 : mesure des Nash purs par chambre R-G (les 6 jeux classiques)
def find_pure_nash(g: OrdinalGame) -> List[Tuple[str, str]]:
"""Nash purs : cases (a,b) telles que a = best_response(Row) et b = best_response(Col)."""
results = []
for row_a in ["C", "D"]:
for col_a in ["C", "D"]:
br_row = best_response(g, "Row", col_a)
br_col = best_response(g, "Col", row_a)
if row_a == br_row and col_a == br_col:
results.append((row_a, col_a))
return results
print("=== E1 : Equilibres de Nash purs par chambre R-G ===")
print(f"{'Jeu':15s} {'Nash purs':15s} {'Cardinalite'}")
for g_name in ["BattleSexes", "StagHunt", "Dilemme", "Chicken", "Harmony", "Coordination"]:
g = CLASSIC_GAMES[g_name]
nash = find_pure_nash(g)
cardinalite = "0" if not nash else f"{len(nash)}"
nash_str = ", ".join(f"{a}{b}" for a, b in nash) if nash else "(aucun)"
print(f"{g_name:15s} {nash_str:15s} {cardinalite}")=== E1 : Equilibres de Nash purs par chambre R-G ===
Jeu Nash purs Cardinalite
BattleSexes CD, DC 2
StagHunt CC, DD 2
Dilemme DD 1
Chicken CD, DC 2
Harmony CC 1
Coordination CC, DD 2
Lecture de E1
Trois chambres à 1 Nash : Dilemme (D,D unique — grim trigger), Harmony (C,C unique — coopératif dominant).
Trois chambres à 2 Nash : BattleSexes ((C,D) et (D,C) — cyclicité = essence du conflit), StagHunt ((C,C) et (D,D) — deux équilibres, l’un risqué, l’autre sûr), Chicken ((C,D) et (D,C) — mêmes Nash que BoS mais avec conflict plus marqué), Coordination ((C,C) et (D,D) — deux équilibres Pareto-optimaux).
Pattern attendu du papier sur le joueur LLM :
- En Dilemme → grim trigger immédiat (toujours D) ✓
- en Harmony → C permanent ✓
- en BattleSexes / Coordination / StagHunt → alternance si dissociation est absente, C-permanent (ou D-permanent) si dissociation est présente (le modèle colle à son option préférée).
C’est exactement ce que les cellules E2-E4 mesurent.
Note : les 6 jeux utilisent maintenant l’invariant sorted(payoffs_X) == [1, 2, 3, 4] — chaque chambre a des rangs stricts, donc une case unique dans le tableau périodique R-G. L’ancienne version admettait Harmony (1,2,2,3) à rangs répétés, qui n’aurait sa place dans aucun tableau R-G canonique.
2. E2 — Le joueur LLM face à deux jeux
Le protocole du papier (Mei et al.) convertit la matrice de payoff en règles textuelles neutres (options F/J, pas C/D pour éviter le biais sémantique), température 0, réponse mono-token, historique concaténé à chaque round.
Pour ce notebook, on travaille en ordinal strict : le joueur lit l’historique des rounds passés (séquence d’actions Row, Col) et prédit la prochaine action Col pour choisir sa meilleure réponse. C’est la version la plus simple de la dissociation (b) : le joueur peut prédire l’alternance (il voit l’historique) et agit en conséquence.
Stub C.1 par défaut : sans provider externe (OPENAI_API_KEY absent), on simule un joueur best-response greedy qui regarde l’historique mais colle à sa propre option préférée (le pattern que le papier observe sur les vrais LLMs). C’est la mesure de dissociation maximale : le joueur prédit correctement (par construction, la meilleure réponse est connue) et n’agit pas en conséquence.
def simulate_player(g: OrdinalGame, player: str, history: List[Tuple[str, str]],
mode: str = "sticky_preferred", first_action: str = "C",
rng=None, epsilon: float = 0.1) -> str:
"""
Simule UN coup du joueur `player` dans `g`, etant donne `history`.
Le comportement EMERGE de la regle, round apres round : aucune sequence
n'est pre-calculee par l'appelant.
mode :
- "sticky_preferred" : colle a sa premiere action jouee (pattern observe sur vrais LLMs)
- "alternating" : alterne C, D, C, D... (baseline theorique)
- "best_response" : meilleure reponse au dernier coup adverse (reference)
- "noisy" : meilleure reponse + probabilite epsilon de devier (`rng`, reproductible)
- "scot" : PREDIT le coup adverse (sa BR contre mon dernier coup), puis joue
sa BR contre cette prediction (Social Chain-of-Thought, E2bis)
first_action : action d'ouverture quand l'historique propre est vide. Parametrable :
un sticky qui ouvre par D doit produire une trajectoire differente du sticky qui ouvre par C.
"""
def other() -> str:
return "Col" if player == "Row" else "Row"
def my_actions() -> List[str]:
# Mes coups deja joues -- le "?" marque le co-joueur pas encore decide
out = []
for r, c in history:
a = r if player == "Row" else c
if a != "?":
out.append(a)
return out
mine = my_actions()
if mode == "scot":
if not mine:
return first_action
adversaire_predira = best_response(g, other(), mine[-1])
return best_response(g, player, adversaire_predira)
if mode == "noisy":
opp = [c if player == "Row" else r for r, c in history]
opp_last = [a for a in opp if a != "?"]
if not opp_last:
return first_action
base = best_response(g, player, opp_last[-1])
if rng is not None and rng.random() < epsilon:
return "D" if base == "C" else "C"
return base
if mode == "best_response":
if not history:
return first_action
opp_last = history[-1][1] if player == "Row" else history[-1][0]
if opp_last == "?":
return first_action
return best_response(g, player, opp_last)
if mode == "sticky_preferred":
return mine[0] if mine else first_action
if mode == "alternating":
return "C" if len(mine) % 2 == 0 else "D"
raise ValueError(f"Mode inconnu : {mode}")
def play_repeated(g: OrdinalGame, n_rounds: int = 10, mode: str = "sticky_preferred",
first_action: str = "C", seed: int = None, epsilon: float = 0.1
) -> List[Tuple[str, str]]:
"""
Joue `g` sur n_rounds. Chaque round, Row puis Col jouent TOUS DEUX via
simulate_player, pour TOUS les modes : le comportement emerge de la regle,
il n'est jamais ecrit en dur. Col voit l'action de Row du round courant
(convention sequentielle, meme information que le mode best_response).
"""
rng = np.random.default_rng(seed) if mode == "noisy" else None
history: List[Tuple[str, str]] = []
for _ in range(n_rounds):
row_a = simulate_player(g, "Row", history, mode, first_action, rng, epsilon)
col_a = simulate_player(g, "Col", history + [(row_a, "?")], mode, first_action, rng, epsilon)
history.append((row_a, col_a))
return history
def cooperation_rate(history: List[Tuple[str, str]]) -> float:
if not history:
return 0.0
return sum(1 for r, c in history if r == "C" and c == "C") / len(history)
def defection_rate(history: List[Tuple[str, str]]) -> float:
if not history:
return 0.0
return sum(1 for r, c in history if r == "D" or c == "D") / len(history)
def nash_attainment_rate(history: List[Tuple[str, str]], nash_set: List[Tuple[str, str]]) -> float:
if not nash_set:
return 0.0
return sum(1 for r, c in history if (r, c) in nash_set) / len(history)
MODES_E2 = [("sticky/C", "sticky_preferred", "C"), ("sticky/D", "sticky_preferred", "D"),
("alternating", "alternating", "C"), ("noisy", "noisy", "C"),
("best_response", "best_response", "C")]
print("=== E2 : cinq modeles de joueur x six chambres (10 rounds) ===")
print(f"{'chambre':14s} " + " ".join(f"{lbl:>17s}" for lbl, _, _ in MODES_E2))
for g_name in ["BattleSexes", "StagHunt", "Dilemme", "Chicken", "Harmony", "Coordination"]:
g = CLASSIC_GAMES[g_name]
nash_set = find_pure_nash(g)
cells = []
for _, mode, fa in MODES_E2:
h = play_repeated(g, n_rounds=10, mode=mode, first_action=fa, seed=0, epsilon=0.2)
cells.append(f"coop={cooperation_rate(h):.0%} nash={nash_attainment_rate(h, nash_set):.0%}")
print(f"{g_name:14s} " + " ".join(f"{c:>17s}" for c in cells))
print()
print("=== E2 : trajectoires (verification -- chaque regle produit bien la sienne) ===")
for g_name in ["Dilemme", "BattleSexes", "StagHunt"]:
g = CLASSIC_GAMES[g_name]
for lbl, mode, fa in MODES_E2:
h = play_repeated(g, n_rounds=10, mode=mode, first_action=fa, seed=0, epsilon=0.2)
seq = " ".join(r + c for r, c in h[:8])
print(f"{g_name:14s} {lbl:>14s} : {seq} ...")=== E2 : cinq modeles de joueur x six chambres (10 rounds) ===
chambre sticky/C sticky/D alternating noisy best_response
BattleSexes coop=100% nash=0% coop=0% nash=0% coop=50% nash=0% coop=10% nash=90% coop=0% nash=100%
StagHunt coop=100% nash=100% coop=0% nash=100% coop=50% nash=100% coop=60% nash=90% coop=100% nash=100%
Dilemme coop=100% nash=0% coop=0% nash=100% coop=50% nash=50% coop=0% nash=40% coop=0% nash=90%
Chicken coop=100% nash=0% coop=0% nash=0% coop=50% nash=0% coop=10% nash=90% coop=0% nash=100%
Harmony coop=100% nash=100% coop=0% nash=0% coop=50% nash=50% coop=50% nash=50% coop=100% nash=100%
Coordination coop=100% nash=100% coop=0% nash=100% coop=50% nash=100% coop=60% nash=90% coop=100% nash=100%
=== E2 : trajectoires (verification -- chaque regle produit bien la sienne) ===
Dilemme sticky/C : CC CC CC CC CC CC CC CC ...
Dilemme sticky/D : DD DD DD DD DD DD DD DD ...
Dilemme alternating : CC DD CC DD CC DD CC DD ...
Dilemme noisy : CD DC CD DD DD DD CD CD ...
Dilemme best_response : CD DD DD DD DD DD DD DD ...
BattleSexes sticky/C : CC CC CC CC CC CC CC CC ...
BattleSexes sticky/D : DD DD DD DD DD DD DD DD ...
BattleSexes alternating : CC DD CC DD CC DD CC DD ...
BattleSexes noisy : CD CC CD CD CD CD DC CD ...
BattleSexes best_response : CD CD CD CD CD CD CD CD ...
StagHunt sticky/C : CC CC CC CC CC CC CC CC ...
StagHunt sticky/D : DD DD DD DD DD DD DD DD ...
StagHunt alternating : CC DD CC DD CC DD CC DD ...
StagHunt noisy : CC CD CC CC CC CC DD CC ...
StagHunt best_response : CC CC CC CC CC CC CC CC ...
# E2bis (apport (c) du papier) : SCoT -- predire le coup adverse AVANT de choisir.
# Niveau 1 : best_response -- reagir au dernier coup adverse, ne rien predire.
# Niveau 2 : SCoT -- predire "l'adversaire jouera sa BR contre mon dernier coup",
# puis jouer sa BR contre CETTE prediction. Le jeu n'a pas change :
# seule la regle de decision du joueur a change (transmutation).
print("=== E2bis : SCoT vs best_response -- taux d'atteinte de Nash (10 rounds) ===")
print(f"{'chambre':14s} {'BR (niv.1)':>12s} {'SCoT (niv.2)':>14s} {'delta':>8s}")
for g_name in ["BattleSexes", "StagHunt", "Dilemme", "Chicken", "Harmony", "Coordination"]:
g = CLASSIC_GAMES[g_name]
nash_set = find_pure_nash(g)
h_br = play_repeated(g, n_rounds=10, mode="best_response")
h_scot = play_repeated(g, n_rounds=10, mode="scot")
n_br = nash_attainment_rate(h_br, nash_set)
n_scot = nash_attainment_rate(h_scot, nash_set)
print(f"{g_name:14s} {n_br:12.0%} {n_scot:14.0%} {n_scot - n_br:+8.0%}")
print()
print("=== E2bis : le mecanisme ===")
g = CLASSIC_GAMES["BattleSexes"]
h = play_repeated(g, n_rounds=6, mode="scot")
print(f"BattleSexes, SCoT, 6 rounds : {' '.join(r + c for r, c in h)}")
print("Round 2 : mon dernier coup est C, je predits que Col jouera sa BR contre C,")
print("je joue ma BR contre cette prediction... et le round suivant reprend identique.")
print("En chambre a conflit, chacun predit que l'autre va ceder, s'ajuste... et reste")
print("sur son option preferee : cercle auto-confirmatoire de niveau 2. Le sticky n'est")
print("plus une regle figee, c'est la CONSEQUENCE d'une prediction naive.")=== E2bis : SCoT vs best_response -- taux d'atteinte de Nash (10 rounds) ===
chambre BR (niv.1) SCoT (niv.2) delta
BattleSexes 100% 0% -100%
StagHunt 100% 100% +0%
Dilemme 90% 90% +0%
Chicken 100% 0% -100%
Harmony 100% 100% +0%
Coordination 100% 100% +0%
=== E2bis : le mecanisme ===
BattleSexes, SCoT, 6 rounds : CC CC CC CC CC CC
Round 2 : mon dernier coup est C, je predits que Col jouera sa BR contre C,
je joue ma BR contre cette prediction... et le round suivant reprend identique.
En chambre a conflit, chacun predit que l'autre va ceder, s'ajuste... et reste
sur son option preferee : cercle auto-confirmatoire de niveau 2. Le sticky n'est
plus une regle figee, c'est la CONSEQUENCE d'une prediction naive.
Lecture de E2
Ce que les cinq modes montrent (outputs committés ci-dessus) :
- Le comportement émerge de la règle.
sticky/Cproduit CC partout,sticky/Dproduit DD partout — deux trajectoires différentes issues de la même règlesimulate_player, seule la première action change.alternatingproduit l’alternance exacte,noisydévie de la meilleure réponse avec probabilité ε=0.2 (seed 0, reproductible),best_responseconverge vers l’équilibre. - E2 ne rend plus
coop=100%sur les six chambres indistinctement. Le tableau des coop/nash varie selon le couple (chambre, modèle de joueur) : la mesure discrimine réellement les modèles de joueur — c’était l’objectif. - Paysage de performance (apport (a) du papier) : la ligne
sticky/Catteint 100% de Nash dans les chambres où CC est un équilibre (StagHunt, Harmony, Coordination) et 0% dans les chambres à conflit (BattleSexes, Dilemme, Chicken) ;sticky/Drenverse le tableau exactement là où l’ouverture compte — Dilemme (0%→100% : DD y est l’équilibre) et Harmony (100%→0%) — et reste à 100% dans les chambres à double équilibre CC/DD (StagHunt, Coordination). La performance dépend du couple (règle, chambre), pas de la chambre seule.
Lecture de E2bis — SCoT, effet chiffré (apport (c))
Le résultat mesuré est contre-intuitif et instructif : SCoT tombe à 0% d’atteinte de Nash en BattleSexes et Chicken — les chambres à conflit — là où la meilleure réponse de niveau 1 converge vers l’équilibre ; delta nul sur les quatre autres chambres. Le delta n’est jamais positif dans ce simulateur.
Mécanisme (output ci-dessus) : en chambre à conflit, chaque joueur prédit que l’adversaire jouera sa BR contre mon dernier coup — c’est-à-dire qu’il va me céder — puis joue sa BR contre cette prédiction, qui est de garder son camp. Le cercle de niveau 2 est auto-confirmatoire : le sticky n’est plus une règle figée, c’est la conséquence d’une prédiction naïve.
Différence avec le papier, assumée : Mei et al. observent que le SCoT prompting augmente la coordination des vrais LLMs ; notre simulé de niveau 2 montre l’inverse. C’est précisément la comparaison qui instruit : ce n’est pas prédire qui aide, c’est prédire mieux que « la BR contre moi » — un vrai LLM prédit le comportement réel de l’adversaire, y compris sa propre prédiction. L’écart simulé/réel mesure la profondeur de raisonnement social que le prompting déclenche chez un vrai modèle, et que ce simulateur n’a pas.
3. E3 — Le swap en cours de partie
Valeur ajoutée absente du papier : appliquer un swap (R34 ou C23) au round k et mesurer si le joueur suit le déplacement dans l’espace des jeux.
L’idée : le papier observe des joueurs dans des jeux figés. Notre grammaire R-G (cf GameTheory-3, GameTheory-3h) permet de déplacer le joueur dans l’espace des jeux : on change la matrice en cours de partie, et on regarde si le joueur s’adapte (BR sticky ou best_response change).
Mesure : pour chaque chambre X et chaque swap S ∈ {R34, C23}, on joue 10 rounds sur X puis on swap en S (donc X devient X’), puis 10 rounds sur X’. On compare :
- Le taux de Nash sur X’ après swap, en mode sticky_preferred (le joueur garde sa mémoire) vs best_response (le joueur oublie et recalcule).
Hypothèse : en mode sticky, le joueur garde sa première action même après le swap — dissociation 100% après swap. En mode best_response, il rebascule vers le nouveau Nash.
def swap_payoffs(g: OrdinalGame, swap: str) -> OrdinalGame:
"""
Applique un swap R{i}{j} (echange les rangs Row d'indices i, j) ou C{i}{j}.
Convention des indices : 0=CC, 1=CD, 2=DC, 3=DD.
"""
if len(swap) != 3 or swap[0] not in "RC":
raise ValueError(f"Swap invalide : {swap} (attendu R12 ou C03 par exemple)")
try:
i, j = int(swap[1]), int(swap[2])
except ValueError:
raise ValueError(f"Indices non numeriques : {swap}")
if not (0 <= i <= 3 and 0 <= j <= 3):
raise ValueError(f"Indices hors limites 0..3 : {swap}")
if swap[0] == "R":
new_R = list(g.payoffs_R)
new_R[i], new_R[j] = new_R[j], new_R[i]
return OrdinalGame(g.name + "+" + swap, tuple(new_R), g.payoffs_C)
new_C = list(g.payoffs_C)
new_C[i], new_C[j] = new_C[j], new_C[i]
return OrdinalGame(g.name + "+" + swap, g.payoffs_R, tuple(new_C))
def play_with_swap(g: OrdinalGame, swap: str, swap_round: int,
n_total: int = 20, mode: str = "sticky_preferred",
first_action: str = "C", seed: int = None, epsilon: float = 0.1
) -> List[Tuple[str, str]]:
"""
Joue `g` sur n_total rounds avec un swap applique au round `swap_round`
(le jeu courant devient g' = swap_payoffs(g, swap) a partir de ce round).
TOUS les modes passent par simulate_player, round apres round, sur le jeu
COURANT. Si un joueur ignore le swap, c'est sa REGLE qui l'ignore -- le
sticky colle a sa premiere action, l'alternant regarde l'horloge -- pas une
ligne de code qui court-circuite la simulation. Les modes qui ecoutent le
jeu (best_response, noisy, scot) reagissent au swap.
"""
rng = np.random.default_rng(seed) if mode == "noisy" else None
history: List[Tuple[str, str]] = []
current_g = g
for r in range(n_total):
if r == swap_round:
current_g = swap_payoffs(g, swap)
row_a = simulate_player(current_g, "Row", history, mode, first_action, rng, epsilon)
col_a = simulate_player(current_g, "Col", history + [(row_a, "?")], mode, first_action, rng, epsilon)
history.append((row_a, col_a))
return history
print("=== E3 : StagHunt, swap R12 au round 10 (echange rangs Row CD <-> DC) ===")
g = CLASSIC_GAMES["StagHunt"]
g_swap = swap_payoffs(g, "R12")
print(f"Nash : {', '.join(a + b for a, b in find_pure_nash(g))} -> {', '.join(a + b for a, b in find_pure_nash(g_swap))}")
for lbl, mode in [("sticky/C", "sticky_preferred"), ("alternating", "alternating"),
("noisy", "noisy"), ("best_response", "best_response"), ("scot", "scot")]:
h = play_with_swap(g, "R12", swap_round=10, n_total=20, mode=mode, first_action="C", seed=0)
pre = " ".join(r + c for r, c in h[:5])
post = " ".join(r + c for r, c in h[10:15])
print(f" {lbl:14s} : pre={pre} ... | post={post} ...")
print()
print("=== E3 : BattleSexes, swap C12 au round 10 (echange rangs Col CD <-> DC) ===")
g = CLASSIC_GAMES["BattleSexes"]
g_swap = swap_payoffs(g, "C12")
print(f"Nash : {', '.join(a + b for a, b in find_pure_nash(g))} -> {', '.join(a + b for a, b in find_pure_nash(g_swap))}")
for lbl, mode in [("sticky/C", "sticky_preferred"), ("alternating", "alternating"),
("noisy", "noisy"), ("best_response", "best_response"), ("scot", "scot")]:
h = play_with_swap(g, "C12", swap_round=10, n_total=20, mode=mode, first_action="C", seed=0)
pre = " ".join(r + c for r, c in h[:5])
post = " ".join(r + c for r, c in h[10:15])
print(f" {lbl:14s} : pre={pre} ... | post={post} ...")=== E3 : StagHunt, swap R12 au round 10 (echange rangs Row CD <-> DC) ===
Nash : CC, DD -> CC
sticky/C : pre=CC CC CC CC CC ... | post=CC CC CC CC CC ...
alternating : pre=CC DD CC DD CC ... | post=CC DD CC DD CC ...
noisy : pre=CC CD CC CC CC ... | post=CD CC CC CC CC ...
best_response : pre=CC CC CC CC CC ... | post=CC CC CC CC CC ...
scot : pre=CC CC CC CC CC ... | post=CC CC CC CC CC ...
=== E3 : BattleSexes, swap C12 au round 10 (echange rangs Col CD <-> DC) ===
Nash : CD, DC -> CD, DC
sticky/C : pre=CC CC CC CC CC ... | post=CC CC CC CC CC ...
alternating : pre=CC DD CC DD CC ... | post=CC DD CC DD CC ...
noisy : pre=CD CC CD CD CD ... | post=CC DC DC DC DC ...
best_response : pre=CD CD CD CD CD ... | post=CD CD CD CD CD ...
scot : pre=CC CC CC CC CC ... | post=CC CC CC CC CC ...
# Mesure discriminante : Dilemme + C23 -- le swap deplace l'equilibre
print("=== E3 : Dilemme, swap C23 au round 10 ===")
g = CLASSIC_GAMES["Dilemme"]
g_swap = swap_payoffs(g, "C23")
nash_pre, nash_post = find_pure_nash(g), find_pure_nash(g_swap)
print(f"Nash : {', '.join(a + b for a, b in nash_pre)} -> {', '.join(a + b for a, b in nash_post)}")
for lbl, mode in [("sticky/C", "sticky_preferred"), ("alternating", "alternating"),
("noisy", "noisy"), ("best_response", "best_response")]:
h = play_with_swap(g, "C23", swap_round=10, n_total=20, mode=mode, first_action="C", seed=0)
pre = " ".join(r + c for r, c in h[:10])
post = " ".join(r + c for r, c in h[10:])
r_pre = sum(1 for a, b in h[:10] if (a, b) in nash_pre) / 10
r_post = sum(1 for a, b in h[10:] if (a, b) in nash_post) / 10
print(f" {lbl:14s} pre ={pre}")
print(f" {'':14s} post={post} Nash {r_pre:.0%} -> {r_post:.0%}")
print()
print("=== E3 synthese : taux Nash post-swap (rounds 11-20), 4 modeles de joueur ===")
print(f"{'Chambre':14s} {'Swap':5s} {'Nash post':16s} {'sticky':>8s} {'altern.':>8s} {'noisy':>8s} {'BR':>8s}")
test_cases = [("Dilemme", "C23"), ("StagHunt", "R12"), ("Chicken", "R02"),
("BattleSexes", "C12"), ("Harmony", "R12"), ("Coordination", "R01")]
for g_name, swap in test_cases:
g = CLASSIC_GAMES[g_name]
nash_post = find_pure_nash(swap_payoffs(g, swap))
lbl_nash = ", ".join(a + b for a, b in nash_post) if nash_post else "aucun"
rates = []
for mode in ["sticky_preferred", "alternating", "noisy", "best_response"]:
h = play_with_swap(g, swap, swap_round=10, n_total=20, mode=mode, first_action="C", seed=0)
rates.append(sum(1 for a, b in h[10:] if (a, b) in nash_post) / 10)
print(f"{g_name:14s} {swap:5s} {lbl_nash:16s} " + " ".join(f"{r:8.0%}" for r in rates))=== E3 : Dilemme, swap C23 au round 10 ===
Nash : DD -> DC
sticky/C pre =CC CC CC CC CC CC CC CC CC CC
post=CC CC CC CC CC CC CC CC CC CC Nash 0% -> 0%
alternating pre =CC DD CC DD CC DD CC DD CC DD
post=CC DD CC DD CC DD CC DD CC DD Nash 50% -> 0%
noisy pre =CD DC CD DD DD DD CD CD DD DD
post=DD DC DC DC DC DC DC DC DC DC Nash 50% -> 90%
best_response pre =CD DD DD DD DD DD DD DD DD DD
post=DC DC DC DC DC DC DC DC DC DC Nash 90% -> 100%
=== E3 synthese : taux Nash post-swap (rounds 11-20), 4 modeles de joueur ===
Chambre Swap Nash post sticky altern. noisy BR
Dilemme C23 DC 0% 0% 90% 100%
StagHunt R12 CC 100% 50% 90% 100%
Chicken R02 CD 0% 0% 90% 100%
BattleSexes C12 CD, DC 0% 0% 90% 100%
Harmony R12 CC 100% 50% 90% 100%
Coordination R01 aucun 0% 0% 0% 0%
Lecture de E3
Tous les modes passent par simulate_player sur le jeu courant : si un joueur ignore le swap, c’est sa règle qui l’ignore, pas une ligne de code qui court-circuite la simulation.
- Dilemme + C23 (Nash déplacé, output ci-dessus) :
sticky/Cpoursuit CC — sa règle ne regarde jamais le jeu ;alternatingpoursuit son horloge — son alternance ne rencontre jamais le nouvel équilibre ;noisysuit partiellement — il écoute le jeu, avec un tremblement ;best_responsesuit à 100%. Trois réactions différentes au même swap, émergentes de trois règles. - StagHunt + R12 : le swap retire DD du set d’équilibres (CC, DD → CC, output) sans toucher à celui que chacun joue déjà — personne ne bouge, et la mesure ne peut pas discriminer ici. Elle le montre honnêtement.
- La synthèse chiffre les six chambres : quand le swap crée une chambre sans Nash pur (cas cyclique), toutes les colonnes tombent à 0% — non par échec des joueurs, mais parce qu’il n’y a rien à atteindre. La mesure le révèle au lieu de le cacher.
Sur le cas discriminant (Dilemme+C23), la hiérarchie est nette : BR (100%) > noisy (90%) > alternating = sticky (0%). Plus généralement, BR et noisy écoutent le jeu — leurs taux suivent l’équilibre courant — tandis qu’alternating et sticky ne l’écoutent pas : leurs scores post-swap reflètent l’accident de leur règle figée avec le nouvel équilibre, pas une réaction. C’est la structure que E4 mesure explicitement.
4. E4 — Dissociation prédire/agir (mesure explicite)
L’apport (b) du papier : GPT-4 prédit correctement l’alternance en Battle of the Sexes (quand on lui demande « que va jouer l’adversaire ? »), et n’agit pas en conséquence. C’est la dissociation pure.
Pour la mesurer sans appel LLM externe, on utilise la structure du jeu : la prédiction « correcte » est best_response au dernier coup adverse. L’action « réelle » est celle du modèle de joueur. La dissociation = prédiction ≠ action jouée, round par round.
def dissociation_rate(g: OrdinalGame, history: List[Tuple[str, str]]) -> float:
"""
Mesure la dissociation predire/agir : pour chaque round (hors le premier),
la prediction du joueur -- best_response au dernier coup adverse -- differ-t-elle
de l'action reellement jouee ?
"""
if len(history) < 2:
return 0.0
dissociations = 0
for i in range(1, len(history)):
prev_row, prev_col = history[i - 1]
row_a, col_a = history[i]
if row_a != best_response(g, "Row", prev_col):
dissociations += 1
if col_a != best_response(g, "Col", row_a):
dissociations += 1
return dissociations / (2 * (len(history) - 1))
print("=== E4 : Dissociation predire/agir (mesure explicite, 5 modeles) ===")
print("Plus la dissociation est haute, plus le joueur 'sait mais ne suit pas'.")
print()
modes_e4 = [("sticky/C", "sticky_preferred", "C"), ("alternating", "alternating", "C"),
("noisy", "noisy", "C"), ("best_response", "best_response", "C"), ("scot", "scot", "C")]
print(f"{'chambre':14s} " + " ".join(f"{lbl:>12s}" for lbl, _, _ in modes_e4))
for g_name in ["BattleSexes", "StagHunt", "Dilemme", "Chicken", "Harmony", "Coordination"]:
g = CLASSIC_GAMES[g_name]
ds = []
for _, mode, fa in modes_e4:
h = play_repeated(g, n_rounds=10, mode=mode, first_action=fa, seed=0, epsilon=0.2)
ds.append(dissociation_rate(g, h))
print(f"{g_name:14s} " + " ".join(f"{d:12.0%}" for d in ds))=== E4 : Dissociation predire/agir (mesure explicite, 5 modeles) ===
Plus la dissociation est haute, plus le joueur 'sait mais ne suit pas'.
chambre sticky/C alternating noisy best_response scot
BattleSexes 100% 50% 28% 0% 100%
StagHunt 0% 50% 28% 0% 0%
Dilemme 100% 44% 28% 0% 0%
Chicken 100% 50% 28% 0% 100%
Harmony 0% 56% 28% 0% 0%
Coordination 0% 50% 28% 0% 0%
5. Synthèse : ce que le notebook a montré
Cinq observations structurantes :
Paysage de performance (E2) — les cinq modèles de joueur produisent des trajectoires réellement différentes sur les six chambres ; en Dilemme le sticky qui ouvre par C échoue (0%) là où celui qui ouvre par D réussit (100%), et c’est l’inverse en Harmony ; dans les chambres à double équilibre, les deux réussissent. La performance dépend du couple (règle, chambre), pas de la chambre seule.
SCoT, effet chiffré (E2bis) — le joueur de niveau 2 tombe à 0% d’atteinte de Nash en BattleSexes et Chicken, chambres où la BR de niveau 1 converge ; delta nul ailleurs, jamais positif. La prédiction naïve (BR contre mon dernier coup) crée un cercle auto-confirmatoire qui renforce l’attachement à son camp. Différence assumée avec le papier, où le SCoT aide les vrais LLMs : l’écart mesure la profondeur de raisonnement social qu’un vrai modèle déploie et que ce simulateur de niveau 2 n’a pas.
Qui écoute le jeu (E3) — sur Dilemme+C23, le swap déplace l’équilibre : sticky l’ignore (règle aveugle au jeu), alternating l’ignore (règle horloge), noisy le suit avec un tremblement, BR le suit entièrement. La hiérarchie émerge des règles, elle n’est plus codée. Et quand le swap crée une chambre sans Nash pur, toutes les colonnes tombent à 0% — la mesure le révèle au lieu de le cacher.
Dissociation prédire/agir (E4) — dissociation maximale pour le sticky dans les chambres à conflit (il joue C, la prédiction dit D), nulle dans les chambres où C est la prédiction ; 0% pour la BR par construction ; et dissociation forte pour SCoT en chambre à conflit : le joueur de niveau 2 prédit — c’est toute sa règle — et son action n’en décale pas, le cercle se referme sur lui-même.
Le joueur LLM de ce notebook est un simulateur paramétrique — cinq règles explicites, exécutées par le même moteur
simulate_player. Les patterns empiriques du papier (défection permanente en Dilemme, collage à l’option préférée en BoS) y apparaissent comme propriétés de règles, reproductibles sans appel réseau ; l’exercice 1 montre où brancher un vrai modèle pour confronter les deux.
8. Exercices
Cette section rassemble 3 exercices progressifs sur la dissociation prédire/agir.
Exercice 1 — Remplacer le joueur simulé par un vrai LLM (openai-compatible)
L’objectif : remplacer sticky_preferred par un appel provider réel. Quand OPENAI_API_KEY (ou équivalent) est présent dans os.environ, le notebook appelle le modèle ; sinon, il reste sur le stub sticky_preferred. C’est la cellule-type d’extension RECOVERABLE-LOCAL (cf sota-not-workaround.md).
# Exercice 1 : Remplacer le joueur simule par un vrai LLM (openai-compatible)
#
# Configuration (env vars surchargeables, defaut cluster ai-01 vLLM) :
# VLLM_ENDPOINT -- URL du serveur openai-compatible
# (defaut : cluster ai-01 vLLM local)
# VLLM_API_KEY -- cle d'API (defaut : vide ; sans cle, le notebook reste
# executable via le stub C.1 -- definir dans
# .secrets/master.env ou os.environ avant execution)
# VLLM_MODEL -- nom du modele (defaut : qwen3.6-35b-a3b sur cluster ai-01)
#
# La cle de cluster est stockee dans .secrets/master.env (gitignored) -- ne JAMAIS
# la hardcoder ni la mettre en exemple dans le code (secrets-hygiene rule 2).
# Cf scripts/secrets/render_envs.py pour la propagation vers les .env de service.
#
import os, json, urllib.request, urllib.error
from typing import Optional, Dict, List, Tuple
VLLM_ENDPOINT = os.environ.get('VLLM_ENDPOINT', 'http://192.168.0.47:5002')
VLLM_API_KEY = os.environ.get('VLLM_API_KEY', '')
VLLM_MODEL = os.environ.get('VLLM_MODEL', 'qwen3.6-35b-a3b')
# Cassette rejouable : cle = (player, game_name, tuple(history)) -> 'C' ou 'D'
_LLM_CASSETTE = {}
def call_llm_provider(history, player, game_name='Dilemme', cassette=True):
"""
Appelle un modele openai-compatible (vLLM local par defaut, cle VLLM_API_KEY).
Renvoie 'C' ou 'D' selon la convention du notebook (F->C, J->D).
Renvoie None si l'endpoint n'est pas joignable / pas de cle (stub C.1).
Parametres :
- cassette=True : rejoue les appels deja effectues (cle = player+game+history).
C'est le mecanisme de reproductibilite demande par la reserve Hermes.
- Temperature 0 + max_tokens 1 + chat_template_kwargs enable_thinking=False
(cf c.434 vllm-thinking-model-disable-thinking-client-side).
"""
key = (player, game_name, tuple(history))
if cassette and key in _LLM_CASSETTE:
return _LLM_CASSETTE[key]
if not VLLM_API_KEY:
return None
opponent = 'Col' if player == 'Row' else 'Row'
lines = [
f'You are playing a repeated {game_name} game.',
f'On each round, you choose F or J. The other player ({opponent}) also chooses.',
'History (most recent last):',
]
for i, (r, c) in enumerate(history):
mine = r if player == 'Row' else c
lines.append(f' Round {i+1}: F' if mine == 'C' else f' Round {i+1}: J')
lines.append('Choose F or J for the next round. Reply with one character only.')
prompt = '\n'.join(lines)
body = json.dumps({
'model': VLLM_MODEL,
'messages': [{'role': 'user', 'content': prompt}],
'temperature': 0,
'max_tokens': 1,
'chat_template_kwargs': {'enable_thinking': False},
}).encode('utf-8')
req = urllib.request.Request(
f'{VLLM_ENDPOINT}/v1/chat/completions',
data=body,
headers={
'Content-Type': 'application/json',
'Authorization': f'Bearer {VLLM_API_KEY}',
},
method='POST',
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
payload = json.loads(resp.read().decode('utf-8'))
except (urllib.error.URLError, TimeoutError, OSError):
return None
content = payload['choices'][0]['message']['content']
if not content:
return None
ch = content.strip().upper()
if ch not in ('F', 'J'):
return None
action = 'C' if ch == 'F' else 'D'
if cassette:
_LLM_CASSETTE[key] = action
return action
def build_prompt_template(history, player, game_name):
"""Meme template que call_llm_provider, expose pour les etudiants."""
opponent = 'Col' if player == 'Row' else 'Row'
rows = [
f'You are playing a repeated {game_name} game.',
f'On each round, you choose F or J. The other player ({opponent}) also chooses.',
f'History (most recent last):',
]
for i, (r, c) in enumerate(history):
rows.append(f' Round {i+1}: F' if (r == 'C' if player == 'Row' else c == 'C') else f' Round {i+1}: J')
rows.append('Choose F or J for the next round. Reply with one character only.')
return '\n'.join(rows)
def llm_play_repeated(g, n_rounds=5, game_name=None):
"""
Joue g avec un VRAI LLM (vLLM local) sur n_rounds. Chaque round, Row puis Col
appellent call_llm_provider. Cassette rejouable : si la cle est deja connue,
le resultat est restitue sans nouvel appel.
"""
if game_name is None:
game_name = g.name
history = []
for _ in range(n_rounds):
row_a = call_llm_provider(history, 'Row', game_name=game_name)
if row_a is None:
return history
col_a = call_llm_provider(history + [(row_a, '?')], 'Col', game_name=game_name)
if col_a is None:
return history
history.append((row_a, col_a))
return history
print('=== Exercice 1 : appel reel au vLLM local (qwen3.6-35b-a3b) ===')
print(f'Endpoint : {VLLM_ENDPOINT} | Modele : {VLLM_MODEL} | Cle presente : {bool(VLLM_API_KEY)}')
print()
if not VLLM_API_KEY:
print('Pas de cle VLLM_API_KEY -- stub C.1 (notebook reste executable).')
print('=> Configurer VLLM_API_KEY dans .env ou os.environ pour un appel reel.')
else:
g = CLASSIC_GAMES['Dilemme']
print(f'Jeu : {g.name} | 5 rounds | Cassette initiale : {len(_LLM_CASSETTE)} entree(s)')
history = llm_play_repeated(g, n_rounds=5)
if not history:
print('Aucun round joue (endpoint non joignable ou cle invalide).')
else:
seq = ' '.join(r + c for r, c in history)
nash_set = find_pure_nash(g)
nash_rate = sum(1 for r, c in history if (r, c) in nash_set) / len(history)
print(f'Trajectoire : {seq}')
print(f'Taux Nash (DD unique en Dilemme) : {nash_rate:.0%}')
print(f'Cooperation rate : {cooperation_rate(history):.0%}')
print(f'Cassette apres appel : {len(_LLM_CASSETTE)} entree(s)')
print()
cassette_size_before = len(_LLM_CASSETTE)
history_replay = llm_play_repeated(g, n_rounds=5)
cassette_size_after = len(_LLM_CASSETTE)
print(f'Rejeu cassette : trajectoire identique ? {history == history_replay}')
print(f' cassette_size avant/apres : {cassette_size_before} / {cassette_size_after} (egal = pas d appel reseau)')=== Exercice 1 : appel reel au vLLM local (qwen3.6-35b-a3b) ===
Endpoint : http://192.168.0.47:5002 | Modele : qwen3.6-35b-a3b | Cle presente : True
Jeu : Dilemme | 5 rounds | Cassette initiale : 0 entree(s)
Trajectoire : CC CC CC CC CC
Taux Nash (DD unique en Dilemme) : 0%
Cooperation rate : 100%
Cassette apres appel : 10 entree(s)
Rejeu cassette : trajectoire identique ? True
cassette_size avant/apres : 10 / 10 (egal = pas d appel reseau)
Exercice 2 — Caractériser les swaps qui augmentent la dissociation
L’objectif : pour chaque chambre X, lister tous les swaps qui transforment le Nash et mesurer la dissociation sticky vs BR sur chaque swap. Identifier les swaps qui cachent la dissociation (BattleSexes+C12) vs ceux qui la révèlent (Dilemme+C23).
# Exercice 2 : Caracteriser les swaps qui augmenent la dissociation
def dissociation_post_swap(g: OrdinalGame, swap: str,
n_total: int = 20, swap_round: int = 10) -> Tuple[float, float]:
"""
Mesure la dissociation sticky vs BR apres le swap.
Retourne (dissociation_sticky_post, dissociation_BR_post) en pourcentage.
"""
h_sticky = play_with_swap(g, swap, swap_round=swap_round, n_total=n_total, mode="sticky_preferred")
h_br = play_with_swap(g, swap, swap_round=swap_round, n_total=n_total, mode="best_response")
d_sticky = dissociation_rate(g, h_sticky[swap_round:]) # post-swap seulement
d_br = dissociation_rate(g, h_br[swap_round:])
return d_sticky, d_br
# Indice etudiant : pour chaque chambre X, iterer sur tous les swaps R{i}{j} et C{i}{j}
# valides (0 <= i < j <= 3), mesurer dissociation_post_swap(X, swap), et retourner
# les swaps qui maximisent la dissociation sticky vs BR.
def find_max_dissociation_swap(g: OrdinalGame) -> List[Tuple[str, float, float]]:
"""
Pour la chambre g, retourne les swaps (swap, d_sticky, d_br) tries par
dissociation sticky decroissante.
"""
# TODO etudiant : iterer sur tous les swaps valides (6 R-swaps + 6 C-swaps),
# appeler dissociation_post_swap, et retourner la liste triee.
return [] # Stub C.1
# Demonstration partielle : sur Dilemme
print("=== Exercice 2 : swaps qui revelent la dissociation sur Dilemme ===")
results_dilemme = []
for i in range(4):
for j in range(i+1, 4):
for kind in ["R", "C"]:
swap = f"{kind}{i}{j}"
d_s, d_b = dissociation_post_swap(g=CLASSIC_GAMES["Dilemme"], swap=swap)
results_dilemme.append((swap, d_s, d_b))
# Trier par dissociation sticky decroissante
results_dilemme.sort(key=lambda x: -x[1])
print(f"{'Swap':6s} {'d_sticky_post':15s} {'d_BR_post':15s}")
for swap, d_s, d_b in results_dilemme[:6]:
print(f"{swap:6s} {d_s:>13.0%} {d_b:>13.0%}")=== Exercice 2 : swaps qui revelent la dissociation sur Dilemme ===
Swap d_sticky_post d_BR_post
R01 100% 50%
C01 100% 0%
R02 100% 0%
C02 100% 50%
R03 100% 0%
C03 100% 0%
Exercice 3 — Cyclicité de BattleSexes : formalisation en logique modale
L’objectif : proposer un encodage non-transitif de BattleSexes qui préserve ses deux Nash (C,D) et (D,C). Indice : utiliser des préférences lexicographiques ou une logique modale KD45.
# Exercice 3 : Cyclicite de BattleSexes -- formalisation non-transitive
# Indice : pour representer BoS canonique avec 2 Nash (C,D) et (D,C), il faut
# autoriser des preferences NON transitives (le joueur peut preferer C a D,
# D a (C,C), et (C,C) a C, etc.). Une solution : utiliser des "circles de
# preference" plutot que des rangs lineaires.
from typing import Dict, Set # noqa: E402 (import local pour la cellule exercice)
def best_response_nontransitive(g_cyclic: Dict[Tuple[str, str], Set[str]],
player: str, opponent_action: str) -> str:
"""
Pour une representation cyclique des preferences :
g_cyclic[action] = ensemble des actions strictement preferees.
Retourne la meilleure reponse selon cette relation cyclique.
Indice etudiant :
- Si g_cyclic[(C,C)] contient D, alors C < D quand (C,C) est joue
- Si g_cyclic[(D,C)] contient C, alors D < C quand (D,C) est joue
- Pour BattleSexes : definir les 4 ensembles cycliques
"""
# TODO etudiant : definir les 4 ensembles cycliques pour BattleSexes
# et implementer la selection d'action
return "C" # Stub C.1
# Demonstration : pour BattleSexes canonique, une representation cyclique
# pourrait etre :
# - En (C,C) : Row prefere C (relation C < D, i.e. D prefere)
# - En (C,D) : Row prefere D (relation C > D, i.e. C prefere encore)
# - En (D,C) : Row prefere C (relation D < C)
# - En (D,D) : Row prefere C (relation D > C)
# Cette relation est CYCLIQUE : C < D < C, violant la transitivite.
print("=== Exercice 3 : cyclicite de BattleSexes ===")
print("Representation cyclique possible :")
print(" (C,C) : Row prefere C | (C,D) : Row prefere D")
print(" (D,C) : Row prefere C | (D,D) : Row prefere C")
print()
print("Cycle : C < D (en CC) -> D < C (en CD) -> C < D (en DC) -> D < C (en DD)")
print("=> Non representable en ordinal strict transitif.")
print("=> Stub : completer best_response_nontransitive avec les 4 ensembles.")=== Exercice 3 : cyclicite de BattleSexes ===
Representation cyclique possible :
(C,C) : Row prefere C | (C,D) : Row prefere D
(D,C) : Row prefere C | (D,D) : Row prefere C
Cycle : C < D (en CC) -> D < C (en CD) -> C < D (en DC) -> D < C (en DD)
=> Non representable en ordinal strict transitif.
=> Stub : completer best_response_nontransitive avec les 4 ensembles.
9. Conclusion
Le joueur LLM simulé est un modèle paramétrique : cinq règles explicites (sticky paramétrée par sa première action, alternating, noisy, best_response, scot), toutes exécutées par le même moteur simulate_player — aucun comportement n’est écrit en dur, tout émerge de la règle round après round.
BattleSexes, Dilemme, Chicken : dissociation maximale pour le sticky — il colle à son option d’ouverture et n’atteint pas l’équilibre. C’est le pattern empirique du papier (Mei et al. 2025), obtenu ici comme propriété de la règle.
StagHunt, Harmony, Coordination : dissociation nulle pour le sticky — l’option d’ouverture C coïncide avec l’équilibre. Le joueur y performe « bien » par accident de ses préférences, pas par compréhension.
SCoT : la transmutation gratuite chiffrée — et elle échoue dans ce simulateur (0% en BoS/Chicken) là où le papier observe qu’elle aide les vrais LLMs. La leçon : ce n’est pas prédire qui coordonne, c’est prédire récursivement — l’adversaire qui me prédit aussi. L’écart simulé/réel est la signature de la profondeur de raisonnement social.
L’apport conceptuel de ce notebook est de montrer que la dissociation (b) du papier — GPT-4 prédit l’alternance et n’agit pas — est une propriété structurelle des chambres à conflit (BoS, Chicken), pas un artefact du modèle. Et la grammaire R-G (swaps en cours de partie) permet de révéler la dissociation quand elle est accidentellement cachée (E3).
Substrat EPITA (#12254) : les personas de 2025-Epita-Intelligence-Symbolique permettent l’extension directe — l’agent qui anticipe le contre-argument le réfute-t-il ? Plusieurs agents en désaccord sur la méta-action ? Ces questions héritent la grammaire de dissociation et la mesure explicite.
Sources
- Mei et al., Playing Repeated Games with Large Language Models, Nature Human Behaviour (2025), s41562-025-02172-y — page lue 2026-08-22.
- Robinson & Goforth, The Topology of the 2x2 Games (2005) — implémentation dans
GameTheory-3cellule 5 (OrdinalGame). - Bruns, Austausch und Gerechtigkeit (1975) — notion de transmutation (information nouvelle vs déplacement), voir aussi GameTheory-3h (Loi III, transformations vs morphismes).
- GameTheory-3 (chambres R-G) : GameTheory-03-Topology2x2-Python.ipynb
- GameTheory-3h (morphisme fini, swaps préservants) : GameTheory-03g-Deux-Especes-de-Fleches-Python.ipynb
Navigation : GameTheory-3 · GameTheory-03d-Le-Joueur-LLM-Python · GameTheory-3g