@omgbase/oqx 0.14.0 → 0.15.0

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 (61) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +162 -24
  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/ast.d.ts +1 -1
  8. package/dist/ast.d.ts.map +1 -1
  9. package/dist/context.d.ts +24 -0
  10. package/dist/context.d.ts.map +1 -1
  11. package/dist/context.js.map +1 -1
  12. package/dist/engine.d.ts +55 -2
  13. package/dist/engine.d.ts.map +1 -1
  14. package/dist/engine.js +272 -39
  15. package/dist/engine.js.map +1 -1
  16. package/dist/index.d.ts +4 -2
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +2 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/optimize/analysis.d.ts +45 -0
  21. package/dist/optimize/analysis.d.ts.map +1 -0
  22. package/dist/optimize/analysis.js +194 -0
  23. package/dist/optimize/analysis.js.map +1 -0
  24. package/dist/optimize/driver.d.ts +8 -0
  25. package/dist/optimize/driver.d.ts.map +1 -0
  26. package/dist/optimize/driver.js +35 -0
  27. package/dist/optimize/driver.js.map +1 -0
  28. package/dist/optimize/hash-index.d.ts +34 -0
  29. package/dist/optimize/hash-index.d.ts.map +1 -0
  30. package/dist/optimize/hash-index.js +79 -0
  31. package/dist/optimize/hash-index.js.map +1 -0
  32. package/dist/optimize/index.d.ts +8 -0
  33. package/dist/optimize/index.d.ts.map +1 -0
  34. package/dist/optimize/index.js +11 -0
  35. package/dist/optimize/index.js.map +1 -0
  36. package/dist/optimize/ir.d.ts +84 -0
  37. package/dist/optimize/ir.d.ts.map +1 -0
  38. package/dist/optimize/ir.js +60 -0
  39. package/dist/optimize/ir.js.map +1 -0
  40. package/dist/optimize/rules.d.ts +8 -0
  41. package/dist/optimize/rules.d.ts.map +1 -0
  42. package/dist/optimize/rules.js +136 -0
  43. package/dist/optimize/rules.js.map +1 -0
  44. package/dist/plan.js +1 -1
  45. package/dist/plan.js.map +1 -1
  46. package/dist/semantics.js +1 -1
  47. package/dist/semantics.js.map +1 -1
  48. package/package.json +1 -1
  49. package/src/adapters/indexed.ts +60 -24
  50. package/src/ast.ts +2 -2
  51. package/src/context.ts +24 -0
  52. package/src/engine.ts +307 -37
  53. package/src/index.ts +7 -2
  54. package/src/optimize/analysis.ts +183 -0
  55. package/src/optimize/driver.ts +37 -0
  56. package/src/optimize/hash-index.ts +91 -0
  57. package/src/optimize/index.ts +16 -0
  58. package/src/optimize/ir.ts +128 -0
  59. package/src/optimize/rules.ts +136 -0
  60. package/src/plan.ts +1 -1
  61. package/src/semantics.ts +1 -1
package/dist/engine.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- import type { Query } from "./ast.js";
1
+ import type { Query, OpNode } from "./ast.js";
2
2
  import type { DataContext } from "./context.js";
3
+ import type { Rule } from "./optimize/index.js";
3
4
  /** The shaped result of a top-level query, discriminated by consumer. */
