Claude commited on
Commit
3821901
·
unverified ·
1 Parent(s): f00dec9

sprint82: section "Leviers d'amélioration" (A.I.9 bout-en-bout)

Browse files

Le moteur narratif Sprint 19 décrit ce qui s'est passé ; ce sprint
identifie sur quelle dimension un effort éditorial pourrait porter,
purement factuel jamais prescriptif.

- picarones/core/levers.py : dataclass Lever + LeverImportance
+ registre via @register_lever + 5 détecteurs
(dominant_recoverable_class, pareto_concentration,
complementarity_observation, lexical_modernization_observation,
robustness_projection_observation).
- picarones/report/levers_render.py : cards server-side, anti-injection,
adaptive masking, garde-fou anti-hallucination identique au moteur
narratif (chaque chiffre rendu provient du payload).
- 18 clés i18n FR/EN (levers_*).
- 40 tests : modèle, 5 détecteurs individuels, pipeline,
rendu HTML, anti-hallucination FR+EN, complétude i18n.

Tests : 2761 passed, 2 skipped.

https://claude.ai/code/session_01RusTQYcSfXqTsbFNvwmCV7

CHANGELOG.md CHANGED
@@ -16,6 +16,44 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
16
 
17
  ### Ajouté
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  - **Sprint 81 — A.I.8 : robustesse synthétique projetée sur le
20
  corpus réel (couche de calcul).** Le module
21
  ``picarones/core/robustness.py`` (Sprint 8) génère des courbes
 
16
 
17
  ### Ajouté
18
 
19
+ - **Sprint 82 — A.I.9 : section « Leviers d'amélioration »
20
+ (couche calcul + cards HTML).** Le moteur narratif
21
+ (Sprint 19) émet des `Fact` qui décrivent **ce qui s'est
22
+ passé** dans le benchmark. Ce sprint répond à une question
23
+ complémentaire : *« sur quelle dimension le bénéfice attendu
24
+ d'une amélioration serait-il le plus visible ? »*. Approche
25
+ strictement **non-prescriptive** : aucune recommandation
26
+ *« faites X »*, uniquement des **observations factuelles**
27
+ agrégées depuis les modules d'analyse (Sprints 75-81).
28
+ Nouveau module `picarones/core/levers.py` : dataclass
29
+ ``Lever(type, importance, payload, engines_involved)``,
30
+ ``LeverImportance`` (HIGH/MEDIUM/LOW), registre via
31
+ décorateur ``@register_lever``, helper ``detect_levers`` qui
32
+ trie par importance décroissante. **5 détecteurs livrés** :
33
+ ``dominant_recoverable_class`` (≥30 % d'erreurs récupérables
34
+ selon la catégorisation Sprint 77), ``pareto_concentration``
35
+ (top-20 % docs ≥50 % du CER cumulé), ``complementarity_observation``
36
+ (factuel sur ``inter_engine_analysis.complementarity_gap``,
37
+ Sprint 35), ``lexical_modernization_observation`` (top-3
38
+ tokens GT systématiquement modernisés, Sprint 80),
39
+ ``robustness_projection_observation`` (déficit projeté ≥2
40
+ points de CER, Sprint 81). Nouveau module
41
+ `picarones/report/levers_render.py` : ``build_levers_section_html``
42
+ rend des **cards** server-side avec étiquette i18n + phrase
43
+ factuelle + détail compact + niveau d'importance coloré.
44
+ Adaptive masking : ``""`` si aucun levier exploitable.
45
+ Anti-injection systématique via ``html.escape``. Garde-fou
46
+ anti-hallucination identique au moteur narratif : chaque
47
+ chiffre rendu est dans le ``payload`` du levier. +18 clés
48
+ i18n FR/EN (``levers_*``). +40 tests dans
49
+ `test_sprint82_levers.py` (modèle 3, dominant 6, pareto 5,
50
+ complementarity 4, lexical 4, robustness 4, pipeline 3,
51
+ rendu 6, anti-hallucination FR+EN 3, complétude i18n 2).
52
+ **Verrou levé** : le rapport ne se contente plus de décrire
53
+ *ce qui est* — il propose une lecture compacte des
54
+ **dimensions où un effort éditorial pourrait porter**, sans
55
+ jamais imposer un verdict.
56
+
57
  - **Sprint 81 — A.I.8 : robustesse synthétique projetée sur le
58
  corpus réel (couche de calcul).** Le module
59
  ``picarones/core/robustness.py`` (Sprint 8) génère des courbes
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
  | 81 | **Sprint 50 du plan d'évolution 2026 — A.I.8 : robustesse synthétique projetée sur corpus réel (couche calcul)**. `robustness.py` (Sprint 8) génère des courbes CER vs dégradation synthétique ; `image_quality.py` mesure le bruit/flou réels. Ce sprint projette les caractéristiques réelles sur les courbes pour estimer le déficit attendu. Nouveau module `picarones/core/robustness_projection.py` : `_interpolate_cer(levels, cer_values, target_level)` interpolation linéaire avec clip aux bornes (pas d'extrapolation hasardeuse), filtre cer None ; `_extract_quality_value(quality_dict, degradation_type, custom_mapping)` extrait depuis ImageQualityResult (mapping default noise→noise_level, blur→blur_score, etc.) ; `project_robustness_on_corpus(curves, image_qualities)` retourne `{engine: {deg_type: {n_docs, n_docs_with_data, expected_cer_mean/median, baseline_cer, deficit_vs_baseline, n_docs_above_critical, critical_threshold}}}` ; `aggregate_projection_per_engine` somme les déficits par moteur et identifie le worst_degradation_type (hypothèse d'indépendance documentée). +22 tests (interpolation 7 cas, extraction 4 cas, projection 7 cas, agrégation 4 cas). **Verrou levé** : un bench BnF lit « 30 % de vos documents ont un bruit où Tesseract perd 8 points — déficit attendu 2,4 points » — la courbe de robustesse n'est plus déconnectée du corpus réel. |
211
  | 80 | **Sprint 49 du plan d'évolution 2026 — A.I.7 : sur-normalisation lexicale (couche calcul + table HTML)**. Le détecteur `llm_hallucination_flag` (Sprint 19) signale via un score agrégé mais ne dit pas **quoi** corriger dans le prompt. Nouveau module `picarones/core/lexical_modernization.py` : `compute_lexical_modernization(reference, hypothesis, stop_list, case_sensitive)` aligne mot-à-mot via `difflib.SequenceMatcher` et accumule par token GT `{n_total, n_modernized, rate_modernized, variants}` ; `aggregate_lexical_modernization` somme corpus-wide ; `top_modernized_tokens(data, n=20, min_total=1)` retourne les N tokens GT les plus modernisés (tri décroissant par taux, tie-break par n_total, filtre anecdotiques via min_total). Stop-list paramétrable (par défaut vide). Suppression GT → variant ∅. Nouveau module `picarones/report/lexical_modernization_render.py` : `build_lexical_modernization_html(data, labels, top_n, min_total)` tableau 4 colonnes (forme GT, variantes OCR top-3, n GT, % modernisé gradient blanc→orange). Adaptive : "" si data None ou aucun modernisé. +6 clés i18n FR/EN. +20 tests (calcul 9 cas dont systématique/préservé/partiel/multi-variants/stop-list/casse/suppression/vide, agrégation 2 cas, top 2 cas, rendu 5 cas dont anti-injection, complétude i18n). **Verrou levé** : le chercheur lit « maistre → maître modernisé dans 100 % des cas » et ajuste son prompt — info exploitable au lieu d'un score agrégé. |
212
  | 79 | **Sprint 48 du plan d'évolution 2026 — A.I.6 : projection de coût en volume cible (couche de calcul)**. La vue Pareto (Sprint 20) trace CER vs coût mais le coût est par unité (1 000 pages) ; payer 50 € de plus sur 50 pages est trivial, sur 5 millions ça change tout. Nouveau module `picarones/core/cost_projection.py` : `ProjectedCost(engine_key, target_pages, cost_total_eur, co2_total_g, cost_per_1k_pages_eur, co2_per_1k_pages_g, type)`, `project_cost_total/co2_total` linéaire en pages avec `None` si données insuffisantes ou target<0, `project_engine` retourne le ProjectedCost complet, `project_all_engines(engine_costs, target_pages)` projette N moteurs (ValueError si target<0, moteurs sans données conservés avec cost_total=None), `cost_gap_table(projections, baseline)` retourne `{engine: {total, delta_abs, delta_rel}}` vs baseline (KeyError si baseline inconnue, delta_rel=None si baseline=0). +17 tests (calcul 5 cas, CO₂ 2 cas, engine 2 cas, all_engines 3 cas, gap_table 4 cas, **cas réaliste BnF 80 000 pages BMS** Tesseract=3.20€/Pero=0€/Mistral=280€/GPT-4o=600€). **Verrou levé** : couche calcul prête pour câbler le panneau « Avancé » avec champ « Volume cible » qui recalcule Pareto et table coût en valeur totale projetée. UX HTML suivra. |
@@ -299,7 +300,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
299
  ## Contexte développement
300
 
301
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
302
- - **Tests** : 2721 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 — robustesse projetée sur corpus réel**)
303
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
304
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
305
  - **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
