Languages

Julia

The Julia package: formulas built from operators the language already has, monitoring and checking through ccall with no glue layer, the consume contract, and the full exported-name reference.

The Julia package binds the engine through ccall, so there is no glue layer and no build step of your own: add the package, point SENTIL_LIB at the core, and using Sentil brings the full surface into scope. Formulas build from the operators Julia already has, the way a JuMP constraint comes from a comparison, and mutating functions carry the trailing bang. The binding costs nothing you can feel: reading a robustness measures 114 ns against 112 ns for calling the core from Rust itself.

Install

Two pieces have to meet: the Julia package, which compiles nothing of its own because it is pure ccall, and the shared library it calls into. The package comes from the General registry. The library comes from a C ABI release bundle or from a cargo build in a checkout, and SENTIL_LIB names the file for the loader. The package does declare a per-platform artifact as a fallback, but the 0.3.0 entries in its Artifacts.toml still carry placeholder hashes and name tarballs no release builds, so the download fails and SENTIL_LIB is the route that works rather than an override.

From the Julia registry

At the Pkg REPL (press ]):

add Sentil

From a script it is import Pkg; Pkg.add("Sentil"). Julia 1.10 or newer. Every dependency is a standard library, so nothing builds and no compiler is involved on the Julia side.

using Sentil raises an InitError at this point, with SENTIL_LIB named in the message, because no core is on disk yet. A release bundle or a source build puts one there, and both are below.

From a GitHub release

Every tagged release attaches one C ABI bundle per platform. Julia reads the shared library inside it and ignores the rest; include/sentil.h and the pkg-config and CMake files under lib/ are there for the C and C++ builds.

PlatformAssetLibrary inside
Linux x86_64sentil-0.3.0-linux-x86_64.tar.gzlib/libsentil.so
macOS Apple siliconsentil-0.3.0-macos-arm64.tar.gzlib/libsentil.dylib
macOS Intelsentil-0.3.0-macos-x86_64.tar.gzlib/libsentil.dylib
Windows x86_64sentil-0.3.0-windows-x86_64.tar.gzlib/sentil.dll

No bundle here covers Linux on arm64 or Windows on arm64. For a Raspberry Pi or another ARM Linux board, the gnu archives on the CLI page carry lib/libsentil.so next to the binary and serve the same purpose, while the musl archive holds the CLI alone. Those ARM cores are built without the GPU feature, so gpu_available() raises on one rather than returning false. Windows on arm64 builds from source.

Download the bundle for your platform and unpack it. It expands into a directory named after itself.

curl -LO https://github.com/sedislab/SENTIL/releases/download/v0.3.0/sentil-0.3.0-linux-x86_64.tar.gz
tar xzf sentil-0.3.0-linux-x86_64.tar.gz
curl -LO https://github.com/sedislab/SENTIL/releases/download/v0.3.0/sentil-0.3.0-macos-arm64.tar.gz
tar xzf sentil-0.3.0-macos-arm64.tar.gz

On an Intel Mac, swap macos-arm64 for macos-x86_64 in both lines and in the export below.

$url = "https://github.com/sedislab/SENTIL/releases/download/v0.3.0/sentil-0.3.0-windows-x86_64.tar.gz"
Invoke-WebRequest -Uri $url -OutFile sentil.tar.gz
tar xzf sentil.tar.gz

Inside a checkout, scripts/fetch-prebuilt-core.sh picks the bundle matching the machine, unpacks it, and prints the export line to paste.

Point SENTIL_LIB at the library file itself, not at the directory holding it. The loader reads the variable once, in the package's __init__, so it has to be set before the first using Sentil in that session. A path with no file at it fails on the spot and the message repeats the path back to you.

export SENTIL_LIB="$PWD/sentil-0.3.0-linux-x86_64/lib/libsentil.so"
export SENTIL_LIB="$PWD/sentil-0.3.0-macos-arm64/lib/libsentil.dylib"
$env:SENTIL_LIB = "$PWD\sentil-0.3.0-windows-x86_64\lib\sentil.dll"

Check the load.

using Sentil
version()   # (0, 3, 0)

version() reads the numbers back out of the library through ccall, so (0, 3, 0) means the core opened and the call path works.

From source

Building the core needs a Rust toolchain from rustup.rs and a linker, which comes from a different place on each system. The repository-wide steps are on install from source; what follows is the Julia path through them.

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

The Command Line Tools supply the linker.

xcode-select --install

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

Clone the repository and build the C ABI.

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

Point the loader at what you built.

export SENTIL_LIB="$PWD/target/release/libsentil.so"
export SENTIL_LIB="$PWD/target/release/libsentil.dylib"
$env:SENTIL_LIB = "$PWD\target\release\sentil.dll"

Develop the package against the checkout and check the load.

