Claude commited on
Commit
8588daf
·
unverified ·
1 Parent(s): e88e70e

sprint89: A.II.8b score de spécialisation inter-moteurs (calcul + HTML)

Browse files

La matrice de divergence taxonomique (Sprint 35) répondait à
"à quel point ces moteurs se trompent-ils différemment ?".
Ce sprint transforme cette information en un score lisible
et un top-N des paires les plus spécialisées.

Le module ne recommande PAS d'ensemble — observation factuelle.

picarones/core/specialization.py :
- compute_specialization_score : délégué à JS divergence Sprint 35.
- classify_specialization : similar < 0.10, distinct 0.10-0.30,
highly_specialized ≥ 0.30 (seuils éditoriaux surchargeables).
- compute_specialization_matrix : symétrique + max_pair.
- top_specialized_pairs : tri décroissant, n cap, min_score filter.

picarones/report/specialization_render.py : tableau
Moteur A × Moteur B × Score (gradient blanc → bleu profond)
× Lecture (libellé i18n). Adaptive : "" si < 2 moteurs.

Câblage generator : lit aggregated_taxonomy de chaque moteur,
construit la map {engine: counts}. Insertion view_analyses.html.

9 clés i18n FR/EN. 24 tests dans test_sprint89_specialization.py
couvrant symétrie + bornes [0,1], classify 5 cas dont custom,
matrice diagonale 0 + max_pair, top_pairs tri/n/min_score/None,
rendu adaptive + anti-injection + FR/EN, complétude i18n 9 clés.

Tests : 2923 passed, 2 skipped.

https://claude.ai/code/session_01RusTQYcSfXqTsbFNvwmCV7

CHANGELOG.md CHANGED
@@ -16,6 +16,50 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
16
 
17
  ### Ajouté
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  - **Sprint 88 — A.I.8 vue HTML : déficit projeté de robustesse
20
  (clôture A.I.8 bout-en-bout).** Le module
21
  `picarones/core/robustness_projection.py` (Sprint 81)
 
16
 
17
  ### Ajouté
18
 
19
+ - **Sprint 89 — A.II.8b : score de spécialisation inter-moteurs
20
+ (couche calcul + vue HTML).** La matrice de divergence
21
+ taxonomique (Sprint 35) répondait à *« à quel point ces
22
+ moteurs se trompent-ils différemment ? »* ; ce sprint
23
+ transforme cette information en un score lisible et un
24
+ **top-N des paires les plus spécialisées**, qui répond
25
+ directement à la question *« quels moteurs sont des candidats
26
+ pour un voting ensemble ? »*. Le module **ne recommande
27
+ pas** d'ensemble — il livre l'observation factuelle et
28
+ laisse le chercheur arbitrer. Nouveau module
29
+ `picarones/core/specialization.py` :
30
+ `compute_specialization_score(taxonomy_a, taxonomy_b)`
31
+ retourne un score normalisé ∈ [0, 1] (délégué à
32
+ `inter_engine.jensen_shannon_divergence` Sprint 35, pas de
33
+ double calcul) ;
34
+ `classify_specialization(score, thresholds=DEFAULT_THRESHOLDS)`
35
+ classe en `similar` (< 0,10) / `distinct` (0,10–0,30) /
36
+ `highly_specialized` (≥ 0,30) — seuils éditoriaux pas
37
+ verdict, surchargeables ;
38
+ `compute_specialization_matrix(taxonomies)` retourne une
39
+ matrice symétrique avec `max_pair` ;
40
+ `top_specialized_pairs(matrix, n=5, min_score=0)` retourne
41
+ les paires triées par score décroissant avec leur catégorie.
42
+ Nouveau module `picarones/report/specialization_render.py` :
43
+ `build_specialization_html(taxonomies, labels, top_n=5)`
44
+ rend un tableau Moteur A × Moteur B × Score (gradient blanc
45
+ → bleu profond) × Lecture (libellé i18n). Adaptive : `""`
46
+ si moins de 2 moteurs avec taxonomie. Anti-injection.
47
+ Câblage générator : lit les `aggregated_taxonomy` exposés
48
+ sur les moteurs (Sprint 5/runner historique), construit la
49
+ map `{engine: counts}` et passe au renderer. Insertion dans
50
+ `view_analyses.html` derrière la lisibilité. +9 clés i18n
51
+ FR/EN (`specialization_*`). +24 tests dans
52
+ `test_sprint89_specialization.py` (score symétrique +
53
+ identité 0 + disjoint 1 + bornes [0,1], classify 5 cas dont
54
+ custom thresholds, matrice diagonale 0 + symétrique +
55
+ max_pair correctement identifié, top_pairs tri/n/min_score/
56
+ None, rendu adaptive + anti-injection + FR/EN, complétude
57
+ i18n 9 clés). **Verrou levé** : un benchmark BnF avec ≥ 2
58
+ moteurs voit immédiatement *« tess et pero ont une
59
+ spécialisation forte (0,489) — ils font des erreurs de
60
+ natures différentes »* — observation factuelle, le
61
+ chercheur arbitre.
62
+
63
  - **Sprint 88 — A.I.8 vue HTML : déficit projeté de robustesse
64
  (clôture A.I.8 bout-en-bout).** Le module
