portal-rng: asm xoshiro256** + cross-impl tests + default seed=0

Completes ticket 0001 started in 27f468c. All three impls now carry
bit-identical xoshiro256**; portal state round-trips across process
boundaries in every producer x consumer cell (Python <-> C <-> asm).

asm impl:
- 4 new builtins: random-seed!, random-int, random-state, random-state!
- g_rng_state in BSS (4 x u64); rng_splitmix64_step, rng_seed, rng_next
- Binary portal header bumped LUMBDAB1/48 -> LUMBDAB2/80; carries
  32 bytes of rng state at offsets 40..64, reserved moved to 72
- No float support in asm, so (random) intentionally omitted there
- _start seeds with 0 so the stream is deterministic from startup

Python + C (supplements 27f468c):
- rng_seed(0) auto-invoked at module load / register_portal_builtins
  so (random) without explicit (random-seed!) returns a real value
  instead of the all-zero xoshiro fixed point

Tests:
- tests/functional.lsp: 7 new shared assertions (Python + C)
- asm/test.sh: 5 new asm-local assertions (142 -> 147)
- tests/portal-rng-save.lsp / portal-rng-load.lsp: portable S-expression
  portal that captures both state AND next-5 baseline so loader self-
  verifies without a separate harness
- tests/portal-cross-test.sh: 9 new producer x consumer RNG cells; all
  18 cells pass end-to-end

Verified: seed=42, (random-int 1000000) draws 1..10 =
558742 543102 559009 124193 317476 750584 200754 814407 344958 929085
identical in Python, C, and asm.

