File size: 10,213 Bytes
f593a34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
"""Couverture MUFI — Sprint 57.

Sprint 57 — A.II.3.3 du plan d'évolution 2026 (clôture axe A.II.3
philologique).

Pourquoi ce module
------------------
La **Medieval Unicode Font Initiative** (MUFI v4.0) standardise les
caractères médiévaux que les éditeurs critiques attendent dans une
transcription fidèle : signes d'abréviation, ligatures, lettres
spéciales (ƿ wynn, þ thorn), ponctuation médiévale, marques
diacritiques rares, etc.  Pour les médiévistes, la **couverture
MUFI** d'un moteur OCR/HTR est un critère éditorial central.

Ce module mesure le taux de **caractères MUFI de la GT
correctement restitués** dans l'OCR, après alignement caractère par
caractère (même approche que la précision par bloc Unicode du
Sprint 55).

Détection des caractères MUFI
-----------------------------
La spécification MUFI v4.0 référence ~1300 caractères dans plusieurs
plages Unicode.  Plutôt que d'embarquer la liste exhaustive (qui
évolue), on utilise un **set de plages caractéristiques** suffisant
pour les corpus patrimoniaux européens courants :

- PUA principal (U+E000–U+F8FF) : zone usuelle des glyphes MUFI
  qui n'ont pas (encore) de point de code Unicode standard.
- Latin Extended-D (U+A720–U+A7FF) : abréviations latines
  médiévales (ꝑ, ꝓ, ꝗ, etc.).
- Combining Diacritical Marks Supplement (U+1DC0–U+1DFF) :
  diacritiques médiévaux rares (macron suscript, etc.).
- Alphabetic Presentation Forms (U+FB00–U+FB4F) : ligatures
  (fi, fl, ff).
- Une **liste explicite** de caractères médiévaux dans les blocs
  Latin Extended-A/B/Additional (þ, ð, ƿ, ſ, æ, œ, etc.)

L'utilisateur peut personnaliser via le paramètre ``custom_chars``
de ``compute_mufi_coverage`` pour étendre ou restreindre.

Stratégie de découpage
----------------------
Cohérente avec NER (Sprint 38), Flesch (52), Reading order F1 (53),
Layout F1 (54), Bloc Unicode (55), Abréviations (56) : couche de
calcul pure d'abord.  Le câblage runner et la vue HTML suivent dans
des sprints dédiés.
"""

from __future__ import annotations

import logging
from difflib import SequenceMatcher
from typing import Iterable, Optional

from picarones.core.metric_registry import register_metric
from picarones.core.modules import ArtifactType

logger = logging.getLogger(__name__)


# ──────────────────────────────────────────────────────────────────────────
# Plages Unicode considérées comme MUFI
# ──────────────────────────────────────────────────────────────────────────

# Triplets (nom, lo, hi) inclusifs.  Source : MUFI v4.0 spec
# (https://mufi.info/) + revue manuelle des caractères patrimoniaux
# courants.
_MUFI_RANGES: tuple[tuple[str, int, int], ...] = (
    ("Private Use Area",                          0xE000, 0xF8FF),
    ("Latin Extended-D",                          0xA720, 0xA7FF),
    ("Combining Diacritical Marks Supplement",    0x1DC0, 0x1DFF),
    ("Alphabetic Presentation Forms",             0xFB00, 0xFB4F),
)

# Caractères MUFI explicites hors plages couvertes par les ranges.
# Surtout des glyphes médiévaux standardisés en Unicode mais qui ne
# sont pas dans le PUA ni dans Latin Extended-D : þ, ð, ƿ, ſ, æ, œ,
# ø, ƀ, ƕ, etc.  Liste raisonnée pour les corpus européens médiévaux.
_MUFI_EXPLICIT_CHARS: frozenset[str] = frozenset(
    [
        # Lettres médiévales standard
        "þ", "Þ",  # thorn — vieil anglais, islandais
        "ð", "Ð",  # eth — vieil anglais, islandais
        "ƿ", "Ƿ",  # wynn — vieil anglais
        "ſ",       # s long médiéval (déjà U+017F)
        "æ", "Æ",  # ash
        "œ", "Œ",  # ethel
        "ø", "Ø",  # o barré
        # Lettres rares avec barré (pour préfixes abréviés)
        "ƀ",       # b barré
        "ŧ",       # t barré
        "đ",       # d barré
        "ħ",       # h barré
        # Yogh
        "ȝ", "Ȝ",
        # Autres signes médiévaux courants
        "ꜿ",       # con
        # Note : la liste est volontairement courte ; pour étendre,
        # l'utilisateur peut passer ``custom_chars`` à
        # ``compute_mufi_coverage``.
    ]
)


