temporal_field/observer.rs
1//! Observer pattern for temporal fields - brain-style pub/sub
2//!
3//! When a write crosses a threshold, observers are notified automatically.
4//! No polling required - sparks propagate.
5//!
6//! ASTRO_004 compliant: No floats. Energy as u32, thresholds as u32.
7//!
8//! ## Hysteresis
9//!
10//! Thresholds use hysteresis to prevent chattering when values hover near
11//! the boundary. Two thresholds control state transitions:
12//!
13//! - **on_threshold** (higher): Energy must exceed this to become active
14//! - **off_threshold** (lower): Energy must drop below this to become quiet
15//!
16//! When energy is between the thresholds, the previous state is maintained.
17//! This provides stable edge-triggered semantics without explicit refractory periods.
18
19use std::ops::Range;
20
21/// Event emitted when field activity crosses a threshold
22#[derive(Clone, Debug)]
23pub enum FieldEvent {
24 /// A region became active (energy crossed on_threshold from below)
25 RegionActive {
26 region: Range<usize>,
27 /// Energy as sum of squared magnitudes
28 energy: u64,
29 /// The on_threshold that was crossed
30 threshold: u64,
31 },
32 /// A region went quiet (energy dropped below off_threshold)
33 RegionQuiet {
34 region: Range<usize>,
35 /// Energy as sum of squared magnitudes
36 energy: u64,
37 /// The off_threshold that was crossed
38 threshold: u64,
39 },
40 /// Multiple regions active simultaneously (binding opportunity)
41 Convergence {
42 active_regions: Vec<Range<usize>>,
43 /// Total weighted energy (sum of energy × weight for each active region)
44 total_energy: u64,
45 },
46 /// Peak detected in a region (local maximum)
47 Peak {
48 region: Range<usize>,
49 /// Energy as sum of squared effective magnitudes
50 energy: u64,
51 tick: u64,
52 },
53}
54
55/// Observer that receives field events
56pub trait FieldObserver: Send + Sync {
57 /// Called when a field event occurs
58 fn on_event(&self, event: FieldEvent);
59}
60
61/// Function-based observer for simple cases
62pub struct FnObserver<F: Fn(FieldEvent) + Send + Sync>(pub F);
63
64impl<F: Fn(FieldEvent) + Send + Sync> FieldObserver for FnObserver<F> {
65 fn on_event(&self, event: FieldEvent) {
66 (self.0)(event);
67 }
68}
69
70/// Channel-based observer - sends events to a channel
71pub struct ChannelObserver {
72 sender: std::sync::mpsc::Sender<FieldEvent>,
73}
74
75impl ChannelObserver {
76 pub fn new(sender: std::sync::mpsc::Sender<FieldEvent>) -> Self {
77 Self { sender }
78 }
79}
80
81impl FieldObserver for ChannelObserver {
82 fn on_event(&self, event: FieldEvent) {
83 let _ = self.sender.send(event);
84 }
85}
86
87/// Configuration for what triggers notifications
88#[derive(Clone, Debug)]
89pub struct TriggerConfig {
90 /// Regions to monitor (empty = monitor all dims as one region)
91 pub regions: Vec<MonitoredRegion>,
92 /// Minimum regions active for convergence event
93 pub convergence_threshold: usize,
94}
95
96/// Default hysteresis gap as percentage (20 = 20%).
97/// off_threshold = on_threshold * (100 - gap) / 100
98pub const DEFAULT_HYSTERESIS_GAP: u8 = 20;
99
100/// A region being monitored for activity with hysteresis thresholds.
101///
102/// ## Hysteresis
103///
104/// Two thresholds control state transitions to prevent chattering:
105/// - `on_threshold`: Energy must exceed this to become active
106/// - `off_threshold`: Energy must drop below this to become quiet
107///
108/// When energy is between the thresholds, the previous state is maintained.
109///
110/// ## Energy Units
111///
112/// Energy is sum of squared magnitudes: Σ(magnitude²)
113/// For 64 dims with all magnitudes at 128: 64 × 128² = 1,048,576
114/// For 64 dims with all magnitudes at 255: 64 × 255² = 4,161,600
115#[derive(Clone, Debug)]
116pub struct MonitoredRegion {
117 /// Name for identification
118 pub name: String,
119 /// Dimension range
120 pub range: Range<usize>,
121 /// Energy threshold to enter active state (higher threshold)
122 /// Energy = sum of squared effective magnitudes (p×m×k)
123 pub on_threshold: u64,
124 /// Energy threshold to leave active state (lower threshold)
125 pub off_threshold: u64,
126 /// Weight for convergence calculation (100 = 1.0×)
127 pub weight: u8,
128}
129
130impl MonitoredRegion {
131 /// Create a new monitored region with automatic hysteresis.
132 ///
133 /// The off_threshold is set to 80% of the on_threshold by default,
134 /// providing a 20% hysteresis gap to prevent chattering.
135 ///
136 /// threshold: Energy threshold (sum of squared magnitudes)
137 pub fn new(name: impl Into<String>, range: Range<usize>, threshold: u64) -> Self {
138 Self {
139 name: name.into(),
140 range,
141 on_threshold: threshold,
142 off_threshold: threshold * (100 - DEFAULT_HYSTERESIS_GAP as u64) / 100,
143 weight: 100,
144 }
145 }
146
147 /// Create a monitored region with explicit hysteresis thresholds.
148 ///
149 /// # Arguments
150 /// - `on_threshold`: Energy must exceed this to become active
151 /// - `off_threshold`: Energy must drop below this to become quiet
152 ///
153 /// # Panics
154 /// Debug-asserts that off_threshold <= on_threshold
155 pub fn with_hysteresis(
156 name: impl Into<String>,
157 range: Range<usize>,
158 on_threshold: u64,
159 off_threshold: u64,
160 ) -> Self {
161 debug_assert!(
162 off_threshold <= on_threshold,
163 "off_threshold ({}) must be <= on_threshold ({})",
164 off_threshold,
165 on_threshold
166 );
167 Self {
168 name: name.into(),
169 range,
170 on_threshold,
171 off_threshold,
172 weight: 100,
173 }
174 }
175
176 /// Set the hysteresis gap as percentage (0-100).
177 ///
178 /// off_threshold = on_threshold * (100 - gap) / 100
179 pub fn with_gap(mut self, gap: u8) -> Self {
180 let gap = gap.min(100);
181 self.off_threshold = self.on_threshold * (100 - gap as u64) / 100;
182 self
183 }
184
185 /// Set weight (100 = 1.0×, 150 = 1.5×)
186 pub fn with_weight(mut self, weight: u8) -> Self {
187 self.weight = weight;
188 self
189 }
190
191 /// Get the hysteresis gap as percentage.
192 pub fn hysteresis_gap(&self) -> u8 {
193 if self.on_threshold > 0 {
194 (100 - (self.off_threshold * 100 / self.on_threshold)) as u8
195 } else {
196 0
197 }
198 }
199}
200
201impl Default for TriggerConfig {
202 fn default() -> Self {
203 Self {
204 regions: Vec::new(),
205 convergence_threshold: 2,
206 }
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn test_default_hysteresis() {
216 // Default gap is 20%
217 let region = MonitoredRegion::new("test", 0..32, 1000);
218 assert_eq!(region.on_threshold, 1000);
219 assert_eq!(region.off_threshold, 800); // 1000 * 80 / 100
220 assert_eq!(region.hysteresis_gap(), 20);
221 }
222
223 #[test]
224 fn test_custom_hysteresis() {
225 let region = MonitoredRegion::with_hysteresis("test", 0..32, 1000, 700);
226 assert_eq!(region.on_threshold, 1000);
227 assert_eq!(region.off_threshold, 700);
228 assert_eq!(region.hysteresis_gap(), 30);
229 }
230
231 #[test]
232 fn test_with_gap() {
233 let region = MonitoredRegion::new("test", 0..32, 1000).with_gap(30);
234 assert_eq!(region.on_threshold, 1000);
235 assert_eq!(region.off_threshold, 700); // 1000 * 70 / 100
236 }
237
238 #[test]
239 fn test_with_weight() {
240 let region = MonitoredRegion::new("test", 0..32, 1000).with_weight(150);
241 assert_eq!(region.weight, 150);
242 }
243}