Claude commited on
Commit
b9ff8de
·
unverified ·
1 Parent(s): 3116157

feat(pipeline): Sprint A14-S6 — PipelineSpec déclaratif + validation + YAML round-trip

Browse files

Sprint S6 du plan rewrite ciblé. **Clôture la Phase 1** du
rewrite (squelette + règles d'architecture, S3-S6).

Pose le contrat d'un DAG de pipeline composée — purement
déclaratif, sérialisable en YAML, valide statiquement sans
instancier aucun module. C'est ce qui permettra à la BnF de
versionner ``ocr_llm_alto_remap.yaml`` en git et de le faire
exécuter par un service applicatif au S19.

Différence-clé avec l'ancien ``picarones.core.pipeline`` (Sprint 63)
-------------------------------------------------------------------
L'ancien ``PipelineStep`` portait un champ ``module: BaseModule``
— une instance d'objet exécutable. Conséquence : la spec
n'était PAS sérialisable en YAML (un objet Python au milieu d'une
dataclass), et un test qui voulait juste valider la cohérence des
types devait instancier des stubs.

Le nouveau ``PipelineStep`` ne porte qu'un ``adapter_name: str``.
Le mapping ``nom → instance`` est maintenu par un service
applicatif (``adapter_registry``) au S19 et résolu au moment de
l'exécution. Bénéfices :

