File size: 8,377 Bytes
4eb91d0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
"""RecherchabilitΓ© fuzzy β€” Sprint 84 (A.II.5).

Sprint 84 β€” A.II.5 du plan d'Γ©volution 2026.

Pourquoi ce module
------------------
Le CER mesure les erreurs caractère par caractère.  Mais pour
un usage *recherche plein-texte* (ce que font Elastic, Solr en
mode fuzzy, ou la recherche full-text de Gallica), la question
rΓ©elle est :

    *Β« Combien de mots de ma GT sont retrouvables dans la
    sortie OCR, à orthographe approchée près ? »*

Un CER de 8 % peut donner 95 % de findability si les erreurs
sont concentrées sur des caractères non-significatifs ou sur
quelques mots aberrants ; Γ  l'inverse, 4 % de CER mais
distribuΓ© sur tous les noms propres rend le corpus inutilisable
pour l'indexation prosopographique.

MΓ©thode
-------
Pour chaque token GT, on regarde s'il existe au moins un token
hypothΓ¨se Γ  distance de Levenshtein ≀ ``max_distance`` (dΓ©faut
2, valeur Elastic ``fuzziness: AUTO`` standard pour mots β‰₯ 5
caractères).  Le **rappel** est la proportion de tokens GT
ainsi retrouvΓ©s.

MultiplicitΓ©
------------
Si la GT contient *« le »* deux fois et l'hypothèse une fois,
seul un token GT est comptΓ© comme retrouvΓ© (alignement
multi-set, comme ``rare_token_recall`` Sprint 71).

Sortie
------
``compute_searchability(reference, hypothesis)`` retourne
``{n_gt_tokens, n_searchable, recall, missed_tokens}``.

Limites documentΓ©es
-------------------
- Tokenisation par split sur whitespace (cohΓ©rent avec le reste
  du codebase).  Pas de stemming ni de lemmatisation.
- Levenshtein non pondΓ©rΓ© β€” substitution = insertion = suppression
  = 1.  Pour un poids diffΓ©rent (par ex. faute classique
  diacritique = 0,5), passer une fonction custom.
- Pas de sΓ©mantique : *Β« roi Β»* β‰  *Β« souverain Β»*.  Pour la
  similaritΓ© sΓ©mantique, voir des modules futurs (BERTScore).
"""

from __future__ import annotations

import logging
from typing import Optional

from picarones.evaluation.metric_registry import register_metric
from picarones.domain.artifacts import ArtifactType

logger = logging.getLogger(__name__)


# ──────────────────────────────────────────────────────────────────────────
# Tokenisation et distance d'Γ©dition
# ──────────────────────────────────────────────────────────────────────────


def _split_words(text: Optional[str]) -> list[str]:
    """Tokenisation par whitespace β€” cohΓ©rent avec
    ``lexical_modernization.py``, ``rare_tokens.py``, etc."""
    if not text:
        return []
    return text.split()


def levenshtein_distance(a: str, b: str) -> int:
    """Distance de Levenshtein (substitution=insertion=suppression=1).

    ImplΓ©mentation DP O(|a|Β·|b|) en mΓ©moire O(min(|a|,|b|)).
    """
    if a == b:
        return 0
    if len(a) < len(b):
        a, b = b, a
    # |a| β‰₯ |b|
    if not b:
        return len(a)
    previous = list(range(len(b) + 1))
    for i, ca in enumerate(a, start=1):
        current = [i] + [0] * len(b)
        for j, cb in enumerate(b, start=1):
            cost = 0 if ca == cb else 1
            current[j] = min(
                current[j - 1] + 1,        # insertion
                previous[j] + 1,           # suppression
                previous[j - 1] + cost,    # substitution
            )
        previous = current
    return previous[-1]


# ──────────────────────────────────────────────────────────────────────────
# Calcul principal
# ──────────────────────────────────────────────────────────────────────────


