@omgbase/oqx 0.13.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 (59) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +184 -17
  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 +5 -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 +295 -37
  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/parser.d.ts.map +1 -1
  45. package/dist/parser.js +59 -5
  46. package/dist/parser.js.map +1 -1
  47. package/package.json +1 -1
  48. package/src/adapters/indexed.ts +60 -24
  49. package/src/ast.ts +6 -1
  50. package/src/context.ts +24 -0
  51. package/src/engine.ts +329 -35
  52. package/src/index.ts +7 -2
  53. package/src/optimize/analysis.ts +183 -0
  54. package/src/optimize/driver.ts +37 -0
  55. package/src/optimize/hash-index.ts +91 -0
  56. package/src/optimize/index.ts +16 -0
  57. package/src/optimize/ir.ts +128 -0
  58. package/src/optimize/rules.ts +136 -0
  59. package/src/parser.ts +54 -6
package/src/engine.ts 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
@@ -22,6 +28,8 @@ import { OqxError } from "./errors.ts";
22
28
  import {
23
29
  relate, arith, membership, truthy, compareForSort, compare, canonicalKey, makeRange, isEntry, isRange,
24
30
  } from "./semantics.ts";
31
+ import type { BlockPlan, Correlation, Rule, RowIndex } from "./optimize/index.ts";
32
+ import { DEFAULT_RULES, HashIndex, intersectPositions, lookupOrder, planFor, residualWithout } from "./optimize/index.ts";
25
33
 
26
34
  /** The shaped result of a top-level query, discriminated by consumer. */
27
35
  export type OqxResult =
@@ -47,10 +55,59 @@ interface Scope {
47
55
  row: unknown;
48
56
  parent: Scope | null;
49
57
  bindings: readonly unknown[];
58
+ run: RunState;
50
59
  lifts?: Record<string, unknown>;
51
60
  meta?: Record<string, unknown>;
52
61
  }
53
62
 
63
+ // State that lives for exactly one `run()` (nothing here outlives it, so data
64
+ // that changes between runs is never served stale): the rule set, the receiver
65
+ // collections seen — by the receiver VALUE's identity, or by the block when its
66
+ // receiver is statically stable (see `access`) — the values of invariant
67
+ // blocks, and an optional trace sink.
68
+ interface RunState {
69
+ rules: readonly Rule[];
70
+ bindingCount: number;
71
+ collections: WeakMap<object, CollectionState>;
72
+ stableCollections: Map<OpNode, CollectionState>;
73
+ memo: Map<OpNode, unknown>;
74
+ trace?: (event: TraceEvent) => void;
75
+ }
76
+
77
+ // A receiver collection seen during a run: its value (what `toRows` coerces and
78
+ // what `indexFor` is asked about), how many times it was reached, its rows once
79
+ // materialized, and per local path the index serving it and what the context
80
+ // offered (`null`: none could be built / none was offered — the scan serves it).
81
+ interface CollectionState {
82
+ value: unknown;
83
+ seen: number;
84
+ rows: unknown[] | null;
85
+ indexes: Map<string, RowIndex | null>;
86
+ contextIndexes: Map<string, RowIndex | null>;
87
+ }
88
+
89
+ /** What the optimizer did at run time (for tests and diagnostics): an index
90
+ * built (or taken from the context) for a collection and path, a probe that
91
+ * selected a bucket, a context index that answered a probe with rows directly
92
+ * (`lookupRows`: the collection was never materialized), a memoized invariant
93
+ * block reused, a consumer answered from a cardinality, or a probe that fell
94
+ * back to the scan. */
95
+ export type TraceEvent =
96
+ | { kind: "index"; path: readonly string[]; rows: number; source: "engine" | "context" }
97
+ | { kind: "probe"; paths: readonly (readonly string[])[]; candidates: number }
98
+ | { kind: "lookup"; path: readonly string[]; candidates: number }
99
+ | { kind: "memo"; op: OpNode }
100
+ | { kind: "cardinality"; count: number }
101
+ | { kind: "fallback"; reason: "unstable" | "index" | "probe-value" | "not-a-collection" };
102
+
103
+ export interface InMemoryEngineOptions {
104
+ /** The optimizer rules to apply to nested blocks (default `DEFAULT_RULES`);
105
+ * `[]` runs the naive scan everywhere. */
106
+ rules?: readonly Rule[];
107
+ /** Receives a `TraceEvent` for each optimizer action during `run`. */
108
+ trace?: (event: TraceEvent) => void;
109
+ }
110
+
54
111
  const RECUR = new Set(["$depth", "$stop", "$leaf", "$frontier", "$ordinal"]);
