Skip to content

test(reference,gqt): compare query rows with a reference interpreter of the core IR - #1334

Draft
ragnorc wants to merge 16 commits into
ModernRelay:mainfrom
ragnorc:gq3/reference-interpreter
Draft

ragnorc wants to merge 16 commits into
ModernRelay:mainfrom
ragnorc:gq3/reference-interpreter

Conversation

@ragnorc

@ragnorc ragnorc commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

Stacked on #1316. GitHub cannot base a pull request on a branch that exists only on the fork, so this one targets main; its own change is the two commits above #1316's head ae9c2a42, and everything below them is #1316. Review them alone: ragnorc/omnigraph@ae9c2a4...gq3/reference-interpreter. It stays a draft while #1316 and the RFC (#1302) are drafts.

This is step 3 of the GQ 3 programme as the RFC's section 11 now states it: a reference interpreter of the core IR, and GQT's --- expect same as reference.

What changed

crates/omnigraph-reference (test-only, publish = false). Interpreter::new(catalog, graph, params).run(&CoreRead) evaluates a checked read's core IR as the RFC's operator table states it: nested loops, whole-table reads, no index, planner, optimizer or cost model. Graph::from_export reads a branch's JSONL export, the narrowest public read of a whole branch. The crate depends on omnigraph-compiler and serde_json alone; tests/dependencies.rs holds that and refuses a source that names the planner, the engine, Lance or DataFusion.

Each operator, as evaluated:

Operator Evaluation
Scan every node of the type
Expand per row, a breadth-first walk over the selector's members and directions; each node at its shortest distance, kept when it lies in min..=max; set semantics over endpoint pairs; the source sits at distance 0, so a cycle never returns it, and a self-loop edge is the one hop that does; with $k, one row per edge, R + [$y] + [$k]
Filter, Project three-valued predicates; keep appends, otherwise the items alone
Group groups by not-distinct key equality; one row over no keys even when empty; count, sum (exact over integers, rounded to F64 once), avg (F64 accumulation, as the plan's float64 leaf names), min, max, distinct
Correlate, Env the inner term once per outer row, the outer row as its environment
Order, Cut the keys, then the tie rule: every reference column's (type, id) in column order, then every other column's value
Score bm25 over the field corpus (k1 = 1.2, b = 0.75), kept when the field matches, stored at F32; vector distances exact
ApproxTopK exact: every target's squared L2 distance at F32, the k nearest
Fuse Σ wᵢ / (k + rankᵢ) over the arms
Union, Fix, Call concatenation; naive iteration to the least fixpoint under the RFC's set and lattice rules; the declared read with its arguments bound. The planner refuses all three with P006, so they are covered by the crate's unit tests, not by GQT

GQT. --- expect same as reference follows a query step's --- expect shape or --- expect plan, has no body, and adds a comparison to the step's own rows expect: the runner checks the step's query to its core IR, evaluates it over the same branch, and compares rows ordered or unordered as the step's --- expect says; a mismatch prints both row sets. The comparison goes through an ExecutionHost::reference_rows hook, so only the GQT runner depends on the interpreter (the benchmark harness, which shares omnigraph-gqt-core, does not). The section is refused on a mutate step, an error expect, show, branch list and, at run time, a snapshot: target. The served runner refuses it by name, as it refuses --- expect plan. The DST runner skips it, and any other executor refuses the case.

Coverage. The section is restored on the 15 cases that carried the old engine comparison (34 steps) and added to 17 more (153 steps):

bare_node_projection_object_fields, bm25_distinct_scores_rank_descending, integer_sum_and_avg_are_float, issue_659_compiler_scopes, issue_659_edge_selection_semantics, issue_659_member_direction_and_types, issue_659_rrf_typed_edge_identity, issue_659_typed_edge_alternation, issue_723_grouped_transfer_aggregation, issue_858_integer_sum_edge, issue_858_integer_sum_exact_total, nearest_rank_survives_edge_fanout_and_hops, rrf_nearest_and_bm25_agreeing_arms, subquery_column_aggregate_predicate, typed_block_aggregate_contracts, typed_comparison_domains, typed_search_options.

A temporary per-operator counter (not committed) over these 32 cases recorded: Scan 478, Filter 661, Expand 224 single-hop out, 119 against the edge, 54 with an edge variable, 12 multi-hop, 11 alternations, 7 multi-hop alternations, 4 undirected alternations, plus undirected single and edge-variable forms; Project 212 replacing and 188 keeping; Group 287 over no keys and 17 keyed; aggregates count 243 (count(distinct …) included), sum 28, max 25, min 10, avg 8; Correlate 327 with Env 280; Order 41; Cut 41; Score 18; ApproxTopK 11; Fuse 8. Unit appears only as a write group's input, which no read lowers; an edge Scan is P006 in a read.

Docs. The GQT README's new "Reference comparison" section (format, refusals, the interpreter's limits), its served-refusal list, the omnigraph-reference row in docs/dev/testing.md, and AGENTS.md's workspace list plus one bullet where the frozen-v1 bullet was. docs/dev/execution.md does not describe oracles and is unchanged.

