kalix 0.1.0

Declarative Kalman filtering from dynamics expressions. Write the physics, derive the filter.
Documentation

Kalix — Declarative Kalman Filter with Language-Agnostic CLI Bridge

Mission

Kalix is a production-grade Kalman filter library in Rust with a CLI entry point driven via stdin/stdout JSON — callable from any language that can spawn a process (Python, TypeScript, Ruby, Go, shell, etc.). The crate name is kalix throughout — in Cargo.toml, the binary name, and all documentation.

The filter is declaratively configured via TOML — the user writes dynamics as symbolic expressions, and the system automatically derives the F (transition) and H (observation) matrices, detects linearity, and selects the appropriate filter variant (linear KF or EKF).

The CLI supports two modes: live (low-latency streaming, minimal output) and backtest (full audit trail, named state fields, complete covariance matrices). Both modes read the same input format; output verbosity differs.


Repository Layout

kalman/
├── Cargo.toml
├── README.md
├── src/
│   ├── main.rs               # CLI entry point
│   ├── lib.rs                # Public library surface
│   ├── config.rs             # TOML config parsing and validation
│   ├── expr/
│   │   ├── mod.rs
│   │   ├── ast.rs            # Expression AST nodes
│   │   ├── parser.rs         # String → AST parser
│   │   ├── diff.rs           # Symbolic differentiation
│   │   └── eval.rs           # AST evaluator (numeric, given variable bindings)
│   ├── filter/
│   │   ├── mod.rs
│   │   ├── linear.rs         # Standard Kalman filter
│   │   ├── ekf.rs            # Extended Kalman filter
│   │   └── traits.rs         # KalmanFilter trait
│   ├── io/
│   │   ├── mod.rs
│   │   ├── input.rs          # Input message deserialisation
│   │   └── output.rs         # Output message serialisation (live vs backtest)
│   ├── matrix.rs             # Matrix construction from derived Jacobians
│   └── log.rs                # Structured JSON tracing setup
├── configs/
│   ├── trend_no_accel.toml
│   ├── trend_with_accel.toml
│   └── constant_velocity.toml
└── tests/
    ├── test_expr.rs
    ├── test_diff.rs
    ├── test_config.rs
    ├── test_linear_kf.rs
    └── test_ekf.rs

Dependencies (Cargo.toml)

[package]
name        = "kalix"
version     = "0.1.0"
edition     = "2021"
description = "Declarative Kalman filtering from dynamics expressions. Write the physics, derive the filter."
license     = "MIT"

[[bin]]
name = "kalix"
path = "src/main.rs"

[dependencies]
nalgebra = { version = "0.33", features = ["serde-serialize"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] }
anyhow = "1"
thiserror = "1"

[dev-dependencies]
approx = "0.5"

Do not use external expression or CAS crates. Implement the AST, parser, and symbolic differentiator from scratch — the supported operation set is deliberately small.


Expression Language

Expressions are polynomial in state variables and the special token dt. Supported:

Syntax Meaning
pos, vel, x State variable reference
dt Timestep — a runtime parameter, not a state var
3.14, 0.5 Numeric literal
a + b Addition
a - b Subtraction
a * b Multiplication
a / b Division — right-hand side must be a literal
a ^ 2 Integer power (positive integers only)
(expr) Grouping

No trig, no logarithms, no division by state variables. The parser must return a descriptive error for unsupported syntax.

AST Definition

pub enum Expr {
    Lit(f64),
    Var(String),                    // state variable name, or "dt"
    Add(Box<Expr>, Box<Expr>),
    Sub(Box<Expr>, Box<Expr>),
    Mul(Box<Expr>, Box<Expr>),
    Div(Box<Expr>, Box<Expr>),      // right side must be Lit after parsing
    Pow(Box<Expr>, u32),
}

Symbolic Differentiation Rules

