Claude commited on
Commit
68a1ab1
·
unverified ·
1 Parent(s): 9b01b52

sprint35: métriques inter-moteurs (couche de calcul)

Browse files

Premier sprint de l'Étape 2 du plan d'évolution 2026 (axe A —
enrichissement métrique). Pose les fonctions pures qui répondent à deux
questions complémentaires que le rapport ne sait pas répondre
aujourd'hui :

(a) à quel point les moteurs font-ils des erreurs de natures
différentes ? → divergence taxonomique
(b) quel CER serait atteignable si on combinait les moteurs ?
→ complémentarité (oracle token recall)

Nouveau module picarones/core/inter_engine.py :
- Divergence : kl_divergence, jensen_shannon_divergence (symétrique,
bornée [0, 1]), taxonomy_divergence_matrix (triangulaire, JS ou KL).
Lissage epsilon des zéros pour éviter log(0).
- Complémentarité : oracle_token_recall (proxy bag-of-words,
documenté comme borne supérieure optimiste — la vraie borne
séquentielle reste à faire), complementarity_gap qui retourne aussi
best_single_recall, best_engine, absolute_gap, relative_gap (fraction
des erreurs du meilleur moteur récupérable par ensemble),
pairwise_disagreement_rate.

Fonctions pures, sans I/O ni intégration runner. Le câblage narratif
(détecteur ENSEMBLE_OPPORTUNITY) et la matrice de divergence dans le
rapport HTML suivent au Sprint 36 — ce sprint livre la couche de calcul
indépendamment, prête à être consommée.

Tests : +27 dans test_sprint35_inter_engine.py 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 ressortent comme candidats à un ensemble, complémentarité
parfaite atteint oracle = 1), et les garde-fous (référence vide,
hypothèses vides, métrique inconnue).
Suite complète : 1539 → 1566 passed, 2 skipped, 0 failed.

CHANGELOG.md CHANGED
@@ -16,6 +16,31 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
16
 
17
  ### Ajouté
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  - **Sprint 34 — Phase 0.3 : registre typé de métriques (clôture Phase 0).**
20
  Nouveaux modules `picarones/core/metric_registry.py` et
21
  `picarones/core/builtin_metrics.py` :
@@ -83,8 +108,9 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
83
 
84
  ### Tests
85
 
86
- - 1478 → 1539 tests (+17 Sprint 32, +23 Sprint 33, +21 Sprint 34). Aucune
87
- régression sur la suite existante. **Phase 0 du plan d'évolution close.**
 
88
 
89
  ---
90
 
 
16
 
17
  ### Ajouté
18
 
19
+ - **Sprint 35 — Étape 2 du plan d'évolution : métriques inter-moteurs
20
+ (couche de calcul).** Nouveau module `picarones/core/inter_engine.py`
21
+ qui expose deux familles de mesures qui ne dépendent que des données
22
+ déjà produites par le runner :
23
+ - **Divergence taxonomique** : `kl_divergence`,
24
+ `jensen_shannon_divergence` (symétrique, bornée dans `[0, 1]`),
25
+ `taxonomy_divergence_matrix` qui construit la matrice triangulaire
26
+ inter-moteurs sur les distributions de classes d'erreur (issues de
27
+ `taxonomy.py`). Lissage epsilon des zéros pour éviter `log(0)`.
28
+ - **Complémentarité** : `oracle_token_recall` (borne supérieure
29
+ bag-of-words du recall atteignable par voting), `complementarity_gap`
30
+ qui retourne aussi `best_single_recall` / `absolute_gap` /
31
+ `relative_gap` / `best_engine`, `pairwise_disagreement_rate` pour
32
+ quantifier le potentiel d'ensemble entre deux moteurs spécifiques.
33
+ - Fonctions pures, sans I/O ni intégration runner — la couche de calcul
34
+ est livrable indépendamment ; le câblage au moteur narratif
35
+ (`ENSEMBLE_OPPORTUNITY`) et au rapport HTML (matrice de divergence,
36
+ badge oracle gap) suit au Sprint 36.
37
+ - +27 tests dans `tests/test_sprint35_inter_engine.py` couvrant les
38
+ invariants mathématiques (KL ≥ 0, KL(p,p) = 0, JS symétrique et
39
+ bornée, oracle ≥ best_single), les cas concrets (moteurs
40
+ spécialisés ressortent comme candidats à un ensemble, complémentarité
41
+ parfaite atteint oracle = 1), les garde-fous (référence vide,
42
+ hypothèses vides, métrique inconnue).
43
+
44
  - **Sprint 34 — Phase 0.3 : registre typé de métriques (clôture Phase 0).**
