From 630f5321e0322d0081e3e021fff1a696697321de Mon Sep 17 00:00:00 2001 From: ctan Date: Mon, 21 Sep 2026 09:55:53 +0200 Subject: [PATCH] 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. --- docs/phase-2-llm-architecture.md | 262 ++++++++++++++----------------- 1 file changed, 120 insertions(+), 142 deletions(-) diff --git a/docs/phase-2-llm-architecture.md b/docs/phase-2-llm-architecture.md index a5e902d..772984c 100644 --- a/docs/phase-2-llm-architecture.md +++ b/docs/phase-2-llm-architecture.md @@ -1,183 +1,161 @@ # 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. +> 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** (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. +**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). -**Hors périmètre phase 2** : la rédaction du rapport d'équilibrage par la 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. -**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. Décision structurante : deux étages (correction majeure) -## 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é | -| 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 | +| `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 | -### É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 - `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 +## 4. Architecture cible (révisée) ``` - ┌──────────────────────────────────────────┐ - │ 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 │ - └──────────────────────────────────────────────────────────────┘ +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 +**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 - `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 +## 5. Décisions | # | 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. | +| 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`. | -## 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 | |---|---|---| -| 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** | +| 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 LLM et politique +### P1 — Client et politiques | 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 | +| 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 et balayage +### P2 — Stratégie compilée 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 | +| 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 — 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 | |---|---|---| -| 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 | +| 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 | -## 6. Risques et mitigations +## 7. Risques (issus de la critique) -| 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 | +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. -## 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é) -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) ? +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) ?