nervous: gauges and glands are different afferents
A battery at .42 is still .42 when nobody looks — only confidence in an unrefreshed reading decays. Feeding a fuel gauge into an accumulator would drain the pack because the reporter went quiet, which is the empty-result trap wearing a units label. Field keeps exudates; Level keeps readings. sessiond mounts the somatic types the way machined mounts seed.rs. The fields are its own — SOMATIC_NERVOUS_SYSTEM.md answers open question 1 with "sessiond, period".
This commit is contained in:
parent
a3dfe301b7
commit
46876865c9
2 changed files with 233 additions and 30 deletions
|
|
@ -17,6 +17,18 @@
|
|||
#[path = "../sessiond/mod.rs"]
|
||||
pub(crate) mod sessiond;
|
||||
|
||||
// The somatic vocabulary, mounted the way machined mounts `identity/seed.rs`.
|
||||
// It lives under `core/nervous/` because the agent side shares the types, but
|
||||
// the *fields* are sessiond's — SOMATIC_NERVOUS_SYSTEM.md resolves open
|
||||
// question 1 with "sessiond, period", and a second holder of the body's state
|
||||
// would be the fifth blind actor. `use super::belief` inside plexus resolves
|
||||
// to this crate root here and to `core::nervous` there, so one file serves
|
||||
// both without a copy.
|
||||
#[path = "../core/nervous/belief.rs"]
|
||||
pub(crate) mod belief;
|
||||
#[path = "../core/nervous/plexus.rs"]
|
||||
pub(crate) mod plexus;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
|
|
|
|||
|
|
@ -48,8 +48,9 @@ pub const ALLOSTATIC_THRESHOLD: f32 = 0.7;
|
|||
#[derive(Debug, Clone)]
|
||||
pub struct SensorReading {
|
||||
pub source: String,
|
||||
/// Secretion amount: positive adds, negative drains. The caller decides
|
||||
/// the magnitude; the plexus only accumulates.
|
||||
/// Read as a secretion by an exudate field (positive adds, negative
|
||||
/// drains) and as the present reading by a gauge. Which one it is comes
|
||||
/// from how the afferent was declared, never from the reporter.
|
||||
pub amount: f32,
|
||||
}
|
||||
|
||||
|
|
@ -170,15 +171,119 @@ impl Viability {
|
|||
}
|
||||
}
|
||||
|
||||
/// One named field, its side of the boundary, and its bounds.
|
||||
/// A measured level, as opposed to a secretion.
|
||||
///
|
||||
/// The distinction the ladder already draws between exudate and belief. A
|
||||
/// battery at .42 is still at .42 when nobody looks, so the *value* must not
|
||||
/// decay — only confidence in a reading nobody has refreshed. Putting a fuel
|
||||
/// gauge into an accumulator would make the pack drain because the reporter
|
||||
/// went quiet, which is the empty-result trap wearing a units label.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Level {
|
||||
name: String,
|
||||
value: f32,
|
||||
observed_at: DateTime<Utc>,
|
||||
prior: Option<(f32, DateTime<Utc>)>,
|
||||
half_life: Duration,
|
||||
}
|
||||
|
||||
impl Level {
|
||||
fn new(name: impl Into<String>, half_life: Duration) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
value: 0.0,
|
||||
observed_at: DateTime::<Utc>::MIN_UTC,
|
||||
prior: None,
|
||||
half_life,
|
||||
}
|
||||
}
|
||||
|
||||
fn observe(&mut self, value: f32, now: DateTime<Utc>) {
|
||||
if self.seen() {
|
||||
self.prior = Some((self.value, self.observed_at));
|
||||
}
|
||||
self.value = value.clamp(0.0, 1.0);
|
||||
self.observed_at = now;
|
||||
}
|
||||
|
||||
fn seen(&self) -> bool {
|
||||
self.observed_at != DateTime::<Utc>::MIN_UTC
|
||||
}
|
||||
|
||||
fn velocity(&self) -> f32 {
|
||||
let Some((pv, pt)) = self.prior else {
|
||||
return 0.0;
|
||||
};
|
||||
let dt = (self.observed_at - pt).num_milliseconds() as f32 / 1000.0;
|
||||
if dt <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
(self.value - pv) / dt
|
||||
}
|
||||
|
||||
fn as_belief(&self, now: DateTime<Utc>) -> Belief<f32> {
|
||||
if !self.seen() {
|
||||
return Belief::unknown(now);
|
||||
}
|
||||
Belief {
|
||||
value: Some(self.value),
|
||||
confidence: 1.0,
|
||||
trend: Trend::from_velocity(self.velocity()),
|
||||
persistence: Duration::from_millis(
|
||||
(now - self.prior.map_or(self.observed_at, |(_, t)| t))
|
||||
.num_milliseconds()
|
||||
.max(0) as u64,
|
||||
),
|
||||
observed_at: self.observed_at,
|
||||
half_life: self.half_life,
|
||||
sources: vec![self.name.clone()],
|
||||
conflicts: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a named afferent carries.
|
||||
#[derive(Debug)]
|
||||
enum Afferent {
|
||||
/// Secretions that stack and fade. The weather changed.
|
||||
Exudate(Field),
|
||||
/// A gauge. The value stands until re-read; the confidence does not.
|
||||
Level(Level),
|
||||
}
|
||||
|
||||
/// One named afferent, its side of the boundary, and its bounds.
|
||||
#[derive(Debug)]
|
||||
struct FieldEntry {
|
||||
name: String,
|
||||
field: Field,
|
||||
afferent: Afferent,
|
||||
locus: Locus,
|
||||
viability: Option<Viability>,
|
||||
}
|
||||
|
||||
impl FieldEntry {
|
||||
fn belief(&self, now: DateTime<Utc>) -> Belief<f32> {
|
||||
match &self.afferent {
|
||||
Afferent::Exudate(f) => f.as_belief(now),
|
||||
Afferent::Level(l) => l.as_belief(now),
|
||||
}
|
||||
}
|
||||
|
||||
/// The present magnitude, for the viability arithmetic.
|
||||
fn magnitude(&self, now: DateTime<Utc>) -> f32 {
|
||||
match &self.afferent {
|
||||
Afferent::Exudate(f) => f.level_at(now),
|
||||
Afferent::Level(l) => l.value,
|
||||
}
|
||||
}
|
||||
|
||||
fn velocity(&self) -> f32 {
|
||||
match &self.afferent {
|
||||
Afferent::Exudate(f) => f.velocity(),
|
||||
Afferent::Level(l) => l.velocity(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The body's somatic plexus.
|
||||
#[derive(Debug)]
|
||||
pub struct SomaticPlexus {
|
||||
|
|
@ -212,7 +317,7 @@ impl SomaticPlexus {
|
|||
}
|
||||
}
|
||||
|
||||
/// A field reporting the world. Nothing she does moves it.
|
||||
/// An exudate field reporting the world. Nothing she does moves it.
|
||||
pub fn with_boundary_field(
|
||||
mut self,
|
||||
name: impl Into<String>,
|
||||
|
|
@ -221,7 +326,7 @@ impl SomaticPlexus {
|
|||
) -> Self {
|
||||
let name = name.into();
|
||||
self.fields.push(FieldEntry {
|
||||
field: Field::new(&name, half_life, now),
|
||||
afferent: Afferent::Exudate(Field::new(&name, half_life, now)),
|
||||
name,
|
||||
locus: Locus::Boundary,
|
||||
viability: None,
|
||||
|
|
@ -229,8 +334,8 @@ impl SomaticPlexus {
|
|||
self
|
||||
}
|
||||
|
||||
/// A field inside the boundary. `regulator` is the verb that reaches it;
|
||||
/// `None` records a §13 defect rather than hiding one.
|
||||
/// An exudate field inside the boundary. `regulator` is the verb that
|
||||
/// reaches it; `None` records a §13 defect rather than hiding one.
|
||||
pub fn with_inner_field(
|
||||
mut self,
|
||||
name: impl Into<String>,
|
||||
|
|
@ -241,7 +346,43 @@ impl SomaticPlexus {
|
|||
) -> Self {
|
||||
let name = name.into();
|
||||
self.fields.push(FieldEntry {
|
||||
field: Field::new(&name, half_life, now),
|
||||
afferent: Afferent::Exudate(Field::new(&name, half_life, now)),
|
||||
name,
|
||||
locus: Locus::Inner {
|
||||
regulator: regulator.map(str::to_string),
|
||||
},
|
||||
viability,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// A gauge at the boundary — measured, not secreted.
|
||||
pub fn with_boundary_level(
|
||||
mut self,
|
||||
name: impl Into<String>,
|
||||
half_life: Duration,
|
||||
) -> Self {
|
||||
let name = name.into();
|
||||
self.fields.push(FieldEntry {
|
||||
afferent: Afferent::Level(Level::new(&name, half_life)),
|
||||
name,
|
||||
locus: Locus::Boundary,
|
||||
viability: None,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// A gauge inside the boundary — charge, thermal, load.
|
||||
pub fn with_inner_level(
|
||||
mut self,
|
||||
name: impl Into<String>,
|
||||
half_life: Duration,
|
||||
regulator: Option<&str>,
|
||||
viability: Option<Viability>,
|
||||
) -> Self {
|
||||
let name = name.into();
|
||||
self.fields.push(FieldEntry {
|
||||
afferent: Afferent::Level(Level::new(&name, half_life)),
|
||||
name,
|
||||
locus: Locus::Inner {
|
||||
regulator: regulator.map(str::to_string),
|
||||
|
|
@ -270,8 +411,20 @@ impl SomaticPlexus {
|
|||
self.fields.iter().find(|e| e.name == name)
|
||||
}
|
||||
|
||||
/// The exudate field by that name, if it is one. Gauges answer `None`
|
||||
/// here and through [`Self::magnitude`] instead.
|
||||
pub fn field(&self, name: &str) -> Option<&Field> {
|
||||
self.entry(name).map(|e| &e.field)
|
||||
match &self.entry(name)?.afferent {
|
||||
Afferent::Exudate(f) => Some(f),
|
||||
Afferent::Level(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Present magnitude of any afferent, exudate or gauge. `None` when it
|
||||
/// has never spoken — which is not zero.
|
||||
pub fn magnitude(&self, name: &str, now: DateTime<Utc>) -> Option<f32> {
|
||||
let e = self.entry(name)?;
|
||||
e.belief(now).is_known().then(|| e.magnitude(now))
|
||||
}
|
||||
|
||||
pub fn locus(&self, name: &str) -> Option<&Locus> {
|
||||
|
|
@ -298,11 +451,11 @@ impl SomaticPlexus {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Beliefs paired with their field names.
|
||||
/// Beliefs paired with their afferent names.
|
||||
pub fn named_beliefs(&self, now: DateTime<Utc>) -> Vec<(&str, Belief<f32>)> {
|
||||
self.fields
|
||||
.iter()
|
||||
.map(|e| (e.name.as_str(), e.field.as_belief(now)))
|
||||
.map(|e| (e.name.as_str(), e.belief(now)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
|
@ -315,12 +468,12 @@ impl SomaticPlexus {
|
|||
pub fn allostatic_pressure(&self, name: &str, now: DateTime<Utc>) -> Option<f32> {
|
||||
let e = self.entry(name)?;
|
||||
let v = e.viability.as_ref()?;
|
||||
if !e.field.as_belief(now).is_known() {
|
||||
if !e.belief(now).is_known() {
|
||||
return None;
|
||||
}
|
||||
let level = e.field.level_at(now);
|
||||
let level = e.magnitude(now);
|
||||
let proximity = v.boundary_proximity(level);
|
||||
let Some(eta) = v.eta_secs(level, e.field.velocity()) else {
|
||||
let Some(eta) = v.eta_secs(level, e.velocity()) else {
|
||||
return Some(proximity);
|
||||
};
|
||||
let horizon = ALLOSTATIC_HORIZON.as_secs_f64();
|
||||
|
|
@ -352,10 +505,9 @@ impl SomaticPlexus {
|
|||
.iter()
|
||||
.filter_map(|e| {
|
||||
let v = e.viability.as_ref()?;
|
||||
e.field
|
||||
.as_belief(now)
|
||||
e.belief(now)
|
||||
.is_known()
|
||||
.then(|| v.deviation(e.field.level_at(now)))
|
||||
.then(|| v.deviation(e.magnitude(now)))
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
|
@ -422,7 +574,7 @@ impl SomaticPlexus {
|
|||
.fields
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
let belief = e.field.as_belief(now);
|
||||
let belief = e.belief(now);
|
||||
if !belief.is_known() {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -466,7 +618,7 @@ impl SomaticPlexus {
|
|||
}
|
||||
let v = e.viability.as_ref()?;
|
||||
let eta = v
|
||||
.eta_secs(e.field.level_at(now), e.field.velocity())
|
||||
.eta_secs(e.magnitude(now), e.velocity())
|
||||
.map(|s| s.max(0.0) as u64);
|
||||
Some((e.name.clone(), pressure, eta))
|
||||
})
|
||||
|
|
@ -516,7 +668,13 @@ impl NervousNode for SomaticPlexus {
|
|||
self.health.insert(input.source.clone(), SourceHealth::Live);
|
||||
|
||||
if let Some(e) = self.fields.iter_mut().find(|e| e.name == input.source) {
|
||||
e.field.secrete(input.amount, now);
|
||||
// The afferent's declared kind decides what the number means, so
|
||||
// a reporter never has to know whether it is feeding a gauge or
|
||||
// a gland.
|
||||
match &mut e.afferent {
|
||||
Afferent::Exudate(f) => f.secrete(input.amount, now),
|
||||
Afferent::Level(l) => l.observe(input.amount, now),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -527,7 +685,7 @@ impl NervousNode for SomaticPlexus {
|
|||
}
|
||||
|
||||
fn beliefs(&self, now: DateTime<Utc>) -> Vec<Belief<f32>> {
|
||||
self.fields.iter().map(|e| e.field.as_belief(now)).collect()
|
||||
self.fields.iter().map(|e| e.belief(now)).collect()
|
||||
}
|
||||
|
||||
fn notable(&mut self) -> Vec<SomaticEvent> {
|
||||
|
|
@ -558,26 +716,23 @@ mod tests {
|
|||
.with_boundary_field("motion", Duration::from_secs(60), now)
|
||||
.with_boundary_field("light", Duration::from_secs(120), now)
|
||||
.with_boundary_field("touch", Duration::from_secs(10), now)
|
||||
.with_inner_field(
|
||||
.with_inner_level(
|
||||
"charge",
|
||||
Duration::from_secs(300),
|
||||
Some("doze"),
|
||||
Some(Viability::floor(0.2)),
|
||||
now,
|
||||
)
|
||||
.with_inner_field(
|
||||
.with_inner_level(
|
||||
"thermal",
|
||||
Duration::from_secs(120),
|
||||
Some("doze"),
|
||||
Some(Viability::ceiling(0.8)),
|
||||
now,
|
||||
)
|
||||
.with_inner_field(
|
||||
.with_inner_level(
|
||||
"port_mode",
|
||||
Duration::from_secs(600),
|
||||
Some("set_usb_mode"),
|
||||
None,
|
||||
now,
|
||||
)
|
||||
.with_expected("proximity")
|
||||
.with_expected("light")
|
||||
|
|
@ -613,6 +768,41 @@ mod tests {
|
|||
assert!(late < 0.15, "two half-lives leaves little: {late}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gauge_holds_its_value_while_an_exudate_fades() {
|
||||
let mut p = phone_plexus();
|
||||
p.ingest(read("charge", 0.42), t0());
|
||||
p.ingest(read("proximity", 0.5), t0());
|
||||
|
||||
// Ten minutes of silence. The battery is still where it was; the
|
||||
// secretion is not.
|
||||
let charge = p.magnitude("charge", after(600)).unwrap();
|
||||
let prox = p.magnitude("proximity", after(600)).unwrap();
|
||||
assert!(
|
||||
(charge - 0.42).abs() < 0.001,
|
||||
"a pack does not drain because nobody looked: {charge}"
|
||||
);
|
||||
assert!(prox < 0.05, "an exudate fades: {prox}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unread_gauge_loses_confidence_but_not_its_reading() {
|
||||
let mut p = phone_plexus();
|
||||
p.ingest(read("charge", 0.42), t0());
|
||||
|
||||
let fresh = p.named_beliefs(t0());
|
||||
let stale = p.named_beliefs(after(900));
|
||||
let pick = |bs: &Vec<(&str, Belief<f32>)>, at: DateTime<Utc>| {
|
||||
let b = bs.iter().find(|(n, _)| *n == "charge").unwrap().1.clone();
|
||||
(b.value.unwrap(), b.confidence_at(at))
|
||||
};
|
||||
let (v0, c0) = pick(&fresh, t0());
|
||||
let (v1, c1) = pick(&stale, after(900));
|
||||
|
||||
assert_eq!(v0, v1, "the reading stands");
|
||||
assert!(c1 < c0 * 0.2, "trust in it does not: {c0} -> {c1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsecreted_fields_report_unknown_not_zero() {
|
||||
let p = phone_plexus();
|
||||
|
|
@ -822,7 +1012,8 @@ mod tests {
|
|||
p.ingest(read("charge", 0.9), t0());
|
||||
let resting = p.allostatic_pressure("charge", after(1)).unwrap();
|
||||
|
||||
p.ingest(read("charge", -0.35), after(2));
|
||||
// A gauge is *told* its new reading; it is not decremented.
|
||||
p.ingest(read("charge", 0.55), after(2));
|
||||
let draining = p.allostatic_pressure("charge", after(2)).unwrap();
|
||||
assert!(
|
||||
draining > resting,
|
||||
|
|
@ -834,7 +1025,7 @@ mod tests {
|
|||
fn viability_threat_fires_on_the_edge_only() {
|
||||
let mut p = phone_plexus();
|
||||
p.ingest(read("charge", 0.9), t0());
|
||||
p.ingest(read("charge", -0.6), after(1));
|
||||
p.ingest(read("charge", 0.30), after(1));
|
||||
p.tick(after(1));
|
||||
|
||||
let threats: Vec<_> = p
|
||||
|
|
|
|||
Loading…
Reference in a new issue