From 2607b20c5a473e511dc60efc0146f9aa21297532 Mon Sep 17 00:00:00 2001 From: howlcipher Date: Mon, 12 Oct 2026 19:23:15 -0400 Subject: [PATCH] put returns the dict, encode_json keeps int64 exact, NaN ordering is false; narrow difftest HFIR skip (#161, #156, #158) --- internal/ast/desugar_records.go | 5 ++- internal/vm/vm.go | 22 ++++++++++++-- issue161_156_158_cli_test.go | 54 +++++++++++++++++++++++++++++++++ tools/difftest/difftest_test.go | 15 +++++++++ tools/difftest/main.go | 36 +++++++++++++++++----- 5 files changed, 121 insertions(+), 11 deletions(-) create mode 100644 issue161_156_158_cli_test.go diff --git a/internal/ast/desugar_records.go b/internal/ast/desugar_records.go index 1a24416c..82e98faa 100644 --- a/internal/ast/desugar_records.go +++ b/internal/ast/desugar_records.go @@ -134,7 +134,10 @@ func desugarRecords(node *Node) bool { rest := rnLet(at, build(nt, keys[1:]), rnSym(nt, at), rnList(at, rnSym("map_get", at), rnSym(cur, at), rnSym(kt, at))) return rnLet(at, rnList(at, rnSym("do", at), fill, rest), rnSym(kt, at), keys[0]) } - node.Children = rnLet(at, build(t, args[1:len(args)-1]), rnSym(t, at), args[0]).Children + // #161: put evaluates to the updated (root) dict on every path, so + // (encode_json (put d k v)) sees a value instead of an empty stack. + body := rnList(at, rnSym("do", at), build(t, args[1:len(args)-1]), rnSym(t, at)) + node.Children = rnLet(at, body, rnSym(t, at), args[0]).Children case "read_json": node.Children = []*Node{rnSym("parse_json", at), rnSym("Any", at), rnList(at, rnSym("read_text", at), args[0])} case "write_json": diff --git a/internal/vm/vm.go b/internal/vm/vm.go index 663ea516..f5d75158 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -1264,6 +1264,9 @@ func interpNumericDiv(a, b any, node *ast.Node) any { } func interpNumericRelational(op string, a, b any, node *ast.Node) bool { + if relationalHasNaN(a, b) { + return false + } cmp, numeric := compareNumeric(a, b) if !numeric { cmp, numeric = compareStrings(a, b) @@ -1331,6 +1334,18 @@ func mulOverflowsInt64(a, b int64) bool { return c/b != a } +// relationalHasNaN reports whether a numeric relational operand pair includes +// NaN. IEEE 754 and Python order NaN against nothing, so <, <=, > and >= are +// all false (#158). Non-numeric operands still fall through to the type check. +func relationalHasNaN(a, b any) bool { + af, aOk := toFloat64(a) + bf, bOk := toFloat64(b) + if !aOk || !bOk { + return false + } + return math.IsNaN(af) || math.IsNaN(bf) +} + // compareNumeric compares two numeric values. It returns the signed comparison // result (-1, 0, 1) and true when both operands are numeric. Mixed comparisons // never round an exact integer through float64; they are compared as int64 when @@ -3507,6 +3522,9 @@ func bcNumericBinop(op string, a, b any) any { } return res case "<", ">", "<=", ">=": + if relationalHasNaN(a, b) { + return false + } cmp, numeric := compareNumeric(a, b) if !numeric { cmp, numeric = compareStrings(a, b) @@ -3699,9 +3717,9 @@ func normalizeForJSON(v any) any { out[i] = normalizeForJSON(val) } return out - case int64: - return float64(t) default: + // int64 stays int64 so encoding/json writes it exactly; the old + // float64 conversion rounded integers above 2^53 (#156). return v } } diff --git a/issue161_156_158_cli_test.go b/issue161_156_158_cli_test.go new file mode 100644 index 00000000..b90d614c --- /dev/null +++ b/issue161_156_158_cli_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "strings" + "testing" +) + +// issue161Run runs src on the interpreter, governed AST bytecode and HFIR +// bytecode and requires the same exact stdout on every path. +func issue161Run(t *testing.T, src, want string) { + t.Helper() + bin := c1c2Binary(t) + for _, args := range issue149Paths(t, bin, src) { + out, err := issue151Run(t, bin, args) + if err != nil || out != want { + t.Errorf("%v: err=%v out=%q want %q", args[0], err, out, want) + } + } +} + +// Issue #161: put evaluates to the updated root dict on every path, so it +// can be used as an expression (encode_json used to see null or underflow). +func TestIssue161PutReturnsDictOnEveryPath(t *testing.T) { + src := `(do (let (d (dict)) (do (print (encode_json (put d "a" 1))) (print (encode_json d))))` + + ` (let (n (dict)) (do (print (encode_json (put n "a" "b" 2))) (print (encode_json (put n "a" "c" 3)))))` + + ` (let (s (dict)) (do (put s "k" 5) (print (encode_json s)))))` + want := "{\"a\":1}\n{\"a\":1}\n{\"a\":{\"b\":2}}\n{\"a\":{\"b\":2,\"c\":3}}\n{\"k\":5}\n" + issue161Run(t, src, want) +} + +// Issue #156: encode_json writes int64 exactly, with no float64 rounding +// above 2^53. +func TestIssue156EncodeJSONInt64ExactOnEveryPath(t *testing.T) { + src := `(do (print (encode_json 9007199254740993)) (print (encode_json (list 9223372036854775807 -9223372036854775808 9007199254740992)))` + + ` (print (encode_json (dict ("big" 1234567890123456789)))) (print (encode_json 2.5)))` + want := "9007199254740993\n[9223372036854775807,-9223372036854775808,9007199254740992]\n{\"big\":1234567890123456789}\n2.5\n" + issue161Run(t, src, want) +} + +// Issue #158: <, <=, > and >= with a NaN operand are false (IEEE 754, +// Python), and sort/min/max and the _by forms keep their prior output. +func TestIssue158NaNOrderingOnEveryPath(t *testing.T) { + cmp := []string{} + for _, op := range []string{"<", "<=", ">", ">="} { + for _, pair := range []string{"n 1", "1 n", "n 1.0", "1.0 n", "n n"} { + cmp = append(cmp, "("+op+" "+pair+")") + } + } + src := `(do (let (n (to_float "nan")) (do (print (str_join (list ` + strings.Join(cmp, " ") + `) " ")) (print (< 1 2.0) (<= 2 2.0) (>= 3.0 3) (> 1.5 1))` + + ` (let (xs (list 3.0 n 1.0 2.0)) (do (print (sort xs)) (print (min xs)) (print (max xs))` + + ` (print (sort_by x xs (* x 1.0))) (print (max_by x xs x)) (print (min_by x xs x)))))))` + want := strings.TrimSpace(strings.Repeat("false ", len(cmp))) + "\ntrue true true true\n[1 2 3 NaN]\n1\n3\n[1 2 3 NaN]\n3\n1\n" + issue161Run(t, src, want) +} diff --git a/tools/difftest/difftest_test.go b/tools/difftest/difftest_test.go index f675920d..0b3e355c 100644 --- a/tools/difftest/difftest_test.go +++ b/tools/difftest/difftest_test.go @@ -255,3 +255,18 @@ func TestErrorNormalization(t *testing.T) { } } } + +// #161: only an explicitly fenced construct may skip the HFIR leg. +func TestHFIRFencedConstructIsExplicit(t *testing.T) { + fenced := `{"reason":"HFIR direct lowering failed: node kind \"optimize_signature\" is not in the Phase-1 executable subset"}` + if got := hfirFencedConstruct(fenced); got != "optimize_signature" { + t.Fatalf("hfirFencedConstruct(optimize_signature) = %q", got) + } + other := `{"reason":"HFIR direct lowering failed: node kind \"some_new_form\" is not in the Phase-1 executable subset"}` + if got := hfirFencedConstruct(other); got != "" { + t.Fatalf("hfirFencedConstruct(some_new_form) = %q, want no skip", got) + } + if got := hfirFencedConstruct("type error"); got != "" { + t.Fatalf("hfirFencedConstruct(other error) = %q, want no skip", got) + } +} diff --git a/tools/difftest/main.go b/tools/difftest/main.go index ad0238c3..c4d16118 100644 --- a/tools/difftest/main.go +++ b/tools/difftest/main.go @@ -120,28 +120,32 @@ func testFixture(file string) (passed bool, skipReason string, failMsg string) { // #159: the experimental HFIR bytecode path is a third leg; any // divergence from the interpreter fails the run. hfirPath := filepath.Join(outDir, "test.hfir.bc") - // A construct on the locked HFIR promote-blocker fence (see - // TestProdFlipCriteriaLock) is refused at lowering, fail closed; that is - // not a semantic mismatch, so only that refusal skips the leg. - out8, hfirFenced := out1, false + // #161: only a construct on the explicit HFIR fence list (the locked + // promote-blocker fence, see TestProdFlipCriteriaLock) may skip the HFIR + // leg. Any other Phase-1 subset refusal is a failure, and a fenced fixture + // is reported as SKIP, never counted as Passed. + out8, hfirFenced := "", "" out7, err := runCommand("go", "run", "howlframe.go", "-compile-hfir-bc", file, "-o", hfirPath) if err != nil { - if !strings.Contains(out7, "is not in the Phase-1 executable subset") { + hfirFenced = hfirFencedConstruct(out7) + if hfirFenced == "" { return false, "", fmt.Sprintf("(compile-hfir-bc): %v\n%s", err, out7) } - hfirFenced = true } - if !hfirFenced { + if hfirFenced == "" { out8, err = runCommand("go", "run", "howlframe.go", "-run-bc", "-allow-caps", "network,filesystem,process,environment,database", hfirPath) if err != nil { return false, "", fmt.Sprintf("(run-bc hfir): %v\n%s", err, out8) } } - if out1 != out4 || out1 != out6 || out1 != out8 { + if out1 != out4 || out1 != out6 || (hfirFenced == "" && out1 != out8) { msg := fmt.Sprintf("stdout mismatch\n--- interpreter ---\n%s\n--- go backend ---\n%s\n--- bytecode ---\n%s\n--- hfir bytecode ---\n%s", out1, out4, out6, out8) return false, "", msg } + if hfirFenced != "" { + return false, fmt.Sprintf("HFIR leg fenced (%s); interpreter, go and bytecode legs agree", hfirFenced), "" + } return true, "", "" } @@ -158,3 +162,19 @@ func runCommand(name string, arg ...string) (string, error) { out, err := cmd.CombinedOutput() return string(out), err } + +// hfirFencedConstructs is the explicit list of constructs whose HFIR lowering +// refusal skips the HFIR leg. It must not grow to hide unsupported constructs. +var hfirFencedConstructs = []string{"optimize_signature"} + +// hfirFencedConstruct returns the fenced construct named by an HFIR lowering +// refusal, or "" when the refusal is not for a construct on the list. +func hfirFencedConstruct(out string) string { + for _, kind := range hfirFencedConstructs { + if strings.Contains(out, `node kind \"`+kind+`\" is not in the Phase-1 executable subset`) || + strings.Contains(out, `node kind "`+kind+`" is not in the Phase-1 executable subset`) { + return kind + } + } + return "" +}