diff --git a/docs/phase-2-llm-architecture.md b/docs/phase-2-llm-architecture.md new file mode 100644 index 0000000..a5e902d --- /dev/null +++ b/docs/phase-2-llm-architecture.md @@ -0,0 +1,183 @@ +# 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) ?