65
  `picarones/core/robustness_projection.py` (Sprint 81)
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
  | 88 | **Sprint 57 du plan d'évolution 2026 — A.I.8 vue HTML : déficit projeté de robustesse (clôture A.I.8 bout-en-bout)**. Le module `picarones/core/robustness_projection.py` (Sprint 81) calculait la projection des courbes de dégradation synthétique sur les caractéristiques d'image réelles ; ce sprint livre la **vue HTML**. La robustesse étant un workflow CLI séparé (`picarones robustness`) et non intégré au benchmark principal, ce sprint livre un **module de rendu pur** que l'utilisateur compose lui-même (`analyze_robustness` → `project_robustness_on_corpus` → `aggregate_projection_per_engine` → `build_robustness_projection_html`). Nouveau module `picarones/report/robustness_projection_render.py` : **deux tableaux** — (1) **Résumé par moteur** (déficit total avec gradient vert→orange→rouge sur ±5 pts, n types évalués, pire dégradation avec sa contribution, trié par déficit décroissant) ; (2) **Détail (moteur × dégradation)** (docs, docs avec data, déficit projeté coloré, docs au-dessus du seuil critique). Si `aggregated` non fourni, calculé automatiquement. Adaptive : `""` si projection vide. Anti-injection systématique. Note explicite que la sommation suppose l'indépendance des dégradations *« approximation utile pour le diagnostic, pas un verdict »*. +13 clés i18n FR/EN (`robproj_*`). +12 tests dans `test_sprint88_robustness_projection_html.py` (rendu vide/None, rendu complet, calcul automatique de l'agrégation, tri par déficit décroissant, formatage « pire dégradation », gestion déficit None → cellule —, anti-injection nom moteur + type dégradation, rendu FR + EN, **bout-en-bout** avec le pipeline réel `project_robustness_on_corpus` + `aggregate_projection_per_engine`, complétude i18n 13 clés). **Verrou levé** : A.I.8 livrée bout-en-bout (calcul Sprint 81 + vue HTML Sprint 88) — un benchmark BnF qui veut savoir *« mon corpus de notaires XVIIᵉ siècle est-il à risque face à mon moteur OCR ? »* obtient un tableau lisible directement intégrable dans le rapport. |
211
  | 87 | **Sprint 56 du plan d'évolution 2026 — A.II.2 (delta Flesch) câblé bout-en-bout : runner adaptive + vue HTML « Lisibilité »**. Le module `picarones/core/readability.py` (Sprint 52) calculait le delta Flesch *« over-normalisation par LLM »* — ce sprint le remonte automatiquement dans le rapport. Helper `picarones/core/readability_runner.py` : `compute_readability_metrics(reference, hypothesis, lang)` avec **adaptive masking ≥ 5 mots GT** (Flesch instable sur très courts textes) ; `aggregate_readability_metrics` retourne `{lang, n_docs, n_docs_with_delta, delta_mean/median/min/max, n_over_normalized, n_under_normalized, over_normalized_rate}` — over-norm défini à Δ > +5 (LLM modernise un texte ancien), under-norm à Δ < -5 (dégradation OCR brutale). `DocumentResult.readability_metrics` + `EngineReport.aggregated_readability` (sérialisation conditionnelle, libérés par `compact`). Câblage runner : langue lue depuis `corpus.metadata.get("language", "fr")`, fallback fr avec warning si valeur non `fr`/`en`, paramètre `corpus_lang` propagé jusqu'aux workers IO et CPU (workers acceptent 7 ou 8 args en mode legacy pour rétrocompat). Erreur isolée par try/except + warning. Module de rendu `picarones/report/readability_render.py` : tableau résumé moteur × {Δ moyen coloré (vert au centre, orange si over-norm, bleu si under-norm), Δ médian, % over-normalisés, docs under-normalisés, docs} ; saturation à ±15 points. Insertion dans `view_analyses.html` derrière les blocs A.II.5. Anti-injection systématique. +8 clés i18n FR/EN. +20 tests dans `test_sprint87_readability_html.py` (adaptive masking GT < 5 mots, langue fr/en, hypothèse vide → flesch_delta None mais flesch_reference conservé, agrégation moyenne + over-norm rate, sérialisation `DocumentResult`/`EngineReport`, `compact`, masquage adaptatif HTML, rendu FR + EN, anti-injection, complétude i18n 8 clés). **Verrou levé** : le rapport remonte désormais *« GPT-4o : Δ moyen +11,5, 85 % des docs over-normalisés »* directement dans la vue Analyses — métrique critique pour repérer les VLM hallucinant du français moderne sur du français médiéval. Reste pour A.II.2 bout-en-bout : `reading_order_f1` et `layout_f1` (Sprints 53-54), qui requièrent un moteur produisant PAGE/ALTO et seront câblés via les pipelines composées (axe B). |
212
  | 86 | **Sprint 55 du plan d'évolution 2026 — A.II.5 : câblage runner adaptive + vues HTML (clôture A.II.5 bout-en-bout)**. Suite directe Sprints 84+85 — la couche de calcul livrait deux modules pour le mode plein-texte patrimonial, ce sprint les remonte automatiquement dans le rapport. Deux helpers `picarones/core/searchability_runner.py` et `picarones/core/numerical_sequences_runner.py` calculent les métriques par document avec **adaptive masking** (rien n'apparaît pour un doc sans GT exploitable) et agrègent corpus-wide en *micro*-rappel pour searchability et somme par catégorie pour les séquences numériques. `DocumentResult` gagne `searchability_metrics` + `numerical_sequence_metrics` ; `EngineReport` gagne `aggregated_searchability` + `aggregated_numerical_sequences` (sérialisation conditionnelle, libérés par `compact`). Le runner historique calcule les deux inconditionnellement (coût négligeable face à l'OCR), erreur isolée par try/except + warning explicite, rétrocompat stricte. Deux modules de rendu `picarones/report/searchability_render.py` (tableau résumé moteur × {rappel coloré rouge→jaune→vert, retrouvés/total, docs}) et `picarones/report/numerical_sequences_render.py` (tableau moteur × catégorie {year/roman/foliation/currency/regnal} avec **adaptive masking par catégorie** — une catégorie sans signal est omise pour tous les moteurs ; chaque cellule affiche le score strict en gradient + la valeur entre parenthèses + n). Insertion dans `view_analyses.html` derrière le profil philologique, `chart-card` pleine largeur conditionné. Anti-injection systématique. +15 clés i18n FR/EN (`search_*`, `numseq_*`). +25 tests dans `test_sprint86_aii5_html.py` (adaptive masking helpers, agrégation micro-rappel, somme par catégorie, sérialisation `DocumentResult`/`EngineReport`, `compact` qui efface, masquage adaptatif HTML, rendu FR + EN, anti-injection sur nom moteur, complétude i18n 15 clés). **Verrou levé** : un benchmark BnF voit désormais sur la vue Analyses *« Recherchabilité fuzzy : tess 95,2 %, pero 87,8 % »* + le tableau séquences numériques détaillé par catégorie — A.II.5 livrée bout-en-bout (calcul Sprints 84-85, runner et HTML Sprint 86). |
@@ -306,7 +307,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
306
  ## Contexte développement
307
 
308
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
309
- - **Tests** : 2899 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 ; Sprint 78 = A.I.5 couche calcul ; Sprint 79 = A.I.6 couche calcul ; Sprint 80 = A.I.7 ; Sprint 81 = A.I.8 couche calcul ; Sprint 82 = A.I.9 — « Leviers d'amélioration » bout-en-bout ; Sprint 83 = A.II.4 — métriques de fiabilité (IAA Cohen κ + Krippendorff α + stabilité multi-runs, couche calcul) ; Sprint 84 = A.II.5a — recherchabilité fuzzy ; Sprint 85 = A.II.5b — précision séquences numériques ; Sprint 86 = A.II.5 bout-en-bout (câblage runner + vues HTML) ; Sprint 87 = A.II.2 (delta Flesch) câblé bout-en-bout ; **Sprint 88 = A.I.8 — vue HTML « Déficit projeté de robustesse » bout-en-bout**)
310
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
311
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
312
  - **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
+ | 89 | **Sprint 58 du plan d'évolution 2026 — A.II.8b : score de spécialisation inter-moteurs (couche calcul + vue HTML)**. La matrice de divergence taxonomique (Sprint 35) répondait à *« à quel point ces moteurs se trompent-ils différemment ? »* ; ce sprint transforme cette information en un score lisible et un **top-N des paires les plus spécialisées**, qui répond directement à la question *« quels moteurs sont des candidats pour un voting ensemble ? »*. Le module **ne recommande pas** d'ensemble — observation factuelle, le chercheur arbitre. Nouveau module `picarones/core/specialization.py` : `compute_specialization_score(taxonomy_a, taxonomy_b)` retourne un score normalisé ∈ [0, 1] (délégué à `inter_engine.jensen_shannon_divergence` Sprint 35, pas de double calcul) ; `classify_specialization(score)` classe en `similar` (< 0,10) / `distinct` (0,10–0,30) / `highly_specialized` (≥ 0,30) — seuils éditoriaux pas verdict, surchargeables ; `compute_specialization_matrix(taxonomies)` retourne matrice symétrique avec `max_pair` ; `top_specialized_pairs(matrix, n=5, min_score=0)` retourne paires triées par score décroissant + catégorie. Nouveau module `picarones/report/specialization_render.py` : `build_specialization_html` rend tableau Moteur A × Moteur B × Score (gradient blanc → bleu profond) × Lecture (libellé i18n). Adaptive : `""` si < 2 moteurs avec taxonomie. Anti-injection. Câblage générator : lit `aggregated_taxonomy` exposés sur les moteurs (Sprint 5/runner historique), construit map `{engine: counts}`. Insertion `view_analyses.html` derrière la lisibilité. +9 clés i18n FR/EN (`specialization_*`). +24 tests dans `test_sprint89_specialization.py` (score symétrique + identité 0 + disjoint 1 + bornes [0,1], classify 5 cas dont custom thresholds, matrice diagonale 0 + symétrique + max_pair correctement identifié, top_pairs tri/n/min_score/None, rendu adaptive + anti-injection + FR/EN, complétude i18n 9 clés). **Verrou levé** : un benchmark BnF avec ≥ 2 moteurs voit immédiatement *« tess et pero ont une spécialisation forte (0,489) — ils font des erreurs de natures différentes »* — observation factuelle. |
211
  | 88 | **Sprint 57 du plan d'évolution 2026 — A.I.8 vue HTML : déficit projeté de robustesse (clôture A.I.8 bout-en-bout)**. Le module `picarones/core/robustness_projection.py` (Sprint 81) calculait la projection des courbes de dégradation synthétique sur les caractéristiques d'image réelles ; ce sprint livre la **vue HTML**. La robustesse étant un workflow CLI séparé (`picarones robustness`) et non intégré au benchmark principal, ce sprint livre un **module de rendu pur** que l'utilisateur compose lui-même (`analyze_robustness` → `project_robustness_on_corpus` → `aggregate_projection_per_engine` → `build_robustness_projection_html`). Nouveau module `picarones/report/robustness_projection_render.py` : **deux tableaux** — (1) **Résumé par moteur** (déficit total avec gradient vert→orange→rouge sur ±5 pts, n types évalués, pire dégradation avec sa contribution, trié par déficit décroissant) ; (2) **Détail (moteur × dégradation)** (docs, docs avec data, déficit projeté coloré, docs au-dessus du seuil critique). Si `aggregated` non fourni, calculé automatiquement. Adaptive : `""` si projection vide. Anti-injection systématique. Note explicite que la sommation suppose l'indépendance des dégradations *« approximation utile pour le diagnostic, pas un verdict »*. +13 clés i18n FR/EN (`robproj_*`). +12 tests dans `test_sprint88_robustness_projection_html.py` (rendu vide/None, rendu complet, calcul automatique de l'agrégation, tri par déficit décroissant, formatage « pire dégradation », gestion déficit None → cellule —, anti-injection nom moteur + type dégradation, rendu FR + EN, **bout-en-bout** avec le pipeline réel `project_robustness_on_corpus` + `aggregate_projection_per_engine`, complétude i18n 13 clés). **Verrou levé** : A.I.8 livrée bout-en-bout (calcul Sprint 81 + vue HTML Sprint 88) — un benchmark BnF qui veut savoir *« mon corpus de notaires XVIIᵉ siècle est-il à risque face à mon moteur OCR ? »* obtient un tableau lisible directement intégrable dans le rapport. |
212
  | 87 | **Sprint 56 du plan d'évolution 2026 — A.II.2 (delta Flesch) câblé bout-en-bout : runner adaptive + vue HTML « Lisibilité »**. Le module `picarones/core/readability.py` (Sprint 52) calculait le delta Flesch *« over-normalisation par LLM »* — ce sprint le remonte automatiquement dans le rapport. Helper `picarones/core/readability_runner.py` : `compute_readability_metrics(reference, hypothesis, lang)` avec **adaptive masking ≥ 5 mots GT** (Flesch instable sur très courts textes) ; `aggregate_readability_metrics` retourne `{lang, n_docs, n_docs_with_delta, delta_mean/median/min/max, n_over_normalized, n_under_normalized, over_normalized_rate}` — over-norm défini à Δ > +5 (LLM modernise un texte ancien), under-norm à Δ < -5 (dégradation OCR brutale). `DocumentResult.readability_metrics` + `EngineReport.aggregated_readability` (sérialisation conditionnelle, libérés par `compact`). Câblage runner : langue lue depuis `corpus.metadata.get("language", "fr")`, fallback fr avec warning si valeur non `fr`/`en`, paramètre `corpus_lang` propagé jusqu'aux workers IO et CPU (workers acceptent 7 ou 8 args en mode legacy pour rétrocompat). Erreur isolée par try/except + warning. Module de rendu `picarones/report/readability_render.py` : tableau résumé moteur × {Δ moyen coloré (vert au centre, orange si over-norm, bleu si under-norm), Δ médian, % over-normalisés, docs under-normalisés, docs} ; saturation à ±15 points. Insertion dans `view_analyses.html` derrière les blocs A.II.5. Anti-injection systématique. +8 clés i18n FR/EN. +20 tests dans `test_sprint87_readability_html.py` (adaptive masking GT < 5 mots, langue fr/en, hypothèse vide → flesch_delta None mais flesch_reference conservé, agrégation moyenne + over-norm rate, sérialisation `DocumentResult`/`EngineReport`, `compact`, masquage adaptatif HTML, rendu FR + EN, anti-injection, complétude i18n 8 clés). **Verrou levé** : le rapport remonte désormais *« GPT-4o : Δ moyen +11,5, 85 % des docs over-normalisés »* directement dans la vue Analyses — métrique critique pour repérer les VLM hallucinant du français moderne sur du français médiéval. Reste pour A.II.2 bout-en-bout : `reading_order_f1` et `layout_f1` (Sprints 53-54), qui requièrent un moteur produisant PAGE/ALTO et seront câblés via les pipelines composées (axe B). |
213
  | 86 | **Sprint 55 du plan d'évolution 2026 — A.II.5 : câblage runner adaptive + vues HTML (clôture A.II.5 bout-en-bout)**. Suite directe Sprints 84+85 — la couche de calcul livrait deux modules pour le mode plein-texte patrimonial, ce sprint les remonte automatiquement dans le rapport. Deux helpers `picarones/core/searchability_runner.py` et `picarones/core/numerical_sequences_runner.py` calculent les métriques par document avec **adaptive masking** (rien n'apparaît pour un doc sans GT exploitable) et agrègent corpus-wide en *micro*-rappel pour searchability et somme par catégorie pour les séquences numériques. `DocumentResult` gagne `searchability_metrics` + `numerical_sequence_metrics` ; `EngineReport` gagne `aggregated_searchability` + `aggregated_numerical_sequences` (sérialisation conditionnelle, libérés par `compact`). Le runner historique calcule les deux inconditionnellement (coût négligeable face à l'OCR), erreur isolée par try/except + warning explicite, rétrocompat stricte. Deux modules de rendu `picarones/report/searchability_render.py` (tableau résumé moteur × {rappel coloré rouge→jaune→vert, retrouvés/total, docs}) et `picarones/report/numerical_sequences_render.py` (tableau moteur × catégorie {year/roman/foliation/currency/regnal} avec **adaptive masking par catégorie** — une catégorie sans signal est omise pour tous les moteurs ; chaque cellule affiche le score strict en gradient + la valeur entre parenthèses + n). Insertion dans `view_analyses.html` derrière le profil philologique, `chart-card` pleine largeur conditionné. Anti-injection systématique. +15 clés i18n FR/EN (`search_*`, `numseq_*`). +25 tests dans `test_sprint86_aii5_html.py` (adaptive masking helpers, agrégation micro-rappel, somme par catégorie, sérialisation `DocumentResult`/`EngineReport`, `compact` qui efface, masquage adaptatif HTML, rendu FR + EN, anti-injection sur nom moteur, complétude i18n 15 clés). **Verrou levé** : un benchmark BnF voit désormais sur la vue Analyses *« Recherchabilité fuzzy : tess 95,2 %, pero 87,8 % »* + le tableau séquences numériques détaillé par catégorie — A.II.5 livrée bout-en-bout (calcul Sprints 84-85, runner et HTML Sprint 86). |
 
307
  ## Contexte développement
308
 
309
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
310
+ - **Tests** : 2923 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 ; Sprint 78 = A.I.5 couche calcul ; Sprint 79 = A.I.6 couche calcul ; Sprint 80 = A.I.7 ; Sprint 81 = A.I.8 couche calcul ; Sprint 82 = A.I.9 — « Leviers d'amélioration » bout-en-bout ; Sprint 83 = A.II.4 — métriques de fiabilité (IAA Cohen κ + Krippendorff α + stabilité multi-runs, couche calcul) ; Sprint 84 = A.II.5a — recherchabilité fuzzy ; Sprint 85 = A.II.5b — précision séquences numériques ; Sprint 86 = A.II.5 bout-en-bout (câblage runner + vues HTML) ; Sprint 87 = A.II.2 (delta Flesch) câblé bout-en-bout ; Sprint 88 = A.I.8 — vue HTML « Déficit projeté de robustesse » bout-en-bout ; **Sprint 89 = A.II.8b — score de spécialisation inter-moteurs (couche calcul + vue HTML « Top paires spécialisées »)**)
311
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
312
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
313
  - **Transcript de la conversation de développement** :
picarones/core/specialization.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Score de spécialisation inter-moteurs — Sprint 89 (A.II.8b).
2
+
3
+ Sprint 89 — A.II.8b du plan d'évolution 2026.
4
+
5
+ Pourquoi ce module
6
+ ------------------
7
+ La matrice de divergence taxonomique (Sprint 35
8
+ ``inter_engine.taxonomy_divergence_matrix``) répond à *« à quel
9
+ point ces moteurs se trompent-ils différemment ? »*. Ce
10
+ sprint la transforme en un **score de spécialisation** lisible
11
+ et complète la lecture par :
12
+
13
+ - une **classification** discrète (similar / distinct /
14
+ highly_specialized) que le chercheur peut consommer sans
15
+ avoir à interpréter une distance ;
16
+ - un **top-N des paires** les plus spécialisées, qui répond
17
+ directement à la question *« quels moteurs sont les meilleurs
18
+ candidats pour un voting ensemble ? »*.
19
+
20
+ Ce module **ne recommande pas** de pipeline d'ensemble — il
21
+ fournit l'observation factuelle et laisse le chercheur arbitrer.
22
+
23
+ Convention de score
24
+ -------------------
25
+ On utilise la **Jensen-Shannon divergence** déjà calculée par
26
+ ``inter_engine.jensen_shannon_divergence`` : elle est
27
+ symétrique, bornée dans [0, 1], et son interprétation est
28
+ intuitive :
29
+
30
+ - ≈ 0 → profils taxonomiques identiques
31
+ - 1 → distributions totalement disjointes
32
+
33
+ Dépendances
34
+ -----------
35
+ S'appuie strictement sur ``picarones.core.inter_engine`` (Sprint
36
+ 35) — pas de double calcul, pas de logique nouvelle de
37
+ divergence.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import logging
43
+ from typing import Optional
44
+
45
+ from picarones.core.inter_engine import jensen_shannon_divergence
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+
50
+ # Seuils par convention éditoriale. La roadmap ne fixe rien :
51
+ # ces seuils sont des **guides de lecture**, pas des verdicts.
52
+ # Le chercheur peut les surcharger via ``classify_specialization``.
53
+ DEFAULT_THRESHOLDS = (
54
+ ("similar", 0.10),
55
+ ("distinct", 0.30),
56
+ ("highly_specialized", 1.01), # tout score ≥ 0.30
57
+ )
58
+
59
+
60
+ def compute_specialization_score(
61
+ taxonomy_a: dict[str, float],
62
+ taxonomy_b: dict[str, float],
63
+ ) -> float:
64
+ """Score de spécialisation entre deux moteurs ∈ [0, 1].
65
+
66
+ 0 = mêmes erreurs, 1 = erreurs totalement disjointes.
67
+ Délègue à ``jensen_shannon_divergence`` (Sprint 35).
68
+ """
69
+ return jensen_shannon_divergence(taxonomy_a, taxonomy_b)
70
+
71
+
72
+ def classify_specialization(
73
+ score: float,
74
+ thresholds: Optional[tuple[tuple[str, float], ...]] = None,
75
+ ) -> str:
76
+ """Classe le score en catégorie discrète.
77
+
78
+ Convention :
79
+ - score < 0.10 → ``similar``
80
+ - 0.10 ≤ score < 0.30 → ``distinct``
81
+ - score ≥ 0.30 → ``highly_specialized``
82
+
83
+ L'utilisateur peut passer ses propres ``thresholds`` (liste
84
+ triée par valeur croissante de tuples ``(label, max_score)``).
85
+ """
86
+ rules = thresholds or DEFAULT_THRESHOLDS
87
+ for label, max_score in rules:
88
+ if score < max_score:
89
+ return label
90
+ # Garde-fou : si aucun seuil ne match, dernière catégorie
91
+ return rules[-1][0]
92
+
93
+
94
+ def compute_specialization_matrix(
95
+ taxonomies: dict[str, dict[str, float]],
96
+ ) -> Optional[dict]:
97
+ """Matrice de spécialisation symétrique entre tous les moteurs.
98
+
99
+ Parameters
100
+ ----------
101
+ taxonomies:
102
+ Map ``{engine_name: {error_class: count_or_proportion}}``.
103
+
104
+ Returns
105
+ -------
106
+ dict | None
107
+ ``{
108
+ "engines": list[str],
109
+ "matrix": list[list[float]], # carrée, symétrique
110
+ "n_pairs": int, # paires distinctes
111
+ "max_score": float,
112
+ "max_pair": (str, str) | None,
113
+ }`` ; ``None`` si moins de 2 moteurs.
114
+ """
115
+ if not taxonomies or len(taxonomies) < 2:
116
+ return None
117
+ engines = sorted(taxonomies.keys())
118
+ n = len(engines)
119
+ matrix = [[0.0] * n for _ in range(n)]
120
+ n_pairs = 0
121
+ max_score = 0.0
122
+ max_pair: Optional[tuple[str, str]] = None
123
+ for i in range(n):
124
+ for j in range(i + 1, n):
125
+ score = compute_specialization_score(
126
+ taxonomies[engines[i]], taxonomies[engines[j]],
127
+ )
128
+ matrix[i][j] = score
129
+ matrix[j][i] = score
130
+ n_pairs += 1
131
+ if score > max_score:
132
+ max_score = score
133
+ max_pair = (engines[i], engines[j])
134
+ return {
135
+ "engines": engines,
136
+ "matrix": matrix,
137
+ "n_pairs": n_pairs,
138
+ "max_score": max_score,
139
+ "max_pair": max_pair,
140
+ }
141
+
142
+
143
+ def top_specialized_pairs(
144
+ matrix_data: Optional[dict],
145
+ n: int = 5,
146
+ *,
147
+ min_score: float = 0.0,
148
+ ) -> list[dict]:
149
+ """Top-N paires de moteurs triées par score décroissant.
150
+
151
+ Returns
152
+ -------
153
+ list[dict]
154
+ Une liste de ``{
155
+ "engine_a": str, "engine_b": str,
156
+ "score": float, "category": str,
157
+ }`` triée par score décroissant. Liste vide si
158
+ ``matrix_data`` est ``None`` ou que toutes les paires
159
+ sont sous ``min_score``.
160
+ """
161
+ if not matrix_data:
162
+ return []
163
+ engines = matrix_data["engines"]
164
+ matrix = matrix_data["matrix"]
165
+ pairs: list[dict] = []
166
+ for i, engine_a in enumerate(engines):
167
+ for j in range(i + 1, len(engines)):
168
+ score = matrix[i][j]
169
+ if score < min_score:
170
+ continue
171
+ pairs.append({
172
+ "engine_a": engine_a,
173
+ "engine_b": engines[j],
174
+ "score": score,
175
+ "category": classify_specialization(score),
176
+ })
177
+ pairs.sort(key=lambda p: -p["score"])
178
+ return pairs[:n]
179
+
180
+
181
+ __all__ = [
182
+ "DEFAULT_THRESHOLDS",
183
+ "compute_specialization_score",
184
+ "classify_specialization",
185
+ "compute_specialization_matrix",
186
+ "top_specialized_pairs",
187
+ ]
picarones/report/generator.py CHANGED
@@ -819,6 +819,28 @@ class ReportGenerator:
819
  report_data.get("engines", []), labels=labels,
820
  )
