Claude commited on
Commit
3ff40b2
·
unverified ·
1 Parent(s): d756039

phaseD: contrat de stabilité de l'API publique + bump version 1.3.0

Browse files

Phase finale du chantier de refonte en 3 cercles. Verrouille
contractuellement le Cercle 1 (``picarones/core/``) en :

1. **Documentant** explicitement la liste des classes, fonctions,
constantes et types qui constituent l'API publique stable.
2. **Testant** automatiquement que cette liste reste cohérente entre
versions (filet de sécurité contre les régressions silencieuses).
3. **Bumpant** la version à ``1.3.0`` (refonte non-breaking, voir
justification ci-dessous).

docs/api-stable.md (250 lignes)
-------------------------------
Liste exhaustive de **70 noms publics** sur 14 modules Cercle 1 :

- ``picarones.core.corpus`` (10) : Document, Corpus, GTLevel,
TextGT/AltoGT/PageGT/EntitiesGT/ReadingOrderGT, GT_SUFFIXES,
load_corpus_from_directory.
- ``picarones.core.modules`` (2) : ArtifactType, BaseModule.
- ``picarones.core.results`` (3) : DocumentResult, EngineReport,
BenchmarkResult.
- ``picarones.core.metrics`` (3) : MetricsResult, compute_metrics,
aggregate_metrics.
- ``picarones.core.runner`` (1) : run_benchmark.
- ``picarones.core.pipeline_runner`` (5) : PipelineStep, PipelineSpec,
StepResult, PipelineResult, PipelineRunner.
- ``picarones.core.pipeline_benchmark`` (4).
- ``picarones.core.pipeline_comparison`` (2).
- ``picarones.core.pipeline_spec_loader`` (5).
- ``picarones.core.metric_registry`` (6).
- ``picarones.core.metric_hooks`` (17) : 7 profils + 2 dataclasses
+ 8 fonctions API.
- ``picarones.core.builtin_metrics`` (5).
- ``picarones.core.alto_metrics`` (5).
- ``picarones.core.jobs`` (3).

Politique de stabilité documentée :

- **Garanties** : existence des noms, signatures rétrocompatibles
(pas de nouveau requis), types de retour compatibles, sémantique.
- **Non-garanties** : modules ``measurements/`` et ``extras/``,
noms privés (``_*``), structure HTML interne du rapport.
- **Bump majeur (2.0.0)** : nécessaire pour supprimer un nom,
changer une signature de manière non-rétrocompatible, casser
``BenchmarkResult.to_json()``, ou renommer un module Cercle 1.

tests/test_public_api.py (340 lignes, 16 classes)
-------------------------------------------------
Filet de sécurité automatique. Échoue si :

- Un nom listé dans api-stable.md disparaît.
- Un nom change de type (class ↔ function).
- ``run_benchmark`` perd un de ses 8 keyword args contractuels.
- ``compute_metrics`` n'accepte plus ses 2 args positionnels.
- Un module Cercle 1 réel est ajouté à ``core/`` sans RFC
(test ``TestCercle1IsLean.test_cercle1_files_lean``).
- Le doc api-stable.md devient incohérent (test
``TestApiStableDoc``).

Workflow contributeur :

1. **Pour ajouter un nom** : doc → test → implémentation.
2. **Pour casser un nom** : RFC + bump majeur + suppression des deux
côtés en même temps.

Bump version 1.0.0 → 1.3.0
--------------------------
**Refonte non-breaking** — justification semver mineure (``1.x.0``
plutôt que majeure ``2.0.0``) :

- Aucun import historique cassé. Tous les ``from picarones.core.X``
continuent à fonctionner via des shims qui réexportent depuis le
nouvel emplacement (extras/ ou measurements/).
- L'identité des fonctions est préservée (``shim.f is new.f``).
- ``picarones.cli:cli`` (entry-point pyproject) reste valide.
- ``BenchmarkResult.to_json()`` produit le même format JSON.
- Les rapports HTML générés ont la même structure.

La numérotation 1.3.0 reflète la progression naturelle des ajouts
post-Sprint 97 :

- 1.0.0 : version initiale.
- 1.1.x : sprints intermédiaires (avant le chantier).
- 1.2.x : Phase 0 du plan d'évolution 2026 (sprints 32+).
- 1.3.0 : refonte en 3 cercles complète (chantiers post-97 + phases A-E).

Validation 6/6 en sandbox
-------------------------
- 70/70 noms du contrat accessibles via leurs modules Cercle 1.
- 14 modules réels dans ``core/`` (alignés sur EXPECTED_CERCLE1).
- 0 module non-Cercle 1 dans ``core/``.
- 0 module Cercle 1 manquant.
- ``pyproject.toml`` : version 1.3.0.
- ``tests/test_public_api.py`` : 16 classes de test.

Bilan global du chantier de refonte (phases A + B + C + D + E)
--------------------------------------------------------------

