Skip to main content

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}