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

162 lines
9.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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) ?