@omgbase/oqx 0.14.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +139 -6
  3. package/dist/adapters/indexed.d.ts +13 -1
  4. package/dist/adapters/indexed.d.ts.map +1 -1
  5. package/dist/adapters/indexed.js +55 -24
  6. package/dist/adapters/indexed.js.map +1 -1
  7. package/dist/context.d.ts +24 -0
  8. package/dist/context.d.ts.map +1 -1
  9. package/dist/context.js.map +1 -1
  10. package/dist/engine.d.ts +55 -2
  11. package/dist/engine.d.ts.map +1 -1
  12. package/dist/engine.js +268 -35
  13. package/dist/engine.js.map +1 -1
  14. package/dist/index.d.ts +3 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/optimize/analysis.d.ts +45 -0
  19. package/dist/optimize/analysis.d.ts.map +1 -0
  20. package/dist/optimize/analysis.js +194 -0
  21. package/dist/optimize/analysis.js.map +1 -0
  22. package/dist/optimize/driver.d.ts +8 -0
  23. package/dist/optimize/driver.d.ts.map +1 -0
  24. package/dist/optimize/driver.js +35 -0
  25. package/dist/optimize/driver.js.map +1 -0
  26. package/dist/optimize/hash-index.d.ts +34 -0
  27. package/dist/optimize/hash-index.d.ts.map +1 -0
  28. package/dist/optimize/hash-index.js +79 -0
  29. package/dist/optimize/hash-index.js.map +1 -0
  30. package/dist/optimize/index.d.ts +8 -0
  31. package/dist/optimize/index.d.ts.map +1 -0
  32. package/dist/optimize/index.js +11 -0
  33. package/dist/optimize/index.js.map +1 -0
  34. package/dist/optimize/ir.d.ts +84 -0
  35. package/dist/optimize/ir.d.ts.map +1 -0
  36. package/dist/optimize/ir.js +60 -0
  37. package/dist/optimize/ir.js.map +1 -0
  38. package/dist/optimize/rules.d.ts +8 -0
  39. package/dist/optimize/rules.d.ts.map +1 -0
  40. package/dist/optimize/rules.js +136 -0
  41. package/dist/optimize/rules.js.map +1 -0
  42. package/package.json +1 -1
  43. package/src/adapters/indexed.ts +60 -24
  44. package/src/context.ts +24 -0
  45. package/src/engine.ts +303 -33
  46. package/src/index.ts +6 -1
  47. package/src/optimize/analysis.ts +183 -0
  48. package/src/optimize/driver.ts +37 -0
  49. package/src/optimize/hash-index.ts +91 -0
  50. package/src/optimize/index.ts +16 -0
  51. package/src/optimize/ir.ts +128 -0
  52. package/src/optimize/rules.ts +136 -0
