Closes tickets 0001 (portal-rng) and 0002 (os-entropy-seed). Ticket 0001 goal 4 called for proof that seeding with k, drawing N, saving, clearing, resuming in any impl, and drawing M more produces a full stream matching a single-process Python baseline bit-for-bit. Prior tests/portal-cross-test.sh exercised producer-consumer agreement but used a producer-side self-computed baseline; it did not compare against an independent Python run that never saves or resumes. tests/portal-rng-cross-test.sh computes a single-process Python baseline once (seed=42, N+M=10 draws, no portal), then runs all 9 producer x consumer cells (Python, C, asm each side) and checks that producer's first N plus consumer's M equals the independent baseline. All 12 assertions pass. Wired into make test-all. Ticket status updated to resolved on both 0001 and 0002 with dated one-line resolution notes.
5.5 KiB
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
- Pick one deterministic, portable PRNG and bind it in all three impls.
- Expose a minimal builtin API: seed, draw float, draw bounded int, introspect/set state.
- Extend portal-v1 so save/resume round-trips RNG state.
- Prove cross-impl reproducibility with a test: seed
k, drawNvalues, save, clear, resume in any impl, drawMmore — 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
__threadportal checkpoint discipline in C). - Cryptographic strength.
- Rejection-sampling uniformity for
(random-int n)— plain modulo, bias acceptable forn << 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".
{
"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
- Unit — seed
42, first 5 draws match a hardcoded Python baseline bit-for-bit. Asserted in all three impls. - Functional —
tests/functional.lspgets determinism tests. - Portal round-trip —
tests/portal-rng-save.lspseeds, draws 50, saves, exits.tests/portal-rng-load.lspresumes, draws 50 more, compares against full 100-draw baseline. - Cross-impl — wire into
tests/portal-cross-test.sh. Python saves, C resumes, next draws match. All 9 producer×consumer cells. - MOAD scan —
~/git/unmoad.com/unmoadon 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.lspdeterminism 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.shdeterminism checks make test-allgreenunmoadclean on all changed files