Claude commited on
Commit
e1b8452
·
unverified ·
1 Parent(s): 1d8bf56

sprint23: intégrité anti-hallucination du moteur narratif

Browse files

Le test Sprint 19 ``test_every_number_in_synthesis_is_traceable``
tolérait une whitelist ``{"95", "100"}`` de littéraux non-traçables au
payload des Fact. Cette whitelist est maintenant vide ; tout nombre
apparaissant dans la synthèse rendue doit venir du payload d'un Fact
détecté, sans exception.

Changements
-----------

- ``CONFIDENCE_WARNING`` propage ``confidence_level=95`` dans son
payload ; les templates FR/EN référencent ``{confidence_level} %``
au lieu d'écrire "95 %" en dur.
- ``PARETO_ALTERNATIVE`` et ``COST_OUTLIER`` propagent
``cost_unit_pages=1000`` ; les templates FR/EN référencent
``€/{cost_unit_pages} pages`` au lieu de "/1000 pages".
- ``select_facts(facts, type_order=...)`` accepte désormais une
surcharge de l'ordre canonique des types (politique éditoriale).
L'ancienne constante ``_TYPE_ORDER`` reste exportée comme alias de
``DEFAULT_TYPE_ORDER`` pour rétro-compatibilité.

Tests (+14, soit 1256 passing au total)
---------------------------------------

- ``tests/test_sprint23_anti_hallucination.py`` :
* payloads exposent les nouveaux champs (3 tests)
* templates FR/EN ne contiennent plus 95/1000 en dur (4 tests)
* pipeline complet rend sans placeholder non substitué et reste
traçable avec whitelist vide (4 tests)
* stabilité du bootstrap entre seeds — borne ±0,5 pp à n_iter=1000
pour 20 documents (2 tests). Si ce test échoue à l'avenir,
passer ``n_iter=5000``.
* ``select_facts`` respecte un ``type_order`` custom (3 tests).

- ``tests/test_sprint19_narrative_engine.py`` :
``_TEMPLATE_CONSTANTS = frozenset()``.

Documentation
-------------

``docs/developer/narrative-engine.md`` gagne une section "Politique
éditoriale" qui documente l'ordre par défaut, ses hypothèses
implicites, et la procédure de surcharge via ``type_order``.

https://claude.ai/code/session_01L4RGWMrAajn5ZEFgTKjA5P

docs/developer/narrative-engine.md CHANGED
@@ -161,3 +161,90 @@ Si la synthèse ne contient pas votre fait, vérifiez :
161
  par défaut de l'arbitre.
162
  3. Que votre type n'est pas en collision avec un autre déjà retenu pour
163
  le même moteur (cf. `_is_redundant`).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  par défaut de l'arbitre.
162
  3. Que votre type n'est pas en collision avec un autre déjà retenu pour
163
  le même moteur (cf. `_is_redundant`).
