Skip to main content

qala_compiler/
effects.rs

1//! the effect lattice the type checker infers each function against, stored
2//! as a `u8` bitfield.
3//!
4//! Qala tracks four effects -- `Pure`, `Io`, `Alloc`, `Panic` -- but only the
5//! last three are stored explicitly: an empty bitfield IS pure. the lattice
6//! is the powerset of `{Io, Alloc, Panic}` ordered by subset; lattice height
7//! is 4 (the chain `pure -> +io -> +alloc -> +panic`). that height is what
8//! makes Plan 4's effect fixed-point loop terminate -- a monotonic ascent on
9//! a finite lattice of height `h` converges in at most `h - 1` rounds
10//! (Davey & Priestley), so the iteration is guaranteed to settle in <= 3
11//! rounds regardless of call-graph shape.
12//!
13//! the u8 repr is chosen because every operation the type checker needs
14//! collapses to a single machine op:
15//! - `union` is a bitwise `|`
16//! - `is_subset_of` is one mask: `(self & !other) == 0`
17//! - equality is a one-byte compare
18//!
19//! diagnostics need a deterministic textual form -- the renderer must produce
20//! byte-identical output for the same set every time. [`EffectSet::display`]
21//! enumerates the flags in fixed (Io, Alloc, Panic) order and renders an
22//! empty set as `"pure"`. that wording is the contract Plan 5's
23//! `EffectViolation` diagnostic wording depends on.
24//!
25//! this file depends only on `core::fmt`. no inference logic lives here --
26//! the type checker builds an [`EffectSet`] from each function's body in a
27//! later plan; this module just provides the data and its operations.
28
29/// the `Io` flag's bit in [`EffectSet`]'s u8 repr.
30///
31/// the specific bit value is the one implementation detail to lock here -- the
32/// type checker's fixed-point loop only cares that ascent is monotonic, but the
33/// renderer's tests pin the bit layout so a regression in `union` (e.g.
34/// accidentally swapping the io and alloc constants) shows up as a clear
35/// `display()` test failure.
36const IO: u8 = 0b001;
37
38/// the `Alloc` flag's bit in [`EffectSet`]'s u8 repr.
39const ALLOC: u8 = 0b010;
40
41/// the `Panic` flag's bit in [`EffectSet`]'s u8 repr.
42const PANIC: u8 = 0b100;
43
44/// the set of effects a function performs.
45///
46/// an empty set IS pure -- `Pure` is implicit, not a stored flag. the u8 repr
47/// makes `union` a single `|` op, `is_subset_of` a single mask, and equality a
48/// one-byte compare. instances are immutable; every operation returns a fresh
49/// [`EffectSet`] rather than mutating in place. `Copy` matters because the
50/// type checker stores an [`EffectSet`] on every typed function and reads it
51/// at every call site; one byte is cheaper to copy than to reference.
52///
53/// `Default` derives `EffectSet(0)`, which equals [`EffectSet::pure`] -- the
54/// type checker initialises every unannotated function at the bottom of the
55/// lattice and unions upward during fixed-point ascent.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
57pub struct EffectSet(u8);
58
59impl EffectSet {
60    /// the empty set: no flags set, the lattice bottom.
61    ///
62    /// equivalent to `is pure` annotated functions. constructing a function's
63    /// initial [`EffectSet`] for fixed-point ascent starts here.
64    pub fn pure() -> Self {
65        Self(0)
66    }
67
68    /// every flag set: the lattice top.
69    ///
70    /// used as the upper bound for the unannotated-caller fallback: when the
71    /// type checker calls a function it cannot resolve a signature for, it
72    /// treats the call as producing `full()` so an annotated caller's check
73    /// fails closed rather than silently accepting.
74    pub fn full() -> Self {
75        Self(IO | ALLOC | PANIC)
76    }
77
78    /// the singleton set `{Io}`.
79    pub fn io() -> Self {
80        Self(IO)
81    }
82
83    /// the singleton set `{Alloc}`.
84    pub fn alloc() -> Self {
85        Self(ALLOC)
86    }
87
88    /// the singleton set `{Panic}`.
89    pub fn panic() -> Self {
90        Self(PANIC)
91    }
92
93    /// is the `Io` flag set?
94    pub fn has_io(self) -> bool {
95        self.0 & IO != 0
96    }
97
98    /// is the `Alloc` flag set?
99    pub fn has_alloc(self) -> bool {
100        self.0 & ALLOC != 0
101    }
102
103    /// is the `Panic` flag set?
104    pub fn has_panic(self) -> bool {
105        self.0 & PANIC != 0
106    }
107
108    /// is this the empty set?
109    ///
110    /// true iff no flags are set. an `is pure`-annotated function's effect
111    /// set is exactly this case.
112    pub fn is_pure(self) -> bool {
113        self.0 == 0
114    }
115
116    /// the lattice join: the union of `self`'s flags and `other`'s flags.
117    ///
118    /// idempotent (`a.union(a) == a`), commutative (`a.union(b) == b.union(a)`),
119    /// and associative. these properties are what guarantee Plan 4's
120    /// fixed-point loop converges monotonically.
121    pub fn union(self, other: Self) -> Self {
122        Self(self.0 | other.0)
123    }
124
125    /// is every flag set in `self` also set in `other`?
126    ///
127    /// reflexive (`a.is_subset_of(a) == true`), antisymmetric (`a ⊆ b` and
128    /// `b ⊆ a` implies `a == b`), and transitive. the type checker's
129    /// annotation check is `inferred.is_subset_of(annotated)` -- the inferred
130    /// effect of the body must not exceed what the annotation allows.
131    pub fn is_subset_of(self, other: Self) -> bool {
132        (self.0 & !other.0) == 0
133    }
134
135    /// the canonical comma-joined rendering for diagnostics.
136    ///
137    /// flags appear in fixed `(Io, Alloc, Panic)` order regardless of how the
138    /// set was constructed, so `io().union(alloc())` and
139    /// `alloc().union(io())` both render `"io, alloc"`. an empty set renders
140    /// `"pure"`. this is the determinism contract the renderer's
141    /// byte-identical-output tests depend on.
142    pub fn display(&self) -> String {
143        if self.is_pure() {
144            return "pure".to_string();
145        }
146        let mut parts: Vec<&'static str> = Vec::with_capacity(3);
147        if self.has_io() {
148            parts.push("io");
149        }
150        if self.has_alloc() {
151            parts.push("alloc");
152        }
153        if self.has_panic() {
154            parts.push("panic");
155        }
156        parts.join(", ")
157    }
158}
159
160impl core::fmt::Display for EffectSet {
161    /// delegate to [`EffectSet::display`] so `format!("{e}")` produces the
162    /// canonical comma-joined form. lets `EffectViolation` messages
163    /// interpolate effect sets without an explicit `.display()` call at every
164    /// site.
165    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
166        f.write_str(&self.display())
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn pure_has_no_flags_set() {
176        let p = EffectSet::pure();
177        assert!(p.is_pure());
178        assert!(!p.has_io());
179        assert!(!p.has_alloc());
180        assert!(!p.has_panic());
181    }
182
183    #[test]
184    fn io_singleton_carries_only_io() {
185        let e = EffectSet::io();
186        assert!(e.has_io());
187        assert!(!e.has_alloc());
188        assert!(!e.has_panic());
189        assert!(!e.is_pure());
190    }
191
192    #[test]
193    fn alloc_singleton_carries_only_alloc() {
194        let e = EffectSet::alloc();
195        assert!(!e.has_io());
196        assert!(e.has_alloc());
197        assert!(!e.has_panic());
198        assert!(!e.is_pure());
199    }
200
201    #[test]
202    fn panic_singleton_carries_only_panic() {
203        let e = EffectSet::panic();
204        assert!(!e.has_io());
205        assert!(!e.has_alloc());
206        assert!(e.has_panic());
207        assert!(!e.is_pure());
208    }
209
210    #[test]
211    fn full_has_every_flag_set() {
212        let e = EffectSet::full();
213        assert!(e.has_io());
214        assert!(e.has_alloc());
215        assert!(e.has_panic());
216        assert!(!e.is_pure());
217    }
218
219    #[test]
220    fn union_with_pure_returns_the_other_set() {
221        // pure() is the lattice bottom; unioning with it is the identity.
222        assert_eq!(EffectSet::pure().union(EffectSet::io()), EffectSet::io(),);
223        assert_eq!(EffectSet::io().union(EffectSet::pure()), EffectSet::io(),);
224        assert_eq!(
225            EffectSet::pure().union(EffectSet::full()),
226            EffectSet::full(),
227        );
228    }
229
230    #[test]
231    fn union_combines_distinct_flags() {
232        let io_alloc = EffectSet::io().union(EffectSet::alloc());
233        assert!(io_alloc.has_io());
234        assert!(io_alloc.has_alloc());
235        assert!(!io_alloc.has_panic());
236    }
237
238    #[test]
239    fn union_is_commutative() {
240        // a sample of pairs: union should give the same set regardless of
241        // argument order.
242        let pairs = [
243            (EffectSet::pure(), EffectSet::io()),
244            (EffectSet::io(), EffectSet::alloc()),
245            (EffectSet::alloc(), EffectSet::panic()),
246            (EffectSet::io(), EffectSet::full()),
247            (EffectSet::pure(), EffectSet::full()),
248        ];
249        for (a, b) in pairs {
250            assert_eq!(
251                a.union(b),
252                b.union(a),
253                "union not commutative on {a:?} and {b:?}"
254            );
255        }
256    }
257
258    #[test]
259    fn union_is_idempotent() {
260        // a.union(a) == a -- the lattice property the fixed-point loop relies on.
261        let samples = [
262            EffectSet::pure(),
263            EffectSet::io(),
264            EffectSet::alloc(),
265            EffectSet::panic(),
266            EffectSet::io().union(EffectSet::alloc()),
267            EffectSet::full(),
268        ];
269        for s in samples {
270            assert_eq!(s.union(s), s, "union not idempotent on {s:?}");
271        }
272    }
273
274    #[test]
275    fn is_subset_of_matches_lattice_subset_semantics() {
276        // pure is a subset of everything; full is a subset only of itself.
277        assert!(EffectSet::pure().is_subset_of(EffectSet::full()));
278        assert!(!EffectSet::full().is_subset_of(EffectSet::pure()));
279        // io ⊆ {io, alloc}
280        let io_alloc = EffectSet::io().union(EffectSet::alloc());
281        assert!(EffectSet::io().is_subset_of(io_alloc));
282        // {io, alloc} ⊄ {io}
283        assert!(!io_alloc.is_subset_of(EffectSet::io()));
284        // disjoint singletons are not subsets of each other.
285        assert!(!EffectSet::io().is_subset_of(EffectSet::alloc()));
286        assert!(!EffectSet::alloc().is_subset_of(EffectSet::io()));
287    }
288
289    #[test]
290    fn is_subset_of_is_reflexive() {
291        // a ⊆ a for every a in the sample.
292        let samples = [
293            EffectSet::pure(),
294            EffectSet::io(),
295            EffectSet::alloc(),
296            EffectSet::panic(),
297            EffectSet::io().union(EffectSet::alloc()),
298            EffectSet::io().union(EffectSet::panic()),
299            EffectSet::alloc().union(EffectSet::panic()),
300            EffectSet::full(),
301        ];
302        for s in samples {
303            assert!(s.is_subset_of(s), "subset reflexivity failure on {s:?}");
304        }
305    }
306
307    #[test]
308    fn display_renders_each_set_in_fixed_io_alloc_panic_order() {
309        // the determinism contract: enumerate flags in (Io, Alloc, Panic)
310        // order regardless of which order they were unioned in.
311        assert_eq!(EffectSet::pure().display(), "pure");
312        assert_eq!(EffectSet::io().display(), "io");
313        assert_eq!(EffectSet::alloc().display(), "alloc");
314        assert_eq!(EffectSet::panic().display(), "panic");
315        // io appears before alloc in the rendered string, even when alloc was
316        // the left-hand argument to union.
317        assert_eq!(
318            EffectSet::io().union(EffectSet::alloc()).display(),
319            "io, alloc",
320        );
321        assert_eq!(
322            EffectSet::alloc().union(EffectSet::io()).display(),
323            "io, alloc",
324        );
325        assert_eq!(
326            EffectSet::io().union(EffectSet::panic()).display(),
327            "io, panic",
328        );
329        assert_eq!(
330            EffectSet::alloc().union(EffectSet::panic()).display(),
331            "alloc, panic",
332        );
333        assert_eq!(EffectSet::full().display(), "io, alloc, panic");
334    }
335
336    #[test]
337    fn display_trait_matches_display_method() {
338        // the `Display` impl must produce the same bytes as `.display()` so
339        // `format!("{e}")` is interchangeable with `e.display()` at call
340        // sites like the `EffectViolation` message construction.
341        let cases = [
342            EffectSet::pure(),
343            EffectSet::io(),
344            EffectSet::alloc(),
345            EffectSet::panic(),
346            EffectSet::io().union(EffectSet::alloc()),
347            EffectSet::full(),
348        ];
349        for e in cases {
350            assert_eq!(format!("{e}"), e.display(), "Display mismatch on {e:?}");
351        }
352    }
353
354    #[test]
355    fn equality_distinguishes_distinct_sets() {
356        assert_eq!(EffectSet::pure(), EffectSet::pure());
357        assert_ne!(EffectSet::io(), EffectSet::alloc());
358        assert_ne!(EffectSet::pure(), EffectSet::io());
359        // commutativity-of-union implies equality regardless of construction
360        // order.
361        assert_eq!(
362            EffectSet::io().union(EffectSet::alloc()),
363            EffectSet::alloc().union(EffectSet::io()),
364        );
365        // EffectSet::default() equals pure().
366        assert_eq!(EffectSet::default(), EffectSet::pure());
367    }
368
369    #[test]
370    fn monotonic_ascent_converges_in_at_most_three_rounds() {
371        // the typechecker's effect fixed-point relies on this property: starting
372        // from pure(), unioning with full() reaches full() in one round. starting
373        // from a single flag, reaching full() takes at most 2 more rounds. the
374        // lattice has height 4 (chains pure -> +io -> +alloc -> +panic at most),
375        // so the loop terminates regardless of input order.
376        let mut e = EffectSet::pure();
377        let target = EffectSet::full();
378        let mut rounds = 0;
379        while e != target {
380            e = e.union(target);
381            rounds += 1;
382            assert!(
383                rounds <= 3,
384                "should converge within 3 rounds; took {rounds}"
385            );
386        }
387        assert!(rounds <= 3);
388
389        // also verify the worst-case chain: ascend one flag per round.
390        let chain = [EffectSet::io(), EffectSet::alloc(), EffectSet::panic()];
391        let mut e = EffectSet::pure();
392        let mut rounds = 0;
393        for step in chain {
394            e = e.union(step);
395            rounds += 1;
396        }
397        assert_eq!(e, EffectSet::full());
398        // three rounds to ascend the full chain: pure -> +io -> +alloc -> +panic.
399        assert_eq!(rounds, 3);
400    }
401}