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}