Skip to content

Generate and read scaffold networks - #56

Draft
cpetersen wants to merge 2 commits into
mainfrom
scaffold_network_generator
Draft

Generate and read scaffold networks#56
cpetersen wants to merge 2 commits into
mainfrom
scaffold_network_generator

Conversation

@cpetersen

@cpetersen cpetersen commented Aug 1, 2026

Copy link
Copy Markdown

What

The scaffold_network bridge could build a ScaffoldNetworkParams and hand back an empty
ScaffoldNetwork, but there was no binding for RDKit's generator and no way to read a network's
contents. A scaffold network could not actually be produced from Rust. This adds both layers.

rdkit-sys

wrapper/{include,src}/scaffold_network.{h,cc} + src/bridge/scaffold_network.rs:

  • create_scaffold_network(mol, params) -> Result<SharedPtr<ScaffoldNetworkClass>>
  • update_scaffold_network(network, mol, params) -> Result<()>
  • result accessors returning plain data, one parallel vector per ScaffoldNetwork member:
    scaffold_network_nodes (Vec<String>), scaffold_network_counts / scaffold_network_mol_counts
    (Vec<u32>), scaffold_network_edge_begin_indices / ..._end_indices (Vec<usize>) and
    scaffold_network_edge_types (Vec<String>).

Two things shaped this:

  • RDKit::ScaffoldNetwork::createScaffoldNetwork is a template, and only std::vector<ROMOL_SPTR>
    (which is boost::shared_ptr<ROMol>) and std::vector<std::shared_ptr<ROMol>> are explicitly
    instantiated. cxx hands us one std::shared_ptr<ROMol> at a time, so the wrapper takes a single
    molecule and wraps it in a one element vector rather than fighting cxx over
    Vec<SharedPtr<ROMol>> — the pain point already written up in BACKLOG.md. Callers who want a
    network spanning several molecules accumulate with update_scaffold_network.
  • Accessors return plain data for the same reason: nothing but strings and integers crosses the
    bridge, so there is no Vec<SharedPtr<T>> anywhere. Edge type strings come from RDKit's own
    operator<< for EdgeType so they cannot drift from upstream naming.

Both generator functions are declared Result<...>, so RDKit's ValueErrorException (bad params,
null molecule) surfaces as an Err instead of unwinding through Rust.

build.rs needed no change, as expected — it discovers bridges by scanning src/bridge and already
links RDKitScaffoldNetwork.

rdkit

New src/scaffold_network.rs, re-exported from src/lib.rs, mirroring src/substruct_match/:

  • ScaffoldNetworkParams with new() / with_bond_breakers() and a set_* per RDKit field
  • scaffold_network_for_mol(&ROMol, &ScaffoldNetworkParams) -> Result<ScaffoldNetwork, ScaffoldNetworkError>
  • scaffold_network_for_mols(&[ROMol], ...) for one network spanning several molecules
  • ScaffoldNetwork { nodes: Vec<ScaffoldNetworkNode>, edges: Vec<ScaffoldNetworkEdge> }, with
    ScaffoldNetworkEdgeType as an enum rather than a bare string

ScaffoldNetworkEdge exposes child_idx() / parent_idx() on top of begin_idx / end_idx:
RDKit's beginIdx is the more specific scaffold and endIdx the more general one, which is
easy to get backwards and matters a lot to anything building a hierarchy out of this.

mol_count is Option<u32> because RDKit leaves molCounts empty rather than zero filled when
collectMolCounts is off, so the vector does not always parallel nodes.

Two behaviours worth knowing about

Both are pinned by tests so they cannot regress silently.

  1. new_scaffold_network_params(&vec![]) disables fragmentation entirely. The argument goes
    straight to ScaffoldNetworkParams(const std::vector<std::string> &bondBreakersSmarts), so an
    empty vec means no bond breaker reactions and every network comes back as a single node. The
    pre-existing test only ever called it with an empty vec, which is part of why this went unnoticed.
    ScaffoldNetworkParams::new() uses default_scaffold_network_params() instead, which carries
    RDKit's default [!#0;R:1]-!@[!#0:2]>>[*:1]-[#0].[#0]-[*:2].
  2. A molecule with no rings yields an empty-string scaffold node, reached by an Initialize
    edge — e.g. CCO gives nodes ["CCO", ""]. Consumers deduping scaffolds by SMILES will want to
    decide deliberately what to do with that node.

