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:
parent
6e9d3ea52f
commit
27f468c5b7
3 changed files with 366 additions and 0 deletions
161
docs/tickets/0001-portal-rng.md
Normal file
161
docs/tickets/0001-portal-rng.md
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue