use parlov_core::{
ImpactClass, NormativeStrength, OracleClass, OracleResult, OracleVerdict, Severity, Vector,
};
use proptest::prelude::*;
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,
}
}
#[test]
fn fresh_accumulator_posterior_is_half() {
let acc = EvidenceAccumulator::new();
assert!(
(acc.posterior_probability() - 0.5).abs() < 1e-10,
"expected 0.5, got {}",
acc.posterior_probability()
);
}
#[test]
fn positive_outcome_raises_posterior() {
let mut acc = EvidenceAccumulator::new();
let outcome = StrategyOutcome::Positive(make_result(85));
acc.ingest(&outcome, Vector::StatusCodeDiff);
assert!(
acc.posterior_probability() > 0.5,
"expected posterior > 0.5, got {}",
acc.posterior_probability()
);
}
#[test]
fn contradictory_outcome_lowers_posterior() {
let mut acc = EvidenceAccumulator::new();
let outcome = StrategyOutcome::Contradictory(make_result(85), 1.0);
acc.ingest(&outcome, Vector::StatusCodeDiff);
assert!(
acc.posterior_probability() < 0.5,
"expected posterior < 0.5, got {}",
acc.posterior_probability()
);
}
#[test]
fn no_signal_leaves_posterior_unchanged() {
let mut acc = EvidenceAccumulator::new();
let outcome = StrategyOutcome::NoSignal(make_result(85));
acc.ingest(&outcome, Vector::StatusCodeDiff);
assert!(
(acc.posterior_probability() - 0.5).abs() < 1e-10,
"expected 0.5, got {}",
acc.posterior_probability()
);
}
#[test]
fn inapplicable_leaves_posterior_unchanged() {
let mut acc = EvidenceAccumulator::new();
let outcome = StrategyOutcome::Inapplicable("no ETag support".into());
acc.ingest(&outcome, Vector::StatusCodeDiff);
assert!(
(acc.posterior_probability() - 0.5).abs() < 1e-10,
"expected 0.5, got {}",
acc.posterior_probability()
);
}
#[test]
fn two_positives_same_family_diminishing_returns() {
let outcome = StrategyOutcome::Positive(make_result(65));
let mut acc_one = EvidenceAccumulator::new();
acc_one.ingest(&outcome, Vector::StatusCodeDiff);
let p_one = acc_one.posterior_probability();
let dist_one = p_one - 0.5;
let mut acc_two = EvidenceAccumulator::new();
acc_two.ingest(&outcome, Vector::StatusCodeDiff);
acc_two.ingest(&outcome, Vector::StatusCodeDiff);
let p_two = acc_two.posterior_probability();
let dist_two = p_two - 0.5;
assert!(
dist_two > dist_one,
"two positives must be stronger than one"
);
assert!(
dist_two < 2.0 * dist_one,
"second positive contributes less; distance ({dist_two}) must be < 2x first ({dist_one})"
);
}
#[test]
fn third_positive_same_family_zero_contribution() {
let outcome = StrategyOutcome::Positive(make_result(85));
let mut acc = EvidenceAccumulator::new();
acc.ingest(&outcome, Vector::StatusCodeDiff);
acc.ingest(&outcome, Vector::StatusCodeDiff);
let p_after_two = acc.posterior_probability();
acc.ingest(&outcome, Vector::StatusCodeDiff);
let p_after_three = acc.posterior_probability();
assert!(
(p_after_three - p_after_two).abs() < 1e-10,
"third positive from same family must not change posterior: after_two={p_after_two}, after_three={p_after_three}"
);
}
#[test]
fn two_positives_different_families_full_weight() {
let outcome = StrategyOutcome::Positive(make_result(85));
let mut acc_single = EvidenceAccumulator::new();
acc_single.ingest(&outcome, Vector::StatusCodeDiff);
let p_single = acc_single.posterior_probability();
let mut acc_two = EvidenceAccumulator::new();
acc_two.ingest(&outcome, Vector::StatusCodeDiff);
acc_two.ingest(&outcome, Vector::CacheProbing);
let p_two = acc_two.posterior_probability();
assert!(
p_two > p_single,
"two full-weight positives must exceed one: p_single={p_single}, p_two={p_two}"
);
assert!(
(acc_two.log_odds_current() - 2.0 * acc_single.log_odds_current()).abs() < 1e-10,
"log_odds must be exactly double for two independent families"
);
}
#[test]
fn posterior_clamped_to_unit_interval() {
let mut acc = EvidenceAccumulator::new();
let high = StrategyOutcome::Positive(make_result(100));
for _ in 0..20 {
acc.ingest(&high, Vector::StatusCodeDiff);
}
let p = acc.posterior_probability();
assert!((0.0..=1.0).contains(&p), "posterior {p} out of [0, 1]");
let mut acc2 = EvidenceAccumulator::new();
let low = StrategyOutcome::Contradictory(make_result(0), 1.0);
for _ in 0..20 {
acc2.ingest(&low, Vector::StatusCodeDiff);
}
let p2 = acc2.posterior_probability();
assert!((0.0..=1.0).contains(&p2), "posterior {p2} out of [0, 1]");
}
#[test]
fn max_positive_remaining_empty_is_zero() {
let acc = EvidenceAccumulator::new();
assert!(
acc.max_positive_remaining(&[]).abs() < 1e-10,
"expected 0.0 for empty remaining"
);
}
#[test]
#[cfg(debug_assertions)]
fn ingest_positive_with_zero_confidence_fires_debug_assert() {
use parlov_core::StrategyOutcome;
let result = OracleResult {
class: OracleClass::Existence,
verdict: OracleVerdict::Confirmed,
severity: None,
confidence: 0,
impact_class: None,
reasons: vec![],
signals: vec![],
technique_id: None,
vector: None,
normative_strength: None,
label: None,
leaks: None,
rfc_basis: None,
};
let outcome = StrategyOutcome::Positive(result);
let mut acc = EvidenceAccumulator::new();
acc.ingest(&outcome, Vector::StatusCodeDiff);
assert_eq!(acc.event_count(), 0, "sub-60 Positive must be skipped");
assert!(
(acc.posterior_probability() - 0.5).abs() < 1e-10,
"posterior must remain 0.5 after skipped ingest; got {}",
acc.posterior_probability()
);
}
#[test]
fn max_positive_remaining_zero_when_family_already_capped() {
let mut acc = EvidenceAccumulator::new();
let pos = StrategyOutcome::Positive(make_result(85));
acc.ingest(&pos, Vector::CacheProbing);
let remaining = vec![meta(Vector::CacheProbing)];
let max_pos = acc.max_positive_remaining(&remaining);
assert!(
max_pos.abs() < 1e-9,
"saturated family must yield 0.0 max_positive_remaining; got {max_pos}"
);
}
#[test]
fn max_positive_remaining_positive_for_fresh_family() {
let acc = EvidenceAccumulator::new();
let remaining = vec![meta(Vector::StatusCodeDiff)];
let max_pos = acc.max_positive_remaining(&remaining);
assert!(
max_pos > 0.0,
"fresh family must yield positive max_positive_remaining; got {max_pos}"
);
}
#[derive(Debug, Clone)]
enum Step {
Positive(u8, Vector),
Contradictory(u8, f32, Vector),
NoSignal(Vector),
}
fn arb_vector() -> impl Strategy<Value = Vector> {
prop_oneof![
Just(Vector::StatusCodeDiff),
Just(Vector::CacheProbing),
Just(Vector::ErrorMessageGranularity),
Just(Vector::RedirectDiff),
]
}
fn arb_step() -> impl Strategy<Value = Step> {
prop_oneof![
(60u8..=99, arb_vector()).prop_map(|(c, v)| Step::Positive(c, v)),
(0u8..=99, 0.01f32..=1.0, arb_vector()).prop_map(|(c, w, v)| Step::Contradictory(c, w, v)),
arb_vector().prop_map(Step::NoSignal),
]
}
fn arb_steps() -> impl Strategy<Value = Vec<Step>> {
prop::collection::vec(arb_step(), 0..12)
}
fn permute_indices(len: usize, seeds: &[u32]) -> Vec<usize> {
let mut indices: Vec<usize> = (0..len).collect();
for (offset, i) in (1..len).rev().enumerate() {
let seed = seeds.get(offset).copied().unwrap_or(0);
let j = (seed as usize) % (i + 1);
indices.swap(i, j);
}
indices
}
fn ingest_steps(steps: &[Step]) -> EvidenceAccumulator {
let mut acc = EvidenceAccumulator::new();
for step in steps {
match step {
Step::Positive(c, v) => {
acc.ingest(&StrategyOutcome::Positive(make_result(*c)), *v);
}
Step::Contradictory(c, w, v) => {
acc.ingest(&StrategyOutcome::Contradictory(make_result(*c), *w), *v);
}
Step::NoSignal(v) => {
acc.ingest(&StrategyOutcome::NoSignal(make_result(0)), *v);
}
}
}
acc
}
proptest! {
#[test]
fn posterior_is_order_invariant(
steps in arb_steps(),
seeds in prop::collection::vec(any::<u32>(), 0..12),
) {
let baseline = ingest_steps(&steps).posterior_probability();
let perm = permute_indices(steps.len(), &seeds);
let permuted: Vec<Step> = perm.into_iter().map(|i| steps[i].clone()).collect();
let permuted_post = ingest_steps(&permuted).posterior_probability();
prop_assert!((baseline - permuted_post).abs() < 1e-9);
}
#[test]
fn posterior_in_unit_interval(steps in arb_steps()) {
let p = ingest_steps(&steps).posterior_probability();
prop_assert!((0.0..=1.0).contains(&p));
}
#[test]
fn ingest_positive_above_threshold_always_raises_posterior(confidence in 60u8..=100u8) {
let result = make_result(confidence);
let mut acc = EvidenceAccumulator::new();
acc.ingest(&StrategyOutcome::Positive(result), Vector::StatusCodeDiff);
prop_assert!(
acc.posterior_probability() > 0.5,
"confidence={confidence} should raise posterior above 0.5, got {}",
acc.posterior_probability()
);
}
}
#[test]
fn ingest_positive_below_threshold_skipped() {
let mut acc = EvidenceAccumulator::new();
acc.ingest(
&StrategyOutcome::Positive(make_result(59)),
Vector::StatusCodeDiff,
);
assert_eq!(
acc.event_count(),
0,
"sub-60 Positive must produce zero events"
);
assert!(
(acc.posterior_probability() - 0.5).abs() < 1e-10,
"posterior must be 0.5 after skipped ingest; got {}",
acc.posterior_probability()
);
}
#[test]
fn ingest_positive_at_threshold_accepted() {
let mut acc = EvidenceAccumulator::new();
acc.ingest(
&StrategyOutcome::Positive(make_result(60)),
Vector::StatusCodeDiff,
);
assert!(
acc.posterior_probability() > 0.5,
"confidence=60 Positive must raise posterior above 0.5; got {}",
acc.posterior_probability()
);
}