Repository navigation
Conversation
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
left a comment
There was a problem hiding this comment.
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-1169usestokens(&q).iter().all(...).fuzzyat:1171does 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 toOr. - Reproduced with a temporary case:
search($c.text, "alpha beta")over"alpha only here"and"alpha beta both". The engine returns both rows (its ownexpectpasses). The reference returns onlyc1, 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_termsandterms(...).
B2. DateTime values are compared as text.
lib.rs:100keeps aDateTimeliteral 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 withZ. So a projectednow()always disagrees.- Fix: parse
Date/DateTimeto epoch days/milliseconds when you read the export, the literals and the parameters. Bindnow()to the instant the engine used, or refusenow()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 aBTreeMap, 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 1with equalgrp, edgesx1->y2andx2->y1. The engine returnsx2/y1(tie on$afirst). The reference returnsx1/y2(tie on$zfirst). - 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.
F32arithmetic is computed inf64(arith,lib.rs:1296). The checker typesF32 op F32asF32, and the engine computes onFloat32Array. So$a + $b = $cwith 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.
Unionkeeps 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 whereqis null. TheFixtest does not see this, because it writes$r as q. - S3.
Callcan capture its own arguments (lib.rs:891-905). It writes each argument into the shared parameter map before it evaluates the next argument. Declaref($a, $b)and callf($b, $a):$breceives the caller's$b. RFC section 2 says arguments are never captured. - S4.
Fixstops quietly after 1,000 rounds (lib.rs:866) and returns a partial result. It also ignoreslattice. An oracle should refuse when it runs out of budget. A partial result can match an engine that has the same cap. - S5.
match_termsnever 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::MINgives 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 keepingtokens.
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_rowsexports 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'sgraph_commit_idto 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
orderedflag is dropped (.map(|(rows, _)| rows)). Only the step'sexpect ordereddecides. Some steps withorderandlimitare 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,
termsmodes), 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::nodeis a linear search, andGroupand theFixde-duplication are quadratic. That is fine for the corpus, but it will not fit acases_slowcase.- Compiler coupling: the
matchstatements over IR variants have no catch-all, which is good. Thecastandarithmatches ontydo 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_tests536/536, integration 16,runner_dispatch29, all pass.- Four temporary cases (
zz_probe_*.gqt, each withexpect same as reference, then deleted):searchany-term (B1), DateTimeZ(B2) and the cross-binding tie (D1) failed only on the reference comparison; each engineexpectpassed. 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.
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 headae9c2a42, 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_exportreads a branch's JSONL export, the narrowest public read of a whole branch. The crate depends onomnigraph-compilerandserde_jsonalone;tests/dependencies.rsholds that and refuses a source that names the planner, the engine, Lance or DataFusion.Each operator, as evaluated:
ScanExpandmin..=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,Projectkeepappends, otherwise the items aloneGroupcount,sum(exact over integers, rounded toF64once),avg(F64accumulation, as the plan'sfloat64leaf names),min,max,distinctCorrelate,EnvOrder,CutScorebm25over the field corpus (k1 = 1.2, b = 0.75), kept when the field matches, stored atF32; vector distances exactApproxTopKF32, theknearestFuseUnion,Fix,CallP006, so they are covered by the crate's unit tests, not by GQTGQT.
--- expect same as referencefollows a query step's--- expect shapeor--- 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--- expectsays; a mismatch prints both row sets. The comparison goes through anExecutionHost::reference_rowshook, so only the GQT runner depends on the interpreter (the benchmark harness, which sharesomnigraph-gqt-core, does not). The section is refused on a mutate step, an error expect,show,branch listand, at run time, asnapshot: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:
Scan478,Filter661,Expand224 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;Project212 replacing and 188 keeping;Group287 over no keys and 17 keyed; aggregatescount243 (count(distinct …)included),sum28,max25,min10,avg8;Correlate327 withEnv280;Order41;Cut41;Score18;ApproxTopK11;Fuse8.Unitappears only as a write group's input, which no read lowers; an edgeScanisP006in a read.Docs. The GQT README's new "Reference comparison" section (format, refusals, the interpreter's limits), its served-refusal list, the
omnigraph-referencerow indocs/dev/testing.md, and AGENTS.md's workspace list plus one bullet where the frozen-v1 bullet was.docs/dev/execution.mddoes not describe oracles and is unchanged.How it was tested
cargo test -p omnigraph-reference(19 tests: one per operator,FixandCallincluded, 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@typein every read position).-D warningson the three crates,cargo fmt --check,check-docs.py,check-agents-md.sh,check-dependency-sources.py,typos.cargo denyis 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,
F32storage ofF32and 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:@index(fulltext)written without an analyzer keeps Lance's default (English stemming, stop words, ASCII folding, a 40-byte token filter), sobm25ties and orders between documents that analysis treats differently (nearest_bm25_and_rrf_rank_the_search_fixturestep 2), a projected score's last digits (issue_640_search_score_projection,bm25_score_ignores_a_filter_over_an_unindexed_fragment) andfuzzy(text_search_fuzzy_and_match_text_filtersstep 3) differ.DateTimeoffsets. The interpreter compares aDateTimeas written;issue_857_datetime_sub_millisecond_digits_refusedstep 3 equates two spellings with different offsets.mixed_joins_and_refusals,operators_and_passesandsettings_engine_counts_and_filtersread engine-provided relations no export contains.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
Expandand the start node. Section 3, besidereachable, saysExpandnever returns its start node. Today the engine returns it through a self-loop edge at hop 1, andissue_613_self_loop_pinned_hop_distanceandissue_659_typed_edge_alternationpin that (knows{1,2}frombobreturnsbobover its own loop). The operator table'sExpandrow says[as today]; the prose should name the self-loop exception, or the engine should change. The reference follows today.bm25's type. The operator table types abm25scoreF64; the engine and feat(compiler,planner,engine)!: replace the query language with GQ 3 in one break #1316's checker type$scoreF32, as several cases' shapes pin. The reference stores it atF32to compare. One of the two should move.@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 forbm25comparisons) would close this.Orderbreaks ties by reference columns in column order, soExpand's output typeR + [$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'ssort tiebreak [$a.@id, $b.@id, $e.@id]; it is worth stating in theExpandrow that the target column precedes the edge column.