Skip to main content

axon_frontend/
session.rs

1//! Session types — the algebra of typed bidirectional dialogue.
2//!
3//! §Fase 41.a — *WebSocket as a Cognitive Primitive*. This module is the pure
4//! mathematical core of the paper (`docs/paper_websocket_cognitive_primitive.md`):
5//! the session-type grammar (§3.1), the **duality** involution `(·)⊥` (§3.2),
6//! the **regular-coinductive equality** for recursive (`μ`) types, and the
7//! **connection law** — a connection with endpoints typed `S` and `T` is
8//! well-formed iff `T ≡ S⊥`. Grounded in Caires & Pfenning's Curry–Howard
9//! correspondence (session types ARE intuitionistic linear-logic propositions),
10//! it is the static guarantee RFC 6455 lacks: *what one end sends, the other
11//! expects* — making a dialogue **deadlock-free and protocol-conformant by
12//! construction**, not by per-message runtime validation.
13//!
14//! This is the pure algebra only: no parser/AST (Fase 41.b), no runtime (41.d),
15//! no multiparty projection (41.h). The payload carried by `send`/`recv` is an
16//! opaque [`Payload`] (a canonical type name); 41.b binds it to the real AST
17//! value types — the duality + equality algebra here depends only on payload
18//! *equality*, never on payload structure, so it is decoupled by construction.
19//!
20//! §Fase 41.c — **credit-refined backpressure** (D2 of the plan vivo, §4.2 of
21//! the paper). `Send` / `Recv` now carry an optional credit index `n: u64`
22//! (`!ⁿA.S` / `?ⁿA.S`); `None` is the unbounded fragment (`!∞A.S`, the algebra
23//! before 41.c). The "send at n = 0 has no typing rule" axiom is implemented by
24//! [`SessionType::has_send_at_zero`] (an explicit `!⁰A.S` in the type is
25//! unprovable) and by the **Presburger-decidable** flow analysis
26//! [`SessionType::credit_analyse`], which — given a socket budget `k` — checks
27//! that every send fires at an available credit `> 0` (no rule at n=0) and that
28//! every recursive body is **sustainable** (per-iteration net send count
29//! `Δ = #send − #recv ≤ 0`, the loop-fixpoint inequality). All constraints are
30//! linear over the naturals → decidable in the theory of Presburger arithmetic.
31
32use std::collections::BTreeMap;
33use std::fmt;
34
35use serde::{Deserialize, Serialize};
36
37/// The value type carried by a `send`/`recv`. Opaque at this layer (a canonical
38/// type name); Fase 41.b replaces it with the real AST value type. Duality and
39/// equality treat it nominally — only `Payload == Payload` matters.
40///
41/// `#[serde(transparent)]` — the JSON encoding is the bare type-name string
42/// (the wire shape the §Fase 41.g sealed-snapshot serialiser depends on);
43/// `Payload("Msg")` ↔ `"Msg"` on the wire.
44#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
45#[serde(transparent)]
46pub struct Payload(pub String);
47
48impl Payload {
49    pub fn new(name: impl Into<String>) -> Self {
50        Payload(name.into())
51    }
52}
53
54impl fmt::Display for Payload {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        f.write_str(&self.0)
57    }
58}
59
60/// A session type — the protocol of one endpoint of a connection (§3.1 of the
61/// paper). `Select`/`Branch` carry their labelled continuations in a `BTreeMap`
62/// so the label set is canonically ordered (deterministic duality + equality).
63///
64/// `Serialize` + `Deserialize` — §Fase 41.g sealed-snapshot resume needs the
65/// residual cursor + the protocol schema serialisable. The encoding is
66/// stable across the algebra layer + the enterprise persistence layer: the
67/// same JSON shape goes into the AAD-bound `cognitive_states` ciphertext
68/// and comes back out via [`SessionRuntime::resume`].
69#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
70pub enum SessionType {
71    /// `end` — the dialogue is complete.
72    End,
73    /// `!ⁿA.S` — send a value of type `A`, then behave as `S`. The optional
74    /// `credit` is the Fase 41.c index `n` (paper §4.2): `Some(n)` types a send
75    /// that *requires* `n > 0` available credit (the "no rule at n = 0" axiom
76    /// makes `Some(0)` unprovable); `None` is the unbounded fragment `!∞A.S`.
77    Send {
78        payload: Payload,
79        credit: Option<u64>,
80        cont: Box<SessionType>,
81    },
82    /// `?ⁿA.S` — receive a value of type `A`, then behave as `S`. Symmetric to
83    /// [`SessionType::Send`]: the index `n` bounds the receiver-side window.
84    Recv {
85        payload: Payload,
86        credit: Option<u64>,
87        cont: Box<SessionType>,
88    },
89    /// `⊕{ℓᵢ:Sᵢ}` — internal choice: this endpoint *selects* a label.
90    Select(BTreeMap<String, SessionType>),
91    /// `&{ℓᵢ:Sᵢ}` — external choice: this endpoint *offers* the branches.
92    Branch(BTreeMap<String, SessionType>),
93    /// `μX.S` — recursive session (equirecursive: `μX.S ≡ S[μX.S/X]`).
94    Rec(String, Box<SessionType>),
95    /// `X` — a recursion variable (bound by an enclosing `Rec`).
96    Var(String),
97    /// §Fase 79 — `Intr(sig; B, H)` — an **interruptible region** (paper
98    /// *Interruptible Sessions* §3.3). `body` (`B`) is the interruptible
99    /// protocol; on the `signal` (a closed `CallInterruptCause` cause) the
100    /// `handler` (`H`) runs. `H` is a **two-exit** construct: its normal exit is
101    /// [`SessionType::Resume`] (control returns to `B`'s residual), its
102    /// abandon exit is `End`. Duality is `Intr(sig; B, H)⊥ = Intr(sig; B⊥, H⊥)`
103    /// (Theorem 1: the connection law is preserved across both exits).
104    Interrupt {
105        signal: Payload,
106        body: Box<SessionType>,
107        handler: Box<SessionType>,
108    },
109    /// §Fase 79 — the interrupt handler's **normal exit**: hand control back to
110    /// the parked body's residual (`resume`). A self-dual leaf, like `End`
111    /// (resumption is symmetric — the peer receives control back). Only
112    /// well-formed inside an [`SessionType::Interrupt`] handler (checked at
113    /// §79.c); the abandon exit is the ordinary `End`.
114    Resume,
115}
116
117impl SessionType {
118    // ── Smart constructors (ergonomic + keep call sites readable) ──────────
119
120    /// `!A.S` — unbounded send (`credit = None`, the pre-41.c fragment).
121    pub fn send(payload: impl Into<String>, then: SessionType) -> Self {
122        SessionType::Send {
123            payload: Payload::new(payload),
124            credit: None,
125            cont: Box::new(then),
126        }
127    }
128    /// `?A.S` — unbounded receive (`credit = None`).
129    pub fn recv(payload: impl Into<String>, then: SessionType) -> Self {
130        SessionType::Recv {
131            payload: Payload::new(payload),
132            credit: None,
133            cont: Box::new(then),
134        }
135    }
136    /// `!ⁿA.S` — credit-refined send (Fase 41.c, paper §4.2). The continuation
137    /// `then` runs in the same window — the budget is global to the socket; the
138    /// `n` here is the *snapshot* of available credit demanded at this step.
139    pub fn send_credit(payload: impl Into<String>, n: u64, then: SessionType) -> Self {
140        SessionType::Send {
141            payload: Payload::new(payload),
142            credit: Some(n),
143            cont: Box::new(then),
144        }
145    }
146    /// `?ⁿA.S` — credit-refined receive (Fase 41.c).
147    pub fn recv_credit(payload: impl Into<String>, n: u64, then: SessionType) -> Self {
148        SessionType::Recv {
149            payload: Payload::new(payload),
150            credit: Some(n),
151            cont: Box::new(then),
152        }
153    }
154    pub fn select(branches: impl IntoIterator<Item = (String, SessionType)>) -> Self {
155        SessionType::Select(branches.into_iter().collect())
156    }
157    pub fn branch(branches: impl IntoIterator<Item = (String, SessionType)>) -> Self {
158        SessionType::Branch(branches.into_iter().collect())
159    }
160    pub fn rec(var: impl Into<String>, body: SessionType) -> Self {
161        SessionType::Rec(var.into(), Box::new(body))
162    }
163    pub fn var(name: impl Into<String>) -> Self {
164        SessionType::Var(name.into())
165    }
166
167    // ── Duality (§3.2): the involution that swaps the two sides ────────────
168
169    /// The dual `S⊥`: swaps `send`↔`recv` and `select`↔`branch`, recursing into
170    /// continuations; `end`, `Rec` binders and `Var`s are preserved. Payloads
171    /// **and** the credit index `n` are unchanged — `(!ⁿA.S)⊥ = ?ⁿA.S⊥` (same
172    /// `A`, same `n`, opposite direction). Symmetric credit is the standard
173    /// credit-flow semantics (Rast lineage): the sender's window-of-n is
174    /// exactly what the receiver-side is sized to absorb.
175    pub fn dual(&self) -> SessionType {
176        match self {
177            SessionType::End => SessionType::End,
178            SessionType::Send { payload, credit, cont } => SessionType::Recv {
179                payload: payload.clone(),
180                credit: *credit,
181                cont: Box::new(cont.dual()),
182            },
183            SessionType::Recv { payload, credit, cont } => SessionType::Send {
184                payload: payload.clone(),
185                credit: *credit,
186                cont: Box::new(cont.dual()),
187            },
188            SessionType::Select(m) => SessionType::Branch(dual_map(m)),
189            SessionType::Branch(m) => SessionType::Select(dual_map(m)),
190            SessionType::Rec(x, b) => SessionType::Rec(x.clone(), Box::new(b.dual())),
191            SessionType::Var(x) => SessionType::Var(x.clone()),
192            // §Fase 79 — Intr(sig; B, H)⊥ = Intr(sig; B⊥, H⊥); Resume self-dual
193            // (Theorem 1: connection law preserved across both handler exits).
194            SessionType::Interrupt { signal, body, handler } => SessionType::Interrupt {
195                signal: signal.clone(),
196                body: Box::new(body.dual()),
197                handler: Box::new(handler.dual()),
198            },
199            SessionType::Resume => SessionType::Resume,
200        }
201    }
202
203    // ── Equirecursive unfolding + capture-stopping substitution ────────────
204
205    /// Substitute the free variable `var` by `repl`. Stops at a shadowing
206    /// `Rec(var, …)` (the inner binder re-captures the name).
207    fn subst(&self, var: &str, repl: &SessionType) -> SessionType {
208        match self {
209            SessionType::End => SessionType::End,
210            SessionType::Send { payload, credit, cont } => SessionType::Send {
211                payload: payload.clone(),
212                credit: *credit,
213                cont: Box::new(cont.subst(var, repl)),
214            },
215            SessionType::Recv { payload, credit, cont } => SessionType::Recv {
216                payload: payload.clone(),
217                credit: *credit,
218                cont: Box::new(cont.subst(var, repl)),
219            },
220            SessionType::Select(m) => SessionType::Select(subst_map(m, var, repl)),
221            SessionType::Branch(m) => SessionType::Branch(subst_map(m, var, repl)),
222            SessionType::Rec(x, b) => {
223                if x == var {
224                    self.clone() // shadowed — leave the inner Rec untouched
225                } else {
226                    SessionType::Rec(x.clone(), Box::new(b.subst(var, repl)))
227                }
228            }
229            SessionType::Var(x) => {
230                if x == var {
231                    repl.clone()
232                } else {
233                    self.clone()
234                }
235            }
236            // §Fase 79 — substitution descends into both interrupt sub-protocols;
237            // Resume is a leaf (no free vars of its own).
238            SessionType::Interrupt { signal, body, handler } => SessionType::Interrupt {
239                signal: signal.clone(),
240                body: Box::new(body.subst(var, repl)),
241                handler: Box::new(handler.subst(var, repl)),
242            },
243            SessionType::Resume => SessionType::Resume,
244        }
245    }
246
247    /// Unfold every *leading* `Rec` so the head constructor is exposed:
248    /// `μX.S ↦ S[μX.S/X]`, repeated. Terminates for **contractive** types
249    /// (a guard appears under each `Rec` before the variable recurs).
250    ///
251    /// Public so the 41.d runtime can drive the session-type cursor over a
252    /// live connection: after every operational step the continuation is
253    /// re-unfolded so the cursor never carries a leading `Rec` for the
254    /// state machine to interpret.
255    pub fn unfold_head(&self) -> SessionType {
256        let mut t = self.clone();
257        while let SessionType::Rec(x, b) = t {
258            let whole = SessionType::Rec(x.clone(), b.clone());
259            t = b.subst(&x, &whole);
260        }
261        t
262    }
263
264    // ── Regular-coinductive equality ──────────────────────────────────────
265
266    /// Equirecursive equality: `S ≡ T` iff their infinite unfoldings coincide.
267    /// Decided by the standard coinductive algorithm — assume the pair equal,
268    /// unfold leading `Rec`s, compare heads, recurse; a re-encountered pair is
269    /// discharged by the assumption (the greatest fixed point). Terminates
270    /// because a regular type has finitely many distinct sub-pairs.
271    pub fn equiv(&self, other: &SessionType) -> bool {
272        let mut assumed: Vec<(SessionType, SessionType)> = Vec::new();
273        equiv_inner(self, other, &mut assumed)
274    }
275
276    /// The **connection law** (§3.2): a connection whose two endpoints are typed
277    /// `self` and `peer` is well-formed iff `peer ≡ self⊥`. Symmetric up to
278    /// involutivity (`(S⊥)⊥ ≡ S`).
279    pub fn is_dual_to(&self, peer: &SessionType) -> bool {
280        peer.equiv(&self.dual())
281    }
282
283    // ── Fase 41.c — credit-refined backpressure (D2, paper §4.2) ────────────
284
285    /// Stamp every (recursively-reachable) `Send` and `Recv` with the credit
286    /// index `n`. Idempotent on already-stamped types. Used by the type
287    /// checker to lift the socket's `backpressure: credit(k)` annotation onto
288    /// the bare session protocol so the algebra-level analysis can discharge
289    /// the constraint.
290    pub fn with_credit(&self, n: u64) -> SessionType {
291        match self {
292            SessionType::End => SessionType::End,
293            SessionType::Send { payload, cont, .. } => SessionType::Send {
294                payload: payload.clone(),
295                credit: Some(n),
296                cont: Box::new(cont.with_credit(n)),
297            },
298            SessionType::Recv { payload, cont, .. } => SessionType::Recv {
299                payload: payload.clone(),
300                credit: Some(n),
301                cont: Box::new(cont.with_credit(n)),
302            },
303            SessionType::Select(m) => SessionType::Select(
304                m.iter().map(|(l, s)| (l.clone(), s.with_credit(n))).collect(),
305            ),
306            SessionType::Branch(m) => SessionType::Branch(
307                m.iter().map(|(l, s)| (l.clone(), s.with_credit(n))).collect(),
308            ),
309            SessionType::Rec(x, b) => SessionType::Rec(x.clone(), Box::new(b.with_credit(n))),
310            SessionType::Var(x) => SessionType::Var(x.clone()),
311            // §Fase 79 — stamp both sub-protocols; the handler's disjoint-budget
312            // symmetry (Thm 3) is enforced structurally by the checker, not here.
313            SessionType::Interrupt { signal, body, handler } => SessionType::Interrupt {
314                signal: signal.clone(),
315                body: Box::new(body.with_credit(n)),
316                handler: Box::new(handler.with_credit(n)),
317            },
318            SessionType::Resume => SessionType::Resume,
319        }
320    }
321
322    /// The "no rule at n = 0" axiom (paper §4.2): an explicit `!⁰A.S` in the
323    /// type is **unprovable** — there is no typing rule for a send at zero
324    /// available credit. Returns the offending payload of the first such send
325    /// (in a deterministic left-to-right walk) if any.
326    ///
327    /// Decidable in linear time over the type structure.
328    pub fn has_send_at_zero(&self) -> Option<Payload> {
329        match self {
330            SessionType::End => None,
331            SessionType::Send { payload, credit: Some(0), .. } => Some(payload.clone()),
332            SessionType::Send { cont, .. } | SessionType::Recv { cont, .. } => cont.has_send_at_zero(),
333            SessionType::Select(m) | SessionType::Branch(m) => {
334                m.values().find_map(|s| s.has_send_at_zero())
335            }
336            SessionType::Rec(_, b) => b.has_send_at_zero(),
337            SessionType::Var(_) => None,
338            // §Fase 79 — an explicit `!⁰A.S` anywhere in either sub-protocol.
339            SessionType::Interrupt { body, handler, .. } => {
340                body.has_send_at_zero().or_else(|| handler.has_send_at_zero())
341            }
342            SessionType::Resume => None,
343        }
344    }
345
346    /// Decide the **credit conformance** of `self` against a budget `k`
347    /// (the socket's `backpressure: credit(k)` window). This is the
348    /// Presburger discharge — the constraints are linear arithmetic over the
349    /// naturals, so satisfiability is decidable; the algorithm here is the
350    /// direct fixpoint formulation specialised to closed, contractive session
351    /// types (Rast lineage, §4.2 of the paper).
352    ///
353    /// The check fires three kinds of error:
354    ///
355    /// 1. **Send at zero** — an explicit `!⁰A.S` in the type. Unprovable by
356    ///    construction (no typing rule applies).
357    /// 2. **Burst overflow** — a straight-line send burst exceeding the
358    ///    available window. With initial budget `k`, the abstract trace must
359    ///    never reach `available_credit < 0` at a send.
360    /// 3. **Loop unsustainability** — a recursive body whose per-iteration net
361    ///    send count `Δ = #send − #recv` is strictly positive: each iteration
362    ///    drains the window, so unbounded iteration is unsound under *any*
363    ///    finite budget. (`Δ ≤ 0` is the Presburger fixpoint inequality.)
364    ///
365    /// Returns `Ok(())` if the protocol is conformant, or [`CreditError`] with
366    /// the offending witness. Total over closed, contractive session types.
367    pub fn credit_analyse(&self, budget: u64) -> Result<(), CreditError> {
368        if let Some(p) = self.has_send_at_zero() {
369            return Err(CreditError::SendAtZero { payload: p });
370        }
371        // Initial window = full budget. The walker tracks the minimum
372        // available credit reachable along any execution path; if at any send
373        // it would fall below 0 → BurstOverflow. Recursive bodies are
374        // discharged by the Δ ≤ 0 fixpoint inequality.
375        let _final = credit_walk(self, budget as i64, budget as i64)?;
376        Ok(())
377    }
378
379    /// Enumerate the **recurring paths** of `self` w.r.t. recursion variable
380    /// `x` — every trace from the root that reaches `Var(x)`. Each path is
381    /// reported as `(#send, #recv)`; terminating paths (reaching `End` or a
382    /// different free variable) are dropped (they don't iterate, so they
383    /// don't constrain unbounded sustainability). Shadowing `Rec(x, …)` cuts
384    /// the descent — references inside refer to the inner binder.
385    ///
386    /// Total in time linear in the size of `self`; the path count is bounded
387    /// by the number of leaves of the choice tree.
388    pub fn recurring_paths(&self, x: &str) -> Vec<(u64, u64)> {
389        let mut out = Vec::new();
390        recurring_paths_into(self, x, 0, 0, &mut out);
391        out
392    }
393
394    /// Worst-case (maximum-Δ) recurring path of `self` w.r.t. `x`. Used by
395    /// the type checker to report the offending iteration count. Returns
396    /// `(0, 0)` if there are no recurring paths.
397    pub fn credit_delta(&self, x: &str) -> (u64, u64) {
398        self.recurring_paths(x)
399            .into_iter()
400            .max_by_key(|(s, r)| *s as i64 - *r as i64)
401            .unwrap_or((0, 0))
402    }
403
404    // ── Fase 41.e — SSE-as-fragment unification (D3, paper §4.4) ─────────
405
406    /// True iff `self` lies in the **SSE producer fragment**: the
407    /// connection only sends to its peer. Concretely the type contains
408    /// only `End`, `Send`, internal-`Select`, `Rec`, and `Var` — no
409    /// `Recv` (would mean the producer expects client input) and no
410    /// `Branch` (would mean the producer offers a choice the client
411    /// picks). For such a type the §4.4 identity `S_SSE = Π↓(S_WS)`
412    /// holds with `Π↓ = id`: the protocol *is already* the SSE fragment,
413    /// runnable over W3C SSE without WebSocket bidirectionality.
414    ///
415    /// Total over closed, contractive session types; linear in the size
416    /// of `self`.
417    pub fn projects_to_sse(&self) -> bool {
418        self.has_polarity(Polarity::Producer)
419    }
420
421    /// Dual of [`projects_to_sse`] — the **SSE consumer fragment**: the
422    /// connection only receives from its peer (`End`, `Recv`,
423    /// external-`Branch`, `Rec`, `Var`). The §4.4 theorem
424    /// `Π↓(S)⊥ = Π↑(S⊥)` ties this to `projects_to_sse` via duality:
425    /// `S.projects_to_sse() ⇔ S.dual().projects_to_sse_consumer()`.
426    pub fn projects_to_sse_consumer(&self) -> bool {
427        self.has_polarity(Polarity::Consumer)
428    }
429
430    /// Unified polarity test. The two SSE fragments are exactly the two
431    /// inhabitants of [`Polarity`]: `Producer = !/⊕/end/μ/var-only` and
432    /// `Consumer = ?/&/end/μ/var-only`.
433    pub fn has_polarity(&self, p: Polarity) -> bool {
434        match (self, p) {
435            (SessionType::End, _) => true,
436            (SessionType::Var(_), _) => true,
437            (SessionType::Send { cont, .. }, Polarity::Producer) => cont.has_polarity(p),
438            (SessionType::Recv { cont, .. }, Polarity::Consumer) => cont.has_polarity(p),
439            (SessionType::Select(arms), Polarity::Producer) => {
440                arms.values().all(|s| s.has_polarity(p))
441            }
442            (SessionType::Branch(arms), Polarity::Consumer) => {
443                arms.values().all(|s| s.has_polarity(p))
444            }
445            (SessionType::Rec(_, body), _) => body.has_polarity(p),
446            // Wrong-polarity head: `Send` in Consumer fragment, `Recv` in
447            // Producer fragment, `Branch` in Producer fragment, `Select`
448            // in Consumer fragment. Each one immediately disqualifies the
449            // type from the single-polarity SSE projection.
450            _ => false,
451        }
452    }
453}
454
455/// Which side of an SSE-projectable connection a session type describes
456/// — the **producer** (server-side, only sends/selects) or the
457/// **consumer** (client-side, only receives/branches). Used by
458/// [`SessionType::has_polarity`] to discharge the §4.4 SSE-fragment
459/// predicate `Π↓(S_WS) = S_SSE`.
460#[derive(Debug, Clone, Copy, PartialEq, Eq)]
461pub enum Polarity {
462    /// Server-side: only outbound actions (`Send`, `Select`).
463    Producer,
464    /// Client-side: only inbound actions (`Recv`, `Branch`).
465    Consumer,
466}
467
468impl Polarity {
469    /// The dual of this polarity — used to express the connection-law
470    /// preservation: an SSE-projectable session has a Producer role and
471    /// a Consumer role, related by duality.
472    pub fn flip(self) -> Self {
473        match self {
474            Polarity::Producer => Polarity::Consumer,
475            Polarity::Consumer => Polarity::Producer,
476        }
477    }
478}
479
480/// The Presburger discharge's negative verdict — the witness of an
481/// unconformant credit constraint. Surfaced verbatim by the type checker.
482#[derive(Debug, Clone, PartialEq, Eq)]
483pub enum CreditError {
484    /// An explicit `!⁰A.S` — the "no rule at n=0" axiom rejects it.
485    SendAtZero { payload: Payload },
486    /// A straight-line send burst exceeds the budget `k`: at the offending
487    /// send the abstract credit window would fall below 0.
488    BurstOverflow { payload: Payload, budget: u64, burst: u64 },
489    /// A recursive body has Δ > 0 (per iteration drains the window): no finite
490    /// budget makes unbounded iteration sound.
491    LoopUnsustainable { sends_per_iter: u64, recvs_per_iter: u64 },
492}
493
494impl fmt::Display for CreditError {
495    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
496        match self {
497            CreditError::SendAtZero { payload } => {
498                write!(f, "send `{payload}` at credit n=0 has no typing rule (D2, §4.2)")
499            }
500            CreditError::BurstOverflow { payload, budget, burst } => write!(
501                f,
502                "credit-window overflow at send `{payload}`: the protocol requires a \
503                 send-burst of {burst} but the socket's `credit({budget})` cannot absorb it"
504            ),
505            CreditError::LoopUnsustainable { sends_per_iter, recvs_per_iter } => write!(
506                f,
507                "recursive body is unsustainable: Δ = {sends_per_iter} - {recvs_per_iter} > 0 \
508                 (no finite credit window keeps unbounded iteration in flight)"
509            ),
510        }
511    }
512}
513
514/// Abstract-interpretation walker for the credit constraint. `available` is the
515/// current window snapshot; `budget` is the maximum (recv refills are capped at
516/// budget, the standard credit-flow semantics). Returns the available credit
517/// at the end of the executed branch (the *minimum* across choice arms so the
518/// caller sees the worst-case continuation).
519fn credit_walk(t: &SessionType, available: i64, budget: i64) -> Result<i64, CreditError> {
520    match t {
521        SessionType::End => Ok(available),
522        SessionType::Send { payload, cont, .. } => {
523            let next = available - 1;
524            if next < 0 {
525                return Err(CreditError::BurstOverflow {
526                    payload: payload.clone(),
527                    budget: budget as u64,
528                    burst: (budget - available + 1) as u64,
529                });
530            }
531            credit_walk(cont, next, budget)
532        }
533        SessionType::Recv { cont, .. } => {
534            // A recv refills one credit, capped at the budget (TCP-window
535            // semantics: the receiver never accumulates more than `k`).
536            let next = (available + 1).min(budget);
537            credit_walk(cont, next, budget)
538        }
539        SessionType::Select(m) | SessionType::Branch(m) => {
540            // Each arm must be conformant on its own; the conservative
541            // post-state is the minimum (worst case) across arms.
542            let mut worst = available;
543            for arm in m.values() {
544                let post = credit_walk(arm, available, budget)?;
545                if post < worst {
546                    worst = post;
547                }
548            }
549            Ok(worst)
550        }
551        SessionType::Rec(x, body) => {
552            // Loop sustainability (Presburger fixpoint): for every recurring
553            // path back to `Var(x)`, the per-iteration net send count must
554            // satisfy `Δ = #send − #recv ≤ 0`. A non-recurring arm (one that
555            // terminates in `end`) is exempt — it executes at most once.
556            // If *any* recurring path has Δ > 0, the window strictly drains
557            // on that iteration and no finite `k` is sufficient → reject.
558            for (s, r) in body.recurring_paths(x) {
559                if s > r {
560                    return Err(CreditError::LoopUnsustainable {
561                        sends_per_iter: s,
562                        recvs_per_iter: r,
563                    });
564                }
565            }
566            // Walk one iteration so a burst inside the body is surfaced even
567            // when the loop is sustainable on net (Δ ≤ 0 doesn't bound peak).
568            credit_walk(body, available, budget)
569        }
570        SessionType::Var(_) => {
571            // Recursion re-entry: nothing further to walk on this iteration;
572            // the fixpoint check above already vetted sustainability.
573            Ok(available)
574        }
575        // §Fase 79 — the body runs on the socket window; the handler runs on a
576        // separate declared budget (Thm 3 / D79.11b) and does not debit this
577        // window. On `resume` the window returns to its pre-interrupt state, so
578        // the region's post-state is exactly the body's normal completion.
579        SessionType::Interrupt { body, .. } => credit_walk(body, available, budget),
580        SessionType::Resume => Ok(available),
581    }
582}
583
584/// Enumerate `(#send, #recv)` for every path from `t` that reaches `Var(x)`
585/// (the loop-recurring traces). Paths that hit `End` or a free `Var(y≠x)` are
586/// dropped — they exit the loop, not iterate. A shadowing `Rec(x, _)` cuts the
587/// descent (the inner binder re-captures the name). Total in linear time.
588fn recurring_paths_into(t: &SessionType, x: &str, s: u64, r: u64, out: &mut Vec<(u64, u64)>) {
589    match t {
590        SessionType::End => {} // terminates — not a recurring path
591        SessionType::Var(y) if y == x => out.push((s, r)),
592        SessionType::Var(_) => {} // a free var that isn't our loop's
593        SessionType::Send { cont, .. } => recurring_paths_into(cont, x, s + 1, r, out),
594        SessionType::Recv { cont, .. } => recurring_paths_into(cont, x, s, r + 1, out),
595        SessionType::Select(m) | SessionType::Branch(m) => {
596            // Each arm is its own trace — descend into all of them.
597            for arm in m.values() {
598                recurring_paths_into(arm, x, s, r, out);
599            }
600        }
601        SessionType::Rec(y, body) if y != x => recurring_paths_into(body, x, s, r, out),
602        SessionType::Rec(_, _) => {} // shadows x — its inner Var refers to itself
603        // §Fase 79 — descend into the body (its sends/recvs count toward the loop
604        // Δ); the handler's disjoint budget is analysed separately, and Resume is
605        // a return-to-body marker, not a fresh recurring path.
606        SessionType::Interrupt { body, .. } => recurring_paths_into(body, x, s, r, out),
607        SessionType::Resume => {}
608    }
609}
610
611fn dual_map(m: &BTreeMap<String, SessionType>) -> BTreeMap<String, SessionType> {
612    m.iter().map(|(l, s)| (l.clone(), s.dual())).collect()
613}
614
615fn subst_map(m: &BTreeMap<String, SessionType>, var: &str, repl: &SessionType) -> BTreeMap<String, SessionType> {
616    m.iter().map(|(l, s)| (l.clone(), s.subst(var, repl))).collect()
617}
618
619fn equiv_inner(s: &SessionType, t: &SessionType, assumed: &mut Vec<(SessionType, SessionType)>) -> bool {
620    // Coinduction: a pair we are already proving equal is taken as equal.
621    if assumed.iter().any(|(x, y)| x == s && y == t) {
622        return true;
623    }
624    assumed.push((s.clone(), t.clone()));
625
626    match (s.unfold_head(), t.unfold_head()) {
627        (SessionType::End, SessionType::End) => true,
628        (
629            SessionType::Send { payload: a, credit: ca, cont: sk },
630            SessionType::Send { payload: b, credit: cb, cont: tk },
631        ) => a == b && ca == cb && equiv_inner(&sk, &tk, assumed),
632        (
633            SessionType::Recv { payload: a, credit: ca, cont: sk },
634            SessionType::Recv { payload: b, credit: cb, cont: tk },
635        ) => a == b && ca == cb && equiv_inner(&sk, &tk, assumed),
636        (SessionType::Select(m1), SessionType::Select(m2)) => equiv_maps(&m1, &m2, assumed),
637        (SessionType::Branch(m1), SessionType::Branch(m2)) => equiv_maps(&m1, &m2, assumed),
638        // A bare `Var` survives unfolding only if it is free (open type); compare
639        // nominally. Closed, contractive types never reach this with a head Var.
640        (SessionType::Var(x), SessionType::Var(y)) => x == y,
641        // §Fase 79 — two interrupt regions are equivalent iff same signal cause
642        // and equivalent body + handler. Required so `is_dual_to` recognizes the
643        // Intr(sig;B,H) / Intr(sig;B⊥,H⊥) pair.
644        (
645            SessionType::Interrupt { signal: a, body: b1, handler: h1 },
646            SessionType::Interrupt { signal: b, body: b2, handler: h2 },
647        ) => a == b && equiv_inner(&b1, &b2, assumed) && equiv_inner(&h1, &h2, assumed),
648        (SessionType::Resume, SessionType::Resume) => true,
649        _ => false,
650    }
651}
652
653fn equiv_maps(
654    m1: &BTreeMap<String, SessionType>,
655    m2: &BTreeMap<String, SessionType>,
656    assumed: &mut Vec<(SessionType, SessionType)>,
657) -> bool {
658    // Same label set, and equal continuations label-by-label.
659    if m1.len() != m2.len() || !m1.keys().all(|l| m2.contains_key(l)) {
660        return false;
661    }
662    m1.iter().all(|(l, s1)| equiv_inner(s1, &m2[l], assumed))
663}
664
665impl fmt::Display for SessionType {
666    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
667        match self {
668            SessionType::End => f.write_str("end"),
669            SessionType::Send { payload, credit, cont } => match credit {
670                Some(n) => write!(f, "!^{n}{payload}.{cont}"),
671                None => write!(f, "!{payload}.{cont}"),
672            },
673            SessionType::Recv { payload, credit, cont } => match credit {
674                Some(n) => write!(f, "?^{n}{payload}.{cont}"),
675                None => write!(f, "?{payload}.{cont}"),
676            },
677            SessionType::Select(m) => write_choice(f, "+", m),
678            SessionType::Branch(m) => write_choice(f, "&", m),
679            SessionType::Rec(x, b) => write!(f, "rec {x}.{b}"),
680            SessionType::Var(x) => f.write_str(x),
681            SessionType::Interrupt { signal, body, handler } => {
682                write!(f, "intr[{signal}]({body} ; {handler})")
683            }
684            SessionType::Resume => f.write_str("resume"),
685        }
686    }
687}
688
689fn write_choice(f: &mut fmt::Formatter<'_>, sym: &str, m: &BTreeMap<String, SessionType>) -> fmt::Result {
690    write!(f, "{sym}{{")?;
691    for (i, (l, s)) in m.iter().enumerate() {
692        if i > 0 {
693            f.write_str(", ")?;
694        }
695        write!(f, "{l}: {s}")?;
696    }
697    f.write_str("}")
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703
704    // Helpers for readable session-type literals.
705    fn sel(pairs: &[(&str, SessionType)]) -> SessionType {
706        SessionType::select(pairs.iter().map(|(l, s)| (l.to_string(), s.clone())))
707    }
708    fn brn(pairs: &[(&str, SessionType)]) -> SessionType {
709        SessionType::branch(pairs.iter().map(|(l, s)| (l.to_string(), s.clone())))
710    }
711
712    // ── Duality basics ─────────────────────────────────────────────────────
713
714    #[test]
715    fn dual_swaps_send_recv_and_keeps_payload() {
716        let s = SessionType::send("Int", SessionType::End);
717        assert_eq!(s.dual(), SessionType::recv("Int", SessionType::End));
718        // The payload (`Int`) is unchanged — only the direction flips.
719        assert_eq!(SessionType::recv("Int", SessionType::End).dual(), s);
720    }
721
722    #[test]
723    fn dual_swaps_select_branch() {
724        let s = sel(&[("a", SessionType::End), ("b", SessionType::send("T", SessionType::End))]);
725        let d = s.dual();
726        assert!(matches!(d, SessionType::Branch(_)));
727        // …and the continuations are dualised too.
728        assert_eq!(d, brn(&[("a", SessionType::End), ("b", SessionType::recv("T", SessionType::End))]));
729    }
730
731    // ── Involutivity: (S⊥)⊥ ≡ S — the cornerstone of the connection law ─────
732
733    #[test]
734    fn duality_is_an_involution() {
735        let samples = vec![
736            SessionType::End,
737            SessionType::send("A", SessionType::recv("B", SessionType::End)),
738            sel(&[("x", SessionType::End), ("y", SessionType::recv("Q", SessionType::End))]),
739            // recursive: rec X. !Msg. &{ more: X, done: end }
740            SessionType::rec(
741                "X",
742                SessionType::send("Msg", brn(&[("more", SessionType::var("X")), ("done", SessionType::End)])),
743            ),
744        ];
745        for s in samples {
746            assert!(s.dual().dual().equiv(&s), "(S⊥)⊥ ≢ S for {s}");
747        }
748    }
749
750    // ── The connection law: S is dual to S⊥, and only to S⊥ ─────────────────
751
752    #[test]
753    fn connection_law_holds_for_dual_and_fails_otherwise() {
754        let s = SessionType::send("Q", SessionType::recv("R", SessionType::End));
755        assert!(s.is_dual_to(&s.dual()), "a session must be dual to its own dual");
756        // Not dual to itself (it sends where the peer must receive).
757        assert!(!s.is_dual_to(&s));
758        // Not dual to a peer with a mismatched payload.
759        let wrong = SessionType::recv("Q", SessionType::send("WRONG", SessionType::End));
760        assert!(!s.is_dual_to(&wrong));
761    }
762
763    // ── Regular-coinductive equality: fold/unfold + α-renaming ──────────────
764
765    #[test]
766    fn equirecursive_fold_unfold_equality() {
767        // μX. !A.X  ≡  !A.(μX. !A.X)   — one unfolding is equal.
768        let folded = SessionType::rec("X", SessionType::send("A", SessionType::var("X")));
769        let unfolded = SessionType::send("A", folded.clone());
770        assert!(folded.equiv(&unfolded));
771        assert!(unfolded.equiv(&folded));
772    }
773
774    #[test]
775    fn equality_is_insensitive_to_bound_variable_name() {
776        let x = SessionType::rec("X", SessionType::send("A", SessionType::var("X")));
777        let y = SessionType::rec("Y", SessionType::send("A", SessionType::var("Y")));
778        assert!(x.equiv(&y), "α-equivalent recursive sessions must be equal");
779    }
780
781    #[test]
782    fn equality_reflexive_and_rejects_real_differences() {
783        let s = sel(&[("a", SessionType::send("T", SessionType::End)), ("b", SessionType::End)]);
784        assert!(s.equiv(&s));
785        // Direction differs.
786        assert!(!SessionType::send("T", SessionType::End).equiv(&SessionType::recv("T", SessionType::End)));
787        // Payload differs.
788        assert!(!SessionType::send("A", SessionType::End).equiv(&SessionType::send("B", SessionType::End)));
789        // Label set differs.
790        let s2 = sel(&[("a", SessionType::send("T", SessionType::End)), ("c", SessionType::End)]);
791        assert!(!s.equiv(&s2));
792        // Choice kind differs (select vs branch).
793        assert!(!sel(&[("a", SessionType::End)]).equiv(&brn(&[("a", SessionType::End)])));
794    }
795
796    #[test]
797    fn connection_law_holds_for_recursive_dialogue() {
798        // A realistic chat dialogue: rec X. +{ ask: !Utterance. &{ token: ?Token.X, done: end }, cancel: end }
799        let client = SessionType::rec(
800            "X",
801            sel(&[
802                (
803                    "ask",
804                    SessionType::send(
805                        "Utterance",
806                        brn(&[("token", SessionType::recv("Token", SessionType::var("X"))), ("done", SessionType::End)]),
807                    ),
808                ),
809                ("cancel", SessionType::End),
810            ]),
811        );
812        // The server endpoint is the structural dual; the law must accept it,
813        // and reject the (non-dual) identical copy.
814        assert!(client.is_dual_to(&client.dual()));
815        assert!(!client.is_dual_to(&client));
816        // equiv terminates on this recursive type (no stack blow-up).
817        assert!(client.equiv(&client));
818    }
819
820    #[test]
821    fn display_is_readable() {
822        let s = SessionType::send("Int", SessionType::recv("Bool", SessionType::End));
823        assert_eq!(s.to_string(), "!Int.?Bool.end");
824        assert_eq!(SessionType::rec("X", SessionType::var("X")).to_string(), "rec X.X");
825    }
826
827    // ── Fase 41.c — credit-refined backpressure (D2) ─────────────────────────
828
829    #[test]
830    fn dual_preserves_credit_index() {
831        // (!ⁿA.S)⊥ = ?ⁿA.S⊥ — same credit, opposite direction.
832        let s = SessionType::send_credit("Msg", 7, SessionType::End);
833        assert_eq!(s.dual(), SessionType::recv_credit("Msg", 7, SessionType::End));
834        // Round-trip preserves the credit through both polarities.
835        assert!(s.dual().dual().equiv(&s));
836    }
837
838    #[test]
839    fn equality_distinguishes_credit_index() {
840        // Different numeric credit ⇒ structurally distinct types.
841        let a = SessionType::send_credit("T", 1, SessionType::End);
842        let b = SessionType::send_credit("T", 2, SessionType::End);
843        assert!(!a.equiv(&b));
844        // Unbounded (credit=None) is distinct from any stamped credit.
845        let unbounded = SessionType::send("T", SessionType::End);
846        assert!(!a.equiv(&unbounded));
847    }
848
849    #[test]
850    fn with_credit_stamps_every_send_and_recv() {
851        let bare = SessionType::send(
852            "A",
853            SessionType::recv("B", SessionType::send("C", SessionType::End)),
854        );
855        let stamped = bare.with_credit(4);
856        let expected = SessionType::send_credit(
857            "A",
858            4,
859            SessionType::recv_credit("B", 4, SessionType::send_credit("C", 4, SessionType::End)),
860        );
861        assert_eq!(stamped, expected);
862        // Idempotent on already-stamped.
863        assert_eq!(stamped.with_credit(4), stamped);
864    }
865
866    #[test]
867    fn has_send_at_zero_finds_the_unprovable_send() {
868        let bad = SessionType::recv(
869            "Q",
870            SessionType::send_credit("Boom", 0, SessionType::End),
871        );
872        assert_eq!(bad.has_send_at_zero(), Some(Payload::new("Boom")));
873        // A protocol with no `!⁰…` is clean.
874        let ok = SessionType::send_credit("A", 3, SessionType::End);
875        assert_eq!(ok.has_send_at_zero(), None);
876        // Sends inside choice arms are reached.
877        let choice = sel(&[("ask", SessionType::send_credit("X", 0, SessionType::End))]);
878        assert_eq!(choice.has_send_at_zero(), Some(Payload::new("X")));
879    }
880
881    // ── The Presburger discharge: credit_analyse(budget) ────────────────────
882
883    #[test]
884    fn credit_analyse_accepts_a_straight_line_protocol_within_budget() {
885        // Two consecutive sends; budget = 2 ⇒ enough window.
886        let s = SessionType::send("A", SessionType::send("B", SessionType::End));
887        assert!(s.credit_analyse(2).is_ok());
888    }
889
890    #[test]
891    fn credit_analyse_rejects_burst_overflow() {
892        // Three sends in a row; budget = 2 ⇒ the third send hits available = 0.
893        let s = SessionType::send(
894            "A",
895            SessionType::send("B", SessionType::send("C", SessionType::End)),
896        );
897        match s.credit_analyse(2) {
898            Err(CreditError::BurstOverflow { payload, budget: 2, .. }) => {
899                assert_eq!(payload, Payload::new("C"));
900            }
901            other => panic!("expected BurstOverflow, got {other:?}"),
902        }
903    }
904
905    #[test]
906    fn credit_analyse_rejects_explicit_send_at_zero() {
907        let s = SessionType::send_credit("X", 0, SessionType::End);
908        match s.credit_analyse(8) {
909            Err(CreditError::SendAtZero { payload }) => assert_eq!(payload, Payload::new("X")),
910            other => panic!("expected SendAtZero, got {other:?}"),
911        }
912    }
913
914    #[test]
915    fn credit_analyse_rejects_unsustainable_loop() {
916        // rec X. !A.!B.?Ack.X — Δ = 2 - 1 = 1 > 0; no finite budget keeps
917        // unbounded iteration in flight.
918        let s = SessionType::rec(
919            "X",
920            SessionType::send(
921                "A",
922                SessionType::send("B", SessionType::recv("Ack", SessionType::var("X"))),
923            ),
924        );
925        match s.credit_analyse(100) {
926            Err(CreditError::LoopUnsustainable { sends_per_iter: 2, recvs_per_iter: 1 }) => {}
927            other => panic!("expected LoopUnsustainable(2,1), got {other:?}"),
928        }
929    }
930
931    #[test]
932    fn credit_analyse_accepts_a_balanced_loop() {
933        // rec X. !A.?Ack.X — Δ = 1 - 1 = 0; sustainable under budget ≥ 1.
934        let s = SessionType::rec(
935            "X",
936            SessionType::send("A", SessionType::recv("Ack", SessionType::var("X"))),
937        );
938        assert!(s.credit_analyse(1).is_ok());
939        assert!(s.credit_analyse(8).is_ok());
940    }
941
942    #[test]
943    fn credit_analyse_walks_choice_arms_worst_case() {
944        // +{ ask: !A.!B.end, quit: end } — the ask arm needs window 2.
945        let s = sel(&[
946            ("ask", SessionType::send("A", SessionType::send("B", SessionType::End))),
947            ("quit", SessionType::End),
948        ]);
949        assert!(s.credit_analyse(2).is_ok()); // both arms fit
950        assert!(matches!(
951            s.credit_analyse(1),
952            Err(CreditError::BurstOverflow { .. })
953        ));
954    }
955
956    #[test]
957    fn credit_delta_counts_per_iteration() {
958        // rec X. !A.!B.?Ack.X — the body's single recurring path has Δ = (2, 1).
959        let body = SessionType::send(
960            "A",
961            SessionType::send("B", SessionType::recv("Ack", SessionType::var("X"))),
962        );
963        assert_eq!(body.credit_delta("X"), (2, 1));
964        // Non-recurring tail (no Var(X)) yields no recurring paths → (0, 0).
965        let non_recurring = SessionType::send("A", SessionType::End);
966        assert_eq!(non_recurring.credit_delta("X"), (0, 0));
967        // Choice: only the recurring arm contributes; `cancel: end` is exempt.
968        let body_chat = sel(&[
969            (
970                "ask",
971                SessionType::send("U", SessionType::recv("Tok", SessionType::var("X"))),
972            ),
973            ("cancel", SessionType::End),
974        ]);
975        assert_eq!(body_chat.credit_delta("X"), (1, 1));
976    }
977
978    // ── Fase 41.e — SSE-as-fragment unification (D3) ─────────────────────
979
980    #[test]
981    fn pure_send_chain_is_in_the_sse_producer_fragment() {
982        // !A.!B.end — the canonical SSE shape: a server emits a sequence
983        // of events, no client input.
984        let s = SessionType::send("A", SessionType::send("B", SessionType::End));
985        assert!(s.projects_to_sse());
986        // Its dual is the consumer-side, in the dual SSE fragment.
987        assert!(s.dual().projects_to_sse_consumer());
988    }
989
990    #[test]
991    fn any_recv_disqualifies_the_producer_fragment() {
992        // Even one `Recv` makes the type two-polarity.
993        let s = SessionType::send("Q", SessionType::recv("Ack", SessionType::End));
994        assert!(!s.projects_to_sse());
995        // …and the dual still has the offending direction, just flipped.
996        assert!(!s.dual().projects_to_sse_consumer());
997    }
998
999    #[test]
1000    fn branch_disqualifies_the_producer_fragment() {
1001        // Branch = client picks. The producer (server) cannot offer one.
1002        let s = SessionType::branch([("ack".into(), SessionType::End)]);
1003        assert!(!s.projects_to_sse());
1004        // The dual is a Select, which IS in the producer fragment.
1005        assert!(s.dual().projects_to_sse());
1006    }
1007
1008    #[test]
1009    fn select_is_in_the_producer_fragment_iff_all_arms_are() {
1010        // ⊕{a: !A.end, b: end} — pure server-side internal choice.
1011        let ok = sel(&[
1012            ("a", SessionType::send("A", SessionType::End)),
1013            ("b", SessionType::End),
1014        ]);
1015        assert!(ok.projects_to_sse());
1016        // ⊕{a: !A.end, b: ?Q.end} — one arm asks for client input,
1017        // disqualifies the entire choice.
1018        let bad = sel(&[
1019            ("a", SessionType::send("A", SessionType::End)),
1020            ("b", SessionType::recv("Q", SessionType::End)),
1021        ]);
1022        assert!(!bad.projects_to_sse());
1023    }
1024
1025    #[test]
1026    fn recursive_sse_token_stream_is_in_the_producer_fragment() {
1027        // rec X. !Token.X — an unbounded SSE token stream. The canonical
1028        // example: every Fase 33 server-token stream is exactly this
1029        // type (modulo the closing `end` we typically wrap it in).
1030        let s = SessionType::rec(
1031            "X",
1032            SessionType::send("Token", SessionType::var("X")),
1033        );
1034        assert!(s.projects_to_sse());
1035        // …and the dual SSE-consumer view is `rec X. ?Token.X`.
1036        assert!(s.dual().projects_to_sse_consumer());
1037    }
1038
1039    #[test]
1040    fn the_two_polarities_partition_the_sse_projectable_space() {
1041        // For every S, S.projects_to_sse() ⇔ S.dual().projects_to_sse_consumer().
1042        // We sample a handful of producer-side shapes + the negative cases.
1043        let samples_producer: Vec<SessionType> = vec![
1044            SessionType::End,
1045            SessionType::send("A", SessionType::End),
1046            sel(&[("x", SessionType::End), ("y", SessionType::send("T", SessionType::End))]),
1047            SessionType::rec("X", SessionType::send("T", SessionType::var("X"))),
1048        ];
1049        for s in samples_producer {
1050            assert!(s.projects_to_sse(), "{s} should project to SSE producer");
1051            assert!(s.dual().projects_to_sse_consumer(), "{s}⊥ should project to SSE consumer");
1052        }
1053        let samples_non_sse: Vec<SessionType> = vec![
1054            SessionType::recv("A", SessionType::send("B", SessionType::End)),
1055            SessionType::send("A", SessionType::recv("B", SessionType::End)),
1056            brn(&[("x", SessionType::End)]),
1057        ];
1058        for s in samples_non_sse {
1059            assert!(!s.projects_to_sse(), "{s} should NOT project to SSE producer");
1060        }
1061    }
1062
1063    #[test]
1064    fn polarity_flip_is_an_involution() {
1065        assert_eq!(Polarity::Producer.flip(), Polarity::Consumer);
1066        assert_eq!(Polarity::Consumer.flip(), Polarity::Producer);
1067        for p in [Polarity::Producer, Polarity::Consumer] {
1068            assert_eq!(p.flip().flip(), p);
1069        }
1070    }
1071
1072    #[test]
1073    fn credit_analyse_is_total_on_realistic_chat_dialogue() {
1074        // The 41.a chat sample: rec X. +{ ask: !Utterance. &{ token: ?Token.X,
1075        // done: end }, cancel: end }. Worst-case arm has Δ = 1 - 1 = 0.
1076        let client = SessionType::rec(
1077            "X",
1078            sel(&[
1079                (
1080                    "ask",
1081                    SessionType::send(
1082                        "Utterance",
1083                        brn(&[
1084                            ("token", SessionType::recv("Token", SessionType::var("X"))),
1085                            ("done", SessionType::End),
1086                        ]),
1087                    ),
1088                ),
1089                ("cancel", SessionType::End),
1090            ]),
1091        );
1092        assert!(client.credit_analyse(4).is_ok());
1093        // The dual receiver also conforms — symmetric credit.
1094        assert!(client.dual().credit_analyse(4).is_ok());
1095    }
1096}