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}