164
+
165
+ ---
166
+
167
+ ## Politique éditoriale (Sprint 23)
168
+
169
+ L'arbitre départage les faits d'**égale importance** par un ordre canonique
170
+ des types : c'est un choix éditorial qui répond à la question *« quand A et
171
+ B sont aussi importants l'un que l'autre, lequel parle en premier ? »*.
172
+
173
+ L'ordre par défaut est défini dans `arbiter.py` sous le nom
174
+ `DEFAULT_TYPE_ORDER` :
175
+
176
+ ```python
177
+ DEFAULT_TYPE_ORDER = (
178
+ FactType.GLOBAL_LEADER_CER, # 1. Qui gagne globalement
179
+ FactType.STATISTICAL_TIE, # 2. Y a-t-il un ex-aequo
180
+ FactType.SIGNIFICANT_GAP, # 3. À quel point l'écart est solide
181
+ FactType.STRATUM_WINNER, # 4. Qui domine sur quel sous-corpus
182
+ FactType.STRATUM_COLLAPSE, # 5. Qui s'effondre sur quoi
183
+ FactType.ERROR_PROFILE_OUTLIER, # 6. Qui se trompe différemment
184
+ FactType.LLM_HALLUCINATION_FLAG, # 7. Hallucinations VLM
185
+ FactType.ROBUSTNESS_FRAGILE, # 8. Sensibilité aux dégradations
186
+ FactType.PARETO_ALTERNATIVE, # 9. Y a-t-il un compromis coût/qualité
187
+ FactType.SPEED_WINNER, # 10. Vitesse
188
+ FactType.COST_OUTLIER, # 11. Coût aberrant
189
+ FactType.CONFIDENCE_WARNING, # 12. Mise en garde sur la fiabilité
190
+ )
191
+ ```
192
+
193
+ **Hypothèse implicite** : un lecteur d'institution patrimoniale veut
194
+ d'abord savoir *qui gagne* puis *à quel point cette victoire est solide*,
195
+ avant de découvrir des considérations de coût ou de vitesse. Une équipe
196
+ DevOps cherchant à industrialiser une chaîne aurait probablement l'ordre
197
+ inverse — vitesse et coût d'abord, qualité ensuite.
198
+
199
+ ### Surcharger l'ordre sans patcher le code
200
+
201
+ Depuis le Sprint 23, `select_facts` accepte un argument optionnel
202
+ `type_order` :
203
+
204
+ ```python
205
+ from picarones.core.narrative import build_synthesis
206
+ from picarones.core.narrative.arbiter import select_facts, DEFAULT_TYPE_ORDER
207
+ from picarones.core.narrative.facts import FactType
208
+
209
+ # Réordonnancement : on remonte vitesse et coût avant qualité.
210
+ custom = (
211
+ FactType.SPEED_WINNER,
212
+ FactType.COST_OUTLIER,
213
+ FactType.PARETO_ALTERNATIVE,
214
+ FactType.GLOBAL_LEADER_CER,
215
+ # ... compléter avec les autres types ; ceux qui manquent sont
216
+ # relégués à la fin sans crash.
217
+ )
218
+
219
+ facts = detect_all(benchmark_data)
220
+ selected = select_facts(facts, max_facts=5, type_order=custom)
221
+ ```
222
+
223
+ Cas d'usage typiques :
224
+
225
+ - **Atelier MOOC** : promouvoir `STRATUM_COLLAPSE` et
226
+ `ERROR_PROFILE_OUTLIER` en tête pour mettre l'accent sur la lecture
227
+ diagnostique des erreurs.
228
+ - **Comité technique** : promouvoir `CONFIDENCE_WARNING` en tête pour
229
+ forcer la discussion sur la fiabilité avant les classements.
230
+ - **Évaluation budgétaire** : promouvoir `COST_OUTLIER` et
231
+ `PARETO_ALTERNATIVE` en tête.
232
+
233
+ ### Règle anti-hallucination renforcée (Sprint 23)
234
+
235
+ Avant le Sprint 23, le test de traçabilité des nombres tolérait deux
236
+ littéraux non-traçables au payload (`95` pour le seuil de l'IC, `100`
237
+ comme tolérance numérique). Cette whitelist est désormais vide :
238
+
239
+ - Le seuil de confiance est propagé via `confidence_level` dans le
240
+ payload des `Fact` de type `CONFIDENCE_WARNING`.
241
+ - L'unité du coût (`/1000 pages`) est propagée via `cost_unit_pages`
242
+ dans `PARETO_ALTERNATIVE` et `COST_OUTLIER`.
243
+
244
+ **Si vous ajoutez un détecteur dont le template référence un nombre
245
+ constant** (ex. *« seuil α = 0,05 »*), vous devez **systématiquement**
246
+ le mettre dans le `payload`. Le test
247
+ `test_sprint19_narrative_engine.py::test_every_number_in_synthesis_is_traceable`
248
+ plus le test
249
+ `test_sprint23_anti_hallucination.py::TestTemplatesNoHardcodedLiterals`
250
+ échoueront sinon.
picarones/core/narrative/arbiter.py CHANGED
@@ -19,13 +19,19 @@ pas mais peut limiter par type.
19
 
20
  from __future__ import annotations
21
 
22
- from typing import Iterable
23
 
24
  from picarones.core.narrative.facts import Fact, FactImportance, FactType
25
 
26
 
27
  # Ordre canonique des types pour départager les ex-aequo à l'importance égale.
