Picarones / tests /evaluation /test_public_api.py
Claude
refactor(adapters): retrait de execution_mode (mensonge structurel)
5e13c0d unverified
"""Test de stabilité de l'API publique de Picarones (Cercle 1).
Phase D du chantier de refonte en 3 cercles. Ce test est le **filet de
sécurité contractuel** documenté dans :doc:`docs/api-stable.md` : il
échoue dès qu'un nom listé dans le contrat de stabilité du Cercle 1
disparaît, change de type (class ↔ function), ou perd un argument
attendu.
Discipline
----------
Toute modification d'un test ici doit être accompagnée d'une mise à
jour de ``docs/api-stable.md`` et **justifiée par une RFC** si elle
casse la rétrocompat. Ce test est la traduction technique d'un
engagement public.
Si une PR doit ajouter un nom à l'API publique, suivre dans l'ordre :
1. Documenter le nom dans ``docs/api-stable.md``.
2. Ajouter le test correspondant ici.
3. Implémenter / exposer le nom.
Si une PR doit casser un nom de l'API publique :
1. RFC + bump majeur (``2.0.0``).
2. Mise à jour de ``docs/api-stable.md`` (suppression).
3. Mise à jour des tests ici.
Les noms historiques rétrocompat (Cercle 2 / Cercle 3 via shims) ne
sont **pas** couverts par ce test — ils ont leurs propres tests dans
``tests/test_phaseA_migration.py``, ``test_phaseB_migration.py``, etc.
"""
from __future__ import annotations
import importlib
import inspect
import pytest
# ──────────────────────────────────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────────────────────────────────
def _get_attr(module_path: str, name: str):
mod = importlib.import_module(module_path)
assert hasattr(mod, name), (
f"API publique cassée : {module_path}.{name} a disparu"
)
return getattr(mod, name)
def _assert_class(module_path: str, name: str, *, abstract: bool = False):
obj = _get_attr(module_path, name)
assert inspect.isclass(obj), (
f"{module_path}.{name} : attendu class, obtenu {type(obj).__name__}"
)
if abstract:
assert inspect.isabstract(obj) or hasattr(obj, "__abstractmethods__"), (
f"{module_path}.{name} : attendu classe abstraite"
)
return obj
def _assert_function(module_path: str, name: str):
obj = _get_attr(module_path, name)
assert callable(obj), (
f"{module_path}.{name} : attendu callable, obtenu {type(obj).__name__}"
)
return obj
# ──────────────────────────────────────────────────────────────────────────
# 1. picarones.evaluation.corpus — modèle Document/Corpus + GT multi-niveaux (canonique)
# ──────────────────────────────────────────────────────────────────────────
class TestCorpusApi:
@pytest.mark.parametrize("name", [
"Document", "Corpus",
"TextGT", "AltoGT", "PageGT", "EntitiesGT", "ReadingOrderGT",
])
def test_class_exists(self, name):
_assert_class("picarones.evaluation.corpus", name)
def test_load_corpus_from_directory_exists(self):
_assert_function("picarones.evaluation.corpus", "load_corpus_from_directory")
def test_gt_suffixes_constant(self):
from picarones.domain.artifacts import ArtifactType
from picarones.evaluation.corpus import GT_SUFFIXES
assert isinstance(GT_SUFFIXES, dict)
# Chacun des 5 niveaux GT (ArtifactType) doit avoir un suffixe
for level in (
ArtifactType.RAW_TEXT,
ArtifactType.ALTO_XML,
ArtifactType.PAGE_XML,
ArtifactType.ENTITIES,
ArtifactType.READING_ORDER,
):
assert level in GT_SUFFIXES, (
f"GT_SUFFIXES manque le niveau {level}"
)
# ──────────────────────────────────────────────────────────────────────────
# 2. picarones.domain — BaseModule + ArtifactType (canoniques)
# ──────────────────────────────────────────────────────────────────────────
class TestModulesApi:
def test_artifact_type_values(self):
from picarones.domain.artifacts import ArtifactType
names = {member.value for member in ArtifactType}
# Phase 4-bis : ``ArtifactType`` canonique (``domain.artifacts``)
# — 10 valeurs. L'ancien set legacy (``image, text, alto, page,
# entities, reading_order``) reste accessible via les aliases
# ``TEXT``/``ALTO``/``PAGE`` qui pointent vers les valeurs
# canoniques ``raw_text``/``alto_xml``/``page_xml``. Les
# aliases n'apparaissent pas dans cette itération (Python
# masque les membres aliasés dans ``__members__`` itérable).
assert names == {
"image",
"raw_text",
"corrected_text",
"alto_xml",
"page_xml",
"canonical_document",
"entities",
"reading_order",
"alignment",
"confidences",
}
def test_basemodule_is_abstract(self):
cls = _assert_class("picarones.domain.module_protocol", "BaseModule")
# Doit avoir `process` abstrait
assert "process" in cls.__abstractmethods__ or hasattr(cls, "process")
def test_basemodule_class_attributes(self):
from picarones.domain.module_protocol import BaseModule
# Contrat : ces attributs de classe sont lisibles depuis la base
assert hasattr(BaseModule, "input_types")
assert hasattr(BaseModule, "output_types")
assert hasattr(BaseModule, "validate_inputs")
assert hasattr(BaseModule, "validate_outputs")
assert hasattr(BaseModule, "metadata")
# ──────────────────────────────────────────────────────────────────────────
# 3. picarones.evaluation.benchmark_result — modèles de résultats (canonique)
# ──────────────────────────────────────────────────────────────────────────
class TestResultsApi:
@pytest.mark.parametrize("name", [
"DocumentResult", "EngineReport", "BenchmarkResult",
])
def test_class_exists(self, name):
_assert_class("picarones.evaluation.benchmark_result", name)
# ──────────────────────────────────────────────────────────────────────────
# 4. picarones.evaluation.metrics.text_metrics — métriques de base
# ──────────────────────────────────────────────────────────────────────────
class TestMetricsApi:
def test_metrics_result_class(self):
_assert_class("picarones.evaluation.metrics.text_metrics", "MetricsResult")
@pytest.mark.parametrize("name", [
"compute_metrics", "aggregate_metrics",
])
def test_function_exists(self, name):
_assert_function("picarones.evaluation.metrics.text_metrics", name)
def test_compute_metrics_signature(self):
"""``compute_metrics(reference, hypothesis, char_exclude=None)`` est
contractuel — les 2 premiers args sont positionnels, le 3ᵉ keyword."""
from picarones.evaluation.metrics.text_metrics import compute_metrics
sig = inspect.signature(compute_metrics)
params = list(sig.parameters.values())
# Au moins 2 paramètres positionnels (reference, hypothesis)
positional = [p for p in params
if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
and p.default is p.empty]
assert len(positional) >= 2, (
f"compute_metrics doit accepter >= 2 args positionnels — "
f"signature actuelle : {sig}"
)
# ──────────────────────────────────────────────────────────────────────────
# 5. (anciennement) ``picarones.app.services.benchmark_runner`` —
# supprimé en Phase B3-final (mai 2026, migration Option B).
# ──────────────────────────────────────────────────────────────────────────
# Le module ``benchmark_runner.py`` portait l'entry point legacy
# ``run_benchmark_via_service`` qui a été remplacé par
# ``picarones.RunOrchestrator`` (consommant un ``RunSpec`` Pydantic
# ou des objets domain pré-construits via ``execute_preset()``).
# Le contract test du legacy a été supprimé avec le module. Voir
# ``TestRunOrchestratorApi`` ci-dessous pour le contrat de
# l'entry point canonique actuel.
# ──────────────────────────────────────────────────────────────────────────
# 6. (anciennement) ``picarones.pipeline.legacy_*`` — supprimé en Phase 7.D
# ──────────────────────────────────────────────────────────────────────────
# Les modules ``pipeline.legacy_runner``, ``legacy_pipeline_benchmark``,
# ``legacy_pipeline_comparison`` et ``measurements.pipeline_spec_loader``
# ont été supprimés en Phase 7.D (mai 2026). L'API canonique vit dans
# ``picarones.pipeline.executor`` (``PipelineExecutor``) et
# ``picarones.domain.pipeline_spec`` (``PipelineSpec``, ``PipelineStep``).
# ──────────────────────────────────────────────────────────────────────────
# 6.bis. picarones.app.services — RunOrchestrator (Phase B3 migration Option B)
# ──────────────────────────────────────────────────────────────────────────
class TestRunOrchestratorApi:
"""Phase B3 — l'entry-point canonique pour lancer un benchmark est
désormais ``picarones.RunOrchestrator`` (consomme un ``RunSpec``).
``run_benchmark_via_service`` reste exporté mais émet une
``DeprecationWarning`` à l'appel. Retrait prévu Phase B8.
"""
def test_run_orchestrator_class_exposed_at_root(self):
"""``RunOrchestrator`` est accessible depuis le namespace racine."""
import picarones
assert hasattr(picarones, "RunOrchestrator"), (
"RunOrchestrator devrait être exporté depuis picarones (Phase B3)"
)
from picarones import RunOrchestrator
assert inspect.isclass(RunOrchestrator)
def test_run_spec_class_exposed_at_root(self):
"""``RunSpec`` Pydantic est accessible depuis le namespace racine."""
import picarones
assert hasattr(picarones, "RunSpec")
from picarones import RunSpec
assert inspect.isclass(RunSpec)
@pytest.mark.parametrize("name", [
"OrchestrationResult",
"RunOrchestrator",
"RunSpec",
"RunSpecLoadError",
"load_run_spec_from_yaml",
])
def test_all_new_exports_present(self, name):
"""Les 5 symboles ajoutés en B3 sont tous dans __all__."""
import picarones
assert name in picarones.__all__, (
f"Phase B3 — '{name}' devrait être dans picarones.__all__"
)
def test_prepare_preset_args_exposed_at_root(self):
"""Phase B3-final — ``prepare_preset_args`` est l'API
publique pour les callers Python qui instancient leurs adapters
en mémoire (par opposition au chargement YAML via ``RunSpec``).
"""
from picarones.app.services import (
PresetArgs,
prepare_preset_args,
run_result_to_benchmark_result,
)
assert callable(prepare_preset_args)
assert callable(run_result_to_benchmark_result)
assert inspect.isclass(PresetArgs)
# ──────────────────────────────────────────────────────────────────────────
# 7. picarones.evaluation.metric_registry — registre typé (canonique)
# ──────────────────────────────────────────────────────────────────────────
class TestMetricRegistryApi:
def test_metric_spec_class(self):
_assert_class("picarones.evaluation.metric_registry", "MetricSpec")
@pytest.mark.parametrize("name", [
"register_metric", "get_metric", "all_metrics",
"select_metrics", "compute_at_junction",
])
def test_function_exists(self, name):
_assert_function("picarones.evaluation.metric_registry", name)
def test_register_metric_keyword_only(self):
"""``register_metric`` est exclusivement keyword-only sur ``name``,
``input_types`` etc. — décorateur factory."""
from picarones.evaluation.metric_registry import register_metric
sig = inspect.signature(register_metric)
for name in ["name", "input_types", "description"]:
assert name in sig.parameters, (
f"register_metric : keyword '{name}' manquant"
)
# ──────────────────────────────────────────────────────────────────────────
# 8. picarones.evaluation.metric_hooks — profils + registre de hooks (canonique)
# ──────────────────────────────────────────────────────────────────────────
class TestMetricHooksApi:
@pytest.mark.parametrize("profile_name", [
"PROFILE_MINIMAL", "PROFILE_STANDARD", "PROFILE_PHILOLOGICAL",
"PROFILE_DIAGNOSTICS", "PROFILE_ECONOMICS", "PROFILE_PIPELINE",
"PROFILE_FULL",
])
def test_profile_constant_exists(self, profile_name):
from picarones.evaluation import metric_hooks
assert hasattr(metric_hooks, profile_name), (
f"Profil {profile_name} disparu"
)
assert isinstance(getattr(metric_hooks, profile_name), str)
def test_known_profiles_set(self):
from picarones.evaluation.metric_hooks import KNOWN_PROFILES
assert isinstance(KNOWN_PROFILES, frozenset)
# Les 7 profils contractuels
assert len(KNOWN_PROFILES) == 7
@pytest.mark.parametrize("name", [
"DocumentMetricHook", "CorpusMetricAggregator",
])
def test_class_exists(self, name):
_assert_class("picarones.evaluation.metric_hooks", name)
@pytest.mark.parametrize("name", [
"validate_profile",
"register_document_metric", "register_corpus_aggregator",
"select_document_hooks", "select_corpus_aggregators",
"run_document_hooks", "run_corpus_aggregators",
])
def test_function_exists(self, name):
_assert_function("picarones.evaluation.metric_hooks", name)
# ──────────────────────────────────────────────────────────────────────────
# 9. picarones.evaluation.metrics.builtin_metrics — CER/WER/MER/WIL natifs
# ──────────────────────────────────────────────────────────────────────────
class TestBuiltinMetricsApi:
@pytest.mark.parametrize("name", [
"cer", "wer", "mer", "wil",
"text_preservation_after_reconstruction",
])
def test_function_exists(self, name):
_assert_function("picarones.evaluation.metrics.builtin_metrics", name)
# ──────────────────────────────────────────────────────────────────────────
# 10. picarones.evaluation.metrics.alto_metrics — métriques (ALTO, ALTO)
# ──────────────────────────────────────────────────────────────────────────
class TestAltoMetricsApi:
def test_extract_text_from_alto(self):
_assert_function("picarones.evaluation.metrics.alto_metrics", "extract_text_from_alto")
@pytest.mark.parametrize("name", [
"alto_text_cer", "alto_text_wer",
"alto_text_mer", "alto_text_wil",
])
def test_alto_metric_function(self, name):
_assert_function("picarones.evaluation.metrics.alto_metrics", name)
# ──────────────────────────────────────────────────────────────────────────
# 11. picarones.interfaces.web.jobs — JobStore (utilisé par web/)
# ──────────────────────────────────────────────────────────────────────────
class TestJobsApi:
def test_job_store(self):
_assert_class("picarones.interfaces.web.jobs", "JobStore")
@pytest.mark.parametrize("name", [
"get_default_store", "reset_default_store",
])
def test_function_exists(self, name):
_assert_function("picarones.interfaces.web.jobs", name)
# ──────────────────────────────────────────────────────────────────────────
# 12. Anti-régression : aucune fuite de Cercle 2/3 dans le Cercle 1
# ──────────────────────────────────────────────────────────────────────────
class TestCercle1IsLean:
"""``picarones/core/`` ne doit contenir que les modules Cercle 1 réels
(les autres sont des shims). Ce test garde-fou empêche un module
métrique d'être réintroduit dans le cœur sans RFC."""
# Modules Cercle 1 — abstractions pures (corpus, contrats, registres).
# Tout module avec de la logique métier (calcul, orchestration)
# appartient au Cercle 2 (``measurements/``) ou au Cercle 3
# (``extras/``, ``report/``).
EXPECTED_CERCLE1: set[str] = set()
# Phase 1 du retrait du legacy a déplacé `facts.py`,
# `diff_utils.py` et `xml_utils.py` vers leurs canoniques
# (`domain/facts.py`, `evaluation/_diff_utils.py`,
# `formats/_xml_utils.py`). Les fichiers `core/X.py`
# restent comme shims re-export avec DeprecationWarning
# (< 30 lignes), donc ne comptent plus comme "real_modules"
# au sens de ce test.
# Phase 4-bis a fait pareil pour `modules.py` (canonique :
# `domain/module_protocol.py` + `domain/artifacts.py`).
# Phase 4-ter a fait pareil pour `metric_registry.py`,
# `metric_hooks.py` (canonique : `evaluation/metric_*.py`),
# `metrics.py` (canonique : `evaluation/metric_result.py`)
# et `results.py` (canonique :
# `evaluation/benchmark_result.py`).
# Phase 4-quater a fait pareil pour `corpus.py`
# (canonique : `evaluation/corpus.py`).
# Phase 5.C.batch7 a fait pareil pour `pipeline.py`
# (canonique : `evaluation/pipeline.py`). Désormais
# ``core/`` ne contient plus que des shims < 30 lignes.
def test_cercle1_files_lean(self):
from pathlib import Path
repo = Path(__file__).parent.parent.parent
core_dir = repo / "picarones" / "core"
real_modules = set()
for path in core_dir.glob("*.py"):
content = path.read_text(encoding="utf-8")
n_lines = len(
[line for line in content.splitlines() if line.strip()],
)
# Un shim a < 30 lignes ; un module Cercle 1 a > 30 lignes
if n_lines > 30:
real_modules.add(path.name)
unexpected = real_modules - self.EXPECTED_CERCLE1
assert not unexpected, (
f"Modules non-Cercle 1 réintroduits dans core/ : {unexpected}. "
"Soit les déplacer dans measurements/ (Cercle 2) ou extras/ "
"(Cercle 3), soit ajouter à EXPECTED_CERCLE1 + api-stable.md "
"via RFC."
)
missing = self.EXPECTED_CERCLE1 - real_modules
assert not missing, (
f"Modules Cercle 1 manquants : {missing}. Restaurer ou retirer "
"de EXPECTED_CERCLE1."
)
# ──────────────────────────────────────────────────────────────────────────
# 13. Doc api-stable.md présente et complète
# ──────────────────────────────────────────────────────────────────────────
class TestApiStableDoc:
def test_doc_exists(self):
from pathlib import Path
# S60 — la doc a migré sous ``docs/reference/`` (Diataxis).
path = (
Path(__file__).parent.parent.parent
/ "docs"
/ "reference"
/ "api-stable.md"
)
assert path.exists(), "docs/reference/api-stable.md manquant"
content = path.read_text(encoding="utf-8")
# Présence des sections (1 par module canonique).
# Phase B3-final (mai 2026) — ``picarones.app.services.benchmark_runner``
# supprimé après la migration Option B ; remplacé dans la
# liste par ``picarones.app.services`` (entry point moderne
# via RunOrchestrator + prepare_preset_args).
for module in [
"picarones.evaluation.corpus",
"picarones.domain.artifacts",
"picarones.domain.module_protocol",
"picarones.evaluation.benchmark_result",
"picarones.evaluation.metrics.text_metrics",
"picarones.app.services",
"picarones.evaluation.metric_registry",
"picarones.evaluation.metric_hooks",
"picarones.evaluation.metrics.builtin_metrics",
"picarones.evaluation.metrics.alto_metrics",
"picarones.interfaces.web.jobs",
]:
assert module in content, (
f"docs/api-stable.md ne mentionne pas {module}"
)
def test_doc_mentions_stability_policy(self):
from pathlib import Path
path = (
Path(__file__).parent.parent.parent
/ "docs"
/ "reference"
/ "api-stable.md"
)
content = path.read_text(encoding="utf-8")
# Les sections clés du contrat
assert "Politique de stabilité" in content
assert "Ce que nous garantissons" in content
assert "Bump majeur" in content