Implement diff(expr: &Expr, var: &str) -> Expr:

  • d/dx(lit) = 0
  • d/dx(x) = 1, d/dx(y) = 0 for y != x
  • d/dx(dt) = 0 — dt is a parameter, not a state variable
  • Standard sum, difference, product, and power rules
  • After differentiation, run a simplification pass: 0 + x -> x, x + 0 -> x, 1 * x -> x, x * 1 -> x, 0 * x -> 0, x * 0 -> 0, x ^ 1 -> x, x ^ 0 -> Lit(1)

Linearity Detection

After computing all partial derivatives df_i/dx_j, check whether any resulting expression still contains a Var node whose name is a state variable (not dt). If any partial is nonlinear -> EKF. Otherwise -> linear KF, F built once at startup.


TOML Config Format

[filter]
name        = "trend_with_accel"
description = "Position/velocity/acceleration trend follower"

[state]
variables = ["pos", "vel", "acc"]

[dynamics]
# One entry per state variable — how it evolves each timestep
pos = "pos + vel*dt + 0.5*acc*dt^2"
vel = "vel + acc*dt"
acc = "acc"

[observation]
# Names and expressions for each measurement channel
variables   = ["z"]
expressions = ["pos"]    # z measures pos directly

[noise]
process     = [[0.01, 0, 0], [0, 0.01, 0], [0, 0, 0.01]]   # Q — n x n
measurement = [[1.0]]                                         # R — m x m

[initial]
state      = [0.0, 0.0, 0.0]
covariance = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]

Config Validation (fail loudly at load time)

  • [dynamics] must have exactly one entry per state variable, in the declared order
  • Every variable in a dynamics expression must be in [state].variables or be dt
  • Every observation expression must reference only state variables (not dt)
  • F -> n x n, H -> m x n, Q -> n x n, R -> m x m — dimension mismatches are errors
  • Q and R diagonal entries must all be >= 0, with at least one > 0 each

KalmanFilter Trait

pub trait KalmanFilter {
    /// Advance state by dt. Returns predicted state before incorporating measurement.
    fn predict(&mut self, dt: f64) -> FilterState;

    /// Incorporate observation z. Must be called after predict.
    fn update(&mut self, z: &[f64]) -> UpdateResult;

    /// Convenience: predict + update in one call.
    fn step(&mut self, dt: f64, z: &[f64]) -> StepResult;

    /// Predict only — no measurement available (sensor dropout / missing bar).
    fn predict_only(&mut self, dt: f64) -> FilterState;

    /// Current state vector (post-update).
    fn state(&self) -> &[f64];

    /// Full covariance matrix (post-update).
    fn covariance(&self) -> &nalgebra::DMatrix<f64>;

    /// Jacobian of dynamics evaluated at the given state and dt.
    /// Linear KF returns the fixed F matrix regardless of state.
    /// EKF evaluates symbolically at the given point.
    fn jacobian(&self, x: &[f64], dt: f64) -> nalgebra::DMatrix<f64>;
}

pub struct FilterState {
    pub x: Vec<f64>,
    pub P: nalgebra::DMatrix<f64>,
}

pub struct UpdateResult {
    pub updated:        FilterState,
    pub residual:       Vec<f64>,           // z - H*x_predicted
    pub kalman_gain:    Vec<Vec<f64>>,      // K as row-major nested vec
    pub innovation_cov: Vec<Vec<f64>>,      // S = H*P*H' + R
}

pub struct StepResult {
    pub predicted: FilterState,
    pub update:    UpdateResult,
}

CLI

Invocation

# Live mode — reads from stdin, minimal output
./kalix --config configs/trend_with_accel.toml --mode live

# Backtest mode — reads from stdin, full audit output
./kalix --config configs/trend_with_accel.toml --mode backtest

# Backtest from file (--input implies --mode backtest)
./kalix --config configs/trend_with_accel.toml --input prices.jsonl

# --input combined with --mode live is a validation error — exit 1

Startup Event (stdout, both modes)

Emitted once after successful config load, before reading any input:

{
  "event": "ready",
  "filter": "trend_with_accel",
  "variant": "linear",
  "mode": "backtest",
  "state_variables": ["pos", "vel", "acc"],
  "observation_variables": ["z"],
  "F": [
    [1, 1, 0.5],
    [0, 1, 1],
    [0, 0, 1]
  ],
  "H": [[1, 0, 0]]
}