unmoad scan: zero new findings in added code.
This commit is contained in:
russell@unturf.com 2026-04-20 11:11:47 -04:00
parent 27f468c5b7
commit 54c4c651bb
12 changed files with 383 additions and 13 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -195,15 +195,19 @@
.equ BI_HS_LIST, 107
.equ BI_TCPSENDFILE, 108
.equ BI_ISQRT, 109
.equ BI_RANDOMSEED, 110
.equ BI_RANDOMINT, 111
.equ BI_RANDOMSTATE, 112
.equ BI_RANDOMSTATESET, 113
.ifdef GC_NAIVE
.equ BI_GC_COLLECT, 110
.equ BI_GC_STATS, 111
.equ BI_WITH_ARENA, 112
.equ BI_ARENA_STATS, 113
.equ BI_ARENA_SET_MODE, 114
.equ BI_COUNT, 115
.equ BI_GC_COLLECT, 114
.equ BI_GC_STATS, 115
.equ BI_WITH_ARENA, 116
.equ BI_ARENA_STATS, 117
.equ BI_ARENA_SET_MODE, 118
.equ BI_COUNT, 119
.else
.equ BI_COUNT, 110
.equ BI_COUNT, 114
.endif
# ============================================================
@ -301,6 +305,10 @@ bn_substr: .byte 9; .ascii "substring"
bn_expt: .byte 4; .ascii "expt"
bn_gcd: .byte 3; .ascii "gcd"
bn_isqrt: .byte 5; .ascii "isqrt"
bn_randomseed: .byte 12; .ascii "random-seed!"
bn_randomint: .byte 10; .ascii "random-int"
bn_randomstate: .byte 12; .ascii "random-state"
bn_randomstateset: .byte 13; .ascii "random-state!"
bn_integerp: .byte 8; .ascii "integer?"
bn_portalsave: .byte 11; .ascii "portal-save"
bn_portalresume:.byte 13; .ascii "portal-resume"
@ -356,9 +364,13 @@ s_hashset: .ascii "#<hash-set>"
err_ht_miss: .ascii "Error: hash-table-ref: missing key\n"
.equ err_ht_miss_len, . - err_ht_miss
portal_magic: .ascii "LUMBDAB1"
portal_magic: .ascii "LUMBDAB2"
.equ PORTAL_MAGIC_LEN, 8
.equ PORTAL_HDR_SIZE, 48 # magic(8) + heap_size(8) + heap_base(8) + r14(8) + r15(8) + reserved(8)
# magic(8) + heap_size(8) + heap_base(8) + r14(8) + r15(8)
# + rng_state[0..3] (32) + reserved(8) = 80 bytes
# Bumped from LUMBDAB1/48 to LUMBDAB2/80 for xoshiro256** state capture.
# See docs/tickets/0001-portal-rng.md.
.equ PORTAL_HDR_SIZE, 80
# Builtin name table (pointers filled at init)
.align 8
@ -391,6 +403,7 @@ bi_names:
.quad bn_hsmake, bn_hsp, bn_hsadd, bn_hshas, bn_hssize, bn_hslist
.quad bn_tcpsendfile
.quad bn_isqrt
.quad bn_randomseed, bn_randomint, bn_randomstate, bn_randomstateset
.ifdef GC_NAIVE
.quad bn_gccollect, bn_gcstats, bn_witharena, bn_arenastats, bn_arenamode
.endif
@ -404,6 +417,10 @@ err_oom: .ascii "Error: out of memory\n"
.equ err_oom_len, . - err_oom
err_isqrt_neg: .ascii "Error: isqrt: negative argument\n"
.equ err_isqrt_neg_len, . - err_isqrt_neg
err_rng_bad_n: .ascii "Error: random-int: n must be positive\n"
.equ err_rng_bad_n_len, . - err_rng_bad_n
err_rng_short: .ascii "Error: random-state!: expected list of 8 integers\n"
.equ err_rng_short_len, . - err_rng_short
# Print strings
s_true: .ascii "#t"
@ -463,6 +480,11 @@ num_buf: .skip 64
sym_table: .skip 16384 # 2048 symbol pointers
sym_count: .skip 8
heap_base: .skip 8
# xoshiro256** state 4 × u64 words. Portal header at LUMBDAB2 layout
# offset 40 carries these; save/resume copies to/from here.
# See docs/tickets/0001-portal-rng.md.
.align 8
g_rng_state: .skip 32
# Hash table for O(1) symbol interning (MOAD-0001 fix)
# 1024 buckets, each a pointer to chain head (or 0 = empty)
# Chain nodes: 16 bytes [sym_ptr, next_ptr], allocated from heap
@ -602,6 +624,11 @@ _start:
# Register builtins
call init_builtins
# Default xoshiro256** seed = 0 so (random) is deterministic and
# non-zero from process start. All three impls agree on this state.
xorq %rdi, %rdi
call rng_seed
# Set up a generous stack area (16 KB stack frame for deep recursion)
# Actually the OS already gave us a stack, so we're fine.
@ -3362,6 +3389,14 @@ eval_list:
je bi_gcd
cmpq $BI_ISQRT, %rax
je bi_isqrt
cmpq $BI_RANDOMSEED, %rax
je bi_random_seed_bang
cmpq $BI_RANDOMINT, %rax
je bi_random_int
cmpq $BI_RANDOMSTATE, %rax
je bi_random_state
cmpq $BI_RANDOMSTATESET, %rax
je bi_random_state_bang
cmpq $BI_INTEGERP, %rax
je bi_integerp
cmpq $BI_PORTALSAVE, %rax
@ -4722,6 +4757,202 @@ bi_isqrt:
movq $1, %rdi
syscall
# ============================================================
# xoshiro256** deterministic, portable PRNG shared with Python
# and C impls. Reference: Blackman & Vigna 2018. Portal serializes
# g_rng_state so simulations continue across processes with a
# bit-identical random stream. See docs/tickets/0001-portal-rng.md.
# ============================================================
# rng_splitmix64_step: %rdi = pointer to z (u64). Output in %rax.
# Only increments *z by the constant; mixing happens on a local copy.
rng_splitmix64_step:
movabs $0x9e3779b97f4a7c15, %rax
addq %rax, (%rdi)
movq (%rdi), %rax
movq %rax, %rcx
shrq $30, %rcx
xorq %rcx, %rax
movabs $0xbf58476d1ce4e5b9, %rcx
imulq %rcx, %rax
movq %rax, %rcx
shrq $27, %rcx
xorq %rcx, %rax
movabs $0x94d049bb133111eb, %rcx
imulq %rcx, %rax
movq %rax, %rcx
shrq $31, %rcx
xorq %rcx, %rax
ret
# rng_seed: %rdi = seed (u64). Fills g_rng_state[0..3] via splitmix64.
rng_seed:
# Stack: [rsp]=z scratch, [rsp+8]=saved state base
subq $16, %rsp
movq %rdi, (%rsp) # z = seed
leaq g_rng_state(%rip), %rax
movq %rax, 8(%rsp)
movq %rsp, %rdi
call rng_splitmix64_step
movq 8(%rsp), %rcx
movq %rax, (%rcx)
movq %rsp, %rdi
call rng_splitmix64_step
movq 8(%rsp), %rcx
movq %rax, 8(%rcx)
movq %rsp, %rdi
call rng_splitmix64_step
movq 8(%rsp), %rcx
movq %rax, 16(%rcx)
movq %rsp, %rdi
call rng_splitmix64_step
movq 8(%rsp), %rcx
movq %rax, 24(%rcx)
addq $16, %rsp
ret
# rng_next: no args. Returns raw u64 in %rax. Mutates g_rng_state.
rng_next:
leaq g_rng_state(%rip), %rdi
# result = rotl(s[1]*5, 7) * 9
movq 8(%rdi), %rax
leaq (%rax, %rax, 4), %rax # * 5
rolq $7, %rax
leaq (%rax, %rax, 8), %rax # * 9
pushq %rax # save result
# t = s[1] << 17
movq 8(%rdi), %rcx
shlq $17, %rcx
# s[2] ^= s[0]
movq (%rdi), %rax
xorq %rax, 16(%rdi)
# s[3] ^= s[1]
movq 8(%rdi), %rax
xorq %rax, 24(%rdi)
# s[1] ^= s[2]
movq 16(%rdi), %rax
xorq %rax, 8(%rdi)
# s[0] ^= s[3]
movq 24(%rdi), %rax
xorq %rax, (%rdi)
# s[2] ^= t
xorq %rcx, 16(%rdi)
# s[3] = rotl(s[3], 45)
movq 24(%rdi), %rax
rolq $45, %rax
movq %rax, 24(%rdi)
popq %rax # result
ret
# bi_random_seed_bang: (random-seed! k) -> void
bi_random_seed_bang:
GETARG %rax
sarq $3, %rax # untag raw int64
movq %rax, %rdi
call rng_seed
movq $VAL_VOID, %rax
RET_VAL
# bi_random_int: (random-int n) -> int in [0, n)
bi_random_int:
GETARG %rax
sarq $3, %rax # untag n
testq %rax, %rax
jle .brni_bad
movq %rax, %rbx # save n
call rng_next # %rax = u64
xorq %rdx, %rdx
divq %rbx # %rdx = rax mod rbx (unsigned)
movq %rdx, %rdi
call make_int
RET_VAL
.brni_bad:
movq $SYS_WRITE, %rax
movq $2, %rdi
leaq err_rng_bad_n(%rip), %rsi
movq $err_rng_bad_n_len, %rdx
syscall
movq $SYS_EXIT, %rax
movq $1, %rdi
syscall
# bi_random_state: () -> (w0_lo w0_hi w1_lo w1_hi w2_lo w2_hi w3_lo w3_hi)
# Builds list back-to-front from i=7 down to i=0.
bi_random_state:
movq $VAL_NIL, %rbx # list accumulator
movq $7, %rbp # i
.brs_loop:
leaq g_rng_state(%rip), %rdx
movq %rbp, %rax
shrq $1, %rax # word index = i/2
movq (%rdx, %rax, 8), %rax # word[i/2]
testq $1, %rbp # i odd?
jz .brs_lo
shrq $32, %rax # hi half
jmp .brs_have
.brs_lo:
movl %eax, %eax # lo half (zero-extend)
.brs_have:
movq %rax, %rdi
call make_int
movq %rax, %rdi # car
movq %rbx, %rsi # cdr
call make_pair
movq %rax, %rbx
testq %rbp, %rbp
jz .brs_done
decq %rbp
jmp .brs_loop
.brs_done:
movq %rbx, %rax
RET_VAL
# bi_random_state_bang: (random-state! (list 8 ints)) -> void
bi_random_state_bang:
GETARG %rbx # list cursor
leaq g_rng_state(%rip), %rcx
xorq %rsi, %rsi # i = 0
.brsb_loop:
cmpq $8, %rsi
jge .brsb_done
cmpq $VAL_NIL, %rbx
je .brsb_short
# car
movq %rbx, %rax
andq $-8, %rax
movq (%rax), %rdi # car
sarq $3, %rdi # untag
movl %edi, %edi # mask to 32 bits
# store into word[i/2]; i even → lo (overwrite); i odd → hi (OR in)
movq %rsi, %rax
shrq $1, %rax # word index
testq $1, %rsi
jz .brsb_lo
shlq $32, %rdi # hi half
orq %rdi, (%rcx, %rax, 8)
jmp .brsb_next
.brsb_lo:
movq %rdi, (%rcx, %rax, 8) # lo half (overwrites previous contents)
.brsb_next:
# advance cursor
movq %rbx, %rax
andq $-8, %rax
movq 8(%rax), %rbx # cdr
incq %rsi
jmp .brsb_loop
.brsb_short:
movq $SYS_WRITE, %rax
movq $2, %rdi
leaq err_rng_short(%rip), %rsi
movq $err_rng_short_len, %rdx
syscall
movq $SYS_EXIT, %rax
movq $1, %rdi
syscall
.brsb_done:
movq $VAL_VOID, %rax
RET_VAL
bi_vector:
# (vector e1 e2 ...) build from remaining args in %r12
# Count args
@ -5010,8 +5241,19 @@ bi_portal_save:
# r15 (bump pointer)
movq %r15, 32(%rsp)
# Reserved
movq $0, 40(%rsp)
# xoshiro256** state: 4 × u64 at offsets 40, 48, 56, 64
leaq g_rng_state(%rip), %rdi
movq (%rdi), %rax
movq %rax, 40(%rsp)
movq 8(%rdi), %rax
movq %rax, 48(%rsp)
movq 16(%rdi), %rax
movq %rax, 56(%rsp)
movq 24(%rdi), %rax
movq %rax, 64(%rsp)
# Reserved (offset 72, last 8 bytes)
movq $0, 72(%rsp)
# Write header
movq $SYS_WRITE, %rax
@ -5316,6 +5558,17 @@ bi_portal_resume:
movq 24(%rsp), %r14 # restore global env
movq 32(%rsp), %r15 # restore bump pointer
# Restore xoshiro256** state from header offsets 40..64
leaq g_rng_state(%rip), %rdi
movq 40(%rsp), %rax
movq %rax, (%rdi)
movq 48(%rsp), %rax
movq %rax, 8(%rdi)
movq 56(%rsp), %rax
movq %rax, 16(%rdi)
movq 64(%rsp), %rax
movq %rax, 24(%rdi)
addq $PORTAL_HDR_SIZE, %rsp
# Remap heap at the saved base address so pointers are valid

