Skip to content

Commit ced9cb6

Browse files
wolpertclaude
andcommitted
Say what the container-equality fix actually changed, and pin a hash that can move
Review of the fix commit found it correct and under-described, which for a change that widens what matches is most of the way to being a defect. The explanation was entirely about decimal scale -- the commit message, the Javadoc, the spec amendment and all six tests -- and decimal scale is only part of what moved. Jackson's node equality is representation equality: it separates IntNode from DoubleNode from DecimalNode, and from Jackson 3 one DecimalNode scale from another. Section 2.6.1 puts all of them in one {number} class. So delegating container equality to Jackson was never the same predicate, and three things changed when it stopped: {a: 100.00} vs {a: 100.0} unequal -> equal (Jackson 3 regression) {a: 1} vs {a: 1.0} unequal -> equal (Jackson 2 did this too) {a: NaN} vs {a: NaN} equal -> unequal (went the other way) Only the first was caused by the migration. The second is older and needs no BigDecimal to reach -- readTree on ordinary JSON produces it -- so the blast radius is wider than the money example implied, and the previous commit's claim that DecimalNodes do not come from readTree was true of the reported bug and misleading about the fix. The third is a narrowing, and it is what the scalar path always did. All three now answer the way the scalar path answers, which is the point; the amendment tabulates them rather than leaving a reader to infer the second from a sentence about money. That direction matters enough to state twice. This widens what matches, and section 2.6.1's design exists largely to stop rules quietly matching more than they say. Correct behaviour arriving unannounced is still unannounced. Three tests added for the three cases the old set could not have caught: cross-representation equality from readTree, NaN, and IN/NOT_IN -- the last because section 2.6.1 defines IN as EQ against each element, so it inherits all of this through one comparator. It cannot drift, but "cannot drift" is a claim about today's call graph and IN is the operator an author actually reaches for. The pinned version hash was also too narrow to do its job. It covered strings and integers, which are exactly the node kinds whose rendering will not move, and omitted decimals, escaped and non-ASCII strings, containers, and tags -- tags being the one canonicalisation bug this project has actually shipped, since canonicalise sorts them through a TreeSet to keep Set.copyOf's per-JVM salt out of the hash. A pin that cannot catch what it was built to catch reads like coverage while providing none. The fixture now carries all of them, and perturbing a decimal's scale or a tag's spelling moves the hash as it should. Also from the review: nodesEqual documents why it has no `left == right` fast path, which looks free and is not -- with two NaNs unequal, a reference check would make a node equal to itself while unequal to a structurally identical twin, an identity-dependent answer worse than the depth exposure it saves; the two spellings of the numeric predicate, compareTo() == 0 for containers and stripTrailingZeros().equals() for scalars, now point at each other, since the whole fix rests on them agreeing and no test compares them directly; the performance note no longer implies the walk is free at the leaf, because a numeric comparison allocates where IntNode.equals was a primitive compare; two duplicate imports left by the previous commit's re-sort are gone, the sort now deduplicating as well as ordering; and three comment lines it pushed past the hundred-column limit are rewrapped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbNTKG6shEcXad7hpeUBkN
1 parent 1813457 commit ced9cb6

5 files changed

Lines changed: 126 additions & 21 deletions

File tree

docs/rule-engine-spec.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,19 @@ Three consequences worth stating out loud:
451451

452452
**Type-compatibility classes** for the "wrong type" row: `{number}`, `{string}`, `{boolean}`, `{array}`, `{object}`. Comparison is defined *within* a class only. Two exceptions: `IN`/`NOT_IN` compare a scalar against array *elements* (an array literal is expected, not a mismatch), and `EQ` on two `object`/`array` values is structural — object key order does not matter, array element order does, and **numbers inside a container compare exactly as they do outside one**.
453453

