Claude commited on
Commit
74a4c16
·
unverified ·
1 Parent(s): 16af665

sprint84: recherchabilité fuzzy A.II.5 (couche calcul + registre typé)

Browse files

Le CER mesure les erreurs caractère par caractère ; pour la recherche
plein-texte (Elastic, Solr, Gallica), la question est combien de mots
GT sont retrouvables à orthographe approchée près. Un CER de 8 %
peut donner 95 % de findability si les erreurs sont concentrées sur
des caractères non significatifs.

- picarones/core/searchability.py :
- levenshtein_distance(a, b) : DP O(|a|·|b|), mémoire O(min(|a|,|b|)).
- compute_searchability(reference, hypothesis, max_distance=2,
case_sensitive=False) : alignement multi-set, retourne
{n_gt_tokens, n_searchable, recall, missed_tokens, max_distance} ;
recall=None quand GT vide pour différencier de "zéro match".
- searchability_recall_metric enregistré dans le registre typé
Sprint 34 pour (TEXT, TEXT). Convention float 0.0 si GT vide.

Défaut max_distance=2 aligné sur Elastic `fuzziness: AUTO`.
Limites documentées : split whitespace, Levenshtein non pondéré,
pas de sémantique (BERTScore reporté).

28 tests dans test_sprint84_searchability.py dont 2 cas réalistes
opposés (Charles→Charlemagne non retrouvé vs maistre→maitre
retrouvé) et intégration compute_at_junction.

Tests : 2815 passed, 2 skipped.

https://claude.ai/code/session_01RusTQYcSfXqTsbFNvwmCV7

CHANGELOG.md CHANGED
@@ -16,6 +16,48 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
16
 
17
  ### Ajouté
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  - **Sprint 83 — A.II.4 : métriques de fiabilité (couche de
20
  calcul).** Premier sprint de l'Étape 4 du plan d'évolution
21
  2026 après la clôture de A.I. Une publication scientifique
 
16
 
17
  ### Ajouté
18
 
19
+ - **Sprint 84 — A.II.5 : recherchabilité fuzzy (couche de
20
+ calcul + métrique enregistrée).** Le CER mesure les erreurs
21
+ caractère par caractère ; pour un usage *recherche
22
+ plein-texte* (Elastic, Solr en mode fuzzy, full-text de
23
+ Gallica), la question réelle est : *« combien de mots GT
24
+ sont retrouvables dans la sortie OCR à orthographe approchée
25
+ près ? »*. Un CER de 8 % peut donner 95 % de findability si
26
+ les erreurs sont concentrées sur des caractères non
27
+ significatifs ; à l'inverse, 4 % de CER mais distribué sur
28
+ tous les noms propres rend le corpus inutilisable pour
29
+ l'indexation prosopographique. Nouveau module
30
+ `picarones/core/searchability.py` : `levenshtein_distance(a,
31
+ b)` (DP O(|a|·|b|), mémoire O(min(|a|,|b|)));
32
+ `compute_searchability(reference, hypothesis,
33
+ max_distance=2, case_sensitive=False)` aligne par multi-set
34
+ (un token hyp utilisé une seule fois, comme
35
+ rare_token_recall Sprint 71), retourne `{n_gt_tokens,
36
+ n_searchable, recall, missed_tokens, max_distance}` avec
37
+ `recall=None` quand n_gt=0 (différencie GT vide de aucun
38
+ match), court-circuit longueur (Levenshtein ≥ |Δlen|) et
39
+ arrêt précoce sur match exact. `searchability_recall_metric`
40
+ enregistré dans le registre typé Sprint 34 pour la jonction
41
+ `(TEXT, TEXT)` (convention float : 0.0 si GT vide). Tableau
42
+ Elastic ``fuzziness: AUTO`` (≤ 2) en défaut, paramétrable.
43
+ Limites documentées : tokenisation par split whitespace ;
44
+ Levenshtein non pondéré ; pas de sémantique (BERTScore
45
+ reporté). +28 tests dans `test_sprint84_searchability.py`
46
+ (Levenshtein 9 cas dont identité/insertion/suppression/
47
+ substitution/disjoint/empty/kitten classique, computation
48
+ 13 cas dont identité, complètement différent, GT vide
49
+ (recall None), hypothèse vide (recall 0), max_distance=0
50
+ exact, max_distance=2 swap, max_distance large, casse
51
+ insensible, casse sensible opt-in, multiplicité,
52
+ missed_tokens préserve casse GT, ValueError sur
53
+ max_distance négatif, deux **cas réalistes opposés**
54
+ (« Charles → Charlemagne » non retrouvé vs « maistre →
55
+ maitre » retrouvé), intégration registre 4 cas dont
56
+ `compute_at_junction`). **Verrou levé** : un bench BnF
57
+ d'archive numérique peut désormais classer ses moteurs sur
58
+ la dimension *« mes corpus seront-ils retrouvables après
59
+ OCRisation ? »* — proxy direct de la valeur d'usage.
60
+
61
  - **Sprint 83 — A.II.4 : métriques de fiabilité (couche de
62
  calcul).** Premier sprint de l'Étape 4 du plan d'évolution
