Spaces:
Sleeping
feat(evaluation): Sprint A14-S16 — SearchView + cohérence inter-vues + 3 vues canoniques opérationnelles
Browse filesSprint S16 du plan rewrite ciblé. Phase 3 progresse fortement.
Troisième vue d'évaluation canonique : ``SearchView`` répond à
"quel pipeline maximise la recherchabilité plein-texte ?". Avec
TextView (S14) et AltoView (S15), on a maintenant les **3 lentilles
complémentaires** prévues pour le rapport BnF.
Modules livrés
--------------
``picarones/evaluation/metrics/search.py``
Fonctions de calcul **pures** (sans ``@register_metric`` legacy)
utilisées par SearchView :
- ``levenshtein_distance(a, b)`` — DP O(|a|·|b|), mémoire
O(min(|a|,|b|)). Identique au legacy Sprint 84.
- ``searchability_recall(reference, hypothesis, max_distance=2,
case_sensitive=False)`` — fraction des tokens GT retrouvés à
distance ≤ ``max_distance`` (défaut 2 = Elastic
``fuzziness: AUTO``). Multi-set : un hyp utilisé une fois max.
- ``numerical_sequence_preservation(reference, hypothesis)`` —
fraction des **années 4 chiffres** de la GT préservées
strictement dans hyp. Volontairement minimaliste pour S16
(le legacy Sprint 85 supporte aussi numéraux romains,
foliations, monnaies, années régnales — réintégrés au S20
avec le registre).
Toutes ∈ [0, 1] avec ``higher_is_better=True``. Aucune dep
vers ``picarones.measurements.*``.
``picarones/evaluation/views/search_view.py``
``build_search_view(...)`` factory :
- 5 candidate_types (idem TextView : tout ce qui est
projetable vers RAW_TEXT)
- 2 métriques par défaut
- 3 ignored_dimensions (char_level_accuracy → TextView ;
geometry/block_structure/reading_order → AltoView ;
semantic_equivalence → reportée)
- 2 warnings critiques :
* "lire ensemble TextView et SearchView pour juger un
pipeline"
* "higher_is_better=True (rappel) — sens de coloration
OPPOSÉ à TextView (lower_is_better=erreurs)"
Tests — 26 nouveaux tests S16
-----------------------------
``tests/evaluation/views/test_sprint_a14_s16_search_view.py`` (16 tests) :
- 4 tests Levenshtein (identité, vide, single sub, kitten→sitting).
- 7 tests searchability_recall (perfect, fuzzy ≤ 2, fuzzy > 2,
GT vide, multiplicité, case-insensitive, max_distance < 0
rejected).
- 5 tests numerical_sequence_preservation (perfect, year corrupted,
partial, no GT years, regex bounds).
- 4 tests SearchView shape (5 types acceptés, métriques par
défaut, projection_for ALTO routes correctement, warnings
signalent l'inversion higher_is_better).
- 2 tests SearchView avec executor (perfect text → recall 1,
partial quality with year loss).
``tests/evaluation/test_sprint_a14_s16_views_consistency.py`` (10 tests) :
- **TestPerfectPipelineAcrossViews** — pipeline parfait
maximise TextView et SearchView, AltoView OMIS si pas d'ALTO.
- **TestDivergencePattern** — démontre le pattern critique :
une corruption d'année (1789 → 1798) donne CER=0.03 (excellent
côté TextView) MAIS numerical_sequence_preservation=0.0
(catastrophique côté SearchView pour un historien indexant
par date). C'est précisément ce que le rapport BnF doit
rendre visible.
- **TestAltoPipelineEvaluatedInThreeViews** — un pipeline ALTO
est évaluable dans les 3 vues : TextView (via projection),
AltoView (direct), SearchView (via projection).
- **TestProjectionReportConsistency** — pour un même candidat
ALTO_XML évalué dans TextView et SearchView, les deux
ViewResult portent un projection_report cohérent (même
projecteur ``alto_to_text``).
Documentation — ``docs/views/comparing-views.md``
-------------------------------------------------
Document utilisateur central (sera référencé depuis le rapport
HTML S22). Couvre :
- Tableau des 3 vues avec leurs questions, métriques, direction
(lower_is_better vs higher_is_better).
- **3 patterns critiques** illustrant pourquoi les 3 vues sont
nécessaires :
1. CER excellent + recherchabilité numérique catastrophique
(corruption d'année invisible au CER)
2. Texte parfait mais ALTO inexistant (omission explicite)
3. ALTO valide mais texte hallucinant (vues complémentaires
sont indispensables)
- Recommandation de lecture pour le rapport BnF :
- tableau de synthèse avec OMIS explicite
- encart "divergences notables" (rang qui change entre vues)
- warnings d'``ignored_dimensions``
- **Critères de choix selon l'usage** :
| Usage | Vue principale | Vues secondaires |
| Lecture humaine | TextView | AltoView |
| Indexation Elastic | SearchView | TextView |
| IIIF/Mirador | AltoView | TextView |
| Citation académique | TextView + SearchView | AltoView |
| Fac-similé | AltoView | TextView |
État de la suite
----------------
``pytest tests/ -q`` → 4242 passed, 8 skipped, 2 failed
(strictement environnementaux). +26 tests vs S15. Aucune
régression S16.
Critère go/no-go S16 atteint
----------------------------
- 3 vues canoniques opérationnelles (TextView, AltoView,
SearchView).
- Documentation utilisateur ``comparing-views.md`` qui montre
comment lire les résultats des 3 vues ensemble.
- Pattern de divergence TextView ↔ SearchView démontré et testé
(corruption d'année invisible au CER).
Phase 3 quasi terminée. Restant : S17 (intégration runner +
RunManifest pour persister les ViewResult), S18 (E2E BnF
central avec 3 pipelines × 3 vues).
https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP
- docs/views/comparing-views.md +117 -0
- picarones/evaluation/metrics/search.py +194 -0
- picarones/evaluation/views/__init__.py +15 -0
- picarones/evaluation/views/search_view.py +161 -0
- tests/evaluation/test_sprint_a14_s16_views_consistency.py +329 -0
- tests/evaluation/views/test_sprint_a14_s16_search_view.py +222 -0
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Lire les 3 vues canoniques ensemble
|
| 2 |
+
|
| 3 |
+
Sprint A14-S16 livre la troisième vue canonique du rewrite ciblé :
|
| 4 |
+
`SearchView`. Avec `TextView` (S14) et `AltoView` (S15), on a
|
| 5 |
+
maintenant **trois lentilles complémentaires** pour évaluer un
|
| 6 |
+
même pipeline.
|
| 7 |
+
|
| 8 |
+
## Le tableau des 3 vues
|
| 9 |
+
|
| 10 |
+
| Vue | Question | Métriques | Direction |
|
| 11 |
+
|---|---|---|---|
|
| 12 |
+
| **TextView** (S14) | Quel pipeline produit le meilleur **texte final** ? | CER, WER, MER, WIL | `lower_is_better` (erreurs) |
|
| 13 |
+
| **AltoView** (S15) | Quel pipeline produit le meilleur **ALTO exploitable** ? | alto_validity, line_count_ratio, word_box_coverage | `higher_is_better` (qualité) |
|
| 14 |
+
| **SearchView** (S16) | Quel pipeline maximise la **recherchabilité plein-texte** ? | searchability_recall, numerical_sequence_preservation | `higher_is_better` (rappel) |
|
| 15 |
+
|
| 16 |
+
Aucune des trois vues ne dit toute la vérité sur un pipeline.
|
| 17 |
+
**Ensemble, elles racontent l'histoire complète.**
|
| 18 |
+
|
| 19 |
+
## Pourquoi les trois vues sont nécessaires
|
| 20 |
+
|
| 21 |
+
Un même pipeline peut être **excellent dans une vue et médiocre
|
| 22 |
+
dans une autre**. C'est précisément ce qui rend la comparaison
|
| 23 |
+
hétérogène utile pour la BnF — un seul score (CER global)
|
| 24 |
+
masquerait des informations critiques.
|
| 25 |
+
|
| 26 |
+
### Pattern 1 : CER excellent, recherchabilité numérique catastrophique
|
| 27 |
+
|
| 28 |
+
Démontré dans le test
|
| 29 |
+
`tests/evaluation/test_sprint_a14_s16_views_consistency.py::TestDivergencePattern::test_year_corruption_invisible_to_cer_visible_to_search` :
|
| 30 |
+
|
| 31 |
+
- **GT** : *"Charte signée à Paris le 14 juillet 1789 en présence du roi"*
|
| 32 |
+
- **Hypothèse** : *"Charte signée à Paris le 14 juillet 1798 en présence du roi"*
|
| 33 |
+
|
| 34 |
+
Le LLM de post-correction a "amélioré" la date (1789 → 1798).
|
| 35 |
+
Conséquences :
|
| 36 |
+
|
| 37 |
+
| Vue | Métrique | Valeur | Lecture |
|
| 38 |
+
|---|---|---|---|
|
| 39 |
+
| TextView | CER | ~0.03 | Excellent (3 chars sur 58) |
|
| 40 |
+
| TextView | WER | ~0.09 | Très bon (1 mot sur 11) |
|
| 41 |
+
| SearchView | searchability_recall | ~0.91 | Bon (1798 fuzzy match 1789) |
|
| 42 |
+
| SearchView | **numerical_sequence_preservation** | **0.0** | **Catastrophique** |
|
| 43 |
+
|
| 44 |
+
Pour un historien qui veut indexer ses chartes par date, ce
|
| 45 |
+
pipeline est **inutilisable** — l'année 1789 est silencieusement
|
| 46 |
+
réécrite en 1798. Le CER ne le révèle pas. `SearchView` le
|
| 47 |
+
révèle.
|
| 48 |
+
|
| 49 |
+
### Pattern 2 : Texte parfait, ALTO inexistant
|
| 50 |
+
|
| 51 |
+
Un OCR Tesseract qui ne produit que du texte brut :
|
| 52 |
+
|
| 53 |
+
| Vue | Statut | Lecture |
|
| 54 |
+
|---|---|---|
|
| 55 |
+
| TextView | CER = 0.0 | Pipeline parfait pour la lecture |
|
| 56 |
+
| SearchView | recall = 1.0 | Pipeline parfait pour l'indexation |
|
| 57 |
+
| **AltoView** | **OMIS** | Pipeline non éligible |
|
| 58 |
+
|
| 59 |
+
Pour un workflow IIIF / Mirador qui veut surligner les mots dans
|
| 60 |
+
l'image, ce pipeline est **inutilisable** — pas de coordonnées.
|
| 61 |
+
`AltoView` ne lui attribue pas un score factice à 0 ; le rapport
|
| 62 |
+
affiche *"Tesseract texte brut n'est pas évalué dans AltoView
|
| 63 |
+
(ne produit pas d'ALTO)"*.
|
| 64 |
+
|
| 65 |
+
### Pattern 3 : ALTO valide mais texte hallucinant
|
| 66 |
+
|
| 67 |
+
Un VLM avec module ALTO_reconstruction peut produire un ALTO
|
| 68 |
+
structurellement parfait (validity=1, lignes correctes,
|
| 69 |
+
coordonnées présentes) mais avec du texte inventé :
|
| 70 |
+
|
| 71 |
+
| Vue | Métrique | Valeur | Lecture |
|
| 72 |
+
|---|---|---|---|
|
| 73 |
+
| AltoView | tous | 1.0 | Pipeline parfait structurellement |
|
| 74 |
+
| TextView | CER | élevé | Pipeline mauvais textuellement |
|
| 75 |
+
| SearchView | recall | bas | Pipeline inutile pour la recherche |
|
| 76 |
+
|
| 77 |
+
`AltoView` seul ferait passer ce VLM pour le meilleur pipeline.
|
| 78 |
+
Lire les trois vues ensemble révèle le vrai problème.
|
| 79 |
+
|
| 80 |
+
## Recommandation de lecture pour le rapport BnF
|
| 81 |
+
|
| 82 |
+
Le rapport HTML (S22) présentera les 3 vues côte-à-côte avec
|
| 83 |
+
cette grille de lecture :
|
| 84 |
+
|
| 85 |
+
1. **Tableau de synthèse** : un tableau par vue, chaque ligne =
|
| 86 |
+
un pipeline, chaque colonne = une métrique. Les pipelines
|
| 87 |
+
omis sont indiqués explicitement (pas de valeur factice).
|
| 88 |
+
|
| 89 |
+
2. **Encart "divergences notables"** : signale automatiquement
|
| 90 |
+
les pipelines dont le rang change fortement entre vues
|
| 91 |
+
(par exemple "rang 1 en TextView, rang 5 en SearchView").
|
| 92 |
+
C'est un signal pour l'utilisateur d'aller regarder en
|
| 93 |
+
détail ce qui se passe.
|
| 94 |
+
|
| 95 |
+
3. **Pour chaque vue** : warnings explicites de ce qu'elle
|
| 96 |
+
**n'évalue pas** (cf. `ignored_dimensions` dans chaque
|
| 97 |
+
`ViewResult`). L'utilisateur ne peut pas conclure
|
| 98 |
+
"TextView dit que X est le meilleur" sans avoir vu ce que
|
| 99 |
+
`TextView.ignored_dimensions` ne dit PAS.
|
| 100 |
+
|
| 101 |
+
## Critères de choix selon l'usage
|
| 102 |
+
|
| 103 |
+
| Usage cible | Vue principale | Vues secondaires |
|
| 104 |
+
|---|---|---|
|
| 105 |
+
| Lecture humaine (édition critique) | TextView | AltoView (si édition diplomatique) |
|
| 106 |
+
| Indexation Elastic / Solr / Gallica | SearchView | TextView |
|
| 107 |
+
| Réinjection IIIF / Mirador (mots cliquables) | AltoView | TextView |
|
| 108 |
+
| Citation académique | TextView + SearchView | AltoView |
|
| 109 |
+
| Reproduction d'un fac-similé | AltoView | TextView |
|
| 110 |
+
|
| 111 |
+
## Statut
|
| 112 |
+
|
| 113 |
+
- ✅ Sprint S14 — `TextView`
|
| 114 |
+
- ✅ Sprint S15 — `AltoView`
|
| 115 |
+
- ✅ Sprint S16 — `SearchView` + cohérence inter-vues
|
| 116 |
+
- ⏳ Sprint S17 — intégration runner + RunManifest
|
| 117 |
+
- ⏳ Sprint S18 — tests E2E sur le cas BnF central
|
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Recherchabilité fuzzy + séquences numériques — Sprint A14-S16.
|
| 2 |
+
|
| 3 |
+
Fonctions de calcul **pures** (sans ``@register_metric`` legacy)
|
| 4 |
+
utilisées par ``SearchView``. Réimplémente la logique des modules
|
| 5 |
+
historiques ``picarones.measurements.searchability`` (Sprint 84)
|
| 6 |
+
et ``picarones.measurements.numerical_sequences`` (Sprint 85),
|
| 7 |
+
sans la dépendance vers le singleton global ``core.metric_registry``.
|
| 8 |
+
|
| 9 |
+
Les modules legacy seront supprimés au S20 quand le
|
| 10 |
+
``MetricRegistry`` instancié explicitement (S5) deviendra le seul
|
| 11 |
+
registre. En attendant, ce module fournit la version "couche
|
| 12 |
+
evaluation" propre.
|
| 13 |
+
|
| 14 |
+
Métriques livrées
|
| 15 |
+
-----------------
|
| 16 |
+
- ``searchability_recall(reference, hypothesis, max_distance=2)`` —
|
| 17 |
+
proportion de tokens GT retrouvés dans l'hypothèse à distance
|
| 18 |
+
de Levenshtein ≤ ``max_distance``. Proxy direct de la qualité
|
| 19 |
+
pour la recherche plein-texte (Elastic / Solr / Gallica).
|
| 20 |
+
|
| 21 |
+
- ``numerical_sequence_preservation(reference, hypothesis)`` —
|
| 22 |
+
fraction des séquences numériques de la GT préservées
|
| 23 |
+
strictement dans l'hypothèse. Volontairement minimaliste pour
|
| 24 |
+
S16 : détecte uniquement les **années 4 chiffres** (proxy
|
| 25 |
+
réaliste pour les corpus patrimoniaux datés). Le cas complet
|
| 26 |
+
(numéraux romains, foliations, monnaies, années régnales) reste
|
| 27 |
+
dans le legacy et sera réintégré au S20 avec le registre.
|
| 28 |
+
|
| 29 |
+
Toutes les métriques ∈ [0, 1] avec ``higher_is_better=True``.
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
from __future__ import annotations
|
| 33 |
+
|
| 34 |
+
import re
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# ──────────────────────────────────────────────────────────────────
|
| 38 |
+
# Levenshtein — DP O(|a|·|b|), mémoire O(min(|a|, |b|))
|
| 39 |
+
# ──────────────────────────────────────────────────────────────────
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def levenshtein_distance(a: str, b: str) -> int:
|
| 43 |
+
"""Distance de Levenshtein (substitution = insertion = suppression = 1).
|
| 44 |
+
|
| 45 |
+
Implémentation identique à ``picarones.measurements.searchability``
|
| 46 |
+
(Sprint 84) mais sans le décorateur ``@register_metric``.
|
| 47 |
+
"""
|
| 48 |
+
if a == b:
|
| 49 |
+
return 0
|
| 50 |
+
if len(a) < len(b):
|
| 51 |
+
a, b = b, a
|
| 52 |
+
if not b:
|
| 53 |
+
return len(a)
|
| 54 |
+
previous = list(range(len(b) + 1))
|
| 55 |
+
for i, ca in enumerate(a, start=1):
|
| 56 |
+
current = [i] + [0] * len(b)
|
| 57 |
+
for j, cb in enumerate(b, start=1):
|
| 58 |
+
cost = 0 if ca == cb else 1
|
| 59 |
+
current[j] = min(
|
| 60 |
+
current[j - 1] + 1, # insertion
|
| 61 |
+
previous[j] + 1, # suppression
|
| 62 |
+
previous[j - 1] + cost, # substitution
|
| 63 |
+
)
|
| 64 |
+
previous = current
|
| 65 |
+
return previous[-1]
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# ──────────────────────────────────────────────────────────────────
|
| 69 |
+
# Searchability fuzzy
|
| 70 |
+
# ──────────────────────────────────────────────────────────────────
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _split_words(text: str | None) -> list[str]:
|
| 74 |
+
if not text:
|
| 75 |
+
return []
|
| 76 |
+
return text.split()
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def searchability_recall(
|
| 80 |
+
reference: str,
|
| 81 |
+
hypothesis: str,
|
| 82 |
+
*,
|
| 83 |
+
max_distance: int = 2,
|
| 84 |
+
case_sensitive: bool = False,
|
| 85 |
+
) -> float:
|
| 86 |
+
"""Rappel fuzzy : fraction des tokens GT retrouvés à distance
|
| 87 |
+
de Levenshtein ≤ ``max_distance``.
|
| 88 |
+
|
| 89 |
+
Multi-set : un token hypothèse ne peut servir qu'une fois pour
|
| 90 |
+
être compté comme "match" (alignement bipartite simple).
|
| 91 |
+
|
| 92 |
+
Returns
|
| 93 |
+
-------
|
| 94 |
+
float
|
| 95 |
+
``n_retrouves / n_gt`` ∈ [0, 1]. ``0.0`` si la GT est
|
| 96 |
+
vide (convention identique au legacy Sprint 84).
|
| 97 |
+
"""
|
| 98 |
+
if max_distance < 0:
|
| 99 |
+
raise ValueError(f"max_distance doit être ≥ 0, reçu {max_distance}")
|
| 100 |
+
gt_tokens = _split_words(reference)
|
| 101 |
+
hyp_tokens = _split_words(hypothesis)
|
| 102 |
+
n_gt = len(gt_tokens)
|
| 103 |
+
if n_gt == 0:
|
| 104 |
+
return 0.0
|
| 105 |
+
if case_sensitive:
|
| 106 |
+
gt_for_match = list(gt_tokens)
|
| 107 |
+
hyp_for_match = list(hyp_tokens)
|
| 108 |
+
else:
|
| 109 |
+
gt_for_match = [t.lower() for t in gt_tokens]
|
| 110 |
+
hyp_for_match = [t.lower() for t in hyp_tokens]
|
| 111 |
+
|
| 112 |
+
hyp_used = [False] * len(hyp_for_match)
|
| 113 |
+
n_match = 0
|
| 114 |
+
for gt_match in gt_for_match:
|
| 115 |
+
best_idx = -1
|
| 116 |
+
best_dist = max_distance + 1
|
| 117 |
+
for hi, used in enumerate(hyp_used):
|
| 118 |
+
if used:
|
| 119 |
+
continue
|
| 120 |
+
hyp_match = hyp_for_match[hi]
|
| 121 |
+
if abs(len(hyp_match) - len(gt_match)) > max_distance:
|
| 122 |
+
continue
|
| 123 |
+
d = levenshtein_distance(gt_match, hyp_match)
|
| 124 |
+
if d < best_dist:
|
| 125 |
+
best_dist = d
|
| 126 |
+
best_idx = hi
|
| 127 |
+
if d == 0:
|
| 128 |
+
break
|
| 129 |
+
if best_idx >= 0 and best_dist <= max_distance:
|
| 130 |
+
hyp_used[best_idx] = True
|
| 131 |
+
n_match += 1
|
| 132 |
+
return n_match / n_gt
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# ──────────────────────────────────────────────────────────────────
|
| 136 |
+
# Séquences numériques (S16 minimal : années 4 chiffres)
|
| 137 |
+
# ──────────────────────────────────────────────────────────────────
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
_YEAR_4DIGITS_RE = re.compile(r"\b(1[0-9]{3}|20[0-2][0-9])\b")
|
| 141 |
+
"""Capture les années entre 1000 et 2029 (proxy réaliste pour les
|
| 142 |
+
corpus patrimoniaux : chartes médiévales, registres modernes,
|
| 143 |
+
coupures de presse XIX-XXIᵉ siècle)."""
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _extract_years(text: str | None) -> list[str]:
|
| 147 |
+
if not text:
|
| 148 |
+
return []
|
| 149 |
+
return _YEAR_4DIGITS_RE.findall(text)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def numerical_sequence_preservation(
|
| 153 |
+
reference: str,
|
| 154 |
+
hypothesis: str,
|
| 155 |
+
) -> float:
|
| 156 |
+
"""Fraction des années 4 chiffres de la GT préservées strictement
|
| 157 |
+
dans l'hypothèse.
|
| 158 |
+
|
| 159 |
+
Returns
|
| 160 |
+
-------
|
| 161 |
+
float
|
| 162 |
+
``n_preserved / n_gt_years`` ∈ [0, 1]. ``0.0`` si la GT
|
| 163 |
+
ne contient aucune année.
|
| 164 |
+
|
| 165 |
+
Note méthodologique
|
| 166 |
+
-------------------
|
| 167 |
+
Volontairement minimaliste pour S16 : seules les années 4
|
| 168 |
+
chiffres sont détectées. Le pattern complet (numéraux romains,
|
| 169 |
+
foliations ``f. 12r``, monnaies, années régnales ``an III``)
|
| 170 |
+
reste dans ``picarones.measurements.numerical_sequences``
|
| 171 |
+
(Sprint 85) et sera réintégré dans la couche evaluation au S20.
|
| 172 |
+
|
| 173 |
+
Multi-set : si la GT contient ``"1789"`` deux fois et
|
| 174 |
+
l'hypothèse une fois, seul un est compté préservé.
|
| 175 |
+
"""
|
| 176 |
+
gt_years = _extract_years(reference)
|
| 177 |
+
if not gt_years:
|
| 178 |
+
return 0.0
|
| 179 |
+
hyp_years = _extract_years(hypothesis)
|
| 180 |
+
# Multi-set match.
|
| 181 |
+
hyp_pool = list(hyp_years)
|
| 182 |
+
n_preserved = 0
|
| 183 |
+
for y in gt_years:
|
| 184 |
+
if y in hyp_pool:
|
| 185 |
+
hyp_pool.remove(y)
|
| 186 |
+
n_preserved += 1
|
| 187 |
+
return n_preserved / len(gt_years)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
__all__ = [
|
| 191 |
+
"levenshtein_distance",
|
| 192 |
+
"searchability_recall",
|
| 193 |
+
"numerical_sequence_preservation",
|
| 194 |
+
]
|
|
@@ -32,6 +32,14 @@ from picarones.evaluation.views.executor import (
|
|
| 32 |
DefaultEvaluationViewExecutor,
|
| 33 |
PayloadLoader,
|
| 34 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
from picarones.evaluation.views.text_view import (
|
| 36 |
DEFAULT_TEXT_CANDIDATE_TYPES,
|
| 37 |
DEFAULT_TEXT_IGNORED_DIMENSIONS,
|
|
@@ -61,4 +69,11 @@ __all__ = [
|
|
| 61 |
"DEFAULT_ALTO_CANDIDATE_TYPES",
|
| 62 |
"DEFAULT_ALTO_IGNORED_DIMENSIONS",
|
| 63 |
"DEFAULT_ALTO_WARNINGS",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
]
|
|
|
|
| 32 |
DefaultEvaluationViewExecutor,
|
| 33 |
PayloadLoader,
|
| 34 |
)
|
| 35 |
+
from picarones.evaluation.views.search_view import (
|
| 36 |
+
DEFAULT_SEARCH_CANDIDATE_TYPES,
|
| 37 |
+
DEFAULT_SEARCH_IGNORED_DIMENSIONS,
|
| 38 |
+
DEFAULT_SEARCH_METRICS,
|
| 39 |
+
DEFAULT_SEARCH_PROJECTIONS,
|
| 40 |
+
DEFAULT_SEARCH_WARNINGS,
|
| 41 |
+
build_search_view,
|
| 42 |
+
)
|
| 43 |
from picarones.evaluation.views.text_view import (
|
| 44 |
DEFAULT_TEXT_CANDIDATE_TYPES,
|
| 45 |
DEFAULT_TEXT_IGNORED_DIMENSIONS,
|
|
|
|
| 69 |
"DEFAULT_ALTO_CANDIDATE_TYPES",
|
| 70 |
"DEFAULT_ALTO_IGNORED_DIMENSIONS",
|
| 71 |
"DEFAULT_ALTO_WARNINGS",
|
| 72 |
+
# SearchView (S16)
|
| 73 |
+
"build_search_view",
|
| 74 |
+
"DEFAULT_SEARCH_METRICS",
|
| 75 |
+
"DEFAULT_SEARCH_CANDIDATE_TYPES",
|
| 76 |
+
"DEFAULT_SEARCH_PROJECTIONS",
|
| 77 |
+
"DEFAULT_SEARCH_IGNORED_DIMENSIONS",
|
| 78 |
+
"DEFAULT_SEARCH_WARNINGS",
|
| 79 |
]
|
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``SearchView`` — vue canonique 3, Sprint A14-S16.
|
| 2 |
+
|
| 3 |
+
Troisième vue d'évaluation canonique : "quel pipeline maximise la
|
| 4 |
+
**recherchabilité plein-texte** ?".
|
| 5 |
+
|
| 6 |
+
Distinct de TextView et AltoView
|
| 7 |
+
--------------------------------
|
| 8 |
+
| Vue | Question | Métriques |
|
| 9 |
+
|---|---|---|
|
| 10 |
+
| TextView (S14) | meilleur texte final ? | CER, WER, MER, WIL |
|
| 11 |
+
| AltoView (S15) | meilleur ALTO exploitable ? | validity, line_count, word_box |
|
| 12 |
+
| SearchView (S16) | meilleur pour la recherche plein-texte ? | searchability_recall, numerical_seq |
|
| 13 |
+
|
| 14 |
+
Un même pipeline peut avoir un excellent CER (TextView) tout en
|
| 15 |
+
étant mauvais pour la recherche fuzzy (SearchView), si ses erreurs
|
| 16 |
+
se concentrent sur des noms propres ou des dates. Et inversement,
|
| 17 |
+
un pipeline avec un CER médiocre peut donner une excellente
|
| 18 |
+
recherchabilité si les erreurs sont sur des caractères non-significatifs.
|
| 19 |
+
|
| 20 |
+
Cette divergence est précisément ce que le rapport BnF doit rendre
|
| 21 |
+
visible — c'est l'objet du document
|
| 22 |
+
``docs/views/comparing-views.md``.
|
| 23 |
+
|
| 24 |
+
Types acceptés
|
| 25 |
+
--------------
|
| 26 |
+
Comme TextView : RAW_TEXT, CORRECTED_TEXT, ALTO_XML, PAGE_XML,
|
| 27 |
+
CANONICAL_DOCUMENT. La projection vers RAW_TEXT est appliquée
|
| 28 |
+
automatiquement par ``projections_by_source_type``.
|
| 29 |
+
|
| 30 |
+
Métriques par défaut
|
| 31 |
+
--------------------
|
| 32 |
+
- ``searchability_recall`` — fraction des tokens GT retrouvés à
|
| 33 |
+
distance de Levenshtein ≤ 2 (proxy Elastic).
|
| 34 |
+
- ``numerical_sequence_preservation`` — fraction des années 4
|
| 35 |
+
chiffres de la GT préservées strictement.
|
| 36 |
+
|
| 37 |
+
Toutes ∈ [0, 1] avec ``higher_is_better=True``.
|
| 38 |
+
|
| 39 |
+
higher_is_better
|
| 40 |
+
----------------
|
| 41 |
+
**Critique** : les métriques de cette vue sont des recall
|
| 42 |
+
(``higher_is_better=True``), à l'inverse de TextView dont les
|
| 43 |
+
métriques sont des erreurs (``higher_is_better=False``). Le
|
| 44 |
+
rapport doit colorier les chiffres de SearchView dans le sens
|
| 45 |
+
opposé de ceux de TextView.
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
from __future__ import annotations
|
| 49 |
+
|
| 50 |
+
from picarones.domain.artifacts import ArtifactType
|
| 51 |
+
from picarones.domain.evaluation_spec import EvaluationView
|
| 52 |
+
from picarones.domain.projection_spec import ProjectionSpec
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
#: Métriques calculées par défaut.
|
| 56 |
+
DEFAULT_SEARCH_METRICS: tuple[str, ...] = (
|
| 57 |
+
"searchability_recall",
|
| 58 |
+
"numerical_sequence_preservation",
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
#: Types acceptés. Identique à TextView : tout ce qui peut être
|
| 63 |
+
#: projeté vers RAW_TEXT est éligible.
|
| 64 |
+
DEFAULT_SEARCH_CANDIDATE_TYPES: frozenset[ArtifactType] = frozenset({
|
| 65 |
+
ArtifactType.RAW_TEXT,
|
| 66 |
+
ArtifactType.CORRECTED_TEXT,
|
| 67 |
+
ArtifactType.ALTO_XML,
|
| 68 |
+
ArtifactType.PAGE_XML,
|
| 69 |
+
ArtifactType.CANONICAL_DOCUMENT,
|
| 70 |
+
})
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
#: Mapping ``source_type → ProjectionSpec`` (identique à TextView).
|
| 74 |
+
DEFAULT_SEARCH_PROJECTIONS: dict[ArtifactType, ProjectionSpec] = {
|
| 75 |
+
ArtifactType.ALTO_XML: ProjectionSpec(
|
| 76 |
+
source_type=ArtifactType.ALTO_XML,
|
| 77 |
+
target_type=ArtifactType.RAW_TEXT,
|
| 78 |
+
projector_name="alto_to_text",
|
| 79 |
+
),
|
| 80 |
+
ArtifactType.PAGE_XML: ProjectionSpec(
|
| 81 |
+
source_type=ArtifactType.PAGE_XML,
|
| 82 |
+
target_type=ArtifactType.RAW_TEXT,
|
| 83 |
+
projector_name="page_to_text",
|
| 84 |
+
),
|
| 85 |
+
ArtifactType.CANONICAL_DOCUMENT: ProjectionSpec(
|
| 86 |
+
source_type=ArtifactType.CANONICAL_DOCUMENT,
|
| 87 |
+
target_type=ArtifactType.RAW_TEXT,
|
| 88 |
+
projector_name="canonical_to_text",
|
| 89 |
+
),
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
#: Dimensions explicitement non évaluées.
|
| 94 |
+
DEFAULT_SEARCH_IGNORED_DIMENSIONS: tuple[str, ...] = (
|
| 95 |
+
# Qualité caractère par caractère : c'est TextView (S14).
|
| 96 |
+
"char_level_accuracy",
|
| 97 |
+
# Structure documentaire : c'est AltoView (S15).
|
| 98 |
+
"geometry",
|
| 99 |
+
"block_structure",
|
| 100 |
+
"reading_order",
|
| 101 |
+
# Sémantique (synonymes, paraphrases) : non évaluée par cette
|
| 102 |
+
# vue, qui reste lexicale.
|
| 103 |
+
"semantic_equivalence",
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
#: Avertissement par défaut.
|
| 108 |
+
DEFAULT_SEARCH_WARNINGS: tuple[str, ...] = (
|
| 109 |
+
"Cette vue mesure la recherchabilité PLEIN-TEXTE (rappel "
|
| 110 |
+
"fuzzy à distance de Levenshtein ≤ 2, années préservées). "
|
| 111 |
+
"Un pipeline avec un excellent CER peut être moyen ici si "
|
| 112 |
+
"ses erreurs se concentrent sur les noms propres ou les "
|
| 113 |
+
"dates. Et inversement. Lire ensemble TextView et SearchView "
|
| 114 |
+
"pour juger un pipeline.",
|
| 115 |
+
"Métriques higher_is_better=True (rappel) — le sens de "
|
| 116 |
+
"coloration est OPPOSÉ à celui de TextView (qui mesure des "
|
| 117 |
+
"erreurs, lower_is_better).",
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def build_search_view(
|
| 122 |
+
*,
|
| 123 |
+
name: str = "searchability",
|
| 124 |
+
description: str = (
|
| 125 |
+
"Mesure la recherchabilité plein-texte d'un pipeline "
|
| 126 |
+
"(rappel fuzzy + années préservées)."
|
| 127 |
+
),
|
| 128 |
+
candidate_types: frozenset[ArtifactType] | None = None,
|
| 129 |
+
metric_names: tuple[str, ...] | None = None,
|
| 130 |
+
normalization_profile: str | None = None,
|
| 131 |
+
extra_warnings: tuple[str, ...] = (),
|
| 132 |
+
extra_ignored_dimensions: tuple[str, ...] = (),
|
| 133 |
+
) -> EvaluationView:
|
| 134 |
+
"""Construit la vue canonique SearchView."""
|
| 135 |
+
return EvaluationView(
|
| 136 |
+
name=name,
|
| 137 |
+
description=description,
|
| 138 |
+
candidate_types=(
|
| 139 |
+
candidate_types if candidate_types is not None
|
| 140 |
+
else DEFAULT_SEARCH_CANDIDATE_TYPES
|
| 141 |
+
),
|
| 142 |
+
projection=None,
|
| 143 |
+
projections_by_source_type=DEFAULT_SEARCH_PROJECTIONS,
|
| 144 |
+
normalization_profile=normalization_profile,
|
| 145 |
+
metric_names=(
|
| 146 |
+
metric_names if metric_names is not None
|
| 147 |
+
else DEFAULT_SEARCH_METRICS
|
| 148 |
+
),
|
| 149 |
+
warnings=DEFAULT_SEARCH_WARNINGS + extra_warnings,
|
| 150 |
+
ignored_dimensions=DEFAULT_SEARCH_IGNORED_DIMENSIONS + extra_ignored_dimensions,
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
__all__ = [
|
| 155 |
+
"build_search_view",
|
| 156 |
+
"DEFAULT_SEARCH_METRICS",
|
| 157 |
+
"DEFAULT_SEARCH_CANDIDATE_TYPES",
|
| 158 |
+
"DEFAULT_SEARCH_PROJECTIONS",
|
| 159 |
+
"DEFAULT_SEARCH_IGNORED_DIMENSIONS",
|
| 160 |
+
"DEFAULT_SEARCH_WARNINGS",
|
| 161 |
+
]
|
|
@@ -0,0 +1,329 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sprint A14-S16 — sanity check inter-vues sur le cas BnF central.
|
| 2 |
+
|
| 3 |
+
Vérifie qu'un même pipeline a une cohérence (et parfois une
|
| 4 |
+
divergence intéressante) entre TextView, AltoView et SearchView.
|
| 5 |
+
|
| 6 |
+
Cas démontrés :
|
| 7 |
+
- Pipeline parfait → toutes vues maximisent.
|
| 8 |
+
- Pipeline avec erreur sur une année → SearchView baisse fortement,
|
| 9 |
+
TextView baisse légèrement (pattern "perte de données critiques
|
| 10 |
+
invisible au CER global").
|
| 11 |
+
- Pipeline sans ALTO → AltoView l'OMET, autres vues l'évaluent.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import pytest
|
| 17 |
+
|
| 18 |
+
from picarones.domain import Artifact, ArtifactType, MetricSpec
|
| 19 |
+
from picarones.evaluation.metrics.alto_structural import (
|
| 20 |
+
compute_alto_validity,
|
| 21 |
+
compute_line_count_ratio,
|
| 22 |
+
compute_word_box_coverage,
|
| 23 |
+
)
|
| 24 |
+
from picarones.evaluation.metrics.search import (
|
| 25 |
+
numerical_sequence_preservation,
|
| 26 |
+
searchability_recall,
|
| 27 |
+
)
|
| 28 |
+
from picarones.evaluation.projectors import (
|
| 29 |
+
AltoToText,
|
| 30 |
+
CanonicalToText,
|
| 31 |
+
PageToText,
|
| 32 |
+
ProjectorRegistry,
|
| 33 |
+
)
|
| 34 |
+
from picarones.evaluation.registry import MetricRegistry
|
| 35 |
+
from picarones.evaluation.views import (
|
| 36 |
+
DefaultEvaluationViewExecutor,
|
| 37 |
+
build_alto_view,
|
| 38 |
+
build_search_view,
|
| 39 |
+
build_text_view,
|
| 40 |
+
)
|
| 41 |
+
from picarones.formats.alto.types import (
|
| 42 |
+
AltoBBox,
|
| 43 |
+
AltoDocument,
|
| 44 |
+
AltoLine,
|
| 45 |
+
AltoPage,
|
| 46 |
+
AltoString,
|
| 47 |
+
AltoTextBlock,
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ──────────────────────────────────────────────────────────────────
|
| 52 |
+
# Stubs métriques texte (cer/wer simplifiés sans jiwer)
|
| 53 |
+
# ──────────────────────────────────────────────────────────────────
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _stub_cer(reference: str, hypothesis: str) -> float:
|
| 57 |
+
if not reference:
|
| 58 |
+
return 0.0 if not hypothesis else 1.0
|
| 59 |
+
common = sum(1 for a, b in zip(reference, hypothesis) if a == b)
|
| 60 |
+
return 1.0 - (common / max(len(reference), len(hypothesis)))
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _stub_wer(reference: str, hypothesis: str) -> float:
|
| 64 |
+
ref_w = reference.split()
|
| 65 |
+
hyp_w = hypothesis.split()
|
| 66 |
+
if not ref_w:
|
| 67 |
+
return 0.0 if not hyp_w else 1.0
|
| 68 |
+
common = sum(1 for a, b in zip(ref_w, hyp_w) if a == b)
|
| 69 |
+
return 1.0 - (common / len(ref_w))
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _build_unified_executor(payloads: dict) -> DefaultEvaluationViewExecutor:
|
| 73 |
+
"""Executor configuré pour TextView + AltoView + SearchView."""
|
| 74 |
+
metrics = MetricRegistry()
|
| 75 |
+
# TextView metrics
|
| 76 |
+
for name, fn in (
|
| 77 |
+
("cer", _stub_cer),
|
| 78 |
+
("wer", _stub_wer),
|
| 79 |
+
("mer", _stub_cer),
|
| 80 |
+
("wil", _stub_wer),
|
| 81 |
+
):
|
| 82 |
+
metrics.register(
|
| 83 |
+
MetricSpec(
|
| 84 |
+
name=name,
|
| 85 |
+
input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT),
|
| 86 |
+
),
|
| 87 |
+
fn,
|
| 88 |
+
)
|
| 89 |
+
# AltoView metrics
|
| 90 |
+
for name, fn in (
|
| 91 |
+
("alto_validity", compute_alto_validity),
|
| 92 |
+
("alto_line_count_ratio", compute_line_count_ratio),
|
| 93 |
+
("alto_word_box_coverage", compute_word_box_coverage),
|
| 94 |
+
):
|
| 95 |
+
metrics.register(
|
| 96 |
+
MetricSpec(
|
| 97 |
+
name=name,
|
| 98 |
+
input_types=(ArtifactType.ALTO_XML, ArtifactType.ALTO_XML),
|
| 99 |
+
higher_is_better=True,
|
| 100 |
+
),
|
| 101 |
+
fn,
|
| 102 |
+
)
|
| 103 |
+
# SearchView metrics
|
| 104 |
+
metrics.register(
|
| 105 |
+
MetricSpec(
|
| 106 |
+
name="searchability_recall",
|
| 107 |
+
input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT),
|
| 108 |
+
higher_is_better=True,
|
| 109 |
+
),
|
| 110 |
+
searchability_recall,
|
| 111 |
+
)
|
| 112 |
+
metrics.register(
|
| 113 |
+
MetricSpec(
|
| 114 |
+
name="numerical_sequence_preservation",
|
| 115 |
+
input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT),
|
| 116 |
+
higher_is_better=True,
|
| 117 |
+
),
|
| 118 |
+
numerical_sequence_preservation,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
projectors = ProjectorRegistry()
|
| 122 |
+
projectors.register(AltoToText())
|
| 123 |
+
projectors.register(PageToText())
|
| 124 |
+
projectors.register(CanonicalToText())
|
| 125 |
+
|
| 126 |
+
def loader(art: Artifact):
|
| 127 |
+
if art.id not in payloads:
|
| 128 |
+
raise KeyError(art.id)
|
| 129 |
+
return payloads[art.id]
|
| 130 |
+
|
| 131 |
+
return DefaultEvaluationViewExecutor(metrics, projectors, loader)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# ──────────────────────────────────────────────────────────────────
|
| 135 |
+
# Cas 1 — pipeline parfait
|
| 136 |
+
# ──────────────────────────────────────────────────────────────────
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
class TestPerfectPipelineAcrossViews:
|
| 140 |
+
def test_perfect_text_pipeline_maximizes_text_and_search(self) -> None:
|
| 141 |
+
"""Un pipeline qui produit du texte parfait :
|
| 142 |
+
- TextView : CER = 0
|
| 143 |
+
- SearchView : recall = 1.0, year preservation = 1.0
|
| 144 |
+
- AltoView : OMIS (pas d'ALTO produit).
|
| 145 |
+
"""
|
| 146 |
+
gt_text = "Bonjour Paris en 1789"
|
| 147 |
+
payloads = {"cand": gt_text, "gt_text": gt_text}
|
| 148 |
+
executor = _build_unified_executor(payloads)
|
| 149 |
+
|
| 150 |
+
text_view = build_text_view()
|
| 151 |
+
search_view = build_search_view()
|
| 152 |
+
alto_view = build_alto_view()
|
| 153 |
+
|
| 154 |
+
cand = Artifact(id="cand", document_id="d", type=ArtifactType.RAW_TEXT)
|
| 155 |
+
gt = Artifact(id="gt_text", document_id="d", type=ArtifactType.RAW_TEXT)
|
| 156 |
+
|
| 157 |
+
text_result = executor.evaluate(text_view, cand, gt)
|
| 158 |
+
search_result = executor.evaluate(search_view, cand, gt)
|
| 159 |
+
|
| 160 |
+
assert text_result.metric_values["cer"] == 0.0
|
| 161 |
+
assert search_result.metric_values["searchability_recall"] == 1.0
|
| 162 |
+
assert search_result.metric_values["numerical_sequence_preservation"] == 1.0
|
| 163 |
+
|
| 164 |
+
# AltoView OMIS : le caller doit filtrer.
|
| 165 |
+
assert not alto_view.accepts(cand.type)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
# ──────────────────────────────────────────────────────────────────
|
| 169 |
+
# Cas 2 — divergence TextView ↔ SearchView
|
| 170 |
+
# ──────────────────────────────────────────────────────────────────
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
class TestDivergencePattern:
|
| 174 |
+
def test_year_corruption_invisible_to_cer_visible_to_search(self) -> None:
|
| 175 |
+
"""Pattern critique : une corruption d'année (1 caractère
|
| 176 |
+
sur ~50) est invisible côté CER mais catastrophique côté
|
| 177 |
+
recherchabilité numérique.
|
| 178 |
+
|
| 179 |
+
C'est précisément ce que le rapport BnF doit rendre
|
| 180 |
+
visible — les deux vues racontent des histoires
|
| 181 |
+
complémentaires.
|
| 182 |
+
"""
|
| 183 |
+
gt_text = "Charte signée à Paris le 14 juillet 1789 en présence du roi"
|
| 184 |
+
# Hypothèse : le LLM a "corrigé" 1789 en 1798 (faute grossière).
|
| 185 |
+
# Le reste du texte est identique.
|
| 186 |
+
cand_text = "Charte signée à Paris le 14 juillet 1798 en présence du roi"
|
| 187 |
+
|
| 188 |
+
payloads = {"cand": cand_text, "gt": gt_text}
|
| 189 |
+
executor = _build_unified_executor(payloads)
|
| 190 |
+
|
| 191 |
+
cand = Artifact(id="cand", document_id="d", type=ArtifactType.RAW_TEXT)
|
| 192 |
+
gt = Artifact(id="gt", document_id="d", type=ArtifactType.RAW_TEXT)
|
| 193 |
+
|
| 194 |
+
text_result = executor.evaluate(build_text_view(), cand, gt)
|
| 195 |
+
search_result = executor.evaluate(build_search_view(), cand, gt)
|
| 196 |
+
|
| 197 |
+
# CER ≈ 0.03 (3 chars sur ~58)
|
| 198 |
+
assert text_result.metric_values["cer"] < 0.1, "CER doit rester faible"
|
| 199 |
+
# WER : 1 mot changé sur 11 → 1/11 ≈ 0.09
|
| 200 |
+
assert text_result.metric_values["wer"] < 0.15
|
| 201 |
+
|
| 202 |
+
# Mais SearchView : 1789 (GT) n'est PAS dans hyp_years = [1798]
|
| 203 |
+
# → preservation = 0.0 (catastrophique pour un historien).
|
| 204 |
+
assert search_result.metric_values["numerical_sequence_preservation"] == 0.0
|
| 205 |
+
# Searchability : "1789" GT n'est pas matché à "1798" (distance 2,
|
| 206 |
+
# MAIS la longueur est égale, fuzziness ≤ 2 le matche).
|
| 207 |
+
# On vérifie juste qu'il y a un signal mesurable.
|
| 208 |
+
assert search_result.metric_values["searchability_recall"] >= 0.8
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
# ──────────────────────────────────────────────────────────────────
|
| 212 |
+
# Cas 3 — pipeline ALTO évaluable dans les 3 vues
|
| 213 |
+
# ──────────────────────────────────────────────────────────────────
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def _build_simple_alto(words: list[str], n_lines: int = 1) -> AltoDocument:
|
| 217 |
+
"""Construit un AltoDocument avec ``words`` répartis sur
|
| 218 |
+
``n_lines`` lignes, chaque mot avec une bbox."""
|
| 219 |
+
chunks = [words[i::n_lines] for i in range(n_lines)]
|
| 220 |
+
lines = tuple(
|
| 221 |
+
AltoLine(strings=tuple(
|
| 222 |
+
AltoString(
|
| 223 |
+
content=w,
|
| 224 |
+
bbox=AltoBBox(hpos=0, vpos=0, width=10, height=10),
|
| 225 |
+
)
|
| 226 |
+
for w in chunk
|
| 227 |
+
))
|
| 228 |
+
for chunk in chunks
|
| 229 |
+
)
|
| 230 |
+
return AltoDocument(pages=(AltoPage(blocks=(AltoTextBlock(lines=lines),),),),)
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
class TestAltoPipelineEvaluatedInThreeViews:
|
| 234 |
+
def test_alto_pipeline_has_text_alto_search_results(self, tmp_path) -> None:
|
| 235 |
+
"""Un pipeline qui produit ALTO_XML est évaluable dans les
|
| 236 |
+
3 vues : TextView (via projection), AltoView (direct),
|
| 237 |
+
SearchView (via projection).
|
| 238 |
+
"""
|
| 239 |
+
from picarones.formats.alto import write_alto
|
| 240 |
+
|
| 241 |
+
words_gt = "Charte signée Paris 14 juillet 1789".split()
|
| 242 |
+
words_cand = "Charte signée Paris 14 juillet 1789".split() # identique
|
| 243 |
+
|
| 244 |
+
# n_lines=1 pour préserver l'ordre des mots dans l'extraction
|
| 245 |
+
# (sinon ``alto_document_to_text`` produit des sauts de ligne
|
| 246 |
+
# qui font diverger le CER d'une comparaison ligne unique).
|
| 247 |
+
gt_alto = _build_simple_alto(words_gt, n_lines=1)
|
| 248 |
+
cand_alto = _build_simple_alto(words_cand, n_lines=1)
|
| 249 |
+
cand_alto_path = tmp_path / "cand.alto.xml"
|
| 250 |
+
cand_alto_path.write_bytes(write_alto(cand_alto))
|
| 251 |
+
|
| 252 |
+
# Payloads : raw text pour les payloads projetés depuis ALTO,
|
| 253 |
+
# AltoDocument pour la GT et le candidat ALTO direct.
|
| 254 |
+
from picarones.evaluation.projectors import alto_document_to_text
|
| 255 |
+
payloads = {
|
| 256 |
+
"gt_text": " ".join(words_gt),
|
| 257 |
+
"gt_alto": gt_alto,
|
| 258 |
+
"cand": cand_alto, # AltoDocument pour AltoView
|
| 259 |
+
"cand:projected_text": alto_document_to_text(cand_alto),
|
| 260 |
+
}
|
| 261 |
+
executor = _build_unified_executor(payloads)
|
| 262 |
+
|
| 263 |
+
gt_text_art = Artifact(id="gt_text", document_id="d", type=ArtifactType.RAW_TEXT)
|
| 264 |
+
gt_alto_art = Artifact(id="gt_alto", document_id="d", type=ArtifactType.ALTO_XML)
|
| 265 |
+
cand_art = Artifact(
|
| 266 |
+
id="cand", document_id="d",
|
| 267 |
+
type=ArtifactType.ALTO_XML, uri=str(cand_alto_path),
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
# TextView : projette ALTO → texte, compare au gt_text.
|
| 271 |
+
text_result = executor.evaluate(build_text_view(), cand_art, gt_text_art)
|
| 272 |
+
assert text_result.metric_values["cer"] == 0.0
|
| 273 |
+
|
| 274 |
+
# SearchView : projette ALTO → texte, mesure recall + années.
|
| 275 |
+
search_result = executor.evaluate(build_search_view(), cand_art, gt_text_art)
|
| 276 |
+
assert search_result.metric_values["searchability_recall"] == 1.0
|
| 277 |
+
|
| 278 |
+
# AltoView : compare ALTO direct contre ALTO GT.
|
| 279 |
+
alto_result = executor.evaluate(build_alto_view(), cand_art, gt_alto_art)
|
| 280 |
+
assert alto_result.metric_values["alto_validity"] == 1.0
|
| 281 |
+
assert alto_result.metric_values["alto_line_count_ratio"] == 1.0
|
| 282 |
+
assert alto_result.metric_values["alto_word_box_coverage"] == 1.0
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
# ──────────────────────────────────────────────────────────────────
|
| 286 |
+
# Cohérence globale : projection report présent ssi projection appliquée
|
| 287 |
+
# ──────────────────────────────────────────────────────────────────
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
class TestProjectionReportConsistency:
|
| 291 |
+
def test_text_search_views_share_projection_report_pattern(self) -> None:
|
| 292 |
+
"""Pour un même candidat ALTO_XML évalué dans TextView et
|
| 293 |
+
SearchView, les deux ViewResult doivent porter un
|
| 294 |
+
projection_report (les deux vues projettent vers texte)."""
|
| 295 |
+
gt_text = "test"
|
| 296 |
+
gt_alto = _build_simple_alto(["test"], n_lines=1)
|
| 297 |
+
from picarones.evaluation.projectors import alto_document_to_text
|
| 298 |
+
from picarones.formats.alto import write_alto
|
| 299 |
+
|
| 300 |
+
# Pour ce test on n'a pas besoin du fichier réel — on simule
|
| 301 |
+
# via le payload_loader qui retourne directement le texte
|
| 302 |
+
# extrait pour l'id "cand:projected_text".
|
| 303 |
+
payloads = {
|
| 304 |
+
"gt_text": gt_text,
|
| 305 |
+
"cand:projected_text": alto_document_to_text(gt_alto),
|
| 306 |
+
}
|
| 307 |
+
# Mais le projecteur a besoin d'un URI. On contourne en
|
| 308 |
+
# créant un fichier temporaire dans pytest fixture.
|
| 309 |
+
# Pour ce test simple on écrit dans /tmp.
|
| 310 |
+
import tempfile
|
| 311 |
+
with tempfile.NamedTemporaryFile(suffix=".alto.xml", delete=False) as f:
|
| 312 |
+
f.write(write_alto(gt_alto))
|
| 313 |
+
cand_uri = f.name
|
| 314 |
+
|
| 315 |
+
executor = _build_unified_executor(payloads)
|
| 316 |
+
cand = Artifact(
|
| 317 |
+
id="cand", document_id="d",
|
| 318 |
+
type=ArtifactType.ALTO_XML, uri=cand_uri,
|
| 319 |
+
)
|
| 320 |
+
gt = Artifact(id="gt_text", document_id="d", type=ArtifactType.RAW_TEXT)
|
| 321 |
+
|
| 322 |
+
text_result = executor.evaluate(build_text_view(), cand, gt)
|
| 323 |
+
search_result = executor.evaluate(build_search_view(), cand, gt)
|
| 324 |
+
|
| 325 |
+
# Les deux doivent avoir un projection_report (même projecteur).
|
| 326 |
+
assert text_result.projection_report is not None
|
| 327 |
+
assert search_result.projection_report is not None
|
| 328 |
+
assert text_result.projection_report.projector_name == "alto_to_text"
|
| 329 |
+
assert search_result.projection_report.projector_name == "alto_to_text"
|
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sprint A14-S16 — SearchView + métriques de recherchabilité."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from picarones.domain import Artifact, ArtifactType, MetricSpec
|
| 8 |
+
from picarones.evaluation.metrics.search import (
|
| 9 |
+
levenshtein_distance,
|
| 10 |
+
numerical_sequence_preservation,
|
| 11 |
+
searchability_recall,
|
| 12 |
+
)
|
| 13 |
+
from picarones.evaluation.projectors import (
|
| 14 |
+
AltoToText,
|
| 15 |
+
CanonicalToText,
|
| 16 |
+
PageToText,
|
| 17 |
+
ProjectorRegistry,
|
| 18 |
+
)
|
| 19 |
+
from picarones.evaluation.registry import MetricRegistry
|
| 20 |
+
from picarones.evaluation.views import (
|
| 21 |
+
DEFAULT_SEARCH_METRICS,
|
| 22 |
+
DefaultEvaluationViewExecutor,
|
| 23 |
+
build_search_view,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# ──────────────────────────────────────────────────────────────────
|
| 28 |
+
# Métriques individuelles
|
| 29 |
+
# ──────────────────────────────────────────────────────────────────
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class TestLevenshtein:
|
| 33 |
+
def test_identical(self) -> None:
|
| 34 |
+
assert levenshtein_distance("hello", "hello") == 0
|
| 35 |
+
|
| 36 |
+
def test_empty(self) -> None:
|
| 37 |
+
assert levenshtein_distance("", "") == 0
|
| 38 |
+
assert levenshtein_distance("abc", "") == 3
|
| 39 |
+
assert levenshtein_distance("", "abc") == 3
|
| 40 |
+
|
| 41 |
+
def test_single_substitution(self) -> None:
|
| 42 |
+
assert levenshtein_distance("hello", "hallo") == 1
|
| 43 |
+
|
| 44 |
+
def test_kitten_sitting(self) -> None:
|
| 45 |
+
# Cas canonique : kitten → sitting (k→s, e→i, +g) = 3 ops
|
| 46 |
+
assert levenshtein_distance("kitten", "sitting") == 3
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class TestSearchabilityRecall:
|
| 50 |
+
def test_perfect_match(self) -> None:
|
| 51 |
+
recall = searchability_recall("hello world", "hello world")
|
| 52 |
+
assert recall == 1.0
|
| 53 |
+
|
| 54 |
+
def test_fuzzy_match_within_threshold(self) -> None:
|
| 55 |
+
# "monde" vs "monds" → 1 substitution, ≤ 2 → match
|
| 56 |
+
recall = searchability_recall("le monde", "le monds")
|
| 57 |
+
assert recall == 1.0
|
| 58 |
+
|
| 59 |
+
def test_fuzzy_match_beyond_threshold(self) -> None:
|
| 60 |
+
# "monde" vs "rabbit" → distance > 2 → pas de match
|
| 61 |
+
recall = searchability_recall("le monde", "le rabbit")
|
| 62 |
+
# "le" matche, "monde" non → 1/2 = 0.5
|
| 63 |
+
assert recall == 0.5
|
| 64 |
+
|
| 65 |
+
def test_empty_gt_returns_zero(self) -> None:
|
| 66 |
+
assert searchability_recall("", "hello") == 0.0
|
| 67 |
+
|
| 68 |
+
def test_multiplicity_respected(self) -> None:
|
| 69 |
+
# GT a "le" deux fois, hyp une seule fois → 1/2
|
| 70 |
+
recall = searchability_recall("le le monde", "le monde")
|
| 71 |
+
assert abs(recall - 2 / 3) < 1e-9 # "le", "monde" matchent (1 "le" non)
|
| 72 |
+
|
| 73 |
+
def test_case_insensitive_by_default(self) -> None:
|
| 74 |
+
assert searchability_recall("Bonjour", "bonjour") == 1.0
|
| 75 |
+
|
| 76 |
+
def test_negative_max_distance_raises(self) -> None:
|
| 77 |
+
with pytest.raises(ValueError, match="max_distance"):
|
| 78 |
+
searchability_recall("a", "b", max_distance=-1)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class TestNumericalSequencePreservation:
|
| 82 |
+
def test_perfect_year_preservation(self) -> None:
|
| 83 |
+
score = numerical_sequence_preservation(
|
| 84 |
+
"fait à Paris en 1789",
|
| 85 |
+
"fait à Paris en 1789",
|
| 86 |
+
)
|
| 87 |
+
assert score == 1.0
|
| 88 |
+
|
| 89 |
+
def test_year_corrupted(self) -> None:
|
| 90 |
+
# GT contient "1789", hyp contient "1798" (pas dans hyp_years)
|
| 91 |
+
# Mais "1798" est aussi une année 4 chiffres valide qui matche
|
| 92 |
+
# le regex. Vérifions la sémantique : on cherche les années
|
| 93 |
+
# GT dans les années hyp.
|
| 94 |
+
score = numerical_sequence_preservation(
|
| 95 |
+
"année 1789",
|
| 96 |
+
"année 1798",
|
| 97 |
+
)
|
| 98 |
+
# 1789 (GT) n'est PAS dans hyp_years = [1798] → 0/1 = 0.0
|
| 99 |
+
assert score == 0.0
|
| 100 |
+
|
| 101 |
+
def test_partial_preservation(self) -> None:
|
| 102 |
+
score = numerical_sequence_preservation(
|
| 103 |
+
"1789, 1799, 1815",
|
| 104 |
+
"1789 et 1815", # 1799 perdu
|
| 105 |
+
)
|
| 106 |
+
# 2/3 préservés
|
| 107 |
+
assert abs(score - 2 / 3) < 1e-9
|
| 108 |
+
|
| 109 |
+
def test_no_years_in_gt(self) -> None:
|
| 110 |
+
score = numerical_sequence_preservation(
|
| 111 |
+
"pas de date ici",
|
| 112 |
+
"pas de date là",
|
| 113 |
+
)
|
| 114 |
+
assert score == 0.0 # convention : pas d'années GT → 0.0
|
| 115 |
+
|
| 116 |
+
def test_year_regex_bounds(self) -> None:
|
| 117 |
+
# Année 999 → trop court (3 chiffres)
|
| 118 |
+
# Année 1000 → OK
|
| 119 |
+
# Année 2099 → hors plage (regex 2[0-2][0-9])
|
| 120 |
+
score = numerical_sequence_preservation("an 999 et 1000", "an 999 et 1000")
|
| 121 |
+
# Seul "1000" est détecté en GT → comparé à hyp où "1000" présent aussi
|
| 122 |
+
assert score == 1.0
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# ──────────────────────────────────────────────────────────────────
|
| 126 |
+
# SearchView shape
|
| 127 |
+
# ────────────────────────────────────────────────��─────────────────
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
class TestSearchViewShape:
|
| 131 |
+
def test_default_view_accepts_5_types(self) -> None:
|
| 132 |
+
view = build_search_view()
|
| 133 |
+
for t in (
|
| 134 |
+
ArtifactType.RAW_TEXT,
|
| 135 |
+
ArtifactType.CORRECTED_TEXT,
|
| 136 |
+
ArtifactType.ALTO_XML,
|
| 137 |
+
ArtifactType.PAGE_XML,
|
| 138 |
+
ArtifactType.CANONICAL_DOCUMENT,
|
| 139 |
+
):
|
| 140 |
+
assert view.accepts(t)
|
| 141 |
+
|
| 142 |
+
def test_default_metrics(self) -> None:
|
| 143 |
+
view = build_search_view()
|
| 144 |
+
assert view.metric_names == DEFAULT_SEARCH_METRICS
|
| 145 |
+
|
| 146 |
+
def test_projection_for_alto_routes_correctly(self) -> None:
|
| 147 |
+
view = build_search_view()
|
| 148 |
+
spec = view.projection_for(ArtifactType.ALTO_XML)
|
| 149 |
+
assert spec is not None
|
| 150 |
+
assert spec.projector_name == "alto_to_text"
|
| 151 |
+
|
| 152 |
+
def test_warnings_signal_higher_is_better_inversion(self) -> None:
|
| 153 |
+
view = build_search_view()
|
| 154 |
+
text = " ".join(view.warnings)
|
| 155 |
+
assert "higher_is_better" in text or "OPPOSÉ" in text
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
# ──────────────────────────────────────────────────────────────────
|
| 159 |
+
# SearchView avec executor
|
| 160 |
+
# ──────────────────────────────────────────────────────────────────
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _build_search_executor(payloads: dict[str, str]) -> DefaultEvaluationViewExecutor:
|
| 164 |
+
metrics = MetricRegistry()
|
| 165 |
+
metrics.register(
|
| 166 |
+
MetricSpec(
|
| 167 |
+
name="searchability_recall",
|
| 168 |
+
input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT),
|
| 169 |
+
higher_is_better=True,
|
| 170 |
+
),
|
| 171 |
+
searchability_recall,
|
| 172 |
+
)
|
| 173 |
+
metrics.register(
|
| 174 |
+
MetricSpec(
|
| 175 |
+
name="numerical_sequence_preservation",
|
| 176 |
+
input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT),
|
| 177 |
+
higher_is_better=True,
|
| 178 |
+
),
|
| 179 |
+
numerical_sequence_preservation,
|
| 180 |
+
)
|
| 181 |
+
projectors = ProjectorRegistry()
|
| 182 |
+
projectors.register(AltoToText())
|
| 183 |
+
projectors.register(PageToText())
|
| 184 |
+
projectors.register(CanonicalToText())
|
| 185 |
+
|
| 186 |
+
def loader(art: Artifact) -> str:
|
| 187 |
+
if art.id not in payloads:
|
| 188 |
+
raise KeyError(art.id)
|
| 189 |
+
return payloads[art.id]
|
| 190 |
+
|
| 191 |
+
return DefaultEvaluationViewExecutor(metrics, projectors, loader)
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
class TestSearchViewWithExecutor:
|
| 195 |
+
def test_perfect_text_yields_recall_1(self) -> None:
|
| 196 |
+
payloads = {
|
| 197 |
+
"cand": "le petit chat noir 1789",
|
| 198 |
+
"gt": "le petit chat noir 1789",
|
| 199 |
+
}
|
| 200 |
+
executor = _build_search_executor(payloads)
|
| 201 |
+
view = build_search_view()
|
| 202 |
+
cand = Artifact(id="cand", document_id="d", type=ArtifactType.RAW_TEXT)
|
| 203 |
+
gt = Artifact(id="gt", document_id="d", type=ArtifactType.RAW_TEXT)
|
| 204 |
+
result = executor.evaluate(view, cand, gt)
|
| 205 |
+
assert result.metric_values["searchability_recall"] == 1.0
|
| 206 |
+
assert result.metric_values["numerical_sequence_preservation"] == 1.0
|
| 207 |
+
|
| 208 |
+
def test_partial_text_quality_with_year_loss(self) -> None:
|
| 209 |
+
payloads = {
|
| 210 |
+
"cand": "le pelit chat noir 1798", # erreur typo + année corrompue
|
| 211 |
+
"gt": "le petit chat noir 1789",
|
| 212 |
+
}
|
| 213 |
+
executor = _build_search_executor(payloads)
|
| 214 |
+
view = build_search_view()
|
| 215 |
+
cand = Artifact(id="cand", document_id="d", type=ArtifactType.RAW_TEXT)
|
| 216 |
+
gt = Artifact(id="gt", document_id="d", type=ArtifactType.RAW_TEXT)
|
| 217 |
+
result = executor.evaluate(view, cand, gt)
|
| 218 |
+
# "petit"→"pelit" = 1 sub, OK ; "1789"→"1798" = 2 subs, OK pour
|
| 219 |
+
# searchability fuzzy. Donc searchability_recall ≈ 1.0.
|
| 220 |
+
assert result.metric_values["searchability_recall"] >= 0.8
|
| 221 |
+
# Mais l'année 1789 N'EST PAS dans hyp → preservation = 0.
|
| 222 |
+
assert result.metric_values["numerical_sequence_preservation"] == 0.0
|