Picarones / tests /architecture /test_manifest_reproducibility.py
Claude
feat: audit S59 institutionnel — 2 BLOCKER + 4 HIGH + 3 MEDIUM corrigés
d4d4112 unverified
Raw
History Blame
4.63 kB
"""Garde-fou de reproductibilité du ``RunManifest``.
L'audit S58 a relevé que ``RunManifest.dependencies_lock`` n'était
jamais peuplé et que ``pipeline_specs`` ne contenait que les noms,
rompant la promesse documentée *« à code_version + corpus + specs +
dependencies_lock identiques, ré-exécuter doit donner les mêmes
résultats »*.
Ces tests verrouillent le contrat :
1. ``capture_dependencies_lock()`` retourne un dict non vide trié.
2. ``RunManifest`` accepte des ``pipeline_specs`` complètes (steps,
adapter_name, params, inputs_from), pas seulement des noms.
3. ``adapter_kwargs`` permet de reconstituer les constructeurs
d'adapters (model, temperature, etc.).
4. La sérialisation est déterministe : deux manifests à entrée
identique produisent les mêmes octets JSON.
"""
from __future__ import annotations
from datetime import datetime, timezone
from picarones.app.services.dependencies import capture_dependencies_lock
from picarones.domain.artifacts import ArtifactType
from picarones.domain.pipeline_spec import PipelineSpec, PipelineStep
from picarones.domain.run_manifest import RunManifest
def test_capture_dependencies_lock_non_empty_and_sorted() -> None:
"""``capture_dependencies_lock()`` retourne ≥ 1 paquet (pydantic
au minimum) et trié alphabétiquement (case-insensitive).
"""
lock = capture_dependencies_lock()
assert len(lock) > 0, "lock vide — picarones lui-même doit être listé."
keys = list(lock.keys())
assert keys == sorted(keys, key=str.lower), (
"lock non trié — le manifest ne sera pas bit-for-bit "
"reproductible cross-environnement."
)
# pydantic est une dépendance ferme du projet — sa présence prouve
# que la capture marche sur l'env réel.
assert any(k.lower() == "pydantic" for k in lock)
def test_run_manifest_carries_full_pipeline_specs() -> None:
"""Le manifest doit porter les ``PipelineSpec`` complètes, pas
seulement les noms. Sans ça, un relecteur 5 ans plus tard ne peut
pas reconstituer le DAG sans accès au YAML d'origine.
"""
step = PipelineStep(
id="ocr",
kind="ocr",
adapter_name="tesseract",
input_types=(ArtifactType.IMAGE,),
output_types=(ArtifactType.RAW_TEXT,),
params={"lang": "fra"},
)
spec = PipelineSpec(name="tess_only", steps=(step,))
manifest = RunManifest(
run_id="r1",
corpus_name="c1",
n_documents=1,
pipeline_specs=(spec,),
adapter_kwargs={"tesseract": {"lang": "fra", "psm": 6}},
view_specs=(),
code_version="1.0.0-test",
started_at=datetime.now(tz=timezone.utc),
completed_at=datetime.now(tz=timezone.utc),
dependencies_lock={"pydantic": "2.5.0"},
)
assert manifest.pipeline_specs == (spec,)
# Vue rétrocompat dérivée des specs.
assert manifest.pipeline_names == ("tess_only",)
# Les kwargs d'instanciation sont tracés.
assert manifest.adapter_kwargs["tesseract"]["psm"] == 6
# Le step complet est reconstituable.
assert manifest.pipeline_specs[0].steps[0].params == {"lang": "fra"}
def test_run_manifest_serialization_is_deterministic() -> None:
"""Deux manifests à entrée identique produisent les mêmes
octets JSON — pré-requis pour le hash d'intégrité que la BnF
peut citer dans une publication.
"""
common = dict(
run_id="r1",
corpus_name="c1",
n_documents=42,
pipeline_specs=(),
adapter_kwargs={"a": {"k": 1}, "b": {"k": 2}},
view_specs=(),
code_version="1.0.0",
started_at=datetime(2026, 5, 6, tzinfo=timezone.utc),
completed_at=datetime(2026, 5, 6, tzinfo=timezone.utc),
dependencies_lock={"pkg-a": "1.0", "pkg-b": "2.0"},
metadata={"note": "test"},
)
m1 = RunManifest(**common)
m2 = RunManifest(**common)
assert m1.model_dump_json() == m2.model_dump_json()
def test_run_manifest_rejects_extra_fields() -> None:
"""``extra="forbid"`` — le contrat du manifest n'évolue pas
silencieusement. Tout nouveau champ exige un ajout explicite
au modèle (et donc une revue).
"""
import pytest
from pydantic import ValidationError
with pytest.raises(ValidationError):
RunManifest(
run_id="r1",
corpus_name="c1",
n_documents=1,
code_version="1.0",
started_at=datetime.now(tz=timezone.utc),
completed_at=datetime.now(tz=timezone.utc),
unknown_field="nope", # type: ignore[call-arg]
)