Spaces:
Running
sprint44: A.I.2 médiane par défaut + détecteur d'asymétrie
Browse filesRé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 +35 -4
- CLAUDE.md +2 -1
- picarones/core/narrative/arbiter.py +4 -0
- picarones/core/narrative/detectors.py +59 -0
- picarones/core/narrative/facts.py +6 -0
- picarones/core/narrative/templates/en.yaml +7 -0
- picarones/core/narrative/templates/fr.yaml +7 -0
- picarones/core/results.py +38 -5
- tests/test_sprint44_median_default.py +261 -0
|
@@ -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 →
|
| 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
|
| 381 |
-
plan d'évolution : inter-moteurs (A.II.1.c),
|
| 382 |
-
calibration (A.II.1.b) livrés bout-en-bout
|
|
|
|
| 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 |
|
|
@@ -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** :
|
| 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** :
|
|
@@ -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 |
|
|
@@ -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 |
# ---------------------------------------------------------------------------
|
|
@@ -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."""
|
|
@@ -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.
|
|
@@ -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.
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 274 |
-
|
| 275 |
-
|
| 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 = {
|
|
@@ -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 |
+
)
|