parlov-analysis 0.4.0

Analysis engine trait and signal detection for parlov.
Documentation
//! Confidence computation, impact classification, verdict/severity derivation.
//!
//! Combines base scores from the pattern table with family-adjusted signal contributions,
//! corroboration bonuses, and confidence-gated severity to produce the final scoring breakdown.

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;

/// Complete scoring output used to build `OracleResult`.
pub(crate) struct ScoringOutput {
    /// Numeric confidence score (0-100).
    pub confidence: u8,
    /// Verdict derived from confidence thresholds.
    pub verdict: OracleVerdict,
    /// Impact classification from leak analysis.
    pub impact_class: Option<ImpactClass>,
    /// Severity gated by confidence.
    pub severity: Option<Severity>,
    /// Breakdown of how scores were computed.
    pub reasons: Vec<ScoringReason>,
}

/// Computes the full scoring output from pattern match and signals.
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 }
}

/// Rounds and clamps a float to a `u8` in `[0, 100]`.
#[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
}

/// Computes family-adjusted signal confidence and impact contributions.
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)
}

/// Builds scoring reasons from signal contributions.
#[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
        )
    }
}

/// Derives impact class from the highest-impact signal category observed.
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
}

/// Maps confidence score to verdict.
fn verdict_from_confidence(confidence: u8) -> OracleVerdict {
    if confidence >= 80 {
        OracleVerdict::Confirmed
    } else if confidence >= 60 {
        OracleVerdict::Likely
    } else {
        OracleVerdict::NotPresent
    }
}

/// Gates severity by confidence level.
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)))
        }
    }
}

/// Converts impact class to severity.
fn impact_to_severity(impact: ImpactClass) -> Severity {
    match impact {
        ImpactClass::High => Severity::High,
        ImpactClass::Medium => Severity::Medium,
        ImpactClass::Low => Severity::Low,
    }
}

/// Caps severity one level below for Likely verdicts.
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;