1use crate::grammar::GrammarState;
18use crate::heuristics::SemanticDisposition;
19use crate::dsa::DsaScore;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
27#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
28pub enum PolicyDecision {
29 Silent,
31 Watch,
33 Review,
35 Escalate,
37}
38
39impl PolicyDecision {
40 #[inline]
42 pub fn requires_action(&self) -> bool {
43 matches!(self, PolicyDecision::Review | PolicyDecision::Escalate)
44 }
45
46 #[inline]
48 pub fn level(&self) -> u8 {
49 *self as u8
50 }
51}
52
53#[derive(Debug, Clone, Copy)]
55pub struct PolicyConfig {
56 pub tau: f32,
58 pub k: u8,
60 pub m: u8,
62 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 pub const STAGE_III: Self = Self { tau: 2.0, k: 4, m: 1, extreme_bypass: true };
84}
85
86pub struct PolicyEvaluator {
92 config: PolicyConfig,
93 persistence: u8,
95 episode_open: bool,
97}
98
99impl PolicyEvaluator {
100 pub const fn new() -> Self {
102 Self {
103 config: PolicyConfig::STAGE_III,
104 persistence: 0,
105 episode_open: false,
106 }
107 }
108
109 pub const fn with_config(config: PolicyConfig) -> Self {
111 Self { config, persistence: 0, episode_open: false }
112 }
113
114 pub fn evaluate(
120 &mut self,
121 grammar: GrammarState,
122 disposition: SemanticDisposition,
123 dsa: DsaScore,
124 corroboration_count: u8,
125 ) -> PolicyDecision {
126 let dsa_active = dsa.meets_threshold(self.config.tau);
128 let corroborated = corroboration_count >= self.config.m;
129
130 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 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 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 pub fn reset(&mut self) {
169 self.persistence = 0;
170 self.episode_open = false;
171 }
172
173 #[inline]
175 pub fn episode_open(&self) -> bool {
176 self.episode_open
177 }
178
179 #[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#[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 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 for _ in 0..5 {
268 p.evaluate(boundary(), SemanticDisposition::PreTransitionCluster,
269 DsaScore(3.0), 1);
270 }
271 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}