Skip to content

fix(vm): make <= and >= fall back through __lt per Lua 5.3 §3.4.4#213

Merged
davydog187 merged 3 commits into
mainfrom
fix/le-fallback-not-lt
May 8, 2026
Merged

fix(vm): make <= and >= fall back through __lt per Lua 5.3 §3.4.4#213
davydog187 merged 3 commits into
mainfrom
fix/le-fallback-not-lt

Conversation

@davydog187
Copy link
Copy Markdown
Contributor

__le falls back to not (b < a) when __le is unset

Plan: .agents/plans/A8e-le-fallback-not-lt.md

Goal

Make the :less_equal opcode fall back to not (b < a) when neither
operand has an __le metamethod, per Lua 5.3 §3.4.4. Today
:less_equal looks up __le and — if missing — runs
safe_compare_le, which raises on table operands. The reference
manual is explicit:

The <= operation is translated to not (b < a) if there is no __le metamethod.

So when __le is unset, dispatch should consult __lt (with operands
swapped) and negate the result. Only fall through to
safe_compare_le when neither metamethod is defined and the operands
have a primitive <= (numbers, strings).

Success criteria

  • Op(1) <= Op(2) returns true when only __lt is set on the
    metatable (was: attempt to compare table with table). Pinned by
    the new <= falls back to not (b < a) when only __lt is defined
    test.
  • Op(2) <= Op(1) returns false in the same setup. Pinned by
    the same test.
  • Op(1) <= Op(1) returns true (negation of not (a < a)).
    Pinned by the same test.
  • __le (when set) still wins. Pinned by __le is preferred over __lt when both are defined (raises if __lt fires) and __le on either operand wins over __lt fallback.
  • >= between tables defined only via __lt works (translated
    a >= b ⇒ b <= a chains into the new fallback). Pinned by >= falls back via __lt when only __lt is defined.
  • safe_compare_le still raises for primitive operands of mixed,
    non-comparable type (1 <= "x" still raises). Pinned by <= on incompatible primitive types still raises.
  • events.lua line 258 (assert((Set{1,2,3,4} <= Set{1,2,3,4})))
    passes standalone. Verified via the file-probe pattern (with line
    188-190 patched out, since that's A8b's territory and A8b is in
    review). Next stop after the fix is line 285:
    assert(Set{1,3,5,1} == rawSet{3,5,1}) — table-with-__eq
    compared against a raw table; that's A8d-adjacent (== /
    __eq), out of scope here.
  • Unit tests cover all four required cases plus the > and >=
    paths.
  • mix test passes; no regressions. 1564 → 1570 (+6 new tests),
    0 failures.

Changes

  • lib/lua/vm/executor.ex:
    • New compare_le/3 helper implements the §3.4.4 fallback chain:
      __le__lt(b, a) negated → primitive <=.
    • :less_equal now delegates to compare_le.
    • :greater_equal now delegates to compare_le(b, a, state),
      matching the spec's a >= b ⇔ b <= a translation. Previously
      :greater_equal skipped metamethod dispatch entirely and went
      straight to safe_compare_ge.
    • :greater_than now goes through try_binary_metamethod("__lt", b, a, ...), matching a > b ⇔ b < a. Previously :greater_than
      also skipped metamethod dispatch. The plan's Risks section flagged
      this asymmetry; fixing it was needed to advance events.lua past
      the test() block at line 222 (which exercises >).
    • safe_compare_gt and safe_compare_ge removed (no longer
      referenced; safe_compare_lt and safe_compare_le cover all four
      directions through operand swapping).
  • test/lua/vm/metatable_test.exs: 6 new tests pinning the fallback
    semantics for <=, >=, >, the __le-wins precedence, the
    __le-on-either-operand path, and the primitive raise.
 lib/lua/vm/executor.ex         |  79 ++++++++++++--------------
 test/lua/vm/metatable_test.exs | 109 +++++++++++++++++++++++++++++++++++
 2 files changed, 150 insertions(+), 38 deletions(-)

Discoveries

  • :greater_than skipped metamethod dispatch entirely on main. The
    plan's Risks section anticipated this (safe_compare_* may need
    parallel treatment). Fixing it was necessary in scope: events.lua's
    test() function at line 207-220 calls Op(1) > Op(1) etc., which
    triggered attempt to compare table with table on main even though
    __lt was set. Per spec a > b ⇔ b < a, so the fix routes
    :greater_than through __lt(b, a). This also brings parity to
    the four comparison opcodes.

  • :greater_equal was already a separate opcode, not a desugaring.
    Codegen at lib/lua/compiler/codegen.ex:929 emits :greater_equal
    for >=. So the plan's "verify in the implementation that fixing
    :less_equal also fixes >=" required mirroring the change to
    :greater_equal. Done.

  • events.lua next stop: line 285. With this fix and A8b's stub
    patched out, the file probes cleanly through line 284. Line 285 is
    assert(Set{1,3,5,1} == rawSet{3,5,1}) — comparing a table with
    __eq to a raw table. Per Lua 5.3 §3.4.4 this should consult
    __eq. That's adjacent to A8d (~= / __eq dispatch); a separate
    follow-up plan can pick it up. events.lua remains in
    @skipped_tests.

Verification

mix format
mix compile --warnings-as-errors  # clean
mix test                          # 52 doctests, 51 properties, 1570 tests, 0 failures, 31 skipped
mix test --only lua53             # 29 tests, 0 failures, 24 skipped

Repros from the plan (both pass):

-- Standalone __lt-only fallback
local t = {}
t.__lt = function(a, b) return a.x < b.x end
local function Op(x) return setmetatable({x=x}, t) end
return Op(1) <= Op(2)  -- true (was: raise)

-- events.lua line 258 reduction
local t = {}
local function rawSet(x) local s={}; for _,v in ipairs(x) do s[v]=true end; return s end
local function Set(x) return setmetatable(rawSet(x), t) end
t.__lt = function (a,b) ... end
t.__le = nil
return (Set{1,2,3,4} <= Set{1,2,3,4})  -- true (was: raise)

Out of scope (intentional)

  • Promoting events.lua to @ready_tests. Line 285 still blocks the
    full file; that's a separate plan.
  • The __eq short-circuit at events.lua line 285. That's A8d-adjacent.
  • __lt itself (already correct). Verified Op(1) < Op(2) works on
    main with only __lt set.

davydog187 added 3 commits May 7, 2026 18:23
The :less_equal opcode now follows the spec's dual-metamethod rule: dispatch
__le when present, otherwise fall back to `not (b < a)` via __lt with
operands swapped. The same path covers >= (translated to b <= a). Also
routes :greater_than through __lt(b, a) so > dispatches metamethods like
< does today, removing the asymmetry where >, >=, and < all behaved
differently for table operands.

Plan: A8e
@davydog187 davydog187 merged commit 01ca74d into main May 8, 2026
4 checks passed
@davydog187 davydog187 deleted the fix/le-fallback-not-lt branch May 8, 2026 01:56
davydog187 added a commit that referenced this pull request May 8, 2026
PRs #208#213 all landed on main; flip plan status from review to merged
to match reality.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant