Skip to content

Language Bindings Cross-Reference

Rust crates, Python (PyO3), and WASM (wasm-bindgen) share the Rust core but have different API and error shapes. This page names those differences.

Atom provenance and output order

The atom-tracking APIs expose only mappings with a documented atom order. The checked reaction additions below are included since v1.0.32; the published v1.0.38 83-row gate is 80 exact graph/origin/map rows and three inputs invalid in both engines.

Capability Rust Python WASM / Node
SMILES atom output order write_with_atom_order, canonical_smiles_with_atom_order Mol.smiles_with_atom_order() Not exposed
Connected-component source atom indices Molecule::fragments_with_source_atoms() Mol.connected_components_with_atom_indices() Not exposed
Reaction product atom provenance and template maps PreparedReaction::apply_match_traced, run_reactants_traced_with_rejection_diagnostics run_smirks_checked() returns product_atom_sources and product_template_maps run_reactants_checked() returns the same JSON arrays
Opt-in product rejection diagnostics run_reactants_traced_rdkit_2026_03_6_detailed returns typed reasons and bounded atom context run_smirks_checked(..., rdkit_compat=True) returns rejection_diagnostics run_reactants_checked(..., true) returns the same JSON field
Label-stable R-group columns rgroup_decompose_labeled() returns aligned optional rows rgroup_decompose_columns() rgroup_decompose_columns_json()

order[k] is the input atom index for the k-th atom written in a SMILES string; it is also that atom's index after parsing the returned string. Reaction tracing returns None for an atom newly created by the product template. Python arrays follow the returned Mol atom indices; WASM/Node arrays follow the returned canonical SMILES parse order. Template-map labels may be None even when an atom has a reactant origin. These are additive APIs: ordinary SMILES writing, connected components, and reaction application retain their existing return values. The published rerun records the current bounded reaction result; it is not general SMIRKS parity.

R-group column output is input-aligned and uses null/None for unmatched or absent rows. It covers the documented mapped terminal-wildcard subset; it does not imply RDKit's multi-core alignment, enumeration, or GA scoring.

See also: format-capabilities.md for the per-format read/write/streaming/limits matrix this page's examples are drawn from.

RDKit-compatible Python import surface

The Python package includes an explicit, lightweight compatibility namespace at chematic.rdkit_compat. The common Morgan entry points below resolve to the same chematic-backed fingerprint implementation, so migration does not depend on one particular RDKit import style:

from chematic import rdkit_compat as Chem
from chematic.rdkit_compat import AllChem, rdFingerprintGenerator

mol = Chem.MolFromSmiles("CCO")
legacy = AllChem.GetMorganFingerprintAsBitVect(mol, 2)
stable = rdFingerprintGenerator.GetMorganGenerator(
    radius=2, fpSize=2048
).GetFingerprint(mol)
assert legacy.GetOnBits() == stable.GetOnBits()

Chem.CanonSmiles is also available. Unsupported RDKit options such as count-simulation Morgan fingerprints raise NotImplementedError; they are not silently substituted with a different algorithm. This is a Python API compatibility surface, not a claim of bit parity with RDKit's C++ implementation.

RDKit-compatible WASM / Node surface

MolHandle exposes the separately named rdkit_* writer, scaffold, stereo, hash, Morgan, alignment, seeded-embedding, and bounds-matrix operations. The free functions rdkit_from_pdb_block, rdkit_from_xyz_block, and rdkit_from_mol2_block return a RdkitReadResult with molecule(), coords_json(), and sanitized smiles where applicable.

All atom indices and coordinate rows are zero-based and follow the returned MolHandle order. Alignment search is capped at 1,000,000 matches, stereo enumeration at 4,096 isomers, and ETKDG/bounds output at 512 atoms. These are the RDKit 2026.03.1 compatibility paths; the existing native browser APIs are unchanged.


