parlov_analysis/aggregation/control.rs
1//! Control-integrity gate for evidence modifiers.
2//!
3//! Detects when a route-mutating technique (`case_normalize`, `trailing_slash`) destroyed the
4//! intended baseline reference. The runner issues a pre-flight canonical (unmutated) baseline
5//! alongside the mutated pair; if the canonical succeeds (2xx) but the mutated baseline fails,
6//! the mutation broke routing — the resulting status-equality Contradictory is invalid and the
7//! outcome must downgrade to `Inapplicable(MutationDestroyedControl)`.
8
9use parlov_core::DifferentialSet;
10
11/// Decision returned by [`control_integrity`].
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub enum ControlDecision {
14 /// Control reference preserved (or no canonical was issued — gate inert).
15 Reached(f64),
16 /// Mutation destroyed the control — downgrade to Inapplicable.
17 Blocked,
18}
19
20impl ControlDecision {
21 /// Numeric confidence: `Reached(c)` → `c`; `Blocked` → `0.0`.
22 #[must_use]
23 pub fn confidence(self) -> f64 {
24 match self {
25 Self::Reached(c) => c,
26 Self::Blocked => 0.0,
27 }
28 }
29}
30
31/// Computes the control-integrity modifier for a probe pair carrying an optional canonical.
32///
33/// Returns `Reached(1.0)` when:
34/// - no canonical was issued (gate inert — the strategy didn't mutate the path)
35/// - the canonical and the mutated baseline both succeeded (mutation reached the same controller)
36/// - both canonical and mutated baseline failed (mutation didn't break routing further than auth
37/// or other gates already did)
38///
39/// Returns `Blocked` when:
40/// - canonical succeeded (2xx) but mutated baseline failed (non-2xx) — mutation broke routing
41/// - canonical returned 301 or 308 — server canonicalized away from the mutated path,
42/// indicating the mutated request is not the same resource
43#[must_use]
44pub fn control_integrity(differential: &DifferentialSet) -> ControlDecision {
45 let Some(canonical) = differential.canonical.as_ref() else {
46 return ControlDecision::Reached(1.0);
47 };
48 let Some(mutated) = differential.baseline.first() else {
49 return ControlDecision::Reached(1.0);
50 };
51 let canonical_status = canonical.response.status.as_u16();
52 if matches!(canonical_status, 301 | 308) {
53 return ControlDecision::Blocked;
54 }
55 let canonical_ok = canonical.response.status.is_success();
56 let mutated_ok = mutated.response.status.is_success();
57 if canonical_ok && !mutated_ok {
58 return ControlDecision::Blocked;
59 }
60 ControlDecision::Reached(1.0)
61}
62
63#[cfg(test)]
64#[path = "control_tests.rs"]
65mod tests;