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

438 lines
24 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).
## 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**.
### É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) — gate verte
(**2116 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 : **P3** (optimiseur « meilleure stratégie »), P4 (cartographie
complète), P5 (fournisseur réel + rapport).