The Code

What changed, file by file.

Nine files touched. ~1300 lines added, 27 modified, 0 removed. When cyclic: false, behavior is byte-for-byte identical to upstream BindCraft v1.5.3.

At a glance

Architecture of modified-code/.

modified-code/ ├── README.md # quickstart + 5-command recipe ├── CHANGES.md # this file-by-file changelog ├── PATCH.diff # unified diff against BindCraft v1.5.3 ├── bindcraft.py # MODIFIED (+80 LOC, 8 lines changed) ├── functions/ │ ├── __init__.py # MODIFIED (+18 LOC, re-exports cyclic_utils) │ ├── cyclic_utils.py # NEW (~440 LOC, 11 CYCLIC MODIFICATION markers) │ ├── colabdesign_utils.py # MODIFIED (+190 LOC, 11 markers) │ ├── pyrosetta_utils.py # MODIFIED (+70 LOC, 7 markers) │ ├── biopython_utils.py # MODIFIED (+45 LOC, 5 markers) │ └── generic_utils.py # MODIFIED (+130 LOC, 15 markers) └── settings_* ├── settings_advanced/ │ └── peptide_cyclic_3stage.json # NEW (~80 LOC preset) └── settings_filters/ └── peptide_cyclic_filters.json # NEW (~250 LOC filter set) Total: ~1300 lines added, 27 modified, 0 removed. 67 "CYCLIC MODIFICATION" comment markers across the codebase.

File-by-file

What changed in each file.

functions/cyclic_utils.py

NEW · ~440 LOC · 11 markers

Central module for all cyclization helpers. JAX helpers: cyclize_relpos, cyclic_bonded_edges, _dihedral_rad. Numpy/BioPython: build_cyclic_reses_mask, compute_cyclic_closure_metrics, cyclic_bond_conect_records. PyRosetta: declare_cyclic_bond, add_cyclic_constraints, enable_cyclic_scorefunction_terms, run_cyclic_validator. Geometry constants: TARGET_N_C_DISTANCE=1.33, TARGET_CA_CA_DISTANCE=3.80, TARGET_OMEGA_DEG=180.0.

functions/colabdesign_utils.py

MODIFIED · +190 LOC · 11 markers

NEW install_cyclic_relpos_patch() monkey-patches colabdesign's AF2 model with a "pre" callback. NEW add_cyclic_bond_loss() — 3-term harmonic penalty (N–C 1.33 Å, Cα–Cα 3.80 Å, ω 180°). binder_hallucination, predict_binder_complex, predict_binder_alone, mpnn_gen_sequence all thread the cyclic flag. plot_trajectory adds a cyclic_bond panel.

functions/pyrosetta_utils.py

MODIFIED · +70 LOC · 7 markers

pr_relax() signature extended with cyclic=False, binder_chain="B". When cyclic=True: calls declare_cyclic_bond + add_cyclic_constraints + enable_cyclic_scorefunction_terms before FastRelax; after FastRelax appends REMARK + CONECT records to the relaxed PDB. score_interface() computes and returns 3 new keys: Cyclic_NC_Distance, Cyclic_CACA_Distance, Cyclic_Omega.

functions/biopython_utils.py

MODIFIED · +45 LOC · 5 markers

calculate_clash_score() signature extended with cyclic=False, binder_chain="B"; pre-computes the {first_res, last_res} frozenset of the binder chain and skips their atom pairs in the inner clash loop (otherwise the 1.33 Å N–C amide bond always triggers a clash). validate_design_sequence() adds a "CYCLIC binder" note.

functions/generic_utils.py

MODIFIED · +130 LOC · 15 markers

NEW module-level _CLI_CYCLIC_OVERRIDE + set_cli_cyclic_override() to forward the CLI --cyclic flag into load_json_settings. clean_pdb() signature extended; preserves CONECT/REMARK/HEADER when cyclic=True. generate_dataframe_labels() appends 3 new columns. check_accepted_designs() tie-breaks by closure distance when cyclic.

bindcraft.py

MODIFIED · +80 LOC · 17 markers