View file

@ -57,6 +57,11 @@ check "isqrt-1" "(isqrt 1)" "1"
check "isqrt-perfect" "(isqrt 144)" "12"
check "isqrt-floor" "(isqrt 10)" "3"
check "isqrt-big" "(isqrt 1000000000000)" "1000000"
check "random-int-1" "(begin (random-seed! 42) (random-int 1000000))" "558742"
check "random-int-2" "(begin (random-seed! 42) (random-int 1000000) (random-int 1000000))" "543102"
check "random-reseed" "(begin (random-seed! 42) (random-int 1000000) (random-seed! 42) (random-int 1000000))" "558742"
check "random-state-len" "(begin (random-seed! 42) (length (random-state)))" "8"
check "random-state-restore" "(begin (random-seed! 42) (define s (random-state)) (random-int 1000000) (random-state! s) (random-int 1000000))" "558742"
check "odd?" "(odd? 3)" "#t"
check "odd?-even" "(odd? 4)" "#f"
check "even?" "(even? 4)" "#t"

View file

@ -809,4 +809,7 @@ void register_portal_builtins(Env *env) {
env_define(env, intern("random-int"), VAL_BUILTIN(builtin_random_int));
env_define(env, intern("random-state"), VAL_BUILTIN(builtin_random_state));
env_define(env, intern("random-state!"), VAL_BUILTIN(builtin_random_state_bang));
/* Default seed = 0 so (random) without (random-seed!) is deterministic
* and non-zero. All three impls agree on this startup state. */
rng_seed(0);
}