import Pkg
Pkg.develop(path="sentil-jl")

using Sentil
version()   # (0, 3, 0)

Pkg.test("Sentil") then runs the package's own suite against the core you built, including the oracle cases every binding shares.

Your first monitor

first_monitor.jl
using Sentil

phi = formula("G (speed > 5)")
trace = Trace(collect(0.0:1.0:4.0), "speed", [12.0, 9.0, 7.0, 4.0, 6.0])
robustness(phi, trace)   # -1.0

The result is -1.0: G, read always, keeps the worst margin, and the worst comes at t = 3 where the speed reads 4 against the 5 bound. A negative value is a violation and its size is how far the trace missed. What robustness means covers the semantics, and the grammar reference covers everything formula accepts.

Traces

A Trace maps signal names onto one time grid. Build one from vectors, a Dict, a file, or delimited text, and read values back by name.

trace = Trace([0.0, 1.0, 2.0], Dict("x" => [1.0, 2.0, 3.0], "y" => [0.5, 0.4, 0.3]))
add_signal!(trace, "z", [9.0, 8.0, 7.0])

trace["x"]           # [1.0, 2.0, 3.0]; an unknown name raises SemanticError
haskey(trace, "y")   # true
length(trace)        # 3

logged = read_trace("run.csv")            # format from the .csv or .tsv extension
inline = parse_trace("t,x\n0,1\n1,2")     # in-memory text; format=:csv or :tsv

The shipped core reads CSV and TSV; the extended formats on the trace formats reference need a core built with the matching features. For dense-time work, resample interpolates onto a new grid, and prepare fixes the interpolation once so resampling onto many grids is cheap:

fine = resample(trace, collect(0.0:0.1:2.0); interp=Interpolation.Cubic)
p = prepare(trace; interp=Interpolation.Linear)   # a PreparedTrace
finer = resample(p, collect(0.0:0.05:2.0))

The full constructor and accessor set is under trace functions below.

Streaming

An OnlineMonitor takes one update! per reading, and neither cost nor memory grows with the stream; the monotonic deque page covers how.

streaming.jl
using Sentil

monitor = OnlineMonitor("G[0, 10] (x > -0.9)")
for t in 0:59
    verdict = update!(monitor, Float64(t), Dict("x" => sin(t * 0.3)))
    if verdict.resolved && !verdict.satisfied
        println("violated at t=$t, robustness=$(round(verdict.value, digits=3))")
        break
    end
end

Each update! returns a Robustness; the loop's guard, verdict.resolved && !verdict.satisfied, alarms only on settled violations, since an open window reports value as the midpoint of lower and upper. A bounded horizon like the [0, 10] here is what lets a live stream ever resolve.

Every update! through a Dict allocates; resolve each variable's slot once with symbol_index and hand update_packed! a preallocated vector in that order.

i = symbol_index(monitor, "x")                        # 1-based slot in packed order
buf = Vector{Float64}(undef, variable_count(monitor))
buf[i] = read_sensor()
update_packed!(monitor, t, buf)

reset! rewinds the state, and run!(monitor, trace) replays a recorded trace, returning the verdict at every step.

Live probability

Give the constructor a lifting registry and the monitor tracks a P~p formula live; last_probability reads the running estimate after any update. It returns nothing for a deterministic formula and before the first sample arrives.

lifting = LiftingRegistry()
register_noise!(lifting, "x", gaussian(0.0, 0.3))

pm = OnlineMonitor(formula("P>=0.9 (G[0, 10] (x > -0.9))"), lifting)
update!(pm, 0.0, Dict("x" => 0.2))
last_probability(pm)   # the current satisfaction estimate

Probabilistic monitoring

Attach a noise model per sensor, and a check lifts the trace into an ensemble, evaluates the formula on every member, and reports a probability with a confidence interval; the P~p operator itself is covered in what PrSTL is.

Fit the model from calibration data rather than guessing it:

prstl.jl
using Sentil

truth  = [10.0, 10.5, 11.0, 10.8, 10.2, 9.9, 10.4]
sensor = [10.2, 10.3, 11.3, 10.6, 10.5, 10.1, 10.2]
noise = fit_gaussian(residuals(truth, sensor))

lifting = LiftingRegistry()
register_noise!(lifting, "x", noise)   # consumes the model handle

trace = Trace(collect(0.0:1.0:30.0), "x", fill(10.5, 31))
phi = formula("P>=0.9 (G (x > 9))")

result = check(phi, trace, lifting)
result.probability   # the estimate over 10000 sampled trajectories
result.interval      # a ConfidenceInterval with lower, upper, level
result.holds         # whether the estimate meets the P>=0.9 threshold

