438 lines
24 KiB
Markdown
438 lines
24 KiB
Markdown
# Phase 2 — Architecture et plan d'implémentation de la couche de décision LLM
|
||
|
||
> Document de conception (révisé). **Aucun code n'est encore écrit.**
|
||
> Révision intégrant deux demandes : **optimiser la meilleure stratégie de
|
||
> survie** et **cartographier tous les cas où un camp est en difficulté**.
|
||
|
||
## 1. Objectif et périmètre
|
||
|
||
Le simulateur doit répondre à **trois questions distinctes**, souvent confondues :
|
||
|
||
| Question | Nature | Réponse apportée par |
|
||
|---|---|---|
|
||
| « Comment *exécuter* une tactique donnée ? » | Exécution | Étage A (compilé) / Étage B (LLM par tour) |
|
||
| « Quelle est la *meilleure* stratégie de survie ? » | **Optimisation** | Couche **Optimiseur** (recherche sur `StrategySpec`) |
|
||
| « *Où* les PJ (ou les monstres) sont-ils en difficulté ? » | **Robustesse adversariale** | Couche **Robustesse** (min-max + cartographie de scénarios) |
|
||
|
||
**Hors périmètre** : la rédaction finale du rapport d'équilibrage (phase 3).
|
||
|
||
**Principe directeur** : les **règles** du moteur restent inchangées ; la couche
|
||
LLM et les couches d'analyse sont **additives**. La LLM n'est ni l'optimiseur ni
|
||
l'exécutant des sweeps.
|
||
|
||
## 2. Exécution — les deux étages (rappel)
|
||
|
||
Mettre la LLM à chaque tour dans une boucle Monte Carlo est prohibitif
|
||
(`runs × rounds × combattants` appels, caches non mutualisables). D'où :
|
||
|
||
- **Étage A — stratégie compilée (quantitatif).** La LLM traduit une stratégie NL
|
||
en un **objet de paramètres** (`StrategySpec`) ; un **compilateur déterministe**
|
||
en fait une `Policy` Python exécutée **sans appel LLM**. → bandes valides, coût
|
||
maîtrisé, **déterminisme bit-à-bit**.
|
||
- **Étage B — LLM par tour (qualitatif, vitrine).** `NLPolicy(observation)` à
|
||
chaque tour, sur **≤ 20 runs**. → démonstration du raisonnement, transcripts
|
||
exploitables ; **pas** de conclusion statistique.
|
||
|
||
`default_policy` devient un cas particulier : `compile_strategy(SPEC_PAR_DEFAUT)`.
|
||
|
||
## 3. Décider — les deux nouvelles couches
|
||
|
||
L'étage A fournit une **fonction d'évaluation déterministe** : c'est le bon
|
||
substrat pour *chercher* et pour *éprouver*.
|
||
|
||
### 3.1 Optimiseur — « meilleure stratégie de survie »
|
||
|
||
- **Espace** : les leviers de `StrategySpec` (par PJ ou par rôle).
|
||
- **Oracle** : `evaluate(spec, scenario, n, seeds) → métriques` (runs parallèles).
|
||
- **Objectifs** : `P(survivre tous)`, `P(TPK)`, `P(≥1 mort)`, espérance de PV
|
||
perdus, ressources consommées, rounds, **risque de queue (CVaR / pire décile)**.
|
||
- **Algorithmes** : grille grossière / échantillonnage latin → affinage
|
||
(descente coordonnée, **CMA-ES**, **optimisation bayésienne**) ; la LLM peut
|
||
**amorcer** avec des stratégies candidates (NL → `StrategySpec`).
|
||
- **Sortie** : un **playbook** classé (« si groupés : Buff → Contrôle → Focus
|
||
lanceur ; reculer sous 30 % PV ») avec incertitude.
|
||
|
||
### 3.2 Robustesse et cartographie — « où est le danger »
|
||
|
||
Un danger n'existe que **contre un adversaire qui cherche à gagner** : évaluer
|
||
les PJ contre la politique par défaut **sous-estime** le risque. Il faut :
|
||
|
||
- **`AdversarySpec`** (symétrique de `StrategySpec`) : priorité de cible, focus
|
||
fire, usage des capacités, embuscade, positionnement.
|
||
- **Min-max** : pour chaque scénario, `max_politique_PJ · min_politique_monstres`
|
||
(optimisation alternée = meilleures réponses) → pire cas réaliste. La même
|
||
mesure donne **les deux sens** : difficulté pour les PJ *et* pour les monstres
|
||
(rencontre trop facile).
|
||
- **Cartographie** (au lieu d'une énumération impossible) :
|
||
1. grille grossière sur `ScenarioSpec` → cellules d'échec ;
|
||
2. **affinage près de la frontière** (bissection / apprentissage actif) ;
|
||
3. **frontière de danger** : pour chaque mix, le seuil (ex. « < 45 % PV
|
||
cumulés et sans soigneur à portée ⇒ `P(TPK) > 20 %` ») ;
|
||
4. **analyse de sensibilité** : facteurs dominants (surprise, terrain,
|
||
épuisement des ressources, initiative).
|
||
|
||
## 4. É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)` + métriques 3σ + attrition | `runner.py`, `metrics.py` | oui |
|
||
| Base de règles conforme | `docs/conformance-srd.md` (gate 1981) | oui |
|
||
|
||
**Manques confirmés** : `run_encounter` sans `llm_policy` (`runner.py:177`) ;
|
||
CLI sans politique ; 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 ; **aucune couche d'objectifs,
|
||
de recherche, d'adversaire ou de scénarios**.
|
||
|
||
## 5. Architecture cible
|
||
|
||
```
|
||
Moteur (règles inchangées ; frontière additive)
|
||
|
|
||
|-- EXÉCUTION
|
||
| |- Étage A : compile_strategy(StrategySpec) -> Policy (sweeps)
|
||
| \-- Étage B : NLPolicy(observation) (vitrine <=20 runs)
|
||
|
|
||
|-- DÉCISION / ANALYSE
|
||
| |- objectives.py : métriques de survie/danger (dont queue)
|
||
| |- scenarios.py : ScenarioSpec + échantillonnage
|
||
| |- search.py : optimiseur (objectif = objectives), oracle = simulateur
|
||
| |- adversary.py : AdversarySpec + politiques de monstres fortes (red team)
|
||
| \-- robustness.py : min-max, cartographie de danger, frontière, sensibilité
|
||
|
|
||
\-- SUPPORT
|
||
|- view.py : ObservationView (objet structuré, versionné) + render()
|
||
|- candidates.py : actions plausibles dérivées des pairs
|
||
|- prompt.py : chargement EXTERNE (pack séparé) + versionnage
|
||
|- client.py : LLMClient + cache composite + FakeLLMClient scripté + coût
|
||
|- replay.py : JSONL clé call_seq + replay()
|
||
\-- sweep.py : exécution parallèle des runs (substrat commun)
|
||
```
|
||
|
||
**Interfaces clés** : `ObservationView` (objet + `render()`), `candidates()`
|
||
(dérivées des `pairs`, pas de 2ᵉ moteur de légalité), `ResponseCache` (clé
|
||
composite), `ReplayRecord` (clé `call_seq`), `PolicyFactory` (par run),
|
||
`evaluate(...)` (oracle d'optimisation), `danger_map(...)` (cartographie).
|
||
|
||
## 6. Espaces de recherche
|
||
|
||
| Espace | Contenu | Utilisé par |
|
||
|---|---|---|
|
||
| `StrategySpec` | priorité de cible, style d'engagement, distance, esquive d'AoO, seuil de repli, buffs d'ouverture, politique de sorts, ressources, focus fire | Optimiseur / Étage A |
|
||
| `AdversarySpec` | mêmes leviers côté monstres (focus, ciblage du soigneur/lanceur, contrôle, embuscade) | Robustesse |
|
||
| `ScenarioSpec` | composition/niveaux, **PV et ressources initiaux**, déploiement/positionnement, carte/terrain, **surprise**, distance, mix et nombre de monstres, CR | Cartographie |
|
||
|
||
## 7. Objectifs et métriques
|
||
|
||
| Métrique | Pourquoi |
|
||
|---|---|
|
||
| `P(survivre tous)` / `P(TPK)` / `P(≥ 1 mort)` | lecture directe de la survie |
|
||
| Espérance de PV perdus (groupe / PJ) | gravité |
|
||
| Ressources consommées (sorts, potions, canal) | soutenabilité |
|
||
| Rounds | tempo |
|
||
| **CVaR / pire décile** | **risque de queue** — la moyenne cache les TPK |
|
||
| Bandes 3σ (n suffisant) | incertitude — **étage A uniquement** |
|
||
|
||
## 8. Rôle de la LLM (recadré)
|
||
|
||
| Usage | Pourquoi la LLM |
|
||
|---|---|
|
||
| NL → `StrategySpec` / `AdversarySpec` | traduction structurée (petit, robuste) |
|
||
| **Générateur d'hypothèses** (candidats variés à amorcer) | créativité, pas de gradient |
|
||
| **Red Team** (tactiques de monstres fortes) | révéler les faiblesses |
|
||
| **Explicateur** (lire les transcripts dangereux) | causalité en langage naturel |
|
||
| **Rapporteur** (playbook + carte de risque) | rédaction (phase 3) |
|
||
|
||
**Non-usage** : optimisation numérique (faite par `search.py`) et exécution par
|
||
tour en sweep (faite par l'étage A).
|
||
|
||
## 9. Décisions
|
||
|
||
> Toutes les décisions ci-dessous sont **arbitrées** (voir §13) ; le tableau
|
||
> conserve la recommandation validée. Les prompts sont traités au §14.
|
||
| # | Décision | Recommandation |
|
||
|---|---|---|
|
||
| D1 | Entrée : `(observation, state)` ou `(observation)` ? | **`(observation)`** + adaptateur. **À trancher en P0.0.** |
|
||
| D2 | Vue dédiée vs `to_dict()` brut | Vue compacte, structurée, versionnée |
|
||
| D3 | Mémoire inter-tours | **Retirée de la v1** |
|
||
| D4 | Sortie d'actions | JSON strict, **schéma généré depuis `Action`**, `swift/free/full_round` exclus |
|
||
| D5 | Légalité | **Pas de 2ᵉ moteur** : candidats dérivés des `pairs` |
|
||
| D6 | Reproductibilité | Cache composite `(model, params, prompt_version, schema_version, hash)` |
|
||
| D7 | Runner/CLI | `llm_policy` + `policy_factory` sur `run_encounter` ; entrée de sweep dédiée |
|
||
| D8 | Périmètre v1 | Sous-ensemble curé ; `ReactionPolicy` par défaut conservée |
|
||
| D9 | Économie | **Deux étages** (exécution) |
|
||
| D10 | Parallélisme | Runs indépendants → pool de threads, cache shardé |
|
||
| D11 | Réactions | v1 par défaut ; LLM via `ready` |
|
||
| D12 | Danger = **adversarial** | Mesurer contre des monstres **optimisés**, sinon sous-estimation |
|
||
| D13 | Objectifs | Inclure le **risque de queue**, pas seulement la moyenne |
|
||
| D14 | Cartographie | **Non exhaustive** : coarse-to-fine + frontière + sensibilité |
|
||
|
||
## 10. Plan d'implémentation
|
||
|
||
### P0 — Frontière durcie (sans LLM ; débloque tout)
|
||
P0.0 trancher D1 (obs seule + adaptateur) · P0.1 `schema_version` + clés pinnées ·
|
||
P0.2 `ObservationView` + mesure de taille · P0.3 `candidates()` (depuis `pairs`) ·
|
||
P0.4 `run_encounter(..., llm_policy=, policy_factory=)` · P0.5 `ReplayLog`
|
||
(`call_seq`) + `replay()` identique.
|
||
**Sortie** : gate verte ; un run enregistré se rejoue à l'identique.
|
||
|
||
### P1 — Client et politiques
|
||
P1.1 `LLMClient` + `FakeLLMClient` scripté + cache composite · P1.2 **garde-fous
|
||
coût/tokens + retry/backoff** · P1.3 `NLPolicy` (étage B : parse, valide, 1
|
||
re-essai, repli) · P1.4 `PromptBuilder` versionné + schéma d'action généré.
|
||
**Sortie** : bataille complète pilotée par le Fake, hors ligne.
|
||
|
||
### P2 — Étage A : compilation, objectifs, sweep
|
||
P2.1 compilateur NL → `Policy` paramétrée · P2.2 `objectives.py` · P2.3
|
||
`SweepRunner` **parallèle**.
|
||
**Sortie** : sweep ≥ 2×2 avec métriques, borné en coût.
|
||
|
||
### P3 — Optimiseur (« meilleure stratégie »)
|
||
P3.1 `scenarios.py` (`ScenarioSpec` + échantillonnage) · P3.2 `search.py`
|
||
(grille → affinage) · P3.3 amorçage par candidats LLM.
|
||
**Sortie** : playbook classé avec incertitude pour ≥ 1 rencontre type.
|
||
|
||
### P4 — Adversaire et cartographie (« où est le danger »)
|
||
P4.1 `AdversarySpec` + politiques de monstres fortes · P4.2 `robustness.py`
|
||
(min-max alterné) · P4.3 `danger_map` (coarse-to-fine, frontière, sensibilité).
|
||
**Sortie** : carte de danger (PJ **et** monstres) + frontière seuils.
|
||
|
||
### P5 — Fournisseur réel et rapport (frontière phase 3)
|
||
P5.1 `ProviderClient` (clé env, coût/observabilité) · P5.2 few-shot + réparation
|
||
complète · P5.3 rapport FR (phase 3).
|
||
|
||
## 11. Risques
|
||
|
||
1. **Économie per-tour** → résolue par les deux étages.
|
||
2. **Adversaire trop faible** : danger sous-estimé → monstres **optimisés** (D12).
|
||
3. **Replay sous-spécifié** (`delay`, invocations) → clé `call_seq`.
|
||
4. **Cache sans identité de modèle** → clé composite.
|
||
5. **Divergence de légalité** → candidats dérivés des `pairs`.
|
||
6. **Bascule tardive de D1** → à trancher en P0.0.
|
||
7. **Bandes 3σ trompeuses** à petit n → étage A pour le quantitatif.
|
||
8. **Explosion combinatoire** → coarse-to-fine, pas d'énumération (D14).
|
||
9. **Événements rares (TPK)** : n énorme en Monte Carlo naïf → ciblage par
|
||
scénarios, ou annoncer la limite.
|
||
|
||
## 12. Première action recommandée
|
||
|
||
Un **spike** (sans LLM) qui :
|
||
(a) tranche **D1** + `schema_version` + test de clés pinné ;
|
||
(b) **mesure** taille de prompt et appels par cellule de sweep (décide per-tour
|
||
vs compilé) ;
|
||
(c) esquisse `ScenarioSpec` + `objectives.py` et **valide la chaîne de
|
||
cartographie** sur une rencontre type, en comparant `default_policy` contre une
|
||
politique de monstres renforcée — ce dernier point prouve immédiatement la valeur
|
||
« prédire où sont les difficultés », avant toute LLM.
|
||
|
||
## 13. Décisions arbitrées
|
||
|
||
| # | Décision | Choix retenu |
|
||
|---|---|---|
|
||
| D1 | Entrée de la politique | **`(observation)` seule** + adaptateur de compatibilité |
|
||
| D9 | Exécution | **Deux étages** (compilé pour les sweeps, per-tour pour la vitrine) |
|
||
| D12 | Mesure du danger | **Contre un adversaire optimisé** (`AdversarySpec` + meilleure réponse) |
|
||
| D14 | Cartographie | **Non exhaustive** : région d'échec + frontière + sensibilité |
|
||
| — | Priorité v1 | **Cartographie d'abord**, puis optimiseur (mêmes briques) |
|
||
| — | Fournisseur/modèle | **Client agnostique** ; implémentation distante d'abord, locale ensuite ; **mode JSON/schéma requis** |
|
||
| — | Budget | **Plafonds petits** (≤ 50 appels de compilation/campagne, ≤ 20 combats en vitrine) + coupe-circuit dans le client |
|
||
| — | Reproductibilité | **Cache obligatoire** ; clé composite incluant le modèle ; replay `call_seq` |
|
||
| — | Prompts | **Externes / dépôt séparé** (voir §14) |
|
||
|
||
Reste à préciser au fil des lots (non bloquant) : forme exacte des leviers de
|
||
`StrategySpec`/`AdversarySpec`, choix de l'algorithme d'optimisation, seuils
|
||
statistiques par défaut, et le fournisseur distant concret.
|
||
|
||
## 14. Prompts externes (décision utilisateur)
|
||
|
||
Les prompts **ne vivent pas dans ce dépôt** : ce dépôt ne doit contenir que le
|
||
**code du simulateur** (calcul + couche LLM + rapport). Conséquences :
|
||
|
||
- **Chargement configurable** : le code lit les prompts depuis un **chemin
|
||
externe** (variable d'environnement / fichier de configuration), jamais codés
|
||
en dur.
|
||
- **Versionnage côté pack** : le dépôt de prompts porte sa propre version
|
||
(semver ou hash de commit) ; chaque prompt a un hash individuel.
|
||
- **Traçabilité** : le `prompt_version` (version du pack + hash du prompt)
|
||
entre dans la **clé du cache**, dans le `ReplayRecord` et dans les rapports de
|
||
sweep — sinon une campagne n'est pas reproductible après modification d'un
|
||
prompt.
|
||
- **Tests** : le pack externe est **épinglé** pour l'intégration (version fixée) ;
|
||
les tests unitaires utilisent un **fixture de prompt minimal** local pour que
|
||
la CI ne dépende pas du dépôt externe.
|
||
- **Validation** : un prompt manquant/invalide doit produire une **erreur
|
||
claire** au démarrage (pas de fallback silencieux).
|
||
|
||
## 15. Périmètre du dépôt et backlog de détachement
|
||
|
||
Décision utilisateur : à terme, **seul le code** doit rester ici. Sont
|
||
concernés par un détachement (données et contenu) : fiches de personnages
|
||
(`fiches_personnages/`), données de monstres/PJ/sorts/cartes (`data/`), et les
|
||
fichiers de prompts.
|
||
|
||
**Pour l'instant : laissé en place** (rattrapage prévu en fin de projet).
|
||
Contrainte de conception à respecter dès maintenant pour ne pas se peindre dans
|
||
un coin :
|
||
|
||
- **Aucun chemin de données en dur** dans le nouveau code (couche phase 2) :
|
||
centraliser la résolution des chemins (config/env) pour préparer le
|
||
déplacement.
|
||
- Les prompts sont **déjà** conçus comme externes (§14) — pas de régression.
|
||
- Le détachement final devra fournir un **jeu de données d'exemple** et une
|
||
documentation de configuration (chemins) pour que le dépôt code reste utilisable
|
||
seul.
|
||
|
||
## 16. Résultats du spike (exécuté)
|
||
|
||
**Livré** : D1 (`LLMPolicy(Observation)` + `LegacyLLMPolicy`), `schema_version`
|
||
pinné, `objectives.py`, `run_encounter(..., collect_runs=True)`, une politique
|
||
de monstres **focus fire** (spike) et une **carte de danger** de démonstration.
|
||
|
||
### Mesures (décision d'architecture)
|
||
|
||
| Mesure | Valeur |
|
||
|---|---|
|
||
| Observation, 7 combattants | **64 Ko ≈ 16 000 tokens** (max 67 Ko) |
|
||
| Observation, duel 2v2 | 22 Ko ≈ 5 500 tokens |
|
||
| Appels de politique / combat | **124** (7 combattants), 12 (duel) |
|
||
| Appels par cellule de sweep (1 000 runs) | **124 000** |
|
||
|
||
→ **Conclusion 1** : un LLM **par tour** en sweep est infaisable (~2 milliards de
|
||
tokens par cellule) — **D9 validé**, et la **vue compacte est obligatoire**
|
||
(cible ~2-4 k tokens, contre 16 k pour le snapshot brut).
|
||
|
||
### Carte de danger (RUNS=100, seed=11)
|
||
|
||
| Scénario | Monstres | Victoire PJ | Survivent | TPK | ≥1 mort | PV perdus | Rounds |
|
||
|---|---|---|---|---|---|---|---|
|
||
| warband 4v4 | défaut | 90,0 % | 87,0 % | 0,0 % | 13,0 % | 66,1 | 12,3 |
|
||
| warband 4v4 | focus fire | 98,0 % | 76,0 % | 0,0 % | **24,0 %** | 45,0 | 9,6 |
|
||
| liche 2v2 | défaut | 0,0 % | 100,0 % | 0,0 % | 0,0 % | 44,0 | 3,7 |
|
||
| liche 2v2 | focus fire | 0,0 % | 81,0 % | **2,0 %** | **19,0 %** | 44,0 | 5,8 |
|
||
|
||
→ **Conclusion 2** : mesuré contre la politique par défaut, le danger est
|
||
**sous-estimé** (morts 13 %→24 % et 0 %→19 %, TPK 0 %→2 %) — **D12 validé**.
|
||
→ **Conclusion 3** : le **taux de victoire trompe** (il monte à 98 % pendant que
|
||
les morts augmentent) → les métriques de mort/PV/**queue** sont nécessaires —
|
||
**D13 validé**.
|
||
|
||
**Limites du spike** : n=100 (bandes larges ±9 %), adversaire de niveau spike
|
||
(focus fire seulement, sans positionnement ni sorts), 2 scénarios. La
|
||
cartographie réelle (P4) demandera plus de runs, un adversaire complet
|
||
(`AdversarySpec`) et un balayage de `ScenarioSpec`.
|
||
|
||
### Vue compacte (P0.2, mesures réelles — commit 9da9537)
|
||
|
||
`llm_view.py` : `ObservationView` (structuré, déterministe) + `build_view()`
|
||
+ `render()` vers texte pour prompt. Règles : **grilles sparsifiées** (terrain
|
||
et élévation = cellules non défaut ; `threatened`/`ranged_threatened` = liste
|
||
de cellules, omis si vide), **clés décisionnelles seules** par combattant et
|
||
par paire, `schema_version` inclus, ordre déterministe trié. Mesure sur
|
||
l'affrontement représentatif (oni/misty/nairda vs lich/thrune_agent/orc/goblin,
|
||
seed 1) :
|
||
|
||
| Vue | Octets | Tokens | % du raw |
|
||
|---|---|---|---|
|
||
| brute `to_dict()` | 64 204 | ~16 051 | 100 % |
|
||
| **compacte `render()`** | **15 691** | **~3 922** | **24,4 %** |
|
||
|
||
Ratio 24,4 % = **cible ~2-4k tokens atteinte**. Grilles : terrain 78 cellules
|
||
sparses, élévation 0, menacées 15, menacées à distance 116 (au lieu de matrices
|
||
pleines). Contrats de test : réduction < 25 % et < 16 000 octets, grilles
|
||
sparses, clés supprimées (condition_definitions, etc. — voir le test), ids/
|
||
render déterministes. Test : `tests/test_phase2_view.py`.
|
||
|
||
### Candidats d'action (P0.3 — commit 8b50430)
|
||
|
||
`candidates.py` : `candidates(observation) -> tuple[Action, ...]` — actions
|
||
**plausibles** dérivées des `pairs` et des données par combattant (attack/
|
||
full_attack si `weapon_for`, charge si `can_charge`, pas de 5 ft + attaque si
|
||
`can_step5_to_attack`, mouvement vers une cible, sort connu avec slot, canal/
|
||
hex/deed si ressource présente, défense totale, ready/delay) ; `swift`/`free`/
|
||
`full_round` exclus ; liste bornée et triée. **Pas de second moteur de
|
||
légalité** : le moteur reste l'arbitre. Test : `tests/test_phase2_candidates.py`.
|
||
|
||
### Runner et rejeu (P0.4 + P0.5 — commits 334f8f9, 6c6de16)
|
||
|
||
`run_encounter` accepte désormais `llm_policy=` (observation-only ou legacy,
|
||
transmise à chaque `CombatEngine`) et `policy_factory=` (constructeur de
|
||
`Policy` **par run**, appelé une fois par indice dans l'ordre ; `None` pour un
|
||
run = repli `default_policy`). Les trois entrées (`policy`, `llm_policy`,
|
||
`policy_factory`) sont mutuellement exclusives (erreur claire). Test :
|
||
`tests/test_phase2_runner.py` (factory 1 appel/run dans l'ordre, repli,
|
||
exclusivité, une bataille complète pilotée par `llm_policy`).
|
||
|
||
`replay.py` : `ReplayRecord(call_seq, observation, action)` + `ReplayLog`
|
||
(spec + seed + round_cap + appels) sérialisé en **JSONL** (clé `call_seq`),
|
||
`observation_key()` (digest déterministe, insensible à la collapse
|
||
tuple/liste de JSON), et `replay(log) -> LLMPolicy` qui renvoie les actions
|
||
enregistrées dans l'ordre des `call_seq`, avec gardes : observation
|
||
divergente (`ReplayMismatchError`), log épuisé (`ReplayExhaustedError`),
|
||
`call_seq` dupliqué ou hors ordre, JSONL invalide. **Un run enregistré se
|
||
rejoue à l'identique** (wins/draws/rounds/transcript — aussi depuis un log
|
||
dégelé du JSONL). Test : `tests/test_phase2_replay.py`. Le rejet de replay
|
||
par défaut est documenté (D3 : pas de mémoire inter-tours ; `delay`/ready
|
||
rejoués par les actions enregistrées elles-mêmes, sans ré-invocation).
|
||
|
||
### Étage A (P2 — commit dbd1a58)
|
||
|
||
`strategy.py` : `StrategySpec` (cible `nearest`/`weakest_ac`/`lowest_hp`,
|
||
`retreat_hp_fraction`, `spell_policy` `default`/`none`, `use_charges`,
|
||
`use_class_abilities`) et `compile_strategy(spec) -> Policy` —
|
||
`compile_strategy(StrategySpec()) is default_policy` (identité) ; les autres
|
||
specs compilent vers des politiques paramétrées (échelle de mêlée, charge
|
||
quand la ligne droite existe, retraite sous le seuil de pv, sorts / charges /
|
||
capacités de classe désactivables), cible choisie par `select_target`
|
||
(CA la plus basse, pv les plus bas, ou le plus proche). Test :
|
||
`tests/test_phase2_strategy.py`.
|
||
|
||
`sweep.py` : `SweepCell(scenario, strategy, spec, policy, runs, seed)` +
|
||
`SweepRunner(party=, workers=)` (registry des sorts chargée **une fois**,
|
||
lecture seule partagée entre threads — D10), `run(cells, max_total_runs=)`
|
||
(erreur claire nommant `runs`/`budget`, cellules ≥ 1 run) et `run_grid(...)`
|
||
en ordre **scénario-majeur** — chaque cellule : `run_encounter(collect_runs=True)`
|
||
+ `danger_metrics`. Une politique `None` = `default_policy` (le runner
|
||
n'importe pas `strategy.py`). `format_sweep` : rendu texte aligné (ascii).
|
||
Tests : `tests/test_phase2_sweep.py` (déterminisme bit-à-bit, workers=1 ≡
|
||
workers=4, cellule `None` ≡ `run_encounter` direct, borne de coût).
|
||
|
||
**Mesure représentative (RUNS=100, seed=7, partie `players` = gobelins,
|
||
adversaire `focus fire` = orcs)** :
|
||
|
||
| Scénario | Monstres | Victoire PJ | Survivent | TPK | ≥1 mort | PV perdus | Rounds |
|
||
|---|---|---|---|---|---|---|---|
|
||
| 3 gobelins vs 2 orcs | défaut | 93,0 % | 96,0 % | 0,0 % | 4,0 % | 5,0 | 6,5 |
|
||
| 3 gobelins vs 2 orcs | focus fire | 98,0 % | 100,0 % | 0,0 % | 0,0 % | 2,4 | 5,6 |
|
||
| 2 gobelins vs 3 orcs | défaut | 50,0 % | 96,0 % | 0,0 % | 4,0 % | 7,5 | 10,6 |
|
||
| 2 gobelins vs 3 orcs | focus fire | 54,0 % | 96,0 % | 0,0 % | 4,0 % | 7,3 | 10,1 |
|
||
|
||
→ la sortie P2 (« sweep ≥ 2×2 avec métriques, borné en coût ») est atteinte :
|
||
2×2 = 400 combats en quelques secondes sur 4 workers, métriques de danger
|
||
complètes par cellule, déterminisme vérifié. Ce 2×2 gobelins/orcs est un
|
||
**smoke test du runner** (n=100, bande 3σ ±6 % sur les taux) : à ce n, le
|
||
delta focus fire est dans le bruit sur les morts (4→0 en 3v2, plat en 2v3) —
|
||
contrairement au spike warband/liche où il sortait nettement de la bande. Le
|
||
signal le plus net ici est la **queue** (worst-decile HP 1,4 → 6,8 en 3v2 ;
|
||
0,0 dans les deux cellules 2v3) et les rounds moyens (6,5 → 5,6 en 3v2). La
|
||
cartographie du danger à grande n, PJ **et** monstres, reste **P4**.
|
||
|
||
### État du plan
|
||
|
||
- **P0** ✅ · **P1** ✅ (`LLMClient` + `FakeLLMClient` scripté + cache
|
||
composite, garde-fous coût/tokens, `NLPolicy`, `PromptBuilder` versionné) ·
|
||
**P2** ✅ (`strategy.py` + `SweepRunner`, sortie mesurée) — gate verte
|
||
(**2116 tests**).
|
||
- Détail P0 : **P0.0 (D1)** ✅ · **P0.1 (`schema_version`)** ✅ · mesures ✅ ·
|
||
`objectives.py` ✅ · `collect_runs` ✅ · **P0.2** (vue compacte) ✅ ·
|
||
**P0.3** (`candidates`) ✅ · **P0.4** (`llm_policy` + `policy_factory`) ✅ ·
|
||
**P0.5** (ReplayLog `call_seq` + `replay()` identique) ✅.
|
||
- **Frontière P0 atteinte** : gate verte (2022 tests) ; un run enregistré se
|
||
rejoue à l'identique.
|
||
- Suite : **P3** (optimiseur « meilleure stratégie »), P4 (cartographie
|
||
complète), P5 (fournisseur réel + rapport).
|