Skip to main content

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}