Languages

Rust

The full API reference for Rust.

The sentil crate is the engine every other binding wraps. The first half of this page is a working tour and the second half is the complete reference. Snippets that use ? assume an enclosing function returning sentil::Result<()>.

Install

The crate is on crates.io, so we run cargo add sentil.

From crates.io

Add the dependency.

cargo add sentil

The default features carry the STL monitor, the statistical layer, synthesis, trace ingest with SQLite, Rayon parallelism, and the specifications library. The feature table below has the exact set.

When a project needs a specific heavy backend, name it in the manifest. The gpu feature enables the WebGPU statistical path.

Cargo.toml
[dependencies]
sentil = { version = "0.3.0", features = ["gpu", "parquet"] }

Note that hdf5 expects a library already on the machine.

If you need a build that only monitors STL, run

cargo add sentil --no-default-features --features std

Dropping std as well gives the no_std monitor for microcontroller targets.

Check it.

cargo tree -p sentil --depth 0   # sentil v0.3.0

Then run the first monitor below.

From the repository tip

Point the dependency at the repository instead of the registry. Cargo picks the workspace member named sentil, which is sentil-core, and the feature keys work exactly as they do in the registry form.

Cargo.toml
[dependencies]
sentil = { git = "https://github.com/sedislab/SENTIL" }

Resolve it.

cargo tree -p sentil --depth 0

The line names the crate, version 0.3.0, and the git source with the commit written into your Cargo.lock.

From source

You need a Rust toolchain from rustup.rs and a C compiler, since the default sqlite feature builds a bundled SQLite.

The distribution's compiler package covers it: build-essential on Debian and Ubuntu, gcc and glibc-devel on Fedora and RHEL.

The Command Line Tools carry clang and the linker.

xcode-select --install

Rust links through MSVC, so install the Visual Studio Build Tools with the "Desktop development with C++" workload. The rustup installer prompts for this and can start it for you.

Clone and build the engine. rust-toolchain.toml pins the stable channel, so rustup switches to it inside the checkout and pulls rustfmt and clippy along with it.

git clone https://github.com/sedislab/SENTIL
cd SENTIL
cargo build -p sentil

Point your own project at the checkout with a path dependency.

Cargo.toml
[dependencies]
sentil = { path = "../SENTIL/sentil-core" }

Run an example.

cargo run -p sentil --example offline_monitoring
robustness:       -1
per sample:       [-1.0, -1.0, -1.0, -1.0, 1.0]
dense robustness: -1

For the longer check, cargo test -p sentil runs the engine's whole test suite.

rust-toolchain.toml also installs the aarch64 and armv7 Linux targets, and .cargo/config.toml names the cross-linkers for the gnu ones. The prebuilt ARM archives and which board takes which are on the CLI page. The same checkout builds the CLI and the C ABI, and from source has those two commands.

First monitor

Build a trace, specify a formula, and see the robustness.

src/main.rs
use sentil::{Formula, Trace};

fn main() -> sentil::Result<()> {
    let mut trace = Trace::new(vec![0.0, 1.0, 2.0, 3.0, 4.0])?;
    trace.add_signal("speed", vec![12.0, 9.0, 7.0, 4.0, 6.0])?;

    let phi = Formula::parse("G (speed > 5)")?;
    println!("robustness: {}", phi.robustness(&trace)?); // robustness: -1
    Ok(())
}

The robustness is −1.0-1.0. What STL is covers how to read the robustness. phi.robustness_signal(&trace)? returns the robustness at every sample, and phi.robustness_dense(&trace)? returns the dense robustness.

Traces

A Trace is one strictly increasing time vector with named, equal-length signals over it. Beyond Trace::new plus add_signal, you can read one from CSV text, from a file, or resample onto a new grid.

use sentil::{Interpolation, Trace};

let trace = Trace::from_csv_str("time,speed\n0,12\n1,9\n2,7\n3,4\n4,6")?;
assert_eq!(trace.variables(), vec!["speed"]);

let halves: Vec<f64> = (0..9).map(|i| f64::from(i) * 0.5).collect();
let fine = trace.resample(halves, Interpolation::Linear)?;
assert_eq!(fine.signal("speed").unwrap()[7], 5.0); // interpolated between 4 and 6 at t = 3.5

The time column is detected by name, or the first column is taken when no column is named time. The other columns that are not named time are treated as signals. time, timestamp, t, time_s, time_sec, time_ms, time_ns, elapsed, elapsed_time, epoch are all recognized as time columns.Trace::from_path picks a reader from the extension. We support CSVs, TSVs, text, MATLAB .mat files, Parquet, Arrow, SQLite, HDF5, and MCAP files. We go into more detail about trace formats in format features. Interpolation is Linear, ZeroOrderHold, or CubicSpline.

Streaming

For a live loop, Monitor folds one timestamped sample at a time and holds memory proportional to the largest temporal window, not the stream. Each update returns a Robustness verdict you can act on the instant it resolves.

stream.rs
use sentil::{Monitor, MonitorConfig};

fn main() -> sentil::Result<()> {
    let mut monitor = Monitor::new("G[0, 10] (x > -0.9)", MonitorConfig::new())?;
    for t in 0..60 {
        let x = (f64::from(t) * 0.3).sin();
        let verdict = monitor.update(f64::from(t), &[("x", x)])?;
        if verdict.is_resolved() && verdict.value() < 0.0 {
            println!("violated at t={t}, robustness={:.3}", verdict.value());
            return Ok(());
        }
    }
    println!("held over the whole stream");
    Ok(())
}

This prints violated at t=15, robustness=-0.078. Until the ten-second window behind a sample has fully passed, is_resolved() stays false and value() reports a provisional midpoint. The bounded operators run on the monotonic deque, which is what keeps the per-sample cost flat. The benchmark sustains a median of 81 ns per sample and more than eleven million updates a second, well past a 10 kHz control loop. The deque page explains the structure and its proof.

On the hot path, skip the name lookup by just resolving each variable's slot once with symbol_index, then pushing plain slices.

let slot = monitor.symbol_index("x")?.expect("x is in the formula");
let mut values = [0.0];
for t in 0..60 {
    values[slot] = (f64::from(t) * 0.3).sin();
    monitor.update_packed(f64::from(t), &values)?;
}

A probabilistic formula streams the same way, and the running satisfaction estimate is readable at any instant through the live-probability accessor. Monitor::last_probability prints out the last probability and the StreamMonitor object shows you the full result.

use sentil::{Formula, LiftingRegistry, NoiseInteraction, NoiseModel, SmcConfig, StreamMonitor};

let phi = Formula::parse("P>=0.9 (G[0, 5] (x > 0))")?;
let mut lifting = LiftingRegistry::new();
lifting.register("x", NoiseModel::gaussian(0.0, 0.3)?, NoiseInteraction::Additive);
let config = SmcConfig { samples: 200, ..SmcConfig::default() };

let mut monitor = StreamMonitor::with_lifting(&phi, &lifting, &config)?;
for t in 0..20 {
    monitor.update(f64::from(t), &[("x", 0.8)])?;
    if let Some(p) = monitor.last_probability() {
        println!("running probability {p:.3}"); // 0.975 by the final sample
    }
}

Probabilistic checking

A formula like P>=0.9 (G (x > 0)) asks whether under noise the inner property holds with at least 90 percent probability. We give each channel a noise model, then call check on the formula.

prstl.rs
use sentil::{Formula, LiftingRegistry, NoiseInteraction, NoiseModel, SmcConfig, Trace};