28
- _TYPE_ORDER: tuple[FactType, ...] = (
 
 
 
 
 
 
29
  FactType.GLOBAL_LEADER_CER,
30
  FactType.STATISTICAL_TIE,
31
  FactType.SIGNIFICANT_GAP,
@@ -39,7 +45,10 @@ _TYPE_ORDER: tuple[FactType, ...] = (
39
  FactType.COST_OUTLIER,
40
  FactType.CONFIDENCE_WARNING,
41
  )
42
- _TYPE_INDEX: dict[FactType, int] = {t: i for i, t in enumerate(_TYPE_ORDER)}
 
 
 
43
 
44
 
45
  # Paires de types qui ne sont PAS considérées comme redondantes même quand
@@ -53,11 +62,11 @@ _COMPLEMENTARY_PAIRS: frozenset[frozenset[FactType]] = frozenset({
53
  })
54
 
55
 
56
- def _sort_key(fact: Fact) -> tuple:
57
  """Clé de tri stable : importance (desc), type canonique, moteurs."""
58
  return (
59
  -int(fact.importance),
60
- _TYPE_INDEX.get(fact.type, len(_TYPE_ORDER)),
61
  tuple(sorted(fact.engines_involved)),
62
  fact.stratum or "",
63
  )
@@ -106,6 +115,7 @@ def select_facts(
106
  facts: Iterable[Fact],
107
  max_facts: int = 5,
108
  min_importance: FactImportance = FactImportance.MEDIUM,
 
109
  ) -> list[Fact]:
110
  """Sélectionne la synthèse finale à partir d'une liste brute de faits.
111
 
@@ -117,14 +127,24 @@ def select_facts(
117
  Nombre maximal de faits retenus (défaut : 5).
118
  min_importance:
119
  Seuil minimal d'importance. Les faits ``LOW`` sont exclus par défaut.
 
 
 
 
 
120
 
121
  Returns
122
  -------
123
  Liste ordonnée, prête à être rendue. Toujours ≤ ``max_facts``.
124
  """
 
 
 
 
 
125
  facts_list = [f for f in facts if int(f.importance) >= int(min_importance)]
126
  facts_list = _remove_contradictions(facts_list)
127
- ranked = sorted(facts_list, key=_sort_key)
128
 
129
  selected: list[Fact] = []
130
  for fact in ranked:
 
19
 
20
  from __future__ import annotations
21
 
22
+ from typing import Iterable, Sequence
23
 
24
  from picarones.core.narrative.facts import Fact, FactImportance, FactType
25
 
26
 
27
  # Ordre canonique des types pour départager les ex-aequo à l'importance égale.
28
+ #
29
+ # Politique éditoriale (Sprint 23) — exposée et documentée :
30
+ # voir ``docs/developer/narrative-engine.md`` § Editorial policy.
31
+ # L'ordre encode quels faits sont remontés en priorité quand plusieurs ont
32
+ # la même ``FactImportance`` ; il peut être surchargé via le paramètre
33
+ # ``type_order`` de ``select_facts`` sans patcher le code.
34
+ DEFAULT_TYPE_ORDER: tuple[FactType, ...] = (
35
  FactType.GLOBAL_LEADER_CER,
36
  FactType.STATISTICAL_TIE,
37
  FactType.SIGNIFICANT_GAP,
 
45
  FactType.COST_OUTLIER,
46
  FactType.CONFIDENCE_WARNING,
47
  )
48
+ # Alias rétro-compatible l'ancien nom privé reste exporté pour
49
+ # les tests et le code utilisateur qui s'y appuyaient.
50
+ _TYPE_ORDER = DEFAULT_TYPE_ORDER
51
+ _TYPE_INDEX: dict[FactType, int] = {t: i for i, t in enumerate(DEFAULT_TYPE_ORDER)}
52
 
53
 
54
  # Paires de types qui ne sont PAS considérées comme redondantes même quand
 
62
  })
63
 
64
 
65
+ def _sort_key(fact: Fact, type_index: dict[FactType, int]) -> tuple:
66
  """Clé de tri stable : importance (desc), type canonique, moteurs."""
67
  return (
68
  -int(fact.importance),
69
+ type_index.get(fact.type, len(type_index)),
70
  tuple(sorted(fact.engines_involved)),
71
  fact.stratum or "",
72
  )
 
115
  facts: Iterable[Fact],
116
  max_facts: int = 5,
117
  min_importance: FactImportance = FactImportance.MEDIUM,
118
+ type_order: Sequence[FactType] | None = None,
119
  ) -> list[Fact]:
120
  """Sélectionne la synthèse finale à partir d'une liste brute de faits.
121
 
 
127
  Nombre maximal de faits retenus (défaut : 5).
128
  min_importance:
129
  Seuil minimal d'importance. Les faits ``LOW`` sont exclus par défaut.
130
+ type_order:
131
+ Surcharge optionnelle de l'ordre canonique des types pour départager
132
+ les faits d'égale importance. ``None`` (défaut) utilise
133
+ ``DEFAULT_TYPE_ORDER``. Une institution peut passer son propre ordre
134
+ sans patcher le code — voir ``docs/developer/narrative-engine.md``.
135
 
136
  Returns
137
  -------
138
  Liste ordonnée, prête à être rendue. Toujours ≤ ``max_facts``.
139
  """
