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}