use parlov_core::{
ImpactClass, NormativeStrength, OracleVerdict, ScoringDimension, ScoringReason, Severity,
Signal, SignalKind,
};
use super::families::{
apply_family_adjustment, corroboration_bonus, status_code_family, SignalContribution,
};
use super::patterns::PatternMatch;
use super::signal_weights::weight_signal;
pub(crate) struct ScoringOutput {
pub confidence: u8,
pub verdict: OracleVerdict,
pub impact_class: Option<ImpactClass>,
pub severity: Option<Severity>,
pub reasons: Vec<ScoringReason>,
}
pub(crate) fn compute(
pattern: &PatternMatch,
signals: &[Signal],
strength: NormativeStrength,
baseline_status: u16,
) -> ScoringOutput {
if pattern.base_confidence == 0 && signals.is_empty() {
return not_present_output();
}
let mut reasons = Vec::new();
if pattern.base_confidence > 0 {
reasons.push(ScoringReason {
description: format_base_reason(pattern, baseline_status),
points: i16::from(pattern.base_confidence),
dimension: ScoringDimension::Confidence,
});
}
let status_codes_differ = pattern.base_confidence > 0;
let signal_scores =
compute_signal_scores(signals, strength, baseline_status, status_codes_differ);
reasons.extend(signal_scores.reasons);
let corr = corroboration_bonus(signal_scores.family_count);
if corr > 0 {
let fc = signal_scores.family_count;
reasons.push(ScoringReason {
description: format!("{fc} independent signal families corroborate"),
points: i16::from(corr),
dimension: ScoringDimension::Confidence,
});
}
let confidence = clamp_to_u8(
f32::from(pattern.base_confidence) + signal_scores.confidence_total + f32::from(corr),
);
let impact_score =
clamp_to_u8(f32::from(pattern.base_impact) + f32::from(signal_scores.impact_total));
let impact_class = Some(derive_impact_class(signals, impact_score));
let verdict = verdict_from_confidence(confidence);
let verdict = apply_normative_gate(verdict, strength);
let severity = gate_severity(verdict, impact_class);
ScoringOutput {
confidence,
verdict,
impact_class,
severity,
reasons,
}
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn clamp_to_u8(value: f32) -> u8 {
value.round().clamp(0.0, 100.0) as u8
}
struct SignalScores {
confidence_total: f32,
impact_total: u8,
family_count: usize,
reasons: Vec<ScoringReason>,
}
fn compute_signal_scores(
signals: &[Signal],
strength: NormativeStrength,
baseline_status: u16,
status_codes_differ: bool,
) -> SignalScores {
let contributions: Vec<SignalContribution<'_>> = signals
.iter()
.filter(|s| s.kind != SignalKind::StatusCodeDiff)
.map(|s| {
let repro = body_diff_attenuation(s, status_codes_differ);
let mut c = weight_signal(s, strength, repro);
if c.family == super::families::SignalFamily::General {
c.family = status_code_family(baseline_status);
}
c
})
.collect();
let adjusted = apply_family_adjustment(&contributions);
let reasons = build_signal_reasons(&contributions);
SignalScores {
confidence_total: adjusted.confidence_total,
impact_total: adjusted.impact_total,
family_count: adjusted.family_count,
reasons,
}
}
#[allow(clippy::cast_possible_truncation)]
fn build_signal_reasons(contributions: &[SignalContribution<'_>]) -> Vec<ScoringReason> {
contributions
.iter()
.filter(|c| c.confidence > 0.0 || c.impact > 0)
.map(|c| {
let dim = if c.confidence > 0.0 {
ScoringDimension::Confidence
} else {
ScoringDimension::Impact
};
ScoringReason {
description: c.description.to_owned(),
points: c.confidence.round() as i16,
dimension: dim,
}
})
.collect()
}
fn body_diff_attenuation(signal: &Signal, status_codes_differ: bool) -> f32 {
if signal.kind == SignalKind::BodyDiff && status_codes_differ {
return 0.25;
}
1.0
}
fn not_present_output() -> ScoringOutput {
ScoringOutput {
confidence: 0,
verdict: OracleVerdict::NotPresent,
impact_class: None,
severity: None,
reasons: vec![],
}
}
fn format_base_reason(pattern: &PatternMatch, baseline_status: u16) -> String {
if let Some(label) = pattern.label {
format!(
"{label} (base +{baseline_status}\u{2192}{})",
pattern.base_confidence
)
} else {
format!(
"Status differential from {} (base +{})",
baseline_status, pattern.base_confidence
)
}
}
fn derive_impact_class(signals: &[Signal], impact_score: u8) -> ImpactClass {
let has_size_leak = signals
.iter()
.any(|s| s.kind == SignalKind::MetadataLeak && s.evidence.to_lowercase().contains("size"));
let has_status_diff = signals.iter().any(|s| s.kind == SignalKind::StatusCodeDiff);
let has_metadata = signals.iter().any(|s| {
matches!(
s.kind,
SignalKind::MetadataLeak | SignalKind::HeaderPresence | SignalKind::HeaderValue
) || (s.kind == SignalKind::BodyDiff && !has_status_diff)
});
if has_size_leak {
return ImpactClass::High;
}
if has_metadata && impact_score >= 40 {
return ImpactClass::Medium;
}
if impact_score >= 35 {
return ImpactClass::Medium;
}
ImpactClass::Low
}
fn verdict_from_confidence(confidence: u8) -> OracleVerdict {
if confidence >= 80 {
OracleVerdict::Confirmed
} else if confidence >= 60 {
OracleVerdict::Likely
} else {
OracleVerdict::NotPresent
}
}
fn apply_normative_gate(verdict: OracleVerdict, strength: NormativeStrength) -> OracleVerdict {
if strength == NormativeStrength::May && verdict == OracleVerdict::Confirmed {
OracleVerdict::Likely
} else {
verdict
}
}
fn gate_severity(verdict: OracleVerdict, impact_class: Option<ImpactClass>) -> Option<Severity> {
match verdict {
OracleVerdict::NotPresent | OracleVerdict::Inconclusive => None,
OracleVerdict::Confirmed => impact_class.map(impact_to_severity),
OracleVerdict::Likely => {
impact_class.map(|ic| cap_severity_one_below(impact_to_severity(ic)))
}
}
}
fn impact_to_severity(impact: ImpactClass) -> Severity {
match impact {
ImpactClass::High => Severity::High,
ImpactClass::Medium => Severity::Medium,
ImpactClass::Low => Severity::Low,
}
}
fn cap_severity_one_below(severity: Severity) -> Severity {
match severity {
Severity::High => Severity::Medium,
Severity::Medium | Severity::Low => Severity::Low,
}
}
#[cfg(test)]
#[path = "scoring_tests.rs"]
mod tests;