For EKF: "variant": "ekf" and F is omitted (recomputed per step).


Input Format

One JSON object per line. Both modes share the same input format.

Normal step — observation available:

{ "t": 1746912000.0, "dt": 1.0, "z": [10.3] }

Predict-only step — sensor dropout or missing bar:

{ "t": 1746912001.0, "dt": 1.0, "z": null }

t is informational only — the filter does not infer dt from timestamps. Callers must supply dt explicitly to handle irregular bars (weekends, missing ticks) correctly.

Input Validation

Validate every incoming message before processing. On error, emit a structured JSON line to stderr and apply the --on-error policy (default skip; halt exits with code 1):

./kalix --config trend.toml --mode live --on-error halt
Condition Error message
dt <= 0 "invalid dt: must be positive, got {dt}"
dt is NaN or inf "invalid dt: non-finite value"
wrong z length "z has {n} elements, expected {m}"
unparseable JSON "malformed input: {reason}"

Output Format

Live Mode

One compact JSON line per input line. State named using [state].variables:

{
  "t": 1746912000.0,
  "x": { "pos": 10.1, "vel": 0.31, "acc": 0.02 },
  "p_diag": [0.48, 0.19, 0.01]
}

For predict-only steps, shape is identical with "predict_only": true added:

{
  "t": 1746912001.0,
  "predict_only": true,
  "x": { "pos": 10.41, "vel": 0.31, "acc": 0.02 },
  "p_diag": [0.51, 0.2, 0.011]
}

Backtest Mode

One JSON object per input line. Full covariance matrices and named fields throughout. P is a 2D array; variable order matches [state].variables. Residual is named using [observation].variables.

{
  "t": 1746912000.0,
  "step": 42,
  "predict": {
    "x": { "pos": 10.0, "vel": 0.3, "acc": 0.019 },
    "P": [
      [0.51, 0.01, 0.0],
      [0.01, 0.21, 0.0],
      [0.0, 0.0, 0.011]
    ]
  },
  "update": {
    "x": { "pos": 10.1, "vel": 0.31, "acc": 0.02 },
    "P": [
      [0.48, 0.009, 0.0],
      [0.009, 0.19, 0.0],
      [0.0, 0.0, 0.01]
    ],
    "residual": { "z": 0.2 },
    "kalman_gain": [[0.48], [0.09], [0.01]],
    "innovation_cov": [[1.48]]
  }
}

For predict-only steps in backtest mode, "update" is omitted and "predict_only": true is added at the top level.

Summary Event (backtest mode only)

Emitted to stdout when stdin reaches EOF or the input file is exhausted:

{
  "event": "summary",
  "steps": 1000,
  "predict_only_steps": 12,
  "skipped_steps": 3,
  "final_x": { "pos": 10.1, "vel": 0.3, "acc": 0.019 },
  "final_p_diag": [0.48, 0.19, 0.01]
}

Logging (stderr)

All diagnostic output goes to stderr as structured JSON via tracing + tracing-subscriber with the JSON formatter. stdout is the data channel exclusively.

Control verbosity with RUST_LOG:

RUST_LOG=info — startup summary only:

{"timestamp":"...","level":"INFO","message":"config loaded","filter":"trend_with_accel","variant":"linear"}
{"timestamp":"...","level":"INFO","message":"derived F","F":[[1,1,0.5],[0,1,1],[0,0,1]]}
{"timestamp":"...","level":"INFO","message":"derived H","H":[[1,0,0]]}

RUST_LOG=debug — per-step internals:

{"timestamp":"...","level":"DEBUG","message":"predict","step":42,"x_prior":[10,0.3,0.02],"x_post":[10.3,0.3,0.02]}
{"timestamp":"...","level":"DEBUG","message":"kalman gain","K":[[0.48],[0.09],[0.01]]}
{"timestamp":"...","level":"DEBUG","message":"update","residual":[0.2],"p_diag_post":[0.48,0.19,0.01]}

EKF additionally logs the Jacobian evaluated at the current state each step at DEBUG level.


Example Configs

