Skip to main content

parlov_analysis/existence/
analyzer.rs

1//! `ExistenceAnalyzer` — delegates classification to the pattern table in `classifier`.
2
3#![deny(clippy::all)]
4#![warn(clippy::pedantic)]
5
6use parlov_core::{OracleClass, OracleResult, OracleVerdict, ProbeSet, ResponseSurface};
7
8use crate::{Analyzer, SampleDecision};
9
10use super::classifier::classify;
11
12/// Analyzes a `ProbeSet` for GET existence oracle signals via status-code differential.
13///
14/// Requires three samples when a differential is detected to confirm stability before
15/// classifying. Short-circuits on same-status pairs (`NotPresent`) after the first sample.
16/// Delegates pattern classification to `classifier::classify`.
17pub struct ExistenceAnalyzer;
18
19impl Analyzer for ExistenceAnalyzer {
20    fn evaluate(&self, data: &ProbeSet) -> SampleDecision {
21        let b0 = data.baseline[0].status;
22        let p0 = data.probe[0].status;
23
24        if b0 == p0 {
25            return SampleDecision::Complete(classify(b0, p0));
26        }
27
28        let samples = data.baseline.len();
29        if samples < 3 {
30            return SampleDecision::NeedMore;
31        }
32
33        let stable = is_consistent(&data.baseline) && is_consistent(&data.probe);
34        if stable {
35            SampleDecision::Complete(classify(b0, p0))
36        } else {
37            SampleDecision::Complete(unstable_result(&data.baseline, &data.probe))
38        }
39    }
40
41    fn oracle_class(&self) -> OracleClass {
42        OracleClass::Existence
43    }
44}
45
46/// Returns `true` when every surface in `surfaces` shares the same status as the first.
47fn is_consistent(surfaces: &[ResponseSurface]) -> bool {
48    surfaces.iter().all(|s| s.status == surfaces[0].status)
49}
50
51fn unstable_result(baseline: &[ResponseSurface], probe: &[ResponseSurface]) -> OracleResult {
52    let baseline_stable = is_consistent(baseline);
53    let probe_stable = is_consistent(probe);
54
55    let which = match (baseline_stable, probe_stable) {
56        (false, false) => "baseline and probe sides",
57        (false, true) => "baseline side",
58        (true, false) => "probe side",
59        (true, true) => unreachable!("unstable_result called when both sides are stable"),
60    };
61
62    OracleResult {
63        class: OracleClass::Existence,
64        verdict: OracleVerdict::NotPresent,
65        severity: None,
66        evidence: vec![format!("status codes were inconsistent across samples on {which}")],
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use bytes::Bytes;
74    use http::{HeaderMap, StatusCode};
75    use parlov_core::{OracleVerdict, ProbeSet, ResponseSurface, Severity};
76
77    fn surface(status: u16) -> ResponseSurface {
78        ResponseSurface {
79            status: StatusCode::from_u16(status).expect("valid status code"),
80            headers: HeaderMap::new(),
81            body: Bytes::new(),
82            timing_ns: 0,
83        }
84    }
85
86    fn probe_set_1(baseline_status: u16, probe_status: u16) -> ProbeSet {
87        ProbeSet {
88            baseline: vec![surface(baseline_status)],
89            probe: vec![surface(probe_status)],
90        }
91    }
92
93    fn probe_set_n(baseline_statuses: &[u16], probe_statuses: &[u16]) -> ProbeSet {
94        ProbeSet {
95            baseline: baseline_statuses.iter().map(|&s| surface(s)).collect(),
96            probe: probe_statuses.iter().map(|&s| surface(s)).collect(),
97        }
98    }
99
100    // --- Round 1 new failing tests (written before implementation) ---
101
102    #[test]
103    fn evaluate_1_sample_diff_returns_need_more() {
104        let ps = probe_set_1(403, 404);
105        assert!(matches!(ExistenceAnalyzer.evaluate(&ps), SampleDecision::NeedMore));
106    }
107
108    #[test]
109    fn evaluate_2_samples_diff_returns_need_more() {
110        let ps = probe_set_n(&[403, 403], &[404, 404]);
111        assert!(matches!(ExistenceAnalyzer.evaluate(&ps), SampleDecision::NeedMore));
112    }
113
114    #[test]
115    fn evaluate_3_samples_stable_diff_confirmed() {
116        let ps = probe_set_n(&[403, 403, 403], &[404, 404, 404]);
117        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
118            panic!("expected Complete");
119        };
120        assert_eq!(result.verdict, OracleVerdict::Confirmed);
121        assert_eq!(result.severity, Some(Severity::High));
122    }
123
124    #[test]
125    fn evaluate_3_samples_unstable_probe_not_present() {
126        // Probe alternates: 404, 200, 404 — unstable probe side
127        let ps = probe_set_n(&[403, 403, 403], &[404, 200, 404]);
128        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
129            panic!("expected Complete");
130        };
131        assert_eq!(result.verdict, OracleVerdict::NotPresent);
132        assert_eq!(result.severity, None);
133    }
134
135    #[test]
136    fn evaluate_3_samples_unstable_baseline_not_present() {
137        // Baseline alternates: 403, 200, 403 — unstable baseline side
138        let ps = probe_set_n(&[403, 200, 403], &[404, 404, 404]);
139        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
140            panic!("expected Complete");
141        };
142        assert_eq!(result.verdict, OracleVerdict::NotPresent);
143        assert_eq!(result.severity, None);
144    }
145
146    // --- Pre-existing tests (updated where needed) ---
147
148    #[test]
149    fn evaluate_complete_not_present_on_same_status() {
150        // Same status short-circuits at 1 sample → Complete(NotPresent)
151        let ps = probe_set_1(404, 404);
152        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
153            panic!("expected Complete");
154        };
155        assert_eq!(result.verdict, OracleVerdict::NotPresent);
156        assert_eq!(result.severity, None);
157    }
158
159    #[test]
160    fn evaluate_complete_confirmed_high_on_403_vs_404() {
161        let ps = probe_set_n(&[403, 403, 403], &[404, 404, 404]);
162        let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
163            panic!("expected Complete");
164        };
165        assert_eq!(result.verdict, OracleVerdict::Confirmed);
166        assert_eq!(result.severity, Some(Severity::High));
167    }
168
169    #[test]
170    fn analyze_provided_method_delegates_to_evaluate() {
171        // 3-sample stable ProbeSet so evaluate returns Complete (not NeedMore)
172        let ps = probe_set_n(&[200, 200, 200], &[404, 404, 404]);
173        let result = ExistenceAnalyzer.analyze(&ps);
174        assert_eq!(result.verdict, OracleVerdict::Confirmed);
175        assert_eq!(result.severity, Some(Severity::High));
176    }
177
178    /// Verify `NeedMore` is a valid variant — used by adaptive analyzers in later phases.
179    #[test]
180    fn need_more_variant_is_constructible() {
181        let _decision: SampleDecision = SampleDecision::NeedMore;
182    }
183}