1pub mod layer;
2
3use crate::config::PipelineConfig;
4use crate::nominal::NominalModel;
5use crate::residual::ResidualSet;
6use crate::signs::SignSet;
7use serde::Serialize;
8#[cfg(not(feature = "std"))]
9use alloc::{string::String, vec::Vec};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
12pub enum GrammarState {
13 Admissible,
14 Boundary,
15 Violation,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
19pub enum GrammarReason {
20 Admissible,
21 SustainedOutwardDrift,
22 AbruptSlewViolation,
23 RecurrentBoundaryGrazing,
24 EnvelopeViolation,
25}
26
27#[derive(Debug, Clone, Serialize)]
28pub struct FeatureGrammarTrace {
29 pub feature_index: usize,
30 pub feature_name: String,
31 pub suppressed_by_imputation: Vec<bool>,
32 pub raw_states: Vec<GrammarState>,
33 pub raw_reasons: Vec<GrammarReason>,
34 pub states: Vec<GrammarState>,
35 pub reasons: Vec<GrammarReason>,
36 pub persistent_boundary: Vec<bool>,
37 pub persistent_violation: Vec<bool>,
38}
39
40#[derive(Debug, Clone, Serialize)]
41pub struct GrammarSet {
42 pub traces: Vec<FeatureGrammarTrace>,
43}
44
45pub fn evaluate_grammar(
46 residuals: &ResidualSet,
47 signs: &SignSet,
48 nominal: &NominalModel,
49 config: &PipelineConfig,
50) -> GrammarSet {
51 let mut traces = Vec::with_capacity(residuals.traces.len());
52
53 for (residual_trace, sign_trace) in residuals.traces.iter().zip(&signs.traces) {
54 let feature = &nominal.features[residual_trace.feature_index];
55 let (raw_states, raw_reasons, suppressed_by_imputation) =
56 evaluate_raw_trace(residual_trace, sign_trace, feature, config);
57 let (states, reasons) =
58 apply_hysteresis(&raw_states, &raw_reasons, config.state_confirmation_steps);
59 let persistent_boundary = persistent_mask(
60 &states,
61 GrammarState::Boundary,
62 config.persistent_state_steps,
63 );
64 let persistent_violation = persistent_mask(
65 &states,
66 GrammarState::Violation,
67 config.persistent_state_steps,
68 );
69
70 traces.push(FeatureGrammarTrace {
71 feature_index: residual_trace.feature_index,
72 feature_name: residual_trace.feature_name.clone(),
73 suppressed_by_imputation,
74 raw_states,
75 raw_reasons,
76 states,
77 reasons,
78 persistent_boundary,
79 persistent_violation,
80 });
81 }
82
83 GrammarSet { traces }
84}
85
86fn evaluate_raw_trace(
87 residual_trace: &crate::residual::ResidualFeatureTrace,
88 sign_trace: &crate::signs::FeatureSigns,
89 feature: &crate::nominal::NominalFeature,
90 config: &PipelineConfig,
91) -> (Vec<GrammarState>, Vec<GrammarReason>, Vec<bool>) {
92 let mut states = Vec::with_capacity(residual_trace.norms.len());
93 let mut reasons = Vec::with_capacity(residual_trace.norms.len());
94 let mut suppressed_by_imputation = Vec::with_capacity(residual_trace.norms.len());
95
96 for index in 0..residual_trace.norms.len() {
97 let zone_start = index.saturating_sub(config.grazing_window.saturating_sub(1));
98 let zone_hits = residual_trace.norms[zone_start..=index]
99 .iter()
100 .filter(|value| **value > config.boundary_fraction_of_rho * feature.rho)
101 .count();
102
103 let norm = residual_trace.norms[index];
104 let drift = sign_trace.drift[index];
105 let slew = sign_trace.slew[index].abs();
106 let state_suppressed_by_imputation = feature.analyzable && residual_trace.is_imputed[index];
107
108 let (state, reason) = if !feature.analyzable || state_suppressed_by_imputation {
109 (GrammarState::Admissible, GrammarReason::Admissible)
110 } else if norm > feature.rho {
111 (GrammarState::Violation, GrammarReason::EnvelopeViolation)
112 } else if norm > config.boundary_fraction_of_rho * feature.rho
113 && drift >= sign_trace.drift_threshold
114 && slew >= sign_trace.slew_threshold
115 {
116 (GrammarState::Boundary, GrammarReason::AbruptSlewViolation)
117 } else if norm > config.boundary_fraction_of_rho * feature.rho
118 && drift >= sign_trace.drift_threshold
119 {
120 (GrammarState::Boundary, GrammarReason::SustainedOutwardDrift)
121 } else if norm > config.boundary_fraction_of_rho * feature.rho
122 && zone_hits >= config.grazing_min_hits
123 {
124 (
125 GrammarState::Boundary,
126 GrammarReason::RecurrentBoundaryGrazing,
127 )
128 } else {
129 (GrammarState::Admissible, GrammarReason::Admissible)
130 };
131
132 states.push(state);
133 reasons.push(reason);
134 suppressed_by_imputation.push(state_suppressed_by_imputation);
135 }
136
137 (states, reasons, suppressed_by_imputation)
138}
139
140fn apply_hysteresis(
141 raw_states: &[GrammarState],
142 raw_reasons: &[GrammarReason],
143 confirmation_steps: usize,
144) -> (Vec<GrammarState>, Vec<GrammarReason>) {
145 if raw_states.is_empty() {
146 return (Vec::new(), Vec::new());
147 }
148
149 let mut states = Vec::with_capacity(raw_states.len());
150 let mut reasons = Vec::with_capacity(raw_states.len());
151 let mut current_state = raw_states[0];
152 let mut current_reason = raw_reasons[0];
153 let mut candidate_state: Option<GrammarState> = None;
154 let mut candidate_count = 0usize;
155
156 for (&raw_state, &raw_reason) in raw_states.iter().zip(raw_reasons) {
157 if raw_state == current_state {
158 candidate_state = None;
159 candidate_count = 0;
160 if raw_state != GrammarState::Admissible {
161 current_reason = raw_reason;
162 } else {
163 current_reason = GrammarReason::Admissible;
164 }
165 } else if candidate_state == Some(raw_state) {
166 candidate_count += 1;
167 } else {
168 candidate_state = Some(raw_state);
169 candidate_count = 1;
170 }
171
172 if let Some(next_state) = candidate_state {
173 if candidate_count >= confirmation_steps {
174 current_state = next_state;
175 current_reason = if current_state == GrammarState::Admissible {
176 GrammarReason::Admissible
177 } else {
178 raw_reason
179 };
180 candidate_state = None;
181 candidate_count = 0;
182 }
183 }
184
185 states.push(current_state);
186 reasons.push(if current_state == GrammarState::Admissible {
187 GrammarReason::Admissible
188 } else {
189 current_reason
190 });
191 }
192
193 (states, reasons)
194}
195
196fn persistent_mask(
197 states: &[GrammarState],
198 target: GrammarState,
199 minimum_steps: usize,
200) -> Vec<bool> {
201 let mut out = Vec::with_capacity(states.len());
202 let mut consecutive = 0usize;
203
204 for &state in states {
205 if state == target {
206 consecutive += 1;
207 out.push(consecutive >= minimum_steps);
208 } else {
209 consecutive = 0;
210 out.push(false);
211 }
212 }
213
214 out
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220 use crate::nominal::{NominalFeature, NominalModel};
221 use crate::residual::{ResidualFeatureTrace, ResidualSet};
222 use crate::signs::{FeatureSigns, SignSet};
223
224 fn test_config() -> PipelineConfig {
225 PipelineConfig {
226 state_confirmation_steps: 1,
227 persistent_state_steps: 2,
228 ..PipelineConfig::default()
229 }
230 }
231
232 #[test]
233 fn violation_state_wins_over_boundary() {
234 let residuals = ResidualSet {
235 traces: vec![ResidualFeatureTrace {
236 feature_index: 0,
237 feature_name: "S001".into(),
238 imputed_values: vec![0.0, 2.0],
239 residuals: vec![0.0, 2.0],
240 norms: vec![0.0, 2.0],
241 threshold_alarm: vec![false, true],
242 is_imputed: vec![false, false],
243 }],
244 };
245 let signs = SignSet {
246 traces: vec![FeatureSigns {
247 feature_index: 0,
248 feature_name: "S001".into(),
249 drift: vec![0.0, 1.0],
250 slew: vec![0.0, 1.0],
251 drift_threshold: 0.1,
252 slew_threshold: 0.1,
253 }],
254 };
255 let nominal = NominalModel {
256 features: vec![NominalFeature {
257 feature_index: 0,
258 feature_name: "S001".into(),
259 healthy_mean: 0.0,
260 healthy_std: 0.5,
261 rho: 1.5,
262 healthy_observations: 10,
263 analyzable: true,
264 }],
265 };
266 let grammar = evaluate_grammar(&residuals, &signs, &nominal, &test_config());
267 assert_eq!(grammar.traces[0].raw_states[1], GrammarState::Violation);
268 assert_eq!(grammar.traces[0].states[1], GrammarState::Violation);
269 assert_eq!(
270 grammar.traces[0].raw_reasons[1],
271 GrammarReason::EnvelopeViolation
272 );
273 }
274
275 #[test]
276 fn hysteresis_requires_confirmation_before_state_change() {
277 let raw_states = vec![
278 GrammarState::Admissible,
279 GrammarState::Boundary,
280 GrammarState::Admissible,
281 GrammarState::Boundary,
282 GrammarState::Boundary,
283 ];
284 let raw_reasons = vec![
285 GrammarReason::Admissible,
286 GrammarReason::SustainedOutwardDrift,
287 GrammarReason::Admissible,
288 GrammarReason::SustainedOutwardDrift,
289 GrammarReason::SustainedOutwardDrift,
290 ];
291 let (states, _) = apply_hysteresis(&raw_states, &raw_reasons, 2);
292 assert_eq!(
293 states,
294 vec![
295 GrammarState::Admissible,
296 GrammarState::Admissible,
297 GrammarState::Admissible,
298 GrammarState::Admissible,
299 GrammarState::Boundary,
300 ]
301 );
302 }
303
304 #[test]
305 fn persistence_mask_starts_after_minimum_consecutive_steps() {
306 let states = vec![
307 GrammarState::Admissible,
308 GrammarState::Boundary,
309 GrammarState::Boundary,
310 GrammarState::Boundary,
311 GrammarState::Admissible,
312 ];
313 let mask = persistent_mask(&states, GrammarState::Boundary, 2);
314 assert_eq!(mask, vec![false, false, true, true, false]);
315 }
316
317 #[test]
318 fn imputed_runs_are_suppressed_to_admissible() {
319 let residuals = ResidualSet {
320 traces: vec![ResidualFeatureTrace {
321 feature_index: 0,
322 feature_name: "S001".into(),
323 imputed_values: vec![0.0, 1.2],
324 residuals: vec![0.0, 1.2],
325 norms: vec![0.0, 1.2],
326 threshold_alarm: vec![false, false],
327 is_imputed: vec![false, true],
328 }],
329 };
330 let signs = SignSet {
331 traces: vec![FeatureSigns {
332 feature_index: 0,
333 feature_name: "S001".into(),
334 drift: vec![0.0, 1.0],
335 slew: vec![0.0, 1.0],
336 drift_threshold: 0.1,
337 slew_threshold: 0.1,
338 }],
339 };
340 let nominal = NominalModel {
341 features: vec![NominalFeature {
342 feature_index: 0,
343 feature_name: "S001".into(),
344 healthy_mean: 0.0,
345 healthy_std: 0.5,
346 rho: 1.5,
347 healthy_observations: 10,
348 analyzable: true,
349 }],
350 };
351
352 let grammar = evaluate_grammar(&residuals, &signs, &nominal, &test_config());
353 assert_eq!(grammar.traces[0].raw_states[1], GrammarState::Admissible);
354 assert_eq!(grammar.traces[0].raw_reasons[1], GrammarReason::Admissible);
355 assert!(grammar.traces[0].suppressed_by_imputation[1]);
356 }
357}