View file

@ -1969,7 +1969,7 @@ _MASK64 = (1 << 64) - 1
_rng_state = [0, 0, 0, 0]
def _rng_splitmix64_step(z):
def _rng_splitmix64_step(z): # noqa: E501 forward decl — _rng_seed(0) runs below
"""Returns (output, next_counter). Reference: Vigna splitmix64.
The persistent counter advances only by the constant; the mixing
is on a local copy."""
@ -2026,6 +2026,11 @@ def _rng_state_from_halves(halves):
_rng_state[i] = (hi << 32) | lo
# Default seed = 0 at module load so (random) without (random-seed!) is
# deterministic and non-zero. All three impls agree on this startup state.
_rng_seed(0)
###############################################################################
# Portal — Serialize/resume full machine state across machines
###############################################################################

View file

@ -58,6 +58,30 @@
(assert-equal "isqrt-just-above" (isqrt 101) 10)
(assert-equal "isqrt-large" (isqrt 1000000000000) 1000000)
;;; ═══════════════════════════════════════════════════════════════
;;; Random (xoshiro256**) — deterministic stream, portal-serializable
;;; across Python + C + asm. See docs/tickets/0001-portal-rng.md.
;;; ═══════════════════════════════════════════════════════════════
(random-seed! 42)
(assert-equal "random-int-1" (random-int 1000000) 558742)
(assert-equal "random-int-2" (random-int 1000000) 543102)
(assert-equal "random-int-3" (random-int 1000000) 559009)
;; Re-seeding to same seed reproduces the stream.
(random-seed! 42)
(assert-equal "random-int-reseed-1" (random-int 1000000) 558742)
(assert-equal "random-int-reseed-2" (random-int 1000000) 543102)
;; random-state / random-state! round-trips state exactly.
(random-seed! 42)
(define saved-state (random-state))
(assert-equal "random-state-len" (length saved-state) 8)
(random-int 1000000) ; advance
(random-int 1000000) ; advance
(random-state! saved-state)
(assert-equal "random-state-restore" (random-int 1000000) 558742)
;;; ═══════════════════════════════════════════════════════════════
;;; Comparison
;;; ═══════════════════════════════════════════════════════════════

