memstead_base/ops/
signals.rs1use crate::entity::EntityId;
12use crate::store::Store;
13use memstead_schema::{ReachDirection, SignalDef, SignalLevel, TypeDefinition};
14
15#[derive(Debug, Clone, PartialEq)]
17pub struct ComputedSignal {
18 pub name: String,
19 pub value: u64,
21 pub level: Option<SignalLevel>,
23 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
51pub 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
69pub 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
82pub type SignalSnapshot = std::collections::BTreeMap<String, Vec<(String, Option<SignalLevel>)>>;
88
89pub 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
129pub 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 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}