NEW --cyclic and --cyclic_validator CLI flags. Cyclic-mode banner. Sanity-checks binder length (warns if <8 or >60). Threads cyclic=True through pr_relax, calculate_clash_score (4 call sites). Adds cyc_ prefix to design names. PyRosetta init appends -allow_peptide_bond_to_chain_termini true when cyclic.

functions/__init__.py

MODIFIED · +18 LOC · 1 marker

Re-exports cyclic_utils so from functions import * exposes all cyclization helpers.

peptide_cyclic_3stage.json

NEW · ~80 LOC preset

Derived from peptide_3stage_multimer.json. Sets cyclic=true, use_cyclic_bond_loss=true, cyclic_bond_weight=1.0, use_termini_distance_loss=false. Tighter MPNN sampling (num_seqs=20, sampling_temp=0.05). More iterations (100/50/20). Reduced helicity bias (0.5 vs 0.95). Lower acceptance_rate (0.05).

peptide_cyclic_filters.json

NEW · ~250 LOC filter set

Derived from peptide_filters.json. NEW cyclic closure filters: Average/1/2_Cyclic_NC_Distance (≤5.0 Å), Average/1/2_Cyclic_CACA_Distance (≤6.0 Å), Average/1/2_Cyclic_Omega (≥150°). Relaxed Binder_RMSD (2.5→3.5 Å) and Hotspot_RMSD (3.0→4.0 Å) because AF2 monomer is out-of-distribution for cyclic backbones. Tightened dG threshold (0→-10 kcal/mol).


Real code

Key cyclization functions.

The actual source from modified-code/. Click a tab to switch between Code, Description, Parameters, and Returns.

functions/cyclic_utils.py — cyclize_relpos
def cyclize_relpos(relpos, cyclic_mask, L):
    """CYCLIC MODIFICATION — NEW function (JAX).

    Port of RFpeptide's Embeddings.PositionalEncoding2D.forward cyclic
    wrap-around (Rettie et al. 2025, rfpeptides branch).
    """
    import jax.numpy as jnp

    abs_sep = jnp.abs(relpos)
    sign = jnp.sign(relpos)
    wrapped = jnp.where(abs_sep > L / 2.0,
                        sign * (abs_sep - L),
                        relpos)
    both_cyclic = cyclic_mask[:, None] & cyclic_mask[None, :]
    if relpos.ndim == 3 and both_cyclic.ndim == 2:
        both_cyclic = both_cyclic[..., None]
    return jnp.where(both_cyclic, wrapped, relpos)

Wrap the 2-D relative-position tensor so that for residue pairs on the cyclic chain, the shortest path around the ring is used. Direct port of RFpeptide's PositionalEncoding2D.forward cyclization. For any pair (i, j) on a cyclic chain of length L, the shortest path around the ring is min(|j − i|, L − |j − i|).

NameTypeDefaultMeaning
relposjnp.ndarrayrequiredRaw signed sequence separation idx_j − idx_i, shape (B, L, L) or (L, L)
cyclic_maskjnp.ndarrayrequired1-D bool tensor of length L. True for residues on the cyclic chain (the binder)
LintrequiredLength of the cyclic chain (binder length). Used as the wrap-around modulus

jnp.ndarray of the same shape as relpos, where for any pair (i, j) both on the cyclic chain: if relpos[i, j] > N_cyclic / 2 subtract N_cyclic; if relpos[i, j] < -N_cyclic / 2 add N_cyclic. N_cyclic is cyclic_mask.sum(). Pairs where one or both residues are off the cyclic chain are left unchanged.