140
+ if type_order is None:
141
+ type_index = _TYPE_INDEX
142
+ else:
143
+ type_index = {t: i for i, t in enumerate(type_order)}
144
+
145
  facts_list = [f for f in facts if int(f.importance) >= int(min_importance)]
146
  facts_list = _remove_contradictions(facts_list)
147
+ ranked = sorted(facts_list, key=lambda f: _sort_key(f, type_index))
148
 
149
  selected: list[Fact] = []
150
  for fact in ranked:
picarones/core/narrative/detectors.py CHANGED
@@ -216,6 +216,9 @@ def detect_pareto_alternative(benchmark_data: dict) -> list[Fact]:
216
  "leader_cost": round(leader_cost, 2),
217
  "cost_saving_ratio": round(leader_cost / alt_cost, 1) if alt_cost > 0 else None,
218
  "delta_cer_pct": round((alt_cer - leader_cer) * 100, 2),
 
 
 
219
  },
220
  engines_involved=(alt["engine"],),
221
  )]
@@ -519,6 +522,7 @@ def detect_cost_outlier(benchmark_data: dict) -> list[Fact]:
519
  "median_cost": round(median_cost, 2),
520
  "ratio_to_median": round(c / median_cost, 1),
521
  "cer_pct": round(float(p.get("cer") or 0.0) * 100, 2),
 
522
  },
523
  engines_involved=(p["engine"],),
524
  ))
@@ -642,6 +646,9 @@ def detect_confidence_warning(benchmark_data: dict) -> list[Fact]:
642
  "mean_cer": round(float(ci.get("mean") or 0.0), 4),
643
  "mean_cer_pct": round(float(ci.get("mean") or 0.0) * 100, 2),
644
  "gap_to_runner_up_pct": round(gap * 100, 2),
 
 
 
645
  },
646
  engines_involved=(engine_name,),
647
  ))
 
216
  "leader_cost": round(leader_cost, 2),
217
  "cost_saving_ratio": round(leader_cost / alt_cost, 1) if alt_cost > 0 else None,
218
  "delta_cer_pct": round((alt_cer - leader_cer) * 100, 2),
219
+ # Unité du coût — propagée pour traçabilité (le template ne
220
+ # hardcode plus "1000 pages").
221
+ "cost_unit_pages": 1000,
222
  },
223
  engines_involved=(alt["engine"],),
224
  )]
 
522
  "median_cost": round(median_cost, 2),
523
  "ratio_to_median": round(c / median_cost, 1),
524
  "cer_pct": round(float(p.get("cer") or 0.0) * 100, 2),
525
+ "cost_unit_pages": 1000,
526
  },
527
  engines_involved=(p["engine"],),
528
  ))
 
646
  "mean_cer": round(float(ci.get("mean") or 0.0), 4),
647
  "mean_cer_pct": round(float(ci.get("mean") or 0.0) * 100, 2),
648
  "gap_to_runner_up_pct": round(gap * 100, 2),
649
+ # Niveau de confiance des bornes — propagé pour traçabilité
650
+ # anti-hallucination (le template ne hardcode plus "95 %").
651
+ "confidence_level": 95,
652
  },
653
  engines_involved=(engine_name,),
654
  ))
picarones/core/narrative/templates/en.yaml CHANGED
@@ -42,14 +42,14 @@ speed_winner: >-
42
  median) for comparable quality (CER {cer_pct} %).
43
 
44
  confidence_warning: >-
45
- Ranking is fragile: the 95 % confidence interval of {engine} spans
46
  {ci_width_pct} CER points, compared with a gap of {gap_to_runner_up_pct} points to the runner-up.
47
 
48
  pareto_alternative: >-
49
  At much lower cost, {engine} offers an interesting trade-off ({cer_pct} %
50
- CER for {cost} €/1000 pages, vs {leader_cer_pct} % / {leader_cost} € for
51
  {leader}, i.e. ×{cost_saving_ratio} cheaper).
52
 
53
  cost_outlier: >-
54
- Disproportionate cost for {engine} ({cost} €/1000 pages, ×{ratio_to_median}
55
  the median) without a compensating quality advantage (CER {cer_pct} %).
 
