Skip to content

Add a stateless /v1/scaffold_network endpoint - #140

Draft
cpetersen wants to merge 2 commits into
mainfrom
scaffold_network_endpoint
Draft

Add a stateless /v1/scaffold_network endpoint#140
cpetersen wants to merge 2 commits into
mainfrom
scaffold_network_endpoint

Conversation

@cpetersen

@cpetersen cpetersen commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Depends on rdkit-rs/rdkit#56.

What

POST /v1/scaffold_network computes RDKit's scaffold network (rdScaffoldNetwork, JCIM 2020;
the HierS hierarchy conceptually) for a batch of SMILES. Where a Bemis-Murcko scaffold gives one
framework per molecule, this gives the whole hierarchy: iterative ring fragmentation producing a
graph whose nodes are canonical scaffold SMILES and whose edges point from a more specific scaffold
up to a more general one.

It is deliberately a standalone stateless endpoint rather than something computed at index time.
The consumer is a batch compute over a corpus, not cheminee's own search, so it touches no index and
holds no state.

  • src/search/scaffold_network.rs — the compute, plus the Object payload types, next to the code
    that produces them (same shape as StructureSearchHit / QuerySearchHit in src/search/mod.rs)
  • src/rest_api/api/scaffold_network/ — the handler
  • src/rest_api/api/response_types.rs — request objects and the ApiResponse enum

The existing static scaffold dictionary in src/search/scaffold_search.rs — the precomputed 2D
dictionary matched by substructure search as a tantivy accelerator — is a completely different
thing and is untouched.

Schema

Request:

{
  "smiles": [{"smiles": "c1ccc(-c2ccc3ncccc3c2)cc1"}],
  "params": {                                  // all optional
    "include_generic_scaffolds": false,        // RDKit defaults these on
    "include_generic_bond_scaffolds": false,
    "include_scaffolds_with_attachments": true,
    "include_scaffolds_without_attachments": true,
    "keep_only_first_fragment": true,
    "prune_before_fragmenting": true,
    "flatten_isotopes": true,
    "flatten_chirality": true,
    "flatten_keep_largest": true,
    "collect_mol_counts": true,
    "bond_breaker_smarts": ["[!#0;R:1]-!@[!#0:2]>>[*:1]-[#0].[#0]-[*:2]"],
    "standardize": true,                       // default true
    "max_atoms": 150,
    "max_nodes": 5000
  }
}

Response — one entry per input, in order:

[{
  "smiles": "c1ccc(-c2ccc3ncccc3c2)cc1",            // as submitted
  "standardized_smiles": "c1ccc(-c2ccc3ncccc3c2)cc1",
  "nodes": [
    {"scaffold_smiles": "c1ccc(-c2ccc3ncccc3c2)cc1", "is_generic": false, "has_attachments": false, "count": 1, "mol_count": 1},
    {"scaffold_smiles": "*c1ccccc1",                 "is_generic": false, "has_attachments": true,  "count": 1, "mol_count": 1},
    {"scaffold_smiles": "c1ccccc1",                  "is_generic": false, "has_attachments": false, "count": 1, "mol_count": 1}
  ],
  "edges": [
    {"child_idx": 0, "parent_idx": 1, "child_smiles": "c1ccc(-c2ccc3ncccc3c2)cc1", "parent_smiles": "*c1ccccc1", "edge_type": "Fragment"},
    {"child_idx": 1, "parent_idx": 2, "child_smiles": "*c1ccccc1",                 "parent_smiles": "c1ccccc1",  "edge_type": "RemoveAttachment"}
  ],
  "error": null                                     // omitted when absent
}]

Edges carry both the indices and the SMILES at each end. That is redundant, and it roughly
doubles edge payload, but it means a consumer deduping scaffolds into a global graph never has to
carry the per-molecule node array alongside. Happy to drop the SMILES if the payload size matters
more than the convenience.

edge_type is RDKit's own label: Fragment, Generic, GenericBond, RemoveAttachment or
Initialize.

Failure handling

A bad or pathological input returns 200 with that item's error set, never a 500 and never a
panic. The 500 variant exists only for the compute task itself dying.

The C++ FFI crash-safety story has three parts:

  1. Parse and sanitize failures come back through Result from the rdkit crate, never as an unwind.
  2. RDKit runs to completion once started and cannot be interrupted, so a time-based guard is not
    available. The real protection is refusing oversized input up front: max_atoms defaults to 150,
    comfortably above drug-like. max_nodes (default 5000) is a backstop on response size, checked
    after the fact.
  3. Node flags are derived from the edges RDKit already labelled rather than by re-parsing each
    scaffold SMILES. That is not only cheaper — I tried the re-parsing approach first and handing a
    generic SMILES like *1:*:*:*:*:*:1 back to RDKit trips an Invar::Invariant inside the
    property cache, which reaches a noexcept boundary and aborts the process rather than
    raising. That would take the whole server down.

Fragmenting runs on the blocking pool via spawn_blocking with rayon inside, so a large batch does
not tie up an async worker (same shape as bulk_index).

Two things the consumer needs to know