functions/colabdesign_utils.py — add_cyclic_bond_loss
def add_cyclic_bond_loss(self, weight=1.0,
                         target_N_C=TARGET_N_C_DISTANCE,
                         target_CA_CA=TARGET_CA_CA_DISTANCE,
                         target_omega=TARGET_OMEGA_DEG,
                         nc_tol=NC_TOLERANCE,
                         caca_tol=CA_CA_TOLERANCE,
                         omega_tol=OMEGA_TOLERANCE_DEG):
    """CYCLIC MODIFICATION — NEW function (JAX).

    Replace the soft add_termini_distance_loss (threshold 7 A CA-CA, ELU
    penalty) with a hard amide-bond geometry loss. Three terms:
      1. N-C bond length   — quadratic when |dist(N_1, C_L) - 1.33| > 0.10
      2. CA-CA across bond — quadratic when |dist(CA_1, CA_L) - 3.80| > 0.50
      3. omega dihedral    — quadratic when |omega_deg - 180| > 30
    """
    def loss_fn(inputs, outputs):
        xyz = outputs["structure_module"]
        atoms = xyz["final_atom_positions"][-self._binder_len:]

        N_IDX = residue_constants.atom_order["N"]
        CA_IDX = residue_constants.atom_order["CA"]
        C_IDX = residue_constants.atom_order["C"]

        N1 = atoms[0, N_IDX];   CA1 = atoms[0, CA_IDX]
        CL = atoms[-1, C_IDX];  CAL = atoms[-1, CA_IDX]

        nc_dist = jnp.linalg.norm(N1 - CL)
        nc_dev = jax.nn.relu(jnp.abs(nc_dist - target_N_C) - nc_tol)
        nc_loss = nc_dev ** 2

        caca_dist = jnp.linalg.norm(CA1 - CAL)
        caca_dev = jax.nn.relu(jnp.abs(caca_dist - target_CA_CA) - caca_tol)
        caca_loss = caca_dev ** 2

        omega_rad = _cyclic_dihedral_rad(CAL, CL, N1, CA1)
        omega_deg = jnp.degrees(omega_rad)
        omega_wrapped = ((omega_deg + 180.0) % 360.0) - 180.0
        omega_dev = jax.nn.relu(jnp.abs(omega_wrapped - target_omega) - omega_tol)
        omega_loss = omega_dev ** 2

        total = nc_loss + caca_loss + 0.01 * omega_loss
        return {"cyclic_bond": total}

    self._callbacks["model"]["loss"].append(loss_fn)
    self.opt["weights"]["cyclic_bond"] = weight

Hard amide-bond geometry loss. Replaces the soft add_termini_distance_loss when cyclic=True. Three quadratic-penalty terms on (1) N–C bond length, (2) Cα–Cα distance across the bond, (3) ω dihedral. The penalty is zero inside the tolerance window and quadratic outside.

NameTypeDefaultMeaning
selfAF2 modelColabDesign AF2 model (method via monkey-patch)
weightfloat1.0Loss weight stored on self.opt["weights"]["cyclic_bond"]
target_N_Cfloat1.33Target N(1)–C(L) distance in Å
target_CA_CAfloat3.80Target Cα(1)–Cα(L) distance in Å
target_omegafloat180.0Target ω dihedral in degrees (180 = trans)
nc_tol, caca_tol, omega_tolfloat0.10 / 0.50 / 30.0Tolerance windows; loss is exactly zero inside
functions/cyclic_utils.py — declare_cyclic_bond
def declare_cyclic_bond(pose, binder_chain: str = "B") -> bool:
    """Declare a covalent peptide bond between the binder chain's residue-1
    N atom and residue-L C atom on a PyRosetta pose."""
    try:
        from pyrosetta.rosetta.core.kinematics import DeclareBond
    except Exception:
        return False

    try:
        from pyrosetta.rosetta.core.pose import get_chain_id_from_chain
        cid = get_chain_id_from_chain(binder_chain, pose)
        chain_begin = pose.conformation().chain_begin(cid)
        chain_end = pose.conformation().chain_end(cid)

        declare = DeclareBond()
        declare.set(chain_end, chain_begin, "C", "N", False)
        declare.apply(pose)
        return True
    except Exception:
        return False

Wraps pyrosetta.rosetta.core.kinematics.DeclareBond to create the bond between residue 1 N and residue L C of the binder chain. After this call PyRosetta sees the binder as a single closed ring. Wrapped in try/except so older Rosetta builds degrade gracefully.

functions/biopython_utils.py — calculate_clash_score (cyclic branch)
# CYCLIC MODIFICATION: pre-compute the {first_res, last_res} pair
# of the binder chain and skip those atom pairs in the clash loop.
cyclic_neighbor_pair = None
if cyclic:
    try:
        binder_chain_obj = structure[0][binder_chain]
        binder_residues = [r for r in binder_chain_obj
                           if is_aa(r, standard=True)]
        if len(binder_residues) >= 2:
            first_res = binder_residues[0].id[1]
            last_res = binder_residues[-1].id[1]
            cyclic_neighbor_pair = frozenset((first_res, last_res))
    except Exception:
        cyclic_neighbor_pair = None

