Rust-first SMILES enumeration and next-token decoding with Python bindings.
Project description
GRIMACE
SMILES enumeration with exact next-token decoding.
grimace is a Rust-first RDKit add-on for exact rooted SMILES support and
online next-token decoding. It provides:
- exact support enumeration for a molecule under RDKit-style writer flags
- exact token inventories implied by that support
- legal next-token choices from a current SMILES prefix
By "support" we mean the full set of reachable rooted SMILES strings for the chosen writer flags. A "rooted SMILES" here is a SMILES string generated with a fixed starting atom for a connected molecule, or with one rooted fragment/local root inside the preserved fragment order for a disconnected molecule.
Today, that public runtime is intentionally narrow: exact support and decoding
for RDKit's canonical=False, doRandom=True writer regime under the current
stable writer convention.
There are two separate correctness ideas in this project:
- principled SMILES/chemistry semantics: emitted strings should be valid and parse back to the intended graph and stereo assignment
- RDKit writer parity: emitted strings should match RDKit's actual writer support for the supported regime
grimace targets the current stable RDKit writer convention, currently
RDKit 2026.03.1. Older slash/backslash serialization conventions are out of
scope. The dependency floor is rdkit>=2026.3, but exact output parity is
only validated against that current stable writer convention; newer RDKit
releases may still require fixture or expectation updates.
The package metadata declares Python >=3.11 and rdkit>=2026.3. The
currently exercised CI and release matrix is narrower and documented below.
GRIMACE stands for "graph representation integrating multiple alternate chemical equivalents", motivated by research on NMR spectroscopy with language transformers (link).
[!WARNING]
grimaceis still evolving. The supported public API is usable for the documented runtime subset, but feature coverage is still limited and some public details may continue to change between releases.
[!IMPORTANT]
grimaceis distributed underPolyForm-Noncommercial-1.0.0. Commercial use is not permitted under the current license.
Choose the API
The only supported public Python import name is grimace.
[!CAUTION] Install the PyPI distribution named
grimace-py; import the package asgrimace. Plainpip install grimaceinstalls an unrelated older package, not this library.python -m pip install grimace-py
Main entrypoints:
MolToSmilesEnum(...)Returns the exact SMILES support as an iterator of finished strings.MolToSmilesDecoder(...)Returns an online branch-preserving decoder state.MolToSmilesDeterminizedDecoder(...)Returns an online decoder that merges same-text next choices.MolToSmilesDeviation(...)Reports the first place where a candidate string or token sequence leaves the molecule's supported SMILES language.MolToSmilesTokenInventory(...)Returns the exact set of tokens that can appear in one decoder step.MolToSmilesTokenInventorySuperset(...)Returns a static conservative token inventory for vocabulary coverage.PrepareMol(...)Prepares an RDKit molecule once under fixed writer flags.
Supporting public types:
MolToSmilesChoiceEach choice has.textfor the emitted token and.next_statefor the decoder state after taking that token.PreparedMolOpaque prepared molecule accepted by the runtime, withto_bytes()andPreparedMol.from_bytes(...)for byte round trips.SmilesDeviationDiagnostic result returned byMolToSmilesDeviation(...).
Important runtime requirements today
The public signatures mirror RDKit flag names and defaults, but the current runtime intentionally supports only a strict subset.
[!CAUTION] The signatures preserve RDKit-like defaults for surface compatibility, but those defaults are not currently supported. A naive
grimace.MolToSmilesEnum(mol)call raisesNotImplementedError; passcanonical=FalseanddoRandom=Trueexplicitly.
Today, pass:
canonical=FalsedoRandom=True- omit
rootedAtAtomor passrootedAtAtom=-1for all-roots behavior - pass
rootedAtAtom >= 0for one explicit root - other negative integer
rootedAtAtomvalues are also accepted for RDKit compatibility and behave like-1, but-1is the preferred public spelling rootedAtAtom=Noneis not supported; omit the argument or use-1instead
Unsupported flag combinations fail fast with NotImplementedError. Other
invalid public inputs can still raise more specific exceptions such as
IndexError or ValueError.
The most important rootedAtAtom semantics are:
rootedAtAtom=<idx>uses one explicit starting atom for connected molecules.rootedAtAtom=-1forMolToSmilesEnum(...)returns the exact support unioned across all root atoms.rootedAtAtom=-1for the decoder classes starts from one merged all-roots decoder state.rootedAtAtom=-1forMolToSmilesTokenInventory(...)andMolToSmilesTokenInventorySuperset(...)returns the token inventory unioned across all root atoms.- omitting
rootedAtAtommeans the same thing as passing-1. - other negative integer
rootedAtAtomvalues also behave like-1, to stay close to RDKit's public binding behavior. rootedAtAtom=Noneis rejected across the public API, matching RDKit's binding behavior.- for disconnected molecules, fragment order is preserved; a nonnegative
rootedAtAtomselects the rooted fragment and its local root atom within that fixed fragment order, but non-rooted fragments can still vary internally.
Quickstart
All examples below use the current supported runtime subset:
FLAGS = dict(
canonical=False,
doRandom=True,
)
1. Enumerate the exact support
If you want the exact support across all possible roots, rootedAtAtom=-1 is
the simplest public entrypoint:
from rdkit import Chem
import grimace
mol = Chem.MolFromSmiles("CC(=O)Oc1ccccc1C(=O)O")
all_smiles = tuple(
grimace.MolToSmilesEnum(
mol,
rootedAtAtom=-1,
isomericSmiles=False,
**FLAGS,
)
)
assert len(all_smiles) == 304
If instead you want the exact support from one specific root atom, pass that root explicitly:
root_0_smiles = tuple(
grimace.MolToSmilesEnum(
mol,
rootedAtAtom=0,
isomericSmiles=False,
**FLAGS,
)
)
2. Decode online, one token at a time
MolToSmilesDecoder(...) is branch-preserving. It exposes the exact legal next
choices for the current prefix, and each choice points to a successor state.
decoder = grimace.MolToSmilesDecoder(
mol,
rootedAtAtom=0,
isomericSmiles=False,
**FLAGS,
)
for _ in range(7):
prefix = decoder.prefix if decoder.prefix else '""'
print(f"{prefix} -> {[choice.text for choice in decoder.next_choices]}")
decoder = decoder.next_choices[0].next_state
Early output on aspirin looks like:
"" -> ['C']
C -> ['C']
CC -> ['(', '(']
CC( -> ['=']
CC(= -> ['O']
CC(=O -> [')']
CC(=O) -> ['O']
Notice the duplicate "(" at CC. Those are different branches with the same
emitted token text. That is deliberate: MolToSmilesDecoder(...) preserves
branch identity instead of merging it away.
3. Merge same-text choices when you only care about token text
MolToSmilesDeterminizedDecoder(...) exposes at most one choice per token text
by merging same-text continuations into one combined state.
For example, the merged all-roots decoder can trace one exact route to
c1(ccccc1OC(=O)C)C(O)=O for aspirin:
route = [
"c", "1", "(", "c", "c", "c", "c", "c", "1",
"O", "C", "(", "=", "O", ")", "C", ")",
"C", "(", "O", ")", "=", "O",
]
decoder = grimace.MolToSmilesDeterminizedDecoder(
mol,
rootedAtAtom=-1,
isomericSmiles=False,
**FLAGS,
)
for token in route:
choices = {choice.text: choice.next_state for choice in decoder.next_choices}
decoder = choices[token]
assert decoder.is_terminal
assert decoder.prefix == "".join(route)
The first few merged decisions on that route are:
"": choose"c"from["C", "O", "c"]"c1": choose"("from["(", "c"]"c1(": choose"c"from["O", "c", "C"]"c1(ccccc1": choose"O"from["C", "O"]
4. Diagnose a candidate serialization
MolToSmilesDeviation(...) returns None for an accepted candidate, otherwise
it reports the first mismatch and the legal next Grimace token texts.
small = Chem.MolFromSmiles("CCO")
kwargs = dict(rootedAtAtom=-1, isomericSmiles=False, **FLAGS)
assert grimace.MolToSmilesDeviation(small, "CCO", **kwargs) is None
deviation = grimace.MolToSmilesDeviation(small, "CCN", **kwargs)
assert deviation.accepted_text == "CC"
assert deviation.rejected_text == "N"
assert deviation.legal_next_tokens == ("O",)
String candidates are matched as text. Sequence candidates are atomic external tokens, so boundaries matter:
grimace.MolToSmilesDeviation(small, "CCl", **kwargs).accepted_text
# 'CC'
grimace.MolToSmilesDeviation(small, ("C", "Cl"), **kwargs).accepted_text
# 'C'
5. Ask for the exact token inventory
MolToSmilesTokenInventory(...) answers a different question: not "what full
strings are possible?" but "what one-step tokens can ever appear?"
inventory = grimace.MolToSmilesTokenInventory(
mol,
rootedAtAtom=-1,
isomericSmiles=False,
**FLAGS,
)
assert "C" in inventory
assert "(" in inventory
assert "c" in inventory
The result is a sorted tuple of distinct tokens.
For fast dataset vocabulary coverage, use the static inventory:
vocab_tokens = set()
for mol in mols:
vocab_tokens.update(
grimace.MolToSmilesTokenInventorySuperset(
mol,
rootedAtAtom=-1,
isomericSmiles=True,
**FLAGS,
)
)
For the same molecule and flags, the exact inventory is contained in the superset inventory.
6. Prepare once, reuse, or serialize
PrepareMol(...) pays the RDKit preparation cost once and returns an opaque
PreparedMol.
prepared = grimace.PrepareMol(mol)
payload = prepared.to_bytes()
restored = grimace.PreparedMol.from_bytes(payload)
all_smiles = tuple(
grimace.MolToSmilesEnum(
restored,
rootedAtAtom=-1,
**FLAGS,
)
)
The writer flags passed to runtime calls must match the writer flags baked into
the prepared object. rootedAtAtom, canonical, and doRandom remain runtime
options.
What counts as a token?
A token is one string emitted by one decoder transition. Tokens are defined by the walker itself, not by splitting a finished SMILES into characters and not by integer token IDs. They come from two places:
- the prepared graph's RDKit-style atom and bond tokens, such as
C,c,Cl,[C@H],=,/, or\\ - SMILES syntax literals inserted by the walker, such as
(,),1, or%10
So a token is exactly one appendable SMILES fragment for the current state. It may be one character or several.
Installation
Install the PyPI distribution named grimace-py:
python -m pip install grimace-py
Then import it as grimace:
import grimace
Plain pip install grimace installs an unrelated older project with the same
name, not this library.
PyPI and GitHub release assets currently publish Linux x86_64 wheels for
CPython 3.12 and 3.13. Other environments may require a source build and
are not covered by the release wheels.
Current continuously exercised matrix:
- Linux source-tree tests on CPython
3.12 - Linux wheel build and smoke tests on CPython
3.12and3.13 - source distribution build, metadata validation, and installed-artifact smoke tests
Other Python versions and non-Linux platforms are expected source-build paths,
not part of the current release asset or CI matrix.
Python 3.11 is in that source-build category today: declared, but not part of
the current CI matrix.
GitHub release wheels are also available:
| System | 3.12 | 3.13 |
|---|---|---|
| Linux x86_64 | wheel | wheel |
The built package depends on rdkit>=2026.3.
For local development or a source build, you need:
- a Rust toolchain with
rustc >= 1.83 maturin
Then:
python -m venv .venv
. .venv/bin/activate
python -m pip install maturin
maturin develop --release
Timings
The opt-in timing benchmark generates two artifacts:
- docs/timings.tsv: raw measured summary data
- docs/timings.md: rendered table and column descriptions
The table reports both decoder variants:
- branch-preserving exhaustive traversal via
MolToSmilesDecoder(...) - determinized exhaustive traversal via
MolToSmilesDeterminizedDecoder(...)
Current takeaway from the generated table:
- the table does not time the direct public
MolToSmilesEnum(..., rootedAtAtom=-1)path - the published
Grimace enumcolumn is the more conservative exact baseline: explicit union over per-rootMolToSmilesEnum(..., rootedAtAtom=root_idx)calls - some merged decoder rows are numerically lower than that per-root union column, so this table does not prove a universal exact-method ranking
MolToSmilesDeterminizedDecoder(...)can reduce exhaustive decoder cost on some molecules- the table is still a small curated benchmark: 9 molecules, 2 writer modes, 7 timing repeats, and one development machine
- this is not a workload study and not an exact-versus-exact comparison
- the RDKit columns are not exact enumeration; they are random sampling until
RDKit happens to reach
1/2or full support - because of that, RDKit can be cheaper when you only want a few random strings, especially on small cases
- but on the larger molecules in this table, Grimace exact methods were usually much faster than this RDKit sampling-to-coverage baseline when guaranteed full support was the goal
Regenerate it with:
RUN_PERF_TESTS=1 PYTHONPATH=python:. .venv/bin/python -m unittest tests.perf.test_readme_timings -q
Current limits
The public API keeps RDKit MolToSmiles flag names, but it does not aim for
full RDKit writer-surface parity yet.
For terminology and test policy, see Correctness contracts. In short: RDKit writer-matching behavior is intentionally separate from the principled SMILES/chemistry semantics layer. RDKit-specific traversal and directional-bond placement rules belong to the writer-parity layer, not the generic semantic layer.
Known stereo writer-parity work in progress:
minimal_nonstereo_double_hazard(C/N=C1C=C/C(=N/C)[N-]/1) currently has the same direction-erased skeleton support as RDKit, but some slash/backslash markers are placed in different writer positions.reduced_porphyrin_traversal_couplingcurrently exercises a larger traversal-coupling gap where RDKit's writer policy narrows the traversal assignment support beyond the semantic carrier choices.- A separate RDKit-known quirk,
dative_carbonyl_stereo_annotation_drops_on_smiles_roundtrip, is tracked as observed RDKit behavior rather than a generic SMILES semantics rule.
This work is active on the stereo-constraint-model branch and is not yet in
the released mainline runtime. The goal is exact RDKit writer support for the
documented runtime subset, without mixing RDKit-specific spelling quirks into
the generic semantic layer.
The current public runtime contract is the one described in Important runtime requirements today. The supported writer flags are:
isomericSmileskekuleSmilesallBondsExplicitallHsExplicitignoreAtomMapNumbers
Anything outside that runtime subset fails fast. Unsupported flag combinations
raise NotImplementedError. Other invalid public inputs can still raise more
specific exceptions such as IndexError or ValueError.
Disconnected molecules are supported by the public APIs.
MolToSmilesEnum(...),MolToSmilesDecoder(...), andMolToSmilesDeterminizedDecoder(...)compose fragment-wise behavior directly.MolToSmilesTokenInventory(...)returns the union of fragment inventories plus the"."separator token.MolToSmilesTokenInventorySuperset(...)returns the corresponding static fragment inventory union plus the"."separator token.
Docs
License
grimace is source-available under PolyForm Noncommercial 1.0.0.
Third-party components remain under their own licenses; see
THIRD_PARTY_NOTICES.md.
Commercial use requires a separate commercial license from the author.
The software is provided as is, without warranty or liability, to the extent
allowed by law.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file grimace_py-0.1.11.tar.gz.
File metadata
- Download URL: grimace_py-0.1.11.tar.gz
- Upload date:
- Size: 7.4 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
799526edd0079e6721db659b0421821d2a937d6454d90b0e379148f225045f28
|
|
| MD5 |
0b07b1da3aaa4e9291c1019428823182
|
|
| BLAKE2b-256 |
8e41e5d0d18d21f06ae80f8560e81d966fbac7e96c32769ffe34993ded594b44
|
Provenance
The following attestation bundles were made for grimace_py-0.1.11.tar.gz:
Publisher:
release.yml on numpde/grimace-py
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
grimace_py-0.1.11.tar.gz -
Subject digest:
799526edd0079e6721db659b0421821d2a937d6454d90b0e379148f225045f28 - Sigstore transparency entry: 1607283975
- Sigstore integration time:
-
Permalink:
numpde/grimace-py@b7d036a3613c2cb38e2866e5d04cb4de32c3ceca -
Branch / Tag:
refs/tags/v0.1.11 - Owner: https://github.com/numpde
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@b7d036a3613c2cb38e2866e5d04cb4de32c3ceca -
Trigger Event:
push
-
Statement type:
File details
Details for the file grimace_py-0.1.11-cp313-cp313-manylinux_2_28_x86_64.whl.
File metadata
- Download URL: grimace_py-0.1.11-cp313-cp313-manylinux_2_28_x86_64.whl
- Upload date:
- Size: 4.2 MB
- Tags: CPython 3.13, manylinux: glibc 2.28+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c28ccc0d1c933785ea4e3d5ef4f2f8a70d5a94275650150ef622b116cc340c81
|
|
| MD5 |
98cc6f2548f02ff6a11e9a8806305e5e
|
|
| BLAKE2b-256 |
b714286836de789ce5e2f3c6bd2ee5247891909abadd4f383dd187c16a90677e
|
Provenance
The following attestation bundles were made for grimace_py-0.1.11-cp313-cp313-manylinux_2_28_x86_64.whl:
Publisher:
release.yml on numpde/grimace-py
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
grimace_py-0.1.11-cp313-cp313-manylinux_2_28_x86_64.whl -
Subject digest:
c28ccc0d1c933785ea4e3d5ef4f2f8a70d5a94275650150ef622b116cc340c81 - Sigstore transparency entry: 1607284126
- Sigstore integration time:
-
Permalink:
numpde/grimace-py@b7d036a3613c2cb38e2866e5d04cb4de32c3ceca -
Branch / Tag:
refs/tags/v0.1.11 - Owner: https://github.com/numpde
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@b7d036a3613c2cb38e2866e5d04cb4de32c3ceca -
Trigger Event:
push
-
Statement type:
File details
Details for the file grimace_py-0.1.11-cp312-cp312-manylinux_2_28_x86_64.whl.
File metadata
- Download URL: grimace_py-0.1.11-cp312-cp312-manylinux_2_28_x86_64.whl
- Upload date:
- Size: 4.2 MB
- Tags: CPython 3.12, manylinux: glibc 2.28+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
652eb4ebc0c96531fd58592eafde2da302eae6f04a13854619d0bcc83ad65835
|
|
| MD5 |
d7a05c9cc8b53d9193e29dd18b55f09e
|
|
| BLAKE2b-256 |
f6cece2d55135773624a0d839ed565946579eb6e999f5c793f150df13520cb9e
|
Provenance
The following attestation bundles were made for grimace_py-0.1.11-cp312-cp312-manylinux_2_28_x86_64.whl:
Publisher:
release.yml on numpde/grimace-py
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
grimace_py-0.1.11-cp312-cp312-manylinux_2_28_x86_64.whl -
Subject digest:
652eb4ebc0c96531fd58592eafde2da302eae6f04a13854619d0bcc83ad65835 - Sigstore transparency entry: 1607284065
- Sigstore integration time:
-
Permalink:
numpde/grimace-py@b7d036a3613c2cb38e2866e5d04cb4de32c3ceca -
Branch / Tag:
refs/tags/v0.1.11 - Owner: https://github.com/numpde
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@b7d036a3613c2cb38e2866e5d04cb4de32c3ceca -
Trigger Event:
push
-
Statement type: