Claude commited on
Commit
dbf3578
·
unverified ·
1 Parent(s): 160d558

sprint44: A.I.2 médiane par défaut + détecteur d'asymétrie

Browse files

Réponse à la critique structurelle 2 du plan d'évolution : sur les
corpus patrimoniaux, la moyenne CER est tirée par quelques documents
catastrophiques et masque les performances réelles. La médiane est
plus représentative ; cohérente aussi avec le test de Friedman qui
travaille déjà sur les rangs (Sprint 18).

Modèles
- EngineReport.median_cer : nouvelle propriété qui lit
aggregated_metrics["cer"]["median"].
- BenchmarkResult.ranking() : inclut median_cer dans chaque entrée
et trie par médiane CER croissante par défaut. Fallback sur
mean_cer quand la médiane est absente (cas pathologique).

Détecteur narratif
- Nouveau FactType.MEDIAN_MEAN_GAP_WARNING (priority 140) qui se
déclenche pour le moteur leader quand
|mean - median| / median > 30 %. Importance HIGH si gap relatif
≥ 100 %, sinon MEDIUM. Garde-fou : ne déclenche pas si médiane
nulle (corpus parfait pour ce moteur).
- Templates FR/EN sans nombres en dur (vérifié par test).
- L'arbitre marque la paire {GLOBAL_LEADER_CER,
MEDIAN_MEAN_GAP_WARNING} comme complémentaire : les deux phrases
peuvent coexister dans la synthèse pour nuancer le leader plutôt
que de l'écraser.

Tests : +15 dans test_sprint44_median_default.py couvrant la
propriété median_cer, le tri sur un cas asymétrique réaliste
(80 % à 0.03 + 20 % à 0.40 → A bat B sur la médiane mais perd sur
la moyenne), le fallback mean quand median absent, le déclenchement
du détecteur sur 4 cas dégénérés (symétrique, asymétrique modéré,
asymétrique fort, médiane nulle), la traçabilité anti-hallucination
FR + EN, l'absence de chiffres en dur dans les templates, et
l'intégration dans build_synthesis.
Suite complète : 1795 → 1810 passed, 2 skipped, 0 failed.

CHANGELOG.md CHANGED
@@ -16,6 +16,36 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
16
 
17
  ### Ajouté
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  - **Sprint 43 — A.II.1.b Calibration : vue HTML reliability diagram +
20
  tableau ECE/MCE (clôture A.II.1.b côté rapport).** Suite directe du
21
  Sprint 42 (câblage runner). Les chiffres de calibration sont
@@ -374,12 +404,13 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
374
 
375
  ### Tests
376
 
377
- - 1478 → 1795 tests (+17 Sprint 32, +23 Sprint 33, +21 Sprint 34,
378
  +27 Sprint 35, +22 Sprint 36, +42 Sprint 37, +19 Sprint 38,
379
  +32 Sprint 39, +16 Sprint 40, +38 Sprint 41, +17 Sprint 42,
380
- +43 Sprint 43). Aucune régression. **Phase 0 close ; Étape 2 du
381
- plan d'évolution : inter-moteurs (A.II.1.c), NER (A.II.1.a) et
382
- calibration (A.II.1.b) livrés bout-en-bout calcul → runner → HTML.
 
383
  Reste l'adaptation effective des engines pour exposer leurs
384
  confidences natives (un sprint par adapter).**
385
 
 
16
 
17
  ### Ajouté
18
 
