Skip to main content

boundgraph_grant/
disjoint.rs

1//! The disjointness proof behind `par`.
2//!
3//! Two branches may run concurrently only if neither can observe or clobber
4//! what the other touches. Stated over grant sets, that is the specification's
5//! three-way condition:
6//!
7//! ```text
8//! W(A) ∩ R(B) = ∅   ∧   R(A) ∩ W(B) = ∅   ∧   W(A) ∩ W(B) = ∅
9//! ```
10//!
11//! Read against read is deliberately absent: two branches reading the same file
12//! interfere with nothing, and forbidding that would rule out most useful
13//! parallelism.
14//!
15//! Every other framework lets you run branches concurrently and hope. The
16//! difference here is that an unprovable `par` is a **build error**, so the
17//! race is found in a pull request rather than under production load.
18
19use boundgraph_core::{GrantAtom, GrantSet};
20
21use crate::access::{Access, classify};
22use crate::trie::ResourceTrie;
23
24/// Which of the three conditions a conflicting pair violates.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum ConflictKind {
27    /// Both sides write the same resource.
28    WriteWrite,
29    /// The left side writes what the right side reads.
30    WriteRead,
31    /// The left side reads what the right side writes.
32    ReadWrite,
33}
34
35impl ConflictKind {
36    /// A short description for a diagnostic.
37    pub fn describe(self) -> &'static str {
38        match self {
39            Self::WriteWrite => "both branches write it",
40            Self::WriteRead => "the left branch writes what the right branch reads",
41            Self::ReadWrite => "the left branch reads what the right branch writes",
42        }
43    }
44}
45
46/// The specific pair of atoms that decided the outcome.
47///
48/// Reported so a diagnostic can name the real problem rather than say "these
49/// branches conflict somehow", and so the compiler can suggest a fix: narrow
50/// one of these two grants, or degrade the `par` to a `seq`.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Conflict {
53    /// The atom from the left-hand grant set.
54    pub left: GrantAtom,
55    /// The atom from the right-hand grant set.
56    pub right: GrantAtom,
57    /// Which condition it violates.
58    pub kind: ConflictKind,
59}
60
61/// The result of a disjointness proof.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum Disjointness {
64    /// Proved: the branches cannot touch the same resource. Safe to run in
65    /// parallel.
66    Disjoint,
67    /// Proved: they can interfere. The offending pair is named.
68    Overlapping(Conflict),
69    /// Not decided. Treated as a refusal by the compiler — see US-0206.
70    Undecided(Conflict),
71}
72
73impl Disjointness {
74    /// Whether parallel execution is *proved* safe.
75    ///
76    /// [`Disjointness::Undecided`] is not safe. There is deliberately no
77    /// variant of this that accepts an undecided proof.
78    pub fn is_proved_disjoint(&self) -> bool {
79        matches!(self, Self::Disjoint)
80    }
81
82    /// The conflicting pair, if there is one.
83    pub fn conflict(&self) -> Option<&Conflict> {
84        match self {
85            Self::Disjoint => None,
86            Self::Overlapping(c) | Self::Undecided(c) => Some(c),
87        }
88    }
89}
90
91/// Split a grant set into the resources it reads and those it writes.
92fn partition(set: &GrantSet) -> (Vec<&GrantAtom>, Vec<&GrantAtom>) {
93    set.iter()
94        .partition(|a| classify(&a.action) == Access::Read)
95}
96
97/// Prove — or fail to prove — that two grant sets cannot interfere.
98///
99/// Returns the first proved overlap if there is one; otherwise the first
100/// undecided pair; otherwise [`Disjointness::Disjoint`]. A proved conflict is
101/// always preferred over an undecided one, so diagnostics report the real
102/// problem rather than a gap in the solver.
103pub fn disjoint(a: &GrantSet, b: &GrantSet) -> Disjointness {
104    let (a_reads, a_writes) = partition(a);
105    let (b_reads, b_writes) = partition(b);
106
107    // The three conditions. Read-vs-read is absent by design.
108    let checks = [
109        (&a_writes, &b_reads, ConflictKind::WriteRead),
110        (&a_reads, &b_writes, ConflictKind::ReadWrite),
111        (&a_writes, &b_writes, ConflictKind::WriteWrite),
112    ];
113
114    let mut undecided: Option<Conflict> = None;
115
116    for (left, right, kind) in checks {
117        let trie = ResourceTrie::build(right.iter().copied());
118
119        for l in left.iter() {
120            let probe = trie.probe(l);
121
122            if let Some(index) = probe.hit {
123                return Disjointness::Overlapping(Conflict {
124                    left: (*l).clone(),
125                    right: trie.atom(index).clone(),
126                    kind,
127                });
128            }
129
130            if probe.undecided && undecided.is_none() {
131                // Name a plausible counterpart so the diagnostic has something
132                // concrete to point at.
133                let counterpart = right
134                    .iter()
135                    .find(|r| r.domain == l.domain)
136                    .or_else(|| right.first())
137                    .map(|r| (*r).clone())
138                    .unwrap_or_else(|| (*l).clone());
139                undecided = Some(Conflict {
140                    left: (*l).clone(),
141                    right: counterpart,
142                    kind,
143                });
144            }
145        }
146    }
147
148    undecided.map_or(Disjointness::Disjoint, Disjointness::Undecided)
149}
150
151/// Prove that every pair in a set of branches is mutually disjoint.
152///
153/// This is what a `par` node with more than two branches needs: pairwise
154/// disjointness, since any two of them may run at the same time.
155pub fn all_disjoint<'a>(branches: impl IntoIterator<Item = &'a GrantSet>) -> Disjointness {
156    let sets: Vec<&GrantSet> = branches.into_iter().collect();
157    let mut undecided: Option<Conflict> = None;
158
159    for i in 0..sets.len() {
160        for j in (i + 1)..sets.len() {
161            match disjoint(sets[i], sets[j]) {
162                Disjointness::Disjoint => {}
163                overlapping @ Disjointness::Overlapping(_) => return overlapping,
164                Disjointness::Undecided(c) => {
165                    undecided.get_or_insert(c);
166                }
167            }
168        }
169    }
170
171    undecided.map_or(Disjointness::Disjoint, Disjointness::Undecided)
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    fn set(atoms: &[&str]) -> GrantSet {
179        GrantSet::parse(atoms.iter().copied()).expect("test set should parse")
180    }
181
182    #[test]
183    fn two_reads_of_the_same_file_are_disjoint() {
184        // The whole point of excluding read-vs-read: this must be allowed.
185        let a = set(&["fs:read:/src/**"]);
186        let b = set(&["fs:read:/src/**"]);
187        assert_eq!(disjoint(&a, &b), Disjointness::Disjoint);
188    }
189
190    #[test]
191    fn two_writes_of_the_same_file_are_not() {
192        let a = set(&["fs:write:/build/out"]);
193        let b = set(&["fs:write:/build/out"]);
194        let result = disjoint(&a, &b);
195        assert!(!result.is_proved_disjoint());
196        assert_eq!(result.conflict().unwrap().kind, ConflictKind::WriteWrite);
197    }
198
199    #[test]
200    fn a_write_against_a_read_is_a_race() {
201        let writer = set(&["fs:write:/src/a.rs"]);
202        let reader = set(&["fs:read:/src/**"]);
203
204        let forward = disjoint(&writer, &reader);
205        assert_eq!(forward.conflict().unwrap().kind, ConflictKind::WriteRead);
206
207        let backward = disjoint(&reader, &writer);
208        assert_eq!(backward.conflict().unwrap().kind, ConflictKind::ReadWrite);
209    }
210
211    #[test]
212    fn unrelated_paths_are_disjoint() {
213        let a = set(&["fs:write:/build/**"]);
214        let b = set(&["fs:write:/dist/**"]);
215        assert_eq!(disjoint(&a, &b), Disjointness::Disjoint);
216    }
217
218    #[test]
219    fn different_domains_never_conflict() {
220        let a = set(&["fs:write:/src/a"]);
221        let b = set(&["net:egress:api.example.com:443"]);
222        assert_eq!(disjoint(&a, &b), Disjointness::Disjoint);
223    }
224
225    #[test]
226    fn nesting_counts_as_overlap() {
227        let a = set(&["fs:write:/src/lib/a.rs"]);
228        let b = set(&["fs:read:/src/**"]);
229        assert!(!disjoint(&a, &b).is_proved_disjoint());
230    }
231
232    #[test]
233    fn the_offending_pair_is_reported() {
234        let a = set(&["fs:write:/build/out", "fs:read:/src/**"]);
235        let b = set(&["fs:read:/build/out", "fs:read:/docs/**"]);
236
237        let c = disjoint(&a, &b)
238            .conflict()
239            .cloned()
240            .expect("should conflict");
241        assert_eq!(c.left.to_string(), "fs:write:/build/out");
242        assert_eq!(c.right.to_string(), "fs:read:/build/out");
243        assert_eq!(c.kind, ConflictKind::WriteRead);
244        assert!(!c.kind.describe().is_empty());
245    }
246
247    #[test]
248    fn an_undecidable_pair_is_undecided_not_disjoint() {
249        let a = set(&["fs:write:/src/*.rs"]);
250        let b = set(&["fs:read:/src/main.rs"]);
251        let result = disjoint(&a, &b);
252        assert!(matches!(result, Disjointness::Undecided(_)));
253        assert!(
254            !result.is_proved_disjoint(),
255            "an undecided proof must never read as safe"
256        );
257    }
258
259    #[test]
260    fn a_proved_conflict_outranks_an_undecided_one() {
261        let a = set(&["fs:write:/src/*.rs", "fs:write:/build/out"]);
262        let b = set(&["fs:read:/build/out"]);
263        assert!(matches!(disjoint(&a, &b), Disjointness::Overlapping(_)));
264    }
265
266    #[test]
267    fn exec_and_egress_are_treated_as_writes() {
268        // Two branches running processes are not provably independent.
269        let a = set(&["proc:exec:git"]);
270        let b = set(&["proc:exec:git"]);
271        assert!(!disjoint(&a, &b).is_proved_disjoint());
272
273        let x = set(&["net:egress:api.example.com:443"]);
274        let y = set(&["net:egress:api.example.com:443"]);
275        assert!(!disjoint(&x, &y).is_proved_disjoint());
276    }
277
278    #[test]
279    fn two_branches_using_the_same_secret_are_disjoint() {
280        let a = set(&["secret:use:stripe_key"]);
281        let b = set(&["secret:use:stripe_key"]);
282        assert_eq!(disjoint(&a, &b), Disjointness::Disjoint);
283    }
284
285    #[test]
286    fn empty_grants_are_disjoint_from_everything() {
287        let busy = set(&["fs:write:/**"]);
288        assert_eq!(disjoint(&GrantSet::EMPTY, &busy), Disjointness::Disjoint);
289        assert_eq!(disjoint(&busy, &GrantSet::EMPTY), Disjointness::Disjoint);
290    }
291
292    #[test]
293    fn disjointness_is_symmetric_in_its_verdict() {
294        let cases = [
295            (set(&["fs:write:/a"]), set(&["fs:read:/a"])),
296            (set(&["fs:write:/a"]), set(&["fs:write:/b"])),
297            (set(&["fs:read:/a"]), set(&["fs:read:/a"])),
298            (set(&["fs:write:/src/*.rs"]), set(&["fs:read:/src/main.rs"])),
299        ];
300        for (a, b) in cases {
301            assert_eq!(
302                disjoint(&a, &b).is_proved_disjoint(),
303                disjoint(&b, &a).is_proved_disjoint(),
304                "verdict must not depend on argument order: {a:?} vs {b:?}"
305            );
306        }
307    }
308
309    #[test]
310    fn a_branch_is_never_disjoint_from_itself_when_it_writes() {
311        let w = set(&["fs:write:/build/**"]);
312        assert!(!disjoint(&w, &w).is_proved_disjoint());
313    }
314
315    // ── many branches ───────────────────────────────────────────────────────
316
317    #[test]
318    fn pairwise_disjointness_across_several_branches() {
319        let branches = [
320            set(&["fs:write:/a/**"]),
321            set(&["fs:write:/b/**"]),
322            set(&["fs:write:/c/**"]),
323        ];
324        assert_eq!(all_disjoint(&branches), Disjointness::Disjoint);
325    }
326
327    #[test]
328    fn one_bad_pair_sinks_the_whole_par() {
329        let branches = [
330            set(&["fs:write:/a/**"]),
331            set(&["fs:write:/b/**"]),
332            set(&["fs:read:/a/thing"]), // clashes with the first
333        ];
334        let result = all_disjoint(&branches);
335        assert!(!result.is_proved_disjoint());
336        assert_eq!(result.conflict().unwrap().kind, ConflictKind::WriteRead);
337    }
338
339    #[test]
340    fn fewer_than_two_branches_are_trivially_disjoint() {
341        assert_eq!(all_disjoint(std::iter::empty()), Disjointness::Disjoint);
342        let single = [set(&["fs:write:/a/**"])];
343        assert_eq!(all_disjoint(&single), Disjointness::Disjoint);
344    }
345}