# 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) — module `report.py`, prompt FR `report.md`, CLI `--llm-report`/`--output`, budget `ReportBudget`. ## 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 et seuils statistiques par défaut (le fournisseur distant est **arbitré en P5.1** : un gateway OpenAI-compatible, §16). ## 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. ### Fournisseur réel OpenAI-compatible (P5.1) Le « ProviderClient » du §10 est **`OpenAILLMClient`** (`provider.py`) : il parle le format wire **chat-completions** d'OpenAI sur la bibliothèque standard (`urllib.request`) — aucune dépendance HTTP dans le dépôt. L'endpoint, le modèle et la clé viennent de l'environnement (§15) : `PF1E_LLM_BASE_URL` (défaut `https://api.openai.com/v1`), `PF1E_LLM_MODEL` (défaut `gpt-4o-mini`) et `PF1E_LLM_API_KEY` (jamais codée en dur) ; un gateway vLLM / llama.cpp / compatible se branche par variables d'environnement, sans toucher au code. Taxonomie d'erreurs (partagée avec `client.py`) : - `ProviderError` (transitoire) : erreur de transport, HTTP 429 et tout 5xx — rejoués par la boucle de backoff de `LLMClient` et comptés par le coupe-circuit ; - `ProviderRejectedError` (permanent) : tout autre 4xx (clé invalide, modèle inconnu, quota) — levée immédiatement, jamais rejouée ; - `ProviderResponseError` (permanent) : un corps 2xx qui n'est pas un payload chat-completions valide (JSON invalide, `choices`/`message`/`content` absents, `usage` malformé) — levée immédiatement. Le corps de requête demande `response_format` JSON (`json_object`, mode JSON/schéma requis, D arbitrées §13) ; l'usage est lu du bloc `usage` du payload et estimé au caractère (`estimate_tokens`) quand le fournisseur l'omet. Les 17 cas de `tests/test_phase2_provider.py` pinent le format wire, la taxonomie, la résolution de clé par environnement, le ledger de tokens et le cache — à travers un transport `urlopen` injectable, jamais le réseau. Gate verte : **2186 tests**. ### Few-shot et réparation complète (P5.2) **Few-shot externe** (`prompt.py` + `prompt_builder.py`) : un pack peut porter, à côté de chaque `{name}.md`, un fichier `{name}.examples.json` — une **liste JSON** de paires d'enseignement `{"observation": {...}, "actions": [...]}` (clés strictement validées, toute clé inconnue est un `PromptPackError`). La découverte est optionnelle et triée ; un fichier orphelin (sans `{name}.md` correspondant) est une erreur de pack ; un contenu non-JSON, un non-tableau, un item non-objet, une `observation` non-objet ou des `actions` non-tableau/non-objets sont rejetés à la même taxonomie (`_pack_error`, message citant le chemin et l'index). Le paquet expose les paires via `PromptPack.examples(name)` (tuple de `FewShotExample` frozen) ; le **hash de version couvre désormais aussi le contenu des exemples** : `prompt_version = sha256(content + "\0" + examples_text)[:12]`, **byte-identique** quand aucun fichier d'exemples n'existe (le cache/ledger D6 existant n'est pas invalidé). `PromptBuilder` insère entre le template et l'observation une section `## few-shot examples` dont chaque bloc `### example {n}` rend `observation:` puis `response:` en JSON trié (`sort_keys=True`) — bit-identique sans exemples (garde de régression testée sur l'ordre des sections). **Réparation complète** (`nl_policy.py`) : la boucle de réparation de P1.3 devient **bornée et tracée**. `NLPolicy` porte `max_repairs` (défaut `1` — comportement d'origine) : chaque réponse invalide est suivie d'un retry dont le prompt accumule le **trail chronologique** des échecs précédents (`-- previous reply invalid (attempt N) --` + message), refermé par `Return a valid JSON action sequence.` ; au-delà de `max_repairs` tentatives échouées, le fallback scripté prend le relais (`fallbacks`). `max_repairs=0` désactive la réparation (fallback immédiat) ; une valeur négative est rejetée au `__post_init__` (`ValueError`). Les compteurs vivent dans `NLPolicyStats` (dataclass mutable, `policy.stats`) : `calls`, `repairs_attempted`, `repairs_succeeded` et `fallbacks` — la matière des rapports de P5.3 et du sweep. Fixture `tests/fixtures/prompt_pack_fs/` (pack « few-shot » : version `0.1.0-test`, même prompt texte que le pack source, un micro-exemple goblin→orc). Les 25 nouveaux cas pinent : la validation de `tests/test_phase2_prompt.py` (12), le rendu et l'ordre des sections de `tests/test_phase2_prompt_builder.py` (5, dont le tableau vide → pas de section) et la boucle bornée de `tests/test_phase2_nl_policy.py` (8, via un client enregistreur dérivé de `FakeLLMClient` qui capture chaque prompt envoyé — trail vérifié tentative par tentative). Gate verte : **2211 tests**. ### É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) · **P5.1** ✅ (`provider.py`, fournisseur OpenAI-compatible HTTP au format chat-completions) · **P5.2** ✅ (few-shot externe `*.examples.json` hashé dans `prompt_version` + boucle de réparation bornée `max_repairs` tracée par `NLPolicyStats`) — gate verte (**2211 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. - **P5.3** ✅ (rapport FR) — module `report.py` (`ReportContext`, `LLMReportPolicy`, `ReportBudget`, `format_llm_report`), prompt pack FR `report.md`, CLI `--llm-report`/`--output`, budget `ReportBudget` intégré à `CostBudget`, 27 tests ajoutés — gate verte (**2238 tests**).