import asyncio import hashlib import json import subprocess from pathlib import Path import pytest from apps.web import pipeline_backend as backend from apps.web.vue3 import main as api from gfa_core.mas.stage_capabilities import get_stage_capability from gfa_core.mas.tools.stage_identity import StageIdentity, get_stage_status_for_identity from gfa_core.project_paths import get_conda_env_rscript GENOTYPE = "genotype:" + "a" * 64 PHENOTYPE = "phenotype:" + "b" * 64 COVARIATE = "covariate:" + "c" * 64 SOURCE_MODEL = "gwas-model:" + "d" * 64 ANALYSIS = "gwas-analysis:" + "e" * 64 RSCRIPT = str(get_conda_env_rscript("ASAW")) def _sha(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() def _artifact(workspace: Path, path: Path, role: str) -> dict: return { "role": role, "artifact": role, "path": str(path.relative_to(workspace)), "full_path": str(path.resolve()), "size": path.stat().st_size, "sha256": _sha(path), } def _mlm_manifest(workspace: Path, prefix: str = "cohort") -> tuple[Path, dict[str, Path]]: final = workspace / "final" final.mkdir(parents=True, exist_ok=True) paths = {} artifacts = {} for role in ("mlm_pvalues", "cmlm_pvalues"): path = final / f"{prefix}.gwas_mlm.{role}.tsv" path.write_text("Chromosome\tPosition\tP\n1\t1\t0.5\n", encoding="utf-8") paths[role] = path artifacts[role] = _artifact(workspace, path, role) manifest = { "manifest_version": "1.0", "stage": "gwas_mlm", "prefix": prefix, "status": "success", "genotype_scope_id": GENOTYPE, "phenotype_scope_id": PHENOTYPE, "covariate_scope_id": COVARIATE, "model_scope_id": SOURCE_MODEL, "analysis_scope_id": "gwas-analysis:" + "f" * 64, "metrics": { "candidate_marker_count": 93563, "mlm": {"row_count": 93564}, "cmlm": {"row_count": 93564}, }, "artifacts": artifacts, } manifest_path = final / f"{prefix}.gwas_mlm.identity.manifest.json" manifest_path.write_text(json.dumps(manifest), encoding="utf-8") return manifest_path, paths def _plot_manifest(workspace: Path, prefix: str = "cohort", *, roles=None) -> Path: final = workspace / "final" final.mkdir(parents=True, exist_ok=True) roles = roles or ( "mlm_manhattan", "mlm_qq", "cmlm_manhattan", "cmlm_qq", "report", "environment", ) artifacts = {} for role in roles: suffix = ".png" if role in {"mlm_manhattan", "mlm_qq", "cmlm_manhattan", "cmlm_qq"} else ".txt" path = final / f"{prefix}.gwas_plot.{role}{suffix}" path.write_bytes(b"artifact-" + role.encode()) artifacts[role] = _artifact(workspace, path, role) manifest = { "manifest_version": "1.0", "stage": "gwas_plot", "prefix": prefix, "status": "success", "genotype_scope_id": GENOTYPE, "phenotype_scope_id": PHENOTYPE, "covariate_scope_id": COVARIATE, "source_model_scope_id": SOURCE_MODEL, "analysis_scope_id": ANALYSIS, "elapsed_sec": 4.56, "artifacts": artifacts, } path = final / f"{prefix}.gwas_plot.identity.manifest.json" path.write_text(json.dumps(manifest), encoding="utf-8") return path def _scopes() -> dict: return { "genotypeScopeId": GENOTYPE, "phenotypeScopeId": PHENOTYPE, "covariateScopeId": COVARIATE, "sourceModelScopeId": SOURCE_MODEL, } def test_plot_semantic_preflight_materializes_one_exact_mlm_manifest(monkeypatch, tmp_path): workspace = tmp_path / "workspace" / "job" manifest_path, paths = _mlm_manifest(workspace) monkeypatch.setattr(api, "WORKSPACE_ROOT", tmp_path / "workspace") result = api._api_semantic_preflight("job", "gwas_plot", "cohort", _scopes()) assert result["valid"] is True assert result["materialized_params"] == { "gwasMlmPvalues": str(paths["mlm_pvalues"]), "gwasCmlmPvalues": str(paths["cmlm_pvalues"]), "gwasMlmTestedHypotheses": 93563, "gwasCmlmTestedHypotheses": 93563, "genotypeScopeId": GENOTYPE, "phenotypeScopeId": PHENOTYPE, "covariateScopeId": COVARIATE, "sourceModelScopeId": SOURCE_MODEL, "gwasRscript": RSCRIPT, } assert result["decisions"]["mlm_pvalues"]["source_manifest"] == str(manifest_path) assert result["decisions"]["cmlm_pvalues"]["source_manifest"] == str(manifest_path) def test_plot_semantic_preflight_materializes_scopes_from_unique_mlm_manifest( monkeypatch, tmp_path ): workspace = tmp_path / "workspace" / "job" manifest_path, paths = _mlm_manifest(workspace) monkeypatch.setattr(api, "WORKSPACE_ROOT", tmp_path / "workspace") result = api._api_semantic_preflight("job", "gwas_plot", "cohort", {}) assert result["valid"] is True assert result["materialized_params"] == { "gwasMlmPvalues": str(paths["mlm_pvalues"]), "gwasCmlmPvalues": str(paths["cmlm_pvalues"]), "gwasMlmTestedHypotheses": 93563, "gwasCmlmTestedHypotheses": 93563, "genotypeScopeId": GENOTYPE, "phenotypeScopeId": PHENOTYPE, "covariateScopeId": COVARIATE, "sourceModelScopeId": SOURCE_MODEL, "gwasRscript": RSCRIPT, } assert result["decisions"]["mlm_pvalues"]["source_manifest"] == str(manifest_path) @pytest.mark.parametrize("case", [ "wrong_scope", "failed", "bad_hash", "bad_size", "escaped_path", "ambiguous", "missing_count", "boolean_count", "nonpositive_count", "caller_path", "caller_count", "caller_source_model", "caller_zero_count", ]) def test_plot_semantic_preflight_fails_closed_before_submission(monkeypatch, tmp_path, case): workspace = tmp_path / "workspace" / "job" manifest_path, paths = _mlm_manifest(workspace) manifest = json.loads(manifest_path.read_text(encoding="utf-8")) params = _scopes() if case == "wrong_scope": params["genotypeScopeId"] = "genotype:" + "9" * 64 elif case == "failed": manifest["status"] = "failed" elif case == "bad_hash": manifest["artifacts"]["mlm_pvalues"]["sha256"] = "0" * 64 elif case == "bad_size": manifest["artifacts"]["mlm_pvalues"]["size"] += 1 elif case == "escaped_path": outside = tmp_path / "outside.tsv" outside.write_text("x\n", encoding="utf-8") manifest["artifacts"]["mlm_pvalues"].update({ "path": "../outside.tsv", "full_path": str(outside.resolve()), "size": outside.stat().st_size, "sha256": _sha(outside), }) elif case == "ambiguous": (workspace / "final" / "cohort.gwas_mlm.copy.manifest.json").write_text( json.dumps(manifest), encoding="utf-8" ) elif case == "missing_count": manifest["metrics"].pop("candidate_marker_count") elif case == "boolean_count": manifest["metrics"]["candidate_marker_count"] = True elif case == "nonpositive_count": manifest["metrics"]["candidate_marker_count"] = 0 elif case == "caller_path": params["gwasMlmPvalues"] = str(paths["cmlm_pvalues"]) elif case == "caller_count": params["gwasMlmTestedHypotheses"] = 93564 elif case == "caller_source_model": params["sourceModelScopeId"] = "gwas-model:" + "9" * 64 elif case == "caller_zero_count": params["gwasMlmTestedHypotheses"] = 0 manifest_path.write_text(json.dumps(manifest), encoding="utf-8") monkeypatch.setattr(api, "WORKSPACE_ROOT", tmp_path / "workspace") result = api._api_semantic_preflight("job", "gwas_plot", "cohort", params) assert result["valid"] is False assert result["issues"][0]["code"] == "invalid_gwas_plot_upstream" def test_plot_command_uses_only_canonical_python_runner_and_preserves_all_values(tmp_path): log = tmp_path / "logs" / "plot.log" command = backend._gwas_plot_cli_command( workspace=tmp_path, job_id="job", prefix="cohort", mlm_pvalues=tmp_path / "mlm.tsv", cmlm_pvalues=tmp_path / "cmlm.tsv", mlm_tested_hypotheses=93563, cmlm_tested_hypotheses=93563, genotype_scope_id=GENOTYPE, phenotype_scope_id=PHENOTYPE, covariate_scope_id=COVARIATE, source_model_scope_id=SOURCE_MODEL, rscript=RSCRIPT, log_file=log, ) assert command[:3] == [backend.sys.executable, "-m", "apps.cli.run_gwas_plot"] assert command.count("apps.cli.run_gwas_plot") == 1 expected = { "--mlm-pvalues": str(tmp_path / "mlm.tsv"), "--cmlm-pvalues": str(tmp_path / "cmlm.tsv"), "--mlm-tested-hypotheses": "93563", "--cmlm-tested-hypotheses": "93563", "--genotype-scope-id": GENOTYPE, "--phenotype-scope-id": PHENOTYPE, "--covariate-scope-id": COVARIATE, "--source-model-scope-id": SOURCE_MODEL, "--rscript": RSCRIPT, "--log-file": str(log), } for flag, value in expected.items(): assert command.count(flag) == 1 assert command[command.index(flag) + 1] == value assert not any(token.endswith(".R") for token in command) def test_plot_resolver_rejects_roles_from_different_source_manifests(monkeypatch, tmp_path): from gfa_core import gwas_upstream_manifest as upstream calls = [] def fake_resolve(**kwargs): calls.append(kwargs["artifact_role"]) return { "path": str(tmp_path / f"{kwargs['artifact_role']}.tsv"), "source_manifest": str(tmp_path / f"source-{len(calls)}.manifest.json"), } monkeypatch.setattr(upstream, "resolve_upstream_artifact", fake_resolve) with pytest.raises(upstream.GwasUpstreamError, match="one gwas_mlm manifest"): upstream.resolve_gwas_plot_upstream( workspace=tmp_path, prefix="cohort", genotype_scope_id=GENOTYPE, phenotype_scope_id=PHENOTYPE, covariate_scope_id=COVARIATE, source_model_scope_id=SOURCE_MODEL, ) def test_execute_request_accepts_plot_wire_contract(): request = api.ExecuteRequest( stage="gwas_plot", gwasMlmPvalues="mlm", gwasCmlmPvalues="cmlm", gwasMlmTestedHypotheses=93563, gwasCmlmTestedHypotheses=93563, gwasRscript=RSCRIPT, sourceModelScopeId=SOURCE_MODEL, ) assert request.gwasMlmPvalues == "mlm" assert request.gwasCmlmPvalues == "cmlm" assert request.gwasMlmTestedHypotheses == request.gwasCmlmTestedHypotheses == 93563 assert request.gwasRscript == RSCRIPT assert request.sourceModelScopeId == SOURCE_MODEL def test_execute_plot_omitted_counts_are_materialized_before_submission(monkeypatch, tmp_path): workspace = tmp_path / "workspace" / "job" _mlm_manifest(workspace) class DummyExecutor: def __init__(self): self.calls = [] def submit(self, *args): self.calls.append(args) dummy = DummyExecutor() monkeypatch.setattr(api, "PROJECT_ROOT", tmp_path) monkeypatch.setattr(api, "WORKSPACE_ROOT", tmp_path / "workspace") monkeypatch.setattr(api, "INPUT_ROOT", tmp_path / "input") monkeypatch.setattr(api, "CELERY_AVAILABLE", False) monkeypatch.setattr(api, "executor", dummy) request = api.ExecuteRequest(stage="gwas_plot", job_id="job", prefix="cohort", **_scopes()) assert request.gwasMlmTestedHypotheses is None assert request.gwasCmlmTestedHypotheses is None result = asyncio.run(api.execute_stage(request)) assert result["async"] is True and len(dummy.calls) == 1 submitted_params = dummy.calls[0][4] assert submitted_params["gwasMlmTestedHypotheses"] == 93563 assert submitted_params["gwasCmlmTestedHypotheses"] == 93563 def test_frontend_dag_plot_request_forwards_lineage_and_wire_overrides_into_preflight( monkeypatch, tmp_path, ): workspace = tmp_path / "workspace" / "job" _, paths = _mlm_manifest(workspace) monkeypatch.setattr(api, "WORKSPACE_ROOT", tmp_path / "workspace") frontend = (Path(__file__).resolve().parents[2] / "apps/web/vue3/index.html").read_text(encoding="utf-8") binding_start = frontend.index("const applyGwasExecutionParams =") execute_start = frontend.index("const executeSingleStageWithParams =", binding_start) execute_end = frontend.index("// 执行单个阶段(内部函数)", execute_start) stage_params = { "genotypeScopeId": GENOTYPE, "phenotypeScopeId": PHENOTYPE, "covariateScopeId": COVARIATE, "modelScopeId": SOURCE_MODEL, "gwasMlmPvalues": str(paths["mlm_pvalues"]), "gwasCmlmPvalues": str(paths["cmlm_pvalues"]), "gwasMlmTestedHypotheses": 93563, "gwasCmlmTestedHypotheses": 93563, "gwasRscript": "/custom/Rscript", } node_script = tmp_path / "plot-request.cjs" node_script.write_text( "let captured=null;" "const batchExecuteParams={value:{plan:{params:{modelScopeId:'plan-model'}}}};" "const batchExecuteStages={value:[{id:'gwas_plot',params:" + json.dumps(stage_params) + "}]};" "const confirmParams=new Proxy({cpu:4,comboPanels:[]},{get:(o,k)=>o[k]});" "const sessionId={value:'session'}; const jobInfo={job_id:'job'};" "const getStageId=s=>s.id; const getStageAction=s=>s.action;" "const usesRnaseqSampleParallel=()=>false; const stageCpuForExecution=()=>4;" "const confirmCpuForStage=()=>4; const executionPrefix=()=> 'cohort';" "const applyGeneFamilyContextToRequest=()=>{}; const applyRnaseqExecutionParams=()=>{}; const applyWgsExecutionParams=()=>{};" "const submitExecuteRequestWithAutoAnnotation=async body=>{captured=body;return {success:true,async:false};};" "const createStructuredExecuteError=x=>new Error(JSON.stringify(x));" "const runInteractiveSubfamilyConfirm=async()=>({}); const syncIdentifyExecutionSpecies=()=>{};" "const addAgentMessage=()=>{}; const getStageLabel=x=>x; const activePipelineTaskIds={value:[]};" "const pollTaskUntilComplete=async()=>({}); const console={log:()=>{}};" + frontend[binding_start:execute_start] + frontend[execute_start:execute_end] + "executeSingleStageWithParams('gwas_plot').then(()=>process.stdout.write(JSON.stringify(captured))).catch(e=>{process.stderr.write(String(e));process.exit(1);});", encoding="utf-8", ) completed = subprocess.run(["node", str(node_script)], text=True, capture_output=True, check=False) assert completed.returncode == 0, completed.stderr body = json.loads(completed.stdout) assert {key: body[key] for key in ( "genotypeScopeId", "phenotypeScopeId", "covariateScopeId", "sourceModelScopeId", )} == { "genotypeScopeId": GENOTYPE, "phenotypeScopeId": PHENOTYPE, "covariateScopeId": COVARIATE, "sourceModelScopeId": SOURCE_MODEL, } for key in ( "gwasMlmPvalues", "gwasCmlmPvalues", "gwasMlmTestedHypotheses", "gwasCmlmTestedHypotheses", "gwasRscript", ): assert body[key] == stage_params[key] preflight = api._api_semantic_preflight("job", "gwas_plot", "cohort", body) assert preflight["valid"] is True assert preflight["materialized_params"]["gwasMlmPvalues"] == str(paths["mlm_pvalues"]) assert preflight["materialized_params"]["gwasCmlmPvalues"] == str(paths["cmlm_pvalues"]) assert preflight["materialized_params"]["gwasMlmTestedHypotheses"] == 93563 assert preflight["materialized_params"]["gwasCmlmTestedHypotheses"] == 93563 @pytest.mark.parametrize("explicit_counts,expected_child_calls", [((0, 0), 0), (None, 1)]) def test_stage_tool_preserves_plot_count_omission_vs_explicit_zero( monkeypatch, tmp_path, explicit_counts, expected_child_calls, ): from gfa_core.mas.tools.stage_tools import StageToolExecutor, StageToolRegistry workspace = tmp_path / "workspace" / "job" _, paths = _mlm_manifest(workspace) decisions = { role: {"path": str(path), "source_manifest": "same"} for role, path in paths.items() } decisions["candidate_marker_count"] = 93563 child_commands = [] monkeypatch.setattr(backend, "_resolve_gwas_plot_inputs", lambda **_kwargs: decisions) monkeypatch.setattr( backend, "_run_child_owned_log", lambda command, cwd=None: child_commands.append(command) or 7, ) registry = StageToolRegistry() registry._executor = StageToolExecutor(project_root=tmp_path) params = {**_scopes(), "gwasRscript": RSCRIPT} if explicit_counts is not None: params["gwasMlmTestedHypotheses"], params["gwasCmlmTestedHypotheses"] = explicit_counts result = asyncio.run(registry.execute("gwas_plot", "job", "cohort", params)) assert len(child_commands) == expected_child_calls if explicit_counts is not None: assert "tested hypotheses" in result["message"] else: command = child_commands[0] assert command[command.index("--mlm-tested-hypotheses") + 1] == "93563" assert command[command.index("--cmlm-tested-hypotheses") + 1] == "93563" def test_api_task_forwards_plot_fields_and_accepts_complete_postflight(monkeypatch, tmp_path): import gfa_core.mas.resource_governor as resource_governor captured = {} workspace_root = tmp_path / "workspace" class NoopLease: def __init__(self, **_kwargs): pass def wait(self, update_state=None): return None def release(self, **_kwargs): return None def fake_run_stage(**kwargs): captured.update(kwargs) return "✅ done", _plot_manifest(workspace_root / "job") monkeypatch.setattr(resource_governor, "ResourceLease", NoopLease) monkeypatch.setattr(resource_governor, "get_task", lambda _task_id: {}) monkeypatch.setattr(resource_governor, "snapshot", lambda **_kwargs: {"usage": {}}) monkeypatch.setattr(api, "WORKSPACE_ROOT", workspace_root) monkeypatch.setattr(api, "INPUT_ROOT", tmp_path / "input") monkeypatch.setattr(api, "_attach_single_stage_status", lambda _task: None) monkeypatch.setattr(api, "_attach_annotation_progress", lambda _task: None) monkeypatch.setattr(api.time, "sleep", lambda _seconds: None) monkeypatch.setattr(backend, "run_stage", fake_run_stage) api.tasks["plot-task"] = { "id": "plot-task", "stage": "gwas_plot", "job_id": "job", "status": "pending", "message": "", "created_at": "2026-07-21T00:00:00", } params = { "prefix": "cohort", "gwasMlmPvalues": "mlm", "gwasCmlmPvalues": "cmlm", "gwasMlmTestedHypotheses": 93563, "gwasCmlmTestedHypotheses": 93563, "gwasRscript": RSCRIPT, **_scopes(), } api.run_stage_sync("plot-task", "gwas_plot", "job", params) assert {key: captured[key] for key in ( "gwas_mlm_pvalues", "gwas_cmlm_pvalues", "gwas_mlm_tested_hypotheses", "gwas_cmlm_tested_hypotheses", "gwas_rscript", "source_model_scope_id", )} == { "gwas_mlm_pvalues": "mlm", "gwas_cmlm_pvalues": "cmlm", "gwas_mlm_tested_hypotheses": 93563, "gwas_cmlm_tested_hypotheses": 93563, "gwas_rscript": RSCRIPT, "source_model_scope_id": SOURCE_MODEL, } task = api.tasks["plot-task"] assert task["status"] == "success", task assert len(task["result"]["stage_files"]["gwas_plot"]["images"]) == 4 assert {item["role"] for item in task["result"]["stage_files"]["gwas_plot"]["files"]} == { "report", "environment", "manifest", } assert task["result"]["stage_files"]["gwas_plot"]["elapsed_time"] == 4.6 def test_api_task_rejects_partial_plot_manifest_without_publishing_result(monkeypatch, tmp_path): import gfa_core.mas.resource_governor as resource_governor workspace_root = tmp_path / "workspace" class NoopLease: def __init__(self, **_kwargs): pass def wait(self, update_state=None): return None def release(self, **_kwargs): return None def fake_run_stage(**_kwargs): path = _plot_manifest( workspace_root / "job", roles=("mlm_manhattan", "mlm_qq", "cmlm_manhattan", "report", "environment"), ) return "✅ done", path monkeypatch.setattr(resource_governor, "ResourceLease", NoopLease) monkeypatch.setattr(resource_governor, "get_task", lambda _task_id: {}) monkeypatch.setattr(resource_governor, "snapshot", lambda **_kwargs: {"usage": {}}) monkeypatch.setattr(api, "WORKSPACE_ROOT", workspace_root) monkeypatch.setattr(api, "INPUT_ROOT", tmp_path / "input") monkeypatch.setattr(api, "_attach_single_stage_status", lambda _task: None) monkeypatch.setattr(api, "_attach_annotation_progress", lambda _task: None) monkeypatch.setattr(api.time, "sleep", lambda _seconds: None) monkeypatch.setattr(backend, "run_stage", fake_run_stage) api.tasks["partial-plot"] = { "id": "partial-plot", "stage": "gwas_plot", "job_id": "job", "status": "pending", "message": "", "created_at": "2026-07-21T00:00:00", } api.run_stage_sync("partial-plot", "gwas_plot", "job", {"prefix": "cohort", **_scopes()}) task = api.tasks["partial-plot"] assert task["status"] == "failed" assert task["result"]["success"] is False assert "images" not in task["result"] and "files" not in task["result"] def test_api_task_rejects_hash_invalid_plot_manifest(monkeypatch, tmp_path): import gfa_core.mas.resource_governor as resource_governor workspace_root = tmp_path / "workspace" class NoopLease: def __init__(self, **_kwargs): pass def wait(self, update_state=None): return None def release(self, **_kwargs): return None def fake_run_stage(**_kwargs): path = _plot_manifest(workspace_root / "job") manifest = json.loads(path.read_text(encoding="utf-8")) manifest["artifacts"]["mlm_qq"]["sha256"] = "0" * 64 path.write_text(json.dumps(manifest), encoding="utf-8") return "✅ done", path monkeypatch.setattr(resource_governor, "ResourceLease", NoopLease) monkeypatch.setattr(resource_governor, "get_task", lambda _task_id: {}) monkeypatch.setattr(resource_governor, "snapshot", lambda **_kwargs: {"usage": {}}) monkeypatch.setattr(api, "WORKSPACE_ROOT", workspace_root) monkeypatch.setattr(api, "INPUT_ROOT", tmp_path / "input") monkeypatch.setattr(api, "_attach_single_stage_status", lambda _task: None) monkeypatch.setattr(api, "_attach_annotation_progress", lambda _task: None) monkeypatch.setattr(api.time, "sleep", lambda _seconds: None) monkeypatch.setattr(backend, "run_stage", fake_run_stage) api.tasks["hash-invalid-plot"] = { "id": "hash-invalid-plot", "stage": "gwas_plot", "job_id": "job", "status": "pending", "message": "", "created_at": "2026-07-21T00:00:00", } api.run_stage_sync("hash-invalid-plot", "gwas_plot", "job", {"prefix": "cohort", **_scopes()}) task = api.tasks["hash-invalid-plot"] assert task["status"] == "failed" and task["result"]["success"] is False assert "images" not in task["result"] and "files" not in task["result"] def test_backend_plot_accepts_only_fresh_exact_success_manifest(monkeypatch, tmp_path): workspace = tmp_path / "workspace" _, paths = _mlm_manifest(workspace) decisions = { role: {"path": str(path), "source_manifest": "same"} for role, path in paths.items() } decisions["candidate_marker_count"] = 93563 monkeypatch.setattr(backend, "_resolve_gwas_plot_inputs", lambda **_kwargs: decisions) produced = workspace / "final" / "cohort.gwas_plot.identity.manifest.json" def fake_run(command, cwd=None): assert command[command.index("--log-file") + 1].endswith("_gwas_plot.log") _plot_manifest(workspace) return 0 monkeypatch.setattr(backend, "_run_child_owned_log", fake_run) message, manifest = backend.run_stage( stage="gwas_plot", job=backend.JobInfo("job", str(tmp_path / "input"), str(workspace), str(workspace / "logs"), "now"), prefix="cohort", genotype_scope_id=GENOTYPE, phenotype_scope_id=PHENOTYPE, covariate_scope_id=COVARIATE, source_model_scope_id=SOURCE_MODEL, gwas_rscript=RSCRIPT, ) assert message.startswith("✅") assert manifest == produced def test_backend_plot_failure_never_replays_prior_success(monkeypatch, tmp_path): workspace = tmp_path / "workspace" _, paths = _mlm_manifest(workspace) _plot_manifest(workspace) decisions = {role: {"path": str(path), "source_manifest": "same"} for role, path in paths.items()} decisions["candidate_marker_count"] = 93563 monkeypatch.setattr(backend, "_resolve_gwas_plot_inputs", lambda **_kwargs: decisions) monkeypatch.setattr(backend, "_run_child_owned_log", lambda *_args, **_kwargs: 7) message, manifest = backend.run_stage( stage="gwas_plot", job=backend.JobInfo("job", str(tmp_path / "input"), str(workspace), str(workspace / "logs"), "now"), prefix="cohort", genotype_scope_id=GENOTYPE, phenotype_scope_id=PHENOTYPE, covariate_scope_id=COVARIATE, source_model_scope_id=SOURCE_MODEL, gwas_rscript=RSCRIPT, ) assert "exit code: 7" in message and "Log:" in message assert manifest is None def test_plot_status_and_results_require_exact_five_scope_identity(monkeypatch, tmp_path): workspace = tmp_path / "workspace" / "job" _plot_manifest(workspace) monkeypatch.setattr(api, "WORKSPACE_ROOT", tmp_path / "workspace") identity = StageIdentity( stage="gwas_plot", prefix="cohort", genotype_scope_id=GENOTYPE, phenotype_scope_id=PHENOTYPE, covariate_scope_id=COVARIATE, source_model_scope_id=SOURCE_MODEL, analysis_scope_id=ANALYSIS, ) status = get_stage_status_for_identity( "job", identity, workspace_root=tmp_path / "workspace", strict_identity=True ) assert status.completed is True api_status = asyncio.run(api.get_stage_status( job_id="job", stage="gwas_plot", prefix="cohort", genotype_scope_id=GENOTYPE, phenotype_scope_id=PHENOTYPE, covariate_scope_id=COVARIATE, source_model_scope_id=SOURCE_MODEL, analysis_scope_id=ANALYSIS, )) assert api_status["completed"] is True assert api_status["source_model_scope_id"] == SOURCE_MODEL wrong_status = asyncio.run(api.get_stage_status( job_id="job", stage="gwas_plot", prefix="cohort", genotype_scope_id=GENOTYPE, phenotype_scope_id=PHENOTYPE, covariate_scope_id=COVARIATE, source_model_scope_id="gwas-model:" + "9" * 64, analysis_scope_id=ANALYSIS, )) assert wrong_status["completed"] is False and wrong_status["status"] == "not_found" wrong_analysis_status = asyncio.run(api.get_stage_status( job_id="job", stage="gwas_plot", prefix="cohort", genotype_scope_id=GENOTYPE, phenotype_scope_id=PHENOTYPE, covariate_scope_id=COVARIATE, source_model_scope_id=SOURCE_MODEL, analysis_scope_id="gwas-analysis:" + "9" * 64, )) assert wrong_analysis_status["completed"] is False exact = asyncio.run(api.get_results( job_id="job", stage="gwas_plot", genotype_scope_id=GENOTYPE, phenotype_scope_id=PHENOTYPE, covariate_scope_id=COVARIATE, source_model_scope_id=SOURCE_MODEL, analysis_scope_id=ANALYSIS, )) assert len(exact["gwas_plot"]["images"]) == 4 assert {item["role"] for item in exact["gwas_plot"]["files"]} == { "report", "environment", "manifest", } assert exact["gwas_plot"]["elapsed_time"] == 4.6 wrong = asyncio.run(api.get_results( job_id="job", stage="gwas_plot", genotype_scope_id=GENOTYPE, phenotype_scope_id=PHENOTYPE, covariate_scope_id=COVARIATE, source_model_scope_id="gwas-model:" + "9" * 64, analysis_scope_id=ANALYSIS, )) assert wrong["gwas_plot"]["images"] == [] and wrong["gwas_plot"]["files"] == [] wrong_analysis = asyncio.run(api.get_results( job_id="job", stage="gwas_plot", genotype_scope_id=GENOTYPE, phenotype_scope_id=PHENOTYPE, covariate_scope_id=COVARIATE, source_model_scope_id=SOURCE_MODEL, analysis_scope_id="gwas-analysis:" + "9" * 64, )) assert wrong_analysis["gwas_plot"]["images"] == [] and wrong_analysis["gwas_plot"]["files"] == [] def test_plot_declarations_match_exact_contract(): from gfa_core.mas.tools.stage_tools import StageToolRegistry capability = get_stage_capability("gwas_plot") assert [item.role for item in capability.consumes] == ["mlm_pvalues", "cmlm_pvalues"] assert [item.role for item in capability.provides] == [ "mlm_manhattan", "mlm_qq", "cmlm_manhattan", "cmlm_qq", "manifest", "report", "environment", ] tool = StageToolRegistry().get("gwas_plot") assert tool.required_inputs == ["mlm_pvalues", "cmlm_pvalues"] assert tool.outputs == [ "mlm_manhattan", "mlm_qq", "cmlm_manhattan", "cmlm_qq", "manifest", "report", "environment", ] config = (Path(api.PROJECT_ROOT) / "skills/gwas-plot/config.yaml").read_text(encoding="utf-8") skill = (Path(api.PROJECT_ROOT) / "skills/gwas-plot/SKILL.md").read_text(encoding="utf-8") for role in ( "mlm_pvalues", "cmlm_pvalues", "mlm_manhattan", "mlm_qq", "cmlm_manhattan", "cmlm_qq", "manifest", "report", "environment", ): assert role in config and role in skill