use parlov_core::{
ContributingFinding, NormativeStrength, OracleClass, OracleResult, OracleVerdict, Severity,
StrategyOutcome, StrategyOutcomeKind, Vector,
};
use parlov_output::ScanFinding;
use proptest::prelude::*;
use super::*;
const THRESHOLD: f64 = 1.386_294_361_119_890_6_f64;
fn make_cf(strategy_id: &str, contribution: f64) -> ContributingFinding {
ContributingFinding {
strategy_id: strategy_id.to_owned(),
strategy_name: strategy_id.to_owned(),
outcome_kind: if contribution > 0.0 {
StrategyOutcomeKind::Positive
} else if contribution < 0.0 {
StrategyOutcomeKind::Contradictory
} else {
StrategyOutcomeKind::NoSignal
},
log_odds_contribution: contribution,
block_family: None,
block_reason: None,
}
}
#[test]
fn compute_final_confirming_strategy_none_when_empty() {
let result = compute_final_confirming_strategy(&[], THRESHOLD);
assert!(result.is_none());
}
#[test]
fn compute_final_confirming_strategy_none_when_below_threshold() {
let findings = vec![make_cf("a", 0.5), make_cf("b", 0.5)];
let result = compute_final_confirming_strategy(&findings, THRESHOLD);
assert!(
result.is_none(),
"cumsum 1.0 < threshold {THRESHOLD}; got {result:?}"
);
}
#[test]
fn compute_final_confirming_strategy_first_crossing() {
let findings = vec![
make_cf("a", 0.5),
make_cf("b", 0.5),
make_cf("c", 0.5), make_cf("d", 0.5),
];
let result = compute_final_confirming_strategy(&findings, THRESHOLD);
assert_eq!(result, Some("c".to_owned()));
}
#[test]
fn compute_final_confirming_strategy_single_positive_crosses() {
let findings = vec![make_cf("solo", 2.0)];
let result = compute_final_confirming_strategy(&findings, THRESHOLD);
assert_eq!(result, Some("solo".to_owned()));
}
#[test]
fn compute_final_confirming_strategy_contradictory_drag() {
let findings = vec![
make_cf("pos-a", 1.0),
make_cf("contra", -0.5), make_cf("pos-b", 1.0), ];
let result = compute_final_confirming_strategy(&findings, THRESHOLD);
assert_eq!(result, Some("pos-b".to_owned()));
}
#[test]
fn compute_final_confirming_strategy_exact_threshold() {
let findings = vec![make_cf("exact", THRESHOLD)];
let result = compute_final_confirming_strategy(&findings, THRESHOLD);
assert_eq!(result, Some("exact".to_owned()));
}
#[test]
fn compute_final_confirming_strategy_zero_contribution_not_named() {
let findings = vec![
make_cf("a", 0.0), make_cf("b", THRESHOLD + 0.1), ];
let result = compute_final_confirming_strategy(&findings, THRESHOLD);
assert_eq!(result, Some("b".to_owned()));
assert_ne!(result, Some("a".to_owned()));
}
fn arb_contribution() -> impl Strategy<Value = f64> {
prop_oneof![Just(0.0f64), (-2.0_f64..2.0_f64),]
}
fn arb_findings(max_len: usize) -> impl Strategy<Value = Vec<ContributingFinding>> {
prop::collection::vec(arb_contribution(), 0..max_len).prop_map(|contribs| {
contribs
.into_iter()
.enumerate()
.map(|(i, c)| make_cf(&format!("s{i}"), c))
.collect()
})
}
proptest! {
#[test]
fn prop_named_strategy_has_positive_contribution(
findings in arb_findings(20),
) {
let result = compute_final_confirming_strategy(&findings, THRESHOLD);
if let Some(ref id) = result {
let named = findings.iter().find(|f| &f.strategy_id == id)
.expect("returned id must be in input");
prop_assert!(
named.log_odds_contribution > 0.0,
"named strategy {id} has non-positive contribution {}",
named.log_odds_contribution
);
}
}
#[test]
fn prop_cumsum_to_named_strategy_meets_threshold(
findings in arb_findings(20),
) {
let result = compute_final_confirming_strategy(&findings, THRESHOLD);
if let Some(ref id) = result {
let pos = findings.iter().position(|f| &f.strategy_id == id)
.expect("id must be in slice");
let cumsum: f64 = findings[..=pos].iter().map(|f| f.log_odds_contribution).sum();
prop_assert!(
cumsum >= THRESHOLD,
"cumsum up to {id} ({cumsum}) < threshold {THRESHOLD}"
);
}
}
#[test]
fn prop_prefix_before_named_strategy_is_below_threshold(
findings in arb_findings(20),
) {
let result = compute_final_confirming_strategy(&findings, THRESHOLD);
if let Some(ref id) = result {
let pos = findings.iter().position(|f| &f.strategy_id == id)
.expect("id must be in slice");
if pos > 0 {
let prefix_sum: f64 = findings[..pos].iter().map(|f| f.log_odds_contribution).sum();
prop_assert!(
prefix_sum < THRESHOLD,
"prefix cumsum before {id} ({prefix_sum}) >= threshold {THRESHOLD}; \
should have named an earlier strategy"
);
}
}
}
#[test]
fn prop_none_means_no_prefix_reached_threshold(
findings in arb_findings(20),
) {
let result = compute_final_confirming_strategy(&findings, THRESHOLD);
if result.is_none() {
let mut running = 0.0_f64;
for f in &findings {
running += f.log_odds_contribution;
prop_assert!(
running < THRESHOLD,
"running sum {running} >= threshold {THRESHOLD} but result was None"
);
}
}
}
}
fn make_result_with_confidence(confidence: u8) -> OracleResult {
OracleResult {
class: OracleClass::Existence,
verdict: OracleVerdict::Confirmed,
severity: Some(Severity::High),
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,
}
}
fn make_finding_with_result(result: OracleResult) -> ScanFinding {
ScanFinding {
target_url: "https://example.com/{id}".to_owned(),
strategy_id: "scd-baseline".to_owned(),
strategy_name: "Status Code Diff Baseline".to_owned(),
method: "GET".to_owned(),
result,
repro: None,
probe: parlov_output::ProbeContext {
baseline_url: "https://example.com/1".to_owned(),
probe_url: "https://example.com/9999".to_owned(),
method: "GET".to_owned(),
headers: None,
},
exchange: parlov_output::ExchangeContext {
baseline_status: 200,
probe_status: 404,
headers: None,
body_samples: None,
},
chain_provenance: None,
}
}
#[test]
fn build_endpoint_verdict_derives_oracle_class_from_findings() {
use crate::pipeline_state::ScanPipelineState;
let result = make_result_with_confidence(85);
let finding = make_finding_with_result(result.clone());
let outcome = StrategyOutcome::Positive(result);
let mut state = ScanPipelineState::new(1);
state.accumulator.ingest(&outcome, Vector::StatusCodeDiff);
state.findings.push((finding, outcome));
state.strategies_run = 1;
let verdict = build_endpoint_verdict(&state);
assert_eq!(verdict.oracle_class, OracleClass::Existence);
}
#[test]
fn build_endpoint_verdict_defaults_existence_when_no_findings() {
use crate::pipeline_state::ScanPipelineState;
let state = ScanPipelineState::new(0);
let verdict = build_endpoint_verdict(&state);
assert_eq!(verdict.oracle_class, OracleClass::Existence);
}
fn build_with_findings(steps: &[(StrategyOutcome, Vector)]) -> Vec<ContributingFinding> {
use parlov_analysis::EvidenceAccumulator;
let mut accumulator = EvidenceAccumulator::new();
let mut findings = Vec::with_capacity(steps.len());
for (outcome, vector) in steps {
accumulator.ingest(outcome, *vector);
let result = match outcome {
StrategyOutcome::Positive(r)
| StrategyOutcome::NoSignal(r)
| StrategyOutcome::Contradictory(r, _) => r.clone(),
StrategyOutcome::Inapplicable(_) => make_result_with_confidence(0),
};
findings.push((make_finding_with_result(result), outcome.clone()));
}
super::build_contributing_findings(&accumulator, &findings)
}
#[test]
fn contributing_findings_contradictory_has_negative_log_odds() {
let result = make_result_with_confidence(28);
let outcome = StrategyOutcome::Contradictory(result, 0.2);
let contributions = build_with_findings(&[(outcome, Vector::StatusCodeDiff)]);
assert_eq!(contributions.len(), 1);
assert!(
contributions[0].log_odds_contribution < 0.0,
"Contradictory must produce negative log_odds_contribution; got {}",
contributions[0].log_odds_contribution
);
}
#[test]
fn contributing_findings_contradictory_magnitude_equals_weight() {
let result = make_result_with_confidence(28);
let outcome = StrategyOutcome::Contradictory(result, 0.2);
let contributions = build_with_findings(&[(outcome, Vector::StatusCodeDiff)]);
assert!(
(contributions[0].log_odds_contribution + 0.2).abs() < 1e-6,
"expected -0.2, got {}",
contributions[0].log_odds_contribution
);
}
#[test]
fn contributing_findings_contradictory_diminishing_returns() {
let result = make_result_with_confidence(40);
let oa = StrategyOutcome::Contradictory(result.clone(), 0.2);
let ob = StrategyOutcome::Contradictory(result, 0.2);
let contributions =
build_with_findings(&[(oa, Vector::StatusCodeDiff), (ob, Vector::StatusCodeDiff)]);
assert_eq!(contributions.len(), 2);
assert!(
(contributions[0].log_odds_contribution + 0.2).abs() < 1e-6,
"first slot expected -0.2, got {}",
contributions[0].log_odds_contribution
);
let expected_second = -0.2 * 0.7;
assert!(
(contributions[1].log_odds_contribution - expected_second).abs() < 1e-6,
"second slot expected {expected_second}, got {}",
contributions[1].log_odds_contribution
);
}
#[test]
fn contributing_finding_positive_above_cap_is_clamped_to_cap() {
let result = make_result_with_confidence(85);
let outcome = StrategyOutcome::Positive(result);
let contributions = build_with_findings(&[(outcome, Vector::StatusCodeDiff)]);
assert_eq!(contributions.len(), 1);
let expected = 0.75_f64; assert!(
(contributions[0].log_odds_contribution - expected).abs() < 1e-10,
"expected cap {expected}, got {}",
contributions[0].log_odds_contribution
);
}
#[test]
fn contributing_finding_positive_below_cap_equals_logit_of_confidence() {
let result = make_result_with_confidence(65);
let outcome = StrategyOutcome::Positive(result);
let contributions = build_with_findings(&[(outcome, Vector::StatusCodeDiff)]);
assert_eq!(contributions.len(), 1);
let expected = (0.65_f64 / 0.35_f64).ln(); assert!(
(contributions[0].log_odds_contribution - expected).abs() < 1e-10,
"expected logit(0.65) = {expected}, got {}",
contributions[0].log_odds_contribution
);
}
fn ingest_contradictory(
state: &mut crate::pipeline_state::ScanPipelineState,
weight: f32,
vector: Vector,
) {
let result = make_result_with_confidence(40);
let outcome = StrategyOutcome::Contradictory(result.clone(), weight);
state.accumulator.ingest(&outcome, vector);
state
.findings
.push((make_finding_with_result(result), outcome));
}
fn ingest_positive(
state: &mut crate::pipeline_state::ScanPipelineState,
confidence: u8,
vector: Vector,
) {
let result = make_result_with_confidence(confidence);
let outcome = StrategyOutcome::Positive(result.clone());
state.accumulator.ingest(&outcome, vector);
state
.findings
.push((make_finding_with_result(result), outcome));
}
#[test]
fn build_endpoint_verdict_weak_contradictories_below_threshold_downgrades_to_inconclusive() {
use crate::pipeline_state::ScanPipelineState;
let mut state = ScanPipelineState::new(8);
let weight: f32 = 0.19;
for vector in [
Vector::StatusCodeDiff,
Vector::CacheProbing,
Vector::RedirectDiff,
Vector::ErrorMessageGranularity,
] {
ingest_contradictory(&mut state, weight, vector);
ingest_contradictory(&mut state, weight, vector);
ingest_contradictory(&mut state, weight, vector);
}
let posterior = state.accumulator.posterior_probability();
assert!(
posterior <= 0.20,
"test precondition: posterior must be at-or-below 0.20; got {posterior}"
);
let verdict = build_endpoint_verdict(&state);
assert_eq!(
verdict.verdict,
OracleVerdict::Inconclusive,
"thin coverage (all weak contradictories) must downgrade NotPresent to Inconclusive; \
got verdict={:?}, posterior={:.3}",
verdict.verdict,
verdict.posterior_probability
);
}
#[test]
fn build_endpoint_verdict_gate_passes_keeps_not_present() {
use crate::pipeline_state::ScanPipelineState;
let mut state = ScanPipelineState::new(8);
let weight: f32 = 0.25;
for vector in [
Vector::StatusCodeDiff,
Vector::CacheProbing,
Vector::RedirectDiff,
Vector::ErrorMessageGranularity,
] {
ingest_contradictory(&mut state, weight, vector);
ingest_contradictory(&mut state, weight, vector);
ingest_contradictory(&mut state, weight, vector);
}
let posterior = state.accumulator.posterior_probability();
assert!(
posterior <= 0.20,
"test precondition: posterior must be at-or-below 0.20; got {posterior}"
);
let verdict = build_endpoint_verdict(&state);
assert_eq!(
verdict.verdict,
OracleVerdict::NotPresent,
"passing coverage gate must keep NotPresent verdict; got verdict={:?}, posterior={:.3}",
verdict.verdict,
verdict.posterior_probability
);
}
#[test]
fn build_endpoint_verdict_gate_does_not_alter_confirmed() {
use crate::pipeline_state::ScanPipelineState;
let mut state = ScanPipelineState::new(2);
ingest_positive(&mut state, 99, Vector::StatusCodeDiff);
ingest_positive(&mut state, 99, Vector::CacheProbing);
let verdict = build_endpoint_verdict(&state);
assert!(
matches!(
verdict.verdict,
OracleVerdict::Confirmed | OracleVerdict::Likely
),
"Positive evidence verdict must pass through unchanged; got {:?}",
verdict.verdict
);
}
#[test]
fn build_endpoint_verdict_gate_does_not_alter_inconclusive() {
use crate::pipeline_state::ScanPipelineState;
let state = ScanPipelineState::new(0);
let verdict = build_endpoint_verdict(&state);
assert_eq!(verdict.verdict, OracleVerdict::Inconclusive);
assert!(
(verdict.posterior_probability - 0.5).abs() < 1e-9,
"expected ~0.5, got {}",
verdict.posterior_probability
);
}