Claude commited on
Commit
f0773ce
·
unverified ·
1 Parent(s): a125a5f

test(harness): comble le trou — caractérisation execute_preset

Browse files

Trou assumé restant du harnais désormais comblé. execute_preset
(variante objets domain pré-construits : CLI diagnose/economics/
edition + worker web) partage _execute_with_partial /
_make_context_factory mais a une surface PRÉSET-SPÉCIFIQUE que
Phase B touchera.

6 tests (recette _MockOCR + prepare_preset_args, déterministe,
zéro OCR/réseau) :
- happy path : 4 artefacts + CER déterministe
- ⚠️ contrat hotfix trou #9 (B3-final) : output_json SANS
corpus_legacy ⇒ ValueError "impossible de reloader" (vérifié
empiriquement) ; AVEC ⇒ JSON legacy écrit. Phase B réorganisant
_persist_legacy_benchmark_json ne doit pas casser ce contrat.
- doc_idx GLOBAL via préset (2 engines × 3 docs ⇒ 0..5)
- cancel pré-set court-circuite
- GARDE du fix resume/vues VIA le chemin préset (preset délègue à
_execute_with_partial → le fix doit y tenir aussi : vérifié)

23 tests harnais verts (17 execute + 6 execute_preset), lint
propre. Reste honnêtement non couvert : aucun (le harnais couvre
les 2 entrées publiques stateful + les 5 cas de risque + le défaut
corrigé, sur execute ET execute_preset).

https://claude.ai/code/session_01EmLiMPJJuB44QHEFzDWUvF

tests/golden/test_run_orchestrator_characterization.py CHANGED
@@ -18,8 +18,14 @@ attrape ». Cinq groupes, un par cas de risque identifié :
18
  threads parallèles : isolation cancel/progress, pas de fuite
19
  d'état entre instances.
20
 
21
- Déterminisme : ``PrecomputedTextAdapter`` (lit ``<stem>.<label>.txt``,
22
- aucun OCR/réseau). Un garde explicite
 
 
 
 
 
 
23
  (:meth:`TestGoldenMultiTopology.test_snapshot_is_deterministic`)
24
  échoue si le snapshot n'est pas reproductible — un golden flaky
25
  serait pire que pas de golden.
@@ -38,8 +44,11 @@ from typing import Any
38
 
39
  import pytest
40
 
 
41
  from picarones.app.schemas.run_spec import load_run_spec_from_yaml
42
- from picarones.app.services import RunOrchestrator
 
 
43
 
44
  _FIX_DIR = Path(__file__).parent / "fixtures" / "run_orchestrator"
45
 
@@ -587,3 +596,194 @@ class TestConcurrencyIsolation:
587
  )
588
  # A annulé : strictement moins que 6.
589
  assert len(prog_a) < 6, f"A non annulé : {prog_a}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  threads parallèles : isolation cancel/progress, pas de fuite
19
  d'état entre instances.
20
 
21
+ 6. :class:`TestExecutePresetCharacterization` chemin
22
+ ``execute_preset`` (objets domain pré-construits : CLI
23
+ diagnose/economics/edition + worker web) : hotfix ``corpus_legacy``
24
+ (trou #9 B3-final), doc_idx global, cancel, resume.
25
+
26
+ Déterminisme : ``PrecomputedTextAdapter`` (lit ``<stem>.<label>.txt``)
27
+ pour ``execute`` ; ``_MockOCR`` en mémoire pour ``execute_preset`` —
28
+ aucun OCR/réseau. Un garde explicite
29
  (:meth:`TestGoldenMultiTopology.test_snapshot_is_deterministic`)
30
  échoue si le snapshot n'est pas reproductible — un golden flaky
31
  serait pire que pas de golden.
 
44
 
45
  import pytest
46
 
47
+ from picarones.adapters.ocr.base import BaseOCRAdapter
48
  from picarones.app.schemas.run_spec import load_run_spec_from_yaml
49
+ from picarones.app.services import RunOrchestrator, prepare_preset_args
50
+ from picarones.domain.artifacts import Artifact, ArtifactType
51
+ from picarones.evaluation.corpus import Corpus, Document
52
 
53
  _FIX_DIR = Path(__file__).parent / "fixtures" / "run_orchestrator"
54
 
 
596
  )
597
  # A annulé : strictement moins que 6.
598
  assert len(prog_a) < 6, f"A non annulé : {prog_a}"
