parlov-core 0.7.0

Shared types, error types, and oracle class definitions for parlov.
Documentation
use super::*;
use crate::StrategyOutcomeKind;

fn inapplicable_finding(block_family: Option<BlockFamily>, reason: &str) -> ContributingFinding {
    ContributingFinding {
        strategy_id: "test-strategy".to_owned(),
        strategy_name: "Test Strategy".to_owned(),
        outcome_kind: StrategyOutcomeKind::Inapplicable,
        log_odds_contribution: 0.0,
        block_family,
        block_reason: Some(reason.to_owned()),
    }
}

fn contradictory_finding() -> ContributingFinding {
    ContributingFinding {
        strategy_id: "test-strategy".to_owned(),
        strategy_name: "Test Strategy".to_owned(),
        outcome_kind: StrategyOutcomeKind::Contradictory,
        log_odds_contribution: -0.2,
        block_family: None,
        block_reason: None,
    }
}

fn positive_finding() -> ContributingFinding {
    ContributingFinding {
        strategy_id: "test-strategy".to_owned(),
        strategy_name: "Test Strategy".to_owned(),
        outcome_kind: StrategyOutcomeKind::Positive,
        log_odds_contribution: 0.75,
        block_family: None,
        block_reason: None,
    }
}

fn no_signal_finding() -> ContributingFinding {
    ContributingFinding {
        strategy_id: "test-strategy".to_owned(),
        strategy_name: "Test Strategy".to_owned(),
        outcome_kind: StrategyOutcomeKind::NoSignal,
        log_odds_contribution: 0.0,
        block_family: None,
        block_reason: None,
    }
}

#[test]
fn evidence_bearing_returns_evidence_observed() {
    let findings = vec![positive_finding()];
    let (status, summary) = compute_observability(&findings);
    assert_eq!(status, ObservabilityStatus::EvidenceObserved);
    assert!(summary.is_none());
}

#[test]
fn contradictory_returns_evidence_observed() {
    let findings = vec![contradictory_finding(), contradictory_finding()];
    let (status, _) = compute_observability(&findings);
    assert_eq!(status, ObservabilityStatus::EvidenceObserved);
}

#[test]
fn fewer_than_3_opportunities_returns_underpowered() {
    let findings = vec![
        inapplicable_finding(
            Some(BlockFamily::Authorization),
            "auth gate fired before technique (no credential provided)",
        ),
        inapplicable_finding(
            Some(BlockFamily::Authorization),
            "auth gate fired before technique (no credential provided)",
        ),
    ];
    let (status, _) = compute_observability(&findings);
    assert_eq!(status, ObservabilityStatus::Underpowered);
}

#[test]
fn all_auth_blocked_returns_blocked_before_oracle_layer() {
    let findings: Vec<_> = (0..8)
        .map(|_| {
            inapplicable_finding(
                Some(BlockFamily::Authorization),
                "auth gate fired before technique (no credential provided)",
            )
        })
        .collect();
    let (status, summary) = compute_observability(&findings);
    assert_eq!(status, ObservabilityStatus::BlockedBeforeOracleLayer);
    let s = summary.expect("summary must be present for BlockedBeforeOracleLayer");
    assert_eq!(s.dominant_block_family, BlockFamily::Authorization);
    assert!(s.blocked_fraction >= 0.80);
    assert!(
        s.operator_action
            .as_deref()
            .unwrap_or("")
            .contains("Authorization: Bearer"),
        "operator_action must mention Authorization: Bearer"
    );
}

#[test]
fn all_no_signal_returns_probed_no_evidence() {
    // All 8 inapplicable are TechniqueLocal — not scan-wide blocked; reached_fraction = 1.0.
    let mut findings: Vec<ContributingFinding> = (0..8)
        .map(|_| {
            inapplicable_finding(
                Some(BlockFamily::TechniqueLocal),
                "applicability marker not observed",
            )
        })
        .collect();
    findings.extend((0..2).map(|_| no_signal_finding()));
    let (status, _) = compute_observability(&findings);
    assert_eq!(status, ObservabilityStatus::ProbedNoEvidence);
}

#[test]
fn mixed_blocked_returns_partially_blocked() {
    // 4 auth-blocked, 4 technique-local; opportunities = 8, scan_wide_blocked = 4 → 0.5
    let blocked: Vec<_> = (0..4)
        .map(|_| {
            inapplicable_finding(
                Some(BlockFamily::Authorization),
                "auth gate fired before technique (no credential provided)",
            )
        })
        .collect();
    let local: Vec<_> = (0..4)
        .map(|_| {
            inapplicable_finding(
                Some(BlockFamily::TechniqueLocal),
                "applicability marker not observed",
            )
        })
        .collect();
    let findings: Vec<_> = blocked.into_iter().chain(local).collect();
    let (status, summary) = compute_observability(&findings);
    assert_eq!(status, ObservabilityStatus::PartiallyBlocked);
    assert!(summary.is_some());
}

