Claude commited on
Commit
f1d615d
·
unverified ·
1 Parent(s): b401086

sprint42: A.II.1.b Calibration — token_confidences + câblage runner

Browse files

Suite directe du Sprint 39 (couche de calcul pure). Le runner peut
maintenant calculer ECE/MCE/reliability dès qu'un moteur expose des
confidences au niveau token sur l'EngineResult.

EngineResult.token_confidences (Optional[list[dict[str, Any]]])
- None par défaut → rétrocompat stricte pour TOUS les adapters
historiques (Tesseract, Pero, Mistral OCR, Google Vision, Azure DI).
- Format attendu : [{"token": str, "confidence": float}, …] avec
confidence ∈ [0, 1] ou ∈ [0, 100] (normalisé par le runner).

Modèles étendus
- DocumentResult.calibration_metrics: Optional[dict] (sérialisé dans
as_dict() quand renseigné, libéré par compact()).
- EngineReport.aggregated_calibration: Optional[dict].

Câblage runner
- _calibration_from_engine_result(ground_truth, token_confidences) :
aligne par bag-of-words avec multiplicité (proxy oracle, comme
oracle_token_recall du Sprint 35), normalise les confidences en
pourcentage à [0, 1], ignore les négatives (Tesseract met -1 pour
les non-mots).
- Appelé dans _compute_document_result quand token_confidences est
non-vide ; sinon calibration_metrics reste None.
- _aggregate_calibration combine les bins de tous les docs en somme
pondérée par count, recalcule ECE/MCE micro sur l'ensemble.

L'adaptation de chaque adapter (Tesseract via image_to_data,
Pero via PageLayout, Mistral via confidence, Google Vision via
Word.confidence, Azure DI) à exposer ses confidences natives est
reportée à des sprints dédiés (un par adapter, plus testable
individuellement). Ce sprint pose l'infrastructure complète et la
rend testable de bout-en-bout via mock.

Tests : +17 dans test_sprint42_calibration_runner.py couvrant le
nouveau champ EngineResult, la sérialisation et compact des nouveaux
champs DR/ER, l'helper d'alignement (calibration parfaite quand
conf=accuracy, normalisation %, skip négatifs, bag-of-words avec
multiplicité, skip entrées invalides), l'agrégateur (combinaison de
bins multi-docs avec recalcul ECE/MCE micro), et la rétrocompat
(pas de calcul sans token_confidences).
Suite complète : 1735 → 1752 passed, 2 skipped, 0 failed.

État A.II.1.b (Calibration) : couche de calcul (Sprint 39) + câblage
runner (Sprint 42) livrés. Reste la vue HTML reliability diagram
(Sprint 43 à venir) et l'adaptation effective des engines pour
exposer leurs confidences natives.

CHANGELOG.md CHANGED
@@ -16,6 +16,40 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
16
 
17
  ### Ajouté
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  - **Sprint 41 — A.II.1.a NER : vue HTML dédiée (clôture A.II.1.a).**
20
  Suite directe des Sprints 38-40. Le moteur narratif et le runner ont
21
  déjà tout ce qu'il faut ; ce sprint rend les chiffres visibles et
@@ -299,13 +333,15 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
299
 
300
  ### Tests
301
 
302
- - 1478 → 1735 tests (+17 Sprint 32, +23 Sprint 33, +21 Sprint 34,
303
  +27 Sprint 35, +22 Sprint 36, +42 Sprint 37, +19 Sprint 38,
304
- +32 Sprint 39, +16 Sprint 40, +38 Sprint 41). Aucune régression.
305
- **Phase 0 close ; Étape 2 du plan d'évolution : inter-moteurs
306
- (A.II.1.c) et NER (A.II.1.a) livrés bout-en-bout calcul → runner
307
- → narratif → HTML ; calibration (A.II.1.b) couche de calcul
308
- livrée (Sprint 39).**
 
 
309
 
310
  ---
311
 
 
16
 
17
  ### Ajouté
18
 
19
+ - **Sprint 42 — A.II.1.b Calibration : exposition `token_confidences` +
20
+ câblage runner.** Suite directe du Sprint 39 (couche de calcul). Le
21
+ runner peut maintenant calculer ECE/MCE/reliability dès qu'un moteur
22
+ expose des confidences au niveau token.
23
+ - `EngineResult.token_confidences: Optional[list[dict[str, Any]]]`
24
+ ajouté. Format attendu : `[{"token": str, "confidence": float}, …]`,
25
+ confidence ∈ [0, 1] ou ∈ [0, 100] (normalisé par le runner).
26
+ `None` par défaut → comportement strictement rétrocompat pour tous
27
+ les adapters historiques (Tesseract, Pero, Mistral OCR, Google
28
+ Vision, Azure DI). L'adaptation de chaque adapter à exposer ses
29
+ confidences natives est reportée à des sprints dédiés (un par
30
+ adapter).
31
+ - `DocumentResult.calibration_metrics: Optional[dict]` ajouté
32
+ (sérialisé dans `as_dict` quand renseigné, libéré par `compact()`).
33
+ - `EngineReport.aggregated_calibration: Optional[dict]` ajouté.
34
+ - Helper `_calibration_from_engine_result(ground_truth, token_confidences)` :
35
+ aligne par bag-of-words avec multiplicité (proxy oracle, comme
36
+ `oracle_token_recall` du Sprint 35), normalise les confidences en
37
+ pourcentage à `[0, 1]`, ignore les confidences négatives
38
+ (Tesseract met -1 pour les non-mots), retourne `None` sur entrée
39
+ vide. Appelé dans `_compute_document_result` quand
40
+ `EngineResult.token_confidences` est non-vide.
41
+ - Helper `_aggregate_calibration(doc_results)` : combine les bins de
42
+ tous les docs en somme pondérée par count, recalcule ECE/MCE micro
43
+ sur l'ensemble. Renvoie `None` si aucun doc n'a de
44
+ `calibration_metrics`.
45
+ - +17 tests dans `test_sprint42_calibration_runner.py` couvrant le
46
+ nouveau champ EngineResult, la sérialisation et compact des
47
+ nouveaux champs DR/ER, l'helper d'alignement (calibration parfaite,
48
+ normalisation %, skip négatifs, bag-of-words avec multiplicité,
49
+ skip entrées invalides), l'agrégateur (combinaison de bins
50
+ multi-docs, recalcul ECE/MCE micro), et la rétrocompat
51
+ (pas de calcul sans token_confidences).
52
+
53
  - **Sprint 41 — A.II.1.a NER : vue HTML dédiée (clôture A.II.1.a).**