def compute_searchability(
    reference: Optional[str],
    hypothesis: Optional[str],
    *,
    max_distance: int = 2,
    case_sensitive: bool = False,
) -> dict:
    """RecherchabilitΓ© fuzzy de ``reference`` dans ``hypothesis``.

    Parameters
    ----------
    reference, hypothesis:
        Transcriptions GT et OCR.
    max_distance:
        Seuil de distance de Levenshtein (≀ pour considΓ©rer un
        token comme retrouvΓ©).  DΓ©faut 2 β€” convention
        ``fuzziness: AUTO`` d'Elastic pour mots β‰₯ 5 caractΓ¨res.
    case_sensitive:
        Si False (dΓ©faut), casse insensible cΓ΄tΓ© match β€” la
        sortie ``missed_tokens`` reste avec la casse GT
        originale.

    Returns
    -------
    dict
        ``{
            "n_gt_tokens": int,
            "n_searchable": int,
            "recall": float | None,    # None si n_gt_tokens == 0
            "missed_tokens": list[str],
            "max_distance": int,
        }``
    """
    if max_distance < 0:
        raise ValueError(f"max_distance doit Γͺtre β‰₯ 0, reΓ§u {max_distance}")
    gt_tokens = _split_words(reference)
    hyp_tokens = _split_words(hypothesis)
    n_gt = len(gt_tokens)
    if n_gt == 0:
        return {
            "n_gt_tokens": 0,
            "n_searchable": 0,
            "recall": None,
            "missed_tokens": [],
            "max_distance": max_distance,
        }
    # Multi-set : un token hypothèse ne peut servir qu'une fois.
    # Tri par longueur croissante pour matcher d'abord les
    # tokens GT les plus courts (oΓΉ Ξ΅-fautes sont plus rares).
    if case_sensitive:
        gt_for_match = list(gt_tokens)
        hyp_for_match = list(hyp_tokens)
    else:
        gt_for_match = [t.lower() for t in gt_tokens]
        hyp_for_match = [t.lower() for t in hyp_tokens]

    hyp_used = [False] * len(hyp_for_match)
    n_searchable = 0
    missed: list[str] = []
    for gi, gt_match in enumerate(gt_for_match):
        # Court-circuit si match exact disponible
        best_idx = -1
        best_dist = max_distance + 1
        for hi, used in enumerate(hyp_used):
            if used:
                continue
            hyp_match = hyp_for_match[hi]
            # Court-circuit longueur (Levenshtein β‰₯ |Ξ”len|)
            if abs(len(hyp_match) - len(gt_match)) > max_distance:
                continue
            d = levenshtein_distance(gt_match, hyp_match)
            if d < best_dist:
                best_dist = d
                best_idx = hi
                if d == 0:
                    break  # match exact, inutile de chercher mieux
        if best_idx >= 0 and best_dist <= max_distance:
            hyp_used[best_idx] = True
            n_searchable += 1
        else:
            missed.append(gt_tokens[gi])
    recall = n_searchable / n_gt
    return {
        "n_gt_tokens": n_gt,
        "n_searchable": n_searchable,
        "recall": recall,
        "missed_tokens": missed,
        "max_distance": max_distance,
    }


# ──────────────────────────────────────────────────────────────────────────
# Enregistrement registre typΓ© (Sprint 34)
# ──────────────────────────────────────────────────────────────────────────


@register_metric(
    name="searchability_recall",
    input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
    description=(
        "RecherchabilitΓ© fuzzy : proportion de tokens GT retrouvΓ©s "
        "dans l'OCR Γ  distance de Levenshtein ≀ 2. Proxy direct de "
        "la qualitΓ© pour la recherche plein-texte (Elastic, Solr)."
    ),
)
def searchability_recall_metric(reference: str, hypothesis: str) -> float:
    """Variante scalaire pour le registre typΓ© : retourne le
    rappel en [0, 1], ou ``0.0`` si la GT est vide (convention
    cohΓ©rente avec rare_token_recall Sprint 71).
    """
    result = compute_searchability(reference, hypothesis)
    recall = result.get("recall")
    return 0.0 if recall is None else recall


__all__ = [
    "levenshtein_distance",
    "compute_searchability",
    "searchability_recall_metric",
]