Watch
1
0
Fork
You've already forked souveraine
0

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:
Fimeg 2026-08-15 15:03:46 -04:00
commit f8fccd3ee2
3 changed files with 588 additions and 0 deletions

View file

@ -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::*;

View file

@ -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
View 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"
)));
}
}