821
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
822
  env = _build_jinja_env()
823
  template = env.get_template("base.html.j2")
824
  html = template.render(
@@ -843,6 +865,7 @@ class ReportGenerator:
843
  searchability_html=searchability_html,
844
  numerical_sequences_html=numerical_sequences_html,
845
  readability_html=readability_html,
 
846
  )
847
 
848
  output_path.write_text(html, encoding="utf-8")
 
819
  report_data.get("engines", []), labels=labels,
820
  )
821
 
822
+ # Sprint 89 — A.II.8b : spécialisation inter-moteurs.
823
+ # Adaptive : "" si moins de 2 moteurs avec taxonomie.
824
+ from picarones.report.specialization_render import (
825
+ build_specialization_html,
826
+ )
827
+ # Construit une map {engine: counts} depuis les
828
+ # ``aggregated_taxonomy`` ; un moteur sans taxonomie
829
+ # est exclu.
830
+ _taxos: dict = {}
831
+ for eng in report_data.get("engines", []):
832
+ tax = eng.get("aggregated_taxonomy")
833
+ if isinstance(tax, dict):
834
+ counts = tax.get("counts") if "counts" in tax else tax
835
+ if isinstance(counts, dict) and counts:
836
+ _taxos[eng.get("name", "?")] = {
837
+ k: float(v) for k, v in counts.items()
838
+ if isinstance(v, (int, float))
839
+ }
840
+ specialization_html = build_specialization_html(
841
+ _taxos, labels=labels,
842
+ )
843
+
844
  env = _build_jinja_env()
