Skip to content

RulesEngine: add JSON Logic predicate evaluator#3482

Draft
ajpallares wants to merge 12 commits into
pallares/rules-engine-enforce-internal-apifrom
pallares/json-logic-evaluator
Draft

RulesEngine: add JSON Logic predicate evaluator#3482
ajpallares wants to merge 12 commits into
pallares/rules-engine-enforce-internal-apifrom
pallares/json-logic-evaluator

Conversation

@ajpallares
Copy link
Copy Markdown
Member

@ajpallares ajpallares commented May 14, 2026

Resolves SDK-4326

Checklist

  • Unit tests added

Motivation

First slice of the JSON Logic rules engine, stacked on the no-public-APIs enforcement in #3480 (and through it, the skeleton in #3478). Resolves SDK-4320.

Description

  • Adds Logger, RuleError, Value, Evaluator, and operators/ with the MVP set: var, missing, ==, !=, ===, !==, !, !!, and, or, if.
  • Every new declaration is module-internal, so Enforce :rules-engine has no public API outside @InternalRulesEngineAPI #3480's intrinsic "no non-internal public API" check stays clean and the engine can land incrementally.
  • Test-only ValueJsonHelper lets predicates be authored the way they appear in rule artifacts; production callers will build Value trees from the host SDK's own JSON parser.

Verification: ./gradlew :rules-engine:test (63/63 pass), ./gradlew :rules-engine:check (lint + metalava clean), ./gradlew detektAll clean.

Made with Cursor

First slice of the JSON Logic rules engine in the new `:rules-engine`
module (built on top of the module skeleton from
`pallares/rules-engine-skeleton`). Pure Kotlin, no exported API
surface — every new declaration is module-`internal` so the engine can
land incrementally without changing `rules-engine/api.txt` or pulling
anything into the SDK's metalava signature.

What's in:

- `Logger.kt` — internal `RulesEngineLogger` interface + default
  `PrintlnLogger` (stderr) + test `CapturingLogger`. Kept internal so a
  future host-supplied logger can be adapted to the same interface
  without API churn.
- `Value.kt` — typed `Value` sealed class (`Null` / `BoolValue` /
  `IntValue` / `FloatValue` / `StringValue` / `ArrayValue` /
  `ObjectValue`), JSON Logic truthiness + loose (`==`) / strict (`===`)
  equality with type coercion.
- `RuleError.kt` — `RuleError` sealed `RuntimeException` (`Parse`,
  `TypeMismatch`, `UnsupportedOperator`).
- `Evaluator.kt` — top-level dispatcher; takes a typed `Value`
  predicate (no JSON parsing in the engine) and returns `Boolean`.
- `operators/` — MVP set: `var` (strict dot-path), `missing`, `==`,
  `!=`, `===`, `!==`, `!`, `!!`, `and`, `or`, `if`.
- Test source set adds a `helpers/ValueJsonHelper.kt` (`org.json` +
  `BigDecimal` scale check to preserve int-vs-decimal intent) so test
  predicates can be expressed as JSON literals — production callers
  will construct `Value` trees from the host SDK's own JSON parser.

Key decisions:

- **JSON parsing lives outside the engine.** The engine accepts a typed
  `Value` tree (the JSON-shaped sealed class is what cross-language
  bridges can express across the boundary). The `org.json`-backed JSON
  helper is gated to the test source set only via a new
  `testImplementation(libs.json)` dep.
- **Missing variables** resolve to `Null` and emit a warning, per JSON
  Logic spec. No `MissingVariable` subclass in v1; reserved for a
  future strict mode.
- **`var` lookup** uses strict dot-path navigation. Flat-key fallback
  considered and deferred — pure addition if we need it later.

Verification:

- `./gradlew :rules-engine:test` — 63 tests, 0 failures.
- `./gradlew :rules-engine:check` — `lint` + `metalavaCheckCompatibility`
  pass; `api.txt` unchanged because every new declaration is
  `internal`.
