Skip to main content

memstead_base/ops/
signals.rs

1//! Aggregate-signal evaluation — the one computation behind every
2//! surface that serves a declared signal (entity reads, the `signals`
3//! health axis, and the `SIGNAL_THRESHOLD_CROSSED` mutation warning).
4//!
5//! A signal is an exact, parameter-free count with declared
6//! thresholds: nothing multiplies, averages, or decays, and values
7//! are computed at read time in O(degree) per signal — never stored,
8//! never metadata, never part of `_hash`. The evidence (contributing
9//! entity ids) ships with the number, always.
10
11use crate::entity::EntityId;
12use crate::store::Store;
13use memstead_schema::{ReachDirection, SignalDef, SignalLevel, TypeDefinition};
14
15/// One evaluated signal on one entity.
16#[derive(Debug, Clone, PartialEq)]
17pub struct ComputedSignal {
18    pub name: String,
19    /// The exact edge count (each qualifying edge counts once).
20    pub value: u64,
21    /// `None` below the first threshold — wire level `none`.
22    pub level: Option<SignalLevel>,
23    /// The counterpart entity of every counted edge, deduplicated and
24    /// sorted for deterministic payloads.
25    pub contributors: Vec<EntityId>,
26}
27
28impl ComputedSignal {
29    pub fn level_wire(&self) -> &'static str {
30        match self.level {
31            None => "none",
32            Some(SignalLevel::Notice) => "notice",
33            Some(SignalLevel::Warn) => "warn",
34        }
35    }
36}
37
38impl serde::Serialize for ComputedSignal {
39    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
40        use serde::ser::SerializeStruct;
41        let mut s = serializer.serialize_struct("ComputedSignal", 4)?;
42        s.serialize_field("name", &self.name)?;
43        s.serialize_field("value", &self.value)?;
44        s.serialize_field("level", self.level_wire())?;
45        let contributors: Vec<String> = self.contributors.iter().map(|c| c.to_string()).collect();
46        s.serialize_field("contributors", &contributors)?;
47        s.end()
48    }
49}
50
51/// Wire form shared by the structured entity envelope and the health
52/// axis: `[{name, value, level, contributors}]`.
53pub fn signals_json(signals: &[ComputedSignal]) -> serde_json::Value {
54    serde_json::Value::Array(
55        signals
56            .iter()
57            .map(|s| {
58                serde_json::json!({
59                    "name": s.name,
60                    "value": s.value,
61                    "level": s.level_wire(),
62                    "contributors": s.contributors.iter().map(|id| id.to_string()).collect::<Vec<_>>(),
63                })
64            })
65            .collect(),
66    )
67}
68
69/// Evaluate every signal the type declares for one entity, in
70/// declaration order. Counts edges of the declared relation set in
71/// the declared direction; with a neighbour pair declared, an edge
72/// counts only when its counterpart entity holds the declared value
73/// (a counterpart lacking the field, holding another value, or being
74/// a stub simply does not count).
75pub fn compute_signals(store: &Store, td: &TypeDefinition, id: &EntityId) -> Vec<ComputedSignal> {
76    td.signals
77        .iter()
78        .map(|sig| compute_one(store, sig, id))
79        .collect()
80}
81
82/// Per-entity signal levels captured before a write applies — the
83/// baseline the crossing detection diffs against, keyed by the
84/// canonical id string (BTreeMap for deterministic warning order).
85/// An id with no snapshot entry (an entity being created) reads as
86/// all-`none`.
87pub type SignalSnapshot = std::collections::BTreeMap<String, Vec<(String, Option<SignalLevel>)>>;
88
89/// Capture the current signal levels of the candidate entities a
90/// write may move. Entities that do not exist yet, are stubs, or
91/// whose type declares no signals snapshot as an empty list (every
92/// later level then diffs against `none`).
93pub fn snapshot_levels<'a>(
94    store: &Store,
95    schemas: &std::collections::HashMap<String, std::sync::Arc<memstead_schema::Schema>>,
96    ids: impl IntoIterator<Item = &'a EntityId>,
97) -> SignalSnapshot {
98    let mut snap = SignalSnapshot::new();
99    for id in ids {
100        let levels = signal_levels_of(store, schemas, id);
101        snap.entry(id.0.clone()).or_insert(levels);
102    }
103    snap
104}
105
106fn signal_levels_of(
107    store: &Store,
108    schemas: &std::collections::HashMap<String, std::sync::Arc<memstead_schema::Schema>>,
109    id: &EntityId,
110) -> Vec<(String, Option<SignalLevel>)> {
111    let Some(entity) = store.get(id).filter(|e| !e.stub) else {
112        return Vec::new();
113    };
114    let Some(schema) = schemas.get(entity.mem.as_str()) else {
115        return Vec::new();
116    };
117    let Some(td) = schema.types.get(entity.entity_type.as_str()) else {
118        return Vec::new();
119    };
120    if td.signals.is_empty() {
121        return Vec::new();
122    }
123    compute_signals(store, td, id)
124        .into_iter()
125        .map(|s| (s.name, s.level))
126        .collect()
127}
128
129/// Diff the snapshot against the post-write state and emit one
130/// `SIGNAL_THRESHOLD_CROSSED` warning per signal whose level changed,
131/// in either direction. Rides the out-of-band warning channel beside
132/// the success payload; a write that crosses nothing emits nothing.
133pub fn crossing_warnings(
134    store: &Store,
135    schemas: &std::collections::HashMap<String, std::sync::Arc<memstead_schema::Schema>>,
136    before: &SignalSnapshot,
137) -> Vec<crate::ops::WarningHint> {
138    let wire = |level: Option<SignalLevel>| -> String {
139        match level {
140            None => "none".to_string(),
141            Some(SignalLevel::Notice) => "notice".to_string(),
142            Some(SignalLevel::Warn) => "warn".to_string(),
143        }
144    };
145    let mut out = Vec::new();
146    for (id_str, old_levels) in before {
147        let id = EntityId(id_str.clone());
148        let Some(entity) = store.get(&id).filter(|e| !e.stub) else {
149            continue;
150        };
151        let Some(schema) = schemas.get(entity.mem.as_str()) else {
152            continue;
153        };
154        let Some(td) = schema.types.get(entity.entity_type.as_str()) else {
155            continue;
156        };
157        if td.signals.is_empty() {
158            continue;
159        }
160        for after in compute_signals(store, td, &id) {
161            let old = old_levels
162                .iter()
163                .find(|(name, _)| name == &after.name)
164                .map(|(_, level)| *level)
165                .unwrap_or(None);
166            if old != after.level {
167                out.push(crate::ops::WarningHint::SignalThresholdCrossed {
168                    entity_id: id.clone(),
169                    signal: after.name.clone(),
170                    value: after.value,
171                    old_level: wire(old),
172                    new_level: wire(after.level),
173                });
174            }
175        }
176    }
177    out
178}
179
180fn compute_one(store: &Store, sig: &SignalDef, id: &EntityId) -> ComputedSignal {
181    // (counterpart, qualifies) per candidate edge of the set.
182    let counterparts: Vec<EntityId> = match sig.direction {
183        ReachDirection::Out => store
184            .outgoing(id)
185            .iter()
186            .filter(|e| sig.relationships.iter().any(|n| n == &e.rel_type))
187            .map(|e| e.target.clone())
188            .collect(),
189        ReachDirection::In => store
190            .incoming(id)
191            .iter()
192            .filter(|e| sig.relationships.iter().any(|n| n == &e.rel_type))
193            .map(|e| e.from.clone())
194            .collect(),
195    };
196    let qualifying: Vec<EntityId> =
197        if let (Some(field), Some(value)) = (&sig.neighbour_field, &sig.neighbour_value) {
198            counterparts
199                .into_iter()
200                .filter(|c| {
201                    store.get(c).is_some_and(|e| {
202                        !e.stub
203                            && e.metadata
204                                .get(field.as_str())
205                                .is_some_and(|v| v.to_frontmatter_string() == *value)
206                    })
207                })
208                .collect()
209        } else {
210            counterparts
211        };
212    let value = qualifying.len() as u64;
213    let mut contributors = qualifying;
214    contributors.sort_by(|a, b| a.0.cmp(&b.0));
215    contributors.dedup();
216    ComputedSignal {
217        name: sig.name.clone(),
218        value,
219        level: sig.level_for(value),
220        contributors,
221    }
222}