845
  template = env.get_template("base.html.j2")
846
  html = template.render(
 
865
  searchability_html=searchability_html,
866
  numerical_sequences_html=numerical_sequences_html,
867
  readability_html=readability_html,
868
+ specialization_html=specialization_html,
869
  )
870
 
871
  output_path.write_text(html, encoding="utf-8")
picarones/report/i18n/en.json CHANGED
@@ -316,5 +316,14 @@
316
  "robproj_n_docs": "Docs",
317
  "robproj_n_with_data": "Docs with data",
318
  "robproj_deficit": "Projected ΔCER (pts)",
319
- "robproj_above": "Docs ≥ critical threshold"
 
 
 
 
 
 
 
 
 
320
  }
 
316
  "robproj_n_docs": "Docs",
317
  "robproj_n_with_data": "Docs with data",
318
  "robproj_deficit": "Projected ΔCER (pts)",
319
+ "robproj_above": "Docs ≥ critical threshold",
320
+ "specialization_title": "Inter-engine specialisation",
321
+ "specialization_note": "Jensen-Shannon divergence between the taxonomic profiles of each pair of engines (0 = identical profiles, 1 = fully disjoint). A highly specialised pair signals error categories of different natures — it is for the researcher to act on it, not for the tool to prescribe an ensemble.",
322
+ "specialization_engine_a": "Engine A",
323
+ "specialization_engine_b": "Engine B",
324
+ "specialization_score": "Score",
325
+ "specialization_category": "Reading",
326
+ "specialization_cat_similar": "Similar profiles",
327
+ "specialization_cat_distinct": "Distinct profiles",
328
+ "specialization_cat_highly_specialized": "Highly specialised"
329
  }