+ | 82 | **Sprint 51 du plan d'évolution 2026 — A.I.9 : section « Leviers d'amélioration » (couche calcul + cards HTML)**. Le moteur narratif Sprint 19 dit *ce qui s'est passé* ; ce sprint dit *sur quelle dimension un effort éditorial pourrait porter* — purement factuel, jamais prescriptif. Nouveau module `picarones/core/levers.py` : dataclass `Lever(type, importance, payload, engines_involved)`, `LeverImportance` (HIGH=70/MEDIUM=40/LOW=10), registre via décorateur `@register_lever` (parallèle au registre narratif), `detect_levers(benchmark_data)` trie par importance décroissante. **5 détecteurs** : `dominant_recoverable_class` (≥30 % d'erreurs récupérables Sprint 77, HIGH si ≥50 %, top-3 classes), `pareto_concentration` (top-20 % des docs ≥50 % du CER cumulé sur le moteur leader, HIGH si ≥75 %), `complementarity_observation` (factuel sur `inter_engine_analysis.complementarity_gap` Sprint 35, HIGH si rel_gap ≥50 %), `lexical_modernization_observation` (top-3 tokens GT systématiquement modernisés Sprint 80, min_total=3, min_rate=0.50, HIGH si max_rate ≥90 %), `robustness_projection_observation` (déficit projeté ≥2 points de CER Sprint 81, HIGH si ≥5 points, sorted desc). Nouveau module `picarones/report/levers_render.py` : `build_levers_section_html` rend des **cards** server-side (étiquette i18n + phrase factuelle + détail compact + niveau d'importance coloré bleu/orange). Adaptive : `""` si aucun levier exploitable. Anti-injection systématique. Garde-fou anti-hallucination identique au moteur narratif : chaque chiffre rendu est dans le `payload` (test prouve la traçabilité FR+EN sur 3 leviers). +18 clés i18n FR/EN. +40 tests (modèle 3, dominant_recoverable 6, pareto 5, complementarity 4, lexical 4, robustness 4, pipeline 3, rendu 6, anti-hallucination 3, complétude i18n 2). **Verrou levé** : le rapport propose une lecture compacte des dimensions actionnables sans imposer de verdict — *« 65 % des erreurs de Tesseract sont récupérables », « 12 % des docs concentrent 78 % du CER », « top tokens modernisés : maistre, nostre, veoir »* — le chercheur juge selon son workflow. |
211
  | 81 | **Sprint 50 du plan d'évolution 2026 — A.I.8 : robustesse synthétique projetée sur corpus réel (couche calcul)**. `robustness.py` (Sprint 8) génère des courbes CER vs dégradation synthétique ; `image_quality.py` mesure le bruit/flou réels. Ce sprint projette les caractéristiques réelles sur les courbes pour estimer le déficit attendu. Nouveau module `picarones/core/robustness_projection.py` : `_interpolate_cer(levels, cer_values, target_level)` interpolation linéaire avec clip aux bornes (pas d'extrapolation hasardeuse), filtre cer None ; `_extract_quality_value(quality_dict, degradation_type, custom_mapping)` extrait depuis ImageQualityResult (mapping default noise→noise_level, blur→blur_score, etc.) ; `project_robustness_on_corpus(curves, image_qualities)` retourne `{engine: {deg_type: {n_docs, n_docs_with_data, expected_cer_mean/median, baseline_cer, deficit_vs_baseline, n_docs_above_critical, critical_threshold}}}` ; `aggregate_projection_per_engine` somme les déficits par moteur et identifie le worst_degradation_type (hypothèse d'indépendance documentée). +22 tests (interpolation 7 cas, extraction 4 cas, projection 7 cas, agrégation 4 cas). **Verrou levé** : un bench BnF lit « 30 % de vos documents ont un bruit où Tesseract perd 8 points — déficit attendu 2,4 points » — la courbe de robustesse n'est plus déconnectée du corpus réel. |
212
  | 80 | **Sprint 49 du plan d'évolution 2026 — A.I.7 : sur-normalisation lexicale (couche calcul + table HTML)**. Le détecteur `llm_hallucination_flag` (Sprint 19) signale via un score agrégé mais ne dit pas **quoi** corriger dans le prompt. Nouveau module `picarones/core/lexical_modernization.py` : `compute_lexical_modernization(reference, hypothesis, stop_list, case_sensitive)` aligne mot-à-mot via `difflib.SequenceMatcher` et accumule par token GT `{n_total, n_modernized, rate_modernized, variants}` ; `aggregate_lexical_modernization` somme corpus-wide ; `top_modernized_tokens(data, n=20, min_total=1)` retourne les N tokens GT les plus modernisés (tri décroissant par taux, tie-break par n_total, filtre anecdotiques via min_total). Stop-list paramétrable (par défaut vide). Suppression GT → variant ∅. Nouveau module `picarones/report/lexical_modernization_render.py` : `build_lexical_modernization_html(data, labels, top_n, min_total)` tableau 4 colonnes (forme GT, variantes OCR top-3, n GT, % modernisé gradient blanc→orange). Adaptive : "" si data None ou aucun modernisé. +6 clés i18n FR/EN. +20 tests (calcul 9 cas dont systématique/préservé/partiel/multi-variants/stop-list/casse/suppression/vide, agrégation 2 cas, top 2 cas, rendu 5 cas dont anti-injection, complétude i18n). **Verrou levé** : le chercheur lit « maistre → maître modernisé dans 100 % des cas » et ajuste son prompt — info exploitable au lieu d'un score agrégé. |
213
  | 79 | **Sprint 48 du plan d'évolution 2026 — A.I.6 : projection de coût en volume cible (couche de calcul)**. La vue Pareto (Sprint 20) trace CER vs coût mais le coût est par unité (1 000 pages) ; payer 50 € de plus sur 50 pages est trivial, sur 5 millions ça change tout. Nouveau module `picarones/core/cost_projection.py` : `ProjectedCost(engine_key, target_pages, cost_total_eur, co2_total_g, cost_per_1k_pages_eur, co2_per_1k_pages_g, type)`, `project_cost_total/co2_total` linéaire en pages avec `None` si données insuffisantes ou target<0, `project_engine` retourne le ProjectedCost complet, `project_all_engines(engine_costs, target_pages)` projette N moteurs (ValueError si target<0, moteurs sans données conservés avec cost_total=None), `cost_gap_table(projections, baseline)` retourne `{engine: {total, delta_abs, delta_rel}}` vs baseline (KeyError si baseline inconnue, delta_rel=None si baseline=0). +17 tests (calcul 5 cas, CO₂ 2 cas, engine 2 cas, all_engines 3 cas, gap_table 4 cas, **cas réaliste BnF 80 000 pages BMS** Tesseract=3.20€/Pero=0€/Mistral=280€/GPT-4o=600€). **Verrou levé** : couche calcul prête pour câbler le panneau « Avancé » avec champ « Volume cible » qui recalcule Pareto et table coût en valeur totale projetée. UX HTML suivra. |
 
300
  ## Contexte développement
301
 
302
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
303
+ - **Tests** : 2761 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 — robustesse projetée sur corpus réel ; **Sprint 82 = A.I.9 — section « Leviers d'amélioration » bout-en-bout**)
304
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
305
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
306
  - **Transcript de la conversation de développement** :
picarones/core/levers.py ADDED
@@ -0,0 +1,561 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Section « Leviers d'amélioration » — Sprint 82 (A.I.9).
2
+
3
+ Sprint 82 — A.I.9 du plan d'évolution 2026.
4
+
5
+ Pourquoi ce module
6
+ ------------------
7
+ Le moteur narratif (Sprint 19) émet des `Fact` qui décrivent **ce
8
+ qui s'est passé** dans le benchmark : qui gagne, qui s'effondre,
9
+ qui est fragile. Ce sprint répond à une question
10
+ complémentaire : **sur quelle dimension le bénéfice attendu d'une
11
+ amélioration serait-il le plus visible ?**
12
+
13
+ Pas de prescription
14
+ -------------------
15
+ Picarones est un **outil de recherche**, pas un atelier de
16
+ production. Le module ne dit jamais *« faites X »* ni
17
+ *« utilisez le moteur Y »* ; il agrège des **observations
18
+ factuelles** déjà calculées dans d'autres modules (Sprints 75-81)
19
+ et les présente comme un récapitulatif compact en bas du rapport.
20
+ Le chercheur lit, juge et arbitre.
21
+
22
+ Exemples de leviers émis
23
+ ------------------------
24
+ - *« 65 % des erreurs de Tesseract sont de classe récupérable
25
+ (case_error, ligature_error, abbreviation_error) — un
26
+ post-processing trivial absorberait une partie. »*
27
+ - *« 12 % de vos documents concentrent 78 % du CER total
28
+ (Pareto-CER). »*
29
+ - *« Le déficit projeté du moteur le plus fragile sur le corpus
30
+ réel est de 4,2 points de CER (Sprint 81). »*
31
+ - *« Le top-3 des tokens GT systématiquement modernisés est
32
+ maistre, nostre, veoir (Sprint 80). »*
33
+
34
+ Structure
35
+ ---------
36
+ Module parallèle au registre narratif Sprint 19 : `Lever` est la
37
+ dataclass équivalente à `Fact`, `LeverImportance` reprend la
38
+ sémantique de `FactImportance`, `@register_lever` indexe les
39
+ détecteurs. Garde-fou anti-hallucination identique : chaque
40
+ nombre rendu doit être présent dans le `payload` du `Lever`.
41
+
42
+ Les détecteurs lisent **uniquement** des structures déjà
43
+ construites par le pipeline du benchmark — ils ne calculent rien
44
+ de nouveau, ils synthétisent. C'est pourquoi le module est
45
+ résolument optionnel : si un benchmark n'expose pas
46
+ `taxonomy_aggregated`, `inter_engine_analysis`, `corpus_difficulty`,
47
+ `lexical_modernization` ou `robustness_projection`, le détecteur
48
+ correspondant retourne tout simplement `[]`.
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ import logging
54
+ import threading
55
+ from dataclasses import dataclass
56
+ from enum import Enum
57
+ from typing import Callable
58
+
59
+ logger = logging.getLogger(__name__)
60
+
61
+
62
+ # ──────────────────────────────────────────────────────────────────────────
63
+ # Modèle
64
+ # ──────────────────────────────────────────────────────────────────────────
65
+
66
+
67
+ class LeverType(str, Enum):
68
+ """Types de leviers détectés."""
69
+
70
+ DOMINANT_RECOVERABLE_CLASS = "dominant_recoverable_class"
71
+ """Une part importante des erreurs d'un moteur est dans des classes
72
+ catégorisées « récupérables » (Sprint 77)."""
73
+
74
+ PARETO_CONCENTRATION = "pareto_concentration"
75
+ """Une fraction minoritaire de documents concentre une fraction
76
+ majoritaire du CER total — l'inspection ciblée est rentable."""
77
+
78
+ COMPLEMENTARITY_OBSERVATION = "complementarity_observation"
79
+ """Le `complementarity_gap` (Sprint 35) entre l'oracle et le
80
+ meilleur moteur seul est non négligeable — observation factuelle,
81
+ aucune recommandation d'ensemble."""
82
+
83
+ LEXICAL_MODERNIZATION_OBSERVATION = "lexical_modernization_observation"
84
+ """Top-N des tokens GT systématiquement modernisés (Sprint 80)."""
85
+
86
+ ROBUSTNESS_PROJECTION_OBSERVATION = "robustness_projection_observation"
87
+ """Déficit projeté global le plus important pour un moteur sur
88
+ le corpus réel (Sprint 81)."""
89
+
90
+
91
+ class LeverImportance(int, Enum):
92
+ """Importance éditoriale d'un levier."""
93
+
94
+ HIGH = 70
95
+ MEDIUM = 40
96
+ LOW = 10
97
+
98
+
99
+ @dataclass
100
+ class Lever:
101
+ """Observation factuelle synthétisable en encart « Leviers ».
102
+
103
+ Attributes
104
+ ----------
105
+ type:
106
+ Le type de levier (voir `LeverType`).
107
+ importance:
108
+ Score qui décide l'ordre d'affichage.
109
+ payload:
110
+ Données brutes — **tout chiffre rendu dans le HTML doit
111
+ provenir d'ici**, jamais d'un calcul du renderer.
112
+ engines_involved:
113
+ Noms des moteurs concernés (peut être vide pour un levier
114
+ corpus-wide).
115
+ """
116
+
117
+ type: LeverType
118
+ importance: LeverImportance
119
+ payload: dict
120
+ engines_involved: tuple[str, ...] = ()
121
+
122
+ def as_dict(self) -> dict:
123
+ return {
124
+ "type": self.type.value,
125
+ "importance": int(self.importance),
126
+ "payload": self.payload,
127
+ "engines_involved": list(self.engines_involved),
128
+ }
129
+
130
+
131
+ # ──────────────────────────────────────────────────────────────────────────
132
+ # Registre
133
+ # ──────────────────────────────────────────────────────────────────────────
134
+
135
+
136
+ LeverDetectorFn = Callable[[dict], list[Lever]]
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class LeverDetectorEntry:
141
+ lever_type: LeverType
142
+ fn: LeverDetectorFn
143
+ priority: int
144
+
145
+
146
+ _LEVER_REGISTRY: dict[LeverType, LeverDetectorEntry] = {}
147
+ _LEVER_REGISTRY_LOCK = threading.Lock()
148
+
149
+
150
+ def register_lever(
151
+ lever_type: LeverType,
152
+ *,
153
+ priority: int,
154
+ ) -> Callable[[LeverDetectorFn], LeverDetectorFn]:
155
+ """Décorateur : enregistre un détecteur de levier.
156
+
157
+ Une seule fonction par type — réenregistrer lève `ValueError`.
158
+ """
159
+ def _decorator(fn: LeverDetectorFn) -> LeverDetectorFn:
160
+ with _LEVER_REGISTRY_LOCK:
161
+ if lever_type in _LEVER_REGISTRY:
162
+ raise ValueError(
163
+ f"Détecteur déjà enregistré pour {lever_type.value!r} : "
164
+ f"{_LEVER_REGISTRY[lever_type].fn.__name__}."
165
+ )
166
+ _LEVER_REGISTRY[lever_type] = LeverDetectorEntry(
167
+ lever_type=lever_type, fn=fn, priority=int(priority),
168
+ )
169
+ return fn
170
+ return _decorator
171
+
172
+
173
+ def unregister_lever(lever_type: LeverType) -> None:
174
+ with _LEVER_REGISTRY_LOCK:
175
+ _LEVER_REGISTRY.pop(lever_type, None)
176
+
177
+
178
+ def iter_lever_detectors() -> list[LeverDetectorEntry]:
179
+ with _LEVER_REGISTRY_LOCK:
180
+ entries = list(_LEVER_REGISTRY.values())
181
+ entries.sort(key=lambda e: e.priority)
182
+ return entries
183
+
184
+
185
+ def detect_levers(benchmark_data: dict) -> list[Lever]:
186
+ """Applique tous les détecteurs enregistrés et trie par importance
187
+ décroissante puis priorité d'enregistrement croissante."""
188
+ levers: list[Lever] = []
189
+ for entry in iter_lever_detectors():
190
+ try:
191
+ result = entry.fn(benchmark_data)
192
+ except Exception as e:
193
+ logger.warning(
194
+ "[levers.detector.%s] fonctionnalité dégradée : %s",
195
+ entry.lever_type.value, e,
196
+ )
197
+ continue
198
+ if result:
199
+ levers.extend(result)
200
+ # Tri stable : importance décroissante d'abord
201
+ levers.sort(key=lambda lv: -int(lv.importance))
202
+ return levers
203
+
204
+
205
+ # ──────────────────────────────────────────────────────────────────────────
206
+ # Détecteurs
207
+ # ──────────────────────────────────────────────────────────────────────────
208
+
209
+
210
+ # Catégorisation reprise du Sprint 77 (taxonomy_comparison.py).
211
+ # Volontairement dupliquée ici pour ne pas introduire d'import
212
+ # circulaire — la sémantique est gelée.
213
+ _RECOVERABILITY: dict[str, str] = {
214
+ "case_error": "recoverable",
215
+ "ligature_error": "recoverable",
216
+ "abbreviation_error": "recoverable",
217
+ "diacritic_error": "difficult",
218
+ "visual_confusion": "difficult",
219
+ "hapax": "difficult",
220
+ "lacuna": "irrecoverable",
221
+ "oov_character": "irrecoverable",
222
+ "segmentation_error": "irrecoverable",
223
+ }
224
+
225
+
226
+ @register_lever(LeverType.DOMINANT_RECOVERABLE_CLASS, priority=10)
227
+ def detect_dominant_recoverable_class(
228
+ benchmark_data: dict,
229
+ *,
230
+ threshold: float = 0.30,
231
+ ) -> list[Lever]:
232
+ """Émet un levier si ≥ `threshold` des erreurs d'un moteur sont
233
+ classifiées récupérables (catégorisation Sprint 77).
234
+
235
+ Lit `benchmark_data["engines"][i]["aggregated_taxonomy"]` —
236
+ structure produite par le runner historique. Si absent, retourne
237
+ [].
238
+ """
239
+ engines = benchmark_data.get("engines") or []
240
+ out: list[Lever] = []
241
+ for engine in engines:
242
+ taxonomy = engine.get("aggregated_taxonomy")
243
+ if not taxonomy:
244
+ continue
245
+ # `taxonomy` peut être {class_name: int} ou un dict avec une
246
+ # sous-clé "counts" — on accepte les deux conventions.
247
+ counts = taxonomy.get("counts") if isinstance(taxonomy, dict) and "counts" in taxonomy else taxonomy
248
+ if not isinstance(counts, dict) or not counts:
249
+ continue
250
+ try:
251
+ int_counts = {k: int(v) for k, v in counts.items() if isinstance(v, (int, float))}
252
+ except (TypeError, ValueError):
253
+ continue
254
+ total = sum(int_counts.values())
255
+ if total <= 0:
256
+ continue
257
+ recoverable_total = sum(
258
+ v for k, v in int_counts.items()
259
+ if _RECOVERABILITY.get(k) == "recoverable"
260
+ )
261
+ share = recoverable_total / total
262
+ if share < threshold:
263
+ continue
264
+ # Classes récupérables non vides triées par count décroissant
265
+ breakdown = sorted(
266
+ (
267
+ (k, v) for k, v in int_counts.items()
268
+ if _RECOVERABILITY.get(k) == "recoverable" and v > 0
269
+ ),
270
+ key=lambda kv: -kv[1],
271
+ )
272
+ importance = (
273
+ LeverImportance.HIGH if share >= 0.50 else LeverImportance.MEDIUM
274
+ )
275
+ out.append(Lever(
276
+ type=LeverType.DOMINANT_RECOVERABLE_CLASS,
277
+ importance=importance,
278
+ payload={
279
+ "engine": engine.get("name") or "?",
280
+ "share_recoverable": share,
281
+ "share_recoverable_pct": round(share * 100, 1),
282
+ "n_recoverable": recoverable_total,
283
+ "n_total_errors": total,
284
+ "top_classes": [
285
+ {"class": k, "count": v} for k, v in breakdown[:3]
286
+ ],
287
+ },
288
+ engines_involved=(engine.get("name") or "?",),
289
+ ))
290
+ return out
291
+
292
+
293
+ @register_lever(LeverType.PARETO_CONCENTRATION, priority=20)
294
+ def detect_pareto_concentration(
295
+ benchmark_data: dict,
296
+ *,
297
+ top_share: float = 0.20,
298
+ cer_share_threshold: float = 0.50,
299
+ ) -> list[Lever]:
300
+ """Émet un levier si une fraction minoritaire de documents
301
+ (`top_share`) concentre plus de `cer_share_threshold` du CER
302
+ total cumulé sur le moteur leader.
303
+
304
+ Lit `benchmark_data["per_doc_cer"][engine_name]` ou tente de
305
+ reconstruire depuis `benchmark_data["engines"][...]["per_doc"]`.
306
+ Si rien d'exploitable, retourne [].
307
+ """
308
+ ranking = benchmark_data.get("ranking") or []
309
+ if not ranking:
310
+ return []
311
+ leader = ranking[0]
312
+ leader_name = leader.get("engine")
313
+ if not leader_name:
314
+ return []
315
+
316
+ per_doc_cer: list[float] = []
317
+ # Voie 1 : structure plate "per_doc_cer"
318
+ flat = benchmark_data.get("per_doc_cer") or {}
319
+ if isinstance(flat, dict) and leader_name in flat and isinstance(flat[leader_name], list):
320
+ per_doc_cer = [float(x) for x in flat[leader_name] if isinstance(x, (int, float))]
321
+ else:
322
+ # Voie 2 : engine.per_doc liste de dicts {cer: float}
323
+ for engine in benchmark_data.get("engines") or []:
324
+ if engine.get("name") != leader_name:
325
+ continue
326
+ per_doc = engine.get("per_doc") or []
327
+ for entry in per_doc:
328
+ if isinstance(entry, dict) and isinstance(entry.get("cer"), (int, float)):
329
+ per_doc_cer.append(float(entry["cer"]))
330
+ break
331
+
332
+ if not per_doc_cer:
333
+ return []
334
+ total_cer = sum(per_doc_cer)
335
+ if total_cer <= 0:
336
+ return []
337
+
338
+ sorted_cer = sorted(per_doc_cer, reverse=True)
339
+ n = len(sorted_cer)
340
+ n_top = max(1, int(round(top_share * n)))
341
+ top_cer_sum = sum(sorted_cer[:n_top])
342
+ share_of_total = top_cer_sum / total_cer
343
+ if share_of_total < cer_share_threshold:
344
+ return []
345
+ importance = (
346
+ LeverImportance.HIGH if share_of_total >= 0.75
347
+ else LeverImportance.MEDIUM
348
+ )
349
+ return [Lever(
350
+ type=LeverType.PARETO_CONCENTRATION,
351
+ importance=importance,
352
+ payload={
353
+ "engine": leader_name,
354
+ "n_docs": n,
355
+ "n_docs_top": n_top,
356
+ "top_share_pct": round((n_top / n) * 100, 1),
357
+ "cer_share_of_total": share_of_total,
358
+ "cer_share_pct": round(share_of_total * 100, 1),
359
+ },
360
+ engines_involved=(leader_name,),
361
+ )]
362
+
363
+
364
+ @register_lever(LeverType.COMPLEMENTARITY_OBSERVATION, priority=30)
365
+ def detect_complementarity_observation(
366
+ benchmark_data: dict,
367
+ *,
368
+ min_relative_gap: float = 0.20,
369
+ ) -> list[Lever]:
370
+ """Reformule factuellement le `complementarity_gap` (Sprint 35).
371
+
372
+ Lit `benchmark_data["inter_engine_analysis"]`. Garde-fou : ne
373
+ déclenche que si `relative_gap` ≥ `min_relative_gap`. **Aucune
374
+ recommandation d'ensemble** — le levier dit factuellement
375
+ « X points séparent l'oracle du meilleur moteur », c'est tout.
376
+ """
377
+ inter = benchmark_data.get("inter_engine_analysis") or {}
378
+ cgap = inter.get("complementarity_gap") or {}
379
+ relative_gap = cgap.get("relative_gap")
380
+ absolute_gap = cgap.get("absolute_gap")
381
+ if relative_gap is None or absolute_gap is None:
382
+ return []
383
+ try:
384
+ rg = float(relative_gap)
385
+ ag = float(absolute_gap)
386
+ except (TypeError, ValueError):
387
+ return []
388
+ if rg < min_relative_gap:
389
+ return []
390
+ importance = (
391
+ LeverImportance.HIGH if rg >= 0.50 else LeverImportance.MEDIUM
392
+ )
393
+ payload: dict = {
394
+ "absolute_gap": ag,
395
+ "absolute_gap_pct": round(ag * 100, 1),
396
+ "relative_gap": rg,
397
+ "relative_gap_pct": round(rg * 100, 1),
398
+ }
399
+ best_engine = cgap.get("best_engine") or inter.get("best_engine")
400
+ best_recall = cgap.get("best_recall") or inter.get("best_engine_recall")
401
+ oracle_recall = cgap.get("oracle_recall") or inter.get("oracle_recall")
402
+ engines_involved: tuple[str, ...] = ()
403
+ if best_engine:
404
+ payload["best_engine"] = str(best_engine)
405
+ engines_involved = (str(best_engine),)
406
+ if isinstance(best_recall, (int, float)):
407
+ payload["best_recall"] = float(best_recall)
408
+ if isinstance(oracle_recall, (int, float)):
409
+ payload["oracle_recall"] = float(oracle_recall)
410
+ return [Lever(
411
+ type=LeverType.COMPLEMENTARITY_OBSERVATION,
412
+ importance=importance,
413
+ payload=payload,
414
+ engines_involved=engines_involved,
415
+ )]
416
+
417
+
418
+ @register_lever(LeverType.LEXICAL_MODERNIZATION_OBSERVATION, priority=40)
419
+ def detect_lexical_modernization_observation(
420
+ benchmark_data: dict,
421
+ *,
422
+ top_n: int = 3,
423
+ min_total: int = 3,
424
+ min_rate: float = 0.50,
425
+ ) -> list[Lever]:
426
+ """Pour chaque moteur disposant de `lexical_modernization`,
427
+ émet un levier listant les `top_n` tokens GT les plus modernisés.
428
+
429
+ Lit `benchmark_data["engines"][i]["lexical_modernization"]` qui
430
+ suit la forme produite par `compute_lexical_modernization` du
431
+ Sprint 80 (`{"n_gt_tokens": int, "tokens": dict}`).
432
+ """
433
+ out: list[Lever] = []
434
+ for engine in benchmark_data.get("engines") or []:
435
+ data = engine.get("lexical_modernization")
436
+ if not isinstance(data, dict):
437
+ continue
438
+ tokens = data.get("tokens") or {}
439
+ if not isinstance(tokens, dict) or not tokens:
440
+ continue
441
+ candidates: list[tuple[str, dict]] = []
442
+ for gt_token, slot in tokens.items():
443
+ if not isinstance(slot, dict):
444
+ continue
445
+ n_total = slot.get("n_total")
446
+ rate = slot.get("rate_modernized")
447
+ if not isinstance(n_total, (int, float)) or not isinstance(rate, (int, float)):
448
+ continue
449
+ if int(n_total) < min_total:
450
+ continue
451
+ if float(rate) < min_rate:
452
+ continue
453
+ candidates.append((gt_token, dict(slot)))
454
+ if not candidates:
455
+ continue
456
+ candidates.sort(
457
+ key=lambda kv: (-float(kv[1].get("rate_modernized", 0.0)),
458
+ -int(kv[1].get("n_total", 0)),
459
+ kv[0]),
460
+ )
461
+ top = candidates[:top_n]
462
+ engine_name = engine.get("name") or "?"
463
+ max_rate = max(float(slot.get("rate_modernized", 0.0)) for _, slot in top)
464
+ importance = (
465
+ LeverImportance.HIGH if max_rate >= 0.90 else LeverImportance.MEDIUM
466
+ )
467
+ out.append(Lever(
468
+ type=LeverType.LEXICAL_MODERNIZATION_OBSERVATION,
469
+ importance=importance,
470
+ payload={
471
+ "engine": engine_name,
472
+ "top_tokens": [
473
+ {
474
+ "gt_token": gt,
475
+ "n_total": int(slot.get("n_total", 0)),
476
+ "rate_modernized": float(slot.get("rate_modernized", 0.0)),
477
+ "rate_modernized_pct": round(
478
+ float(slot.get("rate_modernized", 0.0)) * 100, 1,
479
+ ),
480
+ }
481
+ for gt, slot in top
482
+ ],
483
+ },
484
+ engines_involved=(engine_name,),
485
+ ))
486
+ return out
487
+
488
+
489
+ @register_lever(LeverType.ROBUSTNESS_PROJECTION_OBSERVATION, priority=50)
490
+ def detect_robustness_projection_observation(
491
+ benchmark_data: dict,
492
+ *,
493
+ min_total_deficit: float = 0.02,
494
+ ) -> list[Lever]:
495
+ """Lit l'agrégation par moteur de la projection de robustesse
496
+ (Sprint 81). Émet le levier pour le moteur dont
497
+ `total_expected_deficit` est ≥ `min_total_deficit` (par défaut
498
+ 2 points de CER).
499
+
500
+ Lit `benchmark_data["robustness_projection_aggregated"]` —
501
+ structure produite par `aggregate_projection_per_engine`.
502
+ """
503
+ agg = benchmark_data.get("robustness_projection_aggregated") or {}
504
+ if not isinstance(agg, dict) or not agg:
505
+ return []
506
+ out: list[Lever] = []
507
+ for engine_name, info in agg.items():
508
+ if not isinstance(info, dict):
509
+ continue
510
+ total_deficit = info.get("total_expected_deficit")
511
+ worst_type = info.get("worst_degradation_type")
512
+ worst_deficit = info.get("worst_degradation_deficit")
513
+ if not isinstance(total_deficit, (int, float)):
514
+ continue
515
+ if float(total_deficit) < min_total_deficit:
516
+ continue
517
+ importance = (
518
+ LeverImportance.HIGH if float(total_deficit) >= 0.05
519
+ else LeverImportance.MEDIUM
520
+ )
521
+ payload: dict = {
522
+ "engine": engine_name,
523
+ "total_expected_deficit": float(total_deficit),
524
+ "total_expected_deficit_pct": round(float(total_deficit) * 100, 1),
525
+ "n_degradation_types": int(info.get("n_degradation_types") or 0),
526
+ }
527
+ if isinstance(worst_type, str):
528
+ payload["worst_degradation_type"] = worst_type
529
+ if isinstance(worst_deficit, (int, float)):
530
+ payload["worst_degradation_deficit"] = float(worst_deficit)
531
+ payload["worst_degradation_deficit_pct"] = round(
532
+ float(worst_deficit) * 100, 1,
533
+ )
534
+ out.append(Lever(
535
+ type=LeverType.ROBUSTNESS_PROJECTION_OBSERVATION,
536
+ importance=importance,
537
+ payload=payload,
538
+ engines_involved=(engine_name,),
539
+ ))
540
+ # Tri par déficit décroissant pour stabilité d'affichage.
541
+ out.sort(
542
+ key=lambda lv: -float(lv.payload.get("total_expected_deficit") or 0.0),
543
+ )
544
+ return out
545
+
546
+
547
+ __all__ = [
548
+ "Lever",
549
+ "LeverImportance",
550
+ "LeverType",
551
+ "LeverDetectorEntry",
552
+ "register_lever",
553
+ "unregister_lever",
554
+ "iter_lever_detectors",
555
+ "detect_levers",
556
+ "detect_dominant_recoverable_class",
557
+ "detect_pareto_concentration",
558
+ "detect_complementarity_observation",
559
+ "detect_lexical_modernization_observation",
560
+ "detect_robustness_projection_observation",
561
+ ]
picarones/report/i18n/en.json CHANGED
@@ -262,5 +262,23 @@
262
  "lexmod_gt_label": "Historical GT form",
263
  "lexmod_hyp_label": "OCR variants",
264
  "lexmod_n_label": "n GT",
265
- "lexmod_rate_label": "% modernized"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  }
 