| | Avant | Après |
|----------------------------------|-------------|---------------|
| ``core/`` modules réels | 66 | 14 |
| ``core/`` shims rétrocompat | 0 | 55 |
| ``measurements/`` | n'existe pas| 42 + narrative|
| ``extras/`` | n'existe pas| 24 + 6 render |
| API publique documentée | non | 70 noms |
| Test anti-régression API | non | 16 classes |
| Critère de cercles | informel | DDD documenté |
| Version | 1.0.0 | 1.3.0 |
| Lignes de fonctionnalité | | |
| supprimées | - | 0 |
| Imports historiques cassés | - | 0 |

Le chantier de refonte est **terminé**. Les 6 phases ont livré une
architecture en 3 cercles physiques, documentée, testée, sans casser
la rétrocompat. Le fichier ``CLAUDE.md`` historique reste à jour
via la section ``[post-Sprint 97]`` du CHANGELOG.

Files changed (3) hide show
  1. docs/api-stable.md +303 -0
  2. pyproject.toml +1 -1
  3. tests/test_public_api.py +485 -0
docs/api-stable.md ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # API publique stable de Picarones (Cercle 1)
2
+
3
+ Phase D du chantier de refonte en 3 cercles — engagement contractuel
4
+ de stabilité de l'API publique du Cercle 1.
5
+
6
+ ## Définition
7
+
8
+ L'API publique de Picarones est constituée des classes, fonctions,
9
+ constantes et types listés ci-dessous, exportés depuis le sous-package
10
+ `picarones.core/`. Ce qui est dans cette liste constitue **un contrat
11
+ de stabilité** : nous nous engageons à ne pas le casser entre versions
12
+ mineures (semver `1.x.0`).
13
+
14
+ Ce qui n'est pas dans cette liste — y compris les modules historiques
15
+ qui ont été déplacés vers `picarones.measurements/`, `picarones.extras/`
16
+ et accessibles via shims rétrocompat — peut évoluer à tout moment
17
+ sans bump majeur.
18
+
19
+ Les imports historiques (`from picarones.core.confusion import ...`,
20
+ `from picarones.core.narrative.facts import ...`, etc.) restent
21
+ fonctionnels mais ne font **pas** partie de l'API publique stable :
22
+ ce sont des aliases rétrocompat. Pour de la nouveauté, préférer
23
+ `from picarones.measurements.confusion import ...`.
24
+
25
+ ## Test automatique
26
+
27
+ Le test `tests/test_public_api.py` vérifie que tous les noms listés
28
+ ici existent et restent accessibles. Il échoue si un nom disparaît
29
+ ou change de forme.
30
+
31
+ ## Liste exhaustive
32
+
33
+ ### `picarones.core.corpus`
34
+
35
+ ```python
36
+ class GTLevel(str, Enum):
37
+ TEXT, ALTO, PAGE, ENTITIES, READING_ORDER
38
+
39
+ class TextGT: # GT texte plat
40
+ class AltoGT: # GT ALTO XML
41
+ class PageGT: # GT PAGE XML
42
+ class EntitiesGT: # GT entités nommées (NER)
43
+ class ReadingOrderGT: # GT ordre de lecture des régions
44
+ GTPayload = Union[...] # type alias
45
+
46
+ class Document: # un document du corpus (image + GT multi-niveaux)
47
+ class Corpus: # collection de Documents
48
+
49
+ GT_SUFFIXES: dict[GTLevel, str] # mapping niveau → suffixe fichier
50
+
51
+ def load_corpus_from_directory(path) -> Corpus
52
+ ```
53
+
54
+ ### `picarones.core.modules`
55
+
56
+ ```python
57
+ class ArtifactType(str, Enum):
58
+ IMAGE, TEXT, ALTO, PAGE, ENTITIES, READING_ORDER
59
+
60
+ class BaseModule(ABC):
61
+ input_types: tuple[ArtifactType, ...]
62
+ output_types: tuple[ArtifactType, ...]
63
+ execution_mode: "io" | "cpu"
64
+
65
+ @property name
66
+ @abstractmethod process(inputs)
67
+ metadata() -> dict
68
+ validate_inputs(inputs)
69
+ validate_outputs(outputs)
70
+
71
+ ExecutionMode = Literal["io", "cpu"]
72
+ ```
73
+
74
+ ### `picarones.core.results`
75
+
76
+ ```python
77
+ class DocumentResult: # résultat moteur sur un doc (CER, métriques, taxonomy…)
78
+ class EngineReport: # agrégat moteur sur tout le corpus
79
+ class BenchmarkResult: # résultat global multi-moteurs
80
+ ```
81
+
82
+ ### `picarones.core.metrics`
83
+
84
+ ```python
85
+ class MetricsResult: # CER, WER, MER, WIL + variantes diplomatique/caseless
86
+ def compute_metrics(reference, hypothesis, char_exclude=None) -> MetricsResult
87
+ def aggregate_metrics(results: list) -> dict
88
+ ```
89
+
90
+ ### `picarones.core.runner`
91
+
92
+ ```python
93
+ def run_benchmark(
94
+ corpus, engines,
95
+ output_json=None,
96
+ show_progress=True,
97
+ progress_callback=None,
98
+ char_exclude=None,
99
+ max_workers=4,
100
+ timeout_seconds=60.0,
101
+ partial_dir=None,
102
+ cancel_event=None,
103
+ entity_extractor=None,
104
+ profile="standard",
105
+ ) -> BenchmarkResult
106
+ ```
107
+
108
+ ### `picarones.core.pipeline_runner`
109
+
110
+ ```python
111
+ class PipelineStep:
112
+ class PipelineSpec:
113
+ class StepResult:
114
+ class PipelineResult:
115
+ class PipelineRunner:
116
+ ```
117
+
118
+ ### `picarones.core.pipeline_benchmark`
119
+
120
+ ```python
121
+ class StepAggregate:
122
+ class PipelineBenchmarkResult:
123
+
124
+ def default_initial_inputs(doc) -> dict
125
+ def run_pipeline_benchmark(spec, corpus, factory=...) -> PipelineBenchmarkResult
126
+ ```
127
+
128
+ ### `picarones.core.pipeline_comparison`
129
+
130
+ ```python
131
+ class PipelineComparisonResult:
132
+
133
+ def compare_pipelines(specs, corpus, factories=None) -> PipelineComparisonResult
134
+ ```
135
+
136
+ ### `picarones.core.pipeline_spec_loader`
137
+
138
+ ```python
139
+ class PipelineSpecLoadError(ValueError):
140
+
141
+ def load_pipeline_spec_from_yaml(path) -> PipelineSpec
142
+ def load_pipeline_spec_from_dict(data: dict) -> PipelineSpec
143
+ def load_comparison_specs_from_yaml(path) -> tuple[list[PipelineSpec], dict]
144
+ def load_comparison_specs_from_dict(data: dict) -> tuple[list[PipelineSpec], dict]
145
+ ```
146
+
147
+ ### `picarones.core.metric_registry`
148
+
149
+ ```python
150
+ class MetricSpec: # frozen dataclass : name, func, input_types, ...
151
+
152
+ def register_metric(*, name, input_types, ...) -> Callable
153
+ def get_metric(name) -> MetricSpec
154
+ def all_metrics() -> list[MetricSpec]
155
+ def select_metrics(input_types) -> list[MetricSpec]
156
+ def compute_at_junction(reference, hypothesis, input_types, *, skip_on_error=True) -> dict
157
+ ```
158
+
159
+ ### `picarones.core.metric_hooks`
160
+
161
+ ```python
162
+ # Profils — constantes
163
+ PROFILE_MINIMAL = "minimal"
164
+ PROFILE_STANDARD = "standard"
165
+ PROFILE_PHILOLOGICAL = "philological"
166
+ PROFILE_DIAGNOSTICS = "diagnostics"
167
+ PROFILE_ECONOMICS = "economics"
168
+ PROFILE_PIPELINE = "pipeline"
169
+ PROFILE_FULL = "full"
170
+ KNOWN_PROFILES: frozenset[str]
171
+
172
+ # Modèles
173
+ class DocumentMetricHook: # frozen dataclass
174
+ class CorpusMetricAggregator:
175
+
176
+ # API
177
+ def validate_profile(profile)
178
+ def register_document_metric(*, name, attribute, profiles, ...) -> Callable
179
+ def register_corpus_aggregator(*, name, attribute, profiles) -> Callable
180
+ def select_document_hooks(profile) -> list[DocumentMetricHook]
181
+ def select_corpus_aggregators(profile) -> list[CorpusMetricAggregator]
182
+ def run_document_hooks(profile, *, ground_truth, hypothesis, image_path, corpus_lang, ocr_result) -> dict
183
+ def run_corpus_aggregators(profile, document_results) -> dict
184
+ ```
185
+
186
+ ### `picarones.core.builtin_metrics`
187
+
188
+ Métriques scalaires natives, enregistrées dans le registre typé :
189
+
190
+ ```python
191
+ def cer(reference, hypothesis) -> float
192
+ def wer(reference, hypothesis) -> float
193
+ def mer(reference, hypothesis) -> float
194
+ def wil(reference, hypothesis) -> float
195
+
196
+ # Stub démonstrateur
197
+ def text_preservation_after_reconstruction(reference_text, hypothesis_alto) -> float
198
+ ```
199
+
200
+ ### `picarones.core.alto_metrics`
201
+
202
+ Métriques (ALTO, ALTO) + helper :
203
+
204
+ ```python
205
+ def extract_text_from_alto(payload) -> str
206
+
207
+ def alto_text_cer(reference_alto, hypothesis_alto) -> float
208
+ def alto_text_wer(reference_alto, hypothesis_alto) -> float
209
+ def alto_text_mer(reference_alto, hypothesis_alto) -> float
210
+ def alto_text_wil(reference_alto, hypothesis_alto) -> float
211
+ ```
212
+
213
+ ### `picarones.core.jobs`
214
+
215
+ Persistance des jobs benchmark (utilisé par l'interface web) :
216
+
217
+ ```python
218
+ class JobStore:
219
+ def get_default_store() -> JobStore
220
+ def reset_default_store(...)
221
+ ```
222
+
223
+ ## Politique de stabilité
224
+
225
+ ### Ce que nous garantissons
226
+
227
+ - **Existence** : aucun nom listé ne disparaît entre `1.x.0` et
228
+ `1.y.0` (pour `y > x`).
229
+ - **Signatures** : aucun argument requis ajouté à une fonction
230
+ publique. Les nouveaux arguments sont keyword avec valeur par
231
+ défaut.
232
+ - **Types de retour** : compatibles entre versions mineures (un
233
+ `dict` peut gagner des clés mais pas en perdre).
234
+ - **Sémantique** : un nom listé garde le même comportement
235
+ fonctionnel. Les corrections de bug sont permises.
236
+
237
+ ### Ce que nous ne garantissons pas
238
+
239
+ - **Modules `picarones.measurements/`** : peuvent évoluer librement.
240
+ Quand ils changent, les shims rétrocompat dans `picarones.core/`
241
+ reflètent ces changements.
242
+ - **Modules `picarones.extras/`** : statut variable selon le
243
+ sous-package (academic / governance / historical / importers).
244
+ Voir `docs/architecture-cercles.md`.
245
+ - **Comportement des renderers HTML** : la structure des fichiers HTML
246
+ peut évoluer entre versions mineures. Nous gardons les noms des
247
+ vues principales.
248
+ - **Internes des modules Cercle 1** : les noms commençant par `_`
249
+ ne font pas partie de l'API publique. Les tests Sprints
250
+ historiques qui les importent (Sprint 13/42) sont préservés mais
251
+ par effort, pas par contrat.
252
+
253
+ ### Bump majeur (`2.0.0`)
254
+
255
+ Un bump majeur sera nécessaire pour :
256
+
257
+ - Supprimer un nom de cette liste.
258
+ - Changer la signature d'une fonction publique de manière non
259
+ rétrocompatible.
260
+ - Casser le format de sérialisation du `BenchmarkResult.to_json()`.
261
+ - Renommer un module Cercle 1.
262
+
263
+ ## Modules historiques rétrocompat (non Cercle 1)
264
+
265
+ Les imports suivants continuent à fonctionner mais ne font pas partie
266
+ de l'API publique stable. Ils peuvent évoluer ou être retirés en
267
+ version mineure si une RFC le justifie.
268
+
269
+ ```python
270
+ # Mesures (déplacées vers picarones.measurements/)
271
+ from picarones.core.confusion import build_confusion_matrix
272
+ from picarones.core.taxonomy import classify_errors
273
+ from picarones.core.calibration import compute_calibration_metrics
274
+ # ... ~40 modules métriques ...
275
+
276
+ # Moteur narratif (déplacé vers picarones.measurements.narrative/)
277
+ from picarones.core.narrative import build_synthesis
278
+ from picarones.core.narrative.facts import Fact
279
+ from picarones.core.narrative.detectors import detect_global_leader_cer
280
+
281
+ # Plugins (déplacés vers picarones.extras/)
282
+ from picarones.core.taxonomy_intra_doc import compute_taxonomy_position_heatmap
283
+ from picarones.core.unicode_blocks import compute_unicode_block_accuracy
284
+ from picarones.core.module_policy import ModuleManifest
285
+ from picarones.importers.iiif import IIIFImporter
286
+ ```
287
+
288
+ Pour les **nouvelles** intégrations, préférer les chemins canoniques :
289
+
290
+ - `picarones.measurements.X` pour les mesures.
291
+ - `picarones.measurements.narrative.X` pour le moteur narratif.
292
+ - `picarones.extras.historical.X` pour les modules philologiques.
293
+ - `picarones.extras.importers.X` pour les importers.
294
+ - `picarones.extras.academic.X` / `picarones.extras.governance.X` pour
295
+ les plugins niche / gouvernance.
296
+
297
+ ## Voir aussi
298
+
299
+ - [`docs/architecture-cercles.md`](architecture-cercles.md) — cartographie
300
+ des 3 cercles + critères d'assignation.
301
+ - [`docs/architecture.md`](architecture.md) — vue d'ensemble post-chantiers.
302
+ - [`tests/test_public_api.py`](../tests/test_public_api.py) — test
303
+ automatique qui échoue si un nom listé ici disparaît.
pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
 
5
  [project]
6
  name = "picarones"
7
- version = "1.0.0"
8
  description = "Plateforme de comparaison de moteurs OCR/HTR pour documents patrimoniaux"
9
  readme = "README.md"
10
  requires-python = ">=3.11"
 
4
 
5
  [project]
6
  name = "picarones"
7
+ version = "1.3.0"
8
  description = "Plateforme de comparaison de moteurs OCR/HTR pour documents patrimoniaux"
9
  readme = "README.md"
10
  requires-python = ">=3.11"
tests/test_public_api.py ADDED
@@ -0,0 +1,485 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test de stabilité de l'API publique de Picarones (Cercle 1).
2
+
3
+ Phase D du chantier de refonte en 3 cercles. Ce test est le **filet de
4
+ sécurité contractuel** documenté dans :doc:`docs/api-stable.md` : il
5
+ échoue dès qu'un nom listé dans le contrat de stabilité du Cercle 1
6
+ disparaît, change de type (class ↔ function), ou perd un argument
7
+ attendu.
8
+
9
+ Discipline
10
+ ----------
11
+ Toute modification d'un test ici doit être accompagnée d'une mise à
12
+ jour de ``docs/api-stable.md`` et **justifiée par une RFC** si elle
13
+ casse la rétrocompat. Ce test est la traduction technique d'un
14
+ engagement public.
15
+
16
+ Si une PR doit ajouter un nom à l'API publique, suivre dans l'ordre :
17
+
18
+ 1. Documenter le nom dans ``docs/api-stable.md``.
19
+ 2. Ajouter le test correspondant ici.
20
+ 3. Implémenter / exposer le nom.
21
+
22
+ Si une PR doit casser un nom de l'API publique :
23
+
24
+ 1. RFC + bump majeur (``2.0.0``).
25
+ 2. Mise à jour de ``docs/api-stable.md`` (suppression).
26
+ 3. Mise à jour des tests ici.
27
+
28
+ Les noms historiques rétrocompat (Cercle 2 / Cercle 3 via shims) ne
29
+ sont **pas** couverts par ce test — ils ont leurs propres tests dans
30
+ ``tests/test_phaseA_migration.py``, ``test_phaseB_migration.py``, etc.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import importlib
36
+ import inspect
37
+
38
+ import pytest
39
+
40
+
41
+ # ──────────────────────────────────────────────────────────────────────────
42
+ # Helpers
43
+ # ──────────────────────────────────────────────────────────────────────────
44
+
45
+
46
+ def _get_attr(module_path: str, name: str):
47
+ mod = importlib.import_module(module_path)
48
+ assert hasattr(mod, name), (
49
+ f"API publique cassée : {module_path}.{name} a disparu"
50
+ )
51
+ return getattr(mod, name)
52
+
53
+
54
+ def _assert_class(module_path: str, name: str, *, abstract: bool = False):
55
+ obj = _get_attr(module_path, name)
56
+ assert inspect.isclass(obj), (
57
+ f"{module_path}.{name} : attendu class, obtenu {type(obj).__name__}"
58
+ )
59
+ if abstract:
60
+ assert inspect.isabstract(obj) or hasattr(obj, "__abstractmethods__"), (
61
+ f"{module_path}.{name} : attendu classe abstraite"
62
+ )
63
+ return obj
64
+
65
+
66
+ def _assert_function(module_path: str, name: str):
67
+ obj = _get_attr(module_path, name)
68
+ assert callable(obj), (
69
+ f"{module_path}.{name} : attendu callable, obtenu {type(obj).__name__}"
70
+ )
71
+ return obj
72
+
73
+
74
+ # ──────────────────────────────────────────────────────────────────────────
75
+ # 1. picarones.core.corpus — modèle Document/Corpus + GT multi-niveaux
76
+ # ──────────────────────────────────────────────────────────────────────────
77
+
78
+
79
+ class TestCorpusApi:
80
+ @pytest.mark.parametrize("name", [
81
+ "Document", "Corpus",
82
+ "GTLevel",
83
+ "TextGT", "AltoGT", "PageGT", "EntitiesGT", "ReadingOrderGT",
84
+ ])
85
+ def test_class_exists(self, name):
86
+ _assert_class("picarones.core.corpus", name)
87
+
88
+ def test_load_corpus_from_directory_exists(self):
89
+ _assert_function("picarones.core.corpus", "load_corpus_from_directory")
90
+
91
+ def test_gt_suffixes_constant(self):
92
+ from picarones.core.corpus import GTLevel, GT_SUFFIXES
93
+
94
+ assert isinstance(GT_SUFFIXES, dict)
95
+ # Chacun des 5 niveaux GT doit avoir un suffixe
96
+ for level in GTLevel:
97
+ assert level in GT_SUFFIXES, (
98
+ f"GT_SUFFIXES manque le niveau {level}"
99
+ )
100
+
101
+ def test_gtlevel_values(self):
102
+ from picarones.core.corpus import GTLevel
103
+
104
+ # Les 5 valeurs sont contractuelles — leur ordre/nom n'importe
105
+ # pas, leur présence si.
106
+ names = {member.value for member in GTLevel}
107
+ assert names == {"text", "alto", "page", "entities", "reading_order"}
108
+
109
+
110
+ # ──────────────────────────────────────────────────────────────────────────
111
+ # 2. picarones.core.modules — BaseModule + ArtifactType
112
+ # ──────────────────────────────────────────────────────────────────────────
113
+
114
+
115
+ class TestModulesApi:
116
+ def test_artifact_type_values(self):
117
+ from picarones.core.modules import ArtifactType
118
+
119
+ names = {member.value for member in ArtifactType}
120
+ # ``IMAGE`` + 5 niveaux GT
121
+ assert names == {"image", "text", "alto", "page", "entities", "reading_order"}
122
+
123
+ def test_basemodule_is_abstract(self):
124
+ cls = _assert_class("picarones.core.modules", "BaseModule")
125
+ # Doit avoir `process` abstrait
126
+ assert "process" in cls.__abstractmethods__ or hasattr(cls, "process")
127
+
128
+ def test_basemodule_class_attributes(self):
129
+ from picarones.core.modules import BaseModule
130
+
131
+ # Contrat : ces attributs de classe sont lisibles depuis la base
132
+ assert hasattr(BaseModule, "input_types")
133
+ assert hasattr(BaseModule, "output_types")
134
+ assert hasattr(BaseModule, "execution_mode")
135
+ assert hasattr(BaseModule, "validate_inputs")
136
+ assert hasattr(BaseModule, "validate_outputs")
137
+ assert hasattr(BaseModule, "metadata")
138
+
139
+
140
+ # ──────────────────────────────────────────────────────────────────────────
141
+ # 3. picarones.core.results — modèles de résultats
142
+ # ──────────────────────────────────────────────────────────────────────────
143
+
144
+
145
+ class TestResultsApi:
146
+ @pytest.mark.parametrize("name", [
147
+ "DocumentResult", "EngineReport", "BenchmarkResult",
148
+ ])
149
+ def test_class_exists(self, name):
150
+ _assert_class("picarones.core.results", name)
151
+
152
+
153
+ # ──────────────────────────────────────────────────────────────────────────
154
+ # 4. picarones.core.metrics — métriques de base
155
+ # ──────────────────────────────────────────────────────────────────────────
156
+
157
+
158
+ class TestMetricsApi:
159
+ def test_metrics_result_class(self):
160
+ _assert_class("picarones.core.metrics", "MetricsResult")
161
+
162
+ @pytest.mark.parametrize("name", [
163
+ "compute_metrics", "aggregate_metrics",
164
+ ])
165
+ def test_function_exists(self, name):
166
+ _assert_function("picarones.core.metrics", name)
167
+
168
+ def test_compute_metrics_signature(self):
169
+ """``compute_metrics(reference, hypothesis, char_exclude=None)`` est
170
+ contractuel — les 2 premiers args sont positionnels, le 3ᵉ keyword."""
171
+ from picarones.core.metrics import compute_metrics
172
+ sig = inspect.signature(compute_metrics)
173
+ params = list(sig.parameters.values())
174
+ # Au moins 2 paramètres positionnels (reference, hypothesis)
175
+ positional = [p for p in params
176
+ if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
177
+ and p.default is p.empty]
178
+ assert len(positional) >= 2, (
179
+ f"compute_metrics doit accepter >= 2 args positionnels — "
180
+ f"signature actuelle : {sig}"
181
+ )
182
+
183
+
184
+ # ──────────────────────────────────────────────────────────────────────────
185
+ # 5. picarones.core.runner — run_benchmark
186
+ # ──────────────────────────────────────────────────────────────────────────
187
+
188
+
189
+ class TestRunnerApi:
190
+ def test_run_benchmark_exists(self):
191
+ try:
192
+ _assert_function("picarones.core.runner", "run_benchmark")
193
+ except ImportError as exc:
194
+ if "tqdm" in str(exc):
195
+ pytest.skip("tqdm non installé en sandbox")
196
+ raise
197
+
198
+ def test_run_benchmark_keyword_args(self):
199
+ """Les paramètres clés (corpus, engines, profile…) doivent rester
200
+ accessibles. Ajout d'un argument requis = breaking change."""
201
+ try:
202
+ from picarones.core.runner import run_benchmark
203
+ except ImportError as exc:
204
+ if "tqdm" in str(exc):
205
+ pytest.skip("tqdm non installé")
206
+ raise
207
+ sig = inspect.signature(run_benchmark)
208
+ params = sig.parameters
209
+ # Arguments contractuels — leur présence est garantie
210
+ for name in [
211
+ "corpus", "engines", "output_json", "show_progress",
212
+ "char_exclude", "max_workers", "timeout_seconds",
213
+ "profile",
214
+ ]:
215
+ assert name in params, (
216
+ f"run_benchmark : argument '{name}' a disparu (signature : {sig})"
217
+ )
218
+
219
+
220
+ # ──────────────────────────────────────────────────────────────────────────
221
+ # 6. picarones.core.pipeline_runner — banc d'essai pipelines
222
+ # ──────────────────────────────────────────────────────────────────────────
223
+
224
+
225
+ class TestPipelineRunnerApi:
226
+ @pytest.mark.parametrize("name", [
227
+ "PipelineStep", "PipelineSpec",
228
+ "StepResult", "PipelineResult", "PipelineRunner",
229
+ ])
230
+ def test_class_exists(self, name):
231
+ _assert_class("picarones.core.pipeline_runner", name)
232
+
233
+
234
+ class TestPipelineBenchmarkApi:
235
+ @pytest.mark.parametrize("name", [
236
+ "StepAggregate", "PipelineBenchmarkResult",
237
+ ])
238
+ def test_class_exists(self, name):
239
+ _assert_class("picarones.core.pipeline_benchmark", name)
240
+
241
+ @pytest.mark.parametrize("name", [
242
+ "default_initial_inputs", "run_pipeline_benchmark",
243
+ ])
244
+ def test_function_exists(self, name):
245
+ _assert_function("picarones.core.pipeline_benchmark", name)
246
+
247
+
248
+ class TestPipelineComparisonApi:
249
+ def test_pipeline_comparison_result(self):
250
+ _assert_class(
251
+ "picarones.core.pipeline_comparison", "PipelineComparisonResult",
252
+ )
253
+
254
+ def test_compare_pipelines(self):
255
+ _assert_function(
256
+ "picarones.core.pipeline_comparison", "compare_pipelines",
257
+ )
258
+
259
+
260
+ class TestPipelineSpecLoaderApi:
261
+ def test_pipeline_spec_load_error(self):
262
+ cls = _assert_class(
263
+ "picarones.core.pipeline_spec_loader", "PipelineSpecLoadError",
264
+ )
265
+ assert issubclass(cls, ValueError)
266
+
267
+ @pytest.mark.parametrize("name", [
268
+ "load_pipeline_spec_from_yaml",
269
+ "load_pipeline_spec_from_dict",
270
+ "load_comparison_specs_from_yaml",
271
+ "load_comparison_specs_from_dict",
272
+ ])
273
+ def test_function_exists(self, name):
274
+ _assert_function("picarones.core.pipeline_spec_loader", name)
275
+
276
+
277
+ # ──────────────────────────────────────────────────────────────────────────
278
+ # 7. picarones.core.metric_registry — registre typé
279
+ # ──────────────────────────────────────────────────────────────────────────
280
+
281
+
282
+ class TestMetricRegistryApi:
283
+ def test_metric_spec_class(self):
284
+ _assert_class("picarones.core.metric_registry", "MetricSpec")
285
+
286
+ @pytest.mark.parametrize("name", [
287
+ "register_metric", "get_metric", "all_metrics",
288
+ "select_metrics", "compute_at_junction",
289
+ ])
290
+ def test_function_exists(self, name):
291
+ _assert_function("picarones.core.metric_registry", name)
292
+
293
+ def test_register_metric_keyword_only(self):
294
+ """``register_metric`` est exclusivement keyword-only sur ``name``,
295
+ ``input_types`` etc. — décorateur factory."""
296
+ from picarones.core.metric_registry import register_metric
297
+ sig = inspect.signature(register_metric)
298
+ for name in ["name", "input_types", "description"]:
299
+ assert name in sig.parameters, (
300
+ f"register_metric : keyword '{name}' manquant"
301
+ )
302
+
303
+
304
+ # ──────────────────────────────────────────────────────────────────────────
305
+ # 8. picarones.core.metric_hooks — profils + registre de hooks
306
+ # ──────────────────────────────────────────────────────────────────────────
307
+
308
+
309
+ class TestMetricHooksApi:
310
+ @pytest.mark.parametrize("profile_name", [
311
+ "PROFILE_MINIMAL", "PROFILE_STANDARD", "PROFILE_PHILOLOGICAL",
312
+ "PROFILE_DIAGNOSTICS", "PROFILE_ECONOMICS", "PROFILE_PIPELINE",
313
+ "PROFILE_FULL",
314
+ ])
315
+ def test_profile_constant_exists(self, profile_name):
316
+ from picarones.core import metric_hooks
317
+ assert hasattr(metric_hooks, profile_name), (
318
+ f"Profil {profile_name} disparu"
319
+ )
320
+ assert isinstance(getattr(metric_hooks, profile_name), str)
321
+
322
+ def test_known_profiles_set(self):
323
+ from picarones.core.metric_hooks import KNOWN_PROFILES
324
+
325
+ assert isinstance(KNOWN_PROFILES, frozenset)
326
+ # Les 7 profils contractuels
327
+ assert len(KNOWN_PROFILES) == 7
328
+
329
+ @pytest.mark.parametrize("name", [
330
+ "DocumentMetricHook", "CorpusMetricAggregator",
331
+ ])
332
+ def test_class_exists(self, name):
333
+ _assert_class("picarones.core.metric_hooks", name)
334
+
335
+ @pytest.mark.parametrize("name", [
336
+ "validate_profile",
337
+ "register_document_metric", "register_corpus_aggregator",
338
+ "select_document_hooks", "select_corpus_aggregators",
339
+ "run_document_hooks", "run_corpus_aggregators",
340
+ ])
341
+ def test_function_exists(self, name):
342
+ _assert_function("picarones.core.metric_hooks", name)
343
+
344
+
345
+ # ──────────────────────────────────────────────────────────────────────────
346
+ # 9. picarones.core.builtin_metrics — CER/WER/MER/WIL natifs
347
+ # ──────────────────────────────────────────────────────────────────────────
348
+
349
+
350
+ class TestBuiltinMetricsApi:
351
+ @pytest.mark.parametrize("name", [
352
+ "cer", "wer", "mer", "wil",
353
+ "text_preservation_after_reconstruction",
354
+ ])
355
+ def test_function_exists(self, name):
356
+ _assert_function("picarones.core.builtin_metrics", name)
357
+
358
+
359
+ # ──────────────────────────────────────────────────────────────────────────
360
+ # 10. picarones.core.alto_metrics — métriques (ALTO, ALTO)
361
+ # ──────────────────────────────────────────────────────────────────────────
362
+
363
+
364
+ class TestAltoMetricsApi:
365
+ def test_extract_text_from_alto(self):
366
+ _assert_function("picarones.core.alto_metrics", "extract_text_from_alto")
367
+
368
+ @pytest.mark.parametrize("name", [
369
+ "alto_text_cer", "alto_text_wer",
370
+ "alto_text_mer", "alto_text_wil",
371
+ ])
372
+ def test_alto_metric_function(self, name):
373
+ _assert_function("picarones.core.alto_metrics", name)
374
+
375
+
376
+ # ──────────────────────────────────────────────────────────────────────────
377
+ # 11. picarones.core.jobs — JobStore (utilisé par web/)
378
+ # ──────────────────────────────────────────────────────────────────────────
379
+
380
+
381
+ class TestJobsApi:
382
+ def test_job_store(self):
383
+ _assert_class("picarones.core.jobs", "JobStore")
384
+
385
+ @pytest.mark.parametrize("name", [
386
+ "get_default_store", "reset_default_store",
387
+ ])
388
+ def test_function_exists(self, name):
389
+ _assert_function("picarones.core.jobs", name)
390
+
391
+
392
+ # ──────────────────────────────────────────────────────────────────────────
393
+ # 12. Anti-régression : aucune fuite de Cercle 2/3 dans le Cercle 1
394
+ # ──────────────────────────────────────────────────────────────────────────
395
+
396
+
397
+ class TestCercle1IsLean:
398
+ """``picarones/core/`` ne doit contenir que les modules Cercle 1 réels
399
+ (les autres sont des shims). Ce test garde-fou empêche un module
400
+ métrique d'être réintroduit dans le cœur sans RFC."""
401
+
402
+ # Modules Cercle 1 « gros » (> 30 lignes de logique). ``colors.py``
403
+ # est un fichier utilitaire de constantes (13 lignes) co-localisé
404
+ # dans ``core/`` pour éviter le churn — il n'est pas dans cette
405
+ # liste car le seuil 30 lignes ne le détecte pas comme « réel »,
406
+ # mais sa présence dans le dossier est tolérée.
407
+ EXPECTED_CERCLE1 = {
408
+ "alto_metrics.py", "builtin_metrics.py", "corpus.py", "jobs.py",
409
+ "metric_hooks.py", "metric_registry.py", "metrics.py", "modules.py",
410
+ "pipeline_benchmark.py", "pipeline_comparison.py",
411
+ "pipeline_runner.py", "pipeline_spec_loader.py",
412
+ "results.py", "runner.py",
413
+ }
414
+
415
+ def test_cercle1_files_lean(self):
416
+ from pathlib import Path
417
+
418
+ repo = Path(__file__).parent.parent
419
+ core_dir = repo / "picarones" / "core"
420
+
421
+ real_modules = set()
422
+ for path in core_dir.glob("*.py"):
423
+ content = path.read_text(encoding="utf-8")
424
+ n_lines = len([l for l in content.splitlines() if l.strip()])
425
+ # Un shim a < 30 lignes ; un module Cercle 1 a > 30 lignes
426
+ if n_lines > 30:
427
+ real_modules.add(path.name)
428
+
429
+ unexpected = real_modules - self.EXPECTED_CERCLE1
430
+ assert not unexpected, (
431
+ f"Modules non-Cercle 1 réintroduits dans core/ : {unexpected}. "
432
+ "Soit les déplacer dans measurements/ (Cercle 2) ou extras/ "
433
+ "(Cercle 3), soit ajouter à EXPECTED_CERCLE1 + api-stable.md "
434
+ "via RFC."
435
+ )
436
+
437
+ missing = self.EXPECTED_CERCLE1 - real_modules
438
+ assert not missing, (
439
+ f"Modules Cercle 1 manquants : {missing}. Restaurer ou retirer "
440
+ "de EXPECTED_CERCLE1."
441
+ )
442
+
443
+
444
+ # ──────────────────────────────────────────────────────────────────────────
445
+ # 13. Doc api-stable.md présente et complète
446
+ # ──────────────────────────────────────────────────────────────────────────
447
+
448
+
449
+ class TestApiStableDoc:
450
+ def test_doc_exists(self):
451
+ from pathlib import Path
452
+
453
+ path = Path(__file__).parent.parent / "docs" / "api-stable.md"
454
+ assert path.exists(), "docs/api-stable.md manquant"
455
+ content = path.read_text(encoding="utf-8")
456
+ # Présence des 14 sections (1 par module)
457
+ for module in [
458
+ "picarones.core.corpus",
459
+ "picarones.core.modules",
460
+ "picarones.core.results",
461
+ "picarones.core.metrics",
462
+ "picarones.core.runner",
463
+ "picarones.core.pipeline_runner",
464
+ "picarones.core.pipeline_benchmark",
465
+ "picarones.core.pipeline_comparison",
466
+ "picarones.core.pipeline_spec_loader",
467
+ "picarones.core.metric_registry",
468
+ "picarones.core.metric_hooks",
469
+ "picarones.core.builtin_metrics",
470
+ "picarones.core.alto_metrics",
471
+ "picarones.core.jobs",
472
+ ]:
473
+ assert module in content, (
474
+ f"docs/api-stable.md ne mentionne pas {module}"
475
+ )
476
+
477
+ def test_doc_mentions_stability_policy(self):
478
+ from pathlib import Path
479
+
480
+ path = Path(__file__).parent.parent / "docs" / "api-stable.md"
481
+ content = path.read_text(encoding="utf-8")
482
+ # Les sections clés du contrat
483
+ assert "Politique de stabilité" in content
484
+ assert "Ce que nous garantissons" in content
485
+ assert "Bump majeur" in content