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//! ## Hysteresis
7//!
8//! Thresholds use hysteresis to prevent chattering when values hover near
9//! the boundary. Two thresholds control state transitions:
10//!
11//! - **on_threshold** (higher): Energy must exceed this to become active
12//! - **off_threshold** (lower): Energy must drop below this to become quiet
13//!
14//! When energy is between the thresholds, the previous state is maintained.
15//! This provides stable edge-triggered semantics without explicit refractory periods.
16
17use std::ops::Range;
18
19/// Event emitted when field activity crosses a threshold
20#[derive(Clone, Debug)]
21pub enum FieldEvent {
22    /// A region became active (energy crossed on_threshold from below)
23    RegionActive {
24        region: Range<usize>,
25        energy: f32,
26        /// The on_threshold that was crossed
27        threshold: f32,
28    },
29    /// A region went quiet (energy dropped below off_threshold)
30    RegionQuiet {
31        region: Range<usize>,
32        energy: f32,
33        /// The off_threshold that was crossed
34        threshold: f32,
35    },
36    /// Multiple regions active simultaneously (binding opportunity)
37    Convergence {
38        active_regions: Vec<Range<usize>>,
39        total_energy: f32,
40    },
41    /// Peak detected in a region (local maximum)
42    Peak {
43        region: Range<usize>,
44        energy: f32,
45        tick: u64,
46    },
47}
48
49/// Observer that receives field events
50pub trait FieldObserver: Send + Sync {
51    /// Called when a field event occurs
52    fn on_event(&self, event: FieldEvent);
53}
54
55/// Function-based observer for simple cases
56pub struct FnObserver<F: Fn(FieldEvent) + Send + Sync>(pub F);
57
58impl<F: Fn(FieldEvent) + Send + Sync> FieldObserver for FnObserver<F> {
59    fn on_event(&self, event: FieldEvent) {
60        (self.0)(event);
61    }
62}
63
64/// Channel-based observer - sends events to a channel
65pub struct ChannelObserver {
66    sender: std::sync::mpsc::Sender<FieldEvent>,
67}
68
69impl ChannelObserver {
70    pub fn new(sender: std::sync::mpsc::Sender<FieldEvent>) -> Self {
71        Self { sender }
72    }
73}
74
75impl FieldObserver for ChannelObserver {
76    fn on_event(&self, event: FieldEvent) {
77        let _ = self.sender.send(event);
78    }
79}
80
81/// Configuration for what triggers notifications
82#[derive(Clone, Debug)]
83pub struct TriggerConfig {
84    /// Regions to monitor (empty = monitor all dims as one region)
85    pub regions: Vec<MonitoredRegion>,
86    /// Minimum regions active for convergence event
87    pub convergence_threshold: usize,
88}
89
90/// A region being monitored for activity with hysteresis thresholds.
91///
92/// ## Hysteresis
93///
94/// Two thresholds control state transitions to prevent chattering:
95/// - `on_threshold`: Energy must exceed this to become active
96/// - `off_threshold`: Energy must drop below this to become quiet
97///
98/// When energy is between the thresholds, the previous state is maintained.
99#[derive(Clone, Debug)]
100pub struct MonitoredRegion {
101    /// Name for identification
102    pub name: String,
103    /// Dimension range
104    pub range: Range<usize>,
105    /// Energy threshold to enter active state (higher threshold)
106    pub on_threshold: f32,
107    /// Energy threshold to leave active state (lower threshold)
108    pub off_threshold: f32,
109    /// Weight for convergence calculation
110    pub weight: f32,
111}
112
113/// Default hysteresis gap as a fraction of on_threshold.
114/// off_threshold = on_threshold * (1.0 - HYSTERESIS_GAP)
115pub const DEFAULT_HYSTERESIS_GAP: f32 = 0.2;
116
117impl MonitoredRegion {
118    /// Create a new monitored region with automatic hysteresis.
119    ///
120    /// The off_threshold is set to 80% of the on_threshold by default,
121    /// providing a 20% hysteresis gap to prevent chattering.
122    pub fn new(name: impl Into<String>, range: Range<usize>, threshold: f32) -> Self {
123        Self {
124            name: name.into(),
125            range,
126            on_threshold: threshold,
127            off_threshold: threshold * (1.0 - DEFAULT_HYSTERESIS_GAP),
128            weight: 1.0,
129        }
130    }
131
132    /// Create a monitored region with explicit hysteresis thresholds.
133    ///
134    /// # Arguments
135    /// - `on_threshold`: Energy must exceed this to become active
136    /// - `off_threshold`: Energy must drop below this to become quiet
137    ///
138    /// # Panics
139    /// Debug-asserts that off_threshold <= on_threshold
140    pub fn with_hysteresis(
141        name: impl Into<String>,
142        range: Range<usize>,
143        on_threshold: f32,
144        off_threshold: f32,
145    ) -> Self {
146        debug_assert!(
147            off_threshold <= on_threshold,
148            "off_threshold ({}) must be <= on_threshold ({})",
149            off_threshold,
150            on_threshold
151        );
152        Self {
153            name: name.into(),
154            range,
155            on_threshold,
156            off_threshold,
157            weight: 1.0,
158        }
159    }
160
161    /// Set the hysteresis gap as a fraction (0.0 to 1.0).
162    ///
163    /// off_threshold = on_threshold * (1.0 - gap)
164    pub fn with_gap(mut self, gap: f32) -> Self {
165        self.off_threshold = self.on_threshold * (1.0 - gap.clamp(0.0, 1.0));
166        self
167    }
168
169    pub fn with_weight(mut self, weight: f32) -> Self {
170        self.weight = weight;
171        self
172    }
173
174    /// Get the hysteresis gap as a fraction.
175    pub fn hysteresis_gap(&self) -> f32 {
176        if self.on_threshold > 0.0 {
177            1.0 - (self.off_threshold / self.on_threshold)
178        } else {
179            0.0
180        }
181    }
182}
183
184impl Default for TriggerConfig {
185    fn default() -> Self {
186        Self {
187            regions: Vec::new(),
188            convergence_threshold: 2,
189        }
190    }
191}