File size: 6,983 Bytes
d756039
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
979f3c3
d756039
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
979f3c3
d756039
 
979f3c3
d756039
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
"""É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",
]