454-
> **Amendment (Jackson 3 migration).** This sentence read "is Jackson's structural `equals`" until the engine moved to Jackson 3. That was accurate while Jackson 2's `DecimalNode.equals` compared with `BigDecimal.compareTo`, which ignores scale, and so happened to agree with this engine's own numeric equality. Jackson 3 switched to `BigDecimal.equals`, which is scale-sensitive — so `{amount: 100.00}` and `{amount: 100.0}` stopped being equal inside a container while remaining equal as scalars. The sentence stayed literally true and quietly meant something else. Delegating equality to a library is only safe while the library agrees with you; `Comparisons` now walks containers itself and compares numbers through `Canonical` at every depth, which is what the `{number}` type class in this section implies. See `ReviewRegressionTest.ContainerNumericEquality`.
454+
> **Amendment (Jackson 3 migration).** This sentence read "is Jackson's structural `equals`" until the engine moved to Jackson 3, and delegating to a library is only safe while the library agrees with you. Jackson's node equality is *representation* equality — it distinguishes `IntNode` from `DoubleNode` from `DecimalNode`, and (from Jackson 3) one `DecimalNode` scale from another. This section puts all of them in one `{number}` class, so the two definitions were never the same thing; they merely agreed often enough for the difference to stay hidden.
455+
>
456+
> Three concrete consequences, all of which the container path now answers the same way the scalar path always did:
457+
>
458+
> | | before | now |
459+
> |---|---|---|
460+
> | `{a: 100.00}` vs `{a: 100.0}` | unequal *(Jackson 3 only)* | equal |
461+
> | `{a: 1}` vs `{a: 1.0}`, straight from `readTree` | unequal *(Jackson 2 as well)* | equal |
462+
> | `{a: NaN}` vs `{a: NaN}` | equal | unequal |
463+
>
464+
> Only the first was a Jackson 3 regression. The second is older and had nothing to do with the migration — `1` and `1.0` were already equal as scalars and unequal inside a container, and it needs no `BigDecimal` to reach, just ordinary parsed JSON. The migration exposed a narrow slice of a wider inconsistency and both are fixed together.
465+
>
466+
> **Note the direction: this widens what matches**, which is the outcome this section's design is otherwise built to avoid, so it is stated rather than left to be discovered. `Comparisons` walks containers itself and compares numbers through `Canonical` at every depth; `IN`/`NOT_IN` inherit it, being `EQ` against each element. Object key order still does not matter and array element order still does. See `ReviewRegressionTest.ContainerNumericEquality`.
455467
456468
#### 2.6.2 Numeric canonicalization
457469

