Skip to main content

dsfb_robotics/
lib.rs

1//! # dsfb-robotics — DSFB Structural Semiotics Engine for Robotics Health Monitoring
2//!
3//! **What this crate is, in one paragraph.** A deterministic, `no_std`,
4//! `no_alloc`, zero-`unsafe` *observer* that reads residual streams — joint
5//! torque identification residuals, inverse-dynamics residuals, whole-body
6//! MPC force residuals, centroidal-momentum observer residuals, bearing
7//! envelope-spectrum residuals, health-index trajectories — which existing
8//! robot control and prognostics pipelines already compute, and structures
9//! them into a typed grammar of human-readable episodes. DSFB does **not**
10//! replace inverse-dynamics identification, Kalman / Luenberger observers,
11//! whole-body controllers, MPC, rainflow RUL estimators, or vibration-based
12//! FDD classifiers — it **augments** them by giving operators a structural
13//! view of what those systems discard. Removing DSFB leaves the upstream
14//! control and prognostics stack unchanged.
15//!
16//! ---
17//!
18//! **Invariant Forge LLC** — Prior art under 35 U.S.C. § 102.
19//! Commercial deployment requires a separate written license.
20//! Reference implementation: Apache-2.0.
21//! <licensing@invariantforge.net>
22//!
23//! ## Positioning — Augmentation, not competition
24//!
25//! DSFB **does not compete** with existing robotics sensing, kinematic
26//! identification, whole-body balance control, or PHM methods. Existing
27//! methods will continue to outperform DSFB at their own tasks — earlier
28//! fault detection, lower false-alarm rates, better RUL accuracy, tighter
29//! tracking control. DSFB's role is orthogonal: it reads the **residuals
30//! those methods already produce and usually discard**, and structures
31//! them into a human-readable grammar (Admissible / Boundary / Violation)
32//! with typed episodes and provenance-tagged audit trails.
33//!
34//! This makes existing methods **more important**, not less — DSFB is
35//! literally dependent on a functioning upstream observer chain to have
36//! anything to interpret.
37//!
38//! ## Architectural Contract
39//!
40//! - **Observer-only.** Public API accepts `&[f64]` (immutable reference
41//!   only). There is no mutable write path into any upstream data
42//!   structure. Enforced by type signature.
43//! - **`#![no_std]`.** Core modules link against neither the Rust standard
44//!   library nor any OS runtime. Deployable on bare-metal MCUs (Cortex-M4F,
45//!   RISC-V 32-bit) alongside a safety-gate companion to an industrial
46//!   robot controller.
47//! - **`no_alloc` in core.** All internal structures use fixed-capacity
48//!   array-backed types. The canonical [`observe`] signature takes a
49//!   caller-supplied `&mut [Episode]` output buffer. No heap allocation in
50//!   any hot path of the default build.
51//! - **Zero `unsafe`.** No `unsafe` blocks, no `UnsafeCell`, no `RefCell`
52//!   in any observer code path. Enforced at compile time by
53//!   `#![forbid(unsafe_code)]` below.
54//!
55//! ## Non-Claims (from companion paper §11)
56//!
57//! This crate does **not** provide:
58//! - Fault classification (bearing fault type, root-cause identification)
59//! - Calibrated Pd/Pfa or F1/ROC-AUC guarantees
60//! - Earlier detection than incumbent threshold alarms, RMS monitors, or
61//!   CUSUM/EWMA change-point detectors
62//! - Hard real-time latency bounds under specific controller platforms
63//! - RUL (remaining useful life) prediction
64//! - ISO 10218-1/-2:2025 or IEC 61508 certification
65//! - A replacement for any upstream observer, estimator, or controller
66//!
67//! ## Feature Flags
68//!
69//! | Feature | Description |
70//! |---------|-------------|
71//! | *(none)* | Core engine: `no_std` + `no_alloc` + zero unsafe |
72//! | `alloc` | Opt-in heap via `alloc` crate for host-side convenience wrappers |
73//! | `std` | Opt-in std library for pipeline and output modules |
74//! | `serde` | JSON artefact serialization (requires `std`) |
75//! | `paper_lock` | Headline-metric enforcement for deterministic reproducibility |
76//! | `real_figures` | Real-dataset figure bank for the companion paper (requires `std`) |
77//! | `experimental` | Exploratory extensions not validated in the companion paper |
78//!
79//! ## Minimal usage (bare-metal, `no_std` + `no_alloc`)
80//!
81//! ```
82//! use dsfb_robotics::{Episode, observe};
83//! let residuals: &[f64] = &[0.01, 0.02, 0.05, 0.12, 0.21];
84//! let mut out = [Episode::empty(); 16];
85//! let n = observe(residuals, &mut out);
86//! for e in &out[..n] {
87//!     // advisory only — no write-back, no upstream coupling
88//!     let _ = (e.index, e.grammar, e.decision);
89//! }
90//! ```
91//!
92//! ## Streaming engine usage (per-observation API)
93//!
94//! ```
95//! use dsfb_robotics::engine::DsfbRoboticsEngine;
96//! use dsfb_robotics::platform::RobotContext;
97//!
98//! // W=8 drift window, K=4 persistence threshold
99//! let mut eng = DsfbRoboticsEngine::<8, 4>::new(0.1);
100//!
101//! let residual_norm: f64 = 0.045; // ‖r(k)‖ from your upstream observer
102//! let ep = eng.observe_one(residual_norm, false, RobotContext::ArmOperating, 0);
103//! let _ = (ep.grammar, ep.decision);
104//! // upstream robot controller: UNCHANGED
105//! ```
106
107#![no_std]
108#![forbid(unsafe_code)]
109#![deny(missing_docs)]
110#![deny(clippy::all)]
111#![cfg_attr(docsrs, feature(doc_cfg))]
112
113// ---------------------------------------------------------------
114// Conditional std/alloc imports — core does not require either.
115// ---------------------------------------------------------------
116#[cfg(feature = "alloc")]
117extern crate alloc;
118
119#[cfg(feature = "std")]
120extern crate std;
121
122// ---------------------------------------------------------------
123// Core modules — unconditionally no_std + no_alloc + zero unsafe
124// ---------------------------------------------------------------
125
126/// `libm`-free f64 helpers for `no_std` + `no_alloc` core.
127pub mod math;
128
129/// Robot operating context: commissioning, operating, stance, swing, maintenance.
130pub mod platform;
131
132/// Residual sign tuple σ(k) = (‖r‖, ṙ, r̈).
133pub mod sign;
134
135/// Admissibility envelope `E(k) = {r : ‖r‖ ≤ ρ(k)}`.
136pub mod envelope;
137
138/// Grammar FSM: `Admissible | Boundary[ReasonCode] | Violation`.
139pub mod grammar;
140
141/// Canonical [`Episode`] struct emitted by the observer.
142pub mod episode;
143
144/// Advisory policy layer: grammar → decision.
145pub mod policy;
146
147/// Heuristics bank: typed robotics motifs.
148pub mod heuristics;
149
150/// Syntax layer: classify sign tuples into named motifs (see
151/// [`heuristics::RoboticsMotif`] for the typed motif catalogue).
152pub mod syntax;
153
154/// Shared residual helper for kinematic-identification datasets.
155pub mod kinematics;
156
157/// Shared residual helper for balancing datasets.
158pub mod balancing;
159
160/// Healthy-window envelope calibration.
161pub mod calibration;
162
163/// Wide-sense-stationarity check for calibration windows.
164pub mod stationarity;
165
166/// Uncertainty budget per GUM JCGM 100:2008.
167pub mod uncertainty;
168
169/// Streaming DSFB engine orchestrator. See
170/// [`engine::DsfbRoboticsEngine`] and [`grammar::GrammarEvaluator`]
171/// for the canonical per-sample pipeline.
172pub mod engine;
173
174/// Per-dataset residual adapters across PHM (CWRU, IMS, FEMTO-ST),
175/// kinematics (KUKA LWR-IV+, Franka Panda Gaz, DLR-class
176/// Giacomuzzo, UR10 Polydoros), and balancing (MIT Mini-Cheetah,
177/// iCub push-recovery, ANYmal, Unitree G1, ergoCub Sorrentino,
178/// plus the LeRobot ALOHA / Mobile-ALOHA / SO-100 / DROID / OpenX
179/// teleoperation slates). See [`datasets::DatasetId`] for the
180/// canonical slug enumeration.
181pub mod datasets;
182
183/// Paper-lock driver: per-dataset DSFB evaluation, deterministic
184/// JSON emission, bit-exact reproducibility gate. Feature-gated on
185/// `paper_lock` (which pulls in `std` + `serde` + `serde_json`).
186#[cfg(feature = "paper_lock")]
187pub mod paper_lock;
188
189// Kani formal-verification harnesses — compiled only when the crate is
190// built with `#[cfg(kani)]` (which Kani itself sets). Invisible in
191// stock `cargo build` output. See `src/kani_proofs.rs` for the
192// harness inventory.
193#[cfg(kani)]
194mod kani_proofs;
195
196// ---------------------------------------------------------------
197// Public flat re-exports — the most-used types at crate root so that
198// `use dsfb_robotics::{Episode, observe, GrammarState, DsfbRoboticsEngine};`
199// is idiomatic.
200// ---------------------------------------------------------------
201
202pub use crate::engine::DsfbRoboticsEngine;
203pub use crate::envelope::AdmissibilityEnvelope;
204pub use crate::episode::Episode;
205pub use crate::grammar::{GrammarState, ReasonCode};
206pub use crate::platform::RobotContext;
207pub use crate::policy::PolicyDecision;
208pub use crate::sign::{SignTuple, SignWindow};
209
210// ---------------------------------------------------------------
211// Top-level convenience observe()
212// ---------------------------------------------------------------
213
214/// Read-only one-shot DSFB observation of a residual slice.
215///
216/// Constructs a default-parameter engine (`W = 8`, `K = 4`, envelope
217/// radius ρ calibrated from the **first 20 %** of the input under the
218/// paper's Stage III protocol) and streams `residuals` into `out`.
219///
220/// Returns the number of episodes written. Never writes past
221/// `out.len()`. Callers that need a custom drift window, persistence
222/// threshold, or a pre-computed envelope should use
223/// [`DsfbRoboticsEngine`] directly.
224///
225/// This is the advertised `no_alloc` entry point:
226/// `observe(&[f64], &mut [Episode]) -> usize`.
227///
228/// # Determinism
229///
230/// Pure function; identical ordered inputs produce identical outputs.
231/// No global state, no allocation, no side effects, no panic paths.
232///
233/// # Non-finite input samples
234///
235/// Treated as below-floor (missingness-aware): they always produce
236/// `grammar = "Admissible"`, `decision = "Silent"` and are not
237/// counted toward drift or envelope statistics.
238///
239/// # Edge cases
240///
241/// - Empty input or empty output buffer → `0`.
242/// - Calibration window (first 20 %) contains no finite samples →
243///   all episodes `Admissible` / `Silent` (the engine runs with a
244///   zero-radius envelope, which is then suppressed by the
245///   non-finite-input fall-through).
246pub fn observe(residuals: &[f64], out: &mut [Episode]) -> usize {
247    debug_assert!(residuals.len() <= usize::MAX / 2, "residuals slice unreasonably large");
248    debug_assert!(out.len() <= usize::MAX / 2, "output buffer unreasonably large");
249
250    if residuals.is_empty() || out.is_empty() {
251        return 0;
252    }
253
254    // Stage III calibration: use the first 20 % of the input as the
255    // healthy window (bounded below at 1, above at residuals.len()).
256    let cal_len = (residuals.len() / 5).max(1).min(residuals.len());
257    let cal_slice = &residuals[..cal_len];
258
259    // Compute finite-valued norms for calibration.
260    let mut cal_buf = [0.0_f64; 64];
261    let mut cal_n = 0_usize;
262    let mut i = 0_usize;
263    while i < cal_slice.len() && cal_n < cal_buf.len() {
264        let x = cal_slice[i];
265        if x.is_finite() {
266            cal_buf[cal_n] = crate::math::abs_f64(x);
267            cal_n += 1;
268        }
269        i += 1;
270    }
271    let envelope = if cal_n == 0 {
272        // Fall back to a permissive envelope; the non-finite fall-through
273        // in the engine will still produce all Admissible episodes.
274        AdmissibilityEnvelope::new(f64::INFINITY)
275    } else {
276        AdmissibilityEnvelope::calibrate_from_window(&cal_buf[..cal_n])
277            .unwrap_or_else(|| AdmissibilityEnvelope::new(f64::INFINITY))
278    };
279
280    let mut eng = DsfbRoboticsEngine::<8, 4>::from_envelope(envelope);
281    eng.observe(residuals, out, RobotContext::ArmOperating)
282}
283
284// ---------------------------------------------------------------
285// Top-level smoke tests — crate-level invariants
286// ---------------------------------------------------------------
287#[cfg(test)]
288mod smoke_tests {
289    use super::*;
290
291    #[test]
292    fn empty_input_returns_zero_episodes() {
293        let mut out = [Episode::empty(); 4];
294        assert_eq!(observe(&[], &mut out), 0);
295    }
296
297    #[test]
298    fn empty_output_buffer_returns_zero() {
299        let mut out: [Episode; 0] = [];
300        assert_eq!(observe(&[0.1, 0.2, 0.3], &mut out), 0);
301    }
302
303    #[test]
304    fn calibration_then_drop_is_admissible() {
305        // Calibration window (first 20 %) has residual magnitude around
306        // 0.01 → envelope radius ρ ≈ 0.01. Subsequent samples at 0.001
307        // are well below the boundary-approach band (0.5·ρ = 0.005)
308        // and must stay Admissible / Silent.
309        let mut residuals = [0.001_f64; 32];
310        for v in residuals.iter_mut().take(6) {
311            *v = 0.01;
312        }
313        let mut out = [Episode::empty(); 32];
314        let n = observe(&residuals, &mut out);
315        assert_eq!(n, 32);
316        // After the calibration samples settle, steady-state residuals
317        // below the boundary band must be Admissible / Silent.
318        let tail_admissible = out[10..n].iter().all(|e| e.grammar == "Admissible");
319        assert!(tail_admissible, "tail episodes must be Admissible once residuals drop below boundary band");
320    }
321
322    #[test]
323    fn observe_respects_output_capacity() {
324        let residuals = [0.02_f64; 32];
325        let mut small = [Episode::empty(); 4];
326        let n = observe(&residuals, &mut small);
327        assert_eq!(n, 4);
328    }
329
330    #[test]
331    fn episode_index_matches_input_position() {
332        let residuals = [0.02_f64; 16];
333        let mut out = [Episode::empty(); 16];
334        let n = observe(&residuals, &mut out);
335        for (i, e) in out[..n].iter().enumerate() {
336            assert_eq!(e.index, i);
337        }
338    }
339
340    #[test]
341    fn stepwise_jump_eventually_escalates() {
342        // Calibration window (first 20 %, i.e. 0.01 constants) → rho ≈ 0.01.
343        // Followed by 0.5 residuals → escalated.
344        let mut residuals = [0.01_f64; 32];
345        for v in &mut residuals[6..] {
346            *v = 0.5;
347        }
348        let mut out = [Episode::empty(); 32];
349        let n = observe(&residuals, &mut out);
350        assert_eq!(n, 32);
351        let escalated = out[..n].iter().filter(|e| e.decision == "Escalate").count();
352        assert!(escalated >= 20, "expected many Escalate episodes, got {}", escalated);
353    }
354
355    #[test]
356    fn non_finite_inputs_stay_admissible() {
357        let residuals = [f64::NAN; 16];
358        let mut out = [Episode::empty(); 16];
359        let n = observe(&residuals, &mut out);
360        assert_eq!(n, 16);
361        for e in &out[..n] {
362            assert_eq!(e.grammar, "Admissible");
363        }
364    }
365}