fix: #2304 compare numeric strings by magnitude in relational operators - #2319
fix: #2304 compare numeric strings by magnitude in relational operators#2319bongbongcrypto wants to merge 3 commits into
Conversation
…l operators Relational operators are documented and presented as numeric, but template resolution hands the evaluator its values as strings, so applyBinary compared two strings with code-unit ordering and "9" < "10" was false. Above MAX_SAFE_INTEGER the operands had already been promoted to BigInt, so the verdict flipped at that boundary. Coerce relational operands only: a digit string becomes BigInt, so magnitude never limits exactness, and a decimal string becomes Number, which is the closest available and carries the usual double precision limit. Nothing else moves, so dates, semver, addresses, hashes and prose keep the ordering they have today. This also settles decimals in BigInt mode, where applyBigIntConversion leaves them as strings and StringToBigInt made every relational comparison false in both directions at once. Equality and arithmetic are untouched. String equality is meaningful for addresses and symbols, and + concatenates by design. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
About the
|
suisuss
left a comment
There was a problem hiding this comment.
What this changes
lib/workflow/nodes/condition/safe-eval.ts gains INTEGER_OPERAND_RE = /^\d+$/ at :552 and DECIMAL_OPERAND_RE = /^\d+\.\d+$/ at :558, plus toRelationalOperand at :572: a digit-only string becomes BigInt, a d+.d+ string becomes Number, everything else passes through. The four relational arms at :604, :607, :610 and :613 run both operands through it. Equality and arithmetic arms are untouched, as is the parser, the allowlist and resolveMissingOperands.
Does it match the description
Matches on scope - equality and + are correctly left alone, and the decimal case is in as agreed.
The doc comment at :568-570 is wrong, though, and it is load-bearing for the rest of the review. It says "Only digit strings move. Everything else - dates, semver, addresses, hashes, prose - keeps the behaviour it has today." That is not what happens. Once one operand is a BigInt, the other is coerced by StringToBigInt, which accepts signs, surrounding whitespace, and hex, octal and binary literals. So the untouched operand moves too - inside the engine rather than in toRelationalOperand. That single mechanism produces three of the four blockers below.
Blocking
-
lib/workflow/nodes/condition/safe-eval.ts:580- the decimal path reintroduces the precision loss the change exists to remove."1000000000000000000.5" > "1000000000000000000": the left matchesDECIMAL_OPERAND_REandNumber()rounds it to exactly1e18, the right becomes1000000000000000000n, and the result isfalse. On staging this was a string comparison and returnedtrue, correctly. -> A formatted balance with a fractional part is exactly the wei-scale case this PR is for, and it gets the wrong answer where the old code got the right one. -> Compare integer and fraction parts as BigInts, or refuse to convert a decimal whose integer part exceeds MAX_SAFE_INTEGER instead of silently rounding it. -
safe-eval.ts:577- a digit string against a hex string silently reverses. With"1"on the left and an address like"0x0000000000000000000000000000000000000002"on the right, the left becomes1n, the engine parses the right as hex to2n, and<istrue. On staging"1" < "0x00..."wasfalse. -> A condition comparing a count against an address-shaped or hash-shaped field flips, silently, with no error. Same for"10" < "0x1f". -> Convert only when both operands are recognised numerics, and compare explicitly rather than letting the engine coerce the second one. -
safe-eval.ts:577- a digit string against a non-numeric string is now false in both directions.9n < "apple"isfalseand9n > "apple"is alsofalse; on staging"9" < "apple"wastrue."1e3"behaves the same way, sinceStringToBigIntrejects exponent notation. -> Worse than a wrong answer:a < b,a > banda === bare all false at once, so an author cannot write an exhaustive set of branches. This is the trap you correctly identified for decimals in BigInt mode, newly created for the mixed case. -> Same fix as above. -
safe-eval.ts:552-^\d+$does not admit a sign, and the resulting gap is asymmetric."-5" < "-3"converts neither operand, falls back to string comparison and returnsfalse, where the answer istrue. But"-5" < "3"is correct, because the right side becomes3nand the engine then parses"-5"properly. -> A negative operand's correctness depends on whether its counterpart happens to be a bare digit string. -> Use/^[+-]?\d+$/and the signed decimal form.NUMERIC_LITERAL_REatlib/workflow/nodes/condition/expression.ts:20already has this shape and the builder uses it. -
The
lintjob fails as pushed..github/workflows/pr-checks.yml:98-110runspnpm checkunconditionally, andbiome.jsoncexcludes neitherlib/workflow/nodes/condition/nortests/. Thereturn a >\n b;form must be parenthesised, and several addedexpect(...).toBe(true);lines exceed the 80-column width.pnpm exec biome check --writeon the two changed files fixes both.
Mechanical - actionable as-is
-
Three of the five new cases assert behaviour that already holds on staging through the production path, so they do not isolate the regression.
tests/unit/condition-safe-eval.test.ts:211and themax/pastpair at:194both use operands that tripneedsBigIntMode(lib/bigint-condition-utils.ts:74-100), soapplyBigIntConversionalready converts them atlib/workflow/executor/executor.workflow.ts:521-527; the tests callsafeEvaluateConditiondirectly and bypass that.:235asserts"9" < 10, also already true. The"999999999999999"/"1000000000000000"assertion at:205-210is the one that is genuinely new coverage, and it is the right one. -
Changed branches with no test: digit against a non-numeric string, digit against a hex or address string, negative operands anywhere, leading or trailing whitespace, empty string,
null/undefined, a boolean against a converted side, a decimal above MAX_SAFE_INTEGER, and the decimal path through>at:604and<=at:613- only<and>=are asserted at:220-233. -
Correct the comment at
:568-570once the coercion is made explicit, since the claim will still be false while the engine is doing the second conversion.
With the team
- Whether the engine and the exported SDK are allowed to disagree on relational semantics.
lib/workflow/codegen/codegen.ts:917and:1046emit the user's condition verbatim into exported JS, which runs plain relational rules - so with this change the code we show a user no longer computes what the engine computes for these operators. I am weighing emitting a comparison helper into generated code against accepting the divergence and documenting it; the first keeps one meaning for a condition, the second is much less code. This does not block any of the fixes above - the same divergence exists today forapplyBigIntConversion. I'm taking it to the core team and will come back.
Verdict
Changes requested - the decimal path loses precision at wei scale, and three operand shapes change answers because the engine coerces the operand toRelationalOperand leaves alone.
The diagnosis is right and the fix is in the right place. (left as number) < (right as number) really was a compile-time assertion converting nothing, and doing the coercion at the comparison site really does settle the BigInt-mode decimal case. All four blockers come from one decision - relying on JS's implicit BigInt-versus-string coercion for the second operand - and converting only when both sides match a numeric shape, then comparing explicitly, fixes them together.
Review found four ways the first attempt changed answers it should have left alone, and all four came from one decision: converting one operand and letting the engine coerce the other. StringToBigInt accepts signs, whitespace and hex, octal and binary literals, so the operand toRelationalOperand deliberately did not touch was converted anyway, inside the comparison. So neither operand moves now unless both are decimals, and the comparison is made here rather than by the operator. That settles the four together: - A decimal is no longer routed through Number. The fractions are padded to a common width and compared as BigInt, so "1000000000000000000.5" is greater than "1000000000000000000" instead of rounding to equal. - A digit string against a hex or address-shaped string is not recognised as a pair, so "1" < "0x00...02" keeps the ordering it has today rather than silently reversing. - A digit string against a word is not a pair either, so <, > and === are no longer all false at once and a set of branches can still be exhaustive. - The grammar admits a sign, so "-5" < "-3" is true regardless of what the other operand happens to look like. The grammar is NUMERIC_LITERAL_RE from ./expression.ts, which is what the visual builder uses to decide whether a value may be emitted as a bare number. Hex and exponent forms are excluded there, so a value the builder would have quoted is not one the evaluator reads as a number. Tests: dropped three cases that already held on staging through applyBigIntConversion, and covered the branches this actually changes - signs, decimals past the double precision limit, every relational operator, hex, exponents, whitespace, the empty string, words, null, booleans and a non-integral Number. Ran against staging, against the previous revision and against this one: staging fails three, the previous revision fails four, this one passes. Corrected the doc comment, which claimed untouched operands kept their behaviour while the engine was converting them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thank you - the diagnosis in the review is right, and it is one decision rather than four. Pushed 7b9300c. Neither operand moves unless both are decimals, and the comparison is made in
A On the tests: dropped the three that already held on staging through I ran the suite against three trees to check the tests bite rather than just pass: staging fails three, the previous revision fails four (the blockers above), this revision passes 41. On the SDK divergence: understood, and I have not touched |
suisuss
left a comment
There was a problem hiding this comment.
What this changes
One commit. INTEGER_OPERAND_RE and DECIMAL_OPERAND_RE collapse into NUMERIC_OPERAND_RE = /^[+-]?\d+(\.\d+)?$/ (lib/workflow/nodes/condition/safe-eval.ts:559), and the four relational arms route through a new compareRelational that pads both fractions to a common width and compares scaled BigInts, with no Number() anywhere on the path. An operand pair where either side fails to parse falls back to the exact staging expression.
All five blocking items land, and I ran them differentially against staging and the previous head rather than reading them: the wei-scale decimal case reverses correctly at head and not at b610489; "1" < "0x00…02" and "9" < "apple" are back to their staging answers; "-5" < "-3" is true at head and false on both earlier trees. The three already-passing tests are gone and 3 of 8 relational cases now fail on staging, which is the point. biome format and biome lint are clean on both files under the repo's own config.
Does it match the description
Matches.
Blocking
lib/workflow/nodes/condition/safe-eval.ts:630against the untouched equality arms at:650and:654- ordering is now numeric while equality is still textual, so the trichotomy hole has moved rather than closed. For two operands that are numerically equal and textually different,<,>,===and==are now all false at once. Verified:"1"vs"1.0","1000000000000000000"vs"1000000000000000000.0","007"vs"7","0.5"vs"0.50","-0"vs"0","+1"vs"1"- every one returns[false,false,false,false]at head where staging returned true on<. -> A Condition branchingbalance < threshold/balance > threshold/balance === threshold, where the template resolves to a formatter's"1000000000000000000.0"and the author typed the integer, takes no branch at all. That is a formatted-balance against an integer literal, which is the pair this change exists to serve, and it is the same "an author cannot write an exhaustive set of branches" defect the first round named. -> Route===,==,!==and!=throughcompareRelationalas well, so the three predicates agree on one notion of equality.
Mechanical - actionable as-is
-
lib/workflow/nodes/condition/safe-eval.ts:604- no length guard on the operand beforeBigInt(...). Measured on a single comparison: 100k digits 0.17 ms on staging against 49.5 ms at head, 1M digits 2.71 ms against 269.6 ms, and a 2M-digit operand runs 665 ms with no cap and no throw. ThepadEndallocation is sized by the other operand's fraction. I did not trace whether any upstream node output is capped before template resolution, so mark the reachability UNSURE - but refusing to convert past a few hundred digits and falling through to the staging path costs nothing. -
lib/workflow/nodes/condition/safe-eval.ts:593- the comment says a non-safe-integer number is "left to the relational operator, where JavaScript already orders numbers and BigInts exactly". True for number-against-number and number-against-bigint; false when the other operand is a string, which the fallback puts throughToNumber-"9007199254740993" > 9007199254740992isfalse. Not a regression, andneedsBigIntMode(lib/bigint-condition-utils.ts:76-88) promotes both sides beforesafeEvaluateConditionis reached, so it is unreachable through the executor. The comment is what needs correcting. Same misattribution ontests/unit/condition-safe-eval.test.ts:263-264, which attaches that rationale to a string-against-number case. -
tests/unit/condition-safe-eval.test.ts- the wei-scale digit-string assertion was deleted this round and nothing replaced it.:203is a 15-to-16-digit pair, well insideMAX_SAFE_INTEGER, so no test now pins1e18against1e18 + 1as digit strings - the case the issue is about. Also uncovered: leading zeros (which is the blocking case above), two-component version strings like"1.10"against"1.9"(these do match the grammar and are compared as decimals; the note at:274covers only three-part semver),"Infinity"and"NaN"as strings, andundefined. -
lib/workflow/nodes/condition/safe-eval.ts:590- every bigint operand is round-tripped throughtoString()andBigInt()on each compare, which in BigInt mode replaces a native compare with two string parses. Cheap to special-case.
With the team
- Whether the exported SDK should share the engine's comparison.
lib/workflow/codegen/sdk.ts:913and:1043still emit the condition verbatim, so exported code runs plain relational rules. This was already deferred, but the deferral was priced against a narrower gap: before this PR the two agreed on plain numeric strings and diverged only whereapplyBigIntConversionfired; at head"9" < "10"is true in the engine and false in the code we hand the user. I'm weighing emitting a comparison shim into the generated code against documenting the divergence - the tradeoff is generated-code weight versus a silent disagreement between what runs and what we show. I'm taking it back to the core team with the wider number. Nothing here is blocked on you.
Verdict
Changes requested - ordering became numeric while equality stayed textual, so a formatted decimal against an integer literal now satisfies none of the three comparisons.
…ators Ordering became numeric in the last revision while equality stayed textual, so a pair that is equal by magnitude and different as text answered false to <, > and === at once, and a Condition branching on the three took no branch at all. ===, !==, == and != now ask compareRelational the same question the four ordering operators ask. A pair it does not recognise keeps the equality it has always had, so "" == 0, a boolean against 1 and null against undefined are where they were. That alone was not enough. A number counted only when it was a safe integer, so "1.5" === 1.5 still had no true answer among the three. That pair is the ordinary shape of a rule built in the UI: wrapOperand in ./expression.ts emits a value the author typed as a bare number whenever it matches NUMERIC_LITERAL_RE, and template resolution hands the other side over as a string. So a number is now read through its own decimal form, toString being the shortest decimal that reads back as the same double, and the grammar decides whether it counts. Ordering by that form is ordering by value, since two different doubles never print the same. It also settles "9007199254740993" > 9007199254740992, which was false through ToNumber. Three assertions change that were written on purpose: - condition-safe-eval, "0" === 0 was false. Numbers are no longer what separates === from ==; every pair that is not two numbers still is, and the test pins that instead. - condition-executor, the same through evaluateConditionExpression. - condition-executor, a BigInt value against the same number quoted was false, with a comment telling authors to unquote the literal. That case is the hole itself: BigInt mode promotes the left and leaves the quoted right a string, so <, > and === were all false and the workaround was the only way out. Two cases that still answer false are added beside it. OPERATOR_METADATA described === as strict equality with no type coercion, which is no longer true of a numeric pair, so the dropdown hint moves with the behaviour. Also, from the same review: - An operand past 256 digits is refused and the pair falls back rather than pay a BigInt parse that grows with the square of the length. A uint256 is 78 digits and the same value formatted with 18 decimals is 97 characters. Two 1,000,000-digit operands cost 2,644 ms a comparison uncapped and 0.01 ms capped. Past the cap the comparison is still a total order. - Two BigInts are compared as they are, rather than printed and reparsed on every comparison. That is the shape applyBigIntConversion produces. - The comment claiming an unsafe number is left to the operator is gone, along with the same claim attached to a string-against-number case in the tests. Tests: the 1e18 against 1e18 + 1 digit-string pair is back as its own case. Added a case asserting exactly one of <, > and === answers true, over nine pairs equal by magnitude and different as text and five that are not equal, plus leading zeros, two-component versions against three, Infinity and NaN as strings against the number NaN, undefined on either side, and the cap boundary. Ran the file against staging, the first revision, the revision reviewed and this one: 7, 8 and 4 failures against 0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
All of it is in, and the blocking one took me further than the instruction, so that part is first. Blocking: one notion of equality
That alone did not close the hole. Doing only what you asked left Run through the real generator and the real executor, an "equals" rule against a typed number:
Reading a number that way also settles What it cost, which you should look atThree assertions changed that were written on purpose, and the operator dropdown moved with them.
If you would rather keep strict Mechanical
EvidenceSame test file against four trees, the runner being a minimal
At Beyond that file: the other ten condition test files pass (417 tests across the eleven). A full On the SDK divergenceThe gap you are taking to the core team is wider now than when you measured it. It was |
Issue
Closes #2304
What this changes
applyBinarycompared relational operands with(left as number) < (right as number), acompile time assertion that converts nothing, so two strings took code-unit ordering.
Template resolution returns values as strings, so that is the usual pairing.
toRelationalOperandnow coerces the operands of<,<=,>and>=only:/^\d+$/becomesBigInt, so magnitude never limits exactness/^\d+\.\d+$/becomesNumber, the closest available representation, with the usualdouble precision limit
Following your answer on the decimal question, decimals are in scope. A single point and
digits on both sides means semver is not a decimal, and digit-only means dates, addresses
and hashes are not numeric here either.
This also settles the BigInt-mode decimal case you described, where
applyBigIntConversionleaves a decimal string alone andStringToBigIntmade<and>=on the same pair both false. The coercion happens at the comparison site, so thedecimal becomes a Number and the comparison against a BigInt operand is well defined.
Two things deliberately not changed, per your correction to my scope:
+at the same site, which concatenates."9" + "10"is still"910". It sits on thesame cast, so it should not be described as checked-and-correct, but the visual builder
cannot emit it and concatenation is plausibly the intent when someone writes it by hand.
Scope
One change. The coercion and its tests cannot ship apart: the tests assert the behaviour
the coercion introduces, and the coercion without them leaves the boundary unguarded.
No response shape, unit, status code or default moves. What changes is the verdict on a
digit string against another numeric value, which is the reported defect.
How it was verified
Added to
tests/unit/condition-safe-eval.test.ts, matching the matrix you asked for:"9" < "10""99" < "100""999999999999999" < "1000000000000000""9007199254740991" < "9007199254740992""9007199254740991" > "9007199254740992""9.5" < "10.2""9.5" >= "10.2"10n < "10.5"10n >= "10.5""1000000000000000000" < "1000000000000000001"===address"apple" < "banana""1.2.3" < "1.10.0""2026-09-05" < "2026-10-01"The MAX_SAFE_INTEGER pair is included as the actual edge rather than the digit count, and
the decimal pair appears in both regimes. Each of the first nine fails without the fix.
Honest note on the checklist below. I have not run
pnpm checkorpnpm type-checklocally, because I do not install and run an unfamiliar repository on the machine I work
from. What I did instead: ran the coercion predicate against the table above in a
standalone harness, and type-checked the two changed files in isolation for syntax. CI on
this PR is the real check, and I will fix whatever it reports.
Screenshots
Nothing renders.
stagingpnpm checkandpnpm type-checkpass (not run locally, see the note above).envfiles, or credentials committed🤖 Generated with Claude Code