63
  2026 après la clôture de A.I. Une publication scientifique
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
  | 83 | **Sprint 52 du plan d'évolution 2026 — A.II.4 : métriques de fiabilité (couche de calcul, démarrage Étape 4 post-A.I)**. Une publication scientifique qui rapporte un CER LLM sans stabilité est méthodologiquement faible ; un benchmark qui ignore le plafond humain crée des classements faussement optimistes. Nouveau module `picarones/core/reliability.py` couvrant deux familles : (1) **IAA caractère** — `cohen_kappa(annotations_a, annotations_b)` retourne κ standard avec convention 1.0/0.0 documentée pour `pe=1` indéfini, garde-fous sur tailles/vide ; `krippendorff_alpha(units)` mode nominal généralisé à N annotateurs avec missing values (cellules None autorisées), formule `1 - D_o / D_e` sur paires sans remise, `None` si single label corpus-wide ou aucune unité ≥2 valides ; `_aligned_char_pairs(text_a, text_b)` aligne via `SequenceMatcher` sur opcodes `equal` et `replace` (insert/delete sans alignement bilatéral), `compute_iaa(transcription_a, transcription_b)` retourne `{n_aligned_chars, cohen_kappa, krippendorff_alpha, agreement_rate}`. (2) **Stabilité multi-runs** — `compute_multirun_stability(runs, reference=None)` mesure `pairwise_disagreement_mean/max` (Jaccard token-level), `identical_run_rate`, `n_distinct_outputs` ; si reference fournie, calcule `cer_per_run`, `cer_mean`, `cer_stdev`, `cer_cv` (None si mean=0 pour éviter division par zéro). Retourne None si <2 runs. Pure couche de calcul : pas d'extension du loader pour multi-GT, pas d'option runner `--repeats N`, pas de détecteur narratif `engine_unstable` — reportés à des sprints dédiés. +26 tests dans `test_sprint83_reliability.py` (cohen_kappa 6 cas dont accord parfait/désaccord pire que hasard κ=-1/un seul label, krippendorff 5 cas dont missing/single label corpus-wide, compute_iaa 5 cas dont empty/one-empty, multirun 6 cas dont reference parfaite et CV indéfini, _aligned_char_pairs 4 cas). **Verrou levé** : le rapport pourra demain afficher *« CER de Pero 4,2 % approche le plafond inter-paléographes κ=0,89 »* et signaler les pipelines LLM dont la variance dépasse un seuil. |
211
  | 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. |
212
  | 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. |
@@ -301,7 +302,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
301
  ## Contexte développement
302
 
303
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
304
- - **Tests** : 2787 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 ; **Sprint 83 = A.II.4 — métriques de fiabilité (IAA Cohen κ + Krippendorff α + stabilité multi-runs, couche calcul)**)
305
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
306
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
307
  - **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
