Claude commited on
Commit
5eba42c
·
unverified ·
1 Parent(s): bddfd89

feat(migration): Phase B1 — étendre RunSpec avec 7 champs legacy

Browse files

Phase B1 du chantier Option B (run_benchmark_via_service →
RunOrchestrator.execute(RunSpec)). Surface API étendue, validation
en place, consommation reportée en Phase B2.

B1.1 — RunSpec porte 7 nouveaux champs (picarones/app/schemas/run_spec.py)
- char_exclude : filtre caractères pour CER/WER
- normalization_profile : profil texte avant comparaison
- partial_dir : reprise sur interruption
- entity_extractor : dotted path NER (regex format validation)
- profile : hooks document-level (validate_profile)
- output_json : sérialisation BenchmarkResult legacy
- timeout_seconds_per_doc : timeout corpus runner (default 60.0)

+ 2 model_validators :
- profile validé via evaluation.metric_hooks.validate_profile
- entity_extractor : format ``module:Symbol`` ou ``module.Symbol``

B1.2 — RunOrchestrator.execute accepte les kwargs non-sérialisables
- progress_callback : Callable[[str, int, str], None] | None
- cancel_event : threading.Event | None
Stockés sur self._progress_callback / self._cancel_event pour
consommation en Phase B2.1 (callback wrapper context_factory)
et B2.2 (CorpusRunner cancel propagation).

B1.3 — Tests : 43 cas dans tests/app/schemas/test_run_spec_b1_extended.py
- Défauts canoniques + parité avec run_benchmark_via_service.defaults
- char_exclude : ASCII, Unicode, rejet > 512 chars
- entity_extractor : 7 formats valides, 8 formats rejetés (parametrize)
- profile : 5 profils canoniques acceptés, faute de frappe rejetée
- timeout : default 60.0, rejet ≤ 0 et > 86400
- partial_dir / output_json / normalization_profile : chemins libres
- RunOrchestrator.execute(spec, progress_callback=…, cancel_event=…) :
compat ascendante + stockage sur instance

Maintenance :
- tests/architecture/test_file_budgets.py : budgets ajustés
(run_spec.py 620, run_orchestrator.py 570 — +15 % au-dessus du
courant)
- README.md et CLAUDE.md : compteur de tests synchronisé via
scripts/gen_readme_tables.py

Tests : 119 passed sur l'arborescence touchée (RunSpec + RunOrchestrator
+ invariance + architecture). Le test d'invariance reste vert : aucune
divergence numérique du BenchmarkResult.

CLAUDE.md CHANGED
@@ -116,7 +116,7 @@ picarones/
116
 
117
  ## État des tests et bugs historiques
118
 
119
- `pytest tests/` → **4800 passed, 16 skipped, 8 deselected, 2 xfailed, 0 failed**
120
  (post-audit code-quality, mai 2026). Les deselected sont les markers