check takes its settings as a keyword: check(phi, trace, lifting; config=SmcConfig(samples=5000, confidence=0.99)). check_conservative swaps in the Clopper-Pearson exact interval, check_distribution also returns the spread of robustness values across the ensemble, and lift(lifting, trace; seed=42) hands you one noisy realization when you want to see what the ensemble is made of. Choosing an interval is covered in confidence intervals, the seventeen families in noise models.

Sequential and Bayesian decisions

If a yes or no is all you need, the sequential tests get there in far fewer lifted samples than an estimate. Both take their config positionally, unlike check:

check_sequential(phi, trace, lifting, SprtConfig(0.85, 0.95)).verdict   # AcceptH0, AcceptH1, or Inconclusive
check_bayesian(phi, trace, lifting, BayesConfig(0.9)).verdict           # Holds, Fails, or Inconclusive

sequential_test and bayes_sequential_test run the same tests over any () -> Bool source you supply, with no trace involved.

Rare events

check_rare_event runs adaptive multilevel splitting over a StochasticSystem, with its config as a keyword; rare events explains how the splitter reaches probabilities sampling cannot:

system = StochasticSystem(["x"], 1.0, 200;
    init = seed -> [0.0],
    step = (prev, t, seed) -> [0.9 * prev[1] + 0.1 * randn()])

rare = check_rare_event(phi, system; config=RareEventConfig(particles=8192))
rare.probability   # with rare.violation_probability, rare.holds, rare.simulations

The GPU path takes a declarative SimModel, not a StochasticSystem, because the dynamics must lower onto the device. Without a gpu build and a present device it raises EvaluationError with code SENTIL_ERR_GPU rather than silently falling back to the CPU; probe with gpu_available() first. Declaring a SimModel is shown under stochastic systems.

Synthesis

Synthesis runs the engine backward: given a system model, bounds, and a spec, find an input that satisfies it. The package covers open-loop synthesis, a receding-horizon controller, falsification, and parameter mining; the solvers behind them are on synthesis backends.

synthesis.jl
using Sentil

a = reshape([1.0], 1, 1)
b = reshape([1.0], 1, 1)
model = linear_model(a, b, [0.0], ["x"], 1.0, 10)
spec = formula("F[0, 10](x > 5) & G[0, 10](x < 12)")

result = synthesize(model, spec; bounds=Bounds(fill(-3.0, 10), fill(3.0, 10)))
result.input        # the input sequence found
result.robustness   # positive when the spec is met
result.holds
result.backend      # the solver that ran, e.g. Backend.Gradient

synthesize is keyword-driven: bounds, smooth, backend, max_iters, and population all have working defaults, and Backend.Auto picks a solver from the problem's structure. An infeasible spec returns the least violating input rather than nothing.

The online form is a Controller that re-plans every step within a hard deadline, here one millisecond:

controller = Controller(linear_model(a, b, [0.0], ["x"], 1.0, 10), spec, 1, 1_000_000;
                        bounds=Bounds([-3.0], [3.0]))
u = control(controller, [0.0])   # the planned input for this step

Controller consumes the model and spec it is built from. SafetyFilter, ChanceConstraint, falsification, and parameter mining are all under synthesis and optimization.

Errors and the consume contract

Every failure raises a subtype of the abstract SentilError: ParseError for text that does not parse, with the offending column in the message; SemanticError for well-formed input the engine cannot use, an unknown variable or a statistical check on a formula with no P operator; EvaluationError for everything at runtime, from an invalid config to a failed fit. Each carries msg and a code::SentilErrorCode, the same stable status every binding surfaces, enumerated on error codes.

try
    formula("G (speed >")
catch e
    e isa ParseError && println("bad formula: ", e.msg)
end

One contract is worth knowing before it bites: composition consumes its operands. always(f) and its family, &, |, !, until, since, probability, Monitor(f), register_noise! on its model, mixture on its components, Controller, ChanceConstraint, and SafetyFilter on theirs, and the SpecBuilder chain all take ownership of the handles they build on. Touching a consumed handle raises EvaluationError saying the handle is no longer usable. The escape hatch is copy(f):

p = formula("speed > 5")
g = always(copy(p))    # the copy is consumed, p stays usable
h = eventually(p)      # p is consumed here
# robustness(p, trace) would now raise EvaluationError

Handles free their native memory through finalizers; close!(handle) releases it eagerly instead. Calling it twice is safe, and a closed handle raises the same typed error rather than touching freed memory. The cross-binding conventions are in handling errors across bindings.

Reference

The exported names in full; signatures show keyword defaults exactly as the source defines them.

Package and handles

NameSignatureWhat it is
versionversion() -> Tuple{Int, Int, Int}the bound core's version, (0, 3, 0)
close!close!(handle)free a handle's native memory now; safe to repeat
copycopy(f::Formula) -> Formulaan independent duplicate, the escape from the consume contract
SENTIL_LIBenvironment variablepath to the core, read before the bundled artifact