- `./gradlew detektAll` — clean.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ternal-api' into pallares/json-logic-evaluator
@ajpallares ajpallares changed the base branch from pallares/rules-engine-skeleton to pallares/rules-engine-enforce-internal-api May 15, 2026 18:07
ajpallares and others added 2 commits May 16, 2026 08:54
`opIf` already supports the 1-arg `{"if": [expr]}` (returns the evaluated
arg) and 2-arg `{"if": [cond, then]}` (returns evaluated `then` when
truthy, `Null` otherwise) forms via the trailing-else fall-through path.
Both were unpinned by tests — only the 3-arg, chained-else-if, even-arity
"no else" and empty cases were covered. Add focused tests so a future
tweak to the loop bounds in `opIf` can't silently change either form.

Co-authored-by: Cursor <cursoragent@cursor.com>
`looseEq` and `strictEq` of `FloatValue(Double.NaN)` against itself both
return `false` (IEEE 754: `NaN == NaN` is always false). `FloatValue` of
`Double.POSITIVE_INFINITY` against itself returns `true`. Both match the
JS `==` / `===` semantics the engine targets.

Pin the behavior so:
1. A future refactor can't accidentally special-case NaN-equals-NaN.
2. Cross-platform drift (Android's `String.toDoubleOrNull` vs iOS's
   `Double(String)`) stays visible.

Mirrors the equivalent test in the iOS RulesEngine PR.

Co-authored-by: Cursor <cursoragent@cursor.com>
@codecov
Copy link
Copy Markdown

codecov Bot commented May 16, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.89%. Comparing base (0e150a0) to head (e81d6c5).
⚠️ Report is 5 commits behind head on pallares/rules-engine-enforce-internal-api.

Additional details and impacted files
@@                             Coverage Diff                             @@
##           pallares/rules-engine-enforce-internal-api    #3482   +/-   ##
===========================================================================
  Coverage                                       79.89%   79.89%           
===========================================================================
  Files                                             369      369           
  Lines                                           14871    14871           
  Branches                                         2048     2048           
===========================================================================
  Hits                                            11881    11881           
  Misses                                           2157     2157           
  Partials                                          833      833           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

ajpallares and others added 8 commits May 16, 2026 12:52
Threading a `RulesEngineLogger` parameter through every signature in
`Evaluator` + every `Operators` helper added a lot of boilerplate for
two warning sites (`var` missing-variable and `var` extra-args).

Replace the parameter with a module-internal `RulesEngineLog` object that
holds a swappable `sink: RulesEngineLogger` (defaulting to `PrintlnLogger`).
Production callers — currently only the engine internals — call
`RulesEngineLog.warn(...)` directly. A future host-supplied logger swaps
the sink at startup.

Mechanical changes:
- New `RulesEngineLog` object in `Logger.kt`.
- Drop `logger` param from `Evaluator.evaluate*`, `Operators.dispatch`,
  `Operators.evalArgs`, `Operators.evalTwo`, every `op*` in
  `AccessorOperators` / `EqualityOperators` / `LogicOperators`.
- Move `CapturingLogger` to the test source set (no production caller).
- Add `CapturingLoggerRule`: a JUnit `TestRule` that swaps the sink to a
  fresh `CapturingLogger` for the duration of the test and restores the
  previous sink afterwards. Tests assert via `loggerRule.warnings`.
- Update all rules-engine tests to install the rule and drop the
  per-call logger argument.

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

Two reference-impl deviations the previous review surfaced. Pulling them
into spec compliance now while the engine has no consumers — cheaper to
fix here than after rule artifacts start depending on the deviations.