54
  Suite directe des Sprints 38-40. Le moteur narratif et le runner ont
55
  déjà tout ce qu'il faut ; ce sprint rend les chiffres visibles et
 
333
 
334
  ### Tests
335
 
336
+ - 1478 → 1752 tests (+17 Sprint 32, +23 Sprint 33, +21 Sprint 34,
337
  +27 Sprint 35, +22 Sprint 36, +42 Sprint 37, +19 Sprint 38,
338
+ +32 Sprint 39, +16 Sprint 40, +38 Sprint 41, +17 Sprint 42).
339
+ Aucune régression. **Phase 0 close ; Étape 2 du plan d'évolution :
340
+ inter-moteurs (A.II.1.c) et NER (A.II.1.a) livrés bout-en-bout
341
+ calcul runner → narratif → HTML ; calibration (A.II.1.b) couche
342
+ de calcul + câblage runner livrés (Sprints 39+42), il manque la vue
343
+ HTML reliability diagram et l'adaptation des engines pour exposer
344
+ leurs confidences natives.**
345
 
346
  ---
347
 
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
  | 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). |
211
  | 40 | **Sprint 9 du plan d'évolution 2026 — Étape 2 / axe A.II.1.a : NER backend + câblage runner**. Suite du Sprint 38 (couche de calcul). Nouveau module `picarones/core/ner_backends.py` : `EntityExtractor` (Protocol, tout callable `(text) → list[dict]` est valide), `SpacyEntityExtractor` (lazy-import spaCy, charge le modèle au premier appel, fallback gracieux silencieux + warning explicite si spaCy/modèle absent, mapping par défaut spaCy → conventions HIPE : PERSON→PER, GPE→LOC, etc.), `SPACY_PROFILES` (6 profils nommés), `get_extractor(profile)`, `is_spacy_available()`. `DocumentResult.ner_metrics: Optional[dict]` et `EngineReport.aggregated_ner` ajoutés (sérialisés dans `as_dict` quand renseignés, libérés par `compact()`). `runner.run_benchmark` accepte un nouveau paramètre optionnel `entity_extractor` ; si fourni, helpers `_attach_ner_metrics` et `_aggregate_ner` calculent les métriques en post-process (main process pour éviter de pickler spaCy dans les sous-processus). Rétrocompat stricte : sans `entity_extractor`, aucun calcul ni champ ajouté. Nouveau extra `[ner]` dans `pyproject.toml` (spacy>=3.7.0). +16 tests dans `test_sprint40_ner_runner.py` (fallback sans spaCy + warning, idempotence load, profils + factory, sérialisation nouveaux champs, câblage runner avec mock injecté, agrégation micro-F1, rétrocompat sans extracteur, robustesse à un extracteur qui lève). **Verrou levé** : un benchmark dont le corpus a une GT entités produit maintenant des métriques NER bout-en-bout — il manque uniquement la vue HTML dédiée (Sprint 41 à venir). |
212
  | 39 | **Sprint 8 du plan d'évolution 2026 — Étape 2 / axe A.II.1.b : Calibration (couche de calcul)**. Nouveau module `picarones/core/calibration.py` avec dataclass `CalibrationBin` (`bin_low/high`, `avg_confidence`, `accuracy`, `count`, propriété `gap`), `reliability_diagram`, `expected_calibration_error` (ECE — moyenne pondérée par bin de `\|conf - accuracy\|`, ∈ [0, 1]), `maximum_calibration_error` (MCE — pire écart sur les bins non vides), `compute_calibration_metrics` (vue agrégée). Calcul d'index de bin par multiplication `int(c * n_bins)` plutôt que division pour éviter le piège IEEE 754 (`0.6 / 0.1 = 5.999…`). Aucune dépendance externe — les listes `confidences` ∈ [0, 1] et `is_correct` ∈ {0,1} sont fournies en entrée ; l'extraction depuis les engines existants est reportée à un sprint dédié. +32 tests couvrant calibration parfaite (ECE = 0), cas extrêmes (sur/sous-confiance → ECE = 0,5), biais constant (ECE = `\|c-a\|`), binning correct (0.6 placé dans le bon bin), bins vides (`gap = None`), garde-fous, monotonie `n_bins` plus fins → ECE ne décroît pas. **Verrou levé** : un workflow patrimonial peut maintenant répondre à *« quand le moteur dit qu'il est sûr, est-il vraiment sûr ? »* — différence entre vérification humaine systématique (100 %) et ciblée (15 %) sur les passages à faible confiance. |
@@ -259,7 +260,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
259
  ## Contexte développement
260
 
