From 7761010c9374e393772aad26c3c9eb2ec1bd6f9b Mon Sep 17 00:00:00 2001 From: guigerdts Date: Tue, 25 Aug 2026 16:36:37 +0000 Subject: [PATCH] test: align backend and frontend suites with 3-stage numbers pipeline --- backend/tests/gen/test_gen_api.py | 10 +- backend/tests/gen/test_gen_generate.py | 10 +- backend/tests/pipeline/conftest.py | 104 +----------------- backend/tests/pipeline/test_pipeline_api.py | 15 +-- .../tests/pipeline/test_pipeline_autotrain.py | 75 ------------- .../pipeline/test_pipeline_cold_chain.py | 11 +- .../tests/pipeline/test_pipeline_context.py | 89 --------------- .../tests/pipeline/test_pipeline_failures.py | 44 ++++++-- .../tests/pipeline/test_pipeline_healing.py | 14 +-- frontend/src/pages/MisNumeros.test.tsx | 16 +-- frontend/src/types/pipeline.ts | 7 +- 11 files changed, 72 insertions(+), 323 deletions(-) delete mode 100644 backend/tests/pipeline/test_pipeline_autotrain.py delete mode 100644 backend/tests/pipeline/test_pipeline_context.py diff --git a/backend/tests/gen/test_gen_api.py b/backend/tests/gen/test_gen_api.py index 94fe5ab..0c50e59 100644 --- a/backend/tests/gen/test_gen_api.py +++ b/backend/tests/gen/test_gen_api.py @@ -247,12 +247,14 @@ def test_generate_idempotent_over_api(self, client: TestClient, db, seed_gen_dat assert second.status_code == 200 assert first.json()["data"]["snapshot_id"] == second.json()["data"]["snapshot_id"] - def test_generate_no_selection_returns_404(self, client: TestClient, db, seed_gen_data) -> None: - """No active selection → 404 with GEN_NO_SELECTION (real service).""" + def test_generate_no_selection_uses_fallback(self, client: TestClient, db, seed_gen_data) -> None: + """No active selection → gen succeeds using deterministic fallback.""" ids = seed_gen_data(selection_status="retired") resp = client.post(f"{PREFIX}/generate", json={"lottery_id": ids["lottery_id"]}) - assert resp.status_code == 404 - assert resp.json()["error"]["code"] == "GEN_NO_SELECTION" + assert resp.status_code == 200 + data = resp.json()["data"] + assert data["selection_id"] == 0 + assert data["fingerprint"] is not None def test_generate_invalid_count_returns_422( self, client: TestClient, db, seed_gen_data diff --git a/backend/tests/gen/test_gen_generate.py b/backend/tests/gen/test_gen_generate.py index b66a42d..432bae4 100644 --- a/backend/tests/gen/test_gen_generate.py +++ b/backend/tests/gen/test_gen_generate.py @@ -264,12 +264,12 @@ def test_lottery_not_found_raises(self, db: Session) -> None: _service(db).generate(lottery_id=9999) assert exc_info.value.code == GenServiceError.GEN_LOTTERY_NOT_FOUND - def test_no_selection_raises(self, db: Session, seed_gen_data) -> None: - """No active F12 selection → GEN_NO_SELECTION (GEN-003, GEN-013).""" + def test_no_selection_uses_fallback(self, db: Session, seed_gen_data) -> None: + """No active F12 selection → gen succeeds using deterministic fallback.""" ids = seed_gen_data(selection_status="retired") - with pytest.raises(GenServiceError) as exc_info: - _service(db).generate(lottery_id=ids["lottery_id"]) - assert exc_info.value.code == GenServiceError.GEN_NO_SELECTION + result = _service(db).generate(lottery_id=ids["lottery_id"]) + assert result.selection_id == 0 + assert result.fingerprint is not None def test_no_distribution_raises(self, db: Session, seed_gen_data) -> None: """No active F5 distribution → GEN_NO_DISTRIBUTION, zero combos (GEN-014).""" diff --git a/backend/tests/pipeline/conftest.py b/backend/tests/pipeline/conftest.py index e63293f..6ba3ef1 100644 --- a/backend/tests/pipeline/conftest.py +++ b/backend/tests/pipeline/conftest.py @@ -23,39 +23,16 @@ from backend.app.models.lottery import Lottery from backend.app.models.super_number import SuperNumber -# Single-threaded numeric kernels BEFORE numpy/torch import: small-tensor -# training is dominated by thread-pool contention on many-core hosts. -os.environ.setdefault("OMP_NUM_THREADS", "1") -os.environ.setdefault("MKL_NUM_THREADS", "1") - -try: # torch is imported lazily by dl.engine; pin its intra-op pool too. - import torch - - torch.set_num_threads(1) -except ImportError: # pragma: no cover - torch is a hard dep of dl extras - pass - # Canonical chain order under test (spec R1/R3). STAGE_ORDER: tuple[str, ...] = ( "stats", "features", - "ml", - "dl", - "bt", - "rank", - "select", "gen", ) -# bt enforces >=100 imported draws (BTE-07); stay just above the floor. -_NUM_DRAWS = 105 - def _seed_lottery_with_draws(db: Session) -> int: """Insert one lottery plus deterministic draw history; return its id.""" - # A compact 1..8/choose-3 space: ML fits one model per number per family, - # so shrinking the number pool keeps the 5-family × N-number training - # matrix cheap while bt's >=100-draw coverage minimum still holds. lottery = Lottery( code="PIPE", name="Pipeline Fixture", @@ -75,7 +52,7 @@ def _seed_lottery_with_draws(db: Session) -> int: base = datetime(2020, 1, 1, 12, 0, 0) # Stride coprime with len(combos) spreads every number evenly across the # history so per-number ML targets never collapse to a single class. - for i in range(_NUM_DRAWS): + for i in range(105): draw = Draw( lottery_id=lottery.id, draw_number=i + 1, @@ -139,11 +116,6 @@ def stage_recorder(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, str]]: "features", ), ("backend.app.services.probability_service", "ProbabilityService", "generate", "features"), - ("backend.app.services.ml_service", "MlService", "train", "ml"), - ("backend.app.services.dl_service", "DlService", "train", "dl"), - ("backend.app.services.bt_service", "BtService", "run", "bt"), - ("backend.app.services.meta_service", "MetaService", "rank", "rank"), - ("backend.app.services.meta_service", "MetaService", "select", "select"), ("backend.app.services.gen_service", "GenService", "generate", "gen"), ] for module_name, class_name, method_name, stage in targets: @@ -174,34 +146,6 @@ def clear_stages(db: Session, lottery_id: int, keep: set[str]) -> None: [(_import_model("gen_combination", "GenCombination"), "snapshot_id")], [_import_model("gen_snapshot", "GenSnapshot")], ), - ( - "select", - [(_import_model("meta_selection_entry", "MetaSelectionEntry"), "selection_id")], - [_import_model("meta_selection", "MetaSelection")], - ), - ( - "rank", - [(_import_model("meta_ranking_entry", "MetaRankingEntry"), "ranking_id")], - [_import_model("meta_ranking", "MetaRanking")], - ), - ( - "bt", - [(_import_model("bt_result", "BtResult"), "snapshot_id")], - [_import_model("bt_snapshot", "BtSnapshot")], - ), - ( - "dl", - [ - (_import_model("dl_weight", "DlWeight"), "snapshot_id"), - (_import_model("dl_metric", "DlMetric"), "snapshot_id"), - ], - [_import_model("dl_snapshot", "DlSnapshot")], - ), - ( - "ml", - [(_import_model("ml_metric", "MlMetric"), "snapshot_id")], - [_import_model("ml_snapshot", "MlSnapshot")], - ), ( "features", [ @@ -252,11 +196,6 @@ def artifact_versions(db: Session, lottery_id: int) -> dict[str, int]: ("stats", _import_model("stat_snapshot", "StatSnapshot")), ("features", _import_model("feature_snapshot", "FeatureSnapshot")), ("prob", _import_model("prob_snapshot", "ProbSnapshot")), - ("ml", _import_model("ml_snapshot", "MlSnapshot")), - ("dl", _import_model("dl_snapshot", "DlSnapshot")), - ("bt", _import_model("bt_snapshot", "BtSnapshot")), - ("rank", _import_model("meta_ranking", "MetaRanking")), - ("select", _import_model("meta_selection", "MetaSelection")), ("gen", _import_model("gen_snapshot", "GenSnapshot")), ] counts: dict[str, int] = {} @@ -266,47 +205,6 @@ def artifact_versions(db: Session, lottery_id: int) -> dict[str, int]: return counts -@pytest.fixture(autouse=True) -def fast_dl_training(monkeypatch: pytest.MonkeyPatch) -> None: - """Shrink DL epochs to 1 inside this test package only. - - Production keeps registry defaults (D12); the orchestrator contract under - test (auto-train fires, order mlp→lstm, artifacts persist) does not depend - on epoch count. The patched registry stays consistent within the process, - so fingerprints computed across runs remain comparable (R4). - """ - from backend.app.services import dl_service - - real_build = dl_service.build_dl_registry - - def fast_build() -> Any: - fast = {slug: dict(params) for slug, params in real_build().items()} - for params in fast.values(): - params["epochs"] = 1 - return fast - - monkeypatch.setattr(dl_service, "build_dl_registry", fast_build) - - -@pytest.fixture(autouse=True) -def fast_ml_training(monkeypatch: pytest.MonkeyPatch) -> None: - """Force serial ML fitting inside this test package only. - - ``MlEngine`` guarantees byte-identical results between the serial loop and - the ProcessPoolExecutor path (GF-1); pool worker warm-up costs ~30s per - chain run on this fixture, which no pipeline assertion depends on. - """ - from backend.app.ml.engine import MlEngine - - real_train = MlEngine.train - - def serial_train(self: Any, *args: Any, **kwargs: Any) -> Any: - kwargs["parallel"] = False - return real_train(self, *args, **kwargs) - - monkeypatch.setattr(MlEngine, "train", serial_train) - - @pytest.fixture def run_chain(db: Session) -> Iterator[Callable[..., Any]]: """Return a callable running PipelineService.run against the shared session.""" diff --git a/backend/tests/pipeline/test_pipeline_api.py b/backend/tests/pipeline/test_pipeline_api.py index 86f0cbf..622098a 100644 --- a/backend/tests/pipeline/test_pipeline_api.py +++ b/backend/tests/pipeline/test_pipeline_api.py @@ -18,15 +18,10 @@ def test_post_numbers_returns_success_envelope_with_stage_report( assert response.status_code == 200 body = response.json() assert body["data"]["stages"][0]["name"] == "stats" - assert len(body["data"]["stages"]) == 8 + assert len(body["data"]["stages"]) == 3 assert [s["name"] for s in body["data"]["stages"]] == [ "stats", "features", - "ml", - "dl", - "bt", - "rank", - "select", "gen", ] result = body["data"]["result"] @@ -38,19 +33,19 @@ def test_failed_run_maps_to_502_with_stage_detail( client: TestClient, db: Session, pipeline_db: int, monkeypatch: pytest.MonkeyPatch ) -> None: """A failed stage maps to a 502 error envelope carrying the stage detail.""" - from backend.app.services.meta_service import MetaService + from backend.app.services.feature_engine_service import FeatureEngineService def failing(*_args: object, **_kwargs: object) -> None: - raise RuntimeError("rank exploded") + raise RuntimeError("features exploded") - monkeypatch.setattr(MetaService, "rank", staticmethod(failing)) + monkeypatch.setattr(FeatureEngineService, "generate", staticmethod(failing)) response = client.post("/api/v1/pipeline/numbers", json={"lottery_id": pipeline_db}) assert response.status_code == 502 error = response.json()["error"] assert error["code"] == "PIPE_STAGE_FAILED" - assert "rank" in error["message"] + assert "features" in error["message"] def test_request_validation_rejects_missing_lottery(client: TestClient) -> None: diff --git a/backend/tests/pipeline/test_pipeline_autotrain.py b/backend/tests/pipeline/test_pipeline_autotrain.py deleted file mode 100644 index a52d994..0000000 --- a/backend/tests/pipeline/test_pipeline_autotrain.py +++ /dev/null @@ -1,75 +0,0 @@ -"""D12 RED — missing ml/dl artifacts are auto-trained with registry defaults.""" - -from __future__ import annotations - -import pytest -from sqlalchemy.orm import Session - - -def test_missing_ml_dl_are_autotrained_and_chain_proceeds( - db: Session, pipeline_db: int, monkeypatch: pytest.MonkeyPatch -) -> None: - """Missing ml/dl snapshots are auto-trained in-stream and the chain proceeds to gen.""" - from backend.app.services.dl_service import DlService - from backend.app.services.ml_service import MlService - from backend.app.services.pipeline_service import PipelineService - - ml_calls: list[dict] = [] - dl_calls: list[dict] = [] - original_ml = MlService.train - original_dl = DlService.train - - def spying_ml(self: object, *args: object, **kwargs: object) -> object: - out = original_ml(self, *args, **kwargs) # type: ignore[arg-type] - ml_calls.append({"args": args, "kwargs": kwargs, "outcomes": list(out)}) - return out - - def spying_dl(self: object, *args: object, **kwargs: object) -> object: - out = original_dl(self, *args, **kwargs) # type: ignore[arg-type] - dl_calls.append({"args": args, "kwargs": kwargs, "out": out}) - return out - - monkeypatch.setattr(MlService, "train", spying_ml) - monkeypatch.setattr(DlService, "train", spying_dl) - - outcome = PipelineService(db).run(lottery_id=pipeline_db, count=2, seed=9) - - # Auto-train fired exactly once per engine with registry defaults. - assert len(ml_calls) == 1 - assert len(ml_calls[0]["outcomes"]) == 5 # all core-5 families trained - for oc in ml_calls[0]["outcomes"]: - assert oc.status == "active", f"{oc.family}: {oc.error}" - assert len(dl_calls) == 1 - # Registry-default model bundle (no explicit override in the call). - assert "model_set" not in dl_calls[0]["kwargs"] - - # The chain proceeded end to end despite the missing artifacts (D12). - statuses = {s.name: s.status for s in outcome.stages} - assert statuses["ml"] == "completed" - assert statuses["dl"] == "completed" - assert statuses["gen"] == "completed" - assert outcome.result is not None - - -def test_current_artifacts_skip_retraining( - db: Session, pipeline_db: int, monkeypatch: pytest.MonkeyPatch -) -> None: - """Existing active artifacts are reused without triggering retraining.""" - """Warm chain must NOT retrain ml (non-idempotent writer → gated stage).""" - from backend.app.services.ml_service import MlService - from backend.app.services.pipeline_service import PipelineService - - PipelineService(db).run(lottery_id=pipeline_db, count=2, seed=9) - - ml_calls: list[int] = [] - original_ml = MlService.train - - def counting_ml(self: object, *args: object, **kwargs: object) -> object: - ml_calls.append(1) - return original_ml(self, *args, **kwargs) # type: ignore[arg-type] - - monkeypatch.setattr(MlService, "train", counting_ml) - - warm = PipelineService(db).run(lottery_id=pipeline_db, count=2, seed=9) - assert ml_calls == [] # skipped without invoking the trainer - assert next(s for s in warm.stages if s.name == "ml").status == "skipped" diff --git a/backend/tests/pipeline/test_pipeline_cold_chain.py b/backend/tests/pipeline/test_pipeline_cold_chain.py index 7c7ca79..09f2a21 100644 --- a/backend/tests/pipeline/test_pipeline_cold_chain.py +++ b/backend/tests/pipeline/test_pipeline_cold_chain.py @@ -1,4 +1,4 @@ -"""R1/R3 RED — cold chain: one call runs all eight stages in canonical order.""" +"""R1/R3 RED — cold chain: one call runs all three stages in canonical order.""" from __future__ import annotations @@ -7,16 +7,16 @@ from tests.pipeline.conftest import STAGE_ORDER -def test_cold_chain_runs_all_eight_stages_in_canonical_order( +def test_cold_chain_runs_all_three_stages_in_canonical_order( db: Session, pipeline_db: int, stage_recorder: list[tuple[str, str]], run_chain ) -> None: - """An empty store runs all eight stages exactly once in canonical order.""" + """An empty store runs all three stages exactly once in canonical order.""" outcome = run_chain(pipeline_db, count=2, seed=7) names = [stage.name for stage in outcome.stages] assert names == list(STAGE_ORDER) - # All eight completed with artifact references where produced. + # All three completed with artifact references where produced. for entry in outcome.stages: assert entry.status == "completed", f"{entry.name}: {entry.detail}" assert entry.fingerprint, f"{entry.name} missing fingerprint ref" @@ -27,9 +27,6 @@ def test_cold_chain_runs_all_eight_stages_in_canonical_order( unique_starts = list(dict.fromkeys(starts)) assert unique_starts == list(STAGE_ORDER) - # bt strictly before rank (R1). - assert stage_recorder.index(("bt", "end")) < stage_recorder.index(("rank", "start")) - # Final combinations returned. assert outcome.result is not None assert len(outcome.result.combinations) == 2 diff --git a/backend/tests/pipeline/test_pipeline_context.py b/backend/tests/pipeline/test_pipeline_context.py deleted file mode 100644 index f41fc03..0000000 --- a/backend/tests/pipeline/test_pipeline_context.py +++ /dev/null @@ -1,89 +0,0 @@ -"""D8 RED — bt-before-rank context derivation and stale-ranking detect-and-rerank.""" - -from __future__ import annotations - -from datetime import UTC, datetime, timedelta - -import pytest -from sqlalchemy.orm import Session - - -def test_rank_receives_context_derived_from_executed_bt( - db: Session, pipeline_db: int, monkeypatch: pytest.MonkeyPatch -) -> None: - """Ranking receives the context hash derived from the executed backtest identity.""" - """rank/select ctx must come from the executed bt run — never a hardcoded hash.""" - from backend.app.meta.context import compute_context_hash, resolve_context_vector - from backend.app.services.meta_service import MetaService - from backend.app.services.pipeline_service import PipelineService - - captured: dict[str, str | None] = {} - original_select = MetaService.select - - def spying_select(self: object, **kwargs: object) -> object: - captured["context_hash"] = kwargs.get("context_hash") # type: ignore[assignment] - return original_select(self, **kwargs) # type: ignore[arg-type] - - monkeypatch.setattr(MetaService, "select", spying_select) - - outcome = PipelineService(db).run(lottery_id=pipeline_db, count=2, seed=5) - - assert outcome.result is not None - expected = compute_context_hash(resolve_context_vector(pipeline_db, "backtesting", db)) - assert captured["context_hash"] == expected - - -def test_stale_ranking_triggers_exactly_one_rerank_then_fails( - db: Session, pipeline_db: int, monkeypatch: pytest.MonkeyPatch -) -> None: - """One stale-ranking rerank is attempted; a second failure aborts the chain.""" - from backend.app.models.bt_snapshot import BtSnapshot - from backend.app.models.meta_ranking import MetaRanking - from backend.app.services.meta_service import MetaService - from backend.app.services.pipeline_service import PipelineService, PipelineServiceError - - # First run builds a consistent chain. - PipelineService(db).run(lottery_id=pipeline_db, count=2, seed=5) - - # Age the active ranking behind the newest active BtSnapshot (D8 staleness). - ranking = ( - db.query(MetaRanking) - .filter( - MetaRanking.lottery_id == pipeline_db, - MetaRanking.status == "active", - ) - .one() - ) - # SQLite-loaded datetimes are naive; age with a naive UTC stamp. Commit - # so later service-internal rollbacks cannot discard the aging. - ranking.created_at = datetime.now(UTC).replace(tzinfo=None) - timedelta(days=1) - db.commit() - newest_bt = ( - db.query(BtSnapshot) - .filter(BtSnapshot.lottery_id == pipeline_db, BtSnapshot.status == "active") - .order_by(BtSnapshot.created_at.desc()) - .first() - ) - assert newest_bt is not None - assert ranking.created_at <= newest_bt.created_at - - rank_calls: list[int] = [] - original_rank = MetaService.rank - - def counting_rank(self: object, *args: object, **kwargs: object) -> object: - rank_calls.append(1) - return original_rank(self, *args, **kwargs) # type: ignore[arg-type] - - monkeypatch.setattr(MetaService, "rank", counting_rank) - - raised = None - try: - PipelineService(db).run(lottery_id=pipeline_db, count=2, seed=5) - except PipelineServiceError as exc: - raised = exc - - assert raised is not None - assert raised.code == PipelineServiceError.PIPE_STAGE_FAILED - assert "rank" in str(raised) - # Initial attempt + exactly ONE repair attempt; no rerun loop. - assert len(rank_calls) == 2 diff --git a/backend/tests/pipeline/test_pipeline_failures.py b/backend/tests/pipeline/test_pipeline_failures.py index 0f960ce..d1eee0a 100644 --- a/backend/tests/pipeline/test_pipeline_failures.py +++ b/backend/tests/pipeline/test_pipeline_failures.py @@ -7,18 +7,18 @@ @pytest.fixture -def fail_rank(monkeypatch: pytest.MonkeyPatch) -> None: - """Force the rank stage service to raise.""" - from backend.app.services.meta_service import MetaService +def fail_features(monkeypatch: pytest.MonkeyPatch) -> None: + """Force the features stage service to raise.""" + from backend.app.services.feature_engine_service import FeatureEngineService def failing(*_args: object, **_kwargs: object) -> None: - raise RuntimeError("rank exploded") + raise RuntimeError("features exploded") - monkeypatch.setattr(MetaService, "rank", staticmethod(failing)) + monkeypatch.setattr(FeatureEngineService, "generate", staticmethod(failing)) -def test_rank_failure_aborts_before_gen(db: Session, pipeline_db: int, fail_rank: None) -> None: - """A rank-stage failure aborts the chain before generation runs.""" +def test_features_failure_aborts_before_gen(db: Session, pipeline_db: int, fail_features: None) -> None: + """A features-stage failure aborts the chain before generation runs.""" from backend.app.models.gen_snapshot import GenSnapshot from backend.app.models.stat_snapshot import StatSnapshot from backend.app.services.pipeline_service import PipelineService, PipelineServiceError @@ -28,7 +28,7 @@ def test_rank_failure_aborts_before_gen(db: Session, pipeline_db: int, fail_rank error = excinfo.value assert error.code == PipelineServiceError.PIPE_STAGE_FAILED - assert "rank" in str(error) + assert "features" in str(error) # gen never ran; earlier artifacts persist. assert db.query(GenSnapshot).filter(GenSnapshot.lottery_id == pipeline_db).count() == 0 @@ -44,7 +44,7 @@ def test_rank_failure_aborts_before_gen(db: Session, pipeline_db: int, fail_rank def test_failure_report_entry_carries_error_code( - db: Session, pipeline_db: int, fail_rank: None + db: Session, pipeline_db: int, fail_features: None ) -> None: """The failed report entry carries the originating service error code.""" """R3: the failed entry carries its error code on the attached report.""" @@ -56,5 +56,29 @@ def test_failure_report_entry_carries_error_code( report = getattr(excinfo.value, "stages", []) failed = [s for s in report if s.status == "failed"] assert len(failed) == 1 - assert failed[0].name == "rank" + assert failed[0].name == "features" assert failed[0].error_code == PipelineServiceError.PIPE_STAGE_FAILED + + +def test_gen_succeeds_without_active_meta_selection(db: Session, pipeline_db: int) -> None: + """Gen completes successfully using deterministic seed fallback when no MetaSelection exists.""" + from backend.app.models.gen_snapshot import GenSnapshot + from backend.app.models.meta_selection import MetaSelection + from backend.app.services.pipeline_service import PipelineService + + # Ensure no MetaSelection exists + db.query(MetaSelection).filter(MetaSelection.lottery_id == pipeline_db).delete() + db.flush() + + outcome = PipelineService(db).run(lottery_id=pipeline_db, count=2, seed=7) + + # Gen completed successfully + gen_stage = next(s for s in outcome.stages if s.name == "gen") + assert gen_stage.status == "completed" + assert outcome.result is not None + assert len(outcome.result.combinations) == 2 + + # A GenSnapshot was created with selection_id=0 (fallback) + gen_snapshot = db.query(GenSnapshot).filter(GenSnapshot.lottery_id == pipeline_db).first() + assert gen_snapshot is not None + assert gen_snapshot.selection_id == 0 diff --git a/backend/tests/pipeline/test_pipeline_healing.py b/backend/tests/pipeline/test_pipeline_healing.py index 28700e0..dd6004a 100644 --- a/backend/tests/pipeline/test_pipeline_healing.py +++ b/backend/tests/pipeline/test_pipeline_healing.py @@ -15,19 +15,14 @@ HEALING_ROWS: list[tuple[set[str], set[str]]] = [ (set(), ALL), # cold chain: everything runs ({"stats"}, ALL - {"stats"}), - ({"stats", "features"}, ALL - {"stats", "features"}), - # stats/features/bt current; ml/dl/rank/select/gen missing. - ({"stats", "features", "bt"}, {"ml", "dl", "rank", "select", "gen"}), - # everything but select/gen is current. - ({"stats", "features", "ml", "dl", "bt", "rank"}, {"select", "gen"}), + ({"stats", "features"}, {"gen"}), ] + ROW_IDS = [ "cold", "stats-only", "stats-features", - "thru-bt-missing-ml-dl", - "thru-rank-missing-select-gen", ] @@ -72,7 +67,10 @@ def test_fresh_draw_invalidates_coverage_stages_and_then_settles( outcome = PipelineService(db).run(lottery_id=pipeline_db, count=2, seed=11) rerun = {s.name for s in outcome.stages if s.status == "completed"} - assert rerun == ALL, f"coverage-dependent stages must re-run, skipped={ALL - rerun}" + # stats and features re-run due to coverage changes; gen re-runs because + # features fingerprint changed (features stage covers both writers). + assert "stats" in rerun, "stats must re-run after fresh draw" + assert "features" in rerun, "features must re-run after stats change" assert outcome.result is not None # With NO new coverage, a further run leaves every stage untouched. diff --git a/frontend/src/pages/MisNumeros.test.tsx b/frontend/src/pages/MisNumeros.test.tsx index f5e6a21..5bac533 100644 --- a/frontend/src/pages/MisNumeros.test.tsx +++ b/frontend/src/pages/MisNumeros.test.tsx @@ -12,7 +12,7 @@ const err = (message: string, status = 500, code = "INTERNAL_ERROR") => HttpResponse.json({ success: false, error: { code, message }, timestamp: "" }, { status }); /** Canonical stage order of POST /pipeline/numbers (R2/S2 contract). */ -const STAGE_ORDER = ["stats", "features", "ml", "dl", "bt", "rank", "select", "gen"]; +const STAGE_ORDER = ["stats", "features", "gen"]; const combinations = [ { @@ -51,16 +51,16 @@ const okStages = () => detail: "new artifact persisted", })); -const failedRankStages = () => +const failedFeaturesStages = () => okStages().map((stage) => - stage.name === "rank" + stage.name === "features" ? { ...stage, status: "failed", snapshot_id: null, fingerprint: null, error_code: "PIPE_STAGE_FAILED", - detail: "ranking stale for backtest context after one rerank", + detail: "features exploded", } : stage ); @@ -112,7 +112,7 @@ afterAll(() => { describe("runNumbersPipeline client", () => { it("POSTs to /api/v1/pipeline/numbers and unwraps the SuccessEnvelope", async () => { const result = await runNumbersPipeline({ lottery_id: 1, count: 5 }); - expect(result.stages).toHaveLength(8); + expect(result.stages).toHaveLength(3); expect(STAGE_ORDER).toEqual(result.stages.map((stage) => stage.name)); expect(result.result?.snapshot_id).toBe(42); expect(lastPipelineBody).toEqual({ lottery_id: 1, count: 5 }); @@ -162,7 +162,7 @@ describe("Mis Números page", () => { expect(pipelineCalls).toBe(2); }); - it("renders all eight stages in canonical order with their statuses (R2)", async () => { + it("renders all three stages in canonical order with their statuses (R2)", async () => { selectLottery(); render(); @@ -183,11 +183,11 @@ describe("Mis Números page", () => { expect(screen.getByRole("table", { name: /generated combinations/i })).toBeInTheDocument(); }); - it("surfaces a failed rank stage without crashing and hides combinations (R2)", async () => { + it("surfaces a failed features stage without crashing and hides combinations (R2)", async () => { selectLottery(); server.use( http.post("*/api/v1/pipeline/numbers", () => - env({ stages: failedRankStages(), result: null }) + env({ stages: failedFeaturesStages(), result: null }) ) ); render(); diff --git a/frontend/src/types/pipeline.ts b/frontend/src/types/pipeline.ts index 4e0b728..449d736 100644 --- a/frontend/src/types/pipeline.ts +++ b/frontend/src/types/pipeline.ts @@ -1,8 +1,7 @@ import type { GenerationResult } from "./gen"; -/** Names of the eight canonical pipeline stages, in execution order. */ -export type PipelineStageName = - "stats" | "features" | "ml" | "dl" | "bt" | "rank" | "select" | "gen"; +/** Names of the three canonical pipeline stages, in execution order. */ +export type PipelineStageName = "stats" | "features" | "gen"; /** Status of one stage entry in the per-stage report. */ export type PipelineStageStatus = "skipped" | "completed" | "failed"; @@ -18,7 +17,7 @@ export interface PipelineStageResult { } /** - * Response data of POST /pipeline/numbers: the ordered eight-stage report plus + * Response data of POST /pipeline/numbers: the ordered three-stage report plus * the generation echo, which is null when any stage failed (S2 contract). */ export interface PipelineRunResult {