1use serde::{Deserialize, Serialize};
4
5use crate::{BlockFamily, BlockSummary, ObservabilityStatus, OracleClass, OracleVerdict, Severity};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct EndpointVerdict {
10 pub oracle_class: OracleClass,
12 pub posterior_probability: f64,
14 pub verdict: OracleVerdict,
16 pub severity: Option<Severity>,
18 pub strategies_run: usize,
20 pub strategies_total: usize,
22 pub stop_reason: Option<EndpointStopReason>,
24 #[serde(skip_serializing_if = "Option::is_none")]
29 pub first_threshold_crossed_by: Option<String>,
30 #[serde(skip_serializing_if = "Option::is_none")]
34 pub final_confirming_strategy: Option<String>,
35 pub contributing_findings: Vec<ContributingFinding>,
37 pub observability_status: ObservabilityStatus,
39 #[serde(skip_serializing_if = "Option::is_none")]
42 pub block_summary: Option<BlockSummary>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct ContributingFinding {
48 pub strategy_id: String,
50 pub strategy_name: String,
52 pub outcome_kind: StrategyOutcomeKind,
54 pub log_odds_contribution: f64,
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub block_family: Option<BlockFamily>,
59 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub enum StrategyOutcomeKind {
79 Positive,
81 NoSignal,
83 Contradictory,
85 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#[derive(Debug, Clone, Serialize, Deserialize)]
102pub enum EndpointStopReason {
103 EarlyAccept,
106 EarlyReject,
111 ExhaustedPlan,
113}
114
115#[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#[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 #[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 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 #[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}