From 28a4eb30b4447c896ee713cc8bfd301ce78aa9db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Albert=20Castellana=20LLu=C3=ADs?= Date: Fri, 19 Jun 2026 14:28:02 +0200 Subject: [PATCH] =?UTF-8?q?term:=20self-normalize=20exponent=20encoding=20?= =?UTF-8?q?so=20identity=20is=20libc-independent=20(=C2=A74.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit num_enc now rewrites the %.17g exponent to the canonical e±dd form (lowercase e, explicit sign, >=2 digits) instead of trusting platform printf. encode / fingerprint / policy identity are now byte-identical on every libc — previously a libc that pads exponents differently (e.g. MSVC's "e-005") forked the identity of any policy carrying an exponent-form number, breaking the cache key, replay/audit, and on-chain commitment. Stays sigma-pol/v1: this rewrites 0 of the committed golden vectors (all already canonical), so no existing identity rotates; only previously non-conformant platforms change. Adds a direct conformance test for the normalization (tests/unit/ir_term.lua) — the golden's exponent vectors are already canonical and a 2-digit-printf CI emits canonical form from %.17g, so the golden alone can't catch a regression of this logic. Full suite: 571 passed, 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) --- llm_policy/term.lua | 21 ++++++++++++++++++++- tests/unit/ir_term.lua | 29 +++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/llm_policy/term.lua b/llm_policy/term.lua index 9719d4c..e37b791 100644 --- a/llm_policy/term.lua +++ b/llm_policy/term.lua @@ -234,12 +234,31 @@ end -- encode (canonical serialization; the hash input) -- =========================================================================== +-- Normalize a printf %g exponent to the canonical e±dd form — lowercase e, +-- explicit sign, exponent zero-padded to a minimum of two digits (more only +-- when needed). This lets the reference encoder self-enforce the SIGMA-POL §4.1 +-- grammar instead of trusting platform printf (e.g. MSVC's three-digit "e-005" +-- collapses to "e-05"), so encode/fingerprint/identity are byte-identical on +-- every libc. Exposed as T._normalize_exp so the conformance test can feed it +-- non-canonical inputs directly — a 2-digit-printf host never produces them +-- through %.17g, so encode() alone can't exercise this branch there. +local function normalize_exp(s) + return (s:gsub("[eE]([%+%-]?)(%d+)", function(sign, digits) + if sign == "" then sign = "+" end + digits = digits:gsub("^0+", "") -- strip leading zeros + if #digits == 0 then digits = "0" end + if #digits < 2 then digits = string.rep("0", 2 - #digits) .. digits end + return "e" .. sign .. digits + end)) +end +T._normalize_exp = normalize_exp + local function num_enc(v) if v == 0 then return "0" end -- normalizes -0 if v % 1 == 0 and v >= -2^53 and v <= 2^53 then return string.format("%.0f", v) end - return string.format("%.17g", v) + return normalize_exp(string.format("%.17g", v)) end local function str_enc(v) diff --git a/tests/unit/ir_term.lua b/tests/unit/ir_term.lua index 7b71797..bbc882a 100644 --- a/tests/unit/ir_term.lua +++ b/tests/unit/ir_term.lua @@ -195,3 +195,32 @@ t.test("check: chain entries are closed records (identity stays injective)", fun sort, err = T.check({ "chain", { { provider = "p1", model = "m1", note = "x" } } }) t.falsy(sort, "extra record key in a chain entry is rejected") end) + +-- §4.1 canonical exponent. The golden vectors only carry ALREADY-canonical +-- exponents, and a 2-digit-printf libc (glibc/macOS) emits canonical form +-- straight from %.17g — so encode()/golden alone can't catch a regression of +-- the normalization there. Test the normalizer directly on the non-canonical +-- forms a 3-digit/odd libc (e.g. MSVC "e-005") would produce. +t.test("encode: exponent is normalized to canonical e±dd (SIGMA-POL §4.1)", function() + local ne = T._normalize_exp + t.eq(ne("1e-005"), "1e-05", "MSVC 3-digit exponent collapses") + t.eq(ne("1E-05"), "1e-05", "uppercase E lowercased") + t.eq(ne("1e-5"), "1e-05", "single-digit exponent padded to two") + t.eq(ne("1e+017"), "1e+17", "3-digit positive exponent stripped") + t.eq(ne("1e5"), "1e+05", "missing sign inserted") + t.eq(ne("1.0000000000000001e-05"), "1.0000000000000001e-05", "canonical is idempotent") + t.eq(ne("1e+100"), "1e+100", "3 digits kept when the exponent needs them") + t.eq(ne("-2.5e-10"), "-2.5e-10", "mantissa sign preserved") + t.eq(ne("0.5"), "0.5", "non-exponent string untouched") + t.eq(ne("200000"), "200000", "integer string untouched") +end) + +-- Integration: exponent-range params round-trip through the full encoder to +-- canonical bytes (also a strong guard on any 3-digit-printf CI job). +t.test("encode: exponent-range number params encode canonically", function() + local pol = { "policy", { "ev_zero" }, + { "and", { "meets_req" }, { "cmp", "price_out", "le", 1e-5 } }, + { "neg", { "normalize", { "field", "price_out" } } }, + { "argmax" }, { "id" }, { "always", { action = "next_candidate" } } } + t.contains(enc(pol), "1.0000000000000001e-05", "tiny price ceiling encodes canonically") +end)