# ... inside the pair loop:
if (cyclic_neighbor_pair is not None
        and chain_i == chain_j == binder_chain
        and frozenset((res_i, res_j)) == cyclic_neighbor_pair):
    continue   # skip — this is the cyclic amide bond, not a clash

Cyclic-aware clash detection. Mirrors the stock calculate_clash_score (BioPython cKDTree.query_pairs) with one added exclusion: the {1, L} pair on the binder chain is skipped. This prevents the 1.33 Å N(1)–C(L) peptide bond from being falsely flagged as a clash.

functions/cyclic_utils.py — cyclic_bond_conect_records
def cyclic_bond_conect_records(pdb_file: str,
                               binder_chain: str = "B") -> str:
    """Read a PDB and return a CONECT record string that documents
    the head-to-tail cyclic amide bond between residue-1 N and
    residue-L C."""
    from Bio.PDB import PDBParser, is_aa

    parser = PDBParser(QUIET=True)
    structure = parser.get_structure("conect", pdb_file)
    chain = structure[0][binder_chain]
    residues = [r for r in chain if is_aa(r, standard=True)]
    if len(residues) < 2:
        return ""

    first = residues[0]; last = residues[-1]
    if "N" not in first or "C" not in last:
        return ""

    n_serial = first["N"].get_serial_number()
    c_serial = last["C"].get_serial_number()
    if n_serial is None or c_serial is None:
        return ""

    return f"CONECT{n_serial:>5d}{c_serial:>5d}{'':>70s}\n".rstrip() + "\n"

Generate a PDB CONECT record for the head-to-tail amide bond. Atom serials are taken from the input PDB's SERIAL column. Downstream tools (PyMOL, ChimeraX, Rosetta's pose_from_pdb) read CONECT records to draw bonds; emitting one here makes the cyclic topology visually and chemically explicit on disk.


Presets

The cyclic presets, side by side.

settings_advanced/peptide_cyclic_3stage.json

3-stage design preset

excerpt
{
  "cyclic": true,
  "use_cyclic_bond_loss": true,
  "cyclic_bond_weight": 1.0,
  "cyclic_validator": null,

  "omit_AAs": "C",
  "use_multimer_design": true,
  "design_algorithm": "3stage",
  "predict_initial_guess": true,

  "soft_iterations": 100,
  "temporary_iterations": 50,
  "greedy_iterations": 20,
  "greedy_percentage": 8,

  "weights_helicity": 0.5,
  "use_i_ptm_loss": true,
  "weights_iptm": 0.05,
  "use_rg_loss": false,
  "use_termini_distance_loss": false,

  "enable_mpnn": true,
  "num_seqs": 20,
  "max_mpnn_sequences": 2,
  "sampling_temp": 0.05,
  "mpnn_weights": "soluble",

  "num_recycles_design": 1,
  "num_recycles_validation": 3,

  "acceptance_rate": 0.05,
  "start_monitoring": 200
}

settings_filters/peptide_cyclic_filters.json

Filter preset (cyclic columns)

excerpt — cyclic closure filters
{
  "Average_pLDDT":                { "threshold": 0.75, "higher": true  },
  "Average_i_pTM":                { "threshold": 0.40, "higher": true  },
  "Average_i_pAE":                { "threshold": 0.35, "higher": false },
  "Average_dG":                   { "threshold": -10,  "higher": false },
  "Average_Binder_RMSD":          { "threshold": 3.5,  "higher": false },
  "Average_Hotspot_RMSD":         { "threshold": 4.0,  "higher": false },
  "Average_Relaxed_Clashes":      { "threshold": 0,    "higher": false },

  "Average_Cyclic_NC_Distance":   { "threshold": 5.0,  "higher": false },
  "1_Cyclic_NC_Distance":         { "threshold": 5.0,  "higher": false },
  "2_Cyclic_NC_Distance":         { "threshold": 5.0,  "higher": false },

  "Average_Cyclic_CACA_Distance": { "threshold": 6.0,  "higher": false },
  "1_Cyclic_CACA_Distance":       { "threshold": 6.0,  "higher": false },
  "2_Cyclic_CACA_Distance":       { "threshold": 6.0,  "higher": false },

  "Average_Cyclic_Omega":         { "threshold": 150.0,"higher": true  },
  "1_Cyclic_Omega":               { "threshold": 150.0,"higher": true  },
  "2_Cyclic_Omega":               { "threshold": 150.0,"higher": true  }
}