The three surfaces, in one sentence each

  • Rust (chematic-* crates): the source of truth. Typed errors (thiserror-style enums), zero-copy where the type system allows it, BufRead-based streaming where a reader type exists.
  • Python (chematic-py, PyO3): free functions mirroring the Rust free functions where practical (parse_mmcif, parse_pqr, ...), plus Mol and a handful of dedicated pyclasses (VolumetricGrid, LammpsDumpFrame, PipelineV2Config). Typed Rust errors map to Python ValueError. NumPy arrays are used for large numeric payloads (fingerprints, grid values) — always fresh copies, never views.
  • WASM (chematic-wasm, wasm-bindgen): mostly free functions returning JSON strings (Result<String, JsValue>), with selected functions also returning js_sys::Float64Array/Uint32Array for large numeric grid/row payloads — also always fresh copies, never views. Typed Rust errors map to a thrown JS error / JsValue.

Python NumPy arrays and WASM typed arrays are copies, not Rust-memory views. Parsers are format-specific; there is no parse_any(text) auto-detection.

Protein structure projection follows the same rule: Rust exposes typed ProteinProjection/ProteinChain/ProteinResidue/ProteinAtom values through project_protein_from_pdb and project_protein_from_mmcif; Python and WASM expose the same bounded hierarchy as JSON through protein_from_pdb_json and protein_from_mmcif_json. It is an amino-acid-only view, not a full PDB/mmCIF biological-assembly model.


Copy-vs-view semantics

Boundary Owns a view of Rust memory? Notes
Python NumPy arrays (ecfp4_numpy(), the other *_fp_numpy() fingerprint methods, VolumetricGrid.values, VolumetricGrid.values_3d, ...) No — fresh copy Fingerprints are one-dimensional uint8 arrays in the same LSB-first order as their packed-byte methods. values_3d reshapes the flat values copy to (nx, ny, nz), third axis (k) fastest, matching chematic_mol::VolumetricGrid::checked_index.
WASM js_sys::Float64Array/Uint32Array (cube_values_f64, opendx_values_f64, lammps_dump_rows_f64, lammps_dump_cartesian_positions_f64, cube_shape_u32, opendx_shape_u32) No — fresh copy Additive alongside the JSON-string functions; neither replaces the other.
WASM JSON strings (cube_grid_json, opendx_grid_json, mmcif_to_json, ...) No — serialized copy The oldest/default binding shape in chematic-wasm; large numeric arrays here round-trip through a JSON number array, a disclosed perf trade-off versus the typed-array functions above.

Unit-conversion ownership: who converts what

Concept Owned by Where
Gaussian Cube Bohr/Ångström tag Rust core GridUnits::{Bohr, Angstrom} — read and preserved as-is, never silently converted, in Rust, Python, and WASM alike.
OpenDX Ångström assumption Rust core No in-file unit tag exists; every parsed grid is tagged Angstrom by convention in chematic_mol::opendx. This is a core-crate assumption, not a binding-layer one — Python and WASM inherit it unchanged.
QCSchema geometry Bohr → Ångström Binding layer, not core chematic_mol::QcMolecule.geometry stays in Bohr (a0), matching the QCSchema spec. Python and WASM bindings convert to Ångström for convenience when exposing coordinates — this conversion does not happen in chematic-mol itself.
mmCIF/PQR/ORCA coordinate units Rust core Always Ångström as documented per-format; no binding-layer conversion involved since there is nothing to convert.

None / null / Err divergence points

These are real, disclosed differences in how "no value" is represented across the three languages for the same underlying Rust computation — not accidents.

LAMMPS dump Cartesian positions — the clearest case

chematic_mol::LammpsDumpFrame::cartesian_positions() returns Option<Vec<[f64; 3]>> — None when the frame has no x y z or xs ys zs columns (it deliberately does not fall back to xu yu zu; see format-capabilities.md).

Language Function "no value" representation
Rust LammpsDumpFrame::cartesian_positions() None
Python LammpsDumpFrame.cartesian_positions() (Python-native None, same Option mapping)
WASM (JSON) lammps_dump_cartesian_positions_json JSON null
WASM (typed array) lammps_dump_cartesian_positions_f64 Err, not null — a Float64Array has no null representation, so the unresolvable case becomes a thrown error with a message naming the columns it looked for.

The JSON and typed-array siblings deliberately differ; see their definitions in crates/chematic-wasm/src/format_io.rs.

Typed-error mapping