`missing`: per json-logic-js (`logic.js`'s `missing` op), a key is
"missing" when its resolved value is `null` OR `""`. Our `opMissing`
only checked for "absent from data" (Kotlin-null from `lookupPath`), so
data like `{"email": null}` or `{"email": ""}` reported `email` as
present. Now they're reported as missing. Other falsy-but-defined values
(`0`, `false`, `[]`, `{}`, `"0"`) stay non-missing per spec.

`and` / `or` empty-args: json-logic-js leaves its `current` accumulator
`undefined` when the loop body never runs and returns it; we returned
`BoolValue(true)` / `BoolValue(false)` (vacuous truth/falsity). Both
were falsy in boolean contexts but the JS-`undefined` shape only mapped
cleanly to `Value.Null` in our algebra, so visible-as-value contexts
like `{"if": [{"and": []}, "yes", "no"]}` were producing different
branches across engines. Now we return `Value.Null` for both empty
cases, matching the JS reference impl.

Tests:
- `missing reports null-valued keys as missing per spec`
- `missing reports empty-string-valued keys as missing per spec`
- `missing does not report falsy-but-defined values as missing`
  (pins the negative side: `0`, `false`, `[]`, `{}`, `"0"`)
- `and empty is null per spec` / `or empty is null per spec`
  (replacing the previous `BoolValue(true)` / `BoolValue(false)` asserts)

iOS PR #6789 has the same deviations on both fronts; will follow up
there to keep the two evaluators in lockstep.

Co-authored-by: Cursor <cursoragent@cursor.com>
`json-logic-js` recursively evaluates the argument(s) of `var` and
`missing` before using them as paths, so dynamic constructs like
`{"var": {"var": "active_path_key"}}` or
`{"missing": [{"var": "key_to_check"}]}` are valid. Our previous
implementation treated those args as literals, which silently rejected
otherwise-valid predicates.

`opVar` now routes its argument through `Evaluator.evaluateValue` before
parsing: the array form evaluates each element in place (path and
default both become dynamic), and the singleton form evaluates the lone
argument and treats the result as the path. `opMissing` evaluates each
key the same way, and additionally unpacks the first evaluated arg when
it resolves to an array — mirrors `Array.isArray(arguments[0])` so
constructs like `{"missing": {"merge": [["a"], ["b"]]}}` work as the
spec describes.

The one deliberate deviation: when the singleton form of `var`
evaluates to a non-primitive (e.g. an array), we throw `TypeMismatch`
instead of JS-stringifying it ("x,y") and looking that up. Pinned by
`var singleton expression resolving to array throws`.

Doc comments updated to describe the spec-aligned behavior. New tests
cover dynamic singleton paths, dynamic array-form paths, dynamic
defaults, dynamic `missing` keys, and the first-arg-array unpack rule.
Three float-path edge cases mirrored from iOS pin our `formatNumber`
behavior under integer-valued, fractional, and oversized doubles.

Ports purchases-ios `267947e60a` to keep the two evaluators in lockstep.

Co-authored-by: Cursor <cursoragent@cursor.com>
Mirrors the iOS RulesEngine's `Rules.logger` singleton: the host SDK
installs an adapter once at integration time, and the engine routes
diagnostic warnings through it. The default falls through to a stderr
`PrintlnLogger` until the SDK wires it up.

The static accessor and the logger interface are public-but-opt-in
(`@InternalRulesEngineAPI`) so `:purchases` / `:ui:revenuecatui` /
hybrid bridges can swap the sink without leaking new public API. The
`InternalRulesEngineAPI` annotation is a metalava `hiddenAnnotation`,
so the API dump still contains only the marker annotation itself —
verified by `scripts/check-rules-engine-internal-only.sh`.

Co-authored-by: Cursor <cursoragent@cursor.com>
Carry over four behavioral / cosmetic deltas the iOS counterpart PR
landed during review. None of these change semantics on Android — most
are documentation tightening or test-pin additions — but keeping the
two platforms in lockstep avoids future drift.

- Drop the `if (items.isEmpty()) return Value.Null` early guard from
  `LogicOperators.opIf`. The loop / final return already fall through
  to `Value.Null` for empty args, so the guard is dead. Mirrors iOS
  `fed4baea5`.
- Add `empty and inside if takes else branch` and the `or` mirror to
  `LogicOperatorsTest`. The existing tests pinned the return value of
  `{"and": []}` / `{"or": []}` but nothing exercised the
  cross-operator interaction the spec alignment is meant to preserve
  (`{"if": [{"and": []}, …]}` must take the `else` branch). Mirrors
  iOS `459f78f9b`.
- Tighten `ValueJsonHelper.convert` to throw `RuleError.Parse` for
  unknown `JSONTokener` outputs (e.g. `Date`, custom `JSONString`
  impls) instead of silently coercing to `Value.Null`. Better to fail
  loudly than mask a parser misconfiguration with phantom `null`
  operands. Mirrors iOS `4c4cc95fa`.
- Trim the verbose KDoc on the `Rules` namespace and drop the now
  redundant doc on `Rules.logger`. The opt-in / cross-module
  reasoning lives on `InternalRulesEngineAPI`. Mirrors iOS
  `266415d91` + `d63dac156`.

Verified: `:rules-engine:testDefaultsDebugUnitTest`, `detektAll`, and
`scripts/check-rules-engine-internal-only.sh` all green.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ternal-api' into pallares/json-logic-evaluator

Pulls in the skeleton + enforce-side annotation drop. With
`@InternalRulesEngineAPI` deleted upstream, every evaluator-side
file that referenced it would fail to compile until the references
are stripped, so the cleanup is folded into the merge commit:

- `Logger.kt`: drop the file-level `@OptIn`, drop the
  `@InternalRulesEngineAPI` on `RulesEngineLogger`, trim its KDoc
  to drop the gating-mechanism explanation. The interface is now a
  plain `public interface`.
- `Rules.kt`: drop the `@InternalRulesEngineAPI` on the namespace
  object. Stays plain `public object Rules`.
- `operators/AccessorOperators.kt`: drop the file-level `@OptIn`
  and the `InternalRulesEngineAPI` import. Internal callsites of
  `Rules.logger.warn(...)` no longer need the opt-in dance.
- `CapturingLogger.kt`, `CapturingLoggerRule.kt`, `LoggerTest.kt`:
  drop the file-level `@OptIn`. Test plumbing reaches `Rules.logger`
  and `RulesEngineLogger` directly now.

The conflicts the merge produced were both modify/delete pairs on
files the evaluator branch already deleted (`RulesEngine.kt` /
`RulesEngineTest.kt` were renamed to `Rules.kt` + dropped here in
commit `162574d20`); resolution is `git rm` to keep the deletions.

The committed `rules-engine/api.txt` baseline is regenerated to
reflect the new public surface — `Rules` (with its `logger`
property) and `RulesEngineLogger`.

Verified: `:rules-engine:testDefaultsDebugUnitTest`, `detektAll`,
and `:rules-engine:metalavaCheckDefaultsRelease` all green.

Co-authored-by: Cursor <cursoragent@cursor.com>
Locks in the observed runtime behavior for `{"==": [{"a":1}, {"a":1}]}`:
both our engine and json-logic-js dispatch single-key objects as
operators via `is_logic`/`evaluateValue`, so this literal predicate
throws `RuleError.UnsupportedOperator("a")` instead of doing a
structural compare. Pins the contrast with the existing multi-key
test (where the structural-vs-reference divergence still applies) so
a future operator-dispatch refactor can't quietly regress this.

Also tightens the doc comment on `multi-key object is a literal data
value` to mirror its iOS counterpart.

Co-authored-by: Cursor <cursoragent@cursor.com>
`looseEq` only had explicit cases for primitives and same-compound
operands, so `[1] == "1"` and `[1, 2] == "1,2"` returned `false` —
divergent from json-logic-js, which inherits JS abstract equality:
when one side is a compound (Array/Object) and the other is a
primitive, the compound goes through ToPrimitive (string hint) and
the comparison is retried.

Add four cross-type arms to `looseEq` that mirror exactly that
coercion: arrays render via `Array.prototype.toString()` (recursive
comma-join, with `null` elements as the empty string); objects render
as `"[object Object]"`. The recursive call falls through to the
existing primitive arms — string-vs-string for `[1, 2] == "1,2"`,
the numeric fallback for `[] == 0` / `[1] == 1`. Same-compound
comparisons keep their structural-eq behavior (deliberate divergence
from JS reference identity, which would make rule patterns like
`{"==": [{"var": "tags"}, ["a", "b"]]}` always false).

Sharpen the `looseEq` doc comment to spell out the four behaviors
(same-type, cross-numeric, same-compound structural, compound-vs-
primitive ToPrimitive) and the JS reference identity divergence.

Pin the new behavior with `ValueTest` cases covering each spec
example (`[1] == "1"`, `[1, 2] == "1,2"`, `[null, 1] == ",1"`,
`[] == ""`, nested arrays, the numeric fallback path, JS-specific
float rendering, `{...} == "[object Object]"`, and array-vs-object).
Also pin the deliberate same-compound structural rule in a separate
`looseEq objects structural` test, mirroring the array case.

Add two `EvaluatorTest` cases that drive the same coercion through
the full `Evaluator.evaluate` path so a future regression in either
layer is caught.

Mirrors purchases-ios c5d53ec71.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant