1use 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#[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#[derive(Clone, Debug)]
77pub struct Explanation {
78 pub key: Key,
79 pub chosen: Tier,
80 pub row: Row,
81 pub pinned: bool,
85 pub candidates: Vec<(Tier, Cost)>,
88 pub because: String,
89}
90
91#[derive(Clone, Copy, Debug, PartialEq, Eq)]
92pub enum Method {
93 Exhaustive,
95 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#[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 pub churn: Vec<(Key, Tier, Tier)>,
118 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#[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
184struct Node {
189 key: Key,
190 row: Row,
191 work: i64,
192 carried: Ty,
194 pinned: Option<(Tier, String)>,
195 candidates: Vec<Tier>,
196}
197
198const 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
246pub 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
260fn 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
277const EXHAUSTIVE_LIMIT: usize = 10;
284
285pub 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 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 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 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 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 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 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
577fn 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
609fn 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
685pub 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
700pub 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 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 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#[derive(Clone, Copy)]
812enum Why {
813 Annotated,
815 Structural(&'static str),
818 Solved,
820}
821
822const 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 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 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 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
949pub fn report(solution: &Solution, only: Option<&str>) -> Result<String, String> {
958 use std::fmt::Write;
959 let mut out = String::new();
960
961 let Some(name) = only else {
962 let _ = writeln!(out, "{:<20} {:<8} {:<10} effects", "name", "tier", "kind");
963 for e in &solution.explanations {
964 let kind = match &e.key {
965 Key::Def(_) => "definition",
966 Key::Signal(_) => "signal",
967 };
968 let _ = writeln!(
969 out,
970 "{:<20} {:<8} {:<10} {}",
971 e.key.name(),
972 e.chosen.name(),
973 kind,
974 e.row
975 );
976 }
977 let _ = writeln!(
978 out,
979 "\nunplaced (`any`) means pure, so it compiles to every tier that needs it — that\n\
980 duplication is the payoff, not waste. Solved {}; total cost {:.1}.\n\
981 `beck explain place <file> <name>` shows one decision's candidates and their costs.",
982 solution.method.name(),
983 solution.total as f64 / 100.0
984 );
985 return Ok(out);
986 };
987
988 let Some(e) = solution.explanation(name) else {
989 return Err(format!("no `{name}` in this program"));
990 };
991 let _ = writeln!(out, "{} → {} tier\n", e.key.name(), e.chosen.name());
992 let _ = writeln!(
993 out,
994 " effects : {}",
995 if e.row.visible().is_empty() {
996 "{} (pure; placeable anywhere)".to_string()
997 } else {
998 format!("{}", e.row)
999 }
1000 );
1001 let costs: Vec<String> = e
1002 .candidates
1003 .iter()
1004 .map(|(t, c)| {
1005 if *c >= FORBIDDEN {
1006 format!("{} (cannot discharge this row)", t.name())
1007 } else {
1008 format!("{} (cost {:.1})", t.name(), *c as f64 / 100.0)
1009 }
1010 })
1011 .collect();
1012 let _ = writeln!(out, " candidates : {}", costs.join(", "));
1013 let _ = writeln!(out, " chosen : {}", e.chosen.name());
1014 let _ = writeln!(out, " because : {}", e.because);
1015 let _ = writeln!(
1016 out,
1017 "\ncosts are whole-program: what this program would cost with `{}` on that tier and \n\
1018 everything else where it is. Solved {}.",
1019 e.key.name(),
1020 solution.method.name()
1021 );
1022 Ok(out)
1023}
1024
1025fn fold_functions(c: &crate::core::Core) -> Vec<&crate::core::Core> {
1027 use crate::core::{CoreKind, Prim};
1028 let mut out = Vec::new();
1029 if let CoreKind::Prim { op, args } = &c.kind {
1030 if *op == Prim::Fold {
1031 if let Some(f) = args.first() {
1032 out.push(f);
1033 }
1034 }
1035 for a in args {
1036 out.extend(fold_functions(a));
1037 }
1038 }
1039 out
1040}
1041
1042#[cfg(test)]
1043mod tests {
1044 use super::*;
1045 use crate::{check_str, compile_str};
1046
1047 fn errors(src: &str) -> Vec<&'static str> {
1048 let (_, d, _) = compile_str("t.beck", src);
1049 d.iter().map(|x| x.code).collect()
1050 }
1051
1052 fn solved(src: &str) -> BTreeMap<String, Tier> {
1054 let (program, d, map) = check_str("t.beck", src);
1055 assert!(!d.has_errors(), "{}", d.render(&map));
1056 solve(&program, None)
1057 .tiers
1058 .into_iter()
1059 .map(|(k, t)| (k.to_string(), t))
1060 .collect()
1061 }
1062
1063 fn bare_sketch() -> String {
1065 let bare = crate::split::tests::TODO
1066 .replace("@on(server)\n", "")
1067 .replace("@on(data)\n", "")
1068 .replace("@on(client)\n", "");
1069 assert!(
1070 !bare.contains("@on"),
1071 "the annotations must actually be gone"
1072 );
1073 bare
1074 }
1075
1076 const DOMAIN: &str = "\
1077union Event:
1078 Added(id: Str, text: Str)
1079
1080model State:
1081 count: Int
1082
1083def apply_event(s: State, env: Envelope[Event]) -> State:
1084 return s.with(count=(s.count + 1))
1085";
1086
1087 #[test]
1088 fn a_durable_fold_on_the_client_is_rejected_by_name() {
1089 let src = format!(
1090 "{DOMAIN}
1091@on(client)
1092todos: Signal[State] = durable(fold(apply_event, State(count=0), events))
1093
1094@on(server)
1095events: Stream[Event] = merge_clients()
1096"
1097 );
1098 assert!(errors(&src).contains(&"B0401"), "{:?}", errors(&src));
1099 }
1100
1101 #[test]
1102 fn ingress_on_the_server_is_accepted() {
1103 let src = format!(
1104 "{DOMAIN}
1105@on(server)
1106proposals: Stream[Proposal] = merge_clients()
1107"
1108 );
1109 let codes = errors(&src);
1110 assert!(
1111 !codes.iter().any(|c| c.starts_with("B04")),
1112 "unexpected placement errors: {codes:?}"
1113 );
1114 }
1115
1116 #[test]
1117 fn an_effectful_declaration_with_no_placement_is_solved_rather_than_refused() {
1118 let src = format!(
1121 "{DOMAIN}
1122proposals: Stream[Proposal] = merge_clients()
1123"
1124 );
1125 assert_eq!(solved(&src).get("signal/proposals"), Some(&Tier::Server));
1126 }
1127
1128 #[test]
1129 fn two_merge_points_are_rejected() {
1130 let src = format!(
1131 "{DOMAIN}
1132@on(server)
1133a: Stream[Proposal] = merge_clients()
1134
1135@on(server)
1136b: Stream[Proposal] = merge_clients()
1137"
1138 );
1139 assert!(errors(&src).contains(&"B0403"), "{:?}", errors(&src));
1140 }
1141
1142 #[test]
1143 fn a_fold_that_reaches_nondeterminism_through_a_function_is_rejected() {
1144 let src = crate::split::tests::TODO.replace(
1145 "return s.with(todos=map_remove(s.todos, id))",
1146 "return s.with(todos=map_remove(s.todos, uuid()))",
1147 );
1148 let (_, d, _) = compile_str("t.beck", &src);
1149 let codes: Vec<&str> = d.iter().map(|x| x.code).collect();
1150 assert!(codes.contains(&"B0402"), "got {codes:?}");
1151 }
1152
1153 #[test]
1154 fn a_fold_that_reaches_nondeterminism_two_calls_deep_is_rejected() {
1155 let src = crate::split::tests::TODO
1159 .replace(
1160 "def toggle(s: State, id: Id) -> State:",
1161 "def stamp(id: Id) -> Id:\n return Id(value=uuid())\n\n\
1162 def toggle(s: State, id: Id) -> State:",
1163 )
1164 .replace(
1165 "return s.with(todos=map_remove(s.todos, id))",
1166 "return s.with(todos=map_remove(s.todos, stamp(id)))",
1167 );
1168 let (_, d, _) = compile_str("t.beck", &src);
1169 let codes: Vec<&str> = d.iter().map(|x| x.code).collect();
1170 assert!(codes.contains(&"B0402"), "got {codes:?}");
1171 }
1172
1173 #[test]
1174 fn the_sketch_places_itself_with_no_annotations_at_all() {
1175 let t = solved(&bare_sketch());
1179 assert_eq!(t.get("signal/proposals"), Some(&Tier::Server), "{t:?}");
1180 assert_eq!(t.get("signal/todos"), Some(&Tier::Data), "{t:?}");
1181 assert_eq!(t.get("signal/page"), Some(&Tier::Client), "{t:?}");
1182 assert_eq!(t.get("def/view"), Some(&Tier::Any), "{t:?}");
1183 assert_eq!(t.get("def/apply_event"), Some(&Tier::Any), "{t:?}");
1184 assert_eq!(t.get("def/validate"), Some(&Tier::Any), "{t:?}");
1185 }
1186
1187 #[test]
1188 fn a_named_host_is_the_servers_to_reach_and_the_own_origin_is_a_real_choice() {
1189 let src = "\
1192def ping() -> Str uses net.out(origin):
1193 return \"pong\"
1194
1195def dial() -> Str uses net.out(payments.example.com):
1196 return \"charged\"
1197";
1198 let t = solved(src);
1199 assert_eq!(t.get("def/dial"), Some(&Tier::Server));
1200 assert!(
1201 matches!(t.get("def/ping"), Some(Tier::Client) | Some(Tier::Server)),
1202 "own-origin is dischargeable on both: {t:?}"
1203 );
1204 }
1205
1206 #[test]
1207 fn the_solution_is_deterministic() {
1208 let bare = bare_sketch();
1209 let first = solved(&bare);
1210 for _ in 0..8 {
1211 assert_eq!(solved(&bare), first, "§3.4: same input, same solution");
1212 }
1213 }
1214
1215 #[test]
1216 fn a_lock_round_trips_and_disagreement_is_reported_as_churn() {
1217 let (program, _, _) = check_str("t.beck", &bare_sketch());
1218 let solution = solve(&program, None);
1219 let lock = Lock::of(&solution);
1220 assert_eq!(
1221 Lock::from_json(&lock.to_json())
1222 .expect("the lock parses")
1223 .tiers,
1224 lock.tiers
1225 );
1226
1227 let mut stale = lock.clone();
1230 stale.tiers.insert("signal/todos".into(), Tier::Server);
1231 let again = solve(&program, Some(&stale));
1232 assert_eq!(again.tiers[&Key::Signal("todos".into())], Tier::Data);
1233 assert!(
1234 again
1235 .churn
1236 .iter()
1237 .any(|(k, was, now)| k.to_string() == "signal/todos"
1238 && *was == Tier::Server
1239 && *now == Tier::Data),
1240 "churn must name what moved: {:?}",
1241 again.churn
1242 );
1243 }
1244
1245 #[test]
1246 fn the_lock_settles_a_tie_that_the_cost_model_cannot() {
1247 let src = "\
1251def ping() -> Str uses net.out(origin):
1252 return \"pong\"
1253";
1254 let (program, _, _) = check_str("t.beck", src);
1255 let free = solve(&program, None);
1256 let other = if free.tiers[&Key::Def("ping".into())] == Tier::Server {
1257 Tier::Client
1258 } else {
1259 Tier::Server
1260 };
1261 let mut lock = Lock::of(&free);
1262 lock.tiers.insert("def/ping".into(), other);
1263 let relocked = solve(&program, Some(&lock));
1264 if free.ties.iter().any(|(k, _)| k.to_string() == "def/ping") {
1265 assert_eq!(
1266 relocked.tiers[&Key::Def("ping".into())],
1267 other,
1268 "on a tie the lock decides, so yesterday's answer survives today's edit"
1269 );
1270 }
1271 }
1272
1273 #[test]
1274 fn a_solved_placement_is_explained_rather_than_asserted() {
1275 let (program, _, _) = check_str("t.beck", &bare_sketch());
1276 let solution = solve(&program, None);
1277 let todos = solution.explanation("todos").expect("todos is explained");
1278 assert_eq!(todos.chosen, Tier::Data);
1279 assert!(todos.because.contains("log"), "{}", todos.because);
1280 let client = todos
1282 .candidates
1283 .iter()
1284 .find(|(t, _)| *t == Tier::Client)
1285 .expect("client is a candidate to reject");
1286 assert_eq!(client.1, FORBIDDEN);
1287 let server = todos
1288 .candidates
1289 .iter()
1290 .find(|(t, _)| *t == Tier::Server)
1291 .unwrap();
1292 let data = todos
1293 .candidates
1294 .iter()
1295 .find(|(t, _)| *t == Tier::Data)
1296 .unwrap();
1297 assert!(
1298 data.1 < server.1,
1299 "data must be cheaper: {data:?} {server:?}"
1300 );
1301 assert_eq!(solution.method, Method::Exhaustive);
1302 }
1303
1304 #[test]
1305 fn an_annotation_always_wins_even_when_it_costs_more() {
1306 let src = crate::split::tests::TODO
1309 .replace("@on(data)\ntodos", "@on(server)\ntodos")
1310 .replace("@on(server)\nevents", "@on(data)\nevents");
1311 let t = solved(&src);
1312 assert_eq!(t.get("signal/todos"), Some(&Tier::Server));
1313 assert_eq!(t.get("signal/events"), Some(&Tier::Data));
1314 }
1315}