42
  median) for comparable quality (CER {cer_pct} %).
43
 
44
  confidence_warning: >-
45
+ Ranking is fragile: the {confidence_level} % confidence interval of {engine} spans
46
  {ci_width_pct} CER points, compared with a gap of {gap_to_runner_up_pct} points to the runner-up.
47
 
48
  pareto_alternative: >-
49
  At much lower cost, {engine} offers an interesting trade-off ({cer_pct} %
50
+ CER for {cost} €/{cost_unit_pages} pages, vs {leader_cer_pct} % / {leader_cost} € for
51
  {leader}, i.e. ×{cost_saving_ratio} cheaper).
52
 
53
  cost_outlier: >-
54
+ Disproportionate cost for {engine} ({cost} €/{cost_unit_pages} pages, ×{ratio_to_median}
55
  the median) without a compensating quality advantage (CER {cer_pct} %).
picarones/core/narrative/templates/fr.yaml CHANGED
@@ -46,14 +46,14 @@ speed_winner: >-
46
  que la médiane) pour un CER comparable ({cer_pct} %).
47
 
48
  confidence_warning: >-
49
- Classement fragile : l'intervalle de confiance à 95 % de {engine} s'étend
50
  sur {ci_width_pct} points de CER, à comparer à l'écart de {gap_to_runner_up_pct} points avec le second.
51
 
52
  pareto_alternative: >-
53
  À coût sensiblement inférieur, {engine} offre un compromis intéressant
54
- ({cer_pct} % de CER pour {cost} €/1000 pages, contre {leader_cer_pct} % /
55
  {leader_cost} € pour {leader}, soit ×{cost_saving_ratio} moins cher).
56
 
57
  cost_outlier: >-
58
- Coût disproportionné pour {engine} ({cost} €/1000 pages, ×{ratio_to_median}
59
  la médiane) sans avantage de qualité compensatoire (CER {cer_pct} %).
 
46
  que la médiane) pour un CER comparable ({cer_pct} %).
47
 
48
  confidence_warning: >-
49
+ Classement fragile : l'intervalle de confiance à {confidence_level} % de {engine} s'étend
50
  sur {ci_width_pct} points de CER, à comparer à l'écart de {gap_to_runner_up_pct} points avec le second.
51
 
52
  pareto_alternative: >-
53
  À coût sensiblement inférieur, {engine} offre un compromis intéressant
54
+ ({cer_pct} % de CER pour {cost} €/{cost_unit_pages} pages, contre {leader_cer_pct} % /
55
  {leader_cost} € pour {leader}, soit ×{cost_saving_ratio} moins cher).
56
 
57
  cost_outlier: >-
58
+ Coût disproportionné pour {engine} ({cost} €/{cost_unit_pages} pages, ×{ratio_to_median}
59
  la médiane) sans avantage de qualité compensatoire (CER {cer_pct} %).
tests/test_sprint19_narrative_engine.py CHANGED
@@ -493,10 +493,13 @@ def _numbers_in_payload(payload: dict) -> set[str]:
493
  return out
494
 
495
 
496
- # Constantes littérales autorisées dans les templates (non traçables au
497
- # payload car ce sont des éléments typographiques seuil 95 % correspondant
498
- # à α = 0,05, etc.). Ajouter ici rend la règle explicite.
499
- _TEMPLATE_CONSTANTS = {"95", "100"}
 
 
 
500
 
501
 
502
  class TestAntiHallucinationTraceability:
 
493
  return out
494
 
495
 
496
+ # Sprint 23 : whitelist vidée. Tout nombre rendu dans la synthèse doit
497
+ # venir du payload d'un Fact. Le seuil de confiance (95) est désormais
498
+ # propagé via ``confidence_level`` dans le payload de
499
+ # ``FactType.CONFIDENCE_WARNING`` et l'unité du coût (1000 pages) via
500
+ # ``cost_unit_pages`` dans ``PARETO_ALTERNATIVE`` / ``COST_OUTLIER``.
501
+ # Aucun littéral hors-payload n'est plus autorisé.
502
+ _TEMPLATE_CONSTANTS: frozenset[str] = frozenset()
503
 
504
 
505
  class TestAntiHallucinationTraceability:
