Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions backend/tests/gen/test_gen_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions backend/tests/gen/test_gen_generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Expand Down
104 changes: 1 addition & 103 deletions backend/tests/pipeline/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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",
[
Expand Down Expand Up @@ -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] = {}
Expand All @@ -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."""
Expand Down
15 changes: 5 additions & 10 deletions backend/tests/pipeline/test_pipeline_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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:
Expand Down
75 changes: 0 additions & 75 deletions backend/tests/pipeline/test_pipeline_autotrain.py

This file was deleted.

11 changes: 4 additions & 7 deletions backend/tests/pipeline/test_pipeline_cold_chain.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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"
Expand All @@ -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
Expand Down
Loading
Loading