Claude commited on
Commit
b826d7e
·
unverified ·
1 Parent(s): 8542799

sprint77: taxonomie comparative côte-à-côte (A.I.4 chantier 3, clôture A.I.4)

Browse files

Troisième et dernier chantier d'A.I.4. Le détecteur
error_profile_outlier (Sprint 19) signale qu'un moteur a un profil
taxonomique éloigné de ses concurrents, mais sans visualisation.
Ce sprint répond à « deux moteurs ont le même CER global, mais
lequel fait des erreurs plus récupérables ? ».

- Nouveau module picarones/core/taxonomy_comparison.py :
- compare_taxonomies(engine_a, counts_a, engine_b, counts_b)
normalise en proportions, deltas signés, agrège par
récupérabilité éditoriale (recoverable/difficult/irrecoverable).
- Constante RECOVERABILITY exportée.
- Nouveau module picarones/report/taxonomy_comparison_render.py :
- build_taxonomy_comparison_html : titre + note + diagramme
miroir SVG + tableau résumé.
- Mirror chart : ligne par classe, barres horizontales A à
gauche / B à droite, étiquettes au centre, % à côté, couleur
selon récupérabilité (vert/orange/rouge), échelle normalisée.
- Tableau récupérabilité 3×2 avec pastilles colorées.
- Adaptive : "" si data None ou pas de classes.
- Choix éditorial assumé : la classification est un guide
pragmatique, pas un verdict imposé.
- +6 clés i18n FR/EN.
- +18 tests dans test_sprint77_taxonomy_comparison.py.

A.I.4 livré bout-en-bout (Sprints 75-77).

Tests : 2645 passed, 2 skipped, 0 failed.

https://claude.ai/code/session_01RusTQYcSfXqTsbFNvwmCV7

CHANGELOG.md CHANGED
@@ -16,6 +16,64 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
16
 
17
  ### Ajouté
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  - **Sprint 76 — A.I.4 chantier 2 : évolution intra-document
20
  des classes taxonomiques (couche calcul + heatmap SVG).**
21
  Deuxième des trois chantiers d'A.I.4. ``line_metrics.py``
 
16
 
17
  ### Ajouté
18
 
