beck_core/
row.rs

1//! Effect rows — §3.2, made real.
2//!
3//! [`docs/03-type-and-effect-system.md`](../../../../../docs/03-type-and-effect-system.md) §3.2:
4//! "Every function type carries an inferred, row-polymorphic effect row … Effect polymorphism is
5//! what keeps one standard library: `map : (list[a], (a -> b ! e)) -> list[b] ! e`."
6//!
7//! Phase 1 had four atoms, declared with `uses` and *collected* by walking what a body calls. This
8//! module is the replacement: a wider atom set, row variables, and a unifier — so a row is a thing
9//! the checker solves for rather than a list the programmer maintains.
10//!
11//! # The shape of a row
12//!
13//! A row is a **set** of atoms plus a set of row *variables* standing for "whatever else the
14//! caller's function argument does":
15//!
16//! ```text
17//! {}                      pure
18//! { durable }             closed
19//! { dom | e }             open: dom, plus whatever `e` turns out to be
20//! { e, f }                the union of two callers' rows
21//! ```
22//!
23//! Sets, not Rémy-style scoped labels: an effect happening twice is an effect happening. That makes
24//! *union* — the operation inference actually performs, once per call — trivial and exact, which is
25//! the operation that has to be right. Unification is the rarer one.
26//!
27//! # Why a row can hold several variables
28//!
29//! `fn twice(f, g) = f(); g()` performs `e_f ∪ e_g`, and there is no single variable that is their
30//! union. A row that could hold only one tail would have to force `e_f = e_g`, which is a lie about
31//! a program that typechecks. Holding a *set* of tails costs nothing and says the truth.
32
33use std::collections::BTreeSet;
34use std::fmt;
35use std::sync::Arc;
36
37/// An effect atom. §3.2's list, with the one correction Phase 2 makes to it (see [`Effect::Nondet`]).
38#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
39pub enum Effect {
40    /// A merge point: arbitrary interleaving. "there is exactly one of these" (§3.7).
41    Ingress,
42    /// A persistent accumulator — the log.
43    Durable,
44    /// Touches the document.
45    Dom,
46    /// Reads a clock, a random source, or mints an id.
47    ///
48    /// §3.2 files `time` and `rand` under the *ambient* set — "implicitly available outside folds
49    /// and elided from signatures". Phase 2 does not, and the reason is §3.3's own table: the fold
50    /// engine is a **tier**, and whether a tier can discharge time and randomness is precisely
51    /// §3.7's determinism rule. An effect that decides a placement cannot also be elided from the
52    /// signature that placement is derived from. `log` and `metrics` stay ambient
53    /// ([`Effect::Ambient`]); these do not.
54    Nondet,
55    /// `net.out(host)` — an outbound call to a named host. The host is what
56    /// [`docs/06-kubernetes-and-packaging.md`](../../../../../docs/06-kubernetes-and-packaging.md)
57    /// §6.5 turns into a NetworkPolicy peer.
58    NetOut(Arc<str>),
59    /// `net.in` — accepts inbound connections.
60    NetIn,
61    /// `fs.read(path)` and `fs.write(path)` — two atoms for one resource.
62    ///
63    /// §3.2 listed a single `fs(path)` until [`docs/81`](../../../../../docs/81-fs-is-two-atoms-report.md).
64    /// One atom naming a resource without saying what is done to it cannot answer the two questions
65    /// that are actually asked of it: whether two things may happen at once
66    /// ([`crate::check`]'s `parallel:` rule) and whether a mount needs to be writable
67    /// ([`docs/06`](../../../../../docs/06-kubernetes-and-packaging.md) §6.5). §3.8's escape
68    /// hatches were already two — [`Effect::ExternalRead`] and [`Effect::ExternalWrite`] — and
69    /// this is the same split for the same reason.
70    FsRead(Arc<str>),
71    FsWrite(Arc<str>),
72    /// Reads process environment.
73    Env,
74    /// Starts concurrent work.
75    Spawn,
76    /// `cap.X` — a capability the caller must hold. §3.5: "forgetting an auth check means the
77    /// `cap.*` effect goes undischarged — a compile error, not a pentest finding".
78    Cap(Arc<str>),
79    /// May diverge or panic.
80    Partial,
81    /// `raises(E)` — this may fail with a value of the named type.
82    ///
83    /// An error is a **row label**, not a mechanism: a signature without one provably cannot fail,
84    /// and `Result[T, E]` is the *reified* form a handler produces rather than a parallel channel
85    /// ([`docs/38`](../../../../../docs/38-literature-survey.md) §38.4, adopting Koka's `exn`). The
86    /// atom names the error's type because a handler has to say what it catches — a `try` that
87    /// caught everything would turn a caller's unknown failure into this one's `Result`.
88    Raises(Arc<str>),
89    /// §3.8's escape hatches: an existing store the team already has.
90    ExternalRead(Arc<str>),
91    ExternalWrite(Arc<str>),
92    /// The ambient set that survives §3.2's description: available everywhere, elided from
93    /// signatures, and never a reason to place anything.
94    Ambient(Ambient),
95}
96
97#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
98pub enum Ambient {
99    Log,
100    Metrics,
101}
102
103impl Effect {
104    pub fn name(&self) -> String {
105        match self {
106            Effect::Ingress => "ingress".into(),
107            Effect::Durable => "durable".into(),
108            Effect::Dom => "dom".into(),
109            Effect::Nondet => "nondet".into(),
110            Effect::NetOut(h) => format!("net.out({h})"),
111            Effect::NetIn => "net.in".into(),
112            Effect::FsRead(p) => format!("fs.read({p})"),
113            Effect::FsWrite(p) => format!("fs.write({p})"),
114            Effect::Env => "env".into(),
115            Effect::Spawn => "spawn".into(),
116            Effect::Cap(c) => format!("cap.{c}"),
117            Effect::Partial => "partial".into(),
118            Effect::Raises(t) => format!("raises({t})"),
119            Effect::ExternalRead(s) => format!("external.read({s})"),
120            Effect::ExternalWrite(s) => format!("external.write({s})"),
121            Effect::Ambient(Ambient::Log) => "log".into(),
122            Effect::Ambient(Ambient::Metrics) => "metrics".into(),
123        }
124    }
125
126    /// Parse an atom as written in a `uses` clause: `durable`, `net.out(api.example.com)`, `cap.session`.
127    pub fn parse(s: &str) -> Option<Effect> {
128        let (head, arg) = match s.split_once('(') {
129            Some((h, rest)) => (h.trim(), Some(rest.trim_end_matches(')').trim())),
130            None => (s.trim(), None),
131        };
132        Some(match (head, arg) {
133            ("ingress", None) => Effect::Ingress,
134            ("durable", None) => Effect::Durable,
135            ("dom", None) => Effect::Dom,
136            ("nondet" | "nondeterministic", None) => Effect::Nondet,
137            ("net.out", Some(h)) => Effect::NetOut(Arc::from(h)),
138            ("net.in", None) => Effect::NetIn,
139            ("fs.read", Some(p)) => Effect::FsRead(Arc::from(p)),
140            ("fs.write", Some(p)) => Effect::FsWrite(Arc::from(p)),
141            ("env", None) => Effect::Env,
142            ("spawn", None) => Effect::Spawn,
143            ("partial", None) => Effect::Partial,
144            ("raises", Some(t)) => Effect::Raises(Arc::from(t)),
145            ("external.read", Some(s)) => Effect::ExternalRead(Arc::from(s)),
146            ("external.write", Some(s)) => Effect::ExternalWrite(Arc::from(s)),
147            ("log", None) => Effect::Ambient(Ambient::Log),
148            ("metrics", None) => Effect::Ambient(Ambient::Metrics),
149            (other, None) if other.starts_with("cap.") => Effect::Cap(Arc::from(&other[4..])),
150            _ => return None,
151        })
152    }
153
154    /// Ambient effects are available on every tier and elided from printed signatures (§3.2).
155    pub fn is_ambient(&self) -> bool {
156        matches!(self, Effect::Ambient(_))
157    }
158
159    /// The atom without its argument — what a tier's discharge table and a cost model key on.
160    pub fn family(&self) -> &'static str {
161        match self {
162            Effect::Ingress => "ingress",
163            Effect::Durable => "durable",
164            Effect::Dom => "dom",
165            Effect::Nondet => "nondet",
166            Effect::NetOut(_) => "net.out",
167            Effect::NetIn => "net.in",
168            Effect::FsRead(_) => "fs.read",
169            Effect::FsWrite(_) => "fs.write",
170            Effect::Env => "env",
171            Effect::Spawn => "spawn",
172            Effect::Cap(_) => "cap",
173            Effect::Partial => "partial",
174            Effect::Raises(_) => "raises",
175            Effect::ExternalRead(_) => "external.read",
176            Effect::ExternalWrite(_) => "external.write",
177            Effect::Ambient(_) => "ambient",
178        }
179    }
180}
181
182impl fmt::Display for Effect {
183    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184        f.write_str(&self.name())
185    }
186}
187
188pub type RowVarId = u32;
189
190/// An effect row: a set of atoms, plus row variables standing for the rest.
191#[derive(Clone, Debug, Default, PartialEq, Eq)]
192pub struct Row {
193    pub atoms: BTreeSet<Effect>,
194    pub tails: BTreeSet<RowVarId>,
195}
196
197impl Row {
198    pub fn empty() -> Row {
199        Row::default()
200    }
201
202    pub fn of(atoms: impl IntoIterator<Item = Effect>) -> Row {
203        Row {
204            atoms: atoms.into_iter().collect(),
205            tails: BTreeSet::new(),
206        }
207    }
208
209    pub fn var(v: RowVarId) -> Row {
210        Row {
211            atoms: BTreeSet::new(),
212            tails: BTreeSet::from([v]),
213        }
214    }
215
216    pub fn is_closed(&self) -> bool {
217        self.tails.is_empty()
218    }
219
220    pub fn is_pure(&self) -> bool {
221        self.atoms.is_empty() && self.tails.is_empty()
222    }
223
224    /// The union — the operation inference performs once per call.
225    pub fn union(mut self, other: &Row) -> Row {
226        self.atoms.extend(other.atoms.iter().cloned());
227        self.tails.extend(other.tails.iter().copied());
228        self
229    }
230
231    pub fn add(&mut self, e: Effect) {
232        self.atoms.insert(e);
233    }
234
235    /// The atoms, in a stable order, with ambient ones dropped — how a signature prints (§3.2).
236    pub fn visible(&self) -> Vec<Effect> {
237        self.atoms
238            .iter()
239            .filter(|e| !e.is_ambient())
240            .cloned()
241            .collect()
242    }
243}
244
245impl fmt::Display for Row {
246    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247        let atoms: Vec<String> = self.atoms.iter().map(|e| e.name()).collect();
248        let tails: Vec<String> = self.tails.iter().map(|v| format!("e{v}")).collect();
249        match (atoms.is_empty(), tails.is_empty()) {
250            (true, true) => f.write_str("{}"),
251            (false, true) => write!(f, "{{{}}}", atoms.join(", ")),
252            (true, false) => write!(f, "{{{}}}", tails.join(" | ")),
253            (false, false) => write!(f, "{{{} | {}}}", atoms.join(", "), tails.join(" | ")),
254        }
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn atoms_round_trip_through_their_written_form() {
264        for text in [
265            "ingress",
266            "durable",
267            "dom",
268            "nondet",
269            "net.out(api.example.com)",
270            "net.in",
271            "fs.read(/var/lib/beck)",
272            "fs.write(/var/lib/beck)",
273            "env",
274            "spawn",
275            "cap.session",
276            "partial",
277            "external.read(legacy)",
278            "external.write(legacy)",
279            "log",
280            "metrics",
281        ] {
282            let e = Effect::parse(text).unwrap_or_else(|| panic!("`{text}` should parse"));
283            assert_eq!(e.name(), text, "`{text}` must print as it parsed");
284        }
285        assert!(Effect::parse("teleport").is_none());
286    }
287
288    #[test]
289    fn only_log_and_metrics_are_ambient() {
290        // §3.2 also lists `time` and `rand`. Phase 2 does not, and `Effect::Nondet` says why:
291        // whether a tier discharges them *is* the determinism rule, so they cannot be elided.
292        assert!(Effect::Ambient(Ambient::Log).is_ambient());
293        assert!(Effect::Ambient(Ambient::Metrics).is_ambient());
294        assert!(!Effect::Nondet.is_ambient());
295        let row = Row::of([Effect::Durable, Effect::Ambient(Ambient::Log)]);
296        assert_eq!(row.visible(), vec![Effect::Durable]);
297    }
298
299    #[test]
300    fn a_row_prints_its_variables_after_a_bar() {
301        assert_eq!(Row::empty().to_string(), "{}");
302        assert_eq!(Row::of([Effect::Dom]).to_string(), "{dom}");
303        assert_eq!(Row::var(3).to_string(), "{e3}");
304        assert_eq!(
305            Row::of([Effect::Dom]).union(&Row::var(3)).to_string(),
306            "{dom | e3}"
307        );
308    }
309
310    #[test]
311    fn union_is_idempotent_and_commutative() {
312        let a = Row::of([Effect::Dom]).union(&Row::var(1));
313        let b = Row::of([Effect::Durable]).union(&Row::var(1));
314        assert_eq!(a.clone().union(&b), b.union(&a));
315        assert_eq!(a.clone().union(&a), a);
316    }
317}