"""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