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.
9.6 KiB
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 (
Policyclassique, 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).
NLPolicyappelé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 depuispairs(le moteur reste l'arbitre vialast_turn_results).LLMClient:complete(prompt) -> str;FakeLLMClientpiloté par une file de réponses scriptée (idiome deScriptedRng).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é monotonecall_seq(et non{round, actor}), cardelayre-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)
- Économie de l'étage B : LLM par tour × Monte Carlo = coût prohibitif → résolu par les deux étages (§2).
- Replay sous-spécifié :
delay/re-queue et invocations → clécall_seqobligatoire (P0.5). - Cache sans identité de modèle → clé composite (D6).
- Divergence de légalité : un
legal_actionsmaison dériverait des helpers moteur → candidats dérivés depairs(D5). - Bascule tardive de D1 → invalide runner/démo/replay ; à trancher en P0.0.
- Bandes 3σ trompeuses à petit n (étage B) → réservées à l'étage A.
- 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
- Fournisseur/modèle (API distante ou local) ?
- Budget (tokens/coût) par sweep ?
- Reproductibilité : cache obligatoire (runs rejouables) ?
- Stratégie v1 : offensive, positionnement, contrôle — ordre ?
- Prompts dans le dépôt ou externes ?
- Valides-tu D1 (
LLMPolicy(observation)seule) et D9 (deux étages) ?