Enums

Each enum is a small module, so members read as TimeMode.Dense and never collide across enums.

EnumMembers
TimeModeDiscrete, Dense
InterpolationLinear, Hold, Cubic
IntervalMethodWilson, ClopperPearson, Jeffreys, AgrestiCoull
NoiseInteractionAdditive, Multiplicative
SprtVerdictAcceptH0, AcceptH1, Inconclusive
BayesVerdictHolds, Fails, Inconclusive
SoftKindLogSumExp, ArithmeticGeometricMean
BackendAuto, Gradient, CmaEs, Milp
ProbabilityOpGe, Gt, Le, Lt

SentilErrorCode is a flat @enum of the C status integers, SENTIL_OK = 0 through SENTIL_ERR_PANIC = 17.

Trace functions

A Trace owns its grid and signals; the accessors copy values out, so what you get back is yours.

NameSignatureWhat it does
TraceTrace(times), Trace(times, name, values), Trace(times, signals::AbstractDict)build over a grid: empty, one signal, or many
indexed_traceindexed_trace(len) -> Tracethe grid 0, 1, ..., len - 1
add_signal!add_signal!(trace, name, values) -> traceadd a signal matching the grid length
timestimes(trace) -> Vector{Float64}the time grid
signalsignal(trace, name) -> Union{Vector{Float64}, Nothing}a signal's values, nothing if absent
variablesvariables(trace) -> Vector{String}the signal names
read_traceread_trace(path) -> Traceread a file, format from the extension
parse_traceparse_trace(text; format=:csv) -> Traceparse delimited text, :csv or :tsv
resampleresample(trace, times; interp=Interpolation.Linear) -> Traceinterpolate onto a new grid
prepareprepare(trace; interp=Interpolation.Linear) -> PreparedTracefix the interpolation; resample(prepared, times) then costs one call per grid

Base extensions: length, isempty, haskey(trace, name), and indexing trace[name] (getindex), which raises SemanticError for a name the trace does not carry.

Ring buffers

RingBuffer(capacity) holds the newest timed samples with running statistics. Queries with no answer return nothing; positional queries return a Sample with found, time, and value.

NameSignatureWhat it does
RingBufferRingBuffer(capacity::Integer)a buffer of the most recent capacity samples
push!push!(buffer, time, value) -> Union{Sample, Nothing}append; once full, returns the evicted oldest sample
capacitycapacity(b) -> Intthe size limit
is_fullis_full(b) -> Boolwhether at capacity
clear!clear!(b) -> bdrop every sample
front / backfront(b) -> Union{Sample, Nothing}oldest and newest
pop_front! / pop_back!pop_front!(b) -> Union{Sample, Nothing}remove and return oldest or newest
closest_to_timeclosest_to_time(b, time) -> Union{Sample, Nothing}the sample nearest in time
at_timeat_time(b, time) -> Union{Float64, Nothing}the value recorded at time, within a small tolerance
time_rangetime_range(b) -> Union{Tuple{Float64, Float64}, Nothing}earliest and latest times held
betweenbetween(b, start, stop) -> Vector{Sample}the samples in [start, stop]
mean / var / stdmean(b) -> Union{Float64, Nothing}running statistics; variance and deviation need two samples
minimum / maximumminimum(b) -> Union{Float64, Nothing}extrema over the held values
recompute_statistics!recompute_statistics!(b) -> brebuild the running statistics from the held samples

Base extensions: length, isempty, and 1-based indexing b[i], which raises BoundsError out of range.

Formula building

Parse text, or build from terms. A term is a Sentil.Expr (deliberately unexported, so the name never shadows Base.Expr), created by variable and literal and combined with ordinary operators; comparing two terms yields a predicate Formula rather than a Bool. Every combinator consumes its operands, per the contract above.

NameSignatureWhat it does
formulaformula(text) -> Formulaparse PrSTL text; parse(Formula, text) is the same call
variablevariable(name) -> Expra term reading a signal
literalliteral(value::Real) -> Expra constant term
powpow(a, b) -> Exprexponentiation; a ^ b reads the same
lnln(e) -> Exprnatural log; in formulas log is log10
and / orand(a, b) -> Formulasame as a & b and a | b
impliesimplies(a, b) -> Formula!a | b
nextnext(f) -> Formulaholds at the next sample
always / eventuallyalways(f; lower=0.0, upper=nothing) -> Formulabounded when upper is set, open-ended otherwise
historically / oncehistorically(f; lower=0.0, upper=nothing) -> Formulathe past-time duals
until / sinceuntil(a, b; lower=0.0, upper=nothing) -> Formulatwo operands, both consumed
probabilityprobability(f, op::ProbabilityOp.T, threshold) -> Formulawrap in P~p; the threshold is validated in [0, 1]
to_json / from_jsonto_json(f) -> String, from_json(Formula, json) -> Formularound-trip a formula
depthdepth(f) -> Intnesting depth; a predicate is 1
is_temporalis_temporal(f) -> Boolwhether a temporal operator is present
variablesvariables(f) -> Vector{String}the referenced names, sorted and unique