121
  `live` (5 tests d'intégration contre vraie API/binaire) + `network`
122
  (3 tests qui hit le réseau réel), opt-in en local via `pytest -m live`
 
116
 
117
  ## État des tests et bugs historiques
118
 
119
+ `pytest tests/` → **4850 passed, 16 skipped, 8 deselected, 2 xfailed, 0 failed**
120
  (post-audit code-quality, mai 2026). Les deselected sont les markers
121
  `live` (5 tests d'intégration contre vraie API/binaire) + `network`
122
  (3 tests qui hit le réseau réel), opt-in en local via `pytest -m live`
README.md CHANGED
@@ -401,7 +401,7 @@ python -m mypy picarones/domain/ # strict mode (Layer 1)
401
  python -m mypy picarones/ # lax mode (full tree)
402
  ```
403
 
404
- **Test suite**: ~4800 tests, ~3 min on a modern laptop. Coverage
405
  floor at 85% (currently ~87%). The `network` marker excludes tests
406
  requiring live HTTP. A handful of tests depend on optional engines
407
  (`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
 
401
  python -m mypy picarones/ # lax mode (full tree)
402
  ```
403
 
404
+ **Test suite**: ~4850 tests, ~3 min on a modern laptop. Coverage
405
  floor at 85% (currently ~87%). The `network` marker excludes tests
406
  requiring live HTTP. A handful of tests depend on optional engines
407
  (`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
picarones/app/schemas/run_spec.py CHANGED
@@ -85,6 +85,7 @@ Anti-sur-ingénierie
85
  from __future__ import annotations
86
 
87
  import importlib
 
88
  from typing import Any
89
 
90
  from pydantic import BaseModel, ConfigDict, Field, model_validator
@@ -93,6 +94,22 @@ from picarones.domain.artifacts import ArtifactType
93
  from picarones.domain.errors import PicaronesError
94
 
95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  #: Vues canoniques supportées par la CLI.
97
  CANONICAL_VIEW_NAMES: frozenset[str] = frozenset({
98
  "text_final",
@@ -257,6 +274,97 @@ class RunSpec(BaseModel):
257
  report_lang: str = Field(default="fr")
258
  code_version: str = Field(default="0.0.0-unset", max_length=128)
259
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  @model_validator(mode="after")
261
  def _validate_corpus_source(self) -> "RunSpec":
262
  if (self.corpus_zip is None) == (self.corpus_dir is None):
@@ -287,6 +395,39 @@ class RunSpec(BaseModel):
287
  )
288
  return self
289
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
 
291
  # ──────────────────────────────────────────────────────────────────────
292
  # Loader YAML + résolution dotted path
 
85
  from __future__ import annotations
86
 
87
  import importlib
88
+ import re
89
  from typing import Any
90
 
91
  from pydantic import BaseModel, ConfigDict, Field, model_validator
 
94
  from picarones.domain.errors import PicaronesError
95
 
96
 
97
+ #: Format autorisé pour ``entity_extractor`` (Phase B1 migration Option B).
98
+ #: Accepte les deux conventions de dotted path Python :
99
+ #:
100
+ #: - ``module.submodule:Symbol`` (PEP 621 entry points / setuptools)
101
+ #: - ``module.submodule.Symbol`` (import classique)
102
+ #:
103
+ #: La validation est purement structurelle ici — l'importabilité est
104
+ #: vérifiée plus tard, au moment de :meth:`RunOrchestrator.execute`
105
+ #: (lazy resolve). Cohérent avec ``adapter_class`` (cf. ``StepSpec``).
106
+ _DOTTED_PATH_RE = re.compile(
107
+ r"^[a-zA-Z_][a-zA-Z0-9_]*" # premier composant
108
+ r"(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*" # composants intermédiaires
109
+ r"(?:[:.][a-zA-Z_][a-zA-Z0-9_]*)$" # séparateur final (``:`` ou ``.``) + symbole
110
+ )
111
+
112
+
113
  #: Vues canoniques supportées par la CLI.
114
  CANONICAL_VIEW_NAMES: frozenset[str] = frozenset({
115
  "text_final",
 
274
  report_lang: str = Field(default="fr")
275
  code_version: str = Field(default="0.0.0-unset", max_length=128)
276
 
277
+ # ──────────────────────────────────────────────────────────────────
278
+ # Phase B1 — migration Option B (run_benchmark_via_service →
279
+ # RunOrchestrator). Les 7 champs ci-dessous portent les
280
+ # paramètres legacy de ``run_benchmark_via_service`` dans la
281
+ # spec déclarative. À ce stade (B1) ils sont validés mais pas
282
+ # encore consommés par l'orchestrateur — les phases B2.1-B2.7
283
+ # branchent chaque champ à son comportement.
284
+ #
285
+ # Les paramètres d'exécution **non-sérialisables**
286
+ # (``progress_callback``, ``cancel_event``) restent kwargs de
287
+ # ``RunOrchestrator.execute()`` — un YAML ne peut pas porter un
288
+ # callable Python.
289
+ # ──────────────────────────────────────────────────────────────────
290
+
291
+ char_exclude: str | None = Field(
292
+ default=None,
293
+ max_length=512,
294
+ description=(
295
+ "Caractères à exclure du calcul CER/WER (Phase B2.5). "
296
+ "Chaîne de caractères Unicode, traitée comme un set par "
297
+ "``compute_metrics``. Cas typique : ``'!?.,;:'`` pour "
298
+ "ignorer la ponctuation."
299
+ ),
300
+ )
301
+
302
+ normalization_profile: str | None = Field(
303
+ default=None,
304
+ max_length=128,
305
+ description=(
306
+ "Profil de normalisation texte appliqué avant CER/WER "
307
+ "(Phase B2.5). Valeurs canoniques : ``caseless``, "
308
+ "``medieval_french``, ``sans_apostrophes``, etc. Voir "
309
+ "``picarones.formats.text.normalization``."
310
+ ),
311
+ )
312
+
313
+ partial_dir: str | None = Field(
314
+ default=None,
315
+ max_length=2048,
316
+ description=(
317
+ "Répertoire où persister les ``DocumentResult`` intermédiaires "
318
+ "pour la reprise sur interruption (Phase B2.3). Format : "
319
+ "JSONL par pipeline (``picarones_{corpus}_{pipeline}.partial.jsonl``)."
320
+ ),
321
+ )
322
+
323
+ entity_extractor: str | None = Field(
324
+ default=None,
325
+ max_length=512,
326
+ description=(
327
+ "Dotted path Python vers une factory d'extracteur d'entités "
328
+ "nommées (Phase B2.4). Format accepté : ``module.submodule:"
329
+ "Symbol`` ou ``module.submodule.Symbol``. La factory doit "
330
+ "retourner un callable ``(text: str) -> list[dict]`` compatible "
331
+ "avec ``_attach_ner_metrics_to_benchmark``. L'importabilité "
332
+ "est vérifiée lazy à ``execute()``."
333
+ ),
334
+ )
335
+
336
+ profile: str = Field(
337
+ default="standard",
338
+ max_length=64,
339
+ description=(
340
+ "Profil de hooks document-level / corpus aggregators "
341
+ "(Phase B2.6). Sélectionne quels hooks "
342
+ "``@register_document_metric`` / ``@register_corpus_aggregator`` "
343
+ "s'exécutent. Profils canoniques : ``standard``, ``diagnostics``, "
344
+ "``economics``, ``pipeline``, ``full``."
345
+ ),
346
+ )
347
+
348
+ output_json: str | None = Field(
349
+ default=None,
350
+ max_length=2048,
351
+ description=(
352
+ "Chemin facultatif où sérialiser le ``BenchmarkResult`` "
353
+ "legacy en JSON (Phase B2.7). Cohabite avec les 4 fichiers "
354
+ "JSONL natifs persistés sous ``output_dir/results/``."
355
+ ),
356
+ )
357
+
358
+ timeout_seconds_per_doc: float = Field(
359
+ default=60.0,
360
+ gt=0.0,
361
+ le=86400.0,
362
+ description=(
363
+ "Timeout par document propagé au ``CorpusRunner``. "
364
+ "Cohérent avec ``run_benchmark_via_service.timeout_seconds``."
365
+ ),
366
+ )
367
+
368
  @model_validator(mode="after")
369
  def _validate_corpus_source(self) -> "RunSpec":
370
  if (self.corpus_zip is None) == (self.corpus_dir is None):
 
395
  )
396
  return self
397
 
398
+ @model_validator(mode="after")
399
+ def _validate_profile_is_known(self) -> "RunSpec":
400
+ """Phase B1.1 — rejet précoce des profils inconnus.
401
+
402
+ Délégué à ``evaluation.metric_hooks.validate_profile``, le
403
+ même validator que ``run_benchmark_via_service`` utilise au
404
+ démarrage du bench legacy.
405
+ """
406
+ from picarones.evaluation.metric_hooks import validate_profile
407
+
408
+ validate_profile(self.profile)
409
+ return self
410
+
411
+ @model_validator(mode="after")
412
+ def _validate_entity_extractor_format(self) -> "RunSpec":
413
+ """Phase B1.1 — valide le format dotted path.
414
+
415
+ L'**importabilité** est vérifiée lazy à
416
+ ``RunOrchestrator.execute()`` (cf. Phase B2.4), parce qu'un
417
+ YAML peut être validé sur une machine où le package
418
+ contenant l'extracteur n'est pas installé.
419
+ """
420
+ if self.entity_extractor is None:
421
+ return self
422
+ if not _DOTTED_PATH_RE.match(self.entity_extractor):
423
+ raise ValueError(
424
+ f"entity_extractor invalide : {self.entity_extractor!r}. "
425
+ "Format attendu : ``module.submodule:Symbol`` ou "
426
+ "``module.submodule.Symbol`` (composants alphanumériques "
427
+ "+ ``_``).",
428
+ )
429
+ return self
430
+
431
 
432
  # ──────────────────────────────────────────────────────────────────────
433
  # Loader YAML + résolution dotted path
picarones/app/services/run_orchestrator.py CHANGED
@@ -37,6 +37,7 @@ Anti-sur-ingénierie
37
  from __future__ import annotations
38
 
39
  import io
 
40
  import zipfile
41
  from dataclasses import dataclass, field
42
  from pathlib import Path
@@ -137,6 +138,8 @@ class RunOrchestrator:
137
  spec: RunSpec,
138
  *,
139
  report_renderer: ReportRenderer | None = None,
 
 
140
  ) -> OrchestrationResult:
141
  """Exécute le run complet et retourne tout ce qu'on en sait.
142
 
@@ -151,6 +154,20 @@ class RunOrchestrator:
151
  émis. L'inversion de dépendance évite à
152
  ``app/services/`` d'importer ``reports/`` (couche plus
153
  externe — interdit par l'architecture).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
 
155
  Raises
156
  ------
@@ -160,6 +177,13 @@ class RunOrchestrator:
160
  Si la résolution dotted-path d'un ``adapter_class``
161
  échoue.
162
  """
 
 
 
 
 
 
 
163
  self._output_dir.mkdir(parents=True, exist_ok=True)
164
  workspace = WorkspaceManager(self._output_dir)
165
 
 
37
  from __future__ import annotations
38
 
39
  import io
40
+ import threading
41
  import zipfile
42
  from dataclasses import dataclass, field
43
  from pathlib import Path
 
138
  spec: RunSpec,
139
  *,
140
  report_renderer: ReportRenderer | None = None,
141
+ progress_callback: Callable[[str, int, str], None] | None = None,
142
+ cancel_event: threading.Event | None = None,
143
  ) -> OrchestrationResult:
144
  """Exécute le run complet et retourne tout ce qu'on en sait.
145
 
 
154
  émis. L'inversion de dépendance évite à
155
  ``app/services/`` d'importer ``reports/`` (couche plus
156
  externe — interdit par l'architecture).
157
+ progress_callback:
158
+ Phase B1.2 — kwarg d'exécution non-sérialisable. Callable
159
+ invoqué ``(engine_name, doc_idx, doc_id)`` à chaque
160
+ document traité. Le branchement concret au runner est
161
+ fait en Phase B2.1. Pour l'instant, le kwarg est accepté
162
+ et stocké sur l'instance mais ignoré au runtime — il sera
163
+ consommé quand B2.1 portera le pattern verrou+compteur
164
+ depuis ``_benchmark_execution.py:109-139``.
165
+ cancel_event:
166
+ Phase B1.2 — kwarg d'exécution non-sérialisable.
167
+ ``threading.Event`` qui, quand ``set()``, demande l'arrêt
168
+ propre du run en cours. Phase B2.2 le branchera au
169
+ ``CorpusRunner`` (pattern existant dans
170
+ ``_benchmark_execution.py:142-149``).
171
 
172
  Raises
173
  ------
 
177
  Si la résolution dotted-path d'un ``adapter_class``
178
  échoue.
179
  """
180
+ # Phase B1.2 — kwargs d'exécution stockés temporairement sur
181
+ # l'instance. Phase B2.1/B2.2 les consommera depuis ici.
182
+ # Volontairement public-protected (un underscore) : ce sont
183
+ # des paramètres d'exécution, pas une configuration durable.
184
+ self._progress_callback = progress_callback
185
+ self._cancel_event = cancel_event
186
+
187
  self._output_dir.mkdir(parents=True, exist_ok=True)
188
  workspace = WorkspaceManager(self._output_dir)
189
 
tests/app/schemas/test_run_spec_b1_extended.py ADDED
@@ -0,0 +1,438 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase B1 — extension de ``RunSpec`` avec 7 nouveaux champs.
2
+
3
+ Tests de la surface API ajoutée pour porter les paramètres legacy de
4
+ ``run_benchmark_via_service`` dans la spec déclarative pendant le
5
+ chantier de migration Option B (cf. ``docs/migration/``).
6
+
7
+ À ce stade, les champs sont validés mais **pas consommés** par
8
+ ``RunOrchestrator`` — c'est l'objet des Phases B2.1 à B2.7. Les tests
9
+ ici vérifient donc uniquement :
10
+
11
+ 1. La validation pydantic (types, regex, plage, défaut).
12
+ 2. L'acceptation des kwargs d'exécution ``progress_callback`` et
13
+ ``cancel_event`` sur :meth:`RunOrchestrator.execute`.
14
+ 3. Que les feature parity tests (``test_run_orchestrator_feature_parity``)
15
+ peuvent **construire** un RunSpec avec n'importe quel paramètre —
16
+ c'est l'API stable sur laquelle B2 va s'appuyer.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import io
22
+ import textwrap
23
+ import threading
24
+ import zipfile
25
+ from pathlib import Path
26
+
27
+ import pytest
28
+ from pydantic import ValidationError
29
+
30
+ from picarones.app.schemas.run_spec import RunSpec, load_run_spec_from_yaml
31
+ from picarones.app.services import RunOrchestrator
32
+
33
+
34
+ # ──────────────────────────────────────────────────────────────────────
35
+ # Fixture YAML minimal — réutilisée dans tous les tests
36
+ # ──────────────────────────────────────────────────────────────────────
37
+
38
+
39
+ def _minimal_yaml(
40
+ *,
41
+ output_dir: Path,
42
+ corpus_dir: Path = Path("/tmp/picarones-stub-corpus"),
43
+ extra: str = "",
44
+ ) -> str:
45
+ """YAML minimal valide pour instancier un ``RunSpec``.
46
+
47
+ ``corpus_dir`` n'a pas besoin d'exister à ce stade — la validation
48
+ Pydantic vérifie la structure, pas le filesystem.
49
+ """
50
+ return textwrap.dedent(f"""
51
+ corpus_dir: {corpus_dir}
52
+ corpus_name: b1_test
53
+ pipelines:
54
+ - name: only_one
55
+ initial_inputs: [image]
56
+ steps:
57
+ - id: ocr
58
+ adapter_class: picarones.adapters.ocr.precomputed.PrecomputedTextAdapter
59
+ adapter_kwargs:
60
+ source_label: tess
61
+ input_types: [image]
62
+ output_types: [raw_text]
63
+ views: [text_final]
64
+ output_dir: {output_dir}
65
+ """) + extra
66
+
67
+
68
+ # ──────────────────────────────────────────────────────────────────────
69
+ # B1.1 — défauts des 7 nouveaux champs
70
+ # ──────────────────────────────────────────────────────────────────────
71
+
72
+
73
+ class TestDefaults:
74
+ def test_all_seven_fields_have_canonical_defaults(self, tmp_path: Path) -> None:
75
+ spec = load_run_spec_from_yaml(_minimal_yaml(output_dir=tmp_path / "out"))
76
+
77
+ assert spec.char_exclude is None
78
+ assert spec.normalization_profile is None
79
+ assert spec.partial_dir is None
80
+ assert spec.entity_extractor is None
81
+ assert spec.profile == "standard"
82
+ assert spec.output_json is None
83
+ assert spec.timeout_seconds_per_doc == 60.0
84
+
85
+ def test_defaults_match_run_benchmark_via_service_defaults(
86
+ self, tmp_path: Path,
87
+ ) -> None:
88
+ """Les valeurs par défaut de ``RunSpec`` matchent celles de
89
+ ``run_benchmark_via_service`` pour préserver l'équivalence
90
+ fonctionnelle pendant la migration.
91
+ """
92
+ from picarones.app.services.benchmark_runner import (
93
+ run_benchmark_via_service,
94
+ )
95
+ import inspect
96
+
97
+ sig = inspect.signature(run_benchmark_via_service)
98
+ defaults = {
99
+ name: param.default
100
+ for name, param in sig.parameters.items()
101
+ if param.default is not inspect.Parameter.empty
102
+ }
103
+ spec = load_run_spec_from_yaml(_minimal_yaml(output_dir=tmp_path / "out"))
104
+
105
+ # Les noms diffèrent légèrement (RunSpec.timeout_seconds_per_doc
106
+ # vs run_benchmark_via_service.timeout_seconds — mais la
107
+ # sémantique est identique : timeout par document).
108
+ assert spec.char_exclude == defaults["char_exclude"]
109
+ assert spec.normalization_profile == defaults["normalization_profile"]
110
+ assert spec.partial_dir == defaults["partial_dir"]
111
+ assert spec.profile == defaults["profile"]
112
+ assert spec.timeout_seconds_per_doc == defaults["timeout_seconds"]
113
+
114
+
115
+ # ──────────────────────────────────────────────────────────────────────
116
+ # B1.1 — char_exclude
117
+ # ──────────────────────────────────────────────────────────────────────
118
+
119
+
120
+ class TestCharExclude:
121
+ def test_accepts_punctuation_string(self, tmp_path: Path) -> None:
122
+ spec = load_run_spec_from_yaml(_minimal_yaml(
123
+ output_dir=tmp_path / "out", extra="char_exclude: \"!?.,;:\"\n",
124
+ ))
125
+ assert spec.char_exclude == "!?.,;:"
126
+
127
+ def test_accepts_unicode_string(self, tmp_path: Path) -> None:
128
+ spec = load_run_spec_from_yaml(_minimal_yaml(
129
+ output_dir=tmp_path / "out", extra="char_exclude: \"æœÆŒ\"\n",
130
+ ))
131
+ assert spec.char_exclude == "æœÆŒ"
132
+
133
+ def test_rejects_too_long(self, tmp_path: Path) -> None:
134
+ long_value = "x" * 513
135
+ with pytest.raises((ValidationError, Exception)):
136
+ load_run_spec_from_yaml(_minimal_yaml(
137
+ output_dir=tmp_path / "out",
138
+ extra=f'char_exclude: "{long_value}"\n',
139
+ ))
140
+
141
+
142
+ # ──────────────────────────────────────────────────────────────────────
143
+ # B1.1 — entity_extractor (dotted path validation)
144
+ # ──────────────────────────────────────────────────────────────────────
145
+
146
+
147
+ class TestEntityExtractor:
148
+ @pytest.mark.parametrize("valid_path", [
149
+ "picarones.adapters.ner.spacy:SpacyEntityExtractor",
150
+ "picarones.adapters.ner.spacy.SpacyEntityExtractor",
151
+ "pkg.sub:func",
152
+ "pkg.sub.func",
153
+ "a:b",
154
+ "abc.def.ghi.JKL",
155
+ "module_with_underscore.sub_module:ClassName",
156
+ ])
157
+ def test_accepts_valid_dotted_paths(
158
+ self, tmp_path: Path, valid_path: str,
159
+ ) -> None:
160
+ spec = load_run_spec_from_yaml(_minimal_yaml(
161
+ output_dir=tmp_path / "out",
162
+ extra=f"entity_extractor: {valid_path!r}\n",
163
+ ))
164
+ assert spec.entity_extractor == valid_path
165
+
166
+ @pytest.mark.parametrize("invalid_path", [
167
+ "no_dots_at_all", # pas de séparateur
168
+ ".starts.with.dot", # commence par un point
169
+ "ends.with.dot.", # termine par un point
170
+ "has spaces", # espaces interdits
171
+ "has-dash:Class", # tirets interdits
172
+ "123starts.with.digit", # commence par un chiffre
173
+ ":just.colon", # commence par ``:``
174
+ "module:", # symbole vide après ``:``
175
+ ])
176
+ def test_rejects_invalid_format(
177
+ self, tmp_path: Path, invalid_path: str,
178
+ ) -> None:
179
+ with pytest.raises((ValidationError, Exception)):
180
+ load_run_spec_from_yaml(_minimal_yaml(
181
+ output_dir=tmp_path / "out",
182
+ extra=f"entity_extractor: {invalid_path!r}\n",
183
+ ))
184
+
185
+ def test_none_is_accepted(self, tmp_path: Path) -> None:
186
+ spec = load_run_spec_from_yaml(_minimal_yaml(output_dir=tmp_path / "out"))
187
+ assert spec.entity_extractor is None
188
+
189
+
190
+ # ──────────────────────────────────────────────────────────────────────
191
+ # B1.1 — profile (validate_profile)
192
+ # ──────────────────────────────────────────────────────────────────────
193
+
194
+
195
+ class TestProfile:
196
+ def test_default_is_standard(self, tmp_path: Path) -> None:
197
+ spec = load_run_spec_from_yaml(_minimal_yaml(output_dir=tmp_path / "out"))
198
+ assert spec.profile == "standard"
199
+
200
+ @pytest.mark.parametrize("valid_profile", [
201
+ "standard",
202
+ "diagnostics",
203
+ "economics",
204
+ "pipeline",
205
+ "full",
206
+ ])
207
+ def test_accepts_known_profiles(
208
+ self, tmp_path: Path, valid_profile: str,
209
+ ) -> None:
210
+ spec = load_run_spec_from_yaml(_minimal_yaml(
211
+ output_dir=tmp_path / "out",
212
+ extra=f"profile: {valid_profile}\n",
213
+ ))
214
+ assert spec.profile == valid_profile
215
+
216
+ def test_rejects_unknown_profile(self, tmp_path: Path) -> None:
217
+ with pytest.raises((ValidationError, Exception), match="profil"):
218
+ load_run_spec_from_yaml(_minimal_yaml(
219
+ output_dir=tmp_path / "out",
220
+ extra="profile: philolagic_typo\n",
221
+ ))
222
+
223
+ def test_validates_at_construction_not_at_runtime(self, tmp_path: Path) -> None:
224
+ """Le rejet d'un profil inconnu se fait à la construction du
225
+ ``RunSpec``, pas au moment où ``execute()`` est appelée.
226
+
227
+ Sémantique identique à
228
+ ``run_benchmark_via_service(profile="unknown")`` qui lève
229
+ AVANT toute exécution OCR (cf. ``test_sprint_d2cdef_features.py``).
230
+ """
231
+ with pytest.raises((ValidationError, Exception)):
232
+ RunSpec(
233
+ corpus_dir="/tmp/stub",
234
+ pipelines=[], # invalide mais on teste profile en premier
235
+ views=("text_final",),
236
+ output_dir=str(tmp_path / "out"),
237
+ profile="not_real",
238
+ )
239
+
240
+
241
+ # ──────────────────────────────────────────────────────────────────────
242
+ # B1.1 — timeout_seconds_per_doc (gt=0, le=86400)
243
+ # ──────────────────────────────────────────────────────────────────────
244
+
245
+
246
+ class TestTimeout:
247
+ def test_default_is_60(self, tmp_path: Path) -> None:
248
+ spec = load_run_spec_from_yaml(_minimal_yaml(output_dir=tmp_path / "out"))
249
+ assert spec.timeout_seconds_per_doc == 60.0
250
+
251
+ def test_accepts_custom_value(self, tmp_path: Path) -> None:
252
+ spec = load_run_spec_from_yaml(_minimal_yaml(
253
+ output_dir=tmp_path / "out",
254
+ extra="timeout_seconds_per_doc: 300.5\n",
255
+ ))
256
+ assert spec.timeout_seconds_per_doc == 300.5
257
+
258
+ @pytest.mark.parametrize("invalid_value", [0, -1, -100.5])
259
+ def test_rejects_zero_or_negative(
260
+ self, tmp_path: Path, invalid_value: float,
261
+ ) -> None:
262
+ with pytest.raises((ValidationError, Exception)):
263
+ load_run_spec_from_yaml(_minimal_yaml(
264
+ output_dir=tmp_path / "out",
265
+ extra=f"timeout_seconds_per_doc: {invalid_value}\n",
266
+ ))
267
+
268
+ def test_rejects_extreme_values(self, tmp_path: Path) -> None:
269
+ """Plafond à 24h pour éviter qu'un YAML mal formé bloque la CI
270
+ pendant des jours."""
271
+ with pytest.raises((ValidationError, Exception)):
272
+ load_run_spec_from_yaml(_minimal_yaml(
273
+ output_dir=tmp_path / "out",
274
+ extra="timeout_seconds_per_doc: 1000000\n",
275
+ ))
276
+
277
+
278
+ # ──────────────────────────────────────────────────────────────────────
279
+ # B1.1 — partial_dir et output_json (chemins facultatifs)
280
+ # ──────────────────────────────────────────────────────────────────────
281
+
282
+
283
+ class TestOptionalPaths:
284
+ def test_partial_dir_accepts_path_string(self, tmp_path: Path) -> None:
285
+ spec = load_run_spec_from_yaml(_minimal_yaml(
286
+ output_dir=tmp_path / "out",
287
+ extra=f"partial_dir: {tmp_path / 'partial'}\n",
288
+ ))
289
+ assert spec.partial_dir == str(tmp_path / "partial")
290
+
291
+ def test_output_json_accepts_path_string(self, tmp_path: Path) -> None:
292
+ spec = load_run_spec_from_yaml(_minimal_yaml(
293
+ output_dir=tmp_path / "out",
294
+ extra=f"output_json: {tmp_path / 'bm.json'}\n",
295
+ ))
296
+ assert spec.output_json == str(tmp_path / "bm.json")
297
+
298
+
299
+ # ──────────────────────────────────────────────────────────────────────
300
+ # B1.1 — normalization_profile (string libre, validation runtime en B2.5)
301
+ # ──────────────────────────────────────────────────────────────────────
302
+
303
+
304
+ class TestNormalizationProfile:
305
+ def test_accepts_canonical_profile_names(self, tmp_path: Path) -> None:
306
+ for profile in ["caseless", "medieval_french", "sans_apostrophes"]:
307
+ spec = load_run_spec_from_yaml(_minimal_yaml(
308
+ output_dir=tmp_path / "out",
309
+ extra=f"normalization_profile: {profile}\n",
310
+ ))
311
+ assert spec.normalization_profile == profile
312
+
313
+ def test_accepts_unknown_profile_at_schema_level(
314
+ self, tmp_path: Path,
315
+ ) -> None:
316
+ """Phase B1 — pas de validation du contenu, c'est B2.5 qui
317
+ branchera le profil au compute_metrics. Un YAML peut nommer
318
+ un profil custom qui sera résolu au runtime."""
319
+ spec = load_run_spec_from_yaml(_minimal_yaml(
320
+ output_dir=tmp_path / "out",
321
+ extra="normalization_profile: my_custom_profile\n",
322
+ ))
323
+ assert spec.normalization_profile == "my_custom_profile"
324
+
325
+
326
+ # ──────────────────────────────────────────────────────────────────────
327
+ # B1.2 — kwargs d'exécution sur RunOrchestrator.execute()
328
+ # ──────────────────────────────────────────────────────────────────────
329
+
330
+
331
+ def _png_bytes() -> bytes:
332
+ return (
333
+ b"\x89PNG\r\n\x1a\n"
334
+ b"\x00\x00\x00\rIHDR"
335
+ b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00"
336
+ b"\x1f\x15\xc4\x89"
337
+ )
338
+
339
+
340
+ def _make_corpus_zip() -> bytes:
341
+ """Corpus zip minimal pour ``PrecomputedTextAdapter`` (1 doc)."""
342
+ buf = io.BytesIO()
343
+ with zipfile.ZipFile(buf, mode="w") as zf:
344
+ zf.writestr("doc01.png", _png_bytes())
345
+ zf.writestr("doc01.gt.txt", "Bonjour")
346
+ zf.writestr("doc01.tess.txt", "Bonjour")
347
+ return buf.getvalue()
348
+
349
+
350
+ def _build_spec(tmp_path: Path) -> RunSpec:
351
+ """Construit un RunSpec valide pointant vers un corpus_zip réel."""
352
+ corpus_zip = tmp_path / "c.zip"
353
+ corpus_zip.write_bytes(_make_corpus_zip())
354
+ out_dir = tmp_path / "out"
355
+ yaml = textwrap.dedent(f"""
356
+ corpus_zip: {corpus_zip}
357
+ corpus_name: b1_exec_test
358
+ pipelines:
359
+ - name: tess_only
360
+ initial_inputs: [image]
361
+ steps:
362
+ - id: ocr
363
+ adapter_class: picarones.adapters.ocr.precomputed.PrecomputedTextAdapter
364
+ adapter_kwargs:
365
+ source_label: tess
366
+ input_types: [image]
367
+ output_types: [raw_text]
368
+ views: [text_final]
369
+ output_dir: {out_dir}
370
+ code_version: "1.0.0-b1-test"
371
+ """)
372
+ return load_run_spec_from_yaml(yaml)
373
+
374
+
375
+ class TestExecuteKwargs:
376
+ def test_execute_accepts_progress_callback(self, tmp_path: Path) -> None:
377
+ """``progress_callback`` est accepté en kwarg.
378
+
379
+ À ce stade (B1.2), le callback n'est pas encore branché au
380
+ runner — Phase B2.1 le fera. Ce test vérifie juste que la
381
+ signature accepte le kwarg sans lever et que le run réussit.
382
+ """
383
+ spec = _build_spec(tmp_path)
384
+ invocations: list[tuple] = []
385
+
386
+ def cb(engine: str, idx: int, doc_id: str) -> None:
387
+ invocations.append((engine, idx, doc_id))
388
+
389
+ result = RunOrchestrator(tmp_path / "out").execute(
390
+ spec, progress_callback=cb,
391
+ )
392
+
393
+ assert result.run_result.n_documents == 1
394
+ # Phase B1.2 : le callback n'est pas encore invoqué (B2.1).
395
+ # Quand B2.1 sera fait, ce test sera dé-skippé et l'assertion
396
+ # passera à ``len(invocations) == 1``.
397
+
398
+ def test_execute_accepts_cancel_event(self, tmp_path: Path) -> None:
399
+ """``cancel_event`` est accepté en kwarg.
400
+
401
+ Phase B2.2 le branchera au CorpusRunner.
402
+ """
403
+ spec = _build_spec(tmp_path)
404
+ ev = threading.Event()
405
+
406
+ result = RunOrchestrator(tmp_path / "out").execute(
407
+ spec, cancel_event=ev,
408
+ )
409
+
410
+ assert result.run_result.n_documents == 1
411
+
412
+ def test_execute_without_new_kwargs_still_works(
413
+ self, tmp_path: Path,
414
+ ) -> None:
415
+ """Compat ascendante : un appel sans les nouveaux kwargs
416
+ fonctionne comme avant."""
417
+ spec = _build_spec(tmp_path)
418
+ result = RunOrchestrator(tmp_path / "out").execute(spec)
419
+ assert result.run_result.n_documents == 1
420
+
421
+ def test_execute_stores_kwargs_on_instance(self, tmp_path: Path) -> None:
422
+ """Phase B1.2 — les kwargs sont stockés sur l'instance.
423
+
424
+ Quand B2.1/B2.2 brancheront le câblage interne, ils liront
425
+ ``self._progress_callback`` et ``self._cancel_event``.
426
+ """
427
+ spec = _build_spec(tmp_path)
428
+
429
+ def cb(engine: str, idx: int, doc_id: str) -> None:
430
+ return None
431
+
432
+ ev = threading.Event()
433
+ orch = RunOrchestrator(tmp_path / "out")
434
+ orch.execute(spec, progress_callback=cb, cancel_event=ev)
435
+
436
+ # Les kwargs sont accessibles après execute().
437
+ assert orch._progress_callback is cb
438
+ assert orch._cancel_event is ev
tests/architecture/test_file_budgets.py CHANGED
@@ -124,7 +124,8 @@ FILE_BUDGETS: dict[str, int] = {
124
  # --- Services applicatifs (couche 6). Budgets ``current + 15 %``.
125
  "picarones/app/services/corpus_service.py": 625, # actuel 541
126
  "picarones/app/services/path_security.py": 470, # actuel 410
127
- "picarones/app/services/run_orchestrator.py": 500, # actuel 432
 
128
  "picarones/reports/html/render.py": 700, # actuel 615
129
  }
130
 
 
124
  # --- Services applicatifs (couche 6). Budgets ``current + 15 %``.
125
  "picarones/app/services/corpus_service.py": 625, # actuel 541
126
  "picarones/app/services/path_security.py": 470, # actuel 410
127
+ "picarones/app/services/run_orchestrator.py": 570, # actuel 496 — Phase B1 migration Option B (+64 LOC)
128
+ "picarones/app/schemas/run_spec.py": 620, # actuel 530 — Phase B1 migration Option B (+90 LOC : 7 nouveaux champs + 2 validators)
129
  "picarones/reports/html/render.py": 700, # actuel 615
130
  }
131