19
+ - **Sprint 77 — A.I.4 chantier 3 : taxonomie comparative
20
+ côte-à-côte (clôture A.I.4).** Troisième et dernier chantier
21
+ d'A.I.4. Le détecteur ``error_profile_outlier`` (Sprint 19)
22
+ signale qu'un moteur a un profil taxonomique éloigné de ses
23
+ concurrents, mais sans visualisation. Ce sprint répond à
24
+ *« deux moteurs ont le même CER global, mais lequel fait des
25
+ erreurs plus récupérables ? »*.
26
+ - Nouveau module `picarones/core/taxonomy_comparison.py` :
27
+ - ``compare_taxonomies(engine_a, counts_a, engine_b, counts_b)``
28
+ normalise les comptes en proportions (somme = 1), calcule
29
+ les ``deltas`` signés (b - a) par classe, et agrège par
30
+ niveau de **récupérabilité éditoriale** :
31
+
32
+ - ``recoverable`` : case_error, ligature_error,
33
+ abbreviation_error (corrigeables par post-processing
34
+ trivial)
35
+ - ``difficult`` : diacritic_error, visual_confusion,
36
+ hapax (effort modéré requis)
37
+ - ``irrecoverable`` : lacuna, oov_character,
38
+ segmentation_error (impossibles sans relire l'image)
39
+ - Constante ``RECOVERABILITY`` exportée pour utilisation
40
+ externe.
41
+ - Retourne ``None`` si les deux moteurs ont 0 erreur chacun.
42
+ - Nouveau module `picarones/report/taxonomy_comparison_render.py` :
43
+ - ``build_taxonomy_comparison_html(data, labels)`` produit
44
+ titre + note d'usage + diagramme miroir SVG + tableau
45
+ résumé par catégorie.
46
+ - ``_build_mirror_chart_svg`` server-side : une ligne par
47
+ classe, deux barres horizontales (A à gauche, B à droite),
48
+ étiquette de classe au centre, valeurs en %. Couleur de
49
+ la barre selon ``recoverability`` (vert / orange / rouge).
50
+ Échelle normalisée à la proportion max pour visibilité
51
+ uniforme.
52
+ - ``_build_recoverability_summary_html`` : tableau 3 lignes
53
+ (Récupérable / Difficile / Irrécupérable) × 2 colonnes
54
+ (engine A / engine B) avec pastille colorée et %.
55
+ - Adaptive : ``""`` si ``data is None`` ou pas de classes.
56
+ - Anti-injection systématique sur noms de moteurs et labels
57
+ i18n. Accessible : ``role="img"`` + ``aria-label``.
58
+ - +6 clés i18n FR/EN (``taxocomp_*``) avec template Python
59
+ ``{engine_a}/{engine_b}``.
60
+ - +18 tests dans `test_sprint77_taxonomy_comparison.py` :
61
+ couche calcul (7 cas — proportions, deltas signés,
62
+ récupérabilité, vide, classe unique chez un moteur, totaux,
63
+ sanité ``RECOVERABILITY`` couvre toutes ``ERROR_CLASSES``),
64
+ rendu (7 cas — None, SVG, noms moteurs, labels classes,
65
+ résumé récupérabilité, % affichés, codes couleur), anti-
66
+ injection (nom moteur + label i18n), complétude i18n FR + EN.
67
+ - **Choix éditorial assumé** : la classification
68
+ ``recoverable``/``difficult``/``irrecoverable`` est un
69
+ **guide pragmatique pour le chercheur**, pas un verdict
70
+ imposé. La note explicative dit textuellement « à CER égal,
71
+ un moteur dont les erreurs sont majoritairement vertes est
72
+ préférable pour une édition critique » — c'est au chercheur
73
+ de juger selon ses besoins.
74
+ - **A.I.4 livré bout-en-bout** : co-occurrence (Sprint 75) +
75
+ intra-document (Sprint 76) + comparatif (Sprint 77).
76
+
77
  - **Sprint 76 — A.I.4 chantier 2 : évolution intra-document
78
  des classes taxonomiques (couche calcul + heatmap SVG).**
79
  Deuxième des trois chantiers d'A.I.4. ``line_metrics.py``
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
  | 76 | **Sprint 45 du plan d'évolution 2026 — A.I.4 chantier 2 : évolution intra-document des classes taxonomiques (couche calcul + heatmap SVG)**. `line_metrics.py` (Sprint 10) avait déjà heatmap CER×position ; ce sprint l'étend à toutes les classes taxonomiques. Nouveau module `picarones/core/taxonomy_intra_doc.py` : `compute_taxonomy_position_heatmap(reference, hypothesis, n_bins=10)` calcule par classe le compte par tranche de position, réutilise classification mot-à-mot Sprint 5 en gardant `i1` (position GT) et binnifiant via `floor(i1/n_gt*n_bins)`. `_classify_word_pair` variante pure. `_bin_for_position` clip 0..n_bins-1. `ValueError` si n_bins≤0, `None` si GT vide. Nouveau module `picarones/report/taxonomy_intra_doc_render.py` : `build_taxonomy_intra_doc_html` produit heatmap SVG class×position avec gradient blanc→orange profond, densité relative au max de chaque classe (met en évidence les positions concentrées), filtrage classes avec ≥1 erreur, étiquettes positions/classes, accessible. Adaptive : `""` si None/no_errors/aucune classe avec erreurs. +3 clés i18n FR/EN. +16 tests (calcul 8 cas dont identité/début/fin/uniforme/breakdown, rendu 5 cas, anti-injection, complétude i18n). **Verrou levé** : un chercheur voit où chaque type d'erreur apparaît — distingue erreurs de marge (concentrées) vs scribe (uniformes). |
211
  | 75 | **Sprint 44 du plan d'évolution 2026 — A.I.4 chantier 1 : co-occurrence taxonomique (couche calcul + heatmap SVG)**. Premier des 3 chantiers d'A.I.4. Répond à « quelles classes d'erreur tendent à apparaître ensemble ? » — utile pour stratifier *a posteriori*. Nouveau module `picarones/core/taxonomy_cooccurrence.py` : `compute_taxonomy_cooccurrence(per_doc_classes, min_doc_count=1, top_n_pairs=10)` calcule l'indice de Jaccard entre paires de classes au niveau document (présence binaire), symétrique, diagonale=1.0, filtrage classes anecdotiques via min_doc_count, top_pairs triées Jaccard décroissant. Retourne None si vide. Nouveau module `picarones/report/taxonomy_cooccurrence_render.py` : `build_taxonomy_cooccurrence_html` produit titre + note + heatmap SVG + table top_pairs. `_build_heatmap_svg` server-side avec cellules colorées blanc→bleu profond, valeur affichée si >0.05, étiquettes rotées -45° en haut/normales à gauche, accessible (role/aria-label). Adaptive : "" si None ou matrice vide. +5 clés i18n FR/EN. +22 tests (calcul 11 cas dont toujours/jamais ensemble, diagonale, symétrie, chevauchement partiel, min_doc_count, top_pairs triées, none doc skipped ; rendu 7 cas ; anti-injection ; complétude i18n). **Verrou levé** : un chercheur voit d'un coup d'œil quelles classes d'erreur sont corrélées dans son corpus. |
212
  | 74 | **Sprint 43 du plan d'évolution 2026 — A.I.3 chantier 1 : encart HTML « Ce corpus est-il habituel ? » (clôture A.I.3)**. Suite directe Sprint 73 (couche calcul + détecteur narratif). Nouveau module `picarones/report/baseline_render.py` : `build_corpus_difficulty_baseline_html(percentile_data, historical_values, labels)` produit phrase factuelle + boxplot SVG, phrase template auto-sélectionnée selon harder_than_usual/easier_than_usual/usual flags. `_build_difficulty_boxplot_svg` server-side avec moustache min→max, boîte Q1→Q3, médiane, point courant **coloré adaptive** (bleu si <Q1 plus facile, rouge si >Q3 plus difficile, vert sinon habituel), étiquettes numériques, accessible (role/aria-label). Helper `_quantiles` méthode inclusive gère N=0/1. Adaptive : `""` si percentile_data None, boxplot omis si historical_values vide. +4 clés i18n FR/EN avec templates Python `{current:.2f}/{percentile:.0f}/{n_runs}`. +20 tests (quantiles 3 cas, SVG 8 cas dont couleurs/dégénéré, HTML 6 cas, anti-injection, complétude i18n). **Verrou levé** : un bench avec historique SQLite chargé voit en tête de rapport « ce corpus est plus difficile que la moyenne — au 88ᵉ percentile des 47 corpus précédents » avec boxplot. **A.I.3 livré bout-en-bout** (Sprint 73 calc+narrative + Sprint 74 vue HTML). |
@@ -294,7 +295,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
294
  ## Contexte développement
295
 
296
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
297
- - **Tests** : 2627 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 ; Sprints 45+46 = stratification A.III livrée bout-en-bout ; Sprints 47-51 = les 5 adapters OCR exposent leurs confidences natives ; **Étape 2 close** ; Sprints 52-54 = axe A.II.2 (métriques structurelles) couches de calcul intégralement livrées ; Sprints 55-62 = extension philologique livrée bout-en-bout sur trois périodes + numéraux romains transversaux + câblage runner adaptive + vue HTML « Profil philologique » ; Sprints 63-70 = axe B livré bout-en-bout ; Sprints 71-72 = A.I.1 livré bout-en-bout ; Sprints 73-74 = A.I.3 livré bout-en-bout ; **Sprints 75-76 = A.I.4 chantiers 1+2 — co-occurrence Jaccard + heatmap intra-document class×position**)
298
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
299
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
300
  - **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