4
5
  export type OqxResult = {
5
6
  consumer: "collect";
@@ -24,9 +25,47 @@ export type OqxResult = {
24
25
  export interface Engine {
25
26
  run(query: Query, bindings: readonly unknown[]): OqxResult;
26
27
  }
28
+ /** What the optimizer did at run time (for tests and diagnostics): an index
29
+ * built (or taken from the context) for a collection and path, a probe that
30
+ * selected a bucket, a context index that answered a probe with rows directly
31
+ * (`lookupRows`: the collection was never materialized), a memoized invariant
32
+ * block reused, a consumer answered from a cardinality, or a probe that fell
33
+ * back to the scan. */
34
+ export type TraceEvent = {
35
+ kind: "index";
36
+ path: readonly string[];
37
+ rows: number;
38
+ source: "engine" | "context";
39
+ } | {
40
+ kind: "probe";
41
+ paths: readonly (readonly string[])[];
42
+ candidates: number;
43
+ } | {
44
+ kind: "lookup";
45
+ path: readonly string[];
46
+ candidates: number;
47
+ } | {
48
+ kind: "memo";
49
+ op: OpNode;
50
+ } | {
51
+ kind: "cardinality";
52
+ count: number;
53
+ } | {
54
+ kind: "fallback";
55
+ reason: "unstable" | "index" | "probe-value" | "not-a-collection";
56
+ };
57
+ export interface InMemoryEngineOptions {
58
+ /** The optimizer rules to apply to nested blocks (default `DEFAULT_RULES`);
59
+ * `[]` runs the naive scan everywhere. */
60
+ rules?: readonly Rule[];
61
+ /** Receives a `TraceEvent` for each optimizer action during `run`. */
62
+ trace?: (event: TraceEvent) => void;
63
+ }
27
64
  export declare class InMemoryEngine implements Engine {
28
65
  private ctx;
29
- constructor(context?: DataContext);
66
+ private rules;
67
+ private trace;
68
+ constructor(context?: DataContext, options?: InMemoryEngineOptions);
30
69
  run(query: Query, bindings?: readonly unknown[]): OqxResult;
31
70
  private boundOf;
32
71
  private matches;
@@ -41,13 +80,27 @@ export declare class InMemoryEngine implements Engine {
41
80
  private itemValue;
42
81
  private evalWhere;
43
82
  private evalWhereOp;
83
+ private evalWhereOpPlanned;
44
84
  private opRows;
45
85
  private anyMatch;
46
86
  private iterMatchRows;
47
87
  private matchRows;
88
+ private planOf;
89
+ private memoized;
90
+ private access;
91
+ private scan;
92
+ private reproject;
93
+ private materialize;
94
+ private lookupRows;
95
+ private probe;
96
+ private outerValue;
97
+ private contextIndex;
98
+ private indexFor;
48
99
  private evalCollectValue;
100
+ private evalCollectValuePlanned;
49
101
  private sortScopes;
50
102
  private evalExpr;
103
+ private evalRaw;
51
104
  private resolveIn;
52
105
  private evalCall;
53
106
  }
@@ -1 +1 @@
1
- {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EACV,KAAK,EACN,MAAM,UAAU,CAAC;AAClB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAOhD,yEAAyE;AACzE,MAAM,MAAM,SAAS,GACjB;IAAE,QAAQ,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE,OAAO,EAAE,CAAA;CAAE,GACxC;IAAE,QAAQ,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,GACvC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GACnC;IAAE,QAAQ,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GACpC;IAAE,QAAQ,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,OAAO,GAAG,IAAI,CAAA;CAAE,GAC1C;IAAE,QAAQ,EAAE,QAAQ,CAAC;IAAC,GAAG,EAAE,OAAO,GAAG,IAAI,CAAA;CAAE,CAAC;AAEhD,6EAA6E;AAC7E,MAAM,WAAW,MAAM;IACrB,GAAG,CAAC,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAS,OAAO,EAAE,GAAG,SAAS,CAAC;CAC5D;AA4BD,qBAAa,cAAe,YAAW,MAAM;IAC3C,OAAO,CAAC,GAAG,CAAc;gBAEb,OAAO,GAAE,WAAkC;IAIvD,GAAG,CAAC,KAAK,EAAE,KAAK,EAAE,QAAQ,GAAE,SAAS,OAAO,EAAO,GAAG,SAAS;IAmD/D,OAAO,CAAC,OAAO;IAef,OAAO,CAAC,OAAO;IAKf,OAAO,CAAC,MAAM;IAId,OAAO,CAAC,KAAK;IASb,OAAO,CAAC,KAAK;IAab,OAAO,CAAC,SAAS;IAiBjB,OAAO,CAAC,iBAAiB;IAuCzB,OAAO,CAAC,UAAU;IA4ElB,OAAO,CAAC,KAAK;IAgBb,OAAO,CAAC,UAAU;IASlB,OAAO,CAAC,SAAS;IAMjB,OAAO,CAAC,SAAS;IAYjB,OAAO,CAAC,WAAW;IAiCnB,OAAO,CAAC,MAAM;IAQd,OAAO,CAAC,QAAQ;IAKhB,OAAO,CAAE,aAAa;IAStB,OAAO,CAAC,SAAS;IAKjB,OAAO,CAAC,gBAAgB;IA2BxB,OAAO,CAAC,UAAU;IAwBlB,OAAO,CAAC,QAAQ;IAsEhB,OAAO,CAAC,SAAS;IAQjB,OAAO,CAAC,QAAQ;CAYjB;AAuGD,8EAA8E;AAC9E,wBAAgB,QAAQ,CAAC,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAS,OAAO,EAAE,EAAE,KAAK,EAAE,OAAO,GAAG,SAAS,CAG9F"}
1
+ {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"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"}
package/dist/engine.js CHANGED
@@ -8,6 +8,12 @@
8
8
  // and `&&` evaluates strictly left to right — evaluation order is observable
9
9
  // through errors, so the engine never reorders conjuncts.)
10
10
  //
11
+ // Nested blocks run through the optimizer (`./optimize`): each block gets a
12
+ // `BlockPlan` the engine only EXECUTES (`access`), degrading to the plain scan
13
+ // whenever the planned access path is unavailable at run time. The rules and
14
+ // their semantics arguments live in `optimize/rules.ts`; `rules: []` turns the
15
+ // optimizer off, which the conformance suite uses to compare both paths.
16
+ //
11
17
  // Name resolution is strictly lexical and LOCAL: a bare identifier is read from
12
18
  // the current scope only, and an enclosing scope is reached solely through an
13
19
  // explicit `^name` (exactly one scope out per caret). There is no implicit
@@ -15,21 +21,32 @@
15
21
  import { DefaultContext } from "./context.js";
16
22
  import { OqxError } from "./errors.js";
17
23
  import { relate, arith, membership, truthy, compareForSort, compare, canonicalKey, makeRange, isEntry, isRange, } from "./semantics.js";
24
+ import { DEFAULT_RULES, HashIndex, intersectPositions, lookupOrder, planFor, residualWithout } from "./optimize/index.js";
18
25
  const RECUR = new Set(["$depth", "$stop", "$leaf", "$frontier", "$ordinal"]);
19
26
  const KEY = "$key";
20
27
  const HARD_DEPTH_CAP = 8;
21
28
  const UNBOUNDED = { offset: 0, limit: null };
22
29
  export class InMemoryEngine {
23
30
  ctx;
24
- constructor(context = new DefaultContext()) {
31
+ rules;
32
+ trace;
33
+ constructor(context = new DefaultContext(), options = {}) {
25
34
  this.ctx = context;
35
+ this.rules = options.rules ?? DEFAULT_RULES;
36
+ this.trace = options.trace;
26
37
  }
27
38
  run(query, bindings = []) {
28
- const root = { row: null, parent: null, bindings };
29
- let rows = this.rowsOf(this.evalExpr(query.source, root));
30
- for (const proj of query.from) {
31
- rows = rows.flatMap((r) => this.rowsOf(this.evalExpr(proj, this.child(r, root))));
32
- }
39
+ const run = {
40
+ rules: this.rules,
41
+ bindingCount: bindings.length,
42
+ collections: new WeakMap(),
43
+ stableCollections: new Map(),
44
+ memo: new Map(),
45
+ };
46
+ if (this.trace)
47
+ run.trace = this.trace;
48
+ const root = { row: null, parent: null, bindings, run };
49
+ const rows = this.reproject(this.rowsOf(this.evalRaw(query.source, root)), query.from, root);
33
50
  const bound = this.boundOf(query, root);
34
51
  if (query.follow)
35
52
  return this.runFollow(query, rows, root, bound);
@@ -83,7 +100,7 @@ export class InMemoryEngine {
83
100
  boundOf(b, enclosing) {
84
101
  if (!b.limit && !b.offset)
85
102
  return UNBOUNDED;
86
- const scope = enclosing.parent === null ? enclosing : { row: undefined, parent: enclosing, bindings: enclosing.bindings };
103
+ const scope = enclosing.parent === null ? enclosing : rowless(enclosing);
87
104
  const read = (e, word) => {
88
105
  if (!e)
89
106
  return null;
@@ -108,12 +125,12 @@ export class InMemoryEngine {
108
125
  return this.enter(row, parent);
109
126
  }
110
127
  // Make the scope for a row. An `entries()` entry is unwrapped here: the scope's
111
- // row is the property's VALUE (so `$value` and bare names read it) and the key
128
+ // row is the property's VALUE (so `$it` and bare names read it) and the key
112
129
  // becomes the `$key` intrinsic in `meta`. Every place a row becomes a scope
113
130
  // goes through this, so entries behave the same at the top level, in nested
114
131
  // blocks, as `from` re-projections, and as follow seeds.
115
132
  enter(row, parent, extra = {}) {
116
- const s = { row, parent, bindings: parent.bindings };
133
+ const s = { row, parent, bindings: parent.bindings, run: parent.run };
117
134
  if (extra.lifts)
118
135
  s.lifts = extra.lifts;
119
136
  if (extra.meta)
@@ -190,7 +207,7 @@ export class InMemoryEngine {
190
207
  const rowScope = scopeFor(row);
191
208
  const raw = [];
192
209
  for (const dest of follow.destinations) {
193
- const v = dest.kind === "op" ? this.evalCollectValue(dest, rowScope) : this.evalExpr(dest, rowScope);
210
+ const v = dest.kind === "op" ? this.evalCollectValue(dest, rowScope) : this.evalRaw(dest, rowScope);
194
211
  raw.push(...this.rowsOf(v));
195
212
  }
196
213
  const kept = follow.where ? raw.filter((x) => truthy(this.evalExpr(follow.where, this.enter(x, rowScope)))) : raw;
@@ -303,12 +320,32 @@ export class InMemoryEngine {
303
320
  evalWhereOp(op, scope) {
304
321
  if (op.sub.follow)
305
322
  throw new OqxError("`follow` is only valid on a select-position collect { … }, not a where op", "eval");
323
+ const plan = this.planOf(op, scope);
324
+ if (!plan.invariant)
325
+ return this.evalWhereOpPlanned(op, plan, scope);
326
+ return this.memoized(op, scope, () => this.evalWhereOpPlanned(op, plan, scope));
327
+ }
328
+ evalWhereOpPlanned(op, plan, scope) {
306
329
  const bound = this.boundOf(op.sub, scope);
307
- if (op.op === "exists" || op.op === "none") {
308
- // Unbounded: stop at the first match (dedup cannot change emptiness).
309
- // Bounded: the offset/limit decide emptiness, so materialize the set.
310
- const any = bound === UNBOUNDED ? this.anyMatch(op, scope) : this.opRows(op, scope, bound).length > 0;
311
- return op.op === "exists" ? any : !any;
330
+ if (op.op === "exists" || op.op === "none" || op.op === "count") {
331
+ const acc = this.access(op, plan, scope);
332
+ let n;
333
+ if (plan.fromCardinality && acc.where === null) {
334
+ // Nothing left to evaluate per row: the accessed rows ARE the matched
335
+ // rows, and the consumer only needs their count after the bound.
336
+ n = boundedCount(acc.rows.length, bound);
337
+ scope.run.trace?.({ kind: "cardinality", count: n });
338
+ }
339
+ else if (op.op !== "count" && bound === UNBOUNDED) {
340
+ // Unbounded exists/none: stop at the first match (dedup cannot change
341
+ // emptiness). Bounded: the offset/limit decide emptiness, so materialize.
342
+ n = this.anyMatch(op, scope, acc) ? 1 : 0;
343
+ }
344
+ else
345
+ n = this.opRows(op, scope, bound, acc).length;
346
+ if (op.op === "count")
347
+ return op.countCmp ? compareCount(n, op.countCmp) : n > 0;
348
+ return op.op === "exists" ? n > 0 : n === 0;
312
349
  }
313
350
  if (op.op === "collect") {
314
351
  const matched = this.opRows(op, scope, bound);
@@ -328,51 +365,218 @@ export class InMemoryEngine {
328
365
  }
329
366
  return matched.length > 0;
330
367
  }
331
- if (op.op === "count") {
332
- const n = this.opRows(op, scope, bound).length;
333
- return op.countCmp ? compareCount(n, op.countCmp) : n > 0;
334
- }
335
368
  return this.opRows(op, scope, bound).length > 0;
336
369
  }
337
370
  // The rows a consumer op reduces: matched → ordered → distinct → bounded.
338
- opRows(op, scope, bound) {
339
- let scopes = this.matchRows(op, scope);
371
+ opRows(op, scope, bound, acc) {
372
+ let scopes = this.matchRows(op, scope, acc);
340
373
  this.sortScopes(scopes, op.sub.orderBy);
341
374
  if (op.distinct)
342
375
  scopes = this.dedupByProjection(scopes, op.sub);
343
376
  return sliceBound(scopes, bound);
344
377
  }
345
- // Short-circuiting existence check over a consumer receiver.
346
- anyMatch(op, scope) {
347
- for (const s of this.iterMatchRows(op, scope)) {
378
+ // Short-circuiting existence check: over a probe's bucket this stops at the
379
+ // first row that passes the residual, so `exists`/`none` never materialize it.
380
+ anyMatch(op, scope, acc) {
381
+ for (const s of this.iterMatchRows(op, scope, acc)) {
348
382
  void s;
349
383
  return true;
350
384
  }
351
385
  return false;
352
386
  }
353
- *iterMatchRows(op, scope) {
354
- let rows = this.rowsOf(this.evalExpr(op.receiver, scope));
355
- for (const proj of op.sub.from)
356
- rows = rows.flatMap((r) => this.rowsOf(this.evalExpr(proj, this.child(r, scope))));
387
+ // The block's accessed rows, entered as scopes under `scope`, filtered by the
388
+ // predicate the access left to evaluate.
389
+ *iterMatchRows(op, scope, acc) {
390
+ const { rows, where } = acc ?? this.access(op, this.planOf(op, scope), scope);
357
391
  for (const r of rows) {
358
392
  const s = this.enter(r, scope, { lifts: {} });
359
- if (!op.sub.where || this.evalWhere(op.sub.where, s))
393
+ if (!where || this.evalWhere(where, s))
360
394
  yield s;
361
395
  }
362
396
  }
363
- matchRows(op, scope) {
364
- return Array.from(this.iterMatchRows(op, scope));
397
+ matchRows(op, scope, acc) {
398
+ return Array.from(this.iterMatchRows(op, scope, acc));
399
+ }
400
+ // ---- planned access -------------------------------------------------------
401
+ //
402
+ // The engine EXECUTES a block's `BlockPlan`; the rules and the semantics
403
+ // argument for each live in `./optimize` (rules.ts). `access` is the one path
404
+ // every consumer takes to a block's rows, and whatever the plan asked for
405
+ // that cannot be served at run time degrades to the scan the plan started from.
406
+ planOf(op, scope) {
407
+ return planFor(op, scopeDepth(scope), { bindingCount: scope.run.bindingCount }, scope.run.rules);
408
+ }
409
+ // An invariant block (rule 2) is evaluated the first time it is reached and
410
+ // its value reused, by reference, for every other enclosing row.
411
+ memoized(op, scope, compute) {
412
+ const memo = scope.run.memo;
413
+ if (memo.has(op)) {
414
+ scope.run.trace?.({ kind: "memo", op });
415
+ return memo.get(op);
416
+ }
417
+ const value = compute();
418
+ memo.set(op, value);
419
+ return value;
420
+ }
421
+ // A block's rows and the predicate left to evaluate over each. A correlated
422
+ // plan is served, in order of preference, by a context index that yields rows
423
+ // (`lookupRows` — asked on every sight: it costs nothing and the collection
424
+ // is never read), by a probe on the engine's own index (built on the
425
+ // collection's second sight, or at once for a statically stable receiver: a
426
+ // collection seen once is cheaper to scan than to index), else by the scan.
427
+ // A statically stable receiver (rule 1b) is evaluated once per run; any other
428
+ // is keyed by its value's identity, so a fresh array per row is never indexed.
429
+ access(op, plan, scope) {
430
+ const run = scope.run;
431
+ let state = plan.receiverStable ? run.stableCollections.get(op) : undefined;
432
+ if (!state) {
433
+ const value = this.evalRaw(op.receiver, scope);
434
+ if (plan.receiverStable)
435
+ run.stableCollections.set(op, (state = newCollection(value)));
436
+ else if (plan.correlated.length === 0)
437
+ return this.scan(this.rowsOf(value), op, scope);
438
+ else if (value !== null && typeof value === "object") {
439
+ state = run.collections.get(value);
440
+ if (!state)
441
+ run.collections.set(value, (state = newCollection(value)));
442
+ }
443
+ else {
444
+ run.trace?.({ kind: "fallback", reason: "not-a-collection" });
445
+ return this.scan(this.rowsOf(value), op, scope);
446
+ }
447
+ }
448
+ state.seen++;
449
+ if (plan.correlated.length > 0) {
450
+ const direct = this.lookupRows(plan, state, scope);
451
+ if (direct)
452
+ return direct;
453
+ if (state.seen >= 2 || plan.receiverStable) {
454
+ const probed = this.probe(plan, state, scope);
455
+ if (probed)
456
+ return { rows: probed, where: plan.residual };
457
+ }
458
+ else
459
+ run.trace?.({ kind: "fallback", reason: "unstable" });
460
+ }
461
+ return this.scan(this.materialize(state), op, scope);
462
+ }
463
+ // The plain scan: the rows, re-projected by `from`, with the whole `where`.
464
+ scan(rows, op, scope) {
465
+ return { rows: this.reproject(rows, op.sub.from, scope), where: op.sub.where };
466
+ }
467
+ reproject(rows, from, scope) {
468
+ for (const proj of from)
469
+ rows = rows.flatMap((r) => this.rowsOf(this.evalRaw(proj, this.child(r, scope))));
470
+ return rows;
471
+ }
472
+ materialize(state) {
473
+ return (state.rows ??= this.rowsOf(state.value));
474
+ }
475
+ // A probe answered by a context index that yields rows directly, before the
476
+ // collection is materialized: one correlation is probed (`lookupOrder`) and
477
+ // every other conjunct stays in place per selected row (`residualWithout`).
478
+ lookupRows(plan, state, scope) {
479
+ for (const c of lookupOrder(plan)) {
480
+ const index = this.contextIndex(state, c);
481
+ if (!index?.lookupRows)
482
+ continue;
483
+ const outer = this.outerValue(c, scope);
484
+ if (!outer)
485
+ return null;
486
+ const rows = Array.from(index.lookupRows(outer.value));
487
+ scope.run.trace?.({ kind: "lookup", path: c.path, candidates: rows.length });
488
+ return { rows, where: residualWithout(plan, c) };
489
+ }
490
+ return null;
491
+ }
492
+ // The engine's probe: one bucket per correlation, intersected smallest-first;
493
+ // the candidates come back in receiver order. `null` (an index that could
494
+ // not be built, an outer value whose evaluation threw) leaves the scan in charge.
495
+ probe(plan, state, scope) {
496
+ const run = scope.run;
497
+ const rows = this.materialize(state);
498
+ const buckets = [];
499
+ for (const c of plan.correlated) {
500
+ const index = this.indexFor(state, c, scope);
501
+ if (!index) {
502
+ run.trace?.({ kind: "fallback", reason: "index" });
503
+ return null;
504
+ }
505
+ const outer = this.outerValue(c, scope);
506
+ if (!outer)
507
+ return null;
508
+ buckets.push(index.lookup(outer.value));
509
+ }
510
+ buckets.sort((a, b) => a.length - b.length);
511
+ let candidates = buckets[0];
512
+ for (let i = 1; i < buckets.length && candidates.length > 0; i++)
513
+ candidates = intersectPositions(candidates, buckets[i]);
514
+ run.trace?.({ kind: "probe", paths: plan.correlated.map((c) => c.path), candidates: candidates.length });
515
+ return candidates.map((i) => rows[i]);
516
+ }
517
+ // A correlation's outer side, read once in a row-less scope inside the block
518
+ // (as a bound is); `null` when it threw — the scan then raises it in place.
519
+ outerValue(c, scope) {
520
+ try {
521
+ return { value: this.evalExpr(c.outer, rowless(scope)) };
522
+ }
523
+ catch {
524
+ scope.run.trace?.({ kind: "fallback", reason: "probe-value" });
525
+ return null;
526
+ }
527
+ }
528
+ // The context's pre-built index on a correlation's local path, if it offers
529
+ // one (asked once per collection and path; a refusal is cached too).
530
+ contextIndex(state, c) {
531
+ const key = pathKey(c.path);
532
+ let index = state.contextIndexes.get(key);
533
+ if (index === undefined)
534
+ state.contextIndexes.set(key, (index = this.ctx.indexFor?.(state.value, c.path) ?? null));
535
+ return index;
536
+ }
537
+ // The index serving a correlation's local path: the context's, else one built
538
+ // here by evaluating the local expression in each row's scope — the same read
539
+ // the scan performs, so the keys are exactly what `==` would compare. A throw
540
+ // while building (a context that rejects a read) leaves the path un-indexed.
541
+ indexFor(state, c, scope) {
542
+ const key = pathKey(c.path);
543
+ const hit = state.indexes.get(key);
544
+ if (hit !== undefined)
545
+ return hit;
546
+ const rows = this.materialize(state);
547
+ let index = this.contextIndex(state, c);
548
+ let source = "context";
549
+ if (!index) {
550
+ source = "engine";
551
+ const built = new HashIndex();
552
+ try {
553
+ for (let i = 0; i < rows.length; i++)
554
+ built.add(this.evalExpr(c.local, this.enter(rows[i], scope)), i);
555
+ index = built;
556
+ }
557
+ catch { /* un-indexed: the scan serves this path */ }
558
+ }
559
+ if (index)
560
+ scope.run.trace?.({ kind: "index", path: c.path, rows: rows.length, source });
561
+ state.indexes.set(key, index);
562
+ return index;
365
563
  }
366
564
  // A select-position collect/first/single, optionally recursive via `follow`.
367
565
  evalCollectValue(op, scope) {
566
+ const plan = this.planOf(op, scope);
567
+ if (!plan.invariant)
568
+ return this.evalCollectValuePlanned(op, plan, scope);
569
+ return this.memoized(op, scope, () => this.evalCollectValuePlanned(op, plan, scope));
570
+ }
571
+ evalCollectValuePlanned(op, plan, scope) {
368
572
  const sub = op.sub;
369
573
  const bound = this.boundOf(sub, scope);
370
574
  let scopes;
371
575
  if (sub.follow) {
372
- let rows = this.rowsOf(this.evalExpr(op.receiver, scope));
373
- for (const proj of sub.from)
374
- rows = rows.flatMap((r) => this.rowsOf(this.evalExpr(proj, this.child(r, scope))));
375
- const seeds = sub.where ? rows.filter((r) => this.evalWhere(sub.where, this.enter(r, scope, { lifts: {} }))) : rows;
576
+ // The seeds are the matched rows themselves (`followWalk` enters them); a
577
+ // follow block is never correlated, so `where` here is the whole predicate.
578
+ const { rows, where } = this.access(op, plan, scope);
579
+ const seeds = where ? rows.filter((r) => this.evalWhere(where, this.enter(r, scope, { lifts: {} }))) : rows;
376
580
  scopes = this.followWalk(seeds, sub.follow, scope).map((o) => this.enter(o.row, scope, { meta: o.meta }));
377
581
  this.sortScopes(scopes, sub.orderBy);
378
582
  if (op.distinct)
@@ -380,7 +584,7 @@ export class InMemoryEngine {
380
584
  scopes = sliceBound(scopes, bound);
381
585
  }
382
586
  else {
383
- scopes = this.opRows(op, scope, bound);
587
+ scopes = this.opRows(op, scope, bound, this.access(op, plan, scope));
384
588
  }
385
589
  switch (op.op) {
386
590
  case "collect": return scopes.map((s) => this.projectRow(sub, s));
@@ -420,7 +624,18 @@ export class InMemoryEngine {
420
624
  });
421
625
  }
422
626
  // ---- scalar expression evaluation -----------------------------------------
627
+ // An expression as a VALUE: whatever `evalRaw` yields, with a context's lazy
628
+ // stand-in resolved (`DataContext.materialize`), so no operand, argument, key
629
+ // or projected item ever observes a value the context meant as a collection
630
+ // it had not read yet.
423
631
  evalExpr(e, scope) {
632
+ const v = this.evalRaw(e, scope);
633
+ return this.ctx.materialize ? this.ctx.materialize(v) : v;
634
+ }
635
+ // An expression as the context handed it out — for ROW POSITION only (the
636
+ // source, a receiver, a `from`, a `follow` destination), where the value goes
637
+ // straight to `toRows` / `indexFor`. Operands inside it are values (`evalExpr`).
638
+ evalRaw(e, scope) {
424
639
  switch (e.kind) {
425
640
  case "lit": return e.value;
426
641
  case "binding":
@@ -469,9 +684,9 @@ export class InMemoryEngine {
469
684
  }
470
685
  }
471
686
  // Resolve a name against ONE scope — never its ancestors. A scope provides,
472
- // in order: `$value` (the scope's row itself — the current item, whatever its
687
+ // in order: `$it` (the scope's row itself — the current item, whatever its
473
688
  // type, so scalar collections are queryable; absent at the root, which has no
474
- // row); `$key` (the property key) when it is an entry scope — see `enter`;
689
+ // row; `$value`, its pre-0.15 spelling, is an ordinary property name now); `$key` (the property key) when it is an entry scope — see `enter`;
475
690
  // the recursion intrinsics (`$depth`, …) when it is a follow occurrence;
476
691
  // values lifted into it by `^name:` items; then either the row's own property
477
692
  // or, for the root scope (no row), the context's named roots.
@@ -490,7 +705,7 @@ export class InMemoryEngine {
490
705
  // always spelled explicitly as `^name`. Present-but-falsy values (null, false,
491
706
  // 0, "") need no special case — there is no "absent, so look outward" rule.
492
707
  resolveIn(name, scope) {
493
- if (name === "$value")
708
+ if (name === "$it")
494
709
  return scope.parent === null ? undefined : scope.row;
495
710
  if ((name === KEY || RECUR.has(name)) && scope.meta && Object.hasOwn(scope.meta, name))
496
711
  return scope.meta[name];
@@ -516,6 +731,24 @@ export class InMemoryEngine {
516
731
  }
517
732
  }
518
733
  // ---- free helpers -----------------------------------------------------------
734
+ function newCollection(value) {
735
+ return { value, seen: 0, rows: null, indexes: new Map(), contextIndexes: new Map() };
736
+ }
737
+ function pathKey(path) {
738
+ return JSON.stringify(path);
739
+ }
740
+ // A row-less scope INSIDE a block: a bare name is absent, `^name` is the
741
+ // enclosing row — how a block's bound and a probe's outer value are read.
742
+ function rowless(enclosing) {
743
+ return { row: undefined, parent: enclosing, bindings: enclosing.bindings, run: enclosing.run };
744
+ }
745
+ // How many scopes out the root is (the root itself is 0).
746
+ function scopeDepth(scope) {
747
+ let d = 0;
748
+ for (let s = scope; s.parent !== null; s = s.parent)
749
+ d++;
750
+ return d;
751
+ }
519
752
  function isRelOp(op) {
520
753
  return op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
521
754
  }