File size: 9,101 Bytes
e407ec0
0864c88
 
 
 
 
 
 
 
 
 
e407ec0
 
 
 
0864c88
 
e407ec0
 
 
 
 
0864c88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
"""Registre typé de métriques (couche 3 — evaluation).

Pattern et données
------------------
Registre **module-level** alimenté par effet de bord d'import via
le décorateur ``@register_metric``.  Chaque métrique enregistre une
``MetricSpec`` (nom + signature de types + callable) ; la sélection
typée à une jonction se fait via ``select_metrics(input_types)``.

Différence avec ``picarones.evaluation.registry.MetricRegistry``
----------------------------------------------------------------
Le présent module est le pattern **module-level** : un registre
unique global, alimenté par les imports des sous-packages
(``picarones.evaluation.metrics.__init__`` charge tous les modules
définissant des ``@register_metric``).

``picarones.evaluation.registry.MetricRegistry`` est une **classe
instanciable** — un service applicatif l'instancie explicitement
et y enregistre les métriques sans side-effect d'import.  Les
deux patterns coexistent : le module-level fonctionne pour les
~37 métriques existantes, l'instance-based est réservé aux
contributions tierces et au cadre des ``EvaluationView``.

Exemple d'usage
---------------
>>> from picarones.domain.artifacts import ArtifactType
>>> from picarones.evaluation.metric_registry import (
...     register_metric, select_metrics, compute_at_junction,
... )
>>>
>>> @register_metric(
...     name="my_word_count_ratio",
...     input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT),
...     description="Rapport du nombre de mots OCR / GT",
... )
... def word_count_ratio(reference: str, hypothesis: str) -> float:
...     ref = max(1, len(reference.split()))
...     return len(hypothesis.split()) / ref
>>>
>>> applicable = select_metrics(
...     (ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT),
... )
>>> any(spec.name == "my_word_count_ratio" for spec in applicable)
True
"""

from __future__ import annotations

import logging
from dataclasses import dataclass, field
from typing import Any, Callable

from picarones.domain.artifacts import ArtifactType

logger = logging.getLogger(__name__)


# ──────────────────────────────────────────────────────────────────────────
# Spécification d'une métrique typée
# ──────────────────────────────────────────────────────────────────────────


@dataclass(frozen=True)
class MetricSpec:
    """Description déclarative d'une métrique enregistrée.

    Attributs
    ---------
    name:
        Identifiant unique du registre (ex. ``"cer"``,
        ``"reading_order_f1"``).  Deux enregistrements avec le même
        ``name`` lèvent ``ValueError`` à l'enregistrement.
    func:
        Fonction de calcul ``f(reference, hypothesis) -> Any``.  Le type
        des deux arguments doit correspondre à ``input_types``.
    input_types:
        Couple ``(reference_type, hypothesis_type)`` indiquant ce que la
        métrique attend.  Le runner sélectionne par cette signature.
    description:
        Phrase courte affichée dans le rapport / le glossaire.
    higher_is_better:
        ``True`` si une valeur plus élevée signale une meilleure qualité
        (ex : F1, recall) ; ``False`` pour les métriques d'erreur (CER,
        WER).  Utilisé par le moteur narratif pour orienter ses
        comparaisons.
    tags:
        Étiquettes libres pour grouper les métriques (ex. ``{"text",
        "edit_distance"}`` ou ``{"structure", "icdar"}``).
    """

    name: str
    func: Callable[..., Any]
    input_types: tuple[ArtifactType, ArtifactType]
    description: str = ""
    higher_is_better: bool = False
    tags: frozenset[str] = field(default_factory=frozenset)


# ──────────────────────────────────────────────────────────────────────────
# Registre global
# ──────────────────────────────────────────────────────────────────────────


_METRIC_REGISTRY: dict[str, MetricSpec] = {}


