Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 40 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,13 @@ WaterFlow processes structure files through several stages to create training-re
- ASU ligand atoms are appended after ASU and mate atoms and carry the boolean `is_ligand` mask plus `residue_index = -1` (they have no residue embedding, so residue pooling masks them out)
- `is_ligand` marks **ASU ligands only**. Symmetry-mate generation is currently unfiltered, so mate nodes can include HETATM and water atoms that `is_ligand` does not mark — see `TODO(mates)` in `ProteinWaterDataset._preprocess_one`. Don't treat `is_ligand` as an exhaustive ligand selector
- Edge types (defined in `src/constants.py`):
- `('protein', 'pp', 'protein')`: protein-protein edges
- `('protein', 'pw', 'water')`: protein to water
- `('water', 'wp', 'protein')`: water to protein
- `('water', 'ww', 'water')`: water-water edges
- `('protein', 'pp', 'protein')`: protein-protein edges — cached at preprocessing
- `('protein', 'pw', 'water')`: protein to water — built at runtime
- `('water', 'wp', 'protein')`: water to protein — built at runtime, ablatable
- `('water', 'ww', 'water')`: water-water edges — built at runtime, ablatable
- Only PP edges are stored in the geometry cache; every water-touching edge is
rebuilt each forward pass, since water positions move during integration. See
[Edge Construction](#edge-construction)
Comment on lines +88 to +90
- Default edge cutoff: 8.0Å (`RBF_CUTOFF` in constants.py)

**Feature Encoding**
Expand Down Expand Up @@ -199,6 +202,34 @@ WaterFlow uses a two-stage architecture:
| `esm` | Uses ESM3 language model embeddings | Yes (`generate_esm_embeddings.py`) |
| `slae` | Uses SLAE ([Strictly Local All-Atom Environment](https://www.biorxiv.org/content/10.1101/2025.10.03.680398v1)) embeddings | Yes (`generate_slae_embeddings.py`) |

### Edge Construction

Water-touching edges (PW, WW, WP) are rebuilt every forward pass because water
positions change during integration. How they are built is fixed at model
construction, so training and inference always agree:

| `--dynamic_edge_policy` | Behaviour |
|-------------------------|-----------|
| `radius` (default) | Connect every pair within `--cutoff`, capped at `--max_neighbors` per source |
| `knn` | Connect a fixed number of nearest neighbours (`--k_pw`, `--k_ww`, `--k_wp`) |
Comment on lines +213 to +214

The two differ in which side the neighbour budget applies to. KNN queries *per
destination*, so every destination is guaranteed edges but a source may have
none — coverage checks must read the destination row. Radius guarantees nothing:
a water with no protein atom inside `--cutoff` gets no PW edges at all.

`--knn_fallback_k` repairs that. Under `radius`, any water the query stranded is
reconnected to that many nearest protein atoms regardless of distance. Set it to
`0` to disable. It has no effect under `knn`, which cannot strand a node.

Set `--disable_ww` / `--disable_wp` to ablate those edge types; PW and PP are
always active.

> Configs written before the radius/KNN split recorded a three-valued
> `dynamic_edge_policy` (`auto`, `radius`, `knn_if_isolated`). All three built a
> radius graph, so they load and map to `radius`; whether stranded waters are
> rescued is now `--knn_fallback_k`'s job.
Comment on lines +211 to +231

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

The Edge Construction section does not match the shipped policy set.

scripts/train.py accepts four values: auto, radius, knn, and knn_if_isolated. The default is auto. This section lists only radius and knn, and it describes auto and knn_if_isolated as legacy values that map to radius. resolve_edge_policy maps auto to knn_if_isolated when the sampling strategy is scaled_gaussian.

Lines 221-223 also state that --knn_fallback_k repairs stranded waters "under radius". ProteinWaterUpdate.__init__ sets rescue_isolated only when the resolved policy is knn_if_isolated. A positive --knn_fallback_k under radius has no effect, which test_plain_radius_does_not_rescue pins.

Document all four values, the auto resolution rule, and the fact that the rescue requires knn_if_isolated.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 211 - 231, Update the Edge Construction section to
document all four dynamic_edge_policy values: auto, radius, knn, and
knn_if_isolated. Describe that auto resolves to knn_if_isolated for
scaled_gaussian sampling and otherwise follows the standard default behavior,
and state that stranded-water rescue via knn_fallback_k requires the resolved
knn_if_isolated policy; positive fallback values do not rescue under plain
radius.


## Embedding Generation

For `esm` and `slae` encoder types, you must precompute embeddings before training or inference.
Expand Down Expand Up @@ -272,6 +303,11 @@ To resume training from a checkpoint, you can load the model weights and optimiz
| `--scheduler` | `cosine` | LR scheduler: `cosine`, `step`, or `none` |
| `--warmup_steps` | `0` | Linear warmup steps |
| `--processed_dir` | `~/flow_cache/` | Cache directory for preprocessed data |
| `--dynamic_edge_policy` | `radius` | How water-touching edges are built: `radius` or `knn` (see [Edge Construction](#edge-construction)) |
| `--cutoff` | `8.0` | Distance cutoff in Å for radius edges |
| `--knn_fallback_k` | `8` | Nearest neighbours attached to waters stranded by the radius query; `0` disables |
Comment on lines +306 to +308

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the documented defaults.

The table shows --dynamic_edge_policy default radius and lists only radius or knn. scripts/train.py sets default="auto" with choices=["auto", "radius", "knn", "knn_if_isolated"]. The --knn_fallback_k row implies that the rescue applies to radius runs; it applies only to knn_if_isolated.

📝 Proposed table fix
-| `--dynamic_edge_policy` | `radius` | How water-touching edges are built: `radius` or `knn` (see [Edge Construction](`#edge-construction`)) |
+| `--dynamic_edge_policy` | `auto` | How water-touching edges are built: `auto`, `radius`, `knn`, or `knn_if_isolated` (see [Edge Construction](`#edge-construction`)) |
 | `--cutoff` | `8.0` | Distance cutoff in Å for radius edges |
-| `--knn_fallback_k` | `8` | Nearest neighbours attached to waters stranded by the radius query; `0` disables |
+| `--knn_fallback_k` | `8` | Nearest neighbours attached to waters stranded by the radius query under `knn_if_isolated`; `0` disables |
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
| `--dynamic_edge_policy` | `radius` | How water-touching edges are built: `radius` or `knn` (see [Edge Construction](#edge-construction)) |
| `--cutoff` | `8.0` | Distance cutoff in Å for radius edges |
| `--knn_fallback_k` | `8` | Nearest neighbours attached to waters stranded by the radius query; `0` disables |
| `--dynamic_edge_policy` | `auto` | How water-touching edges are built: `auto`, `radius`, `knn`, or `knn_if_isolated` (see [Edge Construction](`#edge-construction`)) |
| `--cutoff` | `8.0` | Distance cutoff in Å for radius edges |
| `--knn_fallback_k` | `8` | Nearest neighbours attached to waters stranded by the radius query under `knn_if_isolated`; `0` disables |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 306 - 308, Update the README options table to match
scripts/train.py: document dynamic_edge_policy’s default as auto and include
auto, radius, knn, and knn_if_isolated as valid policies. Revise the
knn_fallback_k description to state that the rescue applies only to
knn_if_isolated runs, while preserving its default and disabled value.

| `--disable_ww` | `false` | Ablate water→water edges |
| `--disable_wp` | `false` | Ablate water→protein edges |
| `--include_mates` | `false` | Include symmetry mate atoms as protein nodes |
| `--include_ligands` | `true` | Include ligand/ion/cofactor/nucleic acid heavy atoms as protein nodes. Negate with `--no-include_ligands` |
| `--save_dir` | `../flow_checkpoints` | Directory to save checkpoints |
Expand Down
13 changes: 11 additions & 2 deletions scripts/inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,8 +273,17 @@ def build_model_from_config(config: dict, device: torch.device) -> nn.Module:
drop_rate=config.get("drop_rate", 0.1),
n_message_gvps=config.get("n_message_gvps", 2),
n_update_gvps=config.get("n_update_gvps", 2),
k_pw=config.get("k_pw") or 16,
k_ww=config.get("k_ww") or 16,
cutoff=config.get("cutoff", 8.0),
max_neighbors=config.get("max_neighbors", 256),
dynamic_edge_policy=config.get("dynamic_edge_policy", "radius"),
# "auto" depends on which prior the run uses, so pass that through.
sampling_strategy=config.get("sampling_strategy", "uniform_ball"),
knn_fallback_k=config.get("knn_fallback_k", 8),
Comment on lines +279 to +281

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Also forward sampling_strategy to FlowMatcher.

This call restores the recorded sampling_strategy for the model. The FlowMatcher construction later in this file does not receive it, so integration samples the water prior with the "uniform_ball" default. A run trained with "scaled_gaussian" then integrates from a different prior than it was trained on.

🐛 Proposed fix at the FlowMatcher call site
FlowMatcher(
    model=model,
    p_self_cond=config.get("p_self_cond", 0.5),
    sampling_strategy=config.get("sampling_strategy", "uniform_ball"),
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/inference.py` around lines 279 - 281, Update the FlowMatcher
construction in scripts/inference.py to pass the recorded sampling_strategy from
config, defaulting to "uniform_ball" consistently with the earlier configuration
handling. Preserve the existing model and p_self_cond arguments while ensuring
integration uses the training run’s selected prior.

disable_ww=config.get("disable_ww", False),
disable_wp=config.get("disable_wp", False),
k_pw=config.get("k_pw", 12),
k_ww=config.get("k_ww", 8),
k_wp=config.get("k_wp", 8),
).to(device)

return model
Expand Down
77 changes: 74 additions & 3 deletions scripts/train.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@

from src.dataset import get_dataloader
from src.encoder_base import build_encoder
from src.flow import FlowMatcher, FlowWaterGVP
from src.flow import DYNAMIC_EDGE_POLICIES, FlowMatcher, FlowWaterGVP
from src.utils import (
compute_placement_metrics,
compute_rmsd,
Expand Down Expand Up @@ -219,8 +219,70 @@ def parse_args():
default=0.1,
help="Dropout rate for GVP layers (default: 0.1)",
)
p.add_argument("--k_pw", type=int, default=16)
p.add_argument("--k_ww", type=int, default=16)
# edge construction
p.add_argument(
"--dynamic_edge_policy",
type=str,
default="auto",
choices=["auto", *DYNAMIC_EDGE_POLICIES],
help=(
"How water-touching edges are built: 'radius' connects everything "
"within --cutoff, 'knn' takes a fixed neighbour count, "
"'knn_if_isolated' is radius plus a rescue for stranded waters. "
"'auto' picks radius under uniform_ball and knn_if_isolated under "
"scaled_gaussian (default: auto)"
),
)
p.add_argument(
"--cutoff",
type=float,
default=8.0,
help="Distance cutoff in Angstroms for radius edges (default: 8.0)",
)
p.add_argument(
"--max_neighbors",
type=int,
default=256,
help="Per-source cap on radius query results (default: 256)",
)
p.add_argument(
"--knn_fallback_k",
type=int,
default=8,
help=(
"Nearest neighbours attached to waters the radius query stranded; "
"0 disables the rescue. Ignored under --dynamic_edge_policy knn "
"(default: 8)"
),
)
p.add_argument(
"--disable_ww",
action="store_true",
help="Ablate water->water edges",
)
p.add_argument(
"--disable_wp",
action="store_true",
help="Ablate water->protein edges",
)
p.add_argument(
"--k_pw",
type=int,
default=12,
help="Nearest neighbours for protein->water edges under 'knn' (default: 12)",
)
p.add_argument(
"--k_ww",
type=int,
default=8,
help="Nearest neighbours for water->water edges under 'knn' (default: 8)",
)
p.add_argument(
"--k_wp",
type=int,
default=8,
help="Nearest neighbours for water->protein edges under 'knn' (default: 8)",
)

# optional cached-embedding override
p.add_argument(
Expand Down Expand Up @@ -563,8 +625,17 @@ def build_model(
n_message_gvps=args.n_message_gvps,
n_update_gvps=args.n_update_gvps,
drop_rate=args.drop_rate,
cutoff=args.cutoff,
max_neighbors=args.max_neighbors,
dynamic_edge_policy=args.dynamic_edge_policy,
# "auto" depends on which prior the run uses, so pass that through.
sampling_strategy=args.sampling_strategy,
knn_fallback_k=args.knn_fallback_k,
disable_ww=args.disable_ww,
disable_wp=args.disable_wp,
k_pw=args.k_pw,
k_ww=args.k_ww,
k_wp=args.k_wp,
Comment on lines +628 to +638

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Trace every sampling_strategy producer and consumer.
rg -n -C3 'sampling_strategy' scripts/ src/ tests/

Repository: diff-use/WaterFlow

Length of output: 6895


🏁 Script executed:

#!/bin/bash
# Inspect the argument parser and the FlowMatcher call sites to determine the exact wiring failure.
set -e

printf '\n## scripts/train.py parse_args sampling_strategy references\n'
rg -n -C4 'def parse_args|add_argument|sampling_strategy|args\.' scripts/train.py | sed -n '1,220p'

printf '\n## scripts/train.py FlowMatcher construction\n'
sed -n '1076,1112p' scripts/train.py | cat -n

printf '\n## scripts/inference.py FlowMatcher construction\n'
sed -n '260,290p' scripts/inference.py | cat -n

printf '\n## src/flow.py FlowMatcher signature and sampling implementation\n'
sed -n '860,955p' src/flow.py | cat -n
sed -n '850,898p' src/flow.py | cat -n

Repository: diff-use/WaterFlow

Length of output: 17436


Wire sampling_strategy through training. scripts/train.py#L632 reads args.sampling_strategy, but scripts/train.py:parse_args() does not define --sampling_strategy, so training fails at startup. Add the CLI option with uniform_ball / scaled_gaussian choices, then pass sampling_strategy=args.sampling_strategy to the FlowMatcher construction.

📍 Affects 2 files
  • scripts/train.py#L628-L638 (this comment)
  • scripts/train.py#L1101-L1108
  • scripts/inference.py#L279-L281
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/train.py` around lines 628 - 638, The training CLI must define the
sampling_strategy argument before it is read. In scripts/train.py lines 628-638,
retain sampling_strategy=args.sampling_strategy in the FlowMatcher construction;
in scripts/train.py lines 1101-1108, add the parse_args option with uniform_ball
and scaled_gaussian choices; and in scripts/inference.py lines 279-281, make the
corresponding argument wiring consistent as required by the existing FlowMatcher
configuration.

).to(device)

return model
Expand Down
29 changes: 29 additions & 0 deletions src/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,35 @@
# all edge types used in the model (future support: het atoms)
ALL_EDGE_TYPES = [EDGE_PW, EDGE_WW, EDGE_PP, EDGE_WP]


def get_active_edge_types(
disable_ww: bool = False, disable_wp: bool = False
) -> list[tuple[str, str, str]]:
"""
Return the active edge types for a model configuration.

PW and PP are always active: PW carries protein context onto waters and PP
is read from the geometry cache. WW and WP are ablatable.

The returned order differs from ``ALL_EDGE_TYPES``. That is safe -- edge
types key ``HeteroConv``'s parameters by name (``convs.<protein___pw___water>``),
not by position, so ordering does not affect state-dict compatibility.

Args:
disable_ww: Drop water -> water edges.
disable_wp: Drop water -> protein edges.

Returns:
List of (src_type, relation, dst_type) tuples.
"""
etypes = [EDGE_PW, EDGE_PP]
if not disable_ww:
etypes.append(EDGE_WW)
if not disable_wp:
etypes.append(EDGE_WP)
return etypes


# Standard 3-letter to 1-letter amino acid mapping
# Includes 20 canonical amino acids plus common non-standard residues
# Non-canonical residues not in this dict should be mapped to 'X'
Expand Down
Loading
Loading