Skip to main content

differential_engine/
invariants.rs

1//! Invariants 1–4 (spec/invariants.md). All of them run before any document is
2//! emitted; every one caught a real bug during prototype validation.
3
4use crate::EngineError;
5use crate::model::{DiffView, Disposition, Hunk};
6use crate::ports::{ObjectReader, ObjectWriter, RecountSource, TreeBuilder, TreeResolver};
7use crate::tree::build_tree;
8
9#[derive(Debug, Clone, serde::Serialize)]
10pub struct InvariantReport {
11    pub files_total: usize,
12    /// Text files (non-binary, non-submodule) checked byte-exactly.
13    pub applier_total: usize,
14    pub applier_ok: usize,
15    pub applier_mismatches: Vec<String>,
16    /// Binary files verified by oid instead of by reconstruction.
17    pub binary_oid_checked: usize,
18    pub hunks_total: usize,
19    pub accounting_ok: bool,
20    pub built_tree: Option<String>,
21    pub head_tree: String,
22    pub tree_ok: bool,
23    pub recount: usize,
24    pub recount_ok: bool,
25}
26
27impl InvariantReport {
28    pub fn all_ok(&self) -> bool {
29        self.applier_mismatches.is_empty()
30            && self.applier_ok == self.applier_total
31            && self.accounting_ok
32            && self.tree_ok
33            && self.recount_ok
34    }
35
36    /// "n/n" for the audit block.
37    pub fn applier_exact(&self) -> String {
38        format!("{}/{}", self.applier_ok, self.applier_total)
39    }
40}
41
42impl std::fmt::Display for InvariantReport {
43    /// The human form of the report: totals and invariants 1-4, one per line.
44    ///
45    /// Formatting, not printing — the engine still writes nothing. The
46    /// endpoints are deliberately absent: they are not part of the report (nor
47    /// of `--json`), so a caller that wants a range header prints its own.
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        let verdict = |ok: bool| if ok { "PASS" } else { "FAIL" };
50        writeln!(
51            f,
52            "files      {} ({} binary, checked by oid only — tree assertion is tautological for those)",
53            self.files_total, self.binary_oid_checked
54        )?;
55        writeln!(f, "hunks      {}", self.hunks_total)?;
56        writeln!(
57            f,
58            "inv1 applier fidelity   {}  {}",
59            self.applier_exact(),
60            verdict(self.applier_mismatches.is_empty())
61        )?;
62        for m in &self.applier_mismatches {
63            writeln!(f, "           mismatch: {m}")?;
64        }
65        writeln!(f, "inv2 hunk accounting    {}", verdict(self.accounting_ok))?;
66        writeln!(
67            f,
68            "inv3 tree assertion     {}  built {} head {}",
69            verdict(self.tree_ok),
70            self.built_tree.as_deref().unwrap_or("(not built)"),
71            self.head_tree
72        )?;
73        writeln!(
74            f,
75            "inv4 independent recount {} of {}  {}",
76            self.recount,
77            self.hunks_total,
78            verdict(self.recount_ok)
79        )?;
80        write!(
81            f,
82            "note: tree building writes unreferenced loose objects into the odb (gc-able)"
83        )
84    }
85}
86
87/// Run invariants 1–4. Invariant 1 (applier fidelity) is asserted BEFORE the
88/// tree is built; if it fails, nothing is built on top of it.
89pub fn check_all<G>(
90    git: &G,
91    base: &str,
92    head: &str,
93    view: &DiffView,
94) -> Result<InvariantReport, EngineError>
95where
96    G: ObjectReader + ObjectWriter + TreeResolver + TreeBuilder + RecountSource,
97{
98    // ---- Invariant 1: applier fidelity ------------------------------------
99    let mut applier_total = 0usize;
100    let mut applier_ok = 0usize;
101    let mut mismatches = Vec::new();
102    let mut binary_checked = 0usize;
103
104    for f in &view.files {
105        match fidelity(f) {
106            Fidelity::Skip => continue,
107            Fidelity::ByOid(oid) => {
108                // The recorded object must exist in the odb.
109                git.require_object(oid)?;
110                binary_checked += 1;
111                continue;
112            }
113            Fidelity::NoOid => {
114                binary_checked += 1;
115                continue;
116            }
117            Fidelity::Reconstruct => {}
118        }
119        applier_total += 1;
120        let hunks: Vec<&Hunk> = f.hunks.iter().map(|&i| &view.hunks[i]).collect();
121        let base_content = git.blob(base, &f.path)?;
122        let got = crate::apply::apply_hunks(base_content.as_deref(), &hunks);
123        let want = if f.disposition == Disposition::Deleted {
124            Vec::new()
125        } else {
126            git.blob(head, &f.path)?.unwrap_or_default()
127        };
128        if got == want {
129            applier_ok += 1;
130        } else {
131            mismatches.push(format!(
132                "{}: reconstructed {}B, expected {}B",
133                String::from_utf8_lossy(&f.path),
134                got.len(),
135                want.len()
136            ));
137        }
138    }
139
140    // ---- Invariant 2: hunk accounting --------------------------------------
141    let accounting_ok = check_accounting(view);
142
143    let head_tree = git.tree_of(head)?;
144
145    // ---- Invariant 3: non-tautological tree assertion ----------------------
146    // Refuse to build on a broken applier (the prototype's rule).
147    let (built_tree, tree_ok) = if may_build_tree(applier_total, applier_ok, &mismatches) {
148        let built = build_tree(git, base, view)?;
149        let ok = built == head_tree;
150        (Some(built), ok)
151    } else {
152        (None, false)
153    };
154
155    // ---- Invariant 4: independent recount -----------------------------------
156    // Computed from git's own output over the BUILT tree, by a counter that is
157    // deliberately not the parser.
158    let (recount, recount_ok) = match &built_tree {
159        Some(t) => {
160            // Invariant 4's own port, never enumeration's: a change to one
161            // cannot move both sides of this comparison.
162            let out = git.recount_patch(base, t.as_str())?;
163            let n = dumb_hunk_count(&out);
164            (n, n == view.hunks.len())
165        }
166        None => (0, false),
167    };
168
169    Ok(InvariantReport {
170        files_total: view.files.len(),
171        applier_total,
172        applier_ok,
173        applier_mismatches: mismatches,
174        binary_oid_checked: binary_checked,
175        hunks_total: view.hunks.len(),
176        accounting_ok,
177        built_tree,
178        head_tree,
179        tree_ok,
180        recount,
181        recount_ok,
182    })
183}
184
185/// How invariant 1 verifies one file.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187enum Fidelity<'a> {
188    /// Submodules: a gitlink has no content to reconstruct.
189    Skip,
190    /// Binary: no hunks exist, so the recorded oid is all there is to check.
191    ByOid(&'a str),
192    /// Binary with nothing recorded — counted, but there is nothing to assert.
193    NoOid,
194    /// Text: rebuild from base + hunks and compare byte for byte.
195    Reconstruct,
196}
197
198fn fidelity(f: &crate::model::FileChange) -> Fidelity<'_> {
199    if f.submodule.is_some() {
200        return Fidelity::Skip;
201    }
202    if f.binary {
203        return match f.new_oid.as_deref() {
204            Some(oid) => Fidelity::ByOid(oid),
205            None => Fidelity::NoOid,
206        };
207    }
208    Fidelity::Reconstruct
209}
210
211/// Invariant 2, entire: every hunk belongs to exactly one file, that file
212/// agrees, and the per-file lists sum to the canonical count.
213///
214/// Touches no git — it is a statement about the view's internal consistency,
215/// which is why it can be exercised against a hand-built view.
216fn check_accounting(view: &DiffView) -> bool {
217    let mut seen = vec![false; view.hunks.len()];
218    let mut ok = true;
219    let mut carried = 0usize;
220    for (fi, f) in view.files.iter().enumerate() {
221        for &hi in &f.hunks {
222            if hi >= seen.len() || seen[hi] || view.hunks[hi].file != fi {
223                ok = false;
224                continue;
225            }
226            seen[hi] = true;
227            carried += 1;
228        }
229    }
230    ok && carried == view.hunks.len()
231}
232
233/// Whether invariant 3 may run: never build a tree on a broken applier, or the
234/// tree assertion is being made on top of a failure it cannot see.
235fn may_build_tree(applier_total: usize, applier_ok: usize, mismatches: &[String]) -> bool {
236    mismatches.is_empty() && applier_ok == applier_total
237}
238
239/// The deliberately dumb `@@` counter. Must never share code with `parse.rs` —
240/// a shared bug would make invariant 4 circular.
241pub fn dumb_hunk_count(patch: &[u8]) -> usize {
242    patch
243        .split(|&b| b == b'\n')
244        .filter(|l| l.starts_with(b"@@ -"))
245        .count()
246}
247
248#[cfg(test)]
249mod tests {
250    use super::{Fidelity, check_accounting, dumb_hunk_count, fidelity, may_build_tree};
251    use crate::model::{DiffView, Disposition, FileChange, Hunk};
252
253    fn hunk(file: usize) -> Hunk {
254        Hunk {
255            file,
256            old_start: 1,
257            old_count: 1,
258            new_start: 1,
259            new_count: 1,
260            removed: vec![],
261            added: vec![],
262            nonl_old: false,
263            nonl_new: false,
264        }
265    }
266
267    fn file(hunks: Vec<usize>) -> FileChange {
268        FileChange {
269            path: b"f".to_vec(),
270            disposition: Disposition::Modified,
271            new_mode: Some("100644".into()),
272            old_mode: None,
273            binary: false,
274            submodule: None,
275            old_oid: None,
276            new_oid: None,
277            hunks,
278            rename_similarity: None,
279            rename_from: None,
280            rename_to: None,
281            generated: None,
282        }
283    }
284
285    /// Invariant 2 was 14 lines inlined in a function that needed a repository,
286    /// so none of these cases had ever been asserted directly.
287    #[test]
288    fn accounting_holds_when_every_hunk_belongs_to_exactly_one_file() {
289        let view = DiffView {
290            files: vec![file(vec![0, 1]), file(vec![2])],
291            hunks: vec![hunk(0), hunk(0), hunk(1)],
292        };
293        assert!(check_accounting(&view));
294    }
295
296    #[test]
297    fn accounting_catches_a_hunk_claimed_twice() {
298        let view = DiffView {
299            files: vec![file(vec![0]), file(vec![0])],
300            hunks: vec![hunk(0)],
301        };
302        assert!(!check_accounting(&view), "one hunk in two files");
303    }
304
305    #[test]
306    fn accounting_catches_a_hunk_no_file_claims() {
307        let view = DiffView {
308            files: vec![file(vec![0])],
309            hunks: vec![hunk(0), hunk(0)],
310        };
311        assert!(!check_accounting(&view), "h1 is carried by nothing");
312    }
313
314    #[test]
315    fn accounting_catches_a_file_claiming_another_files_hunk() {
316        // The disagreement that matters: the file lists it, the hunk denies it.
317        let view = DiffView {
318            files: vec![file(vec![0]), file(vec![1])],
319            hunks: vec![hunk(0), hunk(0)],
320        };
321        assert!(!check_accounting(&view));
322    }
323
324    #[test]
325    fn accounting_catches_an_out_of_range_index() {
326        let view = DiffView {
327            files: vec![file(vec![7])],
328            hunks: vec![hunk(0)],
329        };
330        assert!(!check_accounting(&view));
331    }
332
333    #[test]
334    fn binary_and_submodule_files_are_verified_differently_from_text() {
335        let mut f = file(vec![]);
336        assert_eq!(fidelity(&f), Fidelity::Reconstruct);
337
338        f.binary = true;
339        f.new_oid = Some("abc".into());
340        assert_eq!(fidelity(&f), Fidelity::ByOid("abc"));
341
342        f.new_oid = None;
343        assert_eq!(fidelity(&f), Fidelity::NoOid);
344
345        f.binary = false;
346        f.submodule = Some((None, Some("s".into())));
347        assert_eq!(fidelity(&f), Fidelity::Skip);
348    }
349
350    /// The prototype's rule: a tree built on a broken applier would assert
351    /// nothing, so invariant 3 does not run at all.
352    #[test]
353    fn a_broken_applier_stops_the_tree_from_being_built() {
354        assert!(may_build_tree(3, 3, &[]));
355        assert!(!may_build_tree(3, 2, &[]));
356        assert!(!may_build_tree(3, 3, &["f: mismatch".to_string()]));
357    }
358
359    #[test]
360    fn dumb_counter_counts_headers_only() {
361        let patch = b"diff --git a/f b/f\n@@ -1,2 +1,2 @@\n-a\n+b\n@@ -9 +9 @@\n-x\n+y\n";
362        assert_eq!(dumb_hunk_count(patch), 2);
363    }
364
365    #[test]
366    fn dumb_counter_ignores_content_lines_that_mention_hunks() {
367        // An added line whose content starts with "@@ -" gets a "+" prefix in
368        // the patch, so it cannot collide.
369        let patch = b"@@ -1,0 +1,1 @@\n+@@ -5,5 +5,5 @@\n";
370        assert_eq!(dumb_hunk_count(patch), 1);
371    }
372}