# 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.