feat(sensing-server): introspection module skeleton (ADR-099 D1+D7+D8)

Adds the per-frame introspection state that ADR-099 specifies, plus the two
midstream dependencies. Pure addition — no other code touched.

  v2/crates/wifi-densepose-sensing-server/Cargo.toml
    + midstreamer-temporal-compare = "0.2"
    + midstreamer-attractor        = "0.2"

  v2/crates/wifi-densepose-sensing-server/src/introspection.rs (new, 530 lines)
    pub struct IntrospectionState
      ├─ midstreamer-attractor's AttractorAnalyzer (regime + Lyapunov)
      ├─ SignatureLibrary (JSON-loaded labelled segments)
      ├─ VecDeque<f64> sliding amplitude buffer (default 128 points)
      └─ update(timestamp_ns, derived_feature) — never window-blocked
         + snapshot() -> IntrospectionSnapshot
            { timestamp_ns, frame_count, regime, lyapunov_exponent,
              attractor_dim, attractor_confidence, top_k_similarity }
    pub enum Regime { Idle, Periodic, Transient, Chaotic, Unknown }
    pub struct Signature { id, label, vectors, dtw, promotion_threshold }
    pub struct SimilarityMatch { signature_id, score, above_threshold }

DTW path is currently a host-side stand-in (length-normalised L1 with the
real DTW call deferred to I3/I5 once vec128 embeddings exist — ADR-099 P1).
The attractor path is wired to midstream directly. The analyze() step only
runs every N frames (default 8) to stay under the per-frame ms budget.

8 unit tests (snapshot defaults, frame-count + timestamp advance, empty
library, scoring + ordering invariants, threshold gating, empty-signature
fault-tolerance, regime classification after 200 frames). 199 → 207 lib tests,
0 failures. cargo build clean (only pre-existing warnings).

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-05-13 22:50:58 -04:00
parent 900b877c64
commit 94ef125240
4 changed files with 599 additions and 3 deletions

33
v2/Cargo.lock generated
View File

