# 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 > l'architecture cible, les décisions à trancher et un plan d'implémentation > vérifiable par étapes. ## 1. Objectif et périmètre **Objectif phase 2** (cf. README, feuille de route) : traduire des stratégies exprimées en langage naturel en politiques tactiques, puis balayer des matrices de positionnement / de stratégie, en s'appuyant sur le moteur déterministe. **Hors périmètre phase 2** : la rédaction du rapport d'équilibrage par la LLM (phase 3). **Principe directeur** : le moteur reste **déterministe et ignorant de la LLM**. 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) | Élément | Emplacement | État | |---|---|---| | Snapshot `Observation` (figé, `to_dict()` JSON-sérialisable, sans `engine.*`) | `combat.py:1262` / `to_dict` `1310` | en place, documenté, audité | | Types de politique `Policy`, `LLMPolicy`, `ReactionPolicy` | `combat.py:10573-10577` | en place | | Câblage moteur `policy` / `llm_policy` (mutuellement exclusifs) | `combat.py:1615-1622` | en place | | Retour d'action `ActionResult` + `last_turn_results` + vocabulaire des raisons | `combat.py:1483` | en place | | 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éterminisme (`SeededRng(seed+i)`, `ScriptedRng`) | `rng.py`, `runner.py` | en place | | Base de règles conforme SRD | `docs/conformance-srd.md` | 15 passes d'audit, gate 1981 tests | ### Écarts / manques identifiés pour la phase 2 1. **`run_encounter` n'accepte pas `llm_policy`** (`runner.py:172` : seul `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 (inchangé) │ │ CombatEngine : Observation, Action, │ │ ActionResult, RNG seedé, règles SRD │ └───────────────┬──────────────────────────┘ │ observation (snapshot figé) ▼ ┌──────────────────────────────────────────────────────────────┐ │ Couche Phase 2 (nouvelle) │ │ │ │ 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 - `LLMClient` (protocol) : `complete(prompt: Prompt) -> str` ; implémentations `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 | |---|---|---| | 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é. | | D2 | Alimenter la LLM avec `to_dict()` brut ou une vue dédiée ? | **Vue dédiée** compacte + versionnée (tokens, stabilité). | | D3 | Mémoire inter-tours | `PolicyMemory` opt-in (scratchpad borné), sinon politique sans état. | | D4 | Format de sortie des actions | JSON strict (liste d'objets `{kind, params}`) + validation schéma ; repair loop. | | D5 | Exposer `legal_actions` ? | Oui : réduit les rejets et sert de garde-fou déterministe. | | D6 | Reproductibilité | Température 0 + `ResponseCache` (hash de prompt) + log de replay. | | 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. | | 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. | ## 5. Plan d'implémentation ### P0 — Fondations (sans LLM) — prérequis | 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.2 | `ObservationView` compacte et déterministe (`llm_view.py`) + mesure de taille | test : rendu stable ; taille documentée | | P0.3 | `legal_actions(view)` (réutilise les helpers existants) | tests unitaires par kind | | P0.4 | Paramètre `llm_policy` sur `run_encounter` | test : une politique LLM factice tourne via le runner | | P0.5 | `ReplayLog` JSONL + fonction `replay(record)` | test : un run enregistré se rejoue **à l'identique** | ### P1 — Client LLM et politique | Tâche | Livrable | Critère de sortie | |---|---|---| | P1.1 | `LLMClient` protocol + `FakeLLMClient` + `ResponseCache` | 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.3 | `PromptBuilder` versionné (système + stratégie + vue + actions) | golden de prompt (version pinnée) | | P1.4 | Tests de robustesse : réponse malformée, action illégale, timeout | repli sûr, aucun crash | ### P2 — Stratégie et balayage | Tâche | Livrable | Critère de sortie | |---|---|---| | P2.1 | `StrategySpec` (texte NL + leviers structurés) → politique | compilation testée | | P2.2 | `SweepRunner` : stratégies × cartes × déploiements | sweep ≥ 2×2 bout en bout | | P2.3 | Métriques : taux de victoire ±3σ, taux d'actions invalides, essais de réparation, tokens/latence/coût | rapport de sweep produit | ### P3 — Intégration fournisseur et rapport (frontière phase 3) | 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.2 | Few-shot, versionnement de prompt, validation de schéma renforcée | taux d'invalidité mesuré et stable | | P3.3 | Rapport d'équilibrage (phase 3) alimenté par les métriques | sortie FR style README | ## 6. Risques et mitigations | Risque | Mitigation | |---|---| | Non-déterminisme / coût de la LLM | température 0, `ResponseCache`, `FakeLLMClient` en CI, petits sweeps | | Dérive des prompts | `schema_version` + `prompt_version` + goldens de prompt | | Actions invalides fréquentes | `legal_actions` + repair loop + repli `default_policy` | | Explosion de tokens | vue compacte + grilles sparsifiées + bornage des listes | | Évolution du modèle fournisseur | cache + replays enregistrés + version pinnée | | Fuite d'état vivant dans la politique | entrée `(observation)` seule (D1) + tests d'isolation | ## 7. Questions ouvertes (à trancher avant P1/P3) 1. **Fournisseur/modèle** : API distante ou modèle local ? (impacte P3, coût, latence, confidentialité) 2. **Budget** : plafond de tokens/coût par sweep ? 3. **Exigence de reproductibilité** : un run LLM doit-il être rejouable bit-à-bit (cache obligatoire) ? 4. **Périmètre tactique v1** : quelles dimensions en premier (offensive, positionnement, contrôle) ? 5. **Emplacement des prompts** : dans le dépôt (versionnés) ou externes ? 6. **Entrée de la politique** : valide-t-on D1 (`observation` seule) ?