Reference notes from reading three codebases at the commits below, plus a
working prototype (foldsteer/) that implements the design.
| source | commit / version | license |
|---|---|---|
jwohlwend/boltz |
b1ebfc4, v2.2.1 |
MIT |
bytedance/Protenix |
4c355be |
Apache-2.0 |
aqlaboratory/openfold-3 |
f5df7b8, v0.4.x |
Apache-2.0 |
AF3-style models place atoms with a diffusion process trained on a coordinate loss. Nothing in that objective knows what a chemical bond is. The result is a characteristic failure mode: a ligand can land within 2 Å RMSD of the reference pose and still be chemically impossible — an inverted stereocenter, a twisted amide, a distorted aromatic ring, atoms overlapping across a chain interface.
The fix is not retraining. It is to inject chemical knowledge at sampling
time, using the fact that the diffusion sampler predicts a clean structure
x0 at every step and there is nothing stopping you from editing it before the
sampler takes its next step.
Two mechanisms do this, and both Boltz-1x and Protenix-v2 implement both.
Define an energy E(x) that is zero for chemically valid geometry and positive
otherwise. After the denoiser predicts x0, run a few steps of gradient
descent on E and add the correction:
x0 -> x0 - Σ_k w_k(t) · ∇E_k(x0)
In Boltz (model/modules/diffusion.py, ~line 608) this is an inner loop of
num_gd_steps (default 20) applied to atom_coords_denoised, after which the
sampler's ordinary Euler step proceeds unchanged. Guidance edits the denoised
prediction, never the noisy state — that is what keeps it compatible with the
sampler's update rule.
Every term is a flat-bottom restraint: zero energy inside a permitted interval, linear penalty outside it. This choice does real work:
- the gradient magnitude is bounded, so a badly violated constraint cannot blow up the update the way a harmonic term would;
- a structure that is already valid gets exactly zero energy and zero gradient, so guidance is inert on predictions the model already got right.
The second property is the one that makes this safe to leave on by default. The
prototype asserts it as a test: a valid conformer produces max_energy = 0.0
and max_update = 0.0.
| term | geometric variable | catches |
|---|---|---|
| bounds matrix | interatomic distance | bond lengths, 1-3 angles, internal clashes |
| VDW overlap | interatomic distance | steric clash across chains |
| chirality | improper dihedral | inverted stereocenters |
| stereo bond | |dihedral| | E/Z flips |
| planarity (sp2) | |improper| | distorted aromatic rings |
| non-planarity (sp3) | |improper| | flattened sp3 centers |
| conjugated torsion | |sin(dihedral)| | twisted amides |
| connections | distance | covalently linked chains drifting apart |
Bounds come from RDKit's GetMoleculeBoundsMatrix with triangle smoothing —
the same distance-geometry machinery used for conformer embedding, so the
restraint targets a real conformer ensemble rather than one idealized geometry.
Buffers (12.5% on bonds, 30° on stereocenters) loosen the intervals so normal
conformational spread is unpenalized.
Chirality deserves special mention: it is the one property here that cannot be repaired by post-hoc relaxation, because inverting a center requires passing through a planar transition state. It has to be enforced during sampling.
Guidance is local: it descends into the nearest valid geometry. If the model committed to a bad binding mode early, no amount of gradient descent recovers.
FK steering runs num_particles trajectories at once and periodically culls
them. At each checkpoint, particles are scored and resampled with replacement
in proportion to softmax(λ · log G).
The important detail is the choice of potential:
log_G = energy_traj[:, -2] - energy_traj[:, -1] # improvement, not levellog G is the energy decrease since the last checkpoint, not the raw energy.
Rewarding improvement rather than absolute score keeps the population from
collapsing onto whichever particle started in an easy basin.
When guidance is also active, the resampling weights include a correction:
ll_difference = (eps² − (eps + scaled_guidance_update)²).sum() / (2·noise_var)This accounts for guidance having already biased the transition kernel away from the model's own distribution — without it, the particle weights would double-count the steering that guidance already applied.
Cost is linear in particle count: 3 particles ≈ 3× the denoiser calls. This is the expensive half of steering, and the reason Boltz gates it behind a separate flag from guidance.
Protenix ships steering as its own subpackage (protenix/tfg/, 2360 lines:
config.py, engine.py, potentials.py) with a class registry, config-driven
term construction, and a single integration point in
protenix/model/generator.py. That is the strongest available evidence that
the "separate activatable package" you are asking for is the right shape — a
second team building this independently converged on it.
Three substantive additions beyond Boltz:
Projection (SHAKE-like). Instead of only descending the gradient, solve a linearized constraint projection for the minimum-norm correction:
dx = −Jᵀ(JJᵀ)⁻¹ v
for the active constraint set (_solve_constraint_projection,
potentials.py:443). This converges in far fewer iterations than gradient
descent for tight constraints like bond lengths, at the cost of a linear solve
per batch element. Protenix runs it as nested loops
(projection_outer=2, projection_inner=10) ordered chirality → distances.
Denoiser-path guidance (rho). Optionally backpropagate ∇log p(x0)
through the denoiser to get a gradient on x_t rather than x0. Principled,
but it requires a differentiable forward pass through the network. It defaults
to rho=0.0 — off — and I would leave it off: the memory cost of taping an
AF3-scale denoiser is severe and the reported gains come from the other terms.
A stricter validity definition. This is the finding most worth acting on. Protenix-v2 reports that models can score well on standard PoseBusters checks while remaining chemically implausible, and extends PXMeter (v1.1.0) with checks on sp2-center planarity, amide planarity, and sp3 non-planarity. Their Table 1 shows Protenix-v2-TFG at 60.46% joint RMSD-and-validity success under the revised criterion versus Boltz-1x at 53.96%, and Figure 7 identifies sp2 planarity and sp3 non-planarity as the checks with the largest spread between steered and unsteered models.
Boltz's PlanarBondPotential keys off BondType.DOUBLE. RDKit types aromatic
bonds as AROMATIC, and an amide C–N as SINGLE. So a direct port of Boltz's
term covers neither aromatic rings nor amides — exactly the two failure classes
Protenix-v2 calls out. The prototype adds three terms for this
(Sp2CenterPotential, Sp3CenterPotential, ConjugatedTorsionPotential) and
verifies each against a deliberately constructed distortion.
Note the amide case needs a different geometric variable. Planarity there is a
torsion, not an improper — after hydrogens are stripped a secondary amide N
has only two heavy neighbours, so no improper exists — and it is satisfied at
both 0 and π. A flat-bottom interval on the torsion cannot express "near 0 or
near π"; taking |sin(φ)| collapses both into one restraint near zero.
SampleDiffusion._sample_rollout in
openfold3/core/model/structure/diffusion_module.py (~line 289):
for tau, c_tau in enumerate(noise_schedule[1:]):
xl = centre_random_augmentation(xl=xl, atom_mask=atom_mask)
gamma = self.gamma_0 if c_tau > self.gamma_min else 0
t = noise_schedule[tau] * (gamma + 1)
noise = self.noise_scale * sqrt(t² − noise_schedule[tau]²) * randn_like(xl)
xl_noisy = xl + noise
xl_denoised = self.diffusion_module(...) # <-- guidance goes here
delta = (xl_noisy − xl_denoised) / t
xl = xl_noisy + self.step_scale * (c_tau − t) * deltaStructurally identical to Boltz's loop. Guidance inserts between
xl_denoised and delta; FK resampling inserts immediately after, reindexing
xl, xl_noisy, and xl_denoised together.
Two OF3-specific details:
- Coordinate layout is
[B, S, N, 3]— batch, rollout samples, atoms. The engine works on[*B, N, 3], so flatten the leading dims and restore after. The rollout-sample axis doubles as the FK particle axis; the final resampling step collapses each group to one survivor. centre_random_augmentationruns at the top of every step. A guidance update computed at step n is expressed in a frame that no longer applies at step n+1. Boltz handles this by explicitly rotating its storedscaled_guidance_updateby the same random rotation. The simplest correct approach is to recompute each step and use the stored update only within the step that produced it.
Steering needs bonds, elements, and stereo assignments. In OF3 these are already present at sampling time:
single_datasets/inference.py:318attaches the biotiteAtomArrayto the feature dict asfeatures["atom_array"], with a comment calling it a pseudo-feature pending a cleaner mechanism.core/utils/tensor_utils.py:66—dict_multimapspecial-casesAtomArray | strand passes them through as a plain list instead of trying to pad-stack them.
So batch["atom_array"] survives collation into the model, carrying the
BondList (with bond orders), elements, and chain ids. That is enough to
rebuild an RDKit Mol per ligand chain and derive every constraint. No new
feature has to be threaded through the data pipeline, which is what would
otherwise make this a large change.
The prototype's _mols_from_atom_array does this reconstruction. It restricts
to ligand chains — polymer geometry is already well handled by the network, and
Boltz likewise scopes its bounds-matrix term to non-polymer entities.
Three details in that reconstruction only became apparent against real OF3 input, and each failed silently rather than raising:
molecule_type_idholdsMoleculeTypeintegers (LIGAND == 3), not names. Selecting ligands by string dropped every one of them, leaving an empty constraint set and steering that did nothing.- biotite bond orders 5 and 6 are
AROMATIC_SINGLEandAROMATIC_DOUBLE, not two spellings ofAROMATIC. Mapping both onto RDKit'sAROMATICthrows the Kekulé structure away and sanitization then fails on nearly every aromatic ligand — which the extractor logs and skips, so again: no constraints. - Stereo must be perceived from
ref_pos, OF3's reference-conformer feature (RDKit's own generated conformer per component).atom_array.coordis the obvious candidate and the wrong one: at inference it holds no meaningful coordinates, because it is what the model is about to predict.
The lesson generalizes past OF3 — every one of these turns steering into a
no-op without an error — so the adapter counts what it found (STATS) and
tests/test_of3_extraction.py pins all three.
OF3 already has a feature shaped exactly like steering: pocket constraints.
data/pipelines/featurization/pocket_constraints.pybuilds sampler-only tensors (pocket_sampling_vdw_radii, ligand masks, RDKit conformers) and returns{}when the feature is not requested;model/structure/pocket_constraints.pyconsumes them;SampleDiffusion.forwardcalls_pocket_sampling_enabled(batch)and runs a second partial-diffusion rollout when set.
It even computes VDW radii and generates RDKit conformers already. Steering is the same shape of change, which is good evidence the seam is real rather than being forced.
The requirement was that steering work independently of OpenFold3 — installable and activatable on its own. The design that achieves this is a two-sided contract with a single data structure in the middle.
host model foldsteer host model
┌────────────────┐ ┌──────────────────────┐ ┌───────────────┐
│ batch / │─────▶│ ChemicalContext │─────▶│ sampling loop │
│ AtomArray / │ adapt│ (atom-indexed │ hook │ x0 += guide │
│ RDKit mols │ │ constraint tensors)│ │ idx = resamp │
└────────────────┘ └──────────────────────┘ └───────────────┘
ChemicalContext holds only atom-indexed tensors in the host's atom ordering.
It knows nothing about MSAs, trunk embeddings, tokens, or checkpoints. The
engine consumes it and exposes two pure functions:
update = engine.guide(x0, t) # tensor -> tensor
idx = engine.resample_indices(x0, t, ...) # -> integer indicesThe engine never sees the denoiser, the noise schedule, or the model. That is what makes it host-agnostic: adding a second host means writing one adapter function, not touching the physics.
Concretely, the package imports nothing from OpenFold3 at module scope — the adapter imports it lazily inside the patch function. Verified:
openfold3 imported at module scope: False
9 passed
The whole test suite runs with no folding model installed and no GPU.
from foldsteer.adapters.openfold3 import patch_sample_diffusion
from foldsteer import default_config
unpatch = patch_sample_diffusion(model, default_config(num_particles=3))
... # run inference as normal
unpatch() # restoreMonkey-patching the bound method rather than subclassing is deliberate: OF3
builds SampleDiffusion inside the model's __init__ from a config dict, so
substituting the class means rebuilding the model. Patching the method leaves
checkpoint loading untouched and makes the change reversible within a process.
That form needs the model object, which under OF3's own runner does not exist
yet: Lightning constructs the model inside InferenceExperimentRunner, so
there is no seam to reach the sampler beforehand.
patch_sample_diffusion_class(config) closes the gap by wrapping
SampleDiffusion.__init__, so every sampler Lightning builds afterwards is
handed to the same instance-level patch above. The cost is that the CLI must be
invoked in-process (cli.main(["predict", ...], standalone_mode=False)) — a
subprocess builds its model in a different interpreter and runs unsteered, with
no error. examples/run_of3_steered.py is the whole activation path, and the
STATS counters exist so a silently unsteered run is visible.
For upstreaming rather than bolting on, the same engine calls would go directly
into _sample_rollout behind a steering_config argument defaulting to
None — about 15 lines at two call sites.
Everything below is measured by the prototype, not asserted.
Gradients are correct. All eight potentials check against autograd across
four molecules (alanine, a trans-amide, benzanilide, a dipeptide). Max absolute
error ≈ 1e-15 — machine precision.
chiral_drug BoundsMatrixPotential K=15 err=8.9e-16
chiral_drug ChiralAtomPotential K=1 err=2.3e-15
aromatic BoundsMatrixPotential K=105 err=8.9e-16
complex BoundsMatrixPotential K=136 err=4.4e-16
complex Sp2CenterPotential K=3 err=7.2e-16
amide ConjugatedTorsionPotential K=1 err=0.0e+00
Writing analytic gradients rather than using autograd was the single largest source of bugs — two broadcasting errors in the chain rule, both caught only by this comparison. If you implement this, write the autograd check first.
Both bugs were the same mistake: inserting the arity axis at -3 instead of
-2 when scattering dE/dv · dv/dx back to atom space. It is worth
understanding why this is dangerous rather than merely wrong. de_dv is
[*B, K] and dv_dx is [*B, A, K, 3], where the arity A is 2 for distance
terms and 4 for dihedrals. unsqueeze(-3) on a single-batch tensor yields
(1, B, K, 1) instead of the required (B, 1, K, 1). When B == A this still
broadcasts — silently, with wrong values and no exception. When B != A it
raises, and for unbatched input -3 is outside torch's valid range entirely.
So the failure is load-bearing on batch size, which is exactly the kind of bug
that survives a smoke test and reaches production. tests/test_broadcast_regression.py
sweeps batch sizes that both do and do not collide with the arity, plus the
unbatched and two-leading-dim ([B, S, N, 3], OF3's layout) cases. Reintroducing
the bad axis fails 7 of its 8 cases — and passes the 8th, which is the
collision case, confirming that a single-batch-size test would have missed it.
Valid geometry is untouched. An MMFF-optimized conformer gives
max_energy = 0.0, max_update = 0.0.
The documented failure modes are caught. Each distortion built explicitly from a valid conformer:
| distortion | E(valid) | E(distorted) |
|---|---|---|
| twisted amide (90°) | 0.0000 | 0.6754 |
| puckered aromatic ring | 0.0000 | 1.2202 |
| flattened sp3 center | 0.0000 | 0.3487 |
End-to-end against a mock OF3 sampler replicating _sample_rollout's exact
contract — same signature, same [B,S,N,3] layout, same augmentation and noise
schedule — with a denoiser that returns a deliberately distorted ligand:
| variant | mean final strain (6 seeds) |
|---|---|
| unsteered | 11.30 |
| guidance only | 5.74 |
| FK only (3 particles) | 9.28 |
| guidance + FK (3 particles) | 5.66 |
Guidance accounts for most of the reduction. Caveat: the mock denoiser adds independent Gaussian noise each step, so its particles differ by noise rather than occupying distinct binding modes — the situation FK steering exists to exploit. This benchmark therefore understates FK and should not be read as evidence against it; the published Boltz-1x and Protenix-v2 results, which use real denoisers, are the relevant evidence there.
- Start with guidance, not FK. Most of the validity gain, ~1.2× cost instead of 3×, and no change to the sample-count semantics.
- Port Boltz's default weights verbatim to begin with. They are the only published settings validated against PoseBusters at scale. Retune against a benchmark, not intuition.
- Include the sp2/sp3/amide terms from the start. They are cheap, and Protenix-v2's analysis says they are where steered and unsteered models differ most.
- Write the autograd gradient check before the potentials. See §7.
- Evaluate under the revised validity criterion, not just standard PoseBusters — otherwise you cannot see the failures §4 is about.
- Watch the augmentation frame. The most likely silent bug in an OF3 port
is applying a stale guidance update after
centre_random_augmentationhas rotated the coordinates.
The prototype covers guidance, FK steering, and the eight potentials. Left out:
Protenix's constraint projection (worth adding — better convergence on bond
lengths for a linear solve per step), denoiser-path guidance (rho; skip),
symmetric-chain COM and template restraints (in the context schema, no
potentials written), and the real _mols_from_atom_array path, which is
written against the OF3 AtomArray API but has been exercised only on
synthetic arrays — it needs a run against genuine OF3 inference output before
it can be trusted.