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
949fn 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 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 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 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 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 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 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 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 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 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 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}