+ | 84 | **Sprint 53 du plan d'évolution 2026 — A.II.5 : recherchabilité fuzzy (couche de calcul + registre typé)**. Le CER mesure les erreurs caractère par caractère ; pour la recherche plein-texte (Elastic, Solr, full-text Gallica), la question réelle est *« combien de mots GT sont retrouvables à orthographe approchée près ? »*. Un CER de 8 % peut donner 95 % de findability si les erreurs sont sur des caractères non significatifs ; à l'inverse 4 % distribué sur tous les noms propres rend le corpus inutilisable pour l'indexation prosopographique. Nouveau module `picarones/core/searchability.py` : `levenshtein_distance(a, b)` DP O(|a|·|b|) mémoire O(min(|a|,|b|)) ; `compute_searchability(reference, hypothesis, max_distance=2, case_sensitive=False)` aligne par multi-set (un token hyp utilisé une seule fois, comme `rare_token_recall` Sprint 71), retourne `{n_gt_tokens, n_searchable, recall, missed_tokens, max_distance}` avec `recall=None` quand n_gt=0 (différencie GT vide de zéro match), court-circuit longueur (Levenshtein ≥ |Δlen|) et arrêt précoce sur match exact ; `searchability_recall_metric` enregistré dans le registre typé Sprint 34 pour `(TEXT, TEXT)` (convention float : 0.0 si GT vide pour cohérence runner). Défaut `max_distance=2` aligné sur Elastic `fuzziness: AUTO`. Limites documentées : tokenisation par split whitespace, Levenshtein non pondéré, pas de sémantique. +28 tests (Levenshtein 9 cas standards dont kitten classique, computation 13 cas dont identité/disjoint/GT vide/hypothèse vide/max_distance=0|2|large/casse/multiplicité/missed_tokens préserve casse GT/ValueError max_distance<0, **2 cas réalistes opposés** Charles→Charlemagne non retrouvé vs maistre→maitre retrouvé, intégration registre 4 cas dont `compute_at_junction`). **Verrou levé** : un bench BnF d'archive numérique peut désormais classer ses moteurs sur la dimension *« mes corpus seront-ils retrouvables après OCRisation ? »* — proxy direct de la valeur d'usage. |
211
  | 83 | **Sprint 52 du plan d'évolution 2026 — A.II.4 : métriques de fiabilité (couche de calcul, démarrage Étape 4 post-A.I)**. Une publication scientifique qui rapporte un CER LLM sans stabilité est méthodologiquement faible ; un benchmark qui ignore le plafond humain crée des classements faussement optimistes. Nouveau module `picarones/core/reliability.py` couvrant deux familles : (1) **IAA caractère** — `cohen_kappa(annotations_a, annotations_b)` retourne κ standard avec convention 1.0/0.0 documentée pour `pe=1` indéfini, garde-fous sur tailles/vide ; `krippendorff_alpha(units)` mode nominal généralisé à N annotateurs avec missing values (cellules None autorisées), formule `1 - D_o / D_e` sur paires sans remise, `None` si single label corpus-wide ou aucune unité ≥2 valides ; `_aligned_char_pairs(text_a, text_b)` aligne via `SequenceMatcher` sur opcodes `equal` et `replace` (insert/delete sans alignement bilatéral), `compute_iaa(transcription_a, transcription_b)` retourne `{n_aligned_chars, cohen_kappa, krippendorff_alpha, agreement_rate}`. (2) **Stabilité multi-runs** — `compute_multirun_stability(runs, reference=None)` mesure `pairwise_disagreement_mean/max` (Jaccard token-level), `identical_run_rate`, `n_distinct_outputs` ; si reference fournie, calcule `cer_per_run`, `cer_mean`, `cer_stdev`, `cer_cv` (None si mean=0 pour éviter division par zéro). Retourne None si <2 runs. Pure couche de calcul : pas d'extension du loader pour multi-GT, pas d'option runner `--repeats N`, pas de détecteur narratif `engine_unstable` — reportés à des sprints dédiés. +26 tests dans `test_sprint83_reliability.py` (cohen_kappa 6 cas dont accord parfait/désaccord pire que hasard κ=-1/un seul label, krippendorff 5 cas dont missing/single label corpus-wide, compute_iaa 5 cas dont empty/one-empty, multirun 6 cas dont reference parfaite et CV indéfini, _aligned_char_pairs 4 cas). **Verrou levé** : le rapport pourra demain afficher *« CER de Pero 4,2 % approche le plafond inter-paléographes κ=0,89 »* et signaler les pipelines LLM dont la variance dépasse un seuil. |