+ | 77 | **Sprint 46 du plan d'évolution 2026 — A.I.4 chantier 3 : taxonomie comparative côte-à-côte (clôture A.I.4)**. Troisième chantier d'A.I.4. Répond à « deux moteurs ont le même CER global, mais lequel fait des erreurs plus récupérables ? ». Nouveau module `picarones/core/taxonomy_comparison.py` : `compare_taxonomies(engine_a, counts_a, engine_b, counts_b)` normalise en proportions, calcule deltas signés, agrège par niveau de **récupérabilité éditoriale** (recoverable: case/ligature/abbreviation ; difficult: diacritic/visual/hapax ; irrecoverable: lacuna/oov/segmentation). Constante `RECOVERABILITY` exportée. Retourne None si vide. Nouveau module `picarones/report/taxonomy_comparison_render.py` : `build_taxonomy_comparison_html` produit titre + note + diagramme miroir SVG + tableau résumé par catégorie. `_build_mirror_chart_svg` server-side : ligne par classe, barres horizontales A à gauche / B à droite, étiquette au centre, %, couleur selon récupérabilité (vert/orange/rouge), échelle normalisée. `_build_recoverability_summary_html` : tableau 3×2 avec pastilles colorées. Adaptive : "" si None ou pas de classes. +6 clés i18n FR/EN. +18 tests (calcul 7 cas dont sanité RECOVERABILITY couvre ERROR_CLASSES, rendu 7 cas, anti-injection, i18n). **Choix éditorial assumé** : classification recoverable/difficult/irrecoverable est un guide pragmatique, pas un verdict — note explicative dit « à CER égal, un moteur dont les erreurs sont majoritairement vertes est préférable pour une édition critique ». **A.I.4 livré bout-en-bout** (Sprints 75-77). |
211
  | 76 | **Sprint 45 du plan d'évolution 2026 — A.I.4 chantier 2 : évolution intra-document des classes taxonomiques (couche calcul + heatmap SVG)**. `line_metrics.py` (Sprint 10) avait déjà heatmap CER×position ; ce sprint l'étend à toutes les classes taxonomiques. Nouveau module `picarones/core/taxonomy_intra_doc.py` : `compute_taxonomy_position_heatmap(reference, hypothesis, n_bins=10)` calcule par classe le compte par tranche de position, réutilise classification mot-à-mot Sprint 5 en gardant `i1` (position GT) et binnifiant via `floor(i1/n_gt*n_bins)`. `_classify_word_pair` variante pure. `_bin_for_position` clip 0..n_bins-1. `ValueError` si n_bins≤0, `None` si GT vide. Nouveau module `picarones/report/taxonomy_intra_doc_render.py` : `build_taxonomy_intra_doc_html` produit heatmap SVG class×position avec gradient blanc→orange profond, densité relative au max de chaque classe (met en évidence les positions concentrées), filtrage classes avec ≥1 erreur, étiquettes positions/classes, accessible. Adaptive : `""` si None/no_errors/aucune classe avec erreurs. +3 clés i18n FR/EN. +16 tests (calcul 8 cas dont identité/début/fin/uniforme/breakdown, rendu 5 cas, anti-injection, complétude i18n). **Verrou levé** : un chercheur voit où chaque type d'erreur apparaît — distingue erreurs de marge (concentrées) vs scribe (uniformes). |
212
  | 75 | **Sprint 44 du plan d'évolution 2026 — A.I.4 chantier 1 : co-occurrence taxonomique (couche calcul + heatmap SVG)**. Premier des 3 chantiers d'A.I.4. Répond à « quelles classes d'erreur tendent à apparaître ensemble ? » — utile pour stratifier *a posteriori*. Nouveau module `picarones/core/taxonomy_cooccurrence.py` : `compute_taxonomy_cooccurrence(per_doc_classes, min_doc_count=1, top_n_pairs=10)` calcule l'indice de Jaccard entre paires de classes au niveau document (présence binaire), symétrique, diagonale=1.0, filtrage classes anecdotiques via min_doc_count, top_pairs triées Jaccard décroissant. Retourne None si vide. Nouveau module `picarones/report/taxonomy_cooccurrence_render.py` : `build_taxonomy_cooccurrence_html` produit titre + note + heatmap SVG + table top_pairs. `_build_heatmap_svg` server-side avec cellules colorées blanc→bleu profond, valeur affichée si >0.05, étiquettes rotées -45° en haut/normales à gauche, accessible (role/aria-label). Adaptive : "" si None ou matrice vide. +5 clés i18n FR/EN. +22 tests (calcul 11 cas dont toujours/jamais ensemble, diagonale, symétrie, chevauchement partiel, min_doc_count, top_pairs triées, none doc skipped ; rendu 7 cas ; anti-injection ; complétude i18n). **Verrou levé** : un chercheur voit d'un coup d'œil quelles classes d'erreur sont corrélées dans son corpus. |
213
  | 74 | **Sprint 43 du plan d'évolution 2026 — A.I.3 chantier 1 : encart HTML « Ce corpus est-il habituel ? » (clôture A.I.3)**. Suite directe Sprint 73 (couche calcul + détecteur narratif). Nouveau module `picarones/report/baseline_render.py` : `build_corpus_difficulty_baseline_html(percentile_data, historical_values, labels)` produit phrase factuelle + boxplot SVG, phrase template auto-sélectionnée selon harder_than_usual/easier_than_usual/usual flags. `_build_difficulty_boxplot_svg` server-side avec moustache min→max, boîte Q1→Q3, médiane, point courant **coloré adaptive** (bleu si <Q1 plus facile, rouge si >Q3 plus difficile, vert sinon habituel), étiquettes numériques, accessible (role/aria-label). Helper `_quantiles` méthode inclusive gère N=0/1. Adaptive : `""` si percentile_data None, boxplot omis si historical_values vide. +4 clés i18n FR/EN avec templates Python `{current:.2f}/{percentile:.0f}/{n_runs}`. +20 tests (quantiles 3 cas, SVG 8 cas dont couleurs/dégénéré, HTML 6 cas, anti-injection, complétude i18n). **Verrou levé** : un bench avec historique SQLite chargé voit en tête de rapport « ce corpus est plus difficile que la moyenne — au 88ᵉ percentile des 47 corpus précédents » avec boxplot. **A.I.3 livré bout-en-bout** (Sprint 73 calc+narrative + Sprint 74 vue HTML). |
 
295
  ## Contexte développement
296
 
