Skip to content

Exact Python float % and //, VALUE_ERROR on Inf/NaN (#151) - #153

Merged
howlcipher merged 1 commit into
mainfrom
fix-151-float-mod
Oct 12, 2026
Merged

howlcipher merged 1 commit into
mainfrom
fix-151-float-mod

Conversation

@howlcipher

Copy link
Copy Markdown
Owner

Fixes #151

What changes

  • Float and mixed %: Python semantics, exact. math.fmod is computed with existing constructs only: |b|·2^k values are doubled into a list, then subtracted back from the largest down. Each subtraction is exact (Sterbenz), and the result is then floored to the divisor's sign. A zero result takes the divisor's sign. (% MaxInt64 1e33) = 9.223372036854776e+18 (was 1.0000000000000092e+33), (% 1 0.1) = 0.09999999999999995, (% 4.0 -2.0) = -0.
  • Float and mixed //: CPython float floor division, returned as an int like the rest of //. (// MaxInt64 1e33) = 0 (was -1). A result outside int64 gives VALUE_ERROR: // result outside int64.
  • Inf/NaN operands give VALUE_ERROR: % and // need finite operands. Division by zero (including -0.0) is still the existing division by zero.
  • Integral floats that print like ints (e.g. 189944.0) paired with a big int used to run the int core in float arithmetic, which gave silently wrong results that the fuzzer found. They now take the float path (detected via x + 2^62).
  • Mixed int/float comparison (VM, shared by all paths): floatIsWholeInt64 accepted 2^63, so int64(2^63) became MinInt64 and (< 2^63-float 0) was true. That was the root cause of the +1e33 in Float % and // wrong with huge operands; #150 follow-up nits #151. Non-whole or out-of-range floats are now ordered against ints without rounding the int through float64.
  • Nits:
    • A non-numeric operand now reports % requires numeric operands (int or float) (or //) at the form, instead of < compares incompatible types.
    • Template VALUE_ERRORs, destructuring included, carry at line L, column C.
    • Instruction cost is documented in desugar_controlflow.go and change_log.md.
    • The construct-table notes for % and // no longer say via to_int(/).

No new opcode or capability. Go/JS still reject with UNSUPPORTED_ON_TARGET.

Instruction cost (-run-bc)

pre-#149 after #149 this PR
20k-iteration loop, one % + one // 1.55M 3.17M 4.30M
to_base MinInt64 2 — 13.4k 16.2k
worst float case 1e308 % 5e-324 — — ~53k

The default 100k limit now fits about 460 loop iterations of % + //. The added cost comes from the whole-number check on the cheap path, which routes non-integral floats to the exact path. No benchmark solution hits the limit.

Verification

  • issue151_float_math_cli_test.go runs every case on the interpreter, -run-bc (governed AST) and HFIR bytecode:
    • 41 Python-generated boundary pairs: 1e308, 5e-324, ±2^63, MaxInt64/MinInt64 mixed with floats, -0.0, 0.1 and others.
    • Error cases: ±Inf, NaN, zero divisors, out-of-range //, and line/column.
    • The non-numeric message, mixed comparisons, and Go/JS rejection.
  • Differential fuzz against Python: 2000 random float/mixed pairs × %/// × 3 paths gave 0 mismatches. That covers subnormals, magnitudes up to 1e308 and integral floats.
  • The Integer // % to_base go through float64: silent wrong answers above 2^53 #149 int tests pass unchanged.
  • All 5 benchmark scripts are byte-identical: the v0, heldout-v0, v1 and v2 run.sh, plus run_postcf.sh. git status is clean after the runs.
  • gofmt is clean, and go build ./..., go vet ./... and go test ./... pass (arity, drift and fuzz included).

Behaviour notes

  • // still returns an int. Python returns a float, so results outside int64 are a VALUE_ERROR rather than a large float.
  • NaN ordering in </<= is unchanged and out of scope. The template detects Inf/NaN by their printed form.

Hard nos: no production 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.

Review of ebfeda3 (COMMENT only)

Verdict: merge-ready. No blockers. It merges cleanly onto current main a6bb53b (git merge-tree exit 0). CI build is green.

1. vm.go mixed compare fix (shared path)

  • Callers: compareNumeric has exactly two call sites: the interpreter's interpNumericRelational (vm.go:1236) and the VM binop for < > <= >= (vm.go:3479). min/max, sort_by, max_by/min_by and range all reach it through those relationals. == doesn't use it, so equality is unchanged.
  • Logic: cmpIntNonWholeFloat is sound. A non-whole in-range float has |f| < 2^52, so int64(floor(f)) is exact, and i <= floor(f) holds exactly when i < f. Inf and values ≥2^63 or <−2^63 are handled by the explicit bounds. The new f >= 2^63 cutoff in floatIsWholeInt64 fixes the MinInt64 wrap.
  • Boundary differential: 39 values (±2^53±1, 2^63 as a float, ±2^63±2048, MaxInt64, MinInt64, ±0.0, ±5e-324, ±1e300, ±Inf, halves), all 1521 ordered pairs × 4 operators = 6084 checks per path, compared with Python's exact int/float ordering.
    • Head: 0 mismatches on the interpreter, governed bytecode and HFIR.
    • Base d887b96: 26 mismatching pairs on every path, all involving the float 2^63. None of them have both operands under 2^53.
  • Normal-value regression check: 1500 random small int/float compares, plus 60 rounds of min/max/sort_by/max_by/min_by and range. Head output is byte-identical to base on all 3 paths.

2. Float/mixed % and // differential

  • Random cases: 10 seeds, 2030 pairs biased toward boundaries: subnormals, ±1e308, 2^53, ±2^63, integral floats, random bit patterns, mixed int/float, and (% MaxInt64 189944.0), (% MaxInt64 1e33), (// MinInt64 0.5).
    • Python oracle: a % b (value and sign of zero), and a // b converted to int when it fits in int64.
    • Result: 3505 checks × 3 paths, 0 mismatches.
  • Error cases: 210 checked × 3 paths, 0 bad.
    • Every quotient outside int64 gives VALUE_ERROR: // result outside int64, never a wrong int.
    • ±Inf/NaN operands on either side give VALUE_ERROR: % and // need finite operands.

3. #150 int paths

480 random and boundary int pairs × %////to_base 7 give 0 mismatches against Python, and head output is byte-identical to base on all 3 paths. TestIssue149* passes.

4. Cost

Measured instructions_used from the -run-bc receipt, with the empty-loop baseline subtracted:

pre-#149 (6502033) #150 (d887b96) head
1000 iterations of (% i 7) + (// i 3) 62.7k 143.7k 200.0k

So head costs 3.19x pre-#149 and 1.39x #150.

  • (% 7.5 2) takes 253 instructions (56 on base).
  • 1e308 % 5e-324 takes 52,738 instructions (base: invalid-float error).

All benchmark solutions pass with the default limits: v0 60/60, heldout-v0 30/30, v1 34/34, v2 37/37, postcf 37/37.

Recommendation: acceptable for this PR. File the dedicated-opcode follow-up, which should also cover these two side effects I found:

  • (a) Artifact size. One % builds to about 11.7 KB of bytecode and one // to about 22.7 KB, against 7.4/12.7 KB on #150 and 1.2/1.3 KB before #149. A program with about 150 %+// pairs (450 static op sites) builds fine but then fails to load: artifact payload exceeds trusted maximum size (10 MiB), on both governed and HFIR. Base loads the same program.
  • (b) Memory. The exact fmod loop's APPENDs count against the 64 MiB cap. 200 × (% 1e300 (+ i 0.5)) hits LIMIT_EXCEEDED: memory limit exceeded, while the reverse operand order fits easily.

Neither affects any benchmark today.

5. NaN in </<=

Unchanged from base and identical on all 3 paths, but wrong: with n = NaN, (<= n 1), (>= n 1), (<= 1 n) and (<= n n) are all true. NaN currently compares as equal. Recommend a follow-up issue so NaN is unordered (all four relationals false) or a VALUE_ERROR, matching the %/// stance.

6. Nits addressed

  • The destructure VALUE_ERROR carries at line 2, column 8 (tested on all paths).
  • The non-numeric operand message is reported at the form.
  • Construct-table notes and cost docs are updated in desugar_controlflow.go and change_log.md.
  • Small doc nit: the stated "2.8x" includes loop overhead. Per operation, I measured about 3.2x pre-#149.

7. Pins and fences

  • I ran all 5 benchmark scripts (v0, heldout-v0, v1, v2 run.sh, plus run_postcf.sh; tiktoken 0.8.0) and every one exited 0. git status was clean afterwards, so all pins are byte-identical.
  • These tests pass: TestProdFlipCriteriaLock, TestIssue141EveryArityEntry, TestIssue141ArityFuzzNoPanic, TestIssue141MotokoArityRepros, the drift test, TestIssue143FetchBodyEveryPath, and TestIssue149/151 RejectedOnGoJS (UNSUPPORTED_ON_TARGET).

8. Build and tests

All on a fresh checkout:

  • gofmt -l . printed nothing.
  • go vet ./... exited 0.
  • go test ./... exited 0: 74 packages ok and no apps/* flake this run.

9. Hard nos

Intact. There's no prod -compile-bc/HFIR default flip, no tip-lock 4d74dbc, and no live LLM or H1 PASS/KILL claim. The 6 changed files are desugar, checker, construct, vm compare, change_log and the test.

Follow-ups to file: (1) dedicated %/// opcode covering cost, artifact size and fmod memory; (2) NaN ordering in relationals.

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.

Float % and // wrong with huge operands; #150 follow-up nits

1 participant