212
  | 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. |
213
  | 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. |
 
302
  ## Contexte développement
303
 
304
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
305
+ - **Tests** : 2815 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 ; Sprint 83 = A.II.4 — métriques de fiabilité (IAA Cohen κ + Krippendorff α + stabilité multi-runs, couche calcul) ; **Sprint 84 = A.II.5 — recherchabilité fuzzy (Levenshtein ≤ 2, registre typé)**)
306
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
307
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
308
  - **Transcript de la conversation de développement** :
picarones/core/searchability.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Recherchabilité fuzzy — Sprint 84 (A.II.5).
2
+
3
+ Sprint 84 — A.II.5 du plan d'évolution 2026.
4
+
5
+ Pourquoi ce module
6
+ ------------------
7
+ Le CER mesure les erreurs caractère par caractère. Mais pour
8
+ un usage *recherche plein-texte* (ce que font Elastic, Solr en
9
+ mode fuzzy, ou la recherche full-text de Gallica), la question
10
+ réelle est :
11
+
12
+ *« Combien de mots de ma GT sont retrouvables dans la
13
+ sortie OCR, à orthographe approchée près ? »*
14
+
15
+ Un CER de 8 % peut donner 95 % de findability si les erreurs
16
+ sont concentrées sur des caractères non-significatifs ou sur
17
+ quelques mots aberrants ; à l'inverse, 4 % de CER mais
18
+ distribué sur tous les noms propres rend le corpus inutilisable
19
+ pour l'indexation prosopographique.
20
+
21
+ Méthode
22
+ -------
23
+ Pour chaque token GT, on regarde s'il existe au moins un token
24
+ hypothèse à distance de Levenshtein ≤ ``max_distance`` (défaut
25
+ 2, valeur Elastic ``fuzziness: AUTO`` standard pour mots ≥ 5
26
+ caractères). Le **rappel** est la proportion de tokens GT
27
+ ainsi retrouvés.
28
+
29
+ Multiplicité
30
+ ------------
31
+ Si la GT contient *« le »* deux fois et l'hypothèse une fois,
32
+ seul un token GT est compté comme retrouvé (alignement
33
+ multi-set, comme ``rare_token_recall`` Sprint 71).
34
+
35
+ Sortie
36
+ ------
37
+ ``compute_searchability(reference, hypothesis)`` retourne
38
+ ``{n_gt_tokens, n_searchable, recall, missed_tokens}``.
39
+
40
+ Limites documentées
41
+ -------------------
42
+ - Tokenisation par split sur whitespace (cohérent avec le reste
43
+ du codebase). Pas de stemming ni de lemmatisation.
44
+ - Levenshtein non pondéré — substitution = insertion = suppression
45
+ = 1. Pour un poids différent (par ex. faute classique
46
+ diacritique = 0,5), passer une fonction custom.
47
+ - Pas de sémantique : *« roi »* ≠ *« souverain »*. Pour la
48
+ similarité sémantique, voir des modules futurs (BERTScore).
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ import logging
54
+ from typing import Optional
55
+
56
+ from picarones.core.metric_registry import register_metric
57
+ from picarones.core.modules import ArtifactType
58
+
59
+ logger = logging.getLogger(__name__)
60
+
61
+
62
+ # ──────────────────────────────────────────────────────────────────────────
63
+ # Tokenisation et distance d'édition
64
+ # ──────────────────────────────────────────────────────────────────────────
65
+
66
+
67
+ def _split_words(text: Optional[str]) -> list[str]:
68
+ """Tokenisation par whitespace — cohérent avec
69
+ ``lexical_modernization.py``, ``rare_tokens.py``, etc."""
70
+ if not text:
71
+ return []
72
+ return text.split()
73
+
74
+
75
+ def levenshtein_distance(a: str, b: str) -> int:
76
+ """Distance de Levenshtein (substitution=insertion=suppression=1).
77
+
78
+ Implémentation DP O(|a|·|b|) en mémoire O(min(|a|,|b|)).
79
+ """
80
+ if a == b:
81
+ return 0
82
+ if len(a) < len(b):
83
+ a, b = b, a
84
+ # |a| ≥ |b|
85
+ if not b:
86
+ return len(a)
87
+ previous = list(range(len(b) + 1))
88
+ for i, ca in enumerate(a, start=1):
89
+ current = [i] + [0] * len(b)
90
+ for j, cb in enumerate(b, start=1):
91
+ cost = 0 if ca == cb else 1
92
+ current[j] = min(
93
+ current[j - 1] + 1, # insertion
94
+ previous[j] + 1, # suppression
95
+ previous[j - 1] + cost, # substitution
96
+ )
97
+ previous = current
98
+ return previous[-1]
99
+
100
+
101
+ # ──────────────────────────────────────────────────────────────────────────
102
+ # Calcul principal
103
+ # ──────────────────────────────────────────────────────────────────────────
104
+
105
+
106
+ def compute_searchability(
107
+ reference: Optional[str],
108
+ hypothesis: Optional[str],
109
+ *,
110
+ max_distance: int = 2,
111
+ case_sensitive: bool = False,
112
+ ) -> dict:
113
+ """Recherchabilité fuzzy de ``reference`` dans ``hypothesis``.
114
+
115
+ Parameters
116
+ ----------
117
+ reference, hypothesis:
118
+ Transcriptions GT et OCR.
119
+ max_distance:
120
+ Seuil de distance de Levenshtein (≤ pour considérer un
121
+ token comme retrouvé). Défaut 2 — convention
122
+ ``fuzziness: AUTO`` d'Elastic pour mots ≥ 5 caractères.
123
+ case_sensitive:
124
+ Si False (défaut), casse insensible côté match — la
125
+ sortie ``missed_tokens`` reste avec la casse GT
126
+ originale.
127
+
128
+ Returns
129
+ -------
130
+ dict
131
+ ``{
132
+ "n_gt_tokens": int,
133
+ "n_searchable": int,
134
+ "recall": float | None, # None si n_gt_tokens == 0
135
+ "missed_tokens": list[str],
136
+ "max_distance": int,
137
+ }``
138
+ """
139
+ if max_distance < 0:
140
+ raise ValueError(f"max_distance doit être ≥ 0, reçu {max_distance}")
141
+ gt_tokens = _split_words(reference)
142
+ hyp_tokens = _split_words(hypothesis)
143
+ n_gt = len(gt_tokens)
144
+ if n_gt == 0:
145
+ return {
146
+ "n_gt_tokens": 0,
147
+ "n_searchable": 0,
148
+ "recall": None,
149
+ "missed_tokens": [],
150
+ "max_distance": max_distance,
151
+ }
152
+ # Multi-set : un token hypothèse ne peut servir qu'une fois.
153
+ # Tri par longueur croissante pour matcher d'abord les
154
+ # tokens GT les plus courts (où ε-fautes sont plus rares).
155
+ if case_sensitive:
156
+ gt_for_match = list(gt_tokens)
157
+ hyp_for_match = list(hyp_tokens)
158
+ else:
159
+ gt_for_match = [t.lower() for t in gt_tokens]
160
+ hyp_for_match = [t.lower() for t in hyp_tokens]
161
+
162
+ hyp_used = [False] * len(hyp_for_match)
163
+ n_searchable = 0
164
+ missed: list[str] = []
165
+ for gi, gt_match in enumerate(gt_for_match):
166
+ # Court-circuit si match exact disponible
167
+ best_idx = -1
168
+ best_dist = max_distance + 1
169
+ for hi, used in enumerate(hyp_used):
170
+ if used:
171
+ continue
172
+ hyp_match = hyp_for_match[hi]
173
+ # Court-circuit longueur (Levenshtein ≥ |Δlen|)
174
+ if abs(len(hyp_match) - len(gt_match)) > max_distance:
175
+ continue
176
+ d = levenshtein_distance(gt_match, hyp_match)
177
+ if d < best_dist:
178
+ best_dist = d
179
+ best_idx = hi
180
+ if d == 0:
181
+ break # match exact, inutile de chercher mieux
182
+ if best_idx >= 0 and best_dist <= max_distance:
183
+ hyp_used[best_idx] = True
184
+ n_searchable += 1
185
+ else:
186
+ missed.append(gt_tokens[gi])
187
+ recall = n_searchable / n_gt
188
+ return {
189
+ "n_gt_tokens": n_gt,
190
+ "n_searchable": n_searchable,
191
+ "recall": recall,
192
+ "missed_tokens": missed,
193
+ "max_distance": max_distance,
194
+ }
195
+
196
+
197
+ # ──────────────────────────────────────────────────────────────────────────
198
+ # Enregistrement registre typé (Sprint 34)
199
+ # ──────────────────────────────────────────────────────────────────────────
200
+
201
+
202
+ @register_metric(
203
+ name="searchability_recall",
204
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
205
+ description=(
206
+ "Recherchabilité fuzzy : proportion de tokens GT retrouvés "
207
+ "dans l'OCR à distance de Levenshtein ≤ 2. Proxy direct de "
208
+ "la qualité pour la recherche plein-texte (Elastic, Solr)."
209
+ ),
210
+ )
211
+ def searchability_recall_metric(reference: str, hypothesis: str) -> float:
212
+ """Variante scalaire pour le registre typé : retourne le
213
+ rappel en [0, 1], ou ``0.0`` si la GT est vide (convention
214
+ cohérente avec rare_token_recall Sprint 71).
215
+ """
216
+ result = compute_searchability(reference, hypothesis)
217
+ recall = result.get("recall")
218
+ return 0.0 if recall is None else recall
219
+
220
+
221
+ __all__ = [
222
+ "levenshtein_distance",
223
+ "compute_searchability",
224
+ "searchability_recall_metric",
225
+ ]
tests/test_sprint84_searchability.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests Sprint 84 — A.II.5 : recherchabilité fuzzy.
2
+
3
+ Couvre :
4
+
5
+ 1. ``levenshtein_distance`` : invariants + cas standard.
6
+ 2. ``compute_searchability`` :
7
+ - identité → recall = 1
8
+ - aucun match → recall = 0
9
+ - GT vide → recall None
10
+ - hypothèse vide → recall = 0
11
+ - max_distance = 0 → match exact uniquement
12
+ - max_distance large
13
+ - case insensitive par défaut
14
+ - case sensitive opt-in
15
+ - multiplicité (un token hyp utilisé une seule fois)
16
+ - missed_tokens préserve la casse GT
17
+ - ValueError pour max_distance < 0
18
+ 3. Cas réaliste : CER élevé mais findability élevée.
19
+ 4. ``searchability_recall_metric`` enregistré dans le registre typé.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import pytest
25
+
26
+ from picarones.core.searchability import (
27
+ compute_searchability,
28
+ levenshtein_distance,
29
+ searchability_recall_metric,
30
+ )
31
+
32
+
33
+ # ──────────────────────────────────────────────────────────────────────────
34
+ # 1. levenshtein_distance
35
+ # ──────────────────────────────────────────────────────────────────────────
36
+
37
+
38
+ class TestLevenshtein:
39
+ def test_identity(self) -> None:
40
+ assert levenshtein_distance("hello", "hello") == 0
41
+
42
+ def test_one_substitution(self) -> None:
43
+ assert levenshtein_distance("hello", "hallo") == 1
44
+
45
+ def test_one_deletion(self) -> None:
46
+ assert levenshtein_distance("hello", "helo") == 1
47
+
48
+ def test_one_insertion(self) -> None:
49
+ assert levenshtein_distance("helo", "hello") == 1
50
+
51
+ def test_disjoint(self) -> None:
52
+ assert levenshtein_distance("abc", "xyz") == 3
53
+
54
+ def test_empty_left(self) -> None:
55
+ assert levenshtein_distance("", "abc") == 3
56
+
57
+ def test_empty_right(self) -> None:
58
+ assert levenshtein_distance("abc", "") == 3
59
+
60
+ def test_both_empty(self) -> None:
61
+ assert levenshtein_distance("", "") == 0
62
+
63
+ def test_classical_kitten(self) -> None:
64
+ # Cas standard de la littérature : kitten → sitting = 3
65
+ assert levenshtein_distance("kitten", "sitting") == 3
66
+
67
+
68
+ # ──────────────────────────────────────────────────────────────────────────
69
+ # 2. compute_searchability
70
+ # ──────────────────────────────────────────────────────────────────────────
71
+
72
+
73
+ class TestSearchability:
74
+ def test_identical_texts(self) -> None:
75
+ r = compute_searchability("le roi signa", "le roi signa")
76
+ assert r["recall"] == 1.0
77
+ assert r["missed_tokens"] == []
78
+ assert r["n_gt_tokens"] == 3
79
+ assert r["n_searchable"] == 3
80
+
81
+ def test_completely_different(self) -> None:
82
+ r = compute_searchability("alpha beta gamma", "rouge bleu vert")
83
+ assert r["recall"] == 0.0
84
+ assert sorted(r["missed_tokens"]) == ["alpha", "beta", "gamma"]
85
+
86
+ def test_empty_gt_returns_none_recall(self) -> None:
87
+ r = compute_searchability("", "anything")
88
+ assert r["recall"] is None
89
+ assert r["n_gt_tokens"] == 0
90
+
91
+ def test_empty_hypothesis_zero_recall(self) -> None:
92
+ r = compute_searchability("le roi", "")
93
+ assert r["recall"] == 0.0
94
+ assert r["missed_tokens"] == ["le", "roi"]
95
+
96
+ def test_max_distance_zero_requires_exact(self) -> None:
97
+ # « hallo » à distance 1 de « hello » → exclu si max_distance = 0
98
+ r = compute_searchability(
99
+ "hello world", "hallo world", max_distance=0,
100
+ )
101
+ assert r["n_searchable"] == 1 # « world » seulement
102
+ assert "hello" in r["missed_tokens"]
103
+
104
+ def test_max_distance_two_default(self) -> None:
105
+ r = compute_searchability("Charles", "Charlse") # 1 swap → distance 2
106
+ assert r["recall"] == 1.0
107
+
108
+ def test_max_distance_large_matches_loosely(self) -> None:
109
+ r = compute_searchability(
110
+ "completely different",
111
+ "ompletely ifferent",
112
+ max_distance=2,
113
+ )
114
+ assert r["recall"] == 1.0
115
+
116
+ def test_case_insensitive_by_default(self) -> None:
117
+ r = compute_searchability("Le Roi", "le roi")
118
+ assert r["recall"] == 1.0
119
+
120
+ def test_case_sensitive_opt_in(self) -> None:
121
+ # « Le » distance 1 de « le » (casse) → exclu si exact
122
+ r = compute_searchability(
123
+ "Le Roi", "le roi", max_distance=0, case_sensitive=True,
124
+ )
125
+ assert r["n_searchable"] == 0
126
+
127
+ def test_multiplicity_each_hyp_used_once(self) -> None:
128
+ # GT : « le le », hyp : « le » → un seul matché
129
+ r = compute_searchability("le le", "le")
130
+ assert r["n_searchable"] == 1
131
+ assert r["missed_tokens"] == ["le"]
132
+
133
+ def test_missed_tokens_preserve_gt_case(self) -> None:
134
+ r = compute_searchability("Charlemagne", "absent")
135
+ assert r["missed_tokens"] == ["Charlemagne"]
136
+
137
+ def test_negative_max_distance_raises(self) -> None:
138
+ with pytest.raises(ValueError):
139
+ compute_searchability("a", "b", max_distance=-1)
140
+
141
+ def test_default_max_distance_is_two(self) -> None:
142
+ r = compute_searchability("a", "b")
143
+ assert r["max_distance"] == 2
144
+
145
+
146
+ # ──────────────────────────────────────────────────────────────────────────
147
+ # 3. Cas réaliste : findability robuste à un CER élevé
148
+ # ──────────────────────────────────────────────────────────────────────────
149
+
150
+
151
+ class TestRealisticCase:
152
+ def test_high_cer_low_findability(self) -> None:
153
+ """Erreurs concentrées sur quelques mots → findability faible."""
154
+ gt = "le roi Charles VII signa la charte royale en 1450"
155
+ # « Charles » ↔ « Charlemagne » : distance 5 → non retrouvé
156
+ # « 1450 » ↔ « 1480 » : distance 1 → retrouvé
157
+ # « charte » remplacé par « lettre » : distance 5 → non retrouvé
158
+ hyp = "le roi Charlemagne VII signa la lettre royale en 1480"
159
+ r = compute_searchability(gt, hyp)
160
+ assert r["n_searchable"] < r["n_gt_tokens"]
161
+ assert "Charles" in r["missed_tokens"]
162
+ assert "charte" in r["missed_tokens"]
163
+
164
+ def test_high_cer_high_findability(self) -> None:
165
+ """Erreurs réparties (≤ 2 par mot) → findability élevée."""
166
+ gt = "maistre Pierre du Bois écrivit cette charte"
167
+ # 1 faute par mot, distance ≤ 2
168
+ hyp = "maitre Piere du Boys ecrivit cete charte"
169
+ r = compute_searchability(gt, hyp)
170
+ # Le CER est non négligeable mais tous les mots restent
171
+ # retrouvables en mode fuzzy
172
+ assert r["recall"] == 1.0
173
+
174
+
175
+ # ──────────────────────────────────────────────────────────────────────────
176
+ # 4. Intégration registre typé
177
+ # ──────────────────────────────────────────────────────────────────────────
178
+
179
+
180
+ class TestRegistry:
181
+ def test_metric_registered(self) -> None:
182
+ from picarones.core.metric_registry import select_metrics
183
+ from picarones.core.modules import ArtifactType
184
+
185
+ metrics = select_metrics(
186
+ (ArtifactType.TEXT, ArtifactType.TEXT),
187
+ )
188
+ names = [m.name for m in metrics]
189
+ assert "searchability_recall" in names
190
+
191
+ def test_metric_callable(self) -> None:
192
+ v = searchability_recall_metric("hello world", "helo world")
193
+ assert v == 1.0
194
+
195
+ def test_metric_returns_zero_for_empty_gt(self) -> None:
196
+ # Convention : registre typé attend un float, pas None
197
+ v = searchability_recall_metric("", "anything")
198
+ assert v == 0.0
199
+
200
+ def test_metric_via_compute_at_junction(self) -> None:
201
+ from picarones.core.metric_registry import compute_at_junction
202
+ from picarones.core.modules import ArtifactType
203
+
204
+ results = compute_at_junction(
205
+ "le roi", "le roi",
206
+ (ArtifactType.TEXT, ArtifactType.TEXT),
207
+ )
208
+ assert "searchability_recall" in results
209
+ assert results["searchability_recall"] == 1.0