picarones/report/i18n/fr.json CHANGED
@@ -316,5 +316,14 @@
316
  "robproj_n_docs": "Docs",
317
  "robproj_n_with_data": "Docs avec data",
318
  "robproj_deficit": "Δ CER projeté (pts)",
319
- "robproj_above": "Docs ≥ seuil critique"
 
 
 
 
 
 
 
 
 
320
  }
 
316
  "robproj_n_docs": "Docs",
317
  "robproj_n_with_data": "Docs avec data",
318
  "robproj_deficit": "Δ CER projeté (pts)",
319
+ "robproj_above": "Docs ≥ seuil critique",
320
+ "specialization_title": "Spécialisation inter-moteurs",
321
+ "specialization_note": "Score de divergence Jensen-Shannon entre les profils taxonomiques de chaque paire de moteurs (0 = profils identiques, 1 = totalement disjoints). Une paire très spécialisée signale des erreurs de natures différentes — c'est au chercheur d'en tirer parti, pas à l'outil de prescrire un ensemble.",
322
+ "specialization_engine_a": "Moteur A",
323
+ "specialization_engine_b": "Moteur B",
324
+ "specialization_score": "Score",
325
+ "specialization_category": "Lecture",
326
+ "specialization_cat_similar": "Profils similaires",
327
+ "specialization_cat_distinct": "Profils distincts",
328
+ "specialization_cat_highly_specialized": "Forte spécialisation"
329
  }
