Skip to main content

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}