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 {
return not_present_output();
}
let mut reasons = Vec::new();
reasons.push(ScoringReason {
description: format_base_reason(pattern, baseline_status),
points: i16::from(pattern.base_confidence),
dimension: ScoringDimension::Confidence,
});
let (signal_conf, signal_impact, family_count, signal_reasons) =
compute_signal_scores(signals, strength, baseline_status);
reasons.extend(signal_reasons);
let corr = corroboration_bonus(family_count);
if corr > 0 {
reasons.push(ScoringReason {
description: format!("{family_count} independent signal families corroborate"),
points: i16::from(corr),
dimension: ScoringDimension::Confidence,
});
}
let confidence = clamp_to_u8(
f32::from(pattern.base_confidence) + signal_conf + f32::from(corr),
);
let impact_score = clamp_to_u8(
f32::from(pattern.base_impact) + f32::from(signal_impact),
);
let impact_class = Some(derive_impact_class(signals, impact_score));
let verdict = verdict_from_confidence(confidence);
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
}
fn compute_signal_scores(
signals: &[Signal],
strength: NormativeStrength,
baseline_status: u16,
) -> (f32, u8, usize, Vec<ScoringReason>) {
let contributions: Vec<SignalContribution> = signals
.iter()
.filter(|s| s.kind != SignalKind::StatusCodeDiff)
.map(|s| {
let mut c = weight_signal(s, strength, 1.0);
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);
(adjusted.confidence_total, adjusted.impact_total, 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.clone(),
points: c.confidence.round() as i16,
dimension: dim,
}
})
.collect()
}
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_metadata = signals.iter().any(|s| {
matches!(
s.kind,
SignalKind::MetadataLeak | SignalKind::HeaderPresence | SignalKind::HeaderValue
)
});
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 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;