# 0001 — Portal preserves RNG state across processes

**Status:** resolved
**Reporter:** Zoe (via fox)
**Implementer:** blackops
**Opened:** 2026-04-20
**Resolved:** 2026-04-24 — xoshiro256\*\* shipped in Python, C, asm; portal round-trips RNG state; `tests/portal-rng-cross-test.sh` proves all 9 producer×consumer cells reproduce the independent single-process Python baseline bit-for-bit. Wired into `make test-all`.

## Problem

Our portal serializes environment bindings and full continuations. It does
not capture any random-number-generator state — because no RNG builtin
exists in any of our three impls today. A simulation that draws randoms,
saves mid-run, and resumes in another process will diverge from a
single-process baseline the moment it calls `(random)`.

Zoe flagged this as a research contribution: *"does portal tech keep the
random seed so we can transfer random entropy between processes when
continuing a simulation?"* Answer today: no. Answer after this ticket:
yes, bit-for-bit identical across Python ↔ C ↔ asm.

## Goals

1. Pick one deterministic, portable PRNG and bind it in all three impls.
2. Expose a minimal builtin API: seed, draw float, draw bounded int,
   introspect/set state.
3. Extend portal-v1 so save/resume round-trips RNG state.
4. Prove cross-impl reproducibility with a test: seed `k`, draw `N`
   values, save, clear, resume in any impl, draw `M` more — full stream
   matches a single-process Python baseline.

## Non-goals

- Statistical quality beyond xoshiro256\*\*'s documented properties.
- Thread-local RNG (one global stream per process; matches current
  `__thread` portal checkpoint discipline in C).
- Cryptographic strength.
- Rejection-sampling uniformity for `(random-int n)` — plain modulo,
  bias acceptable for `n << 2^64`.

## Algorithm — xoshiro256\*\*

State: four `uint64_t` words `s[0..3]`.

```
rotl(x, k) = (x << k) | (x >> (64 - k))

next():
    result = rotl(s[1] * 5, 7) * 9
    t      = s[1] << 17
    s[2]  ^= s[0]
    s[3]  ^= s[1]
    s[1]  ^= s[2]
    s[0]  ^= s[3]
    s[2]  ^= t
    s[3]   = rotl(s[3], 45)
    return result
```

Seeding uses splitmix64 from a single 64-bit seed:

```
splitmix64(&z):
    z += 0x9e3779b97f4a7c15
    z  = (z ^ (z >> 30)) * 0xbf58476d1ce4e5b9
    z  = (z ^ (z >> 27)) * 0x94d049bb133111eb
    return z ^ (z >> 31)

seed(k):
    z = k
    for i in 0..3: s[i] = splitmix64(&z)
```

Chosen because:
- Public domain reference (Blackman & Vigna, 2018).
- Identical bit output on any machine — no libc, no FPU, no `rand()`.
- Four u64 words → same wire format across Python, C, asm.
- Small code footprint — asm port is ~40 instructions.

## Builtin API

| builtin | args | returns |
|---|---|---|
| `(random-seed! k)` | int `k` | void — reseeds from `k` via splitmix64 |
| `(random)` | — | float in `[0.0, 1.0)` — top 53 bits / 2^53 |
| `(random-int n)` | positive int `n` | int in `[0, n)` — raw modulo |
| `(random-state)` | — | list of 8 ints: `(w0_lo w0_hi w1_lo w1_hi w2_lo w2_hi w3_lo w3_hi)` each in `[0, 2^32)` |
| `(random-state! s)` | list of 8 ints | void — restores state from the list |

Rationale for the 8-int representation: each 64-bit word split into low
and high 32-bit halves. Fits Lumbda's 48-bit C int and 61-bit asm int
without widening. Portable over the S-expression portal too.

Default seed at interpreter startup: `0` (all impls). Deterministic from
process start means tests don't need to seed explicitly unless they
want a specific stream.

## Portal format changes

### JSON (Python + C) — lumbda-portal-v1

Add optional top-level key `"rng"` to the existing `lumbda-portal-v1`
envelope. No magic bump required — absent field means "assume seed 0".

```json
{
  "format": "lumbda-portal-v1",
  "env": {...},
  "continuation": ...,
  "rng": {
    "algo": "xoshiro256**",
    "state": [w0_lo, w0_hi, w1_lo, w1_hi, w2_lo, w2_hi, w3_lo, w3_hi]
  }
}
```

### Asm binary — LUMBDAB2

Bump magic from `LUMBDAB1` to `LUMBDAB2`. Extend `PORTAL_HDR_SIZE` from
48 to 80 bytes: append 32 bytes of raw RNG state (4 × u64, little-endian,
native layout).

Old `LUMBDAB1` files rejected during resume. (Portal is not yet
externally released, so no migration burden.)

### Asm text (GC build) — `;; lumbda-portal v1`

Append one comment line after the header:

```
;; lumbda-portal v1
;; rng xoshiro256** w0_lo w0_hi w1_lo w1_hi w2_lo w2_hi w3_lo w3_hi
...
```

Parser recognizes the `;; rng ` prefix and calls `random-state!`
internally.

## Test plan

1. **Unit** — seed `42`, first 5 draws match a hardcoded Python baseline
   bit-for-bit. Asserted in all three impls.
2. **Functional** — `tests/functional.lsp` gets determinism tests.
3. **Portal round-trip** — `tests/portal-rng-save.lsp` seeds, draws 50,
   saves, exits. `tests/portal-rng-load.lsp` resumes, draws 50 more,
   compares against full 100-draw baseline.
4. **Cross-impl** — wire into `tests/portal-cross-test.sh`. Python saves,
   C resumes, next draws match. All 9 producer×consumer cells.
5. **MOAD scan** — `~/git/unmoad.com/unmoad` on all changed files.

## Deliverables

- [ ] `docs/tickets/0001-portal-rng.md` (this ticket)
- [ ] Python: `lumbda.py` — xoshiro256\*\* + 5 builtins + portal hooks
- [ ] C: `c/builtins.c` + `c/portal.c` + `c/lumbda.h` — same
- [ ] Asm: `asm/lumbda.s` — same, plus LUMBDAB2 header
- [ ] Shared tests: `tests/functional.lsp` determinism block
- [ ] Cross tests: `tests/portal-rng-save.lsp`, `tests/portal-rng-load.lsp`
- [ ] `tests/portal-cross-test.sh` — new RNG cells
- [ ] Asm test: `asm/test.sh` determinism checks
- [ ] `make test-all` green
- [ ] `unmoad` clean on all changed files
