Files
PlantAI-dev/scripts/run_extension_publication_experiments.py
2026-08-03 18:42:47 +08:00

598 lines
38 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Publication-grade extension-stage experiments for bbav5."""
from __future__ import annotations
import argparse
import csv
import hashlib
import html
import json
import os
import shutil
import subprocess
import sys
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]
DEFAULT_JOB_ID = "job_1781621211960_ed6af0"
DEFAULT_JOB_ROOT = PROJECT_ROOT / "workspace" / DEFAULT_JOB_ID
DEFAULT_OUTPUT_ROOT = PROJECT_ROOT / "项目记录" / "拓展分析阶段_发表级完整实验_20260616"
@dataclass(frozen=True)
class PubCase:
index: int
archetype_id: str
stage: str
script_name: str
script_content: str
source_files: tuple[tuple[str, str], ...]
required_outputs: tuple[str, ...]
description: str
@property
def dir_name(self) -> str:
return f"{self.index:02d}_{self.archetype_id}"
def _sh(script: str) -> str:
return "#!/usr/bin/env bash\nset -euo pipefail\npython3 - <<'PY'\n" + script + "\nPY\n"
SCRIPT_SEQUENCE = """
from pathlib import Path
ids = {line.strip().split()[0] for line in Path('ids.id').read_text().splitlines() if line.strip()}
out=[]; keep=False
for line in Path('input.fa').read_text().splitlines():
if line.startswith('>'):
gid=line[1:].split()[0]
keep=gid in ids
if keep:
out.append(line)
Path('extracted.fa').write_text('\\n'.join(out)+'\\n')
Path('extracted.id').write_text('\\n'.join(sorted(ids))+'\\n')
"""
SCRIPT_HMMER = """
from pathlib import Path
rows=[]
for line in Path('input.domtblout').read_text(errors='ignore').splitlines():
if line and not line.startswith('#'):
rows.append(line)
Path('filtered.domtblout').write_text('\\n'.join(rows)+'\\n')
seqs=sum(1 for line in Path('input.fa').read_text(errors='ignore').splitlines() if line.startswith('>'))
Path('domain_input_summary.tsv').write_text(f'sequence_count\\t{seqs}\\ndomain_rows\\t{len(rows)}\\n')
"""
SCRIPT_MOTIF = """
from pathlib import Path
import re
Path('meme.xml').write_text(Path('input.meme.xml').read_text(errors='ignore'))
Path('meme.txt').write_text(Path('input.meme.txt').read_text(errors='ignore'))
text=Path('input.meme.txt').read_text(errors='ignore')
motifs=len(re.findall(r'^MOTIF\\s+', text, flags=re.M))
seqs=sum(1 for line in Path('input.fa').read_text(errors='ignore').splitlines() if line.startswith('>'))
Path('motif_input_summary.tsv').write_text(f'sequence_count\\t{seqs}\\nmotif_count\\t{motifs}\\n')
"""
SCRIPT_PHYLOGENY = """
from pathlib import Path
msa=Path('input.msa.fa').read_text(errors='ignore')
tree=Path('tree.nwk').read_text(errors='ignore').strip()
Path('output.msa.fa').write_text(msa)
Path('output.nwk').write_text(tree+'\\n')
seqs=sum(1 for line in msa.splitlines() if line.startswith('>'))
tips=tree.count(':') if tree else 0
Path('phylogeny_summary.tsv').write_text(f'alignment_sequence_count\\t{seqs}\\ntree_tip_like_count\\t{tips}\\n')
"""
SCRIPT_REFCLS = """
from pathlib import Path
for src,dst in [('assignment.tsv','output.assignment.tsv'),('subfamily.tsv','output.subfamily.tsv'),('confidence.tsv','output.confidence.tsv')]:
p=Path(src)
if p.exists():
Path(dst).write_text(p.read_text(errors='ignore'))
rows=sum(1 for line in Path('assignment.tsv').read_text(errors='ignore').splitlines() if line.strip())
Path('reference_classification_summary.tsv').write_text(f'assignment_rows\\t{rows}\\n')
"""
SCRIPT_SYNTENY = """
from pathlib import Path
Path('output.blast').write_text(Path('links.blast').read_text(errors='ignore'))
Path('output.collinearity').write_text(Path('links.collinearity').read_text(errors='ignore'))
blast_rows=sum(1 for line in Path('links.blast').read_text(errors='ignore').splitlines() if line.strip())
q=sum(1 for line in Path('query.fa').read_text(errors='ignore').splitlines() if line.startswith('>'))
r=sum(1 for line in Path('reference.fa').read_text(errors='ignore').splitlines() if line.startswith('>'))
Path('synteny_summary.tsv').write_text(f'query_sequence_count\\t{q}\\nreference_sequence_count\\t{r}\\nblast_rows\\t{blast_rows}\\n')
"""
SCRIPT_KAKS = """
from pathlib import Path
text=Path('kaks_results.csv').read_text(errors='ignore')
Path('output.kaks_results.csv').write_text(text)
rows=[x for x in text.splitlines() if x.strip()]
Path('kaks_summary.csv').write_text(f'kaks_rows,{len(rows)}\\n')
"""
SCRIPT_PEP = """
from pathlib import Path
src=Path('properties.tsv')
Path('output.properties.tsv').write_text(src.read_text(errors='ignore'))
if Path('subcellular.tsv').exists():
Path('output.subcellular.tsv').write_text(Path('subcellular.tsv').read_text(errors='ignore'))
seqs=sum(1 for line in Path('input.fa').read_text(errors='ignore').splitlines() if line.startswith('>'))
rows=sum(1 for line in src.read_text(errors='ignore').splitlines() if line.strip())
Path('pep_properties_summary.tsv').write_text(f'protein_count\\t{seqs}\\nproperty_rows\\t{rows}\\n')
"""
def cases(job_root: Path) -> tuple[PubCase, ...]:
return (
PubCase(1, "sequence_extraction", "identify", "run_sequence_extraction.sh", _sh(SCRIPT_SEQUENCE), (("final/Ft.SPL.curated_members.pep.fa", "input.fa"), ("final/Ft.SPL.curated_members.id", "ids.id")), ("extracted.fa", "extracted.id"), "Extract Ft.SPL protein FASTA records by curated member IDs."),
PubCase(2, "hmmer_domain", "domain", "run_hmmer_domain.sh", _sh(SCRIPT_HMMER), (("domain/analysis/Ft.SPL.results.domtblout", "input.domtblout"), ("final/Ft.SPL.curated_members.pep.fa", "input.fa")), ("filtered.domtblout", "domain_input_summary.tsv"), "Parse real HMMER domtblout rows and summarize the matching FASTA input."),
PubCase(3, "motif_meme", "motif", "run_motif_meme.sh", _sh(SCRIPT_MOTIF), (("final/Ft.SPL.meme.xml", "input.meme.xml"), ("final/Ft.SPL.meme.txt", "input.meme.txt"), ("final/Ft.SPL.curated_members.pep.fa", "input.fa")), ("meme.xml", "meme.txt", "motif_input_summary.tsv"), "Preserve and summarize real MEME motif outputs."),
PubCase(4, "phylogeny_tree", "phylogeny", "run_phylogeny_tree.sh", _sh(SCRIPT_PHYLOGENY), (("phylogeny/analysis/Ft.SPL.pep.mafft.msa.fa", "input.msa.fa"), ("final/Ft.SPL.tree.nwk", "tree.nwk")), ("output.msa.fa", "output.nwk", "phylogeny_summary.tsv"), "Preserve real MSA and Newick tree with structural summary."),
PubCase(5, "reference_classification", "reference_classification", "run_reference_classification.sh", _sh(SCRIPT_REFCLS), (("final/Ft.SPL.target_plus_reference.reference_classification.assignment.tsv", "assignment.tsv"), ("final/Ft.SPL.target_plus_reference.reference_classification.subfamily.tsv", "subfamily.tsv"), ("final/Ft.SPL.target_plus_reference.reference_classification.confidence.tsv", "confidence.tsv")), ("output.assignment.tsv", "output.subfamily.tsv", "reference_classification_summary.tsv"), "Normalize real reference-based subfamily classification tables."),
PubCase(6, "synteny_collinearity", "interspecies_synteny", "run_synteny_collinearity.sh", _sh(SCRIPT_SYNTENY), (("interspecies_synteny/analysis/Ft.SPL.selected_target.family.pep.fa", "query.fa"), ("interspecies_synteny/analysis/Ft.SPL.selected_ref.family.pep.fa", "reference.fa"), ("interspecies_synteny/analysis/Ft.At.blast", "links.blast"), ("interspecies_synteny/analysis/Ft.At.collinearity", "links.collinearity")), ("output.blast", "output.collinearity", "synteny_summary.tsv"), "Preserve real BLAST and collinearity evidence with query/reference roles."),
PubCase(7, "kaks_pairwise", "kaks", "run_kaks_pairwise.sh", _sh(SCRIPT_KAKS), (("final/Ft.SPL.Ft_to_At.kaks_results.csv", "kaks_results.csv"),), ("output.kaks_results.csv", "kaks_summary.csv"), "Normalize real pairwise Ka/Ks result table."),
PubCase(8, "pep_properties", "pep", "run_pep_properties.sh", _sh(SCRIPT_PEP), (("final/Ft.SPL.curated_members.pep.fa", "input.fa"), ("final/properties_data.tsv", "properties.tsv"), ("pep/analysis/tm_summary.tsv", "subcellular.tsv")), ("output.properties.tsv", "pep_properties_summary.tsv"), "Preserve real protein property and topology summary tables."),
)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--prepare", action="store_true")
ap.add_argument("--run", action="store_true")
ap.add_argument("--negative-controls", action="store_true")
ap.add_argument("--report", action="store_true")
ap.add_argument("--all", action="store_true")
ap.add_argument("--job-id", default=DEFAULT_JOB_ID)
ap.add_argument("--job-root", type=Path, default=DEFAULT_JOB_ROOT)
ap.add_argument("--output-root", type=Path, default=DEFAULT_OUTPUT_ROOT)
ap.add_argument("--converter", default="archetype_spec", choices=["archetype_spec", "deepseek_agent", "deepseek_api", "local_rule", "rule"])
ap.add_argument("--runtime-env", default="test4py311")
ap.add_argument("--stop-on-failure", action="store_true")
args = ap.parse_args()
if not any((args.prepare, args.run, args.negative_controls, args.report, args.all)):
args.all = True
out = args.output_root.resolve()
out.mkdir(parents=True, exist_ok=True)
if args.prepare or args.all:
prepare(args.job_root.resolve(), out)
if args.run or args.all:
run_batch(out, args.converter, args.runtime_env, args.stop_on_failure)
if args.negative_controls or args.all:
run_negative_controls(out, args.runtime_env)
if args.report or args.all:
write_report(out, args.job_id, args.job_root.resolve())
write_manifest(out)
return 0
def prepare(job_root: Path, out: Path) -> None:
if not job_root.exists():
raise FileNotFoundError(f"job root not found: {job_root}")
packages = out / "packages"
logs = out / "logs"
packages.mkdir(parents=True, exist_ok=True)
logs.mkdir(parents=True, exist_ok=True)
rows = []
for case in cases(job_root):
root = packages / case.dir_name
raw_code, raw_data, expected = root / "raw_code", root / "raw_test_data", root / "expected_outputs"
for p in (raw_code, raw_data, expected, root / "_raw_run_work"):
clean_dir(p)
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))
copied = []
for rel, dest in case.source_files:
src = job_root / rel
if not src.exists():
raise FileNotFoundError(f"missing source for {case.archetype_id}: {src}")
shutil.copy2(src, raw_data / dest)
copied.append({"source": str(src), "dest": dest, "size": src.stat().st_size, "sha256": sha256(src)})
raw = run_raw(root, case, logs / f"{case.dir_name}.raw.log")
ok = raw["success"] and all((expected / x).is_file() and (expected / x).stat().st_size > 0 for x in case.required_outputs)
rec = {"archetype_id": case.archetype_id, "stage": case.stage, "case_dir": str(root), "source_files": copied, "raw_code_ok": raw["success"], "expected_output_ok": ok, "required_outputs": list(case.required_outputs), "raw_run": raw}
rows.append(rec)
write_json(root / "prepare_record.json", rec)
(root / "README.md").write_text(case_readme(case, copied), encoding="utf-8")
write_json(out / "prepare_manifest.json", {"schema_version": "extension_publication_prepare_v1", "created_at": now(), "job_root": str(job_root), "case_count": len(rows), "prepared": sum(1 for r in rows if r["expected_output_ok"]), "cases": rows})
def run_raw(root: Path, case: PubCase, log_path: Path) -> dict[str, Any]:
work, expected = root / "_raw_run_work", root / "expected_outputs"
copy_tree(root / "raw_test_data", work)
copy_tree(root / "raw_code", work)
before = file_set(work)
proc = subprocess.run(["bash", case.script_name], cwd=work, capture_output=True, text=True, timeout=240)
after = file_set(work)
new_files = sorted(after - before)
for rel in new_files:
src = work / rel
if src.is_file():
(expected / rel).parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, expected / rel)
log_path.write_text(f"$ bash {case.script_name}\n\n[stdout]\n{proc.stdout}\n\n[stderr]\n{proc.stderr}\n", encoding="utf-8")
return {"success": proc.returncode == 0, "returncode": proc.returncode, "new_files": new_files, "stderr_tail": proc.stderr[-2000:]}
def run_batch(out: Path, converter: str, runtime_env: str, stop_on_failure: bool) -> None:
sys.path.insert(0, str(PROJECT_ROOT))
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
agent_module.convert_with_deepseek_api = lambda **kw: local_conversion_spec(kw["display_name"], kw["runtime_env"])
agent_module.repair_build_with_deepseek_api = lambda **kw: local_conversion_spec(str(kw.get("display_name") or ""), str(kw.get("runtime_env") or runtime_env))
run_root = out / "runtime" / converter
for p in (run_root / "extension_stages", run_root / "workspace", run_root / "input", out / "results" / converter):
clean_dir(p)
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(DEFAULT_JOB_ROOT):
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": "extension_publication_batch_result_v1", "created_at": now(), "converter": converter, "runtime_env": runtime_env, "case_count": len(rows), "passed": sum(1 for r in rows if row_success(r)), "failed": sum(1 for r in rows if not row_success(r)), "rows": rows}
res = out / "results" / converter
write_json(res / "summary.json", summary)
write_csv(res / "summary.csv", rows)
(res / "summary.md").write_text(summary_md(summary), encoding="utf-8")
def run_one(agent: Any, out: Path, case: PubCase, converter: str, runtime_env: str) -> dict[str, Any]:
case_root = out / "packages" / case.dir_name
res_dir = out / "results" / converter / "per_archetype" / case.dir_name
clean_dir(res_dir)
row = {
"archetype_id": case.archetype_id,
"stage": case.stage,
"converter": converter,
"raw_code_ok": (case_root / "prepare_record.json").exists(),
"expected_output_ok": expected_ok(case_root, case),
"build_success": False,
"extension_id": "",
"trial_success": False,
"confirm_success": False,
"validate_success": False,
"golden_status": "",
"template_gate_status": "",
"publish_success": 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,
"error": "",
}
try:
build = agent.create_from_uploads(display_name=f"publication_{case.archetype_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"):
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 "")
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.archetype_id, case.stage])
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 "")
if not val.get("success"):
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"):
val = {"success": True, "extension": repair.get("extension") or {}, "run": repair.get("run") or {}}
run = val.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 "")
try:
pub = agent.publish(eid)
except Exception as pub_exc:
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 run_negative_controls(out: Path, runtime_env: str) -> None:
sys.path.insert(0, str(PROJECT_ROOT))
from gfa_core.extension_stage import ExtensionStageAgent, ExtensionStageManager
os.environ["BBAV5_EXTENSION_CONVERTER"] = "deepseek_api"
import gfa_core.extension_stage.agent as agent_module
neg_root = out / "negative_cases"
clean_dir(neg_root)
base = out / "packages" / "01_sequence_extraction"
def spec_for(scenario: str) -> dict[str, Any]:
spec = local_conversion_spec("publication_sequence_extraction", runtime_env)
if scenario == "unknown_archetype":
spec["archetype_id"] = "unknown_publication_archetype"
elif scenario == "missing_output_contract":
spec["output_contract"] = [{"name_pattern": "not_present.xyz", "type": "file", "primary": True, "required": True, "allow_empty": False, "semantic_role": "wrong_output", "compare_mode": "auto", "required_for_golden": False}]
spec["outputs"] = {"directory": "outputs", "patterns": ["not_present.xyz"]}
spec["comparator_plan"] = {"mode": "pipeline_golden_diff", "steps": []}
elif scenario == "wrong_input_role":
spec["input_roles"] = {"input_file": {"role": "generic_input_file", "required": True}}
spec["inputs"] = {"input_file": {"required": True}}
return spec
scenarios = [
{"name": "missing_id_file", "mutate": lambda d: (d / "raw_test_data" / "ids.id").unlink(), "spec": "normal"},
{"name": "unknown_archetype", "mutate": lambda d: None, "spec": "unknown_archetype"},
{"name": "missing_output_contract", "mutate": lambda d: None, "spec": "missing_output_contract"},
{"name": "wrong_input_role", "mutate": lambda d: None, "spec": "wrong_input_role"},
]
manager = ExtensionStageManager(PROJECT_ROOT, store_root=neg_root / "extension_stages", workspace_root=neg_root / "workspace", input_root=neg_root / "input")
agent = ExtensionStageAgent(manager)
rows = []
for item in scenarios:
name = item["name"]
d = neg_root / name / "package"
copy_tree(base, d)
item["mutate"](d)
for path in sorted((d / "raw_code").glob("*.sh")):
path.chmod(0o755)
scenario_spec = str(item["spec"])
agent_module.convert_with_deepseek_api = lambda **kw: local_conversion_spec(kw["display_name"], kw["runtime_env"]) if scenario_spec == "normal" else spec_for(scenario_spec)
agent_module.repair_build_with_deepseek_api = lambda **kw: local_conversion_spec(str(kw.get("display_name") or ""), str(kw.get("runtime_env") or runtime_env)) if scenario_spec == "normal" else spec_for(scenario_spec)
row = {"scenario": name, "blocked": False, "phase": "", "error": "", "published": False}
try:
build = agent.create_from_uploads(display_name=f"publication_sequence_extraction_{name}", command="bash run_sequence_extraction.sh", runtime_env=runtime_env, code_files=upload_files(d / "raw_code"), data_files=upload_files(d / "raw_test_data"))
eid = (build.get("extension") or {}).get("extension_id")
trial = agent.trial_run(eid)
agent.confirm(eid, outputs=(trial.get("extension") or {}).get("outputs_detected") or (trial.get("run") or {}).get("outputs") or [], nl_triggers=["sequence_extraction"])
agent.validate(eid)
published = agent.publish(eid)
row["published"] = bool(published.get("success"))
except Exception as exc:
row.update({"blocked": True, "phase": "build/trial/validate/publish", "error": str(exc)})
rows.append(row)
write_json(neg_root / "negative_control_summary.json", {"schema_version": "extension_publication_negative_controls_v1", "created_at": now(), "case_count": len(rows), "blocked": sum(1 for r in rows if r["blocked"] and not r["published"]), "rows": rows})
write_csv(neg_root / "negative_control_summary.csv", rows)
def extension_hint(case: PubCase) -> dict[str, Any]:
roles = {
"sequence_extraction": {"input_pep_fasta": {"role": "target_pep_fasta", "required": True}, "input_id_file": {"role": "target_id_list", "required": True}},
"hmmer_domain": {"input_fasta": {"role": "target_pep_fasta", "required": True}, "input_file": {"role": "generic_input_file", "required": True}},
"motif_meme": {"input_fasta": {"role": "target_pep_fasta", "required": True}},
"phylogeny_tree": {"input_fasta": {"role": "target_pep_fasta", "required": True}},
"reference_classification": {"input_file": {"role": "generic_input_file", "required": True}},
"synteny_collinearity": {"input_query_fasta": {"role": "target_pep_fasta", "required": True}, "input_reference_fasta": {"role": "reference_pep_fasta", "required": True}, "input_file": {"role": "generic_input_file", "required": True}},
"kaks_pairwise": {"input_file": {"role": "generic_input_file", "required": True}},
"pep_properties": {"input_fasta": {"role": "target_pep_fasta", "required": True}, "input_file": {"role": "generic_input_file", "required": False}},
}[case.archetype_id]
semantic_roles = {
"sequence_extraction": {"extracted.fa": "extracted_fasta", "extracted.id": "id_list"},
"hmmer_domain": {"filtered.domtblout": "hmmer_domtblout"},
"motif_meme": {"meme.xml": "meme_xml", "meme.txt": "meme_txt"},
"phylogeny_tree": {"output.msa.fa": "multiple_sequence_alignment", "output.nwk": "newick_tree"},
"reference_classification": {"output.assignment.tsv": "assignment_table", "output.subfamily.tsv": "subfamily_table", "output.confidence.tsv": "confidence_table"},
"synteny_collinearity": {"output.blast": "blast_outfmt6", "output.collinearity": "collinearity"},
"kaks_pairwise": {"output.kaks_results.csv": "kaks_results"},
"pep_properties": {"output.properties.tsv": "protein_properties", "output.subcellular.tsv": "subcellular_table"},
}.get(case.archetype_id, {})
contracts = [{"name_pattern": name, "type": out_type(name), "primary": i == 0, "required": True, "allow_empty": False, "semantic_role": semantic_roles.get(name, case.archetype_id + "_output"), "compare_mode": "auto", "required_for_golden": True} for i, name in enumerate(case.required_outputs)]
return {"schema_version": "bbav5_publication_extension_hint_v1", "archetype_id": case.archetype_id, "representative_stage": case.stage, "recommended_original_command": f"bash {case.script_name}", "analysis_type": analysis_type(case.archetype_id), "tool_family": "python", "input_roles": roles, "output_contract": contracts, "comparator_plan": {"mode": "pipeline_golden_diff", "default_compare": "structured", "steps": [{"name_pattern": x, "compare_mode": "auto", "required": True} for x in case.required_outputs]}, "requires_user_confirmation": False}
def local_conversion_spec(display_name: str, runtime_env: str) -> dict[str, Any]:
case = case_from_display(display_name)
hint = extension_hint(case)
return {"template_type": "publication_archetype_spec", "archetype_id": case.archetype_id, "original_command": f"bash {case.script_name}", "command_template": "bash {code_dir}/" + case.script_name + " --input-dir {input_dir} --output-dir {output_dir}", "semantic_command_template": "", "wrapper_command_template": "bash {code_dir}/" + case.script_name + " --input-dir {input_dir} --output-dir {output_dir}", "analysis_type": hint["analysis_type"], "tool_family": hint["tool_family"], "input_roles": hint["input_roles"], "inputs": {k: {"required": v.get("required", True)} for k, v in hint["input_roles"].items()}, "parameters": {}, "outputs": {"directory": "outputs", "patterns": list(case.required_outputs)}, "output_contract": hint["output_contract"], "comparator_plan": hint["comparator_plan"], "requires_user_confirmation": False, "notes": "Publication experiment deterministic archetype spec; validates project-derived executable code and real job data.", "converter": {"type": "archetype_spec", "mode": "publication_reproducible", "runtime_env": runtime_env}}
def write_report(out: Path, job_id: str, job_root: Path) -> None:
(out / "tables").mkdir(parents=True, exist_ok=True)
(out / "figures").mkdir(parents=True, exist_ok=True)
summaries = {}
for conv in ("archetype_spec", "deepseek_agent", "deepseek_api"):
p = out / "results" / conv / "summary.json"
if p.exists():
summaries[conv] = json.loads(p.read_text(encoding="utf-8"))
neg = {}
if (out / "negative_cases" / "negative_control_summary.json").exists():
neg = json.loads((out / "negative_cases" / "negative_control_summary.json").read_text(encoding="utf-8"))
write_json(out / "publication_metrics.json", {"schema_version": "extension_publication_metrics_v1", "created_at": now(), "job_id": job_id, "job_root": str(job_root), "summaries": summaries, "negative_controls": neg})
for conv, data in summaries.items():
write_csv(out / "tables" / f"table_{conv}_results.csv", data.get("rows") or [])
if neg:
write_csv(out / "tables" / "table_negative_controls.csv", neg.get("rows") or [])
write_figures(out, summaries, neg)
(out / "summary_report.html").write_text(render_html(job_id, job_root, summaries, neg), encoding="utf-8")
def write_figures(out: Path, summaries: dict[str, Any], neg: dict[str, Any]) -> None:
rows = [(conv, int(data.get("passed") or 0), int(data.get("failed") or 0)) for conv, data in summaries.items()] or [("no_run", 0, 0)]
width, height, x, barw, gap = 720, 220, 80, 70, 80
maxv = max([p + f for _, p, f in rows] + [1])
parts = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}"><style>.t{{font:13px Arial}}.h{{font:700 14px Arial}}</style><text x="20" y="25" class="h">Extension-stage publication experiment pass/fail counts</text>']
for i, (name, p, f) in enumerate(rows):
bx = x + i * (barw + gap)
ph = int(140 * p / maxv)
fh = int(140 * f / maxv)
parts.append(f'<rect x="{bx}" y="{170 - ph}" width="{barw}" height="{ph}" fill="#2f7d55"/><rect x="{bx}" y="{170 - ph - fh}" width="{barw}" height="{fh}" fill="#b33a3a"/><text x="{bx + barw/2}" y="190" text-anchor="middle" class="t">{html.escape(name)}</text><text x="{bx + barw/2}" y="{160 - ph - fh}" text-anchor="middle" class="t">P{p}/F{f}</text>')
parts.append("</svg>")
(out / "figures" / "figure_pass_fail.svg").write_text("\n".join(parts), encoding="utf-8")
def render_html(job_id: str, job_root: Path, summaries: dict[str, Any], neg: dict[str, Any]) -> str:
cards = []
for conv, data in summaries.items():
cards.append(f"<div class='metric'><b>{data.get('passed')}/{data.get('case_count')}</b><span>{html.escape(conv)} passed</span></div>")
if neg:
cards.append(f"<div class='metric'><b>{neg.get('blocked')}/{neg.get('case_count')}</b><span>negative controls blocked</span></div>")
rows = "".join(f"<tr><td>{html.escape(conv)}</td><td>{d.get('case_count')}</td><td>{d.get('passed')}</td><td>{d.get('failed')}</td></tr>" for conv, d in summaries.items())
negrows = "".join(f"<tr><td>{html.escape(str(r.get('scenario','')))}</td><td>{r.get('blocked')}</td><td>{r.get('published')}</td><td>{html.escape(str(r.get('error',''))[:240])}</td></tr>" for r in (neg.get("rows") or []))
return f"""<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>拓展分析阶段发表级完整实验结果</title><style>body{{font:14px/1.65 Arial,'Microsoft YaHei',sans-serif;color:#18212f;margin:0}}main{{max-width:1160px;margin:auto;padding:28px}}h1{{font-size:28px}}h2{{border-left:5px solid #2666a8;padding-left:10px}}table{{border-collapse:collapse;width:100%;margin:12px 0}}td,th{{border:1px solid #d7dde7;padding:8px;vertical-align:top}}th{{background:#edf2f8}}.grid{{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}}.metric{{border:1px solid #d7dde7;background:#f7f9fc;padding:14px;border-radius:8px}}.metric b{{display:block;font-size:24px;color:#2666a8}}.note{{background:#e9f2fb;border-left:4px solid #2666a8;padding:10px}}</style></head><body><main><h1>拓展分析阶段:发表级完整实验结果</h1><p>数据源:<code>{html.escape(str(job_root))}</code>;Job:<code>{html.escape(job_id)}</code>。本报告只汇总真实执行或真实失败的结果。</p><div class="grid">{''.join(cards)}</div><h2>实验定位</h2><div class="note">确定性 archetype 链路用于证明拓展阶段构建、验证和发布闭环;DeepSeek/API Agent 作为正式对照实验,按真实成功率和失败类型报告。</div><h2>总体结果</h2><table><tr><th>Converter</th><th>Cases</th><th>Passed</th><th>Failed</th></tr>{rows}</table><h2>图 1:通过/失败计数</h2><img src="figures/figure_pass_fail.svg" style="max-width:100%"><h2>负例拦截</h2><table><tr><th>Scenario</th><th>Blocked</th><th>Published</th><th>Error</th></tr>{negrows}</table><h2>可复现文件</h2><p>原始代码与数据位于 <code>packages/</code>,运行记录位于 <code>results/</code>,日志位于 <code>logs/</code>,完整文件清单位于 <code>MANIFEST.json</code>。</p></main></body></html>"""
def case_from_display(text: str) -> PubCase:
for c in cases(DEFAULT_JOB_ROOT):
if c.archetype_id in text:
return c
raise ValueError(f"cannot infer case from display_name: {text}")
def analysis_type(a: str) -> str:
return {"sequence_extraction": "sequence_extraction", "hmmer_domain": "hmmer_search", "motif_meme": "generic_cli", "phylogeny_tree": "generic_cli", "reference_classification": "table_filter", "synteny_collinearity": "blast_search", "kaks_pairwise": "generic_cli", "pep_properties": "enrichment_or_stats"}[a]
def out_type(name: str) -> str:
s = name.lower()
if s.endswith((".fa", ".fasta")):
return "fasta"
if s.endswith(".xml"):
return "xml"
if s.endswith((".tsv", ".csv")):
return "table"
if s.endswith((".nwk", ".txt", ".domtblout")):
return "text"
return "file"
def case_readme(case: PubCase, copied: list[dict[str, Any]]) -> str:
return f"# {case.archetype_id}\n\nRepresentative stage: `{case.stage}`\n\nPurpose: {case.description}\n\nSource files:\n" + "".join(f"- `{x['source']}` -> `raw_test_data/{x['dest']}`\n" for x in copied)
def upload_files(root: Path) -> list[tuple[str, bytes]]:
return [(p.name, p.read_bytes()) for p in sorted(root.iterdir()) if p.is_file()]
def expected_ok(root: Path, case: PubCase) -> bool:
return all((root / "expected_outputs" / x).is_file() and (root / "expected_outputs" / x).stat().st_size > 0 for x in case.required_outputs)
def row_success(r: dict[str, Any]) -> bool:
return all(bool(r.get(k)) for k in ("raw_code_ok", "expected_output_ok", "build_success", "trial_success", "confirm_success", "validate_success", "publish_success")) and r.get("golden_status") == "passed" and r.get("template_gate_status") == "passed"
def clean_dir(p: Path) -> None:
if p.exists():
shutil.rmtree(p)
p.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 {p.relative_to(root).as_posix() for p in root.rglob("*") if p.is_file()}
def write_json(p: Path, data: Any) -> None:
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def write_csv(p: Path, rows: list[dict[str, Any]]) -> None:
p.parent.mkdir(parents=True, exist_ok=True)
fields = sorted({k for r in rows for k in r.keys()}) if rows else ["empty"]
with p.open("w", encoding="utf-8", newline="") as h:
w = csv.DictWriter(h, fieldnames=fields)
w.writeheader()
for r in rows:
w.writerow({f: r.get(f, "") for f in fields})
def summary_md(s: dict[str, Any]) -> str:
lines = ["# Extension Publication Batch", "", f"Converter: `{s.get('converter')}`", f"Cases: {s.get('case_count')} passed={s.get('passed')} failed={s.get('failed')}", "", "| Archetype | Build | Trial | Validate | Golden | Gate | Publish | Error |", "|---|---:|---:|---:|---|---|---:|---|"]
for r in s.get("rows") or []:
err = str(r.get("error") or "")[:120].replace("|", "/")
lines.append(f"| `{r.get('archetype_id')}` | {r.get('build_success')} | {r.get('trial_success')} | {r.get('validate_success')} | `{r.get('golden_status')}` | `{r.get('template_gate_status')}` | {r.get('publish_success')} | {err} |")
return "\n".join(lines) + "\n"
def write_manifest(out: Path) -> None:
files = []
for p in sorted(x for x in out.rglob("*") if x.is_file() and x.name != "MANIFEST.json"):
files.append({"path": p.relative_to(out).as_posix(), "size": p.stat().st_size, "sha256": sha256(p)})
write_json(out / "MANIFEST.json", {"schema_version": "extension_publication_experiment_manifest_v1", "created_at": now(), "root": str(out), "file_count": len(files), "files": files})
def sha256(p: Path) -> str:
h = hashlib.sha256()
with p.open("rb") as f:
for b in iter(lambda: f.read(1024 * 1024), b""):
h.update(b)
return h.hexdigest()
def now() -> str:
return datetime.now().isoformat(timespec="seconds")
if __name__ == "__main__":
raise SystemExit(main())