Skip to main content

dsfb_rf/
policy.rs

1//! Policy engine: Silent | Watch | Review | Escalate.
2//!
3//! The policy engine is the final stage in the deterministic pipeline:
4//!
5//!   IQ Residual → Sign → Syntax → Grammar → Semantics → Policy
6//!
7//! It maps (grammar_state, semantic_disposition, dsa_score, corroboration)
8//! → PolicyDecision, subject to persistence and fragmentation constraints.
9//!
10//! ## Policy Rules (paper §VIII, §B.5)
11//!
12//! - Silent:   grammar Admissible, DSA < τ, or persistence gate failed
13//! - Watch:    motif active, DSA < τ or persistence < K
14//! - Review:   persistence ≥ K AND motif class = Review-grade
15//! - Escalate: persistence ≥ K AND Violation-class motif or Violation grammar
16
17use crate::grammar::GrammarState;
18use crate::heuristics::SemanticDisposition;
19use crate::dsa::DsaScore;
20
21/// The operator-facing policy decision.
22///
23/// This is the terminal output of the DSFB pipeline. It is the single
24/// value the integration layer presents to the operator or upstream
25/// alerting system.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
27#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
28pub enum PolicyDecision {
29    /// No structural activity detected. Nominal operation.
30    Silent,
31    /// Structural activity below escalation threshold. Continue monitoring.
32    Watch,
33    /// Persistent structural episode. Operator review warranted.
34    Review,
35    /// Violation-class episode. Immediate operator attention required.
36    Escalate,
37}
38
39impl PolicyDecision {
40    /// Returns true if this decision requires operator action.
41    #[inline]
42    pub fn requires_action(&self) -> bool {
43        matches!(self, PolicyDecision::Review | PolicyDecision::Escalate)
44    }
45
46    /// Numeric level for metric computation (0–3).
47    #[inline]
48    pub fn level(&self) -> u8 {
49        *self as u8
50    }
51}
52
53/// Policy configuration — the Stage III fixed protocol parameters.
54#[derive(Debug, Clone, Copy)]
55pub struct PolicyConfig {
56    /// DSA score threshold τ. Default 2.0 (paper Stage III).
57    pub tau: f32,
58    /// Persistence count K. Default 4 (paper Stage III).
59    pub k: u8,
60    /// Minimum corroboration count m. Default 1 (paper Stage III).
61    pub m: u8,
62    /// When `true`, a [`GrammarState::Violation`] causes an immediate
63    /// [`PolicyDecision::Escalate`] without waiting for K persistence
64    /// observations.  This is the **magnitude-gated bypass** described in
65    /// paper §L item 9 (hypersonic detection latency defence): an extreme
66    /// violation (residual norm >> ρ, triggering `Violation` directly) must
67    /// not be delayed by the hysteresis confirmation window.
68    ///
69    /// Default: `true`.  Set to `false` only if false-escalation suppression
70    /// is more important than latency (e.g., benign laboratory environments
71    /// with high transient artefact rates).
72    pub extreme_bypass: bool,
73}
74
75impl Default for PolicyConfig {
76    fn default() -> Self {
77        Self { tau: 2.0, k: 4, m: 1, extreme_bypass: true }
78    }
79}
80
81impl PolicyConfig {
82    /// Paper Stage III fixed configuration.
83    pub const STAGE_III: Self = Self { tau: 2.0, k: 4, m: 1, extreme_bypass: true };
84}
85
86/// Policy evaluator with persistence tracking.
87///
88/// Maintains a consecutive-observation persistence counter.
89/// Fires Review/Escalate only when DSA ≥ τ for ≥ K consecutive observations
90/// with ≥ m corroborating channels.
91pub struct PolicyEvaluator {
92    config: PolicyConfig,
93    /// Consecutive observations with DSA ≥ τ.
94    persistence: u8,
95    /// Whether last-committed decision was Review or Escalate (for fragmentation guard).
96    episode_open: bool,
97}
98
99impl PolicyEvaluator {
100    /// Create a new evaluator with Stage III defaults.
101    pub const fn new() -> Self {
102        Self {
103            config: PolicyConfig::STAGE_III,
104            persistence: 0,
105            episode_open: false,
106        }
107    }
108
109    /// Create with custom configuration.
110    pub const fn with_config(config: PolicyConfig) -> Self {
111        Self { config, persistence: 0, episode_open: false }
112    }
113
114    /// Evaluate policy for one observation.
115    ///
116    /// The integration contract: this method has `&mut self` (the evaluator
117    /// maintains persistence state), but accepts the upstream observables
118    /// as immutable references. There is no write path into upstream data.
119    pub fn evaluate(
120        &mut self,
121        grammar: GrammarState,
122        disposition: SemanticDisposition,
123        dsa: DsaScore,
124        corroboration_count: u8,
125    ) -> PolicyDecision {
126        // DSA threshold and corroboration gate
127        let dsa_active = dsa.meets_threshold(self.config.tau);
128        let corroborated = corroboration_count >= self.config.m;
129
130        // Magnitude-gated extreme bypass (paper §L item 9, hypersonic defence):
131        // An immediate Violation grammar state bypasses the K-persistence
132        // hysteresis gate and escalates on the first observation.  This
133        // prevents multi-window confirmation delay when the residual norm
134        // far exceeds ρ (the grammar only assigns Violation for |r| ≫ ρ_eff).
135        if self.config.extreme_bypass && grammar.is_violation() && corroborated {
136            self.persistence = self.persistence.saturating_add(1);
137            self.episode_open = true;
138            return PolicyDecision::Escalate;
139        }
140
141        // Update persistence counter
142        if dsa_active && corroborated && grammar.requires_attention() {
143            self.persistence = self.persistence.saturating_add(1);
144        } else {
145            self.persistence = 0;
146            self.episode_open = false;
147        }
148
149        // Decision logic
150        if !grammar.requires_attention() || !corroborated {
151            PolicyDecision::Silent
152        } else if !dsa_active || self.persistence < self.config.k {
153            PolicyDecision::Watch
154        } else if grammar.is_violation()
155            || matches!(disposition,
156                SemanticDisposition::AbruptOnsetEvent
157                | SemanticDisposition::PreTransitionCluster)
158        {
159            self.episode_open = true;
160            PolicyDecision::Escalate
161        } else {
162            self.episode_open = true;
163            PolicyDecision::Review
164        }
165    }
166
167    /// Reset the evaluator (e.g., after a post-transition guard window).
168    pub fn reset(&mut self) {
169        self.persistence = 0;
170        self.episode_open = false;
171    }
172
173    /// Returns true if an episode is currently open.
174    #[inline]
175    pub fn episode_open(&self) -> bool {
176        self.episode_open
177    }
178
179    /// Current persistence count.
180    #[inline]
181    pub fn persistence(&self) -> u8 {
182        self.persistence
183    }
184}
185
186impl Default for PolicyEvaluator {
187    fn default() -> Self {
188        Self::new()
189    }
190}
191
192// ---------------------------------------------------------------
193// Tests
194// ---------------------------------------------------------------
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use crate::grammar::{GrammarState, ReasonCode};
199    use crate::heuristics::SemanticDisposition;
200    use crate::dsa::DsaScore;
201
202    fn boundary() -> GrammarState {
203        GrammarState::Boundary(ReasonCode::SustainedOutwardDrift)
204    }
205
206    #[test]
207    fn clean_signal_is_silent() {
208        let mut p = PolicyEvaluator::new();
209        let d = p.evaluate(
210            GrammarState::Admissible,
211            SemanticDisposition::Unknown,
212            DsaScore(0.1),
213            0,
214        );
215        assert_eq!(d, PolicyDecision::Silent);
216    }
217
218    #[test]
219    fn watch_before_persistence_threshold() {
220        let mut p = PolicyEvaluator::new();
221        // K=4, so first 3 should be Watch
222        for _ in 0..3 {
223            let d = p.evaluate(boundary(), SemanticDisposition::PreTransitionCluster,
224                DsaScore(3.0), 1);
225            assert_eq!(d, PolicyDecision::Watch,
226                "should be Watch before K=4 persistence");
227        }
228    }
229
230    #[test]
231    fn escalate_after_k_consecutive_with_pre_transition() {
232        let mut p = PolicyEvaluator::new();
233        let mut last = PolicyDecision::Silent;
234        for _ in 0..5 {
235            last = p.evaluate(boundary(), SemanticDisposition::PreTransitionCluster,
236                DsaScore(3.0), 1);
237        }
238        assert_eq!(last, PolicyDecision::Escalate);
239    }
240
241    #[test]
242    fn review_for_corroborating_drift() {
243        let mut p = PolicyEvaluator::new();
244        let mut last = PolicyDecision::Silent;
245        for _ in 0..5 {
246            last = p.evaluate(boundary(), SemanticDisposition::CorroboratingDrift,
247                DsaScore(3.0), 1);
248        }
249        assert_eq!(last, PolicyDecision::Review);
250    }
251
252    #[test]
253    fn violation_always_escalates_after_persistence() {
254        let mut p = PolicyEvaluator::new();
255        let mut last = PolicyDecision::Silent;
256        for _ in 0..5 {
257            last = p.evaluate(GrammarState::Violation,
258                SemanticDisposition::Unknown, DsaScore(3.0), 1);
259        }
260        assert_eq!(last, PolicyDecision::Escalate);
261    }
262
263    #[test]
264    fn policy_resets_on_clean_window() {
265        let mut p = PolicyEvaluator::new();
266        // Build up persistence
267        for _ in 0..5 {
268            p.evaluate(boundary(), SemanticDisposition::PreTransitionCluster,
269                DsaScore(3.0), 1);
270        }
271        // Clean observation resets
272        p.evaluate(GrammarState::Admissible, SemanticDisposition::Unknown,
273            DsaScore(0.1), 0);
274        assert_eq!(p.persistence(), 0);
275        assert!(!p.episode_open());
276    }
277
278    #[test]
279    fn requires_action_only_for_review_escalate() {
280        assert!(!PolicyDecision::Silent.requires_action());
281        assert!(!PolicyDecision::Watch.requires_action());
282        assert!(PolicyDecision::Review.requires_action());
283        assert!(PolicyDecision::Escalate.requires_action());
284    }
285}