picarones/report/specialization_render.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML « Spécialisation inter-moteurs » — Sprint 89
2
+ (A.II.8b).
3
+
4
+ Suite directe ``picarones/core/specialization.py``. Vue
5
+ **factuelle** sans recommandation : on liste les paires de
6
+ moteurs les plus spécialisées, le chercheur arbitre.
7
+
8
+ Pattern identique aux autres rendus : server-side, pas de JS,
9
+ anti-injection systématique.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from html import escape as _e
15
+ from typing import Optional
16
+
17
+ from picarones.core.specialization import (
18
+ compute_specialization_matrix,
19
+ top_specialized_pairs,
20
+ )
21
+
22
+
23
+ def _color_for_score(score: float) -> str:
24
+ """Gradient blanc → bleu profond."""
25
+ f = max(0.0, min(1.0, score))
26
+ r = int(255 + (50 - 255) * f)
27
+ g = int(255 + (110 - 255) * f)
28
+ b = int(255 + (180 - 255) * f)
29
+ return f"#{r:02x}{g:02x}{b:02x}"
30
+
31
+
32
+ def _category_label(cat: str, labels: dict[str, str]) -> str:
33
+ return labels.get(f"specialization_cat_{cat}", cat)
34
+
35
+
36
+ def build_specialization_html(
37
+ taxonomies: Optional[dict[str, dict[str, float]]],
38
+ labels: Optional[dict[str, str]] = None,
39
+ *,
40
+ top_n: int = 5,
41
+ ) -> str:
42
+ """Construit la vue HTML de spécialisation inter-moteurs.
43
+
44
+ Parameters
45
+ ----------
46
+ taxonomies:
47
+ Map ``{engine: {error_class: count}}``. Si ``None`` ou
48
+ moins de 2 moteurs, retourne ``""``.
49
+ labels:
50
+ Dict i18n. Clés sous le préfixe ``specialization_*``.
51
+ top_n:
52
+ Nombre de paires à afficher (défaut 5).
53
+ """
54
+ if not taxonomies or len(taxonomies) < 2:
55
+ return ""
56
+ matrix_data = compute_specialization_matrix(taxonomies)
57
+ if not matrix_data:
58
+ return ""
59
+ pairs = top_specialized_pairs(matrix_data, n=top_n)
60
+ if not pairs:
61
+ return ""
62
+ labels = labels or {}
63
+ title = labels.get(
64
+ "specialization_title", "Spécialisation inter-moteurs",
65
+ )
66
+ note = labels.get(
67
+ "specialization_note",
68
+ "Score de divergence Jensen-Shannon entre les profils "
69
+ "taxonomiques de chaque paire de moteurs (0 = profils "
70
+ "identiques, 1 = totalement disjoints). Une paire très "
71
+ "spécialisée signale des erreurs de natures différentes "
72
+ "— c'est au chercheur d'en tirer parti, pas à l'outil "
73
+ "de prescrire un ensemble.",
74
+ )
75
+ h_a = labels.get("specialization_engine_a", "Moteur A")
76
+ h_b = labels.get("specialization_engine_b", "Moteur B")
77
+ h_score = labels.get("specialization_score", "Score")
78
+ h_cat = labels.get("specialization_category", "Lecture")
79
+
80
+ parts = [
81
+ '<section class="specialization-section" '
82
+ 'style="margin:1rem 0">',
83
+ f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
84
+ f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.6rem">'
85
+ f'{_e(note)}</div>',
86
+ '<table style="border-collapse:collapse;width:100%;'
87
+ 'font-size:.9rem">',
88
+ '<thead><tr>',
89
+ ]
90
+ for col in (h_a, h_b, h_score, h_cat):
91
+ parts.append(
92
+ f'<th style="padding:.4rem .6rem;text-align:left;'
93
+ f'border-bottom:1px solid #ccc;font-weight:600">'
94
+ f'{_e(col)}</th>'
95
+ )
96
+ parts.append("</tr></thead><tbody>")
97
+ for pair in pairs:
98
+ score = float(pair.get("score") or 0.0)
99
+ cat = pair.get("category") or "?"
100
+ color = _color_for_score(score)
101
+ parts.append(
102
+ f'<tr>'
103
+ f'<td style="padding:.4rem .6rem">'
104
+ f'{_e(str(pair.get("engine_a", "?")))}</td>'
105
+ f'<td style="padding:.4rem .6rem">'
106
+ f'{_e(str(pair.get("engine_b", "?")))}</td>'
107
+ f'<td style="padding:.4rem .6rem;text-align:right;'
108
+ f'background:{color};font-family:monospace;font-weight:600">'
109
+ f'{score:.3f}</td>'
110
+ f'<td style="padding:.4rem .6rem">'
111
+ f'{_e(_category_label(cat, labels))}</td>'
112
+ f'</tr>'
113
+ )
114
+ parts.append("</tbody></table></section>")
115
+ return "".join(parts)
116
+
117
+
118
+ __all__ = ["build_specialization_html"]
picarones/report/templates/view_analyses.html CHANGED
@@ -231,6 +231,14 @@
231
  </div>