configs/trend_no_accel.toml

[filter]
name = "trend_no_accel"

[state]
variables = ["pos", "vel"]

[dynamics]
pos = "pos + vel*dt"
vel = "vel"

[observation]
variables   = ["z"]
expressions = ["pos"]

[noise]
process     = [[0.01, 0], [0, 0.01]]
measurement = [[1.0]]

[initial]
state      = [0.0, 0.0]
covariance = [[1, 0], [0, 1]]

configs/trend_with_accel.toml

[filter]
name = "trend_with_accel"

[state]
variables = ["pos", "vel", "acc"]

[dynamics]
pos = "pos + vel*dt + 0.5*acc*dt^2"
vel = "vel + acc*dt"
acc = "acc"

[observation]
variables   = ["z"]
expressions = ["pos"]

[noise]
process     = [[0.01,0,0],[0,0.01,0],[0,0,0.01]]
measurement = [[1.0]]

[initial]
state      = [0.0, 0.0, 0.0]
covariance = [[1,0,0],[0,1,0],[0,0,1]]

configs/constant_velocity.toml

[filter]
name = "constant_velocity"

[state]
variables = ["pos", "vel"]

[dynamics]
pos = "pos + vel*dt"
vel = "vel"

[observation]
variables   = ["z"]
expressions = ["pos"]

[noise]
process     = [[0.1, 0], [0, 0.1]]
measurement = [[5.0]]

[initial]
state      = [0.0, 0.0]
covariance = [[10, 0], [0, 10]]

Language Integration

The CLI communicates exclusively via stdin/stdout JSON lines — no language-specific bindings required. Any language that can spawn a child process works identically.

Python

import subprocess, json

proc = subprocess.Popen(
    ["./target/release/kalix", "--config", "configs/trend_with_accel.toml", "--mode", "live"],
    stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)

# Read the ready event
ready = json.loads(proc.stdout.readline())
print(f"Filter ready: {ready['filter']} ({ready['variant']})")

# Normal observation
proc.stdin.write(json.dumps({"t": 1000.0, "dt": 1.0, "z": [10.3]}) + "\n")
proc.stdin.flush()
result = json.loads(proc.stdout.readline())
print(f"pos={result['x']['pos']:.3f}, vel={result['x']['vel']:.3f}")

# Predict-only (sensor dropout)
proc.stdin.write(json.dumps({"t": 1001.0, "dt": 1.0, "z": None}) + "\n")
proc.stdin.flush()
result = json.loads(proc.stdout.readline())
print(f"predict-only: pos={result['x']['pos']:.3f}")

proc.stdin.close()
proc.wait()

Python — backtest from file

import subprocess, json

proc = subprocess.Popen(
    ["./target/release/kalix", "--config", "configs/trend_with_accel.toml", "--input", "prices.jsonl"],
    stdout=subprocess.PIPE, text=True
)

results = []
for line in proc.stdout:
    msg = json.loads(line)
    if msg.get("event") == "ready":
        continue
    if msg.get("event") == "summary":
        print(f"Done: {msg['steps']} steps, {msg['predict_only_steps']} predict-only")
        break
    results.append(msg)  # full per-step audit record

proc.wait()

TypeScript

import { spawn } from "child_process";
import * as readline from "readline";

const proc = spawn("./target/release/kalix", [
  "--config",
  "configs/trend_with_accel.toml",
  "--mode",
  "live",
]);

const rl = readline.createInterface({ input: proc.stdout });

rl.on("line", (line) => {
  const msg = JSON.parse(line);
  if (msg.event === "ready") {
    console.log("Filter ready:", msg.filter, msg.variant);
    return;
  }
  console.log("pos:", msg.x.pos, "vel:", msg.x.vel);
});

// Normal observation
proc.stdin.write(
  JSON.stringify({ t: Date.now() / 1000, dt: 1.0, z: [10.3] }) + "\n",
);

// Predict-only (sensor dropout)
proc.stdin.write(
  JSON.stringify({ t: Date.now() / 1000 + 1, dt: 1.0, z: null }) + "\n",
);

Tests

Two clearly separated test concerns:

