trilean
v1.6.3
Published
Three-valued predicate and expression evaluation trees, stored as JSON and evaluated against injected resolvers, for domain logic — business rules, eligibility checks, formulas, search filters, and more — that needs to be data instead of code.
Maintainers
Readme
trilean
/ˈtraɪ.li.ən/ (TRY-lee-ən) — rhymes with "boolean".
"Tri-" for three-valued logic — the three possible outcomes of an evaluation (definitely true, definitely false, or indeterminate — see The evaluation model) — "-lean" echoing "boolean" itself, George Boole's own two-valued logic.
A serialisable (JSON) representation of two related tree structures — a predicate tree (truth-valued) and an expression tree (value-valued) — together with an evaluator for both. The package is deliberately domain-agnostic: the schema layer never assumes anything about where data actually comes from. Every point of contact with a consumer's real data is an injected, opaque resolver function supplied by whoever embeds the package.
Typical use: representing business rules, eligibility conditions, formulas, or validation logic as data (JSON) that can be stored, transmitted, edited by non-developers via a UI, and evaluated identically wherever it lands — a browser, a server, a batch job — without recompiling anything.
Getting started
npm install trilean
# or
pnpm add trileanThe package ships as dual ESM and CJS builds, is isomorphic (no assumptions about a Node, browser, or Workers runtime — see Design principles), and has zero runtime dependencies beyond Zod.
import { evaluatePredicate, type PredicateNode, type Resolvers } from "trilean";
const node: PredicateNode = {
kind: "compare",
op: "gt",
left: { kind: "reference", key: "age" },
right: { kind: "numberLiteral", value: 18 },
};
const resolvers: Resolvers = {
async resolveValue(key, context) {
const record = context as Record<string, unknown>;
return key === "age" && "age" in record
? { found: true, value: { kind: "number", value: record.age as number } }
: { found: false };
},
async resolveLookup() {
return { found: false };
},
async resolveCollection() {
return [];
},
};
await evaluatePredicate(node, { age: 21 }, resolvers);
// => { status: "definite", value: true }See Evaluator entry points and Resolvers for the full contract, and the Worked example for a larger tree combining boolean logic, a formula, and an aggregation.
A nested filter for a REST API search endpoint
A search endpoint's filter criteria are exactly the kind of thing this package is for: nested boolean logic, stored as JSON, that a client can construct, a non-developer can edit via a UI, and a server evaluates per record without ever hardcoding the filter or redeploying when it changes. There is no query-string DSL to parse and no ORM query-builder to translate into — the request body already is the tree:
POST /orders/search HTTP/1.1
Content-Type: application/json
{
"filter": {
"kind": "and",
"left": {
"kind": "textCompare",
"op": "equals",
"left": { "kind": "reference", "key": "status" },
"right": { "kind": "textLiteral", "value": "active" }
},
"right": {
"kind": "or",
"left": {
"kind": "compare",
"op": "gt",
"left": { "kind": "reference", "key": "orderTotal" },
"right": { "kind": "numberLiteral", "value": 100 }
},
"right": {
"kind": "memberOf",
"op": "in",
"operand": { "kind": "reference", "key": "category" },
"candidates": [
{ "kind": "textLiteral", "value": "electronics" },
{ "kind": "textLiteral", "value": "books" }
]
}
}
}
}status equals "active" AND (orderTotal > 100 OR category is a preferred one) — two levels of nesting: an or inside the right branch of an and. The server parses that body's filter field as a PredicateNode and evaluates it, unmodified, against each candidate order:
import { evaluatePredicate, type PredicateNode, type Resolvers } from "trilean";
interface Order {
status: string;
orderTotal: number;
category: string;
}
// The parsed `filter` field from the request body above.
const filter: PredicateNode = {
kind: "and",
left: {
kind: "textCompare",
op: "equals",
left: { kind: "reference", key: "status" },
right: { kind: "textLiteral", value: "active" },
},
right: {
kind: "or",
left: {
kind: "compare",
op: "gt",
left: { kind: "reference", key: "orderTotal" },
right: { kind: "numberLiteral", value: 100 },
},
right: {
kind: "memberOf",
op: "in",
operand: { kind: "reference", key: "category" },
candidates: [
{ kind: "textLiteral", value: "electronics" },
{ kind: "textLiteral", value: "books" },
],
},
},
};
const orderResolvers: Resolvers = {
async resolveValue(key, context) {
const order = context as Order;
switch (key) {
case "status":
return { found: true, value: { kind: "text", value: order.status } };
case "orderTotal":
return { found: true, value: { kind: "number", value: order.orderTotal } };
case "category":
return { found: true, value: { kind: "text", value: order.category } };
default:
return { found: false };
}
},
async resolveLookup() {
return { found: false };
},
async resolveCollection() {
return [];
},
};
const orders: Order[] = [
{ status: "active", orderTotal: 42, category: "electronics" },
{ status: "active", orderTotal: 150, category: "garden" },
{ status: "cancelled", orderTotal: 200, category: "electronics" },
];
const results = await Promise.all(
orders.map((order) => evaluatePredicate(filter, order, orderResolvers)),
);
const matching = orders.filter((_, i) => results[i]?.status === "definite" && results[i]?.value === true);
// => the first two orders match; the cancelled one doesn't reach the "or" at all, since "and" absorbs on its left operand's definite falseSee and/or, compare, textCompare, and memberOf for the full node-kind reference.
Build, test, and lint
pnpm install
pnpm build # tsdown -> dist/, then generates schemas/trilean.schema.json
pnpm test # unit suite, against src/
pnpm test:integration # multi-kind composition, schema-pipeline, and function-registry/delegate tests, against src/
pnpm test:smoke # builds first, then checks dist/ in both ESM and CJS plus the generated JSON Schema
pnpm test:workers # runs the evaluator inside a real Cloudflare Workers isolate
pnpm lint
pnpm typecheckSee CONTRIBUTING.md for the git hooks, the release process, and the constraints an implementation change must preserve.
Design principles
These hold across every part of the design below, and any implementation change must preserve them:
- No assumptions about consumer data. The only places this package touches real data are three named resolver contracts (see Resolvers). The schema stores what to pass to a resolver, never any resolver logic itself, and never interprets the meaning of an opaque key, table identifier, or collection reference.
- Three outcomes, never two. Every evaluation produces a definite result or an indeterminate result carrying a reason — never a bare
boolean/number, and never a thrown exception for a data-quality problem. See The evaluation model. - Derived constructs are compositions, not new logic. Anything describable as "some other primitive, wired together" is implemented that way, so its correctness is inherited rather than requiring separate proof. See Derived connectives, Derived aggregates, Derived values, Pattern-matching builders, and Defining your own named presets.
- One schema, mechanically derived artefacts. A single canonical type definition produces the runtime validator and the portable wire-format schema; they cannot drift apart because there is only one source. See Schema strategy.
- A numeric extension that stays closed-form is in scope; a different kind of computation is not. When something the current numeric model does not cover comes up, the test is whether evaluating it is still closed-form numeric evaluation — no solving, no simplification, no code execution. If it is, it belongs here, however unlike the existing kinds it looks: Complex values were once listed under Out of scope on a sizing judgement that turned out to be wrong, since complex arithmetic is exactly the closed-form evaluation this evaluator already does for every other kind. What stays behind
delegateis a genuinely different kind of computation — symbolic algebra, arbitrary external computation — not merely a kind of number the model has not reached yet. - Generic examples only. Every example in this document uses invented, placeholder field names (
temperature,orderTotal,isActive,x,y,amount,items) with no resemblance to any particular company, product, or industry's real data model.
The evaluation model
Every evaluation — of a predicate node or an expression node — produces exactly one of two outcomes:
type Evaluation<T> =
| { status: "definite"; value: T }
| { status: "indeterminate"; reason: IndeterminateReason };
interface IndeterminateReason {
/** Which of the three reason categories applies. */
code: "not-found" | "wrong-type" | "domain-error";
/** A human-readable explanation, for logging and debugging. */
message: string;
}The three reason codes are:
| Code | Meaning |
|---|---|
| not-found | A value a node needed did not exist in the underlying data at all. |
| wrong-type | A value existed but was not of a kind the operation could use (e.g. non-numeric where a number was required). |
| domain-error | A mathematical operation was attempted outside its valid domain (division by zero, a function given an input outside its allowed range, an aggregation with nothing to aggregate). |
domain-error is not a separate error type, exception, or crash — it uses exactly the same Evaluation/IndeterminateReason mechanism as the other two. This three-outcome model applies uniformly to every node kind in both trees: arithmetic, comparison, and boolean logic alike. It never collapses to a plain boolean or number at any intermediate point inside the tree; only the code that consumes the final top-level Evaluation decides what to do with an indeterminate outcome (reject, default, surface to a user, etc.) — that decision is deliberately outside this package's scope.
Infrastructure failures are a different concern. If a resolver itself throws (a network error, a database outage), that propagates as an ordinary rejected promise from evaluatePredicate/evaluateValue, exactly like any other function call failure. The three-outcome model exists to describe data-quality states inside the domain being modelled — it does not, and should not, attempt to also model transport-level failure.
Where an indeterminate outcome can carry more than one candidate reason
Some nodes combine several sub-evaluations that could each independently be indeterminate for a different reason (e.g. an and node whose both operands are indeterminate, one not-found and one wrong-type). This design resolves ties with a single, consistently-applied rule: take the first indeterminate reason encountered in the node's own declared operand order (left before right; list order for N-ary/collection operands). This is an implementation decision this document makes explicitly, once, so every node kind's evaluator can apply the same rule without re-deriving it.
Three-valued propagation rules
Let U denote "indeterminate" for the purposes of these tables — the specific reason is preserved and reported per the tie-break rule above, but propagation logic itself only cares that an operand is not a definite value. T = true, F = false.
Any arithmetic operation or relational comparison with at least one indeterminate operand always produces an indeterminate result. There is no operand value that can rescue an arithmetic or single relational comparison once one side is indeterminate — arithmetic and single relational comparisons have no absorbing value and no short-circuit.
Logical AND, OR, and NOT behave differently: they have absorbing values, and this absorption must be preserved exactly as specified below. A design in which any indeterminate operand automatically makes the whole boolean result indeterminate, with no absorption, is a specification defect — it would silently discard cases where the answer was already determined regardless of the indeterminate side.
AND — false is absorbing/dominant:
| AND | T | F | U | |---|---|---|---| | T | T | F | U | | F | F | F | F | | U | U | F | U |
OR — true is absorbing/dominant (mirror image of AND):
| OR | T | F | U | |---|---|---|---| | T | T | T | T | | F | T | F | U | | U | T | U | U |
NOT — negates a definite result; leaves indeterminate as indeterminate, reason unchanged:
| NOT | result | |---|---| | T | F | | F | T | | U | U |
Identity elements for the N-ary and collection forms. AND is a fold over true (the identity for AND), OR is a fold over false (the identity for OR) — this is a structural property of the operation, not a separate design choice, so it applies consistently everywhere an AND/OR is taken across a list: an empty allOf is definitely true; an empty anyOf is definitely false; a "some" quantifier over an empty collection is definitely false (no item can satisfy it).
Deliberate, settled:
everyover an empty collection is definitelytrue. This is vacuous truth — the standard convention for universal quantification over an empty set, and exactly the same identity-element reasoning already used forallOfabove (an emptyallOf'strueand an emptyevery'strueare the same fact, stated twice becauseeveryis a quantifier over resolved items rather than a literal list of sub-nodes). This is worth stating explicitly and prominently, rather than leaving it as something an implementer might reasonably second-guess, because at least one other real, existing tool in this space gets exactly this case wrong — its own "all" operator returnsfalsefor an empty collection, which is simply an incorrect implementation of universal quantification, not an equally valid alternative convention. Nothing about a genuinely empty collection can violate "every item satisfies X", sotrueis the only value consistent with what the quantifier claims to mean; this document'severymust not be "fixed" to match that other tool's behaviour.
Derived connectives
Exclusive-or, NAND, NOR, implication, and the biconditional are never implemented as independently-evaluated node kinds. Each is defined purely as a fixed composition of unary NOT and binary AND/OR, expressed as ordinary builder functions that construct a tree of primitive nodes:
const not = (a: PredicateNode): PredicateNode => ({ kind: "not", operand: a });
const and = (a: PredicateNode, b: PredicateNode): PredicateNode => ({ kind: "and", left: a, right: b });
const or = (a: PredicateNode, b: PredicateNode): PredicateNode => ({ kind: "or", left: a, right: b });
const xor = (a: PredicateNode, b: PredicateNode): PredicateNode => or(and(a, not(b)), and(not(a), b));
const nand = (a: PredicateNode, b: PredicateNode): PredicateNode => not(and(a, b));
const nor = (a: PredicateNode, b: PredicateNode): PredicateNode => not(or(a, b));
const implies = (a: PredicateNode, b: PredicateNode): PredicateNode => or(not(a), b);
const iff = (a: PredicateNode, b: PredicateNode): PredicateNode => not(xor(a, b));
const none = (collection: JsonValue, item: PredicateNode, filter?: PredicateNode): PredicateNode =>
not({ kind: "some", collection, item, filter });None of xor/nand/nor/implies/iff ever appears as a kind discriminant on the wire — a serialised tree containing an XOR is indistinguishable from one written out by hand using or/and/not. Three-valued correctness for all five is therefore inherited automatically from the already-verified AND/OR/NOT tables above, never requiring a separate proof for each.
The same treatment applies to a third quantifier, none ("no item satisfies") — defined purely as not(some(...)), never as its own independently-evaluated node kind, and so never appearing as its own kind discriminant either. Its three-valued correctness is inherited automatically from NOT and from some's own already-established correctness (including its absorbing behaviour and its filter handling) — no new truth table or worked proof is needed, exactly as for the five connectives above.
Worked correctness check: exclusive-or
Applying the AND/OR/NOT tables above to xor(A, B) = or(and(A, not(B)), and(not(A), B)) across all nine combinations of {T, F, U} for A and B:
| A | B | not B | A ∧ ¬B | not A | ¬A ∧ B | result (∨) | expected | |---|---|---|---|---|---|---|---| | T | T | F | F | F | F | F | F | | T | F | T | T | F | F | T | T | | T | U | U | U | F | F | U | U | | F | T | F | F | T | T | T | T | | F | F | T | F | T | F | F | F | | F | U | U | F | T | U | U | U | | U | T | F | F | U | U | U | U | | U | F | T | U | U | F | U | U | | U | U | U | U | U | U | U | U |
Every fully-known input pair produces the correct classical XOR, and every combination with at least one U produces U. This is the correct three-valued extension specifically for XOR — unlike AND/OR, exclusive-or has no operand value that determines the result on its own (there is no value of B for which xor(anything, B) is fixed regardless of the other side), so it has no absorbing value and "any unknown input yields an unknown output" is exactly right here — even though the identical blanket rule would be wrong for AND/OR, where it would ignore real absorption. NAND, NOR, implication, and the biconditional each inherit correct behaviour the same way, purely from being built out of NOT/AND/OR — check any of them the same way, by writing out all nine input combinations and confirming the result matches intuition. As one further spot check: implies(F, U) = or(not(F), U) = or(T, U) = T — a false antecedent makes an implication vacuously true regardless of whether the consequent is even knowable, which is the absorbing behaviour correctly carried through from OR.
Schema strategy
The canonical definition lives in one place: a Zod schema per node kind. The TypeScript type is inferred from the schema (z.infer<...>), and a portable wire-format schema for documentation or cross-language interoperability is mechanically derived from the same Zod schema via z.toJSONSchema(). There is exactly one hand-authored artefact; the runtime validator and the JSON Schema document cannot drift apart because the second is generated from the first, not maintained alongside it.
import { z } from "zod";
// A JSON value with no further meaning imposed by this schema — used for every
// opaque payload (reference keys, table identifiers, collection references,
// delegation payloads). "Opaque" means "uninterpreted by this package", not
// "untyped" — every one of these must still be plain, serialisable JSON.
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
const JsonValueSchema: z.ZodType<JsonValue> = z.lazy(() =>
z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(JsonValueSchema), z.record(z.string(), JsonValueSchema)])
);Node schemas are z.discriminatedUnion("kind", [...]) over per-kind z.object shapes, following the concrete definitions below. A generated JSON Schema document (produced once, as a build step, via z.toJSONSchema(PredicateNodeSchema) / z.toJSONSchema(ExpressionNodeSchema)) is what a non-TypeScript consumer or an authoring UI would target.
The generated document carries a version-pinned $id — a jsDelivr URL naming the exact published version, e.g. https://cdn.jsdelivr.net/npm/[email protected]/schemas/trilean.schema.json — so a consumer's own rule file can point its $schema at a fixed target rather than a moving one. The file's bytes are exactly its RFC 8785 (JSON Canonicalization Scheme) canonical form — keys sorted recursively, no whitespace between tokens, no trailing newline — so canonicalize(JSON.parse(file)) === file holds under any JCS implementation, and the same input always produces the same bytes. That makes the file's own SHA-256 re-derivable from its parsed content alone, which is what lets a downloaded copy be checked against this package's SBOM and build-provenance attestations (see the release workflow).
Performance
A consumer that parses and evaluates many trees at high throughput can opt into Zod 4.5's compiled-schema fast path by importing zod/compile once, at their own application's entry point:
import "zod/compile";This package deliberately does not import it itself — zod/compile has global side effects on the Zod runtime, which would contradict this package's own sideEffects: false declaration and could surprise a consumer who never asked for it. Opting in (or not) is left entirely to whoever embeds the package.
The predicate tree
A PredicateNode evaluates to Evaluation<boolean> — true, false, or indeterminate-with-reason.
type ComparisonOperator = "gt" | "gte" | "lt" | "lte" | "eq" | "neq";
type TextComparisonOperator = "equals" | "notEquals" | "matches" | "notMatches" | "portableMatches" | "portableNotMatches";
type MembershipOperator = "in" | "notIn";
type PredicateNode =
| { kind: "not"; operand: PredicateNode }
| { kind: "and"; left: PredicateNode; right: PredicateNode }
| { kind: "or"; left: PredicateNode; right: PredicateNode }
| { kind: "allOf"; operands: PredicateNode[] }
| { kind: "anyOf"; operands: PredicateNode[] }
| { kind: "compare"; op: ComparisonOperator; left: ExpressionNode; right: ExpressionNode }
| { kind: "textCompare"; op: TextComparisonOperator; left: ExpressionNode; right: ExpressionNode }
| { kind: "memberOf"; op: MembershipOperator; operand: ExpressionNode; candidates: ExpressionNode[] }
| { kind: "exists"; operand: ExpressionNode }
| { kind: "some"; collection: JsonValue; item: PredicateNode; filter?: PredicateNode }
| { kind: "every"; collection: JsonValue; item: PredicateNode; filter?: PredicateNode }
| { kind: "treeReference"; key: JsonValue };not, and, or
The three primitives. not takes exactly one operand — it is never modelled as a two-operand node with an unused second slot. and/or each take exactly two named operands (left/right), evaluated per the truth tables above.
allOf, anyOf
The N-ary forms of and/or: given an ordered list of operands (rather than exactly two), combine all of them with AND, or all of them with OR, respectively. Defined as repeated pairwise application of and/or — an implementation detail, not a new evaluation rule requiring separate verification. Because resolvers are asynchronous, a reference implementation is free to evaluate every operand concurrently and then apply the absorption rule when combining results, rather than evaluating strictly left-to-right; both strategies produce an identical final Evaluation because absorption is a property of the values, not of execution order. The empty-list identity values from Three-valued propagation rules apply: allOf([]) is definitely true; anyOf([]) is definitely false.
compare
A relational-comparison leaf: compares two computed values using gt/gte/lt/lte/eq/neq. Both left and right are ExpressionNode — either side may be a plain literal/reference or an arbitrary formula from the expression tree; the comparison is symmetric, and an implementation that only allows a formula on one side is incomplete. Valid operand kinds are number (matching units required — see Units), instant, duration, or boolean, plus complex for eq/neq only (see Complex values); comparing across different computed-value kinds, or comparing two numbers with incompatible units, is wrong-type. boolean only supports eq/neq — there is no natural ordering for a truth value, so gt/gte/lt/lte are wrong-type for a boolean operand.
textCompare
A text-matching leaf, symmetric in the same way as compare: both left and right are ExpressionNode, and either may be a literal or an arbitrary formula. equals/notEquals are exact string equality; matches/notMatches interpret right as a pattern (an ECMAScript-style regular expression) tested against left's text. Both operands must resolve to the text computed-value kind; anything else is wrong-type. A "small fixed category" value (e.g. a status label) is simply a text computed value from this leaf's point of view — no separate category kind exists.
portableMatches/portableNotMatches answer the same question as matches/notMatches but interpret right as a pattern in trilean-regex's own grammar instead of ECMAScript's — a genuine regular language (no backreferences, no lookaround), matched by that package's Thompson-construction NFA simulation rather than the host's native RegExp engine. Two consequences follow from that restriction, and are the reason to reach for the portable pair instead of the plain one:
- No catastrophic-backtracking risk. An NFA simulation runs in time linear in the pattern's compiled state count and the input's length, regardless of the pattern's shape — there is no input a
portableMatchespattern can be made to hang on the way a pathological ECMAScript pattern can hangmatches. - Provable pushdown equivalence. Because the grammar is a true regular language,
trilean-sql's dialect compilers can translate aportableMatches/portableNotMatchespattern into a database's ownLIKE/GLOB/native-regex operators and prove the translation agrees with this package's own evaluator row for row — the same "compile and measure agreement against the reference matcher" disciplinetrilean-sqlalready applies to everything else it compiles.matches/notMatchescarry no such guarantee: what a SQL engine's own native regex operator accepts is a different language from ECMAScript's, andtrilean-sqlrefuses to push either operator down for exactly that reason (seetrilean-sql's README).
This is additive, not a redefinition: matches/notMatches keep their existing ECMAScript semantics unchanged, and nothing already written against them is affected. Reach for portableMatches/portableNotMatches specifically when a tree might be compiled to SQL (or when the backtracking-safety guarantee alone is worth the smaller grammar), and keep matches/notMatches for in-process-only trees that need ECMAScript's full expressiveness. An invalid trilean-regex pattern is wrong-type, exactly like an invalid ECMAScript pattern under matches — see trilean-regex's own README for the grammar's precise definition and what it deliberately excludes.
Pattern-matching builders
matches already covers arbitrary pattern matching, but writing the regular expression by hand is where the common, narrower cases go wrong: getting the escape-then-convert ordering backwards either stops wildcards working or silently reinterprets a literal asterisk in real data as one. Three builder functions compile a pattern string into an ordinary textCompare node instead — never a new node kind, never an evaluator branch, exactly the same composition-not-new-logic treatment Derived connectives, Derived aggregates, and Derived values already give xor/sum/coalesce:
const command: ExpressionNode = { kind: "reference", key: "command" };
const path: ExpressionNode = { kind: "reference", key: "path" };
// Matches "ls" and "ls -la", never "lsof".
prefixPattern(command, "ls");
// Matches "git add file" and, by the trailing-wildcard convenience below, bare "git".
wildcardPattern(command, "git *");
// Matches "workspace/report.txt", but not "workspace/archive/report.txt".
hierarchicalGlobPattern(path, "workspace/*");Each returns an ordinary predicate node — prefixPattern(command, "ls") is exactly { kind: "textCompare", op: "matches", left: command, right: { kind: "textLiteral", value: "^ls(?: [\\s\\S]*)?$" } }. Compilation happens once, when the tree is built, so what is stored and serialised is a textCompare tree indistinguishable from one written out by hand — a consumer that never calls a builder loses nothing, and a serialised tree carries no dependency on the builder that produced it.
The three are separate dialects, deliberately not one function with a mode argument, because they answer different questions and mixing them silently changes what a pattern means:
| Builder | * | ** | ? | Escapes | Intended for |
|---|---|---|---|---|---|
| prefixPattern | literal | literal | literal | none — the prefix is a plain literal throughout | Command/label prefixes where "ls" must match "ls" and "ls -la" but never "lsof" |
| wildcardPattern | any characters | (two wildcards in a row) | literal | \* for a literal asterisk, \\ for a literal backslash | Flat strings with no internal hierarchy |
| hierarchicalGlobPattern | any characters within one /-delimited segment | any characters across segments | one character within a segment | none — a backslash is a literal backslash | Path- or category-tree-shaped values |
Two behaviours in wildcardPattern are worth stating rather than leaving to be inferred. Its pattern is trimmed before compiling. And a pattern whose only unescaped wildcard is a trailing " *" also matches the bare prefix, so "git *" matches "git" as well as "git add file" — the convenience does not apply to "git * *", where both wildcards are still required.
The compiled pattern is a fully anchored, flag-free string: "any character" is spelled [\s\S] rather than ., because the stored pattern carries no s flag and nothing downstream can add one, and the only characters escaped are ECMAScript's own SyntaxCharacter set plus /, which are exactly the escapes that stay valid under a u/v-flagged RegExp as well as an unflagged one. A compiled pattern is therefore portable in the strongest available sense — it means the same thing wherever it is compiled, including pasted verbatim into a /.../ literal.
Three-valued behaviour is inherited unchanged from textCompare and needs no separate proof: an unresolvable subject is indeterminate rather than a non-match, and a non-text subject is wrong-type — a compiled pattern never turns a data problem into a definite false.
memberOf
A membership-test leaf, parallel to compare and textCompare rather than folded into either one's operator set: operand is the ExpressionNode being tested; candidates is a list of ExpressionNodes to test it against, every element of which may independently be an arbitrary formula, not only a literal — the same symmetry principle already applied to compare and textCompare. op: "in" asks whether operand equals any candidate; op: "notIn" asks whether it equals none of them.
Membership is decided by value equality between computed values of the same kind, respecting units for numeric values exactly as compare's own eq already does — a candidate of an incompatible kind, or a number candidate with an incompatible unit, can never be a match, and the comparison for that one element is wrong-type, not simply "not equal".
Evaluate operand first; if it is indeterminate, the whole leaf is indeterminate with that reason. Otherwise, scan candidates in order: a candidate that is a definite match immediately settles the result — in is definitely true, notIn is definitely false — regardless of any not-yet-scanned or indeterminate candidates, mirroring the same absorbing-value discipline already established for OR and some elsewhere in this document (a confirmed match cannot be undone by an unrelated element's data problem). If scanning completes with no definite match: the leaf is indeterminate (first indeterminate candidate's reason, per the tie-break rule in The evaluation model) if at least one candidate was itself indeterminate or of an incompatible kind/unit; otherwise every candidate was a definite, comparable non-match, and in is definitely false, notIn is definitely true. An empty candidates list is never scanned and never indeterminate: in is definitely false and notIn is definitely true — the same non-vacuous facts an empty anyOf/allOf already establishes for OR/AND.
exists
Evaluates true if the given ExpressionNode can be resolved to some value at all, false if it definitely cannot be resolved (the data point is genuinely absent), independent of whether that value would itself be usable in further computation. Concretely: evaluate the operand; if the result is definite, exists is true; if the result is indeterminate with reason not-found, exists is false; if the result is indeterminate with reason wrong-type or domain-error, exists is still true — the underlying data point did resolve to something, it merely wasn't usable for whatever computation was attempted around it, which is exactly why section The evaluation model distinguishes "did not exist" from "existed but unusable" in the first place. exists itself is never indeterminate — it always produces a definite boolean.
some, every
Quantifiers over a collection, sharing the exact collection-resolution mechanism described in Collections. some is semantically an OR of item evaluated once per participating item; every is semantically an AND of item evaluated once per participating item — both inherit the absorbing-value propagation from the AND/OR tables applied across the whole collection (e.g. some can be definitely true from one known-true item even if every other participating item is unresolvable). An optional filter narrows which resolved items participate at all before either quantifier runs over them — see Collections for exactly how a filter result feeds into this same absorption. The item's own evaluation context (for both filter and item) is the item itself — see Collections. A third quantifier, "no item satisfies", is derived from some — see Derived connectives.
The expression tree
An ExpressionNode evaluates to Evaluation<ComputedValue>.
type Unit = Record<string, number>; // dimension symbol -> exponent, e.g. { m: 1, s: -1 } for metres per second
type DurationUnit = "ms" | "s" | "min" | "h" | "d";
type ComputedValue =
| { kind: "number"; value: number; unit?: Unit }
| { kind: "text"; value: string }
| { kind: "boolean"; value: boolean }
| { kind: "instant"; value: string } // ISO-8601 timestamp
| { kind: "duration"; value: number; unit: DurationUnit }
| { kind: "complex"; re: number; im: number; unit?: Unit };
type ArithmeticOperator = "add" | "subtract" | "multiply" | "divide" | "power" | "modulo";
type FoldCombiner =
| { mode: "max"; item: ExpressionNode }
| { mode: "min"; item: ExpressionNode }
| { mode: "reduce"; initial: ExpressionNode; combine: ExpressionNode };
type HitPolicy = "first" | "unique";
type ExpressionNode =
| { kind: "numberLiteral"; value: number; unit?: Unit }
| { kind: "textLiteral"; value: string }
| { kind: "booleanLiteral"; value: boolean }
| { kind: "instantLiteral"; value: string }
| { kind: "durationLiteral"; value: number; unit: DurationUnit }
| { kind: "complexLiteral"; re: number; im: number; unit?: Unit } // rectangular
| { kind: "complexLiteral"; magnitude: number; phase: number; unit?: Unit } // polar -- see Complex values
| { kind: "reference"; key: JsonValue; unit?: Unit }
| { kind: "arithmetic"; op: ArithmeticOperator; left: ExpressionNode; right: ExpressionNode }
| { kind: "negate"; operand: ExpressionNode }
| { kind: "call"; fn: string; args: ExpressionNode[] }
| { kind: "lookup"; table: JsonValue; keys: ExpressionNode[] }
| { kind: "conditional"; hitPolicy?: HitPolicy; cases: { when: PredicateNode; then: ExpressionNode }[]; fallback: ExpressionNode }
| { kind: "fold"; collection: JsonValue; filter?: PredicateNode; combiner: FoldCombiner }
| { kind: "accumulator" }
| { kind: "delegate"; system: string; payload: JsonValue }
| { kind: "treeReference"; key: JsonValue };A textLiteral kind is included even though it is not separately enumerated as its own top-level construct, because textCompare's symmetry requirement (either side may be an arbitrary computed value, per the section above) is meaningless without a way to write a constant string or pattern — matching a field against the fixed text "active", or against a fixed regular expression, needs a text constant on one side. This is a structural consequence of the symmetry already required for text matching, not an added feature.
Literals
numberLiteral, textLiteral, booleanLiteral, instantLiteral (an ISO-8601 timestamp string), durationLiteral (a magnitude plus a DurationUnit), and complexLiteral (either a real and an imaginary component, or a magnitude and a phase, plus an optional Unit — see Complex values) are always definite by construction — a literal node never itself produces an indeterminate outcome.
reference
A reference to a single external value, identified by an opaque key whose meaning is entirely up to the embedding consumer — the schema never interprets it (see Resolvers, resolver 1). May optionally carry an expected unit, validated against whatever the resolver actually returns for a number result; a mismatch (or an expectation of a unit on a non-numeric result) is wrong-type. If the resolver reports absence, the result is not-found.
arithmetic, negate
Binary arithmetic (add/subtract/multiply/divide/power/modulo) and unary negation, each over number computed values by default, with the temporal exceptions listed under Temporal values and the complex ones under Complex values below. negate is an explicit node — never sugar for "zero minus the value" — because it also applies to duration values (negating a duration reverses its direction) where "zero minus" has no natural literal-zero counterpart; over a complex value it flips both components. Division by zero, or any operator given an operand outside its mathematical domain, is domain-error; a non-numeric, non-temporal operand where a number was required is wrong-type; any operand that is itself indeterminate makes the whole node indeterminate, with no rescuing value on the other side (see Three-valued propagation rules).
call
A named function applied to an ordered list of ExpressionNode arguments. The set of named functions is intentionally open-ended and resolved through a function registry supplied at evaluator construction time — minimum, maximum, absoluteValue, round, squareRoot, and logarithm are starting examples, not an exhaustive list; new functions are added to the registry as concrete need arises. Calling an unregistered function name is wrong-type ("no function registered under this name"); calling a registered function with an argument outside its domain (e.g. squareRoot given a negative number) is domain-error.
Units
numberLiteral, complexLiteral, and reference may carry a unit, represented as a dimensional-exponent map (e.g. { m: 1, s: -1 } for metres per second) rather than an opaque string, so that unit combination follows real dimensional analysis instead of string matching. A bare symbol like "kg" is shorthand for { kg: 1 }.
add/subtractbetween two unit-tagged numbers require identical dimensional-exponent maps. A mismatch iswrong-type("incompatible units") — units are never silently coerced or dropped.multiply/dividecombine the two operands' unit maps by dimensional analysis: multiplying adds exponents per dimension, dividing subtracts them. An operand with nounitis treated as dimensionless (an empty map) for this purpose.
Temporal values
instant (a point in time) and duration are computed-value kinds distinct from number, even though a duration ultimately carries a numeric magnitude — an instant is never treated as "a number that happens to represent a date". The only well-defined cross-kind arithmetic is:
instant − instant → durationinstant + duration → instant(andduration + instant → instant)
Any other arithmetic combination touching an instant or duration (adding two instants, multiplying a duration by an instant, comparing an instant against a plain number, and so on) is wrong-type. A reference implementation normalises duration values to a single base unit (milliseconds) internally before combining two durations of different DurationUnits, then reports the result in whichever unit the node's own context calls for.
Complex values
complex is a computed-value kind alongside number, for the domains — signal processing, control theory, anything phasor-shaped — where a formula naturally mixes real and complex terms in one expression. It stays inside this evaluator rather than behind delegate because it is closed-form numeric evaluation, exactly what every other kind here already does; see Design principles for that scope test in general.
One canonical representation, rectangular. A complex value is stored as { re, im } and never as a magnitude and a phase, and there is deliberately no form discriminant offering both. Three reasons, in order of weight:
- A second form would make equality ambiguous. Polar coordinates do not encode a value uniquely — phase is only defined modulo a full turn, and a zero-magnitude value has no meaningful phase at all — so the same complex number would have unboundedly many polar encodings.
eqandmemberOfare exact equality throughout this design (seecompare); making them work across two forms would mean either normalising on every comparison or introducing an approximate equality for this one kind, and neither belongs in a design where every other kind compares exactly. - A discriminant would double the branching in every operator — quadruple it for a binary one — for a choice that changes no value. Every operator would still convert to rectangular internally, because that is where the closed forms live, so the discriminant would buy nothing at evaluation time and cost at every boundary.
- Rectangular is what the operators actually need.
add/subtractare component-wise in it;multiply,divide,negate, and integerpowerall have standard closed forms in it. Polar's advantage — multiplication and division as one product of magnitudes and one sum of angles — does not extend to addition at all, which would have to convert back and forth.
The magnitude-and-phase view stays reachable through four exported conversion helpers rather than a second encoding: complexFromPolar(magnitude, phase, unit?) and complexLiteralFromPolar(magnitude, phase, unit?) build a value or a literal node from polar terms, and complexMagnitude(value) and complexPhase(value) read them back out — the magnitude as a real number in the value's own unit, the phase as a dimensionless real number of radians. Conversions at the edges, one representation in the middle.
The wire-format literal accepts either authoring form, structurally discriminated. ComputedValue's own complex kind stays exactly the single rectangular shape described above — nothing about it changes. But the complexLiteral node is a plain union of two shapes, { kind: "complexLiteral", re, im, unit? } and { kind: "complexLiteral", magnitude, phase, unit? }, told apart by which fields are present rather than by a form tag, since both still share the one literal kind. This is not a second encoding of ComputedValue reappearing through the back door — it exists only at the authoring boundary, for whichever of the two forms is natural for a given domain to write directly into JSON rather than hand-computing a conversion before ever constructing the tree, and the evaluator normalises whichever form was used to the single rectangular ComputedValue immediately, before any arithmetic, comparison, or negation ever runs. A rectangular literal and a polar literal representing the same underlying number are therefore indistinguishable from that point on: they evaluate to the identical ComputedValue and compare eq to one another exactly as two rectangular literals with the same components would.
Arithmetic.
add/subtractare component-wise, requiring identical dimensional-exponent maps exactly as real numbers do (see Units).multiply/divideare real complex multiplication and division —(a + bi)(c + di) = (ac − bd) + (ad + bc)i, and the corresponding quotient — never component-wise. Units combine by the same dimensional analysis real numbers use. A zero divisor means both components zero; a divisor with only a zero real part divides perfectly well.poweris defined for a real integer exponent and evaluated as the repeated multiplication that integer exponentiation is, with a negative exponent the reciprocal of the positive one. Like a realpower, it requires dimensionless operands. An arbitrary complex exponent is a genuinely bigger question — it needs the complex logarithm, which is multivalued, so it needs a branch-cut convention this design has not chosen — and is deliberately out of scope for now: it iswrong-type, as is a non-integer real exponent, on the same reading of that code used throughout ("an answer exists, but this operator does not accept this operand" — comparepower's existing dimensionless-operands requirement, alsowrong-type).moduloisdomain-error, notwrong-type: a remainder needs a canonical notion of how many whole divisors fit, and the complex plane has no ordering to supply one. There is no answer to accept, which is the same category as division by zero.
A real operand is promoted, never rejected. Mixing a number with a complex in one arithmetic node works: every real number is a complex number with a zero imaginary part, so the promotion is exact, total, and canonical — unlike the temporal cross-kind combinations above, which had to be enumerated one by one precisely because no such embedding exists between an instant and a duration. Scaling a complex value by a real one, or offsetting it by a real constant, is the common case, and forcing every real literal in such a formula to be rewritten as a complex one would defeat the point. The result is complex whenever either operand is, even when the imaginary part comes out zero: a node's result kind follows its operand kinds, never the values that happen to flow through it.
Comparison is kind-strict, deliberately unlike arithmetic. gt/gte/lt/lte are wrong-type for a complex operand — the complex plane carries no total order — exactly as they already are for text. eq/neq work normally, as exact equality across both components under the same unit-compatibility rule numbers already have, and memberOf matches the same way. But a complex compared against a number is wrong-type, with no promotion: arithmetic produces a value, so promoting a real operand loses nothing, whereas a comparison consumes two, and this design already treats a kind difference between them as a modelling error worth surfacing — the same reason an instant is never compared against a plain number despite being a count of milliseconds underneath.
Ordering a complex quantity therefore goes through whichever real projection the formula actually means — most often its magnitude. This package ships no built-in function set (see call), so that bridge is an ordinary registry entry, one line over the exported helper:
const functions: FunctionRegistry = {
magnitude: (args) =>
args[0]?.kind === "complex"
? complexMagnitude(args[0])
: { domainError: "expected a complex argument" },
};which a tree then calls like any other function, putting a real number back on the left of an ordinary compare:
{
"kind": "compare",
"op": "gt",
"left": { "kind": "call", "fn": "magnitude", "args": [{ "kind": "reference", "key": "x" }] },
"right": { "kind": "numberLiteral", "value": 13 }
}lookup
Resolves a single value from a named external table-like source, keyed by one or more ExpressionNode keys, via resolver 2 (see Resolvers). The schema never interprets what "table" or "key" mean to a given consumer; table and the resolved key values are passed through verbatim. If any key expression is itself indeterminate, the lookup is indeterminate with that reason (no key evaluation, no lookup attempt). If the resolver reports no match, the result is not-found.
conditional
A piecewise/conditional-value node: an ordered, possibly-empty list of { when, then } cases plus a required fallback. An optional hitPolicy field ("first" or "unique") decides how cases are read; absent is treated as "first" — the exact, unchanged behaviour of every tree serialised before this field existed, not a masked-bug fallback.
hitPolicy: "first" (the default). Evaluates to the then of the first case whose when predicate is definitely true; if no case matches, evaluates to fallback. If evaluating a when predicate produces an indeterminate outcome before any earlier case has matched, the whole conditional node's own result is that same indeterminate outcome (reason preserved) — evaluation does not skip past an unknown guard to try the next one, because doing so could silently pick a later branch that only looks correct because an earlier one couldn't actually be checked.
hitPolicy: "unique" asserts that at most one case is expected to match, and treats two or more matches as a data error rather than silently taking the first. Every case's when is evaluated concurrently (there is no "earlier case" to short-circuit on), then resolved in this order:
- Two or more cases are definitely
true—domain-error("more than one case matched under the 'unique' hit policy"), regardless of any other case's own indeterminacy. This mirrorsmemberOf/some/every's existing absorption: a confirmed outcome (here, "there is a genuine ambiguity") cannot be undone by an unrelated case's data problem. - Otherwise, any case's
whenis indeterminate — the whole node is indeterminate with that reason (first such candidate, in declared case order, per The evaluation model's tie-break rule). This is deliberately not absorbed by a single already-confirmed match, unlike step 1 above and unlikememberOf/some/every's own absorption: an unresolved case might still turn out to be a second match, so "exactly one match so far" cannot be trusted as final until every other case is known to not also match. - Otherwise, exactly one case is definitely
true— evaluate and return that case'sthen. No other case'sthenis ever evaluated. - Otherwise (zero matches, and nothing indeterminate) — evaluate and return
fallback, exactly as"first"already does.
fold
An aggregation over a collection (see Collections): collection is the opaque collection reference; an optional filter narrows which resolved items participate (see Collections); combiner decides how the participating items' values become one result. There is exactly one general mechanism, reduce, and exactly two named forms, max/min, that cannot be expressed as an instance of it — see Derived aggregates for why sum, count, and average need no combiner mode of their own at all.
reduce is "fold with an accumulator": initial is evaluated once, in the fold node's own (outer) context, to seed the running result; then, for each participating item in turn, combine is evaluated with that item as its evaluation context to produce the new running result from the old one. combine reaches the running result through the dedicated accumulator leaf; the item's own fields are reached the ordinary way, through reference/lookup nodes resolved against the item context. Over an empty (post-filter) collection, a reduce fold evaluates to initial without ever touching combine.
max/min each carry an item, evaluated once per participating item using that item as its evaluation context, and keep the largest/smallest projected value seen. These two are the only combining behaviours that stay as their own directly-specified forms, for a precise mathematical reason rather than an arbitrary exception: reduce needs a seed value that is also the identity for combine (as 0 is for addition), and there is no largest or smallest real number to seed a running maximum or minimum with — the JSON number model has no literal for an unbounded sentinel. max/min are still the same underlying mechanism, just its standard unseeded variant (sometimes called "reduce1" elsewhere): the running result starts as the first participating item's own projected value, and combine (the ordinary "keep the larger"/"keep the smaller" comparison) is applied to each item after that — not an independently-invented special case, only the one variant of the mechanism that a literal initial genuinely cannot express. Over an empty (post-filter) collection, both are domain-error (undefined over an empty set, the same category as division by zero, per The evaluation model's explicit allowance for "any comparable domain violation for any function added later") — there is no first item to seed from.
Indeterminacy, both forms. If any participating item's filter evaluation is indeterminate, the whole fold is indeterminate with that reason — fold has no absorbing value (see Three-valued propagation rules), so unlike a quantifier's OR/AND there is no other item's outcome that can override this (see Pre-filtering which items participate). The same is true of any participating item's item/combine evaluation, and of a reduce's initial: if any is indeterminate, the whole fold is indeterminate with that reason (first such candidate, in resolved-list order, initial counting as evaluated before any item).
accumulator
A zero-field leaf, meaningful only inside the combine expression of an enclosing fold's reduce form (see fold above), where it evaluates to that step's running accumulated result. A nested fold's own combine expression introduces its own, separate accumulator scope — accumulator always refers to the innermost enclosing reduce fold. Using accumulator anywhere else (a max/min fold's item, a filter predicate, a quantifier's item, or outside any fold at all) is wrong-type — there is no running accumulator in scope.
Derived aggregates
sum, count, and average are never their own FoldCombiner mode — each is a builder function that assembles an ordinary fold (and, for average, one arithmetic division of two ordinary folds), exactly the same treatment Derived connectives already gives xor/nand/nor/implies/iff/none: correctness is inherited from the mechanism they're built from, rather than needing its own independent implementation that could silently drift from it.
const sum = (collection: JsonValue, item: ExpressionNode, filter?: PredicateNode): ExpressionNode => ({
kind: "fold",
collection,
filter,
combiner: {
mode: "reduce",
initial: { kind: "numberLiteral", value: 0 },
combine: { kind: "arithmetic", op: "add", left: { kind: "accumulator" }, right: item },
},
});
const presenceOf = (probe: ExpressionNode): ExpressionNode => ({
kind: "conditional",
cases: [
{
when: { kind: "memberOf", op: "in", operand: probe, candidates: [probe] },
then: { kind: "numberLiteral", value: 1 },
},
],
fallback: { kind: "numberLiteral", value: 0 }, // unreachable: a definite probe is always a member of the single-element list containing only itself
});
const count = (collection: JsonValue, filter?: PredicateNode, probe?: ExpressionNode): ExpressionNode => ({
kind: "fold",
collection,
filter,
combiner: {
mode: "reduce",
initial: { kind: "numberLiteral", value: 0 },
combine: {
kind: "arithmetic",
op: "add",
left: { kind: "accumulator" },
right: probe ? presenceOf(probe) : { kind: "numberLiteral", value: 1 },
},
},
});
const average = (collection: JsonValue, item: ExpressionNode, filter?: PredicateNode): ExpressionNode => ({
kind: "arithmetic",
op: "divide",
left: sum(collection, item, filter),
right: count(collection, filter),
});sum needs no per-item probe beyond item itself: it is a literal reduce seeded at 0, adding each participating item's projected value to the running total, and it already goes indeterminate if item fails to resolve for any participating item — no separate mechanism needed, since item's value is exactly what gets added.
count takes an optional third argument, probe, and this is where it matters that filter and a probe are not the same thing. filter excludes an item from participating — a filtered-out item's absence is invisible in the final result, exactly as if it had never been in the collection at all. A probe does the opposite: it doesn't decide whether an item participates, it makes the whole count indeterminate if it fails to resolve for any participating item, surfacing "I cannot give you a trustworthy count" rather than silently reporting a smaller, technically-successful count for the same underlying data-quality problem — precisely the distinction the rest of this document's indeterminate-outcome model exists to preserve (see The evaluation model). count(collection, filter) with no probe is a plain reduce seeded at 0 that adds 1 per participating item, with no indeterminacy of its own beyond filter's. count(collection, filter, probe) instead adds presenceOf(probe) per participating item — a small helper built entirely from already-established primitives, with no restriction on probe's kind: it tests probe for membership in the single-element list [probe], so a memberOf "in" test against itself is trivially true whenever probe resolves to a definite value of any kind (memberOf's equality is already kind-agnostic across number/text/boolean/instant/duration — see memberOf), and exactly probe's own indeterminate outcome otherwise, per memberOf's own "evaluate operand first" rule. A conditional then turns that boolean into the number 1; its fallback is never reached, since a definite probe always equals itself. (A real implementation may memoise probe's single evaluation rather than running the resolver twice for operand and its one candidates entry — resolvers are pure functions of their inputs throughout this design, so this is a performance choice, not a correctness one.)
average is sum divided by count over the same collection/filter, with no probe — sum's own item already forces every participating item's projected value to resolve, so average's numerator is already indeterminate under exactly the condition a count probe exists to detect, with nothing left to duplicate. Nothing new to verify for the empty-collection case either: division's own already-established rule (zero divisor is domain-error) is why average over an empty collection is domain-error, since count over an empty collection is 0 and sum(...)/0 already means exactly that.
Derived values
coalesce is never its own evaluated node kind — it is a builder function that assembles an ordinary conditional, the same treatment Derived connectives and Derived aggregates already give xor/nand/nor/implies/iff/none/sum/count/average: correctness is inherited from the mechanism it's built from, rather than needing its own independent implementation that could silently drift from it.
const coalesce = (
first: ExpressionNode,
second: ExpressionNode,
...rest: ExpressionNode[]
): ExpressionNode =>
[first, second, ...rest].reduceRight(
(fallback, candidate): ExpressionNode => ({
kind: "conditional",
cases: [{ when: { kind: "exists", operand: candidate }, then: candidate }],
fallback,
}),
);Built right-to-left over the full candidate list via reduceRight, needing no seed value: first/second are required arguments (rather than accepting a single ExpressionNode[]), which guarantees the list always has at least two elements, so the no-initial-value overload of reduceRight never hits an empty array.
Worked correctness check. coalesce's only interesting behaviour — whether a given candidate is skipped past or propagated — reduces entirely to what its single exists probe reports, per exists's and conditional's own already-established rules:
| A candidate's own evaluation | exists(candidate) | The conditional case | coalesce evaluates to |
|---|---|---|---|
| A definite value | definite true | matches | The candidate's value (re-evaluated as then, same result) |
| Indeterminate, not-found | definite false | does not match | The next candidate (the enclosing fallback), evaluated fresh |
| Indeterminate, wrong-type | definite true | matches | The candidate's own wrong-type result (re-evaluated as then) |
| Indeterminate, domain-error | definite true | matches | The candidate's own domain-error result (re-evaluated as then) |
Falling through to the next candidate therefore happens only on exists's own false — a genuinely absent value (not-found) — never on a candidate that resolved to something merely unusable (wrong-type/domain-error): exists already draws exactly that line, and coalesce inherits it unmodified rather than re-deciding it. This is the one behaviour a naive reimplementation is likely to get backwards (treating any indeterminate candidate as "try the next one"), so it is worth stating explicitly rather than leaving it to be inferred from the composition alone.
A real implementation may memoise a candidate's single evaluation rather than running its resolver twice — once for the exists probe, once again for then — exactly the same performance caveat Derived aggregates's presenceOf already documents for its own memberOf probe; resolvers are pure functions of their inputs throughout this design, so this is a performance choice, not a correctness one.
Defining your own named presets
This is exactly the same composition-not-new-logic treatment already given to xor/sum/coalesce above — nothing stops application code from defining its own named builder functions the same way, for whatever domain-specific composed queries come up repeatedly in a given consumer's own rules.
/** isRecentlyActive(30) reads as "the item's lastActiveAt instant is within the last 30 days" — a small, named composition over compare/arithmetic, exactly the same "assembles ordinary nodes" treatment sum/coalesce already get above. Built as `now + (-days)` rather than `now - days`: per "Temporal values" above, `insta