@@ -0,0 +1,37 @@
1
+ // The optimizer driver: applies a rule set to a block's logical plan until no
2
+ // rule changes it (a fixpoint, bounded), and caches the result per AST node —
3
+ // the analysis is syntactic, so a plan depends only on the node, its static
4
+ // depth, the run's binding count and the rule set.
5
+
6
+ import type { OpNode } from "../ast.ts";
7
+ import type { BlockPlan, Rule, RuleContext } from "./ir.ts";
8
+ import { logicalBlock } from "./ir.ts";
9
+
10
+ const MAX_PASSES = 8;
11
+
12
+ /** Rewrite `logicalBlock(node, depth)` to a fixpoint of `rules`. */
13
+ export function optimizeBlock(node: OpNode, depth: number, ctx: RuleContext, rules: readonly Rule[]): BlockPlan {
14
+ let plan = logicalBlock(node, depth);
15
+ for (let pass = 0; pass < MAX_PASSES; pass++) {
16
+ let changed = false;
17
+ for (const rule of rules) {
18
+ const next = rule(plan, ctx);
19
+ if (next !== null && next !== plan) { plan = next; changed = true; }
20
+ }
21
+ if (!changed) break;
22
+ }
23
+ return plan;
24
+ }
25
+
26
+ interface Cached { depth: number; bindingCount: number; rules: readonly Rule[]; plan: BlockPlan }
27
+ const cache = new WeakMap<OpNode, Cached>();
28
+
29
+ /** `optimizeBlock`, memoized on the node (re-planned when depth, binding count
30
+ * or rule set differ from the cached entry). */
31
+ export function planFor(node: OpNode, depth: number, ctx: RuleContext, rules: readonly Rule[]): BlockPlan {
32
+ const hit = cache.get(node);
33
+ if (hit && hit.depth === depth && hit.bindingCount === ctx.bindingCount && hit.rules === rules) return hit.plan;
34
+ const plan = optimizeBlock(node, depth, ctx, rules);
35
+ cache.set(node, { depth, bindingCount: ctx.bindingCount, rules, plan });
36
+ return plan;
37
+ }
@@ -0,0 +1,91 @@
1
+ // A hash index over the rows of one collection, keyed under OQX equality
2
+ // (SEMANTICS §5) — the only key discipline that makes an index probe return
3
+ // exactly the rows a `local == value` scan would keep:
4
+ //
5
+ // • absent is one value: `undefined` and `null` share a bucket;
6
+ // • numbers are doubles: `-0` meets `0`; `NaN` equals nothing, so a row whose
7
+ // key is `NaN` is never indexed and a probe for `NaN` finds nothing;
8
+ // • strings, booleans and bigints key by value with a type tag, so `"5"`,
9
+ // `5` and `true` stay apart;
10
+ // • objects, arrays, functions and symbols key by REFERENCE — the reference
11
+ // engine's `===` — never structurally.
12
+ //
13
+ // Buckets hold row POSITIONS in ascending order, so a probe yields rows in the
14
+ // receiver's order (§3 and §13 — a block's rows are the receiver's, in order).
15
+
16
+ /** A pre-built equality index a `DataContext` may expose for a collection
17
+ * (see `DataContext.indexFor`): `lookup(value)` returns the ascending positions
18
+ * (into `toRows(collection)`) of the rows whose key equals `value` under §5.
19
+ *
20
+ * `lookupRows`, when present, answers the same probe with the ROWS themselves —
21
+ * exactly the rows `lookup(value)` would select, in receiver order — without
22
+ * the engine ever materializing `toRows(collection)`. A store-backed context
23
+ * implements it over its own indexes (one indexed statement per probe); the
24
+ * engine prefers it for a statically stable receiver, so the collection is
25
+ * never read whole. A throw is the query's error (as a `get` that throws is). */
26
+ export interface RowIndex {
27
+ lookup(value: unknown): readonly number[];
28
+ lookupRows?(value: unknown): Iterable<unknown>;
29
+ }
30
+
31
+ const NONE: readonly number[] = Object.freeze([]);
32
+
33
+ /** The engine's own `RowIndex`: `add` every row's key in row order, then probe. */
34
+ export class HashIndex implements RowIndex {
35
+ private prim = new Map<string, number[]>();
36
+ private refs = new Map<unknown, number[]>();
37
+ private count = 0;
38
+
39
+ /** Record that the row at `position` has key `value`. Positions must be
40
+ * added in ascending order (row order). A `NaN` key is not indexed. */
41
+ add(value: unknown, position: number): void {
42
+ const k = primitiveKey(value);
43
+ if (k === null) return; // NaN: equals nothing
44
+ const map = k === undefined ? this.refs : this.prim;
45
+ const key = k === undefined ? value : k;
46
+ let bucket = map.get(key);
47
+ if (!bucket) map.set(key, (bucket = []));
48
+ bucket.push(position);
49
+ this.count++;
50
+ }
51
+
52
+ lookup(value: unknown): readonly number[] {
53
+ const k = primitiveKey(value);
54
+ if (k === null) return NONE;
55
+ return (k === undefined ? this.refs.get(value) : this.prim.get(k)) ?? NONE;
56
+ }
57
+
58
+ /** How many rows were indexed (rows with a `NaN` key are not). */
59
+ get size(): number {
60
+ return this.count;
61
+ }
62
+ }
63
+
64
+ /** The tagged bucket key of a primitive, `null` for `NaN` (no key: it equals
65
+ * nothing), `undefined` for a reference-keyed value (object, array, function,
66
+ * symbol — compared by identity). Mirrors `semantics.equals`: `===` after
67
+ * normalizing `undefined` to `null`. */
68
+ export function primitiveKey(v: unknown): string | null | undefined {
69
+ switch (typeof v) {
70
+ case "undefined": return "∅"; // absent ≡ absent
71
+ case "object": return v === null ? "∅" : undefined;
72
+ case "number": return Number.isNaN(v) ? null : `n${v}`; // `${-0}` is "0": -0 meets 0
73
+ case "string": return `s${v}`;
74
+ case "boolean": return v ? "t" : "f";
75
+ case "bigint": return `i${v}`;
76
+ default: return undefined; // function, symbol: reference identity
77
+ }
78
+ }
79
+
80
+ /** Intersect two ascending position lists, ascending. */
81
+ export function intersectPositions(a: readonly number[], b: readonly number[]): readonly number[] {
82
+ const out: number[] = [];
83
+ let i = 0, j = 0;
84
+ while (i < a.length && j < b.length) {
85
+ const x = a[i]!, y = b[j]!;
86
+ if (x === y) { out.push(x); i++; j++; }
87
+ else if (x < y) i++;
88
+ else j++;
89
+ }
90
+ return out;
91
+ }
@@ -0,0 +1,16 @@
1
+ // The optimizer: a logical plan per nested block, rules that rewrite it, a
2
+ // driver that applies them to a fixpoint, and the equality-faithful hash index
3
+ // the hash-probe rule executes with. See `rules.ts` for the catalog and each
4
+ // rule's semantics argument; README "Performance: relational patterns" for the
5
+ // user-facing summary.
6
+
7
+ export type { BlockPlan, Correlation, Rule, RuleContext } from "./ir.ts";
8
+ export { logicalBlock, conjunction, conjuncts, residualWithout, lookupOrder } from "./ir.ts";
9
+ export { optimizeBlock, planFor } from "./driver.ts";
10
+ export { correlatedEqualityProbe, stableReceiver, invariantBlock, cardinalityOnly, DEFAULT_RULES } from "./rules.ts";
11
+ export type { RowIndex } from "./hash-index.ts";
12
+ export { HashIndex, primitiveKey, intersectPositions } from "./hash-index.ts";
13
+ export {
14
+ exprReadsScopeIn, whereReadsScopeIn, opReadsScopeIn, exprReadsCurrentScope,
15
+ exprRaiseFree, whereRaiseFree, opRaiseFree, opHasLifts, whereHasLifts, localPath, SCOPE_INTRINSICS,
16
+ } from "./analysis.ts";
@@ -0,0 +1,128 @@
1
+ // The optimizer's logical representation of one nested block (an `OpNode`):
2
+ // where its rows come from, which predicates correlate them with the enclosing
3
+ // rows, what remains to be checked per row, how they are shaped, and how the
4
+ // consumer reduces them. Rules rewrite a `BlockPlan`; the engine executes it.
5
+ //
6
+ // A plan starts as a faithful transcription of the AST (`logicalBlock`): every
7
+ // predicate residual, nothing correlated, nothing memoized. Rules move work
8
+ // from the per-row path into the per-run or per-probe path only when the
9
+ // semantics argument in `rules.ts` holds; the engine's fallbacks (an index that
10
+ // cannot be built, a receiver that is not stable) always degrade to the plain
11
+ // scan the plan started from.
12
+
13
+ import type { Consumer, Expr, Follow, OpNode, OrderSpec, SelectItem, Where } from "../ast.ts";
14
+
15
+ /** One hoisted equality `local == outer` from the block's top-level `&&`
16
+ * conjunction: `local` reads only the block's row (an identifier or member
17
+ * chain; `path` is its property path, `[]` for `$value`), `outer` reads nothing
18
+ * from the block's row (outer references, bindings, literals, arithmetic over
19
+ * them) and cannot raise. */
20
+ export interface Correlation {
21
+ readonly local: Expr;
22
+ readonly outer: Expr;
23
+ readonly path: readonly string[];
24
+ /** Position of the equality among `conjuncts(where)`, so a probe that
25
+ * answers ONE correlation (a store-backed `lookupRows`) can put the others
26
+ * back in their place as residual conjuncts. */
27
+ readonly index: number;
28
+ }
29
+
30
+ /** The plan for one block. `receiver`/`from`/`projection`/`orderBy`/`bound`/
31
+ * `consumer`/`follow`/`distinct` transcribe the AST; `correlated`/`residual`
32
+ * split its `where`; `receiverStable`, `invariant` and `fromCardinality` are the
33
+ * annotations rules add. */
34
+ export interface BlockPlan {
35
+ readonly node: OpNode;
36
+ /** Scope depth of the ENCLOSING scope (root = 0): the receiver is read there;
37
+ * the block's rows are scopes at `depth + 1`. */
38
+ readonly depth: number;
39
+ readonly consumer: Consumer;
40
+ readonly receiver: Expr;
41
+ readonly from: readonly Expr[];
42
+ readonly where: Where | null;
43
+ readonly projection: { readonly select: readonly SelectItem[]; readonly values: boolean };
44
+ readonly orderBy: readonly OrderSpec[] | null;
45
+ readonly distinct: boolean;
46
+ readonly bound: { readonly limit: Expr | undefined; readonly offset: Expr | undefined };
47
+ readonly follow: Follow | null;
48
+ /** Equalities answered by an index probe on the receiver (in conjunct order). */
49
+ readonly correlated: readonly Correlation[];
50
+ /** The conjuncts still evaluated per row once the probe has selected its
51
+ * bucket; equals `where` when nothing is correlated. */
52
+ readonly residual: Where | null;
53
+ /** The receiver reads nothing from any enclosing row (only the root scope
54
+ * and bindings), so its rows are the same for every enclosing row and can be
55
+ * materialized and indexed once per run. */
56
+ readonly receiverStable: boolean;
57
+ /** The whole block reads nothing from any enclosing row and has no side
58
+ * effects on one (no lifts): its value is the same for every enclosing row
59
+ * and is computed once per run. */
60
+ readonly invariant: boolean;
61
+ /** `exists`/`none`/`count` may be answered from the cardinality of the
62
+ * accessed rows when no residual predicate remains (nothing per row to
63
+ * evaluate, order, dedup or project). */
64
+ readonly fromCardinality: boolean;
65
+ }
66
+
67
+ /** What a rule may consult besides the plan. */
68
+ export interface RuleContext {
69
+ /** How many positional bindings this run has (a `${i}` with `i` at or past
70
+ * it raises when evaluated, so it is not raise-free). */
71
+ readonly bindingCount: number;
72
+ }
73
+
74
+ /** A rewrite: return an improved plan, or `null` when the rule does not apply
75
+ * (or has already been applied). Rules must be idempotent on their own output. */
76
+ export type Rule = (plan: BlockPlan, ctx: RuleContext) => BlockPlan | null;
77
+
78
+ /** The faithful transcription of a block: a scan with every predicate residual. */
79
+ export function logicalBlock(node: OpNode, depth: number): BlockPlan {
80
+ const sub = node.sub;
81
+ return {
82
+ node,
83
+ depth,
84
+ consumer: node.op,
85
+ receiver: node.receiver,
86
+ from: sub.from,
87
+ where: sub.where,
88
+ projection: { select: sub.select, values: sub.values ?? false },
89
+ orderBy: sub.orderBy,
90
+ distinct: node.distinct ?? false,
91
+ bound: { limit: sub.limit, offset: sub.offset },
92
+ follow: sub.follow,
93
+ correlated: [],
94
+ residual: sub.where,
95
+ receiverStable: false,
96
+ invariant: false,
97
+ fromCardinality: false,
98
+ };
99
+ }
100
+
101
+ /** Rebuild a conjunction from its remaining parts. */
102
+ export function conjunction(parts: readonly Where[]): Where | null {
103
+ return parts.length === 0 ? null : parts.length === 1 ? parts[0]! : { kind: "and", parts: [...parts] };
104
+ }
105
+
106
+ /** The top-level `&&` conjuncts of a where tree (a non-`and` tree is one). */
107
+ export function conjuncts(where: Where): readonly Where[] {
108
+ return where.kind === "and" ? where.parts : [where];
109
+ }
110
+
111
+ /** The residual when exactly ONE correlation is answered by a probe (a context
112
+ * index's `lookupRows`): every other conjunct, the remaining equalities
113
+ * included, stays in its original place. A kept equality is true by
114
+ * construction for the rows the probe selects and, being two reads, can neither
115
+ * raise nor bind, so the scan's strict left-to-right order and outcome hold. */
116
+ export function residualWithout(plan: BlockPlan, answered: Correlation): Where | null {
117
+ return plan.where ? conjunction(conjuncts(plan.where).filter((_, i) => i !== answered.index)) : null;
118
+ }
119
+
120
+ /** The order in which a single-correlation probe tries the plan's correlations:
121
+ * those whose outer side varies with the enclosing row first (a literal selects
122
+ * the same rows for every enclosing row and narrows nothing), each group in
123
+ * conjunct order. */
124
+ export function lookupOrder(plan: BlockPlan): readonly Correlation[] {
125
+ const all = plan.correlated;
126
+ if (all.length < 2) return all;
127
+ return [...all.filter((c) => c.outer.kind !== "lit"), ...all.filter((c) => c.outer.kind === "lit")];
128
+ }
@@ -0,0 +1,136 @@
1
+ // The rule catalog. Each rule states its precondition and the argument that the
2
+ // rewritten plan is indistinguishable from the scan it replaces — same rows in
3
+ // the same order, same lifts, same error (same message) at the same point, or
4
+ // no error — under SEMANTICS §13's strict left-to-right `&&`.
5
+
6
+ import type { Expr, Where } from "../ast.ts";
7
+ import {
8
+ exprRaiseFree, exprReadsCurrentScope, exprReadsScopeIn, localPath, opHasLifts, opReadsScopeIn,
9
+ whereHasLifts, whereRaiseFree,
10
+ } from "./analysis.ts";
11
+ import type { Correlation, Rule, RuleContext } from "./ir.ts";
12
+ import { conjunction, conjuncts } from "./ir.ts";
13
+
14
+ // ---- rule 1: correlated equality → hash probe ------------------------------------
15
+ //
16
+ // Precondition. The block has no `from` re-projection (its rows ARE the
17
+ // receiver's rows) and no `follow`; its `where` has no lift anywhere (a lift
18
+ // into an enclosing scope by one row could change what the outer side reads
19
+ // for the next row); some top-level conjunct k is `local == outer` (either
20
+ // side) with `local` an identifier/member chain on the block's row and `outer`
21
+ // a raise-free expression reading nothing from the block's row; and every
22
+ // conjunct left of k is raise-free (no call, no `single`, no lift, no bound, no
23
+ // binding past the run's count).
24
+ //
25
+ // Argument. The scan evaluates, per receiver row r in order, c1(r) && … &&
26
+ // ck(r) && …, short-circuiting. The probe selects exactly the rows with
27
+ // local(r) == outer (the index reproduces §5 equality, `outer` is evaluated
28
+ // once because nothing in the block can change what it reads) and evaluates the
29
+ // residual conjunction over them, in receiver order. For a selected row every
30
+ // conjunct is evaluated as the scan would (ck is dropped: it is true by
31
+ // construction and, being two reads, cannot raise or bind). For a row the probe
32
+ // rejects, the scan would have evaluated c1..c(k-1) — which cannot raise or
33
+ // bind — then ck, false, and stopped: no result, no error, no lift. So the two
34
+ // paths keep the same rows, raise the same first error, and bind the same
35
+ // lifts. A probe that cannot be built at run time (a receiver that is not
36
+ // stable, an index whose construction throws) falls back to the scan itself.
37
+
38
+ export const correlatedEqualityProbe: Rule = (plan, ctx) => {
39
+ if (plan.correlated.length > 0 || plan.from.length > 0 || plan.follow || !plan.where) return null;
40
+ if (whereHasLifts(plan.where)) return null;
41
+ const correlated: Correlation[] = [];
42
+ const residual: Where[] = [];
43
+ let prefixRaiseFree = true;
44
+ conjuncts(plan.where).forEach((part, index) => {
45
+ const eq = prefixRaiseFree ? asCorrelation(part, index, ctx) : null;
46
+ if (eq) correlated.push(eq);
47
+ else {
48
+ residual.push(part);
49
+ prefixRaiseFree &&= whereRaiseFree(part, ctx);
50
+ }
51
+ });
52
+ if (correlated.length === 0) return null;
53
+ return { ...plan, correlated, residual: conjunction(residual) };
54
+ };
55
+
56
+ /** `local == outer` / `outer == local` as a correlation, else null. */
57
+ function asCorrelation(w: Where, index: number, ctx: RuleContext): Correlation | null {
58
+ if (w.kind !== "scalar" || w.expr.kind !== "binary" || w.expr.op !== "==") return null;
59
+ const { left, right } = w.expr;
60
+ return pair(left, right, index, ctx) ?? pair(right, left, index, ctx);
61
+ }
62
+
63
+ function pair(local: Expr, outer: Expr, index: number, ctx: RuleContext): Correlation | null {
64
+ const path = localPath(local);
65
+ if (path === null) return null;
66
+ if (exprReadsCurrentScope(outer) || !exprRaiseFree(outer, ctx)) return null;
67
+ return { local, outer, path, index };
68
+ }
69
+
70
+ // ---- rule 1b: a receiver stable across enclosing rows ----------------------------
71
+ //
72
+ // Precondition. The receiver expression, read in the enclosing scope (depth
73
+ // `plan.depth`), reads no scope at depth 1..depth: only the root (named roots,
74
+ // `^…^name` reaching it, or past it) and bindings.
75
+ //
76
+ // Argument. Such an expression yields the same value for every enclosing row
77
+ // (evaluation is pure; the root and the bindings do not change within a run),
78
+ // so its rows can be materialized once per run and indexed once per path. A
79
+ // receiver that does read an enclosing row is still indexable when it happens
80
+ // to evaluate to the same object twice (the engine keys indexes by collection
81
+ // identity as well); a receiver that yields a fresh value per row is scanned.
82
+
83
+ export const stableReceiver: Rule = (plan) => {
84
+ if (plan.receiverStable) return null;
85
+ if (exprReadsScopeIn(plan.receiver, plan.depth, plan.depth)) return null;
86
+ return { ...plan, receiverStable: true };
87
+ };
88
+
89
+ // ---- rule 2: invariant nested block → evaluate once per run ----------------------
90
+ //
91
+ // Precondition. Nothing in the block — receiver, `from`, `where`, `select`,
92
+ // `order by`, bound, `follow` — reads a scope at depth 1..depth (an enclosing
93
+ // row), and the block lifts nothing (a lift is a side effect on an enclosing
94
+ // scope that must recur per row).
95
+ //
96
+ // Argument. The block's value is a function of the root scope, the bindings
97
+ // and its own rows only, all constant within a run, so every evaluation yields
98
+ // the same value or raises the same error. The engine evaluates it the first
99
+ // time the scan would (so an error surfaces at the same point) and returns the
100
+ // memoized value afterwards; a run that raised never reaches a second
101
+ // evaluation. The memoized value is shared by reference across the enclosing
102
+ // rows that project it.
103
+
104
+ export const invariantBlock: Rule = (plan) => {
105
+ if (plan.invariant) return null;
106
+ if (opHasLifts(plan.node)) return null;
107
+ if (opReadsScopeIn(plan.node, plan.depth, plan.depth)) return null;
108
+ return { ...plan, invariant: true };
109
+ };
110
+
111
+ // ---- rule 3: exists / none / count from cardinality -------------------------------
112
+ //
113
+ // Precondition. A where-position `exists`, `none` or `count` without `distinct`
114
+ // (dedup projects every row), without `order by` (`count` evaluates the keys),
115
+ // without `follow`, and with nothing left to evaluate per row: either the block
116
+ // has no `where`, or every conjunct is correlated (so the probe's bucket is the
117
+ // matched set).
118
+ //
119
+ // Argument. With no residual predicate the matched rows are exactly the
120
+ // accessed rows (§13: the receiver's rows filtered by `where`), entering a row
121
+ // as a scope cannot raise, and the consumers only need the count after the
122
+ // bound (§18): `count` is `boundedCount(n)`, `exists` is `boundedCount(n) > 0`,
123
+ // `none` its negation — what the scan computes after materializing. When the
124
+ // probe is unavailable at run time the residual is the whole `where` and the
125
+ // engine takes the per-row path.
126
+
127
+ export const cardinalityOnly: Rule = (plan) => {
128
+ if (plan.fromCardinality) return null;
129
+ if (plan.consumer !== "exists" && plan.consumer !== "none" && plan.consumer !== "count") return null;
130
+ if (plan.distinct || plan.orderBy || plan.follow) return null;
131
+ if (plan.where !== null && plan.residual !== null) return null;
132
+ return { ...plan, fromCardinality: true };
133
+ };
134
+
135
+ /** The rules the engine applies by default, in order. */
136
+ export const DEFAULT_RULES: readonly Rule[] = [correlatedEqualityProbe, stableReceiver, invariantBlock, cardinalityOnly];