fn main() -> sentil::Result<()> {
    let times: Vec<f64> = (0..20).map(f64::from).collect();
    let mut trace = Trace::new(times)?;
    trace.add_signal("x", (0..20).map(|i| 0.4 + 0.05 * f64::from(i)).collect::<Vec<_>>())?;

    let mut lifting = LiftingRegistry::new();
    lifting.register("x", NoiseModel::gaussian(0.0, 0.3)?, NoiseInteraction::Additive);

    let phi = Formula::parse("P>=0.9 (G (x > 0))")?;
    let config = SmcConfig { samples: 5000, ..SmcConfig::default() };
    let result = phi.check(&trace, &lifting, &config)?;
    println!(
        "probability {:.3}, interval [{:.3}, {:.3}], holds {}",
        result.probability, result.interval.lower, result.interval.upper, result.holds
    );
    Ok(())
}

This prints probability 0.733, interval [0.721, 0.745], holds false. The early samples sit near 0.4, so noise with a 0.3 standard deviation pushes them below zero often enough that the 0.9 threshold fails, and the Wilson interval around the estimate says 5000 samples were plenty to be sure. register returns &mut Self for chaining, not a Result, so no ? goes after it. SmcConfig defaults to 10,000 samples, 0.95 confidence, seed 42, and the Wilson interval. What PrSTL is covers the operator, and confidence intervals the surety about the estimate.

Instead of check, you can use check_conservative to rerun the verdict with the Clopper-Pearson exact interval or check_distribution which returns how the inner robustness spread across the ensemble.

Sequential tests

A sequential test draws only as many samples as the decision needs as compared to a fixed-budget test. Wald's SPRT decides between an indifference region's two hypotheses with bounded error rates:

use sentil::{SprtConfig, SprtResult};

let sprt = SprtConfig::new(0.85, 0.95, 0.05, 0.05, 10_000)?;
match phi.check_sequential(&trace, &lifting, &sprt)? {
    SprtResult::AcceptH1 { samples } => println!("holds, decided after {samples} draws"),
    SprtResult::AcceptH0 { samples } => println!("fails, decided after {samples} draws"),
    SprtResult::Inconclusive { samples, .. } => println!("undecided after {samples} draws"),
}

On a trace that clearly satisfies the property, this decides after a few dozen draws where the fixed-budget check would burn thousands. The Bayesian alternative starts from a uniform Beta(1, 1) prior and stops when the Bayes factor clears a cutoff:

use sentil::{BayesConfig, BayesResult};

let bayes = BayesConfig::new(0.9, 100.0, 10_000)?;
match phi.check_bayesian(&trace, &lifting, &bayes)? {
    BayesResult::Holds { samples, posterior } => println!("holds after {samples} draws, posterior {posterior:.3}"),
    BayesResult::Fails { samples, .. } => println!("fails after {samples} draws"),
    BayesResult::Inconclusive { samples, .. } => println!("undecided after {samples} draws"),
}

The SPRT concept page derives the decision bounds and statistical methods compares the three methods.

Rare events

Below probabilities below 10−510^{-5}, plain Monte Carlo ceases to be effective. The rare-event path runs adaptive multilevel splitting over a StochasticSystem and is able to resolve probabilities down to 10−1210^{-12} or lower.

use sentil::{Formula, NoiseModel, RareEventConfig, StochasticSystem};

// A random walk with Gaussian steps; drifting past 12 within the horizon is rare.
let step = NoiseModel::gaussian(0.0, 0.3)?;
let system = StochasticSystem::new(["x"], 1.0, 100, |_rng| vec![0.0], move |prev, _t, rng| {
    vec![prev[0] + step.sample(rng)]
})?;

let phi = Formula::parse("P>=0.9999 (G (x < 12))")?;
let config = RareEventConfig { particles: 4096, ..RareEventConfig::default() };
let result = phi.check_rare_event(&system, &config)?;
println!(
    "violation probability {:.2e} from {} simulations, holds {}",
    result.violation_probability, result.simulations, result.holds
);

This resolves a violation probability near 5×10−55 \times 10^{-5}, far below what 4096 plain samples could see. The inner formula should be a G, over a predicate. The rare events page explains the algorithm, and the GPU guide covers the gpu-feature path for large particle counts.

Synthesis

Given a system model and a spec, can we find the input that satisfies it? This is the problem that Synthesis seeks to solve. The default backend is projected gradient ascent on a smooth robustness.

synth.rs
use sentil::{Backend, Bounds, Formula, LinearModel, SynthesisProblem, Synthesizer};

fn main() -> sentil::Result<()> {
    // A single integrator x_{t+1} = x_t + u_t, five steps from x0 = 0.
    let model = LinearModel::new(vec![vec![1.0]], vec![vec![1.0]], [0.0], ["pos"], 1.0, 5)?;
    let spec = Formula::parse("F[0, 5] (pos > 2)")?;

    let problem = SynthesisProblem::new(&model, &spec)
        .with_bounds(Bounds::new(vec![-1.0; 5], vec![1.0; 5])?)
        .with_backend(Backend::Gradient)
        .with_budget(200);
    let result = Synthesizer::solve(&problem)?;
    println!(
        "input {:?}, robustness {:.3}, holds {}",
        result.input, result.robustness, result.holds
    );
    Ok(())
}

This finds input [1.0, 1.0, 1.0, 1.0, 1.0] with robustness 3.0: pushing at full bound reaches position 5, three units past the target. An infeasible spec returns the least-violating input rather than an error. Online, Controller re-plans a short horizon every step inside a hard deadline:

use std::time::Duration;
use sentil::{Bounds, Controller, Formula, LinearModel};

let model = LinearModel::new(vec![vec![1.0]], vec![vec![1.0]], [0.0], ["pos"], 1.0, 8)?;
let spec = Formula::parse("F[0, 8] (pos > 2)")?;
let mut controller = Controller::new(&model, &spec, 1, Duration::from_millis(5))
    .with_bounds(Bounds::new(vec![-1.0; 8], vec![1.0; 8])?);

let mut state = vec![0.0];
for _ in 0..8 {
    let u = controller.control(&state)?;
    state[0] += u[0]; // ends at 8.0, well past the target
}

The same machinery searches in reverse: spec.falsify(&model, &bounds, CmaConfig::default(), 3)? hunts for a violating input and returns it as a Witness, and mine_tightest_parameter finds the sharpest constant a spec supports on recorded traces. On the first-monitor trace it recovers exactly the dip:

use sentil::mine_tightest_parameter;

let traces = [trace]; // the first-monitor trace
let c = mine_tightest_parameter(
    |c| Formula::parse(&format!("G (speed > {c})")),
    &traces,
    0.0,
    20.0,
)?;
// c is 4.000, the tightest threshold the trace satisfies

Backend selection, the smooth semantics, and the MILP encoding are covered in synthesis backends; the synthesis guides work through receding horizon control, falsification, chance constraints, and the safety filter.

Errors

Anything fallible returns sentil::Result<T>, an alias for Result<T, sentil::Error>. Nothing reachable from user input panics. Match the variant you can act on and propagate the rest with ?:

use sentil::{Error, Formula};

match Formula::parse("G (speed >") {
    Ok(phi) => println!("{:?}", phi.variables()),
    Err(Error::Parse(e)) => {
        eprintln!("parse failed at line {}, column {}: {}", e.line, e.column, e.message);
    }
    Err(e) => eprintln!("{e}"),
}

