Spaces:
Running
test(harness): comble le trou — caractérisation execute_preset
Browse filesTrou 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
|
@@ -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 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
)
|