import numpy as np
rng = np.random.default_rng(42)
print("Setup OK - simulation Wargames R12 §5, numpy uniquement")Setup OK - simulation Wargames R12 §5, numpy uniquement
Ce notebook reproduit le scenario Wargames de R12 Section 5 (Engels et al, NeurIPS 2025, arXiv:2504.18530) en simulation stochastique pure, sans LLM externe.
Le protocole 3 roles :
d_strength ∈ [0,1]).judge_noise ∈ [0, 0.5].Mesures :
p_attacker) en fonction de K, d_strength, n_questions.p_judged contre p_real sur le meme balayage (l’ecart-type inter-episodes n’est pas mesure).Source : R12 Section 5. PDF archive hors depot : G:\Mon Drive\MyIA\IA\Bibliographie IA\XAI\2025 - Engels et al - Scaling Laws For Scalable Oversight.pdf (sha8 FDA29C9A).
Auto-contenu : pas de GPU, pas de LLM externe. Simulation pure, 100% reproductible (seed = 42).
On formalise le scenario 3 roles :
s ∈ {0, 1, ..., K-1} aleatoirement.n_questions) :
True/False avec une probabilite 1 - d_strength d’etre correct (force de defense : d_strength = 1 = defense parfaite, mente systematiquement ; d_strength = 0 = revele toujours la verite).judge_noise (le Judge peut se tromper avec cette probabilite).Cas degeneres :
d_strength = 0 (Defender transparent) : Attacker reussit des la 1ere question.K = 2 (binaire) : Attacker reussit en log2(K) questions en moyenne.judge_noise = 0 : le verdict est deterministe (utile pour calibration).class Defender:
"""Defender avec force de defense d_strength (1 = parfait menteur, 0 = transparent)."""
def __init__(self, secret, K, d_strength, rng):
self.secret = secret
self.K = K
self.d_strength = d_strength
self.rng = rng
def answer(self, subset):
"""subset est un set d'indices candidats. Retourne True si secret ∈ subset."""
truth = self.secret in subset
if self.rng.random() < self.d_strength:
return not truth # mente
return truth
class BayesianAttacker:
"""Attacker bayesien : maintient une posterior sur le secret, met a jour a chaque reponse."""
def __init__(self, K, rng):
self.K = K
self.rng = rng
# posterior uniforme au depart
self.posterior = np.ones(K) / K
def reset(self):
self.posterior = np.ones(self.K) / self.K
def best_guess(self):
"""Renvoie l'indice avec la posterior la plus haute."""
return int(np.argmax(self.posterior))
def update(self, subset, answer):
"""Met a jour la posterior selon la formule de Bayes."""
# P(subset | s ∈ subset ∨ s ∉ subset) depends on d_strength mais on simplifie :
# on suppose d_strength = 0 (Defender transparent) pour la version de base
# version sophistiquee : on apprendrait aussi le d_strength
likelihood = np.zeros(self.K)
for s in range(self.K):
likelihood[s] = 1.0 if ((s in subset) == answer) else 0.0
# Si d_strength > 0, le likelihood s'inverse selon la proba de mentir
# Version simplifiee : on suppose Defender transparent
self.posterior *= likelihood
if self.posterior.sum() == 0:
self.posterior = np.ones(self.K) / self.K
else:
self.posterior /= self.posterior.sum()
def ask_question(self, posterior):
"""Question binaire informee : coupe la posterior en 2 moities les plus equilibrees."""
sorted_idx = np.argsort(posterior)[::-1]
cumulative = 0
half = posterior.sum() / 2
split = 1
for i, idx in enumerate(sorted_idx):
cumulative += posterior[idx]
if cumulative >= half:
split = i + 1
break
return set(sorted_idx[:split].tolist())
print("Classes Defender et BayesianAttacker OK")Classes Defender et BayesianAttacker OK
def run_episode(K, d_strength, n_questions, rng):
"""Execute un episode Wargames et renvoie (succes, n_questions_utilisees)."""
secret = int(rng.integers(0, K))
defender = Defender(secret, K, d_strength, rng)
attacker = BayesianAttacker(K, rng)
for q in range(n_questions):
subset = attacker.ask_question(attacker.posterior)
answer = defender.answer(subset)
attacker.update(subset, answer)
# Si la posterior est deja concentree, on peut s'arreter
if attacker.posterior.max() > 0.99:
break
guess = attacker.best_guess()
return guess == secret, q + 1
print("run_episode(K, d_strength, n_questions, rng) -> (succes, n_used)")run_episode(K, d_strength, n_questions, rng) -> (succes, n_used)
class Judge:
"""Evaluateur final : decide si Attacker a identifie le secret, avec bruit judge_noise."""
def __init__(self, judge_noise, rng):
self.judge_noise = judge_noise
self.rng = rng
def evaluate(self, guess, secret):
"""Renvoie True si Judge declare succes, False sinon.
judge_noise = 0 : deterministe (True ssi guess == secret).
judge_noise > 0 : bruite le verdict."""
truthful = (guess == secret)
if self.rng.random() < self.judge_noise:
return not truthful # bruite
return truthful
def run_episode_judged(K, d_strength, n_questions, judge_noise, rng):
"""Episode complet : Defender + Attacker + Judge (avec bruit)."""
secret = int(rng.integers(0, K))
defender = Defender(secret, K, d_strength, rng)
attacker = BayesianAttacker(K, rng)
judge = Judge(judge_noise, rng)
for q in range(n_questions):
subset = attacker.ask_question(attacker.posterior)
answer = defender.answer(subset)
attacker.update(subset, answer)
if attacker.posterior.max() > 0.99:
break
guess = attacker.best_guess()
real_success = (guess == secret)
judged_success = judge.evaluate(guess, secret)
return real_success, judged_success, q + 1
# Sweep sur n_questions (demi-vie de controle) avec judge fixe
print("Sweep demi-vie : K=10, d_strength=0.3, judge_noise=0.15, 500 episodes par point")
n_q_values = [1, 3, 5, 10, 15, 20]
results_half_life = {}
for n_q in n_q_values:
episodes = [run_episode_judged(10, 0.3, n_q, 0.15, rng) for _ in range(500)]
real_succ = sum(1 for r, j, _ in episodes if r)
judged_succ = sum(1 for r, j, _ in episodes if j)
avg_q = float(np.mean([q for _, _, q in episodes]))
results_half_life[n_q] = {
"p_real": real_succ / 500,
"p_judged": judged_succ / 500,
"avg_questions": avg_q,
}
print()
header_fmt = "{:>4} {:>8} {:>10} {:>7}"
print(header_fmt.format("n_q", "p_real", "p_judged", "avg_Q"))
print("-" * 36)
for n_q in n_q_values:
r = results_half_life[n_q]
print(header_fmt.format(str(n_q), f"{r['p_real']:.3f}", f"{r['p_judged']:.3f}", f"{r['avg_questions']:.1f}"))Sweep demi-vie : K=10, d_strength=0.3, judge_noise=0.15, 500 episodes par point
n_q p_real p_judged avg_Q
------------------------------------
1 0.144 0.268 1.0
3 0.282 0.366 3.0
5 0.296 0.370 3.2
10 0.314 0.342 3.2
15 0.258 0.314 3.2
20 0.298 0.346 3.2
p_real grimpe de 0.144 (1 question) a un plateau ~0.3 des 3 questions (0.282 / 0.296 / 0.314) - jamais 0.5 : la demi-vie de controle n’est pas definie dans ce regime. avg_Q cale a 3.2 des n_q=5 : l’arret anticipe posterior.max() > 0.99 coupe l’interrogation.
Cote Judge : p_judged (0.268-0.370) surestime systematiquement p_real (0.144-0.314) - un bruit de judge a 0.15 ajoute ~0.1 de verdicts faux positifs, l’ecart est le signal de supervision, pas un artefact. L’ecart-type inter-episodes des evaluations Judge n’est pas mesure ici (une moyenne par point) - la promesse en est retiree de l’intro.
# Parametres du sweep
K_values = [4, 10, 25, 100]
d_strength_values = [0.0, 0.1, 0.3, 0.5, 0.8]
n_questions_max = 20
n_episodes = 500
print(f"Sweep {len(K_values)} K x {len(d_strength_values)} d_strength x {n_episodes} episodes")
print(f"Total : {len(K_values) * len(d_strength_values) * n_episodes} episodes simules")
print(f"Cible : {n_questions_max} questions max par episode")Sweep 4 K x 5 d_strength x 500 episodes
Total : 10000 episodes simules
Cible : 20 questions max par episode
# Executer le sweep
results = {}
for K in K_values:
for d in d_strength_values:
episodes = [run_episode(K, d, n_questions_max, rng) for _ in range(n_episodes)]
successes = sum(1 for s, _ in episodes if s)
avg_questions = np.mean([q for _, q in episodes])
results[(K, d)] = {
"p_success": successes / n_episodes,
"avg_questions": avg_questions,
"n_success": successes,
"n_episodes": n_episodes,
}
# Affichage compact
print(f"\n{'K':>5} {'d_str':>6} {'p_succ':>8} {'avg_Q':>7} {'n_succ':>7}/{'n_eps':>6}")
print("-" * 50)
for K in K_values:
for d in d_strength_values:
r = results[(K, d)]
print(f"{K:>5} {d:>6.2f} {r['p_success']:>8.3f} {r['avg_questions']:>7.1f} {r['n_success']:>7}/{r['n_episodes']:>6}")
K d_str p_succ avg_Q n_succ/ n_eps
--------------------------------------------------
4 0.00 1.000 2.0 500/ 500
4 0.10 0.806 2.0 403/ 500
4 0.30 0.490 2.0 245/ 500
4 0.50 0.226 2.0 113/ 500
4 0.80 0.038 2.0 19/ 500
10 0.00 1.000 3.4 500/ 500
10 0.10 0.718 3.3 359/ 500
10 0.30 0.302 3.2 151/ 500
10 0.50 0.102 3.3 51/ 500
10 0.80 0.010 3.6 5/ 500
25 0.00 1.000 4.7 500/ 500
25 0.10 0.592 4.6 296/ 500
25 0.30 0.170 4.4 85/ 500
25 0.50 0.038 4.5 19/ 500
25 0.80 0.002 4.8 1/ 500
100 0.00 1.000 6.7 500/ 500
100 0.10 0.476 6.4 238/ 500
100 0.30 0.094 6.4 47/ 500
100 0.50 0.008 6.6 4/ 500
100 0.80 0.000 6.9 0/ 500
Regimes observes :
d_strength = 0) : Attacker atteint ~100% en ~log2(K) questions (mesure : avg_Q = 2.0 / 3.4 / 4.7 / 6.7 pour K = 4 / 10 / 25 / 100 - recherche binaire avec arret anticipe des que le posterior culmine a > 0.99).d_strength = 0.1-0.3) : p_success decroit avec K et avec d_strength — a K=4 : 0.806 (d=0.1) et 0.490 (d=0.3) ; a K=100 : 0.476 et 0.094. Le regime reste au-dessus du hasard 1/K, mais seul d = 0.1 avec K <= 10 depasse 0.7 ; des K = 25 la reussite tombe a 0.592 (d=0.1) et 0.170 (d=0.3).d_strength = 0.5) : le Defender est essentiellement aleatoire. Attacker tombe a 1/K environ.d_strength = 0.8) : Defender presque parfait. Le posterior se concentre sur le candidat miroir que designe le mensonge systematique : a K=4, p = 0.038, sous le hasard 1/K = 0.25 - l’Attacker est activement trompe, pas seulement aveugle.Demi-vie de controle : non atteinte dans ce regime. A K=10, d_strength=0.3, p_real plafonne a ~0.3 des 3 questions (0.282 a 0.314, cf. balayage) - l’Attacker ne franchit jamais 50%, et avg_Q cale a 3.2 (l’arret anticipe posterior.max() > 0.99 coupe l’interrogation avant que davantage de questions puissent aider).
PR 2 a montre que NSO close-form n* = log(q)/log(1 - D/400) decompose le regime Houdini-vs-Guard en Elo oversight-specifique. Wargames ajoute une dimension : la strategie defensive du Defender.
Hypothese : le gap Elo D dans Wargames depend de d_strength et K. Plus le Defender est fort, plus le gap est grand, plus le regime NSO degrade.
On approxime : D ≈ f(d_strength) = -400 * log10(K * (1 - d_strength) + d_strength) (calibration grossiere, le K multiplie l’ambiguite de la question).
Pour un Defender ‘parfait’ (d_strength = 1.0) : D = +0.0 quel que soit K (le terme K*(1-d) + d vaut 1, log10(1) = 0) - mesure dans la table ci-dessous. Sur toute la grille, D est negatif et < 400 : n* rend NaN partout, regime « Attacker dominant » - ce mapping heuristique ne produit aucun point d’equilibre NSO.
Pour un Defender ‘moyen’ (d_strength = 0.3, K = 10) : D ≈ -400 * log10(7.3) ≈ -345 Elo — meme regime « Attacker dominant » que le reste de la grille. La saturation NSO est a l’autre extremite (D → +400, cf PR 2), et le point le plus proche du domaine (D > 0) est le Defender parfait (d = 1.0), a D = +0.0 : exclu lui aussi, la borne etant stricte.
Pour un Defender ‘faible’ (d_strength = 0.0, K = 10) : D ≈ -400 * log10(10) ≈ -400 Elo (mais le Defender revele tout, donc le Guard n’a pas besoin de superviser).
Ce que cette table ne peut pas trancher : l’hypothese de l’intro (« plus le Defender est fort, plus le regime NSO degrade ») n’est pas observable ici — n* vaut NaN sur les 24 points de la grille, donc aucune valeur ne peut monter ni descendre. Ce qui est mesure est plus etroit : D_approx reste du cote « Attacker dominant » partout, et ne fournit aucun point d’entree au formalisme NSO.
def D_approx(d_strength, K):
"""Approximation du gap Elo Wargames selon d_strength et K.
Convention R12 : D > 0 = Defender plus fort qu'Attacker, D < 0 = Attacker
plus fort. Ici D est *negatif* car le Defender n'a pas d'avantage Elo
intrinsque : on mesure l'ambiguite du secret K et la force de brouillage.
Domaine : la close-form NSO `n* = log(q)/log(1 - D/400)` n'a un sens que
pour 0 < D < 400. Sur toute la grille mesuree plus bas, D reste <= 0 : cette
approximation ne produit aucun point exploitable par le formalisme NSO, et
elle ne mesure donc aucune « degradation » du regime NSO.
"""
effective_K = K * (1 - d_strength) + d_strength
if effective_K <= 1:
return 0 # Defense triviale, pas de gap
return -400 * np.log10(effective_K)
def n_star_wargames(d_strength, K, q):
"""n* pour Wargames. Renvoie NaN hors domaine (D <= 0 ou D >= 400).
Convention R12 : D > 0 = Defender plus fort, n* > 0 = plusieurs niveaux.
Pour D <= 0 la close-form rend un n* *negatif* (log(1 - D/400) > 0 alors que
log(q) < 0), qui ne s'interprete pas comme un nombre de niveaux : on marque
NaN plutot que de lui preter le sens « un seul niveau suffit ».
"""
D = D_approx(d_strength, K)
if D <= 0 or D >= 400:
return float("nan")
if q <= 0.5 or q >= 1.0:
return float("nan")
base = 1 - D / 400
if base <= 0:
return float("nan")
val = np.log(q) / np.log(base)
if not np.isfinite(val):
return float("nan")
return val
print(f"{'d_str':>6} {'K':>5} {'D':>7} {'n*(q=0.8)':>11} {'n*(q=0.95)':>12} {'regime':>20}")
print("-" * 65)
for d in [0.0, 0.1, 0.3, 0.5, 0.8, 1.0]:
for K in [4, 10, 25, 100]:
D = D_approx(d, K)
n1 = n_star_wargames(d, K, 0.8)
n2 = n_star_wargames(d, K, 0.95)
if D <= 0:
regime = "Attacker dominant"
elif D >= 400:
regime = "Defender sature"
else:
regime = "NSO exploitable"
n1_str = f"{n1:>+11.3f}" if np.isfinite(n1) else " NaN"
n2_str = f"{n2:>+12.3f}" if np.isfinite(n2) else " NaN"
print(f"{d:>6.2f} {K:>5} {D:>+7.1f} {n1_str} {n2_str} {regime:>20}") d_str K D n*(q=0.8) n*(q=0.95) regime
-----------------------------------------------------------------
0.00 4 -240.8 NaN NaN Attacker dominant
0.00 10 -400.0 NaN NaN Attacker dominant
0.00 25 -559.2 NaN NaN Attacker dominant
0.00 100 -800.0 NaN NaN Attacker dominant
0.10 4 -227.3 NaN NaN Attacker dominant
0.10 10 -383.6 NaN NaN Attacker dominant
0.10 25 -541.6 NaN NaN Attacker dominant
0.10 100 -781.9 NaN NaN Attacker dominant
0.30 4 -196.5 NaN NaN Attacker dominant
0.30 10 -345.3 NaN NaN Attacker dominant
0.30 25 -500.2 NaN NaN Attacker dominant
0.30 100 -738.8 NaN NaN Attacker dominant
0.50 4 -159.2 NaN NaN Attacker dominant
0.50 10 -296.1 NaN NaN Attacker dominant
0.50 25 -445.6 NaN NaN Attacker dominant
0.50 100 -681.3 NaN NaN Attacker dominant
0.80 4 -81.6 NaN NaN Attacker dominant
0.80 10 -178.9 NaN NaN Attacker dominant
0.80 25 -305.4 NaN NaN Attacker dominant
0.80 100 -527.2 NaN NaN Attacker dominant
1.00 4 +0.0 NaN NaN Attacker dominant
1.00 10 +0.0 NaN NaN Attacker dominant
1.00 25 +0.0 NaN NaN Attacker dominant
1.00 100 +0.0 NaN NaN Attacker dominant
La table dit une chose simple et décisive : sur toute la grille (K ∈ {4, 10, 25, 100}, d ∈ {0, 0.1, 0.3}), D reste ≤ 0 — colonne « Attacker dominant » de bout en bout, et n* vaut NaN partout. Ce n’est pas un bug de calcul : D_approx vaut -400·log10(K(1-d) + d), quantité structurellement négative dès que K > 1, car elle mesure l’ambiguïté du secret, pas un avantage du Defender. La conséquence est forte : dans cette paramétrisation, le régime NSO (0 < D < 400) n’est jamais atteint, et le close-form ne peut donc rien prédire ici. Le notebook le dit honnêtement au lieu de forcer un chiffre.
Le sweep de la cellule précédente donne la contrepartie mesurée de cette impasse : quand le brouillage croît, ce n’est pas le nombre de questions qui bouge (avg_Q reste calé sur log2(K) : 2.0, 3.4, 4.7, 6.7) mais le taux de succès qui s’effondre (K=10 : 1.000 → 0.010 entre d=0 et d=0.8). La ressource rare n’est pas l’interrogation, c’est la fiabilité de chaque réponse.
Le sweep montre p_success qui chute de 1.000 à 0.010 (K=10) entre d=0 et d=0.8, alors que avg_Q ne bouge presque pas. Précisez la transition.
run_episode).Indice : chaque question du Judge porte sur une réponse bruitée à probabilité d ; l’attaquant bayésien a besoin de plusieurs réponses concordantes — le log de p_success devrait être proche d’affine en d.
# Exercice 1 : localiser l'effondrement de p_success (K=10)
# Etape 1 : p_success pour d in {0.55, 0.6, 0.65, 0.7, 0.75}, 500 episodes
# Etape 2 : echelle log -- decroissance exponentielle ? taux ?
# Etape 3 : d tel que p_success = 0.5 par interpolation
# TODO etudiant
p_succ_fins = None # TODO etudiant : dict {d: p_success}
d_demi = None # TODO etudiant : d ou p_success = 0.5
print("Exercice 1 a completer : effondrement de p_success")Exercice 1 a completer : effondrement de p_success
À d=0, avg_Q vaut 2.0 (K=4), 3.4 (K=10), 4.7 (K=25), 6.7 (K=100) — des valeurs proches de log2(K), sans écart constant apparent. Vérifiez cette loi.
results (le dictionnaire du sweep, d=0.0), extrayez avg_Q pour les quatre K.np.polyfit sur (log2 K, avg_Q>. L’attaquant par dichotomie postérieure paie-t-il exactement le log2, ou un peu plus ?Indice : un écart qui grandirait avec K trahirait un coût non logarithmique ; un écart qui reste petit et stable signalerait au contraire une taxe de terminaison fixe (la dernière question qui confirme au lieu de couper).
# Exercice 2 : avg_Q vs log2(K) -- hypothese affine
# Etape 1 : extraire avg_Q (d=0.0) pour K in {4, 10, 25, 100}
# Etape 2 : ecart avg_Q - log2(K) par K
# Etape 3 : np.polyfit sur (log2 K, avg_Q) -- pente et ordonnee
# TODO etudiant
pente = None # TODO etudiant : pente du fit affine
ecarts_log = None # TODO etudiant : liste avg_Q - log2(K)
print("Exercice 2 a completer : cout informationnel de K")Exercice 2 a completer : cout informationnel de K
La Lecture de la section 2 l’affirme : D_approx ≤ 0 partout dès K > 1, donc n* est toujours NaN. Démontrez-le proprement, puis cherchez ce qu’il faudrait changer.
D_approx (par exemple un avantage Elo défensif additif quand d < seuil) qui rende le régime (0, 400) atteignable, et dites ce que la modification signifie conceptuellement.Indice : l’étape 1 est une inégalité à une ligne ; l’étape 3 n’a pas de réponse unique — c’est la discussion de design qui compte.
# Exercice 3 : le regime NSO (0 < D < 400) est-il atteignable ici ?
# Etape 1 : preuve algebrique K(1-d)+d >= 1 pour K>1, d in [0,1]
# Etape 2 : verification numerique sur grille fine -- aucun D > 0 ?
# Etape 3 : modification minimale de D_approx rendant (0,400) atteignable
# TODO etudiant
D_max_grille = None # TODO etudiant : max de D_approx sur la grille
proposition = None # TODO etudiant : description de la modification
print("Exercice 3 a completer : atteignabilite du regime NSO")Exercice 3 a completer : atteignabilite du regime NSO
p_real plafonne a ~0.3 des 3 questions (K=10, d=0.3). L’ordre de grandeur operationnel est un plateau, pas une demi-vie.D_approx(d_strength, K) permet d’etendre le formalisme R12 au cas Wargames, mais reste grossiere (le vrai mapping Elo/Defense Strategy demanderait une calibration empirique sur LLM reels).D_approx est une heuristique, pas une mesure. La vraie calibration demanderait des experiences Wargames avec LLMS sur les 4 jeux R12.Wargames formalise un scenario que nous jouons en pratique : quand un coordinateur demande a un worker de prouver qu’un livrable est valide (le ‘secret’ = le contenu reel), un reviewer peut soit valider sans regarder (Defender transparent), soit appliquer un audit strict (Defender fort). Le ‘Judge’ dans R12 = notre checker Hermes/NanoClaw qui evalue la qualite du livrable.
Pour notre cluster : d_strength correspond a la probabilite de reponse non conforme du worker (un taux de conformite observe de ~85% equivaut a d = 0.15), K au nombre de possibilites de sortie (~10-50 selon la complexite du grain), et le Judge = Hermes/NanoClaw avec judge_noise ≈ 0.1-0.2 (faux positifs/negatifs).
Implication operationnelle : la table NSO ne tranche pas - n* rend NaN sur toute la grille (D negatif, < 400 partout, regime « Attacker dominant »). Le choix du nombre de niveaux de Judge reste empirique (coordinateur + bots specialises pour les grains complexes).
Oversight/Oversight-Statistics.ipynb : module stats consolidé final (Elo + L-BFGS-B + AIC + NSO + Wargames). Sur les 4 notebooks : Nim (mesure), Analytics (close-form), Wargames (simulation), Statistics (consolidation).Sources : - R12 - Engels, Baek, Kantamneni, Tegmark. Scaling Laws For Scalable Oversight. NeurIPS 2025. arXiv:2504.18530, Section 5. - Sub-grain #16754 (T13 distillation corpus Tegmark) - EPIC #16741.