From 2c35af802cc46849f4cec8e4dc9636408f924331 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 09:14:57 +0000 Subject: [PATCH] fix(parser): preserve Rat-ness of slash-containing number literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eigentrust pitfalls #3: in WS mode, `'[0/1 1/2 1/2]` annotated as `[List Rat]` raised `Type mismatch [List [List Rat]] ...` because `string->number "0/1"` simplified to integer 0 *before* literal-type inference, mixing Int and Rat in a list whose annotation demanded Rat. Fix shape (a — preserve at parse time): a number-token lexeme containing `/` is wrapped in a `($rat-literal val)` sentinel by the WS reader, mirroring the existing `($nat-literal val)` sentinel for `42N`. Both surface parsers (parser.rkt for the preparse path, tree-parser.rkt for the cell pipeline path) route the sentinel / slash-lexeme to `surf-rat-lit`, preserving Rat-ness even when the simplified value is an integer. Why (a) over (b — context-aware coercion): (a) is a single-source- faithful transform — a user who writes `0/1` MEANS Rat, regardless of what `string->number` simplifies to. (b) would require plumbing expected-type context into list-literal element parsing, which the parser pipeline doesn't carry. (a) keeps the bare integer literals `0` and `42` as Int (still! — the slash is the distinguisher). Trade-off: the WS reader's wire format for slash literals now wraps in `$rat-literal`. One existing round-trip assertion was updated (test-negative-literals.rkt:120) to expect the sentinel. No behavioral change for `(rat 0/1)`, `0`, `42`, true rationals (`1/2`, `-3/7`) — all keep their existing types. Files changed: - racket/prologos/parse-reader.rkt +13 emit $rat-literal sentinel - racket/prologos/parser.rkt +13 handle $rat-literal sentinel - racket/prologos/tree-parser.rkt +9 slash-lexeme → surf-rat-lit; flatten-ws-datum exemption - tests/test-rat-literal-in-list.rkt +new 17 cases: reproducer + corners - tests/test-negative-literals.rkt +6 update one round-trip expect Tests verified: test-rat (31), test-rat-literal-in-list (17), test-list-literals + test-pvec* (98), test-parse-reader + test-parser + test-sexp-reader-parity (211), test-integration + test-parse-integration + test-surface-integration (117), test-negative-literals (25), test-approx-literal (22), test-refined-rat. Total 521+ tests pass. Co-authored-by: kumavis <1474978+kumavis@users.noreply.github.com> --- racket/prologos/parse-reader.rkt | 12 ++ racket/prologos/parser.rkt | 12 ++ .../prologos/tests/test-negative-literals.rkt | 6 +- .../tests/test-rat-literal-in-list.rkt | 147 ++++++++++++++++++ racket/prologos/tree-parser.rkt | 8 +- 5 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 racket/prologos/tests/test-rat-literal-in-list.rkt diff --git a/racket/prologos/parse-reader.rkt b/racket/prologos/parse-reader.rkt index 8b6a94288..f67416fcc 100644 --- a/racket/prologos/parse-reader.rkt +++ b/racket/prologos/parse-reader.rkt @@ -1844,6 +1844,18 @@ (make-stx (list (make-stx '$nat-literal source line col pos1 0) (make-stx value source line col pos1 span)) source line col pos1 span)] + ;; Slash-containing number lexeme (e.g. 0/1, 1/2, -3/7) → ($rat-literal n). + ;; A user-written `0/1` IS a Rat literal even when string->number simplifies + ;; the value to the integer 0; the `/` is a load-bearing source token. The + ;; sentinel preserves Rat-ness through the parse pipeline so downstream + ;; typing sees `0/1` as Rat, not as Int. + [(number) + (cond + [(string-contains? lexeme "/") + (make-stx (list (make-stx '$rat-literal source line col pos1 0) + (make-stx value source line col pos1 span)) + source line col pos1 span)] + [else (make-stx value source line col pos1 span)])] [(rest-param) (if (string=? lexeme "...") ;; standalone ... → $rest symbol diff --git a/racket/prologos/parser.rkt b/racket/prologos/parser.rkt index a9cfc5914..3120c8207 100644 --- a/racket/prologos/parser.rkt +++ b/racket/prologos/parser.rkt @@ -623,6 +623,18 @@ (parse-error loc (format "N suffix requires a non-negative integer, got: ~a" v) #f))) (parse-error loc "N suffix requires exactly one argument" #f))] + ;; $rat-literal sentinel: a number lexeme containing `/` (e.g. 0/1, 1/2, + ;; -3/7) is a Rat literal even when string->number simplifies it (e.g. + ;; `0/1` → 0). The WS reader emits this sentinel for any slash-containing + ;; number token to preserve Rat-ness through the parse pipeline. + [(and (symbol? head) (eq? head '$rat-literal)) + (if (= (length args) 1) + (let ([v (stx->datum (car args))]) + (if (and (number? v) (exact? v) (rational? v)) + (surf-rat-lit v loc) + (parse-error loc (format "rat literal requires an exact rational, got: ~a" v) #f))) + (parse-error loc "rat literal requires exactly one argument" #f))] + ;; $approx-literal sentinel: ~N → surf-approx-literal [(and (symbol? head) (eq? head '$approx-literal)) (if (= (length args) 1) diff --git a/racket/prologos/tests/test-negative-literals.rkt b/racket/prologos/tests/test-negative-literals.rkt index 77e86d7c0..6a486ef44 100644 --- a/racket/prologos/tests/test-negative-literals.rkt +++ b/racket/prologos/tests/test-negative-literals.rkt @@ -117,8 +117,12 @@ '((eval -42)))) (test-case "neg-lit/roundtrip: eval -3/7" + ;; Slash-containing number lexemes are wrapped in a $rat-literal sentinel + ;; by the WS reader so that `0/1`, `1/1`, etc. retain Rat-ness even when + ;; string->number would simplify them to integers. (See eigentrust pitfalls + ;; doc #3 and tests/test-rat-literal-in-list.rkt.) (check-equal? (read-all-forms-string "eval -3/7") - '((eval -3/7)))) + '((eval ($rat-literal -3/7))))) (test-case "neg-lit/roundtrip: def x -5" (check-equal? (read-all-forms-string "def x -5") diff --git a/racket/prologos/tests/test-rat-literal-in-list.rkt b/racket/prologos/tests/test-rat-literal-in-list.rkt new file mode 100644 index 000000000..026acc00b --- /dev/null +++ b/racket/prologos/tests/test-rat-literal-in-list.rkt @@ -0,0 +1,147 @@ +#lang racket/base + +;;; +;;; Tests for eigentrust pitfalls doc #3: +;;; `0/1` and `1/1` inside nested list literals silently became Int. +;;; +;;; Root cause: the WS reader called `string->number "0/1"` which Racket +;;; simplifies to the integer 0, losing the user's `Rat`-literal intent. +;;; Downstream typing then saw a mix of Int and Rat in `'[0/1 1/2 ...]` +;;; and either picked Int or rejected the outer `[List Rat]` annotation. +;;; +;;; Fix shape (a — preserve at parse time): a number-token lexeme that +;;; contains `/` is wrapped in a `($rat-literal val)` sentinel by the +;;; WS reader, mirroring `($nat-literal val)` for `42N`. Both surface +;;; parsers (parser.rkt for the preparse path, tree-parser.rkt for the +;;; cell pipeline path) then route the sentinel / slash-lexeme to +;;; `surf-rat-lit`, preserving Rat-ness even when the simplified value +;;; is an integer. +;;; +;;; Files touched: +;;; - racket/prologos/parse-reader.rkt (emit $rat-literal sentinel) +;;; - racket/prologos/parser.rkt (handle $rat-literal sentinel) +;;; - racket/prologos/tree-parser.rkt (slash-lexeme → surf-rat-lit; +;;; flatten-ws-datum exemption) +;;; + +(require rackunit + racket/string + "test-support.rkt") + +;; ======================================== +;; A. The eigentrust reproducer +;; ======================================== +;; The original failing form, scaled to 2x2 to keep the test small. +;; Before the fix this raised "Type mismatch [List [List Rat]] ... +;; '['[0 1/2] '[1/2 0]]" because `0/1` had been collapsed to Int 0. + +(test-case "rat-literal-in-list/eigentrust-reproducer-2x2" + ;; Before the fix this raised "Type mismatch [List [List Rat]] ...". + ;; After: elaborates and evaluates cleanly, and the inferred type is + ;; the annotated `[List [List Rat]]`. (Pretty-print collapses 0/1 → 0 + ;; in the value display, but the surrounding type is what matters.) + (define out + (run-ns-ws-last + "ns rl1\ndef C : [List [List Rat]] := \x27[\x27[0/1 1/2] \x27[1/2 0/1]]\neval C")) + (check-true (string-contains? out "List [prologos::data::list::List Rat]")) + (check-false (string-contains? out "Type mismatch"))) + +(test-case "rat-literal-in-list/eigentrust-reproducer-3x3" + ;; The exact 3x3 form from the eigentrust pitfalls doc. + (check-equal? + (run-ns-ws-last + "ns rl2\ndef C : [List [List Rat]]\n := \x27[\x27[0/1 1/2 1/2] \x27[1/2 0/1 1/2] \x27[1/2 1/2 0/1]]\ninfer C") + "[prologos::data::list::List [prologos::data::list::List Rat]]")) + +;; ======================================== +;; B. Top-level Rat literal regressions +;; ======================================== +;; Bare `0/1` and `1/1` outside a list must still be Rat (not Int). + +(test-case "rat-literal-in-list/bare-zero-over-one-is-rat" + (check-equal? (run-ns-ws-last "ns rl3\ninfer 0/1") "Rat")) + +(test-case "rat-literal-in-list/bare-one-over-one-is-rat" + (check-equal? (run-ns-ws-last "ns rl4\ninfer 1/1") "Rat")) + +(test-case "rat-literal-in-list/bare-half-is-rat" + (check-equal? (run-ns-ws-last "ns rl5\ninfer 1/2") "Rat")) + +(test-case "rat-literal-in-list/bare-negative-rat" + (check-equal? (run-ns-ws-last "ns rl6\ninfer -3/7") "Rat")) + +(test-case "rat-literal-in-list/bare-negative-zero-over-one" + (check-equal? (run-ns-ws-last "ns rl7\ninfer -0/1") "Rat")) + +;; ======================================== +;; C. Bare integer regression: `0` and `42` are still Int +;; ======================================== +;; The fix MUST NOT promote bare integers to Rat — only slash-containing +;; lexemes are Rat literals. + +(test-case "rat-literal-in-list/bare-zero-is-int" + (check-equal? (run-ns-ws-last "ns ri1\ninfer 0") "Int")) + +(test-case "rat-literal-in-list/bare-42-is-int" + (check-equal? (run-ns-ws-last "ns ri2\ninfer 42") "Int")) + +(test-case "rat-literal-in-list/bare-negative-int" + (check-equal? (run-ns-ws-last "ns ri3\ninfer -7") "Int")) + +;; ======================================== +;; D. Single-level list literals +;; ======================================== +;; The simplest list-literal case: `'[0/1 1/2]` should be `List Rat`, +;; not `List Int`. + +(test-case "rat-literal-in-list/single-list-zero-and-half" + (check-equal? + (run-ns-ws-last "ns rl8\ninfer \x27[0/1 1/2]") + "[prologos::data::list::List Rat]")) + +(test-case "rat-literal-in-list/single-list-with-one-over-one" + (check-equal? + (run-ns-ws-last "ns rl9\ninfer \x27[1/1 1/2 0/1]") + "[prologos::data::list::List Rat]")) + +;; ======================================== +;; E. PVec literals — companion path +;; ======================================== +;; Per the eigentrust pitfalls doc #10, `@[0/1 1/2]` already worked for PVec. +;; Confirm it continues to work and that this fix doesn't regress the PVec path. + +(test-case "rat-literal-in-list/pvec-zero-and-half" + (check-equal? + (run-ns-ws-last "ns rp1\ninfer @[0/1 1/2]") + "(PVec Rat)")) + +(test-case "rat-literal-in-list/pvec-with-one-over-one" + (check-equal? + (run-ns-ws-last "ns rp2\ninfer @[1/1 1/2 0/1]") + "(PVec Rat)")) + +;; ======================================== +;; F. Annotated def with mixed simplifying / non-simplifying rationals +;; ======================================== +;; The annotation `[List Rat]` declares the target type. Before the fix, +;; `0/1` collapsed to Int and the annotation check failed. After the fix, +;; the slash sentinel preserves Rat-ness and the def elaborates cleanly. + +(test-case "rat-literal-in-list/annotated-list-with-zero-over-one" + (check-true + (string-contains? + (run-ns-ws-last + "ns rl10\ndef xs : [List Rat] := \x27[0/1 1/2 1/1]\neval xs") + "List Rat"))) + +;; ======================================== +;; G. Sexp-mode (rat ...) constructor still works +;; ======================================== +;; Defensive: the explicit `(rat 0/1)` constructor was never affected, +;; but confirm the explicit-constructor path remains intact. + +(test-case "rat-literal-in-list/explicit-rat-zero-over-one" + (check-equal? (run-ns-last "(ns rs1)\n(infer (rat 0/1))") "Rat")) + +(test-case "rat-literal-in-list/explicit-rat-one-over-one" + (check-equal? (run-ns-last "(ns rs2)\n(infer (rat 1/1))") "Rat")) diff --git a/racket/prologos/tree-parser.rkt b/racket/prologos/tree-parser.rkt index e386fe11f..d555cd6b1 100644 --- a/racket/prologos/tree-parser.rkt +++ b/racket/prologos/tree-parser.rkt @@ -226,10 +226,14 @@ [(and (> (string-length lex) 1) (char=? (string-ref lex 0) #\:)) (surf-keyword (string->symbol (substring lex 1)) loc)] - ;; Number — integer + ;; Number — integer or rational. + ;; A slash-containing lexeme (e.g. 0/1, 1/2, -3/7) is a Rat literal even + ;; when string->number simplifies the value to an integer (e.g. `0/1` → 0). + ;; The `/` is a load-bearing source token that survives simplification. [(string->number lex) => (lambda (n) (cond + [(and (exact-integer? n) (string-contains? lex "/")) (surf-rat-lit n loc)] [(and (exact-integer? n) (>= n 0)) (surf-int-lit n loc)] [(exact-integer? n) (surf-int-lit n loc)] [(rational? n) (surf-rat-lit n loc)] @@ -613,7 +617,7 @@ item] [(and (pair? item) (not (memq (car item) '($brace-params $angle-type $list-literal - $nat-literal $approx-literal $decimal-literal + $nat-literal $rat-literal $approx-literal $decimal-literal $set-literal $vec-literal $foreign-block $typed-hole $solver-config quote $quote))) (>= (length item) 2)