parlov-core 0.7.0

Shared types, error types, and oracle class definitions for parlov.
Documentation
//! Endpoint-level aggregated verdict after running all strategies.

use serde::{Deserialize, Serialize};

use crate::{BlockFamily, BlockSummary, ObservabilityStatus, OracleClass, OracleVerdict, Severity};

/// Aggregated oracle verdict for a single endpoint, produced after running all strategies.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EndpointVerdict {
    /// Oracle class being evaluated.
    pub oracle_class: OracleClass,
    /// Bayesian posterior: `[0.0, 1.0]`
    pub posterior_probability: f64,
    /// Derived from posterior via threshold mapping.
    pub verdict: OracleVerdict,
    /// `None` when `Inconclusive` or `NotPresent`
    pub severity: Option<Severity>,
    /// Number of strategies dispatched during this scan.
    pub strategies_run: usize,
    /// Total strategies planned at scan start — denominator for coverage.
    pub strategies_total: usize,
    /// `None` only while scan is still running
    pub stop_reason: Option<EndpointStopReason>,
    /// Strategy being ingested when the running posterior first crossed the confirm threshold.
    /// Populated in exhaustive mode only. MAY have `log_odds_contribution == 0.0` in the final
    /// attribution when schedule-capping pushes the crossing strategy past its slot limit.
    /// Use `final_confirming_strategy` for operator-facing explanations.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub first_threshold_crossed_by: Option<String>,
    /// First strategy in scan order where the cumulative final-attributed `log_odds_contribution`
    /// crosses the confirm threshold. MUST have `log_odds_contribution > 0.0` by construction.
    /// `None` when verdict is not `Confirmed`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub final_confirming_strategy: Option<String>,
    /// Per-strategy contributions to the posterior.
    pub contributing_findings: Vec<ContributingFinding>,
    /// Whether techniques actually reached the oracle layer.
    pub observability_status: ObservabilityStatus,
    /// `Some` only when `observability_status` is `BlockedBeforeOracleLayer` or
    /// `PartiallyBlocked`; `None` for all other statuses.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub block_summary: Option<BlockSummary>,
}

/// One strategy's contribution to the endpoint posterior.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContributingFinding {
    /// e.g. `"existence-get-200-404"`
    pub strategy_id: String,
    /// Human-readable strategy name for display.
    pub strategy_name: String,
    /// How this outcome was classified for aggregation.
    pub outcome_kind: StrategyOutcomeKind,
    /// log-odds delta applied to the running total
    pub log_odds_contribution: f64,
    /// Block family for `Inapplicable` outcomes; `None` for all other kinds.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub block_family: Option<BlockFamily>,
    /// `PreconditionBlock::as_str()` for `Inapplicable` outcomes; `None` otherwise.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub block_reason: Option<String>,
}

impl std::fmt::Display for EndpointStopReason {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::EarlyAccept => write!(f, "EarlyAccept"),
            Self::EarlyReject => write!(f, "EarlyReject"),
            Self::ExhaustedPlan => write!(f, "ExhaustedPlan"),
        }
    }
}

/// Classification of a strategy outcome for aggregation.
///
/// Mirrors `StrategyOutcome` variants without carrying the `OracleResult` payload.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum StrategyOutcomeKind {
    /// Strategy observed a differential supporting existence.
    Positive,
    /// Strategy ran but produced no actionable differential.
    NoSignal,
    /// Strategy observed a pattern that actively supports nonexistence.
    Contradictory,
    /// Strategy could not run due to missing prerequisites.
    Inapplicable,
}

impl std::fmt::Display for StrategyOutcomeKind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Positive => write!(f, "Positive"),
            Self::NoSignal => write!(f, "NoSignal"),
            Self::Contradictory => write!(f, "Contradictory"),
            Self::Inapplicable => write!(f, "Inapplicable"),
        }
    }
}

/// Reason the scan stopped dispatching strategies.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum EndpointStopReason {
    /// Posterior is high enough that even worst-case remaining evidence cannot drop it below
    /// the confirm threshold.
    EarlyAccept,
    /// Stopped early because remaining strategies cannot raise the posterior above
    /// the `Likely` threshold (logit(0.60)). The current verdict may be
    /// `Inconclusive` if the posterior has not fallen below the `NotPresent`
    /// threshold (0.20) either.
    EarlyReject,
    /// All planned strategies were dispatched.
    ExhaustedPlan,
}

/// Maps a posterior probability to an `OracleVerdict` via threshold rules.
///
/// - `p >= 0.80`        → `Confirmed`
/// - `0.60 <= p < 0.80` → `Likely`
/// - `p <= 0.20`        → `NotPresent`
/// - otherwise          → `Inconclusive`
#[must_use]
pub fn posterior_to_verdict(p: f64) -> OracleVerdict {
    if p >= 0.80 {
        OracleVerdict::Confirmed
    } else if p >= 0.60 {
        OracleVerdict::Likely
    } else if p <= 0.20 {
        OracleVerdict::NotPresent
    } else {
        OracleVerdict::Inconclusive
    }
}