This prints parse failed at line 1, column 11: expected a value or `(`, found end of input; both coordinates are 1-based. The failure a first session actually hits is a name mismatch between formula and trace, which arrives as Error::UnknownVariable:

let phi = Formula::parse("G (velocity > 5)")?;
match phi.robustness(&trace) {
    Err(Error::UnknownVariable { name }) => {
        eprintln!("the trace has no signal named {name}"); // velocity
    }
    other => println!("{other:?}"),
}

Error is #[non_exhaustive], so a match always carries a catch-all arm; it implements std::error::Error and fits any error chain. The reference table below lists all twenty variants, and the same messages cross into every binding: see errors across bindings and the error-code mapping.

Cargo features

Everything below this point is the reference: every public item, grouped by task. It starts with the features that gate the layers.

FeatureWhat it adds
stdThe standard library; drop it for a no_std STL monitor that does its math through libm
serdeSerialize/Deserialize on Formula, NoiseModel, and the run configs
statisticalThe PrSTL layer: noise models, lifting, Monte Carlo, intervals, SPRT, Bayesian, rare events
synthesisThe synthesis subsystem and smooth robustness; dependency-free and no_std-friendly
parallelRayon work-stealing for the Monte Carlo path; same answers, each sample independently seeded
ingestTrace reading from CSV, TSV, text, and classic MATLAB .mat files
specsThe premade specifications library, embedded as parameterized templates
parquet / arrow / sqlite / hdf5 / mcapOne trace format each on top of ingest; SQLite is bundled, HDF5 needs the library
gpuThe WebGPU statistical path and the SimModel/SimExpr/GpuSplittingEstimate re-exports
synthesis-gpuGPU batching for synthesis; implies both synthesis and gpu

The default set is std, statistical, ingest, parallel, synthesis, specs, and sqlite. A monitor-only build is --no-default-features --features std.

Three gate consequences are easy to miss. First, the statistical methods on the monitor family disappear in a monitor-only build: MonitorConfig::smc/rare/smc_config/rare_config, Monitor::check/check_sequential/check_rare/last_probability, StreamMonitor::with_lifting/last_probability, MultiFormulaMonitor::add_probabilistic/probabilities/probability, and FormulaBank::check are all behind statistical. Second, SynthesisProblem::on_gpu needs synthesis-gpu, not plain synthesis, and solve_milp additionally needs std. Third, GPU entry points return a typed Error::Gpu when no device is present rather than silently switching; call sentil::gpu::is_available() to decide, once, whether to take the GPU path. The feature reference has the cross-package matrix.

The error type

Result<T> is an alias for core::result::Result<T, Error>. Error has twenty variants, each carrying what a caller needs to fix the input; the enum and its richer variants are #[non_exhaustive], so match with a catch-all arm and destructure struct variants with ...