262
  "lexmod_gt_label": "Historical GT form",
263
  "lexmod_hyp_label": "OCR variants",
264
  "lexmod_n_label": "n GT",
265
+ "lexmod_rate_label": "% modernized",
266
+ "levers_title": "Improvement leverages",
267
+ "levers_note": "Factual observations synthesized from the analysis modules. No prescription imposed — the researcher decides what is actionable for their workflow.",
268
+ "levers_top_classes": "Main classes:",
269
+ "levers_importance_high": "Important",
270
+ "levers_importance_medium": "Notable",
271
+ "levers_importance_low": "Minor",
272
+ "levers_label_dominant_recoverable_class": "Mostly recoverable error classes",
273
+ "levers_label_pareto_concentration": "CER Pareto concentration",
274
+ "levers_label_complementarity_observation": "Inter-engine complementarity",
275
+ "levers_label_lexical_modernization_observation": "Systematic lexical modernization",
276
+ "levers_label_robustness_projection_observation": "Projected deficit on the real corpus",
277
+ "levers_dominant_recoverable_phrase": "{pct}% of {engine}'s errors ({n_recov}/{n_total}) are classified as recoverable (case_error, ligature_error, abbreviation_error).",
278
+ "levers_pareto_phrase": "On {engine}, {n_top} documents ({top_pct}% of the corpus) concentrate {cer_pct}% of the cumulative CER (out of {n_total} documents).",
279
+ "levers_complementarity_phrase": "The bag-of-words oracle achieves a recall {abs_pct} points higher (+{rel_pct}% relative) than the best single engine.",
280
+ "levers_complementarity_phrase_with_engine": "The bag-of-words oracle achieves a recall {abs_pct} points higher (+{rel_pct}% relative) than the best single engine ({best_engine}).",
281
+ "levers_lexical_phrase": "Top GT tokens systematically modernized by {engine}: {items}.",
282
+ "levers_robustness_phrase": "Projected deficit of {engine} on the real corpus: {deficit_pct} CER points cumulated over {n_types} degradations.",
283
+ "levers_robustness_phrase_with_worst": "Projected deficit of {engine} on the real corpus: {deficit_pct} CER points cumulated over {n_types} degradations — worst degradation: {worst_type} ({worst_pct} points)."
284
  }