- YAML versionnable en git indépendamment de l'environnement
Python (BnF peut commit ``my_pipeline.yaml`` sans imposer aux
contributeurs d'avoir tous les SDK installés).
- ``validate_spec`` s'exécute sans instancier aucun adapter →
tests rapides et déterministes.
- Le rapport peut citer le YAML exact, le commit du code et la
version des adapters — séparation propre de la déclaration et
de l'implémentation.

L'ancien code reste en place et continue à être utilisé par
``measurements.runner``. Sa migration en re-exports vers le
nouveau pipeline est S10/S11.

Modules livrés
--------------
``picarones/pipeline/spec.py``
- ``PipelineStep`` frozen pydantic (id, kind, adapter_name,
params, input_types, output_types, inputs_from). Validation
de l'id (alphanum + ``_-``, refus de ``__initial__``).
- ``PipelineSpec`` frozen pydantic (name, description,
initial_inputs, steps). Helper ``step_by_id``.
- Constante ``INITIAL_STEP_ID = "__initial__"`` pour les
références ``inputs_from`` vers les entrées du runner.

``picarones/pipeline/types.py``
- ``RunContext`` (document_id, code_version, pipeline_name,
workspace_uri). Contexte passé à chaque ``execute()``.
- ``StepResult`` (step_id, succeeded, duration_seconds,
produced_artifacts, error). Validation duration_seconds >= 0.
- ``PipelineResult`` (pipeline_name, document_id, step_results,
succeeded, duration_seconds, artifacts). Helpers
``step_result_by_id`` et ``artifacts_of_type``.

``picarones/pipeline/protocols.py``
- ``ExecutionMode = Literal["io", "cpu"]``.
- ``StepExecutor`` (Protocol runtime_checkable) :
name + input_types + output_types + execution_mode + execute.
Le runner utilisera ``isinstance(adapter, StepExecutor)``
pour valider les adapters au S11.

``picarones/pipeline/validation.py``
- ``ValidationError`` frozen pydantic (step_id, code, message).
7 codes : ``empty_pipeline``, ``duplicate_id``,
``unknown_adapter``, ``missing_input``, ``inputs_from_unused``,
``unknown_input_source``, ``source_does_not_produce_type``.
- ``validate_spec(spec, available_adapters=None)`` retourne la
liste des erreurs (ne s'arrête pas à la première).
``available_adapters=None`` saute la check d'adapters →
permet de valider un YAML sans avoir le runtime chargé.

``picarones/pipeline/yaml_io.py``
- ``dump_spec_to_yaml(spec)`` → str YAML déterministe (block
style, sort_keys=False, allow_unicode=True).
- ``load_spec_from_yaml(text)`` → ``PipelineSpec`` validée.
- YAML vide → ``PicaronesError`` explicite.

``picarones/pipeline/__init__.py`` expose 12 symboles publics.

Mise à jour de la whitelist de la couche
----------------------------------------
``tests/architecture/test_layer_dependencies.py`` :
``EXTERNAL_ALLOWED["pipeline"]`` ajoute ``"yaml"``. Justifié :
versionner les pipelines en git en YAML est un cas d'usage
explicite du rewrite (cf. docs/roadmap/rewrite-2026.md).

Anti-sur-ingénierie respectée
-----------------------------
- Pas de typage des ``params`` par adapter (chaque adapter
validera les siens au runtime).
- Pas de versioning de schéma de spec (rebump pydantic suffit).
- Pas d'``outputs_preferred`` ("preferred_text = step3.RAW_TEXT")
reporté quand un caller en aura concrètement besoin.
- Pas de détection de cycles graphes complexe — le DAG est
exprimé par ordre des steps, donc une boucle = référence vers
un nom inconnu, déjà détectée.

Tests — 42 nouveaux tests
-------------------------
- tests/pipeline/test_sprint_a14_s6_spec.py (12) — PipelineStep
validation (id space/dot/réservé), params, inputs_from, frozen,
extra=forbid. PipelineSpec minimal/avec steps/step_by_id.
- tests/pipeline/test_sprint_a14_s6_validation.py (14) — 4 cas
valides (dont **def of done : Tesseract+LLM+ALTO_remap**) ;
10 cas invalides (DAG vide, missing_input, ordre incorrect,
duplicate_id, unknown_adapter avec/sans registry,
inputs_from_unused, unknown_input_source,
source_does_not_produce_type, multi_errors).
- tests/pipeline/test_sprint_a14_s6_yaml_roundtrip.py (6) —
round-trip preserve l'égalité (paramétré sur 2 specs),
idempotence du dump → load → dump, YAML lisible (block style,
ordre des champs respecté), vide → erreur, type bogus → erreur.
- tests/pipeline/test_sprint_a14_s6_protocols.py (10) —
RunContext, StepResult success/failure/duration < 0 rejeté,
PipelineResult avec helpers, StepExecutor satisfait par stub +
classe non-conforme rejetée.

Critère go/no-go S6 atteint
---------------------------
La spec ``tesseract_llm_alto_remap`` (3 étapes : OCR → LLM
correction → ALTO remap, avec inputs_from explicites) :

- se construit sans erreur,
- valide à zéro erreur via ``validate_spec``,
- se dump en YAML lisible,
- se reload en spec strictement égale (testé).

État de la suite
----------------
``pytest tests/ -q`` → 4074 passed, 6 skipped, 2 failed.
+42 tests par rapport à S5. Les 2 fails restants sont
strictement environnementaux (sous-process pytest sans
``pip install -e .``). Aucune régression S6.

Critère fin de Phase 1 atteint : tests d'architecture passent,
les 4 cercles internes (domain, evaluation, pipeline) ont leurs
contrats déclaratifs et runtime + validation. L'outil actuel
reste utilisable pour la BnF. Prêt pour Phase 2 (S7-S12 :
PipelineExecutor + migration des calculs).

https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP

picarones/pipeline/__init__.py CHANGED
@@ -1,27 +1,33 @@
1
  """Cercle 2 — Pipeline execution.
2
 
3
  Exécution séquentielle ou DAG-branchante d'une chaîne de modules
4
- tiers (BaseModule). Picarones ne fournit **aucun module métier** —
5
- l'utilisateur amène ses propres adaptateurs OCR/LLM/VLM/correcteur/
6
- reconstructeur ALTO ; le pipeline executor les compose, valide les
7
- types aux jonctions et évalue automatiquement chaque artefact
8
- produit contre la GT correspondante.
9
 
10
- Modules cibles venir Sprints S6-S8) :
 
 
 
 
 
 
 
 
 
 
11
 
12
- - ``spec.py`` ``PipelineSpec``, ``PipelineStep``, ``inputs_from``
13
- (DAG branchant), validation statique des types aux jonctions.
14
- - ``executor.py`` — ``PipelineExecutor.run(spec, document, inputs)``
15
- exécute mono-document avec capture gracieuse des erreurs.
16
- - ``runner.py`` — ``CorpusRunner`` orchestre l'executor sur un
17
- corpus complet avec **backpressure**, **timeout depuis le début
18
- d'exécution réelle** (pas depuis la submission), **annulation
19
- propre** (signal aux workers en cours).
20
  - ``cache.py`` — ``ArtifactCache`` indexé par
21
- ``hash(content + spec + code_version)`` pour reprise hashée
22
- (Sprint S7).
23
- - ``protocols.py`` — protocole ``StepExecutor`` que doivent
24
- implémenter les adaptateurs.
25
 
26
  Cible du Sprint S12 : équivalence numérique CER/WER avec l'ancien
27
  ``measurements.runner`` à 1e-9 près sur les fixtures.
@@ -29,4 +35,28 @@ Cible du Sprint S12 : équivalence numérique CER/WER avec l'ancien
29
 
30
  from __future__ import annotations
31
 
32
- __all__: list[str] = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """Cercle 2 — Pipeline execution.
2
 
3
  Exécution séquentielle ou DAG-branchante d'une chaîne de modules
4
+ tiers (``StepExecutor``). Picarones ne fournit **aucun module
5
+ métier** — l'utilisateur amène ses propres adapters OCR/LLM/VLM/
6
+ correcteur/reconstructeur ALTO ; le pipeline executor les compose,
7
+ valide les types aux jonctions et évalue automatiquement chaque
8
+ artefact produit contre la GT correspondante.
9
 
10
+ Modules livrés au S6
11
+ --------------------
12
+ - ``spec.py`` — ``PipelineStep``, ``PipelineSpec``, ``INITIAL_STEP_ID``.
13
+ Spec déclarative sérialisable en YAML (cf. ``yaml_io.py``).
14
+ - ``types.py`` — ``RunContext``, ``StepResult``, ``PipelineResult``.
15
+ Types runtime de l'executor.
16
+ - ``protocols.py`` — ``StepExecutor`` (Protocol), ``ExecutionMode``.
17
+ Contrat d'un adapter exécutable.
18
+ - ``validation.py`` — ``validate_spec(spec, available_adapters)``,
19
+ ``ValidationError``. Validation statique sans instancier de module.
20
+ - ``yaml_io.py`` — ``dump_spec_to_yaml`` / ``load_spec_from_yaml``.
21
 
22
+ À venir aux Sprints S7-S8
23
+ -------------------------
24
+ - ``executor.py`` — ``PipelineExecutor.run(spec, document, inputs,
25
+ context)`` exécute mono-document avec capture gracieuse des erreurs.
26
+ - ``runner.py`` — ``CorpusRunner`` orchestre l'executor sur un corpus
27
+ complet avec **backpressure**, **timeout depuis le début
28
+ d'exécution réelle**, **annulation propre**.
 
29
  - ``cache.py`` — ``ArtifactCache`` indexé par
30
+ ``hash(content + spec + code_version)``.
 
 
 
31
 
32
  Cible du Sprint S12 : équivalence numérique CER/WER avec l'ancien
33
  ``measurements.runner`` à 1e-9 près sur les fixtures.
 
35
 
36
  from __future__ import annotations
37
 
38
+ from picarones.pipeline.protocols import ExecutionMode, StepExecutor
39
+ from picarones.pipeline.spec import INITIAL_STEP_ID, PipelineSpec, PipelineStep
40
+ from picarones.pipeline.types import PipelineResult, RunContext, StepResult
41
+ from picarones.pipeline.validation import ValidationError, validate_spec
42
+ from picarones.pipeline.yaml_io import dump_spec_to_yaml, load_spec_from_yaml
43
+
44
+ __all__ = [
45
+ # Spec déclarative
46
+ "PipelineSpec",
47
+ "PipelineStep",
48
+ "INITIAL_STEP_ID",
49
+ # Runtime types
50
+ "RunContext",
51
+ "StepResult",
52
+ "PipelineResult",
53
+ # Protocol
54
+ "StepExecutor",
55
+ "ExecutionMode",
56
+ # Validation
57
+ "validate_spec",
58
+ "ValidationError",
59
+ # YAML IO
60
+ "dump_spec_to_yaml",
61
+ "load_spec_from_yaml",
62
+ ]
picarones/pipeline/protocols.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``StepExecutor`` (Protocol) — Sprint A14-S6.
2
+
3
+ Contrat que doit satisfaire tout adapter exécutable par le pipeline
4
+ runner. Une fonction ou une classe peut satisfaire le protocole —
5
+ le runner ne se soucie que de l'interface.
6
+
7
+ Implémentations concrètes au Sprint S11 dans ``picarones/adapters/``
8
+ (Tesseract, Pero OCR, Mistral OCR, Google Vision, Azure DI, OpenAI,
9
+ Anthropic, Mistral, Ollama, ...).
10
+
11
+ Pattern d'utilisation cible :
12
+
13
+ .. code-block:: python
14
+
15
+ class TesseractExecutor:
16
+ name = "tesseract"
17
+ input_types = frozenset({ArtifactType.IMAGE})
18
+ output_types = frozenset({ArtifactType.RAW_TEXT})
19
+ execution_mode = "cpu"
20
+
21
+ def execute(
22
+ self,
23
+ inputs: dict[ArtifactType, Artifact],
24
+ params: dict,
25
+ context: RunContext,
26
+ ) -> dict[ArtifactType, Artifact]:
27
+ image_artifact = inputs[ArtifactType.IMAGE]
28
+ text = pytesseract.image_to_string(image_artifact.uri, **params)
29
+ return {ArtifactType.RAW_TEXT: build_text_artifact(text, context)}
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from typing import Literal, Protocol, runtime_checkable
35
+
36
+ from picarones.domain.artifacts import Artifact, ArtifactType
37
+ from picarones.pipeline.types import RunContext
38
+
39
+
40
+ #: Mode d'exécution déclaré par l'adapter. Le runner choisit
41
+ #: ``ProcessPoolExecutor`` pour ``"cpu"``, ``ThreadPoolExecutor`` pour
42
+ #: ``"io"``.
43
+ ExecutionMode = Literal["io", "cpu"]
44
+
45
+
46
+ @runtime_checkable
47
+ class StepExecutor(Protocol):
48
+ """Contrat d'un adapter exécutable.
49
+
50
+ Trois propriétés statiques (le runner les inspecte sans appeler
51
+ ``execute()``) :
52
+
53
+ - ``name`` : identifiant stable (cf. ``PipelineStep.adapter_name``).
54
+ - ``input_types`` : types consommés.
55
+ - ``output_types`` : types produits.
56
+ - ``execution_mode`` : ``"io"`` ou ``"cpu"``.
57
+
58
+ Une méthode ``execute(inputs, params, context) -> dict[ArtifactType, Artifact]``.
59
+
60
+ Le runner garantit que :
61
+
62
+ - ``inputs`` contient au moins tous les types listés dans
63
+ ``input_types``.
64
+ - ``params`` est le dict ``PipelineStep.params`` (copie).
65
+ - ``context`` est le ``RunContext`` du document courant.
66
+
67
+ L'adapter garantit que :
68
+
69
+ - Le dict retourné contient au moins tous les types listés dans
70
+ ``output_types``. Le runner valide cette propriété et marque
71
+ le step en échec si un type promis manque.
72
+ - Toute exception levée est propagée au runner ; ne rien capturer
73
+ silencieusement.
74
+
75
+ Le ``execute`` reste **pur du point de vue du runner** : il
76
+ peut faire des side effects (écrire un fichier, appeler une API),
77
+ mais le runner garantit qu'il ne sera pas appelé deux fois pour
78
+ le même couple ``(document_id, step_id)`` dans le même run
79
+ (cache du Sprint S7).
80
+ """
81
+
82
+ @property
83
+ def name(self) -> str: ...
84
+
85
+ @property
86
+ def input_types(self) -> frozenset[ArtifactType]: ...
87
+
88
+ @property
89
+ def output_types(self) -> frozenset[ArtifactType]: ...
90
+
91
+ @property
92
+ def execution_mode(self) -> ExecutionMode: ...
93
+
94
+ def execute(
95
+ self,
96
+ inputs: dict[ArtifactType, Artifact],
97
+ params: dict[str, str | int | float | bool],
98
+ context: RunContext,
99
+ ) -> dict[ArtifactType, Artifact]: ...
100
+
101
+
102
+ __all__ = ["StepExecutor", "ExecutionMode"]
picarones/pipeline/spec.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``PipelineStep`` et ``PipelineSpec`` — Sprint A14-S6.
2
+
3
+ Description **purement déclarative** d'un DAG de transformation
4
+ documentaire. Sérialisable en YAML, versionnable en git, valide
5
+ sans avoir besoin d'instancier les modules concrets.
6
+
7
+ Différence avec l'ancien ``picarones.core.pipeline`` (Sprint 63)
8
+ ----------------------------------------------------------------
9
+ L'ancien ``PipelineStep`` portait un champ ``module: BaseModule``
10
+ — une **instance** d'objet exécutable. Conséquence : la spec
11
+ n'était pas sérialisable en YAML, et un test qui voulait juste
12
+ valider la cohérence des types devait instancier des stubs.
13
+
14
+ Ici, ``PipelineStep`` ne porte qu'un ``adapter_name: str``. Le
15
+ mapping ``nom → instance`` est maintenu par un service applicatif
16
+ (``picarones.app.services.adapter_registry`` au S19) et résolu au
17
+ moment de l'exécution, pas de la spec.
18
+
19
+ Bénéfices :
20
+
21
+ - Le YAML d'une pipeline composée est versionnable en git
22
+ indépendamment de l'environnement Python (BnF peut commit
23
+ ``ocr_llm_alto_remap.yaml`` sans imposer aux contributeurs
24
+ d'avoir tous les SDK installés).
25
+ - ``validate_spec`` peut s'exécuter sans instancier aucun module
26
+ → tests rapides et déterministes.
27
+ - Le rapport de reproductibilité peut citer le YAML exact, le
28
+ commit du code et la version des adapters utilisés —
29
+ séparation propre de la déclaration et de l'implémentation.
30
+
31
+ Anti-sur-ingénierie
32
+ -------------------
33
+ - Pas de typage des ``params`` par adapter ici (chaque adapter
34
+ validera ses propres params au moment de l'exécution).
35
+ - Pas de versioning de spec — un nouveau champ se traduit par un
36
+ rebump pydantic. Si on veut migrer entre versions de schéma,
37
+ on l'ajoutera quand le besoin sera concret.
38
+ - Pas d'``outputs_preferred`` (mapping logique "preferred_text =
39
+ step3.RAW_TEXT"). Reporté quand un caller en aura concrètement
40
+ besoin.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import re
46
+
47
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
48
+
49
+ from picarones.domain.artifacts import ArtifactType
50
+
51
+
52
+ #: Identifiant d'étape — alphanum + ``_-``. Doit être un nom court
53
+ #: lisible par un humain dans les logs et le rapport.
54
+ _STEP_ID_RE = re.compile(r"^[A-Za-z0-9_\-]+$")
55
+
56
+ #: Sentinel pour ``inputs_from`` qui désigne les artefacts initiaux
57
+ #: fournis au runner (typiquement ``IMAGE``).
58
+ INITIAL_STEP_ID = "__initial__"
59
+
60
+
61
+ class PipelineStep(BaseModel):
62
+ """Une étape déclarative dans un DAG de pipeline.
63
+
64
+ Attributs
65
+ ---------
66
+ id:
67
+ Identifiant unique de l'étape dans la pipeline (alphanum +
68
+ ``_-``). Sert dans les logs, le rapport, et comme cible
69
+ des références ``inputs_from`` des étapes en aval.
70
+ kind:
71
+ Catégorie informationnelle de l'étape (``"ocr"``,
72
+ ``"post_correction"``, ``"alto_remapping"``,
73
+ ``"alto_reconstruction"``, etc.). Pas de validation
74
+ d'enum — c'est un label libre que les services et le
75
+ rapport peuvent grouper. Par convention, en
76
+ ``snake_case``.
77
+ adapter_name:
78
+ Nom de l'adapter dans le registre runtime (résolu par
79
+ ``app/services`` au S19). Convention :
80
+ ``"<provider>:<engine_or_model>"`` (ex : ``"tesseract"``,
81
+ ``"openai:gpt-4o"``, ``"mistral:large"``,
82
+ ``"<vendor>:<custom_module>"``).
83
+ params:
84
+ Paramètres passés à l'adapter au moment de l'exécution.
85
+ Format libre (chaque adapter valide les siens) — typage
86
+ scalaire pour rester sérialisable en YAML.
87
+ input_types:
88
+ Types d'artefacts consommés par l'étape. Validés par
89
+ ``validate_spec`` contre les outputs des étapes antérieures.
90
+ output_types:
91
+ Types d'artefacts produits. Validés au runtime par
92
+ l'executor (qui vérifie que tous les types déclarés sont
93
+ bien dans le dict retourné par l'adapter).
94
+ inputs_from:
95
+ DAG branchant (héritage du Sprint 66). Pour chaque type
96
+ d'entrée, désigne explicitement l'étape source. La chaîne
97
+ spéciale ``"__initial__"`` désigne les entrées initiales
98
+ du runner. Si le dict est vide, l'executor prend la
99
+ version la plus récente de chaque type dans le bag.
100
+ """
101
+
102
+ model_config = ConfigDict(frozen=True, extra="forbid")
103
+
104
+ id: str = Field(min_length=1, max_length=128)
105
+ kind: str = Field(min_length=1, max_length=64)
106
+ adapter_name: str = Field(min_length=1, max_length=256)
107
+ params: dict[str, str | int | float | bool] = Field(default_factory=dict)
108
+ input_types: tuple[ArtifactType, ...] = Field(default_factory=tuple)
109
+ output_types: tuple[ArtifactType, ...] = Field(default_factory=tuple)
110
+ inputs_from: dict[ArtifactType, str] = Field(default_factory=dict)
111
+
112
+ @field_validator("id")
113
+ @classmethod
114
+ def _validate_step_id(cls, v: str) -> str:
115
+ if not _STEP_ID_RE.match(v):
116
+ from picarones.domain.errors import PicaronesError
117
+ raise PicaronesError(
118
+ f"step id invalide : {v!r}. "
119
+ f"Doit matcher {_STEP_ID_RE.pattern!r} (alphanum + _-)."
120
+ )
121
+ if v == INITIAL_STEP_ID:
122
+ from picarones.domain.errors import PicaronesError
123
+ raise PicaronesError(
124
+ f"step id réservé : {INITIAL_STEP_ID!r} désigne "
125
+ "les entrées initiales du runner."
126
+ )
127
+ return v
128
+
129
+
130
+ class PipelineSpec(BaseModel):
131
+ """DAG déclaratif d'une pipeline composée.
132
+
133
+ Sérialisable en YAML via ``model_dump()`` + ``yaml.safe_dump``,
134
+ chargeable via ``model_validate(yaml.safe_load(text))``. Le
135
+ round-trip est testé.
136
+
137
+ Attributs
138
+ ---------
139
+ name:
140
+ Nom court de la pipeline (utilisé dans les logs, le cache,
141
+ le rapport). Convention ``snake_case``.
142
+ description:
143
+ Phrase courte d'introduction affichée dans le rapport.
144
+ initial_inputs:
145
+ Types d'artefacts qui doivent être fournis par le caller
146
+ au moment de l'exécution. Convention : ``(IMAGE,)`` pour
147
+ une pipeline OCR classique, ``(IMAGE, RAW_TEXT)`` pour
148
+ une post-correction qui part d'un OCR pré-calculé.
149
+ steps:
150
+ Étapes du DAG, ordonnées par dépendance topologique
151
+ d'exécution. Si une étape ``s2`` dépend de ``s1``, alors
152
+ ``s1`` apparaît avant ``s2``. ``validate_spec`` détecte
153
+ les violations.
154
+ """
155
+
156
+ model_config = ConfigDict(frozen=True, extra="forbid")
157
+
158
+ name: str = Field(min_length=1, max_length=128)
159
+ description: str = ""
160
+ initial_inputs: tuple[ArtifactType, ...] = Field(default_factory=tuple)
161
+ steps: tuple[PipelineStep, ...] = Field(default_factory=tuple)
162
+
163
+ def step_by_id(self, step_id: str) -> PipelineStep | None:
164
+ for s in self.steps:
165
+ if s.id == step_id:
166
+ return s
167
+ return None
168
+
169
+
170
+ __all__ = ["PipelineStep", "PipelineSpec", "INITIAL_STEP_ID"]
picarones/pipeline/types.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``RunContext``, ``StepResult``, ``PipelineResult`` — Sprint A14-S6.
2
+
3
+ Types runtime du pipeline executor (à implémenter au Sprint S7).
4
+ Distincts des specs déclaratives (``picarones.pipeline.spec``) —
5
+ ces types portent les **résultats** de l'exécution, pas la
6
+ description du DAG.
7
+
8
+ Aucune logique métier ici : juste des dataclasses pydantic qu'un
9
+ service applicatif peut sérialiser dans le manifest d'un run.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ from pydantic import BaseModel, ConfigDict, Field
17
+
18
+ from picarones.domain.artifacts import Artifact
19
+
20
+
21
+ class RunContext(BaseModel):
22
+ """Contexte d'exécution passé à chaque ``StepExecutor.execute()``.
23
+
24
+ Le caller (typiquement ``app/services/benchmark_service`` au
25
+ S19) construit un ``RunContext`` par document et le passe à
26
+ l'executor pour chaque étape.
27
+
28
+ Attributs
29
+ ---------
30
+ document_id:
31
+ ``DocumentRef.id`` du document en cours de traitement.
32
+ code_version:
33
+ Version du code (``picarones.__version__``) au moment du
34
+ run. Sert à étiqueter la ``ProvenanceRecord`` de chaque
35
+ artefact produit.
36
+ pipeline_name:
37
+ Nom de la pipeline en cours. Permet à un adapter de
38
+ loguer ``[pipeline_x] step_y : ...`` plutôt que
39
+ ``[unknown] ...``.
40
+ workspace_uri:
41
+ URI/chemin du workspace dans lequel l'adapter peut écrire
42
+ ses artefacts intermédiaires. ``None`` autorisé pour les
43
+ adapters qui n'écrivent rien sur disque (mode in-memory).
44
+
45
+ Anti-sur-ingénierie : pas de logger injecté, pas d'horloge
46
+ abstraite, pas de cancellation token. Ces extras viendront
47
+ quand un caller en aura concrètement besoin (probablement S7
48
+ pour la cancellation, S8 pour le timeout réel).
49
+ """
50
+
51
+ model_config = ConfigDict(frozen=True, extra="forbid")
52
+
53
+ document_id: str = Field(min_length=1, max_length=256)
54
+ code_version: str = Field(min_length=1, max_length=128)
55
+ pipeline_name: str = Field(min_length=1, max_length=128)
56
+ workspace_uri: str | None = Field(default=None, max_length=2048)
57
+
58
+
59
+ class StepResult(BaseModel):
60
+ """Résultat de l'exécution d'une étape sur un document.
61
+
62
+ Sérialisable JSON pour persistance dans le manifest du run.
63
+
64
+ Attributs
65
+ ---------
66
+ step_id:
67
+ Identifiant de l'étape (cf. ``PipelineStep.id``).
68
+ succeeded:
69
+ ``True`` si l'étape s'est exécutée sans lever d'exception
70
+ et a produit tous les types déclarés dans
71
+ ``output_types``. ``False`` sinon.
72
+ duration_seconds:
73
+ Wall-clock time de ``execute()`` (du début effectif à la
74
+ fin). L'executor du S8 garantira que ce temps est mesuré
75
+ depuis le démarrage réel (pas depuis la submission au pool).
76
+ produced_artifacts:
77
+ Map ``{ArtifactType: artifact_id}`` des artefacts produits.
78
+ Vide en cas d'échec.
79
+ error:
80
+ ``None`` en cas de succès ; sinon message d'erreur. Format
81
+ libre (le caller décide de la structure dans son rapport).
82
+ """
83
+
84
+ model_config = ConfigDict(frozen=True, extra="forbid")
85
+
86
+ step_id: str = Field(min_length=1, max_length=128)
87
+ succeeded: bool
88
+ duration_seconds: float = Field(ge=0.0)
89
+ produced_artifacts: dict[str, str] = Field(default_factory=dict)
90
+ """Map ``{ArtifactType.value: Artifact.id}``.
91
+
92
+ Sérialisée avec la valeur string de l'enum (``"raw_text"``,
93
+ ``"alto_xml"``) pour faciliter la lecture humaine du JSON.
94
+ """
95
+ error: str | None = None
96
+
97
+
98
+ class PipelineResult(BaseModel):
99
+ """Résultat complet d'une exécution de pipeline sur un document.
100
+
101
+ Attributs
102
+ ---------
103
+ pipeline_name:
104
+ Nom de la pipeline qui a produit ce résultat.
105
+ document_id:
106
+ Document traité.
107
+ step_results:
108
+ Résultats de chaque étape, dans l'ordre d'exécution.
109
+ succeeded:
110
+ ``True`` ssi tous les ``step_results`` sont des succès.
111
+ Si ``False``, un ou plusieurs ``StepResult.error`` sont
112
+ non-None.
113
+ duration_seconds:
114
+ Wall-clock total (somme des étapes + overhead orchestration).
115
+ artifacts:
116
+ Liste **plate** de tous les artefacts produits par la
117
+ pipeline. Permet à un consommateur (rapport, vue
118
+ d'évaluation) d'accéder directement à un artefact par son
119
+ id sans parcourir l'arborescence des étapes.
120
+ """
121
+
122
+ model_config = ConfigDict(frozen=True, extra="forbid")
123
+
124
+ pipeline_name: str
125
+ document_id: str
126
+ step_results: tuple[StepResult, ...] = Field(default_factory=tuple)
127
+ succeeded: bool = False
128
+ duration_seconds: float = Field(default=0.0, ge=0.0)
129
+ artifacts: tuple[Artifact, ...] = Field(default_factory=tuple)
130
+
131
+ def step_result_by_id(self, step_id: str) -> StepResult | None:
132
+ for r in self.step_results:
133
+ if r.step_id == step_id:
134
+ return r
135
+ return None
136
+
137
+ def artifacts_of_type(self, artifact_type: Any) -> tuple[Artifact, ...]:
138
+ """Retourne tous les artefacts du type donné dans l'ordre
139
+ de production."""
140
+ return tuple(a for a in self.artifacts if a.type == artifact_type)
141
+
142
+
143
+ __all__ = ["RunContext", "StepResult", "PipelineResult"]
picarones/pipeline/validation.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``validate_spec`` — Sprint A14-S6.
2
+
3
+ Validation statique d'une ``PipelineSpec`` : vérifier que les
4
+ types s'enchaînent, qu'il n'y a pas d'IDs dupliqués, que les
5
+ références ``inputs_from`` pointent bien vers des étapes
6
+ antérieures qui produisent le bon type, et (optionnellement) que
7
+ les ``adapter_name`` existent dans un registre fourni.
8
+
9
+ S'exécute **sans instancier aucun adapter** — c'est le bénéfice
10
+ clé de la séparation déclaratif/runtime du S6.
11
+
12
+ API :
13
+
14
+ >>> errors = validate_spec(spec)
15
+ >>> if errors:
16
+ ... for e in errors:
17
+ ... print(f"{e.step_id}: {e.message}")
18
+
19
+ Le caller décide de la suite — typiquement un service applicatif
20
+ refuse de démarrer un run si la spec a des erreurs.
21
+
22
+ Anti-sur-ingénierie
23
+ -------------------
24
+ Pas de détection de cycles graphes complexe (le DAG est exprimé
25
+ par ordre des steps, donc impossible de référencer une étape
26
+ postérieure : si tu as une boucle, c'est qu'une référence pointe
27
+ vers un nom inconnu, déjà détecté).
28
+
29
+ Pas de validation des params (chaque adapter validera les siens
30
+ au moment de l'exécution — le format libre des params est un
31
+ choix assumé).
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from pydantic import BaseModel, ConfigDict
37
+
38
+ from picarones.domain.artifacts import ArtifactType
39
+ from picarones.pipeline.spec import INITIAL_STEP_ID, PipelineSpec, PipelineStep
40
+
41
+
42
+ class ValidationError(BaseModel):
43
+ """Une erreur de validation d'une ``PipelineSpec``.
44
+
45
+ Format structuré pour faciliter le rendu (CLI, rapport, JSON).
46
+ Volontairement plat — pas de hiérarchie d'erreurs ; on ajoute
47
+ un ``code`` discriminant si un caller en a besoin.
48
+ """
49
+
50
+ model_config = ConfigDict(frozen=True, extra="forbid")
51
+
52
+ step_id: str | None
53
+ """Step concerné, ou ``None`` pour les erreurs globales (DAG vide,
54
+ ID dupliqué détecté entre deux steps...)."""
55
+
56
+ code: str
57
+ """Identifiant court (``"duplicate_id"``, ``"unknown_adapter"``,
58
+ ``"missing_input"``, ``"unknown_input_source"``, ...). Permet
59
+ à un test d'asserter sur le code plutôt que sur le message
60
+ français.
61
+ """
62
+
63
+ message: str
64
+ """Description humainement lisible (français)."""
65
+
66
+
67
+ def validate_spec(
68
+ spec: PipelineSpec,
69
+ available_adapters: set[str] | None = None,
70
+ ) -> list[ValidationError]:
71
+ """Vérifie une ``PipelineSpec`` et retourne la liste des erreurs.
72
+
73
+ Parameters
74
+ ----------
75
+ spec:
76
+ La spec à valider.
77
+ available_adapters:
78
+ Set des noms d'adapters connus. Si fourni, chaque
79
+ ``adapter_name`` du DAG est vérifié. Si ``None`` (défaut),
80
+ cette validation est sautée — utile pour les tests qui
81
+ valident la cohérence d'un YAML sans avoir le runtime
82
+ chargé.
83
+
84
+ Returns
85
+ -------
86
+ list[ValidationError]
87
+ Liste vide si la spec est valide ; sinon un ou plusieurs
88
+ problèmes (ne s'arrête pas à la première erreur — le
89
+ caller veut tout voir d'un coup).
90
+ """
91
+ errors: list[ValidationError] = []
92
+
93
+ # -- 0. Steps absents
94
+ if not spec.steps:
95
+ errors.append(ValidationError(
96
+ step_id=None,
97
+ code="empty_pipeline",
98
+ message="pipeline vide : au moins une étape est requise",
99
+ ))
100
+ return errors # impossible de continuer
101
+
102
+ # -- 1. IDs dupliqués
103
+ seen_ids: dict[str, int] = {}
104
+ for i, step in enumerate(spec.steps):
105
+ if step.id in seen_ids:
106
+ errors.append(ValidationError(
107
+ step_id=step.id,
108
+ code="duplicate_id",
109
+ message=(
110
+ f"id dupliqué : '{step.id}' apparaît à l'étape {i} "
111
+ f"et précédemment à {seen_ids[step.id]}"
112
+ ),
113
+ ))
114
+ else:
115
+ seen_ids[step.id] = i
116
+
117
+ # -- 2. Adapter inconnu (si registre fourni)
118
+ if available_adapters is not None:
119
+ for step in spec.steps:
120
+ if step.adapter_name not in available_adapters:
121
+ errors.append(ValidationError(
122
+ step_id=step.id,
123
+ code="unknown_adapter",
124
+ message=(
125
+ f"adapter '{step.adapter_name}' non disponible. "
126
+ f"Adapters connus : {sorted(available_adapters)}"
127
+ ),
128
+ ))
129
+
130
+ # -- 3. Cohérence des types et des références inputs_from
131
+ # On simule un parcours topologique en ordre de spec.steps.
132
+ # À chaque étape :
133
+ # a) Tout type de input_types doit être disponible (soit
134
+ # initial, soit produit par une étape antérieure).
135
+ # b) Si inputs_from[type] = "src", "src" doit être une étape
136
+ # antérieure connue (ou "__initial__") qui produit ce type.
137
+
138
+ # Map { step_id (ou "__initial__") -> set(types qu'elle produit) }.
139
+ step_outputs: dict[str, set[ArtifactType]] = {
140
+ INITIAL_STEP_ID: set(spec.initial_inputs),
141
+ }
142
+ # Set des types disponibles à un instant t (latest seulement).
143
+ available: set[ArtifactType] = set(spec.initial_inputs)
144
+
145
+ for step in spec.steps:
146
+ errors.extend(_validate_step_against_state(
147
+ step=step,
148
+ step_outputs=step_outputs,
149
+ available=available,
150
+ ))
151
+ # Mise à jour de l'état pour les étapes suivantes.
152
+ step_outputs[step.id] = set(step.output_types)
153
+ available.update(step.output_types)
154
+
155
+ return errors
156
+
157
+
158
+ def _validate_step_against_state(
159
+ *,
160
+ step: PipelineStep,
161
+ step_outputs: dict[str, set[ArtifactType]],
162
+ available: set[ArtifactType],
163
+ ) -> list[ValidationError]:
164
+ """Valide une étape donnée contre l'état des types
165
+ disponibles et des outputs des étapes antérieures."""
166
+ errors: list[ValidationError] = []
167
+
168
+ # 3.a — entrées disponibles
169
+ missing = [t for t in step.input_types if t not in available]
170
+ if missing:
171
+ errors.append(ValidationError(
172
+ step_id=step.id,
173
+ code="missing_input",
174
+ message=(
175
+ f"types d'entrée non disponibles : "
176
+ f"{[t.value for t in missing]}. "
177
+ f"Disponibles : {sorted(t.value for t in available)}"
178
+ ),
179
+ ))
180
+
181
+ # 3.b — références inputs_from
182
+ for ref_type, ref_step in step.inputs_from.items():
183
+ if ref_type not in step.input_types:
184
+ errors.append(ValidationError(
185
+ step_id=step.id,
186
+ code="inputs_from_unused",
187
+ message=(
188
+ f"inputs_from[{ref_type.value}]={ref_step!r} "
189
+ "mais l'étape ne consomme pas ce type "
190
+ f"(input_types = {[t.value for t in step.input_types]})"
191
+ ),
192
+ ))
193
+ continue
194
+ if ref_step not in step_outputs:
195
+ errors.append(ValidationError(
196
+ step_id=step.id,
197
+ code="unknown_input_source",
198
+ message=(
199
+ f"inputs_from[{ref_type.value}]={ref_step!r} "
200
+ "ne désigne pas une étape antérieure connue "
201
+ f"({INITIAL_STEP_ID!r} pour les entrées initiales)"
202
+ ),
203
+ ))
204
+ continue
205
+ if ref_type not in step_outputs[ref_step]:
206
+ errors.append(ValidationError(
207
+ step_id=step.id,
208
+ code="source_does_not_produce_type",
209
+ message=(
210
+ f"inputs_from[{ref_type.value}]={ref_step!r} "
211
+ f"mais '{ref_step}' ne produit pas {ref_type.value!r}"
212
+ ),
213
+ ))
214
+
215
+ return errors
216
+
217
+
218
+ __all__ = ["validate_spec", "ValidationError"]
picarones/pipeline/yaml_io.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sérialisation YAML des ``PipelineSpec`` — Sprint A14-S6.
2
+
3
+ Helpers de chargement / écriture YAML. Volontairement minces —
4
+ ``pydantic.model_dump()`` produit déjà un dict imbriqué
5
+ sérialisable, et ``yaml.safe_dump`` / ``yaml.safe_load`` sont
6
+ suffisants pour le contrat round-trip.
7
+
8
+ Pourquoi un module dédié plutôt qu'une méthode de classe ?
9
+ ----------------------------------------------------------
10
+ Le ``domain/`` ne doit pas dépendre de PyYAML — c'est une lib
11
+ externe que la couche layer permet seulement à ``formats/``,
12
+ ``app/`` et adjacents. ``pipeline/`` peut importer pyyaml
13
+ (autorisé par les règles du S3), donc le helper vit ici.
14
+
15
+ API :
16
+
17
+ >>> from picarones.pipeline import dump_spec_to_yaml, load_spec_from_yaml
18
+ >>> text = dump_spec_to_yaml(spec)
19
+ >>> spec2 = load_spec_from_yaml(text)
20
+ >>> spec == spec2
21
+ True
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import yaml
27
+
28
+ from picarones.pipeline.spec import PipelineSpec
29
+
30
+
31
+ def dump_spec_to_yaml(spec: PipelineSpec) -> str:
32
+ """Sérialise une ``PipelineSpec`` en YAML déterministe.
33
+
34
+ Le YAML produit est compatible avec ``load_spec_from_yaml``
35
+ et conserve l'ordre des champs et des étapes.
36
+ """
37
+ payload = spec.model_dump(mode="json")
38
+ return yaml.safe_dump(
39
+ payload,
40
+ sort_keys=False, # conserve l'ordre des champs
41
+ allow_unicode=True, # préserve accents et caractères spéciaux
42
+ default_flow_style=False, # style "block" lisible
43
+ )
44
+
45
+
46
+ def load_spec_from_yaml(text: str) -> PipelineSpec:
47
+ """Parse une chaîne YAML et retourne une ``PipelineSpec`` validée.
48
+
49
+ Lève ``pydantic.ValidationError`` si le YAML ne respecte pas
50
+ le schéma, ou ``yaml.YAMLError`` si le YAML est mal formé.
51
+ """
52
+ payload = yaml.safe_load(text)
53
+ if payload is None:
54
+ from picarones.domain.errors import PicaronesError
55
+ raise PicaronesError("YAML vide — pas de PipelineSpec à charger")
56
+ return PipelineSpec.model_validate(payload)
57
+
58
+
59
+ __all__ = ["dump_spec_to_yaml", "load_spec_from_yaml"]
tests/architecture/test_layer_dependencies.py CHANGED
@@ -90,6 +90,11 @@ EXTERNAL_ALLOWED: dict[str, frozenset[str]] = {
90
  "pipeline": frozenset({
91
  "pydantic", "typing_extensions", "annotated_types",
92
  "numpy", "scipy",
 
 
 
 
 
93
  }),
94
  "formats": frozenset({
95
  "pydantic", "typing_extensions", "annotated_types",
 
90
  "pipeline": frozenset({
91
  "pydantic", "typing_extensions", "annotated_types",
92
  "numpy", "scipy",
93
+ # S6 — yaml pour la sérialisation YAML des PipelineSpec
94
+ # (cf. picarones/pipeline/yaml_io.py). Versionner les
95
+ # pipelines en git en YAML est un cas d'usage explicite du
96
+ # rewrite, justifie l'ajout à la whitelist.
97
+ "yaml",
98
  }),
99
  "formats": frozenset({
100
  "pydantic", "typing_extensions", "annotated_types",
tests/pipeline/__init__.py ADDED
File without changes
tests/pipeline/test_sprint_a14_s6_protocols.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sprint A14-S6 — protocoles ``StepExecutor`` + types runtime.
2
+
3
+ Vérifie que :
4
+
5
+ - une classe minimale satisfait ``StepExecutor`` ;
6
+ - ``RunContext``, ``StepResult``, ``PipelineResult`` se construisent
7
+ et sérialisent ;
8
+ - ``isinstance(x, StepExecutor)`` rejette les classes non-conformes.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import pytest
14
+
15
+ from picarones.domain import Artifact, ArtifactType
16
+ from picarones.pipeline import (
17
+ PipelineResult,
18
+ RunContext,
19
+ StepExecutor,
20
+ StepResult,
21
+ )
22
+
23
+
24
+ # ──────────────────────────────────────────────────────────────────────
25
+ # RunContext
26
+ # ──────────────────────────────────────────────────────────────────────
27
+
28
+
29
+ class TestRunContext:
30
+ def test_minimal_context(self) -> None:
31
+ ctx = RunContext(
32
+ document_id="d1",
33
+ code_version="1.0.0",
34
+ pipeline_name="ocr_only",
35
+ )
36
+ assert ctx.workspace_uri is None
37
+
38
+ def test_with_workspace(self) -> None:
39
+ ctx = RunContext(
40
+ document_id="d1",
41
+ code_version="1.0.0",
42
+ pipeline_name="ocr_only",
43
+ workspace_uri="/tmp/picarones/runs/abc",
44
+ )
45
+ assert ctx.workspace_uri == "/tmp/picarones/runs/abc"
46
+
47
+ def test_frozen(self) -> None:
48
+ ctx = RunContext(document_id="d", code_version="v", pipeline_name="p")
49
+ with pytest.raises(Exception):
50
+ ctx.document_id = "x" # type: ignore[misc]
51
+
52
+
53
+ # ──────────────────────────────────────────────────────────────────────
54
+ # StepResult & PipelineResult
55
+ # ──────────────────────────────────────────────────────────────────────
56
+
57
+
58
+ class TestStepResult:
59
+ def test_success(self) -> None:
60
+ r = StepResult(
61
+ step_id="ocr",
62
+ succeeded=True,
63
+ duration_seconds=2.5,
64
+ produced_artifacts={"raw_text": "d1:ocr:raw_text"},
65
+ )
66
+ assert r.succeeded
67
+ assert r.error is None
68
+
69
+ def test_failure(self) -> None:
70
+ r = StepResult(
71
+ step_id="ocr",
72
+ succeeded=False,
73
+ duration_seconds=0.1,
74
+ error="Tesseract introuvable",
75
+ )
76
+ assert not r.succeeded
77
+ assert r.produced_artifacts == {}
78
+ assert r.error == "Tesseract introuvable"
79
+
80
+ def test_negative_duration_rejected(self) -> None:
81
+ with pytest.raises(Exception):
82
+ StepResult(step_id="x", succeeded=True, duration_seconds=-1.0)
83
+
84
+
85
+ class TestPipelineResult:
86
+ def test_with_artifacts(self) -> None:
87
+ a = Artifact(id="d1:ocr:raw_text", document_id="d1",
88
+ type=ArtifactType.RAW_TEXT)
89
+ b = Artifact(id="d1:ocr:alto_xml", document_id="d1",
90
+ type=ArtifactType.ALTO_XML)
91
+ result = PipelineResult(
92
+ pipeline_name="ocr_only",
93
+ document_id="d1",
94
+ step_results=(
95
+ StepResult(step_id="ocr", succeeded=True, duration_seconds=1.0,
96
+ produced_artifacts={
97
+ "raw_text": a.id, "alto_xml": b.id,
98
+ }),
99
+ ),
100
+ succeeded=True,
101
+ duration_seconds=1.05,
102
+ artifacts=(a, b),
103
+ )
104
+ assert result.step_result_by_id("ocr") is not None
105
+ assert result.step_result_by_id("missing") is None
106
+ text_arts = result.artifacts_of_type(ArtifactType.RAW_TEXT)
107
+ assert len(text_arts) == 1
108
+ assert text_arts[0].id == a.id
109
+
110
+
111
+ # ──────────────────────────────────────────────────────────────────────
112
+ # StepExecutor protocol
113
+ # ──────────────────────────────────────────────────────────────────────
114
+
115
+
116
+ class _StubExecutor:
117
+ """Minimum pour satisfaire ``StepExecutor``."""
118
+
119
+ name = "tesseract"
120
+ input_types = frozenset({ArtifactType.IMAGE})
121
+ output_types = frozenset({ArtifactType.RAW_TEXT})
122
+ execution_mode = "cpu"
123
+
124
+ def execute(
125
+ self,
126
+ inputs: dict[ArtifactType, Artifact],
127
+ params: dict[str, str | int | float | bool],
128
+ context: RunContext,
129
+ ) -> dict[ArtifactType, Artifact]:
130
+ image = inputs[ArtifactType.IMAGE]
131
+ return {
132
+ ArtifactType.RAW_TEXT: Artifact(
133
+ id=f"{context.document_id}:tesseract:raw_text",
134
+ document_id=context.document_id,
135
+ type=ArtifactType.RAW_TEXT,
136
+ produced_by_step="ocr",
137
+ ),
138
+ }
139
+
140
+
141
+ class TestStepExecutorProtocol:
142
+ def test_stub_satisfies_protocol(self) -> None:
143
+ ex = _StubExecutor()
144
+ assert isinstance(ex, StepExecutor)
145
+
146
+ def test_non_conforming_does_not_satisfy(self) -> None:
147
+ class _NotAnExecutor:
148
+ pass
149
+ assert not isinstance(_NotAnExecutor(), StepExecutor)
150
+
151
+ def test_stub_can_execute(self) -> None:
152
+ ex = _StubExecutor()
153
+ ctx = RunContext(document_id="d1", code_version="v", pipeline_name="p")
154
+ img = Artifact(id="d1:img", document_id="d1", type=ArtifactType.IMAGE)
155
+ out = ex.execute({ArtifactType.IMAGE: img}, {}, ctx)
156
+ assert ArtifactType.RAW_TEXT in out
157
+ assert out[ArtifactType.RAW_TEXT].document_id == "d1"
tests/pipeline/test_sprint_a14_s6_spec.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sprint A14-S6 — ``PipelineStep``, ``PipelineSpec`` (déclaratifs)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from picarones.domain import ArtifactType, PicaronesError
8
+ from picarones.pipeline import INITIAL_STEP_ID, PipelineSpec, PipelineStep
9
+
10
+
11
+ # ──────────────────────────────────────────────────────────────────────
12
+ # PipelineStep — validation des id et champs
13
+ # ──────────────────────────────────────────────────────────────────────
14
+
15
+
16
+ class TestPipelineStep:
17
+ def test_minimal_step(self) -> None:
18
+ s = PipelineStep(
19
+ id="ocr",
20
+ kind="ocr",
21
+ adapter_name="tesseract",
22
+ input_types=(ArtifactType.IMAGE,),
23
+ output_types=(ArtifactType.RAW_TEXT,),
24
+ )
25
+ assert s.id == "ocr"
26
+ assert s.params == {}
27
+ assert s.inputs_from == {}
28
+
29
+ def test_step_with_inputs_from(self) -> None:
30
+ s = PipelineStep(
31
+ id="correction",
32
+ kind="post_correction",
33
+ adapter_name="openai:gpt-4o",
34
+ input_types=(ArtifactType.RAW_TEXT,),
35
+ output_types=(ArtifactType.CORRECTED_TEXT,),
36
+ inputs_from={ArtifactType.RAW_TEXT: "ocr"},
37
+ )
38
+ assert s.inputs_from[ArtifactType.RAW_TEXT] == "ocr"
39
+
40
+ def test_step_with_params(self) -> None:
41
+ s = PipelineStep(
42
+ id="ocr",
43
+ kind="ocr",
44
+ adapter_name="tesseract",
45
+ params={"lang": "fra", "psm": 6, "preserve_interword_spaces": True},
46
+ )
47
+ assert s.params["lang"] == "fra"
48
+ assert s.params["psm"] == 6
49
+
50
+ def test_id_validation_rejects_space(self) -> None:
51
+ with pytest.raises(PicaronesError, match="step id invalide"):
52
+ PipelineStep(id="bad id", kind="x", adapter_name="y")
53
+
54
+ def test_id_validation_rejects_dot(self) -> None:
55
+ with pytest.raises(PicaronesError, match="step id invalide"):
56
+ PipelineStep(id="bad.id", kind="x", adapter_name="y")
57
+
58
+ def test_id_validation_rejects_initial_sentinel(self) -> None:
59
+ """``__initial__`` est réservé pour désigner les entrées
60
+ initiales du runner — un step ne peut pas porter ce nom."""
61
+ with pytest.raises(PicaronesError, match="réservé"):
62
+ PipelineStep(id=INITIAL_STEP_ID, kind="x", adapter_name="y")
63
+
64
+ def test_id_accepts_alphanum_underscore_dash(self) -> None:
65
+ s = PipelineStep(id="step_1-final", kind="x", adapter_name="y")
66
+ assert s.id == "step_1-final"
67
+
68
+ def test_frozen(self) -> None:
69
+ s = PipelineStep(id="a", kind="b", adapter_name="c")
70
+ with pytest.raises(Exception):
71
+ s.id = "d" # type: ignore[misc]
72
+
73
+ def test_extra_field_rejected(self) -> None:
74
+ with pytest.raises(Exception):
75
+ PipelineStep( # type: ignore[call-arg]
76
+ id="a", kind="b", adapter_name="c", bogus=42,
77
+ )
78
+
79
+
80
+ # ──────────────────────────────────────────────────────────────────────
81
+ # PipelineSpec
82
+ # ──────────────────────────────────────────────────────────────────────
83
+
84
+
85
+ class TestPipelineSpec:
86
+ def test_minimal_spec(self) -> None:
87
+ s = PipelineSpec(name="empty")
88
+ assert s.name == "empty"
89
+ assert s.steps == ()
90
+ assert s.initial_inputs == ()
91
+
92
+ def test_spec_with_steps(self) -> None:
93
+ s = PipelineSpec(
94
+ name="ocr_only",
95
+ initial_inputs=(ArtifactType.IMAGE,),
96
+ steps=(
97
+ PipelineStep(
98
+ id="ocr",
99
+ kind="ocr",
100
+ adapter_name="tesseract",
101
+ input_types=(ArtifactType.IMAGE,),
102
+ output_types=(ArtifactType.RAW_TEXT,),
103
+ ),
104
+ ),
105
+ )
106
+ assert len(s.steps) == 1
107
+ assert s.step_by_id("ocr") is not None
108
+ assert s.step_by_id("missing") is None
109
+
110
+ def test_frozen(self) -> None:
111
+ s = PipelineSpec(name="x")
112
+ with pytest.raises(Exception):
113
+ s.name = "y" # type: ignore[misc]
tests/pipeline/test_sprint_a14_s6_validation.py ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sprint A14-S6 — ``validate_spec``.
2
+
3
+ Couvre les ~12 cas typiques : chaîne valide, type manquant,
4
+ adapter inconnu, fork avec ``inputs_from``, références invalides,
5
+ DAG vide, IDs dupliqués.
6
+
7
+ Aucun ``StepExecutor`` instancié — la validation est purement
8
+ statique sur la spec.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from picarones.domain import ArtifactType
14
+ from picarones.pipeline import (
15
+ INITIAL_STEP_ID,
16
+ PipelineSpec,
17
+ PipelineStep,
18
+ validate_spec,
19
+ )
20
+
21
+
22
+ # ──────────────────────────────────────────────────────────────────────
23
+ # Cas valides
24
+ # ──────────────────────────────────────────────────────────────────────
25
+
26
+
27
+ class TestValidSpecs:
28
+ def test_simple_ocr_pipeline(self) -> None:
29
+ spec = PipelineSpec(
30
+ name="ocr_only",
31
+ initial_inputs=(ArtifactType.IMAGE,),
32
+ steps=(
33
+ PipelineStep(
34
+ id="ocr", kind="ocr", adapter_name="tesseract",
35
+ input_types=(ArtifactType.IMAGE,),
36
+ output_types=(ArtifactType.RAW_TEXT,),
37
+ ),
38
+ ),
39
+ )
40
+ assert validate_spec(spec) == []
41
+
42
+ def test_ocr_then_llm(self) -> None:
43
+ spec = PipelineSpec(
44
+ name="ocr_llm",
45
+ initial_inputs=(ArtifactType.IMAGE,),
46
+ steps=(
47
+ PipelineStep(
48
+ id="ocr", kind="ocr", adapter_name="tesseract",
49
+ input_types=(ArtifactType.IMAGE,),
50
+ output_types=(ArtifactType.RAW_TEXT,),
51
+ ),
52
+ PipelineStep(
53
+ id="correct", kind="post_correction",
54
+ adapter_name="openai:gpt-4o",
55
+ input_types=(ArtifactType.RAW_TEXT,),
56
+ output_types=(ArtifactType.CORRECTED_TEXT,),
57
+ ),
58
+ ),
59
+ )
60
+ assert validate_spec(spec) == []
61
+
62
+ def test_def_of_done_tesseract_llm_alto_remap(self) -> None:
63
+ """Définition de done du S6 : valider le YAML cible BnF."""
64
+ spec = PipelineSpec(
65
+ name="tesseract_llm_alto_remap",
66
+ initial_inputs=(ArtifactType.IMAGE,),
67
+ steps=(
68
+ PipelineStep(
69
+ id="ocr", kind="ocr", adapter_name="tesseract",
70
+ input_types=(ArtifactType.IMAGE,),
71
+ output_types=(ArtifactType.RAW_TEXT, ArtifactType.ALTO_XML),
72
+ ),
73
+ PipelineStep(
74
+ id="correction", kind="post_correction",
75
+ adapter_name="openai:gpt-4o",
76
+ input_types=(ArtifactType.RAW_TEXT,),
77
+ output_types=(ArtifactType.CORRECTED_TEXT,),
78
+ inputs_from={ArtifactType.RAW_TEXT: "ocr"},
79
+ ),
80
+ PipelineStep(
81
+ id="alto_remap", kind="alto_remapping",
82
+ adapter_name="picarones-contrib:line_remapper",
83
+ input_types=(
84
+ ArtifactType.CORRECTED_TEXT, ArtifactType.ALTO_XML,
85
+ ),
86
+ output_types=(ArtifactType.ALTO_XML,),
87
+ inputs_from={
88
+ ArtifactType.CORRECTED_TEXT: "correction",
89
+ ArtifactType.ALTO_XML: "ocr",
90
+ },
91
+ ),
92
+ ),
93
+ )
94
+ assert validate_spec(spec) == []
95
+
96
+ def test_inputs_from_initial_explicit(self) -> None:
97
+ """Une étape peut référencer explicitement les entrées
98
+ initiales via ``__initial__``."""
99
+ spec = PipelineSpec(
100
+ name="explicit_initial",
101
+ initial_inputs=(ArtifactType.IMAGE,),
102
+ steps=(
103
+ PipelineStep(
104
+ id="ocr", kind="ocr", adapter_name="tesseract",
105
+ input_types=(ArtifactType.IMAGE,),
106
+ output_types=(ArtifactType.RAW_TEXT,),
107
+ inputs_from={ArtifactType.IMAGE: INITIAL_STEP_ID},
108
+ ),
109
+ ),
110
+ )
111
+ assert validate_spec(spec) == []
112
+
113
+
114
+ # ──────────────────────────────────────────────────────────────────────
115
+ # Cas invalides
116
+ # ──────────────────────────────────────────────────────────────────────
117
+
118
+
119
+ class TestInvalidSpecs:
120
+ def test_empty_pipeline(self) -> None:
121
+ spec = PipelineSpec(name="empty")
122
+ errors = validate_spec(spec)
123
+ assert len(errors) == 1
124
+ assert errors[0].code == "empty_pipeline"
125
+
126
+ def test_missing_input_no_initial(self) -> None:
127
+ """Une étape qui demande IMAGE mais initial_inputs vide."""
128
+ spec = PipelineSpec(
129
+ name="missing_image",
130
+ initial_inputs=(),
131
+ steps=(
132
+ PipelineStep(
133
+ id="ocr", kind="ocr", adapter_name="tesseract",
134
+ input_types=(ArtifactType.IMAGE,),
135
+ output_types=(ArtifactType.RAW_TEXT,),
136
+ ),
137
+ ),
138
+ )
139
+ errors = validate_spec(spec)
140
+ codes = [e.code for e in errors]
141
+ assert "missing_input" in codes
142
+
143
+ def test_missing_input_step_order_wrong(self) -> None:
144
+ """L'étape de correction est avant l'OCR — le RAW_TEXT n'existe
145
+ pas encore."""
146
+ spec = PipelineSpec(
147
+ name="wrong_order",
148
+ initial_inputs=(ArtifactType.IMAGE,),
149
+ steps=(
150
+ PipelineStep(
151
+ id="correct", kind="post_correction",
152
+ adapter_name="openai",
153
+ input_types=(ArtifactType.RAW_TEXT,),
154
+ output_types=(ArtifactType.CORRECTED_TEXT,),
155
+ ),
156
+ PipelineStep(
157
+ id="ocr", kind="ocr", adapter_name="tesseract",
158
+ input_types=(ArtifactType.IMAGE,),
159
+ output_types=(ArtifactType.RAW_TEXT,),
160
+ ),
161
+ ),
162
+ )
163
+ errors = validate_spec(spec)
164
+ codes = [e.code for e in errors]
165
+ assert "missing_input" in codes
166
+ # La première étape (correct) doit être le step_id signalé.
167
+ missing = [e for e in errors if e.code == "missing_input"]
168
+ assert any(e.step_id == "correct" for e in missing)
169
+
170
+ def test_duplicate_step_id(self) -> None:
171
+ spec = PipelineSpec(
172
+ name="dup",
173
+ initial_inputs=(ArtifactType.IMAGE,),
174
+ steps=(
175
+ PipelineStep(
176
+ id="step", kind="ocr", adapter_name="a",
177
+ input_types=(ArtifactType.IMAGE,),
178
+ output_types=(ArtifactType.RAW_TEXT,),
179
+ ),
180
+ PipelineStep(
181
+ id="step", kind="post_correction", adapter_name="b",
182
+ input_types=(ArtifactType.RAW_TEXT,),
183
+ output_types=(ArtifactType.CORRECTED_TEXT,),
184
+ ),
185
+ ),
186
+ )
187
+ errors = validate_spec(spec)
188
+ codes = [e.code for e in errors]
189
+ assert "duplicate_id" in codes
190
+
191
+ def test_unknown_adapter_when_registry_provided(self) -> None:
192
+ spec = PipelineSpec(
193
+ name="unknown",
194
+ initial_inputs=(ArtifactType.IMAGE,),
195
+ steps=(
196
+ PipelineStep(
197
+ id="ocr", kind="ocr", adapter_name="not_in_registry",
198
+ input_types=(ArtifactType.IMAGE,),
199
+ output_types=(ArtifactType.RAW_TEXT,),
200
+ ),
201
+ ),
202
+ )
203
+ errors = validate_spec(spec, available_adapters={"tesseract"})
204
+ codes = [e.code for e in errors]
205
+ assert "unknown_adapter" in codes
206
+
207
+ def test_no_adapter_check_when_registry_none(self) -> None:
208
+ """Si available_adapters=None, on ne vérifie pas les adapters."""
209
+ spec = PipelineSpec(
210
+ name="x",
211
+ initial_inputs=(ArtifactType.IMAGE,),
212
+ steps=(
213
+ PipelineStep(
214
+ id="ocr", kind="ocr", adapter_name="not_registered_anywhere",
215
+ input_types=(ArtifactType.IMAGE,),
216
+ output_types=(ArtifactType.RAW_TEXT,),
217
+ ),
218
+ ),
219
+ )
220
+ errors = validate_spec(spec) # registry=None
221
+ codes = [e.code for e in errors]
222
+ assert "unknown_adapter" not in codes
223
+
224
+ def test_inputs_from_unused_type(self) -> None:
225
+ """Une étape déclare ``inputs_from[X]`` mais X n'est pas dans
226
+ son ``input_types``."""
227
+ spec = PipelineSpec(
228
+ name="x",
229
+ initial_inputs=(ArtifactType.IMAGE,),
230
+ steps=(
231
+ PipelineStep(
232
+ id="ocr", kind="ocr", adapter_name="tess",
233
+ input_types=(ArtifactType.IMAGE,),
234
+ output_types=(ArtifactType.RAW_TEXT,),
235
+ inputs_from={ArtifactType.ALTO_XML: INITIAL_STEP_ID},
236
+ ),
237
+ ),
238
+ )
239
+ errors = validate_spec(spec)
240
+ codes = [e.code for e in errors]
241
+ assert "inputs_from_unused" in codes
242
+
243
+ def test_unknown_input_source(self) -> None:
244
+ """``inputs_from[type] = "ghost"`` mais ``ghost`` n'existe pas."""
245
+ spec = PipelineSpec(
246
+ name="x",
247
+ initial_inputs=(ArtifactType.IMAGE,),
248
+ steps=(
249
+ PipelineStep(
250
+ id="ocr", kind="ocr", adapter_name="tess",
251
+ input_types=(ArtifactType.IMAGE,),
252
+ output_types=(ArtifactType.RAW_TEXT,),
253
+ inputs_from={ArtifactType.IMAGE: "ghost"},
254
+ ),
255
+ ),
256
+ )
257
+ errors = validate_spec(spec)
258
+ codes = [e.code for e in errors]
259
+ assert "unknown_input_source" in codes
260
+
261
+ def test_source_does_not_produce_type(self) -> None:
262
+ """``inputs_from[ALTO_XML] = "ocr"`` mais ``ocr`` ne produit que
263
+ ``RAW_TEXT``."""
264
+ spec = PipelineSpec(
265
+ name="x",
266
+ initial_inputs=(ArtifactType.IMAGE,),
267
+ steps=(
268
+ PipelineStep(
269
+ id="ocr", kind="ocr", adapter_name="tess",
270
+ input_types=(ArtifactType.IMAGE,),
271
+ output_types=(ArtifactType.RAW_TEXT,),
272
+ ),
273
+ PipelineStep(
274
+ id="alto_consumer", kind="x", adapter_name="y",
275
+ input_types=(ArtifactType.ALTO_XML,),
276
+ output_types=(ArtifactType.ALTO_XML,),
277
+ inputs_from={ArtifactType.ALTO_XML: "ocr"},
278
+ ),
279
+ ),
280
+ )
281
+ errors = validate_spec(spec)
282
+ codes = [e.code for e in errors]
283
+ assert "source_does_not_produce_type" in codes
284
+ # En plus, ALTO_XML n'est pas disponible dans le bag → missing_input
285
+ # peut aussi être levé.
286
+
287
+ def test_multiple_errors_at_once(self) -> None:
288
+ """``validate_spec`` ne s'arrête pas à la première erreur."""
289
+ spec = PipelineSpec(
290
+ name="multi_errors",
291
+ initial_inputs=(),
292
+ steps=(
293
+ PipelineStep(
294
+ id="dup", kind="x", adapter_name="a",
295
+ input_types=(ArtifactType.IMAGE,),
296
+ output_types=(),
297
+ ),
298
+ PipelineStep(
299
+ id="dup", kind="y", adapter_name="b",
300
+ input_types=(ArtifactType.RAW_TEXT,),
301
+ output_types=(),
302
+ ),
303
+ ),
304
+ )
305
+ errors = validate_spec(spec)
306
+ codes = [e.code for e in errors]
307
+ assert "duplicate_id" in codes
308
+ assert "missing_input" in codes # IMAGE et RAW_TEXT manquants
tests/pipeline/test_sprint_a14_s6_yaml_roundtrip.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sprint A14-S6 — round-trip YAML d'une ``PipelineSpec``.
2
+
3
+ Garantit que ``dump_spec_to_yaml(spec)`` produit du YAML qui se
4
+ recharge en une spec strictement égale. C'est la propriété qui
5
+ permet de versionner les pipelines en git de façon
6
+ human-readable + machine-actionable.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import pytest
12
+
13
+ from picarones.domain import ArtifactType, PicaronesError
14
+ from picarones.pipeline import (
15
+ PipelineSpec,
16
+ PipelineStep,
17
+ dump_spec_to_yaml,
18
+ load_spec_from_yaml,
19
+ )
20
+
21
+
22
+ def _ocr_only_spec() -> PipelineSpec:
23
+ return PipelineSpec(
24
+ name="ocr_only",
25
+ description="Tesseract sur image patrimoniale.",
26
+ initial_inputs=(ArtifactType.IMAGE,),
27
+ steps=(
28
+ PipelineStep(
29
+ id="ocr",
30
+ kind="ocr",
31
+ adapter_name="tesseract",
32
+ params={"lang": "fra", "psm": 6},
33
+ input_types=(ArtifactType.IMAGE,),
34
+ output_types=(ArtifactType.RAW_TEXT,),
35
+ ),
36
+ ),
37
+ )
38
+
39
+
40
+ def _full_pipeline_spec() -> PipelineSpec:
41
+ return PipelineSpec(
42
+ name="tesseract_llm_alto_remap",
43
+ description="OCR + LLM + remapping ALTO (cas BnF central).",
44
+ initial_inputs=(ArtifactType.IMAGE,),
45
+ steps=(
46
+ PipelineStep(
47
+ id="ocr",
48
+ kind="ocr",
49
+ adapter_name="tesseract",
50
+ params={"lang": "fra"},
51
+ input_types=(ArtifactType.IMAGE,),
52
+ output_types=(ArtifactType.RAW_TEXT, ArtifactType.ALTO_XML),
53
+ ),
54
+ PipelineStep(
55
+ id="correction",
56
+ kind="post_correction",
57
+ adapter_name="openai:gpt-4o",
58
+ params={"temperature": 0.0, "max_tokens": 4096},
59
+ input_types=(ArtifactType.RAW_TEXT,),
60
+ output_types=(ArtifactType.CORRECTED_TEXT,),
61
+ inputs_from={ArtifactType.RAW_TEXT: "ocr"},
62
+ ),
63
+ PipelineStep(
64
+ id="alto_remap",
65
+ kind="alto_remapping",
66
+ adapter_name="picarones-contrib:line_remapper",
67
+ input_types=(
68
+ ArtifactType.CORRECTED_TEXT, ArtifactType.ALTO_XML,
69
+ ),
70
+ output_types=(ArtifactType.ALTO_XML,),
71
+ inputs_from={
72
+ ArtifactType.CORRECTED_TEXT: "correction",
73
+ ArtifactType.ALTO_XML: "ocr",
74
+ },
75
+ ),
76
+ ),
77
+ )
78
+
79
+
80
+ class TestYAMLRoundtrip:
81
+ @pytest.mark.parametrize("spec_factory", [_ocr_only_spec, _full_pipeline_spec])
82
+ def test_roundtrip_preserves_equality(self, spec_factory) -> None:
83
+ spec = spec_factory()
84
+ yml = dump_spec_to_yaml(spec)
85
+ spec2 = load_spec_from_yaml(yml)
86
+ assert spec == spec2
87
+
88
+ def test_roundtrip_is_idempotent(self) -> None:
89
+ """Dump → Load → Dump produit le même YAML byte-pour-byte."""
90
+ spec = _full_pipeline_spec()
91
+ yml1 = dump_spec_to_yaml(spec)
92
+ spec2 = load_spec_from_yaml(yml1)
93
+ yml2 = dump_spec_to_yaml(spec2)
94
+ assert yml1 == yml2
95
+
96
+ def test_yaml_is_human_readable(self) -> None:
97
+ """Le YAML produit doit utiliser le style 'block' (un champ
98
+ par ligne), pas le style 'flow' (JSON-like)."""
99
+ yml = dump_spec_to_yaml(_full_pipeline_spec())
100
+ assert "name: tesseract_llm_alto_remap" in yml
101
+ assert "steps:" in yml
102
+ # Pas de "{" pour signaler le style block.
103
+ # Les ``params`` peuvent encore contenir des ``{}`` quand le
104
+ # dict est vide ; on vérifie juste que le format général
105
+ # est lisible.
106
+ assert "- id: ocr" in yml
107
+
108
+ def test_empty_yaml_raises(self) -> None:
109
+ with pytest.raises(PicaronesError, match="vide"):
110
+ load_spec_from_yaml("")
111
+
112
+ def test_yaml_ordered_fields(self) -> None:
113
+ """``sort_keys=False`` doit être respecté."""
114
+ yml = dump_spec_to_yaml(_ocr_only_spec())
115
+ # Dans la spec, ``name`` apparaît avant ``description``,
116
+ # ``initial_inputs`` avant ``steps``.
117
+ i_name = yml.index("name:")
118
+ i_desc = yml.index("description:")
119
+ i_init = yml.index("initial_inputs:")
120
+ i_steps = yml.index("steps:")
121
+ assert i_name < i_desc < i_init < i_steps
122
+
123
+ def test_invalid_yaml_raises(self) -> None:
124
+ """Un YAML qui ne respecte pas le schéma de PipelineSpec
125
+ lève une ValidationError pydantic."""
126
+ bad = "name: x\nsteps:\n - id: ocr\n kind: ocr\n adapter_name: x\n input_types: [bogus_type]\n"
127
+ with pytest.raises(Exception): # pydantic ValidationError
128
+ load_spec_from_yaml(bad)