diff --git a/asm/lumbda b/asm/lumbda index 6cf4790..1ef3a7b 100755 Binary files a/asm/lumbda and b/asm/lumbda differ diff --git a/asm/lumbda-gc b/asm/lumbda-gc index 55eba6e..5f58cf6 100755 Binary files a/asm/lumbda-gc and b/asm/lumbda-gc differ diff --git a/asm/lumbda-gc.o b/asm/lumbda-gc.o index 29f33f3..b418552 100644 Binary files a/asm/lumbda-gc.o and b/asm/lumbda-gc.o differ diff --git a/asm/lumbda.o b/asm/lumbda.o index 8e1a7e1..7470d5c 100644 Binary files a/asm/lumbda.o and b/asm/lumbda.o differ diff --git a/asm/lumbda.s b/asm/lumbda.s index 0d2fd50..26101e3 100644 --- a/asm/lumbda.s +++ b/asm/lumbda.s @@ -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 "#" 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 diff --git a/asm/test.sh b/asm/test.sh index d11bfe6..d8e1058 100644 --- a/asm/test.sh +++ b/asm/test.sh @@ -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" diff --git a/c/portal.c b/c/portal.c index e2382b4..7eee21b 100644 --- a/c/portal.c +++ b/c/portal.c @@ -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); } diff --git a/lumbda.py b/lumbda.py index f213e8a..0262e3e 100644 --- a/lumbda.py +++ b/lumbda.py @@ -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 ############################################################################### diff --git a/tests/functional.lsp b/tests/functional.lsp index 8d16486..a632ea2 100644 --- a/tests/functional.lsp +++ b/tests/functional.lsp @@ -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 ;;; ═══════════════════════════════════════════════════════════════ diff --git a/tests/portal-cross-test.sh b/tests/portal-cross-test.sh index a982dda..41cef62 100755 --- a/tests/portal-cross-test.sh +++ b/tests/portal-cross-test.sh @@ -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 diff --git a/tests/portal-rng-load.lsp b/tests/portal-rng-load.lsp new file mode 100644 index 0000000..953acf3 --- /dev/null +++ b/tests/portal-rng-load.lsp @@ -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")) diff --git a/tests/portal-rng-save.lsp b/tests/portal-rng-save.lsp new file mode 100644 index 0000000..8020132 --- /dev/null +++ b/tests/portal-rng-save.lsp @@ -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)