Files
pf1e-simulator/docs/phase-2-llm-architecture.md
T

34 KiB
Raw Blame History

Phase 2 — Architecture et plan d'implémentation de la couche de décision LLM

Document de conception (révisé). Aucun code n'est encore écrit. Révision intégrant deux demandes : optimiser la meilleure stratégie de survie et cartographier tous les cas où un camp est en difficulté.

1. Objectif et périmètre

Le simulateur doit répondre à trois questions distinctes, souvent confondues :

Question Nature Réponse apportée par
« Comment exécuter une tactique donnée ? » Exécution Étage A (compilé) / Étage B (LLM par tour)
« Quelle est la meilleure stratégie de survie ? » Optimisation Couche Optimiseur (recherche sur StrategySpec)
« Où les PJ (ou les monstres) sont-ils en difficulté ? » Robustesse adversariale Couche Robustesse (min-max + cartographie de scénarios)

Hors périmètre : la rédaction finale du rapport d'équilibrage (phase 3).

Principe directeur : les règles du moteur restent inchangées ; la couche LLM et les couches d'analyse sont additives. La LLM n'est ni l'optimiseur ni l'exécutant des sweeps.

2. Exécution — les deux étages (rappel)

Mettre la LLM à chaque tour dans une boucle Monte Carlo est prohibitif (runs × rounds × combattants appels, caches non mutualisables). D'où :

  • Étage A — stratégie compilée (quantitatif). La LLM traduit une stratégie NL en un objet de paramètres (StrategySpec) ; un compilateur déterministe en fait une Policy Python exécutée sans appel LLM. → bandes valides, coût maîtrisé, déterminisme bit-à-bit.
  • Étage B — LLM par tour (qualitatif, vitrine). NLPolicy(observation) à chaque tour, sur ≤ 20 runs. → démonstration du raisonnement, transcripts exploitables ; pas de conclusion statistique.

default_policy devient un cas particulier : compile_strategy(SPEC_PAR_DEFAUT).

3. Décider — les deux nouvelles couches

L'étage A fournit une fonction d'évaluation déterministe : c'est le bon substrat pour chercher et pour éprouver.

3.1 Optimiseur — « meilleure stratégie de survie »

  • Espace : les leviers de StrategySpec (par PJ ou par rôle).
  • Oracle : evaluate(spec, scenario, n, seeds) → métriques (runs parallèles).
  • Objectifs : P(survivre tous), P(TPK), P(≥1 mort), espérance de PV perdus, ressources consommées, rounds, risque de queue (CVaR / pire décile).
  • Algorithmes : grille grossière / échantillonnage latin → affinage (descente coordonnée, CMA-ES, optimisation bayésienne) ; la LLM peut amorcer avec des stratégies candidates (NL → StrategySpec).
  • Sortie : un playbook classé (« si groupés : Buff → Contrôle → Focus lanceur ; reculer sous 30 % PV ») avec incertitude.

3.2 Robustesse et cartographie — « où est le danger »

