subetha_cxc/policy_gate.rs
1//! Confidence gating for sidecar policy decisions.
2//!
3//! Fixed-interval hysteresis treats every policy recommendation the
4//! same: wait out the cooldown, then act on the latest scan. Under
5//! oscillating load that thrashes (each half-period legitimately
6//! crosses a threshold), and under noisy signals it acts on
7//! single-scan spikes.
8//!
9//! [`ConfidenceGate`] replaces the fixed timer with conviction
10//! dynamics: a scalar `c in [floor, 1]` grows logistically
11//! (`c += rate * c * (1 - c)`) each scan the policy repeats the
12//! SAME recommendation, collapses multiplicatively (`c *= shock`)
13//! when the recommendation changes or an external regime-shift
14//! signal arrives, and the decision fires only when `c` crosses
15//! `threshold` with at least `min_samples` consecutive agreeing
16//! scans. After firing, conviction resets to the floor so the next
17//! decision needs fresh evidence. The effective hysteresis adapts
18//! to signal stability: a steady recommendation passes in a handful
19//! of scans, an oscillating one never accumulates conviction at
20//! all.
21//!
22//! The gate is generic over the recommendation type so one
23//! implementation serves the capacity (usize), shape (RingShape),
24//! ordering (OrderingMode), and locale (Locale) sidecars.
25
26/// Tuning for a [`ConfidenceGate`]. The default is DISABLED, which
27/// makes every gated sidecar reproduce the ungated behavior
28/// exactly - enabling the gate is an explicit, per-spawn choice.
29#[derive(Debug, Clone, Copy)]
30pub struct GateConfig {
31 /// Master switch. `false` = pass every recommendation through
32 /// untouched (today's semantics).
33 pub enabled: bool,
34 /// Logistic growth rate per agreeing scan. At the default 0.9
35 /// a recommendation must hold for 5 consecutive scans to carry
36 /// conviction from the floor across the threshold.
37 pub rate: f32,
38 /// Multiplier applied to conviction on a recommendation change
39 /// or an external regime-shift signal.
40 pub shock: f32,
41 /// Conviction level at which a held recommendation fires.
42 pub threshold: f32,
43 /// Lower clamp and post-fire reset for conviction. Nonzero
44 /// because the logistic map's fixed point at 0 is absorbing -
45 /// conviction parked at exactly 0 never grows again.
46 pub floor: f32,
47 /// Minimum consecutive agreeing scans before a decision is
48 /// eligible regardless of conviction. 0 disables the sample
49 /// gate. [`min_samples_for_arity`] derives the recommended
50 /// floor from the decision's arity.
51 pub min_samples: u32,
52}
53
54impl Default for GateConfig {
55 fn default() -> Self {
56 Self {
57 enabled: false,
58 rate: 0.9,
59 shock: 0.25,
60 threshold: 0.7,
61 floor: 0.05,
62 min_samples: 0,
63 }
64 }
65}
66
67impl GateConfig {
68 /// An enabled gate with default dynamics and no sample floor.
69 pub fn enabled() -> Self {
70 Self { enabled: true, ..Self::default() }
71 }
72
73 /// An enabled gate with the sample floor derived from the
74 /// decision's arity via [`min_samples_for_arity`].
75 pub fn enabled_with_arity(k: u32) -> Self {
76 Self {
77 enabled: true,
78 min_samples: min_samples_for_arity(k),
79 ..Self::default()
80 }
81 }
82}
83
84/// Recommended minimum sample count for a decision over a k-way
85/// signal: `2 * ceil(log2(k))`, with k clamped to at least 2.
86/// A binary decision needs 2 agreeing samples, a 3-or-4-way
87/// decision 4, an 8-way decision 6.
88pub fn min_samples_for_arity(k: u32) -> u32 {
89 let k = k.max(2);
90 let ceil_log2 = 32 - (k - 1).leading_zeros();
91 2 * ceil_log2
92}
93
94/// Per-sidecar-loop conviction state. One instance per gated
95/// decision axis; lives on the sidecar thread, no shared state.
96pub struct ConfidenceGate<T: PartialEq + Copy> {
97 cfg: GateConfig,
98 c: f32,
99 held: Option<T>,
100 samples: u32,
101}
102
103impl<T: PartialEq + Copy> ConfidenceGate<T> {
104 pub fn new(cfg: GateConfig) -> Self {
105 Self { cfg, c: cfg.floor, held: None, samples: 0 }
106 }
107
108 /// Feed one scan's recommendation. Returns the decision the
109 /// sidecar may act on this scan: the recommendation itself
110 /// when the gate is disabled, otherwise only once conviction
111 /// and the sample floor are both satisfied.
112 pub fn observe(&mut self, recommendation: Option<T>) -> Option<T> {
113 if !self.cfg.enabled {
114 return recommendation;
115 }
116 match (recommendation, self.held) {
117 (None, _) => {
118 // Recommendation withdrawn: the signal no longer
119 // supports acting. Collapse conviction; forget the
120 // held target.
121 self.c = (self.c * self.cfg.shock).max(self.cfg.floor);
122 self.held = None;
123 self.samples = 0;
124 None
125 }
126 (Some(r), Some(h)) if r == h => {
127 self.samples = self.samples.saturating_add(1);
128 self.c = (self.c + self.cfg.rate * self.c * (1.0 - self.c)).min(1.0);
129 if self.c >= self.cfg.threshold
130 && self.samples >= self.cfg.min_samples.max(1)
131 {
132 // Fire, then demand fresh evidence for the
133 // next decision.
134 self.c = self.cfg.floor;
135 self.held = None;
136 self.samples = 0;
137 Some(r)
138 } else {
139 None
140 }
141 }
142 (Some(r), _) => {
143 // New or REVERSED recommendation. A reversal is
144 // direct evidence of oscillation - collapse
145 // conviction and start counting for the new target.
146 // First sighting never fires.
147 self.c = (self.c * self.cfg.shock).max(self.cfg.floor);
148 self.held = Some(r);
149 self.samples = 1;
150 None
151 }
152 }
153 }
154
155 /// External regime-shift signal (peer-count change, fill jump,
156 /// inversion-rate discontinuity): collapse conviction so the
157 /// gate demands fresh agreement under the new regime before
158 /// acting.
159 pub fn shock(&mut self) {
160 if self.cfg.enabled {
161 self.c = (self.c * self.cfg.shock).max(self.cfg.floor);
162 self.samples = 0;
163 }
164 }
165
166 /// Current conviction (observability).
167 pub fn confidence(&self) -> f32 {
168 self.c
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn disabled_gate_is_passthrough() {
178 let mut g: ConfidenceGate<u32> = ConfidenceGate::new(GateConfig::default());
179 assert_eq!(g.observe(Some(7)), Some(7), "disabled = today's semantics");
180 assert_eq!(g.observe(None), None);
181 assert_eq!(g.observe(Some(9)), Some(9));
182 }
183
184 #[test]
185 fn steady_recommendation_fires_after_logistic_crossing() {
186 let mut g: ConfidenceGate<u32> = ConfidenceGate::new(GateConfig::enabled());
187 let mut fired_at = None;
188 for scan in 1..=20 {
189 if g.observe(Some(512)).is_some() {
190 fired_at = Some(scan);
191 break;
192 }
193 }
194 // floor 0.05, rate 0.9: 0.05 -> 0.093 -> 0.169 -> 0.295
195 // -> 0.482 -> 0.707 >= threshold on the 6th observation
196 // (first sighting resets, five agreements grow).
197 assert_eq!(fired_at, Some(6),
198 "default dynamics fire on the 6th consecutive agreeing scan");
199 // Post-fire: conviction reset; an immediate repeat must NOT
200 // fire on the next scan.
201 assert_eq!(g.observe(Some(512)), None,
202 "post-fire decisions need fresh conviction");
203 }
204
205 #[test]
206 fn oscillating_recommendation_never_fires() {
207 let mut g: ConfidenceGate<u32> = ConfidenceGate::new(GateConfig::enabled());
208 for _ in 0..100 {
209 assert_eq!(g.observe(Some(512)), None);
210 assert_eq!(g.observe(Some(128)), None,
211 "each reversal collapses conviction - oscillation starves the gate");
212 }
213 assert!(g.confidence() < 0.2);
214 }
215
216 #[test]
217 fn withdrawal_collapses_conviction() {
218 let mut g: ConfidenceGate<u32> = ConfidenceGate::new(GateConfig::enabled());
219 for _ in 0..4 {
220 g.observe(Some(512));
221 }
222 let before = g.confidence();
223 g.observe(None);
224 assert!(g.confidence() < before * 0.5,
225 "withdrawal must shock conviction down");
226 // The previously-held target starts over.
227 let mut fired = false;
228 for _ in 0..3 {
229 fired |= g.observe(Some(512)).is_some();
230 }
231 assert!(!fired, "post-withdrawal the target re-earns conviction from scratch");
232 }
233
234 #[test]
235 fn external_shock_resets_progress() {
236 let mut g: ConfidenceGate<u32> = ConfidenceGate::new(GateConfig::enabled());
237 for _ in 0..4 {
238 g.observe(Some(512));
239 }
240 g.shock();
241 assert!(g.confidence() <= 0.2);
242 assert_eq!(g.observe(Some(512)), None,
243 "agreement after a regime shift starts a fresh climb");
244 }
245
246 #[test]
247 fn min_samples_gate_delays_even_full_conviction() {
248 let cfg = GateConfig { min_samples: 10, ..GateConfig::enabled() };
249 let mut g: ConfidenceGate<u32> = ConfidenceGate::new(cfg);
250 let mut fired_at = None;
251 for scan in 1..=20 {
252 if g.observe(Some(512)).is_some() {
253 fired_at = Some(scan);
254 break;
255 }
256 }
257 assert_eq!(fired_at, Some(10),
258 "the sample floor binds when it exceeds the conviction crossing");
259 }
260
261 #[test]
262 fn arity_derived_sample_floors() {
263 assert_eq!(min_samples_for_arity(2), 2);
264 assert_eq!(min_samples_for_arity(3), 4);
265 assert_eq!(min_samples_for_arity(4), 4);
266 assert_eq!(min_samples_for_arity(8), 6);
267 assert_eq!(min_samples_for_arity(64), 12);
268 assert_eq!(min_samples_for_arity(0), 2, "arity clamps to binary");
269 }
270}