Both are pinned by tests.

  1. A ring-free molecule produces a self-loop. CCO reduces to the empty scaffold "", whose
    generic twin is also "", so RDKit folds them into one node with an edge pointing at itself.
    Anything walking these edges as a DAG has to expect that, and will probably want to drop the
    empty-SMILES node outright.
  2. bond_breaker_smarts: [] disables fragmentation entirely and every network comes back as a
    single node. Omitting the field keeps RDKit's default breaker, which is what you want.

Also worth flagging: standardize defaults to true, matching /v1/standardize. Tautomer
canonicalization dominates the cost of the whole call, so a batch job over already-canonical SMILES
should send "standardize": false.

Version pin

rdkit currently points at the PR branch so this can be reviewed and tested:

rdkit = { git = "https://github.com/rdkit-rs/rdkit", branch = "scaffold_network_generator" }

Before merge this goes back to rdkit = { version = "0.4.13" }, once rdkit-rs/rdkit#56 is
merged and released. There is a TODO on the line. cheminee's own version is left alone.

Reaching the Ruby client — needs a tag

.github/workflows/generate_ruby_gem.yaml runs only on a tag push. There is no PR-time gate
comparing the spec to the gem, so merging this does not move assaydepot/cheminee-ruby at all.

After merge, push a tag (next would be 0.1.53) to run the generator, commit to
assaydepot/cheminee-ruby and push the gem to RubyGems. Only then does this appear as:

  • Cheminee::DefaultApi#v1_scaffold_network_post
  • Cheminee::ScaffoldNetworkRequest, Cheminee::ScaffoldNetworkParams,
    Cheminee::ScaffoldNetworkResult, Cheminee::ScaffoldNetworkNode,
    Cheminee::ScaffoldNetworkEdge, Cheminee::ScaffoldNetworkResponseError

Method and model names confirmed against the naming the existing gem already uses
(v1_convert_mol_block_to_smiles_post etc). No hand-editing of the generated gem.

Test evidence

Run against RDKit 2024_09_1 + TensorFlow 2.15.1 on Ubuntu 22.04, matching what
test_suite.yml installs, since neither is available on the dev host.

cargo test
  tests/api_tests.rs ................ 22 passed   (17 existing + 5 new)
  tests/search_tests.rs ............. 19 passed   (8 existing + 11 new)
  tests/cpd_processing_tests.rs ..... 15 passed
  tests/cli_tests.rs ................  2 passed
  tests/structure_matching_tests.rs .  3 passed
  tests/encoder_tests.rs ............  1 passed
  tests/index_management_tests.rs ...  1 passed
  tests/user_data_indexing_tests.rs .  1 passed
  unittests src/lib.rs ..............  1 passed
  0 failed

New coverage: multi-ring molecule producing a multi-node hierarchy with parent/child edges asserted
by SMILES rather than by index; single ring giving a trivial one-node network; bad SMILES giving a
per-item error inside a 200 while its neighbour in the batch still succeeds; a params toggle
changing the output (9 nodes with generics, 5 without); the generic/attachment flags on all four
combinations; both limits; the empty-bond-breaker case; the ring-free self-loop; and
standardize: false.

cargo fmt --check   # clean
cargo clippy        # no new warnings (3 pre-existing remain in bulk_delete.rs x2 and
                    # compound_processing.rs — untouched)

Spec regenerated with cheminee rest-api-server spec -o openapi.json and checked: /v1/scaffold_network
is present with 200 and 500 responses, and all six models resolve. openapi.json is gitignored, so
nothing is committed for it — CI regenerates at tag time.

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.

Computes RDKit's scaffold network for a batch of SMILES: the full hierarchy of
ring systems and linkers a molecule reduces to, as a graph of canonical
scaffold SMILES with edges pointing from a more specific scaffold up to a more
general one. The primary consumer is a batch corpus compute rather than
cheminee's own search, so this is a standalone stateless endpoint that touches
no index.

Per-molecule failures — unparseable SMILES, a molecule over the atom bound, a
network over the node bound, or an exception out of RDKit — come back inside a
200 with that molecule's error set, so one bad input cannot sink a batch of a
thousand. The 500 is reserved for the compute task itself dying.

RDKit runs to completion once started and cannot be interrupted, so the guard
against a pathological input is to refuse it before RDKit sees it: max_atoms
defaults to 150, with max_nodes as a backstop on response size.

Node flags are derived from the edges RDKit already labelled rather than by
re-parsing the scaffold SMILES. That is not just cheaper: handing a generic
SMILES like *1:*:*:*:*:*:1 back to RDKit trips a property-cache invariant that
aborts the process rather than raising, which would take the server down.

Note the existing static scaffold dictionary in search/scaffold_search.rs is a
different thing entirely and is untouched.

The rdkit dependency points at a branch for now; it goes back to a released
version before this merges.
Credits Wilkens/Janes/Su (J. Med. Chem. 2005) as the prior art behind this
class of hierarchy without claiming it is what RDKit implements, and points at
the unrelated scaffold_search dictionary so the two are not confused.
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