297
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
298
+ - **Tests** : 2645 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 ; Sprints 45+46 = stratification A.III livrée bout-en-bout ; Sprints 47-51 = les 5 adapters OCR exposent leurs confidences natives ; **Étape 2 close** ; Sprints 52-54 = axe A.II.2 (métriques structurelles) couches de calcul intégralement livrées ; Sprints 55-62 = extension philologique livrée bout-en-bout sur trois périodes + numéraux romains transversaux + câblage runner adaptive + vue HTML « Profil philologique » ; Sprints 63-70 = axe B livré bout-en-bout ; Sprints 71-72 = A.I.1 livré bout-en-bout ; Sprints 73-74 = A.I.3 livré bout-en-bout ; **Sprints 75-77 = A.I.4 livré bout-en-bout — co-occurrence Jaccard + heatmap intra-document class×position + diagramme miroir comparatif inter-moteurs**)
299
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
300
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
301
  - **Transcript de la conversation de développement** :
picarones/core/taxonomy_comparison.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Taxonomie comparative entre deux moteurs — Sprint 77 (A.I.4 chantier 3).
2
+
3
+ Sprint 77 — A.I.4 chantier 3 du plan d'évolution 2026 (clôture A.I.4).
4
+
5
+ Pourquoi ce module
6
+ ------------------
7
+ Le détecteur narratif ``error_profile_outlier`` (Sprint 19) signale
8
+ qu'un moteur a un profil taxonomique éloigné de ses concurrents,
9
+ mais le rapport n'expose pas cette différence visuellement. Ce
10
+ sprint répond à *« deux moteurs ont le même CER global, mais lequel
11
+ fait des erreurs plus récupérables ? »*.
12
+
13
+ Lecture concrète
14
+ ----------------
15
+ - Moteur A : 80 % d'erreurs ``case_error`` → toutes corrigeables
16
+ par un post-processing trivial (récupérables).
17
+ - Moteur B : 80 % d'erreurs ``lacuna`` (mots manquants) →
18
+ irrécupérables sans relire l'image.
19
+
20
+ À CER égal, A est massivement préférable pour un workflow
21
+ d'édition critique. Cette vue rend la différence visible.
22
+
23
+ Catégorisation des classes
24
+ --------------------------
25
+ On annote chaque classe d'erreur d'un degré de **récupérabilité**
26
+ (critère éditorial pragmatique, pas verdict imposé) :
27
+
28
+ - ``recoverable`` : récupérable par post-processing trivial
29
+ (case_error, ligature_error, abbreviation_error)
30
+ - ``difficult`` : récupérable au prix d'un effort
31
+ (diacritic_error, visual_confusion, hapax)
32
+ - ``irrecoverable`` : impossible à corriger sans l'image
33
+ (lacuna, oov_character, segmentation_error)
34
+
35
+ L'utilisateur consulte ces catégories comme un guide, pas un
36
+ verdict — c'est lui qui juge selon ses besoins éditoriaux.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import logging
42
+ from typing import Optional
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+
47
+ # Classification éditoriale. Documentée dans la docstring.
48
+ RECOVERABILITY: dict[str, str] = {
49
+ "case_error": "recoverable",
50
+ "ligature_error": "recoverable",
51
+ "abbreviation_error": "recoverable",
52
+ "diacritic_error": "difficult",
53
+ "visual_confusion": "difficult",
54
+ "hapax": "difficult",
55
+ "lacuna": "irrecoverable",
56
+ "oov_character": "irrecoverable",
57
+ "segmentation_error": "irrecoverable",
58
+ }
59
+
60
+
61
+ def _normalize_counts(counts: dict[str, int]) -> dict[str, float]:
62
+ """Convertit un dict de comptes en proportions [0, 1]."""
63
+ total = sum(counts.values())
64
+ if total <= 0:
65
+ return {k: 0.0 for k in counts}
66
+ return {k: v / total for k, v in counts.items()}
67
+
68
+
69
+ def compare_taxonomies(
70
+ engine_a_name: str,
71
+ engine_a_counts: dict[str, int],
72
+ engine_b_name: str,
73
+ engine_b_counts: dict[str, int],
74
+ ) -> Optional[dict]:
75
+ """Compare deux profils taxonomiques.
76
+
77
+ Parameters
78
+ ----------
79
+ engine_a_name, engine_b_name:
80
+ Noms d'identification des moteurs (utilisés dans le rendu).
81
+ engine_a_counts, engine_b_counts:
82
+ Maps ``{class_name: count}`` produites par
83
+ ``aggregate_taxonomy``.
84
+
85
+ Returns
86
+ -------
87
+ Optional[dict]
88
+ ``{
89
+ "engine_a": str, "engine_b": str,
90
+ "total_a": int, "total_b": int,
91
+ "classes": list[str], # classes apparaissant chez A ou B
92
+ "proportions_a": dict[str, float],
93
+ "proportions_b": dict[str, float],
94
+ "deltas": dict[str, float], # prop_b - prop_a (signé)
95
+ "recoverability": dict[str, str], # mapping class → niveau
96
+ "totals_by_recoverability": {
97
+ "recoverable": {"a": float, "b": float},
98
+ "difficult": {"a": float, "b": float},
99
+ "irrecoverable": {"a": float, "b": float},
100
+ },
101
+ }``
102
+ Ou ``None`` si les deux moteurs ont 0 erreur chacun.
103
+ """
104
+ if engine_a_name == engine_b_name:
105
+ # On accepte des comparaisons même si les noms sont
106
+ # identiques (cas tests), mais on émet un warning.
107
+ logger.warning(
108
+ "[taxonomy_comparison] engine_a et engine_b ont le même nom : %s",
109
+ engine_a_name,
110
+ )
111
+
112
+ total_a = sum(engine_a_counts.values()) if engine_a_counts else 0
113
+ total_b = sum(engine_b_counts.values()) if engine_b_counts else 0
114
+ if total_a == 0 and total_b == 0:
115
+ return None
116
+
117
+ classes = sorted(set(engine_a_counts) | set(engine_b_counts))
118
+ if not classes:
119
+ return None
120
+
121
+ prop_a = _normalize_counts(
122
+ {c: engine_a_counts.get(c, 0) for c in classes},
123
+ )
124
+ prop_b = _normalize_counts(
125
+ {c: engine_b_counts.get(c, 0) for c in classes},
126
+ )
127
+ deltas = {c: prop_b[c] - prop_a[c] for c in classes}
128
+
129
+ # Agrégat par récupérabilité (utile pour la lecture rapide)
130
+ totals_recov: dict[str, dict[str, float]] = {
131
+ "recoverable": {"a": 0.0, "b": 0.0},
132
+ "difficult": {"a": 0.0, "b": 0.0},
133
+ "irrecoverable": {"a": 0.0, "b": 0.0},
134
+ }
135
+ for cls in classes:
136
+ level = RECOVERABILITY.get(cls, "difficult")
137
+ if level not in totals_recov:
138
+ level = "difficult"
139
+ totals_recov[level]["a"] += prop_a[cls]
140
+ totals_recov[level]["b"] += prop_b[cls]
141
+
142
+ return {
143
+ "engine_a": engine_a_name,
144
+ "engine_b": engine_b_name,
145
+ "total_a": total_a,
146
+ "total_b": total_b,
147
+ "classes": classes,
148
+ "proportions_a": prop_a,
149
+ "proportions_b": prop_b,
150
+ "deltas": deltas,
151
+ "recoverability": {
152
+ cls: RECOVERABILITY.get(cls, "difficult") for cls in classes
153
+ },
154
+ "totals_by_recoverability": totals_recov,
155
+ }
156
+
157
+
158
+ __all__ = [
159
+ "RECOVERABILITY",
160
+ "compare_taxonomies",
161
+ ]
picarones/report/i18n/en.json CHANGED
@@ -250,5 +250,11 @@
250
  "taxocooc_jaccard_label": "Jaccard",
