Skip to content

Allow heterogeneous int/uint/double comparisons (fixes #114) - #1

Closed
benja0rtzzz wants to merge 1 commit into
mainfrom
heterogeneous-numeric-comparisons
Closed

Allow heterogeneous int/uint/double comparisons (fixes #114)#1
benja0rtzzz wants to merge 1 commit into
mainfrom
heterogeneous-numeric-comparisons

Conversation

@benja0rtzzz

@benja0rtzzz benja0rtzzz commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Allow heterogeneous int/uint/double comparisons (fixes cloud-custodian#114)

Problem

The six comparison operators fail on mixed numeric types, and whether they
fail depends on which operand you write first:

4.0 < 10    # true
10 < 4.0    # CELEvalError: found no matching overload
1 == 1.0    # fails in both orders

The CEL spec requires ==, !=, <, <=, >, >= to work across int,
uint, and double in any combination and any operand order — see
Numeric Values
in the language definition, and compareIntDouble/compareUintDouble in
cel-go's common/types. Arithmetic across those types stays homogeneous;
only comparison is relaxed.

Cause. IntType defines all six comparison dunders behind the
type_matched guard. UintType and DoubleType guard only __eq__/__ne__
and never override the four relational operators at all, so those inherit
Python's native int/float methods, which already accept mixed operands.
Python tries the left operand's method first, so:

  • 4.0 < 10 reaches plain float.__lt__ — celpy's type system is bypassed
    entirely, and it works.
  • 10 < 4.0 reaches IntType.__lt__, which raises. Raising is not returning
    NotImplemented, so Python never gets to try DoubleType's reflected
    method — the answer was available and unreachable.
  • ==/!= are guarded on all three types, so they fail in both orders.

Fix

type_matched gains one branch: when both operands are some combination of
IntType, UintType, and DoubleType but aren't otherwise type-matched, it
compares the unwrapped Python values with the matching
operator.{eq,ne,lt,le,gt,ge} instead of raising. Any other mismatch raises
exactly as before, with the same message.

Applying an operator to the wrapped
values re-enters the guard. Unwrapping reaches the native comparison
underneath.

The unwrap must not cast int to float. IntType/UintType unwrap to
int, DoubleType to float, each to its natural type, and Python's mixed
int/float comparison, which is exact by language definition. A pair of float() casts would look equivalent and
silently break every integer above 2**53, where a double can no longer
represent consecutive integers. NaN, ±infinity and -0.0 come along for free
from IEEE 754.

The exception is deliberately narrow. BoolType subclasses int but is a
distinct CEL type, so it stays rejected; an isinstance(other, int) check
would have let it through.

Consequences of the __eq__ half,
list membership (1 in [1.0, 2.0]), list/map equality ({1: 1} == {1.0: 1.0}),
and cross-type map-key lookup ({1: 'a'}[1u]) now work. All three delegate to
==. The map lookup was never a "key not found" — Python already guarantees
equal int/float values hash alike, so it found the right bucket and then
raised during the collision check.

Not changed: arithmetic (IntType(1) + DoubleType(2.0) still raises);
comparison strictness for strings, bytes, timestamps and durations;
MapType, ListType, and every __hash__, all byte-for-byte identical.

Expression Before After
IntType(10) < DoubleType(4.0) TypeError False
IntType(2) == DoubleType(2.0) TypeError True
UintType(1) < DoubleType(1.5) TypeError True
IntType(2**53+1) == DoubleType(float(2**53)) TypeError False — exact
DoubleType(nan) == IntType(5) TypeError False
DoubleType(-0.0) == IntType(0) TypeError True
IntType(1) + DoubleType(2.0) TypeError TypeError — unchanged
IntType(1) == BoolType(True) TypeError TypeError — unchanged

Tests

Ten new unit tests in tests/test_celtypes.py cover the full int/uint/double
matrix in both operand orders, the issue's repro, and the 2**53 / NaN /
infinity / -0.0 edge cases. Two new scenarios in features/json_query.feature
run the issue's literal CLI repro end to end.

Before After
pytest 468 passed, 1 skipped 478 passed, 1 skipped
behave (native) 1275 passed, 0 failed 1371 passed, 0 failed
behave (compiled) 1275 passed, 0 failed 1371 passed, 0 failed
coverage 100% 100%

Verified on the whole envlist — py310, py311, py312, py313, py314
and tools all pass. mypy --strict and ruff format --diff are clean. No
tracked test changed status or regressed.

ruff check fails, but it already fails on main — see Notes.

94 @wip tags removed across 6 feature files — comparisons.feature (80),
lists.feature (6), fields.feature (4), proto2.feature (2),
proto3.feature (2) — each with its mirror entry in tools/tags.toml, in the
order that file already used. Per your request on the issue thread, so a
future CEL-spec extraction doesn't re-tag them. No other entry in those
sections was touched.

One expected output changed, in json_query.feature's
JQ Conditionals and Comparisons: ==. It evaluates _ == 1 against 1,
1.0, "1", "banana". The 1.0 case printed null because the comparison
raised — and the scenario's own comment recorded that as intended CEL
semantics ("CEL does not do the required type coercions"). It wasn't; the spec
says 1 == 1.0 is true. Expected output is now true\ntrue\nnull\nnull\n and
the comment distinguishes the two claims: no string coercion (correct), but
heterogeneous numeric comparison per spec. The string cases are unaffected.

Reinforcement proof. celtypes.py alone was reverted to upstream with the scenarios left untagged, and the tracked suite re-run:
1274 passed, 91 failed, 4 error. That's 95 tracked failures — exactly the 94
newly-untagged scenarios plus the json_query one.
The fix was then restored and the suite re-confirmed green.

Left in place, disclosed

16 scenarios kept @wip — pre-existing compiled-runner gap. They were
already failing before this change and still fail after; no status changed and
this PR claims none of them. They now fail differently, under the compiled
runner only, with int() argument must be ... not 'list'. All 16 involve
dyn() over a constructed wrapper-message literal (e.g.
Int32Value{value: 34} == dyn(UInt64Value{value: 34u})). I haven't chased it
down, but the trigger seems to sit upstream of the comparison: something
reaches the unwrap as a numeric CEL type while carrying a list as its payload.
If that's right, the old guard was rejecting it for an unrelated reason and
never inspecting it, so this change surfaced it rather than caused it — which
would point at dyn() over constructed messages rather than celtypes.py.
You'd know better than me; happy to open a separate issue if it's useful.

1 scenario kept @wip — a question rather than a fix.
lt_literal/not_lt_dyn_int_big_lossy_double is
dyn(9223372036854775807) < 9223372036854775808.0, i.e. int64::MAX < 2**63.
This returns true, the exact answer; the fixture expects false. That looks
like a cel-go artifact — its clamping compares against
float64(math.MaxInt64), and 2**63−1 isn't representable as a double, so
it rounds up to 2**63 and the comparison collapses to 2**63 < 2**63.

So the open question is whether celpy should match cel-go here or stay
mathematically exact. Reproducing the artifact would mean reintroducing the
imprecision this patch otherwise avoids, so I left the scenario @wip rather
than decide it.

Arithmetic asymmetry, out of scope. DoubleType(2.0) + IntType(1) already
returns a bare, unwrapped float rather than raising — the same
left-operand-first asymmetry, on the arithmetic side, escaping the CEL type
system. Untouched here since arithmetic is meant to stay homogeneous.

Notes

tox -e lint fails on main today, independently of this PR. ruff is
unpinned (ruff>=0.15.13), and 0.16.0 — released 2026-07-23, after the last
CI run on main on 2026-07-17 — widened the default rule set. Under the
locked 0.15.13 everything is clean. Under 0.16.1, ruff check src tools
reports 476 errors on untouched main and 479 on this branch. The 3
extra are UP006/UP007 on the Dict/Union annotations I added, matching
the style celtypes.py already uses throughout — I left them consistent with
the file rather than modernizing 3 lines out of ~476. Since Tests has
needs: Lint, the matrix here will be skipped until this is settled. Happy to
pin ruff, change my 3, or leave it to a separate PR — whichever you prefer.

tools/tags.toml was hand-edited to mirror the .feature changes; it
round-trips through tomllib and pytest tools passes. Coverage stays at
100%.

…n#114)

The CEL spec requires the six comparison operators to work across int,
uint, and double in any operand order; arithmetic stays homogeneous.
IntType guarded all six with type_matched, while UintType and DoubleType
guarded only __eq__/__ne__, so the result depended on which operand came
first.

type_matched now compares the unwrapped Python values when both operands
are numeric CEL types. It must not cast int to float: Python's mixed
comparison is exact, while a float() cast would silently break integers
above 2**53. BoolType and every other type still raise.

Also removes the @wip tag, and its tools/tags.toml mirror, from the 94
conformance scenarios this makes pass, and corrects json_query.feature's
`==` scenario, which recorded the old raising behavior as intended CEL
semantics.
@benja0rtzzz
benja0rtzzz force-pushed the heterogeneous-numeric-comparisons branch from 0a54f64 to 0b17228 Compare August 1, 2026 19:47
@benja0rtzzz

Copy link
Copy Markdown
Owner Author

Superseded by cloud-custodian#192.

@benja0rtzzz benja0rtzzz closed this Aug 1, 2026
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.

type coercion inconsistently fails depending on order of arguments

1 participant