Claude commited on
Commit
db7da83
·
unverified ·
1 Parent(s): 99ad1af

feat(evaluation): Sprint A14-S16 — SearchView + cohérence inter-vues + 3 vues canoniques opérationnelles

Browse files

Sprint 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 ADDED
@@ -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
picarones/evaluation/metrics/search.py ADDED
@@ -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
+ ]
picarones/evaluation/views/__init__.py CHANGED
@@ -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
  ]
picarones/evaluation/views/search_view.py ADDED
@@ -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
+ ]
tests/evaluation/test_sprint_a14_s16_views_consistency.py ADDED
@@ -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"
tests/evaluation/views/test_sprint_a14_s16_search_view.py ADDED
@@ -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