Design-only ticket. C's 48-bit NaN-boxed TAG_INT silently truncates any result > 2^47 (e.g. 10^16 becomes -133099161583616), while Python (bignum) and asm (61-bit) compute correctly. Recommends heap-allocated bigint via new TAG_BIGINT, preserving NaN-boxing and JIT fast path for the inline 48-bit common case. Phased migration with Phase 0 fail-loud stopgap before the full bignum lands.
16 KiB
0003 — Widen C integer domain to match Python and asm
Status: open (design only — no code changes in this ticket) Reporter: blackops (audit finding, prompted by fox's isqrt review) Implementer: tbd Opened: 2026-04-24
Problem
Our three implementations disagree on integer width:
| impl | integer representation | exact range |
|---|---|---|
Python (lumbda.py) |
native int |
unbounded (bignum) |
C (c/lumbda.h) |
NaN-boxed 48-bit payload, TAG_INT = 1 |
±2^47 = ±140,737,488,355,328 |
asm (asm/lumbda.s) |
tagged int, sarq $3 / shlq $3 |
61-bit signed = ±2^60 |
C's ceiling is the lowest. Any integer magnitude > 2^47 silently
truncates in C because VAL_INT(n) masks with PAYLOAD_MASK
(0xFFFFFFFFFFFF). This breaks the "bit-for-bit identical across
Python ↔ C ↔ asm" claim made in ticket 0001 and in the whitepaper's
portal section.
Concrete repro
cd ~/git/lumbda
cat > /tmp/bigint-test.lsp <<'EOF'
(display (* 1000000 1000000)) (newline) ; 10^12 — fits 48-bit
(display (* 10000000 10000000)) (newline) ; 10^14 — fits 48-bit
(display (* 100000000 100000000)) (newline) ; 10^16 — overflows 48-bit
EOF
python3 lumbda.py /tmp/bigint-test.lsp
# 1000000000000
# 100000000000000
# 10000000000000000 ← correct
./asm/lumbda /tmp/bigint-test.lsp
# 1000000000000
# 100000000000000
# 10000000000000000 ← correct (61-bit tagged int)
./c/lumbda /tmp/bigint-test.lsp
# 1000000000000
# 100000000000000
# -133099161583616 ← silent truncation, not even an error
Exposure surface
*,expt,+composition: anything producing > 2^47.(random-int n)(ticket 0001) withn > 2^47.(random-state)returns eight integers in[0, 2^32)— fits today, but any future design that stores 64-bit words directly breaks.- Portal round-trip: Python seeds, saves a u64 counter at 10^15, C
resumes and reads a truncated value.
tests/portal-cross-test.shdoes not yet exercise this because its workloads stay under 2^47. isqrtfrom commit6e9d3eahappens to hide the defect (output is always smaller than input) but its callers (cryptographic digest code, simulation step counters) do not.
Why fox's isqrt review caught this
isqrt of a 10^18 input would need a 61-bit argument. Today Python and asm accept it; C silently truncates the input before isqrt even runs. No error, no warning — a correct-looking but wrong result.
Goals
- C can represent any integer Python can (up to available memory).
- Portal save from any impl, resume in any other impl, preserves integer value exactly. Cross matrix stays 9/9 green.
- Existing 48-bit fast paths stay fast for the common case (most integers in real workloads fit in 48 bits).
- No change to Python. No change to asm.
- MOAD-0001 clean: bignum add/mul must not be O(n²) the obvious way (see Risk section).
Non-goals
- Widening asm past 61 bits. Its tagged-int discipline is fine for the workloads we ship; a second ticket can take it to bignum if needed.
- Making rational numerator/denominator bignum. Rationals today are
two
int64_t; lift separately when a real user hits the ceiling. - GMP dependency in asm or Python.
- Changing the portal wire format for integers that already fit 48 bits (backwards compatible for existing portal files).
Options weighed
Option A — Heap-allocated bigint, new TAG_BIGINT
New NaN-box tag TAG_BIGINT = 6. Small integers (fits 48-bit signed)
stay inline TAG_INT = 1. Large integers get boxed as a heap
Bigint struct (sign + length + array of 32-bit or 64-bit limbs).
typedef struct Bigint {
ObjHeader hdr;
int32_t sign; // -1, 0, +1
uint32_t nlimbs;
uint64_t *limbs; // little-endian, limb[0] = least significant
} Bigint;
Arithmetic dispatch:
- Both inline? Try 64-bit op with overflow check; on overflow, promote.
- Either heap? Call bignum path; result normalizes back to inline if it fits.
Pro: NaN-boxing stays. Cache-friendly for small ints (still 8
bytes, not 16). JIT fast path for 48-bit arithmetic keeps working
unchanged — the JIT still sees TAG_INT; bignum fallback is in the C
runtime, called via existing num_* helpers when the fast guard fails.
Pro: Portal format extends cleanly: bigints serialize as decimal
strings (matches Python's repr(int)), preserving exact value across
tiers without a wire-format renegotiation for small ints.
Con: Every arithmetic op needs a "did this overflow 48 bits?"
guard on the inline path. Cheap if we use __builtin_mul_overflow and
friends; costs a branch per op.
Con: TAG_BIGINT competes for tag space. We use tags 0–5 today; 6
is free. 7 is the last slot (3-bit tag field).
Con: GC awareness: Bigint.limbs must be GC_MALLOCed (when
Boehm is on) so it is traced/freed. One extra indirection.
Size: ~400 lines of new C (add/sub/mul/divmod/compare/from_int64/ to_int64/to_string/from_string) + integration.
Option B — Abandon NaN-boxing, use a 2-word Value struct
typedef struct { uint64_t tag; uint64_t payload; } Value;
Tag word holds type; payload holds any 64-bit value including full int64_t. For wider integers, payload is a pointer.
Pro: Clean. No bit-packing. Doubles, 64-bit ints, and pointers all sit in payload without tricks.
Pro: Debugger-friendly.
Con: Every Value is 16 bytes, not 8. Every Pair, Vector,
Env, stack slot, register spill — doubles in size. The VM passes
Values through registers constantly; this is a real perf regression.
Con: The entire codebase passes Value by value in signatures
(Value f(Value a, Value b)). x86_64 SysV passes one Value in a
register today; two doubles that. Every builtin signature, every VM
opcode handler, every macro (VAL_INT, VAL_NIL, IS_INT) — touched.
Con: The JIT in c/jit.c is built around 64-bit values in a
single register. A 16-byte value needs two registers everywhere or a
stack round-trip. Rewrite, not port.
Size: The entire C implementation. ~4–6 weeks.
Option C — Document the 48-bit ceiling, do not fix
Add a note to docs/whitepaper and to portal envelope header
documenting that C rejects (or truncates) integers past ±2^47.
Pro: Zero code change.
Con: Truncates silently. If we switch to loud rejection we break programs that run fine in Python and asm today. Either way, the cross-impl "bit-for-bit identical" promise becomes conditional.
Con: Violates the planet-patching mission. Downstream users who build simulations (fox's stochastic experiments, Zoe's RNG portal work) would hit this ceiling and blame Lumbda, not their code.
Con: Our own test suite — tests/functional.lsp — would need a
guard rail noting "do not exercise integers > 2^47 in shared tests."
That is the wrong direction. A shared test should describe what
Scheme is, not what our weakest impl tolerates.
Recommendation — Option A (heap-allocated bigint, new TAG_BIGINT)
Aligned against the eight capital queues:
- Intellectual capital (gain): we ship a clean bignum in public domain C, portable, no GMP dependency, ~400 LOC. Downstream projects that embed Lumbda inherit a bignum for free. Open capital, not rent.
- Living capital (gain): the fix removes a class of silent-wrong
results from simulations downstream users run. No more
-133099161583616when you asked for 10^16. A simulation that reports correct numbers serves living systems better than one that reports fast but wrong ones. - Experiential capital (gain): "it just works across all three impls" becomes true without asterisks. fox's stochastic workflow does not need to learn "avoid C for > 2^47" as folklore.
- Financial capital (neutral): no rent extraction either way.
- Material capital (slight cost): more memory per large integer (heap allocation + limb array). Acceptable — small ints still free.
Option B is an attractive redesign but a demolition project, not cleanup crew. Option C abandons the cross-impl promise. Option A preserves our existing investment (NaN-boxing, JIT fast path, portal format for small ints) while extending the domain exactly where it is broken.
Migration path — ordered
Phase 0 — Add the inline overflow guard only (loud, no bignum yet). Lets us ship a fail-loud interpreter immediately, buying time for the bignum work without shipping another release that silently truncates.
c/lumbda.h: add inlineVAL_INT_CHECKED(n)that callslisp_errorifndoes not fit 48-bit signed. Wire it intoc/reader.clarge-literal path and all arithmetic return paths inc/types.c.tests/functional.lsp: add two assertions that exercise 48-bit-fit arithmetic; mark any > 2^47 test as C-xfail until Phase 3.
Phase 1 — Bignum struct and primitives (standalone, no integration).
- New file
c/bignum.c(declarations inc/lumbda.h):Bigint *bi_from_int64(int64_t)int64_t bi_to_int64(Bigint *, bool *fits)Bigint *bi_add(Bigint *, Bigint *),bi_sub,bi_mul,bi_negvoid bi_divmod(Bigint *a, Bigint *b, Bigint **q, Bigint **r)int bi_cmp(Bigint *, Bigint *)char *bi_to_string(Bigint *),Bigint *bi_from_string(const char *)Bigint *bi_normalize(Bigint *)— trims leading-zero limbs, may return a caller-known sentinel meaning "fits in int64_t, unbox me".
- Unit test
c/bignum_test.c— add/sub/mul against known-good decimal strings from Python. No VM integration yet.
Phase 2 — Wire bignum into the Value type.
c/lumbda.h: addTAG_BIGINT = 6,IS_BIGINT,AS_BIGINT,VAL_BIGINT, addOBJ_BIGINTtoObjType.c/types.c: rewritenum_add,num_sub,num_mul,num_neg,num_cmp,to_rational:- Fast path: both inline
TAG_INT, use__builtin_*_overflow. On overflow, promote to bigint. - Slow path: promote inline to bigint, operate, normalize result back to inline if it fits.
- Fast path: both inline
c/reader.cparse_atom: whenstrtollfails withERANGEor the value does not fit 48-bit, callbi_from_stringand returnVAL_BIGINT.c/printer.c:showfor bigint callsbi_to_string.
Phase 3 — JIT fast path.
c/jit.c: JIT today compiles arithmetic that assumesTAG_INToperands. Keep that fast path intact. On overflow check (__builtin_mul_overflow-equivalent in emitted asm, or usejo/jnoafter animul), branch out of the JIT fast path into the C runtime'snum_mul(which handles promotion). Bignum never runs inside JIT'd code — JIT only handles the inline case.- This keeps the fast path at its current speed.
- Guard: any JIT'd arithmetic that today assumes no overflow must
add the
jotrampoline. Missing one = silent truncation regression.
Phase 4 — Portal.
c/portal.cJSON writer: for bigint, emit as{"t":"bigint","v":"1234..."}. Decimal string, arbitrary length.c/portal.cJSON reader: on encountering{"t":"bigint",...}, callbi_from_string, normalize, return aValue.lumbda.pyportal reader: accept{"t":"bigint",...}and map to Pythonint(...). Writer emits{"t":"bigint",...}only whenabs(v) > 2^47(backwards compatible — small ints stay as bare JSON numbers).asm/lumbda.s: text portal (;; lumbda-portal v1) emits bigints as their decimal string — already handles arbitrary int print vianumber->string; just needs reader to route strings-that-parse-as- integers-larger-than-61-bit into... (deferred to a later asm bignum ticket; for now asm rejects portal files containing bigints with a clear error, rather than truncating).
Phase 5 — MOAD audit and tests.
~/git/unmoad.com/unmoad c/bignum.c— bignum schoolbook mul is O(n·m) in limb counts; that is fine. What MOAD catches: a loop that callsbi_mulinsidefor_eachon a growing list, which would be O(n³) in bit-length. Keep bignum ops out of hot loops in the runtime itself.tests/portal-cross-test.sh: new workload saves(expt 10 30), resumes across all 9 producer×consumer cells.tests/functional.lsp: add assertions with 10^20 and(factorial 25).
Test strategy
All must pass before the ticket closes:
- Unit (new) —
c/bignum_test.c: decimal-string round-trips, add/sub/mul against Pythonhex(n)reference vectors, divmod against Pythondivmod(), compare with sign/zero edge cases. - Shared functional —
tests/functional.lsp: add 20+ assertions that exercise integers past 2^47. Because this file runs under Python and C, both tiers must agree. (Asm runs this file too but its 61-bit ceiling is a separate future ticket — we gate via an existing impl-specific skip mechanism or split intofunctional.lsp+functional-bigint.lsploaded by Python+C only.) - Portal cross —
tests/portal-cross-test.sh: new large-int workload. All 9 producer×consumer cells green. Any cell that has a bigint consumer which does not support bigints yet must report a clear error, not silently truncate. - MOAD scan —
~/git/unmoad.com/unmoad c/: clean on all changed files. - Perf check —
tests/web-benchmark.sh: C tier throughput does not regress by more than 5% on workloads that stay inside 48-bit. Inline path is the 99th percentile case; regression there is real cost. - The original repro above prints
10000000000000000under C.
Risk section
MOAD-0001 in bignum itself (the scariest risk).
- Schoolbook multiply is O(n·m) in limb counts — acceptable for n, m up to a few thousand limbs (realistic max for our users).
- String formatting the obvious way (
repeated divide by 10) is O(n²) in digit count. Use a divide-and-conquer base conversion for output of numbers with > 100 digits. Cite a source in the code comment. - Ticket 0001 lesson: ML agents (blackops included) propagate O(n²)
loops by default.
unmoadon bignum.c is mandatory, not optional.
JIT regression (silent wrong answers).
- The JIT today assumes
TAG_INTarithmetic does not overflow 48 bits. Phase 3 adds thejo/jnotrampoline. A single missed JIT emit site = regression back to silent truncation, worse than today because portal tests might pass while random production workloads truncate. - Mitigation: add a JIT property test that runs every JIT'd op on inputs straddling the 48-bit boundary and asserts the result matches the interpreter path.
Portal backwards compatibility.
- Old portal files (written before this ticket ships) contain bare
JSON integers only. New readers must accept both bare and
{"t":"bigint",...}. Writers should prefer bare for small ints to keep files readable.
Memory and GC.
Bigint.limbsmust be allocated viaGC_MALLOC(Boehm on) so it gets freed when theBigintbecomes unreachable. Without Boehm, limbs leak today (consistent with how other heap objects behave in the non-GC build — not worse, but worth naming in code comments).
Asm divergence.
- This ticket does NOT fix asm's 61-bit ceiling. A program that
uses 2^62 in Python + C will still misbehave in asm. The whitepaper
and
asm/READMEmust acknowledge this, and a follow-up ticket (0004?) can apply the same pattern to asm (hand-rolled bignum via the asm heap bump allocator, new tag above the 3-bit region).
Effort estimate.
- Medium (Phase 0–4, Python+C): ~2 weeks with tests.
- Large if we also do asm: +2 weeks.
- Small if we ship Phase 0 alone (fail-loud, no bignum): ~1 day. Proposal: ship Phase 0 immediately as a stopgap under this same ticket, then the full fix as a follow-on.
Deliverables (on full implementation)
docs/tickets/0003-c-int-widening.md(this ticket)c/bignum.c,c/bignum_test.c— newc/lumbda.h—TAG_BIGINT,OBJ_BIGINT,Bigintstruct,VAL_BIGINT,AS_BIGINT,IS_BIGINTc/types.c—num_*routed through bignum on overflowc/reader.c— large-literal fallthrough tobi_from_stringc/printer.c— bigint showc/portal.c— JSON read/write for bigintc/jit.c— overflow trampoline on inlineTAG_INTfast pathlumbda.py— portal read/write bigint (emit only for > 48-bit)tests/functional.lsp— 20+ new bigint assertionstests/portal-cross-test.sh— large-int workload across 9 cells- Whitepaper — update §x noting C integer domain now unbounded
make test-allgreenunmoadclean onc/bignum.cand all changed files