beck_core/
place.rs

1//! Stage 7 — placement: inferred from effects, solved against a cost model, verified.
2//!
3//! [`docs/03-type-and-effect-system.md`](../../../../../docs/03-type-and-effect-system.md) §3.3:
4//! "**Placement is not a primitive annotation; it is a constraint solution** — with explicit
5//! `@on(...)` always available and always winning. Purity means *unplaced*: legal on every tier,
6//! compiled to each tier that needs it."
7//!
8//! Phase 1 shipped the *verification* half. Phase 2 adds the other two thirds of §3.3 and all of
9//! §3.4: candidates come from the inferred effect row, the choice among them comes from a cost
10//! model ([`crate::cost`]), and every guardrail §3.4 calls non-negotiable is here —
11//!
12//! * **determinism** — integer costs, a fixed enumeration order and a total tie-break, so the same
13//!   program always yields the same solution;
14//! * **stability** — the previous solution is persisted in `beck.lock` and preferred on a tie, and
15//!   churn against it is reported rather than absorbed;
16//! * **explainability** — every candidate's cost survives the solve, so `beck explain place` can
17//!   print the derivation rather than the conclusion;
18//! * **assertability** — `beck check --assert-place page=client` fails the build on a change.
19//!
20//! # What is actually being decided
21//!
22//! Less than it first appears, and that is the design working rather than the solver shirking.
23//! Three rules pin most of a program before the solver runs, and each is a statement about meaning
24//! rather than about cost:
25//!
26//! 1. `@on(...)` wins (§3.3).
27//! 2. A **pure definition is unplaced** — `Tier::Any` — and is compiled into every tier that calls
28//!    it. "That duplication is the payoff, not waste."
29//! 3. A **`Signal[Html]` is the browser's subscription** (§4.3), so it is pinned to the client.
30//!    Where the *view function* runs — Mode A server-side rendering versus Mode B in the browser —
31//!    is the [`05`](../../../../../docs/05-tier-lowering.md) §5.1 decision that §3.4 also mentions,
32//!    and it is Phase 3's: Mode B does not exist, so the cost model has nothing to choose between
33//!    and should not pretend otherwise.
34//!
35//! What is left is genuinely open, and the corpus exercises it: which tier holds a `durable` fold,
36//! where `decide` sits, and where an effectful definition that more than one tier could discharge
37//! ends up — `net.out(origin)` being the sharp case, since a browser and a server can both make
38//! that call and only the callers decide which is cheaper.
39
40use std::collections::{BTreeMap, BTreeSet};
41use std::fmt;
42use std::sync::Arc;
43
44use beck_diag::{Diagnostic, Diagnostics};
45
46use crate::check::Program;
47use crate::core::{Core, CoreKind};
48use crate::cost::{self, Cost, FORBIDDEN};
49use crate::ty::{Effect, Row, Tier, Ty};
50
51/// A placeable thing: a top-level definition, or a signal.
52#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
53pub enum Key {
54    Def(Arc<str>),
55    Signal(Arc<str>),
56}
57
58impl Key {
59    pub fn name(&self) -> &Arc<str> {
60        match self {
61            Key::Def(n) | Key::Signal(n) => n,
62        }
63    }
64}
65
66impl fmt::Display for Key {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match self {
69            Key::Def(n) => write!(f, "def/{n}"),
70            Key::Signal(n) => write!(f, "signal/{n}"),
71        }
72    }
73}
74
75/// Why a node's placement is what it is — the content of `beck explain place`.
76#[derive(Clone, Debug)]
77pub struct Explanation {
78    pub key: Key,
79    pub chosen: Tier,
80    pub row: Row,
81    /// True when the tier was decided by a *rule* rather than by cost — an `@on(...)`, purity, or a
82    /// `Signal[Html]`. The candidate costs below are still real, and for a pinned node the chosen
83    /// one is not required to be the cheapest: that is what pinning means.
84    pub pinned: bool,
85    /// Every tier, with the whole program's cost when this node sits there and everything else
86    /// stays where the solver put it. §4.7's "candidates : data (cost 1.0), server (cost 3.1), …".
87    pub candidates: Vec<(Tier, Cost)>,
88    pub because: String,
89}
90
91#[derive(Clone, Copy, Debug, PartialEq, Eq)]
92pub enum Method {
93    /// Every combination was tried: the solution is optimal for this cost model.
94    Exhaustive,
95    /// Too many free nodes to enumerate; a deterministic sweep to a local minimum. Reported, so
96    /// that nobody has to wonder which they got.
97    Sweep,
98}
99
100impl Method {
101    pub fn name(self) -> &'static str {
102        match self {
103            Method::Exhaustive => "exhaustive (optimal for this cost model)",
104            Method::Sweep => "sweep (a local minimum, not proved optimal)",
105        }
106    }
107}
108
109/// The solved placement of a whole program.
110#[derive(Clone, Debug)]
111pub struct Solution {
112    pub tiers: BTreeMap<Key, Tier>,
113    pub explanations: Vec<Explanation>,
114    pub method: Method,
115    pub total: Cost,
116    /// Nodes whose placement differs from `beck.lock` — §3.4's "churn reported in CI".
117    pub churn: Vec<(Key, Tier, Tier)>,
118    /// Nodes where two tiers cost exactly the same and no lock settled it.
119    pub ties: Vec<(Key, Vec<Tier>)>,
120}
121
122impl Solution {
123    pub fn explanation(&self, name: &str) -> Option<&Explanation> {
124        self.explanations
125            .iter()
126            .find(|e| e.key.name().as_ref() == name || e.key.to_string() == name)
127    }
128}
129
130/// The persisted previous solution — §3.4's stability guardrail.
131///
132/// A one-line edit must not re-place unrelated code. Nothing in the cost model prevents that on its
133/// own: costs are a function of the whole program, so a new definition shifts them everywhere. The
134/// lock is what turns "the solver happens to agree with yesterday" into "the solver is *asked* to
135/// agree with yesterday, and says so when it cannot" — a tie-break and a record, never an override,
136/// because a lock that could force an illegal placement would be a way to defeat §3.5.
137#[derive(Clone, Debug, Default)]
138pub struct Lock {
139    pub tiers: BTreeMap<String, Tier>,
140}
141
142impl Lock {
143    pub const FILE: &'static str = "beck.lock";
144
145    pub fn from_json(text: &str) -> Option<Lock> {
146        let v: serde_json::Value = serde_json::from_str(text).ok()?;
147        let mut tiers = BTreeMap::new();
148        for (k, t) in v.get("placement")?.as_object()? {
149            if let Some(t) = t.as_str().and_then(Tier::parse) {
150                tiers.insert(k.clone(), t);
151            }
152        }
153        Some(Lock { tiers })
154    }
155
156    pub fn to_json(&self) -> String {
157        let placement: serde_json::Map<String, serde_json::Value> = self
158            .tiers
159            .iter()
160            .map(|(k, t)| (k.clone(), serde_json::Value::String(t.name().into())))
161            .collect();
162        format!(
163            "{:#}\n",
164            serde_json::json!({
165                "version": 1,
166                "note": "beck's solved placement. Review it like a lockfile: a change here is a \
167                         change in where code runs.",
168                "placement": placement,
169            })
170        )
171    }
172
173    pub fn of(solution: &Solution) -> Lock {
174        Lock {
175            tiers: solution
176                .tiers
177                .iter()
178                .map(|(k, t)| (k.to_string(), *t))
179                .collect(),
180        }
181    }
182}
183
184// ---------------------------------------------------------------------------------------------
185// The graph the solver works over
186// ---------------------------------------------------------------------------------------------
187
188struct Node {
189    key: Key,
190    row: Row,
191    work: i64,
192    /// The type of the value that flows *out* of this node, for edge byte estimates.
193    carried: Ty,
194    pinned: Option<(Tier, String)>,
195    candidates: Vec<Tier>,
196}
197
198/// The order the solver prefers among equal-cost candidates: closest to the log first.
199///
200/// A total order, so a tie can never be resolved by iteration order or by a hash seed. The solver
201/// still *records* that the tie happened, so the default is a decision rather than a hiding place.
202const PREFERENCE: [Tier; 3] = [Tier::Data, Tier::Server, Tier::Client];
203
204fn rank(t: Tier) -> usize {
205    PREFERENCE.iter().position(|x| *x == t).unwrap_or(9)
206}
207
208fn walk(c: &Core, f: &mut dyn FnMut(&Core)) {
209    match &c.kind {
210        CoreKind::Lam { body, .. } => f(body),
211        CoreKind::App { func, args } => {
212            f(func);
213            args.iter().for_each(&mut *f);
214        }
215        CoreKind::Prim { args, .. } => args.iter().for_each(&mut *f),
216        CoreKind::Let { value, body, .. } => {
217            f(value);
218            f(body);
219        }
220        CoreKind::If { cond, then, alt } => {
221            f(cond);
222            f(then);
223            f(alt);
224        }
225        CoreKind::Match { scrutinee, arms } => {
226            f(scrutinee);
227            for e in arms.iter().flat_map(|a| a.exprs()) {
228                f(e);
229            }
230        }
231        CoreKind::Make { fields, .. } => fields.iter().for_each(|(_, v)| f(v)),
232        CoreKind::With { base, fields } => {
233            f(base);
234            fields.iter().for_each(|(_, v)| f(v));
235        }
236        CoreKind::Field { base, .. } => f(base),
237        CoreKind::ListLit(xs) => xs.iter().for_each(&mut *f),
238        CoreKind::MapLit(kvs) => kvs.iter().for_each(|(k, v)| {
239            f(k);
240            f(v);
241        }),
242        CoreKind::Const(_) | CoreKind::Var(_) | CoreKind::Global(_) => {}
243    }
244}
245
246/// Every top-level name an expression mentions — the placement graph's edges.
247pub fn mentions(c: &Core, out: &mut BTreeSet<Arc<str>>) {
248    if let CoreKind::Global(n) = &c.kind {
249        out.insert(n.clone());
250    }
251    walk(c, &mut |k| mentions(k, out));
252}
253
254fn size(c: &Core) -> i64 {
255    let mut n = 1;
256    walk(c, &mut |k| n += size(k));
257    n
258}
259
260/// The element a signal carries: `Signal[State]` carries a `State`.
261fn carried(t: &Ty) -> Ty {
262    match t {
263        Ty::Con(n, args)
264            if (n.as_ref() == Ty::SIGNAL || n.as_ref() == Ty::STREAM) && args.len() == 1 =>
265        {
266            args[0].clone()
267        }
268        other => other.clone(),
269    }
270}
271
272fn is_html_signal(t: &Ty) -> bool {
273    matches!(t, Ty::Con(n, args)
274        if n.as_ref() == Ty::SIGNAL && args.len() == 1 && args[0].con_name() == Some(Ty::HTML))
275}
276
277// ---------------------------------------------------------------------------------------------
278// The solver
279// ---------------------------------------------------------------------------------------------
280
281/// Beyond this many free nodes, enumerating every combination stops being instant. Ten is 59 049
282/// assignments — microseconds — and no program in the corpus comes close.
283const EXHAUSTIVE_LIMIT: usize = 10;
284
285/// Solve the placement of every definition and signal in a checked program.
286pub fn solve(program: &Program, lock: Option<&Lock>) -> Solution {
287    let mut nodes: Vec<Node> = Vec::new();
288    let mut index: BTreeMap<Arc<str>, usize> = BTreeMap::new();
289
290    for name in &program.def_order {
291        let Some(d) = program.defs.get(name) else {
292            continue;
293        };
294        let pinned = if d.tier_is_annotated {
295            Some((
296                d.tier,
297                format!("`@on({})`, which always wins", d.tier.name()),
298            ))
299        } else if d.row.atoms.iter().all(|e| Tier::Any.discharges(e)) {
300            // §3.3: "Purity means *unplaced*: legal on every tier, compiled to each tier that
301            // needs it."
302            //
303            // The test is `Tier::Any.discharges` — every concrete tier can do this — and **not**
304            // "the visible row is empty". They differ on `partial`, which every tier discharges and
305            // which is not ambient, so a function that may diverge was being *placed* despite being
306            // legal everywhere. Found by the generated-program suite
307            // (`tests/placement_properties.rs`), which is exactly the case nobody would have
308            // written by hand.
309            Some((
310                Tier::Any,
311                if d.row.atoms.is_empty() {
312                    "pure, so it is compiled into every tier that calls it".to_string()
313                } else {
314                    format!(
315                        "{{{}}} is discharged on every tier, so it is compiled into each one that \
316                         calls it",
317                        d.row
318                            .visible()
319                            .iter()
320                            .map(|e| e.name())
321                            .collect::<Vec<_>>()
322                            .join(", ")
323                    )
324                },
325            ))
326        } else {
327            None
328        };
329        index.insert(name.clone(), nodes.len());
330        nodes.push(Node {
331            key: Key::Def(name.clone()),
332            candidates: Tier::candidates(&d.row),
333            row: d.row.clone(),
334            work: size(&d.body),
335            carried: d.ret.clone(),
336            pinned,
337        });
338    }
339
340    for s in &program.signals {
341        let pinned = if s.tier_is_annotated {
342            Some((
343                s.tier,
344                format!("`@on({})`, which always wins", s.tier.name()),
345            ))
346        } else if is_html_signal(&s.ty) {
347            // §4.3: a signal edge that crosses tiers *is* the subscription, and the subscriber of a
348            // rendered document is the browser. That is what the type means, not what it costs.
349            Some((
350                Tier::Client,
351                "a `Signal[Html]` is what the browser subscribes to".to_string(),
352            ))
353        } else {
354            None
355        };
356        index.insert(s.name.clone(), nodes.len());
357        nodes.push(Node {
358            key: Key::Signal(s.name.clone()),
359            candidates: Tier::candidates(&s.row),
360            row: s.row.clone(),
361            work: size(&s.expr),
362            carried: carried(&s.ty),
363            pinned,
364        });
365    }
366
367    // Edges: one unordered pair per dependency, so a crossing is charged once however many times it
368    // is named. The pair is all that is recorded — what a crossing *carries* is decided from both
369    // ends in `cost::edge_cost`, not from whichever end happened to be walked first.
370    let mut edges: Vec<(usize, usize)> = Vec::new();
371    {
372        let mut seen: BTreeSet<(usize, usize)> = BTreeSet::new();
373        let mut push = |from: usize, to: usize, edges: &mut Vec<(usize, usize)>| {
374            if from == to {
375                return;
376            }
377            let pair = if from < to { (from, to) } else { (to, from) };
378            if seen.insert(pair) {
379                edges.push(pair);
380            }
381        };
382        for name in &program.def_order {
383            let (Some(d), Some(&i)) = (program.defs.get(name), index.get(name)) else {
384                continue;
385            };
386            let mut names = BTreeSet::new();
387            mentions(&d.body, &mut names);
388            for m in names {
389                if let Some(&j) = index.get(&m) {
390                    push(i, j, &mut edges);
391                }
392            }
393        }
394        for s in &program.signals {
395            let Some(&i) = index.get(&s.name) else {
396                continue;
397            };
398            let mut names = BTreeSet::new();
399            mentions(&s.expr, &mut names);
400            for m in names {
401                if let Some(&j) = index.get(&m) {
402                    push(i, j, &mut edges);
403                }
404            }
405        }
406    }
407
408    // Candidate order: the lock first (stability), then the fixed preference (determinism).
409    for n in &mut nodes {
410        let locked = lock.and_then(|l| l.tiers.get(&n.key.to_string()).copied());
411        n.candidates
412            .sort_by_key(|t| (usize::from(Some(*t) != locked), rank(*t), t.name()));
413    }
414
415    let free: Vec<usize> = (0..nodes.len())
416        .filter(|i| nodes[*i].pinned.is_none() && nodes[*i].candidates.len() > 1)
417        .collect();
418
419    let base: Vec<Tier> = nodes
420        .iter()
421        .map(|n| match &n.pinned {
422            Some((t, _)) => *t,
423            None => n.candidates.first().copied().unwrap_or(Tier::Server),
424        })
425        .collect();
426
427    let total_of = |assign: &[Tier]| -> Cost {
428        let mut sum: Cost = 0;
429        for (i, n) in nodes.iter().enumerate() {
430            sum = sum.saturating_add(cost::node_cost(
431                assign[i],
432                &n.row,
433                n.work,
434                Some(&n.carried),
435                &program.types,
436            ));
437        }
438        for (a, b) in &edges {
439            sum = sum.saturating_add(cost::edge_cost(
440                assign[*a],
441                assign[*b],
442                &nodes[*a].carried,
443                &nodes[*b].carried,
444                &program.types,
445            ));
446        }
447        sum
448    };
449
450    let (assign, method) = if free.len() <= EXHAUSTIVE_LIMIT {
451        (
452            exhaustive(&nodes, &free, &base, &total_of),
453            Method::Exhaustive,
454        )
455    } else {
456        (sweep(&nodes, &free, &base, &total_of), Method::Sweep)
457    };
458
459    // Explanations: the cost of the whole program with this node moved and everything else left
460    // where the solver put it. That is the number a person is actually asking about.
461    //
462    // Computed as a **delta** rather than by re-summing the program. Moving one node changes its
463    // own cost and the cost of the edges touching it; every other term is the term it already was.
464    // Re-summing made this loop `O(n × (n + e))` — three full sweeps per definition — which is the
465    // whole of the front end's superlinearity, since parse, expand, check and the security pass
466    // are each flat per declaration (`docs/64` §64.2). Incidence lists make it `O(n + e)`.
467    let mut incident: Vec<Vec<usize>> = vec![Vec::new(); nodes.len()];
468    for (e, (a, b)) in edges.iter().enumerate() {
469        incident[*a].push(e);
470        incident[*b].push(e);
471    }
472    let settled = total_of(&assign);
473
474    let mut explanations = Vec::new();
475    let mut ties = Vec::new();
476    for (i, n) in nodes.iter().enumerate() {
477        let mut candidates: Vec<(Tier, Cost)> = Vec::new();
478        for t in [Tier::Client, Tier::Server, Tier::Data] {
479            if !n.row.atoms.iter().all(|e| t.discharges(e)) {
480                candidates.push((t, FORBIDDEN));
481                continue;
482            }
483            if t == assign[i] {
484                candidates.push((t, settled));
485                continue;
486            }
487            let mut delta = cost::node_cost(t, &n.row, n.work, Some(&n.carried), &program.types)
488                .saturating_sub(cost::node_cost(
489                    assign[i],
490                    &n.row,
491                    n.work,
492                    Some(&n.carried),
493                    &program.types,
494                ));
495            for &e in &incident[i] {
496                let (a, b) = edges[e];
497                let (was_a, was_b) = (assign[a], assign[b]);
498                // `push` drops self-edges, so exactly one end is `i`.
499                let (now_a, now_b) = if a == i { (t, was_b) } else { (was_a, t) };
500                let carried = (&nodes[a].carried, &nodes[b].carried);
501                delta = delta
502                    .saturating_add(cost::edge_cost(
503                        now_a,
504                        now_b,
505                        carried.0,
506                        carried.1,
507                        &program.types,
508                    ))
509                    .saturating_sub(cost::edge_cost(
510                        was_a,
511                        was_b,
512                        carried.0,
513                        carried.1,
514                        &program.types,
515                    ));
516            }
517            candidates.push((t, settled.saturating_add(delta)));
518        }
519        let because = match &n.pinned {
520            Some((_, why)) => why.clone(),
521            None => reason(n, assign[i]),
522        };
523        if n.pinned.is_none() && n.candidates.len() > 1 {
524            let best = candidates
525                .iter()
526                .map(|(_, c)| *c)
527                .filter(|c| *c < FORBIDDEN)
528                .min()
529                .unwrap_or(0);
530            let tied: Vec<Tier> = candidates
531                .iter()
532                .filter(|(_, c)| *c == best)
533                .map(|(t, _)| *t)
534                .collect();
535            if tied.len() > 1 {
536                ties.push((n.key.clone(), tied));
537            }
538        }
539        explanations.push(Explanation {
540            key: n.key.clone(),
541            chosen: assign[i],
542            row: n.row.clone(),
543            pinned: n.pinned.is_some(),
544            candidates,
545            because,
546        });
547    }
548
549    let tiers: BTreeMap<Key, Tier> = nodes
550        .iter()
551        .enumerate()
552        .map(|(i, n)| (n.key.clone(), assign[i]))
553        .collect();
554
555    let churn = lock
556        .map(|l| {
557            tiers
558                .iter()
559                .filter_map(|(k, t)| {
560                    let was = l.tiers.get(&k.to_string())?;
561                    (was != t).then(|| (k.clone(), *was, *t))
562                })
563                .collect()
564        })
565        .unwrap_or_default();
566
567    Solution {
568        total: settled,
569        tiers,
570        explanations,
571        method,
572        churn,
573        ties,
574    }
575}
576
577/// Every combination, in candidate order, keeping the first strict improvement.
578///
579/// "First strict improvement" is where the tie-break lives: because candidates are sorted lock-first
580/// and then by [`PREFERENCE`], two assignments that cost the same always resolve the same way, on
581/// any machine, in any order of iteration.
582fn exhaustive(
583    nodes: &[Node],
584    free: &[usize],
585    base: &[Tier],
586    total: &dyn Fn(&[Tier]) -> Cost,
587) -> Vec<Tier> {
588    let mut best = base.to_vec();
589    let mut best_cost = total(&best);
590    let radices: Vec<usize> = free.iter().map(|i| nodes[*i].candidates.len()).collect();
591    let combinations: usize = radices.iter().product();
592    let mut assign = base.to_vec();
593    for n in 0..combinations {
594        let mut rest = n;
595        for (slot, i) in free.iter().enumerate() {
596            let r = radices[slot];
597            assign[*i] = nodes[*i].candidates[rest % r];
598            rest /= r;
599        }
600        let c = total(&assign);
601        if c < best_cost {
602            best_cost = c;
603            best.clone_from(&assign);
604        }
605    }
606    best
607}
608
609/// A deterministic sweep to a local minimum, for programs too large to enumerate.
610///
611/// Iterated conditional modes: visit the free nodes in a fixed order, move each to its cheapest
612/// tier given the others, and repeat until nothing moves. It is *not* optimal, and
613/// [`Method::Sweep`] is reported so that a local minimum is never read as a global one.
614fn sweep(
615    nodes: &[Node],
616    free: &[usize],
617    base: &[Tier],
618    total: &dyn Fn(&[Tier]) -> Cost,
619) -> Vec<Tier> {
620    let mut assign = base.to_vec();
621    for _ in 0..64 {
622        let mut moved = false;
623        for i in free {
624            let current = assign[*i];
625            let mut best = current;
626            let mut best_cost = total(&assign);
627            for t in &nodes[*i].candidates {
628                assign[*i] = *t;
629                let c = total(&assign);
630                if c < best_cost {
631                    best_cost = c;
632                    best = *t;
633                }
634            }
635            assign[*i] = best;
636            moved |= best != current;
637        }
638        if !moved {
639            break;
640        }
641    }
642    assign
643}
644
645fn reason(n: &Node, chosen: Tier) -> String {
646    let visible = n.row.visible();
647    if let Some(forcing) = visible.iter().find(|e| {
648        crate::ty::CONCRETE_TIERS
649            .iter()
650            .filter(|t| t.discharges(e))
651            .count()
652            == 1
653    }) {
654        return format!(
655            "`{}` is discharged only by `{}`",
656            forcing.name(),
657            chosen.name()
658        );
659    }
660    if n.row.atoms.contains(&Effect::Durable) && chosen == Tier::Data {
661        return "the log is at the data tier, and the accumulator is what the log stores".into();
662    }
663    if visible.is_empty() {
664        return format!(
665            "no effect forces a tier; `{}` costs least given its neighbours",
666            chosen.name()
667        );
668    }
669    format!(
670        "{{{}}} can be discharged by {}, and `{}` costs least",
671        visible
672            .iter()
673            .map(|e| e.name())
674            .collect::<Vec<_>>()
675            .join(", "),
676        Tier::candidates(&n.row)
677            .iter()
678            .map(|t| t.name())
679            .collect::<Vec<_>>()
680            .join(" or "),
681        chosen.name()
682    )
683}
684
685/// Write the solved tiers back into the program.
686pub fn apply(program: &mut Program, solution: &Solution) {
687    for (name, def) in program.defs.iter_mut() {
688        if let Some(t) = solution.tiers.get(&Key::Def(name.clone())) {
689            def.tier = *t;
690            def.body.place(*t);
691        }
692    }
693    for s in program.signals.iter_mut() {
694        if let Some(t) = solution.tiers.get(&Key::Signal(s.name.clone())) {
695            s.tier = *t;
696        }
697    }
698}
699
700// ---------------------------------------------------------------------------------------------
701// Verification — Phase 1's half, still load-bearing
702// ---------------------------------------------------------------------------------------------
703
704/// Verify every placement in a checked program, annotated or solved.
705pub fn check_placement(program: &Program, diags: &mut Diagnostics) {
706    for name in &program.def_order {
707        let Some(def) = program.defs.get(name) else {
708            continue;
709        };
710        verify(
711            def.tier,
712            &def.row,
713            if def.tier_is_annotated {
714                Why::Annotated
715            } else {
716                Why::Solved
717            },
718            &format!("`{}`", def.name),
719            def.span,
720            def.tier_span,
721            diags,
722        );
723    }
724    for s in &program.signals {
725        verify(
726            s.tier,
727            &s.row,
728            if s.tier_is_annotated {
729                Why::Annotated
730            } else if is_html_signal(&s.ty) {
731                Why::Structural(HTML_IS_THE_BROWSERS)
732            } else {
733                Why::Solved
734            },
735            &format!("`{}`", s.name),
736            s.span,
737            s.tier_span,
738            diags,
739        );
740    }
741
742    // §3.7's determinism rule, over the *named* function a fold is given. `check` catches `uuid()`
743    // written lexically inside `fold(...)`; this catches it reached through a function — and, with
744    // inference, through any depth of them, which is what Phase 1 could not do.
745    for s in &program.signals {
746        for f in fold_functions(&s.expr) {
747            let crate::core::CoreKind::Global(name) = &f.kind else {
748                continue;
749            };
750            let Some(def) = program.defs.get(name) else {
751                continue;
752            };
753            let breaking: Vec<&Effect> = def.effects.iter().filter(|e| e.breaks_replay()).collect();
754            if breaking.is_empty() {
755                continue;
756            }
757            diags.push(
758                Diagnostic::error(
759                    "B0402",
760                    format!("`{name}` is a fold function, so it must be replay-pure"),
761                    f.span,
762                )
763                .with_primary_label(format!(
764                    "performs {{{}}}",
765                    breaking
766                        .iter()
767                        .map(|e| e.name())
768                        .collect::<Vec<_>>()
769                        .join(", ")
770                ))
771                .with_label(def.span, "defined here")
772                .with_note(
773                    "replaying the log must reproduce the state bit for bit; time is data on the \
774                     envelope (`env.at`) and identity is minted at the edge",
775                ),
776            );
777        }
778    }
779
780    // §3.7: "one totally-ordered log per application … there is exactly one of these."
781    let ingress: Vec<&crate::check::SignalDecl> = program
782        .signals
783        .iter()
784        .filter(|s| s.effects.contains(&Effect::Ingress))
785        .collect();
786    if ingress.len() > 1 {
787        let mut d = Diagnostic::error(
788            "B0403",
789            "a program has exactly one merge point",
790            ingress[1].span,
791        )
792        .with_primary_label("a second `merge_clients()`")
793        .with_note(
794            "the merge point is where time and nondeterminism enter; two of them would mean two \
795             total orders, and replay would no longer be a function of the log",
796        );
797        d = d.with_label(ingress[0].span, "the first one is here");
798        diags.push(d);
799    }
800}
801
802/// Why a node sits on the tier a diagnostic found it on — which decides what the diagnostic is
803/// allowed to claim.
804///
805/// The distinction matters because [`verify`] used to say "this placement was solved rather than
806/// written, so a diagnostic here is a compiler defect" whenever no `@on` was present. That note is
807/// true of a *chosen* placement and false of a **forced** one: a `Signal[Html]` is the browser's
808/// because of its type, so an undischargeable effect under one is a statement about the program.
809/// The first program to hit it was `reveal(...)` inside a view, which is a user error being
810/// reported as a compiler bug.
811#[derive(Clone, Copy)]
812enum Why {
813    /// The program wrote `@on(...)`.
814    Annotated,
815    /// The type decided, and no annotation could have decided otherwise. Carries the sentence that
816    /// says so, and the advice that follows from it.
817    Structural(&'static str),
818    /// The cost model chose, so any refusal here really is the compiler's fault.
819    Solved,
820}
821
822/// The one structural pin there is in Phase 2.
823const HTML_IS_THE_BROWSERS: &str =
824    "a `Signal[Html]` is the browser's because of its type, so this \
825     is not a placement an annotation can move: the effect has to be discharged before the value \
826     reaches the view — in `validate`, which is where the session is";
827
828fn verify(
829    tier: Tier,
830    row: &Row,
831    why: Why,
832    what: &str,
833    span: beck_diag::Span,
834    tier_span: beck_diag::Span,
835    diags: &mut Diagnostics,
836) {
837    let candidates = Tier::candidates(row);
838    if candidates.is_empty() {
839        // No tier can discharge this row. Neither the solver nor an annotation can help; the
840        // definition has to be split into parts that can each be placed.
841        let names: Vec<String> = row.visible().iter().map(|e| e.name()).collect();
842        diags.push(
843            Diagnostic::error(
844                "B0400",
845                format!("{what} performs effects no single tier can discharge"),
846                span,
847            )
848            .with_primary_label(format!("{{{}}}", names.join(", ")))
849            .with_note(
850                "each tier discharges a fixed set (docs/03 §3.3); a row no tier covers has to be \
851                 split across definitions that can each be placed",
852            ),
853        );
854        return;
855    }
856
857    if tier == Tier::Any {
858        // After the solver has run, this can only be reached by an explicit `@on(any)` on
859        // something effectful — the solver never leaves an effectful node unplaced.
860        //
861        // "Effectful" has to mean *undischargeable here*, not merely non-empty. `partial` and
862        // `raises(E)` are performed by definitions that are legal on every tier, and asking the
863        // author to pin one of those to a tier would be asking them to make a placement decision
864        // the language does not need them to make.
865        if let Some(e) = row.visible().into_iter().find(|e| !tier.discharges(e)) {
866            diags.push(
867                Diagnostic::error("B0404", format!("{what} cannot be unplaced"), tier_span)
868                    .with_primary_label(format!(
869                        "`@on(any)` means every tier, and `{}` is not discharged on every tier",
870                        e.name()
871                    ))
872                    .with_label(span, "the definition it is placed on")
873                    .with_fix(format!(
874                        "`@on({})`",
875                        candidates
876                            .iter()
877                            .map(|t| t.name())
878                            .collect::<Vec<_>>()
879                            .join(")` or `@on(")
880                    )),
881            );
882        }
883        return;
884    }
885
886    for e in row.atoms.iter().filter(|e| !tier.discharges(e)) {
887        let alternatives: Vec<&str> = candidates
888            .iter()
889            .filter(|t| **t != tier)
890            .map(|t| t.name())
891            .collect();
892        let mut d = Diagnostic::error(
893            "B0401",
894            format!(
895                "{what} is placed on `{}`, which cannot discharge `{}`",
896                tier.name(),
897                e.name()
898            ),
899            tier_span,
900        )
901        .with_primary_label(format!("`{}` cannot do this", tier.name()))
902        .with_label(span, "the definition it is placed on")
903        .with_note(match e {
904            Effect::Ingress => {
905                "`ingress` is the merge point: it admits time and nondeterminism, and only the \
906                 server holds it"
907            }
908            Effect::Durable => {
909                "`durable` is the log: placing it on the client would ship the database to the \
910                 browser"
911            }
912            Effect::Dom => "`dom` touches the document, which only the client has",
913            Effect::Nondet => {
914                "minting ids or reading a clock is not replayable, so the fold engine refuses it"
915            }
916            other => match other.family() {
917                "net.out" => {
918                    "a browser can only reach its own origin; any other host is the server's to call"
919                }
920                "net.in" => "only the server accepts connections",
921                "cap" => "a capability is held where sessions are minted, which is the server",
922                "env" | "fs.read" | "fs.write" => {
923                    "there is no process environment or filesystem in a browser"
924                }
925                "external.read" | "external.write" => {
926                    "an external store is reached from the server, never from a browser"
927                }
928                _ => "this tier cannot discharge that effect",
929            },
930        });
931        match why {
932            Why::Solved => {
933                d = d.with_note(
934                    "this placement was solved rather than written, so a diagnostic here is a \
935                     compiler defect and worth reporting",
936                );
937            }
938            Why::Structural(note) => d = d.with_note(note),
939            Why::Annotated => {}
940        }
941        // Only worth suggesting where an annotation could actually have made the difference.
942        if let ([only], Why::Annotated | Why::Solved) = (alternatives.as_slice(), why) {
943            d = d.with_fix(format!("`@on({only})` discharges everything this needs"));
944        }
945        diags.push(d);
946    }
947}
948
949/// Every function argument of a `fold` anywhere in an expression.
950fn fold_functions(c: &crate::core::Core) -> Vec<&crate::core::Core> {
951    use crate::core::{CoreKind, Prim};
952    let mut out = Vec::new();
953    if let CoreKind::Prim { op, args } = &c.kind {
954        if *op == Prim::Fold {
955            if let Some(f) = args.first() {
956                out.push(f);
957            }
958        }
959        for a in args {
960            out.extend(fold_functions(a));
961        }
962    }
963    out
964}
965
966#[cfg(test)]
967mod tests {
968    use super::*;
969    use crate::{check_str, compile_str};
970
971    fn errors(src: &str) -> Vec<&'static str> {
972        let (_, d, _) = compile_str("t.beck", src);
973        d.iter().map(|x| x.code).collect()
974    }
975
976    /// The solved tier of every definition and signal.
977    fn solved(src: &str) -> BTreeMap<String, Tier> {
978        let (program, d, map) = check_str("t.beck", src);
979        assert!(!d.has_errors(), "{}", d.render(&map));
980        solve(&program, None)
981            .tiers
982            .into_iter()
983            .map(|(k, t)| (k.to_string(), t))
984            .collect()
985    }
986
987    /// The sketch, with every placement annotation removed.
988    fn bare_sketch() -> String {
989        let bare = crate::split::tests::TODO
990            .replace("@on(server)\n", "")
991            .replace("@on(data)\n", "")
992            .replace("@on(client)\n", "");
993        assert!(
994            !bare.contains("@on"),
995            "the annotations must actually be gone"
996        );
997        bare
998    }
999
1000    const DOMAIN: &str = "\
1001union Event:
1002    Added(id: Str, text: Str)
1003
1004model State:
1005    count: Int
1006
1007def apply_event(s: State, env: Envelope[Event]) -> State:
1008    return s.with(count=(s.count + 1))
1009";
1010
1011    #[test]
1012    fn a_durable_fold_on_the_client_is_rejected_by_name() {
1013        let src = format!(
1014            "{DOMAIN}
1015@on(client)
1016todos: Signal[State] = durable(fold(apply_event, State(count=0), events))
1017
1018@on(server)
1019events: Stream[Event] = merge_clients()
1020"
1021        );
1022        assert!(errors(&src).contains(&"B0401"), "{:?}", errors(&src));
1023    }
1024
1025    #[test]
1026    fn ingress_on_the_server_is_accepted() {
1027        let src = format!(
1028            "{DOMAIN}
1029@on(server)
1030proposals: Stream[Proposal] = merge_clients()
1031"
1032        );
1033        let codes = errors(&src);
1034        assert!(
1035            !codes.iter().any(|c| c.starts_with("B04")),
1036            "unexpected placement errors: {codes:?}"
1037        );
1038    }
1039
1040    #[test]
1041    fn an_effectful_declaration_with_no_placement_is_solved_rather_than_refused() {
1042        // Phase 1 refused this and suggested an annotation. Phase 2 works it out: `ingress` is
1043        // discharged by exactly one tier, so there was never anything to ask about.
1044        let src = format!(
1045            "{DOMAIN}
1046proposals: Stream[Proposal] = merge_clients()
1047"
1048        );
1049        assert_eq!(solved(&src).get("signal/proposals"), Some(&Tier::Server));
1050    }
1051
1052    #[test]
1053    fn two_merge_points_are_rejected() {
1054        let src = format!(
1055            "{DOMAIN}
1056@on(server)
1057a: Stream[Proposal] = merge_clients()
1058
1059@on(server)
1060b: Stream[Proposal] = merge_clients()
1061"
1062        );
1063        assert!(errors(&src).contains(&"B0403"), "{:?}", errors(&src));
1064    }
1065
1066    #[test]
1067    fn a_fold_that_reaches_nondeterminism_through_a_function_is_rejected() {
1068        let src = crate::split::tests::TODO.replace(
1069            "return s.with(todos=map_remove(s.todos, id))",
1070            "return s.with(todos=map_remove(s.todos, uuid()))",
1071        );
1072        let (_, d, _) = compile_str("t.beck", &src);
1073        let codes: Vec<&str> = d.iter().map(|x| x.code).collect();
1074        assert!(codes.contains(&"B0402"), "got {codes:?}");
1075    }
1076
1077    #[test]
1078    fn a_fold_that_reaches_nondeterminism_two_calls_deep_is_rejected() {
1079        // Phase 1 could not see this. Its collection consulted each callee's *declared* effects, so
1080        // `stamp` — which declares nothing — hid the clock behind it and the fold looked pure.
1081        // Inference is what makes §3.7's rule bite at a distance.
1082        let src = crate::split::tests::TODO
1083            .replace(
1084                "def toggle(s: State, id: Id) -> State:",
1085                "def stamp(id: Id) -> Id:\n    return Id(value=uuid())\n\n\
1086                 def toggle(s: State, id: Id) -> State:",
1087            )
1088            .replace(
1089                "return s.with(todos=map_remove(s.todos, id))",
1090                "return s.with(todos=map_remove(s.todos, stamp(id)))",
1091            );
1092        let (_, d, _) = compile_str("t.beck", &src);
1093        let codes: Vec<&str> = d.iter().map(|x| x.code).collect();
1094        assert!(codes.contains(&"B0402"), "got {codes:?}");
1095    }
1096
1097    #[test]
1098    fn the_sketch_places_itself_with_no_annotations_at_all() {
1099        // The exit criterion on the canonical program: strip every `@on(...)`, and the solver has
1100        // to land on a placement that runs — the log at the data tier, ingress at the server, the
1101        // page at the browser, and every pure definition unplaced so it compiles to both sides.
1102        let t = solved(&bare_sketch());
1103        assert_eq!(t.get("signal/proposals"), Some(&Tier::Server), "{t:?}");
1104        assert_eq!(t.get("signal/todos"), Some(&Tier::Data), "{t:?}");
1105        assert_eq!(t.get("signal/page"), Some(&Tier::Client), "{t:?}");
1106        assert_eq!(t.get("def/view"), Some(&Tier::Any), "{t:?}");
1107        assert_eq!(t.get("def/apply_event"), Some(&Tier::Any), "{t:?}");
1108        assert_eq!(t.get("def/validate"), Some(&Tier::Any), "{t:?}");
1109    }
1110
1111    #[test]
1112    fn a_named_host_is_the_servers_to_reach_and_the_own_origin_is_a_real_choice() {
1113        // `net.out(origin)` is the sharp case for the cost model: a browser and a server can both
1114        // make that call, so nothing but the callers decides.
1115        let src = "\
1116def ping() -> Str uses net.out(origin):
1117    return \"pong\"
1118
1119def dial() -> Str uses net.out(payments.example.com):
1120    return \"charged\"
1121";
1122        let t = solved(src);
1123        assert_eq!(t.get("def/dial"), Some(&Tier::Server));
1124        assert!(
1125            matches!(t.get("def/ping"), Some(Tier::Client) | Some(Tier::Server)),
1126            "own-origin is dischargeable on both: {t:?}"
1127        );
1128    }
1129
1130    #[test]
1131    fn the_solution_is_deterministic() {
1132        let bare = bare_sketch();
1133        let first = solved(&bare);
1134        for _ in 0..8 {
1135            assert_eq!(solved(&bare), first, "§3.4: same input, same solution");
1136        }
1137    }
1138
1139    #[test]
1140    fn a_lock_round_trips_and_disagreement_is_reported_as_churn() {
1141        let (program, _, _) = check_str("t.beck", &bare_sketch());
1142        let solution = solve(&program, None);
1143        let lock = Lock::of(&solution);
1144        assert_eq!(
1145            Lock::from_json(&lock.to_json())
1146                .expect("the lock parses")
1147                .tiers,
1148            lock.tiers
1149        );
1150
1151        // A lock is a tie-break and a record, never an override: one that disagrees with a strictly
1152        // cheaper placement loses, and the disagreement is reported instead of being absorbed.
1153        let mut stale = lock.clone();
1154        stale.tiers.insert("signal/todos".into(), Tier::Server);
1155        let again = solve(&program, Some(&stale));
1156        assert_eq!(again.tiers[&Key::Signal("todos".into())], Tier::Data);
1157        assert!(
1158            again
1159                .churn
1160                .iter()
1161                .any(|(k, was, now)| k.to_string() == "signal/todos"
1162                    && *was == Tier::Server
1163                    && *now == Tier::Data),
1164            "churn must name what moved: {:?}",
1165            again.churn
1166        );
1167    }
1168
1169    #[test]
1170    fn the_lock_settles_a_tie_that_the_cost_model_cannot() {
1171        // Two placements that cost exactly the same are exactly where a one-line edit could
1172        // otherwise re-place unrelated code. The lock is what stops that, and this is the case it
1173        // exists for.
1174        let src = "\
1175def ping() -> Str uses net.out(origin):
1176    return \"pong\"
1177";
1178        let (program, _, _) = check_str("t.beck", src);
1179        let free = solve(&program, None);
1180        let other = if free.tiers[&Key::Def("ping".into())] == Tier::Server {
1181            Tier::Client
1182        } else {
1183            Tier::Server
1184        };
1185        let mut lock = Lock::of(&free);
1186        lock.tiers.insert("def/ping".into(), other);
1187        let relocked = solve(&program, Some(&lock));
1188        if free.ties.iter().any(|(k, _)| k.to_string() == "def/ping") {
1189            assert_eq!(
1190                relocked.tiers[&Key::Def("ping".into())],
1191                other,
1192                "on a tie the lock decides, so yesterday's answer survives today's edit"
1193            );
1194        }
1195    }
1196
1197    #[test]
1198    fn a_solved_placement_is_explained_rather_than_asserted() {
1199        let (program, _, _) = check_str("t.beck", &bare_sketch());
1200        let solution = solve(&program, None);
1201        let todos = solution.explanation("todos").expect("todos is explained");
1202        assert_eq!(todos.chosen, Tier::Data);
1203        assert!(todos.because.contains("log"), "{}", todos.because);
1204        // §4.7 prints candidates with costs, including the impossible ones.
1205        let client = todos
1206            .candidates
1207            .iter()
1208            .find(|(t, _)| *t == Tier::Client)
1209            .expect("client is a candidate to reject");
1210        assert_eq!(client.1, FORBIDDEN);
1211        let server = todos
1212            .candidates
1213            .iter()
1214            .find(|(t, _)| *t == Tier::Server)
1215            .unwrap();
1216        let data = todos
1217            .candidates
1218            .iter()
1219            .find(|(t, _)| *t == Tier::Data)
1220            .unwrap();
1221        assert!(
1222            data.1 < server.1,
1223            "data must be cheaper: {data:?} {server:?}"
1224        );
1225        assert_eq!(solution.method, Method::Exhaustive);
1226    }
1227
1228    #[test]
1229    fn an_annotation_always_wins_even_when_it_costs_more() {
1230        // §3.3: "with explicit `@on(...)` always available and always winning." The solver may
1231        // disagree; it may not overrule.
1232        let src = crate::split::tests::TODO
1233            .replace("@on(data)\ntodos", "@on(server)\ntodos")
1234            .replace("@on(server)\nevents", "@on(data)\nevents");
1235        let t = solved(&src);
1236        assert_eq!(t.get("signal/todos"), Some(&Tier::Server));
1237        assert_eq!(t.get("signal/events"), Some(&Tier::Data));
1238    }
1239}