Un danger n'existe que contre un adversaire qui cherche à gagner : évaluer les PJ contre la politique par défaut sous-estime le risque. Il faut :

  • AdversarySpec (symétrique de StrategySpec) : priorité de cible, focus fire, usage des capacités, embuscade, positionnement.
  • Min-max : pour chaque scénario, max_politique_PJ · min_politique_monstres (optimisation alternée = meilleures réponses) → pire cas réaliste. La même mesure donne les deux sens : difficulté pour les PJ et pour les monstres (rencontre trop facile).
  • Cartographie (au lieu d'une énumération impossible) :
    1. grille grossière sur ScenarioSpec → cellules d'échec ;
    2. affinage près de la frontière (bissection / apprentissage actif) ;
    3. frontière de danger : pour chaque mix, le seuil (ex. « < 45 % PV cumulés et sans soigneur à portée ⇒ P(TPK) > 20 % ») ;
    4. analyse de sensibilité : facteurs dominants (surprise, terrain, épuisement des ressources, initiative).

4. État des lieux (socle vérifié)

Élément Emplacement Vérifié
Observation figée, to_dict() JSON, sans engine.* combat.py:1262 / 1310 oui
Policy / LLMPolicy / ReactionPolicy combat.py:10573/10575/10577 oui
Câblage policy/llm_policy exclusifs combat.py:1615-1623 oui
ActionResult + ACTION_RESULT_REASONS + last_turn_results combat.py:1483, 1528 oui
Démo « observation seule » tests/test_llm_engine_capabilities.py oui
Déterminisme SeededRng(seed+i) + métriques 3σ + attrition runner.py, metrics.py oui
Base de règles conforme docs/conformance-srd.md (gate 1981) oui

Manques confirmés : run_encounter sans llm_policy (runner.py:177) ; CLI sans politique ; pas de schema_version ; pas de vue compacte ; pas d'API d'actions légales ; pas de mémoire inter-tours ; pas de journal de replay ; LLMPolicy(obs, state) transmet l'état vivant ; aucune couche d'objectifs, de recherche, d'adversaire ou de scénarios.

5. Architecture cible

Moteur (règles inchangées ; frontière additive)
  |
  |-- EXÉCUTION
  |     |- Étage A : compile_strategy(StrategySpec) -> Policy   (sweeps)
  |     \-- Étage B : NLPolicy(observation)                     (vitrine <=20 runs)
  |
  |-- DÉCISION / ANALYSE
  |     |- objectives.py  : métriques de survie/danger (dont queue)
  |     |- scenarios.py   : ScenarioSpec + échantillonnage
  |     |- search.py      : optimiseur (objectif = objectives), oracle = simulateur
  |     |- adversary.py   : AdversarySpec + politiques de monstres fortes (red team)
  |     \-- robustness.py : min-max, cartographie de danger, frontière, sensibilité
  |
  \-- SUPPORT
        |- view.py    : ObservationView (objet structuré, versionné) + render()
        |- candidates.py : actions plausibles dérivées des pairs
        |- prompt.py  : chargement EXTERNE (pack séparé) + versionnage
        |- client.py  : LLMClient + cache composite + FakeLLMClient scripté + coût
        |- replay.py  : JSONL clé call_seq + replay()
        \-- sweep.py  : exécution parallèle des runs (substrat commun)

Interfaces clés : ObservationView (objet + render()), candidates() (dérivées des pairs, pas de 2ᵉ moteur de légalité), ResponseCache (clé composite), ReplayRecord (clé call_seq), PolicyFactory (par run), evaluate(...) (oracle d'optimisation), danger_map(...) (cartographie).

6. Espaces de recherche

Espace Contenu Utilisé par
StrategySpec priorité de cible, style d'engagement, distance, esquive d'AoO, seuil de repli, buffs d'ouverture, politique de sorts, ressources, focus fire Optimiseur / Étage A
AdversarySpec mêmes leviers côté monstres (focus, ciblage du soigneur/lanceur, contrôle, embuscade) Robustesse
ScenarioSpec composition/niveaux, PV et ressources initiaux, déploiement/positionnement, carte/terrain, surprise, distance, mix et nombre de monstres, CR Cartographie

7. Objectifs et métriques

Métrique Pourquoi
P(survivre tous) / P(TPK) / P(≥ 1 mort) lecture directe de la survie
Espérance de PV perdus (groupe / PJ) gravité
Ressources consommées (sorts, potions, canal) soutenabilité
Rounds tempo
CVaR / pire décile risque de queue — la moyenne cache les TPK
Bandes 3σ (n suffisant) incertitude — étage A uniquement

8. Rôle de la LLM (recadré)

Usage Pourquoi la LLM
NL → StrategySpec / AdversarySpec traduction structurée (petit, robuste)
Générateur d'hypothèses (candidats variés à amorcer) créativité, pas de gradient
Red Team (tactiques de monstres fortes) révéler les faiblesses
Explicateur (lire les transcripts dangereux) causalité en langage naturel
Rapporteur (playbook + carte de risque) rédaction (phase 3)

Non-usage : optimisation numérique (faite par search.py) et exécution par tour en sweep (faite par l'étage A).

9. Décisions

Toutes les décisions ci-dessous sont arbitrées (voir §13) ; le tableau conserve la recommandation validée. Les prompts sont traités au §14.

# Décision Recommandation
D1 Entrée : (observation, state) ou (observation) ? (observation) + adaptateur. À trancher en P0.0.
D2 Vue dédiée vs to_dict() brut Vue compacte, structurée, versionnée
D3 Mémoire inter-tours Retirée de la v1
D4 Sortie d'actions JSON strict, schéma généré depuis Action, swift/free/full_round exclus
D5 Légalité Pas de 2ᵉ moteur : candidats dérivés des pairs
D6 Reproductibilité Cache composite (model, params, prompt_version, schema_version, hash)
D7 Runner/CLI llm_policy + policy_factory sur run_encounter ; entrée de sweep dédiée
D8 Périmètre v1 Sous-ensemble curé ; ReactionPolicy par défaut conservée
D9 Économie Deux étages (exécution)
D10 Parallélisme Runs indépendants → pool de threads, cache shardé
D11 Réactions v1 par défaut ; LLM via ready
D12 Danger = adversarial Mesurer contre des monstres optimisés, sinon sous-estimation
D13 Objectifs Inclure le risque de queue, pas seulement la moyenne
D14 Cartographie Non exhaustive : coarse-to-fine + frontière + sensibilité

10. Plan d'implémentation

P0 — Frontière durcie (sans LLM ; débloque tout)

P0.0 trancher D1 (obs seule + adaptateur) · P0.1 schema_version + clés pinnées · P0.2 ObservationView + mesure de taille · P0.3 candidates() (depuis pairs) · P0.4 run_encounter(..., llm_policy=, policy_factory=) · P0.5 ReplayLog (call_seq) + replay() identique. Sortie : gate verte ; un run enregistré se rejoue à l'identique.

P1 — Client et politiques

P1.1 LLMClient + FakeLLMClient scripté + cache composite · P1.2 garde-fous coût/tokens + retry/backoff · P1.3 NLPolicy (étage B : parse, valide, 1 re-essai, repli) · P1.4 PromptBuilder versionné + schéma d'action généré. Sortie : bataille complète pilotée par le Fake, hors ligne.

P2 — Étage A : compilation, objectifs, sweep

P2.1 compilateur NL → Policy paramétrée · P2.2 objectives.py · P2.3 SweepRunner parallèle. Sortie : sweep ≥ 2×2 avec métriques, borné en coût.

P3 — Optimiseur (« meilleure stratégie »)

P3.1 scenarios.py (ScenarioSpec + échantillonnage) · P3.2 search.py (grille → affinage) · P3.3 amorçage par candidats LLM. Sortie : playbook classé avec incertitude pour ≥ 1 rencontre type.

P4 — Adversaire et cartographie (« où est le danger »)

P4.1 AdversarySpec + politiques de monstres fortes · P4.2 robustness.py (min-max alterné) · P4.3 danger_map (coarse-to-fine, frontière, sensibilité). Sortie : carte de danger (PJ et monstres) + frontière seuils.

P5 — Fournisseur réel et rapport (frontière phase 3)

P5.1 ProviderClient (clé env, coût/observabilité) · P5.2 few-shot + réparation complète · P5.3 rapport FR (phase 3).

11. Risques

  1. Économie per-tour → résolue par les deux étages.
  2. Adversaire trop faible : danger sous-estimé → monstres optimisés (D12).
  3. Replay sous-spécifié (delay, invocations) → clé call_seq.
  4. Cache sans identité de modèle → clé composite.
  5. Divergence de légalité → candidats dérivés des pairs.
  6. Bascule tardive de D1 → à trancher en P0.0.
  7. Bandes 3σ trompeuses à petit n → étage A pour le quantitatif.
  8. Explosion combinatoire → coarse-to-fine, pas d'énumération (D14).
  9. Événements rares (TPK) : n énorme en Monte Carlo naïf → ciblage par scénarios, ou annoncer la limite.

12. Première action recommandée

Un spike (sans LLM) qui : (a) tranche D1 + schema_version + test de clés pinné ; (b) mesure taille de prompt et appels par cellule de sweep (décide per-tour vs compilé) ; (c) esquisse ScenarioSpec + objectives.py et valide la chaîne de cartographie sur une rencontre type, en comparant default_policy contre une politique de monstres renforcée — ce dernier point prouve immédiatement la valeur « prédire où sont les difficultés », avant toute LLM.

13. Décisions arbitrées

# Décision Choix retenu
D1 Entrée de la politique (observation) seule + adaptateur de compatibilité
D9 Exécution Deux étages (compilé pour les sweeps, per-tour pour la vitrine)
D12 Mesure du danger Contre un adversaire optimisé (AdversarySpec + meilleure réponse)
D14 Cartographie Non exhaustive : région d'échec + frontière + sensibilité
— Priorité v1 Cartographie d'abord, puis optimiseur (mêmes briques)
— Fournisseur/modèle Client agnostique ; implémentation distante d'abord, locale ensuite ; mode JSON/schéma requis
— Budget Plafonds petits (≤ 50 appels de compilation/campagne, ≤ 20 combats en vitrine) + coupe-circuit dans le client
— Reproductibilité Cache obligatoire ; clé composite incluant le modèle ; replay call_seq
— Prompts Externes / dépôt séparé (voir §14)

Reste à préciser au fil des lots (non bloquant) : forme exacte des leviers de StrategySpec/AdversarySpec, choix de l'algorithme d'optimisation, seuils statistiques par défaut, et le fournisseur distant concret.

14. Prompts externes (décision utilisateur)

Les prompts ne vivent pas dans ce dépôt : ce dépôt ne doit contenir que le code du simulateur (calcul + couche LLM + rapport). Conséquences :

  • Chargement configurable : le code lit les prompts depuis un chemin externe (variable d'environnement / fichier de configuration), jamais codés en dur.
  • Versionnage côté pack : le dépôt de prompts porte sa propre version (semver ou hash de commit) ; chaque prompt a un hash individuel.
  • Traçabilité : le prompt_version (version du pack + hash du prompt) entre dans la clé du cache, dans le ReplayRecord et dans les rapports de sweep — sinon une campagne n'est pas reproductible après modification d'un prompt.
  • Tests : le pack externe est épinglé pour l'intégration (version fixée) ; les tests unitaires utilisent un fixture de prompt minimal local pour que la CI ne dépende pas du dépôt externe.
  • Validation : un prompt manquant/invalide doit produire une erreur claire au démarrage (pas de fallback silencieux).

15. Périmètre du dépôt et backlog de détachement

Décision utilisateur : à terme, seul le code doit rester ici. Sont concernés par un détachement (données et contenu) : fiches de personnages (fiches_personnages/), données de monstres/PJ/sorts/cartes (data/), et les fichiers de prompts.

Pour l'instant : laissé en place (rattrapage prévu en fin de projet). Contrainte de conception à respecter dès maintenant pour ne pas se peindre dans un coin :

  • Aucun chemin de données en dur dans le nouveau code (couche phase 2) : centraliser la résolution des chemins (config/env) pour préparer le déplacement.
  • Les prompts sont déjà conçus comme externes (§14) — pas de régression.
  • Le détachement final devra fournir un jeu de données d'exemple et une documentation de configuration (chemins) pour que le dépôt code reste utilisable seul.

16. Résultats du spike (exécuté)

Livré : D1 (LLMPolicy(Observation) + LegacyLLMPolicy), schema_version pinné, objectives.py, run_encounter(..., collect_runs=True), une politique de monstres focus fire (spike) et une carte de danger de démonstration.

Mesures (décision d'architecture)

Mesure Valeur
Observation, 7 combattants 64 Ko ≈ 16 000 tokens (max 67 Ko)
Observation, duel 2v2 22 Ko ≈ 5 500 tokens
Appels de politique / combat 124 (7 combattants), 12 (duel)
Appels par cellule de sweep (1 000 runs) 124 000

→ Conclusion 1 : un LLM par tour en sweep est infaisable (~2 milliards de tokens par cellule) — D9 validé, et la vue compacte est obligatoire (cible ~2-4 k tokens, contre 16 k pour le snapshot brut).

Carte de danger (RUNS=100, seed=11)

Scénario Monstres Victoire PJ Survivent TPK ≥1 mort PV perdus Rounds
warband 4v4 défaut 90,0 % 87,0 % 0,0 % 13,0 % 66,1 12,3
warband 4v4 focus fire 98,0 % 76,0 % 0,0 % 24,0 % 45,0 9,6
liche 2v2 défaut 0,0 % 100,0 % 0,0 % 0,0 % 44,0 3,7
liche 2v2 focus fire 0,0 % 81,0 % 2,0 % 19,0 % 44,0 5,8

→ Conclusion 2 : mesuré contre la politique par défaut, le danger est sous-estimé (morts 13 %→24 % et 0 %→19 %, TPK 0 %→2 %) — D12 validé. → Conclusion 3 : le taux de victoire trompe (il monte à 98 % pendant que les morts augmentent) → les métriques de mort/PV/queue sont nécessaires — D13 validé.

Limites du spike : n=100 (bandes larges ±9 %), adversaire de niveau spike (focus fire seulement, sans positionnement ni sorts), 2 scénarios. La cartographie réelle (P4) demandera plus de runs, un adversaire complet (AdversarySpec) et un balayage de ScenarioSpec.

Vue compacte (P0.2, mesures réelles — commit 9da9537)

llm_view.py : ObservationView (structuré, déterministe) + build_view()

  • render() vers texte pour prompt. Règles : grilles sparsifiées (terrain et élévation = cellules non défaut ; threatened/ranged_threatened = liste de cellules, omis si vide), clés décisionnelles seules par combattant et par paire, schema_version inclus, ordre déterministe trié. Mesure sur l'affrontement représentatif (oni/misty/nairda vs lich/thrune_agent/orc/goblin, seed 1) :
Vue Octets Tokens % du raw
brute to_dict() 64 204 ~16 051 100 %
compacte render() 15 691 ~3 922 24,4 %

Ratio 24,4 % = cible ~2-4k tokens atteinte. Grilles : terrain 78 cellules sparses, élévation 0, menacées 15, menacées à distance 116 (au lieu de matrices pleines). Contrats de test : réduction < 25 % et < 16 000 octets, grilles sparses, clés supprimées (condition_definitions, etc. — voir le test), ids/ render déterministes. Test : tests/test_phase2_view.py.

Candidats d'action (P0.3 — commit 8b50430)

candidates.py : candidates(observation) -> tuple[Action, ...] — actions plausibles dérivées des pairs et des données par combattant (attack/ full_attack si weapon_for, charge si can_charge, pas de 5 ft + attaque si can_step5_to_attack, mouvement vers une cible, sort connu avec slot, canal/ hex/deed si ressource présente, défense totale, ready/delay) ; swift/free/ full_round exclus ; liste bornée et triée. Pas de second moteur de légalité : le moteur reste l'arbitre. Test : tests/test_phase2_candidates.py.

Runner et rejeu (P0.4 + P0.5 — commits 334f8f9, 6c6de16)

run_encounter accepte désormais llm_policy= (observation-only ou legacy, transmise à chaque CombatEngine) et policy_factory= (constructeur de Policy par run, appelé une fois par indice dans l'ordre ; None pour un run = repli default_policy). Les trois entrées (policy, llm_policy, policy_factory) sont mutuellement exclusives (erreur claire). Test : tests/test_phase2_runner.py (factory 1 appel/run dans l'ordre, repli, exclusivité, une bataille complète pilotée par llm_policy).

replay.py : ReplayRecord(call_seq, observation, action) + ReplayLog (spec + seed + round_cap + appels) sérialisé en JSONL (clé call_seq), observation_key() (digest déterministe, insensible à la collapse tuple/liste de JSON), et replay(log) -> LLMPolicy qui renvoie les actions enregistrées dans l'ordre des call_seq, avec gardes : observation divergente (ReplayMismatchError), log épuisé (ReplayExhaustedError), call_seq dupliqué ou hors ordre, JSONL invalide. Un run enregistré se rejoue à l'identique (wins/draws/rounds/transcript — aussi depuis un log dégelé du JSONL). Test : tests/test_phase2_replay.py. Le rejet de replay par défaut est documenté (D3 : pas de mémoire inter-tours ; delay/ready rejoués par les actions enregistrées elles-mêmes, sans ré-invocation).

Étage A (P2 — commit dbd1a58)

strategy.py : StrategySpec (cible nearest/weakest_ac/lowest_hp, retreat_hp_fraction, spell_policy default/none, use_charges, use_class_abilities) et compile_strategy(spec) -> Policy — compile_strategy(StrategySpec()) is default_policy (identité) ; les autres specs compilent vers des politiques paramétrées (échelle de mêlée, charge quand la ligne droite existe, retraite sous le seuil de pv, sorts / charges / capacités de classe désactivables), cible choisie par select_target (CA la plus basse, pv les plus bas, ou le plus proche). Test : tests/test_phase2_strategy.py.

sweep.py : SweepCell(scenario, strategy, spec, policy, runs, seed) + SweepRunner(party=, workers=) (registry des sorts chargée une fois, lecture seule partagée entre threads — D10), run(cells, max_total_runs=) (erreur claire nommant runs/budget, cellules ≥ 1 run) et run_grid(...) en ordre scénario-majeur — chaque cellule : run_encounter(collect_runs=True)

  • danger_metrics. Une politique None = default_policy (le runner n'importe pas strategy.py). format_sweep : rendu texte aligné (ascii). Tests : tests/test_phase2_sweep.py (déterminisme bit-à-bit, workers=1 ≡ workers=4, cellule None ≡ run_encounter direct, borne de coût).

Mesure représentative (RUNS=100, seed=7, partie players = gobelins, adversaire focus fire = orcs) :

Scénario Monstres Victoire PJ Survivent TPK ≥1 mort PV perdus Rounds
3 gobelins vs 2 orcs défaut 93,0 % 96,0 % 0,0 % 4,0 % 5,0 6,5
3 gobelins vs 2 orcs focus fire 98,0 % 100,0 % 0,0 % 0,0 % 2,4 5,6
2 gobelins vs 3 orcs défaut 50,0 % 96,0 % 0,0 % 4,0 % 7,5 10,6
2 gobelins vs 3 orcs focus fire 54,0 % 96,0 % 0,0 % 4,0 % 7,3 10,1

→ la sortie P2 (« sweep ≥ 2×2 avec métriques, borné en coût ») est atteinte : 2×2 = 400 combats en quelques secondes sur 4 workers, métriques de danger complètes par cellule, déterminisme vérifié. Ce 2×2 gobelins/orcs est un smoke test du runner (n=100, bande 3σ ±6 % sur les taux) : à ce n, le delta focus fire est dans le bruit sur les morts (4→0 en 3v2, plat en 2v3) — contrairement au spike warband/liche où il sortait nettement de la bande. Le signal le plus net ici est la queue (worst-decile HP 1,4 → 6,8 en 3v2 ; 0,0 dans les deux cellules 2v3) et les rounds moyens (6,5 → 5,6 en 3v2). La cartographie du danger à grande n, PJ et monstres, reste P4.

Optimiseur (P3 — commit dd64c2e)

scenarios.py : SideSpec(name, zone, combatant_files, hp_fraction=1.0) et ScenarioSpec(name, map_file, sides, round_cap=100) — build_encounter compile un scénario en EncounterSpec déterministe (aucun chemin de données en dur dans le src, §15), scenario_variants(base, hp_fractions=...) étend grossier le scénario par fractions de PV des côtés (grille de robustesse, sans RNG). Test : tests/test_phase2_scenarios.py.

search.py : evaluate(strategy, scenario, *, party, runs, seed, workers=4) (StrategyBenchmark : métriques de danger du camp party + bande 3σ sur le taux de victoire), PlaybookEntry(rank, strategy, benchmark), strategy_name (nom canonique de StrategySpec) et optimize(scenario, *, party, runs, seed, workers=4, max_total_runs=None, candidates=()) — recherche en 3 étages : grille grossière de 24 cellules (cible × politique de sorts × charges × capacités de classe, repli à 0), affinage des 3 meilleures cellules sur retreat_hp_fraction ∈ {0.25, 0.5, 0.75} (9 cellules ; cellules déjà mesurées dédupliquées), puis les candidats LLM (candidates, P3.3) dédupliqués et évalués. Classement : (-P(survive), PV perdus, -pire dcile PV, rounds, nom). Le budget max_total_runs est vérifié avant tout combat (ValueError nommant budget/max_total_runs). format_playbook rend le classement en colonnes ASCII avec la bande 3σ. Test : tests/test_phase2_search.py.

Playbook représentatif (RUNS=200, seed=7, workers=4, camp mesuré = players = 3 gobelins vs 2 orcs) — 33 cellules (grille 24 + affinage 9 + candidats 0) ; les rangs non listés sont des variantes à leviers inertes ou de repli strictement identiques à leur cellule de tête :

Rang Stratégie Victoire PJ (3σ) Survivent ≥1 mort PV perdus Pire dcile Rounds
1 lowest_hp (+ inertes, rangs 2-8) 97,0 % [93,4 ; 100,0] 99,0 % 1,0 % 3,0 5,2 5,8
9 weakest_ac (+ inertes, rangs 10-16) 97,0 % [93,4 ; 100,0] 99,0 % 1,0 % 3,0 5,2 5,8
17 lowest_hp, retreat=0.25 (+ inertes) 96,0 % [91,8 ; 100,0] 97,5 % 2,5 % 3,1 3,9 6,1
20 lowest_hp, retreat=0.5 (+ inertes) 95,0 % [90,4 ; 99,6] 97,5 % 2,5 % 3,5 3,0 6,4
23 default (nearest, repli 0) 95,0 % [90,4 ; 99,6] 97,0 % 3,0 % 5,0 2,8 6,9
31 lowest_hp, retreat=0.75 (+ inertes) 94,0 % [89,0 ; 99,0] 95,5 % 4,5 % 3,7 2,7 7,6

TPK 0,0 % dans les 33 cellules. → la sortie P3 (« meilleur candidat du grid + affinage, playbook classé ») est atteinte : 6600 combats en quelques secondes sur 4 workers, classement déterministe (rejoué bit-à-bit), budget vérifié avant exécution. → Le ciblage est le levier dominant : lowest_hp et weakest_ac battent default sur toutes les métriques du camp — PV perdus 5,0 → 3,0, pire dcile 2,8 → 5,2, rounds 6,9 → 5,8, survive 97,0 → 99,0 %. Égalité bit-à-bit entre les deux ordres de cibles : les adversaires adverses étant statistiquement identiques (gobelins entre eux, orcs entre eux), les deux heuristiques sélectionnent la même cible au même rang. → La retraite agressive est strictement dominée : seuil 0,25 → 0,5 → 0,75 dégrade la survie (99,0 → 97,5 → 97,5 → 95,5 %), le pire dcile (5,2 → 3,9 → 3,0 → 2,7) et allonge le combat (5,8 → 6,1 → 6,4 → 7,6 rounds) — une retraite est une action à round complet qui paie le départ sans compenser. → Leviers inertes sur cette rencontre : spell_policy, use_charges et use_class_abilities ne changent rien (gobelins et orcs n'ont ni sorts, ni ressources, ni capacités de classe) — leurs variantes font exactement égalité avec leur cellule de grille (rangs 2-8, 10-16, 24-30). Un scénario avec lanceur est nécessaire pour discriminer ces leviers (rencontres riches en P4/P5). → Sémantique de mesure — symétrique : une politique par cellule, appliquée aux deux camps (CombatEngine exécute la politique passée au moteur, sans routage par côté ; compile_strategy est côté-agnostic) ; le classement porte sur les DangerMetrics du camp party. Contrairement au spike P2 (focus_fire_policy routait par côté : orcs uniquement), le playbook mesure donc « la meilleure stratégie quand tout le monde la joue » — l'asymétrie PJ contre adversaire optimisé arrive en P4 (AdversarySpec). → Cohérence : la cellule default (95,0 % [90,4 ; 99,6]) contient la ligne P2 défaut (93,0 %) dans sa bande 3σ (mêmes seeds, n = 100 → 200). → Limite : P3.3 (candidats LLM) est câblé et testé (déduplication, 0 candidat ici) mais non exercé contre un vrai LLM — aucun fournisseur avant P5. Bande 3σ ±4,6 points à p = 0,97, n = 200 : au sein d'une famille à leviers inertes les écarts sont nuls (bit-identiques), mais un départage plus fin (n plus grand, carte plus riche) reste ouvert.

Adversaire et robustesse (P4)

adversary.py : `AdversarySpec(target_priority="nearest" | "lowest_hp" |
"weakest_ac" | "healer", spell_policy="default" | "none",
retreat_hp_fraction=0.0 | 0.25 | 0.5, use_charges=True | False)` — les
leviers adverses (D12 : l'adversaire joue pour gagner) ; un seul
AdversarySpec() (défaut = nearest, la politique focus fire du spike P2)
est composé avec la stratégie du camp mesuré en une seule politique de
combat par compile_matchup(party, adversary, party_side=). adversary_name
nom canonique d'un levier. Test : tests/test_phase2_adversary.py.

robustness.py : matchup_benchmark(scenario, party, adversary, *, party_side, runs, seed) -> MatchupBenchmark (mesure le danger des DEUX camps — même mesure, les deux sens du §3.2), puis les trois couches :

  • adversarial_analysis (P4.2, min-max alterné) : itérations successives — la partie maximise sa survie parmi party_candidates, puis l'adversaire minimise le danger de la partie parmi adversary_candidates ; chaque itération est rendue par format_robustness. Le budget max_total_runs= est vérifié avant tout combat (ValueError nommant budget/max_total_runs).
  • danger_map (P4.3) : grille SideSpec.hp_fraction PJ × adversaire, une cellule par paire de fractions = un MatchupBenchmark ; format_danger_map rend la grille (survie des deux camps par cellule).
  • frontier (P4.3) : bissection de la fraction de PV PJ où criterion croise target (convention §3.2), adversaire fixé — le retour est plafonné au pas precision (ceil, jamais arrondi, pour rester du côté croisant du seuil) ; None quand même max_fraction ne croise pas. format_frontier rend « no frontier ».
  • sensitivity (P4.3) : classe les 7 leviers adverses par |delta| du critère de la partie vs baseline AdversarySpec() (budget exact 8×runs, vérifié avant combat) ; format_sensitivity affiche les probabilités en %, expected_hp_lost à 1 décimale.

Tests : tests/test_phase2_robustness.py.

Mesures représentatives (RUNS=100, seed=7, camp mesuré = players = 3 gobelins vs 2 orcs, sample_arena.yaml) :

P4.1 — équilibre du min-max alterné (2 itérations ; l'adversaire best- response converge vers AdversarySpec() dès la première itération, les deux itérations sont bit-identiques) :

Stratégie PJ P(survie PJ) PV perdus PJ P(survie adv) PV perdus adv TPK
default (nearest) 96 % 5,0 98 % 11,6 0 %
lowest_hp 95 % 6,3 99 % 11,5 0 %
retreat=0.5 97 % 5,3 99 % 11,4 0 %

→ la meilleure réponse adverse est le ciblage nearest par défaut (le plus dangereux pour la partie, voir sensitivity) ; face à cet équilibre les trois stratégies PJ sont dans le bruit (bande 3σ ±6 points à n=100). Le camp adverse encaisse ~11,5 PV moyens par combat (il perd presque toutes les runs) mais ne meurt (hp ≤ −CON) que dans ~2 % des runs (98-99 % de survie adv) : la rencontre bat l'adversaire en le mettant à ≤ 0 pv sans souvent le tuer — cohérent avec la règle de mort hp ≤ −CON du moteur.

P4.2 — danger_map (PJ default × adversaire weakest_ac, fractions 0.5/0.7/1.0, 9 cellules ; grille Party\Adv : survie partie/survie adversaire par cellule) :

adv=0.5 adv=0.7 adv=1.0
party=0.5 100 %/100 % 100 %/99 % 99 %/99 %
party=0.7 99 %/100 % 100 %/99 % 100 %/99 %
party=1.0 100 %/100 % 100 %/99 % 100 %/99 %

→ la grille est plate (99-100 %) et le TPK nul partout : la mort est un événement rare sur cette rencontre (au plus 1 run sur 100 par cellule) et les écarts observés restent sous la bande 3σ (±6 points) — même décimée à 50 % de PV, la partie survit. Une carte discriminante exige une rencontre plus équilibrée (le 2 gobelins vs 3 orcs de P2, où ≥ 1 mort monte à 4 %).

P4.3 — frontier (p_survives, target=0.5, fractions [0.10, 1.00], adversaire healer) : no frontier — la survie de la partie ne descend jamais sous 50 %, même à 10 % de PV initiaux : le plancher de robustesse de la partie est sous la barre des 10 %.

P4.3 — sensitivity (criterion=expected_hp_lost, baseline default : PV perdus 4,97, survie 96 %) :

Rang Levier Value Delta
1 healer 2,4 −2,6
2 lowest_hp 2,4 −2,6
3 weakest_ac 2,4 −2,6
4 retreat=0.5 3,1 −1,9
5 retreat=0.25 4,0 −1,0
6 spell_policy=none 5,0 +0,0
7 use_charges=false 5,0 +0,0

→ le ciblage est le levier adverse le plus sensible : quitter nearest pour n'importe quel ciblage explicite (healer/lowest_hp/weakest_ac, ex æquo à −2,6) fait tomber l'attrition de la partie de 5,0 à 2,4 PV et remonte sa survie à 100 % — le ciblage de la cible la plus proche (la plus engagée en mêlée) est donc le plus dangereux pour la partie ; des adversaires statistiquement identiques rendent les trois ciblages ex æquo (même cible au même rang, déjà observé en P3). La retraite orc (combat écourté) réduit aussi le danger (−1,9 / −1,0). Leviers inertes : spell_policy et use_charges (les orcs n'ont ni sorts ni ressources) — delta 0,000. Budget : adversarial_analysis(max_total_runs=1) → ValueError (« robustness budget exceeded: 800 runs > 1 max_total_runs ») avant tout combat. Déterminisme : deux passes de matchup_benchmark bit-identiques (p_survives 1,000 / p_tpk 0,000 / hp_lost 2,4).

→ la sortie P4 (« adversaire AdversarySpec + cartographie de danger complète, bornée en coût ») est atteinte : min-max alterné convergeant, grille de danger 3×3, frontière de survie, classement des leviers adverses, déterminisme et budget vérifiés. Les mesures placent la rencontre de référence 3v2 du côté sûr de l'équilibre : TPK nul, plancher de survie sous 10 % de PV, et le seul levier adverse qui y compte est le ciblage par défaut.

État du plan

  • P0 ✅ · P1 ✅ (LLMClient + FakeLLMClient scripté + cache composite, garde-fous coût/tokens, NLPolicy, PromptBuilder versionné) · P2 ✅ (strategy.py + SweepRunner, sortie mesurée) · P3 ✅ (scenarios.py + search.py, playbook représentatif mesuré) · P4 ✅ (adversary.py + robustness.py, adversaire AdversarySpec + carte de danger complète) — gate verte (2169 tests).
  • Détail P0 : P0.0 (D1) ✅ · P0.1 (schema_version) ✅ · mesures ✅ · objectives.py ✅ · collect_runs ✅ · P0.2 (vue compacte) ✅ · P0.3 (candidates) ✅ · P0.4 (llm_policy + policy_factory) ✅ · P0.5 (ReplayLog call_seq + replay() identique) ✅.
  • Frontière P0 atteinte : gate verte (2022 tests) ; un run enregistré se rejoue à l'identique.
  • Suite : P5 (fournisseur réel + rapport).