261
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
262
- - **Tests** : 1735 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 calcul → runner → HTML ; Sprint 39 = calibration couche de calcul, vue HTML à venir)
263
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
264
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
265
  - **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
+ | 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. |
211
  | 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). |
212
  | 40 | **Sprint 9 du plan d'évolution 2026 — Étape 2 / axe A.II.1.a : NER backend + câblage runner**. Suite du Sprint 38 (couche de calcul). Nouveau module `picarones/core/ner_backends.py` : `EntityExtractor` (Protocol, tout callable `(text) → list[dict]` est valide), `SpacyEntityExtractor` (lazy-import spaCy, charge le modèle au premier appel, fallback gracieux silencieux + warning explicite si spaCy/modèle absent, mapping par défaut spaCy → conventions HIPE : PERSON→PER, GPE→LOC, etc.), `SPACY_PROFILES` (6 profils nommés), `get_extractor(profile)`, `is_spacy_available()`. `DocumentResult.ner_metrics: Optional[dict]` et `EngineReport.aggregated_ner` ajoutés (sérialisés dans `as_dict` quand renseignés, libérés par `compact()`). `runner.run_benchmark` accepte un nouveau paramètre optionnel `entity_extractor` ; si fourni, helpers `_attach_ner_metrics` et `_aggregate_ner` calculent les métriques en post-process (main process pour éviter de pickler spaCy dans les sous-processus). Rétrocompat stricte : sans `entity_extractor`, aucun calcul ni champ ajouté. Nouveau extra `[ner]` dans `pyproject.toml` (spacy>=3.7.0). +16 tests dans `test_sprint40_ner_runner.py` (fallback sans spaCy + warning, idempotence load, profils + factory, sérialisation nouveaux champs, câblage runner avec mock injecté, agrégation micro-F1, rétrocompat sans extracteur, robustesse à un extracteur qui lève). **Verrou levé** : un benchmark dont le corpus a une GT entités produit maintenant des métriques NER bout-en-bout — il manque uniquement la vue HTML dédiée (Sprint 41 à venir). |
213
  | 39 | **Sprint 8 du plan d'évolution 2026 — Étape 2 / axe A.II.1.b : Calibration (couche de calcul)**. Nouveau module `picarones/core/calibration.py` avec dataclass `CalibrationBin` (`bin_low/high`, `avg_confidence`, `accuracy`, `count`, propriété `gap`), `reliability_diagram`, `expected_calibration_error` (ECE — moyenne pondérée par bin de `\|conf - accuracy\|`, ∈ [0, 1]), `maximum_calibration_error` (MCE — pire écart sur les bins non vides), `compute_calibration_metrics` (vue agrégée). Calcul d'index de bin par multiplication `int(c * n_bins)` plutôt que division pour éviter le piège IEEE 754 (`0.6 / 0.1 = 5.999…`). Aucune dépendance externe — les listes `confidences` ∈ [0, 1] et `is_correct` ∈ {0,1} sont fournies en entrée ; l'extraction depuis les engines existants est reportée à un sprint dédié. +32 tests couvrant calibration parfaite (ECE = 0), cas extrêmes (sur/sous-confiance → ECE = 0,5), biais constant (ECE = `\|c-a\|`), binning correct (0.6 placé dans le bon bin), bins vides (`gap = None`), garde-fous, monotonie `n_bins` plus fins → ECE ne décroît pas. **Verrou levé** : un workflow patrimonial peut maintenant répondre à *« quand le moteur dit qu'il est sûr, est-il vraiment sûr ? »* — différence entre vérification humaine systématique (100 %) et ciblée (15 %) sur les passages à faible confiance. |
 
260
  ## Contexte développement
261
 
262
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
263
+ - **Tests** : 1752 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 calcul → runner → HTML ; Sprints 39+42 = calibration couche de calcul + câblage runner, vue HTML reliability + adaptation engines à venir)
264
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
265
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
266
  - **Transcript de la conversation de développement** :
picarones/core/results.py CHANGED
@@ -61,6 +61,15 @@ class DocumentResult:
61
  le document a un niveau de GT ``ENTITIES`` ET que le runner a reçu
62
  un ``EntityExtractor``.
63
  """
 
 
 
 
 
 
 
 
 
64
 
65
  def as_dict(self) -> dict:
66
  d = {
@@ -92,6 +101,8 @@ class DocumentResult:
92
  d["hallucination_metrics"] = self.hallucination_metrics
93
  if self.ner_metrics is not None:
94
  d["ner_metrics"] = self.ner_metrics
 
 
95
  return d
96
 
97
  def compact(self) -> None:
@@ -118,6 +129,7 @@ class DocumentResult:
118
  self.line_metrics = None
119
  self.hallucination_metrics = None
120
  self.ner_metrics = None
 
121
 
122
 
123
  @dataclass
@@ -155,6 +167,12 @@ class EngineReport:
155
  """Métriques NER agrégées sur le corpus : F1 micro/macro globaux et
156
  par catégorie, total hallucinations/missed. ``None`` si aucun
157
  document n'a porté de calcul NER."""
 
 
 
 
 
 
158
 
159
  def __post_init__(self) -> None:
160
  if not self.aggregated_metrics and self.document_results:
@@ -217,6 +235,8 @@ class EngineReport:
217
  d["aggregated_hallucination"] = self.aggregated_hallucination
218
  if self.aggregated_ner is not None:
219
  d["aggregated_ner"] = self.aggregated_ner
 
 
220
  return d
221
 
222
 
 
61
  le document a un niveau de GT ``ENTITIES`` ET que le runner a reçu
