630f5321e0
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.
162 lines
9.6 KiB
Markdown
162 lines
9.6 KiB
Markdown
# 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** (`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é |
|
||
|---|---|---|
|
||
| `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 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).
|
||
|
||
## 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)
|
||
|
||
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.
|
||
|
||
## 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
|
||
|
||
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) ?
|