Base extensions on terms and formulas: arithmetic +, -, *, /, % (also mod), and ^ with Real coercion on either side; comparisons <, <=, >, >=, ==, != building predicates; boolean !, ~, &, | on formulas; and the math functions abs, sqrt, exp, log, sin, cos, tan, floor, ceil, min, max on terms.

speed = variable("speed")
phi = always(speed > 5; lower=0.0, upper=10.0)   # G[0, 10](speed > 5)

Formula evaluation

NameSignatureWhat it does
robustnessrobustness(f, trace; dense=false) -> Float64robustness at the start time
robustness_signalrobustness_signal(f, trace; dense=false) -> Vector{Float64}robustness at every sample
violationsviolations(f, trace) -> Vector{Interval}the failing spans, each an Interval with start and stop
violation_intervalsviolation_intervals(times, values) -> Vector{Interval}the negative spans of a robustness signal you already hold

dense=true interpolates between samples so a crossing between grid points still counts; discrete versus dense is the guide.

Monitors

Monitor compiles a formula once and serves both whole-trace and streaming evaluation under a Config. Building one from a Formula consumes it; building from text does not involve a handle at all.

NameSignatureWhat it does
ConfigConfig(; time=TimeMode.Discrete)the monitor's time mode; Config(time=TimeMode.Dense) makes every evaluation dense
time_modetime_mode(c) -> TimeMode.Tread it back
MonitorMonitor(f::Formula; config=Config()), Monitor(text; config=Config())compile; the Formula form consumes f
formulaformula(m::Monitor) -> Formulaan owned copy of the watched formula
configconfig(m::Monitor) -> Configan owned copy of its config
robustness / robustness_signal / violationsrobustness(m, trace) -> Float64 and friendswhole-trace evaluation under the config's time mode
update!update!(m, time, samples::AbstractDict) -> Robustnessfold one sample
update_packed!update_packed!(m, time, values::AbstractVector) -> Robustnessthe allocation-free fold, values in symbol_index order
symbol_indexsymbol_index(m, name) -> Union{Int, Nothing}the 1-based packed slot
reset!reset!(m) -> mrewind the streaming state
last_probabilitylast_probability(m) -> Union{Float64, Nothing}the running P~p estimate
checkcheck(m, trace, lifting) -> SmcResultstatistical check with the monitor's own settings
check_sequentialcheck_sequential(m, trace, lifting, config::SprtConfig) -> SprtResultsequential decision
check_rare_eventcheck_rare_event(m, system) -> RareEventResultsplitting with the monitor's defaults

Streaming monitors

OnlineMonitor is the streaming-only form: no whole-trace calls and the smallest state. Its constructors borrow their arguments rather than consuming them.

NameSignatureWhat it does
OnlineMonitorOnlineMonitor(text), OnlineMonitor(f::Formula)deterministic streaming
OnlineMonitorOnlineMonitor(f, lifting::LiftingRegistry; config=SmcConfig())track a P~p formula live
update! / update_packed!as on Monitorfold one sample
symbol_indexsymbol_index(m, name) -> Union{Int, Nothing}the packed slot
variable_countvariable_count(m) -> Inthow many distinct variables the formula reads
run!run!(m, trace) -> Vector{Robustness}replay a trace, one verdict per step
reset! / last_probabilityas on Monitorrewind; read the running estimate

Both monitor types return a Robustness struct: resolved, satisfied, value, lower, upper.

Multi-formula monitoring

MultiMonitor advances several named formulas from one sample stream; FormulaBank evaluates a named set over a whole trace at once. add! borrows a Formula, so yours stays usable.

NameSignatureWhat it does
MultiMonitorMultiMonitor()empty; add formulas under ids
add!add!(m, id, text), add!(m, id, f::Formula)track another formula
add_probabilistic!add_probabilistic!(m, id, f, lifting; config=SmcConfig()) -> mtrack a P~p formula alongside the deterministic ones
remove!remove!(m, id) -> Boolstop tracking; whether the id was there
idsids(m) -> Vector{String}in insertion order
update!update!(m, time, samples) -> Dict{String, Robustness}one sample in, every verdict out
probabilityprobability(m, id) -> Union{Float64, Nothing}one formula's running estimate
probabilitiesprobabilities(m) -> Dict{String, Union{Float64, Nothing}}every estimate keyed by id
reset!reset!(m) -> mrewind all of them
FormulaBankFormulaBank()a named set for offline evaluation; add! and ids as above
robustnessrobustness(bank, trace; dense=false) -> Dict{String, Float64}every formula's robustness by id; a failing formula raises, naming its id