How it was tested

  • cargo test -p omnigraph-reference (19 tests: one per operator, Fix and Call included, plus the self-loop rule, the exact integer sum and the tie rule's column order) and the dependency guard.
  • cargo test -p omnigraph-gqt-core (215): the section's placement and refusals, and the ordered and unordered comparison.
  • cargo test -p omnigraph-gqt (the 536-case corpus and the runner tests, including an end-to-end comparison of an edge's @type in every read position).
  • Clippy with -D warnings on the three crates, cargo fmt --check, check-docs.py, check-agents-md.sh, check-dependency-sources.py, typos. cargo deny is left to CI (not installed locally).

Where the engine and the reference disagree today

As an experiment, the section was added to every query step with a rows expect in the whole corpus (not committed). 509 cases agreed. Each disagreement was resolved in the reference where it was the reference's mistake (shortest cycle distance, exact integer sums, F32 storage of F32 and vector values and parameters, a whole float literal cast to an integer domain, vector and Blob fields left out of a bare node's object, now() bound once per read, Expand's column order in the tie rule). What remains, and why no committed case carries the section there:

  • Text analysis. The interpreter splits text into lowercase alphanumeric tokens with no stemming or stop words. A @index(fulltext) written without an analyzer keeps Lance's default (English stemming, stop words, ASCII folding, a 40-byte token filter), so bm25 ties and orders between documents that analysis treats differently (nearest_bm25_and_rrf_rank_the_search_fixture step 2), a projected score's last digits (issue_640_search_score_projection, bm25_score_ignores_a_filter_over_an_unindexed_fragment) and fuzzy (text_search_fuzzy_and_match_text_filters step 3) differ.
  • DateTime offsets. The interpreter compares a DateTime as written; issue_857_datetime_sub_millisecond_digits_refused step 3 equates two spellings with different offsets.
  • System relations. mixed_joins_and_refusals, operators_and_passes and settings_engine_counts_and_filters read engine-provided relations no export contains.
  • Steps whose target is a snapshot or a proposal (unattached_branch_*, unpublished_commit_*, issue_1236_stacked_diff_after_parent_delete), which the reference refuses.

None of these is an engine defect the reference found; the agreement on the rest found none either.

RFC findings

  1. Expand and the start node. Section 3, beside reachable, says Expand never returns its start node. Today the engine returns it through a self-loop edge at hop 1, and issue_613_self_loop_pinned_hop_distance and issue_659_typed_edge_alternation pin that (knows{1,2} from bob returns bob over its own loop). The operator table's Expand row says [as today]; the prose should name the self-loop exception, or the engine should change. The reference follows today.
  2. bm25's type. The operator table types a bm25 score F64; the engine and feat(compiler,planner,engine)!: replace the query language with GQ 3 in one break #1316's checker type $score F32, as several cases' shapes pin. The reference stores it at F32 to compare. One of the two should move.
  3. The undeclared analyzer. The analyzer profiles pin every setting, but an undeclared @index(fulltext) keeps "Lance's default", which the RFC does not pin. A reference cannot model it without sharing Lance's analyzer, which would stop it being independent. Pinning the default as a named profile (or requiring a declared analyzer for bm25 comparisons) would close this.
  4. The tie rule's column order is consequential. Order breaks ties by reference columns in column order, so Expand's output type R + [$y] + [$k] decides a tie between two rows that reach different targets over different edges (issue_659_edge_selection_semantics, named_unprojected_type_order). That matches the engine's sort tiebreak [$a.@id, $b.@id, $e.@id]; it is worth stating in the Expand row that the target column precedes the edge column.

Phase 0 of the GQ 3 RFC (draft): the compiler's half of edition 3. The
one front door, parse_query, reads the edition line: no line or `gq 2;`
is edition 2, unchanged; `gq 3;` selects the edition-3 parser, whose
refusals name the construct and carry byte-range suggestions; any other
line is Q006 with `gq 3;` as the fix.

An edition-3 declaration is typed into the core IR (ir::core3) for every
form of sections 2 to 4 and 7, recursion typed to Fix, and the planner
refuses it with P006 (plan_edition3) at each engine door before a
snapshot is captured. Nothing in edition 3 executes.

Tests: the rule-set pin, the red-team corpus with pinned repaired trees
(4 listed mismatches), the parse-print-parse property (2,000 cases, and
100,000 as a hunt), the desugaring-uniqueness test and the closure
table, the before-and-after asset type-checked, edition-2 texts lowered
to the same IR as before, a zero-I/O engine test, and one GQT case per
code. The GQT runner keeps a step the compiler refuses at parse under
`expect error:` and renders parse diagnostics with their code.
…est to its code

Engine v1, its frozen pin, the session's read-executor seam it ran
through, the compiler's untyped read IR and GQT's `expect same as v1`
section go; every case keeps its pinned rows. The red-team corpus and the
RFC asset copies leave the tree: each distinct refusal shape the corpus
found is a repair row in the parser or checker tests, and the RFC's
examples are type-checked inline. The release note, the release.json
retarget and the user page are withdrawn until the RFC is accepted.
The edition-3 parser, tree, printer, foreign-habit table and checker move
under query/syntax and query/check, the core IR to ir/core.rs and the
planner's refusal to core_plan.rs; edition-2 parsing reads through
syntax/edition_window and the front door stays query/parser.rs. Types and
functions drop their edition suffixes (SourceFile, Expr, check_file,
plan_core, CheckedQuery::Core, QueryDecl::syntax). Tests sit in tests.rs
beside each module, and the diagnostics cases move to cases/diagnostics.
No behaviour changes.
# Conflicts:
#	crates/omnigraph-compiler/src/query/codes.rs
#	crates/omnigraph-compiler/src/query/parser.rs
…it arithmetic

IRExpr, its block predicate and their tests move from ir/mod.rs to
ir/expr.rs. The expression gains the nodes the core IR needs and has no
other spelling for: BinaryOp::Arith over one numeric type, a unary Neg,
and a typed scalar registry Call (if, coalesce, concat, match_terms),
with local type checks, GQ rendering at the right precedence and a
can_fail walk. The bound-plan mirror reads and writes them as new
variants, so every existing plan serializes as before.

The engine evaluates them per batch. An integer overflow, a zero divisor
and a non-finite float refuse the row with execution diagnostics E1, E2
and E3; a null operand gives a null row. Arithmetic is never folded or
pushed into a scan, so its failure keeps its place. Nothing produces
these nodes yet.
The core's terms, aggregates, scores, call bindings and sink assignments
store IRExpr instead of their own scalar type. The checker keeps its
typed working form private (query/check/scalar.rs) and converts it as it
builds each term: a reference column becomes a Variable, a value column
an AliasRef, a property or meta field a PropAccess, membership a
Contains comparison, and the registry rows their own nodes. ExprType
gains Edge so an edge reference column has a type. No behaviour
changes; edition 3 still stops at P006.
GQ 3 replaces the language whole (docs/rfcs/2026-11-07-gq-3.md, sections
6 and 11): one grammar with plain rule names, one type checker, one IR.
The edition line, the old parser, AST, checker and lowering, and the
pipeline IR are gone.

The type checker binds every expression (physical system columns, the
casts `coerce` chooses, folded literals) and infers result schemas from
the core. The planner reads only the core through the read shape, which
lowers the pattern, its projection, grouping, order and cut, and the
`from nearest/bm25/rrf` rankings to today's ranked scans; the engine
lowers write groups to today's staged writes. Any other core node is
P006. Every header consumer checks through the core.

The repository's query text was rewritten by the one-shot rewriter; the
seventeen fusion cases the RFC lists were edited by hand, and the
cross-version journeys keep predecessor syntax for predecessor binaries.
Five diagnostics improve: SQL `union` names the relation function, T74
names the return item, T8 prints the block form, T65 carries an
applicable edit, and type errors carry the position of their clause.
…of the core IR

Add `omnigraph-reference`, a test-only interpreter that evaluates a checked
read's core IR as the RFC's operator table states it: nested loops over a
branch's export, no index, planner, optimizer or cost model. It depends on
the compiler and serde_json alone, which `tests/dependencies.rs` holds. Every
operator the planner lowers has an evaluation and a unit test, and so do
`Union`, `Fix` (naive iteration to the fixpoint) and `Call`, which the
planner refuses with P006.

GQT's `--- expect same as reference` evaluates a query step's read with it
and compares the rows with the engine's, ordered or unordered as the step's
rows expect says. The comparison goes through an `ExecutionHost` hook, so
only the GQT runner depends on the interpreter; the served runner refuses
the section by name, the DST runner skips it, and other executors refuse the
case. The section is restored on the 15 cases that carried the old engine
comparison and added to 17 more, so every lowered operator, aggregate and
traversal form runs against the reference at least once.
@ragnorc ragnorc added the skip-changelog Typed change with nothing users can see; waives the changelog.d note check label Nov 10, 2026

@ragnorc ragnorc left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Why. The GQ 3 break deletes the frozen v1 engine, so GQT needs a new differential oracle. RFC section 11 names it: a reference interpreter of the core IR.

What. This PR adds crates/omnigraph-reference (test only) and the GQT section --- expect same as reference. The section is on 187 steps in 31 cases. The PR's own change is the two commits above ae9c2a42.

How. The interpreter reads a branch export into memory and evaluates each core operator with nested loops. The GQT runner compares its rows with the engine's rows.

Impact. The idea is correct and the boundary is clean: the crate depends on the compiler only, and tests/dependencies.rs holds that. But the oracle has three semantic differences that I reproduced at this head. Each gives a false disagreement or hides a real one. AGENTS.md now says "a disagreement is a bug in one of the two". For that rule to be useful, the oracle must agree with the RFC wherever the RFC is clear.

Recommendation: keep as draft (as the stack already is). Fix B1 and B2, decide D1, and add compared cases for each.

Reviewed head: 863cdc90. Reviewed delta: ae9c2a42...863cdc90.

Blocking

B1. search, match_text and fuzzy require every term. The RFC and the engine require any term.

  • crates/omnigraph-reference/src/lib.rs:1162-1169 uses tokens(&q).iter().all(...). fuzzy at :1171 does the same.
  • RFC section 4 keeps "today's any-term membership". The engine builds FullTextSearchQuery::new(q) (crates/omnigraph/src/engine/scan.rs:951), and Lance's match query defaults to Or.
  • Reproduced with a temporary case: search($c.text, "alpha beta") over "alpha only here" and "alpha beta both". The engine returns both rows (its own expect passes). The reference returns only c1, and the step fails with "the engine and the reference disagree".
  • Fix: use the any-term rule for the predicates. Keep the mode for match_terms and terms(...).

B2. DateTime values are compared as text.

  • lib.rs:100 keeps a DateTime literal or parameter as the string the user wrote. The export writes stored values without a zone (2024-01-01T00:00:00). The engine converts both sides to milliseconds.
  • Reproduced: seed "at":"2024-01-01T00:00:00Z", filter $e.at = datetime("2024-01-01T00:00:00Z"). The engine returns the row. The reference returns no rows. >= and < on the same instant also give the wrong answer, because the shorter string sorts first.
  • The README caveat says only "spellings with different offsets". The actual scope is every literal with Z, which is the usual way to write one.
  • now() (lib.rs:305) is a second clock reading, also spelled with Z. So a projected now() always disagrees.
  • Fix: parse Date/DateTime to epoch days/milliseconds when you read the export, the literals and the parameters. Bind now() to the instant the engine used, or refuse now() in a compared step.

Needs decision

D1. Tie order: the reference follows the RFC, the engine does not.

  • RFC section 2, Order: ties fall to "the identity of every reference column in column order". The reference does this (tie, crates/omnigraph-reference/src/lib.rs:1223).
  • The planner's scope_tiebreaks (crates/omnigraph-planner/src/optimizer.rs:1116-1146) puts the bindings in a BTreeMap, so it sorts them by name, with fresh variables last.
  • Reproduced: match { $z: P $a: P $z knows $a } return { $z.key, $a.key } order { $z.grp } limit 1 with equal grp, edges x1->y2 and x2->y1. The engine returns x2/y1 (tie on $a first). The reference returns x1/y2 (tie on $z first).
  • No compared step in the corpus has a key tie across two bindings, so CI does not see this.
  • Decide which side changes: the engine (in #1316) or the RFC text (in #1302). Then add this case with expect same as reference.

Optional (code reading; the engine cannot run most of these today)

  • S1. F32 arithmetic is computed in f64 (arith, lib.rs:1296). The checker types F32 op F32 as F32, and the engine computes on Float32Array. So $a + $b = $c with 0.1, 0.2, 0.3 is true in the engine and false in the reference. I could not run this: GQT's plan renderer refuses property arithmetic ("expect plan: malformed typed expression tree", crates/omnigraph-gqt-core/src/plan.rs:1023). That comes from #1316, not from this PR.
  • S2. Union keeps the right arm's own column names (lib.rs:695). The checker matches arms by position and takes the names from the left arm. union({return {$q}}, {return {$r}}) gives right rows where q is null. The Fix test does not see this, because it writes $r as q.
  • S3. Call can capture its own arguments (lib.rs:891-905). It writes each argument into the shared parameter map before it evaluates the next argument. Declare f($a, $b) and call f($b, $a): $b receives the caller's $b. RFC section 2 says arguments are never captured.
  • S4. Fix stops quietly after 1,000 rounds (lib.rs:866) and returns a partial result. It also ignores lattice. An oracle should refuse when it runs out of budget. A partial result can match an engine that has the same cap.
  • S5. match_terms never matches. The compiler gives it three arguments (field, text, edit budget). The reference matches only a two-value slice (lib.rs:1149), so the result is always null.
  • S6. Unary minus on I32::MIN gives 2147483648 instead of E1. A float division by zero gives E3 where the engine gives E2. The runner never compares when the engine refuses, so neither is visible now.
  • S7. The analyzer is a second copy. tokens (lib.rs:1386) lowercases alphanumeric runs. The engine's default inverted index also stems, removes stop words and folds ASCII. Stop words change document length and the mean length, so they change the order of rows, not only "a projected score's last digits" as the README says. The compared seeds avoid stop words. Per RFC section 4 the analyzer belongs to the schema. When that lands, call the one shared analyzer instead of keeping tokens.

Runner integration

These parts are correct: a reference refusal fails the step, so an empty reference result cannot pass by accident; mutate, error, show, branch list and snapshot: steps are refused; the served runner refuses the section by name.

Smaller points:

  • reference_rows exports the branch head after the query finished, not the commit the query read. This is safe only because steps run one at a time. Export at the query's graph_commit_id to remove the dependency.
  • The DST runner skips the comparison (!host.active()). A case that runs only under DST would pass the step with no comparison. Today each such case also runs direct. Consider refusing a case whose environments are all DST.
  • The comparison runs only when the engine returns rows. A wrong engine refusal can never be caught. That is a fair scope for now, but say it in the README.
  • The reference's own ordered flag is dropped (.map(|(rows, _)| rows)). Only the step's expect ordered decides. Some steps with order and limit are compared unordered, so only membership is checked there.

Tradeoffs and long-term liability

  • Removes: the frozen v1 engine as the oracle, and its pipeline IR. One oracle that reads the same core IR as the planner is the right owner.
  • Adds: a second copy of value semantics: DateTime parsing, float width, the value order and tie rule, the analyzer and bm25, and the engine's row rendering. B2, S1 and S7 are exactly this copy drifting. After five more changes of this kind (new scalar types, new score kinds, terms modes), each one needs two implementations and a compared case. Nothing makes the compared case required.
  • Proposal: move the value conversions (literal or JSON cell to a typed value, the fixed value order) to one owner in the compiler, and let the engine's literal path and the reference both use it. Then the reference keeps only operator semantics, which is its actual job.
  • Graph::node is a linear search, and Group and the Fix de-duplication are quadratic. That is fine for the corpus, but it will not fit a cases_slow case.
  • Compiler coupling: the match statements over IR variants have no catch-all, which is good. The cast and arith matches on ty do have a wildcard, so a new type passes through without a compile error.

Validation

All local runs are at 863cdc90 (CARGO_TARGET_DIR shared, default features):

  • cargo test -p omnigraph-reference --locked: 19 unit tests and the dependency test pass.
  • cargo test -p omnigraph-gqt --locked: lib 51, gq_logic_tests 536/536, integration 16, runner_dispatch 29, all pass.
  • Four temporary cases (zz_probe_*.gqt, each with expect same as reference, then deleted): search any-term (B1), DateTime Z (B2) and the cross-binding tie (D1) failed only on the reference comparison; each engine expect passed. The F32 case (S1) was blocked by the GQT plan renderer.
  • Exact-head CI at 863cdc90: all required checks pass, including GQT ordinary, served and DST, Test Workspace and clippy.

Limitations: S2 to S7 are from code reading only. This PR fixes no defect, so there is no before/after-fix run. I made no performance claims. The worktree is clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-changelog Typed change with nothing users can see; waives the changelog.d note check

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant