#!/usr/bin/env python3 """Publication-style GWAS extension-stage experiment harness. The harness keeps GWAS as candidate extension evidence until dedicated GWAS template archetypes are admitted. It records both extension-stage lifecycle metrics and GWAS-specific biological/statistical metrics. """ from __future__ import annotations import argparse import csv import gzip import hashlib import html import json import os import shutil import subprocess import sys import time import traceback from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import Any PROJECT_ROOT = Path(__file__).resolve().parents[1] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) DEFAULT_SOURCE_ROOT = Path("/data_old/home/xia-lab/wutx/plantAnalysisAgent/vcf_code/gwas") DEFAULT_OUTPUT_ROOT = PROJECT_ROOT / "项目记录" / "GWAS拓展分析阶段_发表级完整实验_20260622" DEFAULT_ASAW_ENV = Path("/data/miniconda3/envs/ASAW") @dataclass(frozen=True) class GwasCase: index: int case_id: str group: str stage: str archetype_id: str script_name: str script_content: str required_outputs: tuple[str, ...] dependencies: tuple[str, ...] description: str candidate_only: bool = True validation_scope: str = "subset" @property def dir_name(self) -> str: return f"{self.index:02d}_{self.case_id}" COMMON_PY = r''' import csv import gzip import json import math import os import re import shutil import subprocess import sys import time from collections import defaultdict from pathlib import Path def open_text(path): path = Path(path) if path.suffix == ".gz": return gzip.open(path, "rt", encoding="utf-8", errors="ignore") return path.open("r", encoding="utf-8", errors="ignore") def write_json(path, data): path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") def run_cmd(cmd, cwd=None, timeout=240): started = time.time() def stable_cmd(items): stable = [] for item in items: text = str(item) stable.append(Path(text).name if "/" in text else text) return stable def stable_tail(text): text = re.sub(r"/[A-Za-z0-9_./\\-]+", "", text or "") text = re.sub(r"\\d{4}-\\d{2}-\\d{2}[T ][0-9:.+-]+", "", text) return text[-4000:] try: proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout) success = proc.returncode == 0 return { "cmd": stable_cmd(cmd), "returncode": proc.returncode, "success": success, "stdout_tail": "" if success else stable_tail(proc.stdout), "stderr_tail": "" if success else stable_tail(proc.stderr), "wall_sec": 0, } except Exception as exc: return {"cmd": stable_cmd(cmd), "returncode": None, "success": False, "stdout_tail": "", "stderr_tail": stable_tail(str(exc)), "wall_sec": 0} def vcf_stats(path, max_records=0): samples = [] variants = 0 contigs = set() missing_genotypes = 0 called_genotypes = 0 with open_text(path) as handle: for raw in handle: if raw.startswith("##"): continue if raw.startswith("#CHROM"): samples = raw.rstrip("\n").split("\t")[9:] continue if not raw.strip(): continue variants += 1 parts = raw.rstrip("\n").split("\t") contigs.add(parts[0]) for cell in parts[9:]: gt = cell.split(":", 1)[0] if "." in gt: missing_genotypes += 1 else: called_genotypes += 1 if max_records and variants >= max_records: break total = missing_genotypes + called_genotypes return { "vcf_samples": len(samples), "sample_ids": samples, "variant_count": variants, "contig_count": len(contigs), "contigs_preview": sorted(contigs)[:20], "missing_genotype_count": missing_genotypes, "called_genotype_count": called_genotypes, "missing_genotype_rate": round(missing_genotypes / total, 6) if total else 0, } def read_trait(path, trait_column="GC"): values = defaultdict(list) rows = 0 na_rows = 0 with Path(path).open("r", encoding="utf-8", errors="ignore", newline="") as handle: reader = csv.DictReader(handle, delimiter="\t") if not reader.fieldnames: raise SystemExit("trait file has no header") id_col = "ID" if "ID" in reader.fieldnames else reader.fieldnames[0] if trait_column not in reader.fieldnames: trait_column = reader.fieldnames[1] for row in reader: rows += 1 sid = str(row.get(id_col, "")).strip() raw_value = str(row.get(trait_column, "")).strip() if not sid or raw_value.upper() in {"", "NA", "NAN", "NULL"}: na_rows += 1 continue try: values[sid].append(float(raw_value)) except ValueError: na_rows += 1 cleaned = {sid: sum(vals) / len(vals) for sid, vals in values.items() if vals} duplicates = {sid: len(vals) for sid, vals in values.items() if len(vals) > 1} return cleaned, duplicates, rows, na_rows, trait_column def write_trait_outputs(vcf, trait, output_dir, prefix="trait"): out = Path(output_dir) out.mkdir(parents=True, exist_ok=True) stats = vcf_stats(vcf) traits, duplicates, trait_rows, na_rows, trait_column = read_trait(trait) samples = stats["sample_ids"] trait_set = set(traits) sample_set = set(samples) matched = [sid for sid in samples if sid in trait_set] vcf_without_trait = [sid for sid in samples if sid not in trait_set] trait_without_vcf = sorted(trait_set - sample_set) with (out / f"{prefix}.clean.tsv").open("w", encoding="utf-8", newline="") as handle: writer = csv.writer(handle, delimiter="\t") writer.writerow(["ID", trait_column]) for sid in matched: writer.writerow([sid, f"{traits[sid]:.8g}"]) with (out / "sample_match_report.tsv").open("w", encoding="utf-8", newline="") as handle: writer = csv.writer(handle, delimiter="\t") writer.writerow(["sample_id", "in_vcf", "has_trait", "used_for_gwas", "status"]) for sid in samples: has_trait = sid in trait_set writer.writerow([sid, "yes", "yes" if has_trait else "no", "yes" if has_trait else "no", "matched" if has_trait else "vcf_without_trait"]) for sid in trait_without_vcf: writer.writerow([sid, "no", "yes", "no", "trait_without_vcf"]) metrics = dict(stats) metrics.update({ "trait_rows": trait_rows, "trait_na_or_invalid_rows": na_rows, "trait_unique_non_na": len(traits), "duplicate_trait_ids": len(duplicates), "duplicate_trait_id_counts": duplicates, "matched_samples": len(matched), "sample_match_rate": round(len(matched) / len(samples), 6) if samples else 0, "vcf_without_trait": vcf_without_trait, "trait_without_vcf": trait_without_vcf, "trait_column": trait_column, "status": "success" if matched else "failed", }) return metrics def dosage_from_gt(gt): gt = (gt or "").replace("|", "/").split(":", 1)[0] if "." in gt or not gt: return None alleles = gt.split("/") try: return sum(1 for allele in alleles if int(allele) > 0) except Exception: return None def parse_vcf_records(path, max_records=0): samples = [] records = [] with open_text(path) as handle: for raw in handle: if raw.startswith("##"): continue if raw.startswith("#CHROM"): samples = raw.rstrip("\n").split("\t")[9:] continue if not raw.strip(): continue parts = raw.rstrip("\n").split("\t") if len(parts) < 10: continue records.append({ "chrom": parts[0], "pos": parts[1], "id": parts[2] if parts[2] not in {"", "."} else f"{parts[0]}:{parts[1]}", "ref": parts[3], "alt": parts[4].split(",", 1)[0], "calls": parts[9:], }) if max_records and len(records) >= max_records: break return samples, records def hapmap_gt(ref, alt, call): gt = (call or "").split(":", 1)[0].replace("|", "/") if "." in gt or not gt: return "NN" alleles = [] for item in gt.split("/"): alleles.append(alt if item != "0" else ref) if not alleles: return "NN" if len(alleles) == 1: alleles.append(alleles[0]) return "".join(alleles[:2]) def write_hapmap_from_vcf(vcf, out_path): samples, records = parse_vcf_records(vcf) out_path = Path(out_path) with out_path.open("w", encoding="utf-8", newline="") as handle: writer = csv.writer(handle, delimiter="\t") writer.writerow(["rs#", "alleles", "chrom", "pos", "strand", "assembly#", "center", "protLSID", "assayLSID", "panelLSID", "QCcode", *samples]) for rec in records: writer.writerow([ rec["id"], f"{rec['ref']}/{rec['alt']}", rec["chrom"], rec["pos"], "+", "NA", "NA", "NA", "NA", "NA", "NA", *[hapmap_gt(rec["ref"], rec["alt"], call) for call in rec["calls"]], ]) return {"samples": len(samples), "rows": len(records)} def pearson(xs, ys): n = len(xs) if n < 3: return 0.0 mx = sum(xs) / n my = sum(ys) / n vx = sum((x - mx) ** 2 for x in xs) vy = sum((y - my) ** 2 for y in ys) if vx <= 0 or vy <= 0: return 0.0 return sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / math.sqrt(vx * vy) def write_candidate_pvalues(vcf, trait_table, out_path): traits = {} with Path(trait_table).open("r", encoding="utf-8", errors="ignore") as handle: reader = csv.DictReader(handle, delimiter="\t") value_col = "" if reader.fieldnames: for name in reader.fieldnames: if name != "ID": value_col = name break for row in reader: sid = str(row.get("ID", "")).strip() if not sid: continue try: traits[sid] = float(row.get(value_col, "nan")) except Exception: continue samples, records = parse_vcf_records(vcf) rows = [] for rec in records: xs = [] ys = [] for sid, call in zip(samples, rec["calls"]): if sid not in traits: continue dosage = dosage_from_gt(call) if dosage is None: continue xs.append(float(dosage)) ys.append(float(traits[sid])) r = pearson(xs, ys) n = len(xs) z = abs(r) * math.sqrt(max(n - 2, 1)) p_value = max(min(math.erfc(z / math.sqrt(2)), 1.0), 1e-300) rows.append({"chr": rec["chrom"], "pos": rec["pos"], "marker": rec["id"], "n": n, "r": r, "p": p_value}) with Path(out_path).open("w", encoding="utf-8", newline="") as handle: writer = csv.writer(handle, delimiter="\t") writer.writerow(["chr", "pos", "marker", "n", "r", "p"]) for row in rows: writer.writerow([row["chr"], row["pos"], row["marker"], row["n"], f"{row['r']:.8g}", f"{row['p']:.8g}"]) top = min(rows, key=lambda row: row["p"]) if rows else {} return {"rows": len(rows), "top_marker": top.get("marker", ""), "min_p": top.get("p")} ''' SCRIPT_TRAIT_VCF_QC = r'''#!/usr/bin/env bash set -euo pipefail for _gwas_bin in /data/miniconda3/envs/ASAW/bin /home/xia-lab/miniconda3/envs/ASAW/bin; do if [[ -d "$_gwas_bin" ]]; then export PATH="$_gwas_bin:$PATH"; break; fi done INPUT_DIR="${1:-${INPUT_DIR:-raw_test_data}}" OUTPUT_DIR="${2:-${OUTPUT_DIR:-outputs}}" mkdir -p "$OUTPUT_DIR" python3 - "$INPUT_DIR" "$OUTPUT_DIR" <<'PY' ''' + COMMON_PY + r''' input_dir = Path(sys.argv[1]) output_dir = Path(sys.argv[2]) metrics = write_trait_outputs(input_dir / "subset.clean.vcf.gz", input_dir / "90.txt", output_dir, prefix="trait") metrics["schema_version"] = "gwas_trait_vcf_qc_metrics_v1" metrics["case_id"] = "gwas_trait_vcf_qc" write_json(output_dir / "gwas_metrics.json", metrics) if metrics["status"] != "success": raise SystemExit("no matched samples") PY ''' SCRIPT_TRAIT_PREPARE = r'''#!/usr/bin/env bash set -euo pipefail for _gwas_bin in /data/miniconda3/envs/ASAW/bin /home/xia-lab/miniconda3/envs/ASAW/bin; do if [[ -d "$_gwas_bin" ]]; then export PATH="$_gwas_bin:$PATH"; break; fi done INPUT_DIR="${1:-${INPUT_DIR:-raw_test_data}}" OUTPUT_DIR="${2:-${OUTPUT_DIR:-outputs}}" mkdir -p "$OUTPUT_DIR" python3 - "$INPUT_DIR" "$OUTPUT_DIR" <<'PY' ''' + COMMON_PY + r''' input_dir = Path(sys.argv[1]) output_dir = Path(sys.argv[2]) metrics = write_trait_outputs(input_dir / "subset.clean.vcf.gz", input_dir / "90.txt", output_dir, prefix="trait.prepared") metrics["schema_version"] = "gwas_trait_prepare_match_metrics_v1" metrics["case_id"] = "gwas_trait_prepare_match" shutil.copy2(output_dir / "trait.prepared.clean.tsv", output_dir / "trait.prepared.tsv") write_json(output_dir / "gwas_metrics.json", metrics) PY ''' SCRIPT_VCF_FILTER = r'''#!/usr/bin/env bash set -euo pipefail for _gwas_bin in /data/miniconda3/envs/ASAW/bin /home/xia-lab/miniconda3/envs/ASAW/bin; do if [[ -d "$_gwas_bin" ]]; then export PATH="$_gwas_bin:$PATH"; break; fi done INPUT_DIR="${1:-${INPUT_DIR:-raw_test_data}}" OUTPUT_DIR="${2:-${OUTPUT_DIR:-outputs}}" mkdir -p "$OUTPUT_DIR" python3 - "$INPUT_DIR" "$OUTPUT_DIR" <<'PY' ''' + COMMON_PY + r''' input_dir = Path(sys.argv[1]) output_dir = Path(sys.argv[2]) vcf = input_dir / "subset.clean.vcf.gz" before = vcf_stats(vcf) cmd = ["bcftools", "view", "-i", "QUAL>=0", "-Oz", "-o", str(output_dir / "filtered.vcf.gz"), str(vcf)] view = run_cmd(cmd, timeout=240) if not view["success"]: write_json(output_dir / "gwas_metrics.json", {"schema_version": "gwas_vcf_filter_index_metrics_v1", "status": "failed", "bcftools_view": view, "input": before}) raise SystemExit("bcftools view failed") tabix = run_cmd(["tabix", "-f", "-p", "vcf", str(output_dir / "filtered.vcf.gz")], timeout=120) after = vcf_stats(output_dir / "filtered.vcf.gz") metrics = { "schema_version": "gwas_vcf_filter_index_metrics_v1", "case_id": "gwas_vcf_filter_index", "filter_expression": "QUAL>=0", "input_variant_count": before["variant_count"], "output_variant_count": after["variant_count"], "variant_retention_rate": round(after["variant_count"] / before["variant_count"], 6) if before["variant_count"] else 0, "input_sample_count": before["vcf_samples"], "output_sample_count": after["vcf_samples"], "index_exists": (output_dir / "filtered.vcf.gz.tbi").exists(), "bcftools_view": view, "tabix": tabix, "status": "success" if tabix["success"] else "failed", } write_json(output_dir / "gwas_metrics.json", metrics) if not tabix["success"]: raise SystemExit("tabix failed") PY ''' SCRIPT_PLINK_PCA = r'''#!/usr/bin/env bash set -euo pipefail for _gwas_bin in /data/miniconda3/envs/ASAW/bin /home/xia-lab/miniconda3/envs/ASAW/bin; do if [[ -d "$_gwas_bin" ]]; then export PATH="$_gwas_bin:$PATH"; break; fi done INPUT_DIR="${1:-${INPUT_DIR:-raw_test_data}}" OUTPUT_DIR="${2:-${OUTPUT_DIR:-outputs}}" mkdir -p "$OUTPUT_DIR" python3 - "$INPUT_DIR" "$OUTPUT_DIR" <<'PY' ''' + COMMON_PY + r''' input_dir = Path(sys.argv[1]) output_dir = Path(sys.argv[2]) vcf = input_dir / "subset.clean.vcf.gz" stats = vcf_stats(vcf) prefix = output_dir / "plink_pca" cmd = ["plink", "--vcf", str(vcf), "--double-id", "--allow-extra-chr", "--pca", "3", "--out", str(prefix)] plink = run_cmd(cmd, timeout=240) eigenvec = prefix.with_suffix(".eigenvec") eigenval = prefix.with_suffix(".eigenval") vec_rows = [line for line in eigenvec.read_text(encoding="utf-8", errors="ignore").splitlines() if line.strip()] if eigenvec.exists() else [] val_rows = [line for line in eigenval.read_text(encoding="utf-8", errors="ignore").splitlines() if line.strip()] if eigenval.exists() else [] metrics = { "schema_version": "gwas_plink_pca_metrics_v1", "case_id": "gwas_plink_pca", "vcf_samples": stats["vcf_samples"], "variant_count": stats["variant_count"], "plink": plink, "eigenvec_exists": eigenvec.exists(), "eigenval_exists": eigenval.exists(), "eigenvec_rows": len(vec_rows), "eigenval_count": len(val_rows), "pc_count_requested": 3, "status": "success" if plink["success"] and eigenvec.exists() and eigenval.exists() else "failed", } write_json(output_dir / "gwas_metrics.json", metrics) if metrics["status"] != "success": raise SystemExit("plink PCA failed") PY ''' SCRIPT_TRAIT_BLUP_FORMAT = r'''#!/usr/bin/env bash set -euo pipefail for _gwas_bin in /data/miniconda3/envs/ASAW/bin /home/xia-lab/miniconda3/envs/ASAW/bin; do if [[ -d "$_gwas_bin" ]]; then export PATH="$_gwas_bin:$PATH"; break; fi done INPUT_DIR="${1:-${INPUT_DIR:-raw_test_data}}" OUTPUT_DIR="${2:-${OUTPUT_DIR:-outputs}}" mkdir -p "$OUTPUT_DIR" python3 - "$INPUT_DIR" "$OUTPUT_DIR" <<'PY' ''' + COMMON_PY + r''' input_dir = Path(sys.argv[1]) output_dir = Path(sys.argv[2]) metrics = write_trait_outputs(input_dir / "subset.clean.vcf.gz", input_dir / "90.txt", output_dir, prefix="heading_days_BLUP_value") shutil.copy2(output_dir / "heading_days_BLUP_value.clean.tsv", output_dir / "heading_days_BLUP_value.tsv") with (output_dir / "heading_days_BLUP_value.tsv").open("r", encoding="utf-8") as src, (output_dir / "heading_days_BLUP_value.tassel.tsv").open("w", encoding="utf-8", newline="") as dst: reader = csv.reader(src, delimiter="\t") writer = csv.writer(dst, delimiter="\t") header = next(reader, []) writer.writerow(["taxa", header[1] if len(header) > 1 else "GC"]) for row in reader: if len(row) >= 2: writer.writerow([row[0], row[1]]) metrics["schema_version"] = "gwas_trait_blup_format_metrics_v1" metrics["case_id"] = "gwas_trait_blup_format" metrics["blup_policy"] = "duplicate_sample_mean_as_blup_compatible_value" metrics["tassel_trait_table"] = "heading_days_BLUP_value.tassel.tsv" write_json(output_dir / "gwas_metrics.json", metrics) if metrics["status"] != "success": raise SystemExit("no matched samples") PY ''' SCRIPT_PLINK_IMPUTE = r'''#!/usr/bin/env bash set -euo pipefail for _gwas_bin in /data/miniconda3/envs/ASAW/bin /home/xia-lab/miniconda3/envs/ASAW/bin; do if [[ -d "$_gwas_bin" ]]; then export PATH="$_gwas_bin:$PATH"; break; fi done INPUT_DIR="${1:-${INPUT_DIR:-raw_test_data}}" OUTPUT_DIR="${2:-${OUTPUT_DIR:-outputs}}" mkdir -p "$OUTPUT_DIR" python3 - "$INPUT_DIR" "$OUTPUT_DIR" <<'PY' ''' + COMMON_PY + r''' input_dir = Path(sys.argv[1]) output_dir = Path(sys.argv[2]) vcf = input_dir / "subset.clean.vcf.gz" before = vcf_stats(vcf) sorted_prefix = output_dir / "sorted_bed" imputed_prefix = output_dir / "imputed_bed" vcf_prefix = output_dir / "all.impute" make_bed = run_cmd(["plink", "--vcf", str(vcf), "--double-id", "--allow-extra-chr", "--set-missing-var-ids", "@:#", "--make-bed", "--out", str(sorted_prefix)], timeout=240) fill = run_cmd(["plink", "--bfile", str(sorted_prefix), "--allow-extra-chr", "--make-bed", "--fill-missing-a2", "--out", str(imputed_prefix)], timeout=240) if make_bed["success"] else {"success": False, "stderr_tail": "make-bed failed"} recode = run_cmd(["plink", "--bfile", str(imputed_prefix), "--allow-extra-chr", "--recode", "vcf-iid", "--out", str(vcf_prefix)], timeout=240) if fill["success"] else {"success": False, "stderr_tail": "fill-missing-a2 failed"} bgzip = run_cmd(["bgzip", "-f", str(vcf_prefix) + ".vcf"], timeout=120) if recode["success"] else {"success": False, "stderr_tail": "recode vcf failed"} tabix = run_cmd(["tabix", "-f", "-p", "vcf", str(vcf_prefix) + ".vcf.gz"], timeout=120) if bgzip["success"] else {"success": False, "stderr_tail": "bgzip failed"} after_path = output_dir / "all.impute.vcf.gz" after = vcf_stats(after_path) if after_path.exists() else {} metrics = { "schema_version": "gwas_plink_impute_metrics_v1", "case_id": "gwas_plink_impute", "input_variant_count": before["variant_count"], "input_sample_count": before["vcf_samples"], "input_missing_genotype_rate": before["missing_genotype_rate"], "output_variant_count": after.get("variant_count", 0), "output_sample_count": after.get("vcf_samples", 0), "output_missing_genotype_rate": after.get("missing_genotype_rate", 0), "make_bed": make_bed, "fill_missing_a2": fill, "recode_vcf": recode, "bgzip": bgzip, "tabix": tabix, "index_exists": (output_dir / "all.impute.vcf.gz.tbi").exists(), "status": "success" if tabix["success"] and after_path.exists() and (output_dir / "all.impute.vcf.gz.tbi").exists() else "failed", } write_json(output_dir / "gwas_metrics.json", metrics) if metrics["status"] != "success": raise SystemExit("plink imputation failed") PY ''' SCRIPT_TASSEL_COMPATIBLE = r'''#!/usr/bin/env bash set -euo pipefail for _gwas_bin in /data/miniconda3/envs/ASAW/bin /home/xia-lab/miniconda3/envs/ASAW/bin; do if [[ -d "$_gwas_bin" ]]; then export PATH="$_gwas_bin:$PATH"; break; fi done INPUT_DIR="${1:-${INPUT_DIR:-raw_test_data}}" OUTPUT_DIR="${2:-${OUTPUT_DIR:-outputs}}" mkdir -p "$OUTPUT_DIR" python3 - "$INPUT_DIR" "$OUTPUT_DIR" <<'PY' ''' + COMMON_PY + r''' input_dir = Path(sys.argv[1]) output_dir = Path(sys.argv[2]) vcf = input_dir / "subset.clean.vcf.gz" trait = input_dir / "90.txt" trait_metrics = write_trait_outputs(vcf, trait, output_dir, prefix="tassel_trait_work") shutil.copy2(output_dir / "tassel_trait_work.clean.tsv", output_dir / "tassel_trait.tsv") hapmap = write_hapmap_from_vcf(vcf, output_dir / "hapmap.hmp.txt") assoc = write_candidate_pvalues(vcf, output_dir / "tassel_trait.tsv", output_dir / "glm_pvalue.txt") probe = run_cmd(["run_pipeline.pl", "-Xmx2G", "-importGuess", str(vcf), "-ExportPlugin", "-saveAs", str(output_dir / "tassel_probe.hmp.txt"), "-format", "Hapmap"], timeout=240) probe_record = { "schema_version": "gwas_tassel_probe_v1", "case_id": "gwas_tassel_compatible", "command": probe.get("cmd"), "success": bool(probe.get("success")), "returncode": probe.get("returncode"), "stderr_tail": probe.get("stderr_tail", ""), "stdout_tail": probe.get("stdout_tail", ""), "interpretation": "TASSEL import/export probe only; subset data may be rejected by TASSEL contig ordering while compatible HapMap/trait/pvalue artifacts remain generated.", } write_json(output_dir / "tassel_probe.json", probe_record) metrics = dict(trait_metrics) metrics.update({ "schema_version": "gwas_tassel_compatible_metrics_v1", "case_id": "gwas_tassel_compatible", "hapmap_rows": hapmap["rows"], "hapmap_samples": hapmap["samples"], "glm_rows": assoc["rows"], "top_marker": assoc["top_marker"], "min_p": assoc["min_p"], "tassel_probe_success": bool(probe.get("success")), "tassel_full_model_executed": False, "compatibility_policy": "produce TASSEL-compatible HapMap/trait/pvalue package and record real TASSEL probe result separately", "status": "success" if hapmap["rows"] > 0 and assoc["rows"] > 0 else "failed", }) write_json(output_dir / "gwas_metrics.json", metrics) if metrics["status"] != "success": raise SystemExit("TASSEL-compatible package generation failed") PY ''' SCRIPT_GEMMA_ASSOC = r'''#!/usr/bin/env bash set -euo pipefail for _gwas_bin in /data/miniconda3/envs/ASAW/bin /home/xia-lab/miniconda3/envs/ASAW/bin; do if [[ -d "$_gwas_bin" ]]; then export PATH="$_gwas_bin:$PATH"; break; fi done INPUT_DIR="${1:-${INPUT_DIR:-raw_test_data}}" OUTPUT_DIR="${2:-${OUTPUT_DIR:-outputs}}" mkdir -p "$OUTPUT_DIR" python3 - "$INPUT_DIR" "$OUTPUT_DIR" <<'PY' ''' + COMMON_PY + r''' input_dir = Path(sys.argv[1]) output_dir = Path(sys.argv[2]) vcf = input_dir / "subset.clean.vcf.gz" trait = input_dir / "90.txt" trait_metrics = write_trait_outputs(vcf, trait, output_dir, prefix="trait") plink_prefix = output_dir / "gemma_snp" plink = run_cmd(["plink", "--vcf", str(vcf), "--double-id", "--allow-extra-chr", "--make-bed", "--out", str(plink_prefix)], timeout=240) fam = plink_prefix.with_suffix(".fam") trait_values = {} with (output_dir / "trait.clean.tsv").open("r", encoding="utf-8") as handle: next(handle, None) for line in handle: parts = line.rstrip("\n").split("\t") if len(parts) >= 2: trait_values[parts[0]] = parts[1] if fam.exists(): rows = [] for line in fam.read_text(encoding="utf-8", errors="ignore").splitlines(): parts = line.split() if len(parts) >= 6: sid = parts[1] parts[5] = trait_values.get(sid, "-9") rows.append(" ".join(parts)) fam.write_text("\n".join(rows) + "\n", encoding="utf-8") gemma_version = run_cmd(["gemma", "-h", "1"], timeout=30) kin = run_cmd(["gemma", "-bfile", str(plink_prefix), "-gk", "1", "-o", "kin", "-outdir", str(output_dir)], timeout=240) if plink["success"] else {"success": False, "stderr_tail": "plink failed"} assoc = run_cmd(["gemma", "-bfile", str(plink_prefix), "-lm", "4", "-o", "lm", "-outdir", str(output_dir)], timeout=240) if plink["success"] else {"success": False, "stderr_tail": "plink failed"} assoc_files = sorted(output_dir.glob("*.assoc.txt")) assoc_rows = 0 min_p = None top_marker = "" if assoc_files: with assoc_files[0].open("r", encoding="utf-8", errors="ignore") as handle: header = next(handle, "").rstrip("\n").split("\t") p_index = header.index("p_wald") if "p_wald" in header else -1 rs_index = header.index("rs") if "rs" in header else 0 for line in handle: parts = line.rstrip("\n").split("\t") if len(parts) <= max(p_index, rs_index): continue assoc_rows += 1 try: p = float(parts[p_index]) except Exception: continue if min_p is None or p < min_p: min_p = p top_marker = parts[rs_index] metrics = dict(trait_metrics) metrics.update({ "schema_version": "gwas_gemma_assoc_metrics_v1", "case_id": "gwas_gemma_assoc", "plink": plink, "gemma_version_probe": { "cmd": gemma_version.get("cmd"), "success": bool(gemma_version.get("stdout_tail") or gemma_version.get("success")), "probe": "GEMMA 0.98.3" if "GEMMA 0.98.3" in str(gemma_version.get("stdout_tail") or "") else str(gemma_version.get("stdout_tail") or "")[:80], }, "gemma_kinship": kin, "gemma_lm": assoc, "assoc_file_count": len(assoc_files), "assoc_rows": assoc_rows, "min_p": min_p, "top_marker": top_marker, "status": "success" if assoc["success"] and assoc_rows > 0 else "failed", }) write_json(output_dir / "gwas_metrics.json", metrics) if metrics["status"] != "success": raise SystemExit("gemma association failed") PY ''' def cases() -> tuple[GwasCase, ...]: return ( GwasCase(1, "gwas_trait_vcf_qc", "gwas_trait", "gwas_trait_prepare", "gwas_trait_vcf_qc", "run_gwas_trait_vcf_qc.sh", SCRIPT_TRAIT_VCF_QC, ("trait.clean.tsv", "sample_match_report.tsv", "gwas_metrics.json"), (), "Clean trait values and match VCF samples."), GwasCase(2, "gwas_vcf_filter_index", "gwas_vcf", "gwas_vcf_filter", "gwas_vcf_filter_index", "run_gwas_vcf_filter_index.sh", SCRIPT_VCF_FILTER, ("filtered.vcf.gz", "filtered.vcf.gz.tbi", "gwas_metrics.json"), ("bcftools", "tabix"), "Filter/index a bounded VCF subset with bcftools/tabix."), GwasCase(3, "gwas_trait_prepare_match", "gwas_trait", "gwas_trait_prepare", "gwas_trait_prepare_match", "run_gwas_trait_prepare_match.sh", SCRIPT_TRAIT_PREPARE, ("trait.prepared.tsv", "sample_match_report.tsv", "gwas_metrics.json"), (), "Trait cleaning as a standalone GWAS substep."), GwasCase(4, "gwas_plink_pca", "gwas_population_structure", "gwas_pca", "gwas_plink_pca", "run_gwas_plink_pca.sh", SCRIPT_PLINK_PCA, ("plink_pca.eigenvec", "plink_pca.eigenval", "gwas_metrics.json"), ("plink",), "Run PLINK PCA on a bounded VCF subset."), GwasCase(5, "gwas_trait_blup_format", "gwas_trait", "gwas_trait_blup_format", "gwas_trait_blup_format", "run_gwas_trait_blup_format.sh", SCRIPT_TRAIT_BLUP_FORMAT, ("heading_days_BLUP_value.tsv", "heading_days_BLUP_value.tassel.tsv", "sample_match_report.tsv", "gwas_metrics.json"), (), "Prepare BLUP-compatible trait tables for downstream GWAS tools."), GwasCase(6, "gwas_plink_impute", "gwas_imputation", "gwas_impute", "gwas_plink_impute", "run_gwas_plink_impute.sh", SCRIPT_PLINK_IMPUTE, ("all.impute.vcf.gz", "all.impute.vcf.gz.tbi", "imputed_bed.bed", "imputed_bed.bim", "imputed_bed.fam", "gwas_metrics.json"), ("plink", "bgzip", "tabix"), "Impute missing genotypes with PLINK fill-missing-a2 and emit VCF/BED artifacts."), GwasCase(7, "gwas_tassel_compatible", "gwas_tassel", "gwas_tassel_assoc", "gwas_tassel_compatible", "run_gwas_tassel_compatible.sh", SCRIPT_TASSEL_COMPATIBLE, ("hapmap.hmp.txt", "tassel_trait.tsv", "glm_pvalue.txt", "tassel_probe.json", "gwas_metrics.json"), ("run_pipeline.pl",), "Generate a TASSEL-compatible GWAS package and record the real TASSEL probe result.", validation_scope="subset_tassel_compatible"), GwasCase(8, "gwas_gemma_assoc", "gwas_association", "gwas_gemma_assoc", "gwas_gemma_assoc", "run_gwas_gemma_assoc.sh", SCRIPT_GEMMA_ASSOC, ("lm.assoc.txt", "gwas_metrics.json"), ("plink", "gemma"), "Run GEMMA LM on a bounded VCF/trait subset.", validation_scope="subset_association"), ) TOOL_ARGS = { "bcftools": ["--version"], "tabix": ["--version"], "bgzip": ["--version"], "vcftools": ["--version"], "plink": ["--version"], "admixture": [], "run_pipeline.pl": [], "run_tassel_pipeline.pl": [], "gemma": [], "java": ["-version"], "Rscript": ["--version"], } def now() -> str: return datetime.now().isoformat(timespec="seconds") def asaw_env(asaw: Path) -> dict[str, str]: env = dict(os.environ) env["PATH"] = f"{asaw / 'bin'}:{env.get('PATH', '')}" env["CONDA_PREFIX"] = str(asaw) return env def run_cmd(cmd: list[str], *, timeout: int = 30, env: dict[str, str] | None = None, cwd: Path | None = None) -> dict[str, Any]: started = time.time() try: proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout, env=env) return {"cmd": cmd, "returncode": proc.returncode, "success": proc.returncode == 0, "stdout_tail": proc.stdout[-4000:], "stderr_tail": proc.stderr[-4000:], "wall_sec": round(time.time() - started, 3)} except Exception as exc: return {"cmd": cmd, "returncode": None, "success": False, "stdout_tail": "", "stderr_tail": str(exc), "wall_sec": round(time.time() - started, 3)} def preflight(asaw: Path, out: Path) -> dict[str, Any]: env = asaw_env(asaw) rows = [] for tool, args in TOOL_ARGS.items(): exe = asaw / "bin" / tool present = exe.exists() result = run_cmd([str(exe), *args], env=env, timeout=60) if present else {"returncode": None, "success": False, "stdout_tail": "", "stderr_tail": "not present", "cmd": [str(exe), *args], "wall_sec": 0} output = (result.get("stdout_tail") or result.get("stderr_tail") or "").strip() usable = present and ("error while loading shared libraries" not in output.lower()) and ("no such file or directory" not in output.lower()) if tool == "admixture" and present and result.get("returncode") in {0, 255}: usable = True if tool == "gemma" and present and "GEMMA" in output: usable = True rows.append({"tool": tool, "path": str(exe), "present": present, "usable": bool(usable), "returncode": result.get("returncode"), "probe": output.splitlines()[0] if output else "", "stdout_tail": result.get("stdout_tail", ""), "stderr_tail": result.get("stderr_tail", "")}) report = {"schema_version": "gwas_publication_tool_preflight_v1", "created_at": now(), "asaw_env": str(asaw), "tools": rows, "usable_tools": sorted(row["tool"] for row in rows if row["usable"]), "blocked_tools": sorted(row["tool"] for row in rows if row["present"] and not row["usable"]), "missing_tools": sorted(row["tool"] for row in rows if not row["present"])} write_json(out / "tool_preflight.json", report) write_csv(out / "tool_preflight.csv", rows) return report def prepare(source_root: Path, out: Path, asaw: Path, max_variants: int) -> None: pre = preflight(asaw, out) usable = set(pre.get("usable_tools") or []) clean_vcf = source_root / "clean.vcf.gz" trait = source_root / "90.txt" if not clean_vcf.exists(): raise FileNotFoundError(f"clean VCF not found: {clean_vcf}") if not trait.exists(): raise FileNotFoundError(f"trait table not found: {trait}") rows = [] for case in cases(): root = out / "packages" / case.dir_name raw_code = root / "raw_code" raw_data = root / "raw_test_data" expected = root / "expected_outputs" raw_work = root / "_raw_run_work" for path in (raw_code, raw_data, expected, raw_work): clean_dir(path) script = raw_code / case.script_name script.write_text(case.script_content, encoding="utf-8") script.chmod(0o755) write_json(raw_code / "bbav5_extension_hint.json", extension_hint(case)) subset = raw_data / "subset.clean.vcf.gz" make_vcf_subset(clean_vcf, subset, max_variants=max_variants) shutil.copy2(trait, raw_data / "90.txt") missing = [tool for tool in case.dependencies if tool not in usable] if missing: raw = {"success": False, "skipped": True, "returncode": None, "new_files": [], "stderr_tail": "missing or unusable dependency: " + ", ".join(missing), "wall_sec": 0} metrics = {"schema_version": "gwas_case_blocked_metrics_v1", "case_id": case.case_id, "status": "blocked", "missing_dependencies": missing} write_json(expected / "gwas_metrics.json", metrics) else: raw = run_raw(root, case, asaw, out / "logs" / f"{case.dir_name}.raw.log") gwas_metrics = read_json(expected / "gwas_metrics.json") if (expected / "gwas_metrics.json").exists() else {} ok = raw.get("success") and all((expected / name).is_file() and (expected / name).stat().st_size > 0 for name in case.required_outputs) rec = { "schema_version": "gwas_publication_prepare_record_v1", "created_at": now(), "case_id": case.case_id, "group": case.group, "stage": case.stage, "archetype_id": case.archetype_id, "candidate_only": case.candidate_only, "validation_scope": case.validation_scope, "case_dir": str(root), "source_vcf": str(clean_vcf), "trait": str(trait), "subset": {"max_variants": max_variants, "subset_vcf": str(subset), "sha256": sha256(subset)}, "dependencies": list(case.dependencies), "raw_code_ok": bool(raw.get("success")), "expected_output_ok": bool(ok), "required_outputs": list(case.required_outputs), "raw_run": raw, "gwas_metrics": gwas_metrics, } rows.append(rec) write_json(root / "prepare_record.json", rec) (root / "README.md").write_text(case_readme(case, rec), encoding="utf-8") write_json(out / "prepare_manifest.json", {"schema_version": "gwas_publication_prepare_manifest_v1", "created_at": now(), "source_root": str(source_root), "case_count": len(rows), "prepared": sum(1 for row in rows if row["expected_output_ok"]), "blocked": sum(1 for row in rows if (row.get("raw_run") or {}).get("skipped")), "cases": rows}) def run_raw(root: Path, case: GwasCase, asaw: Path, log_path: Path) -> dict[str, Any]: work = root / "_raw_run_work" expected = root / "expected_outputs" copy_tree(root / "raw_test_data", work / "raw_test_data") copy_tree(root / "raw_code", work / "raw_code") before = file_set(work) cmd = ["bash", f"raw_code/{case.script_name}", "raw_test_data", "outputs"] started = time.time() proc = subprocess.run(cmd, cwd=work, capture_output=True, text=True, timeout=600, env=asaw_env(asaw)) after = file_set(work) new_files = sorted(after - before) if (work / "outputs").exists(): copy_tree(work / "outputs", expected) log_path.parent.mkdir(parents=True, exist_ok=True) log_path.write_text("$ " + " ".join(cmd) + "\n\n[stdout]\n" + proc.stdout + "\n[stderr]\n" + proc.stderr + "\n", encoding="utf-8") return {"success": proc.returncode == 0, "returncode": proc.returncode, "new_files": new_files, "stdout_tail": proc.stdout[-4000:], "stderr_tail": proc.stderr[-4000:], "wall_sec": round(time.time() - started, 3)} def run_batch(out: Path, converter: str, runtime_env: str, include_blocked: bool, stop_on_failure: bool) -> None: from gfa_core.extension_stage import ExtensionStageAgent, ExtensionStageManager effective = "deepseek_api" if converter in {"archetype_spec", "deepseek_agent"} else converter os.environ["BBAV5_EXTENSION_CONVERTER"] = effective if converter == "archetype_spec": import gfa_core.extension_stage.agent as agent_module import gfa_core.extension_stage.manager as manager_module def _convert_with_archetype_spec(**kwargs: Any) -> dict[str, Any]: return local_conversion_spec(str(kwargs.get("display_name") or ""), str(kwargs.get("runtime_env") or runtime_env)) agent_module.convert_with_deepseek_api = _convert_with_archetype_spec agent_module.repair_build_with_deepseek_api = _convert_with_archetype_spec agent_module.build_api_conversion_spec = _convert_with_archetype_spec manager_module.build_api_conversion_spec = _convert_with_archetype_spec run_root = out / "runtime" / converter for path in (run_root / "extension_stages", run_root / "workspace", run_root / "input", out / "results" / converter): clean_dir(path) manager = ExtensionStageManager(PROJECT_ROOT, store_root=run_root / "extension_stages", workspace_root=run_root / "workspace", input_root=run_root / "input") agent = ExtensionStageAgent(manager) rows = [] for case in cases(): prep = read_json(out / "packages" / case.dir_name / "prepare_record.json") if (out / "packages" / case.dir_name / "prepare_record.json").exists() else {} if not include_blocked and not prep.get("expected_output_ok"): rows.append(blocked_row(case, prep, converter)) continue row = run_one(agent, out, case, converter, runtime_env) rows.append(row) if stop_on_failure and not row_success(row): break summary = {"schema_version": "gwas_publication_batch_result_v1", "created_at": now(), "converter": converter, "runtime_env": runtime_env, "case_count": len(rows), "passed": sum(1 for row in rows if row_success(row)), "candidate_passed": sum(1 for row in rows if row.get("candidate_success")), "template_blocked": sum(1 for row in rows if row.get("template_blocked")), "failed": sum(1 for row in rows if not row_success(row) and not row.get("template_blocked")), "rows": rows} result_dir = out / "results" / converter write_json(result_dir / "summary.json", summary) write_csv(result_dir / "summary.csv", rows) (result_dir / "summary.md").write_text(summary_markdown(summary), encoding="utf-8") def run_one(agent: Any, out: Path, case: GwasCase, converter: str, runtime_env: str) -> dict[str, Any]: case_root = out / "packages" / case.dir_name res_dir = out / "results" / converter / "per_case" / case.dir_name clean_dir(res_dir) prep = read_json(case_root / "prepare_record.json") row = lifecycle_base_row(case, converter, prep) allow_api_repair = converter != "archetype_spec" try: build = agent.create_from_uploads(display_name=f"gwas_publication_{case.case_id}", command=f"bash {case.script_name}", runtime_env=runtime_env, code_files=upload_files(case_root / "raw_code"), data_files=upload_files(case_root / "raw_test_data"), description=case.description) write_json(res_dir / "build_response.json", build) ext = build.get("extension") or {} eid = str(ext.get("extension_id") or "") row.update({"build_success": bool(build.get("success") and eid), "extension_id": eid, "api_attempt_count": len(ext.get("build_attempts") or []), "build_api_attempt_count": len(ext.get("build_attempts") or []), "sanitizer_action_count": len(ext.get("sanitizer_actions") or [])}) trial = agent.trial_run(eid) write_json(res_dir / "trial_run.json", trial) if not trial.get("success") and allow_api_repair: repair = agent.repair_from_context(eid, phase="trial", run_context=trial.get("run") or {}, max_attempts=20, auto_publish=False) write_json(res_dir / "trial_repair_response.json", repair) row["trial_repair_attempt_count"] = int(repair.get("attempts") or 0) if repair.get("success"): trial = {"success": True, "extension": repair.get("extension") or {}, "run": repair.get("run") or {}} else: row["last_repair_phase"] = "trial" row["last_repair_error"] = str(repair.get("last_error") or "") elif not trial.get("success"): row["last_repair_phase"] = "trial" row["last_repair_error"] = str((trial.get("run") or {}).get("error") or "trial failed; API repair disabled for deterministic baseline") row["trial_success"] = bool(trial.get("success")) row["output_count"] = len((trial.get("run") or {}).get("outputs") or []) outputs = (trial.get("extension") or {}).get("outputs_detected") or (trial.get("run") or {}).get("outputs") or [] confirm = agent.confirm(eid, outputs=outputs, nl_triggers=[case.case_id, case.stage, case.archetype_id]) write_json(res_dir / "confirm_response.json", confirm) row["confirm_success"] = bool(confirm.get("success")) val = agent.validate(eid) write_json(res_dir / "validate_response.json", val) run = val.get("run") or {} gate = run.get("template_gate") or {} golden = run.get("golden_comparison") or {} row["validate_success"] = bool(val.get("success")) row["golden_status"] = str(golden.get("status") or "") row["template_gate_status"] = str(gate.get("status") or "") row["final_validation_status"] = str(run.get("status") or "") gate_errors = "; ".join(gate.get("errors") or []) if gate.get("status") == "failed" and ("unknown archetype_id" in gate_errors or "missing archetype_id" in gate_errors): row["template_blocked"] = True row["candidate_success"] = row["golden_status"] == "passed" and row["trial_success"] and row["confirm_success"] row["last_repair_phase"] = "template_gate" row["last_repair_error"] = gate_errors elif not val.get("success") and allow_api_repair: repair = agent.repair_from_context(eid, phase="validation", run_context=run, max_attempts=20, auto_publish=False) write_json(res_dir / "validation_repair_response.json", repair) row["validation_repair_attempt_count"] = int(repair.get("attempts") or 0) if repair.get("success"): run = repair.get("run") or {} gate = run.get("template_gate") or {} golden = run.get("golden_comparison") or {} row["validate_success"] = True row["golden_status"] = str(golden.get("status") or "") row["template_gate_status"] = str(gate.get("status") or "") row["final_validation_status"] = str(run.get("status") or "") else: row["last_repair_phase"] = "validation" row["last_repair_error"] = str(repair.get("last_error") or "") elif not val.get("success"): row["last_repair_phase"] = "validation" row["last_repair_error"] = str(run.get("error") or "validation failed; API repair disabled for deterministic baseline") if not row.get("template_blocked"): try: pub = agent.publish(eid) except Exception as pub_exc: if not allow_api_repair: row["last_repair_phase"] = "publish_gate" row["last_repair_error"] = str(pub_exc) raise publish_context = {"repair_phase": "publish_gate", "status": "failed", "error": str(pub_exc), "publish_checks": (agent.load(eid).get("publish_checks") or {}) if hasattr(agent, "load") else {}, "last_validation": run, "golden_comparison": golden, "template_gate": gate} repair = agent.repair_from_context(eid, phase="publish_gate", run_context=publish_context, max_attempts=20, auto_publish=True) write_json(res_dir / "publish_repair_response.json", repair) row["publish_repair_attempt_count"] = int(repair.get("attempts") or 0) if repair.get("success") and repair.get("published"): pub = {"success": True, "extension": repair.get("extension") or {}} else: row["last_repair_phase"] = "publish_gate" row["last_repair_error"] = str(repair.get("last_error") or str(pub_exc)) raise pub_exc write_json(res_dir / "publish_response.json", pub) row["publish_success"] = bool(pub.get("success")) row["final_publish_status"] = str((pub.get("extension") or {}).get("status") or "") except Exception as exc: row["error"] = str(exc) write_json(res_dir / "error.json", {"error": str(exc), "traceback": traceback.format_exc(), "row": row}) row["total_api_repair_attempt_count"] = int(row.get("trial_repair_attempt_count") or 0) + int(row.get("validation_repair_attempt_count") or 0) + int(row.get("publish_repair_attempt_count") or 0) write_json(res_dir / "result_row.json", row) return row def lifecycle_base_row(case: GwasCase, converter: str, prep: dict[str, Any]) -> dict[str, Any]: return {"case_id": case.case_id, "group": case.group, "stage": case.stage, "archetype_id": case.archetype_id, "candidate_only": case.candidate_only, "converter": converter, "raw_code_ok": bool(prep.get("raw_code_ok")), "expected_output_ok": bool(prep.get("expected_output_ok")), "build_success": False, "extension_id": "", "trial_success": False, "confirm_success": False, "validate_success": False, "golden_status": "", "template_gate_status": "", "publish_success": False, "candidate_success": False, "template_blocked": False, "output_count": 0, "api_attempt_count": 0, "build_api_attempt_count": 0, "trial_repair_attempt_count": 0, "validation_repair_attempt_count": 0, "publish_repair_attempt_count": 0, "total_api_repair_attempt_count": 0, "final_validation_status": "", "final_publish_status": "", "last_repair_phase": "", "last_repair_error": "", "sanitizer_action_count": 0, "gwas_metrics": prep.get("gwas_metrics") or {}, "error": ""} def blocked_row(case: GwasCase, prep: dict[str, Any], converter: str) -> dict[str, Any]: row = lifecycle_base_row(case, converter, prep) row["error"] = str((prep.get("raw_run") or {}).get("stderr_tail") or "case not prepared") return row def extension_hint(case: GwasCase) -> dict[str, Any]: volatile_binary = {"filtered.vcf.gz", "filtered.vcf.gz.tbi", "all.impute.vcf.gz", "all.impute.vcf.gz.tbi", "imputed_bed.bed"} output_contract = [{"name_pattern": name, "type": out_type(name), "primary": index == 0, "required": True, "allow_empty": False, "semantic_role": semantic_role(name), "compare_mode": compare_mode(name), "required_for_golden": name not in volatile_binary} for index, name in enumerate(case.required_outputs)] return {"schema_version": "bbav5_gwas_publication_extension_hint_v1", "case_id": case.case_id, "archetype_id": case.archetype_id, "representative_stage": case.stage, "recommended_original_command": f"bash {case.script_name}", "analysis_type": "generic_cli", "tool_family": "generic", "input_roles": {"input_file": {"role": "generic_input_file", "required": True}}, "output_contract": output_contract, "comparator_plan": {"mode": "pipeline_golden_diff", "default_compare": "structured", "allowed_differences": ["absolute paths", "tool runtime logs"], "steps": [{"name_pattern": item["name_pattern"], "compare_mode": item["compare_mode"], "required": True} for item in output_contract]}, "requires_user_confirmation": True, "candidate_only": case.candidate_only, "validation_scope": case.validation_scope} def local_conversion_spec(display_name: str, runtime_env: str) -> dict[str, Any]: case = case_from_display(display_name) hint = extension_hint(case) original_command = f"bash {case.script_name} . outputs" templated_command = f"bash {{code_dir}}/{case.script_name} {{input_dir}} {{output_dir}}" return {"template_type": "gwas_publication_candidate_spec", "archetype_id": case.archetype_id, "original_command": original_command, "command_template": templated_command, "semantic_command_template": "", "wrapper_command_template": templated_command, "analysis_type": hint["analysis_type"], "tool_family": hint["tool_family"], "input_roles": hint["input_roles"], "inputs": {"input_file": {"required": True}}, "parameters": {}, "outputs": {"directory": "outputs", "patterns": list(case.required_outputs)}, "output_contract": hint["output_contract"], "comparator_plan": hint["comparator_plan"], "requires_user_confirmation": True, "notes": "GWAS publication candidate spec; template gate is expected to block until GWAS archetypes are admitted.", "converter": {"type": "archetype_spec", "mode": "gwas_publication_candidate", "runtime_env": runtime_env}} def write_report(out: Path) -> None: summaries = {} for converter in ("archetype_spec", "deepseek_agent", "deepseek_api"): path = out / "results" / converter / "summary.json" if path.exists(): summaries[converter] = read_json(path) prepare_manifest = read_json(out / "prepare_manifest.json") if (out / "prepare_manifest.json").exists() else {} pre = read_json(out / "tool_preflight.json") if (out / "tool_preflight.json").exists() else {} metrics = {"schema_version": "gwas_publication_metrics_v1", "created_at": now(), "tool_preflight": pre, "prepare": {"case_count": prepare_manifest.get("case_count", 0), "prepared": prepare_manifest.get("prepared", 0), "blocked": prepare_manifest.get("blocked", 0)}, "summaries": summaries} write_json(out / "publication_metrics.json", metrics) tables = out / "tables" tables.mkdir(parents=True, exist_ok=True) for converter, summary in summaries.items(): write_csv(tables / f"table_{converter}_results.csv", summary.get("rows") or []) write_csv(tables / "table_prepare_cases.csv", prepare_manifest.get("cases") or []) (out / "summary_report.html").write_text(report_html(metrics), encoding="utf-8") def report_html(metrics: dict[str, Any]) -> str: rows = [] for name, summary in (metrics.get("summaries") or {}).items(): rows.append(f"{html.escape(name)}{summary.get('case_count')}{summary.get('passed')}{summary.get('candidate_passed')}{summary.get('template_blocked')}{summary.get('failed')}") tool_rows = [] for row in ((metrics.get("tool_preflight") or {}).get("tools") or []): tool_rows.append(f"{html.escape(str(row.get('tool')))}{row.get('present')}{row.get('usable')}{html.escape(str(row.get('probe') or ''))}") return "GWAS extension publication experiment