/// Maps an `OracleVerdict` to the appropriate `Severity`, if any.
///
/// Returns `None` for `Inconclusive` and `NotPresent`.
#[must_use]
pub fn verdict_to_severity(verdict: OracleVerdict) -> Option<Severity> {
    match verdict {
        OracleVerdict::Confirmed => Some(Severity::High),
        OracleVerdict::Likely => Some(Severity::Medium),
        OracleVerdict::Inconclusive | OracleVerdict::NotPresent => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // --- posterior_to_verdict boundary tests ---

    #[test]
    fn posterior_to_verdict_at_0_80_is_confirmed() {
        assert_eq!(posterior_to_verdict(0.80), OracleVerdict::Confirmed);
    }

    #[test]
    fn posterior_to_verdict_at_0_60_is_likely() {
        assert_eq!(posterior_to_verdict(0.60), OracleVerdict::Likely);
    }

    #[test]
    fn posterior_to_verdict_at_0_21_is_inconclusive() {
        assert_eq!(posterior_to_verdict(0.21), OracleVerdict::Inconclusive);
    }

    #[test]
    fn posterior_to_verdict_at_0_20_is_not_present() {
        assert_eq!(posterior_to_verdict(0.20), OracleVerdict::NotPresent);
    }

    #[test]
    fn posterior_to_verdict_at_0_79_is_likely() {
        // 0.79 satisfies p >= 0.60 so maps to Likely, not Inconclusive.
        assert_eq!(posterior_to_verdict(0.79), OracleVerdict::Likely);
    }

    #[test]
    fn posterior_to_verdict_at_0_59_is_inconclusive() {
        assert_eq!(posterior_to_verdict(0.59), OracleVerdict::Inconclusive);
    }

    // --- serialization round-trips ---

    #[test]
    fn endpoint_verdict_roundtrip() {
        let v = EndpointVerdict {
            oracle_class: OracleClass::Existence,
            posterior_probability: 0.85,
            verdict: OracleVerdict::Confirmed,
            severity: Some(Severity::High),
            strategies_run: 5,
            strategies_total: 10,
            stop_reason: Some(EndpointStopReason::EarlyAccept),
            first_threshold_crossed_by: None,
            final_confirming_strategy: None,
            contributing_findings: vec![ContributingFinding {
                strategy_id: "existence-get-200-404".to_owned(),
                strategy_name: "GET 200/404 existence".to_owned(),
                outcome_kind: StrategyOutcomeKind::Positive,
                log_odds_contribution: 1.73,
                block_family: None,
                block_reason: None,
            }],
            observability_status: ObservabilityStatus::EvidenceObserved,
            block_summary: None,
        };
        let json = serde_json::to_string(&v).expect("serialization failed");
        let back: EndpointVerdict = serde_json::from_str(&json).expect("deserialization failed");
        assert_eq!(back.strategies_run, 5);
        assert_eq!(back.strategies_total, 10);
        assert!((back.posterior_probability - 0.85).abs() < f64::EPSILON);
        assert_eq!(back.contributing_findings.len(), 1);
    }

    #[test]
    fn endpoint_stop_reason_all_variants_roundtrip() {
        for reason in [
            EndpointStopReason::EarlyAccept,
            EndpointStopReason::EarlyReject,
            EndpointStopReason::ExhaustedPlan,
        ] {
            let json = serde_json::to_string(&reason).expect("serialization failed");
            let _back: EndpointStopReason =
                serde_json::from_str(&json).expect("deserialization failed");
        }
    }

    #[test]
    fn endpoint_stop_reason_display() {
        assert_eq!(
            format!("{}", EndpointStopReason::EarlyAccept),
            "EarlyAccept"
        );
        assert_eq!(
            format!("{}", EndpointStopReason::EarlyReject),
            "EarlyReject"
        );
        assert_eq!(
            format!("{}", EndpointStopReason::ExhaustedPlan),
            "ExhaustedPlan"
        );
    }

    #[test]
    fn strategy_outcome_kind_display() {
        assert_eq!(format!("{}", StrategyOutcomeKind::Positive), "Positive");
        assert_eq!(format!("{}", StrategyOutcomeKind::NoSignal), "NoSignal");
        assert_eq!(
            format!("{}", StrategyOutcomeKind::Contradictory),
            "Contradictory"
        );
        assert_eq!(
            format!("{}", StrategyOutcomeKind::Inapplicable),
            "Inapplicable"
        );
    }

    #[test]
    fn strategy_outcome_kind_all_variants_roundtrip() {
        for kind in [
            StrategyOutcomeKind::Positive,
            StrategyOutcomeKind::NoSignal,
            StrategyOutcomeKind::Contradictory,
            StrategyOutcomeKind::Inapplicable,
        ] {
            let json = serde_json::to_string(&kind).expect("serialization failed");
            let _back: StrategyOutcomeKind =
                serde_json::from_str(&json).expect("deserialization failed");
        }
    }
}