VariantPayloadWhen it arises
ParseParse(ParseError)The formula text is malformed; the payload points at the spot
UnknownVariable{ name: String }The formula reads a signal the trace or update does not carry
DivisionByZero{ term: String }An arithmetic term divided by zero, named in the payload
UnknownFunction{ name: String, arity: usize }A predicate called a function outside the twelve provided
ArityMismatch{ name, expected, found }A known function got the wrong argument count
NonMonotonicTime{ previous: f64, time: f64 }Trace or stream times failed to strictly increase
NonFiniteSample{ kind: &'static str, value: f64 }A time or value was NaN or infinite
SignalLengthMismatch{ variable, expected, found }A signal's sample count does not match the time grid
EmptyTraceunitRobustness was requested over zero samples
PackedLength{ expected, found }An update_packed slice had the wrong width
ProbabilisticOperatorunitA P operator reached deterministic evaluation, which cannot score it
Unsupported{ feature: &'static str }The build or target lacks what the call needs
InvalidNoiseModel{ model: &'static str, reason: String }Constructor parameters outside the family's valid range
NotProbabilisticunitA statistical check on a formula not wrapped in P
Ingest{ path, row, message }A trace source could not be read; path and 1-based row when known
Fit{ method: &'static str, message: String }Noise fitting failed, for example on too few samples
InvalidConfig{ context: &'static str, message: String }A configuration value out of range, naming the procedure
Splitting{ particle, level, message }A rare-event run hit a numerical problem
Transpilation{ message: String }A formula cannot lower to a GPU shader; gpu
Gpu{ message: String }A device fault after acquisition; gpu

ParseError is { message: String, line: usize, column: usize } with public fields, both coordinates 1-based, and ParseError::at(message, line, column) constructs one when a tool of your own wants to report positions the same way. Every variant maps to a stable C ABI status code; the error-code reference has the table.

Formulas and the syntax tree

Formula is a parsed PrSTL syntax tree; the grammar defines the text it accepts and the operator reference the semantics of each node.

ItemSignatureWhat it does
Formula::parseparse(input: &str) -> Result<Formula>Parse text; the error's ParseError points at line and column
phi.variables()variables(&self) -> Vec<String>The signal names the formula reads, sorted and deduplicated
phi.depth()depth(&self) -> usizeNesting depth; a bare predicate is 1
phi.has_temporal()has_temporal(&self) -> boolWhether any temporal operator appears
phi.to_string()via DisplayRound-trips to parseable text

The tree itself is public under sentil::formula, so you can build formulas without the parser and bindings can inspect them. Formula has thirteen variants:

VariantShape
PredicatePredicate(Predicate)
NotNot(Box<Formula>)
And / Or / ImpliesAnd(Box<Formula>, Box<Formula>) and likewise
Always / EventuallyAlways(Interval, Box<Formula>) and likewise
Until / SinceUntil(Interval, Box<Formula>, Box<Formula>) and likewise
NextNext(Box<Formula>)
Historically / OnceHistorically(Interval, Box<Formula>) and likewise
ProbabilisticProbabilistic(ProbabilityOp, f64, Box<Formula>)

A built tree compares equal to its parsed twin:

use sentil::formula::{ComparisonOp, Expr, Formula, Interval, Predicate};

let phi = Formula::Always(
    Interval::bounded(0.0, 10.0)?,
    Box::new(Formula::Predicate(Predicate {
        lhs: Expr::Variable("speed".to_owned()),
        op: ComparisonOp::Greater,
        rhs: Expr::Literal(5.0),
    })),
);
assert_eq!(phi, Formula::parse("G[0, 10] (speed > 5)")?);

The supporting types, all under sentil::formula:

TypeMembers
Predicate{ lhs: Expr, op: ComparisonOp, rhs: Expr }, all public
ExprBinary(BinaryOp, Box<Expr>, Box<Expr>), Call(String, Vec<Expr>), Literal(f64), Variable(String)
ComparisonOpLess, LessEqual, Greater, GreaterEqual, Equal, NotEqual
BinaryOpAdd, Sub, Mul, Div, Mod, Pow
ProbabilityOpGreaterEqual, Greater, LessEqual, Less

Interval is the time window a temporal operator quantifies over; its fields are private and every constructor validates.

ItemSignatureWhat it does
Interval::newnew(lower: f64, upper: Option<f64>) -> Result<Interval>The validating entry point; None means unbounded above
Interval::boundedbounded(lower: f64, upper: f64) -> Result<Interval>[lower, upper]
Interval::from_lowerfrom_lower(lower: f64) -> Result<Interval>[lower, inf)
Interval::unboundedunbounded() -> Interval[0, inf), what an omitted interval means
lower() / upper()-> f64 / -> Option<f64>The bounds as stored
upper_or_infinity()-> f64The upper bound, or positive infinity
is_bounded()-> boolWhether a finite upper bound exists
is_unbounded()-> boolWhether the interval is the whole future [0, inf)
contains(t)contains(&self, t: f64) -> boolMembership, inclusive at both ends

Note that is_bounded and is_unbounded are not complements: Interval::from_lower(2.0)? is neither, since it has no finite upper bound but is not the whole future.

Evaluating robustness

Five methods on Formula and one free function score a formula against a trace. The robustness semantics page gives the exact value each operator computes, in both time models.

ItemSignatureWhat it does
phi.robustnessrobustness(&self, trace: &Trace) -> Result<f64>Discrete-time robustness at the trace start
phi.robustness_denserobustness_dense(&self, trace: &Trace) -> Result<f64>Dense-time robustness, catching crossings between samples
phi.robustness_signalrobustness_signal(&self, trace: &Trace) -> Result<Vec<f64>>The robustness at every sample
phi.robustness_dense_signalrobustness_dense_signal(&self, trace: &Trace) -> Result<Vec<f64>>The per-sample signal in dense time
phi.violationsviolations(&self, trace: &Trace) -> Result<Vec<(f64, f64)>>The time spans where the formula fails
violation_intervalsviolation_intervals(times: &[f64], signal: &[f64]) -> Vec<(f64, f64)>The negative spans of any robustness signal

On the first-monitor trace, Formula::parse("speed > 5")?.robustness_signal(&trace)? is [7.0, 4.0, 2.0, -1.0, 1.0] and violations returns [(3.0, 3.0)], the single dip as a degenerate span.

Monitors

Monitor wraps one formula with a configuration and serves offline evaluation, streaming, and statistical checking from one handle. MonitorConfig is a consuming builder; TimeMode is Discrete (the default) or Dense.

ItemSignatureWhat it does
MonitorConfig::newnew() -> MonitorConfigThe default config: discrete time
.timetime(self, mode: TimeMode) -> MonitorConfigPick the time model for offline evaluation
.smcsmc(self, smc: SmcConfig) -> MonitorConfigMonte Carlo settings for check; statistical
.rarerare(self, rare: RareEventConfig) -> MonitorConfigSplitting settings for check_rare; statistical
.time_modetime_mode(&self) -> TimeModeRead back the time model
.smc_config / .rare_config-> SmcConfig / -> RareEventConfigRead back the statistical settings; statistical
ItemSignatureWhat it does
Monitor::newnew(formula: &str, config: MonitorConfig) -> Result<Monitor>Parse and wrap in one step
Monitor::from_formulafrom_formula(formula: Formula, config: MonitorConfig) -> MonitorWrap an already-built tree; cannot fail
.robustnessrobustness(&self, trace: &Trace) -> Result<f64>Offline evaluation honouring the configured TimeMode
.robustness_signalrobustness_signal(&self, trace: &Trace) -> Result<Vec<f64>>Per-sample values under the configured mode
.violationsviolations(&self, trace: &Trace) -> Result<Vec<(f64, f64)>>Failing spans under the configured mode
.updateupdate(&mut self, time: f64, values: &[(&str, f64)]) -> Result<Robustness>Fold one named sample
.update_packedupdate_packed(&mut self, time: f64, values: &[f64]) -> Result<Robustness>Fold one sample by slot, no name lookup
.symbol_indexsymbol_index(&mut self, name: &str) -> Result<Option<usize>>The packed slot for a variable
.last_probabilitylast_probability(&self) -> Option<f64>The live satisfaction estimate for a P formula; statistical
.resetreset(&mut self)Forget the stream and start over
.formula / .config-> &Formula / -> &MonitorConfigBorrow what the monitor wraps
.checkcheck(&self, trace: &Trace, lifting: &LiftingRegistry) -> Result<SmcResult>The Monte Carlo check with the configured SmcConfig; statistical
.check_sequentialcheck_sequential(&self, trace: &Trace, lifting: &LiftingRegistry, sprt: &SprtConfig) -> Result<SprtResult>The SPRT check; statistical
.check_rarecheck_rare(&self, system: &StochasticSystem) -> Result<RareEventResult>Splitting with the configured RareEventConfig; statistical

The same checks exist directly on Formula when you would rather pass the config per call: check, check_distribution, check_conservative, check_sequential, check_bayesian, check_rare_event, and, under gpu, check_rare_event_gpu. Only the Bayesian regime has no Monitor form.

Streaming verdicts

Robustness is the verdict every streaming update returns. It is an enum, not a struct: Concrete(f64) once the value is final, Interval(f64, f64) while future samples can still move it.

ItemSignatureWhat it does
Robustness::TRUE / FALSEconstsConcrete positive and negative infinity
Robustness::UNKNOWNconstInterval(-inf, inf), nothing decided yet
.is_resolvedis_resolved(&self) -> boolWhether the value is final
.valuevalue(&self) -> f64The value, or the interval midpoint while unresolved; NaN for UNKNOWN
.concreteconcrete(&self) -> Option<f64>The value only when resolved
.lower / .upperlower(&self) -> f64 / upper(&self) -> f64The interval ends; equal once resolved
.is_satisfiedis_satisfied(&self) -> boolWhether value() >= 0, so zero counts as satisfied and an unresolved verdict answers from the midpoint
.negate / .min / .max / .impliesnegate(self) -> Robustness and likewiseThe STL operations lifted to intervals
use sentil::Robustness;

let pending = Robustness::Interval(-0.5, 2.0);
assert!(!pending.is_resolved());
assert_eq!(pending.value(), 0.75); // midpoint until the window closes

let done = Robustness::Concrete(1.5);
assert_eq!(done.concrete(), Some(1.5));
assert!(done.is_satisfied());

Treat is_satisfied on an unresolved verdict as provisional; gate alarms on is_resolved() first, as the streaming example above does.

Multi-formula monitoring

Three types run more than one property, or strip the single-formula path to its core. All are crate-root exports.

StreamMonitor is the bare streaming engine behind Monitor, with no config and no offline path:

ItemSignatureWhat it does
StreamMonitor::newnew(formula: &str) -> Result<StreamMonitor>Parse and build
StreamMonitor::from_formulafrom_formula(formula: &Formula) -> Result<StreamMonitor>Build from a tree
StreamMonitor::with_liftingwith_lifting(formula: &Formula, lifting: &LiftingRegistry, config: &SmcConfig) -> Result<StreamMonitor>Online PrSTL: stream a particle ensemble; statistical
.update / .update_packedas MonitorFold one sample
.runrun(&mut self, trace: &Trace) -> Result<Vec<Robustness>>Replay a whole trace, one verdict per sample
.symbol_indexsymbol_index(&self, name: &str) -> Option<usize>The packed slot; note &self and a bare Option, unlike Monitor
.variable_countvariable_count(&self) -> usizeThe packed width
.last_probabilitylast_probability(&self) -> Option<f64>The live estimate under with_lifting; statistical
.resetreset(&mut self)Forget the stream

MultiFormulaMonitor advances many streaming formulas under one clock, each keyed by an id:

use sentil::MultiFormulaMonitor;

let mut fleet = MultiFormulaMonitor::new();
fleet.add("speed-floor", "G[0, 10] (speed > 5)")?;
fleet.add("accel-cap", "G[0, 10] (accel < 3)")?;

let verdicts = fleet.update(0.0, &[("speed", 9.0), ("accel", 1.0)])?;
for (id, verdict) in &verdicts {
    if verdict.is_resolved() && !verdict.is_satisfied() {
        println!("{id} violated");
    }
}
ItemSignatureWhat it does
MultiFormulaMonitor::newnew() -> MultiFormulaMonitorAn empty set
.addadd(&mut self, id: impl Into<String>, formula: &str) -> Result<()>Parse and register under an id
.add_formulaadd_formula(&mut self, id: impl Into<String>, formula: &Formula) -> Result<()>Register a built tree
.add_probabilisticadd_probabilistic(&mut self, id, formula: &Formula, lifting: &LiftingRegistry, config: &SmcConfig) -> Result<()>Register a streaming PrSTL formula; statistical
.updateupdate(&mut self, time: f64, values: &[(&str, f64)]) -> Result<Vec<(String, Robustness)>>One shared sample in, one verdict per formula out
.probabilities / .probabilityprobabilities(&self) -> Vec<(String, Option<f64>)> / probability(&self, id: &str) -> Option<f64>The live estimates; statistical
.reset / .removereset(&mut self) / remove(&mut self, id: &str) -> boolRestart the clock, or drop one formula
.ids / .len / .is_emptyids(&self) -> impl Iterator<Item = &str> and the usual pairEnumerate and count

FormulaBank is the offline counterpart, evaluating many named formulas over one trace; per-formula failures come back as per-id Results rather than failing the batch:

ItemSignatureWhat it does
FormulaBank::newnew() -> FormulaBankAn empty bank
.add / .add_formulaadd(&mut self, id, formula: &str) -> Result<()> / add_formula(&mut self, id, formula: &Formula)Register
.robustness / .robustness_denserobustness(&self, trace: &Trace) -> Vec<(String, Result<f64>)> and the dense twinScore every formula
.checkcheck(&self, trace: &Trace, lifting: &LiftingRegistry, config: &SmcConfig) -> Vec<(String, Result<SmcResult>)>Batch PrSTL checks; statistical
.ids / .len / .is_emptyas MultiFormulaMonitorEnumerate and count

Traces and buffers

The trace types in full. Construction and file reading:

ItemSignatureWhat it does
Trace::newnew(times: impl Into<Vec<f64>>) -> Result<Trace>A trace over strictly increasing times, no signals yet
Trace::from_signalfrom_signal(times, name: &str, values) -> Result<Trace>The one-signal convenience
Trace::indexedindexed(len: usize) -> TraceInteger times 0..len for evenly sampled data
Trace::from_csv_str / from_tsv_strfrom_csv_str(text: &str) -> Result<Trace> and the TSV twinParse in-memory text; ingest
Trace::from_pathfrom_path(path: impl AsRef<Path>) -> Result<Trace>Read a file, choosing the reader by extension; ingest plus format features
.add_signaladd_signal(&mut self, name: &str, values: impl Into<Vec<f64>>) -> Result<()>Add or replace a named signal

Reading and resampling:

ItemSignatureWhat it does
.times / .signaltimes(&self) -> &[f64] / signal(&self, name: &str) -> Option<&[f64]>Borrow the grid or one signal
.variablesvariables(&self) -> Vec<&str>The signal names
.len / .is_emptylen(&self) -> usize / is_empty(&self) -> boolSample count
.resampleresample(&self, times, interpolation: Interpolation) -> Result<Trace>A new trace on a new grid
.prepareprepare(&self, interp: Interpolation) -> PreparedTraceFix interpolation coefficients once
PreparedTrace::resampleresample(&self, times: impl Into<Vec<f64>>) -> Result<Trace>Resample repeatedly without refitting

RingBuffer is a fixed-capacity rolling window with running statistics, the piece you reach for when a monitor's verdict should react to recent history you manage yourself:

use sentil::RingBuffer;

let mut window = RingBuffer::new(4)?;
for (t, v) in [(0.0, 12.0), (1.0, 9.0), (2.0, 7.0), (3.0, 4.0), (4.0, 6.0)] {
    window.push(t, v)?; // the fifth push evicts (0.0, 12.0) and returns it
}
assert_eq!(window.min(), Some(4.0));
assert_eq!(window.mean(), Some(6.5));
ItemSignatureWhat it does
RingBuffer::newnew(capacity: usize) -> Result<RingBuffer>A window holding at most capacity samples
.pushpush(&mut self, time: f64, value: f64) -> Result<Option<(f64, f64)>>Append; returns the evicted oldest sample when full
.pop_front / .pop_back-> Option<(f64, f64)>Remove from either end
.get / .front / .backget(&self, index: usize) -> Option<(f64, f64)> and the endsIndexed access
.capacity / .len / .is_empty / .is_fullthe usual accessorsSize state
.clearclear(&mut self)Empty the window
.time_rangetime_range(&self) -> Option<(f64, f64)>Oldest and newest timestamps
.mean / .variance / .std_dev-> Option<f64>Running statistics over the window
.recompute_statisticsrecompute_statistics(&mut self)Rebuild the running sums from scratch
.min / .max-> Option<f64>Extremes over the window
.iter / .values / .timesiteratorsWalk pairs, values, or times
.recentrecent(&self, count: usize) -> impl IteratorThe newest count samples
.at_time / .closest_to_timeat_time(&self, time: f64) -> Option<f64> and the pair formExact or nearest lookup
.betweenbetween(&self, start: f64, end: f64) -> impl IteratorThe samples in a time span

Noise models and lifting

Everything here is behind statistical. NoiseModel carries seventeen families, each behind a validating constructor; the noise models reference describes when each fits.

ConstructorSignature
NoiseModel::diracdirac(value: f64) -> Result<NoiseModel>
NoiseModel::gaussiangaussian(mean: f64, std_dev: f64) -> Result<NoiseModel>
NoiseModel::uniformuniform(low: f64, high: f64) -> Result<NoiseModel>
NoiseModel::log_normallog_normal(mu: f64, sigma: f64) -> Result<NoiseModel>
NoiseModel::exponentialexponential(lambda: f64) -> Result<NoiseModel>
NoiseModel::gammagamma(shape: f64, scale: f64) -> Result<NoiseModel>
NoiseModel::betabeta(alpha: f64, beta: f64) -> Result<NoiseModel>
NoiseModel::weibullweibull(shape: f64, scale: f64) -> Result<NoiseModel>
NoiseModel::rayleighrayleigh(scale: f64) -> Result<NoiseModel>
NoiseModel::gumbelgumbel(location: f64, scale: f64) -> Result<NoiseModel>
NoiseModel::cauchycauchy(location: f64, scale: f64) -> Result<NoiseModel>
NoiseModel::student_tstudent_t(df: f64, location: f64, scale: f64) -> Result<NoiseModel>
NoiseModel::truncated_normaltruncated_normal(mean: f64, std_dev: f64, lower: f64, upper: f64) -> Result<NoiseModel>
NoiseModel::poissonpoisson(lambda: f64) -> Result<NoiseModel>
NoiseModel::binomialbinomial(n: u64, p: f64) -> Result<NoiseModel>
NoiseModel::bootstrapbootstrap(residuals: impl Into<Vec<f64>>) -> Result<NoiseModel>
NoiseModel::mixturemixture(weights: impl Into<Vec<f64>>, components: Vec<NoiseModel>) -> Result<NoiseModel>

Fitting a model from paired calibration data is a two-step: compute residuals under an interaction, then fit a family to them.

use sentil::{NoiseInteraction, NoiseModel};

let residuals = NoiseModel::residuals(&ground_truth, &readings, NoiseInteraction::Additive)?;
let fitted = NoiseModel::fit_gaussian(&residuals)?;
ItemSignatureWhat it does
NoiseModel::residualsresiduals(ground_truth: &[f64], readings: &[f64], interaction: NoiseInteraction) -> Result<Vec<f64>>y - g or y / g per pair
NoiseModel::fit_gaussianfit_gaussian(samples: &[f64]) -> Result<NoiseModel>Maximum-likelihood Gaussian
NoiseModel::fit_bootstrapfit_bootstrap(samples: &[f64]) -> Result<NoiseModel>The empirical distribution, resampled with replacement
NoiseModel::fit_bootstrap_reservoirfit_bootstrap_reservoir(samples: &[f64], max_samples: usize) -> Result<NoiseModel>Bootstrap with a bounded reservoir for long calibrations
NoiseModel::fit_gaussian_mixturefit_gaussian_mixture(samples: &[f64], components: usize, seed: u64) -> Result<NoiseModel>Expectation-maximization mixture fit
.samplesample<R: Rng + ?Sized>(&self, rng: &mut R) -> f64One draw
.mean / .variance-> Option<f64>Moments, when the family has them
NoiseInteractionAdditive or MultiplicativeHow noise combines with a reading
NoiseInteraction::applyapply(self, reading: f64, noise: f64) -> f64reading + noise or reading * noise

LiftingRegistry maps variables to noise models and turns a deterministic trace into noisy realizations:

ItemSignatureWhat it does
LiftingRegistry::newnew() -> LiftingRegistryAn empty registry
.registerregister(&mut self, variable: &str, model: NoiseModel, interaction: NoiseInteraction) -> &mut LiftingRegistryAttach a model; returns &mut Self for chaining, not a Result
.variables / .is_emptyvariables(&self) -> Vec<&str> / is_empty(&self) -> boolWhat is registered
.liftlift(&self, trace: &Trace, seed: u64) -> Result<Trace>One seeded noisy realization; unregistered signals pass through

The lifting guide walks a full calibration.

Confidence intervals and sample sizing

The interval machinery is public on its own, and the free functions live at the sentil::stats:: path rather than the crate root.

ItemSignatureWhat it does
ConfidenceInterval{ lower: f64, upper: f64, level: f64 }, all publicAn interval at a confidence level
.width / .containswidth(&self) -> f64 / contains(&self, p: f64) -> boolRead it
IntervalMethodWilson (default), ClopperPearson, Jeffreys, AgrestiCoullThe four interval constructions
IntervalMethod::intervalinterval(self, successes: u64, trials: u64, level: f64) -> ConfidenceIntervalApply any method by value
stats::wilson_intervalwilson_interval(successes: u64, trials: u64, level: f64) -> ConfidenceIntervalThe default score interval
stats::clopper_pearsonsame shapeThe conservative exact interval
stats::jeffreys_interval / stats::agresti_coullsame shapeThe Bayesian-prior and pragmatic alternatives
stats::z_scorez_score(level: f64) -> f64The two-sided normal quantile; z_score(0.95) is 1.959964
stats::chernoff_hoeffding_sampleschernoff_hoeffding_samples(epsilon: f64, delta: f64) -> Result<u64>A priori sample sizing; (0.1, 0.05) gives 185
stats::wilson_sampleswilson_samples(epsilon: f64, level: f64) -> Result<u64>Samples for a target half-width; (0.01, 0.95) gives 9604

A number to check the wiring with: stats::clopper_pearson(50, 100, 0.95) is [0.398321, 0.601679], slightly wider than Wilson's [0.4038, 0.5962] on the same counts, which is the exact interval's price. Confidence intervals explains how to choose, and the sample-size guide budgets a run.

The Monte Carlo configuration and results:

ItemShapeNotes
SmcConfig{ samples: u64, confidence: f64, seed: u64, interval_method: IntervalMethod }Default is 10,000 samples, 0.95, seed 42, Wilson
SmcResult{ probability: f64, interval: ConfidenceInterval, satisfactions: u64, samples: u64, holds: bool }satisfactions is the raw success count behind the estimate
RobustnessDistribution{ count: u64, mean: f64, variance: f64, min: f64, max: f64 } plus std_dev()From check_distribution, the ensemble's spread

Sequential test configuration

SprtConfig and BayesConfig validate on construction and again on deserialization under serde, so an invalid shape cannot arrive from a config file.

ItemSignatureWhat it does
SprtConfig::newnew(p0: f64, p1: f64, alpha: f64, beta: f64, max_samples: u64) -> Result<SprtConfig>The indifference region (p0, p1) and both error rates
.with_seedwith_seed(self, seed: u64) -> SprtConfigReseed the draws
.seed / .p0 / .p1 / .alpha / .beta / .max_samplesaccessorsRead the settings back
SprtResultAcceptH0 { samples }, AcceptH1 { samples }, Inconclusive { samples, log_likelihood }AcceptH1 means the property holds at the p1 rate
stats::sequential_testsequential_test<F: FnMut() -> Result<bool>>(config: &SprtConfig, draw: F) -> Result<SprtResult>Run Wald's test over any Bernoulli source
BayesConfig::newnew(threshold: f64, bayes_factor: f64, max_samples: u64) -> Result<BayesConfig>Beta(1, 1) prior, stop at the factor cutoff
.with_seed and accessorsas SprtConfig
BayesResultHolds { samples, posterior }, Fails { samples, posterior }, Inconclusive { samples, posterior }Every variant carries the posterior
stats::bayes_sequential_testbayes_sequential_test<F: FnMut() -> Result<bool>>(config: &BayesConfig, draw: F) -> Result<BayesResult>The Bayesian loop over any Bernoulli source

The two free functions accept any draw closure, so they also test things that are not formulas; feeding sequential_test a source that succeeds nine times in ten decides AcceptH1 within a handful of draws. The SPRT guide tunes the error rates.

Stochastic systems and rare events

StochasticSystem wraps two closures into a sampling-ready simulator; it drives both check_rare_event and ChanceConstraint::validate.

ItemSignatureWhat it does
StochasticSystem::newnew(variables, dt: f64, horizon: usize, init: impl Fn(&mut dyn RngCore) -> Vec<f64> + Sync + 'static, step: impl Fn(&[f64], f64, &mut dyn RngCore) -> Vec<f64> + Sync + 'static) -> Result<StochasticSystem>init draws the initial state, step advances it one dt
.thread_confined / .is_thread_confinedthread_confined(self) -> StochasticSystem and the accessorOpt out of parallel sampling when the closures touch a runtime that is not Sync-safe in practice
.variables / .dt / .horizonaccessorsThe declared shape
.initial / .advanceinitial(&self, rng: &mut dyn RngCore) -> Vec<f64> / advance(&self, previous: &[f64], time: f64, rng: &mut dyn RngCore) -> Vec<f64>Drive it by hand
.simulatesimulate(&self, rng: &mut dyn RngCore) -> Result<Trace>One full trajectory: the initial state plus horizon steps, so horizon + 1 samples

The splitting configuration and result:

ItemShapeNotes
RareEventConfig{ particles: usize, margin: f64, seed: u64 }Default is 4096 particles, margin 0, seed 42
RareEventResult{ probability: f64, violation_probability: f64, holds: bool, simulations: u64 }The two probabilities sum to one; a point estimate by design, with no interval

Underneath sits a generic estimator you can drive with your own state machine, at the sentil::stats:: path:

ItemSignatureWhat it does
RareEventSimulatortrait: type State: Clone, initial_state(&self, rng) -> State, step(&self, &State, rng) -> State, is_terminal(&self, &State) -> (bool, bool), score(&self, &State) -> f64is_terminal returns (finished, event occurred); score must rise toward the event
RareEventEstimate{ probability: f64, simulations: u64 }The raw estimate
stats::adaptive_multilevel_splittingadaptive_multilevel_splitting<S: RareEventSimulator>(simulator: &S, particles: usize, target_score: f64, max_steps: u64, seed: u64) -> Result<RareEventEstimate>Clone survivors level by level until the target score is reached

SimModel is the declarative twin of StochasticSystem: dynamics as SimExpr terms instead of closures, which is what lets the GPU transpile them. Both re-export at the crate root under the gpu feature.

ItemSignatureWhat it does
SimExprPrev(usize), Time, Const(f64), Add, Sub, Mul, Div, Call(String, Vec<SimExpr>), Noise(usize)One update term; Prev(i) reads variable i at the previous step, Noise(i) draws from noise source i
SimModel::newnew(variables, dt: f64, horizon: usize, init: Vec<SimExpr>, advance: Vec<SimExpr>, noise: Vec<NoiseModel>) -> Result<SimModel>One init and one advance expression per variable
.variables / .dt / .horizonaccessorsThe declared shape
.simulatesimulate(&self, rng: &mut dyn RngCore) -> Result<Trace>Interpret on the CPU
.to_stochastic_systemto_stochastic_system(&self) -> Result<StochasticSystem>Bridge to every CPU consumer
Formula::check_rare_event_gpucheck_rare_event_gpu(&self, model: &SimModel, config: &RareEventConfig) -> Result<GpuSplittingEstimate>GPU splitting; needs a G-shaped, atemporal-bodied inner formula with a window starting at 0
gpu::is_availableis_available() -> boolWhether a usable device exists; cached after the first call
GpuSplittingEstimate{ violation_probability: f64, particles: usize, levels: u32 }What the GPU run resolved

Synthesis reference

The synthesis surface in full. The worked path is in the synthesis section above; backends and their tradeoffs are on synthesis backends.

Models. SystemModel is the trait the synthesizer drives, and implementing it is how custom dynamics plug in; the cart in this example damps its velocity by ten percent per step:

use sentil::{Bounds, Formula, SynthesisProblem, Synthesizer, SystemModel, Trace};

struct Cart {
    x0: [f64; 2],
}

impl SystemModel for Cart {
    fn input_dimension(&self) -> usize {
        10
    }

    fn initial_state(&self) -> &[f64] {
        &self.x0
    }

    fn rollout_from(&self, initial: &[f64], input: &[f64]) -> sentil::Result<Trace> {
        let (mut pos, mut vel) = (initial[0], initial[1]);
        let mut positions = Vec::with_capacity(input.len());
        for u in input {
            vel = 0.9 * vel + u;
            pos += vel;
            positions.push(pos);
        }
        let times: Vec<f64> = (1..=input.len()).map(|i| i as f64).collect();
        Trace::from_signal(times, "pos", positions)
    }
}

let model = Cart { x0: [0.0, 0.0] };
let spec = Formula::parse("F[0, 10] (pos > 4)")?;
let problem = SynthesisProblem::new(&model, &spec)
    .with_bounds(Bounds::new(vec![-1.0; 10], vec![1.0; 10])?);
assert!(Synthesizer::solve(&problem)?.holds);
ItemSignatureWhat it does
SystemModel::input_dimensioninput_dimension(&self) -> usizeThe packed input length
SystemModel::initial_stateinitial_state(&self) -> &[f64]The default start; the online controller rolls from the live state instead
SystemModel::rollout_fromrollout_from(&self, initial: &[f64], input: &[f64]) -> Result<Trace>Turn an input into the trace the formula reads
SystemModel::affine_formaffine_form(&self) -> Option<AffineForm>Opt into the MILP backend; the default None opts out
LinearModel::newnew(a: Vec<Vec<f64>>, b: Vec<Vec<f64>>, x0, variables, dt: f64, horizon: usize) -> Result<LinearModel>Built-in x_{t+1} = A x_t + B u_t, each state component a named signal
AffineForm{ a, b, x0, variables: Vec<String>, dt: f64, horizon: usize }, all publicThe affine structure a model hands the MILP encoder
Bounds::newnew(lower: impl Into<Vec<f64>>, upper: impl Into<Vec<f64>>) -> Result<Bounds>A per-coordinate box
Bounds::unboundedunbounded(dimension: usize) -> BoundsNo box, for barrier-only filtering
.clamp / .dimension / .lower / .upperclamp(&self, point: &mut [f64]) and accessorsProject a point into the box, or read it

Problems and solving:

ItemSignatureWhat it does
SynthesisProblem::newnew(model: &M, spec: &Formula) -> SynthesisProblem<M>An open-loop problem; the builder methods consume and return self
.with_bounds / .with_smooth / .with_budget / .with_backend / .with_populationbuilder methodsBox, smoothing, iteration cap, backend choice, CMA-ES population
.on_gpuon_gpu(self, enable: bool) -> SynthesisProblem<M>Batch candidate scoring on the GPU; synthesis-gpu
Synthesizer::solvesolve(problem: &SynthesisProblem<M>) -> Result<SynthesisResult>Run the chosen backend
SynthesisResult{ input: Vec<f64>, robustness: f64, holds: bool, backend: Backend }backend records which solver actually ran
BackendAuto (default), Gradient, CmaEs, MilpAuto picks by problem structure

Smoothing. Synthesis differentiates through a soft robustness; the smooth semantics page defines it exactly.

ItemSignatureWhat it does
SmoothConfig::newnew(temperature: f64) -> Result<SmoothConfig>Lower temperature hugs the exact min and max tighter
.with_kind / .temperature / .kindbuilder and accessorsPick the softening
SoftKindLogSumExp (default), ArithmeticGeometricMeanThe two soft-extremum families
Formula::smooth_robustnesssmooth_robustness(&self, trace: &Trace, config: SmoothConfig) -> Result<f64>The differentiable score; a soft minimum sits at or below the exact one
Formula::smooth_value_and_gradientsmooth_value_and_gradient(&self, trace: &Trace, config: SmoothConfig) -> Result<(f64, BTreeMap<String, Vec<f64>>)>Reverse-mode gradients per signal; LogSumExp only
Formula::smooth_gradientsmooth_gradient(&self, model: &impl SystemModel, initial: &[f64], input: &[f64], config: SmoothConfig) -> Result<(f64, Vec<f64>)>Value and gradient with respect to the input, through the model
synthesis::soft_min / soft_maxsoft_min(values: &[f64], temperature: f64) -> f64 and the dualThe raw soft extrema

Control:

ItemSignatureWhat it does
Controller::newnew(model: &M, spec: &Formula, input_width: usize, budget: Duration) -> Controller<M>Receding horizon under a wall-clock deadline; anytime, returning the best input found in budget
Controller::with_iterationswith_iterations(model: &M, spec: &Formula, input_width: usize, max_iters: usize) -> Controller<M>The alternate constructor: a fixed iteration cap instead of a clock, for no_std targets
.with_bounds / .with_smoothbuilder methodsConstrain and tune
.controlcontrol(&mut self, state: &[f64]) -> Result<Vec<f64>>One planned input, warm-started from the previous step
SafetyFilter::newnew(bounds: Bounds) -> SafetyFilterA least-restrictive shield over any nominal controller
SafetyFilter::filterfilter(&self, nominal: &[f64], barriers: &[(Vec<f64>, f64)]) -> Result<Vec<f64>>Each barrier is a . u >= b; a safe nominal passes through unchanged, an unsafe one moves the least distance that satisfies every barrier
ChanceConstraint::newnew(formula: Formula, probability: f64) -> Result<ChanceConstraint>A probabilistic guarantee; statistical too
.with_confidence / .with_tighteningbuilder methodsThe validation level and a conservative margin
.validatevalidate(&self, system: &StochasticSystem, samples: u64, seed: u64) -> Result<ChanceReport>Sample the system and test the guarantee
ChanceReport{ estimate: f64, lower_bound: f64, samples: u64, holds: bool }holds compares the interval's lower bound against the target

Falsification and mining:

ItemSignatureWhat it does
Formula::find_counterexamplefind_counterexample(&self, model: &M, bounds: &Bounds, max_iters: usize, smooth: SmoothConfig) -> Result<Witness>Gradient descent toward a violation
Formula::falsifyfalsify(&self, model: &M, bounds: &Bounds, config: CmaConfig, restarts: usize) -> Result<Witness>Restarted CMA-ES, for rugged objectives
Witness{ input: Vec<f64>, robustness: f64, trace: Trace }, all publicNegative robustness means a genuine counterexample
mine_tightest_parametermine_tightest_parameter<M: Fn(f64) -> Result<Formula>>(make, traces: &[Trace], lower: f64, upper: f64) -> Result<f64>Binary-search the sharpest constant that holds on every trace
CmaConfig{ population, max_generations, initial_step, tol_step, seed }, all public, with DefaultTuning for CMA-ES and the falsifier

Numeric building blocks, at the sentil::synthesis:: path; useful when you drive the optimizers directly:

ItemSignatureWhat it does
synthesis::maximizemaximize<F: Fn(&[f64]) -> Result<(f64, Vec<f64>)>>(objective, start: &[f64], bounds: &Bounds, max_iters: usize) -> Result<(Vec<f64>, f64)>Projected gradient ascent on any objective that returns value and gradient
synthesis::cma_escma_es<F: Fn(&[f64]) -> Result<f64>>(objective, start: &[f64], bounds: &Bounds, config: CmaConfig) -> Result<(Vec<f64>, f64)>Black-box search, no gradient needed
synthesis::cma_es_batchedcma_es_batched<F: Fn(&[Vec<f64>]) -> Result<Vec<f64>>>(batch_objective, start, bounds, config) -> Result<(Vec<f64>, f64)>Score a whole generation per call, the shape GPU batching uses
synthesis::solve_qpsolve_qp(p: &[Vec<f64>], q: &[f64], g: &[Vec<f64>], h: &[f64], max_iters: usize) -> Result<Vec<f64>>Minimize 1/2 uáµ€Pu + qáµ€u subject to Gu <= h, for positive-definite P
synthesis::solve_milpsolve_milp(affine: &AffineForm, spec: &Formula, bounds: &Bounds, max_nodes: usize) -> Result<Vec<f64>>The complete big-M encoding over affine dynamics; std
synthesis::symmetric_eigensymmetric_eigen(matrix: &[Vec<f64>]) -> Result<(Vec<f64>, Vec<Vec<f64>>)>Eigendecomposition of a symmetric matrix
synthesis::solve_spdsolve_spd(matrix: &[Vec<f64>], rhs: &[f64]) -> Result<Vec<f64>>Solve a symmetric positive-definite system

The specifications library

Behind specs, the crate embeds the standards-derived specification templates and resolves them by name. The catalog itself is browsable under specifications.

use sentil::SpecRegistry;

let builder = SpecRegistry::global()
    .builder("controls/overshoot")?
    .with_param("max_overshoot", 0.02)?
    .with_variant("bidirectional")?;

println!("{}", builder.build_deterministic()?);
// always[0, 30.0](abs(output - reference) < 0.02 * 1.0)
let monitor = SpecRegistry::global().builder("controls/overshoot")?.into_monitor()?;

with_param rejects a name the template does not define, so a typo fails at build time rather than monitoring the wrong thing; controls/overshoot takes max_overshoot, step_amplitude, T, and p. The builder methods consume self and return Result<Self>, so chain them with ?.

ItemSignatureWhat it does
SpecRegistry::globalglobal() -> &'static SpecRegistryThe process-wide registry of embedded templates
.availableavailable(&self) -> Vec<String>Every template name, sorted
.getget(&self, name: &str) -> Result<SpecTemplate>The raw parsed template
.load_fileload_file<P: AsRef<Path>>(&self, path: P) -> Result<SpecTemplate>Read a template you authored, same format as the embedded ones
.builderbuilder(&self, name: &str) -> Result<SpecBuilder>Start resolving a named template
SpecBuilder::newnew(template: SpecTemplate) -> SpecBuilderBuild from a template you loaded yourself
.templatetemplate(&self) -> &SpecTemplateBorrow the template underneath
.with_variantwith_variant(self, variant: &str) -> Result<SpecBuilder>Select a named variant
.with_paramwith_param(self, name: &str, value: f64) -> Result<SpecBuilder>Override a parameter; unknown names and out-of-range values error
.available_variantsavailable_variants(&self) -> Vec<&str>The variant names
.parametersparameters(&self) -> HashMap<String, f64>The effective parameter values after overrides
.build_deterministic / .build_probabilistic-> Result<String>The resolved formula as text, STL or PrSTL
.build_formula / .build_probabilistic_formula-> Result<Formula>The same, parsed
.resolved_noiseresolved_noise(&self) -> Option<HashMap<String, NoiseDef>>The template's noise declarations after variant overrides
.build_lifting_registrybuild_lifting_registry(&self) -> Result<LiftingRegistry>The declared noise as a ready registry
.smc_settings / .sprt_settings / .ams_settings-> Option<&SmcSettings> and kinThe template's recommended verification settings
.noise_fitnoise_fit(&self) -> Option<&HashMap<String, NoiseFitDef>>How the template suggests fitting noise from your calibration data
.into_monitorinto_monitor(self) -> Result<Monitor>A monitor preloaded with the spec and its recommended settings

The template's component types live at the sentil::spec_builder:: path; you meet them when authoring templates or introspecting one:

TypeShape
SpecTemplate{ metadata, variables, parameters, formulas, noise, noise_fit, verification, variants }, all public
Metadata{ name, domain, description, references }
VariableDef{ unit, description }
ParameterDef{ param_type, default: f64, unit, range: Option<[f64; 2]>, description }
Formulas{ deterministic: String, probabilistic: Option<String> }
NoiseDef{ model: String, interaction: String, params }
NoiseFitDef{ algorithm, k, max_iters, interaction }
VerificationConfig{ smc, sprt, ams }
SmcSettings / SprtSettings / AmsSettings{ confidence, sample_budget } / { p0, p1, alpha, beta, max_samples } / { num_particles, max_steps }
VariantDef / VariantFormulas / VariantParamOverridea variant's description, formula overrides, and { default: f64 } parameter overrides
spec_builder::noise_model_from_defnoise_model_from_def(def: &NoiseDef) -> Result<NoiseModel> turns one declaration into a model
Edit this page on GitHub