Picarones / picarones /measurements /equivalence_profile.py
Claude
refactor(core): faire de core/ un cercle 1 strict, déplacer cercle 2 vers measurements/
979f3c3 unverified
Raw
History Blame
6.98 kB
"""Équivalences diplomatiques granulaires — Sprint 78 (A.I.5).
Sprint 78 — A.I.5 du plan d'évolution 2026.
Pourquoi ce module
------------------
Aujourd'hui les profils de ``picarones/core/normalization.py``
(``medieval_french``, ``early_modern_french``, etc.) appliquent un
**bloc entier** de transformations. Mais un éditeur peut vouloir
nuancer : *« je tolère ``ſ → s`` mais pas ``u → v`` »* — par
exemple parce qu'il édite un imprimé du XVIᵉ où u/v sont
distinctes mais où le s long doit être normalisé.
Ce module **éclate** chaque profil en règles d'équivalence
**nommées et indépendantes** que l'utilisateur peut activer ou
désactiver une par une. La couche de calcul retourne le CER
recalculé avec un sous-ensemble personnalisé.
Format
------
Chaque règle a :
- ``name`` : identifiant stable utilisé dans les URLs et l'UX
(ex. ``"longs_s"``, ``"u_eq_v"``)
- ``source`` : caractère ou séquence à remplacer
- ``target`` : caractère ou séquence cible
- ``description`` : phrase courte FR destinée à l'utilisateur
- ``profile_tag`` : nom du profil dont elle est issue (utile pour
grouper dans l'UX)
Stratégie de découpage
----------------------
Couche de calcul d'abord (pattern Sprint 71/75/76). L'UX panneau
avancé (cases à cocher + recalcul JS client + URL state) suivra
dans un sprint dédié — la couche calcul livrée ici est une
fondation suffisante pour qu'un développeur frontend câble la vue.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Iterable, Optional
from picarones.measurements.normalization import (
DIPLOMATIC_EN_EARLY_MODERN,
DIPLOMATIC_FR_EARLY_MODERN,
DIPLOMATIC_LATIN_MEDIEVAL,
DIPLOMATIC_MINIMAL,
)
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class EquivalenceRule:
"""Une équivalence diplomatique nommée et indépendante."""
name: str
source: str
target: str
description: str
profile_tag: str
# Catalogue : on dérive des profils existants en attribuant un nom
# stable à chaque transformation. Les doublons (ex. ``ſ → s``
# présent dans plusieurs profils) sont fusionnés sous un nom unique
# (le premier rencontré).
def _build_catalog() -> dict[str, EquivalenceRule]:
catalog: dict[str, EquivalenceRule] = {}
# Noms canoniques pour les transformations courantes
canonical_names: dict[tuple[str, str], tuple[str, str]] = {
("ſ", "s"): ("longs_s", "s long ſ → s"),
("u", "v"): ("u_eq_v", "u/v interchangeables (vpon → upon)"),
("i", "j"): ("i_eq_j", "i/j interchangeables (ioy → joy)"),
("y", "i"): ("y_eq_i", "y → i (Latin médiéval)"),
("vv", "w"): ("vv_eq_w", "vv → w (anglais moderne)"),
("æ", "ae"): ("ae_ligature", "æ → ae"),
("œ", "oe"): ("oe_ligature", "œ → oe"),
("þ", "th"): ("thorn_th", "þ (thorn) → th"),
("ð", "th"): ("eth_th", "ð (eth) → th"),
("ȝ", "y"): ("yogh_y", "ȝ (yogh) → y"),
("&", "et"): ("ampersand_et", "& → et (esperluette)"),
("ỹ", "yn"): ("y_tilde_yn", "ỹ → yn"),
("ꝑ", "per"): ("p_per", "ꝑ → per (abréviation Capelli)"),
("ꝓ", "pro"): ("p_pro", "ꝓ → pro (abréviation Capelli)"),
("ꝗ", "que"): ("q_que", "ꝗ → que (q barré)"),
}
sources = [
("medieval_french", DIPLOMATIC_LATIN_MEDIEVAL),
("early_modern_french", DIPLOMATIC_FR_EARLY_MODERN),
("early_modern_english", DIPLOMATIC_EN_EARLY_MODERN),
("minimal", DIPLOMATIC_MINIMAL),
]
for profile_tag, profile_dict in sources:
for source, target in profile_dict.items():
key = (source, target)
if key in canonical_names:
name, desc = canonical_names[key]
else:
# Fallback : générer un nom à partir des codepoints
name = f"{source}_to_{target}".replace(" ", "_")
desc = f"{source}{target}"
if name in catalog:
# On garde le profile_tag du premier rencontré, mais
# on note que la règle est partagée.
continue
catalog[name] = EquivalenceRule(
name=name,
source=source,
target=target,
description=desc,
profile_tag=profile_tag,
)
return catalog
BUILTIN_EQUIVALENCES: dict[str, EquivalenceRule] = _build_catalog()
def list_equivalences_by_profile(
profile_name: Optional[str] = None,
) -> list[EquivalenceRule]:
"""Liste les règles d'équivalence disponibles.
Si ``profile_name`` est fourni, ne retourne que les règles dont
``profile_tag == profile_name`` (ou les règles dérivées de
plusieurs profils dont au moins un est ``profile_name``).
"""
if profile_name is None:
return list(BUILTIN_EQUIVALENCES.values())
return [
rule for rule in BUILTIN_EQUIVALENCES.values()
if rule.profile_tag == profile_name
]
def apply_selected_equivalences(
text: Optional[str],
selected_names: Iterable[str],
) -> str:
"""Applique uniquement les règles dont le nom est dans
``selected_names``.
L'ordre d'application est l'ordre du catalogue interne — les
transformations sont appliquées séquentiellement sur le texte.
Les règles inconnues sont silencieusement ignorées (avec
warning).
"""
if not text:
return text or ""
selected_set = set(selected_names)
if not selected_set:
return text
out = text
for name, rule in BUILTIN_EQUIVALENCES.items():
if name not in selected_set:
continue
out = out.replace(rule.source, rule.target)
# Détection des règles inconnues (pour logger explicite)
unknown = selected_set - set(BUILTIN_EQUIVALENCES.keys())
if unknown:
logger.warning(
"[equivalence_profile] règles inconnues ignorées : %s",
sorted(unknown),
)
return out
def compute_cer_with_equivalences(
reference: Optional[str],
hypothesis: Optional[str],
selected_names: Iterable[str],
) -> float:
"""Calcule le CER après application des équivalences sélectionnées
sur les **deux** côtés (GT et hypothèse).
Utilise ``picarones.measurements.metrics.compute_metrics`` et extrait
le champ ``cer`` du résultat.
"""
from picarones.measurements.metrics import compute_metrics
selected_list = list(selected_names)
ref = apply_selected_equivalences(reference or "", selected_list)
hyp = apply_selected_equivalences(hypothesis or "", selected_list)
result = compute_metrics(ref, hyp)
return result.cer
__all__ = [
"EquivalenceRule",
"BUILTIN_EQUIVALENCES",
"list_equivalences_by_profile",
"apply_selected_equivalences",
"compute_cer_with_equivalences",
]