Files
pf1e-simulator/docs/phase-2-llm-architecture.md
T
ctan 3fd9860b5c docs(phase2): record the arbitrated decisions (external prompts, cartography-first)
All recommendations accepted by the user except prompt location: prompts live
outside this repo (separate pack, configurable path, versioned; prompt_version
feeds the cache key and the replay record; a pinned pack for integration, a
minimal local fixture for unit tests, no silent fallback). Also records the
repo-scope decision (code only: calculation + LLM + report) with the final
detachment of data/sheets/prompts as a backlog item and the design constraint
to avoid hardcoded data paths in the new code. Priority: cartography first.
2026-09-21 10:49:00 +02:00

289 lines
15 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.