parlov-analysis 0.7.0

Analysis engine trait and signal detection for parlov.
Documentation
use parlov_core::{
    ImpactClass, NormativeStrength, OracleClass, OracleResult, OracleVerdict, Severity,
    StrategyMetaForStop, StrategyOutcome, Vector,
};

use super::*;

fn make_result(confidence: u8) -> OracleResult {
    OracleResult {
        class: OracleClass::Existence,
        verdict: OracleVerdict::Confirmed,
        severity: Some(Severity::High),
        confidence,
        impact_class: Some(ImpactClass::High),
        reasons: vec![],
        signals: vec![],
        technique_id: None,
        vector: None,
        normative_strength: None,
        label: None,
        leaks: None,
        rfc_basis: None,
    }
}

fn meta(v: Vector) -> StrategyMetaForStop {
    StrategyMetaForStop {
        vector: v,
        normative_strength: NormativeStrength::Must,
    }
}

/// Build an accumulator whose `log_odds` closely approximates `target` by ingesting
/// many high-confidence positives, using different vectors (families) to avoid diminishing returns.
/// Returns the accumulator with `log_odds` >= target.
fn acc_with_log_odds(target: f64) -> EvidenceAccumulator {
    // Use vectors that map to distinct families to maximise the achievable log_odds.
    // StatusCodeDiff→General, CacheProbing→CacheValidator, ErrorMessageGranularity→ErrorBody,
    // RedirectDiff→Redirect. Each family allows 2 contributing slots (1.0 + 0.5 multiplier).
    let vectors = [
        Vector::StatusCodeDiff,
        Vector::CacheProbing,
        Vector::ErrorMessageGranularity,
        Vector::RedirectDiff,
    ];
    let mut acc = EvidenceAccumulator::new();
    let pos = StrategyOutcome::Positive(make_result(99));
    let mut idx = 0usize;
    while acc.log_odds_current() < target {
        let vector = vectors[idx % vectors.len()];
        acc.ingest(&pos, vector);
        idx += 1;
        // Safety valve: once all families exhaust their two slots, stop.
        if idx > vectors.len() * 2 + 2 {
            break;
        }
    }
    debug_assert!(
        acc.log_odds_current() >= target,
        "acc_with_log_odds: target {target} is unreachable with available vectors/families"
    );
    acc
}

/// Build an accumulator with `log_odds` at a given target using negative (contradictory) evidence.
fn acc_with_negative_log_odds(target: f64) -> EvidenceAccumulator {
    assert!(target <= 0.0, "use acc_with_log_odds for positive targets");
    let vectors = [
        Vector::StatusCodeDiff,
        Vector::CacheProbing,
        Vector::ErrorMessageGranularity,
        Vector::RedirectDiff,
    ];
    let mut acc = EvidenceAccumulator::new();
    let contra = StrategyOutcome::Contradictory(make_result(99), 1.0);
    let mut idx = 0usize;
    while acc.log_odds_current() > target {
        let vector = vectors[idx % vectors.len()];
        acc.ingest(&contra, vector);
        idx += 1;
        if idx > vectors.len() * 2 + 2 {
            break;
        }
    }
    debug_assert!(
        acc.log_odds_current() <= target,
        "acc_with_negative_log_odds: target {target} is unreachable with available vectors/families"
    );
    acc
}

// Test 1: fresh accumulator, no remaining → EarlyReject
//
// log_odds=0, max_pos=0: 0+0=0.0 <= likely_threshold(0.405) → EarlyReject.
// There are no remaining strategies and neutral evidence — the posterior cannot rise above the
// likely threshold, so the reject condition fires before Continue is reached.
#[test]
fn fresh_accumulator_no_remaining_is_early_reject() {
    let acc = EvidenceAccumulator::new();
    let rule = StopRule::new();
    assert!(
        matches!(rule.evaluate(&acc, &[]), StopDecision::EarlyReject { .. }),
        "expected EarlyReject when no evidence and no remaining strategies"
    );
}

// Test 2: log_odds=2.0 with max_negative=0.1 → EarlyAccept (2.0-0.1=1.9 >= logit(0.80)≈1.386)
#[test]
fn high_log_odds_with_small_max_neg_is_early_accept() {
    // Build accumulator to approximately 2.0 log-odds
    let acc = acc_with_log_odds(2.0);
    let rule = StopRule::new();
    // Single StatusCodeDiff remaining contributes max_neg = 0.25*1.0 = 0.25.
    // We need max_neg small, so use no remaining or a non-StatusCodeDiff vector.
    // With no remaining, max_neg=0. log_odds=2.0 - 0 = 2.0 >= 1.386 → EarlyAccept.
    let decision = rule.evaluate(&acc, &[]);
    assert!(
        matches!(decision, StopDecision::EarlyAccept { .. }),
        "expected EarlyAccept with log_odds={}, got {decision:?}",
        acc.log_odds_current()
    );
}