Reference

PATCH.diff preview.

The unified diff against upstream BindCraft v1.5.3 (commit b971db4). Apply with patch -p1 < PATCH.diff from a fresh BindCraft clone.

PATCH.diff — first 60 lines (preview) Full patch on GitHub
# PATCH.diff — Cyclic BindCraft vs. upstream BindCraft v1.5.3 (commit b971db4)
#
# Generated by: diff -ruN (with binary/doc/excluded-file filters)
# Purpose: Reference unified diff showing every source/JSON file
#          modified or added to convert BindCraft into a cyclic-binder
#          design tool (head-to-tail amide-bond cyclization).
#
# Files in this patch (9 total):
#   MODIFIED  bindcraft.py
#   MODIFIED  functions/__init__.py
#   MODIFIED  functions/biopython_utils.py
#   MODIFIED  functions/colabdesign_utils.py
#   ADDED     functions/cyclic_utils.py                       (NEW file)
#   MODIFIED  functions/generic_utils.py
#   MODIFIED  functions/pyrosetta_utils.py
#   ADDED     settings_advanced/peptide_cyclic_3stage.json    (NEW preset)
#   ADDED     settings_filters/peptide_cyclic_filters.json   (NEW preset)
#
# To apply this patch to a fresh BindCraft v1.5.3 clone:
#   cd BindCraft/
#   patch -p1 < /path/to/PATCH.diff
#
# To verify the patch applied cleanly:
#   python -c "import ast; ast.parse(open('bindcraft.py').read())"
#   for f in functions/*.py; do python -c "import ast; ast.parse(open('$f').read())"; done

diff -ruN ... repo/bindcraft.py modified-code/bindcraft.py
--- repo/bindcraft.py	2026-07-08 13:32:19.932898998 +0000
+++ modified-code/bindcraft.py	2026-07-08 13:58:43.072666670 +0000
@@ -18,14 +18,44 @@
 parser.add_argument('--advanced', '-a', type=str, default='./settings_advanced/default_4stage_multimer.json',
                     help='Path to the advanced.json file with additional design settings. If not provided, default will be used.')

+# CYCLIC MODIFICATION: top-level --cyclic flag. When set, overrides
+# advanced_settings["cyclic"] to True (regardless of what the JSON
+# file says). When omitted, defers to the JSON config — so a user
+# can either:
+#   (a) pass --cyclic on the CLI for one-off cyclic runs, or
+#   (b) set "cyclic": true in the advanced JSON for repeatable runs.
+parser.add_argument('--cyclic', action='store_true', default=None,
+                    help='CYCLIC MODIFICATION: Enable cyclic-binder mode '
+                         '(head-to-tail amide bond). Overrides "cyclic": false '
+                         'in the advanced JSON. See README.md for details.')
+# Optional: select a second-pass cyclic-aware validator
+parser.add_argument('--cyclic_validator', type=str, default=None,
+                    choices=[None, 'afcycdesign', 'rf2cyclic'],
+                    help='CYCLIC MODIFICATION: Optional second-pass validator '
+                         '(AfCycDesign or RF2-cyclic). Best-effort: if the '
+                         'binary is not on $PATH the validator is skipped.')

 args = parser.parse_args()
 # perform checks of input setting files
 settings_path, filters_path, advanced_path = perform_input_check(args)

+# CYCLIC MODIFICATION: forward the CLI --cyclic flag into
+# load_json_settings via the module-level override hook.
+if args.cyclic is not None:
+    set_cli_cyclic_override(bool(args.cyclic))

 ### load settings from JSON
 target_settings, advanced_settings, filters = load_json_settings(settings_path, filters_path, advanced_path)