GWAS extension publication experiment

Run summaries

" + "".join(rows) + "
convertercasespublished passedcandidate passedtemplate blockedfailed

Tool preflight

" + "".join(tool_rows) + "
toolpresentusableprobe
" def summary_markdown(summary: dict[str, Any]) -> str: lines = ["# GWAS publication extension summary", "", f"- converter: `{summary.get('converter')}`", f"- cases: `{summary.get('case_count')}`", f"- published passed: `{summary.get('passed')}`", f"- candidate passed: `{summary.get('candidate_passed')}`", f"- template blocked: `{summary.get('template_blocked')}`", f"- failed: `{summary.get('failed')}`", ""] return "\n".join(lines) def row_success(row: dict[str, Any]) -> bool: return all(bool(row.get(key)) for key in ("raw_code_ok", "expected_output_ok", "build_success", "trial_success", "confirm_success", "validate_success", "publish_success")) and row.get("golden_status") == "passed" and row.get("template_gate_status") == "passed" def make_vcf_subset(source: Path, dest: Path, *, max_variants: int) -> None: dest.parent.mkdir(parents=True, exist_ok=True) written = 0 with gzip.open(source, "rt", encoding="utf-8", errors="ignore") as src, gzip.open(dest, "wt", encoding="utf-8") as out: for raw in src: if raw.startswith("#"): out.write(raw) continue if written >= max_variants: break out.write(raw) written += 1 if written == 0: raise RuntimeError(f"no variants copied from {source}") def case_from_display(text: str) -> GwasCase: for case in cases(): if case.case_id in text or case.archetype_id in text: return case raise ValueError(f"cannot infer GWAS case from display name: {text}") def case_readme(case: GwasCase, record: dict[str, Any]) -> str: return f"# {case.case_id}\n\n- stage: `{case.stage}`\n- archetype_id: `{case.archetype_id}`\n- candidate_only: `{case.candidate_only}`\n- validation_scope: `{case.validation_scope}`\n- expected_output_ok: `{record.get('expected_output_ok')}`\n\nThis package is part of the GWAS extension-stage publication experiment and uses a bounded VCF subset.\n" def upload_files(root: Path) -> list[tuple[str, bytes]]: return [(path.name, path.read_bytes()) for path in sorted(root.iterdir()) if path.is_file()] def expected_ok(root: Path, case: GwasCase) -> bool: return all((root / "expected_outputs" / name).is_file() and (root / "expected_outputs" / name).stat().st_size > 0 for name in case.required_outputs) def clean_dir(path: Path) -> None: if path.exists(): shutil.rmtree(path) path.mkdir(parents=True, exist_ok=True) def copy_tree(src: Path, dst: Path) -> None: dst.mkdir(parents=True, exist_ok=True) if not src.exists(): return for item in src.iterdir(): target = dst / item.name if item.is_dir(): shutil.copytree(item, target, dirs_exist_ok=True) else: shutil.copy2(item, target) def file_set(root: Path) -> set[str]: return {path.relative_to(root).as_posix() for path in root.rglob("*") if path.is_file()} def write_json(path: Path, data: Any) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") def read_json(path: Path) -> Any: return json.loads(path.read_text(encoding="utf-8")) def write_csv(path: Path, rows: list[dict[str, Any]]) -> None: path.parent.mkdir(parents=True, exist_ok=True) keys: list[str] = [] for row in rows: for key in row: if key not in keys: keys.append(key) with path.open("w", encoding="utf-8", newline="") as handle: writer = csv.DictWriter(handle, fieldnames=keys) writer.writeheader() for row in rows: writer.writerow({key: json.dumps(value, ensure_ascii=False) if isinstance(value, (dict, list)) else value for key, value in row.items()}) def sha256(path: Path) -> str: h = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): h.update(chunk) return h.hexdigest() def out_type(name: str) -> str: if name.endswith(".json"): return "json" if name.endswith((".tsv", ".csv", ".txt")): return "table" if name.endswith(".gz"): return "vcf" return "file" def compare_mode(name: str) -> str: if name.endswith(".json"): return "json" if name.endswith((".tsv", ".csv", ".txt")): return "table" return "file_presence" def semantic_role(name: str) -> str: mapping = { "heading_days_BLUP_value.tsv": "gwas_blup_trait_table", "heading_days_BLUP_value.tassel.tsv": "gwas_tassel_trait_table", "all.impute.vcf.gz": "gwas_imputed_vcf_gz", "all.impute.vcf.gz.tbi": "gwas_imputed_vcf_index", "imputed_bed.bed": "gwas_imputed_plink_bed", "imputed_bed.bim": "gwas_imputed_plink_bim", "imputed_bed.fam": "gwas_imputed_plink_fam", "hapmap.hmp.txt": "gwas_tassel_hapmap", "tassel_trait.tsv": "gwas_tassel_trait_table", "glm_pvalue.txt": "gwas_tassel_glm_pvalue_table", "tassel_probe.json": "gwas_tassel_probe_record", "lm.assoc.txt": "gwas_gemma_assoc_table", } if name in mapping: return mapping[name] if "metrics" in name: return "gwas_metrics" if "trait" in name: return "gwas_trait_table" if "sample_match" in name: return "gwas_sample_match_report" if "eigen" in name: return "gwas_pca_output" if "vcf" in name: return "gwas_vcf" return "gwas_output" def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--preflight", action="store_true") parser.add_argument("--prepare", action="store_true") parser.add_argument("--run", action="store_true") parser.add_argument("--report", action="store_true") parser.add_argument("--all", action="store_true") parser.add_argument("--source-root", type=Path, default=DEFAULT_SOURCE_ROOT) parser.add_argument("--output-root", type=Path, default=DEFAULT_OUTPUT_ROOT) parser.add_argument("--asaw-env", type=Path, default=DEFAULT_ASAW_ENV) parser.add_argument("--converter", default="archetype_spec", choices=["archetype_spec", "deepseek_agent", "deepseek_api", "local_rule", "rule"]) parser.add_argument("--runtime-env", default="ASAW") parser.add_argument("--max-variants", type=int, default=1000) parser.add_argument("--include-blocked", action="store_true") parser.add_argument("--stop-on-failure", action="store_true") args = parser.parse_args() if not any((args.preflight, args.prepare, args.run, args.report, args.all)): args.preflight = True out = args.output_root.resolve() out.mkdir(parents=True, exist_ok=True) if args.preflight or args.all: preflight(args.asaw_env.resolve(), out) if args.prepare or args.all: prepare(args.source_root.resolve(), out, args.asaw_env.resolve(), max_variants=max(1, args.max_variants)) if args.run or args.all: run_batch(out, args.converter, args.runtime_env, args.include_blocked, args.stop_on_failure) if args.report or args.all: write_report(out) write_json(out / "MANIFEST.json", {"schema_version": "gwas_publication_experiment_manifest_v1", "created_at": now(), "output_root": str(out), "script": str(Path(__file__).resolve()), "cases": [case.case_id for case in cases()]}) return 0 if __name__ == "__main__": raise SystemExit(main())