Repository navigation
Exact Python float % and //, VALUE_ERROR on Inf/NaN (#151) - #153
Conversation
…//; clearer operand errors (#151)
howlcipher
left a comment
There was a problem hiding this comment.
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:
compareNumerichas exactly two call sites: the interpreter'sinterpNumericRelational(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:
cmpIntNonWholeFloatis sound. A non-whole in-range float has |f| < 2^52, soint64(floor(f))is exact, andi <= floor(f)holds exactly wheni < f. Inf and values ≥2^63 or <−2^63 are handled by the explicit bounds. The newf >= 2^63cutoff infloatIsWholeInt64fixes 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), anda // bconverted to int when it fits in int64. - Result: 3505 checks × 3 paths, 0 mismatches.
- Python oracle:
- 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.
- Every quotient outside int64 gives
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-324takes 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))hitsLIMIT_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.goandchange_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, plusrun_postcf.sh; tiktoken 0.8.0) and every one exited 0.git statuswas 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.
Fixes #151
What changes
%: Python semantics, exact.math.fmodis 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(was1.0000000000000092e+33),(% 1 0.1)=0.09999999999999995,(% 4.0 -2.0)=-0.//: CPython float floor division, returned as an int like the rest of//.(// MaxInt64 1e33)=0(was-1). A result outside int64 givesVALUE_ERROR: // result outside int64.VALUE_ERROR: % and // need finite operands. Division by zero (including-0.0) is still the existingdivision by zero.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 viax + 2^62).floatIsWholeInt64accepted 2^63, soint64(2^63)became MinInt64 and(< 2^63-float 0)was true. That was the root cause of the+1e33in 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.% requires numeric operands (int or float)(or//) at the form, instead of< compares incompatible types.at line L, column C.desugar_controlflow.goandchange_log.md.%and//no longer sayvia to_int(/).No new opcode or capability. Go/JS still reject with
UNSUPPORTED_ON_TARGET.Instruction cost (-run-bc)
%+ one//to_base MinInt64 21e308 % 5e-324The 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.goruns every case on the interpreter,-run-bc(governed AST) and HFIR bytecode://, and line/column.%///× 3 paths gave 0 mismatches. That covers subnormals, magnitudes up to 1e308 and integral floats.run.sh, plusrun_postcf.sh.git statusis clean after the runs.go build ./...,go vet ./...andgo 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.</<=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.