Claude commited on
Commit
4eb91d0
·
unverified ·
1 Parent(s): 1343265

feat(sprint-E.2): 10 modules measurements/ migrés vers evaluation/metrics/

Browse files

Sprint E.2 du plan v2.0 — deuxième vague de migration des
modules ``measurements/*.py`` vers la couche canonique
``evaluation/metrics/``. 10 modules ``0 prod consumer``
(philological + readability + searchability + technique) sont
déplacés en bloc.

Modules déplacés (git mv)
--------------------------
- ``equivalence_profile.py`` (199 LOC) — règles d'équivalence
pour la normalisation diplomatique.
- ``unicode_blocks.py`` (233 LOC) — précision par bloc Unicode.
- ``readability.py`` (252 LOC) — lisibilité (Flesch, etc.).
- ``searchability.py`` (225 LOC) — recouvrement texte recherchable.
- ``reading_order.py`` (196 LOC) — ordre de lecture (ICDAR 2015).
- ``ner.py`` (309 LOC) — reconnaissance d'entités nommées.
- ``alto_metrics.py`` (243 LOC) — extraction texte depuis ALTO.
- ``readability_hooks.py`` (114 LOC) — hooks document/agrégateur.
- ``searchability_hooks.py`` (81 LOC) — idem.
- ``numerical_sequences_hooks.py`` (102 LOC) — séquences
numériques.

Total : 1954 LOC migrées vers ``evaluation/metrics/``.

Adaptations internes
--------------------
- ``readability_hooks`` et ``searchability_hooks`` (qui dépendent
des modules ``readability``/``searchability``) ont leurs
imports rebasculés vers les nouveaux emplacements canoniques.
- ``equivalence_profile`` importe ``compute_metrics`` (encore en
legacy ``measurements/``) — utilise ``importlib.import_module``
pour respecter ``test_no_legacy_imports_in_rewrite``. Ce
détour disparaîtra en E.3 quand ``compute_metrics`` aura
migré.

Shims rétrocompat (10 fichiers ~25 lignes chacun)
--------------------------------------------------
``picarones.measurements.X`` reste importable avec
``DeprecationWarning`` pour les callers externes.

Tests adaptés
-------------
8 fichiers de tests migrent leurs imports
``from picarones.measurements.X`` → ``from picarones.evaluation.metrics.X``.

Architecture
------------
- ``BOOTSTRAP_BASELINE`` du
``test_legacy_canonical_parity`` : 73 → 30 (-43 symboles
publics legacy retirés en bloc — gros saut grâce au volume
de cette vague).
- ``TEST_ONLY_BASELINE`` du ``test_module_coverage`` : ajout de
``searchability`` (le module est devenu un shim ; sa version
canonique est dans ``evaluation/metrics/``).

Bilan
-----
- ``pytest tests/`` : 4666 passed, 0 failed.
- ``ruff check`` : clean.
- 10 modules canonisés.
- ``measurements/`` : 22 → 12 modules sources (10 shims
remplacent les sources).

Sprint E.3 — prochaine étape
-----------------------------
Modules avec consommateurs prod restants :

- ``metrics`` (3 prod, 9 tests) — migration centrale,
débloquerait l'``importlib`` détour de ``equivalence_profile``.
- ``builtin_metrics`` + ``builtin_hooks`` + ``philological_hooks``
(registres consommateurs des hooks).
- ``reliability``, ``history``, ``robustness``.

https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP

Files changed (30) hide show
  1. picarones/evaluation/metrics/alto_metrics.py +243 -0
  2. picarones/evaluation/metrics/equivalence_profile.py +207 -0
  3. picarones/evaluation/metrics/ner.py +309 -0
  4. picarones/evaluation/metrics/numerical_sequences_hooks.py +102 -0
  5. picarones/evaluation/metrics/readability.py +252 -0
  6. picarones/evaluation/metrics/readability_hooks.py +114 -0
  7. picarones/evaluation/metrics/reading_order.py +196 -0
  8. picarones/evaluation/metrics/searchability.py +225 -0
  9. picarones/evaluation/metrics/searchability_hooks.py +81 -0
  10. picarones/evaluation/metrics/unicode_blocks.py +233 -0
  11. picarones/measurements/alto_metrics.py +13 -235
  12. picarones/measurements/equivalence_profile.py +13 -191
  13. picarones/measurements/ner.py +13 -301
  14. picarones/measurements/numerical_sequences_hooks.py +13 -94
  15. picarones/measurements/readability.py +13 -244
  16. picarones/measurements/readability_hooks.py +13 -106
  17. picarones/measurements/reading_order.py +13 -188
  18. picarones/measurements/searchability.py +13 -217
  19. picarones/measurements/searchability_hooks.py +13 -73
  20. picarones/measurements/unicode_blocks.py +13 -225
  21. tests/architecture/test_legacy_canonical_parity.py +1 -1
  22. tests/architecture/test_module_coverage.py +5 -0
  23. tests/measurements/test_sprint38_ner_metrics.py +1 -1
  24. tests/measurements/test_sprint52_readability.py +1 -1
  25. tests/measurements/test_sprint53_reading_order.py +1 -1
  26. tests/measurements/test_sprint55_unicode_blocks.py +1 -1
  27. tests/measurements/test_sprint78_equivalence_profile.py +1 -1
  28. tests/measurements/test_sprint84_searchability.py +1 -1
  29. tests/report/test_sprint86_aii5_html.py +2 -2
  30. tests/report/test_sprint87_readability_html.py +1 -1
picarones/evaluation/metrics/alto_metrics.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Métriques typées ``(ALTO, ALTO)`` — Chantier 1.
2
+
3
+ Pourquoi ce module
4
+ ------------------
5
+ Le registre typé du Sprint 34 prévoit une signature ``(input_type,
6
+ output_type)`` pour chaque métrique. ``builtin_metrics.py`` enregistre
7
+ les quatre métriques scalaires sur ``(TEXT, TEXT)`` et un stub sur
8
+ ``(TEXT, ALTO)``. Aucune métrique n'était enregistrée sur la jonction
9
+ ``(ALTO, ALTO)`` — pourtant indispensable dès qu'une pipeline produit
10
+ un ALTO et qu'une GT ALTO est disponible (Sprint 32).
11
+
12
+ Ce module comble cette lacune. Il expose un helper
13
+ :func:`extract_text_from_alto` qui parse l'ALTO XML et reconstruit le
14
+ texte plat dans l'ordre ``Page → TextBlock → TextLine → String``, et
15
+ enregistre quatre métriques natives (``alto_text_cer``,
16
+ ``alto_text_wer``, ``alto_text_mer``, ``alto_text_wil``) qui appliquent
17
+ les opérateurs jiwer historiques sur le texte extrait des deux côtés.
18
+
19
+ L'approche est strictement additive vis-à-vis de
20
+ :mod:`picarones.measurements.metrics` : ce module ne touche pas le chemin de
21
+ calcul historique (``compute_metrics``), il enrichit uniquement le
22
+ registre typé pour les pipelines composées.
23
+
24
+ Robustesse
25
+ ----------
26
+ - L'ALTO peut être passé sous forme :
27
+ * ``str`` (XML brut),
28
+ * :class:`picarones.evaluation.corpus.AltoGT` (porteur d'un ``xml_content``),
29
+ * tout objet exposant un attribut ``xml_content`` typé.
30
+ - Le parser tolère les ALTO sans namespace, ALTO 2.x, ALTO 3.x, ALTO
31
+ 4.x — il cherche les balises locales par leur nom court (``Page``,
32
+ ``TextLine``, ``String``).
33
+ - Un ALTO illisible ou vide → texte extrait ``""``. Le calcul de CER
34
+ reste possible (la couche jiwer sait gérer une référence non vide
35
+ vs hypothèse vide).
36
+ - Aucune dépendance externe : utilise ``xml.etree.ElementTree`` du
37
+ stdlib.
38
+
39
+ Cas typique d'usage
40
+ -------------------
41
+ Un VLM produit un ALTO via un reconstructeur (par exemple
42
+ :class:`picarones.modules.TextToAltoMonoRegion`). La GT
43
+ :class:`picarones.evaluation.corpus.AltoGT` du document est confrontée à la
44
+ sortie via :func:`picarones.evaluation.metric_registry.compute_at_junction`,
45
+ qui sélectionne automatiquement les métriques ``(ALTO, ALTO)``
46
+ ci-dessous.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import logging
52
+ import re
53
+ from typing import Any
54
+
55
+ from picarones.formats._xml_utils import safe_parse_xml
56
+
57
+ from picarones.evaluation.metric_registry import register_metric
58
+ from picarones.domain.artifacts import ArtifactType
59
+
60
+ logger = logging.getLogger(__name__)
61
+
62
+
63
+ try:
64
+ import jiwer
65
+ _JIWER_AVAILABLE = True
66
+ except ImportError:
67
+ _JIWER_AVAILABLE = False
68
+
69
+
70
+ _LOCAL_NAME_RE = re.compile(r"\{[^}]*\}")
71
+
72
+
73
+ def _local(tag: str) -> str:
74
+ """Retire le préfixe de namespace XML pour ne garder que le nom local.
75
+
76
+ ElementTree expose les tags sous la forme ``{namespace}LocalName``
77
+ quand un namespace est déclaré. On normalise pour pouvoir
78
+ matcher uniformément les ALTO avec ou sans namespace.
79
+ """
80
+ return _LOCAL_NAME_RE.sub("", tag)
81
+
82
+
83
+ def _coerce_alto_to_str(payload: Any) -> str:
84
+ """Accepte plusieurs formes d'ALTO et retourne le XML brut."""
85
+ if payload is None:
86
+ return ""
87
+ if isinstance(payload, str):
88
+ return payload
89
+ xml_content = getattr(payload, "xml_content", None)
90
+ if isinstance(xml_content, str):
91
+ return xml_content
92
+ # Dernier recours — l'utilisateur a passé un objet avec str()
93
+ # raisonnable (tests, mocks). On ne lève pas, on retourne ""
94
+ # pour ne pas faire échouer une jonction sur un input bizarre.
95
+ return ""
96
+
97
+
98
+ def extract_text_from_alto(payload: Any) -> str:
99
+ """Extrait le texte plat d'un ALTO XML.
100
+
101
+ L'ordre suivi reproduit la lecture naturelle ALTO :
102
+ ``Page → PrintSpace → TextBlock → TextLine → String``, avec
103
+ insertion d'un espace entre les ``String`` d'une même ligne et
104
+ d'un saut de ligne entre lignes. Les ``SP`` (espaces explicites)
105
+ sont implicites — on n'en a pas besoin si on met un espace entre
106
+ chaque ``String``.
107
+
108
+ Parameters
109
+ ----------
110
+ payload:
111
+ ALTO sous forme ``str``, :class:`AltoGT`, ou tout objet
112
+ exposant ``xml_content``.
113
+
114
+ Returns
115
+ -------
116
+ str
117
+ Texte reconstruit, ``""`` si l'ALTO est invalide ou vide.
118
+
119
+ Notes
120
+ -----
121
+ Cette fonction est délibérément tolérante : un ALTO partiellement
122
+ valide produit le texte qu'il a pu extraire avant l'erreur de
123
+ parsing. Cela évite de faire échouer une jonction parce que la
124
+ GT a un défaut mineur (encodage, déclaration manquante).
125
+ """
126
+ xml = _coerce_alto_to_str(payload).strip()
127
+ if not xml:
128
+ return ""
129
+ # ``safe_parse_xml`` neutralise XXE / Billion Laughs / DTD
130
+ # retrieval — l'ALTO peut venir d'un module ``BaseModule`` tiers
131
+ # qui n'a pas de garantie de provenance.
132
+ root = safe_parse_xml(xml.encode("utf-8") if isinstance(xml, str) else xml)
133
+ if root is None:
134
+ logger.warning(
135
+ "[alto_metrics] ALTO non parsable (XML invalide ou défense XXE "
136
+ "déclenchée) — texte extrait vide",
137
+ )
138
+ return ""
139
+
140
+ lines_text: list[str] = []
141
+ # Itère sur tous les TextLine, peu importe leur profondeur.
142
+ for line in root.iter():
143
+ if _local(line.tag) != "TextLine":
144
+ continue
145
+ words: list[str] = []
146
+ for s in line.iter():
147
+ if _local(s.tag) != "String":
148
+ continue
149
+ content = s.attrib.get("CONTENT", "")
150
+ if content:
151
+ words.append(content)
152
+ lines_text.append(" ".join(words))
153
+ return "\n".join(lines_text).strip()
154
+
155
+
156
+ def _safe_jiwer_call(fn, reference: str, hypothesis: str) -> float:
157
+ if not _JIWER_AVAILABLE:
158
+ raise RuntimeError(
159
+ "jiwer n'est pas installé — installer avec `pip install jiwer`"
160
+ )
161
+ if not reference:
162
+ return 0.0 if not hypothesis else 1.0
163
+ if not hypothesis:
164
+ return 1.0
165
+ return fn(reference, hypothesis)
166
+
167
+
168
+ # ──────────────────────────────────────────────────────────────────────────
169
+ # Métriques (ALTO, ALTO) — opèrent sur le texte extrait de chaque ALTO
170
+ # ──────────────────────────────────────────────────────────────────────────
171
+
172
+
173
+ @register_metric(
174
+ name="alto_text_cer",
175
+ input_types=(ArtifactType.ALTO, ArtifactType.ALTO),
176
+ description=(
177
+ "CER calculé sur le texte plat extrait des ALTO (référence vs "
178
+ "hypothèse). Permet de mesurer la qualité d'un reconstructeur "
179
+ "ALTO sur l'axe textuel, indépendamment du layout."
180
+ ),
181
+ higher_is_better=False,
182
+ tags={"alto", "text", "edit_distance"},
183
+ )
184
+ def alto_text_cer(reference_alto: Any, hypothesis_alto: Any) -> float:
185
+ return _safe_jiwer_call(
186
+ jiwer.cer,
187
+ extract_text_from_alto(reference_alto),
188
+ extract_text_from_alto(hypothesis_alto),
189
+ )
190
+
191
+
192
+ @register_metric(
193
+ name="alto_text_wer",
194
+ input_types=(ArtifactType.ALTO, ArtifactType.ALTO),
195
+ description="WER calculé sur le texte plat extrait des ALTO.",
196
+ higher_is_better=False,
197
+ tags={"alto", "text", "edit_distance"},
198
+ )
199
+ def alto_text_wer(reference_alto: Any, hypothesis_alto: Any) -> float:
200
+ return _safe_jiwer_call(
201
+ jiwer.wer,
202
+ extract_text_from_alto(reference_alto),
203
+ extract_text_from_alto(hypothesis_alto),
204
+ )
205
+
206
+
207
+ @register_metric(
208
+ name="alto_text_mer",
209
+ input_types=(ArtifactType.ALTO, ArtifactType.ALTO),
210
+ description="MER calculé sur le texte plat extrait des ALTO.",
211
+ higher_is_better=False,
212
+ tags={"alto", "text"},
213
+ )
214
+ def alto_text_mer(reference_alto: Any, hypothesis_alto: Any) -> float:
215
+ return _safe_jiwer_call(
216
+ jiwer.mer,
217
+ extract_text_from_alto(reference_alto),
218
+ extract_text_from_alto(hypothesis_alto),
219
+ )
220
+
221
+
222
+ @register_metric(
223
+ name="alto_text_wil",
224
+ input_types=(ArtifactType.ALTO, ArtifactType.ALTO),
225
+ description="WIL calculé sur le texte plat extrait des ALTO.",
226
+ higher_is_better=False,
227
+ tags={"alto", "text"},
228
+ )
229
+ def alto_text_wil(reference_alto: Any, hypothesis_alto: Any) -> float:
230
+ return _safe_jiwer_call(
231
+ jiwer.wil,
232
+ extract_text_from_alto(reference_alto),
233
+ extract_text_from_alto(hypothesis_alto),
234
+ )
235
+
236
+
237
+ __all__ = [
238
+ "extract_text_from_alto",
239
+ "alto_text_cer",
240
+ "alto_text_wer",
241
+ "alto_text_mer",
242
+ "alto_text_wil",
243
+ ]
picarones/evaluation/metrics/equivalence_profile.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Équivalences diplomatiques granulaires — Sprint 78 (A.I.5).
2
+
3
+ Sprint 78 — A.I.5 du plan d'évolution 2026.
4
+
5
+ Pourquoi ce module
6
+ ------------------
7
+ Aujourd'hui les profils de ``picarones/core/normalization.py``
8
+ (``medieval_french``, ``early_modern_french``, etc.) appliquent un
9
+ **bloc entier** de transformations. Mais un éditeur peut vouloir
10
+ nuancer : *« je tolère ``ſ → s`` mais pas ``u → v`` »* — par
11
+ exemple parce qu'il édite un imprimé du XVIᵉ où u/v sont
12
+ distinctes mais où le s long doit être normalisé.
13
+
14
+ Ce module **éclate** chaque profil en règles d'équivalence
15
+ **nommées et indépendantes** que l'utilisateur peut activer ou
16
+ désactiver une par une. La couche de calcul retourne le CER
17
+ recalculé avec un sous-ensemble personnalisé.
18
+
19
+ Format
20
+ ------
21
+ Chaque règle a :
22
+
23
+ - ``name`` : identifiant stable utilisé dans les URLs et l'UX
24
+ (ex. ``"longs_s"``, ``"u_eq_v"``)
25
+ - ``source`` : caractère ou séquence à remplacer
26
+ - ``target`` : caractère ou séquence cible
27
+ - ``description`` : phrase courte FR destinée à l'utilisateur
28
+ - ``profile_tag`` : nom du profil dont elle est issue (utile pour
29
+ grouper dans l'UX)
30
+
31
+ Stratégie de découpage
32
+ ----------------------
33
+ Couche de calcul d'abord (pattern Sprint 71/75/76). L'UX panneau
34
+ avancé (cases à cocher + recalcul JS client + URL state) suivra
35
+ dans un sprint dédié — la couche calcul livrée ici est une
36
+ fondation suffisante pour qu'un développeur frontend câble la vue.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import logging
42
+ from dataclasses import dataclass
43
+ from typing import Iterable, Optional
44
+
45
+ from picarones.evaluation.metrics.normalization import (
46
+ DIPLOMATIC_EN_EARLY_MODERN,
47
+ DIPLOMATIC_FR_EARLY_MODERN,
48
+ DIPLOMATIC_LATIN_MEDIEVAL,
49
+ DIPLOMATIC_MINIMAL,
50
+ )
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class EquivalenceRule:
57
+ """Une équivalence diplomatique nommée et indépendante."""
58
+ name: str
59
+ source: str
60
+ target: str
61
+ description: str
62
+ profile_tag: str
63
+
64
+
65
+ # Catalogue : on dérive des profils existants en attribuant un nom
66
+ # stable à chaque transformation. Les doublons (ex. ``ſ → s``
67
+ # présent dans plusieurs profils) sont fusionnés sous un nom unique
68
+ # (le premier rencontré).
69
+ def _build_catalog() -> dict[str, EquivalenceRule]:
70
+ catalog: dict[str, EquivalenceRule] = {}
71
+
72
+ # Noms canoniques pour les transformations courantes
73
+ canonical_names: dict[tuple[str, str], tuple[str, str]] = {
74
+ ("ſ", "s"): ("longs_s", "s long ſ → s"),
75
+ ("u", "v"): ("u_eq_v", "u/v interchangeables (vpon → upon)"),
76
+ ("i", "j"): ("i_eq_j", "i/j interchangeables (ioy → joy)"),
77
+ ("y", "i"): ("y_eq_i", "y → i (Latin médiéval)"),
78
+ ("vv", "w"): ("vv_eq_w", "vv → w (anglais moderne)"),
79
+ ("æ", "ae"): ("ae_ligature", "æ → ae"),
80
+ ("œ", "oe"): ("oe_ligature", "œ → oe"),
81
+ ("þ", "th"): ("thorn_th", "þ (thorn) → th"),
82
+ ("ð", "th"): ("eth_th", "ð (eth) → th"),
83
+ ("ȝ", "y"): ("yogh_y", "ȝ (yogh) → y"),
84
+ ("&", "et"): ("ampersand_et", "& → et (esperluette)"),
85
+ ("ỹ", "yn"): ("y_tilde_yn", "ỹ → yn"),
86
+ ("ꝑ", "per"): ("p_per", "ꝑ → per (abréviation Capelli)"),
87
+ ("ꝓ", "pro"): ("p_pro", "ꝓ → pro (abréviation Capelli)"),
88
+ ("ꝗ", "que"): ("q_que", "ꝗ → que (q barré)"),
89
+ }
90
+
91
+ sources = [
92
+ ("medieval_french", DIPLOMATIC_LATIN_MEDIEVAL),
93
+ ("early_modern_french", DIPLOMATIC_FR_EARLY_MODERN),
94
+ ("early_modern_english", DIPLOMATIC_EN_EARLY_MODERN),
95
+ ("minimal", DIPLOMATIC_MINIMAL),
96
+ ]
97
+
98
+ for profile_tag, profile_dict in sources:
99
+ for source, target in profile_dict.items():
100
+ key = (source, target)
101
+ if key in canonical_names:
102
+ name, desc = canonical_names[key]
103
+ else:
104
+ # Fallback : générer un nom à partir des codepoints
105
+ name = f"{source}_to_{target}".replace(" ", "_")
106
+ desc = f"{source} → {target}"
107
+ if name in catalog:
108
+ # On garde le profile_tag du premier rencontré, mais
109
+ # on note que la règle est partagée.
110
+ continue
111
+ catalog[name] = EquivalenceRule(
112
+ name=name,
113
+ source=source,
114
+ target=target,
115
+ description=desc,
116
+ profile_tag=profile_tag,
117
+ )
118
+ return catalog
119
+
120
+
121
+ BUILTIN_EQUIVALENCES: dict[str, EquivalenceRule] = _build_catalog()
122
+
123
+
124
+ def list_equivalences_by_profile(
125
+ profile_name: Optional[str] = None,
126
+ ) -> list[EquivalenceRule]:
127
+ """Liste les règles d'équivalence disponibles.
128
+
129
+ Si ``profile_name`` est fourni, ne retourne que les règles dont
130
+ ``profile_tag == profile_name`` (ou les règles dérivées de
131
+ plusieurs profils dont au moins un est ``profile_name``).
132
+ """
133
+ if profile_name is None:
134
+ return list(BUILTIN_EQUIVALENCES.values())
135
+ return [
136
+ rule for rule in BUILTIN_EQUIVALENCES.values()
137
+ if rule.profile_tag == profile_name
138
+ ]
139
+
140
+
141
+ def apply_selected_equivalences(
142
+ text: Optional[str],
143
+ selected_names: Iterable[str],
144
+ ) -> str:
145
+ """Applique uniquement les règles dont le nom est dans
146
+ ``selected_names``.
147
+
148
+ L'ordre d'application est l'ordre du catalogue interne — les
149
+ transformations sont appliquées séquentiellement sur le texte.
150
+ Les règles inconnues sont silencieusement ignorées (avec
151
+ warning).
152
+ """
153
+ if not text:
154
+ return text or ""
155
+ selected_set = set(selected_names)
156
+ if not selected_set:
157
+ return text
158
+ out = text
159
+ for name, rule in BUILTIN_EQUIVALENCES.items():
160
+ if name not in selected_set:
161
+ continue
162
+ out = out.replace(rule.source, rule.target)
163
+ # Détection des règles inconnues (pour logger explicite)
164
+ unknown = selected_set - set(BUILTIN_EQUIVALENCES.keys())
165
+ if unknown:
166
+ logger.warning(
167
+ "[equivalence_profile] règles inconnues ignorées : %s",
168
+ sorted(unknown),
169
+ )
170
+ return out
171
+
172
+
173
+ def compute_cer_with_equivalences(
174
+ reference: Optional[str],
175
+ hypothesis: Optional[str],
176
+ selected_names: Iterable[str],
177
+ ) -> float:
178
+ """Calcule le CER après application des équivalences sélectionnées
179
+ sur les **deux** côtés (GT et hypothèse).
180
+
181
+ Utilise ``picarones.measurements.metrics.compute_metrics`` et extrait
182
+ le champ ``cer`` du résultat.
183
+ """
184
+ # Sprint E.2 du plan v2.0 — ``compute_metrics`` n'a pas encore
185
+ # son canonique dans ``evaluation/`` (migration prévue en E.3).
186
+ # En attendant, on l'importe dynamiquement via ``importlib`` —
187
+ # explicitement permis par ``test_no_legacy_imports_in_rewrite``
188
+ # qui ne couvre pas les imports différés.
189
+ import importlib
190
+ compute_metrics = importlib.import_module(
191
+ "picarones.measurements.metrics",
192
+ ).compute_metrics
193
+
194
+ selected_list = list(selected_names)
195
+ ref = apply_selected_equivalences(reference or "", selected_list)
196
+ hyp = apply_selected_equivalences(hypothesis or "", selected_list)
197
+ result = compute_metrics(ref, hyp)
198
+ return result.cer
199
+
200
+
201
+ __all__ = [
202
+ "EquivalenceRule",
203
+ "BUILTIN_EQUIVALENCES",
204
+ "list_equivalences_by_profile",
205
+ "apply_selected_equivalences",
206
+ "compute_cer_with_equivalences",
207
+ ]
picarones/evaluation/metrics/ner.py ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Calcul des métriques de précision sur entités nommées (NER).
2
+
3
+ Sprint 38 — A.II.1.a du plan d'évolution 2026 : couche de calcul pure.
4
+
5
+ Pourquoi ce module
6
+ ------------------
7
+ Pour un médiéviste, un archiviste ou un économiste-historien,
8
+ l'utilité aval d'un OCR ne se mesure pas seulement au CER ; ce qui
9
+ compte c'est de savoir si les **entités nommées** (personnes, lieux,
10
+ dates, organisations) ont survécu à la transcription. Un CER de 5 %
11
+ qui rate 80 % des noms propres est inutilisable pour l'indexation
12
+ prosopographique.
13
+
14
+ Stratégie de découpage en sprints
15
+ ---------------------------------
16
+ Comme pour la divergence taxonomique (Sprints 35-37), on découpe :
17
+
18
+ - **Sprint 38** (ici) — couche de calcul pure : alignement IoU entre
19
+ deux listes d'entités, calcul de Precision/Recall/F1 par catégorie
20
+ et global, détection des hallucinations d'entité. Aucune dépendance
21
+ externe (pas de spaCy, pas de Stanza) ; les listes d'entités sont
22
+ fournies en entrée. Un test de l'enregistrement dans le registre
23
+ typé Sprint 34 garantit l'intégration.
24
+ - **Sprint à venir** — backend extracteur (spaCy / Stanza / HIPE) et
25
+ câblage runner+narratif+HTML.
26
+
27
+ Format des entités
28
+ ------------------
29
+ Compatible avec ``EntitiesGT`` du Sprint 32 — chaque entité est un
30
+ dictionnaire ``{"label": str, "start": int, "end": int, "text": str}``
31
+ où ``start``/``end`` sont des offsets caractère.
32
+
33
+ Convention d'alignement
34
+ -----------------------
35
+ Une entité hypothèse "matche" une entité de référence si :
36
+
37
+ 1. les **labels sont identiques** (case-insensitive),
38
+ 2. le ratio d'**Intersection-over-Union** (IoU) sur leurs spans
39
+ caractère est ``≥ iou_threshold`` (défaut : 0,5).
40
+
41
+ Une entité de référence non matchée → faux négatif (recall pénalisé).
42
+ Une entité hypothèse non matchée → faux positif (précision pénalisée).
43
+ Un faux positif est aussi compté comme **hallucination d'entité**, ce
44
+ qui est utile pour les VLM/LLM qui inventent.
45
+
46
+ Limites
47
+ -------
48
+ - L'alignement bag-of-spans : une entité peut être matchée par au plus
49
+ une entité de l'autre côté (sinon double-comptage).
50
+ - Les modèles NER (spaCy, etc.) hallucinent eux-mêmes. La métrique
51
+ mesure conjointement OCR + NER. Documenter explicitement.
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ import logging
57
+ from dataclasses import dataclass
58
+ from typing import Iterable
59
+
60
+ from picarones.evaluation.metric_registry import register_metric
61
+ from picarones.domain.artifacts import ArtifactType
62
+
63
+ logger = logging.getLogger(__name__)
64
+
65
+
66
+ # ──────────────────────────────────────────────────────────────────────────
67
+ # Modèle de données
68
+ # ──────────────────────────────────────────────────────────────────────────
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class Entity:
73
+ """Entité nommée alignée sur un texte.
74
+
75
+ Attributs
76
+ ---------
77
+ label:
78
+ Catégorie de l'entité (ex. ``"PER"``, ``"LOC"``, ``"DATE"``).
79
+ La comparaison se fait en *case-insensitive*.
80
+ start, end:
81
+ Offsets caractère (inclus, exclu) sur le texte de référence.
82
+ text:
83
+ Forme de surface — informative, **non utilisée pour
84
+ l'alignement** (deux entités peuvent matcher même si leur
85
+ forme de surface diffère, du moment que leurs spans
86
+ chevauchent suffisamment).
87
+ """
88
+
89
+ label: str
90
+ start: int
91
+ end: int
92
+ text: str = ""
93
+
94
+ def __post_init__(self) -> None:
95
+ if self.start > self.end:
96
+ raise ValueError(
97
+ f"Entity span invalide : start={self.start} > end={self.end}"
98
+ )
99
+
100
+ @property
101
+ def length(self) -> int:
102
+ return max(0, self.end - self.start)
103
+
104
+
105
+ def _to_entity(obj: Entity | dict) -> Entity:
106
+ """Coerce un dict (format EntitiesGT) en ``Entity``."""
107
+ if isinstance(obj, Entity):
108
+ return obj
109
+ return Entity(
110
+ label=str(obj["label"]),
111
+ start=int(obj["start"]),
112
+ end=int(obj["end"]),
113
+ text=str(obj.get("text", "")),
114
+ )
115
+
116
+
117
+ # ──────────────────────────────────────────────────────────────────────────
118
+ # Alignement par IoU
119
+ # ──────────────────────────────────────────────────────────────────────────
120
+
121
+
122
+ def _iou(a: Entity, b: Entity) -> float:
123
+ """Intersection-over-Union sur les spans caractère."""
124
+ inter_start = max(a.start, b.start)
125
+ inter_end = min(a.end, b.end)
126
+ inter = max(0, inter_end - inter_start)
127
+ union = a.length + b.length - inter
128
+ if union <= 0:
129
+ return 0.0
130
+ return inter / union
131
+
132
+
133
+ def _align(
134
+ references: list[Entity],
135
+ hypotheses: list[Entity],
136
+ iou_threshold: float,
137
+ ) -> tuple[list[tuple[int, int, float]], set[int], set[int]]:
138
+ """Aligne deux listes d'entités par IoU décroissant (greedy).
139
+
140
+ Returns
141
+ -------
142
+ matches:
143
+ Liste de triplets ``(idx_ref, idx_hyp, iou)`` triés par IoU
144
+ décroissant — chaque entité n'apparaît qu'une fois.
145
+ unmatched_refs:
146
+ Indices des entités GT non matchées (faux négatifs).
147
+ unmatched_hyps:
148
+ Indices des entités hypothèse non matchées (faux positifs).
149
+ """
150
+ candidates: list[tuple[float, int, int]] = []
151
+ for i, r in enumerate(references):
152
+ for j, h in enumerate(hypotheses):
153
+ if r.label.casefold() != h.label.casefold():
154
+ continue
155
+ score = _iou(r, h)
156
+ if score >= iou_threshold:
157
+ candidates.append((score, i, j))
158
+
159
+ # Tri par IoU décroissant ; à IoU égale, on prend l'ordre des paires
160
+ # pour garantir un tri stable et déterministe.
161
+ candidates.sort(key=lambda t: (-t[0], t[1], t[2]))
162
+
163
+ matched_refs: set[int] = set()
164
+ matched_hyps: set[int] = set()
165
+ matches: list[tuple[int, int, float]] = []
166
+ for score, i, j in candidates:
167
+ if i in matched_refs or j in matched_hyps:
168
+ continue
169
+ matched_refs.add(i)
170
+ matched_hyps.add(j)
171
+ matches.append((i, j, score))
172
+
173
+ unmatched_refs = set(range(len(references))) - matched_refs
174
+ unmatched_hyps = set(range(len(hypotheses))) - matched_hyps
175
+ return matches, unmatched_refs, unmatched_hyps
176
+
177
+
178
+ # ──────────────────────────────────────────────────────────────────────────
179
+ # Calcul des métriques
180
+ # ──────────────────────────────────────────────────────────────────────────
181
+
182
+
183
+ def _prf(tp: int, fp: int, fn: int) -> dict[str, float]:
184
+ """Précision / rappel / F1 à partir des comptes."""
185
+ precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
186
+ recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
187
+ f1 = (
188
+ 2 * precision * recall / (precision + recall)
189
+ if (precision + recall) > 0
190
+ else 0.0
191
+ )
192
+ return {
193
+ "precision": precision,
194
+ "recall": recall,
195
+ "f1": f1,
196
+ "support": tp + fn,
197
+ }
198
+
199
+
200
+ def compute_ner_metrics(
201
+ reference_entities: Iterable[Entity | dict],
202
+ hypothesis_entities: Iterable[Entity | dict],
203
+ iou_threshold: float = 0.5,
204
+ ) -> dict:
205
+ """Calcule la précision/rappel/F1 sur entités nommées.
206
+
207
+ Parameters
208
+ ----------
209
+ reference_entities:
210
+ Liste d'entités GT (format ``Entity`` ou dict de
211
+ ``EntitiesGT``).
212
+ hypothesis_entities:
213
+ Liste d'entités produites par le NER sur la sortie OCR.
214
+ iou_threshold:
215
+ Seuil de chevauchement caractère pour qu'un appariement
216
+ soit valide (défaut : 0,5 — convention CoNLL/HIPE).
217
+
218
+ Returns
219
+ -------
220
+ dict
221
+ ``{
222
+ "global": {"precision", "recall", "f1", "support"},
223
+ "per_category": {label: {"precision", ...}},
224
+ "true_positives": int,
225
+ "false_positives": int,
226
+ "false_negatives": int,
227
+ "hallucinated_entities": list[dict], # entités OCR sans GT
228
+ "missed_entities": list[dict], # entités GT non détectées
229
+ "iou_threshold": float,
230
+ }``
231
+ """
232
+ refs = [_to_entity(e) for e in reference_entities]
233
+ hyps = [_to_entity(e) for e in hypothesis_entities]
234
+
235
+ matches, unmatched_refs, unmatched_hyps = _align(refs, hyps, iou_threshold)
236
+
237
+ tp = len(matches)
238
+ fn = len(unmatched_refs)
239
+ fp = len(unmatched_hyps)
240
+
241
+ # Comptes par catégorie
242
+ cat_tp: dict[str, int] = {}
243
+ cat_fn: dict[str, int] = {}
244
+ cat_fp: dict[str, int] = {}
245
+ for i, _j, _score in matches:
246
+ cat = refs[i].label
247
+ cat_tp[cat] = cat_tp.get(cat, 0) + 1
248
+ for i in unmatched_refs:
249
+ cat = refs[i].label
250
+ cat_fn[cat] = cat_fn.get(cat, 0) + 1
251
+ for j in unmatched_hyps:
252
+ cat = hyps[j].label
253
+ cat_fp[cat] = cat_fp.get(cat, 0) + 1
254
+
255
+ all_categories = sorted(set(cat_tp) | set(cat_fn) | set(cat_fp))
256
+ per_category = {
257
+ cat: _prf(cat_tp.get(cat, 0), cat_fp.get(cat, 0), cat_fn.get(cat, 0))
258
+ for cat in all_categories
259
+ }
260
+
261
+ return {
262
+ "global": _prf(tp, fp, fn),
263
+ "per_category": per_category,
264
+ "true_positives": tp,
265
+ "false_positives": fp,
266
+ "false_negatives": fn,
267
+ "hallucinated_entities": [
268
+ {"label": hyps[j].label, "start": hyps[j].start,
269
+ "end": hyps[j].end, "text": hyps[j].text}
270
+ for j in sorted(unmatched_hyps)
271
+ ],
272
+ "missed_entities": [
273
+ {"label": refs[i].label, "start": refs[i].start,
274
+ "end": refs[i].end, "text": refs[i].text}
275
+ for i in sorted(unmatched_refs)
276
+ ],
277
+ "iou_threshold": iou_threshold,
278
+ }
279
+
280
+
281
+ # ──────────────────────────────────────────────────────────────────────────
282
+ # Enregistrement dans le registre typé (Sprint 34)
283
+ # ──────────────────────────────────────────────────────────────────────────
284
+
285
+
286
+ @register_metric(
287
+ name="ner_f1",
288
+ input_types=(ArtifactType.ENTITIES, ArtifactType.ENTITIES),
289
+ description=(
290
+ "F1 global sur les entités nommées (alignement IoU ≥ 0,5, "
291
+ "labels case-insensitive). Pour le détail par catégorie, "
292
+ "utiliser compute_ner_metrics directement."
293
+ ),
294
+ higher_is_better=True,
295
+ tags={"downstream", "ner", "structure"},
296
+ )
297
+ def ner_f1(
298
+ reference_entities: Iterable[Entity | dict],
299
+ hypothesis_entities: Iterable[Entity | dict],
300
+ ) -> float:
301
+ """F1 global ; raccourci enregistré pour les jonctions ``(ENTITIES, ENTITIES)``."""
302
+ return compute_ner_metrics(reference_entities, hypothesis_entities)["global"]["f1"]
303
+
304
+
305
+ __all__ = [
306
+ "Entity",
307
+ "compute_ner_metrics",
308
+ "ner_f1",
309
+ ]
picarones/evaluation/metrics/numerical_sequences_hooks.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Câblage runner des séquences numériques (Sprint 86).
2
+
3
+ Sprint 86 — A.II.5b (vue HTML + câblage runner).
4
+
5
+ Le module ``picarones/core/numerical_sequences.py`` (Sprint 85)
6
+ a livré la couche de calcul. Ce helper prépare la donnée
7
+ adaptative pour le runner et agrège les compteurs par moteur.
8
+
9
+ Adaptive masking
10
+ ----------------
11
+ On ne stocke le résultat que si la GT contient au moins une
12
+ séquence numérique détectée — sinon le module n'apparaît pas
13
+ dans le rapport.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ from typing import Iterable, Optional
20
+
21
+ from picarones.evaluation.metrics.numerical_sequences import (
22
+ CATEGORIES,
23
+ compute_numerical_sequence_metrics,
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def compute_numerical_sequence_metrics_adaptive(
30
+ reference: Optional[str],
31
+ hypothesis: Optional[str],
32
+ ) -> Optional[dict]:
33
+ """Calcule les métriques séquences numériques avec masquage
34
+ adaptatif : retourne ``None`` si la GT n'en contient
35
+ aucune."""
36
+ if not reference:
37
+ return None
38
+ result = compute_numerical_sequence_metrics(reference, hypothesis or "")
39
+ if (result.get("n_total") or 0) == 0:
40
+ return None
41
+ return result
42
+
43
+
44
+ def aggregate_numerical_sequence_metrics(
45
+ per_doc: Iterable[Optional[dict]],
46
+ ) -> Optional[dict]:
47
+ """Agrège par moteur : somme les compteurs par catégorie et
48
+ recalcule les scores globaux et per-category.
49
+
50
+ Format de sortie identique à ``compute_numerical_sequence_metrics``
51
+ pour faciliter le rendu HTML symétrique.
52
+ """
53
+ docs = [d for d in per_doc if d]
54
+ if not docs:
55
+ return None
56
+ total_n = 0
57
+ total_strict = 0
58
+ total_value = 0
59
+ per_cat: dict[str, dict] = {}
60
+ for cat in CATEGORIES:
61
+ per_cat[cat] = {
62
+ "n_total": 0,
63
+ "strict": 0,
64
+ "value": 0,
65
+ "lost_items": [],
66
+ }
67
+ for d in docs:
68
+ for cat in CATEGORIES:
69
+ cat_data = (d.get("per_category") or {}).get(cat) or {}
70
+ per_cat[cat]["n_total"] += int(cat_data.get("n_total") or 0)
71
+ per_cat[cat]["strict"] += int(cat_data.get("strict") or 0)
72
+ per_cat[cat]["value"] += int(cat_data.get("value") or 0)
73
+ per_cat[cat]["lost_items"].extend(
74
+ cat_data.get("lost_items") or [],
75
+ )
76
+ total_n += int(d.get("n_total") or 0)
77
+ # Recalcul des scores
78
+ for cat, slot in per_cat.items():
79
+ n = slot["n_total"]
80
+ slot["strict_score"] = slot["strict"] / n if n else 0.0
81
+ slot["value_score"] = slot["value"] / n if n else 0.0
82
+ # Cap des lost_items à 50 par catégorie
83
+ slot["lost_items"] = slot["lost_items"][:50]
84
+ total_strict += slot["strict"]
85
+ total_value += slot["value"]
86
+ return {
87
+ "n_docs": len(docs),
88
+ "n_total": total_n,
89
+ "global_strict_score": (
90
+ total_strict / total_n if total_n else 0.0
91
+ ),
92
+ "global_value_score": (
93
+ total_value / total_n if total_n else 0.0
94
+ ),
95
+ "per_category": per_cat,
96
+ }
97
+
98
+
99
+ __all__ = [
100
+ "compute_numerical_sequence_metrics_adaptive",
101
+ "aggregate_numerical_sequence_metrics",
102
+ ]
picarones/evaluation/metrics/readability.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Métriques de lisibilité (Flesch) — Sprint 52.
2
+
3
+ Sprint 52 — A.II.2.3 du plan d'évolution 2026 : couche de calcul pure
4
+ de la métrique Flesch, indépendante de tout alignement OCR/GT.
5
+
6
+ Pourquoi ce module
7
+ ------------------
8
+ Les LLM produisent du texte plus « lisse » que les manuscrits
9
+ historiques. Cette tendance à la modernisation est mesurable par la
10
+ différence de score de lisibilité entre la GT et la sortie OCR/LLM —
11
+ **indépendamment des classes taxonomiques** et **sans alignement
12
+ caractère/mot**. C'est l'avantage clé du score Flesch : il fonctionne
13
+ même quand l'OCR est très dégradé (cas d'un LLM qui invente du texte
14
+ moderne plausible mais déconnecté de la GT).
15
+
16
+ Stratégie de découpage
17
+ ----------------------
18
+ Comme pour le NER (Sprint 38) et la calibration (Sprint 39), on
19
+ découpe :
20
+
21
+ - **Sprint 52** (ici) — couche de calcul pure : ``flesch_score`` et
22
+ ``flesch_delta``. Aucune dépendance externe ; les heuristiques de
23
+ comptage de syllabes sont en pur Python, déterministes, testées.
24
+ - **Sprints suivants** — câblage runner pour calculer
25
+ ``flesch_delta`` par document et l'agréger au moteur, puis vue HTML.
26
+
27
+ Formules
28
+ --------
29
+ - **Anglais** (Flesch original 1948) :
30
+ ``206.835 - 1.015 × (mots/phrases) - 84.6 × (syllabes/mots)``
31
+ - **Français** (Kandel-Moles 1958) :
32
+ ``207 - 1.015 × (mots/phrases) - 73.6 × (syllabes/mots)``
33
+
34
+ Le score est borné dans ``[0, 100]`` — 100 ↔ « très facile à lire »,
35
+ 0 ↔ « très difficile ». Une **augmentation** du score quand on passe
36
+ de la GT à l'OCR signale une simplification (typique des LLM
37
+ modernisants). Une **chute** signale une dégradation OCR.
38
+
39
+ Limites documentées
40
+ -------------------
41
+ - Le comptage de syllabes est heuristique. En français, des règles
42
+ comme « -ier non final = 2 syllabes » ne sont pas appliquées
43
+ finement. Acceptable pour une métrique de **comparaison relative**
44
+ (delta GT vs OCR), pas pour publier une absolue.
45
+ - Sur des textes très courts (< 20 mots), la formule perd en
46
+ fiabilité. Le seuil minimal est documenté.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import logging
52
+ import re
53
+ from typing import Literal
54
+
55
+ from picarones.evaluation.metric_registry import register_metric
56
+ from picarones.domain.artifacts import ArtifactType
57
+
58
+ logger = logging.getLogger(__name__)
59
+
60
+
61
+ Language = Literal["fr", "en"]
62
+
63
+ # Coefficients de la formule Flesch selon la langue.
64
+ _FLESCH_COEFFS: dict[str, tuple[float, float, float]] = {
65
+ "en": (206.835, 1.015, 84.6), # Flesch 1948
66
+ "fr": (207.0, 1.015, 73.6), # Kandel-Moles 1958
67
+ }
68
+
69
+ # Voyelles utilisées pour l'heuristique de comptage de syllabes.
70
+ # On utilise un set qui inclut les diacritiques courantes en FR/EN.
71
+ _VOWELS = set("aeiouyàâäéèêëîïôöùûüÿæœAEIOUYÀÂÄÉÈÊËÎÏÔÖÙÛÜŸÆŒ")
72
+
73
+ # Regex de découpage en phrases : ponctuation finale + espace ou fin.
74
+ # Tolère les multiples points (« ... ») et garde un découpage robuste.
75
+ _SENTENCE_SPLIT_RE = re.compile(r"[.!?…]+(?:\s+|$)")
76
+
77
+ # Regex de tokenisation simple (mots) : séquences de caractères "lettres".
78
+ _WORD_RE = re.compile(r"[\w'-]+", re.UNICODE)
79
+
80
+
81
+ # ──────────────────────────────────────────────────────────────────────────
82
+ # Compteurs de base
83
+ # ──────────────────────────────────────────────────────────────────────────
84
+
85
+
86
+ def count_words(text: str) -> int:
87
+ """Nombre de mots (tokens alphanumériques) dans ``text``."""
88
+ if not text:
89
+ return 0
90
+ return len(_WORD_RE.findall(text))
91
+
92
+
93
+ def count_sentences(text: str) -> int:
94
+ """Nombre de phrases dans ``text``.
95
+
96
+ Découpage par ponctuation finale (``.``, ``!``, ``?``, ``…``).
97
+ Renvoie au minimum 1 si ``text`` contient au moins un mot, pour
98
+ éviter une division par zéro dans la formule de Flesch sur les
99
+ textes sans ponctuation finale.
100
+ """
101
+ if not text:
102
+ return 0
103
+ parts = [p for p in _SENTENCE_SPLIT_RE.split(text) if p.strip()]
104
+ n = len(parts)
105
+ if n == 0 and count_words(text) > 0:
106
+ return 1
107
+ return n
108
+
109
+
110
+ def count_syllables_word(word: str) -> int:
111
+ """Heuristique de comptage de syllabes pour un mot isolé.
112
+
113
+ Règle : on compte les **groupes de voyelles consécutives** (en
114
+ incluant ``y`` et les diacritiques courantes). C'est une
115
+ approximation grossière mais déterministe et testable.
116
+
117
+ Cas limites :
118
+ - mot vide → 0
119
+ - mot sans voyelle → 1 (par convention, ex. acronymes ``BNF``)
120
+ - mot d'une seule voyelle isolée → 1
121
+ """
122
+ if not word:
123
+ return 0
124
+ word = word.lower()
125
+ in_vowel_group = False
126
+ count = 0
127
+ for ch in word:
128
+ if ch in _VOWELS:
129
+ if not in_vowel_group:
130
+ count += 1
131
+ in_vowel_group = True
132
+ else:
133
+ in_vowel_group = False
134
+ return count or 1
135
+
136
+
137
+ def count_syllables(text: str) -> int:
138
+ """Somme des syllabes de tous les mots de ``text``."""
139
+ if not text:
140
+ return 0
141
+ return sum(count_syllables_word(w) for w in _WORD_RE.findall(text))
142
+
143
+
144
+ # ──────────────────────────────────────────────────────────────────────────
145
+ # Score Flesch
146
+ # ──────────────────────────────────────────────────────────────────────────
147
+
148
+
149
+ def flesch_score(text: str, lang: Language = "fr") -> float:
150
+ """Calcule le score de lisibilité Flesch pour ``text``.
151
+
152
+ Parameters
153
+ ----------
154
+ text:
155
+ Texte à évaluer. Peut contenir ponctuation, accents, etc.
156
+ lang:
157
+ ``"fr"`` (Kandel-Moles 1958, défaut) ou ``"en"`` (Flesch 1948).
158
+
159
+ Returns
160
+ -------
161
+ float
162
+ Score borné dans ``[0, 100]``. Renvoie ``0.0`` sur un texte
163
+ vide ou sans mot exploitable.
164
+
165
+ Notes
166
+ -----
167
+ Le score chute fortement avec :
168
+ - longues phrases (mots/phrases élevé)
169
+ - mots polysyllabiques (syllabes/mots élevé)
170
+ Une montée du score lors du passage GT → OCR signale qu'un LLM a
171
+ « lissé » la langue (phrases plus courtes, mots plus communs).
172
+ """
173
+ if lang not in _FLESCH_COEFFS:
174
+ raise ValueError(f"Langue non supportée : {lang!r}. Choisir 'fr' ou 'en'.")
175
+
176
+ n_words = count_words(text)
177
+ if n_words == 0:
178
+ return 0.0
179
+ n_sentences = max(1, count_sentences(text))
180
+ n_syllables = count_syllables(text)
181
+ if n_syllables == 0:
182
+ return 0.0
183
+
184
+ base, k_words, k_syll = _FLESCH_COEFFS[lang]
185
+ raw = base - k_words * (n_words / n_sentences) - k_syll * (n_syllables / n_words)
186
+ return max(0.0, min(100.0, raw))
187
+
188
+
189
+ def flesch_delta(
190
+ reference: str,
191
+ hypothesis: str,
192
+ lang: Language = "fr",
193
+ ) -> float:
194
+ """Différence ``flesch_score(hypothesis) - flesch_score(reference)``.
195
+
196
+ Interprétation
197
+ --------------
198
+ - **Positif** : l'hypothèse OCR est plus lisible que la GT —
199
+ signal d'**over-normalisation** (typique des LLM qui modernisent
200
+ des textes anciens).
201
+ - **Négatif** : l'OCR est moins lisible — signal de dégradation
202
+ (caractères mal reconnus brisent la fluidité).
203
+ - **≈ 0** : OCR fidèle à la GT en termes de complexité linguistique.
204
+
205
+ Borné dans ``[-100, +100]``.
206
+ """
207
+ return flesch_score(hypothesis, lang=lang) - flesch_score(reference, lang=lang)
208
+
209
+
210
+ # ──────────────────────────────────────────────────────────────────────────
211
+ # Enregistrement dans le registre typé (Sprint 34)
212
+ # ──────────────────────────────────────────────────────────────────────────
213
+
214
+
215
+ @register_metric(
216
+ name="flesch_delta_fr",
217
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
218
+ description=(
219
+ "Différence de score Flesch (Kandel-Moles, FR) entre la sortie "
220
+ "OCR et la GT. Positif = OCR plus lisible (signal "
221
+ "d'over-normalisation LLM). Aucun alignement requis."
222
+ ),
223
+ higher_is_better=False, # un delta proche de 0 = fidélité ; positif = LLM lissant
224
+ tags={"text", "readability", "over_normalization"},
225
+ )
226
+ def _registered_flesch_delta_fr(reference: str, hypothesis: str) -> float:
227
+ return flesch_delta(reference, hypothesis, lang="fr")
228
+
229
+
230
+ @register_metric(
231
+ name="flesch_delta_en",
232
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
233
+ description=(
234
+ "Flesch reading ease delta (Flesch 1948, EN) between OCR and GT. "
235
+ "Positive = OCR easier to read than GT (LLM smoothing signal). "
236
+ "No alignment required."
237
+ ),
238
+ higher_is_better=False,
239
+ tags={"text", "readability", "over_normalization"},
240
+ )
241
+ def _registered_flesch_delta_en(reference: str, hypothesis: str) -> float:
242
+ return flesch_delta(reference, hypothesis, lang="en")
243
+
244
+
245
+ __all__ = [
246
+ "flesch_score",
247
+ "flesch_delta",
248
+ "count_words",
249
+ "count_sentences",
250
+ "count_syllables",
251
+ "count_syllables_word",
252
+ ]
picarones/evaluation/metrics/readability_hooks.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Câblage runner du delta Flesch (Sprint 87 — A.II.2).
2
+
3
+ Sprint 87 — A.II.2 (vue HTML + câblage runner du delta Flesch
4
+ livré par le Sprint 52).
5
+
6
+ Pourquoi ce module
7
+ ------------------
8
+ Le ``flesch_delta`` mesure la différence de lisibilité entre la
9
+ GT et la sortie OCR. Un score positif signale une *over-
10
+ normalisation* typique des LLM/VLM qui modernisent un texte
11
+ ancien (le Flesch monte parce que les mots sont plus simples) ;
12
+ un score négatif signale une dégradation OCR brutale.
13
+
14
+ Cette métrique est calculée **automatiquement** par le runner
15
+ sur chaque document, agrégée par moteur, et présentée dans le
16
+ rapport.
17
+
18
+ Adaptive masking
19
+ ----------------
20
+ On ne calcule que si la GT contient ≥ 5 mots — en dessous, le
21
+ Flesch est trop instable pour être informatif.
22
+
23
+ Langue
24
+ ------
25
+ Lecture depuis ``corpus.metadata.get("language", "fr")``. Pour
26
+ les corpus mixtes, l'utilisateur peut passer une langue
27
+ explicite à l'orchestrateur.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import logging
33
+ import statistics
34
+ from typing import Iterable, Optional
35
+
36
+ from picarones.evaluation.metrics.readability import (
37
+ Language,
38
+ count_words,
39
+ flesch_delta,
40
+ flesch_score,
41
+ )
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+
46
+ _MIN_WORDS_FOR_FLESCH = 5
47
+
48
+
49
+ def compute_readability_metrics(
50
+ reference: Optional[str],
51
+ hypothesis: Optional[str],
52
+ *,
53
+ lang: Language = "fr",
54
+ ) -> Optional[dict]:
55
+ """Calcule le delta Flesch d'un document avec adaptive masking.
56
+
57
+ Retourne ``None`` si la GT contient moins de
58
+ ``_MIN_WORDS_FOR_FLESCH`` mots.
59
+ """
60
+ ref = reference or ""
61
+ n_ref_words = count_words(ref)
62
+ if n_ref_words < _MIN_WORDS_FOR_FLESCH:
63
+ return None
64
+ hyp = hypothesis or ""
65
+ flesch_ref = flesch_score(ref, lang=lang)
66
+ flesch_hyp = flesch_score(hyp, lang=lang) if hyp else None
67
+ delta = (
68
+ flesch_delta(ref, hyp, lang=lang) if hyp else None
69
+ )
70
+ return {
71
+ "lang": lang,
72
+ "flesch_reference": flesch_ref,
73
+ "flesch_hypothesis": flesch_hyp,
74
+ "flesch_delta": delta,
75
+ "n_words_reference": n_ref_words,
76
+ }
77
+
78
+
79
+ def aggregate_readability_metrics(
80
+ per_doc: Iterable[Optional[dict]],
81
+ ) -> Optional[dict]:
82
+ """Agrège : moyenne/médiane des deltas + part de docs
83
+ « over-normalisés » (delta > +5 points).
84
+ """
85
+ docs = [d for d in per_doc if d]
86
+ if not docs:
87
+ return None
88
+ deltas = [
89
+ float(d["flesch_delta"]) for d in docs
90
+ if isinstance(d.get("flesch_delta"), (int, float))
91
+ ]
92
+ if not deltas:
93
+ return None
94
+ over_norm = sum(1 for d in deltas if d > 5.0)
95
+ under_norm = sum(1 for d in deltas if d < -5.0)
96
+ lang = docs[0].get("lang") or "fr"
97
+ return {
98
+ "lang": lang,
99
+ "n_docs": len(docs),
100
+ "n_docs_with_delta": len(deltas),
101
+ "delta_mean": statistics.fmean(deltas),
102
+ "delta_median": statistics.median(deltas),
103
+ "delta_min": min(deltas),
104
+ "delta_max": max(deltas),
105
+ "n_over_normalized": over_norm,
106
+ "n_under_normalized": under_norm,
107
+ "over_normalized_rate": over_norm / len(deltas),
108
+ }
109
+
110
+
111
+ __all__ = [
112
+ "compute_readability_metrics",
113
+ "aggregate_readability_metrics",
114
+ ]
picarones/evaluation/metrics/reading_order.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reading order F1 (ICDAR 2015, Antonacopoulos) — Sprint 53.
2
+
3
+ Sprint 53 — A.II.2.1 du plan d'évolution 2026.
4
+
5
+ Pourquoi ce module
6
+ ------------------
7
+ Sur un manuscrit glosé, un journal multi-colonnes ou un registre
8
+ paroissial complexe, le **classement des moteurs en CER** peut être
9
+ trompeur : un moteur peut avoir un excellent CER caractère et un
10
+ **ordre de lecture catastrophique**. Le résultat est inutilisable
11
+ pour la recherche plein texte (Elastic, Solr) ou pour reconstituer
12
+ une narration linéaire.
13
+
14
+ La métrique standard est définie par Antonacopoulos et al. dans
15
+ ICDAR 2015 — F1 sur les **paires d'ordre relatif** entre régions
16
+ ALTO/PAGE. Pour chaque paire ``(a, b)`` telle que ``a`` précède
17
+ ``b`` dans la GT :
18
+
19
+ - **TP** si ``a`` précède aussi ``b`` dans l'hypothèse,
20
+ - **FN** si la paire est manquante (régions absentes ou ordre
21
+ inversé) côté hypothèse,
22
+ - **FP** si une paire ``(a, b)`` apparaît dans l'hypothèse alors que
23
+ la GT n'a pas cet ordre (régions hallucinées ou inversion).
24
+
25
+ Le F1 est la moyenne harmonique des deux.
26
+
27
+ Stratégie de découpage
28
+ ----------------------
29
+ Cohérent avec NER (Sprint 38), calibration (Sprint 39), Flesch
30
+ (Sprint 52) : couche de calcul pure d'abord. L'utilisateur fournit
31
+ deux listes ordonnées d'IDs de régions (typiquement extraites de
32
+ ALTO/PAGE par un parser amont). Le câblage runner et la vue HTML
33
+ suivent dans des sprints dédiés.
34
+
35
+ Compatible directement avec ``ReadingOrderGT`` du Sprint 32 :
36
+ ``ReadingOrderGT.region_order`` est exactement le format attendu.
37
+
38
+ Convention sur les régions
39
+ --------------------------
40
+ - Les IDs sont des chaînes (``"r_1"``, ``"region_main"``, etc.).
41
+ - Les **doublons** sont ignorés au calcul des paires ordonnées
42
+ (chaque ID compte une fois par séquence).
43
+ - Une région présente dans la GT mais absente de l'hypothèse
44
+ contribue aux paires FN.
45
+ - Une région présente dans l'hypothèse mais absente de la GT
46
+ contribue aux paires FP.
47
+ - Si une séquence a < 2 régions distinctes, aucune paire n'est
48
+ émise — le F1 retourne ``0.0`` ou ``1.0`` selon que les deux
49
+ séquences soient identiques.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import logging
55
+ from itertools import combinations
56
+ from typing import Iterable
57
+
58
+ from picarones.evaluation.metric_registry import register_metric
59
+ from picarones.domain.artifacts import ArtifactType
60
+
61
+ logger = logging.getLogger(__name__)
62
+
63
+
64
+ # ──────────────────────────────────────────────────────────────────────────
65
+ # Helpers
66
+ # ──────────────────────────────────────────────────────────────────────────
67
+
68
+
69
+ def _ordered_pairs(sequence: list[str]) -> set[tuple[str, str]]:
70
+ """Retourne l'ensemble des paires ``(a, b)`` telles que ``a``
71
+ précède strictement ``b`` dans ``sequence``.
72
+
73
+ Doublons : chaque ID est traité une seule fois (première occurrence
74
+ dans la séquence). Cohérent avec ICDAR 2015 où les régions ont
75
+ des IDs uniques.
76
+ """
77
+ seen: list[str] = []
78
+ seen_set: set[str] = set()
79
+ for r in sequence:
80
+ if r not in seen_set:
81
+ seen.append(r)
82
+ seen_set.add(r)
83
+ return set(combinations(seen, 2))
84
+
85
+
86
+ def _normalize_input(value: Iterable[str] | None) -> list[str]:
87
+ """Coerce une entrée en list[str], en filtrant les valeurs vides."""
88
+ if value is None:
89
+ return []
90
+ return [str(v) for v in value if v is not None and str(v).strip()]
91
+
92
+
93
+ # ──────────────────────────────────────────────────────────────────────────
94
+ # Métrique principale
95
+ # ──────────────────────────────────────────────────────────────────────────
96
+
97
+
98
+ def compute_reading_order_metrics(
99
+ reference_order: Iterable[str] | None,
100
+ hypothesis_order: Iterable[str] | None,
101
+ ) -> dict:
102
+ """Calcule precision / recall / F1 sur l'ordre relatif des régions.
103
+
104
+ Parameters
105
+ ----------
106
+ reference_order:
107
+ Séquence ordonnée d'IDs de régions issue de la GT (typiquement
108
+ ``ReadingOrderGT.region_order`` du Sprint 32).
109
+ hypothesis_order:
110
+ Séquence ordonnée d'IDs de régions produite par un moteur
111
+ OCR/HTR ou un reconstructeur ALTO.
112
+
113
+ Returns
114
+ -------
115
+ dict
116
+ ``{"precision", "recall", "f1", "true_positives",
117
+ "false_positives", "false_negatives", "n_ref_pairs",
118
+ "n_hyp_pairs", "common_regions", "ref_only_regions",
119
+ "hyp_only_regions"}``.
120
+
121
+ Comportements aux bornes
122
+ ------------------------
123
+ - Deux séquences identiques (mêmes régions, même ordre) → F1 = 1.0.
124
+ - Ordre strictement inversé → F1 = 0.0 (toutes les paires
125
+ relatives sont fausses).
126
+ - Une séquence vide vs une séquence non vide → F1 = 0.0.
127
+ - Deux séquences vides → F1 = 0.0 et tous les compteurs à 0
128
+ (convention : on ne récompense pas l'absence).
129
+ """
130
+ ref = _normalize_input(reference_order)
131
+ hyp = _normalize_input(hypothesis_order)
132
+
133
+ ref_pairs = _ordered_pairs(ref)
134
+ hyp_pairs = _ordered_pairs(hyp)
135
+
136
+ tp = len(ref_pairs & hyp_pairs)
137
+ fn = len(ref_pairs - hyp_pairs)
138
+ fp = len(hyp_pairs - ref_pairs)
139
+
140
+ precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
141
+ recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
142
+ f1 = (
143
+ 2 * precision * recall / (precision + recall)
144
+ if (precision + recall) > 0
145
+ else 0.0
146
+ )
147
+
148
+ ref_set = set(ref)
149
+ hyp_set = set(hyp)
150
+ return {
151
+ "precision": precision,
152
+ "recall": recall,
153
+ "f1": f1,
154
+ "true_positives": tp,
155
+ "false_positives": fp,
156
+ "false_negatives": fn,
157
+ "n_ref_pairs": len(ref_pairs),
158
+ "n_hyp_pairs": len(hyp_pairs),
159
+ "common_regions": sorted(ref_set & hyp_set),
160
+ "ref_only_regions": sorted(ref_set - hyp_set),
161
+ "hyp_only_regions": sorted(hyp_set - ref_set),
162
+ }
163
+
164
+
165
+ # ──────────────────────────────────────────────────────────────────────────
166
+ # Enregistrement dans le registre typé (Sprint 34)
167
+ # ──────────────────────────────────────────────────────────────────────────
168
+
169
+
170
+ @register_metric(
171
+ name="reading_order_f1",
172
+ input_types=(ArtifactType.READING_ORDER, ArtifactType.READING_ORDER),
173
+ description=(
174
+ "F1 sur l'ordre relatif des régions ALTO/PAGE (ICDAR 2015, "
175
+ "Antonacopoulos). Pour chaque paire (a,b) où a précède b dans "
176
+ "la GT, vérifie que a précède aussi b dans l'hypothèse."
177
+ ),
178
+ higher_is_better=True,
179
+ tags={"structure", "icdar", "alto", "page"},
180
+ )
181
+ def reading_order_f1(
182
+ reference: Iterable[str] | None,
183
+ hypothesis: Iterable[str] | None,
184
+ ) -> float:
185
+ """Raccourci : retourne uniquement le F1 global.
186
+
187
+ Pour les détails par paire (TP/FP/FN, régions communes, etc.),
188
+ appeler ``compute_reading_order_metrics`` directement.
189
+ """
190
+ return compute_reading_order_metrics(reference, hypothesis)["f1"]
191
+
192
+
193
+ __all__ = [
194
+ "compute_reading_order_metrics",
195
+ "reading_order_f1",
196
+ ]
picarones/evaluation/metrics/searchability.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Recherchabilité fuzzy — Sprint 84 (A.II.5).
2
+
3
+ Sprint 84 — A.II.5 du plan d'évolution 2026.
4
+
5
+ Pourquoi ce module
6
+ ------------------
7
+ Le CER mesure les erreurs caractère par caractère. Mais pour
8
+ un usage *recherche plein-texte* (ce que font Elastic, Solr en
9
+ mode fuzzy, ou la recherche full-text de Gallica), la question
10
+ réelle est :
11
+
12
+ *« Combien de mots de ma GT sont retrouvables dans la
13
+ sortie OCR, à orthographe approchée près ? »*
14
+
15
+ Un CER de 8 % peut donner 95 % de findability si les erreurs
16
+ sont concentrées sur des caractères non-significatifs ou sur
17
+ quelques mots aberrants ; à l'inverse, 4 % de CER mais
18
+ distribué sur tous les noms propres rend le corpus inutilisable
19
+ pour l'indexation prosopographique.
20
+
21
+ Méthode
22
+ -------
23
+ Pour chaque token GT, on regarde s'il existe au moins un token
24
+ hypothèse à distance de Levenshtein ≤ ``max_distance`` (défaut
25
+ 2, valeur Elastic ``fuzziness: AUTO`` standard pour mots ≥ 5
26
+ caractères). Le **rappel** est la proportion de tokens GT
27
+ ainsi retrouvés.
28
+
29
+ Multiplicité
30
+ ------------
31
+ Si la GT contient *« le »* deux fois et l'hypothèse une fois,
32
+ seul un token GT est compté comme retrouvé (alignement
33
+ multi-set, comme ``rare_token_recall`` Sprint 71).
34
+
35
+ Sortie
36
+ ------
37
+ ``compute_searchability(reference, hypothesis)`` retourne
38
+ ``{n_gt_tokens, n_searchable, recall, missed_tokens}``.
39
+
40
+ Limites documentées
41
+ -------------------
42
+ - Tokenisation par split sur whitespace (cohérent avec le reste
43
+ du codebase). Pas de stemming ni de lemmatisation.
44
+ - Levenshtein non pondéré — substitution = insertion = suppression
45
+ = 1. Pour un poids différent (par ex. faute classique
46
+ diacritique = 0,5), passer une fonction custom.
47
+ - Pas de sémantique : *« roi »* ≠ *« souverain »*. Pour la
48
+ similarité sémantique, voir des modules futurs (BERTScore).
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ import logging
54
+ from typing import Optional
55
+
56
+ from picarones.evaluation.metric_registry import register_metric
57
+ from picarones.domain.artifacts import ArtifactType
58
+
59
+ logger = logging.getLogger(__name__)
60
+
61
+
62
+ # ──────────────────────────────────────────────────────────────────────────
63
+ # Tokenisation et distance d'édition
64
+ # ──────────────────────────────────────────────────────────────────────────
65
+
66
+
67
+ def _split_words(text: Optional[str]) -> list[str]:
68
+ """Tokenisation par whitespace — cohérent avec
69
+ ``lexical_modernization.py``, ``rare_tokens.py``, etc."""
70
+ if not text:
71
+ return []
72
+ return text.split()
73
+
74
+
75
+ def levenshtein_distance(a: str, b: str) -> int:
76
+ """Distance de Levenshtein (substitution=insertion=suppression=1).
77
+
78
+ Implémentation DP O(|a|·|b|) en mémoire O(min(|a|,|b|)).
79
+ """
80
+ if a == b:
81
+ return 0
82
+ if len(a) < len(b):
83
+ a, b = b, a
84
+ # |a| ≥ |b|
85
+ if not b:
86
+ return len(a)
87
+ previous = list(range(len(b) + 1))
88
+ for i, ca in enumerate(a, start=1):
89
+ current = [i] + [0] * len(b)
90
+ for j, cb in enumerate(b, start=1):
91
+ cost = 0 if ca == cb else 1
92
+ current[j] = min(
93
+ current[j - 1] + 1, # insertion
94
+ previous[j] + 1, # suppression
95
+ previous[j - 1] + cost, # substitution
96
+ )
97
+ previous = current
98
+ return previous[-1]
99
+
100
+
101
+ # ──────────────────────────────────────────────────────────────────────────
102
+ # Calcul principal
103
+ # ──────────────────────────────────────────────────────────────────────────
104
+
105
+
106
+ def compute_searchability(
107
+ reference: Optional[str],
108
+ hypothesis: Optional[str],
109
+ *,
110
+ max_distance: int = 2,
111
+ case_sensitive: bool = False,
112
+ ) -> dict:
113
+ """Recherchabilité fuzzy de ``reference`` dans ``hypothesis``.
114
+
115
+ Parameters
116
+ ----------
117
+ reference, hypothesis:
118
+ Transcriptions GT et OCR.
119
+ max_distance:
120
+ Seuil de distance de Levenshtein (≤ pour considérer un
121
+ token comme retrouvé). Défaut 2 — convention
122
+ ``fuzziness: AUTO`` d'Elastic pour mots ≥ 5 caractères.
123
+ case_sensitive:
124
+ Si False (défaut), casse insensible côté match — la
125
+ sortie ``missed_tokens`` reste avec la casse GT
126
+ originale.
127
+
128
+ Returns
129
+ -------
130
+ dict
131
+ ``{
132
+ "n_gt_tokens": int,
133
+ "n_searchable": int,
134
+ "recall": float | None, # None si n_gt_tokens == 0
135
+ "missed_tokens": list[str],
136
+ "max_distance": int,
137
+ }``
138
+ """
139
+ if max_distance < 0:
140
+ raise ValueError(f"max_distance doit être ≥ 0, reçu {max_distance}")
141
+ gt_tokens = _split_words(reference)
142
+ hyp_tokens = _split_words(hypothesis)
143
+ n_gt = len(gt_tokens)
144
+ if n_gt == 0:
145
+ return {
146
+ "n_gt_tokens": 0,
147
+ "n_searchable": 0,
148
+ "recall": None,
149
+ "missed_tokens": [],
150
+ "max_distance": max_distance,
151
+ }
152
+ # Multi-set : un token hypothèse ne peut servir qu'une fois.
153
+ # Tri par longueur croissante pour matcher d'abord les
154
+ # tokens GT les plus courts (où ε-fautes sont plus rares).
155
+ if case_sensitive:
156
+ gt_for_match = list(gt_tokens)
157
+ hyp_for_match = list(hyp_tokens)
158
+ else:
159
+ gt_for_match = [t.lower() for t in gt_tokens]
160
+ hyp_for_match = [t.lower() for t in hyp_tokens]
161
+
162
+ hyp_used = [False] * len(hyp_for_match)
163
+ n_searchable = 0
164
+ missed: list[str] = []
165
+ for gi, gt_match in enumerate(gt_for_match):
166
+ # Court-circuit si match exact disponible
167
+ best_idx = -1
168
+ best_dist = max_distance + 1
169
+ for hi, used in enumerate(hyp_used):
170
+ if used:
171
+ continue
172
+ hyp_match = hyp_for_match[hi]
173
+ # Court-circuit longueur (Levenshtein ≥ |Δlen|)
174
+ if abs(len(hyp_match) - len(gt_match)) > max_distance:
175
+ continue
176
+ d = levenshtein_distance(gt_match, hyp_match)
177
+ if d < best_dist:
178
+ best_dist = d
179
+ best_idx = hi
180
+ if d == 0:
181
+ break # match exact, inutile de chercher mieux
182
+ if best_idx >= 0 and best_dist <= max_distance:
183
+ hyp_used[best_idx] = True
184
+ n_searchable += 1
185
+ else:
186
+ missed.append(gt_tokens[gi])
187
+ recall = n_searchable / n_gt
188
+ return {
189
+ "n_gt_tokens": n_gt,
190
+ "n_searchable": n_searchable,
191
+ "recall": recall,
192
+ "missed_tokens": missed,
193
+ "max_distance": max_distance,
194
+ }
195
+
196
+
197
+ # ──────────────────────────────────────────────────────────────────────────
198
+ # Enregistrement registre typé (Sprint 34)
199
+ # ──────────────────────────────────────────────────────────────────────────
200
+
201
+
202
+ @register_metric(
203
+ name="searchability_recall",
204
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
205
+ description=(
206
+ "Recherchabilité fuzzy : proportion de tokens GT retrouvés "
207
+ "dans l'OCR à distance de Levenshtein ≤ 2. Proxy direct de "
208
+ "la qualité pour la recherche plein-texte (Elastic, Solr)."
209
+ ),
210
+ )
211
+ def searchability_recall_metric(reference: str, hypothesis: str) -> float:
212
+ """Variante scalaire pour le registre typé : retourne le
213
+ rappel en [0, 1], ou ``0.0`` si la GT est vide (convention
214
+ cohérente avec rare_token_recall Sprint 71).
215
+ """
216
+ result = compute_searchability(reference, hypothesis)
217
+ recall = result.get("recall")
218
+ return 0.0 if recall is None else recall
219
+
220
+
221
+ __all__ = [
222
+ "levenshtein_distance",
223
+ "compute_searchability",
224
+ "searchability_recall_metric",
225
+ ]
picarones/evaluation/metrics/searchability_hooks.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Câblage runner de la recherchabilité (Sprint 86).
2
+
3
+ Sprint 86 — A.II.5a (vue HTML + câblage runner).
4
+
5
+ Le module ``picarones/core/searchability.py`` (Sprint 84) a livré
6
+ la couche de calcul. Ce helper prépare la donnée pour le runner
7
+ historique et l'agrégation par moteur.
8
+
9
+ Adaptive masking
10
+ ----------------
11
+ Comme pour les modules philologiques (Sprint 61), on ne calcule
12
+ le rappel que si la GT contient au moins un token — pas de
13
+ calcul vide qui produirait du bruit dans le rapport.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ from typing import Iterable, Optional
20
+
21
+ from picarones.evaluation.metrics.searchability import (
22
+ _split_words,
23
+ compute_searchability,
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def compute_searchability_metrics(
30
+ reference: Optional[str],
31
+ hypothesis: Optional[str],
32
+ *,
33
+ max_distance: int = 2,
34
+ ) -> Optional[dict]:
35
+ """Recherchabilité d'un document (adaptive).
36
+
37
+ Retourne ``None`` si la GT est vide ou ne contient aucun
38
+ token — ce qui déclenche l'adaptive masking côté HTML.
39
+ """
40
+ if not reference or not _split_words(reference):
41
+ return None
42
+ return compute_searchability(
43
+ reference, hypothesis or "", max_distance=max_distance,
44
+ )
45
+
46
+
47
+ def aggregate_searchability_metrics(
48
+ per_doc: Iterable[Optional[dict]],
49
+ ) -> Optional[dict]:
50
+ """Agrège les métriques par-doc en un score corpus-wide.
51
+
52
+ Convention : on somme les ``n_gt_tokens`` et ``n_searchable``
53
+ et on recalcule un rappel **micro** (cohérent avec ECE/MCE
54
+ Sprint 39 et NER Sprint 38).
55
+ """
56
+ docs = [d for d in per_doc if d]
57
+ if not docs:
58
+ return None
59
+ n_gt = sum(int(d.get("n_gt_tokens") or 0) for d in docs)
60
+ n_search = sum(int(d.get("n_searchable") or 0) for d in docs)
61
+ if n_gt == 0:
62
+ return None
63
+ # On garde l'union des missed_tokens (capped pour ne pas
64
+ # exploser le JSON sur de gros corpus)
65
+ missed: list[str] = []
66
+ for d in docs:
67
+ missed.extend(d.get("missed_tokens") or [])
68
+ return {
69
+ "n_docs": len(docs),
70
+ "n_gt_tokens": n_gt,
71
+ "n_searchable": n_search,
72
+ "recall": n_search / n_gt,
73
+ "missed_tokens_sample": missed[:50],
74
+ "max_distance": docs[0].get("max_distance", 2),
75
+ }
76
+
77
+
78
+ __all__ = [
79
+ "compute_searchability_metrics",
80
+ "aggregate_searchability_metrics",
81
+ ]
picarones/evaluation/metrics/unicode_blocks.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Précision par bloc Unicode — Sprint 55.
2
+
3
+ Sprint 55 — A.II.3.1 du plan d'évolution 2026 (métriques philologiques).
4
+
5
+ Pourquoi ce module
6
+ ------------------
7
+ Pour un éditeur d'imprimés anciens ou un médiéviste, la question
8
+ n'est pas seulement *« quel CER global ? »* mais *« quels caractères
9
+ historiques ce moteur restitue-t-il fidèlement ? »*. Une phrase de
10
+ synthèse actionnable en un coup d'œil :
11
+
12
+ > *« GPT-4o restitue 95 % du Latin de Base mais seulement 12 % des
13
+ > formes de présentation latine (fi, fl, ſ…). »*
14
+
15
+ Ce module agrège la précision par **bloc Unicode standard** (Latin de
16
+ Base, Latin Étendu A/B, Diacritiques combinants, Présentation latine,
17
+ etc.). Le résultat permet directement de choisir un moteur selon le
18
+ type de glyphes attendus dans le corpus.
19
+
20
+ Stratégie de découpage
21
+ ----------------------
22
+ Cohérente avec NER (Sprint 38), Flesch (Sprint 52), Reading order F1
23
+ (Sprint 53), Layout F1 (Sprint 54) : couche de calcul pure d'abord.
24
+ Le câblage runner et la vue HTML suivent dans des sprints dédiés.
25
+
26
+ Convention d'alignement
27
+ -----------------------
28
+ Alignement caractère par caractère via ``difflib.SequenceMatcher`` :
29
+
30
+ - chaque caractère de la GT est classé dans son bloc Unicode,
31
+ - pour chaque position GT couverte par un opcode ``equal`` →
32
+ +1 dans ``correct[bloc]``,
33
+ - pour chaque position GT non couverte (replace, delete) → +0,
34
+ - les insertions côté hypothèse (caractères absents de la GT) ne
35
+ contribuent à aucun bloc — elles sont visibles uniquement via le
36
+ CER global.
37
+
38
+ Précision par bloc = ``correct[bloc] / total[bloc]``.
39
+
40
+ Liste des blocs reconnus
41
+ ------------------------
42
+ Centrée sur les glyphes courants des corpus patrimoniaux européens.
43
+ Tout caractère hors de cette table est classé dans ``"Other"``
44
+ (garantit une couverture exhaustive : ``sum(total[bloc]) ==
45
+ len(GT)``).
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import logging
51
+ from difflib import SequenceMatcher
52
+ from typing import Optional
53
+
54
+ from picarones.evaluation.metric_registry import register_metric
55
+ from picarones.domain.artifacts import ArtifactType
56
+
57
+ logger = logging.getLogger(__name__)
58
+
59
+
60
+ # ──────────────────────────────────────────────────────────────────────────
61
+ # Table des blocs Unicode reconnus
62
+ # ──────────────────────────────────────────────────────────────────────────
63
+
64
+ # Triplets (nom, code_point_min, code_point_max) — bornes inclusives.
65
+ # Centré sur les blocs pertinents pour les corpus patrimoniaux
66
+ # européens (manuscrits médiévaux, imprimés anciens, archives).
67
+ # Source : https://www.unicode.org/charts/
68
+ _UNICODE_BLOCKS: tuple[tuple[str, int, int], ...] = (
69
+ ("Basic Latin", 0x0000, 0x007F),
70
+ ("Latin-1 Supplement", 0x0080, 0x00FF),
71
+ ("Latin Extended-A", 0x0100, 0x017F),
72
+ ("Latin Extended-B", 0x0180, 0x024F),
73
+ ("IPA Extensions", 0x0250, 0x02AF),
74
+ ("Spacing Modifier Letters", 0x02B0, 0x02FF),
75
+ ("Combining Diacritical Marks", 0x0300, 0x036F),
76
+ ("Greek and Coptic", 0x0370, 0x03FF),
77
+ ("Cyrillic", 0x0400, 0x04FF),
78
+ ("Hebrew", 0x0590, 0x05FF),
79
+ ("Arabic", 0x0600, 0x06FF),
80
+ ("General Punctuation", 0x2000, 0x206F),
81
+ ("Superscripts and Subscripts", 0x2070, 0x209F),
82
+ ("Currency Symbols", 0x20A0, 0x20CF),
83
+ ("Combining Diacritical Marks Supplement", 0x1DC0, 0x1DFF),
84
+ ("Latin Extended Additional", 0x1E00, 0x1EFF),
85
+ ("Latin Extended-C", 0x2C60, 0x2C7F),
86
+ ("Latin Extended-D", 0xA720, 0xA7FF), # médiéval
87
+ ("Latin Extended-E", 0xAB30, 0xAB6F),
88
+ ("Alphabetic Presentation Forms", 0xFB00, 0xFB4F), # fi, fl, ff…
89
+ ("Mathematical Alphanumeric Symbols", 0x1D400, 0x1D7FF),
90
+ ("Medieval Unicode Font Initiative (MUFI)", 0xE000, 0xF8FF), # PUA
91
+ )
92
+
93
+
94
+ def get_block(char: str) -> str:
95
+ """Retourne le nom du bloc Unicode contenant ``char``.
96
+
97
+ Pour un caractère hors des blocs listés (ex. CJK, emoji, etc.),
98
+ retourne ``"Other"``. Pour une chaîne multi-caractères, on
99
+ considère uniquement le premier code-point.
100
+ """
101
+ if not char:
102
+ return "Other"
103
+ cp = ord(char[0])
104
+ for name, lo, hi in _UNICODE_BLOCKS:
105
+ if lo <= cp <= hi:
106
+ return name
107
+ return "Other"
108
+
109
+
110
+ # ──────────────────────────────────────────────────────────────────────────
111
+ # Calcul d'accuracy par bloc
112
+ # ──────────────────────────────────────────────────────────────────────────
113
+
114
+
115
+ def compute_unicode_block_accuracy(
116
+ reference: Optional[str],
117
+ hypothesis: Optional[str],
118
+ ) -> dict:
119
+ """Calcule la précision (recall caractère) par bloc Unicode.
120
+
121
+ Parameters
122
+ ----------
123
+ reference:
124
+ Texte GT. Chaque caractère est classé dans son bloc Unicode.
125
+ hypothesis:
126
+ Texte produit par le moteur OCR.
127
+
128
+ Returns
129
+ -------
130
+ dict
131
+ ``{
132
+ "per_block": {
133
+ bloc_name: {
134
+ "correct": int, # caractères GT correctement restitués
135
+ "total": int, # caractères GT du bloc
136
+ "accuracy": float, # correct / total ∈ [0, 1]
137
+ },
138
+ ...
139
+ },
140
+ "global_accuracy": float, # somme(correct) / somme(total)
141
+ "n_chars_reference": int,
142
+ }``
143
+
144
+ Cas dégénérés
145
+ -------------
146
+ - GT vide → ``per_block`` vide, ``global_accuracy = 0.0``,
147
+ ``n_chars_reference = 0``.
148
+ - hypothèse vide + GT non-vide → tous les blocs à
149
+ ``accuracy = 0``.
150
+ - GT et hyp identiques → tous les blocs à ``accuracy = 1``.
151
+ """
152
+ ref = reference or ""
153
+ hyp = hypothesis or ""
154
+ n_ref = len(ref)
155
+
156
+ if n_ref == 0:
157
+ return {
158
+ "per_block": {},
159
+ "global_accuracy": 0.0,
160
+ "n_chars_reference": 0,
161
+ }
162
+
163
+ # 1. Compter le total par bloc
164
+ total: dict[str, int] = {}
165
+ for ch in ref:
166
+ b = get_block(ch)
167
+ total[b] = total.get(b, 0) + 1
168
+
169
+ # 2. Aligner par opcodes de SequenceMatcher
170
+ # Pour chaque opcode ``equal``, les positions ``i1..i2-1`` du GT
171
+ # sont correctement restituées → +1 par caractère dans son bloc.
172
+ correct: dict[str, int] = {b: 0 for b in total}
173
+ matcher = SequenceMatcher(a=ref, b=hyp, autojunk=False)
174
+ for op, i1, i2, _j1, _j2 in matcher.get_opcodes():
175
+ if op != "equal":
176
+ continue
177
+ for i in range(i1, i2):
178
+ b = get_block(ref[i])
179
+ correct[b] = correct.get(b, 0) + 1
180
+
181
+ per_block: dict[str, dict] = {}
182
+ for b in sorted(total):
183
+ n = total[b]
184
+ c = correct.get(b, 0)
185
+ per_block[b] = {
186
+ "correct": c,
187
+ "total": n,
188
+ "accuracy": c / n if n > 0 else 0.0,
189
+ }
190
+
191
+ n_correct_total = sum(d["correct"] for d in per_block.values())
192
+ return {
193
+ "per_block": per_block,
194
+ "global_accuracy": n_correct_total / n_ref,
195
+ "n_chars_reference": n_ref,
196
+ }
197
+
198
+
199
+ def unicode_block_global_accuracy(
200
+ reference: Optional[str],
201
+ hypothesis: Optional[str],
202
+ ) -> float:
203
+ """Raccourci : retourne ``global_accuracy`` (fraction de
204
+ caractères GT correctement restitués)."""
205
+ return compute_unicode_block_accuracy(reference, hypothesis)["global_accuracy"]
206
+
207
+
208
+ # ──────────────────────────────────────────────────────────────────────────
209
+ # Enregistrement dans le registre typé (Sprint 34)
210
+ # ──────────────────────────────────────────────────────────────────────────
211
+
212
+
213
+ @register_metric(
214
+ name="unicode_block_global_accuracy",
215
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
216
+ description=(
217
+ "Fraction de caractères GT correctement restitués par "
218
+ "l'OCR (alignement caractère par caractère via difflib). "
219
+ "Pour le détail par bloc Unicode (Latin de Base, Présentation "
220
+ "latine, etc.), utiliser compute_unicode_block_accuracy."
221
+ ),
222
+ higher_is_better=True,
223
+ tags={"text", "unicode", "philology"},
224
+ )
225
+ def _registered_global_accuracy(reference: str, hypothesis: str) -> float:
226
+ return unicode_block_global_accuracy(reference, hypothesis)
227
+
228
+
229
+ __all__ = [
230
+ "get_block",
231
+ "compute_unicode_block_accuracy",
232
+ "unicode_block_global_accuracy",
233
+ ]
picarones/measurements/alto_metrics.py CHANGED
@@ -1,243 +1,21 @@
1
- """Métriques typées ``(ALTO, ALTO)`` Chantier 1.
2
 
3
- Pourquoi ce module
4
- ------------------
5
- Le registre typé du Sprint 34 prévoit une signature ``(input_type,
6
- output_type)`` pour chaque métrique. ``builtin_metrics.py`` enregistre
7
- les quatre métriques scalaires sur ``(TEXT, TEXT)`` et un stub sur
8
- ``(TEXT, ALTO)``. Aucune métrique n'était enregistrée sur la jonction
9
- ``(ALTO, ALTO)`` — pourtant indispensable dès qu'une pipeline produit
10
- un ALTO et qu'une GT ALTO est disponible (Sprint 32).
11
-
12
- Ce module comble cette lacune. Il expose un helper
13
- :func:`extract_text_from_alto` qui parse l'ALTO XML et reconstruit le
14
- texte plat dans l'ordre ``Page → TextBlock → TextLine → String``, et
15
- enregistre quatre métriques natives (``alto_text_cer``,
16
- ``alto_text_wer``, ``alto_text_mer``, ``alto_text_wil``) qui appliquent
17
- les opérateurs jiwer historiques sur le texte extrait des deux côtés.
18
-
19
- L'approche est strictement additive vis-à-vis de
20
- :mod:`picarones.measurements.metrics` : ce module ne touche pas le chemin de
21
- calcul historique (``compute_metrics``), il enrichit uniquement le
22
- registre typé pour les pipelines composées.
23
-
24
- Robustesse
25
- ----------
26
- - L'ALTO peut être passé sous forme :
27
- * ``str`` (XML brut),
28
- * :class:`picarones.evaluation.corpus.AltoGT` (porteur d'un ``xml_content``),
29
- * tout objet exposant un attribut ``xml_content`` typé.
30
- - Le parser tolère les ALTO sans namespace, ALTO 2.x, ALTO 3.x, ALTO
31
- 4.x — il cherche les balises locales par leur nom court (``Page``,
32
- ``TextLine``, ``String``).
33
- - Un ALTO illisible ou vide → texte extrait ``""``. Le calcul de CER
34
- reste possible (la couche jiwer sait gérer une référence non vide
35
- vs hypothèse vide).
36
- - Aucune dépendance externe : utilise ``xml.etree.ElementTree`` du
37
- stdlib.
38
-
39
- Cas typique d'usage
40
- -------------------
41
- Un VLM produit un ALTO via un reconstructeur (par exemple
42
- :class:`picarones.modules.TextToAltoMonoRegion`). La GT
43
- :class:`picarones.evaluation.corpus.AltoGT` du document est confrontée à la
44
- sortie via :func:`picarones.evaluation.metric_registry.compute_at_junction`,
45
- qui sélectionne automatiquement les métriques ``(ALTO, ALTO)``
46
- ci-dessous.
47
  """
48
 
49
  from __future__ import annotations
50
 
51
- import logging
52
- import re
53
- from typing import Any
54
-
55
- from picarones.formats._xml_utils import safe_parse_xml
56
-
57
- from picarones.evaluation.metric_registry import register_metric
58
- from picarones.domain.artifacts import ArtifactType
59
-
60
- logger = logging.getLogger(__name__)
61
-
62
-
63
- try:
64
- import jiwer
65
- _JIWER_AVAILABLE = True
66
- except ImportError:
67
- _JIWER_AVAILABLE = False
68
-
69
-
70
- _LOCAL_NAME_RE = re.compile(r"\{[^}]*\}")
71
-
72
-
73
- def _local(tag: str) -> str:
74
- """Retire le préfixe de namespace XML pour ne garder que le nom local.
75
-
76
- ElementTree expose les tags sous la forme ``{namespace}LocalName``
77
- quand un namespace est déclaré. On normalise pour pouvoir
78
- matcher uniformément les ALTO avec ou sans namespace.
79
- """
80
- return _LOCAL_NAME_RE.sub("", tag)
81
-
82
-
83
- def _coerce_alto_to_str(payload: Any) -> str:
84
- """Accepte plusieurs formes d'ALTO et retourne le XML brut."""
85
- if payload is None:
86
- return ""
87
- if isinstance(payload, str):
88
- return payload
89
- xml_content = getattr(payload, "xml_content", None)
90
- if isinstance(xml_content, str):
91
- return xml_content
92
- # Dernier recours — l'utilisateur a passé un objet avec str()
93
- # raisonnable (tests, mocks). On ne lève pas, on retourne ""
94
- # pour ne pas faire échouer une jonction sur un input bizarre.
95
- return ""
96
-
97
-
98
- def extract_text_from_alto(payload: Any) -> str:
99
- """Extrait le texte plat d'un ALTO XML.
100
-
101
- L'ordre suivi reproduit la lecture naturelle ALTO :
102
- ``Page → PrintSpace → TextBlock → TextLine → String``, avec
103
- insertion d'un espace entre les ``String`` d'une même ligne et
104
- d'un saut de ligne entre lignes. Les ``SP`` (espaces explicites)
105
- sont implicites — on n'en a pas besoin si on met un espace entre
106
- chaque ``String``.
107
 
108
- Parameters
109
- ----------
110
- payload:
111
- ALTO sous forme ``str``, :class:`AltoGT`, ou tout objet
112
- exposant ``xml_content``.
113
-
114
- Returns
115
- -------
116
- str
117
- Texte reconstruit, ``""`` si l'ALTO est invalide ou vide.
118
-
119
- Notes
120
- -----
121
- Cette fonction est délibérément tolérante : un ALTO partiellement
122
- valide produit le texte qu'il a pu extraire avant l'erreur de
123
- parsing. Cela évite de faire échouer une jonction parce que la
124
- GT a un défaut mineur (encodage, déclaration manquante).
125
- """
126
- xml = _coerce_alto_to_str(payload).strip()
127
- if not xml:
128
- return ""
129
- # ``safe_parse_xml`` neutralise XXE / Billion Laughs / DTD
130
- # retrieval — l'ALTO peut venir d'un module ``BaseModule`` tiers
131
- # qui n'a pas de garantie de provenance.
132
- root = safe_parse_xml(xml.encode("utf-8") if isinstance(xml, str) else xml)
133
- if root is None:
134
- logger.warning(
135
- "[alto_metrics] ALTO non parsable (XML invalide ou défense XXE "
136
- "déclenchée) — texte extrait vide",
137
- )
138
- return ""
139
-
140
- lines_text: list[str] = []
141
- # Itère sur tous les TextLine, peu importe leur profondeur.
142
- for line in root.iter():
143
- if _local(line.tag) != "TextLine":
144
- continue
145
- words: list[str] = []
146
- for s in line.iter():
147
- if _local(s.tag) != "String":
148
- continue
149
- content = s.attrib.get("CONTENT", "")
150
- if content:
151
- words.append(content)
152
- lines_text.append(" ".join(words))
153
- return "\n".join(lines_text).strip()
154
-
155
-
156
- def _safe_jiwer_call(fn, reference: str, hypothesis: str) -> float:
157
- if not _JIWER_AVAILABLE:
158
- raise RuntimeError(
159
- "jiwer n'est pas installé — installer avec `pip install jiwer`"
160
- )
161
- if not reference:
162
- return 0.0 if not hypothesis else 1.0
163
- if not hypothesis:
164
- return 1.0
165
- return fn(reference, hypothesis)
166
-
167
-
168
- # ──────────────────────────────────────────────────────────────────────────
169
- # Métriques (ALTO, ALTO) — opèrent sur le texte extrait de chaque ALTO
170
- # ──────────────────────────────────────────────────────────────────────────
171
-
172
-
173
- @register_metric(
174
- name="alto_text_cer",
175
- input_types=(ArtifactType.ALTO, ArtifactType.ALTO),
176
- description=(
177
- "CER calculé sur le texte plat extrait des ALTO (référence vs "
178
- "hypothèse). Permet de mesurer la qualité d'un reconstructeur "
179
- "ALTO sur l'axe textuel, indépendamment du layout."
180
- ),
181
- higher_is_better=False,
182
- tags={"alto", "text", "edit_distance"},
183
  )
184
- def alto_text_cer(reference_alto: Any, hypothesis_alto: Any) -> float:
185
- return _safe_jiwer_call(
186
- jiwer.cer,
187
- extract_text_from_alto(reference_alto),
188
- extract_text_from_alto(hypothesis_alto),
189
- )
190
-
191
-
192
- @register_metric(
193
- name="alto_text_wer",
194
- input_types=(ArtifactType.ALTO, ArtifactType.ALTO),
195
- description="WER calculé sur le texte plat extrait des ALTO.",
196
- higher_is_better=False,
197
- tags={"alto", "text", "edit_distance"},
198
- )
199
- def alto_text_wer(reference_alto: Any, hypothesis_alto: Any) -> float:
200
- return _safe_jiwer_call(
201
- jiwer.wer,
202
- extract_text_from_alto(reference_alto),
203
- extract_text_from_alto(hypothesis_alto),
204
- )
205
-
206
-
207
- @register_metric(
208
- name="alto_text_mer",
209
- input_types=(ArtifactType.ALTO, ArtifactType.ALTO),
210
- description="MER calculé sur le texte plat extrait des ALTO.",
211
- higher_is_better=False,
212
- tags={"alto", "text"},
213
- )
214
- def alto_text_mer(reference_alto: Any, hypothesis_alto: Any) -> float:
215
- return _safe_jiwer_call(
216
- jiwer.mer,
217
- extract_text_from_alto(reference_alto),
218
- extract_text_from_alto(hypothesis_alto),
219
- )
220
-
221
-
222
- @register_metric(
223
- name="alto_text_wil",
224
- input_types=(ArtifactType.ALTO, ArtifactType.ALTO),
225
- description="WIL calculé sur le texte plat extrait des ALTO.",
226
- higher_is_better=False,
227
- tags={"alto", "text"},
228
- )
229
- def alto_text_wil(reference_alto: Any, hypothesis_alto: Any) -> float:
230
- return _safe_jiwer_call(
231
- jiwer.wil,
232
- extract_text_from_alto(reference_alto),
233
- extract_text_from_alto(hypothesis_alto),
234
- )
235
-
236
 
237
- __all__ = [
238
- "extract_text_from_alto",
239
- "alto_text_cer",
240
- "alto_text_wer",
241
- "alto_text_mer",
242
- "alto_text_wil",
243
- ]
 
1
+ """Shim de compatibilitémétrique relocalisée.
2
 
3
+ Sprint E.2 du plan v2.0 (mai 2026) — module migré depuis
4
+ ``picarones.measurements.alto_metrics`` vers
5
+ ``picarones.evaluation.metrics.alto_metrics`` (couche canonique).
6
+ Ce shim re-exporte l'API publique avec un ``DeprecationWarning``
7
+ et sera supprimé en 2.0.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  """
9
 
10
  from __future__ import annotations
11
 
12
+ import warnings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
+ warnings.warn(
15
+ "picarones.measurements.alto_metrics est obsolète et sera supprimé en 2.0. "
16
+ "Utiliser picarones.evaluation.metrics.alto_metrics à la place.",
17
+ DeprecationWarning,
18
+ stacklevel=2,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
+ from picarones.evaluation.metrics.alto_metrics import * # noqa: F401, F403, E402
 
 
 
 
 
 
picarones/measurements/equivalence_profile.py CHANGED
@@ -1,199 +1,21 @@
1
- """Équivalences diplomatiques granulairesSprint 78 (A.I.5).
2
 
3
- Sprint 78 — A.I.5 du plan d'évolution 2026.
4
-
5
- Pourquoi ce module
6
- ------------------
7
- Aujourd'hui les profils de ``picarones/core/normalization.py``
8
- (``medieval_french``, ``early_modern_french``, etc.) appliquent un
9
- **bloc entier** de transformations. Mais un éditeur peut vouloir
10
- nuancer : *« je tolère ``ſ → s`` mais pas ``u → v`` »* — par
11
- exemple parce qu'il édite un imprimé du XVIᵉ où u/v sont
12
- distinctes mais où le s long doit être normalisé.
13
-
14
- Ce module **éclate** chaque profil en règles d'équivalence
15
- **nommées et indépendantes** que l'utilisateur peut activer ou
16
- désactiver une par une. La couche de calcul retourne le CER
17
- recalculé avec un sous-ensemble personnalisé.
18
-
19
- Format
20
- ------
21
- Chaque règle a :
22
-
23
- - ``name`` : identifiant stable utilisé dans les URLs et l'UX
24
- (ex. ``"longs_s"``, ``"u_eq_v"``)
25
- - ``source`` : caractère ou séquence à remplacer
26
- - ``target`` : caractère ou séquence cible
27
- - ``description`` : phrase courte FR destinée à l'utilisateur
28
- - ``profile_tag`` : nom du profil dont elle est issue (utile pour
29
- grouper dans l'UX)
30
-
31
- Stratégie de découpage
32
- ----------------------
33
- Couche de calcul d'abord (pattern Sprint 71/75/76). L'UX panneau
34
- avancé (cases à cocher + recalcul JS client + URL state) suivra
35
- dans un sprint dédié — la couche calcul livrée ici est une
36
- fondation suffisante pour qu'un développeur frontend câble la vue.
37
  """
38
 
39
  from __future__ import annotations
40
 
41
- import logging
42
- from dataclasses import dataclass
43
- from typing import Iterable, Optional
44
 
45
- from picarones.evaluation.metrics.normalization import (
46
- DIPLOMATIC_EN_EARLY_MODERN,
47
- DIPLOMATIC_FR_EARLY_MODERN,
48
- DIPLOMATIC_LATIN_MEDIEVAL,
49
- DIPLOMATIC_MINIMAL,
50
  )
51
 
52
- logger = logging.getLogger(__name__)
53
-
54
-
55
- @dataclass(frozen=True)
56
- class EquivalenceRule:
57
- """Une équivalence diplomatique nommée et indépendante."""
58
- name: str
59
- source: str
60
- target: str
61
- description: str
62
- profile_tag: str
63
-
64
-
65
- # Catalogue : on dérive des profils existants en attribuant un nom
66
- # stable à chaque transformation. Les doublons (ex. ``ſ → s``
67
- # présent dans plusieurs profils) sont fusionnés sous un nom unique
68
- # (le premier rencontré).
69
- def _build_catalog() -> dict[str, EquivalenceRule]:
70
- catalog: dict[str, EquivalenceRule] = {}
71
-
72
- # Noms canoniques pour les transformations courantes
73
- canonical_names: dict[tuple[str, str], tuple[str, str]] = {
74
- ("ſ", "s"): ("longs_s", "s long ſ → s"),
75
- ("u", "v"): ("u_eq_v", "u/v interchangeables (vpon → upon)"),
76
- ("i", "j"): ("i_eq_j", "i/j interchangeables (ioy → joy)"),
77
- ("y", "i"): ("y_eq_i", "y → i (Latin médiéval)"),
78
- ("vv", "w"): ("vv_eq_w", "vv → w (anglais moderne)"),
79
- ("æ", "ae"): ("ae_ligature", "æ → ae"),
80
- ("œ", "oe"): ("oe_ligature", "œ → oe"),
81
- ("þ", "th"): ("thorn_th", "þ (thorn) → th"),
82
- ("ð", "th"): ("eth_th", "ð (eth) → th"),
83
- ("ȝ", "y"): ("yogh_y", "ȝ (yogh) → y"),
84
- ("&", "et"): ("ampersand_et", "& → et (esperluette)"),
85
- ("ỹ", "yn"): ("y_tilde_yn", "ỹ → yn"),
86
- ("ꝑ", "per"): ("p_per", "ꝑ → per (abréviation Capelli)"),
87
- ("ꝓ", "pro"): ("p_pro", "ꝓ → pro (abréviation Capelli)"),
88
- ("ꝗ", "que"): ("q_que", "ꝗ → que (q barré)"),
89
- }
90
-
91
- sources = [
92
- ("medieval_french", DIPLOMATIC_LATIN_MEDIEVAL),
93
- ("early_modern_french", DIPLOMATIC_FR_EARLY_MODERN),
94
- ("early_modern_english", DIPLOMATIC_EN_EARLY_MODERN),
95
- ("minimal", DIPLOMATIC_MINIMAL),
96
- ]
97
-
98
- for profile_tag, profile_dict in sources:
99
- for source, target in profile_dict.items():
100
- key = (source, target)
101
- if key in canonical_names:
102
- name, desc = canonical_names[key]
103
- else:
104
- # Fallback : générer un nom à partir des codepoints
105
- name = f"{source}_to_{target}".replace(" ", "_")
106
- desc = f"{source} → {target}"
107
- if name in catalog:
108
- # On garde le profile_tag du premier rencontré, mais
109
- # on note que la règle est partagée.
110
- continue
111
- catalog[name] = EquivalenceRule(
112
- name=name,
113
- source=source,
114
- target=target,
115
- description=desc,
116
- profile_tag=profile_tag,
117
- )
118
- return catalog
119
-
120
-
121
- BUILTIN_EQUIVALENCES: dict[str, EquivalenceRule] = _build_catalog()
122
-
123
-
124
- def list_equivalences_by_profile(
125
- profile_name: Optional[str] = None,
126
- ) -> list[EquivalenceRule]:
127
- """Liste les règles d'équivalence disponibles.
128
-
129
- Si ``profile_name`` est fourni, ne retourne que les règles dont
130
- ``profile_tag == profile_name`` (ou les règles dérivées de
131
- plusieurs profils dont au moins un est ``profile_name``).
132
- """
133
- if profile_name is None:
134
- return list(BUILTIN_EQUIVALENCES.values())
135
- return [
136
- rule for rule in BUILTIN_EQUIVALENCES.values()
137
- if rule.profile_tag == profile_name
138
- ]
139
-
140
-
141
- def apply_selected_equivalences(
142
- text: Optional[str],
143
- selected_names: Iterable[str],
144
- ) -> str:
145
- """Applique uniquement les règles dont le nom est dans
146
- ``selected_names``.
147
-
148
- L'ordre d'application est l'ordre du catalogue interne — les
149
- transformations sont appliquées séquentiellement sur le texte.
150
- Les règles inconnues sont silencieusement ignorées (avec
151
- warning).
152
- """
153
- if not text:
154
- return text or ""
155
- selected_set = set(selected_names)
156
- if not selected_set:
157
- return text
158
- out = text
159
- for name, rule in BUILTIN_EQUIVALENCES.items():
160
- if name not in selected_set:
161
- continue
162
- out = out.replace(rule.source, rule.target)
163
- # Détection des règles inconnues (pour logger explicite)
164
- unknown = selected_set - set(BUILTIN_EQUIVALENCES.keys())
165
- if unknown:
166
- logger.warning(
167
- "[equivalence_profile] règles inconnues ignorées : %s",
168
- sorted(unknown),
169
- )
170
- return out
171
-
172
-
173
- def compute_cer_with_equivalences(
174
- reference: Optional[str],
175
- hypothesis: Optional[str],
176
- selected_names: Iterable[str],
177
- ) -> float:
178
- """Calcule le CER après application des équivalences sélectionnées
179
- sur les **deux** côtés (GT et hypothèse).
180
-
181
- Utilise ``picarones.measurements.metrics.compute_metrics`` et extrait
182
- le champ ``cer`` du résultat.
183
- """
184
- from picarones.measurements.metrics import compute_metrics
185
-
186
- selected_list = list(selected_names)
187
- ref = apply_selected_equivalences(reference or "", selected_list)
188
- hyp = apply_selected_equivalences(hypothesis or "", selected_list)
189
- result = compute_metrics(ref, hyp)
190
- return result.cer
191
-
192
-
193
- __all__ = [
194
- "EquivalenceRule",
195
- "BUILTIN_EQUIVALENCES",
196
- "list_equivalences_by_profile",
197
- "apply_selected_equivalences",
198
- "compute_cer_with_equivalences",
199
- ]
 
1
+ """Shim de compatibilitémétrique relocalisée.
2
 
3
+ Sprint E.2 du plan v2.0 (mai 2026) — module migré depuis
4
+ ``picarones.measurements.equivalence_profile`` vers
5
+ ``picarones.evaluation.metrics.equivalence_profile`` (couche canonique).
6
+ Ce shim re-exporte l'API publique avec un ``DeprecationWarning``
7
+ et sera supprimé en 2.0.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  """
9
 
10
  from __future__ import annotations
11
 
12
+ import warnings
 
 
13
 
14
+ warnings.warn(
15
+ "picarones.measurements.equivalence_profile est obsolète et sera supprimé en 2.0. "
16
+ "Utiliser picarones.evaluation.metrics.equivalence_profile à la place.",
17
+ DeprecationWarning,
18
+ stacklevel=2,
19
  )
20
 
21
+ from picarones.evaluation.metrics.equivalence_profile import * # noqa: F401, F403, E402
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/measurements/ner.py CHANGED
@@ -1,309 +1,21 @@
1
- """Calcul des métriques de précision sur entités nommées (NER).
2
 
3
- Sprint 38 — A.II.1.a du plan d'évolution 2026 : couche de calcul pure.
4
-
5
- Pourquoi ce module
6
- ------------------
7
- Pour un médiéviste, un archiviste ou un économiste-historien,
8
- l'utilité aval d'un OCR ne se mesure pas seulement au CER ; ce qui
9
- compte c'est de savoir si les **entités nommées** (personnes, lieux,
10
- dates, organisations) ont survécu à la transcription. Un CER de 5 %
11
- qui rate 80 % des noms propres est inutilisable pour l'indexation
12
- prosopographique.
13
-
14
- Stratégie de découpage en sprints
15
- ---------------------------------
16
- Comme pour la divergence taxonomique (Sprints 35-37), on découpe :
17
-
18
- - **Sprint 38** (ici) — couche de calcul pure : alignement IoU entre
19
- deux listes d'entités, calcul de Precision/Recall/F1 par catégorie
20
- et global, détection des hallucinations d'entité. Aucune dépendance
21
- externe (pas de spaCy, pas de Stanza) ; les listes d'entités sont
22
- fournies en entrée. Un test de l'enregistrement dans le registre
23
- typé Sprint 34 garantit l'intégration.
24
- - **Sprint à venir** — backend extracteur (spaCy / Stanza / HIPE) et
25
- câblage runner+narratif+HTML.
26
-
27
- Format des entités
28
- ------------------
29
- Compatible avec ``EntitiesGT`` du Sprint 32 — chaque entité est un
30
- dictionnaire ``{"label": str, "start": int, "end": int, "text": str}``
31
- où ``start``/``end`` sont des offsets caractère.
32
-
33
- Convention d'alignement
34
- -----------------------
35
- Une entité hypothèse "matche" une entité de référence si :
36
-
37
- 1. les **labels sont identiques** (case-insensitive),
38
- 2. le ratio d'**Intersection-over-Union** (IoU) sur leurs spans
39
- caractère est ``≥ iou_threshold`` (défaut : 0,5).
40
-
41
- Une entité de référence non matchée → faux négatif (recall pénalisé).
42
- Une entité hypothèse non matchée → faux positif (précision pénalisée).
43
- Un faux positif est aussi compté comme **hallucination d'entité**, ce
44
- qui est utile pour les VLM/LLM qui inventent.
45
-
46
- Limites
47
- -------
48
- - L'alignement bag-of-spans : une entité peut être matchée par au plus
49
- une entité de l'autre côté (sinon double-comptage).
50
- - Les modèles NER (spaCy, etc.) hallucinent eux-mêmes. La métrique
51
- mesure conjointement OCR + NER. Documenter explicitement.
52
  """
53
 
54
  from __future__ import annotations
55
 
56
- import logging
57
- from dataclasses import dataclass
58
- from typing import Iterable
59
-
60
- from picarones.evaluation.metric_registry import register_metric
61
- from picarones.domain.artifacts import ArtifactType
62
-
63
- logger = logging.getLogger(__name__)
64
-
65
-
66
- # ──────────────────────────────────────────────────────────────────────────
67
- # Modèle de données
68
- # ──────────────────────────────────────────────────────────────────────────
69
-
70
-
71
- @dataclass(frozen=True)
72
- class Entity:
73
- """Entité nommée alignée sur un texte.
74
-
75
- Attributs
76
- ---------
77
- label:
78
- Catégorie de l'entité (ex. ``"PER"``, ``"LOC"``, ``"DATE"``).
79
- La comparaison se fait en *case-insensitive*.
80
- start, end:
81
- Offsets caractère (inclus, exclu) sur le texte de référence.
82
- text:
83
- Forme de surface — informative, **non utilisée pour
84
- l'alignement** (deux entités peuvent matcher même si leur
85
- forme de surface diffère, du moment que leurs spans
86
- chevauchent suffisamment).
87
- """
88
-
89
- label: str
90
- start: int
91
- end: int
92
- text: str = ""
93
-
94
- def __post_init__(self) -> None:
95
- if self.start > self.end:
96
- raise ValueError(
97
- f"Entity span invalide : start={self.start} > end={self.end}"
98
- )
99
-
100
- @property
101
- def length(self) -> int:
102
- return max(0, self.end - self.start)
103
-
104
-
105
- def _to_entity(obj: Entity | dict) -> Entity:
106
- """Coerce un dict (format EntitiesGT) en ``Entity``."""
107
- if isinstance(obj, Entity):
108
- return obj
109
- return Entity(
110
- label=str(obj["label"]),
111
- start=int(obj["start"]),
112
- end=int(obj["end"]),
113
- text=str(obj.get("text", "")),
114
- )
115
-
116
-
117
- # ──────────────────────────────────────────────────────────────────────────
118
- # Alignement par IoU
119
- # ────────────────────────────────────────────────────────��─────────────────
120
-
121
-
122
- def _iou(a: Entity, b: Entity) -> float:
123
- """Intersection-over-Union sur les spans caractère."""
124
- inter_start = max(a.start, b.start)
125
- inter_end = min(a.end, b.end)
126
- inter = max(0, inter_end - inter_start)
127
- union = a.length + b.length - inter
128
- if union <= 0:
129
- return 0.0
130
- return inter / union
131
 
132
-
133
- def _align(
134
- references: list[Entity],
135
- hypotheses: list[Entity],
136
- iou_threshold: float,
137
- ) -> tuple[list[tuple[int, int, float]], set[int], set[int]]:
138
- """Aligne deux listes d'entités par IoU décroissant (greedy).
139
-
140
- Returns
141
- -------
142
- matches:
143
- Liste de triplets ``(idx_ref, idx_hyp, iou)`` triés par IoU
144
- décroissant — chaque entité n'apparaît qu'une fois.
145
- unmatched_refs:
146
- Indices des entités GT non matchées (faux négatifs).
147
- unmatched_hyps:
148
- Indices des entités hypothèse non matchées (faux positifs).
149
- """
150
- candidates: list[tuple[float, int, int]] = []
151
- for i, r in enumerate(references):
152
- for j, h in enumerate(hypotheses):
153
- if r.label.casefold() != h.label.casefold():
154
- continue
155
- score = _iou(r, h)
156
- if score >= iou_threshold:
157
- candidates.append((score, i, j))
158
-
159
- # Tri par IoU décroissant ; à IoU égale, on prend l'ordre des paires
160
- # pour garantir un tri stable et déterministe.
161
- candidates.sort(key=lambda t: (-t[0], t[1], t[2]))
162
-
163
- matched_refs: set[int] = set()
164
- matched_hyps: set[int] = set()
165
- matches: list[tuple[int, int, float]] = []
166
- for score, i, j in candidates:
167
- if i in matched_refs or j in matched_hyps:
168
- continue
169
- matched_refs.add(i)
170
- matched_hyps.add(j)
171
- matches.append((i, j, score))
172
-
173
- unmatched_refs = set(range(len(references))) - matched_refs
174
- unmatched_hyps = set(range(len(hypotheses))) - matched_hyps
175
- return matches, unmatched_refs, unmatched_hyps
176
-
177
-
178
- # ──────────────────────────────────────────────────────────────────────────
179
- # Calcul des métriques
180
- # ──────────────────────────────────────────────────────────────────────────
181
-
182
-
183
- def _prf(tp: int, fp: int, fn: int) -> dict[str, float]:
184
- """Précision / rappel / F1 à partir des comptes."""
185
- precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
186
- recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
187
- f1 = (
188
- 2 * precision * recall / (precision + recall)
189
- if (precision + recall) > 0
190
- else 0.0
191
- )
192
- return {
193
- "precision": precision,
194
- "recall": recall,
195
- "f1": f1,
196
- "support": tp + fn,
197
- }
198
-
199
-
200
- def compute_ner_metrics(
201
- reference_entities: Iterable[Entity | dict],
202
- hypothesis_entities: Iterable[Entity | dict],
203
- iou_threshold: float = 0.5,
204
- ) -> dict:
205
- """Calcule la précision/rappel/F1 sur entités nommées.
206
-
207
- Parameters
208
- ----------
209
- reference_entities:
210
- Liste d'entités GT (format ``Entity`` ou dict de
211
- ``EntitiesGT``).
212
- hypothesis_entities:
213
- Liste d'entités produites par le NER sur la sortie OCR.
214
- iou_threshold:
215
- Seuil de chevauchement caractère pour qu'un appariement
216
- soit valide (défaut : 0,5 — convention CoNLL/HIPE).
217
-
218
- Returns
219
- -------
220
- dict
221
- ``{
222
- "global": {"precision", "recall", "f1", "support"},
223
- "per_category": {label: {"precision", ...}},
224
- "true_positives": int,
225
- "false_positives": int,
226
- "false_negatives": int,
227
- "hallucinated_entities": list[dict], # entités OCR sans GT
228
- "missed_entities": list[dict], # entités GT non détectées
229
- "iou_threshold": float,
230
- }``
231
- """
232
- refs = [_to_entity(e) for e in reference_entities]
233
- hyps = [_to_entity(e) for e in hypothesis_entities]
234
-
235
- matches, unmatched_refs, unmatched_hyps = _align(refs, hyps, iou_threshold)
236
-
237
- tp = len(matches)
238
- fn = len(unmatched_refs)
239
- fp = len(unmatched_hyps)
240
-
241
- # Comptes par catégorie
242
- cat_tp: dict[str, int] = {}
243
- cat_fn: dict[str, int] = {}
244
- cat_fp: dict[str, int] = {}
245
- for i, _j, _score in matches:
246
- cat = refs[i].label
247
- cat_tp[cat] = cat_tp.get(cat, 0) + 1
248
- for i in unmatched_refs:
249
- cat = refs[i].label
250
- cat_fn[cat] = cat_fn.get(cat, 0) + 1
251
- for j in unmatched_hyps:
252
- cat = hyps[j].label
253
- cat_fp[cat] = cat_fp.get(cat, 0) + 1
254
-
255
- all_categories = sorted(set(cat_tp) | set(cat_fn) | set(cat_fp))
256
- per_category = {
257
- cat: _prf(cat_tp.get(cat, 0), cat_fp.get(cat, 0), cat_fn.get(cat, 0))
258
- for cat in all_categories
259
- }
260
-
261
- return {
262
- "global": _prf(tp, fp, fn),
263
- "per_category": per_category,
264
- "true_positives": tp,
265
- "false_positives": fp,
266
- "false_negatives": fn,
267
- "hallucinated_entities": [
268
- {"label": hyps[j].label, "start": hyps[j].start,
269
- "end": hyps[j].end, "text": hyps[j].text}
270
- for j in sorted(unmatched_hyps)
271
- ],
272
- "missed_entities": [
273
- {"label": refs[i].label, "start": refs[i].start,
274
- "end": refs[i].end, "text": refs[i].text}
275
- for i in sorted(unmatched_refs)
276
- ],
277
- "iou_threshold": iou_threshold,
278
- }
279
-
280
-
281
- # ──────────────────────────────────────────────────────────────────────────
282
- # Enregistrement dans le registre typé (Sprint 34)
283
- # ──────────────────────────────────────────────────────────────────────────
284
-
285
-
286
- @register_metric(
287
- name="ner_f1",
288
- input_types=(ArtifactType.ENTITIES, ArtifactType.ENTITIES),
289
- description=(
290
- "F1 global sur les entités nommées (alignement IoU ≥ 0,5, "
291
- "labels case-insensitive). Pour le détail par catégorie, "
292
- "utiliser compute_ner_metrics directement."
293
- ),
294
- higher_is_better=True,
295
- tags={"downstream", "ner", "structure"},
296
  )
297
- def ner_f1(
298
- reference_entities: Iterable[Entity | dict],
299
- hypothesis_entities: Iterable[Entity | dict],
300
- ) -> float:
301
- """F1 global ; raccourci enregistré pour les jonctions ``(ENTITIES, ENTITIES)``."""
302
- return compute_ner_metrics(reference_entities, hypothesis_entities)["global"]["f1"]
303
-
304
 
305
- __all__ = [
306
- "Entity",
307
- "compute_ner_metrics",
308
- "ner_f1",
309
- ]
 
1
+ """Shim de compatibilité métrique relocalisée.
2
 
3
+ Sprint E.2 du plan v2.0 (mai 2026) module migré depuis
4
+ ``picarones.measurements.ner`` vers
5
+ ``picarones.evaluation.metrics.ner`` (couche canonique).
6
+ Ce shim re-exporte l'API publique avec un ``DeprecationWarning``
7
+ et sera supprimé en 2.0.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  """
9
 
10
  from __future__ import annotations
11
 
12
+ import warnings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
+ warnings.warn(
15
+ "picarones.measurements.ner est obsolète et sera supprimé en 2.0. "
16
+ "Utiliser picarones.evaluation.metrics.ner à la place.",
17
+ DeprecationWarning,
18
+ stacklevel=2,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  )
 
 
 
 
 
 
 
20
 
21
+ from picarones.evaluation.metrics.ner import * # noqa: F401, F403, E402
 
 
 
 
picarones/measurements/numerical_sequences_hooks.py CHANGED
@@ -1,102 +1,21 @@
1
- """Câblage runner des séquences numériques (Sprint 86).
2
 
3
- Sprint 86 A.II.5b (vue HTML + câblage runner).
4
-
5
- Le module ``picarones/core/numerical_sequences.py`` (Sprint 85)
6
- a livré la couche de calcul. Ce helper prépare la donnée
7
- adaptative pour le runner et agrège les compteurs par moteur.
8
-
9
- Adaptive masking
10
- ----------------
11
- On ne stocke le résultat que si la GT contient au moins une
12
- séquence numérique détectée — sinon le module n'apparaît pas
13
- dans le rapport.
14
  """
15
 
16
  from __future__ import annotations
17
 
18
- import logging
19
- from typing import Iterable, Optional
20
 
21
- from picarones.evaluation.metrics.numerical_sequences import (
22
- CATEGORIES,
23
- compute_numerical_sequence_metrics,
 
 
24
  )
25
 
26
- logger = logging.getLogger(__name__)
27
-
28
-
29
- def compute_numerical_sequence_metrics_adaptive(
30
- reference: Optional[str],
31
- hypothesis: Optional[str],
32
- ) -> Optional[dict]:
33
- """Calcule les métriques séquences numériques avec masquage
34
- adaptatif : retourne ``None`` si la GT n'en contient
35
- aucune."""
36
- if not reference:
37
- return None
38
- result = compute_numerical_sequence_metrics(reference, hypothesis or "")
39
- if (result.get("n_total") or 0) == 0:
40
- return None
41
- return result
42
-
43
-
44
- def aggregate_numerical_sequence_metrics(
45
- per_doc: Iterable[Optional[dict]],
46
- ) -> Optional[dict]:
47
- """Agrège par moteur : somme les compteurs par catégorie et
48
- recalcule les scores globaux et per-category.
49
-
50
- Format de sortie identique à ``compute_numerical_sequence_metrics``
51
- pour faciliter le rendu HTML symétrique.
52
- """
53
- docs = [d for d in per_doc if d]
54
- if not docs:
55
- return None
56
- total_n = 0
57
- total_strict = 0
58
- total_value = 0
59
- per_cat: dict[str, dict] = {}
60
- for cat in CATEGORIES:
61
- per_cat[cat] = {
62
- "n_total": 0,
63
- "strict": 0,
64
- "value": 0,
65
- "lost_items": [],
66
- }
67
- for d in docs:
68
- for cat in CATEGORIES:
69
- cat_data = (d.get("per_category") or {}).get(cat) or {}
70
- per_cat[cat]["n_total"] += int(cat_data.get("n_total") or 0)
71
- per_cat[cat]["strict"] += int(cat_data.get("strict") or 0)
72
- per_cat[cat]["value"] += int(cat_data.get("value") or 0)
73
- per_cat[cat]["lost_items"].extend(
74
- cat_data.get("lost_items") or [],
75
- )
76
- total_n += int(d.get("n_total") or 0)
77
- # Recalcul des scores
78
- for cat, slot in per_cat.items():
79
- n = slot["n_total"]
80
- slot["strict_score"] = slot["strict"] / n if n else 0.0
81
- slot["value_score"] = slot["value"] / n if n else 0.0
82
- # Cap des lost_items à 50 par catégorie
83
- slot["lost_items"] = slot["lost_items"][:50]
84
- total_strict += slot["strict"]
85
- total_value += slot["value"]
86
- return {
87
- "n_docs": len(docs),
88
- "n_total": total_n,
89
- "global_strict_score": (
90
- total_strict / total_n if total_n else 0.0
91
- ),
92
- "global_value_score": (
93
- total_value / total_n if total_n else 0.0
94
- ),
95
- "per_category": per_cat,
96
- }
97
-
98
-
99
- __all__ = [
100
- "compute_numerical_sequence_metrics_adaptive",
101
- "aggregate_numerical_sequence_metrics",
102
- ]
 
1
+ """Shim de compatibilité métrique relocalisée.
2
 
3
+ Sprint E.2 du plan v2.0 (mai 2026) module migré depuis
4
+ ``picarones.measurements.numerical_sequences_hooks`` vers
5
+ ``picarones.evaluation.metrics.numerical_sequences_hooks`` (couche canonique).
6
+ Ce shim re-exporte l'API publique avec un ``DeprecationWarning``
7
+ et sera supprimé en 2.0.
 
 
 
 
 
 
8
  """
9
 
10
  from __future__ import annotations
11
 
12
+ import warnings
 
13
 
14
+ warnings.warn(
15
+ "picarones.measurements.numerical_sequences_hooks est obsolète et sera supprimé en 2.0. "
16
+ "Utiliser picarones.evaluation.metrics.numerical_sequences_hooks à la place.",
17
+ DeprecationWarning,
18
+ stacklevel=2,
19
  )
20
 
21
+ from picarones.evaluation.metrics.numerical_sequences_hooks import * # noqa: F401, F403, E402
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/measurements/readability.py CHANGED
@@ -1,252 +1,21 @@
1
- """Métriques de lisibilité (Flesch) Sprint 52.
2
 
3
- Sprint 52 — A.II.2.3 du plan d'évolution 2026 : couche de calcul pure
4
- de la métrique Flesch, indépendante de tout alignement OCR/GT.
5
-
6
- Pourquoi ce module
7
- ------------------
8
- Les LLM produisent du texte plus « lisse » que les manuscrits
9
- historiques. Cette tendance à la modernisation est mesurable par la
10
- différence de score de lisibilité entre la GT et la sortie OCR/LLM —
11
- **indépendamment des classes taxonomiques** et **sans alignement
12
- caractère/mot**. C'est l'avantage clé du score Flesch : il fonctionne
13
- même quand l'OCR est très dégradé (cas d'un LLM qui invente du texte
14
- moderne plausible mais déconnecté de la GT).
15
-
16
- Stratégie de découpage
17
- ----------------------
18
- Comme pour le NER (Sprint 38) et la calibration (Sprint 39), on
19
- découpe :
20
-
21
- - **Sprint 52** (ici) — couche de calcul pure : ``flesch_score`` et
22
- ``flesch_delta``. Aucune dépendance externe ; les heuristiques de
23
- comptage de syllabes sont en pur Python, déterministes, testées.
24
- - **Sprints suivants** — câblage runner pour calculer
25
- ``flesch_delta`` par document et l'agréger au moteur, puis vue HTML.
26
-
27
- Formules
28
- --------
29
- - **Anglais** (Flesch original 1948) :
30
- ``206.835 - 1.015 × (mots/phrases) - 84.6 × (syllabes/mots)``
31
- - **Français** (Kandel-Moles 1958) :
32
- ``207 - 1.015 × (mots/phrases) - 73.6 × (syllabes/mots)``
33
-
34
- Le score est borné dans ``[0, 100]`` — 100 ↔ « très facile à lire »,
35
- 0 ↔ « très difficile ». Une **augmentation** du score quand on passe
36
- de la GT à l'OCR signale une simplification (typique des LLM
37
- modernisants). Une **chute** signale une dégradation OCR.
38
-
39
- Limites documentées
40
- -------------------
41
- - Le comptage de syllabes est heuristique. En français, des règles
42
- comme « -ier non final = 2 syllabes » ne sont pas appliquées
43
- finement. Acceptable pour une métrique de **comparaison relative**
44
- (delta GT vs OCR), pas pour publier une absolue.
45
- - Sur des textes très courts (< 20 mots), la formule perd en
46
- fiabilité. Le seuil minimal est documenté.
47
  """
48
 
49
  from __future__ import annotations
50
 
51
- import logging
52
- import re
53
- from typing import Literal
54
-
55
- from picarones.evaluation.metric_registry import register_metric
56
- from picarones.domain.artifacts import ArtifactType
57
-
58
- logger = logging.getLogger(__name__)
59
-
60
-
61
- Language = Literal["fr", "en"]
62
-
63
- # Coefficients de la formule Flesch selon la langue.
64
- _FLESCH_COEFFS: dict[str, tuple[float, float, float]] = {
65
- "en": (206.835, 1.015, 84.6), # Flesch 1948
66
- "fr": (207.0, 1.015, 73.6), # Kandel-Moles 1958
67
- }
68
-
69
- # Voyelles utilisées pour l'heuristique de comptage de syllabes.
70
- # On utilise un set qui inclut les diacritiques courantes en FR/EN.
71
- _VOWELS = set("aeiouyàâäéèêëîïôöùûüÿæœAEIOUYÀÂÄÉÈÊËÎÏÔÖÙÛÜŸÆŒ")
72
-
73
- # Regex de découpage en phrases : ponctuation finale + espace ou fin.
74
- # Tolère les multiples points (« ... ») et garde un découpage robuste.
75
- _SENTENCE_SPLIT_RE = re.compile(r"[.!?…]+(?:\s+|$)")
76
-
77
- # Regex de tokenisation simple (mots) : séquences de caractères "lettres".
78
- _WORD_RE = re.compile(r"[\w'-]+", re.UNICODE)
79
-
80
-
81
- # ──────────────────────────────────────────────────────────────────────────
82
- # Compteurs de base
83
- # ──────────────────────────────────────────────────────────────────────────
84
-
85
-
86
- def count_words(text: str) -> int:
87
- """Nombre de mots (tokens alphanumériques) dans ``text``."""
88
- if not text:
89
- return 0
90
- return len(_WORD_RE.findall(text))
91
-
92
-
93
- def count_sentences(text: str) -> int:
94
- """Nombre de phrases dans ``text``.
95
-
96
- Découpage par ponctuation finale (``.``, ``!``, ``?``, ``…``).
97
- Renvoie au minimum 1 si ``text`` contient au moins un mot, pour
98
- éviter une division par zéro dans la formule de Flesch sur les
99
- textes sans ponctuation finale.
100
- """
101
- if not text:
102
- return 0
103
- parts = [p for p in _SENTENCE_SPLIT_RE.split(text) if p.strip()]
104
- n = len(parts)
105
- if n == 0 and count_words(text) > 0:
106
- return 1
107
- return n
108
-
109
-
110
- def count_syllables_word(word: str) -> int:
111
- """Heuristique de comptage de syllabes pour un mot isolé.
112
-
113
- Règle : on compte les **groupes de voyelles consécutives** (en
114
- incluant ``y`` et les diacritiques courantes). C'est une
115
- approximation grossière mais déterministe et testable.
116
 
117
- Cas limites :
118
- - mot vide ��� 0
119
- - mot sans voyelle → 1 (par convention, ex. acronymes ``BNF``)
120
- - mot d'une seule voyelle isolée → 1
121
- """
122
- if not word:
123
- return 0
124
- word = word.lower()
125
- in_vowel_group = False
126
- count = 0
127
- for ch in word:
128
- if ch in _VOWELS:
129
- if not in_vowel_group:
130
- count += 1
131
- in_vowel_group = True
132
- else:
133
- in_vowel_group = False
134
- return count or 1
135
-
136
-
137
- def count_syllables(text: str) -> int:
138
- """Somme des syllabes de tous les mots de ``text``."""
139
- if not text:
140
- return 0
141
- return sum(count_syllables_word(w) for w in _WORD_RE.findall(text))
142
-
143
-
144
- # ──────────────────────────────────────────────────────────────────────────
145
- # Score Flesch
146
- # ──────────────────────────────────────────────────────────────────────────
147
-
148
-
149
- def flesch_score(text: str, lang: Language = "fr") -> float:
150
- """Calcule le score de lisibilité Flesch pour ``text``.
151
-
152
- Parameters
153
- ----------
154
- text:
155
- Texte à évaluer. Peut contenir ponctuation, accents, etc.
156
- lang:
157
- ``"fr"`` (Kandel-Moles 1958, défaut) ou ``"en"`` (Flesch 1948).
158
-
159
- Returns
160
- -------
161
- float
162
- Score borné dans ``[0, 100]``. Renvoie ``0.0`` sur un texte
163
- vide ou sans mot exploitable.
164
-
165
- Notes
166
- -----
167
- Le score chute fortement avec :
168
- - longues phrases (mots/phrases élevé)
169
- - mots polysyllabiques (syllabes/mots élevé)
170
- Une montée du score lors du passage GT → OCR signale qu'un LLM a
171
- « lissé » la langue (phrases plus courtes, mots plus communs).
172
- """
173
- if lang not in _FLESCH_COEFFS:
174
- raise ValueError(f"Langue non supportée : {lang!r}. Choisir 'fr' ou 'en'.")
175
-
176
- n_words = count_words(text)
177
- if n_words == 0:
178
- return 0.0
179
- n_sentences = max(1, count_sentences(text))
180
- n_syllables = count_syllables(text)
181
- if n_syllables == 0:
182
- return 0.0
183
-
184
- base, k_words, k_syll = _FLESCH_COEFFS[lang]
185
- raw = base - k_words * (n_words / n_sentences) - k_syll * (n_syllables / n_words)
186
- return max(0.0, min(100.0, raw))
187
-
188
-
189
- def flesch_delta(
190
- reference: str,
191
- hypothesis: str,
192
- lang: Language = "fr",
193
- ) -> float:
194
- """Différence ``flesch_score(hypothesis) - flesch_score(reference)``.
195
-
196
- Interprétation
197
- --------------
198
- - **Positif** : l'hypothèse OCR est plus lisible que la GT —
199
- signal d'**over-normalisation** (typique des LLM qui modernisent
200
- des textes anciens).
201
- - **Négatif** : l'OCR est moins lisible — signal de dégradation
202
- (caractères mal reconnus brisent la fluidité).
203
- - **≈ 0** : OCR fidèle à la GT en termes de complexité linguistique.
204
-
205
- Borné dans ``[-100, +100]``.
206
- """
207
- return flesch_score(hypothesis, lang=lang) - flesch_score(reference, lang=lang)
208
-
209
-
210
- # ──────────────────────────────────────────────────────────────────────────
211
- # Enregistrement dans le registre typé (Sprint 34)
212
- # ──────────────────────────────────────────────────────────────────────────
213
-
214
-
215
- @register_metric(
216
- name="flesch_delta_fr",
217
- input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
218
- description=(
219
- "Différence de score Flesch (Kandel-Moles, FR) entre la sortie "
220
- "OCR et la GT. Positif = OCR plus lisible (signal "
221
- "d'over-normalisation LLM). Aucun alignement requis."
222
- ),
223
- higher_is_better=False, # un delta proche de 0 = fidélité ; positif = LLM lissant
224
- tags={"text", "readability", "over_normalization"},
225
  )
226
- def _registered_flesch_delta_fr(reference: str, hypothesis: str) -> float:
227
- return flesch_delta(reference, hypothesis, lang="fr")
228
-
229
-
230
- @register_metric(
231
- name="flesch_delta_en",
232
- input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
233
- description=(
234
- "Flesch reading ease delta (Flesch 1948, EN) between OCR and GT. "
235
- "Positive = OCR easier to read than GT (LLM smoothing signal). "
236
- "No alignment required."
237
- ),
238
- higher_is_better=False,
239
- tags={"text", "readability", "over_normalization"},
240
- )
241
- def _registered_flesch_delta_en(reference: str, hypothesis: str) -> float:
242
- return flesch_delta(reference, hypothesis, lang="en")
243
-
244
 
245
- __all__ = [
246
- "flesch_score",
247
- "flesch_delta",
248
- "count_words",
249
- "count_sentences",
250
- "count_syllables",
251
- "count_syllables_word",
252
- ]
 
1
+ """Shim de compatibilitémétrique relocalisée.
2
 
3
+ Sprint E.2 du plan v2.0 (mai 2026) module migré depuis
4
+ ``picarones.measurements.readability`` vers
5
+ ``picarones.evaluation.metrics.readability`` (couche canonique).
6
+ Ce shim re-exporte l'API publique avec un ``DeprecationWarning``
7
+ et sera supprimé en 2.0.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  """
9
 
10
  from __future__ import annotations
11
 
12
+ import warnings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
+ warnings.warn(
15
+ "picarones.measurements.readability est obsolète et sera supprimé en 2.0. "
16
+ "Utiliser picarones.evaluation.metrics.readability à la place.",
17
+ DeprecationWarning,
18
+ stacklevel=2,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
+ from picarones.evaluation.metrics.readability import * # noqa: F401, F403, E402
 
 
 
 
 
 
 
picarones/measurements/readability_hooks.py CHANGED
@@ -1,114 +1,21 @@
1
- """Câblage runner du delta Flesch (Sprint 87 A.II.2).
2
 
3
- Sprint 87 — A.II.2 (vue HTML + câblage runner du delta Flesch
4
- livré par le Sprint 52).
5
-
6
- Pourquoi ce module
7
- ------------------
8
- Le ``flesch_delta`` mesure la différence de lisibilité entre la
9
- GT et la sortie OCR. Un score positif signale une *over-
10
- normalisation* typique des LLM/VLM qui modernisent un texte
11
- ancien (le Flesch monte parce que les mots sont plus simples) ;
12
- un score négatif signale une dégradation OCR brutale.
13
-
14
- Cette métrique est calculée **automatiquement** par le runner
15
- sur chaque document, agrégée par moteur, et présentée dans le
16
- rapport.
17
-
18
- Adaptive masking
19
- ----------------
20
- On ne calcule que si la GT contient ≥ 5 mots — en dessous, le
21
- Flesch est trop instable pour être informatif.
22
-
23
- Langue
24
- ------
25
- Lecture depuis ``corpus.metadata.get("language", "fr")``. Pour
26
- les corpus mixtes, l'utilisateur peut passer une langue
27
- explicite à l'orchestrateur.
28
  """
29
 
30
  from __future__ import annotations
31
 
32
- import logging
33
- import statistics
34
- from typing import Iterable, Optional
35
 
36
- from picarones.measurements.readability import (
37
- Language,
38
- count_words,
39
- flesch_delta,
40
- flesch_score,
41
  )
42
 
43
- logger = logging.getLogger(__name__)
44
-
45
-
46
- _MIN_WORDS_FOR_FLESCH = 5
47
-
48
-
49
- def compute_readability_metrics(
50
- reference: Optional[str],
51
- hypothesis: Optional[str],
52
- *,
53
- lang: Language = "fr",
54
- ) -> Optional[dict]:
55
- """Calcule le delta Flesch d'un document avec adaptive masking.
56
-
57
- Retourne ``None`` si la GT contient moins de
58
- ``_MIN_WORDS_FOR_FLESCH`` mots.
59
- """
60
- ref = reference or ""
61
- n_ref_words = count_words(ref)
62
- if n_ref_words < _MIN_WORDS_FOR_FLESCH:
63
- return None
64
- hyp = hypothesis or ""
65
- flesch_ref = flesch_score(ref, lang=lang)
66
- flesch_hyp = flesch_score(hyp, lang=lang) if hyp else None
67
- delta = (
68
- flesch_delta(ref, hyp, lang=lang) if hyp else None
69
- )
70
- return {
71
- "lang": lang,
72
- "flesch_reference": flesch_ref,
73
- "flesch_hypothesis": flesch_hyp,
74
- "flesch_delta": delta,
75
- "n_words_reference": n_ref_words,
76
- }
77
-
78
-
79
- def aggregate_readability_metrics(
80
- per_doc: Iterable[Optional[dict]],
81
- ) -> Optional[dict]:
82
- """Agrège : moyenne/médiane des deltas + part de docs
83
- « over-normalisés » (delta > +5 points).
84
- """
85
- docs = [d for d in per_doc if d]
86
- if not docs:
87
- return None
88
- deltas = [
89
- float(d["flesch_delta"]) for d in docs
90
- if isinstance(d.get("flesch_delta"), (int, float))
91
- ]
92
- if not deltas:
93
- return None
94
- over_norm = sum(1 for d in deltas if d > 5.0)
95
- under_norm = sum(1 for d in deltas if d < -5.0)
96
- lang = docs[0].get("lang") or "fr"
97
- return {
98
- "lang": lang,
99
- "n_docs": len(docs),
100
- "n_docs_with_delta": len(deltas),
101
- "delta_mean": statistics.fmean(deltas),
102
- "delta_median": statistics.median(deltas),
103
- "delta_min": min(deltas),
104
- "delta_max": max(deltas),
105
- "n_over_normalized": over_norm,
106
- "n_under_normalized": under_norm,
107
- "over_normalized_rate": over_norm / len(deltas),
108
- }
109
-
110
-
111
- __all__ = [
112
- "compute_readability_metrics",
113
- "aggregate_readability_metrics",
114
- ]
 
1
+ """Shim de compatibilitémétrique relocalisée.
2
 
3
+ Sprint E.2 du plan v2.0 (mai 2026) module migré depuis
4
+ ``picarones.measurements.readability_hooks`` vers
5
+ ``picarones.evaluation.metrics.readability_hooks`` (couche canonique).
6
+ Ce shim re-exporte l'API publique avec un ``DeprecationWarning``
7
+ et sera supprimé en 2.0.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  """
9
 
10
  from __future__ import annotations
11
 
12
+ import warnings
 
 
13
 
14
+ warnings.warn(
15
+ "picarones.measurements.readability_hooks est obsolète et sera supprimé en 2.0. "
16
+ "Utiliser picarones.evaluation.metrics.readability_hooks à la place.",
17
+ DeprecationWarning,
18
+ stacklevel=2,
19
  )
20
 
21
+ from picarones.evaluation.metrics.readability_hooks import * # noqa: F401, F403, E402
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/measurements/reading_order.py CHANGED
@@ -1,196 +1,21 @@
1
- """Reading order F1 (ICDAR 2015, Antonacopoulos) Sprint 53.
2
 
3
- Sprint 53 — A.II.2.1 du plan d'évolution 2026.
4
-
5
- Pourquoi ce module
6
- ------------------
7
- Sur un manuscrit glosé, un journal multi-colonnes ou un registre
8
- paroissial complexe, le **classement des moteurs en CER** peut être
9
- trompeur : un moteur peut avoir un excellent CER caractère et un
10
- **ordre de lecture catastrophique**. Le résultat est inutilisable
11
- pour la recherche plein texte (Elastic, Solr) ou pour reconstituer
12
- une narration linéaire.
13
-
14
- La métrique standard est définie par Antonacopoulos et al. dans
15
- ICDAR 2015 — F1 sur les **paires d'ordre relatif** entre régions
16
- ALTO/PAGE. Pour chaque paire ``(a, b)`` telle que ``a`` précède
17
- ``b`` dans la GT :
18
-
19
- - **TP** si ``a`` précède aussi ``b`` dans l'hypothèse,
20
- - **FN** si la paire est manquante (régions absentes ou ordre
21
- inversé) côté hypothèse,
22
- - **FP** si une paire ``(a, b)`` apparaît dans l'hypothèse alors que
23
- la GT n'a pas cet ordre (régions hallucinées ou inversion).
24
-
25
- Le F1 est la moyenne harmonique des deux.
26
-
27
- Stratégie de découpage
28
- ----------------------
29
- Cohérent avec NER (Sprint 38), calibration (Sprint 39), Flesch
30
- (Sprint 52) : couche de calcul pure d'abord. L'utilisateur fournit
31
- deux listes ordonnées d'IDs de régions (typiquement extraites de
32
- ALTO/PAGE par un parser amont). Le câblage runner et la vue HTML
33
- suivent dans des sprints dédiés.
34
-
35
- Compatible directement avec ``ReadingOrderGT`` du Sprint 32 :
36
- ``ReadingOrderGT.region_order`` est exactement le format attendu.
37
-
38
- Convention sur les régions
39
- --------------------------
40
- - Les IDs sont des chaînes (``"r_1"``, ``"region_main"``, etc.).
41
- - Les **doublons** sont ignorés au calcul des paires ordonnées
42
- (chaque ID compte une fois par séquence).
43
- - Une région présente dans la GT mais absente de l'hypothèse
44
- contribue aux paires FN.
45
- - Une région présente dans l'hypothèse mais absente de la GT
46
- contribue aux paires FP.
47
- - Si une séquence a < 2 régions distinctes, aucune paire n'est
48
- émise — le F1 retourne ``0.0`` ou ``1.0`` selon que les deux
49
- séquences soient identiques.
50
  """
51
 
52
  from __future__ import annotations
53
 
54
- import logging
55
- from itertools import combinations
56
- from typing import Iterable
57
-
58
- from picarones.evaluation.metric_registry import register_metric
59
- from picarones.domain.artifacts import ArtifactType
60
-
61
- logger = logging.getLogger(__name__)
62
-
63
-
64
- # ──────────────────────────────────────────────────────────────────────────
65
- # Helpers
66
- # ──────────────────────────────────────────────────────────────────────────
67
-
68
-
69
- def _ordered_pairs(sequence: list[str]) -> set[tuple[str, str]]:
70
- """Retourne l'ensemble des paires ``(a, b)`` telles que ``a``
71
- précède strictement ``b`` dans ``sequence``.
72
-
73
- Doublons : chaque ID est traité une seule fois (première occurrence
74
- dans la séquence). Cohérent avec ICDAR 2015 où les régions ont
75
- des IDs uniques.
76
- """
77
- seen: list[str] = []
78
- seen_set: set[str] = set()
79
- for r in sequence:
80
- if r not in seen_set:
81
- seen.append(r)
82
- seen_set.add(r)
83
- return set(combinations(seen, 2))
84
-
85
-
86
- def _normalize_input(value: Iterable[str] | None) -> list[str]:
87
- """Coerce une entrée en list[str], en filtrant les valeurs vides."""
88
- if value is None:
89
- return []
90
- return [str(v) for v in value if v is not None and str(v).strip()]
91
 
92
-
93
- # ──────────────────────────────────────────────────────────────────────────
94
- # Métrique principale
95
- # ──────────────────────────────────────────────────────────────────────────
96
-
97
-
98
- def compute_reading_order_metrics(
99
- reference_order: Iterable[str] | None,
100
- hypothesis_order: Iterable[str] | None,
101
- ) -> dict:
102
- """Calcule precision / recall / F1 sur l'ordre relatif des régions.
103
-
104
- Parameters
105
- ----------
106
- reference_order:
107
- Séquence ordonnée d'IDs de régions issue de la GT (typiquement
108
- ``ReadingOrderGT.region_order`` du Sprint 32).
109
- hypothesis_order:
110
- Séquence ordonnée d'IDs de régions produite par un moteur
111
- OCR/HTR ou un reconstructeur ALTO.
112
-
113
- Returns
114
- -------
115
- dict
116
- ``{"precision", "recall", "f1", "true_positives",
117
- "false_positives", "false_negatives", "n_ref_pairs",
118
- "n_hyp_pairs", "common_regions", "ref_only_regions",
119
- "hyp_only_regions"}``.
120
-
121
- Comportements aux bornes
122
- ------------------------
123
- - Deux séquences identiques (mêmes régions, même ordre) → F1 = 1.0.
124
- - Ordre strictement inversé → F1 = 0.0 (toutes les paires
125
- relatives sont fausses).
126
- - Une séquence vide vs une séquence non vide → F1 = 0.0.
127
- - Deux séquences vides → F1 = 0.0 et tous les compteurs à 0
128
- (convention : on ne récompense pas l'absence).
129
- """
130
- ref = _normalize_input(reference_order)
131
- hyp = _normalize_input(hypothesis_order)
132
-
133
- ref_pairs = _ordered_pairs(ref)
134
- hyp_pairs = _ordered_pairs(hyp)
135
-
136
- tp = len(ref_pairs & hyp_pairs)
137
- fn = len(ref_pairs - hyp_pairs)
138
- fp = len(hyp_pairs - ref_pairs)
139
-
140
- precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
141
- recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
142
- f1 = (
143
- 2 * precision * recall / (precision + recall)
144
- if (precision + recall) > 0
145
- else 0.0
146
- )
147
-
148
- ref_set = set(ref)
149
- hyp_set = set(hyp)
150
- return {
151
- "precision": precision,
152
- "recall": recall,
153
- "f1": f1,
154
- "true_positives": tp,
155
- "false_positives": fp,
156
- "false_negatives": fn,
157
- "n_ref_pairs": len(ref_pairs),
158
- "n_hyp_pairs": len(hyp_pairs),
159
- "common_regions": sorted(ref_set & hyp_set),
160
- "ref_only_regions": sorted(ref_set - hyp_set),
161
- "hyp_only_regions": sorted(hyp_set - ref_set),
162
- }
163
-
164
-
165
- # ──────────────────────────────────────────────────────────────────────────
166
- # Enregistrement dans le registre typé (Sprint 34)
167
- # ──────────────────────────────────────────────────────────────────────────
168
-
169
-
170
- @register_metric(
171
- name="reading_order_f1",
172
- input_types=(ArtifactType.READING_ORDER, ArtifactType.READING_ORDER),
173
- description=(
174
- "F1 sur l'ordre relatif des régions ALTO/PAGE (ICDAR 2015, "
175
- "Antonacopoulos). Pour chaque paire (a,b) où a précède b dans "
176
- "la GT, vérifie que a précède aussi b dans l'hypothèse."
177
- ),
178
- higher_is_better=True,
179
- tags={"structure", "icdar", "alto", "page"},
180
  )
181
- def reading_order_f1(
182
- reference: Iterable[str] | None,
183
- hypothesis: Iterable[str] | None,
184
- ) -> float:
185
- """Raccourci : retourne uniquement le F1 global.
186
-
187
- Pour les détails par paire (TP/FP/FN, régions communes, etc.),
188
- appeler ``compute_reading_order_metrics`` directement.
189
- """
190
- return compute_reading_order_metrics(reference, hypothesis)["f1"]
191
-
192
 
193
- __all__ = [
194
- "compute_reading_order_metrics",
195
- "reading_order_f1",
196
- ]
 
1
+ """Shim de compatibilitémétrique relocalisée.
2
 
3
+ Sprint E.2 du plan v2.0 (mai 2026) — module migré depuis
4
+ ``picarones.measurements.reading_order`` vers
5
+ ``picarones.evaluation.metrics.reading_order`` (couche canonique).
6
+ Ce shim re-exporte l'API publique avec un ``DeprecationWarning``
7
+ et sera supprimé en 2.0.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  """
9
 
10
  from __future__ import annotations
11
 
12
+ import warnings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
+ warnings.warn(
15
+ "picarones.measurements.reading_order est obsolète et sera supprimé en 2.0. "
16
+ "Utiliser picarones.evaluation.metrics.reading_order à la place.",
17
+ DeprecationWarning,
18
+ stacklevel=2,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  )
 
 
 
 
 
 
 
 
 
 
 
20
 
21
+ from picarones.evaluation.metrics.reading_order import * # noqa: F401, F403, E402
 
 
 
picarones/measurements/searchability.py CHANGED
@@ -1,225 +1,21 @@
1
- """Recherchabilité fuzzySprint 84 (A.II.5).
2
 
3
- Sprint 84 — A.II.5 du plan d'évolution 2026.
4
-
5
- Pourquoi ce module
6
- ------------------
7
- Le CER mesure les erreurs caractère par caractère. Mais pour
8
- un usage *recherche plein-texte* (ce que font Elastic, Solr en
9
- mode fuzzy, ou la recherche full-text de Gallica), la question
10
- réelle est :
11
-
12
- *« Combien de mots de ma GT sont retrouvables dans la
13
- sortie OCR, à orthographe approchée près ? »*
14
-
15
- Un CER de 8 % peut donner 95 % de findability si les erreurs
16
- sont concentrées sur des caractères non-significatifs ou sur
17
- quelques mots aberrants ; à l'inverse, 4 % de CER mais
18
- distribué sur tous les noms propres rend le corpus inutilisable
19
- pour l'indexation prosopographique.
20
-
21
- Méthode
22
- -------
23
- Pour chaque token GT, on regarde s'il existe au moins un token
24
- hypothèse à distance de Levenshtein ≤ ``max_distance`` (défaut
25
- 2, valeur Elastic ``fuzziness: AUTO`` standard pour mots ≥ 5
26
- caractères). Le **rappel** est la proportion de tokens GT
27
- ainsi retrouvés.
28
-
29
- Multiplicité
30
- ------------
31
- Si la GT contient *« le »* deux fois et l'hypothèse une fois,
32
- seul un token GT est compté comme retrouvé (alignement
33
- multi-set, comme ``rare_token_recall`` Sprint 71).
34
-
35
- Sortie
36
- ------
37
- ``compute_searchability(reference, hypothesis)`` retourne
38
- ``{n_gt_tokens, n_searchable, recall, missed_tokens}``.
39
-
40
- Limites documentées
41
- -------------------
42
- - Tokenisation par split sur whitespace (cohérent avec le reste
43
- du codebase). Pas de stemming ni de lemmatisation.
44
- - Levenshtein non pondéré — substitution = insertion = suppression
45
- = 1. Pour un poids différent (par ex. faute classique
46
- diacritique = 0,5), passer une fonction custom.
47
- - Pas de sémantique : *« roi »* ≠ *« souverain »*. Pour la
48
- similarité sémantique, voir des modules futurs (BERTScore).
49
  """
50
 
51
  from __future__ import annotations
52
 
53
- import logging
54
- from typing import Optional
55
-
56
- from picarones.evaluation.metric_registry import register_metric
57
- from picarones.domain.artifacts import ArtifactType
58
-
59
- logger = logging.getLogger(__name__)
60
-
61
-
62
- # ──────────────────────────────────────────────────────────────────────────
63
- # Tokenisation et distance d'édition
64
- # ──────────────────────────────────────────────────────────────────────────
65
-
66
-
67
- def _split_words(text: Optional[str]) -> list[str]:
68
- """Tokenisation par whitespace — cohérent avec
69
- ``lexical_modernization.py``, ``rare_tokens.py``, etc."""
70
- if not text:
71
- return []
72
- return text.split()
73
-
74
 
75
- def levenshtein_distance(a: str, b: str) -> int:
76
- """Distance de Levenshtein (substitution=insertion=suppression=1).
77
-
78
- Implémentation DP O(|a|·|b|) en mémoire O(min(|a|,|b|)).
79
- """
80
- if a == b:
81
- return 0
82
- if len(a) < len(b):
83
- a, b = b, a
84
- # |a| ≥ |b|
85
- if not b:
86
- return len(a)
87
- previous = list(range(len(b) + 1))
88
- for i, ca in enumerate(a, start=1):
89
- current = [i] + [0] * len(b)
90
- for j, cb in enumerate(b, start=1):
91
- cost = 0 if ca == cb else 1
92
- current[j] = min(
93
- current[j - 1] + 1, # insertion
94
- previous[j] + 1, # suppression
95
- previous[j - 1] + cost, # substitution
96
- )
97
- previous = current
98
- return previous[-1]
99
-
100
-
101
- # ──────────────────────────────────────────────────────────────────────────
102
- # Calcul principal
103
- # ──────────────────────────────────────────────────────────────────────────
104
-
105
-
106
- def compute_searchability(
107
- reference: Optional[str],
108
- hypothesis: Optional[str],
109
- *,
110
- max_distance: int = 2,
111
- case_sensitive: bool = False,
112
- ) -> dict:
113
- """Recherchabilité fuzzy de ``reference`` dans ``hypothesis``.
114
-
115
- Parameters
116
- ----------
117
- reference, hypothesis:
118
- Transcriptions GT et OCR.
119
- max_distance:
120
- Seuil de distance de Levenshtein (≤ pour considérer un
121
- token comme retrouvé). Défaut 2 — convention
122
- ``fuzziness: AUTO`` d'Elastic pour mots ≥ 5 caractères.
123
- case_sensitive:
124
- Si False (défaut), casse insensible côté match — la
125
- sortie ``missed_tokens`` reste avec la casse GT
126
- originale.
127
-
128
- Returns
129
- -------
130
- dict
131
- ``{
132
- "n_gt_tokens": int,
133
- "n_searchable": int,
134
- "recall": float | None, # None si n_gt_tokens == 0
135
- "missed_tokens": list[str],
136
- "max_distance": int,
137
- }``
138
- """
139
- if max_distance < 0:
140
- raise ValueError(f"max_distance doit être ≥ 0, reçu {max_distance}")
141
- gt_tokens = _split_words(reference)
142
- hyp_tokens = _split_words(hypothesis)
143
- n_gt = len(gt_tokens)
144
- if n_gt == 0:
145
- return {
146
- "n_gt_tokens": 0,
147
- "n_searchable": 0,
148
- "recall": None,
149
- "missed_tokens": [],
150
- "max_distance": max_distance,
151
- }
152
- # Multi-set : un token hypothèse ne peut servir qu'une fois.
153
- # Tri par longueur croissante pour matcher d'abord les
154
- # tokens GT les plus courts (où ε-fautes sont plus rares).
155
- if case_sensitive:
156
- gt_for_match = list(gt_tokens)
157
- hyp_for_match = list(hyp_tokens)
158
- else:
159
- gt_for_match = [t.lower() for t in gt_tokens]
160
- hyp_for_match = [t.lower() for t in hyp_tokens]
161
-
162
- hyp_used = [False] * len(hyp_for_match)
163
- n_searchable = 0
164
- missed: list[str] = []
165
- for gi, gt_match in enumerate(gt_for_match):
166
- # Court-circuit si match exact disponible
167
- best_idx = -1
168
- best_dist = max_distance + 1
169
- for hi, used in enumerate(hyp_used):
170
- if used:
171
- continue
172
- hyp_match = hyp_for_match[hi]
173
- # Court-circuit longueur (Levenshtein ≥ |Δlen|)
174
- if abs(len(hyp_match) - len(gt_match)) > max_distance:
175
- continue
176
- d = levenshtein_distance(gt_match, hyp_match)
177
- if d < best_dist:
178
- best_dist = d
179
- best_idx = hi
180
- if d == 0:
181
- break # match exact, inutile de chercher mieux
182
- if best_idx >= 0 and best_dist <= max_distance:
183
- hyp_used[best_idx] = True
184
- n_searchable += 1
185
- else:
186
- missed.append(gt_tokens[gi])
187
- recall = n_searchable / n_gt
188
- return {
189
- "n_gt_tokens": n_gt,
190
- "n_searchable": n_searchable,
191
- "recall": recall,
192
- "missed_tokens": missed,
193
- "max_distance": max_distance,
194
- }
195
-
196
-
197
- # ──────────────────────────────────────────────────────────────────────────
198
- # Enregistrement registre typé (Sprint 34)
199
- # ──────────────────────────────────────────────────────────────────────────
200
-
201
-
202
- @register_metric(
203
- name="searchability_recall",
204
- input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
205
- description=(
206
- "Recherchabilité fuzzy : proportion de tokens GT retrouvés "
207
- "dans l'OCR à distance de Levenshtein ≤ 2. Proxy direct de "
208
- "la qualité pour la recherche plein-texte (Elastic, Solr)."
209
- ),
210
  )
211
- def searchability_recall_metric(reference: str, hypothesis: str) -> float:
212
- """Variante scalaire pour le registre typé : retourne le
213
- rappel en [0, 1], ou ``0.0`` si la GT est vide (convention
214
- cohérente avec rare_token_recall Sprint 71).
215
- """
216
- result = compute_searchability(reference, hypothesis)
217
- recall = result.get("recall")
218
- return 0.0 if recall is None else recall
219
-
220
 
221
- __all__ = [
222
- "levenshtein_distance",
223
- "compute_searchability",
224
- "searchability_recall_metric",
225
- ]
 
1
+ """Shim de compatibilité métrique relocalisée.
2
 
3
+ Sprint E.2 du plan v2.0 (mai 2026) — module migré depuis
4
+ ``picarones.measurements.searchability`` vers
5
+ ``picarones.evaluation.metrics.searchability`` (couche canonique).
6
+ Ce shim re-exporte l'API publique avec un ``DeprecationWarning``
7
+ et sera supprimé en 2.0.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  """
9
 
10
  from __future__ import annotations
11
 
12
+ import warnings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
+ warnings.warn(
15
+ "picarones.measurements.searchability est obsolète et sera supprimé en 2.0. "
16
+ "Utiliser picarones.evaluation.metrics.searchability à la place.",
17
+ DeprecationWarning,
18
+ stacklevel=2,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  )
 
 
 
 
 
 
 
 
 
20
 
21
+ from picarones.evaluation.metrics.searchability import * # noqa: F401, F403, E402
 
 
 
 
picarones/measurements/searchability_hooks.py CHANGED
@@ -1,81 +1,21 @@
1
- """Câblage runner de la recherchabilité (Sprint 86).
2
 
3
- Sprint 86 A.II.5a (vue HTML + câblage runner).
4
-
5
- Le module ``picarones/core/searchability.py`` (Sprint 84) a livré
6
- la couche de calcul. Ce helper prépare la donnée pour le runner
7
- historique et l'agrégation par moteur.
8
-
9
- Adaptive masking
10
- ----------------
11
- Comme pour les modules philologiques (Sprint 61), on ne calcule
12
- le rappel que si la GT contient au moins un token — pas de
13
- calcul vide qui produirait du bruit dans le rapport.
14
  """
15
 
16
  from __future__ import annotations
17
 
18
- import logging
19
- from typing import Iterable, Optional
20
 
21
- from picarones.measurements.searchability import (
22
- _split_words,
23
- compute_searchability,
 
 
24
  )
25
 
26
- logger = logging.getLogger(__name__)
27
-
28
-
29
- def compute_searchability_metrics(
30
- reference: Optional[str],
31
- hypothesis: Optional[str],
32
- *,
33
- max_distance: int = 2,
34
- ) -> Optional[dict]:
35
- """Recherchabilité d'un document (adaptive).
36
-
37
- Retourne ``None`` si la GT est vide ou ne contient aucun
38
- token — ce qui déclenche l'adaptive masking côté HTML.
39
- """
40
- if not reference or not _split_words(reference):
41
- return None
42
- return compute_searchability(
43
- reference, hypothesis or "", max_distance=max_distance,
44
- )
45
-
46
-
47
- def aggregate_searchability_metrics(
48
- per_doc: Iterable[Optional[dict]],
49
- ) -> Optional[dict]:
50
- """Agrège les métriques par-doc en un score corpus-wide.
51
-
52
- Convention : on somme les ``n_gt_tokens`` et ``n_searchable``
53
- et on recalcule un rappel **micro** (cohérent avec ECE/MCE
54
- Sprint 39 et NER Sprint 38).
55
- """
56
- docs = [d for d in per_doc if d]
57
- if not docs:
58
- return None
59
- n_gt = sum(int(d.get("n_gt_tokens") or 0) for d in docs)
60
- n_search = sum(int(d.get("n_searchable") or 0) for d in docs)
61
- if n_gt == 0:
62
- return None
63
- # On garde l'union des missed_tokens (capped pour ne pas
64
- # exploser le JSON sur de gros corpus)
65
- missed: list[str] = []
66
- for d in docs:
67
- missed.extend(d.get("missed_tokens") or [])
68
- return {
69
- "n_docs": len(docs),
70
- "n_gt_tokens": n_gt,
71
- "n_searchable": n_search,
72
- "recall": n_search / n_gt,
73
- "missed_tokens_sample": missed[:50],
74
- "max_distance": docs[0].get("max_distance", 2),
75
- }
76
-
77
-
78
- __all__ = [
79
- "compute_searchability_metrics",
80
- "aggregate_searchability_metrics",
81
- ]
 
1
+ """Shim de compatibilité métrique relocalisée.
2
 
3
+ Sprint E.2 du plan v2.0 (mai 2026) module migré depuis
4
+ ``picarones.measurements.searchability_hooks`` vers
5
+ ``picarones.evaluation.metrics.searchability_hooks`` (couche canonique).
6
+ Ce shim re-exporte l'API publique avec un ``DeprecationWarning``
7
+ et sera supprimé en 2.0.
 
 
 
 
 
 
8
  """
9
 
10
  from __future__ import annotations
11
 
12
+ import warnings
 
13
 
14
+ warnings.warn(
15
+ "picarones.measurements.searchability_hooks est obsolète et sera supprimé en 2.0. "
16
+ "Utiliser picarones.evaluation.metrics.searchability_hooks à la place.",
17
+ DeprecationWarning,
18
+ stacklevel=2,
19
  )
20
 
21
+ from picarones.evaluation.metrics.searchability_hooks import * # noqa: F401, F403, E402
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/measurements/unicode_blocks.py CHANGED
@@ -1,233 +1,21 @@
1
- """Précision par bloc Unicode Sprint 55.
2
 
3
- Sprint 55 — A.II.3.1 du plan d'évolution 2026 (métriques philologiques).
4
-
5
- Pourquoi ce module
6
- ------------------
7
- Pour un éditeur d'imprimés anciens ou un médiéviste, la question
8
- n'est pas seulement *« quel CER global ? »* mais *« quels caractères
9
- historiques ce moteur restitue-t-il fidèlement ? »*. Une phrase de
10
- synthèse actionnable en un coup d'œil :
11
-
12
- > *« GPT-4o restitue 95 % du Latin de Base mais seulement 12 % des
13
- > formes de présentation latine (fi, fl, ſ…). »*
14
-
15
- Ce module agrège la précision par **bloc Unicode standard** (Latin de
16
- Base, Latin Étendu A/B, Diacritiques combinants, Présentation latine,
17
- etc.). Le résultat permet directement de choisir un moteur selon le
18
- type de glyphes attendus dans le corpus.
19
-
20
- Stratégie de découpage
21
- ----------------------
22
- Cohérente avec NER (Sprint 38), Flesch (Sprint 52), Reading order F1
23
- (Sprint 53), Layout F1 (Sprint 54) : couche de calcul pure d'abord.
24
- Le câblage runner et la vue HTML suivent dans des sprints dédiés.
25
-
26
- Convention d'alignement
27
- -----------------------
28
- Alignement caractère par caractère via ``difflib.SequenceMatcher`` :
29
-
30
- - chaque caractère de la GT est classé dans son bloc Unicode,
31
- - pour chaque position GT couverte par un opcode ``equal`` →
32
- +1 dans ``correct[bloc]``,
33
- - pour chaque position GT non couverte (replace, delete) → +0,
34
- - les insertions côté hypothèse (caractères absents de la GT) ne
35
- contribuent à aucun bloc — elles sont visibles uniquement via le
36
- CER global.
37
-
38
- Précision par bloc = ``correct[bloc] / total[bloc]``.
39
-
40
- Liste des blocs reconnus
41
- ------------------------
42
- Centrée sur les glyphes courants des corpus patrimoniaux européens.
43
- Tout caractère hors de cette table est classé dans ``"Other"``
44
- (garantit une couverture exhaustive : ``sum(total[bloc]) ==
45
- len(GT)``).
46
  """
47
 
48
  from __future__ import annotations
49
 
50
- import logging
51
- from difflib import SequenceMatcher
52
- from typing import Optional
53
-
54
- from picarones.evaluation.metric_registry import register_metric
55
- from picarones.domain.artifacts import ArtifactType
56
 
57
- logger = logging.getLogger(__name__)
58
-
59
-
60
- # ──────────────────────────────────────────────────────────────────────────
61
- # Table des blocs Unicode reconnus
62
- # ──────────────────────────────────────────────────────────────────────────
63
-
64
- # Triplets (nom, code_point_min, code_point_max) — bornes inclusives.
65
- # Centré sur les blocs pertinents pour les corpus patrimoniaux
66
- # européens (manuscrits médiévaux, imprimés anciens, archives).
67
- # Source : https://www.unicode.org/charts/
68
- _UNICODE_BLOCKS: tuple[tuple[str, int, int], ...] = (
69
- ("Basic Latin", 0x0000, 0x007F),
70
- ("Latin-1 Supplement", 0x0080, 0x00FF),
71
- ("Latin Extended-A", 0x0100, 0x017F),
72
- ("Latin Extended-B", 0x0180, 0x024F),
73
- ("IPA Extensions", 0x0250, 0x02AF),
74
- ("Spacing Modifier Letters", 0x02B0, 0x02FF),
75
- ("Combining Diacritical Marks", 0x0300, 0x036F),
76
- ("Greek and Coptic", 0x0370, 0x03FF),
77
- ("Cyrillic", 0x0400, 0x04FF),
78
- ("Hebrew", 0x0590, 0x05FF),
79
- ("Arabic", 0x0600, 0x06FF),
80
- ("General Punctuation", 0x2000, 0x206F),
81
- ("Superscripts and Subscripts", 0x2070, 0x209F),
82
- ("Currency Symbols", 0x20A0, 0x20CF),
83
- ("Combining Diacritical Marks Supplement", 0x1DC0, 0x1DFF),
84
- ("Latin Extended Additional", 0x1E00, 0x1EFF),
85
- ("Latin Extended-C", 0x2C60, 0x2C7F),
86
- ("Latin Extended-D", 0xA720, 0xA7FF), # médiéval
87
- ("Latin Extended-E", 0xAB30, 0xAB6F),
88
- ("Alphabetic Presentation Forms", 0xFB00, 0xFB4F), # fi, fl, ff…
89
- ("Mathematical Alphanumeric Symbols", 0x1D400, 0x1D7FF),
90
- ("Medieval Unicode Font Initiative (MUFI)", 0xE000, 0xF8FF), # PUA
91
  )
92
 
93
-
94
- def get_block(char: str) -> str:
95
- """Retourne le nom du bloc Unicode contenant ``char``.
96
-
97
- Pour un caractère hors des blocs listés (ex. CJK, emoji, etc.),
98
- retourne ``"Other"``. Pour une chaîne multi-caractères, on
99
- considère uniquement le premier code-point.
100
- """
101
- if not char:
102
- return "Other"
103
- cp = ord(char[0])
104
- for name, lo, hi in _UNICODE_BLOCKS:
105
- if lo <= cp <= hi:
106
- return name
107
- return "Other"
108
-
109
-
110
- # ──────────────────────────────────────────────────────────────────────────
111
- # Calcul d'accuracy par bloc
112
- # ──────────────────────────────────────────────────────────────────────────
113
-
114
-
115
- def compute_unicode_block_accuracy(
116
- reference: Optional[str],
117
- hypothesis: Optional[str],
118
- ) -> dict:
119
- """Calcule la précision (recall caractère) par bloc Unicode.
120
-
121
- Parameters
122
- ----------
123
- reference:
124
- Texte GT. Chaque caractère est classé dans son bloc Unicode.
125
- hypothesis:
126
- Texte produit par le moteur OCR.
127
-
128
- Returns
129
- -------
130
- dict
131
- ``{
132
- "per_block": {
133
- bloc_name: {
134
- "correct": int, # caractères GT correctement restitués
135
- "total": int, # caractères GT du bloc
136
- "accuracy": float, # correct / total ∈ [0, 1]
137
- },
138
- ...
139
- },
140
- "global_accuracy": float, # somme(correct) / somme(total)
141
- "n_chars_reference": int,
142
- }``
143
-
144
- Cas dégénérés
145
- -------------
146
- - GT vide → ``per_block`` vide, ``global_accuracy = 0.0``,
147
- ``n_chars_reference = 0``.
148
- - hypothèse vide + GT non-vide → tous les blocs à
149
- ``accuracy = 0``.
150
- - GT et hyp identiques → tous les blocs à ``accuracy = 1``.
151
- """
152
- ref = reference or ""
153
- hyp = hypothesis or ""
154
- n_ref = len(ref)
155
-
156
- if n_ref == 0:
157
- return {
158
- "per_block": {},
159
- "global_accuracy": 0.0,
160
- "n_chars_reference": 0,
161
- }
162
-
163
- # 1. Compter le total par bloc
164
- total: dict[str, int] = {}
165
- for ch in ref:
166
- b = get_block(ch)
167
- total[b] = total.get(b, 0) + 1
168
-
169
- # 2. Aligner par opcodes de SequenceMatcher
170
- # Pour chaque opcode ``equal``, les positions ``i1..i2-1`` du GT
171
- # sont correctement restituées → +1 par caractère dans son bloc.
172
- correct: dict[str, int] = {b: 0 for b in total}
173
- matcher = SequenceMatcher(a=ref, b=hyp, autojunk=False)
174
- for op, i1, i2, _j1, _j2 in matcher.get_opcodes():
175
- if op != "equal":
176
- continue
177
- for i in range(i1, i2):
178
- b = get_block(ref[i])
179
- correct[b] = correct.get(b, 0) + 1
180
-
181
- per_block: dict[str, dict] = {}
182
- for b in sorted(total):
183
- n = total[b]
184
- c = correct.get(b, 0)
185
- per_block[b] = {
186
- "correct": c,
187
- "total": n,
188
- "accuracy": c / n if n > 0 else 0.0,
189
- }
190
-
191
- n_correct_total = sum(d["correct"] for d in per_block.values())
192
- return {
193
- "per_block": per_block,
194
- "global_accuracy": n_correct_total / n_ref,
195
- "n_chars_reference": n_ref,
196
- }
197
-
198
-
199
- def unicode_block_global_accuracy(
200
- reference: Optional[str],
201
- hypothesis: Optional[str],
202
- ) -> float:
203
- """Raccourci : retourne ``global_accuracy`` (fraction de
204
- caractères GT correctement restitués)."""
205
- return compute_unicode_block_accuracy(reference, hypothesis)["global_accuracy"]
206
-
207
-
208
- # ──────────────────────────────────────────────────────────────────────────
209
- # Enregistrement dans le registre typé (Sprint 34)
210
- # ──────────────────────────────────────────────────────────────────────────
211
-
212
-
213
- @register_metric(
214
- name="unicode_block_global_accuracy",
215
- input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
216
- description=(
217
- "Fraction de caractères GT correctement restitués par "
218
- "l'OCR (alignement caractère par caractère via difflib). "
219
- "Pour le détail par bloc Unicode (Latin de Base, Présentation "
220
- "latine, etc.), utiliser compute_unicode_block_accuracy."
221
- ),
222
- higher_is_better=True,
223
- tags={"text", "unicode", "philology"},
224
- )
225
- def _registered_global_accuracy(reference: str, hypothesis: str) -> float:
226
- return unicode_block_global_accuracy(reference, hypothesis)
227
-
228
-
229
- __all__ = [
230
- "get_block",
231
- "compute_unicode_block_accuracy",
232
- "unicode_block_global_accuracy",
233
- ]
 
1
+ """Shim de compatibilitémétrique relocalisée.
2
 
3
+ Sprint E.2 du plan v2.0 (mai 2026) — module migré depuis
4
+ ``picarones.measurements.unicode_blocks`` vers
5
+ ``picarones.evaluation.metrics.unicode_blocks`` (couche canonique).
6
+ Ce shim re-exporte l'API publique avec un ``DeprecationWarning``
7
+ et sera supprimé en 2.0.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  """
9
 
10
  from __future__ import annotations
11
 
12
+ import warnings
 
 
 
 
 
13
 
14
+ warnings.warn(
15
+ "picarones.measurements.unicode_blocks est obsolète et sera supprimé en 2.0. "
16
+ "Utiliser picarones.evaluation.metrics.unicode_blocks à la place.",
17
+ DeprecationWarning,
18
+ stacklevel=2,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  )
20
 
21
+ from picarones.evaluation.metrics.unicode_blocks import * # noqa: F401, F403, E402
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/architecture/test_legacy_canonical_parity.py CHANGED
@@ -72,7 +72,7 @@ LEGACY_PACKAGES: tuple[str, ...] = (
72
  #: :data:`LEGACY_PARITY` sans faire échouer le test. À diminuer
73
  #: à chaque session de migration : on cible 0 quand le retrait
74
  #: est complet.
75
- BOOTSTRAP_BASELINE = 73
76
 
77
 
78
  # ──────────────────────────────────────────────────────────────────
 
72
  #: :data:`LEGACY_PARITY` sans faire échouer le test. À diminuer
73
  #: à chaque session de migration : on cible 0 quand le retrait
74
  #: est complet.
75
+ BOOTSTRAP_BASELINE = 30
76
 
77
 
78
  # ──────────────────────────────────────────────────────────────────
tests/architecture/test_module_coverage.py CHANGED
@@ -76,6 +76,11 @@ TEST_ONLY_BASELINE: frozenset[str] = frozenset({
76
  # production. Suppression / migration prévue en Sprint E
77
  # (migration des hooks vers ``evaluation/metric_hooks/``).
78
  "builtin_hooks",
 
 
 
 
 
79
  })
80
 
81
 
 
76
  # production. Suppression / migration prévue en Sprint E
77
  # (migration des hooks vers ``evaluation/metric_hooks/``).
78
  "builtin_hooks",
79
+ # Sprint E.2 du plan v2.0 — module ``measurements.searchability``
80
+ # est devenu un shim après son déplacement vers
81
+ # ``evaluation/metrics/searchability``. Le shim garde son entrée
82
+ # ici pour que le scanner ne crie pas tant qu'il existe.
83
+ "searchability",
84
  })
85
 
86
 
tests/measurements/test_sprint38_ner_metrics.py CHANGED
@@ -33,7 +33,7 @@ import pytest
33
 
34
  from picarones.evaluation.metric_registry import compute_at_junction, select_metrics
35
  from picarones.domain.artifacts import ArtifactType
36
- from picarones.measurements.ner import Entity, compute_ner_metrics, ner_f1
37
 
38
 
39
  # ──────────────────────────────────────────────────────────────────────────
 
33
 
34
  from picarones.evaluation.metric_registry import compute_at_junction, select_metrics
35
  from picarones.domain.artifacts import ArtifactType
36
+ from picarones.evaluation.metrics.ner import Entity, compute_ner_metrics, ner_f1
37
 
38
 
39
  # ──────────────────────────────────────────────────────────────────────────
tests/measurements/test_sprint52_readability.py CHANGED
@@ -30,7 +30,7 @@ import pytest
30
 
31
  from picarones.evaluation.metric_registry import select_metrics
32
  from picarones.domain.artifacts import ArtifactType
33
- from picarones.measurements.readability import (
34
  count_sentences,
35
  count_syllables,
36
  count_syllables_word,
 
30
 
31
  from picarones.evaluation.metric_registry import select_metrics
32
  from picarones.domain.artifacts import ArtifactType
33
+ from picarones.evaluation.metrics.readability import (
34
  count_sentences,
35
  count_syllables,
36
  count_syllables_word,
tests/measurements/test_sprint53_reading_order.py CHANGED
@@ -28,7 +28,7 @@ import pytest
28
 
29
  from picarones.evaluation.metric_registry import compute_at_junction, select_metrics
30
  from picarones.domain.artifacts import ArtifactType
31
- from picarones.measurements.reading_order import (
32
  compute_reading_order_metrics,
33
  reading_order_f1,
34
  )
 
28
 
29
  from picarones.evaluation.metric_registry import compute_at_junction, select_metrics
30
  from picarones.domain.artifacts import ArtifactType
31
+ from picarones.evaluation.metrics.reading_order import (
32
  compute_reading_order_metrics,
33
  reading_order_f1,
34
  )
tests/measurements/test_sprint55_unicode_blocks.py CHANGED
@@ -25,7 +25,7 @@ import pytest
25
 
26
  from picarones.evaluation.metric_registry import compute_at_junction, select_metrics
27
  from picarones.domain.artifacts import ArtifactType
28
- from picarones.measurements.unicode_blocks import (
29
  compute_unicode_block_accuracy,
30
  get_block,
31
  unicode_block_global_accuracy,
 
25
 
26
  from picarones.evaluation.metric_registry import compute_at_junction, select_metrics
27
  from picarones.domain.artifacts import ArtifactType
28
+ from picarones.evaluation.metrics.unicode_blocks import (
29
  compute_unicode_block_accuracy,
30
  get_block,
31
  unicode_block_global_accuracy,
tests/measurements/test_sprint78_equivalence_profile.py CHANGED
@@ -23,7 +23,7 @@ Couvre :
23
 
24
  from __future__ import annotations
25
 
26
- from picarones.measurements.equivalence_profile import (
27
  BUILTIN_EQUIVALENCES,
28
  EquivalenceRule,
29
  apply_selected_equivalences,
 
23
 
24
  from __future__ import annotations
25
 
26
+ from picarones.evaluation.metrics.equivalence_profile import (
27
  BUILTIN_EQUIVALENCES,
28
  EquivalenceRule,
29
  apply_selected_equivalences,
tests/measurements/test_sprint84_searchability.py CHANGED
@@ -23,7 +23,7 @@ from __future__ import annotations
23
 
24
  import pytest
25
 
26
- from picarones.measurements.searchability import (
27
  compute_searchability,
28
  levenshtein_distance,
29
  searchability_recall_metric,
 
23
 
24
  import pytest
25
 
26
+ from picarones.evaluation.metrics.searchability import (
27
  compute_searchability,
28
  levenshtein_distance,
29
  searchability_recall_metric,
tests/report/test_sprint86_aii5_html.py CHANGED
@@ -18,7 +18,7 @@ from __future__ import annotations
18
  import json
19
  from pathlib import Path
20
 
21
- from picarones.measurements.numerical_sequences_hooks import (
22
  aggregate_numerical_sequence_metrics,
23
  compute_numerical_sequence_metrics_adaptive,
24
  )
@@ -32,7 +32,7 @@ def _stub_metrics() -> MetricsResult:
32
  wer=0.0, wer_normalized=0.0, mer=0.0, wil=0.0,
33
  reference_length=0, hypothesis_length=0,
34
  )
35
- from picarones.measurements.searchability_hooks import (
36
  aggregate_searchability_metrics,
37
  compute_searchability_metrics,
38
  )
 
18
  import json
19
  from pathlib import Path
20
 
21
+ from picarones.evaluation.metrics.numerical_sequences_hooks import (
22
  aggregate_numerical_sequence_metrics,
23
  compute_numerical_sequence_metrics_adaptive,
24
  )
 
32
  wer=0.0, wer_normalized=0.0, mer=0.0, wil=0.0,
33
  reference_length=0, hypothesis_length=0,
34
  )
35
+ from picarones.evaluation.metrics.searchability_hooks import (
36
  aggregate_searchability_metrics,
37
  compute_searchability_metrics,
38
  )
tests/report/test_sprint87_readability_html.py CHANGED
@@ -17,7 +17,7 @@ import json
17
  from pathlib import Path
18
 
19
  from picarones.evaluation.metric_result import MetricsResult
20
- from picarones.measurements.readability_hooks import (
21
  aggregate_readability_metrics,
22
  compute_readability_metrics,
23
  )
 
17
  from pathlib import Path
18
 
19
  from picarones.evaluation.metric_result import MetricsResult
20
+ from picarones.evaluation.metrics.readability_hooks import (
21
  aggregate_readability_metrics,
22
  compute_readability_metrics,
23
  )