diff --git a/docs/phase-2-llm-architecture.md b/docs/phase-2-llm-architecture.md index e93fbe0..32cb788 100644 --- a/docs/phase-2-llm-architecture.md +++ b/docs/phase-2-llm-architecture.md @@ -494,18 +494,131 @@ 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. +### Adversaire et robustesse (P4) + +`adversary.py` : `AdversarySpec(target_priority="nearest" | "lowest_hp" | +"weakest_ac" | "healer", spell_policy="default" | "none", +retreat_hp_fraction=0.0 | 0.25 | 0.5, use_charges=True | False)` — les +leviers adverses (D12 : l'adversaire joue pour gagner) ; un seul +`AdversarySpec()` (défaut = `nearest`, la politique focus fire du spike P2) +est composé avec la stratégie du camp mesuré en une seule politique de +combat par `compile_matchup(party, adversary, party_side=)`. `adversary_name` +: nom canonique d'un levier. Test : `tests/test_phase2_adversary.py`. + +`robustness.py` : `matchup_benchmark(scenario, party, adversary, *, +party_side, runs, seed) -> MatchupBenchmark` (mesure le danger des DEUX +camps — même mesure, les deux sens du §3.2), puis les trois couches : + +- `adversarial_analysis` (P4.2, min-max alterné) : itérations successives — + la partie maximise sa survie parmi `party_candidates`, puis l'adversaire + minimise le danger de la partie parmi `adversary_candidates` ; chaque + itération est rendue par `format_robustness`. Le budget `max_total_runs=` + est vérifié **avant** tout combat (`ValueError` nommant + `budget`/`max_total_runs`). +- `danger_map` (P4.3) : grille `SideSpec.hp_fraction` PJ × adversaire, une + cellule par paire de fractions = un `MatchupBenchmark` ; `format_danger_map` + rend la grille (survie des deux camps par cellule). +- `frontier` (P4.3) : bissection de la fraction de PV PJ où `criterion` + croise `target` (convention §3.2), adversaire fixé — le retour est plafonné + au pas `precision` (`ceil`, jamais arrondi, pour rester du côté croisant du + seuil) ; None quand même `max_fraction` ne croise pas. `format_frontier` + rend « no frontier ». +- `sensitivity` (P4.3) : classe les 7 leviers adverses par |delta| du + critère de la partie vs baseline `AdversarySpec()` (budget exact 8×runs, + vérifié avant combat) ; `format_sensitivity` affiche les probabilités en %, + `expected_hp_lost` à 1 décimale. + +Tests : `tests/test_phase2_robustness.py`. + +**Mesures représentatives (RUNS=100, seed=7, camp mesuré = `players` = +3 gobelins vs 2 orcs, `sample_arena.yaml`)** : + +P4.1 — équilibre du min-max alterné (2 itérations ; l'adversaire best- +response converge vers `AdversarySpec()` dès la première itération, les deux +itérations sont bit-identiques) : + +| Stratégie PJ | P(survie PJ) | PV perdus PJ | P(survie adv) | PV perdus adv | TPK | +|---|---|---|---|---|---| +| `default` (nearest) | 96 % | 5,0 | 98 % | 11,6 | 0 % | +| `lowest_hp` | 95 % | 6,3 | 99 % | 11,5 | 0 % | +| `retreat=0.5` | 97 % | 5,3 | 99 % | 11,4 | 0 % | + +→ la meilleure réponse adverse est le ciblage `nearest` par défaut (le plus +dangereux pour la partie, voir sensitivity) ; face à cet équilibre les trois +stratégies PJ sont dans le bruit (bande 3σ ±6 points à n=100). Le camp +adverse encaisse ~11,5 PV moyens par combat (il perd presque toutes les +runs) mais ne **meurt** (hp ≤ −CON) que dans ~2 % des runs (98-99 % de +survie adv) : la rencontre bat l'adversaire en le mettant à ≤ 0 pv sans +souvent le tuer — cohérent avec la règle de mort `hp ≤ −CON` du moteur. + +P4.2 — danger_map (PJ `default` × adversaire `weakest_ac`, fractions +0.5/0.7/1.0, 9 cellules ; grille `Party\Adv` : survie partie/survie +adversaire par cellule) : + +| | adv=0.5 | adv=0.7 | adv=1.0 | +|---|---|---|---| +| party=0.5 | 100 %/100 % | 100 %/99 % | 99 %/99 % | +| party=0.7 | 99 %/100 % | 100 %/99 % | 100 %/99 % | +| party=1.0 | 100 %/100 % | 100 %/99 % | 100 %/99 % | + +→ la grille est plate (99-100 %) et le TPK nul partout : la mort est un +événement rare sur cette rencontre (au plus 1 run sur 100 par cellule) et +les écarts observés restent sous la bande 3σ (±6 points) — même décimée à +50 % de PV, la partie survit. Une carte discriminante exige une rencontre +plus équilibrée (le 2 gobelins vs 3 orcs de P2, où ≥ 1 mort monte à 4 %). + +P4.3 — frontier (`p_survives`, target=0.5, fractions [0.10, 1.00], +adversaire `healer`) : **`no frontier`** — la survie de la partie ne +descend jamais sous 50 %, même à 10 % de PV initiaux : le plancher de +robustesse de la partie est sous la barre des 10 %. + +P4.3 — sensitivity (criterion=`expected_hp_lost`, baseline `default` : +PV perdus 4,97, survie 96 %) : + +| Rang | Levier | Value | Delta | +|---|---|---|---| +| 1 | `healer` | 2,4 | −2,6 | +| 2 | `lowest_hp` | 2,4 | −2,6 | +| 3 | `weakest_ac` | 2,4 | −2,6 | +| 4 | `retreat=0.5` | 3,1 | −1,9 | +| 5 | `retreat=0.25` | 4,0 | −1,0 | +| 6 | `spell_policy=none` | 5,0 | +0,0 | +| 7 | `use_charges=false` | 5,0 | +0,0 | + +→ le ciblage est le levier adverse le plus sensible : quitter `nearest` +pour n'importe quel ciblage explicite (healer/lowest_hp/weakest_ac, ex æquo +à −2,6) fait tomber l'attrition de la partie de 5,0 à 2,4 PV et remonte sa +survie à 100 % — le ciblage de la cible la plus proche (la plus engagée en +mêlée) est donc le plus dangereux pour la partie ; des adversaires +statistiquement identiques rendent les trois ciblages ex æquo (même cible au +même rang, déjà observé en P3). La retraite orc (combat écourté) réduit +aussi le danger (−1,9 / −1,0). Leviers inertes : `spell_policy` et +`use_charges` (les orcs n'ont ni sorts ni ressources) — delta 0,000. +Budget : `adversarial_analysis(max_total_runs=1)` → `ValueError` +(« robustness budget exceeded: 800 runs > 1 max_total_runs ») avant tout +combat. Déterminisme : deux passes de `matchup_benchmark` bit-identiques +(p_survives 1,000 / p_tpk 0,000 / hp_lost 2,4). + +→ la sortie P4 (« adversaire `AdversarySpec` + cartographie de danger +complète, bornée en coût ») est atteinte : min-max alterné convergeant, +grille de danger 3×3, frontière de survie, classement des leviers adverses, +déterminisme et budget vérifiés. Les mesures placent la rencontre de +référence 3v2 du côté sûr de l'équilibre : TPK nul, plancher de survie sous +10 % de PV, et le seul levier adverse qui y compte est le ciblage par +défaut. + ### É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) · **P3** ✅ - (`scenarios.py` + `search.py`, playbook représentatif mesuré) — gate verte - (**2134 tests**). + (`scenarios.py` + `search.py`, playbook représentatif mesuré) · **P4** ✅ + (`adversary.py` + `robustness.py`, adversaire `AdversarySpec` + carte de + danger complète) — gate verte (**2169 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 : **P4** (adversaire `AdversarySpec` + cartographie complète), P5 - (fournisseur réel + rapport). +- Suite : **P5** (fournisseur réel + rapport).