Skip to main content

tp_lib_core/detections/
validate.rs

1//! Detection validation (T012).
2//!
3//! Performs cross-detection structural & semantic validation:
4//! - linear time ordering (`t_from <= t_to`)
5//! - netelement existence (FATAL `UnknownNetelement`)
6//! - intrinsic ∈ [0, 1] (FATAL `InvalidIntrinsic`)
7//! - same-timestamp + same netelement → recoverable duplicate
8//! - same-timestamp + different netelement → FATAL `ConflictingDetections`
9
10use std::collections::HashMap;
11
12use crate::models::{
13    Detection, DetectionKind, DetectionRecord, DetectionStatus, DiscardReason, Netelement,
14    TimestampOrRange,
15};
16
17use super::error::DetectionError;
18
19/// Output of [`validate_detections`]: surviving detections plus duplicate
20/// discard records.
21#[derive(Debug, Clone, Default)]
22pub struct ValidationOutcome {
23    pub kept: Vec<Detection>,
24    pub duplicate_records: Vec<DetectionRecord>,
25}
26
27/// Validate a set of detections against the available netelements.
28pub fn validate_detections(
29    detections: Vec<Detection>,
30    netelements: &[Netelement],
31) -> Result<ValidationOutcome, DetectionError> {
32    let known_ids: std::collections::HashSet<&str> =
33        netelements.iter().map(|n| n.id.as_str()).collect();
34
35    let mut seen: HashMap<(String, String), usize> = HashMap::new();
36    let mut kept: Vec<Detection> = Vec::with_capacity(detections.len());
37    let mut duplicate_records = Vec::new();
38
39    for det in detections.into_iter() {
40        match &det {
41            Detection::Punctual(p) => {
42                if let Some(loc) = &p.location {
43                    if !known_ids.contains(loc.netelement_id.as_str()) {
44                        return Err(DetectionError::UnknownNetelement {
45                            source_file: p.source_file.clone(),
46                            source_row: p.source_row,
47                            netelement_id: loc.netelement_id.clone(),
48                        });
49                    }
50                    if !(0.0..=1.0).contains(&loc.intrinsic) {
51                        return Err(DetectionError::InvalidIntrinsic {
52                            source_file: p.source_file.clone(),
53                            source_row: p.source_row,
54                            value: loc.intrinsic,
55                        });
56                    }
57                }
58
59                let ts_key = p.timestamp.to_rfc3339();
60                let ne_key = p
61                    .location
62                    .as_ref()
63                    .map(|l| l.netelement_id.clone())
64                    .unwrap_or_default();
65
66                if !ne_key.is_empty() {
67                    for (prior_ts, prior_ne) in seen.keys() {
68                        if prior_ts == &ts_key && prior_ne != &ne_key && !prior_ne.is_empty() {
69                            return Err(DetectionError::ConflictingDetections {
70                                timestamp: p.timestamp,
71                                netelement_a: prior_ne.clone(),
72                                netelement_b: ne_key.clone(),
73                            });
74                        }
75                    }
76                }
77
78                let key = (ts_key, ne_key);
79                if !key.1.is_empty() {
80                    if let Some(&kept_index) = seen.get(&key) {
81                        duplicate_records.push(DetectionRecord {
82                            source_file: p.source_file.clone(),
83                            source_row: p.source_row,
84                            kind: DetectionKind::Punctual,
85                            timestamp: TimestampOrRange::Single {
86                                timestamp: p.timestamp,
87                            },
88                            status: DetectionStatus::Discarded {
89                                reason: DiscardReason::DuplicateOfPriorDetection { kept_index },
90                            },
91                            id: p.id.clone(),
92                            source: p.source.clone(),
93                            metadata: p.metadata.clone(),
94                        });
95                        continue;
96                    }
97                    seen.insert(key, kept.len());
98                }
99                kept.push(det);
100            }
101            Detection::Linear(l) => {
102                if l.t_to < l.t_from {
103                    return Err(DetectionError::InvalidTimeRange {
104                        source_file: l.source_file.clone(),
105                        source_row: l.source_row,
106                        t_from: l.t_from,
107                        t_to: l.t_to,
108                    });
109                }
110                if !known_ids.contains(l.netelement_id.as_str()) {
111                    return Err(DetectionError::UnknownNetelement {
112                        source_file: l.source_file.clone(),
113                        source_row: l.source_row,
114                        netelement_id: l.netelement_id.clone(),
115                    });
116                }
117                if !(0.0..=1.0).contains(&l.start_intrinsic) {
118                    return Err(DetectionError::InvalidIntrinsic {
119                        source_file: l.source_file.clone(),
120                        source_row: l.source_row,
121                        value: l.start_intrinsic,
122                    });
123                }
124                if !(0.0..=1.0).contains(&l.end_intrinsic) {
125                    return Err(DetectionError::InvalidIntrinsic {
126                        source_file: l.source_file.clone(),
127                        source_row: l.source_row,
128                        value: l.end_intrinsic,
129                    });
130                }
131                kept.push(det);
132            }
133        }
134    }
135
136    Ok(ValidationOutcome {
137        kept,
138        duplicate_records,
139    })
140}