29 KiB
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 unePolicyPython 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 deStrategySpec) : 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) :
- grille grossière sur
ScenarioSpec→ cellules d'échec ; - affinage près de la frontière (bissection / apprentissage actif) ;
- frontière de danger : pour chaque mix, le seuil (ex. « < 45 % PV
cumulés et sans soigneur à portée ⇒
P(TPK) > 20 %») ; - analyse de sensibilité : facteurs dominants (surprise, terrain, épuisement des ressources, initiative).
- grille grossière sur
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()brutVue 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_roundexclusD5 Légalité Pas de 2ᵉ moteur : candidats dérivés des pairsD6 Reproductibilité Cache composite (model, params, prompt_version, schema_version, hash)D7 Runner/CLI llm_policy+policy_factorysurrun_encounter; entrée de sweep dédiéeD8 Périmètre v1 Sous-ensemble curé ; ReactionPolicypar défaut conservéeD9 É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 readyD12 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
- Économie per-tour → résolue par les deux étages.
- Adversaire trop faible : danger sous-estimé → monstres optimisés (D12).
- Replay sous-spécifié (
delay, invocations) → clécall_seq. - Cache sans identité de modèle → clé composite.
- Divergence de légalité → candidats dérivés des
pairs. - Bascule tardive de D1 → à trancher en P0.0.
- Bandes 3σ trompeuses à petit n → étage A pour le quantitatif.
- Explosion combinatoire → coarse-to-fine, pas d'énumération (D14).
- É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 leReplayRecordet 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_versioninclus, 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 politiqueNone=default_policy(le runner n'importe passtrategy.py).format_sweep: rendu texte aligné (ascii). Tests :tests/test_phase2_sweep.py(déterminisme bit-à-bit, workers=1 ≡ workers=4, celluleNone≡run_encounterdirect, 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.
Optimiseur (P3 — commit dd64c2e)
scenarios.py : SideSpec(name, zone, combatant_files, hp_fraction=1.0) et
ScenarioSpec(name, map_file, sides, round_cap=100) — build_encounter
compile un scénario en EncounterSpec déterministe (aucun chemin de données
en dur dans le src, §15), scenario_variants(base, hp_fractions=...) étend
grossier le scénario par fractions de PV des côtés (grille de robustesse, sans
RNG). Test : tests/test_phase2_scenarios.py.
search.py : evaluate(strategy, scenario, *, party, runs, seed, workers=4)
(StrategyBenchmark : métriques de danger du camp party + bande 3σ sur le
taux de victoire), PlaybookEntry(rank, strategy, benchmark),
strategy_name (nom canonique de StrategySpec) et
optimize(scenario, *, party, runs, seed, workers=4, max_total_runs=None, candidates=()) — recherche en 3 étages : grille grossière de 24 cellules
(cible × politique de sorts × charges × capacités de classe, repli à 0),
affinage des 3 meilleures cellules sur retreat_hp_fraction ∈ {0.25, 0.5, 0.75} (9 cellules ; cellules déjà mesurées dédupliquées), puis les candidats
LLM (candidates, P3.3) dédupliqués et évalués. Classement :
(-P(survive), PV perdus, -pire dcile PV, rounds, nom). Le budget
max_total_runs est vérifié avant tout combat (ValueError nommant
budget/max_total_runs). format_playbook rend le classement en colonnes
ASCII avec la bande 3σ. Test : tests/test_phase2_search.py.
Playbook représentatif (RUNS=200, seed=7, workers=4, camp mesuré =
players = 3 gobelins vs 2 orcs) — 33 cellules (grille 24 + affinage 9 +
candidats 0) ; les rangs non listés sont des variantes à leviers inertes ou
de repli strictement identiques à leur cellule de tête :
| Rang | Stratégie | Victoire PJ (3σ) | Survivent | ≥1 mort | PV perdus | Pire dcile | Rounds |
|---|---|---|---|---|---|---|---|
| 1 | lowest_hp (+ inertes, rangs 2-8) |
97,0 % [93,4 ; 100,0] | 99,0 % | 1,0 % | 3,0 | 5,2 | 5,8 |
| 9 | weakest_ac (+ inertes, rangs 10-16) |
97,0 % [93,4 ; 100,0] | 99,0 % | 1,0 % | 3,0 | 5,2 | 5,8 |
| 17 | lowest_hp, retreat=0.25 (+ inertes) |
96,0 % [91,8 ; 100,0] | 97,5 % | 2,5 % | 3,1 | 3,9 | 6,1 |
| 20 | lowest_hp, retreat=0.5 (+ inertes) |
95,0 % [90,4 ; 99,6] | 97,5 % | 2,5 % | 3,5 | 3,0 | 6,4 |
| 23 | default (nearest, repli 0) |
95,0 % [90,4 ; 99,6] | 97,0 % | 3,0 % | 5,0 | 2,8 | 6,9 |
| 31 | lowest_hp, retreat=0.75 (+ inertes) |
94,0 % [89,0 ; 99,0] | 95,5 % | 4,5 % | 3,7 | 2,7 | 7,6 |
TPK 0,0 % dans les 33 cellules. → la sortie P3 (« meilleur candidat du grid +
affinage, playbook classé ») est atteinte : 6600 combats en quelques secondes
sur 4 workers, classement déterministe (rejoué bit-à-bit), budget vérifié
avant exécution.
→ Le ciblage est le levier dominant : lowest_hp et weakest_ac battent
default sur toutes les métriques du camp — PV perdus 5,0 → 3,0, pire dcile
2,8 → 5,2, rounds 6,9 → 5,8, survive 97,0 → 99,0 %. Égalité bit-à-bit entre
les deux ordres de cibles : les adversaires adverses étant statistiquement
identiques (gobelins entre eux, orcs entre eux), les deux heuristiques
sélectionnent la même cible au même rang.
→ La retraite agressive est strictement dominée : seuil 0,25 → 0,5 → 0,75
dégrade la survie (99,0 → 97,5 → 97,5 → 95,5 %), le pire dcile (5,2 → 3,9 →
3,0 → 2,7) et allonge le combat (5,8 → 6,1 → 6,4 → 7,6 rounds) — une retraite
est une action à round complet qui paie le départ sans compenser.
→ Leviers inertes sur cette rencontre : spell_policy, use_charges et
use_class_abilities ne changent rien (gobelins et orcs n'ont ni sorts, ni
ressources, ni capacités de classe) — leurs variantes font exactement égalité
avec leur cellule de grille (rangs 2-8, 10-16, 24-30). Un scénario avec
lanceur est nécessaire pour discriminer ces leviers (rencontres riches en
P4/P5).
→ Sémantique de mesure — symétrique : une politique par cellule, appliquée
aux deux camps (CombatEngine exécute la politique passée au moteur, sans
routage par côté ; compile_strategy est côté-agnostic) ; le classement porte
sur les DangerMetrics du camp party. Contrairement au spike P2
(focus_fire_policy routait par côté : orcs uniquement), le playbook mesure
donc « la meilleure stratégie quand tout le monde la joue » — l'asymétrie PJ
contre adversaire optimisé arrive en P4 (AdversarySpec).
→ Cohérence : la cellule default (95,0 % [90,4 ; 99,6]) contient la ligne P2
défaut (93,0 %) dans sa bande 3σ (mêmes seeds, n = 100 → 200).
→ Limite : P3.3 (candidats LLM) est câblé et testé (déduplication,
0 candidat ici) mais non exercé contre un vrai LLM — aucun fournisseur avant
P5. Bande 3σ ±4,6 points à p = 0,97, n = 200 : au sein d'une famille à
leviers inertes les écarts sont nuls (bit-identiques), mais un départage plus
fin (n plus grand, carte plus riche) reste ouvert.
État du plan
- P0 ✅ · P1 ✅ (
LLMClient+FakeLLMClientscripté + cache composite, garde-fous coût/tokens,NLPolicy,PromptBuilderversionné) · P2 ✅ (strategy.py+SweepRunner, sortie mesurée) · P3 ✅ (scenarios.py+search.py, playbook représentatif mesuré) — gate verte (2134 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 (ReplayLogcall_seq+replay()identique) ✅. - Frontière P0 atteinte : gate verte (2022 tests) ; un run enregistré se rejoue à l'identique.
- Suite : P4 (adversaire
AdversarySpec+ cartographie complète), P5 (fournisseur réel + rapport).