diff --git a/docs/phase-2-llm-architecture.md b/docs/phase-2-llm-architecture.md index 772984c..a94bff9 100644 --- a/docs/phase-2-llm-architecture.md +++ b/docs/phase-2-llm-architecture.md @@ -1,40 +1,77 @@ # Phase 2 — Architecture et plan d'implémentation de la couche de décision LLM -> Document de conception (révisé après critique). **Aucun code n'est encore -> écrit.** Il fige l'architecture cible, les décisions et un plan vérifiable. +> 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 -**Objectif phase 2** : traduire des stratégies en langage naturel en politiques -tactiques, puis balayer des matrices de stratégie/positionnement sur le moteur -déterministe. **Hors périmètre** : le rapport d'équilibrage LLM (phase 3). +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 est additive (nouvelles API et points d'entrée), branchée par une politique. +LLM et les couches d'analyse sont **additives**. La LLM n'est ni l'optimiseur ni +l'exécutant des sweeps. -## 2. Décision structurante : deux étages (correction majeure) +## 2. Exécution — les deux étages (rappel) -Mettre la LLM **à chaque tour** dans une boucle Monte Carlo est prohibitif : -`runs × rounds × combattants` appels par cellule de sweep (≈ 35 000 appels pour -1 000 runs), et les caches ne sont pas réutilisables entre runs (chaque run -diverge dès qu'une décision diffère). On distingue donc : +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, sweeps).** La LLM est appelée - **hors de la boucle** : elle compile une stratégie NL en une **politique - déterministe paramétrée** (`Policy` classique, closure sur des leviers : - priorité de cible, politique de sorts, règles de positionnement, seuils de - repli). Le Monte Carlo tourne ensuite avec cette politique → bandes 3σ - valides, coût maîtrisé (quelques appels par stratégie). -- **Étage B — LLM par tour (qualitatif, vitrine).** `NLPolicy` appelée à chaque - tour, pour un **petit nombre de runs (≤ 20)** : démonstration du raisonnement - tactique et transcripts exploitables. Les bandes statistiques y sont - **qualitatives** (n faible) et ne doivent pas servir à des conclusions - quantitatives. +- **É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. -Le reste de l'architecture sert les deux étages ; l'étage B est le banc d'essai -de l'étage A. +`default_policy` devient un cas particulier : `compile_strategy(SPEC_PAR_DEFAUT)`. -## 3. État des lieux (socle vérifié) +## 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é | |---|---|---| @@ -43,119 +80,161 @@ de l'étage A. | 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)` | `runner.py:216` | oui | -| Base de règles conforme | `docs/conformance-srd.md` (15 passes, gate 1981) | 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` n'accepte pas `llm_policy` -(`runner.py:177`) ; aucune option de politique CLI ; 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. +**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**. -## 4. Architecture cible (révisée) +## 5. Architecture cible ``` Moteur (règles inchangées ; frontière additive) - |- Observation --> view.py : ObservationView (objet structuré, versionné) - | \-- render() -> prompt compact (grilles sparsifiées) - |- pairs + ACTION_RESULT_REASONS --> candidates() (pas de 2e moteur de légalité) - |- LLMPolicy(observation) <-- Étage B : policy.py NLPolicy (parse/valide/repli) - \-- Policy(engine, state) <-- Étage A : strategy.py compile NL -> Policy - -Support : client.py (LLMClient + cache composite + Fake scripté + garde-fous coût) - prompt.py (prompt versionné) · replay.py (JSONL + call_seq + replay) - sweep.py (exécution parallèle des runs, métriques) + | + |-- 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 : prompt versionné + |- 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 structuré** (pas une chaîne) ; `render()` produit la - projection compacte pour le prompt ; `candidates()` dérive les actions - plausibles depuis `pairs` (le moteur reste l'arbitre via `last_turn_results`). -- `LLMClient` : `complete(prompt) -> str` ; `FakeLLMClient` piloté par une - **file de réponses scriptée** (idiome de `ScriptedRng`). -- `ResponseCache` : clé = `(model_id, params, prompt_version, schema_version, - hash(prompt))` — sans l'identité du modèle, un changement de version passerait - inaperçu. -- `ReplayRecord` : clé **monotone `call_seq`** (et non `{round, actor}`), car - `delay` re-queue un tour et les invocations ajoutent des combattants. -- `PolicyFactory` : produit une politique **par run** (reset mémoire/cache/coût). +**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). -## 5. Décisions +## 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 | # | Décision | Recommandation | |---|---|---| -| D1 | Entrée : `(observation, state)` ou `(observation)` ? | **`(observation)`** + adaptateur de compatibilité (accepte 1 ou 2 args). **À trancher avant P0.4** (impacte runner, démo, replay). | -| D2 | Vue dédiée vs `to_dict()` brut ? | Vue dédiée compacte et versionnée (objet structuré). | -| D3 | Mémoire inter-tours | **Retirée de la v1** (le snapshot porte déjà `last_turn_results`, `readied`, `pending_cast`). | -| D4 | Sortie des actions | JSON strict ; **schéma généré depuis `Action`** ; **exclure** `swift`/`free`/`full_round` (rejetés). | -| D5 | API d'actions légales | **Pas de second moteur** : candidats dérivés de `pairs` ; le rejet moteur est l'arbitre. | -| D6 | Reproductibilité | Cache composite (clé ci-dessus) ; temp 0 = best-effort, le cache est la garantie. | -| D7 | Intégration runner/CLI | `llm_policy` + `PolicyFactory` sur `run_encounter` ; point d'entrée de sweep dédié ; CLI par défaut intacte. | -| D8 | Périmètre v1 | Sous-ensemble curé (attaques, sorts, déplacement/destination, 5-ft, défense totale, ready/delay) ; `ReactionPolicy` par défaut conservée. | -| D9 | Économie | **Deux étages** (§2) : compilé pour les sweeps, par-tour pour la vitrine. | -| D10 | Parallélisme | Runs indépendants (états neufs + RNG seedé) → pool de threads, cache shardé. | -| D11 | Réactions | v1 : politique de réaction par défaut ; la LLM agit via `ready`. | +| 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é | -## 6. Plan d'implémentation (révisé) +## 10. Plan d'implémentation ### P0 — Frontière durcie (sans LLM ; débloque tout) - -| Tâche | Livrable | Critère de sortie | -|---|---|---| -| P0.0 | **Trancher D1** (obs seule + adaptateur) | type + adaptateur + tests | -| P0.1 | `schema_version` dans `to_dict()` + test du jeu de clés pinné | gate verte | -| P0.2 | `ObservationView` structurée + `render()` compact + mesure de taille | rendu stable, taille documentée | -| P0.3 | `candidates()` dérivées de `pairs` (pas de 2e moteur) | tests par kind (hors unsupported) | -| P0.4 | `run_encounter(..., llm_policy=..., policy_factory=...)` (reset par run) | test : politique factice via le runner | -| P0.5 | `ReplayLog` JSONL clé `call_seq` + `replay()` | un run enregistré se rejoue **à l'identique** (delay/invocations inclus) | +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. -| Tâche | Livrable | Critère de sortie | -|---|---|---| -| P1.1 | `LLMClient` + `FakeLLMClient` (file scriptée) + cache composite | tests hors ligne | -| P1.2 | **Garde-fous coût/tokens + retry/backoff/rate-limit** (dans le client, dès maintenant) | plafond par sweep respecté | -| P1.3 | `NLPolicy` (étage B) : prompt, parse JSON, validation, 1 re-essai, repli | bataille complète pilotée par le Fake | -| P1.4 | `PromptBuilder` versionné + golden de prompt ; schéma d'action généré depuis `Action` | prompt/schéma pinnés | +### 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. -### P2 — Stratégie compilée et balayage +### 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. -| Tâche | Livrable | Critère de sortie | -|---|---|---| -| P2.1 | `StrategySpec` (NL) + **compilateur NL → `Policy` paramétrée** (étage A) | compilation testée, politique déterministe | -| P2.2 | `SweepRunner` **parallèle** : stratégies × cartes × déploiements | sweep ≥ 2x2 bout en bout, borné en coût | -| P2.3 | Métriques : victoire ±3σ (étage A uniquement), taux d'invalidité, réparations, tokens/latence/coût | rapport de sweep ; **caveat statistique** documenté pour l'étage B | +### 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. -### P3 — Fournisseur réel et rapport (frontière phase 3) +### 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). -| Tâche | Livrable | Critère de sortie | -|---|---|---| -| P3.1 | `ProviderClient` (clé via environnement), coût/observabilité | run réseau reproductible via cache | -| P3.2 | Few-shot, boucle de réparation complète, versionnement renforcé | taux d'invalidité mesuré | -| P3.3 | Rapport d'équilibrage FR (phase 3) | sortie style README | +## 11. Risques -## 7. Risques (issus de la critique) +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. -1. **Économie de l'étage B** : LLM par tour × Monte Carlo = coût prohibitif → résolu par les deux étages (§2). -2. **Replay sous-spécifié** : `delay`/re-queue et invocations → clé `call_seq` obligatoire (P0.5). -3. **Cache sans identité de modèle** → clé composite (D6). -4. **Divergence de légalité** : un `legal_actions` maison dériverait des helpers moteur → candidats dérivés de `pairs` (D5). -5. **Bascule tardive de D1** → invalide runner/démo/replay ; à trancher en P0.0. -6. **Bandes 3σ trompeuses** à petit n (étage B) → réservées à l'étage A. -7. Non-déterminisme fournisseur, dérive de prompt, fuite de secrets → cache/versions/env. +## 12. Première action recommandée -## 8. 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. -Un **spike** qui tranche les deux forks avant tout composant : -(a) D1 (observation seule + adaptateur) et `schema_version` + test de clés pinné ; -(b) **mesure réelle** de la taille du prompt par tour et du nombre d'appels par -cellule de sweep avec la politique de démo — ce chiffre décide si la v1 est -per-tour ou compilée, et vaut plus que n'importe quel composant du plan. - -## 9. Questions ouvertes +## 13. Questions ouvertes 1. Fournisseur/modèle (API distante ou local) ? -2. Budget (tokens/coût) par sweep ? +2. Budget (tokens/coût) par sweep / par optimisation ? 3. Reproductibilité : cache obligatoire (runs rejouables) ? -4. Stratégie v1 : offensive, positionnement, contrôle — ordre ? +4. Priorité v1 : **optimiseur** (meilleure stratégie) ou **cartographie** + (où est le danger) ? 5. Prompts dans le dépôt ou externes ? -6. Valides-tu **D1** (`LLMPolicy(observation)` seule) et **D9** (deux étages) ? +6. Valides-tu **D1** (observation seule), **D9** (deux étages), **D12** + (danger mesuré en adversaire) et **D14** (cartographie non exhaustive) ?