Spaces:
Sleeping
phaseD: contrat de stabilité de l'API publique + bump version 1.3.0
Browse filesPhase 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.
- docs/api-stable.md +303 -0
- pyproject.toml +1 -1
- tests/test_public_api.py +485 -0
|
@@ -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.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "picarones"
|
| 7 |
-
version = "1.
|
| 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"
|
|
@@ -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
|