use parlov_analysis::EvidenceAccumulator;
use parlov_core::{
compute_observability, posterior_to_verdict, verdict_to_severity, BlockFamily,
ContributingFinding, EndpointStopReason, EndpointVerdict, OracleClass, OracleVerdict,
StrategyOutcome, StrategyOutcomeKind,
};
use parlov_output::ScanFinding;
use crate::pipeline_state::ScanPipelineState;
use crate::scan::CONFIRM_LOG_ODDS_THRESHOLD;
pub(crate) fn build_endpoint_verdict(state: &ScanPipelineState) -> EndpointVerdict {
use parlov_analysis::StopDecision;
let posterior = state.accumulator.posterior_probability();
let raw_verdict = posterior_to_verdict(posterior);
let verdict = if raw_verdict == OracleVerdict::NotPresent
&& !parlov_analysis::passes_not_present_gate(state.accumulator.events())
{
OracleVerdict::Inconclusive
} else {
raw_verdict
};
let severity = verdict_to_severity(verdict);
let stop_reason = match &state.stop_decision {
Some(StopDecision::EarlyAccept { .. }) => Some(EndpointStopReason::EarlyAccept),
Some(StopDecision::EarlyReject { .. }) => Some(EndpointStopReason::EarlyReject),
None | Some(StopDecision::Continue) => Some(EndpointStopReason::ExhaustedPlan),
};
let contributing_findings = build_contributing_findings(&state.accumulator, &state.findings);
let oracle_class = state
.findings
.first()
.map_or(OracleClass::Existence, |(f, _)| f.result.class);
let (observability_status, block_summary) = compute_observability(&contributing_findings);
let final_confirming_strategy =
compute_final_confirming_strategy(&contributing_findings, CONFIRM_LOG_ODDS_THRESHOLD);
EndpointVerdict {
oracle_class,
posterior_probability: posterior,
verdict,
severity,
strategies_run: state.strategies_run,
strategies_total: state.strategies_total,
stop_reason,
first_threshold_crossed_by: state.first_threshold_crossed_by.clone(),
final_confirming_strategy,
contributing_findings,
observability_status,
block_summary,
}
}
pub(crate) fn compute_final_confirming_strategy(
findings: &[ContributingFinding],
threshold: f64,
) -> Option<String> {
debug_assert!(threshold > 0.0, "threshold must be positive");
let mut running = 0.0_f64;
findings.iter().find_map(|f| {
running += f.log_odds_contribution;
if running >= threshold {
Some(f.strategy_id.clone())
} else {
None
}
})
}
fn build_contributing_findings(
accumulator: &EvidenceAccumulator,
findings: &[(ScanFinding, StrategyOutcome)],
) -> Vec<ContributingFinding> {
let attribution = accumulator.reduce_with_attribution();
debug_assert_eq!(
accumulator.event_count(),
findings
.iter()
.filter(|(_, o)| matches!(
o,
StrategyOutcome::Positive(_) | StrategyOutcome::Contradictory(_, _)
))
.count(),
"accumulator events out of sync with findings"
);
let mut event_iter = attribution.contributions.into_iter();
findings
.iter()
.map(|(finding, outcome)| build_finding(finding, outcome, &mut event_iter))
.collect()
}
fn build_finding(
finding: &ScanFinding,
outcome: &StrategyOutcome,
contributions: &mut impl Iterator<Item = f64>,
) -> ContributingFinding {
let (outcome_kind, log_odds_contribution, block_family, block_reason) = match outcome {
StrategyOutcome::Positive(_) => (
StrategyOutcomeKind::Positive,
contributions.next().unwrap_or(0.0),
None,
None,
),
StrategyOutcome::Contradictory(_, _) => (
StrategyOutcomeKind::Contradictory,
contributions.next().unwrap_or(0.0),
None,
None,
),
StrategyOutcome::NoSignal(_) => (StrategyOutcomeKind::NoSignal, 0.0, None, None),
StrategyOutcome::Inapplicable(reason) => {
let family = block_family_from_reason(reason);
let reason_str = Some(reason.to_string());
(StrategyOutcomeKind::Inapplicable, 0.0, family, reason_str)
}
};
ContributingFinding {
strategy_id: finding.strategy_id.clone(),
strategy_name: finding.strategy_name.clone(),
outcome_kind,
log_odds_contribution,
block_family,
block_reason,
}
}
fn block_family_from_reason(reason: &str) -> Option<BlockFamily> {
if reason.contains("auth gate fired before technique")
|| reason.contains("auth gate fired before technique reached oracle layer")
|| reason.contains("proxy auth required")
|| reason.contains("network/captive-portal")
|| reason.contains("login-redirect fired")
{
return Some(BlockFamily::Authorization);
}
if reason.contains("method-level rejection") {
return Some(BlockFamily::Method);
}
if reason.contains("parser/validator rejection") {
return Some(BlockFamily::Parser);
}
if reason.contains("applicability marker not observed")
|| reason.contains("mutation broke baseline route")
{
return Some(BlockFamily::TechniqueLocal);
}
if reason.contains("surface this technique does not test") {
return Some(BlockFamily::Surface);
}
None
}
#[cfg(test)]
#[path = "verdict_builder_tests.rs"]
mod tests;