Corrections to what we thought was there

  • NetworkEdge::beginIdx / endIdx are size_t, not unsigned.
  • ScaffoldNetworkParams has no bondBreakersSmarts member; it has
    bondBreakersRxns (std::vector<std::shared_ptr<ChemicalReaction>>) and the SMARTS vector is a
    constructor argument only. The existing bridge already had this right.
  • counts / molCounts are std::vector<unsigned>.

Versions

rdkit and rdkit-sys both 0.4.12 -> 0.4.13.

rdkit's dependency on rdkit-sys also moves from version = "0.4.9" to "0.4.13". That is
load-bearing, not cosmetic: a published rdkit 0.4.13 asking for rdkit-sys ^0.4.9 could resolve
to 0.4.12, which has none of these bindings, and fail to compile.

Downstream should pin rdkit = "0.4.13" once this is released.

Test evidence

Run against RDKit 2024_09_1 on Ubuntu 22.04 (same tarball CI installs), since RDKit is not available
on the dev host.

cargo test --workspace
  rdkit       tests/test_scaffold_network.rs ... 8 passed
  rdkit-sys   tests/test_scaffold_network.rs ... 7 passed
  ... all other suites unchanged and green

The new rdkit-sys tests build a real network for 2-phenylquinoline and assert exact node SMILES,
edge indices and edge types; plus single-ring, ring-free, multi-molecule, empty-bond-breaker and
bad-params cases. The rdkit tests cover the same ground through the safe API and assert the
parent/child direction of every edge by SMILES rather than by index.

cargo fmt --check          # clean, root and rdkit-sys, on nightly as CI does
cargo clippy               # no new warnings (3 pre-existing ones remain in
                           # descriptors.rs, ro_mol.rs, periodic_table.rs — untouched)
clang-format 13 --dry-run --Werror on both wrapper files   # clean

Prior art

This binds RDKit's rdScaffoldNetwork; it is not an implementation of HierS. The two are in the
same family but differ: HierS (Wilkens, Janes & Su, "HierS: hierarchical scaffold clustering using
topological chemical graphs", J. Med. Chem. 2005, 48(9), 3182-93,
doi:10.1021/jm049032d, PMID 15857124) recursively enumerates
ring-delimited substructures, where RDKit applies a bond breaking reaction and additionally emits
Generic, GenericBond and RemoveAttachment scaffolds that HierS has no equivalent of.

HierS is cited in the module docs as the prior art that established this class of hierarchy, and
labelled as such rather than as the implemented algorithm. Worth noting that RDKit's
Code/GraphMol/ScaffoldNetwork/ sources carry no citation of their own, so there is no primary
reference to point at for the exact algorithm.

The scaffold_network bridge could build ScaffoldNetworkParams but had no way
to run RDKit's generator or read a network back, so a network could not be
produced from Rust at all.

rdkit-sys gains create_scaffold_network/update_scaffold_network plus accessors
that return the nodes, counts, molCounts and edges as plain data. RDKit's
createScaffoldNetwork is a template and only std::vector<ROMOL_SPTR> (which is
boost::shared_ptr) and std::vector<std::shared_ptr<ROMol>> are explicitly
instantiated, so the wrapper takes one molecule at a time and wraps it in a
one element vector rather than fighting cxx over Vec<SharedPtr<ROMol>>
(BACKLOG.md). Callers wanting a network across several molecules build it up
with update_scaffold_network.

rdkit gains a safe ScaffoldNetworkParams builder and
scaffold_network_for_mol/scaffold_network_for_mols, returning a ScaffoldNetwork
of nodes and edges. Edges carry child_idx/parent_idx accessors because RDKit's
beginIdx is the more specific scaffold and endIdx the more general one, which
is easy to get backwards.

Worth knowing: new_scaffold_network_params(&vec![]) leaves RDKit with no bond
breaker reactions, so nothing ever fragments and every network is a single
node. The safe wrapper defaults to default_scaffold_network_params instead, and
tests pin both behaviours.
Credits Wilkens/Janes/Su (J. Med. Chem. 2005) as the prior art that established
hierarchical scaffold clustering, while being explicit that it is not the
algorithm RDKit implements: HierS enumerates ring-delimited substructures
directly, where RDKit applies a bond breaking reaction and additionally emits
generic and attachment point scaffolds. RDKit's own sources cite no paper.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant