Skip to content

Compile once, bind many datasets

This page explains how a ModelBuilder model becomes something you can fit repeatedly: what compile() fixes, what bind() checks, what each artifact format keeps, and where the boundary between the Rust core and the Python adapter falls. It is for contributors and for anyone deciding whether the compile-once model fits their deployment. To use the API, read custom models instead.

Status: current as of 0.13.

ModelBuilder.compile() constructs the parameter and operation graph once and returns a Python CompiledModel. The compiled object owns one Arc<Graph> whose dataset payload vectors are empty; its structural DataSchema records required predictor, response, and matrix keys. Matrix column counts and parameter/group cardinalities are structural, while the observation row count belongs to each DataBinding and may differ from one fit to the next.

CompiledModel.bind(data) eagerly validates missing and unexpected keys, 1-D versus 2-D kind, non-empty payloads, row counts within each named dimension, matrix storage and column count, and finite values. Independent dimensions may have different lengths; indexed group arrays retain fixed parameter cardinality. It returns BoundModel. NumPy inputs are currently copied into Arc<[f64]>; this implementation intentionally makes no zero-copy claim.

ModelBuilder, CompiledModel, BoundModel, and BatchFit all support optional context-manager syntax. The contract is deliberately nonambient and non-owning: __enter__ returns the same object, __exit__ never suppresses an exception, and leaving the block does not close or invalidate the object. A builder block therefore does not install a PyMC-style "current model"; calls remain explicit on the builder. Likewise, compiled models and bindings can be reused after a block. The syntax provides lexical scoping only, not compilation, data binding, cleanup, or thread-local state.

CompiledModel.sample() evaluates one binding against the shared structure. CompiledModel.sample_batch() preserves input order and caller-supplied IDs and uses the same structure for every dataset. It shares the forecasting batch executor, uses stable ID seeds by default, controls native worker count, and can collect failures. See execution options. Legacy sample() and batch_sample() remain available. batch_sample() runs on the same native batch path as sample_batch(), with positional cell seeds and stop-at-first-failure behaviour. Generic fits can predict on a new binding without response placeholders.

The earlier data-owning JSON CompiledModelArtifact, and the rustmc_core::compiled_model module that defined it, have been removed. It was never emitted or accepted by the Python CompiledModel API, so nothing that used that API changes. Python CompiledModel.to_json/from_json uses a versioned declarative format, preserving schema widths/dimensions and omitting bound training payloads. Loading rebuilds through the validated compiler. Generic fit artifacts additionally retain training inputs and posterior/telemetry arrays for prediction; see artifact semantics.

Future work includes streaming input/result retention and borrowed read-only NumPy buffers with pinned ownership. Chunked execution still retains all submitted payloads and posterior results; it is not a streaming memory guarantee.

Native kernel boundaries

The declarative model definition, expression compiler, prediction binder, and artifact loader live in rustmc_core::model. Python provides construction and input adapters. See standalone Rust execution. The core evaluator owns per-node lengths so that named dimensions affect computation. One native observation simulator supplies prior and posterior generation.

A second, allocating reference evaluator in autodiff_reference.rs cross-checks the production evaluator. It is compiled only under #[cfg(test)], so it is test scaffolding rather than public API. It still walks the graph independently, but it is no longer an independent implementation of the derivatives: for elementwise operators, Student-t, Bernoulli, and Poisson it deliberately calls the same ElementwiseOp::adjoints, student_t_derivatives, bernoulli_logp_dp, and poisson_logp_dlam that production uses. The two had drifted apart and the oracle was the one that was wrong in the tails, so sharing them was the fix. The consequence is that this comparison checks graph traversal, broadcasting, and adjoint accumulation in the optimized evaluator — not the derivative formulas themselves, which the two now agree on by construction.

The formulas are covered separately, in rust_core/tests/numerical_stability.rs. Almost every expectation there is a closed form, a central finite difference, or a constant computed outside the crate with Python's decimal at 400 or 1500 significant digits. Two are deliberate exceptions, and the file marks both: one compares the evaluator against transform.apply and one compares mean() against diagnostics(), and in each case both sides call the same helper. Those two are tripwires against a second formula being reintroduced, not checks on the formula — which is the same caveat that applies to the reference evaluator above.

Structural components compile into validated state blocks handled by Gaussian FFBS/Gibbs (with Student-t latent precision updates when requested). Dynamic GLMs use an explicitly selected elliptical-slice kernel for their Gaussian latent priors. The LogDensity trait is an independent Rust extension boundary sharing the HMC/NUTS integrators. These are explicit inference choices; there is no automatic kernel planner.