parlov-analysis 0.7.0

Analysis engine trait and signal detection for parlov.
Documentation
//! Tests pinning the contract that `Contradictory` outcomes contribute negatively to
//! `log_odds`, with a magnitude proportional to `weight` and independent of
//! `result.confidence`. The legacy formula `-logit(p) * weight * multiplier` violated
//! this contract for any `confidence < 50` and is replaced by `-weight * multiplier`.

use parlov_core::{
    NormativeStrength, OracleClass, OracleResult, OracleVerdict, Severity, StrategyOutcome, Vector,
};
use proptest::prelude::*;

use super::*;

/// Builds a minimal `OracleResult` carrying the given `confidence` byte.
///
/// Mirrors the pattern used by `evidence_tests::make_result`; we use a `NotPresent`
/// verdict because the analyzer's `SameStatus` path produces low-confidence results.
fn make_contradictory_result(confidence: u8) -> OracleResult {
    OracleResult {
        class: OracleClass::Existence,
        verdict: OracleVerdict::NotPresent,
        severity: Some(Severity::Low),
        confidence,
        impact_class: None,
        reasons: vec![],
        signals: vec![],
        technique_id: None,
        vector: Some(Vector::StatusCodeDiff),
        normative_strength: Some(NormativeStrength::Must),
        label: None,
        leaks: None,
        rfc_basis: None,
    }
}

/// A `Contradictory` outcome with confidence < 50 must contribute a non-positive value
/// to `log_odds`. This was the bug: the legacy `-logit(p)` formula flipped sign at p=0.5.
#[test]
fn contradictory_low_confidence_produces_negative_contribution() {
    let mut acc = EvidenceAccumulator::new();
    let outcome = StrategyOutcome::Contradictory(make_contradictory_result(28), 1.0);
    acc.ingest(&outcome, Vector::StatusCodeDiff);
    assert!(
        acc.log_odds_current() < 0.0,
        "low-confidence Contradictory must lower log_odds; got {}",
        acc.log_odds_current()
    );
}

/// A `Contradictory` outcome with confidence >= 50 must also contribute negatively.
/// (The legacy formula already produced negative values here, but the magnitude is now
/// tied to `weight` rather than `logit(confidence)`.)
#[test]
fn contradictory_high_confidence_produces_negative_contribution() {
    let mut acc = EvidenceAccumulator::new();
    let outcome = StrategyOutcome::Contradictory(make_contradictory_result(85), 1.0);
    acc.ingest(&outcome, Vector::StatusCodeDiff);
    assert!(
        acc.log_odds_current() < 0.0,
        "high-confidence Contradictory must lower log_odds; got {}",
        acc.log_odds_current()
    );
}

/// Magnitude of the negative contribution must scale with `weight` while `confidence`
/// is held constant.
#[test]
fn contradictory_magnitude_proportional_to_weight() {
    let mut light = EvidenceAccumulator::new();
    let mut heavy = EvidenceAccumulator::new();
    let r = make_contradictory_result(30);
    light.ingest(
        &StrategyOutcome::Contradictory(r.clone(), 0.15),
        Vector::StatusCodeDiff,
    );
    heavy.ingest(
        &StrategyOutcome::Contradictory(r, 1.0),
        Vector::StatusCodeDiff,
    );
    assert!(
        heavy.log_odds_current() < light.log_odds_current(),
        "weight=1.0 must produce a stronger negative shift than weight=0.15: \
         heavy={}, light={}",
        heavy.log_odds_current(),
        light.log_odds_current()
    );
}

/// Confidence must NOT influence the magnitude when weight is fixed. This is the
/// inverse of the bug: under the legacy formula, c=10 and c=49 produced very different
/// contributions because `logit(0.10) != logit(0.49)`.
#[test]
fn contradictory_magnitude_independent_of_confidence() {
    let mut a = EvidenceAccumulator::new();
    let mut b = EvidenceAccumulator::new();
    a.ingest(
        &StrategyOutcome::Contradictory(make_contradictory_result(10), 0.5),
        Vector::StatusCodeDiff,
    );
    b.ingest(
        &StrategyOutcome::Contradictory(make_contradictory_result(49), 0.5),
        Vector::StatusCodeDiff,
    );
    assert!(
        (a.log_odds_current() - b.log_odds_current()).abs() < 1e-9,
        "confidence influenced magnitude: c=10 gave {}, c=49 gave {}",
        a.log_odds_current(),
        b.log_odds_current()
    );
}

