{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"gpu","dataSources":[{"sourceType":"competition","sourceId":118765,"databundleVersionId":15231210},{"sourceType":"datasetVersion","sourceId":14604295,"datasetId":9328538,"databundleVersionId":15440074},{"sourceType":"datasetVersion","sourceId":14787388,"datasetId":9453383,"databundleVersionId":15641141},{"sourceType":"datasetVersion","sourceId":14786962,"datasetId":9447634,"databundleVersionId":15640661},{"sourceType":"datasetVersion","sourceId":14893119,"datasetId":9529097,"databundleVersionId":15757178},{"sourceType":"datasetVersion","sourceId":11118830,"datasetId":6933267,"databundleVersionId":11511771},{"sourceType":"datasetVersion","sourceId":14874339,"datasetId":9502242,"databundleVersionId":15736806},{"sourceType":"datasetVersion","sourceId":14519720,"datasetId":9271415,"databundleVersionId":15347344},{"sourceType":"datasetVersion","sourceId":11988454,"datasetId":7540404,"databundleVersionId":12503961},{"sourceType":"datasetVersion","sourceId":11908669,"datasetId":7405022,"databundleVersionId":12414900},{"sourceType":"datasetVersion","sourceId":10855324,"datasetId":6742586,"databundleVersionId":11219268},{"sourceType":"modelInstanceVersion","sourceId":311741,"databundleVersionId":11641144,"modelInstanceId":264400,"modelId":285488},{"sourceType":"kernelVersion","sourceId":290004465},{"sourceType":"kernelVersion","sourceId":297950976}],"dockerImageVersionId":31260,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"!pip install --no-index \\\n--find-links=/kaggle/input/notebooks/packagemanager/pm-107220031-at-02-16-2026-03-40-04 \\\nbiotite \\\nrdkit \\\nbiopandas \\\ntorch \\\npytorch-lightning==2.5.0 \\\ndm-tree==0.1.8 \\\nrequests==2.32.3 \\\neinops==0.8.0 \\\neinx==0.3.0 \\\nmashumaro==3.14 \\\nclick==8.1.7 \\\npyyaml==6.0.2 \\\nscipy==1.13.1 \\\nnumba==0.61.0 \\\ngemmi==0.6.5 \\\nscikit-learn==1.6.1 \\\nchembl_structure_pipeline==1.2.2","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-15T16:06:44.490149Z","iopub.execute_input":"2026-03-15T16:06:44.490762Z","iopub.status.idle":"2026-03-15T16:07:10.021595Z","shell.execute_reply.started":"2026-03-15T16:06:44.490733Z","shell.execute_reply":"2026-03-15T16:07:10.020763Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Fix Protenix checkpoint AND common files path ─────────────────────────────\nimport os\n\n# 源数据目录\nPROTENIX_DATA_DIR = \"/kaggle/input/datasets/qiweiyin/protenix-v1-adjusted/Protenix-v1-adjust-v2/Protenix-v1-adjust-v2/Protenix-v1\"\n\n# 1. 链接 checkpoint\nos.makedirs(\"/root/checkpoint\", exist_ok=True)\nos.system(\n    f'ln -sf \"{PROTENIX_DATA_DIR}/checkpoint/protenix_base_20250630_v1.0.0.pt\" '\n    '\"/root/checkpoint/protenix_base_default_v1.0.0.pt\"'\n)\nprint(\"✓ Protenix checkpoint linked:\", os.path.exists(\"/root/checkpoint/protenix_base_default_v1.0.0.pt\"))\n\n# 2. 链接 common 文件夹（这是你缺少的关键步骤！）\nos.makedirs(\"/root/common\", exist_ok=True)\n\n# 链接 CCD 文件\nfor fname in [\"components.cif\", \"components.cif.rdkit_mol.pkl\"]:\n    src = f\"{PROTENIX_DATA_DIR}/common/{fname}\"\n    dst = f\"/root/common/{fname}\"\n    if os.path.exists(src) and not os.path.exists(dst):\n        os.system(f'ln -sf \"{src}\" \"{dst}\"')\n        print(f\"✓ Linked: {fname}\")\n    elif os.path.exists(dst):\n        print(f\"✓ Already exists: {fname}\")\n    else:\n        print(f\"✗ Source not found: {src}\")\n\n# 3. 验证\nprint(\"\\n=== Verification ===\")\nprint(\"checkpoint exists:\", os.path.exists(\"/root/checkpoint/protenix_base_default_v1.0.0.pt\"))\nprint(\"components.cif exists:\", os.path.exists(\"/root/common/components.cif\"))\nprint(\"rdkit_mol.pkl exists:\", os.path.exists(\"/root/common/components.cif.rdkit_mol.pkl\"))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-15T16:07:10.023556Z","iopub.execute_input":"2026-03-15T16:07:10.023805Z","iopub.status.idle":"2026-03-15T16:07:10.063987Z","shell.execute_reply.started":"2026-03-15T16:07:10.023776Z","shell.execute_reply":"2026-03-15T16:07:10.063075Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# =========================\n# FIX USALIGN PERMISSION\n# =========================\n\nimport shutil, os\n\nUSALIGN_SRC = \"/kaggle/input/datasets/metric/usalign/USalign\"\nUSALIGN_BIN = \"/kaggle/working/USalign\"\n\nshutil.copy(USALIGN_SRC, USALIGN_BIN)\nos.chmod(USALIGN_BIN, 0o755)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-15T16:07:10.06526Z","iopub.execute_input":"2026-03-15T16:07:10.065488Z","iopub.status.idle":"2026-03-15T16:07:10.126834Z","shell.execute_reply.started":"2026-03-15T16:07:10.065467Z","shell.execute_reply":"2026-03-15T16:07:10.126237Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# Stanford RNA 3D Folding — TBM-First Hybrid Submission v2\n#\n# ★ 10个内部预测 → confidence筛选 → 5个提交\n# ★ 双模型ensemble: Protenix + Boltz\n# ★ TBM最多6个，避免过度依赖模板\n# ============================================================\n\nimport os, sys, gc, json, shutil, time, warnings, random, glob, gzip\nimport numpy as np\nimport pandas as pd\nimport torch\nwarnings.filterwarnings(\"ignore\")\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 0 — Environment Detection & Dependencies\n# ─────────────────────────────────────────────────────────────\n\nIS_SCORING_RUN = os.environ.get('KAGGLE_IS_COMPETITION_RERUN')\nIS_KAGGLE = bool(IS_SCORING_RUN)\nLOCAL_N_SAMPLES = 2\n\nprint(f\"IS_SCORING_RUN = {IS_SCORING_RUN}\")\nprint(f\"Mode: {'KAGGLE COMPETITION' if IS_KAGGLE else 'LOCAL TESTING'}\")\n\n# ── Biopython ────────────────────────────────────────────────\nos.system(\n    \"pip install --no-index \"\n    \"/kaggle/input/datasets/kami1976/biopython-cp312/\"\n    \"biopython-1.86-cp312-cp312-manylinux2014_x86_64\"\n    \".manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl 2>/dev/null\"\n)\n\n# ── Boltz ────────────────────────────────────────────────────\nos.system(\"cp -r /kaggle/input/datasets/lbugnon/boltz-src-minimal ./ 2>/dev/null\")\nos.system(\"pip install --no-index --no-build-isolation -e ./boltz-src-minimal 2>/dev/null\")\nos.system(\"mkdir -p boltz_cache\")\nos.system(\"cp -r /kaggle/input/datasets/lbugnon/boltz2 boltz_cache 2>/dev/null\")\nos.system(\"mv boltz_cache/boltz2/mols/mols/* boltz_cache/boltz2/mols/ 2>/dev/null\")\nos.system(\"rm -r boltz_cache/boltz2/mols/mols/ 2>/dev/null\")\nos.system(\"tar -cf boltz_cache/boltz2/mols.tar boltz_cache/boltz2/mols 2>/dev/null\")\n\n# ── RNAPro ───────────────────────────────────────────────────\nos.system(\"cp -r /kaggle/input/rnapro-src/RNAPro . 2>/dev/null\")\nos.system(\"cp /kaggle/input/rnapro-src/rnapro-private-best-500m.ckpt . 2>/dev/null\")\nif os.path.isdir(\"RNAPro\"):\n    os.chdir(\"RNAPro\")\n    os.system(\"pip install -e . --no-deps 2>/dev/null\")\n    os.chdir(\"..\")\n\n# ── Protenix ─────────────────────────────────────────────────\n_PROTENIX_INPUT_DIRS = [\n    \"/kaggle/input/datasets/qiweiyin/protenix-v1-adjusted/Protenix-v1-adjust-v2/Protenix-v1-adjust-v2/Protenix-v1\",\n    \"/kaggle/input/protenix-src/Protenix-v1\",\n]\nPROTENIX_CODE_DIR = None\n\nfor _ptx_src in _PROTENIX_INPUT_DIRS:\n    if os.path.isdir(_ptx_src):\n        _ptx_dst = \"/kaggle/working/protenix_src\"\n        if not os.path.isdir(_ptx_dst):\n            print(f\"Copying Protenix source from {_ptx_src} …\")\n            shutil.copytree(_ptx_src, _ptx_dst)\n        PROTENIX_CODE_DIR = _ptx_dst\n        print(f\"✓ Protenix source ready: {PROTENIX_CODE_DIR}\")\n        break\n\nif PROTENIX_CODE_DIR is None:\n    print(\"⚠  Protenix source not found — will skip Protenix predictions\")\n\nPROTENIX_ROOT_DIR = \"/kaggle/working/protenix_root\"\nos.makedirs(PROTENIX_ROOT_DIR, exist_ok=True)\nos.environ[\"PROTENIX_ROOT_DIR\"] = PROTENIX_ROOT_DIR\n\nos.makedirs(f\"{PROTENIX_ROOT_DIR}/mmcif\", exist_ok=True)\n\n_readonly_assets = \"/kaggle/input/datasets/qiweiyin/protenix-v1-adjusted/Protenix-v1-adjust-v2/Protenix-v1-adjust-v2/Protenix-v1\"\nfor folder in [\"common\", \"checkpoint\"]:\n    src = f\"{_readonly_assets}/{folder}\"\n    dst = f\"{PROTENIX_ROOT_DIR}/{folder}\"\n    if not os.path.exists(dst) and os.path.exists(src):\n        os.symlink(src, dst)\n        print(f\"  Symlinked {folder}\")\n\nDIST = \"/kaggle/working/RNAPro/release_data/ccd_cache/\"\nos.makedirs(DIST, exist_ok=True)\nfor _ccd_file in [\"components.v20240608.cif\", \"components.v20240608.cif.rdkit_mol.pkl\"]:\n    _ccd_src = f\"/kaggle/input/protenix-checkpoints/{_ccd_file}\"\n    if os.path.exists(_ccd_src):\n        os.system(f\"cp {_ccd_src} {DIST} 2>/dev/null\")\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 1 — Imports\n# ─────────────────────────────────────────────────────────────\n\nfrom pathlib import Path\nfrom tqdm.auto import tqdm\nfrom collections import Counter\nfrom Bio.Align import PairwiseAligner\n\nseed = 42\nnp.random.seed(seed)\nrandom.seed(seed)\ntorch.manual_seed(seed)\nif torch.cuda.is_available():\n    torch.cuda.manual_seed_all(seed)\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 2 — Configuration\n# ─────────────────────────────────────────────────────────────\n\n# ★ 核心配置改动\nN_SUBMIT = 5              # 最终提交5个预测\nN_INTERNAL_PREDS = 10     # 内部生成10个，筛选top 5\nMAX_TBM = 10               # TBM最多6个\n\n# 模型切换阈值\nPROTENIX_BOLTZ_THRESHOLD = 200  # ≤300用Protenix主，>300用Boltz主\nMAX_BOLTZ_LEN = 900             # Boltz最大长度\nMAX_PROTENIX_LEN = 512          # Protenix单次最大长度\n\nPROTENIX_MODEL_NAME = \"protenix_base_20250630_v1.0.0\"\nPROTENIX_SEED = 42\n\nMIN_SIMILARITY = 0.0\nMIN_PERCENT_IDENTITY = 50.0\n\nMSA_ROOT = \"/kaggle/input/stanford-rna-3d-folding-2/MSA\"\n\n# Chunking parameters\nCHUNK_OVERLAP = 128\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 3 — Data Ingestion\n# ─────────────────────────────────────────────────────────────\n\nBASE_PATH = '/kaggle/input/stanford-rna-3d-folding-2'\n\ntest_seqs_full = pd.read_csv(f'{BASE_PATH}/test_sequences.csv')\ntrain_seqs = pd.read_csv(f'{BASE_PATH}/train_sequences.csv')\nvalidation_seqs = pd.read_csv(f'{BASE_PATH}/validation_sequences.csv')\ntrain_labels = pd.read_csv(f'{BASE_PATH}/train_labels.csv', low_memory=False)\nvalidation_labels = pd.read_csv(f'{BASE_PATH}/validation_labels.csv')\n\ntest_seqs = (test_seqs_full if IS_KAGGLE else test_seqs_full.head(LOCAL_N_SAMPLES)).reset_index(drop=True)\n\nprint(f\"✓ {len(train_seqs)} training | {len(validation_seqs)} validation | {len(test_seqs)} test sequences\")\nif not IS_KAGGLE:\n    print(f\"  (LOCAL MODE: processing only {len(test_seqs)} targets)\")\n\ndef parse_fasta(fasta_content: str) -> dict:\n    out, cur, seq_parts = {}, None, []\n    for line in str(fasta_content).splitlines():\n        line = line.strip()\n        if not line:\n            continue\n        if line.startswith(\">\"):\n            if cur is not None:\n                out[cur] = \"\".join(seq_parts)\n            cur = line[1:].split()[0]\n            seq_parts = []\n        else:\n            seq_parts.append(line.replace(\" \", \"\"))\n    if cur is not None:\n        out[cur] = \"\".join(seq_parts)\n    return out\n\ndef parse_stoichiometry(stoich: str) -> list:\n    if pd.isna(stoich) or str(stoich).strip() == \"\":\n        return []\n    return [(p.split(\":\")[0].strip(), int(p.split(\":\")[1]))\n            for p in str(stoich).split(\";\")]\n\ndef get_chain_segments(row) -> list:\n    seq = row[\"sequence\"]\n    stoich = row.get(\"stoichiometry\", \"\")\n    all_seq = row.get(\"all_sequences\", \"\")\n\n    if pd.isna(stoich) or pd.isna(all_seq) or str(stoich).strip() == \"\" or str(all_seq).strip() == \"\":\n        return [(0, len(seq))]\n\n    try:\n        chain_dict = parse_fasta(all_seq)\n        order = parse_stoichiometry(stoich)\n        segs, pos = [], 0\n        for ch, cnt in order:\n            base = chain_dict.get(ch)\n            if base is None:\n                return [(0, len(seq))]\n            for _ in range(cnt):\n                segs.append((pos, pos + len(base)))\n                pos += len(base)\n        return segs if pos == len(seq) else [(0, len(seq))]\n    except Exception:\n        return [(0, len(seq))]\n\ndef build_segments_map(df: pd.DataFrame) -> dict:\n    return {r[\"target_id\"]: get_chain_segments(r) for _, r in df.iterrows()}\n\ntest_segs_map = build_segments_map(test_seqs)\ntrain_segs_map = build_segments_map(train_seqs)\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 4 — Labels → Coordinate Dictionaries\n# ─────────────────────────────────────────────────────────────\n\ndef process_labels(labels_df: pd.DataFrame) -> dict:\n    labels_df = labels_df.copy()\n    labels_df['target_id'] = labels_df['ID'].str.rsplit('_', n=1).str[0]\n    labels_df = labels_df.sort_values(['target_id', 'resid'])\n\n    coord_cols = ['x_1', 'y_1', 'z_1']\n    arr = labels_df[coord_cols].values.copy()\n    arr[arr < -1e6] = np.nan\n    labels_df[coord_cols] = arr\n\n    coords_dict = {}\n    for tid, grp in tqdm(labels_df.groupby('target_id', sort=False), desc=\"Processing structures\"):\n        coords_dict[tid] = grp[coord_cols].values\n    return coords_dict\n\nprint(\"\\nLoading training structures...\")\ntrain_coords_dict = process_labels(train_labels)\nvalid_coords_dict = process_labels(validation_labels)\n\nall_train_seqs = pd.concat([train_seqs, validation_seqs], ignore_index=True)\nall_train_coords = {**train_coords_dict, **valid_coords_dict}\n\nprint(f\"✓ Template pool (train+val): {len(all_train_coords)} structures\")\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 4.5 — External Template Library\n# ─────────────────────────────────────────────────────────────\n\ndef load_json_coords_fixed(json_file):\n    try:\n        if json_file.endswith(\".gz\"):\n            with gzip.open(json_file, \"rt\") as f:\n                data = json.load(f)\n        else:\n            with open(json_file) as f:\n                data = json.load(f)\n\n        key = list(data.keys())[0]\n        residues = data[key]\n\n        if not isinstance(residues, list) or len(residues) == 0:\n            return None, None\n\n        seq = []\n        coords = []\n\n        for r in residues:\n            if not isinstance(r, dict):\n                continue\n            \n            one_letter = r.get(\"one_letter_code\", \"\")\n            if not one_letter:\n                continue\n            seq.append(one_letter)\n\n            atoms = r.get(\"atoms\", {})\n            if \"C1'\" in atoms:\n                c1_coord = atoms[\"C1'\"]\n                if isinstance(c1_coord, list) and len(c1_coord) == 3:\n                    coords.append([float(c1_coord[0]), float(c1_coord[1]), float(c1_coord[2])])\n                else:\n                    coords.append([np.nan, np.nan, np.nan])\n            else:\n                coords.append([np.nan, np.nan, np.nan])\n\n        if len(seq) == 0:\n            return None, None\n\n        seq_str = \"\".join(seq).upper().replace(\"T\", \"U\")\n        coords_arr = np.array(coords, dtype=np.float64)\n\n        valid_mask = ~np.isnan(coords_arr[:, 0])\n        if valid_mask.sum() < max(3, 0.3 * len(seq_str)):\n            return None, None\n\n        rna_frac = sum(1 for c in seq_str if c in 'ACGU') / len(seq_str)\n        if rna_frac < 0.7:\n            return None, None\n\n        return seq_str, coords_arr\n\n    except Exception as e:\n        return None, None\n\n\nTBM_ROOT_ALTERNATIVES = [\n    \"/kaggle/input/datasets/odat1248/rnatbm-templates-20250521-re/fortbm_clustered_sampled_20250526_gt_re\",\n    \"/kaggle/input/datasets/odat1248/rnatbm2025-set/rna_tbm2025_clustered/fortbm_clustered_sampled_20250526_gt_0521\",\n    \"/kaggle/input/rnatbm-templates-20250521-re/fortbm_clustered_sampled_20250526_gt_re\",\n    \"/kaggle/input/rnatbm2025-set/rna_tbm2025_clustered/fortbm_clustered_sampled_20250526_gt_0521\",\n]\n\next_loaded = 0\nexisting_seqs = set(all_train_seqs['sequence'].values)\n\nvalid_tbm_roots = []\nfor tbm_path in TBM_ROOT_ALTERNATIVES:\n    if os.path.exists(tbm_path):\n        valid_tbm_roots.append(tbm_path)\n        print(f\"★★ Found external template root: {tbm_path}\")\n\nif valid_tbm_roots:\n    json_files = []\n    for tbm_root in valid_tbm_roots:\n        files = glob.glob(tbm_root + \"/**/*.json*\", recursive=True)\n        json_files.extend(files)\n        print(f\"  {tbm_root}: {len(files)} JSON files\")\n    \n    print(f\"  Total: {len(json_files)} JSON files\")\n    \n    new_rows = []\n    failed = 0\n    parsed_ok = 0\n    \n    for f in tqdm(json_files, desc=\"Loading external templates\", mininterval=5):\n        seq, coords = load_json_coords_fixed(f)\n        \n        if seq is None:\n            failed += 1\n            continue\n        \n        parsed_ok += 1\n        \n        if seq in existing_seqs:\n            continue\n        \n        ext_id = f\"EXT_{abs(hash(seq)) % 10**8:08d}\"\n        \n        all_train_coords[ext_id] = coords\n        new_rows.append({'target_id': ext_id, 'sequence': seq})\n        existing_seqs.add(seq)\n    \n    print(f\"  Parsed OK: {parsed_ok} | Failed: {failed} | New unique: {len(new_rows)}\")\n    \n    if new_rows:\n        ext_df = pd.DataFrame(new_rows)\n        all_train_seqs = pd.concat([all_train_seqs, ext_df], ignore_index=True)\n        ext_loaded = len(new_rows)\n        print(f\"  ✓ Added {len(new_rows)} unique external templates\")\n\nelse:\n    print(\"\\n★★ External template roots NOT found\")\n\nif ext_loaded > 0:\n    print(f\"\\n★★ Template pool after merge: {len(all_train_coords)} (+{ext_loaded} external)\")\nelse:\n    print(f\"\\n★★ Template pool: {len(all_train_coords)} structures (train+val only)\")\n\ndel existing_seqs\ngc.collect()\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 5 — TBM: Aligners and Template Search\n# ─────────────────────────────────────────────────────────────\n\ndef _make_aligner() -> PairwiseAligner:\n    al = PairwiseAligner()\n    al.mode = \"global\"\n    al.match_score = 2\n    al.mismatch_score = -1.5\n    al.open_gap_score = -8\n    al.extend_gap_score = -0.4\n    al.query_left_open_gap_score = -8\n    al.query_left_extend_gap_score = -0.4\n    al.query_right_open_gap_score = -8\n    al.query_right_extend_gap_score = -0.4\n    al.target_left_open_gap_score = -8\n    al.target_left_extend_gap_score = -0.4\n    al.target_right_open_gap_score = -8\n    al.target_right_extend_gap_score = -0.4\n    return al\n\n_aligner = _make_aligner()\n\ndef _build_aligned_strings(query_seq, template_seq, alignment):\n    q_segs, t_segs = alignment.aligned\n    aq, at, qi, ti = [], [], 0, 0\n    for (qs, qe), (ts, te) in zip(q_segs, t_segs):\n        while qi < qs: aq.append(query_seq[qi]); at.append(\"-\"); qi += 1\n        while ti < ts: aq.append(\"-\"); at.append(template_seq[ti]); ti += 1\n        for qp, tp in zip(range(qs, qe), range(ts, te)):\n            aq.append(query_seq[qp]); at.append(template_seq[tp])\n        qi, ti = qe, te\n    while qi < len(query_seq): aq.append(query_seq[qi]); at.append(\"-\"); qi += 1\n    while ti < len(template_seq): aq.append(\"-\"); at.append(template_seq[ti]); ti += 1\n    return \"\".join(aq), \"\".join(at)\n\ndef find_similar_sequences_detailed(\n    query_seq: str,\n    train_seqs_df: pd.DataFrame,\n    train_coords_dict: dict,\n    top_n: int = 30\n) -> list:\n    results = []\n\n    for _, row in train_seqs_df.iterrows():\n        tid, tseq = row[\"target_id\"], row[\"sequence\"]\n        if tid not in train_coords_dict:\n            continue\n\n        if abs(len(tseq) - len(query_seq)) / max(len(tseq), len(query_seq)) > 0.3:\n            continue\n\n        aln = next(iter(_aligner.align(query_seq, tseq)))\n        norm_s = aln.score / (2 * min(len(query_seq), len(tseq)))\n\n        identical = sum(\n            1 for (qs, qe), (ts, te) in zip(*aln.aligned)\n            for qp, tp in zip(range(qs, qe), range(ts, te))\n            if query_seq[qp] == tseq[tp]\n        )\n        pct_id = 100 * identical / len(query_seq)\n\n        aq, at = _build_aligned_strings(query_seq, tseq, aln)\n\n        results.append((tid, tseq, norm_s, train_coords_dict[tid], pct_id, aq, at))\n\n    results.sort(key=lambda x: x[2], reverse=True)\n    return results[:top_n]\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 6 — Template Transfer + Geometry Refinement\n# ─────────────────────────────────────────────────────────────\n\ndef adapt_template_to_query(query_seq: str, template_seq: str, template_coords: np.ndarray) -> np.ndarray:\n    aln = next(iter(_aligner.align(query_seq, template_seq)))\n    new_coords = np.full((len(query_seq), 3), np.nan)\n\n    for (qs, qe), (ts, te) in zip(*aln.aligned):\n        chunk = template_coords[ts:te]\n        if len(chunk) == (qe - qs):\n            new_coords[qs:qe] = chunk\n\n    for i in range(len(new_coords)):\n        if np.isnan(new_coords[i, 0]):\n            pv = next((j for j in range(i - 1, -1, -1) if not np.isnan(new_coords[j, 0])), -1)\n            nv = next((j for j in range(i + 1, len(new_coords)) if not np.isnan(new_coords[j, 0])), -1)\n            if pv >= 0 and nv >= 0:\n                w = (i - pv) / (nv - pv)\n                new_coords[i] = (1 - w) * new_coords[pv] + w * new_coords[nv]\n            elif pv >= 0:\n                new_coords[i] = new_coords[pv] + [3, 0, 0]\n            elif nv >= 0:\n                new_coords[i] = new_coords[nv] + [3, 0, 0]\n            else:\n                new_coords[i] = [i * 3, 0, 0]\n\n    return np.nan_to_num(new_coords)\n\n\ndef hybrid_confidence(coords: np.ndarray) -> float:\n    if coords is None or len(coords) < 3:\n        return -1e9\n\n    valid = np.sum(np.isfinite(coords[:, 0]))\n    if valid < 3:\n        return -1e9\n\n    coverage = valid / len(coords)\n\n    finite_mask = np.isfinite(coords[:, 0])\n    finite_coords = coords[finite_mask]\n    diffs = np.linalg.norm(np.diff(finite_coords, axis=0), axis=1)\n\n    if len(diffs) == 0:\n        return -1e9\n\n    smooth = np.std(diffs)\n    mean_step = np.mean(diffs)\n    spacing_error = abs(mean_step - 5.95)\n\n    center = finite_coords.mean(axis=0)\n    compact = np.mean(np.linalg.norm(finite_coords - center, axis=1))\n\n    return coverage * 2.0 - smooth * 0.3 - spacing_error * 0.2 - 0.02 * compact\n\n\ndef kabsch(P, Q):\n    assert P.shape == Q.shape\n    mask = np.isfinite(P[:, 0]) & np.isfinite(Q[:, 0])\n    Pm, Qm = P[mask], Q[mask]\n    if len(Pm) < 3:\n        return None, None, None\n    Pc, Qc = Pm.mean(0), Qm.mean(0)\n    H = (Pm - Pc).T @ (Qm - Qc)\n    V, S, W = np.linalg.svd(H)\n    d = np.sign(np.linalg.det(V @ W))\n    U = V @ np.diag([1, 1, d]) @ W\n    return U, Pc, Qc\n\n\ndef ensemble_centroid(preds):\n    if not preds or len(preds) == 0:\n        return None\n    if len(preds) == 1:\n        return preds[0].copy()\n\n    ref = preds[0]\n    aligned = [ref.copy()]\n\n    for p in preds[1:]:\n        result = kabsch(p.copy(), ref.copy())\n        if result[0] is not None:\n            U, Pc, Qc = result\n            aligned.append((p - Pc) @ U + Qc)\n        else:\n            aligned.append(p.copy())\n\n    return np.median(np.stack(aligned), axis=0)\n\n\ndef adaptive_rna_constraints(\n    coords: np.ndarray,\n    target_id: str,\n    segments_map: dict,\n    confidence: float = 1.0,\n    passes: int = 2\n) -> np.ndarray:\n    X = coords.copy()\n    segments = segments_map.get(target_id, [(0, len(X))])\n    strength = max(0.75 * (1.0 - min(confidence, 0.97)), 0.02)\n\n    for _ in range(passes):\n        for s, e in segments:\n            C = X[s:e]\n            L = e - s\n            if L < 3:\n                continue\n\n            d = C[1:] - C[:-1]\n            dist = np.linalg.norm(d, axis=1) + 1e-6\n            adj = d * ((5.95 - dist) / dist)[:, None] * (0.22 * strength)\n            C[:-1] -= adj\n            C[1:] += adj\n\n            d2 = C[2:] - C[:-2]\n            d2n = np.linalg.norm(d2, axis=1) + 1e-6\n            adj2 = d2 * ((10.2 - d2n) / d2n)[:, None] * (0.10 * strength)\n            C[:-2] -= adj2\n            C[2:] += adj2\n\n            C[1:-1] += (0.06 * strength) * (0.5 * (C[:-2] + C[2:]) - C[1:-1])\n\n            if L >= 25:\n                idx = np.linspace(0, L - 1, min(L, 160)).astype(int) if L > 220 else np.arange(L)\n                P = C[idx]\n                diff = P[:, None, :] - P[None, :, :]\n                dm = np.linalg.norm(diff, axis=2) + 1e-6\n                sep = np.abs(idx[:, None] - idx[None, :])\n                mask = (sep > 2) & (dm < 3.2)\n                if np.any(mask):\n                    vec = (diff * ((3.2 - dm) / dm)[:, :, None] * mask[:, :, None]).sum(axis=1)\n                    C[idx] += (0.015 * strength) * vec\n\n            X[s:e] = C\n\n    return X\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 6.5 — Chunking Utilities for Long Sequences\n# ─────────────────────────────────────────────────────────────\n\ndef split_into_chunks(seq_len: int, max_len: int, overlap: int) -> list:\n    \"\"\"Split a sequence into overlapping (start, end) chunks.\"\"\"\n    if seq_len <= max_len:\n        return [(0, seq_len)]\n    chunks = []\n    step = max_len - overlap\n    pos = 0\n    while pos < seq_len:\n        end = min(pos + max_len, seq_len)\n        chunks.append((pos, end))\n        if end == seq_len:\n            break\n        pos += step\n    return chunks\n\n\ndef stitch_chunk_coords(chunk_coords_list: list,\n                        chunk_ranges: list,\n                        seq_len: int) -> np.ndarray:\n    if len(chunk_coords_list) == 1:\n        coords = chunk_coords_list[0]\n        if coords.shape[0] >= seq_len:\n            return coords[:seq_len]\n        out = np.zeros((seq_len, 3), dtype=coords.dtype)\n        out[:coords.shape[0]] = coords\n        return out\n\n    aligned = [chunk_coords_list[0].copy()]\n\n    for i in range(1, len(chunk_coords_list)):\n        prev_start, prev_end = chunk_ranges[i - 1]\n        cur_start, cur_end = chunk_ranges[i]\n\n        ov_start = cur_start\n        ov_end = min(prev_end, cur_end)\n        ov_len = ov_end - ov_start\n\n        if ov_len < 3:\n            aligned.append(chunk_coords_list[i].copy())\n            continue\n\n        prev_ov = aligned[i - 1][ov_start - prev_start: ov_end - prev_start]\n        cur_ov = chunk_coords_list[i][ov_start - cur_start: ov_end - cur_start]\n\n        valid = ~(np.isnan(prev_ov).any(axis=1) | np.isnan(cur_ov).any(axis=1))\n        if valid.sum() < 3:\n            aligned.append(chunk_coords_list[i].copy())\n            continue\n\n        result = kabsch(cur_ov[valid], prev_ov[valid])\n        if result[0] is not None:\n            U, Pc, Qc = result\n            transformed = (chunk_coords_list[i] - Pc) @ U + Qc\n            aligned.append(transformed)\n        else:\n            aligned.append(chunk_coords_list[i].copy())\n\n    full = np.zeros((seq_len, 3), dtype=np.float64)\n    weights = np.zeros(seq_len, dtype=np.float64)\n\n    for i, ((s, e), coords) in enumerate(zip(chunk_ranges, aligned)):\n        chunk_len = coords.shape[0]\n        actual_end = min(s + chunk_len, seq_len)\n        used_len = actual_end - s\n\n        w = np.ones(used_len, dtype=np.float64)\n\n        if i > 0:\n            ov_start = s\n            ov_end = min(chunk_ranges[i - 1][1], e)\n            ramp_len = ov_end - ov_start\n            if ramp_len > 0:\n                w[:ramp_len] = np.linspace(0.0, 1.0, ramp_len)\n\n        if i < len(chunk_ranges) - 1:\n            next_s = chunk_ranges[i + 1][0]\n            ramp_start = next_s - s\n            ramp_len = actual_end - next_s\n            if ramp_len > 0 and ramp_start < used_len:\n                w[ramp_start:used_len] = np.linspace(1.0, 0.0, ramp_len)\n\n        full[s:actual_end] += coords[:used_len] * w[:, None]\n        weights[s:actual_end] += w\n\n    mask = weights > 0\n    full[mask] /= weights[mask, None]\n\n    return full\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 7 — Geometric Transform Helpers\n# ─────────────────────────────────────────────────────────────\n\ndef _rotmat(axis, ang):\n    a = np.asarray(axis, float)\n    a /= np.linalg.norm(a) + 1e-12\n    x, y, z = a\n    c, s = np.cos(ang), np.sin(ang)\n    CC = 1 - c\n    return np.array([\n        [c + x*x*CC, x*y*CC - z*s, x*z*CC + y*s],\n        [y*x*CC + z*s, c + y*y*CC, y*z*CC - x*s],\n        [z*x*CC - y*s, z*y*CC + x*s, c + z*z*CC]\n    ])\n\ndef apply_hinge(coords, seg, rng, deg=22):\n    s, e = seg\n    L = e - s\n    if L < 30:\n        return coords\n    pivot = s + int(rng.integers(10, L - 10))\n    R = _rotmat(rng.normal(size=3), np.deg2rad(float(rng.uniform(-deg, deg))))\n    X = coords.copy()\n    p0 = X[pivot].copy()\n    X[pivot+1:e] = (X[pivot+1:e] - p0) @ R.T + p0\n    return X\n\ndef jitter_chains(coords, segs, rng, deg=12, trans=1.5):\n    X = coords.copy()\n    gc_ = X.mean(0, keepdims=True)\n    for s, e in segs:\n        R = _rotmat(rng.normal(size=3), np.deg2rad(float(rng.uniform(-deg, deg))))\n        shift = rng.normal(size=3)\n        shift = shift / (np.linalg.norm(shift) + 1e-12) * float(rng.uniform(0, trans))\n        c = X[s:e].mean(0, keepdims=True)\n        X[s:e] = (X[s:e] - c) @ R.T + c + shift\n    X -= X.mean(0, keepdims=True) - gc_\n    return X\n\ndef smooth_wiggle(coords, segs, rng, amp=0.8):\n    X = coords.copy()\n    for s, e in segs:\n        L = e - s\n        if L < 20:\n            continue\n        ctrl = np.linspace(0, L - 1, 6)\n        disp = rng.normal(0, amp, (6, 3))\n        t = np.arange(L)\n        X[s:e] += np.vstack([np.interp(t, ctrl, disp[:, k]) for k in range(3)]).T\n    return X\n\ndef generate_rna_structure(sequence: str, seed=None) -> np.ndarray:\n    if seed is not None:\n        np.random.seed(seed)\n    n = len(sequence)\n    coords = np.zeros((n, 3))\n    for i in range(n):\n        ang = i * 0.6\n        coords[i] = [10.0 * np.cos(ang), 10.0 * np.sin(ang), i * 2.5]\n    return coords\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 8 — TBM Phase (Modified: max MAX_TBM templates)\n# ─────────────────────────────────────────────────────────────\n\ndef tbm_phase(test_df, train_seqs_df, train_coords_dict, segments_map):\n    print(f\"\\n{'='*60}\")\n    print(f\"PHASE 1: Template-Based Modeling (MAX_TBM={MAX_TBM})\")\n    print(f\"  MIN_SIMILARITY = {MIN_SIMILARITY}  |  MIN_PCT_IDENTITY = {MIN_PERCENT_IDENTITY}\")\n    print(f\"{'='*60}\")\n    t0 = time.time()\n\n    template_predictions = {}\n    dl_queue = {}\n\n    for _, row in test_df.iterrows():\n        tid = row[\"target_id\"]\n        seq = row[\"sequence\"]\n        seq_len = len(seq)\n\n        similar = find_similar_sequences_detailed(seq, train_seqs_df, train_coords_dict, top_n=30)\n\n        preds = []\n        used = set()\n\n        # ★ 限制TBM最多MAX_TBM个\n        for i, (tmpl_id, tmpl_seq, sim, tmpl_coords, pct_id, _, _) in enumerate(similar):\n            if len(preds) >= MAX_TBM:\n                break\n            if sim < MIN_SIMILARITY or pct_id < MIN_PERCENT_IDENTITY:\n                break\n            if tmpl_id in used:\n                continue\n\n            rng = np.random.default_rng((abs(hash(tid)) + i * 10007) % (2**32))\n            adapted = adapt_template_to_query(seq, tmpl_seq, tmpl_coords)\n            segs = segments_map.get(tid, [(0, len(seq))])\n\n            slot = len(preds)\n            if slot == 0:\n                X = adapted\n            elif slot == 1:\n                X = adapted + rng.normal(0, max(0.01, (0.40 - sim) * 0.06), adapted.shape)\n            elif slot == 2:\n                longest = max(segs, key=lambda se: se[1] - se[0])\n                X = apply_hinge(adapted, longest, rng)\n            elif slot == 3:\n                X = jitter_chains(adapted, segs, rng)\n            else:\n                X = smooth_wiggle(adapted, segs, rng)\n\n            refined = adaptive_rna_constraints(X, tid, segments_map, confidence=sim)\n            preds.append(refined)\n            used.add(tmpl_id)\n\n        template_predictions[tid] = preds\n        \n        # ★ 所有目标都需要DL补充（即使TBM满了也要记录）\n        n_tbm = len(preds)\n        n_dl_needed = N_INTERNAL_PREDS - n_tbm\n        dl_queue[tid] = {\n            'n_tbm': n_tbm,\n            'n_dl_needed': n_dl_needed,\n            'sequence': seq,\n            'seq_len': seq_len,\n        }\n        \n        if n_tbm >= MAX_TBM:\n            print(f\"  {tid} ({seq_len} nt): {n_tbm} TBM (capped) → need {n_dl_needed} from DL\")\n        elif n_tbm > 0:\n            print(f\"  {tid} ({seq_len} nt): {n_tbm} TBM → need {n_dl_needed} from DL\")\n        else:\n            print(f\"  {tid} ({seq_len} nt): no TBM → need {n_dl_needed} from DL\")\n\n    elapsed = time.time() - t0\n    print(f\"\\nPhase 1 done in {elapsed:.1f}s\")\n    return template_predictions, dl_queue\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 9 — Run TBM Phase\n# ─────────────────────────────────────────────────────────────\n\nprint(\"\\n\" + \"=\"*60)\nprint(\"PHASE 1: TEMPLATE-BASED MODELLING (TBM)\")\nprint(\"=\"*60)\n\ntemplate_preds_dict, dl_queue = tbm_phase(\n    test_seqs, all_train_seqs, all_train_coords, test_segs_map\n)\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 10 — Calculate DL Allocation per Target\n# ─────────────────────────────────────────────────────────────\n\nprint(f\"\\n{'='*60}\")\nprint(\"DL ALLOCATION STRATEGY\")\nprint(f\"  Threshold: ≤{PROTENIX_BOLTZ_THRESHOLD}nt → Protenix主+Boltz副\")\nprint(f\"             >{PROTENIX_BOLTZ_THRESHOLD}nt → Boltz主+Protenix副\")\nprint(f\"             >{MAX_BOLTZ_LEN}nt → Chunked Protenix only\")\nprint(f\"{'='*60}\")\n\n# 计算每个目标需要的DL预测数量\nprotenix_tasks = []  # (tid, n_samples, is_primary, seq, seq_len)\nboltz_tasks = []     # (tid, n_samples, is_primary, seq, seq_len)\nprotenix_chunked_targets = []  # (tid, info_dict)\n\nfor tid, info in dl_queue.items():\n    n_tbm = info['n_tbm']\n    n_dl_needed = info['n_dl_needed']\n    seq = info['sequence']\n    seq_len = info['seq_len']\n    \n    if n_dl_needed <= 0:\n        continue\n    \n    if seq_len > MAX_BOLTZ_LEN:\n        # 超长序列：只用Chunked Protenix\n        protenix_chunked_targets.append((tid, {\n            'sequence': seq,\n            'seq_len': seq_len,\n            'n_needed': n_dl_needed,\n        }))\n        print(f\"  {tid} ({seq_len}nt): Chunked Protenix × {n_dl_needed}\")\n        \n    elif seq_len <= PROTENIX_BOLTZ_THRESHOLD:\n        # 短序列：Protenix主 + Boltz副\n        n_secondary = max(1, n_dl_needed // 4)\n        n_primary = n_dl_needed - n_secondary\n        \n        protenix_tasks.append((tid, n_primary, True, seq, seq_len))\n        boltz_tasks.append((tid, n_secondary, False, seq, seq_len))\n        print(f\"  {tid} ({seq_len}nt): Protenix(主)×{n_primary} + Boltz(副)×{n_secondary}\")\n        \n    else:\n        # 中长序列：Boltz主 + Protenix副\n        n_secondary = max(1, n_dl_needed // 4)\n        n_primary = n_dl_needed - n_secondary\n        \n        boltz_tasks.append((tid, n_primary, True, seq, seq_len))\n        protenix_tasks.append((tid, n_secondary, False, seq, seq_len))\n        print(f\"  {tid} ({seq_len}nt): Boltz(主)×{n_primary} + Protenix(副)×{n_secondary}\")\n\nprint(f\"\\n  Total Protenix tasks: {len(protenix_tasks)}\")\nprint(f\"  Total Boltz tasks: {len(boltz_tasks)}\")\nprint(f\"  Total Chunked tasks: {len(protenix_chunked_targets)}\")\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 11 — Protenix Unified Inference (Regular + Chunked，共用Runner)\n# ─────────────────────────────────────────────────────────────\n\nprotenix_preds_dict = {}\nprotenix_chunked_preds_dict = {}\n\nhas_protenix_work = PROTENIX_CODE_DIR is not None and (protenix_tasks or protenix_chunked_targets)\n\nif has_protenix_work:\n    print(f\"\\n{'='*60}\")\n    print(f\"PHASE 2: PROTENIX UNIFIED PREDICTIONS\")\n    print(f\"  Regular tasks: {len(protenix_tasks)}\")\n    print(f\"  Chunked targets: {len(protenix_chunked_targets)}\")\n    print(f\"{'='*60}\")\n\n    PTX_DUMP_DIR = \"/kaggle/working/ptx_output\"\n    PTX_CHUNK_DIR = \"/kaggle/working/ptx_chunked_output\"\n    os.makedirs(PTX_DUMP_DIR, exist_ok=True)\n    os.makedirs(PTX_CHUNK_DIR, exist_ok=True)\n    os.environ[\"LAYERNORM_TYPE\"] = \"torch\"\n\n    if PROTENIX_CODE_DIR not in sys.path:\n        sys.path.insert(0, PROTENIX_CODE_DIR)\n\n    try:\n        from configs.configs_base import configs as configs_base\n        from configs.configs_data import data_configs\n        from configs.configs_inference import inference_configs\n        from configs.configs_model_type import model_configs\n        from protenix.config.config import parse_configs\n        from protenix.data.inference.infer_dataloader import InferenceDataset\n        from runner.inference import (InferenceRunner, update_gpu_compatible_configs,\n                                      update_inference_configs)\n\n        def build_protenix_configs(input_json_path: str, dump_dir: str, n_sample: int,\n                                   seed_val: int = PROTENIX_SEED):\n            base = {**configs_base, **{\"data\": data_configs}, **inference_configs}\n\n            def deep_update(target, patch):\n                for k, v in patch.items():\n                    if isinstance(v, dict) and k in target and isinstance(target[k], dict):\n                        deep_update(target[k], v)\n                    else:\n                        target[k] = v\n\n            deep_update(base, model_configs[PROTENIX_MODEL_NAME])\n\n            arg_str = \" \".join([\n                f\"--model_name {PROTENIX_MODEL_NAME}\",\n                f\"--input_json_path {input_json_path}\",\n                f\"--dump_dir {dump_dir}\",\n                \"--use_msa true\",\n                \"--use_template false\",\n                \"--use_rna_msa true\",\n                f\"--sample_diffusion.N_sample {n_sample}\",\n                f\"--seeds {seed_val}\",\n            ])\n\n            return parse_configs(configs=base, arg_str=arg_str, fill_required_with_null=True)\n\n        def get_c1_mask(data: dict, atom_array, full_seq_len: int) -> torch.Tensor:\n            if atom_array is not None:\n                try:\n                    if hasattr(atom_array, \"centre_atom_mask\"):\n                        m = atom_array.centre_atom_mask == 1\n                        if hasattr(atom_array, \"is_rna\"):\n                            m = m & atom_array.is_rna\n                        return torch.from_numpy(m).bool()\n                    if hasattr(atom_array, \"atom_name\"):\n                        base = atom_array.atom_name == \"C1'\"\n                        if hasattr(atom_array, \"is_rna\"):\n                            base = base & atom_array.is_rna\n                        return torch.from_numpy(base).bool()\n                except Exception:\n                    pass\n\n            feat = data[\"input_feature_dict\"]\n            if \"centre_atom_mask\" in feat:\n                return (feat[\"centre_atom_mask\"] == 1).bool()\n            if \"center_atom_mask\" in feat:\n                return (feat[\"center_atom_mask\"] == 1).bool()\n\n            if \"atom_to_tokatom_idx\" in feat:\n                m11 = (feat[\"atom_to_tokatom_idx\"] == 11)\n                m12 = (feat[\"atom_to_tokatom_idx\"] == 12)\n                c11, c12 = m11.sum().item(), m12.sum().item()\n                if abs(c11 - full_seq_len) < abs(c12 - full_seq_len):\n                    return m11.bool()\n                else:\n                    return m12.bool()\n\n            n_atoms = feat.get(\"N_atom\", torch.tensor(0)).item()\n            return torch.zeros(n_atoms, dtype=torch.bool)\n\n        # ═══════════════════════════════════════════════════════════\n        # ★★★ 单次初始化 Runner（常规 + Chunked 共用）★★★\n        # ═══════════════════════════════════════════════════════════\n        \n        print(\"\\n[OPTIMIZE] Initializing Protenix Runner ONCE for ALL tasks...\")\n        init_start = time.time()\n        \n        # 计算总任务数（用于估算节省时间）\n        n_regular_targets = len(set(t[0] for t in protenix_tasks)) if protenix_tasks else 0\n        n_chunk_tasks = 0\n        if protenix_chunked_targets:\n            for tid, info in protenix_chunked_targets:\n                chunks = split_into_chunks(info['seq_len'], MAX_PROTENIX_LEN, CHUNK_OVERLAP)\n                n_chunk_tasks += len(chunks)\n        total_runner_calls = n_regular_targets + n_chunk_tasks\n        \n        # 用一个简单序列创建初始配置\n        init_json_path = os.path.join(PTX_DUMP_DIR, \"init_input.json\")\n        init_payload = [{\n            \"name\": \"init_dummy\",\n            \"covalent_bonds\": [],\n            \"sequences\": [{\"rnaSequence\": {\"sequence\": \"ACGUACGUACGU\", \"count\": 1}}],\n        }]\n        with open(init_json_path, \"w\") as f:\n            json.dump(init_payload, f)\n        \n        init_configs = build_protenix_configs(\n            init_json_path, PTX_DUMP_DIR, \n            n_sample=1, seed_val=PROTENIX_SEED\n        )\n        init_configs = update_gpu_compatible_configs(init_configs)\n        init_configs.use_msa = False\n        init_configs.use_rna_msa = False\n        \n        # ★★★ 创建全局 Runner（最耗时的步骤，只执行一次）★★★\n        global_runner = InferenceRunner(init_configs)\n        \n        init_elapsed = time.time() - init_start\n        saved_time = max(0, (total_runner_calls - 1) * 60)\n        print(f\"[OPTIMIZE] ✓ Runner initialized in {init_elapsed:.1f}s\")\n        print(f\"[OPTIMIZE] ✓ Will process {total_runner_calls} inference calls\")\n        print(f\"[OPTIMIZE] ✓ Estimated time saved: ~{saved_time:.0f}s ({saved_time/60:.1f} min)\")\n\n        # ═══════════════════════════════════════════════════════════\n        # PART A: 常规 Protenix 预测\n        # ═══════════════════════════════════════════════════════════\n        \n        if protenix_tasks:\n            print(f\"\\n{'─'*50}\")\n            print(f\"PART A: Regular Protenix ({len(protenix_tasks)} tasks)\")\n            print(f\"{'─'*50}\")\n            \n            # 按target分组\n            target_samples = {}\n            for tid, n_samples, is_primary, seq, seq_len in protenix_tasks:\n                if tid not in target_samples:\n                    target_samples[tid] = {'n_samples': 0, 'seq': seq, 'seq_len': seq_len}\n                target_samples[tid]['n_samples'] += n_samples\n\n            for tid, info in tqdm(target_samples.items(), desc=\"Regular Protenix\"):\n                n_samples = info['n_samples']\n                seq = info['seq']\n                full_seq_len = info['seq_len']\n\n                single_json_path = os.path.join(PTX_DUMP_DIR, f\"{tid}_input.json\")\n                payload = [{\n                    \"name\": tid,\n                    \"covalent_bonds\": [],\n                    \"sequences\": [{\"rnaSequence\": {\"sequence\": seq, \"count\": 1}}],\n                }]\n                with open(single_json_path, \"w\") as f:\n                    json.dump(payload, f)\n\n                msa_file = os.path.join(MSA_ROOT, f\"{tid}.MSA.fasta\")\n                msa_exists = os.path.isfile(msa_file)\n\n                try:\n                    configs = build_protenix_configs(\n                        single_json_path, PTX_DUMP_DIR,\n                        n_sample=n_samples, seed_val=PROTENIX_SEED\n                    )\n                    configs = update_gpu_compatible_configs(configs)\n\n                    if msa_exists:\n                        configs.use_msa = True\n                        configs.use_rna_msa = True\n                        if hasattr(configs, 'data') and hasattr(configs.data, 'msa'):\n                            configs.data.msa.enable_rna_msa = True\n                            configs.data.msa.rna_msadir_raw_paths = [MSA_ROOT]\n                    else:\n                        configs.use_msa = False\n                        configs.use_rna_msa = False\n\n                    dataset = InferenceDataset(configs)\n\n                    if len(dataset) == 0:\n                        print(f\"  ✗ {tid}: empty dataset\")\n                        continue\n\n                    data, atom_array, error_message = dataset[0]\n                    if error_message:\n                        print(f\"  ✗ {tid}: {error_message[:80]}\")\n                        continue\n\n                    # ★★★ 复用 global_runner ★★★\n                    new_cfg = update_inference_configs(configs, data[\"N_token\"].item())\n                    new_cfg.sample_diffusion.N_sample = n_samples\n                    global_runner.update_model_configs(new_cfg)\n\n                    prediction = global_runner.predict(data)\n                    raw_coords = prediction[\"coordinate\"]\n\n                    mask = get_c1_mask(data, atom_array, full_seq_len).to(raw_coords.device)\n                    coords = raw_coords[:, mask, :].detach().cpu().numpy()\n\n                    if coords.shape[1] == 0:\n                        print(f\"  ✗ {tid}: empty C1' mask\")\n                        continue\n\n                    if coords.shape[1] != full_seq_len:\n                        padded = np.zeros((coords.shape[0], full_seq_len, 3), dtype=np.float32)\n                        min_len = min(coords.shape[1], full_seq_len)\n                        padded[:, :min_len, :] = coords[:, :min_len, :]\n                        coords = padded\n\n                    protenix_preds_dict[tid] = [coords[i] for i in range(coords.shape[0])]\n                    print(f\"  ✓ {tid}: {coords.shape[0]} samples\")\n\n                    del prediction, raw_coords, dataset, data, atom_array\n\n                except Exception as exc:\n                    print(f\"  ✗ {tid}: {str(exc)[:100]}\")\n\n                finally:\n                    gc.collect()\n                    torch.cuda.empty_cache()\n\n            print(f\"\\n✓ Regular Protenix: {len(protenix_preds_dict)}/{len(target_samples)} targets\")\n\n        # ═══════════════════════════════════════════════════════════\n        # PART B: Chunked Protenix 预测\n        # ═══════════════════════════════════════════════════════════\n        \n        if protenix_chunked_targets:\n            print(f\"\\n{'─'*50}\")\n            print(f\"PART B: Chunked Protenix ({len(protenix_chunked_targets)} targets >{MAX_BOLTZ_LEN} nt)\")\n            print(f\"{'─'*50}\")\n\n            # 建立查找字典\n            chunked_info_dict = {tid: info for tid, info in protenix_chunked_targets}\n\n            # Build chunk tasks\n            chunk_tasks_list = []\n            chunk_info = {}  # tid -> list of {\"name\": chunk_name, \"range\": (s, e)}\n\n            for tid, info in protenix_chunked_targets:\n                full_seq = info['sequence']\n                seq_len = info['seq_len']\n                n_needed = info['n_needed']\n\n                chunks = split_into_chunks(seq_len, MAX_PROTENIX_LEN, CHUNK_OVERLAP)\n                print(f\"  {tid} ({seq_len} nt): {len(chunks)} chunks\")\n\n                chunk_info[tid] = []\n                for ci, (cs, ce) in enumerate(chunks):\n                    chunk_name = f\"{tid}_chunk{ci}\"\n                    sub_seq = full_seq[cs:ce]\n                    chunk_tasks_list.append({\n                        \"target_id\": chunk_name, \n                        \"sequence\": sub_seq, \n                        \"parent_tid\": tid, \n                        \"range\": (cs, ce)\n                    })\n                    chunk_info[tid].append({\"name\": chunk_name, \"range\": (cs, ce)})\n\n            # Process chunks\n            chunk_results = {}  # chunk_name -> coords array (shape: [n_generate, chunk_len, 3])\n\n            for task in tqdm(chunk_tasks_list, desc=\"Protenix Chunks\"):\n                chunk_name = task[\"target_id\"]\n                sub_seq = task[\"sequence\"]\n                parent_tid = task[\"parent_tid\"]\n                \n                # 从查找字典获取 n_needed\n                n_needed = chunked_info_dict[parent_tid]['n_needed']\n                \n                # 过采样：为每个chunk生成更多样本\n                # n_generate = min(n_needed * 2, 6)\n                n_generate = n_needed\n\n                single_json_path = os.path.join(PTX_CHUNK_DIR, f\"{chunk_name}_input.json\")\n                payload = [{\n                    \"name\": chunk_name,\n                    \"covalent_bonds\": [],\n                    \"sequences\": [{\"rnaSequence\": {\"sequence\": sub_seq, \"count\": 1}}],\n                }]\n                with open(single_json_path, \"w\") as f:\n                    json.dump(payload, f)\n\n                try:\n                    configs = build_protenix_configs(single_json_path, PTX_CHUNK_DIR,\n                                                    n_sample=n_generate, seed_val=PROTENIX_SEED)\n                    configs = update_gpu_compatible_configs(configs)\n\n                    # MSA setup\n                    msa_file = os.path.join(MSA_ROOT, f\"{parent_tid}.MSA.fasta\")\n                    if os.path.isfile(msa_file):\n                        configs.use_msa = True\n                        configs.use_rna_msa = True\n                    else:\n                        configs.use_msa = False\n                        configs.use_rna_msa = False\n\n                    # ★★★ 复用 global_runner ★★★\n                    dataset = InferenceDataset(configs)\n\n                    if len(dataset) == 0:\n                        print(f\"    {chunk_name}: empty dataset\")\n                        chunk_results[chunk_name] = None\n                        continue\n\n                    data, atom_array, error_message = dataset[0]\n                    if error_message:\n                        print(f\"    {chunk_name}: {error_message[:60]}\")\n                        chunk_results[chunk_name] = None\n                        continue\n\n                    new_cfg = update_inference_configs(configs, data[\"N_token\"].item())\n                    new_cfg.sample_diffusion.N_sample = n_generate\n                    global_runner.update_model_configs(new_cfg)\n\n                    prediction = global_runner.predict(data)\n                    raw_coords = prediction[\"coordinate\"]\n\n                    # Extract C1' coordinates\n                    mask = get_c1_mask(data, atom_array, len(sub_seq)).to(raw_coords.device)\n                    coords = raw_coords[:, mask, :].detach().cpu().numpy()\n\n                    # Pad if needed\n                    if coords.shape[1] != len(sub_seq):\n                        padded = np.zeros((coords.shape[0], len(sub_seq), 3), dtype=np.float32)\n                        ml = min(coords.shape[1], len(sub_seq))\n                        padded[:, :ml, :] = coords[:, :ml, :]\n                        coords = padded\n\n                    chunk_results[chunk_name] = coords\n                    print(f\"    {chunk_name}: {coords.shape[0]} samples OK\")\n\n                    del prediction, raw_coords, dataset, data, atom_array\n\n                except Exception as e:\n                    print(f\"    {chunk_name} error: {str(e)[:60]}\")\n                    chunk_results[chunk_name] = None\n\n                gc.collect()\n                torch.cuda.empty_cache()\n\n            # Stitch chunks for each target\n            for tid, info in protenix_chunked_targets:\n                full_seq = info['sequence']\n                seq_len = info['seq_len']\n                n_needed = info['n_needed']\n                # n_generate = min(n_needed * 2, 6)\n                n_generate  = n_needed\n                chunks = chunk_info.get(tid, [])\n\n                if not chunks:\n                    continue\n\n                # Check if all chunks succeeded\n                all_ok = True\n                for cinfo in chunks:\n                    if chunk_results.get(cinfo[\"name\"]) is None:\n                        all_ok = False\n                        break\n\n                if not all_ok:\n                    print(f\"  {tid}: chunked inference incomplete\")\n                    protenix_chunked_preds_dict[tid] = None\n                    continue\n\n                # Stitch each sample independently\n                stitched_samples = []\n                for s_idx in range(n_generate):\n                    coords_list = []\n                    ranges_list = []\n                    for cinfo in chunks:\n                        ccoords = chunk_results[cinfo[\"name\"]]\n                        if s_idx < ccoords.shape[0]:\n                            coords_list.append(ccoords[s_idx])\n                        else:\n                            coords_list.append(ccoords[-1])\n                        ranges_list.append(cinfo[\"range\"])\n\n                    full_coords = stitch_chunk_coords(coords_list, ranges_list, seq_len)\n                    stitched_samples.append(full_coords)\n\n                # 按confidence排序，取top n_needed\n                scored_samples = [(p, hybrid_confidence(p)) for p in stitched_samples]\n                scored_samples.sort(key=lambda x: x[1], reverse=True)\n                \n                protenix_chunked_preds_dict[tid] = [p[0] for p in scored_samples[:n_needed]]\n                \n                best_sc = scored_samples[0][1]\n                worst_idx = min(n_needed, len(scored_samples)) - 1\n                worst_kept_sc = scored_samples[worst_idx][1]\n                print(f\"  ✓ {tid}: {len(stitched_samples)} stitched → kept top {min(n_needed, len(stitched_samples))}, \"\n                      f\"conf=[{best_sc:.3f}, {worst_kept_sc:.3f}]\")\n\n            print(f\"\\n✓ Chunked Protenix: {sum(1 for v in protenix_chunked_preds_dict.values() if v)}/{len(protenix_chunked_targets)} targets\")\n\n        # ═══════════════════════════════════════════════════════════\n        # 清理全局 Runner\n        # ═══════════════════════════════════════════════════════════\n        print(\"\\n[OPTIMIZE] Cleaning up global runner...\")\n        del global_runner\n        gc.collect()\n        torch.cuda.empty_cache()\n\n    except ImportError as e:\n        print(f\"Protenix import failed: {e}\")\n    except Exception as e:\n        print(f\"Protenix initialization failed: {e}\")\n        import traceback\n        traceback.print_exc()\n\n    gc.collect()\n    torch.cuda.empty_cache()\n\nprint(f\"\\n{'='*60}\")\nprint(f\"PROTENIX SUMMARY:\")\nprint(f\"  Regular: {len(protenix_preds_dict)} targets\")\nprint(f\"  Chunked: {sum(1 for v in protenix_chunked_preds_dict.values() if v)}/{len(protenix_chunked_targets)} targets\")\nprint(f\"{'='*60}\")\n# ─────────────────────────────────────────────────────────────\n# SECTION 12 — Boltz Inference\n# ─────────────────────────────────────────────────────────────\n\nboltz_preds_dict = {}\n\nif boltz_tasks:\n    print(f\"\\n{'='*60}\")\n    print(f\"PHASE 2B: BOLTZ PREDICTIONS\")\n    print(f\"({len(boltz_tasks)} targets)\")\n    print(f\"{'='*60}\")\n\n    os.system(\"mkdir -p input_fasta\")\n    os.system(\"rm -rf boltz_results* boltz_repeat_*\")\n\n    try:\n        from biopandas.pdb import PandasPdb\n\n        # 按target分组\n        target_samples = {}\n        for tid, n_samples, is_primary, seq, seq_len in boltz_tasks:\n            if tid not in target_samples:\n                target_samples[tid] = {'n_samples': 0, 'seq': seq, 'seq_len': seq_len}\n            target_samples[tid]['n_samples'] += n_samples\n\n        for tid, info in tqdm(target_samples.items(), desc=\"Boltz\"):\n            seq = info['seq']\n            n_samples = info['n_samples']\n            full_seq_len = info['seq_len']\n\n            with open(f\"input_fasta/{tid}.fasta\", \"w\") as fout:\n                fout.write(f\">0|rna|\\n{seq}\\n\")\n\n            print(f\"  {tid}: generating {n_samples} samples\")\n\n            repeat_coords = []\n\n            for repeat in range(n_samples):\n                os.system(\n                    f\"boltz predict input_fasta/{tid}.fasta \"\n                    f\"--num_workers 1 --max_parallel_samples 1 \"\n                    f\"--output_format pdb \"\n                    f\"--cache boltz_cache/boltz2/ \"\n                    f\"--out_dir boltz_repeat_{repeat} 2>/dev/null\"\n                )\n\n                fname = f\"boltz_repeat_{repeat}/boltz_results_{tid}/predictions/{tid}/{tid}_model_0.pdb\"\n                try:\n                    atom_df = PandasPdb().read_pdb(fname).df[\"ATOM\"]\n                    c1_atoms = (atom_df[(atom_df.chain_id == \"0\") & (atom_df.atom_name == \"C1'\")]\n                               .sort_values(\"residue_number\"))\n\n                    if len(c1_atoms) == full_seq_len:\n                        coords = c1_atoms[[\"x_coord\", \"y_coord\", \"z_coord\"]].values.astype(float)\n                        repeat_coords.append(coords)\n                    else:\n                        repeat_coords.append(None)\n                except:\n                    repeat_coords.append(None)\n\n                gc.collect()\n                torch.cuda.empty_cache()\n\n            valid_preds = [c for c in repeat_coords if c is not None]\n            if valid_preds:\n                boltz_preds_dict[tid] = valid_preds\n                print(f\"  ✓ {tid}: {len(valid_preds)}/{n_samples} valid\")\n            else:\n                print(f\"  ✗ {tid}: no valid predictions\")\n\n    except ImportError:\n        print(\"  ⚠ biopandas not available, skipping Boltz\")\n    except Exception as e:\n        print(f\"  ⚠ Boltz error: {e}\")\n\n    n_success = sum(1 for v in boltz_preds_dict.values() if v)\n    print(f\"\\n✓ Boltz: {n_success}/{len(target_samples)} targets\")\n\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 14 — Combine All Predictions & Select Top 5\n# ─────────────────────────────────────────────────────────────\n\nprint(f\"\\n{'='*60}\")\nprint(\"PHASE 3: COMBINING & SELECTING TOP 5\")\nprint(f\"  Internal predictions: {N_INTERNAL_PREDS}\")\nprint(f\"  Final submission: {N_SUBMIT}\")\nprint(\"=\"*60)\n\nall_predictions = []\nstrategy_rows = []\nstart_time = time.time()\n\nfor idx, row in test_seqs.iterrows():\n    tid = row[\"target_id\"]\n    seq = row[\"sequence\"]\n    L = len(seq)\n    segments = test_segs_map.get(tid, [(0, L)])\n    longest = max(segments, key=lambda se: se[1] - se[0])\n\n    seed_val = (row.name * 99_999 + 42) % (2**32)\n    rng = np.random.default_rng(seed_val)\n\n    if idx % 10 == 0:\n        print(f\"  {idx}/{len(test_seqs)} {tid} | {time.time()-start_time:.1f}s\")\n\n    candidates = []  # (coords, source, confidence)\n\n    # 1. TBM predictions\n    tbm_preds = template_preds_dict.get(tid, [])\n    for p in tbm_preds:\n        conf = hybrid_confidence(p)\n        candidates.append((p, 'TBM', conf))\n\n    n_tbm = len(candidates)\n\n    # 2. Protenix predictions\n    ptx_preds = protenix_preds_dict.get(tid, [])\n    for p in ptx_preds:\n        if p is not None and p.shape == (L, 3):\n            refined = adaptive_rna_constraints(p.copy(), tid, test_segs_map, confidence=0.8, passes=2)\n            conf = hybrid_confidence(refined)\n            candidates.append((refined, 'Protenix', conf))\n\n    # 3. Boltz predictions\n    boltz_preds = boltz_preds_dict.get(tid, [])\n    for p in boltz_preds:\n        if p is not None and p.shape == (L, 3):\n            refined = adaptive_rna_constraints(p.copy(), tid, test_segs_map, confidence=0.8, passes=2)\n            conf = hybrid_confidence(refined)\n            candidates.append((refined, 'Boltz', conf))\n\n    # 4. Chunked Protenix predictions\n    chunked_preds = protenix_chunked_preds_dict.get(tid, [])\n    for p in chunked_preds:\n        if p is not None and p.shape == (L, 3):\n            refined = adaptive_rna_constraints(p.copy(), tid, test_segs_map, confidence=0.7, passes=2)\n            conf = hybrid_confidence(refined)\n            candidates.append((refined, 'PTX_Chunk', conf))\n\n    n_dl = len(candidates) - n_tbm\n\n    # 5. De-novo fallback if needed\n    n_denovo = 0\n    while len(candidates) < N_INTERNAL_PREDS:\n        slot_idx = len(candidates)\n\n        if candidates:\n            base = candidates[slot_idx % len(candidates)][0].copy()\n\n            transform_type = slot_idx % 5\n            if transform_type == 0:\n                X = base + rng.normal(0, 1.2, base.shape)\n            elif transform_type == 1:\n                X = apply_hinge(base, longest, rng, deg=35)\n            elif transform_type == 2:\n                X = jitter_chains(base, segments, rng, deg=20, trans=2.5)\n            elif transform_type == 3:\n                X = smooth_wiggle(base, segments, rng, amp=1.5)\n            else:\n                X = apply_hinge(base, longest, rng, deg=25)\n                X = X + rng.normal(0, 0.8, X.shape)\n        else:\n            X = generate_rna_structure(seq, seed=seed_val + slot_idx * 1000)\n\n        refined = adaptive_rna_constraints(X, tid, test_segs_map, confidence=0.3, passes=2)\n        conf = hybrid_confidence(refined)\n        candidates.append((refined, 'DeNovo', conf))\n        n_denovo += 1\n\n    # 6. Add ensemble centroid\n    non_denovo = [c[0] for c in candidates if c[1] != 'DeNovo']\n    if len(non_denovo) >= 3:\n        centroid = ensemble_centroid(non_denovo)\n        if centroid is not None and np.isfinite(centroid).all():\n            refined_centroid = adaptive_rna_constraints(centroid, tid, test_segs_map, confidence=0.9)\n            conf = hybrid_confidence(refined_centroid)\n            candidates.append((refined_centroid, 'Centroid', conf))\n\n    # ★ 按confidence排序，选择top N_SUBMIT\n    candidates.sort(key=lambda x: x[2], reverse=True)\n    final_preds = [c[0] for c in candidates[:N_SUBMIT]]\n    final_sources = [c[1] for c in candidates[:N_SUBMIT]]\n\n    # 确保有N_SUBMIT个预测\n    while len(final_preds) < N_SUBMIT:\n        X = generate_rna_structure(seq, seed=seed_val + len(final_preds) * 999)\n        refined = adaptive_rna_constraints(X, tid, test_segs_map, confidence=0.3, passes=2)\n        final_preds.append(refined)\n        final_sources.append('Fallback')\n\n    assert len(final_preds) == N_SUBMIT\n\n    for i, p in enumerate(final_preds):\n        assert isinstance(p, np.ndarray) and p.shape == (L, 3)\n        if not np.isfinite(p).all():\n            final_preds[i] = np.nan_to_num(p)\n\n    for j in range(L):\n        res = {\"ID\": f\"{tid}_{j+1}\", \"resname\": seq[j], \"resid\": j + 1}\n        for i in range(N_SUBMIT):\n            res[f\"x_{i+1}\"], res[f\"y_{i+1}\"], res[f\"z_{i+1}\"] = final_preds[i][j]\n        all_predictions.append(res)\n\n    # Strategy logging\n    source_counts = Counter(final_sources)\n    n_tbm_final = source_counts.get('TBM', 0)\n    n_protenix_final = source_counts.get('Protenix', 0)\n    n_boltz_final = source_counts.get('Boltz', 0)\n    n_chunked_final = source_counts.get('PTX_Chunk', 0)\n    n_centroid_final = source_counts.get('Centroid', 0)\n    n_denovo_final = source_counts.get('DeNovo', 0) + source_counts.get('Fallback', 0)\n\n    strategy = f\"TBM{n_tbm_final}_PTX{n_protenix_final}_BLT{n_boltz_final}\"\n    if n_chunked_final > 0:\n        strategy += f\"_CHK{n_chunked_final}\"\n    if n_centroid_final > 0:\n        strategy += \"_C\"\n    if n_denovo_final > 0:\n        strategy += f\"_DN{n_denovo_final}\"\n\n    strategy_rows.append({\n        \"target_id\": tid,\n        \"seq_len\": L,\n        \"n_candidates\": len(candidates),\n        \"n_tbm_final\": n_tbm_final,\n        \"n_protenix_final\": n_protenix_final,\n        \"n_boltz_final\": n_boltz_final,\n        \"n_chunked_final\": n_chunked_final,\n        \"n_centroid_final\": n_centroid_final,\n        \"n_denovo_final\": n_denovo_final,\n        \"sources\": \",\".join(final_sources),\n        \"strategy\": strategy,\n    })\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 15 — Save Submission\n# ─────────────────────────────────────────────────────────────\n\nsub = pd.DataFrame(all_predictions)\ncols = [\"ID\", \"resname\", \"resid\"] + [f\"{c}_{i}\" for i in range(1, N_SUBMIT+1) for c in [\"x\", \"y\", \"z\"]]\ncoord_cols = [c for c in cols if c not in (\"ID\", \"resname\", \"resid\")]\nsub[coord_cols] = sub[coord_cols].clip(-999.999, 9999.999)\nsub[cols].to_csv(\"submission.csv\", index=False)\nprint(\"\\n✓ submission.csv saved\")\n\nstrategy_df = pd.DataFrame(strategy_rows)\nstrategy_df.to_csv(\"strategy_log.csv\", index=False)\n\nprint(\"\\n\" + \"=\"*60)\nprint(\"FINAL SUMMARY\")\nprint(\"=\"*60)\n\nprint(\"\\n===== Strategy Distribution =====\")\nprint(strategy_df.groupby(\"strategy\")[\"seq_len\"].agg([\"count\", \"mean\"]).round(1).rename(\n    columns={\"count\": \"targets\", \"mean\": \"avg_len\"}))\n\nprint(\"\\n===== Source Statistics (in final 5) =====\")\nprint(f\"  TBM:        {strategy_df['n_tbm_final'].sum()}\")\nprint(f\"  Protenix:   {strategy_df['n_protenix_final'].sum()}\")\nprint(f\"  Boltz:      {strategy_df['n_boltz_final'].sum()}\")\nprint(f\"  Chunked:    {strategy_df['n_chunked_final'].sum()}\")\nprint(f\"  Centroid:   {strategy_df['n_centroid_final'].sum()}\")\nprint(f\"  DeNovo:     {strategy_df['n_denovo_final'].sum()}\")\n\nprint(\"\\n===== Per-Target Details =====\")\nprint(strategy_df[[\"target_id\", \"seq_len\", \"n_candidates\", \"sources\", \"strategy\"]]\n      .head(20).to_string(index=False))\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 16 — Optional Validation Scoring\n# ─────────────────────────────────────────────────────────────\n\ntry:\n    import runpy\n    module_globals = runpy.run_path(\"/kaggle/usr/lib/tm-score-permutechains/metric.py\")\n    score = module_globals['score']\n\n    sol = pd.read_csv(f'{BASE_PATH}/validation_labels.csv')\n    sub_val = pd.read_csv('/kaggle/working/submission.csv')\n    sol['target_id'] = sol['ID'].apply(lambda x: '_'.join(str(x).split('_')[:-1]))\n    sub_val['target_id'] = sub_val['ID'].apply(lambda x: '_'.join(str(x).split('_')[:-1]))\n\n    results_rows = []\n    for target_id, group_native in sol.groupby('target_id'):\n        group_pred = sub_val[sub_val['target_id'] == target_id]\n        if len(group_pred) == 0:\n            continue\n        tm_result = score(group_native, group_pred, 'ID')\n        strat = strategy_df[strategy_df.target_id == target_id]['strategy'].values\n        results_rows.append({\n            'target_id': target_id,\n            'strategy': strat[0] if len(strat) else 'unknown',\n            'tm_score': round(float(tm_result), 5),\n        })\n\n    if results_rows:\n        results_df = (pd.DataFrame(results_rows)\n                      .sort_values('tm_score', ascending=False)\n                      .reset_index(drop=True))\n\n        print(\"\\n===== Validation TM-Scores =====\")\n        print(results_df.head(20).to_string(index=False))\n        print(f\"\\nMean TM-score: {results_df['tm_score'].mean():.5f} (n={len(results_df)})\")\n\n        print(\"\\n===== By Strategy =====\")\n        print(results_df.groupby('strategy')['tm_score'].agg(['mean', 'count']).round(5))\n\nexcept Exception as e:\n    print(f\"\\nValidation scoring skipped: {e}\")\n\nprint(\"\\n✓ Pipeline complete!\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-15T16:07:10.128706Z","iopub.execute_input":"2026-03-15T16:07:10.129458Z","iopub.status.idle":"2026-03-15T17:29:41.907232Z","shell.execute_reply.started":"2026-03-15T16:07:10.12943Z","shell.execute_reply":"2026-03-15T17:29:41.906318Z"}},"outputs":[],"execution_count":null}]}