Files
pf1e-simulator/docs/phase-2-llm-architecture.md
T
ctan 630f5321e0 docs(phase2): revise the LLM architecture after critique
Key correction: the LLM must not sit on the per-turn hot path of a Monte
Carlo sweep (runs x rounds x combatants calls, no cross-run cache reuse).
Adopts a two-tier design - a compiled, parameterised deterministic Policy for
quantitative sweeps (tier A) and a per-turn NLPolicy for a handful of showcase
battles (tier B). Also fixes: 'engine unchanged' -> rules unchanged/additive
boundary; replay keyed by a monotonic call_seq (delay re-queues, summons);
composite response-cache key incl. model id; ObservationView as a structured
object; candidates derived from pairs instead of a second legality engine;
per-run PolicyFactory; cost guardrails in P1; concurrency; D1 to settle in P0.
2026-09-21 09:55:53 +02:00

9.6 KiB
Raw Blame History

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.

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

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.

2. Décision structurante : deux étages (correction majeure)

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 :

  • É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.

Le reste de l'architecture sert les deux étages ; l'étage B est le banc d'essai de l'étage A.

3. É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) runner.py:216 oui
Base de règles conforme docs/conformance-srd.md (15 passes, 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.

4. Architecture cible (révisée)

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)

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

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

6. Plan d'implémentation (révisé)

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)

P1 — Client et politiques

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 — Stratégie compilée et balayage

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

P3 — Fournisseur réel et rapport (frontière 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

7. Risques (issus de la critique)

  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.

8. Première action recommandée

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

  1. Fournisseur/modèle (API distante ou local) ?
  2. Budget (tokens/coût) par sweep ?
  3. Reproductibilité : cache obligatoire (runs rejouables) ?
  4. Stratégie v1 : offensive, positionnement, contrôle — ordre ?
  5. Prompts dans le dépôt ou externes ?
  6. Valides-tu D1 (LLMPolicy(observation) seule) et D9 (deux étages) ?