#[test]
fn no_findings_returns_underpowered() {
    let (status, _) = compute_observability(&[]);
    assert_eq!(status, ObservabilityStatus::Underpowered);
}

#[test]
fn method_blocked_returns_blocked_before_oracle_layer() {
    let findings: Vec<_> = (0..5)
        .map(|_| {
            inapplicable_finding(
                Some(BlockFamily::Method),
                "method-level rejection before resource lookup",
            )
        })
        .collect();
    let (status, summary) = compute_observability(&findings);
    assert_eq!(status, ObservabilityStatus::BlockedBeforeOracleLayer);
    let s = summary.unwrap();
    assert_eq!(s.dominant_block_family, BlockFamily::Method);
    assert!(s
        .operator_action
        .as_deref()
        .unwrap_or("")
        .contains("HTTP method"));
}

#[test]
fn blocked_fraction_is_clamped_to_unit_interval() {
    let findings: Vec<_> = (0..10)
        .map(|_| {
            inapplicable_finding(
                Some(BlockFamily::Authorization),
                "auth gate fired before technique (no credential provided)",
            )
        })
        .collect();
    let (_, summary) = compute_observability(&findings);
    let s = summary.unwrap();
    assert!((0.0..=1.0).contains(&s.blocked_fraction));
}

// --- property tests ---

use proptest::prelude::*;

proptest! {
    /// Any positive finding must yield `EvidenceObserved` regardless of inapplicable count.
    #[test]
    fn evidence_bearing_always_yields_evidence_observed(
        inapplicable_count in 0usize..20usize,
    ) {
        let mut findings: Vec<ContributingFinding> = (0..inapplicable_count)
            .map(|_| inapplicable_finding(
                Some(BlockFamily::Authorization),
                "auth gate fired before technique (no credential provided)",
            ))
            .collect();
        findings.push(positive_finding());

        let (status, _) = compute_observability(&findings);
        prop_assert_eq!(
            status,
            ObservabilityStatus::EvidenceObserved,
            "presence of positive finding must yield EvidenceObserved"
        );
    }

    /// `blocked_fraction` in any `BlockSummary` must be in `[0.0, 1.0]`.
    #[test]
    fn block_summary_fraction_is_in_unit_interval(
        auth_count in 0usize..20usize,
        method_count in 0usize..20usize,
        local_count in 0usize..20usize,
    ) {
        let mut findings = Vec::new();
        for _ in 0..auth_count {
            findings.push(inapplicable_finding(
                Some(BlockFamily::Authorization),
                "auth gate fired before technique (no credential provided)",
            ));
        }
        for _ in 0..method_count {
            findings.push(inapplicable_finding(
                Some(BlockFamily::Method),
                "method-level rejection before resource lookup",
            ));
        }
        for _ in 0..local_count {
            findings.push(inapplicable_finding(
                Some(BlockFamily::TechniqueLocal),
                "applicability marker not observed",
            ));
        }
        let (_, summary) = compute_observability(&findings);
        if let Some(s) = summary {
            prop_assert!(
                (0.0..=1.0).contains(&s.blocked_fraction),
                "blocked_fraction out of range: {}",
                s.blocked_fraction
            );
        }
    }

    /// `BlockedBeforeOracleLayer` requires dominant family to be `Authorization` or `Method`.
    #[test]
    fn blocked_before_oracle_dominant_family_is_scan_wide(
        count in 3usize..20usize,
        family in prop_oneof![Just(BlockFamily::Authorization), Just(BlockFamily::Method)],
    ) {
        let reason = match family {
            BlockFamily::Authorization =>
                "auth gate fired before technique (no credential provided)",
            BlockFamily::Method => "method-level rejection before resource lookup",
            _ => unreachable!(),
        };
        let findings: Vec<_> = (0..count)
            .map(|_| inapplicable_finding(Some(family), reason))
            .collect();
        let (status, summary) = compute_observability(&findings);
        if status == ObservabilityStatus::BlockedBeforeOracleLayer {
            let s = summary.expect("block_summary must be Some for BlockedBeforeOracleLayer");
            prop_assert!(
                matches!(s.dominant_block_family, BlockFamily::Authorization | BlockFamily::Method),
                "dominant_block_family must be a scan-wide family"
            );
        }
    }
}