portal-rng: Python + C impls of xoshiro256** + portal state capture

Zoe's contribution question: does our portal preserve RNG state so a
simulation can continue in another process with the same random stream?
Answer today: no — no RNG existed. Answer now (Python + C): yes, bit-identical.

- New builtins: random-seed!, random, random-int, random-state, random-state!
- xoshiro256** (Blackman & Vigna 2018) — deterministic, portable, no libc rand
- State = 4 x u64; portal-v1 JSON gains 'rng' field with 8 x u32 halves
- Python and C produce bit-identical streams (verified: seed=42, 10 draws)
- Asm impl + cross-impl tests + whitepaper note: next commits

Ticket: docs/tickets/0001-portal-rng.md
This commit is contained in:
russell@unturf.com 2026-04-20 10:57:39 -04:00
parent 6e9d3ea52f
commit 27f468c5b7
3 changed files with 366 additions and 0 deletions

View file

@ -0,0 +1,161 @@
# 0001 — Portal preserves RNG state across processes
**Status:** open
**Reporter:** Zoe (via fox)
**Implementer:** blackops
**Opened:** 2026-04-20
## 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