beck_core/
cost.rs

1//! The placement cost model — §3.4's node costs, edge costs and byte estimates.
2//!
3//! [`docs/03-type-and-effect-system.md`](../../../../../docs/03-type-and-effect-system.md) §3.4:
4//!
5//! > **Node costs**: ∞ for forbidden tiers; tier-specific compute cost otherwise (client CPU is
6//! > expensive and untrusted; fold-engine compute is cheap and adjacent to state).
7//! > **Edge costs**: for each *signal edge or call* that crosses tiers, `latency + bytes × unit`,
8//! > bytes estimated from row types.
9//!
10//! Everything here is an **integer**. Not for speed — the graphs are tiny — but because §3.4's
11//! first guardrail is determinism, and a cost model in floating point makes "these two placements
12//! cost the same" a question about rounding. Costs are in hundredths of a notional millisecond, so
13//! a latency of 25 ms is `2_500`.
14//!
15//! # The numbers, and where they come from
16//!
17//! They are not measured; they are *ordered*, and the ordering is what a placement decision reads.
18//! Every one is a ratio the design already states, so the model can be argued with rather than
19//! tuned in the dark:
20//!
21//! | quantity | value | from |
22//! |---|---|---|
23//! | compute weight, data | 1 | "fold-engine compute is cheap and adjacent to state" (§3.4) |
24//! | compute weight, server | 2 | the middle |
25//! | compute weight, client | 8 | "client CPU is expensive and untrusted" (§3.4) |
26//! | latency, data ↔ server | 1 ms | same pod network |
27//! | latency, server ↔ client | 25 ms | Phase 0's realistic RTT ([`18`](../../../../../docs/18-phase-0-report.md)) |
28//! | latency, data ↔ client | 26 ms | it goes through the server |
29//! | bytes → cost | ×5 per byte | so a 2 KB crossing (~100 ms) outweighs an RTT, which is the
30//! |   |   | trade a placement decision is actually making |
31//!
32//! There is no constant for "a fold that is not at the data tier". [`node_cost`] charges such a
33//! fold an *edge to the log*, sized from the accumulator, because that is what it physically pays:
34//! the log is at the data tier, and an accumulator kept elsewhere crosses to it on every event. A
35//! constant would have been a number to tune; an edge is a number to derive.
36//!
37//! # The one rule worth arguing with
38//!
39//! A crossing is charged the **smaller** of its two endpoints' values, and that is not a
40//! simplification — it is §5.1's Mode A/B question, expressed as a cost instead of as a mode.
41//! Between a `Signal[State]` at the data tier and a `Signal[Html]` at the browser, the compiler may
42//! either send the state and render in the browser (Mode B: 2 KB of data patches) or render first
43//! and send the document (Mode A: 1 KB of DOM patches). It will pick the cheaper, so the cheaper is
44//! what the boundary costs. Phase 2 only *implements* Mode A, so today the minimum is a prediction
45//! rather than a choice — and it is the right prediction to be making when Phase 3 makes the choice
46//! real.
47
48use std::collections::BTreeMap;
49use std::sync::Arc;
50
51use crate::ty::{Effect, Row, Tier, Ty, TyDecl};
52
53/// Costs are in hundredths of a millisecond.
54pub type Cost = i64;
55
56/// A placement that cannot be, kept finite so that sums never overflow and comparisons stay total.
57pub const FORBIDDEN: Cost = 1_000_000_000;
58
59/// §3.4: "client CPU is expensive and untrusted; fold-engine compute is cheap and adjacent to state".
60pub fn compute_weight(t: Tier) -> Cost {
61    match t {
62        Tier::Data => 1,
63        Tier::Server => 2,
64        Tier::Client => 8,
65        // Unplaced code is compiled into whichever tier needs it, so it is charged to that tier's
66        // own work rather than to a placement of its own (§3.3).
67        Tier::Any => 0,
68    }
69}
70
71/// Round-trip latency between two tiers, in hundredths of a millisecond.
72pub fn latency(a: Tier, b: Tier) -> Cost {
73    use Tier::*;
74    match (a, b) {
75        // Unplaced code crosses nothing: it is duplicated to the tier that calls it.
76        (Any, _) | (_, Any) => 0,
77        (x, y) if x == y => 0,
78        (Data, Server) | (Server, Data) => 100,
79        (Server, Client) | (Client, Server) => 2_500,
80        (Data, Client) | (Client, Data) => 2_600,
81        _ => 0,
82    }
83}
84
85/// The cost of moving one byte across a tier boundary.
86pub const BYTE_UNIT: Cost = 5;
87
88/// The node cost of putting `row` on `tier`, given how much work the node does and — for a durable
89/// fold — what its accumulator looks like.
90pub fn node_cost(
91    tier: Tier,
92    row: &Row,
93    work: i64,
94    state: Option<&Ty>,
95    types: &BTreeMap<Arc<str>, TyDecl>,
96) -> Cost {
97    if !row.atoms.iter().all(|e| tier.discharges(e)) {
98        return FORBIDDEN;
99    }
100    let mut cost = compute_weight(tier) * work;
101    // The log lives at the data tier. A fold placed anywhere else does not merely compute
102    // elsewhere; it crosses to the log on every event, carrying its accumulator. That is an edge,
103    // so it is priced as one.
104    if row.atoms.contains(&Effect::Durable) && tier != Tier::Data {
105        let bytes = state.map(|t| estimate_bytes(t, types)).unwrap_or(0);
106        cost += latency(tier, Tier::Data) + bytes * BYTE_UNIT;
107    }
108    cost
109}
110
111/// The cost of an edge between two placed nodes.
112///
113/// The two types are what each *end* produces. The bytes charged are the smaller: see the module
114/// docs — the compiler may compute on either side of a boundary and will send whichever is less, so
115/// the boundary costs the lesser of the two.
116pub fn edge_cost(
117    a: Tier,
118    b: Tier,
119    ty_a: &Ty,
120    ty_b: &Ty,
121    types: &BTreeMap<Arc<str>, TyDecl>,
122) -> Cost {
123    if a == b || a == Tier::Any || b == Tier::Any {
124        return 0;
125    }
126    let bytes = estimate_bytes(ty_a, types).min(estimate_bytes(ty_b, types));
127    latency(a, b) + bytes * BYTE_UNIT
128}
129
130/// Estimate the wire size of a value of this type — §3.4's "bytes estimated from row types".
131///
132/// An estimate, and deliberately a crude one: what a placement decision needs is the *ratio*
133/// between a `Map[Id, Todo]` and an `Int`, not a byte count. `ASSUMED_CARDINALITY` is the one place
134/// a guess is made about data the compiler cannot see, and it is named so that it can be argued
135/// with rather than discovered.
136pub fn estimate_bytes(ty: &Ty, types: &BTreeMap<Arc<str>, TyDecl>) -> Cost {
137    fn go(ty: &Ty, types: &BTreeMap<Arc<str>, TyDecl>, depth: u32) -> Cost {
138        if depth > 8 {
139            // A recursive type: stop, and charge it as one more element rather than diverging.
140            return 32;
141        }
142        match ty {
143            Ty::Var(_) => 16,
144            Ty::Fun(..) => 0, // a function does not cross; a closure is not Sendable
145            Ty::Con(name, args) => match name.as_ref() {
146                Ty::BOOL => 1,
147                Ty::INT | Ty::FLOAT => 8,
148                Ty::STR => 32,
149                Ty::UNIT => 0,
150                // A rendered document, or a patch derived from one. Big enough that placing a view
151                // across a boundary is visible in the model, which is the point.
152                Ty::HTML => 1_024,
153                Ty::ATTR => 32,
154                Ty::LIST | Ty::STREAM | Ty::SIGNAL | Ty::OPTION | Ty::ENVELOPE | Ty::SECRET => {
155                    let elem = args.first().map(|a| go(a, types, depth + 1)).unwrap_or(16);
156                    if name.as_ref() == Ty::OPTION
157                        || name.as_ref() == Ty::ENVELOPE
158                        || name.as_ref() == Ty::SECRET
159                    {
160                        elem + 8
161                    } else {
162                        elem * ASSUMED_CARDINALITY
163                    }
164                }
165                Ty::MAP => {
166                    let k = args.first().map(|a| go(a, types, depth + 1)).unwrap_or(16);
167                    let v = args.get(1).map(|a| go(a, types, depth + 1)).unwrap_or(16);
168                    (k + v) * ASSUMED_CARDINALITY
169                }
170                Ty::RESULT => args
171                    .iter()
172                    .map(|a| go(a, types, depth + 1))
173                    .max()
174                    .unwrap_or(16),
175                other => match types.get(other) {
176                    Some(TyDecl::Model { fields, .. }) => {
177                        fields.iter().map(|(_, t)| go(t, types, depth + 1)).sum()
178                    }
179                    // A union costs its largest variant plus a tag.
180                    Some(TyDecl::Union { variants, .. }) => {
181                        1 + variants
182                            .iter()
183                            .map(|v| v.fields.iter().map(|(_, t)| go(t, types, depth + 1)).sum())
184                            .max()
185                            .unwrap_or(0)
186                    }
187                    Some(TyDecl::Newtype { inner, .. }) => go(inner, types, depth + 1),
188                    Some(TyDecl::Alias { ty, .. }) => go(ty, types, depth + 1),
189                    None => 16,
190                },
191            },
192        }
193    }
194    go(ty, types, 0)
195}
196
197/// How many elements a collection is assumed to hold when nothing says otherwise.
198///
199/// The compiler cannot know, and the honest options are to guess visibly or to pretend a `Map` is
200/// the size of a pointer. `beck tune` (Phase 4) is where a measured number would replace this one.
201pub const ASSUMED_CARDINALITY: Cost = 16;
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use crate::ty::Variant;
207
208    fn types() -> BTreeMap<Arc<str>, TyDecl> {
209        BTreeMap::from([
210            (
211                Arc::from("Todo"),
212                TyDecl::Model {
213                    name: Arc::from("Todo"),
214                    params: Vec::new(),
215                    fields: vec![
216                        (Arc::from("text"), Ty::str_()),
217                        (Arc::from("done"), Ty::bool_()),
218                    ],
219                },
220            ),
221            (
222                Arc::from("Command"),
223                TyDecl::Union {
224                    name: Arc::from("Command"),
225                    params: Vec::new(),
226                    variants: vec![
227                        Variant {
228                            name: Arc::from("Toggle"),
229                            fields: vec![(Arc::from("id"), Ty::str_())],
230                        },
231                        Variant {
232                            name: Arc::from("Add"),
233                            fields: vec![
234                                (Arc::from("id"), Ty::str_()),
235                                (Arc::from("text"), Ty::str_()),
236                            ],
237                        },
238                    ],
239                },
240            ),
241        ])
242    }
243
244    #[test]
245    fn a_collection_costs_more_to_cross_than_a_scalar() {
246        let t = types();
247        let scalar = estimate_bytes(&Ty::int(), &t);
248        let record = estimate_bytes(&Ty::con("Todo"), &t);
249        let collection = estimate_bytes(&Ty::map(Ty::str_(), Ty::con("Todo")), &t);
250        assert!(scalar < record, "{scalar} < {record}");
251        assert!(record < collection, "{record} < {collection}");
252        // A union is its largest variant plus a tag, not the sum of all of them.
253        assert_eq!(estimate_bytes(&Ty::con("Command"), &t), 1 + 64);
254    }
255
256    #[test]
257    fn a_function_type_has_no_wire_size_because_it_never_crosses() {
258        assert_eq!(
259            estimate_bytes(&Ty::fun(vec![Ty::int()], Ty::int()), &types()),
260            0
261        );
262    }
263
264    #[test]
265    fn a_recursive_type_terminates() {
266        // `model Tree: kids: list[Tree]` — the estimate must stop rather than diverge.
267        let t = BTreeMap::from([(
268            Arc::from("Tree"),
269            TyDecl::Model {
270                name: Arc::from("Tree"),
271                params: Vec::new(),
272                fields: vec![(Arc::from("kids"), Ty::list(Ty::con("Tree")))],
273            },
274        )]);
275        assert!(estimate_bytes(&Ty::con("Tree"), &t) > 0);
276    }
277
278    #[test]
279    fn a_forbidden_tier_costs_more_than_any_reachable_placement() {
280        let t = types();
281        let durable = Row::of([Effect::Durable]);
282        let state = Ty::map(Ty::str_(), Ty::con("Todo"));
283        assert_eq!(
284            node_cost(Tier::Client, &durable, 1, Some(&state), &t),
285            FORBIDDEN
286        );
287        // …and the log's residency makes the data tier the cheap place for a real accumulator, by a
288        // margin no amount of compute closes: the charge is the accumulator's own size.
289        assert!(
290            node_cost(Tier::Data, &durable, 1_000, Some(&state), &t)
291                < node_cost(Tier::Server, &durable, 1, Some(&state), &t)
292        );
293        // A trivial accumulator is a different matter, and the model says so rather than pretending
294        // otherwise — which is the difference between a derived number and a tuned one.
295        assert!(
296            node_cost(Tier::Server, &durable, 1, Some(&Ty::int()), &t)
297                < node_cost(Tier::Server, &durable, 1, Some(&state), &t)
298        );
299    }
300
301    #[test]
302    fn crossing_to_a_browser_costs_an_rtt_and_crossing_within_a_pod_does_not() {
303        let t = types();
304        assert_eq!(
305            edge_cost(Tier::Data, Tier::Data, &Ty::int(), &Ty::int(), &t),
306            0
307        );
308        assert!(
309            edge_cost(Tier::Server, Tier::Client, &Ty::int(), &Ty::int(), &t)
310                > edge_cost(Tier::Data, Tier::Server, &Ty::int(), &Ty::int(), &t)
311        );
312        // Unplaced code is duplicated rather than called across a boundary, so it crosses nothing.
313        assert_eq!(
314            edge_cost(Tier::Any, Tier::Client, &Ty::html(), &Ty::html(), &t),
315            0
316        );
317    }
318
319    #[test]
320    fn a_crossing_costs_the_smaller_of_its_two_ends() {
321        // §5.1's Mode A/B decision as a cost: between a big state and a smaller rendered document,
322        // the boundary carries the document, because a compiler free to render on either side will.
323        let t = types();
324        let state = Ty::map(Ty::str_(), Ty::con("Todo"));
325        let both = edge_cost(Tier::Data, Tier::Client, &state, &Ty::html(), &t);
326        let doc_only = edge_cost(Tier::Data, Tier::Client, &Ty::html(), &Ty::html(), &t);
327        assert_eq!(both, doc_only);
328        assert!(both < edge_cost(Tier::Data, Tier::Client, &state, &state, &t));
329    }
330}