Error Handling & Parse Limits
What actually limits input size, how typed errors surface in each language, and which operations are explicitly named as lossy. This describes real, specific, checkable behavior — bounded input validation on the formats that have it, not a claim that chematic is broadly "secure." Formats without a documented limit type are named as such below, not silently folded into a "handled" bucket.
See also: format-capabilities.md for the
per-format matrix these limits are drawn from, and
language-bindings.md for the ValueError/
JS-error mapping in more general terms.
Parse limits, by format
The formats below have dedicated *ParseLimits or reader-options types. The
rest do not — this is not
inconsistent oversight to be quietly worked around; it reflects that most of
these formats have no natural analogue of "grid points" or a comparably
unbounded substructure, or simply have not had a limits type added yet.
| Format | Limits type | Fields |
|---|---|---|
| mmCIF | MmcifParseLimits |
max_input_bytes, max_atoms, max_line_len |
| PQR | PqrParseLimits |
max_input_bytes, max_atoms, max_line_len |
| Gaussian Cube | CubeParseLimits |
max_input_bytes, max_atoms, max_grid_points |
| OpenDX | OpenDxParseLimits |
max_input_bytes, max_grid_points (no max_atoms — the format has no atom section) |
| SMILES | none | — |
| SMARTS | none | — |
| MOL/SDF | SdfParseLimits |
max_input_bytes, max_record_bytes, max_line_bytes, max_records |
| PDB | none | — |
| CIF (plain) | none | — |
| XYZ / Extended XYZ | chematic_3d::XyzParseLimits, chematic_mol::XyzParseLimits |
3D XYZ: max_input_bytes, max_atoms, max_line_bytes; extended XYZ: input/atom/frame/line/property limits |
| KET | KetParseLimits |
max_input_bytes, max_atoms, max_bonds |
| SMILES table | SmiFileParseLimits |
max_input_bytes, max_line_bytes, max_records |
| Streaming SMILES table | SmilesReaderOptions |
max_line_bytes, max_records, max_fields |
| Streaming TDT | TdtReaderOptions |
max_line_bytes, max_records, max_tags_per_record |
| InChI (pure Rust) | InchiParseLimits |
max_input_bytes, max_atoms |
| MDL RXN | RxnFileParseLimits |
max_input_bytes, max_reactants, max_products, max_molecules |
| QCSchema | none | — (JSON-size limits, if any, are whatever the caller or serde_json impose) |
| ORCA input | none | — |
| ORCA output | none | — |
| LAMMPS data | none | — |
| LAMMPS dump/trajectory | none | — |
In Python, each *ParseLimits field is exposed as an optional keyword
argument on the corresponding parse_*_with_limits-style function, using
the Rust Default values when omitted (e.g. parse_mmcif(text,
max_input_bytes=None, max_atoms=None, max_line_len=None)).
WASM convenience renderers also apply binding-level bounds before building
intermediate molecule vectors: depict_svg_grid,
depict_svg_grid_highlighted, and batch_report_html accept at most 1 MiB of
input, 1,024 non-empty records, and 10,000 atoms per parsed molecule. Since
these legacy APIs return strings rather than Result, a violation returns an
explicit SVG/HTML error document and never a partial rendering.
The high-level WASM workflow APIs apply the same 1 MiB and 1,024-record
boundaries, plus a 10,000-atom per-molecule bound, before comparison,
screening, reporting, or 3D generation. APIs returning Result surface the
violation as a JavaScript error; screen_smiles_json returns an explicit JSON
error object.
WASM JSON writers for mmCIF, PQR, and LAMMPS trajectories also cap every
record/frame array at 1,024 items before converting values into Rust vectors.
The same cap applies to nested record arrays such as ORCA coordinate atoms.
LAMMPS trajectory readers apply the same cap while retaining parsed frames;
the 1,025th frame is rejected rather than returned as a partial success.
WASM ORCA output parsing likewise applies the binding input, line, geometry
frame, and geometry atom limits through OrcaOutputParseLimits before
serializing the trajectory.
ORCA input and QCSchema WASM parsers also override the larger Rust defaults
with binding-sized line/block/atom and JSON depth/array/string limits before
decoding structured documents.
Cube/OpenDX, mmCIF, PQR, LAMMPS data, and single-frame dump WASM parsers
likewise override their larger Rust defaults for input bytes, atom/record
counts, line size, section counts, columns, and volumetric grid points.
Cube/OpenDX typed-array accessors use those same limits rather than delegating
to the larger parser defaults, so JSON and typed-array entry points reject the
same oversized grids.
The WASM convert_common_format bridge also rejects parsed molecules over the
10,000-atom binding limit before serializing a target format.
The MCP PubChem lookup caps the upstream response body at 1 MiB and rejects
oversized or invalid UTF-8 responses before JSON parsing.
MCP SMILES tool arguments are limited to 100,000 bytes and 10,000 parsed
atoms; SMARTS matching applies the 100,000-byte query limit before compiling
the query.
MCP MolJSON input uses explicit limits of 100,000 bytes, depth 64, 1,024 array
items, 10,000 atoms, and 20,000 bonds before molecule construction.
MCP SMARTS matching is bounded to 10,000 embeddings and 1,000,000 VF2 visits;
exhaustion is reported as an explicit domain error rather than a partial list.
MCP stdio JSON-RPC responses are capped at 1 MiB after envelope serialization;
an oversized response is replaced by an explicit protocol error.
MCP stdio request framing also reads at most the 1 MiB request boundary before
JSON parsing; an oversized frame terminates the loop rather than being split
into a later request.
CLI output is capped at 64 MiB for both stdout and file destinations; an
oversized result is rejected before any bytes are written.
Format-oriented WASM JSON helpers cap serialized results at 16 MiB and return
an explicit JS error when the boundary is exceeded.
The Extended XYZ frame JSON helper uses the same shared output boundary.
WASM tautomer enumeration also caps the result array at 1,024 entries and
returns an explicit error object when the cap is exceeded.
WASM run_reactants bounds reaction input to 1 MiB, reactants to 1,024
entries, each reactant to 10,000 atoms, and each product set to 1,024 entries.
The opt-in run_reactants_checked(smirks, reactants_smiles, rdkit_compat)
returns a JSON object with profile, status, reason, detail, products
and accepted/applied/valence-rejected match counts. With rdkit_compat=true,
reaction stereochemistry follows pinned RDKit 2026.03.6; a product whose
stereo depends on unrecorded ring-closure numbers returns
typed_unsupported and ambiguous_stereo_bond_order, a match rejected only
by the native E/Z check ez_reactant_template_semantics, and stereo syntax
the profile does not read (@?, @TH1, component grouping)
chiral_reactant_template_semantics. Input, arity, product-valence and
resource-limit failures are typed refusals; a combination above 1,024
accepted matches fails closed rather than returning partial products. This
SMILES-level API does not yet expose atom origins or template-map identities.
WASM MCS configuration JSON is also limited to the 1 MiB binding input
boundary before deserialization.
WASM R-group decomposition applies the same 1 MiB input limit to its
core_smarts query before compilation.
WASM MMP pair output is capped at 1,024 pairs before JSON materialization;
larger result sets return an explicit JS error.
WASM R-group queries are limited to 10,000 SMARTS query atoms after
compilation, in addition to the 1 MiB core_smarts source limit.
WASM reaction and library products are also limited to 10,000 atoms before
canonicalization and JSON materialization.
WASM library templates and fragment inputs are limited to 1 MiB, and each
scaffold/building-block molecule is limited to 10,000 atoms before enumeration.
Checked arithmetic and NaN/Infinity rejection
VolumetricGrid::checked_index(shared by Cube and OpenDX) computes the flat-array index from(i, j, k)with explicit bounds checking — it returnsNonerather than panicking or wrapping on out-of-range input, including index-arithmetic overflow.- QCSchema rejects every non-finite numeric leaf (
NaN/Infinity) rather than letting it pass through —serde_json::Numberitself cannot represent a non-finite value, and the parser treats an attempt to smuggle one in as a typed error rather than silently coercing it tonullor0. - LAMMPS dump's box-bounds/triclinic conversion (
box_bounds_to_true/true_to_box_bounds) is typed to reject a non-finite coordinate, not just a malformed one.
Unsupported-format-subset behavior: reject, don't guess
Every one of these is a typed rejection, not a silent best-effort fallback or a silent truncation:
| Situation | Behavior |
|---|---|
LAMMPS data with an atom_style outside atomic/charge/molecular/full |
LammpsDataError::UnsupportedAtomStyle — charge and molecular rows are both genuinely ambiguous 6-field rows by column count alone, so there is no safe guess to fall back to. |
LAMMPS "Type Labels" sections (Atom Type Labels, Bond Type Labels, etc.) |
rejected — any section name ending in "Type Labels" fails the parse rather than being silently misread as a different section shape. |
| Gaussian Cube file with more than one dataset | CubeError::MultiDatasetUnsupported — rejected, not silently truncated to the first dataset. |
OpenDX write of a Bohr-tagged grid via write_opendx |
OpenDxError::NonAngstromUnits — see the fail-closed section below. |
| OpenDX write of a grid with any atoms (either writer) | OpenDxError::AtomsNotSupported — there is no lossy-atom-dropping path; the format simply has no atom section to write to. |
Typed-error taxonomy → ValueError / JS-error mapping
Every format module defines its own error enum (LammpsDataError,
OpenDxError, CubeError, MmcifError, PqrError, OrcaInputError,
OrcaOutputError, QcSchemaError, CifError, XyzError,
MolParseError, CdxmlError, CmlError, ...). The mapping across
language boundaries is uniform but lossy in one specific sense:
| Language | Representation |
|---|---|
| Rust | the original typed enum variant, with its own fields (e.g. CubeError::MultiDatasetUnsupported { natoms_field, nval }) |
| Python | ValueError, message = the Rust error's Display text |
| WASM | thrown JS error / JsValue::from_str(...), message = the Rust error's Display text |
Both bindings currently flatten the structured Rust variant down to a string message — neither language exposes the original enum's fields programmatically across the boundary today. If you need to distinguish error kinds (not just read a message) in Python or JS, you currently have to do it by matching on message text, which is a real limitation, not an oversight this page is hiding.
The bounded nucleic-acid JSON API is an explicit exception to this legacy
mapping. nucleic_acid_validate_json and
nucleic_acid_apply_json_command return the same tagged envelope in Python
and WASM/Node. Failures contain stable error.code, error.path, and
error.message fields; callers never need to match display text. Its default
limits are documented in Semantic model API.
Abstention matrix (audited 2026-10-08)
scripts/typed_abstention_audit.py and scripts/typed_abstention_audit.mjs
run one probe per operation family and abstention category on the Python
binding and the WASM package
(validation/results/typed-abstention-audit-*-2026-10-08.json):
| Family | Category probed | Python | WASM / Node |
|---|---|---|---|
| SMILES, SMARTS, MOL block, InChI parse | malformed | ChematicInputError(ValueError): category, code (smiles_parse, …), format |
thrown string (not an Error), message only |
mmCIF parse past max_input_bytes |
resource_limit | ChematicInputError: category="resource_limit", code="input_too_large" |
thrown string, message only |
SMIRKS (run_smirks_checked / run_reactants_checked) |
malformed, unsupported | envelope status: typed_refusal, reason (smirks_parse, reactant_count_mismatch) |
same envelope |
The RDKit-compatible checked reaction path also returns bounded
rejection_diagnostics for filtered product sets. Reasons are valence,
aromaticity, kekulization, or unknown; implicated product atom indices
and template maps never turn a rejected molecule into a successful product.
| 3D pipeline v2 | unsupported (Pt complex, no MMFF94 type) | PipelineV2Error(ValueError) with diagnostics.cause.kind | envelope ok: false, error.cause.kind |
| Nucleic-acid document | ambiguous, unsupported, resource_limit | envelope ok: false, error.code, error.path | same envelope |
A SMILES whose graph gives a neutral oxygen more than two bonds or a neutral
fluorine more than one (O=O1C=CC=C1, CO(C)C, F(C)C; #769) is not a
molecule: the Rust parser returns SmilesError::InvalidValence { element,
atom, valence, max } and Python raises ChematicInputError with
code="smiles_valence". The valence counts bond orders and explicit
hydrogens (an aromatic bond counts one, a dative bond only at its acceptor).
Charged atoms ([O+], [o+]) and the hypervalent states of S, P, N and the
heavier halogens are not checked.
So the reaction, 3D and nucleic-acid APIs report a stable machine-readable
kind in both bindings. In Python, the parsers raise ChematicInputError, a
ValueError subclass (except ValueError keeps working) whose category
is one of malformed, unsupported, ambiguous and resource_limit, with
a stable code and the format. The WASM parsers still throw strings:
thrown Error objects with the same fields would change what code that
compares the thrown value with a string sees, so that change is left for a
release that may change the error surface.
Fail-closed writers
write_opendx is the clearest example in the codebase: given a grid whose
units field is GridUnits::Bohr, it refuses to write rather than
producing an OpenDX file (a format with no in-file unit tag) that a
downstream reader would silently misinterpret as Ångström. The explicit
opt-in for the lossy path is write_opendx_lossy — see the naming
convention below. This same fail-closed principle applies to the
unsupported-format-subset rejections in the table above: every one of them
raises a typed error instead of writing/returning a best-effort,
potentially-wrong result.
The *_lossy naming convention
Every explicitly lossy write path in this codebase is named with a
_lossy suffix and is opt-in only — never the default, never triggered
implicitly by a missing flag:
write_opendx_lossy(Rust) /VolumetricGrid.to_opendx_lossy()(Python) /write_opendx_lossy_json(WASM) — the sole named lossy operation across all 15 formats in this pass. It rescalesorigin/axesfrom Bohr to Ångström; it never rescalesvalues, and it still refuses (AtomsNotSupported) a grid with atoms.
No other format in this documentation pass has a _lossy-suffixed
function. Where a format has a documented lossy characteristic that isn't
behind an explicit opt-in — e.g. mmCIF's label_*/auth_* tag-pair
collapse on read, which is unconditional, not a named lossy function — it
is called out in format-capabilities.md's
per-format detail instead, precisely because it doesn't fit this
opt-in-only convention.
Formats that never fabricate bonds
mmCIF, PQR, ORCA (input and output), Gaussian Cube, OpenDX, LAMMPS data,
and plain CIF never infer or fabricate a bond table. PDB and
chematic_3d::parse_xyz are the two exceptions among the 15 covered here —
both infer bonds from 3D geometry (distance-based), a disclosed,
documented choice specific to those two entry points, not a default
behavior you should assume elsewhere. See
format-capabilities.md
for the full per-format connectivity notes.
Streaming vs. full materialization, per format/language
LAMMPS dump/trajectory is the one format with a real Rust-level
streaming-vs-materializing distinction: LammpsDumpReader<R: BufRead> is a
true streaming Iterator, while both the Python (parse_lammps_dump_all)
and WASM (lammps_trajectory_to_json) bindings materialize the whole
trajectory instead — a documented scope choice. MOL/SDF's SdfFileReader<R:
BufRead> is a true streaming reader too. Rust callers can use
SdfBatchReader for bounded, input-ordered batches with explicit cancellation
and a versioned progress manifest; rejected records remain in their position
within each batch. Python's iter_sdf and iter_sdf_batched now expose the
file-backed reader; the batch iterator has bounded batch sizes, explicit
cancellation, and a JSON progress manifest including records seen, emitted,
and rejected. Malformed records remain excluded from yielded Python records
but are counted explicitly in that manifest. See
language-bindings.md
for the full per-format table.
Misuse-prevention examples
These are short illustrations of the fail-closed/typed-rejection behavior above — not new functionality, just what already happens if you try the tempting-but-wrong thing.
OpenDX: writing a Bohr-tagged grid without _lossy
// grid.units == GridUnits::Bohr
let err = write_opendx(&grid).unwrap_err();
// err is OpenDxError::NonAngstromUnits { units: GridUnits::Bohr } —
// the file is not written. Use write_opendx_lossy(&grid) if you
// intend the Bohr->Angstrom conversion (rescales origin/axes only).
Gaussian Cube: a multi-dataset file
// input .cube has natoms field encoding "2 datasets"
let err = parse_cube(text).unwrap_err();
// err is CubeError::MultiDatasetUnsupported { natoms_field, nval } —
// not silently parsed as dataset 1 of N.
LAMMPS: guessing atom_style instead of stating it
// A 6-field Atoms row is ambiguous: could be `charge` (id type q x y z)
// or `molecular` (id mol-id type x y z). Passing the wrong style
// doesn't error at read time -- it silently mis-assigns which
// column is charge vs. molecule-id. Always pass the atom_style the
// simulation actually used; an out-of-set value (e.g. a typo) is
// caught (`UnsupportedAtomStyle`), but a *wrong-but-valid* style is not.
let data = parse_lammps_data(text, LammpsAtomStyle::Full)?; // be explicit
QCSchema: assuming connectivity is always present
let qc: QcMolecule = parse_qcschema_molecule(text)?;
// qc.connectivity: Option<Vec<(usize, usize, f64)>> -- QCSchema does
// not require a bond list. Unwrapping without checking panics on any
// spec-valid molecule that omits it (most do).
if let Some(bonds) = &qc.connectivity {
// only reachable if the source document actually included one
} else {
// no bonds available -- do not fabricate them here
}