Claude commited on
Commit
63ceb34
·
unverified ·
1 Parent(s): 56c3bee

feat(evaluation): Sprint A14-S13 — DefaultEvaluationViewExecutor + ProjectorRegistry + ré-ordonnancement des couches

Browse files

Sprint S13 du plan rewrite ciblé. Démarre la Phase 3 (vues
d'évaluation, S13-S18). Pose le moteur d'orchestration des vues :
recevoir un candidat + une GT, projeter si nécessaire, normaliser,
calculer chaque métrique, retourner un ViewResult.

Modules livrés
--------------
``picarones/evaluation/projectors/registry.py``
``ProjectorRegistry`` instancié explicitement (symétrique au
``MetricRegistry`` du S5). API : register(projector), get(name),
__contains__, __len__, names(). Erreurs typées
``ProjectorRegistrationError``, ``ProjectorNotFoundError`` (héritent
de ``PicaronesError``). Pas de singleton global, pas de side-effect.

``picarones/evaluation/views/executor.py``
``DefaultEvaluationViewExecutor(metric_registry, projector_registry,
payload_loader)`` — implémentation concrète du protocole
``EvaluationViewExecutor`` (S5).

Flux d'``evaluate(view, candidate, ground_truth)`` :

1. Vérifie ``view.accepts(candidate.type)``. Refuse en
``ValueError`` si non.
2. Si ``view.projection`` non-identité : récupère le projecteur du
registre, applique, capture ``ProjectionReport``.
``ProjectorNotFoundError`` → ``ProjectionError`` typée.
Toute autre exception du projecteur → ``ProjectionError`` typée.
3. Charge les payloads candidat + GT via ``payload_loader``.
Si le loader plante : ``ViewResult`` avec ``failed_metrics``
pour toutes les métriques, ``warnings`` enrichi du message.
4. Si ``view.normalization_profile`` : applique le profil aux
deux payloads (str uniquement, les non-str passent inchangés).
5. Pour chaque métrique : compute via ``MetricRegistry``. Métrique
qui lève ou non enregistrée → ``failed_metrics`` (le ViewResult
reste construit, autres métriques traitées normalement).
6. Construit le ``ViewResult`` final en fusionnant :
- ``view.warnings`` + ``projection_report.warnings``,
- ``view.ignored_dimensions`` + ``projection_report.ignored_dimensions``
(déduplication préservant l'ordre).

``payload_loader`` injecté pour découpler l'executor du stockage
(filesystem, in-memory, distant). En tests : dict in-memory.
En prod (S19) : service applicatif qui sait gérer les workspaces.

Ré-ordonnancement architectural
-------------------------------
``LAYER_ORDER`` mis à jour : ``formats`` passe avant ``evaluation``.

Ancien : domain → evaluation → pipeline → formats → adapters → ...
Nouveau : domain → formats → evaluation → pipeline → adapters → ...

Justification : ``formats/`` (parsers, normalization) est un
utilitaire bas niveau qu'``evaluation/`` consomme (cf.
``DefaultEvaluationViewExecutor`` qui charge un profil de
normalisation depuis ``formats.text.normalization``). L'inverse
n'a aucun sens.

Conséquence : les projecteurs ``AltoToText`` et ``PageToText`` qui
vivaient dans ``formats/alto/projector.py`` et
``formats/pagexml/projector.py`` violaient la nouvelle hiérarchie
(ils importent ``evaluation.projectors.base.ProjectionReport``).
Déplacés dans :

``picarones.evaluation.projectors.alto``
``picarones.evaluation.projectors.pagexml``

Choix architectural assumé : la projection est conceptuellement un
**composant d'évaluation**, pas un format. Un parser ALTO appartient
à ``formats/`` (lit/écrit du XML). Une projection ALTO → texte
appartient à ``evaluation/`` (transforme un artefact pour le
comparer dans une vue).

Aucun re-export ascendant n'est ajouté dans ``formats/alto/__init__.py``
(violerait la couche). Les 2 tests S9 qui importaient ces noms
sont mis à jour pour utiliser le nouveau chemin :

from picarones.evaluation.projectors import (
AltoToText, alto_document_to_text,
PageToText, page_document_to_text,
)

Tests — 18 nouveaux tests
-------------------------
``tests/evaluation/test_sprint_a14_s13_view_executor.py`` :

10 cas d'évaluation paramétrant le flux principal :
1. RAW_TEXT direct, pas de projection (égalité parfaite).
2. RAW_TEXT direct, candidat différent.
3. ALTO_XML projeté en RAW_TEXT, ProjectionReport présent.
4. View rejette wrong artifact type → ValueError.
5. Projecteur introuvable → ProjectionError typée.
6. Projecteur qui lève → ProjectionError typée.
7. Métrique qui lève → failed_metrics, autres OK.
8. Métrique non enregistrée → failed_metrics.
9. View avec normalization_profile → normalisation appliquée.
10. Loader qui plante → toutes métriques en failed.

3 tests sur le constructeur (rejette types invalides).

5 tests sur ``ProjectorRegistry`` (register, idempotent, not found,
two registries indépendants, refus protocole non satisfait).

État de la suite
----------------
``pytest tests/ -q`` → 4188 passed, 8 skipped, 2 failed
(strictement environnementaux). +18 tests vs S12. Aucune
régression S13.

Critère go/no-go S13 atteint
----------------------------
``executor.evaluate(text_view, alto_artifact, gt_text_artifact)``
retourne un ``ViewResult`` avec :
- ``metric_values["cer"]`` calculé après projection ALTO→texte,
- ``projection_report`` avec ``ignored_dimensions`` et
``warnings`` propagés,
- ``ignored_dimensions`` du ViewResult fusionnant ceux de la vue
et ceux de la projection.

Prêt pour S14 (TextView — première vue canonique qui répond à
"quel pipeline produit le meilleur texte final ?").

https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP

picarones/evaluation/projectors/__init__.py CHANGED
@@ -21,6 +21,32 @@ pas un projecteur.
21
 
22
  from __future__ import annotations
23
 
 
 
 
 
24
  from picarones.evaluation.projectors.base import ProjectionReport, Projector
 
 
 
 
 
 
 
 
 
25
 
26
- __all__ = ["Projector", "ProjectionReport"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
  from __future__ import annotations
23
 
24
+ from picarones.evaluation.projectors.alto import (
25
+ AltoToText,
26
+ alto_document_to_text,
27
+ )
28
  from picarones.evaluation.projectors.base import ProjectionReport, Projector
29
+ from picarones.evaluation.projectors.pagexml import (
30
+ PageToText,
31
+ page_document_to_text,
32
+ )
33
+ from picarones.evaluation.projectors.registry import (
34
+ ProjectorNotFoundError,
35
+ ProjectorRegistrationError,
36
+ ProjectorRegistry,
37
+ )
38
 
39
+ __all__ = [
40
+ # Protocol + report
41
+ "Projector",
42
+ "ProjectionReport",
43
+ # Registry
44
+ "ProjectorRegistry",
45
+ "ProjectorRegistrationError",
46
+ "ProjectorNotFoundError",
47
+ # Concrete projectors (déplacés depuis formats/ au S13)
48
+ "AltoToText",
49
+ "alto_document_to_text",
50
+ "PageToText",
51
+ "page_document_to_text",
52
+ ]
picarones/{formats/alto/projector.py → evaluation/projectors/alto.py} RENAMED
File without changes
picarones/{formats/pagexml/projector.py → evaluation/projectors/pagexml.py} RENAMED
File without changes
picarones/evaluation/projectors/registry.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``ProjectorRegistry`` — Sprint A14-S13.
2
+
3
+ Container instancié explicitement qui mappe ``projector_name``
4
+ vers une instance ``Projector``. Symétrique du ``MetricRegistry``
5
+ (S5) : pas de singleton global, pas de side-effect d'import.
6
+
7
+ Pattern d'utilisation
8
+ ---------------------
9
+
10
+ .. code-block:: python
11
+
12
+ from picarones.evaluation.projectors import (
13
+ ProjectorRegistry, AltoToText,
14
+ )
15
+ from picarones.formats.alto import AltoToText as _AltoToText
16
+
17
+ registry = ProjectorRegistry()
18
+ registry.register(_AltoToText())
19
+ registry.register(PageToText())
20
+
21
+ projector = registry.get("alto_to_text")
22
+ target_artifact, report = projector.project(source_artifact, {})
23
+
24
+ Au S20, ce registre sera construit par
25
+ ``app/services/registry_service.py`` au démarrage de l'application.
26
+ Pour S13-S18, chaque test ou consommateur l'instancie explicitement.
27
+
28
+ Anti-sur-ingénierie
29
+ -------------------
30
+ Pas de versioning de projecteur, pas de namespace, pas de recherche
31
+ par tag. Ces extras viendront quand un caller en aura concrètement
32
+ besoin (probablement avec les projecteurs contribués par des modules
33
+ tiers, post-livraison).
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from picarones.domain.errors import PicaronesError
39
+ from picarones.evaluation.projectors.base import Projector
40
+
41
+
42
+ class ProjectorRegistrationError(PicaronesError):
43
+ """Tentative d'enregistrement invalide d'un projecteur."""
44
+
45
+
46
+ class ProjectorNotFoundError(PicaronesError):
47
+ """Le projecteur demandé n'est pas enregistré."""
48
+
49
+
50
+ class ProjectorRegistry:
51
+ """Container mutable de projecteurs indexés par ``name``.
52
+
53
+ Thread-safe en lecture après initialisation ; la séquence
54
+ d'enregistrement attendue est : un seul service, au démarrage,
55
+ enregistre tous les projecteurs en une fois, puis l'instance
56
+ est figée par convention.
57
+ """
58
+
59
+ def __init__(self) -> None:
60
+ self._projectors: dict[str, Projector] = {}
61
+
62
+ # ──────────────────────────────────────────────────────────────────
63
+ # Enregistrement
64
+ # ──────────────────────────────────────────────────────────────────
65
+
66
+ def register(self, projector: Projector) -> None:
67
+ """Enregistre un projecteur.
68
+
69
+ Raises
70
+ ------
71
+ ProjectorRegistrationError
72
+ Si un projecteur du même nom est déjà enregistré (sauf
73
+ re-enregistrement strict du même objet, toléré pour les
74
+ tests qui re-instancient).
75
+ """
76
+ if not hasattr(projector, "name"):
77
+ raise ProjectorRegistrationError(
78
+ "register : l'objet n'expose pas d'attribut ``name``."
79
+ )
80
+ if not isinstance(projector, Projector):
81
+ raise ProjectorRegistrationError(
82
+ f"register : {projector!r} ne satisfait pas le protocole "
83
+ "Projector (attributs ``name``, ``source_type``, "
84
+ "``target_type``, méthode ``project``)."
85
+ )
86
+ existing = self._projectors.get(projector.name)
87
+ if existing is not None:
88
+ if existing is projector:
89
+ return # idempotent
90
+ raise ProjectorRegistrationError(
91
+ f"Projecteur {projector.name!r} déjà enregistré avec "
92
+ "une autre instance."
93
+ )
94
+ self._projectors[projector.name] = projector
95
+
96
+ # ──────────────────────────────────────────────────────────────────
97
+ # Lecture
98
+ # ──────────────────────────────────────────────────────────────────
99
+
100
+ def __contains__(self, name: str) -> bool:
101
+ return name in self._projectors
102
+
103
+ def __len__(self) -> int:
104
+ return len(self._projectors)
105
+
106
+ def names(self) -> list[str]:
107
+ """Liste des noms enregistrés (ordre d'enregistrement)."""
108
+ return list(self._projectors.keys())
109
+
110
+ def get(self, name: str) -> Projector:
111
+ """Récupère le projecteur par son ``name``.
112
+
113
+ Raises
114
+ ------
115
+ ProjectorNotFoundError
116
+ Si le nom n'est pas enregistré.
117
+ """
118
+ if name not in self._projectors:
119
+ raise ProjectorNotFoundError(
120
+ f"Projecteur {name!r} non enregistré. "
121
+ f"Disponibles : {sorted(self._projectors)}."
122
+ )
123
+ return self._projectors[name]
124
+
125
+
126
+ __all__ = [
127
+ "ProjectorRegistry",
128
+ "ProjectorRegistrationError",
129
+ "ProjectorNotFoundError",
130
+ ]
picarones/evaluation/views/__init__.py CHANGED
@@ -21,5 +21,14 @@ Reporté post-livraison : ``LayoutView``, ``HallucinationView``,
21
  from __future__ import annotations
22
 
23
  from picarones.evaluation.views.base import EvaluationViewExecutor, ViewResult
 
 
 
 
24
 
25
- __all__ = ["EvaluationViewExecutor", "ViewResult"]
 
 
 
 
 
 
21
  from __future__ import annotations
22
 
23
  from picarones.evaluation.views.base import EvaluationViewExecutor, ViewResult
24
+ from picarones.evaluation.views.executor import (
25
+ DefaultEvaluationViewExecutor,
26
+ PayloadLoader,
27
+ )
28
 
29
+ __all__ = [
30
+ "EvaluationViewExecutor",
31
+ "ViewResult",
32
+ "DefaultEvaluationViewExecutor",
33
+ "PayloadLoader",
34
+ ]
picarones/evaluation/views/executor.py ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``DefaultEvaluationViewExecutor`` — Sprint A14-S13.
2
+
3
+ Implémentation concrète du protocole ``EvaluationViewExecutor`` (S5).
4
+ Orchestration d'une vue d'évaluation sur une paire (candidat, GT) :
5
+
6
+ 1. Vérifie que ``candidate.type`` est dans ``view.candidate_types``.
7
+ 2. Si ``view.projection`` est défini, récupère le projecteur depuis
8
+ ``ProjectorRegistry`` et applique la projection. Capture le
9
+ ``ProjectionReport``.
10
+ 3. Charge les payloads (texte, ALTO parsé, etc.) via le
11
+ ``payload_loader`` injecté au constructeur.
12
+ 4. Applique optionnellement un profil de normalisation texte
13
+ (``view.normalization_profile``) sur les payloads texte.
14
+ 5. Calcule chaque métrique listée dans ``view.metric_names`` via
15
+ ``MetricRegistry``. Une métrique qui lève est enregistrée dans
16
+ ``failed_metrics`` au lieu de planter le ViewResult complet.
17
+ 6. Retourne un ``ViewResult`` agrégeant tout (metric_values,
18
+ failed_metrics, projection_report, warnings,
19
+ ignored_dimensions).
20
+
21
+ Le ``payload_loader`` est injecté pour découpler l'executor de la
22
+ manière dont les artefacts sont stockés (filesystem, in-memory,
23
+ remote). Le service applicatif (S19) injectera un loader qui sait
24
+ gérer les workspaces sandboxés.
25
+
26
+ Anti-sur-ingénierie
27
+ -------------------
28
+ Pas de cache de payload chargé entre métriques (chaque métrique
29
+ relit l'artefact via le loader). Si un caller veut éviter le coût
30
+ de re-lecture, il instancie un loader qui memo-ize lui-même.
31
+
32
+ Pas de gestion de batch (évaluer N paires en une seule passe). À
33
+ ajouter quand un caller en a concrètement besoin.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import logging
39
+ from typing import Any, Callable
40
+
41
+ from picarones.domain.artifacts import Artifact
42
+ from picarones.domain.errors import ProjectionError
43
+ from picarones.domain.evaluation_spec import EvaluationView
44
+ from picarones.evaluation.projectors.registry import (
45
+ ProjectorNotFoundError,
46
+ ProjectorRegistry,
47
+ )
48
+ from picarones.evaluation.registry import MetricRegistry, MetricNotFoundError
49
+ from picarones.evaluation.views.base import ViewResult
50
+
51
+ logger = logging.getLogger(__name__)
52
+
53
+
54
+ #: Type alias : un payload loader prend un Artifact et retourne le
55
+ #: contenu chargé (str pour RAW_TEXT, dict pour ENTITIES, etc.).
56
+ PayloadLoader = Callable[[Artifact], Any]
57
+
58
+
59
+ class DefaultEvaluationViewExecutor:
60
+ """Implémentation par défaut de ``EvaluationViewExecutor``.
61
+
62
+ Parameters
63
+ ----------
64
+ metric_registry:
65
+ ``MetricRegistry`` contenant les métriques référencées par
66
+ ``view.metric_names``.
67
+ projector_registry:
68
+ ``ProjectorRegistry`` contenant les projecteurs référencés
69
+ par ``view.projection.projector_name``.
70
+ payload_loader:
71
+ Callable ``(Artifact) -> Any`` qui charge le contenu d'un
72
+ artefact. Pour les tests, typiquement un dict in-memory.
73
+ En production (S19), un service applicatif qui sait gérer
74
+ les workspaces.
75
+ """
76
+
77
+ def __init__(
78
+ self,
79
+ metric_registry: MetricRegistry,
80
+ projector_registry: ProjectorRegistry,
81
+ payload_loader: PayloadLoader,
82
+ ) -> None:
83
+ if not isinstance(metric_registry, MetricRegistry):
84
+ raise TypeError(
85
+ "metric_registry doit être un MetricRegistry."
86
+ )
87
+ if not isinstance(projector_registry, ProjectorRegistry):
88
+ raise TypeError(
89
+ "projector_registry doit être un ProjectorRegistry."
90
+ )
91
+ if not callable(payload_loader):
92
+ raise TypeError("payload_loader doit être callable.")
93
+ self._metrics = metric_registry
94
+ self._projectors = projector_registry
95
+ self._loader = payload_loader
96
+
97
+ # ──────────────────────────────────────────────────────────────────
98
+ # API publique
99
+ # ──────────────────────────────────────────────────────────────────
100
+
101
+ def evaluate(
102
+ self,
103
+ view: EvaluationView,
104
+ candidate: Artifact,
105
+ ground_truth: Artifact,
106
+ ) -> ViewResult:
107
+ """Évalue la vue sur la paire (candidat, GT).
108
+
109
+ Returns
110
+ -------
111
+ ViewResult
112
+ Toujours retourné, jamais d'exception en sortie normale —
113
+ les erreurs vont dans ``failed_metrics`` ou
114
+ (pour les erreurs de projection) lèvent ``ProjectionError``
115
+ qui est cohérente avec le contrat du S5.
116
+
117
+ Raises
118
+ ------
119
+ ProjectionError
120
+ Si la vue exige une projection que le projecteur ne peut
121
+ pas réaliser (ex : type d'entrée incompatible avec le
122
+ projecteur trouvé).
123
+ ValueError
124
+ Si ``candidate.type`` n'est pas dans
125
+ ``view.candidate_types``. Le caller (typiquement le
126
+ service applicatif) doit filtrer les pipelines qui ne
127
+ produisent pas le bon type avant d'appeler ``evaluate``.
128
+ """
129
+ # 1. Vérification du type d'entrée.
130
+ if not view.accepts(candidate.type):
131
+ raise ValueError(
132
+ f"View {view.name!r} n'accepte pas l'artefact "
133
+ f"{candidate.id!r} (type {candidate.type.value!r}). "
134
+ f"Types acceptés : "
135
+ f"{sorted(t.value for t in view.candidate_types)}."
136
+ )
137
+
138
+ # 2. Projection (optionnelle).
139
+ effective_candidate = candidate
140
+ projection_report = None
141
+ if view.projection is not None and not view.projection.is_identity:
142
+ try:
143
+ projector = self._projectors.get(
144
+ view.projection.projector_name,
145
+ )
146
+ except ProjectorNotFoundError as exc:
147
+ raise ProjectionError(
148
+ f"View {view.name!r} référence le projecteur "
149
+ f"{view.projection.projector_name!r} introuvable "
150
+ "dans le ProjectorRegistry."
151
+ ) from exc
152
+ try:
153
+ effective_candidate, projection_report = projector.project(
154
+ candidate, dict(view.projection.params),
155
+ )
156
+ except ProjectionError:
157
+ raise
158
+ except Exception as exc: # noqa: BLE001
159
+ raise ProjectionError(
160
+ f"Projecteur {view.projection.projector_name!r} a "
161
+ f"levé sur l'artefact {candidate.id!r} : {exc}"
162
+ ) from exc
163
+
164
+ # 3. Chargement des payloads.
165
+ # Échec de chargement = ViewResult avec une erreur globale
166
+ # (pas de failed_metric par métrique — l'erreur est en amont).
167
+ try:
168
+ cand_payload = self._loader(effective_candidate)
169
+ except Exception as exc: # noqa: BLE001
170
+ return self._failed_view_result(
171
+ view=view,
172
+ candidate=candidate,
173
+ ground_truth=ground_truth,
174
+ projection_report=projection_report,
175
+ global_error=(
176
+ f"payload_loader a échoué sur le candidat "
177
+ f"{effective_candidate.id!r} : {exc}"
178
+ ),
179
+ )
180
+ try:
181
+ gt_payload = self._loader(ground_truth)
182
+ except Exception as exc: # noqa: BLE001
183
+ return self._failed_view_result(
184
+ view=view,
185
+ candidate=candidate,
186
+ ground_truth=ground_truth,
187
+ projection_report=projection_report,
188
+ global_error=(
189
+ f"payload_loader a échoué sur la GT "
190
+ f"{ground_truth.id!r} : {exc}"
191
+ ),
192
+ )
193
+
194
+ # 4. Normalisation texte (optionnelle).
195
+ if view.normalization_profile is not None:
196
+ cand_payload, gt_payload = self._apply_normalization(
197
+ view.normalization_profile, cand_payload, gt_payload,
198
+ )
199
+
200
+ # 5. Calcul des métriques. Une métrique qui lève va dans
201
+ # failed_metrics. Une métrique non enregistrée va dans
202
+ # failed_metrics avec un message explicite.
203
+ metric_values: dict[str, Any] = {}
204
+ failed_metrics: dict[str, str] = {}
205
+ for name in view.metric_names:
206
+ try:
207
+ value = self._metrics.compute(name, gt_payload, cand_payload)
208
+ metric_values[name] = value
209
+ except MetricNotFoundError as exc:
210
+ failed_metrics[name] = (
211
+ f"métrique non enregistrée dans le MetricRegistry : "
212
+ f"{exc}"
213
+ )
214
+ except Exception as exc: # noqa: BLE001
215
+ failed_metrics[name] = (
216
+ f"{type(exc).__name__}: {exc}"
217
+ )
218
+
219
+ # 6. Construction du ViewResult.
220
+ warnings = tuple(view.warnings)
221
+ ignored = tuple(view.ignored_dimensions)
222
+ if projection_report is not None:
223
+ warnings = warnings + tuple(projection_report.warnings)
224
+ # Déduplique les ignored_dimensions tout en préservant l'ordre.
225
+ seen: set[str] = set(ignored)
226
+ extra = tuple(
227
+ d for d in projection_report.ignored_dimensions
228
+ if d not in seen
229
+ )
230
+ ignored = ignored + extra
231
+
232
+ return ViewResult(
233
+ view_name=view.name,
234
+ candidate_artifact_id=candidate.id,
235
+ ground_truth_artifact_id=ground_truth.id,
236
+ metric_values=metric_values,
237
+ failed_metrics=failed_metrics,
238
+ projection_report=projection_report,
239
+ warnings=warnings,
240
+ ignored_dimensions=ignored,
241
+ )
242
+
243
+ # ──────────────────────────────────────────────────────────────────
244
+ # Helpers internes
245
+ # ──────────────────────────────────────────────────────────────────
246
+
247
+ @staticmethod
248
+ def _apply_normalization(
249
+ profile_name: str,
250
+ cand_payload: Any,
251
+ gt_payload: Any,
252
+ ) -> tuple[Any, Any]:
253
+ """Applique un profil de normalisation aux deux payloads.
254
+
255
+ Si l'un des deux n'est pas une string, on saute la
256
+ normalisation pour ce payload (cas typique : ALTO non encore
257
+ projeté en texte → on laisse passer).
258
+ """
259
+ from picarones.formats.text.normalization import get_builtin_profile
260
+ try:
261
+ profile = get_builtin_profile(profile_name)
262
+ except Exception as exc: # noqa: BLE001
263
+ logger.warning(
264
+ "[view_executor] profil normalisation %r introuvable : %s",
265
+ profile_name, exc,
266
+ )
267
+ return cand_payload, gt_payload
268
+ normalized_cand = (
269
+ profile.normalize(cand_payload)
270
+ if isinstance(cand_payload, str)
271
+ else cand_payload
272
+ )
273
+ normalized_gt = (
274
+ profile.normalize(gt_payload)
275
+ if isinstance(gt_payload, str)
276
+ else gt_payload
277
+ )
278
+ return normalized_cand, normalized_gt
279
+
280
+ @staticmethod
281
+ def _failed_view_result(
282
+ *,
283
+ view: EvaluationView,
284
+ candidate: Artifact,
285
+ ground_truth: Artifact,
286
+ projection_report: Any,
287
+ global_error: str,
288
+ ) -> ViewResult:
289
+ """Construit un ``ViewResult`` quand le payload n'a pas pu
290
+ être chargé. Toutes les métriques sont marquées en échec
291
+ avec le même message d'erreur global."""
292
+ failed = {name: global_error for name in view.metric_names}
293
+ return ViewResult(
294
+ view_name=view.name,
295
+ candidate_artifact_id=candidate.id,
296
+ ground_truth_artifact_id=ground_truth.id,
297
+ metric_values={},
298
+ failed_metrics=failed,
299
+ projection_report=projection_report,
300
+ warnings=tuple(view.warnings) + (global_error,),
301
+ ignored_dimensions=tuple(view.ignored_dimensions),
302
+ )
303
+
304
+
305
+ __all__ = [
306
+ "DefaultEvaluationViewExecutor",
307
+ "PayloadLoader",
308
+ ]
picarones/formats/alto/__init__.py CHANGED
@@ -25,7 +25,6 @@ Anti-sur-ingénierie
25
  from __future__ import annotations
26
 
27
  from picarones.formats.alto.parser import AltoParseError, parse_alto
28
- from picarones.formats.alto.projector import AltoToText, alto_document_to_text
29
  from picarones.formats.alto.types import (
30
  AltoBBox,
31
  AltoDocument,
@@ -36,6 +35,15 @@ from picarones.formats.alto.types import (
36
  )
37
  from picarones.formats.alto.writer import write_alto
38
 
 
 
 
 
 
 
 
 
 
39
  __all__ = [
40
  # Types
41
  "AltoBBox",
@@ -48,7 +56,4 @@ __all__ = [
48
  "parse_alto",
49
  "AltoParseError",
50
  "write_alto",
51
- # Projector
52
- "alto_document_to_text",
53
- "AltoToText",
54
  ]
 
25
  from __future__ import annotations
26
 
27
  from picarones.formats.alto.parser import AltoParseError, parse_alto
 
28
  from picarones.formats.alto.types import (
29
  AltoBBox,
30
  AltoDocument,
 
35
  )
36
  from picarones.formats.alto.writer import write_alto
37
 
38
+ # S13 — les projecteurs ``alto_document_to_text`` et ``AltoToText``
39
+ # vivent désormais dans ``picarones.evaluation.projectors.alto``
40
+ # (la projection est conceptuellement un composant d'évaluation,
41
+ # pas un format). Importer depuis le nouveau chemin :
42
+ #
43
+ # from picarones.evaluation.projectors import (
44
+ # AltoToText, alto_document_to_text,
45
+ # )
46
+
47
  __all__ = [
48
  # Types
49
  "AltoBBox",
 
56
  "parse_alto",
57
  "AltoParseError",
58
  "write_alto",
 
 
 
59
  ]
picarones/formats/pagexml/__init__.py CHANGED
@@ -16,7 +16,6 @@ est plus rare que pour ALTO).
16
  from __future__ import annotations
17
 
18
  from picarones.formats.pagexml.parser import PageParseError, parse_pagexml
19
- from picarones.formats.pagexml.projector import PageToText, page_document_to_text
20
  from picarones.formats.pagexml.types import (
21
  PageDocument,
22
  PagePage,
@@ -24,6 +23,9 @@ from picarones.formats.pagexml.types import (
24
  PageTextRegion,
25
  )
26
 
 
 
 
27
  __all__ = [
28
  "PageTextLine",
29
  "PageTextRegion",
@@ -31,6 +33,4 @@ __all__ = [
31
  "PageDocument",
32
  "parse_pagexml",
33
  "PageParseError",
34
- "page_document_to_text",
35
- "PageToText",
36
  ]
 
16
  from __future__ import annotations
17
 
18
  from picarones.formats.pagexml.parser import PageParseError, parse_pagexml
 
19
  from picarones.formats.pagexml.types import (
20
  PageDocument,
21
  PagePage,
 
23
  PageTextRegion,
24
  )
25
 
26
+ # S13 — les projecteurs vivent désormais dans
27
+ # ``picarones.evaluation.projectors.pagexml``.
28
+
29
  __all__ = [
30
  "PageTextLine",
31
  "PageTextRegion",
 
33
  "PageDocument",
34
  "parse_pagexml",
35
  "PageParseError",
 
 
36
  ]
tests/architecture/test_layer_dependencies.py CHANGED
@@ -64,9 +64,13 @@ PICARONES_ROOT = REPO_ROOT / "picarones"
64
  #: avant** la sienne (i.e. plus internes), mais jamais l'inverse.
65
  LAYER_ORDER: tuple[str, ...] = (
66
  "domain",
 
 
 
 
 
67
  "evaluation",
68
  "pipeline",
69
- "formats",
70
  "adapters",
71
  "app",
72
  "reports_v2",
 
64
  #: avant** la sienne (i.e. plus internes), mais jamais l'inverse.
65
  LAYER_ORDER: tuple[str, ...] = (
66
  "domain",
67
+ "formats", # S13 — re-ordonné : parsers/normalization sont des
68
+ # utilitaires bas niveau qu'``evaluation`` consomme
69
+ # (ex : ``DefaultEvaluationViewExecutor`` charge un
70
+ # profil de normalisation depuis
71
+ # ``formats.text.normalization``).
72
  "evaluation",
73
  "pipeline",
 
74
  "adapters",
75
  "app",
76
  "reports_v2",
tests/evaluation/test_sprint_a14_s13_view_executor.py ADDED
@@ -0,0 +1,379 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sprint A14-S13 — ``DefaultEvaluationViewExecutor``.
2
+
3
+ Tests d'orchestration : la vue + ses dépendances (registries +
4
+ payload loader) sur 10+ cas couvrant les chemins critiques.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import pytest
10
+
11
+ from picarones.domain import (
12
+ Artifact,
13
+ ArtifactType,
14
+ EvaluationView,
15
+ MetricSpec,
16
+ ProjectionError,
17
+ ProjectionSpec,
18
+ )
19
+ from picarones.evaluation.projectors import (
20
+ ProjectionReport,
21
+ ProjectorRegistry,
22
+ ProjectorRegistrationError,
23
+ ProjectorNotFoundError,
24
+ )
25
+ from picarones.evaluation.registry import MetricRegistry
26
+ from picarones.evaluation.views import (
27
+ DefaultEvaluationViewExecutor,
28
+ ViewResult,
29
+ )
30
+
31
+
32
+ # ──────────────────────────────────────────────────────────────────────
33
+ # Stubs réutilisables
34
+ # ──────────────────────────────────────────────────────────────────────
35
+
36
+
37
+ class _StubProjector:
38
+ """Projecteur ALTO → texte simple pour les tests."""
39
+
40
+ name = "stub_alto_to_text"
41
+ source_type = ArtifactType.ALTO_XML
42
+ target_type = ArtifactType.RAW_TEXT
43
+
44
+ def __init__(self, output_payload: str = "projected text") -> None:
45
+ self.output_payload = output_payload
46
+
47
+ def project(self, artifact, params):
48
+ target = Artifact(
49
+ id=f"{artifact.id}:projected",
50
+ document_id=artifact.document_id,
51
+ type=self.target_type,
52
+ )
53
+ report = ProjectionReport(
54
+ source_artifact_id=artifact.id,
55
+ source_type=self.source_type,
56
+ target_type=self.target_type,
57
+ projector_name=self.name,
58
+ lossy=True,
59
+ ignored_dimensions=("geometry", "blocks"),
60
+ warnings=("ordre de lecture deviné",),
61
+ )
62
+ return target, report
63
+
64
+
65
+ def _build_executor(
66
+ payloads: dict[str, object],
67
+ *,
68
+ register_projector: bool = True,
69
+ extra_metrics: dict[str, object] | None = None,
70
+ ) -> DefaultEvaluationViewExecutor:
71
+ metrics = MetricRegistry()
72
+ metrics.register(
73
+ MetricSpec(
74
+ name="cer",
75
+ input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT),
76
+ ),
77
+ lambda gt, hyp: 0.0 if gt == hyp else (
78
+ 0.5 if isinstance(gt, str) and isinstance(hyp, str) and len(gt) == len(hyp)
79
+ else 1.0
80
+ ),
81
+ )
82
+ metrics.register(
83
+ MetricSpec(
84
+ name="wer",
85
+ input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT),
86
+ ),
87
+ lambda gt, hyp: 0.0 if gt == hyp else 0.5,
88
+ )
89
+ if extra_metrics:
90
+ for name, fn in extra_metrics.items():
91
+ metrics.register(
92
+ MetricSpec(
93
+ name=name,
94
+ input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT),
95
+ ),
96
+ fn,
97
+ )
98
+
99
+ projectors = ProjectorRegistry()
100
+ if register_projector:
101
+ projectors.register(_StubProjector())
102
+
103
+ def loader(artifact: Artifact):
104
+ if artifact.id not in payloads:
105
+ raise KeyError(f"payload manquant : {artifact.id}")
106
+ return payloads[artifact.id]
107
+
108
+ return DefaultEvaluationViewExecutor(metrics, projectors, loader)
109
+
110
+
111
+ def _text_view(
112
+ *,
113
+ name: str = "text_final",
114
+ candidate_types: frozenset = frozenset({
115
+ ArtifactType.RAW_TEXT,
116
+ ArtifactType.CORRECTED_TEXT,
117
+ ArtifactType.ALTO_XML,
118
+ }),
119
+ projection: ProjectionSpec | None = None,
120
+ normalization_profile: str | None = None,
121
+ metric_names: tuple[str, ...] = ("cer",),
122
+ ignored_dimensions: tuple[str, ...] = (),
123
+ warnings: tuple[str, ...] = (),
124
+ ) -> EvaluationView:
125
+ return EvaluationView(
126
+ name=name,
127
+ candidate_types=candidate_types,
128
+ projection=projection,
129
+ normalization_profile=normalization_profile,
130
+ metric_names=metric_names,
131
+ ignored_dimensions=ignored_dimensions,
132
+ warnings=warnings,
133
+ )
134
+
135
+
136
+ # ──────────────────────────────────────────────────────────────────────
137
+ # 10 cas d'évaluation
138
+ # ──────────────────────────────────────────────────────────────────────
139
+
140
+
141
+ class TestEvaluator:
142
+
143
+ def test_text_direct_no_projection(self) -> None:
144
+ """Cas 1 — RAW_TEXT direct, pas de projection."""
145
+ payloads = {"cand": "hello", "gt": "hello"}
146
+ executor = _build_executor(payloads)
147
+ view = _text_view(metric_names=("cer", "wer"))
148
+ cand = Artifact(id="cand", document_id="d", type=ArtifactType.RAW_TEXT)
149
+ gt = Artifact(id="gt", document_id="d", type=ArtifactType.RAW_TEXT)
150
+ result = executor.evaluate(view, cand, gt)
151
+ assert result.metric_values["cer"] == 0.0
152
+ assert result.metric_values["wer"] == 0.0
153
+ assert result.projection_report is None
154
+ assert result.failed_metrics == {}
155
+
156
+ def test_text_direct_with_difference(self) -> None:
157
+ """Cas 2 — RAW_TEXT, candidat différent de la GT."""
158
+ payloads = {"cand": "world", "gt": "hello"}
159
+ executor = _build_executor(payloads)
160
+ view = _text_view()
161
+ cand = Artifact(id="cand", document_id="d", type=ArtifactType.RAW_TEXT)
162
+ gt = Artifact(id="gt", document_id="d", type=ArtifactType.RAW_TEXT)
163
+ result = executor.evaluate(view, cand, gt)
164
+ assert result.metric_values["cer"] > 0
165
+
166
+ def test_alto_to_text_via_projection(self) -> None:
167
+ """Cas 3 — ALTO_XML projeté en RAW_TEXT, projection_report présent."""
168
+ payloads = {
169
+ "alto:projected": "projected text",
170
+ "gt": "projected text",
171
+ }
172
+ executor = _build_executor(payloads)
173
+ view = _text_view(
174
+ projection=ProjectionSpec(
175
+ source_type=ArtifactType.ALTO_XML,
176
+ target_type=ArtifactType.RAW_TEXT,
177
+ projector_name="stub_alto_to_text",
178
+ ),
179
+ )
180
+ cand = Artifact(id="alto", document_id="d", type=ArtifactType.ALTO_XML)
181
+ gt = Artifact(id="gt", document_id="d", type=ArtifactType.RAW_TEXT)
182
+ result = executor.evaluate(view, cand, gt)
183
+ assert result.projection_report is not None
184
+ assert result.projection_report.projector_name == "stub_alto_to_text"
185
+ assert "geometry" in result.ignored_dimensions
186
+ assert "ordre de lecture deviné" in result.warnings
187
+ assert result.metric_values["cer"] == 0.0
188
+
189
+ def test_view_rejects_wrong_artifact_type(self) -> None:
190
+ """Cas 4 — la vue n'accepte pas IMAGE → ValueError."""
191
+ payloads = {}
192
+ executor = _build_executor(payloads)
193
+ view = _text_view(
194
+ candidate_types=frozenset({ArtifactType.RAW_TEXT}),
195
+ )
196
+ cand = Artifact(id="x", document_id="d", type=ArtifactType.IMAGE)
197
+ gt = Artifact(id="gt", document_id="d", type=ArtifactType.RAW_TEXT)
198
+ with pytest.raises(ValueError, match="n'accepte pas"):
199
+ executor.evaluate(view, cand, gt)
200
+
201
+ def test_unknown_projector_raises_projection_error(self) -> None:
202
+ """Cas 5 — la vue référence un projecteur non enregistré."""
203
+ payloads = {"cand": "x", "gt": "x"}
204
+ executor = _build_executor(payloads, register_projector=False)
205
+ view = _text_view(
206
+ projection=ProjectionSpec(
207
+ source_type=ArtifactType.ALTO_XML,
208
+ target_type=ArtifactType.RAW_TEXT,
209
+ projector_name="nonexistent",
210
+ ),
211
+ )
212
+ cand = Artifact(id="cand", document_id="d", type=ArtifactType.ALTO_XML)
213
+ gt = Artifact(id="gt", document_id="d", type=ArtifactType.RAW_TEXT)
214
+ with pytest.raises(ProjectionError, match="introuvable"):
215
+ executor.evaluate(view, cand, gt)
216
+
217
+ def test_projector_that_raises_wraps_in_projection_error(self) -> None:
218
+ """Cas 6 — le projecteur lève une exception interne."""
219
+ class _CrashingProjector:
220
+ name = "crash"
221
+ source_type = ArtifactType.ALTO_XML
222
+ target_type = ArtifactType.RAW_TEXT
223
+ def project(self, artifact, params):
224
+ raise RuntimeError("boom interne")
225
+
226
+ metrics = MetricRegistry()
227
+ projectors = ProjectorRegistry()
228
+ projectors.register(_CrashingProjector())
229
+ executor = DefaultEvaluationViewExecutor(
230
+ metrics, projectors, lambda a: None,
231
+ )
232
+ view = _text_view(
233
+ projection=ProjectionSpec(
234
+ source_type=ArtifactType.ALTO_XML,
235
+ target_type=ArtifactType.RAW_TEXT,
236
+ projector_name="crash",
237
+ ),
238
+ metric_names=(),
239
+ )
240
+ cand = Artifact(id="c", document_id="d", type=ArtifactType.ALTO_XML)
241
+ gt = Artifact(id="gt", document_id="d", type=ArtifactType.RAW_TEXT)
242
+ with pytest.raises(ProjectionError, match="boom interne"):
243
+ executor.evaluate(view, cand, gt)
244
+
245
+ def test_metric_that_raises_goes_to_failed_metrics(self) -> None:
246
+ """Cas 7 — une métrique qui lève → failed_metrics, pas plante."""
247
+ def _broken(gt, hyp):
248
+ raise ValueError("métrique cassée")
249
+ payloads = {"cand": "x", "gt": "x"}
250
+ executor = _build_executor(
251
+ payloads,
252
+ extra_metrics={"broken": _broken},
253
+ )
254
+ view = _text_view(metric_names=("cer", "broken", "wer"))
255
+ cand = Artifact(id="cand", document_id="d", type=ArtifactType.RAW_TEXT)
256
+ gt = Artifact(id="gt", document_id="d", type=ArtifactType.RAW_TEXT)
257
+ result = executor.evaluate(view, cand, gt)
258
+ assert "cer" in result.metric_values
259
+ assert "wer" in result.metric_values
260
+ assert "broken" in result.failed_metrics
261
+ assert "métrique cassée" in result.failed_metrics["broken"]
262
+
263
+ def test_unknown_metric_goes_to_failed_metrics(self) -> None:
264
+ """Cas 8 — une métrique non enregistrée → failed_metrics."""
265
+ payloads = {"cand": "x", "gt": "x"}
266
+ executor = _build_executor(payloads)
267
+ view = _text_view(metric_names=("cer", "nonexistent_metric"))
268
+ cand = Artifact(id="cand", document_id="d", type=ArtifactType.RAW_TEXT)
269
+ gt = Artifact(id="gt", document_id="d", type=ArtifactType.RAW_TEXT)
270
+ result = executor.evaluate(view, cand, gt)
271
+ assert "cer" in result.metric_values
272
+ assert "nonexistent_metric" in result.failed_metrics
273
+ assert "non enregistrée" in result.failed_metrics["nonexistent_metric"]
274
+
275
+ def test_normalization_profile_applied(self) -> None:
276
+ """Cas 9 — vue avec normalization_profile applique la
277
+ normalisation aux deux payloads."""
278
+ # Avec medieval_french : ſ → s, u → v
279
+ payloads = {"cand": "afpre", "gt": "aſpre"}
280
+ executor = _build_executor(payloads)
281
+ view = _text_view(normalization_profile="medieval_french")
282
+ cand = Artifact(id="cand", document_id="d", type=ArtifactType.RAW_TEXT)
283
+ gt = Artifact(id="gt", document_id="d", type=ArtifactType.RAW_TEXT)
284
+ result = executor.evaluate(view, cand, gt)
285
+ # Après normalisation, les deux deviennent "aspre" (cer stub
286
+ # retourne 0.5 pour len égal, 0.0 pour égalité stricte).
287
+ # On vérifie au moins que la métrique a été calculée.
288
+ assert "cer" in result.metric_values
289
+
290
+ def test_payload_loader_failure_blocks_all_metrics(self) -> None:
291
+ """Cas 10 — le loader plante → toutes les métriques sont
292
+ marquées en échec global."""
293
+ # Loader plante systématiquement.
294
+ metrics = MetricRegistry()
295
+ metrics.register(
296
+ MetricSpec(
297
+ name="cer",
298
+ input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT),
299
+ ),
300
+ lambda r, h: 0.0,
301
+ )
302
+ projectors = ProjectorRegistry()
303
+
304
+ def _bad_loader(artifact):
305
+ raise FileNotFoundError(f"missing file for {artifact.id}")
306
+
307
+ executor = DefaultEvaluationViewExecutor(metrics, projectors, _bad_loader)
308
+ view = _text_view(metric_names=("cer",))
309
+ cand = Artifact(id="cand", document_id="d", type=ArtifactType.RAW_TEXT)
310
+ gt = Artifact(id="gt", document_id="d", type=ArtifactType.RAW_TEXT)
311
+ result = executor.evaluate(view, cand, gt)
312
+ assert result.metric_values == {}
313
+ assert "cer" in result.failed_metrics
314
+ assert "payload_loader a échoué" in result.failed_metrics["cer"]
315
+
316
+
317
+ # ──────────────────────────────────────────────────────────────────────
318
+ # Constructor validation
319
+ # ──────────────────────────────────────────────────────────────────────
320
+
321
+
322
+ class TestConstructor:
323
+ def test_rejects_non_metric_registry(self) -> None:
324
+ with pytest.raises(TypeError, match="metric_registry"):
325
+ DefaultEvaluationViewExecutor(
326
+ "not a registry", ProjectorRegistry(), lambda a: None, # type: ignore[arg-type]
327
+ )
328
+
329
+ def test_rejects_non_projector_registry(self) -> None:
330
+ with pytest.raises(TypeError, match="projector_registry"):
331
+ DefaultEvaluationViewExecutor(
332
+ MetricRegistry(), "nope", lambda a: None, # type: ignore[arg-type]
333
+ )
334
+
335
+ def test_rejects_non_callable_loader(self) -> None:
336
+ with pytest.raises(TypeError, match="callable"):
337
+ DefaultEvaluationViewExecutor(
338
+ MetricRegistry(), ProjectorRegistry(), "not_callable", # type: ignore[arg-type]
339
+ )
340
+
341
+
342
+ # ──────────────────────────────────────────────────────────────────────
343
+ # ProjectorRegistry — tests directs
344
+ # ──────────────────────────────────────────────────────────────────────
345
+
346
+
347
+ class TestProjectorRegistry:
348
+ def test_register_and_get(self) -> None:
349
+ reg = ProjectorRegistry()
350
+ p = _StubProjector()
351
+ reg.register(p)
352
+ assert "stub_alto_to_text" in reg
353
+ assert reg.get("stub_alto_to_text") is p
354
+
355
+ def test_register_non_protocol_raises(self) -> None:
356
+ reg = ProjectorRegistry()
357
+ class _NotAProjector:
358
+ pass
359
+ with pytest.raises(ProjectorRegistrationError):
360
+ reg.register(_NotAProjector()) # type: ignore[arg-type]
361
+
362
+ def test_idempotent_re_registration(self) -> None:
363
+ reg = ProjectorRegistry()
364
+ p = _StubProjector()
365
+ reg.register(p)
366
+ reg.register(p) # ne lève pas
367
+ assert len(reg) == 1
368
+
369
+ def test_get_unknown_raises(self) -> None:
370
+ reg = ProjectorRegistry()
371
+ with pytest.raises(ProjectorNotFoundError):
372
+ reg.get("missing")
373
+
374
+ def test_two_registries_independent(self) -> None:
375
+ a = ProjectorRegistry()
376
+ b = ProjectorRegistry()
377
+ a.register(_StubProjector())
378
+ assert "stub_alto_to_text" in a
379
+ assert "stub_alto_to_text" not in b
tests/formats/alto/test_sprint_a14_s9_alto.py CHANGED
@@ -15,6 +15,7 @@ import pytest
15
 
16
  from picarones.domain import Artifact, ArtifactType
17
  from picarones.domain.errors import ProjectionError
 
18
  from picarones.formats.alto import (
19
  AltoBBox,
20
  AltoDocument,
@@ -23,8 +24,6 @@ from picarones.formats.alto import (
23
  AltoParseError,
24
  AltoString,
25
  AltoTextBlock,
26
- AltoToText,
27
- alto_document_to_text,
28
  parse_alto,
29
  write_alto,
30
  )
 
15
 
16
  from picarones.domain import Artifact, ArtifactType
17
  from picarones.domain.errors import ProjectionError
18
+ from picarones.evaluation.projectors import AltoToText, alto_document_to_text
19
  from picarones.formats.alto import (
20
  AltoBBox,
21
  AltoDocument,
 
24
  AltoParseError,
25
  AltoString,
26
  AltoTextBlock,
 
 
27
  parse_alto,
28
  write_alto,
29
  )
tests/formats/pagexml/test_sprint_a14_s9_pagexml.py CHANGED
@@ -6,14 +6,13 @@ import pytest
6
 
7
  from picarones.domain import Artifact, ArtifactType
8
  from picarones.domain.errors import ProjectionError
 
9
  from picarones.formats.pagexml import (
10
  PageDocument,
11
  PageParseError,
12
  PagePage,
13
  PageTextLine,
14
  PageTextRegion,
15
- PageToText,
16
- page_document_to_text,
17
  parse_pagexml,
18
  )
19
 
 
6
 
7
  from picarones.domain import Artifact, ArtifactType
8
  from picarones.domain.errors import ProjectionError
9
+ from picarones.evaluation.projectors import PageToText, page_document_to_text
10
  from picarones.formats.pagexml import (
11
  PageDocument,
12
  PageParseError,
13
  PagePage,
14
  PageTextLine,
15
  PageTextRegion,
 
 
16
  parse_pagexml,
17
  )
18