parlov-analysis 0.7.0

Analysis engine trait and signal detection for parlov.
Documentation
//! Control-integrity gate for evidence modifiers.
//!
//! Detects when a route-mutating technique (`case_normalize`, `trailing_slash`) destroyed the
//! intended baseline reference. The runner issues a pre-flight canonical (unmutated) baseline
//! alongside the mutated pair; if the canonical succeeds (2xx) but the mutated baseline fails,
//! the mutation broke routing — the resulting status-equality Contradictory is invalid and the
//! outcome must downgrade to `Inapplicable(MutationDestroyedControl)`.

use parlov_core::DifferentialSet;

/// Decision returned by [`control_integrity`].
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ControlDecision {
    /// Control reference preserved (or no canonical was issued — gate inert).
    Reached(f64),
    /// Mutation destroyed the control — downgrade to Inapplicable.
    Blocked,
}

impl ControlDecision {
    /// Numeric confidence: `Reached(c)` → `c`; `Blocked` → `0.0`.
    #[must_use]
    pub fn confidence(self) -> f64 {
        match self {
            Self::Reached(c) => c,
            Self::Blocked => 0.0,
        }
    }
}

/// Computes the control-integrity modifier for a probe pair carrying an optional canonical.
///
/// Returns `Reached(1.0)` when:
/// - no canonical was issued (gate inert — the strategy didn't mutate the path)
/// - the canonical and the mutated baseline both succeeded (mutation reached the same controller)
/// - both canonical and mutated baseline failed (mutation didn't break routing further than auth
///   or other gates already did)
///
/// Returns `Blocked` when:
/// - canonical succeeded (2xx) but mutated baseline failed (non-2xx) — mutation broke routing
/// - canonical returned 301 or 308 — server canonicalized away from the mutated path,
///   indicating the mutated request is not the same resource
#[must_use]
pub fn control_integrity(differential: &DifferentialSet) -> ControlDecision {
    let Some(canonical) = differential.canonical.as_ref() else {
        return ControlDecision::Reached(1.0);
    };
    let Some(mutated) = differential.baseline.first() else {
        return ControlDecision::Reached(1.0);
    };
    let canonical_status = canonical.response.status.as_u16();
    if matches!(canonical_status, 301 | 308) {
        return ControlDecision::Blocked;
    }
    let canonical_ok = canonical.response.status.is_success();
    let mutated_ok = mutated.response.status.is_success();
    if canonical_ok && !mutated_ok {
        return ControlDecision::Blocked;
    }
    ControlDecision::Reached(1.0)
}

#[cfg(test)]
#[path = "control_tests.rs"]
mod tests;