Architecture

Inside the cyclic design pipeline.

Five stages, three concentric levels of early termination, one cyclic-specific retry. The whole machinery is ~250 lines of Python on top of stock BindCraft v1.5.3.

At a glance

The 5-stage pipeline.

Read it left-to-right: each stage consumes the previous stage's output and either advances the design or short-circuits it into a rejection bucket.

The five-stage Cyclic BindCraft pipeline as a horizontal flow: target ingestion → cyclic AF2 hallucination → constrained FastRelax → ProteinMPNN redesign → cyclic-aware filters.

Mermaid equivalent

flowchart TD
    IN["3 JSON configs
(target / advanced / filters)"] --> S1[Stage 1: Target ingestion
+ cyclic_reses mask] S1 --> S2[Stage 2: AF2 hallucination
cyclic relpos + bonded edge + bond loss] S2 --> S2F{trajectory filters
clash, pLDDT, contacts} S2F -->|fail| REJ1[Trajectory/LowConfidence
or Trajectory/Clashing] S2F -->|pass| S3[Stage 3: pr_relax_cyclic
DeclareBond + bond constraints] S3 --> S4[Stage 4: ProteinMPNN
soluble weights, fix interface] S4 --> S5[Stage 5: AF2 re-prediction
cyclic relpos applied] S5 --> S5R[pr_relax_cyclic + InterfaceAnalyzer] S5R --> S5F{run_cyclic_filters
+ JSON filter engine} S5F -->|fail| REJ2[Rejected/ + failure_csv] S5F -->|pass| ACC[Accepted/] ACC --> COUNT{#Accepted ≥ N?} COUNT -->|no| S1 COUNT -->|yes| RANK[rank by Average_i_pTM
write final_design_stats.csv
exit]

I

Stage 1

Target ingestion & hotspot prep.

Modules: bindcraft.py outer loop (lines ~71–122); functions/colabdesign_utils.py:binder_hallucination (entry); functions/cyclic_utils.py:build_cyclic_reses_mask.

Input: Three JSON files; one target PDB; hotspot string (e.g. A48,A53,A66).

Input

3 JSON configs + target PDB + hotspot spec

Process

load_json_settingsperform_input_check → per trajectory: sample length, build cyclic_reses mask, prep_inputs

Output

af_model instance with target as chain A, binder as chain B, and af_model._cyclic_reses attached

functions/cyclic_utils.py — build_cyclic_reses_mask
def build_cyclic_reses_mask(num_residues: int,
                            binder_len: int,
                            target_len: int,
                            binder_is_cyclic: bool = True) -> np.ndarray:
    """Build a 1-D bool mask: True for binder residues (the tail)."""
    mask = np.zeros(num_residues, dtype=bool)
    if not binder_is_cyclic or binder_len <= 0:
        return mask
    mask[num_residues - binder_len:] = True
    return mask

II

Stage 2

AF2 hallucination with cyclic relpos + bond loss.

Modules: functions/colabdesign_utils.py:binder_hallucination (modified); functions/cyclic_utils.py:cyclize_relpos, cyclic_bonded_edges, add_cyclic_bond_loss.

Input

Configured af_model + cyclic_reses mask

Process

3-stage design (logits → soft → pssm_semigreedy) with cyclized relpos, cyclic bonded edge, hard amide-bond loss; trajectory-level clash/pLDDT/contact filters

Output

Trajectory/<design>.pdb — chain A target + chain B binder, linear coords; N(1)···C(L) typically within ~4 Å

Cyclic patch (replaces add_termini_distance_loss): When cyclic_binder=true, add_cyclic_bond_loss(af_model, weight=0.5, target_N_C=1.33, target_CA_CA=3.8, target_omega=180.0) is registered. On every AF2 forward pass:

  • Extract binder atoms from outputs["structure_module"]["final_atom_positions"] sliced as [-self._binder_len:].
  • Three harmonic penalties: |dist(N(1), C(L)) − 1.33|², |dist(Cα(1), Cα(L)) − 3.8|², |dihedral(Cα(L), C(L), N(1), Cα(1)) − 180°|² (wrapped to [−180°, 180°]).
  • Returns {"cyclic_bond": sum_of_penalties} registered with af_model.opt["weights"]["cyclic_bond"] = weight.

Cyclic AF2 forward-pass diagram

