Files
PlantAI-dev/tests/unit/test_gwas_plot_backend.py
T
2026-08-03 18:42:47 +08:00

637 lines
29 KiB
Python

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