19
+ - **Sprint 44 — A.I.2 : tri par médiane CER par défaut + détecteur
20
+ d'asymétrie.** Réponse à la critique structurelle 2 du plan
21
+ d'évolution : sur les corpus patrimoniaux, la moyenne est facilement
22
+ tirée par quelques documents catastrophiques et masque les
23
+ performances réelles ; la médiane est plus représentative.
24
+ - `EngineReport.median_cer` : nouvelle propriété qui lit
25
+ `aggregated_metrics["cer"]["median"]`.
26
+ - `BenchmarkResult.ranking()` :
27
+ - inclut désormais `median_cer` dans chaque entrée (additif)
28
+ - **trie par médiane CER croissante par défaut** (et non plus
29
+ par moyenne)
30
+ - retombe sur `mean_cer` quand `median_cer` est absent
31
+ (rétrocompat pour le cas pathologique)
32
+ - Nouveau `FactType.MEDIAN_MEAN_GAP_WARNING` et détecteur
33
+ `detect_median_mean_gap_warning` (priority 140) : émet un Fact
34
+ quand `|mean - median| / median > 30 %` pour le moteur leader.
35
+ Importance MEDIUM par défaut, HIGH si gap relatif ≥ 100 %.
36
+ Garde-fou : ne déclenche pas si la médiane est nulle.
37
+ - Templates FR/EN — aucun nombre en dur, tout vient du payload
38
+ (vérifié par test).
39
+ - L'arbitre marque la paire `{GLOBAL_LEADER_CER,
40
+ MEDIAN_MEAN_GAP_WARNING}` comme **complémentaire** : les deux
41
+ phrases peuvent coexister dans la synthèse pour nuancer le
42
+ leader.
43
+ - +15 tests dans `test_sprint44_median_default.py` (propriété
44
+ median_cer, tri par médiane sur cas asymétrique réaliste,
45
+ fallback sur la moyenne, déclenchement du détecteur sur 4 cas
46
+ dégénérés, importance MEDIUM/HIGH selon gap, traçabilité
47
+ anti-hallucination FR + EN, intégration via build_synthesis).
48
+
49
  - **Sprint 43 — A.II.1.b Calibration : vue HTML reliability diagram +
50
  tableau ECE/MCE (clôture A.II.1.b côté rapport).** Suite directe du
51
  Sprint 42 (câblage runner). Les chiffres de calibration sont
 
404
 
405
  ### Tests
406
 
407
+ - 1478 → 1810 tests (+17 Sprint 32, +23 Sprint 33, +21 Sprint 34,
408
  +27 Sprint 35, +22 Sprint 36, +42 Sprint 37, +19 Sprint 38,
409
  +32 Sprint 39, +16 Sprint 40, +38 Sprint 41, +17 Sprint 42,
410
+ +43 Sprint 43, +15 Sprint 44). Aucune régression. **Phase 0
411
+ close ; Étape 2 du plan d'évolution : inter-moteurs (A.II.1.c),
412
+ NER (A.II.1.a) et calibration (A.II.1.b) livrés bout-en-bout
413
+ calcul → runner → HTML ; A.I.2 médiane par défaut livré (Sprint 44).
414
  Reste l'adaptation effective des engines pour exposer leurs
415
  confidences natives (un sprint par adapter).**
416
 
CLAUDE.md CHANGED
@@ -207,6 +207,7 @@ AZURE_DOC_INTEL_KEY=...
207
  | 33 | **Sprint 2 du plan d'évolution 2026 — Phase 0.2 : interface module générique**. Nouveau module `picarones/core/modules.py` avec l'enum `ArtifactType` (IMAGE, TEXT, ALTO, PAGE, ENTITIES, READING_ORDER) et la classe abstraite `BaseModule` qui déclare `input_types`/`output_types`, `execution_mode` (`"io"`/`"cpu"`), une méthode `process(dict[ArtifactType, Any]) → dict[ArtifactType, Any]`, et des helpers `validate_inputs`/`validate_outputs`. `BaseOCREngine` (`picarones/engines/base.py`) hérite désormais de `BaseModule` avec `input_types=(IMAGE,)` et `output_types=(TEXT,)` ; sa nouvelle méthode `process` wrappe l'API historique `run()`. Aucun adaptateur OCR existant n'est touché — `test_engines.py` passe à 20/20 sans modification. +23 tests dans `test_sprint33_module_interface.py` (contrat, validation, MockModule TEXT→ALTO démonstratif comme demandé par le plan, délégation `BaseOCREngine.process → run`, cohérence ArtifactType/GTLevel). **Verrou levé** : un même runner peut maintenant exécuter un OCR (image→texte), un mappeur VLM→ALTO, un rewriter ALTO→ALTO, un module NER (texte→entités), etc. — fondation directe pour l'axe B du plan. |
208
  | 34 | **Sprint 3 du plan d'évolution 2026 — Phase 0.3 : registre typé de métriques (clôture Phase 0)**. Nouveaux modules `picarones/core/metric_registry.py` (`MetricSpec`, `@register_metric`, `select_metrics`, `compute_at_junction`) et `picarones/core/builtin_metrics.py` qui enregistre `cer`, `wer`, `mer`, `wil` sur `(TEXT, TEXT)` plus un stub `text_preservation_after_reconstruction` sur `(TEXT, ALTO)` comme preuve de concept de jonction hétérogène. **Approche strictement additive** : ni `metrics.py` ni `compute_metrics` ne sont modifiés, le rapport HTML reste identique octet par octet. La sélection par signature de types est exacte (pas de coercion). +21 tests dans `test_sprint34_metric_registry.py`, dont une parité numérique CER/WER/MER/WIL avec `compute_metrics` legacy à 1e-9 près sur 4 paires de textes. **Verrou levé** : le runner d'une pipeline composée peut maintenant calculer automatiquement la métrique adéquate à chaque jonction de son DAG selon les types d'artefacts produits/attendus — fondation directe pour la métrique d'absorption d'erreur (acte B.3) et toutes les métriques structurelles à venir (Layout F1, reading order F1, NER). |
209
  | 35 | **Sprint 4 du plan d'évolution 2026 — Étape 2 / axe A : métriques inter-moteurs (couche de calcul)**. Nouveau module `picarones/core/inter_engine.py` qui répond à deux questions distinctes mais liées : *(a) à quel point les moteurs font-ils des erreurs de natures différentes ?* via `kl_divergence`, `jensen_shannon_divergence` (symétrique, bornée `[0, 1]`), et `taxonomy_divergence_matrix` qui construit la matrice triangulaire inter-moteurs ; *(b) quel CER serait atteignable si on combinait les moteurs ?* via `oracle_token_recall` (proxy bag-of-words, borne supérieure du recall atteignable), `complementarity_gap` (oracle vs meilleur moteur seul, gap absolu/relatif), et `pairwise_disagreement_rate`. Fonctions pures, sans I/O ni intégration runner — la couche de calcul est livrée indépendamment, le câblage narratif (`ENSEMBLE_OPPORTUNITY`) et HTML (matrice de divergence, badge oracle) suit au Sprint 36. +27 tests couvrant les invariants mathématiques (KL ≥ 0, KL(p,p) = 0, JS symétrique et bornée, oracle ≥ best_single, multiplicité respectée), les cas concrets (deux moteurs spécialisés sortent comme candidats ensemble, complémentarité parfaite atteint oracle = 1), et les garde-fous (référence vide, hypothèses vides, métrique inconnue). |
 
210
  | 43 | **Sprint 12 du plan d'évolution 2026 — Étape 2 / axe A.II.1.b : vue HTML calibration (clôture A.II.1.b côté rapport)**. Nouveau module `picarones/report/calibration_render.py` : `build_calibration_summary_html` rend un tableau résumé (ECE, MCE, accuracy moyenne, confidence moyenne, n_predictions, doc_count) avec cellule ECE colorée par gradient vert (bien calibré) → rouge (mal calibré) ; `build_reliability_diagram_svg` rend un SVG par moteur avec barres d'accuracy par bin, ligne reliant les points `(avg_confidence, accuracy)`, diagonale en pointillé pour la calibration parfaite, axes annotés (graduations 0/0.5/1) ; `build_reliability_diagrams_grid_html` génère une grille auto-fit (un SVG par moteur ayant `aggregated_calibration`). Rendu strictement server-side, pas de JS, déterministe. `_build_report_data` expose `aggregated_calibration` par moteur ; `ReportGenerator.generate` calcule les blocs et les passe à `view_analyses.html` qui les affiche **uniquement si ≥ 1 moteur a un `aggregated_calibration`** (rapport adaptatif). Anti-injection HTML via `html.escape`. +13 clés i18n FR/EN. +43 tests dans `test_sprint43_calibration_html.py` couvrant le rendu (résumé, SVG, grille), le masquage adaptatif, l'anti-injection, l'intégration FR + EN, la complétude i18n. **Verrou levé** : A.II.1.b (calibration) est désormais visible bout-en-bout dans le rapport — il manque uniquement l'adaptation effective des engines pour exposer leurs confidences natives (un sprint par adapter : Tesseract `image_to_data`, Pero `PageLayout`, Mistral `confidence`, Google Vision `Word.confidence`, Azure DI). |
211
  | 42 | **Sprint 11 du plan d'évolution 2026 — Étape 2 / axe A.II.1.b : exposition `token_confidences` + câblage runner**. Suite du Sprint 39 (couche de calcul). `EngineResult` gagne un champ optionnel `token_confidences: Optional[list[dict[str, Any]]]` (`None` par défaut → rétrocompat stricte). `DocumentResult.calibration_metrics` et `EngineReport.aggregated_calibration` ajoutés (sérialisation dans `as_dict` conditionnelle, libérés par `compact()`). Nouveau helper `_calibration_from_engine_result` qui aligne par bag-of-words avec multiplicité (proxy oracle, comme `oracle_token_recall`), normalise les confidences en pourcentage à `[0, 1]`, ignore les confidences négatives (Tesseract met -1 pour les non-mots) ; appelé dans `_compute_document_result` quand `token_confidences` est non-vide. Helper `_aggregate_calibration` combine les bins de tous les docs en somme pondérée par count, recalcule ECE/MCE micro. **L'adaptation de chaque adapter (Tesseract, Pero OCR, Mistral OCR, Google Vision, Azure DI) à exposer ses confidences natives est reportée à des sprints dédiés** : ce sprint pose l'infrastructure complète et la teste avec un mock. +17 tests dans `test_sprint42_calibration_runner.py` (champ EngineResult, sérialisation/compact, helper d'alignement avec calibration parfaite + normalisation % + skip négatifs + bag-of-words multiplicité, agrégation multi-docs, rétrocompat sans confidences). **Verrou levé** : un moteur qui expose ses confidences (cas réel à venir) verra automatiquement ses métriques de calibration calculées et agrégées par le runner — il manque uniquement la vue HTML reliability et l'adaptation des engines un par un. |
212
  | 41 | **Sprint 10 du plan d'évolution 2026 — Étape 2 / axe A.II.1.a : vue HTML NER (clôture A.II.1.a)**. Nouveau module `picarones/report/ner_render.py` : `build_ner_summary_html` rend un tableau résumé (F1 global, P, R, docs évalués, hallucinations, missed) avec cellule F1 colorée par gradient rouge → jaune → vert ; `build_ner_per_category_html` rend la heatmap moteur × catégorie d'entité (PER, LOC, ORG, DATE, MISC…) avec tooltip `support=N`, cellule vide marquée `—` pour les catégories non observées. Rendu server-side, pas de JS, déterministe. Anti-injection HTML via `html.escape`. `_build_report_data` expose `aggregated_ner` par moteur. `ReportGenerator.generate` calcule les deux blocs et les passe au template `view_analyses.html` qui les affiche dans une `chart-card` à largeur pleine **uniquement si ≥ 1 moteur a un `aggregated_ner`**. +12 clés i18n FR/EN. +38 tests dans `test_sprint41_ner_html.py` (rendu, masquage adaptatif, anti-injection, intégration FR + EN, complétude i18n). **Verrou levé** : A.II.1.a (NER) est désormais livré bout-en-bout — couche de calcul (Sprint 38) + backend + câblage runner (Sprint 40) + vue HTML (Sprint 41). Reste la calibration A.II.1.b à finir bout-en-bout (extraction des token_confidences depuis les engines + vue HTML reliability diagram). |
@@ -261,7 +262,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
261
  ## Contexte développement
262
 
263
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
264
- - **Tests** : 1795 passed, 2 skipped (Sprints 32-34 = Phase 0 close ; Sprints 35-37 = inter-moteurs livrés bout-en-bout ; Sprints 38+40+41 = NER livré bout-en-bout ; Sprints 39+42+43 = calibration livrée bout-en-bout calcul runner HTML, adaptation engines à venir)
265
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
266
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
267
  - **Transcript de la conversation de développement** :
 
207
  | 33 | **Sprint 2 du plan d'évolution 2026 — Phase 0.2 : interface module générique**. Nouveau module `picarones/core/modules.py` avec l'enum `ArtifactType` (IMAGE, TEXT, ALTO, PAGE, ENTITIES, READING_ORDER) et la classe abstraite `BaseModule` qui déclare `input_types`/`output_types`, `execution_mode` (`"io"`/`"cpu"`), une méthode `process(dict[ArtifactType, Any]) → dict[ArtifactType, Any]`, et des helpers `validate_inputs`/`validate_outputs`. `BaseOCREngine` (`picarones/engines/base.py`) hérite désormais de `BaseModule` avec `input_types=(IMAGE,)` et `output_types=(TEXT,)` ; sa nouvelle méthode `process` wrappe l'API historique `run()`. Aucun adaptateur OCR existant n'est touché — `test_engines.py` passe à 20/20 sans modification. +23 tests dans `test_sprint33_module_interface.py` (contrat, validation, MockModule TEXT→ALTO démonstratif comme demandé par le plan, délégation `BaseOCREngine.process → run`, cohérence ArtifactType/GTLevel). **Verrou levé** : un même runner peut maintenant exécuter un OCR (image→texte), un mappeur VLM→ALTO, un rewriter ALTO→ALTO, un module NER (texte→entités), etc. — fondation directe pour l'axe B du plan. |
208
  | 34 | **Sprint 3 du plan d'évolution 2026 — Phase 0.3 : registre typé de métriques (clôture Phase 0)**. Nouveaux modules `picarones/core/metric_registry.py` (`MetricSpec`, `@register_metric`, `select_metrics`, `compute_at_junction`) et `picarones/core/builtin_metrics.py` qui enregistre `cer`, `wer`, `mer`, `wil` sur `(TEXT, TEXT)` plus un stub `text_preservation_after_reconstruction` sur `(TEXT, ALTO)` comme preuve de concept de jonction hétérogène. **Approche strictement additive** : ni `metrics.py` ni `compute_metrics` ne sont modifiés, le rapport HTML reste identique octet par octet. La sélection par signature de types est exacte (pas de coercion). +21 tests dans `test_sprint34_metric_registry.py`, dont une parité numérique CER/WER/MER/WIL avec `compute_metrics` legacy à 1e-9 près sur 4 paires de textes. **Verrou levé** : le runner d'une pipeline composée peut maintenant calculer automatiquement la métrique adéquate à chaque jonction de son DAG selon les types d'artefacts produits/attendus — fondation directe pour la métrique d'absorption d'erreur (acte B.3) et toutes les métriques structurelles à venir (Layout F1, reading order F1, NER). |
209
  | 35 | **Sprint 4 du plan d'évolution 2026 — Étape 2 / axe A : métriques inter-moteurs (couche de calcul)**. Nouveau module `picarones/core/inter_engine.py` qui répond à deux questions distinctes mais liées : *(a) à quel point les moteurs font-ils des erreurs de natures différentes ?* via `kl_divergence`, `jensen_shannon_divergence` (symétrique, bornée `[0, 1]`), et `taxonomy_divergence_matrix` qui construit la matrice triangulaire inter-moteurs ; *(b) quel CER serait atteignable si on combinait les moteurs ?* via `oracle_token_recall` (proxy bag-of-words, borne supérieure du recall atteignable), `complementarity_gap` (oracle vs meilleur moteur seul, gap absolu/relatif), et `pairwise_disagreement_rate`. Fonctions pures, sans I/O ni intégration runner — la couche de calcul est livrée indépendamment, le câblage narratif (`ENSEMBLE_OPPORTUNITY`) et HTML (matrice de divergence, badge oracle) suit au Sprint 36. +27 tests couvrant les invariants mathématiques (KL ≥ 0, KL(p,p) = 0, JS symétrique et bornée, oracle ≥ best_single, multiplicité respectée), les cas concrets (deux moteurs spécialisés sortent comme candidats ensemble, complémentarité parfaite atteint oracle = 1), et les garde-fous (référence vide, hypothèses vides, métrique inconnue). |
210
+ | 44 | **Sprint 13 du plan d'évolution 2026 — Étape 2 / axe A.I.2 : tri par médiane par défaut + détecteur d'asymétrie**. Réponse à la critique structurelle 2 du plan : sur les corpus patrimoniaux, la moyenne est tirée par quelques documents catastrophiques et masque les performances réelles. `EngineReport.median_cer` ajouté (lit `aggregated_metrics["cer"]["median"]`). `BenchmarkResult.ranking()` inclut désormais `median_cer` dans chaque entrée et **trie par médiane CER croissante par défaut** (fallback sur `mean_cer` si médiane absente). Nouveau `FactType.MEDIAN_MEAN_GAP_WARNING` + détecteur `detect_median_mean_gap_warning` (priority 140) : émet un Fact quand `\|mean - median\| / median > 30 %` pour le moteur leader, importance HIGH si gap relatif ≥ 100 % (sinon MEDIUM). Garde-fou : ne déclenche pas si médiane nulle. Templates FR/EN sans nombres en dur (vérifié). L'arbitre marque la paire `{GLOBAL_LEADER_CER, MEDIAN_MEAN_GAP_WARNING}` comme **complémentaire** : les deux phrases peuvent coexister dans la synthèse pour nuancer le leader. +15 tests dans `test_sprint44_median_default.py` (propriété, tri sur cas asymétrique réaliste, fallback, déclenchement détecteur sur 4 cas dégénérés, importance, traçabilité anti-hallucination FR + EN, intégration build_synthesis). **Verrou levé** : la critique « le rapport classe sur la moyenne alors que les distributions patrimoniales sont asymétriques » est résolue ; le lecteur voit immédiatement le moteur le plus représentatif et est averti quand l'écart médiane/moyenne est suspect. |
211
  | 43 | **Sprint 12 du plan d'évolution 2026 — Étape 2 / axe A.II.1.b : vue HTML calibration (clôture A.II.1.b côté rapport)**. Nouveau module `picarones/report/calibration_render.py` : `build_calibration_summary_html` rend un tableau résumé (ECE, MCE, accuracy moyenne, confidence moyenne, n_predictions, doc_count) avec cellule ECE colorée par gradient vert (bien calibré) → rouge (mal calibré) ; `build_reliability_diagram_svg` rend un SVG par moteur avec barres d'accuracy par bin, ligne reliant les points `(avg_confidence, accuracy)`, diagonale en pointillé pour la calibration parfaite, axes annotés (graduations 0/0.5/1) ; `build_reliability_diagrams_grid_html` génère une grille auto-fit (un SVG par moteur ayant `aggregated_calibration`). Rendu strictement server-side, pas de JS, déterministe. `_build_report_data` expose `aggregated_calibration` par moteur ; `ReportGenerator.generate` calcule les blocs et les passe à `view_analyses.html` qui les affiche **uniquement si ≥ 1 moteur a un `aggregated_calibration`** (rapport adaptatif). Anti-injection HTML via `html.escape`. +13 clés i18n FR/EN. +43 tests dans `test_sprint43_calibration_html.py` couvrant le rendu (résumé, SVG, grille), le masquage adaptatif, l'anti-injection, l'intégration FR + EN, la complétude i18n. **Verrou levé** : A.II.1.b (calibration) est désormais visible bout-en-bout dans le rapport — il manque uniquement l'adaptation effective des engines pour exposer leurs confidences natives (un sprint par adapter : Tesseract `image_to_data`, Pero `PageLayout`, Mistral `confidence`, Google Vision `Word.confidence`, Azure DI). |
212
  | 42 | **Sprint 11 du plan d'évolution 2026 — Étape 2 / axe A.II.1.b : exposition `token_confidences` + câblage runner**. Suite du Sprint 39 (couche de calcul). `EngineResult` gagne un champ optionnel `token_confidences: Optional[list[dict[str, Any]]]` (`None` par défaut → rétrocompat stricte). `DocumentResult.calibration_metrics` et `EngineReport.aggregated_calibration` ajoutés (sérialisation dans `as_dict` conditionnelle, libérés par `compact()`). Nouveau helper `_calibration_from_engine_result` qui aligne par bag-of-words avec multiplicité (proxy oracle, comme `oracle_token_recall`), normalise les confidences en pourcentage à `[0, 1]`, ignore les confidences négatives (Tesseract met -1 pour les non-mots) ; appelé dans `_compute_document_result` quand `token_confidences` est non-vide. Helper `_aggregate_calibration` combine les bins de tous les docs en somme pondérée par count, recalcule ECE/MCE micro. **L'adaptation de chaque adapter (Tesseract, Pero OCR, Mistral OCR, Google Vision, Azure DI) à exposer ses confidences natives est reportée à des sprints dédiés** : ce sprint pose l'infrastructure complète et la teste avec un mock. +17 tests dans `test_sprint42_calibration_runner.py` (champ EngineResult, sérialisation/compact, helper d'alignement avec calibration parfaite + normalisation % + skip négatifs + bag-of-words multiplicité, agrégation multi-docs, rétrocompat sans confidences). **Verrou levé** : un moteur qui expose ses confidences (cas réel à venir) verra automatiquement ses métriques de calibration calculées et agrégées par le runner — il manque uniquement la vue HTML reliability et l'adaptation des engines un par un. |
213
  | 41 | **Sprint 10 du plan d'évolution 2026 — Étape 2 / axe A.II.1.a : vue HTML NER (clôture A.II.1.a)**. Nouveau module `picarones/report/ner_render.py` : `build_ner_summary_html` rend un tableau résumé (F1 global, P, R, docs évalués, hallucinations, missed) avec cellule F1 colorée par gradient rouge → jaune → vert ; `build_ner_per_category_html` rend la heatmap moteur × catégorie d'entité (PER, LOC, ORG, DATE, MISC…) avec tooltip `support=N`, cellule vide marquée `—` pour les catégories non observées. Rendu server-side, pas de JS, déterministe. Anti-injection HTML via `html.escape`. `_build_report_data` expose `aggregated_ner` par moteur. `ReportGenerator.generate` calcule les deux blocs et les passe au template `view_analyses.html` qui les affiche dans une `chart-card` à largeur pleine **uniquement si ≥ 1 moteur a un `aggregated_ner`**. +12 clés i18n FR/EN. +38 tests dans `test_sprint41_ner_html.py` (rendu, masquage adaptatif, anti-injection, intégration FR + EN, complétude i18n). **Verrou levé** : A.II.1.a (NER) est désormais livré bout-en-bout — couche de calcul (Sprint 38) + backend + câblage runner (Sprint 40) + vue HTML (Sprint 41). Reste la calibration A.II.1.b à finir bout-en-bout (extraction des token_confidences depuis les engines + vue HTML reliability diagram). |
 
262
  ## Contexte développement
263
 
264
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
265
+ - **Tests** : 1810 passed, 2 skipped (Sprints 32-34 = Phase 0 close ; Sprints 35-37 = inter-moteurs livrés bout-en-bout ; Sprints 38+40+41 = NER livré bout-en-bout ; Sprints 39+42+43 = calibration livrée bout-en-bout côté rapport ; Sprint 44 = médiane par défaut)
266
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
267
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
268
  - **Transcript de la conversation de développement** :
picarones/core/narrative/arbiter.py CHANGED
@@ -64,6 +64,7 @@ _FALLBACK_TYPE_ORDER: tuple[FactType, ...] = (
64
  FactType.COST_OUTLIER,
65
  FactType.CONFIDENCE_WARNING,
66
  FactType.ENSEMBLE_OPPORTUNITY,
 
67
  )
68
 
69
 
@@ -86,6 +87,9 @@ _COMPLEMENTARY_PAIRS: frozenset[frozenset[FactType]] = frozenset({
86
  frozenset({FactType.GLOBAL_LEADER_CER, FactType.SPEED_WINNER}),
87
  frozenset({FactType.GLOBAL_LEADER_CER, FactType.CONFIDENCE_WARNING}),
88
  frozenset({FactType.STATISTICAL_TIE, FactType.SPEED_WINNER}),
 
 
 
89
  })
90
 
91
 
 
64
  FactType.COST_OUTLIER,
65
  FactType.CONFIDENCE_WARNING,
66
  FactType.ENSEMBLE_OPPORTUNITY,
67
+ FactType.MEDIAN_MEAN_GAP_WARNING,
68
  )
69
 
70
 
 
87
  frozenset({FactType.GLOBAL_LEADER_CER, FactType.SPEED_WINNER}),
88
  frozenset({FactType.GLOBAL_LEADER_CER, FactType.CONFIDENCE_WARNING}),
89
  frozenset({FactType.STATISTICAL_TIE, FactType.SPEED_WINNER}),
90
+ # Sprint 44 — l'avertissement d'asymétrie nuance le leader
91
+ # plutôt que de le doubler : on veut les deux phrases ensemble.
92
+ frozenset({FactType.GLOBAL_LEADER_CER, FactType.MEDIAN_MEAN_GAP_WARNING}),
93
  })
94
 
95
 
picarones/core/narrative/detectors.py CHANGED
@@ -717,6 +717,65 @@ def detect_confidence_warning(benchmark_data: dict) -> list[Fact]:
717
  return facts
718
 
719
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
720
  # ---------------------------------------------------------------------------
721
  # Détecteur Sprint 36 — opportunité d'ensemble (complémentarité)
722
  # ---------------------------------------------------------------------------
 
717
  return facts
718
 
719
 
720
+ # ---------------------------------------------------------------------------
721
+ # Détecteur Sprint 44 — distribution asymétrique (médiane vs moyenne)
722
+ # ---------------------------------------------------------------------------
723
+
724
+ @register_detector(
725
+ FactType.MEDIAN_MEAN_GAP_WARNING,
726
+ priority=140,
727
+ importance=FactImportance.MEDIUM,
728
+ )
729
+ def detect_median_mean_gap_warning(benchmark_data: dict) -> list[Fact]:
730
+ """Avertit quand le ratio ``|moyenne - médiane| / médiane`` du leader
731
+ dépasse 30 %, ce qui indique une distribution fortement asymétrique
732
+ où la moyenne masque les performances réelles.
733
+
734
+ Sprint 44 — A.I.2 du plan d'évolution. Cohérent avec le passage du
735
+ tri par défaut sur la médiane : si la moyenne du leader diverge
736
+ fortement de la médiane, l'utilisateur doit le savoir pour
737
+ interpréter correctement les chiffres.
738
+ """
739
+ ranking = benchmark_data.get("ranking") or []
740
+ valid = [
741
+ r for r in ranking
742
+ if r.get("median_cer") is not None
743
+ and r.get("mean_cer") is not None
744
+ ]
745
+ if not valid:
746
+ return []
747
+
748
+ leader = valid[0]
749
+ median_cer = float(leader["median_cer"])
750
+ mean_cer = float(leader["mean_cer"])
751
+
752
+ if median_cer <= 0:
753
+ # Médiane nulle (corpus très facile pour ce moteur) — l'écart
754
+ # relatif n'est pas calculable de manière utile, on s'abstient.
755
+ return []
756
+
757
+ relative_gap = abs(mean_cer - median_cer) / median_cer
758
+ if relative_gap < 0.30:
759
+ return []
760
+
761
+ importance = (
762
+ FactImportance.HIGH if relative_gap >= 1.0 else FactImportance.MEDIUM
763
+ )
764
+
765
+ return [Fact(
766
+ type=FactType.MEDIAN_MEAN_GAP_WARNING,
767
+ importance=importance,
768
+ payload={
769
+ "engine": leader["engine"],
770
+ "median_cer_pct": round(median_cer * 100, 2),
771
+ "mean_cer_pct": round(mean_cer * 100, 2),
772
+ "relative_gap_pct": round(relative_gap * 100, 1),
773
+ "n_docs": int(leader.get("documents") or 0),
774
+ },
775
+ engines_involved=(leader["engine"],),
776
+ )]
777
+
778
+
779
  # ---------------------------------------------------------------------------
780
  # Détecteur Sprint 36 — opportunité d'ensemble (complémentarité)
781
  # ---------------------------------------------------------------------------
picarones/core/narrative/facts.py CHANGED
@@ -64,6 +64,12 @@ class FactType(str, Enum):
64
  """Deux moteurs sont fortement complémentaires : un voting majoritaire
65
  pourrait améliorer significativement le CER (Sprint 36)."""
66
 
 
 
 
 
 
 
67
 
68
  class FactImportance(int, Enum):
69
  """Score d'importance d'un fait — décide l'ordre et la sélection."""
 
64
  """Deux moteurs sont fortement complémentaires : un voting majoritaire
65
  pourrait améliorer significativement le CER (Sprint 36)."""
66
 
67
+ MEDIAN_MEAN_GAP_WARNING = "median_mean_gap_warning"
68
+ """Distribution des CER fortement asymétrique sur le corpus —
69
+ la moyenne du leader est tirée par quelques documents catastrophiques
70
+ et masque les performances réelles. La médiane (utilisée pour le tri
71
+ par défaut depuis Sprint 44) est plus représentative."""
72
+
73
 
74
  class FactImportance(int, Enum):
75
  """Score d'importance d'un fait — décide l'ordre et la sélection."""
picarones/core/narrative/templates/en.yaml CHANGED
@@ -61,3 +61,10 @@ ensemble_opportunity: >-
61
  among the engines would preserve {oracle_recall_pct} % — i.e.
62
  {absolute_gap_pct} points recoverable ({relative_gap_pct} % of the best
63
  engine's errors).
 
 
 
 
 
 
 
 
61
  among the engines would preserve {oracle_recall_pct} % — i.e.
62
  {absolute_gap_pct} points recoverable ({relative_gap_pct} % of the best
63
  engine's errors).
64
+
65
+ median_mean_gap_warning: >-
66
+ Asymmetric distribution for {engine}: median CER {median_cer_pct} %
67
+ vs mean {mean_cer_pct} % across {n_docs} documents (relative gap
68
+ {relative_gap_pct} %). The mean is pulled by a few catastrophic
69
+ documents — the median (now used for default ranking) is more
70
+ representative.
picarones/core/narrative/templates/fr.yaml CHANGED
@@ -65,3 +65,10 @@ ensemble_opportunity: >-
65
  entre les moteurs en préserverait {oracle_recall_pct} %, soit
66
  {absolute_gap_pct} points récupérables ({relative_gap_pct} % des erreurs
67
  du meilleur moteur).
 
 
 
 
 
 
 
 
65
  entre les moteurs en préserverait {oracle_recall_pct} %, soit
66
  {absolute_gap_pct} points récupérables ({relative_gap_pct} % des erreurs
67
  du meilleur moteur).
68
+
69
+ median_mean_gap_warning: >-
70
+ Distribution asymétrique pour {engine} : médiane CER {median_cer_pct} %
71
+ vs moyenne {mean_cer_pct} % sur {n_docs} documents (écart relatif
72
+ {relative_gap_pct} %). La moyenne est tirée par quelques documents
73
+ catastrophiques — la médiane (utilisée pour le tri par défaut) est
74
+ plus représentative.
picarones/core/results.py CHANGED
@@ -185,6 +185,18 @@ class EngineReport:
185
  cer_stats = self.aggregated_metrics.get("cer", {})
186
  return cer_stats.get("mean")
187
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  @property
189
  def mean_wer(self) -> Optional[float]:
190
  wer_stats = self.aggregated_metrics.get("wer", {})
@@ -258,22 +270,43 @@ class BenchmarkResult:
258
  inter_engine_analysis: Optional[dict] = None
259
 
260
  def ranking(self) -> list[dict]:
261
- """Retourne le classement des moteurs trié par CER croissant."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
  ranked = []
263
  for report in self.engine_reports:
264
  ranked.append(
265
  {
266
  "engine": report.engine_name,
267
  "mean_cer": report.mean_cer,
 
268
  "mean_wer": report.mean_wer,
269
  "documents": len(report.document_results),
270
  "failed": report.aggregated_metrics.get("failed_count", 0),
271
  }
272
  )
273
- return sorted(
274
- ranked,
275
- key=lambda x: (x["mean_cer"] is None, x["mean_cer"] or float("inf")),
276
- )
 
 
 
 
 
277
 
278
  def as_dict(self) -> dict:
279
  d = {
 
185
  cer_stats = self.aggregated_metrics.get("cer", {})
186
  return cer_stats.get("mean")
187
 
188
+ @property
189
+ def median_cer(self) -> Optional[float]:
190
+ """CER médian sur le corpus.
191
+
192
+ Sprint 44 — devient le critère de tri par défaut du ``ranking()``
193
+ car la moyenne est facilement tirée par quelques documents
194
+ catastrophiques sur une distribution asymétrique (typique des
195
+ corpus patrimoniaux).
196
+ """
197
+ cer_stats = self.aggregated_metrics.get("cer", {})
198
+ return cer_stats.get("median")
199
+
200
  @property
201
  def mean_wer(self) -> Optional[float]:
202
  wer_stats = self.aggregated_metrics.get("wer", {})
 
270
  inter_engine_analysis: Optional[dict] = None
271
 
272
  def ranking(self) -> list[dict]:
273
+ """Retourne le classement des moteurs trié par **médiane CER** croissante.
274
+
275
+ Sprint 44 — A.I.2 du plan d'évolution : le tri par défaut bascule
276
+ de la moyenne vers la médiane. Sur des distributions
277
+ asymétriques (typique des corpus patrimoniaux : 80 % des docs
278
+ à 3 % de CER, 20 % à 40 %), la moyenne est tirée par quelques
279
+ documents catastrophiques et masque les performances réelles.
280
+ La médiane est plus représentative ; cohérente aussi avec le
281
+ test de Friedman qui travaille déjà sur les rangs (Sprint 18).
282
+
283
+ Le champ ``mean_cer`` est conservé dans chaque entrée pour
284
+ rétrocompatibilité — les consommateurs (CLI, détecteurs
285
+ narratifs, vue HTML) continuent à pouvoir l'afficher en colonne
286
+ secondaire. Le tri prend ``median_cer`` quand disponible et
287
+ retombe sur ``mean_cer`` sinon.
288
+ """
289
  ranked = []
290
  for report in self.engine_reports:
291
  ranked.append(
292
  {
293
  "engine": report.engine_name,
294
  "mean_cer": report.mean_cer,
295
+ "median_cer": report.median_cer,
296
  "mean_wer": report.mean_wer,
297
  "documents": len(report.document_results),
298
  "failed": report.aggregated_metrics.get("failed_count", 0),
299
  }
300
  )
301
+
302
+ def _sort_key(entry: dict) -> tuple:
303
+ # Priorité : médiane si disponible, sinon moyenne, sinon +∞
304
+ primary = entry.get("median_cer")
305
+ if primary is None:
306
+ primary = entry.get("mean_cer")
307
+ return (primary is None, primary if primary is not None else float("inf"))
308
+
309
+ return sorted(ranked, key=_sort_key)
310
 
311
  def as_dict(self) -> dict:
312
  d = {
tests/test_sprint44_median_default.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests Sprint 44 — médiane par défaut + détecteur d'asymétrie.
2
+
3
+ Couvre :
4
+
5
+ 1. ``EngineReport.median_cer`` lit ``aggregated_metrics["cer"]["median"]``.
6
+ 2. ``BenchmarkResult.ranking()`` :
7
+ - inclut ``median_cer`` dans chaque entrée
8
+ - trie sur la médiane par défaut (et non plus la moyenne)
9
+ - retombe sur la moyenne si la médiane est absente
10
+ 3. Détecteur ``MEDIAN_MEAN_GAP_WARNING`` :
11
+ - se déclenche quand le ratio ``|moyenne - médiane| / médiane > 30%``
12
+ - ne se déclenche pas quand symétrique
13
+ - ne se déclenche pas si la médiane est nulle (corpus parfait)
14
+ - importance HIGH si gap relatif ≥ 100 %
15
+ 4. Anti-hallucination : chaque nombre rendu est dans le payload.
16
+ 5. Rétrocompat : les consommateurs qui lisent ``mean_cer`` continuent
17
+ à fonctionner.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+
24
+ import pytest
25
+
26
+ from picarones.core.metrics import MetricsResult
27
+ from picarones.core.narrative.detectors import detect_median_mean_gap_warning
28
+ from picarones.core.narrative.facts import FactImportance, FactType
29
+ from picarones.core.narrative.renderer import extract_numbers, render_fact
30
+ from picarones.core.results import BenchmarkResult, DocumentResult, EngineReport
31
+
32
+
33
+ # ──────────────────────────────────────────────────────────────────────────
34
+ # Helpers
35
+ # ──────────────────────────────────────────────────────────────────────────
36
+
37
+
38
+ def _make_dr(cer: float, doc_id: str = "d") -> DocumentResult:
39
+ return DocumentResult(
40
+ doc_id=doc_id, image_path="/tmp/x.png",
41
+ ground_truth="x", hypothesis="x",
42
+ metrics=MetricsResult(
43
+ cer=cer, cer_nfc=cer, cer_caseless=cer,
44
+ wer=cer, wer_normalized=cer, mer=cer, wil=cer,
45
+ reference_length=1, hypothesis_length=1,
46
+ ),
47
+ duration_seconds=0.1,
48
+ )
49
+
50
+
51
+ def _make_engine_report(name: str, cers: list[float]) -> EngineReport:
52
+ drs = [_make_dr(c, doc_id=f"d{i}") for i, c in enumerate(cers)]
53
+ return EngineReport(
54
+ engine_name=name, engine_version="1", engine_config={},
55
+ document_results=drs,
56
+ )
57
+
58
+
59
+ # ──────────────────────────────────────────────────────────────────────────
60
+ # 1. EngineReport.median_cer
61
+ # ──────────────────────────────────────────────────────────────────────────
62
+
63
+
64
+ class TestMedianCerProperty:
65
+ def test_returns_median_from_aggregated(self) -> None:
66
+ rep = _make_engine_report("e", [0.0, 0.0, 0.0, 1.0, 1.0])
67
+ # Médiane de [0,0,0,1,1] = 0
68
+ assert rep.median_cer == pytest.approx(0.0)
69
+
70
+ def test_returns_none_when_no_docs(self) -> None:
71
+ rep = EngineReport(
72
+ engine_name="e", engine_version="1", engine_config={},
73
+ document_results=[],
74
+ )
75
+ # Pas de docs → aggregated_metrics vide → mean/median = None
76
+ assert rep.median_cer is None
77
+
78
+
79
+ # ──────────────────────────────────────────────────────────────────────────
80
+ # 2. ranking() — tri par médiane
81
+ # ──────────────────────────────────────────────────────────────────────────
82
+
83
+
84
+ class TestRankingByMedian:
85
+ def test_includes_median_cer(self) -> None:
86
+ bench = BenchmarkResult(
87
+ corpus_name="c", corpus_source=None, document_count=3,
88
+ engine_reports=[_make_engine_report("a", [0.1, 0.2, 0.3])],
89
+ )
90
+ ranking = bench.ranking()
91
+ assert "median_cer" in ranking[0]
92
+ assert ranking[0]["median_cer"] == pytest.approx(0.2)
93
+
94
+ def test_sorts_by_median_not_mean(self) -> None:
95
+ # Moteur A : 80 % à 0,03 + 20 % à 0,40 → moyenne ≈ 0,11, médiane = 0,03
96
+ # Moteur B : 100 % à 0,05 → moyenne = 0,05, médiane = 0,05
97
+ # Tri par moyenne : B (0.05) < A (0.11) → A est 2e
98
+ # Tri par médiane : A (0.03) < B (0.05) → A est 1er
99
+ ers = [
100
+ _make_engine_report(
101
+ "A_asymmetric",
102
+ [0.03] * 8 + [0.40] * 2,
103
+ ),
104
+ _make_engine_report(
105
+ "B_steady",
106
+ [0.05] * 10,
107
+ ),
108
+ ]
109
+ bench = BenchmarkResult(
110
+ corpus_name="c", corpus_source=None, document_count=10,
111
+ engine_reports=ers,
112
+ )
113
+ ranking = bench.ranking()
114
+ # Le moteur A doit gagner sur la médiane même si sa moyenne est pire
115
+ assert ranking[0]["engine"] == "A_asymmetric"
116
+ assert ranking[0]["mean_cer"] > ranking[1]["mean_cer"]
117
+ assert ranking[0]["median_cer"] < ranking[1]["median_cer"]
118
+
119
+ def test_falls_back_to_mean_when_median_missing(self) -> None:
120
+ """Si median_cer est None, le tri retombe sur mean_cer.
121
+
122
+ On reproduit ici la clé de tri utilisée par
123
+ ``BenchmarkResult.ranking()`` pour valider sa logique sur des
124
+ entrées synthétiques (impossible à produire via vrais
125
+ ``EngineReport`` car ``aggregate_metrics`` calcule toujours
126
+ une médiane quand il y a au moins un doc).
127
+ """
128
+ ranked = [
129
+ {"engine": "x", "mean_cer": 0.10, "median_cer": None,
130
+ "mean_wer": 0.0, "documents": 1, "failed": 0},
131
+ {"engine": "y", "mean_cer": 0.05, "median_cer": None,
132
+ "mean_wer": 0.0, "documents": 1, "failed": 0},
133
+ ]
134
+
135
+ def _key(e: dict) -> tuple:
136
+ p = e.get("median_cer") if e.get("median_cer") is not None else e.get("mean_cer")
137
+ return (p is None, p if p is not None else float("inf"))
138
+
139
+ ranking = sorted(ranked, key=_key)
140
+ # y (mean=0.05) doit passer avant x (mean=0.10)
141
+ assert ranking[0]["engine"] == "y"
142
+
143
+
144
+ # ──────────────────────────────────────────────────────────────────────────
145
+ # 3. Détecteur MEDIAN_MEAN_GAP_WARNING
146
+ # ──────────────────────────────────────────────────────────────────────────
147
+
148
+
149
+ class TestMedianMeanGapDetector:
150
+ def test_no_fact_when_distribution_symmetric(self) -> None:
151
+ data = {"ranking": [{
152
+ "engine": "tess", "median_cer": 0.05, "mean_cer": 0.055,
153
+ "documents": 100,
154
+ }]}
155
+ # Gap relatif = 10% → en dessous du seuil 30%
156
+ assert detect_median_mean_gap_warning(data) == []
157
+
158
+ def test_emits_fact_when_asymmetric(self) -> None:
159
+ data = {"ranking": [{
160
+ "engine": "tess", "median_cer": 0.03, "mean_cer": 0.07,
161
+ "documents": 100,
162
+ }]}
163
+ # Gap relatif = 133% → au-dessus du seuil
164
+ facts = detect_median_mean_gap_warning(data)
165
+ assert len(facts) == 1
166
+ assert facts[0].type is FactType.MEDIAN_MEAN_GAP_WARNING
167
+ assert facts[0].importance is FactImportance.HIGH # >= 100 %
168
+ assert facts[0].payload["engine"] == "tess"
169
+
170
+ def test_medium_importance_when_moderate_gap(self) -> None:
171
+ data = {"ranking": [{
172
+ "engine": "tess", "median_cer": 0.05, "mean_cer": 0.075,
173
+ "documents": 100,
174
+ }]}
175
+ # Gap relatif = 50% → au-dessus du seuil mais < 100 %
176
+ facts = detect_median_mean_gap_warning(data)
177
+ assert facts[0].importance is FactImportance.MEDIUM
178
+
179
+ def test_no_fact_when_median_zero(self) -> None:
180
+ """Médiane nulle → ratio non calculable → on s'abstient."""
181
+ data = {"ranking": [{
182
+ "engine": "tess", "median_cer": 0.0, "mean_cer": 0.05,
183
+ "documents": 100,
184
+ }]}
185
+ assert detect_median_mean_gap_warning(data) == []
186
+
187
+ def test_no_fact_when_no_ranking(self) -> None:
188
+ assert detect_median_mean_gap_warning({}) == []
189
+ assert detect_median_mean_gap_warning({"ranking": []}) == []
190
+ assert detect_median_mean_gap_warning({"ranking": [{
191
+ "engine": "x", "mean_cer": None, "median_cer": None,
192
+ }]}) == []
193
+
194
+
195
+ # ──────────────────────────────────────────────────────────────────────────
196
+ # 4. Traçabilité anti-hallucination
197
+ # ──────────────────────────────────────────────────────────────────────────
198
+
199
+
200
+ class TestTraceability:
201
+ @pytest.mark.parametrize("lang", ["fr", "en"])
202
+ def test_every_rendered_number_is_in_payload(self, lang: str) -> None:
203
+ data = {"ranking": [{
204
+ "engine": "tess", "median_cer": 0.03, "mean_cer": 0.07,
205
+ "documents": 100,
206
+ }]}
207
+ facts = detect_median_mean_gap_warning(data)
208
+ sentence = render_fact(facts[0], lang)
209
+
210
+ # Whitelist : aucune constante de template n'est attendue ici
211
+ whitelist: set[str] = set()
212
+ # Recompute payload representations
213
+ payload_nums: set[str] = set()
214
+ for v in facts[0].payload.values():
215
+ if isinstance(v, (int, float)):
216
+ payload_nums.add(str(v))
217
+ if isinstance(v, float) and v.is_integer():
218
+ payload_nums.add(str(int(v)))
219
+
220
+ for num in extract_numbers(sentence):
221
+ normalized = num.replace(",", ".")
222
+ assert normalized in payload_nums | whitelist, (
223
+ f"Nombre {normalized!r} dans la phrase rendue n'est pas "
224
+ f"traçable au payload {facts[0].payload!r}"
225
+ )
226
+
227
+ def test_template_has_no_hardcoded_numbers(self) -> None:
228
+ from picarones.core.narrative.renderer import _load_templates
229
+ for lang in ("fr", "en"):
230
+ tpl = _load_templates(lang).get("median_mean_gap_warning", "")
231
+ assert tpl, f"Template absent pour {lang}"
232
+ # Enlever les placeholders {x} avant de chercher des chiffres
233
+ cleaned = re.sub(r"\{[^}]+\}", "", tpl)
234
+ digits = re.findall(r"\d", cleaned)
235
+ assert not digits, f"Template {lang} contient des chiffres en dur : {digits}"
236
+
237
+
238
+ # ──────────────────────────────────────────────────────────────────────────
239
+ # 5. Intégration via build_synthesis
240
+ # ──────────────────────────────────────────────────────────────────────────
241
+
242
+
243
+ class TestSynthesisIntegration:
244
+ def test_detector_registered_by_default(self) -> None:
245
+ from picarones.core.narrative.registry import iter_detectors
246
+ types = {entry.fact_type for entry in iter_detectors()}
247
+ assert FactType.MEDIAN_MEAN_GAP_WARNING in types
248
+
249
+ def test_synthesis_includes_warning_when_asymmetric(self) -> None:
250
+ from picarones.core.narrative import build_synthesis
251
+ data = {"ranking": [{
252
+ "engine": "tess", "median_cer": 0.03, "mean_cer": 0.07,
253
+ "documents": 100,
254
+ }]}
255
+ out = build_synthesis(data, lang="fr", max_facts=5)
256
+ sentences = out["sentences"]
257
+ # Au moins une phrase doit mentionner l'asymétrie
258
+ assert any(
259
+ "asymétrique" in s.lower() or "médiane" in s.lower()
260
+ for s in sentences
261
+ )