Add a stateless /v1/scaffold_network endpoint - #140
Draft
cpetersen wants to merge 2 commits into
Draft
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Depends on rdkit-rs/rdkit#56.
What
POST /v1/scaffold_networkcomputes 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 theObjectpayload types, next to the codethat produces them (same shape as
StructureSearchHit/QuerySearchHitinsrc/search/mod.rs)src/rest_api/api/scaffold_network/— the handlersrc/rest_api/api/response_types.rs— request objects and theApiResponseenumThe existing static scaffold dictionary in
src/search/scaffold_search.rs— the precomputed 2Ddictionary 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_typeis RDKit's own label:Fragment,Generic,GenericBond,RemoveAttachmentorInitialize.Failure handling
A bad or pathological input returns 200 with that item's
errorset, never a 500 and never apanic. The 500 variant exists only for the compute task itself dying.
The C++ FFI crash-safety story has three parts:
Resultfrom the rdkit crate, never as an unwind.available. The real protection is refusing oversized input up front:
max_atomsdefaults to 150,comfortably above drug-like.
max_nodes(default 5000) is a backstop on response size, checkedafter the fact.
scaffold SMILES. That is not only cheaper — I tried the re-parsing approach first and handing a
generic SMILES like
*1:*:*:*:*:*:1back to RDKit trips anInvar::Invariantinside theproperty cache, which reaches a
noexceptboundary and aborts the process rather thanraising. That would take the whole server down.
Fragmenting runs on the blocking pool via
spawn_blockingwith rayon inside, so a large batch doesnot tie up an async worker (same shape as
bulk_index).Two things the consumer needs to know
Both are pinned by tests.
CCOreduces to the empty scaffold"", whosegeneric 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.
bond_breaker_smarts: []disables fragmentation entirely and every network comes back as asingle node. Omitting the field keeps RDKit's default breaker, which is what you want.
Also worth flagging:
standardizedefaults to true, matching/v1/standardize. Tautomercanonicalization dominates the cost of the whole call, so a batch job over already-canonical SMILES
should send
"standardize": false.Version pin
rdkitcurrently points at the PR branch so this can be reviewed and tested:Before merge this goes back to
rdkit = { version = "0.4.13" }, once rdkit-rs/rdkit#56 ismerged and released. There is a
TODOon the line. cheminee's own version is left alone.Reaching the Ruby client — needs a tag
.github/workflows/generate_ruby_gem.yamlruns only on a tag push. There is no PR-time gatecomparing the spec to the gem, so merging this does not move
assaydepot/cheminee-rubyat all.After merge, push a tag (next would be
0.1.53) to run the generator, commit toassaydepot/cheminee-rubyand push the gem to RubyGems. Only then does this appear as:Cheminee::DefaultApi#v1_scaffold_network_postCheminee::ScaffoldNetworkRequest,Cheminee::ScaffoldNetworkParams,Cheminee::ScaffoldNetworkResult,Cheminee::ScaffoldNetworkNode,Cheminee::ScaffoldNetworkEdge,Cheminee::ScaffoldNetworkResponseErrorMethod and model names confirmed against the naming the existing gem already uses
(
v1_convert_mol_block_to_smiles_postetc). 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.ymlinstalls, since neither is available on the dev host.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.Spec regenerated with
cheminee rest-api-server spec -o openapi.jsonand checked:/v1/scaffold_networkis present with 200 and 500 responses, and all six models resolve.
openapi.jsonis gitignored, sonothing 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 thesame 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,GenericBondandRemoveAttachmentscaffolds 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 primaryreference to point at for the exact algorithm.