62
  un ``EntityExtractor``.
63
  """
64
+ # Sprint 42 — calibration des confidences moteur (ECE, MCE, bins)
65
+ calibration_metrics: Optional[dict] = None
66
+ """Métriques de calibration (Sprint 39+42).
67
+
68
+ Format : retour de ``compute_calibration_metrics`` (ece, mce,
69
+ n_bins, n_predictions, overall_accuracy, overall_confidence, bins).
70
+ Présent uniquement si le moteur a fourni des ``token_confidences``
71
+ sur l'``EngineResult``.
72
+ """
73
 
74
  def as_dict(self) -> dict:
75
  d = {
 
101
  d["hallucination_metrics"] = self.hallucination_metrics
102
  if self.ner_metrics is not None:
103
  d["ner_metrics"] = self.ner_metrics
104
+ if self.calibration_metrics is not None:
105
+ d["calibration_metrics"] = self.calibration_metrics
106
  return d
107
 
108
  def compact(self) -> None:
 
129
  self.line_metrics = None
130
  self.hallucination_metrics = None
131
  self.ner_metrics = None
132
+ self.calibration_metrics = None
133
 
134
 
135
  @dataclass
 
167
  """Métriques NER agrégées sur le corpus : F1 micro/macro globaux et
168
  par catégorie, total hallucinations/missed. ``None`` si aucun
169
  document n'a porté de calcul NER."""
170
+ # Sprint 42
171
+ aggregated_calibration: Optional[dict] = None
172
+ """Calibration agrégée sur le corpus : ECE, MCE, reliability diagram
173
+ micro recalculé à partir des sommes par bin. ``None`` si aucun
174
+ document n'avait de ``calibration_metrics`` (cas par défaut tant que
175
+ les engines n'exposent pas ``token_confidences``)."""
176
 
177
  def __post_init__(self) -> None:
178
  if not self.aggregated_metrics and self.document_results:
 
235
  d["aggregated_hallucination"] = self.aggregated_hallucination
236
  if self.aggregated_ner is not None:
237
  d["aggregated_ner"] = self.aggregated_ner
238
+ if self.aggregated_calibration is not None:
239
+ d["aggregated_calibration"] = self.aggregated_calibration
240
  return d
241
 
242
 