55
112
  const KEY = "$key";
56
113
  const HARD_DEPTH_CAP = 8;
@@ -65,17 +122,26 @@ const UNBOUNDED: Bound = { offset: 0, limit: null };
65
122
 
66
123
  export class InMemoryEngine implements Engine {
67
124
  private ctx: DataContext;
125
+ private rules: readonly Rule[];
126
+ private trace: ((event: TraceEvent) => void) | undefined;
68
127
 
69
- constructor(context: DataContext = new DefaultContext()) {
128
+ constructor(context: DataContext = new DefaultContext(), options: InMemoryEngineOptions = {}) {
70
129
  this.ctx = context;
130
+ this.rules = options.rules ?? DEFAULT_RULES;
131
+ this.trace = options.trace;
71
132
  }
72
133
 
73
134
  run(query: Query, bindings: readonly unknown[] = []): OqxResult {
74
- const root: Scope = { row: null, parent: null, bindings };
75
- let rows = this.rowsOf(this.evalExpr(query.source, root));
76
- for (const proj of query.from) {
77
- rows = rows.flatMap((r) => this.rowsOf(this.evalExpr(proj, this.child(r, root))));
78
- }
135
+ const run: RunState = {
136
+ rules: this.rules,
137
+ bindingCount: bindings.length,
138
+ collections: new WeakMap(),
139
+ stableCollections: new Map(),
140
+ memo: new Map(),
141
+ };
142
+ if (this.trace) run.trace = this.trace;
143
+ const root: Scope = { row: null, parent: null, bindings, run };
144
+ const rows = this.reproject(this.rowsOf(this.evalRaw(query.source, root)), query.from, root);
79
145
 
80
146
  const bound = this.boundOf(query, root);
81
147
  if (query.follow) return this.runFollow(query, rows, root, bound);
@@ -123,7 +189,7 @@ export class InMemoryEngine implements Engine {
123
189
  // is the root. Each must be a non-negative integer.
124
190
  private boundOf(b: Pick<Subquery, "limit" | "offset">, enclosing: Scope): Bound {
125
191
  if (!b.limit && !b.offset) return UNBOUNDED;
126
- const scope: Scope = enclosing.parent === null ? enclosing : { row: undefined, parent: enclosing, bindings: enclosing.bindings };
192
+ const scope: Scope = enclosing.parent === null ? enclosing : rowless(enclosing);
127
193
  const read = (e: Expr | undefined, word: string): number | null => {
128
194
  if (!e) return null;
129
195
  const v = this.evalExpr(e, scope);
@@ -155,7 +221,7 @@ export class InMemoryEngine implements Engine {
155
221
  // goes through this, so entries behave the same at the top level, in nested
156
222
  // blocks, as `from` re-projections, and as follow seeds.
157
223
  private enter(row: unknown, parent: Scope, extra: { lifts?: Record<string, unknown>; meta?: Record<string, unknown> } = {}): Scope {
158
- const s: Scope = { row, parent, bindings: parent.bindings };
224
+ const s: Scope = { row, parent, bindings: parent.bindings, run: parent.run };
159
225
  if (extra.lifts) s.lifts = extra.lifts;
160
226
  if (extra.meta) s.meta = extra.meta;
161
227
  if (isEntry(row)) {
@@ -212,15 +278,39 @@ export class InMemoryEngine implements Engine {
212
278
  // • identity for cycle detection + `distinct` is `by <expr>` when given, else
213
279
  // `ctx.identity(row)` (an entry's identity is its value's); identities are
214
280
  // compared structurally via `canonicalKey`, and `$ordinal` paths compare
215
- // component-wise (`comparePath`), so `10` follows `9`.
281
+ // component-wise (`comparePath`), so `10` follows `9`;
282
+ // • a row's successors are the destinations' rows in source order — a plain
283
+ // relation read in the row's scope, a destination block evaluated as a
284
+ // select-position directive in that scope (so `^` inside it is the frontier
285
+ // row and its `^people` receiver resolves one scope further out) — filtered
286
+ // by the follow `where`, then unioned by identity within the step (a
287
+ // recurring identity is kept once, at its first position);
288
+ // • the follow `where` reads the candidate's scope hung off the FRONTIER
289
+ // row's scope: a bare name is the candidate's, `^name` the row being
290
+ // expanded, `^^name` the walk's enclosing scope. `frontier` and `by` read
291
+ // the occurrence's own scope with `^` the enclosing scope, as the body does.
216
292
  private followWalk(seedRows: unknown[], follow: Follow, parent: Scope): Occurrence[] {
217
293
  const cap = follow.depth ?? HARD_DEPTH_CAP;
218
294
  const scopeFor = (row: unknown): Scope => this.enter(row, parent);
219
295
  const identityOf = (row: unknown): unknown =>
220
296
  follow.by ? this.evalExpr(follow.by, scopeFor(row)) : this.ctx.identity(isEntry(row) ? row.value : row);
221
297
  const succOf = (row: unknown): unknown[] => {
222
- const raw = this.rowsOf(this.evalExpr(follow.receiver, scopeFor(row)));
223
- return follow.where ? raw.filter((x) => truthy(this.evalExpr(follow.where!, scopeFor(x)))) : raw;
298
+ const rowScope = scopeFor(row);
299
+ const raw: unknown[] = [];
300
+ for (const dest of follow.destinations) {
301
+ const v = dest.kind === "op" ? this.evalCollectValue(dest, rowScope) : this.evalRaw(dest, rowScope);
302
+ raw.push(...this.rowsOf(v));
303
+ }
304
+ const kept = follow.where ? raw.filter((x) => truthy(this.evalExpr(follow.where!, this.enter(x, rowScope)))) : raw;
305
+ const seen = new Set<string>();
306
+ const out: unknown[] = [];
307
+ for (const x of kept) {
308
+ const key = canonicalKey(identityOf(x));
309
+ if (seen.has(key)) continue;
310
+ seen.add(key);
311
+ out.push(x);
312
+ }
313
+ return out;
224
314
  };
225
315
  const frontierHit = (row: unknown): boolean =>
226
316
  follow.frontier ? truthy(this.evalExpr(follow.frontier, scopeFor(row))) : false;
@@ -320,12 +410,28 @@ export class InMemoryEngine implements Engine {
320
410
 
321
411
  private evalWhereOp(op: OpNode, scope: Scope): boolean {
322
412
  if (op.sub.follow) throw new OqxError("`follow` is only valid on a select-position collect { … }, not a where op", "eval");
413
+ const plan = this.planOf(op, scope);
414
+ if (!plan.invariant) return this.evalWhereOpPlanned(op, plan, scope);
415
+ return this.memoized(op, scope, () => this.evalWhereOpPlanned(op, plan, scope));
416
+ }
417
+
418
+ private evalWhereOpPlanned(op: OpNode, plan: BlockPlan, scope: Scope): boolean {
323
419
  const bound = this.boundOf(op.sub, scope);
324
- if (op.op === "exists" || op.op === "none") {
325
- // Unbounded: stop at the first match (dedup cannot change emptiness).
326
- // Bounded: the offset/limit decide emptiness, so materialize the set.
327
- const any = bound === UNBOUNDED ? this.anyMatch(op, scope) : this.opRows(op, scope, bound).length > 0;
328
- return op.op === "exists" ? any : !any;
420
+ if (op.op === "exists" || op.op === "none" || op.op === "count") {
421
+ const acc = this.access(op, plan, scope);
422
+ let n: number;
423
+ if (plan.fromCardinality && acc.where === null) {
424
+ // Nothing left to evaluate per row: the accessed rows ARE the matched
425
+ // rows, and the consumer only needs their count after the bound.
426
+ n = boundedCount(acc.rows.length, bound);
427
+ scope.run.trace?.({ kind: "cardinality", count: n });
428
+ } else if (op.op !== "count" && bound === UNBOUNDED) {
429
+ // Unbounded exists/none: stop at the first match (dedup cannot change
430
+ // emptiness). Bounded: the offset/limit decide emptiness, so materialize.
431
+ n = this.anyMatch(op, scope, acc) ? 1 : 0;
432
+ } else n = this.opRows(op, scope, bound, acc).length;
433
+ if (op.op === "count") return op.countCmp ? compareCount(n, op.countCmp) : n > 0;
434
+ return op.op === "exists" ? n > 0 : n === 0;
329
435
  }
330
436
  if (op.op === "collect") {
331
437
  const matched = this.opRows(op, scope, bound);
@@ -343,55 +449,207 @@ export class InMemoryEngine implements Engine {
343
449
  }
344
450
  return matched.length > 0;
345
451
  }
346
- if (op.op === "count") {
347
- const n = this.opRows(op, scope, bound).length;
348
- return op.countCmp ? compareCount(n, op.countCmp) : n > 0;
349
- }
350
452
  return this.opRows(op, scope, bound).length > 0;
351
453
  }
352
454
 
353
455
  // The rows a consumer op reduces: matched → ordered → distinct → bounded.
354
- private opRows(op: OpNode, scope: Scope, bound: Bound): Scope[] {
355
- let scopes = this.matchRows(op, scope);
456
+ private opRows(op: OpNode, scope: Scope, bound: Bound, acc?: Access): Scope[] {
457
+ let scopes = this.matchRows(op, scope, acc);
356
458
  this.sortScopes(scopes, op.sub.orderBy);
357
459
  if (op.distinct) scopes = this.dedupByProjection(scopes, op.sub);
358
460
  return sliceBound(scopes, bound);
359
461
  }
360
462
 
361
- // Short-circuiting existence check over a consumer receiver.
362
- private anyMatch(op: OpNode, scope: Scope): boolean {
363
- for (const s of this.iterMatchRows(op, scope)) { void s; return true; }
463
+ // Short-circuiting existence check: over a probe's bucket this stops at the
464
+ // first row that passes the residual, so `exists`/`none` never materialize it.
465
+ private anyMatch(op: OpNode, scope: Scope, acc?: Access): boolean {
466
+ for (const s of this.iterMatchRows(op, scope, acc)) { void s; return true; }
364
467
  return false;
365
468
  }
366
469
 
367
- private *iterMatchRows(op: OpNode, scope: Scope): Generator<Scope> {
368
- let rows = this.rowsOf(this.evalExpr(op.receiver, scope));
369
- for (const proj of op.sub.from) rows = rows.flatMap((r) => this.rowsOf(this.evalExpr(proj, this.child(r, scope))));
470
+ // The block's accessed rows, entered as scopes under `scope`, filtered by the
471
+ // predicate the access left to evaluate.
472
+ private *iterMatchRows(op: OpNode, scope: Scope, acc?: Access): Generator<Scope> {
473
+ const { rows, where } = acc ?? this.access(op, this.planOf(op, scope), scope);
370
474
  for (const r of rows) {
371
475
  const s = this.enter(r, scope, { lifts: {} });
372
- if (!op.sub.where || this.evalWhere(op.sub.where, s)) yield s;
476
+ if (!where || this.evalWhere(where, s)) yield s;
373
477
  }
374
478
  }
375
479
 
376
- private matchRows(op: OpNode, scope: Scope): Scope[] {
377
- return Array.from(this.iterMatchRows(op, scope));
480
+ private matchRows(op: OpNode, scope: Scope, acc?: Access): Scope[] {
481
+ return Array.from(this.iterMatchRows(op, scope, acc));
482
+ }
483
+
484
+ // ---- planned access -------------------------------------------------------
485
+ //
486
+ // The engine EXECUTES a block's `BlockPlan`; the rules and the semantics
487
+ // argument for each live in `./optimize` (rules.ts). `access` is the one path
488
+ // every consumer takes to a block's rows, and whatever the plan asked for
489
+ // that cannot be served at run time degrades to the scan the plan started from.
490
+
491
+ private planOf(op: OpNode, scope: Scope): BlockPlan {
492
+ return planFor(op, scopeDepth(scope), { bindingCount: scope.run.bindingCount }, scope.run.rules);
493
+ }
494
+
495
+ // An invariant block (rule 2) is evaluated the first time it is reached and
496
+ // its value reused, by reference, for every other enclosing row.
497
+ private memoized<T>(op: OpNode, scope: Scope, compute: () => T): T {
498
+ const memo = scope.run.memo;
499
+ if (memo.has(op)) { scope.run.trace?.({ kind: "memo", op }); return memo.get(op) as T; }
500
+ const value = compute();
501
+ memo.set(op, value);
502
+ return value;
503
+ }
504
+
505
+ // A block's rows and the predicate left to evaluate over each. A correlated
506
+ // plan is served, in order of preference, by a context index that yields rows
507
+ // (`lookupRows` — asked on every sight: it costs nothing and the collection
508
+ // is never read), by a probe on the engine's own index (built on the
509
+ // collection's second sight, or at once for a statically stable receiver: a
510
+ // collection seen once is cheaper to scan than to index), else by the scan.
511
+ // A statically stable receiver (rule 1b) is evaluated once per run; any other
512
+ // is keyed by its value's identity, so a fresh array per row is never indexed.
513
+ private access(op: OpNode, plan: BlockPlan, scope: Scope): Access {
514
+ const run = scope.run;
515
+ let state = plan.receiverStable ? run.stableCollections.get(op) : undefined;
516
+ if (!state) {
517
+ const value = this.evalRaw(op.receiver, scope);
518
+ if (plan.receiverStable) run.stableCollections.set(op, (state = newCollection(value)));
519
+ else if (plan.correlated.length === 0) return this.scan(this.rowsOf(value), op, scope);
520
+ else if (value !== null && typeof value === "object") {
521
+ state = run.collections.get(value);
522
+ if (!state) run.collections.set(value, (state = newCollection(value)));
523
+ } else {
524
+ run.trace?.({ kind: "fallback", reason: "not-a-collection" });
525
+ return this.scan(this.rowsOf(value), op, scope);
526
+ }
527
+ }
528
+ state.seen++;
529
+ if (plan.correlated.length > 0) {
530
+ const direct = this.lookupRows(plan, state, scope);
531
+ if (direct) return direct;
532
+ if (state.seen >= 2 || plan.receiverStable) {
533
+ const probed = this.probe(plan, state, scope);
534
+ if (probed) return { rows: probed, where: plan.residual };
535
+ } else run.trace?.({ kind: "fallback", reason: "unstable" });
536
+ }
537
+ return this.scan(this.materialize(state), op, scope);
538
+ }
539
+
540
+ // The plain scan: the rows, re-projected by `from`, with the whole `where`.
541
+ private scan(rows: unknown[], op: OpNode, scope: Scope): Access {
542
+ return { rows: this.reproject(rows, op.sub.from, scope), where: op.sub.where };
543
+ }
544
+
545
+ private reproject(rows: unknown[], from: readonly Expr[], scope: Scope): unknown[] {
546
+ for (const proj of from) rows = rows.flatMap((r) => this.rowsOf(this.evalRaw(proj, this.child(r, scope))));
547
+ return rows;
548
+ }
549
+
550
+ private materialize(state: CollectionState): unknown[] {
551
+ return (state.rows ??= this.rowsOf(state.value));
552
+ }
553
+
554
+ // A probe answered by a context index that yields rows directly, before the
555
+ // collection is materialized: one correlation is probed (`lookupOrder`) and
556
+ // every other conjunct stays in place per selected row (`residualWithout`).
557
+ private lookupRows(plan: BlockPlan, state: CollectionState, scope: Scope): Access | null {
558
+ for (const c of lookupOrder(plan)) {
559
+ const index = this.contextIndex(state, c);
560
+ if (!index?.lookupRows) continue;
561
+ const outer = this.outerValue(c, scope);
562
+ if (!outer) return null;
563
+ const rows = Array.from(index.lookupRows(outer.value));
564
+ scope.run.trace?.({ kind: "lookup", path: c.path, candidates: rows.length });
565
+ return { rows, where: residualWithout(plan, c) };
566
+ }
567
+ return null;
568
+ }
569
+
570
+ // The engine's probe: one bucket per correlation, intersected smallest-first;
571
+ // the candidates come back in receiver order. `null` (an index that could
572
+ // not be built, an outer value whose evaluation threw) leaves the scan in charge.
573
+ private probe(plan: BlockPlan, state: CollectionState, scope: Scope): unknown[] | null {
574
+ const run = scope.run;
575
+ const rows = this.materialize(state);
576
+ const buckets: (readonly number[])[] = [];
577
+ for (const c of plan.correlated) {
578
+ const index = this.indexFor(state, c, scope);
579
+ if (!index) { run.trace?.({ kind: "fallback", reason: "index" }); return null; }
580
+ const outer = this.outerValue(c, scope);
581
+ if (!outer) return null;
582
+ buckets.push(index.lookup(outer.value));
583
+ }
584
+ buckets.sort((a, b) => a.length - b.length);
585
+ let candidates = buckets[0]!;
586
+ for (let i = 1; i < buckets.length && candidates.length > 0; i++) candidates = intersectPositions(candidates, buckets[i]!);
587
+ run.trace?.({ kind: "probe", paths: plan.correlated.map((c) => c.path), candidates: candidates.length });
588
+ return candidates.map((i) => rows[i]);
589
+ }
590
+
591
+ // A correlation's outer side, read once in a row-less scope inside the block
592
+ // (as a bound is); `null` when it threw — the scan then raises it in place.
593
+ private outerValue(c: Correlation, scope: Scope): { value: unknown } | null {
594
+ try { return { value: this.evalExpr(c.outer, rowless(scope)) }; }
595
+ catch { scope.run.trace?.({ kind: "fallback", reason: "probe-value" }); return null; }
596
+ }
597
+
598
+ // The context's pre-built index on a correlation's local path, if it offers
599
+ // one (asked once per collection and path; a refusal is cached too).
600
+ private contextIndex(state: CollectionState, c: Correlation): RowIndex | null {
601
+ const key = pathKey(c.path);
602
+ let index = state.contextIndexes.get(key);
603
+ if (index === undefined) state.contextIndexes.set(key, (index = this.ctx.indexFor?.(state.value, c.path) ?? null));
604
+ return index;
605
+ }
606
+
607
+ // The index serving a correlation's local path: the context's, else one built
608
+ // here by evaluating the local expression in each row's scope — the same read
609
+ // the scan performs, so the keys are exactly what `==` would compare. A throw
610
+ // while building (a context that rejects a read) leaves the path un-indexed.
611
+ private indexFor(state: CollectionState, c: Correlation, scope: Scope): RowIndex | null {
612
+ const key = pathKey(c.path);
613
+ const hit = state.indexes.get(key);
614
+ if (hit !== undefined) return hit;
615
+ const rows = this.materialize(state);
616
+ let index = this.contextIndex(state, c);
617
+ let source: "context" | "engine" = "context";
618
+ if (!index) {
619
+ source = "engine";
620
+ const built = new HashIndex();
621
+ try {
622
+ for (let i = 0; i < rows.length; i++) built.add(this.evalExpr(c.local, this.enter(rows[i], scope)), i);
623
+ index = built;
624
+ } catch { /* un-indexed: the scan serves this path */ }
625
+ }
626
+ if (index) scope.run.trace?.({ kind: "index", path: c.path, rows: rows.length, source });
627
+ state.indexes.set(key, index);
628
+ return index;
378
629
  }
379
630
 
380
631
  // A select-position collect/first/single, optionally recursive via `follow`.
381
632
  private evalCollectValue(op: OpNode, scope: Scope): unknown {
633
+ const plan = this.planOf(op, scope);
634
+ if (!plan.invariant) return this.evalCollectValuePlanned(op, plan, scope);
635
+ return this.memoized(op, scope, () => this.evalCollectValuePlanned(op, plan, scope));
636
+ }
637
+
638
+ private evalCollectValuePlanned(op: OpNode, plan: BlockPlan, scope: Scope): unknown {
382
639
  const sub = op.sub;
383
640
  const bound = this.boundOf(sub, scope);
384
641
  let scopes: Scope[];
385
642
  if (sub.follow) {
386
- let rows = this.rowsOf(this.evalExpr(op.receiver, scope));
387
- for (const proj of sub.from) rows = rows.flatMap((r) => this.rowsOf(this.evalExpr(proj, this.child(r, scope))));
388
- const seeds = sub.where ? rows.filter((r) => this.evalWhere(sub.where!, this.enter(r, scope, { lifts: {} }))) : rows;
643
+ // The seeds are the matched rows themselves (`followWalk` enters them); a
644
+ // follow block is never correlated, so `where` here is the whole predicate.
645
+ const { rows, where } = this.access(op, plan, scope);
646
+ const seeds = where ? rows.filter((r) => this.evalWhere(where, this.enter(r, scope, { lifts: {} }))) : rows;
389
647
  scopes = this.followWalk(seeds, sub.follow, scope).map((o) => this.enter(o.row, scope, { meta: o.meta }));
390
648
  this.sortScopes(scopes, sub.orderBy);
391
649
  if (op.distinct) scopes = this.dedupByProjection(scopes, sub);
392
650
  scopes = sliceBound(scopes, bound);
393
651
  } else {
394
- scopes = this.opRows(op, scope, bound);
652
+ scopes = this.opRows(op, scope, bound, this.access(op, plan, scope));
395
653
  }
396
654
  switch (op.op) {
397
655
  case "collect": return scopes.map((s) => this.projectRow(sub, s));
@@ -429,7 +687,19 @@ export class InMemoryEngine implements Engine {
429
687
 
430
688
  // ---- scalar expression evaluation -----------------------------------------
431
689
 
690
+ // An expression as a VALUE: whatever `evalRaw` yields, with a context's lazy
691
+ // stand-in resolved (`DataContext.materialize`), so no operand, argument, key
692
+ // or projected item ever observes a value the context meant as a collection
693
+ // it had not read yet.
432
694
  private evalExpr(e: Expr, scope: Scope): unknown {
695
+ const v = this.evalRaw(e, scope);
696
+ return this.ctx.materialize ? this.ctx.materialize(v) : v;
697
+ }
698
+
699
+ // An expression as the context handed it out — for ROW POSITION only (the
700
+ // source, a receiver, a `from`, a `follow` destination), where the value goes
701
+ // straight to `toRows` / `indexFor`. Operands inside it are values (`evalExpr`).
702
+ private evalRaw(e: Expr, scope: Scope): unknown {
433
703
  switch (e.kind) {
434
704
  case "lit": return e.value;
435
705
  case "binding":
@@ -523,8 +793,32 @@ export class InMemoryEngine implements Engine {
523
793
 
524
794
  interface Occurrence { row: unknown; meta: Record<string, unknown>; }
525
795
 
796
+ // A block's accessed rows and the predicate still to evaluate over each.
797
+ interface Access { rows: unknown[]; where: Where | null; }
798
+
526
799
  // ---- free helpers -----------------------------------------------------------
527
800
 
801
+ function newCollection(value: unknown): CollectionState {
802
+ return { value, seen: 0, rows: null, indexes: new Map(), contextIndexes: new Map() };
803
+ }
804
+
805
+ function pathKey(path: readonly string[]): string {
806
+ return JSON.stringify(path);
807
+ }
808
+
809
+ // A row-less scope INSIDE a block: a bare name is absent, `^name` is the
810
+ // enclosing row — how a block's bound and a probe's outer value are read.
811
+ function rowless(enclosing: Scope): Scope {
812
+ return { row: undefined, parent: enclosing, bindings: enclosing.bindings, run: enclosing.run };
813
+ }
814
+
815
+ // How many scopes out the root is (the root itself is 0).
816
+ function scopeDepth(scope: Scope): number {
817
+ let d = 0;
818
+ for (let s = scope; s.parent !== null; s = s.parent) d++;
819
+ return d;
820
+ }
821
+
528
822
  function isRelOp(op: string): boolean {
529
823
  return op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
530
824
  }
package/src/index.ts CHANGED
@@ -30,9 +30,14 @@ export { OqxError } from "./errors.ts";
30
30
 
31
31
  /** The OQX spec version (`spec/oqx/VERSION`, `major.minor`) this implementation
32
32
  * conforms to — distinct from the package's own semver. */
33
- export const LANGUAGE_VERSION = "0.13";
34
- export type { OqxResult, Engine } from "./engine.ts";
33
+ export const LANGUAGE_VERSION = "0.14";
34
+ export type { OqxResult, Engine, InMemoryEngineOptions, TraceEvent } from "./engine.ts";
35
35
  export { InMemoryEngine, runQuery } from "./engine.ts";
36
+ export type { BlockPlan, Correlation, Rule, RuleContext, RowIndex } from "./optimize/index.ts";
37
+ export {
38
+ DEFAULT_RULES, correlatedEqualityProbe, stableReceiver, invariantBlock, cardinalityOnly,
39
+ optimizeBlock, logicalBlock, HashIndex,
40
+ } from "./optimize/index.ts";
36
41
  export type { DataContext, CallResult, DefaultContextOptions } from "./context.ts";
37
42
  export { DefaultContext } from "./context.ts";
38
43
  export type { RegexDialect, RegexFlags } from "./regex.ts";