45
  Nouveaux modules `picarones/core/metric_registry.py` et
46
  `picarones/core/builtin_metrics.py` :
 
108
 
109
  ### Tests
110
 
111
+ - 1478 → 1566 tests (+17 Sprint 32, +23 Sprint 33, +21 Sprint 34,
112
+ +27 Sprint 35). Aucune régression. **Phase 0 close ; Étape 2 démarrée
113
+ (couche de calcul des métriques inter-moteurs).**
114
 
115
  ---
116
 
CLAUDE.md CHANGED
@@ -206,6 +206,7 @@ AZURE_DOC_INTEL_KEY=...
206
  | 32 | **Sprint 1 du plan d'évolution 2026 — Phase 0.1 : GT multi-niveaux**. Refonte de `picarones/core/corpus.py` pour porter une vérité terrain à plusieurs niveaux (`GTLevel.{TEXT,ALTO,PAGE,ENTITIES,READING_ORDER}`), payloads typés (`TextGT`, `AltoGT`, `PageGT`, `EntitiesGT`, `ReadingOrderGT`) avec `source_path` traçable. Le champ `Document.ground_truth: str` reste la source de vérité historique et est synchronisé automatiquement avec `Document.ground_truths[GTLevel.TEXT]` — rétrocompatibilité stricte (1478 tests existants passent sans modification). Le loader détecte automatiquement `.gt.alto.xml`, `.gt.page.xml`, `.gt.entities.json`, `.gt.reading_order.json` à côté de l'image. `Corpus.gt_level_coverage()` et `Corpus.available_gt_levels` exposent la couverture. Erreurs de parse dégradées en `logger.warning` (jamais `except: pass`). +17 tests dans `test_sprint32_multi_level_gt.py`. **Verrou levé** : ce sprint débloque l'évaluation des modules qui produisent ou consomment ALTO/PAGE/entités (axe B du plan, à venir Sprint 35+) et plusieurs métriques de l'axe A (Layout F1, reading order F1, NER). |
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
 
210
  ---
211
 
@@ -252,7 +253,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
252
  ## Contexte développement
253
 