tests/test_sprint23_anti_hallucination.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests Sprint 23 — intégrité anti-hallucination du moteur narratif.
2
+
3
+ Le Sprint 23 ferme le trou méthodologique laissé par le Sprint 19 : le test
4
+ de traçabilité des nombres dans la synthèse rendue tolérait une whitelist
5
+ ``{"95", "100"}`` de littéraux non-traçables au payload. Cette whitelist
6
+ est désormais vide ; toute valeur numérique apparaissant dans la synthèse
7
+ doit provenir du ``Fact.payload`` d'un détecteur.
8
+
9
+ Ce module vérifie quatre choses :
10
+
11
+ 1. Les payloads des détecteurs concernés (``CONFIDENCE_WARNING``,
12
+ ``PARETO_ALTERNATIVE``, ``COST_OUTLIER``) exposent bien les nouveaux
13
+ champs (``confidence_level``, ``cost_unit_pages``).
14
+ 2. Les templates FR/EN ne contiennent plus les littéraux ``95`` ni ``1000``
15
+ en dehors d'un placeholder ``{...}``.
16
+ 3. Le test de traçabilité reste vert avec une whitelist vide.
17
+ 4. La stabilité du bootstrap est testée : deux seeds produisent des bornes
18
+ d'IC à ±0,5 pp pour ``n=20`` documents — garantit que l'IC affiché
19
+ dans le rapport est représentatif (sinon il faudrait passer
20
+ ``n_iter=5000``).
21
+ 5. Le pipeline narratif EN bout-en-bout produit des phrases anglaises
22
+ bien formées (pas de placeholder non substitué) sur fixtures réalistes.
23
+ 6. ``select_facts`` accepte un ``type_order`` custom et le respecte.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ from pathlib import Path
30
+
31
+ import pytest
32
+
33
+ from picarones.core.narrative import (
34
+ Fact,
35
+ FactImportance,
36
+ FactType,
37
+ build_synthesis,
38
+ select_facts,
39
+ )
40
+ from picarones.core.narrative.arbiter import DEFAULT_TYPE_ORDER
41
+ from picarones.core.statistics import bootstrap_ci
42
+
43
+ ROOT = Path(__file__).parent.parent
44
+ TEMPLATES_DIR = ROOT / "picarones" / "core" / "narrative" / "templates"
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Fixtures locales — minimum viable pour faire émettre chaque détecteur
49
+ # ---------------------------------------------------------------------------
50
+
51
+ def _full_data() -> dict:
52
+ """Données qui déclenchent ``CONFIDENCE_WARNING`` (IC large) et le Pareto."""
53
+ return {
54
+ "ranking": [
55
+ {"engine": "A", "mean_cer": 0.05, "wer": 0.10},
56
+ {"engine": "B", "mean_cer": 0.06, "wer": 0.12},
57
+ {"engine": "C", "mean_cer": 0.20, "wer": 0.30},
58
+ ],
59
+ "n_documents": 20,
60
+ "statistics": {
61
+ "bootstrap_cis": [
62
+ # IC large pour A → confidence_warning
63
+ {"engine": "A", "mean": 0.05, "ci_lower": 0.01, "ci_upper": 0.15},
64
+ {"engine": "B", "mean": 0.06, "ci_lower": 0.05, "ci_upper": 0.07},
65
+ {"engine": "C", "mean": 0.20, "ci_lower": 0.18, "ci_upper": 0.22},
66
+ ],
67
+ },
68
+ "pareto": {
69
+ "cost": {
70
+ "front": ["A", "B"],
71
+ "points": [
72
+ {"engine": "A", "cer": 0.05, "cost": 50.0},
73
+ {"engine": "B", "cer": 0.06, "cost": 5.0}, # alternative pas chère
74
+ {"engine": "C", "cer": 0.20, "cost": 300.0}, # cost outlier
75
+ ],
76
+ },
77
+ },
78
+ }
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # 1. Payloads exposent les nouveaux champs
83
+ # ---------------------------------------------------------------------------
84
+
85
+ class TestPayloadsCarryFormerlyHardcodedConstants:
86
+ def test_confidence_warning_payload_carries_confidence_level(self):
87
+ from picarones.core.narrative.detectors import detect_confidence_warning
88
+
89
+ facts = detect_confidence_warning(_full_data())
90
+ assert facts, "fixture devrait déclencher au moins un confidence_warning"
91
+ for f in facts:
92
+ assert f.payload.get("confidence_level") == 95, (
93
+ "Le seuil 95 doit être propagé dans le payload "
94
+ "(plus de littéral hardcodé dans le template)."
95
+ )
96
+
97
+ def test_pareto_alternative_payload_carries_cost_unit(self):
98
+ from picarones.core.narrative.detectors import detect_pareto_alternative
99
+
100
+ facts = detect_pareto_alternative(_full_data())
101
+ assert facts, "fixture devrait déclencher au moins un pareto_alternative"
102
+ for f in facts:
103
+ assert f.payload.get("cost_unit_pages") == 1000
104
+
105
+ def test_cost_outlier_payload_carries_cost_unit(self):
106
+ from picarones.core.narrative.detectors import detect_cost_outlier
107
+
108
+ facts = detect_cost_outlier(_full_data())
109
+ assert facts, "fixture devrait déclencher au moins un cost_outlier"
110
+ for f in facts:
111
+ assert f.payload.get("cost_unit_pages") == 1000
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # 2. Les templates ne hardcodent plus les littéraux 95 et 1000
116
+ # ---------------------------------------------------------------------------
117
+
118
+ # Toute occurrence d'un nombre HORS d'un placeholder ``{...}`` est
119
+ # considérée comme un littéral hardcodé. On scanne en remplaçant d'abord
120
+ # tous les placeholders par un marqueur neutre.
121
+ _PLACEHOLDER_RE = re.compile(r"\{[^{}]+\}")
122
+ _NUMBER_RE = re.compile(r"\b\d+\b")
123
+
124
+
125
+ def _strip_placeholders(template: str) -> str:
126
+ return _PLACEHOLDER_RE.sub("PLACEHOLDER", template)
127
+
128
+
129
+ @pytest.mark.parametrize("lang", ["fr", "en"])
130
+ class TestTemplatesNoHardcodedLiterals:
131
+ def test_no_hardcoded_95(self, lang):
132
+ import yaml
133
+
134
+ path = TEMPLATES_DIR / f"{lang}.yaml"
135
+ templates = yaml.safe_load(path.read_text(encoding="utf-8"))
136
+ for key, tpl in templates.items():
137
+ stripped = _strip_placeholders(tpl)
138
+ numbers = _NUMBER_RE.findall(stripped)
139
+ assert "95" not in numbers, (
140
+ f"Template {lang}/{key} contient encore le littéral 95 ; "
141
+ "doit utiliser {confidence_level}."
142
+ )
143
+ assert "1000" not in numbers, (
144
+ f"Template {lang}/{key} contient encore le littéral 1000 ; "
145
+ "doit utiliser {cost_unit_pages}."
146
+ )
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # 3. Pipeline complet produit une synthèse traçable, whitelist vide
151
+ # ---------------------------------------------------------------------------
152
+
153
+ class TestEndToEndWithEmptyWhitelist:
154
+ @pytest.mark.parametrize("lang", ["fr", "en"])
155
+ def test_synthesis_renders_without_unsubstituted_placeholders(self, lang):
156
+ result = build_synthesis(_full_data(), lang)
157
+ for sentence in result["sentences"]:
158
+ assert "{" not in sentence and "}" not in sentence, (
159
+ f"Placeholder non substitué dans la synthèse {lang} : {sentence!r}"
160
+ )
161
+
162
+ @pytest.mark.parametrize("lang", ["fr", "en"])
163
+ def test_every_number_traceable_with_empty_whitelist(self, lang):
164
+ from picarones.core.narrative import extract_numbers
165
+
166
+ from tests.test_sprint19_narrative_engine import _numbers_in_payload
167
+
168
+ result = build_synthesis(_full_data(), lang)
169
+ allowed: set[str] = set()
170
+ for f in result["facts"]:
171
+ allowed |= _numbers_in_payload(f.get("payload", {}))
172
+
173
+ unknown: list[tuple[str, str]] = []
174
+ for sentence in result["sentences"]:
175
+ for num in extract_numbers(sentence):
176
+ num_norm = num.replace(",", ".")
177
+ if num_norm not in allowed:
178
+ unknown.append((num, sentence))
179
+ assert not unknown, (
180
+ f"[{lang}] Nombres non traçables au payload : {unknown}"
181
+ )
182
+
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # 4. Stabilité du bootstrap entre seeds
186
+ # ---------------------------------------------------------------------------
187
+
188
+ class TestBootstrapStabilityAcrossSeeds:
189
+ """Vérifie que ``bootstrap_ci`` à n_iter=1000 est suffisamment stable.
190
+
191
+ Pour 20 documents avec un CER moyen ~5 %, l'écart entre deux seeds sur
192
+ chacune des bornes (lower, upper) doit rester inférieur à 0,5 point de
193
+ pourcentage de CER (= 0.005 en absolu). Si ce test échoue à l'avenir,
194
+ cela signifie qu'il faut passer à ``n_iter=5000`` pour fiabiliser
195
+ l'IC affiché dans le rapport.
196
+ """
197
+
198
+ def test_bootstrap_stable_for_typical_cer_distribution(self):
199
+ # 20 valeurs de CER autour de 5 % — distribution réaliste.
200
+ values = [
201
+ 0.02, 0.03, 0.04, 0.04, 0.045, 0.05, 0.05, 0.05, 0.055, 0.055,
202
+ 0.06, 0.06, 0.06, 0.065, 0.07, 0.07, 0.075, 0.08, 0.085, 0.10,
203
+ ]
204
+ lo1, hi1 = bootstrap_ci(values, n_iter=1000, seed=42)
205
+ lo2, hi2 = bootstrap_ci(values, n_iter=1000, seed=7)
206
+ assert abs(lo1 - lo2) < 0.005, (
207
+ f"Borne basse instable entre seeds (Δ = {abs(lo1 - lo2):.4f}) ; "
208
+ "envisager n_iter=5000."
209
+ )
210
+ assert abs(hi1 - hi2) < 0.005, (
211
+ f"Borne haute instable entre seeds (Δ = {abs(hi1 - hi2):.4f}) ; "
212
+ "envisager n_iter=5000."
213
+ )
214
+
215
+ def test_bootstrap_strictly_deterministic_same_seed(self):
216
+ values = [0.01, 0.05, 0.1, 0.2]
217
+ a = bootstrap_ci(values, n_iter=1000, seed=42)
218
+ b = bootstrap_ci(values, n_iter=1000, seed=42)
219
+ assert a == b, "Bootstrap doit être bit-à-bit reproductible sur seed identique."
220
+
221
+
222
+ # ---------------------------------------------------------------------------
223
+ # 5. select_facts respecte un type_order custom
224
+ # ---------------------------------------------------------------------------
225
+
226
+ class TestSelectFactsCustomTypeOrder:
227
+ def _make_facts(self) -> list[Fact]:
228
+ return [
229
+ Fact(
230
+ type=FactType.GLOBAL_LEADER_CER,
231
+ importance=FactImportance.HIGH,
232
+ payload={"engine": "A"},
233
+ engines_involved=("A",),
234
+ ),
235
+ Fact(
236
+ type=FactType.SPEED_WINNER,
237
+ importance=FactImportance.HIGH,
238
+ payload={"engine": "B"},
239
+ engines_involved=("B",),
240
+ ),
241
+ Fact(
242
+ type=FactType.PARETO_ALTERNATIVE,
243
+ importance=FactImportance.HIGH,
244
+ payload={"engine": "C"},
245
+ engines_involved=("C",),
246
+ ),
247
+ ]
248
+
249
+ def test_default_order_puts_global_leader_first(self):
250
+ selected = select_facts(self._make_facts(), max_facts=3)
251
+ assert selected[0].type == FactType.GLOBAL_LEADER_CER
252
+
253
+ def test_custom_order_promotes_speed_winner(self):
254
+ custom = (
255
+ FactType.SPEED_WINNER,
256
+ FactType.GLOBAL_LEADER_CER,
257
+ FactType.PARETO_ALTERNATIVE,
258
+ ) + tuple(t for t in DEFAULT_TYPE_ORDER if t not in {
259
+ FactType.SPEED_WINNER,
260
+ FactType.GLOBAL_LEADER_CER,
261
+ FactType.PARETO_ALTERNATIVE,
262
+ })
263
+ selected = select_facts(self._make_facts(), max_facts=3, type_order=custom)
264
+ assert selected[0].type == FactType.SPEED_WINNER, (
265
+ "Avec un type_order custom plaçant SPEED_WINNER en premier, "
266
+ "il doit ressortir avant GLOBAL_LEADER_CER à importance égale."
267
+ )
268
+
269
+ def test_unknown_types_in_custom_order_fall_to_end(self):
270
+ # Un type_order réduit (ne mentionne que GLOBAL_LEADER_CER) ; les autres
271
+ # types sont relégués à la fin sans crash.
272
+ custom = (FactType.GLOBAL_LEADER_CER,)
273
+ selected = select_facts(self._make_facts(), max_facts=3, type_order=custom)
274
+ assert selected[0].type == FactType.GLOBAL_LEADER_CER
275
+ assert len(selected) == 3