599
+
600
+
601
+ # ---------------------------------------------------------------------------
602
+ # 6. execute_preset — trou comblé (objets domain pré-construits)
603
+ # ---------------------------------------------------------------------------
604
+
605
+ class _MockOCR(BaseOCRAdapter):
606
+ """Adapter déterministe en mémoire (aucun OCR/réseau) : écrit
607
+ ``"hello"`` et retourne un RAW_TEXT. Recette copiée verbatim de
608
+ ``tests/app/services/test_python_helpers.py`` (infra préset
609
+ canonique)."""
610
+
611
+ def __init__(self, name: str = "mock") -> None:
612
+ self._name = name
613
+
614
+ @property
615
+ def name(self) -> str:
616
+ return self._name
617
+
618
+ def execute(self, inputs: Any, params: Any, context: Any) -> Any:
619
+ d = Path(context.workspace_uri)
620
+ d.mkdir(parents=True, exist_ok=True)
621
+ p = d / f"{context.document_id}.txt"
622
+ p.write_text("hello", encoding="utf-8")
623
+ return {ArtifactType.RAW_TEXT: Artifact(
624
+ id=f"{context.document_id}:{self._name}:raw_text",
625
+ document_id=context.document_id,
626
+ type=ArtifactType.RAW_TEXT,
627
+ produced_by_step="ocr",
628
+ uri=str(p),
629
+ )}
630
+
631
+
632
+ def _legacy_corpus(tmp: Path, n: int = 3) -> Any:
633
+ docs = []
634
+ for i in range(1, n + 1):
635
+ img = tmp / f"doc{i:02d}.png"
636
+ img.write_bytes(b"x")
637
+ docs.append(Document(
638
+ image_path=img, ground_truth="hello", doc_id=f"doc{i:02d}",
639
+ ))
640
+ return Corpus(name="preset_charac", documents=docs)
641
+
642
+
643
+ def _preset_run(
644
+ tmp: Path, n: int, engines: list[Any], **prep: Any,
645
+ ) -> tuple[Any, Any, Any]:
646
+ """Recette préset déterministe : Corpus mémoire + MockOCR →
647
+ prepare_preset_args → execute_preset. Retourne
648
+ (OrchestrationResult, corpus, PresetArgs)."""
649
+ # Sépare les kwargs de CONTRÔLE (réservés au harnais) des kwargs
650
+ # de ``prepare_preset_args``.
651
+ cb = prep.pop("_cb", None)
652
+ ev = prep.pop("_ev", None)
653
+ pass_legacy = prep.pop("_pass_corpus_legacy", True)
654
+
655
+ (tmp / "src").mkdir(parents=True, exist_ok=True)
656
+ corpus = _legacy_corpus(tmp / "src", n)
657
+ out = tmp / "out"
658
+ args = prepare_preset_args(
659
+ corpus, engines,
660
+ workspace_dir=tmp / "ws", output_dir=out, **prep,
661
+ )
662
+ kw: dict[str, Any] = {}
663
+ if pass_legacy:
664
+ kw["corpus_legacy"] = corpus
665
+ res = RunOrchestrator(out).execute_preset(
666
+ spec=args.spec,
667
+ corpus_spec=args.corpus_spec,
668
+ extracted_dir=args.extracted_dir,
669
+ pipeline_specs=args.pipeline_specs,
670
+ adapter_resolver=args.adapter_resolver,
671
+ adapter_kwargs=args.adapter_kwargs,
672
+ progress_callback=cb,
673
+ cancel_event=ev,
674
+ **kw,
675
+ )
676
+ return res, corpus, args
677
+
678
+
679
+ class TestExecutePresetCharacterization:
680
+ """Trou comblé : ``execute_preset`` (variante objets domain
681
+ pré-construits, utilisée par CLI ``diagnose/economics/edition``
682
+ et le worker web). Caractérise le surface-risque PRÉSET-
683
+ SPÉCIFIQUE pour Phase B."""
684
+
685
+ def test_preset_happy_path_four_artifacts_deterministic_cer(
686
+ self, tmp_path: Path,
687
+ ) -> None:
688
+ res, _, _ = _preset_run(tmp_path, 3, [_MockOCR()])
689
+ assert res.run_result.n_documents == 3
690
+ rd = tmp_path / "out" / "results"
691
+ for f in (
692
+ "run_manifest.json", "pipeline_results.jsonl",
693
+ "view_results.jsonl", "artifacts_index.jsonl",
694
+ ):
695
+ assert (rd / f).exists(), f"artefact manquant : {f}"
696
+ pr, vr = sorted({r["document_id"] for r in _jsonl(rd / "pipeline_results.jsonl")}), \
697
+ sorted({r["document_id"] for r in _jsonl(rd / "view_results.jsonl")})
698
+ assert pr == vr == ["doc01", "doc02", "doc03"]
699
+
700
+ def test_preset_corpus_legacy_hotfix_writes_legacy_json(
701
+ self, tmp_path: Path,
702
+ ) -> None:
703
+ """Trou #9 (B3-final) : ``output_json`` + ``corpus_legacy``
704
+ fourni ⇒ JSON legacy écrit SANS erreur (court-circuite le
705
+ reload depuis ``workspace_dir`` qui n'a que des .gt.txt)."""
706
+ oj = tmp_path / "legacy.json"
707
+ res, _, _ = _preset_run(
708
+ tmp_path, 2, [_MockOCR()], output_json=oj,
709
+ )
710
+ assert res.run_result.n_documents == 2
711
+ assert oj.exists(), "JSON legacy non écrit malgré corpus_legacy"
712
+
713
+ def test_preset_without_corpus_legacy_reproduces_trou9_DEFECT(
714
+ self, tmp_path: Path,
715
+ ) -> None:
716
+ """⚠️ CONTRAT DU HOTFIX ⚠️ : ``output_json`` SANS
717
+ ``corpus_legacy`` ⇒ ``ValueError`` (reload impossible depuis
718
+ le ``workspace_dir`` synthétisé). C'est précisément le bug
719
+ que le hotfix corrige : si Phase B réorganise
720
+ ``_persist_legacy_benchmark_json``, ce contrat ne doit pas
721
+ changer en silence (sinon le hotfix devient inopérant ou le
722
+ message d'erreur trompeur revient)."""
723
+ oj = tmp_path / "legacy.json"
724
+ with pytest.raises(ValueError, match="impossible de reloader"):
725
+ _preset_run(
726
+ tmp_path, 2, [_MockOCR()],
727
+ output_json=oj, _pass_corpus_legacy=False,
728
+ )
729
+
730
+ def test_preset_doc_idx_global_across_two_engines(
731
+ self, tmp_path: Path,
732
+ ) -> None:
733
+ """``execute_preset`` partage ``_make_context_factory`` :
734
+ 2 engines (⇒ 2 pipelines) × 3 docs ⇒ compteur GLOBAL contigu
735
+ 0..5 (même invariant que ``execute``, via le chemin préset)."""
736
+ calls: list[int] = []
737
+ _preset_run(
738
+ tmp_path, 3, [_MockOCR("a"), _MockOCR("b")],
739
+ _cb=lambda e, i, d: calls.append(i),
740
+ )
741
+ assert sorted(calls) == [0, 1, 2, 3, 4, 5], (
742
+ f"compteur préset non global/contigu : {sorted(calls)}"
743
+ )
744
+
745
+ def test_preset_preset_cancel_short_circuits(
746
+ self, tmp_path: Path,
747
+ ) -> None:
748
+ ev = threading.Event()
749
+ ev.set()
750
+ t0 = time.monotonic()
751
+ _preset_run(tmp_path, 5, [_MockOCR()], _ev=ev)
752
+ assert time.monotonic() - t0 < 10.0
753
+
754
+ def test_preset_resume_keeps_views_complete_FIX_GUARD(
755
+ self, tmp_path: Path,
756
+ ) -> None:
757
+ """Le chemin préset délègue à ``_execute_with_partial`` : le
758
+ FIX resume/vues doit tenir AUSSI via ``execute_preset``.
759
+ Interruption réelle puis resume ⇒ pipeline ET vues complets
760
+ (== run propre). Garde anti-régression du fix par le chemin
761
+ préset."""
762
+ partial = tmp_path / "pd"
763
+ partial.mkdir()
764
+ ev = threading.Event()
765
+ seen = {"n": 0}
766
+
767
+ def cb(e: str, i: int, d: str) -> None:
768
+ seen["n"] += 1
769
+ if seen["n"] == 2:
770
+ ev.set()
771
+
772
+ # Run 1 : interrompu après 2 docs.
773
+ _preset_run(
774
+ tmp_path / "r1", 5, [_MockOCR()],
775
+ partial_dir=str(partial), _cb=cb, _ev=ev,
776
+ )
777
+ # Run 2 : même partial_dir, sans cancel → doit compléter.
778
+ res2, _, _ = _preset_run(
779
+ tmp_path / "r2", 5, [_MockOCR()], partial_dir=str(partial),
780
+ )
781
+ rd = tmp_path / "r2" / "out" / "results"
782
+ pr = sorted({r["document_id"] for r in _jsonl(rd / "pipeline_results.jsonl")})
783
+ vr = sorted({r["document_id"] for r in _jsonl(rd / "view_results.jsonl")})
784
+ full = ["doc01", "doc02", "doc03", "doc04", "doc05"]
785
+ assert pr == full, f"pipeline incomplet (préset resume) : {pr}"
786
+ assert vr == full, (
787
+ f"vues incomplètes au resume PRÉSET : {vr} — le fix "
788
+ "resume/vues ne tient pas via execute_preset"
789
+ )