Rust Python WASM
A typed error enum (e.g. LammpsDataError::UnsupportedAtomStyle, OpenDxError::NonAngstromUnits, MmcifError::...) ValueError with the Rust error's Display text as the message Thrown JS error / JsValue::from_str(...) with the same Display text

Both bindings flatten the Rust error variant to message text; they do not expose its structured fields.

QCSchema unknown fields

chematic_mol::qcschema's unknown_fields: JsonObject bag (kept distinct from the spec's own open extensibility bags — extras/keywords/ protocols/native_files/properties) round-trips losslessly in all three language bindings — Rust, Python (routed through Python's own json module as the dict↔text boundary, not a hand-written field mapper), and WASM. This is one of the few places where all three surfaces behave identically rather than diverging; noted here for contrast with the divergent cases above.


Worked examples: same concept, three languages

Gaussian Cube parse + grid values

Language Parse Access grid values
Rust chematic_mol::parse_cube(text) / parse_cube_with_limits(text, limits) → VolumetricGrid grid.values: Vec<f64> (flat, row-major, third-axis-fastest — see checked_index)
Python chematic.VolumetricGrid.from_cube(text) (staticmethod) grid.values (flat NumPy copy) or grid.values_3d (reshaped (nx, ny, nz) NumPy copy, k fastest)
WASM cube_grid_json(text) → JSON string cube_values_f64(text) → Float64Array (flat, same ordering) or parse the JSON string's values field

OpenDX strict vs. lossy write

Language Strict (fails closed on non-Ångström units) Explicit lossy (Bohr→Ångström, opt-in)
Rust write_opendx(&grid) → Err(OpenDxError::NonAngstromUnits) for a Bohr-tagged grid write_opendx_lossy(&grid) — rescales origin/axes only, never values
Python grid.to_opendx() → raises ValueError for Bohr grid.to_opendx_lossy()
WASM write_opendx_json(grid_json) → Err/thrown error write_opendx_lossy_json(grid_json)

No language collapses these into a single lossy-by-default function — the strict/lossy split from the Rust core is preserved identically in every binding.

Streaming vs. materialization, by language

Format Rust Python WASM
MOL/SDF SdfFileReader<R: BufRead> streams records iter_sdf / iter_sdf_batched stream from files with seen/emitted/rejected accounting sdf_records_batch_json is bounded and resumable over an in-memory string, not file-backed streaming
XYZ/Extended XYZ File-backed readers are fail-stop on ambiguous frame boundaries Batch readers use one-line look-ahead recovery and count rejected frames xyz_frames_batch_json / extxyz_frames_batch_json resume over an in-memory string; the convenience path recovers only at unambiguous count lines
LAMMPS dump/trajectory LammpsDumpReader<R: BufRead> — true streaming Iterator parse_lammps_dump_all materializes the whole trajectory as a list (disclosed scope choice, not a silently dropped capability) lammps_trajectory_to_json materializes (same disclosed choice)
Gaussian Cube CubeFileReader<R: BufRead> streams the input reading only — the returned VolumetricGrid.values is still one fully-materialized Vec<f64> (single-dataset format, nothing to iterate across) via VolumetricGrid.from_cube(), materializes materializes
Other documented formats no BufRead-backed streaming reader type exists materializes materializes

LAMMPS bindings deliberately materialize even though Rust has a streaming reader.


Fail-closed behavior (binding-independent)

These fail-closed checks live in the Rust core and are inherited unchanged by both bindings — no binding loosens or bypasses them:

  • write_opendx on a Bohr-tagged grid → typed error in all 3 languages (see OpenDX table above).
  • Both OpenDX writers on a grid with any atoms → OpenDxError::AtomsNotSupported in all 3 languages (no lossy-atom-dropping path exists at all).
  • Gaussian Cube multi-dataset input → CubeError::MultiDatasetUnsupported in all 3 languages (typed rejection, not silent truncation).
  • LAMMPS data with an unrecognized atom_style → LammpsDataError::UnsupportedAtomStyle in all 3 languages (no best-effort guess).
  • LAMMPS "Type Labels" sections → rejected in all 3 languages.

See error-and-limits.md for the full typed-error taxonomy and parse-limit reference.