File size: 6,333 Bytes
2e9e564
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""``EvaluationEngine`` — Sprint A14-S27.

Pendant de ``ProjectionEngine`` (cf. ``projection_engine.py``).
Le S13 fusionnait dans ``DefaultEvaluationViewExecutor`` projection
**et** évaluation ; la cible architecturale les sépare en deux
moteurs spécialisés à responsabilité unique.

``EvaluationEngine`` calcule un ensemble nommé de métriques sur
une paire ``(reference, hypothesis)`` de payloads.  Une métrique
qui lève en interne va dans ``failed_metrics`` au lieu de planter
l'évaluation complète — l'erreur est capturée et associée au nom
de la métrique.

Pourquoi cette séparation
-------------------------
- **Réutilisation** : le ``PipelineExecutor`` (S28+) peut appeler
  ``EvaluationEngine.evaluate`` pour des métriques de jonction
  intra-pipeline (ex : « score de stabilité entre deux étapes ») sans
  passer par un ``EvaluationView``.
- **Testabilité** : on teste la collecte d'erreurs (métrique cassée,
  métrique inconnue) sans instancier de vue ni de projecteur.
- **Découplage** : ``EvaluationEngine`` ne sait rien des artefacts,
  des projections, des vues — il prend des payloads bruts.

Anti-sur-ingénierie
-------------------
Pas de batch (évaluer N paires en une passe), pas de cache de
payload normalisé, pas de pré-tri des métriques.  Le moteur est
volontairement minimal — la complexité vit dans les métriques
elles-mêmes (cf. ``picarones/evaluation/metrics/``).
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any

from picarones.evaluation.registry import (
    MetricNotFoundError,
    MetricRegistry,
)


@dataclass(frozen=True)
class EvaluationResult:
    """Résultat d'un appel à ``EvaluationEngine.evaluate``.

    Attributes
    ----------
    metric_values:
        Métriques calculées avec succès, ``{name: value}``.
    failed_metrics:
        Métriques qui ont échoué, ``{name: error_message}``.  Les
        deux dicts sont disjoints : une métrique apparaît dans l'un
        ou l'autre, jamais les deux.

    Notes
    -----
    Frozen dataclass : container immuable ; les dicts internes le
    sont aussi grâce à ``field(default_factory=dict)`` qu'on ne
    mute pas après construction.  Le caller doit considérer les
    dicts comme lecture seule.
    """

    metric_values: dict[str, Any] = field(default_factory=dict)
    failed_metrics: dict[str, str] = field(default_factory=dict)

    @property
    def n_succeeded(self) -> int:
        return len(self.metric_values)

    @property
    def n_failed(self) -> int:
        return len(self.failed_metrics)

    @property
    def all_succeeded(self) -> bool:
        return self.n_failed == 0

    def with_global_failure(self, error: str) -> "EvaluationResult":
        """Retourne un nouveau ``EvaluationResult`` où **toutes** les
        métriques portent le même message d'erreur global.  Utile à
        un caller qui constate qu'un payload n'a pas pu être chargé
        et veut marquer l'évaluation entière en échec."""
        return EvaluationResult(
            metric_values={},
            failed_metrics={
                name: error
                for name in (
                    list(self.metric_values) + list(self.failed_metrics)
                )
            },
        )


class EvaluationEngine:
    """Moteur de calcul de métriques sur une paire de payloads.

    Responsabilité unique : prendre un ``MetricRegistry``, une liste
    de noms de métriques, et une paire ``(reference, hypothesis)``,
    retourner un ``EvaluationResult``.  Pas de connaissance des
    artefacts, des projections, des vues.

    Parameters
    ----------
    metric_registry:
        Registre des métriques, instancié explicitement au démarrage
        (pas de singleton global, pas de side-effect d'import).
    """

    def __init__(self, metric_registry: MetricRegistry) -> None:
        if not isinstance(metric_registry, MetricRegistry):
            raise TypeError(
                "metric_registry doit être un MetricRegistry."
            )
        self._metrics = metric_registry

    @property
    def metrics(self) -> MetricRegistry:
        """Accès en lecture au registre sous-jacent (utile aux tests)."""
        return self._metrics

    def evaluate(
        self,
        metric_names: tuple[str, ...] | list[str],
        reference: Any,
        hypothesis: Any,
    ) -> EvaluationResult:
        """Calcule chaque métrique nommée sur la paire (référence, hypothèse).

        Comportement :

        - Une métrique enregistrée et qui retourne une valeur → entrée
          dans ``metric_values``.
        - Une métrique enregistrée qui lève une exception → entrée
          dans ``failed_metrics`` avec le message ``f"{type}: {message}"``.
        - Un nom de métrique non enregistré → entrée dans
          ``failed_metrics`` avec un message explicite.

        L'ordre d'évaluation suit l'ordre de ``metric_names`` ; les
        deux dicts résultats préservent cet ordre (Python 3.7+
        garantit l'ordre d'insertion sur les ``dict``).
        """
        metric_values: dict[str, Any] = {}
        failed_metrics: dict[str, str] = {}

        for name in metric_names:
            try:
                value = self._metrics.compute(name, reference, hypothesis)
                metric_values[name] = value
            except MetricNotFoundError as exc:
                failed_metrics[name] = (
                    f"métrique non enregistrée dans le MetricRegistry : "
                    f"{exc}"
                )
            except Exception as exc:  # noqa: BLE001
                failed_metrics[name] = f"{type(exc).__name__}: {exc}"

        return EvaluationResult(
            metric_values=metric_values,
            failed_metrics=failed_metrics,
        )

    def evaluate_one(
        self,
        metric_name: str,
        reference: Any,
        hypothesis: Any,
    ) -> EvaluationResult:
        """Cas particulier : une seule métrique.  Sucre syntaxique sur
        ``evaluate``.  Utile aux callers qui pilotent une jonction
        unique (typiquement le pipeline executor sur une métrique de
        jonction)."""
        return self.evaluate((metric_name,), reference, hypothesis)


__all__ = ["EvaluationEngine", "EvaluationResult"]