axon_frontend/multiparty.rs
1//! Multiparty session types — the global view + projection.
2//!
3//! §Fase 41.h (D6 of the plan vivo, paper §5). Where the binary algebra
4//! [`crate::session`] describes **one endpoint's** view of a two-party
5//! dialogue, this module ascends to the **global view**: a [`GlobalType`]
6//! `G` declaratively names every message + choice in an n-party protocol
7//! (Honda–Yoshida–Carbone, POPL'08), and the [`GlobalType::project`]
8//! operator extracts each role's local [`SessionType`] from it. Together
9//! they realise the §41.a/b/c algebra at scale: one declaration, n
10//! cursor-driven runtimes, lock-step by construction.
11//!
12//! The **safe-realizability gate** ([`GlobalType::project_all`]) is the
13//! theorem in code: a global type `G` is *implementable* by independent
14//! per-role runtimes iff projection succeeds for every role mentioned —
15//! the gate refuses choices a non-participating role couldn't observe
16//! (the **merge condition**), self-messages (`p → p`), and free
17//! recursion variables. A passing gate is the structural certificate
18//! the §41.f enterprise WS surface needs to mount one binding per role
19//! and have them stay in lock-step without any cross-role coordination.
20//!
21//! ### Grammar (paper §5.1)
22//!
23//! ```text
24//! G ::= end — terminated protocol
25//! | p → q : T . G — p sends T to q, then G
26//! | p → q : { ℓᵢ : Gᵢ } — p selects ℓᵢ to send to q
27//! | μX. G — recursive protocol
28//! | X — recursion variable
29//! ```
30//!
31//! ### Projection rules (paper §5.2)
32//!
33//! For each role `r`:
34//!
35//! ```text
36//! end ⌐ r = end
37//! (p→q : T . G) ⌐ r = !T.(G ⌐ r) if r = p
38//! = ?T.(G ⌐ r) if r = q
39//! = G ⌐ r otherwise (r not involved)
40//! (p→q : {ℓᵢ:Gᵢ}) ⌐ r = ⊕{ℓᵢ : Gᵢ ⌐ r} if r = p
41//! = &{ℓᵢ : Gᵢ ⌐ r} if r = q
42//! = merge_i (Gᵢ ⌐ r) if r ∉ {p, q}
43//! μX.G ⌐ r = μX.(G ⌐ r)
44//! X ⌐ r = X
45//! ```
46//!
47//! The **merge** of `{Gᵢ ⌐ r}` is defined iff all branches project to
48//! `≡`-equivalent session types for `r` — otherwise `r` couldn't tell
49//! which arm `p` chose (`r` saw nothing); the protocol is then
50//! unrealizable + the gate rejects.
51
52use std::collections::{BTreeMap, BTreeSet};
53use std::fmt;
54
55use serde::{Deserialize, Serialize};
56
57use crate::session::{Payload, SessionType};
58
59/// A participant in a multiparty protocol — an opaque, comparable name.
60///
61/// `Role("Client")` and `Role("client")` are distinct (case-sensitive,
62/// the same discipline §41.b's `socket` declaration uses). The wire
63/// encoding is the bare string (`#[serde(transparent)]`).
64#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
65#[serde(transparent)]
66pub struct Role(pub String);
67
68impl Role {
69 pub fn new(name: impl Into<String>) -> Self {
70 Role(name.into())
71 }
72}
73
74impl fmt::Display for Role {
75 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76 f.write_str(&self.0)
77 }
78}
79
80/// A global session type — the protocol viewed from above. Each constructor
81/// names every participant explicitly so projection has no ambiguity.
82///
83/// Serialisable so a global type can travel between deployment stages
84/// (declaration in source → IR → an enterprise registry that drives the
85/// §41.f WS surface). Hashable so a deployment can fingerprint the
86/// declared protocol before issuing snapshots that bind to it.
87#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
88pub enum GlobalType {
89 /// `end` — the protocol terminates.
90 End,
91 /// `p → q : T . G` — role `from` sends a value of type `payload` to
92 /// role `to`, then the protocol continues as `cont`. `from ≠ to` is
93 /// enforced by the projection gate (a self-message has no operational
94 /// meaning at the global layer).
95 Message {
96 from: Role,
97 to: Role,
98 payload: Payload,
99 cont: Box<GlobalType>,
100 },
101 /// `p → q : { ℓᵢ : Gᵢ }` — role `from` selects label `ℓᵢ`, sends it
102 /// to `to`, the protocol continues as `Gᵢ`. The label set is canonical
103 /// (`BTreeMap`); arms must be non-empty.
104 Choice {
105 from: Role,
106 to: Role,
107 arms: BTreeMap<String, GlobalType>,
108 },
109 /// `μX. G` — recursive protocol. Bind `var` in `body`.
110 Rec(String, Box<GlobalType>),
111 /// `X` — recursion variable. Free vars on the gate's input are an
112 /// error ([`ProjectionError::UnboundVariable`]).
113 Var(String),
114}
115
116impl GlobalType {
117 // ── Smart constructors ────────────────────────────────────────────────
118
119 /// `from → to : payload . cont`.
120 pub fn message(
121 from: impl Into<String>,
122 to: impl Into<String>,
123 payload: impl Into<String>,
124 cont: GlobalType,
125 ) -> Self {
126 GlobalType::Message {
127 from: Role::new(from),
128 to: Role::new(to),
129 payload: Payload(payload.into()),
130 cont: Box::new(cont),
131 }
132 }
133 /// `from → to : { ℓ : G, … }`.
134 pub fn choice(
135 from: impl Into<String>,
136 to: impl Into<String>,
137 arms: impl IntoIterator<Item = (String, GlobalType)>,
138 ) -> Self {
139 GlobalType::Choice {
140 from: Role::new(from),
141 to: Role::new(to),
142 arms: arms.into_iter().collect(),
143 }
144 }
145 /// `μX. G`.
146 pub fn rec(var: impl Into<String>, body: GlobalType) -> Self {
147 GlobalType::Rec(var.into(), Box::new(body))
148 }
149 /// `X`.
150 pub fn var(name: impl Into<String>) -> Self {
151 GlobalType::Var(name.into())
152 }
153
154 // ── The roles a global type mentions ──────────────────────────────────
155
156 /// Every participant named anywhere in `self`. Used by
157 /// [`Self::project_all`] to drive the per-role projection loop.
158 pub fn roles(&self) -> BTreeSet<Role> {
159 let mut out = BTreeSet::new();
160 collect_roles(self, &mut out);
161 out
162 }
163
164 // ── Projection (the §5.2 operator) ────────────────────────────────────
165
166 /// Project `self` to the local session type role `r` is expected to
167 /// run. Returns the binary [`SessionType`] the §41.a algebra +
168 /// §41.d runtime consume.
169 ///
170 /// Total over closed, contractive global types; rejects only if the
171 /// gate fires:
172 /// - [`ProjectionError::SelfMessage`] — `from == to` somewhere;
173 /// - [`ProjectionError::MergeFailed`] — a non-participating role
174 /// couldn't observe the choice (arms diverge);
175 /// - [`ProjectionError::EmptyChoice`] — `Choice { arms: {} }`;
176 /// - [`ProjectionError::UnboundVariable`] — `Var(x)` without an
177 /// enclosing `Rec(x, _)` on the path.
178 pub fn project(&self, r: &Role) -> Result<SessionType, ProjectionError> {
179 project_inner(self, r)
180 }
181
182 /// Project for every role this global type mentions. A `Result::Ok`
183 /// is the **safe-realizability certificate**: every endpoint has a
184 /// well-defined local protocol, and any compliant per-role runtime
185 /// composes into a faithful realisation of `self`.
186 pub fn project_all(&self) -> Result<BTreeMap<Role, SessionType>, ProjectionError> {
187 let mut out = BTreeMap::new();
188 for role in self.roles() {
189 let local = self.project(&role)?;
190 out.insert(role, local);
191 }
192 Ok(out)
193 }
194
195 /// Is `self` safely realisable? Convenience wrapper around
196 /// [`Self::project_all`] for callers that only care about the gate
197 /// verdict (the type-checker's safety predicate).
198 pub fn is_safely_realizable(&self) -> bool {
199 self.project_all().is_ok()
200 }
201}
202
203/// Negative verdict of the projection / safe-realizability gate.
204#[derive(Debug, Clone, PartialEq, Eq)]
205pub enum ProjectionError {
206 /// `from == to` for some message or choice — protocols at the
207 /// global layer don't model self-talk (it would have no observable
208 /// effect; the §41.a algebra has no operational rule for it).
209 SelfMessage { role: Role },
210 /// `Choice { arms: {} }` — a choice with no arms has no projection
211 /// for the chooser (no internal-choice arm to select).
212 EmptyChoice { from: Role, to: Role },
213 /// The merge condition failed: a role `r` that doesn't participate
214 /// in a choice `p → q` saw arms that project to non-equivalent local
215 /// types, so it cannot observe which branch `p` chose. The protocol
216 /// is unrealizable.
217 MergeFailed {
218 /// The role whose projection diverged across arms.
219 role: Role,
220 /// The two labels whose continuations disagree (deterministic
221 /// pick: alphabetically first divergent pair).
222 labels: (String, String),
223 /// The two projections that fail to merge — useful for the
224 /// type-checker's diagnostic.
225 left: Box<SessionType>,
226 right: Box<SessionType>,
227 },
228 /// `Var(x)` reached outside any enclosing `Rec(x, _)`. The global
229 /// type is open and has no closed-form projection.
230 UnboundVariable(String),
231}
232
233impl fmt::Display for ProjectionError {
234 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
235 match self {
236 ProjectionError::SelfMessage { role } => write!(
237 f,
238 "global protocol has a self-message at role `{role}` — \
239 a global type's projection has no rule for `p → p`"
240 ),
241 ProjectionError::EmptyChoice { from, to } => write!(
242 f,
243 "global choice `{from} → {to}` has no arms — \
244 the projection for `{from}` is the empty internal-choice ⊕{{}}"
245 ),
246 ProjectionError::MergeFailed { role, labels, left, right } => write!(
247 f,
248 "global type is not safely realizable: role `{role}` cannot \
249 observe the choice — arm `{}` projects to `{left}` and arm `{}` \
250 projects to `{right}` (they must be ≡ for {role} to stay in step)",
251 labels.0, labels.1
252 ),
253 ProjectionError::UnboundVariable(var) => write!(
254 f,
255 "global type has a free recursion variable `{var}` — \
256 no enclosing `μ{var}. _` binds it"
257 ),
258 }
259 }
260}
261
262impl std::error::Error for ProjectionError {}
263
264// ── Internal helpers ──────────────────────────────────────────────────────
265
266fn collect_roles(g: &GlobalType, out: &mut BTreeSet<Role>) {
267 match g {
268 GlobalType::End | GlobalType::Var(_) => {}
269 GlobalType::Message { from, to, cont, .. } => {
270 out.insert(from.clone());
271 out.insert(to.clone());
272 collect_roles(cont, out);
273 }
274 GlobalType::Choice { from, to, arms } => {
275 out.insert(from.clone());
276 out.insert(to.clone());
277 for g in arms.values() {
278 collect_roles(g, out);
279 }
280 }
281 GlobalType::Rec(_, body) => collect_roles(body, out),
282 }
283}
284
285fn project_inner(g: &GlobalType, r: &Role) -> Result<SessionType, ProjectionError> {
286 match g {
287 GlobalType::End => Ok(SessionType::End),
288 GlobalType::Message { from, to, payload, cont } => {
289 if from == to {
290 return Err(ProjectionError::SelfMessage { role: from.clone() });
291 }
292 let k = project_inner(cont, r)?;
293 if r == from {
294 Ok(SessionType::Send {
295 payload: payload.clone(),
296 credit: None,
297 cont: Box::new(k),
298 })
299 } else if r == to {
300 Ok(SessionType::Recv {
301 payload: payload.clone(),
302 credit: None,
303 cont: Box::new(k),
304 })
305 } else {
306 // r is uninvolved — the message is invisible to it.
307 Ok(k)
308 }
309 }
310 GlobalType::Choice { from, to, arms } => {
311 if from == to {
312 return Err(ProjectionError::SelfMessage { role: from.clone() });
313 }
314 if arms.is_empty() {
315 return Err(ProjectionError::EmptyChoice {
316 from: from.clone(),
317 to: to.clone(),
318 });
319 }
320 // Project every arm for `r`.
321 let mut arm_projections: Vec<(&String, SessionType)> = Vec::with_capacity(arms.len());
322 for (label, arm) in arms {
323 let local = project_inner(arm, r)?;
324 arm_projections.push((label, local));
325 }
326 if r == from {
327 // Internal choice — keep the label set, project per arm.
328 let map: BTreeMap<String, SessionType> = arm_projections
329 .into_iter()
330 .map(|(l, s)| (l.clone(), s))
331 .collect();
332 Ok(SessionType::Select(map))
333 } else if r == to {
334 // External choice — the receiver offers all arms.
335 let map: BTreeMap<String, SessionType> = arm_projections
336 .into_iter()
337 .map(|(l, s)| (l.clone(), s))
338 .collect();
339 Ok(SessionType::Branch(map))
340 } else {
341 // r is uninvolved — every arm MUST project to the same
342 // type for r (otherwise r would have to know which arm
343 // was selected, but r saw nothing). This is the merge
344 // condition; the verdict is the canonical safety gate.
345 let mut iter = arm_projections.into_iter();
346 let (first_label, first_proj) =
347 iter.next().expect("non-empty arms (checked above)");
348 let mut canonical = first_proj;
349 let canonical_label = first_label.clone();
350 for (label, proj) in iter {
351 if !canonical.equiv(&proj) {
352 return Err(ProjectionError::MergeFailed {
353 role: r.clone(),
354 labels: ordered_pair(canonical_label.clone(), label.clone()),
355 left: Box::new(canonical),
356 right: Box::new(proj),
357 });
358 }
359 }
360 Ok(canonical)
361 }
362 }
363 GlobalType::Rec(var, body) => {
364 // Non-participation rule (standard MPST): if `r` does not
365 // appear anywhere in the body, the recursion is invisible to
366 // `r` and the projection is `End`. This handles `μX. X` and
367 // the more common case where the loop touches a strict
368 // subset of the participants — the rest just terminate.
369 let mut body_roles = BTreeSet::new();
370 collect_roles(body, &mut body_roles);
371 if !body_roles.contains(r) {
372 return Ok(SessionType::End);
373 }
374 let inner = project_inner(body, r)?;
375 // Drop the wrapper if the body's projection doesn't actually
376 // recur (the var is gone after the per-message elision
377 // sequence above). Keeps the projected session type minimal.
378 if contains_var(&inner, var) {
379 Ok(SessionType::Rec(var.clone(), Box::new(inner)))
380 } else {
381 Ok(inner)
382 }
383 }
384 GlobalType::Var(var) => Ok(SessionType::Var(var.clone())),
385 }
386}
387
388/// Does `t` contain a free occurrence of `Var(var)` (i.e. not shadowed by
389/// an inner `Rec(var, _)`)? Used to elide vacuous `Rec` wrappers when a
390/// role's projection never recurses.
391fn contains_var(t: &SessionType, var: &str) -> bool {
392 match t {
393 SessionType::End => false,
394 SessionType::Send { cont, .. } | SessionType::Recv { cont, .. } => {
395 contains_var(cont, var)
396 }
397 SessionType::Select(m) | SessionType::Branch(m) => {
398 m.values().any(|s| contains_var(s, var))
399 }
400 SessionType::Rec(y, b) if y == var => false, // shadowed
401 SessionType::Rec(_, b) => contains_var(b, var),
402 SessionType::Var(x) => x == var,
403 }
404}
405
406/// Canonicalised pair (alphabetical) for deterministic diagnostics.
407fn ordered_pair(a: String, b: String) -> (String, String) {
408 if a <= b {
409 (a, b)
410 } else {
411 (b, a)
412 }
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 // ── Helpers for readable tests ───────────────────────────────────────
420
421 fn r(s: &str) -> Role {
422 Role::new(s)
423 }
424
425 // ── 1. Sender / receiver / non-participant projection rules ───────────
426
427 #[test]
428 fn project_sender_yields_send() {
429 // A → B : Msg . end
430 let g = GlobalType::message("A", "B", "Msg", GlobalType::End);
431 assert_eq!(
432 g.project(&r("A")).unwrap(),
433 SessionType::send("Msg", SessionType::End)
434 );
435 }
436
437 #[test]
438 fn project_receiver_yields_recv() {
439 let g = GlobalType::message("A", "B", "Msg", GlobalType::End);
440 assert_eq!(
441 g.project(&r("B")).unwrap(),
442 SessionType::recv("Msg", SessionType::End)
443 );
444 }
445
446 #[test]
447 fn projection_for_uninvolved_role_skips_the_message() {
448 // A → B : Msg . end — role C is never mentioned, projects to End.
449 let g = GlobalType::message("A", "B", "Msg", GlobalType::End);
450 assert_eq!(g.project(&r("C")).unwrap(), SessionType::End);
451 // Two-message protocol where C is also uninvolved.
452 let g2 = GlobalType::message(
453 "A",
454 "B",
455 "Msg",
456 GlobalType::message("B", "A", "Ack", GlobalType::End),
457 );
458 assert_eq!(g2.project(&r("C")).unwrap(), SessionType::End);
459 }
460
461 // ── 2. Choice projection rules ───────────────────────────────────────
462
463 #[test]
464 fn project_chooser_yields_internal_choice() {
465 // A → B : { yes: end, no: end } — A picks → ⊕.
466 let g = GlobalType::choice(
467 "A",
468 "B",
469 [("yes".into(), GlobalType::End), ("no".into(), GlobalType::End)],
470 );
471 let p = g.project(&r("A")).unwrap();
472 assert!(matches!(p, SessionType::Select(_)));
473 }
474
475 #[test]
476 fn project_offerer_yields_external_choice() {
477 let g = GlobalType::choice(
478 "A",
479 "B",
480 [("yes".into(), GlobalType::End), ("no".into(), GlobalType::End)],
481 );
482 let p = g.project(&r("B")).unwrap();
483 assert!(matches!(p, SessionType::Branch(_)));
484 }
485
486 #[test]
487 fn merge_condition_passes_when_uninvolved_role_sees_equivalent_arms() {
488 // A → B : { yes: C → D : T . end, no: C → D : T . end }
489 // role C sees both arms project to !T.end (same), merge OK.
490 let arm = GlobalType::message("C", "D", "T", GlobalType::End);
491 let g = GlobalType::choice(
492 "A",
493 "B",
494 [("yes".into(), arm.clone()), ("no".into(), arm)],
495 );
496 let pc = g.project(&r("C")).unwrap();
497 assert_eq!(pc, SessionType::send("T", SessionType::End));
498 // D too — receiver-side same projection.
499 let pd = g.project(&r("D")).unwrap();
500 assert_eq!(pd, SessionType::recv("T", SessionType::End));
501 }
502
503 #[test]
504 fn merge_condition_fails_when_uninvolved_role_sees_divergent_arms() {
505 // A → B : { yes: C → D : T . end, no: end }
506 // Arm `yes`: C projects to !T.end. Arm `no`: C projects to end. Diverge.
507 let g = GlobalType::choice(
508 "A",
509 "B",
510 [
511 ("no".into(), GlobalType::End),
512 (
513 "yes".into(),
514 GlobalType::message("C", "D", "T", GlobalType::End),
515 ),
516 ],
517 );
518 match g.project(&r("C")) {
519 Err(ProjectionError::MergeFailed { role, labels, .. }) => {
520 assert_eq!(role, r("C"));
521 // The labels are reported in alphabetical order.
522 assert_eq!(labels, ("no".into(), "yes".into()));
523 }
524 other => panic!("expected MergeFailed for C, got {other:?}"),
525 }
526 }
527
528 // ── 3. Recursion + the elision optimisation ──────────────────────────
529
530 #[test]
531 fn recursion_round_trips_through_projection_for_an_iterating_role() {
532 // μX. A → B : T . X — A and B iterate forever; C doesn't.
533 let g = GlobalType::rec(
534 "X",
535 GlobalType::message("A", "B", "T", GlobalType::var("X")),
536 );
537 // A's projection: rec X. !T.X
538 let pa = g.project(&r("A")).unwrap();
539 assert_eq!(
540 pa,
541 SessionType::rec("X", SessionType::send("T", SessionType::var("X")))
542 );
543 // B's projection: rec X. ?T.X
544 let pb = g.project(&r("B")).unwrap();
545 assert_eq!(
546 pb,
547 SessionType::rec("X", SessionType::recv("T", SessionType::var("X")))
548 );
549 }
550
551 #[test]
552 fn projection_elides_vacuous_rec_for_a_non_iterating_role() {
553 // μX. A → B : T . X — C never participates, so its projection
554 // should collapse to End (no need to wrap in rec — the var would
555 // not occur inside, and our optimisation drops the wrapper).
556 let g = GlobalType::rec(
557 "X",
558 GlobalType::message("A", "B", "T", GlobalType::var("X")),
559 );
560 let pc = g.project(&r("C")).unwrap();
561 // Sanity: the projected type is not a vacuous rec.
562 assert!(!matches!(pc, SessionType::Rec(_, _)));
563 }
564
565 // ── 4. Gate rejections ───────────────────────────────────────────────
566
567 #[test]
568 fn self_message_is_rejected() {
569 let g = GlobalType::message("A", "A", "T", GlobalType::End);
570 match g.project(&r("B")) {
571 Err(ProjectionError::SelfMessage { role }) => assert_eq!(role, r("A")),
572 other => panic!("expected SelfMessage, got {other:?}"),
573 }
574 }
575
576 #[test]
577 fn empty_choice_is_rejected() {
578 let g = GlobalType::choice("A", "B", []);
579 match g.project(&r("A")) {
580 Err(ProjectionError::EmptyChoice { from, to }) => {
581 assert_eq!(from, r("A"));
582 assert_eq!(to, r("B"));
583 }
584 other => panic!("expected EmptyChoice, got {other:?}"),
585 }
586 }
587
588 // ── 5. roles() + project_all() ───────────────────────────────────────
589
590 #[test]
591 fn roles_collects_every_participant() {
592 // Three-role chat: User → Agent → Tool → Agent → User.
593 let g = GlobalType::message(
594 "User",
595 "Agent",
596 "Query",
597 GlobalType::message(
598 "Agent",
599 "Tool",
600 "Sub",
601 GlobalType::message(
602 "Tool",
603 "Agent",
604 "Resp",
605 GlobalType::message("Agent", "User", "Reply", GlobalType::End),
606 ),
607 ),
608 );
609 let roles = g.roles();
610 assert_eq!(roles, [r("Agent"), r("Tool"), r("User")].into_iter().collect());
611 }
612
613 #[test]
614 fn project_all_succeeds_on_a_safely_realizable_three_role_protocol() {
615 let g = GlobalType::message(
616 "User",
617 "Agent",
618 "Query",
619 GlobalType::message(
620 "Agent",
621 "Tool",
622 "Sub",
623 GlobalType::message(
624 "Tool",
625 "Agent",
626 "Resp",
627 GlobalType::message("Agent", "User", "Reply", GlobalType::End),
628 ),
629 ),
630 );
631 let all = g.project_all().expect("safely realizable");
632 assert_eq!(all.len(), 3);
633 assert!(g.is_safely_realizable());
634 // Spot-check the User projection: !Query.?Reply.end.
635 let pu = &all[&r("User")];
636 assert_eq!(
637 pu,
638 &SessionType::send("Query", SessionType::recv("Reply", SessionType::End))
639 );
640 // Tool: ?Sub.!Resp.end.
641 let pt = &all[&r("Tool")];
642 assert_eq!(
643 pt,
644 &SessionType::recv("Sub", SessionType::send("Resp", SessionType::End))
645 );
646 // Agent: ?Query.!Sub.?Resp.!Reply.end (the orchestrator's view).
647 let pa = &all[&r("Agent")];
648 assert_eq!(
649 pa,
650 &SessionType::recv(
651 "Query",
652 SessionType::send(
653 "Sub",
654 SessionType::recv("Resp", SessionType::send("Reply", SessionType::End))
655 )
656 )
657 );
658 }
659
660 #[test]
661 fn is_safely_realizable_rejects_the_diverging_choice() {
662 let g = GlobalType::choice(
663 "A",
664 "B",
665 [
666 ("yes".into(), GlobalType::message("C", "D", "T", GlobalType::End)),
667 ("no".into(), GlobalType::End),
668 ],
669 );
670 assert!(!g.is_safely_realizable());
671 }
672
673 // ── 6. Serde round-trip — the wire shape downstream tools consume ────
674
675 #[test]
676 fn global_type_round_trips_through_json() {
677 let g = GlobalType::rec(
678 "X",
679 GlobalType::choice(
680 "Client",
681 "Server",
682 [
683 (
684 "ask".into(),
685 GlobalType::message(
686 "Server",
687 "Client",
688 "Token",
689 GlobalType::var("X"),
690 ),
691 ),
692 ("cancel".into(), GlobalType::End),
693 ],
694 ),
695 );
696 let json = serde_json::to_string(&g).unwrap();
697 let back: GlobalType = serde_json::from_str(&json).unwrap();
698 assert_eq!(back, g);
699 }
700
701 // ── 7. The Kivi paper-style three-role chat with a recursion ─────────
702
703 #[test]
704 fn kivi_three_role_recursive_chat_projects_per_role() {
705 // The §41.h motivating example — orchestrator + skill + user, a
706 // genuinely safely-realizable three-role loop (every participant
707 // observes every iteration, no hidden choice):
708 //
709 // μX. User → Agent : Utterance .
710 // Agent → Skill : Sub .
711 // Skill → Agent : Resp .
712 // Agent → User : Reply .
713 // X
714 //
715 // Adding a "done" arm a non-participant role couldn't observe
716 // (e.g. an `Agent → User : {more: …, done: end}` where Skill is
717 // silent on the choice) is correctly REJECTED by the gate — see
718 // `merge_condition_fails_…` above. To support termination across
719 // all three, the protocol must propagate the choice signal to
720 // every active role (e.g. Agent → Skill : {more, done} before
721 // the outer choice); we exercise that pattern in
722 // `safely_propagated_choice_projects_for_every_role` below.
723 let g = GlobalType::rec(
724 "X",
725 GlobalType::message(
726 "User",
727 "Agent",
728 "Utterance",
729 GlobalType::message(
730 "Agent",
731 "Skill",
732 "Sub",
733 GlobalType::message(
734 "Skill",
735 "Agent",
736 "Resp",
737 GlobalType::message("Agent", "User", "Reply", GlobalType::var("X")),
738 ),
739 ),
740 ),
741 );
742 let all = g.project_all().expect("3-role chat is safely realizable");
743 assert_eq!(all.len(), 3);
744 // Each role iterates — the recursion is preserved per-role.
745 for role in [r("User"), r("Agent"), r("Skill")] {
746 assert!(
747 matches!(all[&role], SessionType::Rec(_, _)),
748 "{role} should iterate (got {})",
749 all[&role]
750 );
751 }
752 // Skill's body: ?Sub.!Resp.X (the choice/Reply are uninvolved
753 // for Skill, so they elide).
754 let skill = &all[&r("Skill")];
755 if let SessionType::Rec(_, body) = skill {
756 assert_eq!(
757 **body,
758 SessionType::recv("Sub", SessionType::send("Resp", SessionType::var("X")))
759 );
760 }
761 }
762
763 #[test]
764 fn safely_realizable_choice_projects_for_every_role() {
765 // The canonical "uniform-continuation choice" — both arms have
766 // identical observable behaviour for every non-participating
767 // role, so the merge condition is trivially satisfied. Agent
768 // decides whether to acknowledge User; either way, Agent
769 // delivers `T` to Skill.
770 //
771 // Agent → User : { ack: Agent → Skill : T . end,
772 // nak: Agent → Skill : T . end }
773 //
774 // Roles + projections:
775 // - Agent: ⊕{ ack: !T.end, nak: !T.end } (chooser)
776 // - User : &{ ack: end, nak: end } (offerer)
777 // - Skill: ?T.end (uninvolved in
778 // outer choice;
779 // merge of both
780 // arms = ?T.end)
781 let g = GlobalType::choice(
782 "Agent",
783 "User",
784 [
785 (
786 "ack".into(),
787 GlobalType::message("Agent", "Skill", "T", GlobalType::End),
788 ),
789 (
790 "nak".into(),
791 GlobalType::message("Agent", "Skill", "T", GlobalType::End),
792 ),
793 ],
794 );
795 let all = g.project_all().expect("uniform-continuation choice is realizable");
796 assert_eq!(all.len(), 3);
797 // Skill's projection collapses the choice (both arms project to
798 // the same `?T.end` for Skill — merge passes silently).
799 assert_eq!(
800 all[&r("Skill")],
801 SessionType::recv("T", SessionType::End)
802 );
803 // Agent is the chooser → internal choice.
804 assert!(matches!(all[&r("Agent")], SessionType::Select(_)));
805 // User is the offerer → external choice.
806 assert!(matches!(all[&r("User")], SessionType::Branch(_)));
807 }
808
809 #[test]
810 fn contains_var_handles_shadowing_correctly() {
811 // rec Y. send T . var X — outer X is free; rec Y body has X as free.
812 let t = SessionType::rec(
813 "Y",
814 SessionType::send("T", SessionType::var("X")),
815 );
816 assert!(contains_var(&t, "X"));
817 // rec X. send T . var X — X is bound inside; from outside, no X.
818 let t2 = SessionType::rec(
819 "X",
820 SessionType::send("T", SessionType::var("X")),
821 );
822 assert!(!contains_var(&t2, "X"));
823 }
824}