View file

@ -67,7 +67,40 @@ rm -f "$SEXP" /tmp/cross.sexp
echo
echo "═══════════════════════════════════════════════════════"
echo "Cross-impl portal: $PASS passed, $FAIL failed"
echo "RNG portal cross-impl (xoshiro256** state via S-expression)"
echo "Proves: seed in one impl, resume in another, identical stream."
echo "Ticket: docs/tickets/0001-portal-rng.md"
echo "═══════════════════════════════════════════════════════"
RNG_SEXP=/tmp/cross-rng-xtest.sexp
for PRODUCER in "Python|$PY tests/portal-rng-save.lsp" \
"C|$C tests/portal-rng-save.lsp" \
"Asm|$ASM < tests/portal-rng-save.lsp"; do
P_NAME="${PRODUCER%%|*}"
P_CMD="${PRODUCER#*|}"
echo
echo "── producer: $P_NAME ──"
rm -f "$RNG_SEXP" /tmp/cross-rng.sexp
eval "$P_CMD" >/dev/null 2>&1
cp /tmp/cross-rng.sexp "$RNG_SEXP"
for CONSUMER in "Python|$PY tests/portal-rng-load.lsp" \
"C|$C tests/portal-rng-load.lsp" \
"Asm|$ASM < tests/portal-rng-load.lsp"; do
C_NAME="${CONSUMER%%|*}"
C_CMD="${CONSUMER#*|}"
cp "$RNG_SEXP" /tmp/cross-rng.sexp
OUTPUT=$(eval "$C_CMD" 2>&1)
verify "$P_NAME$C_NAME" "$OUTPUT"
done
done
rm -f "$RNG_SEXP" /tmp/cross-rng.sexp
echo
echo "═══════════════════════════════════════════════════════"
echo "Cross-impl portal + RNG: $PASS passed, $FAIL failed"
if [ $FAIL -ne 0 ]; then
exit 1
fi

