nervous: somatic plexus, NervousNode trait
Fields replace booleans: accumulation with half-life decay, velocity, trend detection. Source health monitoring closes the gap the mapping session found — a dead nerve now reads as silent, not calm. Thirteen tests.
This commit is contained in:
parent
2fa9221066
commit
f8fccd3ee2
3 changed files with 588 additions and 0 deletions
|
|
@ -268,6 +268,44 @@ impl Field {
|
|||
}
|
||||
}
|
||||
|
||||
// ── The somatic contract ──────────────────────────────────────────
|
||||
|
||||
/// An event worth waking for. Generated at threshold crossings and
|
||||
/// source health transitions, consumed on read — each edge fires once.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum SomaticEvent {
|
||||
/// A field's trend changed — the body shifted.
|
||||
TrendShift {
|
||||
field: String,
|
||||
from: Trend,
|
||||
to: Trend,
|
||||
level: f32,
|
||||
},
|
||||
/// An expected source went silent past the health threshold.
|
||||
SourceSilent {
|
||||
source: String,
|
||||
silent_for_secs: u64,
|
||||
},
|
||||
/// A previously silent source resumed reporting.
|
||||
SourceRecovered { source: String },
|
||||
}
|
||||
|
||||
/// The contract every rung of the somatic ladder implements.
|
||||
///
|
||||
/// Diverges from the doc in two places, both for the read-without-mutate
|
||||
/// principle: `beliefs` takes `now` so decay is current at the reader's
|
||||
/// instant, and returns `Vec` because decayed values are computed rather
|
||||
/// than stored.
|
||||
pub trait NervousNode {
|
||||
type Input;
|
||||
type Output;
|
||||
|
||||
fn ingest(&mut self, input: Self::Input, now: DateTime<Utc>);
|
||||
fn tick(&mut self, now: DateTime<Utc>);
|
||||
fn beliefs(&self, now: DateTime<Utc>) -> Vec<Belief<Self::Output>>;
|
||||
fn notable(&mut self) -> Vec<SomaticEvent>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ pub mod cron;
|
|||
pub mod event_log;
|
||||
pub mod handler;
|
||||
pub mod pending;
|
||||
pub mod plexus;
|
||||
pub mod turn_dispatcher;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
|
|
|||
549
src/core/nervous/plexus.rs
Normal file
549
src/core/nervous/plexus.rs
Normal file
|
|
@ -0,0 +1,549 @@
|
|||
//! The somatic plexus — regional integration of sensor fields.
|
||||
//!
|
||||
//! Replaces boolean evidence with Fields that accumulate, decay, and
|
||||
//! carry momentum. Monitors source health — the piece the nervous side
|
||||
//! was missing. Emits notable events at threshold crossings, once per
|
||||
//! edge.
|
||||
//!
|
||||
//! Fields are named, not struct'd, so new sensors arrive as builder
|
||||
//! calls rather than struct changes. The phone's plexus currently
|
||||
//! carries:
|
||||
//!
|
||||
//! - **iio sensors**: proximity, motion (accel), light, touch
|
||||
//! - **power**: charge rate, charger online
|
||||
//! - **USB-C port**: attached, data role (host/device), gadget mode,
|
||||
//! peer display (GUD/smoo), peer input (HID endpoints), peer network
|
||||
//! (NCM). usb-signaller + smoo make these first-class.
|
||||
//! - **grip** (owed): per-gauge strain, not compressed to a boolean
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::belief::{Belief, Field, NervousNode, SomaticEvent, Trend};
|
||||
|
||||
/// A reading entering the plexus.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SensorReading {
|
||||
pub source: String,
|
||||
/// Secretion amount: positive adds, negative drains. The caller
|
||||
/// decides the magnitude; the plexus only accumulates.
|
||||
pub amount: f32,
|
||||
}
|
||||
|
||||
/// Whether a source is reporting.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SourceHealth {
|
||||
#[default]
|
||||
Unknown,
|
||||
Live,
|
||||
/// Was Live, now silent past the threshold.
|
||||
Silent,
|
||||
/// Expected on this body and has never once spoken.
|
||||
Absent,
|
||||
}
|
||||
|
||||
/// The body's somatic plexus.
|
||||
#[derive(Debug)]
|
||||
pub struct SomaticPlexus {
|
||||
fields: Vec<(String, Field)>,
|
||||
expected: Vec<String>,
|
||||
last_seen: HashMap<String, DateTime<Utc>>,
|
||||
health: HashMap<String, SourceHealth>,
|
||||
silent_after: Duration,
|
||||
absent_after: Duration,
|
||||
started_at: DateTime<Utc>,
|
||||
last_trend: HashMap<String, Trend>,
|
||||
pending: Vec<SomaticEvent>,
|
||||
}
|
||||
|
||||
impl SomaticPlexus {
|
||||
pub fn new(now: DateTime<Utc>) -> Self {
|
||||
Self {
|
||||
fields: Vec::new(),
|
||||
expected: Vec::new(),
|
||||
last_seen: HashMap::new(),
|
||||
health: HashMap::new(),
|
||||
silent_after: Duration::from_secs(90),
|
||||
absent_after: Duration::from_secs(300),
|
||||
started_at: now,
|
||||
last_trend: HashMap::new(),
|
||||
pending: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_field(
|
||||
mut self,
|
||||
name: impl Into<String>,
|
||||
half_life: Duration,
|
||||
now: DateTime<Utc>,
|
||||
) -> Self {
|
||||
let name = name.into();
|
||||
self.fields
|
||||
.push((name.clone(), Field::new(&name, half_life, now)));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_expected(mut self, source: impl Into<String>) -> Self {
|
||||
self.expected.push(source.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_silent_after(mut self, d: Duration) -> Self {
|
||||
self.silent_after = d;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_absent_after(mut self, d: Duration) -> Self {
|
||||
self.absent_after = d;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn field(&self, name: &str) -> Option<&Field> {
|
||||
self.fields
|
||||
.iter()
|
||||
.find(|(n, _)| n == name)
|
||||
.map(|(_, f)| f)
|
||||
}
|
||||
|
||||
fn field_mut(&mut self, name: &str) -> Option<&mut Field> {
|
||||
self.fields
|
||||
.iter_mut()
|
||||
.find(|(n, _)| n == name)
|
||||
.map(|(_, f)| f)
|
||||
}
|
||||
|
||||
pub fn source_health(&self, name: &str) -> SourceHealth {
|
||||
self.health
|
||||
.get(name)
|
||||
.copied()
|
||||
.unwrap_or(SourceHealth::Unknown)
|
||||
}
|
||||
|
||||
/// Beliefs paired with their field names.
|
||||
pub fn named_beliefs(&self, now: DateTime<Utc>) -> Vec<(&str, Belief<f32>)> {
|
||||
self.fields
|
||||
.iter()
|
||||
.map(|(name, field)| (name.as_str(), field.as_belief(now)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn evaluate_health(&mut self, now: DateTime<Utc>) {
|
||||
let newly_silent: Vec<(String, u64)> = self
|
||||
.last_seen
|
||||
.iter()
|
||||
.filter_map(|(source, &last)| {
|
||||
let elapsed = (now - last).num_seconds().max(0) as u64;
|
||||
let current = self.health.get(source).copied().unwrap_or(SourceHealth::Unknown);
|
||||
if elapsed > self.silent_after.as_secs() && current == SourceHealth::Live {
|
||||
Some((source.clone(), elapsed))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
for (source, elapsed) in newly_silent {
|
||||
self.health.insert(source.clone(), SourceHealth::Silent);
|
||||
self.pending.push(SomaticEvent::SourceSilent {
|
||||
source,
|
||||
silent_for_secs: elapsed,
|
||||
});
|
||||
}
|
||||
|
||||
let boot_secs = (now - self.started_at).num_seconds().max(0) as u64;
|
||||
if boot_secs > self.absent_after.as_secs() {
|
||||
let newly_absent: Vec<String> = self
|
||||
.expected
|
||||
.iter()
|
||||
.filter(|source| {
|
||||
!self.last_seen.contains_key(*source)
|
||||
&& self.health.get(*source).copied() != Some(SourceHealth::Absent)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
for source in newly_absent {
|
||||
self.health.insert(source.clone(), SourceHealth::Absent);
|
||||
self.pending.push(SomaticEvent::SourceSilent {
|
||||
source,
|
||||
silent_for_secs: boot_secs,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_trend_shifts(&mut self, now: DateTime<Utc>) {
|
||||
let shifts: Vec<(String, Trend, Trend, f32)> = self
|
||||
.fields
|
||||
.iter()
|
||||
.filter_map(|(name, field)| {
|
||||
let belief = field.as_belief(now);
|
||||
if !belief.is_known() {
|
||||
return None;
|
||||
}
|
||||
let current = belief.trend;
|
||||
let previous = self.last_trend.get(name).copied().unwrap_or(Trend::Stable);
|
||||
if current != previous {
|
||||
Some((name.clone(), previous, current, belief.value.unwrap_or(0.0)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
for (name, from, to, level) in shifts {
|
||||
self.last_trend.insert(name.clone(), to);
|
||||
self.pending.push(SomaticEvent::TrendShift {
|
||||
field: name,
|
||||
from,
|
||||
to,
|
||||
level,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NervousNode for SomaticPlexus {
|
||||
type Input = SensorReading;
|
||||
type Output = f32;
|
||||
|
||||
fn ingest(&mut self, input: SensorReading, now: DateTime<Utc>) {
|
||||
self.last_seen.insert(input.source.clone(), now);
|
||||
|
||||
let was = self
|
||||
.health
|
||||
.get(&input.source)
|
||||
.copied()
|
||||
.unwrap_or(SourceHealth::Unknown);
|
||||
if matches!(was, SourceHealth::Silent | SourceHealth::Absent) {
|
||||
self.pending.push(SomaticEvent::SourceRecovered {
|
||||
source: input.source.clone(),
|
||||
});
|
||||
}
|
||||
self.health
|
||||
.insert(input.source.clone(), SourceHealth::Live);
|
||||
|
||||
if let Some(field) = self.field_mut(&input.source) {
|
||||
field.secrete(input.amount, now);
|
||||
}
|
||||
}
|
||||
|
||||
fn tick(&mut self, now: DateTime<Utc>) {
|
||||
self.evaluate_health(now);
|
||||
self.detect_trend_shifts(now);
|
||||
}
|
||||
|
||||
fn beliefs(&self, now: DateTime<Utc>) -> Vec<Belief<f32>> {
|
||||
self.fields
|
||||
.iter()
|
||||
.map(|(_, field)| field.as_belief(now))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn notable(&mut self) -> Vec<SomaticEvent> {
|
||||
std::mem::take(&mut self.pending)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn t0() -> DateTime<Utc> {
|
||||
DateTime::parse_from_rfc3339("2026-08-15T14:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc)
|
||||
}
|
||||
|
||||
fn after(secs: i64) -> DateTime<Utc> {
|
||||
t0() + chrono::Duration::seconds(secs)
|
||||
}
|
||||
|
||||
fn phone_plexus() -> SomaticPlexus {
|
||||
let now = t0();
|
||||
SomaticPlexus::new(now)
|
||||
.with_field("proximity", Duration::from_secs(30), now)
|
||||
.with_field("motion", Duration::from_secs(60), now)
|
||||
.with_field("light", Duration::from_secs(120), now)
|
||||
.with_field("touch", Duration::from_secs(10), now)
|
||||
.with_field("charge", Duration::from_secs(300), now)
|
||||
.with_expected("proximity")
|
||||
.with_expected("light")
|
||||
.with_expected("charge")
|
||||
.with_silent_after(Duration::from_secs(90))
|
||||
.with_absent_after(Duration::from_secs(300))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_accumulate_on_repeated_secretions() {
|
||||
let mut p = phone_plexus();
|
||||
for i in 0..5 {
|
||||
p.ingest(
|
||||
SensorReading {
|
||||
source: "proximity".into(),
|
||||
amount: 0.15,
|
||||
},
|
||||
after(i * 2),
|
||||
);
|
||||
}
|
||||
let level = p.field("proximity").unwrap().level_at(after(8));
|
||||
assert!(level > 0.5, "five secretions should accumulate: {level}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_decay_when_untouched() {
|
||||
let mut p = phone_plexus();
|
||||
p.ingest(
|
||||
SensorReading {
|
||||
source: "proximity".into(),
|
||||
amount: 0.5,
|
||||
},
|
||||
t0(),
|
||||
);
|
||||
let early = p.field("proximity").unwrap().level_at(after(5));
|
||||
let late = p.field("proximity").unwrap().level_at(after(60));
|
||||
assert!(late < early, "should decay: early={early}, late={late}");
|
||||
assert!(
|
||||
late < 0.15,
|
||||
"after two half-lives, well below initial: {late}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_goes_silent_after_threshold() {
|
||||
let mut p = phone_plexus();
|
||||
p.ingest(
|
||||
SensorReading {
|
||||
source: "proximity".into(),
|
||||
amount: 0.2,
|
||||
},
|
||||
t0(),
|
||||
);
|
||||
assert_eq!(p.source_health("proximity"), SourceHealth::Live);
|
||||
|
||||
p.tick(after(100));
|
||||
assert_eq!(p.source_health("proximity"), SourceHealth::Silent);
|
||||
|
||||
let events = p.notable();
|
||||
assert!(events.iter().any(|e| matches!(
|
||||
e,
|
||||
SomaticEvent::SourceSilent { source, .. } if source == "proximity"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expected_source_absent_if_never_reported() {
|
||||
let mut p = phone_plexus();
|
||||
p.tick(after(310));
|
||||
assert_eq!(p.source_health("charge"), SourceHealth::Absent);
|
||||
|
||||
let events = p.notable();
|
||||
assert!(events.iter().any(|e| matches!(
|
||||
e,
|
||||
SomaticEvent::SourceSilent { source, .. } if source == "charge"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silent_source_recovers_on_report() {
|
||||
let mut p = phone_plexus();
|
||||
p.ingest(
|
||||
SensorReading {
|
||||
source: "proximity".into(),
|
||||
amount: 0.2,
|
||||
},
|
||||
t0(),
|
||||
);
|
||||
p.tick(after(100));
|
||||
p.notable();
|
||||
|
||||
p.ingest(
|
||||
SensorReading {
|
||||
source: "proximity".into(),
|
||||
amount: 0.2,
|
||||
},
|
||||
after(101),
|
||||
);
|
||||
assert_eq!(p.source_health("proximity"), SourceHealth::Live);
|
||||
|
||||
let events = p.notable();
|
||||
assert!(events.iter().any(|e| matches!(
|
||||
e,
|
||||
SomaticEvent::SourceRecovered { source } if source == "proximity"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notable_events_are_consumed_on_read() {
|
||||
let mut p = phone_plexus();
|
||||
p.ingest(
|
||||
SensorReading {
|
||||
source: "proximity".into(),
|
||||
amount: 0.2,
|
||||
},
|
||||
t0(),
|
||||
);
|
||||
p.tick(after(100));
|
||||
assert!(!p.notable().is_empty());
|
||||
assert!(p.notable().is_empty(), "second read must be empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trend_shifts_are_detected() {
|
||||
let mut p = phone_plexus();
|
||||
p.ingest(
|
||||
SensorReading {
|
||||
source: "motion".into(),
|
||||
amount: 0.1,
|
||||
},
|
||||
t0(),
|
||||
);
|
||||
p.ingest(
|
||||
SensorReading {
|
||||
source: "motion".into(),
|
||||
amount: 0.4,
|
||||
},
|
||||
after(1),
|
||||
);
|
||||
p.tick(after(1));
|
||||
|
||||
let events = p.notable();
|
||||
let shift = events.iter().find(|e| {
|
||||
matches!(
|
||||
e,
|
||||
SomaticEvent::TrendShift { field, .. } if field == "motion"
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
shift.is_some(),
|
||||
"should detect motion trending up: {events:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trend_shifts_fire_once_per_edge() {
|
||||
let mut p = phone_plexus();
|
||||
p.ingest(
|
||||
SensorReading {
|
||||
source: "motion".into(),
|
||||
amount: 0.1,
|
||||
},
|
||||
t0(),
|
||||
);
|
||||
p.ingest(
|
||||
SensorReading {
|
||||
source: "motion".into(),
|
||||
amount: 0.4,
|
||||
},
|
||||
after(1),
|
||||
);
|
||||
p.tick(after(1));
|
||||
p.notable();
|
||||
|
||||
p.tick(after(2));
|
||||
let events = p.notable();
|
||||
let shifts: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
e,
|
||||
SomaticEvent::TrendShift { field, .. } if field == "motion"
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
shifts.is_empty(),
|
||||
"same trend should not re-fire: {shifts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beliefs_reflect_accumulated_state() {
|
||||
let mut p = phone_plexus();
|
||||
p.ingest(
|
||||
SensorReading {
|
||||
source: "proximity".into(),
|
||||
amount: 0.5,
|
||||
},
|
||||
t0(),
|
||||
);
|
||||
p.ingest(
|
||||
SensorReading {
|
||||
source: "motion".into(),
|
||||
amount: 0.3,
|
||||
},
|
||||
t0(),
|
||||
);
|
||||
|
||||
let beliefs = p.beliefs(after(1));
|
||||
assert_eq!(beliefs.len(), 5);
|
||||
assert!(beliefs[0].is_known());
|
||||
assert!(beliefs[0].value.unwrap() > 0.3);
|
||||
assert!(beliefs[1].is_known());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_beliefs_pair_fields_with_names() {
|
||||
let mut p = phone_plexus();
|
||||
p.ingest(
|
||||
SensorReading {
|
||||
source: "proximity".into(),
|
||||
amount: 0.5,
|
||||
},
|
||||
t0(),
|
||||
);
|
||||
|
||||
let named = p.named_beliefs(after(1));
|
||||
assert_eq!(named[0].0, "proximity");
|
||||
assert!(named[0].1.is_known());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_source_with_no_field_is_health_tracked_only() {
|
||||
let mut p = phone_plexus();
|
||||
p.ingest(
|
||||
SensorReading {
|
||||
source: "usb_peer".into(),
|
||||
amount: 1.0,
|
||||
},
|
||||
t0(),
|
||||
);
|
||||
assert_eq!(p.source_health("usb_peer"), SourceHealth::Live);
|
||||
assert_eq!(p.fields.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsecretd_fields_report_unknown_not_zero() {
|
||||
let p = phone_plexus();
|
||||
for b in &p.beliefs(after(5)) {
|
||||
assert!(!b.is_known(), "never secreted = unknown, not zero");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_source_recovers_on_first_report() {
|
||||
let mut p = phone_plexus();
|
||||
p.tick(after(310));
|
||||
assert_eq!(p.source_health("charge"), SourceHealth::Absent);
|
||||
p.notable();
|
||||
|
||||
p.ingest(
|
||||
SensorReading {
|
||||
source: "charge".into(),
|
||||
amount: 0.1,
|
||||
},
|
||||
after(311),
|
||||
);
|
||||
assert_eq!(p.source_health("charge"), SourceHealth::Live);
|
||||
|
||||
let events = p.notable();
|
||||
assert!(events.iter().any(|e| matches!(
|
||||
e,
|
||||
SomaticEvent::SourceRecovered { source } if source == "charge"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue