Skip to content

Compare object keys by UTF-16 code unit in the rules engine - #7562

Draft
ajpallares wants to merge 8 commits into
mainfrom
pallares/rules-engine-object-key-identity
Draft

Compare object keys by UTF-16 code unit in the rules engine#7562
ajpallares wants to merge 8 commits into
mainfrom
pallares/rules-engine-object-key-identity

Conversation

@ajpallares

@ajpallares ajpallares commented Sep 1, 2026

Copy link
Copy Markdown
Member

Checklist

  • If applicable, unit tests
  • If applicable, create follow-up issues for purchases-android and hybrids

Motivation

Related to #7518. That PR routed every string comparison through UTF-16 code units, but object keys still went through Swift's Dictionary, which keys by canonical equivalence. So a rule reading a non-ASCII key could resolve on iOS and fail on Android.

Description

  • A var path spelled café with é as U+00E9 resolved against a key spelling it e + U+0301, where JS and Kotlin find nothing. Subscriber attribute names are developer-supplied and unrestricted, so both spellings are reachable.
  • An object holding both spellings kept only one entry, and which one survived was decided by hash order rather than document order.
  • Kotlin already keys maps by code unit, so purchases-android needs the new fixtures only: Pin object key identity in the predicate fixtures purchases-android#4149.

ajpallares and others added 7 commits August 26, 2026 20:16
A JS string is a sequence of UTF-16 code units and every string operation
works on those units. Swift's String compares canonical equivalence over
Unicode scalars, so <, <=, >, >=, ==, ===, in and rc.entries key ordering
all had cases where a predicate evaluated one way on iOS and the other in
JS and Kotlin.

Co-authored-by: Cursor <cursoragent@cursor.com>
The fixture files are byte-identical across the iOS and Android engines, so
a description that explains the contract in terms of one host language's
string comparison does not read correctly in the other repository.

Co-authored-by: Cursor <cursoragent@cursor.com>
The predicates spell these strings as \uXXXX escapes so the exact code
units are unambiguous, which leaves a reader unable to tell which
character a fixture is about. Each description now shows the glyph and
its code point, and spells out the surrogate pair where one is the
point of the case.

Co-authored-by: Cursor <cursoragent@cursor.com>
…-utf16-string-comparison

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	Tests/UnitTests/RulesEngine/PredicateFixtureTests.swift
The operator open-coded the UTF-16 comparison because the shared helper
did not exist yet when it landed. It does now, and the rationale lives
in the helper's doc, so the operator no longer repeats it.

Co-authored-by: Cursor <cursoragent@cursor.com>
…-utf16-string-comparison

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	Tests/UnitTests/RulesEngine/PredicateFixtureTests.swift
A Swift dictionary keys by canonical equivalence, so é (U+00E9) and e
followed by a combining acute (U+0301) were one key: an object holding
both spellings kept only one, and a var path spelled either way resolved
against the other. JS and Kotlin keep the two apart, so a rule reading a
non-ASCII key could answer differently per platform.

Value.object now stores an ObjectValue, whose keys compare and hash by
code unit, and the JSON parser iterates the NSDictionary rather than
casting to [String: Any], which merges the keys before the engine sees
them.

Co-authored-by: Cursor <cursoragent@cursor.com>
…-object-key-identity

The merge is semantic in three places:

- rc.length was removed from main (#7568), so the two rc_entries fixtures
  this branch adds count their entries with reduce instead. The branch's
  edit to a description in rc_length.json goes with the deleted file.
- rc.let (#7506) landed after this branch was written and keeps its
  bindings in a Swift dictionary, which is the same key collapse this
  branch is about: two spellings of the same glyph bound in one rc.let
  became one binding, and a var path spelled either way resolved against
  the other. Scope.bindings is now an ObjectValue, so bindings are keyed
  by code unit like every other object.
- expectedCount recomputed from disk as 681.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant