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.
This commit is contained in:
2026-09-21 09:55:53 +02:00
parent be64451ba4
commit 630f5321e0
+120 -142
View File
@@ -1,183 +1,161 @@
# Phase 2 — Architecture et plan d'implémentation de la couche de décision LLM # Phase 2 — Architecture et plan d'implémentation de la couche de décision LLM
> Document de conception. **Aucun code n'est encore écrit.** Ce document fige > Document de conception (révisé après critique). **Aucun code n'est encore
> l'architecture cible, les décisions à trancher et un plan d'implémentation > écrit.** Il fige l'architecture cible, les décisions et un plan vérifiable.
> vérifiable par étapes.
## 1. Objectif et périmètre ## 1. Objectif et périmètre
**Objectif phase 2** (cf. README, feuille de route) : traduire des stratégies **Objectif phase 2** : traduire des stratégies en langage naturel en politiques
exprimées en langage naturel en politiques tactiques, puis balayer des matrices tactiques, puis balayer des matrices de stratégie/positionnement sur le moteur
de positionnement / de stratégie, en s'appuyant sur le moteur déterministe. déterministe. **Hors périmètre** : le rapport d'équilibrage LLM (phase 3).
**Hors périmètre phase 2** : la rédaction du rapport d'équilibrage par la LLM **Principe directeur** : les **règles** du moteur restent inchangées ; la couche
(phase 3). LLM est additive (nouvelles API et points d'entrée), branchée par une politique.
**Principe directeur** : le moteur reste **déterministe et ignorant de la LLM**. ## 2. Décision structurante : deux étages (correction majeure)
Toute la partie LLM vit dans une couche séparée, branchée par une politique
(`llm_policy`) ; le snapshot d'observation est la seule entrée autorisée.
## 2. État des lieux (socle déjà en place) 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 :
| Élément | Emplacement | État | - **É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é |
|---|---|---| |---|---|---|
| Snapshot `Observation` (figé, `to_dict()` JSON-sérialisable, sans `engine.*`) | `combat.py:1262` / `to_dict` `1310` | en place, documenté, audité | | `Observation` figée, `to_dict()` JSON, sans `engine.*` | `combat.py:1262` / `1310` | oui |
| Types de politique `Policy`, `LLMPolicy`, `ReactionPolicy` | `combat.py:10573-10577` | en place | | `Policy` / `LLMPolicy` / `ReactionPolicy` | `combat.py:10573/10575/10577` | oui |
| Câblage moteur `policy` / `llm_policy` (mutuellement exclusifs) | `combat.py:1615-1622` | en place | | Câblage `policy`/`llm_policy` exclusifs | `combat.py:1615-1623` | oui |
| Retour d'action `ActionResult` + `last_turn_results` + vocabulaire des raisons | `combat.py:1483` | en place | | `ActionResult` + `ACTION_RESULT_REASONS` + `last_turn_results` | `combat.py:1483`, `1528` | oui |
| Démo « observation seule » prouvant les capacités tactiques | `tests/test_llm_engine_capabilities.py`, `tests/test_observation.py::test_llm_policy_wrapper_e2e` | en place | | Démo « observation seule » | `tests/test_llm_engine_capabilities.py` | oui |
| Déterminisme (`SeededRng(seed+i)`, `ScriptedRng`) | `rng.py`, `runner.py` | en place | | Déterminisme `SeededRng(seed+i)` | `runner.py:216` | oui |
| Base de règles conforme SRD | `docs/conformance-srd.md` | 15 passes d'audit, gate 1981 tests | | Base de règles conforme | `docs/conformance-srd.md` (15 passes, gate 1981) | oui |
### Écarts / manques identifiés pour la phase 2 **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.
1. **`run_encounter` n'accepte pas `llm_policy`** (`runner.py:172` : seul ## 4. Architecture cible (révisée)
`policy` est paramétrable) → impossible de lancer un sweep LLM via le runner
sans modifier le code.
2. **La CLI n'a aucune option de politique** (`cli.py` : politique par défaut
implicite) → pas de point d'entrée pour un run LLM.
3. **Pas de `schema_version`** dans `to_dict()` → prompts non versionnables.
4. **Pas de vue compacte** du snapshot pour l'LLM (les grilles `terrain`,
`elevation`, `threatened`, `ranged_threatened` peuvent être volumineuses).
5. **Pas d'API « actions légales »** : la LLM doit inférer la légalité depuis
les helpers de paire (`weapon_for`, `can_charge`, `movement_cost_ft`…).
6. **Pas d'historique/mémoire** : `LLMPolicy(observation, state)` ne reçoit pas
de mémoire inter-tours (seul `last_turn_results`).
7. **Pas de journal de replay/évaluation** : seuls les transcripts sont capturés
et mis en forme ; pas de log `(observation, actions, résultats)` exploitable.
8. **Signature `LLMPolicy(observation, state)`** : le README affirme que le
snapshot est la seule source, mais `state` (vivant) est transmis → à trancher.
## 3. Architecture cible
``` ```
┌──────────────────────────────────────────┐ Moteur (règles inchangées ; frontière additive)
│ Moteur (inchangé) │ |- Observation --> view.py : ObservationView (objet structuré, versionné)
│ CombatEngine : Observation, Action, │ | \-- render() -> prompt compact (grilles sparsifiées)
│ ActionResult, RNG seedé, règles SRD │ |- pairs + ACTION_RESULT_REASONS --> candidates() (pas de 2e moteur de légalité)
└───────────────┬──────────────────────────┘ |- LLMPolicy(observation) <-- Étage B : policy.py NLPolicy (parse/valide/repli)
│ observation (snapshot figé) \-- Policy(engine, state) <-- Étage A : strategy.py compile NL -> Policy
▼
┌──────────────────────────────────────────────────────────────┐ Support : client.py (LLMClient + cache composite + Fake scripté + garde-fous coût)
│ Couche Phase 2 (nouvelle) │ prompt.py (prompt versionné) · replay.py (JSONL + call_seq + replay)
│ │ sweep.py (exécution parallèle des runs, métriques)
│ view.py ObservationView : projection compacte, │
│ déterministe, versionnée (schema_version) │
│ │
│ legality.py legal_actions(view) : actions légales │
│ (réutilise les helpers du moteur) │
│ │
│ prompt.py PromptBuilder : système + stratégie + vue + │
│ vocabulaire d'actions + version de prompt │
│ │
│ client.py LLMClient (protocol) : complete(prompt)->str │
│ + ResponseCache(prompt_hash) │
│ + FakeLLMClient (CI, déterministe) │
│ │
│ policy.py NLPolicy : build → call → parse → validate │
│ → repair (N essais) → fallback sûr │
│ + PolicyMemory (notes inter-tours, opt-in) │
│ │
│ strategy.py StrategySpec (texte NL + leviers) → politique│
│ sweep.py SweepRunner : stratégies × cartes × │
│ déploiements → métriques agrégées │
│ replay.py ReplayLog (JSONL) + replay déterministe │
└──────────────────────────────────────────────────────────────┘
``` ```
### Interfaces clés **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).
- `LLMClient` (protocol) : `complete(prompt: Prompt) -> str` ; implémentations ## 5. Décisions
`FakeLLMClient` (tests/CI), `ProviderClient` (réseau, phase 3).
- `ObservationView.render(observation) -> str` : rendu compact et stable
(clés triées, grilles sparsifiées : listes de cases `walls`/`difficult`).
- `legal_actions(view) -> tuple[Action, ...]` : sous-ensemble légal (facultatif
mais recommandé pour réduire les rejets).
- `NLPolicy(observation) -> tuple[Action, ...]` : politique branchée au moteur ;
en interne : vue → prompt → client → parse JSON strict → validation →
réparation → repli (`default_policy` ou `wait`).
- `PolicyMemory` : notes par combattant portées entre les tours (l'observation
étant sans état), opt-in via configuration.
- `ReplayRecord` : `{round, actor, schema_version, prompt_version, view,
actions, results}` sérialisé en JSONL.
### Flux d'un tour
1. `CombatEngine._take_turn` construit `Observation` et appelle `NLPolicy`.
2. `NLPolicy` projette la vue, assemble le prompt (système + stratégie + vue +
rappel des actions légales + derniers résultats), interroge le client
(cache d'abord), parse et valide la séquence d'`Action`.
3. En cas d'actions invalides : boucle de réparation (renvoyer le motif de
rejet) ; au-delà de N essais : repli sûr.
4. Le moteur exécute, produit `ActionResult` + transcript ; le `ReplayLog`
enregistre tout.
## 4. Décisions de conception à trancher
| # | Décision | Recommandation | | # | Décision | Recommandation |
|---|---|---| |---|---|---|
| D1 | Entrée de la politique : `(observation, state)` ou `(observation)` seule ? | Passer à `(observation)` (le `state` viole le contrat « snapshot seule source ») ; garder un adaptateur de compatibilité. | | 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 | Alimenter la LLM avec `to_dict()` brut ou une vue dédiée ? | **Vue dédiée** compacte + versionnée (tokens, stabilité). | | D2 | Vue dédiée vs `to_dict()` brut ? | Vue dédiée compacte et versionnée (objet structuré). |
| D3 | Mémoire inter-tours | `PolicyMemory` opt-in (scratchpad borné), sinon politique sans état. | | D3 | Mémoire inter-tours | **Retirée de la v1** (le snapshot porte déjà `last_turn_results`, `readied`, `pending_cast`). |
| D4 | Format de sortie des actions | JSON strict (liste d'objets `{kind, params}`) + validation schéma ; repair loop. | | D4 | Sortie des actions | JSON strict ; **schéma généré depuis `Action`** ; **exclure** `swift`/`free`/`full_round` (rejetés). |
| D5 | Exposer `legal_actions` ? | Oui : réduit les rejets et sert de garde-fou déterministe. | | D5 | API d'actions légales | **Pas de second moteur** : candidats dérivés de `pairs` ; le rejet moteur est l'arbitre. |
| D6 | Reproductibilité | Température 0 + `ResponseCache` (hash de prompt) + log de replay. | | D6 | Reproductibilité | Cache composite (clé ci-dessus) ; temp 0 = best-effort, le cache est la garantie. |
| D7 | Intégration runner/CLI | Ajouter `llm_policy` à `run_encounter` + un point d'entrée de sweep dédié (module/commande), sans toucher à la CLI par défaut. | | 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 tactique v1 | Sous-ensemble curé d'actions (attaques, sorts, déplacement/destination, 5-ft, défense totale, ready/delay) avant le vocabulaire complet. | | 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`. |
## 5. Plan d'implémentation ## 6. Plan d'implémentation (révisé)
### P0 — Fondations (sans LLM) — prérequis ### P0 — Frontière durcie (sans LLM ; débloque tout)
| Tâche | Livrable | Critère de sortie | | Tâche | Livrable | Critère de sortie |
|---|---|---| |---|---|---|
| P0.1 | `schema_version` dans `Observation.to_dict()` + test du jeu de clés | gate verte ; version pinnée | | P0.0 | **Trancher D1** (obs seule + adaptateur) | type + adaptateur + tests |
| P0.2 | `ObservationView` compacte et déterministe (`llm_view.py`) + mesure de taille | test : rendu stable ; taille documentée | | P0.1 | `schema_version` dans `to_dict()` + test du jeu de clés pinné | gate verte |
| P0.3 | `legal_actions(view)` (réutilise les helpers existants) | tests unitaires par kind | | P0.2 | `ObservationView` structurée + `render()` compact + mesure de taille | rendu stable, taille documentée |
| P0.4 | Paramètre `llm_policy` sur `run_encounter` | test : une politique LLM factice tourne via le runner | | P0.3 | `candidates()` dérivées de `pairs` (pas de 2e moteur) | tests par kind (hors unsupported) |
| P0.5 | `ReplayLog` JSONL + fonction `replay(record)` | test : un run enregistré se rejoue **à l'identique** | | 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 LLM et politique ### P1 — Client et politiques
| Tâche | Livrable | Critère de sortie | | Tâche | Livrable | Critère de sortie |
|---|---|---| |---|---|---|
| P1.1 | `LLMClient` protocol + `FakeLLMClient` + `ResponseCache` | tests hors ligne | | P1.1 | `LLMClient` + `FakeLLMClient` (file scriptée) + cache composite | tests hors ligne |
| P1.2 | `NLPolicy` : prompt, parse JSON strict, validation, repair loop, repli | une bataille complète pilotée par le Fake ; rejets gérés et rejoués | | P1.2 | **Garde-fous coût/tokens + retry/backoff/rate-limit** (dans le client, dès maintenant) | plafond par sweep respecté |
| P1.3 | `PromptBuilder` versionné (système + stratégie + vue + actions) | golden de prompt (version pinnée) | | P1.3 | `NLPolicy` (étage B) : prompt, parse JSON, validation, 1 re-essai, repli | bataille complète pilotée par le Fake |
| P1.4 | Tests de robustesse : réponse malformée, action illégale, timeout | repli sûr, aucun crash | | P1.4 | `PromptBuilder` versionné + golden de prompt ; schéma d'action généré depuis `Action` | prompt/schéma pinnés |
### P2 — Stratégie et balayage ### P2 — Stratégie compilée et balayage
| Tâche | Livrable | Critère de sortie | | Tâche | Livrable | Critère de sortie |
|---|---|---| |---|---|---|
| P2.1 | `StrategySpec` (texte NL + leviers structurés) → politique | compilation testée | | P2.1 | `StrategySpec` (NL) + **compilateur NL → `Policy` paramétrée** (étage A) | compilation testée, politique déterministe |
| P2.2 | `SweepRunner` : stratégies × cartes × déploiements | sweep ≥ 2×2 bout en bout | | P2.2 | `SweepRunner` **parallèle** : stratégies × cartes × déploiements | sweep ≥ 2x2 bout en bout, borné en coût |
| P2.3 | Métriques : taux de victoire ±3σ, taux d'actions invalides, essais de réparation, tokens/latence/coût | rapport de sweep produit | | 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 — Intégration fournisseur et rapport (frontière phase 3) ### P3 — Fournisseur réel et rapport (frontière phase 3)
| Tâche | Livrable | Critère de sortie | | Tâche | Livrable | Critère de sortie |
|---|---|---| |---|---|---|
| P3.1 | `ProviderClient` réel (clé via environnement), garde-fous de coût | run réseau reproductible via cache | | P3.1 | `ProviderClient` (clé via environnement), coût/observabilité | run réseau reproductible via cache |
| P3.2 | Few-shot, versionnement de prompt, validation de schéma renforcée | taux d'invalidité mesuré et stable | | P3.2 | Few-shot, boucle de réparation complète, versionnement renforcé | taux d'invalidité mesuré |
| P3.3 | Rapport d'équilibrage (phase 3) alimenté par les métriques | sortie FR style README | | P3.3 | Rapport d'équilibrage FR (phase 3) | sortie style README |
## 6. Risques et mitigations ## 7. Risques (issus de la critique)
| Risque | Mitigation | 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).
| Non-déterminisme / coût de la LLM | température 0, `ResponseCache`, `FakeLLMClient` en CI, petits sweeps | 3. **Cache sans identité de modèle** → clé composite (D6).
| Dérive des prompts | `schema_version` + `prompt_version` + goldens de prompt | 4. **Divergence de légalité** : un `legal_actions` maison dériverait des helpers moteur → candidats dérivés de `pairs` (D5).
| Actions invalides fréquentes | `legal_actions` + repair loop + repli `default_policy` | 5. **Bascule tardive de D1** → invalide runner/démo/replay ; à trancher en P0.0.
| Explosion de tokens | vue compacte + grilles sparsifiées + bornage des listes | 6. **Bandes 3σ trompeuses** à petit n (étage B) → réservées à l'étage A.
| Évolution du modèle fournisseur | cache + replays enregistrés + version pinnée | 7. Non-déterminisme fournisseur, dérive de prompt, fuite de secrets → cache/versions/env.
| Fuite d'état vivant dans la politique | entrée `(observation)` seule (D1) + tests d'isolation |
## 7. Questions ouvertes (à trancher avant P1/P3) ## 8. Première action recommandée
1. **Fournisseur/modèle** : API distante ou modèle local ? (impacte P3, coût, latence, confidentialité) Un **spike** qui tranche les deux forks avant tout composant :
2. **Budget** : plafond de tokens/coût par sweep ? (a) D1 (observation seule + adaptateur) et `schema_version` + test de clés pinné ;
3. **Exigence de reproductibilité** : un run LLM doit-il être rejouable bit-à-bit (cache obligatoire) ? (b) **mesure réelle** de la taille du prompt par tour et du nombre d'appels par
4. **Périmètre tactique v1** : quelles dimensions en premier (offensive, positionnement, contrôle) ? cellule de sweep avec la politique de démo — ce chiffre décide si la v1 est
5. **Emplacement des prompts** : dans le dépôt (versionnés) ou externes ? per-tour ou compilée, et vaut plus que n'importe quel composant du plan.
6. **Entrée de la politique** : valide-t-on D1 (`observation` seule) ?
## 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) ?