mirror of
https://github.com/ruvnet/RuView.git
synced 2026-04-28 05:59:32 +00:00
- ADR-042: Coherent Human Channel Imaging (non-CSI sensing protocol) with DDD domain model (6 bounded contexts) - 24 new WASM edge modules: medical (5), retail (5), security (5), building (5), industrial (5), exotic (8) - README: plain-language rewrites, moved detail sections below TOC, added edge module links to use case tables, firmware release docs - User guide: firmware release table, edge intelligence documentation - .gitignore: added rules for wasm, esp32 temp files, NVS binaries - WASM edge crate: cargo config, integration tests, module registry Co-Authored-By: claude-flow <ruv@ruv.net>
451 lines
15 KiB
Rust
451 lines
15 KiB
Rust
//! Temporal symmetry breaking (time crystal) detector — ADR-041 exotic module.
|
|
//!
|
|
//! # Algorithm
|
|
//!
|
|
//! Samples `motion_energy` at frame rate (~20 Hz) into a 256-point circular
|
|
//! buffer. Each frame computes the autocorrelation of the buffer at lags
|
|
//! 1..128 and searches for:
|
|
//!
|
|
//! 1. **Period doubling** -- a *discrete time translation symmetry breaking*
|
|
//! signature. Detected when the autocorrelation peak at lag L is strong
|
|
//! (>0.5) AND the peak at lag 2L is also strong. This mirrors the
|
|
//! Floquet time-crystal criterion: the system oscillates at a sub-harmonic
|
|
//! of the driving frequency.
|
|
//!
|
|
//! 2. **Multi-person temporal coordination** -- multiple autocorrelation peaks
|
|
//! at non-harmonic ratios indicate coordinated but independent periodic
|
|
//! motions (e.g., two people walking at different cadences).
|
|
//!
|
|
//! 3. **Stability** -- peak persistence is tracked across 10-second windows
|
|
//! (200 frames at 20 Hz). A crystal is "stable" only if the same
|
|
//! period multiplier persists for the full window.
|
|
//!
|
|
//! # Events (680-series: Exotic / Research)
|
|
//!
|
|
//! - `CRYSTAL_DETECTED` (680): Period multiplier (2 = classic doubling).
|
|
//! - `CRYSTAL_STABILITY` (681): Stability score [0, 1] over the window.
|
|
//! - `COORDINATION_INDEX` (682): Number of distinct non-harmonic peaks.
|
|
//!
|
|
//! # Budget
|
|
//!
|
|
//! H (heavy, < 10 ms) -- autocorrelation of 256 points at 128 lags = 32K
|
|
//! multiply-accumulates, tight but within budget on ESP32-S3 WASM3.
|
|
|
|
use crate::vendor_common::{CircularBuffer, Ema};
|
|
use libm::fabsf;
|
|
|
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
|
|
/// Motion energy circular buffer length (256 points at 20 Hz = 12.8 s).
|
|
const BUF_LEN: usize = 256;
|
|
|
|
/// Maximum autocorrelation lag to compute.
|
|
const MAX_LAG: usize = 128;
|
|
|
|
/// Minimum autocorrelation peak magnitude to count as "strong".
|
|
const PEAK_THRESHOLD: f32 = 0.5;
|
|
|
|
/// Minimum buffer fill before computing autocorrelation.
|
|
const MIN_FILL: usize = 64;
|
|
|
|
/// Ratio tolerance for harmonic detection: peaks within 5% of integer
|
|
/// multiples of the fundamental are considered harmonics, not independent.
|
|
const HARMONIC_TOLERANCE: f32 = 0.05;
|
|
|
|
/// Maximum number of distinct peaks to track for coordination index.
|
|
const MAX_PEAKS: usize = 8;
|
|
|
|
/// Stability window length in frames (10 s at 20 Hz).
|
|
const STABILITY_WINDOW: u32 = 200;
|
|
|
|
/// EMA smoothing factor for stability tracking.
|
|
const STABILITY_ALPHA: f32 = 0.05;
|
|
|
|
// ── Event IDs (680-series: Exotic) ───────────────────────────────────────────
|
|
|
|
pub const EVENT_CRYSTAL_DETECTED: i32 = 680;
|
|
pub const EVENT_CRYSTAL_STABILITY: i32 = 681;
|
|
pub const EVENT_COORDINATION_INDEX: i32 = 682;
|
|
|
|
// ── Time Crystal Detector ────────────────────────────────────────────────────
|
|
|
|
/// Temporal symmetry breaking pattern detector.
|
|
///
|
|
/// Samples `motion_energy` into a circular buffer and runs autocorrelation
|
|
/// to detect period doubling and multi-person temporal coordination.
|
|
pub struct TimeCrystalDetector {
|
|
/// Circular buffer of motion energy samples.
|
|
motion_buf: CircularBuffer<BUF_LEN>,
|
|
/// Autocorrelation values at lags 1..MAX_LAG.
|
|
autocorr: [f32; MAX_LAG],
|
|
/// Last detected period multiplier (0 = none).
|
|
last_multiplier: u8,
|
|
/// Frame counter within the current stability window.
|
|
stability_counter: u32,
|
|
/// Number of frames in window where crystal was detected.
|
|
stability_persist: u32,
|
|
/// EMA-smoothed stability score [0, 1].
|
|
stability_ema: Ema,
|
|
/// Coordination index: count of distinct non-harmonic peaks.
|
|
coordination: u8,
|
|
/// Total frames processed.
|
|
frame_count: u32,
|
|
/// Whether crystal is currently detected.
|
|
detected: bool,
|
|
/// Cached buffer mean (for stats).
|
|
buf_mean: f32,
|
|
/// Cached buffer variance (for stats).
|
|
buf_var: f32,
|
|
}
|
|
|
|
impl TimeCrystalDetector {
|
|
pub const fn new() -> Self {
|
|
Self {
|
|
motion_buf: CircularBuffer::new(),
|
|
autocorr: [0.0; MAX_LAG],
|
|
last_multiplier: 0,
|
|
stability_counter: 0,
|
|
stability_persist: 0,
|
|
stability_ema: Ema::new(STABILITY_ALPHA),
|
|
coordination: 0,
|
|
frame_count: 0,
|
|
detected: false,
|
|
buf_mean: 0.0,
|
|
buf_var: 0.0,
|
|
}
|
|
}
|
|
|
|
/// Process one frame. `motion_energy` comes from the host Tier 2 DSP.
|
|
///
|
|
/// Returns events as `(event_id, value)` pairs in a static buffer.
|
|
pub fn process_frame(&mut self, motion_energy: f32) -> &[(i32, f32)] {
|
|
static mut EVENTS: [(i32, f32); 3] = [(0, 0.0); 3];
|
|
let mut n_ev = 0usize;
|
|
|
|
// Push sample into circular buffer.
|
|
self.motion_buf.push(motion_energy);
|
|
self.frame_count += 1;
|
|
|
|
let fill = self.motion_buf.len();
|
|
|
|
// Need at least MIN_FILL samples before analysis.
|
|
if fill < MIN_FILL {
|
|
return &[];
|
|
}
|
|
|
|
// Compute buffer statistics (mean, variance) for normalization.
|
|
self.compute_stats(fill);
|
|
|
|
// Skip if signal is essentially constant (no motion).
|
|
if self.buf_var < 1e-8 {
|
|
return &[];
|
|
}
|
|
|
|
// Compute normalized autocorrelation at lags 1..MAX_LAG.
|
|
self.compute_autocorrelation(fill);
|
|
|
|
// Find all local peaks in the autocorrelation.
|
|
let max_lag = if fill / 2 < MAX_LAG { fill / 2 } else { MAX_LAG };
|
|
|
|
let mut peak_lags = [0u16; MAX_PEAKS];
|
|
let mut peak_vals = [0.0f32; MAX_PEAKS];
|
|
let mut n_peaks = 0usize;
|
|
|
|
// Skip trivial near-zero lags (start at lag 4).
|
|
let mut i = 4;
|
|
while i < max_lag.saturating_sub(1) {
|
|
let prev = self.autocorr[i - 1];
|
|
let curr = self.autocorr[i];
|
|
let next = self.autocorr[i + 1];
|
|
if curr > prev && curr > next && curr > PEAK_THRESHOLD {
|
|
if n_peaks < MAX_PEAKS {
|
|
peak_lags[n_peaks] = (i + 1) as u16; // lag is 1-indexed
|
|
peak_vals[n_peaks] = curr;
|
|
n_peaks += 1;
|
|
}
|
|
}
|
|
i += 1;
|
|
}
|
|
|
|
// Detect period doubling: peak at lag L AND peak at lag 2L.
|
|
let mut detected_multiplier: u8 = 0;
|
|
'outer: for p in 0..n_peaks {
|
|
let lag_l = peak_lags[p] as usize;
|
|
let lag_2l = lag_l * 2;
|
|
if lag_2l > max_lag {
|
|
continue;
|
|
}
|
|
// Check if there is a peak near lag 2L (+/- 2 tolerance).
|
|
for q in 0..n_peaks {
|
|
let lag_q = peak_lags[q] as usize;
|
|
let diff = if lag_q > lag_2l {
|
|
lag_q - lag_2l
|
|
} else {
|
|
lag_2l - lag_q
|
|
};
|
|
if diff <= 2 && peak_vals[q] > PEAK_THRESHOLD {
|
|
detected_multiplier = 2;
|
|
break 'outer;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Count coordination index: number of distinct non-harmonic peaks.
|
|
let coordination = self.count_non_harmonic_peaks(
|
|
&peak_lags[..n_peaks],
|
|
);
|
|
self.coordination = coordination;
|
|
self.detected = detected_multiplier > 0;
|
|
|
|
// Update stability tracking.
|
|
self.stability_counter += 1;
|
|
if detected_multiplier > 0 && detected_multiplier == self.last_multiplier {
|
|
self.stability_persist += 1;
|
|
} else if detected_multiplier > 0 {
|
|
self.stability_persist = 1;
|
|
}
|
|
|
|
if self.stability_counter >= STABILITY_WINDOW {
|
|
let raw = self.stability_persist as f32 / STABILITY_WINDOW as f32;
|
|
self.stability_ema.update(raw);
|
|
self.stability_counter = 0;
|
|
self.stability_persist = 0;
|
|
}
|
|
|
|
self.last_multiplier = detected_multiplier;
|
|
|
|
// Emit events.
|
|
if detected_multiplier > 0 {
|
|
unsafe {
|
|
EVENTS[n_ev] = (EVENT_CRYSTAL_DETECTED, detected_multiplier as f32);
|
|
}
|
|
n_ev += 1;
|
|
}
|
|
|
|
unsafe {
|
|
EVENTS[n_ev] = (EVENT_CRYSTAL_STABILITY, self.stability_ema.value);
|
|
}
|
|
n_ev += 1;
|
|
|
|
if coordination > 0 {
|
|
unsafe {
|
|
EVENTS[n_ev] = (EVENT_COORDINATION_INDEX, coordination as f32);
|
|
}
|
|
n_ev += 1;
|
|
}
|
|
|
|
unsafe { &EVENTS[..n_ev] }
|
|
}
|
|
|
|
/// Compute mean and variance of the circular buffer contents.
|
|
///
|
|
/// PERF: Single-pass computation using sum and sum-of-squares identity:
|
|
/// var = E[x^2] - E[x]^2 = (sum_sq / n) - (sum / n)^2
|
|
/// Reduces from 2 passes (2 * fill get() calls with modulus) to 1 pass.
|
|
fn compute_stats(&mut self, fill: usize) {
|
|
let n = fill as f32;
|
|
let mut sum = 0.0f32;
|
|
let mut sum_sq = 0.0f32;
|
|
for i in 0..fill {
|
|
let v = self.motion_buf.get(i);
|
|
sum += v;
|
|
sum_sq += v * v;
|
|
}
|
|
self.buf_mean = sum / n;
|
|
// var = E[x^2] - (E[x])^2, clamped to avoid negative due to float rounding.
|
|
let var = sum_sq / n - self.buf_mean * self.buf_mean;
|
|
self.buf_var = if var > 0.0 { var } else { 0.0 };
|
|
}
|
|
|
|
/// Compute normalized autocorrelation r(k) for lags k=1..MAX_LAG.
|
|
///
|
|
/// r(k) = (1/(N-k)) * sum_{t=0}^{N-k-1} (x[t]-mean)*(x[t+k]-mean) / var
|
|
///
|
|
/// PERF: Pre-linearize circular buffer to contiguous stack array, eliminating
|
|
/// modulus operations in the inner loop and improving cache locality.
|
|
/// Reduces ~64K modulus ops to 0 for full buffer (256 * 128 * 2 get() calls).
|
|
fn compute_autocorrelation(&mut self, fill: usize) {
|
|
let max_lag = if fill / 2 < MAX_LAG { fill / 2 } else { MAX_LAG };
|
|
let inv_var = 1.0 / self.buf_var;
|
|
|
|
// Pre-linearize: copy circular buffer to contiguous array, subtracting
|
|
// mean so we avoid the subtraction in the inner loop (saves fill*max_lag
|
|
// subtractions).
|
|
let mut linear = [0.0f32; BUF_LEN];
|
|
for t in 0..fill {
|
|
linear[t] = self.motion_buf.get(t) - self.buf_mean;
|
|
}
|
|
|
|
for k in 0..max_lag {
|
|
let lag = k + 1; // lags 1..MAX_LAG
|
|
let pairs = fill - lag;
|
|
let mut sum = 0.0f32;
|
|
// Inner loop now accesses contiguous memory with no modulus.
|
|
let mut t = 0;
|
|
while t < pairs {
|
|
sum += linear[t] * linear[t + lag];
|
|
t += 1;
|
|
}
|
|
self.autocorr[k] = (sum / pairs as f32) * inv_var;
|
|
}
|
|
|
|
// Zero out unused lags.
|
|
for k in max_lag..MAX_LAG {
|
|
self.autocorr[k] = 0.0;
|
|
}
|
|
}
|
|
|
|
/// Count peaks whose lag ratios are not integer multiples of any other
|
|
/// peak's lag. These represent independent periodic components.
|
|
fn count_non_harmonic_peaks(&self, lags: &[u16]) -> u8 {
|
|
if lags.is_empty() {
|
|
return 0;
|
|
}
|
|
if lags.len() == 1 {
|
|
return 1;
|
|
}
|
|
|
|
let fundamental = lags[0] as f32;
|
|
if fundamental < 1.0 {
|
|
return lags.len() as u8;
|
|
}
|
|
let mut independent = 1u8; // fundamental itself counts
|
|
|
|
for i in 1..lags.len() {
|
|
let ratio = lags[i] as f32 / fundamental;
|
|
let nearest_int = (ratio + 0.5) as u32;
|
|
if nearest_int == 0 {
|
|
independent += 1;
|
|
continue;
|
|
}
|
|
let deviation = fabsf(ratio - nearest_int as f32) / nearest_int as f32;
|
|
if deviation > HARMONIC_TOLERANCE {
|
|
independent += 1;
|
|
}
|
|
}
|
|
independent
|
|
}
|
|
|
|
/// Get the most recent autocorrelation values.
|
|
pub fn autocorrelation(&self) -> &[f32; MAX_LAG] {
|
|
&self.autocorr
|
|
}
|
|
|
|
/// Get the current stability score [0, 1].
|
|
pub fn stability(&self) -> f32 {
|
|
self.stability_ema.value
|
|
}
|
|
|
|
/// Get the last detected period multiplier (0 = none, 2 = doubling).
|
|
pub fn multiplier(&self) -> u8 {
|
|
self.last_multiplier
|
|
}
|
|
|
|
/// Whether a crystal pattern is currently detected.
|
|
pub fn is_detected(&self) -> bool {
|
|
self.detected
|
|
}
|
|
|
|
/// Get the coordination index (non-harmonic peak count).
|
|
pub fn coordination_index(&self) -> u8 {
|
|
self.coordination
|
|
}
|
|
|
|
/// Total frames processed.
|
|
pub fn frame_count(&self) -> u32 {
|
|
self.frame_count
|
|
}
|
|
|
|
/// Reset to initial state.
|
|
pub fn reset(&mut self) {
|
|
*self = Self::new();
|
|
}
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_const_new() {
|
|
let tc = TimeCrystalDetector::new();
|
|
assert_eq!(tc.frame_count(), 0);
|
|
assert_eq!(tc.multiplier(), 0);
|
|
assert_eq!(tc.coordination_index(), 0);
|
|
assert!(!tc.is_detected());
|
|
}
|
|
|
|
#[test]
|
|
fn test_insufficient_data_no_events() {
|
|
let mut tc = TimeCrystalDetector::new();
|
|
for i in 0..(MIN_FILL - 1) {
|
|
let events = tc.process_frame(i as f32 * 0.1);
|
|
assert!(events.is_empty(), "should not emit before MIN_FILL");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_constant_signal_no_crystal() {
|
|
let mut tc = TimeCrystalDetector::new();
|
|
for _ in 0..BUF_LEN {
|
|
let events = tc.process_frame(1.0);
|
|
for ev in events {
|
|
assert_ne!(ev.0, EVENT_CRYSTAL_DETECTED,
|
|
"constant signal should not produce crystal");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_periodic_signal_produces_autocorrelation_peak() {
|
|
let mut tc = TimeCrystalDetector::new();
|
|
// Generate a periodic signal: period = 10 frames.
|
|
for frame in 0..BUF_LEN {
|
|
let val = if (frame % 10) < 5 { 1.0 } else { 0.0 };
|
|
tc.process_frame(val);
|
|
}
|
|
// The autocorrelation at lag 10 should be near 1.0.
|
|
let acorr_lag10 = tc.autocorrelation()[9]; // 0-indexed: autocorr[k] is lag k+1
|
|
assert!(acorr_lag10 > 0.5,
|
|
"periodic signal should have strong autocorrelation at period lag, got {}",
|
|
acorr_lag10);
|
|
}
|
|
|
|
#[test]
|
|
fn test_coordination_single_peak() {
|
|
let tc = TimeCrystalDetector::new();
|
|
let lags = [10u16];
|
|
let coord = tc.count_non_harmonic_peaks(&lags);
|
|
assert_eq!(coord, 1, "single peak = 1 independent component");
|
|
}
|
|
|
|
#[test]
|
|
fn test_coordination_harmonic_peaks() {
|
|
let tc = TimeCrystalDetector::new();
|
|
let lags = [10u16, 20, 30];
|
|
let coord = tc.count_non_harmonic_peaks(&lags);
|
|
assert_eq!(coord, 1, "harmonics of fundamental should count as 1");
|
|
}
|
|
|
|
#[test]
|
|
fn test_coordination_non_harmonic_peaks() {
|
|
let tc = TimeCrystalDetector::new();
|
|
let lags = [10u16, 17];
|
|
let coord = tc.count_non_harmonic_peaks(&lags);
|
|
assert_eq!(coord, 2, "non-harmonic peak should count as independent");
|
|
}
|
|
|
|
#[test]
|
|
fn test_reset() {
|
|
let mut tc = TimeCrystalDetector::new();
|
|
for _ in 0..100 {
|
|
tc.process_frame(1.5);
|
|
}
|
|
assert!(tc.frame_count() > 0);
|
|
tc.reset();
|
|
assert_eq!(tc.frame_count(), 0);
|
|
assert_eq!(tc.multiplier(), 0);
|
|
}
|
|
}
|