254
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
255
- - **Tests** : 1539 passed, 2 skipped (Sprints 32-34 Phase 0 du plan d'évolution 2026 close)
256
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
257
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
258
  - **Transcript de la conversation de développement** :
 
206
  | 32 | **Sprint 1 du plan d'évolution 2026 — Phase 0.1 : GT multi-niveaux**. Refonte de `picarones/core/corpus.py` pour porter une vérité terrain à plusieurs niveaux (`GTLevel.{TEXT,ALTO,PAGE,ENTITIES,READING_ORDER}`), payloads typés (`TextGT`, `AltoGT`, `PageGT`, `EntitiesGT`, `ReadingOrderGT`) avec `source_path` traçable. Le champ `Document.ground_truth: str` reste la source de vérité historique et est synchronisé automatiquement avec `Document.ground_truths[GTLevel.TEXT]` — rétrocompatibilité stricte (1478 tests existants passent sans modification). Le loader détecte automatiquement `.gt.alto.xml`, `.gt.page.xml`, `.gt.entities.json`, `.gt.reading_order.json` à côté de l'image. `Corpus.gt_level_coverage()` et `Corpus.available_gt_levels` exposent la couverture. Erreurs de parse dégradées en `logger.warning` (jamais `except: pass`). +17 tests dans `test_sprint32_multi_level_gt.py`. **Verrou levé** : ce sprint débloque l'évaluation des modules qui produisent ou consomment ALTO/PAGE/entités (axe B du plan, à venir Sprint 35+) et plusieurs métriques de l'axe A (Layout F1, reading order F1, NER). |
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
 
211
  ---
212
 
 
253
  ## Contexte développement
254
 
255
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
256
+ - **Tests** : 1566 passed, 2 skipped (Sprints 32-34 = Phase 0 close ; Sprint 35 = couche de calcul inter-moteurs)
257
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
258
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
259
  - **Transcript de la conversation de développement** :
picarones/core/inter_engine.py ADDED
@@ -0,0 +1,316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Métriques inter-moteurs (Sprint 35 — Étape 2 du plan d'évolution).
2
+
3
+ Deux familles de mesures qui répondent à des questions différentes mais
4
+ liées :
5
+
6
+ 1. **Divergence taxonomique** (`kl_divergence`, `jensen_shannon_divergence`,
7
+ `taxonomy_divergence_matrix`) — *à quel point les moteurs font-ils des
8
+ erreurs de natures différentes ?* Une divergence élevée signale des
9
+ moteurs spécialisés sur des classes d'erreurs distinctes (visual vs
10
+ abréviation vs casse) et donc des candidats pour un voting ensemble.
11
+
12
+ 2. **Complémentarité** (`oracle_token_recall`, `complementarity_gap`,
13
+ `pairwise_disagreement_rate`) — *quel CER serait atteignable si on
14
+ combinait les moteurs ?* La borne inférieure du CER atteignable par
15
+ un voting majoritaire token-level est ``1 - oracle_token_recall``.
16
+ Si elle est très inférieure au CER du meilleur moteur seul, l'effort
17
+ d'un pipeline d'ensemble se justifie. Sinon non.
18
+
19
+ Convention de typage
20
+ --------------------
21
+ Toutes les fonctions sont enregistrables dans le registre Sprint 34 si
22
+ on les wrappe par un adaptateur ``(input_types=(TEXT, TEXT))``. Pour
23
+ limiter le bruit, on ne les enregistre **pas** automatiquement : ce sont
24
+ des métriques d'agrégation (multi-moteurs ou multi-documents) qui ne
25
+ correspondent pas au modèle « une jonction = une métrique » du runner.
26
+ Elles sont consommées par les détecteurs narratifs et le rapport HTML.
27
+
28
+ Note sur l'oracle
29
+ -----------------
30
+ La métrique ``oracle_token_recall`` retournée ici utilise un alignement
31
+ bag-of-words pondéré par multiplicité. Ce n'est **pas** une vraie
32
+ borne atteignable par voting majoritaire séquentiel — c'est une borne
33
+ supérieure (proxy optimiste). La vraie borne demanderait un
34
+ alignement séquentiel des hypothèses, ce qui est plus coûteux. Pour
35
+ le diagnostic « ensemble vaut-il le coup ? », le proxy suffit
36
+ largement ; on documente clairement la limite dans le glossaire et le
37
+ rapport.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import logging
43
+ import math
44
+ from collections import Counter
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+
49
+ # ──────────────────────────────────────────────────────────────────────────
50
+ # Divergence taxonomique (KL / Jensen-Shannon)
51
+ # ──────────────────────────────────────────────────────────────────────────
52
+
53
+
54
+ def _smoothed_distribution(
55
+ distribution: dict[str, float],
56
+ keys: list[str],
57
+ epsilon: float = 1e-12,
58
+ ) -> list[float]:
59
+ """Aligne une distribution sur l'ordre de ``keys`` et lisse les zéros.
60
+
61
+ Le lissage évite ``log(0)`` dans la KL. ``epsilon`` est volontairement
62
+ minuscule pour ne pas modifier le résultat de manière sensible.
63
+ """
64
+ smoothed = [max(distribution.get(k, 0.0), epsilon) for k in keys]
65
+ total = sum(smoothed)
66
+ return [v / total for v in smoothed]
67
+
68
+
69
+ def kl_divergence(p: dict[str, float], q: dict[str, float]) -> float:
70
+ """KL-divergence ``D(P||Q)`` en bits, sur l'union des clés.
71
+
72
+ Les distributions n'ont pas besoin de partager exactement les mêmes
73
+ clés ; les clés manquantes sont lissées à ``epsilon`` puis
74
+ renormalisées.
75
+
76
+ Returns
77
+ -------
78
+ float
79
+ ``D(P||Q) ≥ 0``. Vaut 0 si et seulement si P == Q. N'est pas
80
+ symétrique : ``kl(p, q) != kl(q, p)`` en général.
81
+ """
82
+ keys = sorted(set(p.keys()) | set(q.keys()))
83
+ if not keys:
84
+ return 0.0
85
+ p_vec = _smoothed_distribution(p, keys)
86
+ q_vec = _smoothed_distribution(q, keys)
87
+ return sum(pi * math.log2(pi / qi) for pi, qi in zip(p_vec, q_vec))
88
+
89
+
90
+ def jensen_shannon_divergence(
91
+ p: dict[str, float],
92
+ q: dict[str, float],
93
+ ) -> float:
94
+ """JS-divergence symétrique en bits, bornée dans ``[0, 1]``.
95
+
96
+ ``JS(P, Q) = ½ D(P||M) + ½ D(Q||M)`` avec ``M = (P + Q) / 2``.
97
+ Symétrique et bornée — préférable à la KL pour construire une
98
+ matrice triangulaire de divergences entre moteurs.
99
+ """
100
+ keys = sorted(set(p.keys()) | set(q.keys()))
101
+ if not keys:
102
+ return 0.0
103
+ p_vec = _smoothed_distribution(p, keys)
104
+ q_vec = _smoothed_distribution(q, keys)
105
+ m_vec = [(pi + qi) / 2.0 for pi, qi in zip(p_vec, q_vec)]
106
+
107
+ def _kl(a: list[float], b: list[float]) -> float:
108
+ return sum(ai * math.log2(ai / bi) for ai, bi in zip(a, b) if ai > 0)
109
+
110
+ js = 0.5 * _kl(p_vec, m_vec) + 0.5 * _kl(q_vec, m_vec)
111
+ # Borne théorique : JS ∈ [0, 1] en bits. Clamp pour absorber les
112
+ # erreurs d'arrondi flottant.
113
+ return max(0.0, min(1.0, js))
114
+
115
+
116
+ def taxonomy_divergence_matrix(
117
+ distributions: dict[str, dict[str, float]],
118
+ metric: str = "js",
119
+ ) -> dict[str, dict[str, float]]:
120
+ """Construit la matrice de divergence triangulaire entre moteurs.
121
+
122
+ Parameters
123
+ ----------
124
+ distributions:
125
+ ``{engine_name: {error_class: probability}}``. Chaque
126
+ distribution doit sommer à environ 1 (pas de validation stricte
127
+ — les distributions taxonomiques de Picarones sont déjà
128
+ normalisées par ``aggregate_taxonomy``).
129
+ metric:
130
+ ``"js"`` (défaut, symétrique) ou ``"kl"`` (asymétrique).
131
+
132
+ Returns
133
+ -------
134
+ dict[str, dict[str, float]]
135
+ Matrice ``{engine_a: {engine_b: divergence}}`` symétrique pour
136
+ ``js``, asymétrique pour ``kl``. La diagonale vaut 0.
137
+ """
138
+ if metric not in ("js", "kl"):
139
+ raise ValueError(f"metric doit être 'js' ou 'kl' — reçu {metric!r}")
140
+ fn = jensen_shannon_divergence if metric == "js" else kl_divergence
141
+
142
+ engines = sorted(distributions.keys())
143
+ matrix: dict[str, dict[str, float]] = {a: {} for a in engines}
144
+ for a in engines:
145
+ for b in engines:
146
+ if a == b:
147
+ matrix[a][b] = 0.0
148
+ elif metric == "js" and b in matrix and a in matrix[b]:
149
+ # Symétrique : recopie pour éviter de recalculer
150
+ matrix[a][b] = matrix[b][a]
151
+ else:
152
+ matrix[a][b] = fn(distributions[a], distributions[b])
153
+ return matrix
154
+
155
+
156
+ # ──────────────────────────────────────────────────────────────────────────
157
+ # Complémentarité (oracle token recall)
158
+ # ──────────────────────────────────────────────────────────────────────────
159
+
160
+
161
+ def _word_multiset(text: str) -> Counter[str]:
162
+ """Décomposition en multiset de tokens (séparateur whitespace)."""
163
+ return Counter(tok for tok in text.split() if tok)
164
+
165
+
166
+ def oracle_token_recall(
167
+ reference: str,
168
+ hypotheses: dict[str, str],
169
+ ) -> float:
170
+ """Borne supérieure (proxy bag-of-words) du token-recall atteignable
171
+ par un voting majoritaire entre tous les moteurs fournis.
172
+
173
+ Pour chaque token de la référence (avec sa multiplicité), on
174
+ considère qu'il est "préservé" par l'ensemble si au moins un moteur
175
+ en produit une occurrence non encore comptée. Le score est le ratio
176
+ d'occurrences GT préservées sur le total.
177
+
178
+ Parameters
179
+ ----------
180
+ reference:
181
+ Texte GT.
182
+ hypotheses:
183
+ ``{engine_name: hypothesis_text}``.
184
+
185
+ Returns
186
+ -------
187
+ float
188
+ Ratio dans ``[0, 1]``. ``1.0`` = chaque token GT est présent
189
+ dans au moins une hypothèse à hauteur de sa multiplicité.
190
+
191
+ Note
192
+ ----
193
+ Cette borne est **optimiste** (supérieure à la vraie borne par
194
+ voting séquentiel) car elle ignore l'ordre d'apparition. Pour le
195
+ diagnostic « un voting vaut-il l'effort ? » le proxy suffit ; pour
196
+ une vraie borne il faudrait un alignement séquentiel.
197
+ """
198
+ ref_counter = _word_multiset(reference)
199
+ if not ref_counter or not hypotheses:
200
+ return 1.0 if not ref_counter else 0.0
201
+
202
+ hyp_counters = [_word_multiset(h) for h in hypotheses.values()]
203
+ total_ref = sum(ref_counter.values())
204
+ preserved = 0
205
+ for token, gt_count in ref_counter.items():
206
+ # Pour chaque moteur, le nombre d'occurrences disponibles, plafonné
207
+ # à la multiplicité GT. L'oracle prend le max sur les moteurs.
208
+ best = max((min(gt_count, hc.get(token, 0)) for hc in hyp_counters), default=0)
209
+ preserved += best
210
+ return preserved / total_ref
211
+
212
+
213
+ def complementarity_gap(
214
+ reference: str,
215
+ hypotheses: dict[str, str],
216
+ ) -> dict[str, float]:
217
+ """Compare l'oracle au meilleur moteur seul.
218
+
219
+ Returns
220
+ -------
221
+ dict
222
+ ``{
223
+ "oracle_recall": float, # bag-of-words recall de l'oracle
224
+ "best_single_recall": float, # meilleur recall token d'un moteur seul
225
+ "best_engine": str, # nom du moteur correspondant
226
+ "absolute_gap": float, # oracle - best_single (toujours ≥ 0)
227
+ "relative_gap": float, # absolute_gap / (1 - best_single + ε)
228
+ # = fraction des erreurs encore évitables
229
+ # par un ensemble
230
+ }``
231
+ """
232
+ ref_counter = _word_multiset(reference)
233
+ total = sum(ref_counter.values())
234
+ if not total:
235
+ return {
236
+ "oracle_recall": 1.0,
237
+ "best_single_recall": 1.0,
238
+ "best_engine": "",
239
+ "absolute_gap": 0.0,
240
+ "relative_gap": 0.0,
241
+ }
242
+
243
+ def _single_recall(hyp_text: str) -> float:
244
+ hc = _word_multiset(hyp_text)
245
+ preserved = sum(min(gt, hc.get(tok, 0)) for tok, gt in ref_counter.items())
246
+ return preserved / total
247
+
248
+ if not hypotheses:
249
+ return {
250
+ "oracle_recall": 0.0,
251
+ "best_single_recall": 0.0,
252
+ "best_engine": "",
253
+ "absolute_gap": 0.0,
254
+ "relative_gap": 0.0,
255
+ }
256
+
257
+ per_engine = {name: _single_recall(h) for name, h in hypotheses.items()}
258
+ best_engine, best_recall = max(per_engine.items(), key=lambda kv: kv[1])
259
+ oracle = oracle_token_recall(reference, hypotheses)
260
+
261
+ absolute_gap = max(0.0, oracle - best_recall)
262
+ # relative_gap : fraction des erreurs du meilleur moteur que l'ensemble
263
+ # serait théoriquement capable de récupérer (∈ [0, 1])
264
+ headroom = max(1.0 - best_recall, 1e-12)
265
+ relative_gap = min(1.0, absolute_gap / headroom)
266
+
267
+ return {
268
+ "oracle_recall": oracle,
269
+ "best_single_recall": best_recall,
270
+ "best_engine": best_engine,
271
+ "absolute_gap": absolute_gap,
272
+ "relative_gap": relative_gap,
273
+ }
274
+
275
+
276
+ def pairwise_disagreement_rate(
277
+ reference: str,
278
+ hyp_a: str,
279
+ hyp_b: str,
280
+ ) -> float:
281
+ """Fraction de tokens GT pour lesquels A et B sont en désaccord.
282
+
283
+ Un désaccord = (l'un préserve le token, l'autre non) OU
284
+ (les deux le ratent mais avec des substitutions différentes — non
285
+ capturé ici, on reste sur la version simple présence/absence).
286
+
287
+ Returns
288
+ -------
289
+ float
290
+ Ratio dans ``[0, 1]``. ``0`` = A et B font les mêmes choix
291
+ (pas de gain d'ensemble). ``1`` = A et B sont toujours en
292
+ désaccord (gain d'ensemble maximal).
293
+ """
294
+ ref_counter = _word_multiset(reference)
295
+ if not ref_counter:
296
+ return 0.0
297
+ a = _word_multiset(hyp_a)
298
+ b = _word_multiset(hyp_b)
299
+ total = sum(ref_counter.values())
300
+ disagree = 0
301
+ for tok, gt_count in ref_counter.items():
302
+ a_pres = min(gt_count, a.get(tok, 0))
303
+ b_pres = min(gt_count, b.get(tok, 0))
304
+ # Compte les positions où A et B donnent une réponse différente
305
+ disagree += abs(a_pres - b_pres)
306
+ return disagree / total
307
+
308
+
309
+ __all__ = [
310
+ "kl_divergence",
311
+ "jensen_shannon_divergence",
312
+ "taxonomy_divergence_matrix",
313
+ "oracle_token_recall",
314
+ "complementarity_gap",
315
+ "pairwise_disagreement_rate",
316
+ ]
tests/test_sprint35_inter_engine.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests Sprint 35 — métriques inter-moteurs (Étape 2 du plan).
2
+
3
+ Couvre les deux familles de mesures du module ``picarones.core.inter_engine`` :
4
+
5
+ 1. **Divergence taxonomique** : KL et JS-divergence sur les
6
+ distributions de classes d'erreur, plus la matrice triangulaire
7
+ inter-moteurs. Tests : invariants mathématiques (positivité, JS
8
+ symétrique et bornée, KL(p,p)=0), comportement sur clés disjointes.
9
+
10
+ 2. **Complémentarité** : oracle token recall, gap absolu/relatif vs
11
+ meilleur moteur seul, taux de désaccord par paire. Tests : cas
12
+ parfait (oracle = best = 1), cas où un ensemble apporte un vrai gain,
13
+ cas d'égalité parfaite (gap = 0), garde-fous (référence vide,
14
+ hypothèses vides).
15
+
16
+ Les fonctions sont pures ; pas besoin de fixtures d'I/O ni de moteurs
17
+ réels.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import math
23
+
24
+ import pytest
25
+
26
+ from picarones.core.inter_engine import (
27
+ complementarity_gap,
28
+ jensen_shannon_divergence,
29
+ kl_divergence,
30
+ oracle_token_recall,
31
+ pairwise_disagreement_rate,
32
+ taxonomy_divergence_matrix,
33
+ )
34
+
35
+
36
+ # ──────────────────────────────────────────────────────────────────────────
37
+ # 1. KL-divergence
38
+ # ──────────────────────────────────────────────────────────────────────────
39
+
40
+
41
+ class TestKLDivergence:
42
+ def test_self_divergence_is_zero(self) -> None:
43
+ p = {"a": 0.4, "b": 0.3, "c": 0.3}
44
+ assert kl_divergence(p, p) == pytest.approx(0.0, abs=1e-9)
45
+
46
+ def test_kl_is_non_negative(self) -> None:
47
+ p = {"a": 0.7, "b": 0.2, "c": 0.1}
48
+ q = {"a": 0.1, "b": 0.4, "c": 0.5}
49
+ assert kl_divergence(p, q) > 0
50
+ assert kl_divergence(q, p) > 0
51
+
52
+ def test_kl_is_asymmetric_in_general(self) -> None:
53
+ # Choix asymétrique non symétrique par permutation
54
+ p = {"a": 0.9, "b": 0.05, "c": 0.05}
55
+ q = {"a": 0.4, "b": 0.4, "c": 0.2}
56
+ assert kl_divergence(p, q) != pytest.approx(kl_divergence(q, p), abs=1e-3)
57
+
58
+ def test_disjoint_keys_handled(self) -> None:
59
+ # Pas de clé en commun : doit retourner une valeur finie grâce
60
+ # au lissage epsilon.
61
+ p = {"a": 1.0}
62
+ q = {"b": 1.0}
63
+ kl = kl_divergence(p, q)
64
+ assert math.isfinite(kl)
65
+ assert kl > 0
66
+
67
+ def test_empty_distributions_return_zero(self) -> None:
68
+ assert kl_divergence({}, {}) == 0.0
69
+
70
+
71
+ # ──────────────────────────────────────────────────────────────────────────
72
+ # 2. Jensen-Shannon divergence
73
+ # ──────────────────────────────────────────────────────────────────────────
74
+
75
+
76
+ class TestJensenShannonDivergence:
77
+ def test_self_divergence_is_zero(self) -> None:
78
+ p = {"a": 0.4, "b": 0.3, "c": 0.3}
79
+ assert jensen_shannon_divergence(p, p) == pytest.approx(0.0, abs=1e-9)
80
+
81
+ def test_symmetric(self) -> None:
82
+ p = {"a": 0.7, "b": 0.2, "c": 0.1}
83
+ q = {"a": 0.1, "b": 0.4, "c": 0.5}
84
+ assert jensen_shannon_divergence(p, q) == pytest.approx(
85
+ jensen_shannon_divergence(q, p), abs=1e-9
86
+ )
87
+
88
+ def test_bounded_in_unit_interval(self) -> None:
89
+ # JS en bits ∈ [0, 1]. Distributions extrêmes : disjointes.
90
+ p = {"a": 1.0}
91
+ q = {"b": 1.0}
92
+ js = jensen_shannon_divergence(p, q)
93
+ assert 0.0 <= js <= 1.0
94
+ # Les distributions disjointes donnent une JS proche de 1 (la
95
+ # borne est atteinte asymptotiquement).
96
+ assert js > 0.5
97
+
98
+ def test_close_distributions_have_small_js(self) -> None:
99
+ p = {"a": 0.5, "b": 0.5}
100
+ q = {"a": 0.51, "b": 0.49}
101
+ assert jensen_shannon_divergence(p, q) < 0.01
102
+
103
+
104
+ # ──────────────────────────────────────────────────────────────────────────
105
+ # 3. Matrice de divergence inter-moteurs
106
+ # ──────────────────────────────────────────────────────────────────────────
107
+
108
+
109
+ class TestDivergenceMatrix:
110
+ @pytest.fixture
111
+ def engines(self) -> dict[str, dict[str, float]]:
112
+ return {
113
+ "tesseract": {"visual": 0.5, "casse": 0.3, "abbrev": 0.2},
114
+ "pero": {"visual": 0.2, "casse": 0.3, "abbrev": 0.5},
115
+ "mistral": {"visual": 0.4, "casse": 0.4, "abbrev": 0.2},
116
+ }
117
+
118
+ def test_diagonal_is_zero(
119
+ self, engines: dict[str, dict[str, float]]
120
+ ) -> None:
121
+ mat = taxonomy_divergence_matrix(engines)
122
+ for name in engines:
123
+ assert mat[name][name] == pytest.approx(0.0, abs=1e-9)
124
+
125
+ def test_js_matrix_is_symmetric(
126
+ self, engines: dict[str, dict[str, float]]
127
+ ) -> None:
128
+ mat = taxonomy_divergence_matrix(engines, metric="js")
129
+ for a in engines:
130
+ for b in engines:
131
+ assert mat[a][b] == pytest.approx(mat[b][a], abs=1e-9)
132
+
133
+ def test_kl_matrix_is_asymmetric(
134
+ self, engines: dict[str, dict[str, float]]
135
+ ) -> None:
136
+ mat = taxonomy_divergence_matrix(engines, metric="kl")
137
+ # Au moins une paire doit être asymétrique
138
+ asymmetric_found = any(
139
+ abs(mat[a][b] - mat[b][a]) > 1e-6
140
+ for a in engines for b in engines if a != b
141
+ )
142
+ assert asymmetric_found
143
+
144
+ def test_unknown_metric_raises(
145
+ self, engines: dict[str, dict[str, float]]
146
+ ) -> None:
147
+ with pytest.raises(ValueError, match="metric"):
148
+ taxonomy_divergence_matrix(engines, metric="hellinger")
149
+
150
+ def test_distinguishes_specialized_engines(self) -> None:
151
+ """Deux moteurs avec profils opposés doivent ressortir comme
152
+ candidats à un ensemble (JS élevée)."""
153
+ engines = {
154
+ "visual_specialist": {"visual": 0.9, "casse": 0.05, "abbrev": 0.05},
155
+ "abbrev_specialist": {"visual": 0.05, "casse": 0.05, "abbrev": 0.9},
156
+ "balanced": {"visual": 0.33, "casse": 0.33, "abbrev": 0.34},
157
+ }
158
+ mat = taxonomy_divergence_matrix(engines, metric="js")
159
+ # Les deux spécialistes doivent diverger plus l'un de l'autre que
160
+ # n'importe lequel d'eux du moteur balanced.
161
+ assert mat["visual_specialist"]["abbrev_specialist"] > mat["visual_specialist"]["balanced"]
162
+ assert mat["visual_specialist"]["abbrev_specialist"] > mat["abbrev_specialist"]["balanced"]
163
+
164
+
165
+ # ──────────────────────────────────────────────────────────────────────────
166
+ # 4. Oracle token recall
167
+ # ──────────────────────────────────────────────────────────────────────────
168
+
169
+
170
+ class TestOracleTokenRecall:
171
+ def test_perfect_engine_oracle_is_one(self) -> None:
172
+ ref = "le manuscrit est ancien"
173
+ hyps = {"perfect": ref}
174
+ assert oracle_token_recall(ref, hyps) == pytest.approx(1.0)
175
+
176
+ def test_no_engine_recovers_anything(self) -> None:
177
+ ref = "alpha beta gamma"
178
+ hyps = {"a": "x y z", "b": "x y z"}
179
+ assert oracle_token_recall(ref, hyps) == pytest.approx(0.0)
180
+
181
+ def test_complementarity_pays_off(self) -> None:
182
+ """A et B se complètent : aucun ne fait tout, ensemble ils font tout."""
183
+ ref = "alpha beta gamma delta"
184
+ hyps = {
185
+ "a": "alpha beta x y", # alpha + beta seulement
186
+ "b": "x y gamma delta", # gamma + delta seulement
187
+ }
188
+ assert oracle_token_recall(ref, hyps) == pytest.approx(1.0)
189
+ # Et chacun seul ne fait que la moitié
190
+ from picarones.core.inter_engine import complementarity_gap
191
+ gap = complementarity_gap(ref, hyps)
192
+ assert gap["best_single_recall"] == pytest.approx(0.5)
193
+ assert gap["oracle_recall"] == pytest.approx(1.0)
194
+ assert gap["absolute_gap"] == pytest.approx(0.5)
195
+ # Tout l'écart restant est récupérable → relative_gap = 1
196
+ assert gap["relative_gap"] == pytest.approx(1.0)
197
+
198
+ def test_multiplicity_is_respected(self) -> None:
199
+ """Si la GT a deux 'le' et le moteur n'en produit qu'un, recall = 0.5
200
+ sur ce token."""
201
+ ref = "le chat le chien" # 2× 'le', 1× 'chat', 1× 'chien'
202
+ hyps = {"a": "le chat le chien"} # parfait
203
+ assert oracle_token_recall(ref, hyps) == pytest.approx(1.0)
204
+ hyps2 = {"a": "le chat chien"} # un seul 'le'
205
+ assert oracle_token_recall(ref, hyps2) == pytest.approx(3 / 4)
206
+
207
+ def test_empty_reference_returns_one(self) -> None:
208
+ assert oracle_token_recall("", {"a": "anything"}) == pytest.approx(1.0)
209
+
210
+ def test_no_hypotheses_returns_zero(self) -> None:
211
+ assert oracle_token_recall("alpha", {}) == pytest.approx(0.0)
212
+
213
+ def test_oracle_is_at_least_best_single(self) -> None:
214
+ """Invariant : l'oracle est toujours ≥ au meilleur moteur seul."""
215
+ ref = "alpha beta gamma delta epsilon"
216
+ hyps = {
217
+ "a": "alpha beta gamma x y",
218
+ "b": "alpha x gamma delta z",
219
+ "c": "x y z delta epsilon",
220
+ }
221
+ gap = complementarity_gap(ref, hyps)
222
+ assert gap["oracle_recall"] >= gap["best_single_recall"]
223
+
224
+
225
+ # ──────────────��───────────────────────────────────────────────────────────
226
+ # 5. Gap et désaccord par paire
227
+ # ──────────────────────────────────────────────────────────────────────────
228
+
229
+
230
+ class TestComplementarityGap:
231
+ def test_no_gap_when_engines_are_redundant(self) -> None:
232
+ ref = "alpha beta gamma"
233
+ hyps = {"a": "alpha beta x", "b": "alpha beta x"} # redondants
234
+ gap = complementarity_gap(ref, hyps)
235
+ # Les deux ratent le même token → oracle = best_single
236
+ assert gap["absolute_gap"] == pytest.approx(0.0)
237
+ assert gap["relative_gap"] == pytest.approx(0.0)
238
+
239
+ def test_best_engine_named(self) -> None:
240
+ ref = "alpha beta gamma"
241
+ hyps = {
242
+ "tesseract": "alpha x x", # 1/3
243
+ "pero": "alpha beta x", # 2/3
244
+ }
245
+ gap = complementarity_gap(ref, hyps)
246
+ assert gap["best_engine"] == "pero"
247
+
248
+ def test_empty_reference(self) -> None:
249
+ gap = complementarity_gap("", {"a": "anything"})
250
+ assert gap["oracle_recall"] == 1.0
251
+ assert gap["best_single_recall"] == 1.0
252
+ assert gap["absolute_gap"] == 0.0
253
+
254
+
255
+ class TestPairwiseDisagreement:
256
+ def test_identical_hypotheses_zero_disagreement(self) -> None:
257
+ ref = "alpha beta gamma"
258
+ h = "alpha beta x"
259
+ assert pairwise_disagreement_rate(ref, h, h) == pytest.approx(0.0)
260
+
261
+ def test_complete_disagreement_when_complementary(self) -> None:
262
+ ref = "alpha beta"
263
+ # A préserve alpha, B préserve beta — désaccord sur les deux
264
+ rate = pairwise_disagreement_rate(ref, "alpha x", "x beta")
265
+ assert rate == pytest.approx(1.0)
266
+
267
+ def test_empty_reference_returns_zero(self) -> None:
268
+ assert pairwise_disagreement_rate("", "x", "y") == 0.0