Files
pf1e-simulator/docs/phase-2-llm-architecture.md
T
ctan 25418f164f docs(phase2): record the executed spike (measurements + first danger map)
Measures: observation 64 KB (~16k tokens) for 7 combatants / 22 KB for a 2v2;
124 policy calls per battle, i.e. 124k calls per 1000-run sweep cell -> a
per-turn LLM in a sweep is infeasible (D9 validated) and a compact view is
mandatory. Danger map (default vs focus-fire monsters, 100 runs): the default
policy understates danger (PC deaths 13%->24% and 0%->19%, TPK 0%->2%) and the
win rate alone misleads (rises to 98% while deaths rise) -> D12/D13 validated.
Notes the spike limits and the remaining P0.2-P0.5 items.
2026-09-21 12:15:16 +02:00

336 lines
18 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`.
### État du plan
- **P0.0 (D1)** ✅, **P0.1 (`schema_version`)** ✅, mesures ✅, `objectives.py` ✅,
`collect_runs` ✅.
- Restent : **P0.2** (vue compacte), **P0.3** (`candidates`), **P0.4** (`llm_policy`
sur le runner + `policy_factory`), **P0.5** (replay `call_seq`) → puis P1 (client
+ politiques), P3 (optimiseur), P4 (cartographie complète), P5 (fournisseur + rapport).