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:
parent
11c464c08c
commit
9dee0d02ee
1 changed files with 379 additions and 0 deletions
379
docs/tickets/0003-c-int-widening.md
Normal file
379
docs/tickets/0003-c-int-widening.md
Normal 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 0–5 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. ~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
|
||||
`-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 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` — 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue