Files
pf1e-simulator/docs/phase-2-llm-architecture.md
T
ctan be64451ba4 docs(phase2): architecture and implementation plan for the LLM decision layer
Design-only document (no code): target architecture (compact versioned
observation view, legal-actions API, LLM client + cache, NL policy with
parse/validate/repair/fallback, strategy sweep, replay/eval harness), the
concrete gaps in the current runner/CLI wiring, the decisions to settle and a
phased implementation plan (P0 foundations, P1 client+policy, P2 strategy
sweep, P3 provider+report) with exit criteria and risks.
2026-09-21 09:51:05 +02:00

184 lines
12 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. **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) ?