Section 1 — Design: verify TOML -> matrix derivation. Uses exact equality — safe because all F/H entries are integers or IEEE-754-exact fractions (0.5, 0.125).

Section 2 — Filter numerics: verify running filter output. Uses approx::assert_abs_diff_eq! throughout. Never use exact float equality on filter state. Shape assertions (lengths, matrix dimensions) may use assert_eq!.


Section 1 — Design: Matrix Derivation

tests/test_expr.rs — Parser

// Literal
parse("3.14") -> Expr::Lit(3.14)

// Variable
parse("vel") -> Expr::Var("vel")

// Addition
parse("pos + vel") -> Add(Var("pos"), Var("vel"))

// Multiplication
parse("vel * dt") -> Mul(Var("vel"), Var("dt"))

// Integer power
parse("dt^2") -> Pow(Var("dt"), 2)

// Complex expression — must parse without error
parse("pos + vel*dt + 0.5*acc*dt^2") -> Ok(...)

// Unsupported — must return Err with descriptive message
parse("sin(pos)")  -> Err(...)    // no trig
parse("pos / vel") -> Err(...)    // division by variable disallowed

tests/test_diff.rs — Symbolic Differentiation

Differentiate symbolically then evaluate at concrete bindings. All values below are IEEE-754 exact — assert with epsilon = 1e-15.

let expr = "pos + vel*dt + 0.5*acc*dt^2";

// d/d(pos) = 1.0 — independent of bindings
eval(diff(expr, "pos"), {pos:0, vel:0, acc:0, dt:1.0}) == 1.0
eval(diff(expr, "pos"), {pos:5, vel:3, acc:1, dt:0.5}) == 1.0

// d/d(vel) = dt
eval(diff(expr, "vel"), {dt: 1.0}) == 1.0
eval(diff(expr, "vel"), {dt: 0.5}) == 0.5

// d/d(acc) = 0.5 * dt^2
eval(diff(expr, "acc"), {dt: 1.0}) == 0.5    // 0.5 * 1.0 — exact
eval(diff(expr, "acc"), {dt: 0.5}) == 0.125  // 0.5 * 0.25 — exact

// Simplification: d/d(pos) of "pos" must reduce to Lit(1), not a compound expr
matches!(diff("pos", "pos"), Expr::Lit(v) if v == 1.0)

// d/d(vel) and d/d(acc) of "vel + acc*dt"
eval(diff("vel + acc*dt", "vel"), {dt: 1.0}) == 1.0
eval(diff("vel + acc*dt", "acc"), {dt: 1.0}) == 1.0
eval(diff("vel + acc*dt", "acc"), {dt: 0.5}) == 0.5

tests/test_config.rs — Config Validation and Matrix Derivation

Exact equality on F and H is correct — all entries are 0, 1, or IEEE-754-exact fractions.

// ── trend_no_accel at dt=1.0 ──────────────────────────────────────────
// d(pos + vel*dt)/d(pos) = 1,  d(...)/d(vel) = dt = 1
// d(vel)/d(pos)          = 0,  d(vel)/d(vel) = 1
let F = derive_F(&config_no_accel, dt=1.0);
assert_eq!(F, [[1.0, 1.0],
               [0.0, 1.0]]);

let H = derive_H(&config_no_accel);
assert_eq!(H, [[1.0, 0.0]]);

assert_eq!(detect_variant(&config_no_accel), Variant::Linear);

// ── trend_with_accel at dt=1.0 ────────────────────────────────────────
// d(pos+vel*dt+0.5*acc*dt^2)/d(pos,vel,acc) = [1, 1,   0.5]
// d(vel+acc*dt)/d(pos,vel,acc)               = [0, 1,   1.0]
// d(acc)/d(pos,vel,acc)                      = [0, 0,   1.0]
let F = derive_F(&config_with_accel, dt=1.0);
assert_eq!(F, [[1.0, 1.0, 0.5],
               [0.0, 1.0, 1.0],
               [0.0, 0.0, 1.0]]);