picarones/report/i18n/fr.json CHANGED
@@ -262,5 +262,23 @@
262
  "lexmod_gt_label": "Forme historique GT",
263
  "lexmod_hyp_label": "Variantes OCR",
264
  "lexmod_n_label": "n GT",
265
- "lexmod_rate_label": "% modernisé"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  }
 
262
  "lexmod_gt_label": "Forme historique GT",
263
  "lexmod_hyp_label": "Variantes OCR",
264
  "lexmod_n_label": "n GT",
265
+ "lexmod_rate_label": "% modernisé",
266
+ "levers_title": "Leviers d'amélioration",
267
+ "levers_note": "Observations factuelles synthétisées depuis les modules d'analyse. Aucune recommandation imposée — c'est au chercheur de juger ce qui est exploitable selon son workflow.",
268
+ "levers_top_classes": "Principales classes :",
269
+ "levers_importance_high": "Important",
270
+ "levers_importance_medium": "À noter",
271
+ "levers_importance_low": "Mineur",
272
+ "levers_label_dominant_recoverable_class": "Erreurs majoritairement récupérables",
273
+ "levers_label_pareto_concentration": "Concentration Pareto du CER",
274
+ "levers_label_complementarity_observation": "Complémentarité inter-moteurs",
275
+ "levers_label_lexical_modernization_observation": "Modernisation lexicale systématique",
276
+ "levers_label_robustness_projection_observation": "Déficit projeté sur le corpus réel",
277
+ "levers_dominant_recoverable_phrase": "{pct}% des erreurs de {engine} ({n_recov}/{n_total}) sont classifiées récupérables (case_error, ligature_error, abbreviation_error).",
278
+ "levers_pareto_phrase": "Sur {engine}, {n_top} documents ({top_pct}% du corpus) concentrent {cer_pct}% du CER cumulé (sur {n_total} documents au total).",
279
+ "levers_complementarity_phrase": "L'oracle bag-of-words atteint un rappel supérieur de {abs_pct} points (+{rel_pct}% relatif) à celui du meilleur moteur seul.",
280
+ "levers_complementarity_phrase_with_engine": "L'oracle bag-of-words atteint un rappel supérieur de {abs_pct} points (+{rel_pct}% relatif) à celui du meilleur moteur seul ({best_engine}).",
281
+ "levers_lexical_phrase": "Top tokens GT systématiquement modernisés par {engine} : {items}.",
282
+ "levers_robustness_phrase": "Déficit projeté de {engine} sur le corpus réel : {deficit_pct} points de CER cumulés sur {n_types} dégradations.",
283
+ "levers_robustness_phrase_with_worst": "Déficit projeté de {engine} sur le corpus réel : {deficit_pct} points de CER cumulés sur {n_types} dégradations — pire dégradation : {worst_type} ({worst_pct} points)."
284
  }
