#!/usr/bin/env python3 """Prepare and optionally run tri-chain extension-stage evidence experiments. This script strengthens the extension-stage template evidence base with cases selected from three bbav5 chains: 1. subfamily/reference classification 2. gene structure and promoter/cis-element post-processing 3. RNA-seq table post-processing with bounded input subsets RNA-seq cases use bounded table subsets and are promoted only as lightweight post-processing stages. A complete RNA-seq workflow remains outside the extension-stage boundary. """ 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] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) DEFAULT_EVIDENCE_JOB = PROJECT_ROOT / "workspace" / "job_1781949576460_93f63d" DEFAULT_VALIDATION_JOB = PROJECT_ROOT / "workspace" / "job_1780581757475_be7fd4" DEFAULT_PROMOTER_JOB = PROJECT_ROOT / "workspace" / "job_1782045849720_9a6952" DEFAULT_RNASEQ_JOB = PROJECT_ROOT / "workspace" / "job_1780313949486_3f3732" DEFAULT_OUTPUT_ROOT = PROJECT_ROOT / "项目记录" / "拓展分析阶段_三链路模板证据实验_20260622" @dataclass(frozen=True) class SourceFile: rel_path: str dest_name: str row_limit: int = 0 required: bool = True @dataclass(frozen=True) class TriCase: index: int case_id: str group: str archetype_id: str stage: str split: str job_root: Path script_name: str script_content: str source_files: tuple[SourceFile, ...] required_outputs: tuple[str, ...] description: str candidate_only: bool = False validation_gap: str = "" @property def dir_name(self) -> str: suffix = "candidate" if self.candidate_only else self.split return f"{self.index:02d}_{self.case_id}_{suffix}" def _sh(script: str) -> str: return "#!/usr/bin/env bash\nset -euo pipefail\npython3 - <<'PY'\n" + script.strip() + "\nPY\n" SCRIPT_REFCLS = _sh( """ from pathlib import Path for src, dst in [ ('assignment.tsv', 'output.assignment.tsv'), ('subfamily.tsv', 'output.subfamily.tsv'), ('confidence.tsv', 'output.confidence.tsv'), ('tree.nwk', 'output.tree.nwk'), ]: p = Path(src) if p.exists(): Path(dst).write_text(p.read_text(encoding='utf-8', errors='ignore'), encoding='utf-8') rows = [line for line in Path('assignment.tsv').read_text(encoding='utf-8', errors='ignore').splitlines() if line.strip()] subfamilies = set() for line in Path('subfamily.tsv').read_text(encoding='utf-8', errors='ignore').splitlines()[1:]: parts = line.split('\\t') if len(parts) > 1 and parts[1]: subfamilies.add(parts[1]) Path('reference_classification_summary.tsv').write_text( 'metric\\tvalue\\nassignment_rows\\t{}\\nsubfamily_count\\t{}\\n'.format(max(len(rows) - 1, 0), len(subfamilies)), encoding='utf-8', ) """ ) SCRIPT_STRUCTURE = _sh( """ from pathlib import Path gff = Path('structure.filtered.gff3').read_text(encoding='utf-8', errors='ignore') table = Path('structure.tsv').read_text(encoding='utf-8', errors='ignore') Path('output.structure.filtered.gff3').write_text(gff, encoding='utf-8') Path('output.structure.tsv').write_text(table, encoding='utf-8') features = 0 genes = set() for line in gff.splitlines(): if not line or line.startswith('#'): continue features += 1 attrs = line.split('\\t')[-1] if '\\t' in line else '' for token in attrs.replace(';', '\\t').split('\\t'): if token.startswith('ID=') or token.startswith('Parent='): genes.add(token.split('=', 1)[1].split('.')[0]) Path('structure_summary.tsv').write_text( 'metric\\tvalue\\ngff_feature_rows\\t{}\\ngene_like_ids\\t{}\\nstructure_table_rows\\t{}\\n'.format( features, len(genes), max(len([x for x in table.splitlines() if x.strip()]) - 1, 0) ), encoding='utf-8', ) """ ) SCRIPT_PROMOTER = _sh( """ from pathlib import Path rows = [line for line in Path('plantcare_filtered.tab').read_text(encoding='utf-8', errors='ignore').splitlines() if line.strip()] Path('output.cis_elements.tsv').write_text('\\n'.join(rows) + '\\n', encoding='utf-8') counts = Path('element_counts.txt') if counts.exists(): Path('output.element_counts.tsv').write_text(counts.read_text(encoding='utf-8', errors='ignore'), encoding='utf-8') else: seen = {} for line in rows[1:]: parts = line.split('\\t') if len(parts) > 1: seen[parts[1]] = seen.get(parts[1], 0) + 1 Path('output.element_counts.tsv').write_text('element\\tcount\\n' + ''.join(f'{k}\\t{v}\\n' for k, v in sorted(seen.items())), encoding='utf-8') bed = Path('PlantCARE.bed') if bed.exists(): Path('output.promoter.bed').write_text(bed.read_text(encoding='utf-8', errors='ignore'), encoding='utf-8') Path('output.promoter_summary.tsv').write_text( 'metric\\tvalue\\ncis_element_rows\\t{}\\nelement_count_rows\\t{}\\nbed_rows\\t{}\\n'.format( max(len(rows) - 1, 0), len([x for x in Path('output.element_counts.tsv').read_text(encoding='utf-8', errors='ignore').splitlines() if x.strip()]), len([x for x in bed.read_text(encoding='utf-8', errors='ignore').splitlines() if x.strip()]) if bed.exists() else 0, ), encoding='utf-8', ) """ ) SCRIPT_RNASEQ_QUANT = _sh( """ from pathlib import Path for src, dst in [('count_matrix.txt', 'output.count_matrix.tsv'), ('tpm_matrix.txt', 'output.tpm_matrix.tsv')]: Path(dst).write_text(Path(src).read_text(encoding='utf-8', errors='ignore'), encoding='utf-8') counts = Path('count_matrix.txt').read_text(encoding='utf-8', errors='ignore').splitlines() tpm = Path('tpm_matrix.txt').read_text(encoding='utf-8', errors='ignore').splitlines() Path('quant_summary.tsv').write_text( 'metric\\tvalue\\ncount_matrix_rows\\t{}\\ntpm_matrix_rows\\t{}\\n'.format(max(len(counts) - 1, 0), max(len(tpm) - 1, 0)), encoding='utf-8', ) """ ) SCRIPT_RNASEQ_DE = _sh( """ from pathlib import Path text = Path('de_results.txt').read_text(encoding='utf-8', errors='ignore') Path('output.de_results.tsv').write_text(text, encoding='utf-8') lines = [line for line in text.splitlines() if line.strip()] Path('de_summary.tsv').write_text('metric\\tvalue\\nde_result_rows\\t{}\\n'.format(max(len(lines) - 1, 0)), encoding='utf-8') Path('top_de_preview.tsv').write_text('\\n'.join(lines[:51]) + ('\\n' if lines else ''), encoding='utf-8') """ ) def tri_cases(evidence_job: Path, validation_job: Path, promoter_job: Path, rnaseq_job: Path) -> tuple[TriCase, ...]: return ( TriCase( 1, "reference_classification_hdzip_prediction", "subfamily_reference", "reference_classification", "reference_classification", "evidence", evidence_job, "run_reference_classification_tables.sh", SCRIPT_REFCLS, ( SourceFile("final/Target.HDZIP.target-Si.ref-At.subfamily_prediction.reference_classification.assignment.tsv", "assignment.tsv"), SourceFile("final/Target.HDZIP.target-Si.ref-At.subfamily_prediction.reference_classification.subfamily.tsv", "subfamily.tsv"), SourceFile("final/Target.HDZIP.target-Si.ref-At.subfamily_prediction.reference_classification.confidence.tsv", "confidence.tsv"), SourceFile("final/Target.HDZIP.target-Si.ref-At.subfamily_prediction.reference_classification.tree.nwk", "tree.nwk"), ), ("output.assignment.tsv", "output.subfamily.tsv", "reference_classification_summary.tsv"), "Use Si HDZIP target plus At reference subfamily prediction outputs from job_1781949576460_93f63d.", ), TriCase( 2, "reference_classification_hdzip_target_plus_reference", "subfamily_reference", "reference_classification", "reference_classification", "validation", evidence_job, "run_reference_classification_tables.sh", SCRIPT_REFCLS, ( SourceFile("final/Si.HDZIP.target_plus_reference.reference_classification.assignment.tsv", "assignment.tsv"), SourceFile("final/Si.HDZIP.target_plus_reference.reference_classification.subfamily.tsv", "subfamily.tsv"), SourceFile("final/Si.HDZIP.target_plus_reference.reference_classification.confidence.tsv", "confidence.tsv"), SourceFile("final/Si.HDZIP.target_plus_reference.reference_classification.tree.nwk", "tree.nwk", required=False), ), ("output.assignment.tsv", "output.subfamily.tsv", "reference_classification_summary.tsv"), "Use Si HDZIP target_plus_reference classification tables from the same completed job as an alternate split.", ), TriCase( 3, "gene_structure_hdzip", "gene_family_structure_promoter", "gene_structure_gff", "structure", "evidence", evidence_job, "run_gene_structure_gff.sh", SCRIPT_STRUCTURE, ( SourceFile("structure/analysis/Target.HDZIP.target-Si.ref-At.subfamily_evidence.structure.filtered.gff3", "structure.filtered.gff3"), SourceFile("structure/analysis/Target.HDZIP.target-Si.ref-At.subfamily_evidence.structure.tsv", "structure.tsv"), ), ("output.structure.filtered.gff3", "output.structure.tsv", "structure_summary.tsv"), "Use Si HDZIP subfamily-evidence structure GFF/table from job_1781949576460_93f63d.", ), TriCase( 4, "gene_structure_ls", "gene_family_structure_promoter", "gene_structure_gff", "structure", "validation", validation_job, "run_gene_structure_gff.sh", SCRIPT_STRUCTURE, ( SourceFile("structure/analysis/Ls.SPL.structure.filtered.gff3", "structure.filtered.gff3"), SourceFile("structure/analysis/Ls.SPL.structure.tsv", "structure.tsv"), ), ("output.structure.filtered.gff3", "output.structure.tsv", "structure_summary.tsv"), "Use Ls gene structure GFF/table as a held-out validation split.", ), TriCase( 5, "promoter_spl", "gene_family_structure_promoter", "promoter_cis_element", "promoter", "evidence", promoter_job, "run_promoter_cis_element.sh", SCRIPT_PROMOTER, ( SourceFile("promoter/analysis/plantCARE_output_filtered.tab", "plantcare_filtered.tab"), SourceFile("promoter/analysis/element_counts.txt", "element_counts.txt"), SourceFile("promoter/analysis/PlantCARE.bed", "PlantCARE.bed"), ), ("output.cis_elements.tsv", "output.element_counts.tsv", "output.promoter_summary.tsv"), "Use existing SPL promoter PlantCARE/BED/count artifacts because job_1781949576460_93f63d has no promoter stage outputs.", ), TriCase( 6, "rnaseq_quant_subset", "rnaseq_table_extensions", "rnaseq_quant_matrix", "rnaseq_quant", "subset", rnaseq_job, "run_rnaseq_quant_subset.sh", SCRIPT_RNASEQ_QUANT, ( SourceFile("final/Os.count_matrix.txt", "count_matrix.txt", row_limit=200), SourceFile("final/Os.tpm_matrix.txt", "tpm_matrix.txt", row_limit=200), ), ("output.count_matrix.tsv", "output.tpm_matrix.tsv", "quant_summary.tsv"), "Bounded RNA-seq quantification matrix subset for publishable table post-processing validation.", ), TriCase( 7, "rnaseq_de_subset", "rnaseq_table_extensions", "rnaseq_de_table", "rnaseq_de", "subset", rnaseq_job, "run_rnaseq_de_subset.sh", SCRIPT_RNASEQ_DE, (SourceFile("final/Os.rnaseq_de.H1_vs_Con.DE_results.txt", "de_results.txt", row_limit=200),), ("output.de_results.tsv", "de_summary.tsv", "top_de_preview.tsv"), "Bounded RNA-seq DE result table subset for publishable table post-processing validation.", ), ) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--prepare", action="store_true", help="Create raw_code/raw_test_data/expected_outputs packages.") parser.add_argument("--run", action="store_true", help="Run publish/validate loop for non-candidate cases.") parser.add_argument("--report", action="store_true", help="Write metrics and HTML/Markdown reports.") parser.add_argument("--all", action="store_true", help="Run prepare, non-candidate extension loop, and report.") parser.add_argument("--output-root", type=Path, default=DEFAULT_OUTPUT_ROOT) parser.add_argument("--evidence-job", type=Path, default=DEFAULT_EVIDENCE_JOB) parser.add_argument("--validation-job", type=Path, default=DEFAULT_VALIDATION_JOB) parser.add_argument("--promoter-job", type=Path, default=DEFAULT_PROMOTER_JOB) parser.add_argument("--rnaseq-job", type=Path, default=DEFAULT_RNASEQ_JOB) parser.add_argument("--converter", default="archetype_spec", choices=["archetype_spec", "deepseek_agent", "deepseek_api", "local_rule", "rule"]) parser.add_argument("--runtime-env", default="test4py311") parser.add_argument("--include-candidates", action="store_true", help="Also send candidate-only cases to the extension loop.") parser.add_argument("--stop-on-failure", action="store_true") args = parser.parse_args() if not any((args.prepare, args.run, args.report, args.all)): args.prepare = True args.report = True output_root = args.output_root.resolve() output_root.mkdir(parents=True, exist_ok=True) cases = tri_cases(args.evidence_job.resolve(), args.validation_job.resolve(), args.promoter_job.resolve(), args.rnaseq_job.resolve()) if args.prepare or args.all: prepare(cases, output_root) if args.run or args.all: run_batch(cases, output_root, args.converter, args.runtime_env, args.include_candidates, args.stop_on_failure) if args.report or args.all: write_report(output_root, cases) write_manifest(output_root) return 0 def prepare(cases: tuple[TriCase, ...], output_root: Path) -> None: packages = output_root / "packages" logs = output_root / "logs" packages.mkdir(parents=True, exist_ok=True) logs.mkdir(parents=True, exist_ok=True) rows = [] for case in cases: root = packages / case.dir_name raw_code = root / "raw_code" raw_data = root / "raw_test_data" expected = root / "expected_outputs" for path in (raw_code, raw_data, expected, root / "_raw_run_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)) copied = copy_sources(case, raw_data) raw = run_raw(root, case, logs / f"{case.dir_name}.raw.log") expected_ok = raw["success"] and all((expected / name).is_file() and (expected / name).stat().st_size > 0 for name in case.required_outputs) record = { "case_id": case.case_id, "group": case.group, "archetype_id": case.archetype_id, "stage": case.stage, "split": case.split, "candidate_only": case.candidate_only, "validation_gap": case.validation_gap, "case_dir": str(root), "source_job": str(case.job_root), "source_files": copied, "raw_code_ok": raw["success"], "expected_output_ok": expected_ok, "required_outputs": list(case.required_outputs), "raw_run": raw, } rows.append(record) write_json(root / "prepare_record.json", record) (root / "README.md").write_text(case_readme(case, copied), encoding="utf-8") write_json(output_root / "prepare_manifest.json", { "schema_version": "extension_tri_chain_prepare_v1", "created_at": now(), "case_count": len(rows), "prepared": sum(1 for row in rows if row["expected_output_ok"]), "candidate_only": sum(1 for row in rows if row["candidate_only"]), "cases": rows, }) def copy_sources(case: TriCase, raw_data: Path) -> list[dict[str, Any]]: copied = [] for source in case.source_files: src = case.job_root / source.rel_path if not src.is_file(): if source.required: raise FileNotFoundError(f"missing source for {case.case_id}: {src}") copied.append({ "source": str(src), "dest": source.dest_name, "source_size": 0, "dest_size": 0, "sha256": "", "row_limit": source.row_limit, "copied_rows": 0, "required": False, "missing_optional": True, }) continue dest = raw_data / source.dest_name dest.parent.mkdir(parents=True, exist_ok=True) if source.row_limit and _looks_tabular(src): copied_rows = copy_tabular_subset(src, dest, source.row_limit) else: shutil.copy2(src, dest) copied_rows = 0 copied.append({ "source": str(src), "dest": source.dest_name, "source_size": src.stat().st_size, "dest_size": dest.stat().st_size, "sha256": sha256(dest), "row_limit": source.row_limit, "copied_rows": copied_rows, "required": source.required, }) return copied def copy_tabular_subset(src: Path, dest: Path, row_limit: int) -> int: rows = [] with src.open("r", encoding="utf-8", errors="ignore") as handle: for index, line in enumerate(handle): if index == 0 or index <= row_limit: rows.append(line) else: break dest.write_text("".join(rows), encoding="utf-8") return max(len(rows) - 1, 0) def run_raw(root: Path, case: TriCase, log_path: Path) -> dict[str, Any]: work = root / "_raw_run_work" expected = 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(cases: tuple[TriCase, ...], output_root: Path, converter: str, runtime_env: str, include_candidates: bool, 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 runnable = tuple(case for case in cases if include_candidates or not case.candidate_only) if converter == "archetype_spec": import gfa_core.extension_stage.agent as agent_module def _convert_with_archetype_spec(**kwargs: Any) -> dict[str, Any]: return local_conversion_spec(runnable, 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 run_root = output_root / "runtime" / converter for path in (run_root / "extension_stages", run_root / "workspace", run_root / "input", output_root / "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 runnable: row = run_one(agent, output_root, case, converter, runtime_env) rows.append(row) if stop_on_failure and not row_success(row): break summary = { "schema_version": "extension_tri_chain_batch_result_v1", "created_at": now(), "converter": converter, "runtime_env": runtime_env, "include_candidates": include_candidates, "case_count": len(rows), "passed": sum(1 for row in rows if row_success(row)), "failed": sum(1 for row in rows if not row_success(row)), "rows": rows, } result_dir = output_root / "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, output_root: Path, case: TriCase, converter: str, runtime_env: str) -> dict[str, Any]: case_root = output_root / "packages" / case.dir_name result_dir = output_root / "results" / converter / "per_case" / case.dir_name clean_dir(result_dir) row = { "case_id": case.case_id, "group": case.group, "archetype_id": case.archetype_id, "stage": case.stage, "split": case.split, "converter": converter, "candidate_only": case.candidate_only, "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, "error": "", } try: build = agent.create_from_uploads( display_name=f"tri_chain_{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(result_dir / "build_response.json", build) extension = build.get("extension") or {} extension_id = str(extension.get("extension_id") or "") row.update({"build_success": bool(build.get("success") and extension_id), "extension_id": extension_id}) trial = agent.trial_run(extension_id) write_json(result_dir / "trial_run.json", trial) 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(extension_id, outputs=outputs, nl_triggers=[case.case_id, case.stage, case.archetype_id]) write_json(result_dir / "confirm_response.json", confirm) row["confirm_success"] = bool(confirm.get("success")) validation = agent.validate(extension_id) write_json(result_dir / "validate_response.json", validation) run = validation.get("run") or {} row["validate_success"] = bool(validation.get("success")) row["golden_status"] = str((run.get("golden_comparison") or {}).get("status") or "") row["template_gate_status"] = str((run.get("template_gate") or {}).get("status") or "") publish = agent.publish(extension_id) write_json(result_dir / "publish_response.json", publish) row["publish_success"] = bool(publish.get("success")) except Exception as exc: row["error"] = str(exc) write_json(result_dir / "error.json", {"error": str(exc), "traceback": traceback.format_exc(), "row": row}) write_json(result_dir / "result_row.json", row) return row def extension_hint(case: TriCase) -> dict[str, Any]: if case.archetype_id: from gfa_core.extension_stage.template_cases import ARCHETYPE_BY_ID archetype = ARCHETYPE_BY_ID[case.archetype_id] input_roles = {key: dict(value) for key, value in archetype.input_roles.items()} output_contract = [dict(item) for item in archetype.output_contract] comparator_plan = dict(archetype.comparator_plan) analysis_type = archetype.analysis_type tool_family = archetype.tool_family else: input_roles = {"input_file": {"role": "generic_input_file", "required": True}} output_contract = [ {"name_pattern": name, "type": out_type(name), "primary": index == 0, "required": True, "allow_empty": False, "semantic_role": "rnaseq_table_candidate", "compare_mode": "table", "required_for_golden": True} for index, name in enumerate(case.required_outputs) ] comparator_plan = {"mode": "pipeline_golden_diff", "default_compare": "structured", "allowed_differences": []} analysis_type = "table_filter" tool_family = "python" return { "schema_version": "bbav5_tri_chain_extension_hint_v1", "case_id": case.case_id, "archetype_id": case.archetype_id, "group": case.group, "representative_stage": case.stage, "recommended_original_command": f"bash {case.script_name}", "analysis_type": analysis_type, "tool_family": tool_family, "input_roles": input_roles, "output_contract": output_contract, "comparator_plan": comparator_plan, "requires_user_confirmation": bool(case.candidate_only), "candidate_only": case.candidate_only, "validation_gap": case.validation_gap, } def local_conversion_spec(cases: tuple[TriCase, ...], display_name: str, runtime_env: str) -> dict[str, Any]: case = case_from_display(cases, display_name) hint = extension_hint(case) return { "template_type": "tri_chain_archetype_spec", "archetype_id": case.archetype_id, "original_command": f"bash {case.script_name}", "command_template": f"bash {{code_dir}}/{case.script_name} --input-dir {{input_dir}} --output-dir {{output_dir}}", "semantic_command_template": "", "wrapper_command_template": f"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": {key: {"required": value.get("required", True)} for key, value 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": bool(case.candidate_only), "notes": "Tri-chain deterministic archetype spec; RNA-seq cases are bounded table post-processing subsets, not full RNA-seq workflows.", "converter": {"type": "archetype_spec", "mode": "tri_chain_reproducible", "runtime_env": runtime_env}, } def write_report(output_root: Path, cases: tuple[TriCase, ...]) -> None: prepare_manifest = read_json(output_root / "prepare_manifest.json") summaries = {} for converter in ("archetype_spec", "deepseek_agent", "deepseek_api", "local_rule", "rule"): path = output_root / "results" / converter / "summary.json" if path.exists(): summaries[converter] = read_json(path) metrics = { "schema_version": "extension_tri_chain_metrics_v1", "created_at": now(), "case_count": len(cases), "prepared": prepare_manifest.get("prepared", 0), "candidate_only": sum(1 for case in cases if case.candidate_only), "template_cases": sum(1 for case in cases if case.archetype_id), "groups": group_metrics(cases, prepare_manifest, summaries), "summaries": summaries, "validation_gaps": [case.validation_gap for case in cases if case.validation_gap], } write_json(output_root / "tri_chain_metrics.json", metrics) write_csv(output_root / "tri_chain_case_table.csv", case_rows(cases, prepare_manifest)) (output_root / "tri_chain_report.md").write_text(report_markdown(metrics), encoding="utf-8") (output_root / "tri_chain_report.html").write_text(report_html(metrics), encoding="utf-8") def group_metrics(cases: tuple[TriCase, ...], prepare_manifest: dict[str, Any], summaries: dict[str, Any]) -> list[dict[str, Any]]: rows = [] prepared_by_case = {row.get("case_id"): row for row in prepare_manifest.get("cases") or []} for group in sorted({case.group for case in cases}): group_cases = [case for case in cases if case.group == group] rows.append({ "group": group, "case_count": len(group_cases), "prepared": sum(1 for case in group_cases if (prepared_by_case.get(case.case_id) or {}).get("expected_output_ok")), "candidate_only": sum(1 for case in group_cases if case.candidate_only), "template_archetypes": sorted({case.archetype_id for case in group_cases if case.archetype_id}), "run_passed": { name: sum(1 for row in summary.get("rows") or [] if row.get("group") == group and row_success(row)) for name, summary in summaries.items() }, }) return rows def case_rows(cases: tuple[TriCase, ...], prepare_manifest: dict[str, Any]) -> list[dict[str, Any]]: prepared = {row.get("case_id"): row for row in prepare_manifest.get("cases") or []} rows = [] for case in cases: prep = prepared.get(case.case_id) or {} rows.append({ "case_id": case.case_id, "group": case.group, "archetype_id": case.archetype_id, "stage": case.stage, "split": case.split, "candidate_only": case.candidate_only, "source_job": str(case.job_root), "expected_output_ok": prep.get("expected_output_ok", False), "validation_gap": case.validation_gap, }) return rows def report_markdown(metrics: dict[str, Any]) -> str: lines = [ "# 拓展分析阶段三链路模板证据实验", "", f"- Cases: {metrics.get('case_count')}", f"- Prepared: {metrics.get('prepared')}", f"- Template cases: {metrics.get('template_cases')}", f"- Candidate-only cases: {metrics.get('candidate_only')}", "", "## Coverage", "", "| Group | Cases | Prepared | Candidate-only | Template archetypes |", "|---|---:|---:|---:|---|", ] for row in metrics.get("groups") or []: lines.append(f"| `{row.get('group')}` | {row.get('case_count')} | {row.get('prepared')} | {row.get('candidate_only')} | {', '.join(row.get('template_archetypes') or [])} |") lines.extend(["", "## Validation Gaps", ""]) for gap in metrics.get("validation_gaps") or []: lines.append(f"- {gap}") if not metrics.get("validation_gaps"): lines.append("- None recorded.") lines.append("") return "\n".join(lines) def report_html(metrics: dict[str, Any]) -> str: rows = "".join( "{}{}{}{}{}".format( html.escape(str(row.get("group"))), row.get("case_count"), row.get("prepared"), row.get("candidate_only"), html.escape(", ".join(row.get("template_archetypes") or [])), ) for row in metrics.get("groups") or [] ) gaps = "".join(f"
  • {html.escape(str(gap))}
  • " for gap in metrics.get("validation_gaps") or []) return f"""拓展分析阶段三链路模板证据实验

    拓展分析阶段三链路模板证据实验

    {metrics.get('prepared')}prepared
    {metrics.get('template_cases')}template cases
    {metrics.get('candidate_only')}candidate-only cases

    Coverage

    {rows}
    GroupCasesPreparedCandidate-onlyTemplate archetypes

    Validation Gaps

    """ def case_from_display(cases: tuple[TriCase, ...], text: str) -> TriCase: for case in cases: if case.case_id in text or (case.archetype_id and case.archetype_id in text): return case raise ValueError(f"cannot infer tri-chain case from display_name: {text}") 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: TriCase) -> 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 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 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) -> dict[str, Any]: if not path.exists(): return {} 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) fields = sorted({key for row in rows for key in row}) if rows else ["empty"] with path.open("w", encoding="utf-8", newline="") as handle: writer = csv.DictWriter(handle, fieldnames=fields) writer.writeheader() for row in rows: writer.writerow({field: row.get(field, "") for field in fields}) def summary_markdown(summary: dict[str, Any]) -> str: lines = [ "# Tri-chain Extension Batch", "", f"Converter: `{summary.get('converter')}`", f"Cases: {summary.get('case_count')} passed={summary.get('passed')} failed={summary.get('failed')}", "", "| Case | Build | Trial | Validate | Golden | Gate | Publish | Error |", "|---|---:|---:|---:|---|---|---:|---|", ] for row in summary.get("rows") or []: error = str(row.get("error") or "")[:120].replace("|", "/") lines.append(f"| `{row.get('case_id')}` | {row.get('build_success')} | {row.get('trial_success')} | {row.get('validate_success')} | `{row.get('golden_status')}` | `{row.get('template_gate_status')}` | {row.get('publish_success')} | {error} |") return "\n".join(lines) + "\n" def case_readme(case: TriCase, copied: list[dict[str, Any]]) -> str: lines = [ f"# {case.case_id}", "", f"- Group: `{case.group}`", f"- Stage: `{case.stage}`", f"- Archetype: `{case.archetype_id or 'candidate_only'}`", f"- Split: `{case.split}`", f"- Candidate only: `{case.candidate_only}`", "", case.description, "", "## Source Files", "", ] for item in copied: rows = f", subset rows={item['copied_rows']}" if item.get("row_limit") else "" lines.append(f"- `{item['source']}` -> `raw_test_data/{item['dest']}` ({item['dest_size']} bytes{rows})") if case.validation_gap: lines.extend(["", "## Validation Gap", "", case.validation_gap]) lines.append("") return "\n".join(lines) def _looks_tabular(path: Path) -> bool: return path.suffix.lower() in {".txt", ".tsv", ".csv", ".tab"} def out_type(name: str) -> str: lower = name.lower() if lower.endswith((".tsv", ".csv", ".txt", ".tab")): return "table" if lower.endswith((".gff", ".gff3", ".bed")): return "annotation" if lower.endswith(".nwk"): return "tree" return "file" def write_manifest(output_root: Path) -> None: files = [] for path in sorted(item for item in output_root.rglob("*") if item.is_file() and item.name != "MANIFEST.json"): files.append({"path": path.relative_to(output_root).as_posix(), "size": path.stat().st_size, "sha256": sha256(path)}) write_json(output_root / "MANIFEST.json", { "schema_version": "extension_tri_chain_experiment_manifest_v1", "created_at": now(), "root": str(output_root), "file_count": len(files), "files": files, }) def sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def now() -> str: return datetime.now().isoformat(timespec="seconds") if __name__ == "__main__": raise SystemExit(main())