// Same config at dt=0.5 — 0.5 and 0.125 are IEEE-754 exact
let F = derive_F(&config_with_accel, dt=0.5);
assert_eq!(F, [[1.0, 0.5, 0.125],
               [0.0, 1.0, 0.5  ],
               [0.0, 0.0, 1.0  ]]);

let H = derive_H(&config_with_accel);
assert_eq!(H, [[1.0, 0.0, 0.0]]);

assert_eq!(detect_variant(&config_with_accel), Variant::Linear);

// ── EKF detection ─────────────────────────────────────────────────────
// vel_next = vel - 0.01*vel^2*dt
// d/d(vel) = 1 - 0.02*vel*dt  <- contains Var("vel") -> nonlinear
assert_eq!(detect_variant(&config_drag), Variant::Ekf);

// ── Validation errors ─────────────────────────────────────────────────
config_with_bad_dynamics_key()       -> Err(msg contains "unknown state variable")
config_with_unknown_var_in_expr()    -> Err(msg contains "unknown variable")
config_with_wrong_Q_size()           -> Err(msg contains "Q must be 3x3")
config_with_input_and_live_mode()    -> Err(msg contains "--input requires backtest mode")

Section 2 — Filter Numerics

All assertions use approx::assert_abs_diff_eq!(actual, expected, epsilon = 1e-3) unless stated otherwise. Shape assertions use assert_eq!. Never use exact equality on filter state.

All expected values were computed analytically and verified with a Python reference implementation before inclusion in this prompt.

tests/test_linear_kf.rs

Config: trend_no_accel, x=[0,0], P=I, Q=0.01*I, R=[[1.0]], dt=1.0.

// ── Step 1: z = [10.0] ───────────────────────────────────────────────
let result = filter.step(dt=1.0, z=[10.0]);

// Predicted (F*[0,0] = [0,0])
assert_abs_diff_eq!(result.predicted.x[0], 0.0, epsilon=1e-3);
assert_abs_diff_eq!(result.predicted.x[1], 0.0, epsilon=1e-3);

// Residual = z - H*x_predicted = 10.0 - 0.0
assert_abs_diff_eq!(result.update.residual[0], 10.0, epsilon=1e-3);

// Kalman gain shape: n x m = 2 x 1
assert_eq!(result.update.kalman_gain.len(),    2);
assert_eq!(result.update.kalman_gain[0].len(), 1);

// Kalman gain values: K ~= [0.6678, 0.3322]
// Gain is split across both state variables because vel feeds into pos prediction
assert_abs_diff_eq!(result.update.kalman_gain[0][0], 0.6678, epsilon=1e-3);
assert_abs_diff_eq!(result.update.kalman_gain[1][0], 0.3322, epsilon=1e-3);

// Updated state
assert_abs_diff_eq!(result.update.updated.x[0], 6.6777, epsilon=1e-3);
assert_abs_diff_eq!(result.update.updated.x[1], 3.3223, epsilon=1e-3);

// ── Step 2: z = [11.0] ───────────────────────────────────────────────
let result = filter.step(dt=1.0, z=[11.0]);

// Predicted: pos = 6.6777 + 3.3223*1.0 = 10.0, vel = 3.3223
assert_abs_diff_eq!(result.predicted.x[0], 10.0,   epsilon=1e-3);
assert_abs_diff_eq!(result.predicted.x[1],  3.3223, epsilon=1e-3);

// Residual = 11.0 - 10.0 = 1.0
assert_abs_diff_eq!(result.update.residual[0], 1.0, epsilon=1e-3);

// Updated
assert_abs_diff_eq!(result.update.updated.x[0], 10.6689, epsilon=1e-3);
assert_abs_diff_eq!(result.update.updated.x[1],  3.6567, epsilon=1e-3);

// ── Predict-only step ─────────────────────────────────────────────────
// After step 2, call predict_only — state advances but P must grow (no measurement)
let p_post_diag = result.update.updated.P.diagonal().clone();
let predicted = filter.predict_only(dt=1.0);
// pos_predicted ~= 10.6689 + 3.6567 = 14.3256
assert_abs_diff_eq!(predicted.x[0], 14.3256, epsilon=1e-2);
// Covariance diagonal must be >= post-update diagonal — uncertainty grows without data
for i in 0..2 {
    assert!(predicted.P[(i,i)] >= p_post_diag[i] - 1e-9);
}

