# 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) ?