{"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":[{"sourceId":118765,"databundleVersionId":15231210,"isSourceIdPinned":false,"sourceType":"competition"},{"sourceId":10855324,"sourceType":"datasetVersion","datasetId":6742586},{"sourceId":11118830,"sourceType":"datasetVersion","datasetId":6933267},{"sourceId":14519720,"sourceType":"datasetVersion","datasetId":9271415},{"sourceId":14604295,"sourceType":"datasetVersion","datasetId":9328538},{"sourceId":14786962,"sourceType":"datasetVersion","datasetId":9447634},{"sourceId":14787388,"sourceType":"datasetVersion","datasetId":9453383},{"sourceId":290004465,"sourceType":"kernelVersion"},{"sourceId":297543755,"sourceType":"kernelVersion"},{"sourceId":311741,"sourceType":"modelInstanceVersion","isSourceIdPinned":false,"modelInstanceId":264400,"modelId":285488}],"dockerImageVersionId":31260,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"# ============================================================\n# Stanford RNA 3D Folding — Tri-Model Hybrid Submission\n#\n# Combines three prediction sources per target:\n#   1. Best Enhanced TBM  (template-based modelling, enhanced)\n#   2. Best Boltz-2       (deep-learning, Boltz repeat-0)\n#   3. Best RNAPro        (deep-learning, 500M model)\n#   4. 50/50 blend        (Boltz + TBM, centroid-aligned)\n#   5. 50/50 blend        (RNAPro + Boltz, centroid-aligned)\n#\n# Fallback tiers (applied per-target):\n#   Tier A — all three available        → strategy above\n#   Tier B — RNAPro unavailable         → TBM, Boltz, blend, hinge, jitter\n#   Tier C — Boltz unavailable          → TBM, RNAPro, blend, hinge, jitter\n#   Tier D — only TBM available         → 5 geometric variants of TBM\n#\n# All predictions are refined with adaptive_rna_constraints for\n# physical validity (bond lengths / clash prevention).\n# ============================================================\n\nimport os, sys, gc, json, shutil, time, warnings, random\nimport numpy as np\nimport pandas as pd\nimport torch\nwarnings.filterwarnings(\"ignore\")\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 0 — Install dependencies & model sources\n# ─────────────────────────────────────────────────────────────\n\nIS_SCORING_RUN = os.environ.get('KAGGLE_IS_COMPETITION_RERUN')\nprint(f\"IS_SCORING_RUN = {IS_SCORING_RUN}\")\n\n# ── Biopython ────────────────────────────────────────────────\n# (Adjust wheel path for your Kaggle dataset input)\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\"\n)\n\n# ── Boltz ────────────────────────────────────────────────────\nos.system(\"cp -r /kaggle/input/datasets/lbugnon/boltz-src-minimal ./\")\nos.system(\"pip install --no-index --no-build-isolation -e ./boltz-src-minimal\")\nos.system(\"mkdir -p boltz_cache\")\nos.system(\"cp -r /kaggle/input/datasets/lbugnon/boltz2 boltz_cache\")\nos.system(\"mv boltz_cache/boltz2/mols/mols/* boltz_cache/boltz2/mols/\")\nos.system(\"rm -r boltz_cache/boltz2/mols/mols/\")\nos.system(\"tar -cf boltz_cache/boltz2/mols.tar boltz_cache/boltz2/mols\")\n\n# ── RNAPro ───────────────────────────────────────────────────\nos.system(\"cp -r /kaggle/input/rnapro-src/RNAPro .\")\nos.system(\"cp /kaggle/input/rnapro-src/rnapro-private-best-500m.ckpt .\")\nos.chdir(\"RNAPro\")\nos.system(\"pip install -e . --no-deps\")\nos.chdir(\"..\")\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 1 — Imports (all heavy imports after installs)\n# ─────────────────────────────────────────────────────────────\n\nfrom tqdm.auto import tqdm\nfrom collections import Counter\nfrom random import shuffle\nfrom scipy.cluster.hierarchy import linkage, fcluster\nfrom scipy.spatial.distance import pdist\nfrom scipy.spatial import distance_matrix\nfrom scipy.spatial.transform import Rotation as R_scipy\n\nfrom Bio.Align import PairwiseAligner\nfrom Bio.Seq import Seq\n\nseed = 21\nnp.random.seed(seed)\nrandom.seed(seed)\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 2 — Data ingestion\n# ─────────────────────────────────────────────────────────────\n\nBASE_PATH = '/kaggle/input/stanford-rna-3d-folding-2'\n\ntest_seqs        = 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')\nsample_submission = pd.read_csv(f'{BASE_PATH}/sample_submission.csv')\n\nprint(f\"✓ {len(train_seqs)} training | {len(validation_seqs)} validation | {len(test_seqs)} test sequences\")\n\n# ── FASTA / stoichiometry helpers ────────────────────────────\n\nnucleotides = {\"A\", \"G\", \"C\", \"U\"}\naminoacids  = {\"A\",\"R\",\"N\",\"D\",\"C\",\"E\",\"Q\",\"G\",\"H\",\"I\",\"L\",\"K\",\"M\",\"F\",\"P\",\"S\",\"T\",\"W\",\"Y\",\"V\"}\n\ndef parse_fasta(fasta_content: str):\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]; 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):\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):\n    seq, stoich, all_seq = row[\"sequence\"], row.get(\"stoichiometry\", \"\"), row.get(\"all_sequences\", \"\")\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    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))); 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):\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 3 — Labels → coordinate dictionaries\n# ─────────────────────────────────────────────────────────────\n\ndef process_labels(labels_df: pd.DataFrame):\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    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    coords_dict = {}\n    for tid, grp in tqdm(labels_df.groupby('target_id', sort=False),\n                         desc=\"Processing structures\"):\n        coords_dict[tid] = grp[coord_cols].values\n    return coords_dict\n\ntrain_coords_dict = process_labels(train_labels)\nvalid_coords_dict = process_labels(validation_labels)\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 4 — Enhanced TBM: aligners, features, search\n# ─────────────────────────────────────────────────────────────\n\ndef create_global_aligner():\n    a = PairwiseAligner(); a.mode = \"global\"\n    a.match_score = 2; a.mismatch_score = -1.5\n    a.open_gap_score = -8; a.extend_gap_score = -0.4\n    for attr in [\"query_left\",\"query_right\",\"target_left\",\"target_right\"]:\n        setattr(a, f\"{attr}_open_gap_score\",  -8)\n        setattr(a, f\"{attr}_extend_gap_score\", -0.4)\n    return a\n\ndef create_local_aligner():\n    a = PairwiseAligner(); a.mode = \"local\"\n    a.match_score = 2; a.mismatch_score = -1.5\n    a.open_gap_score = -6; a.extend_gap_score = -0.3\n    return a\n\nglobal_aligner = create_global_aligner()\nlocal_aligner  = create_local_aligner()\n\ndef compute_gc_content(seq):\n    s = seq.upper()\n    return (s.count('G') + s.count('C')) / len(s) if s else 0.0\n\ndef compute_dinucleotide_frequencies(seq):\n    s = seq.upper(); nucs = ['A','C','G','U']\n    dinucs = [a+b for a in nucs for b in nucs]\n    counts = Counter(s[i:i+2] for i in range(len(s)-1) if s[i:i+2] in dinucs)\n    v = np.array([counts.get(d, 0) for d in dinucs], dtype=float)\n    t = v.sum()\n    return v / t if t > 0 else v\n\ndef compute_sequence_entropy(seq):\n    counts = Counter(seq.upper()); total = len(seq)\n    if not total: return 0.0\n    return -sum((c/total)*np.log2(c/total) for c in counts.values() if c > 0)\n\ndef compute_kmer_overlap(s1, s2, k=3):\n    if len(s1) < k or len(s2) < k: return 0.0\n    k1 = set(s1[i:i+k] for i in range(len(s1)-k+1))\n    k2 = set(s2[i:i+k] for i in range(len(s2)-k+1))\n    inter, union = len(k1 & k2), len(k1 | k2)\n    return inter / union if union else 0.0\n\ndef compute_feature_similarity(q, t):\n    gc_sim    = 1.0 - abs(compute_gc_content(q) - compute_gc_content(t))\n    d1, d2    = compute_dinucleotide_frequencies(q), compute_dinucleotide_frequencies(t)\n    n1, n2    = np.linalg.norm(d1), np.linalg.norm(d2)\n    dinuc_sim = np.dot(d1, d2) / (n1*n2) if n1 > 0 and n2 > 0 else 0.0\n    ent_sim   = 1.0 - abs(compute_sequence_entropy(q) - compute_sequence_entropy(t)) / 2.0\n    return (gc_sim + dinuc_sim + ent_sim) / 3.0\n\ndef compute_composite_score(q, t):\n    ml = min(len(q), len(t))\n    if ml == 0:\n        return {'composite_score': 0.0, 'global_score': 0.0,\n                'local_score': 0.0, 'feature_score': 0.0, 'kmer_score': 0.0}\n    g_n = max(0.0, min(1.0, global_aligner.score(q, t) / (2*ml)))\n    l_n = max(0.0, min(1.0, local_aligner.score(q, t)  / (2*ml)))\n    f_s = compute_feature_similarity(q, t)\n    k_s = compute_kmer_overlap(q, t, k=3)\n    comp = 0.4*g_n+0.2*l_n+0.25*f_s+0.1*k_s\n    return {'global_score': g_n, 'local_score': l_n,\n            'feature_score': f_s, 'kmer_score': k_s, 'composite_score': comp}\n\ndef extract_template_features(seq):\n    return np.concatenate([[compute_gc_content(seq)],\n                           compute_dinucleotide_frequencies(seq),\n                           [compute_sequence_entropy(seq)],\n                           [np.log10(len(seq)+1)]])\n\ndef cluster_and_diversify(candidates, n_final, distance_threshold=0.3):\n    if len(candidates) <= n_final: return candidates\n    feats = np.array([extract_template_features(c[1]) for c in candidates])\n    feats = (feats - feats.mean(0)) / (feats.std(0) + 1e-8)\n    try:\n        n_clus = min(len(candidates), max(n_final, int(len(candidates)*0.3)))\n        labels = fcluster(linkage(pdist(feats,'euclidean'), 'ward'), n_clus, 'maxclust')\n    except Exception:\n        labels = np.arange(len(candidates)) + 1\n    reps = {}\n    for idx, lab in enumerate(labels):\n        if lab not in reps or candidates[idx][2] > reps[lab][2]:\n            reps[lab] = candidates[idx]\n    return sorted(reps.values(), key=lambda x: x[2], reverse=True)[:n_final]\n\ndef get_adaptive_length_threshold(qlen):\n    return 0.60 if qlen < 50 else (0.20 if qlen > 1000 else 0.40)\n\ndef passes_length_filter(qlen, tlen):\n    return abs(tlen - qlen) / max(tlen, qlen) <= get_adaptive_length_threshold(qlen)\n\ndef find_similar_sequences_enhanced(query_seq, train_seqs_df, train_coords_dict,\n                                    top_n=5, candidate_pool_size=30,\n                                    enable_clustering=True, temporal_cutoff=None):\n    \"\"\"Returns (candidates_list, best_score_dict).\"\"\"\n    qlen, candidates = len(query_seq), []\n    filtered = (train_seqs_df[train_seqs_df['temporal_cutoff'] < temporal_cutoff]\n                if temporal_cutoff else train_seqs_df)\n    for _, row in filtered.iterrows():\n        tid, tseq = row[\"target_id\"], row[\"sequence\"]\n        if tid not in train_coords_dict: continue\n        if not passes_length_filter(qlen, len(tseq)): continue\n        sd = compute_composite_score(query_seq, tseq)\n        candidates.append((tid, tseq, sd['composite_score'], train_coords_dict[tid], sd))\n\n    candidates.sort(key=lambda x: x[2], reverse=True)\n    candidates = candidates[:candidate_pool_size]\n\n    if enable_clustering and len(candidates) > top_n:\n        slim     = [(c[0],c[1],c[2],c[3]) for c in candidates]\n        sdlookup = {c[0]: c[4] for c in candidates}\n        slim_div = cluster_and_diversify(slim, n_final=top_n)\n        final    = [(c[0],c[1],c[2],c[3],sdlookup.get(c[0],{})) for c in slim_div]\n    else:\n        final = candidates[:top_n]\n\n    best_scores = final[0][4] if final else {}\n    return [(c[0],c[1],c[2],c[3]) for c in final], best_scores\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 5 — Template transfer + geometry refinement\n# ─────────────────────────────────────────────────────────────\n\ndef adapt_template_to_query(query_seq, template_seq, template_coords):\n    alignment  = next(iter(global_aligner.align(query_seq, template_seq)))\n    new_coords = np.full((len(query_seq), 3), np.nan, dtype=float)\n    for (q_s,q_e),(t_s,t_e) in zip(*alignment.aligned):\n        chunk = template_coords[t_s:t_e]\n        if len(chunk) == (q_e - q_s):\n            new_coords[q_s:q_e] = chunk\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: new_coords[i] = new_coords[pv] + [3,0,0]\n            elif nv >= 0: new_coords[i] = new_coords[nv] + [3,0,0]\n            else:         new_coords[i] = [i*3, 0, 0]\n    return np.nan_to_num(new_coords)\n\ndef adaptive_rna_constraints(coordinates, target_id_or_segs, confidence=1.0, passes=2):\n    \"\"\"Physical constraint refinement. Accepts either target_id string or segments list.\"\"\"\n    coords = coordinates.copy()\n    if isinstance(target_id_or_segs, str):\n        segments = test_segs_map.get(target_id_or_segs, [(0, len(coords))])\n    else:\n        segments = target_id_or_segs if target_id_or_segs else [(0, len(coords))]\n    strength = max(0.02, 0.75*(1.0 - min(confidence, 0.90)))\n    for _ in range(passes):\n        for (s, e) in segments:\n            X = coords[s:e]; L = e - s\n            if L < 3: coords[s:e] = X; continue\n            d    = X[1:]-X[:-1]; dist = np.linalg.norm(d,axis=1)+1e-6\n            adj  = (d*(5.95-dist)[:,None]/dist[:,None])*(0.22*strength)\n            X[:-1] -= adj; X[1:] += adj\n            d2   = X[2:]-X[:-2]; dist2 = np.linalg.norm(d2,axis=1)+1e-6\n            adj2 = (d2*(10.2-dist2)[:,None]/dist2[:,None])*(0.10*strength)\n            X[:-2] -= adj2; X[2:] += adj2\n            lap  = 0.5*(X[:-2]+X[2:])-X[1:-1]\n            X[1:-1] += (0.06*strength)*lap\n            if L >= 25:\n                k   = min(L,160) if L > 220 else L\n                idx = np.linspace(0,L-1,k).astype(int) if k < L else np.arange(L)\n                P   = X[idx]; 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                    force = (3.2-dm)/dm\n                    vec   = (diff*force[:,:,None]*mask[:,:,None]).sum(axis=1)\n                    X[idx] += (0.015*strength)*vec\n            coords[s:e] = X\n    return coords\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 6 — Geometric transform helpers\n# ─────────────────────────────────────────────────────────────\n\ndef _rotmat(axis, ang):\n    axis = np.asarray(axis, float)\n    axis = axis / (np.linalg.norm(axis)+1e-12)\n    x,y,z = axis; c,s = np.cos(ang),np.sin(ang); C = 1-c\n    return np.array([[c+x*x*C,    x*y*C-z*s, x*z*C+y*s],\n                     [y*x*C+z*s, c+y*y*C,    y*z*C-x*s],\n                     [z*x*C-y*s, z*y*C+x*s, c+z*z*C   ]], float)\n\ndef apply_hinge(coords, seg, rng, max_angle_deg=25):\n    s, e = seg; L = e - s\n    if L < 30: return coords\n    pivot = s + int(rng.integers(10, L-10))\n    Rmat  = _rotmat(rng.normal(size=3),\n                    np.deg2rad(float(rng.uniform(-max_angle_deg, max_angle_deg))))\n    X = coords.copy(); p0 = X[pivot].copy()\n    X[pivot+1:e] = (X[pivot+1:e]-p0) @ Rmat.T + p0\n    return X\n\ndef jitter_chains(coords, segments, rng, max_angle_deg=15, max_trans=1.5):\n    X = coords.copy(); gc = X.mean(0, keepdims=True)\n    for (s,e) in segments:\n        Rmat = _rotmat(rng.normal(size=3),\n                       np.deg2rad(float(rng.uniform(-max_angle_deg, max_angle_deg))))\n        sh   = rng.normal(size=3)\n        sh   = sh/(np.linalg.norm(sh)+1e-12)*float(rng.uniform(0, max_trans))\n        c    = X[s:e].mean(0, keepdims=True)\n        X[s:e] = (X[s:e]-c) @ Rmat.T + c + sh\n    X -= X.mean(0, keepdims=True) - gc\n    return X\n\ndef smooth_wiggle(coords, segments, rng, amp=0.8):\n    X = coords.copy()\n    for (s,e) in segments:\n        L = e - s\n        if L < 25: continue\n        ctrl_x = np.linspace(0, L-1, 6)\n        ctrl_d = rng.normal(0, amp, (6,3))\n        t      = np.arange(L)\n        X[s:e] += np.vstack([np.interp(t, ctrl_x, ctrl_d[:,k]) for k in range(3)]).T\n    return X\n\ndef centroid_align_and_blend(arr1, arr2, w1, w2):\n    \"\"\"Blend two coordinate sets after centroid-aligning each.\"\"\"\n    c1, c2  = arr1.mean(axis=0), arr2.mean(axis=0)\n    blended = w1*(arr1-c1) + w2*(arr2-c2)\n    # restore the Boltz/secondary centroid position\n    blended += w1*c1 + w2*c2\n    return blended\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 7 — Build enhanced TBM predictions (5 per target)\n# ─────────────────────────────────────────────────────────────\n\ntarget_score_log  = {}   # { target_id: score_dict }\ntemplate_preds_dict = {}  # { target_id: [pred_1 .. pred_5] }\n\ndef build_template_predictions(row, n_predictions=5):\n    tid      = row[\"target_id\"]\n    seq      = row[\"sequence\"]\n    segments = test_segs_map.get(tid, [(0, len(seq))])\n    cutoff   = row.get('temporal_cutoff', None)\n\n    cands, best_scores = find_similar_sequences_enhanced(\n        query_seq=seq, train_seqs_df=train_seqs,\n        train_coords_dict=train_coords_dict,\n        top_n=30, candidate_pool_size=50,\n        enable_clustering=True, temporal_cutoff=cutoff)\n\n    target_score_log[tid] = {k: round(v,4) for k,v in best_scores.items()}\n\n    predictions, used = [], set()\n    for i in range(n_predictions):\n        seed_val = (row.name * 10_000_000_000 + i * 10007) % (2**32)\n        rng = np.random.default_rng(seed_val)\n\n        if not cands:\n            coords = np.zeros((len(seq), 3), float)\n            for (s,e) in segments:\n                for j in range(s+1, e): coords[j] = coords[j-1] + [5.95,0,0]\n            predictions.append(coords); continue\n\n        if i == 0:\n            t_id, t_seq, sim, t_coords = cands[0]\n        else:\n            K    = min(12, len(cands))\n            sims = np.array([cands[k][2] for k in range(K)], float)\n            w    = np.exp((sims - sims.max()) / 0.08)\n            for k in range(K):\n                if cands[k][0] in used: w[k] *= 0.10\n            w    = w / (w.sum()+1e-12)\n            k    = int(rng.choice(np.arange(K), p=w))\n            t_id, t_seq, sim, t_coords = cands[k]\n        used.add(t_id)\n\n        adapted = adapt_template_to_query(seq, t_seq, t_coords)\n\n        if   i == 0: X = adapted\n        elif i == 1: X = adapted + rng.normal(0, max(0.01,(0.40-sim)*0.06), adapted.shape)\n        elif i == 2:\n            longest = max(segments, key=lambda se: se[1]-se[0])\n            X = apply_hinge(adapted, longest, rng, max_angle_deg=22)\n        elif i == 3: X = jitter_chains(adapted, segments, rng, 10, 1.0)\n        else:        X = smooth_wiggle(adapted, segments, rng, 0.7)\n\n        refined = adaptive_rna_constraints(X, tid, confidence=sim, passes=2)\n        predictions.append(refined)\n\n    return predictions\n\nprint(\"Building enhanced TBM predictions for all test targets...\")\nfor idx, row in tqdm(test_seqs.iterrows(), total=len(test_seqs), desc=\"TBM\"):\n    template_preds_dict[row[\"target_id\"]] = build_template_predictions(row)\n\n# Also save TBM predictions as CSV (needed as RNAPro templates)\ntbm_rows = []\nfor idx, row in test_seqs.iterrows():\n    tid  = row[\"target_id\"]\n    seq  = row[\"sequence\"]\n    preds = template_preds_dict[tid]\n    for j in range(len(seq)):\n        r = {\"ID\": f\"{tid}_{j+1}\", \"resname\": seq[j], \"resid\": j+1}\n        for i in range(5):\n            r[f\"x_{i+1}\"] = preds[i][j][0]\n            r[f\"y_{i+1}\"] = preds[i][j][1]\n            r[f\"z_{i+1}\"] = preds[i][j][2]\n        tbm_rows.append(r)\n\nsub_tbm = pd.DataFrame(tbm_rows)\ncol_order = [\"ID\",\"resname\",\"resid\"] + \\\n            [f\"{c}_{i}\" for i in range(1,6) for c in [\"x\",\"y\",\"z\"]]\nsub_tbm = sub_tbm[col_order]\nsub_tbm.to_csv(\"/kaggle/working/submission_tbm.csv\", index=False)\nprint(\"✓ submission_tbm.csv saved\")\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 8 — RNAPro inference\n# ─────────────────────────────────────────────────────────────\n\n# Convert enhanced TBM CSV to .pt template file for RNAPro\nos.chdir(\"RNAPro\")\nos.system(\n    \"python preprocess/convert_templates_to_pt_files.py \"\n    \"--input_csv /kaggle/working/submission_tbm.csv \"\n    \"--output_name templates.pt\"\n)\nos.chdir(\"..\")\n\n# Copy required CIF reference files\nDIST = \"/kaggle/working/RNAPro/release_data/ccd_cache/\"\nos.makedirs(DIST, exist_ok=True)\nos.system(f\"cp /kaggle/input/protenix-checkpoints/components.v20240608.cif {DIST}\")\nos.system(f\"cp /kaggle/input/protenix-checkpoints/components.v20240608.cif.rdkit_mol.pkl {DIST}\")\n\n# Write the sequences to process (full set in scoring run, else head-5)\ndf_seq = pd.read_csv(f'{BASE_PATH}/test_sequences.csv')\nif not IS_SCORING_RUN:\n    df_seq = df_seq.head(5)\ndf_seq.to_csv('/kaggle/working/sample_sequences.csv', index=False)\n\n# Write the RNAPro inference runner script\nrnapro_runner_code = r\"\"\"\nimport os, shutil, logging, traceback, warnings, argparse, json\nfrom contextlib import nullcontext\nfrom os.path import join as opjoin\nfrom typing import Any, Mapping\nimport torch\nimport pandas as pd\nimport numpy as np\nfrom biotite.structure.io import pdbx\n\nfrom configs.configs_base import configs as configs_base\nfrom configs.configs_data import data_configs\nfrom configs.configs_inference import inference_configs\nfrom runner.dumper import DataDumper\nfrom rnapro.config import parse_sys_args\nfrom rnapro.config.config import ConfigManager, ArgumentNotSet\nfrom rnapro.data.infer_data_pipeline import get_inference_dataloader\nfrom rnapro.model.RNAPro import RNAPro\nfrom rnapro.utils.distributed import DIST_WRAPPER\nfrom rnapro.utils.seed import seed_everything\nfrom rnapro.utils.torch_utils import to_device\n\nwarnings.filterwarnings(\"ignore\", category=UserWarning)\nwarnings.filterwarnings(\"ignore\", category=FutureWarning)\nwarnings.filterwarnings(\"ignore\", category=DeprecationWarning)\nlogging.basicConfig(level=logging.WARNING)\nlogging.getLogger(\"rnapro\").setLevel(logging.WARNING)\n\ndef parse_configs(configs, arg_str=None, fill_required_with_null=False):\n    manager = ConfigManager(configs, fill_required_with_null=fill_required_with_null)\n    parser = argparse.ArgumentParser()\n    parser.add_argument(\"--max_len\", type=int, default=10000, required=False)\n    for key, (dtype, default_value, allow_none, required) in manager.config_infos.items():\n        parser.add_argument(\"--\" + key, type=str, default=ArgumentNotSet(), required=required)\n    merged_configs = manager.merge_configs(\n        vars(parser.parse_args(arg_str.split())) if arg_str else {})\n    merged_configs.max_len = parser.parse_args(arg_str.split()).max_len\n    return merged_configs\n\nclass dotdict(dict):\n    __setattr__ = dict.__setitem__; __delattr__ = dict.__delitem__\n    def __getattr__(self, name):\n        try: return self[name]\n        except KeyError: raise AttributeError(name)\n\nclass InferenceRunner:\n    def __init__(self, configs):\n        self.configs = configs\n        self.init_env(); self.init_basics(); self.init_model()\n        self.load_checkpoint()\n        self.init_dumper(need_atom_confidence=configs.need_atom_confidence,\n                         sorted_by_ranking_score=configs.sorted_by_ranking_score)\n    def init_env(self):\n        self.use_cuda = torch.cuda.device_count() > 0\n        self.device = torch.device(\"cuda:{}\".format(DIST_WRAPPER.local_rank) if self.use_cuda else \"cpu\")\n        if self.use_cuda: torch.cuda.set_device(self.device)\n    def init_basics(self):\n        self.dump_dir = self.configs.dump_dir; self.error_dir = opjoin(self.dump_dir, \"ERR\")\n        os.makedirs(self.dump_dir, exist_ok=True); os.makedirs(self.error_dir, exist_ok=True)\n    def init_model(self):\n        self.model = RNAPro(self.configs).to(self.device)\n    def load_checkpoint(self):\n        ckpt_path = self.configs.load_checkpoint_path\n        if not os.path.exists(ckpt_path): raise Exception(f\"Checkpoint missing: {ckpt_path}\")\n        ckpt = torch.load(ckpt_path, self.device)\n        if list(ckpt[\"model\"].keys())[0].startswith(\"module.\"):\n            ckpt[\"model\"] = {k[7:]: v for k,v in ckpt[\"model\"].items()}\n        self.model.load_state_dict(state_dict=ckpt[\"model\"], strict=True)\n        self.model.eval()\n    def init_dumper(self, need_atom_confidence=False, sorted_by_ranking_score=True):\n        self.dumper = DataDumper(base_dir=self.dump_dir,\n                                 need_atom_confidence=need_atom_confidence,\n                                 sorted_by_ranking_score=sorted_by_ranking_score)\n    @torch.no_grad()\n    def predict(self, data):\n        prec = {\"fp32\": torch.float32, \"bf16\": torch.bfloat16, \"fp16\": torch.float16}[self.configs.dtype]\n        ctx = torch.autocast(device_type=\"cuda\", dtype=prec) if torch.cuda.is_available() else nullcontext()\n        data = to_device(data, self.device)\n        with ctx:\n            prediction, _, _ = self.model(input_feature_dict=data[\"input_feature_dict\"],\n                                          label_full_dict=None, label_dict=None, mode=\"inference\")\n        return prediction\n    def print(self, msg):\n        if DIST_WRAPPER.rank == 0: print(msg)\n    def update_model_configs(self, new_configs):\n        self.model.configs = new_configs\n\ndef update_inference_configs(configs, N_token):\n    if N_token > 3840:   configs.skip_amp.confidence_head = False; configs.skip_amp.sample_diffusion = False\n    elif N_token > 2560: configs.skip_amp.confidence_head = False; configs.skip_amp.sample_diffusion = True\n    else:                configs.skip_amp.confidence_head = True;  configs.skip_amp.sample_diffusion = True\n    return configs\n\ndef infer_predict(runner, configs):\n    try: dataloader = get_inference_dataloader(configs=configs)\n    except Exception as e:\n        with open(opjoin(runner.error_dir, \"error.txt\"), \"a\") as f: f.write(f\"{e}\\n{traceback.format_exc()}\")\n        return\n    num_data = len(dataloader.dataset)\n    for seed in configs.seeds:\n        seed_everything(seed=seed, deterministic=configs.deterministic)\n        for batch in dataloader:\n            try:\n                data, atom_array, data_error_message = batch[0]\n                sample_name = data[\"sample_name\"]\n                if len(data_error_message) > 0:\n                    with open(opjoin(runner.error_dir, f\"{sample_name}.txt\"), \"a\") as f: f.write(data_error_message)\n                    continue\n                new_configs = update_inference_configs(configs, data[\"N_token\"].item())\n                runner.update_model_configs(new_configs)\n                prediction = runner.predict(data)\n                runner.dumper.dump(dataset_name=\"\", pdb_id=sample_name, seed=seed,\n                                   pred_dict=prediction, atom_array=atom_array,\n                                   entity_poly_type=data[\"entity_poly_type\"])\n                torch.cuda.empty_cache()\n            except Exception as e:\n                with open(opjoin(runner.error_dir, f\"{sample_name}.txt\"), \"a\") as f:\n                    f.write(f\"{e}\\n{traceback.format_exc()}\")\n                if hasattr(torch.cuda, \"empty_cache\"): torch.cuda.empty_cache()\n\ndef make_dummy_solution(df):\n    sol = dotdict()\n    for _, r in df.iterrows():\n        sol[r.target_id] = dotdict(target_id=r.target_id, sequence=r.sequence, coord=[])\n    return sol\n\ndef solution_to_submit_df(solution):\n    frames = []\n    for k, s in solution.items():\n        L = len(s.sequence)\n        df = pd.DataFrame()\n        df[\"ID\"] = [f\"{s.target_id}_{i+1}\" for i in range(L)]\n        df[\"resname\"] = list(s.sequence); df[\"resid\"] = list(range(1, L+1))\n        for j, c in enumerate(s.coord):\n            df[f\"x_{j+1}\"] = c[:,0]; df[f\"y_{j+1}\"] = c[:,1]; df[f\"z_{j+1}\"] = c[:,2]\n        frames.append(df)\n    return pd.concat(frames)\n\ndef extract_c1_coordinates(cif_file_path):\n    try:\n        with open(cif_file_path, \"r\") as f: cif_data = pdbx.CIFFile.read(f)\n        atom_array = pdbx.get_structure(cif_data, model=1)\n        mask  = np.char.strip(atom_array.atom_name.astype(str)) == \"C1'\"\n        c1    = atom_array[mask]\n        if len(c1) == 0: return None\n        idx   = np.argsort(c1.res_id)\n        return c1[idx].coord\n    except Exception as e:\n        print(f\"Error extracting C1' from {cif_file_path}: {e}\"); return None\n\ndef create_input_json(sequence, target_id):\n    return [{\"sequences\": [{\"rnaSequence\": {\"sequence\": sequence, \"count\": 1}}], \"name\": target_id}]\n\ndef run_ptx(target_id, sequence, configs, solution, template_idx, runner):\n    temp_dir = f\"./{configs.dump_dir}/input\"; output_dir = f\"./{configs.dump_dir}/output\"\n    os.makedirs(temp_dir, exist_ok=True); os.makedirs(output_dir, exist_ok=True)\n    json_path = os.path.join(temp_dir, f\"{target_id}_input.json\")\n    with open(json_path, \"w\") as f: json.dump(create_input_json(sequence, target_id), f)\n    configs.input_json_path = json_path; configs.template_idx = int(template_idx)\n    infer_predict(runner, configs)\n    cif_path = f\"{configs.dump_dir}/{target_id}/seed_42/predictions/{target_id}_sample_0.cif\"\n    coord = extract_c1_coordinates(cif_path)\n    if coord is None: coord = np.zeros((len(sequence), 3), np.float32)\n    elif coord.shape[0] < len(sequence):\n        coord = np.concatenate([coord, np.zeros((len(sequence)-coord.shape[0], 3), np.float32)])\n    solution[target_id].coord.append(coord)\n\ndef run():\n    configs_base[\"use_deepspeed_evo_attention\"] = \\\n        os.environ.get(\"USE_DEEPSPEED_EVO_ATTENTION\", False) == \"true\"\n    configs = {**configs_base, **{\"data\": data_configs}, **inference_configs}\n    configs = parse_configs(configs=configs, arg_str=parse_sys_args(), fill_required_with_null=True)\n    valid_df = pd.read_csv(configs.sequences_csv)\n    print(f\"\\n -> {len(valid_df)} sequence(s) to process\")\n    runner  = InferenceRunner(configs)\n    solution = make_dummy_solution(valid_df)\n    for idx, row in valid_df.iterrows():\n        print(f\"\\n -> {row.target_id} (len={len(row.sequence)})\")\n        if len(row.sequence) > configs.max_len:\n            print(f\"    Skipping — too long (> {configs.max_len})\")\n            for _ in range(5): solution[row.target_id].coord.append(np.zeros((len(row.sequence),3), np.float32))\n            continue\n        try:\n            for template_idx in range(5):\n                run_ptx(row.target_id, row.sequence, configs, solution, template_idx, runner)\n        except Exception as e:\n            print(f\"    ERROR: {e}\")\n    submit_df = solution_to_submit_df(solution).fillna(0.0)\n    submit_df.to_csv(\"./submission_rnapro.csv\", index=False)\n    print(\"\\n -> Saved submission_rnapro.csv\")\n\nif __name__ == \"__main__\":\n    run()\n\"\"\"\nwith open(\"RNAPro/runner/inference.py\", \"w\") as f:\n    f.write(rnapro_runner_code)\n\n# Write the bash launcher\nrnapro_sh = \"\"\"#!/usr/bin/env bash\nexport LAYERNORM_TYPE=torch\n\nSEED=42\nN_SAMPLE=1\nN_STEP=200\nN_CYCLE=10\n\nDUMP_DIR=\"../rnapro_output\"\nCHECKPOINT_PATH=\"../rnapro-private-best-500m.ckpt\"\nTEMPLATE_DATA=\"./release_data/kaggle/templates.pt\"\nRNA_MSA_DIR=\"/kaggle/input/stanford-rna-3d-folding-2/MSA\"\nSEQUENCES_CSV=\"/kaggle/working/sample_sequences.csv\"\nRIBONANZA_PATH=\"/kaggle/input/ribonanzanet2/pytorch/alpha/1/\"\nMODEL_NAME=\"rnapro_base\"\n\nmkdir -p \"${DUMP_DIR}\"\n\npython3 runner/inference.py \\\\\n    --model_name \"${MODEL_NAME}\" \\\\\n    --seeds ${SEED} \\\\\n    --dump_dir \"${DUMP_DIR}\" \\\\\n    --load_checkpoint_path \"${CHECKPOINT_PATH}\" \\\\\n    --use_msa true \\\\\n    --use_template \"ca_precomputed\" \\\\\n    --model.use_template \"ca_precomputed\" \\\\\n    --model.use_RibonanzaNet2 true \\\\\n    --model.template_embedder.n_blocks 2 \\\\\n    --model.ribonanza_net_path \"${RIBONANZA_PATH}\" \\\\\n    --template_data \"${TEMPLATE_DATA}\" \\\\\n    --template_idx 0 \\\\\n    --rna_msa_dir \"${RNA_MSA_DIR}\" \\\\\n    --model.N_cycle ${N_CYCLE} \\\\\n    --sample_diffusion.N_sample ${N_SAMPLE} \\\\\n    --sample_diffusion.N_step ${N_STEP} \\\\\n    --load_strict true \\\\\n    --num_workers 0 \\\\\n    --triangle_attention \"torch\" \\\\\n    --triangle_multiplicative \"torch\" \\\\\n    --sequences_csv \"${SEQUENCES_CSV}\" \\\\\n    --max_len 1000\n\"\"\"\nwith open(\"RNAPro/rnapro_inference_kaggle.sh\", \"w\") as f:\n    f.write(rnapro_sh)\n\nos.chdir(\"RNAPro\")\nos.system(\"bash ./rnapro_inference_kaggle.sh\")\nos.chdir(\"..\")\nos.system(\"cp rnapro_output/submission_rnapro.csv /kaggle/working/submission_rnapro.csv\")\nprint(\"✓ RNAPro inference done\")\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 9 — Collect RNAPro coordinate arrays\n# ─────────────────────────────────────────────────────────────\n\n# rnapro_preds_dict[target_id] = list of up to 5 (L,3) arrays (one per template_idx)\nrnapro_preds_dict = {}\n\ntry:\n    df_rnapro = pd.read_csv(\"/kaggle/working/submission_rnapro.csv\")\n    df_rnapro[\"target_id\"] = df_rnapro[\"ID\"].str.rsplit(\"_\", n=1).str[0]\n\n    for tid, grp in df_rnapro.groupby(\"target_id\", sort=False):\n        grp = grp.sort_values(\"resid\")\n        preds = []\n        for k in range(1, 6):\n            cols = [f\"x_{k}\", f\"y_{k}\", f\"z_{k}\"]\n            if all(c in grp.columns for c in cols):\n                arr = grp[cols].values.astype(float)\n                if np.isfinite(arr).all() and not np.all(arr == 0):\n                    preds.append(arr)\n        rnapro_preds_dict[tid] = preds if preds else []\n\n    n_rnapro = sum(1 for v in rnapro_preds_dict.values() if v)\n    print(f\"✓ RNAPro predictions collected for {n_rnapro}/{len(test_seqs)} targets\")\nexcept FileNotFoundError:\n    print(\"⚠  submission_rnapro.csv not found — RNAPro predictions will be unavailable\")\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 10 — Boltz inference\n# ─────────────────────────────────────────────────────────────\n\nmax_boltz_tokens = 900\npred_repeats     = 5\n\nos.system(\"mkdir -p input_fasta\")\nos.system(\"rm -rf boltz_results*\")\n\nprint(\"\\n\" + \"=\"*60 + \"\\nRUNNING BOLTZ PREDICTIONS\\n\" + \"=\"*60)\n\nfor k in range(len(test_seqs)):\n    row  = test_seqs.iloc[k]\n    name = row.target_id\n\n    if len(row.sequence) > max_boltz_tokens:\n        print(f\"  [{k+1}] {name}: SKIPPED (len={len(row.sequence)} > {max_boltz_tokens})\")\n        continue\n\n    print(f\"\\n[{k+1}/{len(test_seqs)}] Preparing Boltz input for {name}\")\n\n    pred_seq = row.sequence\n    try:\n        chains, chain_ind = {}, 1\n        repeats = 1\n        for entry in row.all_sequences.split(\"\\n\"):\n            entry = entry.strip()\n            if not entry: continue\n            if entry.startswith(\">\"):\n                try:   repeats = min(len(entry.split(\"|\")[1].split(\",\")), 999)\n                except: repeats = 1\n            else:\n                if entry == pred_seq.strip():\n                    chains[0] = (pred_seq, \"rna\")\n        if 0 not in chains:\n            chains[0] = (pred_seq, \"rna\")\n    except Exception:\n        chains = {0: (pred_seq, \"rna\")}\n\n    with open(f\"input_fasta/{name}.fasta\", \"w\") as fout:\n        for chain in sorted(chains.keys()):\n            seq_c, seq_type = chains[chain]\n            msa = \"\" if seq_type == \"rna\" else \"empty\"\n            fout.write(f\">{chain}|{seq_type}|{msa}\\n{seq_c}\\n\")\n\n    for repeat in range(pred_repeats):\n        os.system(\n            f\"boltz predict input_fasta/{name}.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}\"\n        )\n        gc.collect(); torch.cuda.empty_cache()\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 11 — Collect Boltz C1' coordinates\n# ─────────────────────────────────────────────────────────────\n\nfrom biopandas.pdb import PandasPdb\n\nprint(\"\\n\" + \"=\"*60 + \"\\nCOLLECTING BOLTZ PREDICTIONS\\n\" + \"=\"*60)\n\nboltz_preds_dict = {}  # { target_id: [repeat_0, ..., repeat_4] }  each entry (L,3) or None\n\nfor k in range(len(test_seqs)):\n    row   = test_seqs.iloc[k]\n    name  = row.target_id\n    L_seq = len(row.sequence)\n    repeat_coords = []\n    for rep in range(pred_repeats):\n        fname = (f\"boltz_repeat_{rep}/boltz_results_{name}\"\n                 f\"/predictions/{name}/{name}_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            if len(c1_atoms) != L_seq:\n                repeat_coords.append(None); continue\n            repeat_coords.append(c1_atoms[[\"x_coord\",\"y_coord\",\"z_coord\"]].values.astype(float))\n        except Exception:\n            repeat_coords.append(None)\n\n    boltz_preds_dict[name] = repeat_coords\n    n_ok = sum(r is not None for r in repeat_coords)\n    print(f\"  {name}: {n_ok}/{pred_repeats} Boltz repeats\")\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 12 — Tri-model hybrid combination (5 predictions)\n# ─────────────────────────────────────────────────────────────\n\nRNAPRO_MAX_LEN = 1000  # RNAPro was only run for sequences ≤ this length\n\ndef first_valid(lst):\n    \"\"\"Return first non-None element, or None.\"\"\"\n    for x in lst:\n        if x is not None: return x\n    return None\n\ndef build_hybrid_predictions(row, tmpl_preds, boltz_repeats, rnapro_preds):\n    \"\"\"\n    Combine up to three model sources into exactly 5 predictions.\n\n    Priority scheme:\n      Pred 1 — best TBM            (most trusted template model)\n      Pred 2 — best Boltz repeat   (Boltz repeat-0, or first valid)\n      Pred 3 — best RNAPro         (RNAPro template_idx-0)\n      Pred 4 — Boltz / TBM 50-50 blend (centroid-aligned)\n      Pred 5 — RNAPro / Boltz 50-50 blend  (or RNAPro / TBM if no Boltz)\n\n    All predictions with a DL source are refined with adaptive_rna_constraints.\n    When a DL model is unavailable for a target, geometric TBM variants fill in.\n    \"\"\"\n    tid      = row[\"target_id\"]\n    seq      = row[\"sequence\"]\n    segments = test_segs_map.get(tid, [(0, len(seq))])\n    seed_val = (row.name * 99_999 + 42) % (2**32)\n    rng      = np.random.default_rng(seed_val)\n    longest  = max(segments, key=lambda se: se[1]-se[0])\n\n    tmpl       = tmpl_preds[0]                   # always available\n    boltz_arr  = first_valid(boltz_repeats)       # None if sequence too long / failed\n    rnapro_arr = first_valid(rnapro_preds) if rnapro_preds else None\n\n    # ── Pred 1: best TBM ──────────────────────────────────────\n    pred1 = tmpl.copy()\n\n    # ── Pred 2: best Boltz (or TBM hinge variant) ────────────\n    if boltz_arr is not None and boltz_arr.shape == tmpl.shape:\n        pred2 = boltz_arr.copy()\n    else:\n        pred2 = adaptive_rna_constraints(\n            apply_hinge(tmpl, longest, rng, max_angle_deg=22), tid, confidence=0.5)\n\n    # ── Pred 3: best RNAPro (or TBM jitter variant) ───────────\n    if rnapro_arr is not None and rnapro_arr.shape == tmpl.shape:\n        pred3 = rnapro_arr.copy()\n    else:\n        pred3 = adaptive_rna_constraints(\n            jitter_chains(tmpl, segments, rng, 12, 1.5), tid, confidence=0.5)\n\n    # ── Pred 4: 50/50 Boltz + TBM blend ──────────────────────\n    if boltz_arr is not None and boltz_arr.shape == tmpl.shape:\n        raw4 = centroid_align_and_blend(tmpl, boltz_arr, 0.5, 0.5)\n    else:\n        # Fallback: smooth wiggle on best TBM\n        raw4 = smooth_wiggle(tmpl, segments, rng, amp=0.8)\n    pred4 = adaptive_rna_constraints(raw4, tid, confidence=0.75, passes=2)\n\n    # ── Pred 5: 50/50 RNAPro + Boltz blend (or RNAPro + TBM) ─\n    if rnapro_arr is not None and rnapro_arr.shape == tmpl.shape:\n        partner = boltz_arr if (boltz_arr is not None and boltz_arr.shape == tmpl.shape) else tmpl\n        raw5    = centroid_align_and_blend(rnapro_arr, partner, 0.5, 0.5)\n        jitter  = rng.normal(0, 0.15, tmpl.shape)\n        raw5   += jitter\n    else:\n        # Fallback: second TBM hinge with different rng state\n        raw5 = smooth_wiggle(tmpl, segments, rng, amp=1.2)\n    pred5 = adaptive_rna_constraints(raw5, tid, confidence=0.75, passes=2)\n\n    return [pred1, pred2, pred3, pred4, pred5]\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 13 — Main prediction loop and submission writer\n# ─────────────────────────────────────────────────────────────\n\nprint(\"\\n\" + \"=\"*60 + \"\\nASSEMBLING FINAL HYBRID SUBMISSION\\n\" + \"=\"*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\n    if idx % 10 == 0:\n        print(f\"  {idx}/{len(test_seqs)}  {tid}  | {time.time()-start_time:.1f}s\")\n\n    tmpl_preds    = template_preds_dict[tid]                         # always 5\n    boltz_repeats = boltz_preds_dict.get(tid, [None]*pred_repeats)   # 5 or all-None\n    rnapro_preds  = rnapro_preds_dict.get(tid, [])                   # up to 5 or []\n\n    final_preds = build_hybrid_predictions(row, tmpl_preds, boltz_repeats, rnapro_preds)\n\n    assert len(final_preds) == 5\n    for p in final_preds:\n        assert isinstance(p, np.ndarray) and p.shape == (L, 3), \\\n            f\"Bad pred shape for {tid}: {getattr(p,'shape',None)}\"\n        if not np.isfinite(p).all():\n            p[:] = 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(5):\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    n_boltz  = sum(r is not None for r in boltz_repeats)\n    n_rnapro = len(rnapro_preds)\n    strategy = (\"tri_model\"   if n_boltz > 0 and n_rnapro > 0 else\n                \"boltz_tbm\"   if n_boltz > 0 else\n                \"rnapro_tbm\"  if n_rnapro > 0 else\n                \"tbm_only\")\n    strategy_rows.append({\n        \"target_id\":     tid,\n        \"seq_len\":       L,\n        \"boltz_repeats\": n_boltz,\n        \"rnapro_preds\":  n_rnapro,\n        \"strategy\":      strategy,\n        **{k: round(v,4) for k,v in target_score_log.get(tid,{}).items()},\n    })\n\n# ── Build and save submission CSV ────────────────────────────\nsub = pd.DataFrame(all_predictions)\ncols = [\"ID\",\"resname\",\"resid\"] + [f\"{c}_{i}\" for i in range(1,6) 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\n# ── Strategy log ─────────────────────────────────────────────\nstrategy_df = pd.DataFrame(strategy_rows)\nstrategy_df.to_csv(\"strategy_log.csv\", index=False)\n\nprint(\"\\n===== Strategy Summary =====\")\nprint(strategy_df.groupby(\"strategy\")[\"seq_len\"].agg([\"count\",\"mean\"]).round(1).rename(\n    columns={\"count\":\"targets\",\"mean\":\"avg_len\"}))\n\n# ─────────────────────────────────────────────────────────────\n# SECTION 14 — 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        tm_result  = score(group_native, group_pred, 'ID')\n        comp       = target_score_log.get(target_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            'global_score': comp.get('global_score', float('nan')),\n            'tm_score':     round(float(tm_result), 5),\n        })\n\n    results_df = (pd.DataFrame(results_rows)\n                  .sort_values('tm_score', ascending=False)\n                  .reset_index(drop=True))\n    summary    = pd.DataFrame([{\n        'target_id': 'MEAN', 'strategy': '',\n        'global_score': round(results_df['global_score'].mean(), 4),\n        'tm_score':     round(results_df['tm_score'].mean(), 5),\n    }])\n    display_df = pd.concat([results_df, summary], ignore_index=True)\n    display_df.to_csv('/kaggle/working/score_table.csv', index=False)\n\n    pd.set_option('display.float_format', '{:.4f}'.format)\n    pd.set_option('display.max_rows', 200)\n    print(\"\\n===== Per-Target Score Breakdown =====\")\n    print(display_df.to_string(index=False))\n    print(f\"\\nMean TM-score: {results_df['tm_score'].mean():.5f}  (n={len(results_df)})\")\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}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-17T12:38:17.706437Z","iopub.execute_input":"2026-01-17T12:38:17.706758Z","iopub.status.idle":"2026-01-17T12:38:17.711385Z","shell.execute_reply.started":"2026-01-17T12:38:17.706726Z","shell.execute_reply":"2026-01-17T12:38:17.710612Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}