Skip to content

Exact int64 %, // and to_base; VALUE_ERROR on wrong-length destructuring (fixes #149) - #150

Merged
howlcipher merged 1 commit into
mainfrom
fix-149-int-math
Oct 12, 2026
Merged

howlcipher merged 1 commit into
mainfrom
fix-149-int-math

Conversation

@howlcipher

@howlcipher howlcipher commented Oct 12, 2026 •

Copy link
Copy Markdown
Owner

Fixes #149

What changed

%, // and to_base are still pure desugar (internal/ast/desugar_controlflow.go): no new opcode, capability or effect.

  • Int operands, exact int64, Python floor semantics. The new path works on non-positive magnitudes (N = -|a|, D = -|b|), so MinInt64 can be represented. One float division gives a quotient estimate (error ≤ ~1536 for |q| ≤ 2^62). It is biased 2048 toward zero, so D*Q never overflows. A second float division of the small remainder (biased by 1) and a loop of a few steps then make the remainder exact. After that come the floor or sign fixups.
    • (% MaxInt64 10) = 7, (// 123456789012345678 1000) = 123456789012345.
    • MinInt64 // -1 gives the existing RUNTIME_ERROR integer overflow (via (- 0 a)), not CONVERSION_ERROR. (% MinInt64 -1) = 0. Division by zero is still division by zero.
  • Fast path: when both operands are inside (-2^52, 2^52), the old float expansion runs, because it is exact there. The bound is 2^52, not 2^53, because // divides a - r. With a 2^53 bound, (// (2^53-1) -3) gave -3002399751580330 instead of -…331. This was a pre-existing off-by-one on main, below 2^53.
  • Floats keep float behavior: an operand whose printed form contains ., e, n (Inf) or N (NaN) takes the old float formula. An int64 never prints any of those. Integral floats above 2^52 always print with an exponent.
    • Exact-path % results are wrapped as (+ (+ x (* 0 a)) (* 0 b)), and // leaves as to_int. That adds 0 at run time, but gives the checker the same static type as the float branch, which it requires of both if branches.
  • to_base now walks non-positive N, so (to_base MinInt64 b) works. Before, it negated first.
  • let destructuring checks the length first: (let ((a b) (list 1 2 3)) …) → VALUE_ERROR: destructuring expects 2 elements, exit 1, on every path.

Tests (issue149_int_math_cli_test.go)

  • Three-path parity (interpreter -run, governed AST bytecode -run-bc, HFIR bytecode): 256 // and % pairs over 2^53-1, 2^53, 2^53+1, MaxInt64, MinInt64, the negatives of these, and small or negative operands. Expected values come from Python.
  • Also covered on all three paths: to_base at MaxInt64/MinInt64 in bases 2, 16 and 36, float cases, and errors: MinInt64//-1 overflow (asserts no CONVERSION_ERROR), division by zero for int, big-int and float operands, and wrong-length destructuring.
  • Go/JS still reject the boundary forms with UNSUPPORTED_ON_TARGET.
  • Separately (not committed): a 705-pair differential run including 400 random int64 pairs matched Python on all three paths.

Checks

  • gofmt clean. go build ./..., go vet ./... and go test ./... pass, including the arity, drift, fuzz and existing control-flow tests.
  • All benchmark scripts are byte-identical (git status clean after each): token-efficiency-v0, heldout-v0, heldout-v1, heldout-v2 run.sh, and run_postcf.sh.

Costs / caveats

  • Each % or // adds a fast-path range check, so the bytecode instruction cost of a small-operand op roughly doubles ((% 7 3) goes from 30 to 56 instructions, (// 7 3) from 39 to 94), and the expanded code is larger. A slow-path op costs ~200–230 instructions. No benchmark task gets close to the default 100k limit (an earlier 2^53-bound draft did push w25 over; the final version doesn't).
  • To stay under that limit, to_base at MinInt64 in base 2 (~15k instructions) is tested in one program per case.

Hard nos intact: no production -compile-bc/HFIR flip, #90 stays Partial, no live LLM.

@howlcipher howlcipher left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: merge-ready (COMMENT review, since APPROVE is blocked for this account). Reviewed head b98801cb01bb2e161b1231f073fa0a07439c4f6b against base 65020331 in one fresh checkout. No blockers.

1. Independent differential test (Python floor semantics, equivalent to Go big.Int floor division)

  • 3000 random int64 pairs. 45% sit at boundaries ±3 (0, ±1, ±2, ±3, 7, ±10, 1000, ±2^52±1, ±2^53±1, ±2^62±1, MaxInt64, MinInt64), 30% are uniform int64, 25% are small.
  • 428 to_base cases in bases 2, 10, 16 and 36, including MaxInt64, MinInt64, 0, ±1 and ±2^53.
  • Every case ran on interpreter -run, governed -run-bc and HFIR -run-bc: 471 program runs, 0 mismatches.
  • MinInt64 // -1 gives RUNTIME_ERROR integer overflow on all 3 paths (base gave CONVERSION_ERROR).
  • // 0 and % 0 give division by zero, including big-int operands.

2. Float detection by printed form

  • I ran every probe base vs head on all 3 paths. Output is identical in every case, and the 3 paths agree:
    • 2.0 and 7.0 (print as 2 and 7)
    • -0.0 (prints 0)
    • 4503599627370497.0 (prints 4.503599627370497e+15)
    • 1e33 and 1e296 (CONVERSION_ERROR on both base and head)
    • +Inf, and NaN from Inf-Inf (invalid floating-point result on both)
    • mixed int/float, such as (// 9007199254740993 2.0)
  • Integral floats that print without a dot take the exact path but give the same value, as the comment says.
  • Exponent literals (1e20, 1.0e5) don't lex as floats on either base or head, so you can't write them directly.
  • A string containing n can't reach the detector. Strings, bools and nil are rejected statically on every path. Only the message changed: / requires numeric operands became < compares incompatible types string and int. That's a nit.
  • Pre-existing, not from this PR: (% MaxInt64 1e33) prints 1.0000000000000092e+33 and (// MaxInt64 1e33) prints -1, where Python gives 9.22e18 and 0. Base does the same. That's float-formula cancellation and worth a follow-up issue.

3. + 0 wrapper: at run time it only ever sees ints, and non-numerics are rejected before it runs. I saw no coercion and no new error codes.

4. Cost (governed -run-bc, bisected with -max-instructions)

  • Small-operand ops: % takes 30 instructions on base and 56 on head; // takes 39 and 94. These match the PR text.
  • Slow path: (% MaxInt64 10) takes 228, (// MinInt64 7) takes 219, and (to_base MaxInt64 36) takes 3281.
  • (to_base MinInt64 2) takes 13,407 instructions. On base it failed outright.
  • Tight loop, 20k iterations of (+ (% i 7) (// i 3)): 1,514,285 instructions on base vs 3,134,285 on head. The two ops go from about 61 to 142 per iteration, roughly 2.3x.
    • Wall time is 76→113 ms on the interpreter, 84→178 ms on governed bytecode and 89→153 ms on HFIR.
  • Under the default 100k limit, a loop doing one % and one // per iteration now runs out at roughly 700 iterations instead of 1600.
  • The desugar adds no CALL, so MaxCallDepth is unaffected.
  • All benchmark and example solutions still pass under the limits (60/60, 32/32, 34/34, 37/37, 37/37).

5. Wrong-length destructuring: VALUE_ERROR: destructuring expects N elements, exit 1, on all 3 paths, for too short and too long. Nit: unlike the runtime JSON errors it has no line or column. It matches the existing to_base VALUE_ERROR style (stderr plus exit). Could be done in a follow-up.

6. Pins and fences

  • I installed tiktoken 0.8.0 and ran all 5 benchmark scripts: token-efficiency-v0, heldout-v0, heldout-v1, heldout-v2 run.sh and run_postcf.sh. Each exited 0 and git status stayed clean, so output is byte-identical.
  • Freeze, arity, drift, fuzz, TestProdFlipCriteriaLock and Issue141/149 tests pass (exit 0).
  • Go/JS still reject with UNSUPPORTED_ON_TARGET (TestIssue149RejectedOnGoJS).

7. Stale construct-table notes: nit, not a blocker. The code comments and the change_log are accurate.

8. Build checks: CI build is green. gofmt -l is clean. go test ./... exit 0, and no apps/* flake showed up this run.

9. Hard nos intact: the diff touches only desugar_controlflow.go, the new test file and change_log.md. There's no -compile-bc/HFIR default flip, TestProdFlipCriteriaLock is untouched, the tip-lock is unchanged, and I make no live LLM or H1 claims.

Nits: (a) the changed error text for non-numeric % and //; (b) no line or column on the destructuring VALUE_ERROR; (c) the 2x instruction cost cuts the default-limit loop headroom, so it's worth noting in the docs; (d) the stale construct-table notes; (e) a follow-up issue for the pre-existing float %/// cancellation with huge float divisors.

@howlcipher
howlcipher marked this pull request as ready for review October 12, 2026 21:12
@howlcipher
howlcipher merged commit d887b96 into main Oct 12, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Integer // % to_base go through float64: silent wrong answers above 2^53

1 participant