picarones/core/runner.py CHANGED
@@ -101,6 +101,67 @@ def _io_doc_worker(
101
  # Calcul documentaire centralisé
102
  # ---------------------------------------------------------------------------
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  def _compute_document_result(
105
  doc_id: str,
106
  image_path: str,
@@ -204,6 +265,18 @@ def _compute_document_result(
204
  except Exception as e:
205
  _logger.warning("[hallucination] fonctionnalité dégradée : %s", e)
206
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  try:
208
  from picarones.core.image_quality import analyze_image_quality
209
  iq = analyze_image_quality(image_path)
@@ -229,6 +302,7 @@ def _compute_document_result(
229
  image_quality=image_quality_data,
230
  line_metrics=line_metrics_data,
231
  hallucination_metrics=hallucination_data,
 
232
  )
233
 
234
 
@@ -636,6 +710,7 @@ def run_benchmark(
636
  agg_image_quality = _aggregate_image_quality(document_results)
637
  agg_line_metrics = _aggregate_line_metrics(document_results)
638
  agg_hallucination = _aggregate_hallucination(document_results)
 
639
 
640
  report = EngineReport(
641
  engine_name=engine.name,
@@ -650,6 +725,7 @@ def run_benchmark(
650
  aggregated_image_quality=agg_image_quality,
651
  aggregated_line_metrics=agg_line_metrics,
652
  aggregated_hallucination=agg_hallucination,
 
653
  )
654
  engine_reports.append(report)
655
  logger.info(
@@ -957,6 +1033,102 @@ def _attach_ner_metrics(
957
  logger.info("[ner] %d documents évalués pour NER.", n_done)
958
 
959
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
960
  def _aggregate_ner(doc_results: list) -> Optional[dict]:
961
  """Agrège les métriques NER au niveau du moteur.
962
 
 
101
  # Calcul documentaire centralisé
102
  # ---------------------------------------------------------------------------
103
 
104
+
105
+ def _calibration_from_engine_result(
106
+ ground_truth: str,
107
+ token_confidences: list,
108
+ ) -> Optional[dict]:
109
+ """Aligne les ``token_confidences`` du moteur sur la GT (bag-of-words)
110
+ pour produire les listes parallèles ``confidences`` / ``is_correct``,
111
+ puis appelle ``compute_calibration_metrics`` (Sprint 39).
112
+
113
+ Convention d'alignement (proxy bag-of-words avec multiplicité, comme
114
+ ``oracle_token_recall`` du Sprint 35) : un token de l'hypothèse est
115
+ "correct" si la GT contient encore une occurrence de ce token.
116
+ Ce n'est pas un alignement séquentiel ; c'est volontaire pour rester
117
+ simple et robuste aux décalages d'OCR.
118
+
119
+ Les confidences ``> 1.0`` sont supposées en pourcentage et
120
+ normalisées à ``[0, 1]``. Les confidences négatives (Tesseract met
121
+ -1 pour les non-mots) sont ignorées.
122
+ """
123
+ from collections import Counter
124
+
125
+ from picarones.core.calibration import compute_calibration_metrics
126
+
127
+ if not token_confidences:
128
+ return None
129
+
130
+ gt_counter = Counter((ground_truth or "").split())
131
+ confidences: list[float] = []
132
+ is_correct: list[int] = []
133
+
134
+ for tc in token_confidences:
135
+ if not isinstance(tc, dict):
136
+ continue
137
+ token = str(tc.get("token", ""))
138
+ if not token:
139
+ continue
140
+ try:
141
+ conf = float(tc.get("confidence"))
142
+ except (TypeError, ValueError):
143
+ continue
144
+ if conf < 0:
145
+ # -1 = non-mot dans le format Tesseract image_to_data
146
+ continue
147
+ if conf > 1.0:
148
+ conf = conf / 100.0
149
+ if not 0.0 <= conf <= 1.0:
150
+ continue
151
+ if gt_counter[token] > 0:
152
+ is_correct.append(1)
153
+ gt_counter[token] -= 1
154
+ else:
155
+ is_correct.append(0)
156
+ confidences.append(conf)
157
+
158
+ if not confidences:
159
+ return None
160
+ return compute_calibration_metrics(confidences, is_correct)
161
+
162
+
163
+
164
+
165
  def _compute_document_result(
166
  doc_id: str,
167
  image_path: str,
 
265
  except Exception as e:
266
  _logger.warning("[hallucination] fonctionnalité dégradée : %s", e)
267
 
268
+ # Sprint 42 — calibration des confidences moteur (en dehors du
269
+ # ``if ocr_result.success`` puisqu'on peut avoir des confidences même
270
+ # sur un succès partiel).
271
+ calibration_data: Optional[dict] = None
272
+ if ocr_result.token_confidences:
273
+ try:
274
+ calibration_data = _calibration_from_engine_result(
275
+ ground_truth, ocr_result.token_confidences,
276
+ )
277
+ except Exception as e:
278
+ _logger.warning("[calibration] fonctionnalité dégradée : %s", e)
279
+
280
  try:
281
  from picarones.core.image_quality import analyze_image_quality
282
  iq = analyze_image_quality(image_path)
 
302
  image_quality=image_quality_data,
303
  line_metrics=line_metrics_data,
304
  hallucination_metrics=hallucination_data,
305
+ calibration_metrics=calibration_data,
306
  )
307
 
308
 
 
710
  agg_image_quality = _aggregate_image_quality(document_results)
711
  agg_line_metrics = _aggregate_line_metrics(document_results)
712
  agg_hallucination = _aggregate_hallucination(document_results)
713
+ agg_calibration = _aggregate_calibration(document_results)
714
 
715
  report = EngineReport(
716
  engine_name=engine.name,
 
725
  aggregated_image_quality=agg_image_quality,
726
  aggregated_line_metrics=agg_line_metrics,
727
  aggregated_hallucination=agg_hallucination,
728
+ aggregated_calibration=agg_calibration,
729
  )
730
  engine_reports.append(report)
731
  logger.info(
 
1033
  logger.info("[ner] %d documents évalués pour NER.", n_done)
1034
 
1035
 
1036
+ def _aggregate_calibration(doc_results: list) -> Optional[dict]:
1037
+ """Agrège la calibration micro sur tous les docs.
1038
+
1039
+ Recalcule ECE/MCE à partir de la **somme des bins** de chaque
1040
+ document : pour chaque bin, on additionne ``count``, on agrège la
1041
+ confiance moyenne pondérée par count, et on agrège l'accuracy
1042
+ pondérée par count. L'ECE micro est ensuite la moyenne pondérée
1043
+ par bin de ``|conf - acc|``.
1044
+ """
1045
+ relevant = [
1046
+ dr for dr in doc_results
1047
+ if dr.calibration_metrics is not None
1048
+ and (dr.calibration_metrics.get("bins") or [])
1049
+ ]
1050
+ if not relevant:
1051
+ return None
1052
+
1053
+ # Aligne tous les docs sur le même nombre de bins (par sécurité, on
1054
+ # vérifie qu'ils sont cohérents — sinon on prend le 1er en
1055
+ # référence et on saute les incohérents avec un warning).
1056
+ n_bins = relevant[0].calibration_metrics.get("n_bins", 10)
1057
+ sum_conf: list[float] = [0.0] * n_bins
1058
+ sum_acc: list[float] = [0.0] * n_bins
1059
+ counts: list[int] = [0] * n_bins
1060
+ bin_lows: list[float] = [
1061
+ b["bin_low"] for b in relevant[0].calibration_metrics["bins"]
1062
+ ]
1063
+ bin_highs: list[float] = [
1064
+ b["bin_high"] for b in relevant[0].calibration_metrics["bins"]
1065
+ ]
1066
+
1067
+ for dr in relevant:
1068
+ m = dr.calibration_metrics
1069
+ if m.get("n_bins") != n_bins:
1070
+ logger.warning(
1071
+ "[aggregate_calibration] %s : n_bins=%s ≠ %s — ignoré",
1072
+ dr.doc_id, m.get("n_bins"), n_bins,
1073
+ )
1074
+ continue
1075
+ for k, b in enumerate(m["bins"]):
1076
+ n = int(b.get("count") or 0)
1077
+ if n == 0:
1078
+ continue
1079
+ counts[k] += n
1080
+ sum_conf[k] += float(b.get("avg_confidence") or 0.0) * n
1081
+ sum_acc[k] += float(b.get("accuracy") or 0.0) * n
1082
+
1083
+ total = sum(counts)
1084
+ if total == 0:
1085
+ return None
1086
+
1087
+ bins: list[dict] = []
1088
+ ece = 0.0
1089
+ mce = 0.0
1090
+ for k in range(n_bins):
1091
+ n = counts[k]
1092
+ if n == 0:
1093
+ bins.append({
1094
+ "bin_low": bin_lows[k] if k < len(bin_lows) else k / n_bins,
1095
+ "bin_high": bin_highs[k] if k < len(bin_highs) else (k + 1) / n_bins,
1096
+ "avg_confidence": None,
1097
+ "accuracy": None,
1098
+ "count": 0,
1099
+ "gap": None,
1100
+ })
1101
+ continue
1102
+ avg_conf = sum_conf[k] / n
1103
+ accuracy = sum_acc[k] / n
1104
+ gap = abs(avg_conf - accuracy)
1105
+ bins.append({
1106
+ "bin_low": bin_lows[k] if k < len(bin_lows) else k / n_bins,
1107
+ "bin_high": bin_highs[k] if k < len(bin_highs) else (k + 1) / n_bins,
1108
+ "avg_confidence": avg_conf,
1109
+ "accuracy": accuracy,
1110
+ "count": n,
1111
+ "gap": gap,
1112
+ })
1113
+ ece += (n / total) * gap
1114
+ if gap > mce:
1115
+ mce = gap
1116
+
1117
+ overall_acc = sum(sum_acc) / total
1118
+ overall_conf = sum(sum_conf) / total
1119
+
1120
+ return {
1121
+ "ece": ece,
1122
+ "mce": mce,
1123
+ "n_bins": n_bins,
1124
+ "n_predictions": total,
1125
+ "overall_accuracy": overall_acc,
1126
+ "overall_confidence": overall_conf,
1127
+ "bins": bins,
1128
+ "doc_count": len(relevant),
1129
+ }
1130
+
1131
+
1132
  def _aggregate_ner(doc_results: list) -> Optional[dict]:
1133
  """Agrège les métriques NER au niveau du moteur.
1134
 
picarones/engines/base.py CHANGED
@@ -22,6 +22,13 @@ class EngineResult:
22
  duration_seconds: float
23
  error: Optional[str] = None
24
  metadata: dict = field(default_factory=dict)
 
 
 
 
 
 
 
25
 
26
  @property
27
  def success(self) -> bool:
 
22
  duration_seconds: float
23
  error: Optional[str] = None
24
  metadata: dict = field(default_factory=dict)
25
+ # Sprint 42 — confidences au niveau token (optionnel).
26
+ # Format attendu : liste de dicts ``{"token": str, "confidence": float}``
27
+ # avec ``confidence`` ∈ [0, 1] (ou ∈ [0, 100], normalisé par le runner).
28
+ # ``None`` si le moteur ne fournit pas ce signal — comportement par
29
+ # défaut pour tous les adapters historiques. Quand renseigné,
30
+ # le runner alimente ``DocumentResult.calibration_metrics``.
31
+ token_confidences: Optional[list[dict[str, Any]]] = None
32
 
33
  @property
34
  def success(self) -> bool:
tests/test_sprint42_calibration_runner.py ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests Sprint 42 — exposition des token_confidences + câblage runner.
2
+
3
+ Le runner peut maintenant calculer des métriques de calibration
4
+ (ECE / MCE / reliability) dès qu'un moteur expose des
5
+ ``token_confidences`` sur l'``EngineResult``.
6
+
7
+ Couvre :
8
+
9
+ 1. ``EngineResult.token_confidences`` accepte ``None`` (rétrocompat
10
+ stricte) ou une liste de dicts.
11
+ 2. ``DocumentResult.calibration_metrics`` est sérialisé via ``as_dict``
12
+ uniquement quand renseigné, libéré par ``compact()``.
13
+ 3. ``EngineReport.aggregated_calibration`` apparaît dans ``as_dict``
14
+ quand renseigné.
15
+ 4. ``_calibration_from_engine_result`` :
16
+ - Aligne en bag-of-words avec multiplicité (proxy oracle)
17
+ - Normalise les confidences en pourcentage (>1) à [0, 1]
18
+ - Ignore les confidences négatives (Tesseract -1 pour non-mots)
19
+ - Retourne ``None`` sur entrée vide / ``None``
20
+ 5. ``_aggregate_calibration`` :
21
+ - Combine les bins de plusieurs documents en somme pondérée
22
+ - Recalcule ECE/MCE micro à partir des sommes
23
+ - Retourne ``None`` si aucun doc n'a de calibration
24
+ 6. Rétrocompat : sans token_confidences sur l'EngineResult, aucun
25
+ calcul calibration ; ``aggregated_calibration = None``.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import pytest
31
+
32
+ from picarones.core.runner import (
33
+ _aggregate_calibration,
34
+ _calibration_from_engine_result,
35
+ )
36
+ from picarones.core.results import DocumentResult, EngineReport
37
+ from picarones.engines.base import EngineResult
38
+
39
+
40
+ # ──────────────────────────────────────────────────────────────────────────
41
+ # 1. EngineResult.token_confidences
42
+ # ──────────────────────────────────────────────────────────────────────────
43
+
44
+
45
+ class TestEngineResultExtension:
46
+ def test_default_is_none(self) -> None:
47
+ r = EngineResult("e", "/tmp/x.png", "hello", 1.0)
48
+ assert r.token_confidences is None
49
+
50
+ def test_accepts_list_of_dicts(self) -> None:
51
+ confs = [{"token": "hello", "confidence": 0.95}]
52
+ r = EngineResult("e", "/tmp/x.png", "hello", 1.0, token_confidences=confs)
53
+ assert r.token_confidences == confs
54
+
55
+
56
+ # ──────────────────────────────────────────────────────────────────────────
57
+ # 2-3. Modèles : sérialisation et compact
58
+ # ──────────────────────────────────────────────────────────────────────────
59
+
60
+
61
+ def _make_dr(calibration_metrics: dict | None = None) -> DocumentResult:
62
+ from picarones.core.metrics import MetricsResult
63
+
64
+ return DocumentResult(
65
+ doc_id="d1", image_path="/tmp/x.png",
66
+ ground_truth="a b c", hypothesis="a b c",
67
+ metrics=MetricsResult(
68
+ cer=0.0, cer_nfc=0.0, cer_caseless=0.0,
69
+ wer=0.0, wer_normalized=0.0, mer=0.0, wil=0.0,
70
+ reference_length=5, hypothesis_length=5,
71
+ ),
72
+ duration_seconds=0.1,
73
+ calibration_metrics=calibration_metrics,
74
+ )
75
+
76
+
77
+ class TestModelsSerialization:
78
+ def test_calibration_metrics_omitted_when_none(self) -> None:
79
+ d = _make_dr(None).as_dict()
80
+ assert "calibration_metrics" not in d
81
+
82
+ def test_calibration_metrics_present_when_set(self) -> None:
83
+ d = _make_dr({"ece": 0.05, "mce": 0.1}).as_dict()
84
+ assert d["calibration_metrics"] == {"ece": 0.05, "mce": 0.1}
85
+
86
+ def test_compact_clears_calibration(self) -> None:
87
+ dr = _make_dr({"ece": 0.05})
88
+ dr.compact()
89
+ assert dr.calibration_metrics is None
90
+
91
+ def test_engine_report_aggregated_calibration_omitted_when_none(self) -> None:
92
+ rep = EngineReport(
93
+ engine_name="t", engine_version="1", engine_config={},
94
+ document_results=[_make_dr()],
95
+ )
96
+ assert "aggregated_calibration" not in rep.as_dict()
97
+
98
+ def test_engine_report_aggregated_calibration_included_when_set(self) -> None:
99
+ rep = EngineReport(
100
+ engine_name="t", engine_version="1", engine_config={},
101
+ document_results=[_make_dr()],
102
+ aggregated_calibration={"ece": 0.05, "n_predictions": 100},
103
+ )
104
+ assert rep.as_dict()["aggregated_calibration"] == {
105
+ "ece": 0.05, "n_predictions": 100,
106
+ }
107
+
108
+
109
+ # ──────────────────────────────────────────────────────────────────────────
110
+ # 4. Helper d'alignement
111
+ # ───────────────────────────���──────────────────────────────────────────────
112
+
113
+
114
+ class TestCalibrationFromEngineResult:
115
+ def test_returns_none_for_empty_inputs(self) -> None:
116
+ assert _calibration_from_engine_result("text", None) is None
117
+ assert _calibration_from_engine_result("text", []) is None
118
+
119
+ def test_perfect_calibration_when_conf_matches_accuracy(self) -> None:
120
+ gt = "a b c d e f g h i j"
121
+ # 7 tokens dans la GT à conf=0.7, 3 hors de la GT à conf=0.7 → ECE = 0
122
+ tcs = (
123
+ [{"token": c, "confidence": 0.7} for c in "abcdefg"]
124
+ + [{"token": c, "confidence": 0.7} for c in ["X", "Y", "Z"]]
125
+ )
126
+ m = _calibration_from_engine_result(gt, tcs)
127
+ assert m is not None
128
+ assert m["ece"] == pytest.approx(0.0, abs=1e-9)
129
+ assert m["overall_accuracy"] == pytest.approx(0.7)
130
+ assert m["n_predictions"] == 10
131
+
132
+ def test_normalizes_percentage_confidences(self) -> None:
133
+ """Conf > 1 est interprétée en pourcentage et divisée par 100."""
134
+ m = _calibration_from_engine_result(
135
+ "hello", [{"token": "hello", "confidence": 95.0}],
136
+ )
137
+ assert m is not None
138
+ # 95/100 = 0.95
139
+ assert m["overall_confidence"] == 0.95
140
+
141
+ def test_skips_negative_confidences(self) -> None:
142
+ """Tesseract met -1 pour les non-mots ; on les ignore."""
143
+ m = _calibration_from_engine_result(
144
+ "hello", [
145
+ {"token": "hello", "confidence": 0.9},
146
+ {"token": ".", "confidence": -1.0},
147
+ ],
148
+ )
149
+ assert m is not None
150
+ assert m["n_predictions"] == 1
151
+
152
+ def test_bag_of_words_with_multiplicity(self) -> None:
153
+ # GT contient deux 'le'. L'hypothèse en a trois → 2 corrects, 1 incorrect.
154
+ gt = "le chat le chien"
155
+ tcs = [
156
+ {"token": "le", "confidence": 0.9},
157
+ {"token": "le", "confidence": 0.9},
158
+ {"token": "le", "confidence": 0.9}, # 3e 'le' : pas dans la GT
159
+ {"token": "chat", "confidence": 0.9},
160
+ {"token": "chien", "confidence": 0.9},
161
+ ]
162
+ m = _calibration_from_engine_result(gt, tcs)
163
+ # 4 corrects sur 5
164
+ assert m["overall_accuracy"] == 0.8
165
+ assert m["n_predictions"] == 5
166
+
167
+ def test_skips_invalid_entries(self) -> None:
168
+ m = _calibration_from_engine_result(
169
+ "hello", [
170
+ "not a dict",
171
+ {"no_token": True, "confidence": 0.5},
172
+ {"token": "hello"}, # pas de confidence
173
+ {"token": "hello", "confidence": "abc"}, # conf non numérique
174
+ {"token": "hello", "confidence": 0.9}, # valide
175
+ ],
176
+ )
177
+ assert m is not None
178
+ assert m["n_predictions"] == 1
179
+
180
+
181
+ # ──────────────────────────────────────────────────────────────────────────
182
+ # 5. Agrégateur
183
+ # ──────────────────────────────────────────────────────────────────────────
184
+
185
+
186
+ class TestAggregateCalibration:
187
+ def test_returns_none_when_no_doc_has_calibration(self) -> None:
188
+ drs = [_make_dr(None), _make_dr(None)]
189
+ assert _aggregate_calibration(drs) is None
190
+
191
+ def test_combines_bins_across_docs(self) -> None:
192
+ # Doc 1 : bin [0.5, 0.6) avec 10 prédictions, conf=0.55, acc=0.5
193
+ # Doc 2 : bin [0.5, 0.6) avec 20 prédictions, conf=0.55, acc=0.7
194
+ # Agrégat attendu : 30 prédictions dans ce bin, conf moy = 0.55,
195
+ # acc moy pondérée = (10*0.5 + 20*0.7) / 30 = 19/30 ≈ 0.633
196
+ empty_bin = lambda lo, hi: { # noqa: E731
197
+ "bin_low": lo, "bin_high": hi,
198
+ "avg_confidence": None, "accuracy": None,
199
+ "count": 0, "gap": None,
200
+ }
201
+ bins1 = [empty_bin(k / 10, (k + 1) / 10) for k in range(10)]
202
+ bins1[5] = {
203
+ "bin_low": 0.5, "bin_high": 0.6,
204
+ "avg_confidence": 0.55, "accuracy": 0.5,
205
+ "count": 10, "gap": 0.05,
206
+ }
207
+ m1 = {
208
+ "ece": 0.05, "mce": 0.05, "n_bins": 10, "n_predictions": 10,
209
+ "overall_accuracy": 0.5, "overall_confidence": 0.55, "bins": bins1,
210
+ }
211
+ bins2 = [empty_bin(k / 10, (k + 1) / 10) for k in range(10)]
212
+ bins2[5] = {
213
+ "bin_low": 0.5, "bin_high": 0.6,
214
+ "avg_confidence": 0.55, "accuracy": 0.7,
215
+ "count": 20, "gap": 0.15,
216
+ }
217
+ m2 = {
218
+ "ece": 0.15, "mce": 0.15, "n_bins": 10, "n_predictions": 20,
219
+ "overall_accuracy": 0.7, "overall_confidence": 0.55, "bins": bins2,
220
+ }
221
+ drs = [_make_dr(m1), _make_dr(m2)]
222
+ agg = _aggregate_calibration(drs)
223
+ assert agg is not None
224
+ assert agg["n_predictions"] == 30
225
+ assert agg["doc_count"] == 2
226
+ # Accuracy combinée = (10*0.5 + 20*0.7) / 30
227
+ assert agg["overall_accuracy"] == (10 * 0.5 + 20 * 0.7) / 30
228
+ # Confidence combinée = 0.55 (constante)
229
+ assert abs(agg["overall_confidence"] - 0.55) < 1e-9
230
+ # ECE micro : seul bin non vide (bin 5), avec count=30,
231
+ # avg_conf=0.55, accuracy=19/30 ≈ 0.633, gap = |0.55 - 0.633|
232
+ expected_ece = abs(0.55 - 19 / 30)
233
+ assert abs(agg["ece"] - expected_ece) < 1e-9
234
+ assert agg["mce"] == agg["ece"] # un seul bin non vide → MCE = ECE
235
+
236
+
237
+ # ──────────────────────────────────────────────────────────────────────────
238
+ # 6. Rétrocompat : sans token_confidences, rien ne change
239
+ # ──────────────────────────────────────────────────────────────────────────
240
+
241
+
242
+ class TestBackwardCompat:
243
+ def test_engine_result_default_no_calibration(self) -> None:
244
+ # Un EngineResult sans token_confidences → calibration_metrics
245
+ # ne doit pas être calculée.
246
+ from picarones.core.runner import _compute_document_result
247
+ ocr = EngineResult(
248
+ engine_name="e",
249
+ image_path="/tmp/x.png",
250
+ text="a b c",
251
+ duration_seconds=0.1,
252
+ token_confidences=None,
253
+ )
254
+ dr = _compute_document_result(
255
+ doc_id="d1", image_path="/tmp/x.png",
256
+ ground_truth="a b c",
257
+ ocr_result=ocr,
258
+ char_exclude=None,
259
+ )
260
+ assert dr.calibration_metrics is None
261
+
262
+ def test_engine_result_with_confs_triggers_calibration(self) -> None:
263
+ from picarones.core.runner import _compute_document_result
264
+ ocr = EngineResult(
265
+ engine_name="e",
266
+ image_path="/tmp/x.png",
267
+ text="a b c",
268
+ duration_seconds=0.1,
269
+ token_confidences=[
270
+ {"token": "a", "confidence": 0.9},
271
+ {"token": "b", "confidence": 0.9},
272
+ {"token": "c", "confidence": 0.9},
273
+ ],
274
+ )
275
+ dr = _compute_document_result(
276
+ doc_id="d1", image_path="/tmp/x.png",
277
+ ground_truth="a b c",
278
+ ocr_result=ocr,
279
+ char_exclude=None,
280
+ )
281
+ assert dr.calibration_metrics is not None
282
+ # 3 tokens, tous corrects, conf 0.9 → accuracy = 1, conf = 0.9
283
+ assert dr.calibration_metrics["overall_accuracy"] == 1.0
284
+ assert dr.calibration_metrics["overall_confidence"] == 0.9