docs/tickets: propose widening C integer domain (0003)

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.
This commit is contained in:
russell@unturf.com 2026-04-23 20:17:39 -04:00
parent 11c464c08c
commit 9dee0d02ee

View file

@ -0,0 +1,379 @@
# 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
```bash
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) with `n > 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.sh`
does not yet exercise this because its workloads stay under 2^47.
- `isqrt` from commit `6e9d3ea` happens 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
1. C can represent any integer Python can (up to available memory).
2. Portal save from any impl, resume in any other impl, preserves
integer value exactly. Cross matrix stays 9/9 green.
3. Existing 48-bit fast paths stay fast for the common case
(most integers in real workloads fit in 48 bits).
4. No change to Python. No change to asm.
5. 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).
```c
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 05 today; 6
is free. 7 is the last slot (3-bit tag field).
**Con:** GC awareness: `Bigint.limbs` must be `GC_MALLOC`ed (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
```c
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. ~46 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
`-133099161583616` when 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.
1. `c/lumbda.h`: add inline `VAL_INT_CHECKED(n)` that calls
`lisp_error` if `n` does not fit 48-bit signed. Wire it into
`c/reader.c` large-literal path and all arithmetic return paths in
`c/types.c`.
2. `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).**
3. New file `c/bignum.c` (declarations in `c/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_neg`
- `void 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".
4. 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.**
5. `c/lumbda.h`: add `TAG_BIGINT = 6`, `IS_BIGINT`, `AS_BIGINT`,
`VAL_BIGINT`, add `OBJ_BIGINT` to `ObjType`.
6. `c/types.c`: rewrite `num_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.
7. `c/reader.c` `parse_atom`: when `strtoll` fails with `ERANGE` or
the value does not fit 48-bit, call `bi_from_string` and return
`VAL_BIGINT`.
8. `c/printer.c`: `show` for bigint calls `bi_to_string`.
**Phase 3 — JIT fast path.**
9. `c/jit.c`: JIT today compiles arithmetic that assumes `TAG_INT`
operands. Keep that fast path intact. On overflow check
(`__builtin_mul_overflow`-equivalent in emitted asm, or use
`jo`/`jno` after an `imul`), branch out of the JIT fast path into
the C runtime's `num_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 `jo` trampoline. Missing one = silent truncation regression.
**Phase 4 — Portal.**
10. `c/portal.c` JSON writer: for bigint, emit as
`{"t":"bigint","v":"1234..."}`. Decimal string, arbitrary length.
11. `c/portal.c` JSON reader: on encountering `{"t":"bigint",...}`,
call `bi_from_string`, normalize, return a `Value`.
12. `lumbda.py` portal reader: accept `{"t":"bigint",...}` and map to
Python `int(...)`. Writer emits `{"t":"bigint",...}` only when
`abs(v) > 2^47` (backwards compatible — small ints stay as bare
JSON numbers).
13. `asm/lumbda.s`: text portal (`;; lumbda-portal v1`) emits bigints
as their decimal string — already handles arbitrary int print via
`number->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.**
14. `~/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 calls `bi_mul` inside `for_each` on a growing list, which
would be O(n³) in bit-length. Keep bignum ops out of hot loops in
the runtime itself.
15. `tests/portal-cross-test.sh`: new workload saves `(expt 10 30)`,
resumes across all 9 producer×consumer cells.
16. `tests/functional.lsp`: add assertions with 10^20 and `(factorial 25)`.
## Test strategy
All must pass before the ticket closes:
1. **Unit (new) — `c/bignum_test.c`**: decimal-string round-trips,
add/sub/mul against Python `hex(n)` reference vectors, divmod
against Python `divmod()`, compare with sign/zero edge cases.
2. **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 into
`functional.lsp` + `functional-bigint.lsp` loaded by Python+C only.)
3. **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.
4. **MOAD scan — `~/git/unmoad.com/unmoad c/`**: clean on all changed
files.
5. **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.
6. **The original repro** above prints `10000000000000000` under 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. `unmoad` on bignum.c is **mandatory**, not
optional.
**JIT regression (silent wrong answers).**
- The JIT today assumes `TAG_INT` arithmetic does not overflow 48
bits. Phase 3 adds the `jo`/`jno` trampoline. 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.limbs` must be allocated via `GC_MALLOC` (Boehm on) so it
gets freed when the `Bigint` becomes 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/README` must 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 04, 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` — new
- [ ] `c/lumbda.h``TAG_BIGINT`, `OBJ_BIGINT`, `Bigint` struct,
`VAL_BIGINT`, `AS_BIGINT`, `IS_BIGINT`
- [ ] `c/types.c``num_*` routed through bignum on overflow
- [ ] `c/reader.c` — large-literal fallthrough to `bi_from_string`
- [ ] `c/printer.c` — bigint show
- [ ] `c/portal.c` — JSON read/write for bigint
- [ ] `c/jit.c` — overflow trampoline on inline `TAG_INT` fast path
- [ ] `lumbda.py` — portal read/write bigint (emit only for > 48-bit)
- [ ] `tests/functional.lsp` — 20+ new bigint assertions
- [ ] `tests/portal-cross-test.sh` — large-int workload across 9 cells
- [ ] Whitepaper — update §x noting C integer domain now unbounded
- [ ] `make test-all` green
- [ ] `unmoad` clean on `c/bignum.c` and all changed files