Watch
1
0
Fork
You've already forked souveraine
0

nervous: the belief contract every rung passes upward

Belief<T> and Field. None is not zero: a source that never spoke must
read as I cannot feel my leg, never as nothing is touching it — the
distinction that hid a real outage for a whole boot (2026-07-27).
Disagreement lowers confidence and elects no winner. 13 tests, unwired.
This commit is contained in:
Fimeg 2026-08-13 14:25:34 -04:00
commit 713f3ac1b2
2 changed files with 419 additions and 0 deletions

418
src/core/nervous/belief.rs Normal file
View file

@ -0,0 +1,418 @@
//! Belief — the contract every rung of the somatic ladder passes upward.
//!
//! Design: `SouveraineOS/docs/substrate/SOMATIC_NERVOUS_SYSTEM.md`.
//!
//! The rule this type exists to enforce is the one `sessiond` already lives by:
//! **a receiver never needs to know how the rung beneath it reached its
//! conclusion.** A skin patch does not forward sixteen pressure cells; it
//! forwards `contact: Sustained, confidence .82, rising`. A plexus does not
//! forward twenty patches; it forwards `being_held: .81`. Raw readings stay
//! where they were taken.
//!
//! Two properties are load-bearing and neither is decoration:
//!
//! 1. **`None` is not zero.** A belief with no evidence is `unknown()`, which
//! is categorically different from a belief that something is absent.
//! `sessiond::device_state::SourceHealth` learned this the expensive way —
//! a source that had never once reported sat silent for a whole boot and
//! hid a real outage. Losing tactile telemetry must read as *I cannot feel
//! my leg*, never as *nothing is touching my leg*.
//!
//! 2. **Disagreement lowers confidence; it does not elect a winner.** When two
//! sources conflict, neither becomes truth. That is `sessiond`'s
//! cross-sensor rule, transcribed rather than reinvented.
//!
//! Health lives on the *source*, not here. A belief carries who spoke and how
//! sure it is; when a source goes quiet its contribution decays out on its own
//! half-life, which is the same thing said in a way that cannot drift.
use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// Who contributed evidence. Free-form so a rung can name a skin patch, a
/// recognizer, or a whole plexus without a central registry to keep in sync.
pub type SourceId = String;
/// Direction of travel. `.64` and `.64` feel identical; `.15 → .31 → .49 → .64`
/// does not. Momentum is part of the reading, not a derived nicety.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum Trend {
FallingFast,
Falling,
#[default]
Stable,
Rising,
RisingFast,
}
impl Trend {
/// Units are magnitude-per-second. The band is deliberately wide: a
/// nervous system that reports `Rising` on floating-point noise is a
/// nervous system nobody can read.
pub fn from_velocity(v: f32) -> Self {
match v {
_ if v >= 0.25 => Trend::RisingFast,
_ if v >= 0.02 => Trend::Rising,
_ if v <= -0.25 => Trend::FallingFast,
_ if v <= -0.02 => Trend::Falling,
_ => Trend::Stable,
}
}
pub fn is_moving(self) -> bool {
self != Trend::Stable
}
}
/// What one rung believes, and how strongly.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Belief<T> {
/// `None` means nothing has been heard — *not* that the answer is zero,
/// false, or absent. Every consumer must handle it as ignorance.
pub value: Option<T>,
/// Confidence at `observed_at`. Read it through [`Belief::confidence_at`],
/// which applies the half-life; the bare field is the un-decayed figure.
pub confidence: f32,
pub trend: Trend,
/// How long this value has held without changing.
pub persistence: Duration,
pub observed_at: DateTime<Utc>,
/// How long until confidence halves with no fresh evidence. Evidence gets
/// stale on its own; nothing has to remember to expire it.
pub half_life: Duration,
pub sources: Vec<SourceId>,
/// Sources that disagreed. Kept, not discarded — a belief held over an
/// objection is a different thing from one held unopposed, and the rung
/// above deserves to see which it is.
pub conflicts: Vec<SourceId>,
}
impl<T> Belief<T> {
/// Nothing has been heard. The honest opening state for every rung.
pub fn unknown(now: DateTime<Utc>) -> Self {
Self {
value: None,
confidence: 0.0,
trend: Trend::Stable,
persistence: Duration::ZERO,
observed_at: now,
half_life: Duration::from_secs(30),
sources: Vec::new(),
conflicts: Vec::new(),
}
}
pub fn observed(
value: T,
confidence: f32,
source: impl Into<SourceId>,
now: DateTime<Utc>,
) -> Self {
Self {
value: Some(value),
confidence: confidence.clamp(0.0, 1.0),
trend: Trend::Stable,
persistence: Duration::ZERO,
observed_at: now,
half_life: Duration::from_secs(30),
sources: vec![source.into()],
conflicts: Vec::new(),
}
}
pub fn with_half_life(mut self, half_life: Duration) -> Self {
self.half_life = half_life;
self
}
pub fn with_trend(mut self, trend: Trend) -> Self {
self.trend = trend;
self
}
/// Is there evidence at all? Distinct from asking what the evidence says.
pub fn is_known(&self) -> bool {
self.value.is_some()
}
/// Confidence decayed to `now` on the half-life. Never negative, never
/// above the figure it was observed with.
pub fn confidence_at(&self, now: DateTime<Utc>) -> f32 {
if self.value.is_none() {
return 0.0;
}
let elapsed = (now - self.observed_at).num_milliseconds().max(0) as f32;
let hl = self.half_life.as_millis().max(1) as f32;
self.confidence * 0.5f32.powf(elapsed / hl)
}
/// Faded past usefulness. Two half-lives leaves a quarter of the original
/// confidence — below that a rung is reporting a rumour.
pub fn is_stale(&self, now: DateTime<Utc>) -> bool {
self.confidence_at(now) < self.confidence * 0.25
}
/// A second source agreeing. Confidence rises but never reaches certainty:
/// corroboration closes half the remaining gap, so no amount of agreement
/// produces 1.0. Nothing in a body is ever certain.
pub fn corroborated_by(mut self, source: impl Into<SourceId>, weight: f32) -> Self {
let s = source.into();
if !self.sources.contains(&s) {
self.sources.push(s);
}
let gap = 1.0 - self.confidence;
self.confidence = (self.confidence + gap * weight.clamp(0.0, 1.0)).clamp(0.0, 1.0);
self
}
/// A source that disagreed. It is recorded and confidence falls; the
/// dissenter does not become the new value and the incumbent does not
/// automatically survive. The rung above sees a contested belief and can
/// decide what a contested belief is worth.
pub fn contradicted_by(mut self, source: impl Into<SourceId>, weight: f32) -> Self {
let s = source.into();
if !self.conflicts.contains(&s) {
self.conflicts.push(s);
}
self.confidence = (self.confidence * (1.0 - weight.clamp(0.0, 1.0))).clamp(0.0, 1.0);
self
}
pub fn is_contested(&self) -> bool {
!self.conflicts.is_empty()
}
}
/// A decaying scalar — the accumulator every exudate secretes into.
///
/// This is the cheapest rung in the whole design and it runs without a model
/// in the loop: secretions add, time subtracts, and a threshold crossing is
/// arithmetic. The point of keeping it this cheap is that the language model
/// must never become the nervous system — it is far too expensive to be the
/// thing that notices a value drifting upward over an afternoon.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Field {
pub name: String,
level: f32,
half_life: Duration,
updated_at: DateTime<Utc>,
/// Level at the previous secretion, for velocity.
prior_level: f32,
prior_at: DateTime<Utc>,
}
impl Field {
pub fn new(name: impl Into<String>, half_life: Duration, now: DateTime<Utc>) -> Self {
Self {
name: name.into(),
level: 0.0,
half_life,
updated_at: now,
prior_level: 0.0,
prior_at: now,
}
}
/// Level decayed to `now`. Reading never mutates — two readers at the same
/// instant always agree, which a lazily-decaying field cannot promise.
pub fn level_at(&self, now: DateTime<Utc>) -> f32 {
let elapsed = (now - self.updated_at).num_milliseconds().max(0) as f32;
let hl = self.half_life.as_millis().max(1) as f32;
self.level * 0.5f32.powf(elapsed / hl)
}
/// Add to the field. Amount may be negative — a turn can leave withdrawal
/// behind as readily as approach.
pub fn secrete(&mut self, amount: f32, now: DateTime<Utc>) {
let decayed = self.level_at(now);
self.prior_level = decayed;
self.prior_at = self.updated_at;
self.level = (decayed + amount).clamp(0.0, 1.0);
self.updated_at = now;
}
/// Magnitude per second between the last two observations.
pub fn velocity(&self) -> f32 {
let dt = (self.updated_at - self.prior_at).num_milliseconds() as f32 / 1000.0;
if dt <= 0.0 {
return 0.0;
}
(self.level - self.prior_level) / dt
}
pub fn trend(&self) -> Trend {
Trend::from_velocity(self.velocity())
}
/// Project this field as a belief for the rung above. A field that has
/// never been secreted into reports `unknown` rather than zero — the same
/// distinction the whole design turns on.
pub fn as_belief(&self, now: DateTime<Utc>) -> Belief<f32> {
if self.prior_at == self.updated_at && self.level == 0.0 {
return Belief::unknown(now);
}
Belief {
value: Some(self.level_at(now)),
confidence: 1.0,
trend: self.trend(),
persistence: Duration::from_millis(
(now - self.prior_at).num_milliseconds().max(0) as u64
),
observed_at: self.updated_at,
half_life: self.half_life,
sources: vec![self.name.clone()],
conflicts: Vec::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn t0() -> DateTime<Utc> {
DateTime::parse_from_rfc3339("2026-08-13T14:00:00Z")
.unwrap()
.with_timezone(&Utc)
}
fn after(secs: i64) -> DateTime<Utc> {
t0() + chrono::Duration::seconds(secs)
}
#[test]
fn an_unknown_belief_is_not_a_zero_belief() {
let unknown: Belief<f32> = Belief::unknown(t0());
let absent = Belief::observed(0.0_f32, 0.9, "left-leg", t0());
assert!(!unknown.is_known(), "no evidence must not read as evidence");
assert!(absent.is_known(), "measured zero is a real reading");
assert_eq!(absent.value, Some(0.0));
// The distinction the whole ladder rests on: I cannot feel my leg is
// not the same sentence as nothing is touching my leg.
assert_ne!(unknown.is_known(), absent.is_known());
}
#[test]
fn confidence_halves_on_the_half_life() {
let b = Belief::observed("contact", 0.8, "skin-7", t0())
.with_half_life(Duration::from_secs(10));
assert!((b.confidence_at(t0()) - 0.8).abs() < 0.001);
assert!((b.confidence_at(after(10)) - 0.4).abs() < 0.001);
assert!((b.confidence_at(after(20)) - 0.2).abs() < 0.001);
}
#[test]
fn an_unknown_belief_has_no_confidence_to_decay() {
let unknown: Belief<f32> = Belief::unknown(t0());
assert_eq!(unknown.confidence_at(after(1)), 0.0);
assert_eq!(unknown.confidence_at(after(10_000)), 0.0);
}
#[test]
fn disagreement_lowers_confidence_and_elects_no_winner() {
let b = Belief::observed("held", 0.8, "hand-plexus", t0())
.contradicted_by("proprioception", 0.5);
assert_eq!(b.value, Some("held"), "the dissenter does not take the seat");
assert!(b.is_contested());
assert!(b.confidence < 0.8, "conflict must cost confidence");
assert_eq!(b.conflicts, vec!["proprioception".to_string()]);
}
#[test]
fn corroboration_approaches_certainty_without_reaching_it() {
let mut b = Belief::observed("warm", 0.5, "a", t0());
for src in ["b", "c", "d", "e", "f"] {
b = b.corroborated_by(src, 0.5);
}
assert!(b.confidence > 0.9, "agreement should count for something");
assert!(b.confidence < 1.0, "nothing in a body is ever certain");
assert_eq!(b.sources.len(), 6);
}
#[test]
fn the_same_source_agreeing_twice_is_still_one_source() {
let b = Belief::observed("contact", 0.5, "skin-7", t0())
.corroborated_by("skin-7", 0.5)
.corroborated_by("skin-7", 0.5);
assert_eq!(b.sources, vec!["skin-7".to_string()]);
}
#[test]
fn trend_is_deadbanded_against_noise() {
assert_eq!(Trend::from_velocity(0.001), Trend::Stable);
assert_eq!(Trend::from_velocity(-0.001), Trend::Stable);
assert_eq!(Trend::from_velocity(0.1), Trend::Rising);
assert_eq!(Trend::from_velocity(0.9), Trend::RisingFast);
assert_eq!(Trend::from_velocity(-0.9), Trend::FallingFast);
assert!(!Trend::from_velocity(0.0).is_moving());
}
#[test]
fn a_field_accumulates_and_decays() {
let mut f = Field::new("curiosity", Duration::from_secs(60), t0());
f.secrete(0.08, t0());
f.secrete(0.13, after(10));
f.secrete(0.19, after(20));
let level = f.level_at(after(20));
assert!(level > 0.3, "three secretions should stack: {level}");
assert!(level < 0.4, "and decay should have eaten some: {level}");
// Left alone, it fades rather than persisting forever.
assert!(f.level_at(after(200)) < level * 0.2);
}
#[test]
fn reading_a_field_twice_at_one_instant_agrees() {
let mut f = Field::new("activation", Duration::from_secs(30), t0());
f.secrete(0.5, t0());
let a = f.level_at(after(7));
let b = f.level_at(after(7));
assert_eq!(a, b, "reading must not mutate");
}
#[test]
fn a_field_never_secreted_into_reports_unknown_not_zero() {
let f = Field::new("anticipation", Duration::from_secs(60), t0());
let b = f.as_belief(after(5));
assert!(!b.is_known(), "silence is not a reading of zero");
}
#[test]
fn a_rising_field_reports_its_momentum_upward() {
let mut f = Field::new("anticipation", Duration::from_secs(600), t0());
f.secrete(0.1, t0());
f.secrete(0.3, after(1));
let b = f.as_belief(after(1));
assert!(b.is_known());
assert_eq!(b.trend, Trend::RisingFast, "velocity was ~0.3/s");
assert_eq!(b.sources, vec!["anticipation".to_string()]);
}
#[test]
fn a_field_can_be_secreted_into_negatively() {
let mut f = Field::new("approach", Duration::from_secs(600), t0());
f.secrete(0.6, t0());
f.secrete(-0.4, after(1));
assert!(f.level_at(after(1)) < 0.25);
assert_eq!(f.trend(), Trend::FallingFast);
}
#[test]
fn a_field_is_bounded_at_both_ends() {
let mut f = Field::new("excitation", Duration::from_secs(600), t0());
for _ in 0..50 {
f.secrete(0.5, t0());
}
assert!(f.level_at(t0()) <= 1.0, "no runaway accumulation");
f.secrete(-99.0, t0());
assert!(f.level_at(t0()) >= 0.0, "no negative levels");
}
}

View file

@ -1,4 +1,5 @@
#![allow(dead_code)] // WIP scaffolding not yet wired
pub mod belief;
pub mod cron;
pub mod event_log;
pub mod handler;