Picarones / picarones /evaluation /projection_engine.py
Claude
feat(evaluation): Sprint A14-S27 — découpage ProjectionEngine + EvaluationEngine
2e9e564 unverified
Raw
History Blame
6.35 kB
"""``ProjectionEngine`` — Sprint A14-S27.
Le S13 fusionnait dans ``DefaultEvaluationViewExecutor`` deux
responsabilités distinctes : transformer un artefact d'un type vers
un autre (« projeter ») **et** calculer les métriques sur les
payloads (« évaluer »). La cible architecturale les sépare en
deux moteurs spécialisés à responsabilité unique :
- ``ProjectionEngine`` (ce module) : transforme un ``Artifact``
candidat selon une ``ProjectionSpec`` et retourne le nouvel
artefact, son ``payload`` calculé, et un ``ProjectionReport``
documentant les pertes.
- ``EvaluationEngine`` (cf. ``evaluation_engine.py``) : calcule les
métriques sur des payloads.
L'executor de vue (``DefaultEvaluationViewExecutor``) orchestre les
deux : projection d'abord, puis chargement, normalisation, et
évaluation. Il ne contient plus de logique de projection ni de
calcul de métrique — uniquement la séquence et la collecte d'erreurs.
Pourquoi cette séparation
-------------------------
- **Réutilisation** : le ``PipelineExecutor`` (S28+) appelle
``ProjectionEngine.project`` directement quand il transforme un
artefact entre deux étapes du DAG, sans dépendre de l'executor de
vue.
- **Testabilité** : on peut tester la projection sur des artefacts
arbitraires sans construire un ``EvaluationView`` ni un
``MetricRegistry``.
- **Lisibilité** : chaque moteur expose une API minimale et
vérifiable au type.
Anti-sur-ingénierie
-------------------
Pas de cache de payload entre projections, pas de batch, pas de
pré-validation des params (le projecteur lui-même validera ce qu'il
attend). Le moteur est volontairement minimal — la complexité vit
dans les projecteurs (cf. ``picarones/evaluation/projectors/``).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from picarones.domain.artifacts import Artifact
from picarones.domain.errors import ProjectionError
from picarones.domain.projection_spec import ProjectionSpec
from picarones.evaluation.projectors.base import ProjectionReport
from picarones.evaluation.projectors.registry import (
ProjectorNotFoundError,
ProjectorRegistry,
)
@dataclass(frozen=True)
class ProjectionResult:
"""Résultat d'un appel à ``ProjectionEngine.project``.
Attributes
----------
artifact:
Artefact effectif après projection. Si la spec était
``None`` ou identité, c'est l'artefact d'entrée tel quel.
payload:
Payload calculé par le projecteur, ou ``None`` si aucune
projection n'a été effectuée (le caller chargera depuis
son ``payload_loader``).
report:
Rapport de projection si une projection a eu lieu, ou
``None`` pour une vue sans projection (identité).
Notes
-----
Frozen dataclass : aucune mutation post-construction. La
sérialisation passe par ``ProjectionReport`` (pydantic) qui sait
déjà se sérialiser ; ``ProjectionResult`` reste un container
interne entre engine et executor.
"""
artifact: Artifact
payload: Any | None
report: ProjectionReport | None
@property
def has_projection(self) -> bool:
"""Vrai si une projection effective a eu lieu (report présent)."""
return self.report is not None
class ProjectionEngine:
"""Moteur de projection d'artefacts selon une ``ProjectionSpec``.
Responsabilité unique : prendre un ``Artifact`` et une éventuelle
``ProjectionSpec``, retourner un ``ProjectionResult``. Pas de
chargement de payload depuis un loader externe (le projecteur
fournit le payload calculé directement, depuis Sprint S25). Pas
de connaissance des métriques ni des vues.
Parameters
----------
projector_registry:
Registre des projecteurs disponibles, instancié explicitement
au démarrage de l'application. Pas de singleton global, pas
de side-effect d'import.
"""
def __init__(self, projector_registry: ProjectorRegistry) -> None:
if not isinstance(projector_registry, ProjectorRegistry):
raise TypeError(
"projector_registry doit être un ProjectorRegistry."
)
self._projectors = projector_registry
@property
def projectors(self) -> ProjectorRegistry:
"""Accès en lecture au registre sous-jacent (utile aux tests)."""
return self._projectors
def project(
self,
artifact: Artifact,
spec: ProjectionSpec | None,
) -> ProjectionResult:
"""Applique la projection si pertinente.
Comportement :
- ``spec is None`` ou ``spec.is_identity`` →
``ProjectionResult`` avec l'artefact d'entrée tel quel,
``payload=None``, ``report=None``. Le caller utilisera
son payload_loader pour charger l'artefact original.
- Sinon : résout le projecteur dans le registre, exécute
``project()``, et retourne le ``ProjectionResult`` complet
avec payload calculé.
Raises
------
ProjectionError
Si le projecteur référencé n'est pas enregistré, ou si
le projecteur lève une exception interne (wrappée dans
une ``ProjectionError`` qui préserve la chaîne ``__cause__``).
"""
if spec is None or spec.is_identity:
return ProjectionResult(
artifact=artifact, payload=None, report=None,
)
try:
projector = self._projectors.get(spec.projector_name)
except ProjectorNotFoundError as exc:
raise ProjectionError(
f"Projecteur {spec.projector_name!r} introuvable "
"dans le ProjectorRegistry."
) from exc
try:
target, payload, report = projector.project(
artifact, dict(spec.params),
)
except ProjectionError:
raise
except Exception as exc: # noqa: BLE001
raise ProjectionError(
f"Projecteur {spec.projector_name!r} a levé sur "
f"l'artefact {artifact.id!r} : {exc}"
) from exc
return ProjectionResult(
artifact=target, payload=payload, report=report,
)
__all__ = ["ProjectionEngine", "ProjectionResult"]