length and isempty work on both.

Noise models and lifting

Each of the seventeen families has its own constructor returning a NoiseModel. One naming caveat: binomial is deliberately unexported because the bare name is Base's binomial coefficient, so qualify it as Sentil.binomial(n, p); a bare binomial(20, 0.4) reaches Base.binomial and fails on the Float64.

ConstructorSignature
diracdirac(value)
gaussiangaussian(mean, std_dev)
uniformuniform(low, high)
log_normallog_normal(mu, sigma)
exponentialexponential(lambda)
gammagamma(shape, scale)
betabeta(alpha, b)
weibullweibull(shape, scale)
rayleighrayleigh(scale)
gumbelgumbel(location, scale)
cauchycauchy(location, scale)
student_tstudent_t(df, location, scale)
truncated_normaltruncated_normal(mean, std_dev, lower, upper)
poissonpoisson(lambda)
Sentil.binomialSentil.binomial(n, p)
bootstrapbootstrap(residuals)
mixturemixture(weights, components); the component models are consumed

Fitting and registration:

NameSignatureWhat it does
fit_gaussianfit_gaussian(samples) -> NoiseModelmaximum-likelihood Gaussian
fit_bootstrapfit_bootstrap(samples) -> NoiseModelthe empirical distribution, resampled with replacement
fit_bootstrap_reservoirfit_bootstrap_reservoir(samples, max_samples) -> NoiseModelbootstrap under a memory cap
fit_gaussian_mixturefit_gaussian_mixture(samples, components, max_iters) -> NoiseModelGaussian mixture by expectation-maximization
residualsresiduals(ground_truth, sensor; interaction=NoiseInteraction.Additive) -> Vector{Float64}y - g or y / g, ready to fit
mean / varmean(m::NoiseModel) -> Union{Float64, Nothing}the model's moments, nothing when undefined
to_json / from_jsonto_json(m) -> String, from_json(NoiseModel, json)serialize a fitted model
from_fileSentil.from_file(NoiseModel, path) -> NoiseModelload the saved JSON from disk (unexported)
LiftingRegistryLiftingRegistry()maps variables to noise models; isempty works on it
register_noise!register_noise!(r, variable, model; interaction=NoiseInteraction.Additive) -> rattach a model; the model is consumed
liftlift(r, trace; seed=42) -> Traceone seeded noisy realization
variablesvariables(r) -> Vector{String}the registered names

Statistical checks

The estimators behind check, and the interval helpers usable with no trace at all. Every number here is checkable by hand.

NameSignatureWhat it does
SmcConfigSmcConfig(; samples=10000, confidence=0.95, seed=42, method=IntervalMethod.Wilson)the estimator settings
checkcheck(f, trace, lifting; config=SmcConfig()) -> SmcResultestimate a P~p formula's satisfaction probability
check_conservativesame shapealways the Clopper-Pearson interval
check_distributioncheck_distribution(f, trace, lifting; config=SmcConfig()) -> Tuple{SmcResult, RobustnessDistribution}the estimate plus the robustness spread

SmcResult carries probability, interval::ConfidenceInterval, satisfactions, samples, and holds. RobustnessDistribution carries count, mean, variance, std_dev, min, max.

NameSignatureWhat it returns
wilson_intervalwilson_interval(successes, trials, level) -> ConfidenceIntervalthe default interval; wilson_interval(50, 100, 0.95) gives [0.403831, 0.596169]
clopper_pearsonsame shapethe exact interval; [0.398321, 0.601679] on the same counts
jeffreys_interval / agresti_coullsame shapethe two alternatives
intervalinterval(successes, trials, level; method=IntervalMethod.Wilson)any of the four by enum
widthwidth(ci::ConfidenceInterval) -> Float64upper - lower
z_scorez_score(level) -> Float64the two-sided normal quantile; z_score(0.95) is 1.959964
chernoff_hoeffding_sampleschernoff_hoeffding_samples(epsilon, delta) -> Inta priori sizing; (0.1, 0.05) gives 185
wilson_sampleswilson_samples(epsilon, level) -> Intsizing for a target half-width; (0.01, 0.95) gives 9604

Sequential tests

Both configs are positional in the check calls, and both tests also run standalone over a Bernoulli source you supply.