232
  {% endif %}
233
 
 
 
 
 
 
 
 
 
234
  <!-- Sprint 37 — Analyse inter-moteurs (divergence taxonomique + oracle gap) -->
235
  {% if divergence_matrix_html or oracle_gap_html %}
236
  <div class="chart-card" style="grid-column:1/-1">
 
231
  </div>
232
  {% endif %}
233
 
234
+ <!-- Sprint 89 — A.II.8b : spécialisation inter-moteurs.
235
+ Adaptive : n'apparaît que si ≥ 2 moteurs avec taxonomie. -->
236
+ {% if specialization_html %}
237
+ <div class="chart-card" style="grid-column:1/-1">
238
+ {{ specialization_html }}
239
+ </div>
240
+ {% endif %}
241
+
242
  <!-- Sprint 37 — Analyse inter-moteurs (divergence taxonomique + oracle gap) -->
243
  {% if divergence_matrix_html or oracle_gap_html %}
244
  <div class="chart-card" style="grid-column:1/-1">
tests/test_sprint89_specialization.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests Sprint 89 — A.II.8b : spécialisation inter-moteurs.
2
+
3
+ Couvre :
4
+
5
+ 1. ``compute_specialization_score`` : symétrie, plage [0, 1].
6
+ 2. ``classify_specialization`` : seuils par défaut + custom.
7
+ 3. ``compute_specialization_matrix`` : structure, symétrie, max_pair.
8
+ 4. ``top_specialized_pairs`` : tri, n, min_score.
9
+ 5. Vue HTML : adaptive, anti-injection, FR + EN.
10
+ 6. Complétude i18n FR/EN.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from pathlib import Path
17
+
18
+ from picarones.core.specialization import (
19
+ DEFAULT_THRESHOLDS,
20
+ classify_specialization,
21
+ compute_specialization_matrix,
22
+ compute_specialization_score,
23
+ top_specialized_pairs,
24
+ )
25
+ from picarones.report.specialization_render import (
26
+ build_specialization_html,
27
+ )
28
+
29
+
30
+ def _load_labels(lang: str) -> dict:
31
+ p = (
32
+ Path(__file__).parent.parent
33
+ / "picarones" / "report" / "i18n" / f"{lang}.json"
34
+ )
35
+ return json.loads(p.read_text(encoding="utf-8"))
36
+
37
+
38
+ # ──────────────────────────────────────────────────────────────────────────
39
+ # 1. compute_specialization_score
40
+ # ──────────────────────────────────────────────────────────────────────────
41
+
42
+
43
+ class TestScore:
44
+ def test_identical_profiles_zero(self) -> None:
45
+ tax = {"a": 50, "b": 50}
46
+ assert compute_specialization_score(tax, tax) < 0.001
47
+
48
+ def test_disjoint_profiles_one(self) -> None:
49
+ tax_a = {"a": 100}
50
+ tax_b = {"b": 100}
51
+ assert compute_specialization_score(tax_a, tax_b) > 0.95
52
+
53
+ def test_symmetric(self) -> None:
54
+ a = {"x": 70, "y": 30}
55
+ b = {"x": 20, "y": 80}
56
+ s_ab = compute_specialization_score(a, b)
57
+ s_ba = compute_specialization_score(b, a)
58
+ assert abs(s_ab - s_ba) < 1e-9
59
+
60
+ def test_bounded_zero_one(self) -> None:
61
+ a = {"x": 1, "y": 0, "z": 0}
62
+ b = {"x": 0, "y": 0, "z": 1}
63
+ score = compute_specialization_score(a, b)
64
+ assert 0.0 <= score <= 1.0
65
+
66
+
67
+ # ──────────────────────────────────────────────────────────────────────────
68
+ # 2. classify_specialization
69
+ # ──────────────────────────────────────────────────────────────────────────
70
+
71
+
72
+ class TestClassify:
73
+ def test_below_similar_threshold(self) -> None:
74
+ assert classify_specialization(0.05) == "similar"
75
+
76
+ def test_distinct_band(self) -> None:
77
+ assert classify_specialization(0.20) == "distinct"
78
+
79
+ def test_highly_specialized_above(self) -> None:
80
+ assert classify_specialization(0.50) == "highly_specialized"
81
+
82
+ def test_custom_thresholds(self) -> None:
83
+ custom = (("low", 0.5), ("high", 1.01))
84
+ assert classify_specialization(0.30, custom) == "low"
85
+ assert classify_specialization(0.80, custom) == "high"
86
+
87
+ def test_default_thresholds_exposed(self) -> None:
88
+ assert isinstance(DEFAULT_THRESHOLDS, tuple)
89
+ assert len(DEFAULT_THRESHOLDS) >= 2
90
+
91
+
92
+ # ──────────────────────────────────────────────────────────────────────────
93
+ # 3. compute_specialization_matrix
94
+ # ──────────────────────────────────────────────────────────────────────────
95
+
96
+
97
+ class TestMatrix:
98
+ def test_returns_none_when_lt_two(self) -> None:
99
+ assert compute_specialization_matrix({}) is None
100
+ assert compute_specialization_matrix({"a": {"x": 1}}) is None
101
+
102
+ def test_diagonal_zero(self) -> None:
103
+ tax = {
104
+ "a": {"x": 1, "y": 0},
105
+ "b": {"x": 0, "y": 1},
106
+ }
107
+ m = compute_specialization_matrix(tax)
108
+ for i in range(len(m["engines"])):
109
+ assert m["matrix"][i][i] == 0.0
110
+
111
+ def test_symmetric(self) -> None:
112
+ tax = {
113
+ "a": {"x": 1, "y": 0},
114
+ "b": {"x": 0, "y": 1},
115
+ "c": {"x": 1, "y": 1},
116
+ }
117
+ m = compute_specialization_matrix(tax)
118
+ n = len(m["engines"])
119
+ for i in range(n):
120
+ for j in range(n):
121
+ assert m["matrix"][i][j] == m["matrix"][j][i]
122
+
123
+ def test_max_pair_identifies_most_specialized(self) -> None:
124
+ # A vs B totalement disjoints, C similaire à A.
125
+ tax = {
126
+ "a": {"x": 100, "y": 0},
127
+ "b": {"x": 0, "y": 100},
128
+ "c": {"x": 95, "y": 5},
129
+ }
130
+ m = compute_specialization_matrix(tax)
131
+ # La paire la plus spécialisée doit être (a, b)
132
+ assert set(m["max_pair"]) == {"a", "b"}
133
+
134
+
135
+ # ──────────────────────────────────────────────────────────────────────────
136
+ # 4. top_specialized_pairs
137
+ # ──────────────────────────────────────────────────────────────────────────
138
+
139
+
140
+ class TestTop:
141
+ def _matrix(self) -> dict:
142
+ return compute_specialization_matrix({
143
+ "a": {"x": 100, "y": 0},
144
+ "b": {"x": 0, "y": 100},
145
+ "c": {"x": 95, "y": 5},
146
+ })
147
+
148
+ def test_sorted_descending(self) -> None:
149
+ pairs = top_specialized_pairs(self._matrix(), n=10)
150
+ scores = [p["score"] for p in pairs]
151
+ assert scores == sorted(scores, reverse=True)
152
+
153
+ def test_caps_at_n(self) -> None:
154
+ pairs = top_specialized_pairs(self._matrix(), n=1)
155
+ assert len(pairs) == 1
156
+
157
+ def test_min_score_filter(self) -> None:
158
+ pairs = top_specialized_pairs(
159
+ self._matrix(), n=10, min_score=0.99,
160
+ )
161
+ # Seules les paires (a,b) et éventuellement (b,c) au-dessus
162
+ assert all(p["score"] >= 0.99 for p in pairs)
163
+
164
+ def test_none_input_returns_empty(self) -> None:
165
+ assert top_specialized_pairs(None) == []
166
+
167
+
168
+ # ──────────────────────────────────────────────────────────────────────────
169
+ # 5. Vue HTML
170
+ # ──────────────────────────────────────────────────────────────────────────
171
+
172
+
173
+ class TestRender:
174
+ def test_empty_returns_empty(self) -> None:
175
+ assert build_specialization_html(None) == ""
176
+ assert build_specialization_html({}) == ""
177
+
178
+ def test_single_engine_returns_empty(self) -> None:
179
+ assert build_specialization_html({"a": {"x": 1}}) == ""
180
+
181
+ def test_renders_table(self) -> None:
182
+ tax = {
183
+ "tess": {"visual_confusion": 80, "lacuna": 20},
184
+ "pero": {"visual_confusion": 5, "lacuna": 95},
185
+ }
186
+ html = build_specialization_html(tax, _load_labels("fr"))
187
+ assert "<table" in html
188
+ assert "tess" in html
189
+ assert "pero" in html
190
+ # Catégorie traduite
191
+ assert "Forte spécialisation" in html
192
+
193
+ def test_anti_injection(self) -> None:
194
+ tax = {
195
+ "<script>alert(1)</script>": {"x": 100},
196
+ "pero": {"y": 100},
197
+ }
198
+ html = build_specialization_html(tax, _load_labels("fr"))
199
+ assert "<script>alert" not in html
200
+ assert "&lt;script&gt;" in html
201
+
202
+ def test_renders_in_english(self) -> None:
203
+ tax = {
204
+ "a": {"x": 100, "y": 0},
205
+ "b": {"x": 0, "y": 100},
206
+ }
207
+ html = build_specialization_html(tax, _load_labels("en"))
208
+ assert "Inter-engine specialisation" in html
209
+ assert "Highly specialised" in html
210
+
211
+
212
+ # ──────────────────────────────────────────────────────────────────────────
213
+ # 6. Complétude i18n
214
+ # ──────────────────────────────────────────────────────────────────────────
215
+
216
+
217
+ _KEYS = {
218
+ "specialization_title", "specialization_note",
219
+ "specialization_engine_a", "specialization_engine_b",
220
+ "specialization_score", "specialization_category",
221
+ "specialization_cat_similar", "specialization_cat_distinct",
222
+ "specialization_cat_highly_specialized",
223
+ }
224
+
225
+
226
+ class TestI18n:
227
+ def test_fr(self) -> None:
228
+ d = _load_labels("fr")
229
+ assert not _KEYS - d.keys()
230
+
231
+ def test_en(self) -> None:
232
+ d = _load_labels("en")
233
+ assert not _KEYS - d.keys()