Files
pf1e-simulator/docs/phase-2-llm-architecture.md
T
ctan b0bd0b95f4 docs(phase2): record the P5.3 report writer
- Mark P5.3 (rapport FR) as completed in architecture doc
- Update test count to 2238 (2211 + 27 new tests)
- Document report.py module, prompt pack, CLI flags, budget integration
2026-09-26 10:43:14 +02:00

706 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**).