jsonlogic-hs-0.1.0.0: docs/COMPATIBILITY.md
# 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.