/// Replays the Juice Shop `/api/Users/{id}` situation: two `Contradictory` outcomes
/// from the `Accept` and `If-None-Match`-style elicitations, weight 0.2 each
/// (the canonical `normalization_weight` for Phase 1 `SameStatus` producers).
/// After ingest, the posterior must be < 0.5 — never `Likely` or `Confirmed`.
#[test]
fn juice_shop_two_contradictory_outcomes_drop_verdict() {
    let mut acc = EvidenceAccumulator::new();
    acc.ingest(
        &StrategyOutcome::Contradictory(make_contradictory_result(28), 0.2),
        Vector::StatusCodeDiff,
    );
    acc.ingest(
        &StrategyOutcome::Contradictory(make_contradictory_result(39), 0.2),
        Vector::StatusCodeDiff,
    );
    assert!(
        acc.posterior_probability() < 0.5,
        "two Contradictory outcomes must keep posterior < 0.5; got {}",
        acc.posterior_probability()
    );
}

/// Two `Contradictory` firings at the new max `normalization_weight` of `0.25` push
/// log-odds more negative than the old ceiling from two firings at `0.2`.
///
/// Old ceiling (two firings): -(0.2*1.0 + 0.2*0.5) = -0.30
/// New ceiling (two firings): -(0.25*1.0 + 0.25*0.5) = -0.375
///
/// Assert `log_odds < -0.35` — a threshold strictly between the old and new ceilings.
#[test]
fn two_contradictory_at_new_max_weight_exceed_old_ceiling() {
    let mut acc = EvidenceAccumulator::new();
    acc.ingest(
        &StrategyOutcome::Contradictory(make_contradictory_result(28), 0.25),
        Vector::StatusCodeDiff,
    );
    acc.ingest(
        &StrategyOutcome::Contradictory(make_contradictory_result(28), 0.25),
        Vector::StatusCodeDiff,
    );
    let lo = acc.log_odds_current();
    assert!(
        lo < -0.35,
        "two Contradictory at weight 0.25 must push log_odds below -0.35 (new ceiling -0.375); \
         got {lo}"
    );
}

proptest! {
    /// Fundamental invariant: a `Contradictory` outcome must never increase `log_odds`,
    /// regardless of confidence (0..=100) or positive weight.
    #[test]
    fn prop_contradictory_never_positive_contribution(
        confidence in 0u8..=100,
        weight in 0.01f32..=2.0,
    ) {
        let mut acc = EvidenceAccumulator::new();
        let outcome =
            StrategyOutcome::Contradictory(make_contradictory_result(confidence), weight);
        acc.ingest(&outcome, Vector::StatusCodeDiff);
        prop_assert!(
            acc.log_odds_current() <= 0.0,
            "Contradictory contributed {} to log_odds (must be <= 0)",
            acc.log_odds_current()
        );
    }

    /// Magnitude scales linearly with weight (within rounding) when confidence is fixed AND
    /// both events fall within the per-group cap. Weights must satisfy `w * ratio <= 0.75`;
    /// once either event hits the cap, scaling no longer holds. Use `0.5..=2.0` for the ratio
    /// against a base weight of `0.3`, so the heavier event reaches at most `0.6 < 0.75`.
    #[test]
    fn prop_contradictory_magnitude_scales_with_weight(
        weight_ratio in 0.5f32..=2.0,
    ) {
        let mut a1 = EvidenceAccumulator::new();
        let mut a2 = EvidenceAccumulator::new();
        let r = make_contradictory_result(30);
        let base: f32 = 0.3;
        a1.ingest(
            &StrategyOutcome::Contradictory(r.clone(), base),
            Vector::StatusCodeDiff,
        );
        a2.ingest(
            &StrategyOutcome::Contradictory(r, base * weight_ratio),
            Vector::StatusCodeDiff,
        );
        let l1 = a1.log_odds_current().abs();
        let l2 = a2.log_odds_current().abs();
        prop_assert!(
            (l2 - l1 * f64::from(weight_ratio)).abs() < 1e-6,
            "expected ratio {weight_ratio}, got {} (l1={l1}, l2={l2})",
            l2 / l1
        );
    }

    /// Confidence does NOT influence magnitude when weight is fixed.
    #[test]
    fn prop_contradictory_magnitude_independent_of_confidence(
        c1 in 0u8..=100,
        c2 in 0u8..=100,
    ) {
        let mut a = EvidenceAccumulator::new();
        let mut b = EvidenceAccumulator::new();
        a.ingest(
            &StrategyOutcome::Contradictory(make_contradictory_result(c1), 0.5),
            Vector::StatusCodeDiff,
        );
        b.ingest(
            &StrategyOutcome::Contradictory(make_contradictory_result(c2), 0.5),
            Vector::StatusCodeDiff,
        );
        prop_assert!(
            (a.log_odds_current() - b.log_odds_current()).abs() < 1e-9,
            "confidence influenced magnitude: c1={c1}, c2={c2}, l1={}, l2={}",
            a.log_odds_current(),
            b.log_odds_current()
        );
    }
}