mirror of
https://github.com/ruvnet/RuView
synced 2026-07-26 18:01:48 +00:00
2aac160067
New ruvector module: anonymized ProfileStore (one-way SHA-256 hashed tags, safe-session scores only), deterministic exact kNN, cohort warm-start (a new person's optimizer seeded from the k nearest responders as down-weighted GP pseudo-observations), physiological drift detection (Welford centroid with stimulus-input fields masked out of the distance), and deterministic k-means response clustering. Honesty guarantees, asserted in tests: cohort priors carry >=25x the real-observation noise, are excluded from the EI incumbent, the audit log, and the clinician report — borrowed expectations never masquerade as this person's measured response. The GP gains per-observation noise; the real path is arithmetically unchanged (pinned witness 13cb164c... preserved). Governor wiring: seed_from_cohort, export_anonymized_profile, per-session drift_status. Integration tests: cohort warm-start beats the cold 40 Hz prior for a detuned subject; collapsed physiology flags Drifted. Crate: 75 tests + 1 doctest. Workspace gate: 2,876 passed, 0 failed. Benches: kNN/500 profiles ~15us, warm-start ~16us; no regression on existing paths (recommend ~15us, calibration sweep ~111us). https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH
547 lines
20 KiB
Rust
547 lines
20 KiB
Rust
//! RuVector self-learning layer (ADR-250 §10, items 3–6).
|
||
//!
|
||
//! The adaptive memory *across* people: anonymized 20-field response vectors
|
||
//! ([`crate::response::PersonResponseVector::as_array`]) stored in a
|
||
//! [`ProfileStore`], queried by deterministic k-nearest-neighbor to
|
||
//! **warm-start a new person's optimizer** from similar responders (instead of
|
||
//! the flat 40 Hz prior), plus per-person **drift detection** (item 4) and
|
||
//! cohort **response clustering** (item 5).
|
||
//!
|
||
//! Privacy posture: profiles carry only a one-way hashed tag (never a
|
||
//! `person_id`) and the 20 normalized response fields — no identity, no raw
|
||
//! sensor data. Cohort knowledge enters the optimizer exclusively as
|
||
//! down-weighted pseudo-observations ([`crate::optimizer::BayesianOptimizer::
|
||
//! observe_prior`]) that can shape *where to look first* but never define what
|
||
//! this person's measured response is.
|
||
//!
|
||
//! Everything here is deterministic: distances are fixed-range normalized,
|
||
//! ties break by insertion index, clustering uses farthest-point seeding from
|
||
//! index 0 — same inputs, same outputs, on every machine.
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::math::clamp_safe;
|
||
use crate::simulator::stable_hash;
|
||
|
||
/// Dimensionality of the response vector (ADR-250 §6).
|
||
pub const VECTOR_DIM: usize = 20;
|
||
|
||
/// Fixed per-field normalization ranges `(lo, hi)` for distance computation,
|
||
/// in the ADR-250 §6 field order. Constants (not data-derived statistics) so
|
||
/// distances are stable as the store grows.
|
||
pub const NORM_RANGES: [(f64, f64); VECTOR_DIM] = [
|
||
(0.0, 1.0), // baseline_gamma
|
||
(0.0, 1.0), // baseline_alpha
|
||
(0.0, 5.0), // alpha_gamma_ratio
|
||
(0.0, 1.0), // gamma_power_gain
|
||
(0.0, 1.0), // phase_locking_value
|
||
(6.0, 30.0), // breathing_rate (bpm)
|
||
(0.0, 1.0), // breathing_stability
|
||
(0.0, 1.0), // motion_artifact
|
||
(0.0, 1.0), // posture_state
|
||
(0.0, 1.0), // sleep_state
|
||
(0.0, 1.0), // restlessness_score
|
||
(36.0, 44.0), // stimulus_frequency (Hz)
|
||
(0.0, 1.0), // brightness_level
|
||
(0.0, 1.0), // sound_level
|
||
(0.0, 1.0), // duty_cycle
|
||
(-5.0, 5.0), // phase_offset (ms)
|
||
(0.0, 15.0), // session_duration (min)
|
||
(0.0, 1.0), // comfort_score
|
||
(0.0, 1.0), // adherence_score
|
||
(0.0, 1.0), // adverse_event_flag
|
||
];
|
||
|
||
/// Normalize a raw response vector to the unit hypercube using
|
||
/// [`NORM_RANGES`]. Non-finite fields clamp to the range floor.
|
||
pub fn normalize(v: &[f64; VECTOR_DIM]) -> [f64; VECTOR_DIM] {
|
||
let mut out = [0.0; VECTOR_DIM];
|
||
for (i, (&val, &(lo, hi))) in v.iter().zip(NORM_RANGES.iter()).enumerate() {
|
||
out[i] = clamp_safe((val - lo) / (hi - lo), 0.0, 1.0);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Euclidean distance between two normalized vectors.
|
||
fn unit_distance(a: &[f64; VECTOR_DIM], b: &[f64; VECTOR_DIM]) -> f64 {
|
||
a.iter()
|
||
.zip(b)
|
||
.map(|(x, y)| (x - y) * (x - y))
|
||
.sum::<f64>()
|
||
.sqrt()
|
||
}
|
||
|
||
/// One anonymized responder profile: the hashed tag, the response vector, and
|
||
/// the per-frequency scores their sessions established.
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
pub struct AnonymizedProfile {
|
||
/// One-way tag: first 16 hex chars of SHA-256("gamma-profile" ‖ person_id).
|
||
/// Never the `person_id` itself.
|
||
pub profile_tag: String,
|
||
/// Raw (un-normalized) 20-field response vector.
|
||
pub vector: [f64; VECTOR_DIM],
|
||
/// `(frequency_hz, safe-entrainment score)` summaries from this profile's
|
||
/// safe sessions — the transferable response surface.
|
||
pub frequency_scores: Vec<(f64, f64)>,
|
||
}
|
||
|
||
impl AnonymizedProfile {
|
||
/// Derive the one-way profile tag from a pseudonymous person id.
|
||
pub fn tag_for(person_id: &str) -> String {
|
||
let h = stable_hash(&[b"gamma-profile", person_id.as_bytes()]);
|
||
let mut s = String::with_capacity(16);
|
||
for b in &h[..8] {
|
||
s.push_str(&format!("{b:02x}"));
|
||
}
|
||
s
|
||
}
|
||
}
|
||
|
||
/// A cohort prior for one frequency, produced by [`ProfileStore::warm_start_prior`].
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub struct CohortPrior {
|
||
pub frequency_hz: f64,
|
||
/// Similarity-weighted mean score across the k nearest profiles.
|
||
pub expected_score: f64,
|
||
/// Noise variance to attach to the pseudo-observation: grows with cohort
|
||
/// disagreement and with distance, so dissimilar or conflicting cohorts
|
||
/// are trusted less.
|
||
pub noise_var: f64,
|
||
}
|
||
|
||
/// Deterministic in-memory store of anonymized profiles.
|
||
///
|
||
/// Linear-scan kNN: exact, allocation-light, and fast at research-cohort scale
|
||
/// (sub-µs at hundreds of profiles). An HNSW backend (the `ruvector` crates)
|
||
/// is a drop-in replacement once cohorts grow past ~10⁵ — the public API is
|
||
/// already shaped for it.
|
||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||
pub struct ProfileStore {
|
||
profiles: Vec<AnonymizedProfile>,
|
||
}
|
||
|
||
impl ProfileStore {
|
||
pub fn new() -> Self {
|
||
Self::default()
|
||
}
|
||
|
||
/// Number of stored profiles.
|
||
pub fn len(&self) -> usize {
|
||
self.profiles.len()
|
||
}
|
||
|
||
pub fn is_empty(&self) -> bool {
|
||
self.profiles.is_empty()
|
||
}
|
||
|
||
/// Insert or replace (by `profile_tag`) a profile.
|
||
pub fn upsert(&mut self, profile: AnonymizedProfile) {
|
||
if let Some(p) = self
|
||
.profiles
|
||
.iter_mut()
|
||
.find(|p| p.profile_tag == profile.profile_tag)
|
||
{
|
||
*p = profile;
|
||
} else {
|
||
self.profiles.push(profile);
|
||
}
|
||
}
|
||
|
||
/// k nearest profiles to `query` (raw vector) as `(index, distance)`,
|
||
/// ascending distance, ties broken by insertion index (deterministic).
|
||
pub fn k_nearest(&self, query: &[f64; VECTOR_DIM], k: usize) -> Vec<(usize, f64)> {
|
||
let q = normalize(query);
|
||
let mut d: Vec<(usize, f64)> = self
|
||
.profiles
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, p)| (i, unit_distance(&q, &normalize(&p.vector))))
|
||
.collect();
|
||
d.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal).then(a.0.cmp(&b.0)));
|
||
d.truncate(k);
|
||
d
|
||
}
|
||
|
||
/// Profile by index (as returned from [`k_nearest`](Self::k_nearest)).
|
||
pub fn profile(&self, idx: usize) -> Option<&AnonymizedProfile> {
|
||
self.profiles.get(idx)
|
||
}
|
||
|
||
/// Build cohort priors for a new person: for each integer frequency the
|
||
/// k nearest profiles have scored, the similarity-weighted mean score and
|
||
/// an honesty-scaled noise variance (ADR-250 §10 item 3 + item 6
|
||
/// "protocol recommendation").
|
||
///
|
||
/// `base_noise` is the optimizer's real-observation noise; priors carry at
|
||
/// least `PRIOR_NOISE_FLOOR ×` that, inflated further by cohort variance
|
||
/// and mean neighbor distance.
|
||
pub fn warm_start_prior(
|
||
&self,
|
||
query: &[f64; VECTOR_DIM],
|
||
k: usize,
|
||
base_noise: f64,
|
||
) -> Vec<CohortPrior> {
|
||
let neighbors = self.k_nearest(query, k);
|
||
if neighbors.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
// Bucket scores by quantized frequency (0.1 Hz) across neighbors,
|
||
// weighting each neighbor by 1/(1+distance).
|
||
use std::collections::BTreeMap;
|
||
let mut buckets: BTreeMap<i64, Vec<(f64, f64)>> = BTreeMap::new(); // q_hz -> (score, weight)
|
||
for &(idx, dist) in &neighbors {
|
||
let w = 1.0 / (1.0 + dist);
|
||
for &(hz, score) in &self.profiles[idx].frequency_scores {
|
||
if !hz.is_finite() || !score.is_finite() {
|
||
continue;
|
||
}
|
||
let q = (hz * 10.0).round() as i64;
|
||
buckets.entry(q).or_default().push((score, w));
|
||
}
|
||
}
|
||
let mean_dist =
|
||
neighbors.iter().map(|(_, d)| d).sum::<f64>() / neighbors.len() as f64;
|
||
buckets
|
||
.into_iter()
|
||
.map(|(q, entries)| {
|
||
let wsum: f64 = entries.iter().map(|(_, w)| w).sum();
|
||
let mean: f64 = entries.iter().map(|(s, w)| s * w).sum::<f64>() / wsum;
|
||
let var: f64 = entries
|
||
.iter()
|
||
.map(|(s, w)| w * (s - mean) * (s - mean))
|
||
.sum::<f64>()
|
||
/ wsum;
|
||
CohortPrior {
|
||
frequency_hz: q as f64 / 10.0,
|
||
expected_score: mean,
|
||
// Floor × base, inflated by cohort disagreement and distance.
|
||
noise_var: base_noise * Self::PRIOR_NOISE_FLOOR * (1.0 + mean_dist)
|
||
+ var,
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Minimum factor by which a cohort prior is noisier than a real
|
||
/// observation (priors must never outweigh measured sessions).
|
||
pub const PRIOR_NOISE_FLOOR: f64 = 25.0;
|
||
|
||
/// Deterministic k-means over normalized vectors (ADR-250 §10 item 5):
|
||
/// farthest-point seeding from index 0, fixed `iters` Lloyd steps, ties to
|
||
/// the lowest cluster index. Returns each profile's cluster assignment.
|
||
/// Returns an empty vec if the store is empty or `k == 0`.
|
||
pub fn cluster(&self, k: usize, iters: usize) -> Vec<usize> {
|
||
let n = self.profiles.len();
|
||
if n == 0 || k == 0 {
|
||
return Vec::new();
|
||
}
|
||
let k = k.min(n);
|
||
let pts: Vec<[f64; VECTOR_DIM]> =
|
||
self.profiles.iter().map(|p| normalize(&p.vector)).collect();
|
||
|
||
// Farthest-point initialization (deterministic, no RNG).
|
||
let mut centers: Vec<[f64; VECTOR_DIM]> = vec![pts[0]];
|
||
while centers.len() < k {
|
||
let (far_idx, _) = pts
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, p)| {
|
||
let dmin = centers
|
||
.iter()
|
||
.map(|c| unit_distance(p, c))
|
||
.fold(f64::INFINITY, f64::min);
|
||
(i, dmin)
|
||
})
|
||
.fold((0usize, -1.0f64), |acc, (i, d)| if d > acc.1 { (i, d) } else { acc });
|
||
centers.push(pts[far_idx]);
|
||
}
|
||
|
||
let mut assign = vec![0usize; n];
|
||
for _ in 0..iters {
|
||
// Assignment step.
|
||
for (i, p) in pts.iter().enumerate() {
|
||
let mut best = 0usize;
|
||
let mut bd = f64::INFINITY;
|
||
for (c, center) in centers.iter().enumerate() {
|
||
let d = unit_distance(p, center);
|
||
if d < bd {
|
||
bd = d;
|
||
best = c;
|
||
}
|
||
}
|
||
assign[i] = best;
|
||
}
|
||
// Update step.
|
||
for (c, center) in centers.iter_mut().enumerate() {
|
||
let members: Vec<&[f64; VECTOR_DIM]> = pts
|
||
.iter()
|
||
.zip(&assign)
|
||
.filter(|(_, &a)| a == c)
|
||
.map(|(p, _)| p)
|
||
.collect();
|
||
if members.is_empty() {
|
||
continue; // keep the old center (deterministic)
|
||
}
|
||
let mut mean = [0.0; VECTOR_DIM];
|
||
for m in &members {
|
||
for (dst, src) in mean.iter_mut().zip(m.iter()) {
|
||
*dst += src;
|
||
}
|
||
}
|
||
for dst in mean.iter_mut() {
|
||
*dst /= members.len() as f64;
|
||
}
|
||
*center = mean;
|
||
}
|
||
}
|
||
assign
|
||
}
|
||
}
|
||
|
||
/// Fields that participate in **drift** distance: the person's physiology and
|
||
/// response, *not* the stimulus parameters (indices 11–16) — those are inputs
|
||
/// the protocol changes deliberately (the calibration sweep swings frequency
|
||
/// across the whole band) and must not register as the person drifting.
|
||
pub const DRIFT_MASK: [bool; VECTOR_DIM] = [
|
||
true, // baseline_gamma
|
||
true, // baseline_alpha
|
||
true, // alpha_gamma_ratio
|
||
true, // gamma_power_gain
|
||
true, // phase_locking_value
|
||
true, // breathing_rate
|
||
true, // breathing_stability
|
||
true, // motion_artifact
|
||
true, // posture_state
|
||
true, // sleep_state
|
||
true, // restlessness_score
|
||
false, // stimulus_frequency (protocol input)
|
||
false, // brightness_level (protocol input)
|
||
false, // sound_level (protocol input)
|
||
false, // duty_cycle (protocol input)
|
||
false, // phase_offset (protocol input)
|
||
false, // session_duration (protocol input)
|
||
true, // comfort_score
|
||
true, // adherence_score
|
||
true, // adverse_event_flag
|
||
];
|
||
|
||
/// Euclidean distance over the [`DRIFT_MASK`]-selected fields of two
|
||
/// normalized vectors.
|
||
fn drift_distance(a: &[f64; VECTOR_DIM], b: &[f64; VECTOR_DIM]) -> f64 {
|
||
a.iter()
|
||
.zip(b)
|
||
.zip(DRIFT_MASK.iter())
|
||
.filter(|(_, &m)| m)
|
||
.map(|((x, y), _)| (x - y) * (x - y))
|
||
.sum::<f64>()
|
||
.sqrt()
|
||
}
|
||
|
||
/// Drift status for one person (ADR-250 §10 item 4).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum DriftStatus {
|
||
/// Not enough sessions yet to judge.
|
||
Warmup,
|
||
/// Latest vector is consistent with this person's running centroid.
|
||
Stable,
|
||
/// Latest vector departed from the centroid — recommend recalibration
|
||
/// (re-run the Phase-1 sweep) before trusting further optimization.
|
||
Drifted,
|
||
}
|
||
|
||
/// Per-person drift detector: running mean (Welford) of the normalized
|
||
/// response vector; a session whose [`DRIFT_MASK`]-restricted distance from
|
||
/// the centroid exceeds `threshold` flags drift. Deterministic and O(1) per
|
||
/// update.
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct DriftDetector {
|
||
centroid: [f64; VECTOR_DIM],
|
||
count: u64,
|
||
/// Distance (in normalized space) above which a session counts as drifted.
|
||
pub threshold: f64,
|
||
/// Sessions required before drift can be judged.
|
||
pub warmup: u64,
|
||
}
|
||
|
||
impl Default for DriftDetector {
|
||
fn default() -> Self {
|
||
Self {
|
||
centroid: [0.0; VECTOR_DIM],
|
||
count: 0,
|
||
threshold: 0.35,
|
||
warmup: 3,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl DriftDetector {
|
||
/// Feed the post-session response vector; returns the drift judgment for
|
||
/// this session. The centroid update happens *after* the judgment, so a
|
||
/// drifted session is compared against the pre-drift baseline.
|
||
pub fn update(&mut self, raw: &[f64; VECTOR_DIM]) -> DriftStatus {
|
||
let v = normalize(raw);
|
||
let status = if self.count < self.warmup {
|
||
DriftStatus::Warmup
|
||
} else if drift_distance(&v, &self.centroid) > self.threshold {
|
||
DriftStatus::Drifted
|
||
} else {
|
||
DriftStatus::Stable
|
||
};
|
||
// Welford running-mean update.
|
||
self.count += 1;
|
||
let inv = 1.0 / self.count as f64;
|
||
for (c, x) in self.centroid.iter_mut().zip(v.iter()) {
|
||
*c += (x - *c) * inv;
|
||
}
|
||
status
|
||
}
|
||
|
||
/// Sessions observed so far.
|
||
pub fn sessions(&self) -> u64 {
|
||
self.count
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn profile(tag: &str, freq: f64, peak_score: f64) -> AnonymizedProfile {
|
||
let mut vector = [0.5; VECTOR_DIM];
|
||
vector[5] = 13.0; // breathing_rate in range
|
||
vector[11] = freq; // stimulus_frequency
|
||
AnonymizedProfile {
|
||
profile_tag: tag.into(),
|
||
vector,
|
||
frequency_scores: vec![
|
||
(freq - 1.0, peak_score - 0.2),
|
||
(freq, peak_score),
|
||
(freq + 1.0, peak_score - 0.2),
|
||
],
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn tag_is_one_way_and_stable() {
|
||
let t1 = AnonymizedProfile::tag_for("subject-A");
|
||
let t2 = AnonymizedProfile::tag_for("subject-A");
|
||
assert_eq!(t1, t2);
|
||
assert_eq!(t1.len(), 16);
|
||
assert!(!t1.contains("subject"));
|
||
assert_ne!(t1, AnonymizedProfile::tag_for("subject-B"));
|
||
}
|
||
|
||
#[test]
|
||
fn knn_orders_by_distance_with_deterministic_ties() {
|
||
let mut store = ProfileStore::new();
|
||
store.upsert(profile("a", 38.0, 0.8));
|
||
store.upsert(profile("b", 42.0, 0.8));
|
||
store.upsert(profile("c", 38.0, 0.8)); // identical vector to "a"
|
||
|
||
let mut q = [0.5; VECTOR_DIM];
|
||
q[5] = 13.0;
|
||
q[11] = 38.0;
|
||
let nn = store.k_nearest(&q, 2);
|
||
assert_eq!(nn.len(), 2);
|
||
// "a" (index 0) and "c" (index 2) are equidistant; tie → lower index first.
|
||
assert_eq!(nn[0].0, 0);
|
||
assert_eq!(nn[1].0, 2);
|
||
}
|
||
|
||
#[test]
|
||
fn upsert_replaces_by_tag() {
|
||
let mut store = ProfileStore::new();
|
||
store.upsert(profile("a", 38.0, 0.8));
|
||
store.upsert(profile("a", 42.0, 0.9));
|
||
assert_eq!(store.len(), 1);
|
||
assert_eq!(store.profile(0).unwrap().frequency_scores[1].0, 42.0);
|
||
}
|
||
|
||
#[test]
|
||
fn warm_start_prior_is_noisier_than_real_observations() {
|
||
let mut store = ProfileStore::new();
|
||
store.upsert(profile("a", 39.0, 0.8));
|
||
store.upsert(profile("b", 39.0, 0.7));
|
||
let mut q = [0.5; VECTOR_DIM];
|
||
q[5] = 13.0;
|
||
q[11] = 39.0;
|
||
let base_noise = 1e-4;
|
||
let priors = store.warm_start_prior(&q, 2, base_noise);
|
||
assert!(!priors.is_empty());
|
||
for p in &priors {
|
||
assert!(p.noise_var >= base_noise * ProfileStore::PRIOR_NOISE_FLOOR);
|
||
assert!(p.expected_score.is_finite());
|
||
}
|
||
// The shared peak frequency carries the highest expected score.
|
||
let best = priors
|
||
.iter()
|
||
.max_by(|a, b| a.expected_score.partial_cmp(&b.expected_score).unwrap())
|
||
.unwrap();
|
||
assert_eq!(best.frequency_hz, 39.0);
|
||
}
|
||
|
||
#[test]
|
||
fn warm_start_empty_store_returns_nothing() {
|
||
let store = ProfileStore::new();
|
||
let q = [0.5; VECTOR_DIM];
|
||
assert!(store.warm_start_prior(&q, 3, 1e-4).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn clustering_separates_detuned_groups() {
|
||
let mut store = ProfileStore::new();
|
||
// Two clear groups: peaks near 37 Hz and near 43 Hz.
|
||
for i in 0..4 {
|
||
store.upsert(profile(&format!("lo{i}"), 37.0, 0.8));
|
||
store.upsert(profile(&format!("hi{i}"), 43.0, 0.8));
|
||
}
|
||
let assign = store.cluster(2, 10);
|
||
assert_eq!(assign.len(), 8);
|
||
// All "lo" profiles share a cluster, all "hi" share the other.
|
||
let lo: Vec<usize> = (0..8).step_by(2).map(|i| assign[i]).collect();
|
||
let hi: Vec<usize> = (1..8).step_by(2).map(|i| assign[i]).collect();
|
||
assert!(lo.iter().all(|&c| c == lo[0]));
|
||
assert!(hi.iter().all(|&c| c == hi[0]));
|
||
assert_ne!(lo[0], hi[0]);
|
||
}
|
||
|
||
#[test]
|
||
fn clustering_is_deterministic() {
|
||
let mut store = ProfileStore::new();
|
||
for i in 0..6 {
|
||
store.upsert(profile(&format!("p{i}"), 36.0 + i as f64, 0.7));
|
||
}
|
||
assert_eq!(store.cluster(3, 5), store.cluster(3, 5));
|
||
}
|
||
|
||
#[test]
|
||
fn drift_detector_warmup_then_stable_then_drift() {
|
||
let mut d = DriftDetector::default();
|
||
let mut calm = [0.5; VECTOR_DIM];
|
||
calm[5] = 13.0;
|
||
// Warmup sessions.
|
||
for _ in 0..3 {
|
||
assert_eq!(d.update(&calm), DriftStatus::Warmup);
|
||
}
|
||
// Consistent sessions are stable.
|
||
assert_eq!(d.update(&calm), DriftStatus::Stable);
|
||
// A strongly departed vector flags drift.
|
||
let mut shifted = calm;
|
||
shifted[3] = 0.0; // gamma gain collapsed
|
||
shifted[7] = 1.0; // motion artifact saturated
|
||
shifted[10] = 1.0; // restlessness saturated
|
||
shifted[17] = 0.0; // comfort collapsed
|
||
assert_eq!(d.update(&shifted), DriftStatus::Drifted);
|
||
}
|
||
|
||
#[test]
|
||
fn normalize_handles_non_finite() {
|
||
let mut v = [0.5; VECTOR_DIM];
|
||
v[0] = f64::NAN;
|
||
v[5] = f64::INFINITY;
|
||
let n = normalize(&v);
|
||
assert_eq!(n[0], 0.0);
|
||
assert_eq!(n[5], 0.0);
|
||
}
|
||
}
|