Files
pf1e-simulator/docs/phase-2-llm-architecture.md
T
ctan ac7fe20054 docs(phase2): record the compact view measurements (P0.2) and candidates (P0.3)
Compact render is 15,691 bytes (~3.9k tokens) = 24.4% of the raw 64,204-byte
to_dict() snapshot on the representative 7-combatant encounter, hitting the
~2-4k token target; grids are sparse and determinism is pinned in tests.
candidates() derives plausible actions from the pairs with no second legality
engine (swift/free/full_round excluded).
2026-09-21 14:36:24 +02:00

19 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  : 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.

Vue compacte (P0.2, mesures réelles — commit 9da9537)

llm_view.py : ObservationView (structuré, déterministe) + build_view()

  • render() vers texte pour prompt. Règles : grilles sparsifiées (terrain et élévation = cellules non défaut ; threatened/ranged_threatened = liste de cellules, omis si vide), clés décisionnelles seules par combattant et par paire, schema_version inclus, ordre déterministe trié. Mesure sur l'affrontement représentatif (oni/misty/nairda vs lich/thrune_agent/orc/goblin, seed 1) :
Vue Octets Tokens % du raw
brute to_dict() 64 204 ~16 051 100 %
compacte render() 15 691 ~3 922 24,4 %

Ratio 24,4 % = cible ~2-4k tokens atteinte. Grilles : terrain 78 cellules sparses, élévation 0, menacées 15, menacées à distance 116 (au lieu de matrices pleines). Contrats de test : réduction < 25 % et < 16 000 octets, grilles sparses, clés supprimées (condition_definitions, etc. — voir le test), ids/ render déterministes. Test : tests/test_phase2_view.py.

Candidats d'action (P0.3 — commit 8b50430)

candidates.py : candidates(observation) -> tuple[Action, ...] — actions plausibles dérivées des pairs et des données par combattant (attack/ full_attack si weapon_for, charge si can_charge, pas de 5 ft + attaque si can_step5_to_attack, mouvement vers une cible, sort connu avec slot, canal/ hex/deed si ressource présente, défense totale, ready/delay) ; swift/free/ full_round exclus ; liste bornée et triée. Pas de second moteur de légalité : le moteur reste l'arbitre. Test : tests/test_phase2_candidates.py.

État du plan

  • P0.0 (D1) ✅, P0.1 (schema_version) ✅, mesures ✅, objectives.py ✅, collect_runs ✅, P0.2 (vue compacte) ✅, P0.3 (candidates) ✅.
  • Restent : 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).