rule-engine-core/src/main/java/com/codeheadsystems/rules/value/Comparisons.java

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -118,22 +118,36 @@ private static boolean equal(final JsonNode actual, final JsonNode literal) {
118118
* Structural equality over two containers, with numbers compared the way §2.6.1 compares them
119119
* everywhere else.
120120
*
121-
* <p><strong>Not {@code JsonNode.equals}, and the difference is a defect this replaced.</strong>
122-
* Jackson 2's {@code DecimalNode.equals} compared with {@code BigDecimal.compareTo}, which ignores
123-
* scale; Jackson 3's uses {@code BigDecimal.equals}, which does not. Delegating to Jackson
124-
* therefore made {@code 100.00} and {@code 100.0} unequal <em>inside a container</em> while
125-
* {@link Canonical} kept them equal as scalars -- the same two numbers answering differently
126-
* depending on nesting, which is precisely the kind of silently-wrong match §2.6.1 exists to
127-
* prevent. Money written at two scales is the everyday way to hit it.
121+
* <p><strong>Not {@code JsonNode.equals}, and a wider fix than the bug that prompted it.</strong>
122+
* Jackson's node equality is <em>representation</em> equality: it separates {@code IntNode} from
123+
* {@code DoubleNode} from {@code DecimalNode}, and (from Jackson 3) one {@code DecimalNode} scale
124+
* from another. §2.6.1 puts all of them in one {@code {number}} class, and {@link Canonical}
125+
* implements that for scalars. Delegating containers to Jackson therefore made the engine answer
126+
* differently about the same two numbers depending on whether they sat inside an object.
127+
*
128+
* <p>The scale half of that split arrived with Jackson 3 -- its {@code DecimalNode.equals} uses
129+
* {@code BigDecimal.equals} where Jackson 2 used {@code compareTo} -- and money written at two
130+
* scales is the everyday way to meet it. <strong>The cross-representation half is older and was
131+
* never a Jackson 3 regression at all</strong>: {@code {a: 1}} and {@code {a: 1.0}} straight out
132+
* of {@code readTree} were already unequal here while {@code 1} and {@code 1.0} were equal as
133+
* scalars. Both are fixed together, because both are the same disagreement.
134+
*
135+
* <p>Note the direction: this <em>widens</em> what matches, and §2.6.1's design is largely about
136+
* not doing that quietly. It is stated in the amendment there, and pinned by
137+
* {@code ReviewRegressionTest.ContainerNumericEquality} across scale, representation and NaN.
138+
* Non-finite doubles go the other way -- Jackson called two NaNs equal, this does not -- which
139+
* again is what the scalar path already did.
128140
*
129141
* <p>§2.6.1 originally read "{@code EQ} on two object/array values is Jackson's structural
130142
* equals". That sentence was true of Jackson 2 and stayed literally true of Jackson 3 while
131-
* meaning something else, so the spec is amended rather than the code bent to it: the engine has
132-
* one definition of numeric equality and applies it at every depth. The rest of the container
133-
* contract is unchanged -- object key order does not matter, array element order does.
143+
* meaning something else, so the spec is amended rather than the code bent to it. The rest of the
144+
* container contract is unchanged -- object key order does not matter, array element order does.
134145
*
135-
* <p>Only container {@code EQ} pays for this walk, and {@code JsonNode.equals} was already a deep
136-
* walk, so the cost is a comparison per leaf rather than an extra traversal.
146+
* <p>Cost: no extra traversal, since {@code JsonNode.equals} was already a deep walk, and nothing
147+
* here is quadratic -- {@code properties()} is the live entry set and {@code get(key)} the same
148+
* hash lookup Jackson already did. Not free at the leaf, though: a numeric comparison allocates
149+
* {@code BigDecimal}s and {@code Optional}s where {@code IntNode.equals} was a primitive compare.
150+
* That runs per alpha test per insert, not per fire cycle.
137151
*
138152
* @param left one container
139153
* @param right the other, already known to be the same kind
@@ -168,10 +182,25 @@ private static boolean structurallyEqual(final JsonNode left, final JsonNode rig
168182
* @return whether they are equal
169183
*/
170184
private static boolean nodesEqual(final JsonNode left, final JsonNode right) {
185+
/*
186+
* Deliberately no `left == right` fast path. It looks free and is not: two NaNs are unequal
187+
* here, so a reference check would make a node equal to ITSELF while unequal to a structurally
188+
* identical twin -- an identity-dependent answer, which is worse than the cost it saves. The
189+
* consequence is that comparing a node with itself walks; a self-referential node, which
190+
* Jackson lets you build, overflows the stack rather than short-circuiting. JsonNode.equals had
191+
* the same exposure for two distinct cyclic nodes, and a payload that IS its own constraint
192+
* literal is already a contract violation.
193+
*/
171194
if (left.isNumber() && right.isNumber()) {
172-
// Canonical.compare returns empty for a value BigDecimal cannot represent -- a non-finite
173-
// double built in Java. Those compare unequal rather than throwing, matching what the scalar
174-
// path does with them.
195+
/*
196+
* Canonical.compare, where the scalar path uses Canonical.hashKey -- compareTo() == 0 against
197+
* stripTrailingZeros().equals(). Two spellings of one predicate, and the whole fix rests on
198+
* them agreeing. They do, over every scale, signed zero and exponent form checked; if you
199+
* change either, check the other, because no test compares them directly.
200+
*
201+
* Empty means a value BigDecimal cannot represent -- a non-finite double built in Java. Those
202+
* compare unequal rather than throwing, matching what the scalar path does with them.
203+
*/
175204
final OptionalInt sign = Canonical.compare(left, right);
176205
return sign.isPresent() && sign.getAsInt() == 0;
177206
}

rule-engine-dsl/src/main/java/com/codeheadsystems/rules/dsl/RuleFileReader.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,8 @@ private static Optional<Parsed> bind(final RuleSource source, final JsonNode tre
136136
* Turns a Jackson failure into a located diagnostic.
137137
*
138138
* <p><strong>{@code JacksonException} is unchecked under Jackson 3</strong>, where the Jackson 2
139-
* exception it replaces here, {@code JsonProcessingException}, was checked. Nothing here changed shape, but the
139+
* exception it replaces here, {@code JsonProcessingException}, was checked. Nothing here
140+
* changed shape, but the
140141
* compiler no longer insists: deleting either catch above would now build clean and turn a
141142
* malformed rule file back into a raw stack trace in somebody's startup log, which is the exact
142143
* outcome the second catch's comment argues against. The catches are load-bearing on their own

rule-engine-testkit/src/test/java/com/codeheadsystems/rules/testkit/CompilerValidationTest.java

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,19 +346,35 @@ void versionHashIsPinned() {
346346
* and that is a decision to be made deliberately (bump COMPILER_VERSION and say why in the
347347
* commit) rather than discovered by a consumer whose stored version stopped matching.
348348
*/
349+
/*
350+
* The fixture is chosen for what could MOVE, not for readability. A pin over strings and ints
351+
* would have stayed green through exactly the rendering changes it exists to catch, so it
352+
* carries: a BigDecimal with trailing zeros and a fractional double (scale and exponent form
353+
* are where Jackson's number rendering could plausibly change); a string needing escaping and
354+
* one outside ASCII; an object and an array literal (container rendering, and key order); a
355+
* two-sided range; and two tags, because canonicalise sorts tags() through a TreeSet
356+
* specifically to keep Set.copyOf's per-JVM iteration salt out of the hash -- the one
357+
* canonicalisation bug this project has actually shipped, and the fixture had no tags at all.
358+
*/
349359
final CompiledRuleSet pinned = RuleCompiler.compile(List.of(Rules.rule("pinned")
350360
.salience(5)
361+
.tag("zebra")
362+
.tag("alpha")
351363
.when("o", "Order", pattern -> pattern
352-
.eq("status", "PENDING")
364+
.eq("status", "PENDING\t\"quoted\"\n")
365+
.eq("note", "sale ends soon \u2014 \u00e9t\u00e9")
366+
.eq("breakdown", Facts.obj("net", new java.math.BigDecimal("100.00")))
367+
.eq("codes", Facts.array(1, 2.50d))
353368
.gt("total", 10000)
369+
.between("weight", 0.5d, 99.750d)
354370
.in("region", "EU", "US"))
355371
.when("c", "Customer", pattern -> pattern.ref("id", "o.customerId"))
356372
.then(actions -> actions.emit("out", "id", Rules.ref("o.id")))
357373
.build()));
358374

359375
assertThat(pinned.version())
360376
.describedAs("rule-set identity is a compatibility surface; see this test's comment")
361-
.isEqualTo("sha256:80ead2110b810f97");
377+
.isEqualTo("sha256:8049b5f6bd96b20d");
362378
}
363379

364380
@Test

rule-engine-testkit/src/test/java/com/codeheadsystems/rules/testkit/ReviewRegressionTest.java

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,13 @@
88
import com.codeheadsystems.rules.compiler.RuleCompiler;
99
import com.codeheadsystems.rules.fact.FactHandle;
1010
import com.codeheadsystems.rules.listener.RuleEngineListener;
11-
import com.codeheadsystems.rules.listener.RuleEngineListener;
1211
import com.codeheadsystems.rules.listener.SuppressReason;
1312
import com.codeheadsystems.rules.match.ActivationKey;
1413
import com.codeheadsystems.rules.rhs.RhsErrorHandler;
1514
import com.codeheadsystems.rules.rhs.StagedEffect;
1615
import com.codeheadsystems.rules.rule.Constraint;
1716
import com.codeheadsystems.rules.rule.FieldConstraint;
1817
import com.codeheadsystems.rules.rule.Operator;
19-
import com.codeheadsystems.rules.rule.Operator;
2018
import com.codeheadsystems.rules.rule.RuleDefinition;
2119
import com.codeheadsystems.rules.session.CollectingEventSink;
2220
import com.codeheadsystems.rules.session.CompiledRuleSet;
@@ -282,6 +280,55 @@ void scalarAgreesWithContainer() {
282280
Facts.obj("v", new java.math.BigDecimal("100.0")).get("v"))).isTrue();
283281
}
284282

283+
@Test
284+
@DisplayName("cross-representation too, which was never a Jackson 3 regression at all")
285+
void representationDoesNotDecideEquality() throws Exception {
286+
/*
287+
* The half of this that predates the migration. Jackson separates IntNode from DoubleNode as
288+
* firmly as it separates two DecimalNode scales, so {a: 1} and {a: 1.0} were unequal inside a
289+
* container under Jackson 2 as well -- while 1 and 1.0 were equal as scalars, because the
290+
* scalar path went through Canonical. No BigDecimal required: readTree on ordinary JSON is
291+
* enough, which is why the blast radius is wider than the money example suggests.
292+
*/
293+
assertThat(Comparisons.test(Operator.EQ, Facts.json("{\"a\": 1}"), Facts.json("{\"a\": 1.0}")))
294+
.describedAs("parsed {a:1} EQ parsed {a:1.0}").isTrue();
295+
assertThat(Comparisons.test(Operator.EQ, Facts.array(1), Facts.array(1.0d)))
296+
.describedAs("[1] EQ [1.0]").isTrue();
297+
assertThat(Comparisons.test(Operator.EQ,
298+
Facts.obj("a", 1), Facts.obj("a", new java.math.BigDecimal("1"))))
299+
.describedAs("int against BigDecimal").isTrue();
300+
}
301+
302+
@Test
303+
@DisplayName("a non-finite double is not equal to itself, matching the scalar path")
304+
void nonFiniteGoesTheOtherWay() {
305+
// The one case that got STRICTER. Jackson called two NaNs equal; Canonical cannot represent
306+
// one as a BigDecimal, so the scalar path has always said unequal, and the container path
307+
// now agrees. Recorded because it moved, not because anyone should rely on it.
308+
final ObjectNode left = Facts.obj("v", 1);
309+
final ObjectNode right = Facts.obj("v", 1);
310+
left.put("v", Double.NaN);
311+
right.put("v", Double.NaN);
312+
313+
assertThat(Comparisons.test(Operator.EQ, left, right))
314+
.describedAs("{v: NaN} EQ {v: NaN}").isFalse();
315+
assertThat(Comparisons.test(Operator.EQ, left.get("v"), right.get("v")))
316+
.describedAs("the scalar path, which always said this").isFalse();
317+
}
318+
319+
@Test
320+
@DisplayName("IN and NOT_IN inherit it, being EQ against each element")
321+
void membershipInheritsContainerEquality() {
322+
// §2.6.1 defines IN as EQ against each element, and in() delegates to the same comparator --
323+
// so this cannot drift. Pinned anyway, because "cannot drift" is a claim about today's call
324+
// graph and this is the operator an author actually reaches for.
325+
final ObjectNode needle = Facts.obj("amount", new java.math.BigDecimal("100.00"));
326+
final ArrayNode haystack = Facts.array(Facts.obj("amount", new java.math.BigDecimal("100.0")));
327+
328+
assertThat(Comparisons.test(Operator.IN, needle, haystack)).isTrue();
329+
assertThat(Comparisons.test(Operator.NOT_IN, needle, haystack)).isFalse();
330+
}
331+
285332
@Test
286333
@DisplayName("§2.6.1's other container rules still hold: key order free, element order not")
287334
void structuralRulesUnchanged() {

0 commit comments

Comments
 (0)