Claude commited on
Commit
f593a34
·
unverified ·
1 Parent(s): 4afd2c6

phaseB: extras/historical/ — 8 modules philologiques + 2 renderers en Cercle 3

Browse files

Deuxième phase de la refonte en 3 cercles. Cible : extraire les
métriques philologiques (cas d'usage patrimoniaux par période) du
cœur ``core/`` vers le sous-package ``extras/historical/``.

Modules déplacés (8 modules, ~3000 lignes)
------------------------------------------
``picarones/extras/historical/`` :
- ``unicode_blocks.py`` précision par bloc Unicode (toutes périodes)
- ``abbreviations.py`` score d'expansion (Capelli, médiéval)
- ``mufi.py`` couverture MUFI v4.0 (médiéval, PUA)
- ``early_modern_typography.py`` fl fi ſ ã & ı (XVIᵉ-XVIIIᵉ)
- ``modern_archives.py`` Mme/Mlle/°/†/₶ (XIXᵉ-XXᵉ)
- ``roman_numerals.py`` numéraux romains (toutes périodes)
- ``lexical_modernization.py`` top tokens GT modernisés
- ``philological_runner.py`` orchestration adaptive des 6 modules

Renderers (2 fichiers, ~700 lignes)
-----------------------------------
``picarones/extras/render/`` :
- ``philological_render.py`` profil philologique 6 sections
- ``lexical_modernization_render.py`` table top tokens

Rétrocompatibilité absolue (10 shims de 16 lignes)
---------------------------------------------------
Imports historiques préservés :
from picarones.core.unicode_blocks import compute_unicode_block_accuracy
from picarones.core.philological_runner import compute_philological_metrics
from picarones.report.philological_render import build_philological_profile_html

L'identité est préservée — ``shim.X is extras.X`` (test ``is``
vérifié), pas de duplication de logique.

Dépendance Cercle 2 → Cercle 3 (note architecturale)
-----------------------------------------------------
``picarones/core/numerical_sequences.py`` (Cercle 2 — measurements/)
importe ``roman_numerals`` (Cercle 3 — extras/historical/) pour
détecter les numéraux romains dans les séquences numériques. Cette
dépendance traverse le shim et fonctionne. Acceptée car :

- ``numerical_sequences`` est lui-même semi-historique (détecte
dates anciennes, foliotation archivistique).
- Le shim assure la rétrocompat sans coût d'exécution.
- Si on extrait ``picarones-historical`` en package PyPI séparé un
jour, on devra rendre cette dépendance optionnelle (try/except).

pyproject.toml — extra [historical] documenté
---------------------------------------------
Nouvel extra ``picarones[historical]`` déclaré (vide pour l'instant
— les modules sont dans le package principal). Documente l'intention
de séparation future en package PyPI distinct. Inclus dans l'extra
``[all]``.

Validation 8/8 en sandbox
-------------------------
- 10 imports rétrocompat OK (8 core + 2 render).
- Identité shim ↔ nouveau chemin préservée (3 paires testées).
- ``philological_runner`` détecte 5 modules de signal sur texte
médiéval test (⁊ par leſ XIV. fontoyers) : unicode_blocks,
abbreviations, mufi, early_modern, roman_numerals.
- Dépendance Cercle 2→3 (``numerical_sequences`` → ``roman_numerals``
via shim) : score strict 1.00 sur "Le roi Louis XIV régna jusqu'en 1715".
- Hook ``philological`` toujours présent dans les 12 hooks doc
enregistrés par ``builtin_hooks``.
- pyproject.toml : extra ``[historical]`` documenté.
- 10 shims minces (16 lignes chacun, pas de logique métier).
- Vue ``advanced_taxonomy`` du chantier 3 fonctionne avec
``lexical_modernization`` opt-in (5359 chars produits).

Tests
-----
+250 lignes dans tests/test_phaseB_migration.py organisés en 8 classes :
TestPhilologicalRetrocompat, TestNewHistoricalImports,
TestIdentityThroughShim, TestPhilologicalRunnerIntegration,
TestCercle2DependsOnCercle3ViaShim, TestPyprojectExtra,
TestBuiltinHooksStillRegisterPhilological, TestOriginalsAreShims.

Bilan cumulé phases A + B
-------------------------
- Cercle 3 contient maintenant 18 modules + 6 renderers.
- ``core/`` allégé de 12 modules (4 phase A + 8 phase B).
- ``report/`` allégé de 6 renderers (4 phase A + 2 phase B).
- Aucune ligne de fonctionnalité supprimée.

Phases suivantes
----------------
- Phase C : extras/importers/ (3-5 jours).
- Phase E : core/ → core/ (Cercle 1) + measurements/ (Cercle 2).
- Phase D : docs/api-stable.md + test_public_api.py + version 2.0.

picarones/core/abbreviations.py CHANGED
@@ -1,350 +1,17 @@
1
- """Score d'expansion d'abréviations médiévales Sprint 56.
2
 
3
- Sprint 56 A.II.3.2 du plan d'évolution 2026 (axe philologique).
 
 
 
4
 
5
- Pourquoi ce module
6
- ------------------
7
- Sur les manuscrits médiévaux (chartes, registres, copies de droit
8
- canonique), les scribes utilisent intensivement des **signes
9
- d'abréviation** : ``ꝑ`` (per/par), ``ꝓ`` (pro), ``ꝗ`` (qui),
10
- ``ꝙ`` (quia), ``ꝯ`` (con/-us), ``⁊`` (et), tilde combinant pour
11
- ``-en/-an``, etc.
12
-
13
- Un OCR/HTR a deux comportements possibles face à ces signes :
14
-
15
- 1. **Préservation** : la forme abrégée est gardée telle quelle
16
- (``ꝑ`` → ``ꝑ``). C'est le comportement attendu d'une
17
- transcription **diplomatique** (édition critique).
18
- 2. **Développement** : le signe est remplacé par sa forme
19
- développée (``ꝑ`` → ``per``). C'est le comportement attendu
20
- d'une édition **modernisée**.
21
-
22
- Une troisième possibilité — et c'est l'erreur qu'on cherche à
23
- détecter : le signe est **mal restitué** (remplacé par un
24
- caractère ASCII proche, supprimé, ou mal développé).
25
-
26
- Ce module produit deux scores complémentaires :
27
-
28
- - ``abbreviation_strict_score`` : taux d'abréviations GT dont la
29
- **forme abrégée Unicode est préservée** dans l'OCR.
30
- - ``abbreviation_expansion_score`` : taux d'abréviations GT dont
31
- **soit** la forme abrégée, **soit** la forme développée
32
- attendue, est présente dans l'OCR.
33
-
34
- Le **ratio** des deux dit beaucoup sur la convention adoptée :
35
-
36
- - ``strict ≈ expansion`` proche de 1 → le moteur est diplomatique
37
- (préserve l'abrégé) ;
38
- - ``strict << expansion`` → le moteur est modernisant (développe
39
- systématiquement) ;
40
- - les deux faibles → le moteur perd les abréviations (signal
41
- d'erreur OCR).
42
-
43
- Stratégie de découpage
44
- ----------------------
45
- Cohérente avec NER (Sprint 38), Flesch (52), Reading order F1 (53),
46
- Layout F1 (54), Bloc Unicode (55) : couche de calcul pure d'abord.
47
- Le câblage runner et la vue HTML suivent dans des sprints dédiés.
48
-
49
- Limites documentées
50
- -------------------
51
- - L'alignement est **bag-of-occurrences** (proxy positionnel
52
- simple) : on compte les occurrences GT et on vérifie leur
53
- présence dans l'hyp. Pas d'alignement séquentiel rigoureux.
54
- - La table d'abréviations couvre les signes les plus courants en
55
- scriptura latine européenne (Capelli). Elle est extensible via
56
- ``ABBREVIATION_EXPANSIONS``.
57
- - Pour les abréviations marquées par un **tilde combinant**
58
- (``p̃``, ``q̃``), on détecte la séquence ``lettre + U+0303``.
59
- Pas de gestion fine des polices Capelli/MUFI complètes.
60
  """
61
 
62
- from __future__ import annotations
63
-
64
- import logging
65
- import re
66
- import unicodedata
67
- from typing import Optional
68
-
69
- from picarones.core.metric_registry import register_metric
70
- from picarones.core.modules import ArtifactType
71
-
72
- logger = logging.getLogger(__name__)
73
-
74
-
75
- # ──────────────────────────────────────────────────────────────────────────
76
- # Table d'expansions
77
- # ──────────────────────────────────────────────────────────────────────────
78
-
79
- # Signes d'abréviation latins médiévaux les plus courants.
80
- # Source : Capelli, "Lexicon Abbreviaturarum" (1929) + MUFI.
81
- #
82
- # La clé est une chaîne (1 ou 2 code-points pour le cas tilde
83
- # combinant) ; la valeur est la liste des expansions courantes
84
- # acceptées (les détails varient selon la convention éditoriale,
85
- # on accepte plusieurs formes).
86
- ABBREVIATION_EXPANSIONS: dict[str, tuple[str, ...]] = {
87
- "ꝑ": ("per", "par"), # U+A751
88
- "ꝓ": ("pro",), # U+A753
89
- "ꝗ": ("qui",), # U+A757
90
- "ꝙ": ("quia",), # U+A759
91
- "ꝯ": ("us", "con"), # U+A76F
92
- "⁊": ("et",), # U+204A "et" tironien
93
- "ꝝ": ("rum",), # U+A75D
94
- "ꝫ": ("et",), # U+A76B
95
- "ꝭ": ("is",), # U+A76D
96
- # Tilde combinant après lettre (U+0303 = ̃) : pẽ, qũ, etc.
97
- "p̃": ("par", "per"),
98
- "q̃": ("que", "qui"),
99
- "ñ": ("an", "en"), # U+00F1 (Latin-1 Sup)
100
- # Note : ñ existe aussi comme caractère latin moderne (espagnol),
101
- # donc l'attribuer aux abréviations introduit du bruit ; on
102
- # laisse au benchmark le soin d'évaluer. Pour les éditeurs
103
- # médiévistes qui veulent restreindre, ils peuvent passer par
104
- # une table custom (à venir).
105
- }
106
-
107
-
108
- # Set des "premiers code-points" reconnus comme début d'une
109
- # abréviation (pour balayage rapide).
110
- _ABBR_FIRST_CHARS: frozenset[str] = frozenset(
111
- abbr[0] for abbr in ABBREVIATION_EXPANSIONS
112
- )
113
-
114
-
115
- # Combining tilde (U+0303) — utilisé pour la détection p̃, q̃, etc.
116
- _COMBINING_TILDE = "̃"
117
-
118
-
119
- # ──────────────────────────────────────────────────────────────────────────
120
- # Détection d'abréviations dans un texte
121
- # ──────────────────────────────────────────────────────────────────────────
122
-
123
-
124
- def detect_abbreviations(text: Optional[str]) -> list[str]:
125
- """Liste des abréviations médiévales détectées dans ``text``,
126
- dans l'ordre d'apparition.
127
-
128
- Reconnaît :
129
-
130
- - Les caractères Unicode dédiés présents dans
131
- ``ABBREVIATION_EXPANSIONS`` (``ꝑ``, ``ꝓ``, ``⁊``…).
132
- - Les séquences ``lettre + U+0303`` (tilde combinant) si la
133
- paire est dans la table (``p̃``, ``q̃``).
134
-
135
- Doublons conservés : si le texte contient deux ``ꝑ``, la liste
136
- en a deux. Cohérent avec le calcul bag-of-occurrences en aval.
137
- """
138
- if not text:
139
- return []
140
- found: list[str] = []
141
- # Forme NFD pour reconnaître les ã, p̃, q̃ même quand l'utilisateur
142
- # passe la forme NFC (« ñ » = U+00F1 sera traité par le mapping
143
- # direct ; les séquences manuelles ``p`` + tilde combinant restent
144
- # détectables).
145
- text_nfd = unicodedata.normalize("NFD", text)
146
- i = 0
147
- while i < len(text_nfd):
148
- ch = text_nfd[i]
149
- # Cas 1 : lettre + tilde combinant
150
- if i + 1 < len(text_nfd) and text_nfd[i + 1] == _COMBINING_TILDE:
151
- seq = ch + _COMBINING_TILDE
152
- if seq in ABBREVIATION_EXPANSIONS:
153
- found.append(seq)
154
- i += 2
155
- continue
156
- # Cas 2 : caractère unicode dédié
157
- if ch in ABBREVIATION_EXPANSIONS:
158
- found.append(ch)
159
- i += 1
160
- return found
161
-
162
-
163
- # ──────────────────────────────────────────────────────────────────────────
164
- # Scores
165
- # ──────────────────────────────────────────────────────────────────────────
166
-
167
-
168
- def _hyp_contains_abbr(hypothesis: str, abbr: str) -> bool:
169
- """Vrai si la forme abrégée ``abbr`` apparaît telle quelle dans
170
- ``hypothesis``. Sensible aux deux formes NFC / NFD pour les
171
- séquences à tilde combinant."""
172
- if abbr in hypothesis:
173
- return True
174
- # Pour les séquences ``lettre + tilde combinant``, l'hyp peut
175
- # avoir une forme NFC (ex. ``ñ`` au lieu de ``n + U+0303``).
176
- nfd = unicodedata.normalize("NFD", hypothesis)
177
- return abbr in nfd
178
-
179
-
180
- def _hyp_contains_expansion(
181
- hypothesis: str, expansions: tuple[str, ...],
182
- ) -> bool:
183
- """Vrai si l'une des formes développées apparaît dans ``hypothesis``
184
- (recherche insensible à la casse, sur les frontières de mots
185
- pour limiter les faux positifs sur les sous-chaînes courtes
186
- type ``us`` ou ``et``)."""
187
- if not expansions:
188
- return False
189
- hyp_lower = hypothesis.lower()
190
- for exp in expansions:
191
- if not exp:
192
- continue
193
- # Recherche frontière de mot pour les expansions courtes.
194
- # Pour ``per`` ou ``pro`` : on accepte le développement à
195
- # n'importe quelle position d'un mot (tolère ``per`` dans
196
- # ``permettre``, c'est imprécis mais pragmatique). Pour
197
- # les expansions très courtes (≤ 2 lettres), on impose un
198
- # mot complet pour limiter le bruit.
199
- if len(exp) <= 2:
200
- if re.search(rf"\b{re.escape(exp)}\b", hyp_lower):
201
- return True
202
- else:
203
- if exp.lower() in hyp_lower:
204
- return True
205
- return False
206
-
207
-
208
- def compute_abbreviation_metrics(
209
- reference: Optional[str],
210
- hypothesis: Optional[str],
211
- ) -> dict:
212
- """Calcule les scores d'abréviation strict et d'expansion.
213
-
214
- Parameters
215
- ----------
216
- reference:
217
- Texte GT (avec abréviations médiévales originales).
218
- hypothesis:
219
- Texte produit par l'OCR.
220
-
221
- Returns
222
- -------
223
- dict
224
- ``{
225
- "n_abbreviations_in_reference": int,
226
- "n_strict_preserved": int, # forme abrégée préservée
227
- "n_expansion_preserved": int, # abrégée OU développée
228
- "strict_score": float, # ∈ [0, 1]
229
- "expansion_score": float, # ∈ [0, 1]
230
- "per_abbreviation": [
231
- {"abbr", "strict_preserved", "expansion_preserved",
232
- "expansions"},
233
- ...
234
- ],
235
- }``
236
-
237
- Cas dégénérés
238
- -------------
239
- - GT vide ou sans abréviation détectée → tous les compteurs à 0
240
- et les scores à ``0.0`` (convention : on ne récompense pas
241
- l'absence d'abréviations).
242
- - GT non vide avec abréviations + hyp vide → tous les scores
243
- à ``0.0``.
244
- """
245
- ref = reference or ""
246
- hyp = hypothesis or ""
247
-
248
- abbreviations = detect_abbreviations(ref)
249
- n = len(abbreviations)
250
- if n == 0:
251
- return {
252
- "n_abbreviations_in_reference": 0,
253
- "n_strict_preserved": 0,
254
- "n_expansion_preserved": 0,
255
- "strict_score": 0.0,
256
- "expansion_score": 0.0,
257
- "per_abbreviation": [],
258
- }
259
-
260
- n_strict = 0
261
- n_expansion = 0
262
- per_abbr: list[dict] = []
263
- for abbr in abbreviations:
264
- expansions = ABBREVIATION_EXPANSIONS.get(abbr, ())
265
- strict_ok = _hyp_contains_abbr(hyp, abbr)
266
- # Expansion : on accepte la forme abrégée OU le développement.
267
- # Convention : si l'OCR a préservé la forme abrégée, c'est
268
- # aussi compté comme valide pour le score d'expansion (le
269
- # moteur n'a pas perdu l'information ; il a juste choisi
270
- # une convention diplomatique).
271
- expansion_ok = strict_ok or _hyp_contains_expansion(hyp, expansions)
272
- if strict_ok:
273
- n_strict += 1
274
- if expansion_ok:
275
- n_expansion += 1
276
- per_abbr.append({
277
- "abbr": abbr,
278
- "strict_preserved": strict_ok,
279
- "expansion_preserved": expansion_ok,
280
- "expansions": list(expansions),
281
- })
282
-
283
- return {
284
- "n_abbreviations_in_reference": n,
285
- "n_strict_preserved": n_strict,
286
- "n_expansion_preserved": n_expansion,
287
- "strict_score": n_strict / n,
288
- "expansion_score": n_expansion / n,
289
- "per_abbreviation": per_abbr,
290
- }
291
-
292
-
293
- def abbreviation_strict_score(
294
- reference: Optional[str], hypothesis: Optional[str],
295
- ) -> float:
296
- """Raccourci : taux de préservation **stricte** des abréviations
297
- Unicode (forme abrégée gardée telle quelle)."""
298
- return compute_abbreviation_metrics(reference, hypothesis)["strict_score"]
299
-
300
-
301
- def abbreviation_expansion_score(
302
- reference: Optional[str], hypothesis: Optional[str],
303
- ) -> float:
304
- """Raccourci : taux de préservation par expansion (forme abrégée
305
- OU forme développée présente dans l'hyp)."""
306
- return compute_abbreviation_metrics(reference, hypothesis)["expansion_score"]
307
-
308
-
309
- # ──────────────────────────────────────────────────────────────────────────
310
- # Enregistrement dans le registre typé (Sprint 34)
311
- # ──────────────────────────────────────────────────────────────────────────
312
-
313
-
314
- @register_metric(
315
- name="abbreviation_strict_score",
316
- input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
317
- description=(
318
- "Taux d'abréviations médiévales (Unicode dédié + lettre + "
319
- "tilde combinant) dont la forme abrégée est préservée telle "
320
- "quelle dans l'OCR. Idéal pour les éditions diplomatiques."
321
- ),
322
- higher_is_better=True,
323
- tags={"text", "abbreviation", "philology", "medieval"},
324
- )
325
- def _registered_strict(reference: str, hypothesis: str) -> float:
326
- return abbreviation_strict_score(reference, hypothesis)
327
-
328
-
329
- @register_metric(
330
- name="abbreviation_expansion_score",
331
- input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
332
- description=(
333
- "Taux d'abréviations dont SOIT la forme abrégée Unicode SOIT "
334
- "la forme développée attendue (per, pro, et…) est présente "
335
- "dans l'OCR. Score plus large que strict_score."
336
- ),
337
- higher_is_better=True,
338
- tags={"text", "abbreviation", "philology", "medieval"},
339
- )
340
- def _registered_expansion(reference: str, hypothesis: str) -> float:
341
- return abbreviation_expansion_score(reference, hypothesis)
342
-
343
 
344
- __all__ = [
345
- "ABBREVIATION_EXPANSIONS",
346
- "detect_abbreviations",
347
- "compute_abbreviation_metrics",
348
- "abbreviation_strict_score",
349
- "abbreviation_expansion_score",
350
- ]
 
1
+ """Alias rétrocompat module déplacé dans :mod:`picarones.extras.historical.abbreviations`.
2
 
3
+ Phase B du chantier de refonte en 3 cercles (architecture-cercles.md).
4
+ Ce module philologique est désormais en Cercle 3 (``extras/``). L'alias
5
+ ici permet aux imports historiques (``from picarones.core.abbreviations
6
+ import ...``) de continuer à fonctionner sans modification.
7
 
8
+ Voir :doc:`docs/architecture-cercles.md` et l'extra
9
+ ``picarones[historical]`` du ``pyproject.toml``.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  """
11
 
12
+ from picarones.extras.historical.abbreviations import * # noqa: F401, F403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
+ import picarones.extras.historical.abbreviations as _module
15
+ __all__ = getattr(_module, "__all__", [
16
+ name for name in dir(_module) if not name.startswith("_")
17
+ ])
 
 
 
picarones/core/early_modern_typography.py CHANGED
@@ -1,342 +1,17 @@
1
- """Marqueurs typographiques de l'imprimé ancien (XVIᵉ-XVIIIᵉ).
2
 
3
- Sprint 58 Étape 3 / extension philologique du plan d'évolution
4
- 2026.
 
 
5
 
6
- Pourquoi ce module
7
- ------------------
8
- Les Sprints 56 (abréviations Capelli) et 57 (couverture MUFI) sont
9
- orientés **médiéval scribal**. Mais Picarones doit aussi servir
10
- les éditeurs d'**imprimés anciens** (XVIᵉ-XVIIIᵉ siècles), pour
11
- qui les marqueurs caractéristiques ne sont pas scribaux mais
12
- **typographiques** : ligatures composées (fi, fl, ff, ffi, ffl, ſt),
13
- s long (ſ), i sans point (ı), esperluette (&), tildes nasaux
14
- indiquant une abréviation (ã = an/am, õ = on/om).
15
-
16
- Distinction avec MUFI/abbreviations
17
- ------------------------------------
18
- - ``mufi.py`` (Sprint 57) : caractères médiévaux scribaux
19
- (Capelli + lettres þ ð ƿ + PUA MUFI).
20
- - ``abbreviations.py`` (Sprint 56) : signes d'abréviation latins
21
- scribaux médiévaux (ꝑ ꝓ ⁊ + tildes scribaux).
22
- - ``early_modern_typography.py`` (ce module) : marqueurs
23
- **typographiques** de la composition imprimée ancienne.
24
-
25
- Les ligatures fi et fl sont communes aux deux univers (médiéval et
26
- imprimé ancien) ; le choix du module à utiliser dépend du **corpus**
27
- et de l'angle d'analyse éditoriale, pas du caractère pris isolément.
28
-
29
- Catégorisation
30
- --------------
31
- Les marqueurs sont classés en cinq catégories pour permettre un
32
- breakdown éditorial :
33
-
34
- 1. ``ligatures`` : fi fl ff ffi ffl ſt
35
- 2. ``long_s`` : ſ
36
- 3. ``dotless_i`` : ı
37
- 4. ``ampersand`` : & (esperluette typographique)
38
- 5. ``nasal_tildes`` : ã õ ũ ñ ē ī (abréviation par tilde nasal)
39
-
40
- ``compute_early_modern_metrics`` retourne le taux de préservation
41
- par catégorie + global.
42
  """
43
 
44
- from __future__ import annotations
45
-
46
- import logging
47
- from difflib import SequenceMatcher
48
- from typing import Optional
49
-
50
- from picarones.core.metric_registry import register_metric
51
- from picarones.core.modules import ArtifactType
52
-
53
- logger = logging.getLogger(__name__)
54
-
55
-
56
- # ──────────────────────────────────────────────────────────────────────────
57
- # Marqueurs typographiques imprimé ancien
58
- # ──────────────────────────────────────────────────────────────────────────
59
-
60
- # Ligatures typographiques héritées de l'incunable (XVᵉ) et toujours
61
- # courantes jusqu'au XVIIIᵉ avant la normalisation typographique.
62
- LIGATURES: frozenset[str] = frozenset({
63
- "ff", # U+FB00 ff
64
- "fi", # U+FB01 fi
65
- "fl", # U+FB02 fl
66
- "ffi", # U+FB03 ffi
67
- "ffl", # U+FB04 ffl
68
- "ſt", # U+FB05 long s + t
69
- "st", # U+FB06 st
70
- })
71
-
72
- # S long : Latin Extended-A. Caractéristique de la typographie
73
- # antérieure à 1800.
74
- LONG_S: frozenset[str] = frozenset({"ſ"}) # U+017F
75
-
76
- # i sans point : utilisé en typographie ancienne, parfois confondu
77
- # avec un l ou un 1 par les OCR modernes.
78
- DOTLESS_I: frozenset[str] = frozenset({"ı"}) # U+0131
79
-
80
- # Esperluette typographique : "&" remplace fréquemment "et" dans
81
- # les imprimés ; sa préservation discrimine un OCR diplomatique
82
- # d'un OCR modernisant.
83
- AMPERSAND: frozenset[str] = frozenset({"&"})
84
-
85
- # Tildes nasaux : pré-composés (ñ ã ẽ ĩ õ ũ) ou séquences
86
- # lettre + U+0303 combinant. En imprimé ancien, ã = an/am abrégé,
87
- # õ = on/om, etc. Distinction avec les tildes scribaux médiévaux
88
- # (Sprint 56) : ici on cible les **pré-composés** ou séquences sur
89
- # des voyelles (le scribal médiéval cible plutôt p̃ q̃).
90
- NASAL_TILDE_PRECOMPOSED: frozenset[str] = frozenset({
91
- "ã", "Ã", # U+00E3 / U+00C3
92
- "ñ", "Ñ", # U+00F1 / U+00D1
93
- "õ", "Õ", # U+00F5 / U+00D5
94
- "ũ", "Ũ", # U+0169 / U+0168
95
- "ẽ", "Ẽ", # U+1EBD / U+1EBC
96
- "ĩ", "Ĩ", # U+0129 / U+0128
97
- })
98
-
99
- # Voyelles susceptibles de porter un tilde combinant pour former
100
- # un tilde nasal (couvre les écritures NFD non pré-composées).
101
- _NASAL_TILDE_VOWELS: frozenset[str] = frozenset(
102
- "aeiouAEIOU"
103
- )
104
- _COMBINING_TILDE = "̃"
105
-
106
-
107
- # Catégorisation : nom → set de caractères pré-composés ou séquences.
108
- _CATEGORIES: dict[str, frozenset[str]] = {
109
- "ligatures": LIGATURES,
110
- "long_s": LONG_S,
111
- "dotless_i": DOTLESS_I,
112
- "ampersand": AMPERSAND,
113
- "nasal_tildes": NASAL_TILDE_PRECOMPOSED,
114
- }
115
-
116
-
117
- # ─────────────���────────────────────────────────────────────────────────────
118
- # Détection des marqueurs dans la GT
119
- # ──────────────────────────────────────────────────────────────────────────
120
-
121
-
122
- def _detect_markers(text: str) -> list[tuple[int, str, str]]:
123
- """Retourne les positions des marqueurs typographiques dans
124
- ``text``.
125
-
126
- Forme de sortie : ``[(index, marker, category), ...]`` dans
127
- l'ordre d'apparition. Pour les tildes nasaux non
128
- pré-composés, on détecte les séquences ``voyelle + U+0303`` et
129
- on retourne l'index de la voyelle.
130
- """
131
- if not text:
132
- return []
133
- found: list[tuple[int, str, str]] = []
134
- i = 0
135
- while i < len(text):
136
- ch = text[i]
137
- # Cas 1 : marqueur pré-composé dans une catégorie
138
- category = _category_of_char(ch)
139
- if category is not None:
140
- found.append((i, ch, category))
141
- i += 1
142
- continue
143
- # Cas 2 : voyelle + tilde combinant → nasal_tildes
144
- if (
145
- ch in _NASAL_TILDE_VOWELS
146
- and i + 1 < len(text)
147
- and text[i + 1] == _COMBINING_TILDE
148
- ):
149
- seq = ch + _COMBINING_TILDE
150
- found.append((i, seq, "nasal_tildes"))
151
- i += 2
152
- continue
153
- i += 1
154
- return found
155
-
156
-
157
- def _category_of_char(ch: str) -> Optional[str]:
158
- """Retourne la catégorie d'un caractère typographique ou
159
- ``None`` s'il n'est pas reconnu."""
160
- for cat, chars in _CATEGORIES.items():
161
- if ch in chars:
162
- return cat
163
- return None
164
-
165
-
166
- # ──────────────────────────────────────────────────────────────────────────
167
- # Calcul de la préservation par catégorie
168
- # ──────────────────────────────────────────────────────────────────────────
169
-
170
-
171
- def compute_early_modern_metrics(
172
- reference: Optional[str],
173
- hypothesis: Optional[str],
174
- ) -> dict:
175
- """Mesure la préservation des marqueurs typographiques de
176
- l'imprimé ancien dans l'OCR.
177
-
178
- Stratégie d'alignement
179
- ----------------------
180
- Pour chaque marqueur identifié dans la GT à la position ``i``,
181
- on vérifie si l'OCR l'a préservé en utilisant l'alignement
182
- caractère par caractère via ``difflib.SequenceMatcher`` (même
183
- méthode que les Sprints 55/57) :
184
-
185
- - Marqueur **mono-caractère** (fi, ſ, ı, &, ã…) : la position
186
- ``i`` est-elle dans un opcode ``equal`` ?
187
- - Marqueur **bi-caractère** (voyelle + U+0303) : les positions
188
- ``i`` et ``i+1`` sont-elles toutes deux dans un opcode
189
- ``equal`` ?
190
-
191
- Returns
192
- -------
193
- dict
194
- ``{
195
- "n_markers_reference": int,
196
- "n_markers_preserved": int,
197
- "global_preservation": float, # ∈ [0, 1]
198
- "per_category": {
199
- category: {"total", "preserved", "preservation"}
200
- },
201
- "missed_markers": [{"index", "marker", "category"}, ...],
202
- }``
203
-
204
- Cas dégénérés : GT vide ou sans marqueur → tous compteurs à 0,
205
- ``global_preservation = 0``.
206
- """
207
- ref = reference or ""
208
- hyp = hypothesis or ""
209
-
210
- # Forme NFD pour reconnaître les tildes nasaux décomposés (ã =
211
- # 'a' + U+0303) côté GT — on conserve toutefois la forme passée
212
- # pour les indices rapportés dans missed_markers.
213
- markers = _detect_markers(ref)
214
- n_total = len(markers)
215
-
216
- if n_total == 0:
217
- return {
218
- "n_markers_reference": 0,
219
- "n_markers_preserved": 0,
220
- "global_preservation": 0.0,
221
- "per_category": {},
222
- "missed_markers": [],
223
- }
224
-
225
- # Aligner GT/hyp et récupérer le set des positions GT couvertes
226
- # par un opcode "equal".
227
- matcher = SequenceMatcher(a=ref, b=hyp, autojunk=False)
228
- correct_positions: set[int] = set()
229
- for op, i1, i2, _j1, _j2 in matcher.get_opcodes():
230
- if op == "equal":
231
- correct_positions.update(range(i1, i2))
232
-
233
- per_cat_total: dict[str, int] = {}
234
- per_cat_preserved: dict[str, int] = {}
235
- n_preserved = 0
236
- missed: list[dict] = []
237
-
238
- for index, marker, category in markers:
239
- per_cat_total[category] = per_cat_total.get(category, 0) + 1
240
- # Marqueur préservé si toutes ses positions GT sont dans
241
- # un opcode "equal".
242
- marker_len = len(marker)
243
- positions_ok = all(
244
- (index + k) in correct_positions for k in range(marker_len)
245
- )
246
- if positions_ok:
247
- per_cat_preserved[category] = (
248
- per_cat_preserved.get(category, 0) + 1
249
- )
250
- n_preserved += 1
251
- else:
252
- missed.append({
253
- "index": index,
254
- "marker": marker,
255
- "category": category,
256
- })
257
-
258
- per_category = {
259
- cat: {
260
- "total": per_cat_total[cat],
261
- "preserved": per_cat_preserved.get(cat, 0),
262
- "preservation": (
263
- per_cat_preserved.get(cat, 0) / per_cat_total[cat]
264
- if per_cat_total[cat] > 0
265
- else 0.0
266
- ),
267
- }
268
- for cat in sorted(per_cat_total)
269
- }
270
-
271
- return {
272
- "n_markers_reference": n_total,
273
- "n_markers_preserved": n_preserved,
274
- "global_preservation": n_preserved / n_total,
275
- "per_category": per_category,
276
- "missed_markers": missed,
277
- }
278
-
279
-
280
- def early_modern_preservation(
281
- reference: Optional[str], hypothesis: Optional[str],
282
- ) -> float:
283
- """Raccourci : taux global de préservation des marqueurs
284
- typographiques de l'imprimé ancien."""
285
- return compute_early_modern_metrics(
286
- reference, hypothesis,
287
- )["global_preservation"]
288
-
289
-
290
- # ──────────────────────────────────────────────────────────────────────────
291
- # Helpers exposés
292
- # ──────────────────────────────────────────────────────────────────────────
293
-
294
-
295
- def detect_markers(text: Optional[str]) -> list[tuple[int, str, str]]:
296
- """Wrapper public sur ``_detect_markers`` (acceptant ``None``)."""
297
- return _detect_markers(text or "")
298
-
299
-
300
- def get_category(char: str) -> Optional[str]:
301
- """Retourne la catégorie typographique d'un caractère
302
- (``ligatures``, ``long_s``, ``dotless_i``, ``ampersand``,
303
- ``nasal_tildes``) ou ``None``.
304
-
305
- Pour un tilde combinant suivi d'une voyelle, l'utilisateur doit
306
- utiliser ``detect_markers`` qui gère les séquences.
307
- """
308
- return _category_of_char(char[0]) if char else None
309
-
310
-
311
- # ──────────────────────────────────────────────────────────────────────────
312
- # Enregistrement dans le registre typé (Sprint 34)
313
- # ──────────────────────────────────────────────────────────────────────────
314
-
315
-
316
- @register_metric(
317
- name="early_modern_preservation",
318
- input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
319
- description=(
320
- "Taux de préservation des marqueurs typographiques de "
321
- "l'imprimé ancien (XVIᵉ-XVIIIᵉ) : ligatures fi fl ff, s long ſ, "
322
- "i sans point ı, esperluette &, tildes nasaux ã õ. Critère "
323
- "éditorial pour les éditions diplomatiques d'imprimés anciens."
324
- ),
325
- higher_is_better=True,
326
- tags={"text", "typography", "early_modern", "philology"},
327
- )
328
- def _registered_early_modern(reference: str, hypothesis: str) -> float:
329
- return early_modern_preservation(reference, hypothesis)
330
-
331
 
332
- __all__ = [
333
- "LIGATURES",
334
- "LONG_S",
335
- "DOTLESS_I",
336
- "AMPERSAND",
337
- "NASAL_TILDE_PRECOMPOSED",
338
- "detect_markers",
339
- "get_category",
340
- "compute_early_modern_metrics",
341
- "early_modern_preservation",
342
- ]
 
1
+ """Alias rétrocompat module déplacé dans :mod:`picarones.extras.historical.early_modern_typography`.
2
 
3
+ Phase B du chantier de refonte en 3 cercles (architecture-cercles.md).
4
+ Ce module philologique est désormais en Cercle 3 (``extras/``). L'alias
5
+ ici permet aux imports historiques (``from picarones.core.early_modern_typography
6
+ import ...``) de continuer à fonctionner sans modification.
7
 
8
+ Voir :doc:`docs/architecture-cercles.md` et l'extra
9
+ ``picarones[historical]`` du ``pyproject.toml``.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  """
11
 
12
+ from picarones.extras.historical.early_modern_typography import * # noqa: F401, F403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
+ import picarones.extras.historical.early_modern_typography as _module
15
+ __all__ = getattr(_module, "__all__", [
16
+ name for name in dir(_module) if not name.startswith("_")
17
+ ])
 
 
 
 
 
 
 
picarones/core/lexical_modernization.py CHANGED
@@ -1,263 +1,17 @@
1
- """Détection de la sur-normalisation lexicale par les LLM/VLM —
2
- Sprint 80 (A.I.7).
3
 
4
- Sprint 80 A.I.7 du plan d'évolution 2026.
 
 
 
5
 
6
- Pourquoi ce module
7
- ------------------
8
- Le détecteur ``llm_hallucination_flag`` (Sprint 19) signale qu'un
9
- moteur sur-normalise (« 0,05 % »). Mais ce score agrégé ne dit
10
- rien sur **quoi** corriger dans le prompt. Ce module produit
11
- une **table de fréquences détaillée** :
12
-
13
- +----------------------+--------------------+------+----------+
14
- | Forme historique GT | Forme modernisée | n GT | % modern |
15
- +======================+====================+======+==========+
16
- | maistre | maître | 47 | 85 % |
17
- | nostre | nostre | 92 | 8 % |
18
- | veoir | voir | 23 | 100 % |
19
- +----------------------+--------------------+------+----------+
20
-
21
- Lecture immédiate : *« le LLM modernise systématiquement
22
- maistre → maître ; pour préserver l'orthographe historique, ajouter
23
- au prompt "ne pas moderniser maistre, nostre, veoir" »*.
24
-
25
- Méthode
26
- -------
27
- Alignement mot-à-mot via ``difflib.SequenceMatcher``. Chaque
28
- ``replace`` ou ``equal`` produit une paire ``(gt_token,
29
- hyp_token)``. On accumule pour chaque ``gt_token`` :
30
-
31
- - ``n_total`` : nombre d'occurrences du token dans la GT
32
- - ``n_modernized`` : nombre d'occurrences où ``hyp_token != gt_token``
33
- - ``variants`` : dict des hyp_tokens observés avec leur count
34
-
35
- Stop-list
36
- ---------
37
- L'utilisateur peut passer ``stop_list`` (ensemble de tokens GT à
38
- ignorer). Par défaut, vide — le module ne tente pas de deviner ce
39
- qui est « moderne » ou « historique », c'est au chercheur de
40
- fournir le filtre adapté à son corpus.
41
-
42
- Sortie
43
- ------
44
- ``compute_lexical_modernization`` retourne une structure adaptée
45
- au rendu HTML. ``aggregate_lexical_modernization`` agrège
46
- plusieurs documents.
47
-
48
- Limites documentées
49
- -------------------
50
- - Tokenisation au niveau mot (split sur espace) — cohérent avec
51
- ``taxonomy.py`` et autres modules. Pas de stemming ni de
52
- lemmatisation.
53
- - La métrique mesure la **réécriture lexicale** ; elle n'attrape
54
- pas les modernisations infra-mot (perte du s long ſ qui se
55
- fond dans la même forme). Pour ça, voir ``early_modern_typography``
56
- (Sprint 58) et ``equivalence_profile`` (Sprint 78).
57
  """
58
 
59
- from __future__ import annotations
60
-
61
- import difflib
62
- import logging
63
- from typing import Iterable, Optional
64
-
65
- logger = logging.getLogger(__name__)
66
-
67
-
68
- def _split_words(text: Optional[str]) -> list[str]:
69
- """Tokenisation simple par split sur whitespace."""
70
- if not text:
71
- return []
72
- return text.split()
73
-
74
-
75
- def compute_lexical_modernization(
76
- reference: Optional[str],
77
- hypothesis: Optional[str],
78
- *,
79
- stop_list: Optional[Iterable[str]] = None,
80
- case_sensitive: bool = False,
81
- ) -> dict:
82
- """Calcule le tableau de modernisation lexicale pour un document.
83
-
84
- Returns
85
- -------
86
- dict
87
- ``{
88
- "n_gt_tokens": int,
89
- "tokens": {
90
- gt_token: {
91
- "n_total": int,
92
- "n_modernized": int,
93
- "rate_modernized": float, # ∈ [0, 1]
94
- "variants": {hyp_token: count, ...},
95
- },
96
- ...
97
- },
98
- }``
99
- Si ``reference`` est vide → ``tokens == {}``.
100
- """
101
- ref_tokens = _split_words(reference)
102
- hyp_tokens = _split_words(hypothesis)
103
- if not ref_tokens:
104
- return {"n_gt_tokens": 0, "tokens": {}}
105
-
106
- if not case_sensitive:
107
- ref_for_match = [t.lower() for t in ref_tokens]
108
- hyp_for_match = [t.lower() for t in hyp_tokens]
109
- else:
110
- ref_for_match = ref_tokens
111
- hyp_for_match = hyp_tokens
112
-
113
- stop = frozenset(
114
- (t.lower() if not case_sensitive else t)
115
- for t in (stop_list or [])
116
- )
117
-
118
- # On accumule par gt_token (forme display = forme originale,
119
- # match key = forme casée selon ``case_sensitive``).
120
- tokens_data: dict[str, dict] = {}
121
-
122
- matcher = difflib.SequenceMatcher(
123
- None, ref_for_match, hyp_for_match, autojunk=False,
124
- )
125
- for tag, i1, i2, j1, j2 in matcher.get_opcodes():
126
- if tag == "equal":
127
- for k in range(i2 - i1):
128
- gt_orig = ref_tokens[i1 + k]
129
- gt_match = ref_for_match[i1 + k]
130
- if gt_match in stop:
131
- continue
132
- slot = tokens_data.setdefault(
133
- gt_orig,
134
- {"n_total": 0, "n_modernized": 0, "variants": {}},
135
- )
136
- slot["n_total"] += 1
137
- elif tag == "replace":
138
- # Apparier 1-à-1 quand possible
139
- paired = min(i2 - i1, j2 - j1)
140
- for k in range(paired):
141
- gt_orig = ref_tokens[i1 + k]
142
- gt_match = ref_for_match[i1 + k]
143
- if gt_match in stop:
144
- continue
145
- hyp_orig = hyp_tokens[j1 + k]
146
- slot = tokens_data.setdefault(
147
- gt_orig,
148
- {"n_total": 0, "n_modernized": 0, "variants": {}},
149
- )
150
- slot["n_total"] += 1
151
- slot["n_modernized"] += 1
152
- slot["variants"][hyp_orig] = slot["variants"].get(hyp_orig, 0) + 1
153
- # Si plus de gt que de hyp, le reste des gt_tokens est
154
- # « perdu » — on les compte comme totaux mais pas comme
155
- # modernisés (on ne sait pas en quoi).
156
- for k in range(paired, i2 - i1):
157
- gt_orig = ref_tokens[i1 + k]
158
- gt_match = ref_for_match[i1 + k]
159
- if gt_match in stop:
160
- continue
161
- slot = tokens_data.setdefault(
162
- gt_orig,
163
- {"n_total": 0, "n_modernized": 0, "variants": {}},
164
- )
165
- slot["n_total"] += 1
166
- slot["n_modernized"] += 1
167
- slot["variants"]["∅"] = slot["variants"].get("∅", 0) + 1
168
- elif tag == "delete":
169
- # gt présent, pas en hyp → modernisation par
170
- # suppression (ou perte pure)
171
- for k in range(i2 - i1):
172
- gt_orig = ref_tokens[i1 + k]
173
- gt_match = ref_for_match[i1 + k]
174
- if gt_match in stop:
175
- continue
176
- slot = tokens_data.setdefault(
177
- gt_orig,
178
- {"n_total": 0, "n_modernized": 0, "variants": {}},
179
- )
180
- slot["n_total"] += 1
181
- slot["n_modernized"] += 1
182
- slot["variants"]["∅"] = slot["variants"].get("∅", 0) + 1
183
-
184
- # Calcul du taux par token
185
- for slot in tokens_data.values():
186
- total = slot["n_total"]
187
- slot["rate_modernized"] = (
188
- slot["n_modernized"] / total if total > 0 else 0.0
189
- )
190
-
191
- return {
192
- "n_gt_tokens": len(ref_tokens),
193
- "tokens": tokens_data,
194
- }
195
-
196
-
197
- def aggregate_lexical_modernization(
198
- per_doc_results: Iterable[dict],
199
- ) -> dict:
200
- """Agrège des ``compute_lexical_modernization`` per-doc.
201
-
202
- Renvoie la structure agrégée corpus-wide avec la même forme
203
- que ``compute_lexical_modernization``.
204
- """
205
- agg_tokens: dict[str, dict] = {}
206
- n_gt_total = 0
207
- for doc_result in per_doc_results:
208
- if not doc_result:
209
- continue
210
- n_gt_total += doc_result.get("n_gt_tokens", 0)
211
- for gt, data in (doc_result.get("tokens") or {}).items():
212
- slot = agg_tokens.setdefault(
213
- gt, {"n_total": 0, "n_modernized": 0, "variants": {}},
214
- )
215
- slot["n_total"] += data.get("n_total", 0)
216
- slot["n_modernized"] += data.get("n_modernized", 0)
217
- for hyp_t, count in (data.get("variants") or {}).items():
218
- slot["variants"][hyp_t] = slot["variants"].get(hyp_t, 0) + count
219
-
220
- for slot in agg_tokens.values():
221
- total = slot["n_total"]
222
- slot["rate_modernized"] = (
223
- slot["n_modernized"] / total if total > 0 else 0.0
224
- )
225
- return {
226
- "n_gt_tokens": n_gt_total,
227
- "tokens": agg_tokens,
228
- }
229
-
230
-
231
- def top_modernized_tokens(
232
- data: dict,
233
- *,
234
- n: int = 20,
235
- min_total: int = 1,
236
- ) -> list[tuple[str, dict]]:
237
- """Top-N tokens GT par taux de modernisation.
238
-
239
- Filtre les tokens dont ``n_total < min_total`` (anecdotiques).
240
- Tri par ``rate_modernized`` décroissant, tie-break par
241
- ``n_total`` décroissant.
242
- """
243
- tokens = data.get("tokens") or {}
244
- candidates = [
245
- (gt, slot) for gt, slot in tokens.items()
246
- if slot.get("n_total", 0) >= min_total
247
- and slot.get("n_modernized", 0) > 0
248
- ]
249
- candidates.sort(
250
- key=lambda pair: (
251
- -pair[1].get("rate_modernized", 0.0),
252
- -pair[1].get("n_total", 0),
253
- pair[0],
254
- ),
255
- )
256
- return candidates[:n]
257
-
258
 
259
- __all__ = [
260
- "compute_lexical_modernization",
261
- "aggregate_lexical_modernization",
262
- "top_modernized_tokens",
263
- ]
 
1
+ """Alias rétrocompat module déplacé dans :mod:`picarones.extras.historical.lexical_modernization`.
 
2
 
3
+ Phase B du chantier de refonte en 3 cercles (architecture-cercles.md).
4
+ Ce module philologique est désormais en Cercle 3 (``extras/``). L'alias
5
+ ici permet aux imports historiques (``from picarones.core.lexical_modernization
6
+ import ...``) de continuer à fonctionner sans modification.
7
 
8
+ Voir :doc:`docs/architecture-cercles.md` et l'extra
9
+ ``picarones[historical]`` du ``pyproject.toml``.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  """
11
 
12
+ from picarones.extras.historical.lexical_modernization import * # noqa: F401, F403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
+ import picarones.extras.historical.lexical_modernization as _module
15
+ __all__ = getattr(_module, "__all__", [
16
+ name for name in dir(_module) if not name.startswith("_")
17
+ ])
 
picarones/core/modern_archives.py CHANGED
@@ -1,600 +1,17 @@
1
- """Marqueurs typographiques et abréviations des archives modernes
2
- (XIXᵉ-XXᵉ siècles) — Sprint 59.
3
 
4
- Sprint 59 Étape 3 / extension philologique du plan d'évolution
5
- 2026.
 
 
6
 
7
- Pourquoi ce module
8
- ------------------
9
- Les Sprints 56-57 sont orientés **médiéval scribal** (Capelli, MUFI),
10
- le Sprint 58 cible l'**imprimé ancien** XVIᵉ-XVIIIᵉ. Ce sprint étend
11
- la couverture aux **archives modernes** (XIXᵉ-XXᵉ), période où la
12
- typographie historique a disparu mais où subsistent des conventions
13
- d'abréviation propres aux corpus institutionnels (état civil,
14
- recensements, presse, monographies, archives militaires).
15
-
16
- Distinction avec les modules précédents
17
- ---------------------------------------
18
- - ``mufi.py`` (Sprint 57) : caractères médiévaux scribaux.
19
- - ``abbreviations.py`` (Sprint 56) : signes scribaux médiévaux.
20
- - ``early_modern_typography.py`` (Sprint 58) : marqueurs
21
- typographiques imprimé ancien (fi ſ ı &…).
22
- - ``modern_archives.py`` (ce module) : abréviations et conventions
23
- de l'archive moderne XIXᵉ-XXᵉ.
24
-
25
- Catégories
26
- ----------
27
- 1. ``civility_titles`` : Mme, M., Mlle, Mgr, Dr, Pr, Me, R.P., S.M.,
28
- S.A.R., S.E., S.S.
29
- 2. ``ordinals`` : 1ᵉʳ, 1ʳᵉ, 2ᵉ, 2ᵈ, Vᵉ (avec exposants Unicode)
30
- 3. ``currency`` : ₶ (livre tournois), ₣ ƒ (franc), £, l. s. d.
31
- (livre/sol/denier d'Ancien Régime)
32
- 4. ``administrative`` : arr., dép., cant., com., reg., prov.
33
- 5. ``civil_status`` : °, †, ✶, ⚭, ép., vve
34
- 6. ``typographic_punctuation`` : « », –, —, …, ’
35
- 7. ``latin_abbr_modern`` : e.g., i.e., etc., cf., ibid., op. cit.,
36
- ad lib.
37
- 8. ``bibliographic`` : vol., t., p., pp., n°, fasc., éd., ms.,
38
- r°, v°
39
- 9. ``address`` : bd, av., r., pl., imp., fbg
40
-
41
- Sortie
42
- ------
43
- ``compute_modern_archives_metrics(ref, hyp)`` retourne deux scores
44
- par catégorie (pattern Sprint 56) :
45
-
46
- - ``strict_score`` : forme abrégée préservée telle quelle ;
47
- - ``expansion_score`` : forme abrégée OU forme développée présente.
48
-
49
- Le **ratio strict/expansion** par catégorie permet au chercheur de
50
- juger lui-même la convention adoptée par chaque moteur, sans
51
- classification automatique imposée par le module.
52
-
53
- Stratégie de découpage
54
- ----------------------
55
- Cohérente avec NER (38), Flesch (52), Reading order F1 (53),
56
- Layout F1 (54), Bloc Unicode (55), Abréviations (56), MUFI (57),
57
- Imprimé ancien (58) : couche de calcul pure d'abord ; câblage
58
- runner et HTML dans des sprints dédiés.
59
  """
60
 
61
- from __future__ import annotations
62
-
63
- import logging
64
- import re
65
- from typing import Optional
66
-
67
- from picarones.core.metric_registry import register_metric
68
- from picarones.core.modules import ArtifactType
69
-
70
- logger = logging.getLogger(__name__)
71
-
72
-
73
- # ──────────────────────────────────────────────────────────────────────────
74
- # Tables d'abréviations par catégorie
75
- # ──────────────────────────────────────────────────────────────────────────
76
- #
77
- # Format : tuple ``(marker, expansions, regex_strict_pattern_or_None)``
78
- # où :
79
- # - ``marker`` : forme abrégée canonique (str)
80
- # - ``expansions`` : tuple de formes développées
81
- # acceptées (insensible à la casse)
82
- # - ``regex_strict_pattern`` : pattern Python regex pour la
83
- # détection dans la GT. ``None``
84
- # = on dérive automatiquement
85
- # ``\b<marker_escaped>\b`` (avec
86
- # garde-fou sur les abréviations
87
- # contenant un point).
88
- #
89
- # Détection : pour les abréviations contenant un ``.`` (« M. »),
90
- # on n'utilise pas ``\b`` standard car « M.\b » match dans
91
- # « M.A. » (le ``.`` étant non-mot, ``\b`` est satisfait). On
92
- # exige donc explicitement une frontière espace/début/fin/
93
- # ponctuation après le point.
94
-
95
- CIVILITY_TITLES: tuple[tuple[str, tuple[str, ...]], ...] = (
96
- ("Mme", ("Madame",)),
97
- ("Mlle", ("Mademoiselle",)),
98
- ("Mgr", ("Monseigneur",)),
99
- ("Dr", ("Docteur",)),
100
- ("Pr", ("Professeur",)),
101
- ("Me", ("Maître",)),
102
- ("M.", ("Monsieur",)),
103
- ("R.P.", ("Révérend Père",)),
104
- ("S.M.", ("Sa Majesté",)),
105
- ("S.A.R.", ("Son Altesse Royale",)),
106
- ("S.E.", ("Son Excellence",)),
107
- ("S.S.", ("Sa Sainteté",)),
108
- )
109
-
110
- # Ordinaux : la forme **strict** porte l'exposant Unicode
111
- # (1ᵉʳ U+1D49 U+02B3, 1ʳᵉ, 2ᵈ, 2ᵉ, 3ᵉ…) ; la forme **expansion**
112
- # accepte la version plate (« 1er », « 1re », « 2nd ») ou la forme
113
- # textuelle (« premier », « première »).
114
- #
115
- # On définit chaque ordinal explicitement (1-12 + Vᵉ pour les
116
- # numéraux romains de siècle). Au-delà, l'exposant ᵉ seul couvre
117
- # les usages courants (3ᵉ, 4ᵉ, 5ᵉ, 6ᵉ, 7ᵉ, 8ᵉ, 9ᵉ, 10ᵉ).
118
-
119
- ORDINALS: tuple[tuple[str, tuple[str, ...]], ...] = (
120
- ("1ᵉʳ", ("1er", "premier")),
121
- ("1ʳᵉ", ("1re", "première", "premiere")),
122
- ("2ᵈ", ("2d", "second")),
123
- ("2ᵈᵉ", ("2de", "seconde")),
124
- ("2ᵉ", ("2e", "deuxième", "deuxieme")),
125
- ("3ᵉ", ("3e", "troisième", "troisieme")),
126
- ("Iᵉʳ", ("Ier", "premier")),
127
- ("Vᵉ", ("Ve", "cinquième", "cinquieme")),
128
- ("XIᵉ", ("XIe", "onzième", "onzieme")),
129
- ("XIIᵉ", ("XIIe", "douzième", "douzieme")),
130
- ("XVIᵉ", ("XVIe", "seizième", "seizieme")),
131
- ("XVIIᵉ", ("XVIIe", "dix-septième", "dix-septieme")),
132
- ("XVIIIᵉ", ("XVIIIe", "dix-huitième", "dix-huitieme")),
133
- ("XIXᵉ", ("XIXe", "dix-neuvième", "dix-neuvieme")),
134
- ("XXᵉ", ("XXe", "vingtième", "vingtieme")),
135
- )
136
-
137
- CURRENCY: tuple[tuple[str, tuple[str, ...]], ...] = (
138
- ("₶", ("livre tournois", "livres tournois")),
139
- ("₣", ("franc", "francs")),
140
- ("ƒ", ("florin", "florins")),
141
- ("£", ("livre", "livres", "pound", "pounds")),
142
- ("l.", ("livre", "livres")),
143
- ("s.", ("sol", "sols", "sou", "sous")),
144
- ("d.", ("denier", "deniers")),
145
- )
146
-
147
- ADMINISTRATIVE: tuple[tuple[str, tuple[str, ...]], ...] = (
148
- ("arr.", ("arrondissement",)),
149
- ("dép.", ("département", "departement")),
150
- ("cant.", ("canton",)),
151
- ("com.", ("commune",)),
152
- ("reg.", ("régiment", "regiment")),
153
- ("prov.", ("province",)),
154
- )
155
-
156
- # État civil : signes typographiques (° = né, † = mort, ⚭ = marié)
157
- # et abréviations textuelles (ép. = épouse/époux, vve = veuve).
158
- CIVIL_STATUS: tuple[tuple[str, tuple[str, ...]], ...] = (
159
- ("°", ("né", "née")),
160
- ("†", ("mort", "morte", "décédé", "décédée")),
161
- ("✶", ("naissance",)),
162
- ("⚭", ("marié", "mariée", "épousa", "epousa")),
163
- ("ép.", ("épouse", "époux", "epouse", "epoux")),
164
- ("vve", ("veuve",)),
165
- )
166
-
167
- # Ponctuation typographique : ces marqueurs sont préservés en
168
- # diplomatique et remplacés par leur équivalent ASCII en
169
- # modernisant. L'expansion n'est pas une « expansion » au sens
170
- # linguistique mais un substitut typographique.
171
- TYPOGRAPHIC_PUNCTUATION: tuple[tuple[str, tuple[str, ...]], ...] = (
172
- ("«", ('"',)),
173
- ("»", ('"',)),
174
- ("—", ("-", "--")),
175
- ("–", ("-",)),
176
- ("…", ("...",)),
177
- ("’", ("'",)),
178
- ("‘", ("'",)),
179
- )
180
-
181
- LATIN_ABBR_MODERN: tuple[tuple[str, tuple[str, ...]], ...] = (
182
- ("e.g.", ("for example", "par exemple", "exempli gratia")),
183
- ("i.e.", ("c'est-à-dire", "id est", "that is")),
184
- ("etc.", ("et cetera", "et caetera")),
185
- ("cf.", ("confer", "voir")),
186
- ("ibid.", ("ibidem",)),
187
- ("op. cit.", ("opere citato", "opus citatum")),
188
- ("ad lib.", ("ad libitum",)),
189
- ("N.B.", ("nota bene",)),
190
- )
191
-
192
- BIBLIOGRAPHIC: tuple[tuple[str, tuple[str, ...]], ...] = (
193
- ("vol.", ("volume",)),
194
- ("t.", ("tome",)),
195
- ("p.", ("page",)),
196
- ("pp.", ("pages",)),
197
- ("n°", ("numéro", "numero", "no")),
198
- ("fasc.", ("fascicule",)),
199
- ("éd.", ("édition", "edition")),
200
- ("ms.", ("manuscrit",)),
201
- ("f.", ("folio",)),
202
- ("r°", ("recto",)),
203
- ("v°", ("verso",)),
204
- )
205
-
206
- ADDRESS: tuple[tuple[str, tuple[str, ...]], ...] = (
207
- ("bd", ("boulevard",)),
208
- ("av.", ("avenue",)),
209
- ("r.", ("rue",)),
210
- ("pl.", ("place",)),
211
- ("imp.", ("impasse",)),
212
- ("fbg", ("faubourg",)),
213
- )
214
-
215
-
216
- # ──────────────────────────────────────────────────────────────────────────
217
- # Indexation par catégorie
218
- # ──────────────────────────────────────────────────────────────────────────
219
-
220
- _CATEGORIES: dict[str, tuple[tuple[str, tuple[str, ...]], ...]] = {
221
- "civility_titles": CIVILITY_TITLES,
222
- "ordinals": ORDINALS,
223
- "currency": CURRENCY,
224
- "administrative": ADMINISTRATIVE,
225
- "civil_status": CIVIL_STATUS,
226
- "typographic_punctuation": TYPOGRAPHIC_PUNCTUATION,
227
- "latin_abbr_modern": LATIN_ABBR_MODERN,
228
- "bibliographic": BIBLIOGRAPHIC,
229
- "address": ADDRESS,
230
- }
231
-
232
- # Liste plate de tous les marqueurs avec leur catégorie. Triée par
233
- # longueur décroissante pour que la détection préfère le marqueur
234
- # le plus long quand plusieurs préfixes matchent (ex. « S.A.R. »
235
- # avant « S.A. ").
236
- _ALL_MARKERS: list[tuple[str, tuple[str, ...], str]] = sorted(
237
- [
238
- (marker, expansions, category)
239
- for category, entries in _CATEGORIES.items()
240
- for marker, expansions in entries
241
- ],
242
- key=lambda triple: -len(triple[0]),
243
- )
244
-
245
-
246
- # ──────────────────────────────────────────────────────────────────────────
247
- # Compilation des patterns regex
248
- # ──────────────────────────────────────────────────────────────────────────
249
- #
250
- # Pour chaque marqueur, on compile un pattern qui exige une
251
- # frontière de mot adaptée :
252
- #
253
- # - Marqueur alphabétique seul (« Mme », « bd ») → ``\b<marker>\b``
254
- # (le ``\b`` Python gère correctement les bords).
255
- # - Marqueur contenant un point (« M. », « S.A.R. », « arr. »,
256
- # « r° », « n° ») → frontière espace/début/fin/ponctuation
257
- # explicite (le ``.`` final étant non-mot, ``\b`` standard
258
- # matcherait dans « arr.acher »).
259
- # - Marqueur contenant un caractère non ASCII (exposant, monnaie,
260
- # guillemet, croix d'état civil) → match littéral, pas de
261
- # frontière de mot car ``\b`` ne fonctionne pas sur les
262
- # caractères non-mot Unicode.
263
- #
264
- # La frontière de droite après un point exige soit la fin de
265
- # chaîne, soit un blanc, soit une ponctuation usuelle (« , ; : ! ? )
266
- # … » »).
267
-
268
- _TRAILING_BOUNDARY = r"(?=$|[\s,;:!?\)\]\»\"\'\n\r\t…])"
269
- _LEADING_BOUNDARY = r"(?:^|(?<=[\s,;:!?\(\[\«\"\'\n\r\t]))"
270
-
271
-
272
- def _is_alphanumeric_only(text: str) -> bool:
273
- """Vrai si tous les caractères sont alphanumériques ASCII."""
274
- return all(c.isascii() and c.isalnum() for c in text)
275
-
276
-
277
- def _compile_pattern(marker: str) -> re.Pattern[str]:
278
- """Compile le pattern regex pour la détection d'un marqueur
279
- dans la GT et l'hypothèse.
280
-
281
- La logique de frontière de mot dépend de la composition du
282
- marqueur (cf. commentaire principal).
283
- """
284
- escaped = re.escape(marker)
285
- if "." in marker:
286
- # Frontière explicite après le point final.
287
- return re.compile(_LEADING_BOUNDARY + escaped + _TRAILING_BOUNDARY)
288
- if _is_alphanumeric_only(marker):
289
- return re.compile(r"\b" + escaped + r"\b")
290
- # Marqueurs Unicode (exposants, monnaies, guillemets, ponctuation
291
- # typographique, croix) : match littéral, pas de \b.
292
- return re.compile(escaped)
293
-
294
-
295
- # Cache des patterns compilés : (marker, category) → pattern.
296
- _PATTERNS: dict[tuple[str, str], re.Pattern[str]] = {
297
- (marker, category): _compile_pattern(marker)
298
- for marker, _expansions, category in _ALL_MARKERS
299
- }
300
-
301
- # Patterns d'expansion (insensibles à la casse, frontière de mot
302
- # si la forme développée est purement alphabétique).
303
- _EXPANSION_PATTERNS: dict[str, list[re.Pattern[str]]] = {}
304
- for marker, expansions, _category in _ALL_MARKERS:
305
- compiled: list[re.Pattern[str]] = []
306
- for exp in expansions:
307
- escaped = re.escape(exp)
308
- if exp and _is_alphanumeric_only(exp):
309
- compiled.append(re.compile(r"\b" + escaped + r"\b", re.IGNORECASE))
310
- else:
311
- compiled.append(re.compile(escaped, re.IGNORECASE))
312
- _EXPANSION_PATTERNS[marker] = compiled
313
-
314
-
315
- # ──────────────────────────────────────────────────────────────────────────
316
- # API publique : catégorisation + détection
317
- # ──────────────────────────────────────────────────────────────────────────
318
-
319
-
320
- def get_category(marker: str) -> Optional[str]:
321
- """Retourne la catégorie d'un marqueur ou ``None`` si inconnu.
322
-
323
- La comparaison est exacte (sensible à la casse, aux exposants
324
- Unicode et aux points).
325
- """
326
- if not marker:
327
- return None
328
- for category, entries in _CATEGORIES.items():
329
- for known, _expansions in entries:
330
- if known == marker:
331
- return category
332
- return None
333
-
334
-
335
- def get_expansions(marker: str) -> tuple[str, ...]:
336
- """Retourne les formes développées connues pour un marqueur,
337
- ou un tuple vide si inconnu."""
338
- if not marker:
339
- return ()
340
- for _category, entries in _CATEGORIES.items():
341
- for known, expansions in entries:
342
- if known == marker:
343
- return expansions
344
- return ()
345
-
346
-
347
- def detect_modern_markers(
348
- text: Optional[str],
349
- ) -> list[tuple[int, str, str]]:
350
- """Retourne les marqueurs trouvés dans ``text``.
351
-
352
- Forme de sortie : ``[(index, marker, category), ...]`` triée
353
- par index croissant. Si plusieurs marqueurs se chevauchent, le
354
- plus long gagne (ex. « S.A.R. » plutôt que « S. " puis « A.R. »).
355
-
356
- Tolérance casse
357
- ---------------
358
- Les marqueurs alphabétiques courts (« Mme », « Dr », « bd »)
359
- sont matchés tels quels (sensibilité à la casse) — on n'élargit
360
- pas car « me » en minuscule n'est pas une abréviation de
361
- « Maître ».
362
- """
363
- if not text:
364
- return []
365
- # Collecte tous les matches de tous les marqueurs.
366
- candidates: list[tuple[int, int, str, str]] = [] # start, end, marker, cat
367
- for marker, _expansions, category in _ALL_MARKERS:
368
- pattern = _PATTERNS[(marker, category)]
369
- for match in pattern.finditer(text):
370
- candidates.append((match.start(), match.end(), marker, category))
371
- # Tri par (start, -length) pour appliquer une stratégie greedy
372
- # « plus long gagne » à chaque position.
373
- candidates.sort(key=lambda c: (c[0], -(c[1] - c[0])))
374
- chosen: list[tuple[int, str, str]] = []
375
- last_end = -1
376
- for start, end, marker, category in candidates:
377
- if start < last_end:
378
- continue
379
- chosen.append((start, marker, category))
380
- last_end = end
381
- return chosen
382
-
383
-
384
- # ──────────────────────────────────────────────────────────────────────────
385
- # Calcul des scores strict / expansion
386
- # ──────────────────────────────────────────────────────────────────────────
387
-
388
-
389
- def _hyp_contains_marker(
390
- hypothesis: str, marker: str, category: str,
391
- ) -> bool:
392
- """Vrai si le marqueur est présent (au moins une occurrence) dans
393
- l'hypothèse, avec la même règle de frontière qu'en GT."""
394
- pattern = _PATTERNS[(marker, category)]
395
- return pattern.search(hypothesis) is not None
396
-
397
-
398
- def _hyp_contains_expansion(hypothesis: str, marker: str) -> bool:
399
- """Vrai si une forme développée connue du marqueur est présente
400
- dans l'hypothèse (insensible à la casse)."""
401
- for pattern in _EXPANSION_PATTERNS.get(marker, ()):
402
- if pattern.search(hypothesis) is not None:
403
- return True
404
- return False
405
-
406
-
407
- def compute_modern_archives_metrics(
408
- reference: Optional[str],
409
- hypothesis: Optional[str],
410
- ) -> dict:
411
- """Calcule la préservation des marqueurs d'archives modernes.
412
-
413
- Pour chaque catégorie : retourne le ``strict_score`` (forme
414
- abrégée préservée) et l'``expansion_score`` (abrégée OU
415
- développée présente). Le ratio des deux donne au chercheur la
416
- convention adoptée (diplomatique / modernisante / mixte) sans
417
- qu'aucune classification ne soit imposée.
418
-
419
- Returns
420
- -------
421
- dict
422
- ``{
423
- "n_markers_reference": int,
424
- "n_strict_preserved": int,
425
- "n_expansion_preserved": int,
426
- "global_strict_score": float,
427
- "global_expansion_score": float,
428
- "per_category": {
429
- category: {
430
- "n_total": int,
431
- "n_strict_preserved": int,
432
- "n_expansion_preserved": int,
433
- "strict_score": float,
434
- "expansion_score": float,
435
- }
436
- },
437
- "missed_markers": [
438
- {"index": int, "marker": str, "category": str,
439
- "expansion_preserved": bool}
440
- ],
441
- }``
442
-
443
- Cas dégénérés
444
- -------------
445
- - GT vide ou sans marqueur → tous les compteurs à 0, scores à
446
- ``0.0``, ``per_category == {}``.
447
- - GT non vide avec marqueurs + hyp vide → tous les scores à
448
- ``0.0``, tous les marqueurs dans ``missed_markers``.
449
- """
450
- ref = reference or ""
451
- hyp = hypothesis or ""
452
-
453
- detected = detect_modern_markers(ref)
454
- n_total = len(detected)
455
- if n_total == 0:
456
- return {
457
- "n_markers_reference": 0,
458
- "n_strict_preserved": 0,
459
- "n_expansion_preserved": 0,
460
- "global_strict_score": 0.0,
461
- "global_expansion_score": 0.0,
462
- "per_category": {},
463
- "missed_markers": [],
464
- }
465
-
466
- per_cat_total: dict[str, int] = {}
467
- per_cat_strict: dict[str, int] = {}
468
- per_cat_expansion: dict[str, int] = {}
469
- n_strict = 0
470
- n_expansion = 0
471
- missed: list[dict] = []
472
-
473
- for index, marker, category in detected:
474
- per_cat_total[category] = per_cat_total.get(category, 0) + 1
475
- strict_ok = _hyp_contains_marker(hyp, marker, category)
476
- # Convention identique à Sprint 56 : si l'abrégé est
477
- # préservé, c'est aussi un succès pour expansion (l'OCR n'a
478
- # pas perdu l'information).
479
- expansion_ok = strict_ok or _hyp_contains_expansion(hyp, marker)
480
- if strict_ok:
481
- per_cat_strict[category] = per_cat_strict.get(category, 0) + 1
482
- n_strict += 1
483
- if expansion_ok:
484
- per_cat_expansion[category] = per_cat_expansion.get(category, 0) + 1
485
- n_expansion += 1
486
- if not strict_ok:
487
- missed.append({
488
- "index": index,
489
- "marker": marker,
490
- "category": category,
491
- "expansion_preserved": expansion_ok,
492
- })
493
-
494
- per_category = {
495
- cat: {
496
- "n_total": per_cat_total[cat],
497
- "n_strict_preserved": per_cat_strict.get(cat, 0),
498
- "n_expansion_preserved": per_cat_expansion.get(cat, 0),
499
- "strict_score": (
500
- per_cat_strict.get(cat, 0) / per_cat_total[cat]
501
- if per_cat_total[cat] > 0 else 0.0
502
- ),
503
- "expansion_score": (
504
- per_cat_expansion.get(cat, 0) / per_cat_total[cat]
505
- if per_cat_total[cat] > 0 else 0.0
506
- ),
507
- }
508
- for cat in sorted(per_cat_total)
509
- }
510
-
511
- return {
512
- "n_markers_reference": n_total,
513
- "n_strict_preserved": n_strict,
514
- "n_expansion_preserved": n_expansion,
515
- "global_strict_score": n_strict / n_total,
516
- "global_expansion_score": n_expansion / n_total,
517
- "per_category": per_category,
518
- "missed_markers": missed,
519
- }
520
-
521
-
522
- def modern_archives_strict_score(
523
- reference: Optional[str], hypothesis: Optional[str],
524
- ) -> float:
525
- """Raccourci : taux global de préservation **stricte** des
526
- marqueurs d'archives modernes ∈ [0, 1]."""
527
- return compute_modern_archives_metrics(
528
- reference, hypothesis,
529
- )["global_strict_score"]
530
-
531
-
532
- def modern_archives_expansion_score(
533
- reference: Optional[str], hypothesis: Optional[str],
534
- ) -> float:
535
- """Raccourci : taux global de préservation **étendue** (abrégée
536
- OU développée) des marqueurs d'archives modernes ∈ [0, 1]."""
537
- return compute_modern_archives_metrics(
538
- reference, hypothesis,
539
- )["global_expansion_score"]
540
-
541
-
542
- # ──────────────────────────────────────────────────────────────────────────
543
- # Enregistrement dans le registre typé (Sprint 34)
544
- # ──────────────────────────────────────────────────────────────────────────
545
-
546
-
547
- @register_metric(
548
- name="modern_archives_strict_score",
549
- input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
550
- description=(
551
- "Taux de préservation stricte des abréviations et marqueurs "
552
- "typographiques caractéristiques des archives modernes "
553
- "(XIXᵉ-XXᵉ) : titres de civilité, ordinaux, monnaies, "
554
- "abréviations administratives, état civil, ponctuation "
555
- "typographique, abréviations latines, abréviations "
556
- "bibliographiques, abréviations d'adresse. Forme abrégée "
557
- "préservée telle quelle (signal d'édition diplomatique)."
558
- ),
559
- higher_is_better=True,
560
- tags={"text", "modern_archives", "philology", "abbreviations"},
561
- )
562
- def _registered_strict(reference: str, hypothesis: str) -> float:
563
- return modern_archives_strict_score(reference, hypothesis)
564
-
565
-
566
- @register_metric(
567
- name="modern_archives_expansion_score",
568
- input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
569
- description=(
570
- "Taux de préservation étendue (forme abrégée OU forme "
571
- "développée présente) des marqueurs d'archives modernes "
572
- "XIXᵉ-XXᵉ. Le ratio strict/expansion par catégorie "
573
- "permet au chercheur de juger lui-même la convention "
574
- "éditoriale adoptée."
575
- ),
576
- higher_is_better=True,
577
- tags={"text", "modern_archives", "philology", "abbreviations"},
578
- )
579
- def _registered_expansion(reference: str, hypothesis: str) -> float:
580
- return modern_archives_expansion_score(reference, hypothesis)
581
-
582
-
583
- __all__ = [
584
- "CIVILITY_TITLES",
585
- "ORDINALS",
586
- "CURRENCY",
587
- "ADMINISTRATIVE",
588
- "CIVIL_STATUS",
589
- "TYPOGRAPHIC_PUNCTUATION",
590
- "LATIN_ABBR_MODERN",
591
- "BIBLIOGRAPHIC",
592
- "ADDRESS",
593
- "compute_modern_archives_metrics",
594
- "detect_modern_markers",
595
- "get_category",
596
- "get_expansions",
597
- "modern_archives_strict_score",
598
- "modern_archives_expansion_score",
599
- ]
600
 
 
 
 
 
 
1
+ """Alias rétrocompat module déplacé dans :mod:`picarones.extras.historical.modern_archives`.
 
2
 
3
+ Phase B du chantier de refonte en 3 cercles (architecture-cercles.md).
4
+ Ce module philologique est désormais en Cercle 3 (``extras/``). L'alias
5
+ ici permet aux imports historiques (``from picarones.core.modern_archives
6
+ import ...``) de continuer à fonctionner sans modification.
7
 
8
+ Voir :doc:`docs/architecture-cercles.md` et l'extra
9
+ ``picarones[historical]`` du ``pyproject.toml``.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  """
11
 
12
+ from picarones.extras.historical.modern_archives import * # noqa: F401, F403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
+ import picarones.extras.historical.modern_archives as _module
15
+ __all__ = getattr(_module, "__all__", [
16
+ name for name in dir(_module) if not name.startswith("_")
17
+ ])
picarones/core/mufi.py CHANGED
@@ -1,264 +1,17 @@
1
- """Couverture MUFISprint 57.
2
 
3
- Sprint 57 — A.II.3.3 du plan d'évolution 2026 (clôture axe A.II.3
4
- philologique).
 
 
5
 
6
- Pourquoi ce module
7
- ------------------
8
- La **Medieval Unicode Font Initiative** (MUFI v4.0) standardise les
9
- caractères médiévaux que les éditeurs critiques attendent dans une
10
- transcription fidèle : signes d'abréviation, ligatures, lettres
11
- spéciales (ƿ wynn, þ thorn), ponctuation médiévale, marques
12
- diacritiques rares, etc. Pour les médiévistes, la **couverture
13
- MUFI** d'un moteur OCR/HTR est un critère éditorial central.
14
-
15
- Ce module mesure le taux de **caractères MUFI de la GT
16
- correctement restitués** dans l'OCR, après alignement caractère par
17
- caractère (même approche que la précision par bloc Unicode du
18
- Sprint 55).
19
-
20
- Détection des caractères MUFI
21
- -----------------------------
22
- La spécification MUFI v4.0 référence ~1300 caractères dans plusieurs
23
- plages Unicode. Plutôt que d'embarquer la liste exhaustive (qui
24
- évolue), on utilise un **set de plages caractéristiques** suffisant
25
- pour les corpus patrimoniaux européens courants :
26
-
27
- - PUA principal (U+E000–U+F8FF) : zone usuelle des glyphes MUFI
28
- qui n'ont pas (encore) de point de code Unicode standard.
29
- - Latin Extended-D (U+A720–U+A7FF) : abréviations latines
30
- médiévales (ꝑ, ꝓ, ꝗ, etc.).
31
- - Combining Diacritical Marks Supplement (U+1DC0–U+1DFF) :
32
- diacritiques médiévaux rares (macron suscript, etc.).
33
- - Alphabetic Presentation Forms (U+FB00–U+FB4F) : ligatures
34
- (fi, fl, ff).
35
- - Une **liste explicite** de caractères médiévaux dans les blocs
36
- Latin Extended-A/B/Additional (þ, ð, ƿ, ſ, æ, œ, etc.)
37
-
38
- L'utilisateur peut personnaliser via le paramètre ``custom_chars``
39
- de ``compute_mufi_coverage`` pour étendre ou restreindre.
40
-
41
- Stratégie de découpage
42
- ----------------------
43
- Cohérente avec NER (Sprint 38), Flesch (52), Reading order F1 (53),
44
- Layout F1 (54), Bloc Unicode (55), Abréviations (56) : couche de
45
- calcul pure d'abord. Le câblage runner et la vue HTML suivent dans
46
- des sprints dédiés.
47
  """
48
 
49
- from __future__ import annotations
50
-
51
- import logging
52
- from difflib import SequenceMatcher
53
- from typing import Iterable, Optional
54
-
55
- from picarones.core.metric_registry import register_metric
56
- from picarones.core.modules import ArtifactType
57
-
58
- logger = logging.getLogger(__name__)
59
-
60
-
61
- # ──────────────────────────────────────────────────────────────────────────
62
- # Plages Unicode considérées comme MUFI
63
- # ──────────────────────────────────────────────────────────────────────────
64
-
65
- # Triplets (nom, lo, hi) inclusifs. Source : MUFI v4.0 spec
66
- # (https://mufi.info/) + revue manuelle des caractères patrimoniaux
67
- # courants.
68
- _MUFI_RANGES: tuple[tuple[str, int, int], ...] = (
69
- ("Private Use Area", 0xE000, 0xF8FF),
70
- ("Latin Extended-D", 0xA720, 0xA7FF),
71
- ("Combining Diacritical Marks Supplement", 0x1DC0, 0x1DFF),
72
- ("Alphabetic Presentation Forms", 0xFB00, 0xFB4F),
73
- )
74
-
75
- # Caractères MUFI explicites hors plages couvertes par les ranges.
76
- # Surtout des glyphes médiévaux standardisés en Unicode mais qui ne
77
- # sont pas dans le PUA ni dans Latin Extended-D : þ, ð, ƿ, ſ, æ, œ,
78
- # ø, ƀ, ƕ, etc. Liste raisonnée pour les corpus européens médiévaux.
79
- _MUFI_EXPLICIT_CHARS: frozenset[str] = frozenset(
80
- [
81
- # Lettres médiévales standard
82
- "þ", "Þ", # thorn — vieil anglais, islandais
83
- "ð", "Ð", # eth — vieil anglais, islandais
84
- "ƿ", "Ƿ", # wynn — vieil anglais
85
- "ſ", # s long médiéval (déjà U+017F)
86
- "æ", "Æ", # ash
87
- "œ", "Œ", # ethel
88
- "ø", "Ø", # o barré
89
- # Lettres rares avec barré (pour préfixes abréviés)
90
- "ƀ", # b barré
91
- "ŧ", # t barré
92
- "đ", # d barré
93
- "ħ", # h barré
94
- # Yogh
95
- "ȝ", "Ȝ",
96
- # Autres signes médiévaux courants
97
- "ꜿ", # con
98
- # Note : la liste est volontairement courte ; pour étendre,
99
- # l'utilisateur peut passer ``custom_chars`` à
100
- # ``compute_mufi_coverage``.
101
- ]
102
- )
103
-
104
-
105
- def is_mufi_char(char: str, custom_chars: Optional[frozenset[str]] = None) -> bool:
106
- """Retourne ``True`` si ``char`` est considéré comme MUFI.
107
-
108
- Reconnaît :
109
-
110
- - les caractères dans les plages Unicode MUFI (``_MUFI_RANGES``),
111
- - les caractères de la liste explicite (``_MUFI_EXPLICIT_CHARS``),
112
- - tout caractère supplémentaire fourni via ``custom_chars``.
113
-
114
- Pour une chaîne multi-caractères, seul le premier code-point
115
- est considéré.
116
- """
117
- if not char:
118
- return False
119
- cp = ord(char[0])
120
- for _name, lo, hi in _MUFI_RANGES:
121
- if lo <= cp <= hi:
122
- return True
123
- if char[0] in _MUFI_EXPLICIT_CHARS:
124
- return True
125
- if custom_chars and char[0] in custom_chars:
126
- return True
127
- return False
128
-
129
-
130
- # ──────────────────────────────────────────────────────────────────────────
131
- # Calcul de couverture MUFI
132
- # ──────────────────────────────────────────────────────────────────────────
133
-
134
-
135
- def compute_mufi_coverage(
136
- reference: Optional[str],
137
- hypothesis: Optional[str],
138
- custom_chars: Optional[Iterable[str]] = None,
139
- ) -> dict:
140
- """Calcule la couverture MUFI : taux de caractères MUFI de la GT
141
- correctement restitués dans l'hypothèse.
142
-
143
- Parameters
144
- ----------
145
- reference:
146
- Texte GT.
147
- hypothesis:
148
- Texte produit par l'OCR.
149
- custom_chars:
150
- Itérable optionnel de caractères supplémentaires à considérer
151
- comme MUFI (utile pour les éditeurs ayant une convention
152
- propre). Chaque entrée doit être un caractère unique.
153
-
154
- Returns
155
- -------
156
- dict
157
- ``{
158
- "n_mufi_chars_reference": int, # caractères MUFI dans la GT
159
- "n_mufi_chars_preserved": int, # MUFI restitués correctement
160
- "coverage": float, # ∈ [0, 1] ou 0 si N=0
161
- "per_char": {char: {"total", "preserved", "coverage"}},
162
- "missed_chars": list[str], # caractères MUFI ratés
163
- }``
164
-
165
- Cas dégénérés
166
- -------------
167
- - GT vide ou sans caractère MUFI → ``coverage = 0`` (convention :
168
- pas de récompense gratuite).
169
- - Hyp vide + MUFI dans GT → ``coverage = 0``.
170
- - GT et hyp identiques avec MUFI → ``coverage = 1``.
171
- """
172
- ref = reference or ""
173
- hyp = hypothesis or ""
174
- extra: Optional[frozenset[str]] = (
175
- frozenset(c for c in custom_chars if c) if custom_chars else None
176
- )
177
-
178
- # 1. Identifier les positions MUFI dans la GT
179
- mufi_positions = [i for i, ch in enumerate(ref) if is_mufi_char(ch, extra)]
180
- n_total = len(mufi_positions)
181
-
182
- if n_total == 0:
183
- return {
184
- "n_mufi_chars_reference": 0,
185
- "n_mufi_chars_preserved": 0,
186
- "coverage": 0.0,
187
- "per_char": {},
188
- "missed_chars": [],
189
- }
190
-
191
- # 2. Aligner via SequenceMatcher (même méthode que Sprint 55)
192
- matcher = SequenceMatcher(a=ref, b=hyp, autojunk=False)
193
- correct_positions: set[int] = set()
194
- for op, i1, i2, _j1, _j2 in matcher.get_opcodes():
195
- if op == "equal":
196
- correct_positions.update(range(i1, i2))
197
-
198
- # 3. Compter par caractère
199
- per_char_total: dict[str, int] = {}
200
- per_char_preserved: dict[str, int] = {}
201
- missed: list[str] = []
202
- for i in mufi_positions:
203
- ch = ref[i]
204
- per_char_total[ch] = per_char_total.get(ch, 0) + 1
205
- if i in correct_positions:
206
- per_char_preserved[ch] = per_char_preserved.get(ch, 0) + 1
207
- else:
208
- missed.append(ch)
209
-
210
- n_preserved = sum(per_char_preserved.values())
211
- per_char = {
212
- ch: {
213
- "total": per_char_total[ch],
214
- "preserved": per_char_preserved.get(ch, 0),
215
- "coverage": (
216
- per_char_preserved.get(ch, 0) / per_char_total[ch]
217
- if per_char_total[ch] > 0
218
- else 0.0
219
- ),
220
- }
221
- for ch in sorted(per_char_total)
222
- }
223
-
224
- return {
225
- "n_mufi_chars_reference": n_total,
226
- "n_mufi_chars_preserved": n_preserved,
227
- "coverage": n_preserved / n_total,
228
- "per_char": per_char,
229
- "missed_chars": missed,
230
- }
231
-
232
-
233
- def mufi_coverage(
234
- reference: Optional[str], hypothesis: Optional[str],
235
- ) -> float:
236
- """Raccourci : retourne la couverture MUFI globale ∈ [0, 1]."""
237
- return compute_mufi_coverage(reference, hypothesis)["coverage"]
238
-
239
-
240
- # ──────────────────────────────────────────────────────────────────────────
241
- # Enregistrement dans le registre typé (Sprint 34)
242
- # ──────────────────────────────────────────��───────────────────────────────
243
-
244
-
245
- @register_metric(
246
- name="mufi_coverage",
247
- input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
248
- description=(
249
- "Taux de caractères MUFI (Medieval Unicode Font Initiative) "
250
- "de la GT correctement restitués dans l'OCR. Critère "
251
- "éditorial central pour les médiévistes."
252
- ),
253
- higher_is_better=True,
254
- tags={"text", "mufi", "philology", "medieval"},
255
- )
256
- def _registered_mufi_coverage(reference: str, hypothesis: str) -> float:
257
- return mufi_coverage(reference, hypothesis)
258
-
259
 
260
- __all__ = [
261
- "is_mufi_char",
262
- "compute_mufi_coverage",
263
- "mufi_coverage",
264
- ]
 
1
+ """Alias rétrocompatmodule déplacé dans :mod:`picarones.extras.historical.mufi`.
2
 
3
+ Phase B du chantier de refonte en 3 cercles (architecture-cercles.md).
4
+ Ce module philologique est désormais en Cercle 3 (``extras/``). L'alias
5
+ ici permet aux imports historiques (``from picarones.core.mufi
6
+ import ...``) de continuer à fonctionner sans modification.
7
 
8
+ Voir :doc:`docs/architecture-cercles.md` et l'extra
9
+ ``picarones[historical]`` du ``pyproject.toml``.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  """
11
 
12
+ from picarones.extras.historical.mufi import * # noqa: F401, F403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
+ import picarones.extras.historical.mufi as _module
15
+ __all__ = getattr(_module, "__all__", [
16
+ name for name in dir(_module) if not name.startswith("_")
17
+ ])
 
picarones/core/philological_runner.py CHANGED
@@ -1,363 +1,17 @@
1
- """Helpers de câblage des métriques philologiques (Sprints 55-60) au runner.
2
 
3
- Sprint 61 câblage backend des 6 modules philologiques :
 
 
 
4
 
5
- - ``unicode_blocks`` (Sprint 55)
6
- - ``abbreviations`` (Sprint 56)
7
- - ``mufi`` (Sprint 57)
8
- - ``early_modern`` (Sprint 58)
9
- - ``modern_archives`` (Sprint 59)
10
- - ``roman_numerals`` (Sprint 60)
11
-
12
- Principe « adaptive »
13
- ----------------------
14
- Un module n'est inclus dans le résultat que si la **GT contient du
15
- signal exploitable** pour ce module. Cette logique évite de polluer
16
- les rapports sur les corpus sans marqueurs philologiques (typique
17
- sur des données XXIᵉ ou des transcriptions modernes propres).
18
-
19
- Coût
20
- ----
21
- Les 6 calculs sont O(N) sur la longueur du texte ; le surcoût total
22
- par document est négligeable face à un appel OCR. L'activation est
23
- donc **automatique** (pas d'opt-in), contrairement aux backends NER
24
- ou calibration qui exigent une dépendance externe ou des données
25
- spécifiques.
26
  """
27
 
28
- from __future__ import annotations
29
-
30
- import logging
31
- from typing import Optional
32
-
33
- from picarones.core.abbreviations import compute_abbreviation_metrics
34
- from picarones.core.early_modern_typography import compute_early_modern_metrics
35
- from picarones.core.modern_archives import compute_modern_archives_metrics
36
- from picarones.core.mufi import compute_mufi_coverage
37
- from picarones.core.roman_numerals import compute_roman_numeral_metrics
38
- from picarones.core.unicode_blocks import compute_unicode_block_accuracy
39
-
40
- logger = logging.getLogger(__name__)
41
-
42
-
43
- # ──────────────────────────────────────────────────────────────────────────
44
- # Critères « le module a-t-il du signal sur ce document ? »
45
- # ──────────────────────────────────────────────────────────────────────────
46
- #
47
- # Pour chaque module, on définit un prédicat sur le résultat : si vrai,
48
- # le module est inclus ; sinon, il est omis pour ne pas alourdir le
49
- # rapport.
50
-
51
- def _has_unicode_signal(result: dict) -> bool:
52
- # Le module retourne toujours du signal dès que GT non-vide ; on
53
- # n'inclut que si la GT a au moins un caractère **hors Basic
54
- # Latin** (sinon le breakdown se réduit à 100 % Basic Latin et
55
- # n'apporte rien au lecteur).
56
- per_block = result.get("per_block", {})
57
- for block, stats in per_block.items():
58
- if block == "Basic Latin":
59
- continue
60
- if stats.get("total", 0) > 0:
61
- return True
62
- return False
63
-
64
-
65
- def _has_abbreviation_signal(result: dict) -> bool:
66
- return result.get("n_abbreviations_in_reference", 0) > 0
67
-
68
-
69
- def _has_mufi_signal(result: dict) -> bool:
70
- return result.get("n_mufi_chars_reference", 0) > 0
71
-
72
-
73
- def _has_early_modern_signal(result: dict) -> bool:
74
- return result.get("n_markers_reference", 0) > 0
75
-
76
-
77
- def _has_modern_archives_signal(result: dict) -> bool:
78
- return result.get("n_markers_reference", 0) > 0
79
-
80
-
81
- def _has_roman_numeral_signal(result: dict) -> bool:
82
- return result.get("n_numerals_reference", 0) > 0
83
-
84
-
85
- # Ordre fixé pour la reproductibilité des sorties.
86
- _PHILOLOGICAL_MODULES: tuple[
87
- tuple[str, callable, callable], ...
88
- ] = (
89
- ("unicode_blocks", compute_unicode_block_accuracy, _has_unicode_signal),
90
- ("abbreviations", compute_abbreviation_metrics, _has_abbreviation_signal),
91
- ("mufi", compute_mufi_coverage, _has_mufi_signal),
92
- ("early_modern", compute_early_modern_metrics, _has_early_modern_signal),
93
- ("modern_archives", compute_modern_archives_metrics, _has_modern_archives_signal),
94
- ("roman_numerals", compute_roman_numeral_metrics, _has_roman_numeral_signal),
95
- )
96
-
97
-
98
- # ──────────────────────────────────────────────────────────────────────────
99
- # Calcul par document
100
- # ──────────────────────────────────────────────────────────────────────────
101
-
102
-
103
- def compute_philological_metrics(
104
- reference: Optional[str],
105
- hypothesis: Optional[str],
106
- ) -> Optional[dict]:
107
- """Calcule les 6 métriques philologiques pour un document.
108
-
109
- Retourne un dict avec une clé par module ayant du signal, ou
110
- ``None`` si aucun module n'en a (corpus sans marqueur
111
- philologique pertinent).
112
-
113
- En cas d'erreur dans un module individuel, le module est
114
- silencieusement omis et un warning est émis (les autres modules
115
- restent calculés).
116
- """
117
- ref = reference or ""
118
- if not ref:
119
- return None
120
- out: dict = {}
121
- for name, compute_fn, has_signal_fn in _PHILOLOGICAL_MODULES:
122
- try:
123
- result = compute_fn(ref, hypothesis or "")
124
- except Exception as exc: # pragma: no cover — défense en profondeur
125
- logger.warning(
126
- "[philological_runner] module %s a échoué : %s", name, exc,
127
- )
128
- continue
129
- if has_signal_fn(result):
130
- out[name] = result
131
- return out if out else None
132
-
133
-
134
- # ──────────────────────────────────────────────────────────────────────────
135
- # Agrégation corpus-wide par moteur
136
- # ──────────────────────────────────────────────────────────────────────────
137
-
138
-
139
- def _aggregate_unicode(per_doc: list[dict]) -> dict:
140
- total_correct = 0
141
- total_chars = 0
142
- per_block: dict[str, dict[str, int]] = {}
143
- for d in per_doc:
144
- for block, stats in d.get("per_block", {}).items():
145
- slot = per_block.setdefault(block, {"correct": 0, "total": 0})
146
- slot["correct"] += stats.get("correct", 0)
147
- slot["total"] += stats.get("total", 0)
148
- total_correct += stats.get("correct", 0)
149
- total_chars += stats.get("total", 0)
150
- out_per_block = {
151
- block: {
152
- "correct": slot["correct"],
153
- "total": slot["total"],
154
- "accuracy": (
155
- slot["correct"] / slot["total"] if slot["total"] > 0 else 0.0
156
- ),
157
- }
158
- for block, slot in sorted(per_block.items())
159
- }
160
- return {
161
- "global_accuracy": total_correct / total_chars if total_chars > 0 else 0.0,
162
- "n_chars_total": total_chars,
163
- "n_chars_correct": total_correct,
164
- "per_block": out_per_block,
165
- "doc_count": len(per_doc),
166
- }
167
-
168
-
169
- def _aggregate_abbreviations(per_doc: list[dict]) -> dict:
170
- n_total = 0
171
- n_strict = 0
172
- n_expansion = 0
173
- per_abbr: dict[str, dict[str, int]] = {}
174
- for d in per_doc:
175
- n_total += d.get("n_abbreviations_in_reference", 0)
176
- n_strict += d.get("n_strict_preserved", 0)
177
- n_expansion += d.get("n_expansion_preserved", 0)
178
- for entry in d.get("per_abbreviation", []):
179
- slot = per_abbr.setdefault(
180
- entry["abbr"],
181
- {"total": 0, "strict": 0, "expansion": 0},
182
- )
183
- slot["total"] += 1
184
- if entry.get("strict_preserved"):
185
- slot["strict"] += 1
186
- if entry.get("expansion_preserved"):
187
- slot["expansion"] += 1
188
- return {
189
- "n_abbreviations_in_reference": n_total,
190
- "n_strict_preserved": n_strict,
191
- "n_expansion_preserved": n_expansion,
192
- "global_strict_score": n_strict / n_total if n_total > 0 else 0.0,
193
- "global_expansion_score": n_expansion / n_total if n_total > 0 else 0.0,
194
- "per_abbreviation": {
195
- abbr: {
196
- "n_total": slot["total"],
197
- "n_strict": slot["strict"],
198
- "n_expansion": slot["expansion"],
199
- "strict_score": slot["strict"] / slot["total"],
200
- "expansion_score": slot["expansion"] / slot["total"],
201
- }
202
- for abbr, slot in sorted(per_abbr.items())
203
- },
204
- "doc_count": len(per_doc),
205
- }
206
-
207
-
208
- def _aggregate_mufi(per_doc: list[dict]) -> dict:
209
- n_total = 0
210
- n_preserved = 0
211
- per_char: dict[str, dict[str, int]] = {}
212
- for d in per_doc:
213
- n_total += d.get("n_mufi_chars_reference", 0)
214
- n_preserved += d.get("n_mufi_chars_preserved", 0)
215
- for ch, stats in d.get("per_char", {}).items():
216
- slot = per_char.setdefault(ch, {"total": 0, "preserved": 0})
217
- slot["total"] += stats.get("total", 0)
218
- slot["preserved"] += stats.get("preserved", 0)
219
- return {
220
- "n_mufi_chars_reference": n_total,
221
- "n_mufi_chars_preserved": n_preserved,
222
- "coverage": n_preserved / n_total if n_total > 0 else 0.0,
223
- "per_char": {
224
- ch: {
225
- "total": slot["total"],
226
- "preserved": slot["preserved"],
227
- "coverage": slot["preserved"] / slot["total"],
228
- }
229
- for ch, slot in sorted(per_char.items())
230
- },
231
- "doc_count": len(per_doc),
232
- }
233
-
234
-
235
- def _aggregate_early_modern(per_doc: list[dict]) -> dict:
236
- n_total = 0
237
- n_preserved = 0
238
- per_cat: dict[str, dict[str, int]] = {}
239
- for d in per_doc:
240
- n_total += d.get("n_markers_reference", 0)
241
- n_preserved += d.get("n_markers_preserved", 0)
242
- for cat, stats in d.get("per_category", {}).items():
243
- slot = per_cat.setdefault(cat, {"total": 0, "preserved": 0})
244
- slot["total"] += stats.get("total", 0)
245
- slot["preserved"] += stats.get("preserved", 0)
246
- return {
247
- "n_markers_reference": n_total,
248
- "n_markers_preserved": n_preserved,
249
- "global_preservation": n_preserved / n_total if n_total > 0 else 0.0,
250
- "per_category": {
251
- cat: {
252
- "total": slot["total"],
253
- "preserved": slot["preserved"],
254
- "preservation": slot["preserved"] / slot["total"],
255
- }
256
- for cat, slot in sorted(per_cat.items())
257
- },
258
- "doc_count": len(per_doc),
259
- }
260
-
261
-
262
- def _aggregate_modern_archives(per_doc: list[dict]) -> dict:
263
- n_total = 0
264
- n_strict = 0
265
- n_expansion = 0
266
- per_cat: dict[str, dict[str, int]] = {}
267
- for d in per_doc:
268
- n_total += d.get("n_markers_reference", 0)
269
- n_strict += d.get("n_strict_preserved", 0)
270
- n_expansion += d.get("n_expansion_preserved", 0)
271
- for cat, stats in d.get("per_category", {}).items():
272
- slot = per_cat.setdefault(
273
- cat, {"total": 0, "strict": 0, "expansion": 0},
274
- )
275
- slot["total"] += stats.get("n_total", 0)
276
- slot["strict"] += stats.get("n_strict_preserved", 0)
277
- slot["expansion"] += stats.get("n_expansion_preserved", 0)
278
- return {
279
- "n_markers_reference": n_total,
280
- "n_strict_preserved": n_strict,
281
- "n_expansion_preserved": n_expansion,
282
- "global_strict_score": n_strict / n_total if n_total > 0 else 0.0,
283
- "global_expansion_score": n_expansion / n_total if n_total > 0 else 0.0,
284
- "per_category": {
285
- cat: {
286
- "n_total": slot["total"],
287
- "n_strict_preserved": slot["strict"],
288
- "n_expansion_preserved": slot["expansion"],
289
- "strict_score": slot["strict"] / slot["total"],
290
- "expansion_score": slot["expansion"] / slot["total"],
291
- }
292
- for cat, slot in sorted(per_cat.items())
293
- },
294
- "doc_count": len(per_doc),
295
- }
296
-
297
-
298
- def _aggregate_roman_numerals(per_doc: list[dict]) -> dict:
299
- from picarones.core.roman_numerals import ALL_STATUSES, VALUE_PRESERVING_STATUSES
300
-
301
- n_total = 0
302
- per_status: dict[str, int] = {s: 0 for s in ALL_STATUSES}
303
- for d in per_doc:
304
- n_total += d.get("n_numerals_reference", 0)
305
- for status, count in d.get("per_status", {}).items():
306
- per_status[status] = per_status.get(status, 0) + count
307
- n_strict = per_status.get("strict_preserved", 0)
308
- n_value = sum(per_status.get(s, 0) for s in VALUE_PRESERVING_STATUSES)
309
- return {
310
- "n_numerals_reference": n_total,
311
- "n_strict_preserved": n_strict,
312
- "n_value_preserved": n_value,
313
- "global_strict_score": n_strict / n_total if n_total > 0 else 0.0,
314
- "global_value_score": n_value / n_total if n_total > 0 else 0.0,
315
- "per_status": per_status,
316
- "doc_count": len(per_doc),
317
- }
318
-
319
-
320
- _AGGREGATORS = {
321
- "unicode_blocks": _aggregate_unicode,
322
- "abbreviations": _aggregate_abbreviations,
323
- "mufi": _aggregate_mufi,
324
- "early_modern": _aggregate_early_modern,
325
- "modern_archives": _aggregate_modern_archives,
326
- "roman_numerals": _aggregate_roman_numerals,
327
- }
328
-
329
-
330
- def aggregate_philological_metrics(
331
- doc_metrics: list[Optional[dict]],
332
- ) -> Optional[dict]:
333
- """Agrège les ``philological_metrics`` per-document en un dict
334
- corpus-wide par module.
335
-
336
- Pour chaque module, on agrège uniquement les documents qui ont
337
- eu du signal pour ce module. Si aucun module n'a été calculé
338
- sur aucun document, retourne ``None``.
339
- """
340
- by_module: dict[str, list[dict]] = {}
341
- for doc in doc_metrics:
342
- if not doc:
343
- continue
344
- for module, payload in doc.items():
345
- by_module.setdefault(module, []).append(payload)
346
- if not by_module:
347
- return None
348
- out: dict = {}
349
- for module, payloads in by_module.items():
350
- aggregator = _AGGREGATORS.get(module)
351
- if aggregator is None: # pragma: no cover
352
- logger.warning(
353
- "[philological_runner] aucun agrégateur pour %s", module,
354
- )
355
- continue
356
- out[module] = aggregator(payloads)
357
- return out if out else None
358
-
359
 
360
- __all__ = [
361
- "compute_philological_metrics",
362
- "aggregate_philological_metrics",
363
- ]
 
1
+ """Alias rétrocompat module déplacé dans :mod:`picarones.extras.historical.philological_runner`.
2
 
3
+ Phase B du chantier de refonte en 3 cercles (architecture-cercles.md).
4
+ Ce module philologique est désormais en Cercle 3 (``extras/``). L'alias
5
+ ici permet aux imports historiques (``from picarones.core.philological_runner
6
+ import ...``) de continuer à fonctionner sans modification.
7
 
8
+ Voir :doc:`docs/architecture-cercles.md` et l'extra
9
+ ``picarones[historical]`` du ``pyproject.toml``.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  """
11
 
12
+ from picarones.extras.historical.philological_runner import * # noqa: F401, F403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
+ import picarones.extras.historical.philological_runner as _module
15
+ __all__ = getattr(_module, "__all__", [
16
+ name for name in dir(_module) if not name.startswith("_")
17
+ ])
picarones/core/roman_numerals.py CHANGED
@@ -1,478 +1,17 @@
1
- """Numéraux romainsSprint 60.
2
 
3
- Sprint 60 Étape 3 / extension philologique transversale du plan
4
- d'évolution 2026.
 
 
5
 
6
- Pourquoi ce module
7
- ------------------
8
- Les numéraux romains traversent **toutes les périodes patrimoniales**
9
- servies par Picarones :
10
-
11
- - **Médiéval** : minuscules avec ``j`` final pour le dernier ``i``
12
- (``ij`` = 2, ``iij`` = 3, ``viij`` = 8, ``mcclxxxij`` = 1282).
13
- Convention scribale standard dans les chartes et registres.
14
- - **Imprimé ancien** : majuscules (``Tome IV``, ``Chap. VII``).
15
- - **Moderne** : majuscules pour les souverains (``Louis XIV``) et
16
- les siècles (``XIXᵉ siècle`` — la partie exposant ᵉ est gérée
17
- par le Sprint 59 ``ordinals``, ce module ne traite que la partie
18
- numérale ``XIX``).
19
-
20
- Quatre traitements possibles d'un numéral par l'OCR
21
- ----------------------------------------------------
22
- Pour chaque numéral romain présent dans la GT, l'OCR peut :
23
-
24
- 1. **Préserver strictement** : forme exacte gardée
25
- (``mcclxxxij`` → ``mcclxxxij``). Édition diplomatique idéale.
26
- 2. **Préserver en changeant la casse** : la valeur est intacte mais
27
- la convention typographique est modifiée
28
- (``xiv`` → ``XIV``). Modernisation typographique courante.
29
- 3. **Préserver en supprimant le ``j`` final** :
30
- (``mcclxxxij`` → ``mcclxxxii``). Modernisation orthographique
31
- médiévale → standard académique moderne.
32
- 4. **Convertir en chiffres arabes** : la valeur est préservée mais
33
- le système de numération est modernisé
34
- (``XIV`` → ``14``). Modernisation profonde, perte de
35
- l'information typographique.
36
- 5. **Perdre** : aucune trace de la valeur dans l'hypothèse.
37
-
38
- Ce module retourne un breakdown par statut pour que le chercheur
39
- juge lui-même la convention adoptée par chaque moteur, **sans
40
- classification automatique imposée**.
41
-
42
- Stratégie de découpage
43
- ----------------------
44
- Cohérente avec NER (38), Flesch (52), Reading order F1 (53),
45
- Layout F1 (54), Bloc Unicode (55), Abréviations (56), MUFI (57),
46
- Imprimé ancien (58), Archives modernes (59) : couche de calcul
47
- pure d'abord ; câblage runner et HTML dans des sprints dédiés.
48
-
49
- Limites documentées
50
- -------------------
51
- - Détection greedy par regex ``\\b[IVXLCDMivxlcdmj]+\\b`` puis
52
- validation par parsing. Les faux positifs restent possibles sur
53
- des mots courts (``I`` pronom anglais, ``MM`` initiales, ``LL``).
54
- Le paramètre ``min_length`` permet de filtrer les single-letter.
55
- - Pas de gestion des notations rares avec barre suscript pour
56
- multiplier par 1000 (V̄ = 5000, X̄ = 10000) — usage très rare en
57
- corpus patrimonial européen courant.
58
  """
59
 
60
- from __future__ import annotations
61
-
62
- import logging
63
- import re
64
- from typing import Optional
65
-
66
- from picarones.core.metric_registry import register_metric
67
- from picarones.core.modules import ArtifactType
68
-
69
- logger = logging.getLogger(__name__)
70
-
71
-
72
- # ──────────────────────────────────────────────────────────────────────────
73
- # Table de conversion + parsing
74
- # ──────────────────────────────────────────────────────────────────────────
75
-
76
- ROMAN_VALUES: dict[str, int] = {
77
- "I": 1, "V": 5, "X": 10,
78
- "L": 50, "C": 100, "D": 500, "M": 1000,
79
- }
80
-
81
- # Caractères acceptés en entrée (incluant minuscules + j médiéval).
82
- _ROMAN_CHARS = "IVXLCDMivxlcdmj"
83
- _ROMAN_RE = re.compile(rf"\b[{_ROMAN_CHARS}]+\b")
84
-
85
-
86
- def _normalize_roman(s: str) -> str:
87
- """Normalise un numéral romain : majuscule + ``j`` final → ``i``.
88
-
89
- Les manuscrits médiévaux notent traditionnellement le dernier
90
- ``i`` d'une suite par ``j`` (« ij », « iij », « viij »…). On
91
- convertit pour pouvoir parser comme un numéral standard.
92
- """
93
- if not s:
94
- return ""
95
- upper = s.upper()
96
- if upper.endswith("J"):
97
- upper = upper[:-1] + "I"
98
- return upper
99
-
100
-
101
- def _parse_normalized_roman(s: str) -> Optional[int]:
102
- """Parse un numéral romain **après normalisation** (majuscule,
103
- sans ``j`` médiéval). Retourne ``None`` si la chaîne n'est pas
104
- un numéral romain valide.
105
-
106
- Validation : on parse en additionnant/soustrayant selon la règle
107
- classique, puis on **regénère la forme standard** et on compare
108
- pour rejeter les formes non canoniques (« IIII » au lieu de
109
- « IV », « VV » au lieu de « X »). Cette stricte validation
110
- garantit qu'on ne compte pas des séquences absurdes comme
111
- « XXXX » comme un numéral.
112
-
113
- Note : les manuscrits médiévaux utilisent fréquemment « IIII »
114
- pour 4 (notation soustractive plus tardive). On accepte donc
115
- aussi cette forme via une règle relâchée : tant que les valeurs
116
- sont décroissantes ou suivent la règle soustractive standard,
117
- on accepte.
118
- """
119
- if not s or not all(c in "IVXLCDM" for c in s):
120
- return None
121
- # Calcul par soustraction.
122
- total = 0
123
- prev_value = 0
124
- for ch in reversed(s):
125
- v = ROMAN_VALUES[ch]
126
- if v < prev_value:
127
- total -= v
128
- else:
129
- total += v
130
- prev_value = v
131
- if total <= 0:
132
- return None
133
- # Validation relâchée : on accepte les formes médiévales (IIII,
134
- # VIIII) mais on rejette les vraiment absurdes (IIIII, VVVV).
135
- if not _is_plausible_roman(s):
136
- return None
137
- return total
138
-
139
-
140
- def _is_plausible_roman(s: str) -> bool:
141
- """Validation relâchée d'un numéral romain (majuscule).
142
-
143
- On rejette :
144
-
145
- - 5 caractères identiques d'affilée ou plus (« IIIII », « XXXXX »).
146
- - Les répétitions de V, L, D (jamais répétés en notation
147
- classique : « VV », « LL », « DD »).
148
- - Les paires soustractives non standard. En romain canonique,
149
- seules sont valides : IV, IX, XL, XC, CD, CM. Toute autre
150
- combinaison « petit avant grand » est rejetée. Cela élimine
151
- les faux positifs sur des mots français comme « ici » (qui
152
- formerait sinon « I + C » = 99) ou « IL » qui formerait 49.
153
- """
154
- if not s:
155
- return False
156
- # Pas de répétitions invalides
157
- for forbidden in ("VV", "LL", "DD", "IIIII", "XXXXX", "CCCCC", "MMMMMM"):
158
- if forbidden in s:
159
- return False
160
- # Paires soustractives autorisées (toutes les autres sont rejetées)
161
- legal_subtractive = {"IV", "IX", "XL", "XC", "CD", "CM"}
162
- for i in range(len(s) - 1):
163
- a, b = s[i], s[i + 1]
164
- if ROMAN_VALUES[a] < ROMAN_VALUES[b]:
165
- if (a + b) not in legal_subtractive:
166
- return False
167
- return True
168
-
169
-
170
- def roman_to_int(s: Optional[str]) -> Optional[int]:
171
- """Convertit une chaîne en numéral romain entier. Tolère casse
172
- et ``j`` médiéval final. Retourne ``None`` si invalide.
173
- """
174
- if not s:
175
- return None
176
- return _parse_normalized_roman(_normalize_roman(s))
177
-
178
-
179
- def int_to_roman(n: int) -> str:
180
- """Convertit un entier en numéral romain majuscule standard.
181
-
182
- Utilise la notation classique (IV, IX, XL, XC, CD, CM) — pas la
183
- forme médiévale relâchée.
184
- """
185
- if n <= 0:
186
- raise ValueError("n must be positive")
187
- pairs = [
188
- (1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
189
- (100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
190
- (10, "X"), (9, "IX"), (5, "V"), (4, "IV"),
191
- (1, "I"),
192
- ]
193
- out: list[str] = []
194
- for value, symbol in pairs:
195
- while n >= value:
196
- out.append(symbol)
197
- n -= value
198
- return "".join(out)
199
-
200
-
201
- # ──────────────────────────────────────────────────────────────────────────
202
- # Détection dans le texte
203
- # ──────────────────────────────────────────────────────────────────────────
204
-
205
-
206
- def detect_roman_numerals(
207
- text: Optional[str],
208
- *,
209
- min_length: int = 1,
210
- ) -> list[tuple[int, str, int]]:
211
- """Retourne les numéraux romains valides dans ``text``.
212
-
213
- Forme : ``[(start_index, numeral_string, integer_value), ...]``
214
- triée par index croissant.
215
-
216
- Parameters
217
- ----------
218
- text:
219
- Texte à analyser.
220
- min_length:
221
- Longueur minimale d'un numéral retenu. Par défaut ``1``.
222
- Mettre à ``2`` pour filtrer les single-letter ambigus (``I``
223
- pronom, ``M`` initiale).
224
-
225
- Faux positifs connus
226
- --------------------
227
- - ``I`` (pronom anglais), ``M`` ou ``D`` en initiale d'une
228
- personne ne peuvent pas être distingués sans NER. Le chercheur
229
- qui s'inquiète de ces faux positifs peut passer
230
- ``min_length=2``.
231
- """
232
- if not text:
233
- return []
234
- found: list[tuple[int, str, int]] = []
235
- for match in _ROMAN_RE.finditer(text):
236
- s = match.group(0)
237
- if len(s) < min_length:
238
- continue
239
- value = roman_to_int(s)
240
- if value is None:
241
- continue
242
- found.append((match.start(), s, value))
243
- return found
244
-
245
-
246
- # ──────────────────────────────────────────────────────────────���───────────
247
- # Classification de la restitution dans l'hypothèse
248
- # ──────────────────────────────────────────────────────────────────────────
249
-
250
- # Statuts possibles, dans l'ordre de priorité (un numéral est
251
- # classé selon le premier statut qui s'applique).
252
-
253
- STATUS_STRICT_PRESERVED = "strict_preserved"
254
- STATUS_CASE_CHANGED = "case_changed"
255
- STATUS_J_DROPPED = "j_dropped"
256
- STATUS_CONVERTED_TO_ARABIC = "converted_to_arabic"
257
- STATUS_LOST = "lost"
258
-
259
- ALL_STATUSES = (
260
- STATUS_STRICT_PRESERVED,
261
- STATUS_CASE_CHANGED,
262
- STATUS_J_DROPPED,
263
- STATUS_CONVERTED_TO_ARABIC,
264
- STATUS_LOST,
265
- )
266
-
267
- # Statuts qui indiquent une préservation de la valeur (par opposition
268
- # à la perte).
269
- VALUE_PRESERVING_STATUSES = frozenset({
270
- STATUS_STRICT_PRESERVED,
271
- STATUS_CASE_CHANGED,
272
- STATUS_J_DROPPED,
273
- STATUS_CONVERTED_TO_ARABIC,
274
- })
275
-
276
-
277
- def _classify_restitution(numeral: str, value: int, hyp: str) -> str:
278
- """Classifie comment ``numeral`` (de valeur ``value``) est
279
- restitué dans ``hyp`` selon les 5 statuts définis."""
280
- # 1. Forme stricte présente
281
- if re.search(r"(?<![A-Za-z])" + re.escape(numeral) + r"(?![A-Za-z])", hyp):
282
- return STATUS_STRICT_PRESERVED
283
- # 2. Variante de casse seule
284
- swapped = numeral.swapcase()
285
- if swapped != numeral and re.search(
286
- r"(?<![A-Za-z])" + re.escape(swapped) + r"(?![A-Za-z])", hyp,
287
- ):
288
- return STATUS_CASE_CHANGED
289
- # 3. ``j`` final remplacé par ``i`` (ou inverse)
290
- if numeral.lower().endswith("j"):
291
- no_j = numeral[:-1] + ("I" if numeral[-1] == "J" else "i")
292
- elif numeral.lower().endswith("i"):
293
- no_j = numeral[:-1] + ("J" if numeral[-1] == "I" else "j")
294
- else:
295
- no_j = numeral
296
- if no_j != numeral and re.search(
297
- r"(?<![A-Za-z])" + re.escape(no_j) + r"(?![A-Za-z])", hyp,
298
- ):
299
- return STATUS_J_DROPPED
300
- # Variante de casse + j-flip combinés
301
- no_j_swapped = no_j.swapcase()
302
- if no_j_swapped != numeral and re.search(
303
- r"(?<![A-Za-z])" + re.escape(no_j_swapped) + r"(?![A-Za-z])", hyp,
304
- ):
305
- return STATUS_J_DROPPED
306
- # 4. Conversion en chiffres arabes
307
- if re.search(r"(?<!\d)" + str(value) + r"(?!\d)", hyp):
308
- return STATUS_CONVERTED_TO_ARABIC
309
- # 5. Perdu
310
- return STATUS_LOST
311
-
312
-
313
- # ──────────────────────────────────────────────────────────────────────────
314
- # Calcul de la métrique
315
- # ──────────────────────────────────────────────────────────────────────────
316
-
317
-
318
- def compute_roman_numeral_metrics(
319
- reference: Optional[str],
320
- hypothesis: Optional[str],
321
- *,
322
- min_length: int = 1,
323
- ) -> dict:
324
- """Calcule la préservation des numéraux romains.
325
-
326
- Pour chaque numéral romain dans la GT, on classifie sa
327
- restitution dans l'hypothèse selon l'un des 5 statuts (forme
328
- stricte / casse modifiée / j supprimé / conversion arabe / perdu).
329
-
330
- Returns
331
- -------
332
- dict
333
- ``{
334
- "n_numerals_reference": int,
335
- "n_strict_preserved": int,
336
- "n_value_preserved": int, # tous statuts sauf LOST
337
- "global_strict_score": float,
338
- "global_value_score": float,
339
- "per_status": {status: count for status in ALL_STATUSES},
340
- "per_numeral": [
341
- {"index", "numeral", "value", "status"}
342
- ],
343
- "lost_numerals": [
344
- {"index", "numeral", "value"}
345
- ],
346
- }``
347
-
348
- Cas dégénérés
349
- -------------
350
- - GT vide ou sans numéral → tous compteurs à 0, scores à 0.0,
351
- ``per_status`` initialisé à 0 sur tous les statuts.
352
- - GT avec numéraux + hyp vide → tous classés ``lost``,
353
- strict_score = value_score = 0.0.
354
- """
355
- ref = reference or ""
356
- hyp = hypothesis or ""
357
-
358
- detected = detect_roman_numerals(ref, min_length=min_length)
359
- n_total = len(detected)
360
- per_status_init = {status: 0 for status in ALL_STATUSES}
361
-
362
- if n_total == 0:
363
- return {
364
- "n_numerals_reference": 0,
365
- "n_strict_preserved": 0,
366
- "n_value_preserved": 0,
367
- "global_strict_score": 0.0,
368
- "global_value_score": 0.0,
369
- "per_status": per_status_init,
370
- "per_numeral": [],
371
- "lost_numerals": [],
372
- }
373
-
374
- per_status: dict[str, int] = dict(per_status_init)
375
- per_numeral: list[dict] = []
376
- lost: list[dict] = []
377
- for index, numeral, value in detected:
378
- status = _classify_restitution(numeral, value, hyp)
379
- per_status[status] = per_status.get(status, 0) + 1
380
- per_numeral.append({
381
- "index": index,
382
- "numeral": numeral,
383
- "value": value,
384
- "status": status,
385
- })
386
- if status == STATUS_LOST:
387
- lost.append({"index": index, "numeral": numeral, "value": value})
388
-
389
- n_strict = per_status[STATUS_STRICT_PRESERVED]
390
- n_value = sum(per_status[s] for s in VALUE_PRESERVING_STATUSES)
391
-
392
- return {
393
- "n_numerals_reference": n_total,
394
- "n_strict_preserved": n_strict,
395
- "n_value_preserved": n_value,
396
- "global_strict_score": n_strict / n_total,
397
- "global_value_score": n_value / n_total,
398
- "per_status": per_status,
399
- "per_numeral": per_numeral,
400
- "lost_numerals": lost,
401
- }
402
-
403
-
404
- def roman_numeral_strict_score(
405
- reference: Optional[str], hypothesis: Optional[str],
406
- ) -> float:
407
- """Raccourci : taux global de préservation **stricte** des
408
- numéraux romains ∈ [0, 1]."""
409
- return compute_roman_numeral_metrics(
410
- reference, hypothesis,
411
- )["global_strict_score"]
412
-
413
-
414
- def roman_numeral_value_score(
415
- reference: Optional[str], hypothesis: Optional[str],
416
- ) -> float:
417
- """Raccourci : taux global de préservation de la **valeur** des
418
- numéraux romains (toute forme confondue : strict, case_changed,
419
- j_dropped, arabe) ∈ [0, 1]."""
420
- return compute_roman_numeral_metrics(
421
- reference, hypothesis,
422
- )["global_value_score"]
423
-
424
-
425
- # ──────────────────────────────────────────────────────────────────────────
426
- # Enregistrement dans le registre typé (Sprint 34)
427
- # ──────────────────────────────────────────────────────────────────────────
428
-
429
-
430
- @register_metric(
431
- name="roman_numeral_strict_score",
432
- input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
433
- description=(
434
- "Taux de préservation stricte des numéraux romains "
435
- "(forme exacte gardée : casse, j médiéval final). "
436
- "Métrique transversale aux périodes médiévale, imprimé "
437
- "ancien et moderne."
438
- ),
439
- higher_is_better=True,
440
- tags={"text", "roman_numerals", "philology"},
441
- )
442
- def _registered_strict(reference: str, hypothesis: str) -> float:
443
- return roman_numeral_strict_score(reference, hypothesis)
444
-
445
-
446
- @register_metric(
447
- name="roman_numeral_value_score",
448
- input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
449
- description=(
450
- "Taux de préservation de la valeur numérique des numéraux "
451
- "romains, indépendamment de la forme (strict, casse "
452
- "changée, j supprimé, conversion en chiffres arabes). "
453
- "Le breakdown per_status permet au chercheur de juger la "
454
- "convention adoptée."
455
- ),
456
- higher_is_better=True,
457
- tags={"text", "roman_numerals", "philology"},
458
- )
459
- def _registered_value(reference: str, hypothesis: str) -> float:
460
- return roman_numeral_value_score(reference, hypothesis)
461
-
462
 
463
- __all__ = [
464
- "ROMAN_VALUES",
465
- "ALL_STATUSES",
466
- "STATUS_STRICT_PRESERVED",
467
- "STATUS_CASE_CHANGED",
468
- "STATUS_J_DROPPED",
469
- "STATUS_CONVERTED_TO_ARABIC",
470
- "STATUS_LOST",
471
- "VALUE_PRESERVING_STATUSES",
472
- "compute_roman_numeral_metrics",
473
- "detect_roman_numerals",
474
- "int_to_roman",
475
- "roman_numeral_strict_score",
476
- "roman_numeral_value_score",
477
- "roman_to_int",
478
- ]
 
1
+ """Alias rétrocompatmodule déplacé dans :mod:`picarones.extras.historical.roman_numerals`.
2
 
3
+ Phase B du chantier de refonte en 3 cercles (architecture-cercles.md).
4
+ Ce module philologique est désormais en Cercle 3 (``extras/``). L'alias
5
+ ici permet aux imports historiques (``from picarones.core.roman_numerals
6
+ import ...``) de continuer à fonctionner sans modification.
7
 
8
+ Voir :doc:`docs/architecture-cercles.md` et l'extra
9
+ ``picarones[historical]`` du ``pyproject.toml``.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  """
11
 
12
+ from picarones.extras.historical.roman_numerals import * # noqa: F401, F403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
+ import picarones.extras.historical.roman_numerals as _module
15
+ __all__ = getattr(_module, "__all__", [
16
+ name for name in dir(_module) if not name.startswith("_")
17
+ ])
 
 
 
 
 
 
 
 
 
 
 
 
picarones/core/unicode_blocks.py CHANGED
@@ -1,233 +1,17 @@
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.core.metric_registry import register_metric
55
- from picarones.core.modules 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
+ """Alias rétrocompat module déplacé dans :mod:`picarones.extras.historical.unicode_blocks`.
2
 
3
+ Phase B du chantier de refonte en 3 cercles (architecture-cercles.md).
4
+ Ce module philologique est désormais en Cercle 3 (``extras/``). L'alias
5
+ ici permet aux imports historiques (``from picarones.core.unicode_blocks
6
+ import ...``) de continuer à fonctionner sans modification.
7
 
8
+ Voir :doc:`docs/architecture-cercles.md` et l'extra
9
+ ``picarones[historical]`` du ``pyproject.toml``.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  """
11
 
12
+ from picarones.extras.historical.unicode_blocks import * # noqa: F401, F403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
+ import picarones.extras.historical.unicode_blocks as _module
15
+ __all__ = getattr(_module, "__all__", [
16
+ name for name in dir(_module) if not name.startswith("_")
17
+ ])
 
picarones/extras/historical/__init__.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Métriques philologiques pour documents historiques (Cercle 3).
2
+
3
+ Modules orientés cas d'usage patrimoniaux par période :
4
+
5
+ - :mod:`unicode_blocks` — précision par bloc Unicode (toutes périodes)
6
+ - :mod:`abbreviations` — score d'expansion d'abréviations (médiéval)
7
+ - :mod:`mufi` — couverture MUFI v4.0 (médiéval, PUA)
8
+ - :mod:`early_modern_typography` — fl, fi, ſ, ã, &, ı (XVIᵉ-XVIIIᵉ siècles)
9
+ - :mod:`modern_archives` — Mme/Mlle/°/†/₶ (XIXᵉ-XXᵉ siècles)
10
+ - :mod:`roman_numerals` — numéraux romains (toutes périodes)
11
+ - :mod:`lexical_modernization` — top tokens GT modernisés par le moteur
12
+ - :mod:`philological_runner` — orchestration adaptive des 6 modules
13
+
14
+ Utilité
15
+ -------
16
+ Ces métriques répondent à la question éditoriale *« quels caractères
17
+ historiques ce moteur restitue-t-il fidèlement ? »*. Elles ne
18
+ participent pas à la décision « peut-on déployer ce moteur en prod ? »
19
+ quand le corpus est moderne (les modules retournent ``None`` via
20
+ adaptive masking sur un texte sans signal philologique).
21
+
22
+ Plugin séparable
23
+ ----------------
24
+ Distribué via l'extra pip ``picarones[historical]``. Les imports
25
+ historiques ``from picarones.core.unicode_blocks import ...`` restent
26
+ fonctionnels via des fichiers-shims dans :mod:`picarones.core`.
27
+
28
+ Phase B du chantier de refonte en 3 cercles — voir
29
+ :doc:`docs/architecture-cercles.md`.
30
+ """
picarones/extras/historical/abbreviations.py ADDED
@@ -0,0 +1,350 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Score d'expansion d'abréviations médiévales — Sprint 56.
2
+
3
+ Sprint 56 — A.II.3.2 du plan d'évolution 2026 (axe philologique).
4
+
5
+ Pourquoi ce module
6
+ ------------------
7
+ Sur les manuscrits médiévaux (chartes, registres, copies de droit
8
+ canonique), les scribes utilisent intensivement des **signes
9
+ d'abréviation** : ``ꝑ`` (per/par), ``ꝓ`` (pro), ``ꝗ`` (qui),
10
+ ``ꝙ`` (quia), ``ꝯ`` (con/-us), ``⁊`` (et), tilde combinant pour
11
+ ``-en/-an``, etc.
12
+
13
+ Un OCR/HTR a deux comportements possibles face à ces signes :
14
+
15
+ 1. **Préservation** : la forme abrégée est gardée telle quelle
16
+ (``ꝑ`` → ``ꝑ``). C'est le comportement attendu d'une
17
+ transcription **diplomatique** (édition critique).
18
+ 2. **Développement** : le signe est remplacé par sa forme
19
+ développée (``ꝑ`` → ``per``). C'est le comportement attendu
20
+ d'une édition **modernisée**.
21
+
22
+ Une troisième possibilité — et c'est l'erreur qu'on cherche à
23
+ détecter : le signe est **mal restitué** (remplacé par un
24
+ caractère ASCII proche, supprimé, ou mal développé).
25
+
26
+ Ce module produit deux scores complémentaires :
27
+
28
+ - ``abbreviation_strict_score`` : taux d'abréviations GT dont la
29
+ **forme abrégée Unicode est préservée** dans l'OCR.
30
+ - ``abbreviation_expansion_score`` : taux d'abréviations GT dont
31
+ **soit** la forme abrégée, **soit** la forme développée
32
+ attendue, est présente dans l'OCR.
33
+
34
+ Le **ratio** des deux dit beaucoup sur la convention adoptée :
35
+
36
+ - ``strict ≈ expansion`` proche de 1 → le moteur est diplomatique
37
+ (préserve l'abrégé) ;
38
+ - ``strict << expansion`` → le moteur est modernisant (développe
39
+ systématiquement) ;
40
+ - les deux faibles → le moteur perd les abréviations (signal
41
+ d'erreur OCR).
42
+
43
+ Stratégie de découpage
44
+ ----------------------
45
+ Cohérente avec NER (Sprint 38), Flesch (52), Reading order F1 (53),
46
+ Layout F1 (54), Bloc Unicode (55) : couche de calcul pure d'abord.
47
+ Le câblage runner et la vue HTML suivent dans des sprints dédiés.
48
+
49
+ Limites documentées
50
+ -------------------
51
+ - L'alignement est **bag-of-occurrences** (proxy positionnel
52
+ simple) : on compte les occurrences GT et on vérifie leur
53
+ présence dans l'hyp. Pas d'alignement séquentiel rigoureux.
54
+ - La table d'abréviations couvre les signes les plus courants en
55
+ scriptura latine européenne (Capelli). Elle est extensible via
56
+ ``ABBREVIATION_EXPANSIONS``.
57
+ - Pour les abréviations marquées par un **tilde combinant**
58
+ (``p̃``, ``q̃``), on détecte la séquence ``lettre + U+0303``.
59
+ Pas de gestion fine des polices Capelli/MUFI complètes.
60
+ """
61
+
62
+ from __future__ import annotations
63
+
64
+ import logging
65
+ import re
66
+ import unicodedata
67
+ from typing import Optional
68
+
69
+ from picarones.core.metric_registry import register_metric
70
+ from picarones.core.modules import ArtifactType
71
+
72
+ logger = logging.getLogger(__name__)
73
+
74
+
75
+ # ──────────────────────────────────────────────────────────────────────────
76
+ # Table d'expansions
77
+ # ──────────────────────────────────────────────────────────────────────────
78
+
79
+ # Signes d'abréviation latins médiévaux les plus courants.
80
+ # Source : Capelli, "Lexicon Abbreviaturarum" (1929) + MUFI.
81
+ #
82
+ # La clé est une chaîne (1 ou 2 code-points pour le cas tilde
83
+ # combinant) ; la valeur est la liste des expansions courantes
84
+ # acceptées (les détails varient selon la convention éditoriale,
85
+ # on accepte plusieurs formes).
86
+ ABBREVIATION_EXPANSIONS: dict[str, tuple[str, ...]] = {
87
+ "ꝑ": ("per", "par"), # U+A751
88
+ "ꝓ": ("pro",), # U+A753
89
+ "ꝗ": ("qui",), # U+A757
90
+ "ꝙ": ("quia",), # U+A759
91
+ "ꝯ": ("us", "con"), # U+A76F
92
+ "⁊": ("et",), # U+204A "et" tironien
93
+ "ꝝ": ("rum",), # U+A75D
94
+ "ꝫ": ("et",), # U+A76B
95
+ "ꝭ": ("is",), # U+A76D
96
+ # Tilde combinant après lettre (U+0303 = ̃) : pẽ, qũ, etc.
97
+ "p̃": ("par", "per"),
98
+ "q̃": ("que", "qui"),
99
+ "ñ": ("an", "en"), # U+00F1 (Latin-1 Sup)
100
+ # Note : ñ existe aussi comme caractère latin moderne (espagnol),
101
+ # donc l'attribuer aux abréviations introduit du bruit ; on
102
+ # laisse au benchmark le soin d'évaluer. Pour les éditeurs
103
+ # médiévistes qui veulent restreindre, ils peuvent passer par
104
+ # une table custom (à venir).
105
+ }
106
+
107
+
108
+ # Set des "premiers code-points" reconnus comme début d'une
109
+ # abréviation (pour balayage rapide).
110
+ _ABBR_FIRST_CHARS: frozenset[str] = frozenset(
111
+ abbr[0] for abbr in ABBREVIATION_EXPANSIONS
112
+ )
113
+
114
+
115
+ # Combining tilde (U+0303) — utilisé pour la détection p̃, q̃, etc.
116
+ _COMBINING_TILDE = "̃"
117
+
118
+
119
+ # ──────────────────────────────────────────────────────────────────────────
120
+ # Détection d'abréviations dans un texte
121
+ # ──────────────────────────────────────────────────────────────────────────
122
+
123
+
124
+ def detect_abbreviations(text: Optional[str]) -> list[str]:
125
+ """Liste des abréviations médiévales détectées dans ``text``,
126
+ dans l'ordre d'apparition.
127
+
128
+ Reconnaît :
129
+
130
+ - Les caractères Unicode dédiés présents dans
131
+ ``ABBREVIATION_EXPANSIONS`` (``ꝑ``, ``ꝓ``, ``⁊``…).
132
+ - Les séquences ``lettre + U+0303`` (tilde combinant) si la
133
+ paire est dans la table (``p̃``, ``q̃``).
134
+
135
+ Doublons conservés : si le texte contient deux ``ꝑ``, la liste
136
+ en a deux. Cohérent avec le calcul bag-of-occurrences en aval.
137
+ """
138
+ if not text:
139
+ return []
140
+ found: list[str] = []
141
+ # Forme NFD pour reconnaître les ã, p̃, q̃ même quand l'utilisateur
142
+ # passe la forme NFC (« ñ » = U+00F1 sera traité par le mapping
143
+ # direct ; les séquences manuelles ``p`` + tilde combinant restent
144
+ # détectables).
145
+ text_nfd = unicodedata.normalize("NFD", text)
146
+ i = 0
147
+ while i < len(text_nfd):
148
+ ch = text_nfd[i]
149
+ # Cas 1 : lettre + tilde combinant
150
+ if i + 1 < len(text_nfd) and text_nfd[i + 1] == _COMBINING_TILDE:
151
+ seq = ch + _COMBINING_TILDE
152
+ if seq in ABBREVIATION_EXPANSIONS:
153
+ found.append(seq)
154
+ i += 2
155
+ continue
156
+ # Cas 2 : caractère unicode dédié
157
+ if ch in ABBREVIATION_EXPANSIONS:
158
+ found.append(ch)
159
+ i += 1
160
+ return found
161
+
162
+
163
+ # ──────────────────────────────────────────────────────────────────────────
164
+ # Scores
165
+ # ──────────────────────────────────────────────────────────────────────────
166
+
167
+
168
+ def _hyp_contains_abbr(hypothesis: str, abbr: str) -> bool:
169
+ """Vrai si la forme abrégée ``abbr`` apparaît telle quelle dans
170
+ ``hypothesis``. Sensible aux deux formes NFC / NFD pour les
171
+ séquences à tilde combinant."""
172
+ if abbr in hypothesis:
173
+ return True
174
+ # Pour les séquences ``lettre + tilde combinant``, l'hyp peut
175
+ # avoir une forme NFC (ex. ``ñ`` au lieu de ``n + U+0303``).
176
+ nfd = unicodedata.normalize("NFD", hypothesis)
177
+ return abbr in nfd
178
+
179
+
180
+ def _hyp_contains_expansion(
181
+ hypothesis: str, expansions: tuple[str, ...],
182
+ ) -> bool:
183
+ """Vrai si l'une des formes développées apparaît dans ``hypothesis``
184
+ (recherche insensible à la casse, sur les frontières de mots
185
+ pour limiter les faux positifs sur les sous-chaînes courtes
186
+ type ``us`` ou ``et``)."""
187
+ if not expansions:
188
+ return False
189
+ hyp_lower = hypothesis.lower()
190
+ for exp in expansions:
191
+ if not exp:
192
+ continue
193
+ # Recherche frontière de mot pour les expansions courtes.
194
+ # Pour ``per`` ou ``pro`` : on accepte le développement à
195
+ # n'importe quelle position d'un mot (tolère ``per`` dans
196
+ # ``permettre``, c'est imprécis mais pragmatique). Pour
197
+ # les expansions très courtes (≤ 2 lettres), on impose un
198
+ # mot complet pour limiter le bruit.
199
+ if len(exp) <= 2:
200
+ if re.search(rf"\b{re.escape(exp)}\b", hyp_lower):
201
+ return True
202
+ else:
203
+ if exp.lower() in hyp_lower:
204
+ return True
205
+ return False
206
+
207
+
208
+ def compute_abbreviation_metrics(
209
+ reference: Optional[str],
210
+ hypothesis: Optional[str],
211
+ ) -> dict:
212
+ """Calcule les scores d'abréviation strict et d'expansion.
213
+
214
+ Parameters
215
+ ----------
216
+ reference:
217
+ Texte GT (avec abréviations médiévales originales).
218
+ hypothesis:
219
+ Texte produit par l'OCR.
220
+
221
+ Returns
222
+ -------
223
+ dict
224
+ ``{
225
+ "n_abbreviations_in_reference": int,
226
+ "n_strict_preserved": int, # forme abrégée préservée
227
+ "n_expansion_preserved": int, # abrégée OU développée
228
+ "strict_score": float, # ∈ [0, 1]
229
+ "expansion_score": float, # ∈ [0, 1]
230
+ "per_abbreviation": [
231
+ {"abbr", "strict_preserved", "expansion_preserved",
232
+ "expansions"},
233
+ ...
234
+ ],
235
+ }``
236
+
237
+ Cas dégénérés
238
+ -------------
239
+ - GT vide ou sans abréviation détectée → tous les compteurs à 0
240
+ et les scores à ``0.0`` (convention : on ne récompense pas
241
+ l'absence d'abréviations).
242
+ - GT non vide avec abréviations + hyp vide → tous les scores
243
+ à ``0.0``.
244
+ """
245
+ ref = reference or ""
246
+ hyp = hypothesis or ""
247
+
248
+ abbreviations = detect_abbreviations(ref)
249
+ n = len(abbreviations)
250
+ if n == 0:
251
+ return {
252
+ "n_abbreviations_in_reference": 0,
253
+ "n_strict_preserved": 0,
254
+ "n_expansion_preserved": 0,
255
+ "strict_score": 0.0,
256
+ "expansion_score": 0.0,
257
+ "per_abbreviation": [],
258
+ }
259
+
260
+ n_strict = 0
261
+ n_expansion = 0
262
+ per_abbr: list[dict] = []
263
+ for abbr in abbreviations:
264
+ expansions = ABBREVIATION_EXPANSIONS.get(abbr, ())
265
+ strict_ok = _hyp_contains_abbr(hyp, abbr)
266
+ # Expansion : on accepte la forme abrégée OU le développement.
267
+ # Convention : si l'OCR a préservé la forme abrégée, c'est
268
+ # aussi compté comme valide pour le score d'expansion (le
269
+ # moteur n'a pas perdu l'information ; il a juste choisi
270
+ # une convention diplomatique).
271
+ expansion_ok = strict_ok or _hyp_contains_expansion(hyp, expansions)
272
+ if strict_ok:
273
+ n_strict += 1
274
+ if expansion_ok:
275
+ n_expansion += 1
276
+ per_abbr.append({
277
+ "abbr": abbr,
278
+ "strict_preserved": strict_ok,
279
+ "expansion_preserved": expansion_ok,
280
+ "expansions": list(expansions),
281
+ })
282
+
283
+ return {
284
+ "n_abbreviations_in_reference": n,
285
+ "n_strict_preserved": n_strict,
286
+ "n_expansion_preserved": n_expansion,
287
+ "strict_score": n_strict / n,
288
+ "expansion_score": n_expansion / n,
289
+ "per_abbreviation": per_abbr,
290
+ }
291
+
292
+
293
+ def abbreviation_strict_score(
294
+ reference: Optional[str], hypothesis: Optional[str],
295
+ ) -> float:
296
+ """Raccourci : taux de préservation **stricte** des abréviations
297
+ Unicode (forme abrégée gardée telle quelle)."""
298
+ return compute_abbreviation_metrics(reference, hypothesis)["strict_score"]
299
+
300
+
301
+ def abbreviation_expansion_score(
302
+ reference: Optional[str], hypothesis: Optional[str],
303
+ ) -> float:
304
+ """Raccourci : taux de préservation par expansion (forme abrégée
305
+ OU forme développée présente dans l'hyp)."""
306
+ return compute_abbreviation_metrics(reference, hypothesis)["expansion_score"]
307
+
308
+
309
+ # ──────────────────────────────────────────────────────────────────────────
310
+ # Enregistrement dans le registre typé (Sprint 34)
311
+ # ──────────────────────────────────────────────────────────────────────────
312
+
313
+
314
+ @register_metric(
315
+ name="abbreviation_strict_score",
316
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
317
+ description=(
318
+ "Taux d'abréviations médiévales (Unicode dédié + lettre + "
319
+ "tilde combinant) dont la forme abrégée est préservée telle "
320
+ "quelle dans l'OCR. Idéal pour les éditions diplomatiques."
321
+ ),
322
+ higher_is_better=True,
323
+ tags={"text", "abbreviation", "philology", "medieval"},
324
+ )
325
+ def _registered_strict(reference: str, hypothesis: str) -> float:
326
+ return abbreviation_strict_score(reference, hypothesis)
327
+
328
+
329
+ @register_metric(
330
+ name="abbreviation_expansion_score",
331
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
332
+ description=(
333
+ "Taux d'abréviations dont SOIT la forme abrégée Unicode SOIT "
334
+ "la forme développée attendue (per, pro, et…) est présente "
335
+ "dans l'OCR. Score plus large que strict_score."
336
+ ),
337
+ higher_is_better=True,
338
+ tags={"text", "abbreviation", "philology", "medieval"},
339
+ )
340
+ def _registered_expansion(reference: str, hypothesis: str) -> float:
341
+ return abbreviation_expansion_score(reference, hypothesis)
342
+
343
+
344
+ __all__ = [
345
+ "ABBREVIATION_EXPANSIONS",
346
+ "detect_abbreviations",
347
+ "compute_abbreviation_metrics",
348
+ "abbreviation_strict_score",
349
+ "abbreviation_expansion_score",
350
+ ]
picarones/extras/historical/early_modern_typography.py ADDED
@@ -0,0 +1,342 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Marqueurs typographiques de l'imprimé ancien (XVIᵉ-XVIIIᵉ).
2
+
3
+ Sprint 58 — Étape 3 / extension philologique du plan d'évolution
4
+ 2026.
5
+
6
+ Pourquoi ce module
7
+ ------------------
8
+ Les Sprints 56 (abréviations Capelli) et 57 (couverture MUFI) sont
9
+ orientés **médiéval scribal**. Mais Picarones doit aussi servir
10
+ les éditeurs d'**imprimés anciens** (XVIᵉ-XVIIIᵉ siècles), pour
11
+ qui les marqueurs caractéristiques ne sont pas scribaux mais
12
+ **typographiques** : ligatures composées (fi, fl, ff, ffi, ffl, ſt),
13
+ s long (ſ), i sans point (ı), esperluette (&), tildes nasaux
14
+ indiquant une abréviation (ã = an/am, õ = on/om).
15
+
16
+ Distinction avec MUFI/abbreviations
17
+ ------------------------------------
18
+ - ``mufi.py`` (Sprint 57) : caractères médiévaux scribaux
19
+ (Capelli + lettres þ ð ƿ + PUA MUFI).
20
+ - ``abbreviations.py`` (Sprint 56) : signes d'abréviation latins
21
+ scribaux médiévaux (ꝑ ꝓ ⁊ + tildes scribaux).
22
+ - ``early_modern_typography.py`` (ce module) : marqueurs
23
+ **typographiques** de la composition imprimée ancienne.
24
+
25
+ Les ligatures fi et fl sont communes aux deux univers (médiéval et
26
+ imprimé ancien) ; le choix du module à utiliser dépend du **corpus**
27
+ et de l'angle d'analyse éditoriale, pas du caractère pris isolément.
28
+
29
+ Catégorisation
30
+ --------------
31
+ Les marqueurs sont classés en cinq catégories pour permettre un
32
+ breakdown éditorial :
33
+
34
+ 1. ``ligatures`` : fi fl ff ffi ffl ſt
35
+ 2. ``long_s`` : ſ
36
+ 3. ``dotless_i`` : ı
37
+ 4. ``ampersand`` : & (esperluette typographique)
38
+ 5. ``nasal_tildes`` : ã õ ũ ñ ē ī (abréviation par tilde nasal)
39
+
40
+ ``compute_early_modern_metrics`` retourne le taux de préservation
41
+ par catégorie + global.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import logging
47
+ from difflib import SequenceMatcher
48
+ from typing import Optional
49
+
50
+ from picarones.core.metric_registry import register_metric
51
+ from picarones.core.modules import ArtifactType
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+
56
+ # ──────────────────────────────────────────────────────────────────────────
57
+ # Marqueurs typographiques imprimé ancien
58
+ # ──────────────────────────────────────────────────────────────────────────
59
+
60
+ # Ligatures typographiques héritées de l'incunable (XVᵉ) et toujours
61
+ # courantes jusqu'au XVIIIᵉ avant la normalisation typographique.
62
+ LIGATURES: frozenset[str] = frozenset({
63
+ "ff", # U+FB00 ff
64
+ "fi", # U+FB01 fi
65
+ "fl", # U+FB02 fl
66
+ "ffi", # U+FB03 ffi
67
+ "ffl", # U+FB04 ffl
68
+ "ſt", # U+FB05 long s + t
69
+ "st", # U+FB06 st
70
+ })
71
+
72
+ # S long : Latin Extended-A. Caractéristique de la typographie
73
+ # antérieure à 1800.
74
+ LONG_S: frozenset[str] = frozenset({"ſ"}) # U+017F
75
+
76
+ # i sans point : utilisé en typographie ancienne, parfois confondu
77
+ # avec un l ou un 1 par les OCR modernes.
78
+ DOTLESS_I: frozenset[str] = frozenset({"ı"}) # U+0131
79
+
80
+ # Esperluette typographique : "&" remplace fréquemment "et" dans
81
+ # les imprimés ; sa préservation discrimine un OCR diplomatique
82
+ # d'un OCR modernisant.
83
+ AMPERSAND: frozenset[str] = frozenset({"&"})
84
+
85
+ # Tildes nasaux : pré-composés (ñ ã ẽ ĩ õ ũ) ou séquences
86
+ # lettre + U+0303 combinant. En imprimé ancien, ã = an/am abrégé,
87
+ # õ = on/om, etc. Distinction avec les tildes scribaux médiévaux
88
+ # (Sprint 56) : ici on cible les **pré-composés** ou séquences sur
89
+ # des voyelles (le scribal médiéval cible plutôt p̃ q̃).
90
+ NASAL_TILDE_PRECOMPOSED: frozenset[str] = frozenset({
91
+ "ã", "Ã", # U+00E3 / U+00C3
92
+ "ñ", "Ñ", # U+00F1 / U+00D1
93
+ "õ", "Õ", # U+00F5 / U+00D5
94
+ "ũ", "Ũ", # U+0169 / U+0168
95
+ "ẽ", "Ẽ", # U+1EBD / U+1EBC
96
+ "ĩ", "Ĩ", # U+0129 / U+0128
97
+ })
98
+
99
+ # Voyelles susceptibles de porter un tilde combinant pour former
100
+ # un tilde nasal (couvre les écritures NFD non pré-composées).
101
+ _NASAL_TILDE_VOWELS: frozenset[str] = frozenset(
102
+ "aeiouAEIOU"
103
+ )
104
+ _COMBINING_TILDE = "̃"
105
+
106
+
107
+ # Catégorisation : nom → set de caractères pré-composés ou séquences.
108
+ _CATEGORIES: dict[str, frozenset[str]] = {
109
+ "ligatures": LIGATURES,
110
+ "long_s": LONG_S,
111
+ "dotless_i": DOTLESS_I,
112
+ "ampersand": AMPERSAND,
113
+ "nasal_tildes": NASAL_TILDE_PRECOMPOSED,
114
+ }
115
+
116
+
117
+ # ──────────────────────────────────────────────────────────────────────────
118
+ # Détection des marqueurs dans la GT
119
+ # ──────────────────────────────────────────────────────────────────────────
120
+
121
+
122
+ def _detect_markers(text: str) -> list[tuple[int, str, str]]:
123
+ """Retourne les positions des marqueurs typographiques dans
124
+ ``text``.
125
+
126
+ Forme de sortie : ``[(index, marker, category), ...]`` dans
127
+ l'ordre d'apparition. Pour les tildes nasaux non
128
+ pré-composés, on détecte les séquences ``voyelle + U+0303`` et
129
+ on retourne l'index de la voyelle.
130
+ """
131
+ if not text:
132
+ return []
133
+ found: list[tuple[int, str, str]] = []
134
+ i = 0
135
+ while i < len(text):
136
+ ch = text[i]
137
+ # Cas 1 : marqueur pré-composé dans une catégorie
138
+ category = _category_of_char(ch)
139
+ if category is not None:
140
+ found.append((i, ch, category))
141
+ i += 1
142
+ continue
143
+ # Cas 2 : voyelle + tilde combinant → nasal_tildes
144
+ if (
145
+ ch in _NASAL_TILDE_VOWELS
146
+ and i + 1 < len(text)
147
+ and text[i + 1] == _COMBINING_TILDE
148
+ ):
149
+ seq = ch + _COMBINING_TILDE
150
+ found.append((i, seq, "nasal_tildes"))
151
+ i += 2
152
+ continue
153
+ i += 1
154
+ return found
155
+
156
+
157
+ def _category_of_char(ch: str) -> Optional[str]:
158
+ """Retourne la catégorie d'un caractère typographique ou
159
+ ``None`` s'il n'est pas reconnu."""
160
+ for cat, chars in _CATEGORIES.items():
161
+ if ch in chars:
162
+ return cat
163
+ return None
164
+
165
+
166
+ # ──────────────────────────────────────────────────────────────────────────
167
+ # Calcul de la préservation par catégorie
168
+ # ──────────────────────────────────────────────────────────────────────────
169
+
170
+
171
+ def compute_early_modern_metrics(
172
+ reference: Optional[str],
173
+ hypothesis: Optional[str],
174
+ ) -> dict:
175
+ """Mesure la préservation des marqueurs typographiques de
176
+ l'imprimé ancien dans l'OCR.
177
+
178
+ Stratégie d'alignement
179
+ ----------------------
180
+ Pour chaque marqueur identifié dans la GT à la position ``i``,
181
+ on vérifie si l'OCR l'a préservé en utilisant l'alignement
182
+ caractère par caractère via ``difflib.SequenceMatcher`` (même
183
+ méthode que les Sprints 55/57) :
184
+
185
+ - Marqueur **mono-caractère** (fi, ſ, ı, &, ã…) : la position
186
+ ``i`` est-elle dans un opcode ``equal`` ?
187
+ - Marqueur **bi-caractère** (voyelle + U+0303) : les positions
188
+ ``i`` et ``i+1`` sont-elles toutes deux dans un opcode
189
+ ``equal`` ?
190
+
191
+ Returns
192
+ -------
193
+ dict
194
+ ``{
195
+ "n_markers_reference": int,
196
+ "n_markers_preserved": int,
197
+ "global_preservation": float, # ∈ [0, 1]
198
+ "per_category": {
199
+ category: {"total", "preserved", "preservation"}
200
+ },
201
+ "missed_markers": [{"index", "marker", "category"}, ...],
202
+ }``
203
+
204
+ Cas dégénérés : GT vide ou sans marqueur → tous compteurs à 0,
205
+ ``global_preservation = 0``.
206
+ """
207
+ ref = reference or ""
208
+ hyp = hypothesis or ""
209
+
210
+ # Forme NFD pour reconnaître les tildes nasaux décomposés (ã =
211
+ # 'a' + U+0303) côté GT — on conserve toutefois la forme passée
212
+ # pour les indices rapportés dans missed_markers.
213
+ markers = _detect_markers(ref)
214
+ n_total = len(markers)
215
+
216
+ if n_total == 0:
217
+ return {
218
+ "n_markers_reference": 0,
219
+ "n_markers_preserved": 0,
220
+ "global_preservation": 0.0,
221
+ "per_category": {},
222
+ "missed_markers": [],
223
+ }
224
+
225
+ # Aligner GT/hyp et récupérer le set des positions GT couvertes
226
+ # par un opcode "equal".
227
+ matcher = SequenceMatcher(a=ref, b=hyp, autojunk=False)
228
+ correct_positions: set[int] = set()
229
+ for op, i1, i2, _j1, _j2 in matcher.get_opcodes():
230
+ if op == "equal":
231
+ correct_positions.update(range(i1, i2))
232
+
233
+ per_cat_total: dict[str, int] = {}
234
+ per_cat_preserved: dict[str, int] = {}
235
+ n_preserved = 0
236
+ missed: list[dict] = []
237
+
238
+ for index, marker, category in markers:
239
+ per_cat_total[category] = per_cat_total.get(category, 0) + 1
240
+ # Marqueur préservé si toutes ses positions GT sont dans
241
+ # un opcode "equal".
242
+ marker_len = len(marker)
243
+ positions_ok = all(
244
+ (index + k) in correct_positions for k in range(marker_len)
245
+ )
246
+ if positions_ok:
247
+ per_cat_preserved[category] = (
248
+ per_cat_preserved.get(category, 0) + 1
249
+ )
250
+ n_preserved += 1
251
+ else:
252
+ missed.append({
253
+ "index": index,
254
+ "marker": marker,
255
+ "category": category,
256
+ })
257
+
258
+ per_category = {
259
+ cat: {
260
+ "total": per_cat_total[cat],
261
+ "preserved": per_cat_preserved.get(cat, 0),
262
+ "preservation": (
263
+ per_cat_preserved.get(cat, 0) / per_cat_total[cat]
264
+ if per_cat_total[cat] > 0
265
+ else 0.0
266
+ ),
267
+ }
268
+ for cat in sorted(per_cat_total)
269
+ }
270
+
271
+ return {
272
+ "n_markers_reference": n_total,
273
+ "n_markers_preserved": n_preserved,
274
+ "global_preservation": n_preserved / n_total,
275
+ "per_category": per_category,
276
+ "missed_markers": missed,
277
+ }
278
+
279
+
280
+ def early_modern_preservation(
281
+ reference: Optional[str], hypothesis: Optional[str],
282
+ ) -> float:
283
+ """Raccourci : taux global de préservation des marqueurs
284
+ typographiques de l'imprimé ancien."""
285
+ return compute_early_modern_metrics(
286
+ reference, hypothesis,
287
+ )["global_preservation"]
288
+
289
+
290
+ # ──────────────────────────────────────────────────────────────────────────
291
+ # Helpers exposés
292
+ # ──────────────────────────────────────────────────────────────────────────
293
+
294
+
295
+ def detect_markers(text: Optional[str]) -> list[tuple[int, str, str]]:
296
+ """Wrapper public sur ``_detect_markers`` (acceptant ``None``)."""
297
+ return _detect_markers(text or "")
298
+
299
+
300
+ def get_category(char: str) -> Optional[str]:
301
+ """Retourne la catégorie typographique d'un caractère
302
+ (``ligatures``, ``long_s``, ``dotless_i``, ``ampersand``,
303
+ ``nasal_tildes``) ou ``None``.
304
+
305
+ Pour un tilde combinant suivi d'une voyelle, l'utilisateur doit
306
+ utiliser ``detect_markers`` qui gère les séquences.
307
+ """
308
+ return _category_of_char(char[0]) if char else None
309
+
310
+
311
+ # ──────────────────────────────────────────────────────────────────────────
312
+ # Enregistrement dans le registre typé (Sprint 34)
313
+ # ──────────────────────────────────────────────────────────────────────────
314
+
315
+
316
+ @register_metric(
317
+ name="early_modern_preservation",
318
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
319
+ description=(
320
+ "Taux de préservation des marqueurs typographiques de "
321
+ "l'imprimé ancien (XVIᵉ-XVIIIᵉ) : ligatures fi fl ff, s long ſ, "
322
+ "i sans point ı, esperluette &, tildes nasaux ã õ. Critère "
323
+ "éditorial pour les éditions diplomatiques d'imprimés anciens."
324
+ ),
325
+ higher_is_better=True,
326
+ tags={"text", "typography", "early_modern", "philology"},
327
+ )
328
+ def _registered_early_modern(reference: str, hypothesis: str) -> float:
329
+ return early_modern_preservation(reference, hypothesis)
330
+
331
+
332
+ __all__ = [
333
+ "LIGATURES",
334
+ "LONG_S",
335
+ "DOTLESS_I",
336
+ "AMPERSAND",
337
+ "NASAL_TILDE_PRECOMPOSED",
338
+ "detect_markers",
339
+ "get_category",
340
+ "compute_early_modern_metrics",
341
+ "early_modern_preservation",
342
+ ]
picarones/extras/historical/lexical_modernization.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Détection de la sur-normalisation lexicale par les LLM/VLM —
2
+ Sprint 80 (A.I.7).
3
+
4
+ Sprint 80 — A.I.7 du plan d'évolution 2026.
5
+
6
+ Pourquoi ce module
7
+ ------------------
8
+ Le détecteur ``llm_hallucination_flag`` (Sprint 19) signale qu'un
9
+ moteur sur-normalise (« 0,05 % »). Mais ce score agrégé ne dit
10
+ rien sur **quoi** corriger dans le prompt. Ce module produit
11
+ une **table de fréquences détaillée** :
12
+
13
+ +----------------------+--------------------+------+----------+
14
+ | Forme historique GT | Forme modernisée | n GT | % modern |
15
+ +======================+====================+======+==========+
16
+ | maistre | maître | 47 | 85 % |
17
+ | nostre | nostre | 92 | 8 % |
18
+ | veoir | voir | 23 | 100 % |
19
+ +----------------------+--------------------+------+----------+
20
+
21
+ Lecture immédiate : *« le LLM modernise systématiquement
22
+ maistre → maître ; pour préserver l'orthographe historique, ajouter
23
+ au prompt "ne pas moderniser maistre, nostre, veoir" »*.
24
+
25
+ Méthode
26
+ -------
27
+ Alignement mot-à-mot via ``difflib.SequenceMatcher``. Chaque
28
+ ``replace`` ou ``equal`` produit une paire ``(gt_token,
29
+ hyp_token)``. On accumule pour chaque ``gt_token`` :
30
+
31
+ - ``n_total`` : nombre d'occurrences du token dans la GT
32
+ - ``n_modernized`` : nombre d'occurrences où ``hyp_token != gt_token``
33
+ - ``variants`` : dict des hyp_tokens observés avec leur count
34
+
35
+ Stop-list
36
+ ---------
37
+ L'utilisateur peut passer ``stop_list`` (ensemble de tokens GT à
38
+ ignorer). Par défaut, vide — le module ne tente pas de deviner ce
39
+ qui est « moderne » ou « historique », c'est au chercheur de
40
+ fournir le filtre adapté à son corpus.
41
+
42
+ Sortie
43
+ ------
44
+ ``compute_lexical_modernization`` retourne une structure adaptée
45
+ au rendu HTML. ``aggregate_lexical_modernization`` agrège
46
+ plusieurs documents.
47
+
48
+ Limites documentées
49
+ -------------------
50
+ - Tokenisation au niveau mot (split sur espace) — cohérent avec
51
+ ``taxonomy.py`` et autres modules. Pas de stemming ni de
52
+ lemmatisation.
53
+ - La métrique mesure la **réécriture lexicale** ; elle n'attrape
54
+ pas les modernisations infra-mot (perte du s long ſ qui se
55
+ fond dans la même forme). Pour ça, voir ``early_modern_typography``
56
+ (Sprint 58) et ``equivalence_profile`` (Sprint 78).
57
+ """
58
+
59
+ from __future__ import annotations
60
+
61
+ import difflib
62
+ import logging
63
+ from typing import Iterable, Optional
64
+
65
+ logger = logging.getLogger(__name__)
66
+
67
+
68
+ def _split_words(text: Optional[str]) -> list[str]:
69
+ """Tokenisation simple par split sur whitespace."""
70
+ if not text:
71
+ return []
72
+ return text.split()
73
+
74
+
75
+ def compute_lexical_modernization(
76
+ reference: Optional[str],
77
+ hypothesis: Optional[str],
78
+ *,
79
+ stop_list: Optional[Iterable[str]] = None,
80
+ case_sensitive: bool = False,
81
+ ) -> dict:
82
+ """Calcule le tableau de modernisation lexicale pour un document.
83
+
84
+ Returns
85
+ -------
86
+ dict
87
+ ``{
88
+ "n_gt_tokens": int,
89
+ "tokens": {
90
+ gt_token: {
91
+ "n_total": int,
92
+ "n_modernized": int,
93
+ "rate_modernized": float, # ∈ [0, 1]
94
+ "variants": {hyp_token: count, ...},
95
+ },
96
+ ...
97
+ },
98
+ }``
99
+ Si ``reference`` est vide → ``tokens == {}``.
100
+ """
101
+ ref_tokens = _split_words(reference)
102
+ hyp_tokens = _split_words(hypothesis)
103
+ if not ref_tokens:
104
+ return {"n_gt_tokens": 0, "tokens": {}}
105
+
106
+ if not case_sensitive:
107
+ ref_for_match = [t.lower() for t in ref_tokens]
108
+ hyp_for_match = [t.lower() for t in hyp_tokens]
109
+ else:
110
+ ref_for_match = ref_tokens
111
+ hyp_for_match = hyp_tokens
112
+
113
+ stop = frozenset(
114
+ (t.lower() if not case_sensitive else t)
115
+ for t in (stop_list or [])
116
+ )
117
+
118
+ # On accumule par gt_token (forme display = forme originale,
119
+ # match key = forme casée selon ``case_sensitive``).
120
+ tokens_data: dict[str, dict] = {}
121
+
122
+ matcher = difflib.SequenceMatcher(
123
+ None, ref_for_match, hyp_for_match, autojunk=False,
124
+ )
125
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
126
+ if tag == "equal":
127
+ for k in range(i2 - i1):
128
+ gt_orig = ref_tokens[i1 + k]
129
+ gt_match = ref_for_match[i1 + k]
130
+ if gt_match in stop:
131
+ continue
132
+ slot = tokens_data.setdefault(
133
+ gt_orig,
134
+ {"n_total": 0, "n_modernized": 0, "variants": {}},
135
+ )
136
+ slot["n_total"] += 1
137
+ elif tag == "replace":
138
+ # Apparier 1-à-1 quand possible
139
+ paired = min(i2 - i1, j2 - j1)
140
+ for k in range(paired):
141
+ gt_orig = ref_tokens[i1 + k]
142
+ gt_match = ref_for_match[i1 + k]
143
+ if gt_match in stop:
144
+ continue
145
+ hyp_orig = hyp_tokens[j1 + k]
146
+ slot = tokens_data.setdefault(
147
+ gt_orig,
148
+ {"n_total": 0, "n_modernized": 0, "variants": {}},
149
+ )
150
+ slot["n_total"] += 1
151
+ slot["n_modernized"] += 1
152
+ slot["variants"][hyp_orig] = slot["variants"].get(hyp_orig, 0) + 1
153
+ # Si plus de gt que de hyp, le reste des gt_tokens est
154
+ # « perdu » — on les compte comme totaux mais pas comme
155
+ # modernisés (on ne sait pas en quoi).
156
+ for k in range(paired, i2 - i1):
157
+ gt_orig = ref_tokens[i1 + k]
158
+ gt_match = ref_for_match[i1 + k]
159
+ if gt_match in stop:
160
+ continue
161
+ slot = tokens_data.setdefault(
162
+ gt_orig,
163
+ {"n_total": 0, "n_modernized": 0, "variants": {}},
164
+ )
165
+ slot["n_total"] += 1
166
+ slot["n_modernized"] += 1
167
+ slot["variants"]["∅"] = slot["variants"].get("∅", 0) + 1
168
+ elif tag == "delete":
169
+ # gt présent, pas en hyp → modernisation par
170
+ # suppression (ou perte pure)
171
+ for k in range(i2 - i1):
172
+ gt_orig = ref_tokens[i1 + k]
173
+ gt_match = ref_for_match[i1 + k]
174
+ if gt_match in stop:
175
+ continue
176
+ slot = tokens_data.setdefault(
177
+ gt_orig,
178
+ {"n_total": 0, "n_modernized": 0, "variants": {}},
179
+ )
180
+ slot["n_total"] += 1
181
+ slot["n_modernized"] += 1
182
+ slot["variants"]["∅"] = slot["variants"].get("∅", 0) + 1
183
+
184
+ # Calcul du taux par token
185
+ for slot in tokens_data.values():
186
+ total = slot["n_total"]
187
+ slot["rate_modernized"] = (
188
+ slot["n_modernized"] / total if total > 0 else 0.0
189
+ )
190
+
191
+ return {
192
+ "n_gt_tokens": len(ref_tokens),
193
+ "tokens": tokens_data,
194
+ }
195
+
196
+
197
+ def aggregate_lexical_modernization(
198
+ per_doc_results: Iterable[dict],
199
+ ) -> dict:
200
+ """Agrège des ``compute_lexical_modernization`` per-doc.
201
+
202
+ Renvoie la structure agrégée corpus-wide avec la même forme
203
+ que ``compute_lexical_modernization``.
204
+ """
205
+ agg_tokens: dict[str, dict] = {}
206
+ n_gt_total = 0
207
+ for doc_result in per_doc_results:
208
+ if not doc_result:
209
+ continue
210
+ n_gt_total += doc_result.get("n_gt_tokens", 0)
211
+ for gt, data in (doc_result.get("tokens") or {}).items():
212
+ slot = agg_tokens.setdefault(
213
+ gt, {"n_total": 0, "n_modernized": 0, "variants": {}},
214
+ )
215
+ slot["n_total"] += data.get("n_total", 0)
216
+ slot["n_modernized"] += data.get("n_modernized", 0)
217
+ for hyp_t, count in (data.get("variants") or {}).items():
218
+ slot["variants"][hyp_t] = slot["variants"].get(hyp_t, 0) + count
219
+
220
+ for slot in agg_tokens.values():
221
+ total = slot["n_total"]
222
+ slot["rate_modernized"] = (
223
+ slot["n_modernized"] / total if total > 0 else 0.0
224
+ )
225
+ return {
226
+ "n_gt_tokens": n_gt_total,
227
+ "tokens": agg_tokens,
228
+ }
229
+
230
+
231
+ def top_modernized_tokens(
232
+ data: dict,
233
+ *,
234
+ n: int = 20,
235
+ min_total: int = 1,
236
+ ) -> list[tuple[str, dict]]:
237
+ """Top-N tokens GT par taux de modernisation.
238
+
239
+ Filtre les tokens dont ``n_total < min_total`` (anecdotiques).
240
+ Tri par ``rate_modernized`` décroissant, tie-break par
241
+ ``n_total`` décroissant.
242
+ """
243
+ tokens = data.get("tokens") or {}
244
+ candidates = [
245
+ (gt, slot) for gt, slot in tokens.items()
246
+ if slot.get("n_total", 0) >= min_total
247
+ and slot.get("n_modernized", 0) > 0
248
+ ]
249
+ candidates.sort(
250
+ key=lambda pair: (
251
+ -pair[1].get("rate_modernized", 0.0),
252
+ -pair[1].get("n_total", 0),
253
+ pair[0],
254
+ ),
255
+ )
256
+ return candidates[:n]
257
+
258
+
259
+ __all__ = [
260
+ "compute_lexical_modernization",
261
+ "aggregate_lexical_modernization",
262
+ "top_modernized_tokens",
263
+ ]
picarones/extras/historical/modern_archives.py ADDED
@@ -0,0 +1,600 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Marqueurs typographiques et abréviations des archives modernes
2
+ (XIXᵉ-XXᵉ siècles) — Sprint 59.
3
+
4
+ Sprint 59 — Étape 3 / extension philologique du plan d'évolution
5
+ 2026.
6
+
7
+ Pourquoi ce module
8
+ ------------------
9
+ Les Sprints 56-57 sont orientés **médiéval scribal** (Capelli, MUFI),
10
+ le Sprint 58 cible l'**imprimé ancien** XVIᵉ-XVIIIᵉ. Ce sprint étend
11
+ la couverture aux **archives modernes** (XIXᵉ-XXᵉ), période où la
12
+ typographie historique a disparu mais où subsistent des conventions
13
+ d'abréviation propres aux corpus institutionnels (état civil,
14
+ recensements, presse, monographies, archives militaires).
15
+
16
+ Distinction avec les modules précédents
17
+ ---------------------------------------
18
+ - ``mufi.py`` (Sprint 57) : caractères médiévaux scribaux.
19
+ - ``abbreviations.py`` (Sprint 56) : signes scribaux médiévaux.
20
+ - ``early_modern_typography.py`` (Sprint 58) : marqueurs
21
+ typographiques imprimé ancien (fi ſ ı &…).
22
+ - ``modern_archives.py`` (ce module) : abréviations et conventions
23
+ de l'archive moderne XIXᵉ-XXᵉ.
24
+
25
+ Catégories
26
+ ----------
27
+ 1. ``civility_titles`` : Mme, M., Mlle, Mgr, Dr, Pr, Me, R.P., S.M.,
28
+ S.A.R., S.E., S.S.
29
+ 2. ``ordinals`` : 1ᵉʳ, 1ʳᵉ, 2ᵉ, 2ᵈ, Vᵉ (avec exposants Unicode)
30
+ 3. ``currency`` : ₶ (livre tournois), ₣ ƒ (franc), £, l. s. d.
31
+ (livre/sol/denier d'Ancien Régime)
32
+ 4. ``administrative`` : arr., dép., cant., com., reg., prov.
33
+ 5. ``civil_status`` : °, †, ✶, ⚭, ép., vve
34
+ 6. ``typographic_punctuation`` : « », –, —, …, ’
35
+ 7. ``latin_abbr_modern`` : e.g., i.e., etc., cf., ibid., op. cit.,
36
+ ad lib.
37
+ 8. ``bibliographic`` : vol., t., p., pp., n°, fasc., éd., ms.,
38
+ r°, v°
39
+ 9. ``address`` : bd, av., r., pl., imp., fbg
40
+
41
+ Sortie
42
+ ------
43
+ ``compute_modern_archives_metrics(ref, hyp)`` retourne deux scores
44
+ par catégorie (pattern Sprint 56) :
45
+
46
+ - ``strict_score`` : forme abrégée préservée telle quelle ;
47
+ - ``expansion_score`` : forme abrégée OU forme développée présente.
48
+
49
+ Le **ratio strict/expansion** par catégorie permet au chercheur de
50
+ juger lui-même la convention adoptée par chaque moteur, sans
51
+ classification automatique imposée par le module.
52
+
53
+ Stratégie de découpage
54
+ ----------------------
55
+ Cohérente avec NER (38), Flesch (52), Reading order F1 (53),
56
+ Layout F1 (54), Bloc Unicode (55), Abréviations (56), MUFI (57),
57
+ Imprimé ancien (58) : couche de calcul pure d'abord ; câblage
58
+ runner et HTML dans des sprints dédiés.
59
+ """
60
+
61
+ from __future__ import annotations
62
+
63
+ import logging
64
+ import re
65
+ from typing import Optional
66
+
67
+ from picarones.core.metric_registry import register_metric
68
+ from picarones.core.modules import ArtifactType
69
+
70
+ logger = logging.getLogger(__name__)
71
+
72
+
73
+ # ──────────────────────────────────────────────────────────────────────────
74
+ # Tables d'abréviations par catégorie
75
+ # ──────────────────────────────────────────────────────────────────────────
76
+ #
77
+ # Format : tuple ``(marker, expansions, regex_strict_pattern_or_None)``
78
+ # où :
79
+ # - ``marker`` : forme abrégée canonique (str)
80
+ # - ``expansions`` : tuple de formes développées
81
+ # acceptées (insensible à la casse)
82
+ # - ``regex_strict_pattern`` : pattern Python regex pour la
83
+ # détection dans la GT. ``None``
84
+ # = on dérive automatiquement
85
+ # ``\b<marker_escaped>\b`` (avec
86
+ # garde-fou sur les abréviations
87
+ # contenant un point).
88
+ #
89
+ # Détection : pour les abréviations contenant un ``.`` (« M. »),
90
+ # on n'utilise pas ``\b`` standard car « M.\b » match dans
91
+ # « M.A. » (le ``.`` étant non-mot, ``\b`` est satisfait). On
92
+ # exige donc explicitement une frontière espace/début/fin/
93
+ # ponctuation après le point.
94
+
95
+ CIVILITY_TITLES: tuple[tuple[str, tuple[str, ...]], ...] = (
96
+ ("Mme", ("Madame",)),
97
+ ("Mlle", ("Mademoiselle",)),
98
+ ("Mgr", ("Monseigneur",)),
99
+ ("Dr", ("Docteur",)),
100
+ ("Pr", ("Professeur",)),
101
+ ("Me", ("Maître",)),
102
+ ("M.", ("Monsieur",)),
103
+ ("R.P.", ("Révérend Père",)),
104
+ ("S.M.", ("Sa Majesté",)),
105
+ ("S.A.R.", ("Son Altesse Royale",)),
106
+ ("S.E.", ("Son Excellence",)),
107
+ ("S.S.", ("Sa Sainteté",)),
108
+ )
109
+
110
+ # Ordinaux : la forme **strict** porte l'exposant Unicode
111
+ # (1ᵉʳ U+1D49 U+02B3, 1ʳᵉ, 2ᵈ, 2ᵉ, 3ᵉ…) ; la forme **expansion**
112
+ # accepte la version plate (« 1er », « 1re », « 2nd ») ou la forme
113
+ # textuelle (« premier », « première »).
114
+ #
115
+ # On définit chaque ordinal explicitement (1-12 + Vᵉ pour les
116
+ # numéraux romains de siècle). Au-delà, l'exposant ᵉ seul couvre
117
+ # les usages courants (3ᵉ, 4ᵉ, 5ᵉ, 6ᵉ, 7ᵉ, 8ᵉ, 9ᵉ, 10ᵉ).
118
+
119
+ ORDINALS: tuple[tuple[str, tuple[str, ...]], ...] = (
120
+ ("1ᵉʳ", ("1er", "premier")),
121
+ ("1ʳᵉ", ("1re", "première", "premiere")),
122
+ ("2ᵈ", ("2d", "second")),
123
+ ("2ᵈᵉ", ("2de", "seconde")),
124
+ ("2ᵉ", ("2e", "deuxième", "deuxieme")),
125
+ ("3ᵉ", ("3e", "troisième", "troisieme")),
126
+ ("Iᵉʳ", ("Ier", "premier")),
127
+ ("Vᵉ", ("Ve", "cinquième", "cinquieme")),
128
+ ("XIᵉ", ("XIe", "onzième", "onzieme")),
129
+ ("XIIᵉ", ("XIIe", "douzième", "douzieme")),
130
+ ("XVIᵉ", ("XVIe", "seizième", "seizieme")),
131
+ ("XVIIᵉ", ("XVIIe", "dix-septième", "dix-septieme")),
132
+ ("XVIIIᵉ", ("XVIIIe", "dix-huitième", "dix-huitieme")),
133
+ ("XIXᵉ", ("XIXe", "dix-neuvième", "dix-neuvieme")),
134
+ ("XXᵉ", ("XXe", "vingtième", "vingtieme")),
135
+ )
136
+
137
+ CURRENCY: tuple[tuple[str, tuple[str, ...]], ...] = (
138
+ ("₶", ("livre tournois", "livres tournois")),
139
+ ("₣", ("franc", "francs")),
140
+ ("ƒ", ("florin", "florins")),
141
+ ("£", ("livre", "livres", "pound", "pounds")),
142
+ ("l.", ("livre", "livres")),
143
+ ("s.", ("sol", "sols", "sou", "sous")),
144
+ ("d.", ("denier", "deniers")),
145
+ )
146
+
147
+ ADMINISTRATIVE: tuple[tuple[str, tuple[str, ...]], ...] = (
148
+ ("arr.", ("arrondissement",)),
149
+ ("dép.", ("département", "departement")),
150
+ ("cant.", ("canton",)),
151
+ ("com.", ("commune",)),
152
+ ("reg.", ("régiment", "regiment")),
153
+ ("prov.", ("province",)),
154
+ )
155
+
156
+ # État civil : signes typographiques (° = né, † = mort, ⚭ = marié)
157
+ # et abréviations textuelles (ép. = épouse/époux, vve = veuve).
158
+ CIVIL_STATUS: tuple[tuple[str, tuple[str, ...]], ...] = (
159
+ ("°", ("né", "née")),
160
+ ("†", ("mort", "morte", "décédé", "décédée")),
161
+ ("✶", ("naissance",)),
162
+ ("⚭", ("marié", "mariée", "épousa", "epousa")),
163
+ ("ép.", ("épouse", "époux", "epouse", "epoux")),
164
+ ("vve", ("veuve",)),
165
+ )
166
+
167
+ # Ponctuation typographique : ces marqueurs sont préservés en
168
+ # diplomatique et remplacés par leur équivalent ASCII en
169
+ # modernisant. L'expansion n'est pas une « expansion » au sens
170
+ # linguistique mais un substitut typographique.
171
+ TYPOGRAPHIC_PUNCTUATION: tuple[tuple[str, tuple[str, ...]], ...] = (
172
+ ("«", ('"',)),
173
+ ("»", ('"',)),
174
+ ("—", ("-", "--")),
175
+ ("–", ("-",)),
176
+ ("…", ("...",)),
177
+ ("’", ("'",)),
178
+ ("‘", ("'",)),
179
+ )
180
+
181
+ LATIN_ABBR_MODERN: tuple[tuple[str, tuple[str, ...]], ...] = (
182
+ ("e.g.", ("for example", "par exemple", "exempli gratia")),
183
+ ("i.e.", ("c'est-à-dire", "id est", "that is")),
184
+ ("etc.", ("et cetera", "et caetera")),
185
+ ("cf.", ("confer", "voir")),
186
+ ("ibid.", ("ibidem",)),
187
+ ("op. cit.", ("opere citato", "opus citatum")),
188
+ ("ad lib.", ("ad libitum",)),
189
+ ("N.B.", ("nota bene",)),
190
+ )
191
+
192
+ BIBLIOGRAPHIC: tuple[tuple[str, tuple[str, ...]], ...] = (
193
+ ("vol.", ("volume",)),
194
+ ("t.", ("tome",)),
195
+ ("p.", ("page",)),
196
+ ("pp.", ("pages",)),
197
+ ("n°", ("numéro", "numero", "no")),
198
+ ("fasc.", ("fascicule",)),
199
+ ("éd.", ("édition", "edition")),
200
+ ("ms.", ("manuscrit",)),
201
+ ("f.", ("folio",)),
202
+ ("r°", ("recto",)),
203
+ ("v°", ("verso",)),
204
+ )
205
+
206
+ ADDRESS: tuple[tuple[str, tuple[str, ...]], ...] = (
207
+ ("bd", ("boulevard",)),
208
+ ("av.", ("avenue",)),
209
+ ("r.", ("rue",)),
210
+ ("pl.", ("place",)),
211
+ ("imp.", ("impasse",)),
212
+ ("fbg", ("faubourg",)),
213
+ )
214
+
215
+
216
+ # ──────────────────────────────────────────────────────────────────────────
217
+ # Indexation par catégorie
218
+ # ──────────────────────────────────────────────────────────────────────────
219
+
220
+ _CATEGORIES: dict[str, tuple[tuple[str, tuple[str, ...]], ...]] = {
221
+ "civility_titles": CIVILITY_TITLES,
222
+ "ordinals": ORDINALS,
223
+ "currency": CURRENCY,
224
+ "administrative": ADMINISTRATIVE,
225
+ "civil_status": CIVIL_STATUS,
226
+ "typographic_punctuation": TYPOGRAPHIC_PUNCTUATION,
227
+ "latin_abbr_modern": LATIN_ABBR_MODERN,
228
+ "bibliographic": BIBLIOGRAPHIC,
229
+ "address": ADDRESS,
230
+ }
231
+
232
+ # Liste plate de tous les marqueurs avec leur catégorie. Triée par
233
+ # longueur décroissante pour que la détection préfère le marqueur
234
+ # le plus long quand plusieurs préfixes matchent (ex. « S.A.R. »
235
+ # avant « S.A. ").
236
+ _ALL_MARKERS: list[tuple[str, tuple[str, ...], str]] = sorted(
237
+ [
238
+ (marker, expansions, category)
239
+ for category, entries in _CATEGORIES.items()
240
+ for marker, expansions in entries
241
+ ],
242
+ key=lambda triple: -len(triple[0]),
243
+ )
244
+
245
+
246
+ # ──────────────────────────────────────────────────────────────────────────
247
+ # Compilation des patterns regex
248
+ # ──────────────────────────────────────────────────────────────────────────
249
+ #
250
+ # Pour chaque marqueur, on compile un pattern qui exige une
251
+ # frontière de mot adaptée :
252
+ #
253
+ # - Marqueur alphabétique seul (« Mme », « bd ») → ``\b<marker>\b``
254
+ # (le ``\b`` Python gère correctement les bords).
255
+ # - Marqueur contenant un point (« M. », « S.A.R. », « arr. »,
256
+ # « r° », « n° ») → frontière espace/début/fin/ponctuation
257
+ # explicite (le ``.`` final étant non-mot, ``\b`` standard
258
+ # matcherait dans « arr.acher »).
259
+ # - Marqueur contenant un caractère non ASCII (exposant, monnaie,
260
+ # guillemet, croix d'état civil) → match littéral, pas de
261
+ # frontière de mot car ``\b`` ne fonctionne pas sur les
262
+ # caractères non-mot Unicode.
263
+ #
264
+ # La frontière de droite après un point exige soit la fin de
265
+ # chaîne, soit un blanc, soit une ponctuation usuelle (« , ; : ! ? )
266
+ # … » »).
267
+
268
+ _TRAILING_BOUNDARY = r"(?=$|[\s,;:!?\)\]\»\"\'\n\r\t…])"
269
+ _LEADING_BOUNDARY = r"(?:^|(?<=[\s,;:!?\(\[\«\"\'\n\r\t]))"
270
+
271
+
272
+ def _is_alphanumeric_only(text: str) -> bool:
273
+ """Vrai si tous les caractères sont alphanumériques ASCII."""
274
+ return all(c.isascii() and c.isalnum() for c in text)
275
+
276
+
277
+ def _compile_pattern(marker: str) -> re.Pattern[str]:
278
+ """Compile le pattern regex pour la détection d'un marqueur
279
+ dans la GT et l'hypothèse.
280
+
281
+ La logique de frontière de mot dépend de la composition du
282
+ marqueur (cf. commentaire principal).
283
+ """
284
+ escaped = re.escape(marker)
285
+ if "." in marker:
286
+ # Frontière explicite après le point final.
287
+ return re.compile(_LEADING_BOUNDARY + escaped + _TRAILING_BOUNDARY)
288
+ if _is_alphanumeric_only(marker):
289
+ return re.compile(r"\b" + escaped + r"\b")
290
+ # Marqueurs Unicode (exposants, monnaies, guillemets, ponctuation
291
+ # typographique, croix) : match littéral, pas de \b.
292
+ return re.compile(escaped)
293
+
294
+
295
+ # Cache des patterns compilés : (marker, category) → pattern.
296
+ _PATTERNS: dict[tuple[str, str], re.Pattern[str]] = {
297
+ (marker, category): _compile_pattern(marker)
298
+ for marker, _expansions, category in _ALL_MARKERS
299
+ }
300
+
301
+ # Patterns d'expansion (insensibles à la casse, frontière de mot
302
+ # si la forme développée est purement alphabétique).
303
+ _EXPANSION_PATTERNS: dict[str, list[re.Pattern[str]]] = {}
304
+ for marker, expansions, _category in _ALL_MARKERS:
305
+ compiled: list[re.Pattern[str]] = []
306
+ for exp in expansions:
307
+ escaped = re.escape(exp)
308
+ if exp and _is_alphanumeric_only(exp):
309
+ compiled.append(re.compile(r"\b" + escaped + r"\b", re.IGNORECASE))
310
+ else:
311
+ compiled.append(re.compile(escaped, re.IGNORECASE))
312
+ _EXPANSION_PATTERNS[marker] = compiled
313
+
314
+
315
+ # ──────────────────────────────────────────────────────────────────────────
316
+ # API publique : catégorisation + détection
317
+ # ──────────────────────────────────────────────────────────────────────────
318
+
319
+
320
+ def get_category(marker: str) -> Optional[str]:
321
+ """Retourne la catégorie d'un marqueur ou ``None`` si inconnu.
322
+
323
+ La comparaison est exacte (sensible à la casse, aux exposants
324
+ Unicode et aux points).
325
+ """
326
+ if not marker:
327
+ return None
328
+ for category, entries in _CATEGORIES.items():
329
+ for known, _expansions in entries:
330
+ if known == marker:
331
+ return category
332
+ return None
333
+
334
+
335
+ def get_expansions(marker: str) -> tuple[str, ...]:
336
+ """Retourne les formes développées connues pour un marqueur,
337
+ ou un tuple vide si inconnu."""
338
+ if not marker:
339
+ return ()
340
+ for _category, entries in _CATEGORIES.items():
341
+ for known, expansions in entries:
342
+ if known == marker:
343
+ return expansions
344
+ return ()
345
+
346
+
347
+ def detect_modern_markers(
348
+ text: Optional[str],
349
+ ) -> list[tuple[int, str, str]]:
350
+ """Retourne les marqueurs trouvés dans ``text``.
351
+
352
+ Forme de sortie : ``[(index, marker, category), ...]`` triée
353
+ par index croissant. Si plusieurs marqueurs se chevauchent, le
354
+ plus long gagne (ex. « S.A.R. » plutôt que « S. " puis « A.R. »).
355
+
356
+ Tolérance casse
357
+ ---------------
358
+ Les marqueurs alphabétiques courts (« Mme », « Dr », « bd »)
359
+ sont matchés tels quels (sensibilité à la casse) — on n'élargit
360
+ pas car « me » en minuscule n'est pas une abréviation de
361
+ « Maître ».
362
+ """
363
+ if not text:
364
+ return []
365
+ # Collecte tous les matches de tous les marqueurs.
366
+ candidates: list[tuple[int, int, str, str]] = [] # start, end, marker, cat
367
+ for marker, _expansions, category in _ALL_MARKERS:
368
+ pattern = _PATTERNS[(marker, category)]
369
+ for match in pattern.finditer(text):
370
+ candidates.append((match.start(), match.end(), marker, category))
371
+ # Tri par (start, -length) pour appliquer une stratégie greedy
372
+ # « plus long gagne » à chaque position.
373
+ candidates.sort(key=lambda c: (c[0], -(c[1] - c[0])))
374
+ chosen: list[tuple[int, str, str]] = []
375
+ last_end = -1
376
+ for start, end, marker, category in candidates:
377
+ if start < last_end:
378
+ continue
379
+ chosen.append((start, marker, category))
380
+ last_end = end
381
+ return chosen
382
+
383
+
384
+ # ──────────────────────────────────────────────────────────────────────────
385
+ # Calcul des scores strict / expansion
386
+ # ──────────────────────────────────────────────────────────────────────────
387
+
388
+
389
+ def _hyp_contains_marker(
390
+ hypothesis: str, marker: str, category: str,
391
+ ) -> bool:
392
+ """Vrai si le marqueur est présent (au moins une occurrence) dans
393
+ l'hypothèse, avec la même règle de frontière qu'en GT."""
394
+ pattern = _PATTERNS[(marker, category)]
395
+ return pattern.search(hypothesis) is not None
396
+
397
+
398
+ def _hyp_contains_expansion(hypothesis: str, marker: str) -> bool:
399
+ """Vrai si une forme développée connue du marqueur est présente
400
+ dans l'hypothèse (insensible à la casse)."""
401
+ for pattern in _EXPANSION_PATTERNS.get(marker, ()):
402
+ if pattern.search(hypothesis) is not None:
403
+ return True
404
+ return False
405
+
406
+
407
+ def compute_modern_archives_metrics(
408
+ reference: Optional[str],
409
+ hypothesis: Optional[str],
410
+ ) -> dict:
411
+ """Calcule la préservation des marqueurs d'archives modernes.
412
+
413
+ Pour chaque catégorie : retourne le ``strict_score`` (forme
414
+ abrégée préservée) et l'``expansion_score`` (abrégée OU
415
+ développée présente). Le ratio des deux donne au chercheur la
416
+ convention adoptée (diplomatique / modernisante / mixte) sans
417
+ qu'aucune classification ne soit imposée.
418
+
419
+ Returns
420
+ -------
421
+ dict
422
+ ``{
423
+ "n_markers_reference": int,
424
+ "n_strict_preserved": int,
425
+ "n_expansion_preserved": int,
426
+ "global_strict_score": float,
427
+ "global_expansion_score": float,
428
+ "per_category": {
429
+ category: {
430
+ "n_total": int,
431
+ "n_strict_preserved": int,
432
+ "n_expansion_preserved": int,
433
+ "strict_score": float,
434
+ "expansion_score": float,
435
+ }
436
+ },
437
+ "missed_markers": [
438
+ {"index": int, "marker": str, "category": str,
439
+ "expansion_preserved": bool}
440
+ ],
441
+ }``
442
+
443
+ Cas dégénérés
444
+ -------------
445
+ - GT vide ou sans marqueur → tous les compteurs à 0, scores à
446
+ ``0.0``, ``per_category == {}``.
447
+ - GT non vide avec marqueurs + hyp vide → tous les scores à
448
+ ``0.0``, tous les marqueurs dans ``missed_markers``.
449
+ """
450
+ ref = reference or ""
451
+ hyp = hypothesis or ""
452
+
453
+ detected = detect_modern_markers(ref)
454
+ n_total = len(detected)
455
+ if n_total == 0:
456
+ return {
457
+ "n_markers_reference": 0,
458
+ "n_strict_preserved": 0,
459
+ "n_expansion_preserved": 0,
460
+ "global_strict_score": 0.0,
461
+ "global_expansion_score": 0.0,
462
+ "per_category": {},
463
+ "missed_markers": [],
464
+ }
465
+
466
+ per_cat_total: dict[str, int] = {}
467
+ per_cat_strict: dict[str, int] = {}
468
+ per_cat_expansion: dict[str, int] = {}
469
+ n_strict = 0
470
+ n_expansion = 0
471
+ missed: list[dict] = []
472
+
473
+ for index, marker, category in detected:
474
+ per_cat_total[category] = per_cat_total.get(category, 0) + 1
475
+ strict_ok = _hyp_contains_marker(hyp, marker, category)
476
+ # Convention identique à Sprint 56 : si l'abrégé est
477
+ # préservé, c'est aussi un succès pour expansion (l'OCR n'a
478
+ # pas perdu l'information).
479
+ expansion_ok = strict_ok or _hyp_contains_expansion(hyp, marker)
480
+ if strict_ok:
481
+ per_cat_strict[category] = per_cat_strict.get(category, 0) + 1
482
+ n_strict += 1
483
+ if expansion_ok:
484
+ per_cat_expansion[category] = per_cat_expansion.get(category, 0) + 1
485
+ n_expansion += 1
486
+ if not strict_ok:
487
+ missed.append({
488
+ "index": index,
489
+ "marker": marker,
490
+ "category": category,
491
+ "expansion_preserved": expansion_ok,
492
+ })
493
+
494
+ per_category = {
495
+ cat: {
496
+ "n_total": per_cat_total[cat],
497
+ "n_strict_preserved": per_cat_strict.get(cat, 0),
498
+ "n_expansion_preserved": per_cat_expansion.get(cat, 0),
499
+ "strict_score": (
500
+ per_cat_strict.get(cat, 0) / per_cat_total[cat]
501
+ if per_cat_total[cat] > 0 else 0.0
502
+ ),
503
+ "expansion_score": (
504
+ per_cat_expansion.get(cat, 0) / per_cat_total[cat]
505
+ if per_cat_total[cat] > 0 else 0.0
506
+ ),
507
+ }
508
+ for cat in sorted(per_cat_total)
509
+ }
510
+
511
+ return {
512
+ "n_markers_reference": n_total,
513
+ "n_strict_preserved": n_strict,
514
+ "n_expansion_preserved": n_expansion,
515
+ "global_strict_score": n_strict / n_total,
516
+ "global_expansion_score": n_expansion / n_total,
517
+ "per_category": per_category,
518
+ "missed_markers": missed,
519
+ }
520
+
521
+
522
+ def modern_archives_strict_score(
523
+ reference: Optional[str], hypothesis: Optional[str],
524
+ ) -> float:
525
+ """Raccourci : taux global de préservation **stricte** des
526
+ marqueurs d'archives modernes ∈ [0, 1]."""
527
+ return compute_modern_archives_metrics(
528
+ reference, hypothesis,
529
+ )["global_strict_score"]
530
+
531
+
532
+ def modern_archives_expansion_score(
533
+ reference: Optional[str], hypothesis: Optional[str],
534
+ ) -> float:
535
+ """Raccourci : taux global de préservation **étendue** (abrégée
536
+ OU développée) des marqueurs d'archives modernes ∈ [0, 1]."""
537
+ return compute_modern_archives_metrics(
538
+ reference, hypothesis,
539
+ )["global_expansion_score"]
540
+
541
+
542
+ # ──────────────────────────────────────────────────────────────────────────
543
+ # Enregistrement dans le registre typé (Sprint 34)
544
+ # ──────────────────────────────────────────────────────────────────────────
545
+
546
+
547
+ @register_metric(
548
+ name="modern_archives_strict_score",
549
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
550
+ description=(
551
+ "Taux de préservation stricte des abréviations et marqueurs "
552
+ "typographiques caractéristiques des archives modernes "
553
+ "(XIXᵉ-XXᵉ) : titres de civilité, ordinaux, monnaies, "
554
+ "abréviations administratives, état civil, ponctuation "
555
+ "typographique, abréviations latines, abréviations "
556
+ "bibliographiques, abréviations d'adresse. Forme abrégée "
557
+ "préservée telle quelle (signal d'édition diplomatique)."
558
+ ),
559
+ higher_is_better=True,
560
+ tags={"text", "modern_archives", "philology", "abbreviations"},
561
+ )
562
+ def _registered_strict(reference: str, hypothesis: str) -> float:
563
+ return modern_archives_strict_score(reference, hypothesis)
564
+
565
+
566
+ @register_metric(
567
+ name="modern_archives_expansion_score",
568
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
569
+ description=(
570
+ "Taux de préservation étendue (forme abrégée OU forme "
571
+ "développée présente) des marqueurs d'archives modernes "
572
+ "XIXᵉ-XXᵉ. Le ratio strict/expansion par catégorie "
573
+ "permet au chercheur de juger lui-même la convention "
574
+ "éditoriale adoptée."
575
+ ),
576
+ higher_is_better=True,
577
+ tags={"text", "modern_archives", "philology", "abbreviations"},
578
+ )
579
+ def _registered_expansion(reference: str, hypothesis: str) -> float:
580
+ return modern_archives_expansion_score(reference, hypothesis)
581
+
582
+
583
+ __all__ = [
584
+ "CIVILITY_TITLES",
585
+ "ORDINALS",
586
+ "CURRENCY",
587
+ "ADMINISTRATIVE",
588
+ "CIVIL_STATUS",
589
+ "TYPOGRAPHIC_PUNCTUATION",
590
+ "LATIN_ABBR_MODERN",
591
+ "BIBLIOGRAPHIC",
592
+ "ADDRESS",
593
+ "compute_modern_archives_metrics",
594
+ "detect_modern_markers",
595
+ "get_category",
596
+ "get_expansions",
597
+ "modern_archives_strict_score",
598
+ "modern_archives_expansion_score",
599
+ ]
600
+
picarones/extras/historical/mufi.py ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Couverture MUFI — Sprint 57.
2
+
3
+ Sprint 57 — A.II.3.3 du plan d'évolution 2026 (clôture axe A.II.3
4
+ philologique).
5
+
6
+ Pourquoi ce module
7
+ ------------------
8
+ La **Medieval Unicode Font Initiative** (MUFI v4.0) standardise les
9
+ caractères médiévaux que les éditeurs critiques attendent dans une
10
+ transcription fidèle : signes d'abréviation, ligatures, lettres
11
+ spéciales (ƿ wynn, þ thorn), ponctuation médiévale, marques
12
+ diacritiques rares, etc. Pour les médiévistes, la **couverture
13
+ MUFI** d'un moteur OCR/HTR est un critère éditorial central.
14
+
15
+ Ce module mesure le taux de **caractères MUFI de la GT
16
+ correctement restitués** dans l'OCR, après alignement caractère par
17
+ caractère (même approche que la précision par bloc Unicode du
18
+ Sprint 55).
19
+
20
+ Détection des caractères MUFI
21
+ -----------------------------
22
+ La spécification MUFI v4.0 référence ~1300 caractères dans plusieurs
23
+ plages Unicode. Plutôt que d'embarquer la liste exhaustive (qui
24
+ évolue), on utilise un **set de plages caractéristiques** suffisant
25
+ pour les corpus patrimoniaux européens courants :
26
+
27
+ - PUA principal (U+E000–U+F8FF) : zone usuelle des glyphes MUFI
28
+ qui n'ont pas (encore) de point de code Unicode standard.
29
+ - Latin Extended-D (U+A720–U+A7FF) : abréviations latines
30
+ médiévales (ꝑ, ꝓ, ꝗ, etc.).
31
+ - Combining Diacritical Marks Supplement (U+1DC0–U+1DFF) :
32
+ diacritiques médiévaux rares (macron suscript, etc.).
33
+ - Alphabetic Presentation Forms (U+FB00–U+FB4F) : ligatures
34
+ (fi, fl, ff).
35
+ - Une **liste explicite** de caractères médiévaux dans les blocs
36
+ Latin Extended-A/B/Additional (þ, ð, ƿ, ſ, æ, œ, etc.)
37
+
38
+ L'utilisateur peut personnaliser via le paramètre ``custom_chars``
39
+ de ``compute_mufi_coverage`` pour étendre ou restreindre.
40
+
41
+ Stratégie de découpage
42
+ ----------------------
43
+ Cohérente avec NER (Sprint 38), Flesch (52), Reading order F1 (53),
44
+ Layout F1 (54), Bloc Unicode (55), Abréviations (56) : couche de
45
+ calcul pure d'abord. Le câblage runner et la vue HTML suivent dans
46
+ des sprints dédiés.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import logging
52
+ from difflib import SequenceMatcher
53
+ from typing import Iterable, Optional
54
+
55
+ from picarones.core.metric_registry import register_metric
56
+ from picarones.core.modules import ArtifactType
57
+
58
+ logger = logging.getLogger(__name__)
59
+
60
+
61
+ # ──────────────────────────────────────────────────────────────────────────
62
+ # Plages Unicode considérées comme MUFI
63
+ # ──────────────────────────────────────────────────────────────────────────
64
+
65
+ # Triplets (nom, lo, hi) inclusifs. Source : MUFI v4.0 spec
66
+ # (https://mufi.info/) + revue manuelle des caractères patrimoniaux
67
+ # courants.
68
+ _MUFI_RANGES: tuple[tuple[str, int, int], ...] = (
69
+ ("Private Use Area", 0xE000, 0xF8FF),
70
+ ("Latin Extended-D", 0xA720, 0xA7FF),
71
+ ("Combining Diacritical Marks Supplement", 0x1DC0, 0x1DFF),
72
+ ("Alphabetic Presentation Forms", 0xFB00, 0xFB4F),
73
+ )
74
+
75
+ # Caractères MUFI explicites hors plages couvertes par les ranges.
76
+ # Surtout des glyphes médiévaux standardisés en Unicode mais qui ne
77
+ # sont pas dans le PUA ni dans Latin Extended-D : þ, ð, ƿ, ſ, æ, œ,
78
+ # ø, ƀ, ƕ, etc. Liste raisonnée pour les corpus européens médiévaux.
79
+ _MUFI_EXPLICIT_CHARS: frozenset[str] = frozenset(
80
+ [
81
+ # Lettres médiévales standard
82
+ "þ", "Þ", # thorn — vieil anglais, islandais
83
+ "ð", "Ð", # eth — vieil anglais, islandais
84
+ "ƿ", "Ƿ", # wynn — vieil anglais
85
+ "ſ", # s long médiéval (déjà U+017F)
86
+ "æ", "Æ", # ash
87
+ "œ", "Œ", # ethel
88
+ "ø", "Ø", # o barré
89
+ # Lettres rares avec barré (pour préfixes abréviés)
90
+ "ƀ", # b barré
91
+ "ŧ", # t barré
92
+ "đ", # d barré
93
+ "ħ", # h barré
94
+ # Yogh
95
+ "ȝ", "Ȝ",
96
+ # Autres signes médiévaux courants
97
+ "ꜿ", # con
98
+ # Note : la liste est volontairement courte ; pour étendre,
99
+ # l'utilisateur peut passer ``custom_chars`` à
100
+ # ``compute_mufi_coverage``.
101
+ ]
102
+ )
103
+
104
+
105
+ def is_mufi_char(char: str, custom_chars: Optional[frozenset[str]] = None) -> bool:
106
+ """Retourne ``True`` si ``char`` est considéré comme MUFI.
107
+
108
+ Reconnaît :
109
+
110
+ - les caractères dans les plages Unicode MUFI (``_MUFI_RANGES``),
111
+ - les caractères de la liste explicite (``_MUFI_EXPLICIT_CHARS``),
112
+ - tout caractère supplémentaire fourni via ``custom_chars``.
113
+
114
+ Pour une chaîne multi-caractères, seul le premier code-point
115
+ est considéré.
116
+ """
117
+ if not char:
118
+ return False
119
+ cp = ord(char[0])
120
+ for _name, lo, hi in _MUFI_RANGES:
121
+ if lo <= cp <= hi:
122
+ return True
123
+ if char[0] in _MUFI_EXPLICIT_CHARS:
124
+ return True
125
+ if custom_chars and char[0] in custom_chars:
126
+ return True
127
+ return False
128
+
129
+
130
+ # ──────────────────────────────────────────────────────────────────────────
131
+ # Calcul de couverture MUFI
132
+ # ──────────────────────────────────────────────────────────────────────────
133
+
134
+
135
+ def compute_mufi_coverage(
136
+ reference: Optional[str],
137
+ hypothesis: Optional[str],
138
+ custom_chars: Optional[Iterable[str]] = None,
139
+ ) -> dict:
140
+ """Calcule la couverture MUFI : taux de caractères MUFI de la GT
141
+ correctement restitués dans l'hypothèse.
142
+
143
+ Parameters
144
+ ----------
145
+ reference:
146
+ Texte GT.
147
+ hypothesis:
148
+ Texte produit par l'OCR.
149
+ custom_chars:
150
+ Itérable optionnel de caractères supplémentaires à considérer
151
+ comme MUFI (utile pour les éditeurs ayant une convention
152
+ propre). Chaque entrée doit être un caractère unique.
153
+
154
+ Returns
155
+ -------
156
+ dict
157
+ ``{
158
+ "n_mufi_chars_reference": int, # caractères MUFI dans la GT
159
+ "n_mufi_chars_preserved": int, # MUFI restitués correctement
160
+ "coverage": float, # ∈ [0, 1] ou 0 si N=0
161
+ "per_char": {char: {"total", "preserved", "coverage"}},
162
+ "missed_chars": list[str], # caractères MUFI ratés
163
+ }``
164
+
165
+ Cas dégénérés
166
+ -------------
167
+ - GT vide ou sans caractère MUFI → ``coverage = 0`` (convention :
168
+ pas de récompense gratuite).
169
+ - Hyp vide + MUFI dans GT → ``coverage = 0``.
170
+ - GT et hyp identiques avec MUFI → ``coverage = 1``.
171
+ """
172
+ ref = reference or ""
173
+ hyp = hypothesis or ""
174
+ extra: Optional[frozenset[str]] = (
175
+ frozenset(c for c in custom_chars if c) if custom_chars else None
176
+ )
177
+
178
+ # 1. Identifier les positions MUFI dans la GT
179
+ mufi_positions = [i for i, ch in enumerate(ref) if is_mufi_char(ch, extra)]
180
+ n_total = len(mufi_positions)
181
+
182
+ if n_total == 0:
183
+ return {
184
+ "n_mufi_chars_reference": 0,
185
+ "n_mufi_chars_preserved": 0,
186
+ "coverage": 0.0,
187
+ "per_char": {},
188
+ "missed_chars": [],
189
+ }
190
+
191
+ # 2. Aligner via SequenceMatcher (même méthode que Sprint 55)
192
+ matcher = SequenceMatcher(a=ref, b=hyp, autojunk=False)
193
+ correct_positions: set[int] = set()
194
+ for op, i1, i2, _j1, _j2 in matcher.get_opcodes():
195
+ if op == "equal":
196
+ correct_positions.update(range(i1, i2))
197
+
198
+ # 3. Compter par caractère
199
+ per_char_total: dict[str, int] = {}
200
+ per_char_preserved: dict[str, int] = {}
201
+ missed: list[str] = []
202
+ for i in mufi_positions:
203
+ ch = ref[i]
204
+ per_char_total[ch] = per_char_total.get(ch, 0) + 1
205
+ if i in correct_positions:
206
+ per_char_preserved[ch] = per_char_preserved.get(ch, 0) + 1
207
+ else:
208
+ missed.append(ch)
209
+
210
+ n_preserved = sum(per_char_preserved.values())
211
+ per_char = {
212
+ ch: {
213
+ "total": per_char_total[ch],
214
+ "preserved": per_char_preserved.get(ch, 0),
215
+ "coverage": (
216
+ per_char_preserved.get(ch, 0) / per_char_total[ch]
217
+ if per_char_total[ch] > 0
218
+ else 0.0
219
+ ),
220
+ }
221
+ for ch in sorted(per_char_total)
222
+ }
223
+
224
+ return {
225
+ "n_mufi_chars_reference": n_total,
226
+ "n_mufi_chars_preserved": n_preserved,
227
+ "coverage": n_preserved / n_total,
228
+ "per_char": per_char,
229
+ "missed_chars": missed,
230
+ }
231
+
232
+
233
+ def mufi_coverage(
234
+ reference: Optional[str], hypothesis: Optional[str],
235
+ ) -> float:
236
+ """Raccourci : retourne la couverture MUFI globale ∈ [0, 1]."""
237
+ return compute_mufi_coverage(reference, hypothesis)["coverage"]
238
+
239
+
240
+ # ──────────────────────────────────────────────────────────────────────────
241
+ # Enregistrement dans le registre typé (Sprint 34)
242
+ # ──────────────────────────────────────────────────────────────────────────
243
+
244
+
245
+ @register_metric(
246
+ name="mufi_coverage",
247
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
248
+ description=(
249
+ "Taux de caractères MUFI (Medieval Unicode Font Initiative) "
250
+ "de la GT correctement restitués dans l'OCR. Critère "
251
+ "éditorial central pour les médiévistes."
252
+ ),
253
+ higher_is_better=True,
254
+ tags={"text", "mufi", "philology", "medieval"},
255
+ )
256
+ def _registered_mufi_coverage(reference: str, hypothesis: str) -> float:
257
+ return mufi_coverage(reference, hypothesis)
258
+
259
+
260
+ __all__ = [
261
+ "is_mufi_char",
262
+ "compute_mufi_coverage",
263
+ "mufi_coverage",
264
+ ]
picarones/extras/historical/philological_runner.py ADDED
@@ -0,0 +1,363 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Helpers de câblage des métriques philologiques (Sprints 55-60) au runner.
2
+
3
+ Sprint 61 — câblage backend des 6 modules philologiques :
4
+
5
+ - ``unicode_blocks`` (Sprint 55)
6
+ - ``abbreviations`` (Sprint 56)
7
+ - ``mufi`` (Sprint 57)
8
+ - ``early_modern`` (Sprint 58)
9
+ - ``modern_archives`` (Sprint 59)
10
+ - ``roman_numerals`` (Sprint 60)
11
+
12
+ Principe « adaptive »
13
+ ----------------------
14
+ Un module n'est inclus dans le résultat que si la **GT contient du
15
+ signal exploitable** pour ce module. Cette logique évite de polluer
16
+ les rapports sur les corpus sans marqueurs philologiques (typique
17
+ sur des données XXIᵉ ou des transcriptions modernes propres).
18
+
19
+ Coût
20
+ ----
21
+ Les 6 calculs sont O(N) sur la longueur du texte ; le surcoût total
22
+ par document est négligeable face à un appel OCR. L'activation est
23
+ donc **automatique** (pas d'opt-in), contrairement aux backends NER
24
+ ou calibration qui exigent une dépendance externe ou des données
25
+ spécifiques.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import logging
31
+ from typing import Optional
32
+
33
+ from picarones.core.abbreviations import compute_abbreviation_metrics
34
+ from picarones.core.early_modern_typography import compute_early_modern_metrics
35
+ from picarones.core.modern_archives import compute_modern_archives_metrics
36
+ from picarones.core.mufi import compute_mufi_coverage
37
+ from picarones.core.roman_numerals import compute_roman_numeral_metrics
38
+ from picarones.core.unicode_blocks import compute_unicode_block_accuracy
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ # ──────────────────────────────────────────────────────────────────────────
44
+ # Critères « le module a-t-il du signal sur ce document ? »
45
+ # ──────────────────────────────────────────────────────────────────────────
46
+ #
47
+ # Pour chaque module, on définit un prédicat sur le résultat : si vrai,
48
+ # le module est inclus ; sinon, il est omis pour ne pas alourdir le
49
+ # rapport.
50
+
51
+ def _has_unicode_signal(result: dict) -> bool:
52
+ # Le module retourne toujours du signal dès que GT non-vide ; on
53
+ # n'inclut que si la GT a au moins un caractère **hors Basic
54
+ # Latin** (sinon le breakdown se réduit à 100 % Basic Latin et
55
+ # n'apporte rien au lecteur).
56
+ per_block = result.get("per_block", {})
57
+ for block, stats in per_block.items():
58
+ if block == "Basic Latin":
59
+ continue
60
+ if stats.get("total", 0) > 0:
61
+ return True
62
+ return False
63
+
64
+
65
+ def _has_abbreviation_signal(result: dict) -> bool:
66
+ return result.get("n_abbreviations_in_reference", 0) > 0
67
+
68
+
69
+ def _has_mufi_signal(result: dict) -> bool:
70
+ return result.get("n_mufi_chars_reference", 0) > 0
71
+
72
+
73
+ def _has_early_modern_signal(result: dict) -> bool:
74
+ return result.get("n_markers_reference", 0) > 0
75
+
76
+
77
+ def _has_modern_archives_signal(result: dict) -> bool:
78
+ return result.get("n_markers_reference", 0) > 0
79
+
80
+
81
+ def _has_roman_numeral_signal(result: dict) -> bool:
82
+ return result.get("n_numerals_reference", 0) > 0
83
+
84
+
85
+ # Ordre fixé pour la reproductibilité des sorties.
86
+ _PHILOLOGICAL_MODULES: tuple[
87
+ tuple[str, callable, callable], ...
88
+ ] = (
89
+ ("unicode_blocks", compute_unicode_block_accuracy, _has_unicode_signal),
90
+ ("abbreviations", compute_abbreviation_metrics, _has_abbreviation_signal),
91
+ ("mufi", compute_mufi_coverage, _has_mufi_signal),
92
+ ("early_modern", compute_early_modern_metrics, _has_early_modern_signal),
93
+ ("modern_archives", compute_modern_archives_metrics, _has_modern_archives_signal),
94
+ ("roman_numerals", compute_roman_numeral_metrics, _has_roman_numeral_signal),
95
+ )
96
+
97
+
98
+ # ──────────────────────────────────────────────────────────────────────────
99
+ # Calcul par document
100
+ # ──────────────────────────────────────────────────────────────────────────
101
+
102
+
103
+ def compute_philological_metrics(
104
+ reference: Optional[str],
105
+ hypothesis: Optional[str],
106
+ ) -> Optional[dict]:
107
+ """Calcule les 6 métriques philologiques pour un document.
108
+
109
+ Retourne un dict avec une clé par module ayant du signal, ou
110
+ ``None`` si aucun module n'en a (corpus sans marqueur
111
+ philologique pertinent).
112
+
113
+ En cas d'erreur dans un module individuel, le module est
114
+ silencieusement omis et un warning est émis (les autres modules
115
+ restent calculés).
116
+ """
117
+ ref = reference or ""
118
+ if not ref:
119
+ return None
120
+ out: dict = {}
121
+ for name, compute_fn, has_signal_fn in _PHILOLOGICAL_MODULES:
122
+ try:
123
+ result = compute_fn(ref, hypothesis or "")
124
+ except Exception as exc: # pragma: no cover — défense en profondeur
125
+ logger.warning(
126
+ "[philological_runner] module %s a échoué : %s", name, exc,
127
+ )
128
+ continue
129
+ if has_signal_fn(result):
130
+ out[name] = result
131
+ return out if out else None
132
+
133
+
134
+ # ──────────────────────────────────────────────────────────────────────────
135
+ # Agrégation corpus-wide par moteur
136
+ # ──────────────────────────────────────────────────────────────────────────
137
+
138
+
139
+ def _aggregate_unicode(per_doc: list[dict]) -> dict:
140
+ total_correct = 0
141
+ total_chars = 0
142
+ per_block: dict[str, dict[str, int]] = {}
143
+ for d in per_doc:
144
+ for block, stats in d.get("per_block", {}).items():
145
+ slot = per_block.setdefault(block, {"correct": 0, "total": 0})
146
+ slot["correct"] += stats.get("correct", 0)
147
+ slot["total"] += stats.get("total", 0)
148
+ total_correct += stats.get("correct", 0)
149
+ total_chars += stats.get("total", 0)
150
+ out_per_block = {
151
+ block: {
152
+ "correct": slot["correct"],
153
+ "total": slot["total"],
154
+ "accuracy": (
155
+ slot["correct"] / slot["total"] if slot["total"] > 0 else 0.0
156
+ ),
157
+ }
158
+ for block, slot in sorted(per_block.items())
159
+ }
160
+ return {
161
+ "global_accuracy": total_correct / total_chars if total_chars > 0 else 0.0,
162
+ "n_chars_total": total_chars,
163
+ "n_chars_correct": total_correct,
164
+ "per_block": out_per_block,
165
+ "doc_count": len(per_doc),
166
+ }
167
+
168
+
169
+ def _aggregate_abbreviations(per_doc: list[dict]) -> dict:
170
+ n_total = 0
171
+ n_strict = 0
172
+ n_expansion = 0
173
+ per_abbr: dict[str, dict[str, int]] = {}
174
+ for d in per_doc:
175
+ n_total += d.get("n_abbreviations_in_reference", 0)
176
+ n_strict += d.get("n_strict_preserved", 0)
177
+ n_expansion += d.get("n_expansion_preserved", 0)
178
+ for entry in d.get("per_abbreviation", []):
179
+ slot = per_abbr.setdefault(
180
+ entry["abbr"],
181
+ {"total": 0, "strict": 0, "expansion": 0},
182
+ )
183
+ slot["total"] += 1
184
+ if entry.get("strict_preserved"):
185
+ slot["strict"] += 1
186
+ if entry.get("expansion_preserved"):
187
+ slot["expansion"] += 1
188
+ return {
189
+ "n_abbreviations_in_reference": n_total,
190
+ "n_strict_preserved": n_strict,
191
+ "n_expansion_preserved": n_expansion,
192
+ "global_strict_score": n_strict / n_total if n_total > 0 else 0.0,
193
+ "global_expansion_score": n_expansion / n_total if n_total > 0 else 0.0,
194
+ "per_abbreviation": {
195
+ abbr: {
196
+ "n_total": slot["total"],
197
+ "n_strict": slot["strict"],
198
+ "n_expansion": slot["expansion"],
199
+ "strict_score": slot["strict"] / slot["total"],
200
+ "expansion_score": slot["expansion"] / slot["total"],
201
+ }
202
+ for abbr, slot in sorted(per_abbr.items())
203
+ },
204
+ "doc_count": len(per_doc),
205
+ }
206
+
207
+
208
+ def _aggregate_mufi(per_doc: list[dict]) -> dict:
209
+ n_total = 0
210
+ n_preserved = 0
211
+ per_char: dict[str, dict[str, int]] = {}
212
+ for d in per_doc:
213
+ n_total += d.get("n_mufi_chars_reference", 0)
214
+ n_preserved += d.get("n_mufi_chars_preserved", 0)
215
+ for ch, stats in d.get("per_char", {}).items():
216
+ slot = per_char.setdefault(ch, {"total": 0, "preserved": 0})
217
+ slot["total"] += stats.get("total", 0)
218
+ slot["preserved"] += stats.get("preserved", 0)
219
+ return {
220
+ "n_mufi_chars_reference": n_total,
221
+ "n_mufi_chars_preserved": n_preserved,
222
+ "coverage": n_preserved / n_total if n_total > 0 else 0.0,
223
+ "per_char": {
224
+ ch: {
225
+ "total": slot["total"],
226
+ "preserved": slot["preserved"],
227
+ "coverage": slot["preserved"] / slot["total"],
228
+ }
229
+ for ch, slot in sorted(per_char.items())
230
+ },
231
+ "doc_count": len(per_doc),
232
+ }
233
+
234
+
235
+ def _aggregate_early_modern(per_doc: list[dict]) -> dict:
236
+ n_total = 0
237
+ n_preserved = 0
238
+ per_cat: dict[str, dict[str, int]] = {}
239
+ for d in per_doc:
240
+ n_total += d.get("n_markers_reference", 0)
241
+ n_preserved += d.get("n_markers_preserved", 0)
242
+ for cat, stats in d.get("per_category", {}).items():
243
+ slot = per_cat.setdefault(cat, {"total": 0, "preserved": 0})
244
+ slot["total"] += stats.get("total", 0)
245
+ slot["preserved"] += stats.get("preserved", 0)
246
+ return {
247
+ "n_markers_reference": n_total,
248
+ "n_markers_preserved": n_preserved,
249
+ "global_preservation": n_preserved / n_total if n_total > 0 else 0.0,
250
+ "per_category": {
251
+ cat: {
252
+ "total": slot["total"],
253
+ "preserved": slot["preserved"],
254
+ "preservation": slot["preserved"] / slot["total"],
255
+ }
256
+ for cat, slot in sorted(per_cat.items())
257
+ },
258
+ "doc_count": len(per_doc),
259
+ }
260
+
261
+
262
+ def _aggregate_modern_archives(per_doc: list[dict]) -> dict:
263
+ n_total = 0
264
+ n_strict = 0
265
+ n_expansion = 0
266
+ per_cat: dict[str, dict[str, int]] = {}
267
+ for d in per_doc:
268
+ n_total += d.get("n_markers_reference", 0)
269
+ n_strict += d.get("n_strict_preserved", 0)
270
+ n_expansion += d.get("n_expansion_preserved", 0)
271
+ for cat, stats in d.get("per_category", {}).items():
272
+ slot = per_cat.setdefault(
273
+ cat, {"total": 0, "strict": 0, "expansion": 0},
274
+ )
275
+ slot["total"] += stats.get("n_total", 0)
276
+ slot["strict"] += stats.get("n_strict_preserved", 0)
277
+ slot["expansion"] += stats.get("n_expansion_preserved", 0)
278
+ return {
279
+ "n_markers_reference": n_total,
280
+ "n_strict_preserved": n_strict,
281
+ "n_expansion_preserved": n_expansion,
282
+ "global_strict_score": n_strict / n_total if n_total > 0 else 0.0,
283
+ "global_expansion_score": n_expansion / n_total if n_total > 0 else 0.0,
284
+ "per_category": {
285
+ cat: {
286
+ "n_total": slot["total"],
287
+ "n_strict_preserved": slot["strict"],
288
+ "n_expansion_preserved": slot["expansion"],
289
+ "strict_score": slot["strict"] / slot["total"],
290
+ "expansion_score": slot["expansion"] / slot["total"],
291
+ }
292
+ for cat, slot in sorted(per_cat.items())
293
+ },
294
+ "doc_count": len(per_doc),
295
+ }
296
+
297
+
298
+ def _aggregate_roman_numerals(per_doc: list[dict]) -> dict:
299
+ from picarones.core.roman_numerals import ALL_STATUSES, VALUE_PRESERVING_STATUSES
300
+
301
+ n_total = 0
302
+ per_status: dict[str, int] = {s: 0 for s in ALL_STATUSES}
303
+ for d in per_doc:
304
+ n_total += d.get("n_numerals_reference", 0)
305
+ for status, count in d.get("per_status", {}).items():
306
+ per_status[status] = per_status.get(status, 0) + count
307
+ n_strict = per_status.get("strict_preserved", 0)
308
+ n_value = sum(per_status.get(s, 0) for s in VALUE_PRESERVING_STATUSES)
309
+ return {
310
+ "n_numerals_reference": n_total,
311
+ "n_strict_preserved": n_strict,
312
+ "n_value_preserved": n_value,
313
+ "global_strict_score": n_strict / n_total if n_total > 0 else 0.0,
314
+ "global_value_score": n_value / n_total if n_total > 0 else 0.0,
315
+ "per_status": per_status,
316
+ "doc_count": len(per_doc),
317
+ }
318
+
319
+
320
+ _AGGREGATORS = {
321
+ "unicode_blocks": _aggregate_unicode,
322
+ "abbreviations": _aggregate_abbreviations,
323
+ "mufi": _aggregate_mufi,
324
+ "early_modern": _aggregate_early_modern,
325
+ "modern_archives": _aggregate_modern_archives,
326
+ "roman_numerals": _aggregate_roman_numerals,
327
+ }
328
+
329
+
330
+ def aggregate_philological_metrics(
331
+ doc_metrics: list[Optional[dict]],
332
+ ) -> Optional[dict]:
333
+ """Agrège les ``philological_metrics`` per-document en un dict
334
+ corpus-wide par module.
335
+
336
+ Pour chaque module, on agrège uniquement les documents qui ont
337
+ eu du signal pour ce module. Si aucun module n'a été calculé
338
+ sur aucun document, retourne ``None``.
339
+ """
340
+ by_module: dict[str, list[dict]] = {}
341
+ for doc in doc_metrics:
342
+ if not doc:
343
+ continue
344
+ for module, payload in doc.items():
345
+ by_module.setdefault(module, []).append(payload)
346
+ if not by_module:
347
+ return None
348
+ out: dict = {}
349
+ for module, payloads in by_module.items():
350
+ aggregator = _AGGREGATORS.get(module)
351
+ if aggregator is None: # pragma: no cover
352
+ logger.warning(
353
+ "[philological_runner] aucun agrégateur pour %s", module,
354
+ )
355
+ continue
356
+ out[module] = aggregator(payloads)
357
+ return out if out else None
358
+
359
+
360
+ __all__ = [
361
+ "compute_philological_metrics",
362
+ "aggregate_philological_metrics",
363
+ ]
picarones/extras/historical/roman_numerals.py ADDED
@@ -0,0 +1,478 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Numéraux romains — Sprint 60.
2
+
3
+ Sprint 60 — Étape 3 / extension philologique transversale du plan
4
+ d'évolution 2026.
5
+
6
+ Pourquoi ce module
7
+ ------------------
8
+ Les numéraux romains traversent **toutes les périodes patrimoniales**
9
+ servies par Picarones :
10
+
11
+ - **Médiéval** : minuscules avec ``j`` final pour le dernier ``i``
12
+ (``ij`` = 2, ``iij`` = 3, ``viij`` = 8, ``mcclxxxij`` = 1282).
13
+ Convention scribale standard dans les chartes et registres.
14
+ - **Imprimé ancien** : majuscules (``Tome IV``, ``Chap. VII``).
15
+ - **Moderne** : majuscules pour les souverains (``Louis XIV``) et
16
+ les siècles (``XIXᵉ siècle`` — la partie exposant ᵉ est gérée
17
+ par le Sprint 59 ``ordinals``, ce module ne traite que la partie
18
+ numérale ``XIX``).
19
+
20
+ Quatre traitements possibles d'un numéral par l'OCR
21
+ ----------------------------------------------------
22
+ Pour chaque numéral romain présent dans la GT, l'OCR peut :
23
+
24
+ 1. **Préserver strictement** : forme exacte gardée
25
+ (``mcclxxxij`` → ``mcclxxxij``). Édition diplomatique idéale.
26
+ 2. **Préserver en changeant la casse** : la valeur est intacte mais
27
+ la convention typographique est modifiée
28
+ (``xiv`` → ``XIV``). Modernisation typographique courante.
29
+ 3. **Préserver en supprimant le ``j`` final** :
30
+ (``mcclxxxij`` → ``mcclxxxii``). Modernisation orthographique
31
+ médiévale → standard académique moderne.
32
+ 4. **Convertir en chiffres arabes** : la valeur est préservée mais
33
+ le système de numération est modernisé
34
+ (``XIV`` → ``14``). Modernisation profonde, perte de
35
+ l'information typographique.
36
+ 5. **Perdre** : aucune trace de la valeur dans l'hypothèse.
37
+
38
+ Ce module retourne un breakdown par statut pour que le chercheur
39
+ juge lui-même la convention adoptée par chaque moteur, **sans
40
+ classification automatique imposée**.
41
+
42
+ Stratégie de découpage
43
+ ----------------------
44
+ Cohérente avec NER (38), Flesch (52), Reading order F1 (53),
45
+ Layout F1 (54), Bloc Unicode (55), Abréviations (56), MUFI (57),
46
+ Imprimé ancien (58), Archives modernes (59) : couche de calcul
47
+ pure d'abord ; câblage runner et HTML dans des sprints dédiés.
48
+
49
+ Limites documentées
50
+ -------------------
51
+ - Détection greedy par regex ``\\b[IVXLCDMivxlcdmj]+\\b`` puis
52
+ validation par parsing. Les faux positifs restent possibles sur
53
+ des mots courts (``I`` pronom anglais, ``MM`` initiales, ``LL``).
54
+ Le paramètre ``min_length`` permet de filtrer les single-letter.
55
+ - Pas de gestion des notations rares avec barre suscript pour
56
+ multiplier par 1000 (V̄ = 5000, X̄ = 10000) — usage très rare en
57
+ corpus patrimonial européen courant.
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ import logging
63
+ import re
64
+ from typing import Optional
65
+
66
+ from picarones.core.metric_registry import register_metric
67
+ from picarones.core.modules import ArtifactType
68
+
69
+ logger = logging.getLogger(__name__)
70
+
71
+
72
+ # ──────────────────────────────────────────────────────────────────────────
73
+ # Table de conversion + parsing
74
+ # ──────────────────────────────────────────────────────────────────────────
75
+
76
+ ROMAN_VALUES: dict[str, int] = {
77
+ "I": 1, "V": 5, "X": 10,
78
+ "L": 50, "C": 100, "D": 500, "M": 1000,
79
+ }
80
+
81
+ # Caractères acceptés en entrée (incluant minuscules + j médiéval).
82
+ _ROMAN_CHARS = "IVXLCDMivxlcdmj"
83
+ _ROMAN_RE = re.compile(rf"\b[{_ROMAN_CHARS}]+\b")
84
+
85
+
86
+ def _normalize_roman(s: str) -> str:
87
+ """Normalise un numéral romain : majuscule + ``j`` final → ``i``.
88
+
89
+ Les manuscrits médiévaux notent traditionnellement le dernier
90
+ ``i`` d'une suite par ``j`` (« ij », « iij », « viij »…). On
91
+ convertit pour pouvoir parser comme un numéral standard.
92
+ """
93
+ if not s:
94
+ return ""
95
+ upper = s.upper()
96
+ if upper.endswith("J"):
97
+ upper = upper[:-1] + "I"
98
+ return upper
99
+
100
+
101
+ def _parse_normalized_roman(s: str) -> Optional[int]:
102
+ """Parse un numéral romain **après normalisation** (majuscule,
103
+ sans ``j`` médiéval). Retourne ``None`` si la chaîne n'est pas
104
+ un numéral romain valide.
105
+
106
+ Validation : on parse en additionnant/soustrayant selon la règle
107
+ classique, puis on **regénère la forme standard** et on compare
108
+ pour rejeter les formes non canoniques (« IIII » au lieu de
109
+ « IV », « VV » au lieu de « X »). Cette stricte validation
110
+ garantit qu'on ne compte pas des séquences absurdes comme
111
+ « XXXX » comme un numéral.
112
+
113
+ Note : les manuscrits médiévaux utilisent fréquemment « IIII »
114
+ pour 4 (notation soustractive plus tardive). On accepte donc
115
+ aussi cette forme via une règle relâchée : tant que les valeurs
116
+ sont décroissantes ou suivent la règle soustractive standard,
117
+ on accepte.
118
+ """
119
+ if not s or not all(c in "IVXLCDM" for c in s):
120
+ return None
121
+ # Calcul par soustraction.
122
+ total = 0
123
+ prev_value = 0
124
+ for ch in reversed(s):
125
+ v = ROMAN_VALUES[ch]
126
+ if v < prev_value:
127
+ total -= v
128
+ else:
129
+ total += v
130
+ prev_value = v
131
+ if total <= 0:
132
+ return None
133
+ # Validation relâchée : on accepte les formes médiévales (IIII,
134
+ # VIIII) mais on rejette les vraiment absurdes (IIIII, VVVV).
135
+ if not _is_plausible_roman(s):
136
+ return None
137
+ return total
138
+
139
+
140
+ def _is_plausible_roman(s: str) -> bool:
141
+ """Validation relâchée d'un numéral romain (majuscule).
142
+
143
+ On rejette :
144
+
145
+ - 5 caractères identiques d'affilée ou plus (« IIIII », « XXXXX »).
146
+ - Les répétitions de V, L, D (jamais répétés en notation
147
+ classique : « VV », « LL », « DD »).
148
+ - Les paires soustractives non standard. En romain canonique,
149
+ seules sont valides : IV, IX, XL, XC, CD, CM. Toute autre
150
+ combinaison « petit avant grand » est rejetée. Cela élimine
151
+ les faux positifs sur des mots français comme « ici » (qui
152
+ formerait sinon « I + C » = 99) ou « IL » qui formerait 49.
153
+ """
154
+ if not s:
155
+ return False
156
+ # Pas de répétitions invalides
157
+ for forbidden in ("VV", "LL", "DD", "IIIII", "XXXXX", "CCCCC", "MMMMMM"):
158
+ if forbidden in s:
159
+ return False
160
+ # Paires soustractives autorisées (toutes les autres sont rejetées)
161
+ legal_subtractive = {"IV", "IX", "XL", "XC", "CD", "CM"}
162
+ for i in range(len(s) - 1):
163
+ a, b = s[i], s[i + 1]
164
+ if ROMAN_VALUES[a] < ROMAN_VALUES[b]:
165
+ if (a + b) not in legal_subtractive:
166
+ return False
167
+ return True
168
+
169
+
170
+ def roman_to_int(s: Optional[str]) -> Optional[int]:
171
+ """Convertit une chaîne en numéral romain entier. Tolère casse
172
+ et ``j`` médiéval final. Retourne ``None`` si invalide.
173
+ """
174
+ if not s:
175
+ return None
176
+ return _parse_normalized_roman(_normalize_roman(s))
177
+
178
+
179
+ def int_to_roman(n: int) -> str:
180
+ """Convertit un entier en numéral romain majuscule standard.
181
+
182
+ Utilise la notation classique (IV, IX, XL, XC, CD, CM) — pas la
183
+ forme médiévale relâchée.
184
+ """
185
+ if n <= 0:
186
+ raise ValueError("n must be positive")
187
+ pairs = [
188
+ (1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
189
+ (100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
190
+ (10, "X"), (9, "IX"), (5, "V"), (4, "IV"),
191
+ (1, "I"),
192
+ ]
193
+ out: list[str] = []
194
+ for value, symbol in pairs:
195
+ while n >= value:
196
+ out.append(symbol)
197
+ n -= value
198
+ return "".join(out)
199
+
200
+
201
+ # ──────────────────────────────────────────────────────────────────────────
202
+ # Détection dans le texte
203
+ # ──────────────────────────────────────────────────────────────────────────
204
+
205
+
206
+ def detect_roman_numerals(
207
+ text: Optional[str],
208
+ *,
209
+ min_length: int = 1,
210
+ ) -> list[tuple[int, str, int]]:
211
+ """Retourne les numéraux romains valides dans ``text``.
212
+
213
+ Forme : ``[(start_index, numeral_string, integer_value), ...]``
214
+ triée par index croissant.
215
+
216
+ Parameters
217
+ ----------
218
+ text:
219
+ Texte à analyser.
220
+ min_length:
221
+ Longueur minimale d'un numéral retenu. Par défaut ``1``.
222
+ Mettre à ``2`` pour filtrer les single-letter ambigus (``I``
223
+ pronom, ``M`` initiale).
224
+
225
+ Faux positifs connus
226
+ --------------------
227
+ - ``I`` (pronom anglais), ``M`` ou ``D`` en initiale d'une
228
+ personne ne peuvent pas être distingués sans NER. Le chercheur
229
+ qui s'inquiète de ces faux positifs peut passer
230
+ ``min_length=2``.
231
+ """
232
+ if not text:
233
+ return []
234
+ found: list[tuple[int, str, int]] = []
235
+ for match in _ROMAN_RE.finditer(text):
236
+ s = match.group(0)
237
+ if len(s) < min_length:
238
+ continue
239
+ value = roman_to_int(s)
240
+ if value is None:
241
+ continue
242
+ found.append((match.start(), s, value))
243
+ return found
244
+
245
+
246
+ # ──────────────────────────────────────────────────────────────────────────
247
+ # Classification de la restitution dans l'hypothèse
248
+ # ──────────────────────────────────────────────────────────────────────────
249
+
250
+ # Statuts possibles, dans l'ordre de priorité (un numéral est
251
+ # classé selon le premier statut qui s'applique).
252
+
253
+ STATUS_STRICT_PRESERVED = "strict_preserved"
254
+ STATUS_CASE_CHANGED = "case_changed"
255
+ STATUS_J_DROPPED = "j_dropped"
256
+ STATUS_CONVERTED_TO_ARABIC = "converted_to_arabic"
257
+ STATUS_LOST = "lost"
258
+
259
+ ALL_STATUSES = (
260
+ STATUS_STRICT_PRESERVED,
261
+ STATUS_CASE_CHANGED,
262
+ STATUS_J_DROPPED,
263
+ STATUS_CONVERTED_TO_ARABIC,
264
+ STATUS_LOST,
265
+ )
266
+
267
+ # Statuts qui indiquent une préservation de la valeur (par opposition
268
+ # à la perte).
269
+ VALUE_PRESERVING_STATUSES = frozenset({
270
+ STATUS_STRICT_PRESERVED,
271
+ STATUS_CASE_CHANGED,
272
+ STATUS_J_DROPPED,
273
+ STATUS_CONVERTED_TO_ARABIC,
274
+ })
275
+
276
+
277
+ def _classify_restitution(numeral: str, value: int, hyp: str) -> str:
278
+ """Classifie comment ``numeral`` (de valeur ``value``) est
279
+ restitué dans ``hyp`` selon les 5 statuts définis."""
280
+ # 1. Forme stricte présente
281
+ if re.search(r"(?<![A-Za-z])" + re.escape(numeral) + r"(?![A-Za-z])", hyp):
282
+ return STATUS_STRICT_PRESERVED
283
+ # 2. Variante de casse seule
284
+ swapped = numeral.swapcase()
285
+ if swapped != numeral and re.search(
286
+ r"(?<![A-Za-z])" + re.escape(swapped) + r"(?![A-Za-z])", hyp,
287
+ ):
288
+ return STATUS_CASE_CHANGED
289
+ # 3. ``j`` final remplacé par ``i`` (ou inverse)
290
+ if numeral.lower().endswith("j"):
291
+ no_j = numeral[:-1] + ("I" if numeral[-1] == "J" else "i")
292
+ elif numeral.lower().endswith("i"):
293
+ no_j = numeral[:-1] + ("J" if numeral[-1] == "I" else "j")
294
+ else:
295
+ no_j = numeral
296
+ if no_j != numeral and re.search(
297
+ r"(?<![A-Za-z])" + re.escape(no_j) + r"(?![A-Za-z])", hyp,
298
+ ):
299
+ return STATUS_J_DROPPED
300
+ # Variante de casse + j-flip combinés
301
+ no_j_swapped = no_j.swapcase()
302
+ if no_j_swapped != numeral and re.search(
303
+ r"(?<![A-Za-z])" + re.escape(no_j_swapped) + r"(?![A-Za-z])", hyp,
304
+ ):
305
+ return STATUS_J_DROPPED
306
+ # 4. Conversion en chiffres arabes
307
+ if re.search(r"(?<!\d)" + str(value) + r"(?!\d)", hyp):
308
+ return STATUS_CONVERTED_TO_ARABIC
309
+ # 5. Perdu
310
+ return STATUS_LOST
311
+
312
+
313
+ # ──────────────────────────────────────────────────────────────────────────
314
+ # Calcul de la métrique
315
+ # ──────────────────────────────────────────────────────────────────────────
316
+
317
+
318
+ def compute_roman_numeral_metrics(
319
+ reference: Optional[str],
320
+ hypothesis: Optional[str],
321
+ *,
322
+ min_length: int = 1,
323
+ ) -> dict:
324
+ """Calcule la préservation des numéraux romains.
325
+
326
+ Pour chaque numéral romain dans la GT, on classifie sa
327
+ restitution dans l'hypothèse selon l'un des 5 statuts (forme
328
+ stricte / casse modifiée / j supprimé / conversion arabe / perdu).
329
+
330
+ Returns
331
+ -------
332
+ dict
333
+ ``{
334
+ "n_numerals_reference": int,
335
+ "n_strict_preserved": int,
336
+ "n_value_preserved": int, # tous statuts sauf LOST
337
+ "global_strict_score": float,
338
+ "global_value_score": float,
339
+ "per_status": {status: count for status in ALL_STATUSES},
340
+ "per_numeral": [
341
+ {"index", "numeral", "value", "status"}
342
+ ],
343
+ "lost_numerals": [
344
+ {"index", "numeral", "value"}
345
+ ],
346
+ }``
347
+
348
+ Cas dégénérés
349
+ -------------
350
+ - GT vide ou sans numéral → tous compteurs à 0, scores à 0.0,
351
+ ``per_status`` initialisé à 0 sur tous les statuts.
352
+ - GT avec numéraux + hyp vide → tous classés ``lost``,
353
+ strict_score = value_score = 0.0.
354
+ """
355
+ ref = reference or ""
356
+ hyp = hypothesis or ""
357
+
358
+ detected = detect_roman_numerals(ref, min_length=min_length)
359
+ n_total = len(detected)
360
+ per_status_init = {status: 0 for status in ALL_STATUSES}
361
+
362
+ if n_total == 0:
363
+ return {
364
+ "n_numerals_reference": 0,
365
+ "n_strict_preserved": 0,
366
+ "n_value_preserved": 0,
367
+ "global_strict_score": 0.0,
368
+ "global_value_score": 0.0,
369
+ "per_status": per_status_init,
370
+ "per_numeral": [],
371
+ "lost_numerals": [],
372
+ }
373
+
374
+ per_status: dict[str, int] = dict(per_status_init)
375
+ per_numeral: list[dict] = []
376
+ lost: list[dict] = []
377
+ for index, numeral, value in detected:
378
+ status = _classify_restitution(numeral, value, hyp)
379
+ per_status[status] = per_status.get(status, 0) + 1
380
+ per_numeral.append({
381
+ "index": index,
382
+ "numeral": numeral,
383
+ "value": value,
384
+ "status": status,
385
+ })
386
+ if status == STATUS_LOST:
387
+ lost.append({"index": index, "numeral": numeral, "value": value})
388
+
389
+ n_strict = per_status[STATUS_STRICT_PRESERVED]
390
+ n_value = sum(per_status[s] for s in VALUE_PRESERVING_STATUSES)
391
+
392
+ return {
393
+ "n_numerals_reference": n_total,
394
+ "n_strict_preserved": n_strict,
395
+ "n_value_preserved": n_value,
396
+ "global_strict_score": n_strict / n_total,
397
+ "global_value_score": n_value / n_total,
398
+ "per_status": per_status,
399
+ "per_numeral": per_numeral,
400
+ "lost_numerals": lost,
401
+ }
402
+
403
+
404
+ def roman_numeral_strict_score(
405
+ reference: Optional[str], hypothesis: Optional[str],
406
+ ) -> float:
407
+ """Raccourci : taux global de préservation **stricte** des
408
+ numéraux romains ∈ [0, 1]."""
409
+ return compute_roman_numeral_metrics(
410
+ reference, hypothesis,
411
+ )["global_strict_score"]
412
+
413
+
414
+ def roman_numeral_value_score(
415
+ reference: Optional[str], hypothesis: Optional[str],
416
+ ) -> float:
417
+ """Raccourci : taux global de préservation de la **valeur** des
418
+ numéraux romains (toute forme confondue : strict, case_changed,
419
+ j_dropped, arabe) ∈ [0, 1]."""
420
+ return compute_roman_numeral_metrics(
421
+ reference, hypothesis,
422
+ )["global_value_score"]
423
+
424
+
425
+ # ──────────────────────────────────────────────────────────────────────────
426
+ # Enregistrement dans le registre typé (Sprint 34)
427
+ # ──────────────────────────────────────────────────────────────────────────
428
+
429
+
430
+ @register_metric(
431
+ name="roman_numeral_strict_score",
432
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
433
+ description=(
434
+ "Taux de préservation stricte des numéraux romains "
435
+ "(forme exacte gardée : casse, j médiéval final). "
436
+ "Métrique transversale aux périodes médiévale, imprimé "
437
+ "ancien et moderne."
438
+ ),
439
+ higher_is_better=True,
440
+ tags={"text", "roman_numerals", "philology"},
441
+ )
442
+ def _registered_strict(reference: str, hypothesis: str) -> float:
443
+ return roman_numeral_strict_score(reference, hypothesis)
444
+
445
+
446
+ @register_metric(
447
+ name="roman_numeral_value_score",
448
+ input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
449
+ description=(
450
+ "Taux de préservation de la valeur numérique des numéraux "
451
+ "romains, indépendamment de la forme (strict, casse "
452
+ "changée, j supprimé, conversion en chiffres arabes). "
453
+ "Le breakdown per_status permet au chercheur de juger la "
454
+ "convention adoptée."
455
+ ),
456
+ higher_is_better=True,
457
+ tags={"text", "roman_numerals", "philology"},
458
+ )
459
+ def _registered_value(reference: str, hypothesis: str) -> float:
460
+ return roman_numeral_value_score(reference, hypothesis)
461
+
462
+
463
+ __all__ = [
464
+ "ROMAN_VALUES",
465
+ "ALL_STATUSES",
466
+ "STATUS_STRICT_PRESERVED",
467
+ "STATUS_CASE_CHANGED",
468
+ "STATUS_J_DROPPED",
469
+ "STATUS_CONVERTED_TO_ARABIC",
470
+ "STATUS_LOST",
471
+ "VALUE_PRESERVING_STATUSES",
472
+ "compute_roman_numeral_metrics",
473
+ "detect_roman_numerals",
474
+ "int_to_roman",
475
+ "roman_numeral_strict_score",
476
+ "roman_numeral_value_score",
477
+ "roman_to_int",
478
+ ]
picarones/extras/historical/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.core.metric_registry import register_metric
55
+ from picarones.core.modules 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/extras/render/lexical_modernization_render.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML de la vue « Modernisation lexicale » — Sprint 80.
2
+
3
+ A.I.7 du plan d'évolution 2026.
4
+
5
+ Suite directe ``picarones/core/lexical_modernization.py``.
6
+ Pattern identique aux autres rendus (Sprints 41/43/62/67/72/74/75/76/77) :
7
+ **server-side**, pas de JavaScript, anti-injection systématique.
8
+
9
+ Vue
10
+ ---
11
+ Tableau trié par taux de modernisation décroissant : forme
12
+ historique GT → forme(s) modernisée(s), occurrences GT, %.
13
+ Couleur de cellule pour le %.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from html import escape as _e
19
+ from typing import Optional
20
+
21
+ from picarones.core.lexical_modernization import top_modernized_tokens
22
+
23
+
24
+ def _color_for_rate(rate: float) -> str:
25
+ """Gradient blanc → orange profond pour rate ∈ [0, 1]."""
26
+ f = max(0.0, min(1.0, rate))
27
+ r = int(255 + (194 - 255) * f)
28
+ g = int(255 + (65 - 255) * f)
29
+ b = int(255 + (12 - 255) * f)
30
+ return f"#{r:02x}{g:02x}{b:02x}"
31
+
32
+
33
+ def _format_variants(variants: dict, max_show: int = 3) -> str:
34
+ """Liste compacte des variants modernisés."""
35
+ items = sorted(variants.items(), key=lambda kv: -kv[1])
36
+ shown = items[:max_show]
37
+ rest = len(items) - max_show
38
+ parts = [
39
+ f"{_e(form)} ({count})"
40
+ for form, count in shown
41
+ ]
42
+ if rest > 0:
43
+ parts.append(f"+{rest}")
44
+ return ", ".join(parts)
45
+
46
+
47
+ def build_lexical_modernization_html(
48
+ data: Optional[dict],
49
+ labels: Optional[dict[str, str]] = None,
50
+ *,
51
+ top_n: int = 20,
52
+ min_total: int = 1,
53
+ ) -> str:
54
+ """Construit la table HTML de modernisation lexicale.
55
+
56
+ Retourne ``""`` si ``data is None`` ou si aucun token modernisé.
57
+ """
58
+ if not data:
59
+ return ""
60
+ rows = top_modernized_tokens(data, n=top_n, min_total=min_total)
61
+ if not rows:
62
+ return ""
63
+ labels = labels or {}
64
+ title = labels.get(
65
+ "lexmod_title", "Modernisation lexicale (top tokens)",
66
+ )
67
+ note = labels.get(
68
+ "lexmod_note",
69
+ "Tokens GT que le moteur réécrit le plus souvent. "
70
+ "Lecture : « maistre → maître modernisé dans 85 % des cas » "
71
+ "indique de quoi corriger dans le prompt pour préserver "
72
+ "l'orthographe historique.",
73
+ )
74
+ gt_label = labels.get("lexmod_gt_label", "Forme historique GT")
75
+ hyp_label = labels.get("lexmod_hyp_label", "Variantes OCR")
76
+ n_label = labels.get("lexmod_n_label", "n GT")
77
+ rate_label = labels.get("lexmod_rate_label", "% modernisé")
78
+
79
+ parts = [
80
+ '<div class="lexmod" style="margin:1rem 0">',
81
+ f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
82
+ f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
83
+ f'{_e(note)}</div>',
84
+ '<table style="border-collapse:collapse;width:100%;'
85
+ 'font-size:.85rem">',
86
+ '<thead><tr>',
87
+ ]
88
+ for col in (gt_label, hyp_label, n_label, rate_label):
89
+ parts.append(
90
+ f'<th style="padding:.3rem .5rem;text-align:left;'
91
+ f'border-bottom:1px solid #ccc;font-weight:600">'
92
+ f'{_e(col)}</th>'
93
+ )
94
+ parts.append("</tr></thead><tbody>")
95
+ for gt_token, slot in rows:
96
+ rate = slot.get("rate_modernized", 0.0)
97
+ n_total = slot.get("n_total", 0)
98
+ variants_str = _format_variants(slot.get("variants") or {})
99
+ rate_color = _color_for_rate(rate)
100
+ parts.append(
101
+ f'<tr>'
102
+ f'<td style="padding:.3rem .5rem;font-family:monospace">'
103
+ f'{_e(gt_token)}</td>'
104
+ f'<td style="padding:.3rem .5rem;font-size:.85rem">'
105
+ f'{variants_str}</td>'
106
+ f'<td style="padding:.3rem .5rem;text-align:right;'
107
+ f'font-family:monospace">{n_total}</td>'
108
+ f'<td style="padding:.3rem .5rem;text-align:right;'
109
+ f'background:{rate_color};font-family:monospace">'
110
+ f'{rate * 100:.0f}%</td>'
111
+ f'</tr>'
112
+ )
113
+ parts.append("</tbody></table></div>")
114
+ return "".join(parts)
115
+
116
+
117
+ __all__ = [
118
+ "build_lexical_modernization_html",
119
+ ]
picarones/extras/render/philological_render.py ADDED
@@ -0,0 +1,615 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML server-side du profil philologique (Sprint 62).
2
+
3
+ Suite directe Sprint 61 (câblage backend) — produit les blocs HTML
4
+ qui exposent les six modules philologiques (Sprints 55-60) dans le
5
+ rapport :
6
+
7
+ - ``unicode_blocks`` (Sprint 55) — précision par bloc Unicode
8
+ - ``abbreviations`` (Sprint 56) — score strict + expansion par
9
+ abréviation médiévale Capelli
10
+ - ``mufi`` (Sprint 57) — couverture MUFI globale + par
11
+ caractère
12
+ - ``early_modern`` (Sprint 58) — préservation des marqueurs
13
+ typographiques imprimé ancien
14
+ - ``modern_archives`` (Sprint 59) — strict + expansion par
15
+ catégorie d'archive moderne
16
+ - ``roman_numerals`` (Sprint 60) — breakdown 5 statuts de
17
+ restitution
18
+
19
+ Principe identique aux Sprints 41 (NER) et 43 (calibration) :
20
+
21
+ - Rendu **server-side**, pas de JavaScript, déterministe.
22
+ - Section adaptive : si aucun moteur n'a de signal pour un module
23
+ donné, la sous-section est silencieusement omise.
24
+ - Si **aucun module** n'a de signal sur l'ensemble des moteurs,
25
+ ``build_philological_profile_html`` retourne une chaîne vide et
26
+ le bloc complet n'apparaît pas dans la vue analyses.
27
+ - **Aucune classification automatique** : on affiche les chiffres
28
+ bruts par catégorie/bloc/statut, le chercheur juge lui-même la
29
+ convention adoptée.
30
+ - Anti-injection : tous les noms de moteurs, catégories, statuts,
31
+ caractères passent par ``html.escape`` avant insertion.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from html import escape as _e
37
+ from typing import Optional
38
+
39
+
40
+ # ──────────────────────────────────────────────────────────────────────────
41
+ # Helpers de coloration
42
+ # ──────────────────────────────────────────────────────────────────────────
43
+
44
+
45
+ def _color_for_score(score: float) -> str:
46
+ """Gradient rouge → jaune → vert proportionnel à ``score`` ∈ [0, 1].
47
+
48
+ Identique à ``ner_render._color_for_f1``. Les scores
49
+ philologiques (preservation, coverage, accuracy) suivent la même
50
+ sémantique « plus c'est haut, mieux c'est » donc le gradient
51
+ est valide.
52
+ """
53
+ f = max(0.0, min(1.0, score))
54
+ if f <= 0.5:
55
+ ratio = f / 0.5
56
+ r = int(220 + (240 - 220) * ratio)
57
+ g = int(100 + (220 - 100) * ratio)
58
+ b = int(100 + (130 - 100) * ratio)
59
+ else:
60
+ ratio = (f - 0.5) / 0.5
61
+ r = int(240 + (130 - 240) * ratio)
62
+ g = int(220 + (200 - 220) * ratio)
63
+ b = int(130 + (130 - 130) * ratio)
64
+ return f"#{r:02x}{g:02x}{b:02x}"
65
+
66
+
67
+ def _engines_with_module(
68
+ engines_summary: list[dict], module: str,
69
+ ) -> list[dict]:
70
+ """Filtre les moteurs ayant des données pour le module donné."""
71
+ out: list[dict] = []
72
+ for eng in engines_summary:
73
+ agg = eng.get("aggregated_philological") or {}
74
+ if module in agg and agg[module]:
75
+ out.append(eng)
76
+ return out
77
+
78
+
79
+ def _score_cell(score: Optional[float], extra: str = "") -> str:
80
+ """Rend une cellule colorée. ``None`` → cellule grise « — »."""
81
+ if score is None:
82
+ return (
83
+ '<td style="padding:.3rem .5rem;text-align:center;'
84
+ 'background:#f0f0f0;color:#999">—</td>'
85
+ )
86
+ color = _color_for_score(score)
87
+ text = f"{score * 100:.1f}%"
88
+ if extra:
89
+ text += f" <span style=\"opacity:.6;font-size:.85em\">({_e(extra)})</span>"
90
+ return (
91
+ f'<td style="padding:.3rem .5rem;text-align:center;'
92
+ f'background:{color}">{text}</td>'
93
+ )
94
+
95
+
96
+ def _table_header(
97
+ columns: list[str], engine_label: str,
98
+ ) -> str:
99
+ """Construit l'entête d'un tableau moteur × colonnes."""
100
+ parts = [
101
+ '<thead><tr>',
102
+ f'<th style="padding:.3rem .5rem;text-align:left;'
103
+ f'border-bottom:1px solid var(--border);font-weight:600">'
104
+ f'{_e(engine_label)}</th>',
105
+ ]
106
+ for col in columns:
107
+ parts.append(
108
+ f'<th style="padding:.3rem .5rem;text-align:center;'
109
+ f'border-bottom:1px solid var(--border);font-weight:600">'
110
+ f'{_e(col)}</th>'
111
+ )
112
+ parts.append('</tr></thead>')
113
+ return "".join(parts)
114
+
115
+
116
+ def _engine_label_cell(name: str) -> str:
117
+ return (
118
+ f'<td style="padding:.3rem .5rem;font-weight:500;'
119
+ f'border-bottom:1px solid var(--border-light)">{_e(name)}</td>'
120
+ )
121
+
122
+
123
+ def _section_open(title: str, note: str = "") -> str:
124
+ parts = [
125
+ '<div class="philological-section" '
126
+ 'style="margin:1rem 0;padding:.75rem;'
127
+ 'background:var(--bg-secondary);border-radius:6px">',
128
+ f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
129
+ ]
130
+ if note:
131
+ parts.append(
132
+ f'<div style="font-size:.8rem;opacity:.75;margin-bottom:.5rem">'
133
+ f'{_e(note)}</div>'
134
+ )
135
+ return "".join(parts)
136
+
137
+
138
+ def _section_close() -> str:
139
+ return "</div>"
140
+
141
+
142
+ def _table_open() -> str:
143
+ return (
144
+ '<table style="border-collapse:collapse;width:100%;'
145
+ 'font-size:.85rem">'
146
+ )
147
+
148
+
149
+ def _table_close() -> str:
150
+ return "</table>"
151
+
152
+
153
+ # ──────────────────────────────────────────────────────────────────────────
154
+ # Sprint 55 — Précision par bloc Unicode
155
+ # ──────────────────────────────────────────────────────────────────────────
156
+
157
+
158
+ def build_unicode_blocks_section(
159
+ engines_summary: list[dict],
160
+ labels: Optional[dict[str, str]] = None,
161
+ ) -> str:
162
+ relevant = _engines_with_module(engines_summary, "unicode_blocks")
163
+ if not relevant:
164
+ return ""
165
+ labels = labels or {}
166
+ title = labels.get(
167
+ "philo_unicode_blocks_title", "Précision par bloc Unicode",
168
+ )
169
+ note = labels.get(
170
+ "philo_unicode_blocks_note",
171
+ "Pourcentage de caractères correctement restitués par bloc "
172
+ "Unicode rencontré dans la GT (hors Basic Latin).",
173
+ )
174
+ engine_label = labels.get("philo_engine_label", "Moteur")
175
+ global_label = labels.get("philo_global_label", "Global")
176
+
177
+ # Collecte tous les blocs présents (hors Basic Latin déjà filtré
178
+ # par adaptive masking, mais on défilte ici si Basic Latin
179
+ # apparaît malgré tout chez certains moteurs).
180
+ all_blocks: set[str] = set()
181
+ for eng in relevant:
182
+ per_block = eng["aggregated_philological"]["unicode_blocks"].get(
183
+ "per_block", {},
184
+ )
185
+ for block in per_block:
186
+ if block != "Basic Latin":
187
+ all_blocks.add(block)
188
+ blocks = sorted(all_blocks)
189
+ if not blocks:
190
+ return ""
191
+
192
+ parts = [_section_open(title, note), _table_open()]
193
+ parts.append(_table_header([global_label] + blocks, engine_label))
194
+ parts.append("<tbody>")
195
+ for eng in relevant:
196
+ agg = eng["aggregated_philological"]["unicode_blocks"]
197
+ global_acc = agg.get("global_accuracy", 0.0)
198
+ n_chars = agg.get("n_chars_total", 0)
199
+ parts.append("<tr>")
200
+ parts.append(_engine_label_cell(eng["name"]))
201
+ parts.append(_score_cell(global_acc, extra=f"n={n_chars}"))
202
+ per_block = agg.get("per_block", {})
203
+ for block in blocks:
204
+ stats = per_block.get(block)
205
+ if stats and stats.get("total", 0) > 0:
206
+ parts.append(_score_cell(
207
+ stats["accuracy"], extra=f"n={stats['total']}",
208
+ ))
209
+ else:
210
+ parts.append(_score_cell(None))
211
+ parts.append("</tr>")
212
+ parts.append("</tbody>")
213
+ parts.append(_table_close())
214
+ parts.append(_section_close())
215
+ return "".join(parts)
216
+
217
+
218
+ # (sections suivantes définies plus loin)
219
+
220
+
221
+ # ──────────────────────────────────────────────────────────────────────────
222
+ # Sprint 56 — Abréviations Capelli médiévales
223
+ # ──────────────────────────────────────────────────────────────────────────
224
+
225
+
226
+ def build_abbreviations_section(
227
+ engines_summary: list[dict],
228
+ labels: Optional[dict[str, str]] = None,
229
+ ) -> str:
230
+ relevant = _engines_with_module(engines_summary, "abbreviations")
231
+ if not relevant:
232
+ return ""
233
+ labels = labels or {}
234
+ title = labels.get(
235
+ "philo_abbreviations_title",
236
+ "Abréviations médiévales (Capelli)",
237
+ )
238
+ note = labels.get(
239
+ "philo_abbreviations_note",
240
+ "Strict = forme abrégée (ꝑ, ꝓ, ⁊…) préservée telle quelle ; "
241
+ "Expansion = abrégée OU forme développée (per, pro, et…) "
242
+ "présente. Le ratio strict/expansion par moteur indique la "
243
+ "convention adoptée (diplomatique / modernisante).",
244
+ )
245
+ engine_label = labels.get("philo_engine_label", "Moteur")
246
+ strict_label = labels.get("philo_strict_label", "Strict")
247
+ expansion_label = labels.get("philo_expansion_label", "Expansion")
248
+ n_label = labels.get("philo_n_total_label", "n total")
249
+
250
+ parts = [_section_open(title, note), _table_open()]
251
+ parts.append(_table_header(
252
+ [strict_label, expansion_label, n_label], engine_label,
253
+ ))
254
+ parts.append("<tbody>")
255
+ for eng in relevant:
256
+ agg = eng["aggregated_philological"]["abbreviations"]
257
+ parts.append("<tr>")
258
+ parts.append(_engine_label_cell(eng["name"]))
259
+ parts.append(_score_cell(agg.get("global_strict_score", 0.0)))
260
+ parts.append(_score_cell(agg.get("global_expansion_score", 0.0)))
261
+ parts.append(
262
+ f'<td style="padding:.3rem .5rem;text-align:center">'
263
+ f'{agg.get("n_abbreviations_in_reference", 0)}</td>'
264
+ )
265
+ parts.append("</tr>")
266
+ parts.append("</tbody>")
267
+ parts.append(_table_close())
268
+ parts.append(_section_close())
269
+ return "".join(parts)
270
+
271
+
272
+ # ──────────────────────────────────────────────────────────────────────────
273
+ # Sprint 57 — Couverture MUFI
274
+ # ──────────────────────────────────────────────────────────────────────────
275
+
276
+
277
+ def build_mufi_section(
278
+ engines_summary: list[dict],
279
+ labels: Optional[dict[str, str]] = None,
280
+ ) -> str:
281
+ relevant = _engines_with_module(engines_summary, "mufi")
282
+ if not relevant:
283
+ return ""
284
+ labels = labels or {}
285
+ title = labels.get(
286
+ "philo_mufi_title",
287
+ "Couverture MUFI (Medieval Unicode Font Initiative)",
288
+ )
289
+ note = labels.get(
290
+ "philo_mufi_note",
291
+ "Taux de caractères MUFI de la GT (þ, ð, ƿ, ſ, æ, lettres "
292
+ "PUA…) correctement restitués dans l'OCR. Critère éditorial "
293
+ "central pour les médiévistes.",
294
+ )
295
+ engine_label = labels.get("philo_engine_label", "Moteur")
296
+ coverage_label = labels.get("philo_mufi_coverage_label", "Couverture")
297
+ n_label = labels.get("philo_n_total_label", "n total")
298
+
299
+ parts = [_section_open(title, note), _table_open()]
300
+ parts.append(_table_header(
301
+ [coverage_label, n_label], engine_label,
302
+ ))
303
+ parts.append("<tbody>")
304
+ for eng in relevant:
305
+ agg = eng["aggregated_philological"]["mufi"]
306
+ parts.append("<tr>")
307
+ parts.append(_engine_label_cell(eng["name"]))
308
+ parts.append(_score_cell(agg.get("coverage", 0.0)))
309
+ parts.append(
310
+ f'<td style="padding:.3rem .5rem;text-align:center">'
311
+ f'{agg.get("n_mufi_chars_reference", 0)}</td>'
312
+ )
313
+ parts.append("</tr>")
314
+ parts.append("</tbody>")
315
+ parts.append(_table_close())
316
+ parts.append(_section_close())
317
+ return "".join(parts)
318
+
319
+
320
+ # ──────────────────────────────────────────────────────────────────────────
321
+ # Sprint 58 — Marqueurs typographiques imprimé ancien (heatmap)
322
+ # ──────────────────────────────────────────────────────────────────────────
323
+
324
+
325
+ def build_early_modern_section(
326
+ engines_summary: list[dict],
327
+ labels: Optional[dict[str, str]] = None,
328
+ ) -> str:
329
+ relevant = _engines_with_module(engines_summary, "early_modern")
330
+ if not relevant:
331
+ return ""
332
+ labels = labels or {}
333
+ title = labels.get(
334
+ "philo_early_modern_title",
335
+ "Marqueurs typographiques imprimé ancien (XVIᵉ-XVIIIᵉ)",
336
+ )
337
+ note = labels.get(
338
+ "philo_early_modern_note",
339
+ "Préservation des ligatures (fi fl ff), s long (ſ), i sans "
340
+ "point (ı), esperluette (&) et tildes nasaux (ã õ ñ). "
341
+ "Une ligne par moteur, une colonne par catégorie.",
342
+ )
343
+ engine_label = labels.get("philo_engine_label", "Moteur")
344
+ global_label = labels.get("philo_global_label", "Global")
345
+
346
+ all_cats: set[str] = set()
347
+ for eng in relevant:
348
+ all_cats.update(
349
+ eng["aggregated_philological"]["early_modern"]
350
+ .get("per_category", {}).keys(),
351
+ )
352
+ cats = sorted(all_cats)
353
+ if not cats:
354
+ return ""
355
+
356
+ parts = [_section_open(title, note), _table_open()]
357
+ parts.append(_table_header([global_label] + cats, engine_label))
358
+ parts.append("<tbody>")
359
+ for eng in relevant:
360
+ agg = eng["aggregated_philological"]["early_modern"]
361
+ n_total = agg.get("n_markers_reference", 0)
362
+ parts.append("<tr>")
363
+ parts.append(_engine_label_cell(eng["name"]))
364
+ parts.append(_score_cell(
365
+ agg.get("global_preservation", 0.0), extra=f"n={n_total}",
366
+ ))
367
+ per_cat = agg.get("per_category", {})
368
+ for cat in cats:
369
+ stats = per_cat.get(cat)
370
+ if stats and stats.get("total", 0) > 0:
371
+ parts.append(_score_cell(
372
+ stats["preservation"], extra=f"n={stats['total']}",
373
+ ))
374
+ else:
375
+ parts.append(_score_cell(None))
376
+ parts.append("</tr>")
377
+ parts.append("</tbody>")
378
+ parts.append(_table_close())
379
+ parts.append(_section_close())
380
+ return "".join(parts)
381
+
382
+
383
+ # ──────────────────────────────────────────────────────────────────────────
384
+ # Sprint 59 — Archives modernes : strict + expansion par catégorie
385
+ # ──────────────────────────────────────────────────────────────────────────
386
+
387
+
388
+ def build_modern_archives_section(
389
+ engines_summary: list[dict],
390
+ labels: Optional[dict[str, str]] = None,
391
+ ) -> str:
392
+ relevant = _engines_with_module(engines_summary, "modern_archives")
393
+ if not relevant:
394
+ return ""
395
+ labels = labels or {}
396
+ title = labels.get(
397
+ "philo_modern_archives_title",
398
+ "Abréviations des archives modernes (XIXᵉ-XXᵉ)",
399
+ )
400
+ note = labels.get(
401
+ "philo_modern_archives_note",
402
+ "Strict = abrégé préservé (Mme, S.A.R., bd, vol., …) ; "
403
+ "Expansion = abrégé OU forme développée. Affiché par "
404
+ "catégorie : civilité, ordinaux, monnaie, administratif, "
405
+ "état civil, ponctuation typo, latin, biblio, adresse.",
406
+ )
407
+ engine_label = labels.get("philo_engine_label", "Moteur")
408
+ global_label = labels.get("philo_global_label", "Global")
409
+ strict_label = labels.get("philo_strict_label", "Strict")
410
+ expansion_label = labels.get("philo_expansion_label", "Expansion")
411
+
412
+ all_cats: set[str] = set()
413
+ for eng in relevant:
414
+ all_cats.update(
415
+ eng["aggregated_philological"]["modern_archives"]
416
+ .get("per_category", {}).keys(),
417
+ )
418
+ cats = sorted(all_cats)
419
+
420
+ parts = [_section_open(title, note)]
421
+ parts.append(
422
+ '<table style="border-collapse:collapse;width:100%;'
423
+ 'font-size:.85rem">'
424
+ )
425
+ parts.append("<thead><tr>")
426
+ parts.append(
427
+ f'<th rowspan="2" style="padding:.3rem .5rem;text-align:left;'
428
+ f'border-bottom:1px solid var(--border);font-weight:600">'
429
+ f'{_e(engine_label)}</th>'
430
+ )
431
+ parts.append(
432
+ f'<th colspan="2" style="padding:.3rem .5rem;text-align:center;'
433
+ f'border-bottom:1px solid var(--border);font-weight:600">'
434
+ f'{_e(global_label)}</th>'
435
+ )
436
+ for cat in cats:
437
+ parts.append(
438
+ f'<th colspan="2" style="padding:.3rem .5rem;text-align:center;'
439
+ f'border-bottom:1px solid var(--border);font-weight:600">'
440
+ f'{_e(cat)}</th>'
441
+ )
442
+ parts.append("</tr><tr>")
443
+ for _ in range(1 + len(cats)):
444
+ parts.append(
445
+ f'<th style="padding:.2rem .4rem;text-align:center;'
446
+ f'font-size:.75rem;font-weight:500;opacity:.7">'
447
+ f'{_e(strict_label)}</th>'
448
+ )
449
+ parts.append(
450
+ f'<th style="padding:.2rem .4rem;text-align:center;'
451
+ f'font-size:.75rem;font-weight:500;opacity:.7">'
452
+ f'{_e(expansion_label)}</th>'
453
+ )
454
+ parts.append("</tr></thead>")
455
+ parts.append("<tbody>")
456
+ for eng in relevant:
457
+ agg = eng["aggregated_philological"]["modern_archives"]
458
+ parts.append("<tr>")
459
+ parts.append(_engine_label_cell(eng["name"]))
460
+ parts.append(_score_cell(agg.get("global_strict_score", 0.0)))
461
+ parts.append(_score_cell(agg.get("global_expansion_score", 0.0)))
462
+ per_cat = agg.get("per_category", {})
463
+ for cat in cats:
464
+ stats = per_cat.get(cat)
465
+ if stats and stats.get("n_total", 0) > 0:
466
+ parts.append(_score_cell(
467
+ stats["strict_score"],
468
+ extra=f"n={stats['n_total']}",
469
+ ))
470
+ parts.append(_score_cell(stats["expansion_score"]))
471
+ else:
472
+ parts.append(_score_cell(None))
473
+ parts.append(_score_cell(None))
474
+ parts.append("</tr>")
475
+ parts.append("</tbody>")
476
+ parts.append(_table_close())
477
+ parts.append(_section_close())
478
+ return "".join(parts)
479
+
480
+
481
+ # ──────────────────────────────────────────────────────────────────────────
482
+ # Sprint 60 — Numéraux romains : breakdown 5 statuts
483
+ # ──────────────────────────────────────────────────────────────────────────
484
+
485
+
486
+ def build_roman_numerals_section(
487
+ engines_summary: list[dict],
488
+ labels: Optional[dict[str, str]] = None,
489
+ ) -> str:
490
+ relevant = _engines_with_module(engines_summary, "roman_numerals")
491
+ if not relevant:
492
+ return ""
493
+ labels = labels or {}
494
+ title = labels.get(
495
+ "philo_roman_numerals_title",
496
+ "Numéraux romains : restitution par statut",
497
+ )
498
+ note = labels.get(
499
+ "philo_roman_numerals_note",
500
+ "Pour chaque numéral romain de la GT, statut de restitution : "
501
+ "strict (forme exacte), case_changed (casse modifiée), "
502
+ "j_dropped (j médiéval normalisé), converted_to_arabic, lost. "
503
+ "Le breakdown indique la convention : majoritaire strict → "
504
+ "diplomatique ; majoritaire arabic → modernisation profonde.",
505
+ )
506
+ engine_label = labels.get("philo_engine_label", "Moteur")
507
+ n_label = labels.get("philo_n_total_label", "n total")
508
+
509
+ statuses = (
510
+ "strict_preserved", "case_changed", "j_dropped",
511
+ "converted_to_arabic", "lost",
512
+ )
513
+ status_labels = {
514
+ s: labels.get(f"philo_roman_status_{s}", s) for s in statuses
515
+ }
516
+
517
+ parts = [_section_open(title, note), _table_open()]
518
+ parts.append(_table_header(
519
+ [n_label] + [status_labels[s] for s in statuses],
520
+ engine_label,
521
+ ))
522
+ parts.append("<tbody>")
523
+ for eng in relevant:
524
+ agg = eng["aggregated_philological"]["roman_numerals"]
525
+ n_total = agg.get("n_numerals_reference", 0)
526
+ per_status = agg.get("per_status", {})
527
+ parts.append("<tr>")
528
+ parts.append(_engine_label_cell(eng["name"]))
529
+ parts.append(
530
+ f'<td style="padding:.3rem .5rem;text-align:center">'
531
+ f'{n_total}</td>'
532
+ )
533
+ for status in statuses:
534
+ count = per_status.get(status, 0)
535
+ if n_total > 0:
536
+ ratio = count / n_total
537
+ # Pour « lost » on inverse la couleur (un haut taux
538
+ # de perte est mauvais). Pour les autres on garde
539
+ # la sémantique « plus c'est haut, plus l'OCR a
540
+ # adopté ce statut ».
541
+ color = (
542
+ _color_for_score(1.0 - ratio) if status == "lost"
543
+ else _color_for_score(ratio)
544
+ )
545
+ parts.append(
546
+ f'<td style="padding:.3rem .5rem;text-align:center;'
547
+ f'background:{color}">{count} '
548
+ f'<span style="opacity:.6;font-size:.85em">'
549
+ f'({ratio * 100:.0f}%)</span></td>'
550
+ )
551
+ else:
552
+ parts.append(_score_cell(None))
553
+ parts.append("</tr>")
554
+ parts.append("</tbody>")
555
+ parts.append(_table_close())
556
+ parts.append(_section_close())
557
+ return "".join(parts)
558
+
559
+
560
+ # ──────────────────────────────────────────────────────────────────────────
561
+ # Agrégateur principal
562
+ # ──────────────────────────────────────────────────────────────────────────
563
+
564
+
565
+ def build_philological_profile_html(
566
+ engines_summary: list[dict],
567
+ labels: Optional[dict[str, str]] = None,
568
+ ) -> str:
569
+ """Assemble les six sections en un bloc unique.
570
+
571
+ Retourne ``""`` si aucune section n'a de contenu (c.-à-d.
572
+ aucun moteur n'a de signal philologique sur le corpus).
573
+ """
574
+ sections = [
575
+ build_unicode_blocks_section(engines_summary, labels),
576
+ build_abbreviations_section(engines_summary, labels),
577
+ build_mufi_section(engines_summary, labels),
578
+ build_early_modern_section(engines_summary, labels),
579
+ build_modern_archives_section(engines_summary, labels),
580
+ build_roman_numerals_section(engines_summary, labels),
581
+ ]
582
+ non_empty = [s for s in sections if s]
583
+ if not non_empty:
584
+ return ""
585
+ labels = labels or {}
586
+ main_title = labels.get(
587
+ "philo_profile_title", "Profil philologique",
588
+ )
589
+ main_note = labels.get(
590
+ "philo_profile_note",
591
+ "Données brutes par catégorie de marqueur philologique. "
592
+ "L'outil ne classifie pas la convention adoptée par chaque "
593
+ "moteur — c'est au chercheur de lire les chiffres et de "
594
+ "conclure selon ses critères éditoriaux.",
595
+ )
596
+ parts = [
597
+ '<div class="philological-profile">',
598
+ f'<h3 style="margin-top:0">{_e(main_title)}</h3>',
599
+ f'<p style="font-size:.85rem;opacity:.8;margin-bottom:.5rem">'
600
+ f'{_e(main_note)}</p>',
601
+ ]
602
+ parts.extend(non_empty)
603
+ parts.append("</div>")
604
+ return "".join(parts)
605
+
606
+
607
+ __all__ = [
608
+ "build_philological_profile_html",
609
+ "build_unicode_blocks_section",
610
+ "build_abbreviations_section",
611
+ "build_mufi_section",
612
+ "build_early_modern_section",
613
+ "build_modern_archives_section",
614
+ "build_roman_numerals_section",
615
+ ]
picarones/report/lexical_modernization_render.py CHANGED
@@ -1,119 +1,17 @@
1
- """Rendu HTML de la vue « Modernisation lexicale » — Sprint 80.
2
 
3
- A.I.7 du plan d'évolution 2026.
 
 
 
4
 
5
- Suite directe ``picarones/core/lexical_modernization.py``.
6
- Pattern identique aux autres rendus (Sprints 41/43/62/67/72/74/75/76/77) :
7
- **server-side**, pas de JavaScript, anti-injection systématique.
8
-
9
- Vue
10
- ---
11
- Tableau trié par taux de modernisation décroissant : forme
12
- historique GT → forme(s) modernisée(s), occurrences GT, %.
13
- Couleur de cellule pour le %.
14
  """
15
 
16
- from __future__ import annotations
17
-
18
- from html import escape as _e
19
- from typing import Optional
20
-
21
- from picarones.core.lexical_modernization import top_modernized_tokens
22
-
23
-
24
- def _color_for_rate(rate: float) -> str:
25
- """Gradient blanc → orange profond pour rate ∈ [0, 1]."""
26
- f = max(0.0, min(1.0, rate))
27
- r = int(255 + (194 - 255) * f)
28
- g = int(255 + (65 - 255) * f)
29
- b = int(255 + (12 - 255) * f)
30
- return f"#{r:02x}{g:02x}{b:02x}"
31
-
32
-
33
- def _format_variants(variants: dict, max_show: int = 3) -> str:
34
- """Liste compacte des variants modernisés."""
35
- items = sorted(variants.items(), key=lambda kv: -kv[1])
36
- shown = items[:max_show]
37
- rest = len(items) - max_show
38
- parts = [
39
- f"{_e(form)} ({count})"
40
- for form, count in shown
41
- ]
42
- if rest > 0:
43
- parts.append(f"+{rest}")
44
- return ", ".join(parts)
45
-
46
-
47
- def build_lexical_modernization_html(
48
- data: Optional[dict],
49
- labels: Optional[dict[str, str]] = None,
50
- *,
51
- top_n: int = 20,
52
- min_total: int = 1,
53
- ) -> str:
54
- """Construit la table HTML de modernisation lexicale.
55
-
56
- Retourne ``""`` si ``data is None`` ou si aucun token modernisé.
57
- """
58
- if not data:
59
- return ""
60
- rows = top_modernized_tokens(data, n=top_n, min_total=min_total)
61
- if not rows:
62
- return ""
63
- labels = labels or {}
64
- title = labels.get(
65
- "lexmod_title", "Modernisation lexicale (top tokens)",
66
- )
67
- note = labels.get(
68
- "lexmod_note",
69
- "Tokens GT que le moteur réécrit le plus souvent. "
70
- "Lecture : « maistre → maître modernisé dans 85 % des cas » "
71
- "indique de quoi corriger dans le prompt pour préserver "
72
- "l'orthographe historique.",
73
- )
74
- gt_label = labels.get("lexmod_gt_label", "Forme historique GT")
75
- hyp_label = labels.get("lexmod_hyp_label", "Variantes OCR")
76
- n_label = labels.get("lexmod_n_label", "n GT")
77
- rate_label = labels.get("lexmod_rate_label", "% modernisé")
78
-
79
- parts = [
80
- '<div class="lexmod" style="margin:1rem 0">',
81
- f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
82
- f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
83
- f'{_e(note)}</div>',
84
- '<table style="border-collapse:collapse;width:100%;'
85
- 'font-size:.85rem">',
86
- '<thead><tr>',
87
- ]
88
- for col in (gt_label, hyp_label, n_label, rate_label):
89
- parts.append(
90
- f'<th style="padding:.3rem .5rem;text-align:left;'
91
- f'border-bottom:1px solid #ccc;font-weight:600">'
92
- f'{_e(col)}</th>'
93
- )
94
- parts.append("</tr></thead><tbody>")
95
- for gt_token, slot in rows:
96
- rate = slot.get("rate_modernized", 0.0)
97
- n_total = slot.get("n_total", 0)
98
- variants_str = _format_variants(slot.get("variants") or {})
99
- rate_color = _color_for_rate(rate)
100
- parts.append(
101
- f'<tr>'
102
- f'<td style="padding:.3rem .5rem;font-family:monospace">'
103
- f'{_e(gt_token)}</td>'
104
- f'<td style="padding:.3rem .5rem;font-size:.85rem">'
105
- f'{variants_str}</td>'
106
- f'<td style="padding:.3rem .5rem;text-align:right;'
107
- f'font-family:monospace">{n_total}</td>'
108
- f'<td style="padding:.3rem .5rem;text-align:right;'
109
- f'background:{rate_color};font-family:monospace">'
110
- f'{rate * 100:.0f}%</td>'
111
- f'</tr>'
112
- )
113
- parts.append("</tbody></table></div>")
114
- return "".join(parts)
115
-
116
 
117
- __all__ = [
118
- "build_lexical_modernization_html",
119
- ]
 
 
1
+ """Alias rétrocompat module déplacé dans :mod:`picarones.extras.render.lexical_modernization_render`.
2
 
3
+ Phase B du chantier de refonte en 3 cercles (architecture-cercles.md).
4
+ Ce module philologique est désormais en Cercle 3 (``extras/``). L'alias
5
+ ici permet aux imports historiques (``from picarones.report.lexical_modernization_render
6
+ import ...``) de continuer à fonctionner sans modification.
7
 
8
+ Voir :doc:`docs/architecture-cercles.md` et l'extra
9
+ ``picarones[historical]`` du ``pyproject.toml``.
 
 
 
 
 
 
 
10
  """
11
 
12
+ from picarones.extras.render.lexical_modernization_render import * # noqa: F401, F403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
+ import picarones.extras.render.lexical_modernization_render as _module
15
+ __all__ = getattr(_module, "__all__", [
16
+ name for name in dir(_module) if not name.startswith("_")
17
+ ])
picarones/report/philological_render.py CHANGED
@@ -1,615 +1,17 @@
1
- """Rendu HTML server-side du profil philologique (Sprint 62).
2
 
3
- Suite directe Sprint 61 (câblage backend) produit les blocs HTML
4
- qui exposent les six modules philologiques (Sprints 55-60) dans le
5
- rapport :
 
6
 
7
- - ``unicode_blocks`` (Sprint 55) — précision par bloc Unicode
8
- - ``abbreviations`` (Sprint 56) — score strict + expansion par
9
- abréviation médiévale Capelli
10
- - ``mufi`` (Sprint 57) — couverture MUFI globale + par
11
- caractère
12
- - ``early_modern`` (Sprint 58) — préservation des marqueurs
13
- typographiques imprimé ancien
14
- - ``modern_archives`` (Sprint 59) — strict + expansion par
15
- catégorie d'archive moderne
16
- - ``roman_numerals`` (Sprint 60) — breakdown 5 statuts de
17
- restitution
18
-
19
- Principe identique aux Sprints 41 (NER) et 43 (calibration) :
20
-
21
- - Rendu **server-side**, pas de JavaScript, déterministe.
22
- - Section adaptive : si aucun moteur n'a de signal pour un module
23
- donné, la sous-section est silencieusement omise.
24
- - Si **aucun module** n'a de signal sur l'ensemble des moteurs,
25
- ``build_philological_profile_html`` retourne une chaîne vide et
26
- le bloc complet n'apparaît pas dans la vue analyses.
27
- - **Aucune classification automatique** : on affiche les chiffres
28
- bruts par catégorie/bloc/statut, le chercheur juge lui-même la
29
- convention adoptée.
30
- - Anti-injection : tous les noms de moteurs, catégories, statuts,
31
- caractères passent par ``html.escape`` avant insertion.
32
  """
33
 
34
- from __future__ import annotations
35
-
36
- from html import escape as _e
37
- from typing import Optional
38
-
39
-
40
- # ──────────────────────────────────────────────────────────────────────────
41
- # Helpers de coloration
42
- # ──────────────────────────────────────────────────────────────────────────
43
-
44
-
45
- def _color_for_score(score: float) -> str:
46
- """Gradient rouge → jaune → vert proportionnel à ``score`` ∈ [0, 1].
47
-
48
- Identique à ``ner_render._color_for_f1``. Les scores
49
- philologiques (preservation, coverage, accuracy) suivent la même
50
- sémantique « plus c'est haut, mieux c'est » donc le gradient
51
- est valide.
52
- """
53
- f = max(0.0, min(1.0, score))
54
- if f <= 0.5:
55
- ratio = f / 0.5
56
- r = int(220 + (240 - 220) * ratio)
57
- g = int(100 + (220 - 100) * ratio)
58
- b = int(100 + (130 - 100) * ratio)
59
- else:
60
- ratio = (f - 0.5) / 0.5
61
- r = int(240 + (130 - 240) * ratio)
62
- g = int(220 + (200 - 220) * ratio)
63
- b = int(130 + (130 - 130) * ratio)
64
- return f"#{r:02x}{g:02x}{b:02x}"
65
-
66
-
67
- def _engines_with_module(
68
- engines_summary: list[dict], module: str,
69
- ) -> list[dict]:
70
- """Filtre les moteurs ayant des données pour le module donné."""
71
- out: list[dict] = []
72
- for eng in engines_summary:
73
- agg = eng.get("aggregated_philological") or {}
74
- if module in agg and agg[module]:
75
- out.append(eng)
76
- return out
77
-
78
-
79
- def _score_cell(score: Optional[float], extra: str = "") -> str:
80
- """Rend une cellule colorée. ``None`` → cellule grise « — »."""
81
- if score is None:
82
- return (
83
- '<td style="padding:.3rem .5rem;text-align:center;'
84
- 'background:#f0f0f0;color:#999">—</td>'
85
- )
86
- color = _color_for_score(score)
87
- text = f"{score * 100:.1f}%"
88
- if extra:
89
- text += f" <span style=\"opacity:.6;font-size:.85em\">({_e(extra)})</span>"
90
- return (
91
- f'<td style="padding:.3rem .5rem;text-align:center;'
92
- f'background:{color}">{text}</td>'
93
- )
94
-
95
-
96
- def _table_header(
97
- columns: list[str], engine_label: str,
98
- ) -> str:
99
- """Construit l'entête d'un tableau moteur × colonnes."""
100
- parts = [
101
- '<thead><tr>',
102
- f'<th style="padding:.3rem .5rem;text-align:left;'
103
- f'border-bottom:1px solid var(--border);font-weight:600">'
104
- f'{_e(engine_label)}</th>',
105
- ]
106
- for col in columns:
107
- parts.append(
108
- f'<th style="padding:.3rem .5rem;text-align:center;'
109
- f'border-bottom:1px solid var(--border);font-weight:600">'
110
- f'{_e(col)}</th>'
111
- )
112
- parts.append('</tr></thead>')
113
- return "".join(parts)
114
-
115
-
116
- def _engine_label_cell(name: str) -> str:
117
- return (
118
- f'<td style="padding:.3rem .5rem;font-weight:500;'
119
- f'border-bottom:1px solid var(--border-light)">{_e(name)}</td>'
120
- )
121
-
122
-
123
- def _section_open(title: str, note: str = "") -> str:
124
- parts = [
125
- '<div class="philological-section" '
126
- 'style="margin:1rem 0;padding:.75rem;'
127
- 'background:var(--bg-secondary);border-radius:6px">',
128
- f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
129
- ]
130
- if note:
131
- parts.append(
132
- f'<div style="font-size:.8rem;opacity:.75;margin-bottom:.5rem">'
133
- f'{_e(note)}</div>'
134
- )
135
- return "".join(parts)
136
-
137
-
138
- def _section_close() -> str:
139
- return "</div>"
140
-
141
-
142
- def _table_open() -> str:
143
- return (
144
- '<table style="border-collapse:collapse;width:100%;'
145
- 'font-size:.85rem">'
146
- )
147
-
148
-
149
- def _table_close() -> str:
150
- return "</table>"
151
-
152
-
153
- # ──────────────────────────────────────────────────────────────────────────
154
- # Sprint 55 — Précision par bloc Unicode
155
- # ──────────────────────────────────────────────────────────────────────────
156
-
157
-
158
- def build_unicode_blocks_section(
159
- engines_summary: list[dict],
160
- labels: Optional[dict[str, str]] = None,
161
- ) -> str:
162
- relevant = _engines_with_module(engines_summary, "unicode_blocks")
163
- if not relevant:
164
- return ""
165
- labels = labels or {}
166
- title = labels.get(
167
- "philo_unicode_blocks_title", "Précision par bloc Unicode",
168
- )
169
- note = labels.get(
170
- "philo_unicode_blocks_note",
171
- "Pourcentage de caractères correctement restitués par bloc "
172
- "Unicode rencontré dans la GT (hors Basic Latin).",
173
- )
174
- engine_label = labels.get("philo_engine_label", "Moteur")
175
- global_label = labels.get("philo_global_label", "Global")
176
-
177
- # Collecte tous les blocs présents (hors Basic Latin déjà filtré
178
- # par adaptive masking, mais on défilte ici si Basic Latin
179
- # apparaît malgré tout chez certains moteurs).
180
- all_blocks: set[str] = set()
181
- for eng in relevant:
182
- per_block = eng["aggregated_philological"]["unicode_blocks"].get(
183
- "per_block", {},
184
- )
185
- for block in per_block:
186
- if block != "Basic Latin":
187
- all_blocks.add(block)
188
- blocks = sorted(all_blocks)
189
- if not blocks:
190
- return ""
191
-
192
- parts = [_section_open(title, note), _table_open()]
193
- parts.append(_table_header([global_label] + blocks, engine_label))
194
- parts.append("<tbody>")
195
- for eng in relevant:
196
- agg = eng["aggregated_philological"]["unicode_blocks"]
197
- global_acc = agg.get("global_accuracy", 0.0)
198
- n_chars = agg.get("n_chars_total", 0)
199
- parts.append("<tr>")
200
- parts.append(_engine_label_cell(eng["name"]))
201
- parts.append(_score_cell(global_acc, extra=f"n={n_chars}"))
202
- per_block = agg.get("per_block", {})
203
- for block in blocks:
204
- stats = per_block.get(block)
205
- if stats and stats.get("total", 0) > 0:
206
- parts.append(_score_cell(
207
- stats["accuracy"], extra=f"n={stats['total']}",
208
- ))
209
- else:
210
- parts.append(_score_cell(None))
211
- parts.append("</tr>")
212
- parts.append("</tbody>")
213
- parts.append(_table_close())
214
- parts.append(_section_close())
215
- return "".join(parts)
216
-
217
-
218
- # (sections suivantes définies plus loin)
219
-
220
-
221
- # ──────────────────────────────────────────────────────────────────────────
222
- # Sprint 56 — Abréviations Capelli médiévales
223
- # ──────────────────────────────────────────────────────────────────────────
224
-
225
-
226
- def build_abbreviations_section(
227
- engines_summary: list[dict],
228
- labels: Optional[dict[str, str]] = None,
229
- ) -> str:
230
- relevant = _engines_with_module(engines_summary, "abbreviations")
231
- if not relevant:
232
- return ""
233
- labels = labels or {}
234
- title = labels.get(
235
- "philo_abbreviations_title",
236
- "Abréviations médiévales (Capelli)",
237
- )
238
- note = labels.get(
239
- "philo_abbreviations_note",
240
- "Strict = forme abrégée (ꝑ, ꝓ, ⁊…) préservée telle quelle ; "
241
- "Expansion = abrégée OU forme développée (per, pro, et…) "
242
- "présente. Le ratio strict/expansion par moteur indique la "
243
- "convention adoptée (diplomatique / modernisante).",
244
- )
245
- engine_label = labels.get("philo_engine_label", "Moteur")
246
- strict_label = labels.get("philo_strict_label", "Strict")
247
- expansion_label = labels.get("philo_expansion_label", "Expansion")
248
- n_label = labels.get("philo_n_total_label", "n total")
249
-
250
- parts = [_section_open(title, note), _table_open()]
251
- parts.append(_table_header(
252
- [strict_label, expansion_label, n_label], engine_label,
253
- ))
254
- parts.append("<tbody>")
255
- for eng in relevant:
256
- agg = eng["aggregated_philological"]["abbreviations"]
257
- parts.append("<tr>")
258
- parts.append(_engine_label_cell(eng["name"]))
259
- parts.append(_score_cell(agg.get("global_strict_score", 0.0)))
260
- parts.append(_score_cell(agg.get("global_expansion_score", 0.0)))
261
- parts.append(
262
- f'<td style="padding:.3rem .5rem;text-align:center">'
263
- f'{agg.get("n_abbreviations_in_reference", 0)}</td>'
264
- )
265
- parts.append("</tr>")
266
- parts.append("</tbody>")
267
- parts.append(_table_close())
268
- parts.append(_section_close())
269
- return "".join(parts)
270
-
271
-
272
- # ──────────────────────────────────────────────────────────────────────────
273
- # Sprint 57 — Couverture MUFI
274
- # ──────────────────────────────────────────────────────────────────────────
275
-
276
-
277
- def build_mufi_section(
278
- engines_summary: list[dict],
279
- labels: Optional[dict[str, str]] = None,
280
- ) -> str:
281
- relevant = _engines_with_module(engines_summary, "mufi")
282
- if not relevant:
283
- return ""
284
- labels = labels or {}
285
- title = labels.get(
286
- "philo_mufi_title",
287
- "Couverture MUFI (Medieval Unicode Font Initiative)",
288
- )
289
- note = labels.get(
290
- "philo_mufi_note",
291
- "Taux de caractères MUFI de la GT (þ, ð, ƿ, ſ, æ, lettres "
292
- "PUA…) correctement restitués dans l'OCR. Critère éditorial "
293
- "central pour les médiévistes.",
294
- )
295
- engine_label = labels.get("philo_engine_label", "Moteur")
296
- coverage_label = labels.get("philo_mufi_coverage_label", "Couverture")
297
- n_label = labels.get("philo_n_total_label", "n total")
298
-
299
- parts = [_section_open(title, note), _table_open()]
300
- parts.append(_table_header(
301
- [coverage_label, n_label], engine_label,
302
- ))
303
- parts.append("<tbody>")
304
- for eng in relevant:
305
- agg = eng["aggregated_philological"]["mufi"]
306
- parts.append("<tr>")
307
- parts.append(_engine_label_cell(eng["name"]))
308
- parts.append(_score_cell(agg.get("coverage", 0.0)))
309
- parts.append(
310
- f'<td style="padding:.3rem .5rem;text-align:center">'
311
- f'{agg.get("n_mufi_chars_reference", 0)}</td>'
312
- )
313
- parts.append("</tr>")
314
- parts.append("</tbody>")
315
- parts.append(_table_close())
316
- parts.append(_section_close())
317
- return "".join(parts)
318
-
319
-
320
- # ──────────────────────────────────────────────────────────────────────────
321
- # Sprint 58 — Marqueurs typographiques imprimé ancien (heatmap)
322
- # ──────────────────────────────────────────────────────────────────────────
323
-
324
-
325
- def build_early_modern_section(
326
- engines_summary: list[dict],
327
- labels: Optional[dict[str, str]] = None,
328
- ) -> str:
329
- relevant = _engines_with_module(engines_summary, "early_modern")
330
- if not relevant:
331
- return ""
332
- labels = labels or {}
333
- title = labels.get(
334
- "philo_early_modern_title",
335
- "Marqueurs typographiques imprimé ancien (XVIᵉ-XVIIIᵉ)",
336
- )
337
- note = labels.get(
338
- "philo_early_modern_note",
339
- "Préservation des ligatures (fi fl ff), s long (ſ), i sans "
340
- "point (ı), esperluette (&) et tildes nasaux (ã õ ñ). "
341
- "Une ligne par moteur, une colonne par catégorie.",
342
- )
343
- engine_label = labels.get("philo_engine_label", "Moteur")
344
- global_label = labels.get("philo_global_label", "Global")
345
-
346
- all_cats: set[str] = set()
347
- for eng in relevant:
348
- all_cats.update(
349
- eng["aggregated_philological"]["early_modern"]
350
- .get("per_category", {}).keys(),
351
- )
352
- cats = sorted(all_cats)
353
- if not cats:
354
- return ""
355
-
356
- parts = [_section_open(title, note), _table_open()]
357
- parts.append(_table_header([global_label] + cats, engine_label))
358
- parts.append("<tbody>")
359
- for eng in relevant:
360
- agg = eng["aggregated_philological"]["early_modern"]
361
- n_total = agg.get("n_markers_reference", 0)
362
- parts.append("<tr>")
363
- parts.append(_engine_label_cell(eng["name"]))
364
- parts.append(_score_cell(
365
- agg.get("global_preservation", 0.0), extra=f"n={n_total}",
366
- ))
367
- per_cat = agg.get("per_category", {})
368
- for cat in cats:
369
- stats = per_cat.get(cat)
370
- if stats and stats.get("total", 0) > 0:
371
- parts.append(_score_cell(
372
- stats["preservation"], extra=f"n={stats['total']}",
373
- ))
374
- else:
375
- parts.append(_score_cell(None))
376
- parts.append("</tr>")
377
- parts.append("</tbody>")
378
- parts.append(_table_close())
379
- parts.append(_section_close())
380
- return "".join(parts)
381
-
382
-
383
- # ──────────────────────────────────────────────────────────────────────────
384
- # Sprint 59 — Archives modernes : strict + expansion par catégorie
385
- # ──────────────────────────────────────────────────────────────────────────
386
-
387
-
388
- def build_modern_archives_section(
389
- engines_summary: list[dict],
390
- labels: Optional[dict[str, str]] = None,
391
- ) -> str:
392
- relevant = _engines_with_module(engines_summary, "modern_archives")
393
- if not relevant:
394
- return ""
395
- labels = labels or {}
396
- title = labels.get(
397
- "philo_modern_archives_title",
398
- "Abréviations des archives modernes (XIXᵉ-XXᵉ)",
399
- )
400
- note = labels.get(
401
- "philo_modern_archives_note",
402
- "Strict = abrégé préservé (Mme, S.A.R., bd, vol., …) ; "
403
- "Expansion = abrégé OU forme développée. Affiché par "
404
- "catégorie : civilité, ordinaux, monnaie, administratif, "
405
- "état civil, ponctuation typo, latin, biblio, adresse.",
406
- )
407
- engine_label = labels.get("philo_engine_label", "Moteur")
408
- global_label = labels.get("philo_global_label", "Global")
409
- strict_label = labels.get("philo_strict_label", "Strict")
410
- expansion_label = labels.get("philo_expansion_label", "Expansion")
411
-
412
- all_cats: set[str] = set()
413
- for eng in relevant:
414
- all_cats.update(
415
- eng["aggregated_philological"]["modern_archives"]
416
- .get("per_category", {}).keys(),
417
- )
418
- cats = sorted(all_cats)
419
-
420
- parts = [_section_open(title, note)]
421
- parts.append(
422
- '<table style="border-collapse:collapse;width:100%;'
423
- 'font-size:.85rem">'
424
- )
425
- parts.append("<thead><tr>")
426
- parts.append(
427
- f'<th rowspan="2" style="padding:.3rem .5rem;text-align:left;'
428
- f'border-bottom:1px solid var(--border);font-weight:600">'
429
- f'{_e(engine_label)}</th>'
430
- )
431
- parts.append(
432
- f'<th colspan="2" style="padding:.3rem .5rem;text-align:center;'
433
- f'border-bottom:1px solid var(--border);font-weight:600">'
434
- f'{_e(global_label)}</th>'
435
- )
436
- for cat in cats:
437
- parts.append(
438
- f'<th colspan="2" style="padding:.3rem .5rem;text-align:center;'
439
- f'border-bottom:1px solid var(--border);font-weight:600">'
440
- f'{_e(cat)}</th>'
441
- )
442
- parts.append("</tr><tr>")
443
- for _ in range(1 + len(cats)):
444
- parts.append(
445
- f'<th style="padding:.2rem .4rem;text-align:center;'
446
- f'font-size:.75rem;font-weight:500;opacity:.7">'
447
- f'{_e(strict_label)}</th>'
448
- )
449
- parts.append(
450
- f'<th style="padding:.2rem .4rem;text-align:center;'
451
- f'font-size:.75rem;font-weight:500;opacity:.7">'
452
- f'{_e(expansion_label)}</th>'
453
- )
454
- parts.append("</tr></thead>")
455
- parts.append("<tbody>")
456
- for eng in relevant:
457
- agg = eng["aggregated_philological"]["modern_archives"]
458
- parts.append("<tr>")
459
- parts.append(_engine_label_cell(eng["name"]))
460
- parts.append(_score_cell(agg.get("global_strict_score", 0.0)))
461
- parts.append(_score_cell(agg.get("global_expansion_score", 0.0)))
462
- per_cat = agg.get("per_category", {})
463
- for cat in cats:
464
- stats = per_cat.get(cat)
465
- if stats and stats.get("n_total", 0) > 0:
466
- parts.append(_score_cell(
467
- stats["strict_score"],
468
- extra=f"n={stats['n_total']}",
469
- ))
470
- parts.append(_score_cell(stats["expansion_score"]))
471
- else:
472
- parts.append(_score_cell(None))
473
- parts.append(_score_cell(None))
474
- parts.append("</tr>")
475
- parts.append("</tbody>")
476
- parts.append(_table_close())
477
- parts.append(_section_close())
478
- return "".join(parts)
479
-
480
-
481
- # ──────────────────────────────────────────────────────────────────────────
482
- # Sprint 60 — Numéraux romains : breakdown 5 statuts
483
- # ──────────────────────────────────────────────────────────────────────────
484
-
485
-
486
- def build_roman_numerals_section(
487
- engines_summary: list[dict],
488
- labels: Optional[dict[str, str]] = None,
489
- ) -> str:
490
- relevant = _engines_with_module(engines_summary, "roman_numerals")
491
- if not relevant:
492
- return ""
493
- labels = labels or {}
494
- title = labels.get(
495
- "philo_roman_numerals_title",
496
- "Numéraux romains : restitution par statut",
497
- )
498
- note = labels.get(
499
- "philo_roman_numerals_note",
500
- "Pour chaque numéral romain de la GT, statut de restitution : "
501
- "strict (forme exacte), case_changed (casse modifiée), "
502
- "j_dropped (j médiéval normalisé), converted_to_arabic, lost. "
503
- "Le breakdown indique la convention : majoritaire strict → "
504
- "diplomatique ; majoritaire arabic → modernisation profonde.",
505
- )
506
- engine_label = labels.get("philo_engine_label", "Moteur")
507
- n_label = labels.get("philo_n_total_label", "n total")
508
-
509
- statuses = (
510
- "strict_preserved", "case_changed", "j_dropped",
511
- "converted_to_arabic", "lost",
512
- )
513
- status_labels = {
514
- s: labels.get(f"philo_roman_status_{s}", s) for s in statuses
515
- }
516
-
517
- parts = [_section_open(title, note), _table_open()]
518
- parts.append(_table_header(
519
- [n_label] + [status_labels[s] for s in statuses],
520
- engine_label,
521
- ))
522
- parts.append("<tbody>")
523
- for eng in relevant:
524
- agg = eng["aggregated_philological"]["roman_numerals"]
525
- n_total = agg.get("n_numerals_reference", 0)
526
- per_status = agg.get("per_status", {})
527
- parts.append("<tr>")
528
- parts.append(_engine_label_cell(eng["name"]))
529
- parts.append(
530
- f'<td style="padding:.3rem .5rem;text-align:center">'
531
- f'{n_total}</td>'
532
- )
533
- for status in statuses:
534
- count = per_status.get(status, 0)
535
- if n_total > 0:
536
- ratio = count / n_total
537
- # Pour « lost » on inverse la couleur (un haut taux
538
- # de perte est mauvais). Pour les autres on garde
539
- # la sémantique « plus c'est haut, plus l'OCR a
540
- # adopté ce statut ».
541
- color = (
542
- _color_for_score(1.0 - ratio) if status == "lost"
543
- else _color_for_score(ratio)
544
- )
545
- parts.append(
546
- f'<td style="padding:.3rem .5rem;text-align:center;'
547
- f'background:{color}">{count} '
548
- f'<span style="opacity:.6;font-size:.85em">'
549
- f'({ratio * 100:.0f}%)</span></td>'
550
- )
551
- else:
552
- parts.append(_score_cell(None))
553
- parts.append("</tr>")
554
- parts.append("</tbody>")
555
- parts.append(_table_close())
556
- parts.append(_section_close())
557
- return "".join(parts)
558
-
559
-
560
- # ──────────────────────────────────────────────────────────────────────────
561
- # Agrégateur principal
562
- # ──────────────────────────────────────────────────────────────────────────
563
-
564
-
565
- def build_philological_profile_html(
566
- engines_summary: list[dict],
567
- labels: Optional[dict[str, str]] = None,
568
- ) -> str:
569
- """Assemble les six sections en un bloc unique.
570
-
571
- Retourne ``""`` si aucune section n'a de contenu (c.-à-d.
572
- aucun moteur n'a de signal philologique sur le corpus).
573
- """
574
- sections = [
575
- build_unicode_blocks_section(engines_summary, labels),
576
- build_abbreviations_section(engines_summary, labels),
577
- build_mufi_section(engines_summary, labels),
578
- build_early_modern_section(engines_summary, labels),
579
- build_modern_archives_section(engines_summary, labels),
580
- build_roman_numerals_section(engines_summary, labels),
581
- ]
582
- non_empty = [s for s in sections if s]
583
- if not non_empty:
584
- return ""
585
- labels = labels or {}
586
- main_title = labels.get(
587
- "philo_profile_title", "Profil philologique",
588
- )
589
- main_note = labels.get(
590
- "philo_profile_note",
591
- "Données brutes par catégorie de marqueur philologique. "
592
- "L'outil ne classifie pas la convention adoptée par chaque "
593
- "moteur — c'est au chercheur de lire les chiffres et de "
594
- "conclure selon ses critères éditoriaux.",
595
- )
596
- parts = [
597
- '<div class="philological-profile">',
598
- f'<h3 style="margin-top:0">{_e(main_title)}</h3>',
599
- f'<p style="font-size:.85rem;opacity:.8;margin-bottom:.5rem">'
600
- f'{_e(main_note)}</p>',
601
- ]
602
- parts.extend(non_empty)
603
- parts.append("</div>")
604
- return "".join(parts)
605
-
606
 
607
- __all__ = [
608
- "build_philological_profile_html",
609
- "build_unicode_blocks_section",
610
- "build_abbreviations_section",
611
- "build_mufi_section",
612
- "build_early_modern_section",
613
- "build_modern_archives_section",
614
- "build_roman_numerals_section",
615
- ]
 
1
+ """Alias rétrocompat module déplacé dans :mod:`picarones.extras.render.philological_render`.
2
 
3
+ Phase B du chantier de refonte en 3 cercles (architecture-cercles.md).
4
+ Ce module philologique est désormais en Cercle 3 (``extras/``). L'alias
5
+ ici permet aux imports historiques (``from picarones.report.philological_render
6
+ import ...``) de continuer à fonctionner sans modification.
7
 
8
+ Voir :doc:`docs/architecture-cercles.md` et l'extra
9
+ ``picarones[historical]`` du ``pyproject.toml``.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  """
11
 
12
+ from picarones.extras.render.philological_render import * # noqa: F401, F403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
+ import picarones.extras.render.philological_render as _module
15
+ __all__ = getattr(_module, "__all__", [
16
+ name for name in dir(_module) if not name.startswith("_")
17
+ ])
 
 
 
 
 
pyproject.toml CHANGED
@@ -69,9 +69,19 @@ ocr-cloud = [
69
  "boto3>=1.34.0",
70
  "azure-ai-formrecognizer>=3.3.0",
71
  ]
 
 
 
 
 
 
 
 
 
 
72
  # Installation complète (tous les extras sauf les OCR cloud)
73
  all = [
74
- "picarones[web,hf,llm,dev]",
75
  ]
76
 
77
  [project.scripts]
 
69
  "boto3>=1.34.0",
70
  "azure-ai-formrecognizer>=3.3.0",
71
  ]
72
+ # Métriques philologiques pour documents historiques (Cercle 3, phase B
73
+ # du chantier de refonte post-Sprint 97). Aujourd'hui les modules
74
+ # philologiques (`picarones.extras.historical.*`) sont livrés dans le
75
+ # package principal sans dépendance externe — l'extra ``[historical]``
76
+ # n'ajoute donc aucun paquet à installer. Il est déclaré ici pour
77
+ # **documenter l'intention** : un usage purement moderne (sans cas
78
+ # d'usage patrimonial) peut ignorer le sous-package extras/historical/
79
+ # entièrement, et un futur split en package PyPI séparé
80
+ # ``picarones-historical`` réutilisera ce nom d'extra.
81
+ historical = []
82
  # Installation complète (tous les extras sauf les OCR cloud)
83
  all = [
84
+ "picarones[web,hf,llm,dev,historical]",
85
  ]
86
 
87
  [project.scripts]
tests/test_phaseB_migration.py ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests de la phase B — extras/historical/ (philologique vers Cercle 3).
2
+
3
+ Couvre :
4
+
5
+ - 8 modules philologiques (Cercle 3) déplacés vers `extras/historical/`.
6
+ - 2 renderers correspondants déplacés vers `extras/render/`.
7
+ - Identité préservée à travers les shims (test ``is``).
8
+ - Intégration : `philological_runner` orchestre toujours les 6 modules
9
+ même après déplacement.
10
+ - Dépendance Cercle 2 → Cercle 3 (`numerical_sequences` →
11
+ `roman_numerals`) continue de fonctionner via shim.
12
+ - pyproject.toml déclare `[historical]` comme extra documentaire.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+
19
+ import pytest
20
+
21
+
22
+ # ──────────────────────────────────────────────────────────────────────────
23
+ # 1. Modules historiques accessibles via shims (rétrocompat)
24
+ # ──────────────────────────────────────────────────────────────────────────
25
+
26
+
27
+ class TestPhilologicalRetrocompat:
28
+ @pytest.mark.parametrize("module_path, attribute", [
29
+ ("picarones.core.unicode_blocks", "compute_unicode_block_accuracy"),
30
+ ("picarones.core.abbreviations", "compute_abbreviation_metrics"),
31
+ ("picarones.core.mufi", "compute_mufi_coverage"),
32
+ ("picarones.core.early_modern_typography", "compute_early_modern_metrics"),
33
+ ("picarones.core.modern_archives", "compute_modern_archives_metrics"),
34
+ ("picarones.core.roman_numerals", "compute_roman_numeral_metrics"),
35
+ ("picarones.core.lexical_modernization", "compute_lexical_modernization"),
36
+ ("picarones.core.philological_runner", "compute_philological_metrics"),
37
+ ("picarones.core.philological_runner", "aggregate_philological_metrics"),
38
+ ])
39
+ def test_core_alias_still_works(self, module_path: str, attribute: str):
40
+ import importlib
41
+ mod = importlib.import_module(module_path)
42
+ assert hasattr(mod, attribute), (
43
+ f"{module_path}.{attribute} a disparu après la phase B"
44
+ )
45
+
46
+ @pytest.mark.parametrize("module_path, attribute", [
47
+ ("picarones.report.philological_render", "build_philological_profile_html"),
48
+ ("picarones.report.lexical_modernization_render",
49
+ "build_lexical_modernization_html"),
50
+ ])
51
+ def test_render_alias_still_works(self, module_path: str, attribute: str):
52
+ import importlib
53
+ mod = importlib.import_module(module_path)
54
+ assert hasattr(mod, attribute)
55
+
56
+
57
+ # ──────────────────────────────────────────────────────────────────────────
58
+ # 2. Modules accessibles via leur nouveau chemin extras/historical/
59
+ # ──────────────────────────────────────────────────────────────────────────
60
+
61
+
62
+ class TestNewHistoricalImports:
63
+ @pytest.mark.parametrize("new_path, attribute", [
64
+ ("picarones.extras.historical.unicode_blocks",
65
+ "compute_unicode_block_accuracy"),
66
+ ("picarones.extras.historical.abbreviations",
67
+ "compute_abbreviation_metrics"),
68
+ ("picarones.extras.historical.mufi", "compute_mufi_coverage"),
69
+ ("picarones.extras.historical.early_modern_typography",
70
+ "compute_early_modern_metrics"),
71
+ ("picarones.extras.historical.modern_archives",
72
+ "compute_modern_archives_metrics"),
73
+ ("picarones.extras.historical.roman_numerals",
74
+ "compute_roman_numeral_metrics"),
75
+ ("picarones.extras.historical.lexical_modernization",
76
+ "compute_lexical_modernization"),
77
+ ("picarones.extras.historical.philological_runner",
78
+ "compute_philological_metrics"),
79
+ ("picarones.extras.render.philological_render",
80
+ "build_philological_profile_html"),
81
+ ("picarones.extras.render.lexical_modernization_render",
82
+ "build_lexical_modernization_html"),
83
+ ])
84
+ def test_extras_path_works(self, new_path: str, attribute: str):
85
+ import importlib
86
+ mod = importlib.import_module(new_path)
87
+ assert hasattr(mod, attribute)
88
+
89
+
90
+ # ──────────────────────────────────────────────────────────────────────────
91
+ # 3. Identité préservée (shim et nouveau chemin = même fonction)
92
+ # ──────────────────────────────────────────────────────────────────────────
93
+
94
+
95
+ class TestIdentityThroughShim:
96
+ def test_unicode_blocks_identity(self):
97
+ from picarones.core.unicode_blocks import (
98
+ compute_unicode_block_accuracy as via_old,
99
+ )
100
+ from picarones.extras.historical.unicode_blocks import (
101
+ compute_unicode_block_accuracy as via_new,
102
+ )
103
+ assert via_old is via_new
104
+
105
+ def test_philological_runner_identity(self):
106
+ from picarones.core.philological_runner import (
107
+ compute_philological_metrics as via_old,
108
+ )
109
+ from picarones.extras.historical.philological_runner import (
110
+ compute_philological_metrics as via_new,
111
+ )
112
+ assert via_old is via_new
113
+
114
+ def test_renderer_identity(self):
115
+ from picarones.report.philological_render import (
116
+ build_philological_profile_html as via_old,
117
+ )
118
+ from picarones.extras.render.philological_render import (
119
+ build_philological_profile_html as via_new,
120
+ )
121
+ assert via_old is via_new
122
+
123
+
124
+ # ──────────────────────────────────────────────────────────────────────────
125
+ # 4. Intégration : philological_runner orchestre toujours les 6 modules
126
+ # ──────────────────────────────────────────────────────────────────────────
127
+
128
+
129
+ class TestPhilologicalRunnerIntegration:
130
+ """Le runner philologique appelle les 6 modules
131
+ philologiques. Vérifie que cette chaîne fonctionne après le
132
+ déplacement (les imports internes traversent les shims)."""
133
+
134
+ def test_runner_returns_dict_or_none(self):
135
+ from picarones.core.philological_runner import (
136
+ compute_philological_metrics,
137
+ )
138
+ # Texte sans signal philologique → None par adaptive masking
139
+ result = compute_philological_metrics(
140
+ "Bonjour le monde", "Bonjour le monde",
141
+ )
142
+ # None acceptable (texte ASCII pur sans aucun marqueur)
143
+ # OU dict vide (signal nul partout)
144
+ assert result is None or isinstance(result, dict)
145
+
146
+ def test_runner_with_medieval_text(self):
147
+ """Texte médiéval avec abréviations + numéraux romains : on
148
+ s'attend à au moins un module qui détecte du signal."""
149
+ from picarones.core.philological_runner import (
150
+ compute_philological_metrics,
151
+ )
152
+ # ⁊ = symbole d'abréviation Capelli ; XIV = numéral romain ; ſ = long s
153
+ ref = "⁊ par leſ XIV. fontoyers"
154
+ hyp = "et par les XIV. fontoyers"
155
+ result = compute_philological_metrics(ref, hyp)
156
+ # Au moins un module doit avoir détecté du signal
157
+ # (abbreviations OU early_modern OU roman_numerals)
158
+ assert result is not None
159
+ assert isinstance(result, dict)
160
+ assert len(result) >= 1
161
+
162
+
163
+ # ──────────────────────────────────────────────────────────────────────────
164
+ # 5. Dépendance Cercle 2 → Cercle 3 fonctionne via shim
165
+ # ──────────────────────────────────────────────────────────────────────────
166
+
167
+
168
+ class TestCercle2DependsOnCercle3ViaShim:
169
+ """``picarones.core.numerical_sequences`` (Cercle 2,
170
+ measurements/) importe ``roman_numerals`` (Cercle 3, extras/).
171
+ Cette dépendance traverse le shim — elle continue à fonctionner."""
172
+
173
+ def test_numerical_sequences_uses_roman_numerals(self):
174
+ from picarones.core.numerical_sequences import (
175
+ compute_numerical_sequence_metrics,
176
+ )
177
+ # Texte avec numéral romain
178
+ result = compute_numerical_sequence_metrics(
179
+ "Le roi Louis XIV régna jusqu'en 1715",
180
+ "Le roi Louis XIV régna jusqu'en 1715",
181
+ )
182
+ # Le score strict global doit refléter au moins la détection
183
+ # du romain et de la date
184
+ assert isinstance(result, dict)
185
+ assert result.get("global_strict_score") is not None
186
+ assert result.get("global_strict_score") >= 0.5
187
+
188
+
189
+ # ──────────────────────────────────────────────────────────────────────────
190
+ # 6. pyproject.toml déclare l'extra [historical]
191
+ # ──────────────────────────────────────────────────────────────────────────
192
+
193
+
194
+ class TestPyprojectExtra:
195
+ def test_historical_extra_declared(self):
196
+ path = Path(__file__).parent.parent / "pyproject.toml"
197
+ content = path.read_text(encoding="utf-8")
198
+ # L'extra [historical] doit être déclaré, même vide
199
+ assert "historical = []" in content or 'historical = [' in content
200
+ # Documentation de l'intention présente
201
+ assert "extras/historical" in content
202
+ assert "Cercle 3" in content
203
+
204
+
205
+ # ──────────────────────────────────────────────────────────────────────────
206
+ # 7. Hooks builtin enregistrés conditionnels (philological + lexical)
207
+ # ──────────────────────────────────────────────────────────────────────────
208
+
209
+
210
+ class TestBuiltinHooksStillRegisterPhilological:
211
+ """Les hooks ``philological`` et ``lexical_modernization``
212
+ s'enregistrent au chargement de :mod:`picarones.core.builtin_hooks`
213
+ via les imports qui traversent les shims (``from
214
+ picarones.core.philological_runner import ...``)."""
215
+
216
+ def test_philological_hook_registered(self):
217
+ # L'import déclenche l'enregistrement
218
+ import picarones.core.builtin_hooks # noqa: F401
219
+ from picarones.core.metric_hooks import _all_document_hook_names
220
+
221
+ assert "philological" in _all_document_hook_names()
222
+
223
+
224
+ # ──────────────────────────────────────────────────────────────────────────
225
+ # 8. Modules originaux sont des shims minces
226
+ # ──────────────────────────────────────────────────────────────────────────
227
+
228
+
229
+ class TestOriginalsAreShims:
230
+ @pytest.mark.parametrize("path", [
231
+ "picarones/core/unicode_blocks.py",
232
+ "picarones/core/abbreviations.py",
233
+ "picarones/core/mufi.py",
234
+ "picarones/core/early_modern_typography.py",
235
+ "picarones/core/modern_archives.py",
236
+ "picarones/core/roman_numerals.py",
237
+ "picarones/core/lexical_modernization.py",
238
+ "picarones/core/philological_runner.py",
239
+ "picarones/report/philological_render.py",
240
+ "picarones/report/lexical_modernization_render.py",
241
+ ])
242
+ def test_is_thin_shim(self, path):
243
+ repo_root = Path(__file__).parent.parent
244
+ content = (repo_root / path).read_text(encoding="utf-8")
245
+ n_lines = len([line for line in content.splitlines() if line.strip()])
246
+ assert n_lines < 30, (
247
+ f"{path} fait {n_lines} lignes — devrait être un shim mince"
248
+ )
249
+ assert "déplacé" in content or "extras" in content