diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,90 @@
+# Changelog for jsonlogic-hs
+
+## 0.1.0.0 -- unreleased
+
+First release.
+
+### Reading and evaluating rules
+
+* Every builtin operator of the classic JsonLogic specification, with
+  JavaScript's semantics as json-logic-js 2.0.5 implements them: coercion,
+  `NaN`, the infinities, `undefined`, negative zero, UTF-16 strings, and
+  JavaScript-identical number parsing and printing.
+* `apply`, `applyWith` and `applyWithConfig`; `compile` to read, check and
+  build a rule once, and `run` to evaluate it any number of times. Custom
+  operators are resolved when the rule is compiled.
+* Strict arity by default (a precise `ArityMismatch`); `arityMode = Lenient`
+  behaves exactly like the reference, including evaluating surplus operands
+  and throwing on an empty `*` only if it is evaluated.
+* Configurable resource limits, on by default: rule depth, evaluation steps,
+  string length and array length, so a rule from an untrusted source cannot
+  exhaust time or memory (`docs/LIMITS.md`). Exceeding one is a typed
+  `LimitExceeded` error.
+* Every error carries the `Location` of the node that failed, a JSON Pointer
+  (RFC 6901): `ParseFailure Location ParseError` and
+  `EvalFailure Location EvalError`; `renderError` starts with it.
+* Custom operators: `operatorsFromList` refuses a builtin name or a name given
+  twice, `Operators` combine with `<>`, and an operator's result is held to
+  the depth and step limits.
+* `dataUse`: which paths of the data a rule can read, found without running
+  it, scope-aware (an iteration body reads the element, not the data), with a
+  tested guarantee.
+
+### How it is tested
+
+* All 278 cases of the classic shared suite; the runner fails if its fixture
+  is missing, truncated or changed.
+* 26,980 outlier cases with the answers json-logic-js 2.0.5 gave, replayed
+  offline by `cabal test` in both arity modes, with a 140,000-case tier
+  nightly (`differential/outliers.js`).
+* The community suite of json-logic/compat-tables (1,138 cases), scored as the
+  compatibility tables score implementations and pinned per file: 782 in
+  lenient mode, the same as json-logic-js file for file, and 839 in strict
+  mode. Every one of its rules is also an outlier case.
+* A differential test against the reference with a fresh seed per CI run,
+  failing-case shrinking and replay.
+* Unit and property tests (278 examples): boundary triples for every limit,
+  amplification rules, laws, error locations that always resolve to the
+  operator at fault, every fast numeric algorithm against an exact one, and
+  `dataUse`'s guarantee.
+* Every example in `docs/TUTORIAL.md` is checked by a test suite.
+* `scripts/check-harness.sh` feeds every runner missing, empty, truncated and
+  tampered fixtures and demands a failure.
+* CI: GHC 9.6 to 9.14 on Linux, plus macOS and Windows; `-Werror` on the
+  primary compiler; hlint, fourmolu, weeder, shellcheck and actionlint;
+  `cabal check`; the source distribution built, tested and documented on its
+  own; a downstream project using only `JsonLogic`.
+
+### Deliberate differences from the reference
+
+Listed with reasons and tests in `docs/COMPATIBILITY.md`: strict arity in the
+default mode, arrays and objects never equal under `==` / `===`, own
+properties only (no prototype chain), data never run as rules by `missing`,
+a slice through a surrogate pair gives U+FFFD, `log` does not print, exact
+custom operator names, and the resource limits.
+
+### Changes made after the external audit (2026-10-22)
+
+These describe how the release candidate changed; nothing here was ever
+released in another form.
+
+* Fixed: lenient mode rejected an empty `*` while reading the rule, so
+  `{"if": [true, 1, {"*": []}]}` failed where the reference gives `1`.
+* Fixed: the licence text (placeholder copyright line, a typo in the warranty
+  clause) and the README, which named the wrong licence. The licence is MIT.
+* Fixed: `cabal check` failed on the benchmark's `-O2`; the weeder CI job never
+  found its interface files, and is now blocking and clean.
+* `compile` takes the custom operators and builds the evaluator once; `run`
+  takes only the compiled rule.
+* Errors carry locations (`docs/adr/0006`).
+* One table of builtin operators (`JsonLogic.Core.Operator`) and one statement
+  of each operator's operands (`shapeOf`), replacing four hand-kept lists.
+* `recursion-schemes`, `deriving-compat` and Template Haskell are no longer
+  dependencies; `vector` is (it was already a dependency of aeson).
+* Faster: arrays are vectors (an index is constant time and, as documented,
+  costs no extra steps), numbers are read with Clinger's fast path and written
+  with Ryū, literal `var` paths are split once, and a step allocates once.
+  `map` over 10,000 elements runs in about 2.5 ms instead of 3.3 ms, printing
+  1,000 doubles in 0.3 ms instead of 3.1 ms.
+* `var`'s strict arity is reported as `Between 0 2`, which is what it always
+  accepted.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Sabrinathan Nair
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,243 @@
+# jsonlogic-hs
+
+A native Haskell implementation of [JsonLogic](https://jsonlogic.com), the
+small rule language that is itself JSON, so rules can be stored, shared
+between systems and evaluated against data without running arbitrary code.
+
+A substantial amount of the code and documentation was written with AI
+assistance.
+
+It is held to the JavaScript reference implementation,
+[json-logic-js](https://github.com/jwadhams/json-logic-js) 2.0.5. On our own
+26,980-case catalogue of reference answers, the next closest implementation we
+tested scores 84.8% (see [How it compares](#how-it-compares)).
+
+Author: Sabrinathan Nair. Licence: MIT LICENSE.
+
+
+| | |
+| --- | --- |
+| Classic shared suite (jsonlogic.com/tests.json) | 278 / 278 |
+| Outlier catalogue, answers recorded from json-logic-js | 26,980 / 26,980 (lenient mode) |
+| Random differential test against json-logic-js | every CI run, fresh seed |
+| Community suite (json-logic/compat-tables) | 782 / 1138 lenient, 839 / 1138 strict |
+| Haddock coverage | 100% of every module |
+
+The community suite (json-logic/compat-tables) is mostly written for the newer
+JSON Logic 2.0 dialect, which this library does not implement. We run it for
+reference, not as a target. In lenient mode the library scores exactly what
+json-logic-js scores on it, file for file (782 / 1138), which is what you would
+expect from a faithful copy of that reference. Libraries that implement the 2.0
+dialect score much higher on it; see [How it compares](#how-it-compares).
+
+## Quick start
+
+```haskell
+{-# LANGUAGE OverloadedStrings #-}
+
+import Data.Aeson (decode)
+import JsonLogic (apply)
+
+main :: IO ()
+main = print (apply <$> decode "{\"==\": [{\"var\": \"a\"}, 1]}" <*> decode "{\"a\": 1}")
+-- Just (Right (Bool True))
+```
+
+`apply rule data` takes both as aeson `Value`s and returns
+`Either Error Value`. [`docs/TUTORIAL.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/TUTORIAL.md) walks through
+everything else: compiling once, configuring limits, custom operators,
+reading errors, and finding out what a rule reads.
+
+## The API in one screen
+
+```haskell
+apply           :: Value -> Value -> Either Error Value
+applyWith       :: Operators -> Value -> Value -> Either Error Value
+applyWithConfig :: Config -> Operators -> Value -> Value -> Either Error Value
+
+compile :: Config -> Operators -> Value -> Either Error Rule   -- read, check, build once
+run     :: Rule -> Value -> Either Error Value                 -- run many times
+dataUse :: Rule -> DataUse                                     -- what it can read
+
+operatorsFromList :: [(OperatorName, CustomOperator)] -> Either OperatorsError Operators
+type CustomOperator = Value -> [Value] -> Either Text Value
+
+data Error = ParseFailure Location ParseError | EvalFailure Location EvalError
+renderLocation :: Location -> Text                             -- an RFC 6901 JSON Pointer
+```
+
+Everything is pure: no `IO`, no global state. The supported API is the
+`JsonLogic` module; `JsonLogic.Core.*` is exposed for advanced use and may
+change before 1.0 ([`docs/STABILITY.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/STABILITY.md)).
+
+## What you can rely on
+
+- **The reference's answers.** Mixed-type behaviour is JavaScript's:
+  `"1" == 1`, `[] == 0`, `"10" < "9"`, `NaN`, the infinities, negative zero,
+  `undefined` (what an empty `and` yields, which is not `null`), UTF-16 string
+  length, indexing and ordering, ECMAScript whitespace in numeric strings, and
+  numbers printed digit for digit as JavaScript prints them. Every place it
+  deliberately differs is listed with a reason and a test in
+  [`docs/COMPATIBILITY.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/COMPATIBILITY.md).
+- **Bounded work.** The default `Config` limits rule depth (64), evaluation
+  steps (1,000,000), string length (1 MiB in UTF-16 code units) and array
+  length (100,000), so a hostile rule cannot hang the process or exhaust its
+  memory. Each limit is exact at both edges ([`docs/LIMITS.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/LIMITS.md)).
+- **Errors you can act on.** A closed set of constructors to match on, each
+  with the JSON Pointer of the node that failed:
+  `renderError` gives `at /if/1: unknown operator "nope"`.
+- **Strict or lenient arity.** By default a wrong argument count is a precise
+  `ArityMismatch` (the reference silently pads and ignores). With
+  `arityMode = Lenient` the library behaves exactly like the reference.
+- **Custom operators** supplied by the caller, refused if they would shadow a
+  builtin, resolved once when the rule is compiled, and held to the same
+  limits.
+- **Speed.** Interpretation is in the same range as json-logic-js running in
+  V8 on the same machine (`map` over 10,000 elements: about 2.5 ms here,
+  2.1 ms for the reference), with `compile` once and `run` many.
+
+## How it is checked
+
+Every claim above has a mechanical check behind it, and the checks are
+checked: [`scripts/check-harness.sh`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/scripts/check-harness.sh) feeds every
+runner missing, empty, truncated and tampered fixtures and demands that each
+fails.
+
+- **The classic suite**, pinned at exactly 278 cases.
+- **The outlier catalogue** (`test/outliers.jsonl`): rules and data chosen to
+  be awkward (type coercion, every whitespace code point, 700-digit numbers,
+  shortest-digit ties, signed zero, surrogate pairs, odd arities of every
+  operator, deep and wide rules, every community rule), with the answers
+  json-logic-js gave. Generated deterministically by
+  `differential/outliers.js`; CI fails if the committed file is stale. Every
+  case runs in both arity modes. A 140,000-case tier runs nightly.
+- **A differential test** that generates random rules, compares each answer
+  with json-logic-js running in Node, and shrinks any disagreement to a
+  minimal reproduction.
+- **The community suite**, scored per file and pinned in
+  `test/community/expected.json`, so the score only changes on purpose.
+- **Unit and property tests**: boundary triples for every limit,
+  amplification rules that must stop quickly, algebraic laws, error locations
+  that always resolve to the operator at fault, the fast numeric algorithms
+  against exact ones on hundreds of thousands of doubles, and `dataUse`'s
+  guarantee.
+- **Hygiene, blocking in CI**: `-Werror` on the primary GHC, a matrix of
+  GHC 9.6 to 9.14 plus macOS and Windows, hlint, fourmolu, shellcheck, actionlint, `cabal check`, a build of the source
+  distribution on its own, and a downstream project that uses only the public
+  module. Dead code is checked with weeder, which is advisory for now.
+
+Implementing JsonLogic in another language? The fixtures are plain JSON and
+JSONL with a documented format; [`docs/CONFORMANCE.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/CONFORMANCE.md)
+explains how to run them against your implementation.
+
+## How it compares
+
+We ran seven other JsonLogic implementations against the same fixtures on
+2026-10-23. The full report is in [`docs/COMPARISON.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/COMPARISON.md).
+The runners and raw results are in [`harness/`](https://github.com/Bombay-Boyz/jsonlogic-hs/tree/main/harness/).
+
+**This is a fidelity test, not a neutral correctness benchmark.** The outlier
+catalogue records what json-logic-js 2.0.5 answered, so json-logic-js scores
+100% by design. The catalogue was generated by this project's author.
+Implementations that follow the newer JSON Logic 2.0 dialect are scored
+against behaviour they do not aim for.
+
+### Results
+
+| Implementation | Classic suite (278) | Outlier catalogue (26,980) | Community suite (1,138) |
+| --- | --- | --- | --- |
+| **jsonlogic-hs** (measured by this repo's own test runner, see note below) | 278 (100%) | 26,980 (100%, lenient mode) | 782 lenient / 839 strict |
+| json-logic-js 2.0.5 (the reference) | 278 (100%) | 26,980 (100%) | 782 (68.7%) |
+| jsonlogic 0.5.1 (Rust) | 270 (97.1%) | 22,881 (84.8%) | 761 (66.9%) |
+| panzi-json-logic 1.0.1 (Python) | 277 (99.6%) | 21,361 (79.2%) | 790 (69.4%) |
+| json-logic-engine 5.0.7 (JS, interpreted) | 278 (100%) | 16,475 (61.1%) | 1,119 (98.3%) |
+| json-logic-engine 5.0.7 (JS, compiled) | 278 (100%) | 16,400 (60.8%) | 1,119 (98.3%) |
+| json-logic-qubit 0.9.1 (Python) | 277 (99.6%) | 15,117 (56.0%) | 759 (66.7%) |
+| datalogic-rs 5.4.0 (Rust, default features) | 278 (100%) | 14,989 (55.6%) | 1,060 (93.1%) |
+| datalogic-rs 5.4.0 (Rust, all extensions) | 278 (100%) | 14,955 (55.4%) | 1,105 (97.1%) |
+| jsonlogic-rs 0.5.0 (Rust) | 276 (99.3%) | 13,801 (51.2%) | 782 (68.7%) |
+
+**Note on the jsonlogic-hs row.** Its figures come from this repository's own
+test runners (`cabal test all`), which use the same fixtures and the judging
+rules in [`docs/CONFORMANCE.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/CONFORMANCE.md). The other rows were
+produced by the comparison harness in [`harness/`](https://github.com/Bombay-Boyz/jsonlogic-hs/tree/main/harness/). jsonlogic-hs
+itself has not been run through that harness, so its row did not go through
+exactly the same judge as the others.
+
+### What this shows
+
+- On the outlier catalogue, the closest other implementation we tested scores
+  84.8%.
+- That implementation, the `jsonlogic` Rust crate, panics on 108 cases and
+  cannot read 73 rules that contain numbers such as `1e309`.
+- The next closest scores 79.2% (panzi-json-logic).
+- On the community suite, json-logic-engine and datalogic-rs score much higher
+  than we do (93-98%).
+- They score higher because they implement the JSON Logic 2.0 dialect, and
+  jsonlogic-hs does not.
+- The two columns pull in opposite directions: following the 2.0 dialect
+  lowers the outlier score.
+
+### What this does not show
+
+- jsonlogic-hs was not run in the comparison harness, because the machine had
+  no suitable GHC. Its row uses the results of this repository's own test
+  runners, not the harness judge the other rows went through.
+- The older Haskell `jsonlogic` package (0.1.0.0) was not tested.
+- Speed was not compared.
+- datalogic-rs was tested at 5.4.0, not the latest release (5.9.0), because
+  newer versions need a newer Rust than was available.
+- Two published scores could not be reproduced exactly: the `jsonlogic` crate
+  (761 here, 798 published) and datalogic-rs (1,105 here, 1,138 published).
+- json-logic-engine has configurable limits on depth and size. We did not test
+  whether they hold, so bounded evaluation is not unique to this library.
+
+## What it is not
+
+- **Not the JSON Logic 2.0 dialect** (`val`, `??`, `exists`, `try`/`throw`,
+  `NaN` as an error). That dialect is what most of the community suite tests,
+  so a low score there reflects a different target, not a bug. It is not
+  implemented.
+- **Not a sandbox for custom operators.** The library bounds its own work and
+  a custom operator's result, not the operator's own computation.
+- **Not a wall-clock timeout.** Limits bound work; add
+  `System.Timeout.timeout` if seconds matter.
+
+## Building and testing
+
+```
+cabal build
+cabal test all            # unit/property, classic suite, outliers, community score
+```
+
+The repository's `cabal.project` turns on what released builds do not:
+`-Werror`, the differential tester (needs Node.js; see
+[`differential/README.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/differential/README.md)) and the benchmarks.
+
+| Command | What it checks |
+| --- | --- |
+| `bash scripts/lint.sh` | hlint, fourmolu, fixture hashes, shellcheck |
+| `bash scripts/weeder.sh` | dead code |
+| `bash scripts/check-harness.sh` | the test runners fail when they should; the outlier fixture is current |
+| `bash scripts/verify-sdist.sh` | the package as Hackage would receive it |
+| `bash scripts/consumer-smoke.sh` | a separate project using only `JsonLogic` |
+| `bash scripts/bench.sh` | benchmarks, with their environment ([`bench/README.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/bench/README.md)) |
+
+## Further reading
+
+- [`docs/TUTORIAL.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/TUTORIAL.md): using the library, step by step.
+- [`docs/ARCHITECTURE.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/ARCHITECTURE.md): how it is built, and why.
+- [`docs/COMPATIBILITY.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/COMPATIBILITY.md): every difference from the reference.
+- [`docs/COMPARISON.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/COMPARISON.md): how other JsonLogic implementations score on these fixtures, and the limits of that comparison.
+- [`docs/LIMITS.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/LIMITS.md): resource limits and what a step is.
+- [`docs/CONFORMANCE.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/CONFORMANCE.md): using these fixtures to test any implementation.
+- [`docs/STABILITY.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/STABILITY.md): what is covered by the versioning policy.
+- [`docs/AUDIT-2026-10.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/docs/AUDIT-2026-10.md): the external audit's findings and how each was resolved.
+- [`docs/adr/`](https://github.com/Bombay-Boyz/jsonlogic-hs/tree/main/docs/adr/): recorded decisions and deviations from the engineering standard.
+- [`VISION.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/VISION.md) and [`ENGINEERING_STANDARD.md`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/ENGINEERING_STANDARD.md): the design and the rules this codebase follows.
+
+## Licence
+
+MIT. See [`LICENSE`](https://github.com/Bombay-Boyz/jsonlogic-hs/blob/main/LICENSE). The vendored test suites keep their own
+licences: `test/community/` is Apache-2.0 (its `LICENSE` is included);
+`test/tests.json` is the shared classic suite from jsonlogic.com.
diff --git a/bench/Bench.hs b/bench/Bench.hs
new file mode 100644
--- /dev/null
+++ b/bench/Bench.hs
@@ -0,0 +1,131 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- |
+-- Benchmarks (Standard 5.6): statistically sound timing and allocation of
+-- the engine, by tasty-bench.
+--
+-- Four groups, so each cost is read on its own:
+--
+-- * @one-shot@: 'apply', which reads the rule and evaluates it, the cost of
+--   a rule used once.
+-- * @compile only@: reading a rule, with no evaluation.
+-- * @precompiled run@: 'run' of a rule read beforehand, the cost of a rule
+--   used many times. One-shot minus this is the reading cost.
+-- * @limits@: a rule that stops at a resource limit.
+--
+-- Large inputs live in the /data/, built once, and every rule or datum
+-- depends on the argument tasty-bench passes in, so GHC cannot lift a
+-- benchmark's work out of the timing loop and run it only once.
+--
+-- > cabal run -O2 jsonlogic-hs-bench -- --csv results.csv
+-- > cabal run -O2 jsonlogic-hs-bench -- --baseline old.csv --fail-if-slower 15
+--
+-- 'scripts/bench.sh' and 'scripts/bench-compare.sh' wrap both, and record
+-- the environment next to the numbers. Timings are comparable only between
+-- runs on one machine; see bench/README.md.
+module Main (main) where
+
+import Data.Aeson (Value (..), encode, object, toJSON, (.=))
+import qualified Data.Aeson.Key as Key
+import qualified Data.ByteString.Lazy as LBS
+import Data.Either (fromRight)
+import qualified Data.Text as Text
+import Test.Tasty.Bench (Benchmark, bench, bgroup, defaultMain, whnf)
+
+import JsonLogic
+
+-- | The size of the encoded answer: forces the whole result, and gives the
+-- benchmark an 'Int' to hold on to.
+size :: Either Error Value -> Int
+size = fromIntegral . LBS.length . encode . fromRight Null
+
+num :: Int -> Value
+num = toJSON
+
+stringOf :: Int -> Value
+stringOf = String . Text.pack . show
+
+var :: Text.Text -> Value
+var path = object ["var" .= path]
+
+call :: Text.Text -> [Value] -> Value
+call operator operands = object [Key.fromText operator .= operands]
+
+-- | 10,000 numbers, the size the iteration benchmarks run over.
+bigNumbers :: Value
+bigNumbers = toJSON [1 .. 10000 :: Int]
+
+-- | The data every workload is evaluated against. Parts a rule does not
+-- touch are never converted, so a small rule pays nothing for the big array.
+dataFor :: Int -> Value
+dataFor n =
+  object
+    [ "x" .= n
+    , "xs" .= bigNumbers
+    , "a" .= object ["b" .= object ["c" .= object ["d" .= n]]]
+    ]
+
+-- | One custom operator, to price the extension point.
+customOps :: Operators
+customOps = fromRight noOperators (operatorsFromList [(OperatorName "double", doubling)])
+  where
+    doubling _ [Number k] = Right (Number (k * 2))
+    doubling _ _ = Left "expected one number"
+
+-- ---------------------------------------------------------------------
+-- Workloads: each is a rule built from the benchmark's argument
+-- ---------------------------------------------------------------------
+
+workloads :: [(String, Int -> Value)]
+workloads =
+  [ ("arithmetic: x + n", \n -> call "+" [var "x", num n])
+  , ("short-circuit: and, false first, 1,000 operands", \n -> call "and" (Bool False : num n : replicate 1000 (var "x")))
+  , ("nested if, depth 60", \n -> foldr (\_ inner -> call "if" [Bool True, inner, num 0]) (num n) [1 .. 60 :: Int])
+  , ("var path a.b.c.d", \_ -> var "a.b.c.d")
+  , ("var 50 lookups to the end of a 10,000-element array", \n -> call "+" (num n : replicate 50 (var "xs.9999")))
+  , ("map over 10,000 elements", \n -> call "map" [var "xs", call "*" [var "", num n]])
+  , ("filter over 10,000 elements", \n -> call "filter" [var "xs", call ">" [var "", num (n + 5000)]])
+  , ("reduce (sum) over 10,000 elements", \n -> call "reduce" [var "xs", call "+" [var "current", var "accumulator"], num n])
+  , ("cat of 1,000 short strings", call "cat" . replicate 1000 . stringOf)
+  , ("number printing via cat, 1,000 doubles", \n -> call "cat" (map (\i -> toJSON (fromIntegral (i + n) / 7 :: Double)) [1 .. 1000 :: Int]))
+  , ("string to number, 200-digit literal", \n -> call "+" [String (Text.pack (show n) <> Text.replicate 200 "7")])
+  , ("custom operator, composed twice", \n -> call "double" [call "double" [num n]])
+  ]
+
+-- ---------------------------------------------------------------------
+-- The four groups
+-- ---------------------------------------------------------------------
+
+oneShot :: (String, Int -> Value) -> Benchmark
+oneShot (name, rule) =
+  bench name (whnf (\n -> size (applyWith customOps (rule n) (dataFor n))) 1)
+
+verdict :: Either Error Rule -> Int
+verdict = either (const 0) (const 1)
+
+compileOnly :: (String, Int -> Value) -> Benchmark
+compileOnly (name, rule) =
+  bench name (whnf (verdict . compile defaultConfig customOps . rule) 1)
+
+-- | The rule is read once, outside the timing loop; only 'run' is timed.
+precompiledRun :: (String, Int -> Value) -> Benchmark
+precompiledRun (name, rule) =
+  bench name (whnf (\n -> maybe 0 (\r -> size (run r (dataFor n))) compiled) 1)
+  where
+    compiled = either (const Nothing) Just (compile defaultConfig customOps (rule 0))
+
+-- | 110,000 operands against a limit of 100,000 steps: the cost of being
+-- refused.
+limitHit :: Benchmark
+limitHit =
+  bench "step limit reached, 110,000 operands" $
+    whnf (\n -> size (applyWithConfig defaultConfig{maxSteps = 100000} customOps (call "+" (num n : replicate 110000 (num 1))) Null)) 1
+
+main :: IO ()
+main =
+  defaultMain
+    [ bgroup "one-shot" (map oneShot workloads)
+    , bgroup "compile only" (map compileOnly workloads)
+    , bgroup "precompiled run" (map precompiledRun workloads)
+    , bgroup "limits" [limitHit]
+    ]
diff --git a/community/Community.hs b/community/Community.hs
new file mode 100644
--- /dev/null
+++ b/community/Community.hs
@@ -0,0 +1,210 @@
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+-- |
+-- Scores jsonlogic-hs on the community test suite (@test/community/@, from
+-- json-logic/compat-tables) and holds the score to @expected.json@.
+--
+-- Each case is scored the way the compatibility tables score an
+-- implementation (compat-tables, @js-tests/engines/TestRunner.js@):
+--
+--   * an expected @null@ accepts any falsy result (@null@, @false@, @0@,
+--     @""@, @[]@);
+--   * numbers are equal within 1e-10;
+--   * arrays and objects are compared element by element and key by key;
+--   * an expected error passes only if evaluation fails with an error of
+--     that type. Errors are named as the suite names them: an unknown
+--     operator is @"Unknown Operator"@, a wrong argument count or an operand
+--     the operator cannot use is @"Invalid Arguments"@. This library has no
+--     error for @NaN@, which the suite's dialect raises and the classic one
+--     does not.
+--
+-- Every case runs in both arity modes. The run fails if any file's case
+-- count or pass counts differ from @test/community/expected.json@, in either
+-- direction, so the score changes only in a commit that says so. It also
+-- fails if a suite file is missing, has no cases, or is not listed in
+-- @expected.json@, so it cannot pass by running nothing.
+--
+-- > cabal test jsonlogic-hs-community --test-show-details=direct
+-- > cabal run jsonlogic-hs-community -- --write     # record a new score
+module Main (main) where
+
+import Control.Monad (forM, unless, when)
+import Data.Aeson (Value (..), eitherDecodeFileStrict, encode, object, (.=))
+import qualified Data.Aeson.Key as Key
+import qualified Data.Aeson.KeyMap as KeyMap
+import qualified Data.ByteString.Lazy.Char8 as Char8
+import Data.Foldable (toList)
+import Data.List (intercalate)
+import Data.Maybe (fromMaybe)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import qualified Data.Vector as Vector
+import System.Directory (doesFileExist)
+import System.Environment (getArgs)
+import System.Exit (exitFailure)
+import System.IO (hPutStrLn, stderr)
+import Text.Printf (printf)
+
+import JsonLogic
+
+suiteDirectory :: FilePath
+suiteDirectory = "test/community/"
+
+expectedPath :: FilePath
+expectedPath = suiteDirectory ++ "expected.json"
+
+-- | One case: rule, data, and what the suite expects.
+data Case = Case Value Value Expectation
+
+data Expectation = ExpectResult Value | ExpectError Text
+
+-- | A file's case count, and how many pass in strict and lenient mode.
+data Score = Score {scoreCases :: Int, scoreStrict :: Int, scoreLenient :: Int}
+  deriving (Eq, Show)
+
+main :: IO ()
+main = do
+  args <- getArgs
+  files <- readJson (suiteDirectory ++ "index.json") >>= stringList "index.json"
+  when (null files) $ abort "index.json lists no files"
+  scores <- forM files $ \file -> do
+    cases <- readJson (suiteDirectory ++ Text.unpack file) >>= suiteCases file
+    when (null cases) $ abort (Text.unpack file ++ " has no cases")
+    pure (file, score cases)
+  report scores
+  case args of
+    ["--write"] -> do
+      writeFile expectedPath (renderExpected scores)
+      putStrLn ("wrote " ++ expectedPath)
+    [] -> compareWithExpected scores
+    _ -> abort "usage: jsonlogic-hs-community [--write]"
+
+-- ---------------------------------------------------------------------
+-- Reading the suite
+-- ---------------------------------------------------------------------
+
+readJson :: FilePath -> IO Value
+readJson path = do
+  exists <- doesFileExist path
+  unless exists $ abort ("required fixture " ++ path ++ " not found (run from the package root)")
+  decoded <- eitherDecodeFileStrict path
+  either (\reason -> abort ("cannot read " ++ path ++ ": " ++ reason)) pure decoded
+
+stringList :: String -> Value -> IO [Text]
+stringList what (Array items) = forM (toList items) $ \case
+  String s -> pure s
+  _ -> abort (what ++ ": expected a list of file names")
+stringList what _ = abort (what ++ ": expected a list of file names")
+
+-- | The cases of a suite file. Strings are section comments; anything else
+-- that is not a case object is an error.
+suiteCases :: Text -> Value -> IO [Case]
+suiteCases file (Array items) = concat <$> mapM entry (toList items)
+  where
+    entry (String _) = pure []
+    entry (Object fields) = case (KeyMap.lookup "rule" fields, KeyMap.lookup "result" fields, KeyMap.lookup "error" fields) of
+      (Just rule, Just result, Nothing) -> pure [Case rule (dataOf fields) (ExpectResult result)]
+      (Just rule, Nothing, Just (Object e)) | Just (String kind) <- KeyMap.lookup "type" e -> pure [Case rule (dataOf fields) (ExpectError kind)]
+      _ -> abort (Text.unpack file ++ ": a case needs a rule and exactly one of result and error")
+    entry _ = abort (Text.unpack file ++ ": an entry is neither a case nor a comment")
+    dataOf fields = fromMaybe Null (KeyMap.lookup "data" fields)
+suiteCases file _ = abort (Text.unpack file ++ " is not a JSON array")
+
+-- ---------------------------------------------------------------------
+-- Scoring
+-- ---------------------------------------------------------------------
+
+score :: [Case] -> Score
+score cases =
+  Score
+    (length cases)
+    (length (filter (passes defaultConfig) cases))
+    (length (filter (passes defaultConfig{arityMode = Lenient}) cases))
+
+passes :: Config -> Case -> Bool
+passes config (Case rule input expectation) =
+  case (applyWithConfig config noOperators rule input, expectation) of
+    (Right got, ExpectResult wanted) -> sameValue got wanted
+    (Left failure, ExpectError kind) -> Text.toLower kind `Text.isInfixOf` Text.toLower (errorType failure)
+    _ -> False
+
+-- | The suite's name for a kind of failure.
+errorType :: Error -> Text
+errorType failure = case failure of
+  ParseFailure _ (ArityMismatch{}) -> "Invalid Arguments"
+  ParseFailure _ (DepthExceeded _) -> "Limit Exceeded"
+  EvalFailure _ (UnknownOperator _) -> "Unknown Operator"
+  EvalFailure _ (InvalidOperand _) -> "Invalid Arguments"
+  EvalFailure _ (CustomOperatorFailed _ reason) -> reason
+  EvalFailure _ (LimitExceeded _) -> "Limit Exceeded"
+
+-- | compat-tables' @compareValues@.
+sameValue :: Value -> Value -> Bool
+sameValue got Null = falsy got
+sameValue (Number a) (Number b) = abs (realToFrac a - realToFrac b :: Double) < 1e-10
+sameValue (Array a) (Array b) = Vector.length a == Vector.length b && and (Vector.zipWith sameValue a b)
+sameValue (Object a) (Object b) =
+  KeyMap.size a == KeyMap.size b
+    && and [maybe False (sameValue v) (KeyMap.lookup k b) | (k, v) <- KeyMap.toList a]
+sameValue got wanted = got == wanted
+
+falsy :: Value -> Bool
+falsy Null = True
+falsy (Bool False) = True
+falsy (Number 0) = True
+falsy (String "") = True
+falsy (Array items) = Vector.null items
+falsy _ = False
+
+-- ---------------------------------------------------------------------
+-- Reporting and pinning
+-- ---------------------------------------------------------------------
+
+report :: [(Text, Score)] -> IO ()
+report scores = do
+  printf "%-36s %6s %8s %8s\n" ("suite" :: String) ("cases" :: String) ("strict" :: String) ("lenient" :: String)
+  mapM_ (\(file, Score n s l) -> printf "%-36s %6d %8d %8d\n" (Text.unpack file) n s l) scores
+  let Score n s l = foldr (\(_, Score a b c) (Score x y z) -> Score (a + x) (b + y) (c + z)) (Score 0 0 0) scores
+  printf "%-36s %6d %8d %8d\n" ("total" :: String) n s l
+
+-- | @expected.json@: one line per file, in the suite's order, so a change
+-- to the score is a readable one-line diff.
+renderExpected :: [(Text, Score)] -> String
+renderExpected scores = "{\n" ++ intercalate ",\n" (map line scores) ++ "\n}\n"
+  where
+    line (file, Score n s l) =
+      "  " ++ Char8.unpack (encode (String file)) ++ ": " ++ Char8.unpack (encode (object ["cases" .= n, "strict" .= s, "lenient" .= l]))
+
+compareWithExpected :: [(Text, Score)] -> IO ()
+compareWithExpected scores = do
+  expected <- readJson expectedPath
+  pinned <- case expected of
+    Object fields -> forM (KeyMap.toList fields) $ \(k, v) -> do
+      pin <- parseScore (Key.toText k) v
+      pure (Key.toText k, pin)
+    _ -> abort (expectedPath ++ " is not an object")
+  let differences =
+        [ Text.unpack file ++ ": expected " ++ show want ++ ", got " ++ maybe "nothing (not in the suite)" show got
+        | (file, want) <- pinned
+        , let got = lookup file scores
+        , got /= Just want
+        ]
+          ++ [ Text.unpack file ++ ": not in " ++ expectedPath
+             | (file, _) <- scores
+             , file `notElem` map fst pinned
+             ]
+  unless (null differences) $ do
+    mapM_ (hPutStrLn stderr . ("community: " ++)) differences
+    abort "the score differs from expected.json; if the change is intended, run with --write and commit the result"
+  putStrLn "community: the score matches expected.json"
+  where
+    parseScore _ (Object o)
+      | Just (Number n) <- KeyMap.lookup "cases" o
+      , Just (Number s) <- KeyMap.lookup "strict" o
+      , Just (Number l) <- KeyMap.lookup "lenient" o =
+          pure (Score (round n) (round s) (round l))
+    parseScore file _ = abort (expectedPath ++ ": bad entry for " ++ Text.unpack file)
+
+abort :: String -> IO a
+abort message = hPutStrLn stderr ("community: " ++ message) >> exitFailure
diff --git a/conformance/Conformance.hs b/conformance/Conformance.hs
new file mode 100644
--- /dev/null
+++ b/conformance/Conformance.hs
@@ -0,0 +1,107 @@
+-- |
+-- Conformance runner for the classic JsonLogic test suite
+-- (@test/tests.json@, from jsonlogic.com/tests.json).
+--
+-- The suite is a JSON array. Each case is a three-element array
+-- @[rule, data, expected]@; bare strings are section comments. Every
+-- case is run through 'apply' and its result compared with @expected@.
+--
+-- This runner cannot pass by running nothing. It exits non-zero if:
+--
+--   * the fixture is missing, unreadable, or not a JSON array;
+--   * any entry is neither a three-element case nor a string comment;
+--   * the number of cases is not exactly 'pinnedCases'; or
+--   * any case fails.
+--
+-- The classic suite passes in full today. Should a change make a case
+-- fail, it is either a bug to fix or a deliberate deviation to record
+-- in docs/COMPATIBILITY.md: a human call, not a threshold. If the suite is
+-- ever updated, change 'pinnedCases' in the same commit.
+module Main (main) where
+
+import Control.Monad (unless)
+import Data.Aeson (Value (..), eitherDecodeFileStrict, encode)
+import qualified Data.ByteString.Lazy.Char8 as Char8
+import Data.Foldable (for_, toList)
+import System.Directory (doesFileExist)
+import System.Exit (exitFailure)
+import System.IO (hPutStrLn, stderr)
+
+import JsonLogic (apply)
+
+suitePath :: FilePath
+suitePath = "test/tests.json"
+
+-- | The exact number of cases in the vendored suite.
+pinnedCases :: Int
+pinnedCases = 278
+
+main :: IO ()
+main = do
+  exists <- doesFileExist suitePath
+  unless exists $
+    abort
+      ( "required fixture "
+          ++ suitePath
+          ++ " not found (run from the package root).\n"
+          ++ "  The classic suite is vendored in the repository; restore it with:\n"
+          ++ "  git checkout -- "
+          ++ suitePath
+      )
+  decoded <- eitherDecodeFileStrict suitePath
+  case decoded of
+    Left reason -> abort ("cannot read " ++ suitePath ++ ": " ++ reason)
+    Right (Array entries) -> report (toList entries)
+    Right _ -> abort (suitePath ++ " is not a JSON array")
+
+abort :: String -> IO a
+abort message = hPutStrLn stderr ("conformance: " ++ message) >> exitFailure
+
+-- | A case, a comment, or neither.
+data Entry = Case Value Value Value | Comment | Malformed Value
+
+classify :: Value -> Entry
+classify (Array parts) = case toList parts of
+  [rule, input, expected] -> Case rule input expected
+  _ -> Malformed (Array parts)
+classify (String _) = Comment
+classify other = Malformed other
+
+report :: [Value] -> IO ()
+report entries = do
+  let classified = map classify entries
+      cases = [(rule, input, expected) | Case rule input expected <- classified]
+      malformed = [v | Malformed v <- classified]
+      failures =
+        [ ((rule, input, expected), outcome)
+        | (rule, input, expected) <- cases
+        , let outcome = apply rule input
+        , outcome /= Right expected
+        ]
+  for_ malformed $ \v ->
+    hPutStrLn stderr ("conformance: entry is neither a case nor a comment: " ++ render v)
+  for_ failures $ \((rule, input, expected), outcome) -> do
+    putStrLn ("FAIL  " ++ render rule ++ "  on  " ++ render input)
+    putStrLn ("      expected " ++ render expected)
+    putStrLn ("      got      " ++ either show render outcome)
+  putStrLn
+    ( show (length cases - length failures)
+        ++ " of "
+        ++ show (length cases)
+        ++ " cases pass; "
+        ++ show (length entries - length cases - length malformed)
+        ++ " comments skipped."
+    )
+  unless (length cases == pinnedCases) $
+    abort
+      ( "expected exactly "
+          ++ show pinnedCases
+          ++ " cases but found "
+          ++ show (length cases)
+          ++ ": the fixture is truncated or changed"
+      )
+  unless (null malformed) exitFailure
+  unless (null failures) exitFailure
+
+render :: Value -> String
+render = Char8.unpack . encode
diff --git a/differential/Differential.hs b/differential/Differential.hs
new file mode 100644
--- /dev/null
+++ b/differential/Differential.hs
@@ -0,0 +1,642 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- |
+-- Differential test: run randomly generated rules through this library and
+-- through the real JavaScript implementation (json-logic-js, via Node), and
+-- report every disagreement.
+--
+-- The classic suite checks the reference's own examples. This checks
+-- everything else: it generates thousands of rules over every builtin
+-- operator, fed deliberately awkward values (@"0x10"@, @" 12 "@, @"1e3"@,
+-- @null@, empty strings), and compares the two answers.
+--
+-- Usage:
+--
+-- > cabal run jsonlogic-hs-differential -- COUNT SEED
+-- > cabal run jsonlogic-hs-differential -- --replay FILE
+--
+-- The seed is always printed first, so any run can be repeated. A failing
+-- case is shrunk against the reference to a smaller rule that still
+-- disagrees, and written to a file that @--replay@ re-checks.
+--
+-- Cases are compared in lenient arity mode, which is the mode that models
+-- the reference (docs/COMPATIBILITY.md); in strict mode the library must
+-- give the same answer unless it rejects the arity. Equality between two
+-- values that are arrays or objects is avoided in generated rules: the
+-- reference compares them by reference identity, which parsed JSON does
+-- not have.
+--
+-- An error is compared with an error only when both are of the same
+-- class (unknown operator, invalid operand, empty @*@). A reference error
+-- of any other kind fails the run, so a new kind of failure is noticed
+-- rather than silently accepted.
+module Main (main) where
+
+import Control.Monad (unless, when, (>=>))
+import Data.Aeson (Value (..), decode, encode, object, toJSON, (.=))
+import Data.Aeson.Key (Key)
+import qualified Data.Aeson.KeyMap as KeyMap
+import qualified Data.ByteString.Lazy as LBS
+import qualified Data.ByteString.Lazy.Char8 as Char8
+import Data.List (isInfixOf)
+import Data.Maybe (fromMaybe, mapMaybe)
+import Data.Scientific (Scientific)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import System.Directory (createDirectoryIfMissing)
+import System.Environment (getArgs)
+import System.Exit (exitFailure)
+import System.IO (hSetEncoding, stderr, stdout, utf8)
+import System.Process (callProcess)
+import Test.QuickCheck (Gen, choose, elements, frequency, vectorOf)
+import Test.QuickCheck.Gen (unGen)
+import Test.QuickCheck.Random (mkQCGen)
+
+import JsonLogic
+
+-- ---------------------------------------------------------------------
+-- Generators
+-- ---------------------------------------------------------------------
+
+-- | Where a rule sits: at the top, inside a @map@-style operator (where
+-- @{"var": ""}@ is the current element), or inside a @reduce@ (where
+-- @current@ and @accumulator@ exist).
+data Ctx = Top | Iter | Fold
+
+trickyStrings :: [Text]
+trickyStrings =
+  [ ""
+  , "0"
+  , "1"
+  , "2"
+  , "a"
+  , "b"
+  , "abc"
+  , "jsonlogic"
+  , " 12 "
+  , "1e3"
+  , "0x10"
+  , "-3"
+  , ".5"
+  , "5."
+  , "Infinity"
+  , "-Infinity"
+  , "NaN"
+  , "10"
+  , "9"
+  , "true"
+  , "null"
+  , "1,2"
+  , "[object Object]"
+  , "a.b"
+  , "\x00a0\&7"
+  , "\xfeff\&3"
+  , "\x2028 4 \x2029"
+  , "\x0085\&5"
+  , "\x200b\&6"
+  , "0x1F"
+  , "0b101"
+  , "1e1000"
+  , "\x1F600"
+  , "a\x1F600\&b"
+  , "\xE000"
+  , "e\x0301"
+  , "\x65E5\x672C"
+  , "9007199254740993"
+  ]
+
+-- | Characters either side of the UTF-16 surrogate range, where ordering
+-- by code unit and by code point part ways.
+unicodeEdges :: [Text]
+unicodeEdges = ["\x1F600", "\xE000", "\xFFFF", "\x10000", "\xD7FF", "a", "", "\x1F600\&a", "\x10FFFF"]
+
+-- | Each ECMAScript whitespace character, and some that look like it,
+-- followed by a digit.
+whitespaceEdges :: [Text]
+whitespaceEdges =
+  [ Text.pack [c, '5']
+  | c <-
+      [ '\t'
+      , '\n'
+      , '\v'
+      , '\f'
+      , '\r'
+      , ' '
+      , '\xA0'
+      , '\x1680'
+      , '\x2000'
+      , '\x2005'
+      , '\x200A'
+      , '\x2028'
+      , '\x2029'
+      , '\x202F'
+      , '\x205F'
+      , '\x3000'
+      , '\xFEFF'
+      , '\x85'
+      , '\x180E'
+      , '\x200B'
+      ]
+  ]
+
+scalarPaths :: [Text]
+scalarPaths =
+  ["a", "b", "c", "o.x", "o.y", "l.0", "l.1", "l.2", "l.length", "z", "a.b", "o.z"]
+
+-- | A scalar chosen to provoke JavaScript's coercion rules.
+genScalar :: Gen Value
+genScalar =
+  frequency
+    [ (1, pure Null)
+    , (2, Bool <$> elements [True, False])
+    , (4, toJSON <$> (choose (-6, 12) :: Gen Int))
+    , (2, toJSON <$> elements decimals)
+    , (5, String <$> elements trickyStrings)
+    , (1, elements extremes)
+    ]
+  where
+    decimals :: [Double]
+    decimals = [0.5, 1.5, -2.25, 100, 1e-7, 1e21]
+    extremes =
+      [ Number 5e-324
+      , Number 1.7976931348623157e308
+      , Number 2.2250738585072014e-308
+      , Number 9007199254740993
+      , Number 1e309
+      , Number 123456789012345678901234567890
+      ]
+
+genArrayOf :: Gen Value -> Gen Value
+genArrayOf element = do
+  n <- choose (0, 3)
+  toJSON <$> vectorOf n element
+
+call :: Key -> [Value] -> Value
+call op args = object [op .= args]
+
+genPath :: Ctx -> Gen Value
+genPath ctx =
+  frequency
+    ( [ (6, String <$> elements scalarPaths)
+      , (1, toJSON <$> (choose (0, 2) :: Gen Int))
+      ]
+        ++ extra
+    )
+  where
+    extra = case ctx of
+      Top -> []
+      Iter -> [(8, pure (String ""))]
+      Fold -> [(6, pure (String "current")), (6, pure (String "accumulator"))]
+
+genVar :: Ctx -> Gen Value
+genVar ctx = do
+  path <- genPath ctx
+  withFallback <- elements [True, False, False]
+  if withFallback
+    then do
+      fallback <- genScalar
+      pure (call "var" [path, fallback])
+    else pure (call "var" [path])
+
+genLeaf :: Ctx -> Gen Value
+genLeaf ctx = frequency [(4, genScalar), (4, genVar ctx), (1, genArrayOf genScalar), (1, elements computed)]
+
+-- | Values JSON cannot write (-0, NaN, the infinities, undefined), produced
+-- by a rule instead. They belong in rules only: put in the data they would
+-- be objects, which the reference compares by identity.
+computed :: [Value]
+computed =
+  [ object ["*" .= [toJSON (-1 :: Int), toJSON (0 :: Int)]]
+  , object ["+" .= [String "a"]]
+  , object ["/" .= [toJSON (1 :: Int), toJSON (0 :: Int)]]
+  , object ["/" .= [toJSON (-1 :: Int), toJSON (0 :: Int)]]
+  , object ["and" .= ([] :: [Int])]
+  ]
+
+genRule :: Ctx -> Int -> Gen Value
+genRule ctx depth
+  | depth <= 0 = genLeaf ctx
+  | otherwise =
+      frequency
+        [ (3, genLeaf ctx)
+        , (2, genLogic ctx depth)
+        , (3, genCompare ctx depth)
+        , (3, genArith ctx depth)
+        , (2, genText ctx depth)
+        , (2, genScoped ctx depth)
+        , (1, genReduce ctx depth)
+        , (1, genMerge ctx depth)
+        , (1, genMissing)
+        , (2, genIf ctx depth)
+        , (1, genArrayRule ctx depth)
+        , (2, genStringFocus)
+        ]
+
+-- | Between @low@ and @high@ sub-rules.
+genArgs :: Ctx -> Int -> Int -> Int -> Gen [Value]
+genArgs ctx depth low high = do
+  n <- choose (low, high)
+  vectorOf n (genRule ctx (depth - 1))
+
+-- | Operators applied straight to awkward strings, which is where
+-- JavaScript's coercion and ordering rules bite hardest.
+genStringFocus :: Gen Value
+genStringFocus = do
+  pool <- elements [trickyStrings, unicodeEdges, whitespaceEdges]
+  a <- String <$> elements pool
+  b <- String <$> elements pool
+  frequency
+    [ (3, (\op -> call op [a, b]) <$> elements ["<", "<=", ">", ">=", "==", "!=", "===", "in", "cat", "max", "min"])
+    , (2, (\op -> call op [a]) <$> elements ["+", "-", "!", "!!", "max", "min", "*"])
+    , (2, (\op -> call op [a, toJSON (1 :: Int)]) <$> elements ["+", "*", "/", "%", "-", "<", "=="])
+    ]
+
+genLogic :: Ctx -> Int -> Gen Value
+genLogic ctx depth =
+  frequency
+    [ (3, call <$> elements ["and", "or"] <*> genArgs ctx depth 0 3)
+    , (2, genUnary ctx depth)
+    ]
+
+-- | @!@ or @!!@, written either with an argument list or, when the
+-- argument is not itself an array, in the bare-value shorthand.
+genUnary :: Ctx -> Int -> Gen Value
+genUnary ctx depth = do
+  op <- elements ["!", "!!"]
+  x <- genRule ctx (depth - 1)
+  shorthand <- elements [True, False]
+  pure $ case x of
+    Array _ -> call op [x]
+    _
+      | shorthand -> object [op .= x]
+      | otherwise -> call op [x]
+
+genCompare :: Ctx -> Int -> Gen Value
+genCompare ctx depth =
+  frequency
+    [ (4, call <$> elements ["==", "===", "!=", "!=="] <*> genArgs ctx depth 2 2)
+    , (3, call <$> elements ["<", "<="] <*> genArgs ctx depth 2 3)
+    , (3, call <$> elements [">", ">="] <*> genArgs ctx depth 2 2)
+    ]
+
+genArith :: Ctx -> Int -> Gen Value
+genArith ctx depth =
+  frequency
+    [ (3, call "+" <$> genArgs ctx depth 0 3)
+    , -- zero operands too: the reference throws on an empty @*@ only when
+      -- it is evaluated, so one in an untaken branch must not fail the rule
+      (2, call "*" <$> genArgs ctx depth 0 3)
+    , (2, call "-" <$> genArgs ctx depth 1 2)
+    , (2, call <$> elements ["/", "%"] <*> genArgs ctx depth 2 2)
+    , (2, call <$> elements ["max", "min"] <*> genArgs ctx depth 0 3)
+    ]
+
+genText :: Ctx -> Int -> Gen Value
+genText ctx depth =
+  frequency
+    [ (3, call "cat" <$> genArgs ctx depth 0 3)
+    , (3, genSubstr ctx depth)
+    , (2, genIn ctx depth)
+    ]
+
+genIndex :: Gen Value
+genIndex =
+  frequency
+    [ (4, toJSON <$> (choose (-7, 7) :: Gen Int))
+    , (1, String <$> elements ["1", "-2", "x"])
+    , (1, pure Null)
+    ]
+
+genSubstr :: Ctx -> Int -> Gen Value
+genSubstr ctx depth = do
+  source <- frequency [(2, String <$> elements trickyStrings), (1, genRule ctx (depth - 1))]
+  start <- genIndex
+  withLength <- elements [True, False]
+  if withLength
+    then do
+      len <- genIndex
+      pure (call "substr" [source, start, len])
+    else pure (call "substr" [source, start])
+
+genIn :: Ctx -> Int -> Gen Value
+genIn ctx depth = do
+  needle <- genRule ctx (depth - 1)
+  haystack <-
+    frequency
+      [ (3, String <$> elements trickyStrings)
+      , (3, genArrayOf genScalar)
+      , (2, pure (call "var" [String "l"]))
+      , (1, genRule ctx (depth - 1))
+      ]
+  pure (call "in" [needle, haystack])
+
+genCollectionSource :: Ctx -> Gen Value
+genCollectionSource ctx =
+  frequency
+    [ (4, pure (call "var" [String "l"]))
+    , (3, genArrayOf genScalar)
+    , (1, pure (call "var" [String "o"]))
+    , (1, genVar ctx)
+    ]
+
+genScoped :: Ctx -> Int -> Gen Value
+genScoped ctx depth = do
+  op <- elements ["map", "filter", "all", "none", "some"]
+  collection <- genCollectionSource ctx
+  logic <- genRule Iter (depth - 1)
+  pure (call op [collection, logic])
+
+genReduce :: Ctx -> Int -> Gen Value
+genReduce ctx depth = do
+  collection <- genCollectionSource ctx
+  logic <- genRule Fold (depth - 1)
+  initial <- genScalar
+  pure (call "reduce" [collection, logic, initial])
+
+genMerge :: Ctx -> Int -> Gen Value
+genMerge ctx depth = do
+  n <- choose (0, 3)
+  parts <- vectorOf n (frequency [(3, genArrayOf genScalar), (2, genRule ctx (depth - 1))])
+  pure (call "merge" parts)
+
+genMissing :: Gen Value
+genMissing = do
+  n <- choose (0, 3)
+  keys <- vectorOf n (String <$> elements scalarPaths)
+  need <- choose (0, 3) :: Gen Int
+  elements [call "missing" keys, call "missing_some" [toJSON need, toJSON keys]]
+
+genIf :: Ctx -> Int -> Gen Value
+genIf ctx depth = do
+  op <- elements ["if", "if", "?:"]
+  call op <$> genArgs ctx depth 0 5
+
+-- | An array literal whose elements are themselves rules.
+genArrayRule :: Ctx -> Int -> Gen Value
+genArrayRule ctx depth = do
+  n <- choose (0, 3)
+  toJSON <$> vectorOf n (genRule ctx (depth - 1))
+
+genObjectData :: Gen Value
+genObjectData = do
+  a <- genScalar
+  b <- genScalar
+  c <- genScalar
+  n <- choose (0, 4)
+  items <- vectorOf n genScalar
+  x <- genScalar
+  y <- genScalar
+  present <- vectorOf 5 (elements [True, True, True, True, False])
+  let values = [a, b, c, toJSON items, object ["x" .= x, "y" .= y]]
+      fields = [(key, value) | (key, value, True) <- zip3 ["a", "b", "c", "l", "o"] values present]
+  pure (object [key .= value | (key, value) <- fields])
+
+genData :: Gen Value
+genData =
+  frequency
+    [ (14, genObjectData)
+    , (1, pure Null)
+    , (1, genScalar)
+    , (1, genArrayOf genScalar)
+    ]
+
+genCase :: Gen (Value, Value)
+genCase = do
+  depth <- choose (1, 4)
+  rule <- genRule Top depth
+  input <- genData
+  pure (rule, input)
+
+-- ---------------------------------------------------------------------
+-- Comparison
+-- ---------------------------------------------------------------------
+
+-- | What the reference answered.
+data Reference = ReferenceValue Value | ReferenceError Text
+
+reference :: Value -> Reference
+reference (Object fields) =
+  case (KeyMap.lookup "ok" fields, KeyMap.lookup "error" fields) of
+    (Just value, _) -> ReferenceValue value
+    (_, Just (String message)) -> ReferenceError message
+    _ -> ReferenceError "malformed oracle line"
+reference _ = ReferenceError "malformed oracle line"
+
+-- | The library's answer in both arity modes.
+data Ours = Ours
+  { lenientAnswer :: Either Error Value
+  , strictAnswer :: Either Error Value
+  }
+
+answers :: (Value, Value) -> Ours
+answers (rule, input) =
+  Ours
+    (applyWithConfig defaultConfig{arityMode = Lenient} noOperators rule input)
+    (applyWithConfig defaultConfig noOperators rule input)
+
+-- | Why the answers disagree, if they do.
+judge :: Reference -> Ours -> Maybe String
+judge expected ours = case (judgeOne expected (lenientAnswer ours), strictAnswer ours) of
+  (Just why, _) -> Just ("lenient: " ++ why)
+  (Nothing, Left (ParseFailure _ ArityMismatch{})) -> Nothing
+  (Nothing, strictResult) -> ("strict: " ++) <$> judgeOne expected strictResult
+
+judgeOne :: Reference -> Either Error Value -> Maybe String
+judgeOne (ReferenceValue theirs) (Right ours)
+  | ours == theirs = Nothing
+  | otherwise = Just "different value"
+judgeOne (ReferenceValue _) (Left e) = Just ("the library failed: " ++ Text.unpack (renderError e))
+judgeOne (ReferenceError _) (Right _) = Just "the reference throws but the library answered"
+judgeOne (ReferenceError message) (Left e) = case errorClass e of
+  Just wanted
+    | any (`isInfixOf` Text.unpack message) wanted -> Nothing
+    | otherwise -> Just ("error of a different class: " ++ Text.unpack (renderError e))
+  Nothing -> Just ("the library failed in a way the reference does not: " ++ Text.unpack (renderError e))
+
+-- | The messages the reference gives for each class of library error.
+errorClass :: Error -> Maybe [String]
+errorClass (EvalFailure _ (UnknownOperator _)) = Just ["Unrecognized operation", "apply is not a function"]
+errorClass (EvalFailure _ (InvalidOperand (OperatorName "*"))) = Just ["Reduce of empty array"]
+errorClass (EvalFailure _ (InvalidOperand _)) = Just ["Cannot read properties"]
+errorClass _ = Nothing
+
+json :: Value -> String
+json = Char8.unpack . encode
+
+describe :: Reference -> String
+describe (ReferenceValue value) = json value
+describe (ReferenceError message) = "error: " ++ Text.unpack message
+
+describeOurs :: Either Error Value -> String
+describeOurs = either (\e -> "error: " ++ Text.unpack (renderError e)) json
+
+-- ---------------------------------------------------------------------
+-- Asking the reference, and shrinking
+-- ---------------------------------------------------------------------
+
+-- | Ask the reference about a batch of cases with one Node process.
+askReference :: FilePath -> [(Value, Value)] -> IO [Reference]
+askReference stem cases = do
+  let casesFile = stem ++ ".cases.jsonl"
+      referenceFile = stem ++ ".reference.jsonl"
+  LBS.writeFile
+    casesFile
+    (Char8.unlines [encode (object ["rule" .= rule, "data" .= input]) | (rule, input) <- cases])
+  callProcess "node" ["differential/oracle.js", casesFile, referenceFile]
+  answered <- Char8.lines <$> LBS.readFile referenceFile
+  unless (length answered == length cases) $ do
+    putStrLn
+      ( "differential: the reference gave "
+          ++ show (length answered)
+          ++ " answers for "
+          ++ show (length cases)
+          ++ " cases"
+      )
+    exitFailure
+  pure (map (maybe (ReferenceError "malformed oracle line") reference . decode) answered)
+
+-- | Simpler variants of a case: a rule replaced by one of its operands, an
+-- operand dropped, a literal made simpler, the data emptied.
+shrinks :: (Value, Value) -> [(Value, Value)]
+shrinks (rule, input) =
+  [(r, input) | r <- shrinkValue rule]
+    ++ [(rule, d) | d <- shrinkData input]
+
+shrinkData :: Value -> [Value]
+shrinkData Null = []
+shrinkData (Object fields) =
+  Null : [Object (KeyMap.delete k fields) | k <- KeyMap.keys fields]
+shrinkData _ = [Null]
+
+shrinkValue :: Value -> [Value]
+shrinkValue value = case value of
+  Object fields -> case KeyMap.toList fields of
+    [(key, args)] ->
+      let operands = case args of
+            Array items -> foldr (:) [] items
+            other -> [other]
+       in operands
+            ++ [object [key .= toJSON (dropAt i operands)] | i <- [0 .. length operands - 1]]
+            ++ [object [key .= toJSON (replaceAt i smaller operands)] | (i, o) <- zip [0 ..] operands, smaller <- shrinkValue o]
+    _ -> [Null]
+  Array items ->
+    let elems = foldr (:) [] items
+     in elems ++ [toJSON (dropAt i elems) | i <- [0 .. length elems - 1]]
+  String t | not (Text.null t) -> [String "", String (Text.take 1 t)]
+  Number n | n /= 0 -> [Number 0, Number 1] ++ [Number (n / 2) | isSmall n]
+  Bool True -> [Bool False]
+  Null -> []
+  _ -> [Null]
+  where
+    isSmall :: Scientific -> Bool
+    isSmall n = abs n < 1e15
+
+dropAt :: Int -> [a] -> [a]
+dropAt i xs = take i xs ++ drop (i + 1) xs
+
+replaceAt :: Int -> a -> [a] -> [a]
+replaceAt i x xs = take i xs ++ [x] ++ drop (i + 1) xs
+
+-- | Greedily replace a failing case by a simpler one that still fails.
+shrinkCase :: FilePath -> (Value, Value) -> IO (Value, Value)
+shrinkCase stem = go (60 :: Int)
+  where
+    go 0 current = pure current
+    go fuel current = do
+      let candidates = take 400 (shrinks current)
+      if null candidates
+        then pure current
+        else do
+          expected <- askReference stem candidates
+          let failing = [c | (c, e) <- zip candidates expected, Just _ <- [judge e (answers c)]]
+          case failing of
+            [] -> pure current
+            c : _ -> go (fuel - 1) c
+
+-- ---------------------------------------------------------------------
+-- Running
+-- ---------------------------------------------------------------------
+
+readInt :: Int -> String -> Int
+readInt fallback text = case reads text of
+  [(n, [])] -> n
+  _ -> fallback
+
+outDir :: FilePath
+outDir = "differential/out"
+
+main :: IO ()
+main = do
+  hSetEncoding stdout utf8
+  hSetEncoding stderr utf8
+  args <- getArgs
+  createDirectoryIfMissing True outDir
+  case args of
+    ["--replay", file] -> replay file
+    [c, s] -> randomRun (readInt 2000 c) (readInt 1 s)
+    [c] -> randomRun (readInt 2000 c) 1
+    _ -> randomRun 2000 1
+
+-- | Re-check the cases in a file, one @{"rule":..,"data":..}@ per line.
+replay :: FilePath -> IO ()
+replay file = do
+  contents <- Char8.lines <$> LBS.readFile file
+  let cases = mapMaybe (decode >=> toCase) contents
+  when (null cases) $ putStrLn ("differential: no cases in " ++ file) >> exitFailure
+  expected <- askReference (outDir ++ "/replay") cases
+  let bad = [(c, e, judge e (answers c)) | (c, e) <- zip cases expected]
+      failures = [(c, e, why) | (c, e, Just why) <- bad]
+  mapM_ (reportFailure Nothing) (zip [1 ..] failures)
+  putStrLn (show (length cases - length failures) ++ " of " ++ show (length cases) ++ " replayed cases agree")
+  unless (null failures) exitFailure
+  where
+    toCase (Object o) = (,) <$> KeyMap.lookup "rule" o <*> pure (fromMaybe Null (KeyMap.lookup "data" o))
+    toCase _ = Nothing
+
+randomRun :: Int -> Int -> IO ()
+randomRun count seed = do
+  putStrLn ("differential: seed " ++ show seed ++ ", " ++ show count ++ " cases")
+  let cases = [unGen genCase (mkQCGen (seed * 1000003 + i)) 30 | i <- [1 .. count]]
+  expected <- askReference (outDir ++ "/run") cases
+  let bad = [(i, c, e, why) | (i, c, e) <- zip3 [1 :: Int ..] cases expected, Just why <- [judge e (answers c)]]
+  putStrLn
+    ( show (count - length bad)
+        ++ " of "
+        ++ show count
+        ++ " random cases agree with the reference (seed "
+        ++ show seed
+        ++ ")"
+    )
+  unless (null bad) $ do
+    -- shrink and report the first few; list every index
+    mapM_
+      ( \(i, c, _, _) -> do
+          small <- shrinkCase (outDir ++ "/shrink") c
+          [e] <- askReference (outDir ++ "/shrink1") [small]
+          let why = fromMaybe "no longer disagrees after shrinking" (judge e (answers small))
+          putStrLn ("  original  " ++ json (fst c) ++ "  on  " ++ json (snd c))
+          reportFailure (Just (seed, i)) (i, (small, e, why))
+      )
+      (take 5 [(i, c, e, w) | (i, c, e, w) <- bad])
+    writeFile
+      (outDir ++ "/mismatches.txt")
+      (unlines ["seed " ++ show seed ++ " index " ++ show i ++ ": " ++ why | (i, _, _, why) <- bad])
+    putStrLn (show (length bad) ++ " mismatches; indices are in " ++ outDir ++ "/mismatches.txt")
+    exitFailure
+
+reportFailure :: Maybe (Int, Int) -> (Int, ((Value, Value), Reference, String)) -> IO ()
+reportFailure origin (n, ((rule, input), expected, why)) = do
+  let reproFile = outDir ++ "/repro-" ++ show n ++ ".jsonl"
+  LBS.writeFile reproFile (encode (object ["rule" .= rule, "data" .= input]) <> "\n")
+  let ours = answers (rule, input)
+  mapM_
+    putStrLn
+    [ "MISMATCH #" ++ show n ++ maybe "" (\(s, i) -> " (seed " ++ show s ++ ", case " ++ show i ++ ", shrunk)") origin
+    , "  why       " ++ why
+    , "  rule      " ++ json rule
+    , "  data      " ++ json input
+    , "  lenient   " ++ describeOurs (lenientAnswer ours)
+    , "  strict    " ++ describeOurs (strictAnswer ours)
+    , "  reference " ++ describe expected
+    , "  repeat    cabal run jsonlogic-hs-differential -- --replay " ++ reproFile
+    ]
diff --git a/differential/README.md b/differential/README.md
new file mode 100644
--- /dev/null
+++ b/differential/README.md
@@ -0,0 +1,106 @@
+# Differential testing against the reference
+
+Two tools, both holding this library to the real JavaScript implementation
+([json-logic-js](https://github.com/jwadhams/json-logic-js) **2.0.5**, run
+through Node):
+
+- **`outliers.js`** writes the outlier catalogue: a large, fixed set of
+  deliberately awkward rules and inputs, with the answers the reference
+  gave. The result is committed as `test/outliers.jsonl` and replayed
+  *offline* by `cabal test` (`jsonlogic-hs-outliers`).
+- **`Differential.hs`** generates random rules and compares each answer with
+  the reference live, shrinking any disagreement.
+
+## Setup (once)
+
+```
+cd differential && npm ci
+```
+
+Needs Node.js 18 or newer. `npm ci` installs exactly the locked oracle
+(`2.0.5`); `oracle.js` and `outliers.js` refuse to run against any other
+version, because a different oracle changes what "correct" means. Neither is
+part of `cabal test`, which must work without Node.
+
+## The outlier catalogue
+
+```
+node outliers.js ../test/outliers.jsonl              # write the committed sample (about 27,000 cases)
+OUTLIER_TIER=full node outliers.js /tmp/full.jsonl   # the whole catalogue (about 140,000 cases)
+node outliers.js --check ../test/outliers.jsonl      # fail if the committed file is stale
+cabal run jsonlogic-hs-outliers -- /tmp/full.jsonl   # replay any tier
+```
+
+There is no randomness: the same script always writes the same bytes, and
+the sample is a stable hash-based thinning of the big cross-product families,
+so it only changes when the catalogue does. Regenerate the committed file
+whenever `outliers.js` changes; CI fails if you forget.
+
+Families (see the top of `outliers.js`): every operator at argument counts 0
+to 4 (`arity`); every operator over a rich pool of awkward values (`unary`,
+`pair`, `triple`); string-to-number forms through fifteen contexts
+(`numstr`); number printing (`numfmt`) and shortest-digit ties (`tie`);
+signed zero, NaN and infinity through every operator and observer
+(`signedzero`); UTF-16 length, indexing, `substr`, ordering (`unicode`);
+variable paths on awkward data (`var`); iteration operators over awkward
+collections and bodies (`collection`); control flow and short-circuiting
+(`flow`, including an empty `*` in branches that are and are not taken); a
+full loose/strict equality and ordering matrix over mixed types
+(`coercion`); literals, deep nesting and very wide rules; and every rule of
+the community suite in `test/community/` (`community`). The format is
+documented for implementers in other languages in `docs/CONFORMANCE.md`.
+
+Each line is one case: `{"id","fam","op","n","rule","data","expect"}`, where
+`expect` is `{"ok":value}` or `{"error":message}`. The first line is a
+`meta` line stating how many cases follow, so truncation is detected. A case
+that exercises a documented deviation carries `"dev"`:
+
+- `"inherited"`: the rule looks up a name like `constructor` that JavaScript
+  finds on the prototype chain. `expect_own` is the reference's answer for a
+  path that is certainly absent, which is what this library must return.
+- `"reeval"`: `missing` / `missing_some` was given a key that came from the
+  data and looks like a rule. Counted, not compared.
+
+Both deviations are explained in `docs/COMPATIBILITY.md`.
+
+## The random differential test
+
+```
+cabal run jsonlogic-hs-differential -- COUNT SEED
+cabal run jsonlogic-hs-differential -- --replay FILE
+```
+
+Defaults: 2000 cases, seed 1. The seed is printed first; the same count and
+seed always generate the same cases. CI uses a fresh seed on every run (the
+run number) and a larger count nightly.
+
+A disagreement is shrunk, by asking the reference about simpler variants, to
+a small rule that still disagrees. The shrunk case, the original, both
+answers (lenient and strict) and the reference's are printed, and the shrunk
+case is written to `differential/out/repro-N.jsonl`, which `--replay`
+re-checks. Every mismatch index is listed in `differential/out/mismatches.txt`.
+
+Cases are compared in lenient arity mode, which models the reference, and
+strict mode must give the same answer unless it rejects the arity. The
+generator produces every operator at every operand count it accepts,
+including an empty `*`, which the reference throws on only when it evaluates
+it. Errors are compared by class (unknown operator, invalid operand, empty
+`*`); a
+reference error of any other kind fails the run, so a new kind of failure
+cannot pass unnoticed.
+
+## Reading a mismatch
+
+Generated rules avoid the documented deviations (docs/COMPATIBILITY.md): no
+array or object is put in the data where the reference would compare it by
+identity, and values JSON cannot write (negative zero, NaN, infinities,
+`undefined`) appear only in rules, as computed values. A mismatch is
+therefore either a bug here or a new deviation to decide on and record.
+
+## Checking the checkers
+
+`bash scripts/check-harness.sh` feeds the runners missing, empty, truncated
+and tampered fixtures and demands a failure each time, and checks the
+committed fixture is current. Planting a bug in string ordering or in
+whitespace handling must make both the outlier suite and the differential
+test fail; when changing the harness, do that once by hand.
diff --git a/differential/oracle.js b/differential/oracle.js
new file mode 100644
--- /dev/null
+++ b/differential/oracle.js
@@ -0,0 +1,61 @@
+#!/usr/bin/env node
+// The reference implementation as an oracle.
+//
+// Usage: node oracle.js CASES RESULTS
+//
+// CASES has one JSON object per line: {"rule": ..., "data": ...}.
+// RESULTS gets one line per case, in order: {"ok": value} or
+// {"error": message}. JavaScript `undefined` is written as null, and NaN
+// and the infinities become null through JSON.stringify, which is exactly
+// how jsonlogic-hs renders them.
+
+const fs = require("fs");
+const jsonLogic = require("json-logic-js");
+console.log = () => {}; // the "log" operator would otherwise write to stdout
+
+const [casesFile, resultsFile] = process.argv.slice(2);
+if (!casesFile || !resultsFile) {
+  console.error("usage: node oracle.js CASES RESULTS");
+  process.exit(2);
+}
+
+// The reference is pinned. Anything else is not the oracle this library is
+// held to, so fail instead of comparing against it.
+const REQUIRED = "2.0.5";
+const version = require("json-logic-js/package.json").version;
+if (version !== REQUIRED) {
+  console.error("oracle: json-logic-js " + version + " is installed, but " + REQUIRED + " is required (run `npm ci` in differential/)");
+  process.exit(3);
+}
+console.error("oracle: json-logic-js " + version + ", node " + process.version);
+
+// Lone surrogates cannot be held in a Haskell Text (nor decoded by aeson), so
+// the reference's answer is normalised the way String.prototype.toWellFormed
+// would: each becomes U+FFFD. This is the one documented string deviation.
+function wellFormed(x) {
+  if (typeof x === "string") return typeof x.toWellFormed === "function" ? x.toWellFormed() : x;
+  if (Array.isArray(x)) return x.map(wellFormed);
+  if (x !== null && typeof x === "object") {
+    const o = {};
+    for (const k of Object.keys(x)) {
+      Object.defineProperty(o, wellFormed(k), { value: wellFormed(x[k]), enumerable: true, writable: true, configurable: true });
+    }
+    return o;
+  }
+  return x;
+}
+
+const answers = [];
+for (const line of fs.readFileSync(casesFile, "utf8").split("\n")) {
+  if (line.trim() === "") continue;
+  const { rule, data } = JSON.parse(line);
+  let answer;
+  try {
+    const result = jsonLogic.apply(rule, data);
+    answer = { ok: wellFormed(result === undefined ? null : result) };
+  } catch (error) {
+    answer = { error: String(error && error.message ? error.message : error) };
+  }
+  answers.push(JSON.stringify(answer));
+}
+fs.writeFileSync(resultsFile, answers.join("\n") + "\n");
diff --git a/differential/outliers.js b/differential/outliers.js
new file mode 100644
--- /dev/null
+++ b/differential/outliers.js
@@ -0,0 +1,663 @@
+#!/usr/bin/env node
+// Outlier fixture generator.
+//
+// Builds a large, fully deterministic catalogue of awkward rules and data
+// (no randomness: the same script always writes the same bytes), runs every
+// case through the reference implementation (json-logic-js, pinned in
+// package.json), and records the reference's answer.
+//
+// Usage:  node outliers.js OUTPUT.jsonl        (writes the fixture)
+//         node outliers.js --check FIXTURE     (fails if FIXTURE is stale)
+//
+// The Haskell test-suite `jsonlogic-hs-outliers` replays the fixture
+// offline, so running it needs neither Node nor network access. Regenerate
+// the fixture only when this file changes; CI runs --check so the committed
+// fixture can never drift from the generator.
+//
+// The first line is {"meta":{"cases":N,"oracle":"2.0.5","tier":"core"}}, so a
+// truncated fixture is detected. Every other line is one case:
+//   {"id":"...","fam":"...","op":"...","n":2,"rule":R,"data":D,"expect":E}
+// where E is {"ok":value} or {"error":"message"}. JavaScript `undefined`,
+// NaN and the infinities are written as null (JSON.stringify), and lone
+// surrogates in output are normalised to U+FFFD (toWellFormed), because a
+// Haskell Text cannot hold them. `n` is the number of operands written, so
+// the runner can tell strict-arity rejections from real disagreements.
+
+"use strict";
+const fs = require("fs");
+const jsonLogic = require("json-logic-js");
+
+const REQUIRED_ORACLE = "2.0.5";
+const version = require("json-logic-js/package.json").version;
+if (version !== REQUIRED_ORACLE) {
+  console.error(`oracle is json-logic-js ${version}, need ${REQUIRED_ORACLE}`);
+  process.exit(2);
+}
+
+// ---------------------------------------------------------------------
+// Serialisation that can carry raw JSON tokens (1e309, -0 ...) which
+// JSON.stringify would destroy.
+// ---------------------------------------------------------------------
+const raw = (text) => ({ __raw: text });
+function ser(x) {
+  if (x !== null && typeof x === "object" && "__raw" in x) return x.__raw;
+  if (Array.isArray(x)) return "[" + x.map(ser).join(",") + "]";
+  if (x !== null && typeof x === "object") {
+    return "{" + Object.keys(x).map((k) => JSON.stringify(k) + ":" + ser(x[k])).join(",") + "}";
+  }
+  return JSON.stringify(x);
+}
+const op = (name, ...args) => ({ [name]: args });
+const opRaw = (name, args) => ({ [name]: args });
+
+// ---------------------------------------------------------------------
+// Value pools
+// ---------------------------------------------------------------------
+const NEG_ZERO = op("*", -1, 0);
+const POS_INF = op("/", 1, 0);
+const NEG_INF = op("/", -1, 0);
+const NOT_A_NUMBER = op("+", "a");
+const UNDEF = op("and");
+
+const NUMBERS = [
+  0, 1, -1, 2, 3, 7, 0.5, -0.5, 2.5, -2.5, 3.5, 10, 100, 1e21, 1e-7, 1e-6,
+  123456789012345680000, 5e-324, 2.2250738585072014e-308, 1.7976931348623157e308,
+  raw("1e309"), raw("-1e309"), raw("1e-400"), raw("-1e-400"), raw("1E5"), raw("1.0"), raw("-0.0"),
+  raw("0e0"), raw("100e-2"), raw("9007199254740991"), raw("9007199254740992"),
+  raw("9007199254740993"), raw("9007199254740995"), raw("18014398509481984"),
+  0.1, 0.2, 0.30000000000000004, 4294967296, 4294967295, 2147483648, -2147483649, 1e300,
+  raw("123456789012345678901234567890"), raw("0.000001"), raw("0.0000001"),
+  raw("1e21"), raw("1e22"), raw("999999999999999900000"), raw("1.7976931348623158e308"),
+  raw("2.2250738585072011e-308"), raw("4.9406564584124654e-324"), raw("2e-324"), raw("3e-324"),
+];
+const COMPUTED_NUMBERS = [NEG_ZERO, POS_INF, NEG_INF, NOT_A_NUMBER, op("-", 0)];
+
+// Strings that stress ToNumber / parseFloat.
+const WS = [
+  "\u0009", "\u000a", "\u000b", "\u000c", "\u000d", "\u0020", "\u00a0", "\u1680",
+  "\u2000", "\u2001", "\u2002", "\u2003", "\u2004", "\u2005", "\u2006", "\u2007",
+  "\u2008", "\u2009", "\u200a", "\u2028", "\u2029", "\u202f", "\u205f", "\u3000", "\ufeff",
+];
+const NOT_WS = ["\u0085", "\u180e", "\u200b", "\u001c", "\u001f", "\u0000", "\u200c", "\u2060", "\ufffe"];
+const NUMERIC_STRINGS = [
+  "", " ", "0", "1", "-1", "+1", "00", "01", "-0", "+0", "0.0", "0x10", "0X1F", "0xg", "0x",
+  "0b101", "0B11", "0b2", "0b", "0o17", "0O7", "0o8", "0o", "-0x10", "+0x10", "0x-1", "0x1.8",
+  "1_000", "1e3", "1E3", "1e", "1e+", "1e-", "1e+3", "1e-3", "1e3.5", ".5", "5.", ".", "-.",
+  "+.5e-3", "-.5", ".e1", "1..2", "1.2.3", "1,5", "Infinity", "-Infinity", "+Infinity",
+  "infinity", "INFINITY", "Infinityx", "Infinit", "NaN", "nan", "-NaN", " 12 ", "\t12\n",
+  "12px", "12 13", "1 2", "١٢٣", "１２３", "²", "①", "१२३", "a", "abc", "true", "false", "null",
+  "undefined", "[object Object]", "[1]", "1,2", "1e1000", "-1e1000", "1e-1000", "0e5", "0.0e5",
+  "9007199254740993", "9007199254740992.5", "0.1", "0.30000000000000004",
+  "1.7976931348623157e308", "1.7976931348623159e308", "1.797693134862315807e308",
+  "4.9e-324", "2e-324", "2.4703282292062327e-324", "2.4703282292062328e-324", "5e-324",
+  "2.2250738585072014e-308", "2.225073858507201e-308", "123456789012345678901234567890",
+  "0.000001", "0.0000001", "1e21", "1e-7", "-1e-7", "+-1", "--1", "- 1", "+ 1", "1 ", " 1",
+  "\u00001", "1\u0000", "0.1e1", "00.5", "0001e3", "1e0003", "1e-0003", ".0e0", "-.0",
+  "1".repeat(20), "1".repeat(400), "1".repeat(767), "1".repeat(768), "1".repeat(769),
+  "9".repeat(309), "9".repeat(310), "0." + "0".repeat(323) + "1", "0." + "0".repeat(400) + "1",
+  "1" + "0".repeat(308), "1" + "0".repeat(309),
+  "0." + "0".repeat(20) + "9".repeat(30), "2.4703282292062327" + "0".repeat(300) + "1e-324",
+  "5e-325", "2.5e-324", "1.5e-323",
+];
+for (const w of WS) {
+  NUMERIC_STRINGS.push(w + "12", "12" + w, w + "12" + w, w, w + "0x10", w + "-5" + w);
+}
+for (const w of NOT_WS) {
+  NUMERIC_STRINGS.push(w + "12", "12" + w);
+}
+
+// Strings that stress length / indexing / ordering / substr.
+const UNICODE_STRINGS = [
+  "", "a", "ab", "abc", "z", "A", "é", "e\u0301", "\u0000", "\u0000a", "a\u0000b", "😀", "a😀b",
+  "😀😀", "👨‍👩‍👧", "🇮🇳", "🇮🇳🇺🇸", "👍🏽", "\ue000", "\uffff", "\ufffd", "\ud7ff", "\uf8ff",
+  "\u{10000}", "\u{10ffff}", "\u{1d11e}", "𝒳", "שלום", "ab\u202ec", "\ud83d\ude00", "ǆ", "ß", "İ", "日本語", "\u{20bb7}",
+];
+
+const CONTAINERS = [
+  [], [[]], [0], [1], [null], [[1]], [1, 2], [1, 2, 3], ["a"], [[], []], {}, { a: 1 },
+  { a: {} }, [{}], [true], ["1"], [false], [""], [0, 0], [[], 0], ["a", "b"], [1, "1"],
+  { a: [1, 2] }, [null, null],
+];
+const MISC = [null, true, false, UNDEF];
+
+// A rich pool for unary experiments and a smaller one for pairs / triples.
+const POOL_A = [
+  ...NUMBERS, ...COMPUTED_NUMBERS, ...MISC, ...CONTAINERS,
+  ...[
+    "", " ", "0", "1", "-1", "a", "abc", "0x10", "1e3", " 12 ", "Infinity", "-Infinity", "NaN",
+    ".5", "5.", "true", "null", "undefined", "1,2", "[object Object]", "\ufeff1", "\u2028 1",
+    "\u00a01", "\u00851", "\u180e1", "\u200b1", "😀", "e\u0301", "\u0000", "١٢٣",
+  ],
+];
+const POOL_B = [
+  0, 1, -1, 0.5, 1e21, 1e-7, 5e-324, 1.7976931348623157e308, raw("9007199254740993"),
+  NEG_ZERO, POS_INF, NEG_INF, NOT_A_NUMBER, null, true, false, UNDEF,
+  "", "0", "1", "a", "10", "9", "0x10", " 12 ", "Infinity", "NaN", "😀", "\ue000",
+  [], [0], [1], [1, 2], ["a"], {}, { a: 1 },
+];
+const POOL_C = [0, 1, "a", "", null, UNDEF, [1, 2], NEG_ZERO];
+
+// ---------------------------------------------------------------------
+// Case collection
+// ---------------------------------------------------------------------
+const cases = [];
+const seen = new Set();
+let counter = 0;
+
+function add(fam, opName, n, rule, data) {
+  const ruleText = ser(rule);
+  const dataText = ser(data === undefined ? null : data);
+  const key = ruleText + "\u0001" + dataText;
+  if (seen.has(key)) return;
+  seen.add(key);
+  counter += 1;
+  cases.push({ id: fam + "-" + String(counter).padStart(6, "0"), fam, op: opName, n, ruleText, dataText });
+}
+
+const UNARY_OPS = [
+  "var", "missing", "!", "!!", "-", "+", "*", "max", "min", "cat", "log", "merge", "if", "?:",
+  "and", "or", "==", "===", "!=", "!==", "<", "<=", ">", ">=", "/", "%", "in", "substr", "map",
+  "filter", "reduce", "all", "none", "some", "missing_some",
+];
+const PAIR_OPS = [
+  "==", "===", "!=", "!==", "<", "<=", ">", ">=", "+", "-", "*", "/", "%", "max", "min", "cat",
+  "in", "merge", "substr", "var", "missing", "missing_some", "and", "or", "if", "map", "filter",
+  "all", "none", "some", "reduce",
+];
+const TRIPLE_OPS = ["<", "<=", "substr", "if", "and", "or", "reduce", "cat", "+", "max", "*", "var", "-"];
+// Every builtin operator. It must be the set in src/JsonLogic/Core/Operator.hs:
+// the Haskell runner fails if any builtin there has too few cases here.
+const ALL_OPS = [
+  "var", "missing", "missing_some", "if", "?:", "and", "or", "!", "!!", "==", "===", "!=", "!==",
+  "<", "<=", ">", ">=", "max", "min", "+", "-", "*", "/", "%", "map", "filter", "reduce", "all",
+  "none", "some", "merge", "in", "cat", "substr", "log",
+];
+
+// 1. Every operator at arities 0..4 with simple operands.
+const ARITY_OPERANDS = [1, "a", [1, 2], null];
+for (const name of ALL_OPS) {
+  for (let n = 0; n <= 4; n++) {
+    add("arity", name, n, opRaw(name, ARITY_OPERANDS.slice(0, n)), { a: 5 });
+    add("arity", name, n, opRaw(name, [{ var: "a" }, { var: "a" }, { var: "a" }, { var: "a" }].slice(0, n)), { a: 5 });
+    add("arity", name, n, opRaw(name, [[1, 2, 3], { "+": [{ var: "" }, 1] }, 0, "x"].slice(0, n)), { a: 5 });
+  }
+}
+// bare (non-array) operand shorthand
+for (const name of ALL_OPS) for (const v of [1, "a", null, true, { var: "a" }]) {
+  add("arity", name, 1, { [name]: v }, { a: 5 });
+}
+
+// 2. Unary experiments over the rich pool.
+for (const name of UNARY_OPS) for (const v of POOL_A) add("unary", name, 1, opRaw(name, [v]), null);
+
+// 3. Pair experiments.
+for (const name of PAIR_OPS) {
+  for (const a of POOL_B) for (const b of POOL_B) add("pair", name, 2, opRaw(name, [a, b]), { a: 1, b: [1, 2] });
+}
+
+// 4. Triple experiments.
+for (const name of TRIPLE_OPS) {
+  for (const a of POOL_C) for (const b of POOL_C) for (const c of POOL_C) add("triple", name, 3, opRaw(name, [a, b, c]), null);
+}
+
+// 5. Number-string conversion through many operators (also via data).
+const NUM_CONTEXTS = [
+  (t) => op("+", t), (t) => op("*", t, 1), (t) => op("-", t), (t) => op("-", t, 0),
+  (t) => op("<", t, 1), (t) => op("==", t, 0), (t) => op("/", t, 1), (t) => op("%", t, 7),
+  (t) => op("max", t), (t) => op("min", t, 5), (t) => op("cat", op("+", t)),
+  (t) => op("+", { var: "s" }, t), (t) => op("===", op("*", t, 1), 0),
+  (t) => op("!!", t), (t) => op(">", t, -1),
+];
+for (const s of NUMERIC_STRINGS) {
+  NUM_CONTEXTS.forEach((ctx, i) => add("numstr", "numeric", 1, ctx(s), { s }));
+}
+
+// 6. Number printing through cat and through results.
+const PRINT_NUMBERS = [...NUMBERS, ...COMPUTED_NUMBERS];
+for (const v of PRINT_NUMBERS) {
+  add("numfmt", "cat", 1, op("cat", v), null);
+  add("numfmt", "+", 1, op("+", v), null);
+  add("numfmt", "*", 2, op("*", v, 1), null);
+  add("numfmt", "-", 2, op("-", v, 0), null);
+  add("numfmt", "*", 2, op("*", v, 3), null);
+  add("numfmt", "/", 2, op("/", v, 3), null);
+  add("numfmt", "/", 2, op("/", 3, v), null);
+  add("numfmt", "cat", 2, op("cat", "n=", v), null);
+  add("numfmt", "in", 2, op("in", v, [v]), null);
+}
+for (const [a, b] of [[0.1, 0.2], [0.1, 0.7], [1e21, 1], [9007199254740992, 1], [9007199254740993, 2],
+  [raw("1e308"), raw("1e308")], [5e-324, 5e-324], [5e-324, 0.5], [0.3, -0.1], [1e16, 1], [1.5, 1.5]]) {
+  for (const o of ["+", "-", "*", "/", "%", "max", "min"]) add("numfmt", o, 2, op(o, a, b), null);
+}
+
+// 6b. Shortest-digit ties: doubles exactly halfway between two decimals of
+// the shortest length (ECMAScript picks the even one).
+for (let k = 0n; k < 60n; k++) {
+  const big = String(9007199254740992n + k);
+  for (const divisor of [10, 100, 1000, 8, 3, 7]) add("tie", "/", 2, op("/", raw(big), divisor), null);
+  add("tie", "*", 2, op("*", raw(big), 1.1), null);
+  add("tie", "+", 2, op("+", raw(big), 0.5), null);
+  add("tie", "-", 2, op("-", raw(String(4503599627370496n + k)), 0.5), null);
+  add("tie", "cat", 1, op("cat", op("/", raw(String(18014398509481984n + k * 3n)), 100)), null);
+}
+for (const x of [0.5, 1.5, 2.5, 4503599627370495.5, 4503599627370496.5, 1125899906842624.25, 562949953421312.125]) {
+  for (const d of [1, 10, 100, 3]) add("tie", "/", 2, op("/", x, d), null);
+  add("tie", "cat", 1, op("cat", x), null);
+}
+
+// 7. Signed zero, NaN, infinity: every combination, then observed.
+const SZ = [0, NEG_ZERO, 1, -1, 2, -2, POS_INF, NEG_INF, NOT_A_NUMBER];
+const OBSERVERS = [
+  (x) => x,
+  (x) => op("/", 1, x),
+  (x) => op("/", -1, x),
+  (x) => op(">", 0, op("/", 1, x)),
+  (x) => op("<", 0, op("/", 1, x)),
+  (x) => op("===", op("/", 1, x), op("/", 1, 0)),
+  (x) => op("===", op("/", 1, x), op("/", -1, 0)),
+  (x) => op("cat", x),
+  (x) => op("!", x),
+  (x) => op("+", x),
+];
+for (const name of ["max", "min", "%", "*", "/", "+", "-"]) {
+  for (const a of SZ) for (const b of SZ) {
+    for (const [i, obs] of OBSERVERS.entries()) add("signedzero", name, 2, obs(op(name, a, b)), null);
+  }
+}
+for (const a of SZ) for (const b of SZ) for (const c of SZ) {
+  add("signedzero", "max", 3, op("/", 1, op("max", a, b, c)), null);
+  add("signedzero", "min", 3, op("/", 1, op("min", a, b, c)), null);
+}
+// literal -0 in JSON text is read by JSON.parse as -0; aeson reads it as 0.
+// That input difference is recorded as a deviation, not tested here.
+
+// 8. Unicode: length, indexing, substr, ordering, in, cat.
+const STARTS = [-100, -3, -2, -1, 0, 1, 2, 3, 5, 100, 0.5, 1.9, -1.9, raw("1e21"), NOT_A_NUMBER, null, "1", ""];
+const LENS = [undefined, 0, 1, 2, 3, -1, -2, -3, 100, 0.5, 1.9, -0.5, NOT_A_NUMBER, null, "2", POS_INF, NEG_INF];
+for (const s of UNICODE_STRINGS) {
+  add("unicode", "var", 1, { var: "s.length" }, { s });
+  add("unicode", "var", 1, { var: "s" }, { s });
+  for (let i = 0; i < 7; i++) add("unicode", "var", 1, { var: "s." + i }, { s });
+  add("unicode", "var", 1, { var: "s.7" }, { s });
+  add("unicode", "cat", 2, op("cat", s, s), null);
+  add("unicode", "in", 2, op("in", s, "x" + s + "y"), null);
+  add("unicode", "in", 2, op("in", "", s), null);
+  add("unicode", "!", 1, op("!", s), null);
+  for (const st of STARTS) {
+    add("unicode", "substr", 2, op("substr", s, st), null);
+    for (const ln of LENS) {
+      if (ln === undefined) continue;
+      add("unicode", "substr", 3, op("substr", s, st, ln), null);
+    }
+  }
+}
+for (const a of UNICODE_STRINGS) for (const b of UNICODE_STRINGS) {
+  for (const o of ["<", "<=", ">", ">=", "==", "===", "!=", "!==", "in", "max", "min"]) add("unicode", o, 2, op(o, a, b), null);
+  add("unicode", "<", 3, op("<", a, b, "\uffff"), null);
+}
+for (const s of UNICODE_STRINGS) {
+  add("unicode", "map", 2, op("map", [s], { var: "" }), null);
+  add("unicode", "merge", 2, op("merge", [s], s), null);
+}
+
+// 9. Variable paths on awkward data.
+const PATHS = [
+  "", null, "a", "a.b", "a.0", "a.1", "a.length", ".", "a.", ".a", "..", "a..b", "constructor",
+  "__proto__", "toString", "valueOf", "hasOwnProperty", "length", "0", "1", "2", "01", "-1", "1e1",
+  "4294967296", "4294967295", "4294967294", "1.5", "a.constructor", "a.__proto__", "a.toString",
+  "length.length", "isPrototypeOf", "toLocaleString", "map", "concat", "indexOf", "charAt", "trim", "0.0", "0.length", " ", "a b", "A", "α", "😀", "a.😀", "[0]", "a[0]", "a.b.c.d",
+  "0.a", raw("0"), raw("1"), raw("1.5"), true, false, [], {}, [0], ["a"], ["a", "b"], 0, 1, -1, 2, raw("1e21"),
+];
+const VAR_DATA = [
+  null, {}, [], "", "str", "😀", "ab😀cd", 0, 5, true, false, [1, 2, 3], [[1], [2]],
+  { a: 1 }, { a: null }, { a: "" }, { a: [] }, { a: {} }, { a: [1, { b: 2 }] }, { a: { b: { c: { d: 4 } } } },
+  { "": 1 }, { a: { "": 5 } }, { 1: "x", 0: "y" }, { length: 5 }, { constructor: 1 }, { toString: 2 },
+  { hasOwnProperty: 3 }, { a: "xyz" }, { a: [10, 20] }, { "a.b": 7 }, { ".": 8 }, { "a": { "b": null } },
+  { 0: "z" }, { "01": "z" }, { "-1": "m" }, { "4294967296": "k" }, { "0.0": 1 },
+  { a: 1, b: 2 }, { "😀": 1 }, { "α": 2 },
+];
+VAR_DATA.push(raw('{"__proto__":{"x":1}}'), raw('{"__proto__":1}'));
+for (const d of VAR_DATA) for (const p of PATHS) {
+  add("var", "var", 1, op("var", p), d);
+  add("var", "var", 2, op("var", p, "dflt"), d);
+  add("var", "var", 2, op("var", p, null), d);
+  add("var", "var", 2, op("var", p, UNDEF), d);
+  add("var", "missing", 1, op("missing", p), d);
+  add("var", "missing", 1, opRaw("missing", [[p, "a"]]), d);
+  add("var", "missing_some", 2, op("missing_some", 1, [p, "a"]), d);
+}
+for (const d of VAR_DATA) {
+  add("var", "missing", 0, op("missing"), d);
+  add("var", "missing", 2, op("missing", "a", "b"), d);
+  add("var", "missing", 3, op("missing", "a", "b", "c"), d);
+  add("var", "missing_some", 2, op("missing_some", 0, ["a", "b"]), d);
+  add("var", "missing_some", 2, op("missing_some", 1, ["a", "b"]), d);
+  add("var", "missing_some", 2, op("missing_some", 2, ["a", "b"]), d);
+  add("var", "missing_some", 2, op("missing_some", 3, ["a", "b"]), d);
+  add("var", "missing_some", 2, op("missing_some", "1", ["a", "b"]), d);
+  add("var", "missing_some", 2, op("missing_some", null, ["a", "b"]), d);
+  add("var", "missing_some", 2, op("missing_some", 1, []), d);
+  add("var", "missing_some", 2, op("missing_some", 1, "ab"), d);
+  add("var", "missing_some", 2, op("missing_some", 1, null), d);
+  add("var", "missing_some", 2, op("missing_some", 1, { a: 1 }), d);
+  add("var", "missing", 1, op("missing", op("var", "a")), d);
+  add("var", "missing", 1, op("missing", { merge: ["a", ["b"]] }), d);
+}
+
+// 10. Collections: every iterating operator over awkward collections and bodies.
+const COLLECTIONS = [
+  [], [0], [1], [1, 2, 3], [0, 0], [null], [[1], [2]], ["a", "b"], [1, "1", true, null], [{}], [{ a: 1 }, { a: 2 }],
+  [[]], "str", "", null, 0, 5, true, {}, { a: 1 }, UNDEF, NOT_A_NUMBER, [UNDEF], [NEG_ZERO, 0],
+  { var: "xs" }, { merge: [[1], [2, 3]] }, { var: "o" },
+];
+const BODIES = [
+  { var: "" }, true, false, null, 0, 1, "s", { "+": [{ var: "" }, 1] }, { "!": { var: "" } },
+  { ">": [{ var: "" }, 1] }, { var: "a" }, { var: "current" }, { var: "accumulator" },
+  { "+": [{ var: "current" }, { var: "accumulator" }] },
+  { cat: [{ var: "accumulator" }, { var: "current" }] },
+  { merge: [{ var: "accumulator" }, [{ var: "current" }]] },
+  { if: [{ var: "" }, 1, 2] }, { nonexistent: [] }, { "==": [{ var: "" }, 1] },
+  { var: ["", "dflt"] }, { and: [] }, { missing: ["a"] }, { in: [{ var: "" }, [1, 2]] },
+  { "*": [{ var: "current" }, 2] }, { max: [{ var: "current" }, { var: "accumulator" }] },
+  { "/": [1, { var: "" }] }, { "%": [{ var: "" }, 2] }, { var: "length" },
+];
+const COLL_DATA = { xs: [1, 2, 3], o: { a: 1 }, a: 9 };
+for (const name of ["map", "filter", "all", "none", "some"]) {
+  for (const c of COLLECTIONS) for (const b of BODIES) add("collection", name, 2, op(name, c, b), COLL_DATA);
+}
+for (const c of COLLECTIONS) for (const b of BODIES) for (const init of [undefined, 0, "", [], null, UNDEF, 1, "x"]) {
+  if (init === undefined) add("collection", "reduce", 2, op("reduce", c, b), COLL_DATA);
+  else add("collection", "reduce", 3, op("reduce", c, b, init), COLL_DATA);
+}
+for (const a of COLLECTIONS) for (const b of COLLECTIONS) {
+  add("collection", "merge", 2, op("merge", a, b), COLL_DATA);
+  add("collection", "in", 2, op("in", a, b), COLL_DATA);
+  add("collection", "cat", 2, op("cat", a, b), COLL_DATA);
+}
+
+// 11. Control flow, short-circuit, unknown operators in untaken branches.
+const BAD = { nonexistent: [] };
+// The reference throws on an empty * only when it is evaluated
+// ("Reduce of empty array with no initial value"), so in an untaken branch it
+// is harmless.
+const EMPTY_MUL = { "*": [] };
+const FLOW = [
+  op("if", true, 1, BAD), op("if", false, BAD, 2), op("if", false, BAD), op("if", true, 1, BAD, BAD),
+  op("if", false, 1, true, 2, BAD), op("if", false, 1, false, 2, 3), op("if", false, 1, false, 2),
+  op("if", 1), op("if", BAD), op("if", true, BAD), op("if", false, BAD), op("if", [], 1, 2), op("if", [0], 1, 2),
+  op("if", "0", 1, 2), op("if", "", 1, 2), op("if", NEG_ZERO, 1, 2), op("if", NOT_A_NUMBER, 1, 2),
+  op("if", {}, 1, 2), op("if", UNDEF, 1, 2), op("?:", true, 1, 2), op("?:", false, 1, 2), op("?:", 0, 1),
+  op("and", false, BAD), op("and", true, BAD), op("or", true, BAD), op("or", false, BAD),
+  op("and", 1, 2, 3), op("and", 1, 0, BAD), op("and", 0), op("and", [], 1), op("and", "", 1),
+  op("or", 0, "", [], null), op("or", 0, "", [], 7), op("or", [], {}), op("and", {}, []), op("or"),
+  op("and", NEG_ZERO), op("or", NOT_A_NUMBER, 0), op("and", 1, UNDEF), op("or", 0, UNDEF),
+  op("!", []), op("!", [[]]), op("!", {}), op("!", "0"), op("!", "false"), op("!!", []), op("!!", [0]),
+  op("!!", NOT_A_NUMBER), op("!!", NEG_ZERO), op("!", UNDEF), op("!!", UNDEF),
+  { "": [] }, { "": 1 }, { "a.b": [] }, { ".a": [] }, { "a.": [] }, { a: [], b: [] }, { constructor: [] },
+  { hasOwnProperty: [] }, { toString: [] }, raw('{"__proto__":[]}'), { "var.x": [] }, { "var.length": ["a"] },
+  { "map.x": [] }, { "+.x": [] }, { "a.constructor": [] }, { "constructor.name": [] },
+  { "hasOwnProperty.call": [] }, { "toString.call": [] }, { "": "" }, { " ": 1 }, { "😀": 1 },
+  { "==": 1 }, { "==": [] }, { "==": null },
+  raw('{"+":[1],"-":[2]}'),
+  op("log", 1), op("log", "a"), op("log", [1]), op("log", NEG_ZERO),
+];
+for (const f of FLOW) add("flow", "mixed", 0, f, { a: 1 });
+for (const f of FLOW) add("flow", "mixed", 0, [f, f], { a: 1 });
+for (const f of FLOW) add("flow", "mixed", 0, { "cat": [f, "x"] }, { a: 1 });
+// literal arrays/objects as operands and results
+for (const v of [[], [1, [2]], [{ var: "a" }], [[{ var: "a" }]], { a: { var: "a" } }, { a: 1, b: 2 }, { a: [] },
+  [{ "+": [1, 2] }, { "+": [3, 4] }], "str", 5, null, true, [null, UNDEF], [NEG_ZERO], [NOT_A_NUMBER, POS_INF]]) {
+  add("literal", "lit", 0, v, { a: 7 });
+  add("literal", "lit", 1, op("merge", v), { a: 7 });
+  add("literal", "lit", 1, op("cat", v), { a: 7 });
+  add("literal", "lit", 1, op("max", v), { a: 7 });
+  add("literal", "lit", 1, op("if", true, v, 0), { a: 7 });
+}
+
+// 12. Deep nesting (up to the library's documented limit) and wide rules.
+for (const depth of [1, 2, 5, 10, 20, 30, 50, 60, 62]) {
+  let r = 1;
+  for (let i = 0; i < depth; i++) r = op("+", r, 1);
+  add("deep", "+", 2, r, null);
+  let q = { var: "a" };
+  for (let i = 0; i < depth; i++) q = op("if", true, q, 0);
+  add("deep", "if", 3, q, { a: 3 });
+  let l = [1];
+  for (let i = 0; i < depth; i++) l = [l];
+  add("deep", "lit", 0, l, null);
+  add("deep", "merge", 1, op("merge", l), null);
+}
+for (const width of [1, 10, 100, 1000, 10000]) {
+  add("wide", "+", width, opRaw("+", Array.from({ length: width }, (_, i) => i)), null);
+  add("wide", "cat", width, opRaw("cat", Array.from({ length: width }, (_, i) => i % 10)), null);
+  add("wide", "max", width, opRaw("max", Array.from({ length: width }, (_, i) => (i * 7919) % 1000)), null);
+  add("wide", "and", width, opRaw("and", Array.from({ length: width }, () => 1)), null);
+  add("wide", "merge", width, opRaw("merge", Array.from({ length: width }, (_, i) => [i])), null);
+  add("wide", "lit", 0, Array.from({ length: width }, (_, i) => i), null);
+  add("wide", "map", 2, op("map", Array.from({ length: width }, (_, i) => i), { "+": [{ var: "" }, 1] }), null);
+  add("wide", "reduce", 3, op("reduce", Array.from({ length: width }, (_, i) => i),
+    { "+": [{ var: "current" }, { var: "accumulator" }] }, 0), null);
+}
+add("wide", "max", 1000, opRaw("max", Array.from({ length: 1000 }, (_, i) => (i % 2 ? NEG_ZERO : 0))), null);
+
+// 13. Coercion matrix: loose and strict equality, ordering, over typed values.
+const TYPED = [
+  null, UNDEF, true, false, 0, 1, -1, NEG_ZERO, NOT_A_NUMBER, POS_INF, NEG_INF, "", "0", "1", "-1", "a",
+  " ", "true", "false", "null", "NaN", "Infinity", "0x0", [], [0], [1], ["a"], [[]], [null], [[0]], {}, { a: 1 },
+];
+for (const a of TYPED) for (const b of TYPED) {
+  for (const o of ["==", "===", "!=", "!==", "<", "<=", ">", ">=", "+", "-", "*", "/", "%", "in", "cat", "max", "min", "substr"]) {
+    add("coercion", o, 2, op(o, a, b), null);
+  }
+}
+
+// 14. Control flow over an operand the reference throws on only when it
+// evaluates it (an empty *), in branches that are and are not taken. Last, so
+// that adding it left the ids of every earlier case unchanged.
+const LAZY_THROW = [
+  op("if", true, 1, EMPTY_MUL), op("if", false, EMPTY_MUL, 2), op("if", EMPTY_MUL, 1, 2),
+  op("or", 1, EMPTY_MUL), op("or", 0, EMPTY_MUL), op("and", 0, EMPTY_MUL), op("and", 1, EMPTY_MUL),
+  op("?:", true, 1, EMPTY_MUL), op("map", [], EMPTY_MUL), op("map", [1], EMPTY_MUL),
+  op("filter", [], EMPTY_MUL), op("all", [], EMPTY_MUL), op("some", [], EMPTY_MUL), op("none", [], EMPTY_MUL),
+  op("reduce", [], EMPTY_MUL, 0), op("reduce", [1], EMPTY_MUL, 0), op("+", 1, EMPTY_MUL), op("==", EMPTY_MUL, 1),
+];
+for (const f of LAZY_THROW) add("flow", "mixed", 0, f, { a: 1 });
+for (const f of LAZY_THROW) add("flow", "mixed", 0, [f, f], { a: 1 });
+for (const f of LAZY_THROW) add("flow", "mixed", 0, { "cat": [f, "x"] }, { a: 1 });
+
+// 15. Every rule of the community test suite (test/community, from
+// json-logic/compat-tables), with the reference's own answers. Most of the
+// suite is written for the JSON Logic 2.0 dialect; what matters here is only
+// that lenient mode answers each rule as json-logic-js does.
+{
+  const path = require("path");
+  const dir = path.join(__dirname, "..", "test", "community");
+  for (const file of JSON.parse(fs.readFileSync(path.join(dir, "index.json"), "utf8"))) {
+    for (const entry of JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"))) {
+      if (entry === null || typeof entry !== "object") continue;
+      const rule = entry.rule;
+      const name = rule !== null && typeof rule === "object" && !Array.isArray(rule) && Object.keys(rule).length === 1
+        ? Object.keys(rule)[0] : "lit";
+      add("community", name, 0, rule, entry.data === undefined ? null : entry.data);
+    }
+  }
+}
+
+// ---------------------------------------------------------------------
+// Inherited-property deviation (docs/COMPATIBILITY.md, DEV-INHERITED).
+//
+// In JavaScript, {"var":"constructor"} on {} finds Object's constructor, and
+// {"var":"__proto__"} finds Object.prototype, because property lookup walks
+// the prototype chain. JSON data has no prototype chain in any other
+// language, so jsonlogic-hs looks at own properties only. For every case
+// whose variable paths use such a name (and whose data does not itself have
+// a key of that name) the fixture records, besides the reference's answer,
+// `expect_own`: the reference's answer when the name is replaced by a key
+// that is certainly absent. The Haskell runner holds the library to
+// `expect_own` for those cases.
+// ---------------------------------------------------------------------
+const INHERITED_NAMES = new Set([
+  "constructor", "__proto__", "toString", "valueOf", "hasOwnProperty", "toLocaleString", "isPrototypeOf",
+  "propertyIsEnumerable", "__defineGetter__", "__defineSetter__", "__lookupGetter__", "__lookupSetter__",
+  "map", "concat", "indexOf", "charAt", "trim", "filter", "slice", "join",
+]);
+const ABSENT = "\u0001absent:";
+
+let currentDataText = "";
+function renamePath(path, used) {
+  if (typeof path !== "string") return path;
+  return path.split(".").map((seg) => {
+    if (!INHERITED_NAMES.has(seg) || currentDataText.includes(JSON.stringify(seg))) return seg;
+    used.add(seg);
+    return ABSENT + seg;
+  }).join(".");
+}
+function renameRule(rule, used) {
+  if (Array.isArray(rule)) return rule.map((r) => renameRule(r, used));
+  if (rule === null || typeof rule !== "object") return rule;
+  const keys = Object.keys(rule);
+  if (keys.length !== 1) return rule;
+  const name = keys[0];
+  let args = rule[name];
+  const wasArray = Array.isArray(args);
+  if (!wasArray) args = [args];
+  let out;
+  if (name === "var") {
+    out = args.map((a, i) => (i === 0 ? renamePath(renameRule(a, used), used) : renameRule(a, used)));
+  } else if (name === "missing") {
+    out = args.map((a) => (Array.isArray(a) ? a.map((k) => renamePath(renameRule(k, used), used)) : renamePath(renameRule(a, used), used)));
+  } else if (name === "missing_some") {
+    out = args.map((a, i) => (i === 1 && Array.isArray(a) ? a.map((k) => renamePath(renameRule(k, used), used)) : renameRule(a, used)));
+  } else {
+    out = args.map((a) => renameRule(a, used));
+  }
+  return { [name]: wasArray ? out : out[0] };
+}
+
+// ---------------------------------------------------------------------
+// Run the oracle and write the fixture.
+// ---------------------------------------------------------------------
+function wf(x) {
+  if (typeof x === "string") return typeof x.toWellFormed === "function" ? x.toWellFormed() : x;
+  if (Array.isArray(x)) return x.map(wf);
+  if (x !== null && typeof x === "object") {
+    const o = {};
+    for (const k of Object.keys(x)) {
+      // defineProperty, not assignment: a key named __proto__ must stay an own property
+      Object.defineProperty(o, wf(k), { value: wf(x[k]), enumerable: true, writable: true, configurable: true });
+    }
+    return o;
+  }
+  return x;
+}
+function canon(x) {
+  // JSON text of a reference result; undefined (and functions) are null.
+  const t = JSON.stringify(wf(x === undefined ? null : x));
+  return t === undefined ? "null" : t;
+}
+function run(rule, data) {
+  try {
+    return '{"ok":' + canon(jsonLogic.apply(rule, data)) + "}";
+  } catch (error) {
+    const message = String(error && error.message ? error.message : error);
+    return '{"error":' + JSON.stringify(wf(message)) + "}";
+  }
+}
+
+// jsonLogic.apply calls console.log for the "log" operator; keep stdout clean.
+console.log = () => {};
+
+function fnv(text) {
+  let h = 0x811c9dc5;
+  for (let i = 0; i < text.length; i++) {
+    h ^= text.charCodeAt(i);
+    h = Math.imul(h, 0x01000193) >>> 0;
+  }
+  return h >>> 0;
+}
+
+// Tiers. `core` is committed and replayed by `cabal test`; `full` is the
+// whole catalogue, replayed by the nightly job. Small, hand-picked families
+// are always kept whole; the big cross-product families are thinned by a
+// stable hash of the case text, so the sample never changes unless the
+// catalogue does.
+const THIN = { pair: 8, triple: 6, unicode: 6, coercion: 8, var: 6, collection: 8, signedzero: 3, numstr: 2, unary: 2 };
+const tier = (process.env.OUTLIER_TIER || "core");
+function kept(c) {
+  if (tier === "full") return true;
+  const k = THIN[c.fam];
+  return !k || fnv(c.ruleText + "\u0001" + c.dataText) % k === 0;
+}
+
+const proto = (c, rule) => {
+  if (!/"(?:[^"\\]*\.)?(?:constructor|__proto__|toString|valueOf|hasOwnProperty|toLocaleString|isPrototypeOf|propertyIsEnumerable|map|concat|indexOf|charAt|trim|filter|slice|join)(?:\.[^"\\]*)?"/.test(c.ruleText)) return null;
+  currentDataText = c.dataText;
+  const used = new Set();
+  const renamed = renameRule(rule, used);
+  if (used.size === 0) return null;
+  let text = run(renamed, JSON.parse(c.dataText));
+  for (const name of used) text = text.split(JSON.stringify(ABSENT + name).slice(1, -1)).join(name);
+  return text;
+};
+
+// DEV-REEVAL: the reference implements missing / missing_some by calling
+// apply({"var": key}) on keys that were already evaluated, so a key that is
+// itself a one-key object (data!) is run as a rule. jsonlogic-hs never runs
+// data as rules. Cases where that happens are tagged and not compared.
+function hasLogicDeep(x) {
+  if (jsonLogic.is_logic(x)) return true;
+  return Array.isArray(x) && x.some(hasLogicDeep);
+}
+function reevaluates(rule, data) {
+  if (rule === null || typeof rule !== "object" || Array.isArray(rule)) return false;
+  const keys = Object.keys(rule);
+  if (keys.length !== 1) return false;
+  const name = keys[0];
+  if (name !== "missing" && name !== "missing_some") return false;
+  let args = Array.isArray(rule[name]) ? rule[name] : [rule[name]];
+  try {
+    const values = args.map((a) => jsonLogic.apply(a, data));
+    if (name === "missing") return values.some(hasLogicDeep);
+    return values.length > 1 && hasLogicDeep(values[1]);
+  } catch (e) {
+    return false;
+  }
+}
+
+const lines = [];
+for (const c of cases) {
+  if (!kept(c)) continue;
+  const rule = JSON.parse(c.ruleText);
+  const data = JSON.parse(c.dataText);
+  const expect = run(rule, data);
+  const own = proto(c, rule);
+  let extra = "";
+  if (reevaluates(rule, data)) extra = ',"dev":"reeval"';
+  else if (own !== null && own !== expect) extra = ',"dev":"inherited","expect_own":' + own;
+  lines.push(
+    '{"id":' + JSON.stringify(c.id) + ',"fam":' + JSON.stringify(c.fam) + ',"op":' + JSON.stringify(c.op) +
+    ',"n":' + c.n + ',"rule":' + c.ruleText + ',"data":' + c.dataText + ',"expect":' + expect + extra + "}"
+  );
+}
+const meta = '{"meta":{"cases":' + lines.length + ',"oracle":' + JSON.stringify(REQUIRED_ORACLE) + ',"tier":' + JSON.stringify(tier) + "}}";
+const output = [meta, ...lines].join("\n") + "\n";
+
+const args = process.argv.slice(2);
+if (args[0] === "--cases") {
+  // Raw rule/data pairs, one per line, for feeding other implementations.
+  fs.writeFileSync(args[1], cases.filter(kept).map((c) => '{"rule":' + c.ruleText + ',"data":' + c.dataText + "}").join("\n") + "\n");
+  process.stderr.write("wrote " + lines.length + " raw cases to " + args[1] + "\n");
+} else if (args[0] === "--check") {
+  const current = fs.readFileSync(args[1], "utf8");
+  if (current !== output) {
+    process.stderr.write("outlier fixture " + args[1] + " is stale: regenerate with `node outliers.js " + args[1] + "`\n");
+    process.exit(1);
+  }
+  process.stderr.write("outlier fixture is up to date (" + lines.length + " cases, tier " + tier + ")\n");
+} else if (args.length === 1) {
+  fs.writeFileSync(args[0], output);
+  process.stderr.write("wrote " + lines.length + " cases to " + args[0] + " (" + output.length + " bytes, tier " + tier + ")\n");
+} else {
+  process.stderr.write("usage: [OUTLIER_TIER=core|full] node outliers.js OUTPUT.jsonl | --check FIXTURE | --cases FILE\n");
+  process.exit(2);
+}
diff --git a/differential/package-lock.json b/differential/package-lock.json
new file mode 100644
--- /dev/null
+++ b/differential/package-lock.json
@@ -0,0 +1,21 @@
+{
+  "name": "jsonlogic-hs-differential-oracle",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "jsonlogic-hs-differential-oracle",
+      "version": "1.0.0",
+      "dependencies": {
+        "json-logic-js": "2.0.5"
+      }
+    },
+    "node_modules/json-logic-js": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/json-logic-js/-/json-logic-js-2.0.5.tgz",
+      "integrity": "sha512-rTT2+lqcuUmj4DgWfmzupZqQDA64AdmYqizzMPWj3DxGdfFNsxPpcNVSaTj4l8W2tG/+hg7/mQhxjU3aPacO6g==",
+      "license": "MIT"
+    }
+  }
+}
diff --git a/differential/package.json b/differential/package.json
new file mode 100644
--- /dev/null
+++ b/differential/package.json
@@ -0,0 +1,9 @@
+{
+  "name": "jsonlogic-hs-differential-oracle",
+  "version": "1.0.0",
+  "private": true,
+  "description": "The reference implementation, wrapped for differential testing against jsonlogic-hs.",
+  "dependencies": {
+    "json-logic-js": "2.0.5"
+  }
+}
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,139 @@
+# Architecture
+
+jsonlogic-hs reads a JSON rule into a closed tree, compiles the tree once into
+a function of the data, and runs that function in a small pure monad that
+counts work. This page explains each stage, the modules that implement it, and
+why it is built this way.
+
+```
+            parse (unfold)               compile (fold)                 run
+  Value ───────────────────▶ Rule ────────────────────▶ Program ──────────────────▶ Value
+  JSON      depth, arity,    tree of Node,  literals,    Val -> Run Val   step budget,
+            locations        each with its  custom ops,                   limits, errors
+                             JSON Pointer   var paths                      with locations
+```
+
+## Modules
+
+The dependency graph is acyclic and points inward; nothing depends on
+`JsonLogic`, the public facade.
+
+| Module | Responsibility |
+| --- | --- |
+| `JsonLogic` | The supported API: `apply`, `compile`, `run`, `dataUse`, configuration, errors, locations. |
+| `JsonLogic.Core.Operator` | The closed set of builtin operators and their names. The only list of them. |
+| `JsonLogic.Core.Location` | JSON Pointers (RFC 6901) into a rule. |
+| `JsonLogic.Core.Errors` | The closed error vocabulary, one type per stage, and their rendering. |
+| `JsonLogic.Core.Config` | Limits and arity mode. |
+| `JsonLogic.Core.Rule` | `RuleF`, one layer of the rule tree; `Node`, a layer with its location; `Rule = Fix Node`. |
+| `JsonLogic.Core.Parse` | JSON to `Rule`: the unfold, the depth bound, and each operator's operand shape (`shapeOf`), which drives both arity modes. |
+| `JsonLogic.Core.Value` | `Val`, JSON plus `NaN`, the infinities, `-0` and `undefined`; JavaScript truthiness, conversions, equality and ordering. |
+| `JsonLogic.Core.Number` | Reading and writing JavaScript numbers exactly; `%`, `Math.max`, `Math.min`. |
+| `JsonLogic.Core.Ryu` | Ryū, the shortest round-tripping decimal of a double. |
+| `JsonLogic.Core.Utf16` | JavaScript's UTF-16 string length, indexing, slicing and ordering over `Text`. |
+| `JsonLogic.Core.Custom` | The registry of caller-supplied operators. |
+| `JsonLogic.Core.Eval` | The fold that compiles a `Rule` to a `Program`, the `Run` monad, and every operator's semantics. |
+| `JsonLogic.Core.Analysis` | `dataUse`: which data paths a rule can read. |
+
+## Reading: an unfold
+
+`parseRuleWith` is `unfoldFixM step seed`. A seed is a piece of JSON still to
+read, how deeply it is nested, and its location. `step` produces one `Node`
+whose children are further seeds:
+
+- an object with exactly one key is an operator call; its operands are the
+  list under the key (a bare value is a list of one), at locations
+  `/key/0`, `/key/1`, ... (or `/key` for the bare value);
+- an array is a node whose elements are rules, at `/0`, `/1`, ...;
+- anything else is a literal, and its own nesting counts towards the depth
+  limit, so a deeply nested object cannot be smuggled through as data.
+
+The depth bound is checked on every seed before any of its children exist,
+so a hostile rule is refused after reading at most the limit's depth of it.
+
+Each builtin's operands are described once, in `shapeOf`: the counts strict
+mode accepts, how many leading operands the reference reads by position, and
+whether it evaluates operands past the last one it uses. Strict mode checks
+the count against the shape. Lenient mode repairs it as the reference would:
+missing operands become `undefined` (an empty `and`), or `null` for
+`reduce`'s initial value; surplus operands are dropped, or kept in a `Discard`
+node when the reference evaluates them for their side effects (that is, for
+whether they fail). A builtin's constructor is shaped so that a wrong count
+cannot be built at all: `Lt a a (Maybe a)`, `Sub a (Maybe a)`.
+
+## Compiling: a fold
+
+`compileRule` is `foldFix compileNode`. Its carrier is not a value but a
+function from the datum to a computation (`Val -> Run Val`). That is what lets
+`map`, `filter`, `reduce`, `all`, `some` and `none` run a sub-rule once per
+element against a different datum, and what makes `if`, `and` and `or`
+short-circuit for free: a branch not taken is a function never called.
+
+Everything that does not depend on the data happens in the fold, once:
+literals are converted to `Val`, custom operators are looked up in the
+registry, and a `var` whose path is a literal has the path split at dots.
+Children are compiled lazily and shared, so a branch no run reaches is never
+compiled.
+
+Operand order follows the reference: every operand is evaluated, left to
+right, before its operator runs, except for `if`, `and`, `or` and the
+iterating operators' bodies.
+
+## Running: a counted, pure computation
+
+`Run a` is `Int -> Result a`, where the `Int` is the remaining step budget and
+`Result` is either a value with the budget left or a failure with its
+location. It is a state monad and an error monad in one, written out by hand
+so that a step costs one allocation; there is no `IO` and no global state.
+
+Every node charges one step before its own work. Work proportional to the
+size of something is charged in proportion: converting an array to a string
+or number, building a string with `cat` or an array with `merge`, walking a
+string to index or measure it, and taking in a custom operator's result. So
+total work is bounded by `maxSteps` times a constant, whatever the rule
+(`docs/LIMITS.md`).
+
+## Values: JavaScript, written out plainly
+
+`Val` is JSON plus what a JavaScript evaluation can produce and JSON cannot
+say: `NaN`, the infinities, negative zero and `undefined`. Arrays are
+`Vector`s (constant-time index and length), objects are aeson's `KeyMap`.
+
+The coercion rules (`==`, `<`, `ToNumber`, `parseFloat`, `String()`) are an
+arbitrary table fixed by JavaScript, so they are written as one plain clause
+per case of the ECMAScript algorithms, not abstracted. Numbers are read with
+Clinger's exact fast path or, failing that, one correctly rounded integer
+division; they are written with Ryū, whose shortest-digits answer is the one
+ECMAScript specifies. Both are checked against slow exact implementations on
+hundreds of thousands of doubles, and the printing against V8 directly.
+
+## Errors
+
+One closed type per stage: `ParseError` (depth, arity) and `EvalError`
+(unknown operator, a custom operator's failure, an operand the reference
+throws on, a limit). `Error` pairs either with the `Location` of the node that
+failed. JavaScript semantics make most odd inputs produce `null`, `false` or
+`NaN` rather than fail, so evaluation errors are rare by design.
+
+## Extension: one open point
+
+The builtin set is closed: `RuleF` is a sum type matched exhaustively, so an
+operator cannot be half-implemented. Operators supplied by the caller are the
+one open extension point, `CustomOp`, resolved against an `Operators` registry
+at compile time. Their contract (eager, evaluated operands, JSON in and out,
+results held to the limits) matches how the reference treats added
+operations.
+
+## Testing as part of the design
+
+Each stage has its own tests (`ParseSpec`, `ValueSpec`, `NumberSpec`,
+`EvalSpec`, `LimitsSpec`, `LocationSpec`, `AnalysisSpec`), and the whole
+pipeline is held to the reference by four independent bodies of evidence:
+the classic suite, the outlier catalogue, the differential test and the
+community suite (`docs/CONFORMANCE.md`). The runners are themselves tested
+(`scripts/check-harness.sh`).
+
+## Decisions
+
+The reasons behind choices that depart from the engineering standard, or
+that a reader might question, are recorded in `docs/adr/`.
diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md
new file mode 100644
--- /dev/null
+++ b/docs/COMPATIBILITY.md
@@ -0,0 +1,172 @@
+# Compatibility with the reference implementation
+
+jsonlogic-hs is held to [json-logic-js](https://github.com/jwadhams/json-logic-js)
+**2.0.5**, the reference implementation. This page lists every place it
+deliberately or unavoidably differs, with the reason and the test that
+pins it. Anything not listed here is a bug: please report it.
+
+How it is held to the reference:
+
+| Check | What it covers | Where |
+| --- | --- | --- |
+| Classic shared suite | The 278 cases of jsonlogic.com/tests.json | `jsonlogic-hs-conformance` |
+| Outlier fixture | 26,980 awkward cases (including every community rule), answers recorded from the reference | `jsonlogic-hs-outliers` (`test/outliers.jsonl`) |
+| Full outlier tier | About 140,000 cases, the same generator | nightly CI; `OUTLIER_TIER=full node differential/outliers.js` |
+| Differential test | Thousands of random rules per run, shrunk on failure | `jsonlogic-hs-differential` |
+| Community suite | 1,138 cases of json-logic/compat-tables, scored and pinned | `jsonlogic-hs-community` (`test/community/`) |
+
+The fixture formats are documented in `docs/CONFORMANCE.md`, so other
+implementations can use them too.
+
+The outlier fixture is generated by `differential/outliers.js` with no
+randomness, and CI regenerates it and fails if the committed file differs.
+Every builtin operator has at least 20 cases, in every family listed in
+`outliers/Outliers.hs`, and the runner fails if a fixture is missing,
+truncated or empty.
+
+In **lenient arity mode** the library agrees with the reference on every
+outlier case except the documented deviations below, which are tagged in the
+fixture and checked against a stated alternative answer (never silently
+skipped, except `DEV-REEVAL`, which is counted).
+
+## Deliberate deviations
+
+### DEV-ARITY: a wrong number of arguments is an error (strict mode, the default)
+
+The reference ignores surplus arguments and treats missing ones as
+`undefined`. In the default **strict** mode, `{"==": [1]}` is a precise
+`ArityMismatch` instead, because a rule with the wrong number of arguments
+is almost always a mistake the reference hides.
+
+Set `arityMode = Lenient` in `Config` to get the reference's behaviour
+exactly, including that surplus arguments of ordinary operators are still
+*evaluated* (so a surplus argument that fails still fails the rule), while
+surplus arguments of `if`, `and`, `or`, `map`, `filter`, `reduce`, `all`,
+`some` and `none` are never looked at.
+
+`{"var": []}` means the data itself in both modes (the classic suite
+requires it). `{"*": []}` is an `ArityMismatch` in strict mode; in lenient
+mode it is accepted and fails with `InvalidOperand "*"` only if it is
+evaluated, exactly where the reference throws (`Reduce of empty array with no
+initial value`), so `{"if": [true, 1, {"*": []}]}` is `1` as in the
+reference.
+
+Pinned by: `CompatSpec` (arity modes), the outlier runner (every case runs in
+both modes; strict must either agree or reject by arity, lenient must agree).
+
+### DEV-IDENTITY: arrays and objects are never equal
+
+`==`, `===`, `!=` and `!==` treat two arrays, two objects, or an array and an
+object as never equal. The reference compares them by reference identity,
+which parsed JSON values do not have. It differs from the reference only
+where the *same* array or object is reached twice, for example
+`{"===": [{"var": ""}, {"var": ""}]}` on `{}`, which is `true` in
+JavaScript. Generated tests avoid such comparisons.
+
+### DEV-LONE-SURROGATE: a severed surrogate pair becomes U+FFFD
+
+Strings are measured, indexed, sliced and ordered by UTF-16 code unit, as in
+JavaScript: `"😀"` has length 2, and sorts below `"\uE000"`. A Haskell `Text`
+cannot hold half of a surrogate pair, so a slice that cuts through one
+(`{"substr": ["😀", 0, 1]}`, or indexing `s.0`) returns U+FFFD for the
+severed half. This is exactly what `String.prototype.toWellFormed()` makes
+of the reference's answer, and is how the fixture normalises it.
+
+### DEV-INHERITED: only own properties are visible
+
+In JavaScript, `{"var": "constructor"}` on `{}` finds `Object`'s
+constructor, and `{"var": "__proto__"}` finds `Object.prototype`, because
+property lookup walks the prototype chain. JSON data has no prototype chain
+in any other language, and exposing those names to rules is a hazard, so
+`var`, `missing` and `missing_some` see own properties only: those paths are
+simply absent unless the data really has a key of that name.
+
+Pinned by: `CompatSpec`, and the fixture's `"dev":"inherited"` cases, each of
+which carries the reference's answer for a path that is certainly absent.
+
+### DEV-REEVAL: data is never run as rules
+
+The reference implements `missing` and `missing_some` by calling
+`apply({"var": key})` on keys that were already evaluated, so a key that
+*came from the data* and looks like a one-key object is executed as a rule.
+That lets data run code against itself, so jsonlogic-hs does not do it. It
+matters only for such a key (the fixture tags the few generated cases
+`"dev":"reeval"` and counts them without comparing).
+
+### DEV-LOG: `log` does not print
+
+`log` returns its argument. The pure core performs no I/O.
+
+### DEV-CUSTOM-NAMES: custom operator names are exact
+
+The reference resolves dotted names (`"Math.max"`) by descending into its
+operations table. jsonlogic-hs matches custom operator names exactly.
+Registering a custom operator named like a builtin is an error
+(`ReservedOperatorName`), not a silent no-op.
+
+### DEV-LIMITS: resource limits
+
+The reference has none. jsonlogic-hs bounds nesting depth, evaluation
+steps, string length and array length (see `docs/LIMITS.md`), and a rule
+that exceeds a limit fails with `LimitExceeded` where the reference would
+run on, or run out of memory.
+
+## The JSON Logic 2.0 dialect (not a deviation: a different language)
+
+The community suite in `test/community/` mostly describes the newer dialect
+of json-logic-engine: operators the classic language does not have (`val`,
+`??`, `exists`, `try`, `throw`, `preserve`), `NaN` as an error instead of a
+value, variadic `/` and `-`, chained `==` and lazily evaluated comparison
+chains. jsonlogic-hs implements the classic language of json-logic-js 2.0.5.
+In lenient mode it scores 782 of 1,138 on the suite, file for file the same
+as json-logic-js; in strict mode 839, because a wrong argument count is then
+an error, as that dialect also asks. The per-file score is pinned in
+`test/community/expected.json`. Implementing the 2.0 dialect as a selectable
+`Config` setting is on the roadmap (`VISION.md`).
+
+## Errors are richer than the reference's
+
+Every error carries the JSON Pointer of the rule node that failed, and errors
+are values (`Error`), never exceptions. The reference throws JavaScript
+exceptions whose messages vary by engine. The outlier runner matches each
+of the reference's exceptions to the library's error of the same kind.
+
+## Input differences that live outside the library
+
+These come from the JSON decoder (aeson), before any rule is evaluated.
+
+- **`-0`**: JavaScript's `JSON.parse("-0")` is negative zero; aeson reads it
+  as `0`. A literal `-0` in rule or data text therefore loses its sign.
+  Negative zero *computed* during evaluation (`{"*": [-1, 0]}`) is exact.
+- **Duplicate keys**: `{"a": 1, "a": 2}`: JavaScript keeps the last value,
+  aeson keeps the first.
+- **Lone surrogates in input**: `"\ud83d"` is rejected by aeson, because a
+  `Text` cannot hold it.
+
+## Numbers and strings: behaviours reproduced on purpose
+
+These look like bugs but are the reference's behaviour, and are pinned by the
+outlier fixture:
+
+- `{"*": ["3"]}` is the string `"3"`: the reference folds without a seed.
+- `{"+": ["1e3"]}` uses `parseFloat`; `{"==": ["0x10", 16]}` uses `Number()`,
+  which reads hex, octal and binary literals. They differ and are separate
+  functions.
+- `NaN` and the infinities are real values while evaluating and become
+  `null` in the result, as `JSON.stringify` writes them. Negative zero is
+  preserved through `max`, `min`, `%`, `*`, `/` and `-`.
+- `{"and": []}` and `{"or": []}` are JavaScript `undefined`, not `null`:
+  `undefined == null`, but `undefined === null` is false, and it converts to
+  `NaN`, not `0`.
+- An object property whose value is `undefined` is left out of the result,
+  as `JSON.stringify` leaves it out.
+- Numbers print with the shortest digits that round-trip, and where two
+  such strings are equally close, the one with the even last digit
+  (ECMAScript's rule): `9007199254740993 / 10` prints `900719925474099.2`.
+- String-to-number conversion trims ECMAScript's whitespace set: it includes
+  U+FEFF, U+2028 and U+2029, and does not include U+0085 or U+180E.
+- `missing_some` throws on a `null` option list, counts a string option by
+  UTF-16 length, and returns the missing keys when its options have no
+  length (`InvalidOperand` is the library's error for the throwing case).
+- A key given to `missing` as an array is a `var` argument list: path, then
+  default.
diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md
new file mode 100644
--- /dev/null
+++ b/docs/CONFORMANCE.md
@@ -0,0 +1,110 @@
+# Using these fixtures to test any JsonLogic implementation
+
+The fixtures in this repository are plain JSON, independent of Haskell. If you
+implement JsonLogic in another language and want to behave like the
+reference, json-logic-js 2.0.5, you can run them against your implementation
+without touching any Haskell. This page states their format exactly and how
+to judge an answer.
+
+| Fixture | Cases | What it checks |
+| --- | --- | --- |
+| `test/tests.json` | 278 | The classic shared suite from jsonlogic.com |
+| `test/outliers.jsonl` | 26,980 | Awkward rules with json-logic-js 2.0.5's own answers |
+| full outlier tier | about 140,000 | The same catalogue, unsampled: `OUTLIER_TIER=full node differential/outliers.js out.jsonl` |
+| `test/community/` | 1,138 | The community suite, scored as the compatibility tables score it |
+
+## The classic suite (`test/tests.json`)
+
+A JSON array. A string is a section comment. Every other entry is a
+three-element array `[rule, data, expected]`: applying `rule` to `data` must
+give exactly `expected`. All 278 must pass.
+
+## The outlier catalogue (`test/outliers.jsonl`)
+
+One JSON object per line, UTF-8, LF line endings.
+
+The first line is `{"meta":{"cases":N,"oracle":"2.0.5","tier":"core"}}`. A
+runner should check that exactly `N` lines follow, so that a truncated file
+fails instead of passing quietly.
+
+Every other line is one case:
+
+```json
+{"id":"pair-009843","fam":"pair","op":"<","n":2,"rule":{"<":[0,0.5]},"data":{"a":1,"b":[1,2]},"expect":{"ok":true}}
+```
+
+| Field | Meaning |
+| --- | --- |
+| `id` | Stable within one version of the generator; family name and a counter. |
+| `fam` | The family: `arity`, `unary`, `pair`, `triple`, `numstr`, `numfmt`, `tie`, `signedzero`, `unicode`, `var`, `collection`, `flow`, `literal`, `deep`, `wide`, `coercion`, `community`. |
+| `op` | The outermost operator (or `lit`). |
+| `n` | The number of operands written. |
+| `rule`, `data` | What to evaluate. Numbers are written as the generator wrote them, so a rule may contain tokens such as `1e309` or `-0.0`; read them with your JSON parser as JavaScript's `JSON.parse` would. |
+| `expect` | `{"ok": value}`, the reference's result, or `{"error": message}`, the message of what it threw. |
+| `dev` | Present only on the few cases that exercise a documented deviation of this library (below). |
+| `expect_own` | With `dev: "inherited"`: the answer for a path that is certainly absent. |
+
+How the expected values were written: JavaScript's `JSON.stringify` of the
+result, so `NaN`, `Infinity` and `undefined` appear as `null`, negative zero
+appears as `0`, and an object property whose value is `undefined` is left out.
+A lone surrogate in a result string is written as U+FFFD
+(`String.prototype.toWellFormed`).
+
+Judging an answer:
+
+- `{"ok": v}`: your result, written as JSON the same way, must equal `v`
+  exactly (numbers compared as doubles).
+- `{"error": m}`: your implementation must fail. The messages are V8's
+  (`Unrecognized operation x`, `Cannot read properties of null (reading
+  'length')`, `Reduce of empty array with no initial value`); match on the
+  kind of failure, not on the text.
+
+Cases tagged `dev` are where an implementation may reasonably choose
+differently from the reference:
+
+- `"inherited"`: the rule looks up a name such as `constructor` that
+  JavaScript finds on the prototype chain of a plain object. Use
+  `expect_own` if your implementation sees only a value's own properties, as
+  this one does.
+- `"reeval"`: the reference re-runs a key that came from the data as a rule.
+  This library skips these two cases.
+
+The families cover: every operator at 0 to 4 operands, as a list and as a
+bare operand; every operator over pools of awkward values; string-to-number
+conversion in fifteen contexts (hex, octal, binary, `Infinity`, every Unicode
+space, 700-digit literals); number printing and shortest-digit ties;
+signed zero, `NaN` and the infinities through every operator; UTF-16 length,
+indexing, `substr` and ordering, including astral characters; `var` paths
+on awkward data; iteration over awkward collections; control flow and
+short-circuiting (including operands that throw only when evaluated); a full
+loose and strict equality and ordering matrix; literals; deep and wide rules;
+and every rule of the community suite.
+
+An implementation that matches the reference on all of these, in a mode that
+pads missing operands with `undefined` and evaluates but ignores surplus
+ones, matches it more closely than any public test suite asks.
+
+## The community suite (`test/community/`)
+
+See `test/community/README.md`. Each file is a JSON array of comments
+(strings) and cases `{"description", "rule", "data"?, "result" | "error"}`;
+`index.json` lists the files. It is scored the way
+[compat-tables](https://github.com/json-logic/compat-tables) scores
+implementations: an expected `null` accepts any falsy result, numbers are
+equal within 1e-10, arrays and objects are compared element by element, and
+an expected error `{"type": T}` passes when the implementation fails with an
+error whose name contains `T`. `test/community/expected.json` records this
+library's per-file score in both arity modes. json-logic-js 2.0.5 scores
+782 of 1,138; an implementation of the JSON Logic 2.0 dialect should score
+far higher.
+
+## Regenerating
+
+```
+cd differential && npm ci                 # installs exactly json-logic-js 2.0.5
+node outliers.js ../test/outliers.jsonl   # the committed tier
+node outliers.js --check ../test/outliers.jsonl
+```
+
+The generator has no randomness. It refuses to run against any other version
+of json-logic-js, because a different oracle changes what "correct" means.
diff --git a/docs/LIMITS.md b/docs/LIMITS.md
new file mode 100644
--- /dev/null
+++ b/docs/LIMITS.md
@@ -0,0 +1,73 @@
+# Resource limits
+
+A JsonLogic rule is data, so rules often come from users. The default
+`Config` makes it safe to evaluate them: every way a rule can make the
+evaluator do unbounded work or allocate unbounded memory is bounded.
+
+```haskell
+import JsonLogic
+
+-- defaults: depth 64, 1,000,000 steps, 1,048,576-unit strings,
+--           100,000-element arrays, strict arity
+applyWithConfig defaultConfig noOperators rule input
+
+-- tighter, for rules from the open internet
+applyWithConfig defaultConfig {maxSteps = 10000, maxStringLength = 4096} noOperators rule input
+```
+
+| Limit | Default | Counted in | Error |
+| --- | --- | --- | --- |
+| `maxDepth` | 64 | levels of nesting of the rule, of any literal in it, and of a custom operator's result | `ParseFailure _ (DepthExceeded n)` or `EvalFailure _ (LimitExceeded (ValueDepthLimit n))` |
+| `maxSteps` | 1,000,000 | see below | `EvalFailure _ (LimitExceeded (StepLimit n))` |
+| `maxStringLength` | 1,048,576 | UTF-16 code units, the longest string `cat` builds | `EvalFailure _ (LimitExceeded (StringLimit n))` |
+| `maxArrayLength` | 100,000 | elements, the longest array `merge` builds and the longest `map` or `filter` iterates | `EvalFailure _ (LimitExceeded (ArrayLimit n))` |
+
+Each limit is exact: a rule that needs exactly the limit succeeds and one
+more fails, and the tests check both sides of every boundary. The `_` in each
+error is the location of the node that hit the limit.
+
+## What a step is
+
+A step is one rule node evaluated, plus: one extra step per element of an
+array operand that an operator converts to a string or number (so comparing a
+big array a thousand times is paid for), one per element or code unit that
+`cat` or `merge` builds, and one per node of a value a custom operator
+returns. Walking a path into the data is paid for where it is not constant time: a
+`var`, `missing` or `missing_some` path costs one step per code point of a
+string it indexes or measures. Object keys, array indices and array lengths
+are constant time and cost nothing extra. Iteration (`map`, `filter`, `reduce`, `all`, `some`, `none`) is
+charged through the nodes its body evaluates. Because building a result costs
+steps in proportion to its size, the total work of an evaluation is bounded
+by `maxSteps` up to a constant factor, even for rules designed to amplify
+(nested maps over ten elements, a `reduce` that doubles a string, a rule
+with two million operands). These cases are tests, and each finishes in well
+under a second.
+
+## What is not bounded
+
+- **Wall-clock time.** The limits bound work, not seconds. Run untrusted
+  evaluations with a timeout (`System.Timeout.timeout`) as well if that
+  matters; evaluation is pure and can be interrupted safely.
+- **A custom operator's own work.** The library cannot see inside it. Its
+  *result* is held to the depth and step limits.
+- **The size of the input.** The rule and the data are values you already
+  hold. Parsing JSON text is aeson's job, and it has no depth limit of its
+  own: bound the size of untrusted text before decoding it.
+- **Searching or slicing a very large string.** `in`, `substr` and string
+  comparison take time linear in the string, which comes from the data you
+  hold; the string limit bounds what `cat` builds, not what the data holds.
+- **Reading a long numeric string.** It is linear in its length (a
+  million-digit string takes about 0.15 s), not bounded by a step.
+
+## Cost
+
+The limits are cheap. Every node costs one counter decrement, and the work
+that is charged in proportion to size is work that is done anyway. On one
+machine (`bench/Bench.hs`, `run` of a compiled rule): `map` or `filter` over
+10,000 elements takes about 2 ms, `reduce` about 3 ms, and a small rule well
+under a microsecond. To apply one rule many times, `compile` it once and
+`run` it: reading and compiling happen once.
+
+`bash scripts/bench.sh` times a fixed set of workloads and records the
+environment; compare two runs on the same machine only
+(`bench/README.md`).
diff --git a/docs/STABILITY.md b/docs/STABILITY.md
new file mode 100644
--- /dev/null
+++ b/docs/STABILITY.md
@@ -0,0 +1,24 @@
+# API stability
+
+jsonlogic-hs follows the [Haskell Package Versioning Policy](https://pvp.haskell.org).
+
+- **`JsonLogic`** is the supported API, and everything it exports is covered
+  by the policy. That includes the error vocabulary (`Error`, `ParseError`,
+  `EvalError`, `LimitKind`, `OperatorsError`, `Arity`), `Location` and
+  `Segment`, `Config` and `ArityMode`, and `DataUse`. Match on constructors,
+  never on `renderError` text, whose wording may change in any release. The
+  rendered form of a `Location` (`renderLocation`) is RFC 6901 and will not
+  change.
+- **`JsonLogic.Core.*`** is exposed for the test suites and for advanced use
+  (working with the rule tree directly). It carries **no stability promise
+  before 1.0**, and may change in any release.
+- Behaviour pinned by the classic suite, the outlier fixture, the community
+  score (`test/community/expected.json`) or `docs/COMPATIBILITY.md` is not
+  changed without a changelog entry and, for a deliberate deviation, a version
+  bump that says so.
+- Which node an error's location names, and how many steps a rule costs, are
+  documented (`docs/LIMITS.md`) and tested, and a change to either is noted in
+  the changelog.
+- `Config` is a record with exported fields; construct it from
+  `defaultConfig` (`defaultConfig {maxSteps = 5000}`) rather than with the
+  constructor, so new fields do not break your code.
diff --git a/docs/TUTORIAL.md b/docs/TUTORIAL.md
new file mode 100644
--- /dev/null
+++ b/docs/TUTORIAL.md
@@ -0,0 +1,214 @@
+# Tutorial
+
+This walks through jsonlogic-hs from a first rule to running untrusted rules
+in production. Every example here is compiled and checked by
+`tutorial/Tutorial.hs` (`cabal test jsonlogic-hs-tutorial`, part of `cabal test all`), so the
+answers shown are the library's real answers.
+
+The examples use these imports:
+
+```haskell
+{-# LANGUAGE OverloadedStrings #-}
+
+import Data.Aeson (Value (..), decode, object, (.=))
+import qualified Data.ByteString.Lazy.Char8 as Char8
+import Data.Maybe (fromMaybe)
+import JsonLogic
+
+-- | JSON from a string literal (examples only: use 'decode' properly in code).
+json :: String -> Value
+json = fromMaybe Null . decode . Char8.pack
+```
+
+## 1. A rule is JSON
+
+A rule is an object with one key, the operator, whose value is the list of
+operands. Operands can themselves be rules. `var` reads from the data.
+
+```haskell
+apply (json "{\"and\": [{\">=\": [{\"var\": \"age\"}, 18]}, {\"var\": \"verified\"}]}")
+      (json "{\"age\": 21, \"verified\": true}")
+-- Right (Bool True)
+```
+
+Anything that is not a one-key object is a literal: `5`, `"x"`, `null`, and
+objects with several keys evaluate to themselves; arrays are evaluated element
+by element.
+
+```haskell
+apply (json "[{\"var\": \"a\"}, 2]") (json "{\"a\": 1}")
+-- Right (Array [Number 1.0,Number 2.0])
+```
+
+Paths are dotted, and numbers index arrays:
+
+```haskell
+apply (json "{\"var\": \"user.roles.0\"}") (json "{\"user\": {\"roles\": [\"admin\"]}}")
+-- Right (String "admin")
+apply (json "{\"var\": [\"missing.path\", \"default\"]}") (json "{}")
+-- Right (String "default")
+```
+
+## 2. JavaScript semantics, on purpose
+
+JsonLogic is defined by its JavaScript reference, so comparisons and
+arithmetic coerce the way JavaScript does. This surprises people; it is also
+what every other implementation they share rules with does.
+
+```haskell
+apply (json "{\"==\": [1, \"1\"]}") Null      -- Right (Bool True)
+apply (json "{\"===\": [1, \"1\"]}") Null     -- Right (Bool False)
+apply (json "{\"<\": [\"10\", \"9\"]}") Null  -- Right (Bool True): two strings compare as strings
+apply (json "{\"+\": [\"3\", 4]}") Null       -- Right (Number 7.0)
+apply (json "{\"/\": [1, 0]}") Null           -- Right Null: Infinity has no JSON form
+apply (json "{\"cat\": [0.1, \"+\", 0.2]}") Null
+-- Right (String "0.1+0.2")
+```
+
+An empty array is falsy (JsonLogic's one change to JavaScript truthiness),
+and `"0"` is truthy:
+
+```haskell
+apply (json "{\"if\": [[], \"yes\", \"no\"]}") Null   -- Right (String "no")
+apply (json "{\"!!\": \"0\"}") Null                   -- Right (Bool True)
+```
+
+`docs/COMPATIBILITY.md` lists the few places the library deliberately differs.
+
+## 3. Compile once, run many times
+
+`apply` reads the rule every time. To evaluate one rule against many inputs,
+`compile` it once: that parses it, checks it, resolves custom operators and
+builds the evaluator, and `run` only evaluates.
+
+```haskell
+case compile defaultConfig noOperators (json "{\"*\": [{\"var\": \"price\"}, 1.2]}") of
+  Left err -> putStrLn (show err)
+  Right rule -> print (map (run rule) [json "{\"price\": 10}", json "{\"price\": 2.5}"])
+-- [Right (Number 12.0),Right (Number 3.0)]
+```
+
+A compiled `Rule` is an ordinary immutable value: share it between threads,
+keep it in a cache, run it as often as you like.
+
+## 4. Reading errors
+
+Every failure is an `Error` with a stage, a reason you can match on, and the
+location of the node that failed, as a JSON Pointer into the rule.
+
+```haskell
+apply (json "{\"if\": [true, {\"nope\": [1]}]}") Null
+-- Left (EvalFailure (fromSegments [Key "if",Index 1]) (UnknownOperator (OperatorName "nope")))
+
+either renderError (const "") (apply (json "{\"if\": [true, {\"nope\": [1]}]}") Null)
+-- "at /if/1: unknown operator \"nope\""
+```
+
+Match on constructors, not on `renderError`'s text, which may change:
+
+```haskell
+describe :: Either Error Value -> String
+describe result = case result of
+  Right v -> "ok: " ++ show v
+  Left (ParseFailure at (ArityMismatch (OperatorName op) _ given)) ->
+    "rule error at " ++ show (renderLocation at) ++ ": " ++ show op ++ " given " ++ show given
+  Left (EvalFailure _ (LimitExceeded _)) -> "too expensive"
+  Left other -> "failed: " ++ show (renderError other)
+```
+
+Two things are not errors in JsonLogic, because they are not in JavaScript:
+odd operands produce `null`, `false` or `NaN`-turned-`null`, and an unknown
+operator in a branch that is never taken is never looked at:
+
+```haskell
+apply (json "{\"or\": [true, {\"nope\": []}]}") Null   -- Right (Bool True)
+```
+
+## 5. Strict and lenient arity
+
+By default a wrong number of operands is an error, because it is almost
+always a mistake:
+
+```haskell
+apply (json "{\"==\": [1]}") Null
+-- Left (ParseFailure (fromSegments []) (ArityMismatch (OperatorName "==") (Exactly 2) 1))
+```
+
+The reference instead pads missing operands with `undefined` and ignores
+extra ones. If you share rules with systems that rely on that, ask for it:
+
+```haskell
+applyWithConfig defaultConfig {arityMode = Lenient} noOperators (json "{\"==\": [1]}") Null
+-- Right (Bool False)
+```
+
+Lenient mode reproduces the reference exactly, including its quirks (a
+surplus operand is still evaluated, so if it fails the rule fails).
+
+## 6. Rules from users: limits
+
+A rule is data, so rules often come from people you do not control. The
+default `Config` is already safe; tighten it for the open internet:
+
+```haskell
+let strict = defaultConfig {maxSteps = 10000, maxStringLength = 4096, maxArrayLength = 1000}
+applyWithConfig strict noOperators (json "{\"map\": [{\"var\": \"xs\"}, {\"map\": [{\"var\": \"\"}, 1]}]}") (object ["xs" .= [[1 .. 2000 :: Int]]])
+-- Left (EvalFailure (fromSegments [Key "map",Index 1]) (LimitExceeded (ArrayLimit 1000)))
+```
+
+What each limit counts, and what is not bounded (wall-clock time, a custom
+operator's own work, the size of the input you decoded), is in
+`docs/LIMITS.md`. Decode untrusted JSON with a size limit first, and wrap
+evaluation in `System.Timeout.timeout` if seconds matter.
+
+## 7. Custom operators
+
+Operators beyond the builtin set are supplied by you. An operator receives
+the datum in scope and its operands, already evaluated, as JSON:
+
+```haskell
+import qualified Data.Text as Text
+
+startsWith :: CustomOperator
+startsWith _ [String prefix, String s] = Right (Bool (prefix `Text.isPrefixOf` s))
+startsWith _ _ = Left "starts_with takes two strings"
+
+operators :: Either OperatorsError Operators
+operators = operatorsFromList [(OperatorName "starts_with", startsWith)]
+
+-- with Right ops = operators:
+applyWith ops (json "{\"starts_with\": [\"ad\", {\"var\": \"role\"}]}") (json "{\"role\": \"admin\"}")
+-- Right (Bool True)
+applyWith ops (json "{\"starts_with\": [1]}") Null
+-- Left (EvalFailure (fromSegments []) (CustomOperatorFailed (OperatorName "starts_with") "starts_with takes two strings"))
+```
+
+A registry refuses a builtin name (`ReservedOperatorName`) and a name given
+twice (`DuplicateOperatorName`) instead of silently picking one. Registries
+combine with `<>`, the left one winning. Operators are resolved when the rule
+is compiled; the full contract is on `CustomOperator`.
+
+## 8. What does this rule read?
+
+`dataUse` tells you, without running the rule, which paths of the data it can
+read: useful to fetch only those fields, or to know which rules a change to
+the data can affect.
+
+```haskell
+dataUse <$> compile defaultConfig noOperators
+  (json "{\"and\": [{\"var\": \"user.age\"}, {\"some\": [{\"var\": \"orders\"}, {\">\": [{\"var\": \"total\"}, 100]}]}]}")
+-- Right (DataUse {usedPaths = ["orders","user.age"], usesAllData = False})
+```
+
+The `var "total"` inside `some` reads each order, not the data, so it is not
+listed. When `usesAllData` is `False`, the answer is guaranteed to depend on
+the data only through the listed paths. A computed path, `{"var": ""}`, or a
+custom operator (which is handed the whole datum) sets `usesAllData`.
+
+## Where next
+
+- `docs/COMPATIBILITY.md`: every deliberate difference from the reference.
+- `docs/LIMITS.md`: the resource limits in detail.
+- `docs/ARCHITECTURE.md`: how the library is built.
+- The Haddock documentation of the `JsonLogic` module: the reference for
+  every function and constructor.
diff --git a/jsonlogic-hs.cabal b/jsonlogic-hs.cabal
new file mode 100644
--- /dev/null
+++ b/jsonlogic-hs.cabal
@@ -0,0 +1,258 @@
+cabal-version:      3.0
+name:               jsonlogic-hs
+version:            0.1.0.0
+synopsis:           JsonLogic rule engine, checked against the JavaScript reference
+description:
+  jsonlogic-hs is a native Haskell implementation of JsonLogic
+  (<https://jsonlogic.com>), a small, closed rule language that is itself
+  JSON, so rules can be stored, shared between systems, and evaluated
+  against data without running arbitrary code.
+
+  It follows the JavaScript reference implementation, json-logic-js 2.0.5:
+  all 278 cases of the classic shared suite pass, and in lenient arity mode
+  it gives the reference's own answers on a committed catalogue of about
+  27,000 deliberately awkward rules (type coercion, UTF-16 strings, signed
+  zero, NaN, infinities, undefined, numeric edge cases, odd arities, and
+  every rule of the community suite), with a 140,000-case tier and thousands
+  of random rules checked against the reference in CI. The few deliberate
+  differences are listed in docs/COMPATIBILITY.md.
+
+  Rules are compiled once and run many times. Evaluation is pure and bounded:
+  rule depth, evaluation steps, string length and array length all have
+  configurable limits, so rules from an untrusted source cannot run
+  unbounded work or exhaust memory. Every error carries the JSON Pointer of
+  the rule node that failed. Arity checking is strict by default and can be
+  switched to the reference's lenient behaviour. Custom operators are
+  supplied by the caller, and dataUse reports which parts of the data a rule
+  can read.
+
+  The supported API is the "JsonLogic" module; docs/TUTORIAL.md walks through
+  it. The "JsonLogic.Core" modules are exposed for advanced use and may
+  change before 1.0.
+category:           Data, JSON
+license:            MIT
+license-file:       LICENSE
+author:             Sabrinathan Nair
+maintainer:         259497327+Bombay-Boyz@users.noreply.github.com
+copyright:          (c) 2026 Sabrinathan Nair
+homepage:           https://github.com/Bombay-Boyz/jsonlogic-hs
+bug-reports:        https://github.com/Bombay-Boyz/jsonlogic-hs/issues
+build-type:         Simple
+tested-with:        GHC ==9.6.7 || ==9.8.4 || ==9.10.3 || ==9.12.4 || ==9.14.1
+extra-doc-files:    CHANGELOG.md
+                    README.md
+                    docs/ARCHITECTURE.md
+                    docs/COMPATIBILITY.md
+                    docs/CONFORMANCE.md
+                    docs/LIMITS.md
+                    docs/STABILITY.md
+                    docs/TUTORIAL.md
+extra-source-files: test/tests.json
+                    test/outliers.jsonl
+                    test/community/LICENSE
+                    test/community/README.md
+                    test/community/*.json
+                    test/community/arithmetic/*.json
+                    test/community/array/*.json
+                    test/community/comparison/*.json
+                    test/community/control/*.json
+                    test/community/string/*.json
+                    differential/README.md
+                    differential/oracle.js
+                    differential/outliers.js
+                    differential/package.json
+                    differential/package-lock.json
+
+-- Treat warnings as errors during development and CI, but not in released
+-- builds: new GHC versions add warnings that would otherwise break
+-- downstream installs (`cabal check` rejects an unconditional -Werror).
+-- Enabled for our own builds through cabal.project.
+flag werror
+  description: Treat warnings as errors (enable in dev/CI, off for releases).
+  default:     False
+  manual:      True
+
+-- The differential test needs Node.js to run, so it is a development tool:
+-- off by default so that installing the package never builds it.
+flag differential
+  description: Build the differential-test executable (needs Node.js to run).
+  default:     False
+  manual:      True
+
+-- Benchmarks are a development tool too.
+flag bench
+  description: Build the benchmark executable.
+  default:     False
+  manual:      True
+
+-- Standard 5.1: the warning flags, shared so every component is held to the
+-- same bar.
+common warnings
+  default-language: Haskell2010
+  ghc-options:      -Wall
+                    -Wcompat
+                    -Widentities
+                    -Wincomplete-record-updates
+                    -Wincomplete-uni-patterns
+                    -Wmissing-export-lists
+                    -Wpartial-fields
+                    -Wredundant-constraints
+                    -Wunused-imports
+                    -Wunused-local-binds
+                    -Wunused-matches
+                    -Wunused-top-binds
+  if flag(werror)
+    ghc-options:    -Werror
+
+-- The supported API is the JsonLogic module. JsonLogic.Core.* is exposed for
+-- advanced use and for the test suites, and may change before 1.0.
+library
+  import:           warnings
+  hs-source-dirs:   src
+  exposed-modules:  JsonLogic
+                    JsonLogic.Core.Analysis
+                    JsonLogic.Core.Config
+                    JsonLogic.Core.Custom
+                    JsonLogic.Core.Errors
+                    JsonLogic.Core.Eval
+                    JsonLogic.Core.Location
+                    JsonLogic.Core.Number
+                    JsonLogic.Core.Operator
+                    JsonLogic.Core.Parse
+                    JsonLogic.Core.Rule
+                    JsonLogic.Core.Ryu
+                    JsonLogic.Core.Utf16
+                    JsonLogic.Core.Value
+  build-depends:    base                 >=4.18 && <4.23
+                  , aeson                >=2.1 && <2.4
+                  , containers           >=0.6 && <0.9
+                  , data-fix             >=0.3 && <0.4
+                  , scientific           >=0.3 && <0.4
+                  , text                 >=2.0 && <2.3
+                  , vector               >=0.12.3 && <0.14
+
+test-suite jsonlogic-hs-test
+  import:           warnings
+  type:             exitcode-stdio-1.0
+  hs-source-dirs:   test
+  main-is:          Spec.hs
+  other-modules:    AnalysisSpec
+                    CompatSpec
+                    EvalSpec
+                    Gen
+                    LimitsSpec
+                    LocationSpec
+                    NumberSpec
+                    ParseSpec
+                    Support
+                    ValueSpec
+  -- a runaway allocation fails the test instead of swapping the machine
+  ghc-options:      -rtsopts "-with-rtsopts=-M2g"
+  build-depends:    base                 >=4.18 && <4.23
+                  , aeson                >=2.1 && <2.4
+                  , bytestring           >=0.11 && <0.13
+                  , data-fix             >=0.3 && <0.4
+                  , hspec                >=2.10 && <2.12
+                  , QuickCheck           >=2.14 && <2.20
+                  , text                 >=2.0 && <2.3
+                  , vector               >=0.12.3 && <0.14
+                  , jsonlogic-hs
+
+-- Runs the classic JsonLogic suite (test/tests.json) and fails if any case
+-- does not pass. Run it with:
+--   cabal test jsonlogic-hs-conformance --test-show-details=direct
+test-suite jsonlogic-hs-conformance
+  import:           warnings
+  type:             exitcode-stdio-1.0
+  hs-source-dirs:   conformance
+  main-is:          Conformance.hs
+  build-depends:    base                 >=4.18 && <4.23
+                  , aeson                >=2.1 && <2.4
+                  , bytestring           >=0.11 && <0.13
+                  , directory            >=1.3 && <1.4
+                  , jsonlogic-hs
+
+-- Replays the committed outlier fixture (test/outliers.jsonl), the
+-- reference implementation's own answers to about 27,000 awkward cases.
+-- Needs neither Node nor a network. Pass a path to replay another tier:
+--   cabal run jsonlogic-hs-outliers -- /path/to/full.jsonl
+test-suite jsonlogic-hs-outliers
+  import:           warnings
+  type:             exitcode-stdio-1.0
+  hs-source-dirs:   outliers
+  main-is:          Outliers.hs
+  build-depends:    base                 >=4.18 && <4.23
+                  , aeson                >=2.1 && <2.4
+                  , bytestring           >=0.11 && <0.13
+                  , containers           >=0.6 && <0.9
+                  , directory            >=1.3 && <1.4
+                  , scientific           >=0.3 && <0.4
+                  , text                 >=2.0 && <2.3
+                  , jsonlogic-hs
+
+-- Every example in docs/TUTORIAL.md, checked against the library.
+test-suite jsonlogic-hs-tutorial
+  import:           warnings
+  type:             exitcode-stdio-1.0
+  hs-source-dirs:   tutorial
+  main-is:          Tutorial.hs
+  build-depends:    base                 >=4.18 && <4.23
+                  , aeson                >=2.1 && <2.4
+                  , bytestring           >=0.11 && <0.13
+                  , text                 >=2.0 && <2.3
+                  , jsonlogic-hs
+
+-- Scores the library on the community suite (test/community, from
+-- json-logic/compat-tables) and fails if the score differs from
+-- test/community/expected.json. See test/community/README.md.
+test-suite jsonlogic-hs-community
+  import:           warnings
+  type:             exitcode-stdio-1.0
+  hs-source-dirs:   community
+  main-is:          Community.hs
+  build-depends:    base                 >=4.18 && <4.23
+                  , aeson                >=2.1 && <2.4
+                  , bytestring           >=0.11 && <0.13
+                  , directory            >=1.3 && <1.4
+                  , text                 >=2.0 && <2.3
+                  , vector               >=0.12.3 && <0.14
+                  , jsonlogic-hs
+
+-- Differential test against the real JavaScript implementation. Needs
+-- Node.js, so it is a flag-gated executable and not part of `cabal test`.
+-- See differential/README.md.
+executable jsonlogic-hs-differential
+  import:           warnings
+  if !flag(differential)
+    buildable:      False
+  hs-source-dirs:   differential
+  main-is:          Differential.hs
+  build-depends:    base                 >=4.18 && <4.23
+                  , aeson                >=2.1 && <2.4
+                  , bytestring           >=0.11 && <0.13
+                  , directory            >=1.3 && <1.4
+                  , process              >=1.6 && <1.7
+                  , QuickCheck           >=2.14 && <2.20
+                  , scientific           >=0.3 && <0.4
+                  , text                 >=2.0 && <2.3
+                  , jsonlogic-hs
+
+-- Benchmarks. See docs/LIMITS.md and bench/Bench.hs.
+executable jsonlogic-hs-bench
+  import:           warnings
+  if !flag(bench)
+    buildable:      False
+  hs-source-dirs:   bench
+  main-is:          Bench.hs
+  -- -T makes the RTS keep allocation statistics, which tasty-bench reports
+  ghc-options:      -rtsopts "-with-rtsopts=-T"
+  build-depends:    base                 >=4.18 && <4.23
+                  , aeson                >=2.1 && <2.4
+                  , bytestring           >=0.11 && <0.13
+                  , tasty-bench          >=0.3 && <0.5
+                  , text                 >=2.0 && <2.3
+                  , jsonlogic-hs
+
+source-repository head
+  type:     git
+  location: https://github.com/Bombay-Boyz/jsonlogic-hs.git
diff --git a/outliers/Outliers.hs b/outliers/Outliers.hs
new file mode 100644
--- /dev/null
+++ b/outliers/Outliers.hs
@@ -0,0 +1,270 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- |
+-- Replays the outlier fixture (test/outliers.jsonl) offline.
+--
+-- The fixture is written by differential/outliers.js from the reference
+-- implementation's own answers; see that file for how the cases are built.
+-- This runner needs neither Node nor a network, and it cannot pass by
+-- running nothing: a missing, unreadable, truncated or empty fixture, an
+-- operator with too few cases, or a line it cannot classify all fail the
+-- run.
+--
+-- Every case is run twice:
+--
+--   * in 'Lenient' mode, where the library must agree with the reference
+--     exactly: the same value, or an error of the same class; and
+--   * in 'Strict' mode (the default), where it must give the same answer
+--     unless the rule has an operator with the wrong number of arguments,
+--     in which case it must reject it with 'ArityMismatch'.
+--
+-- Two documented deviations are honoured, each only for cases the
+-- generator tagged (docs/COMPATIBILITY.md): "inherited" cases are held to
+-- the reference's answer for a property that is certainly absent, and
+-- "reeval" cases are counted but not compared.
+--
+-- Usage: jsonlogic-hs-outliers [FIXTURE]   (default test/outliers.jsonl)
+module Main (main) where
+
+import Control.Monad (unless, when)
+import Data.Aeson
+  ( Value (..)
+  , eitherDecodeStrict
+  , encode
+  )
+import qualified Data.Aeson.KeyMap as KeyMap
+import qualified Data.ByteString.Char8 as B
+import qualified Data.ByteString.Lazy.Char8 as L
+import Data.IORef
+import Data.List (isInfixOf)
+import qualified Data.Map.Strict as Map
+import Data.Scientific (toBoundedInteger)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import System.Directory (doesFileExist)
+import System.Environment (getArgs)
+import System.Exit (exitFailure)
+import System.IO (hPutStrLn, hSetEncoding, stderr, stdout, utf8)
+
+import JsonLogic
+import JsonLogic.Core.Operator (allBuiltins, builtinName)
+
+-- | One fixture case.
+data Case = Case
+  { caseId :: Text
+  , caseFam :: Text
+  , caseOp :: Text
+  , caseRule :: Value
+  , caseData :: Value
+  , caseExpect :: Reference
+  , caseDev :: Maybe Text
+  , caseOwn :: Maybe Reference
+  }
+
+data Reference = RefValue Value | RefError Text
+
+main :: IO ()
+main = do
+  hSetEncoding stdout utf8
+  hSetEncoding stderr utf8
+  args <- getArgs
+  let path = case args of
+        p : _ -> p
+        [] -> "test/outliers.jsonl"
+  exists <- doesFileExist path
+  unless exists (die' ("outlier fixture not found: " ++ path))
+  contents <- B.readFile path
+  (meta, rows) <- case B.lines contents of
+    metaLine : rest -> do
+      declared <- either (die' . ("unreadable meta line: " ++)) pure (eitherDecodeStrict metaLine)
+      pure (declared, rest)
+    [] -> die' ("outlier fixture is empty: " ++ path)
+  expected <- declaredCases meta
+  when (length rows /= expected) $
+    die' ("fixture has " ++ show (length rows) ++ " cases but declares " ++ show expected ++ ": truncated or edited")
+  when (expected < 1000) $ die' "fixture declares fewer than 1000 cases: refusing to pass"
+  cases <- mapM parseCase (zip [2 :: Int ..] rows)
+
+  failures <- newIORef ([] :: [String])
+  tally <- newIORef (Map.empty :: Map.Map String Int)
+  let bump key = modifyIORef' tally (Map.insertWith (+) key 1)
+      failWith msg = modifyIORef' failures (msg :)
+  mapM_ (runCase bump failWith) cases
+
+  -- coverage gate: every builtin operator, and every family, has cases
+  let perOp = Map.fromListWith (+) [(Text.unpack (caseOp c), 1 :: Int) | c <- cases]
+      perFam = Map.fromListWith (+) [(Text.unpack (caseFam c), 1 :: Int) | c <- cases]
+  mapM_
+    ( \o ->
+        when (Map.findWithDefault 0 o perOp < 20) $
+          failWith ("coverage: operator " ++ show o ++ " has fewer than 20 outlier cases")
+    )
+    builtins
+  mapM_
+    ( \f ->
+        when (Map.findWithDefault 0 f perFam < 1) $
+          failWith ("coverage: family " ++ show f ++ " has no cases")
+    )
+    families
+
+  counts <- readIORef tally
+  let count k = Map.findWithDefault 0 k counts
+  putStrLn
+    ( "outliers: "
+        ++ show (length cases)
+        ++ " cases from "
+        ++ path
+        ++ "\n  lenient agrees with the reference: "
+        ++ show (count "lenient-ok")
+        ++ "\n  strict agrees with the reference:  "
+        ++ show (count "strict-ok")
+        ++ "\n  strict rejected by arity:          "
+        ++ show (count "strict-arity")
+        ++ "\n  inherited-property deviation:      "
+        ++ show (count "inherited")
+        ++ "\n  re-evaluation deviation (skipped): "
+        ++ show (count "reeval")
+    )
+  found <- readIORef failures
+  unless (null found) $ do
+    mapM_ (hPutStrLn stderr) (take 40 (reverse found))
+    hPutStrLn stderr (show (length found) ++ " failures")
+    exitFailure
+
+declaredCases :: Value -> IO Int
+declaredCases (Object o)
+  | Just (Object m) <- KeyMap.lookup "meta" o
+  , Just (Number n) <- KeyMap.lookup "cases" m
+  , Just k <- toBoundedInteger n =
+      pure k
+declaredCases _ = die' "first line of the fixture is not a meta line"
+
+die' :: String -> IO a
+die' message = hPutStrLn stderr ("outliers: " ++ message) >> exitFailure
+
+parseCase :: (Int, B.ByteString) -> IO Case
+parseCase (line, bytes) = case eitherDecodeStrict bytes of
+  Left reason -> die' ("line " ++ show line ++ ": " ++ reason)
+  Right (Object o) -> do
+    let text k = case KeyMap.lookup k o of
+          Just (String t) -> pure t
+          _ -> die' ("line " ++ show line ++ ": missing " ++ show k)
+        field k = maybe (die' ("line " ++ show line ++ ": missing " ++ show k)) pure (KeyMap.lookup k o)
+    i <- text "id"
+    f <- text "fam"
+    o' <- text "op"
+    r <- field "rule"
+    d <- field "data"
+    e <- field "expect" >>= reference line
+    own <- maybe (pure Nothing) (fmap Just . reference line) (KeyMap.lookup "expect_own" o)
+    let dev = case KeyMap.lookup "dev" o of
+          Just (String t) -> Just t
+          _ -> Nothing
+    pure (Case i f o' r d e dev own)
+  Right _ -> die' ("line " ++ show line ++ ": not an object")
+
+reference :: Int -> Value -> IO Reference
+reference _ (Object o)
+  | Just v <- KeyMap.lookup "ok" o = pure (RefValue v)
+  | Just (String m) <- KeyMap.lookup "error" o = pure (RefError m)
+reference line _ = die' ("line " ++ show line ++ ": bad expectation")
+
+-- | Every builtin operator, from the library's own table.
+builtins :: [String]
+builtins = map (Text.unpack . builtinName) allBuiltins
+
+families :: [String]
+families =
+  [ "arity"
+  , "unary"
+  , "pair"
+  , "triple"
+  , "numstr"
+  , "numfmt"
+  , "tie"
+  , "signedzero"
+  , "unicode"
+  , "var"
+  , "collection"
+  , "flow"
+  , "literal"
+  , "deep"
+  , "wide"
+  , "coercion"
+  , "community"
+  ]
+
+runCase :: (String -> IO ()) -> (String -> IO ()) -> Case -> IO ()
+runCase bump failWith c
+  | caseDev c == Just "reeval" = bump "reeval"
+  | otherwise = do
+      let target = case (caseDev c, caseOwn c) of
+            (Just "inherited", Just own) -> own
+            _ -> caseExpect c
+          lenient = applyWithConfig defaultConfig{arityMode = Lenient} noOperators (caseRule c) (caseData c)
+          strict = applyWithConfig defaultConfig noOperators (caseRule c) (caseData c)
+      case judge target lenient of
+        Nothing -> bump (if caseDev c == Just "inherited" then "inherited" else "lenient-ok")
+        Just why -> failWith (describe c "lenient" target lenient why)
+      case strict of
+        Left (ParseFailure _ (ArityMismatch (OperatorName name) accepted given))
+          | strictRejects c name accepted given -> bump "strict-arity"
+          | otherwise ->
+              failWith (describe c "strict" target strict "arity rejection that the operator's arity does not justify")
+        _ -> case judge target strict of
+          Nothing -> bump "strict-ok"
+          Just why -> failWith (describe c "strict" target strict why)
+
+-- | Is a strict-mode arity rejection justified: is the argument count
+-- really outside what the operator takes?
+strictRejects :: Case -> Text -> Arity -> Int -> Bool
+strictRejects c _ accepted given = caseFam c `elem` ["flow", "arity", "unary", "pair", "triple", "collection", "community"] && outside
+  where
+    outside = case accepted of
+      Exactly k -> given /= k
+      AtLeast k -> given < k
+      Between lo hi -> given < lo || given > hi
+
+judge :: Reference -> Either Error Value -> Maybe String
+judge (RefValue want) (Right got)
+  | want == got = Nothing
+  | otherwise = Just "different value"
+judge (RefValue _) (Left e) = Just ("library failed: " ++ Text.unpack (renderError e))
+judge (RefError _) (Right _) = Just "reference throws but the library returned a value"
+judge (RefError message) (Left e)
+  | classOf message e = Nothing
+  | otherwise = Just ("error of a different class: " ++ Text.unpack (renderError e))
+
+-- | The reference's error, by what it was, against the library's.
+classOf :: Text -> Error -> Bool
+classOf message e = case e of
+  EvalFailure _ (UnknownOperator _) ->
+    "Unrecognized operation" `isInfixOf` m || "apply is not a function" `isInfixOf` m
+  EvalFailure _ (InvalidOperand (OperatorName "*")) -> "Reduce of empty array" `isInfixOf` m
+  EvalFailure _ (InvalidOperand _) -> "Cannot read properties" `isInfixOf` m
+  ParseFailure _ (ArityMismatch (OperatorName "*") _ 0) -> "Reduce of empty array" `isInfixOf` m
+  _ -> False
+  where
+    m = Text.unpack message
+
+describe :: Case -> String -> Reference -> Either Error Value -> String -> String
+describe c mode target got why =
+  concat
+    [ Text.unpack (caseId c)
+    , " ["
+    , mode
+    , "] "
+    , why
+    , "\n    rule:      "
+    , L.unpack (encode (caseRule c))
+    , "\n    data:      "
+    , L.unpack (encode (caseData c))
+    , "\n    reference: "
+    , showRef target
+    , "\n    library:   "
+    , either (Text.unpack . renderError) (L.unpack . encode) got
+    ]
+
+showRef :: Reference -> String
+showRef (RefValue v) = L.unpack (encode v)
+showRef (RefError m) = "throws: " ++ Text.unpack m
diff --git a/src/JsonLogic.hs b/src/JsonLogic.hs
new file mode 100644
--- /dev/null
+++ b/src/JsonLogic.hs
@@ -0,0 +1,137 @@
+-- |
+-- Module      : JsonLogic
+--
+-- Apply JsonLogic rules (<https://jsonlogic.com>) to data, with the
+-- semantics of the JavaScript reference implementation, json-logic-js 2.0.5.
+--
+-- > apply rule data
+--
+-- Both are aeson 'Value's. The default 'Config' bounds nesting depth,
+-- evaluation steps, string length and array length, so a rule from an
+-- untrusted source cannot exhaust time or memory.
+--
+-- To run one rule against many inputs, 'compile' it once and 'run' it:
+--
+-- > case compile defaultConfig noOperators rule of
+-- >   Left err -> ...
+-- >   Right compiled -> map (run compiled) inputs
+--
+-- Every 'Error' carries the 'Location' in the rule where it happened, as a
+-- JSON Pointer.
+--
+-- This module is the whole supported API. The @JsonLogic.Core.*@ modules
+-- are exposed for the test suites and for advanced use; they carry no
+-- stability promise before 1.0 (see docs/STABILITY.md).
+module JsonLogic
+  ( -- * Applying rules
+    apply
+  , applyWith
+  , applyWithConfig
+
+    -- * Compiling a rule once
+  , Rule
+  , compile
+  , run
+
+    -- * What a rule reads
+  , DataUse (..)
+  , dataUse
+
+    -- * Configuration
+  , Config (..)
+  , ArityMode (..)
+  , defaultConfig
+
+    -- * Custom operators
+  , CustomOperator
+  , Operators
+  , noOperators
+  , operatorsFromList
+  , OperatorsError (..)
+  , OperatorName (..)
+
+    -- * Errors
+  , Error (..)
+  , ParseError (..)
+  , EvalError (..)
+  , LimitKind (..)
+  , Arity (..)
+  , errorLocation
+  , renderError
+
+    -- * Locations
+  , Location
+  , Segment (..)
+  , locationSegments
+  , fromSegments
+  , renderLocation
+  ) where
+
+import Data.Aeson (Value)
+import Data.Bifunctor (first)
+
+import JsonLogic.Core.Analysis (DataUse (..))
+import qualified JsonLogic.Core.Analysis as Analysis
+import JsonLogic.Core.Config (ArityMode (..), Config (..), defaultConfig)
+import JsonLogic.Core.Custom (CustomOperator, Operators, noOperators, operatorsFromList)
+import JsonLogic.Core.Errors
+  ( Arity (..)
+  , Error (..)
+  , EvalError (..)
+  , LimitKind (..)
+  , OperatorsError (..)
+  , ParseError (..)
+  , errorLocation
+  , renderError
+  )
+import JsonLogic.Core.Eval (Program, compileRule, runProgram)
+import JsonLogic.Core.Location (Location, Segment (..), fromSegments, locationSegments, renderLocation)
+import JsonLogic.Core.Operator (OperatorName (..))
+import JsonLogic.Core.Parse (parseRuleWith)
+import qualified JsonLogic.Core.Rule as Core
+
+-- | A rule that has been read, checked and compiled, ready to run against
+-- any number of inputs. It keeps the 'Config' and the custom operators it was
+-- compiled with.
+data Rule = Rule Core.Rule Program
+
+-- | Read a rule and compile it. Fails with a 'ParseFailure' if the rule
+-- nests too deeply or, in 'Strict' mode, gives an operator the wrong number of
+-- arguments. Custom operators are resolved here, once; an operator the
+-- registry does not have is reported as 'UnknownOperator' only if a run
+-- reaches it, because the reference evaluates rules lazily.
+compile :: Config -> Operators -> Value -> Either Error Rule
+compile config operators value = do
+  tree <- first (uncurry ParseFailure) (parseRuleWith config value)
+  pure (Rule tree (compileRule config operators tree))
+
+-- | Run a compiled rule against some data.
+run :: Rule -> Value -> Either Error Value
+run (Rule _ program) input = first (uncurry EvalFailure) (runProgram program input)
+
+-- | What a compiled rule can read from its data, found without running it:
+-- if 'usesAllData' is false, its result depends on the data only through
+-- the values at 'usedPaths'. Unlike the reference's @uses_data@, a @var@
+-- inside a @map@ (or other iteration) body is not counted, because it reads
+-- the element, not the data. See "JsonLogic.Core.Analysis".
+--
+-- > dataUse <$> compile defaultConfig noOperators rule
+dataUse :: Rule -> DataUse
+dataUse (Rule tree _) = Analysis.dataUse tree
+
+-- | Apply a rule to some data, with the builtin operators only and the
+-- default 'Config'.
+apply :: Value -> Value -> Either Error Value
+apply = applyWith noOperators
+
+-- | Apply a rule to some data, with extra operators available and the
+-- default 'Config'.
+applyWith :: Operators -> Value -> Value -> Either Error Value
+applyWith = applyWithConfig defaultConfig
+
+-- | Apply a rule to some data with an explicit 'Config' and extra
+-- operators. Equal to 'compile' then 'run'.
+applyWithConfig :: Config -> Operators -> Value -> Value -> Either Error Value
+applyWithConfig config operators rule input = do
+  compiled <- compile config operators rule
+  run compiled input
diff --git a/src/JsonLogic/Core/Analysis.hs b/src/JsonLogic/Core/Analysis.hs
new file mode 100644
--- /dev/null
+++ b/src/JsonLogic/Core/Analysis.hs
@@ -0,0 +1,131 @@
+-- |
+-- Module      : JsonLogic.Core.Analysis
+--
+-- What a rule can read from its data, found without running it.
+--
+-- The reference implementation offers @uses_data@, which lists every @var@
+-- name in a rule, including those inside a @map@ body that read the element
+-- rather than the data. This analysis follows the scopes instead: the body
+-- of @map@, @filter@, @reduce@, @all@, @none@ and @some@ is evaluated against
+-- an element (or, for @reduce@, against @current@ and @accumulator@), and
+-- classic JsonLogic has no way to reach the outer data from there, so what a
+-- body reads is part of what its collection operand reads.
+--
+-- The answer is an over-approximation, and exact about what it promises: if
+-- 'usesAllData' is false, the result of the rule (value or error) depends on
+-- the data only through the values at 'usedPaths'. A path is the dotted path
+-- @var@ would follow, as written in the rule. Anything that cannot be known
+-- statically (a computed path, a path that is the data itself, a key list
+-- that comes from the data, a custom operator, which is handed the whole
+-- datum) sets 'usesAllData'.
+module JsonLogic.Core.Analysis
+  ( DataUse (..)
+  , dataUse
+  ) where
+
+import Data.Aeson (Value (..))
+import Data.Fix (foldFix)
+import qualified Data.Set as Set
+import Data.Text (Text)
+import qualified Data.Text as Text
+
+import JsonLogic.Core.Rule (Node (..), Rule, RuleF (..))
+import JsonLogic.Core.Value (fromValue, toJsString)
+
+-- | What a rule can read from its data.
+data DataUse = DataUse
+  { usedPaths :: [Text]
+  -- ^ The paths it can read, sorted and without duplicates.
+  , usesAllData :: Bool
+  -- ^ Whether it can read anything else, so that 'usedPaths' is not the
+  -- whole story.
+  }
+  deriving (Eq, Show)
+
+-- | What the rule can read, as a set while it is built.
+data Reads = Reads !(Set.Set Text) !Bool
+
+instance Semigroup Reads where
+  Reads a x <> Reads b y = Reads (Set.union a b) (x || y)
+
+instance Monoid Reads where
+  mempty = Reads Set.empty False
+
+-- | One layer's analysis: what the node and its operands read, and its
+-- shape, so that a parent can see a literal path or key list.
+data Layer = Layer
+  { layerReads :: Reads
+  , layerShape :: Shape
+  }
+
+-- | What a node is, as far as a parent's lookups care.
+data Shape
+  = -- | A literal: a scalar, or an object that is not an operator call.
+    Literal Value
+  | -- | An array literal, whose elements are each a rule.
+    ArrayOf [Shape]
+  | -- | Anything computed.
+    Computed
+
+-- | What a rule can read from its data.
+dataUse :: Rule -> DataUse
+dataUse rule = case layerReads (foldFix layer rule) of
+  Reads paths whole -> DataUse (Set.toAscList paths) whole
+
+layer :: Node Layer -> Layer
+layer (Node _ node) = Layer (readsOf node) shape
+  where
+    shape = case node of
+      Lit value -> Literal value
+      Arr items -> ArrayOf (map layerShape items)
+      _ -> Computed
+
+readsOf :: RuleF Layer -> Reads
+readsOf node = case node of
+  Var path _ -> operands <> lookupOf (layerShape path)
+  Missing keys -> operands <> missingLookups (map layerShape keys)
+  MissingSome _ options -> operands <> optionLookups (layerShape options)
+  -- an iteration body reads the element, which its collection already
+  -- accounts for; only the collection (and reduce's initial value) are
+  -- evaluated against the data
+  Map collection _ -> layerReads collection
+  Filter collection _ -> layerReads collection
+  All collection _ -> layerReads collection
+  None collection _ -> layerReads collection
+  Some collection _ -> layerReads collection
+  Reduce collection _ initial -> layerReads collection <> layerReads initial
+  -- a custom operator is handed the whole datum
+  CustomOp _ _ -> operands <> everything
+  _ -> operands
+  where
+    operands = foldMap layerReads node
+
+-- | A lookup of the path a node evaluates to: known only for a literal.
+lookupOf :: Shape -> Reads
+lookupOf (Literal key) = pathRead key
+lookupOf _ = everything
+
+-- | The path a literal names, as @var@ reads it: the data itself for
+-- @null@ and @""@, otherwise the literal as a string.
+pathRead :: Value -> Reads
+pathRead key = case key of
+  Null -> everything
+  String s | Text.null s -> everything
+  _ -> Reads (Set.singleton (toJsString (fromValue key))) False
+
+-- | @missing@ looks up each key, and if its first operand is an array, that
+-- array is the key list instead. A key that is itself an array is a @var@
+-- argument list, which is not analysed.
+missingLookups :: [Shape] -> Reads
+missingLookups keys = case keys of
+  ArrayOf listed : _ -> foldMap lookupOf listed
+  _ -> foldMap lookupOf keys
+
+-- | @missing_some@ looks up each of its options, or the one option if they
+-- are not an array.
+optionLookups :: Shape -> Reads
+optionLookups (ArrayOf listed) = foldMap lookupOf listed
+optionLookups other = lookupOf other
+
+everything :: Reads
+everything = Reads Set.empty True
diff --git a/src/JsonLogic/Core/Config.hs b/src/JsonLogic/Core/Config.hs
new file mode 100644
--- /dev/null
+++ b/src/JsonLogic/Core/Config.hs
@@ -0,0 +1,62 @@
+-- |
+-- Module      : JsonLogic.Core.Config
+--
+-- Configuration for reading and evaluating rules: the resource limits that
+-- make it safe to evaluate rules from an untrusted source, and the arity
+-- mode.
+--
+-- There is no global state. A @Config@ is an ordinary value passed to
+-- 'JsonLogic.compile' or 'JsonLogic.applyWithConfig'.
+module JsonLogic.Core.Config
+  ( ArityMode (..)
+  , Config (..)
+  , defaultConfig
+  ) where
+
+-- | What to do with an operator given the wrong number of arguments.
+data ArityMode
+  = -- | Reject it with a precise 'JsonLogic.ArityMismatch'. The default:
+    -- a rule that passes the wrong number of arguments is almost always a
+    -- mistake, and the reference silently hides it.
+    Strict
+  | -- | Behave like the reference implementation: ignore surplus
+    -- arguments (after still evaluating them, as the reference does) and
+    -- treat missing ones as JavaScript @undefined@.
+    Lenient
+  deriving (Eq, Show)
+
+-- | Limits and mode. All limits bound /work and memory/, not wall-clock
+-- time: a custom operator that is slow is still slow.
+data Config = Config
+  { maxDepth :: !Int
+  -- ^ How deeply a rule (and any literal inside it, and any value a custom
+  -- operator returns) may nest. Default 64.
+  , maxSteps :: !Int
+  -- ^ How many evaluation steps one run may take. A step is one rule node
+  -- evaluated (so each iteration of @map@ and the others costs the nodes of
+  -- its body), plus one per array element converted to a string or number,
+  -- per code unit or element built by @cat@ or @merge@, per code point of a
+  -- string a path indexes or measures, and per node of a custom operator's
+  -- result (docs/LIMITS.md). Default 1,000,000.
+  , maxStringLength :: !Int
+  -- ^ The longest string @cat@ may build, in UTF-16 code units. Default
+  -- 1,048,576.
+  , maxArrayLength :: !Int
+  -- ^ The longest array @merge@, @map@ or @filter@ may build. Default
+  -- 100,000.
+  , arityMode :: !ArityMode
+  -- ^ 'Strict' (the default) or 'Lenient'.
+  }
+  deriving (Eq, Show)
+
+-- | The default configuration: depth 64, one million steps, 1 MiB strings
+-- (in code units), 100,000-element arrays, strict arity.
+defaultConfig :: Config
+defaultConfig =
+  Config
+    { maxDepth = 64
+    , maxSteps = 1000000
+    , maxStringLength = 1048576
+    , maxArrayLength = 100000
+    , arityMode = Strict
+    }
diff --git a/src/JsonLogic/Core/Custom.hs b/src/JsonLogic/Core/Custom.hs
new file mode 100644
--- /dev/null
+++ b/src/JsonLogic/Core/Custom.hs
@@ -0,0 +1,75 @@
+-- |
+-- Module      : JsonLogic.Core.Custom
+--
+-- Operators supplied by the caller: the one open extension point of the
+-- rule language. A registry is built once, checked when it is built, and
+-- handed to 'JsonLogic.compile', which resolves every custom operator in the
+-- rule against it.
+module JsonLogic.Core.Custom
+  ( CustomOperator
+  , Operators
+  , noOperators
+  , operatorsFromList
+  , lookupOperator
+  ) where
+
+import Control.Monad (foldM)
+import qualified Data.Aeson as Aeson
+import qualified Data.Map.Strict as Map
+import Data.Maybe (isJust)
+import Data.Text (Text)
+
+import JsonLogic.Core.Errors (OperatorsError (..))
+import JsonLogic.Core.Operator (OperatorName (..), lookupBuiltin)
+
+-- | A caller-supplied operator: given the data being evaluated against and
+-- its already-evaluated arguments, produce a result or a reason it failed.
+--
+-- The contract, in full:
+--
+-- * All of the operator's operands are evaluated, left to right, before it
+--   runs, as for the reference's own ordinary operators. An operator that
+--   nobody supplied is therefore reported only after its operands have run,
+--   and only if the rule reaches it.
+-- * The data argument is the datum in scope: inside @map@, @filter@ and the
+--   other iterating operators, the current element. It is converted to JSON
+--   lazily, so an operator that ignores it pays nothing for it.
+-- * Arguments and result are JSON. JavaScript values with no JSON form
+--   cross the boundary as @null@: @NaN@, the infinities and @undefined@.
+-- * The number of arguments is not checked by the library; the operator
+--   checks its own.
+-- * @Left reason@ stops the evaluation with
+--   'JsonLogic.Core.Errors.CustomOperatorFailed'.
+-- * The result is held to the configured depth and step limits; the
+--   operator's own work is not (the library cannot see inside it).
+type CustomOperator = Aeson.Value -> [Aeson.Value] -> Either Text Aeson.Value
+
+-- | A registry of custom operators.
+newtype Operators = Operators (Map.Map OperatorName CustomOperator)
+
+-- | Registries combine left-biased: where both define a name, the left one
+-- wins. Neither can hold a builtin name, so neither can the combination.
+instance Semigroup Operators where
+  Operators left <> Operators right = Operators (Map.union left right)
+
+instance Monoid Operators where
+  mempty = noOperators
+
+-- | No custom operators: any operator outside the builtin set is an
+-- 'JsonLogic.Core.Errors.UnknownOperator' when it is reached.
+noOperators :: Operators
+noOperators = Operators Map.empty
+
+-- | Custom operators from an association list. A name that is a builtin
+-- operator, or that appears twice, is refused rather than silently ignored.
+operatorsFromList :: [(OperatorName, CustomOperator)] -> Either OperatorsError Operators
+operatorsFromList entries = Operators <$> foldM add Map.empty entries
+  where
+    add table (name@(OperatorName text), implementation)
+      | isJust (lookupBuiltin text) = Left (ReservedOperatorName name)
+      | Map.member name table = Left (DuplicateOperatorName name)
+      | otherwise = Right (Map.insert name implementation table)
+
+-- | The operator registered under a name.
+lookupOperator :: Operators -> OperatorName -> Maybe CustomOperator
+lookupOperator (Operators table) name = Map.lookup name table
diff --git a/src/JsonLogic/Core/Errors.hs b/src/JsonLogic/Core/Errors.hs
new file mode 100644
--- /dev/null
+++ b/src/JsonLogic/Core/Errors.hs
@@ -0,0 +1,156 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- |
+-- Module      : JsonLogic.Core.Errors
+--
+-- The closed error vocabulary, layered by stage: reading a rule can fail
+-- ('ParseError'), evaluating one can fail ('EvalError'), and 'Error' is
+-- either, together with the 'Location' in the rule where it happened.
+--
+-- Callers match on the constructors, never on rendered text. 'renderError'
+-- produces a message for people and logs; its wording may change in any
+-- release.
+module JsonLogic.Core.Errors
+  ( Arity (..)
+  , Error (..)
+  , EvalError (..)
+  , LimitKind (..)
+  , OperatorsError (..)
+  , ParseError (..)
+  , errorLocation
+  , renderError
+  , renderEvalError
+  , renderParseError
+  ) where
+
+import Data.Text (Text)
+import qualified Data.Text as Text
+
+import JsonLogic.Core.Location (Location, renderLocation)
+import JsonLogic.Core.Operator (OperatorName (..))
+
+-- | How many arguments an operator accepts.
+data Arity
+  = Exactly Int
+  | AtLeast Int
+  | Between Int Int
+  deriving (Eq, Show)
+
+-- | Why a JSON value could not be read as a rule.
+data ParseError
+  = -- | The rule, or a literal inside it, nests deeper than the configured
+    -- limit (carried here). Raised before evaluation ever sees the rule.
+    DepthExceeded Int
+  | -- | An operator was given a number of arguments it does not take:
+    -- the operator, what it accepts, and how many it was given. Raised in
+    -- strict arity mode only.
+    ArityMismatch OperatorName Arity Int
+  deriving (Eq, Show)
+
+-- | Which resource limit stopped an evaluation, with the configured
+-- limit. The limits bound work and memory, not wall-clock time.
+data LimitKind
+  = -- | More evaluation steps than allowed (see @maxSteps@ in "JsonLogic.Core.Config"
+    -- for what a step is).
+    StepLimit Int
+  | -- | A string result longer than allowed, in UTF-16 code units.
+    StringLimit Int
+  | -- | An array result, or an array to iterate, longer than allowed.
+    ArrayLimit Int
+  | -- | A value a custom operator returned nests deeper than allowed.
+    ValueDepthLimit Int
+  deriving (Eq, Show)
+
+-- | Why a well-formed rule could not be evaluated. JavaScript semantics
+-- turn most odd inputs into @NaN@ or @null@ rather than a failure, so only
+-- an operator nobody supplied, the few operands the reference itself throws
+-- on, a custom operator's own failure, and an exhausted limit can go wrong.
+data EvalError
+  = -- | The rule reached an operator that is neither builtin nor supplied.
+    UnknownOperator OperatorName
+  | -- | A supplied operator reported failure, with its reason.
+    CustomOperatorFailed OperatorName Text
+  | -- | An operand the reference implementation throws a @TypeError@ on:
+    -- @missing_some@ with a @null@ or missing option list, and @*@ with no
+    -- operands at all (reached in lenient arity mode only; strict mode
+    -- rejects it as an 'ArityMismatch' before evaluating).
+    InvalidOperand OperatorName
+  | -- | A configured resource limit was exceeded.
+    LimitExceeded LimitKind
+  deriving (Eq, Show)
+
+-- | Why a registry of custom operators was refused. Raised when the
+-- registry is built, before any rule is read.
+data OperatorsError
+  = -- | The name belongs to a builtin operator, which would shadow it.
+    ReservedOperatorName OperatorName
+  | -- | The name was given more than once.
+    DuplicateOperatorName OperatorName
+  deriving (Eq, Show)
+
+-- | A failure of either stage, and where in the rule it happened.
+data Error
+  = -- | The rule could not be read. The location is the node that could not
+    -- be read: the operator with the wrong argument count, or the first
+    -- value that nests too deeply.
+    ParseFailure Location ParseError
+  | -- | The rule could not be evaluated. The location is the node being
+    -- evaluated when it failed: the unknown or failing operator, the
+    -- operator given an invalid operand, or the node that ran out of a
+    -- resource.
+    EvalFailure Location EvalError
+  deriving (Eq, Show)
+
+-- | Where an error happened.
+errorLocation :: Error -> Location
+errorLocation (ParseFailure location _) = location
+errorLocation (EvalFailure location _) = location
+
+-- | A one-line description of an error and its location, for logs and
+-- messages.
+--
+-- > at /if/1: operator "*" takes at least 1 argument, but was given 0
+renderError :: Error -> Text
+renderError e = case e of
+  ParseFailure location problem -> at location (renderParseError problem)
+  EvalFailure location problem -> at location (renderEvalError problem)
+  where
+    at location message = case renderLocation location of
+      "" -> "at the root of the rule: " <> message
+      pointer -> "at " <> pointer <> ": " <> message
+
+-- | A one-line description of a parse error, without its location.
+renderParseError :: ParseError -> Text
+renderParseError (DepthExceeded limit) =
+  "the rule nests deeper than the limit of " <> shown limit
+renderParseError (ArityMismatch (OperatorName name) accepted given) =
+  "operator " <> quoted name <> " takes " <> renderArity accepted <> ", but was given " <> shown given
+
+-- | A one-line description of an evaluation error, without its location.
+renderEvalError :: EvalError -> Text
+renderEvalError (UnknownOperator (OperatorName name)) =
+  "unknown operator " <> quoted name
+renderEvalError (CustomOperatorFailed (OperatorName name) reason) =
+  "custom operator " <> quoted name <> " failed: " <> reason
+renderEvalError (InvalidOperand (OperatorName name)) =
+  "operator " <> quoted name <> " was given an operand it cannot work with"
+renderEvalError (LimitExceeded kind) = case kind of
+  StepLimit n -> "evaluation exceeded the limit of " <> shown n <> " steps"
+  StringLimit n -> "a string result exceeded the limit of " <> shown n <> " code units"
+  ArrayLimit n -> "an array exceeded the limit of " <> shown n <> " elements"
+  ValueDepthLimit n -> "a value nests deeper than the limit of " <> shown n
+
+renderArity :: Arity -> Text
+renderArity (Exactly n) = "exactly " <> shown n <> plural n
+renderArity (AtLeast n) = "at least " <> shown n <> plural n
+renderArity (Between low high) = "between " <> shown low <> " and " <> shown high <> " arguments"
+
+plural :: Int -> Text
+plural 1 = " argument"
+plural _ = " arguments"
+
+shown :: Int -> Text
+shown = Text.pack . show
+
+quoted :: Text -> Text
+quoted name = "\"" <> name <> "\""
diff --git a/src/JsonLogic/Core/Eval.hs b/src/JsonLogic/Core/Eval.hs
new file mode 100644
--- /dev/null
+++ b/src/JsonLogic/Core/Eval.hs
@@ -0,0 +1,624 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- |
+-- Module      : JsonLogic.Core.Eval
+--
+-- Compiling a rule to a function of the data, and running it.
+--
+-- Compilation is a fold ('foldFix') over the rule tree, done once. Its
+-- carrier is a /function/ from the datum a rule is evaluated against to a
+-- result (@Eval@), not a value. That is what lets @map@, @filter@, @reduce@
+-- and the others run the same sub-rule once per element against a different
+-- datum, and what makes @if@, @and@ and @or@ short-circuit with no extra
+-- code: a branch not taken is a function never applied. Everything that does
+-- not depend on the data (converting literals, resolving custom operators,
+-- choosing each node's code) happens during the fold, so running a compiled
+-- rule many times pays for it once.
+--
+-- Evaluation runs in 'Run', a small state-passing monad whose state is the
+-- remaining step budget. It is pure: the budget is threaded through the
+-- evaluation, never held in a global or in 'IO'. A failure carries the
+-- location of the node that failed. See "JsonLogic.Core.Config" for the
+-- limits.
+--
+-- Operand evaluation order follows the reference: every operand is evaluated
+-- before its operator runs, except where the reference evaluates lazily
+-- (@if@, @and@, @or@) or against a scoped datum (@map@, @filter@, @reduce@,
+-- @all@, @none@, @some@).
+module JsonLogic.Core.Eval
+  ( Program
+  , compileRule
+  , runProgram
+  , evaluate
+  ) where
+
+import Control.Monad (foldM, when)
+import qualified Data.Aeson as Aeson
+import qualified Data.Aeson.Key as Key
+import qualified Data.Aeson.KeyMap as KeyMap
+import Data.Char (digitToInt, isDigit)
+import Data.Fix (foldFix)
+import qualified Data.List as List
+import Data.Maybe (fromMaybe)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import Data.Vector (Vector)
+import qualified Data.Vector as Vector
+
+import JsonLogic.Core.Config (Config (..), defaultConfig)
+import JsonLogic.Core.Custom (Operators, lookupOperator, noOperators)
+import JsonLogic.Core.Errors (EvalError (..), LimitKind (..))
+import JsonLogic.Core.Location (Location)
+import JsonLogic.Core.Number (maximumJs, minimumJs, remainderJs)
+import JsonLogic.Core.Operator (Builtin (..), builtinOperatorName)
+import JsonLogic.Core.Rule (Node (..), Rule, RuleF (..))
+import JsonLogic.Core.Utf16 (utf16CharAt, utf16Length, utf16Slice)
+import JsonLogic.Core.Value
+
+-- ---------------------------------------------------------------------
+-- The evaluation monad
+-- ---------------------------------------------------------------------
+
+-- | The outcome of a step: its value and the budget left, or where and why
+-- it failed. One constructor per outcome, strict, so the budget cannot pile
+-- up as a chain of thunks and a step allocates one cell.
+data Result a
+  = Done !a {-# UNPACK #-} !Int
+  | Failed !Location !EvalError
+
+-- | A computation that may fail, at a location in the rule, and that spends
+-- steps from a budget.
+newtype Run a = Run {unRun :: Int -> Result a}
+
+instance Functor Run where
+  fmap f (Run m) = Run $ \budget -> case m budget of
+    Done a left -> Done (f a) left
+    Failed location e -> Failed location e
+  {-# INLINE fmap #-}
+
+instance Applicative Run where
+  pure a = Run (Done a)
+  {-# INLINE pure #-}
+  Run mf <*> Run ma = Run $ \budget -> case mf budget of
+    Failed location e -> Failed location e
+    Done f left -> case ma left of
+      Failed location e -> Failed location e
+      Done a left' -> Done (f a) left'
+  {-# INLINE (<*>) #-}
+
+instance Monad Run where
+  Run m >>= k = Run $ \budget -> case m budget of
+    Failed location e -> Failed location e
+    Done a left -> unRun (k a) left
+  {-# INLINE (>>=) #-}
+
+-- | What every node's code knows about itself: the configuration it was
+-- compiled with and where it is in the rule.
+data At = At !Config !Location
+
+-- | Fail here.
+failAt :: At -> EvalError -> Run a
+failAt (At _ location) e = Run (const (Failed location e))
+
+-- | Fail here because a limit was exceeded.
+exceeded :: At -> (Config -> LimitKind) -> Run a
+exceeded at@(At config _) kind = failAt at (LimitExceeded (kind config))
+
+-- | Spend @n@ steps, or fail here if fewer than @n@ remain.
+spend :: At -> Int -> Run ()
+spend (At config location) n = Run $ \budget ->
+  if n > budget
+    then Failed location (LimitExceeded (StepLimit (maxSteps config)))
+    else Done () (budget - n)
+{-# INLINE spend #-}
+
+-- | Spend steps for converting an operand to a string or number, which
+-- costs as many steps as an array operand has elements, at any depth.
+-- Every other value converts for free.
+spendOnConversion :: At -> Val -> Run ()
+spendOnConversion at value = case value of
+  VArr _ -> Run $ \budget -> unRun (spend at (valWeightUpTo (budget + 1) value)) budget
+  _ -> pure ()
+
+-- ---------------------------------------------------------------------
+-- Compiling and running
+-- ---------------------------------------------------------------------
+
+-- | A rule compiled to a function of the data.
+type Eval = Val -> Run Val
+
+-- | A compiled node: its code, and its value if it is a literal (so that a
+-- parent can do work ahead of time, such as splitting a @var@ path once).
+data Compiled = Compiled
+  { compiledLiteral :: Maybe Val
+  , compiledEval :: Eval
+  }
+
+-- | A compiled rule, with the configuration it was compiled with.
+data Program = Program !Config Eval
+
+-- | Compile a rule once: convert its literals, resolve its custom operators
+-- against the registry, and build each node's code.
+compileRule :: Config -> Operators -> Rule -> Program
+compileRule config operators rule =
+  Program config (compiledEval (foldFix (compileNode config operators) rule))
+
+-- | Run a compiled rule against some data, with the configured step budget.
+runProgram :: Program -> Aeson.Value -> Either (Location, EvalError) Aeson.Value
+runProgram (Program config eval) input =
+  case unRun (eval (fromValue input)) (maxSteps config) of
+    Failed location e -> Left (location, e)
+    Done result _ -> Right (toValue result)
+
+-- | Compile and run with 'defaultConfig' and no custom operators.
+evaluate :: Rule -> Aeson.Value -> Either (Location, EvalError) Aeson.Value
+evaluate = runProgram . compileRule defaultConfig noOperators
+
+-- | One layer of the fold: given each child already compiled, compile this
+-- node. Evaluating a node costs one step, before its own work.
+compileNode :: Config -> Operators -> Node Compiled -> Compiled
+compileNode config operators (Node location node) = Compiled literal (\env -> spend at 1 >> body env)
+  where
+    at = At config location
+    literal = case node of
+      Lit value -> Just (fromValue value)
+      _ -> Nothing
+    body = case node of
+      -- the path of a @var@ is almost always a literal: read it once, here
+      Var path fallback
+        | Just key <- compiledLiteral path ->
+            let known = pathOf key
+             in variable at (compiledEval path) (compiledEval <$> fallback) (const (pure known))
+      _ -> compileBody at operators (fmap compiledEval node)
+
+compileBody :: At -> Operators -> RuleF Eval -> Eval
+compileBody at@(At config _) operators node = case node of
+  Lit literal ->
+    -- converted once, when the rule is compiled, and shared by every run
+    let converted = fromValue literal in const (pure converted)
+  Arr items ->
+    let elements = Vector.fromList items in \env -> VArr <$> Vector.mapM ($ env) elements
+  Var path fallback -> variable at path fallback (pure . pathOf)
+  Missing keys -> \env -> do
+    names <- evalAll keys env
+    mapM_ (spendOnConversion at) names
+    let (work, absent) = missingKeys (workCap config) names env
+    spend at work
+    pure (VArr (Vector.fromList absent))
+  MissingSome needed options -> \env -> do
+    count <- needed env
+    listed <- options env
+    missingSome at count listed env
+  If branches -> evalIf branches
+  And operands -> evalAnd operands
+  Or operands -> evalOr operands
+  Not operand -> fmap (VBool . not . truthy) . operand
+  DoubleNot operand -> fmap (VBool . truthy) . operand
+  LooseEq x y -> coercing at (\a b -> VBool (looseEquals a b)) x y
+  StrictEq x y -> binary (\a b -> VBool (strictEquals a b)) x y
+  LooseNeq x y -> coercing at (\a b -> VBool (not (looseEquals a b))) x y
+  StrictNeq x y -> binary (\a b -> VBool (not (strictEquals a b))) x y
+  Lt x y z -> between at lessThan x y z
+  Lte x y z -> between at lessOrEqual x y z
+  Gt x y -> coercing at (\a b -> VBool (lessThan b a)) x y
+  Gte x y -> coercing at (\a b -> VBool (lessOrEqual b a)) x y
+  Max operands -> fmap (VNum . maximumJs . map toNumber) . numeric at operands
+  Min operands -> fmap (VNum . minimumJs . map toNumber) . numeric at operands
+  Add operands ->
+    fmap (VNum . List.foldl' (\total v -> total + parseFloat v) 0) . numeric at operands
+  Sub x y -> \env -> do
+    a <- x env
+    b <- definedOperand <$> traverse ($ env) y
+    spendOnConversion at a
+    mapM_ (spendOnConversion at) b
+    pure (VNum (maybe (negate (toNumber a)) (\v -> toNumber a - toNumber v) b))
+  Mul [] -> const (failAt at (InvalidOperand (builtinOperatorName BMul)))
+  Mul (first : rest) -> \env -> do
+    -- the reference folds without a seed: a single operand is returned as
+    -- it is, not converted to a number
+    a <- first env
+    bs <- evalAll rest env
+    mapM_ (spendOnConversion at) (a : bs)
+    pure (List.foldl' (\acc v -> VNum (parseFloat acc * parseFloat v)) a bs)
+  Div x y -> coercing at (\a b -> VNum (toNumber a / toNumber b)) x y
+  Mod x y -> coercing at (\a b -> VNum (remainderJs (toNumber a) (toNumber b))) x y
+  Map collection logic ->
+    withCollection collection (VArr Vector.empty) $ \items -> do
+      guardInput at items
+      VArr <$> Vector.mapM logic items
+  Filter collection logic ->
+    withCollection collection (VArr Vector.empty) $ \items -> do
+      guardInput at items
+      VArr <$> Vector.filterM (fmap truthy . logic) items
+  Reduce collection logic initial -> reduceRule collection logic initial
+  All collection logic ->
+    withCollection collection (VBool False) $ \items ->
+      if Vector.null items then pure (VBool False) else VBool <$> allTruthy logic (Vector.toList items)
+  None collection logic ->
+    withCollection collection (VBool True) $ \items ->
+      if Vector.null items then pure (VBool True) else VBool . not <$> anyTruthy logic (Vector.toList items)
+  Some collection logic ->
+    withCollection collection (VBool False) $ \items ->
+      if Vector.null items then pure (VBool False) else VBool <$> anyTruthy logic (Vector.toList items)
+  Merge operands -> \env -> do
+    values <- evalAll operands env
+    let pieces = map flattenOnce values
+        size = List.foldl' (\total piece -> total + Vector.length piece) 0 pieces
+    when (size > maxArrayLength config) $ exceeded at (ArrayLimit . maxArrayLength)
+    spend at size
+    pure (VArr (Vector.concat pieces))
+  In needle haystack -> coercing at (\a b -> VBool (contains a b)) needle haystack
+  Cat operands -> \env -> do
+    values <- evalAll operands env
+    mapM_ (spendOnConversion at) values
+    let pieces = map toJoinText values
+    total <- foldM (addLength at) 0 pieces
+    spend at total
+    pure (VStr (Text.concat pieces))
+  Substr source start len -> \env -> do
+    text <- source env
+    from <- start env
+    limit <- definedOperand <$> traverse ($ env) len
+    spendOnConversion at text
+    pure (VStr (substring (toJsString text) from limit))
+  Log operand -> operand
+  Discard inner extras -> \env -> do
+    result <- inner env
+    mapM_ ($ env) extras
+    pure result
+  CustomOp name operands -> case lookupOperator operators name of
+    Nothing -> \env -> evalAll operands env >> failAt at (UnknownOperator name)
+    Just implementation -> \env -> do
+      arguments <- evalAll operands env
+      case implementation (toValue env) (map toValue arguments) of
+        Left reason -> failAt at (CustomOperatorFailed name reason)
+        Right result -> do
+          when (nestsDeeperThan (maxDepth config) result) $
+            exceeded at (ValueDepthLimit . maxDepth)
+          spend at (jsonWeightUpTo (maxSteps config + 1) result)
+          pure (fromValue result)
+
+-- | @var@: evaluate the path (for its step and its failures), turn its value
+-- into a 'Path' (ahead of time, when it is a literal), and look it up.
+variable :: At -> Eval -> Maybe Eval -> (Val -> Run Path) -> Eval
+variable at@(At config _) path fallback toPath env = do
+  key <- path env >>= toPath
+  notFound <- maybe (pure VNull) ($ env) fallback
+  let (work, found) = lookupPath (workCap config) env key notFound
+  spend at work
+  pure found
+
+-- ---------------------------------------------------------------------
+-- Combinators over compiled operands
+-- ---------------------------------------------------------------------
+
+evalAll :: [Eval] -> Val -> Run [Val]
+evalAll operands env = traverse ($ env) operands
+
+-- | Two operands, no conversion cost (the combiner never walks an array).
+binary :: (Val -> Val -> Val) -> Eval -> Eval -> Eval
+binary combine x y env = combine <$> x env <*> y env
+
+-- | Two operands that the combiner may convert to a string or number,
+-- which costs as many steps as an array operand has elements.
+coercing :: At -> (Val -> Val -> Val) -> Eval -> Eval -> Eval
+coercing at combine x y env = do
+  a <- x env
+  b <- y env
+  spendOnConversion at a
+  spendOnConversion at b
+  pure (combine a b)
+
+-- | Operands about to be converted to numbers, charged for the conversion.
+numeric :: At -> [Eval] -> Val -> Run [Val]
+numeric at operands env = do
+  values <- evalAll operands env
+  mapM_ (spendOnConversion at) values
+  pure values
+
+-- | @x op y@, or the chained @x op y && y op z@ when a third operand is
+-- given. All operands are evaluated first, as in the reference.
+between :: At -> (Val -> Val -> Bool) -> Eval -> Eval -> Maybe Eval -> Eval
+between at holds x y z env = do
+  a <- x env
+  b <- y env
+  c <- definedOperand <$> traverse ($ env) z
+  mapM_ (spendOnConversion at) [a, b]
+  mapM_ (spendOnConversion at) c
+  pure (VBool (maybe (holds a b) (\third -> holds a b && holds b third) c))
+
+-- | Condition/consequent pairs, then an optional else; no branch means null.
+--
+-- Hand-written recursion (Standard 4.1): a search that stops at the first
+-- condition that holds, in 'Run'. It is a right fold with early exit, and
+-- @base@ has no monadic combinator for that shape.
+evalIf :: [Eval] -> Eval
+evalIf (condition : consequent : rest) env = do
+  decided <- condition env
+  if truthy decided then consequent env else evalIf rest env
+evalIf [fallback] env = fallback env
+evalIf [] _ = pure VNull
+
+-- | The first falsy operand, or the last; JavaScript's @undefined@ (not
+-- null) when there are none.
+--
+-- Hand-written recursion (Standard 4.1): a right fold with early exit in
+-- 'Run'; @base@ has no monadic combinator for that shape.
+evalAnd :: [Eval] -> Eval
+evalAnd [] _ = pure VUndefined
+evalAnd [only] env = only env
+evalAnd (first : rest) env = do
+  value <- first env
+  if truthy value then evalAnd rest env else pure value
+
+-- | The first truthy operand, or the last; JavaScript's @undefined@ (not
+-- null) when there are none.
+--
+-- Hand-written recursion (Standard 4.1): the dual of 'evalAnd'.
+evalOr :: [Eval] -> Eval
+evalOr [] _ = pure VUndefined
+evalOr [only] env = only env
+evalOr (first : rest) env = do
+  value <- first env
+  if truthy value then pure value else evalOr rest env
+
+-- | Evaluate a collection operand; run @onItems@ if it is an array,
+-- otherwise answer @fallback@.
+withCollection :: Eval -> Val -> (Vector Val -> Run Val) -> Eval
+withCollection collection fallback onItems env = do
+  evaluated <- collection env
+  case evaluated of
+    VArr items -> onItems items
+    _ -> pure fallback
+
+-- | Refuse to iterate an array longer than the array limit, before doing
+-- any of the work.
+guardInput :: At -> Vector Val -> Run ()
+guardInput at@(At config _) items =
+  when (Vector.length items > maxArrayLength config) $ exceeded at (ArrayLimit . maxArrayLength)
+
+-- | Add a piece's length (in UTF-16 code units) to a running total,
+-- failing as soon as the total passes the string limit.
+addLength :: At -> Int -> Text -> Run Int
+addLength at@(At config _) total piece
+  | total' > maxStringLength config = exceeded at (StringLimit . maxStringLength)
+  | otherwise = pure total'
+  where
+    total' = total + utf16Length piece
+
+-- | Fold the array with the sub-rule, evaluated against an object holding
+-- the current element and the accumulator.
+reduceRule :: Eval -> Eval -> Eval -> Eval
+reduceRule collection logic initial env = do
+  evaluated <- collection env
+  start <- initial env
+  case evaluated of
+    VArr items -> Vector.foldM' step start items
+    _ -> pure start
+  where
+    step accumulator item =
+      logic (VObj (KeyMap.fromList [("current", item), ("accumulator", accumulator)]))
+
+-- | Does the sub-rule hold for every item? Stops at the first that does not.
+--
+-- Hand-written recursion (Standard 4.1): a monadic @all@, which @base@ does
+-- not provide, in 'Run'.
+allTruthy :: Eval -> [Val] -> Run Bool
+allTruthy _ [] = pure True
+allTruthy logic (item : rest) = do
+  value <- logic item
+  if truthy value then allTruthy logic rest else pure False
+
+-- | Does the sub-rule hold for some item? Stops at the first that does.
+--
+-- Hand-written recursion (Standard 4.1): a monadic @any@, the dual of
+-- 'allTruthy'.
+anyTruthy :: Eval -> [Val] -> Run Bool
+anyTruthy _ [] = pure False
+anyTruthy logic (item : rest) = do
+  value <- logic item
+  if truthy value then pure True else anyTruthy logic rest
+
+-- ---------------------------------------------------------------------
+-- Operator semantics
+-- ---------------------------------------------------------------------
+
+-- | @merge@ flattens one level; anything that is not an array is one
+-- element.
+flattenOnce :: Val -> Vector Val
+flattenOnce (VArr items) = items
+flattenOnce other = Vector.singleton other
+
+-- | @in@: substring of a string, or strict-equal member of an array.
+-- A falsy container (including the empty string) contains nothing.
+contains :: Val -> Val -> Bool
+contains _ (VStr s) | Text.null s = False
+contains needle (VStr s) = Text.isInfixOf (toJsString needle) s
+contains needle (VArr items) = Vector.any (strictEquals needle) items
+contains _ _ = False
+
+-- | Which of the keys have no value, or an empty-string one. If the first
+-- argument is itself an array, it is the list of keys.
+--
+-- The first component is the work the lookups did, in steps (see
+-- 'lookupVariable'); the cap bounds how far any one lookup is followed.
+missingKeys :: Int -> [Val] -> Val -> (Int, [Val])
+missingKeys cap args dat =
+  ( List.foldl' (+) 0 [work | (_, work, _) <- probes]
+  , [key | (key, _, True) <- probes]
+  )
+  where
+    keys = case args of
+      VArr listed : _ -> Vector.toList listed
+      _ -> args
+    probes = map probe keys
+    probe key =
+      let (work, found) = lookupKey key
+       in (key, work, isMissing found)
+    isMissing found = case found of
+      VNull -> True
+      VStr s -> Text.null s
+      _ -> False
+    -- The reference looks each key up with @{"var": key}@, so a key that is
+    -- itself an array is a @var@'s argument list: path, then default.
+    lookupKey key = case key of
+      VArr listed -> case Vector.toList listed of
+        [] -> (0, dat)
+        path : rest -> lookupVariable cap dat path (case rest of fallback : _ -> fallback; [] -> VNull)
+      _ -> lookupVariable cap dat key VNull
+
+-- | @missing_some@: nothing is missing if at least @count@ of the options
+-- are present; otherwise the missing ones.
+--
+-- The reference reads @options.length@, so a @null@ or undefined option
+-- list throws; a string counts its code units; anything without a length
+-- compares as @NaN@, which is never enough, so the missing ones are
+-- returned.
+missingSome :: At -> Val -> Val -> Val -> Run Val
+missingSome at@(At config _) count options env = case options of
+  VNull -> failAt at (InvalidOperand (builtinOperatorName BMissingSome))
+  VUndefined -> failAt at (InvalidOperand (builtinOperatorName BMissingSome))
+  _ -> do
+    spendOnConversion at options
+    let names = case options of
+          VArr items -> Vector.toList items
+          other -> [other]
+        (work, absent) = missingKeys (workCap config) names env
+    spend at work
+    let have = case optionLength of
+          Just n -> n - fromIntegral (length absent)
+          Nothing -> 0 / 0
+    pure (if lessOrEqual count (VNum have) then VArr Vector.empty else VArr (Vector.fromList absent))
+  where
+    optionLength = case options of
+      VArr items -> Just (fromIntegral (Vector.length items))
+      VStr s -> Just (fromIntegral (utf16Length s))
+      VObj fields -> toNumber <$> KeyMap.lookup "length" fields
+      _ -> Nothing
+
+-- | The most work a lookup is followed for: one more than the whole budget,
+-- so a lookup that cannot be paid for is cut short rather than completed.
+workCap :: Config -> Int
+workCap config = maxSteps config + 1
+
+-- | A @var@ path, read: the data itself, or the property names to follow.
+data Path = WholeData | Steps [Text]
+
+-- | Read a @var@ path the way the reference does: an empty or null path is
+-- the data itself; anything else is converted to a string and split at dots.
+pathOf :: Val -> Path
+pathOf VNull = WholeData
+pathOf VUndefined = WholeData
+pathOf (VStr s) | Text.null s = WholeData
+pathOf key = Steps (Text.splitOn "." (toJsString key))
+
+-- | @var@: look a path up in the data; a missing step gives the fallback.
+lookupVariable :: Int -> Val -> Val -> Val -> (Int, Val)
+lookupVariable cap dat = lookupPath cap dat . pathOf
+
+-- | Follow a path. The first component is the work the walk did, in steps:
+-- one per code unit of a string that is indexed or measured (see
+-- 'property'). The caller charges it. It is counted only up to @cap@.
+lookupPath :: Int -> Val -> Path -> Val -> (Int, Val)
+lookupPath _ dat WholeData _ = (0, dat)
+lookupPath cap dat (Steps keys) notFound = (work, fromMaybe (nullIfUndefined notFound) found)
+  where
+    (work, found) = descend cap dat keys
+
+-- | An optional operand that evaluates to undefined counts as absent: the
+-- reference tests the /value/ (@c === undefined@), not how many
+-- arguments were written. So @{"-": [x, y]}@ negates @x@ when @y@ is
+-- undefined, @<@ and @<=@ drop an undefined third operand, and @substr@
+-- ignores an undefined end.
+definedOperand :: Maybe Val -> Maybe Val
+definedOperand (Just VUndefined) = Nothing
+definedOperand other = other
+
+-- | The reference's fallback for a missing variable: an undefined
+-- fallback becomes null.
+nullIfUndefined :: Val -> Val
+nullIfUndefined VUndefined = VNull
+nullIfUndefined value = value
+
+-- | Follow the path one key at a time; 'Nothing' is "no such property".
+-- The first component is the work done, summed over the keys and given up
+-- as soon as it passes @cap@ (the caller then fails the evaluation).
+descend :: Int -> Val -> [Text] -> (Int, Maybe Val)
+descend cap = go 0
+  where
+    go spent current keys
+      | spent > cap = (spent, Nothing)
+      | otherwise = case keys of
+          [] -> (spent, Just current)
+          key : rest -> case current of
+            VNull -> (spent, Nothing)
+            VUndefined -> (spent, Nothing)
+            _ ->
+              let (work, found) = property cap current key
+                  spent' = spent + work
+               in case found of
+                    Nothing -> (spent', Nothing)
+                    -- a property that is itself undefined counts as missing,
+                    -- as in the reference
+                    Just VUndefined -> (spent', Nothing)
+                    Just next -> go spent' next rest
+
+-- | JavaScript property access on a JSON value: object keys, array and
+-- string indices, and @length@. Only a value's own properties are seen:
+-- the reference would also find names inherited through the prototype
+-- chain (@constructor@, @toString@, ...), which JSON data does not have
+-- (docs/COMPATIBILITY.md, DEV-INHERITED).
+--
+-- The first component is the work, in steps: object and array access is
+-- constant time and free; indexing or measuring a string walks it, and costs
+-- its length in code points, counted up to @cap@.
+property :: Int -> Val -> Text -> (Int, Maybe Val)
+property _ (VObj fields) key = (0, KeyMap.lookup (Key.fromText key) fields)
+property _ (VArr items) key
+  | key == "length" = (0, Just (VNum (fromIntegral (Vector.length items))))
+  | otherwise = (0, arrayIndex key >>= (items Vector.!?))
+property cap (VStr s) key
+  | key == "length" = (size, Just (VNum (fromIntegral (utf16Length s))))
+  | otherwise = case arrayIndex key of
+      Nothing -> (0, Nothing)
+      Just i -> (size, VStr <$> utf16CharAt i s)
+  where
+    size = min cap (Text.length s)
+property _ _ _ = (0, Nothing)
+
+-- | A canonical array index: digits, no leading zero, below 10^9.
+arrayIndex :: Text -> Maybe Int
+arrayIndex key
+  | Text.null key || Text.length key > 9 = Nothing
+  | not (Text.all isDigit key) = Nothing
+  | Text.length key > 1 && "0" `Text.isPrefixOf` key = Nothing
+  | otherwise = Just (Text.foldl' (\acc c -> acc * 10 + digitToInt c) 0 key)
+
+-- | @substr@: JavaScript's @substr@, plus the reference's PHP-style
+-- twist that a negative third argument means "stop that many characters
+-- before the end" rather than a negative length.
+substring :: Text -> Val -> Maybe Val -> Text
+substring source start end = case end of
+  Just limit | lessThan limit (VNum 0) -> negativeEnd limit
+  _ -> substrJs source (toNumber start) (fmap toNumber end)
+  where
+    tailText = substrJs source (toNumber start) Nothing
+    negativeEnd (VNum n) =
+      substrJs tailText 0 (Just (fromIntegral (utf16Length tailText) + n))
+    negativeEnd _ = ""
+
+-- | @String.prototype.substr(start, length)@, in UTF-16 code units.
+substrJs :: Text -> Double -> Maybe Double -> Text
+substrJs source start len = utf16Slice from count source
+  where
+    size = utf16Length source
+    relative = toClampedInt (negate size, size) start
+    from = if relative < 0 then size + relative else relative
+    available = size - from
+    count = maybe available (toClampedInt (0, available)) len
+
+-- | Truncate toward zero, clamped into the inclusive range; @NaN@ is zero.
+toClampedInt :: (Int, Int) -> Double -> Int
+toClampedInt (low, high) d
+  | isNaN d = max low (min high 0)
+  | otherwise = truncate (max (fromIntegral low) (min (fromIntegral high) d))
diff --git a/src/JsonLogic/Core/Location.hs b/src/JsonLogic/Core/Location.hs
new file mode 100644
--- /dev/null
+++ b/src/JsonLogic/Core/Location.hs
@@ -0,0 +1,71 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- |
+-- Module      : JsonLogic.Core.Location
+--
+-- Where in a rule something is: a path from the root of the rule's JSON to
+-- one node, written as a JSON Pointer (RFC 6901) when rendered.
+--
+-- In @{"if": [true, {"*": []}]}@ the @*@ is at @/if/1@: the key @if@, then
+-- index 1 of its argument list. An operator written with a bare argument
+-- instead of a list (@{"!": x}@) has that argument at @/!@. The root is the
+-- empty pointer, @""@.
+--
+-- A JSON Pointer names a place in the rule's own JSON text, so a location is
+-- meaningful to a tool in any language. Locations are attached to every
+-- parse and evaluation error.
+module JsonLogic.Core.Location
+  ( Location
+  , Segment (..)
+  , root
+  , child
+  , fromSegments
+  , locationSegments
+  , renderLocation
+  ) where
+
+import Data.Text (Text)
+import qualified Data.Text as Text
+
+-- | One step from a JSON value into a part of it.
+data Segment
+  = -- | An object member, by key.
+    Key !Text
+  | -- | An array element, by zero-based index.
+    Index !Int
+  deriving (Eq, Ord, Show)
+
+-- | A path from the root of a rule. Held innermost-first, so that stepping
+-- into a child while reading a rule is constant time.
+newtype Location = Location [Segment]
+  deriving (Eq, Ord)
+
+-- | Shown as the 'fromSegments' call that rebuilds it.
+instance Show Location where
+  showsPrec d location =
+    showParen (d > 10) (showString "fromSegments " . showsPrec 11 (locationSegments location))
+
+-- | The root of the rule.
+root :: Location
+root = Location []
+
+-- | One step further in.
+child :: Location -> Segment -> Location
+child (Location reversed) segment = Location (segment : reversed)
+
+-- | A location from its segments, outermost first.
+fromSegments :: [Segment] -> Location
+fromSegments = Location . reverse
+
+-- | The segments of a location, outermost first.
+locationSegments :: Location -> [Segment]
+locationSegments (Location reversed) = reverse reversed
+
+-- | The location as a JSON Pointer (RFC 6901): @""@ for the root,
+-- otherwise @/@ before each segment, with @~@ written @~0@ and @/@ written
+-- @~1@ inside keys.
+renderLocation :: Location -> Text
+renderLocation = foldMap (("/" <>) . renderSegment) . locationSegments
+  where
+    renderSegment (Key key) = Text.replace "/" "~1" (Text.replace "~" "~0" key)
+    renderSegment (Index i) = Text.pack (show i)
diff --git a/src/JsonLogic/Core/Number.hs b/src/JsonLogic/Core/Number.hs
new file mode 100644
--- /dev/null
+++ b/src/JsonLogic/Core/Number.hs
@@ -0,0 +1,463 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- |
+-- Module      : JsonLogic.Core.Number
+--
+-- JavaScript numbers: reading decimal text into a double, writing a double
+-- as the text JavaScript would, and the arithmetic helpers whose JavaScript
+-- definitions differ from Haskell's.
+--
+-- Every conversion is exact: a decimal is rounded to the nearest double
+-- once, and a double is written with the shortest digits that read back as
+-- it, chosen as ECMAScript chooses them. Reading has a fast path for the
+-- common case, taken only when it is provably exact, and an exact general
+-- path in 'Rational'; writing uses Ryū. The test suite checks the fast
+-- algorithms against slow, exact ones.
+module JsonLogic.Core.Number
+  ( -- * Reading
+    decimalToDouble
+  , scientificToDouble
+  , stringToNumber
+  , parseFloatString
+  , isJsSpace
+
+    -- * Writing
+  , numberToString
+  , doubleToScientific
+  , shortestDigits
+  , shortestDigitsExact
+
+    -- * Arithmetic
+  , remainderJs
+  , maximumJs
+  , minimumJs
+  ) where
+
+import Data.Bifunctor (first)
+import Data.Bits (shiftL, shiftR, (.&.))
+import Data.Char (digitToInt, isDigit, isHexDigit)
+import Data.Int (Int64)
+import Data.List (dropWhileEnd, sortOn, stripPrefix)
+import qualified Data.List as List
+import Data.Maybe (fromMaybe, listToMaybe, mapMaybe)
+import Data.Scientific (Scientific, base10Exponent, coefficient, scientific)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import Numeric (floatToDigits)
+
+import JsonLogic.Core.Ryu (shortestDecimalRyu)
+
+nan :: Double
+nan = 0 / 0
+
+infinity :: Double
+infinity = 1 / 0
+
+-- | 2^53: below it every integer is a double, and so is every product or
+-- quotient of such an integer and a power of ten up to 10^22 correctly
+-- rounded by one IEEE operation.
+twoTo53 :: Double
+twoTo53 = 9007199254740992
+
+-- | @10^n@ for @0 <= n <= 22@, the powers of ten that are exact doubles.
+-- Every intermediate product of '(^)' is a smaller power of ten, so exact too.
+powerOfTen :: Integer -> Double
+powerOfTen n = 10 ^ n
+
+-- ---------------------------------------------------------------------
+-- Reading
+-- ---------------------------------------------------------------------
+
+-- | A JSON number as the double JavaScript's @JSON.parse@ would make of it.
+scientificToDouble :: Scientific -> Double
+scientificToDouble s = decimalToDouble (coefficient s) (toInteger (base10Exponent s))
+
+-- | @mantissa * 10 ^ exponent10@ as the nearest double (ties to even).
+--
+-- When the mantissa is below 2^53 and the exponent within 22 of zero, both
+-- factors are exact doubles and one IEEE multiplication or division rounds
+-- the result correctly (Clinger's fast path, \"How to read floating point
+-- numbers accurately\", 1990). Otherwise the value is computed exactly in
+-- integers and rounded once ('roundRatio'). Magnitudes that must overflow or
+-- underflow are decided from the digit count, so a hostile exponent cannot
+-- make this build an enormous number.
+decimalToDouble :: Integer -> Integer -> Double
+decimalToDouble mantissa exponent10
+  | mantissa == 0 = 0
+  | mantissa < 0 = negate (decimalToDouble (negate mantissa) exponent10)
+  | mantissa < 9007199254740992 && 0 <= exponent10 && exponent10 <= 22 =
+      fromInteger mantissa * powerOfTen exponent10
+  | mantissa < 9007199254740992 && -22 <= exponent10 && exponent10 < 0 =
+      fromInteger mantissa / powerOfTen (negate exponent10)
+  | magnitude > 310 = infinity
+  | magnitude < -330 = 0
+  | exponent10 >= 0 = roundRatio (mantissa * 10 ^ exponent10) 1
+  | otherwise = roundRatio mantissa (10 ^ negate exponent10)
+  where
+    magnitude = exponent10 + toInteger (length (show mantissa))
+
+-- | @numerator / denominator@, both positive, as the nearest double, ties to
+-- even, including the subnormal range and overflow to infinity.
+--
+-- The quotient is taken with at least 56 significant bits; the bits below the
+-- 53 a double keeps (fewer for a subnormal), together with whether the
+-- division left a remainder, decide the rounding exactly.
+roundRatio :: Integer -> Integer -> Double
+roundRatio numerator denominator
+  | binaryExponent >= 1024 = infinity
+  | otherwise = encodeFloat rounded lowestBit
+  where
+    -- scale so that the quotient has at least 56 bits
+    scale = max 0 (56 + bitLength denominator - bitLength numerator)
+    (quotient, remainder) = (numerator `shiftL` scale) `quotRem` denominator
+    width = bitLength quotient
+    -- the value is quotient * 2^-scale, so its leading bit is worth 2^binaryExponent
+    binaryExponent = width - 1 - scale
+    -- the weight of the last bit kept: 52 below the leading one, but never
+    -- below the smallest subnormal's
+    lowestBit = max (binaryExponent - 52) (-1074)
+    dropped = lowestBit + scale
+    kept = quotient `shiftR` dropped
+    below = quotient .&. ((1 `shiftL` dropped) - 1)
+    half = 1 `shiftL` (dropped - 1)
+    roundsUp = below > half || (below == half && (remainder /= 0 || odd kept))
+    rounded = if roundsUp then kept + 1 else kept
+
+-- | The number of bits in a positive integer.
+bitLength :: Integer -> Int
+bitLength = go 0
+  where
+    go acc v
+      | v >= 18446744073709551616 = go (acc + 64) (v `shiftR` 64)
+      | v == 0 = acc
+      | otherwise = go (acc + 1) (v `shiftR` 1)
+
+-- | ECMAScript's @WhiteSpace@ and @LineTerminator@ code points, which is what
+-- string-to-number conversion trims. Listed explicitly rather than taken from
+-- @Data.Char.isSpace@ because the two differ: U+FEFF, U+2028 and U+2029 are
+-- trimmed by JavaScript and not by @isSpace@, while U+0085 and U+180E are
+-- trimmed by neither (U+180E stopped being a space in Unicode 6.3).
+isJsSpace :: Char -> Bool
+isJsSpace c = case fromEnum c of
+  0x0009 -> True
+  0x000A -> True
+  0x000B -> True
+  0x000C -> True
+  0x000D -> True
+  0x0020 -> True
+  0x00A0 -> True
+  0x1680 -> True
+  0x2028 -> True
+  0x2029 -> True
+  0x202F -> True
+  0x205F -> True
+  0x3000 -> True
+  0xFEFF -> True
+  n -> n >= 0x2000 && n <= 0x200A
+
+-- | The string-to-number conversion of JavaScript's @Number(string)@: the
+-- whole trimmed string must be a literal (decimal, @Infinity@, or an
+-- unsigned @0x@, @0o@ or @0b@ literal); the empty string is zero; anything
+-- else is @NaN@.
+stringToNumber :: Text -> Double
+stringToNumber source
+  | null trimmed = 0
+  | otherwise = case radixLiteral trimmed of
+      Just number -> number
+      Nothing -> case decimalPrefix trimmed of
+        Just (number, []) -> number
+        _ -> nan
+  where
+    trimmed = dropWhileEnd isJsSpace (dropWhile isJsSpace (Text.unpack source))
+
+-- | JavaScript's @parseFloat@ on a string: the longest decimal prefix after
+-- leading whitespace, ignoring what follows; @NaN@ if there is none. Unlike
+-- 'stringToNumber' it accepts trailing junk and does not read @0x@ literals.
+parseFloatString :: Text -> Double
+parseFloatString source =
+  case decimalPrefix (dropWhile isJsSpace (Text.unpack source)) of
+    Just (number, _) -> number
+    Nothing -> nan
+
+-- | @0x..@, @0o..@, @0b..@ literals (unsigned, at least one digit).
+radixLiteral :: String -> Maybe Double
+radixLiteral ('0' : marker : digits)
+  | not (null digits)
+  , Just base <- radixOf marker
+  , all (isDigitIn base) digits =
+      Just (radixValue base digits)
+radixLiteral _ = Nothing
+
+-- | The value of a digit string in a power-of-two base. Anything with more
+-- than 1100 significant bits overflows a double, and is decided from the
+-- digit count so a hostile million-digit literal costs nothing.
+radixValue :: Integer -> String -> Double
+radixValue base digits
+  | length significant * bitsPerDigit > 1100 = infinity
+  | otherwise = fromRational (fromInteger (List.foldl' (\acc c -> acc * base + toInteger (digitToInt c)) 0 significant))
+  where
+    significant = dropWhile (== '0') digits
+    bitsPerDigit = case base of
+      16 -> 4
+      8 -> 3
+      _ -> 1
+
+radixOf :: Char -> Maybe Integer
+radixOf 'x' = Just 16
+radixOf 'X' = Just 16
+radixOf 'o' = Just 8
+radixOf 'O' = Just 8
+radixOf 'b' = Just 2
+radixOf 'B' = Just 2
+radixOf _ = Nothing
+
+isDigitIn :: Integer -> Char -> Bool
+isDigitIn base c = isHexDigit c && toInteger (digitToInt c) < base
+
+-- | The longest prefix of the input that is a decimal literal
+-- (@[+-]digits[.digits][e[+-]digits]@, @.digits@, or @[+-]Infinity@),
+-- with its value and the unread remainder.
+decimalPrefix :: String -> Maybe (Double, String)
+decimalPrefix input =
+  case stripPrefix "Infinity" afterSign of
+    Just remainder -> Just (applySign infinity, remainder)
+    Nothing -> fmap (first applySign) (unsignedDecimal afterSign)
+  where
+    (negative, afterSign) = case input of
+      '-' : rest -> (True, rest)
+      '+' : rest -> (False, rest)
+      _ -> (False, input)
+    applySign x = if negative then negate x else x
+
+unsignedDecimal :: String -> Maybe (Double, String)
+unsignedDecimal s
+  | null intDigits && null fracDigits = Nothing
+  | otherwise = Just (assemble intDigits fracDigits exponentValue, remainder)
+  where
+    (intDigits, afterInt) = span isDigit s
+    (fracDigits, afterFrac) = case afterInt of
+      '.' : rest -> span isDigit rest
+      _ -> ([], afterInt)
+    (exponentValue, remainder) = exponentPart afterFrac
+
+-- | An optional exponent. If the marker is not followed by digits it is
+-- not part of the literal and is left unread.
+exponentPart :: String -> (Integer, String)
+exponentPart input = case input of
+  marker : rest
+    | marker == 'e' || marker == 'E' ->
+        let (isNegative, afterSign) = case rest of
+              '-' : more -> (True, more)
+              '+' : more -> (False, more)
+              _ -> (False, rest)
+            (digits, remainder) = span isDigit afterSign
+         in if null digits
+              then (0, input)
+              else (withSign isNegative (exponentMagnitude digits), remainder)
+  _ -> (0, input)
+  where
+    withSign flag n = if flag then negate n else n
+
+-- | An exponent's size, saturated at 10^9: every double is decided long
+-- before that, and a hostile exponent must not become a huge 'Integer'.
+exponentMagnitude :: String -> Integer
+exponentMagnitude digits
+  | length significant > 9 = 1000000000
+  | otherwise = digitsToInteger significant
+  where
+    significant = dropWhile (== '0') digits
+
+digitsToInteger :: String -> Integer
+digitsToInteger = List.foldl' (\acc c -> acc * 10 + toInteger (digitToInt c)) 0
+
+-- | Build the double from decimal digits and a power of ten, correctly
+-- rounded (see 'decimalToDouble').
+--
+-- Only the first 'significantDigits' digits are converted. A longer literal
+-- needs no more: 768 significant digits decide the rounding of every double
+-- (the longest exact decimal expansion of a halfway case), and any non-zero
+-- digit beyond them can only break a tie, which is recorded as one sticky
+-- trailing digit. That keeps the cost linear in the length of the literal;
+-- converting every digit is quadratic, and a million-digit string used in a
+-- number context would otherwise take minutes.
+assemble :: String -> String -> Integer -> Double
+assemble intDigits fracDigits exponentValue =
+  decimalToDouble mantissa (exponentValue - toInteger (length fracDigits) + dropped)
+  where
+    digits = dropWhile (== '0') (intDigits ++ fracDigits)
+    excess = length digits - significantDigits
+    (mantissa, dropped)
+      | excess <= 0 = (digitsToInteger digits, 0)
+      | any (/= '0') (drop significantDigits digits) =
+          (digitsToInteger (take significantDigits digits) * 10 + 1, toInteger excess - 1)
+      | otherwise = (digitsToInteger (take significantDigits digits), toInteger excess)
+
+-- | Digits kept when converting a decimal literal; see 'assemble'.
+significantDigits :: Int
+significantDigits = 800
+
+-- ---------------------------------------------------------------------
+-- Writing
+-- ---------------------------------------------------------------------
+
+-- | JavaScript's @Number::toString@ (ECMAScript, radix 10): the shortest
+-- digits that read back as the double, laid out in plain or exponent form.
+numberToString :: Double -> Text
+numberToString d
+  | isNaN d = "NaN"
+  | d == 0 = "0"
+  | isInfinite d = if d > 0 then "Infinity" else "-Infinity"
+  | d < 0 = "-" <> numberToString (negate d)
+  | Just whole <- wholeNumber d = Text.pack (show whole)
+  | otherwise = Text.pack (layoutDigits digits (length digits) (length digits + exponent10))
+  where
+    (coefficientDigits, exponent10) = shortestDecimal d
+    digits = show coefficientDigits
+
+-- | A finite double as the decimal JavaScript would print for it. Negative
+-- zero becomes zero, as @JSON.stringify@ writes it.
+doubleToScientific :: Double -> Scientific
+doubleToScientific d
+  | d == 0 = 0
+  | d < 0 = negate (doubleToScientific (negate d))
+  | otherwise = uncurry scientific (shortestDecimal d)
+
+-- | A positive double below 2^53 that is a whole number, as an integer.
+wholeNumber :: Double -> Maybe Int64
+wholeNumber d
+  | d < twoTo53 && fromIntegral whole == d = Just whole
+  | otherwise = Nothing
+  where
+    whole = truncate d
+
+-- | The digits ECMAScript prints for a positive finite double, as
+-- @(digits, pointPosition)@ in the convention of 'floatToDigits': the value
+-- is @0.d1d2...dn * 10^pointPosition@.
+shortestDigits :: Double -> ([Int], Int)
+shortestDigits d = (map digitToInt digits, length digits + exponent10)
+  where
+    (coefficientDigits, exponent10) = shortestDecimal d
+    digits = show coefficientDigits
+
+-- | The decimal ECMAScript prints for a positive finite double, as
+-- @(significand, exponent)@ with the value @significand * 10^exponent@ and no
+-- trailing zeros in the significand.
+--
+-- ECMAScript asks for the fewest digits that read back as the double, and
+-- among those the closest to it, and among two equally close the even one.
+-- A whole number below 2^53 is its own digits; anything else is Ryū
+-- ("JsonLogic.Core.Ryu"), which answers exactly that question.
+shortestDecimal :: Double -> (Integer, Int)
+shortestDecimal d = case wholeNumber d of
+  Just whole -> stripZeros (toInteger whole) 0
+  Nothing -> case shortestDecimalRyu d of
+    (digits, exponent10) -> stripZeros (toInteger digits) exponent10
+  where
+    stripZeros m e
+      | m /= 0 && m `rem` 10 == 0 = stripZeros (m `quot` 10) (e + 1)
+      | otherwise = (m, e)
+
+-- | 'shortestDigits' computed exactly, for every positive finite double.
+--
+-- GHC's 'floatToDigits' only accepts digit strings strictly inside the
+-- rounding interval of the double. ECMAScript also accepts the exact
+-- halfway point when the double's significand is even, because
+-- round-half-even reads it back to the same double. For doubles of 2^53 and
+-- above those points are whole numbers, so a shorter string can exist where
+-- GHC needs one more digit: @2.2531e21@, not @2.2531000000000001e21@. Below
+-- 2^53 the halfway points have too many digits to matter, so GHC's answer
+-- stands, except that when two candidates of the shortest length are equally
+-- close (@9007199254740993 / 10@ is halfway between @...099.2@ and @...099.3@)
+-- ECMAScript takes the even one, and 'floatToDigits' does not.
+shortestDigitsExact :: Double -> ([Int], Int)
+shortestDigitsExact d
+  | d < twoTo53 =
+      -- A tie between two equally short candidates needs 16 or more digits:
+      -- below that, decimal spacing is wider than the spacing of doubles.
+      if length digits >= 16 then fromMaybe generated (shorter (length digits)) else generated
+  | otherwise = fromMaybe generated (listToMaybe (mapMaybe shorter [1 .. length digits]))
+  where
+    generated@(digits, pointPosition) = floatToDigits 10 d
+    exact = toRational d
+    shorter k =
+      case sortOn rank (filter readsBack [lower, upper]) of
+        best : _ -> Just (digitsOf best k)
+        [] -> Nothing
+      where
+        scale = 10 ^^ (pointPosition - k) :: Rational
+        scaled = exact / scale
+        lower = floor scaled :: Integer
+        upper = ceiling scaled :: Integer
+        readsBack m = fromRational (fromInteger m * scale) == d
+        rank m = (abs (fromInteger m * scale - exact), odd m)
+    digitsOf m k = (map digitToInt (dropWhileEnd (== '0') text), pointPosition + length text - k)
+      where
+        text = show m
+
+-- | @layoutDigits ds k n@: @k@ significant digits @ds@ whose decimal
+-- point sits @n@ places to the right of their start, laid out as
+-- ECMAScript's @Number::toString@ does.
+layoutDigits :: String -> Int -> Int -> String
+layoutDigits ds k n
+  | k <= n && n <= 21 = ds ++ replicate (n - k) '0'
+  | 0 < n && n <= 21 = take n ds ++ "." ++ drop n ds
+  | -6 < n && n <= 0 = "0." ++ replicate (negate n) '0' ++ ds
+  | otherwise = exponentForm
+  where
+    power = n - 1
+    sign = if power < 0 then "-" else "+"
+    exponentForm = case ds of
+      [] -> ""
+      [c] -> c : "e" ++ sign ++ show (abs power)
+      c : cs -> c : '.' : cs ++ "e" ++ sign ++ show (abs power)
+
+-- ---------------------------------------------------------------------
+-- Arithmetic
+-- ---------------------------------------------------------------------
+
+-- | JavaScript's @%@ on doubles: the truncating remainder, which takes the
+-- dividend's sign, including when it is zero (@-4 % 2@ is @-0@). Exact:
+-- whole numbers below 2^53 use integer 'rem', anything else is computed in
+-- 'Rational'.
+remainderJs :: Double -> Double -> Double
+remainderJs x y
+  | isNaN x || isNaN y || isInfinite x || y == 0 = nan
+  | isInfinite y = x
+  | x == 0 = x
+  | otherwise = signed (magnitude (abs x) (abs y))
+  where
+    signed r
+      | x < 0 = negate r
+      | otherwise = r
+    magnitude a b = case (wholeNumber a, wholeNumber b) of
+      (Just m, Just n) -> fromIntegral (m `rem` n)
+      _ ->
+        let ra = toRational a
+            rb = toRational b
+         in fromRational (ra - rb * fromInteger (truncate (ra / rb)))
+
+-- | @Math.max@: @NaN@ if any argument is, @-Infinity@ for none, and
+-- @+0@ above @-0@ regardless of argument order.
+maximumJs :: [Double] -> Double
+maximumJs xs
+  | any isNaN xs = nan
+  | otherwise = List.foldl' larger (negate infinity) xs
+  where
+    larger a b
+      | a > b = a
+      | b > a = b
+      | isNegativeZero a = b
+      | otherwise = a
+
+-- | @Math.min@: @NaN@ if any argument is, @Infinity@ for none, and
+-- @-0@ below @+0@ regardless of argument order.
+minimumJs :: [Double] -> Double
+minimumJs xs
+  | any isNaN xs = nan
+  | otherwise = List.foldl' smaller infinity xs
+  where
+    smaller a b
+      | a < b = a
+      | b < a = b
+      | isNegativeZero a = a
+      | otherwise = b
diff --git a/src/JsonLogic/Core/Operator.hs b/src/JsonLogic/Core/Operator.hs
new file mode 100644
--- /dev/null
+++ b/src/JsonLogic/Core/Operator.hs
@@ -0,0 +1,126 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- |
+-- Module      : JsonLogic.Core.Operator
+--
+-- The names of operators, and the closed set of builtin ones.
+--
+-- This module is the single source of truth for which operator names are
+-- builtin. The parser, the custom-operator registry (which refuses builtin
+-- names), the test generators and the conformance runners all take the set
+-- from 'allBuiltins', so adding an operator is one constructor here plus the
+-- cases the compiler then demands, never an edit to a list kept elsewhere.
+module JsonLogic.Core.Operator
+  ( OperatorName (..)
+  , Builtin (..)
+  , allBuiltins
+  , builtinName
+  , builtinOperatorName
+  , lookupBuiltin
+  ) where
+
+import qualified Data.Map.Strict as Map
+import Data.Text (Text)
+
+-- | An operator's name, as it appears as the only key of a rule object.
+-- A newtype, so it cannot be confused with a variable path or any other
+-- string.
+newtype OperatorName = OperatorName Text
+  deriving (Eq, Ord, Show)
+
+-- | The operators of the classic JsonLogic specification
+-- (<https://jsonlogic.com/operations.html>), as implemented by the reference,
+-- json-logic-js 2.0.5. @?:@ is the reference's alias of @if@ and has its own
+-- constructor because it has its own name.
+data Builtin
+  = BVar
+  | BMissing
+  | BMissingSome
+  | BIf
+  | BTernary
+  | BAnd
+  | BOr
+  | BNot
+  | BDoubleNot
+  | BLooseEq
+  | BStrictEq
+  | BLooseNeq
+  | BStrictNeq
+  | BLt
+  | BLte
+  | BGt
+  | BGte
+  | BMax
+  | BMin
+  | BAdd
+  | BSub
+  | BMul
+  | BDiv
+  | BMod
+  | BMap
+  | BFilter
+  | BReduce
+  | BAll
+  | BNone
+  | BSome
+  | BMerge
+  | BIn
+  | BCat
+  | BSubstr
+  | BLog
+  deriving (Eq, Ord, Show, Enum, Bounded)
+
+-- | Every builtin operator, in declaration order.
+allBuiltins :: [Builtin]
+allBuiltins = [minBound .. maxBound]
+
+-- | The name a rule uses for a builtin.
+builtinName :: Builtin -> Text
+builtinName builtin = case builtin of
+  BVar -> "var"
+  BMissing -> "missing"
+  BMissingSome -> "missing_some"
+  BIf -> "if"
+  BTernary -> "?:"
+  BAnd -> "and"
+  BOr -> "or"
+  BNot -> "!"
+  BDoubleNot -> "!!"
+  BLooseEq -> "=="
+  BStrictEq -> "==="
+  BLooseNeq -> "!="
+  BStrictNeq -> "!=="
+  BLt -> "<"
+  BLte -> "<="
+  BGt -> ">"
+  BGte -> ">="
+  BMax -> "max"
+  BMin -> "min"
+  BAdd -> "+"
+  BSub -> "-"
+  BMul -> "*"
+  BDiv -> "/"
+  BMod -> "%"
+  BMap -> "map"
+  BFilter -> "filter"
+  BReduce -> "reduce"
+  BAll -> "all"
+  BNone -> "none"
+  BSome -> "some"
+  BMerge -> "merge"
+  BIn -> "in"
+  BCat -> "cat"
+  BSubstr -> "substr"
+  BLog -> "log"
+
+-- | 'builtinName' as an 'OperatorName'.
+builtinOperatorName :: Builtin -> OperatorName
+builtinOperatorName = OperatorName . builtinName
+
+-- | The builtin with exactly this name, if there is one. There is no
+-- trimming, no case folding and no dotted-path resolution.
+lookupBuiltin :: Text -> Maybe Builtin
+lookupBuiltin name = Map.lookup name byName
+
+byName :: Map.Map Text Builtin
+byName = Map.fromList [(builtinName builtin, builtin) | builtin <- allBuiltins]
diff --git a/src/JsonLogic/Core/Parse.hs b/src/JsonLogic/Core/Parse.hs
new file mode 100644
--- /dev/null
+++ b/src/JsonLogic/Core/Parse.hs
@@ -0,0 +1,249 @@
+{-# LANGUAGE LambdaCase #-}
+
+-- |
+-- Module      : JsonLogic.Core.Parse
+--
+-- Reads a JSON 'Value' into a 'Rule'. Reading is an unfold
+-- ('unfoldFixM'): a seed is a piece of JSON still to read, with how deeply it
+-- is nested and where it is, and each step produces one node whose children
+-- are further seeds.
+--
+-- What counts as a rule follows the reference implementation: an object with
+-- exactly one key is an operator call, an array is read element by element,
+-- and anything else (including an object with zero or several keys) is a
+-- literal.
+--
+-- __Arity.__ The reference ignores surplus operands and treats missing ones as
+-- JavaScript @undefined@. In 'Strict' mode (the default) a wrong count is a
+-- precise 'ArityMismatch' instead. In 'Lenient' mode the count is repaired
+-- here, exactly as the reference behaves: missing operands become
+-- @undefined@ (@null@ for the initial value of @reduce@), and surplus ones are
+-- dropped, after being evaluated for their failures where the reference
+-- evaluates them ('Discard'). How each builtin treats its operands is stated
+-- once, in @shapeOf@.
+module JsonLogic.Core.Parse
+  ( parseRule
+  , parseRuleWith
+  ) where
+
+import Data.Aeson (Value (..))
+import qualified Data.Aeson.Key as Key
+import qualified Data.Aeson.KeyMap as KeyMap
+import Data.Fix (unfoldFixM)
+import Data.Foldable (toList)
+import Data.Text (Text)
+
+import JsonLogic.Core.Config (ArityMode (..), Config (..), defaultConfig)
+import JsonLogic.Core.Errors (Arity (..), ParseError (..))
+import JsonLogic.Core.Location (Location, Segment (..), child, root)
+import JsonLogic.Core.Operator
+  ( Builtin (..)
+  , OperatorName (..)
+  , builtinOperatorName
+  , lookupBuiltin
+  )
+import JsonLogic.Core.Rule (Node (..), Rule, RuleF (..))
+import JsonLogic.Core.Value (nestsDeeperThan)
+
+-- | Read a rule with 'defaultConfig'.
+parseRule :: Value -> Either (Location, ParseError) Rule
+parseRule = parseRuleWith defaultConfig
+
+-- | Read a rule with the configured depth limit and arity mode. A failure
+-- says where in the rule it happened.
+parseRuleWith :: Config -> Value -> Either (Location, ParseError) Rule
+parseRuleWith config value = unfoldFixM (step config) (Seed 0 root (Json value))
+
+-- | Something still to read: how many levels deep it is, where it is, and
+-- what it is.
+data Seed = Seed !Int !Location !Source
+
+data Source
+  = -- | JSON from the rule.
+    Json Value
+  | -- | An operand the rule leaves out, which the reference reads as
+    -- @undefined@ or, in two places, @null@.
+    Absent Absence
+  | -- | An operator call whose operand count has already been repaired.
+    Repaired Builtin [Seed]
+
+data Absence = AbsentUndefined | AbsentNull
+
+-- | One layer of the unfold.
+step :: Config -> Seed -> Either (Location, ParseError) (Node Seed)
+step config (Seed depth location source)
+  | depth > limit = Left (location, DepthExceeded limit)
+  | otherwise = Node location <$> layer
+  where
+    limit = maxDepth config
+    layer = case source of
+      Json (Array items) ->
+        Right (Arr [Seed (depth + 1) (child location (Index i)) (Json item) | (i, item) <- zip [0 ..] (toList items)])
+      Json value@(Object fields) -> case KeyMap.toList fields of
+        [(key, operands)] -> operator config depth location (Key.toText key) operands
+        _ -> literal value
+      Json value -> literal value
+      -- @{"and": []}@ is how the reference's @undefined@ is written as a rule
+      Absent AbsentUndefined -> Right (And [])
+      Absent AbsentNull -> Right (Lit Null)
+      Repaired builtin operands -> build location builtin operands
+    -- A literal's own nesting counts towards the limit, so a deeply nested
+    -- object cannot be smuggled past it inside a rule.
+    literal value
+      | nestsDeeperThan (limit - depth) value = Left (location, DepthExceeded limit)
+      | otherwise = Right (Lit value)
+
+-- | An operator call: a builtin, read to its shape, or a custom operator.
+operator :: Config -> Int -> Location -> Text -> Value -> Either (Location, ParseError) (RuleF Seed)
+operator config depth location name operands = case lookupBuiltin name of
+  Nothing -> Right (CustomOp (OperatorName name) seeds)
+  Just builtin -> case arityMode config of
+    Strict
+      | admits (shapeArity shape) (length seeds) -> build location builtin (padded depth location builtin seeds)
+      | otherwise -> Left (location, ArityMismatch (builtinOperatorName builtin) (shapeArity shape) (length seeds))
+      where
+        shape = shapeOf builtin
+    Lenient -> lenient depth location builtin seeds
+  where
+    seeds = case operands of
+      Array items ->
+        [Seed (depth + 1) (child here (Index i)) (Json item) | (i, item) <- zip [0 ..] (toList items)]
+      -- a bare operand is shorthand for a list of one
+      other -> [Seed (depth + 1) here (Json other)]
+    here = child location (Key name)
+
+-- | Repair the operand count as the reference would, and build the call.
+lenient :: Int -> Location -> Builtin -> [Seed] -> Either (Location, ParseError) (RuleF Seed)
+lenient depth location builtin seeds = case maximumOf (shapeArity shape) of
+  Just most
+    | length full > most -> case shapeSurplus shape of
+        NeverEvaluated -> build location builtin (take most full)
+        Evaluated -> Right (Discard (Seed depth location (Repaired builtin (take most full))) (drop most full))
+  _ -> build location builtin full
+  where
+    shape = shapeOf builtin :: Shape Seed
+    full = padded depth location builtin seeds
+
+-- | The operands, followed by the ones the reference reads by position but
+-- the rule leaves out, as absent values. There is no JSON for those, so they
+-- are given the location of the operator itself.
+padded :: Int -> Location -> Builtin -> [Seed] -> [Seed]
+padded depth location builtin seeds =
+  seeds
+    ++ [ Seed (depth + 1) location (Absent (absence builtin i))
+       | i <- [length seeds .. shapePositional (shapeOf builtin :: Shape Seed) - 1]
+       ]
+
+-- | What the reference reads for a missing operand: @undefined@, except
+-- that a missing @var@ path is the data itself (the classic suite requires
+-- @{"var": []}@ to mean the data, as @{"var": null}@ does), and the missing
+-- initial value of @reduce@ is @null@.
+absence :: Builtin -> Int -> Absence
+absence BVar 0 = AbsentNull
+absence BReduce 2 = AbsentNull
+absence _ _ = AbsentUndefined
+
+-- | Build a call whose operand count the shape admits.
+build :: Location -> Builtin -> [Seed] -> Either (Location, ParseError) (RuleF Seed)
+build location builtin seeds = case shapeBuild shape seeds of
+  Just node -> Right node
+  Nothing -> Left (location, ArityMismatch (builtinOperatorName builtin) (shapeArity shape) (length seeds))
+  where
+    shape = shapeOf builtin
+
+-- ---------------------------------------------------------------------
+-- How each builtin takes its operands
+-- ---------------------------------------------------------------------
+
+-- | How an operator takes its operands, building nodes with children of
+-- type @a@.
+data Shape a = Shape
+  { shapeArity :: Arity
+  -- ^ The operand counts strict mode accepts.
+  , shapePositional :: Int
+  -- ^ How many leading operands the reference reads by position, so that a
+  -- missing one is read as absent ('absence').
+  , shapeSurplus :: Surplus
+  -- ^ Whether the reference evaluates operands past the most it uses.
+  , shapeBuild :: [a] -> Maybe (RuleF a)
+  -- ^ The node, if the count is one the constructor can hold.
+  }
+
+data Surplus
+  = -- | Every operand is evaluated before the operator runs.
+    Evaluated
+  | -- | The operator evaluates only the operands it uses (@map@ and the
+    -- other operators with a body).
+    NeverEvaluated
+
+-- | The single statement of every builtin's operands. The evaluator follows
+-- it; the outlier suite checks it against the reference in both modes.
+shapeOf :: Builtin -> Shape a
+shapeOf builtin = case builtin of
+  BVar -> Shape (Between 0 2) 1 Evaluated (oneOrTwo Var)
+  BMissing -> variadic Missing
+  BMissingSome -> exactlyTwo MissingSome
+  BIf -> variadic If
+  BTernary -> variadic If
+  BAnd -> variadic And
+  BOr -> variadic Or
+  BNot -> exactlyOne Not
+  BDoubleNot -> exactlyOne DoubleNot
+  BLooseEq -> exactlyTwo LooseEq
+  BStrictEq -> exactlyTwo StrictEq
+  BLooseNeq -> exactlyTwo LooseNeq
+  BStrictNeq -> exactlyTwo StrictNeq
+  BLt -> twoOrThree Lt
+  BLte -> twoOrThree Lte
+  BGt -> exactlyTwo Gt
+  BGte -> exactlyTwo Gte
+  BMax -> variadic Max
+  BMin -> variadic Min
+  BAdd -> variadic Add
+  BSub -> Shape (Between 1 2) 1 Evaluated (oneOrTwo Sub)
+  -- strict mode refuses an empty @*@; lenient mode keeps it, and it throws
+  -- if it is ever evaluated, as in the reference
+  BMul -> Shape (AtLeast 1) 0 Evaluated (Just . Mul)
+  BDiv -> exactlyTwo Div
+  BMod -> exactlyTwo Mod
+  BMap -> lazily (exactlyTwo Map)
+  BFilter -> lazily (exactlyTwo Filter)
+  BReduce -> lazily (exactlyThree Reduce)
+  BAll -> lazily (exactlyTwo All)
+  BNone -> lazily (exactlyTwo None)
+  BSome -> lazily (exactlyTwo Some)
+  BMerge -> variadic Merge
+  BIn -> exactlyTwo In
+  BCat -> variadic Cat
+  BSubstr -> twoOrThree Substr
+  BLog -> exactlyOne Log
+  where
+    variadic make = Shape (AtLeast 0) 0 Evaluated (Just . make)
+    exactlyOne make = Shape (Exactly 1) 1 Evaluated $ \case
+      [x] -> Just (make x)
+      _ -> Nothing
+    exactlyTwo make = Shape (Exactly 2) 2 Evaluated $ \case
+      [x, y] -> Just (make x y)
+      _ -> Nothing
+    exactlyThree make = Shape (Exactly 3) 3 Evaluated $ \case
+      [x, y, z] -> Just (make x y z)
+      _ -> Nothing
+    oneOrTwo make = \case
+      [x] -> Just (make x Nothing)
+      [x, y] -> Just (make x (Just y))
+      _ -> Nothing
+    twoOrThree make = Shape (Between 2 3) 2 Evaluated $ \case
+      [x, y] -> Just (make x y Nothing)
+      [x, y, z] -> Just (make x y (Just z))
+      _ -> Nothing
+    lazily shape = shape{shapeSurplus = NeverEvaluated}
+
+admits :: Arity -> Int -> Bool
+admits (Exactly n) given = given == n
+admits (AtLeast n) given = given >= n
+admits (Between low high) given = low <= given && given <= high
+
+maximumOf :: Arity -> Maybe Int
+maximumOf (Exactly n) = Just n
+maximumOf (AtLeast _) = Nothing
+maximumOf (Between _ high) = Just high
diff --git a/src/JsonLogic/Core/Rule.hs b/src/JsonLogic/Core/Rule.hs
new file mode 100644
--- /dev/null
+++ b/src/JsonLogic/Core/Rule.hs
@@ -0,0 +1,185 @@
+{-# LANGUAGE DeriveTraversable #-}
+
+-- |
+-- Module      : JsonLogic.Core.Rule
+--
+-- The rule tree: one layer of it ('RuleF', the base functor), and the
+-- recursive tree whose every node records where in the rule's JSON it came
+-- from ('Rule').
+--
+-- Recursion over the tree is never written by hand: the parser builds it
+-- with an unfold ('Data.Fix.unfoldFixM') and the evaluator consumes it with a
+-- fold ('Data.Fix.foldFix'), both from @data-fix@.
+--
+-- The builtin operators are a closed sum, matched exhaustively, so an
+-- operator cannot be half-implemented. Operators supplied by the caller are
+-- the one open extension point, 'CustomOp', whose behaviour is looked up when
+-- the rule is compiled.
+--
+-- Constructors are shaped so that, where an operator takes a fixed number
+-- of operands or a small range of them, a wrong count cannot be built
+-- ('Lt' takes two operands and an optional third, for instance). The
+-- operators that take any number of operands hold a list.
+module JsonLogic.Core.Rule
+  ( RuleF (..)
+  , Node (..)
+  , Rule
+  , Term
+  , stripLocations
+  ) where
+
+import Data.Aeson (Value)
+import Data.Fix (Fix (..), foldFix)
+import Data.Foldable (toList)
+import Data.Functor (void)
+import Data.Functor.Classes (Eq1 (..), Show1 (..))
+
+import JsonLogic.Core.Location (Location)
+import JsonLogic.Core.Operator (OperatorName)
+
+-- | One layer of a JsonLogic rule, with its sub-rules at type @a@. Each
+-- constructor's comment states what the reference implementation does, which
+-- is what the evaluator implements.
+data RuleF a
+  = -- | A JSON scalar, or an object that is not an operator call (it has zero
+    -- or several keys). Evaluates to itself.
+    Lit Value
+  | -- | A JSON array. The reference evaluates an array element by element,
+    -- each element being a rule.
+    Arr [a]
+  | -- | @{"var": [path, default]}@. The path is a sub-rule, evaluated and
+    -- then read as a dotted path or an index; the default, if given, is
+    -- used when the lookup finds nothing.
+    Var a (Maybe a)
+  | -- | @{"missing": [k1, k2, ...]}@: the keys whose values are absent, or
+    -- empty strings. If the first key evaluates to an array, that array is
+    -- the key list.
+    Missing [a]
+  | -- | @{"missing_some": [need, keys]}@: nothing, if at least @need@ of the
+    -- keys are present; otherwise the missing ones.
+    MissingSome a a
+  | -- | @{"if": [c1, t1, c2, t2, ..., else]}@ (also written @?:@): the
+    -- consequent of the first truthy condition, else the final operand if the
+    -- count is odd, else @null@. Only what is needed is evaluated.
+    If [a]
+  | -- | The first falsy operand, or the last operand; JavaScript @undefined@
+    -- when there are none. Stops at the first falsy one.
+    And [a]
+  | -- | The first truthy operand, or the last operand; JavaScript
+    -- @undefined@ when there are none. Stops at the first truthy one.
+    Or [a]
+  | -- | @!@: the negation of the operand's truthiness.
+    Not a
+  | -- | @!!@: the operand's truthiness.
+    DoubleNot a
+  | -- | @==@, JavaScript's loose equality.
+    LooseEq a a
+  | -- | @===@, JavaScript's strict equality.
+    StrictEq a a
+  | -- | @!=@.
+    LooseNeq a a
+  | -- | @!==@.
+    StrictNeq a a
+  | -- | @<@, or with a third operand the chained @a < b < c@ ("between").
+    Lt a a (Maybe a)
+  | -- | @<=@, or with a third operand the chained @a <= b <= c@.
+    Lte a a (Maybe a)
+  | -- | @>@, strictly binary.
+    Gt a a
+  | -- | @>=@, strictly binary.
+    Gte a a
+  | -- | @Math.max@ of the operands: @-Infinity@ (written @null@) for none.
+    Max [a]
+  | -- | @Math.min@ of the operands: @Infinity@ (written @null@) for none.
+    Min [a]
+  | -- | The sum of the operands read with @parseFloat@; @0@ for none.
+    Add [a]
+  | -- | @{"-": [x]}@ negates; @{"-": [x, y]}@ subtracts.
+    Sub a (Maybe a)
+  | -- | The product. The reference folds without a seed: one operand is
+    -- returned as it is, and none at all throws (@InvalidOperand@
+    -- when reached; strict mode refuses to read it).
+    Mul [a]
+  | -- | @/@, strictly binary.
+    Div a a
+  | -- | @%@, JavaScript's truncating remainder, strictly binary.
+    Mod a a
+  | -- | @{"map": [array, body]}@: the body evaluated against each element.
+    Map a a
+  | -- | @{"filter": [array, body]}@: the elements for which the body is
+    -- truthy.
+    Filter a a
+  | -- | @{"reduce": [array, body, initial]}@: the body evaluated against
+    -- @{"current": element, "accumulator": so far}@ for each element.
+    Reduce a a a
+  | -- | Whether the body is truthy for every element (false for none).
+    All a a
+  | -- | Whether the body is truthy for no element (true for none).
+    None a a
+  | -- | Whether the body is truthy for some element (false for none).
+    Some a a
+  | -- | The operands concatenated, arrays flattened by one level.
+    Merge [a]
+  | -- | @{"in": [needle, haystack]}@: substring of a string, or strict-equal
+    -- element of an array.
+    In a a
+  | -- | The operands joined as strings.
+    Cat [a]
+  | -- | @{"substr": [string, start, length]}@, the length optional.
+    Substr a a (Maybe a)
+  | -- | @log@: its operand, unchanged. The reference also prints it; this
+    -- library does no I/O.
+    Log a
+  | -- | @Discard inner extras@: the value of @inner@, after evaluating each
+    -- of @extras@ for its failures only. Built in lenient arity mode only,
+    -- for the surplus operands of an operator the reference evaluates in
+    -- full before applying it, so that a surplus operand that fails still
+    -- fails the rule, as in the reference.
+    Discard a [a]
+  | -- | An operator outside the builtin set, applied to its operands. Its
+    -- behaviour is supplied by the caller.
+    CustomOp OperatorName [a]
+  deriving (Eq, Show, Functor, Foldable, Traversable)
+
+-- | Two layers are equal when they have the same shape (constructor, fields
+-- other than sub-rules, and number of sub-rules) and pairwise-equal
+-- sub-rules. Written with 'Foldable' instead of derived by Template Haskell:
+-- 'void' keeps the shape, 'toList' gives the sub-rules in order.
+instance Eq1 RuleF where
+  liftEq eq x y = void x == void y && and (zipWith eq (toList x) (toList y))
+
+-- | The derived 'Show', with each sub-rule shown by the given function.
+instance Show1 RuleF where
+  liftShowsPrec showsChild _ d = showsPrec d . fmap (Shown . flip showsChild)
+
+-- | A layer of the tree and where it came from.
+data Node a = Node
+  { nodeLocation :: !Location
+  -- ^ The JSON Pointer of this node in the rule.
+  , nodeRule :: !(RuleF a)
+  -- ^ The node itself.
+  }
+  deriving (Eq, Show, Functor, Foldable, Traversable)
+
+instance Eq1 Node where
+  liftEq eq (Node a x) (Node b y) = a == b && liftEq eq x y
+
+instance Show1 Node where
+  liftShowsPrec showsChild _ d = showsPrec d . fmap (Shown . flip showsChild)
+
+-- | A rule as read from JSON: every node carries its 'Location'.
+type Rule = Fix Node
+
+-- | A rule tree without locations, for comparing shapes.
+type Term = Fix RuleF
+
+-- | Forget where every node came from.
+stripLocations :: Rule -> Term
+stripLocations = foldFix (Fix . nodeRule)
+
+-- | A value shown by a function, so that a derived 'Show' can be reused to
+-- write 'Show1' instances.
+newtype Shown = Shown (Int -> ShowS)
+
+instance Show Shown where
+  showsPrec d (Shown shows') = shows' d
diff --git a/src/JsonLogic/Core/Ryu.hs b/src/JsonLogic/Core/Ryu.hs
new file mode 100644
--- /dev/null
+++ b/src/JsonLogic/Core/Ryu.hs
@@ -0,0 +1,305 @@
+{-# LANGUAGE BangPatterns #-}
+
+-- |
+-- Module      : JsonLogic.Core.Ryu
+--
+-- The shortest decimal that reads back as a double: Ryū, by Ulf Adams
+-- (\"Ryū: fast float-to-string conversion\", PLDI 2018), following the
+-- reference implementation's @d2d@ (github.com/ulfjack/ryu, @d2s.c@).
+--
+-- Ryū's answer is the one ECMAScript's @Number::toString@ asks for: the
+-- fewest significant digits whose decimal reads back as the double under
+-- round-half-even (so an endpoint of the rounding interval counts when the
+-- double's significand is even), and among those the closest to the double,
+-- an exact tie going to the even digit.
+--
+-- The 128-bit multipliers of the reference implementation's tables are
+-- computed here once, by the formulas its table generator uses, and the
+-- products are taken in 64-bit words, as the reference does. The test suite checks this module against
+-- an independent exact algorithm (@JsonLogic.Core.Number.shortestDigitsExact@)
+-- on hundreds of thousands of doubles.
+module JsonLogic.Core.Ryu
+  ( shortestDecimalRyu
+  ) where
+
+import Data.Bits (shiftL, shiftR, (.&.), (.|.))
+import qualified Data.Vector.Unboxed as Unboxed
+import Data.Word (Word64)
+import GHC.Float (castDoubleToWord64)
+
+-- | For a positive, finite, non-zero double, @(digits, exponent)@ with the
+-- double's shortest decimal equal to @digits * 10^exponent@. The digits may
+-- end in zeros; the caller strips them.
+shortestDecimalRyu :: Double -> (Word64, Int)
+shortestDecimalRyu d = removeDigits acceptBounds (interval acceptBounds e2 m2 mmShift)
+  where
+    bits = castDoubleToWord64 d
+    ieeeMantissa = bits .&. ((1 `shiftL` mantissaBits) - 1)
+    ieeeExponent = fromIntegral ((bits `shiftR` mantissaBits) .&. 0x7FF) :: Int
+    -- Step 1: the double as m2 * 2^e2, with two bits of room below.
+    e2
+      | ieeeExponent == 0 = 1 - bias - mantissaBits - 2
+      | otherwise = ieeeExponent - bias - mantissaBits - 2
+    m2
+      | ieeeExponent == 0 = ieeeMantissa
+      | otherwise = (1 `shiftL` mantissaBits) + ieeeMantissa
+    -- An endpoint of the rounding interval reads back to the double under
+    -- round-half-even exactly when the double's significand is even.
+    acceptBounds = even m2
+    -- The lower end of the interval is closer when the significand is a power
+    -- of two (the gap below it is half the gap above).
+    mmShift = if ieeeMantissa /= 0 || ieeeExponent <= 1 then 1 else 0
+
+-- | Step 2 and 3: the decimals that read back, @[vm, vp]@ around the double's
+-- own @vr@, scaled by @10^e10@, and whether the digits dropped by the scaling
+-- were all zeros (for @vm@ and for @vr@).
+data Interval = Interval
+  { _vr :: {-# UNPACK #-} !Word64
+  , _vp :: {-# UNPACK #-} !Word64
+  , _vm :: {-# UNPACK #-} !Word64
+  , _e10 :: {-# UNPACK #-} !Int
+  , _vmZeros :: !Bool
+  , _vrZeros :: !Bool
+  }
+
+interval :: Bool -> Int -> Word64 -> Word64 -> Interval
+interval acceptBounds e2 m2 mmShift
+  | e2 >= 0 =
+      let q = log10Pow2 e2 - (if e2 > 3 then 1 else 0)
+          k = pow5InvBitCount + pow5bits q - 1
+          i = negate e2 + q + k
+          (vr, vp, vm) = mulShiftAll m2 (pow5InvSplit q) i mmShift
+       in if q > 21
+            then Interval vr vp vm q False False
+            else
+              if mv `rem` 5 == 0
+                then Interval vr vp vm q False (multipleOfPowerOf5 mv q)
+                else
+                  if acceptBounds
+                    then Interval vr vp vm q (multipleOfPowerOf5 (mv - 1 - mmShift) q) False
+                    else Interval vr (vp - (if multipleOfPowerOf5 (mv + 2) q then 1 else 0)) vm q False False
+  | otherwise =
+      let q = log10Pow5 (negate e2) - (if negate e2 > 1 then 1 else 0)
+          i = negate e2 - q
+          k = pow5bits i - pow5BitCount
+          j = q - k
+          (vr, vp, vm) = mulShiftAll m2 (pow5Split i) j mmShift
+       in if q <= 1
+            then
+              -- mv has at least q trailing zero bits, so vr is exact
+              if acceptBounds
+                then Interval vr vp vm (q + e2) (mmShift == 1) True
+                else Interval vr (vp - 1) vm (q + e2) False True
+            else Interval vr vp vm (q + e2) False (q < 63 && multipleOfPowerOf2 mv q)
+  where
+    mv = 4 * m2
+
+-- | Step 4: remove digits while the interval still holds a shorter decimal,
+-- then round.
+removeDigits :: Bool -> Interval -> (Word64, Int)
+removeDigits acceptBounds (Interval vr0 vp0 vm0 e10 vmZeros0 vrZeros0)
+  | vmZeros0 || vrZeros0 =
+      -- The general case, which keeps track of whether the removed digits of
+      -- vm and vr were all zeros, so that bounds and exact ties are decided
+      -- right.
+      case dropWhileWide vr0 vp0 vm0 vmZeros0 vrZeros0 0 0 of
+        Dropped vr1 vp1 vm1 vmZeros1 vrZeros1 last1 removed1 ->
+          let Dropped vr2 _ vm2 _ vrZeros2 last2 removed2
+                | vmZeros1 = dropZerosOfVm vr1 vp1 vm1 vrZeros1 last1 removed1
+                | otherwise = Dropped vr1 vp1 vm1 vmZeros1 vrZeros1 last1 removed1
+              -- round half to even when the exact value is ...50...0
+              last3 = if vrZeros2 && last2 == 5 && even vr2 then 4 else last2
+              up = (vr2 == vm2 && (not acceptBounds || not vmZeros1)) || last3 >= 5
+           in (vr2 + (if up then 1 else 0), e10 + removed2)
+  | otherwise =
+      -- The common case: no exact ties or bounds to worry about. Two digits
+      -- at a time first, while that is possible.
+      let wide = vp0 `quot` 100 > vm0 `quot` 100
+          vr1 = if wide then vr0 `quot` 100 else vr0
+          vp1 = if wide then vp0 `quot` 100 else vp0
+          vm1 = if wide then vm0 `quot` 100 else vm0
+          roundUp1 = wide && vr0 `rem` 100 >= 50
+       in case dropCommon vr1 vp1 vm1 roundUp1 (if wide then 2 else 0) of
+            Dropped vr2 _ vm2 _ roundUp2 _ removed2 ->
+              (vr2 + (if vr2 == vm2 || roundUp2 then 1 else 0), e10 + removed2)
+
+-- | The state of digit removal: vr, vp, vm, whether vm's removed digits
+-- were zeros, whether vr's were (or, in the common case, whether to round
+-- up), the last digit removed from vr, and how many were removed.
+data Dropped
+  = Dropped
+      {-# UNPACK #-} !Word64
+      {-# UNPACK #-} !Word64
+      {-# UNPACK #-} !Word64
+      !Bool
+      !Bool
+      {-# UNPACK #-} !Word64
+      {-# UNPACK #-} !Int
+
+-- | Remove digits while the shortened interval is still non-empty, tracking
+-- trailing zeros.
+dropWhileWide :: Word64 -> Word64 -> Word64 -> Bool -> Bool -> Word64 -> Int -> Dropped
+dropWhileWide !vr !vp !vm !vmZeros !vrZeros !lastDigit !removed
+  | vp `quot` 10 <= vm `quot` 10 = Dropped vr vp vm vmZeros vrZeros lastDigit removed
+  | otherwise =
+      dropWhileWide
+        (vr `quot` 10)
+        (vp `quot` 10)
+        (vm `quot` 10)
+        (vmZeros && vm `rem` 10 == 0)
+        (vrZeros && lastDigit == 0)
+        (vr `rem` 10)
+        (removed + 1)
+
+-- | When vm ends in zeros, remove them too: the shorter vm is still in range.
+dropZerosOfVm :: Word64 -> Word64 -> Word64 -> Bool -> Word64 -> Int -> Dropped
+dropZerosOfVm !vr !vp !vm !vrZeros !lastDigit !removed
+  | vm == 0 || vm `rem` 10 /= 0 = Dropped vr vp vm True vrZeros lastDigit removed
+  | otherwise =
+      dropZerosOfVm
+        (vr `quot` 10)
+        (vp `quot` 10)
+        (vm `quot` 10)
+        (vrZeros && lastDigit == 0)
+        (vr `rem` 10)
+        (removed + 1)
+
+-- | The common case's digit removal: remember only whether to round up.
+dropCommon :: Word64 -> Word64 -> Word64 -> Bool -> Int -> Dropped
+dropCommon !vr !vp !vm !roundUp !removed
+  | vp `quot` 10 <= vm `quot` 10 = Dropped vr vp vm False roundUp 0 removed
+  | otherwise = dropCommon (vr `quot` 10) (vp `quot` 10) (vm `quot` 10) (vr `rem` 10 >= 5) (removed + 1)
+
+-- | @floor (m * multiplier / 2^shift)@ for the centre and both ends of the
+-- interval: @4m@, @4m + 2@ and @4m - 1 - mmShift@. The multiplier is 128
+-- bits, as a (low, high) pair.
+mulShiftAll :: Word64 -> (Word64, Word64) -> Int -> Word64 -> (Word64, Word64, Word64)
+{-# INLINE mulShiftAll #-}
+mulShiftAll m multiplier shift mmShift =
+  ( mulShift64 (4 * m) multiplier shift
+  , mulShift64 (4 * m + 2) multiplier shift
+  , mulShift64 (4 * m - 1 - mmShift) multiplier shift
+  )
+
+-- | @floor (m * multiplier / 2^shift)@ for a 64-bit @m@ and a 128-bit
+-- multiplier, with @64 < shift < 128@: the reference implementation's
+-- @mulShift64@.
+mulShift64 :: Word64 -> (Word64, Word64) -> Int -> Word64
+{-# INLINE mulShift64 #-}
+mulShift64 m (mulLow, mulHigh) shift = shiftRight128 sumLow sumHigh (shift - 64)
+  where
+    (high0, _) = umul128 m mulLow
+    (high2, low2) = umul128 m mulHigh
+    sumLow = high0 + low2
+    sumHigh = high2 + (if sumLow < high0 then 1 else 0)
+
+-- | The low 64 bits of a 128-bit value shifted right by @0 < n < 64@.
+shiftRight128 :: Word64 -> Word64 -> Int -> Word64
+{-# INLINE shiftRight128 #-}
+shiftRight128 low high n = (high `shiftL` (64 - n)) .|. (low `shiftR` n)
+
+-- | The full 128-bit product of two 64-bit words, as (high, low), from
+-- 32-bit halves (the reference implementation's portable @umul128@).
+umul128 :: Word64 -> Word64 -> (Word64, Word64)
+{-# INLINE umul128 #-}
+umul128 a b = (high, low)
+  where
+    lowMask = 0xFFFFFFFF
+    aLow = a .&. lowMask
+    aHigh = a `shiftR` 32
+    bLow = b .&. lowMask
+    bHigh = b `shiftR` 32
+    b00 = aLow * bLow
+    b01 = aLow * bHigh
+    b10 = aHigh * bLow
+    b11 = aHigh * bHigh
+    mid1 = b10 + (b00 `shiftR` 32)
+    mid2 = b01 + (mid1 .&. lowMask)
+    high = b11 + (mid1 `shiftR` 32) + (mid2 `shiftR` 32)
+    low = ((mid2 .&. lowMask) `shiftL` 32) .|. (b00 .&. lowMask)
+
+mantissaBits :: Int
+mantissaBits = 52
+
+bias :: Int
+bias = 1023
+
+pow5InvBitCount :: Int
+pow5InvBitCount = 125
+
+pow5BitCount :: Int
+pow5BitCount = 125
+
+-- | The bit length of @5^e@, for @0 <= e <= 3528@.
+pow5bits :: Int -> Int
+pow5bits e = ((e * 1217359) `shiftR` 19) + 1
+
+-- | @floor (log10 (2^e))@, for @0 <= e <= 1650@.
+log10Pow2 :: Int -> Int
+log10Pow2 e = (e * 78913) `shiftR` 18
+
+-- | @floor (log10 (5^e))@, for @0 <= e <= 2620@.
+log10Pow5 :: Int -> Int
+log10Pow5 e = (e * 732923) `shiftR` 20
+
+multipleOfPowerOf5 :: Word64 -> Int -> Bool
+multipleOfPowerOf5 value p = pow5Factor value 0 >= p
+  where
+    pow5Factor !v !count
+      | v /= 0 && v `rem` 5 == 0 = pow5Factor (v `quot` 5) (count + 1)
+      | otherwise = count :: Int
+
+multipleOfPowerOf2 :: Word64 -> Int -> Bool
+multipleOfPowerOf2 value p = value .&. ((1 `shiftL` p) - 1) == 0
+
+-- | @floor (2^(bitLength (5^q) - 1 + 125) / 5^q) + 1@, the reference
+-- implementation's @DOUBLE_POW5_INV_SPLIT[q]@, as (low, high) words.
+pow5InvSplit :: Int -> (Word64, Word64)
+pow5InvSplit q = (pow5InvLow Unboxed.! q, pow5InvHigh Unboxed.! q)
+
+-- | @5^i@ normalised to 125 bits, the reference implementation's
+-- @DOUBLE_POW5_SPLIT[i]@, as (low, high) words.
+pow5Split :: Int -> (Word64, Word64)
+pow5Split i = (pow5Low Unboxed.! i, pow5High Unboxed.! i)
+
+-- | Both tables are computed once, on first use, from their definitions.
+pow5InvLow, pow5InvHigh, pow5Low, pow5High :: Unboxed.Vector Word64
+pow5InvLow = Unboxed.generate pow5InvTableSize (lowWord . pow5InvEntry)
+pow5InvHigh = Unboxed.generate pow5InvTableSize (highWord . pow5InvEntry)
+pow5Low = Unboxed.generate pow5TableSize (lowWord . pow5Entry)
+pow5High = Unboxed.generate pow5TableSize (highWord . pow5Entry)
+{-# NOINLINE pow5InvLow #-}
+{-# NOINLINE pow5InvHigh #-}
+{-# NOINLINE pow5Low #-}
+{-# NOINLINE pow5High #-}
+
+-- | Large enough for every double: @q@ reaches 291 and @i@ reaches 325.
+pow5InvTableSize, pow5TableSize :: Int
+pow5InvTableSize = 342
+pow5TableSize = 326
+
+pow5InvEntry :: Int -> Integer
+pow5InvEntry q = ((1 :: Integer) `shiftL` (bitLength power - 1 + pow5InvBitCount)) `quot` power + 1
+  where
+    power = 5 ^ q
+
+pow5Entry :: Int -> Integer
+pow5Entry i
+  | excess >= 0 = power `shiftR` excess
+  | otherwise = power `shiftL` negate excess
+  where
+    power = 5 ^ i :: Integer
+    excess = bitLength power - pow5BitCount
+
+lowWord :: Integer -> Word64
+lowWord = fromInteger . (.&. 0xFFFFFFFFFFFFFFFF)
+
+highWord :: Integer -> Word64
+highWord = fromInteger . (`shiftR` 64)
+
+bitLength :: Integer -> Int
+bitLength = go 0
+  where
+    go n v
+      | v == 0 = n
+      | otherwise = go (n + 1) (v `shiftR` 1)
diff --git a/src/JsonLogic/Core/Utf16.hs b/src/JsonLogic/Core/Utf16.hs
new file mode 100644
--- /dev/null
+++ b/src/JsonLogic/Core/Utf16.hs
@@ -0,0 +1,107 @@
+-- |
+-- Module      : JsonLogic.Core.Utf16
+--
+-- JavaScript strings are sequences of UTF-16 code units, not of Unicode
+-- code points. The reference implementation's @length@, indexing, @substr@
+-- and @<@ all work on code units, so a character outside the Basic
+-- Multilingual Plane (an emoji, say) has length 2, and sorts /below/
+-- U+E000 to U+FFFF because its first code unit is a surrogate in
+-- U+D800 to U+DBFF.
+--
+-- This module is the one place those semantics are defined, over
+-- 'Text'. A 'Text' cannot hold a lone surrogate, so a slice that cuts
+-- through a surrogate pair yields U+FFFD for the severed half. That is the
+-- single accepted deviation from the reference here, and it is exactly what
+-- @String.prototype.toWellFormed()@ would make of the reference's answer.
+module JsonLogic.Core.Utf16
+  ( utf16Length
+  , utf16Slice
+  , utf16CharAt
+  , compareUtf16
+  , utf16Units
+  , fromUtf16Units
+  ) where
+
+import Data.Bits (shiftL, shiftR, (.&.), (.|.))
+import Data.Char (chr, ord)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import Data.Word (Word16)
+
+-- | The number of UTF-16 code units: JavaScript's @s.length@.
+utf16Length :: Text -> Int
+utf16Length = Text.foldl' (\n c -> if ord c > 0xFFFF then n + 2 else n + 1) 0
+
+-- | The UTF-16 code units of a text.
+utf16Units :: Text -> [Word16]
+utf16Units = concatMap charUnits . Text.unpack
+
+charUnits :: Char -> [Word16]
+charUnits c
+  | n <= 0xFFFF = [fromIntegral n]
+  | otherwise =
+      let m = n - 0x10000
+       in [ fromIntegral (0xD800 .|. (m `shiftR` 10))
+          , fromIntegral (0xDC00 .|. (m .&. 0x3FF))
+          ]
+  where
+    n = ord c
+
+-- | Rebuild a text from UTF-16 code units. A surrogate with no partner
+-- becomes U+FFFD.
+fromUtf16Units :: [Word16] -> Text
+fromUtf16Units = Text.pack . go
+  where
+    go [] = []
+    go (hi : lo : rest)
+      | isHigh hi && isLow lo = pair hi lo : go rest
+    go (u : rest)
+      | isHigh u || isLow u = '\xFFFD' : go rest
+      | otherwise = chr (fromIntegral u) : go rest
+    isHigh u = u >= 0xD800 && u <= 0xDBFF
+    isLow u = u >= 0xDC00 && u <= 0xDFFF
+    pair hi lo =
+      chr
+        ( 0x10000
+            + ((fromIntegral hi - 0xD800) `shiftL` 10)
+            + (fromIntegral lo - 0xDC00)
+        )
+
+-- | @slice from count@: the @count@ code units starting at code unit
+-- @from@, both already clamped into range by the caller. Cutting through a
+-- surrogate pair gives U+FFFD for the severed half.
+utf16Slice :: Int -> Int -> Text -> Text
+utf16Slice from count text
+  | count <= 0 = Text.empty
+  | Text.all isBmp text = Text.take count (Text.drop from text)
+  | otherwise = fromUtf16Units (take count (drop from (utf16Units text)))
+
+-- | The code unit at an index, as a one-unit string, or 'Nothing' past the
+-- end. A lone surrogate half is returned as U+FFFD.
+utf16CharAt :: Int -> Text -> Maybe Text
+utf16CharAt i text
+  | i < 0 = Nothing
+  | Text.all isBmp text =
+      let picked = Text.take 1 (Text.drop i text)
+       in if Text.null picked then Nothing else Just picked
+  | otherwise = case drop i (utf16Units text) of
+      [] -> Nothing
+      unit : _ -> Just (fromUtf16Units [unit])
+
+isBmp :: Char -> Bool
+isBmp c = ord c <= 0xFFFF
+
+-- | JavaScript's string ordering: lexicographic by UTF-16 code unit.
+--
+-- It differs from ordering by code point only when an astral character
+-- (first unit U+D800 to U+DBFF) meets a BMP character in U+E000 to U+FFFF:
+-- by code unit the astral one is smaller.
+compareUtf16 :: Text -> Text -> Ordering
+compareUtf16 a b = go (Text.unpack a) (Text.unpack b)
+  where
+    go [] [] = EQ
+    go [] _ = LT
+    go _ [] = GT
+    go (x : xs) (y : ys)
+      | x == y = go xs ys
+      | otherwise = compare (charUnits x) (charUnits y)
diff --git a/src/JsonLogic/Core/Value.hs b/src/JsonLogic/Core/Value.hs
new file mode 100644
--- /dev/null
+++ b/src/JsonLogic/Core/Value.hs
@@ -0,0 +1,264 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- |
+-- Module      : JsonLogic.Core.Value
+--
+-- The values rules are evaluated over, and the JavaScript semantics the
+-- reference implementation gives them.
+--
+-- JsonLogic is defined by its JavaScript reference implementation, so its
+-- behaviour on mixed types (@"1" == 1@, @[] == 0@, @"10" < "9"@) is
+-- JavaScript's, not something derived from first principles. This module is
+-- deliberately plain: one explicit clause per case of the JavaScript
+-- algorithms, no abstraction layered over the table.
+--
+-- 'Val' is JSON plus what JSON cannot say but a JavaScript evaluation can
+-- produce: @NaN@, the infinities, negative zero and @undefined@. They exist
+-- only during evaluation; 'toValue' writes them as @JSON.stringify@ does.
+--
+-- Recorded deviations (docs/COMPATIBILITY.md): arrays and objects are never
+-- equal to one another under @==@ / @===@ (the reference compares them by
+-- identity, which JSON values do not have). Strings are measured and ordered
+-- by UTF-16 code unit, as in JavaScript (see "JsonLogic.Core.Utf16").
+module JsonLogic.Core.Value
+  ( Val (..)
+  , fromValue
+  , toValue
+  , truthy
+  , strictEquals
+  , looseEquals
+  , lessThan
+  , lessOrEqual
+  , toNumber
+  , parseFloat
+  , toJsString
+  , toJoinText
+  , nestsDeeperThan
+  , jsonWeightUpTo
+  , valWeightUpTo
+  ) where
+
+import qualified Data.Aeson as Aeson
+import qualified Data.Aeson.KeyMap as KeyMap
+import Data.Text (Text)
+import qualified Data.Text as Text
+import Data.Vector (Vector)
+import qualified Data.Vector as Vector
+
+import JsonLogic.Core.Number
+  ( doubleToScientific
+  , numberToString
+  , parseFloatString
+  , scientificToDouble
+  , stringToNumber
+  )
+import JsonLogic.Core.Utf16 (compareUtf16)
+
+-- | A value during evaluation.
+data Val
+  = VNull
+  | -- | JavaScript's @undefined@, which an empty @and@ or @or@ yields. It
+    -- is not 'VNull': it converts to @NaN@ (not 0) and to the string
+    -- @"undefined"@ (not @"null"@), and @undefined === null@ is false.
+    -- Written back as JSON @null@, and left out of objects.
+    VUndefined
+  | VBool !Bool
+  | -- | Any JavaScript number, including @NaN@, the infinities and @-0@.
+    VNum {-# UNPACK #-} !Double
+  | VStr !Text
+  | VArr !(Vector Val)
+  | VObj !(KeyMap.KeyMap Val)
+  deriving (Eq, Show)
+
+-- ---------------------------------------------------------------------
+-- Conversion to and from JSON
+-- ---------------------------------------------------------------------
+
+-- | Read JSON into the evaluation domain. Numbers become doubles, as in
+-- the reference. The elements of arrays and objects are converted only when
+-- they are used.
+fromValue :: Aeson.Value -> Val
+fromValue Aeson.Null = VNull
+fromValue (Aeson.Bool flag) = VBool flag
+fromValue (Aeson.Number n) = VNum (scientificToDouble n)
+fromValue (Aeson.String s) = VStr s
+fromValue (Aeson.Array items) = VArr (Vector.map fromValue items)
+fromValue (Aeson.Object fields) = VObj (KeyMap.map fromValue fields)
+
+-- | Write a result back as JSON, as @JSON.stringify@ would: @NaN@, the
+-- infinities and @undefined@ become @null@, negative zero becomes @0@, and an
+-- object property whose value is @undefined@ is left out.
+toValue :: Val -> Aeson.Value
+toValue VNull = Aeson.Null
+toValue VUndefined = Aeson.Null
+toValue (VBool flag) = Aeson.Bool flag
+toValue (VNum d)
+  | isNaN d || isInfinite d = Aeson.Null
+  | otherwise = Aeson.Number (doubleToScientific d)
+toValue (VStr s) = Aeson.String s
+toValue (VArr items) = Aeson.Array (Vector.map toValue items)
+toValue (VObj fields) = Aeson.Object (KeyMap.map toValue (KeyMap.filter (not . isUndefined) fields))
+  where
+    isUndefined VUndefined = True
+    isUndefined _ = False
+
+-- | Does a JSON value nest more than @n@ levels? An array or object that is
+-- not empty is one level; a scalar is none. Stops as soon as it knows, so
+-- the cost is bounded by @n@ along any path.
+nestsDeeperThan :: Int -> Aeson.Value -> Bool
+nestsDeeperThan n value = case value of
+  Aeson.Array items -> not (null items) && (n <= 0 || any (nestsDeeperThan (n - 1)) items)
+  Aeson.Object fields -> not (KeyMap.null fields) && (n <= 0 || any (nestsDeeperThan (n - 1)) (KeyMap.elems fields))
+  _ -> False
+
+-- | How many nodes a JSON value has, counted only up to @cap@: the work to
+-- take it in, without walking an enormous value to the end.
+jsonWeightUpTo :: Int -> Aeson.Value -> Int
+jsonWeightUpTo cap value = cap - remaining cap value
+  where
+    remaining budget v
+      | budget <= 0 = 0
+      | otherwise = case v of
+          Aeson.Array items -> children (budget - 1) (Vector.toList items)
+          Aeson.Object fields -> children (budget - 1) (KeyMap.elems fields)
+          _ -> budget - 1
+    children budget [] = budget
+    children budget (x : xs)
+      | budget <= 0 = 0
+      | otherwise = children (remaining budget x) xs
+
+-- | How many array elements, at any depth, a value holds, counted only up
+-- to @cap@. This is the work converting an array to a string or number
+-- takes; every other value is free.
+valWeightUpTo :: Int -> Val -> Int
+valWeightUpTo cap value = cap - remaining cap value
+  where
+    remaining budget v = case v of
+      VArr items -> children budget (Vector.toList items)
+      _ -> budget
+    children budget [] = budget
+    children budget (x : xs)
+      | budget <= 0 = 0
+      | otherwise = children (remaining (budget - 1) x) xs
+
+-- ---------------------------------------------------------------------
+-- Truthiness
+-- ---------------------------------------------------------------------
+
+-- | JsonLogic's truthiness: JavaScript's, except that an empty array is
+-- falsy (<https://jsonlogic.com/truthy.html>).
+truthy :: Val -> Bool
+truthy VNull = False
+truthy VUndefined = False
+truthy (VBool flag) = flag
+truthy (VNum d) = not (d == 0 || isNaN d)
+truthy (VStr s) = not (Text.null s)
+truthy (VArr items) = not (Vector.null items)
+truthy (VObj _) = True
+
+-- ---------------------------------------------------------------------
+-- Conversion to string and number
+-- ---------------------------------------------------------------------
+
+-- | JavaScript's @String(value)@.
+toJsString :: Val -> Text
+toJsString VNull = "null"
+toJsString VUndefined = "undefined"
+toJsString (VBool True) = "true"
+toJsString (VBool False) = "false"
+toJsString (VNum d) = numberToString d
+toJsString (VStr s) = s
+toJsString (VArr items) = Text.intercalate "," (map toJoinText (Vector.toList items))
+toJsString (VObj _) = "[object Object]"
+
+-- | How @Array.prototype.join@ renders an element: like 'toJsString',
+-- except that @null@ and @undefined@ become the empty string.
+toJoinText :: Val -> Text
+toJoinText VNull = ""
+toJoinText VUndefined = ""
+toJoinText value = toJsString value
+
+-- | JavaScript's @Number(value)@ (the abstract @ToNumber@).
+toNumber :: Val -> Double
+toNumber VNull = 0
+toNumber VUndefined = 0 / 0
+toNumber (VBool flag) = boolToNumber flag
+toNumber (VNum d) = d
+toNumber (VStr s) = stringToNumber s
+toNumber value@(VArr _) = stringToNumber (toJsString value)
+toNumber (VObj _) = 0 / 0
+
+boolToNumber :: Bool -> Double
+boolToNumber flag = if flag then 1 else 0
+
+-- | JavaScript's @parseFloat(String(value))@, which is how @+@ and @*@ read
+-- their operands: the longest decimal prefix, ignoring leading whitespace;
+-- @NaN@ if there is none. A number is itself, except that negative zero
+-- becomes zero (@String(-0)@ is @"0"@).
+parseFloat :: Val -> Double
+parseFloat (VNum d)
+  | d == 0 = 0
+  | otherwise = d
+parseFloat value = parseFloatString (toJsString value)
+
+-- ---------------------------------------------------------------------
+-- Equality
+-- ---------------------------------------------------------------------
+
+-- | JavaScript's @===@. Arrays and objects are never equal (see the
+-- module header).
+strictEquals :: Val -> Val -> Bool
+strictEquals VNull VNull = True
+strictEquals VUndefined VUndefined = True
+strictEquals (VBool a) (VBool b) = a == b
+strictEquals (VNum a) (VNum b) = a == b
+strictEquals (VStr a) (VStr b) = a == b
+strictEquals _ _ = False
+
+-- | JavaScript's @==@ (the abstract equality comparison).
+looseEquals :: Val -> Val -> Bool
+looseEquals VUndefined VUndefined = True
+looseEquals VUndefined VNull = True
+looseEquals VNull VUndefined = True
+looseEquals VUndefined _ = False
+looseEquals _ VUndefined = False
+looseEquals VNull VNull = True
+looseEquals VNull _ = False
+looseEquals _ VNull = False
+looseEquals (VBool a) y = looseEquals (VNum (boolToNumber a)) y
+looseEquals x (VBool b) = looseEquals x (VNum (boolToNumber b))
+looseEquals (VNum a) (VNum b) = a == b
+looseEquals (VStr a) (VStr b) = a == b
+looseEquals (VNum a) (VStr b) = a == stringToNumber b
+looseEquals (VStr a) (VNum b) = stringToNumber a == b
+looseEquals (VArr _) (VArr _) = False
+looseEquals (VArr _) (VObj _) = False
+looseEquals (VObj _) (VArr _) = False
+looseEquals (VObj _) (VObj _) = False
+looseEquals x y@(VArr _) = looseEquals x (VStr (toJsString y))
+looseEquals x y@(VObj _) = looseEquals x (VStr (toJsString y))
+looseEquals x@(VArr _) y = looseEquals (VStr (toJsString x)) y
+looseEquals x@(VObj _) y = looseEquals (VStr (toJsString x)) y
+
+-- ---------------------------------------------------------------------
+-- Ordering
+-- ---------------------------------------------------------------------
+
+-- | Arrays and objects compare as the strings they convert to.
+toPrimitive :: Val -> Val
+toPrimitive value@(VArr _) = VStr (toJsString value)
+toPrimitive value@(VObj _) = VStr (toJsString value)
+toPrimitive value = value
+
+-- | JavaScript's @<@: two strings compare by UTF-16 code unit, anything
+-- else numerically (so any @NaN@ makes it false).
+lessThan :: Val -> Val -> Bool
+lessThan x y = case (toPrimitive x, toPrimitive y) of
+  (VStr a, VStr b) -> compareUtf16 a b == LT
+  (a, b) -> toNumber a < toNumber b
+
+-- | JavaScript's @<=@.
+lessOrEqual :: Val -> Val -> Bool
+lessOrEqual x y = case (toPrimitive x, toPrimitive y) of
+  (VStr a, VStr b) -> compareUtf16 a b /= GT
+  (a, b) -> toNumber a <= toNumber b
diff --git a/test/AnalysisSpec.hs b/test/AnalysisSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/AnalysisSpec.hs
@@ -0,0 +1,77 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | 'dataUse': examples, and the property that makes it worth having: when
+-- it says a rule reads only some paths, changing anything else in the data
+-- changes nothing about the answer.
+module AnalysisSpec (spec) where
+
+import Data.Aeson (Value (..))
+import qualified Data.Aeson.Key as Key
+import qualified Data.Aeson.KeyMap as KeyMap
+import Data.Text (Text)
+import qualified Data.Text as Text
+import Test.Hspec
+import Test.QuickCheck
+
+import Gen (genData, genRule)
+import JsonLogic
+import Support (json)
+
+uses :: String -> Either Error DataUse
+uses rule = dataUse <$> compile defaultConfig{arityMode = Lenient} noOperators (json rule)
+
+reads' :: [Text] -> Either Error DataUse
+reads' paths = Right (DataUse paths False)
+
+-- | The top-level keys the generated data uses, and one it never does.
+topKeys :: [Text]
+topKeys = ["a", "b", "l", "unused"]
+
+firstSegment :: Text -> Text
+firstSegment = Text.takeWhile (/= '.')
+
+spec :: Spec
+spec = describe "dataUse" $ do
+  describe "examples" $ do
+    it "lists literal var paths, with defaults that are rules too" $
+      uses "{\"+\":[{\"var\":\"a.b\"},{\"var\":[\"c\",{\"var\":\"d\"}]}]}" `shouldBe` reads' ["a.b", "c", "d"]
+    it "does not count what an iteration body reads, which is the element" $
+      uses "{\"map\":[{\"var\":\"xs\"},{\"+\":[{\"var\":\"\"},{\"var\":\"y\"}]}]}" `shouldBe` reads' ["xs"]
+    it "counts reduce's initial value, which is evaluated against the data" $
+      uses "{\"reduce\":[{\"var\":\"xs\"},{\"var\":\"current\"},{\"var\":\"start\"}]}" `shouldBe` reads' ["start", "xs"]
+    it "lists the keys of missing and missing_some" $ do
+      uses "{\"missing\":[\"a\",\"b\"]}" `shouldBe` reads' ["a", "b"]
+      uses "{\"missing\":[[\"a\",\"b\"],\"ignored\"]}" `shouldBe` reads' ["a", "b"]
+      uses "{\"missing_some\":[1,[\"a\",\"b\"]]}" `shouldBe` reads' ["a", "b"]
+    it "reads a numeric path as the string var would use" $
+      uses "{\"var\":1}" `shouldBe` reads' ["1"]
+    it "gives up, explicitly, on what cannot be known" $
+      mapM_
+        (\rule -> fmap usesAllData (uses rule) `shouldBe` Right True)
+        [ "{\"var\":\"\"}"
+        , "{\"var\":[]}"
+        , "{\"var\":{\"cat\":[\"a\",\"b\"]}}"
+        , "{\"missing\":{\"var\":\"keys\"}}"
+        , "{\"missing_some\":[1,{\"var\":\"keys\"}]}"
+        , "{\"custom\":[1]}"
+        ]
+    it "reads nothing from a rule with no lookups" $
+      uses "{\"if\":[true,1,2]}" `shouldBe` reads' []
+
+  it "a rule's answer does not change when data it does not read changes" $
+    checkCoverage $
+      forAll (genRule 6) $ \rule -> forAll (genData 3) $ \dat -> forAll (genData 2) $ \replacement ->
+        forAll (elements [defaultConfig, defaultConfig{arityMode = Lenient}, defaultConfig{maxSteps = 40}]) $ \config ->
+          case (compile config noOperators rule, dat) of
+            (Right compiled, Object fields)
+              | DataUse paths False <- dataUse compiled ->
+                  let unread = [k | k <- topKeys, k `notElem` map firstSegment paths]
+                      changed k = Object (KeyMap.insert (Key.fromText k) replacement fields)
+                      removed k = Object (KeyMap.delete (Key.fromText k) fields)
+                   in cover 5 (any (`elem` ["a", "b", "l"]) unread) "a key the data has goes unread" $
+                        conjoin
+                          [ counterexample (show (k, paths)) (run compiled other === run compiled dat)
+                          | k <- unread
+                          , other <- [changed k, removed k]
+                          ]
+            _ -> cover 5 False "a key the data has goes unread" (property True)
diff --git a/test/CompatSpec.hs b/test/CompatSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/CompatSpec.hs
@@ -0,0 +1,208 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | Compatibility with the reference where the audit found disagreements:
+-- arity modes, UTF-16 strings, signed zero, whitespace, the custom
+-- operator registry, and the other behaviours the outlier fixture also
+-- covers, each stated here as an example a reader can follow.
+module CompatSpec (spec) where
+
+import Data.Aeson (Value (..), object, toJSON, (.=))
+import qualified Data.Aeson.Key as Key
+import qualified Data.Text as Text
+import Test.Hspec
+import Test.QuickCheck
+
+import Gen (genText)
+import JsonLogic
+import JsonLogic.Core.Location (root)
+import JsonLogic.Core.Utf16
+import Support (Problem (..), at, json, problem)
+
+-- | A registry that the test expects to be accepted.
+registry :: [(OperatorName, CustomOperator)] -> Operators
+registry = either (error . show) id . operatorsFromList
+
+strict, lenient :: String -> String -> Either Problem Value
+strict rule dat = problem (apply (json rule) (json dat))
+lenient rule dat = problem (applyWithConfig defaultConfig{arityMode = Lenient} noOperators (json rule) (json dat))
+
+-- | Is a rule's numeric result negative zero? Observed through 1 / x.
+yieldsNegativeZero :: String -> Bool
+yieldsNegativeZero rule = strict ("{\"<\":[{\"/\":[1," ++ rule ++ "]},0]}") "null" == Right (Bool True)
+
+spec :: Spec
+spec = do
+  describe "arity modes" $ do
+    it "strict rejects a wrong argument count precisely" $
+      strict "{\"==\":[1]}" "null"
+        `shouldBe` Left (ParseProblem (ArityMismatch (OperatorName "==") (Exactly 2) 1))
+    it "lenient treats a missing argument as undefined" $ do
+      lenient "{\"==\":[null]}" "null" `shouldBe` Right (Bool True)
+      lenient "{\"==\":[1]}" "null" `shouldBe` Right (Bool False)
+    it "lenient ignores a surplus argument" $
+      lenient "{\"+\":[1]}" "null" `shouldBe` Right (Number 1)
+    it "lenient still evaluates a surplus argument of an ordinary operator, so it can fail" $
+      lenient "{\"==\":[1,1,{\"nope\":[]}]}" "null"
+        `shouldBe` Left (EvalProblem (UnknownOperator (OperatorName "nope")))
+    it "lenient never looks at a surplus argument of map, as in the reference" $
+      lenient "{\"map\":[[1],{\"var\":\"\"},{\"nope\":[]}]}" "null" `shouldBe` Right (toJSON [1 :: Int])
+    it "lenient reduce without an initial value starts from null" $
+      lenient "{\"reduce\":[[1,2],{\"cat\":[{\"var\":\"accumulator\"},{\"var\":\"current\"}]}]}" "null"
+        `shouldBe` Right (String "12")
+    it "strict rejects * with no operands before evaluating anything" $
+      strict "{\"*\":[]}" "null" `shouldBe` Left (ParseProblem (ArityMismatch (OperatorName "*") (AtLeast 1) 0))
+    it "lenient throws on * with no operands only when it is evaluated, as the reference does" $ do
+      lenient "{\"*\":[]}" "null" `shouldBe` Left (EvalProblem (InvalidOperand (OperatorName "*")))
+      lenient "{\"if\":[true,1,{\"*\":[]}]}" "null" `shouldBe` Right (Number 1)
+      lenient "{\"or\":[1,{\"*\":[]}]}" "null" `shouldBe` Right (Number 1)
+      lenient "{\"and\":[0,{\"*\":[]}]}" "null" `shouldBe` Right (Number 0)
+      lenient "{\"map\":[[],{\"*\":[]}]}" "null" `shouldBe` Right (toJSON ([] :: [Int]))
+      lenient "{\"and\":[1,{\"*\":[]}]}" "null" `shouldBe` Left (EvalProblem (InvalidOperand (OperatorName "*")))
+    it "locates the empty * that was reached" $
+      applyWithConfig defaultConfig{arityMode = Lenient} noOperators (json "{\"and\":[1,{\"*\":[]}]}") Null
+        `shouldBe` Left (EvalFailure (at [Left "and", Right 1]) (InvalidOperand (OperatorName "*")))
+    it "strict success implies the same lenient answer" $
+      forAll (genRuleForArity 4) $ \rule -> case apply rule Null of
+        Right v -> applyWithConfig defaultConfig{arityMode = Lenient} noOperators rule Null === Right v
+        Left _ -> property True
+
+  describe "UTF-16 strings" $ do
+    it "an emoji has length 2" $
+      strict "{\"var\":\"s.length\"}" "{\"s\":\"\\ud83d\\ude00\"}" `shouldBe` Right (Number 2)
+    it "a BMP character has length 1" $
+      strict "{\"var\":\"s.length\"}" "{\"s\":\"\\u00e9\"}" `shouldBe` Right (Number 1)
+    it "indexing inside a pair gives U+FFFD, the one accepted deviation" $
+      strict "{\"var\":\"s.0\"}" "{\"s\":\"\\ud83d\\ude00\"}" `shouldBe` Right (String "\xFFFD")
+    it "substr on pair boundaries returns the whole character" $
+      strict "{\"substr\":[\"a\\ud83d\\ude00b\",1,2]}" "null" `shouldBe` Right (String "\x1F600")
+    it "substr through a pair gives U+FFFD for the severed half" $
+      strict "{\"substr\":[\"\\ud83d\\ude00\",0,1]}" "null" `shouldBe` Right (String "\xFFFD")
+    it "orders an astral character below U+E000, as JavaScript does" $ do
+      strict "{\"<\":[\"\\ud83d\\ude00\",\"\\ue000\"]}" "null" `shouldBe` Right (Bool True)
+      strict "{\"<\":[\"\\ue000\",\"\\ud83d\\ude00\"]}" "null" `shouldBe` Right (Bool False)
+    it "orders astral characters by their code points among themselves" $
+      strict "{\"<\":[\"\\ud83d\\ude00\",\"\\ud83d\\ude01\"]}" "null" `shouldBe` Right (Bool True)
+    it "utf16Length equals the length of the UTF-16 encoding" $
+      forAll genText $
+        \t -> utf16Length t === length (utf16Units t)
+    it "units round-trip" $
+      forAll genText $
+        \t -> fromUtf16Units (utf16Units t) === t
+    it "compareUtf16 is code unit order" $
+      forAll genText $
+        \a -> forAll genText $ \b -> compareUtf16 a b === compare (utf16Units a) (utf16Units b)
+    it "slices on character boundaries put the text back together" $
+      forAll genText $ \t -> forAll (choose (0, Text.length t)) $ \k ->
+        let n = utf16Length t
+            cut = utf16Length (Text.take k t)
+         in utf16Slice 0 cut t <> utf16Slice cut (n - cut) t === t
+
+  describe "signed zero" $ do
+    it "Math.max puts +0 above -0 in either order" $ do
+      yieldsNegativeZero "{\"max\":[0,{\"*\":[-1,0]}]}" `shouldBe` False
+      yieldsNegativeZero "{\"max\":[{\"*\":[-1,0]},0]}" `shouldBe` False
+    it "Math.min puts -0 below +0 in either order" $ do
+      yieldsNegativeZero "{\"min\":[0,{\"*\":[-1,0]}]}" `shouldBe` True
+      yieldsNegativeZero "{\"min\":[{\"*\":[-1,0]},0]}" `shouldBe` True
+    it "-0 stays -0 through max and min of itself" $ do
+      yieldsNegativeZero "{\"max\":[{\"*\":[-1,0]},{\"*\":[-1,0]}]}" `shouldBe` True
+      yieldsNegativeZero "{\"min\":[{\"*\":[-1,0]}]}" `shouldBe` True
+    it "a remainder of zero takes the sign of the dividend" $ do
+      yieldsNegativeZero "{\"%\":[-4,2]}" `shouldBe` True
+      yieldsNegativeZero "{\"%\":[4,-2]}" `shouldBe` False
+      yieldsNegativeZero "{\"%\":[-1,1]}" `shouldBe` True
+      yieldsNegativeZero "{\"%\":[{\"*\":[-1,0]},5]}" `shouldBe` True
+    it "the observation 0 > 1 / max(0, -0) is false, as in JavaScript" $
+      strict "{\">\":[0,{\"/\":[1,{\"max\":[0,{\"*\":[-1,0]}]}]}]}" "null" `shouldBe` Right (Bool False)
+
+  describe "string to number whitespace" $ do
+    let plus c = strict ("{\"+\":[\"" ++ c ++ "12\"]}") "null"
+        times c = strict ("{\"*\":[\"" ++ c ++ "12\",1]}") "null"
+    it "trims every ECMAScript WhiteSpace and LineTerminator" $
+      mapM_
+        (\c -> (plus c, times c) `shouldBe` (Right (Number 12), Right (Number 12)))
+        ["\\ufeff", "\\u2028", "\\u2029", "\\u00a0", "\\u1680", "\\u2000", "\\u200a", "\\u202f", "\\u205f", "\\u3000", "\\t", "\\n", "\\u000b", "\\u000c", "\\r", " "]
+    it "does not trim near misses" $
+      mapM_
+        (\c -> (plus c, times c) `shouldBe` (Right Null, Right Null))
+        ["\\u0085", "\\u180e", "\\u200b", "\\u001c", "\\u0000", "\\u2060"]
+
+  describe "numbers" $ do
+    it "rounds halfway decimal ties to the even digit, as ECMAScript prints" $
+      strict "{\"/\":[9007199254740993,10]}" "null" `shouldBe` Right (json "900719925474099.2")
+    it "converts a million-digit string without hanging" $ do
+      let digits = replicate 1000000 '7'
+      applyWithConfig defaultConfig noOperators (json ("{\"+\":[\"" ++ digits ++ "\"]}")) Null
+        `shouldBe` Right Null
+    it "keeps 768 digits significant and a sticky tail" $ do
+      let half n = "0." ++ replicate n '0' ++ "1"
+      strict ("{\"+\":[\"" ++ half 400 ++ "\"]}") "null" `shouldBe` Right (Number 0)
+    it "reads a hex literal of any length without hanging" $
+      strict ("{\"==\":[\"0x" ++ replicate 100000 'f' ++ "\",{\"/\":[1,0]}]}") "null" `shouldBe` Right (Bool True)
+
+  describe "missing and missing_some" $ do
+    it "reads an array key as a variable's path and default" $ do
+      strict "{\"missing\":[\"q\",[\"a\",\"x\"]]}" "{}" `shouldBe` Right (json "[\"q\"]")
+      strict "{\"missing\":[\"q\",[\"a\",null]]}" "{}" `shouldBe` Right (json "[\"q\",[\"a\",null]]")
+    it "counts string options by UTF-16 length, as the reference does" $
+      strict "{\"missing_some\":[2,\"ab\"]}" "{}" `shouldBe` Right (json "[\"ab\"]")
+    it "throws where the reference throws on a null option list" $
+      strict "{\"missing_some\":[1,null]}" "{}"
+        `shouldBe` Left (EvalProblem (InvalidOperand (OperatorName "missing_some")))
+    it "returns the missing keys when the options have no length" $
+      strict "{\"missing_some\":[0,0]}" "{}" `shouldBe` Right (json "[0]")
+
+  describe "inherited property names (DEV-INHERITED)" $ do
+    it "sees only own properties, where the reference walks the prototype chain" $
+      mapM_
+        (\name -> strict ("{\"var\":\"" ++ name ++ "\"}") "{}" `shouldBe` Right Null)
+        ["constructor", "__proto__", "toString", "hasOwnProperty", "valueOf"]
+    it "still finds an own property of that name" $
+      strict "{\"var\":\"constructor\"}" "{\"constructor\":7}" `shouldBe` Right (Number 7)
+
+  describe "undefined" $ do
+    it "an object property that is undefined is left out, as JSON.stringify does" $
+      strict "{\"reduce\":[[0],{\"var\":\"\"},{\"and\":[]}]}" "null" `shouldBe` Right (json "{\"current\":0}")
+
+  describe "custom operators" $ do
+    let double :: CustomOperator
+        double _ args = case args of
+          [Number n] -> Right (Number (n * 2))
+          _ -> Left "expected one number"
+    it "are refused when named like a builtin, instead of being ignored" $
+      case operatorsFromList [(OperatorName "var", double)] of
+        Left e -> e `shouldBe` ReservedOperatorName (OperatorName "var")
+        Right _ -> expectationFailure "a builtin name was accepted"
+    it "are refused when a name is given twice" $
+      case operatorsFromList [(OperatorName "double", double), (OperatorName "double", double)] of
+        Left e -> e `shouldBe` DuplicateOperatorName (OperatorName "double")
+        Right _ -> expectationFailure "a duplicate name was accepted"
+    it "compose left-biased" $ do
+      let a = registry [(OperatorName "f", \_ _ -> Right (toJSON (1 :: Int)))]
+          b = registry [(OperatorName "f", \_ _ -> Right (toJSON (2 :: Int))), (OperatorName "g", \_ _ -> Right (toJSON (3 :: Int)))]
+          both = a <> b
+      applyWith both (object ["f" .= ([] :: [Int])]) Null `shouldBe` Right (Number 1)
+      applyWith both (object ["g" .= ([] :: [Int])]) Null `shouldBe` Right (Number 3)
+    it "a custom failure inside map stops the evaluation" $ do
+      let ops = registry [(OperatorName "double", double)]
+      applyWith ops (json "{\"map\":[[1,\"x\"],{\"double\":[{\"var\":\"\"}]}]}") Null
+        `shouldBe` Left (EvalFailure (at [Left "map", Right 1]) (CustomOperatorFailed (OperatorName "double") "expected one number"))
+    it "combining with the empty registry changes nothing" $ do
+      let ops = registry [(OperatorName "double", double)]
+      applyWith (ops <> noOperators) (json "{\"double\":[2]}") Null `shouldBe` Right (Number 4)
+
+  describe "renderError" $ do
+    it "describes each constructor, after the location" $ do
+      renderError (ParseFailure root (DepthExceeded 64)) `shouldBe` "at the root of the rule: the rule nests deeper than the limit of 64"
+      renderError (EvalFailure (at [Left "if", Right 2]) (UnknownOperator (OperatorName "x")))
+        `shouldBe` "at /if/2: unknown operator \"x\""
+      renderError (EvalFailure root (LimitExceeded (StepLimit 9))) `shouldBe` "at the root of the rule: evaluation exceeded the limit of 9 steps"
+      renderError (ParseFailure (at [Left "and", Right 0]) (ArityMismatch (OperatorName "==") (Exactly 2) 1))
+        `shouldBe` "at /and/0: operator \"==\" takes exactly 2 arguments, but was given 1"
+
+genRuleForArity :: Int -> Gen Value
+genRuleForArity _ = do
+  name <- elements ["+", "==", "<", "-", "cat", "max", "!", "if", "and", "merge"]
+  k <- choose (0, 3)
+  args <- vectorOf k (oneof [toJSON <$> (choose (-3, 3) :: Gen Int), pure Null, String <$> genText])
+  pure (object [Key.fromText name .= args])
diff --git a/test/EvalSpec.hs b/test/EvalSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/EvalSpec.hs
@@ -0,0 +1,254 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module EvalSpec (spec) where
+
+import Data.Aeson (Value (..), decode, object, toJSON, (.=))
+import qualified Data.ByteString.Lazy as LBS
+import qualified Data.ByteString.Lazy.Char8 as Char8
+import Data.Fix (Fix (..))
+import qualified Data.Text as Text
+import Test.Hspec
+import Test.QuickCheck (Gen, choose, forAll, listOf, oneof)
+
+import JsonLogic hiding (Rule)
+import JsonLogic.Core.Eval (evaluate)
+import JsonLogic.Core.Location (root)
+import JsonLogic.Core.Rule (Node (..), Rule, RuleF (..))
+import Support (Problem (..), at, problem)
+
+-- | Test cases are written with single quotes so the JSON stays readable.
+q :: LBS.ByteString -> LBS.ByteString
+q = LBS.map (\byte -> if byte == 39 then 34 else byte)
+
+-- | @evaluates rule input expected@
+evaluates :: LBS.ByteString -> LBS.ByteString -> LBS.ByteString -> Expectation
+evaluates rule input expected =
+  case (decode (q rule), decode (q input), decode (q expected)) of
+    (Just r, Just i, Just e) -> apply r i `shouldBe` Right e
+    _ -> expectationFailure "test case contains invalid JSON"
+
+-- | (rule, data, expected result). Every one of these is behaviour of the
+-- reference implementation, including its quirks.
+cases :: [(LBS.ByteString, LBS.ByteString, LBS.ByteString)]
+cases =
+  [ -- equality and logic
+    ("{'==':[1,1]}", "{}", "true")
+  , ("{'==':[1,'1']}", "{}", "true")
+  , ("{'===':[1,'1']}", "{}", "false")
+  , ("{'!=':[1,2]}", "{}", "true")
+  , ("{'!==':[1,'1']}", "{}", "true")
+  , ("{'!':[true]}", "{}", "false")
+  , ("{'!!':[[]]}", "{}", "false")
+  , ("{'!!':['0']}", "{}", "true")
+  , ("{'and':[true,'a',3]}", "{}", "3")
+  , ("{'and':[true,'',3]}", "{}", "''")
+  , ("{'or':[false,'',0]}", "{}", "0")
+  , ("{'or':[false,'a']}", "{}", "'a'")
+  , ("{'and':[]}", "{}", "null")
+  , -- if
+    ("{'if':[true,'yes','no']}", "{}", "'yes'")
+  , ("{'if':[false,'a',true,'b','c']}", "{}", "'b'")
+  , ("{'if':[false,'a','c']}", "{}", "'c'")
+  , ("{'if':[]}", "{}", "null")
+  , -- variables
+    ("{'var':'a'}", "{'a':1}", "1")
+  , ("{'var':['z',26]}", "{'a':1}", "26")
+  , ("{'var':'a.b'}", "{'a':{'b':2}}", "2")
+  , ("{'var':1}", "['apple','banana']", "'banana'")
+  , ("{'var':''}", "{'a':1}", "{'a':1}")
+  , ("{'var':'a.length'}", "{'a':[1,2,3]}", "3")
+  , ("{'var':['a',5]}", "{'a':null}", "null")
+  , ("{'missing':['a','b']}", "{'a':1}", "['b']")
+  , ("{'missing_some':[1,['a','b','c']]}", "{'a':1}", "[]")
+  , ("{'missing_some':[2,['a','b','c']]}", "{'a':1}", "['b','c']")
+  , -- ordering
+    ("{'<':[1,2]}", "{}", "true")
+  , ("{'<':[1,2,3]}", "{}", "true")
+  , ("{'<':[1,3,2]}", "{}", "false")
+  , ("{'<=':[1,1]}", "{}", "true")
+  , ("{'>':[2,1]}", "{}", "true")
+  , ("{'>=':[1,1]}", "{}", "true")
+  , ("{'<':['10','9']}", "{}", "true")
+  , ("{'<':['2',10]}", "{}", "true")
+  , -- arithmetic
+    ("{'+':[1,2,3]}", "{}", "6")
+  , ("{'+':['3.14']}", "{}", "3.14")
+  , ("{'+':[]}", "{}", "0")
+  , ("{'*':[2,3,4]}", "{}", "24")
+  , ("{'*':['3']}", "{}", "'3'")
+  , ("{'-':[5,2]}", "{}", "3")
+  , ("{'-':[5]}", "{}", "-5")
+  , ("{'/':[4,2]}", "{}", "2")
+  , ("{'/':[1,0]}", "{}", "null")
+  , ("{'%':[101,2]}", "{}", "1")
+  , ("{'max':[1,3,2]}", "{}", "3")
+  , ("{'min':[]}", "{}", "null")
+  , ("{'max':[1,'a']}", "{}", "null")
+  , -- collections
+    ("{'map':[{'var':'nums'},{'*':[{'var':''},2]}]}", "{'nums':[1,2,3]}", "[2,4,6]")
+  , ("{'filter':[[1,2,3,4],{'%':[{'var':''},2]}]}", "{}", "[1,3]")
+  , ("{'reduce':[[1,2,3],{'+':[{'var':'current'},{'var':'accumulator'}]},0]}", "{}", "6")
+  , ("{'all':[[1,2],{'>':[{'var':''},0]}]}", "{}", "true")
+  , ("{'all':[[],true]}", "{}", "false")
+  , ("{'none':[[1,2],{'<':[{'var':''},0]}]}", "{}", "true")
+  , ("{'some':[[1,-2],{'<':[{'var':''},0]}]}", "{}", "true")
+  , ("{'map':[5,{'var':''}]}", "{}", "[]")
+  , ("{'merge':[[1,2],[3,[4]],5]}", "{}", "[1,2,3,[4],5]")
+  , -- strings
+    ("{'in':['Spring','Springfield']}", "{}", "true")
+  , ("{'in':['a',['a','b']]}", "{}", "true")
+  , ("{'in':['z',['a']]}", "{}", "false")
+  , ("{'cat':['a',1,null,true]}", "{}", "'a1true'")
+  , ("{'substr':['jsonlogic',4]}", "{}", "'logic'")
+  , ("{'substr':['jsonlogic',-5]}", "{}", "'logic'")
+  , ("{'substr':['jsonlogic',1,3]}", "{}", "'son'")
+  , ("{'substr':['jsonlogic',4,-2]}", "{}", "'log'")
+  , ("{'log':'hi'}", "{}", "'hi'")
+  , -- undefined (what an empty and / or yields) is not null
+    ("{'max':[{'and':[]}]}", "{}", "null")
+  , ("{'+':[1,{'and':[]}]}", "{}", "null")
+  , ("{'cat':['a',{'and':[]},'b']}", "{}", "'ab'")
+  , ("{'==':[{'and':[]},null]}", "{}", "true")
+  , ("{'===':[{'and':[]},null]}", "{}", "false")
+  , ("{'==':[{'and':[]},0]}", "{}", "false")
+  , ("{'==':[{'and':[]},false]}", "{}", "false")
+  , ("{'in':[{'and':[]},'xundefinedx']}", "{}", "true")
+  , ("{'substr':[{'and':[]},0,3]}", "{}", "'und'")
+  , ("{'<':[{'and':[]},1]}", "{}", "false")
+  , ("{'min':[{'>=':[true,{'and':[]}]}]}", "{}", "0")
+  , ("{'or':[false,{'and':[]}]}", "{}", "null")
+  , ("{'!':[{'and':[]}]}", "{}", "true")
+  , ("{'-':[{'and':[]}]}", "{}", "null")
+  , ("{'var':[{'and':[]}]}", "{'a':1}", "{'a':1}")
+  , ("{'var':['a',{'and':[]}]}", "{}", "null")
+  , ("{'map':[[{'and':[]},1],{'var':''}]}", "{}", "[null,1]")
+  , ("{'map':[[{'and':[]},1],{'var':'x'}]}", "{}", "[null,null]")
+  , ("{'merge':[[{'and':[]}]]}", "{}", "[null]")
+  , ("{'missing':[{'and':[]}]}", "{'a':1}", "[]")
+  , -- an optional operand that evaluates to undefined counts as absent
+    ("{'-':[5,{'and':[]}]}", "{}", "-5")
+  , ("{'-':['7',{'and':[]}]}", "{}", "-7")
+  , ("{'-':[[1.5],{'and':[]}]}", "{}", "-1.5")
+  , ("{'-':[[],{'and':[]}]}", "{}", "0")
+  , ("{'<':[1,2,{'and':[]}]}", "{}", "true")
+  , ("{'<':[2,1,{'and':[]}]}", "{}", "false")
+  , ("{'<=':[1,1,{'and':[]}]}", "{}", "true")
+  , ("{'substr':['jsonlogic',4,{'and':[]}]}", "{}", "'logic'")
+  , ("{'substr':['jsonlogic',4,null]}", "{}", "''")
+  , ("{'substr':['jsonlogic',{'and':[]},3]}", "{}", "'jso'")
+  , -- numbers beyond 2^53 print and parse exactly as JavaScript does
+    ("{'max':[{'cat':[-2.25,'3',1e21]}]}", "{}", "-2.2531e21")
+  , ("{'cat':[2.2531e21]}", "{}", "'2.2531e+21'")
+  , ("{'cat':[9007199254740993]}", "{}", "'9007199254740992'")
+  , ("{'cat':[1e21]}", "{}", "'1e+21'")
+  , ("{'+':['1e400']}", "{}", "null")
+  , ("{'+':['1e-400']}", "{}", "0")
+  , ("{'+':['5e-324']}", "{}", "5e-324")
+  , ("{'+':['123456789012345678901234567890']}", "{}", "1.2345678901234568e29")
+  , -- literals, arrays, laziness
+    ("[{'var':'a'},2]", "{'a':1}", "[1,2]")
+  , ("{'a':1,'b':2}", "{}", "{'a':1,'b':2}")
+  , ("5", "{}", "5")
+  , ("{'or':[true,{'nope':[]}]}", "{}", "true")
+  , ("{'and':[false,{'nope':[]}]}", "{}", "false")
+  , ("{'if':[true,'a',{'nope':[]}]}", "{}", "'a'")
+  ]
+
+label :: LBS.ByteString -> LBS.ByteString -> String
+label rule input = Char8.unpack (q rule) ++ "  on  " ++ Char8.unpack (q input)
+
+-- | A rule nested @n@ operator levels deep around a literal leaf.
+nested :: Int -> Value
+nested n = foldr (\_ inner -> object ["!" .= inner]) (Number 0) [1 .. n]
+
+doubling :: Operators
+doubling = either (error . show) id (operatorsFromList [(OperatorName "double", implementation)])
+  where
+    implementation _ [Number n] = Right (Number (n * 2))
+    implementation _ _ = Left "expected one number"
+
+withJson :: LBS.ByteString -> (Value -> Expectation) -> Expectation
+withJson text check =
+  maybe (expectationFailure "invalid JSON in test") check (decode (q text))
+
+genJson :: Gen Value
+genJson = oneof [scalar, toJSON <$> listOf scalar]
+  where
+    scalar =
+      oneof
+        [ pure Null
+        , Bool <$> oneof [pure True, pure False]
+        , toJSON <$> (choose (-1000, 1000) :: Gen Int)
+        , String . Text.pack <$> listOf (choose ('a', 'z'))
+        ]
+
+-- | A node built by hand, at the root (the laws do not look at locations).
+node :: RuleF Rule -> Rule
+node = Fix . Node root
+
+lit :: Value -> Rule
+lit = node . Lit
+
+result :: Rule -> Either EvalError Value
+result rule = either (Left . snd) Right (evaluate rule Null)
+
+spec :: Spec
+spec = describe "evaluation" $ do
+  describe "reference behaviour" $
+    mapM_ (\(rule, input, expected) -> it (label rule input) (evaluates rule input expected)) cases
+
+  describe "errors" $ do
+    it "reports an unknown operator, where it is" $
+      withJson "{'nope':[1]}" $ \rule ->
+        apply rule Null `shouldBe` Left (EvalFailure root (UnknownOperator (OperatorName "nope")))
+    it "evaluates arguments before the operator, so a bad one still fails" $
+      withJson "{'+':[1,{'nope':[]}]}" $ \rule ->
+        apply rule Null `shouldBe` Left (EvalFailure (at [Left "+", Right 1]) (UnknownOperator (OperatorName "nope")))
+    it "locates a failure inside an iteration body" $
+      withJson "{'map':[[1],{'nope':[]}]}" $ \rule ->
+        apply rule Null `shouldBe` Left (EvalFailure (at [Left "map", Right 1]) (UnknownOperator (OperatorName "nope")))
+    it "surfaces parse errors, with their location" $
+      withJson "{'!':{'==':[1]}}" $ \rule ->
+        apply rule Null
+          `shouldBe` Left (ParseFailure (at [Left "!"]) (ArityMismatch (OperatorName "==") (Exactly 2) 1))
+    it "rejects rules nested past the depth limit before evaluating" $
+      problem (apply (nested 65) Null) `shouldBe` Left (ParseProblem (DepthExceeded 64))
+    it "renders an error with its JSON Pointer" $ do
+      renderError (EvalFailure (at [Left "if", Right 1]) (UnknownOperator (OperatorName "x")))
+        `shouldBe` "at /if/1: unknown operator \"x\""
+      renderError (EvalFailure root (UnknownOperator (OperatorName "x")))
+        `shouldBe` "at the root of the rule: unknown operator \"x\""
+    it "escapes ~ and / in a pointer, as RFC 6901 requires" $
+      renderLocation (at [Left "a/b", Left "c~d", Right 3]) `shouldBe` "/a~1b/c~0d/3"
+
+  describe "custom operators" $ do
+    it "runs a supplied operator on evaluated arguments" $
+      withJson "{'double':[{'var':'x'}]}" $ \rule ->
+        withJson "{'x':21}" $ \input ->
+          applyWith doubling rule input `shouldBe` Right (Number 42)
+    it "reports the operator's own failure" $
+      withJson "{'double':['a']}" $ \rule ->
+        applyWith doubling rule Null
+          `shouldBe` Left (EvalFailure root (CustomOperatorFailed (OperatorName "double") "expected one number"))
+
+  describe "laws" $ do
+    it "De Morgan: not (x and y) agrees with (not x) or (not y)" $
+      forAll genJson $ \x -> forAll genJson $ \y ->
+        result (node (Not (node (And [lit x, lit y]))))
+          == result (node (Or [node (Not (lit x)), node (Not (lit y))]))
+    it "!! is not-not" $
+      forAll genJson $ \x ->
+        result (node (DoubleNot (lit x))) == result (node (Not (node (Not (lit x)))))
+    it "!! is idempotent" $
+      forAll genJson $ \x ->
+        result (node (DoubleNot (node (DoubleNot (lit x))))) == result (node (DoubleNot (lit x)))
+    it "if with a true condition is its consequent" $
+      forAll genJson $ \a -> forAll genJson $ \b ->
+        result (node (If [lit (Bool True), lit a, lit b])) == result (lit a)
+    it "if with a false condition is its alternative" $
+      forAll genJson $ \a -> forAll genJson $ \b ->
+        result (node (If [lit (Bool False), lit a, lit b])) == result (lit b)
+    it "a one-operand and / or is that operand" $
+      forAll genJson $ \x ->
+        result (node (And [lit x])) == result (lit x)
+          && result (node (Or [lit x])) == result (lit x)
diff --git a/test/Gen.hs b/test/Gen.hs
new file mode 100644
--- /dev/null
+++ b/test/Gen.hs
@@ -0,0 +1,74 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | Generators shared by the property tests: bounded random rules and data
+-- over every builtin operator, deliberately including the awkward values
+-- (empty and non-ASCII strings, signed zero, huge and tiny numbers).
+module Gen
+  ( genData
+  , genRule
+  , genText
+  , allOperators
+  ) where
+
+import Data.Aeson (Value (..), object, toJSON, (.=))
+import qualified Data.Aeson.Key as Key
+import Data.Text (Text)
+import qualified Data.Text as Text
+import Test.QuickCheck
+
+import JsonLogic.Core.Operator (allBuiltins, builtinName)
+
+-- | Every builtin operator's name, from the library's own table.
+allOperators :: [Text]
+allOperators = map builtinName allBuiltins
+
+-- | Text drawn from ASCII, BMP characters above U+E000, and astral
+-- characters, so UTF-16 effects show up.
+genText :: Gen Text
+genText = Text.pack <$> listOf (elements pool)
+  where
+    pool = "ab0 1.-e" ++ ['\xE000', '\xFFFF', '\xFFFD', '\x1F600', '\x10000', '\x10FFFF', '\x0301']
+
+genScalar :: Gen Value
+genScalar =
+  oneof
+    [ pure Null
+    , Bool <$> arbitrary
+    , toJSON <$> (choose (-5, 12) :: Gen Int)
+    , toJSON <$> elements [0.5, -2.25, 1e21, 1e-7, 5e-324, 1.7976931348623157e308 :: Double]
+    , String <$> genText
+    , String <$> elements ["", "0", "1", "10", "9", "0x10", " 12 ", "Infinity", "NaN", "a.b"]
+    ]
+
+-- | Data: scalars, arrays and objects with the keys rules look for.
+genData :: Int -> Gen Value
+genData 0 = genScalar
+genData n =
+  frequency
+    [ (3, genScalar)
+    , (2, toJSON <$> listOf (genData (n - 1)))
+    ,
+      ( 2
+      , do
+          a <- genData (n - 1)
+          b <- genData (n - 1)
+          l <- listOf (genData (n - 1))
+          pure (object ["a" .= a, "b" .= b, "l" .= l])
+      )
+    ]
+
+-- | A rule of bounded size. Argument counts are arbitrary on purpose, so
+-- both arity modes get exercised.
+genRule :: Int -> Gen Value
+genRule 0 =
+  oneof
+    [ genScalar
+    , (\p -> object ["var" .= (p :: Text)]) <$> elements ["a", "b", "l", "l.0", "l.length", "", "a.b"]
+    ]
+genRule n = frequency [(2, genRule 0), (6, call)]
+  where
+    call = do
+      name <- elements allOperators
+      k <- choose (0, 3)
+      args <- vectorOf k (genRule (n `div` 2))
+      pure (object [Key.fromText name .= args])
diff --git a/test/LimitsSpec.hs b/test/LimitsSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/LimitsSpec.hs
@@ -0,0 +1,174 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | The resource limits: boundary triples (limit - 1, limit, limit + 1)
+-- for every limit, the amplification rules that would otherwise hang or
+-- exhaust memory, and properties relating the limits to results.
+module LimitsSpec (spec) where
+
+import Control.Exception (evaluate)
+import Data.Aeson (Value (..), encode, object, toJSON, (.=))
+import qualified Data.ByteString.Lazy.Char8 as Char8
+import Data.Either (fromRight)
+import qualified Data.Text
+import System.Timeout (timeout)
+import Test.Hspec
+import Test.QuickCheck
+
+import Gen (genData, genRule)
+import JsonLogic
+import Support (Problem (..), isRight', json, problem)
+
+run' :: Config -> String -> Value -> Either Problem Value
+run' config rule = problem . applyWithConfig config noOperators (json rule)
+
+-- | Force the whole answer, and fail the test if it takes over ten seconds.
+within10s :: Either e Value -> IO (Either e Value)
+within10s result = do
+  outcome <- timeout 10000000 (evaluate (Char8.length (encode (fromRight Null result)) `seq` result))
+  maybe (expectationFailure "evaluation did not finish in 10 seconds" >> pure result) pure outcome
+
+nestedMap :: Int -> Value
+nestedMap levels = foldr (\_ inner -> object ["map" .= [toJSON [1 .. 10 :: Int], inner]]) (object ["var" .= ("" :: String)]) [1 .. levels]
+
+spec :: Spec
+spec = describe "resource limits" $ do
+  describe "steps" $ do
+    -- {"+":[1,1]} is three nodes: the + and its two operands
+    it "allows exactly the budget" $
+      run' defaultConfig{maxSteps = 3} "{\"+\":[1,1]}" Null `shouldBe` Right (Number 2)
+    it "rejects one step short" $
+      run' defaultConfig{maxSteps = 2} "{\"+\":[1,1]}" Null
+        `shouldBe` Left (EvalProblem (LimitExceeded (StepLimit 2)))
+    it "rejects when nothing is allowed" $
+      run' defaultConfig{maxSteps = 0} "1" Null `shouldBe` Left (EvalProblem (LimitExceeded (StepLimit 0)))
+    it "charges nothing extra for an array index, which is constant time" $ do
+      let rule = "{\"var\":\"x.2\"}"
+          dat = json "{\"x\":[10,20,30]}"
+      -- the var and its path are two steps
+      run' defaultConfig{maxSteps = 2} rule dat `shouldBe` Right (Number 30)
+      run' defaultConfig{maxSteps = 1} rule dat `shouldSatisfy` isStepLimit
+    it "charges a string index for the code points it walks" $ do
+      let rule = "{\"var\":\"s.2\"}"
+          dat = json "{\"s\":\"abcd\"}"
+      -- two nodes, and the four code points of the string
+      run' defaultConfig{maxSteps = 6} rule dat `shouldBe` Right (String "c")
+      run' defaultConfig{maxSteps = 5} rule dat `shouldSatisfy` isStepLimit
+    it "charges every iteration of map" $ do
+      let rule = "{\"map\":[[1,2,3],{\"var\":\"\"}]}"
+      -- map node (1), the array and its three literals (4), and three
+      -- bodies of two nodes each, the var and its path (6)
+      run' defaultConfig{maxSteps = 11} rule Null `shouldBe` Right (toJSON [1, 2, 3 :: Int])
+      run' defaultConfig{maxSteps = 10} rule Null `shouldSatisfy` isStepLimit
+
+  describe "string length" $ do
+    let rule = "{\"cat\":[\"aaaa\",\"bbbb\"]}"
+    it "allows exactly the limit" $
+      run' defaultConfig{maxStringLength = 8} rule Null `shouldBe` Right (String "aaaabbbb")
+    it "rejects one over" $
+      run' defaultConfig{maxStringLength = 7} rule Null
+        `shouldBe` Left (EvalProblem (LimitExceeded (StringLimit 7)))
+    it "counts UTF-16 code units, so an emoji is two" $ do
+      run' defaultConfig{maxStringLength = 2} "{\"cat\":[\"\\ud83d\\ude00\"]}" Null `shouldBe` Right (String "\x1F600")
+      run' defaultConfig{maxStringLength = 1} "{\"cat\":[\"\\ud83d\\ude00\"]}" Null
+        `shouldBe` Left (EvalProblem (LimitExceeded (StringLimit 1)))
+
+  describe "array length" $ do
+    it "allows a merge exactly at the limit" $
+      run' defaultConfig{maxArrayLength = 3} "{\"merge\":[[1,2],[3]]}" Null `shouldBe` Right (toJSON [1, 2, 3 :: Int])
+    it "rejects a merge one over" $
+      run' defaultConfig{maxArrayLength = 2} "{\"merge\":[[1,2],[3]]}" Null
+        `shouldBe` Left (EvalProblem (LimitExceeded (ArrayLimit 2)))
+    it "rejects mapping over an array longer than the limit, before any work" $
+      run' defaultConfig{maxArrayLength = 2} "{\"map\":[[1,2,3],{\"var\":\"\"}]}" Null
+        `shouldBe` Left (EvalProblem (LimitExceeded (ArrayLimit 2)))
+    it "allows mapping over an array at the limit" $
+      run' defaultConfig{maxArrayLength = 3} "{\"map\":[[1,2,3],{\"var\":\"\"}]}" Null `shouldBe` Right (toJSON [1, 2, 3 :: Int])
+    it "rejects filtering an array longer than the limit" $
+      run' defaultConfig{maxArrayLength = 2} "{\"filter\":[[1,2,3],true]}" Null
+        `shouldBe` Left (EvalProblem (LimitExceeded (ArrayLimit 2)))
+
+  describe "depth" $ do
+    let nested n = foldr (\_ inner -> object ["!" .= inner]) (toJSON (0 :: Int)) [1 .. n :: Int]
+        at limit n = problem (applyWithConfig defaultConfig{maxDepth = limit} noOperators (nested n) Null)
+    it "accepts exactly the limit" $ at 5 5 `shouldSatisfy` isRight'
+    it "rejects one past it" $ at 5 6 `shouldBe` Left (ParseProblem (DepthExceeded 5))
+    it "counts the nesting of a literal object too" $ do
+      let literal n = foldr (\_ inner -> object ["a" .= inner, "b" .= inner]) (toJSON (0 :: Int)) [1 .. n :: Int]
+      problem (applyWithConfig defaultConfig{maxDepth = 5} noOperators (literal 5) Null) `shouldSatisfy` isRight'
+      problem (applyWithConfig defaultConfig{maxDepth = 5} noOperators (literal 6) Null)
+        `shouldBe` Left (ParseProblem (DepthExceeded 5))
+    it "holds a custom operator's result to the same depth" $ do
+      let deep n = foldr (\_ inner -> toJSON [inner]) (toJSON (0 :: Int)) [1 .. n :: Int]
+          ops = either (error . show) id (operatorsFromList [(OperatorName "deep", \_ _ -> Right (deep 10))])
+          call limit = problem (applyWithConfig defaultConfig{maxDepth = limit} ops (object ["deep" .= ([] :: [Int])]) Null)
+      call 10 `shouldSatisfy` isRight'
+      call 9 `shouldBe` Left (EvalProblem (LimitExceeded (ValueDepthLimit 9)))
+
+  describe "amplification rules finish quickly and fail safely" $ do
+    it "nested maps over a ten-element array (10^20 iterations)" $ do
+      r <- within10s (problem (applyWithConfig defaultConfig noOperators (nestedMap 20) Null))
+      r `shouldSatisfy` isStepLimit
+    it "a reduce that doubles a string on every step" $ do
+      let rule =
+            "{\"reduce\":[[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30],\
+            \{\"cat\":[{\"var\":\"accumulator\"},{\"var\":\"accumulator\"}]},\"xy\"]}"
+      r <- within10s (run' defaultConfig rule Null)
+      -- building the string is paid for in steps, which run out first here
+      r `shouldSatisfy` isStepLimit
+      r' <- within10s (run' defaultConfig{maxSteps = 100000000} rule Null)
+      r' `shouldBe` Left (EvalProblem (LimitExceeded (StringLimit 1048576)))
+    it "a reduce that doubles an array on every step" $ do
+      let rule =
+            "{\"reduce\":[[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30],\
+            \{\"merge\":[{\"var\":\"accumulator\"},{\"var\":\"accumulator\"}]},[1,2]]}"
+      r <- within10s (run' defaultConfig rule Null)
+      r `shouldBe` Left (EvalProblem (LimitExceeded (ArrayLimit 100000)))
+    it "a very wide rule" $ do
+      let wide = object ["+" .= replicate 2000000 (1 :: Int)]
+      r <- within10s (problem (applyWithConfig defaultConfig noOperators wide Null))
+      r `shouldSatisfy` isStepLimit
+    it "a deeply nested datum is only read, never recursed into by the evaluator" $ do
+      let deepData = foldr (\_ inner -> toJSON [inner]) Null [1 .. 20000 :: Int]
+      r <- within10s (problem (applyWithConfig defaultConfig noOperators (object ["var" .= ("" :: String)]) deepData))
+      r `shouldSatisfy` isRight'
+    it "repeated lookups deep into a large array are constant time each" $ do
+      let big = toJSON [1 .. 5000 :: Int]
+          rule = object ["+" .= replicate 2000 (object ["var" .= ("x.4999" :: String)])]
+      r <- within10s (applyWithConfig defaultConfig noOperators rule (object ["x" .= big]))
+      r `shouldBe` Right (Number 10000000)
+    it "repeated lookups into a long string are paid for in steps" $ do
+      let big = String (Data.Text.replicate 5000 "a")
+          rule = object ["+" .= replicate 2000 (object ["var" .= ("s.length" :: String)])]
+      r <- within10s (problem (applyWithConfig defaultConfig noOperators rule (object ["s" .= big])))
+      r `shouldSatisfy` isStepLimit
+    it "repeated comparison of a large array is paid for in steps" $ do
+      let big = toJSON [1 .. 5000 :: Int]
+          rule = object ["+" .= replicate 2000 (object ["==" .= [object ["var" .= ("x" :: String)], toJSON (1 :: Int)]])]
+      r <- within10s (problem (applyWithConfig defaultConfig noOperators rule (object ["x" .= big])))
+      r `shouldSatisfy` isStepLimit
+
+  describe "properties" $ do
+    it "evaluation always terminates, within a constant factor of the budget" $
+      forAll (genRule 6) $ \rule -> forAll (genData 3) $ \dat ->
+        forAll (choose (0, 60)) $ \budget ->
+          ioProperty $ do
+            r <- within10s (applyWithConfig defaultConfig{maxSteps = budget, arityMode = Lenient} noOperators rule dat)
+            pure (r `seq` True)
+    it "raising the step limit never changes an answer that already succeeded" $
+      forAll (genRule 5) $ \rule -> forAll (genData 3) $ \dat ->
+        forAll (choose (1, 80)) $ \budget ->
+          let low = applyWithConfig defaultConfig{maxSteps = budget, arityMode = Lenient} noOperators rule dat
+              high = applyWithConfig defaultConfig{maxSteps = budget * 100, arityMode = Lenient} noOperators rule dat
+           in case low of
+                Right v -> high === Right v
+                Left (EvalFailure _ (LimitExceeded _)) -> property True
+                Left e -> high === Left e
+    it "compile once then run equals apply" $
+      forAll (genRule 5) $ \rule -> forAll (genData 3) $ \dat ->
+        let direct = apply rule dat
+            staged = compile defaultConfig noOperators rule >>= \c -> run c dat
+         in direct === staged
+
+isStepLimit :: Either Problem Value -> Bool
+isStepLimit (Left (EvalProblem (LimitExceeded (StepLimit _)))) = True
+isStepLimit _ = False
diff --git a/test/LocationSpec.hs b/test/LocationSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/LocationSpec.hs
@@ -0,0 +1,72 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | Error locations are JSON Pointers into the rule: every one of them must
+-- name a value that exists in the rule's JSON, and that value must be the
+-- operator call (or literal) the error is about.
+module LocationSpec (spec) where
+
+import Data.Aeson (Value (..))
+import qualified Data.Aeson.Key as Key
+import qualified Data.Aeson.KeyMap as KeyMap
+import Data.Foldable (toList)
+import Data.Maybe (isJust)
+import Test.Hspec
+import Test.QuickCheck
+
+import Gen (genData, genRule)
+import JsonLogic
+import Support (at, json)
+
+-- | The value a pointer names, if it names one (RFC 6901 resolution, on
+-- already-decoded segments).
+resolve :: Location -> Value -> Maybe Value
+resolve location = go (locationSegments location)
+  where
+    go [] v = Just v
+    go (Key k : rest) (Object fields) = KeyMap.lookup (Key.fromText k) fields >>= go rest
+    go (Index i : rest) (Array items) = case drop i (toList items) of
+      item : _ | i >= 0 -> go rest item
+      _ -> Nothing
+    go _ _ = Nothing
+
+-- | For an operator error, the operator call the pointer names.
+operatorAt :: Location -> Value -> Maybe OperatorName
+operatorAt location rule = case resolve location rule of
+  Just (Object fields) | [(key, _)] <- KeyMap.toList fields -> Just (OperatorName (Key.toText key))
+  _ -> Nothing
+
+-- | The operator an error is about, when it is about one.
+operatorOf :: Error -> Maybe OperatorName
+operatorOf e = case e of
+  ParseFailure _ (ArityMismatch name _ _) -> Just name
+  EvalFailure _ (UnknownOperator name) -> Just name
+  EvalFailure _ (CustomOperatorFailed name _) -> Just name
+  EvalFailure _ (InvalidOperand name) -> Just name
+  _ -> Nothing
+
+modes :: [Config]
+modes = [defaultConfig, defaultConfig{arityMode = Lenient}, defaultConfig{maxSteps = 25}, defaultConfig{maxDepth = 3}]
+
+spec :: Spec
+spec = describe "error locations" $ do
+  it "always name a value that exists in the rule" $
+    forAll (genRule 6) $ \rule -> forAll (genData 3) $ \dat -> forAll (elements modes) $ \config ->
+      case applyWithConfig config noOperators rule dat of
+        Left e -> counterexample (show e) (isJust (resolve (errorLocation e) rule))
+        Right _ -> property True
+  it "name the very operator an operator error is about" $
+    forAll (genRule 6) $ \rule -> forAll (genData 3) $ \dat -> forAll (elements modes) $ \config ->
+      case applyWithConfig config noOperators rule dat of
+        Left e | Just name <- operatorOf e -> counterexample (show e) (operatorAt (errorLocation e) rule === Just name)
+        _ -> property True
+  it "follow bare operands, which have no index" $
+    apply (json "{\"!\":{\"!\":{\"nope\":1}}}") Null
+      `shouldBe` Left (EvalFailure (at [Left "!", Left "!"]) (UnknownOperator (OperatorName "nope")))
+  it "point into literals' own arrays" $
+    apply (json "{\"merge\":[[{\"nope\":[]}]]}") Null
+      `shouldBe` Left (EvalFailure (at [Left "merge", Right 0, Right 0]) (UnknownOperator (OperatorName "nope")))
+  it "put an operand the rule leaves out at the operator call, which exists" $
+    -- five steps: the and, its true, the ==, its 1, and the undefined that
+    -- stands in for the missing second operand, which runs out
+    applyWithConfig defaultConfig{arityMode = Lenient, maxSteps = 4} noOperators (json "{\"and\":[true,{\"==\":[1]}]}") Null
+      `shouldBe` Left (EvalFailure (at [Left "and", Right 1]) (LimitExceeded (StepLimit 4)))
diff --git a/test/NumberSpec.hs b/test/NumberSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/NumberSpec.hs
@@ -0,0 +1,115 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | The fast paths of "JsonLogic.Core.Number" against the exact general
+-- paths they stand in for. Each fast path is used only when it is provably
+-- exact; these properties check that claim over hundreds of thousands of
+-- doubles, chosen to include the hard cases (whole numbers at 2^53 and
+-- above, short decimals, numbers needing 16 or 17 digits, subnormals).
+module NumberSpec (spec) where
+
+import Data.Bits (shiftR, (.&.))
+import Data.Word (Word64)
+import GHC.Float (castWord64ToDouble)
+import Test.Hspec
+import Test.Hspec.QuickCheck (modifyMaxSuccess)
+import Test.QuickCheck hiding ((.&.))
+
+import JsonLogic.Core.Number
+
+-- | Any positive finite double, from its bit pattern.
+genAnyDouble :: Gen Double
+genAnyDouble = (castWord64ToDouble . (.&. 0x7FFFFFFFFFFFFFFF) <$> (arbitrary :: Gen Word64)) `suchThat` finitePositive
+
+-- | Doubles of the kinds rules usually hold: short decimals, money, ratios,
+-- and whole numbers either side of 2^53.
+genCommonDouble :: Gen Double
+genCommonDouble =
+  oneof
+    [ (/ 100) . fromInteger <$> choose (1, 10000000)
+    , (/ 1000) . fromInteger <$> choose (1, 100000)
+    , (\a b -> fromInteger a / fromInteger b) <$> choose (1, 1000000) <*> choose (1, 1000)
+    , fromInteger <$> choose (1, 2 ^ (60 :: Int))
+    , (* 1e-9) . fromInteger <$> choose (1, 1000000)
+    , (\w -> fromIntegral (w `shiftR` 11 :: Word64)) <$> arbitrary
+    ]
+    `suchThat` finitePositive
+
+finitePositive :: Double -> Bool
+finitePositive d = d > 0 && not (isInfinite d) && not (isNaN d)
+
+-- | The slow, obviously correct remainder: exact in 'Rational'.
+remainderExact :: Double -> Double -> Double
+remainderExact x y
+  | isNaN x || isNaN y || isInfinite x || y == 0 = 0 / 0
+  | isInfinite y = x
+  | x == 0 = x
+  | exact == 0 = if x < 0 then -0.0 else 0
+  | otherwise = fromRational exact
+  where
+    rx = toRational x
+    ry = toRational y
+    exact = rx - ry * fromInteger (truncate (rx / ry))
+
+-- | Run a property this many times instead of the usual hundred.
+times :: Int -> SpecWith a -> SpecWith a
+times n = modifyMaxSuccess (const n)
+
+sameDouble :: Double -> Double -> Bool
+sameDouble a b = (isNaN a && isNaN b) || (a == b && isNegativeZero a == isNegativeZero b)
+
+spec :: Spec
+spec = describe "JsonLogic.Core.Number" $ do
+  describe "shortest digits" $ do
+    times 100000 $ it "the fast path agrees with the exact algorithm on any double" $ forAll genAnyDouble $ \d -> shortestDigits d === shortestDigitsExact d
+    times 100000 $ it "the fast path agrees with the exact algorithm on common doubles" $ forAll genCommonDouble $ \d -> shortestDigits d === shortestDigitsExact d
+    it "accepts the halfway point above 2^53, as ECMAScript does" $
+      numberToString 2.2531e21 `shouldBe` "2.2531e+21"
+    it "takes the even digit when two candidates are equally close" $
+      numberToString (9007199254740993 / 10) `shouldBe` "900719925474099.2"
+    it "writes the extremes" $
+      map numberToString [5e-324, 1.7976931348623157e308, 0.1, 123.456, 1e21, 1e-7]
+        `shouldBe` ["5e-324", "1.7976931348623157e+308", "0.1", "123.456", "1e+21", "1e-7"]
+
+  describe "reading decimals" $ do
+    times 100000 $
+      it "the fast path is exact for every mantissa below 2^53 and exponent within 22" $
+        forAll (choose (1, 2 ^ (53 :: Int) - 1)) $ \m -> forAll (choose (-22, 22)) $ \e ->
+          decimalToDouble m e === fromRational (fromInteger m * 10 ^^ e)
+    times 20000 $
+      it "is exact beyond the fast path too" $
+        forAll (choose (1, 10 ^ (30 :: Int))) $ \m -> forAll (choose (-340, 320)) $ \e ->
+          decimalToDouble m e === fromRational (fromInteger m * 10 ^^ e)
+    times 50000 $
+      it "is exact for the 16- to 19-digit decimals JavaScript and JSON writers produce" $
+        forAll (choose (10 ^ (15 :: Int), 10 ^ (19 :: Int))) $ \m -> forAll (choose (-40, 30)) $ \e ->
+          decimalToDouble m e === fromRational (fromInteger m * 10 ^^ e)
+    times 20000 $
+      it "rounds correctly into and out of the subnormal range" $
+        forAll (choose (1, 10 ^ (20 :: Int))) $ \m -> forAll (choose (-345, -300)) $ \e ->
+          decimalToDouble m e === fromRational (fromInteger m * 10 ^^ e)
+    it "rounds an exact tie to even" $ do
+      -- 2^53 + 1 is halfway between two doubles; 2^53 + 3 rounds up to 2^53 + 4
+      decimalToDouble 9007199254740993 0 `shouldBe` 9007199254740992
+      decimalToDouble 9007199254740995 0 `shouldBe` 9007199254740996
+      decimalToDouble 90071992547409930 (-1) `shouldBe` 9007199254740992
+    it "overflows to infinity and underflows to zero" $ do
+      isInfinite (decimalToDouble 17976931348623159 292) `shouldBe` True
+      decimalToDouble 17976931348623157 292 `shouldBe` 1.7976931348623157e308
+      decimalToDouble 24703282292062327 (-340) `shouldBe` 0
+      decimalToDouble 24703282292062328 (-340) `shouldBe` 5e-324
+    times 50000 $ it "reads back every double it writes" $ forAll genAnyDouble $ \d -> stringToNumber (numberToString d) === d
+
+  describe "remainder" $ do
+    times 50000 $
+      it "the integer path agrees with the exact remainder" $
+        forAll (choose (negate (2 ^ (53 :: Int)), 2 ^ (53 :: Int))) $ \x -> forAll (choose (-100000, 100000)) $ \y ->
+          let (a, b) = (fromInteger x, fromInteger y)
+           in counterexample (show (a, b)) (sameDouble (remainderJs a b) (remainderExact a b))
+    times 50000 $
+      it "agrees with the exact remainder on any doubles" $
+        forAll ((,) <$> genAnyDouble <*> genAnyDouble) $ \(a, b) ->
+          conjoin
+            [ counterexample (show (x, y)) (sameDouble (remainderJs x y) (remainderExact x y))
+            | x <- [a, negate a]
+            , y <- [b, negate b]
+            ]
diff --git a/test/ParseSpec.hs b/test/ParseSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/ParseSpec.hs
@@ -0,0 +1,151 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module ParseSpec (spec) where
+
+import Data.Aeson (Value (..), object, toJSON, (.=))
+import qualified Data.Aeson.Key as Key
+import Data.Either (isRight)
+import Data.Fix (Fix (..), foldFix)
+import Data.List (nub)
+import Data.Text (Text)
+import Test.Hspec
+import Test.QuickCheck (NonNegative (..), forAll, property)
+
+import Gen (genRule)
+import JsonLogic.Core.Config (ArityMode (..), Config (..), defaultConfig)
+import JsonLogic.Core.Errors (Arity (..), ParseError (..))
+import JsonLogic.Core.Location (Location, root)
+import JsonLogic.Core.Operator (OperatorName (..), allBuiltins, builtinName, lookupBuiltin)
+import JsonLogic.Core.Parse (parseRule, parseRuleWith)
+import JsonLogic.Core.Rule (Node (..), Rule, RuleF (..), Term, stripLocations)
+import Support (at, json)
+
+lit :: Value -> Term
+lit = Fix . Lit
+
+int :: Int -> Value
+int = toJSON
+
+-- | A rule nested @n@ operator levels deep around a literal leaf.
+nested :: Int -> Value
+nested n = foldr (\_ inner -> object ["!" .= inner]) (int 0) [1 .. n]
+
+-- | The shape a rule is read as, without locations.
+shape :: Value -> Either ParseError Term
+shape = either (Left . snd) (Right . stripLocations) . parseRule
+
+lenientShape :: Value -> Either ParseError Term
+lenientShape = either (Left . snd) (Right . stripLocations) . parseRuleWith defaultConfig{arityMode = Lenient}
+
+-- | The location of every node, outermost first.
+locations :: Rule -> [Location]
+locations = foldFix (\(Node location node) -> location : concat node)
+
+parsedLocations :: Value -> [Location]
+parsedLocations = either (const []) locations . parseRule
+
+withDepth :: Int -> Value -> Either (Location, ParseError) Rule
+withDepth limit = parseRuleWith defaultConfig{maxDepth = limit}
+
+spec :: Spec
+spec = describe "parsing" $ do
+  describe "shapes" $ do
+    it "reads a bare number as a literal" $
+      shape (int 1) `shouldBe` Right (lit (int 1))
+    it "reads {\"var\": \"a\"} as a var with no default" $
+      shape (object ["var" .= ("a" :: Text)])
+        `shouldBe` Right (Fix (Var (lit (String "a")) Nothing))
+    it "reads {\"var\": [\"a\", 1]} as a var with a default" $
+      shape (object ["var" .= [toJSON ("a" :: Text), int 1]])
+        `shouldBe` Right (Fix (Var (lit (String "a")) (Just (lit (int 1)))))
+    it "reads {\"var\": []} as a lookup of the data itself" $
+      shape (object ["var" .= ([] :: [Int])])
+        `shouldBe` Right (Fix (Var (lit Null) Nothing))
+    it "reads an array element-wise, as the reference implementation does" $
+      shape (toJSON [1, 2 :: Int]) `shouldBe` Right (Fix (Arr [lit (int 1), lit (int 2)]))
+    it "reads an empty-argument + as Add []" $
+      shape (object ["+" .= ([] :: [Int])]) `shouldBe` Right (Fix (Add []))
+    it "treats an object with two keys as a literal, not a rule" $ do
+      let v = object ["a" .= int 1, "b" .= int 2]
+      shape v `shouldBe` Right (lit v)
+    it "turns an unknown operator into a CustomOp" $
+      shape (object ["frobnicate" .= ([] :: [Int])])
+        `shouldBe` Right (Fix (CustomOp (OperatorName "frobnicate") []))
+    it "reads a bare operand as a list of one" $
+      shape (object ["!" .= True]) `shouldBe` shape (object ["!" .= [True]])
+
+  describe "strict arity" $ do
+    it "rejects a wrong argument count" $
+      shape (object ["==" .= [int 1]]) `shouldBe` Left (ArityMismatch (OperatorName "==") (Exactly 2) 1)
+    it "accepts the three-argument between form for < but not for >" $ do
+      shape (object ["<" .= [int 1, int 2, int 3]]) `shouldSatisfy` isRight
+      shape (object [">" .= [int 1, int 2, int 3]])
+        `shouldBe` Left (ArityMismatch (OperatorName ">") (Exactly 2) 3)
+    it "states var's range as zero to two" $
+      shape (object ["var" .= [int 1, int 2, int 3]])
+        `shouldBe` Left (ArityMismatch (OperatorName "var") (Between 0 2) 3)
+    it "rejects * with no operands" $
+      shape (object ["*" .= ([] :: [Int])]) `shouldBe` Left (ArityMismatch (OperatorName "*") (AtLeast 1) 0)
+
+  describe "lenient arity" $ do
+    it "pads a missing operand with undefined (an empty and)" $
+      lenientShape (object ["==" .= [int 1]])
+        `shouldBe` Right (Fix (LooseEq (lit (int 1)) (Fix (And []))))
+    it "pads the missing initial value of reduce with null" $
+      lenientShape (object ["reduce" .= [String "xs", int 2]])
+        `shouldBe` Right (Fix (Reduce (lit (String "xs")) (lit (int 2)) (lit Null)))
+    it "keeps surplus operands of an ordinary operator to be evaluated" $
+      lenientShape (object ["!" .= [int 1, int 2]])
+        `shouldBe` Right (Fix (Discard (Fix (Not (lit (int 1)))) [lit (int 2)]))
+    it "drops surplus operands of map, which the reference never looks at" $
+      lenientShape (object ["map" .= [int 1, int 2, int 3]])
+        `shouldBe` Right (Fix (Map (lit (int 1)) (lit (int 2))))
+    it "keeps an empty *, which throws only if it is evaluated" $
+      lenientShape (object ["*" .= ([] :: [Int])]) `shouldBe` Right (Fix (Mul []))
+    it "never reports an arity error" $
+      forAll (genRule 5) $ \rule -> case lenientShape rule of
+        Left (ArityMismatch{}) -> False
+        _ -> True
+
+  describe "locations" $ do
+    it "puts the root at the empty pointer" $
+      take 1 (parsedLocations (int 1)) `shouldBe` [root]
+    it "locates operands under the operator's key, by index" $
+      parsedLocations (json "{\"if\":[true,{\"var\":\"a\"}]}")
+        `shouldBe` [ root
+                   , at [Left "if", Right 0]
+                   , at [Left "if", Right 1]
+                   , at [Left "if", Right 1, Left "var"]
+                   ]
+    it "locates a bare operand at the operator's key" $
+      parsedLocations (json "{\"!\":{\"var\":\"a\"}}")
+        `shouldBe` [root, at [Left "!"], at [Left "!", Left "var"]]
+    it "locates array elements by index" $
+      parsedLocations (json "[1,[2]]")
+        `shouldBe` [root, at [Right 0], at [Right 1], at [Right 1, Right 0]]
+    it "reports where an arity error is" $
+      parseRule (json "{\"and\":[true,{\"==\":[1]}]}")
+        `shouldSatisfy` either ((== at [Left "and", Right 1]) . fst) (const False)
+    it "reports where the depth limit was passed" $
+      withDepth 1 (json "{\"!\":{\"!\":{\"!\":1}}}")
+        `shouldSatisfy` either ((== at [Left "!", Left "!"]) . fst) (const False)
+
+  describe "depth" $ do
+    it "reports the configured limit when nesting is too deep" $
+      either (Left . snd) (const (Right ())) (withDepth 3 (nested 10)) `shouldBe` Left (DepthExceeded 3)
+    it "accepts nesting exactly up to the depth limit and no deeper" $
+      property $ \(NonNegative n) (NonNegative limit) ->
+        isRight (withDepth limit (nested n)) == (n <= limit)
+
+  describe "the operator table" $ do
+    it "maps every builtin's name back to it" $
+      map (lookupBuiltin . builtinName) allBuiltins `shouldBe` map Just allBuiltins
+    it "has the 35 builtins of the classic specification, with distinct names" $
+      length (nub (map builtinName allBuiltins)) `shouldBe` 35
+    it "reads every builtin name as a builtin, never as a custom operator" $
+      mapM_
+        ( \builtin -> case lenientShape (object [Key.fromText (builtinName builtin) .= ([] :: [Int])]) of
+            Right (Fix (CustomOp _ _)) -> expectationFailure ("read as custom: " ++ show builtin)
+            _ -> pure ()
+        )
+        allBuiltins
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,23 @@
+module Main (main) where
+
+import Test.Hspec (hspec)
+
+import qualified AnalysisSpec
+import qualified CompatSpec
+import qualified EvalSpec
+import qualified LimitsSpec
+import qualified LocationSpec
+import qualified NumberSpec
+import qualified ParseSpec
+import qualified ValueSpec
+
+main :: IO ()
+main = hspec $ do
+  ParseSpec.spec
+  NumberSpec.spec
+  ValueSpec.spec
+  EvalSpec.spec
+  CompatSpec.spec
+  LimitsSpec.spec
+  LocationSpec.spec
+  AnalysisSpec.spec
diff --git a/test/Support.hs b/test/Support.hs
new file mode 100644
--- /dev/null
+++ b/test/Support.hs
@@ -0,0 +1,41 @@
+-- | Helpers shared by the test modules.
+module Support
+  ( json
+  , Problem (..)
+  , problem
+  , at
+  , isRight'
+  ) where
+
+import Data.Aeson (Value, decode)
+import qualified Data.ByteString.Lazy.Char8 as Char8
+import Data.Maybe (fromMaybe)
+import Data.Text (Text)
+
+import JsonLogic
+
+{- HLINT ignore json "Avoid restricted function" -}
+
+-- | A JSON literal written in the test. A malformed one is a bug in the
+-- test, so it stops the test with the literal in the message (ADR 0004).
+json :: String -> Value
+json text = fromMaybe (error ("bad test JSON: " ++ text)) (decode (Char8.pack text))
+
+-- | What went wrong, without where: most tests are about the first.
+data Problem
+  = ParseProblem ParseError
+  | EvalProblem EvalError
+  deriving (Eq, Show)
+
+-- | An answer with the location dropped from a failure.
+problem :: Either Error a -> Either Problem a
+problem (Left (ParseFailure _ e)) = Left (ParseProblem e)
+problem (Left (EvalFailure _ e)) = Left (EvalProblem e)
+problem (Right a) = Right a
+
+-- | A location from its JSON Pointer segments, written as keys and indices.
+at :: [Either Text Int] -> Location
+at = fromSegments . map (either Key Index)
+
+isRight' :: Either a b -> Bool
+isRight' = either (const False) (const True)
diff --git a/test/ValueSpec.hs b/test/ValueSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/ValueSpec.hs
@@ -0,0 +1,247 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module ValueSpec (spec) where
+
+import qualified Data.Aeson as Aeson
+import qualified Data.Aeson.KeyMap as KeyMap
+import Data.Text (Text)
+import qualified Data.Text as Text
+import Test.Hspec
+import Test.QuickCheck (Gen, arbitrary, choose, elements, forAll, listOf, oneof, (==>))
+
+import qualified Data.Vector as Vector
+
+import JsonLogic.Core.Number (maximumJs, minimumJs, numberToString, remainderJs)
+import JsonLogic.Core.Value
+
+arr :: [Val] -> Val
+arr = VArr . Vector.fromList
+
+nan :: Double
+nan = 0 / 0
+
+infinity :: Double
+infinity = 1 / 0
+
+genScalar :: Gen Val
+genScalar =
+  oneof
+    [ pure VNull
+    , pure VUndefined
+    , VBool <$> arbitrary
+    , VNum <$> oneof [elements [0, -0.0, 1, -1, 0.5, infinity, negate infinity, nan], arbitrary]
+    , VStr . Text.pack <$> listOf (elements "ab01 .-eE")
+    ]
+
+genVal :: Gen Val
+genVal = oneof [genScalar, arr <$> listOf genScalar, pure (VObj KeyMap.empty)]
+
+numberCases :: [(Double, Text)]
+numberCases =
+  [ (1, "1")
+  , (-1, "-1")
+  , (1.5, "1.5")
+  , (100, "100")
+  , (0.5, "0.5")
+  , (0.000001, "0.000001")
+  , (1e-7, "1e-7")
+  , (1e21, "1e+21")
+  , (1e20, "100000000000000000000")
+  , (123.456, "123.456")
+  , (0.1 + 0.2, "0.30000000000000004")
+  , (-0.0, "0")
+  , (1.2345e25, "1.2345e+25")
+  , (5e-324, "5e-324")
+  , (2.2531e21, "2.2531e+21")
+  , (-2.2531e21, "-2.2531e+21")
+  , (9007199254740992, "9007199254740992")
+  , (nan, "NaN")
+  , (infinity, "Infinity")
+  , (negate infinity, "-Infinity")
+  ]
+
+toNumberCases :: [(Val, Double)]
+toNumberCases =
+  [ (VStr "", 0)
+  , (VStr "  12  ", 12)
+  , (VStr "0x10", 16)
+  , (VStr "0b11", 3)
+  , (VStr "1e3", 1000)
+  , (VStr ".5", 0.5)
+  , (VStr "5.", 5)
+  , (VStr "-2.5", -2.5)
+  , (VNull, 0)
+  , (VBool True, 1)
+  , (arr [], 0)
+  , (arr [VNum 7], 7)
+  , (VStr "Infinity", infinity)
+  , (VStr "-Infinity", negate infinity)
+  , (VStr "-2.2531e+21", -2.2531e21)
+  , (VStr "2.2531e21", 2.2531e21)
+  , (VStr "1e400", infinity)
+  , (VStr "1e-400", 0)
+  , (VStr "5e-324", 5e-324)
+  , (VStr "123456789012345678901234567890", 1.2345678901234568e29)
+  ]
+
+parseFloatCases :: [(Val, Double)]
+parseFloatCases =
+  [ (VStr "3.14abc", 3.14)
+  , (VStr "  7", 7)
+  , (VStr "1e3x", 1000)
+  , (VStr ".5", 0.5)
+  , (VStr "-12.5e1", -125)
+  , (arr [VNum 5], 5)
+  , (VNum (-0.0), 0)
+  , (VStr "Infinityx", infinity)
+  ]
+
+looseCases :: [(Val, Val, Bool)]
+looseCases =
+  [ (VNum 1, VStr "1", True)
+  , (VNum 0, VStr "", True)
+  , (VNull, VNull, True)
+  , (VNull, VNum 0, False)
+  , (VNull, VBool False, False)
+  , (VBool True, VNum 1, True)
+  , (VBool False, VStr "0", True)
+  , (VStr "a", VStr "a", True)
+  , (VStr "abc", VNum 0, False)
+  , (arr [VNum 1], VStr "1", True)
+  , (arr [], VNum 0, True)
+  , (arr [], VBool False, True)
+  , (arr [], arr [], False)
+  , (VNum nan, VNum nan, False)
+  , (VUndefined, VNull, True)
+  , (VUndefined, VUndefined, True)
+  , (VUndefined, VNum 0, False)
+  , (VUndefined, VBool False, False)
+  ]
+
+lessCases :: [(Val, Val, Bool)]
+lessCases =
+  [ (VNum 1, VNum 2, True)
+  , (VStr "10", VStr "9", True)
+  , (VStr "2", VNum 10, True)
+  , (VNull, VNum 1, True)
+  , (VNum nan, VNum 1, False)
+  , (VStr "a", VNum 1, False)
+  , (arr [VNum 1], arr [VNum 2], True)
+  ]
+
+remainderCases :: [(Double, Double, Double)]
+remainderCases =
+  [ (5, 3, 2)
+  , (-5, 3, -2)
+  , (5, -3, 2)
+  , (5.5, 2, 1.5)
+  , (5, infinity, 5)
+  ]
+
+-- | Doubles across the whole range, including the large ones where digit
+-- generation has edge cases.
+genWideDouble :: Gen Double
+genWideDouble = do
+  mantissa <- choose (-1000000000000000, 1000000000000000) :: Gen Integer
+  power <- choose (-300, 290) :: Gen Int
+  pure (fromInteger mantissa * 10 ^^ power)
+
+roundTripsThroughJson :: Double -> Bool
+roundTripsThroughJson d = fromValue (toValue (VNum d)) == VNum d
+
+spec :: Spec
+spec = describe "JsonLogic.Core.Value" $ do
+  describe "truthy" $ do
+    it "is false for exactly the reference's falsy values" $
+      map truthy [VNull, VUndefined, VBool False, VNum 0, VNum nan, VStr "", arr []]
+        `shouldBe` replicate 7 False
+    it "is true for everything else, including \"0\" and empty objects" $
+      map truthy [VBool True, VNum 1, VNum (-1), VStr "0", VStr "a", arr [VNull], VObj KeyMap.empty]
+        `shouldBe` replicate 7 True
+
+  describe "numberToString"
+    $ it "follows the ECMAScript Number-to-string rules"
+    $ map (numberToString . fst) numberCases `shouldBe` map snd numberCases
+
+  describe "toNumber" $ do
+    it "converts the way Number() does" $
+      map (toNumber . fst) toNumberCases `shouldBe` map snd toNumberCases
+    it "gives NaN for malformed input" $
+      map
+        (isNaN . toNumber)
+        [ VStr "abc"
+        , VStr "1 2"
+        , VStr "0x"
+        , VStr "--1"
+        , VStr "1e"
+        , VStr "+-1"
+        , VStr "infinity"
+        , VObj KeyMap.empty
+        , arr [VNum 1, VNum 2]
+        ]
+        `shouldBe` replicate 9 True
+
+  describe "parseFloat" $ do
+    it "reads the longest decimal prefix" $
+      map (parseFloat . fst) parseFloatCases `shouldBe` map snd parseFloatCases
+    it "gives NaN when there is no number to read" $
+      map (isNaN . parseFloat) [VStr "abc", VStr "", VStr "x1", VNull, VBool True]
+        `shouldBe` replicate 5 True
+
+  describe "equality" $ do
+    it "looseEquals matches JavaScript's == on a table of cases" $
+      map (\(x, y, _) -> looseEquals x y) looseCases
+        `shouldBe` map (\(_, _, expected) -> expected) looseCases
+    it "strictEquals never converts types" $
+      [ strictEquals (VNum 1) (VStr "1")
+      , strictEquals (VNum nan) (VNum nan)
+      , strictEquals (VStr "a") (VStr "a")
+      , strictEquals (arr []) (arr [])
+      ]
+        `shouldBe` [False, False, True, False]
+
+  describe "ordering" $ do
+    it "lessThan matches JavaScript's < on a table of cases" $
+      map (\(x, y, _) -> lessThan x y) lessCases
+        `shouldBe` map (\(_, _, expected) -> expected) lessCases
+    it "lessOrEqual compares strings lexicographically" $
+      [lessOrEqual (VNum 1) (VNum 1), lessOrEqual (VStr "b") (VStr "a")]
+        `shouldBe` [True, False]
+
+  describe "arithmetic helpers" $ do
+    it "remainderJs takes the sign of the dividend" $
+      map (\(x, y, _) -> remainderJs x y) remainderCases
+        `shouldBe` map (\(_, _, expected) -> expected) remainderCases
+    it "remainderJs gives NaN where JavaScript does" $
+      map (isNaN . uncurry remainderJs) [(5, 0), (infinity, 3), (nan, 1)]
+        `shouldBe` replicate 3 True
+    it "maximumJs and minimumJs follow Math.max and Math.min" $ do
+      (maximumJs [1, 3, 2], maximumJs [], minimumJs []) `shouldBe` (3, negate infinity, infinity)
+      isNaN (maximumJs [1, nan]) `shouldBe` True
+
+  describe "JSON conversion" $ do
+    it "turns NaN and infinities into null" $
+      map (toValue . VNum) [nan, infinity, negate infinity]
+        `shouldBe` replicate 3 Aeson.Null
+    it "keeps ordinary numbers and strings" $ do
+      toValue (VNum 2) `shouldBe` Aeson.Number 2
+      fromValue (Aeson.String "x") `shouldBe` VStr "x"
+
+  describe "laws" $ do
+    it "looseEquals is symmetric" $
+      forAll genVal $
+        \x -> forAll genVal $ \y -> looseEquals x y == looseEquals y x
+    it "strictEquals implies looseEquals" $
+      forAll genVal $
+        \x -> forAll genVal $ \y -> not (strictEquals x y) || looseEquals x y
+    it "lessThan is asymmetric" $
+      forAll genVal $
+        \x -> forAll genVal $ \y -> not (lessThan x y && lessThan y x)
+    it "numberToString round-trips through toNumber" $
+      forAll (arbitrary :: Gen Double) $ \d ->
+        not (isNaN d || isInfinite d) ==> toNumber (VStr (numberToString d)) == d
+    it "writing a double as JSON and reading it back is the identity" $
+      forAll genWideDouble roundTripsThroughJson
+    it "numberToString round-trips for large and tiny doubles too" $
+      forAll genWideDouble $
+        \d -> toNumber (VStr (numberToString d)) == d
diff --git a/test/community/LICENSE b/test/community/LICENSE
new file mode 100644
--- /dev/null
+++ b/test/community/LICENSE
@@ -0,0 +1,201 @@
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/test/community/README.md b/test/community/README.md
new file mode 100644
--- /dev/null
+++ b/test/community/README.md
@@ -0,0 +1,33 @@
+# The community test suite
+
+These files are a verbatim copy of the `suites/` directory of
+[json-logic/compat-tables](https://github.com/json-logic/compat-tables) at
+commit `f70933b8d9ef363afc73af6f6e12e9161fedce17` (2026-10-19), licensed under
+the Apache License 2.0 (`LICENSE` in this directory). `index.json` lists the
+files in the order the compatibility tables use.
+
+The suite has 1,138 cases. Its first file, `compatible.json`, is the classic
+suite (the same 278 rules as `test/tests.json`). The rest describe the
+**JSON Logic 2.0 dialect** implemented by json-logic-engine: new operators
+(`val`, `??`, `exists`, `try`, `throw`, ...), `NaN` as an error rather than a
+value, variadic `/` and `-`, chained comparisons, and lazily evaluated
+comparison chains. jsonlogic-hs implements the classic dialect of
+json-logic-js 2.0.5, so most of those cases are not expected to pass, and the
+gaps are a statement of scope, not bugs (see `docs/COMPATIBILITY.md`).
+
+They are used twice:
+
+- **As a measure.** `cabal test jsonlogic-hs-community` scores every case the
+  way the compatibility tables score an implementation, in both arity modes,
+  and compares the per-file pass counts with `expected.json`. Any change,
+  better or worse, fails the run until `expected.json` is updated in the same
+  commit, so the score can only move on purpose. Regenerate it with
+  `cabal run jsonlogic-hs-community -- --write`.
+- **As more outliers.** `differential/outliers.js` runs every rule of the suite
+  through json-logic-js and records its answers in `test/outliers.jsonl`
+  (family `community`), so lenient mode is held to the reference on all 1,138
+  rules, including the ones written for the other dialect.
+
+To update the suite, copy a newer `suites/` here, record the commit above,
+regenerate `test/outliers.jsonl` and `expected.json`, and update
+`fixtures.sha256`.
diff --git a/test/community/additional.json b/test/community/additional.json
new file mode 100644
--- /dev/null
+++ b/test/community/additional.json
@@ -0,0 +1,92 @@
+[
+    "# These are some tests from https://github.com/TotalTechGeek/json-logic-engine/commit/9125e91b5137938a8319de1103b0ebc5819e54e1",
+    {
+        "description": "Rule array with val",
+        "rule": [
+            1,
+            {
+                "val": "x"
+            },
+            3
+        ],
+        "data": {
+            "x": 2
+        },
+        "result": [
+            1,
+            2,
+            3
+        ]
+    },
+    {
+        "description": "If Operator fetching both condition and consequent from data",
+        "rule": {
+            "if": [
+                {
+                    "val": "x"
+                },
+                [
+                    {
+                        "val": "y"
+                    }
+                ],
+                99
+            ]
+        },
+        "data": {
+            "x": true,
+            "y": 42
+        },
+        "result": [
+            42
+        ]
+    },
+    {
+        "description": "Reduce Operator with val",
+        "rule": {
+            "reduce": [
+                {
+                    "val": "integers"
+                },
+                {
+                    "+": [
+                        {
+                            "val": "current"
+                        },
+                        {
+                            "val": "accumulator"
+                        }
+                    ]
+                },
+                {
+                    "val": "start_with"
+                }
+            ]
+        },
+        "data": {
+            "integers": [
+                1,
+                2,
+                3,
+                4
+            ],
+            "start_with": 59
+        },
+        "result": 69
+    },
+    {
+        "description": "Simple Inlineable Val Chained",
+        "rule": {
+            "val": {
+                "cat": [
+                    "te",
+                    "st"
+                ]
+            }
+        },
+        "data": {
+            "test": 1
+        },
+        "result": 1
+    }
+]
diff --git a/test/community/arithmetic/divide.extra.json b/test/community/arithmetic/divide.extra.json
new file mode 100644
--- /dev/null
+++ b/test/community/arithmetic/divide.extra.json
@@ -0,0 +1,21 @@
+[
+    {
+        "description": "Divide with dynamic empty array",
+        "rule": { "/": { "preserve": [] } },
+        "error": { "type": "Invalid Arguments" },
+        "data": null
+    },
+    {
+        "description": "Divide with dynamic unary array",
+        "rule": { "/": { "preserve": [2] } },
+        "result": 0.5,
+        "decimal": true,
+        "data": null
+    },
+    {
+        "description": "Divide with dynamic array",
+        "rule": { "/": { "preserve": [8, 2] } },
+        "result": 4,
+        "data": null
+    }
+]
diff --git a/test/community/arithmetic/divide.json b/test/community/arithmetic/divide.json
new file mode 100644
--- /dev/null
+++ b/test/community/arithmetic/divide.json
@@ -0,0 +1,193 @@
+[
+    "# Collection of Divide Operator Tests",
+    {
+        "description": "Divide",
+        "rule": { "/": [4, 2] },
+        "result": 2,
+        "data": null
+    },
+    {
+        "description": "Divide to Decimal",
+        "rule": { "/": [2, 4] },
+        "result": 0.5,
+        "data": null,
+        "decimal": true
+    },
+    {
+        "description": "Divide with Multiple Operands",
+        "rule": { "/": [8, 2, 2] },
+        "result": 2,
+        "data": null
+    },
+    {
+        "description": "Divide with Multiple Operands (2)",
+        "rule": { "/": [2, 2, 1] },
+        "result": 1,
+        "data": null
+    },
+    {
+        "description": "Divide with Negative Numbers",
+        "rule": { "/": [-1, 2] },
+        "result": -0.5,
+        "data": null,
+        "decimal": true
+    },
+    {
+        "description": "Divide with Strings",
+        "rule": { "/": ["8", "2", "2"] },
+        "result": 2,
+        "data": null
+    },
+    {
+        "description": "Divide with Booleans",
+        "rule": { "/": [false, true] },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "Divide with Multiple Value Types",
+        "rule": { "/": ["8", true, 2] },
+        "result": 4,
+        "data": null
+    },
+    {
+        "description": "Divide with Multiple Value Types (2)",
+        "rule": { "/": ["1", 1] },
+        "result": 1,
+        "data": null
+    },
+    {
+        "description": "Divide with Single Operand (Number)",
+        "rule": { "/": [1] },
+        "result": 1,
+        "data": null
+    },
+    {
+        "description": "Divide with zero operands is an error",
+        "rule": { "/": [] },
+        "error": { "type": "Invalid Arguments" },
+        "data": null
+    },
+    {
+        "description": "Divide with Single Operand, Direct (Number)",
+        "rule": { "/": 1 },
+        "result": 1,
+        "data": null
+    },
+    {
+        "description": "Divide with Single Operand, Direct (0)",
+        "rule": { "/": 0 },
+        "error": { "type": "NaN" },
+        "data": null
+    },
+    {
+        "description": "Divide Operator with Single Operand (Number)",
+        "rule": { "/": [1] },
+        "result": 1,
+        "data": null
+    },
+    {
+        "description": "Divide Operator with Single Operand (Negative Number)",
+        "rule": { "/": [-1] },
+        "result": -1,
+        "data": null
+    },
+    {
+        "description": "Divide Operator with Single Operand, Direct (Number)",
+        "rule": { "/": 1 },
+        "result": 1,
+        "data": null
+    },
+    {
+        "description": "Divide Operator with Single Operand, Direct (2)",
+        "rule": { "/": 2 },
+        "result": 0.5,
+        "decimal": true,
+        "data": null
+    },
+    {
+        "description": "Divide Operator with Single Operand, Direct (0)",
+        "rule": { "/": 0 },
+        "error": { "type": "NaN" },
+        "data": null
+    },
+    {
+        "description": "Divide Operator with Single Operand (String)",
+        "rule": { "/": ["1"] },
+        "result": 1,
+        "data": null
+    },
+    {
+        "description": "Divide Operator with Single Operand, Direct (Negative Number String)",
+        "rule": { "/": "-1" },
+        "result": -1,
+        "data": null
+    },
+
+    {
+        "description": "Divide Operator with Single Operand, Direct (String 0)",
+        "rule": { "/": "0" },
+        "error": { "type": "NaN" },
+        "data": null
+    },
+    {
+        "description": "Divide Operator with Single Operand, Direct (true)",
+        "rule": { "/": true },
+        "result": 1,
+        "data": null
+    },
+    {
+        "description": "Divide Operator with Single Operand, Direct (false)",
+        "rule": { "/": false },
+        "error": { "type": "NaN" },
+        "data": null
+    },
+    {
+        "description": "Divide Operator with Single Operand, Direct (Empty String)",
+        "rule": { "/": "" },
+        "error": { "type": "NaN" },
+        "data": null
+    },
+    {
+        "description": "Divide Operator with a Single Operand, Direct (null)",
+        "rule": { "/": null },
+        "error": { "type": "NaN" },
+        "data": null
+    },
+    {
+        "description": "Divide with val",
+        "rule": { "/": [{ "val": "x" }, { "val": "y" }] },
+        "data": { "x": 8, "y": 2 },
+        "result": 4
+    },
+    {
+        "description": "Divide by Zero",
+        "rule": { "/": [0, 0] },
+        "error": { "type": "NaN" },
+        "data": null
+    },
+    {
+        "description": "Divide with String produces NaN",
+        "rule": { "/": [1, "a"] },
+        "error": { "type": "NaN" },
+        "data": null
+    },
+    {
+        "description": "Divide with Array produces NaN",
+        "rule": { "/": [1, [1]] },
+        "error": { "type": "NaN" },
+        "data": null
+    },
+    {
+        "description": "Any division by zero should return NaN",
+        "rule": { "/": [1, 0] },
+        "error": { "type": "NaN" },
+        "data": null
+    },
+    {
+        "description": "Any division by zero should return NaN (2)",
+        "rule": { "/": [8, 2, 0] },
+        "error": { "type": "NaN" },
+        "data": null
+    }
+]
diff --git a/test/community/arithmetic/minus.extra.json b/test/community/arithmetic/minus.extra.json
new file mode 100644
--- /dev/null
+++ b/test/community/arithmetic/minus.extra.json
@@ -0,0 +1,20 @@
+[
+    {
+        "description": "Minus with dynamic empty array",
+        "rule": { "-": { "preserve": [] }},
+        "error": { "type": "Invalid Arguments" },
+        "data": null
+    },
+    {
+        "description": "Minus with dynamic unary array",
+        "rule": { "-": { "preserve": [7] }},
+        "result": -7,
+        "data": null
+    },
+    {
+        "description": "Minus with dynamic array",
+        "rule": { "-": { "preserve": [7, 8] }},
+        "result": -1,
+        "data": null
+    }
+]
diff --git a/test/community/arithmetic/minus.json b/test/community/arithmetic/minus.json
new file mode 100644
--- /dev/null
+++ b/test/community/arithmetic/minus.json
@@ -0,0 +1,136 @@
+[
+    "# Collection of Minus Operator Tests",
+    {
+        "description": "Subtraction",
+        "rule": { "-": [1, 2] },
+        "result": -1,
+        "data": null
+    },
+    {
+        "description": "Subtraction (2)",
+        "rule": { "-": [5, 12] },
+        "result": -7,
+        "data": null
+    },
+    {
+        "description": "Subtraction with Multiple Operands",
+        "rule": { "-": [1, 2, 3, 4] },
+        "result": -8,
+        "data": null
+    },
+    {
+        "description": "Subtraction with Negative Numbers",
+        "rule": { "-": [-1, 0, 5] },
+        "result": -6,
+        "data": null
+    },
+    {
+        "description": "Subtraction with Strings",
+        "rule": { "-": ["1", "2", "3"] },
+        "result": -4,
+        "data": null
+    },
+    {
+        "description": "Subtraction with Booleans",
+        "rule": { "-": [true, false, true] },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "Subtraction with Multiple Value Types",
+        "rule": { "-": [1, "2", 3, "4", "", true, false, null] },
+        "result": -9,
+        "data": null
+    },
+    {
+        "description": "Minus Operator with Single Operand (Number)",
+        "rule": { "-": [1] },
+        "result": -1,
+        "data": null
+    },
+    {
+        "description": "Minus Operator with Single Operand (Negative Number)",
+        "rule": { "-": [-1] },
+        "result": 1,
+        "data": null
+    },
+    {
+        "description": "Minus with zero operands is an error",
+        "rule": { "-": [] },
+        "error": { "type": "Invalid Arguments" },
+        "data": null
+    },
+    {
+        "description": "Minus Operator with Single Operand, Direct (Number)",
+        "rule": { "-": 1 },
+        "result": -1,
+        "data": null
+    },
+    {
+        "description": "Minus Operator with Single Operand, Direct (0)",
+        "rule": { "-": 0 },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "Minus Operator with Single Operand (String)",
+        "rule": { "-": ["1"] },
+        "result": -1,
+        "data": null
+    },
+    {
+        "description": "Minus Operator with Single Operand, Direct (Negative Number String)",
+        "rule": { "-": "-1" },
+        "result": 1,
+        "data": null
+    },
+
+    {
+        "description": "Minus Operator with Single Operand, Direct (String 0)",
+        "rule": { "-": "0" },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "Minus Operator with Single Operand, Direct (true)",
+        "rule": { "-": true },
+        "result": -1,
+        "data": null
+    },
+    {
+        "description": "Minus Operator with Single Operand, Direct (false)",
+        "rule": { "-": false },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "Minus Operator with Single Operand, Direct (Empty String)",
+        "rule": { "-": "" },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "Minus Operator with a Single Operand, Direct (null)",
+        "rule": { "-": null },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "Subtraction with val",
+        "rule": { "-": [{ "val": "x" }, { "val": "y" }] },
+        "data": { "x": 1, "y": 2 },
+        "result": -1
+    },
+    {
+        "description": "Subtraction with string produces NaN",
+        "rule": { "-": ["Hey", 1] },
+        "error": { "type": "NaN" },
+        "data": null
+    },
+    {
+        "description": "Subtraction with Array produces NaN",
+        "rule": { "-": [[1], 1] },
+        "error": { "type": "NaN" },
+        "data": null
+    }
+]
diff --git a/test/community/arithmetic/modulo.extra.json b/test/community/arithmetic/modulo.extra.json
new file mode 100644
--- /dev/null
+++ b/test/community/arithmetic/modulo.extra.json
@@ -0,0 +1,14 @@
+[
+    {
+        "description": "Modulo with dynamic array invalid",
+        "rule": { "%": { "preserve": [] } },
+        "error": { "type": "Invalid Arguments" },
+        "data": null
+    },
+    {
+        "description": "Modulo with dynamic array invalid",
+        "rule": { "%": { "preserve": [1] } },
+        "error": { "type": "Invalid Arguments" },
+        "data": null
+    }
+]
diff --git a/test/community/arithmetic/modulo.json b/test/community/arithmetic/modulo.json
new file mode 100644
--- /dev/null
+++ b/test/community/arithmetic/modulo.json
@@ -0,0 +1,191 @@
+[
+    "# Collection of Modulo Operator Tests",
+    {
+        "description": "Modulo",
+        "rule": { "%": [4, 2] },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "Modulo (2)",
+        "rule": { "%": [2, 2] },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "Modulo (3)",
+        "rule": { "%": [3, 2] },
+        "result": 1,
+        "data": null
+    },
+    {
+        "description": "Modulo with a Decimal Operand",
+        "rule": { "%": [1, 0.5] },
+        "result": 0,
+        "data": null,
+        "decimal": true
+    },
+    {
+        "description": "Modulo with Multiple Operands",
+        "rule": { "%": [8, 6, 3] },
+        "result": 2,
+        "data": null
+    },
+    {
+        "description": "Modulo with Multiple Operands (2)",
+        "rule": { "%": [2, 2, 1] },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "Modulo with Negative Numbers",
+        "rule": { "%": [-1, 2] },
+        "result": -1,
+        "data": null
+    },
+    {
+        "description": "Modulo with Strings",
+        "rule": { "%": ["8", "2"] },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "Modulo with Booleans",
+        "rule": { "%": [false, true] },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "Modulo with Multiple Value Types",
+        "rule": { "%": ["8", 3, true] },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "Modulo with Multiple Value Types (2)",
+        "rule": { "%": ["1", 1] },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "Modulo with Single Operand (Number)",
+        "rule": { "%": [1] },
+        "error": { "type": "Invalid Arguments"},
+        "data": null
+    },
+    {
+        "description": "Modulo with Single Operand, Direct (Number)",
+        "rule": { "%": 1 },
+        "error": { "type": "Invalid Arguments"},
+        "data": null
+    },
+    {
+        "description": "Modulo with Single Operand, Direct (0)",
+        "rule": { "%": 0 },
+        "error": { "type": "Invalid Arguments"},
+        "data": null
+    },
+    {
+        "description": "Modulo Operator with Single Operand (Number)",
+        "rule": { "%": [1] },
+        "error": { "type": "Invalid Arguments"},
+        "data": null
+    },
+    {
+        "description": "Modulo Operator with Single Operand (Negative Number)",
+        "rule": { "%": [-1] },
+        "error": { "type": "Invalid Arguments"},
+        "data": null
+    },
+    {
+        "description": "Modulo with zero operands is an error",
+        "rule": { "%": [] },
+        "error": { "type": "Invalid Arguments" },
+        "data": null
+    },
+    {
+        "description": "Modulo Operator with Single Operand, Direct (Number)",
+        "rule": { "%": 1 },
+        "error": { "type": "Invalid Arguments"},
+        "data": null
+    },
+    {
+        "description": "Modulo Operator with Single Operand, Direct (0)",
+        "rule": { "%": 0 },
+        "error": { "type": "Invalid Arguments"},
+        "data": null
+    },
+    {
+        "description": "Modulo Operator with Single Operand (String)",
+        "rule": { "%": ["1"] },
+        "error": { "type": "Invalid Arguments"},
+        "data": null
+    },
+    {
+        "description": "Modulo Operator with Single Operand, Direct (Negative Number String)",
+        "rule": { "%": "-1" },
+        "error": { "type": "Invalid Arguments"},
+        "data": null
+    },
+
+    {
+        "description": "Modulo Operator with Single Operand, Direct (String 0)",
+        "rule": { "%": "0" },
+        "error": { "type": "Invalid Arguments"},
+        "data": null
+    },
+    {
+        "description": "Modulo Operator with Single Operand, Direct (true)",
+        "rule": { "%": true },
+        "error": { "type": "Invalid Arguments"},
+        "data": null
+    },
+    {
+        "description": "Modulo Operator with Single Operand, Direct (false)",
+        "rule": { "%": false },
+        "error": { "type": "Invalid Arguments"},
+        "data": null
+    },
+    {
+        "description": "Modulo Operator with Single Operand, Direct (Empty String)",
+        "rule": { "%": "" },
+        "error": { "type": "Invalid Arguments"},
+        "data": null
+    },
+    {
+        "description": "Modulo Operator with a Single Operand, Direct (null)",
+        "rule": { "%": null },
+        "error": { "type": "Invalid Arguments"},
+        "data": null
+    },
+    {
+        "description": "Modulo with val",
+        "rule": { "%": [{ "val": "x" }, { "val": "y" }] },
+        "data": { "x": 11, "y": 6 },
+        "result": 5
+    },
+    {
+        "description": "Modulo with string produces NaN",
+        "rule": { "%": ["Hey", 1] },
+        "error": { "type": "NaN" },
+        "data": null
+    },
+    {
+        "description": "Modulo with array produces NaN",
+        "rule": { "%": [[1], 1] },
+        "error": { "type": "NaN" },
+        "data": null
+    },
+    {
+        "description": "Modulo with a negative dividend",
+        "rule": { "%": [-8, 3] },
+        "result": -2,
+        "data": null
+    },
+    { 
+        "description": "Modulo with a negative divisor",
+        "rule": { "%": [8, -3] },
+        "result": 2,
+        "data": null
+    }
+]
diff --git a/test/community/arithmetic/multiply.extra.json b/test/community/arithmetic/multiply.extra.json
new file mode 100644
--- /dev/null
+++ b/test/community/arithmetic/multiply.extra.json
@@ -0,0 +1,20 @@
+[
+    {
+        "description": "Multiply with dynamic empty array",
+        "rule": { "*": { "preserve": [] } },
+        "result": 1,
+        "data": null
+    },
+    {
+        "description": "Multiply with dynamic unary array",
+        "rule": { "*": { "preserve": [2] } },
+        "result": 2,
+        "data": null
+    },
+    {
+        "description": "Multiply with dynamic array",
+        "rule": { "*": { "preserve": [8, 2] } },
+        "result": 16,
+        "data": null
+    }
+]
diff --git a/test/community/arithmetic/multiply.json b/test/community/arithmetic/multiply.json
new file mode 100644
--- /dev/null
+++ b/test/community/arithmetic/multiply.json
@@ -0,0 +1,172 @@
+[
+    "# Collection of Multiply Operator Tests",
+    {
+        "description": "Multiply",
+        "rule": { "*": [3, 2] },
+        "result": 6,
+        "data": null
+    },
+    {
+        "description": "Multiply with Multiple Operands",
+        "rule": { "*": [5, 10, 2] },
+        "result": 100,
+        "data": null
+    },
+    {
+        "description": "Multiply with Multiple Operands (2)",
+        "rule": { "*": [2, 2, 2] },
+        "result": 8,
+        "data": null
+    },
+    {
+        "description": "Multiply with Negative Numbers",
+        "rule": { "*": [-1, 2, 5] },
+        "result": -10,
+        "data": null
+    },
+    {
+        "description": "Multiply with Strings",
+        "rule": { "*": ["1", "2", "3"] },
+        "result": 6,
+        "data": null
+    },
+    {
+        "description": "Multiply with Booleans",
+        "rule": { "*": [true, false, true] },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "Multiply with Multiple Value Types",
+        "rule": { "*": [1, "2", 3, "4", true] },
+        "result": 24,
+        "data": null
+    },
+    {
+        "description": "Multiply with Multiple Value Types (2)",
+        "rule": { "*": [1, "1"] },
+        "result": 1,
+        "data": null
+    },
+    {
+        "description": "Multiply with Multiple Value Types (3)",
+        "rule": { "*": ["1", null] },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "Multiply with Single Operand (Number)",
+        "rule": { "*": [1] },
+        "result": 1,
+        "data": null
+    },
+    {
+        "description": "Multiply with Single Operand, Direct (Number)",
+        "rule": { "*": 1 },
+        "result": 1,
+        "data": null
+    },
+    {
+        "description": "Multiply with Single Operand, Direct (0)",
+        "rule": { "*": 0 },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "Multiply Operator with Single Operand (Number)",
+        "rule": { "*": [1] },
+        "result": 1,
+        "data": null
+    },
+    {
+        "description": "Multiply Operator with Single Operand (Negative Number)",
+        "rule": { "*": [-1] },
+        "result": -1,
+        "data": null
+    },
+    {
+        "description": "Multiply with zero operands is 1 (Product)",
+        "rule": { "*": [] },
+        "result": 1,
+        "data": null
+    },
+    {
+        "description": "Multiply Operator with Single Operand, Direct (Number)",
+        "rule": { "*": 1 },
+        "result": 1,
+        "data": null
+    },
+    {
+        "description": "Multiply Operator with Single Operand, Direct (0)",
+        "rule": { "*": 0 },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "Multiply Operator with Single Operand (String)",
+        "rule": { "*": ["1"] },
+        "result": 1,
+        "data": null
+    },
+    {
+        "description": "Multiply Operator with Single Operand, Direct (Negative Number String)",
+        "rule": { "*": "-1" },
+        "result": -1,
+        "data": null
+    },
+
+    {
+        "description": "Multiply Operator with Single Operand, Direct (String 0)",
+        "rule": { "*": "0" },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "Multiply Operator with Single Operand, Direct (true)",
+        "rule": { "*": true },
+        "result": 1,
+        "data": null
+    },
+    {
+        "description": "Multiply Operator with Single Operand, Direct (false)",
+        "rule": { "*": false },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "Multiply Operator with Single Operand, Direct (Empty String)",
+        "rule": { "*": "" },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "Multiply Operator with a Single Operand, Direct (null)",
+        "rule": { "*": null },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "Multiply with val",
+        "rule": { "*": [{ "val": "x" }, { "val": "y" }] },
+        "data": { "x": 8, "y": 2 },
+        "result": 16
+    },
+    {
+        "description": "Multiply with string produces NaN",
+        "rule": { "*": ["Hey", 1] },
+        "error": { "type": "NaN" },
+        "data": null
+    },
+    {
+        "description": "Multiply with a single string produces NaN",
+        "rule": { "*": ["Hey"] },
+        "error": { "type": "NaN" },
+        "data": null
+    },
+    {
+        "description": "Multiply with Array produces NaN",
+        "rule": { "*": [[1], 1] },
+        "error": { "type": "NaN" },
+        "data": null
+    }
+]
diff --git a/test/community/arithmetic/plus.extra.json b/test/community/arithmetic/plus.extra.json
new file mode 100644
--- /dev/null
+++ b/test/community/arithmetic/plus.extra.json
@@ -0,0 +1,20 @@
+[
+    {
+        "description": "Plus operator with empty array dynamic",
+        "rule": { "+": { "preserve": [] } },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "Plus operator with unary array dynamic",
+        "rule": { "+": { "preserve": [7] } },
+        "result": 7,
+        "data": null
+    },
+    {
+        "description": "Plus operator with array dynamic",
+        "rule": { "+": { "preserve": [7, 8] } },
+        "result": 15,
+        "data": null
+    }
+]
diff --git a/test/community/arithmetic/plus.json b/test/community/arithmetic/plus.json
new file mode 100644
--- /dev/null
+++ b/test/community/arithmetic/plus.json
@@ -0,0 +1,198 @@
+[
+    "# Collection of Plus Operator Tests",
+    {
+        "description": "Addition",
+        "rule": { "+": [1, 2] },
+        "result": 3,
+        "data": null
+    },
+    {
+        "description": "Addition (2)",
+        "rule": { "+": [5, 12] },
+        "result": 17,
+        "data": null
+    },
+    {
+        "description": "Addition with Multiple Operands",
+        "rule": { "+": [1, 2, 3, 4] },
+        "result": 10,
+        "data": null
+    },
+    {
+        "description": "Addition with Negative Numbers",
+        "rule": { "+": [-1, 0, 5] },
+        "result": 4,
+        "data": null
+    },
+    {
+        "description": "Addition with Strings",
+        "rule": { "+": ["1", "2", "3"] },
+        "result": 6,
+        "data": null
+    },
+    {
+        "description": "Addition with Booleans",
+        "rule": { "+": [true, false, true] },
+        "result": 2,
+        "data": null
+    },
+    {
+        "description": "Addition with Multiple Value Types",
+        "rule": { "+": [1, "2", 3, "4", "", true, false, null] },
+        "result": 11,
+        "data": null
+    },
+    {
+        "description": "Plus Operator with Single Operand (Number)",
+        "rule": { "+": [1] },
+        "result": 1,
+        "data": null
+    },
+    {
+        "description": "Plus Operator with Single Operand (Negative Number)",
+        "rule": { "+": [-1] },
+        "result": -1,
+        "data": null
+    },
+    {
+        "description": "Plus Operator with zero operands is zero",
+        "rule": { "+": [] },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "Plus Operator with Single Operand, Direct (Number)",
+        "rule": { "+": 1 },
+        "result": 1,
+        "data": null
+    },
+    {
+        "description": "Plus Operator with Single Operand, Direct (0)",
+        "rule": { "+": 0 },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "Plus Operator with Single Operand (String)",
+        "rule": { "+": ["1"] },
+        "result": 1,
+        "data": null
+    },
+    {
+        "description": "Plus Operator with Single Operand, Direct (Negative Number String)",
+        "rule": { "+": "-1" },
+        "result": -1,
+        "data": null
+    },
+    {
+        "description": "Plus Operator with Single Operand, Direct (String Decimal)",
+        "rule": { "+": "1.5" },
+        "result": 1.5,
+        "data": null,
+        "decimal": true
+    },
+    {
+        "description": "Plus Operator with Single Operand, Direct (String Negative Decimal)",
+        "rule": { "+": "-1.5" },
+        "result": -1.5,
+        "data": null,
+        "decimal": true
+    },
+    {
+        "description": "Plus Operator with Single Operand, Direct (String 0.5)",
+        "rule": { "+": "0.5" },
+        "result": 0.5,
+        "data": null,
+        "decimal": true
+    },
+    {
+        "description": "Plus Operator with Single Operand, Direct (String 1e2)",
+        "rule": { "+": "1e2" },
+        "result": 100,
+        "data": null
+    },
+    {
+        "description": "Plus Operator with Single Operand, Direct (String 0)",
+        "rule": { "+": "0" },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "Plus Operator with Single Operand, Direct (true)",
+        "rule": { "+": true },
+        "result": 1,
+        "data": null
+    },
+    {
+        "description": "Plus Operator with Single Operand, Direct (false)",
+        "rule": { "+": false },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "Plus Operator with Single Operand, Direct (Empty String)",
+        "rule": { "+": "" },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "Plus Operator with a Single Operand, Direct (null)",
+        "rule": { "+": null },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "Addition with val",
+        "rule": { "+": [{ "val": "x" }, { "val": "y" }] },
+        "result": 3,
+        "data": { "x": 1, "y": 2 }
+    },
+    {
+        "description": "Addition with string produces NaN",
+        "rule": { "+": ["Hey", 1] },
+        "error": { "type": "NaN" },
+        "data": null
+    },
+    {
+        "description": "Addition with Array produces NaN",
+        "rule": { "+": [[1], 1] },
+        "error": { "type": "NaN" },
+        "data": null
+    },
+    {
+        "description": "Addition with Array from context produces NaN",
+        "rule": { "+": [{ "val": "x" }, 1] },
+        "error": { "type": "NaN" },
+        "data": { "x": [1] }
+    },
+    {
+        "description": "Addition with Object produces NaN",
+        "rule": { "+": [{ "val": "x" }, 1] },
+        "error": { "type": "NaN" },
+        "data": { "x": {} }
+    },
+    {
+        "description": "Plus Operator with Single Operand, Invalid String Produces NaN",
+        "rule": { "+": "Hello" },
+        "error": { "type": "NaN" },
+        "data": null
+    },
+    {
+        "description": "Plus Operator with Single Operand, Array Input Produces NaN",
+        "rule": { "+": [[1]] },
+        "error": { "type": "NaN" },
+        "data": null
+    },
+    {
+        "description": "Plus Operator with Single Operand, Object Input Produces NaN",
+        "rule": { "+": [{}] },
+        "error": { "type": "NaN" },
+        "data": null
+    },
+    {
+        "description": "Plus Operator with Single Operand, Direct Object Input Produces NaN",
+        "rule": { "+": {} },
+        "error": { "type": "NaN" },
+        "data": null
+    }
+]
diff --git a/test/community/array/all.json b/test/community/array/all.json
new file mode 100644
--- /dev/null
+++ b/test/community/array/all.json
@@ -0,0 +1,174 @@
+[
+    "Basic Operations",
+    {
+        "description": "All elements are positive",
+        "rule": {
+            "all": [
+                {"var": "numbers"},
+                {">": [{"var": ""}, 0]}
+            ]
+        },
+        "data": {"numbers": [1, 2, 3, 4]},
+        "result": true
+    },
+    {
+        "description": "Not all elements are positive",
+        "rule": {
+            "all": [
+                {"var": "numbers"},
+                {">": [{"var": ""}, 0]}
+            ]
+        },
+        "data": {"numbers": [-1, 0, 1, 2]},
+        "result": false
+    },
+    "Object Operations",
+    {
+        "description": "All objects meet age requirement",
+        "rule": {
+            "all": [
+                {"var": "people"},
+                {">=": [{"var": "age"}, 21]}
+            ]
+        },
+        "data": {
+            "people": [
+                {"name": "John", "age": 25},
+                {"name": "Jane", "age": 22},
+                {"name": "Bob", "age": 30}
+            ]
+        },
+        "result": true
+    },
+    {
+        "description": "Not all objects meet criteria",
+        "rule": {
+            "all": [
+                {"var": "items"},
+                {">=": [{"var": "stock"}, 10]}
+            ]
+        },
+        "data": {
+            "items": [
+                {"id": 1, "stock": 15},
+                {"id": 2, "stock": 8},
+                {"id": 3, "stock": 12}
+            ]
+        },
+        "result": false
+    },
+    "Edge Cases",
+    {
+        "description": "Empty array returns false",
+        "rule": {
+            "all": [
+                {"var": "empty"},
+                {">": [{"var": ""}, 0]}
+            ]
+        },
+        "data": {"empty": []},
+        "result": false
+    },
+    {
+        "description": "Null array should throw",
+        "rule": {
+            "all": [
+                null,
+                {">": [{"var": ""}, 0]}
+            ]
+        },
+        "data": null,
+        "error": {"type": "Invalid Arguments"}
+    },
+    {
+        "description": "Missing array returns error",
+        "rule": {
+            "all": [
+                {"var": "missing"},
+                {">": [{"var": ""}, 0]}
+            ]
+        },
+        "data": {},
+        "error": {"type": "Invalid Arguments"}
+    },
+    {
+        "description": "Null predicate returns false",
+        "rule": {
+            "all": [
+                {"var": "numbers"},
+                null
+            ]
+        },
+        "data": {"numbers": [1, 2, 3]},
+        "result": false
+    },
+    "Complex Predicates",
+    {
+        "description": "All with compound conditions",
+        "rule": {
+            "all": [
+                {"var": "data"},
+                {"and": [
+                    {">=": [{"var": "score"}, 0]},
+                    {"<=": [{"var": "score"}, 100]}
+                ]}
+            ]
+        },
+        "data": {
+            "data": [
+                {"score": 75},
+                {"score": 82},
+                {"score": 95}
+            ]
+        },
+        "result": true
+    },
+    {
+        "description": "All with nested array operations",
+        "rule": {
+            "all": [
+                {"var": "classes"},
+                {"all": [
+                    {"var": "students"},
+                    {">=": [{"var": "grade"}, 60]}
+                ]}
+            ]
+        },
+        "data": {
+            "classes": [
+                {"students": [{"grade": 80}, {"grade": 75}]},
+                {"students": [{"grade": 90}, {"grade": 85}]}
+            ]
+        },
+        "result": true
+    },
+    "Type Coercion",
+    {
+        "description": "All with mixed types coercion to boolean",
+        "rule": {
+            "all": [
+                {"var": "values"},
+                {"!!": [{"var": ""}]}
+            ]
+        },
+        "data": {"values": [1, "hello", true, [1], {"x": 1}]},
+        "result": true
+    },
+    {
+        "description": "All with string comparison",
+        "rule": {
+            "all": [
+                {"var": "words"},
+                {">": [{"var": "length"}, 3]}
+            ]
+        },
+        "data": {
+            "words": [
+                {"text": "hello", "length": 5},
+                {"text": "world", "length": 5},
+                {"text": "test", "length": 4}
+            ]
+        },
+        "result": true
+    }
+]
diff --git a/test/community/array/filter.json b/test/community/array/filter.json
new file mode 100644
--- /dev/null
+++ b/test/community/array/filter.json
@@ -0,0 +1,173 @@
+[
+    "Basic Operations",
+    {
+        "description": "Filter positive numbers",
+        "rule": {
+            "filter": [
+                {"var": "numbers"},
+                {">": [{"var": ""}, 0]}
+            ]
+        },
+        "data": {"numbers": [-2, -1, 0, 1, 2]},
+        "result": [1, 2]
+    },
+    {
+        "description": "Filter even numbers",
+        "rule": {
+            "filter": [
+                {"var": "numbers"},
+                {"==": [0, {"%": [{"var": ""}, 2]}]}
+            ]
+        },
+        "data": {"numbers": [1, 2, 3, 4, 5, 6]},
+        "result": [2, 4, 6]
+    },
+    "Object Operations",
+    {
+        "description": "Filter objects by property value",
+        "rule": {
+            "filter": [
+                {"var": "people"},
+                {">": [{"var": "age"}, 21]}
+            ]
+        },
+        "data": {
+            "people": [
+                {"name": "John", "age": 20},
+                {"name": "Jane", "age": 25},
+                {"name": "Bob", "age": 30}
+            ]
+        },
+        "result": [
+            {"name": "Jane", "age": 25},
+            {"name": "Bob", "age": 30}
+        ]
+    },
+    {
+        "description": "Filter with multiple conditions",
+        "rule": {
+            "filter": [
+                {"var": "items"},
+                {"and": [
+                    {">": [{"var": "price"}, 10]},
+                    {"<": [{"var": "price"}, 50]}
+                ]}
+            ]
+        },
+        "data": {
+            "items": [
+                {"id": 1, "price": 5},
+                {"id": 2, "price": 25},
+                {"id": 3, "price": 75}
+            ]
+        },
+        "result": [{"id": 2, "price": 25}]
+    },
+    "Edge Cases",
+    {
+        "description": "Filter on null array returns empty array",
+        "rule": {
+            "filter": [
+                {"var": "missing"},
+                {">": [{"var": ""}, 0]}
+            ]
+        },
+        "data": {},
+        "result": []
+    },
+    {
+        "description": "Filter on empty array returns empty array",
+        "rule": {
+            "filter": [
+                {"var": "empty"},
+                {"==": [{"var": ""}, 1]}
+            ]
+        },
+        "data": {"empty": []},
+        "result": []
+    },
+    {
+        "description": "Filter with null predicate should throw",
+        "rule": {
+            "filter": [{"var": "numbers"}, null]
+        },
+        "data": {"numbers": [1, 2, 3]},
+        "error": {"type": "Invalid Arguments"}
+    },
+    {
+        "description": "Filter with null array should throw",
+        "rule": {
+            "filter": [null, {">": [{"var": ""}, 0]}]
+        },
+        "data": null,
+        "error": {"type": "Invalid Arguments"}
+    },
+    "Complex Predicates",
+    {
+        "description": "Filter with nested logic",
+        "rule": {
+            "filter": [
+                {"var": "data"},
+                {"or": [
+                    {"==": [{"var": "type"}, "A"]},
+                    {">": [{"var": "value"}, 100]}
+                ]}
+            ]
+        },
+        "data": {
+            "data": [
+                {"type": "A", "value": 50},
+                {"type": "B", "value": 150},
+                {"type": "C", "value": 75}
+            ]
+        },
+        "result": [
+            {"type": "A", "value": 50},
+            {"type": "B", "value": 150}
+        ]
+    },
+    {
+        "description": "Filter with string operations",
+        "rule": {
+            "filter": [
+                {"var": "words"},
+                {"in": ["a", {"var": ""}]}
+            ]
+        },
+        "data": {"words": ["apple", "banana", "cherry"]},
+        "result": ["apple", "banana"]
+    },
+    "Type Coercion",
+    {
+        "description": "Filter with type coercion",
+        "rule": {
+            "filter": [
+                {"var": "mixed"},
+                {"!!": [{"var": ""}]}
+            ]
+        },
+        "data": {"mixed": [0, 1, "", "hello", [], [1]]},
+        "result": [1, "hello", [1]]
+    },
+    "Path Resolution",
+    {
+        "description": "Filter with deep path access",
+        "rule": {
+            "filter": [
+                {"var": "users"},
+                {"var": "settings.active"}
+            ]
+        },
+        "data": {
+            "users": [
+                {"name": "John", "settings": {"active": true}},
+                {"name": "Jane", "settings": {"active": false}},
+                {"name": "Bob", "settings": {"active": true}}
+            ]
+        },
+        "result": [
+            {"name": "John", "settings": {"active": true}},
+            {"name": "Bob", "settings": {"active": true}}
+        ]
+    }
+]
diff --git a/test/community/array/map.json b/test/community/array/map.json
new file mode 100644
--- /dev/null
+++ b/test/community/array/map.json
@@ -0,0 +1,169 @@
+[
+    "Basic Operations",
+    {
+        "description": "Map with identity function",
+        "rule": {"map": [{"var": "integers"}, {"var": ""}]},
+        "data": {"integers": [1, 2, 3]},
+        "result": [1, 2, 3]
+    },
+    {
+        "description": "Map with multiplication",
+        "rule": {
+            "map": [
+                {"var": "integers"},
+                {"*": [{"var": ""}, 2]}
+            ]
+        },
+        "data": {"integers": [1, 2, 3]},
+        "result": [2, 4, 6]
+    },
+    "Object Operations",
+    {
+        "description": "Map extracting single field from objects",
+        "rule": {
+            "map": [{"var": "desserts"}, {"var": "qty"}]
+        },
+        "data": {
+            "desserts": [
+                {"name": "apple", "qty": 1},
+                {"name": "brownie", "qty": 2},
+                {"name": "cupcake", "qty": 3}
+            ]
+        },
+        "result": [1, 2, 3]
+    },
+    {
+        "description": "Map with complex object transformation",
+        "rule": {
+            "map": [
+                {"var": "items"},
+                {"+": [{"var": "price"}, {"var": "tax"}]}
+            ]
+        },
+        "data": {
+            "items": [
+                {"price": 100, "tax": 10},
+                {"price": 200, "tax": 20}
+            ]
+        },
+        "result": [110, 220]
+    },
+    "Edge Cases",
+    {
+        "description": "Map on null array returns empty array",
+        "rule": {
+            "map": [
+                {"var": "missing"},
+                {"*": [{"var": ""}, 2]}
+            ]
+        },
+        "data": null,
+        "result": []
+    },
+    {
+        "description": "Map on empty array returns empty array",
+        "rule": {
+            "map": [
+                {"var": "empty"},
+                {"*": [{"var": ""}, 2]}
+            ]
+        },
+        "data": {"empty": []},
+        "result": []
+    },
+    {
+        "description": "Map with null mapper should throw",
+        "rule": {"map": [{"var": "integers"}, null]},
+        "data": {"integers": [1, 2, 3]},
+        "error": {"type": "Invalid Arguments"}
+    },
+    {
+        "description": "Map with null array should throw",
+        "rule": {"map": [null, {"var": ""}]},
+        "data": null,
+        "error": {"type": "Invalid Arguments"}
+    },
+    "Complex Transformations",
+    {
+        "description": "Map with nested logic operations",
+        "rule": {
+            "map": [
+                {"var": "values"},
+                {"if": [
+                    {">": [{"var": ""}, 0]},
+                    "positive",
+                    "negative"
+                ]}
+            ]
+        },
+        "data": {"values": [-1, 0, 1]},
+        "result": ["negative", "negative", "positive"]
+    },
+    {
+        "description": "Map with string operations",
+        "rule": {
+            "map": [
+                {"var": "names"},
+                {"cat": [{"var": ""}, "!!"]}
+            ]
+        },
+        "data": {"names": ["hello", "world"]},
+        "result": ["hello!!", "world!!"]
+    },
+    "Path Resolution",
+    {
+        "description": "Map with nested path access",
+        "rule": {
+            "map": [
+                {"var": "users"},
+                {"var": "profile.name"}
+            ]
+        },
+        "data": {
+            "users": [
+                {"profile": {"name": "John"}},
+                {"profile": {"name": "Jane"}}
+            ]
+        },
+        "result": ["John", "Jane"]
+    },
+    {
+        "description": "Map with missing nested paths",
+        "rule": {
+            "map": [
+                {"var": "users"},
+                {"var": "profile.age"}
+            ]
+        },
+        "data": {
+            "users": [
+                {"profile": {"name": "John"}},
+                {"profile": {"name": "Jane"}}
+            ]
+        },
+        "result": [null, null]
+    },
+    "Type Coercion",
+    {
+        "description": "Map with numeric coercion",
+        "rule": {
+            "map": [
+                {"var": "mixed"},
+                {"+": [{"var": ""}, 1]}
+            ]
+        },
+        "data": {"mixed": ["1", 2, "3"]},
+        "result": [2, 3, 4]
+    },
+    {
+        "description": "Map with boolean coercion",
+        "rule": {
+            "map": [
+                {"var": "values"},
+                {"!!": [{"var": ""}]}
+            ]
+        },
+        "data": {"values": [0, 1, "", "hello", [], [1]]},
+        "result": [false, true, false, true, false, true]
+    }
+]
diff --git a/test/community/array/merge.json b/test/community/array/merge.json
new file mode 100644
--- /dev/null
+++ b/test/community/array/merge.json
@@ -0,0 +1,105 @@
+[
+    "Basic Operations",
+    {
+        "description": "Merge two arrays",
+        "rule": {
+            "merge": [
+                [1, 2],
+                [3, 4]
+            ]
+        },
+        "data": null,
+        "result": [1, 2, 3, 4]
+    },
+    {
+        "description": "Merge array variables",
+        "rule": {
+            "merge": [
+                {"var": "arr1"},
+                {"var": "arr2"}
+            ]
+        },
+        "data": {
+            "arr1": [1, 2],
+            "arr2": [3, 4]
+        },
+        "result": [1, 2, 3, 4]
+    },
+    "Edge Cases",
+    {
+        "description": "Merge with empty array",
+        "rule": {
+            "merge": [
+                [1, 2],
+                []
+            ]
+        },
+        "data": null,
+        "result": [1, 2]
+    },
+    {
+        "description": "Merge multiple empty arrays",
+        "rule": {
+            "merge": [[], [], []]
+        },
+        "data": null,
+        "result": []
+    },
+    {
+        "description": "Merge with null should be ignored",
+        "rule": {
+            "merge": [
+                [1, 2],
+                null,
+                [3, 4]
+            ]
+        },
+        "data": null,
+        "result": [1, 2, null, 3, 4]
+    },
+    {
+        "description": "Merge without arguments returns empty array",
+        "rule": {
+            "merge": []
+        },
+        "data": null,
+        "result": []
+    },
+    "Complex Operations",
+    {
+        "description": "Merge arrays of objects",
+        "rule": {
+            "merge": [
+                {"var": "users1"},
+                {"var": "users2"}
+            ]
+        },
+        "data": {
+            "users1": [{"id": 1}, {"id": 2}],
+            "users2": [{"id": 3}, {"id": 4}]
+        },
+        "result": [
+            {"id": 1},
+            {"id": 2},
+            {"id": 3},
+            {"id": 4}
+        ]
+    },
+    {
+        "description": "Merge with filtered arrays",
+        "rule": {
+            "merge": [
+                {"filter": [
+                    {"var": "numbers"},
+                    {"<": [{"var": ""}, 0]}
+                ]},
+                {"filter": [
+                    {"var": "numbers"},
+                    {">": [{"var": ""}, 0]}
+                ]}
+            ]
+        },
+        "data": {"numbers": [-2, -1, 0, 1, 2]},
+        "result": [-2, -1, 1, 2]
+    }
+]
diff --git a/test/community/array/none.json b/test/community/array/none.json
new file mode 100644
--- /dev/null
+++ b/test/community/array/none.json
@@ -0,0 +1,180 @@
+[
+    "Basic Operations",
+    {
+        "description": "None of the elements are negative",
+        "rule": {
+            "none": [
+                {"var": "numbers"},
+                {"<": [{"var": ""}, 0]}
+            ]
+        },
+        "data": {"numbers": [1, 2, 3, 4]},
+        "result": true
+    },
+    {
+        "description": "Some elements are negative (none returns false)",
+        "rule": {
+            "none": [
+                {"var": "numbers"},
+                {"<": [{"var": ""}, 0]}
+            ]
+        },
+        "data": {"numbers": [-1, 0, 1, 2]},
+        "result": false
+    },
+    "Object Operations",
+    {
+        "description": "None of the objects are underage",
+        "rule": {
+            "none": [
+                {"var": "people"},
+                {"<": [{"var": "age"}, 18]}
+            ]
+        },
+        "data": {
+            "people": [
+                {"name": "John", "age": 25},
+                {"name": "Jane", "age": 22},
+                {"name": "Bob", "age": 19}
+            ]
+        },
+        "result": true
+    },
+    {
+        "description": "None of items are out of stock",
+        "rule": {
+            "none": [
+                {"var": "items"},
+                {"==": [{"var": "stock"}, 0]}
+            ]
+        },
+        "data": {
+            "items": [
+                {"id": 1, "stock": 5},
+                {"id": 2, "stock": 15},
+                {"id": 3, "stock": 3}
+            ]
+        },
+        "result": true
+    },
+    "Edge Cases",
+    {
+        "description": "Empty array returns true",
+        "rule": {
+            "none": [
+                {"var": "empty"},
+                {"<": [{"var": ""}, 0]}
+            ]
+        },
+        "data": {"empty": []},
+        "result": true
+    },
+    {
+        "description": "Null array should throw",
+        "rule": {
+            "none": [
+                null,
+                {"<": [{"var": ""}, 0]}
+            ]
+        },
+        "data": null,
+        "error": {"type": "Invalid Arguments"}
+    },
+    {
+        "description": "Missing array returns error",
+        "rule": {
+            "none": [
+                {"var": "missing"},
+                {"<": [{"var": ""}, 0]}
+            ]
+        },
+        "data": {},
+        "error": {"type": "Invalid Arguments"}
+    },
+    {
+        "description": "Null predicate returns true",
+        "rule": {
+            "none": [
+                {"var": "numbers"},
+                null
+            ]
+        },
+        "data": {"numbers": [1, 2, 3]},
+        "result": true
+    },
+    "Complex Predicates",
+    {
+        "description": "None with compound conditions",
+        "rule": {
+            "none": [
+                {"var": "scores"},
+                {"and": [
+                    {"<": [{"var": ""}, 60]},
+                    {">": [{"var": ""}, 0]}
+                ]}
+            ]
+        },
+        "data": {
+            "scores": [75, 85, 95, 65]
+        },
+        "result": true
+    },
+    {
+        "description": "None with nested array operations",
+        "rule": {
+            "none": [
+                {"var": "classes"},
+                {"none": [
+                    {"var": "students"},
+                    {"==": [{"var": "grade"}, "F"]}
+                ]}
+            ]
+        },
+        "data": {
+            "classes": [
+                {"students": [{"grade": "B"}, {"grade": "C"}]},
+                {"students": [{"grade": "A"}, {"grade": "B"}]}
+            ]
+        },
+        "result": false
+    },
+    "Type Coercion",
+    {
+        "description": "None with mixed types coercion to boolean",
+        "rule": {
+            "none": [
+                {"var": "values"},
+                {"!!": [{"var": ""}]}
+            ]
+        },
+        "data": {"values": [0, "", false, null]},
+        "result": true
+    },
+    {
+        "description": "None with string operations",
+        "rule": {
+            "none": [
+                {"var": "words"},
+                {"in": ["x", {"var": ""}]}
+            ]
+        },
+        "data": {"words": ["hello", "world", "test"]},
+        "result": true
+    },
+    {
+        "description": "None with zero length strings",
+        "rule": {
+            "none": [
+                {"var": "strings"},
+                {"==": [{"var": "length"}, 0]}
+            ]
+        },
+        "data": {
+            "strings": [
+                {"text": "hello", "length": 5},
+                {"text": "world", "length": 5}
+            ]
+        },
+        "result": true
+    }
+]
diff --git a/test/community/array/reduce.json b/test/community/array/reduce.json
new file mode 100644
--- /dev/null
+++ b/test/community/array/reduce.json
@@ -0,0 +1,129 @@
+[
+    "Basic Operations",
+    {
+        "description": "Reduce array with sum",
+        "rule": {
+            "reduce": [
+                {"var": "numbers"},
+                {"+": [{"var": "current"}, {"var": "accumulator"}]},
+                0
+            ]
+        },
+        "data": {"numbers": [1, 2, 3, 4]},
+        "result": 10
+    },
+    {
+        "description": "Reduce array with multiplication",
+        "rule": {
+            "reduce": [
+                {"var": "numbers"},
+                {"*": [{"var": "current"}, {"var": "accumulator"}]},
+                1
+            ]
+        },
+        "data": {"numbers": [1, 2, 3, 4]},
+        "result": 24
+    },
+    "Object Operations",
+    {
+        "description": "Reduce objects to sum of specific field",
+        "rule": {
+            "reduce": [
+                {"var": "items"},
+                {"+": [{"var": "current.price"}, {"var": "accumulator"}]},
+                0
+            ]
+        },
+        "data": {
+            "items": [
+                {"id": 1, "price": 10},
+                {"id": 2, "price": 20},
+                {"id": 3, "price": 30}
+            ]
+        },
+        "result": 60
+    },
+    {
+        "description": "Reduce to create aggregated object",
+        "rule": {
+            "reduce": [
+                {"var": "transactions"},
+                {"+": [{"var": "accumulator"}, {"var": "current.amount"}]},
+                0
+            ]
+        },
+        "data": {
+            "transactions": [
+                {"type": "credit", "amount": 100},
+                {"type": "debit", "amount": -50},
+                {"type": "credit", "amount": 75}
+            ]
+        },
+        "result": 125
+    },
+    "Edge Cases",
+    {
+        "description": "Reduce empty array returns initial value",
+        "rule": {
+            "reduce": [
+                {"var": "empty"},
+                {"+": [{"var": "current"}, {"var": "accumulator"}]},
+                0
+            ]
+        },
+        "data": {"empty": []},
+        "result": 0
+    },
+    {
+        "description": "Reduce null array returns initial value",
+        "rule": {
+            "reduce": [
+                {"var": "missing"},
+                {"+": [{"var": "current"}, {"var": "accumulator"}]},
+                42
+            ]
+        },
+        "data": {},
+        "result": 42
+    },
+    {
+        "description": "Initial value is optional",
+        "rule": {
+            "reduce": [
+                {"var": "numbers"},
+                {"+": [{"var": "current"}, {"var": "accumulator"}]}
+            ]
+        },
+        "data": {"numbers": [1, 2, 3]},
+        "result": 6
+    },
+    "Complex Operations",
+    {
+        "description": "Reduce with string concatenation",
+        "rule": {
+            "reduce": [
+                {"var": "words"},
+                {"cat": [{"var": "accumulator"}, " ", {"var": "current"}]},
+                ""
+            ]
+        },
+        "data": {"words": ["Hello", "World", "!"]},
+        "result": " Hello World !"
+    },
+    {
+        "description": "Reduce with conditional logic",
+        "rule": {
+            "reduce": [
+                {"var": "numbers"},
+                {"if": [
+                    {">": [{"var": "current"}, 0]},
+                    {"+": [{"var": "accumulator"}, {"var": "current"}]},
+                    {"var": "accumulator"}
+                ]},
+                0
+            ]
+        },
+        "data": {"numbers": [-1, 2, -3, 4]},
+        "result": 6
+    }
+]
diff --git a/test/community/array/some.json b/test/community/array/some.json
new file mode 100644
--- /dev/null
+++ b/test/community/array/some.json
@@ -0,0 +1,175 @@
+[
+    "Basic Operations",
+    {
+        "description": "Some elements are positive",
+        "rule": {
+            "some": [
+                {"var": "numbers"},
+                {">": [{"var": ""}, 0]}
+            ]
+        },
+        "data": {"numbers": [-2, -1, 0, 1]},
+        "result": true
+    },
+    {
+        "description": "No elements are positive",
+        "rule": {
+            "some": [
+                {"var": "numbers"},
+                {">": [{"var": ""}, 0]}
+            ]
+        },
+        "data": {"numbers": [-3, -2, -1]},
+        "result": false
+    },
+    "Object Operations",
+    {
+        "description": "Some objects meet age requirement",
+        "rule": {
+            "some": [
+                {"var": "people"},
+                {">=": [{"var": "age"}, 21]}
+            ]
+        },
+        "data": {
+            "people": [
+                {"name": "John", "age": 18},
+                {"name": "Jane", "age": 22},
+                {"name": "Bob", "age": 17}
+            ]
+        },
+        "result": true
+    },
+    {
+        "description": "Some objects meet stock threshold",
+        "rule": {
+            "some": [
+                {"var": "items"},
+                {">": [{"var": "stock"}, 100]}
+            ]
+        },
+        "data": {
+            "items": [
+                {"id": 1, "stock": 50},
+                {"id": 2, "stock": 150},
+                {"id": 3, "stock": 75}
+            ]
+        },
+        "result": true
+    },
+    "Edge Cases",
+    {
+        "description": "Empty array returns false",
+        "rule": {
+            "some": [
+                {"var": "empty"},
+                {">": [{"var": ""}, 0]}
+            ]
+        },
+        "data": {"empty": []},
+        "result": false
+    },
+    {
+        "description": "Null array should throw",
+        "rule": {
+            "some": [
+                null,
+                {">": [{"var": ""}, 0]}
+            ]
+        },
+        "data": null,
+        "error": {"type": "Invalid Arguments"}
+    },
+    {
+        "description": "Missing array returns error",
+        "rule": {
+            "some": [
+                {"var": "missing"},
+                {">": [{"var": ""}, 0]}
+            ]
+        },
+        "data": {},
+        "error": {"type": "Invalid Arguments"}
+    },
+    {
+        "description": "Null predicate returns false",
+        "rule": {
+            "some": [
+                {"var": "numbers"},
+                null
+            ]
+        },
+        "data": {"numbers": [1, 2, 3]},
+        "result": false
+    },
+    "Complex Predicates",
+    {
+        "description": "Some with compound conditions",
+        "rule": {
+            "some": [
+                {"var": "scores"},
+                {"and": [
+                    {">=": [{"var": ""}, 90]},
+                    {"<=": [{"var": ""}, 100]}
+                ]}
+            ]
+        },
+        "data": {
+            "scores": [75, 85, 95, 65]
+        },
+        "result": true
+    },
+    {
+        "description": "Some with nested array operations",
+        "rule": {
+            "some": [
+                {"var": "classes"},
+                {"some": [
+                    {"var": "students"},
+                    {"==": [{"var": "grade"}, "A"]}
+                ]}
+            ]
+        },
+        "data": {
+            "classes": [
+                {"students": [{"grade": "B"}, {"grade": "C"}]},
+                {"students": [{"grade": "A"}, {"grade": "B"}]}
+            ]
+        },
+        "result": true
+    },
+    "Type Coercion",
+    {
+        "description": "Some with mixed types coercion to boolean",
+        "rule": {
+            "some": [
+                {"var": "values"},
+                {"!!": [{"var": ""}]}
+            ]
+        },
+        "data": {"values": [0, "", false, 1, null]},
+        "result": true
+    },
+    {
+        "description": "Some with string operations",
+        "rule": {
+            "some": [
+                {"var": "words"},
+                {"in": ["a", {"var": ""}]}
+            ]
+        },
+        "data": {"words": ["hello", "world", "apple"]},
+        "result": true
+    },
+    {
+        "description": "Some with empty strings",
+        "rule": {
+            "some": [
+                {"var": "strings"},
+                {"==": [{"var": ""}, ""]}
+            ]
+        },
+        "data": {"strings": ["hello", "", "world"]},
+        "result": true
+    }
+]
diff --git a/test/community/chained.json b/test/community/chained.json
new file mode 100644
--- /dev/null
+++ b/test/community/chained.json
@@ -0,0 +1,68 @@
+[
+  "# These are tests from https://github.com/orgs/json-logic/discussions/2",
+  {
+      "description": "Standard Max",
+      "rule": { "max": [1, 2, 3] },
+      "data": {},
+      "result": 3
+  },
+  {
+      "description": "Standard Max, Single Argument Sugared",
+      "rule": { "max": 1 },
+      "data": {},
+      "result": 1
+  },
+  {
+      "description": "Max with Logic Chaining",
+      "rule": { "max": { "val": "data" } },
+      "data": { "data": [1, 2, 3] },
+      "result": 3
+  },
+  {
+      "description": "Cat with Logic Chaining",
+      "rule": { "cat": { "merge": [["Hello "], ["World", "!"]] } },
+      "data": {},
+      "result": "Hello World!"
+  },
+  {
+      "description": "Cat with Logic Chaining (Simple)",
+      "rule": { "cat": { "val": "text" } },
+      "data": { "text": ["Hello ", "World", "!"] },
+      "result": "Hello World!"
+  },
+  {
+      "rule": {
+          "max": {
+              "map": [{
+                  "filter": [
+                    { "val": "people" }, 
+                    { "===": [{ "val": "department" }, "Engineering"] }
+                ]}, 
+                { "val": "salary" }
+            ]
+          }
+      },
+      "data": {
+          "people": [
+            { "name": "Jay Ortiz", "salary": 100414, "department": "Engineering"},
+            { "name": "Louisa Hall", "salary": 133601, "department": "Sales"},
+            { "name": "Kyle Carlson", "salary": 139803, "department": "Sales"},
+            { "name": "Grace Ortiz", "salary": 147068, "department": "Engineering"},
+            { "name": "Isabelle Harrington", "salary": 112704, "department": "Marketing"},
+            { "name": "Harold Moore", "salary": 125221, "department": "Sales"},
+            { "name": "Clarence Schultz", "salary": 127985, "department": "Sales"},
+            { "name": "Jesse Keller", "salary": 149212, "department": "Engineering"},
+            { "name": "Phillip Holland", "salary": 105888, "department": "Marketing"},
+            { "name": "Mason Sullivan", "salary": 147161, "department": "Engineering" }
+          ]
+      },
+      "result": 149212,
+      "description": "Max with Logic Chaining (Complex)"
+  },
+  {
+    "description": "Addition Chained w/ Merge",
+    "rule": { "+": { "merge": [{ "val": "x" }, { "val": "y" }] }},
+    "result": 6,
+    "data": { "x": [1, 2], "y": 3 }
+  }
+]
diff --git a/test/community/coalesce.json b/test/community/coalesce.json
new file mode 100644
--- /dev/null
+++ b/test/community/coalesce.json
@@ -0,0 +1,93 @@
+[
+    "Test Specification for ??",
+    {
+        "description": "Coalesces a string alone",
+        "rule": { "??": ["hello"] },
+        "data": null,
+        "result": "hello"
+    },
+    {
+        "description": "Coalesces a number alone",
+        "rule": { "??": [1] },
+        "data": null,
+        "result": 1
+    },
+    {
+        "description": "Coalesces a boolean alone",
+        "rule": { "??": [true] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "Coalesces an object from context alone",
+        "rule": { "??": [{ "val": "person" }]},
+        "data": { "person": { "name": "John" } },
+        "result": { "name": "John" }
+    },
+    {
+        "description": "Empty behavior",
+        "rule": { "??": [] },
+        "data": null,
+        "result": null
+    },
+    {
+        "description": "Coalesces a string with nulls before",
+        "rule": { "??": [null, "hello"] },
+        "data": null,
+        "result": "hello"
+    },
+    {
+        "description": "Coalesces a string with multiple nulls before",
+        "rule": { "??": [null, null, null, "hello"] },
+        "data": null,
+        "result": "hello"
+    },
+    {
+        "description": "Coalesces a string with nulls after",
+        "rule": { "??": ["hello", null] },
+        "data": null,
+        "result": "hello"
+    },
+    {
+        "description": "Coalesces a string with nulls both before and after",
+        "rule": { "??": [null, "hello", null] },
+        "data": null,
+        "result": "hello"
+    },
+    {
+        "description": "Coalesces a number with nulls both before and after",
+        "rule": { "??": [null, 1, null] },
+        "data": null,
+        "result": 1
+    },
+    {
+        "description": "Uses the first non-null value",
+        "rule": { "??": [null, 1, "hello"] },
+        "data": null,
+        "result": 1
+    },
+    {
+        "description": "Uses the first non-null value, even if it is false",
+        "rule": { "??": [null, false, "hello"] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "Uses the first non-null value from context",
+        "rule": { "??": [{ "val": ["person", "name"] }, { "val": "name" }, "Unknown Name"] },
+        "data": { "person": { "name": "John" }, "name": "Jane" },
+        "result": "John"
+    },
+    {
+        "description": "Uses the first non-null value from context (with person undefined)",
+        "rule": { "??": [{ "val": ["person", "name"] }, { "val": "name" }, "Unknown Name"] },
+        "data": { "name": "Jane" },
+        "result": "Jane"
+    },
+    {
+        "description": "Uses the first non-null value from context (without any context)",
+        "rule": { "??": [{ "val": ["person", "name"] }, { "val": "name" }, "Unknown Name"] },
+        "data": {},
+        "result": "Unknown Name"
+    }
+]
diff --git a/test/community/comparison/greaterThan.json b/test/community/comparison/greaterThan.json
new file mode 100644
--- /dev/null
+++ b/test/community/comparison/greaterThan.json
@@ -0,0 +1,212 @@
+[
+    {
+        "description": "> with 3 arguments",
+        "rule": { ">": [3, 2, 1] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "> with 3 arguments failing",
+        "rule": { ">": [3, 2, 3] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "> is lazily evaluated",
+        "rule": { ">": [2, 3, { "throw": "Not Lazy" }] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "> with 1 argument",
+        "rule": { ">": [1] },
+        "data": {},
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "> with direct argument",
+        "rule": { ">": 1 },
+        "data": {},
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "> with empty argument",
+        "rule": { ">": [] },
+        "data": {},
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "> with (false, true)",
+        "rule": { ">": [false, true] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "> with (true, false)",
+        "rule": { ">": [true, false] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "> with (false, false)",
+        "rule": { ">": [false, false] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "> with (true, true)",
+        "rule": { ">": [true, true] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "> with (a, b)",
+        "rule": { ">": ["a", "b"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "> with (b, a)",
+        "rule": { ">": ["b", "a"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "> with (a, a)",
+        "rule": { ">": ["a", "a"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "> with (2024-01-01, 2024-01-02)",
+        "rule": { ">": ["2024-01-01", "2024-01-02"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "> with (2024-01-02, 2024-01-01)",
+        "rule": { ">": ["2024-01-02", "2024-01-01"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "> with (2024-01-01, 2024-01-01)",
+        "rule": { ">": ["2024-01-01", "2024-01-01"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "> with (2023n, 2024-01-01s)",
+        "rule": { ">": [2023, "2024-01-01"] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": "> with (5s, 3n)",
+        "rule": { ">": ["5", 3] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "> with (21s, 3n)",
+        "rule": { ">": ["21", 3] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "> with (21n, 3s)",
+        "rule": { ">": [21, "3"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "> with (3s, 21n)",
+        "rule": { ">": ["3", 21] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "> with (3n, 21s)",
+        "rule": { ">": [3, "21"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "> with (3n, 3s)",
+        "rule": { ">": [3, "3"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "> with (3s, 3n)",
+        "rule": { ">": ["3", 3] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "> with (1n, As)",
+        "rule": { ">": [1, "A"] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": "> with (null, 1)",
+        "rule": { ">": [null, 1] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "> with (1, null)",
+        "rule": { ">": [1, null] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "> with (null, 0)",
+        "rule": { ">": [null, 0] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "> with (null, true)",
+        "rule": { ">": [null, true] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "> with (1, true)",
+        "rule": { ">": [1, true] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "> with (0, false)",
+        "rule": { ">": [0, false] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "> with [] and [1]",
+        "rule": { ">": [[], [1]] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": "> with [1] and 5",
+        "rule": { ">": [[1], 5] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": "> with {} and 1",
+        "rule": { ">": [{}, 1] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": "> with 1 and {}",
+        "rule": { ">": [1, {}] },
+        "data": {},
+        "error": { "type": "NaN" }
+    }
+]
diff --git a/test/community/comparison/greaterThanEquals.json b/test/community/comparison/greaterThanEquals.json
new file mode 100644
--- /dev/null
+++ b/test/community/comparison/greaterThanEquals.json
@@ -0,0 +1,170 @@
+[
+    {
+        "description": ">= with 3 arguments",
+        "rule": { ">=": [3, 3, 1] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": ">= with 3 arguments failing",
+        "rule": { ">=": [3, 3, 4] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": ">= is lazily evaluated",
+        "rule": { ">=": [2, 3, { "throw": "Not Lazy" }] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": ">= with 1 argument",
+        "rule": { ">=": [1] },
+        "data": {},
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": ">= with direct argument",
+        "rule": { ">=": 1 },
+        "data": {},
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": ">= with empty argument",
+        "rule": { ">=": [] },
+        "data": {},
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": ">= with (false, true)",
+        "rule": { ">=": [false, true] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": ">= with (true, false)",
+        "rule": { ">=": [true, false] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": ">= with (false, false)",
+        "rule": { ">=": [false, false] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": ">= with (true, true)",
+        "rule": { ">=": [true, true] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": ">= with (a, b)",
+        "rule": { ">=": ["a", "b"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": ">= with (b, a)",
+        "rule": { ">=": ["b", "a"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": ">= with (a, a)",
+        "rule": { ">=": ["a", "a"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": ">= with (2024-01-01, 2024-01-02)",
+        "rule": { ">=": ["2024-01-01", "2024-01-02"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": ">= with (2024-01-02, 2024-01-01)",
+        "rule": { ">=": ["2024-01-02", "2024-01-01"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": ">= with (2024-01-01, 2024-01-01)",
+        "rule": { ">=": ["2024-01-01", "2024-01-01"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": ">= with (1n, As)",
+        "rule": { ">=": [1, "A"] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": ">= with (1n, As)",
+        "rule": { ">=": [1, "A"] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": ">= with (null, 1)",
+        "rule": { ">=": [null, 1] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": ">= with (1, null)",
+        "rule": { ">=": [1, null] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": ">= with (null, 0)",
+        "rule": { ">=": [null, 0] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": ">= with (null, true)",
+        "rule": { ">=": [null, true] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": ">= with (1, true)",
+        "rule": { ">=": [1, true] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": ">= with (0, false)",
+        "rule": { ">=": [0, false] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": ">= with [] and [1]",
+        "rule": { ">=": [[], [1]] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": ">= with [1] and 5",
+        "rule": { ">=": [[1], 5] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": ">= with {} and 1",
+        "rule": { ">=": [{}, 1] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": ">= with 1 and {}",
+        "rule": { ">=": [1, {}] },
+        "data": {},
+        "error": { "type": "NaN" }
+    }
+]
diff --git a/test/community/comparison/lessThan.json b/test/community/comparison/lessThan.json
new file mode 100644
--- /dev/null
+++ b/test/community/comparison/lessThan.json
@@ -0,0 +1,272 @@
+[
+    {
+        "description": "< with 3 arguments",
+        "rule": { "<": [1, 2, 3] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "< is lazily evaluated",
+        "rule": { "<": [3, 2, { "throw": "Not Lazy" }] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "< with 1 argument",
+        "rule": { "<": [1] },
+        "data": {},
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "< with direct argument",
+        "rule": { "<": 1 },
+        "data": {},
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "< with empty argument",
+        "rule": { "<": [] },
+        "data": {},
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "< with (false, true)",
+        "rule": { "<": [false, true] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "< with (true, false)",
+        "rule": { "<": [true, false] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "< with (false, false)",
+        "rule": { "<": [false, false] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "< with (true, true)",
+        "rule": { "<": [true, true] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "< with (a, b)",
+        "rule": { "<": ["a", "b"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "< with (b, a)",
+        "rule": { "<": ["b", "a"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "< with (a, a)",
+        "rule": { "<": ["a", "a"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "< with (2024-01-01, 2024-01-02)",
+        "rule": { "<": ["2024-01-01", "2024-01-02"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "< with (2024-01-02, 2024-01-01)",
+        "rule": { "<": ["2024-01-02", "2024-01-01"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "< with (2024-01-01, 2024-01-01)",
+        "rule": { "<": ["2024-01-01", "2024-01-01"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "< with (2023, 2024-01-01)",
+        "rule": { "<": ["2023", "2024-01-01"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "< with (2023n, 2024-01-01s)",
+        "rule": { "<": [2023, "2024-01-01"] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": "< with (5s, 3n)",
+        "rule": { "<": ["5", 3] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "< with (21s, 3n)",
+        "rule": { "<": ["21", 3] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "< with (21n, 3s)",
+        "rule": { "<": [21, "3"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "< with (3s, 21n)",
+        "rule": { "<": ["3", 21] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "< with (3n, 21s)",
+        "rule": { "<": [3, "21"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "< with (3n, 3s)",
+        "rule": { "<": [3, "3"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "< with (3s, 3n)",
+        "rule": { "<": ["3", 3] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "< with (3n, 3n)",
+        "rule": { "<": [3, 3] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "< with (3s, 3s)",
+        "rule": { "<": ["3", "3"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "< with (3n, 3)",
+        "rule": { "<": [3, 3] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "< with (3, 3n)",
+        "rule": { "<": [3, 3] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "< with (3s, 3)",
+        "rule": { "<": ["3", 3] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "< with (3, 3s)",
+        "rule": { "<": [3, "3"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "< with (3n, 3n)",
+        "rule": { "<": [3, 3] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "< with (3s, 3s)",
+        "rule": { "<": ["3", "3"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "< with (3n, 3s)",
+        "rule": { "<": [3, "3"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "< with (3s, 3n)",
+        "rule": { "<": ["3", 3] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "< with (1n, As)",
+        "rule": { "<": [1, "A"] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": "< with (null, 1)",
+        "rule": { "<": [null, 1] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "< with (1, null)",
+        "rule": { "<": [1, null] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "< with (null, 0)",
+        "rule": { "<": [null, 0] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "< with (null, true)",
+        "rule": { "<": [null, true] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "< with (1, true)",
+        "rule": { "<": [1, true] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "< with (0, false)",
+        "rule": { "<": [0, false] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "< with [] and [1]",
+        "rule": { "<": [[], [1]] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": "< with [1] and 5",
+        "rule": { "<": [[1], 5] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": "< with {} and 1",
+        "rule": { "<": [{}, 1] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": "< with 1 and {}",
+        "rule": { "<": [1, {}] },
+        "data": {},
+        "error": { "type": "NaN" }
+    }
+]
diff --git a/test/community/comparison/lessThanEquals.json b/test/community/comparison/lessThanEquals.json
new file mode 100644
--- /dev/null
+++ b/test/community/comparison/lessThanEquals.json
@@ -0,0 +1,122 @@
+[
+    {
+        "description": "<= is lazily evaluated",
+        "rule": { "<=": [3, 2, { "throw": "Not Lazy" }] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "<= with (2023n, 2024-01-01s)",
+        "rule": { "<=": [2023, "2024-01-01"] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": "<= with (5s, 3n)",
+        "rule": { "<=": ["5", 3] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "<= with (21s, 3n)",
+        "rule": { "<=": ["21", 3] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "<= with (21n, 3s)",
+        "rule": { "<=": [21, "3"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "<= with (3s, 21n)",
+        "rule": { "<=": ["3", 21] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "<= with (3n, 21s)",
+        "rule": { "<=": [3, "21"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "<= with (3n, 3s)",
+        "rule": { "<=": [3, "3"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "<= with (3s, 3n)",
+        "rule": { "<=": ["3", 3] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "<= with (1n, As)",
+        "rule": { "<=": [1, "A"] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": "<= with (null, 1)",
+        "rule": { "<=": [null, 1] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "<= with (1, null)",
+        "rule": { "<=": [1, null] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "<= with (null, 0)",
+        "rule": { "<=": [null, 0] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "<= with (null, true)",
+        "rule": { "<": [null, true] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "<= with (1, true)",
+        "rule": { "<=": [1, true] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "<= with (0, false)",
+        "rule": { "<=": [0, false] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "<= with [] and [1]",
+        "rule": { "<=": [[], [1]] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": "<= with [1] and 5",
+        "rule": { "<=": [[1], 5] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": "<= with {} and 1",
+        "rule": { "<=": [{}, 1] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": "<= with 1 and {}",
+        "rule": { "<=": [1, {}] },
+        "data": {},
+        "error": { "type": "NaN" }
+    }
+]
diff --git a/test/community/comparison/softEquals.json b/test/community/comparison/softEquals.json
new file mode 100644
--- /dev/null
+++ b/test/community/comparison/softEquals.json
@@ -0,0 +1,212 @@
+[
+    {
+        "description": "== with 3 arguments",
+        "rule": { "==": [3, 3, 3] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "== with 3 arguments failing",
+        "rule": { "==": [3, 2, 3] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "== is lazily evaluated",
+        "rule": { "==": [3, 2, { "throw": "Not Lazy" }] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "== with 1 argument",
+        "rule": { "==": [1] },
+        "data": {},
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "== with direct argument",
+        "rule": { "==": 1 },
+        "data": {},
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "== with empty argument",
+        "rule": { "==": [] },
+        "data": {},
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "== with (false, true)",
+        "rule": { "==": [false, true] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "== with (true, false)",
+        "rule": { "==": [true, false] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "== with (false, false)",
+        "rule": { "==": [false, false] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "== with (true, true)",
+        "rule": { "==": [true, true] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "== with (a, b)",
+        "rule": { "==": ["a", "b"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "== with (b, a)",
+        "rule": { "==": ["b", "a"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "== with (a, a)",
+        "rule": { "==": ["a", "a"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "== with (2024-01-01, 2024-01-02)",
+        "rule": { "==": ["2024-01-01", "2024-01-02"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "== with (2024-01-02, 2024-01-01)",
+        "rule": { "==": ["2024-01-02", "2024-01-01"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "== with (2024-01-01, 2024-01-01)",
+        "rule": { "==": ["2024-01-01", "2024-01-01"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "== with (2023n, 2024-01-01s)",
+        "rule": { "==": [2023, "2024-01-01"] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": "== with (5s, 3n)",
+        "rule": { "==": ["5", 3] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "== with (21s, 3n)",
+        "rule": { "==": ["21", 3] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "== with (21n, 3s)",
+        "rule": { "==": [21, "3"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "== with (3s, 21n)",
+        "rule": { "==": ["3", 21] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "== with (3n, 21s)",
+        "rule": { "==": [3, "21"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "== with (3n, 3s)",
+        "rule": { "==": [3, "3"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "== with (3s, 3n)",
+        "rule": { "==": ["3", 3] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "== with (1n, As)",
+        "rule": { "==": [1, "A"] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": "== with (null, 1)",
+        "rule": { "==": [null, 1] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "== with (1, null)",
+        "rule": { "==": [1, null] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "== with (null, 0)",
+        "rule": { "==": [null, 0] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "== with (null, true)",
+        "rule": { "==": [null, true] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "== with (1, true)",
+        "rule": { "==": [1, true] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "== with (0, false)",
+        "rule": { "==": [0, false] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "== with [] and [1]",
+        "rule": { "==": [[], [1]] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": "== with [1] and 5",
+        "rule": { "==": [[1], 5] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": "== with {} and 1",
+        "rule": { "==": [{}, 1] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": "== with 1 and {}",
+        "rule": { "==": [1, {}] },
+        "data": {},
+        "error": { "type": "NaN" }
+    }
+]
diff --git a/test/community/comparison/softNotEquals.json b/test/community/comparison/softNotEquals.json
new file mode 100644
--- /dev/null
+++ b/test/community/comparison/softNotEquals.json
@@ -0,0 +1,206 @@
+[
+    {
+        "description": "!= with 3 arguments",
+        "rule": { "!=": [3, 2, 3] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!= is lazily evaluated",
+        "rule": { "!=": [3, 3, { "throw": "Not Lazy" }] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "!= with 1 argument",
+        "rule": { "!=": [1] },
+        "data": {},
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "!= with direct argument",
+        "rule": { "!=": 1 },
+        "data": {},
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "!= with empty argument",
+        "rule": { "!=": [] },
+        "data": {},
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "!= with (false, true)",
+        "rule": { "!=": [false, true] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!= with (true, false)",
+        "rule": { "!=": [true, false] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!= with (false, false)",
+        "rule": { "!=": [false, false] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "!= with (true, true)",
+        "rule": { "!=": [true, true] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "!= with (a, b)",
+        "rule": { "!=": ["a", "b"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!= with (b, a)",
+        "rule": { "!=": ["b", "a"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!= with (a, a)",
+        "rule": { "!=": ["a", "a"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "!= with (2024-01-01, 2024-01-02)",
+        "rule": { "!=": ["2024-01-01", "2024-01-02"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!= with (2024-01-02, 2024-01-01)",
+        "rule": { "!=": ["2024-01-02", "2024-01-01"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!= with (2024-01-01, 2024-01-01)",
+        "rule": { "!=": ["2024-01-01", "2024-01-01"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "!= with (2023n, 2024-01-01s)",
+        "rule": { "!=": [2023, "2024-01-01"] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": "!= with (5s, 3n)",
+        "rule": { "!=": ["5", 3] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!= with (21s, 3n)",
+        "rule": { "!=": ["21", 3] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!= with (21n, 3s)",
+        "rule": { "!=": [21, "3"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!= with (3s, 21n)",
+        "rule": { "!=": ["3", 21] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!= with (3n, 21s)",
+        "rule": { "!=": [3, "21"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!= with (3n, 3s)",
+        "rule": { "!=": [3, "3"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "!= with (3s, 3n)",
+        "rule": { "!=": ["3", 3] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "!= with (1n, As)",
+        "rule": { "!=": [1, "A"] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": "!= with (null, 1)",
+        "rule": { "!=": [null, 1] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!= with (1, null)",
+        "rule": { "!=": [1, null] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!= with (null, 0)",
+        "rule": { "!=": [null, 0] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "!= with (null, true)",
+        "rule": { "!=": [null, true] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!= with (1, true)",
+        "rule": { "!=": [1, true] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "!= with (0, false)",
+        "rule": { "!=": [0, false] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "!= with [] and [1]",
+        "rule": { "!=": [[], [1]] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": "!= with [1] and 5",
+        "rule": { "!=": [[1], 5] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": "!= with {} and 1",
+        "rule": { "!=": [{}, 1] },
+        "data": {},
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": "!= with 1 and {}",
+        "rule": { "!=": [1, {}] },
+        "data": {},
+        "error": { "type": "NaN" }
+    }
+]
diff --git a/test/community/comparison/strictEquals.json b/test/community/comparison/strictEquals.json
new file mode 100644
--- /dev/null
+++ b/test/community/comparison/strictEquals.json
@@ -0,0 +1,188 @@
+[
+    {
+        "description": "=== with 3 arguments",
+        "rule": { "===": [3, 3, 3] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "=== with 3 arguments failing",
+        "rule": { "===": [3, 3, 2] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "=== is lazily evaluated",
+        "rule": { "===": [3, 2, { "throw": "Not Lazy" }] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "=== with 1 argument",
+        "rule": { "===": [1] },
+        "data": {},
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "=== with direct argument",
+        "rule": { "===": 1 },
+        "data": {},
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "=== with empty argument",
+        "rule": { "===": [] },
+        "data": {},
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "=== with (false, true)",
+        "rule": { "===": [false, true] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "=== with (true, false)",
+        "rule": { "===": [true, false] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "=== with (false, false)",
+        "rule": { "===": [false, false] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "=== with (true, true)",
+        "rule": { "===": [true, true] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "=== with (a, b)",
+        "rule": { "===": ["a", "b"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "=== with (b, a)",
+        "rule": { "===": ["b", "a"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "=== with (a, a)",
+        "rule": { "===": ["a", "a"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "=== with (2024-01-01, 2024-01-02)",
+        "rule": { "===": ["2024-01-01", "2024-01-02"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "=== with (2024-01-02, 2024-01-01)",
+        "rule": { "===": ["2024-01-02", "2024-01-01"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "=== with (2024-01-01, 2024-01-01)",
+        "rule": { "===": ["2024-01-01", "2024-01-01"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "=== with (2023n, 2024-01-01s)",
+        "rule": { "===": [2023, "2024-01-01"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "=== with (5s, 3n)",
+        "rule": { "===": ["5", 3] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "=== with (21s, 3n)",
+        "rule": { "===": ["21", 3] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "=== with (21n, 3s)",
+        "rule": { "===": [21, "3"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "=== with (3s, 21n)",
+        "rule": { "===": ["3", 21] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "=== with (3n, 21s)",
+        "rule": { "===": [3, "21"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "=== with (3n, 3s)",
+        "rule": { "===": [3, "3"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "=== with (3s, 3n)",
+        "rule": { "===": ["3", 3] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "=== with (1n, As)",
+        "rule": { "===": [1, "A"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "=== with (null, 1)",
+        "rule": { "===": [null, 1] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "=== with (1, null)",
+        "rule": { "===": [1, null] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "=== with (null, 0)",
+        "rule": { "===": [null, 0] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "=== with (null, true)",
+        "rule": { "===": [null, true] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "=== with (1, true)",
+        "rule": { "===": [1, true] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "=== with (0, false)",
+        "rule": { "===": [0, false] },
+        "data": {},
+        "result": false
+    }
+]
diff --git a/test/community/comparison/strictNotEquals.json b/test/community/comparison/strictNotEquals.json
new file mode 100644
--- /dev/null
+++ b/test/community/comparison/strictNotEquals.json
@@ -0,0 +1,182 @@
+[
+    {
+        "description": "!== with 3 arguments failing",
+        "rule": { "!==": [3, 3, 3] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "!== is lazily evaluated",
+        "rule": { "!==": [3, 3, { "throw": "Not Lazy" }] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "!== with 1 argument",
+        "rule": { "!==": [1] },
+        "data": {},
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "!== with direct argument",
+        "rule": { "!==": 1 },
+        "data": {},
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "!== with empty argument",
+        "rule": { "!==": [] },
+        "data": {},
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "!== with (false, true)",
+        "rule": { "!==": [false, true] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!== with (true, false)",
+        "rule": { "!==": [true, false] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!== with (false, false)",
+        "rule": { "!==": [false, false] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "!== with (true, true)",
+        "rule": { "!==": [true, true] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "!== with (a, b)",
+        "rule": { "!==": ["a", "b"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!== with (b, a)",
+        "rule": { "!==": ["b", "a"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!== with (a, a)",
+        "rule": { "!==": ["a", "a"] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "!== with (2024-01-01, 2024-01-02)",
+        "rule": { "!==": ["2024-01-01", "2024-01-02"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!== with (2024-01-02, 2024-01-01)",
+        "rule": { "!==": ["2024-01-02", "2024-01-01"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!== with (2024-01-01, 2024-01-01)",
+        "rule": { "!==": ["2024-01-01", "2024-01-01"] },
+        "data": {},
+        "result": false
+    }, 
+    {
+        "description": "!== with (2023n, 2024-01-01s)",
+        "rule": { "!==": [2023, "2024-01-01"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!== with (5s, 3n)",
+        "rule": { "!==": ["5", 3] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!== with (21s, 3n)",
+        "rule": { "!==": ["21", 3] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!== with (21n, 3s)",
+        "rule": { "!==": [21, "3"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!== with (3s, 21n)",
+        "rule": { "!==": ["3", 21] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!== with (3n, 21s)",
+        "rule": { "!==": [3, "21"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!== with (3n, 3s)",
+        "rule": { "!==": [3, "3"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!== with (3s, 3n)",
+        "rule": { "!==": ["3", 3] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!== with (1n, As)",
+        "rule": { "!==": [1, "A"] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!== with (null, 1)",
+        "rule": { "!==": [null, 1] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!== with (1, null)",
+        "rule": { "!==": [1, null] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!== with (null, 0)",
+        "rule": { "!==": [null, 0] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!== with (null, true)",
+        "rule": { "!==": [null, true] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!== with (1, true)",
+        "rule": { "!==": [1, true] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "!== with (0, false)",
+        "rule": { "!==": [0, false] },
+        "data": {},
+        "result": true
+    }
+]
diff --git a/test/community/compatible.json b/test/community/compatible.json
new file mode 100644
--- /dev/null
+++ b/test/community/compatible.json
@@ -0,0 +1,1895 @@
+[
+    "# Non-rules get passed through",
+    {"description": "true", "rule": true, "result": true},
+    {"description": "false", "rule": false, "result": false},
+    {"description": "17", "rule": 17, "result": 17},
+    {"description": "3.14", "rule": 3.14, "result": 3.14},
+    {"description": "\"apple\"", "rule": "apple", "result": "apple"},
+    {"description": "null", "rule": null, "result": null},
+    { "description": "[\"a\",\"b\"]", "rule": ["a", "b"], "result": ["a", "b"] },
+    "# Single operator tests",
+    {
+        "description": "{\"==\":[1,1]}",
+        "rule": { "==": [1, 1] },
+        "result": true
+    },
+    {
+        "description": "{\"==\":[1,\"1\"]}",
+        "rule": { "==": [1, "1"] },
+        "result": true
+    },
+    {
+        "description": "{\"==\":[1,2]}",
+        "rule": { "==": [1, 2] },
+        "result": false
+    },
+    {
+        "description": "{\"===\":[1,1]}",
+        "rule": { "===": [1, 1] },
+        "result": true
+    },
+    {
+        "description": "{\"===\":[1,\"1\"]}",
+        "rule": { "===": [1, "1"] },
+        "result": false
+    },
+    {
+        "description": "{\"===\":[1,2]}",
+        "rule": { "===": [1, 2] },
+        "result": false
+    },
+    {
+        "description": "{\"!=\":[1,2]}",
+        "rule": { "!=": [1, 2] },
+        "result": true
+    },
+    {
+        "description": "{\"!=\":[1,1]}",
+        "rule": { "!=": [1, 1] },
+        "result": false
+    },
+    {
+        "description": "{\"!=\":[1,\"1\"]}",
+        "rule": { "!=": [1, "1"] },
+        "result": false
+    },
+    {
+        "description": "{\"!==\":[1,2]}",
+        "rule": { "!==": [1, 2] },
+        "result": true
+    },
+    {
+        "description": "{\"!==\":[1,1]}",
+        "rule": { "!==": [1, 1] },
+        "result": false
+    },
+    {
+        "description": "{\"!==\":[1,\"1\"]}",
+        "rule": { "!==": [1, "1"] },
+        "result": true
+    },
+    {
+        "description": "{\">\":[2,1]}",
+        "rule": { ">": [2, 1] },
+        "result": true
+    },
+    {
+        "description": "{\">\":[1,1]}",
+        "rule": { ">": [1, 1] },
+        "result": false
+    },
+    {
+        "description": "{\">\":[1,2]}",
+        "rule": { ">": [1, 2] },
+        "result": false
+    },
+    {
+        "description": "{\">\":[\"2\",1]}",
+        "rule": { ">": ["2", 1] },
+        "result": true
+    },
+    {
+        "description": "{\">=\":[2,1]}",
+        "rule": { ">=": [2, 1] },
+        "result": true
+    },
+    {
+        "description": "{\">=\":[1,1]}",
+        "rule": { ">=": [1, 1] },
+        "result": true
+    },
+    {
+        "description": "{\">=\":[1,2]}",
+        "rule": { ">=": [1, 2] },
+        "result": false
+    },
+    {
+        "description": "{\">=\":[\"2\",1]}",
+        "rule": { ">=": ["2", 1] },
+        "result": true
+    },
+    {
+        "description": "{\"<\":[2,1]}",
+        "rule": { "<": [2, 1] },
+        "result": false
+    },
+    {
+        "description": "{\"<\":[1,1]}",
+        "rule": { "<": [1, 1] },
+        "result": false
+    },
+    {
+        "description": "{\"<\":[1,2]}",
+        "rule": { "<": [1, 2] },
+        "result": true
+    },
+    {
+        "description": "{\"<\":[\"1\",2]}",
+        "rule": { "<": ["1", 2] },
+        "result": true
+    },
+    {
+        "description": "{\"<\":[1,2,3]}",
+        "rule": { "<": [1, 2, 3] },
+        "result": true
+    },
+    {
+        "description": "{\"<\":[1,1,3]}",
+        "rule": { "<": [1, 1, 3] },
+        "result": false
+    },
+    {
+        "description": "{\"<\":[1,4,3]}",
+        "rule": { "<": [1, 4, 3] },
+        "result": false
+    },
+    {
+        "description": "{\"<=\":[2,1]}",
+        "rule": { "<=": [2, 1] },
+        "result": false
+    },
+    {
+        "description": "{\"<=\":[1,1]}",
+        "rule": { "<=": [1, 1] },
+        "result": true
+    },
+    {
+        "description": "{\"<=\":[1,2]}",
+        "rule": { "<=": [1, 2] },
+        "result": true
+    },
+    {
+        "description": "{\"<=\":[\"1\",2]}",
+        "rule": { "<=": ["1", 2] },
+        "result": true
+    },
+    {
+        "description": "{\"<=\":[1,2,3]}",
+        "rule": { "<=": [1, 2, 3] },
+        "result": true
+    },
+    {
+        "description": "{\"<=\":[1,4,3]}",
+        "rule": { "<=": [1, 4, 3] },
+        "result": false
+    },
+    {
+        "description": "{\"!\":[false]}",
+        "rule": { "!": [false] },
+        "result": true
+    },
+    { "description": "{\"!\":false}", "rule": {"!": false}, "result": true },
+    {
+        "description": "{\"!\":[true]}",
+        "rule": { "!": [true] },
+        "result": false
+    },
+    { "description": "{\"!\":true}", "rule": {"!": true}, "result": false },
+    { "description": "{\"!\":0}", "rule": {"!": 0}, "result": true },
+    { "description": "{\"!\":1}", "rule": {"!": 1}, "result": false },
+    {
+        "description": "{\"or\":[true,true]}",
+        "rule": { "or": [true, true] },
+        "result": true
+    },
+    {
+        "description": "{\"or\":[false,true]}",
+        "rule": { "or": [false, true] },
+        "result": true
+    },
+    {
+        "description": "{\"or\":[true,false]}",
+        "rule": { "or": [true, false] },
+        "result": true
+    },
+    {
+        "description": "{\"or\":[false,false]}",
+        "rule": { "or": [false, false] },
+        "result": false
+    },
+    {
+        "description": "{\"or\":[false,false,true]}",
+        "rule": { "or": [false, false, true] },
+        "result": true
+    },
+    {
+        "description": "{\"or\":[false,false,false]}",
+        "rule": { "or": [false, false, false] },
+        "result": false
+    },
+    {
+        "description": "{\"or\":[false]}",
+        "rule": { "or": [false] },
+        "result": false
+    },
+    {
+        "description": "{\"or\":[true]}",
+        "rule": { "or": [true] },
+        "result": true
+    },
+    {
+        "description": "{\"or\":[1,3]}",
+        "rule": { "or": [1, 3] },
+        "result": 1
+    },
+    {
+        "description": "{\"or\":[3,false]}",
+        "rule": { "or": [3, false] },
+        "result": 3
+    },
+    {
+        "description": "{\"or\":[false,3]}",
+        "rule": { "or": [false, 3] },
+        "result": 3
+    },
+    {
+        "description": "{\"and\":[true,true]}",
+        "rule": { "and": [true, true] },
+        "result": true
+    },
+    {
+        "description": "{\"and\":[false,true]}",
+        "rule": { "and": [false, true] },
+        "result": false
+    },
+    {
+        "description": "{\"and\":[true,false]}",
+        "rule": { "and": [true, false] },
+        "result": false
+    },
+    {
+        "description": "{\"and\":[false,false]}",
+        "rule": { "and": [false, false] },
+        "result": false
+    },
+    {
+        "description": "{\"and\":[true,true,true]}",
+        "rule": { "and": [true, true, true] },
+        "result": true
+    },
+    {
+        "description": "{\"and\":[true,true,false]}",
+        "rule": { "and": [true, true, false] },
+        "result": false
+    },
+    {
+        "description": "{\"and\":[false]}",
+        "rule": { "and": [false] },
+        "result": false
+    },
+    {
+        "description": "{\"and\":[true]}",
+        "rule": { "and": [true] },
+        "result": true
+    },
+    {
+        "description": "{\"and\":[1,3]}",
+        "rule": { "and": [1, 3] },
+        "result": 3
+    },
+    {
+        "description": "{\"and\":[3,false]}",
+        "rule": { "and": [3, false] },
+        "result": false
+    },
+    {
+        "description": "{\"and\":[false,3]}",
+        "rule": { "and": [false, 3] },
+        "result": false
+    },
+    {
+        "description": "{\"?:\":[true,1,2]}",
+        "rule": { "?:": [true, 1, 2] },
+        "result": 1
+    },
+    {
+        "description": "{\"?:\":[false,1,2]}",
+        "rule": { "?:": [false, 1, 2] },
+        "result": 2
+    },
+    {
+        "description": "{\"in\":[\"Bart\",[\"Bart\",\"Homer\",\"Lisa\",\"Marge\",\"Maggie\"]]}",
+        "rule": {
+            "in": [ "Bart", ["Bart", "Homer", "Lisa", "Marge", "Maggie"] ]
+        },
+        "result": true
+    },
+    {
+        "description": "{\"in\":[\"Milhouse\",[\"Bart\",\"Homer\",\"Lisa\",\"Marge\",\"Maggie\"]]}",
+        "rule": {
+            "in": [ "Milhouse", ["Bart", "Homer", "Lisa", "Marge", "Maggie"] ]
+        },
+        "result": false
+    },
+    {
+        "description": "{\"in\":[\"Spring\",\"Springfield\"]}",
+        "rule": { "in": ["Spring", "Springfield"] },
+        "result": true
+    },
+    {
+        "description": "{\"in\":[\"i\",\"team\"]}",
+        "rule": { "in": ["i", "team"] },
+        "result": false
+    },
+    { "description": "{\"cat\":\"ice\"}", "rule": {"cat": "ice"}, "result": "ice" },
+    {
+        "description": "{\"cat\":[\"ice\"]}",
+        "rule": { "cat": ["ice"] },
+        "result": "ice"
+    },
+    {
+        "description": "{\"cat\":[\"ice\",\"cream\"]}",
+        "rule": { "cat": ["ice", "cream"] },
+        "result": "icecream"
+    },
+    {
+        "description": "{\"cat\":[1,2]}",
+        "rule": { "cat": [1, 2] },
+        "result": "12"
+    },
+    {
+        "description": "{\"cat\":[\"Robocop\",2]}",
+        "rule": { "cat": ["Robocop", 2] },
+        "result": "Robocop2"
+    },
+    {
+        "description": "{\"cat\":[\"we all scream for \",\"ice\",\"cream\"]}",
+        "rule": { "cat": ["we all scream for ", "ice", "cream"] },
+        "result": "we all scream for icecream"
+    },
+    {
+        "description": "{\"%\":[1,2]}",
+        "rule": { "%": [1, 2] },
+        "result": 1
+    },
+    {
+        "description": "{\"%\":[2,2]}",
+        "rule": { "%": [2, 2] },
+        "result": 0
+    },
+    {
+        "description": "{\"%\":[3,2]}",
+        "rule": { "%": [3, 2] },
+        "result": 1
+    },
+    {
+        "description": "{\"max\":[1,2,3]}",
+        "rule": { "max": [1, 2, 3] },
+        "result": 3
+    },
+    {
+        "description": "{\"max\":[1,3,3]}",
+        "rule": { "max": [1, 3, 3] },
+        "result": 3
+    },
+    {
+        "description": "{\"max\":[3,2,1]}",
+        "rule": { "max": [3, 2, 1] },
+        "result": 3
+    },
+    {
+        "description": "{\"max\":[1]}",
+        "rule": { "max": [1] },
+        "result": 1
+    },
+    {
+        "description": "{\"min\":[1,2,3]}",
+        "rule": { "min": [1, 2, 3] },
+        "result": 1
+    },
+    {
+        "description": "{\"min\":[1,1,3]}",
+        "rule": { "min": [1, 1, 3] },
+        "result": 1
+    },
+    {
+        "description": "{\"min\":[3,2,1]}",
+        "rule": { "min": [3, 2, 1] },
+        "result": 1
+    },
+    {
+        "description": "{\"min\":[1]}",
+        "rule": { "min": [1] },
+        "result": 1
+    },
+    {
+        "description": "{\"+\":[1,2]}",
+        "rule": { "+": [1, 2] },
+        "result": 3
+    },
+    {
+        "description": "{\"+\":[2,2,2]}",
+        "rule": { "+": [2, 2, 2] },
+        "result": 6
+    },
+    {
+        "description": "{\"+\":[1]}",
+        "rule": { "+": [1] },
+        "result": 1
+    },
+    {
+        "description": "{\"+\":[\"1\",1]}",
+        "rule": { "+": ["1", 1] },
+        "result": 2
+    },
+    {
+        "description": "{\"*\":[3,2]}",
+        "rule": { "*": [3, 2] },
+        "result": 6
+    },
+    {
+        "description": "{\"*\":[2,2,2]}",
+        "rule": { "*": [2, 2, 2] },
+        "result": 8
+    },
+    {
+        "description": "{\"*\":[1]}",
+        "rule": { "*": [1] },
+        "result": 1
+    },
+    {
+        "description": "{\"*\":[\"1\",1]}",
+        "rule": { "*": ["1", 1] },
+        "result": 1
+    },
+    {
+        "description": "{\"-\":[2,3]}",
+        "rule": { "-": [2, 3] },
+        "result": -1
+    },
+    {
+        "description": "{\"-\":[3,2]}",
+        "rule": { "-": [3, 2] },
+        "result": 1
+    },
+    {
+        "description": "{\"-\":[3]}",
+        "rule": { "-": [3] },
+        "result": -3
+    },
+    {
+        "description": "{\"-\":[\"1\",1]}",
+        "rule": { "-": ["1", 1] },
+        "result": 0
+    },
+    {
+        "description": "{\"/\":[4,2]}",
+        "rule": { "/": [4, 2] },
+        "result": 2
+    },
+    {
+        "description": "{\"/\":[2,4]}",
+        "rule": { "/": [2, 4] },
+        "result": 0.5
+    },
+    {
+        "description": "{\"/\":[\"1\",1]}",
+        "rule": { "/": ["1", 1] },
+        "result": 1
+    },
+    "Substring",
+    {
+        "description": "{\"substr\":[\"jsonlogic\",4]}",
+        "rule": { "substr": ["jsonlogic", 4] },
+        "data": null,
+        "result": "logic"
+    },
+    {
+        "description": "{\"substr\":[\"jsonlogic\",-5]}",
+        "rule": { "substr": ["jsonlogic", -5] },
+        "data": null,
+        "result": "logic"
+    },
+    {
+        "description": "{\"substr\":[\"jsonlogic\",0,1]}",
+        "rule": { "substr": ["jsonlogic", 0, 1] },
+        "data": null,
+        "result": "j"
+    },
+    {
+        "description": "{\"substr\":[\"jsonlogic\",-1,1]}",
+        "rule": { "substr": ["jsonlogic", -1, 1] },
+        "data": null,
+        "result": "c"
+    },
+    {
+        "description": "{\"substr\":[\"jsonlogic\",4,5]}",
+        "rule": { "substr": ["jsonlogic", 4, 5] },
+        "data": null,
+        "result": "logic"
+    },
+    {
+        "description": "{\"substr\":[\"jsonlogic\",-5,5]}",
+        "rule": { "substr": ["jsonlogic", -5, 5] },
+        "data": null,
+        "result": "logic"
+    },
+    {
+        "description": "{\"substr\":[\"jsonlogic\",-5,-2]}",
+        "rule": { "substr": ["jsonlogic", -5, -2] },
+        "data": null,
+        "result": "log"
+    },
+    {
+        "description": "{\"substr\":[\"jsonlogic\",1,-5]}",
+        "rule": { "substr": ["jsonlogic", 1, -5] },
+        "data": null,
+        "result": "son"
+    },
+    "Merge arrays",
+    { "description": "{\"merge\":[]}", "rule": {"merge": []}, "data": null, "result": [] },
+    {
+        "description": "{\"merge\":[[1]]}",
+        "rule": {
+            "merge": [ [1] ]
+        },
+        "data": null,
+        "result": [1]
+    },
+    {
+        "description": "{\"merge\":[[1],[]]}",
+        "rule": {
+            "merge": [ [1], [] ]
+        },
+        "data": null,
+        "result": [1]
+    },
+    {
+        "description": "{\"merge\":[[1],[2]]}",
+        "rule": {
+            "merge": [ [1], [2] ]
+        },
+        "data": null,
+        "result": [1, 2]
+    },
+    {
+        "description": "{\"merge\":[[1],[2],[3]]}",
+        "rule": {
+            "merge": [ [1], [2], [3] ]
+        },
+        "data": null,
+        "result": [1, 2, 3]
+    },
+    {
+        "description": "{\"merge\":[[1,2],[3]]}",
+        "rule": {
+            "merge": [ [1, 2], [3] ]
+        },
+        "data": null,
+        "result": [1, 2, 3]
+    },
+    {
+        "description": "{\"merge\":[[1],[2,3]]}",
+        "rule": {
+            "merge": [ [1], [2, 3] ]
+        },
+        "data": null,
+        "result": [1, 2, 3]
+    },
+    "Given non-array arguments, merge converts them to arrays",
+    { "description": "{\"merge\":1}", "rule": {"merge": 1}, "data": null, "result": [1] },
+    {
+        "description": "{\"merge\":[1,2]}",
+        "rule": { "merge": [1, 2] },
+        "data": null,
+        "result": [1, 2]
+    },
+    {
+        "description": "{\"merge\":[1,[2]]}",
+        "rule": {
+            "merge": [ 1, [2] ]
+        },
+        "data": null,
+        "result": [1, 2]
+    },
+    "Too few args",
+    { "description": "{\"if\":[]}", "rule": {"if": []}, "data": null, "result": null },
+    {
+        "description": "{\"if\":[true]}",
+        "rule": { "if": [true] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "{\"if\":[false]}",
+        "rule": { "if": [false] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "{\"if\":[\"apple\"]}",
+        "rule": { "if": ["apple"] },
+        "data": null,
+        "result": "apple"
+    },
+    "Simple if/then/else cases",
+    {
+        "description": "{\"if\":[true,\"apple\"]}",
+        "rule": { "if": [true, "apple"] },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "{\"if\":[false,\"apple\"]}",
+        "rule": { "if": [false, "apple"] },
+        "data": null,
+        "result": null
+    },
+    {
+        "description": "{\"if\":[true,\"apple\",\"banana\"]}",
+        "rule": { "if": [true, "apple", "banana"] },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "{\"if\":[false,\"apple\",\"banana\"]}",
+        "rule": { "if": [false, "apple", "banana"] },
+        "data": null,
+        "result": "banana"
+    },
+    "Empty arrays are falsey",
+    {
+        "description": "{\"if\":[[],\"apple\",\"banana\"]}",
+        "rule": { "if": [[], "apple", "banana"] },
+        "data": null,
+        "result": "banana"
+    },
+    {
+        "description": "{\"if\":[[1],\"apple\",\"banana\"]}",
+        "rule": {
+            "if": [ [1], "apple", "banana" ]
+        },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "{\"if\":[[1,2,3,4],\"apple\",\"banana\"]}",
+        "rule": {
+            "if": [ [1, 2, 3, 4], "apple", "banana" ]
+        },
+        "data": null,
+        "result": "apple"
+    },
+    "Empty strings are falsey, all other strings are truthy",
+    {
+        "description": "{\"if\":[\"\",\"apple\",\"banana\"]}",
+        "rule": { "if": ["", "apple", "banana"] },
+        "data": null,
+        "result": "banana"
+    },
+    {
+        "description": "{\"if\":[\"zucchini\",\"apple\",\"banana\"]}",
+        "rule": { "if": ["zucchini", "apple", "banana"] },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "{\"if\":[\"0\",\"apple\",\"banana\"]}",
+        "rule": { "if": ["0", "apple", "banana"] },
+        "data": null,
+        "result": "apple"
+    },
+    "You can cast a string to numeric with a unary + ",
+    {
+        "description": "{\"===\":[0,\"0\"]}",
+        "rule": { "===": [0, "0"] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "{\"===\":[0,{\"+\":\"0\"}]}",
+        "rule": {
+            "===": [ 0, {"+": "0"} ]
+        },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "{\"if\":[{\"+\":\"0\"},\"apple\",\"banana\"]}",
+        "rule": {
+            "if": [ {"+": "0"}, "apple", "banana" ]
+        },
+        "data": null,
+        "result": "banana"
+    },
+    {
+        "description": "{\"if\":[{\"+\":\"1\"},\"apple\",\"banana\"]}",
+        "rule": {
+            "if": [ {"+": "1"}, "apple", "banana" ]
+        },
+        "data": null,
+        "result": "apple"
+    },
+    "Zero is falsy, all other numbers are truthy",
+    {
+        "description": "{\"if\":[0,\"apple\",\"banana\"]}",
+        "rule": { "if": [0, "apple", "banana"] },
+        "data": null,
+        "result": "banana"
+    },
+    {
+        "description": "{\"if\":[1,\"apple\",\"banana\"]}",
+        "rule": { "if": [1, "apple", "banana"] },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "{\"if\":[3.1416,\"apple\",\"banana\"]}",
+        "rule": { "if": [3.1416, "apple", "banana"] },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "{\"if\":[-1,\"apple\",\"banana\"]}",
+        "rule": { "if": [-1, "apple", "banana"] },
+        "data": null,
+        "result": "apple"
+    },
+    "Truthy and falsy definitions matter in Boolean operations",
+    {
+        "description": "{\"!\":[[]]}",
+        "rule": { "!": [[]] },
+        "result": true
+    },
+    {
+        "description": "{\"!!\":[[]]}",
+        "rule": { "!!": [[]] },
+        "result": false
+    },
+    {
+        "description": "{\"and\":[[],true]}",
+        "rule": { "and": [[], true] },
+        "result": []
+    },
+    {
+        "description": "{\"or\":[[],true]}",
+        "rule": { "or": [[], true] },
+        "result": true
+    },
+    {
+        "description": "{\"!\":[0]}",
+        "rule": { "!": [0] },
+        "result": true
+    },
+    {
+        "description": "{\"!!\":[0]}",
+        "rule": { "!!": [0] },
+        "result": false
+    },
+    {
+        "description": "{\"and\":[0,true]}",
+        "rule": { "and": [0, true] },
+        "result": 0
+    },
+    {
+        "description": "{\"or\":[0,true]}",
+        "rule": { "or": [0, true] },
+        "result": true
+    },
+    {
+        "description": "{\"!\":[\"\"]}",
+        "rule": { "!": [""] },
+        "result": true
+    },
+    {
+        "description": "{\"!!\":[\"\"]}",
+        "rule": { "!!": [""] },
+        "result": false
+    },
+    {
+        "description": "{\"and\":[\"\",true]}",
+        "rule": { "and": ["", true] },
+        "result": ""
+    },
+    {
+        "description": "{\"or\":[\"\",true]}",
+        "rule": { "or": ["", true] },
+        "result": true
+    },
+    {
+        "description": "{\"!\":[\"0\"]}",
+        "rule": { "!": ["0"] },
+        "result": false
+    },
+    {
+        "description": "{\"!!\":[\"0\"]}",
+        "rule": { "!!": ["0"] },
+        "result": true
+    },
+    {
+        "description": "{\"and\":[\"0\",true]}",
+        "rule": { "and": ["0", true] },
+        "result": true
+    },
+    {
+        "description": "{\"or\":[\"0\",true]}",
+        "rule": { "or": ["0", true] },
+        "result": "0"
+    },
+    "If the conditional is logic, it gets evaluated",
+    {
+        "description": "{\"if\":[{\">\":[2,1]},\"apple\",\"banana\"]}",
+        "rule": {
+            "if": [
+                { ">": [2, 1] },
+                "apple",
+                "banana"
+            ]
+        },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "{\"if\":[{\">\":[1,2]},\"apple\",\"banana\"]}",
+        "rule": {
+            "if": [
+                { ">": [1, 2] },
+                "apple",
+                "banana"
+            ]
+        },
+        "data": null,
+        "result": "banana"
+    },
+    "If the consequents are logic, they get evaluated",
+    {
+        "description": "{\"if\":[true,{\"cat\":[\"ap\",\"ple\"]},{\"cat\":[\"ba\",\"na\",\"na\"]}]}",
+        "rule": {
+            "if": [
+                true,
+                { "cat": ["ap", "ple"] },
+                { "cat": ["ba", "na", "na"] }
+            ]
+        },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "{\"if\":[false,{\"cat\":[\"ap\",\"ple\"]},{\"cat\":[\"ba\",\"na\",\"na\"]}]}",
+        "rule": {
+            "if": [
+                false,
+                { "cat": ["ap", "ple"] },
+                { "cat": ["ba", "na", "na"] }
+            ]
+        },
+        "data": null,
+        "result": "banana"
+    },
+    "If/then/elseif/then cases",
+    {
+        "description": "{\"if\":[true,\"apple\",true,\"banana\"]}",
+        "rule": { "if": [true, "apple", true, "banana"] },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "{\"if\":[true,\"apple\",false,\"banana\"]}",
+        "rule": { "if": [true, "apple", false, "banana"] },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "{\"if\":[false,\"apple\",true,\"banana\"]}",
+        "rule": { "if": [false, "apple", true, "banana"] },
+        "data": null,
+        "result": "banana"
+    },
+    {
+        "description": "{\"if\":[false,\"apple\",false,\"banana\"]}",
+        "rule": { "if": [false, "apple", false, "banana"] },
+        "data": null,
+        "result": null
+    },
+    {
+        "description": "{\"if\":[true,\"apple\",true,\"banana\",\"carrot\"]}",
+        "rule": { "if": [true, "apple", true, "banana", "carrot"] },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "{\"if\":[true,\"apple\",false,\"banana\",\"carrot\"]}",
+        "rule": { "if": [true, "apple", false, "banana", "carrot"] },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "{\"if\":[false,\"apple\",true,\"banana\",\"carrot\"]}",
+        "rule": { "if": [false, "apple", true, "banana", "carrot"] },
+        "data": null,
+        "result": "banana"
+    },
+    {
+        "description": "{\"if\":[false,\"apple\",false,\"banana\",\"carrot\"]}",
+        "rule": { "if": [false, "apple", false, "banana", "carrot"] },
+        "data": null,
+        "result": "carrot"
+    },
+    {
+        "description": "{\"if\":[false,\"apple\",false,\"banana\",false,\"carrot\"]}",
+        "rule": { "if": [false, "apple", false, "banana", false, "carrot"] },
+        "data": null,
+        "result": null
+    },
+    {
+        "description": "{\"if\":[false,\"apple\",false,\"banana\",false,\"carrot\",\"date\"]}",
+        "rule": { "if": [false, "apple", false, "banana", false, "carrot", "date"] },
+        "data": null,
+        "result": "date"
+    },
+    {
+        "description": "{\"if\":[false,\"apple\",false,\"banana\",true,\"carrot\",\"date\"]}",
+        "rule": { "if": [false, "apple", false, "banana", true, "carrot", "date"] },
+        "data": null,
+        "result": "carrot"
+    },
+    {
+        "description": "{\"if\":[false,\"apple\",true,\"banana\",false,\"carrot\",\"date\"]}",
+        "rule": { "if": [false, "apple", true, "banana", false, "carrot", "date"] },
+        "data": null,
+        "result": "banana"
+    },
+    {
+        "description": "{\"if\":[false,\"apple\",true,\"banana\",true,\"carrot\",\"date\"]}",
+        "rule": { "if": [false, "apple", true, "banana", true, "carrot", "date"] },
+        "data": null,
+        "result": "banana"
+    },
+    {
+        "description": "{\"if\":[true,\"apple\",false,\"banana\",false,\"carrot\",\"date\"]}",
+        "rule": { "if": [true, "apple", false, "banana", false, "carrot", "date"] },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "{\"if\":[true,\"apple\",false,\"banana\",true,\"carrot\",\"date\"]}",
+        "rule": { "if": [true, "apple", false, "banana", true, "carrot", "date"] },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "{\"if\":[true,\"apple\",true,\"banana\",false,\"carrot\",\"date\"]}",
+        "rule": { "if": [true, "apple", true, "banana", false, "carrot", "date"] },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "{\"if\":[true,\"apple\",true,\"banana\",true,\"carrot\",\"date\"]}",
+        "rule": { "if": [true, "apple", true, "banana", true, "carrot", "date"] },
+        "data": null,
+        "result": "apple"
+    },
+    "Arrays with logic",
+    {
+        "description": "[1,{\"var\":\"x\"},3]",
+        "rule": [ 1, {"var": "x"}, 3 ],
+        "data": {"x": 2},
+        "result": [1, 2, 3]
+    },
+    {
+        "description": "{\"if\":[{\"var\":\"x\"},[{\"var\":\"y\"}],99]}",
+        "rule": {
+            "if": [
+                {"var": "x"},
+                [ {"var": "y"} ],
+                99
+            ]
+        },
+        "data": {"x": true, "y": 42},
+        "result": [42]
+    },
+    "# Compound Tests",
+    {
+        "description": "{\"and\":[{\">\":[3,1]},true]}",
+        "rule": {
+            "and": [
+                { ">": [3, 1] },
+                true
+            ]
+        },
+        "result": true
+    },
+    {
+        "description": "{\"and\":[{\">\":[3,1]},false]}",
+        "rule": {
+            "and": [
+                { ">": [3, 1] },
+                false
+            ]
+        },
+        "result": false
+    },
+    {
+        "description": "{\"and\":[{\">\":[3,1]},{\"!\":true}]}",
+        "rule": {
+            "and": [
+                { ">": [3, 1]            },
+                {              "!": true }
+            ]
+        },
+        "result": false
+    },
+    {
+        "description": "{\"and\":[{\">\":[3,1]},{\"<\":[1,3]}]}",
+        "rule": {
+            "and": [
+                { ">": [3, 1]              },
+                {              "<": [1, 3] }
+            ]
+        },
+        "result": true
+    },
+    {
+        "description": "{\"?:\":[{\">\":[3,1]},\"visible\",\"hidden\"]}",
+        "rule": {
+            "?:": [
+                { ">": [3, 1] },
+                "visible",
+                "hidden"
+            ]
+        },
+        "result": "visible"
+    },
+    "# Data-Driven",
+    {
+        "description": "{\"var\":[\"a\"]}",
+        "rule": { "var": ["a"] },
+        "data": {"a": 1},
+        "result": 1
+    },
+    {
+        "description": "{\"var\":[\"b\"]}",
+        "rule": { "var": ["b"] },
+        "data": {"a": 1},
+        "result": null
+    },
+    {
+        "description": "{\"var\":[\"a\"]}",
+        "rule": { "var": ["a"] },
+        "data": null,
+        "result": null
+    },
+    { "description": "{\"var\":\"a\"}", "rule": {"var": "a"}, "data": {"a": 1}, "result": 1 },
+    { "description": "{\"var\":\"b\"}", "rule": {"var": "b"}, "data": {"a": 1}, "result": null },
+    { "description": "{\"var\":\"a\"}", "rule": {"var": "a"}, "data": null, "result": null },
+    {
+        "description": "{\"var\":[\"a\",1]}",
+        "rule": { "var": ["a", 1] },
+        "data": null,
+        "result": 1
+    },
+    {
+        "description": "{\"var\":[\"b\",2]}",
+        "rule": { "var": ["b", 2] },
+        "data": {"a": 1},
+        "result": 2
+    },
+    {
+        "description": "{\"var\":\"a.b\"}",
+        "rule": {"var": "a.b"},
+        "data": { "a": {"b": "c"} },
+        "result": "c"
+    },
+    {
+        "description": "{\"var\":\"a.q\"}",
+        "rule": {"var": "a.q"},
+        "data": { "a": {"b": "c"} },
+        "result": null
+    },
+    {
+        "description": "{\"var\":[\"a.q\",9]}",
+        "rule": { "var": ["a.q", 9] },
+        "data": { "a": {"b": "c"} },
+        "result": 9
+    },
+    {
+        "description": "{\"var\":1}",
+        "rule": {"var": 1},
+        "data": ["apple", "banana"],
+        "result": "banana"
+    },
+    {
+        "description": "{\"var\":\"1\"}",
+        "rule": {"var": "1"},
+        "data": ["apple", "banana"],
+        "result": "banana"
+    },
+    {
+        "description": "{\"var\":\"1.1\"}",
+        "rule": {"var": "1.1"},
+        "data": [ "apple", ["banana", "beer"] ],
+        "result": "beer"
+    },
+    {
+        "description": "{\"and\":[{\"<\":[{\"var\":\"temp\"},110]},{\"==\":[{\"var\":\"pie.filling\"},\"apple\"]}]}",
+        "rule": {
+            "and": [
+                {
+                    "<": [ {"var": "temp"}, 110 ]
+                },
+                {
+                    "==": [ {"var": "pie.filling"}, "apple" ]
+                }
+            ]
+        },
+        "data": { "temp": 100, "pie": {"filling": "apple"} },
+        "result": true
+    },
+    {
+        "description": "{\"var\":[{\"?:\":[{\"<\":[{\"var\":\"temp\"},110]},\"pie.filling\",\"pie.eta\"]}]}",
+        "rule": {
+            "var": [
+                {
+                    "?:": [
+                        {
+                            "<": [ {"var": "temp"}, 110 ]
+                        },
+                        "pie.filling",
+                        "pie.eta"
+                    ]
+                }
+            ]
+        },
+        "data": { "temp": 100, "pie": {"filling": "apple", "eta": "60s"} },
+        "result": "apple"
+    },
+    {
+        "description": "{\"in\":[{\"var\":\"filling\"},[\"apple\",\"cherry\"]]}",
+        "rule": {
+            "in": [ {"var": "filling"}, ["apple", "cherry"] ]
+        },
+        "data": {"filling": "apple"},
+        "result": true
+    },
+    {
+        "description": "{\"var\":\"a.b.c\"}",
+        "rule": {"var": "a.b.c"},
+        "data": null,
+        "result": null
+    },
+    {
+        "description": "{\"var\":\"a.b.c\"}",
+        "rule": {"var": "a.b.c"},
+        "data": {"a": null},
+        "result": null
+    },
+    {
+        "description": "{\"var\":\"a.b.c\"}",
+        "rule": {"var": "a.b.c"},
+        "data": { "a": {"b": null} },
+        "result": null
+    },
+    { "description": "{\"var\":\"\"}", "rule": {"var": ""}, "data": 1, "result": 1 },
+    { "description": "{\"var\":null}", "rule": {"var": null}, "data": 1, "result": 1 },
+    { "description": "{\"var\":[]}", "rule": {"var": []}, "data": 1, "result": 1 },
+    "Missing",
+    { "description": "{\"missing\":[]}", "rule": {"missing": []}, "data": null, "result": [] },
+    {
+        "description": "{\"missing\":[\"a\"]}",
+        "rule": { "missing": ["a"] },
+        "data": null,
+        "result": ["a"]
+    },
+    {
+        "description": "{\"missing\":\"a\"}",
+        "rule": {"missing": "a"},
+        "data": null,
+        "result": ["a"]
+    },
+    {
+        "description": "{\"missing\":\"a\"}",
+        "rule": {"missing": "a"},
+        "data": {"a": "apple"},
+        "result": []
+    },
+    {
+        "description": "{\"missing\":[\"a\"]}",
+        "rule": { "missing": ["a"] },
+        "data": {"a": "apple"},
+        "result": []
+    },
+    {
+        "description": "{\"missing\":[\"a\",\"b\"]}",
+        "rule": { "missing": ["a", "b"] },
+        "data": {"a": "apple"},
+        "result": ["b"]
+    },
+    {
+        "description": "{\"missing\":[\"a\",\"b\"]}",
+        "rule": { "missing": ["a", "b"] },
+        "data": {"b": "banana"},
+        "result": ["a"]
+    },
+    {
+        "description": "{\"missing\":[\"a\",\"b\"]}",
+        "rule": { "missing": ["a", "b"] },
+        "data": {"a": "apple", "b": "banana"},
+        "result": []
+    },
+    {
+        "description": "{\"missing\":[\"a\",\"b\"]}",
+        "rule": { "missing": ["a", "b"] },
+        "result": ["a", "b"]
+    },
+    {
+        "description": "{\"missing\":[\"a\",\"b\"]}",
+        "rule": { "missing": ["a", "b"] },
+        "data": null,
+        "result": ["a", "b"]
+    },
+    {
+        "description": "{\"missing\":[\"a.b\"]}",
+        "rule": { "missing": ["a.b"] },
+        "data": null,
+        "result": ["a.b"]
+    },
+    {
+        "description": "{\"missing\":[\"a.b\"]}",
+        "rule": { "missing": ["a.b"] },
+        "data": {"a": "apple"},
+        "result": ["a.b"]
+    },
+    {
+        "description": "{\"missing\":[\"a.b\"]}",
+        "rule": { "missing": ["a.b"] },
+        "data": { "a": {"c": "apple cake"} },
+        "result": ["a.b"]
+    },
+    {
+        "description": "{\"missing\":[\"a.b\"]}",
+        "rule": { "missing": ["a.b"] },
+        "data": { "a": {"b": "apple brownie"} },
+        "result": []
+    },
+    {
+        "description": "{\"missing\":[\"a.b\",\"a.c\"]}",
+        "rule": { "missing": ["a.b", "a.c"] },
+        "data": { "a": {"b": "apple brownie"} },
+        "result": ["a.c"]
+    },
+    "Missing some",
+    {
+        "description": "{\"missing_some\":[1,[\"a\",\"b\"]]}",
+        "rule": {
+            "missing_some": [ 1, ["a", "b"] ]
+        },
+        "data": {"a": "apple"},
+        "result": []
+    },
+    {
+        "description": "{\"missing_some\":[1,[\"a\",\"b\"]]}",
+        "rule": {
+            "missing_some": [ 1, ["a", "b"] ]
+        },
+        "data": {"b": "banana"},
+        "result": []
+    },
+    {
+        "description": "{\"missing_some\":[1,[\"a\",\"b\"]]}",
+        "rule": {
+            "missing_some": [ 1, ["a", "b"] ]
+        },
+        "data": {"a": "apple", "b": "banana"},
+        "result": []
+    },
+    {
+        "description": "{\"missing_some\":[1,[\"a\",\"b\"]]}",
+        "rule": {
+            "missing_some": [ 1, ["a", "b"] ]
+        },
+        "data": {"c": "carrot"},
+        "result": ["a", "b"]
+    },
+    {
+        "description": "{\"missing_some\":[2,[\"a\",\"b\",\"c\"]]}",
+        "rule": {
+            "missing_some": [ 2, ["a", "b", "c"] ]
+        },
+        "data": {"a": "apple", "b": "banana"},
+        "result": []
+    },
+    {
+        "description": "{\"missing_some\":[2,[\"a\",\"b\",\"c\"]]}",
+        "rule": {
+            "missing_some": [ 2, ["a", "b", "c"] ]
+        },
+        "data": {"a": "apple", "c": "carrot"},
+        "result": []
+    },
+    {
+        "description": "{\"missing_some\":[2,[\"a\",\"b\",\"c\"]]}",
+        "rule": {
+            "missing_some": [ 2, ["a", "b", "c"] ]
+        },
+        "data": {"a": "apple", "b": "banana", "c": "carrot"},
+        "result": []
+    },
+    {
+        "description": "{\"missing_some\":[2,[\"a\",\"b\",\"c\"]]}",
+        "rule": {
+            "missing_some": [ 2, ["a", "b", "c"] ]
+        },
+        "data": {"a": "apple", "d": "durian"},
+        "result": ["b", "c"]
+    },
+    {
+        "description": "{\"missing_some\":[2,[\"a\",\"b\",\"c\"]]}",
+        "rule": {
+            "missing_some": [ 2, ["a", "b", "c"] ]
+        },
+        "data": {"d": "durian", "e": "eggplant"},
+        "result": ["a", "b", "c"]
+    },
+    "Missing and If are friends, because empty arrays are falsey in JsonLogic",
+    {
+        "description": "{\"if\":[{\"missing\":\"a\"},\"missed it\",\"found it\"]}",
+        "rule": {
+            "if": [ {"missing": "a"}, "missed it", "found it" ]
+        },
+        "data": {"a": "apple"},
+        "result": "found it"
+    },
+    {
+        "description": "{\"if\":[{\"missing\":\"a\"},\"missed it\",\"found it\"]}",
+        "rule": {
+            "if": [ {"missing": "a"}, "missed it", "found it" ]
+        },
+        "data": {"b": "banana"},
+        "result": "missed it"
+    },
+    "Missing, Merge, and If are friends. VIN is always required, APR is only required if financing is true.",
+    {
+        "description": "{\"missing\":{\"merge\":[\"vin\",{\"if\":[{\"var\":\"financing\"},[\"apr\"],[]]}]}}",
+        "rule": {
+            "missing": {
+                "merge": [
+                    "vin",
+                    {
+                        "if": [ {"var": "financing"}, ["apr"], [] ]
+                    }
+                ]
+            }
+        },
+        "data": {"financing": true},
+        "result": ["vin", "apr"]
+    },
+    {
+        "description": "{\"missing\":{\"merge\":[\"vin\",{\"if\":[{\"var\":\"financing\"},[\"apr\"],[]]}]}}",
+        "rule": {
+            "missing": {
+                "merge": [
+                    "vin",
+                    {
+                        "if": [ {"var": "financing"}, ["apr"], [] ]
+                    }
+                ]
+            }
+        },
+        "data": {"financing": false},
+        "result": ["vin"]
+    },
+    "Filter, map, all, none, and some",
+    {
+        "description": "{\"filter\":[{\"var\":\"integers\"},true]}",
+        "rule": {
+            "filter": [ {"var": "integers"}, true ]
+        },
+        "data": { "integers": [1, 2, 3] },
+        "result": [1, 2, 3]
+    },
+    {
+        "description": "{\"filter\":[{\"var\":\"integers\"},false]}",
+        "rule": {
+            "filter": [ {"var": "integers"}, false ]
+        },
+        "data": { "integers": [1, 2, 3] },
+        "result": []
+    },
+    {
+        "description": "{\"filter\":[{\"var\":\"integers\"},{\">=\":[{\"var\":\"\"},2]}]}",
+        "rule": {
+            "filter": [
+                {"var": "integers"},
+                {
+                    ">=": [ {"var": ""}, 2 ]
+                }
+            ]
+        },
+        "data": { "integers": [1, 2, 3] },
+        "result": [2, 3]
+    },
+    {
+        "description": "{\"filter\":[{\"var\":\"integers\"},{\"%\":[{\"var\":\"\"},2]}]}",
+        "rule": {
+            "filter": [
+                {"var": "integers"},
+                {
+                    "%": [ {"var": ""}, 2 ]
+                }
+            ]
+        },
+        "data": { "integers": [1, 2, 3] },
+        "result": [1, 3]
+    },
+    {
+        "description": "{\"map\":[{\"var\":\"integers\"},{\"*\":[{\"var\":\"\"},2]}]}",
+        "rule": {
+            "map": [
+                {"var": "integers"},
+                {
+                    "*": [ {"var": ""}, 2 ]
+                }
+            ]
+        },
+        "data": { "integers": [1, 2, 3] },
+        "result": [2, 4, 6]
+    },
+    {
+        "description": "{\"map\":[{\"var\":\"integers\"},{\"*\":[{\"var\":\"\"},2]}]}",
+        "rule": {
+            "map": [
+                {"var": "integers"},
+                {
+                    "*": [ {"var": ""}, 2 ]
+                }
+            ]
+        },
+        "data": null,
+        "result": []
+    },
+    {
+        "description": "{\"map\":[{\"var\":\"desserts\"},{\"var\":\"qty\"}]}",
+        "rule": {
+            "map": [ {"var": "desserts"}, {"var": "qty"} ]
+        },
+        "data": {
+            "desserts": [
+                {"name": "apple"  , "qty": 1},
+                {"name": "brownie", "qty": 2},
+                {"name": "cupcake", "qty": 3}
+            ]
+        },
+        "result": [1, 2, 3]
+    },
+    {
+        "description": "{\"reduce\":[{\"var\":\"integers\"},{\"+\":[{\"var\":\"current\"},{\"var\":\"accumulator\"}]},0]}",
+        "rule": {
+            "reduce": [
+                {"var": "integers"},
+                {
+                    "+": [ {"var": "current"}, {"var": "accumulator"} ]
+                },
+                0
+            ]
+        },
+        "data": { "integers": [1, 2, 3, 4] },
+        "result": 10
+    },
+    {
+        "description": "{\"reduce\":[{\"var\":\"integers\"},{\"+\":[{\"var\":\"current\"},{\"var\":\"accumulator\"}]},{\"var\":\"start_with\"}]}",
+        "rule": {
+            "reduce": [
+                {"var": "integers"},
+                {
+                    "+": [ {"var": "current"}, {"var": "accumulator"} ]
+                },
+                {"var": "start_with"}
+            ]
+        },
+        "data": { "integers": [1, 2, 3, 4], "start_with": 59 },
+        "result": 69
+    },
+    {
+        "description": "{\"reduce\":[{\"var\":\"integers\"},{\"+\":[{\"var\":\"current\"},{\"var\":\"accumulator\"}]},0]}",
+        "rule": {
+            "reduce": [
+                {"var": "integers"},
+                {
+                    "+": [ {"var": "current"}, {"var": "accumulator"} ]
+                },
+                0
+            ]
+        },
+        "data": null,
+        "result": 0
+    },
+    {
+        "description": "{\"reduce\":[{\"var\":\"integers\"},{\"*\":[{\"var\":\"current\"},{\"var\":\"accumulator\"}]},1]}",
+        "rule": {
+            "reduce": [
+                {"var": "integers"},
+                {
+                    "*": [ {"var": "current"}, {"var": "accumulator"} ]
+                },
+                1
+            ]
+        },
+        "data": { "integers": [1, 2, 3, 4] },
+        "result": 24
+    },
+    {
+        "description": "{\"reduce\":[{\"var\":\"integers\"},{\"*\":[{\"var\":\"current\"},{\"var\":\"accumulator\"}]},0]}",
+        "rule": {
+            "reduce": [
+                {"var": "integers"},
+                {
+                    "*": [ {"var": "current"}, {"var": "accumulator"} ]
+                },
+                0
+            ]
+        },
+        "data": { "integers": [1, 2, 3, 4] },
+        "result": 0
+    },
+    {
+        "description": "{\"reduce\":[{\"var\":\"desserts\"},{\"+\":[{\"var\":\"accumulator\"},{\"var\":\"current.qty\"}]},0]}",
+        "rule": {
+            "reduce": [
+                {"var": "desserts"},
+                {
+                    "+": [ {"var": "accumulator"}, {"var": "current.qty"} ]
+                },
+                0
+            ]
+        },
+        "data": {
+            "desserts": [
+                {"name": "apple"  , "qty": 1},
+                {"name": "brownie", "qty": 2},
+                {"name": "cupcake", "qty": 3}
+            ]
+        },
+        "result": 6
+    },
+    {
+        "description": "{\"all\":[{\"var\":\"integers\"},{\">=\":[{\"var\":\"\"},1]}]}",
+        "rule": {
+            "all": [
+                {"var": "integers"},
+                {
+                    ">=": [ {"var": ""}, 1 ]
+                }
+            ]
+        },
+        "data": { "integers": [1, 2, 3] },
+        "result": true
+    },
+    {
+        "description": "{\"all\":[{\"var\":\"integers\"},{\"==\":[{\"var\":\"\"},1]}]}",
+        "rule": {
+            "all": [
+                {"var": "integers"},
+                {
+                    "==": [ {"var": ""}, 1 ]
+                }
+            ]
+        },
+        "data": { "integers": [1, 2, 3] },
+        "result": false
+    },
+    {
+        "description": "{\"all\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}",
+        "rule": {
+            "all": [
+                {"var": "integers"},
+                {
+                    "<": [ {"var": ""}, 1 ]
+                }
+            ]
+        },
+        "data": { "integers": [1, 2, 3] },
+        "result": false
+    },
+    {
+        "description": "{\"all\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}",
+        "rule": {
+            "all": [
+                {"var": "integers"},
+                {
+                    "<": [ {"var": ""}, 1 ]
+                }
+            ]
+        },
+        "data": {"integers": []},
+        "result": false
+    },
+    {
+        "description": "{\"all\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}",
+        "rule": {
+            "all": [
+                {"var": "items"},
+                {
+                    ">=": [ {"var": "qty"}, 1 ]
+                }
+            ]
+        },
+        "data": {
+            "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]
+        },
+        "result": true
+    },
+    {
+        "description": "{\"all\":[{\"var\":\"items\"},{\">\":[{\"var\":\"qty\"},1]}]}",
+        "rule": {
+            "all": [
+                {"var": "items"},
+                {
+                    ">": [ {"var": "qty"}, 1 ]
+                }
+            ]
+        },
+        "data": {
+            "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]
+        },
+        "result": false
+    },
+    {
+        "description": "{\"all\":[{\"var\":\"items\"},{\"<\":[{\"var\":\"qty\"},1]}]}",
+        "rule": {
+            "all": [
+                {"var": "items"},
+                {
+                    "<": [ {"var": "qty"}, 1 ]
+                }
+            ]
+        },
+        "data": {
+            "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]
+        },
+        "result": false
+    },
+    {
+        "description": "{\"all\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}",
+        "rule": {
+            "all": [
+                {"var": "items"},
+                {
+                    ">=": [ {"var": "qty"}, 1 ]
+                }
+            ]
+        },
+        "data": {"items": []},
+        "result": false
+    },
+    {
+        "description": "{\"none\":[{\"var\":\"integers\"},{\">=\":[{\"var\":\"\"},1]}]}",
+        "rule": {
+            "none": [
+                {"var": "integers"},
+                {
+                    ">=": [ {"var": ""}, 1 ]
+                }
+            ]
+        },
+        "data": { "integers": [1, 2, 3] },
+        "result": false
+    },
+    {
+        "description": "{\"none\":[{\"var\":\"integers\"},{\"==\":[{\"var\":\"\"},1]}]}",
+        "rule": {
+            "none": [
+                {"var": "integers"},
+                {
+                    "==": [ {"var": ""}, 1 ]
+                }
+            ]
+        },
+        "data": { "integers": [1, 2, 3] },
+        "result": false
+    },
+    {
+        "description": "{\"none\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}",
+        "rule": {
+            "none": [
+                {"var": "integers"},
+                {
+                    "<": [ {"var": ""}, 1 ]
+                }
+            ]
+        },
+        "data": { "integers": [1, 2, 3] },
+        "result": true
+    },
+    {
+        "description": "{\"none\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}",
+        "rule": {
+            "none": [
+                {"var": "integers"},
+                {
+                    "<": [ {"var": ""}, 1 ]
+                }
+            ]
+        },
+        "data": {"integers": []},
+        "result": true
+    },
+    {
+        "description": "{\"none\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}",
+        "rule": {
+            "none": [
+                {"var": "items"},
+                {
+                    ">=": [ {"var": "qty"}, 1 ]
+                }
+            ]
+        },
+        "data": {
+            "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]
+        },
+        "result": false
+    },
+    {
+        "description": "{\"none\":[{\"var\":\"items\"},{\">\":[{\"var\":\"qty\"},1]}]}",
+        "rule": {
+            "none": [
+                {"var": "items"},
+                {
+                    ">": [ {"var": "qty"}, 1 ]
+                }
+            ]
+        },
+        "data": {
+            "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]
+        },
+        "result": false
+    },
+    {
+        "description": "{\"none\":[{\"var\":\"items\"},{\"<\":[{\"var\":\"qty\"},1]}]}",
+        "rule": {
+            "none": [
+                {"var": "items"},
+                {
+                    "<": [ {"var": "qty"}, 1 ]
+                }
+            ]
+        },
+        "data": {
+            "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]
+        },
+        "result": true
+    },
+    {
+        "description": "{\"none\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}",
+        "rule": {
+            "none": [
+                {"var": "items"},
+                {
+                    ">=": [ {"var": "qty"}, 1 ]
+                }
+            ]
+        },
+        "data": {"items": []},
+        "result": true
+    },
+    {
+        "description": "{\"some\":[{\"var\":\"integers\"},{\">=\":[{\"var\":\"\"},1]}]}",
+        "rule": {
+            "some": [
+                {"var": "integers"},
+                {
+                    ">=": [ {"var": ""}, 1 ]
+                }
+            ]
+        },
+        "data": { "integers": [1, 2, 3] },
+        "result": true
+    },
+    {
+        "description": "{\"some\":[{\"var\":\"integers\"},{\"==\":[{\"var\":\"\"},1]}]}",
+        "rule": {
+            "some": [
+                {"var": "integers"},
+                {
+                    "==": [ {"var": ""}, 1 ]
+                }
+            ]
+        },
+        "data": { "integers": [1, 2, 3] },
+        "result": true
+    },
+    {
+        "description": "{\"some\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}",
+        "rule": {
+            "some": [
+                {"var": "integers"},
+                {
+                    "<": [ {"var": ""}, 1 ]
+                }
+            ]
+        },
+        "data": { "integers": [1, 2, 3] },
+        "result": false
+    },
+    {
+        "description": "{\"some\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}",
+        "rule": {
+            "some": [
+                {"var": "integers"},
+                {
+                    "<": [ {"var": ""}, 1 ]
+                }
+            ]
+        },
+        "data": {"integers": []},
+        "result": false
+    },
+    {
+        "description": "{\"some\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}",
+        "rule": {
+            "some": [
+                {"var": "items"},
+                {
+                    ">=": [ {"var": "qty"}, 1 ]
+                }
+            ]
+        },
+        "data": {
+            "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]
+        },
+        "result": true
+    },
+    {
+        "description": "{\"some\":[{\"var\":\"items\"},{\">\":[{\"var\":\"qty\"},1]}]}",
+        "rule": {
+            "some": [
+                {"var": "items"},
+                {
+                    ">": [ {"var": "qty"}, 1 ]
+                }
+            ]
+        },
+        "data": {
+            "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]
+        },
+        "result": true
+    },
+    {
+        "description": "{\"some\":[{\"var\":\"items\"},{\"<\":[{\"var\":\"qty\"},1]}]}",
+        "rule": {
+            "some": [
+                {"var": "items"},
+                {
+                    "<": [ {"var": "qty"}, 1 ]
+                }
+            ]
+        },
+        "data": {
+            "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]
+        },
+        "result": false
+    },
+    {
+        "description": "{\"some\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}",
+        "rule": {
+            "some": [
+                {"var": "items"},
+                {
+                    ">=": [ {"var": "qty"}, 1 ]
+                }
+            ]
+        },
+        "data": {"items": []},
+        "result": false
+    },
+    "EOF"
+]
diff --git a/test/community/control/and.json b/test/community/control/and.json
new file mode 100644
--- /dev/null
+++ b/test/community/control/and.json
@@ -0,0 +1,155 @@
+[
+    "Basic Checks",
+    {
+        "description": "Two true values should be true",
+        "rule": { "and": [true, true] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "false and true should be false",
+        "rule": { "and": [false, true] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "true and false should be false",
+        "rule": { "and": [true, false] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "Two false values should be false",
+        "rule": { "and": [false, false] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "All true values should be true",
+        "rule": { "and": [true, true, true] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "Any false value should be false",
+        "rule": { "and": [true, true, false] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "And with a single false value should be false",
+        "rule": { "and": [false] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "When all values are truthy, the last truthy value should be returned",
+        "rule": { "and": [1, 2, 3] },
+        "data": null,
+        "result": 3
+    },
+    {
+        "description": "When all values are truthy, the last truthy value should be returned (2)",
+        "rule": { "and": [true, 1, {}] },
+        "data": null,
+        "result": {}
+    },
+    {
+        "description": "And with a single falsey value should be return the falsy value",
+        "rule": { "and": [[]] },
+        "data": null,
+        "result": []
+    },
+    {
+        "description": "And with a single falsey value should be return the falsy value (2)",
+        "rule": { "and": [0] },
+        "data": null,
+        "result": 0
+    },
+    {
+        "description": "And with a single falsey value should be return the falsy value (3)",
+        "rule": { "and": [""] },
+        "data": null,
+        "result": ""
+    },
+    {
+        "description": "And with a single true value should be true",
+        "rule": { "and": [true] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "And with 2 truthy values should return the last truthy value",
+        "rule": { "and": [1, 3] },
+        "data": null,
+        "result": 3
+    },
+    {
+        "description": "And with a truthy value and a false value should return the false value",
+        "rule": { "and": [3, false] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "And with a truthy value and a false value should return the false value (2)",
+        "rule": { "and": [false, 3] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "Empty array is falsy, so it is returned",
+        "rule": { "and": [[], true] },
+        "data": null,
+        "result": []
+    },
+    {
+        "description": "0 is falsy, so it is returned",
+        "rule": { "and": [0, true] },
+        "data": null,
+        "result": 0
+    },
+    {
+        "description": "Empty string is falsy, so it is returned",
+        "rule": { "and": ["", true] },
+        "data": null,
+        "result": ""
+    },
+    {
+        "description": "0 as a string is truthy, so the last truthy value is returned",
+        "rule": { "and": ["0", true] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "And with no arguments should return false",
+        "rule": { "and": [] },
+        "data": null,
+        "result": false
+    },
+    "Valid Arguments Checks",
+    {
+        "description": "And with non-array arguments should throw",
+        "rule": { "and": true },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    "Short Circuiting Checks",
+    {
+        "description": "And will not interpret the second argument if the first is false",
+        "rule": { "and": [false, { "throw": "Not Lazy" }] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "And will not interpret the second argument if the first is falsy",
+        "rule": { "and": [0, { "throw": "Not Lazy" }] },
+        "data": null,
+        "result": 0
+    },
+    {
+        "description": "And will not interpret the nth argument if any value before it is false",
+        "rule": { "and": [true, 1, 2, 3, 4, [], { "throw": "Not Lazy" }] },
+        "data": null,
+        "result": []
+    }
+]
diff --git a/test/community/control/doublebang.json b/test/community/control/doublebang.json
new file mode 100644
--- /dev/null
+++ b/test/community/control/doublebang.json
@@ -0,0 +1,143 @@
+[
+    "Basic Checks",
+    {
+        "description": "Double bang on true returns true",
+        "rule": { "!!": [true] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "Double bang on false returns false",
+        "rule": { "!!": [false] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "Double bang on null returns false",
+        "rule": { "!!": [null] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "Double bang on empty array returns false",
+        "rule": { "!!": [[]] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "Double bang on non-empty array returns true",
+        "rule": { "!!": [[1]] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "Double bang on empty string returns false",
+        "rule": { "!!": [""] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "Double bang on non-empty string returns true",
+        "rule": { "!!": ["hello"] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "Double bang on zero returns false",
+        "rule": { "!!": [0] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "Double bang on one returns true",
+        "rule": { "!!": [1] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "Double bang on string zero returns true",
+        "rule": { "!!": ["0"] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "Double bang on empty object returns true",
+        "rule": { "!!": [{}] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "Double bang on empty string returns false",
+        "rule": { "!!": [""] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "Double bang preserves truthy values",
+        "rule": { "!!": [[1, 2]] },
+        "data": null,
+        "result": true
+    },
+    "Valid Arguments Checks",
+    {
+        "description": "Double bang without arguments return false",
+        "rule": { "!!": [] },
+        "data": null,
+        "result": false
+    },
+    "Complex Evaluations",
+    {
+        "description": "Double bang with nested var operation",
+        "rule": { "!!": [{"var": "value"}] },
+        "data": {"value": false},
+        "result": false
+    },
+    {
+        "description": "Double bang with nested comparison",
+        "rule": { "!!": [{"==": [1, 2]}] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "Double bang with nested AND",
+        "rule": { "!!": [{"and": [true, true]}] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "Double bang with nested OR",
+        "rule": { "!!": [{"or": [false, false]}] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "Double bang with missing variable returns false",
+        "rule": { "!!": [{"var": "missing"}] },
+        "data": {},
+        "result": false
+    },
+    {
+        "description": "Double bang after NOT negates the NOT",
+        "rule": { "!!": [{"!": [true]}] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "Double bang with undefined variable",
+        "rule": { "!!": [{"var": "undefined"}] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "Double bang on negative number returns true",
+        "rule": { "!!": [-1] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "Double bang on NaN returns false",
+        "rule": { "!!": [{"*": [0, 0]}] },
+        "data": null,
+        "result": false
+    }
+]
diff --git a/test/community/control/if.json b/test/community/control/if.json
new file mode 100644
--- /dev/null
+++ b/test/community/control/if.json
@@ -0,0 +1,277 @@
+[
+    "Boolean checks",
+    {
+        "description": "true is true, and uses the then",
+        "rule": { "if": [true, "apple", "banana"] },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "false is false, and uses the fallback",
+        "rule": { "if": [false, "apple", "banana"] },
+        "data": null,
+        "result": "banana"
+    },
+    "Null check",
+    {
+        "description": "null is falsy",
+        "rule": { "if": [[], "apple", "banana"] },
+        "data": null,
+        "result": "banana"
+    },
+    "Object checks",
+    {
+        "description": "Objects are always truthy, even without keys.",
+        "rule": { "if": [{}, "apple", "banana"] },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "Objects are always truthy, with keys defined",
+        "rule": { "if": [{ "val": [] }, "apple", "banana"] },
+        "data": { "some": "value" },
+        "result": "apple"
+    },
+    "Array checks",
+    {
+        "description": "Empty array is falsy",
+        "rule": { "if": [[], "apple", "banana"] },
+        "data": null,
+        "result": "banana"
+    },
+    {
+        "description": "Array with single element is truthy",
+        "rule": { "if": [[1], "apple", "banana" ] },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "Array with multiple elements is truthy",
+        "rule": { "if": [[1, 2, 3, 4], "apple", "banana" ] },
+        "data": null,
+        "result": "apple"
+    },
+    "String checks",
+    {
+        "description": "Empty string is falsy",
+        "rule": { "if": ["", "apple", "banana"] },
+        "data": null,
+        "result": "banana"
+    },
+    {
+        "description": "Any non-empty string is truthy",
+        "rule": { "if": ["zucchini", "apple", "banana"] },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "Any non-empty string is truthy, even 0",
+        "rule": { "if": ["0", "apple", "banana"] },
+        "data": null,
+        "result": "apple"
+    },
+    "Too few arguments",
+    { 
+        "description": "No arguments is just null.", 
+        "rule": { "if": [] }, 
+        "data": null, 
+        "result": null 
+    },
+    {
+        "description": "If no then is defined when true, it'll just return null.",
+        "rule": { "if": [true] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "If no fallback is defined when falsy, it'll just return null.",
+        "rule": { "if": [false] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "If no then is defined when truthy, it'll just return null.",
+        "rule": { "if": ["apple"] },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "When truthy, it'll return the 'then' even if no fallback is defined.",
+        "rule": { "if": [true, "apple"] },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "When falsy, it'll return null if the fallback is not defined, even if the 'then' is defined.",
+        "rule": { "if": [false, "apple"] },
+        "data": null,
+        "result": null
+    },
+    "Variadic Tests",
+    {
+        "description": "Returns the first truthy if condition",
+        "rule": { "if": [true, "apple", false, "banana"] },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "Returns the first truthy then condition, even if multiple conditions are true",
+        "rule": { "if": [true, "apple", true, "banana"] },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "Retuns the first truthy condition, with the fallback defined.",
+        "rule": { "if": [true, "apple", false, "banana", "carrot"] },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "Returns the first truthy condition, with the fallback defined; multiple true conditions.",
+        "rule": { "if": [true, "apple", true, "banana", "carrot"] },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "Returns the first truthy condition, with the first condition falsy. Fallback defined.",
+        "rule": { "if": [false, "apple", true, "banana", "carrot"] },
+        "data": null,
+        "result": "banana"
+    },
+    {
+        "description": "Returns the first truthy condition, in this case it's the 2nd one.",
+        "rule": { "if": [false, "apple", true, "banana"] },
+        "data": null,
+        "result": "banana"
+    },
+    {
+        "description": "When none of the conditions are true, it returns the fallback.",
+        "rule": { "if": [false, "apple", false, "banana", "carrot"] },
+        "data": null,
+        "result": "carrot"
+    },
+    {
+        "description": "When none of the conditions are true, it returns the fallback. In this case the fallback is not defined, so it is null.",
+        "rule": { "if": [false, "apple", false, "banana"] },
+        "data": null,
+        "result": null
+    },
+    {
+        "description": "3 Conditions, all falsy. Returns fallback.",
+        "rule": { "if": [false, "apple", false, "banana", false, "carrot", "date"] },
+        "data": null,
+        "result": "date"
+    },
+    {
+        "description": "3 Conditions, all falsy, no fallback defined. Returns null.",
+        "rule": { "if": [false, "apple", false, "banana", false, "carrot"] },
+        "data": null,
+        "result": null
+    },
+    {
+        "description": "First condition truthy, 3 conditions.",
+        "rule": { "if": [true, "apple", false, "banana", false, "carrot", "date"] },
+        "data": null,
+        "result": "apple"
+    },
+
+    {
+        "description": "2nd Condition is Truthy",
+        "rule": { "if": [false, "apple", true, "banana", false, "carrot", "date"] },
+        "data": null,
+        "result": "banana"
+    },
+    {
+        "description": "Third Condition is Truthy",
+        "rule": { "if": [false, "apple", false, "banana", true, "carrot", "date"] },
+        "data": null,
+        "result": "carrot"
+    },
+    {
+        "description": "Returns first truthy value, some conditions truthy",
+        "rule": { "if": [false, "apple", true, "banana", true, "carrot", "date"] },
+        "data": null,
+        "result": "banana"
+    },
+    {
+        "description": "Returns first truthy value, some conditions truthy (2)",
+        "rule": { "if": [true, "apple", false, "banana", true, "carrot", "date"] },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "Returns first truthy value, some conditions truthy (3)",
+        "rule": { "if": [true, "apple", true, "banana", false, "carrot", "date"] },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "Returns first truthy value, all conditions truthy",
+        "rule": { "if": [true, "apple", true, "banana", true, "carrot", "date"] },
+        "data": null,
+        "result": "apple"
+    },
+    "Some Variadic Checks with Other Types",
+    {
+        "description": "Returns first truthy value, some conditions truthy (4)",
+        "rule": { "if": [false, "apple", [1], "banana", true, "carrot", "date"] },
+        "data": null,
+        "result": "banana"
+    },   
+    {
+        "description": "Returns first truthy value, some conditions truthy (5)",
+        "rule": { "if": [false, "apple", "This is true", "banana", true, "carrot", "date"] },
+        "data": null,
+        "result": "banana"
+    },   
+    {
+        "description": "Returns first truthy value, some conditions truthy (6)",
+        "rule": { "if": [null, "apple", 0, "banana", 7, "carrot", "date"] },
+        "data": null,
+        "result": "carrot"
+    },
+    {
+        "description": "Returns first truthy value, some conditions truthy (7)",
+        "rule": { "if": ["0", "apple", 0, "banana", 7, "carrot", "date"] },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "Returns first truthy value, some conditions truthy (8)",
+        "rule": { "if": [{}, "apple", 0, "banana", 7, "carrot", "date"] },
+        "data": null,
+        "result": "apple"
+    },
+    "Bad Arguments",
+    {
+        "description": "If with non-array arguments throws",
+        "rule": { "if": "apple" },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    "Short circuiting check",
+    {
+        "description": "If will not evaluate then branch if the condition is false",
+        "rule": { "if": [false, { "throw": "Not Lazy" }, "banana"] },
+        "data": null,
+        "result": "banana"
+    },
+    {
+        "description": "If will not evaluate fallback branch if the condition is true",
+        "rule": { "if": [true, "apple", { "throw": "Not Lazy" }] },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "If will not evaluate other branches or checks if a prior condition is true",
+        "rule": { "if": [false, { "throw": "Not Lazy" }, true, "apple", { "throw": "Not Lazy" }, { "throw": "Not Lazy" }, { "throw": "Not Lazy" }] },
+        "data": null,
+        "result": "apple"
+    },
+    {
+        "description": "No branches except the fallback check will be evaluated if the condition is false",
+        "rule": { "if": [false, { "throw": "Not Lazy" }, false, { "throw": "Not Lazy" }, false, { "throw": "Not Lazy" }, "carrot"] },
+        "data": null,
+        "result": "carrot"
+    }
+]
diff --git a/test/community/control/not.json b/test/community/control/not.json
new file mode 100644
--- /dev/null
+++ b/test/community/control/not.json
@@ -0,0 +1,143 @@
+[
+    "Basic Checks",
+    {
+        "description": "NOT true returns false",
+        "rule": { "!": [true] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "NOT false returns true",
+        "rule": { "!": [false] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "NOT null returns true",
+        "rule": { "!": [null] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "NOT empty array returns true",
+        "rule": { "!": [[]] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "NOT non-empty array returns false",
+        "rule": { "!": [[1]] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "NOT empty string returns true",
+        "rule": { "!": [""] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "NOT non-empty string returns false",
+        "rule": { "!": ["hello"] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "NOT zero returns true",
+        "rule": { "!": [0] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "NOT one returns false",
+        "rule": { "!": [1] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "NOT string zero returns false",
+        "rule": { "!": ["0"] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "NOT empty object returns false",
+        "rule": { "!": [{}] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "NOT with non-array argument",
+        "rule": { "!": true },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "NOT with non-array argument false",
+        "rule": { "!": false },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "NOT with non-array argument 0",
+        "rule": { "!": 0 },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "NOT with non-array argument 1",
+        "rule": { "!": 1 },
+        "data": null,
+        "result": false
+    },
+    "Valid Arguments Checks",
+    {
+        "description": "NOT without arguments returns true",
+        "rule": { "!": [] },
+        "data": null,
+        "result": true
+    },
+    "Complex Evaluations",
+    {
+        "description": "NOT with nested var operation",
+        "rule": { "!": [{"var": "value"}] },
+        "data": {"value": false},
+        "result": true
+    },
+    {
+        "description": "NOT with nested comparison",
+        "rule": { "!": [{"==": [1, 2]}] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "NOT with nested AND",
+        "rule": { "!": [{"and": [true, false]}] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "NOT with nested OR",
+        "rule": { "!": [{"or": [false, false]}] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "NOT with missing variable returns true",
+        "rule": { "!": [{"var": "missing"}] },
+        "data": {},
+        "result": true
+    },
+    {
+        "description": "Double NOT preserves original boolean",
+        "rule": { "!": [{"!": [true]}] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "NOT with undefined variable",
+        "rule": { "!": [{"var": "undefined"}] },
+        "data": null,
+        "result": true
+    }
+]
diff --git a/test/community/control/or.json b/test/community/control/or.json
new file mode 100644
--- /dev/null
+++ b/test/community/control/or.json
@@ -0,0 +1,150 @@
+[
+    "Basic Checks",
+    {
+        "description": "Two true values returns first true",
+        "rule": { "or": [true, true] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "false and true returns true",
+        "rule": { "or": [false, true] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "true and false returns true",
+        "rule": { "or": [true, false] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "Two false values returns last false",
+        "rule": { "or": [false, false] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "Empty OR returns false",
+        "rule": { "or": [] },
+        "result": false,
+        "data": null
+    },
+    {
+        "description": "OR with single truthy value returns that value",
+        "rule": { "or": [1] },
+        "result": 1,
+        "data": null
+    },
+    {
+        "description": "OR with single falsy value returns that value",
+        "rule": { "or": [0] },
+        "result": 0,
+        "data": null
+    },
+    {
+        "description": "OR with multiple values returns first truthy value",
+        "rule": { "or": [false, 0, null, "hello", 1, 2] },
+        "result": "hello",
+        "data": null
+    },
+    {
+        "description": "When all values are falsy, the last falsy value should be returned",
+        "rule": { "or": [false, 0, "", null] },
+        "result": null,
+        "data": null
+    },
+    {
+        "description": "OR with a single empty array returns empty array",
+        "rule": { "or": [[]] },
+        "data": null,
+        "result": []
+    },
+    {
+        "description": "OR with a single zero returns zero",
+        "rule": { "or": [0] },
+        "data": null,
+        "result": 0
+    },
+    {
+        "description": "OR with a single empty string returns empty string",
+        "rule": { "or": [""] },
+        "data": null,
+        "result": ""
+    },
+    {
+        "description": "OR with 2 truthy values returns first truthy value",
+        "rule": { "or": [1, 3] },
+        "data": null,
+        "result": 1
+    },
+    {
+        "description": "Empty array then true returns true",
+        "rule": { "or": [[], true] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "Zero then true returns true",
+        "rule": { "or": [0, true] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "Empty string then true returns true",
+        "rule": { "or": ["", true] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "String zero is truthy and returned",
+        "rule": { "or": ["0", false] },
+        "data": null,
+        "result": "0"
+    },
+    "Valid Arguments Checks",
+    {
+        "description": "OR with non-array arguments should throw",
+        "rule": { "or": true },
+        "error": {"type": "Invalid Arguments"},
+        "data": null
+    },
+    "Short Circuiting Checks",
+    {
+        "description": "OR will not interpret the second argument if the first is true",
+        "rule": { "or": [true, {"throw": "Not Lazy"}] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "OR will not interpret the second argument if the first is truthy",
+        "rule": { "or": [1, {"throw": "Not Lazy"}] },
+        "data": null,
+        "result": 1
+    },
+    {
+        "description": "OR will not interpret the nth argument if any value before it is truthy",
+        "rule": { "or": [false, 0, null, "hello", {"throw": "Not Lazy"}] },
+        "data": null,
+        "result": "hello"
+    },
+    "Complex Evaluations",
+    {
+        "description": "OR with nested operations evaluates left to right",
+        "rule": { "or": [{"var": "a"}, {"var": "b"}, {"var": "c"}] },
+        "data": {"a": false, "b": "yes", "c": "not reached"},
+        "result": "yes"
+    },
+    {
+        "description": "OR with nested logic",
+        "rule": { "or": [{"==": [1, 2]}, {"==": [2, 2]}] },
+        "result": true,
+        "data": null
+    },
+    {
+        "description": "OR with deeply nested operations",
+        "rule": { "or": [{"and": [false, true]}, {"and": [true, true]}] },
+        "result": true,
+        "data": null
+    }
+]
diff --git a/test/community/exists.json b/test/community/exists.json
new file mode 100644
--- /dev/null
+++ b/test/community/exists.json
@@ -0,0 +1,51 @@
+[
+    "Test Specification for exists",
+    {
+        "description": "Checks if a normal key exists",
+        "rule": { "exists": "hello" },
+        "data": { "hello" : 1 },
+        "result": true
+    },
+    {
+        "description": "Checks if a normal key exists (array)",
+        "rule": { "exists": ["hello"] },
+        "data": { "hello" : 1 },
+        "result": true
+    },
+    {
+        "description": "Checks if a normal key exists (false)",
+        "rule": { "exists": "hello" },
+        "data": { "world" : 1 },
+        "result": false
+    },
+    {
+        "description": "Checks if an empty key exists (true)",
+        "rule": { "exists": [""] },
+        "data": { "" : 1 },
+        "result": true
+    },
+    {
+        "description": "Checks if an empty key exists (false)",
+        "rule": { "exists": [""] },
+        "data": { "hello" : 1 },
+        "result": false
+    },
+    {
+        "description": "Checks if a nested key exists",
+        "rule": { "exists": ["hello", "world"] },
+        "data": { "hello" : { "world": false } },
+        "result": true
+    },
+    {
+        "description": "Checks if a nested key exists (false)",
+        "rule": { "exists": ["hello", "world"] },
+        "data": { "hello" : { "x": false } },
+        "result": false
+    },
+    {
+        "description": "Checks if a null value exists",
+        "rule": { "exists": "hello" },
+        "data": { "hello" : null },
+        "result": true
+    }
+]
diff --git a/test/community/expected.json b/test/community/expected.json
new file mode 100644
--- /dev/null
+++ b/test/community/expected.json
@@ -0,0 +1,50 @@
+{
+  "compatible.json": {"cases":278,"lenient":278,"strict":278},
+  "arithmetic/plus.json": {"cases":32,"lenient":17,"strict":17},
+  "arithmetic/plus.extra.json": {"cases":3,"lenient":0,"strict":0},
+  "arithmetic/multiply.json": {"cases":28,"lenient":13,"strict":13},
+  "arithmetic/multiply.extra.json": {"cases":3,"lenient":0,"strict":0},
+  "arithmetic/minus.json": {"cases":22,"lenient":13,"strict":14},
+  "arithmetic/minus.extra.json": {"cases":3,"lenient":0,"strict":0},
+  "arithmetic/divide.json": {"cases":31,"lenient":6,"strict":6},
+  "arithmetic/divide.extra.json": {"cases":3,"lenient":0,"strict":1},
+  "arithmetic/modulo.json": {"cases":31,"lenient":12,"strict":25},
+  "arithmetic/modulo.extra.json": {"cases":2,"lenient":0,"strict":2},
+  "comparison/greaterThan.json": {"cases":35,"lenient":24,"strict":26},
+  "comparison/greaterThanEquals.json": {"cases":28,"lenient":17,"strict":19},
+  "comparison/lessThan.json": {"cases":45,"lenient":35,"strict":38},
+  "comparison/lessThanEquals.json": {"cases":20,"lenient":13,"strict":13},
+  "comparison/softEquals.json": {"cases":35,"lenient":24,"strict":25},
+  "comparison/softNotEquals.json": {"cases":34,"lenient":23,"strict":25},
+  "comparison/strictEquals.json": {"cases":31,"lenient":26,"strict":28},
+  "comparison/strictNotEquals.json": {"cases":30,"lenient":26,"strict":28},
+  "control/and.json": {"cases":25,"lenient":23,"strict":23},
+  "control/if.json": {"cases":44,"lenient":42,"strict":42},
+  "control/or.json": {"cases":24,"lenient":22,"strict":22},
+  "control/not.json": {"cases":23,"lenient":23,"strict":22},
+  "control/doublebang.json": {"cases":23,"lenient":23,"strict":22},
+  "string/in.json": {"cases":8,"lenient":8,"strict":8},
+  "string/cat.json": {"cases":9,"lenient":9,"strict":9},
+  "string/substr.json": {"cases":12,"lenient":12,"strict":12},
+  "array/map.json": {"cases":14,"lenient":12,"strict":12},
+  "array/filter.json": {"cases":12,"lenient":10,"strict":10},
+  "array/reduce.json": {"cases":9,"lenient":8,"strict":8},
+  "array/merge.json": {"cases":8,"lenient":8,"strict":8},
+  "array/all.json": {"cases":12,"lenient":10,"strict":10},
+  "array/some.json": {"cases":13,"lenient":11,"strict":11},
+  "array/none.json": {"cases":13,"lenient":11,"strict":11},
+  "truthiness.json": {"cases":13,"lenient":9,"strict":9},
+  "additional.json": {"cases":4,"lenient":0,"strict":0},
+  "coalesce.json": {"cases":15,"lenient":0,"strict":0},
+  "chained.json": {"cases":7,"lenient":2,"strict":2},
+  "iterators.extra.json": {"cases":34,"lenient":0,"strict":28},
+  "exists.json": {"cases":8,"lenient":0,"strict":0},
+  "scopes.json": {"cases":4,"lenient":0,"strict":0},
+  "throw.json": {"cases":3,"lenient":0,"strict":0},
+  "try.json": {"cases":18,"lenient":0,"strict":0},
+  "try.extra.json": {"cases":1,"lenient":0,"strict":0},
+  "val.json": {"cases":13,"lenient":0,"strict":0},
+  "val.extra.json": {"cases":3,"lenient":0,"strict":0},
+  "val-compat.json": {"cases":60,"lenient":0,"strict":0},
+  "var.extra.json": {"cases":12,"lenient":12,"strict":12}
+}
diff --git a/test/community/index.json b/test/community/index.json
new file mode 100644
--- /dev/null
+++ b/test/community/index.json
@@ -0,0 +1,50 @@
+[
+    "compatible.json",
+    "arithmetic/plus.json",
+    "arithmetic/plus.extra.json",
+    "arithmetic/multiply.json",
+    "arithmetic/multiply.extra.json",
+    "arithmetic/minus.json",
+    "arithmetic/minus.extra.json",
+    "arithmetic/divide.json",
+    "arithmetic/divide.extra.json",
+    "arithmetic/modulo.json",
+    "arithmetic/modulo.extra.json",
+    "comparison/greaterThan.json",
+    "comparison/greaterThanEquals.json",
+    "comparison/lessThan.json",
+    "comparison/lessThanEquals.json",
+    "comparison/softEquals.json",
+    "comparison/softNotEquals.json",
+    "comparison/strictEquals.json",
+    "comparison/strictNotEquals.json",
+    "control/and.json",
+    "control/if.json",
+    "control/or.json",
+    "control/not.json",
+    "control/doublebang.json",
+    "string/in.json",
+    "string/cat.json",
+    "string/substr.json",
+    "array/map.json",
+    "array/filter.json",
+    "array/reduce.json",
+    "array/merge.json",
+    "array/all.json",
+    "array/some.json",
+    "array/none.json",
+    "truthiness.json",
+    "additional.json",
+    "coalesce.json",
+    "chained.json",
+    "iterators.extra.json",
+    "exists.json",
+    "scopes.json",
+    "throw.json",
+    "try.json",
+    "try.extra.json",
+    "val.json",
+    "val.extra.json",
+    "val-compat.json",
+    "var.extra.json"
+]
diff --git a/test/community/iterators.extra.json b/test/community/iterators.extra.json
new file mode 100644
--- /dev/null
+++ b/test/community/iterators.extra.json
@@ -0,0 +1,207 @@
+[
+    {
+        "description": "if with dynamic arguments throws",
+        "rule": { "if": { "preserve": [true, 1, 2] } },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "or with dynamic arguments throws",
+        "rule": { "or": { "preserve": [false, false, true] } },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "and with dynamic arguments throws",
+        "rule": { "and": { "preserve": [true, true, true] } },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "map with dynamic arguments throws",
+        "rule": { "map": { "preserve": [[1,2,3], { "val": [] }]} },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "filter with dynamic arguments throws",
+        "rule": { "filter": { "preserve": [[1,2,3], { "val": [] }]} },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "reduce with dynamic arguments throws",
+        "rule": { "reduce": { "preserve": [[1,2,3], { "val": [] }]} },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "some with dynamic arguments throws",
+        "rule": { "some": { "preserve": [[1,2,3], { "val": [] }]} },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "all with dynamic arguments throws",
+        "rule": { "all": { "preserve": [[1,2,3], { "val": [] }]} },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "none with dynamic arguments throws",
+        "rule": { "none": { "preserve": [[1,2,3], { "val": [] }]} },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "< with dynamic arguments throws",
+        "rule": { "<": { "preserve": [1,2,3] } },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "<= with dynamic arguments throws",
+        "rule": { "<=": { "preserve": [1,2,3] } },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "> with dynamic arguments throws",
+        "rule": { ">": { "preserve": [1,2,3] } },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": ">= with dynamic arguments throws",
+        "rule": { ">=": { "preserve": [1,2,3] } },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "== with dynamic arguments throws",
+        "rule": { "==": { "preserve": [1,2,3] } },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "!= with dynamic arguments throws",
+        "rule": { "!=": { "preserve": [1,2,3] } },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "=== with dynamic arguments throws",
+        "rule": { "===": { "preserve": [1,2,3] } },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "!== with dynamic arguments throws",
+        "rule": { "!==": { "preserve": [1,2,3] } },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    "Any of the operators listed when called with a non-array argument, will throw",
+    {
+        "description": "if with static non-array argument throws",
+        "rule": { "if": 5 },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "or with static non-array argument throws",
+        "rule": { "or": 5 },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "and with static non-array argument throws",
+        "rule": { "and": 5 },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "map with static non-array argument throws",
+        "rule": { "map": 5 },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "filter with static non-array argument throws",
+        "rule": { "filter": 5 },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "reduce with static non-array argument throws",
+        "rule": { "reduce": 5 },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "some with static non-array argument throws",
+        "rule": { "some": 5 },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "all with static non-array argument throws",
+        "rule": { "all": 5 },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "none with static non-array argument throws",
+        "rule": { "none": 5 },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "< with static non-array argument throws",
+        "rule": { "<": 5 },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "<= with static non-array argument throws",
+        "rule": { "<=": 5 },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "> with static non-array argument throws",
+        "rule": { ">": 5 },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": ">= with static non-array argument throws",
+        "rule": { ">=": 5 },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "== with static non-array argument throws",
+        "rule": { "==": 5 },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "!= with static non-array argument throws",
+        "rule": { "!=": 5 },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "=== with static non-array argument throws",
+        "rule": { "===": 5 },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    },
+    {
+        "description": "!== with static non-array argument throws",
+        "rule": { "!==": 5 },
+        "data": null,
+        "error": { "type": "Invalid Arguments" }
+    }
+]
diff --git a/test/community/scopes.json b/test/community/scopes.json
new file mode 100644
--- /dev/null
+++ b/test/community/scopes.json
@@ -0,0 +1,67 @@
+[
+    "A proposal for handling scopes",
+    {
+        "description": "Map can add each number to index",
+        "rule": {
+            "map": [
+                { "val": "numbers" },
+                { "+": [{ "val": [[1], "index"] }, { "val": [] }]}
+            ]
+        },
+        "data": { "numbers": [1,2,3] },
+        "result": [1,3,5]
+    },
+    {
+        "description": "Map can add each number to value from context",
+        "rule": {
+            "map": [
+                { "val": "numbers" },
+                { "+": [{ "val": [[2], "value"] }, { "val": [] }]}
+            ]
+        },
+        "data": { "numbers": [1,2,3], "value": 10 },
+        "result": [11,12,13]
+    },
+    {
+        "description": "Filter can use parent context to filter",
+        "rule": {
+            "filter": [
+                { "val": "people" },
+                { "===": [{ "val": "department" }, { "val": [[2], "department"] }] }
+            ]
+        },
+        "data": {
+            "department": "Engineering",
+            "people": [
+                { "name": "Jay Ortiz", "department": "Engineering" },
+                { "name": "Louisa Hall", "department": "Sales" },
+                { "name": "Kyle Carlson", "department": "Sales" },
+                { "name": "Grace Ortiz", "department": "Engineering" },
+                { "name": "Isabelle Harrington", "department": "Marketing" },
+                { "name": "Harold Moore", "department": "Sales" },
+                { "name": "Clarence Schultz", "department": "Sales" },
+                { "name": "Jesse Keller", "department": "Engineering" },
+                { "name": "Phillip Holland", "department": "Marketing" },
+                { "name": "Mason Sullivan", "department": "Engineering" }
+            ]
+        },
+        "result": [
+            { "name": "Jay Ortiz", "department": "Engineering" },
+            { "name": "Grace Ortiz", "department": "Engineering" },
+            { "name": "Jesse Keller", "department": "Engineering" },
+            { "name": "Mason Sullivan", "department": "Engineering" }
+        ]
+    },
+    "The specification below depends upon escaping from another proposal",
+    {
+        "description": "Access an escaped key from above",
+        "rule": { 
+            "map": [
+                { "val": "arr" }, 
+                { "+": [{ "val": [[2], "", "", "/"] }, { "val": [[2], "../"] }]}
+            ] 
+        },
+        "data": { "arr": [1,2,3], "../": 10, "": { "": { "/": 7 }} },
+        "result": [17,17,17]
+    }
+]
diff --git a/test/community/string/cat.json b/test/community/string/cat.json
new file mode 100644
--- /dev/null
+++ b/test/community/string/cat.json
@@ -0,0 +1,60 @@
+[
+    "Basic Concatenation",
+    {
+        "description": "Cat with single string argument",
+        "rule": { "cat": "ice" },
+        "data": null,
+        "result": "ice"
+    },
+    {
+        "description": "Cat with array of single string",
+        "rule": { "cat": ["ice"] },
+        "data": null,
+        "result": "ice"
+    },
+    {
+        "description": "Cat with two strings",
+        "rule": { "cat": ["ice", "cream"] },
+        "data": null,
+        "result": "icecream"
+    },
+    "Type Coercion",
+    {
+        "description": "Cat with numbers",
+        "rule": { "cat": [1, 2] },
+        "data": null,
+        "result": "12"
+    },
+    {
+        "description": "Cat with mixed types",
+        "rule": { "cat": ["Robocop", 2] },
+        "data": null,
+        "result": "Robocop2"
+    },
+    "Multiple Arguments",
+    {
+        "description": "Cat with multiple arguments",
+        "rule": { "cat": ["we all scream for ", "ice", "cream"] },
+        "data": null,
+        "result": "we all scream for icecream"
+    },
+    "Edge Cases",
+    {
+        "description": "Cat with no arguments returns empty string",
+        "rule": { "cat": [] },
+        "data": null,
+        "result": ""
+    },
+    {
+        "description": "Cat with null values",
+        "rule": { "cat": [null, "test", null] },
+        "data": null,
+        "result": "test"
+    },
+    {
+        "description": "Cat with boolean values",
+        "rule": { "cat": [true, false, "test"] },
+        "data": null,
+        "result": "truefalsetest"
+    }
+]
diff --git a/test/community/string/in.json b/test/community/string/in.json
new file mode 100644
--- /dev/null
+++ b/test/community/string/in.json
@@ -0,0 +1,53 @@
+[
+    "Basic Array Checks",
+    {
+        "description": "In operator with array finds exact match",
+        "rule": { "in": ["Bart", ["Bart", "Homer", "Lisa"]] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "In operator with array returns false for no match",
+        "rule": { "in": ["Milhouse", ["Bart", "Homer", "Lisa"]] },
+        "data": null,
+        "result": false
+    },
+    "String Contains Checks",
+    {
+        "description": "In operator with string finds substring",
+        "rule": { "in": ["Spring", "Springfield"] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "In operator with string is case sensitive",
+        "rule": { "in": ["spring", "Springfield"] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "In operator with string returns false for no match",
+        "rule": { "in": ["i", "team"] },
+        "data": null,
+        "result": false
+    },
+    "Edge Cases",
+    {
+        "description": "In operator with empty array returns false",
+        "rule": { "in": ["test", []] },
+        "data": null,
+        "result": false
+    },
+    {
+        "description": "In operator with empty string search returns true",
+        "rule": { "in": ["", "test"] },
+        "data": null,
+        "result": true
+    },
+    {
+        "description": "In operator with empty string target returns false",
+        "rule": { "in": ["test", ""] },
+        "data": null,
+        "result": false
+    }
+]
diff --git a/test/community/string/substr.json b/test/community/string/substr.json
new file mode 100644
--- /dev/null
+++ b/test/community/string/substr.json
@@ -0,0 +1,78 @@
+[
+    "Basic Substring",
+    {
+        "description": "Substr from start index",
+        "rule": { "substr": ["jsonlogic", 4] },
+        "data": null,
+        "result": "logic"
+    },
+    {
+        "description": "Substr with negative start index",
+        "rule": { "substr": ["jsonlogic", -5] },
+        "data": null,
+        "result": "logic"
+    },
+    "Start and Length",
+    {
+        "description": "Substr with start and positive length",
+        "rule": { "substr": ["jsonlogic", 0, 1] },
+        "data": null,
+        "result": "j"
+    },
+    {
+        "description": "Substr with negative start and positive length",
+        "rule": { "substr": ["jsonlogic", -1, 1] },
+        "data": null,
+        "result": "c"
+    },
+    {
+        "description": "Substr with start and length",
+        "rule": { "substr": ["jsonlogic", 4, 5] },
+        "data": null,
+        "result": "logic"
+    },
+    {
+        "description": "Substr with negative start and length",
+        "rule": { "substr": ["jsonlogic", -5, 5] },
+        "data": null,
+        "result": "logic"
+    },
+    "Negative Length",
+    {
+        "description": "Substr with negative length from end",
+        "rule": { "substr": ["jsonlogic", -5, -2] },
+        "data": null,
+        "result": "log"
+    },
+    {
+        "description": "Substr with positive start and negative length",
+        "rule": { "substr": ["jsonlogic", 1, -5] },
+        "data": null,
+        "result": "son"
+    },
+    "Edge Cases",
+    {
+        "description": "Substr with empty string",
+        "rule": { "substr": ["", 0, 1] },
+        "data": null,
+        "result": ""
+    },
+    {
+        "description": "Substr with start beyond string length",
+        "rule": { "substr": ["test", 10, 1] },
+        "data": null,
+        "result": ""
+    },
+    {
+        "description": "Substr with negative start beyond string length",
+        "rule": { "substr": ["test", -10, 1] },
+        "data": null,
+        "result": "t"
+    },
+    {
+        "description": "Substr with non-string input returns empty",
+        "rule": { "substr": [42, 0, 1] },
+        "data": null,
+        "result": "4"
+    }
+]
diff --git a/test/community/throw.json b/test/community/throw.json
new file mode 100644
--- /dev/null
+++ b/test/community/throw.json
@@ -0,0 +1,20 @@
+[
+    {
+        "description": "Throws hello as an error",
+        "rule": { "throw": "hello" },
+        "data": null,
+        "error": { "type": "hello" }
+    },
+    {
+        "description": "Throwing NaN produces an error object or measured equivalent (equivalence class test)",
+        "rule": { "throw": "NaN" },
+        "data": null,
+        "error": { "type": "NaN" }
+    },
+    {
+        "description": "Can throw an error object",
+        "rule": { "throw": { "val": "x" } },
+        "data": { "x": { "type": "Some error" }},
+        "error": { "type": "Some error" }
+    }
+]
diff --git a/test/community/truthiness.json b/test/community/truthiness.json
new file mode 100644
--- /dev/null
+++ b/test/community/truthiness.json
@@ -0,0 +1,83 @@
+[
+    "# This can also be seen as a test suite for !!",
+    "# Primitive Truthiness tests",
+    {
+        "description": "Truthy: true",
+        "rule": { "!!": true },
+        "result": true,
+        "data": null
+    },
+    {
+        "description": "Falsey: false",
+        "rule": { "!!": false },
+        "result": false,
+        "data": null
+    },
+    {
+        "description": "Truthy: 1",
+        "rule": { "!!": 1 },
+        "result": true,
+        "data": null
+    },
+    {
+        "description": "Falsey: 0",
+        "rule": { "!!": 0 },
+        "result": false,
+        "data": null
+    },
+    {
+        "description": "Truthy: -1",
+        "rule": { "!!": -1 },
+        "result": true,
+        "data": null
+    },
+    {
+        "description": "Falsey: []",
+        "rule": { "!!": [[]] },
+        "result": false,
+        "data": null
+    },
+    {
+        "description": "Truthy: {}",
+        "rule": { "!!": [{}] },
+        "result": true,
+        "data": null
+    },
+    {
+        "description": "Falsey: ''",
+        "rule": { "!!": "" },
+        "result": false,
+        "data": null
+    },
+    {
+        "description": "Falsey: null",
+        "rule": { "!!": null },
+        "result": false,
+        "data": null
+    },
+    "# Context Object Tests",
+    {
+        "description": "Truthy: Zero Key Object",
+        "rule": { "!!": { "val": "obj" } },
+        "result": true,
+        "data": { "obj": {} }
+    },
+    {
+        "description": "Truthy: Single Key Object",
+        "rule": { "!!": { "val": "obj" } },
+        "result": true,
+        "data": { "obj": { "a": 0 } }
+    },
+    {
+        "description": "Truthy: Multi Key Object",
+        "rule": { "!!": { "val": "obj" } },
+        "result": true,
+        "data": { "obj": { "a": 0, "b": 0 } }
+    },
+    {
+        "description": "Truthy: Single Key Object (Empty Key)",
+        "rule": { "!!": { "val": "obj" } },
+        "result": true,
+        "data": { "obj": { "": 0 } }
+    }
+]
diff --git a/test/community/try.extra.json b/test/community/try.extra.json
new file mode 100644
--- /dev/null
+++ b/test/community/try.extra.json
@@ -0,0 +1,17 @@
+[
+    "# Not Proposed",
+    {
+        "description": "Try can work further up the AST with Exceptions; Grabbing other Context value",
+        "rule": {
+            "try": [{
+                "if": [
+                    true,
+                    { "map": [[1,2,3], {"/": [0, 0] }]},
+                    null
+                ]
+            }, { "val": [[2], "fallback"] }]
+        },
+        "result": "Hello",
+        "data": { "fallback": "Hello" }
+    }
+]
diff --git a/test/community/try.json b/test/community/try.json
new file mode 100644
--- /dev/null
+++ b/test/community/try.json
@@ -0,0 +1,157 @@
+[
+    "# Collection of Tests for Try",
+    {
+        "description": "Coalesce an error",
+        "rule": { "try": [{ "throw": "Some error" }, 1] },
+        "result": 1,
+        "data": null
+    },
+    {
+        "description": "Coalesce an error emitted from an operator",
+        "rule": { "try": [{ "/": [0, 0]}, 1] },
+        "result": 1,
+        "data": { "hello": "world" }
+    },
+    {
+        "description": "Try is variadic",
+        "rule": { "try": [{ "throw": "Some error" }, { "/": [0, 0] }, 2] },
+        "result": 2,
+        "data": null
+    },
+    {
+        "description": "Panics if none of the values are valid",
+        "rule": { "try": [{ "throw": "Some error" }, { "throw": "Some other error" }] },
+        "error": { "type": "Some other error" },
+        "data": null
+    },
+    {
+        "description": "Panics if none of the values are valid (2)",
+        "rule": { "try": [{ "throw": "Some error" }, { "/": [0, 0] }] },
+        "error": { "type": "NaN" },
+        "data": null
+    },
+    {
+        "description": "Panics if none of the values are valid (3)",
+        "rule": { "try": [{ "/": [0, 0] }, { "/": [1, 0] }, { "/": [2, 0] }] },
+        "error": { "type": "NaN" },
+        "data": null
+    },
+    {
+        "description": "Panics when the only argument is an error",
+        "rule": { "try": { "throw": "Some error" } },
+        "error": { "type": "Some error" },
+        "data": null
+    },
+    {
+        "description": "Panic with an error emitted from an operator",
+        "rule": { "try": [{ "/": [1, 0] }] },
+        "error": { "type": "NaN" },
+        "data": null
+    },
+    {
+        "description": "Panic within an iterator",
+        "rule": { "map": [[1, 2, 3], { "try": [{ "/": [0,0] }] }] },
+        "error": { "type": "NaN" },
+        "data": null
+    },
+    {
+        "description": "Panic based on an error emitted from an if",
+        "rule": { "try": [{ "if": [{"val": ["user", "admin"]}, true, { "throw": "Not an admin" }] }] },
+        "data": { "user": { "admin": false } },
+        "error": { "type": "Not an admin" }
+    },
+    {
+        "description": "Try can work further up the AST with Exceptions",
+        "rule": {
+            "try": [{
+                "if": [
+                    true,
+                    { "map": [[1,2,3], {"/": [0, 0] }]},
+                    null
+                ]
+            }, 10]
+        },
+        "result": 10,
+        "data": null
+    },
+    {
+        "description": "The context switches for the try coalescing to the previous error",
+        "rule": {
+            "try": [
+                { "throw": "Some error" },
+                { "val": "type" }
+            ]
+        },
+        "result": "Some error",
+        "data": null
+    },
+    {
+        "description": "The context switches for the try coalescing to the previous error (2)",
+        "rule": {
+            "try": [
+                { "if": [true, { "throw": "Some error" }, null] },
+                { "val": "type" }
+            ]
+        },
+        "result": "Some error",
+        "data": null
+    },
+    {
+        "description": "The context switches for the try coalescing to the previous error (3)",
+        "rule": {
+            "try": [
+                { "throw": "A" },
+                { "throw": "B" },
+                { "val": "type" }
+            ]
+        },
+        "result": "B",
+        "data": null
+    },
+    {
+        "description": "Error can pull from an error object",
+        "rule": {
+            "try": [{ "throw": { "val": "x" } }, { "val": "type" }]
+        },
+        "data": { "x": { "type": "Some error" }},
+        "result": "Some error"
+    },
+    {
+        "description": "Try can work further up the AST with Exceptions, and return the error",
+        "rule": {
+            "try": [{
+                "if": [
+                    true,
+                    { "map": [[1,2,3], {"/": [0, 0] }]},
+                    null
+                ]
+            }, { "val": "type" }]
+        },
+        "result": "NaN",
+        "data": null
+    },
+    {
+        "description": "Handles NaN Explicitly",
+        "rule": {
+            "try": [
+                { "if": [{ "/": [1, { "val": "x" }] }, { "throw": "Some error" }, null] },
+                {
+                    "if": [{ "===": [{ "val": "type" }, "NaN"]}, "Handled", { "throw": { "val": [] } }]
+                }
+            ]
+        },
+        "result": "Handled",
+        "data": { "x": 0 }
+    },
+    {
+        "description": "Did not NaN, so it errored",
+        "rule": {
+            "try": [
+                { "if": [{ "/": [1, { "val": "x" }] }, { "throw": "Some error" }, null] },
+                { "if": [{ "===": [{ "val": "type" }, "NaN"]}, "Handled", { "throw": { "val": [] } }] }
+            ]
+        },
+        "error": { "type": "Some error" },
+        "data": { "x": 1 }
+    }
+]
diff --git a/test/community/val-compat.json b/test/community/val-compat.json
new file mode 100644
--- /dev/null
+++ b/test/community/val-compat.json
@@ -0,0 +1,665 @@
+[
+  "If the consequents are logic, they get evaluated",
+  {
+      "description": "[1,{\"var\":\"x\"},3]",
+      "rule": [ 1, {"val": "x"}, 3 ],
+      "data": {"x": 2},
+      "result": [1, 2, 3]
+  },
+  {
+      "description": "{\"if\":[{\"var\":\"x\"},[{\"var\":\"y\"}],99]}",
+      "rule": {
+          "if": [
+              {"val": "x"},
+              [ {"val": "y"} ],
+              99
+          ]
+      },
+      "data": {"x": true, "y": 42},
+      "result": [42]
+  },
+  {
+      "description": "{\"var\":[\"a\"]}",
+      "rule": { "val": ["a"] },
+      "data": {"a": 1},
+      "result": 1
+  },
+  {
+      "description": "{\"var\":[\"b\"]}",
+      "rule": { "val": ["b"] },
+      "data": {"a": 1},
+      "result": null
+  },
+  {
+      "description": "{\"var\":[\"a\"]}",
+      "rule": { "val": ["a"] },
+      "data": null,
+      "result": null
+  },
+  { "description": "{\"var\":\"a\"}", "rule": {"val": "a"}, "data": {"a": 1}, "result": 1 },
+  { "description": "{\"var\":\"b\"}", "rule": {"val": "b"}, "data": {"a": 1}, "result": null },
+  { "description": "{\"var\":\"a\"}", "rule": {"val": "a"}, "data": null, "result": null },
+  {
+      "description": "{\"var\":\"a.b\"}",
+      "rule": { "val": ["a", "b"] },
+      "data": { "a": {"b": "c"} },
+      "result": "c"
+  },
+  {
+      "description": "{\"var\":\"a.q\"}",
+      "rule": { "val": ["a", "q"] },
+      "data": { "a": {"b": "c"} },
+      "result": null
+  },
+  {
+      "description": "{\"var\":1}",
+      "rule": {"val": 1},
+      "data": ["apple", "banana"],
+      "result": "banana"
+  },
+  {
+      "description": "{\"var\":\"1\"}",
+      "rule": {"val": "1"},
+      "data": ["apple", "banana"],
+      "result": "banana"
+  },
+  {
+      "description": "{\"var\":\"1.1\"}",
+      "rule": { "val": [1, 1] },
+      "data": [ "apple", ["banana", "beer"] ],
+      "result": "beer"
+  },
+  {
+      "description": "{\"and\":[{\"<\":[{\"var\":\"temp\"},110]},{\"==\":[{\"var\":\"pie.filling\"},\"apple\"]}]}",
+      "rule": {
+          "and": [
+              {
+                  "<": [ {"val": "temp"}, 110 ]
+              },
+              {
+                  "==": [
+                      { "val": ["pie", "filling"] },
+                      "apple"
+                  ]
+              }
+          ]
+      },
+      "data": { "temp": 100, "pie": {"filling": "apple"} },
+      "result": true
+  },
+  {
+      "description": "{\"var\":[{\"?:\":[{\"<\":[{\"var\":\"temp\"},110]},\"pie.filling\",\"pie.eta\"]}]}",
+      "rule": {
+        "?:": [
+            { "<": [ {"val": "temp"}, 110 ] },
+            { "val": ["pie", "filling"] },
+            { "val": ["pie", "eta"] }
+        ]
+      },
+      "data": { "temp": 100, "pie": {"filling": "apple", "eta": "60s"} },
+      "result": "apple"
+  },
+  {
+      "description": "{\"in\":[{\"var\":\"filling\"},[\"apple\",\"cherry\"]]}",
+      "rule": {
+          "in": [ {"val": "filling"}, ["apple", "cherry"] ]
+      },
+      "data": {"filling": "apple"},
+      "result": true
+  },
+  {
+      "description": "{\"var\":\"a.b.c\"}",
+      "rule": { "val": ["a", "b", "c"] },
+      "data": null,
+      "result": null
+  },
+  {
+      "description": "{\"var\":\"a.b.c\"}",
+      "rule": { "val": ["a", "b", "c"] },
+      "data": {"a": null},
+      "result": null
+  },
+  {
+      "description": "{\"var\":\"a.b.c\"}",
+      "rule": { "val": ["a", "b", "c"] },
+      "data": { "a": {"b": null} },
+      "result": null
+  },
+  { "description": "{\"var\":\"\"}", "rule": {"val": []}, "data": 1, "result": 1 },
+  { "description": "{\"val\":[]}", "rule": {"val": []}, "data": 1, "result": 1 },
+  {
+      "description": "{\"missing\":{\"merge\":[\"vin\",{\"if\":[{\"var\":\"financing\"},[\"apr\"],[]]}]}}",
+      "rule": {
+          "missing": {
+              "merge": [
+                  "vin",
+                  {
+                      "if": [ {"val": "financing"}, ["apr"], [] ]
+                  }
+              ]
+          }
+      },
+      "data": {"financing": true},
+      "result": ["vin", "apr"]
+  },
+  {
+      "description": "{\"missing\":{\"merge\":[\"vin\",{\"if\":[{\"var\":\"financing\"},[\"apr\"],[]]}]}}",
+      "rule": {
+          "missing": {
+              "merge": [
+                  "vin",
+                  {
+                      "if": [ {"val": "financing"}, ["apr"], [] ]
+                  }
+              ]
+          }
+      },
+      "data": {"financing": false},
+      "result": ["vin"]
+  },
+  {
+      "description": "{\"filter\":[{\"var\":\"integers\"},true]}",
+      "rule": {
+          "filter": [ {"val": "integers"}, true ]
+      },
+      "data": { "integers": [1, 2, 3] },
+      "result": [1, 2, 3]
+  },
+  {
+      "description": "{\"filter\":[{\"var\":\"integers\"},false]}",
+      "rule": {
+          "filter": [ {"val": "integers"}, false ]
+      },
+      "data": { "integers": [1, 2, 3] },
+      "result": []
+  },
+  {
+      "description": "{\"filter\":[{\"var\":\"integers\"},{\">=\":[{\"var\":\"\"},2]}]}",
+      "rule": {
+          "filter": [
+              {"val": "integers"},
+              {
+                  ">=": [ {"val": []}, 2 ]
+              }
+          ]
+      },
+      "data": { "integers": [1, 2, 3] },
+      "result": [2, 3]
+  },
+  {
+      "description": "{\"filter\":[{\"var\":\"integers\"},{\"%\":[{\"var\":\"\"},2]}]}",
+      "rule": {
+          "filter": [
+              {"val": "integers"},
+              {
+                  "%": [ {"val": []}, 2 ]
+              }
+          ]
+      },
+      "data": { "integers": [1, 2, 3] },
+      "result": [1, 3]
+  },
+  {
+      "description": "{\"map\":[{\"var\":\"integers\"},{\"*\":[{\"var\":\"\"},2]}]}",
+      "rule": {
+          "map": [
+              {"val": "integers"},
+              {
+                  "*": [ {"val": []}, 2 ]
+              }
+          ]
+      },
+      "data": { "integers": [1, 2, 3] },
+      "result": [2, 4, 6]
+  },
+  {
+      "description": "{\"map\":[{\"var\":\"integers\"},{\"*\":[{\"var\":\"\"},2]}]}",
+      "rule": {
+          "map": [
+              {"val": "integers"},
+              {
+                  "*": [ {"val": []}, 2 ]
+              }
+          ]
+      },
+      "data": null,
+      "result": []
+  },
+  {
+      "description": "{\"map\":[{\"var\":\"desserts\"},{\"var\":\"qty\"}]}",
+      "rule": {
+          "map": [ {"val": "desserts"}, {"val": "qty"} ]
+      },
+      "data": {
+          "desserts": [
+              {"name": "apple"  , "qty": 1},
+              {"name": "brownie", "qty": 2},
+              {"name": "cupcake", "qty": 3}
+          ]
+      },
+      "result": [1, 2, 3]
+  },
+  {
+      "description": "{\"reduce\":[{\"var\":\"integers\"},{\"+\":[{\"var\":\"current\"},{\"var\":\"accumulator\"}]},0]}",
+      "rule": {
+          "reduce": [
+              {"val": "integers"},
+              {
+                  "+": [ {"val": "current"}, {"val": "accumulator"} ]
+              },
+              0
+          ]
+      },
+      "data": { "integers": [1, 2, 3, 4] },
+      "result": 10
+  },
+  {
+      "description": "{\"reduce\":[{\"var\":\"integers\"},{\"+\":[{\"var\":\"current\"},{\"var\":\"accumulator\"}]},{\"var\":\"start_with\"}]}",
+      "rule": {
+          "reduce": [
+              {"val": "integers"},
+              {
+                  "+": [ {"val": "current"}, {"val": "accumulator"} ]
+              },
+              {"val": "start_with"}
+          ]
+      },
+      "data": { "integers": [1, 2, 3, 4], "start_with": 59 },
+      "result": 69
+  },
+  {
+      "description": "{\"reduce\":[{\"var\":\"integers\"},{\"+\":[{\"var\":\"current\"},{\"var\":\"accumulator\"}]},0]}",
+      "rule": {
+          "reduce": [
+              {"val": "integers"},
+              {
+                  "+": [ {"val": "current"}, {"val": "accumulator"} ]
+              },
+              0
+          ]
+      },
+      "data": null,
+      "result": 0
+  },
+  {
+      "description": "{\"reduce\":[{\"var\":\"integers\"},{\"*\":[{\"var\":\"current\"},{\"var\":\"accumulator\"}]},1]}",
+      "rule": {
+          "reduce": [
+              {"val": "integers"},
+              {
+                  "*": [ {"val": "current"}, {"val": "accumulator"} ]
+              },
+              1
+          ]
+      },
+      "data": { "integers": [1, 2, 3, 4] },
+      "result": 24
+  },
+  {
+      "description": "{\"reduce\":[{\"var\":\"integers\"},{\"*\":[{\"var\":\"current\"},{\"var\":\"accumulator\"}]},0]}",
+      "rule": {
+          "reduce": [
+              {"val": "integers"},
+              {
+                  "*": [ {"val": "current"}, {"val": "accumulator"} ]
+              },
+              0
+          ]
+      },
+      "data": { "integers": [1, 2, 3, 4] },
+      "result": 0
+  },
+  {
+      "description": "{\"reduce\":[{\"var\":\"desserts\"},{\"+\":[{\"var\":\"accumulator\"},{\"var\":\"current.qty\"}]},0]}",
+      "rule": {
+          "reduce": [
+              {"val": "desserts"},
+              {
+                  "+": [
+                      { "val": "accumulator"      },
+                      { "val": ["current", "qty"] }
+                  ]
+              },
+              0
+          ]
+      },
+      "data": {
+          "desserts": [
+              {"name": "apple"  , "qty": 1},
+              {"name": "brownie", "qty": 2},
+              {"name": "cupcake", "qty": 3}
+          ]
+      },
+      "result": 6
+  },
+  {
+      "description": "{\"all\":[{\"var\":\"integers\"},{\">=\":[{\"var\":\"\"},1]}]}",
+      "rule": {
+          "all": [
+              {"val": "integers"},
+              {
+                  ">=": [ {"val": []}, 1 ]
+              }
+          ]
+      },
+      "data": { "integers": [1, 2, 3] },
+      "result": true
+  },
+  {
+      "description": "{\"all\":[{\"var\":\"integers\"},{\"==\":[{\"var\":\"\"},1]}]}",
+      "rule": {
+          "all": [
+              {"val": "integers"},
+              {
+                  "==": [ {"val": []}, 1 ]
+              }
+          ]
+      },
+      "data": { "integers": [1, 2, 3] },
+      "result": false
+  },
+  {
+      "description": "{\"all\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}",
+      "rule": {
+          "all": [
+              {"val": "integers"},
+              {
+                  "<": [ {"val": []}, 1 ]
+              }
+          ]
+      },
+      "data": { "integers": [1, 2, 3] },
+      "result": false
+  },
+  {
+      "description": "{\"all\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}",
+      "rule": {
+          "all": [
+              {"val": "integers"},
+              {
+                  "<": [ {"val": []}, 1 ]
+              }
+          ]
+      },
+      "data": {"integers": []},
+      "result": false
+  },
+  {
+      "description": "{\"all\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}",
+      "rule": {
+          "all": [
+              {"val": "items"},
+              {
+                  ">=": [ {"val": "qty"}, 1 ]
+              }
+          ]
+      },
+      "data": {
+          "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]
+      },
+      "result": true
+  },
+  {
+      "description": "{\"all\":[{\"var\":\"items\"},{\">\":[{\"var\":\"qty\"},1]}]}",
+      "rule": {
+          "all": [
+              {"val": "items"},
+              {
+                  ">": [ {"val": "qty"}, 1 ]
+              }
+          ]
+      },
+      "data": {
+          "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]
+      },
+      "result": false
+  },
+  {
+      "description": "{\"all\":[{\"var\":\"items\"},{\"<\":[{\"var\":\"qty\"},1]}]}",
+      "rule": {
+          "all": [
+              {"val": "items"},
+              {
+                  "<": [ {"val": "qty"}, 1 ]
+              }
+          ]
+      },
+      "data": {
+          "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]
+      },
+      "result": false
+  },
+  {
+      "description": "{\"all\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}",
+      "rule": {
+          "all": [
+              {"val": "items"},
+              {
+                  ">=": [ {"val": "qty"}, 1 ]
+              }
+          ]
+      },
+      "data": {"items": []},
+      "result": false
+  },
+  {
+      "description": "{\"none\":[{\"var\":\"integers\"},{\">=\":[{\"var\":\"\"},1]}]}",
+      "rule": {
+          "none": [
+              {"val": "integers"},
+              {
+                  ">=": [ {"val": []}, 1 ]
+              }
+          ]
+      },
+      "data": { "integers": [1, 2, 3] },
+      "result": false
+  },
+  {
+      "description": "{\"none\":[{\"var\":\"integers\"},{\"==\":[{\"var\":\"\"},1]}]}",
+      "rule": {
+          "none": [
+              {"val": "integers"},
+              {
+                  "==": [ {"val": []}, 1 ]
+              }
+          ]
+      },
+      "data": { "integers": [1, 2, 3] },
+      "result": false
+  },
+  {
+      "description": "{\"none\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}",
+      "rule": {
+          "none": [
+              {"val": "integers"},
+              {
+                  "<": [ {"val": []}, 1 ]
+              }
+          ]
+      },
+      "data": { "integers": [1, 2, 3] },
+      "result": true
+  },
+  {
+      "description": "{\"none\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}",
+      "rule": {
+          "none": [
+              {"val": "integers"},
+              {
+                  "<": [ {"val": []}, 1 ]
+              }
+          ]
+      },
+      "data": {"integers": []},
+      "result": true
+  },
+  {
+      "description": "{\"none\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}",
+      "rule": {
+          "none": [
+              {"val": "items"},
+              {
+                  ">=": [ {"val": "qty"}, 1 ]
+              }
+          ]
+      },
+      "data": {
+          "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]
+      },
+      "result": false
+  },
+  {
+      "description": "{\"none\":[{\"var\":\"items\"},{\">\":[{\"var\":\"qty\"},1]}]}",
+      "rule": {
+          "none": [
+              {"val": "items"},
+              {
+                  ">": [ {"val": "qty"}, 1 ]
+              }
+          ]
+      },
+      "data": {
+          "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]
+      },
+      "result": false
+  },
+  {
+      "description": "{\"none\":[{\"var\":\"items\"},{\"<\":[{\"var\":\"qty\"},1]}]}",
+      "rule": {
+          "none": [
+              {"val": "items"},
+              {
+                  "<": [ {"val": "qty"}, 1 ]
+              }
+          ]
+      },
+      "data": {
+          "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]
+      },
+      "result": true
+  },
+  {
+      "description": "{\"none\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}",
+      "rule": {
+          "none": [
+              {"val": "items"},
+              {
+                  ">=": [ {"val": "qty"}, 1 ]
+              }
+          ]
+      },
+      "data": {"items": []},
+      "result": true
+  },
+  {
+      "description": "{\"some\":[{\"var\":\"integers\"},{\">=\":[{\"var\":\"\"},1]}]}",
+      "rule": {
+          "some": [
+              {"val": "integers"},
+              {
+                  ">=": [ {"val": []}, 1 ]
+              }
+          ]
+      },
+      "data": { "integers": [1, 2, 3] },
+      "result": true
+  },
+  {
+      "description": "{\"some\":[{\"var\":\"integers\"},{\"==\":[{\"var\":\"\"},1]}]}",
+      "rule": {
+          "some": [
+              {"val": "integers"},
+              {
+                  "==": [ {"val": []}, 1 ]
+              }
+          ]
+      },
+      "data": { "integers": [1, 2, 3] },
+      "result": true
+  },
+  {
+      "description": "{\"some\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}",
+      "rule": {
+          "some": [
+              {"val": "integers"},
+              {
+                  "<": [ {"val": []}, 1 ]
+              }
+          ]
+      },
+      "data": { "integers": [1, 2, 3] },
+      "result": false
+  },
+  {
+      "description": "{\"some\":[{\"var\":\"integers\"},{\"<\":[{\"var\":\"\"},1]}]}",
+      "rule": {
+          "some": [
+              {"val": "integers"},
+              {
+                  "<": [ {"val": []}, 1 ]
+              }
+          ]
+      },
+      "data": {"integers": []},
+      "result": false
+  },
+  {
+      "description": "{\"some\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}",
+      "rule": {
+          "some": [
+              {"val": "items"},
+              {
+                  ">=": [ {"val": "qty"}, 1 ]
+              }
+          ]
+      },
+      "data": {
+          "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]
+      },
+      "result": true
+  },
+  {
+      "description": "{\"some\":[{\"var\":\"items\"},{\">\":[{\"var\":\"qty\"},1]}]}",
+      "rule": {
+          "some": [
+              {"val": "items"},
+              {
+                  ">": [ {"val": "qty"}, 1 ]
+              }
+          ]
+      },
+      "data": {
+          "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]
+      },
+      "result": true
+  },
+  {
+      "description": "{\"some\":[{\"var\":\"items\"},{\"<\":[{\"var\":\"qty\"},1]}]}",
+      "rule": {
+          "some": [
+              {"val": "items"},
+              {
+                  "<": [ {"val": "qty"}, 1 ]
+              }
+          ]
+      },
+      "data": {
+          "items": [ {"qty": 1, "sku": "apple"}, {"qty": 2, "sku": "banana"} ]
+      },
+      "result": false
+  },
+  {
+      "description": "{\"some\":[{\"var\":\"items\"},{\">=\":[{\"var\":\"qty\"},1]}]}",
+      "rule": {
+          "some": [
+              {"val": "items"},
+              {
+                  ">=": [ {"val": "qty"}, 1 ]
+              }
+          ]
+      },
+      "data": {"items": []},
+      "result": false
+  }
+]
diff --git a/test/community/val.extra.json b/test/community/val.extra.json
new file mode 100644
--- /dev/null
+++ b/test/community/val.extra.json
@@ -0,0 +1,23 @@
+[
+    "Testing out scopes",
+    {
+        "description": "Climb up to get adder",
+        "rule": { "map": [[1,2,3], { "+": [{ "val": [] }, { "val": [[-2], "adder"] }] }] },
+        "data": { "adder": 10 },
+        "result": [11,12,13]
+    },
+    {
+        "description": "Climb up to get index",
+        "rule": { "map": [[1,2,3], { "+": [{ "val": [] }, { "val": [[-1], "index"] }] }] },
+        "data": { "adder": 10 },
+        "result": [1,3,5]
+    },
+    {
+        "description": "Nested get adder",
+        "rule": {
+            "map": [["Test"], { "map": [[1,2,3], { "+": [{"val": []}, {"val": [[-4], "adder"]}] }]} ]
+        },
+        "data": { "adder": 10 },
+        "result": [[11,12,13]]
+    }
+]
diff --git a/test/community/val.json b/test/community/val.json
new file mode 100644
--- /dev/null
+++ b/test/community/val.json
@@ -0,0 +1,81 @@
+[
+    "Test Specification for val",
+    {
+        "description": "Fetches a value",
+        "rule": { "val": "hello" },
+        "data": { "hello" : 0 },
+        "result": 0
+    },
+    {
+        "description": "Fetches a nested value",
+        "rule": { "val": ["hello", "world"] },
+        "data": { "hello" : { "world": 1 } },
+        "result": 1
+    },
+    {
+        "description": "Fetches a value from an empty key",
+        "rule": { "val": "" },
+        "data": { "" : 1 },
+        "result": 1
+    },
+    {
+        "description": "Fetches a value from a nested empty key",
+        "rule": { "val": ["", ""] },
+        "data": { "" : { "": 2 } },
+        "result": 2
+    },
+    {
+        "description": "Fetches a value from an array",
+        "rule": { "val": [1] },
+        "data": [1, 2],
+        "result": 2
+    },
+    {
+        "description": "Fetches a value from an array in an object",
+        "rule": { "val": ["arr", 1] },
+        "data": { "arr": [1, 2] },
+        "result": 2
+    },
+    {
+        "description": "Fetches a value from a doubly nested empty key",
+        "rule": { "val": ["", "", ""] },
+        "data": { "" : { "": { "": 3 } } },
+        "result": 3
+    },
+    {
+        "description": "Fetches a value from a key that is purely a dot",
+        "rule": { "val": "." },
+        "data": { "." : 20 },
+        "result": 20
+    },
+    {
+        "description": "Fetching a value from null returns null",
+        "rule": { "val": "hello" },
+        "data": { "hello" : null },
+        "result": null
+    },
+    {
+        "description": "Fetching a value from a null fetched value returns null",
+        "rule": { "val": ["hello", "world"] },
+        "data": { "hello" : null },
+        "result": null
+    },
+    {
+        "description": "Fetches the entire context",
+        "rule": { "val": [] },
+        "data": { "": 21 },
+        "result": { "": 21 }
+    },
+    {
+        "description": "Fetches the entire context for a nested key",
+        "rule": { "val": "" },
+        "data": { "": { "": 22 } },
+        "result": { "": 22 }
+    },
+    {
+        "description": "Using val in a map",
+        "rule": { "map": [[1,2,3], { "+": [{ "val": [] }, 1] }] },
+        "data": null,
+        "result": [2,3,4]
+    }
+]
diff --git a/test/community/var.extra.json b/test/community/var.extra.json
new file mode 100644
--- /dev/null
+++ b/test/community/var.extra.json
@@ -0,0 +1,74 @@
+[
+  {
+    "description": "var returns false when key exists with false value",
+    "rule": { "var": "active" },
+    "data": { "active": false },
+    "result": false
+  },
+  {
+    "description": "var returns 0 when key exists with 0 value",
+    "rule": { "var": "count" },
+    "data": { "count": 0 },
+    "result": 0
+  },
+  {
+    "description": "var returns empty string when key exists with empty string",
+    "rule": { "var": "name" },
+    "data": { "name": "" },
+    "result": ""
+  },
+  {
+    "description": "var with default does NOT use default when value is false",
+    "rule": { "var": ["active", true] },
+    "data": { "active": false },
+    "result": false
+  },
+  {
+    "description": "var with default does NOT use default when value is 0",
+    "rule": { "var": ["count", 99] },
+    "data": { "count": 0 },
+    "result": 0
+  },
+  {
+    "description": "var with default does NOT use default when value is empty string",
+    "rule": { "var": ["name", "anonymous"] },
+    "data": { "name": "" },
+    "result": ""
+  },
+  {
+    "description": "var with default DOES use default when key is missing",
+    "rule": { "var": ["missing", "default"] },
+    "data": { "other": "value" },
+    "result": "default"
+  },
+  {
+    "description": "nested var returns false from nested path",
+    "rule": { "var": "user.active" },
+    "data": { "user": { "active": false } },
+    "result": false
+  },
+  {
+    "description": "nested var returns 0 from nested path",
+    "rule": { "var": "user.score" },
+    "data": { "user": { "score": 0 } },
+    "result": 0
+  },
+  {
+    "description": "equality check with false value from var",
+    "rule": { "==": [{ "var": "is_beta" }, false] },
+    "data": { "is_beta": false },
+    "result": true
+  },
+  {
+    "description": "negation of false value from var",
+    "rule": { "!": [{ "var": "disabled" }] },
+    "data": { "disabled": false },
+    "result": true
+  },
+  {
+    "description": "and with false value from var",
+    "rule": { "and": [{ "var": "enabled" }, true] },
+    "data": { "enabled": false },
+    "result": false
+  }
+]
diff --git a/test/outliers.jsonl b/test/outliers.jsonl
new file mode 100644
# file too large to diff: test/outliers.jsonl
diff --git a/test/tests.json b/test/tests.json
new file mode 100644
--- /dev/null
+++ b/test/tests.json
@@ -0,0 +1,532 @@
+[
+    "# Non-rules get passed through",
+    [ true, {}, true ],
+    [ false, {}, false ],
+    [ 17, {}, 17 ],
+    [ 3.14, {}, 3.14 ],
+    [ "apple", {}, "apple" ],
+    [ null, {}, null ],
+    [ ["a","b"], {}, ["a","b"] ],
+
+    "# Single operator tests",
+    [ {"==":[1,1]}, {}, true ],
+    [ {"==":[1,"1"]}, {}, true ],
+    [ {"==":[1,2]}, {}, false ],
+    [ {"===":[1,1]}, {}, true ],
+    [ {"===":[1,"1"]}, {}, false ],
+    [ {"===":[1,2]}, {}, false ],
+    [ {"!=":[1,2]}, {}, true ],
+    [ {"!=":[1,1]}, {}, false ],
+    [ {"!=":[1,"1"]}, {}, false ],
+    [ {"!==":[1,2]}, {}, true ],
+    [ {"!==":[1,1]}, {}, false ],
+    [ {"!==":[1,"1"]}, {}, true ],
+    [ {">":[2,1]}, {}, true ],
+    [ {">":[1,1]}, {}, false ],
+    [ {">":[1,2]}, {}, false ],
+    [ {">":["2",1]}, {}, true ],
+    [ {">=":[2,1]}, {}, true ],
+    [ {">=":[1,1]}, {}, true ],
+    [ {">=":[1,2]}, {}, false ],
+    [ {">=":["2",1]}, {}, true ],
+    [ {"<":[2,1]}, {}, false ],
+    [ {"<":[1,1]}, {}, false ],
+    [ {"<":[1,2]}, {}, true ],
+    [ {"<":["1",2]}, {}, true ],
+    [ {"<":[1,2,3]}, {}, true ],
+    [ {"<":[1,1,3]}, {}, false ],
+    [ {"<":[1,4,3]}, {}, false ],
+    [ {"<=":[2,1]}, {}, false ],
+    [ {"<=":[1,1]}, {}, true ],
+    [ {"<=":[1,2]}, {}, true ],
+    [ {"<=":["1",2]}, {}, true ],
+    [ {"<=":[1,2,3]}, {}, true ],
+    [ {"<=":[1,4,3]}, {}, false ],
+    [ {"!":[false]}, {}, true ],
+    [ {"!":false}, {}, true ],
+    [ {"!":[true]}, {}, false ],
+    [ {"!":true}, {}, false ],
+    [ {"!":0}, {}, true ],
+    [ {"!":1}, {}, false ],
+    [ {"or":[true,true]}, {}, true ],
+    [ {"or":[false,true]}, {}, true ],
+    [ {"or":[true,false]}, {}, true ],
+    [ {"or":[false,false]}, {}, false ],
+    [ {"or":[false,false,true]}, {}, true ],
+    [ {"or":[false,false,false]}, {}, false ],
+    [ {"or":[false]}, {}, false ],
+    [ {"or":[true]}, {}, true ],
+    [ {"or":[1,3]}, {}, 1 ],
+    [ {"or":[3,false]}, {}, 3 ],
+    [ {"or":[false,3]}, {}, 3 ],
+    [ {"and":[true,true]}, {}, true ],
+    [ {"and":[false,true]}, {}, false ],
+    [ {"and":[true,false]}, {}, false ],
+    [ {"and":[false,false]}, {}, false ],
+    [ {"and":[true,true,true]}, {}, true ],
+    [ {"and":[true,true,false]}, {}, false ],
+    [ {"and":[false]}, {}, false ],
+    [ {"and":[true]}, {}, true ],
+    [ {"and":[1,3]}, {}, 3 ],
+    [ {"and":[3,false]}, {}, false ],
+    [ {"and":[false,3]}, {}, false ],
+    [ {"?:":[true,1,2]}, {}, 1 ],
+    [ {"?:":[false,1,2]}, {}, 2 ],
+    [ {"in":["Bart",["Bart","Homer","Lisa","Marge","Maggie"]]}, {}, true ],
+    [ {"in":["Milhouse",["Bart","Homer","Lisa","Marge","Maggie"]]}, {}, false ],
+    [ {"in":["Spring","Springfield"]}, {}, true ],
+    [ {"in":["i","team"]}, {}, false ],
+    [ {"cat":"ice"}, {}, "ice" ],
+    [ {"cat":["ice"]}, {}, "ice" ],
+    [ {"cat":["ice","cream"]}, {}, "icecream" ],
+    [ {"cat":[1,2]}, {}, "12" ],
+    [ {"cat":["Robocop",2]}, {}, "Robocop2" ],
+    [ {"cat":["we all scream for ","ice","cream"]}, {}, "we all scream for icecream" ],
+    [ {"%":[1,2]}, {}, 1 ],
+    [ {"%":[2,2]}, {}, 0 ],
+    [ {"%":[3,2]}, {}, 1 ],
+    [ {"max":[1,2,3]}, {}, 3 ],
+    [ {"max":[1,3,3]}, {}, 3 ],
+    [ {"max":[3,2,1]}, {}, 3 ],
+    [ {"max":[1]}, {}, 1 ],
+    [ {"min":[1,2,3]}, {}, 1 ],
+    [ {"min":[1,1,3]}, {}, 1 ],
+    [ {"min":[3,2,1]}, {}, 1 ],
+    [ {"min":[1]}, {}, 1 ],
+
+    [ {"+":[1,2]}, {}, 3 ],
+    [ {"+":[2,2,2]}, {}, 6 ],
+    [ {"+":[1]}, {}, 1 ],
+    [ {"+":["1",1]}, {}, 2 ],
+    [ {"*":[3,2]}, {}, 6 ],
+    [ {"*":[2,2,2]}, {}, 8 ],
+    [ {"*":[1]}, {}, 1 ],
+    [ {"*":["1",1]}, {}, 1 ],
+    [ {"-":[2,3]}, {}, -1 ],
+    [ {"-":[3,2]}, {}, 1 ],
+    [ {"-":[3]}, {}, -3 ],
+    [ {"-":["1",1]}, {}, 0 ],
+    [ {"/":[4,2]}, {}, 2 ],
+    [ {"/":[2,4]}, {}, 0.5 ],
+    [ {"/":["1",1]}, {}, 1 ],
+
+    "Substring",
+    [{"substr":["jsonlogic", 4]}, null, "logic"],
+    [{"substr":["jsonlogic", -5]}, null, "logic"],
+    [{"substr":["jsonlogic", 0, 1]}, null, "j"],
+    [{"substr":["jsonlogic", -1, 1]}, null, "c"],
+    [{"substr":["jsonlogic", 4, 5]}, null, "logic"],
+    [{"substr":["jsonlogic", -5, 5]}, null, "logic"],
+    [{"substr":["jsonlogic", -5, -2]}, null, "log"],
+    [{"substr":["jsonlogic", 1, -5]}, null, "son"],
+
+    "Merge arrays",
+    [{"merge":[]}, null, []],
+    [{"merge":[[1]]}, null, [1]],
+    [{"merge":[[1],[]]}, null, [1]],
+    [{"merge":[[1], [2]]}, null, [1,2]],
+    [{"merge":[[1], [2], [3]]}, null, [1,2,3]],
+    [{"merge":[[1, 2], [3]]}, null, [1,2,3]],
+    [{"merge":[[1], [2, 3]]}, null, [1,2,3]],
+    "Given non-array arguments, merge converts them to arrays",
+    [{"merge":1}, null, [1]],
+    [{"merge":[1,2]}, null, [1,2]],
+    [{"merge":[1,[2]]}, null, [1,2]],
+
+    "Too few args",
+    [{"if":[]}, null, null],
+    [{"if":[true]}, null, true],
+    [{"if":[false]}, null, false],
+    [{"if":["apple"]}, null, "apple"],
+
+    "Simple if/then/else cases",
+    [{"if":[true, "apple"]}, null, "apple"],
+    [{"if":[false, "apple"]}, null, null],
+    [{"if":[true, "apple", "banana"]}, null, "apple"],
+    [{"if":[false, "apple", "banana"]}, null, "banana"],
+
+    "Empty arrays are falsey",
+    [{"if":[ [], "apple", "banana"]}, null, "banana"],
+    [{"if":[ [1], "apple", "banana"]}, null, "apple"],
+    [{"if":[ [1,2,3,4], "apple", "banana"]}, null, "apple"],
+
+    "Empty strings are falsey, all other strings are truthy",
+    [{"if":[ "", "apple", "banana"]}, null, "banana"],
+    [{"if":[ "zucchini", "apple", "banana"]}, null, "apple"],
+    [{"if":[ "0", "apple", "banana"]}, null, "apple"],
+
+    "You can cast a string to numeric with a unary + ",
+    [{"===":[0,"0"]}, null, false],
+    [{"===":[0,{"+":"0"}]}, null, true],
+    [{"if":[ {"+":"0"}, "apple", "banana"]}, null, "banana"],
+    [{"if":[ {"+":"1"}, "apple", "banana"]}, null, "apple"],
+
+    "Zero is falsy, all other numbers are truthy",
+    [{"if":[ 0, "apple", "banana"]}, null, "banana"],
+    [{"if":[ 1, "apple", "banana"]}, null, "apple"],
+    [{"if":[ 3.1416, "apple", "banana"]}, null, "apple"],
+    [{"if":[ -1, "apple", "banana"]}, null, "apple"],
+
+    "Truthy and falsy definitions matter in Boolean operations",
+    [{"!" : [ [] ]}, {}, true],
+    [{"!!" : [ [] ]}, {}, false],
+    [{"and" : [ [], true ]}, {}, [] ],
+    [{"or" : [ [], true ]}, {}, true ],
+
+    [{"!" : [ 0 ]}, {}, true],
+    [{"!!" : [ 0 ]}, {}, false],
+    [{"and" : [ 0, true ]}, {}, 0 ],
+    [{"or" : [ 0, true ]}, {}, true ],
+
+    [{"!" : [ "" ]}, {}, true],
+    [{"!!" : [ "" ]}, {}, false],
+    [{"and" : [ "", true ]}, {}, "" ],
+    [{"or" : [ "", true ]}, {}, true ],
+
+    [{"!" : [ "0" ]}, {}, false],
+    [{"!!" : [ "0" ]}, {}, true],
+    [{"and" : [ "0", true ]}, {}, true ],
+    [{"or" : [ "0", true ]}, {}, "0" ],
+
+    "If the conditional is logic, it gets evaluated",
+    [{"if":[ {">":[2,1]}, "apple", "banana"]}, null, "apple"],
+    [{"if":[ {">":[1,2]}, "apple", "banana"]}, null, "banana"],
+
+    "If the consequents are logic, they get evaluated",
+    [{"if":[ true, {"cat":["ap","ple"]}, {"cat":["ba","na","na"]} ]}, null, "apple"],
+    [{"if":[ false, {"cat":["ap","ple"]}, {"cat":["ba","na","na"]} ]}, null, "banana"],
+
+    "If/then/elseif/then cases",
+    [{"if":[true, "apple", true, "banana"]}, null, "apple"],
+    [{"if":[true, "apple", false, "banana"]}, null, "apple"],
+    [{"if":[false, "apple", true, "banana"]}, null, "banana"],
+    [{"if":[false, "apple", false, "banana"]}, null, null],
+
+    [{"if":[true, "apple", true, "banana", "carrot"]}, null, "apple"],
+    [{"if":[true, "apple", false, "banana", "carrot"]}, null, "apple"],
+    [{"if":[false, "apple", true, "banana", "carrot"]}, null, "banana"],
+    [{"if":[false, "apple", false, "banana", "carrot"]}, null, "carrot"],
+
+    [{"if":[false, "apple", false, "banana", false, "carrot"]}, null, null],
+    [{"if":[false, "apple", false, "banana", false, "carrot", "date"]}, null, "date"],
+    [{"if":[false, "apple", false, "banana", true, "carrot", "date"]}, null, "carrot"],
+    [{"if":[false, "apple", true, "banana", false, "carrot", "date"]}, null, "banana"],
+    [{"if":[false, "apple", true, "banana", true, "carrot", "date"]}, null, "banana"],
+    [{"if":[true, "apple", false, "banana", false, "carrot", "date"]}, null, "apple"],
+    [{"if":[true, "apple", false, "banana", true, "carrot", "date"]}, null, "apple"],
+    [{"if":[true, "apple", true, "banana", false, "carrot", "date"]}, null, "apple"],
+    [{"if":[true, "apple", true, "banana", true, "carrot", "date"]}, null, "apple"],
+
+    "Arrays with logic",
+    [[1, {"var": "x"}, 3], {"x": 2}, [1, 2, 3]],
+    [{"if": [{"var": "x"}, [{"var": "y"}], 99]}, {"x": true, "y": 42}, [42]],
+
+    "# Compound Tests",
+    [ {"and":[{">":[3,1]},true]}, {}, true ],
+    [ {"and":[{">":[3,1]},false]}, {}, false ],
+    [ {"and":[{">":[3,1]},{"!":true}]}, {}, false ],
+    [ {"and":[{">":[3,1]},{"<":[1,3]}]}, {}, true ],
+    [ {"?:":[{">":[3,1]},"visible","hidden"]}, {}, "visible" ],
+
+    "# Data-Driven",
+    [ {"var":["a"]},{"a":1},1 ],
+    [ {"var":["b"]},{"a":1},null ],
+    [ {"var":["a"]},null,null ],
+    [ {"var":"a"},{"a":1},1 ],
+    [ {"var":"b"},{"a":1},null ],
+    [ {"var":"a"},null,null ],
+    [ {"var":["a", 1]},null,1 ],
+    [ {"var":["b", 2]},{"a":1},2 ],
+    [ {"var":"a.b"},{"a":{"b":"c"}},"c" ],
+    [ {"var":"a.q"},{"a":{"b":"c"}},null ],
+    [ {"var":["a.q", 9]},{"a":{"b":"c"}},9 ],
+    [ {"var":1}, ["apple","banana"], "banana" ],
+    [ {"var":"1"}, ["apple","banana"], "banana" ],
+    [ {"var":"1.1"}, ["apple",["banana","beer"]], "beer" ],
+    [ {"and":[{"<":[{"var":"temp"},110]},{"==":[{"var":"pie.filling"},"apple"]}]},{"temp":100,"pie":{"filling":"apple"}},true ],
+    [ {"var":[{"?:":[{"<":[{"var":"temp"},110]},"pie.filling","pie.eta"]}]},{"temp":100,"pie":{"filling":"apple","eta":"60s"}},"apple" ],
+    [ {"in":[{"var":"filling"},["apple","cherry"]]},{"filling":"apple"},true ],
+    [ {"var":"a.b.c"}, null, null ],
+    [ {"var":"a.b.c"}, {"a":null}, null ],
+    [ {"var":"a.b.c"}, {"a":{"b":null}}, null ],
+    [ {"var":""}, 1, 1 ],
+    [ {"var":null}, 1, 1 ],
+    [ {"var":[]}, 1, 1 ],
+
+    "Missing",
+    [{"missing":[]}, null, []],
+    [{"missing":["a"]}, null, ["a"]],
+    [{"missing":"a"}, null, ["a"]],
+    [{"missing":"a"}, {"a":"apple"}, []],
+    [{"missing":["a"]}, {"a":"apple"}, []],
+    [{"missing":["a","b"]}, {"a":"apple"}, ["b"]],
+    [{"missing":["a","b"]}, {"b":"banana"}, ["a"]],
+    [{"missing":["a","b"]}, {"a":"apple", "b":"banana"}, []],
+    [{"missing":["a","b"]}, {}, ["a","b"]],
+    [{"missing":["a","b"]}, null, ["a","b"]],
+
+    [{"missing":["a.b"]}, null, ["a.b"]],
+    [{"missing":["a.b"]}, {"a":"apple"}, ["a.b"]],
+    [{"missing":["a.b"]}, {"a":{"c":"apple cake"}}, ["a.b"]],
+    [{"missing":["a.b"]}, {"a":{"b":"apple brownie"}}, []],
+    [{"missing":["a.b", "a.c"]}, {"a":{"b":"apple brownie"}}, ["a.c"]],
+
+
+    "Missing some",
+    [{"missing_some":[1, ["a", "b"]]}, {"a":"apple"}, [] ],
+    [{"missing_some":[1, ["a", "b"]]}, {"b":"banana"}, [] ],
+    [{"missing_some":[1, ["a", "b"]]}, {"a":"apple", "b":"banana"}, [] ],
+    [{"missing_some":[1, ["a", "b"]]}, {"c":"carrot"}, ["a", "b"]],
+
+    [{"missing_some":[2, ["a", "b", "c"]]}, {"a":"apple", "b":"banana"}, [] ],
+    [{"missing_some":[2, ["a", "b", "c"]]}, {"a":"apple", "c":"carrot"}, [] ],
+    [{"missing_some":[2, ["a", "b", "c"]]}, {"a":"apple", "b":"banana", "c":"carrot"}, [] ],
+    [{"missing_some":[2, ["a", "b", "c"]]}, {"a":"apple", "d":"durian"}, ["b", "c"] ],
+    [{"missing_some":[2, ["a", "b", "c"]]}, {"d":"durian", "e":"eggplant"}, ["a", "b", "c"] ],
+
+
+    "Missing and If are friends, because empty arrays are falsey in JsonLogic",
+    [{"if":[ {"missing":"a"}, "missed it", "found it" ]}, {"a":"apple"}, "found it"],
+    [{"if":[ {"missing":"a"}, "missed it", "found it" ]}, {"b":"banana"}, "missed it"],
+
+    "Missing, Merge, and If are friends. VIN is always required, APR is only required if financing is true.",
+    [
+        {"missing":{"merge":[ "vin", {"if": [{"var":"financing"}, ["apr"], [] ]} ]} },
+        {"financing":true},
+        ["vin","apr"]
+    ],
+
+    [
+        {"missing":{"merge":[ "vin", {"if": [{"var":"financing"}, ["apr"], [] ]} ]} },
+        {"financing":false},
+        ["vin"]
+    ],
+
+    "Filter, map, all, none, and some",
+    [
+        {"filter":[{"var":"integers"}, true]},
+        {"integers":[1,2,3]},
+        [1,2,3]
+    ],
+    [
+        {"filter":[{"var":"integers"}, false]},
+        {"integers":[1,2,3]},
+        []
+    ],
+    [
+        {"filter":[{"var":"integers"}, {">=":[{"var":""},2]}]},
+        {"integers":[1,2,3]},
+        [2,3]
+    ],
+    [
+        {"filter":[{"var":"integers"}, {"%":[{"var":""},2]}]},
+        {"integers":[1,2,3]},
+        [1,3]
+    ],
+
+    [
+        {"map":[{"var":"integers"}, {"*":[{"var":""},2]}]},
+        {"integers":[1,2,3]},
+        [2,4,6]
+    ],
+    [
+        {"map":[{"var":"integers"}, {"*":[{"var":""},2]}]},
+        null,
+        []
+    ],
+    [
+        {"map":[{"var":"desserts"}, {"var":"qty"}]},
+        {"desserts":[
+            {"name":"apple","qty":1},
+            {"name":"brownie","qty":2},
+            {"name":"cupcake","qty":3}
+        ]},
+        [1,2,3]
+    ],
+
+    [
+        {"reduce":[
+            {"var":"integers"},
+            {"+":[{"var":"current"}, {"var":"accumulator"}]},
+            0
+        ]},
+        {"integers":[1,2,3,4]},
+        10
+    ],
+    [
+        {"reduce":[
+            {"var":"integers"},
+            {"+":[{"var":"current"}, {"var":"accumulator"}]},
+            {"var": "start_with"}
+        ]},
+        {"integers":[1,2,3,4], "start_with": 59},
+        69
+    ],
+    [
+        {"reduce":[
+            {"var":"integers"},
+            {"+":[{"var":"current"}, {"var":"accumulator"}]},
+            0
+        ]},
+        null,
+        0
+    ],
+    [
+        {"reduce":[
+            {"var":"integers"},
+            {"*":[{"var":"current"}, {"var":"accumulator"}]},
+            1
+        ]},
+        {"integers":[1,2,3,4]},
+        24
+    ],
+    [
+        {"reduce":[
+            {"var":"integers"},
+            {"*":[{"var":"current"}, {"var":"accumulator"}]},
+            0
+        ]},
+        {"integers":[1,2,3,4]},
+        0
+    ],
+    [
+        {"reduce": [
+            {"var":"desserts"},
+            {"+":[ {"var":"accumulator"}, {"var":"current.qty"}]},
+            0
+        ]},
+        {"desserts":[
+            {"name":"apple","qty":1},
+            {"name":"brownie","qty":2},
+            {"name":"cupcake","qty":3}
+        ]},
+        6
+    ],
+
+
+    [
+        {"all":[{"var":"integers"}, {">=":[{"var":""}, 1]}]},
+        {"integers":[1,2,3]},
+        true
+    ],
+    [
+        {"all":[{"var":"integers"}, {"==":[{"var":""}, 1]}]},
+        {"integers":[1,2,3]},
+        false
+    ],
+    [
+        {"all":[{"var":"integers"}, {"<":[{"var":""}, 1]}]},
+        {"integers":[1,2,3]},
+        false
+    ],
+    [
+        {"all":[{"var":"integers"}, {"<":[{"var":""}, 1]}]},
+        {"integers":[]},
+        false
+    ],
+    [
+        {"all":[ {"var":"items"}, {">=":[{"var":"qty"}, 1]}]},
+        {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]},
+        true
+    ],
+    [
+        {"all":[ {"var":"items"}, {">":[{"var":"qty"}, 1]}]},
+        {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]},
+        false
+    ],
+    [
+        {"all":[ {"var":"items"}, {"<":[{"var":"qty"}, 1]}]},
+        {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]},
+        false
+    ],
+    [
+        {"all":[ {"var":"items"}, {">=":[{"var":"qty"}, 1]}]},
+        {"items":[]},
+        false
+    ],
+
+
+    [
+        {"none":[{"var":"integers"}, {">=":[{"var":""}, 1]}]},
+        {"integers":[1,2,3]},
+        false
+    ],
+    [
+        {"none":[{"var":"integers"}, {"==":[{"var":""}, 1]}]},
+        {"integers":[1,2,3]},
+        false
+    ],
+    [
+        {"none":[{"var":"integers"}, {"<":[{"var":""}, 1]}]},
+        {"integers":[1,2,3]},
+        true
+    ],
+    [
+        {"none":[{"var":"integers"}, {"<":[{"var":""}, 1]}]},
+        {"integers":[]},
+        true
+    ],
+    [
+        {"none":[ {"var":"items"}, {">=":[{"var":"qty"}, 1]}]},
+        {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]},
+        false
+    ],
+    [
+        {"none":[ {"var":"items"}, {">":[{"var":"qty"}, 1]}]},
+        {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]},
+        false
+    ],
+    [
+        {"none":[ {"var":"items"}, {"<":[{"var":"qty"}, 1]}]},
+        {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]},
+        true
+    ],
+    [
+        {"none":[ {"var":"items"}, {">=":[{"var":"qty"}, 1]}]},
+        {"items":[]},
+        true
+    ],
+
+    [
+        {"some":[{"var":"integers"}, {">=":[{"var":""}, 1]}]},
+        {"integers":[1,2,3]},
+        true
+    ],
+    [
+        {"some":[{"var":"integers"}, {"==":[{"var":""}, 1]}]},
+        {"integers":[1,2,3]},
+        true
+    ],
+    [
+        {"some":[{"var":"integers"}, {"<":[{"var":""}, 1]}]},
+        {"integers":[1,2,3]},
+        false
+    ],
+    [
+        {"some":[{"var":"integers"}, {"<":[{"var":""}, 1]}]},
+        {"integers":[]},
+        false
+    ],
+    [
+        {"some":[ {"var":"items"}, {">=":[{"var":"qty"}, 1]}]},
+        {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]},
+        true
+    ],
+    [
+        {"some":[ {"var":"items"}, {">":[{"var":"qty"}, 1]}]},
+        {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]},
+        true
+    ],
+    [
+        {"some":[ {"var":"items"}, {"<":[{"var":"qty"}, 1]}]},
+        {"items":[{"qty":1,"sku":"apple"},{"qty":2,"sku":"banana"}]},
+        false
+    ],
+    [
+        {"some":[ {"var":"items"}, {">=":[{"var":"qty"}, 1]}]},
+        {"items":[]},
+        false
+    ],
+
+    "EOF"
+]
diff --git a/tutorial/Tutorial.hs b/tutorial/Tutorial.hs
new file mode 100644
--- /dev/null
+++ b/tutorial/Tutorial.hs
@@ -0,0 +1,104 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- |
+-- Every example in docs/TUTORIAL.md, with the answer the tutorial shows. The
+-- run fails if any answer differs, so the tutorial cannot drift from the
+-- library. Uses only the public "JsonLogic" module, as a reader would.
+module Main (main) where
+
+import Data.Aeson (Value (..), decode, object, (.=))
+import qualified Data.ByteString.Lazy.Char8 as Char8
+import Data.Maybe (fromMaybe)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import System.Exit (exitFailure)
+
+import JsonLogic
+
+json :: String -> Value
+json = fromMaybe Null . decode . Char8.pack
+
+startsWith :: CustomOperator
+startsWith _ [String prefix, String s] = Right (Bool (prefix `Text.isPrefixOf` s))
+startsWith _ _ = Left "starts_with takes two strings"
+
+operators :: Either OperatorsError Operators
+operators = operatorsFromList [(OperatorName "starts_with", startsWith)]
+
+describe :: Either Error Value -> String
+describe result = case result of
+  Right v -> "ok: " ++ show v
+  Left (ParseFailure at (ArityMismatch (OperatorName op) _ given)) ->
+    "rule error at " ++ show (renderLocation at) ++ ": " ++ show op ++ " given " ++ show given
+  Left (EvalFailure _ (LimitExceeded _)) -> "too expensive"
+  Left other -> "failed: " ++ show (renderError other)
+
+-- | (section, what the tutorial shows, what the library says)
+checks :: [(String, String, String)]
+checks =
+  [ check "1 and" (Right (Bool True)) (apply (json "{\"and\": [{\">=\": [{\"var\": \"age\"}, 18]}, {\"var\": \"verified\"}]}") (json "{\"age\": 21, \"verified\": true}"))
+  , check "1 array" (Right (json "[1,2]")) (apply (json "[{\"var\": \"a\"}, 2]") (json "{\"a\": 1}"))
+  , check "1 path" (Right (String "admin")) (apply (json "{\"var\": \"user.roles.0\"}") (json "{\"user\": {\"roles\": [\"admin\"]}}"))
+  , check "1 default" (Right (String "default")) (apply (json "{\"var\": [\"missing.path\", \"default\"]}") (json "{}"))
+  , check "2 ==" (Right (Bool True)) (apply (json "{\"==\": [1, \"1\"]}") Null)
+  , check "2 ===" (Right (Bool False)) (apply (json "{\"===\": [1, \"1\"]}") Null)
+  , check "2 <" (Right (Bool True)) (apply (json "{\"<\": [\"10\", \"9\"]}") Null)
+  , check "2 +" (Right (Number 7)) (apply (json "{\"+\": [\"3\", 4]}") Null)
+  , check "2 /" (Right Null) (apply (json "{\"/\": [1, 0]}") Null)
+  , check "2 cat" (Right (String "0.1+0.2")) (apply (json "{\"cat\": [0.1, \"+\", 0.2]}") Null)
+  , check "2 if []" (Right (String "no")) (apply (json "{\"if\": [[], \"yes\", \"no\"]}") Null)
+  , check "2 !!" (Right (Bool True)) (apply (json "{\"!!\": \"0\"}") Null)
+  , check
+      "3 compile"
+      (Right [Right (Number 12), Right (Number 3)])
+      ((\rule -> map (run rule) [json "{\"price\": 10}", json "{\"price\": 2.5}"]) <$> compile defaultConfig noOperators (json "{\"*\": [{\"var\": \"price\"}, 1.2]}"))
+  , check
+      "4 location"
+      (Left (EvalFailure (fromSegments [Key "if", Index 1]) (UnknownOperator (OperatorName "nope"))))
+      (apply (json "{\"if\": [true, {\"nope\": [1]}]}") Null)
+  , check "4 render" ("at /if/1: unknown operator \"nope\"" :: Text) (either renderError (const "") (apply (json "{\"if\": [true, {\"nope\": [1]}]}") Null))
+  , check "4 describe" "rule error at \"\": \"==\" given 1" (describe (apply (json "{\"==\": [1]}") Null))
+  , check "4 lazy" (Right (Bool True)) (apply (json "{\"or\": [true, {\"nope\": []}]}") Null)
+  , check
+      "5 strict"
+      (Left (ParseFailure (fromSegments []) (ArityMismatch (OperatorName "==") (Exactly 2) 1)))
+      (apply (json "{\"==\": [1]}") Null)
+  , check "5 lenient" (Right (Bool False)) (applyWithConfig defaultConfig{arityMode = Lenient} noOperators (json "{\"==\": [1]}") Null)
+  , check
+      "6 limits"
+      (Left (EvalFailure (fromSegments [Key "map", Index 1]) (LimitExceeded (ArrayLimit 1000))))
+      ( applyWithConfig
+          defaultConfig{maxSteps = 10000, maxStringLength = 4096, maxArrayLength = 1000}
+          noOperators
+          (json "{\"map\": [{\"var\": \"xs\"}, {\"map\": [{\"var\": \"\"}, 1]}]}")
+          (object ["xs" .= [[1 .. 2000 :: Int]]])
+      )
+  , check
+      "7 custom"
+      (Right (Right (Bool True)))
+      ((\ops -> applyWith ops (json "{\"starts_with\": [\"ad\", {\"var\": \"role\"}]}") (json "{\"role\": \"admin\"}")) <$> operators)
+  , check
+      "7 custom failure"
+      (Right (Left (EvalFailure (fromSegments []) (CustomOperatorFailed (OperatorName "starts_with") "starts_with takes two strings"))))
+      ((\ops -> applyWith ops (json "{\"starts_with\": [1]}") Null) <$> operators)
+  , check
+      "8 dataUse"
+      (Right (DataUse ["orders", "user.age"] False))
+      ( dataUse
+          <$> compile
+            defaultConfig
+            noOperators
+            (json "{\"and\": [{\"var\": \"user.age\"}, {\"some\": [{\"var\": \"orders\"}, {\">\": [{\"var\": \"total\"}, 100]}]}]}")
+      )
+  ]
+  where
+    check :: (Eq a, Show a) => String -> a -> a -> (String, String, String)
+    check name shown actual = (name, show shown, if actual == shown then show shown else show actual)
+
+main :: IO ()
+main = do
+  let wrong = [(name, shown, actual) | (name, shown, actual) <- checks, shown /= actual]
+  mapM_ (\(name, shown, actual) -> putStrLn ("tutorial section " ++ name ++ ":\n  shows " ++ shown ++ "\n  gives " ++ actual)) wrong
+  if null wrong
+    then putStrLn ("tutorial: all " ++ show (length checks) ++ " examples give the answers shown")
+    else exitFailure
