# The caller sequence — a cross-platform contract _This document is CC0, and so are the conformance vectors beside it. The mechanism it specifies was dedicated to the public domain when it was published — see claim 17 of ["An Impartially-Called Circulation Sport for the AI Age"](https://thonly.org/research/the-sport-that-says-your-name). Build a client for sey with it; no permission is needed and none can be withheld._ _It is published for a reason that is part of the mechanism rather than a courtesy: the sport's central promise is that touches are distributed equally yet randomly, and that is the one promise a participant cannot check by watching, since over any short window a fair sequence and a rigged one look alike. Because the sequence is a pure function of the roster and a seed declared before play, anyone holding this document can recompute a finished session and see for themselves that nobody was skipped and nobody was protected. Fairness you can recompute outlasts the organization promising it._ --- Two devices in one circle each compute the call sequence **independently**. Only a call _count_ crosses the network; nobody transmits who is next. That is what keeps play working with no connectivity, and it only holds if every client produces **bit-identical** output from the same `(seed, players)`. A client that is one call out of step calls a different name than the circle just heard, with no error to say why. **This document is the contract. Do not reimplement from reading the TypeScript.** Reference implementation: `web/src/caller.ts` in the B-Sey℠ client, which is not public — this document is normative, and is meant to be sufficient without it. Conformance vectors: [`caller-vectors.json`](./caller-vectors.json). --- ## 1. The generator — mulberry32 State is a **32-bit** integer. Every operation wraps at 32 bits. ``` function seededRng(seed): a := int32(seed) on each call: a := int32(a + 0x6D2B79F5) t := imul(a XOR (a >>> 15), a OR 1) t := int32( (t + imul(t XOR (t >>> 7), t OR 61)) XOR t ) return uint32(t XOR (t >>> 14)) / 4294967296 ``` where - `>>>` is a **logical** (unsigned, zero-filling) right shift - `imul(x, y)` is 32-bit multiplication that **discards overflow** — not 64-bit multiplication truncated afterwards in a wider type - `int32` / `uint32` reinterpret the same 32 bits, signed or unsigned - `a OR 1` and `t OR 61` are bitwise OR, not addition **The returned double is exact.** The numerator is an integer below 2³² and the divisor is a power of two, so the quotient is representable in IEEE-754 without rounding. There is no floating-point drift to worry about here or in §2. ### Swift ```swift struct SeededRNG { private var a: UInt32 init(seed: UInt32) { a = seed } mutating func next() -> Double { a = a &+ 0x6D2B79F5 var t = (a ^ (a >> 15)) &* (a | 1) t = (t &+ ((t ^ (t >> 7)) &* (t | 61))) ^ t return Double(t ^ (t >> 14)) / 4294967296.0 } } ``` `&+` and `&*` are the wrapping operators — plain `+` and `*` trap on overflow in Swift and **will crash**, which is at least loud. `>>` on `UInt32` is already logical. ### Kotlin ```kotlin class SeededRng(seed: Int) { private var a: Int = seed fun next(): Double { a += 0x6D2B79F5 var t = (a xor (a ushr 15)) * (a or 1) t = (t + ((t xor (t ushr 7)) * (t or 61))) xor t return ((t xor (t ushr 14)).toLong() and 0xFFFFFFFFL) / 4294967296.0 } } ``` `Int` arithmetic wraps silently, which is what is wanted. Use `ushr`, never `shr`. The `and 0xFFFFFFFFL` is the unsigned reinterpretation before dividing — omit it and every negative result flips sign. ## 2. The shuffle — Fisher-Yates, descending ``` function shuffled(items, rng): out := copy(items) for i from length(out) - 1 down to 1: j := floor(rng() * (i + 1)) swap out[i], out[j] return out ``` Exactly this order. Ascending Fisher-Yates, or drawing `j` before `i`, consumes the generator differently and diverges immediately. `floor(rng() * (i+1))` is exact for any circle size a human can stand in. ## 3. The bag — equal touch Each bag holds an ordered queue, the id it last dealt, and two flags that carry the round boundary out to the caller. ``` setMembers(ids): members := copy(ids) queue := queue filtered to ids present in members # order preserved if queue is empty: refill() refill(): if members is empty: queue := []; return queue := shuffled(members, rng) fresh := true if length(members) > 1 and queue[0] == last: move queue[0] to the end of queue take(): if members is empty: return null if queue is empty: refill() opened := fresh # did THIS draw open a new round fresh := false id := remove first element of queue last := id return id startedRound := opened # about the id take() just returned ``` The rotate-on-collision in `refill` is the only case a shuffle cannot rule out: the same person closing one round and opening the next. It is a **rotation**, not a swap. `startedRound` is a property of **the draw that just happened**, set by `refill` and consumed by the next `take`. It cannot be inferred from outside the bag — only the bag knows whether the id it just handed over came from a queue it had to rebuild first. Note `setMembers` **preserves surviving queue order** and only refills when the queue is emptied. Rebuilding the queue on every membership change consumes the generator differently and diverges. ## 4. The circle ### 4.1 Formats The **format** decides who calls, and it is **part of the determinism contract** — not a presentation setting. It changes the receiver bag's membership, so the same `(seed, players)` deals different names under different formats. | Format | Who calls | In the receiving pool | Floor | | ------- | ---------------------------------- | --------------------- | ----- | | `relay` | a player, rotating | everyone but them | 4 | | `call` | the device, aloud to the circle | everyone | 3 | | `free` | nobody — no call sequence at all | — | — | The floors come from one rule: a bag of two deals a strict alternation everyone can predict, so the pool needs **three**. Where a player calls they are not receiving, and three receivers costs four people; where the device calls, nobody sits out and three people are three receivers. `free` produces no sequence. Everything below is for the caller-on formats, and a client implementing `free` has nothing to compute. **Transmit the format with the roster.** Two clients agreeing on the seed and the roster but not the format compute different names with nothing to signal it. ### 4.2 Construction ``` construct(players, mode, level, format, seed): receiverRng := seededRng(seed) callerRng := seededRng(uint32(seed XOR 0x9E3779B9)) receivers := Bag(receiverRng) callers := Bag(callerRng) callerId := null callsThisCaller := 0 index := 0 setPlayers(players) ``` Two streams from one seed so the receiver order and the caller order neither collide nor drift. `0x9E3779B9` is the golden-ratio constant; the XOR is computed on **unsigned** 32 bits. ``` setPlayers(players): this.players := copy(players) if format is not relay: refreshReceiverPool() # the caller bag is never touched return callers.setMembers(ids of players) if callerId is null or callerId not among players: rotateCaller() else: refreshReceiverPool() refreshReceiverPool(): if format is relay: receivers.setMembers(ids of players excluding callerId) else: receivers.setMembers(ids of players) rotateCaller(): callerId := callers.take() callsThisCaller := 0 refreshReceiverPool() callsPerCaller := max(1, length(players) - 1) # unless explicitly overridden ``` In `relay` the caller is **excluded from the receiving pool while calling** and returns when the role rotates. In `call` nobody is excluded and `callerId` stays null for the life of the circle — **do not draw from the caller bag**, or the unused stream advances and a later reading of it diverges. ``` next(): if format is free: return null if length(players) < floor(format): return null if format is relay and callsThisCaller >= callsPerCaller: rotateCaller() receiverId := receivers.take() roundStart := receivers.startedRound if receiverId is null: return null if format is relay and callerId is null: return null callsThisCaller += 1 index += 1 return { receiver, caller, index, roundStart, ...level properties } ``` `caller` is **null** in the caller-on-device formats. It is a value, not a missing field: a client that treats it as absent and substitutes a player is wrong about the sport. **Order matters.** Rotation is checked _before_ the receiver is drawn, and `roundStart` is read from the bag **after** `take()` — it describes the draw that just happened (§3). Two corrections in this section against earlier revisions of this document, both worth knowing if you built a client from one: - The floor said `< 3` while every reference implementation used four. A client following the spec produced three-player relay sequences the reference refuses. It is now per format, with its reason. - `roundStart` was specified as `remaining == 0`, read _before_ the draw. That is not equivalent, and in relay it never fired at all — the queue is refilled at the boundary by `rotateCaller` one line earlier, so it was never empty when the question was asked. The round mark was false on every call, including the first. Neither is covered by `caller-vectors.json`, which carries no `roundStart` column and had no case below four. That is how both survived to be published. ## 5. Catching up ``` fastForward(toIndex): while index < toIndex and next() is not null: discard ``` A client that joins late, reconnects or reloads rebuilds the circle from the same `(seed, players)` and fast-forwards to the shared count. A few hundred shuffles is nothing. **Players must be in the same order on every client.** The bag shuffles the member list, so a differently ordered roster is a different shuffle. Transmit the roster as an ordered list, never as a set or a dictionary. ## 6. Conformance `caller-vectors.json` carries generated cases: | Case | Checks | | ------------------------ | ----------------------------------------------------------------------- | | `prng_seed_*` | eight raw `uint32` draws and their scaled doubles | | `shuffle_seed_*` | one seven-element shuffle | | `sequence_n*_seed*` | twenty-four `(index, caller, receiver)` triples in **relay**, n = 4, 7, 12 | | `sequence_call_n*_seed*` | the same in **call**, n = 3, 4, 7 — `caller` is `null` throughout | Every sequence case carries a `format` field. The relay keys deliberately do **not** name their format: they were published before the axis existed, their sequences are unchanged by it, and renaming a published key is a worse compatibility break than the one it would tidy. Read `format` if present and default to `relay`. Compare the `uint32` column first — if that is wrong, nothing downstream can be right, and the cause is almost always a signed shift or a non-wrapping multiply. Regenerate after any change to the reference: ```bash cd web && node ../shared/generate-vectors.mjs ``` Changing the sequence for an existing seed is a **breaking change**: circles saved on one client will replay differently on another. If it ever becomes necessary, version the seed rather than the algorithm. The format axis was added this way and is the worked example: `relay` is the default everywhere a format is absent — in stored circles, in shared links, in these vectors — so every sequence that had already shipped still replays byte for byte, and the new format simply has sequences nobody had yet. ## 7. What this contract is protecting The determinism is not a performance trick. It is what lets a second phone join a circle without a live connection, and it is what makes the distribution **auditable** — you can reproduce exactly who was called and when, which for a sport whose central promise is fairness is worth more than the sync. Both properties die silently if two clients disagree by one draw.