251
  "intradoc_title": "Intra-document evolution of error classes",
252
  "intradoc_note": "Heatmap class × position: relative density per class (darker = concentrated). A class concentrated in the first column suggests a margin error; a uniform distribution suggests a scribe error.",
253
- "intradoc_n_words": "Computed on {n_words_gt} GT words, split into {n_bins} bins."
 
 
 
 
 
 
254
  }
 
250
  "taxocooc_jaccard_label": "Jaccard",
251
  "intradoc_title": "Intra-document evolution of error classes",
252
  "intradoc_note": "Heatmap class × position: relative density per class (darker = concentrated). A class concentrated in the first column suggests a margin error; a uniform distribution suggests a scribe error.",
253
+ "intradoc_n_words": "Computed on {n_words_gt} GT words, split into {n_bins} bins.",
254
+ "taxocomp_title": "Taxonomic profile: {engine_a} vs {engine_b}",
255
+ "taxocomp_note": "Mirror chart of error proportions per class. Color by editorial recoverability (green = correctable, red = irrecoverable). At equal global CER, an engine whose errors are mostly green is preferable for a critical edition.",
256
+ "taxocomp_level_label": "Category",
257
+ "taxocomp_recoverable": "Recoverable",
258
+ "taxocomp_difficult": "Difficult",
259
+ "taxocomp_irrecoverable": "Irrecoverable"
260
  }
picarones/report/i18n/fr.json CHANGED
@@ -250,5 +250,11 @@
250
  "taxocooc_jaccard_label": "Jaccard",
251
  "intradoc_title": "Évolution intra-document des classes d'erreur",
252
  "intradoc_note": "Heatmap class × position : densité relative par classe (plus foncé = concentré). Une classe concentrée dans la première colonne suggère une erreur de marge ; une distribution uniforme suggère une erreur de scribe.",
253
- "intradoc_n_words": "Calculé sur {n_words_gt} mots GT, répartis en {n_bins} tranches."
 
 
 
 
 
 
254
  }
 
250
  "taxocooc_jaccard_label": "Jaccard",
251
  "intradoc_title": "Évolution intra-document des classes d'erreur",
252
  "intradoc_note": "Heatmap class × position : densité relative par classe (plus foncé = concentré). Une classe concentrée dans la première colonne suggère une erreur de marge ; une distribution uniforme suggère une erreur de scribe.",
253
+ "intradoc_n_words": "Calculé sur {n_words_gt} mots GT, répartis en {n_bins} tranches.",
254
+ "taxocomp_title": "Profil taxonomique : {engine_a} vs {engine_b}",
255
+ "taxocomp_note": "Diagramme miroir des proportions d'erreurs par classe. Couleur selon récupérabilité éditoriale (vert = corrigeable, rouge = irrécupérable). À CER global égal, un moteur dont les erreurs sont majoritairement vertes est préférable pour une édition critique.",
256
+ "taxocomp_level_label": "Catégorie",
257
+ "taxocomp_recoverable": "Récupérable",
258
+ "taxocomp_difficult": "Difficile",
259
+ "taxocomp_irrecoverable": "Irrécupérable"
260
  }
