Skip to main content

devscout_rs/truth/
fault_controls.rs

1//! The registered fault-control battery.
2//!
3//! Nine wrong-system mutations, each applied to an in-memory copy of a
4//! case's evaluated artifacts -- never a tracked fixture byte -- plus the
5//! assertion the harness must fail on it. A control the harness does not
6//! detect is a registry entry whose own check fails loudly;
7//! [`run_battery`] proves every registered row is actually caught, not
8//! merely declared.
9
10use super::freshness::{
11    evaluate_pair, FreshnessOutcome, FreshnessTrigger, FreshnessVerdict, TransformationPair,
12};
13use super::identity::{CompilationIdentity, OccurrenceSpan, SymbolIdentity};
14use super::manifest::{Case, ExpectedPresentFact, Provenance};
15use super::report::{
16    denominator_shrunk, evaluate_case, ratio_or_undefined, ObservedCase, ObservedFact,
17};
18use super::uncertainty::{ContextHealth, Uncertainty};
19
20/// One registered wrong-system mutation. `ALL` is the exhaustive,
21/// registered list a manifest's fault battery must cover.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum FaultControl {
24    /// The analyzer produced nothing at all.
25    EmptyOutput,
26    /// A failed/unresolved binding is relabeled confirmed.
27    PromotedFailedBinding,
28    /// An ambiguous or off-target match is relabeled confirmed.
29    PromotedAmbiguity,
30    /// A requested project is silently dropped from the run.
31    DroppedProject,
32    /// A generated document is silently omitted.
33    DroppedGeneratedDocument,
34    /// A different profile's records are substituted for the one
35    /// requested.
36    WrongRequestedTarget,
37    /// A cached record is reused after a freshness trigger fired.
38    StaleCacheReuse,
39    /// The reported denominator shrinks without a matching record removal.
40    DenominatorShrinkage,
41    /// The producer's output agrees with itself but contradicts the
42    /// reviewed expectation.
43    SelfAgreeingWrongProducer,
44}
45
46impl FaultControl {
47    /// Every registered control, in declaration order.
48    pub const ALL: [FaultControl; 9] = [
49        FaultControl::EmptyOutput,
50        FaultControl::PromotedFailedBinding,
51        FaultControl::PromotedAmbiguity,
52        FaultControl::DroppedProject,
53        FaultControl::DroppedGeneratedDocument,
54        FaultControl::WrongRequestedTarget,
55        FaultControl::StaleCacheReuse,
56        FaultControl::DenominatorShrinkage,
57        FaultControl::SelfAgreeingWrongProducer,
58    ];
59
60    /// The wire label for this control.
61    pub fn label(self) -> &'static str {
62        match self {
63            FaultControl::EmptyOutput => "empty-output",
64            FaultControl::PromotedFailedBinding => "promoted-failed-binding",
65            FaultControl::PromotedAmbiguity => "promoted-ambiguity",
66            FaultControl::DroppedProject => "dropped-project",
67            FaultControl::DroppedGeneratedDocument => "dropped-generated-document",
68            FaultControl::WrongRequestedTarget => "wrong-requested-target",
69            FaultControl::StaleCacheReuse => "stale-cache-reuse",
70            FaultControl::DenominatorShrinkage => "denominator-shrinkage",
71            FaultControl::SelfAgreeingWrongProducer => "self-agreeing-wrong-producer",
72        }
73    }
74}
75
76/// Whether a registered control's mutation was actually caught.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct ControlResult {
79    /// Which control this result is for.
80    pub control: FaultControl,
81    /// Whether the harness caught the mutation.
82    pub caught: bool,
83}
84
85fn identity(member: &str) -> SymbolIdentity {
86    SymbolIdentity {
87        assembly: "Fixture".to_string(),
88        declaring_type: "Fixture.Cases".to_string(),
89        member: member.to_string(),
90        generic_arity: 0,
91        overload_signature: format!("{member}()"),
92        compilation: CompilationIdentity {
93            project: "Cases".to_string(),
94            tfm: "net8.0".to_string(),
95            configuration: "Release".to_string(),
96        },
97    }
98}
99
100fn occurrence(line: u32) -> OccurrenceSpan {
101    OccurrenceSpan {
102        file: "src/Cases.cs".to_string(),
103        start_line: line,
104        start_col: 1,
105        end_line: line,
106        end_col: 20,
107    }
108}
109
110fn base_case() -> Case {
111    Case {
112        id: "fault-control-base".to_string(),
113        scenario_family: "shared-language-semantics".to_string(),
114        language: "csharp".to_string(),
115        profiles: vec!["csharp-net8.0-sdk".to_string()],
116        prerequisites: vec![],
117        fact_contract: "semantic-truth-v1".to_string(),
118        source: vec!["src/Cases.cs".to_string()],
119        context: "complete".to_string(),
120        present: vec![ExpectedPresentFact {
121            identity: identity("Load"),
122            occurrence: occurrence(10),
123            state: "confirmed".to_string(),
124        }],
125        absent: vec![],
126        unresolved: vec![],
127        diagnostics: vec![],
128        provenance: Provenance::Reviewed,
129    }
130}
131
132fn clean_observation() -> ObservedCase {
133    ObservedCase {
134        context: ContextHealth::Complete,
135        facts: vec![ObservedFact {
136            identity: identity("Load"),
137            occurrence: occurrence(10),
138            state: Uncertainty::Confirmed,
139        }],
140        diagnostics: vec![],
141    }
142}
143
144fn caught_empty_output(case: &Case) -> bool {
145    let mutated = ObservedCase {
146        facts: vec![],
147        ..clean_observation()
148    };
149    !evaluate_case(case, &mutated).is_pass()
150}
151
152fn caught_promoted_unresolved(expected_state: &str, reason: &str) -> bool {
153    let case = Case {
154        present: vec![],
155        unresolved: vec![super::manifest::ExpectedUnresolvedFact {
156            identity: identity("Load"),
157            occurrence: occurrence(10),
158            state: expected_state.to_string(),
159            reason: reason.to_string(),
160        }],
161        ..base_case()
162    };
163    let promoted = clean_observation();
164    !evaluate_case(&case, &promoted).is_pass()
165}
166
167fn caught_dropped_project(case: &Case) -> bool {
168    let mutated = ObservedCase {
169        context: ContextHealth::Partial {
170            reason: "one requested project was dropped".to_string(),
171        },
172        ..clean_observation()
173    };
174    !evaluate_case(case, &mutated).is_pass()
175}
176
177fn caught_dropped_generated_document(case: &Case) -> bool {
178    let mutated = ObservedCase {
179        context: ContextHealth::Partial {
180            reason: "a generated document was omitted".to_string(),
181        },
182        facts: vec![],
183        ..clean_observation()
184    };
185    !evaluate_case(case, &mutated).is_pass()
186}
187
188fn caught_wrong_requested_target(case: &Case) -> bool {
189    let mutated = ObservedCase {
190        facts: vec![ObservedFact {
191            identity: identity("SomeOtherMember"),
192            occurrence: occurrence(99),
193            state: Uncertainty::Confirmed,
194        }],
195        ..clean_observation()
196    };
197    !evaluate_case(case, &mutated).is_pass()
198}
199
200/// The battery's own verdict is DERIVED from `freshness.rs`'s detection,
201/// not a reflexive equality declared here: a producer that reuses cached
202/// facts unchanged after a declared freshness trigger must be caught as
203/// `MissedStaleness` by `evaluate_pair`. If that detection were ever
204/// deleted, this function would stop catching the control too.
205fn caught_stale_cache_reuse() -> bool {
206    let pair = TransformationPair {
207        id: "fault-control-stale-cache-reuse",
208        trigger: FreshnessTrigger::DependencyCompilation,
209        mapping: "identity",
210        expected: FreshnessVerdict::Stale,
211    };
212    let reused_after_trigger = clean_observation();
213    let still_cached = clean_observation();
214    let outcome = evaluate_pair(&reused_after_trigger, &still_cached, &pair);
215    matches!(outcome, FreshnessOutcome::MissedStaleness)
216}
217
218/// A report that silently drops one of two requested cases must be caught
219/// even though its own ratio does not fall.
220fn caught_denominator_shrinkage() -> bool {
221    let declared_case_count = 2;
222    let full_denominator = 2;
223    let dropped_denominator = 1;
224    let full_ratio = ratio_or_undefined(1, full_denominator);
225    let dropped_ratio = ratio_or_undefined(1, dropped_denominator);
226    dropped_ratio > full_ratio && denominator_shrunk(dropped_denominator, declared_case_count)
227}
228
229fn caught_self_agreeing_wrong_producer(case: &Case) -> bool {
230    // The producer's own output agrees with itself but contradicts the
231    // independently reviewed expectation.
232    let self_agreeing_but_wrong = ObservedCase {
233        context: ContextHealth::Complete,
234        facts: vec![ObservedFact {
235            identity: identity("LoadFromCache"),
236            occurrence: occurrence(10),
237            state: Uncertainty::Confirmed,
238        }],
239        diagnostics: vec![],
240    };
241    !evaluate_case(case, &self_agreeing_but_wrong).is_pass()
242}
243
244/// Applies one registered mutation and reports whether the harness catches
245/// it -- the check every control is graded on.
246pub fn run_control(control: FaultControl) -> ControlResult {
247    let case = base_case();
248    let caught = match control {
249        FaultControl::EmptyOutput => caught_empty_output(&case),
250        FaultControl::PromotedFailedBinding => {
251            caught_promoted_unresolved("unresolved", "binding could not be verified")
252        }
253        FaultControl::PromotedAmbiguity => {
254            caught_promoted_unresolved("ambiguous", "an off-target overload also matches")
255        }
256        FaultControl::DroppedProject => caught_dropped_project(&case),
257        FaultControl::DroppedGeneratedDocument => caught_dropped_generated_document(&case),
258        FaultControl::WrongRequestedTarget => caught_wrong_requested_target(&case),
259        FaultControl::StaleCacheReuse => caught_stale_cache_reuse(),
260        FaultControl::DenominatorShrinkage => caught_denominator_shrinkage(),
261        FaultControl::SelfAgreeingWrongProducer => caught_self_agreeing_wrong_producer(&case),
262    };
263    ControlResult { control, caught }
264}
265
266/// Runs every registered control.
267pub fn run_battery() -> Vec<ControlResult> {
268    FaultControl::ALL.iter().map(|c| run_control(*c)).collect()
269}
270
271/// The meta-check: the registered list is exhaustive against `ALL`, and
272/// every registered row is actually caught by the harness that exists
273/// today.
274///
275/// An undetectable control fails this, loudly, rather than being silently
276/// dropped.
277pub fn battery_is_exhaustive_and_all_caught() -> Result<(), Vec<FaultControl>> {
278    let results = run_battery();
279    let missed: Vec<FaultControl> = results
280        .iter()
281        .filter(|r| !r.caught)
282        .map(|r| r.control)
283        .collect();
284    if missed.is_empty() && results.len() == FaultControl::ALL.len() {
285        Ok(())
286    } else {
287        Err(missed)
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn every_registered_control_is_caught() {
297        assert_eq!(battery_is_exhaustive_and_all_caught(), Ok(()));
298    }
299
300    #[test]
301    fn each_row_reports_which_control_it_is() {
302        for result in run_battery() {
303            assert!(result.caught, "{} was not caught", result.control.label());
304        }
305    }
306
307    #[test]
308    fn the_registered_list_has_nine_rows() {
309        assert_eq!(FaultControl::ALL.len(), 9);
310    }
311}