Skip to main content

parlov_core/
endpoint_verdict.rs

1//! Endpoint-level aggregated verdict after running all strategies.
2
3use serde::{Deserialize, Serialize};
4
5use crate::{BlockFamily, BlockSummary, ObservabilityStatus, OracleClass, OracleVerdict, Severity};
6
7/// Aggregated oracle verdict for a single endpoint, produced after running all strategies.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct EndpointVerdict {
10    /// Oracle class being evaluated.
11    pub oracle_class: OracleClass,
12    /// Bayesian posterior: `[0.0, 1.0]`
13    pub posterior_probability: f64,
14    /// Derived from posterior via threshold mapping.
15    pub verdict: OracleVerdict,
16    /// `None` when `Inconclusive` or `NotPresent`
17    pub severity: Option<Severity>,
18    /// Number of strategies dispatched during this scan.
19    pub strategies_run: usize,
20    /// Total strategies planned at scan start — denominator for coverage.
21    pub strategies_total: usize,
22    /// `None` only while scan is still running
23    pub stop_reason: Option<EndpointStopReason>,
24    /// Strategy being ingested when the running posterior first crossed the confirm threshold.
25    /// Populated in exhaustive mode only. MAY have `log_odds_contribution == 0.0` in the final
26    /// attribution when schedule-capping pushes the crossing strategy past its slot limit.
27    /// Use `final_confirming_strategy` for operator-facing explanations.
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub first_threshold_crossed_by: Option<String>,
30    /// First strategy in scan order where the cumulative final-attributed `log_odds_contribution`
31    /// crosses the confirm threshold. MUST have `log_odds_contribution > 0.0` by construction.
32    /// `None` when verdict is not `Confirmed`.
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub final_confirming_strategy: Option<String>,
35    /// Per-strategy contributions to the posterior.
36    pub contributing_findings: Vec<ContributingFinding>,
37    /// Whether techniques actually reached the oracle layer.
38    pub observability_status: ObservabilityStatus,
39    /// `Some` only when `observability_status` is `BlockedBeforeOracleLayer` or
40    /// `PartiallyBlocked`; `None` for all other statuses.
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub block_summary: Option<BlockSummary>,
43}
44
45/// One strategy's contribution to the endpoint posterior.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct ContributingFinding {
48    /// e.g. `"existence-get-200-404"`
49    pub strategy_id: String,
50    /// Human-readable strategy name for display.
51    pub strategy_name: String,
52    /// How this outcome was classified for aggregation.
53    pub outcome_kind: StrategyOutcomeKind,
54    /// log-odds delta applied to the running total
55    pub log_odds_contribution: f64,
56    /// Block family for `Inapplicable` outcomes; `None` for all other kinds.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub block_family: Option<BlockFamily>,
59    /// `PreconditionBlock::as_str()` for `Inapplicable` outcomes; `None` otherwise.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub block_reason: Option<String>,
62}
63
64impl std::fmt::Display for EndpointStopReason {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        match self {
67            Self::EarlyAccept => write!(f, "EarlyAccept"),
68            Self::EarlyReject => write!(f, "EarlyReject"),
69            Self::ExhaustedPlan => write!(f, "ExhaustedPlan"),
70        }
71    }
72}
73
74/// Classification of a strategy outcome for aggregation.
75///
76/// Mirrors `StrategyOutcome` variants without carrying the `OracleResult` payload.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub enum StrategyOutcomeKind {
79    /// Strategy observed a differential supporting existence.
80    Positive,
81    /// Strategy ran but produced no actionable differential.
82    NoSignal,
83    /// Strategy observed a pattern that actively supports nonexistence.
84    Contradictory,
85    /// Strategy could not run due to missing prerequisites.
86    Inapplicable,
87}
88
89impl std::fmt::Display for StrategyOutcomeKind {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        match self {
92            Self::Positive => write!(f, "Positive"),
93            Self::NoSignal => write!(f, "NoSignal"),
94            Self::Contradictory => write!(f, "Contradictory"),
95            Self::Inapplicable => write!(f, "Inapplicable"),
96        }
97    }
98}
99
100/// Reason the scan stopped dispatching strategies.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub enum EndpointStopReason {
103    /// Posterior is high enough that even worst-case remaining evidence cannot drop it below
104    /// the confirm threshold.
105    EarlyAccept,
106    /// Stopped early because remaining strategies cannot raise the posterior above
107    /// the `Likely` threshold (logit(0.60)). The current verdict may be
108    /// `Inconclusive` if the posterior has not fallen below the `NotPresent`
109    /// threshold (0.20) either.
110    EarlyReject,
111    /// All planned strategies were dispatched.
112    ExhaustedPlan,
113}
114
115/// Maps a posterior probability to an `OracleVerdict` via threshold rules.
116///
117/// - `p >= 0.80`        → `Confirmed`
118/// - `0.60 <= p < 0.80` → `Likely`
119/// - `p <= 0.20`        → `NotPresent`
120/// - otherwise          → `Inconclusive`
121#[must_use]
122pub fn posterior_to_verdict(p: f64) -> OracleVerdict {
123    if p >= 0.80 {
124        OracleVerdict::Confirmed
125    } else if p >= 0.60 {
126        OracleVerdict::Likely
127    } else if p <= 0.20 {
128        OracleVerdict::NotPresent
129    } else {
130        OracleVerdict::Inconclusive
131    }
132}
133
134/// Maps an `OracleVerdict` to the appropriate `Severity`, if any.
135///
136/// Returns `None` for `Inconclusive` and `NotPresent`.
137#[must_use]
138pub fn verdict_to_severity(verdict: OracleVerdict) -> Option<Severity> {
139    match verdict {
140        OracleVerdict::Confirmed => Some(Severity::High),
141        OracleVerdict::Likely => Some(Severity::Medium),
142        OracleVerdict::Inconclusive | OracleVerdict::NotPresent => None,
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    // --- posterior_to_verdict boundary tests ---
151
152    #[test]
153    fn posterior_to_verdict_at_0_80_is_confirmed() {
154        assert_eq!(posterior_to_verdict(0.80), OracleVerdict::Confirmed);
155    }
156
157    #[test]
158    fn posterior_to_verdict_at_0_60_is_likely() {
159        assert_eq!(posterior_to_verdict(0.60), OracleVerdict::Likely);
160    }
161
162    #[test]
163    fn posterior_to_verdict_at_0_21_is_inconclusive() {
164        assert_eq!(posterior_to_verdict(0.21), OracleVerdict::Inconclusive);
165    }
166
167    #[test]
168    fn posterior_to_verdict_at_0_20_is_not_present() {
169        assert_eq!(posterior_to_verdict(0.20), OracleVerdict::NotPresent);
170    }
171
172    #[test]
173    fn posterior_to_verdict_at_0_79_is_likely() {
174        // 0.79 satisfies p >= 0.60 so maps to Likely, not Inconclusive.
175        assert_eq!(posterior_to_verdict(0.79), OracleVerdict::Likely);
176    }
177
178    #[test]
179    fn posterior_to_verdict_at_0_59_is_inconclusive() {
180        assert_eq!(posterior_to_verdict(0.59), OracleVerdict::Inconclusive);
181    }
182
183    // --- serialization round-trips ---
184
185    #[test]
186    fn endpoint_verdict_roundtrip() {
187        let v = EndpointVerdict {
188            oracle_class: OracleClass::Existence,
189            posterior_probability: 0.85,
190            verdict: OracleVerdict::Confirmed,
191            severity: Some(Severity::High),
192            strategies_run: 5,
193            strategies_total: 10,
194            stop_reason: Some(EndpointStopReason::EarlyAccept),
195            first_threshold_crossed_by: None,
196            final_confirming_strategy: None,
197            contributing_findings: vec![ContributingFinding {
198                strategy_id: "existence-get-200-404".to_owned(),
199                strategy_name: "GET 200/404 existence".to_owned(),
200                outcome_kind: StrategyOutcomeKind::Positive,
201                log_odds_contribution: 1.73,
202                block_family: None,
203                block_reason: None,
204            }],
205            observability_status: ObservabilityStatus::EvidenceObserved,
206            block_summary: None,
207        };
208        let json = serde_json::to_string(&v).expect("serialization failed");
209        let back: EndpointVerdict = serde_json::from_str(&json).expect("deserialization failed");
210        assert_eq!(back.strategies_run, 5);
211        assert_eq!(back.strategies_total, 10);
212        assert!((back.posterior_probability - 0.85).abs() < f64::EPSILON);
213        assert_eq!(back.contributing_findings.len(), 1);
214    }
215
216    #[test]
217    fn endpoint_stop_reason_all_variants_roundtrip() {
218        for reason in [
219            EndpointStopReason::EarlyAccept,
220            EndpointStopReason::EarlyReject,
221            EndpointStopReason::ExhaustedPlan,
222        ] {
223            let json = serde_json::to_string(&reason).expect("serialization failed");
224            let _back: EndpointStopReason =
225                serde_json::from_str(&json).expect("deserialization failed");
226        }
227    }
228
229    #[test]
230    fn endpoint_stop_reason_display() {
231        assert_eq!(
232            format!("{}", EndpointStopReason::EarlyAccept),
233            "EarlyAccept"
234        );
235        assert_eq!(
236            format!("{}", EndpointStopReason::EarlyReject),
237            "EarlyReject"
238        );
239        assert_eq!(
240            format!("{}", EndpointStopReason::ExhaustedPlan),
241            "ExhaustedPlan"
242        );
243    }
244
245    #[test]
246    fn strategy_outcome_kind_display() {
247        assert_eq!(format!("{}", StrategyOutcomeKind::Positive), "Positive");
248        assert_eq!(format!("{}", StrategyOutcomeKind::NoSignal), "NoSignal");
249        assert_eq!(
250            format!("{}", StrategyOutcomeKind::Contradictory),
251            "Contradictory"
252        );
253        assert_eq!(
254            format!("{}", StrategyOutcomeKind::Inapplicable),
255            "Inapplicable"
256        );
257    }
258
259    #[test]
260    fn strategy_outcome_kind_all_variants_roundtrip() {
261        for kind in [
262            StrategyOutcomeKind::Positive,
263            StrategyOutcomeKind::NoSignal,
264            StrategyOutcomeKind::Contradictory,
265            StrategyOutcomeKind::Inapplicable,
266        ] {
267            let json = serde_json::to_string(&kind).expect("serialization failed");
268            let _back: StrategyOutcomeKind =
269                serde_json::from_str(&json).expect("deserialization failed");
270        }
271    }
272}