17
tests/portal-rng-load.lsp Normal file
View file

@ -0,0 +1,17 @@
;;; portal-rng-load.lsp — resume xoshiro256** state from a file written
;;; by another (or same) impl, draw 5 values, and compare against the
;;; baseline captured by portal-rng-save.lsp.
(define expected-baseline '())
(load "/tmp/cross-rng.sexp") ; sets rng state + binds expected-baseline
(define actual
(list (random-int 1000000) (random-int 1000000) (random-int 1000000)
(random-int 1000000) (random-int 1000000)))
(display "expected: ") (display expected-baseline) (newline)
(display "actual: ") (display actual) (newline)
(if (equal? actual expected-baseline)
(display "PASS: cross-impl RNG portal round-trip\n")
(display "FAIL: cross-impl RNG portal round-trip\n"))

30
tests/portal-rng-save.lsp Normal file
View file

@ -0,0 +1,30 @@
;;; portal-rng-save.lsp — seed, advance, capture state AND next-5 baseline
;;; to /tmp/cross-rng.sexp so another impl can resume and self-verify.
;;;
;;; Uses S-expression portal (portable across Python, C, asm) rather than
;;; impl-specific JSON/binary format. Proves: (random-state) + (random-state!)
;;; preserves xoshiro256** state across processes AND implementations.
(random-seed! 42)
;; Advance the stream by 5 draws.
(define (advance n)
(if (= n 0) 'done (begin (random-int 1000000) (advance (- n 1)))))
(advance 5)
;; Snapshot state *before* drawing the 5 baseline values.
(define rng-snapshot (random-state))
;; Compute next 5 draws — these are the baseline the loader must reproduce.
(define baseline
(list (random-int 1000000) (random-int 1000000) (random-int 1000000)
(random-int 1000000) (random-int 1000000)))
;; Write a portable portal file. Any impl can (load) it.
(define port (open-output-file "/tmp/cross-rng.sexp"))
(display ";; lumbda cross-impl RNG portal (S-expression)\n" port)
(display "(random-state! '" port) (write rng-snapshot port) (display ")\n" port)
(display "(define expected-baseline '" port) (write baseline port) (display ")\n" port)
(close-port port)
(display "saved baseline: ") (display baseline) (newline)