Files
pf1e-simulator/docs/phase-2-llm-architecture.md
T
ctan d794828c7e docs(phase2): add the optimizer and adversarial-cartography layers
The execution tiers answer 'how to run a tactic', not 'which tactic is best'
nor 'where is the danger'. Adds two decision layers: an optimizer over
StrategySpec (objectives incl. tail risk, coarse-to-fine search, LLM as
candidate seeder) and an adversarial cartography (AdversarySpec + alternating
min-max + scenario sweeps + danger frontier + sensitivity), giving the danger
view for BOTH sides. Reframes the LLM as hypothesis generator / red team /
explicator / reporter, never the optimizer or per-turn executor in sweeps.
Adds D12-D14 and phases P3 (optimizer) / P4 (robustness).
2026-09-21 10:27:11 +02:00

12 KiB
Raw Blame History

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  : 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 + 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

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

  1. Fournisseur/modèle (API distante ou local) ?
  2. Budget (tokens/coût) par sweep / par optimisation ?
  3. Reproductibilité : cache obligatoire (runs rejouables) ?
  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 (observation seule), D9 (deux étages), D12 (danger mesuré en adversaire) et D14 (cartographie non exhaustive) ?