Rust API¶
Full rustdoc is published with each release. This page covers the shape of the API and the decisions you have to make.
Two entry points¶
Family::run takes one of the three built-in families as data. This
is the path Python drives, the path a GPU can execute, and the path with
the optimised flat storage.
use perturbation_kernel::config::{Backend, Config};
use perturbation_kernel::family::Family;
let cfg = Config {
n: 262_144,
seed: 20_260_610,
backend: Backend::Auto,
..Default::default()
};
let report = Family::Markov { k: 5, start: 0, base_label: 0, theta_max: 0.3 }
.run(&cfg)?;
Engine::run takes three trait implementations. This is the
extension point, and it is where your own model goes.
The two agree bit for bit on the built-in families;
tests/backends.rs asserts it across ensemble sizes, seeds and
backends.
Implementing your own model¶
Three traits. A complete worked version is
examples/custom_family.rs, which asks whether a fitted regression
slope keeps its sign under measurement error.
pub trait Perturbation<S> {
type Theta: Serialize + DeserializeOwned + Clone;
fn null(&self) -> Self::Theta;
fn sample_theta(&self, rng: &mut Rng) -> Self::Theta;
fn apply(&self, s: &S, theta: &Self::Theta, rng: &mut Rng) -> S;
}
pub trait ForwardModel<S, O> {
fn eval(&self, s: &S) -> O;
fn lipschitz(&self) -> Option<f64> { None }
}
pub trait Invariance<O> {
fn measure(&self, ensemble: &[O]) -> Report;
fn lipschitz_w1(&self) -> Option<f64> { None }
fn name(&self) -> &str { "unspecified" }
}
Three rules that the engine relies on and cannot check for you:
All randomness goes through the rng argument. Nothing may read
global state or call thread_rng(). The engine hands each draw its own
substream, and that is what makes the loop safe to parallelise.
null() must be a genuine identity. apply(s, null(), rng) has to
return s in distribution. The engine cross-checks the value against
the config's null_parameter and refuses to run on a mismatch, but it
cannot check that your apply actually honours it.
measure must be permutation-invariant and must reduce through
reduce::mean, reduce::tree_sum or reduce::sum_sq_dev. A plain
.iter().sum() gives up bit-reproducibility across thread counts,
because floating-point addition is not associative.
Threading and the Send/Sync bounds¶
Engine::run requires S: Sync, O: Send, P: Sync, F: Sync, so
the draw loop can use a thread pool. Any implementation that honours the
purity requirement satisfies these, because a pure function of
(s, theta, rng) holds no thread-affine state.
If yours genuinely cannot cross threads, use Engine::run_sequential,
which has the v1.0.0 signature without the bounds and returns a
bit-identical report. The C ABI uses it, because a foreign vtable holds
an opaque void* and nothing on the Rust side can know whether it is
safe to share.
Reductions¶
use perturbation_kernel::reduce;
reduce::tree_sum(&xs); // deterministic pairwise sum
reduce::mean(&xs);
reduce::sum_sq_dev(&xs, mean); // centred second moment
reduce::dot(&a, &b);
reduce::tree_sum_into(&xs, &mut scratch); // reuse a buffer
reduce::sum_sq_dev_into(&xs, mean, &mut scratch);
reduce::active_backend(); // SimdPath::{Scalar, Neon, Avx2}
The _into variants take a caller-owned scratch buffer, so reducing d
columns costs one allocation rather than d. All of them are
bit-identical to the v1.0.0 reduction and to each other across vector
paths.
The GPU¶
use perturbation_kernel::gpu;
gpu::available(); // bool
gpu::context()?.name; // "Apple M4 Max (Metal, IntegratedGpu)"
Requires the gpu feature. Engine::run returns
Error::UnsupportedBackend for Backend::Gpu, because a device cannot
invoke user trait implementations; use Family::run.
Errors¶
pub enum Error {
Json(serde_json::Error),
SchemaVersion { got: String, want_major: u64 },
NullParameterMismatch { config: String, perturbation: String },
EmptyEnsemble,
SampleFloor { epsilon: f64, eta: f64, floor: u64, n: u64 },
UnsupportedBackend { backend: &'static str, reason: String },
Gpu(String),
InvalidFamily(String),
}
Migrating from 1.0¶
The wire formats are unchanged and SCHEMA_VERSION is still 1.0.0.
Values are bit-identical, enforced by the golden check in CI. Three
source-level changes:
Configgained abackendfield. Use..Default::default()in struct literals, or addbackend: Backend::Auto.Engine::rungainedSend/Syncbounds.Engine::run_sequentialkeeps the old signature.Reportgained an optionalexecutionblock.to_json_v1()emits the strict v1.0.0 field set.
engine::tree_sum is still there, re-exported from reduce.