def is_mufi_char(char: str, custom_chars: Optional[frozenset[str]] = None) -> bool:
    """Retourne ``True`` si ``char`` est considéré comme MUFI.

    Reconnaît :

    - les caractères dans les plages Unicode MUFI (``_MUFI_RANGES``),
    - les caractères de la liste explicite (``_MUFI_EXPLICIT_CHARS``),
    - tout caractère supplémentaire fourni via ``custom_chars``.

    Pour une chaîne multi-caractères, seul le premier code-point
    est considéré.
    """
    if not char:
        return False
    cp = ord(char[0])
    for _name, lo, hi in _MUFI_RANGES:
        if lo <= cp <= hi:
            return True
    if char[0] in _MUFI_EXPLICIT_CHARS:
        return True
    if custom_chars and char[0] in custom_chars:
        return True
    return False


# ──────────────────────────────────────────────────────────────────────────
# Calcul de couverture MUFI
# ──────────────────────────────────────────────────────────────────────────


def compute_mufi_coverage(
    reference: Optional[str],
    hypothesis: Optional[str],
    custom_chars: Optional[Iterable[str]] = None,
) -> dict:
    """Calcule la couverture MUFI : taux de caractères MUFI de la GT
    correctement restitués dans l'hypothèse.

    Parameters
    ----------
    reference:
        Texte GT.
    hypothesis:
        Texte produit par l'OCR.
    custom_chars:
        Itérable optionnel de caractères supplémentaires à considérer
        comme MUFI (utile pour les éditeurs ayant une convention
        propre).  Chaque entrée doit être un caractère unique.

    Returns
    -------
    dict
        ``{
            "n_mufi_chars_reference": int,    # caractères MUFI dans la GT
            "n_mufi_chars_preserved": int,    # MUFI restitués correctement
            "coverage": float,                 # ∈ [0, 1] ou 0 si N=0
            "per_char": {char: {"total", "preserved", "coverage"}},
            "missed_chars": list[str],         # caractères MUFI ratés
        }``

    Cas dégénérés
    -------------
    - GT vide ou sans caractère MUFI → ``coverage = 0`` (convention :
      pas de récompense gratuite).
    - Hyp vide + MUFI dans GT → ``coverage = 0``.
    - GT et hyp identiques avec MUFI → ``coverage = 1``.
    """
    ref = reference or ""
    hyp = hypothesis or ""
    extra: Optional[frozenset[str]] = (
        frozenset(c for c in custom_chars if c) if custom_chars else None
    )

    # 1. Identifier les positions MUFI dans la GT
    mufi_positions = [i for i, ch in enumerate(ref) if is_mufi_char(ch, extra)]
    n_total = len(mufi_positions)

    if n_total == 0:
        return {
            "n_mufi_chars_reference": 0,
            "n_mufi_chars_preserved": 0,
            "coverage": 0.0,
            "per_char": {},
            "missed_chars": [],
        }

    # 2. Aligner via SequenceMatcher (même méthode que Sprint 55)
    matcher = SequenceMatcher(a=ref, b=hyp, autojunk=False)
    correct_positions: set[int] = set()
    for op, i1, i2, _j1, _j2 in matcher.get_opcodes():
        if op == "equal":
            correct_positions.update(range(i1, i2))

    # 3. Compter par caractère
    per_char_total: dict[str, int] = {}
    per_char_preserved: dict[str, int] = {}
    missed: list[str] = []
    for i in mufi_positions:
        ch = ref[i]
        per_char_total[ch] = per_char_total.get(ch, 0) + 1
        if i in correct_positions:
            per_char_preserved[ch] = per_char_preserved.get(ch, 0) + 1
        else:
            missed.append(ch)

    n_preserved = sum(per_char_preserved.values())
    per_char = {
        ch: {
            "total": per_char_total[ch],
            "preserved": per_char_preserved.get(ch, 0),
            "coverage": (
                per_char_preserved.get(ch, 0) / per_char_total[ch]
                if per_char_total[ch] > 0
                else 0.0
            ),
        }
        for ch in sorted(per_char_total)
    }

    return {
        "n_mufi_chars_reference": n_total,
        "n_mufi_chars_preserved": n_preserved,
        "coverage": n_preserved / n_total,
        "per_char": per_char,
        "missed_chars": missed,
    }


def mufi_coverage(
    reference: Optional[str], hypothesis: Optional[str],
) -> float:
    """Raccourci : retourne la couverture MUFI globale ∈ [0, 1]."""
    return compute_mufi_coverage(reference, hypothesis)["coverage"]


# ──────────────────────────────────────────────────────────────────────────
# Enregistrement dans le registre typé (Sprint 34)
# ──────────────────────────────────────────────────────────────────────────


@register_metric(
    name="mufi_coverage",
    input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
    description=(
        "Taux de caractères MUFI (Medieval Unicode Font Initiative) "
        "de la GT correctement restitués dans l'OCR. Critère "
        "éditorial central pour les médiévistes."
    ),
    higher_is_better=True,
    tags={"text", "mufi", "philology", "medieval"},
)
def _registered_mufi_coverage(reference: str, hypothesis: str) -> float:
    return mufi_coverage(reference, hypothesis)


__all__ = [
    "is_mufi_char",
    "compute_mufi_coverage",
    "mufi_coverage",
]