Skip to main content

dsfb_semiconductor/
heuristics.rs

1use crate::metrics::{BenchmarkMetrics, MotifMetric};
2use serde::{Deserialize, Serialize};
3
4pub const PRE_FAILURE_SLOW_DRIFT: &str = "pre_failure_slow_drift";
5pub const TRANSIENT_EXCURSION: &str = "transient_excursion";
6pub const TRANSITION_EXCURSION: &str = "transition_excursion";
7pub const RECURRENT_BOUNDARY_APPROACH: &str = "recurrent_boundary_approach";
8pub const PERSISTENT_INSTABILITY_CLUSTER: &str = "persistent_instability_cluster";
9pub const TRANSITION_CLUSTER_SUPPORT: &str = "transition_cluster_support";
10pub const WATCH_ONLY_BOUNDARY_GRAZING: &str = "watch_only_boundary_grazing";
11
12#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
13pub enum HeuristicAlertClass {
14    Silent,
15    Watch,
16    Review,
17    Escalate,
18}
19
20impl HeuristicAlertClass {
21    pub fn as_lowercase(self) -> &'static str {
22        match self {
23            Self::Silent => "silent",
24            Self::Watch => "watch",
25            Self::Review => "review",
26            Self::Escalate => "escalate",
27        }
28    }
29}
30
31#[derive(Debug, Clone, Copy)]
32pub struct HeuristicPolicyDefinition {
33    pub motif_name: &'static str,
34    pub signature_definition: &'static str,
35    pub grammar_constraints: &'static str,
36    pub regime_conditions: &'static str,
37    pub applicability_rules: &'static str,
38    pub interpretation: &'static str,
39    pub alert_class_default: HeuristicAlertClass,
40    pub requires_persistence: bool,
41    pub requires_corroboration: bool,
42    pub minimum_window: usize,
43    pub minimum_hits: usize,
44    pub recommended_action: &'static str,
45    pub escalation_policy: &'static str,
46    pub non_unique_warning: &'static str,
47    pub known_limitations: &'static str,
48    pub contributes_to_dsa: bool,
49    pub suppresses_alert: bool,
50    pub promotes_alert: bool,
51}
52
53impl HeuristicPolicyDefinition {
54    pub fn maximum_allowed_fragmentation(self) -> f64 {
55        1.0 / self.minimum_hits.max(1) as f64
56    }
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
60pub struct FeaturePolicyOverride {
61    pub feature_index: usize,
62    pub feature_name: String,
63    pub alert_class_override: Option<HeuristicAlertClass>,
64    pub requires_persistence_override: Option<bool>,
65    pub requires_corroboration_override: Option<bool>,
66    pub minimum_window_override: Option<usize>,
67    pub minimum_hits_override: Option<usize>,
68    pub maximum_allowed_fragmentation_override: Option<f64>,
69    pub rescue_eligible: bool,
70    pub rescue_priority: usize,
71    pub allow_watch_only: Option<bool>,
72    pub allow_review_without_escalate: Option<bool>,
73    pub suppress_if_isolated: Option<bool>,
74    pub override_reason: String,
75}
76
77const POLICY_DEFINITIONS: &[HeuristicPolicyDefinition] = &[
78    HeuristicPolicyDefinition {
79        motif_name: PRE_FAILURE_SLOW_DRIFT,
80        signature_definition:
81            "Residual norm exceeds 0.5*rho with drift above the healthy-window drift threshold.",
82        grammar_constraints:
83            "grammar_state=Boundary and grammar_reason=SustainedOutwardDrift",
84        regime_conditions:
85            "Outward drift remains thresholded and curvature stays below the abrupt-slew regime.",
86        applicability_rules:
87            "Apply only after grammar filtering confirms boundary proximity without direct envelope exit.",
88        interpretation:
89            "Candidate DSA-compatible drift motif that supports closer monitoring or maintenance review.",
90        alert_class_default: HeuristicAlertClass::Review,
91        requires_persistence: true,
92        requires_corroboration: false,
93        minimum_window: 5,
94        minimum_hits: 2,
95        recommended_action:
96            "Increase review cadence, inspect neighboring channels, and corroborate with process context before intervention.",
97        escalation_policy:
98            "Escalate when the motif persists across repeated runs or is corroborated by scalar alarms and engineering context.",
99        non_unique_warning:
100            "This motif is not mechanism-specific and may reflect multiple latent causes.",
101        known_limitations:
102            "SECOM is anonymized and instance-level, so this motif does not support chamber-level attribution on its own.",
103        contributes_to_dsa: true,
104        suppresses_alert: false,
105        promotes_alert: true,
106    },
107    HeuristicPolicyDefinition {
108        motif_name: TRANSIENT_EXCURSION,
109        signature_definition:
110            "Residual norm enters the boundary zone with slew above the healthy-window slew threshold.",
111        grammar_constraints:
112            "grammar_state in {Boundary, Violation} and grammar_reason=AbruptSlewViolation",
113        regime_conditions:
114            "Curvature dominates the local sign tuple during a non-admissible excursion.",
115        applicability_rules:
116            "Apply only after grammar filtering confirms abrupt boundary interaction.",
117        interpretation:
118            "Compatible with transient upset or abrupt regime change, but not uniquely diagnostic.",
119        alert_class_default: HeuristicAlertClass::Silent,
120        requires_persistence: true,
121        requires_corroboration: true,
122        minimum_window: 5,
123        minimum_hits: 2,
124        recommended_action:
125            "Check for corroborating context, inspect neighboring channels, and prefer confirmation over immediate intervention.",
126        escalation_policy:
127            "Escalate only when repeated, clustered with other motifs, or accompanied by direct envelope violations.",
128        non_unique_warning:
129            "A transient excursion can reflect measurement noise, regime switch, or genuine degradation.",
130        known_limitations:
131            "A single abrupt excursion does not identify physical cause and may not persist long enough for confident attribution.",
132        contributes_to_dsa: true,
133        suppresses_alert: true,
134        promotes_alert: true,
135    },
136    HeuristicPolicyDefinition {
137        motif_name: RECURRENT_BOUNDARY_APPROACH,
138        signature_definition:
139            "Residual norm revisits the boundary zone repeatedly without a confirmed envelope exit.",
140        grammar_constraints:
141            "grammar_state=Boundary and grammar_reason=RecurrentBoundaryGrazing",
142        regime_conditions:
143            "Boundary revisitation persists without direct envelope exit and without stable violation.",
144        applicability_rules:
145            "Apply only after grammar filtering confirms repeated boundary approach under the local envelope.",
146        interpretation:
147            "Ambiguous DSA motif that warrants continued observation rather than decisive attribution.",
148        alert_class_default: HeuristicAlertClass::Watch,
149        requires_persistence: true,
150        requires_corroboration: true,
151        minimum_window: 10,
152        minimum_hits: 3,
153        recommended_action:
154            "Track persistence, compare against the scalar baselines, and prioritize manual review over automatic maintenance action.",
155        escalation_policy:
156            "Escalate when recurrent grazing concentrates in pre-failure windows or transitions into direct violations.",
157        non_unique_warning:
158            "Repeated boundary grazing can arise from nuisance variation as well as meaningful DSA structure.",
159        known_limitations:
160            "This motif is especially sensitive to envelope and drift thresholds, so calibration materially affects its prevalence.",
161        contributes_to_dsa: true,
162        suppresses_alert: true,
163        promotes_alert: true,
164    },
165];
166
167const EXPANDED_POLICY_DEFINITIONS: &[HeuristicPolicyDefinition] = &[
168    HeuristicPolicyDefinition {
169        motif_name: TRANSITION_EXCURSION,
170        signature_definition:
171            "Grammar-qualified transition motif with elevated slew, non-admissible envelope interaction, and abrupt state change.",
172        grammar_constraints:
173            "grammar_state in {TransientViolation, PersistentViolation} and grammar_reason=AbruptSlewViolation",
174        regime_conditions:
175            "Curvature dominates the sign tuple while the trajectory departs admissibility.",
176        applicability_rules:
177            "Apply only after grammar filtering confirms abrupt transition pressure at the envelope.",
178        interpretation:
179            "Candidate transition-instability event with elevated structural salience but ambiguous physical cause.",
180        alert_class_default: HeuristicAlertClass::Review,
181        requires_persistence: false,
182        requires_corroboration: false,
183        minimum_window: 3,
184        minimum_hits: 1,
185        recommended_action:
186            "Inspect adjacent channels and grouped corroborators before promoting beyond Review.",
187        escalation_policy:
188            "Escalate only when the transition persists, repeats, or aligns with grouped corroboration.",
189        non_unique_warning:
190            "A transition excursion is not a unique fault signature and may reflect multiple process changes.",
191        known_limitations:
192            "SECOM does not expose mechanism labels, so this motif remains interpretive rather than causal.",
193        contributes_to_dsa: false,
194        suppresses_alert: false,
195        promotes_alert: true,
196    },
197    HeuristicPolicyDefinition {
198        motif_name: PERSISTENT_INSTABILITY_CLUSTER,
199        signature_definition:
200            "Repeated or sustained outward grammar pressure that is not reducible to isolated spikes.",
201        grammar_constraints:
202            "grammar_state in {SustainedOutwardDrift, PersistentViolation}",
203        regime_conditions:
204            "Non-admissible pressure recurs across neighboring runs with bounded fragmentation.",
205        applicability_rules:
206            "Apply only after grammar filtering confirms sustained pressure rather than single-point excursions.",
207        interpretation:
208            "Candidate persistent instability regime with potential operator significance if corroborated.",
209        alert_class_default: HeuristicAlertClass::Review,
210        requires_persistence: true,
211        requires_corroboration: true,
212        minimum_window: 5,
213        minimum_hits: 2,
214        recommended_action:
215            "Review grouped corroborators, inspect adjacent precursor channels, and preserve ambiguity explicitly.",
216        escalation_policy:
217            "Escalate only when grouped corroboration or persistent violation confirms sustained structure.",
218        non_unique_warning:
219            "Persistent instability remains semantically ambiguous and does not identify a unique root cause.",
220        known_limitations:
221            "This motif is sensitive to persistence choices and grouped corroboration windows.",
222        contributes_to_dsa: false,
223        suppresses_alert: false,
224        promotes_alert: true,
225    },
226    HeuristicPolicyDefinition {
227        motif_name: TRANSITION_CLUSTER_SUPPORT,
228        signature_definition:
229            "Corroborating burst or boundary-pressure feature that aligns temporally with a primary precursor feature.",
230        grammar_constraints:
231            "grammar_state in {BoundaryGrazing, SustainedOutwardDrift, TransientViolation}",
232        regime_conditions:
233            "Supportive structure is temporally aligned with a grouped primary feature rather than isolated.",
234        applicability_rules:
235            "Apply only after grammar filtering and grouped temporal alignment confirm corroboration.",
236        interpretation:
237            "Supportive corroborator motif that increases confidence in another feature but is not decisive alone.",
238        alert_class_default: HeuristicAlertClass::Watch,
239        requires_persistence: false,
240        requires_corroboration: true,
241        minimum_window: 3,
242        minimum_hits: 1,
243        recommended_action:
244            "Use as corroboration support; do not escalate on this motif alone.",
245        escalation_policy:
246            "Never escalate solely from support motifs without a primary precursor feature.",
247        non_unique_warning:
248            "Corroboration support indicates temporal alignment, not causal identity.",
249        known_limitations:
250            "Grouped alignment is deterministic but remains a limited surrogate for true mechanism coupling.",
251        contributes_to_dsa: false,
252        suppresses_alert: true,
253        promotes_alert: true,
254    },
255    HeuristicPolicyDefinition {
256        motif_name: WATCH_ONLY_BOUNDARY_GRAZING,
257        signature_definition:
258            "Boundary proximity without sufficient persistence or corroboration for Review promotion.",
259        grammar_constraints: "grammar_state=BoundaryGrazing",
260        regime_conditions:
261            "Admissibility pressure is visible but remains weak, isolated, or sentinel-like.",
262        applicability_rules:
263            "Apply only after grammar filtering confirms envelope grazing without sustained outward drift.",
264        interpretation:
265            "Low-amplitude sentinel signal appropriate for Watch-only handling.",
266        alert_class_default: HeuristicAlertClass::Watch,
267        requires_persistence: false,
268        requires_corroboration: true,
269        minimum_window: 3,
270        minimum_hits: 1,
271        recommended_action:
272            "Retain as Watch-only and wait for corroboration before manual investigation.",
273        escalation_policy:
274            "Do not escalate directly from boundary grazing without stronger semantic support.",
275        non_unique_warning:
276            "Boundary grazing alone is structurally ambiguous and often nuisance-dominated.",
277        known_limitations:
278            "This motif deliberately favors burden suppression over coverage recovery when isolated.",
279        contributes_to_dsa: false,
280        suppresses_alert: true,
281        promotes_alert: false,
282    },
283];
284
285#[derive(Debug, Clone, Serialize)]
286pub struct HeuristicEntry {
287    pub motif_name: String,
288    pub signature_definition: String,
289    pub grammar_constraints: String,
290    pub regime_conditions: String,
291    pub applicability_rules: String,
292    pub applicable_dataset: String,
293    pub provenance_status: String,
294    pub interpretation: String,
295    pub severity: String,
296    pub confidence: String,
297    pub alert_class_default: HeuristicAlertClass,
298    pub requires_persistence: bool,
299    pub requires_corroboration: bool,
300    pub minimum_window: usize,
301    pub minimum_hits: usize,
302    pub maximum_allowed_fragmentation: f64,
303    pub recommended_action: String,
304    pub escalation_policy: String,
305    pub non_unique_warning: String,
306    pub known_limitations: String,
307    pub contributes_to_dsa_scoring: bool,
308    pub contributes_to_dsa: bool,
309    pub suppresses_alert: bool,
310    pub promotes_alert: bool,
311    pub observed_point_hits: usize,
312    pub observed_run_hits: usize,
313    pub pre_failure_window_run_hits: usize,
314    pub pre_failure_window_precision_proxy: Option<f64>,
315    pub status_note: String,
316}
317
318pub fn dsa_contributing_motif_names() -> &'static [&'static str] {
319    &[
320        PRE_FAILURE_SLOW_DRIFT,
321        TRANSIENT_EXCURSION,
322        RECURRENT_BOUNDARY_APPROACH,
323    ]
324}
325
326pub fn heuristic_policy_definitions() -> &'static [HeuristicPolicyDefinition] {
327    POLICY_DEFINITIONS
328}
329
330pub fn heuristic_policy_definition(motif_name: &str) -> Option<HeuristicPolicyDefinition> {
331    POLICY_DEFINITIONS
332        .iter()
333        .copied()
334        .chain(EXPANDED_POLICY_DEFINITIONS.iter().copied())
335        .find(|definition| definition.motif_name == motif_name)
336}
337
338pub fn expanded_semantic_policy_definitions() -> Vec<HeuristicPolicyDefinition> {
339    POLICY_DEFINITIONS
340        .iter()
341        .copied()
342        .chain(EXPANDED_POLICY_DEFINITIONS.iter().copied())
343        .collect()
344}
345
346pub fn build_heuristics_bank(
347    metrics: &BenchmarkMetrics,
348    dataset_name: &str,
349) -> Vec<HeuristicEntry> {
350    POLICY_DEFINITIONS
351        .iter()
352        .map(|definition| {
353            let metric = motif(metrics, definition.motif_name);
354            HeuristicEntry {
355                motif_name: definition.motif_name.into(),
356                signature_definition: definition.signature_definition.into(),
357                grammar_constraints: definition.grammar_constraints.into(),
358                regime_conditions: definition.regime_conditions.into(),
359                applicability_rules: definition.applicability_rules.into(),
360                applicable_dataset: dataset_name.into(),
361                provenance_status: observed_status(metric),
362                interpretation: definition.interpretation.into(),
363                severity: definition.alert_class_default.as_lowercase().into(),
364                confidence: confidence_note(metric),
365                alert_class_default: definition.alert_class_default,
366                requires_persistence: definition.requires_persistence,
367                requires_corroboration: definition.requires_corroboration,
368                minimum_window: definition.minimum_window,
369                minimum_hits: definition.minimum_hits,
370                maximum_allowed_fragmentation: definition.maximum_allowed_fragmentation(),
371                recommended_action: definition.recommended_action.into(),
372                escalation_policy: definition.escalation_policy.into(),
373                non_unique_warning: definition.non_unique_warning.into(),
374                known_limitations: definition.known_limitations.into(),
375                contributes_to_dsa_scoring: definition.contributes_to_dsa,
376                contributes_to_dsa: definition.contributes_to_dsa,
377                suppresses_alert: definition.suppresses_alert,
378                promotes_alert: definition.promotes_alert,
379                observed_point_hits: metric.point_hits,
380                observed_run_hits: metric.run_hits,
381                pre_failure_window_run_hits: metric.pre_failure_window_run_hits,
382                pre_failure_window_precision_proxy: metric.pre_failure_window_precision_proxy,
383                status_note: format!(
384                    "Observed {} points and {} run hits; {} of those run hits fall inside the configured pre-failure windows. Default alert class is {} with minimum_window={}, minimum_hits={}, and maximum_allowed_fragmentation={:.4}.",
385                    metric.point_hits,
386                    metric.run_hits,
387                    metric.pre_failure_window_run_hits,
388                    definition.alert_class_default.as_lowercase(),
389                    definition.minimum_window,
390                    definition.minimum_hits,
391                    definition.maximum_allowed_fragmentation(),
392                ),
393            }
394        })
395        .collect()
396}
397
398fn motif<'a>(metrics: &'a BenchmarkMetrics, motif_name: &str) -> &'a MotifMetric {
399    metrics
400        .motif_metrics
401        .iter()
402        .find(|metric| metric.motif_name == motif_name)
403        .unwrap_or_else(|| panic!("missing motif metric for {motif_name}"))
404}
405
406fn observed_status(metric: &MotifMetric) -> String {
407    if metric.point_hits > 0 {
408        "SECOM-observed".into()
409    } else {
410        "framework-defined".into()
411    }
412}
413
414fn confidence_note(metric: &MotifMetric) -> String {
415    if metric.point_hits > 0 {
416        "Stage-II observed on SECOM; interpretive and non-mechanistic.".into()
417    } else {
418        "Framework-defined only; not yet observed in the current run.".into()
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use crate::metrics::{
426        BenchmarkMetrics, BoundaryEpisodeSummary, DensitySummary, LeadTimeSummary,
427    };
428    use crate::preprocessing::DatasetSummary;
429
430    fn sample_metrics() -> BenchmarkMetrics {
431        BenchmarkMetrics {
432            summary: crate::metrics::BenchmarkSummary {
433                dataset_summary: DatasetSummary {
434                    run_count: 10,
435                    feature_count: 3,
436                    pass_count: 8,
437                    fail_count: 2,
438                    dataset_missing_fraction: 0.0,
439                    healthy_pass_runs_requested: 3,
440                    healthy_pass_runs_found: 3,
441                },
442                analyzable_feature_count: 3,
443                grammar_imputation_suppression_points: 0,
444                threshold_alarm_points: 0,
445                ewma_alarm_points: 0,
446                cusum_alarm_points: 0,
447                run_energy_alarm_points: 0,
448                pca_fdc_alarm_points: 0,
449                dsfb_raw_boundary_points: 0,
450                dsfb_persistent_boundary_points: 0,
451                dsfb_raw_violation_points: 0,
452                dsfb_persistent_violation_points: 0,
453                failure_runs: 2,
454                failure_runs_with_preceding_dsfb_raw_signal: 0,
455                failure_runs_with_preceding_dsfb_persistent_signal: 0,
456                failure_runs_with_preceding_dsfb_raw_boundary_signal: 0,
457                failure_runs_with_preceding_dsfb_persistent_boundary_signal: 0,
458                failure_runs_with_preceding_dsfb_raw_violation_signal: 0,
459                failure_runs_with_preceding_dsfb_persistent_violation_signal: 0,
460                failure_runs_with_preceding_ewma_signal: 0,
461                failure_runs_with_preceding_cusum_signal: 0,
462                failure_runs_with_preceding_run_energy_signal: 0,
463                failure_runs_with_preceding_pca_fdc_signal: 0,
464                failure_runs_with_preceding_threshold_signal: 0,
465                pass_runs: 8,
466                pass_runs_with_dsfb_raw_boundary_signal: 0,
467                pass_runs_with_dsfb_persistent_boundary_signal: 0,
468                pass_runs_with_dsfb_raw_violation_signal: 0,
469                pass_runs_with_dsfb_persistent_violation_signal: 0,
470                pass_runs_with_ewma_signal: 0,
471                pass_runs_with_cusum_signal: 0,
472                pass_runs_with_run_energy_signal: 0,
473                pass_runs_with_pca_fdc_signal: 0,
474                pass_runs_with_threshold_signal: 0,
475                pass_run_dsfb_raw_boundary_nuisance_rate: 0.0,
476                pass_run_dsfb_persistent_boundary_nuisance_rate: 0.0,
477                pass_run_dsfb_raw_violation_nuisance_rate: 0.0,
478                pass_run_dsfb_persistent_violation_nuisance_rate: 0.0,
479                pass_run_ewma_nuisance_rate: 0.0,
480                pass_run_cusum_nuisance_rate: 0.0,
481                pass_run_run_energy_nuisance_rate: 0.0,
482                pass_run_pca_fdc_nuisance_rate: 0.0,
483                pass_run_threshold_nuisance_rate: 0.0,
484            },
485            lead_time_summary: LeadTimeSummary {
486                failure_runs_with_raw_boundary_lead: 0,
487                failure_runs_with_persistent_boundary_lead: 0,
488                failure_runs_with_raw_violation_lead: 0,
489                failure_runs_with_persistent_violation_lead: 0,
490                failure_runs_with_threshold_lead: 0,
491                failure_runs_with_ewma_lead: 0,
492                failure_runs_with_cusum_lead: 0,
493                failure_runs_with_run_energy_lead: 0,
494                failure_runs_with_pca_fdc_lead: 0,
495                mean_raw_boundary_lead_runs: None,
496                mean_persistent_boundary_lead_runs: None,
497                mean_raw_violation_lead_runs: None,
498                mean_persistent_violation_lead_runs: None,
499                mean_threshold_lead_runs: None,
500                mean_ewma_lead_runs: None,
501                mean_cusum_lead_runs: None,
502                mean_run_energy_lead_runs: None,
503                mean_pca_fdc_lead_runs: None,
504                mean_raw_boundary_minus_cusum_delta_runs: None,
505                mean_raw_boundary_minus_run_energy_delta_runs: None,
506                mean_raw_boundary_minus_pca_fdc_delta_runs: None,
507                mean_raw_boundary_minus_threshold_delta_runs: None,
508                mean_raw_boundary_minus_ewma_delta_runs: None,
509                mean_persistent_boundary_minus_cusum_delta_runs: None,
510                mean_persistent_boundary_minus_run_energy_delta_runs: None,
511                mean_persistent_boundary_minus_pca_fdc_delta_runs: None,
512                mean_persistent_boundary_minus_threshold_delta_runs: None,
513                mean_persistent_boundary_minus_ewma_delta_runs: None,
514                mean_raw_violation_minus_cusum_delta_runs: None,
515                mean_raw_violation_minus_run_energy_delta_runs: None,
516                mean_raw_violation_minus_pca_fdc_delta_runs: None,
517                mean_raw_violation_minus_threshold_delta_runs: None,
518                mean_raw_violation_minus_ewma_delta_runs: None,
519                mean_persistent_violation_minus_cusum_delta_runs: None,
520                mean_persistent_violation_minus_run_energy_delta_runs: None,
521                mean_persistent_violation_minus_pca_fdc_delta_runs: None,
522                mean_persistent_violation_minus_threshold_delta_runs: None,
523                mean_persistent_violation_minus_ewma_delta_runs: None,
524            },
525            density_summary: DensitySummary {
526                density_window: 3,
527                mean_raw_boundary_density_failure: 0.0,
528                mean_raw_boundary_density_pass: 0.0,
529                mean_persistent_boundary_density_failure: 0.0,
530                mean_persistent_boundary_density_pass: 0.0,
531                mean_raw_violation_density_failure: 0.0,
532                mean_raw_violation_density_pass: 0.0,
533                mean_persistent_violation_density_failure: 0.0,
534                mean_persistent_violation_density_pass: 0.0,
535                mean_threshold_density_failure: 0.0,
536                mean_threshold_density_pass: 0.0,
537                mean_ewma_density_failure: 0.0,
538                mean_ewma_density_pass: 0.0,
539                mean_cusum_density_failure: 0.0,
540                mean_cusum_density_pass: 0.0,
541            },
542            boundary_episode_summary: BoundaryEpisodeSummary {
543                raw_episode_count: 0,
544                persistent_episode_count: 0,
545                mean_raw_episode_length: None,
546                mean_persistent_episode_length: None,
547                max_raw_episode_length: 0,
548                max_persistent_episode_length: 0,
549                raw_non_escalating_episode_fraction: None,
550                persistent_non_escalating_episode_fraction: None,
551            },
552            dsa_summary: None,
553            motif_metrics: vec![
554                MotifMetric {
555                    motif_name: PRE_FAILURE_SLOW_DRIFT.into(),
556                    point_hits: 5,
557                    run_hits: 4,
558                    pre_failure_window_run_hits: 3,
559                    pre_failure_window_precision_proxy: Some(0.75),
560                },
561                MotifMetric {
562                    motif_name: TRANSIENT_EXCURSION.into(),
563                    point_hits: 2,
564                    run_hits: 2,
565                    pre_failure_window_run_hits: 1,
566                    pre_failure_window_precision_proxy: Some(0.5),
567                },
568                MotifMetric {
569                    motif_name: RECURRENT_BOUNDARY_APPROACH.into(),
570                    point_hits: 7,
571                    run_hits: 5,
572                    pre_failure_window_run_hits: 3,
573                    pre_failure_window_precision_proxy: Some(0.6),
574                },
575            ],
576            per_failure_run_signals: Vec::new(),
577            density_metrics: Vec::new(),
578            feature_metrics: Vec::new(),
579            top_feature_indices: Vec::new(),
580        }
581    }
582
583    #[test]
584    fn heuristic_policy_mapping_is_deterministic() {
585        let bank = build_heuristics_bank(&sample_metrics(), "SECOM");
586        let transient = bank
587            .iter()
588            .find(|entry| entry.motif_name == TRANSIENT_EXCURSION)
589            .unwrap();
590        let recurrent = bank
591            .iter()
592            .find(|entry| entry.motif_name == RECURRENT_BOUNDARY_APPROACH)
593            .unwrap();
594        let drift = bank
595            .iter()
596            .find(|entry| entry.motif_name == PRE_FAILURE_SLOW_DRIFT)
597            .unwrap();
598
599        assert_eq!(transient.alert_class_default, HeuristicAlertClass::Silent);
600        assert_eq!(recurrent.alert_class_default, HeuristicAlertClass::Watch);
601        assert_eq!(drift.alert_class_default, HeuristicAlertClass::Review);
602        assert!(transient.requires_corroboration);
603        assert!(recurrent.requires_corroboration);
604        assert!(!drift.requires_corroboration);
605    }
606
607    #[test]
608    fn maximum_fragmentation_defaults_follow_minimum_hits() {
609        let transient = heuristic_policy_definition(TRANSIENT_EXCURSION).unwrap();
610        let recurrent = heuristic_policy_definition(RECURRENT_BOUNDARY_APPROACH).unwrap();
611
612        assert!((transient.maximum_allowed_fragmentation() - 0.5).abs() < 1.0e-9);
613        assert!((recurrent.maximum_allowed_fragmentation() - (1.0 / 3.0)).abs() < 1.0e-9);
614    }
615}