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}