def register_metric(
    *,
    name: str,
    input_types: tuple[ArtifactType, ArtifactType],
    description: str = "",
    higher_is_better: bool = False,
    tags: frozenset[str] | set[str] | None = None,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
    """Décorateur d'enregistrement d'une métrique typée.

    Parameters
    ----------
    name:
        Identifiant unique.
    input_types:
        Couple ``(reference_artifact_type, hypothesis_artifact_type)``.
    description:
        Aide courte (≤ une phrase).
    higher_is_better:
        ``True`` pour les métriques de qualité, ``False`` pour les
        métriques d'erreur.
    tags:
        Étiquettes pour grouper.

    Raises
    ------
    ValueError
        Si ``name`` est déjà enregistré ou si ``input_types`` n'a pas
        exactement deux éléments.
    """
    if len(input_types) != 2:
        raise ValueError(
            f"input_types doit être un couple (ref, hyp) — reçu {input_types!r}"
        )

    frozen_tags = frozenset(tags) if tags is not None else frozenset()

    def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
        if name in _METRIC_REGISTRY:
            existing = _METRIC_REGISTRY[name]
            if existing.func is func:
                # Ré-import du module : on tolère silencieusement.
                return func
            raise ValueError(
                f"Métrique '{name}' déjà enregistrée par "
                f"{existing.func.__module__}.{existing.func.__qualname__}"
            )
        spec = MetricSpec(
            name=name,
            func=func,
            input_types=input_types,
            description=description,
            higher_is_better=higher_is_better,
            tags=frozen_tags,
        )
        _METRIC_REGISTRY[name] = spec
        return func

    return decorator


def get_metric(name: str) -> MetricSpec:
    """Retourne la spec enregistrée pour ``name``.

    Raises
    ------
    KeyError
        Si la métrique n'est pas enregistrée.
    """
    if name not in _METRIC_REGISTRY:
        raise KeyError(f"Métrique '{name}' non enregistrée")
    return _METRIC_REGISTRY[name]


def all_metrics() -> list[MetricSpec]:
    """Liste toutes les métriques enregistrées (ordre d'enregistrement)."""
    return list(_METRIC_REGISTRY.values())


def select_metrics(
    input_types: tuple[ArtifactType, ArtifactType],
) -> list[MetricSpec]:
    """Retourne les métriques applicables à une jonction donnée.

    Parameters
    ----------
    input_types:
        Couple ``(reference_type, hypothesis_type)`` à la jonction.

    Returns
    -------
    list[MetricSpec]
        Liste (potentiellement vide) des métriques dont la signature
        correspond exactement.
    """
    return [spec for spec in _METRIC_REGISTRY.values() if spec.input_types == input_types]


def compute_at_junction(
    reference: Any,
    hypothesis: Any,
    input_types: tuple[ArtifactType, ArtifactType],
    *,
    skip_on_error: bool = True,
) -> dict[str, Any]:
    """Calcule toutes les métriques applicables à une jonction.

    Parameters
    ----------
    reference:
        Artefact de référence (typiquement la GT au niveau attendu).
    hypothesis:
        Artefact à évaluer (sortie d'un module).
    input_types:
        Signature de la jonction.  Détermine quelles métriques sont
        sélectionnées.
    skip_on_error:
        Si ``True`` (défaut), une exception levée par une métrique est
        loggée en warning et la métrique est absente du résultat.  Si
        ``False``, l'exception est propagée — utile pour les tests.

    Returns
    -------
    dict[str, Any]
        Dictionnaire ``{metric_name: value}`` pour chaque métrique
        applicable qui s'est calculée sans erreur.
    """
    selected = select_metrics(input_types)
    results: dict[str, Any] = {}
    for spec in selected:
        try:
            results[spec.name] = spec.func(reference, hypothesis)
        except Exception as exc:  # noqa: BLE001
            if skip_on_error:
                logger.warning(
                    "[metric_registry] '%s' a échoué : %s — métrique ignorée",
                    spec.name, exc,
                )
            else:
                raise
    return results


def _reset_registry_for_tests() -> None:
    """Vide le registre global.  **Réservé aux tests** — ne pas appeler
    en production sous peine de désactiver toutes les métriques."""
    _METRIC_REGISTRY.clear()


__all__ = [
    "MetricSpec",
    "register_metric",
    "get_metric",
    "all_metrics",
    "select_metrics",
    "compute_at_junction",
]