AF2 feature dict (shape annotations for L_target=120, L_binder=33): aatype : (153,) # target (120) + binder (33) residue_index : (153,) # [1..120, 121..153] ← LINEAR by default cyclic_reses : (153,) bool # [F×120, T×33] relpos (raw) : (153,153) # residue_index[j] - residue_index[i] ┌──────────────────────────────────────────────────────────────┐ │ cyclize_relpos(relpos, cyclic_reses, L=33): │ │ for each (i, j) where cyclic_reses[i] && cyclic_reses[j]: │ │ if relpos[i,j] > 33/2 (i.e. > 16): relpos[i,j] -= 33 │ │ if relpos[i,j] < -33/2: relpos[i,j] += 33 │ │ After: residues 1 and 33 on binder have relpos = ±1 │ │ → AF2 sees them as sequence-adjacent (bonded neighbor) │ └──────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────┐ │ cyclic_bonded_edges(seqsep, cyclic_reses, L=33): │ │ Standard AF2 bonded: +1 for (i, i+1), -1 for (i+1, i) │ │ Cyclic addition: +1 for (binder_L, binder_1), -1 reverse │ │ → SE(3) graph adds an edge between binder residue L and 1 │ └──────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────┐ │ add_cyclic_bond_loss callback (after structure module): │ │ binder_xyz = structure_module.final_atom_positions[-33:] │ │ N1, CA1 = binder_xyz[0][N], binder_xyz[0][CA] │ │ CL, CAL = binder_xyz[32][C], binder_xyz[32][CA] │ │ │ │ loss_NC = (|N1 - CL| - 1.33)² │ │ loss_CACA = (|CA1 - CAL| - 3.8)² │ │ loss_omg = (dihedral(CAL, CL, N1, CA1) - 180°)² │ │ total = loss_NC + loss_CACA + 0.01 * loss_omg │ └──────────────────────────────────────────────────────────────┘ AF2 output → trajectory PDB → Stage 3 (pr_relax_cyclic)

III

Stage 3

Constrained FastRelax with DeclareBond.

Module: functions/pyrosetta_utils.py:pr_relax_cyclic (new function in the cyclic patch); falls back to stock pr_relax if cyclic_binder=false.

Input

Trajectory/<design>.pdb

Process

pose_from_pdbDeclareBond(res(L):C, res(1):N) → 4 constraints → FastRelax with chainbreak=5.0AlignChainMoverclean_pdb_with_conect

Output

Trajectory/Relaxed/<design>.pdb — chemically cyclic pose, CONECT record, REMARK 470. NC ≈ 1.33 Å, ω ≈ 180°.

Cyclic FastRelax diagram

Trajectory PDB (linear coords, N1···CL ≈ 4 Å) │ ▼ ┌─────────────────────────────────────────────┐ │ pose = pr.pose_from_pdb(traj.pdb) │ └─────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────┐ │ DeclareBond: │ │ pose.residue(L).atom("C") │ │ ↕ covalent bond │ │ pose.residue(1).atom("N") │ └─────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────┐ │ Add constraints to pose.constraint_set(): │ │ • BondLength (C(L)–N(1), 1.33 Å, σ=0.05) │ │ • BondAngle (CA(L)–C(L)–N(1), 116°, σ=5°) │ │ • BondAngle (C(L)–N(1)–CA(1), 123°, σ=5°) │ │ • Dihedral (CA(L)–C(L)–N(1)–CA(1), 180°) │ └─────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────┐ │ scorefxn = get_fa_scorefxn() │ │ scorefxn.set_weight(chainbreak, 5.0) │ │ scorefxn.set_weight(atom_pair_constraint,1) │ │ scorefxn.set_weight(dihedral_constraint, 1) │ │ scorefxn.set_weight(angle_constraint, 1.0) │ └─────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────┐ │ FastRelax │ │ • MoveMap: CHI=True, BB=True, jump=False │ │ • max_iter=200, lbfgs_armijo_nonmonotone │ │ • constrain_relax_to_start_coords=True │ └─────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────┐ │ AlignChainMover back to start; copy B-factors│ │ pose.dump_pdb(relaxed.pdb) │ │ clean_pdb_with_conect(relaxed.pdb, (1,L)) │ │ └─ adds: │ │ CONECT 1 L # N(1)–C(L) │ │ REMARK 470 CYCLIC B 1 L │ └─────────────────────────────────────────────┘ │ ▼ Relaxed PDB — true cyclic pose, NC ≈ 1.33 Å, ω ≈ 180°

IV

Stage 4

ProteinMPNN sequence redesign.

Module: functions/colabdesign_utils.py:mpnn_gen_sequence (unchanged from stock).

Input

Trajectory/Relaxed/<design>.pdb; binder interface residue list (KD-tree, 4 Å cutoff)

Process

mk_mpnn_model(weights="soluble", model="v_48_020"); fix_pos = "A,<interface>"; rm_aa='C'; sample 20 sequences at temp 0.1

Output

Up to 20 candidate sequences for the trajectory

Cyclic note