picarones/report/levers_render.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML de la section « Leviers d'amélioration » — Sprint 82.
2
+
3
+ A.I.9 du plan d'évolution 2026.
4
+
5
+ Suite directe ``picarones/core/levers.py``. Pattern identique aux
6
+ autres rendus (Sprints 41/43/62/67/72/74/75/76/77/80) : **server-
7
+ side**, pas de JavaScript, anti-injection systématique.
8
+
9
+ Vue
10
+ ---
11
+ Une section composée de **cards** : une par levier, triée par
12
+ importance décroissante. Chaque card affiche :
13
+
14
+ - une *étiquette* (libellé i18n du type de levier) ;
15
+ - une *phrase factuelle* qui réutilise les chiffres du
16
+ ``payload`` (anti-hallucination : aucun chiffre n'est calculé
17
+ dans le rendu) ;
18
+ - éventuellement un **détail compact** (top-N tokens, top-3
19
+ classes, etc.) ;
20
+ - une *note* d'importance : HIGH / MEDIUM / LOW.
21
+
22
+ Aucune classification automatique « bon » / « mauvais » et aucune
23
+ recommandation : la phrase est purement descriptive.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from html import escape as _e
29
+ from typing import Iterable, Optional
30
+
31
+
32
+ def _lever_label(lever_type: str, labels: dict[str, str]) -> str:
33
+ return labels.get(f"levers_label_{lever_type}", lever_type)
34
+
35
+
36
+ def _format_dominant_recoverable(payload: dict, labels: dict[str, str]) -> str:
37
+ engine = _e(str(payload.get("engine", "?")))
38
+ pct = payload.get("share_recoverable_pct")
39
+ n_recov = payload.get("n_recoverable")
40
+ n_total = payload.get("n_total_errors")
41
+ template = labels.get(
42
+ "levers_dominant_recoverable_phrase",
43
+ "{pct}% des erreurs de {engine} ({n_recov}/{n_total}) sont "
44
+ "classifiées récupérables (case_error, ligature_error, "
45
+ "abbreviation_error).",
46
+ )
47
+ sentence = template.format(
48
+ engine=engine,
49
+ pct=pct,
50
+ n_recov=n_recov,
51
+ n_total=n_total,
52
+ )
53
+ top_classes = payload.get("top_classes") or []
54
+ if top_classes:
55
+ breakdown = ", ".join(
56
+ f"{_e(str(c.get('class', '?')))} ({c.get('count', 0)})"
57
+ for c in top_classes
58
+ )
59
+ detail_label = labels.get("levers_top_classes", "Principales :")
60
+ sentence += (
61
+ f' <span style="opacity:.8">— {_e(detail_label)} '
62
+ f'{breakdown}</span>'
63
+ )
64
+ return sentence
65
+
66
+
67
+ def _format_pareto_concentration(payload: dict, labels: dict[str, str]) -> str:
68
+ engine = _e(str(payload.get("engine", "?")))
69
+ n_top = payload.get("n_docs_top")
70
+ n_total = payload.get("n_docs")
71
+ top_pct = payload.get("top_share_pct")
72
+ cer_pct = payload.get("cer_share_pct")
73
+ template = labels.get(
74
+ "levers_pareto_phrase",
75
+ "Sur {engine}, {n_top} documents ({top_pct}% du corpus) "
76
+ "concentrent {cer_pct}% du CER cumulé "
77
+ "(sur {n_total} documents au total).",
78
+ )
79
+ return template.format(
80
+ engine=engine,
81
+ n_top=n_top,
82
+ n_total=n_total,
83
+ top_pct=top_pct,
84
+ cer_pct=cer_pct,
85
+ )
86
+
87
+
88
+ def _format_complementarity(payload: dict, labels: dict[str, str]) -> str:
89
+ abs_pct = payload.get("absolute_gap_pct")
90
+ rel_pct = payload.get("relative_gap_pct")
91
+ best_engine = payload.get("best_engine")
92
+ if best_engine:
93
+ template = labels.get(
94
+ "levers_complementarity_phrase_with_engine",
95
+ "L'oracle bag-of-words atteint un rappel supérieur de "
96
+ "{abs_pct} points (+{rel_pct}% relatif) à celui du meilleur "
97
+ "moteur seul ({best_engine}).",
98
+ )
99
+ return template.format(
100
+ abs_pct=abs_pct,
101
+ rel_pct=rel_pct,
102
+ best_engine=_e(str(best_engine)),
103
+ )
104
+ template = labels.get(
105
+ "levers_complementarity_phrase",
106
+ "L'oracle bag-of-words atteint un rappel supérieur de "
107
+ "{abs_pct} points (+{rel_pct}% relatif) à celui du meilleur "
108
+ "moteur seul.",
109
+ )
110
+ return template.format(abs_pct=abs_pct, rel_pct=rel_pct)
111
+
112
+
113
+ def _format_lexical_modernization(payload: dict, labels: dict[str, str]) -> str:
114
+ engine = _e(str(payload.get("engine", "?")))
115
+ top_tokens = payload.get("top_tokens") or []
116
+ if not top_tokens:
117
+ return ""
118
+ items = ", ".join(
119
+ f"{_e(str(t.get('gt_token', '?')))} "
120
+ f"({t.get('rate_modernized_pct', 0)}%, "
121
+ f"n={t.get('n_total', 0)})"
122
+ for t in top_tokens
123
+ )
124
+ template = labels.get(
125
+ "levers_lexical_phrase",
126
+ "Top tokens GT systématiquement modernisés par {engine} : {items}.",
127
+ )
128
+ return template.format(engine=engine, items=items)
129
+
130
+
131
+ def _format_robustness_projection(payload: dict, labels: dict[str, str]) -> str:
132
+ engine = _e(str(payload.get("engine", "?")))
133
+ deficit_pct = payload.get("total_expected_deficit_pct")
134
+ n_types = payload.get("n_degradation_types", 0)
135
+ worst_type = payload.get("worst_degradation_type")
136
+ worst_pct = payload.get("worst_degradation_deficit_pct")
137
+ if worst_type and worst_pct is not None:
138
+ template = labels.get(
139
+ "levers_robustness_phrase_with_worst",
140
+ "Déficit projeté de {engine} sur le corpus réel : "
141
+ "{deficit_pct} points de CER cumulés sur {n_types} "
142
+ "dégradations — pire dégradation : {worst_type} "
143
+ "({worst_pct} points).",
144
+ )
145
+ return template.format(
146
+ engine=engine,
147
+ deficit_pct=deficit_pct,
148
+ n_types=n_types,
149
+ worst_type=_e(str(worst_type)),
150
+ worst_pct=worst_pct,
151
+ )
152
+ template = labels.get(
153
+ "levers_robustness_phrase",
154
+ "Déficit projeté de {engine} sur le corpus réel : "
155
+ "{deficit_pct} points de CER cumulés sur {n_types} dégradations.",
156
+ )
157
+ return template.format(
158
+ engine=engine, deficit_pct=deficit_pct, n_types=n_types,
159
+ )
160
+
161
+
162
+ _FORMATTERS = {
163
+ "dominant_recoverable_class": _format_dominant_recoverable,
164
+ "pareto_concentration": _format_pareto_concentration,
165
+ "complementarity_observation": _format_complementarity,
166
+ "lexical_modernization_observation": _format_lexical_modernization,
167
+ "robustness_projection_observation": _format_robustness_projection,
168
+ }
169
+
170
+
171
+ def _importance_label(importance: int, labels: dict[str, str]) -> str:
172
+ if importance >= 70:
173
+ return labels.get("levers_importance_high", "Important")
174
+ if importance >= 40:
175
+ return labels.get("levers_importance_medium", "À noter")
176
+ return labels.get("levers_importance_low", "Mineur")
177
+
178
+
179
+ def _importance_color(importance: int) -> str:
180
+ if importance >= 70:
181
+ return "#c2410c" # orange profond
182
+ if importance >= 40:
183
+ return "#0369a1" # bleu
184
+ return "#6b7280" # gris
185
+
186
+
187
+ def build_levers_section_html(
188
+ levers: Iterable,
189
+ labels: Optional[dict[str, str]] = None,
190
+ ) -> str:
191
+ """Construit la section HTML des leviers.
192
+
193
+ Parameters
194
+ ----------
195
+ levers:
196
+ Itérable de ``Lever`` (ou de dicts avec ``type``,
197
+ ``importance``, ``payload``).
198
+ labels:
199
+ Dict i18n. Clés attendues sous le préfixe ``levers_``.
200
+
201
+ Returns
202
+ -------
203
+ str
204
+ Section HTML, ou ``""`` si aucun levier exploitable.
205
+ """
206
+ labels = labels or {}
207
+ cards: list[str] = []
208
+ for lever in levers:
209
+ # Accepter Lever ou dict
210
+ if hasattr(lever, "as_dict"):
211
+ data = lever.as_dict()
212
+ elif isinstance(lever, dict):
213
+ data = lever
214
+ else:
215
+ continue
216
+ lv_type = data.get("type")
217
+ importance = int(data.get("importance") or 0)
218
+ payload = data.get("payload") or {}
219
+ if not lv_type:
220
+ continue
221
+ formatter = _FORMATTERS.get(lv_type)
222
+ if formatter is None:
223
+ continue
224
+ try:
225
+ sentence = formatter(payload, labels)
226
+ except Exception:
227
+ continue
228
+ if not sentence:
229
+ continue
230
+ type_label = _lever_label(lv_type, labels)
231
+ imp_label = _importance_label(importance, labels)
232
+ imp_color = _importance_color(importance)
233
+ cards.append(
234
+ '<div class="lever-card" style="border:1px solid #e5e7eb;'
235
+ 'border-left:4px solid ' + imp_color + ';'
236
+ 'border-radius:.4rem;padding:.7rem .9rem;'
237
+ 'margin:.5rem 0;background:#fafafa">'
238
+ f'<div style="display:flex;justify-content:space-between;'
239
+ f'align-items:center;margin-bottom:.3rem;font-size:.8rem">'
240
+ f'<span style="font-weight:600;text-transform:uppercase;'
241
+ f'letter-spacing:.5px;color:#374151">'
242
+ f'{_e(type_label)}</span>'
243
+ f'<span style="color:{imp_color};font-weight:600">'
244
+ f'{_e(imp_label)}</span>'
245
+ f'</div>'
246
+ f'<div style="font-size:.95rem;line-height:1.45">'
247
+ f'{sentence}</div>'
248
+ '</div>'
249
+ )
250
+
251
+ if not cards:
252
+ return ""
253
+
254
+ title = labels.get("levers_title", "Leviers d'amélioration")
255
+ note = labels.get(
256
+ "levers_note",
257
+ "Observations factuelles synthétisées depuis les modules "
258
+ "d'analyse. Aucune recommandation imposée — c'est au "
259
+ "chercheur de juger ce qui est exploitable selon son "
260
+ "workflow.",
261
+ )
262
+
263
+ parts = [
264
+ '<section class="levers-section" style="margin:1.5rem 0">',
265
+ f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
266
+ f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.6rem">'
267
+ f'{_e(note)}</div>',
268
+ ]
269
+ parts.extend(cards)
270
+ parts.append('</section>')
271
+ return "".join(parts)
272
+
273
+
274
+ __all__ = [
275
+ "build_levers_section_html",
276
+ ]
tests/test_sprint82_levers.py ADDED
@@ -0,0 +1,575 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests Sprint 82 — A.I.9 : section « Leviers d'amélioration ».
2
+
3
+ Couvre :
4
+
5
+ 1. Modèle ``Lever`` + registre.
6
+ 2. Les 5 détecteurs : ``dominant_recoverable_class``,
7
+ ``pareto_concentration``, ``complementarity_observation``,
8
+ ``lexical_modernization_observation``,
9
+ ``robustness_projection_observation``.
10
+ 3. Pipeline ``detect_levers`` (ordre, robustesse aux exceptions).
11
+ 4. Rendu HTML : cards, anti-injection, masquage adaptatif.
12
+ 5. Anti-hallucination : chaque chiffre rendu est dans le payload.
13
+ 6. Complétude i18n FR/EN.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import re
20
+ from pathlib import Path
21
+
22
+ from picarones.core.levers import (
23
+ Lever,
24
+ LeverImportance,
25
+ LeverType,
26
+ detect_complementarity_observation,
27
+ detect_dominant_recoverable_class,
28
+ detect_levers,
29
+ detect_lexical_modernization_observation,
30
+ detect_pareto_concentration,
31
+ detect_robustness_projection_observation,
32
+ iter_lever_detectors,
33
+ )
34
+ from picarones.report.levers_render import build_levers_section_html
35
+
36
+
37
+ # ──────────────────────────────────────────────────────────────────────────
38
+ # 1. Modèle + registre
39
+ # ──────────────────────────────────────────────────────────────────────────
40
+
41
+
42
+ class TestModel:
43
+ def test_lever_as_dict(self) -> None:
44
+ lv = Lever(
45
+ type=LeverType.DOMINANT_RECOVERABLE_CLASS,
46
+ importance=LeverImportance.HIGH,
47
+ payload={"engine": "t", "share_recoverable_pct": 65.0},
48
+ engines_involved=("t",),
49
+ )
50
+ d = lv.as_dict()
51
+ assert d["type"] == "dominant_recoverable_class"
52
+ assert d["importance"] == 70
53
+ assert d["engines_involved"] == ["t"]
54
+
55
+ def test_registry_contains_five_detectors(self) -> None:
56
+ types = {e.lever_type for e in iter_lever_detectors()}
57
+ assert LeverType.DOMINANT_RECOVERABLE_CLASS in types
58
+ assert LeverType.PARETO_CONCENTRATION in types
59
+ assert LeverType.COMPLEMENTARITY_OBSERVATION in types
60
+ assert LeverType.LEXICAL_MODERNIZATION_OBSERVATION in types
61
+ assert LeverType.ROBUSTNESS_PROJECTION_OBSERVATION in types
62
+
63
+ def test_registry_priority_sorted(self) -> None:
64
+ priorities = [e.priority for e in iter_lever_detectors()]
65
+ assert priorities == sorted(priorities)
66
+
67
+
68
+ # ──────────────────────────────────────────────────────────────────────────
69
+ # 2. Détecteur dominant_recoverable_class
70
+ # ──────────────────────────────────────────────────────────────────────────
71
+
72
+
73
+ class TestDominantRecoverable:
74
+ def test_emits_when_share_above_threshold(self) -> None:
75
+ data = {"engines": [{
76
+ "name": "t",
77
+ "aggregated_taxonomy": {
78
+ "case_error": 30,
79
+ "ligature_error": 10,
80
+ "abbreviation_error": 25, # 65 récupérables
81
+ "lacuna": 20,
82
+ "diacritic_error": 15,
83
+ },
84
+ }]}
85
+ levers = detect_dominant_recoverable_class(data)
86
+ assert len(levers) == 1
87
+ lv = levers[0]
88
+ assert lv.payload["engine"] == "t"
89
+ assert lv.payload["n_recoverable"] == 65
90
+ assert lv.payload["n_total_errors"] == 100
91
+ assert lv.payload["share_recoverable_pct"] == 65.0
92
+ assert lv.importance == LeverImportance.HIGH
93
+
94
+ def test_silent_when_below_threshold(self) -> None:
95
+ data = {"engines": [{
96
+ "name": "t",
97
+ "aggregated_taxonomy": {"lacuna": 80, "case_error": 20},
98
+ }]}
99
+ assert detect_dominant_recoverable_class(data) == []
100
+
101
+ def test_silent_when_no_taxonomy(self) -> None:
102
+ data = {"engines": [{"name": "t"}]}
103
+ assert detect_dominant_recoverable_class(data) == []
104
+
105
+ def test_top_classes_sorted_descending(self) -> None:
106
+ data = {"engines": [{
107
+ "name": "t",
108
+ "aggregated_taxonomy": {
109
+ "case_error": 50,
110
+ "ligature_error": 5,
111
+ "abbreviation_error": 30,
112
+ },
113
+ }]}
114
+ lv = detect_dominant_recoverable_class(data)[0]
115
+ names = [c["class"] for c in lv.payload["top_classes"]]
116
+ assert names == ["case_error", "abbreviation_error", "ligature_error"]
117
+
118
+ def test_accepts_counts_subdict(self) -> None:
119
+ data = {"engines": [{
120
+ "name": "t",
121
+ "aggregated_taxonomy": {"counts": {"case_error": 60, "lacuna": 40}},
122
+ }]}
123
+ levers = detect_dominant_recoverable_class(data)
124
+ assert len(levers) == 1
125
+ assert levers[0].payload["n_recoverable"] == 60
126
+
127
+ def test_medium_when_share_in_30_50(self) -> None:
128
+ data = {"engines": [{
129
+ "name": "t",
130
+ "aggregated_taxonomy": {"case_error": 35, "lacuna": 65},
131
+ }]}
132
+ lv = detect_dominant_recoverable_class(data)[0]
133
+ assert lv.importance == LeverImportance.MEDIUM
134
+
135
+
136
+ # ──────────────────────────────────────────────────────────────────────────
137
+ # 3. Détecteur pareto_concentration
138
+ # ──────────────────────────────────────────────────────────────────────────
139
+
140
+
141
+ class TestParetoConcentration:
142
+ def test_concentrated_corpus(self) -> None:
143
+ # 10 docs : 2 catastrophiques (CER 0.8), 8 OK (CER 0.05) → 80 %
144
+ # du CER total est concentré sur 20 % des docs.
145
+ data = {
146
+ "ranking": [{"engine": "t", "mean_cer": 0.20}],
147
+ "per_doc_cer": {"t": [0.8, 0.8] + [0.05] * 8},
148
+ }
149
+ levers = detect_pareto_concentration(data)
150
+ assert len(levers) == 1
151
+ p = levers[0].payload
152
+ assert p["n_docs"] == 10
153
+ assert p["n_docs_top"] == 2
154
+ assert p["cer_share_pct"] >= 70
155
+
156
+ def test_uniform_corpus_silent(self) -> None:
157
+ data = {
158
+ "ranking": [{"engine": "t", "mean_cer": 0.10}],
159
+ "per_doc_cer": {"t": [0.10] * 10},
160
+ }
161
+ assert detect_pareto_concentration(data) == []
162
+
163
+ def test_reads_engine_per_doc(self) -> None:
164
+ data = {
165
+ "ranking": [{"engine": "t", "mean_cer": 0.20}],
166
+ "engines": [{
167
+ "name": "t",
168
+ "per_doc": [
169
+ {"cer": 0.9}, {"cer": 0.9},
170
+ {"cer": 0.05}, {"cer": 0.05}, {"cer": 0.05},
171
+ {"cer": 0.05}, {"cer": 0.05}, {"cer": 0.05},
172
+ {"cer": 0.05}, {"cer": 0.05},
173
+ ],
174
+ }],
175
+ }
176
+ levers = detect_pareto_concentration(data)
177
+ assert len(levers) == 1
178
+
179
+ def test_no_ranking_silent(self) -> None:
180
+ assert detect_pareto_concentration({}) == []
181
+
182
+ def test_no_per_doc_silent(self) -> None:
183
+ data = {"ranking": [{"engine": "t", "mean_cer": 0.10}]}
184
+ assert detect_pareto_concentration(data) == []
185
+
186
+
187
+ # ──────────────────────────────────────────────────────────────────────────
188
+ # 4. Détecteur complementarity_observation
189
+ # ──────────────────────────────────────────────────────────────────────────
190
+
191
+
192
+ class TestComplementarity:
193
+ def test_emits_when_relative_gap_above_threshold(self) -> None:
194
+ data = {"inter_engine_analysis": {
195
+ "complementarity_gap": {
196
+ "absolute_gap": 0.10,
197
+ "relative_gap": 0.30,
198
+ "best_engine": "t",
199
+ "best_recall": 0.70,
200
+ "oracle_recall": 0.80,
201
+ },
202
+ }}
203
+ levers = detect_complementarity_observation(data)
204
+ assert len(levers) == 1
205
+ p = levers[0].payload
206
+ assert p["best_engine"] == "t"
207
+ assert p["absolute_gap_pct"] == 10.0
208
+ assert p["relative_gap_pct"] == 30.0
209
+
210
+ def test_silent_when_below_threshold(self) -> None:
211
+ data = {"inter_engine_analysis": {
212
+ "complementarity_gap": {"absolute_gap": 0.02, "relative_gap": 0.05},
213
+ }}
214
+ assert detect_complementarity_observation(data) == []
215
+
216
+ def test_silent_when_no_data(self) -> None:
217
+ assert detect_complementarity_observation({}) == []
218
+
219
+ def test_high_when_relative_gap_above_50(self) -> None:
220
+ data = {"inter_engine_analysis": {
221
+ "complementarity_gap": {"absolute_gap": 0.30, "relative_gap": 0.60},
222
+ }}
223
+ lv = detect_complementarity_observation(data)[0]
224
+ assert lv.importance == LeverImportance.HIGH
225
+
226
+
227
+ # ──────────────────────────────────────────────────────────────────────────
228
+ # 5. Détecteur lexical_modernization_observation
229
+ # ──────────────────────────────────────────────────────────────────────────
230
+
231
+
232
+ class TestLexicalModernization:
233
+ def test_emits_top_three(self) -> None:
234
+ data = {"engines": [{
235
+ "name": "gpt4o",
236
+ "lexical_modernization": {
237
+ "n_gt_tokens": 50,
238
+ "tokens": {
239
+ "maistre": {"n_total": 10, "n_modernized": 10,
240
+ "rate_modernized": 1.0,
241
+ "variants": {"maître": 10}},
242
+ "veoir": {"n_total": 5, "n_modernized": 5,
243
+ "rate_modernized": 1.0,
244
+ "variants": {"voir": 5}},
245
+ "nostre": {"n_total": 8, "n_modernized": 6,
246
+ "rate_modernized": 0.75,
247
+ "variants": {"notre": 6}},
248
+ "ami": {"n_total": 3, "n_modernized": 0,
249
+ "rate_modernized": 0.0, "variants": {}},
250
+ },
251
+ },
252
+ }]}
253
+ levers = detect_lexical_modernization_observation(data)
254
+ assert len(levers) == 1
255
+ top = levers[0].payload["top_tokens"]
256
+ gt_tokens = [t["gt_token"] for t in top]
257
+ # Tri par rate desc, puis n_total desc → maistre, veoir, nostre
258
+ assert gt_tokens == ["maistre", "veoir", "nostre"]
259
+ assert levers[0].importance == LeverImportance.HIGH
260
+
261
+ def test_silent_when_no_tokens_above_min_rate(self) -> None:
262
+ data = {"engines": [{
263
+ "name": "t",
264
+ "lexical_modernization": {
265
+ "tokens": {"a": {"n_total": 10, "n_modernized": 1,
266
+ "rate_modernized": 0.10, "variants": {}}},
267
+ },
268
+ }]}
269
+ assert detect_lexical_modernization_observation(data) == []
270
+
271
+ def test_silent_when_n_total_below_min(self) -> None:
272
+ data = {"engines": [{
273
+ "name": "t",
274
+ "lexical_modernization": {
275
+ "tokens": {"a": {"n_total": 1, "n_modernized": 1,
276
+ "rate_modernized": 1.0, "variants": {}}},
277
+ },
278
+ }]}
279
+ assert detect_lexical_modernization_observation(data) == []
280
+
281
+ def test_silent_when_no_lexical_field(self) -> None:
282
+ data = {"engines": [{"name": "t"}]}
283
+ assert detect_lexical_modernization_observation(data) == []
284
+
285
+
286
+ # ──────────────────────────────────────────────────────────────────────────
287
+ # 6. Détecteur robustness_projection_observation
288
+ # ──────────────────────────────────────────────────────────────────────────
289
+
290
+
291
+ class TestRobustnessProjection:
292
+ def test_emits_when_deficit_above_threshold(self) -> None:
293
+ data = {"robustness_projection_aggregated": {
294
+ "tess": {
295
+ "total_expected_deficit": 0.06,
296
+ "n_degradation_types": 2,
297
+ "worst_degradation_type": "noise",
298
+ "worst_degradation_deficit": 0.04,
299
+ },
300
+ }}
301
+ levers = detect_robustness_projection_observation(data)
302
+ assert len(levers) == 1
303
+ p = levers[0].payload
304
+ assert p["engine"] == "tess"
305
+ assert p["total_expected_deficit_pct"] == 6.0
306
+ assert p["worst_degradation_type"] == "noise"
307
+ assert levers[0].importance == LeverImportance.HIGH
308
+
309
+ def test_silent_when_deficit_too_low(self) -> None:
310
+ data = {"robustness_projection_aggregated": {
311
+ "tess": {"total_expected_deficit": 0.005},
312
+ }}
313
+ assert detect_robustness_projection_observation(data) == []
314
+
315
+ def test_silent_when_no_data(self) -> None:
316
+ assert detect_robustness_projection_observation({}) == []
317
+
318
+ def test_sorted_by_deficit_descending(self) -> None:
319
+ data = {"robustness_projection_aggregated": {
320
+ "a": {"total_expected_deficit": 0.03,
321
+ "n_degradation_types": 1},
322
+ "b": {"total_expected_deficit": 0.08,
323
+ "n_degradation_types": 2},
324
+ }}
325
+ levers = detect_robustness_projection_observation(data)
326
+ assert [lv.payload["engine"] for lv in levers] == ["b", "a"]
327
+
328
+
329
+ # ──────────────────────────────────────────────────────────────────────────
330
+ # 7. Pipeline detect_levers
331
+ # ──────────────────────────────────────────────────────────────────────────
332
+
333
+
334
+ class TestDetectLevers:
335
+ def test_aggregates_multiple_types(self) -> None:
336
+ data = {
337
+ "engines": [{
338
+ "name": "t",
339
+ "aggregated_taxonomy": {"case_error": 60, "lacuna": 40},
340
+ }],
341
+ "robustness_projection_aggregated": {
342
+ "t": {"total_expected_deficit": 0.07,
343
+ "n_degradation_types": 2},
344
+ },
345
+ }
346
+ levers = detect_levers(data)
347
+ types = [lv.type for lv in levers]
348
+ assert LeverType.DOMINANT_RECOVERABLE_CLASS in types
349
+ assert LeverType.ROBUSTNESS_PROJECTION_OBSERVATION in types
350
+
351
+ def test_sorted_by_importance_desc(self) -> None:
352
+ # HIGH (robustness 7%) avant MEDIUM (recoverable 35%)
353
+ data = {
354
+ "engines": [{
355
+ "name": "t",
356
+ "aggregated_taxonomy": {"case_error": 35, "lacuna": 65},
357
+ }],
358
+ "robustness_projection_aggregated": {
359
+ "t": {"total_expected_deficit": 0.07,
360
+ "n_degradation_types": 2},
361
+ },
362
+ }
363
+ levers = detect_levers(data)
364
+ importances = [int(lv.importance) for lv in levers]
365
+ assert importances == sorted(importances, reverse=True)
366
+
367
+ def test_empty_input_returns_empty(self) -> None:
368
+ assert detect_levers({}) == []
369
+
370
+
371
+ # ──────────────────────────────────────────────────────────────────────────
372
+ # 8. Rendu HTML
373
+ # ──────────────────────────────────────────────────────────────────────────
374
+
375
+
376
+ def _load_labels(lang: str) -> dict:
377
+ p = (
378
+ Path(__file__).parent.parent
379
+ / "picarones" / "report" / "i18n" / f"{lang}.json"
380
+ )
381
+ return json.loads(p.read_text(encoding="utf-8"))
382
+
383
+
384
+ class TestRender:
385
+ def test_empty_returns_empty(self) -> None:
386
+ assert build_levers_section_html([]) == ""
387
+
388
+ def test_card_per_lever(self) -> None:
389
+ levers = [
390
+ Lever(
391
+ type=LeverType.DOMINANT_RECOVERABLE_CLASS,
392
+ importance=LeverImportance.HIGH,
393
+ payload={"engine": "t", "share_recoverable_pct": 65.0,
394
+ "n_recoverable": 65, "n_total_errors": 100,
395
+ "top_classes": [{"class": "case_error", "count": 50}]},
396
+ ),
397
+ ]
398
+ labels = _load_labels("fr")
399
+ html = build_levers_section_html(levers, labels)
400
+ assert "lever-card" in html
401
+ assert "65" in html
402
+ assert "case_error" in html
403
+ assert "Important" in html
404
+
405
+ def test_anti_injection(self) -> None:
406
+ levers = [
407
+ Lever(
408
+ type=LeverType.DOMINANT_RECOVERABLE_CLASS,
409
+ importance=LeverImportance.HIGH,
410
+ payload={"engine": "<script>alert(1)</script>",
411
+ "share_recoverable_pct": 60.0,
412
+ "n_recoverable": 60, "n_total_errors": 100,
413
+ "top_classes": []},
414
+ ),
415
+ ]
416
+ html = build_levers_section_html(levers, _load_labels("fr"))
417
+ assert "<script>alert" not in html
418
+ assert "&lt;script&gt;" in html
419
+
420
+ def test_unknown_type_skipped(self) -> None:
421
+ # Lever-like dict avec type inconnu → ignoré
422
+ bad = {"type": "unknown_type", "importance": 70, "payload": {}}
423
+ html = build_levers_section_html([bad], _load_labels("fr"))
424
+ assert html == ""
425
+
426
+ def test_accepts_dict_input(self) -> None:
427
+ d = {
428
+ "type": "complementarity_observation",
429
+ "importance": 40,
430
+ "payload": {"absolute_gap_pct": 12.0, "relative_gap_pct": 25.0,
431
+ "absolute_gap": 0.12, "relative_gap": 0.25},
432
+ }
433
+ html = build_levers_section_html([d], _load_labels("fr"))
434
+ assert "12" in html and "25" in html
435
+
436
+ def test_renders_in_english(self) -> None:
437
+ levers = [
438
+ Lever(
439
+ type=LeverType.PARETO_CONCENTRATION,
440
+ importance=LeverImportance.HIGH,
441
+ payload={"engine": "t", "n_docs": 10, "n_docs_top": 2,
442
+ "top_share_pct": 20.0,
443
+ "cer_share_of_total": 0.78,
444
+ "cer_share_pct": 78.0},
445
+ ),
446
+ ]
447
+ html = build_levers_section_html(levers, _load_labels("en"))
448
+ assert "Improvement leverages" in html
449
+ assert "78" in html
450
+
451
+
452
+ # ──────────────────────────────────────────────────────────────────────────
453
+ # 9. Anti-hallucination : chaque chiffre rendu provient du payload
454
+ # ──────────────────────────────────────────────────────────────────────────
455
+
456
+
457
+ def _numbers_in(s: str) -> set[str]:
458
+ """Extrait les nombres du HTML rendu visible.
459
+
460
+ On retire :
461
+ - les styles inline ;
462
+ - les entités HTML (``&#x27;`` ne contient pas le chiffre 27) ;
463
+ - les balises elles-mêmes (``<h3>`` ne contient pas le chiffre 3).
464
+ """
465
+ s_clean = re.sub(r'style="[^"]*"', "", s)
466
+ s_clean = re.sub(r"&#x?[0-9a-fA-F]+;", "", s_clean)
467
+ s_clean = re.sub(r"<[^>]+>", " ", s_clean)
468
+ return set(re.findall(r"\d+(?:\.\d+)?", s_clean))
469
+
470
+
471
+ def _payload_numbers(payload: dict) -> set[str]:
472
+ out: set[str] = set()
473
+ def _walk(v):
474
+ if isinstance(v, (int, float)):
475
+ out.add(str(v))
476
+ # Aussi forme entière "65" si 65.0
477
+ if isinstance(v, float) and v.is_integer():
478
+ out.add(str(int(v)))
479
+ elif isinstance(v, dict):
480
+ for vv in v.values():
481
+ _walk(vv)
482
+ elif isinstance(v, list):
483
+ for vv in v:
484
+ _walk(vv)
485
+ _walk(payload)
486
+ return out
487
+
488
+
489
+ class TestAntiHallucination:
490
+ def test_dominant_numbers_traceable_fr(self) -> None:
491
+ lv = Lever(
492
+ type=LeverType.DOMINANT_RECOVERABLE_CLASS,
493
+ importance=LeverImportance.HIGH,
494
+ payload={"engine": "tess", "share_recoverable_pct": 65.0,
495
+ "n_recoverable": 65, "n_total_errors": 100,
496
+ "top_classes": [{"class": "case_error", "count": 50}]},
497
+ )
498
+ html = build_levers_section_html([lv], _load_labels("fr"))
499
+ rendered = _numbers_in(html)
500
+ allowed = _payload_numbers(lv.payload)
501
+ # Tout chiffre du HTML doit être dans le payload
502
+ assert rendered.issubset(allowed), (
503
+ f"non traçable : {rendered - allowed}"
504
+ )
505
+
506
+ def test_pareto_numbers_traceable_en(self) -> None:
507
+ lv = Lever(
508
+ type=LeverType.PARETO_CONCENTRATION,
509
+ importance=LeverImportance.HIGH,
510
+ payload={"engine": "tess", "n_docs": 47, "n_docs_top": 9,
511
+ "top_share_pct": 19.1,
512
+ "cer_share_of_total": 0.81,
513
+ "cer_share_pct": 80.7},
514
+ )
515
+ html = build_levers_section_html([lv], _load_labels("en"))
516
+ rendered = _numbers_in(html)
517
+ allowed = _payload_numbers(lv.payload)
518
+ assert rendered.issubset(allowed), (
519
+ f"non traçable : {rendered - allowed}"
520
+ )
521
+
522
+ def test_robustness_numbers_traceable_fr(self) -> None:
523
+ lv = Lever(
524
+ type=LeverType.ROBUSTNESS_PROJECTION_OBSERVATION,
525
+ importance=LeverImportance.HIGH,
526
+ payload={"engine": "tess", "total_expected_deficit": 0.058,
527
+ "total_expected_deficit_pct": 5.8,
528
+ "n_degradation_types": 3,
529
+ "worst_degradation_type": "noise",
530
+ "worst_degradation_deficit": 0.041,
531
+ "worst_degradation_deficit_pct": 4.1},
532
+ )
533
+ html = build_levers_section_html([lv], _load_labels("fr"))
534
+ rendered = _numbers_in(html)
535
+ allowed = _payload_numbers(lv.payload)
536
+ assert rendered.issubset(allowed), (
537
+ f"non traçable : {rendered - allowed}"
538
+ )
539
+
540
+
541
+ # ──────────────────────────────────────────────────────────────────────────
542
+ # 10. Complétude i18n
543
+ # ──────────────────────────────────────────────────────────────────────────
544
+
545
+
546
+ _LEVERS_KEYS = {
547
+ "levers_title", "levers_note",
548
+ "levers_top_classes",
549
+ "levers_importance_high", "levers_importance_medium",
550
+ "levers_importance_low",
551
+ "levers_label_dominant_recoverable_class",
552
+ "levers_label_pareto_concentration",
553
+ "levers_label_complementarity_observation",
554
+ "levers_label_lexical_modernization_observation",
555
+ "levers_label_robustness_projection_observation",
556
+ "levers_dominant_recoverable_phrase",
557
+ "levers_pareto_phrase",
558
+ "levers_complementarity_phrase",
559
+ "levers_complementarity_phrase_with_engine",
560
+ "levers_lexical_phrase",
561
+ "levers_robustness_phrase",
562
+ "levers_robustness_phrase_with_worst",
563
+ }
564
+
565
+
566
+ class TestI18nCompleteness:
567
+ def test_fr_has_all_keys(self) -> None:
568
+ d = _load_labels("fr")
569
+ missing = _LEVERS_KEYS - d.keys()
570
+ assert not missing, f"manque FR : {missing}"
571
+
572
+ def test_en_has_all_keys(self) -> None:
573
+ d = _load_labels("en")
574
+ missing = _LEVERS_KEYS - d.keys()
575
+ assert not missing, f"manque EN : {missing}"