Spaces:
Sleeping
Sleeping
Claude
docs(sprint-H.8): cleanup obsolete legacy/shim language in production docstrings
e407ec0 unverified | """Numéraux romains — Sprint 60. | |
| Sprint 60 — Étape 3 / extension philologique transversale du plan | |
| d'évolution 2026. | |
| Pourquoi ce module | |
| ------------------ | |
| Les numéraux romains traversent **toutes les périodes patrimoniales** | |
| servies par Picarones : | |
| - **Médiéval** : minuscules avec ``j`` final pour le dernier ``i`` | |
| (``ij`` = 2, ``iij`` = 3, ``viij`` = 8, ``mcclxxxij`` = 1282). | |
| Convention scribale standard dans les chartes et registres. | |
| - **Imprimé ancien** : majuscules (``Tome IV``, ``Chap. VII``). | |
| - **Moderne** : majuscules pour les souverains (``Louis XIV``) et | |
| les siècles (``XIXᵉ siècle`` — la partie exposant ᵉ est gérée | |
| par le Sprint 59 ``ordinals``, ce module ne traite que la partie | |
| numérale ``XIX``). | |
| Quatre traitements possibles d'un numéral par l'OCR | |
| ---------------------------------------------------- | |
| Pour chaque numéral romain présent dans la GT, l'OCR peut : | |
| 1. **Préserver strictement** : forme exacte gardée | |
| (``mcclxxxij`` → ``mcclxxxij``). Édition diplomatique idéale. | |
| 2. **Préserver en changeant la casse** : la valeur est intacte mais | |
| la convention typographique est modifiée | |
| (``xiv`` → ``XIV``). Modernisation typographique courante. | |
| 3. **Préserver en supprimant le ``j`` final** : | |
| (``mcclxxxij`` → ``mcclxxxii``). Modernisation orthographique | |
| médiévale → standard académique moderne. | |
| 4. **Convertir en chiffres arabes** : la valeur est préservée mais | |
| le système de numération est modernisé | |
| (``XIV`` → ``14``). Modernisation profonde, perte de | |
| l'information typographique. | |
| 5. **Perdre** : aucune trace de la valeur dans l'hypothèse. | |
| Ce module retourne un breakdown par statut pour que le chercheur | |
| juge lui-même la convention adoptée par chaque moteur, **sans | |
| classification automatique imposée**. | |
| Stratégie de découpage | |
| ---------------------- | |
| Cohérente avec NER (38), Flesch (52), Reading order F1 (53), | |
| Layout F1 (54), Bloc Unicode (55), Abréviations (56), MUFI (57), | |
| Imprimé ancien (58), Archives modernes (59) : couche de calcul | |
| pure d'abord ; câblage runner et HTML dans des sprints dédiés. | |
| Limites documentées | |
| ------------------- | |
| - Détection greedy par regex ``\\b[IVXLCDMivxlcdmj]+\\b`` puis | |
| validation par parsing. Les faux positifs restent possibles sur | |
| des mots courts (``I`` pronom anglais, ``MM`` initiales, ``LL``). | |
| Le paramètre ``min_length`` permet de filtrer les single-letter. | |
| - Pas de gestion des notations rares avec barre suscript pour | |
| multiplier par 1000 (V̄ = 5000, X̄ = 10000) — usage très rare en | |
| corpus patrimonial européen courant. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import re | |
| from typing import Optional | |
| from picarones.evaluation.metric_registry import register_metric | |
| from picarones.domain.artifacts import ArtifactType | |
| logger = logging.getLogger(__name__) | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # Table de conversion + parsing | |
| # ────────────────────────────────────────────────────────────────────────── | |
| ROMAN_VALUES: dict[str, int] = { | |
| "I": 1, "V": 5, "X": 10, | |
| "L": 50, "C": 100, "D": 500, "M": 1000, | |
| } | |
| # Caractères acceptés en entrée (incluant minuscules + j médiéval). | |
| _ROMAN_CHARS = "IVXLCDMivxlcdmj" | |
| _ROMAN_RE = re.compile(rf"\b[{_ROMAN_CHARS}]+\b") | |
| def _normalize_roman(s: str) -> str: | |
| """Normalise un numéral romain : majuscule + ``j`` final → ``i``. | |
| Les manuscrits médiévaux notent traditionnellement le dernier | |
| ``i`` d'une suite par ``j`` (« ij », « iij », « viij »…). On | |
| convertit pour pouvoir parser comme un numéral standard. | |
| """ | |
| if not s: | |
| return "" | |
| upper = s.upper() | |
| if upper.endswith("J"): | |
| upper = upper[:-1] + "I" | |
| return upper | |
| def _parse_normalized_roman(s: str) -> Optional[int]: | |
| """Parse un numéral romain **après normalisation** (majuscule, | |
| sans ``j`` médiéval). Retourne ``None`` si la chaîne n'est pas | |
| un numéral romain valide. | |
| Validation : on parse en additionnant/soustrayant selon la règle | |
| classique, puis on **regénère la forme standard** et on compare | |
| pour rejeter les formes non canoniques (« IIII » au lieu de | |
| « IV », « VV » au lieu de « X »). Cette stricte validation | |
| garantit qu'on ne compte pas des séquences absurdes comme | |
| « XXXX » comme un numéral. | |
| Note : les manuscrits médiévaux utilisent fréquemment « IIII » | |
| pour 4 (notation soustractive plus tardive). On accepte donc | |
| aussi cette forme via une règle relâchée : tant que les valeurs | |
| sont décroissantes ou suivent la règle soustractive standard, | |
| on accepte. | |
| """ | |
| if not s or not all(c in "IVXLCDM" for c in s): | |
| return None | |
| # Calcul par soustraction. | |
| total = 0 | |
| prev_value = 0 | |
| for ch in reversed(s): | |
| v = ROMAN_VALUES[ch] | |
| if v < prev_value: | |
| total -= v | |
| else: | |
| total += v | |
| prev_value = v | |
| if total <= 0: | |
| return None | |
| # Validation relâchée : on accepte les formes médiévales (IIII, | |
| # VIIII) mais on rejette les vraiment absurdes (IIIII, VVVV). | |
| if not _is_plausible_roman(s): | |
| return None | |
| return total | |
| def _is_plausible_roman(s: str) -> bool: | |
| """Validation relâchée d'un numéral romain (majuscule). | |
| On rejette : | |
| - 5 caractères identiques d'affilée ou plus (« IIIII », « XXXXX »). | |
| - Les répétitions de V, L, D (jamais répétés en notation | |
| classique : « VV », « LL », « DD »). | |
| - Les paires soustractives non standard. En romain canonique, | |
| seules sont valides : IV, IX, XL, XC, CD, CM. Toute autre | |
| combinaison « petit avant grand » est rejetée. Cela élimine | |
| les faux positifs sur des mots français comme « ici » (qui | |
| formerait sinon « I + C » = 99) ou « IL » qui formerait 49. | |
| """ | |
| if not s: | |
| return False | |
| # Pas de répétitions invalides | |
| for forbidden in ("VV", "LL", "DD", "IIIII", "XXXXX", "CCCCC", "MMMMMM"): | |
| if forbidden in s: | |
| return False | |
| # Paires soustractives autorisées (toutes les autres sont rejetées) | |
| legal_subtractive = {"IV", "IX", "XL", "XC", "CD", "CM"} | |
| for i in range(len(s) - 1): | |
| a, b = s[i], s[i + 1] | |
| if ROMAN_VALUES[a] < ROMAN_VALUES[b]: | |
| if (a + b) not in legal_subtractive: | |
| return False | |
| return True | |
| def roman_to_int(s: Optional[str]) -> Optional[int]: | |
| """Convertit une chaîne en numéral romain entier. Tolère casse | |
| et ``j`` médiéval final. Retourne ``None`` si invalide. | |
| """ | |
| if not s: | |
| return None | |
| return _parse_normalized_roman(_normalize_roman(s)) | |
| def int_to_roman(n: int) -> str: | |
| """Convertit un entier en numéral romain majuscule standard. | |
| Utilise la notation classique (IV, IX, XL, XC, CD, CM) — pas la | |
| forme médiévale relâchée. | |
| """ | |
| if n <= 0: | |
| raise ValueError("n must be positive") | |
| pairs = [ | |
| (1000, "M"), (900, "CM"), (500, "D"), (400, "CD"), | |
| (100, "C"), (90, "XC"), (50, "L"), (40, "XL"), | |
| (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), | |
| (1, "I"), | |
| ] | |
| out: list[str] = [] | |
| for value, symbol in pairs: | |
| while n >= value: | |
| out.append(symbol) | |
| n -= value | |
| return "".join(out) | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # Détection dans le texte | |
| # ────────────────────────────────────────────────────────────────────────── | |
| def detect_roman_numerals( | |
| text: Optional[str], | |
| *, | |
| min_length: int = 1, | |
| ) -> list[tuple[int, str, int]]: | |
| """Retourne les numéraux romains valides dans ``text``. | |
| Forme : ``[(start_index, numeral_string, integer_value), ...]`` | |
| triée par index croissant. | |
| Parameters | |
| ---------- | |
| text: | |
| Texte à analyser. | |
| min_length: | |
| Longueur minimale d'un numéral retenu. Par défaut ``1``. | |
| Mettre à ``2`` pour filtrer les single-letter ambigus (``I`` | |
| pronom, ``M`` initiale). | |
| Faux positifs connus | |
| -------------------- | |
| - ``I`` (pronom anglais), ``M`` ou ``D`` en initiale d'une | |
| personne ne peuvent pas être distingués sans NER. Le chercheur | |
| qui s'inquiète de ces faux positifs peut passer | |
| ``min_length=2``. | |
| """ | |
| if not text: | |
| return [] | |
| found: list[tuple[int, str, int]] = [] | |
| for match in _ROMAN_RE.finditer(text): | |
| s = match.group(0) | |
| if len(s) < min_length: | |
| continue | |
| value = roman_to_int(s) | |
| if value is None: | |
| continue | |
| found.append((match.start(), s, value)) | |
| return found | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # Classification de la restitution dans l'hypothèse | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # Statuts possibles, dans l'ordre de priorité (un numéral est | |
| # classé selon le premier statut qui s'applique). | |
| STATUS_STRICT_PRESERVED = "strict_preserved" | |
| STATUS_CASE_CHANGED = "case_changed" | |
| STATUS_J_DROPPED = "j_dropped" | |
| STATUS_CONVERTED_TO_ARABIC = "converted_to_arabic" | |
| STATUS_LOST = "lost" | |
| ALL_STATUSES = ( | |
| STATUS_STRICT_PRESERVED, | |
| STATUS_CASE_CHANGED, | |
| STATUS_J_DROPPED, | |
| STATUS_CONVERTED_TO_ARABIC, | |
| STATUS_LOST, | |
| ) | |
| # Statuts qui indiquent une préservation de la valeur (par opposition | |
| # à la perte). | |
| VALUE_PRESERVING_STATUSES = frozenset({ | |
| STATUS_STRICT_PRESERVED, | |
| STATUS_CASE_CHANGED, | |
| STATUS_J_DROPPED, | |
| STATUS_CONVERTED_TO_ARABIC, | |
| }) | |
| def _classify_restitution(numeral: str, value: int, hyp: str) -> str: | |
| """Classifie comment ``numeral`` (de valeur ``value``) est | |
| restitué dans ``hyp`` selon les 5 statuts définis.""" | |
| # 1. Forme stricte présente | |
| if re.search(r"(?<![A-Za-z])" + re.escape(numeral) + r"(?![A-Za-z])", hyp): | |
| return STATUS_STRICT_PRESERVED | |
| # 2. Variante de casse seule | |
| swapped = numeral.swapcase() | |
| if swapped != numeral and re.search( | |
| r"(?<![A-Za-z])" + re.escape(swapped) + r"(?![A-Za-z])", hyp, | |
| ): | |
| return STATUS_CASE_CHANGED | |
| # 3. ``j`` final remplacé par ``i`` (ou inverse) | |
| if numeral.lower().endswith("j"): | |
| no_j = numeral[:-1] + ("I" if numeral[-1] == "J" else "i") | |
| elif numeral.lower().endswith("i"): | |
| no_j = numeral[:-1] + ("J" if numeral[-1] == "I" else "j") | |
| else: | |
| no_j = numeral | |
| if no_j != numeral and re.search( | |
| r"(?<![A-Za-z])" + re.escape(no_j) + r"(?![A-Za-z])", hyp, | |
| ): | |
| return STATUS_J_DROPPED | |
| # Variante de casse + j-flip combinés | |
| no_j_swapped = no_j.swapcase() | |
| if no_j_swapped != numeral and re.search( | |
| r"(?<![A-Za-z])" + re.escape(no_j_swapped) + r"(?![A-Za-z])", hyp, | |
| ): | |
| return STATUS_J_DROPPED | |
| # 4. Conversion en chiffres arabes | |
| if re.search(r"(?<!\d)" + str(value) + r"(?!\d)", hyp): | |
| return STATUS_CONVERTED_TO_ARABIC | |
| # 5. Perdu | |
| return STATUS_LOST | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # Calcul de la métrique | |
| # ────────────────────────────────────────────────────────────────────────── | |
| def compute_roman_numeral_metrics( | |
| reference: Optional[str], | |
| hypothesis: Optional[str], | |
| *, | |
| min_length: int = 1, | |
| ) -> dict: | |
| """Calcule la préservation des numéraux romains. | |
| Pour chaque numéral romain dans la GT, on classifie sa | |
| restitution dans l'hypothèse selon l'un des 5 statuts (forme | |
| stricte / casse modifiée / j supprimé / conversion arabe / perdu). | |
| Returns | |
| ------- | |
| dict | |
| ``{ | |
| "n_numerals_reference": int, | |
| "n_strict_preserved": int, | |
| "n_value_preserved": int, # tous statuts sauf LOST | |
| "global_strict_score": float, | |
| "global_value_score": float, | |
| "per_status": {status: count for status in ALL_STATUSES}, | |
| "per_numeral": [ | |
| {"index", "numeral", "value", "status"} | |
| ], | |
| "lost_numerals": [ | |
| {"index", "numeral", "value"} | |
| ], | |
| }`` | |
| Cas dégénérés | |
| ------------- | |
| - GT vide ou sans numéral → tous compteurs à 0, scores à 0.0, | |
| ``per_status`` initialisé à 0 sur tous les statuts. | |
| - GT avec numéraux + hyp vide → tous classés ``lost``, | |
| strict_score = value_score = 0.0. | |
| """ | |
| ref = reference or "" | |
| hyp = hypothesis or "" | |
| detected = detect_roman_numerals(ref, min_length=min_length) | |
| n_total = len(detected) | |
| per_status_init = {status: 0 for status in ALL_STATUSES} | |
| if n_total == 0: | |
| return { | |
| "n_numerals_reference": 0, | |
| "n_strict_preserved": 0, | |
| "n_value_preserved": 0, | |
| "global_strict_score": 0.0, | |
| "global_value_score": 0.0, | |
| "per_status": per_status_init, | |
| "per_numeral": [], | |
| "lost_numerals": [], | |
| } | |
| per_status: dict[str, int] = dict(per_status_init) | |
| per_numeral: list[dict] = [] | |
| lost: list[dict] = [] | |
| for index, numeral, value in detected: | |
| status = _classify_restitution(numeral, value, hyp) | |
| per_status[status] = per_status.get(status, 0) + 1 | |
| per_numeral.append({ | |
| "index": index, | |
| "numeral": numeral, | |
| "value": value, | |
| "status": status, | |
| }) | |
| if status == STATUS_LOST: | |
| lost.append({"index": index, "numeral": numeral, "value": value}) | |
| n_strict = per_status[STATUS_STRICT_PRESERVED] | |
| n_value = sum(per_status[s] for s in VALUE_PRESERVING_STATUSES) | |
| return { | |
| "n_numerals_reference": n_total, | |
| "n_strict_preserved": n_strict, | |
| "n_value_preserved": n_value, | |
| "global_strict_score": n_strict / n_total, | |
| "global_value_score": n_value / n_total, | |
| "per_status": per_status, | |
| "per_numeral": per_numeral, | |
| "lost_numerals": lost, | |
| } | |
| def roman_numeral_strict_score( | |
| reference: Optional[str], hypothesis: Optional[str], | |
| ) -> float: | |
| """Raccourci : taux global de préservation **stricte** des | |
| numéraux romains ∈ [0, 1].""" | |
| return compute_roman_numeral_metrics( | |
| reference, hypothesis, | |
| )["global_strict_score"] | |
| def roman_numeral_value_score( | |
| reference: Optional[str], hypothesis: Optional[str], | |
| ) -> float: | |
| """Raccourci : taux global de préservation de la **valeur** des | |
| numéraux romains (toute forme confondue : strict, case_changed, | |
| j_dropped, arabe) ∈ [0, 1].""" | |
| return compute_roman_numeral_metrics( | |
| reference, hypothesis, | |
| )["global_value_score"] | |
| # ────────────────────────────────────────────────────────────────────────── | |
| # Enregistrement dans le registre typé (Sprint 34) | |
| # ────────────────────────────────────────────────────────────────────────── | |
| def _registered_strict(reference: str, hypothesis: str) -> float: | |
| return roman_numeral_strict_score(reference, hypothesis) | |
| def _registered_value(reference: str, hypothesis: str) -> float: | |
| return roman_numeral_value_score(reference, hypothesis) | |
| __all__ = [ | |
| "ROMAN_VALUES", | |
| "ALL_STATUSES", | |
| "STATUS_STRICT_PRESERVED", | |
| "STATUS_CASE_CHANGED", | |
| "STATUS_J_DROPPED", | |
| "STATUS_CONVERTED_TO_ARABIC", | |
| "STATUS_LOST", | |
| "VALUE_PRESERVING_STATUSES", | |
| "compute_roman_numeral_metrics", | |
| "detect_roman_numerals", | |
| "int_to_roman", | |
| "roman_numeral_strict_score", | |
| "roman_numeral_value_score", | |
| "roman_to_int", | |
| ] | |