diff --git a/.claude/board/AGENT_LOG.md b/.claude/board/AGENT_LOG.md index a625f6fb2..79d47c5bd 100644 --- a/.claude/board/AGENT_LOG.md +++ b/.claude/board/AGENT_LOG.md @@ -1,3 +1,9 @@ +## 2026-10-03 — Explore agent: SurrealQL AST harvest (D-FPW-0) + +- Read-only map of `/home/user/surrealdb` parser/AST: `Part::Graph(Lookup)` per hop, `Dir::{In,Out,Both}`, edge tables with `in`/`out`, bag flatten at `val/value/get.rs:499`, `Recurse`/`RecurseInstruction`. +- Constraints: `sql::*` is `pub(crate)`; public arena AST unpublished; BSL 1.1. +- Three claims re-read by the orchestrator. Folded into plan §5. No commit of its own. + ## 2026-09-30 — 5+3 council on `deepnsm-v2-lexical-address-v1` (PR #1307) - The 5: prior-art, iron-rule, code truth, cascade-impact, creative-explorer. diff --git a/.claude/board/INTEGRATION_PLANS.md b/.claude/board/INTEGRATION_PLANS.md index 1688dccb2..198499270 100644 --- a/.claude/board/INTEGRATION_PLANS.md +++ b/.claude/board/INTEGRATION_PLANS.md @@ -1,3 +1,12 @@ +## 2026-10-03 — frontend-parity-witness-v1 — SQL, Cypher, Gremlin, SurrealQL on one population algebra → `.claude/plans/frontend-parity-witness-v1.md` + +**Status:** MEASURED for SQL ↔ Gremlin (test-only witness in `lance-graph-quack/tests/gremlin_parity.rs`); analysis for Cypher (#1306 plan) and SurrealQL (AST). D-FPW-0..4. No production code. + +- Gremlin and SQL meet at the same `Query` value; execution equals DuckDB and a bulk oracle. +- Anchor rule: functional hop keeps the anchor; one fan-out re-anchors on the path population. +- Five gaps pass the two-witness rule; the two that decide traversal are #1308 (a) and (b)/(d). +- Input for #1306 D-CML-2: RF-BAG is broader than the semantics require. + ## 2026-09-30 (2) — deepnsm-v2-lexical-address-v1 — a word is a 16-bit address into a versioned, baked COCA codebook → `.claude/plans/deepnsm-v2-lexical-address-v1.md` **Status:** PROPOSAL (D-LXA-1..4), ratified v3 by a 5+3 council. No code authorized. diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index 7ccc54cd8..bda8b886f 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -1,3 +1,15 @@ +## D-FPW — Frontend parity witness (2026-10-03) + +Plan: `.claude/plans/frontend-parity-witness-v1.md`. Entry: `entries/2026-10-03-frontend-parity-witness.md`. + +| D-id | scope | status | gate / falsifier | +|---|---|---|---| +| **D-FPW-0** | inventory + parity matrix (SQL/Quack, Cypher #1306, Gremlin, SurrealQL) | Shipped | plan §1, §4 | +| **D-FPW-1** | Gremlin witness: typed steps → `quack::Query`, 13 tests | Shipped | `Query ==` SQL shape; DuckDB answers; bulk oracle; 5 disable runs red | +| **D-FPW-2** | feed the anchor rule (functional hop ⇒ exact bag count) into #1306 D-CML-2 | Queued | a Cypher `count(*)` after a functional hop matches the oracle | +| **D-FPW-3** | SurrealQL fork-side lowering hook (`sql::Lookup` → `Query`) | Queued | operator: BSL 1.1 licensing first | +| **D-FPW-4** | gap G4 (sum of a foreign value) checked against `GroupReduce` before being called a primitive | Queued | — | + ## D-LXC — DeepNSM-v2 lexical-evidence consumer + candidate next parts (2026-09-29) Plan: `.claude/plans/deepnsm-v2-lexical-evidence-consumer-v1.md`. Convergence brief: `.claude/prompts/deepnsm-v2-lexical-consumer-converge.md`. diff --git a/.claude/board/entries/2026-10-03-frontend-parity-witness.md b/.claude/board/entries/2026-10-03-frontend-parity-witness.md new file mode 100644 index 000000000..efe76923c --- /dev/null +++ b/.claude/board/entries/2026-10-03-frontend-parity-witness.md @@ -0,0 +1,26 @@ +# Frontend parity witness: Gremlin and SQL meet at the same Quack `Query` (2026-10-03) + +**Status:** MEASURED (SQL ↔ Gremlin); ANALYSIS (Cypher against the #1306 plan, +SurrealQL against its AST). Plan: `.claude/plans/frontend-parity-witness-v1.md`. +Code: `crates/lance-graph-quack/tests/gremlin_parity.rs` (test-only, 13 tests). + +- A typed Gremlin step vocabulary lowers onto `lance_graph_quack::Query` through a + test-only adapter. For the shapes that lower, the Gremlin `Query` is `==` the + SQL-shaped `Query`, and execution equals DuckDB's committed answers + (`join_sum_country` 1237848, `join_group_count_country`, + `join_count_docs_with_posted` 511 via the oracle) and an independent + row-at-a-time bulk oracle. +- **Anchor rule:** a functional hop keeps the anchor (fk reads); one fan-out + re-anchors on the table whose rows are the paths (child or edge table). One M:N + hop is therefore one `Program` over the edge population, with exact bag counts. +- **Input for #1306 D-CML-2:** RF-BAG refuses any non-DISTINCT count after a hop. + After a functional hop, or one hop over an edge population, the bag count is + exact (anchor rows = paths). RF-BAG is broader than the semantics require. +- **Gaps passing the two-witness rule** (none built): composed functional read + (= #1308 (a)); barrier + workspace after a fan-out (= #1308 (b)/(d), #1310); + ordered compare through an fk; sum of a foreign value; ordered compare on `u32`. +- **SurrealQL:** a hop is `Part::Graph(Lookup)`, a per-hop mini-SELECT over a + first-class edge table (`in`/`out`), bag-flattened. Structurally the same as the + edge-population re-anchor. Not reusable as a dependency: `sql::*` is + `pub(crate)`, the public arena AST is unpublished, license is BSL 1.1. +- DuckGQL / DuckPG: no code or plan by those names in the tree. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index b0e063503..0f146f876 100644 --- a/.claude/board/entries/README.md +++ b/.claude/board/entries/README.md @@ -25,11 +25,12 @@ index row, (3) no duplicate entry id. Checks 1 and 2 are deliberately opposite directions; the stranding this convention prevents shows up in exactly one of them, never both. -182 entries, 2026-08-06 .. 2026-10-03. +183 entries, 2026-08-06 .. 2026-10-03. | date | entry id | finding | file | |---|---|---|---| | 2026-10-03 | `known-is-not-alpha-premise-gate` | | [2026-10-03-known-is-not-alpha-premise-gate.md](2026-10-03-known-is-not-alpha-premise-gate.md) | +| 2026-10-03 | `frontend-parity-witness` | | [2026-10-03-frontend-parity-witness.md](2026-10-03-frontend-parity-witness.md) | | 2026-10-03 | `dir-sim-soa-quack` | Directory simulation on SoA + Quack: one-edge mutation 853 B at 1k and 100k users | [2026-10-03-dir-sim-soa-quack.md](2026-10-03-dir-sim-soa-quack.md) | | 2026-10-03 | `coresearch-chained-hop` | | [2026-10-03-coresearch-chained-hop.md](2026-10-03-coresearch-chained-hop.md) | | 2026-09-30 | `three-reference-sets-are-not-ordinal-aligned` | | [2026-09-30-three-reference-sets-are-not-ordinal-aligned.md](2026-09-30-three-reference-sets-are-not-ordinal-aligned.md) | diff --git a/.claude/plans/frontend-parity-witness-v1.md b/.claude/plans/frontend-parity-witness-v1.md new file mode 100644 index 000000000..e3e5b0505 --- /dev/null +++ b/.claude/plans/frontend-parity-witness-v1.md @@ -0,0 +1,232 @@ +# frontend-parity-witness-v1 — do SQL, Cypher, Gremlin and SurrealQL lower onto one population algebra? + +> **Status:** MEASURED for SQL ↔ Gremlin (code: `crates/lance-graph-quack/tests/gremlin_parity.rs`, 13 tests). +> ANALYSIS for Cypher (against the #1306 plan, which has no code yet) and SurrealQL (AST read, not wired). +> D-FPW-0..4. Ratifies nothing in mask-risc or Quack; adds no production code. +> **Board:** `STATUS_BOARD.md` § D-FPW · entry `entries/2026-10-03-frontend-parity-witness.md`. + +## §0 — The question, and the short answer + +Do independently designed query languages reduce to the same execution primitives? +For the shapes the substrate already supports, **yes, and the meeting point is +measurable**: a Gremlin traversal and its SQL equivalent produce the *same* +`lance_graph_quack::Query` value (`assert_eq!` on the struct), then the same +`Program`, then DuckDB's committed answer. The schema declaration and adapter are +about 450 lines of test code (the file is about 1,500 with the oracle and tests) +and add no operator. + +Every traversal that does not lower is refused with a named reason. Two of the +refusals are real substrate gaps, and both were already named independently by +#1306 (Cypher) and #1308/#1310 (chained hops). No new primitive is proposed here. + +## §1 — What exists (A) + +| layer | in code? | where | +|---|---|---| +| **mask-risc** (the one evaluator) | yes | `crates/lance-graph-mask-risc/src/ir.rs`: `Pred` (incl. `EqU32Via`, `Range`, strided), `MaskOp` (`And/Or/Xor/AndNot/Not/Ternlog/Gather`), one `Terminal` per `Program` (`Count/Any/All/MaskedSum|Min|Max/Keep/BlendI32/ScatterOrU32/ScatterCountU32(held)/CountKeyRunsU32/GroupSumI32/GroupSumViaI32/GroupReduce{Lane,Via,Pair}`) | +| **Quack** (the SQL-shaped lowering) | yes | `crates/lance-graph-quack/src/lib.rs`: `Query{Filter, Agg}`, `lower`, `lower_fused`, `lower_group_by[_auto]`, `lower_group_having`, `lower_avg`; DuckDB differential (`tests/duckdb_differential.rs`, 38 cases) | +| **DuckPG / DuckGQL** | **no** | 0 hits for `duckgql`, `duck_gql`, `duckpg`, `duck_pg` across `crates/`, `.claude/`, `docs/`. The SQL frontend in the tree is Quack, with DuckDB as oracle only | +| **Cypher #1306** | plan only | `.claude/plans/cypher-mask-lowering-v2.md` (ratified v3). Lowering table §4, refusal list §5 | +| **R2IL / FoldDialect** | test-only probe | `crates/r2il-mask-abi-probe/tests/row_bridge.rs`: loco programs over OGAR's `0xE2..` fold band that run one mask-risc `Program` per fold boundary — the existing multi-terminal witness (`SUM − SUM`) | +| **"Northstar"** | prose | an epiphany name (`E-OGAR-NORTHSTAR-1`), not a layer | +| **"V4"** | not found as a name | in `mask-risc/src` and `quack/src` (closed space) | +| **dir-sim** | yes, excluded crate | another Quack consumer: anti-join = `negate(Semijoin)` (`entries/2026-10-03-dir-sim-soa-quack.md`) | + +## §2 — The anchor invariant (why one hop of anything is one population) + +Gremlin counts traversers with bulk; SurrealQL flattens per-hop results into a bag +(`core/src/val/value/get.rs:499`); Cypher binds paths; SQL joins produce rows. All +four are **bag** semantics by default. The witness keeps one table as the ANCHOR: +every surviving anchor row is exactly one traverser of bulk 1. + +- A **functional** hop (each anchor row names ≤ 1 target) leaves the anchor where + it is; the target's fields are read through the fk (`EqU32Via`, `GroupKey::Via`). + Consumption class **functional-indexed**: resident reads composed. +- A **fan-out** (reverse fk, or an edge table) moves the anchor to the table whose + rows ARE the paths (the child, or the edge table), carrying the old predicates + across the fk. One fan-out is therefore still one population, still one `Program`. +- A second fan-out, or a read two fks deep, cannot keep the invariant. Those are + the two gaps (§6). + +Consequence for Cypher: #1306's RF-BAG ("any non-DISTINCT count after one or more +hops") is **broader than necessary**. After a functional hop, or a single hop over +an edge population, a bag count is a count of anchor rows — exact. This is an input +for D-CML-2, not an edit to that plan. + +## §3 — Gremlin step → primitive (C) + +| step | lowers to | class | +|---|---|---| +| `V(T)` / `hasLabel(T)` | anchor = T, `Filter::Plane(alpha)` | pointwise | +| `has(f, eq)` on the anchor | `Filter::Cmp` (`EqU32`/`EqI32`) | pointwise | +| `has(f, gt)` on an `i32` anchor field | `Cmp::GtI32` | pointwise | +| `out(rel)`, functional | cursor moves, anchor stays; `Semijoin(fk, target alpha)` | functional-indexed | +| `has(f, eq)` after a functional `out` | `Filter::EqU32Via` | functional-indexed | +| `in(rel)`, reverse of fk | re-anchor on the child; carried predicates become `EqU32Via`/`Semijoin` | fan-out → one population | +| `out/in(rel)` over an edge table | re-anchor on the edge table; both endpoints are fk reads | fan-out → one population | +| `where(out(..).has(..))` | predicate atoms on the anchor | functional-indexed | +| `where(in(..).has(..))` | child anchor + set over the fk | fan-out, set | +| `count()` | `Agg::Count` (bag = anchor rows) | demanded output | +| `dedup().count()` | `Agg::CountDistinctOrderedU32{fk}` (physically refused on an unordered lane, never a seen-set) | demanded output | +| `dedup()` then return | `Agg::ScatterOrU32` (the set mask IS the demanded result) | demanded output | +| `values(f).sum()` on the anchor | `Agg::SumI32` | demanded output | +| `groupCount().by(f)` | `Agg::GroupReduce{Local | Via, Count}` | demanded output | + +Measured parity (all against the independent row-at-a-time bulk oracle in the test): + +- `where(out('billedTo').has('country',3)).values('amount').sum()` = **1237848** = DuckDB `join_sum_country`; the lowered `Query` is `==` the SQL-shaped one. +- `V(partner).has(country,3).in('billedTo').has(status,1)...sum()` (the other direction) lands on the **same anchor and the same atom multiset**, same 1237848. +- `out('billedTo').groupCount().by('country')` = DuckDB `join_group_count_country`. +- `V(doc).where(in('partOf').has(status,1)).count()`: oracle 511 = DuckDB; lowered terminal and physical refusal (`LaneNotOrdered`) are identical to Quack's SQL path. +- One M:N hop (doc → tradesWith → partner, edge table = `line`) with predicates on both endpoints: one `Program`, matches the oracle; `groupCount` by target field matches. +- Bag vs set after a fan-out stay apart: bag `Count` ≠ set `CountDistinctOrdered`; `dedup()`+return scatters a mask whose popcount = oracle's set; returning a bag without `dedup` is refused. + +Adapter tax (debug build, one run, not asserted): adapter ≈ 1.9 µs, Quack lowering ≈ 1.0 µs, execution over 4,096 rows ≈ 167 µs (the timed loop also builds lanes and scratch). Adding a frontend adds lowering rules, not a data path. + +## §4 — Parity matrix (B) + +Classification: **E** existing primitive · **C** composition of existing primitives · **G** missing general primitive · **F** frontend-specific semantics · **O** out of scope. Consumption class in brackets: P pointwise · FI functional-indexed · NFI non-functional-indexed · SC scalar control · D demanded output. + +| construct | SQL / Quack | Cypher (#1306 plan) | Gremlin (witness) | SurrealQL (AST read) | +|---|---|---|---|---| +| population selection | E `Plane` [P] | E classid `EqU32Strided` [P] | E `V(T)` [P] | E `FROM table` [P] | +| predicate, Boolean composition | E `Cmp`, `And/Or/Not`, `Ternlog` [P] | E (+ `fuse`) | E | E `Cond(Expr)` | +| projection | E `Agg::Rows` [D] | E `Keep` | E return at anchor | E `Fields` | +| property lookup through fk | E `EqU32Via` [FI] | NO over row bytes (D-CML-5a) | E | E idiom `.field` after a hop | +| functional relation | E `Via` reads [FI] | needs D-CML-3b pointer | E | E `->rel->` when 1:1 | +| many-to-many, one hop | C re-anchor on edge table [P over edges] | RF-CROSS-SPACE (one population) | C | C edge tables are records with `in`/`out` | +| forward / reverse traversal | C | pull/push + RF-TRANSPOSE | C both directions over fk or edge table | `Dir::{In,Out,Both}` | +| grouping, count, sum, min, max | E `GroupReduce`, `GroupSum*` [D] | count yes; sum NO after hop (RF-BAG) | E (count; sum on anchor) | E `GROUP BY`, `count()`, `math::sum` (named functions) | +| distinct | E ordered `CountKeyRuns`; held seen-set [D] | E `count(DISTINCT n)` | E `dedup` | F none per hop (bag) | +| semi-join | E `Semijoin` / `EqU32Via` [FI] | E `Gather` (pull) | E `where(out..)` | C subquery in `Cond` | +| anti-join | C `negate(Semijoin)` (dir-sim) | C `NOT` | C `not(where..)` (not in witness) | C `NOT` subquery | +| multi-hop, functional | **G** composed via [FI] | STOP (needs pointer + 5a) | **G** refused | **G** | +| multi-hop, fan-out after hop | **G** barrier + workspace [NFI] | RF-CHAIN | **G** refused | **G** | +| ordered compare through fk | **G** `EqU32Via` only [FI] | NO (5a) | **G** refused | **G** | +| sum of a foreign value | **G** no `Σ v[fk[i]]` [FI] | RF-BAG after hop | **G** refused | **G** | +| ordered compare on `u32` | **G** IR orders `i32` only | NO over bytes | refused | — | +| bounded variable length | O here | §4.2 frontier fixpoint (pull only) | O `repeat().times()` | O `{1..3}` `Recurse` | +| path result, path count | F | RF-BAG | F `path()` | F `RecurseInstruction::Path` | +| shortest path | O | O | O | F `Shortest` | +| ordering, limit, range | F | RF-ORDER | F | F `ORDER/LIMIT/START` | +| mutation | O (commit gate) | RF-UNPARSED | F/O `addE`, `property` | O `CREATE/RELATE/UPDATE/DELETE` | +| side effects | O | O | F/O `aggregate`, `sideEffect` | O | + +## §5 — SurrealQL AST harvest (D) + +Read in `/home/user/surrealdb` (fork HEAD `8f5adb2`); two claims re-read by hand +(`sql/lookup.rs:12`, `sql/dir.rs`, `val/value/get.rs:495-502`). + +**Worth mirroring (structure):** +- A graph arrow is one idiom `Part::Graph(Lookup)`; `Lookup { kind: Graph(Dir) | Reference, what: Vec, cond, expr, group, order, limit, start, split, alias }`. **Each hop is a mini-SELECT** — the same shape as "re-anchor on the edge population, then filter, then fold". +- Edges are first-class record tables with `in`/`out` fields (`doc/edges.rs:57-74`): exactly the edge-as-population geometry the witness uses (S = `in`, O = `out`). +- Aggregates are named function calls resolved at plan time (`catalog/aggregation.rs:538-572`), not AST nodes — the adapter maps names to `GroupFold`, as Quack maps `Agg`. +- Recursion is a typed idiom part (`Recurse::{Fixed, Range}`, `RecurseInstruction::{Path, Collect, Shortest}`), so path/set/shortest are distinguished in the AST itself — the same split this repo needs (§4.2 of #1306). + +**Not reusable as a dependency:** +- Every production AST type is `pub(crate)` (`core/src/sql/mod.rs`); `parse()` is public but its result is opaque outside the crate. +- The public arena AST (`surrealdb-ast` / `surrealdb-parser`) is `publish = false`, self-labelled unstable, and not what core runs. +- License: Business Source License 1.1 (change date 2030-01-01). Depending on it is a licensing decision for the operator, not this plan. + +**So the thin adapter is real but has a seam:** a fork-side lowering hook inside +`surrealdb-core` (which already carries AdaWorldAPI features `kv-lance`, +`op-bridge`, `lance-graph`) that walks `sql::Lookup` and emits a Quack `Query` would +need no universal AST. That hook is a separate decision (D-FPW-3). + +## §6 — Converged algebra, and the gaps that survive (E, F) + +**The smallest set every frontend above needs** (all existing): resident plane; +pointwise predicate; Boolean composition; fk-read predicate (`EqU32Via`); semijoin +over a resident plane (`Gather`); one-terminal folds (`Count`, `Masked*`, +`GroupReduce{Lane,Via,Pair}`); ordered distinct count; scatter as a demanded result. +Plus one lowering rule, not a primitive: **re-anchor a fan-out on the population +whose rows are the paths.** + +**Gaps that pass the two-independent-witness rule** (each is demanded by SQL and +Gremlin in this file, and independently by Cypher #1306 or #1308): + +| gap | class | witnesses | already named as | +|---|---|---|---| +| G1 composed functional read `v[fk2[fk1[i]]]` | FI | SQL 3-table fk chain; Gremlin `out().out()`; Cypher fixed 2-hop | #1308 option (a), tile-local gather chain | +| G2 barrier + computation-private workspace (fan-out after a hop) | NFI | SQL 3-way join; Gremlin `out().in()`; Cypher RF-CHAIN; GQL | #1308 (b)/(d), #1310 "b-lite" | +| G3 ordered compare through an fk | FI | SQL `JOIN … WHERE p.x > k`; Gremlin `out().has(gt)`; Cypher §4 NO rows | D-CML-5a (ordered ops over bytes; partly) | +| G4 sum of a foreign value `Σ v[fk[i]]` | FI | SQL `SUM(p.x)` over a join; Gremlin `out().values().sum()`; Cypher `sum(b.p)` | not named before | +| G5 ordered compare on `u32` | P | SQL `doc_id` range (`fixture.rs`); Gremlin `has(gt)` on `u32` | fixture.rs note | + +None is built here. G1 and G2 are the two that decide traversal; G3–G5 are small. + +## §7 — Frontend-specific semantics that must not leak down (G) + +- Gremlin `groupCount` omits zero keys; SQL `LEFT JOIN` keeps them. The sink has + K slots either way; presentation is the frontend's. +- Bag vs set: every frontend defaults to bags. A mask is a set. `dedup` / + `DISTINCT` / SurrealQL's lack of per-hop dedup are frontend semantics; the + substrate carries bags only as anchor rows, and refuses returning a bag of far + elements (`BagOfElementsNotAMask`). +- Paths, ordering, positions, mutation, side effects: refused by name, never + approximated. + +## §8 — Connected-computation implications (H) + +- **Pointwise** and **functional-indexed** cover everything that lowers here, in + ONE `Program`, including one M:N hop (edge-population re-anchor). +- **Non-functional-indexed** begins exactly at the second fan-out (G2). All four + frontends hit it at the same place. +- **Multi-terminal** is independently demanded (Gremlin `group().by(..).by(sum, count)`, + Cypher multiple RETURN aggregates, SQL `AVG = SUM/COUNT` which Quack already + plans as two programs, FoldDialect `SUM − SUM`). It is **not** a traversal + requirement: none of the witness traversals needed it. +- **Multi-phase** is G2. It is the only place a population-sized workspace is + mathematically required. + +## §9 — ABI implications (I) + +- The adapter needed exactly four facts per relation: carrier kind (fk | edge + table), the fk column(s), source and target table. That is #1306's + `(carrier, field, direction, reverse lane)` declaration, plus "edge table" as a + carrier kind #1306 does not have (it has one population). +- Coordinate identity: every foreign plane/lane carries its own row count + (`ForeignPlane::rows`), and the anchor's `Planes::n_rows` is the only + `n_rows`. The witness never compares equal-length populations as equal. +- ClassId: tables here are fixture tables, not classids. Binding `Table` → + classid is #1306's `LabelBinding`; nothing in the witness depends on it. +- Version: not exercised (single version). A workspace for G2 must carry + `(coordinate space, version)` — open. + +## §10 — The PR (J) + +- `crates/lance-graph-quack/tests/gremlin_parity.rs` — test-only: typed step + vocabulary, schema declaration, adapter, refusals, bulk oracle, 13 tests. +- This plan, one board entry, `STATUS_BOARD` / `INTEGRATION_PLANS` rows. +- No change to `mask-risc`, Quack `src/`, or any upstream file. + +Disable checks run (each red, then restored): dedup does not mark the set; a +re-anchor drops carried predicates; a bag emitted as a mask; an edge hop drops the +target-exists semijoin; carry keeps a stale via atom. The edge-hop disable is +caught only by the structural `Query` comparison: the fixture has no dangling fk, +so execution cannot see it. + +## §11 — Falsifiers (K) + +The "shared algebra" hypothesis is too broad if any of these is found: + +1. A traversal the oracle answers, that lowers, and whose execution disagrees with + the oracle or DuckDB. +2. A Gremlin traversal and its SQL equivalent that both lower but to `Query` + values with different atom multisets. +3. A frontend construct that needs a new primitive and has no second independent + frontend demanding the same semantics (it would belong in that frontend). +4. A case where re-anchoring on the edge population gives a different bag count + than the path count (would break §2). +5. Cypher or SurrealQL wiring that cannot reach `Query` without a universal AST in + between. + +## D-ids + +| D-id | scope | status | +|---|---|---| +| D-FPW-0 | inventory + parity matrix (this file) | Shipped (this PR) | +| D-FPW-1 | Gremlin witness `tests/gremlin_parity.rs` | Shipped (this PR) | +| D-FPW-2 | feed §2 (functional hop ⇒ exact bag count) into #1306 D-CML-2's classifier | Queued | +| D-FPW-3 | SurrealQL fork-side lowering hook (`sql::Lookup` → `Query`); licensing decision first | Queued, operator | +| D-FPW-4 | G4 (foreign-value sum) — needs a second look against `GroupReduce` before being called a primitive | Queued | diff --git a/crates/lance-graph-quack/tests/gremlin_parity.rs b/crates/lance-graph-quack/tests/gremlin_parity.rs new file mode 100644 index 000000000..d6feaed31 --- /dev/null +++ b/crates/lance-graph-quack/tests/gremlin_parity.rs @@ -0,0 +1,1481 @@ +//! Frontend-parity witness: a Gremlin-shaped traversal lowered onto Quack's +//! [`Query`], the same target the SQL lowering uses. +//! +//! # What this file is for +//! +//! It tests one hypothesis. SQL (DuckDB) and Gremlin were designed +//! independently. If both lower onto the same small population algebra, then +//! a Gremlin traversal and its SQL equivalent should produce the same +//! [`Query`] value. That is a check BELOW the frontend, not only a check on the +//! final number. +//! +//! The adapter here is test code. It adds no operator to Quack and no op to +//! mask-risc. Every traversal it accepts lowers to a `Query { filter, agg }` +//! that Quack already had. Every traversal it cannot lower is refused, and the +//! refusal names the missing capability. +//! +//! # The invariant that makes it work +//! +//! Gremlin counts traversers with bulk (bag semantics). The adapter keeps one +//! table as the ANCHOR: the table whose rows each stand for exactly one +//! traverser of bulk 1. Two moves keep that true: +//! +//! - a hop along a FUNCTIONAL relation (each anchor row names at most one +//! target) does not change the anchor. The target's fields are read through +//! the foreign key ([`Filter::EqU32Via`], [`GroupAddr::Via`]). This is the +//! functional-indexed consumption class: resident reads composed, nothing +//! materialised; +//! - a hop that fans out (the reverse of a foreign key, or an edge table) +//! MOVES the anchor to the table whose rows are the paths: the child table, +//! or the edge table. The old anchor's predicates are carried across through +//! the foreign key. One hop of fan-out is therefore still one population. +//! +//! A second fan-out, or a functional read two foreign keys deep, cannot be +//! expressed this way. Those are refused, and they are the two real gaps (see +//! [`Refusal::NonFunctionalChain`] and [`Refusal::ComposedFunctionalHop`]). +//! +//! # The graph view of the DuckDB fixture +//! +//! `duckdb/fixture.rs` is an ERP fixture. Read as a property graph: +//! +//! - `line -billedTo-> partner` is functional (`line.partner_id`); +//! - `line -partOf-> doc` is functional (`line.doc_id`); +//! - `doc -tradesWith-> partner` is many-to-many, with `line` as its edge +//! table (`src = doc_id`, `dst = partner_id`); +//! - `doc -ownedBy-> company` is functional (`doc.company`). The `company` +//! table (3 rows, one `region` field) is built here. It exists only so that a +//! two-deep functional chain can be written down. +//! +//! # The oracles +//! +//! Where DuckDB already answered the SQL equivalent (`cases.tsv`), the +//! traversal is checked against that answer. Every traversal is also checked +//! against [`oracle`]: a row-at-a-time Gremlin interpreter, with bulk, that +//! never touches Quack or mask-risc. + +#[path = "duckdb/fixture.rs"] +#[allow(dead_code)] +mod fixture; + +use std::collections::BTreeMap; +use std::path::Path; +use std::time::Instant; + +use lance_graph_mask_risc::{ + execute_into, words_for, ExecError, Foreign, ForeignPlane as FPlane, LaneRef, Out, Planes, + Scratch, Value, +}; +use lance_graph_quack::{ + lower, Agg, Cmp, Col, Filter, ForeignLane, ForeignPlane, GroupAddr, GroupAgg, Mask, Query, +}; + +use fixture::col::{AMOUNT, DOC_ID_U32, PARTNER_ID, STATUS}; +use fixture::{DOC_ROWS, LINE_ROWS, PARTNER_ROWS}; + +// ===================================================================== +// The traversal vocabulary — typed, no strings, no text parser. +// ===================================================================== + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Table { + Line, + Doc, + Partner, + Company, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Field { + Status, + Amount, + DocType, + Country, + Region, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Rel { + BilledTo, + PartOf, + OwnedBy, + TradesWith, +} + +/// A predicate in `has(field, p)`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum P { + Eq(i64), + Gt(i64), +} + +/// The steps. Only the ones a test needs; adding one needs a test. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Step { + V(Table), + Has(Field, P), + Out(Rel), + In(Rel), + /// `where()`: keep the traverser if the sub-traversal + /// reaches anything. + Where(Vec), + Dedup, + Values(Field), + Count, + Sum, + GroupCountBy(Field), + /// Return the current elements (the end of a traversal with no reducer). + Emit, + Limit(u32), + Paths, + /// `repeat(out(rel)).times(k)`. + RepeatTimes(Rel, u32), + /// `property(..)`, `addE(..)`, `aggregate(..)`, `sideEffect(..)`. + SideEffect, +} + +use Step::*; + +// ===================================================================== +// The schema: where each field lives and how each relation is stored. +// ===================================================================== + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Kind { + U32, + I32, +} + +fn home(f: Field) -> Table { + match f { + Field::Status | Field::Amount => Table::Line, + Field::DocType => Table::Doc, + Field::Country => Table::Partner, + Field::Region => Table::Company, + } +} + +fn kind(f: Field) -> Kind { + match f { + Field::Amount => Kind::I32, + _ => Kind::U32, + } +} + +/// How many values a `u32` field takes: the group universe of a groupCount. +fn domain(f: Field) -> u32 { + match f { + Field::Status => 3, + Field::Country => 8, + Field::DocType => 4, + Field::Region => 2, + Field::Amount => 0, + } +} + +fn rows(t: Table) -> usize { + match t { + Table::Line => LINE_ROWS, + Table::Doc => DOC_ROWS, + Table::Partner => PARTNER_ROWS, + Table::Company => COMPANY_ROWS, + } +} + +/// The column of `f` when its table is the anchor ([`Planes::lanes`] index). +fn local_col(f: Field) -> Col { + match f { + Field::Status => STATUS, + Field::Amount => AMOUNT, + Field::DocType | Field::Country | Field::Region => Col(0), + } +} + +/// The foreign lane of `f` when its table is reached through an fk +/// ([`Foreign::lanes`] index; one numbering shared by every anchor). +fn foreign_lane(f: Field) -> Option { + match f { + Field::Country => Some(ForeignLane(0)), + Field::DocType => Some(ForeignLane(1)), + Field::Region => Some(ForeignLane(2)), + _ => None, + } +} + +/// Each table's validity plane, as a foreign plane ([`Foreign::planes`]). +fn foreign_alpha(t: Table) -> ForeignPlane { + match t { + Table::Partner => ForeignPlane(0), + Table::Doc => ForeignPlane(1), + Table::Line => ForeignPlane(2), + Table::Company => ForeignPlane(3), + } +} + +/// Each table's validity plane when it is the anchor: plane 0 of its own +/// `Planes`. Quack's rule: the table IS its validity plane. +const ALPHA: Mask = Mask(0); + +/// The fk column on `doc` when `doc` is the anchor. +const DOC_COMPANY: Col = Col(1); + +/// How a relation is stored. DECLARED here, never guessed by the lowering. +#[derive(Debug, Clone, Copy)] +enum Carrier { + /// Each `on` row holds the row index of one `to` row. + Fk { on: Table, col: Col, to: Table }, + /// Each `edges` row holds a `src` row index and a `dst` row index. + Edge { + edges: Table, + src: Col, + dst: Col, + src_t: Table, + dst_t: Table, + }, +} + +fn carrier(r: Rel) -> Carrier { + match r { + Rel::BilledTo => Carrier::Fk { + on: Table::Line, + col: PARTNER_ID, + to: Table::Partner, + }, + Rel::PartOf => Carrier::Fk { + on: Table::Line, + col: DOC_ID_U32, + to: Table::Doc, + }, + Rel::OwnedBy => Carrier::Fk { + on: Table::Doc, + col: DOC_COMPANY, + to: Table::Company, + }, + Rel::TradesWith => Carrier::Edge { + edges: Table::Line, + src: DOC_ID_U32, + dst: PARTNER_ID, + src_t: Table::Doc, + dst_t: Table::Partner, + }, + } +} + +// ===================================================================== +// The adapter. +// ===================================================================== + +/// Why a traversal was not lowered. Each variant names what is missing. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Refusal { + /// A field read through two foreign keys (`out(partOf).out(ownedBy) + /// .has(..)`). Functional-indexed: it needs a composed read + /// `v[fk2[fk1[i]]]`. The IR reads one fk deep. Gap: the tile-local gather + /// chain (#1308 option (a)). + ComposedFunctionalHop, + /// A fan-out followed by another hop (M:N then M:N, or a functional hop + /// then a fan-out). Non-functional-indexed: it needs a barrier and a + /// workspace inside one computation. Gap: no typed phase handoff exists + /// (#1308 options (b)/(d), #1310 "b-lite"). + NonFunctionalChain, + /// `has(field, gt(..))` on a table reached through an fk. The IR has + /// `EqU32Via` only. Gap: ordered comparison through an fk. + ViaPredicateNotEquality(Field), + /// `has` on a `u32` field with an ordered comparison. The IR orders + /// `i32` lanes only (`fixture.rs` records the same gap for `doc_id`). + OrderedU32(Field), + /// `values(f).sum()` where `f` is reached through an fk. Gap: a sum of a + /// foreign value lane (`Σ v[fk[i]]`). + ValueThroughHop(Field), + /// A field that is not on the element the traverser is at. + FieldNotOnCursor(Field), + /// A relation whose stored direction does not start at the cursor, and no + /// reverse lane is declared. + NoCarrierFromCursor(Rel), + /// Returning elements that may repeat. A mask holds a set; a bag of + /// vertices has no mask form. `dedup()` first. + BagOfElementsNotAMask, + /// A fold over a de-duplicated population other than `count()` or + /// `emit`. Needs a per-group distinct fold, which does not exist. + FoldOverSet, + /// A step after `dedup()` that moves or filters the set population. + ConsumesSetPopulation, + /// `limit`, `range`, `order`: a mask has no order or position. + Positional, + /// `path()`: an answer per path, not per element. + PathMultiplicity, + /// `repeat()`: loop state; refused in all forms (see the parity notes). + Repeat, + /// A step with an effect outside the population. + SideEffect, + /// The traversal does not start with `V()` or ends without a terminal. + Malformed, +} + +/// Where the traverser is, relative to the anchor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Cursor { + /// On the anchor row itself. + Anchor, + /// One functional read away: the anchor's `fk` column names a `table` row. + Via { fk: Col, table: Table }, +} + +/// One predicate, kept symbolic until the anchor is final. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Atom { + /// The table's validity, read on the anchor or through `fk`. + Alpha { table: Table, via: Option }, + /// `field p`, read on the anchor or through `fk`. + Has { + field: Field, + p: P, + via: Option, + }, +} + +#[derive(Debug, Clone)] +struct State { + anchor: Table, + cursor: Cursor, + atoms: Vec, + /// Set by `dedup()` on a `Via` cursor: the population is the set of + /// distinct cursor elements, no longer one per anchor row. + set: bool, + value: Option, +} + +/// The result of lowering: the anchor table and the Quack query over it. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Lowered { + anchor: Table, + query: Query, +} + +fn lower_traversal(steps: &[Step]) -> Result { + let (first, rest) = steps.split_first().ok_or(Refusal::Malformed)?; + let V(t) = *first else { + return Err(Refusal::Malformed); + }; + let mut st = State { + anchor: t, + cursor: Cursor::Anchor, + atoms: vec![Atom::Alpha { + table: t, + via: None, + }], + set: false, + value: None, + }; + let (last, middle) = rest.split_last().ok_or(Refusal::Malformed)?; + for s in middle { + apply(&mut st, s)?; + } + let agg = terminal(&st, last)?; + let filter = build_filter(&st)?; + Ok(Lowered { + anchor: st.anchor, + query: Query { filter, agg }, + }) +} + +fn apply(st: &mut State, s: &Step) -> Result<(), Refusal> { + if st.set && !matches!(s, Has(..)) { + return Err(Refusal::ConsumesSetPopulation); + } + match *s { + Has(f, p) => has(st, f, p), + Out(r) => hop(st, r, true), + In(r) => hop(st, r, false), + Where(ref sub) => where_(st, sub), + Dedup => { + match st.cursor { + // Anchor rows are distinct elements already: one traverser each. + Cursor::Anchor => {} + Cursor::Via { .. } => st.set = true, + } + Ok(()) + } + Values(f) => { + if home(f) != st.anchor || st.cursor != Cursor::Anchor { + return Err(match st.cursor { + Cursor::Via { table, .. } if home(f) == table => Refusal::ValueThroughHop(f), + _ => Refusal::FieldNotOnCursor(f), + }); + } + st.value = Some(f); + Ok(()) + } + Limit(_) => Err(Refusal::Positional), + Paths => Err(Refusal::PathMultiplicity), + RepeatTimes(..) => Err(Refusal::Repeat), + SideEffect => Err(Refusal::SideEffect), + V(_) | Count | Sum | GroupCountBy(_) | Emit => Err(Refusal::Malformed), + } +} + +fn has(st: &mut State, f: Field, p: P) -> Result<(), Refusal> { + let via = match st.cursor { + Cursor::Anchor if home(f) == st.anchor => None, + Cursor::Via { fk, table } if home(f) == table => Some(fk), + _ => return Err(Refusal::FieldNotOnCursor(f)), + }; + st.atoms.push(Atom::Has { field: f, p, via }); + Ok(()) +} + +/// Carry every anchor atom across the fk `col` of the new anchor. An atom +/// already read through an fk would become two fks deep. +fn carry(atoms: &[Atom], col: Col) -> Result, Refusal> { + atoms + .iter() + .map(|a| match *a { + Atom::Alpha { table, via: None } => Ok(Atom::Alpha { + table, + via: Some(col), + }), + Atom::Has { + field, + p, + via: None, + } => Ok(Atom::Has { + field, + p, + via: Some(col), + }), + _ => Err(Refusal::ComposedFunctionalHop), + }) + .collect() +} + +fn hop(st: &mut State, r: Rel, out: bool) -> Result<(), Refusal> { + match (carrier(r), out, st.cursor) { + // Functional, in the stored direction: the anchor stays. + (Carrier::Fk { on, col, to }, true, Cursor::Anchor) if on == st.anchor => { + st.cursor = Cursor::Via { fk: col, table: to }; + st.atoms.push(Atom::Alpha { + table: to, + via: Some(col), + }); + Ok(()) + } + // Functional, a second time: two fks deep. + (Carrier::Fk { on, .. }, true, Cursor::Via { table, .. }) if on == table => { + Err(Refusal::ComposedFunctionalHop) + } + // Reverse of an fk: fan-out. The child table becomes the anchor. + (Carrier::Fk { on, col, to }, false, Cursor::Anchor) if to == st.anchor => { + let mut atoms = vec![Atom::Alpha { + table: on, + via: None, + }]; + atoms.extend(carry(&st.atoms, col)?); + st.anchor = on; + st.atoms = atoms; + Ok(()) + } + // An edge table: fan-out. The edge table becomes the anchor. + ( + Carrier::Edge { + edges, + src, + dst, + src_t, + dst_t, + }, + _, + Cursor::Anchor, + ) => { + let (from_col, from_t, to_col, to_t) = if out { + (src, src_t, dst, dst_t) + } else { + (dst, dst_t, src, src_t) + }; + if from_t != st.anchor { + return Err(Refusal::NoCarrierFromCursor(r)); + } + let mut atoms = vec![Atom::Alpha { + table: edges, + via: None, + }]; + atoms.extend(carry(&st.atoms, from_col)?); + atoms.push(Atom::Alpha { + table: to_t, + via: Some(to_col), + }); + st.anchor = edges; + st.cursor = Cursor::Via { + fk: to_col, + table: to_t, + }; + st.atoms = atoms; + Ok(()) + } + // Any fan-out (or a reverse read) from a cursor that already left the + // anchor needs a second population. + (_, _, Cursor::Via { .. }) => Err(Refusal::NonFunctionalChain), + _ => Err(Refusal::NoCarrierFromCursor(r)), + } +} + +/// `where(sub)`. Two shapes lower: +/// - `sub` stays on the anchor (functional hops and `has` only): its atoms +/// join the anchor's, and the cursor does not move; +/// - `sub` starts by fanning out (`in(rel)` over an fk) and then only filters: +/// the traversal becomes "the distinct parents of the surviving children", +/// i.e. the child table as anchor with a set over the fk. +fn where_(st: &mut State, sub: &[Step]) -> Result<(), Refusal> { + if st.cursor != Cursor::Anchor { + return Err(Refusal::NonFunctionalChain); + } + let mut inner = st.clone(); + for s in sub { + apply(&mut inner, s)?; + } + if inner.anchor == st.anchor { + st.atoms = inner.atoms; + return Ok(()); + } + match sub.first() { + Some(In(r)) => match carrier(*r) { + Carrier::Fk { col, to, .. } if inner.cursor == Cursor::Anchor => { + st.anchor = inner.anchor; + st.atoms = inner.atoms; + st.cursor = Cursor::Via { fk: col, table: to }; + st.set = true; + Ok(()) + } + _ => Err(Refusal::NonFunctionalChain), + }, + _ => Err(Refusal::NonFunctionalChain), + } +} + +fn terminal(st: &State, s: &Step) -> Result { + match (s, st.cursor, st.set) { + (Count, _, false) => Ok(Agg::Count), + (Count, Cursor::Via { fk, .. }, true) => Ok(Agg::CountDistinctOrderedU32 { key: fk }), + (Emit, Cursor::Anchor, false) => Ok(Agg::Rows), + (Emit, Cursor::Via { .. }, false) => Err(Refusal::BagOfElementsNotAMask), + (Emit, Cursor::Via { fk, table }, true) => Ok(Agg::ScatterOrU32 { + fk, + out_rows: rows(table) as u32, + }), + (Sum, _, false) => match st.value { + Some(f) if kind(f) == Kind::I32 => Ok(Agg::SumI32(local_col(f))), + _ => Err(Refusal::Malformed), + }, + (GroupCountBy(f), cursor, false) => { + let f = *f; + let key = match cursor { + Cursor::Anchor if home(f) == st.anchor => GroupAddr::Local(local_col(f)), + Cursor::Via { fk, table } if home(f) == table => GroupAddr::Via { + fk, + key: foreign_lane(f).ok_or(Refusal::FieldNotOnCursor(f))?, + }, + _ => return Err(Refusal::FieldNotOnCursor(f)), + }; + Ok(Agg::GroupReduce { + key, + agg: GroupAgg::Count, + }) + } + (Sum | GroupCountBy(_), _, true) => Err(Refusal::FoldOverSet), + (Limit(_), ..) => Err(Refusal::Positional), + (Paths, ..) => Err(Refusal::PathMultiplicity), + (RepeatTimes(..), ..) => Err(Refusal::Repeat), + (SideEffect, ..) => Err(Refusal::SideEffect), + _ => Err(Refusal::Malformed), + } +} + +fn build_filter(st: &State) -> Result { + let parts = st + .atoms + .iter() + .map(|a| match *a { + Atom::Alpha { via: None, .. } => Ok(Filter::Plane(ALPHA)), + Atom::Alpha { + table, + via: Some(fk), + } => Ok(Filter::semijoin(fk, foreign_alpha(table))), + Atom::Has { + field, + p, + via: None, + } => { + let c = local_col(field); + match (kind(field), p) { + (Kind::U32, P::Eq(v)) => Ok(Filter::cmp(c, Cmp::EqU32(v as u32))), + (Kind::I32, P::Eq(v)) => Ok(Filter::cmp(c, Cmp::EqI32(v as i32))), + (Kind::I32, P::Gt(v)) => Ok(Filter::cmp(c, Cmp::GtI32(v as i32))), + (Kind::U32, P::Gt(_)) => Err(Refusal::OrderedU32(field)), + } + } + Atom::Has { + field, + p, + via: Some(fk), + } => match (kind(field), p, foreign_lane(field)) { + (Kind::U32, P::Eq(v), Some(lane)) => Ok(Filter::eq_u32_via(fk, lane, v as u32)), + _ => Err(Refusal::ViaPredicateNotEquality(field)), + }, + }) + .collect::, _>>()?; + Ok(Filter::and(parts)) +} + +// ===================================================================== +// Execution harness: the fixture as SoA, per anchor. +// ===================================================================== + +const COMPANY_ROWS: usize = 3; +const COMPANY_REGION: [u32; COMPANY_ROWS] = [0, 1, 1]; + +fn ones(n: usize) -> Vec { + let mut w = vec![u64::MAX; words_for(n)]; + if !n.is_multiple_of(64) { + if let Some(last) = w.last_mut() { + *last = (1u64 << (n % 64)) - 1; + } + } + w +} + +struct World { + fx: fixture::Fixture, + alpha: BTreeMap>, +} + +impl World { + fn new() -> Self { + let fx = fixture::generate(); + let alpha = [Table::Line, Table::Doc, Table::Partner, Table::Company] + .into_iter() + .map(|t| (t, ones(rows(t)))) + .collect(); + World { fx, alpha } + } + + /// Run a lowered query. `out` is the sink the terminal needs. + fn run(&self, l: &Lowered, out: Out<'_>) -> Result { + let fx = &self.fx; + let lanes: Vec> = match l.anchor { + // The fixture's column order (`fixture::col`). + Table::Line => vec![ + LaneRef::I32(&fx.line.amount), + LaneRef::I32(&fx.line.qty), + LaneRef::I32(&fx.line.doc_id), + LaneRef::U32(&fx.line.status), + LaneRef::U32(&fx.line.cost_center), + LaneRef::U32(&fx.line.gl_account), + LaneRef::U32(&fx.line.partner_id), + LaneRef::U32(&fx.line.doc_id_u32), + LaneRef::I32(&fx.line.discount), + ], + Table::Doc => vec![ + LaneRef::U32(&fx.doc.doc_type), + LaneRef::U32(&fx.doc.company), + ], + Table::Partner => vec![ + LaneRef::U32(&fx.partner.country), + LaneRef::U32(&fx.partner.pgroup), + ], + Table::Company => vec![LaneRef::U32(&COMPANY_REGION)], + }; + let masks: [&[u64]; 1] = [&self.alpha[&l.anchor]]; + let planes = Planes { + n_rows: rows(l.anchor), + masks: &masks, + lanes: &lanes, + }; + let fplanes = [Table::Partner, Table::Doc, Table::Line, Table::Company].map(|t| FPlane { + words: &self.alpha[&t], + rows: rows(t), + }); + let flanes = [ + LaneRef::U32(&fx.partner.country), + LaneRef::U32(&fx.doc.doc_type), + LaneRef::U32(&COMPANY_REGION), + ]; + let foreign = Foreign { + planes: &fplanes, + lanes: &flanes, + }; + let program = lower(&l.query).expect("an adapter query always lowers"); + let mut scratch = Scratch::for_program(&program, planes.n_rows).expect("carves"); + execute_into(&program, &planes, &foreign, &mut scratch, out) + } + + fn count(&self, l: &Lowered) -> u64 { + match self.run(l, Out::None).expect("runs") { + Value::Count(c) => c as u64, + v => panic!("expected a count, got {v:?}"), + } + } + + fn groups(&self, l: &Lowered, k: usize) -> Vec { + let mut out = vec![0i64; k]; + assert_eq!( + self.run(l, Out::I64(&mut out)).expect("runs"), + Value::GroupReduced + ); + out + } +} + +// ===================================================================== +// The oracle: Gremlin traverser semantics, row at a time, with bulk. +// It never touches Quack or mask-risc. +// ===================================================================== + +mod oracle { + use super::*; + + pub type Elem = (Table, usize); + + #[derive(Debug, Clone, PartialEq)] + pub enum Ans { + Count(u64), + Sum(i64), + /// Gremlin's groupCount map: only keys some traverser reached. + Groups(BTreeMap), + Elems(Vec), + } + + fn field(w: &World, (t, r): Elem, f: Field) -> i64 { + let fx = &w.fx; + assert_eq!(home(f), t, "oracle: {f:?} read on {t:?}"); + match f { + Field::Status => fx.line.status[r] as i64, + Field::Amount => fx.line.amount[r] as i64, + Field::DocType => fx.doc.doc_type[r] as i64, + Field::Country => fx.partner.country[r] as i64, + Field::Region => COMPANY_REGION[r] as i64, + } + } + + /// `(from table, to table, pairs)` of a relation, in stored direction. + fn pairs(w: &World, r: Rel) -> (Table, Table, Vec<(usize, usize)>) { + let fx = &w.fx; + match r { + Rel::BilledTo => ( + Table::Line, + Table::Partner, + fx.line + .partner_id + .iter() + .enumerate() + .map(|(i, &p)| (i, p as usize)) + .collect(), + ), + Rel::PartOf => ( + Table::Line, + Table::Doc, + fx.line + .doc_id_u32 + .iter() + .enumerate() + .map(|(i, &d)| (i, d as usize)) + .collect(), + ), + Rel::OwnedBy => ( + Table::Doc, + Table::Company, + fx.doc + .company + .iter() + .enumerate() + .map(|(i, &c)| (i, c as usize)) + .collect(), + ), + Rel::TradesWith => ( + Table::Doc, + Table::Partner, + (0..LINE_ROWS) + .map(|i| { + ( + fx.line.doc_id_u32[i] as usize, + fx.line.partner_id[i] as usize, + ) + }) + .collect(), + ), + } + } + + fn step(w: &World, trav: Vec<(Elem, u64)>, s: &Step) -> Vec<(Elem, u64)> { + match *s { + Has(f, p) => trav + .into_iter() + .filter(|&(e, _)| { + let v = field(w, e, f); + match p { + P::Eq(x) => v == x, + P::Gt(x) => v > x, + } + }) + .collect(), + Out(r) | In(r) => { + let (a, b, prs) = pairs(w, r); + let (from, to) = if matches!(s, Out(_)) { (a, b) } else { (b, a) }; + let mut adj: BTreeMap> = BTreeMap::new(); + for (x, y) in prs { + let (x, y) = if matches!(s, Out(_)) { (x, y) } else { (y, x) }; + if y < rows(to) { + adj.entry(x).or_default().push(y); + } + } + let mut next = Vec::new(); + for ((t, r0), bulk) in trav { + assert_eq!(t, from); + for &y in adj.get(&r0).map(Vec::as_slice).unwrap_or(&[]) { + next.push(((to, y), bulk)); + } + } + next + } + Where(ref sub) => trav + .into_iter() + .filter(|&t| { + let mut inner = vec![(t.0, 1)]; + for s in sub { + inner = step(w, inner, s); + } + !inner.is_empty() + }) + .collect(), + Dedup => { + let mut seen: BTreeMap = BTreeMap::new(); + for (e, _) in trav { + seen.insert(e, 1); + } + seen.into_iter().collect() + } + RepeatTimes(r, k) => { + let mut cur = trav; + for _ in 0..k { + cur = step(w, cur, &Out(r)); + } + cur + } + ref other => panic!("oracle: no step semantics for {other:?}"), + } + } + + pub fn eval(w: &World, steps: &[Step]) -> Ans { + let V(t) = steps[0] else { + panic!("starts with V") + }; + let mut trav: Vec<(Elem, u64)> = (0..rows(t)).map(|r| ((t, r), 1)).collect(); + let mut value = None; + let (last, middle) = steps[1..].split_last().expect("has a terminal"); + for s in middle { + match s { + Values(f) => value = Some(*f), + s => trav = step(w, trav, s), + } + } + match last { + Count => Ans::Count(trav.iter().map(|&(_, b)| b).sum()), + Sum => { + let f = value.expect("values() before sum()"); + Ans::Sum(trav.iter().map(|&(e, b)| field(w, e, f) * b as i64).sum()) + } + GroupCountBy(f) => { + let mut m = BTreeMap::new(); + for &(e, b) in &trav { + *m.entry(field(w, e, *f)).or_insert(0) += b; + } + Ans::Groups(m) + } + Emit => Ans::Elems(trav.iter().map(|&(e, _)| e).collect()), + other => panic!("oracle: no terminal {other:?}"), + } + } + + pub fn count(w: &World, steps: &[Step]) -> u64 { + match eval(w, steps) { + Ans::Count(c) => c, + a => panic!("expected a count, got {a:?}"), + } + } +} + +// ===================================================================== +// DuckDB's committed answers (never hand-edited; see duckdb/README.txt). +// ===================================================================== + +fn duckdb(id: &str) -> String { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/duckdb/cases.tsv"); + let text = std::fs::read_to_string(path).expect("cases.tsv"); + text.lines() + .find_map(|l| { + let mut p = l.splitn(3, '\t'); + (p.next() == Some(id)).then(|| p.nth(1).unwrap_or("").to_string()) + }) + .unwrap_or_else(|| panic!("no DuckDB case {id}")) +} + +/// The atoms of a lowered filter, order-insensitive. Two traversals that +/// reach the same population by different routes produce the same multiset. +fn atom_set(q: &Query) -> Vec { + let Filter::And(parts) = &q.filter else { + panic!("adapter filters are a flat AND") + }; + let mut v: Vec = parts.iter().map(|p| format!("{p:?}")).collect(); + v.sort(); + v +} + +fn groups_encoded(sink: &[i64]) -> String { + sink.iter() + .enumerate() + .map(|(k, v)| format!("{k}:{v}")) + .collect::>() + .join(";") +} + +// ===================================================================== +// Parity: the same query from SQL and from Gremlin is the same Query. +// ===================================================================== + +/// `SELECT SUM(l.amount) FROM line l JOIN partner p ON p.rid=l.partner_id +/// WHERE l.status=1 AND p.country=3` (DuckDB: `join_sum_country`) against +/// `g.V().hasLabel('line').has('status',1) +/// .where(out('billedTo').has('country',3)).values('amount').sum()`. +/// +/// FAILS IF: the functional hop changes the anchor, the far-side predicate is +/// not read through the fk, or execution disagrees with DuckDB or the oracle. +#[test] +fn functional_where_equals_sql_join_sum() { + let w = World::new(); + let g = [ + V(Table::Line), + Has(Field::Status, P::Eq(1)), + Where(vec![Out(Rel::BilledTo), Has(Field::Country, P::Eq(3))]), + Values(Field::Amount), + Sum, + ]; + // The SQL reading: FROM line (its plane), WHERE l.status = 1, the inner + // join to partner (partner must exist), p.country = 3 read through the fk. + let sql = Query { + filter: Filter::and([ + Filter::Plane(ALPHA), + Filter::cmp(STATUS, Cmp::EqU32(1)), + Filter::semijoin(PARTNER_ID, foreign_alpha(Table::Partner)), + Filter::eq_u32_via(PARTNER_ID, ForeignLane(0), 3), + ]), + agg: Agg::SumI32(AMOUNT), + }; + let l = lower_traversal(&g).expect("lowers"); + assert_eq!(l.anchor, Table::Line); + assert_eq!(l.query, sql, "Gremlin and SQL must meet at the same Query"); + assert_eq!(lower(&l.query), lower(&sql)); + + let got = match w.run(&l, Out::None).expect("runs") { + Value::SumI64(s) => s, + v => panic!("{v:?}"), + }; + assert_eq!(got.to_string(), duckdb("join_sum_country")); + assert_eq!(oracle::eval(&w, &g), oracle::Ans::Sum(got)); +} + +/// `g.V().hasLabel('partner').has('country',3).in('billedTo').has('status',1) +/// .values('amount').sum()` walks the relation the OTHER way: it fans out +/// from partners to lines. It must land on the same anchor and the same atom +/// set as the forward `where(out(..))` traversal, and on DuckDB's answer. +/// +/// FAILS IF: the reverse hop does not move the anchor to `line`, or the +/// partner predicates are not carried through the fk. +#[test] +fn reverse_fanout_reanchors_onto_the_same_population() { + let w = World::new(); + let reverse = [ + V(Table::Partner), + Has(Field::Country, P::Eq(3)), + In(Rel::BilledTo), + Has(Field::Status, P::Eq(1)), + Values(Field::Amount), + Sum, + ]; + let forward = [ + V(Table::Line), + Has(Field::Status, P::Eq(1)), + Where(vec![Out(Rel::BilledTo), Has(Field::Country, P::Eq(3))]), + Values(Field::Amount), + Sum, + ]; + let r = lower_traversal(&reverse).expect("lowers"); + let f = lower_traversal(&forward).expect("lowers"); + assert_eq!( + r.anchor, + Table::Line, + "fan-out moves the anchor to the child table" + ); + assert_eq!(atom_set(&r.query), atom_set(&f.query)); + assert_eq!(r.query.agg, f.query.agg); + let got = match w.run(&r, Out::None).expect("runs") { + Value::SumI64(s) => s, + v => panic!("{v:?}"), + }; + assert_eq!(got.to_string(), duckdb("join_sum_country")); + assert_eq!(oracle::eval(&w, &reverse), oracle::Ans::Sum(got)); +} + +/// `SELECT p.country, COUNT(*) FROM line l JOIN partner p ... WHERE +/// l.status=1 GROUP BY p.country` (DuckDB: `join_group_count_country`) +/// against `g.V().hasLabel('line').has('status',1).out('billedTo') +/// .groupCount().by('country')`. +/// +/// Gremlin counts traversers with bulk, so after a functional hop the count +/// per partner country is the count of lines: SQL's `COUNT(*)` exactly. The +/// two differ only in presentation — Gremlin's map omits zero keys, SQL's +/// LEFT JOIN keeps them — which is the frontend's to render. +#[test] +fn functional_hop_group_count_equals_sql_group_by_via() { + let w = World::new(); + let g = [ + V(Table::Line), + Has(Field::Status, P::Eq(1)), + Out(Rel::BilledTo), + GroupCountBy(Field::Country), + ]; + let sql = Query { + filter: Filter::and([ + Filter::Plane(ALPHA), + Filter::cmp(STATUS, Cmp::EqU32(1)), + Filter::semijoin(PARTNER_ID, foreign_alpha(Table::Partner)), + ]), + agg: Agg::GroupReduce { + key: GroupAddr::Via { + fk: PARTNER_ID, + key: ForeignLane(0), + }, + agg: GroupAgg::Count, + }, + }; + let l = lower_traversal(&g).expect("lowers"); + assert_eq!(l.query, sql); + let sink = w.groups(&l, domain(Field::Country) as usize); + assert_eq!(groups_encoded(&sink), duckdb("join_group_count_country")); + let oracle::Ans::Groups(m) = oracle::eval(&w, &g) else { + panic!() + }; + for (k, &v) in sink.iter().enumerate() { + assert_eq!( + m.get(&(k as i64)).copied().unwrap_or(0), + v as u64, + "group {k}" + ); + } +} + +/// `SELECT COUNT(*) FROM doc d WHERE EXISTS(SELECT 1 FROM line l WHERE +/// l.doc_id=d.rid AND l.status=1)` (DuckDB: 511) against +/// `g.V().hasLabel('doc').where(in('partOf').has('status',1)).count()`. +/// +/// The existential over children lowers to the child table as anchor and a +/// DISTINCT over the fk — the same terminal Quack's own lowering of this SQL +/// uses (`duckdb_differential.rs::join_count_docs_with_posted`). On the +/// generated fixture `doc_id` is not in key order, so execution REFUSES +/// (`LaneNotOrdered`) exactly as it does for the SQL path: same logical +/// answer, same physical refusal, never a seen-set. +#[test] +fn exists_over_children_is_distinct_over_the_child_space() { + let w = World::new(); + let g = [ + V(Table::Doc), + Where(vec![In(Rel::PartOf), Has(Field::Status, P::Eq(1))]), + Count, + ]; + let l = lower_traversal(&g).expect("lowers"); + assert_eq!(l.anchor, Table::Line); + assert_eq!( + l.query, + Query { + filter: Filter::and([ + Filter::Plane(ALPHA), + Filter::semijoin(DOC_ID_U32, foreign_alpha(Table::Doc)), + Filter::cmp(STATUS, Cmp::EqU32(1)), + ]), + agg: Agg::CountDistinctOrderedU32 { key: DOC_ID_U32 }, + } + ); + assert_eq!( + w.run(&l, Out::None), + Err(ExecError::LaneNotOrdered { lane: DOC_ID_U32.0 }) + ); + assert_eq!( + oracle::count(&w, &g).to_string(), + duckdb("join_count_docs_with_posted") + ); +} + +// ===================================================================== +// Many-to-many: one hop is one population (the edge table). +// ===================================================================== + +const DOC_T1_COUNTRY3: [Step; 5] = [ + V(Table::Doc), + Has(Field::DocType, P::Eq(1)), + Out(Rel::TradesWith), + Has(Field::Country, P::Eq(3)), + Count, +]; + +/// `g.V().hasLabel('doc').has('doc_type',1).out('tradesWith') +/// .has('country',3).count()` over an M:N relation stored as an edge table. +/// SQL: `SELECT COUNT(*) FROM doc d JOIN line l ON l.doc_id=d.rid JOIN +/// partner p ON p.rid=l.partner_id WHERE d.doc_type=1 AND p.country=3`. +/// +/// One M:N hop lowers to ONE program over the edge population: both +/// endpoints are functional reads from an edge row. Bulk = one per edge row. +#[test] +fn many_to_many_one_hop_is_one_program_over_the_edge_table() { + let w = World::new(); + let l = lower_traversal(&DOC_T1_COUNTRY3).expect("lowers"); + assert_eq!(l.anchor, Table::Line); + let sql = Query { + filter: Filter::and([ + Filter::Plane(ALPHA), + Filter::semijoin(DOC_ID_U32, foreign_alpha(Table::Doc)), + Filter::eq_u32_via(DOC_ID_U32, ForeignLane(1), 1), + Filter::semijoin(PARTNER_ID, foreign_alpha(Table::Partner)), + Filter::eq_u32_via(PARTNER_ID, ForeignLane(0), 3), + ]), + agg: Agg::Count, + }; + assert_eq!(l.query, sql); + let got = w.count(&l); + assert!(got > 0, "anti-vacuity: the fixture must reach something"); + assert_eq!(got, oracle::count(&w, &DOC_T1_COUNTRY3)); +} + +/// The same hop folded by a target field: `groupCount().by('country')`. +#[test] +fn many_to_many_group_count_by_target_field() { + let w = World::new(); + let g = [ + V(Table::Doc), + Has(Field::DocType, P::Eq(1)), + Out(Rel::TradesWith), + GroupCountBy(Field::Country), + ]; + let l = lower_traversal(&g).expect("lowers"); + let sink = w.groups(&l, 8); + let oracle::Ans::Groups(m) = oracle::eval(&w, &g) else { + panic!() + }; + let nonzero = sink.iter().filter(|&&v| v > 0).count(); + assert!(nonzero > 1, "anti-vacuity: more than one group is reached"); + for (k, &v) in sink.iter().enumerate() { + assert_eq!( + m.get(&(k as i64)).copied().unwrap_or(0), + v as u64, + "group {k}" + ); + } +} + +/// Bag versus set after a fan-out. `.count()` counts paths (edge rows); +/// `.dedup().count()` counts distinct partners. The two differ on this +/// fixture, and the adapter keeps them apart: +/// - the bag count is `Count` over the edge table; +/// - the set count is `CountDistinctOrderedU32` over `partner_id`, which the +/// generated (unordered) layout physically refuses — it never falls back to +/// the bag; +/// - the set itself, as the DEMANDED result (`dedup()` then emit), is a +/// `ScatterOrU32` mask over partners whose popcount is the oracle's set. +#[test] +fn bag_and_set_stay_apart_after_a_fanout() { + let w = World::new(); + let bag = [ + V(Table::Doc), + Has(Field::DocType, P::Eq(1)), + Out(Rel::TradesWith), + Count, + ]; + let set = [ + V(Table::Doc), + Has(Field::DocType, P::Eq(1)), + Out(Rel::TradesWith), + Dedup, + Count, + ]; + let emit = [ + V(Table::Doc), + Has(Field::DocType, P::Eq(1)), + Out(Rel::TradesWith), + Dedup, + Emit, + ]; + let bag_truth = oracle::count(&w, &bag); + let set_truth = oracle::count(&w, &set); + assert!( + bag_truth > set_truth, + "anti-vacuity: {bag_truth} paths vs {set_truth} partners" + ); + + let lb = lower_traversal(&bag).expect("lowers"); + assert_eq!(w.count(&lb), bag_truth); + + let ls = lower_traversal(&set).expect("lowers"); + assert_eq!( + ls.query.agg, + Agg::CountDistinctOrderedU32 { key: PARTNER_ID } + ); + assert_eq!( + w.run(&ls, Out::None), + Err(ExecError::LaneNotOrdered { lane: PARTNER_ID.0 }) + ); + + let le = lower_traversal(&emit).expect("lowers"); + let mut mask = vec![0u64; words_for(PARTNER_ROWS)]; + assert_eq!( + w.run(&le, Out::Mask(&mut mask)).expect("runs"), + Value::Scattered + ); + let popcount: u64 = mask.iter().map(|w| u64::from(w.count_ones())).sum(); + assert_eq!(popcount, set_truth); + + // Without dedup, returning partners is a bag: no mask holds it. + let bag_emit = [ + V(Table::Doc), + Has(Field::DocType, P::Eq(1)), + Out(Rel::TradesWith), + Emit, + ]; + assert_eq!( + lower_traversal(&bag_emit), + Err(Refusal::BagOfElementsNotAMask) + ); +} + +// ===================================================================== +// The two real gaps, each with its can-fire / stay-silent pair. +// ===================================================================== + +/// A functional read two fks deep: `g.V().hasLabel('line').out('partOf') +/// .out('ownedBy').has('region',1).count()`. The question is well defined +/// (the oracle answers it); the IR reads one fk deep, so it is refused as a +/// composed functional hop. One fk deep, the same shape lowers and agrees. +#[test] +fn functional_two_hop_is_refused_one_hop_is_not() { + let w = World::new(); + let two = [ + V(Table::Line), + Out(Rel::PartOf), + Out(Rel::OwnedBy), + Has(Field::Region, P::Eq(1)), + Count, + ]; + assert_eq!(lower_traversal(&two), Err(Refusal::ComposedFunctionalHop)); + let truth = oracle::count(&w, &two); + assert!( + truth > 0 && truth < LINE_ROWS as u64, + "anti-vacuity: {truth}" + ); + + let one = [ + V(Table::Line), + Out(Rel::PartOf), + Has(Field::DocType, P::Eq(1)), + Count, + ]; + let l = lower_traversal(&one).expect("one fk deep lowers"); + assert_eq!(w.count(&l), oracle::count(&w, &one)); + + // A predicate already read through one fk, carried across a fan-out, ends + // up two fks deep: lines of documents whose company is in region 1. + let carried = [ + V(Table::Doc), + Where(vec![Out(Rel::OwnedBy), Has(Field::Region, P::Eq(1))]), + In(Rel::PartOf), + Count, + ]; + assert_eq!( + lower_traversal(&carried), + Err(Refusal::ComposedFunctionalHop) + ); + assert_eq!( + oracle::count(&w, &carried), + truth, + "same question, other route" + ); + + // A fan-out after a functional read (documents sharing a company) is a + // second population, not a deeper read. + let doc_side = [ + V(Table::Doc), + Out(Rel::OwnedBy), + Has(Field::Region, P::Eq(1)), + In(Rel::OwnedBy), + Count, + ]; + assert_eq!(lower_traversal(&doc_side), Err(Refusal::NonFunctionalChain)); +} + +/// Two fan-outs: `g.V().hasLabel('doc').has('doc_type',1).out('tradesWith') +/// .in('tradesWith').count()` — documents that share a partner with a +/// type-1 document, counted per path. It needs the first hop's result as a +/// workspace across a barrier. Refused; the one-hop prefix is not. +#[test] +fn many_to_many_two_hop_is_refused_one_hop_is_not() { + let w = World::new(); + let two = [ + V(Table::Doc), + Has(Field::DocType, P::Eq(1)), + Out(Rel::TradesWith), + In(Rel::TradesWith), + Count, + ]; + assert_eq!(lower_traversal(&two), Err(Refusal::NonFunctionalChain)); + let paths = oracle::count(&w, &two); + let mut distinct = two.to_vec(); + distinct.insert(4, Dedup); + let docs = oracle::count(&w, &distinct); + assert!( + paths > docs && docs > 0, + "anti-vacuity: {paths} paths, {docs} docs" + ); + + let one = [ + V(Table::Doc), + Has(Field::DocType, P::Eq(1)), + Out(Rel::TradesWith), + Count, + ]; + assert!(lower_traversal(&one).is_ok()); + + // A functional hop followed by a fan-out from the far side is the same + // class: lines that share a partner. + let share = [V(Table::Line), Out(Rel::BilledTo), In(Rel::BilledTo), Count]; + assert_eq!(lower_traversal(&share), Err(Refusal::NonFunctionalChain)); +} + +/// An ordered comparison on a table reached through an fk. Equality lowers +/// (`EqU32Via`); `gt` has no via form. +#[test] +fn far_side_ordered_predicate_is_refused_equality_is_not() { + let gt = [ + V(Table::Line), + Out(Rel::BilledTo), + Has(Field::Country, P::Gt(3)), + Count, + ]; + assert_eq!( + lower_traversal(>), + Err(Refusal::ViaPredicateNotEquality(Field::Country)) + ); + let eq = [ + V(Table::Line), + Out(Rel::BilledTo), + Has(Field::Country, P::Eq(3)), + Count, + ]; + assert!(lower_traversal(&eq).is_ok()); + // On the anchor, an ordered compare over an i32 lane lowers. + let local = [V(Table::Line), Has(Field::Amount, P::Gt(1000)), Count]; + let w = World::new(); + let l = lower_traversal(&local).expect("lowers"); + assert_eq!(w.count(&l), oracle::count(&w, &local)); +} + +/// A sum of a value reached through an fk: `out('billedTo').values('country') +/// .sum()`. Gap: no fold of a foreign value lane. +#[test] +fn value_through_hop_is_refused() { + let s = [ + V(Table::Line), + Out(Rel::BilledTo), + Values(Field::Country), + Sum, + ]; + assert_eq!( + lower_traversal(&s), + Err(Refusal::ValueThroughHop(Field::Country)) + ); +} + +/// Things that are not population algebra at all. +#[test] +fn frontend_semantics_are_refused_by_name() { + let base = || vec![V(Table::Line), Has(Field::Status, P::Eq(1))]; + let with = |s: Step, t: Step| { + let mut v = base(); + v.push(s); + v.push(t); + v + }; + assert_eq!( + lower_traversal(&with(Limit(10), Emit)), + Err(Refusal::Positional) + ); + assert_eq!( + lower_traversal(&with(Paths, Count)), + Err(Refusal::PathMultiplicity) + ); + assert_eq!( + lower_traversal(&with(SideEffect, Count)), + Err(Refusal::SideEffect) + ); + assert_eq!( + lower_traversal(&with(RepeatTimes(Rel::BilledTo, 2), Count)), + Err(Refusal::Repeat) + ); + // After dedup, the population is a set: no further hop, no per-group fold. + let after_set = [ + V(Table::Doc), + Out(Rel::TradesWith), + Dedup, + In(Rel::TradesWith), + Count, + ]; + assert_eq!( + lower_traversal(&after_set), + Err(Refusal::ConsumesSetPopulation) + ); + let set_fold = [ + V(Table::Doc), + Out(Rel::TradesWith), + Dedup, + GroupCountBy(Field::Country), + ]; + assert_eq!(lower_traversal(&set_fold), Err(Refusal::FoldOverSet)); + // Silence: the plain prefix lowers. + assert!(lower_traversal(&[V(Table::Line), Has(Field::Status, P::Eq(1)), Count]).is_ok()); +} + +/// The adapter tax: building and lowering the traversal against executing +/// it. Printed, not asserted (timing is machine-dependent). +#[test] +fn adapter_tax_is_printed() { + let w = World::new(); + const N: u32 = 2_000; + let t0 = Instant::now(); + let mut l = None; + for _ in 0..N { + l = Some(lower_traversal(std::hint::black_box(&DOC_T1_COUNTRY3)).expect("lowers")); + } + let adapter = t0.elapsed() / N; + let l = l.expect("lowered"); + let t1 = Instant::now(); + for _ in 0..N { + std::hint::black_box(lower(&l.query).expect("lowers")); + } + let quack = t1.elapsed() / N; + let t2 = Instant::now(); + let mut c = 0; + for _ in 0..N / 10 { + c = w.count(&l); + } + let exec = t2.elapsed() / (N / 10); + eprintln!( + "METRIC gremlin_parity adapter_ns={} quack_lower_ns={} exec_ns={} rows={} count={c} \ + materialized_rows=0", + adapter.as_nanos(), + quack.as_nanos(), + exec.as_nanos(), + LINE_ROWS + ); +}