// ── Convergence: 100 steps, z=10.0, fresh start ───────────────────────
// epsilon=0.01 — we care about convergence, not a precise settled value
assert_abs_diff_eq!(x[0], 10.0, epsilon=0.01);
assert_abs_diff_eq!(x[1],  0.0, epsilon=0.01);

// ── Named output — backtest serialisation ────────────────────────────
// Confirm JSON output uses state variable names, not integer indices
let json = serde_json::to_value(&backtest_step_output).unwrap();
assert!(json["update"]["x"]["pos"].is_number());
assert!(json["update"]["x"]["vel"].is_number());
assert!(json["update"]["residual"]["z"].is_number());

tests/test_ekf.rs

Define the config as an inline &str constant inside the test file. Parse it with the normal config loader. Do not load from disk.

[filter]
name = "drag"

[state]
variables = ["pos", "vel"]

[dynamics]
pos = "pos + vel*dt"
vel = "vel - 0.01*vel^2*dt"    # drag — nonlinear in vel

[observation]
variables   = ["z"]
expressions = ["pos"]

[noise]
process     = [[0.01, 0], [0, 0.01]]
measurement = [[1.0]]

[initial]
state      = [0.0, 1.0]
covariance = [[1, 0], [0, 1]]
// ── Variant detection ─────────────────────────────────────────────────
assert_eq!(detect_variant(&config), Variant::Ekf);

// ── Jacobian at state [10.0, 5.0], dt=1.0 ────────────────────────────
// pos_next = pos + vel*dt  ->  d/dpos=1, d/dvel=dt=1
// vel_next = vel - 0.01*vel^2*dt  ->  d/dpos=0, d/dvel = 1 - 0.02*vel*dt
// At vel=5.0, dt=1.0:  F[1][1] = 1 - 0.02*5.0*1.0 = 0.9  (IEEE-754 exact)
// Use tight epsilon here — these are exact symbolic evaluations, not accumulated ops
let jac = ekf.jacobian(&[10.0, 5.0], 1.0);
assert_abs_diff_eq!(jac[(0,0)], 1.0, epsilon=1e-10);
assert_abs_diff_eq!(jac[(0,1)], 1.0, epsilon=1e-10);
assert_abs_diff_eq!(jac[(1,0)], 0.0, epsilon=1e-10);
assert_abs_diff_eq!(jac[(1,1)], 0.9, epsilon=1e-10);

// ── Single step — must not panic, all outputs must be finite ──────────
let result = ekf.step(1.0, &[1.0]);
assert!(result.update.residual[0].is_finite());
assert!(result.update.updated.x.iter().all(|v| v.is_finite()));
assert!(result.update.kalman_gain.iter().flatten().all(|v| v.is_finite()));

// ── Stability: 50 steps, state must remain bounded ────────────────────
for _ in 0..50 {
    ekf.step(1.0, &[5.0]);
}
assert!(ekf.state().iter().all(|v| v.is_finite()));
assert!(ekf.state()[0].abs() < 1000.0);
assert!(ekf.state()[1].abs() < 1000.0);

// ── Predict-only in EKF — Jacobian must be re-evaluated at current state
let predicted = ekf.predict_only(1.0);
assert!(predicted.x.iter().all(|v| v.is_finite()));
assert!(predicted.P.iter().all(|v| v.is_finite()));

Quality Bar

  • cargo test — zero failures
  • cargo clippy -- -D warnings — zero warnings
  • All public types and trait methods must have doc comments
  • anyhow for application-layer errors (main, CLI), thiserror for library errors (config, expr, filter) — do not mix
  • No unwrap() or expect() in library code — propagate errors explicitly
  • The expression evaluator and symbolic differentiator must be purely functional — no mutation of AST nodes, no interior mutability
  • src/io/ owns all serialisation logic — filter structs must not contain serde derives; the IO layer maps them to output shapes for each mode