NameSignatureWhat it does
SprtConfigSprtConfig(p0, p1; alpha=0.05, beta=0.05, max_samples=100000, seed=42)Wald's SPRT over the indifference band [p0, p1]
check_sequentialcheck_sequential(f, trace, lifting, config::SprtConfig) -> SprtResultdecide a P~p formula sequentially
BayesConfigBayesConfig(threshold; bayes_factor=100.0, max_samples=100000, seed=42)Beta(1, 1) prior, stop at the Bayes-factor cutoff
check_bayesiancheck_bayesian(f, trace, lifting, config::BayesConfig) -> BayesResultthe Bayesian decision
sequential_testsequential_test(draw, config::SprtConfig) -> SprtResultSPRT over any () -> Bool source
bayes_sequential_testbayes_sequential_test(draw, config::BayesConfig) -> BayesResultthe same, Bayesian

SprtResult is verdict, samples, log_likelihood; BayesResult is verdict, samples, posterior. The theory is on the SPRT page.

Stochastic systems and simulation

Two ways to describe dynamics. A SimModel is declarative, built from SimExpr terms, which is what allows it to lower onto a GPU. A StochasticSystem runs host callbacks, so any Julia function can be the dynamics.

NameSignatureWhat it does
sim_prevsim_prev(i) -> SimExprthe previous value of variable i, 1-based
sim_timesim_time() -> SimExprthe current time
sim_constsim_const(value) -> SimExpra constant
sim_noisesim_noise(i) -> SimExpra draw from noise source i, 1-based
SimModelSimModel(variables, dt, horizon, init::Vector{SimExpr}, advance::Vector{SimExpr}, noise::Vector{NoiseModel})assemble a model; the expression and noise handles are consumed
to_stochastic_systemto_stochastic_system(m) -> StochasticSystemmake the model samplable by the parallel engine
StochasticSystemStochasticSystem(variables, dt, horizon; init, step)callback dynamics: init(seed) and step(prev, time, seed) each return a state vector
simulatesimulate(system; seed=42) -> Trace, simulate(model; seed=42)one seeded trajectory
dt / horizonon both typesstep size and step count
variablesvariables(s) -> Vector{String}the variable names

SimExpr supports +, -, *, / and the math functions abs, sin, cos, tan, sqrt, exp, log, floor, ceil, min, max, each combination consuming its operands, the same contract formulas follow. An AR(1) process in three lines:

ar1 = SimModel(["x"], 1.0, 200,
               [sim_const(0.0)],
               [sim_prev(1) * 0.9 + sim_noise(1)],
               [gaussian(0.0, 1.0)])
trace = simulate(ar1; seed=7)

Rare-event estimation

NameSignatureWhat it does
RareEventConfigRareEventConfig(; particles=4096, margin=0.0, seed=42)the splitting settings
check_rare_eventcheck_rare_event(f, system; config=RareEventConfig()) -> RareEventResultCPU splitting over a StochasticSystem; config is a keyword
check_rare_event_gpucheck_rare_event_gpu(f, model::SimModel; config=RareEventConfig()) -> GpuSplittingEstimateGPU splitting over a declarative model
gpu_availablegpu_available() -> Boolwhether a usable device is present
adaptive_multilevel_splittingadaptive_multilevel_splitting(; state_type, initial_state, step, is_terminal, score, particles, target_score, max_steps, seed=42) -> RareEventEstimatesplitting over your own simulator; state_type must be an isbits type

RareEventResult is probability, violation_probability, holds, simulations; RareEventEstimate is probability and simulations; GpuSplittingEstimate is violation_probability, particles, levels. For adaptive_multilevel_splitting, initial_state(seed) and step(state, seed) return a state, is_terminal(state) returns (terminal, in_rare_event), and score(state) returns a real. The method itself is on rare events.

Synthesis and optimization

The synthesizers, controllers, and search entry points, then the smooth semantics and numeric helpers they are built from.

NameSignatureWhat it does
linear_modellinear_model(A, B, x0, variables, dt, horizon) -> SystemModeldiscrete-time x' = A x + B u
SystemModelSystemModel(variables, dt, horizon; input_dimension, rollout, initial)custom dynamics: rollout(initial, input) returns a signal matrix of length(variables) rows by horizon + 1 columns
input_dimensioninput_dimension(m) -> Inttotal input values over the horizon
synthesizesynthesize(model, spec; bounds=nothing, smooth=nothing, backend=Backend.Auto, max_iters=0, population=0) -> SynthesisResultfind the input sequence that best satisfies the spec
BoundsBounds(lower, upper)a per-coordinate box
unbounded_boundsunbounded_bounds(dimension) -> Boundsa box with no limits
dimension / lower / upperdimension(b) -> Int, lower(b) -> Vector{Float64}box accessors
ControllerController(model, spec, input_width, budget_ns; bounds=nothing, smooth=nothing)receding horizon within a hard deadline; consumes model and spec
controlcontrol(c, state) -> Vector{Float64}plan one step from the current state
SafetyFilterSafetyFilter(bounds)least-restrictive shield; consumes the bounds
safe_inputsafe_input(sf, nominal; barriers=[]) -> Vector{Float64}the input nearest nominal that is safe; each barrier (coeff, bound) means coeff . u >= bound
ChanceConstraintChanceConstraint(spec, probability; confidence=0.0, tightening=0.0)probabilistic satisfaction as a risk constraint; consumes the spec
validatevalidate(cc, system; samples=1000, seed=42) -> ChanceReportcheck the constraint by sampling
find_counterexamplefind_counterexample(f, model, bounds=nothing; max_iters=200, smooth=nothing) -> Witnessgradient descent toward a violating run
falsifyfalsify(f, model, bounds=nothing; config=CmaConfig(), restarts=1) -> Witnessrestarted CMA-ES falsification
mine_tightest_parametermine_tightest_parameter(make, traces, lower, upper) -> Float64the tightest p where make(p) holds on every trace; each formula make returns is consumed