ProteinMPNN does not need a cyclic mode. It operates on the binder's backbone coordinates as a single chain; the cyclic geometry (closed ring) is preserved structurally because the input PDB already has N(1) and C(L) within peptide-bond distance. MPNN's chain-aware decoder treats the binder as one linear sequence, which is the correct inductive bias for predicting amino acids on a cyclic backbone.


V

Stage 5

AF2 re-prediction + relax + cyclic-aware filtering.

Modules: functions/colabdesign_utils.py:predict_binder_complex (modified); functions/pyrosetta_utils.py:pr_relax_cyclic + score_interface; functions/cyclic_utils.py:run_cyclic_filters; functions/generic_utils.py:check_filters.

Input

Up to 20 MPNN sequences per trajectory

Process

AF2 monomer models 1, 2 with cyclic relpos applied; 3 recycles; per-model early filters; pr_relax_cyclic; InterfaceAnalyzer → dG, dSASA, SAP, CMS; run_cyclic_filters appends 3 cyclic columns; JSON filter engine tests every threshold

Output

Accepted/<design>_model<N>.pdb or Rejected/<...> + failure_csv.csv row

run_cyclic_filters computes three cyclic-specific columns and appends them to the design dict:

  • cyclic_NC_distance — N(1)–C(L) atom distance in Å (from the final relaxed PDB)
  • cyclic_omegaCα(L)–C(L)–N(1)–Cα(1) dihedral in degrees (wrapped to [−180, 180], absolute value stored)
  • cyclic_CACA_distanceCα(1)–Cα(L) distance in Å

Data structures

How a cyclic binder is represented at each stage.

StageRepresentationLength / ShapeWhere it lives
1. Ingesttarget_settings, advanced_settings, filters (dicts)JSONPython dicts in bindcraft.py
1. IngestAF2 model with cyclic_reses attached(L_target + L_binder,)af_model._cyclic_reses
2. HallucinationAF2 feature dict: aatype, residue_index, relpos (wrapped), bonded (cyclic-augmented)shapes as aboveaf_model._inputs
2. HallucinationLoss callbacks: cyclic_bond + stock lossesscalaraf_model._callbacks["model"]["loss"]
2. OutputTrajectory/<design>.pdb (linear coords)2 chainsdisk
3. RelaxPyRosetta pose with DeclareBond + 4 constraintsposeRAM
3. OutputTrajectory/Relaxed/<design>.pdb (cyclic, CONECT, REMARK 470)2 chainsdisk
4. MPNNProteinMPNN sample dict {seq, score, mask}up to 20 seqsRAM
4. OutputMPNN/Sequences/<design>.fastatextdisk
5. Validationpredict_binder_complex returns per-model AF2 outputsdictRAM
5. OutputMPNN/Binder/<design>_model{1,2}.pdb, MPNN/Relaxed/<...>.pdb2 chainsdisk
5. Filteringdesign_dict (one CSV row): all metrics + 3 cyclic columnsdictRAM
5. OutputAccepted/ or Rejected/ PDBs; CSV rows2 chains + CSVdisk
Rankfinal_design_stats.csv sorted by Average_i_pTM descCSVdisk

Failure modes & retries

Three concentric levels of early termination.

Cyclic BindCraft has three concentric levels of early termination, mirroring stock BindCraft plus a cyclic-specific retry:

  1. In-stage early termination (Stage 2): If pLDDT drops below 0.65 between design stages, ColabDesign skips the remaining stages and dumps the trajectory into Trajectory/LowConfidence/. No retry; the outer loop moves to a new seed.
  2. Trajectory-level termination (Stage 2): Cα clash (>0), pLDDT < 0.7, or <3 hotspot contacts → Trajectory/Clashing/ or Trajectory/LowConfidence/. No retry.
  3. Per-MPNN-sequence AF2 model termination (Stage 5): If any of pLDDT, pTM, i_pTM, pAE, i_pAE fails for AF2 model 1 or 2, skip Rosetta scoring for that MPNN sequence. Try the next MPNN sequence.
  4. Final filter failure (Stage 5): check_filters returns a list of failed columns. The design is moved to Rejected/; the failed columns are appended to failure_csv.csv. Up to max_mpnn_sequences=2 MPNN sequences per trajectory can still pass.
  5. Cyclic-specific retry: If cyclic_NC_distance > 5 Å on the trajectory's relaxed PDB (FastRelax failed to close the ring), the cyclic patch marks the trajectory as CyclicClosureFailed and the outer loop tries a new seed. If this happens >20 times in a row, the cyclic bond loss weight is auto-bumped by 0.5 (up to a cap of 2.0) for subsequent trajectories.

Throughput

This retry logic keeps the pipeline productive: typical runs see ~50% of trajectories terminated early by Stage 2 filters, ~30% rejected by Stage 5 filters, and ~20% accepted.