From 90c29099a1cf614b4fd334b4f53cc73ab7f67a8c Mon Sep 17 00:00:00 2001 From: Bkkpro1980 Date: Thu, 24 Sep 2026 00:51:15 +0700 Subject: [PATCH 1/4] Fix numeric literal precision and constant folding --- src/prometheus/ast.lua | 142 ++++++++++++++++++++++++----------- src/prometheus/parser.lua | 3 +- src/prometheus/tokenizer.lua | 46 +++++++++++- src/prometheus/unparser.lua | 35 +++++++-- 4 files changed, 173 insertions(+), 53 deletions(-) diff --git a/src/prometheus/ast.lua b/src/prometheus/ast.lua index ef390dd7..895fb8f4 100644 --- a/src/prometheus/ast.lua +++ b/src/prometheus/ast.lua @@ -118,6 +118,44 @@ local astKindExpressionLookup = { Ast.AstKind = AstKind; +-- Lua numbers use IEEE-754 doubles, so only integers through 2^53 - 1 are safe. +local MAX_SAFE_INT = 9007199254740991 -- 2^53 - 1 +local MIN_SAFE_INT = -9007199254740991 + +local function isSafeInteger(n) + return type(n) == "number" + and n >= MIN_SAFE_INT + and n <= MAX_SAFE_INT + and (n % 1 == 0) +end + +local function canCompareEqualityConstants(a, b) + if type(a) ~= type(b) then + return true + end + if type(a) == "number" then + return (a == a) and (b == b) + and a >= MIN_SAFE_INT and a <= MAX_SAFE_INT + and b >= MIN_SAFE_INT and b <= MAX_SAFE_INT + end + return true +end + +local function canCompareOrderConstants(a, b) + if type(a) ~= type(b) then + return false + end + if type(a) == "number" then + return (a == a) and (b == b) + and a >= MIN_SAFE_INT and a <= MAX_SAFE_INT + and b >= MIN_SAFE_INT and b <= MAX_SAFE_INT + end + if type(a) == "string" then + return true + end + return false +end + function Ast.astKindExpressionToNumber(kind) return astKindExpressionLookup[kind] or 100; end @@ -432,11 +470,12 @@ function Ast.NilExpression() } end -function Ast.NumberExpression(value) +function Ast.NumberExpression(value, raw) return { kind = AstKind.NumberExpression, isConstant = true, value = value, + raw = raw, } end @@ -450,9 +489,10 @@ end function Ast.OrExpression(lhs, rhs, simplify) if(simplify and rhs.isConstant and lhs.isConstant) then - local success, val = pcall(function() return lhs.value or rhs.value end); - if success then - return Ast.ConstantNode(val); + if lhs.value then + return lhs; + else + return rhs; end end @@ -466,9 +506,10 @@ end function Ast.AndExpression(lhs, rhs, simplify) if(simplify and rhs.isConstant and lhs.isConstant) then - local success, val = pcall(function() return lhs.value and rhs.value end); - if success then - return Ast.ConstantNode(val); + if lhs.value then + return rhs; + else + return lhs; end end @@ -481,7 +522,7 @@ function Ast.AndExpression(lhs, rhs, simplify) end function Ast.LessThanExpression(lhs, rhs, simplify) - if(simplify and rhs.isConstant and lhs.isConstant) then + if(simplify and rhs.isConstant and lhs.isConstant and canCompareOrderConstants(lhs.value, rhs.value)) then local success, val = pcall(function() return lhs.value < rhs.value end); if success then return Ast.ConstantNode(val); @@ -497,7 +538,7 @@ function Ast.LessThanExpression(lhs, rhs, simplify) end function Ast.GreaterThanExpression(lhs, rhs, simplify) - if(simplify and rhs.isConstant and lhs.isConstant) then + if(simplify and rhs.isConstant and lhs.isConstant and canCompareOrderConstants(lhs.value, rhs.value)) then local success, val = pcall(function() return lhs.value > rhs.value end); if success then return Ast.ConstantNode(val); @@ -513,7 +554,7 @@ function Ast.GreaterThanExpression(lhs, rhs, simplify) end function Ast.LessThanOrEqualsExpression(lhs, rhs, simplify) - if(simplify and rhs.isConstant and lhs.isConstant) then + if(simplify and rhs.isConstant and lhs.isConstant and canCompareOrderConstants(lhs.value, rhs.value)) then local success, val = pcall(function() return lhs.value <= rhs.value end); if success then return Ast.ConstantNode(val); @@ -529,7 +570,7 @@ function Ast.LessThanOrEqualsExpression(lhs, rhs, simplify) end function Ast.GreaterThanOrEqualsExpression(lhs, rhs, simplify) - if(simplify and rhs.isConstant and lhs.isConstant) then + if(simplify and rhs.isConstant and lhs.isConstant and canCompareOrderConstants(lhs.value, rhs.value)) then local success, val = pcall(function() return lhs.value >= rhs.value end); if success then return Ast.ConstantNode(val); @@ -545,7 +586,7 @@ function Ast.GreaterThanOrEqualsExpression(lhs, rhs, simplify) end function Ast.NotEqualsExpression(lhs, rhs, simplify) - if(simplify and rhs.isConstant and lhs.isConstant) then + if(simplify and rhs.isConstant and lhs.isConstant and canCompareEqualityConstants(lhs.value, rhs.value)) then local success, val = pcall(function() return lhs.value ~= rhs.value end); if success then return Ast.ConstantNode(val); @@ -561,7 +602,7 @@ function Ast.NotEqualsExpression(lhs, rhs, simplify) end function Ast.EqualsExpression(lhs, rhs, simplify) - if(simplify and rhs.isConstant and lhs.isConstant) then + if(simplify and rhs.isConstant and lhs.isConstant and canCompareEqualityConstants(lhs.value, rhs.value)) then local success, val = pcall(function() return lhs.value == rhs.value end); if success then return Ast.ConstantNode(val); @@ -578,9 +619,8 @@ end function Ast.StrCatExpression(lhs, rhs, simplify) if(simplify and rhs.isConstant and lhs.isConstant) then - local success, val = pcall(function() return lhs.value .. rhs.value end); - if success then - return Ast.ConstantNode(val); + if type(lhs.value) == "string" and type(rhs.value) == "string" then + return Ast.ConstantNode(lhs.value .. rhs.value); end end @@ -594,9 +634,11 @@ end function Ast.AddExpression(lhs, rhs, simplify) if(simplify and rhs.isConstant and lhs.isConstant) then - local success, val = pcall(function() return lhs.value + rhs.value end); - if success then - return Ast.ConstantNode(val); + if isSafeInteger(lhs.value) and isSafeInteger(rhs.value) then + local val = lhs.value + rhs.value; + if isSafeInteger(val) then + return Ast.ConstantNode(val); + end end end @@ -610,9 +652,11 @@ end function Ast.SubExpression(lhs, rhs, simplify) if(simplify and rhs.isConstant and lhs.isConstant) then - local success, val = pcall(function() return lhs.value - rhs.value end); - if success then - return Ast.ConstantNode(val); + if isSafeInteger(lhs.value) and isSafeInteger(rhs.value) then + local val = lhs.value - rhs.value; + if isSafeInteger(val) then + return Ast.ConstantNode(val); + end end end @@ -626,9 +670,11 @@ end function Ast.MulExpression(lhs, rhs, simplify) if(simplify and rhs.isConstant and lhs.isConstant) then - local success, val = pcall(function() return lhs.value * rhs.value end); - if success then - return Ast.ConstantNode(val); + if isSafeInteger(lhs.value) and isSafeInteger(rhs.value) then + local val = lhs.value * rhs.value; + if isSafeInteger(val) then + return Ast.ConstantNode(val); + end end end @@ -641,10 +687,14 @@ function Ast.MulExpression(lhs, rhs, simplify) end function Ast.DivExpression(lhs, rhs, simplify) - if(simplify and rhs.isConstant and lhs.isConstant and rhs.value ~= 0) then - local success, val = pcall(function() return lhs.value / rhs.value end); - if success then - return Ast.ConstantNode(val); + if(simplify and rhs.isConstant and lhs.isConstant) then + if isSafeInteger(lhs.value) and isSafeInteger(rhs.value) and rhs.value ~= 0 then + if lhs.value % rhs.value == 0 then + local val = lhs.value / rhs.value; + if isSafeInteger(val) then + return Ast.ConstantNode(val); + end + end end end @@ -658,9 +708,11 @@ end function Ast.ModExpression(lhs, rhs, simplify) if(simplify and rhs.isConstant and lhs.isConstant) then - local success, val = pcall(function() return lhs.value % rhs.value end); - if success then - return Ast.ConstantNode(val); + if isSafeInteger(lhs.value) and isSafeInteger(rhs.value) and rhs.value ~= 0 then + local val = lhs.value % rhs.value; + if isSafeInteger(val) then + return Ast.ConstantNode(val); + end end end @@ -674,10 +726,7 @@ end function Ast.NotExpression(rhs, simplify) if(simplify and rhs.isConstant) then - local success, val = pcall(function() return not rhs.value end); - if success then - return Ast.ConstantNode(val); - end + return Ast.ConstantNode(not rhs.value); end return { @@ -689,9 +738,11 @@ end function Ast.NegateExpression(rhs, simplify) if(simplify and rhs.isConstant) then - local success, val = pcall(function() return -rhs.value end); - if success then - return Ast.ConstantNode(val); + if isSafeInteger(rhs.value) then + local val = -rhs.value; + if isSafeInteger(val) then + return Ast.ConstantNode(val); + end end end @@ -704,9 +755,8 @@ end function Ast.LenExpression(rhs, simplify) if(simplify and rhs.isConstant) then - local success, val = pcall(function() return #rhs.value end); - if success then - return Ast.ConstantNode(val); + if type(rhs.value) == "string" then + return Ast.ConstantNode(#rhs.value); end end @@ -719,9 +769,11 @@ end function Ast.PowExpression(lhs, rhs, simplify) if(simplify and rhs.isConstant and lhs.isConstant) then - local success, val = pcall(function() return lhs.value ^ rhs.value end); - if success then - return Ast.ConstantNode(val); + if isSafeInteger(lhs.value) and isSafeInteger(rhs.value) and rhs.value >= 0 then + local val = lhs.value ^ rhs.value; + if isSafeInteger(val) then + return Ast.ConstantNode(val); + end end end diff --git a/src/prometheus/parser.lua b/src/prometheus/parser.lua index 57ce4dca..fdf5d1ab 100644 --- a/src/prometheus/parser.lua +++ b/src/prometheus/parser.lua @@ -908,7 +908,8 @@ function Parser:expressionLiteral(scope) -- Number Literal if(is(self, TokenKind.Number)) then - return Ast.NumberExpression(get(self).value); + local tk = get(self); + return Ast.NumberExpression(tk.value, tk.source); end -- True Literal diff --git a/src/prometheus/tokenizer.lua b/src/prometheus/tokenizer.lua index 19295605..c2c8a147 100644 --- a/src/prometheus/tokenizer.lua +++ b/src/prometheus/tokenizer.lua @@ -296,6 +296,42 @@ local function int(self, chars, seperators) return table.concat(buffer); end +local function parseHex(str) + if #str == 0 then return nil end + local num = 0 + for i = 1, #str do + local b = string.byte(str, i) + local digit + if b >= 48 and b <= 57 then + digit = b - 48 + elseif b >= 65 and b <= 70 then + digit = b - 55 + elseif b >= 97 and b <= 102 then + digit = b - 87 + else + break + end + num = num * 16 + digit + end + return num +end + +local function parseBinary(str) + if #str == 0 then return nil end + local num = 0 + for i = 1, #str do + local b = string.byte(str, i) + if b == 48 then + num = num * 2 + elseif b == 49 then + num = num * 2 + 1 + else + break + end + end + return num +end + -- Lex the next token as a Number function Tokenizer:number() local startPos = self.index; @@ -305,14 +341,20 @@ function Tokenizer:number() if self.BinaryNums and is(self, lookupify(self.BinaryNums)) then self.index = self.index + 1; source = int(self, self.BinaryNumberCharsLookup, lookupify(self.DecimalSeperators or {})); - local value = tonumber(source, 2); + local value = parseBinary(source); + if not value then + logger:error(generateError(self, "malformed binary number")); + end return token(self, startPos, Tokenizer.TokenKind.Number, value); end if self.HexadecimalNums and is(self, lookupify(self.HexadecimalNums)) then self.index = self.index + 1; source = int(self, self.HexNumberCharsLookup, lookupify(self.DecimalSeperators or {})); - local value = tonumber(source, 16); + local value = parseHex(source); + if not value then + logger:error(generateError(self, "malformed hexadecimal number")); + end return token(self, startPos, Tokenizer.TokenKind.Number, value); end end diff --git a/src/prometheus/unparser.lua b/src/prometheus/unparser.lua index 0aacd511..b54c45ae 100644 --- a/src/prometheus/unparser.lua +++ b/src/prometheus/unparser.lua @@ -407,15 +407,40 @@ function Unparser:unparseExpression(expression, tabbing) end if(expression.kind == AstKind.NumberExpression) then - local str = tostring(expression.value); - if(str == "inf") then - return "2e1024" + if(expression.raw) then + if self.luaVersion == LuaVersion.Lua51 then + local prefix = expression.raw:sub(1, 2):lower() + -- Lua 5.1 does not support binary literals (0b...) + if prefix ~= "0b" then + return (expression.raw:gsub("_", "")); + end + else + return expression.raw; + end + end + + local val = expression.value; + if(val ~= val) then + return "(0/0)"; end - if(str == "-inf") then - return "-2e1024" + if(val == 1/0) then + return "2e1024"; end + if(val == -1/0) then + return "-2e1024"; + end + + local str; + if(val % 1 == 0 and math.abs(val) <= 9007199254740992) then + str = string.format("%.0f", val); + else + str = string.format("%.17g", val); + end + if(str:sub(1, 2) == "0.") then str = str:sub(2); + elseif(str:sub(1, 3) == "-0.") then + str = "-" .. str:sub(3); end return str; end From 914c3098c76205ac800f957d6f0c2ba887b7790d Mon Sep 17 00:00:00 2001 From: Bkkpro1980 Date: Thu, 24 Sep 2026 15:10:09 +0700 Subject: [PATCH 2/4] Add numeric precision tests --- tests/numeric-precision.lua | 126 ++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 tests/numeric-precision.lua diff --git a/tests/numeric-precision.lua b/tests/numeric-precision.lua new file mode 100644 index 00000000..daa85cb6 --- /dev/null +++ b/tests/numeric-precision.lua @@ -0,0 +1,126 @@ +--============================================================ +-- Numeric Precision & Constant Folding Test Suite +-- Target: Tokenizer, Parser, Ast, Unparser +-- Author: bkkpro1980 +-- Purpose: Verify exact numeric literal preservation, safe constant folding, and safe comparison semantics. +--============================================================ + +local function check(name, actual, expected) + if actual == expected then + print(name .. " PASSED") + else + print(name .. " FAILED") + print("expected:", expected) + print("actual: ", actual) + end +end + +-- 2^32 must remain distinct from the 32-bit maximum. +local x1 = 0x100000000 +local low1 = x1 % 0x100000000 +local high1 = math.floor(x1 / 0x100000000) +check("TEST 1", low1 .. "," .. high1, "0,1") + +-- Verify that both the high and low 32-bit portions are preserved. +local x2 = 0x100000005 +local low2 = x2 % 0x100000000 +local high2 = math.floor(x2 / 0x100000000) +check("TEST 2", low2 .. "," .. high2, "5,1") + +-- 2^53 - 1 is the largest IEEE-754 safe integer. +local x3 = 0x1FFFFFFFFFFFFF +local low3 = x3 % 0x100000000 +local high3 = math.floor(x3 / 0x100000000) +check("TEST 3", low3 .. "," .. high3, "4294967295,2097151") + +-- 2^53 is exactly representable and remains distinct from 2^53 - 1. +local x4 = 9007199254740992 +local diff4 = x4 - 9007199254740991 +local fmt4 = string.format("%.0f", x4) +check("TEST 4", diff4 .. "," .. fmt4, "1,9007199254740992") + +-- Standard constant folding should remain enabled for safe expressions. +local add5 = 2 + 3 +local mul5 = 10 * 20 +local div5 = 12 / 3 +local mod5 = 13 % 5 +local pow5 = 2 ^ 8 +local neg5 = -42 +local str5 = "prom" .. "etheus" +local len5 = #"numeric" +check("TEST 5", add5 .. "," .. mul5 .. "," .. div5 .. "," .. mod5 .. "," .. pow5 .. "," .. neg5 .. "," .. str5 .. "," .. len5, "5,200,4,3,256,-42,prometheus,7") + +-- Safe integer arithmetic should still be folded. +local fold_large_add = 9007199254740990 + 1 +local fold_large_sub = 9007199254740991 - 1 +check("TEST 6", string.format("%.0f,%.0f", fold_large_add, fold_large_sub), "9007199254740991,9007199254740990") + +-- This product exceeds the safe integer range and must not be incorrectly folded. +local hashProduct = 0x811C9DC5 * 0x01000193 +local hashMod = hashProduct % 0x100000000 +check("TEST 7", string.format("%.0f,%.0f", hashProduct, hashMod), "36342807354921656,2275990200") + +-- Inexact division must preserve dyadic fraction precision without assuming (1/3)*3 == 1. +local div_exact = 1000 / 8 +local div_half = 7 / 2 +local div_eighth = 1 / 8 +local ok8 = (div_exact == 125) and (div_half * 2 == 7) and (div_eighth * 8 == 1) +check("TEST 8", ok8, true) + +-- `and` and `or` return their operands rather than booleans. +local or1 = false or 123 +local or2 = 123 or false +local and1 = true and 456 +local and2 = false and 456 +local or_nil = nil or "fallback" +local and_str = "left" and "right" +local or_zero = 0 or "unused" +local and_empty = "" and 789 +check("TEST 9", tostring(or1) .. "," .. tostring(or2) .. "," .. tostring(and1) .. "," .. tostring(and2) .. "," .. tostring(or_nil) .. "," .. tostring(and_str) .. "," .. tostring(or_zero) .. "," .. tostring(and_empty), "123,123,456,false,fallback,right,0,789") + +-- Verify comparisons around the 2^53 boundary. +local c1 = 9007199254740990 < 9007199254740991 +local c2 = 9007199254740991 < 9007199254740992 +local c3 = 9007199254740991 == 9007199254740991 +local c4 = 9007199254740991 ~= 9007199254740992 +local c5 = 9007199254740992 > 9007199254740991 +local c6 = 9007199254740991 >= 9007199254740991 +local c7 = 9007199254740990 <= 9007199254740991 +check("TEST 10", c1 and c2 and c3 and c4 and c5 and c6 and c7, true) + +-- Mixed-type equality is false; string ordering remains valid. +local eq1 = (5 == "5") +local eq2 = (nil == false) +local eq3 = ("abc" == "abc") +local eq4 = ("abc" ~= "def") +local ord1 = ("apple" < "banana") +local ord2 = ("zebra" > "apple") +check("TEST 11", tostring(eq1) .. "," .. tostring(eq2) .. "," .. tostring(eq3) .. "," .. tostring(eq4) .. "," .. tostring(ord1) .. "," .. tostring(ord2), "false,false,true,true,true,true") + +-- A 16-digit float literal must not be truncated to 14 digits by tostring (%.14g). +local f12 = 123456789.0123456 +local s12 = string.format("%.7f", f12) +check("TEST 12", s12, "123456789.0123456") + +-- 4503599627370497 is 2^52 + 1 (16 decimal digits). +-- Subtraction by 2^52 (4503599627370496) must produce 1 without %.14g collapse. +local x13 = 4503599627370497 +local diff13 = x13 - 4503599627370496 +check("TEST 13", diff13, 1) + +-- Negating a large literal must preserve its value and sign. +local neg_large = -0x100000000 +local neg_high = math.floor(neg_large / 0x100000000) +local neg_rem = neg_large % 0x100000000 +check("TEST 14", neg_high .. "," .. neg_rem, "-1,0") + +-- Safe powers fold at compile-time; powers >= 2^53 remain as runtime expressions. +local pow1 = 2 ^ 16 +local pow2 = 2 ^ 32 +local pow3 = 2 ^ 53 + +check( + "TEST 15", + pow1 .. "," .. pow2 .. "," .. string.format("%.0f", pow3), + "65536,4294967296,9007199254740992" +) From a79cf5bbccca5dc6c1970445d2b1626348931f07 Mon Sep 17 00:00:00 2001 From: Bkkpro1980 Date: Fri, 25 Sep 2026 02:45:26 +0700 Subject: [PATCH 3/4] fix(core): prevent precision loss and VM corruption in number mutations and unparser --- src/prometheus/ast.lua | 52 ++++--- src/prometheus/steps/NumbersToExpressions.lua | 132 ++++++++++++------ src/prometheus/unparser.lua | 14 +- tests/numeric-precision.lua | 29 +++- 4 files changed, 158 insertions(+), 69 deletions(-) diff --git a/src/prometheus/ast.lua b/src/prometheus/ast.lua index 895fb8f4..ce5c1124 100644 --- a/src/prometheus/ast.lua +++ b/src/prometheus/ast.lua @@ -488,37 +488,35 @@ function Ast.StringExpression(value) end function Ast.OrExpression(lhs, rhs, simplify) - if(simplify and rhs.isConstant and lhs.isConstant) then - if lhs.value then - return lhs; - else - return rhs; - end - end + if(simplify and rhs.isConstant and lhs.isConstant) then + local success, val = pcall(function() return lhs.value or rhs.value end); + if success then + return Ast.ConstantNode(val); + end + end - return { - kind = AstKind.OrExpression, - lhs = lhs, - rhs = rhs, - isConstant = false, - } + return { + kind = AstKind.OrExpression, + lhs = lhs, + rhs = rhs, + isConstant = false, + } end function Ast.AndExpression(lhs, rhs, simplify) - if(simplify and rhs.isConstant and lhs.isConstant) then - if lhs.value then - return rhs; - else - return lhs; - end - end - - return { - kind = AstKind.AndExpression, - lhs = lhs, - rhs = rhs, - isConstant = false, - } + if(simplify and rhs.isConstant and lhs.isConstant) then + local success, val = pcall(function() return lhs.value and rhs.value end); + if success then + return Ast.ConstantNode(val); + end + end + + return { + kind = AstKind.AndExpression, + lhs = lhs, + rhs = rhs, + isConstant = false, + } end function Ast.LessThanExpression(lhs, rhs, simplify) diff --git a/src/prometheus/steps/NumbersToExpressions.lua b/src/prometheus/steps/NumbersToExpressions.lua index 6d4b3838..74a43757 100644 --- a/src/prometheus/steps/NumbersToExpressions.lua +++ b/src/prometheus/steps/NumbersToExpressions.lua @@ -47,10 +47,54 @@ NumbersToExpressions.SettingsDescriptor = { }, } +local MAX_SAFE_INT = 9007199254740991 -- 2^53 - 1 + +local function isSafeInteger(n) + return type(n) == "number" + and n == math.floor(n) + and n >= -MAX_SAFE_INT + and n <= MAX_SAFE_INT +end + local function generateModuloExpression(n) - local rhs = n + math.random(1, 2^24) - local multiplier = math.random(1, 2^8) - local lhs = n + (multiplier * rhs) + if not isSafeInteger(n) then + return nil + end + + local offset = math.random(1, 2 ^ 24) + local rhs + + if n >= 0 then + rhs = n + offset + else + rhs = n - offset + end + + if rhs == 0 or not isSafeInteger(rhs) then + return nil + end + + local maxMultiplier = math.floor( + (MAX_SAFE_INT - math.abs(n)) / math.abs(rhs) + ) + + maxMultiplier = math.min(maxMultiplier, 2 ^ 8) + + if maxMultiplier < 1 then + return nil + end + + local multiplier = math.random(1, maxMultiplier) + local lhs = n + multiplier * rhs + + if not isSafeInteger(lhs) then + return nil + end + + if lhs % rhs ~= n then + return nil + end + return lhs, rhs end @@ -68,7 +112,7 @@ function NumbersToExpressions:init(_) function(val, depth) -- Addition local val2 = math.random(-2 ^ 20, 2 ^ 20) local diff = val - val2 - if tonumber(tostring(diff)) + tonumber(tostring(val2)) ~= val then + if diff + val2 ~= val then return false end return Ast.AddExpression( @@ -81,7 +125,7 @@ function NumbersToExpressions:init(_) function(val, depth) -- Subtraction local val2 = math.random(-2 ^ 20, 2 ^ 20) local diff = val + val2 - if tonumber(tostring(diff)) - tonumber(tostring(val2)) ~= val then + if diff - val2 ~= val then return false end return Ast.SubExpression( @@ -93,9 +137,11 @@ function NumbersToExpressions:init(_) function(val, depth) -- Modulo local lhs, rhs = generateModuloExpression(val) - if tonumber(tostring(lhs)) % tonumber(tostring(rhs)) ~= val then + + if not lhs then return false end + return Ast.ModExpression( self:CreateNumberExpression(lhs, depth), self:CreateNumberExpression(rhs, depth), @@ -106,54 +152,62 @@ function NumbersToExpressions:init(_) end function NumbersToExpressions:CreateNumberExpression(val, depth) - if depth > 0 and math.random() >= self.InternalThreshold or depth > 15 then - local format = self.AllowedNumberRepresentations[math.random(1, #self.AllowedNumberRepresentations)] - if not self.NumberRepresentationMutation then + if (depth > 0 and math.random() >= self.InternalThreshold) or depth > 15 then + if not self.NumberRepresentationMutation or type(val) ~= "number" then return Ast.NumberExpression(val) end + local format = self.AllowedNumberRepresentations[math.random(1, #self.AllowedNumberRepresentations)] + if format == "hex" then - if val ~= math.floor(val) or val < 0 then - return Ast.NumberExpression(val) - end - local hexStr = string.format("0x%X", val) - local result = "" - for i = 1, #hexStr do - local c = hexStr:sub(i, i) - if math.random() > 0.5 then - result = result .. c:upper() - else - result = result .. c:lower() + -- Lua 5.1 %X only supports positive 32-bit unsigned integers up to 0xFFFFFFFF + if val == math.floor(val) and val >= 0 and val <= 0xFFFFFFFF then + local hexStr = string.format("0x%X", val) + if tonumber(hexStr) == val then + local result = "" + for i = 1, #hexStr do + local c = hexStr:sub(i, i) + if math.random() > 0.5 then + result = result .. c:upper() + else + result = result .. c:lower() + end + end + return Ast.NumberExpression(val, result) end end - return Ast.NumberExpression(result) + return Ast.NumberExpression(val) end if format == "binary" then - if val ~= math.floor(val) or val < 0 then - return Ast.NumberExpression(val) - end - local binary = "" - local n = val - if n == 0 then - binary = "0" - else - while n > 0 do - binary = (n % 2) .. binary - n = math.floor(n / 2) + if val == math.floor(val) and val >= 0 then + local binary = "" + local n = val + if n == 0 then + binary = "0" + else + while n > 0 do + binary = (n % 2) .. binary + n = math.floor(n / 2) + end end + local binStr = "0b" .. binary + return Ast.NumberExpression(val, binStr) end - return Ast.NumberExpression("0b" .. binary) + return Ast.NumberExpression(val) end if format == "scientific" then - if val == 0 then - return Ast.NumberExpression(val) + if val ~= 0 then + local exp = math.floor(math.log10(math.abs(val))) + local mantissa = val / (10 ^ exp) + local sciStr = string.format("%.15ge%d", mantissa, exp) + -- Ensure parsing it back produces the exact same numeric value! + if tonumber(sciStr) == val then + return Ast.NumberExpression(val, sciStr) + end end - - local exp = math.floor(math.log10(math.abs(val))) - local mantissa = val / (10 ^ exp) - return Ast.NumberExpression(string.format("%.15ge%d", mantissa, exp)) + return Ast.NumberExpression(val) end if format == "normal" then diff --git a/src/prometheus/unparser.lua b/src/prometheus/unparser.lua index b54c45ae..35cabfe1 100644 --- a/src/prometheus/unparser.lua +++ b/src/prometheus/unparser.lua @@ -407,9 +407,12 @@ function Unparser:unparseExpression(expression, tabbing) end if(expression.kind == AstKind.NumberExpression) then - if(expression.raw) then + local val = expression.value; + + if(expression.raw and type(val) == "number") then if self.luaVersion == LuaVersion.Lua51 then local prefix = expression.raw:sub(1, 2):lower() + -- Lua 5.1 does not support binary literals (0b...) if prefix ~= "0b" then return (expression.raw:gsub("_", "")); @@ -419,18 +422,24 @@ function Unparser:unparseExpression(expression, tabbing) end end - local val = expression.value; + if type(val) == "string" then + return val; + end + if(val ~= val) then return "(0/0)"; end + if(val == 1/0) then return "2e1024"; end + if(val == -1/0) then return "-2e1024"; end local str; + if(val % 1 == 0 and math.abs(val) <= 9007199254740992) then str = string.format("%.0f", val); else @@ -442,6 +451,7 @@ function Unparser:unparseExpression(expression, tabbing) elseif(str:sub(1, 3) == "-0.") then str = "-" .. str:sub(3); end + return str; end diff --git a/tests/numeric-precision.lua b/tests/numeric-precision.lua index daa85cb6..96640732 100644 --- a/tests/numeric-precision.lua +++ b/tests/numeric-precision.lua @@ -58,7 +58,7 @@ check("TEST 6", string.format("%.0f,%.0f", fold_large_add, fold_large_sub), "900 -- This product exceeds the safe integer range and must not be incorrectly folded. local hashProduct = 0x811C9DC5 * 0x01000193 local hashMod = hashProduct % 0x100000000 -check("TEST 7", string.format("%.0f,%.0f", hashProduct, hashMod), "36342807354921656,2275990200") +check("TEST 7", string.format("%.0f,%.0f", hashProduct, hashMod), "36342608889142560,84696352") -- Inexact division must preserve dyadic fraction precision without assuming (1/3)*3 == 1. local div_exact = 1000 / 8 @@ -124,3 +124,30 @@ check( pow1 .. "," .. pow2 .. "," .. string.format("%.0f", pow3), "65536,4294967296,9007199254740992" ) + +-- Large modulo operands must survive NumbersToExpressions without losing precision. +local mod_large1 = 4503599627370497 % 12345 +local mod_large2 = 9007199254740000 % 65536 +check( + "TEST 16", + mod_large1 .. "," .. mod_large2, + "11177,64544" +) + +-- Negative modulo generation must preserve Lua's modulo semantics. +local mod_negative1 = -123456789 % 1000 +local mod_negative2 = -4503599627370497 % 12345 +check( + "TEST 17", + mod_negative1 .. "," .. mod_negative2, + "211,116" +) + +-- Numbers near the safe-integer boundary must remain exact. +local boundary_pos = 9007199254740991 +local boundary_neg = -9007199254740991 +check( + "TEST 18", + string.format("%.0f,%.0f", boundary_pos, boundary_neg), + "9007199254740991,-9007199254740991" +) From 1b85b33f12be010b6c4bb0daf2df18b6d4d7518b Mon Sep 17 00:00:00 2001 From: Bkkpro1980 Date: Fri, 25 Sep 2026 03:17:12 +0700 Subject: [PATCH 4/4] fix(tokenizer): preserve exact IEEE-754 rounding on large literals through Vmify --- src/prometheus/tokenizer.lua | 54 +++++++++++++++++++----------------- tests/numeric-precision.lua | 12 +++++++- 2 files changed, 39 insertions(+), 27 deletions(-) diff --git a/src/prometheus/tokenizer.lua b/src/prometheus/tokenizer.lua index c2c8a147..91f2b1b3 100644 --- a/src/prometheus/tokenizer.lua +++ b/src/prometheus/tokenizer.lua @@ -296,40 +296,42 @@ local function int(self, chars, seperators) return table.concat(buffer); end +local binToHex = { + ["0000"] = "0", ["0001"] = "1", ["0010"] = "2", ["0011"] = "3", + ["0100"] = "4", ["0101"] = "5", ["0110"] = "6", ["0111"] = "7", + ["1000"] = "8", ["1001"] = "9", ["1010"] = "a", ["1011"] = "b", + ["1100"] = "c", ["1101"] = "d", ["1110"] = "e", ["1111"] = "f", +} + local function parseHex(str) if #str == 0 then return nil end - local num = 0 - for i = 1, #str do - local b = string.byte(str, i) - local digit - if b >= 48 and b <= 57 then - digit = b - 48 - elseif b >= 65 and b <= 70 then - digit = b - 55 - elseif b >= 97 and b <= 102 then - digit = b - 87 - else - break - end - num = num * 16 + digit - end - return num + + -- Let Lua's native C string-to-number parser perform exact IEEE-754 round-to-nearest-even + return tonumber("0x" .. str) end local function parseBinary(str) if #str == 0 then return nil end - local num = 0 - for i = 1, #str do - local b = string.byte(str, i) - if b == 48 then - num = num * 2 - elseif b == 49 then - num = num * 2 + 1 - else - break + if str:find("[^01]") then return nil end + + -- For <= 53 bits, direct integer accumulation into double is exact + if #str <= 53 then + local num = 0 + for i = 1, #str do + num = num * 2 + (string.byte(str, i) - 48) end + return num + end + + -- For > 53 bits, convert 4-bit nibbles to hex to preserve exact IEEE-754 rounding via tonumber + local pad = (4 - (#str % 4)) % 4 + local padded = string.rep("0", pad) .. str + local hexBuf = {} + for i = 1, #padded, 4 do + local nibble = padded:sub(i, i + 3) + hexBuf[#hexBuf + 1] = binToHex[nibble] end - return num + return tonumber("0x" .. table.concat(hexBuf)) end -- Lex the next token as a Number diff --git a/tests/numeric-precision.lua b/tests/numeric-precision.lua index 96640732..a993f306 100644 --- a/tests/numeric-precision.lua +++ b/tests/numeric-precision.lua @@ -1,3 +1,4 @@ +--!nolint IntegerParsing --============================================================ -- Numeric Precision & Constant Folding Test Suite -- Target: Tokenizer, Parser, Ast, Unparser @@ -140,7 +141,7 @@ local mod_negative2 = -4503599627370497 % 12345 check( "TEST 17", mod_negative1 .. "," .. mod_negative2, - "211,116" + "211,1168" ) -- Numbers near the safe-integer boundary must remain exact. @@ -151,3 +152,12 @@ check( string.format("%.0f,%.0f", boundary_pos, boundary_neg), "9007199254740991,-9007199254740991" ) + +-- Large hex literals (>53 bits) must round to nearest-even identically to Lua/Luau, +-- even through passes like Vmify that reconstruct numbers without raw text. +local hex_large = 0x2944075f548d8d9 +check( + "TEST 19", + string.format("%.0f", hex_large), + "185844359999576288" +) \ No newline at end of file