637 lines
29 KiB
Python
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
|