SynthesisResult is input, robustness, holds, and backend::Backend.T, the solver that actually ran. Witness is input, robustness, and the trace it produces. ChanceReport is estimate, lower_bound, samples, holds.

NameSignatureWhat it does
SmoothConfigSmoothConfig(; temperature=10.0, kind=SoftKind.LogSumExp)the soft min and max settings; temperature is ignored by the arithmetic-geometric-mean kind
smooth_robustnesssmooth_robustness(f, trace; config=SmoothConfig()) -> Float64the differentiable robustness the optimizers climb
smooth_value_and_gradientsmooth_value_and_gradient(f, trace; config=SmoothConfig()) -> Tuple{Float64, Dict{String, Vector{Float64}}}the value plus a per-signal, per-sample gradient
smooth_gradientsmooth_gradient(f, model, initial, input; config=SmoothConfig()) -> Tuple{Float64, Vector{Float64}}value and gradient through a model rollout
soft_min / soft_maxsoft_min(values, temperature) -> Float64the underlying soft extrema
maximizemaximize(objective, start; bounds=nothing, max_iters=0) -> Tuple{Vector{Float64}, Float64}projected gradient ascent; objective(x) returns (value, gradient)
cma_escma_es(objective, start; bounds=nothing, config=CmaConfig()) -> Tuple{Vector{Float64}, Float64}black-box search; objective(x) returns a scalar
cma_es_batchedsame shapescores a generation at once; objective takes a matrix whose columns are the candidate points
CmaConfigCmaConfig(; population=0, max_generations=300, initial_step=0.3, tol_step=1e-11, seed=42)CMA-ES settings; population 0 sizes itself from the dimension
solve_qpsolve_qp(P, q, G, h; max_iters=200) -> Vector{Float64}minimize 1/2 u' P u + q' u subject to G u <= h
solve_spdsolve_spd(matrix, rhs) -> Vector{Float64}solve a symmetric positive-definite system
symmetric_eigensymmetric_eigen(matrix) -> Tuple{Vector{Float64}, Matrix{Float64}}eigenvalues and eigenvectors, one eigenvector per row
clamp!clamp!(bounds, point) -> pointproject a point into the box in place

Specification library

The shipped specification catalog, resolved by name. The builder chain consumes itself at each step, so keep the returned builder, not the one you passed in.

available_specs()   # includes "aerospace/altitude_hold"
b = with_param(SpecBuilder("aerospace/altitude_hold"), "tolerance", 50.0)
build_deterministic(b)       # the PrSTL text
monitor = build_monitor(b)   # consumes b
NameSignatureWhat it does
SpecBuilderSpecBuilder(name), SpecBuilder(; file=path)by library name, or from a spec file
available_specsavailable_specs() -> Vector{String}every spec name in the library
available_variantsavailable_variants(b) -> Vector{String}the variants this spec offers
with_variantwith_variant(b, variant) -> SpecBuilderselect one; consumes the input builder
with_paramwith_param(b, name, value) -> SpecBuilderset a parameter; consumes the input builder
build_deterministic / build_probabilisticbuild_deterministic(b) -> Stringthe spec as PrSTL text
build_formula / build_probabilistic_formulabuild_formula(b) -> Formulathe spec parsed
build_lifting_registrybuild_lifting_registry(b) -> LiftingRegistrythe spec's noise models
build_monitorbuild_monitor(b) -> Monitora ready monitor; consumes the builder
parameters_jsonparameters_json(b) -> Stringthe resolved parameters as JSON
smc_settingssmc_settings(b) -> Union{SpecSmcSettings, Nothing}recommended SMC settings: confidence, sample_budget
sprt_settingssprt_settings(b) -> Union{SpecSprtSettings, Nothing}recommended SPRT settings: p0, p1, alpha, beta, max_samples
ams_settingsams_settings(b) -> Union{SpecAmsSettings, Nothing}recommended splitting settings: num_particles, max_steps

The library itself, with each spec's source and citation, is documented under specifications.

Edit this page on GitHub