// Test 3: log_odds=0.0, no remaining → EarlyReject (0.0+0.0=0.0 < logit(0.60)≈0.405)
//
// A neutral accumulator with no evidence and no remaining strategies cannot reach the Likely
// threshold — EarlyReject fires. This replaces an earlier variant that used a sub-Likely
// confidence value (52) to seed a small positive log-odds; that pattern is now structurally
// invalid since Positive outcomes must carry confidence ≥ 60.
#[test]
fn neutral_accumulator_no_remaining_is_early_reject() {
    let acc = EvidenceAccumulator::new();
    let rule = StopRule::new();
    let decision = rule.evaluate(&acc, &[]);
    assert!(
        matches!(decision, StopDecision::EarlyReject { .. }),
        "expected EarlyReject with neutral log_odds and no remaining, got {decision:?}"
    );
}

// Test 4: log_odds exactly at confirm threshold, max_neg=0 → EarlyAccept (boundary inclusive)
#[test]
fn log_odds_at_confirm_threshold_is_early_accept() {
    // We need log_odds exactly at CONFIRM_THRESHOLD≈1.3862944
    // Use acc_with_log_odds and verify we overshoot slightly, then manually set via ingest.
    // Build to just above the threshold (≥ is inclusive).
    let acc = acc_with_log_odds(CONFIRM_THRESHOLD);
    let rule = StopRule::new();
    let decision = rule.evaluate(&acc, &[]);
    assert!(
        matches!(decision, StopDecision::EarlyAccept { .. }),
        "expected EarlyAccept at confirm boundary, log_odds={}, got {decision:?}",
        acc.log_odds_current()
    );
}

// Test 5: large remaining potential on both sides → Continue
#[test]
fn large_remaining_potential_is_continue() {
    // Neutral accumulator + many remaining strategies of each vector type
    let acc = EvidenceAccumulator::new();
    let rule = StopRule::new();
    let remaining: Vec<StrategyMetaForStop> = (0..10)
        .flat_map(|_| {
            vec![
                meta(Vector::CacheProbing),
                meta(Vector::StatusCodeDiff),
                meta(Vector::ErrorMessageGranularity),
                meta(Vector::RedirectDiff),
            ]
        })
        .collect();
    assert_eq!(
        rule.evaluate(&acc, &remaining),
        StopDecision::Continue,
        "many remaining strategies should keep the decision as Continue"
    );
}

// Test 6: EarlyAccept posterior is in (0.5, 1.0]
#[test]
fn early_accept_posterior_above_half() {
    let acc = acc_with_log_odds(2.0);
    let rule = StopRule::new();
    let decision = rule.evaluate(&acc, &[]);
    if let StopDecision::EarlyAccept { posterior } = decision {
        assert!(
            posterior > 0.5 && posterior <= 1.0,
            "EarlyAccept posterior {posterior} not in (0.5, 1.0]"
        );
    } else {
        panic!("expected EarlyAccept, got {decision:?}");
    }
}

// Test 7: EarlyReject posterior is in [0.0, 0.5)
#[test]
fn early_reject_posterior_below_half() {
    let acc = acc_with_negative_log_odds(-1.0);
    let rule = StopRule::new();
    let decision = rule.evaluate(&acc, &[]);
    if let StopDecision::EarlyReject { posterior } = decision {
        assert!(
            (0.0..0.5).contains(&posterior),
            "EarlyReject posterior {posterior} not in [0.0, 0.5)"
        );
    } else {
        panic!("expected EarlyReject, got {decision:?}");
    }
}

// Test 8 (Issue #1): boundary is exclusive — when log_odds + max_pos equals likely_threshold
// exactly, the posterior is exactly logistic(LIKELY_THRESHOLD) == 0.60, which maps to
// `Likely`. EarlyReject must NOT fire because a Likely verdict is still achievable.
// The fix changes `<=` to `<` on the EarlyReject condition.
//
// Strategy: set `likely_threshold` on a custom StopRule to exactly equal the log_odds we can
// achieve (0.0 + max_pos_from_one_remaining_spec). Then verify the boundary value yields
// Continue, not EarlyReject.
#[test]
fn early_reject_boundary_is_exclusive_at_likely_threshold() {
    // A fresh accumulator has log_odds = 0.0. With one remaining StatusCodeDiff strategy,
    // max_pos = logit(0.99) * 1.0 (first family slot, full weight).
    let acc = EvidenceAccumulator::new();
    let remaining = vec![meta(Vector::StatusCodeDiff)];
    let max_pos = acc.max_positive_remaining(&remaining);

    // Construct a StopRule whose likely_threshold is exactly log_odds + max_pos.
    // This is the boundary case: with `<=` it fires EarlyReject, with `<` it returns Continue.
    let rule = StopRule {
        confirm_threshold: CONFIRM_THRESHOLD,
        likely_threshold: 0.0 + max_pos, // exactly at boundary
    };

    let decision = rule.evaluate(&acc, &remaining);
    assert!(
        matches!(decision, StopDecision::Continue),
        "EarlyReject must not fire at the exact boundary (boundary is exclusive, < not <=); got {decision:?}"
    );
}