Skip to content

fix(java): dispatch fromLua on actual Lua type tag, not coercion (#175) - #176

Merged
Liam0205 merged 8 commits into
masterfrom
fix/175-lua-string-number-coercion
Jul 23, 2026
Merged

fix(java): dispatch fromLua on actual Lua type tag, not coercion (#175)#176
Liam0205 merged 8 commits into
masterfrom
fix/175-lua-string-number-coercion

Conversation

@Liam0205

Copy link
Copy Markdown
Owner

Summary

TransformByLua.fromLua dispatched Lua scalars on luaj's isnumber()/isstring(), which implement Lua coercion semantics, not type identity: LuaString.isnumber() is true for any numeric-looking string, and LuaNumber.isstring() is true for every number. A Lua string like "1777288596209286259" therefore took the number branch — losing string type identity ("42"42, "007"7) and, past 2^53, corrupting the value itself through the todouble() round-trip ("1777288596209286259"1777288596209286144) — silently diverging from Go (gopher-lua type switch, wangshu kind tags) and C++ (lua_type() switch), which dispatch on actual type tags.

Fix: dispatch on v.type() == TNUMBER / TSTRING — the only unambiguous dispatch (swapping the check order would break the reverse direction), and the same pattern the function's own table-key check already used.

Closes #175.

Changes

  • fix(java): fromLua scalar dispatch on real type tags; TransformByLuaTypeIdentityTest (8 cases) pins the returned Java class (assertInstanceOf) across the danger zones — 19-digit IDs, 2^53+1, leading zeros, table mixes, the toLua round-trip, and real numbers / arithmetic coercion staying numeric. Verified red on pre-fix code (6/8 fail), green after.
  • test(fixtures): 4 operator-level cases + fixtures/pipelines/lua_string_number_identity.json (item + common mode, 2 seed items for per-item coverage) — feeds cross-validate sections 3/9's type-preserving byte-exact comparison across all three runtimes. A _comment documents the fixture layering boundary (operator-fixture comparators stringify, so they pin value-level parity only).
  • test(fuzz): the differential fuzzer's generated configs never emitted flow_contract, so every engine projected outputs to {} — the comparison saw exit codes and counts but never a computed field value, making this entire bug class invisible by construction. Now ~40% of rounds project all accumulated outputs, and the Lua identity pass-through is forced (25%) when a name/tag field is upstream. Detection verified end-to-end: pre-fix Java diverges from Go on a real generated round; fixed Java matches; 500 fresh rounds with no false positives.
  • docs(llmdoc): reflection + promotions (type-tag dispatch rule with the three-host-library is* semantics table; "fuzz signal must reach the comparison surface" rule; fixture-comparator layering guide).
  • bump: v0.10.15 (306 cross-validate checks green).

Review

Closed a full local independent review loop (close-local-code-review): 5 blind reviewer runs in fixed-commit snapshot clones (no inherited context) + history supplement + terminal audit receipt — final full-range blind report 0 blocking / 0 important / 0 minor. All 6 interim findings fixed or independently disproved; the one pre-existing out-of-scope item (snapshotKeys coercion isstring() at TransformByLua.java:429) is noted for a separate issue.

Testing

  • Java: full mvn test green (310); new test red-before/green-after verified
  • Go: go test ./... green on both Lua backends (wangshu + lua_gopher)
  • C++: doctest suite green (110297 assertions); three-runtime byte-identical output on the new fixture
  • cross-validate sections 3/9: 96/96 + 89/89 including the new fixture; make bump full validation 306 PASS
  • differential-fuzz: 500 rounds seed 2026 + 1000 rounds seed 31337 + 300 rounds seed 175 all PASS

Liam0205 added 8 commits July 23, 2026 12:31
luaj's isnumber()/isstring() implement Lua coercion semantics, not type
identity: LuaString.isnumber() is true for any numeric-looking string,
so fromLua routed Lua strings through the number branch. That lost
string identity for every numeric string ("42" -> 42, "007" -> 7) and
corrupted values past 2^53 through the todouble() round-trip
("1777288596209286259" -> 1777288596209286144) — silently diverging
from Go (gopher-lua/wangshu type switch) and C++ (lua_type switch),
which both dispatch on the actual type tag.

Swapping the check order would break the reverse direction
(LuaNumber.isstring() is also true); v.type() == TNUMBER/TSTRING is the
only unambiguous dispatch, and the same function's table-key check
already used it.

TransformByLuaTypeIdentityTest pins the returned Java class — which
fixture comparators cannot see (they stringify) — across the danger
zones: 19-digit IDs, 2^53+1, leading zeros, table mixes, the toLua
round-trip, and real numbers/arithmetic coercion staying numeric.
Four operator-level cases (19-digit ID, 2^53+1, leading zeros, table of
numeric strings) and one pipeline fixture running both item and common
mode. The pipeline fixture feeds cross-validate sections 3/9, so the
Go/Java/C++ outputs are compared byte-exact — the divergence class from
issue #175 was invisible to every existing fixture because none returned
a numeric-looking string from Lua.

Verified red on the pre-fix Java runtime (PipelineFixtureTest fails,
RunCli emits 1777288596209286144 where Go emits the string) and green
on all three runtimes after the fix.
Add the three #175 danger-zone strings (19-digit ID, 2^53+1, "007") to
EDGE_SCALARS and to the tag/name item-field pool, plus an identity
pass-through Lua function so generated pipelines can route those strings
through toLua -> fromLua. Before this the fuzzer could not express the
divergence class at all: every LUA_* function returned arithmetic
results, and no generated scalar was a numeric-looking string.

The combination (identity function x numeric-string item_name) fires in
roughly 0.25% of rounds — a background-fuzz safety net behind the
deterministic fixtures, not the primary regression gate.
…le (review)

Local blind review flagged the identity path's ~0.25% trigger rate.
Investigating exposed a deeper blind spot: generated configs carried no
flow_contract, so every engine projected common/items down to {} — the
differential comparison saw exit codes, error text, and item counts,
but never a single computed field VALUE. The #175 corruption could not
reach the output diff at all.

Two changes: ~40% of rounds now emit a flow_contract projecting all
accumulated outputs (output-side only; empty inputs keep request
validation out of the way, dedicated error-path rounds cover that), and
item-mode Lua rounds force the identity pass-through on an upstream
name/tag field 25% of the time instead of relying on pool selection.

Verified: pre-fix Java + this generator diverges from Go on a real
generated round (seed 424242 round 2066, normalize_json unequal);
fixed Java matches; 500 fresh rounds pass with no false positives.
The comment claimed a 'guaranteed per-round injection'; the actual
behavior forces the identity pass-through with 25% probability and only
when an item-mode round has a name/tag field upstream. Comment-only.
Two optional-but-cheap hardenings from the final full-range review:

- transform_by_lua_edge_cases.json gains a _comment stating its layer
  boundary: the operator-fixture comparators stringify non-float values,
  so these cases pin value-level parity only; type identity lives in
  the Java unit test and the pipeline fixture (verified the unknown key
  is tolerated by both Go and Java fixture loaders).
- lua_string_number_identity.json seeds two items instead of one so the
  item-mode path proves per-item type preservation, not just a single
  invocation. Re-verified: Go both Lua backends, Java, and a raw
  three-runtime byte comparison all pass.
)

Reflection: luaj's is*() family implements coercion queries, not type
predicates — same-looking APIs across the three Lua host libraries have
opposite semantics (gopher-lua type switch / wangshu kind tags = tags,
luaj is* = coercion). The deeper lesson: the differential fuzzer never
emitted flow_contract, so engines projected all outputs to {} and the
comparison never saw a computed field value — measure effective
visibility at the comparison surface, not shape occurrence.

Promoted to stable docs: operator-contract.md (scalar dispatch must use
real type tags, three-host-library semantics table), ci-quality-baseline.md
(new-fuzz-dimension signal-reaches-comparison-surface rule + red/green
verification recipe), cross-layer-validation.md (fixture comparator
semantics define what each layer can pin). index.md synced.
@github-actions

Copy link
Copy Markdown
Contributor

Codex PR 审查

项目 结果
结论 APPROVE
审查范围 51f0ee0df402fb38f74f19b194f349bf2e813608..0d2af3c60304fd462ef4e29173d6a422736293f7
审查截止 0d2af3c60304fd462ef4e29173d6a422736293f7

审查截止: 0d2af3c

实现正确地改用 Lua 实际类型标签进行分派,并通过单元测试、流水线 fixture 和可观测的差分 fuzz 路径覆盖了该回归,未发现合入风险。

阻塞问题 (0)

重要建议 (0)

小问题 (0)

Sources cited

做得对的地方

  • TransformByLua.java 使用 v.type() == TNUMBER/TSTRING 替代带 coercion 语义的 isnumber()/isstring(),同时保留布尔值、表和既有数值规范化行为。
  • TransformByLuaTypeIdentityTest.java 直接断言 Java 类型身份,并覆盖 19 位 ID、2^53+1、前导零、真实数值、Lua 算术 coercion、输入往返及表内混合类型。
  • lua_string_number_identity.json 同时覆盖 common/item 模式、复合表值和多 item 执行,并通过 flow_contract 将类型敏感结果暴露到跨运行时比对面。
  • differential-fuzz.py 修复了差分测试此前不投影计算字段值的盲区,使新增的数值字符串维度能够真正到达比较面。
  • 使用 JDK 25 执行 TransformByLuaTypeIdentityTestPipelineFixtureTest 均通过;Python fuzz 脚本语法检查及 git diff --check 也通过。

开放问题

@Liam0205
Liam0205 merged commit de9a9e7 into master Jul 23, 2026
21 checks passed
@Liam0205
Liam0205 deleted the fix/175-lua-string-number-coercion branch July 23, 2026 07:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant