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}