Skip to main content

differential_engine/
invariants.rs

1//! Invariants 1–4 (spec/invariants.md); every one caught a real bug during
2//! prototype validation.
3//!
4//! They fall into two halves, and the split is the write boundary.
5//!
6//! **Invariants 1 and 2 are read-only and core.** They run inside the pipeline,
7//! and no document is emitted when either fails. Invariant 1b — the enumeration
8//! hole — is read-only too, and lives earlier still, in `rename_view::merge_raw`.
9//!
10//! **Invariants 3 and 4 build a tree, so they write.** Only a consumer that
11//! reconstructs a tree is protected by them, which is the shadow-branch builder
12//! alone. They run in `pipeline::verify`, which the caller invokes when it wants
13//! them, and they land in the report as `Some(TreeReport)`.
14
15use crate::EngineError;
16use crate::model::{DiffView, Disposition, Hunk};
17use crate::ports::{ObjectReader, ObjectWriter, RecountSource, TreeBuilder, TreeResolver};
18use crate::tree::build_tree;
19
20#[derive(Debug, Clone, serde::Serialize)]
21pub struct InvariantReport {
22    pub files_total: usize,
23    /// Text files (non-binary, non-submodule) checked byte-exactly.
24    pub applier_total: usize,
25    pub applier_ok: usize,
26    pub applier_mismatches: Vec<String>,
27    /// Binary files verified by oid instead of by reconstruction.
28    pub binary_oid_checked: usize,
29    pub hunks_total: usize,
30    pub accounting_ok: bool,
31    /// Invariants 3 and 4. `None` means `pipeline::verify` did not run — not
32    /// that it ran and passed. Consumers must not read absence as success.
33    pub tree: Option<TreeReport>,
34}
35
36/// Invariants 3 and 4: the half that needs a built tree, and so a write.
37#[derive(Debug, Clone, serde::Serialize)]
38pub struct TreeReport {
39    pub built_tree: Option<String>,
40    pub head_tree: String,
41    pub tree_ok: bool,
42    pub recount: usize,
43    pub recount_ok: bool,
44}
45
46impl InvariantReport {
47    /// Invariants 1 and 2 — everything the read-only pipeline can assert.
48    ///
49    /// This is the gate on emitting a document at all: a bad parse, a dropped
50    /// hunk or broken accounting all fail here, and all three would make a
51    /// renderer show the wrong thing.
52    pub fn fidelity_ok(&self) -> bool {
53        self.applier_mismatches.is_empty()
54            && self.applier_ok == self.applier_total
55            && self.accounting_ok
56    }
57
58    /// Invariants 1 to 4. **False when the tree half never ran**, which is
59    /// correct rather than a bug: a caller that wants the weaker claim asks
60    /// `fidelity_ok`.
61    pub fn all_ok(&self) -> bool {
62        self.fidelity_ok()
63            && self
64                .tree
65                .as_ref()
66                .is_some_and(|t| t.tree_ok && t.recount_ok)
67    }
68
69    /// "n/n" for the audit block.
70    pub fn applier_exact(&self) -> String {
71        format!("{}/{}", self.applier_ok, self.applier_total)
72    }
73}
74
75impl std::fmt::Display for InvariantReport {
76    /// The human form of the report: totals and invariants 1-4, one per line.
77    ///
78    /// Formatting, not printing — the engine still writes nothing. The
79    /// endpoints are deliberately absent: they are not part of the report (nor
80    /// of `--json`), so a caller that wants a range header prints its own.
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        let verdict = |ok: bool| if ok { "PASS" } else { "FAIL" };
83        writeln!(
84            f,
85            "files      {} ({} binary, checked by oid only — tree assertion is tautological for those)",
86            self.files_total, self.binary_oid_checked
87        )?;
88        writeln!(f, "hunks      {}", self.hunks_total)?;
89        writeln!(
90            f,
91            "inv1 applier fidelity   {}  {}",
92            self.applier_exact(),
93            verdict(self.applier_mismatches.is_empty())
94        )?;
95        for m in &self.applier_mismatches {
96            writeln!(f, "           mismatch: {m}")?;
97        }
98        writeln!(f, "inv2 hunk accounting    {}", verdict(self.accounting_ok))?;
99        let Some(t) = &self.tree else {
100            // Say it did not run. A blank here would read as a pass.
101            writeln!(f, "inv3 tree assertion     NOT RUN")?;
102            return write!(f, "inv4 independent recount NOT RUN");
103        };
104        writeln!(
105            f,
106            "inv3 tree assertion     {}  built {} head {}",
107            verdict(t.tree_ok),
108            t.built_tree.as_deref().unwrap_or("(not built)"),
109            t.head_tree
110        )?;
111        writeln!(
112            f,
113            "inv4 independent recount {} of {}  {}",
114            t.recount,
115            self.hunks_total,
116            verdict(t.recount_ok)
117        )?;
118        write!(
119            f,
120            "note: tree building writes unreferenced loose objects into the odb (gc-able)"
121        )
122    }
123}
124
125/// Invariants 1 and 2. **Read-only**: the bound list is one port, and that is
126/// the whole proof that the core pipeline cannot write.
127pub fn check_fidelity<G>(
128    git: &G,
129    base: &str,
130    head: &str,
131    view: &DiffView,
132) -> Result<InvariantReport, EngineError>
133where
134    G: ObjectReader,
135{
136    // ---- Invariant 1: applier fidelity ------------------------------------
137    let mut applier_total = 0usize;
138    let mut applier_ok = 0usize;
139    let mut mismatches = Vec::new();
140    let mut binary_checked = 0usize;
141
142    for f in &view.files {
143        match fidelity(f) {
144            Fidelity::Skip => continue,
145            Fidelity::ByOid(oid) => {
146                // The recorded object must exist in the odb.
147                git.require_object(oid)?;
148                binary_checked += 1;
149                continue;
150            }
151            Fidelity::NoOid => {
152                binary_checked += 1;
153                continue;
154            }
155            Fidelity::Reconstruct => {}
156        }
157        applier_total += 1;
158        let hunks: Vec<&Hunk> = f.hunks.iter().map(|&i| &view.hunks[i]).collect();
159        let base_content = git.blob(base, &f.path)?;
160        let got = crate::apply::apply_hunks(base_content.as_deref(), &hunks);
161        let want = if f.disposition == Disposition::Deleted {
162            Vec::new()
163        } else {
164            git.blob(head, &f.path)?.unwrap_or_default()
165        };
166        if got == want {
167            applier_ok += 1;
168        } else {
169            mismatches.push(format!(
170                "{}: reconstructed {}B, expected {}B",
171                String::from_utf8_lossy(&f.path),
172                got.len(),
173                want.len()
174            ));
175        }
176    }
177
178    // ---- Invariant 2: hunk accounting --------------------------------------
179    let accounting_ok = check_accounting(view);
180
181    Ok(InvariantReport {
182        files_total: view.files.len(),
183        applier_total,
184        applier_ok,
185        applier_mismatches: mismatches,
186        binary_oid_checked: binary_checked,
187        hunks_total: view.hunks.len(),
188        accounting_ok,
189        tree: None,
190    })
191}
192
193/// Invariants 3 and 4. **Writes**: it builds a tree from the hunks, which puts
194/// unreferenced loose objects in the odb.
195///
196/// `fidelity` is the report from `check_fidelity`, and it is read for one
197/// reason: never build a tree on a broken applier, or the tree assertion is
198/// made on top of a failure it cannot see (the prototype's rule).
199pub fn check_tree<G>(
200    git: &G,
201    base: &str,
202    head: &str,
203    view: &DiffView,
204    fidelity: &InvariantReport,
205) -> Result<TreeReport, EngineError>
206where
207    G: ObjectReader + ObjectWriter + TreeResolver + TreeBuilder + RecountSource,
208{
209    let head_tree = git.tree_of(head)?;
210
211    // ---- Invariant 3: non-tautological tree assertion ----------------------
212    let (built_tree, tree_ok) = if may_build_tree(fidelity) {
213        let built = build_tree(git, base, view)?;
214        let ok = built == head_tree;
215        (Some(built), ok)
216    } else {
217        (None, false)
218    };
219
220    // ---- Invariant 4: independent recount -----------------------------------
221    // Computed from git's own output over the BUILT tree, by a counter that is
222    // deliberately not the parser.
223    let (recount, recount_ok) = match &built_tree {
224        Some(t) => {
225            // Invariant 4's own port, never enumeration's: a change to one
226            // cannot move both sides of this comparison.
227            let out = git.recount_patch(base, t.as_str())?;
228            let n = dumb_hunk_count(&out);
229            (n, n == view.hunks.len())
230        }
231        None => (0, false),
232    };
233
234    Ok(TreeReport {
235        built_tree,
236        head_tree,
237        tree_ok,
238        recount,
239        recount_ok,
240    })
241}
242
243/// How invariant 1 verifies one file.
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245enum Fidelity<'a> {
246    /// Submodules: a gitlink has no content to reconstruct.
247    Skip,
248    /// Binary: no hunks exist, so the recorded oid is all there is to check.
249    ByOid(&'a str),
250    /// Binary with nothing recorded — counted, but there is nothing to assert.
251    NoOid,
252    /// Text: rebuild from base + hunks and compare byte for byte.
253    Reconstruct,
254}
255
256fn fidelity(f: &crate::model::FileChange) -> Fidelity<'_> {
257    if f.submodule.is_some() {
258        return Fidelity::Skip;
259    }
260    if f.binary {
261        return match f.new_oid.as_deref() {
262            Some(oid) => Fidelity::ByOid(oid),
263            None => Fidelity::NoOid,
264        };
265    }
266    Fidelity::Reconstruct
267}
268
269/// Invariant 2, entire: every hunk belongs to exactly one file, that file
270/// agrees, and the per-file lists sum to the canonical count.
271///
272/// Touches no git — it is a statement about the view's internal consistency,
273/// which is why it can be exercised against a hand-built view.
274fn check_accounting(view: &DiffView) -> bool {
275    let mut seen = vec![false; view.hunks.len()];
276    let mut ok = true;
277    let mut carried = 0usize;
278    for (fi, f) in view.files.iter().enumerate() {
279        for &hi in &f.hunks {
280            if hi >= seen.len() || seen[hi] || view.hunks[hi].file != fi {
281                ok = false;
282                continue;
283            }
284            seen[hi] = true;
285            carried += 1;
286        }
287    }
288    ok && carried == view.hunks.len()
289}
290
291/// Whether invariant 3 may run: never build a tree on a broken applier, or the
292/// tree assertion is being made on top of a failure it cannot see.
293///
294/// Accounting is deliberately not consulted. Invariant 2 is about the view's
295/// bookkeeping; the applier is what the tree is built from.
296fn may_build_tree(fidelity: &InvariantReport) -> bool {
297    fidelity.applier_mismatches.is_empty() && fidelity.applier_ok == fidelity.applier_total
298}
299
300/// The deliberately dumb `@@` counter. Must never share code with `parse.rs` —
301/// a shared bug would make invariant 4 circular.
302pub fn dumb_hunk_count(patch: &[u8]) -> usize {
303    patch
304        .split(|&b| b == b'\n')
305        .filter(|l| l.starts_with(b"@@ -"))
306        .count()
307}
308
309#[cfg(test)]
310mod tests {
311    use super::{
312        Fidelity, InvariantReport, TreeReport, check_accounting, dumb_hunk_count, fidelity,
313        may_build_tree,
314    };
315    use crate::model::{DiffView, Disposition, FileChange, Hunk};
316
317    fn hunk(file: usize) -> Hunk {
318        Hunk {
319            file,
320            old_start: 1,
321            old_count: 1,
322            new_start: 1,
323            new_count: 1,
324            removed: vec![],
325            added: vec![],
326            nonl_old: false,
327            nonl_new: false,
328        }
329    }
330
331    fn file(hunks: Vec<usize>) -> FileChange {
332        FileChange {
333            path: b"f".to_vec(),
334            disposition: Disposition::Modified,
335            new_mode: Some("100644".into()),
336            old_mode: None,
337            binary: false,
338            submodule: None,
339            old_oid: None,
340            new_oid: None,
341            hunks,
342            rename_similarity: None,
343            rename_from: None,
344            rename_to: None,
345            generated: None,
346        }
347    }
348
349    /// Invariant 2 was 14 lines inlined in a function that needed a repository,
350    /// so none of these cases had ever been asserted directly.
351    #[test]
352    fn accounting_holds_when_every_hunk_belongs_to_exactly_one_file() {
353        let view = DiffView {
354            files: vec![file(vec![0, 1]), file(vec![2])],
355            hunks: vec![hunk(0), hunk(0), hunk(1)],
356        };
357        assert!(check_accounting(&view));
358    }
359
360    #[test]
361    fn accounting_catches_a_hunk_claimed_twice() {
362        let view = DiffView {
363            files: vec![file(vec![0]), file(vec![0])],
364            hunks: vec![hunk(0)],
365        };
366        assert!(!check_accounting(&view), "one hunk in two files");
367    }
368
369    #[test]
370    fn accounting_catches_a_hunk_no_file_claims() {
371        let view = DiffView {
372            files: vec![file(vec![0])],
373            hunks: vec![hunk(0), hunk(0)],
374        };
375        assert!(!check_accounting(&view), "h1 is carried by nothing");
376    }
377
378    #[test]
379    fn accounting_catches_a_file_claiming_another_files_hunk() {
380        // The disagreement that matters: the file lists it, the hunk denies it.
381        let view = DiffView {
382            files: vec![file(vec![0]), file(vec![1])],
383            hunks: vec![hunk(0), hunk(0)],
384        };
385        assert!(!check_accounting(&view));
386    }
387
388    #[test]
389    fn accounting_catches_an_out_of_range_index() {
390        let view = DiffView {
391            files: vec![file(vec![7])],
392            hunks: vec![hunk(0)],
393        };
394        assert!(!check_accounting(&view));
395    }
396
397    #[test]
398    fn binary_and_submodule_files_are_verified_differently_from_text() {
399        let mut f = file(vec![]);
400        assert_eq!(fidelity(&f), Fidelity::Reconstruct);
401
402        f.binary = true;
403        f.new_oid = Some("abc".into());
404        assert_eq!(fidelity(&f), Fidelity::ByOid("abc"));
405
406        f.new_oid = None;
407        assert_eq!(fidelity(&f), Fidelity::NoOid);
408
409        f.binary = false;
410        f.submodule = Some((None, Some("s".into())));
411        assert_eq!(fidelity(&f), Fidelity::Skip);
412    }
413
414    fn report(applier_total: usize, applier_ok: usize, mismatches: Vec<String>) -> InvariantReport {
415        InvariantReport {
416            files_total: applier_total,
417            applier_total,
418            applier_ok,
419            applier_mismatches: mismatches,
420            binary_oid_checked: 0,
421            hunks_total: 0,
422            accounting_ok: true,
423            tree: None,
424        }
425    }
426
427    /// The prototype's rule: a tree built on a broken applier would assert
428    /// nothing, so invariant 3 does not run at all.
429    #[test]
430    fn a_broken_applier_stops_the_tree_from_being_built() {
431        assert!(may_build_tree(&report(3, 3, vec![])));
432        assert!(!may_build_tree(&report(3, 2, vec![])));
433        assert!(!may_build_tree(&report(
434            3,
435            3,
436            vec!["f: mismatch".to_string()]
437        )));
438    }
439
440    /// `all_ok` must not read a missing tree half as a pass. This is the whole
441    /// hazard the split introduces, so it is asserted directly.
442    #[test]
443    fn an_unverified_report_is_not_all_ok() {
444        let r = report(3, 3, vec![]);
445        assert!(r.fidelity_ok(), "invariants 1 and 2 passed");
446        assert!(!r.all_ok(), "invariants 3 and 4 never ran");
447    }
448
449    #[test]
450    fn a_verified_report_is_all_ok_only_when_both_halves_pass() {
451        let mut r = report(3, 3, vec![]);
452        r.tree = Some(TreeReport {
453            built_tree: Some("t".into()),
454            head_tree: "t".into(),
455            tree_ok: true,
456            recount: 0,
457            recount_ok: true,
458        });
459        assert!(r.all_ok());
460        r.tree.as_mut().unwrap().recount_ok = false;
461        assert!(!r.all_ok(), "invariant 4 failed");
462        assert!(r.fidelity_ok(), "but 1 and 2 still hold");
463    }
464
465    #[test]
466    fn dumb_counter_counts_headers_only() {
467        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";
468        assert_eq!(dumb_hunk_count(patch), 2);
469    }
470
471    #[test]
472    fn dumb_counter_ignores_content_lines_that_mention_hunks() {
473        // An added line whose content starts with "@@ -" gets a "+" prefix in
474        // the patch, so it cannot collide.
475        let patch = b"@@ -1,0 +1,1 @@\n+@@ -5,5 +5,5 @@\n";
476        assert_eq!(dumb_hunk_count(patch), 1);
477    }
478}