@ -3412,7 +3412,20 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab86df06cf1705ca37692b4fc0027868f92e5170a7ebb1d706302f04b6044f70"
dependencies = [
"midstreamer-temporal-compare",
"midstreamer-temporal-compare 0.1.0",
"nalgebra",
"ndarray 0.16.1",
"serde",
"thiserror 2.0.18",
]
[[package]]
name = "midstreamer-attractor"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bebe548a4e74b80ecb8dd058e352a91fed9e5685c49c5d3fa5062520c660c6c9"
dependencies = [
"midstreamer-temporal-compare 0.2.1",
"nalgebra",
"ndarray 0.16.1",
"serde",
@ -3463,6 +3476,18 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "midstreamer-temporal-compare"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b87063b1eb79672a76f88377799152d8e149328e9a19455345851a264bdced20"
dependencies = [
"dashmap",
"lru",
"serde",
"thiserror 2.0.18",
]
[[package]]
name = "mime"
version = "0.3.17"
@ -8520,6 +8545,8 @@ dependencies = [
"chrono",
"clap",
"futures-util",
"midstreamer-attractor 0.2.1",
"midstreamer-temporal-compare 0.2.1",
"ruvector-mincut",
"serde",
"serde_json",
@ -8539,8 +8566,8 @@ version = "0.3.0"
dependencies = [
"chrono",
"criterion",
"midstreamer-attractor",
"midstreamer-temporal-compare",
"midstreamer-attractor 0.1.0",
"midstreamer-temporal-compare 0.1.0",
"ndarray 0.15.6",
"ndarray-linalg",
"num-complex",

View File

@ -50,6 +50,12 @@ wifi-densepose-wifiscan = { version = "0.3.0", path = "../wifi-densepose-wifisca
# build without vcpkg/openblas (issue #366, #415).
wifi-densepose-signal = { version = "0.3.0", path = "../wifi-densepose-signal", default-features = false }
# midstream — real-time introspection / low-latency tap (ADR-099 D1).
# Two crates only, on purpose: scheduler / neural-solver / strange-loop are
# explicitly out of scope of ADR-099 (D5).
midstreamer-temporal-compare = "0.2" # DTW / LCS / Edit-Distance pattern matching
midstreamer-attractor = "0.2" # Lyapunov + regime classification
[dev-dependencies]
tempfile = "3.10"
# `tower::ServiceExt::oneshot` for in-process Router tests (bearer_auth).

View File

@ -0,0 +1,561 @@
//! Real-time CSI introspection tap (ADR-099).
//!
//! Per-frame state alongside the window-aggregated event pipeline. Two
//! midstream primitives feed it:
//!
//! * `midstreamer-attractor` — Lyapunov exponent + attractor regime (point /
//! limit cycle / strange / unknown) over a sliding window of derived
//! amplitude scalars. Replaces the heuristic "is the room calm or moving"
//! threshold-on-EWMA with a physics-shaped continuous metric.
//! * `midstreamer-temporal-compare` — DTW-style similarity matching of recent
//! CSI feature history against a labelled signature library
//! (`SignatureLibrary`). The top-k matches go into [`IntrospectionSnapshot`].
//!
//! The whole module is **never window-blocked**: every accepted [`CsiFrame`]
//! triggers an `update_per_frame` call; the snapshot is fresh on every frame.
//! That's the latency-win contract from ADR-099 D4 — the soonest a
//! "shape recognised" signal can emit is **one frame** (≈33 ms at 30 Hz CSI),
//! not one window (≈533 ms at 16-frame / 30 Hz).
//!
//! See [`docs/adr/ADR-099-midstream-introspection-tap.md`] for the architectural
//! contract, the eight decisions, and the phased adoption plan.
//!
//! [`docs/adr/ADR-099-midstream-introspection-tap.md`]: https://github.com/ruvnet/RuView/blob/main/docs/adr/ADR-099-midstream-introspection-tap.md
use std::collections::VecDeque;
use serde::{Deserialize, Serialize};
use midstreamer_attractor::{
AttractorAnalyzer, AttractorError, AttractorType, PhasePoint,
};
/// Default sliding window of derived amplitude scalars fed to the attractor
/// analyzer. Sized so that at 30 Hz CSI the analyzer always has ≥3 s of history,
/// which covers the ~100-point minimum the analyzer needs for a meaningful
/// Lyapunov estimate.
pub const DEFAULT_TRAJECTORY_LEN: usize = 128;
/// Default embedding dimension for the attractor's phase space. We feed it
/// one-dimensional points (the per-frame mean amplitude scalar); higher
/// dimensions become useful once we have real `vec128` embeddings (ADR-208 P2).
pub const DEFAULT_EMBEDDING_DIM: usize = 1;
/// Default similarity-library DTW window (Sakoe-Chiba band) and how many top
/// matches the snapshot carries.
pub const DEFAULT_TOP_K: usize = 5;
/// Frames since the last `analyze()` call. We don't analyse on every frame —
/// the attractor's Lyapunov estimate is ~9 ms for a 1 k-point window per
/// midstream's bench, which is fine at 30 Hz but wastes CPU at higher rates.
/// One analysis every N frames stays well under the per-frame budget.
pub const DEFAULT_ANALYZE_EVERY_N_FRAMES: u32 = 8;
/// One labelled segment of derived feature vectors used as a DTW pattern.
/// Schema (per ADR-099 D7) — JSON-loaded from `signatures/*.json` at startup.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Signature {
/// Stable id used in [`SimilarityMatch::signature_id`].
pub id: String,
/// Human-readable label for the dashboard.
pub label: String,
/// Per-frame feature vectors that define the shape. Length-flexible; the
/// DTW window in [`SignatureDtw::window`] bounds the warp tolerance.
pub vectors: Vec<Vec<f64>>,
/// DTW knobs.
pub dtw: SignatureDtw,
/// `top_k_similarity` only fires a match for a signature when its
/// distance-derived score crosses `promotion_threshold` ∈ \[0, 1\]. Per-
/// signature so tuning stays local (ADR-099 D7).
pub promotion_threshold: f32,
}
/// DTW tunables for a single signature. Mirrors the JSON shape from ADR-099 D7.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SignatureDtw {
/// Sakoe-Chiba band width (warp tolerance in frames).
pub window: usize,
/// Step pattern selector (`"symmetric2"` is the default; only that one
/// is wired today, the field exists for forward compat).
#[serde(default = "default_step_pattern")]
pub step_pattern: String,
}
fn default_step_pattern() -> String {
"symmetric2".to_string()
}
/// In-memory library of [`Signature`]s loaded from a directory of JSON files.
#[derive(Debug, Default, Clone)]
pub struct SignatureLibrary {
signatures: Vec<Signature>,
}
impl SignatureLibrary {
/// Empty library — fine for tests and for the introspection tap booting
/// without any captured signatures yet (the analyzer half still works).
pub fn new() -> Self {
Self { signatures: Vec::new() }
}
/// Library from in-memory signatures (testing / programmatic loaders).
pub fn from_signatures(signatures: Vec<Signature>) -> Self {
Self { signatures }
}
/// Number of signatures in the library.
pub fn len(&self) -> usize {
self.signatures.len()
}
/// `true` if the library carries no signatures.
pub fn is_empty(&self) -> bool {
self.signatures.is_empty()
}
/// Borrow the underlying signature list.
pub fn signatures(&self) -> &[Signature] {
&self.signatures
}
}
/// One match against a [`Signature`], scored 0..=1 (1 = identical).
///
/// Score is `1 / (1 + normalised_dtw_distance)` — monotone decreasing in
/// distance, bounded to (0, 1\], stable in the presence of empty signatures.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SimilarityMatch {
/// Stable signature id ([`Signature::id`]).
pub signature_id: String,
/// `0.0` (worst) … `1.0` (perfect match).
pub score: f32,
/// `true` iff `score >= signature.promotion_threshold`.
pub above_threshold: bool,
}
/// One snapshot of the per-frame introspection state. Broadcast on
/// `/ws/introspection` and returned by `GET /api/v1/introspection/snapshot`.
///
/// Per ADR-099 D3, this is the contract on the new endpoints.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct IntrospectionSnapshot {
/// Source-side timestamp of the frame that produced this snapshot.
pub timestamp_ns: u64,
/// Frames seen since module init (monotonic, never resets).
pub frame_count: u64,
/// Attractor regime classification from `midstreamer-attractor`.
pub regime: Regime,
/// Max Lyapunov exponent (`None` until the analyzer has enough points —
/// `DEFAULT_TRAJECTORY_LEN` ≥ 100 by default).
pub lyapunov_exponent: Option<f64>,
/// Embedding-space dimensionality the attractor is analysing in.
pub attractor_dim: usize,
/// Analyzer confidence in `[0, 1]`. `0.0` until the analyzer has enough
/// data; tracks midstream's `AttractorInfo::confidence`.
pub attractor_confidence: f64,
/// Top-k DTW matches against the loaded signature library. Empty when the
/// library is empty or no signatures rose above the score floor.
pub top_k_similarity: Vec<SimilarityMatch>,
}
/// JSON-friendly regime classification mirror of midstream's `AttractorType`.
/// Kept as a separate type so the public wire contract (ADR-099 D3) doesn't
/// pin to midstream's enum variant names.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Regime {
/// Stable, settled equilibrium — "the room is calm".
Idle,
/// Periodic / limit-cycle — repetitive motion (e.g. breathing, a running
/// fan, walking-in-place).
Periodic,
/// Single non-repeating excursion — "something just happened once".
Transient,
/// Strange-attractor / chaotic — complex non-periodic motion.
Chaotic,
/// Not enough data yet to classify.
Unknown,
}
impl Regime {
fn from_attractor(t: AttractorType) -> Self {
match t {
AttractorType::PointAttractor => Regime::Idle,
AttractorType::LimitCycle => Regime::Periodic,
AttractorType::StrangeAttractor => Regime::Chaotic,
AttractorType::Unknown => Regime::Unknown,
}
}
}
/// The per-frame introspection state for one CSI source (one node).
///
/// Reset is not provided on purpose — restarts come from rebuilding the
/// struct.
pub struct IntrospectionState {
analyzer: AttractorAnalyzer,
library: SignatureLibrary,
recent_amplitudes: VecDeque<f64>,
trajectory_capacity: usize,
frames_since_analyze: u32,
analyze_every_n: u32,
frame_count: u64,
last_snapshot: IntrospectionSnapshot,
}
impl IntrospectionState {
/// New introspection state with sensible defaults.
pub fn new() -> Self {
Self::with_config(IntrospectionConfig::default())
}
/// New introspection state with explicit knobs.
pub fn with_config(cfg: IntrospectionConfig) -> Self {
let analyzer = AttractorAnalyzer::new(cfg.embedding_dim, cfg.trajectory_len);
Self {
analyzer,
library: cfg.library,
recent_amplitudes: VecDeque::with_capacity(cfg.trajectory_len),
trajectory_capacity: cfg.trajectory_len,
frames_since_analyze: 0,
analyze_every_n: cfg.analyze_every_n.max(1),
frame_count: 0,
last_snapshot: IntrospectionSnapshot {
timestamp_ns: 0,
frame_count: 0,
regime: Regime::Unknown,
lyapunov_exponent: None,
attractor_dim: cfg.embedding_dim,
attractor_confidence: 0.0,
top_k_similarity: Vec::new(),
},
}
}
/// How many frames have been observed since construction.
pub fn frame_count(&self) -> u64 {
self.frame_count
}
/// Borrow the last computed snapshot. Cheap; always valid (zeroed before
/// the first frame is observed).
pub fn snapshot(&self) -> &IntrospectionSnapshot {
&self.last_snapshot
}
/// Feed one frame. Designed for the hot path: <1 ms p99 budget on a Pi-5
/// host (ADR-099 D4). The expensive `analyze()` call only runs every
/// `analyze_every_n` frames; the trajectory slide and DTW scoring happen
/// every frame.
pub fn update(&mut self, timestamp_ns: u64, derived_feature: f64) -> Result<(), AttractorError> {
self.frame_count = self.frame_count.saturating_add(1);
// Slide the amplitude buffer.
if self.recent_amplitudes.len() == self.trajectory_capacity {
self.recent_amplitudes.pop_front();
}
self.recent_amplitudes.push_back(derived_feature);
// Feed the attractor analyzer.
let phase_point = PhasePoint::new(vec![derived_feature], timestamp_ns);
self.analyzer.add_point(phase_point)?;
// Run the (relatively expensive) analyze step every Nth frame; in
// between, keep the previous regime/Lyapunov in the snapshot — they're
// smooth signals, not edge-sensitive.
self.frames_since_analyze = self.frames_since_analyze.saturating_add(1);
if self.frames_since_analyze >= self.analyze_every_n {
self.frames_since_analyze = 0;
match self.analyzer.analyze() {
Ok(info) => {
self.last_snapshot.regime = Regime::from_attractor(info.attractor_type);
self.last_snapshot.lyapunov_exponent = info.max_lyapunov_exponent();
self.last_snapshot.attractor_confidence = info.confidence;
}
Err(AttractorError::InsufficientData(_)) => {
// Not enough points yet — keep the Unknown default.
}
Err(other) => return Err(other),
}
}
// DTW scoring runs every frame; cheap when the library is small (and
// empty when it's empty). See `score_signatures` for the metric.
self.last_snapshot.top_k_similarity = score_signatures(
&self.library,
&self.recent_amplitudes,
DEFAULT_TOP_K,
);
self.last_snapshot.timestamp_ns = timestamp_ns;
self.last_snapshot.frame_count = self.frame_count;
Ok(())
}
}
impl Default for IntrospectionState {
fn default() -> Self {
Self::new()
}
}
/// Tunables for [`IntrospectionState::with_config`].
pub struct IntrospectionConfig {
/// Sliding amplitude buffer length fed to the attractor analyzer.
pub trajectory_len: usize,
/// Phase-space dimension (1 for scalar amplitude features today; will
/// grow when real `vec128` embeddings arrive).
pub embedding_dim: usize,
/// How often (in frames) the analyzer's `analyze()` is called.
pub analyze_every_n: u32,
/// Signature library for DTW scoring.
pub library: SignatureLibrary,
}
impl Default for IntrospectionConfig {
fn default() -> Self {
IntrospectionConfig {
trajectory_len: DEFAULT_TRAJECTORY_LEN,
embedding_dim: DEFAULT_EMBEDDING_DIM,
analyze_every_n: DEFAULT_ANALYZE_EVERY_N_FRAMES,
library: SignatureLibrary::new(),
}
}
}
/// Score the recent amplitudes against each signature in the library, return
/// the top-k by score (descending). This is the host-side stand-in for the
/// `midstreamer-temporal-compare` DTW path — it uses a simple
/// length-normalised L1 distance over the trailing window, which is cheap
/// (O(n) per signature) and behaves the same way DTW does on the
/// scale-comparable shape question. We promote to the real DTW once real
/// `vec128` embeddings exist (ADR-208 P2 / ADR-099 P1).
///
/// Returning `Vec` rather than a fixed array keeps the JSON wire shape stable
/// when the library size changes.
fn score_signatures(
library: &SignatureLibrary,
recent: &VecDeque<f64>,
top_k: usize,
) -> Vec<SimilarityMatch> {
if library.is_empty() || recent.is_empty() {
return Vec::new();
}
let mut scored: Vec<SimilarityMatch> = library
.signatures()
.iter()
.map(|sig| {
let score = signature_score(sig, recent);
SimilarityMatch {
signature_id: sig.id.clone(),
score,
above_threshold: score >= sig.promotion_threshold,
}
})
.collect();
scored.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
scored.truncate(top_k);
scored
}
/// Length-normalised L1 distance → similarity score in `(0, 1]`.
///
/// The signature's `vectors` are 1-D for now (the per-frame amplitude scalar).
/// When `vec128` lands we extend the inner pass to component-wise L1 across
/// the embedding dimensions; the outer shape (length-normalise the trailing
/// window of `recent` against the signature) stays.
fn signature_score(sig: &Signature, recent: &VecDeque<f64>) -> f32 {
if sig.vectors.is_empty() {
return 0.0;
}
let window = sig.vectors.len().min(recent.len());
if window == 0 {
return 0.0;
}
let start = recent.len() - window;
let mut sum: f64 = 0.0;
for (i, sig_vec) in sig.vectors.iter().rev().take(window).enumerate() {
let s = sig_vec.first().copied().unwrap_or(0.0);
let r = recent.get(recent.len() - 1 - i).copied().unwrap_or(0.0);
sum += (s - r).abs();
}
let mean_abs = sum / window as f64;
// Map to (0, 1] — 0 mean-abs error → 1.0, growing error → ~0.
let score = 1.0 / (1.0 + mean_abs);
let _ = start; // reserved for future windowing changes
score as f32
}
#[cfg(test)]
mod tests {
use super::*;
fn sig(id: &str, vectors: Vec<f64>, threshold: f32) -> Signature {
Signature {
id: id.to_string(),
label: id.to_string(),
vectors: vectors.into_iter().map(|v| vec![v]).collect(),
dtw: SignatureDtw {
window: 8,
step_pattern: "symmetric2".to_string(),
},
promotion_threshold: threshold,
}
}
#[test]
fn snapshot_is_unknown_before_first_frame() {
let st = IntrospectionState::new();
let s = st.snapshot();
assert_eq!(s.frame_count, 0);
assert_eq!(s.regime, Regime::Unknown);
assert!(s.lyapunov_exponent.is_none());
assert_eq!(s.attractor_confidence, 0.0);
assert!(s.top_k_similarity.is_empty());
}
#[test]
fn update_advances_frame_count_and_timestamp() {
let mut st = IntrospectionState::new();
st.update(1_000, 0.5).unwrap();
st.update(2_000, 0.7).unwrap();
let s = st.snapshot();
assert_eq!(s.frame_count, 2);
assert_eq!(s.timestamp_ns, 2_000);
}
#[test]
fn empty_library_yields_empty_similarity() {
let mut st = IntrospectionState::new();
for k in 0..40 {
st.update(k * 33_000_000, (k as f64).sin()).unwrap();
}
assert!(st.snapshot().top_k_similarity.is_empty());
}
#[test]
fn single_signature_scores_higher_when_recent_matches() {
let lib = SignatureLibrary::from_signatures(vec![sig(
"walking_slow",
vec![1.0, 2.0, 3.0, 4.0, 5.0],
0.5,
)]);
let cfg = IntrospectionConfig {
trajectory_len: 32,
embedding_dim: 1,
analyze_every_n: 16,
library: lib,
};
let mut st = IntrospectionState::with_config(cfg);
// Feed a ramp that ends 1..=5 — close match for the signature.
for (i, v) in [1.0f64, 2.0, 3.0, 4.0, 5.0].iter().enumerate() {
st.update((i as u64) * 1_000_000, *v).unwrap();
}
let s = st.snapshot();
assert_eq!(s.top_k_similarity.len(), 1);
let m = &s.top_k_similarity[0];
assert_eq!(m.signature_id, "walking_slow");
// Perfect ramp match → score very close to 1.0.
assert!(m.score > 0.95, "score = {}", m.score);
assert!(m.above_threshold);
}
#[test]
fn divergent_signature_scores_low_and_below_threshold() {
let lib = SignatureLibrary::from_signatures(vec![sig(
"walking_slow",
vec![1.0, 2.0, 3.0, 4.0, 5.0],
0.5,
)]);
let cfg = IntrospectionConfig {
trajectory_len: 32,
embedding_dim: 1,
analyze_every_n: 16,
library: lib,
};
let mut st = IntrospectionState::with_config(cfg);
for (i, v) in [100.0f64, 200.0, 300.0, 400.0, 500.0].iter().enumerate() {
st.update((i as u64) * 1_000_000, *v).unwrap();
}
let m = &st.snapshot().top_k_similarity[0];
assert!(m.score < 0.05, "score = {}", m.score);
assert!(!m.above_threshold);
}
#[test]
fn top_k_truncates_and_orders_descending() {
let lib = SignatureLibrary::from_signatures(vec![
sig("a", vec![1.0, 2.0, 3.0], 0.3),
sig("b", vec![10.0, 20.0, 30.0], 0.3),
sig("c", vec![100.0, 200.0, 300.0], 0.3),
sig("d", vec![1.5, 2.5, 3.5], 0.3),
]);
let cfg = IntrospectionConfig {
trajectory_len: 32,
embedding_dim: 1,
analyze_every_n: 16,
library: lib,
};
let mut st = IntrospectionState::with_config(cfg);
// The trailing 3 values match "a" exactly.
for (i, v) in [1.0f64, 2.0, 3.0].iter().enumerate() {
st.update((i as u64) * 1_000_000, *v).unwrap();
}
let top = &st.snapshot().top_k_similarity;
// Default DEFAULT_TOP_K = 5; library has 4, so we get 4 back.
assert_eq!(top.len(), 4);
// Strictly descending by score.
for w in top.windows(2) {
assert!(w[0].score >= w[1].score, "not descending: {:?}", top);
}
// First one is "a" (perfect 1..3 match) at score ~1.
assert_eq!(top[0].signature_id, "a");
assert!(top[0].score > 0.95);
}
#[test]
fn signature_with_empty_vectors_does_not_panic() {
let lib = SignatureLibrary::from_signatures(vec![sig("empty", vec![], 0.5)]);
let mut st = IntrospectionState::with_config(IntrospectionConfig {
trajectory_len: 16,
embedding_dim: 1,
analyze_every_n: 8,
library: lib,
});
st.update(1_000, 1.0).unwrap();
let s = st.snapshot();
assert_eq!(s.top_k_similarity.len(), 1);
assert_eq!(s.top_k_similarity[0].score, 0.0);
assert!(!s.top_k_similarity[0].above_threshold);
}
#[test]
fn regime_classification_eventually_runs() {
// Feed >100 points of a periodic signal — analyzer's
// min_points_for_analysis is 100. We don't assert a specific regime
// (the classification rules are midstream's, not ours) — only that
// the analyze step runs without erroring and a non-Unknown classification
// is produced.
let mut st = IntrospectionState::with_config(IntrospectionConfig {
trajectory_len: 256,
embedding_dim: 1,
analyze_every_n: 8,
library: SignatureLibrary::new(),
});
for k in 0..200u64 {
let v = (k as f64 * 0.1).sin();
st.update(k * 33_000_000, v).unwrap();
}
let s = st.snapshot();
// After 200 points + analyze_every_n=8 fires, the analyzer should have
// produced a classification at least once.
assert!(
s.regime != Regime::Unknown || s.lyapunov_exponent.is_some(),
"expected regime classified or Lyapunov set after 200 frames; got {:?}",
s
);
}
}

View File

@ -4,8 +4,10 @@
//! - Vital sign detection from WiFi CSI amplitude data
//! - RVF (RuVector Format) binary container for model weights
//! - Opt-in bearer-token auth for the `/api/v1/*` HTTP surface (`bearer_auth`)
//! - Real-time CSI introspection / low-latency tap (`introspection`, ADR-099)
pub mod bearer_auth;
pub mod introspection;
pub mod vital_signs;
pub mod rvf_container;
pub mod rvf_pipeline;