picarones/report/taxonomy_comparison_render.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML du diagramme miroir taxonomique — Sprint 77.
2
+
3
+ A.I.4 chantier 3 du plan d'évolution 2026.
4
+
5
+ Suite directe ``picarones/core/taxonomy_comparison.py``. Pattern
6
+ identique aux autres rendus (Sprints 41/43/62/67/72/74/75/76) :
7
+ **server-side**, pas de JavaScript, anti-injection systématique.
8
+
9
+ Diagramme miroir
10
+ ----------------
11
+ Une ligne par classe taxonomique, divisée en deux barres
12
+ horizontales :
13
+
14
+ - À **gauche** : barre du moteur A (orientée vers la gauche, du
15
+ centre vers le bord).
16
+ - À **droite** : barre du moteur B (orientée vers la droite).
17
+ - Couleur de la classe selon ``recoverability`` :
18
+
19
+ - vert (#5fa860) : ``recoverable``
20
+ - orange (#e0a050) : ``difficult``
21
+ - rouge (#d8553b) : ``irrecoverable``
22
+
23
+ Lecture immédiate : un moteur dont les barres tirent vers la
24
+ **gauche** sur du vert (case_error, ligature_error) et un moteur
25
+ qui tire à droite sur du rouge (lacuna) — la décision éditoriale
26
+ est évidente même si les CER globaux sont identiques.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from html import escape as _e
32
+ from typing import Optional
33
+
34
+
35
+ _RECOVERABILITY_COLORS = {
36
+ "recoverable": "#5fa860",
37
+ "difficult": "#e0a050",
38
+ "irrecoverable": "#d8553b",
39
+ }
40
+
41
+
42
+ def _build_mirror_chart_svg(
43
+ data: dict,
44
+ *,
45
+ bar_max_width: int = 200,
46
+ row_height: int = 22,
47
+ label_width: int = 140,
48
+ margin_top: int = 50,
49
+ margin_bottom: int = 20,
50
+ ) -> str:
51
+ """Construit le diagramme miroir SVG."""
52
+ classes = data["classes"]
53
+ prop_a = data["proportions_a"]
54
+ prop_b = data["proportions_b"]
55
+ recov = data["recoverability"]
56
+ engine_a = data["engine_a"]
57
+ engine_b = data["engine_b"]
58
+
59
+ n_rows = len(classes)
60
+ if n_rows == 0:
61
+ return ""
62
+
63
+ # Échelle : on normalise à la valeur max de toutes les
64
+ # proportions (pour que la classe la plus présente atteigne
65
+ # bar_max_width).
66
+ max_prop = max(
67
+ max(prop_a.values(), default=0.0),
68
+ max(prop_b.values(), default=0.0),
69
+ )
70
+ if max_prop <= 0:
71
+ max_prop = 1.0 # évite division par zéro (cas dégénéré)
72
+
73
+ width = label_width + 2 * bar_max_width + 40
74
+ height = margin_top + n_rows * row_height + margin_bottom
75
+ center = width // 2
76
+
77
+ parts = [
78
+ f'<svg xmlns="http://www.w3.org/2000/svg" '
79
+ f'width="{width}" height="{height}" '
80
+ f'viewBox="0 0 {width} {height}" '
81
+ f'role="img" aria-label="Diagramme miroir taxonomique">',
82
+ # En-têtes des deux moteurs
83
+ f'<text x="{center - bar_max_width // 2}" y="20" '
84
+ f'font-size="13" font-weight="600" fill="#333" '
85
+ f'text-anchor="middle">{_e(engine_a)}</text>',
86
+ f'<text x="{center + bar_max_width // 2}" y="20" '
87
+ f'font-size="13" font-weight="600" fill="#333" '
88
+ f'text-anchor="middle">{_e(engine_b)}</text>',
89
+ # Ligne centrale
90
+ f'<line x1="{center}" y1="{margin_top - 4}" '
91
+ f'x2="{center}" y2="{height - margin_bottom + 4}" '
92
+ f'stroke="#999" stroke-width="1"/>',
93
+ ]
94
+
95
+ # Barres
96
+ for i, cls in enumerate(classes):
97
+ y = margin_top + i * row_height
98
+ level = recov.get(cls, "difficult")
99
+ color = _RECOVERABILITY_COLORS.get(level, "#888")
100
+ # Étiquette de classe au centre
101
+ parts.append(
102
+ f'<text x="{center}" y="{y + row_height // 2 + 4}" '
103
+ f'font-size="11" fill="#222" text-anchor="middle" '
104
+ f'font-family="monospace">{_e(cls)}</text>'
105
+ )
106
+ # Barre A (gauche)
107
+ a_width = (prop_a.get(cls, 0.0) / max_prop) * bar_max_width
108
+ if a_width > 0:
109
+ x_a = center - label_width // 2 - a_width
110
+ parts.append(
111
+ f'<rect x="{x_a:.1f}" y="{y + 3}" '
112
+ f'width="{a_width:.1f}" height="{row_height - 6}" '
113
+ f'fill="{color}" stroke="#666" stroke-width="0.5" '
114
+ f'opacity="0.85"/>'
115
+ )
116
+ # Valeur en %
117
+ parts.append(
118
+ f'<text x="{x_a - 3:.1f}" y="{y + row_height // 2 + 4}" '
119
+ f'font-size="10" fill="#444" text-anchor="end">'
120
+ f'{prop_a.get(cls, 0.0) * 100:.1f}%</text>'
121
+ )
122
+ # Barre B (droite)
123
+ b_width = (prop_b.get(cls, 0.0) / max_prop) * bar_max_width
124
+ if b_width > 0:
125
+ x_b = center + label_width // 2
126
+ parts.append(
127
+ f'<rect x="{x_b:.1f}" y="{y + 3}" '
128
+ f'width="{b_width:.1f}" height="{row_height - 6}" '
129
+ f'fill="{color}" stroke="#666" stroke-width="0.5" '
130
+ f'opacity="0.85"/>'
131
+ )
132
+ parts.append(
133
+ f'<text x="{x_b + b_width + 3:.1f}" '
134
+ f'y="{y + row_height // 2 + 4}" '
135
+ f'font-size="10" fill="#444" text-anchor="start">'
136
+ f'{prop_b.get(cls, 0.0) * 100:.1f}%</text>'
137
+ )
138
+ parts.append("</svg>")
139
+ return "".join(parts)
140
+
141
+
142
+ def _build_recoverability_summary_html(
143
+ data: dict, labels: dict,
144
+ ) -> str:
145
+ """Encart résumé par catégorie de récupérabilité (3 lignes)."""
146
+ totals = data.get("totals_by_recoverability") or {}
147
+ if not totals:
148
+ return ""
149
+ label_recov = labels.get("taxocomp_recoverable", "Récupérable")
150
+ label_diff = labels.get("taxocomp_difficult", "Difficile")
151
+ label_irrec = labels.get("taxocomp_irrecoverable", "Irrécupérable")
152
+ rows = [
153
+ ("recoverable", label_recov),
154
+ ("difficult", label_diff),
155
+ ("irrecoverable", label_irrec),
156
+ ]
157
+ parts = [
158
+ '<table style="border-collapse:collapse;font-size:.85rem;'
159
+ 'margin-top:.5rem">',
160
+ '<thead><tr>',
161
+ '<th style="padding:.2rem .5rem;text-align:left;'
162
+ 'border-bottom:1px solid #ccc">'
163
+ f'{_e(labels.get("taxocomp_level_label", "Catégorie"))}</th>',
164
+ '<th style="padding:.2rem .5rem;text-align:right;'
165
+ 'border-bottom:1px solid #ccc">'
166
+ f'{_e(_e(data["engine_a"]))}</th>',
167
+ '<th style="padding:.2rem .5rem;text-align:right;'
168
+ 'border-bottom:1px solid #ccc">'
169
+ f'{_e(_e(data["engine_b"]))}</th>',
170
+ '</tr></thead><tbody>',
171
+ ]
172
+ for level, label in rows:
173
+ cell = totals.get(level, {"a": 0.0, "b": 0.0})
174
+ color = _RECOVERABILITY_COLORS.get(level, "#888")
175
+ parts.append(
176
+ f'<tr>'
177
+ f'<td style="padding:.2rem .5rem">'
178
+ f'<span style="display:inline-block;width:10px;height:10px;'
179
+ f'background:{color};margin-right:.4rem;border-radius:2px"></span>'
180
+ f'{_e(label)}</td>'
181
+ f'<td style="padding:.2rem .5rem;text-align:right;'
182
+ f'font-family:monospace">{cell["a"] * 100:.1f}%</td>'
183
+ f'<td style="padding:.2rem .5rem;text-align:right;'
184
+ f'font-family:monospace">{cell["b"] * 100:.1f}%</td>'
185
+ f'</tr>'
186
+ )
187
+ parts.append("</tbody></table>")
188
+ return "".join(parts)
189
+
190
+
191
+ def build_taxonomy_comparison_html(
192
+ data: Optional[dict],
193
+ labels: Optional[dict[str, str]] = None,
194
+ ) -> str:
195
+ """Construit le bloc HTML de comparaison taxonomique entre 2 moteurs.
196
+
197
+ Retourne ``""`` si ``data is None`` ou aucune classe.
198
+ """
199
+ if not data:
200
+ return ""
201
+ classes = data.get("classes") or []
202
+ if not classes:
203
+ return ""
204
+ labels = labels or {}
205
+ title_template = labels.get(
206
+ "taxocomp_title", "Profil taxonomique : {engine_a} vs {engine_b}",
207
+ )
208
+ title = title_template.format(
209
+ engine_a=data["engine_a"], engine_b=data["engine_b"],
210
+ )
211
+ note = labels.get(
212
+ "taxocomp_note",
213
+ "Diagramme miroir des proportions d'erreurs par classe. "
214
+ "Couleur selon récupérabilité éditoriale (vert = corrigeable, "
215
+ "rouge = irrécupérable). À CER global égal, un moteur dont les "
216
+ "erreurs sont majoritairement vertes est préférable pour une "
217
+ "édition critique.",
218
+ )
219
+ parts = [
220
+ '<div class="taxocomp" style="margin:1rem 0">',
221
+ f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
222
+ f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
223
+ f'{_e(note)}</div>',
224
+ _build_mirror_chart_svg(data),
225
+ _build_recoverability_summary_html(data, labels),
226
+ "</div>",
227
+ ]
228
+ return "".join(parts)
229
+
230
+
231
+ __all__ = [
232
+ "build_taxonomy_comparison_html",
233
+ ]
tests/test_sprint77_taxonomy_comparison.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests Sprint 77 — A.I.4 chantier 3 : taxonomie comparative.
2
+
3
+ Couvre :
4
+
5
+ 1. ``compare_taxonomies`` :
6
+ - Proportions correctement normalisées (somme = 1)
7
+ - Deltas signés (b - a)
8
+ - Catégorisation par récupérabilité
9
+ - Cas dégénéré : deux comptes vides → None
10
+ - Classes apparaissant chez un seul moteur
11
+ - Totaux par récupérabilité
12
+ 2. Rendu HTML :
13
+ - Diagramme miroir SVG bien formé
14
+ - Tableau récupérabilité présent
15
+ - "" si data None
16
+ - "" si classes vides
17
+ 3. Anti-injection : noms moteurs avec ``<script>``.
18
+ 4. Complétude i18n FR/EN.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from pathlib import Path
25
+
26
+ from picarones.core.taxonomy_comparison import (
27
+ RECOVERABILITY,
28
+ compare_taxonomies,
29
+ )
30
+ from picarones.report.taxonomy_comparison_render import (
31
+ build_taxonomy_comparison_html,
32
+ )
33
+
34
+
35
+ # ──────────────────────────────────────────────────────────────────────────
36
+ # 1. compare_taxonomies
37
+ # ──────────────────────────────────────────────────────────────────────────
38
+
39
+
40
+ class TestCompare:
41
+ def test_proportions_sum_to_one(self) -> None:
42
+ result = compare_taxonomies(
43
+ "A", {"case_error": 8, "lacuna": 2},
44
+ "B", {"case_error": 1, "lacuna": 9},
45
+ )
46
+ assert result is not None
47
+ assert sum(result["proportions_a"].values()) == 1.0
48
+ assert sum(result["proportions_b"].values()) == 1.0
49
+
50
+ def test_deltas_signed(self) -> None:
51
+ result = compare_taxonomies(
52
+ "A", {"case_error": 8, "lacuna": 2},
53
+ "B", {"case_error": 2, "lacuna": 8},
54
+ )
55
+ # B a plus de lacuna, moins de case_error
56
+ assert result["deltas"]["lacuna"] > 0
57
+ assert result["deltas"]["case_error"] < 0
58
+
59
+ def test_recoverability_categorization(self) -> None:
60
+ result = compare_taxonomies(
61
+ "A", {"case_error": 10}, # 100% recoverable
62
+ "B", {"lacuna": 10}, # 100% irrecoverable
63
+ )
64
+ totals = result["totals_by_recoverability"]
65
+ assert totals["recoverable"]["a"] == 1.0
66
+ assert totals["irrecoverable"]["b"] == 1.0
67
+ assert totals["recoverable"]["b"] == 0.0
68
+ assert totals["irrecoverable"]["a"] == 0.0
69
+
70
+ def test_returns_none_when_both_empty(self) -> None:
71
+ assert compare_taxonomies("A", {}, "B", {}) is None
72
+ assert compare_taxonomies("A", {"case_error": 0}, "B", {}) is None
73
+
74
+ def test_class_in_only_one_engine(self) -> None:
75
+ result = compare_taxonomies(
76
+ "A", {"case_error": 5},
77
+ "B", {"lacuna": 5, "case_error": 5},
78
+ )
79
+ # case_error présent chez les deux
80
+ assert result["proportions_a"]["case_error"] == 1.0
81
+ assert result["proportions_a"]["lacuna"] == 0.0
82
+ assert result["proportions_b"]["lacuna"] == 0.5
83
+
84
+ def test_totals_a_and_b_correct(self) -> None:
85
+ result = compare_taxonomies(
86
+ "A", {"case_error": 7, "lacuna": 3},
87
+ "B", {"case_error": 2, "lacuna": 8},
88
+ )
89
+ assert result["total_a"] == 10
90
+ assert result["total_b"] == 10
91
+
92
+ def test_recoverability_constant_complete(self) -> None:
93
+ # Sanité : RECOVERABILITY couvre toutes les classes du module
94
+ from picarones.core.taxonomy import ERROR_CLASSES
95
+ for cls in ERROR_CLASSES:
96
+ assert cls in RECOVERABILITY
97
+
98
+
99
+ # ──────────────────────────────────────────────────────────────────────────
100
+ # 2. Rendu HTML
101
+ # ──────────────────────────────────────────────────────────────────────────
102
+
103
+
104
+ class TestRender:
105
+ def test_returns_empty_when_none(self) -> None:
106
+ assert build_taxonomy_comparison_html(None) == ""
107
+
108
+ def test_renders_svg(self) -> None:
109
+ data = compare_taxonomies(
110
+ "Tesseract", {"case_error": 8, "lacuna": 2},
111
+ "Pero", {"case_error": 2, "lacuna": 8},
112
+ )
113
+ html = build_taxonomy_comparison_html(data)
114
+ assert "<svg" in html
115
+ assert "</svg>" in html
116
+
117
+ def test_engine_names_displayed(self) -> None:
118
+ data = compare_taxonomies(
119
+ "Tesseract", {"case_error": 5},
120
+ "Pero", {"lacuna": 5},
121
+ )
122
+ html = build_taxonomy_comparison_html(data)
123
+ assert "Tesseract" in html
124
+ assert "Pero" in html
125
+
126
+ def test_class_labels_present(self) -> None:
127
+ data = compare_taxonomies(
128
+ "A", {"case_error": 5},
129
+ "B", {"lacuna": 5},
130
+ )
131
+ html = build_taxonomy_comparison_html(data)
132
+ assert "case_error" in html
133
+ assert "lacuna" in html
134
+
135
+ def test_recoverability_summary_present(self) -> None:
136
+ data = compare_taxonomies(
137
+ "A", {"case_error": 5},
138
+ "B", {"lacuna": 5},
139
+ )
140
+ html = build_taxonomy_comparison_html(data)
141
+ assert "Récupérable" in html
142
+ assert "Irrécupérable" in html
143
+
144
+ def test_proportions_displayed(self) -> None:
145
+ data = compare_taxonomies(
146
+ "A", {"case_error": 8, "lacuna": 2},
147
+ "B", {"case_error": 2, "lacuna": 8},
148
+ )
149
+ html = build_taxonomy_comparison_html(data)
150
+ # 80.0% présent dans le SVG (proportion case_error de A)
151
+ assert "80.0%" in html
152
+
153
+ def test_color_codes_present(self) -> None:
154
+ data = compare_taxonomies(
155
+ "A", {"case_error": 5}, # recoverable → vert
156
+ "B", {"lacuna": 5}, # irrecoverable → rouge
157
+ )
158
+ html = build_taxonomy_comparison_html(data)
159
+ assert "#5fa860" in html # vert
160
+ assert "#d8553b" in html # rouge
161
+
162
+
163
+ # ──────────────────────────────────────────────────────────────────────────
164
+ # 3. Anti-injection
165
+ # ──────────────────────────────────────────────────────────────────────────
166
+
167
+
168
+ class TestAntiInjection:
169
+ def test_engine_name_escaped(self) -> None:
170
+ data = compare_taxonomies(
171
+ "<script>alert(1)</script>", {"case_error": 5},
172
+ "Pero", {"lacuna": 5},
173
+ )
174
+ html = build_taxonomy_comparison_html(data)
175
+ assert "<script>alert" not in html
176
+ assert "&lt;script&gt;" in html
177
+
178
+ def test_label_via_i18n_escaped(self) -> None:
179
+ data = compare_taxonomies(
180
+ "A", {"case_error": 5}, "B", {"lacuna": 5},
181
+ )
182
+ labels = {"taxocomp_recoverable": "<b>Hack</b>"}
183
+ html = build_taxonomy_comparison_html(data, labels=labels)
184
+ assert "<b>Hack</b>" not in html
185
+ assert "&lt;b&gt;Hack&lt;/b&gt;" in html
186
+
187
+
188
+ # ──────────────────────────────────────────────────────────────────────────
189
+ # 4. Complétude i18n
190
+ # ──────────────────────────────────────────────────────────────────────────
191
+
192
+
193
+ class TestI18nCompleteness:
194
+ def _load(self, lang: str) -> dict:
195
+ path = (
196
+ Path(__file__).parent.parent
197
+ / "picarones" / "report" / "i18n" / f"{lang}.json"
198
+ )
199
+ return json.loads(path.read_text(encoding="utf-8"))
200
+
201
+ def test_all_keys_fr(self) -> None:
202
+ d = self._load("fr")
203
+ for key in (
204
+ "taxocomp_title", "taxocomp_note", "taxocomp_level_label",
205
+ "taxocomp_recoverable", "taxocomp_difficult",
206
+ "taxocomp_irrecoverable",
207
+ ):
208
+ assert key in d, f"manque clé FR : {key}"
209
+
210
+ def test_all_keys_en(self) -> None:
211
+ d_fr = self._load("fr")
212
+ d_en = self._load("en")
213
+ for key in d_fr:
214
+ if key.startswith("taxocomp_"):
215
+ assert key in d_en, f"manque clé EN : {key}"