Repository navigation
Conversation
The query shapes engine v2's planner refuses by design now carry the same diagnostic contract as parse and type refusals. The ranking of a binding a traversal reaches gets code P001, stage `plan`, the refused call as the expression, and a fix that answers the query: declare that binding first, so the ranking starts the traversal. The edge-selection refusals added with edge alternation and wildcard traversal get P002 (no finite traversal work limit), P003 (the limit out of range or recorded twice) and P004 (CSR traversal mode), with fixes where one exists. `PlanError::Unsupported` holds the diagnostic and `PlanError::refused` builds it; the gate's `Unrouted::UnsupportedQuery` carries it, and `plan_source` returns it as `OmniError::Compiler` on the ordinary and the inspected door, so the server answers 400 with the `diagnostic` object and the CLI prints the code, stage and fix. The diagnostic kind `plan` renders its one-line text as `plan error: P001: …`. Fixes ModernRelay#786.
Before the planning that runs, `load_column_statistics` resolves and rewrites the query once to find the tables whose column statistics the cost model needs. It mapped every planning error through `no_plan`, so a refusal by design raised while resolving, such as an edge wildcard under CSR traversal mode (P004), came back as an internal error carrying the diagnostic only as text. It now maps a refusal through the same conversion the gate's result uses, and leaves `no_plan` for defects.
One validator in omnigraph-planner (validate/) constructs the only executable plan type, AcceptedPlan, whose fields are private. Ordinary execution, the explain document, the explain statement and the inspected run call the same accept_query path; explain renders the accepted plan and reports validation.scope. The checked declaration and its type context travel beside the IR (CompiledQuery), and the requirements are derived from them: binding identity, search identity and approximation, retained eligibility predicates, correlated blocks, the projection and score origin, order and cut, and the declared candidate and probe caps. A fresh plan failing a check is a planner defect; an exhausted validation limit is a resource outcome. Replay decodes an envelope (query source and name, scope, schema digest, bound plan) within a byte limit, checks its versions first, refuses a changed schema as incompatible facts, and accepts the plan again against requirements derived afresh. The implicit tie-break now orders declared bindings before the bindings the lowering makes up (anonymous endpoints, cycle temps), as RFC 0047's comparator requires; the validator rejected the old order.
A node's declared ordering is typed order keys (column, expression with direction and null placement, identity, fused rank), derived by one rule from the operator and its inputs: a sort establishes exactly its comparator, identity keys included, and a filter keeps its input's order. The bound plan format carries them (version 2). Acceptance recomputes the root's order from the operators rather than reading declared properties, and each check has a test that breaks the plan it guards.
…coverage Lance scores fragments no full-text segment covers flat, from statistics over the rows its prefilter admits, so a filter applied before scoring changes those rows' BM25 scores. The plan source reads the ranked property's full-text coverage at the pinned dataset version (full, partial or absent), the plan records it in its assumptions, and each ranked scan declares where it applies its eligibility: a bm25 scan before scoring only under full coverage, else after, as Lance's postfilter. Acceptance refuses a bm25 scan that filters before scoring without recorded full coverage. GQT gains the claim `ranked bm25 eligibility <placement>`.
A query of one binding with Boolean, integer and String eligibility, an optional leading bm25() over a String property, direct property sort keys, a limit and a projection of properties and the selected score is a member of the exact fragment. The optimizer records the rules it applies to such a chain (absorb a conjunct into the scan, prune the scan's columns, lower each node, rank the scan by bm25 with its eligibility placement); the validator checks that the IR is the declaration's lowering, rebuilds the canonical chain, re-applies every rule against its precondition and requires the result to equal the plan. A member is accepted as exact_subset, everything else as invariants_only. Replay envelopes carry the derivation, and replay checks it again.
A nearest scan declares its probe policy (the escalation factor, the flat rescan of rows the partition search did not reach, the uncapped fail-closed rescan when Lance's counters are missing, and the flat start when the eligible set fits within the fetch); every pre-pass declares what an empty eligible set decides and whether the recorded full-text coverage of the bm25 scans it feeds admits it. The scan's ladder and both gates read these from the plan instead of their own constants, and the rrf gate's coverage guard is the recorded fact, no longer a dataset read at run time. Acceptance checks that every ladder terminates and keeps its fallbacks, and that every pre-pass draws its eligible set from required first hops and feeds only what its kind may prefilter. The execution report records each gate's verdict with the counts it read and each probe attempt per rung, and a replay must record the same decisions.
Bytes over the evidence limit exhaust before anything is decoded, every envelope version is read before the body, and the replay door answers an envelope of another version as a conflict that asks for the query again.
…ention A table of admitted and excluded forms pins exact-fragment membership; the plain and explained acceptance entries agree on plan, scope and an exhausted limit; an instrument prints derivation bytes, steps, nodes, visits and time as a member query grows. Predicate retention resumes after its last match, so in-order placement checks in linear work, and a derivation charges the conjuncts each absorption copies.
A prefilter changes the BM25 score of a row no full-text segment covers, and a postfilter keeps the whole corpus's score; the eligibility placement of a bm25 scan rests on both.
… built The execution guide describes plan acceptance, the exact fragment and the replay envelope; the explain guide documents validation, a bm25 scan's eligibility placement, a nearest scan's policy, the recorded full-text coverage and the profile's search decision rows, which the profile now carries; the testing map names the validator's owners; the Lance guide adds the eligibility placement fence; the GQT README adds the scope and eligibility claims. RFC 0047 records where the built design differs from its text, and three release notes describe the user-visible changes.
…verage An order key the planner binds to a return item by equal lowering is accepted when the written key lowers to that item, though the two are written differently (`"a" in $d.tags` and `$d.tags contains "a"`), and a tie-break omission whose returned items all lower to order keys is accepted as an equivalence. Before this a legal query failed as a planner defect. The replay door re-establishes every full-text coverage fact a plan records from the pinned snapshot before acceptance: an envelope claiming full coverage of a partially indexed property, with its bm25 scan and derivation switched to filter before scoring, was accepted and would have changed BM25 scores. Both found in a Codex review.
…r probes
Two defects a Codex review found in plan acceptance and the run report.
An order key that is constant for every row (a parameter, `now()`, a
single edge type's `@type`) orders nothing, and the order check discounts
such keys on both sides. When `return` projects the constant under an
alias, the planner binds the key to that alias, and the check compared an
unresolved alias on one side with a discounted constant on the other, so
`return { $x as x } order { $x }` failed acceptance as a planner defect.
Both sides now resolve an alias to the `return` item it names before
discounting constants.
A fusion recorded its gate decision and returned without its nearest arms'
probe attempts, so inspection and replay could not see them, and every
nearest scan wrote one shared report, so two nearest arms would overwrite
each other. Each nearest scan now reports under its own plan node; the
standalone search reads its scan's entry and a fusion records each arm's
attempts after its gate.
Regressions: cases/v2/constant_order_key_bound_to_a_return_alias.gqt,
validate/tests.rs::a_constant_key_bound_to_a_return_alias_is_accepted, and
engine_v2_plan_replay.rs::a_fusion_replays, extended with a two-arm
rrf(nearest, nearest).
# Conflicts: # crates/omnigraph/src/engine/mod.rs
ragnorc
left a comment
There was a problem hiding this comment.
Recommendation: request changes before merge. I found two reproducible gaps in the new acceptance checks. This is a COMMENT review because the authenticated account is also the PR author.
This PR adds a check between a user's query and the plan that runs it. It aims to catch translation errors before they produce wrong answers. Ordinary queries, explain, and saved-plan replay use the same acceptance path. A small set of single-table queries gets checked rewrite evidence. Other queries get narrower checks, reported as invariants_only. The PR also keeps BM25 scores independent of filters on unindexed rows and puts named bindings first in implicit tie breaks.
Two findings need changes:
- P2 — An unwritten row limit passes acceptance. A saved
count_peopleplan with an addedLimit(0)replays successfully. It returns zero rows instead of the required count row. The inline comment identifies the missing check. - P2 — The checker retains quadratic copies of predicate data. A 526,559-byte query retained 68,717,622 bytes of string literals in its derivation arena. Acceptance still succeeded within every default counter. The inline comment includes the fixture and a control.
The design has a sound starting point: an accepted wrapper closes the execution boundary, and requirements come from the checked declaration. That can catch an error shared by later planning stages. It adds protection by construction. The BM25 change also addresses a cause. In pinned Lance 11, fts_search_source uses an empty filter plan for postfilter search. plan_flat_match_query otherwise filters input before flat scoring. The new Lance surface guard passed locally. See Lance scanner and OmniGraph's scan adapter.
The concrete tradeoffs are:
- Assurance costs planning work on every read. Compiled queries retain the checked declaration, but acceptance and derivation reconstruction still run per request. This matters most for frequent small reads. I did not measure production latency or throughput.
- Stable BM25 scores can require more work. Appends leave rows outside index coverage. Postfiltering scores those rows before removing ineligible results, and loses scalar-index filtering at that stage. This favors correct scores over selective-read speed. Full coverage permits prefiltering again.
- Replay favors a fixed contract over portability. Schema and dataset checks reject stale evidence. Format, rule, and semantics versions require future compatibility decisions. This is a hidden Rust replay API, not a new HTTP endpoint.
- The stronger guarantee has a narrow scope. Traversal, aggregation, ANN, and RRF remain outside the exact subset. Fusion and search-aggregate ordering checks are explicitly deferred in RFC 0047. These limits must remain visible when more rules land.
My liability assessment is mixed. The shared acceptance path, typed ordering, and plan-owned search policy remove places where execution and explain can disagree. But this change adds 7,719 lines and removes 627 across 62 files. It adds another account of lowering semantics, a rule catalogue, retained evidence, and versioned replay obligations. The two findings show real costs of that new responsibility. Five similar extensions should compose through the same small rule set and bounded evidence store. Separate copies of each query shape's semantics would increase long-term liability. Fixing the retained-node issue with existing arena slots would reduce that liability without another abstraction.
Validation on ec8b43a25b7a14ea78312693182413d49cbca19b:
- Local clean baseline: planner suite, 121 passed and one ignored instrument.
- Local engine baseline:
engine_v2,engine_v2_plan_replay,engine_v2_scrubbed_replay, andlance_surface_guards: 103 passed and two ignored tests. - Temporary assertions in existing owners reproduced the accepted extra cut at both planner and replay boundaries. A focused rejection check made the replay assertion pass.
- The existing derivation instrument measured retained string data. Releasing obsolete arena nodes reduced it to 524,562 bytes for the same query. This is retained payload, not a process-RSS measurement.
- Exact-head CI reports successful Test Workspace and GQ Logic Tests. I did not rerun the full workspace or cloud suites locally.
I restored all temporary source edits. The restored planner suite passed all 121 tests, and the restored replay owner passed all 24 tests. The checkout is clean. The review leaves the PR unchanged.
| let mut id = plan.root(); | ||
| if let Some(limit) = self.limit { | ||
| let rows = usize::try_from(limit).unwrap_or(usize::MAX); | ||
| match plan.node(id) { | ||
| Some(PhysicalNode::Limit { input, rows: cut }) if *cut == rows => id = *input, |
There was a problem hiding this comment.
[P2] Reject row cuts that the query does not require
This branch checks a limit only when the query declares one. For an unordered invariants_only query without a limit, an added PhysicalNode::Limit passes acceptance. I reproduced this with the existing count_people replay fixture: add Limit { input: old_root, rows: 0 }, copy the root properties, and select the new root. replay_bound_plan succeeds with zero rows. The original query returns one count row. A planner assertion using a contains predicate also confirms acceptance. This violates the stated row-cut check, even within the narrower validation scope. Reject cuts that the query scope does not justify, and check their placement. Please extend the existing row-cut and replay owners. A temporary check rejecting a limit when the query has none made the replay regression pass. This is a validation/replay gap, not evidence that the current optimizer emits this plan.
There was a problem hiding this comment.
Done in 9f37eca. A read plan now carries exactly the query's row cut: one Limit at the root when the query writes limit, and no Limit or Page anywhere when it does not, a correlated block's tree included. validate/tests.rs::an_unwritten_row_cut_fails_the_row_cut covers an added cut over a query without limit and an extra cut below a written one, and engine_v2_plan_replay.rs::an_unwritten_row_cut_refuses_the_replay_of_a_count is your count_people case: the saved plan with an added Limit(0) is now refused instead of replaying as no rows.
| Some(node) => { | ||
| arena.nodes.push(Some(node)); | ||
| arena.current.insert(role, index); |
There was a problem hiding this comment.
[P2] Release replaced derivation nodes before retaining successors
Each AbsorbScanFilter clones the remaining filter and the growing scan filter. These lines retain both predecessors after replacing their current references. For N conjuncts, the arena therefore retains O(N²) predicate data. Arena::node already refuses references to these obsolete nodes, so they cannot support a later rule. I extended the existing derivation instrument with 128 distinct $d.title != "<index><4096 x characters>" clauses. The 526,559-byte query passes acceptance with 261 charged nodes, 133 steps, and 17,832 visits. Yet its arena retains 68,717,622 bytes of literal strings alone. The serialized derivation is only 553,713 bytes. These copies occur before the execution memory pool exists, on every ordinary query acceptance. The server's 1 MiB request limit admits this query size. A control that clears replaced arena slots reduces retained literals to 524,562 bytes. Release those nodes or share immutable expressions, and charge retained bytes before allocation. The current counters do not provide the stated evidence-memory bound.
There was a problem hiding this comment.
Done in 9f37eca. A replaced node is now released before its successor is stored, so the arena keeps only each role's current node and its expressions partition the query's own. validate/tests.rs::the_derivation_arena_keeps_only_current_nodes absorbs 64 conjuncts one at a time and requires the arena to end with at most six nodes; it fails without the release. I did not add a separate retained-bytes counter: with the release, retained data is linear in the compiled query (bounded by the request limits), the node limit bounds slots and the visit charge bounds the copying work. RFC 0047's statement that the byte and node limits bound evidence memory is replaced by that bound, with a decision-log entry, and execution.md states it.
P001's fix, declare the ranked binding first, cannot answer an rrf() whose arms rank two bindings one traversal connects: the binding declared first starts the traversal and the other is reached, so following the fix only produced the opposite hint. Such a fusion is now refused before either arm is ranked, as P005, which names both bindings and the fused call and offers no fix. The reorder hint stays for a single ranked binding. The issue-786 owner test now runs the fused shape in both declaration orders and requires the same fixless refusal.
Two review findings on plan acceptance. The row-cut check compared a written `limit` with the root's cut but let any cut through when the query wrote none: a saved count plan with an added Limit(0) replayed as no rows. A read plan now carries exactly the query's cut, one at the root when it writes `limit` and none anywhere when it does not, a correlated block's tree included. The derivation arena stored each rule's successors and kept the nodes they replaced, so absorbing N conjuncts one at a time retained O(N^2) predicate data while every counter passed; a 526 KB query held 68 MB of literals. A replaced node is unreachable already (a rule naming it is refused), so it is now released before its successor is stored, and the arena keeps only each role's current node. RFC 0047's claim that the byte and node limits bound evidence memory is replaced by that linear bound. Regressions: validate/tests.rs::an_unwritten_row_cut_fails_the_row_cut, validate/tests.rs::the_derivation_arena_keeps_only_current_nodes, and engine_v2_plan_replay.rs::an_unwritten_row_cut_refuses_the_replay_of_a_count.
ragnorc
left a comment
There was a problem hiding this comment.
Recommendation: request changes for the remaining P2 row-cut gap in the inline comment. The memory finding is fixed. I reviewed exact head 9f37eca717a122882b842630583f524594a65f9d, including the changes since my review of ec8b43a2.
This PR checks that a physical read plan matches the user's checked query before execution. A small set of single-table queries gets checked rewrite evidence. Other queries get narrower checks, reported as invariants_only. The follow-up addresses two failures in that protection: saved plans could add a row limit, and the evidence checker kept obsolete copies of predicates. It now counts explicit limits and releases replaced arena nodes. The memory change works. The row-cut change still misses limits stored in sort nodes.
The remaining defect has an observable result. The existing liked traversal query returns three rows. A saved plan with an extra Sort { fetch: Some(0) } below its final sort passes acceptance and returns zero rows. The new check rejects equivalent Limit and Page nodes, including those inside correlated blocks. It must also check capped sorts. I did not observe the optimizer generate this bad shape. This is a gap in the acceptance and replay contract.
The arena change fixes the memory cause. Old references were already invalid. Clearing their slots now removes the data that those references could no longer use. With 128 large predicates, the same 526,559-byte query retains 524,562 bytes of literal strings, compared with 68,717,622 without the release. The arena holds four live nodes. Measurements at 32 and 64 predicates also follow linear retained growth.
For frequent small reads, acceptance adds work before useful execution. For larger generated predicates, retained copies and concurrent requests become more important. The check runs in the shared engine, so embedded, file, S3, and Azure use need the same contract. These fixes change planner bookkeeping. They need no new storage mechanism.
The tradeoffs and liability are explicit:
- The memory fix reduces retained data by design. It keeps existing slots, role ownership, and stale-reference checks. It adds no cache, memory manager, or production API.
- Each absorption still copies expressions. Linear retained memory does not imply linear total copying work. The measurements cover literal payload, not peak RSS or production latency. The corrected RFC makes the bound relative to compiled query size.
- The row-cut check adds a charged walk over plan nodes. Its cost follows plan size, not stored row count. Strict placement limits future optimizer freedom until a new rewrite has appropriate evidence.
- The corrective commit adds 147 net lines, mostly tests and documentation. It removes substantial memory liability despite the positive line count. The remaining liability is incomplete coverage of row-reducing fields. Five similar extensions should share the same acceptance rules and arena ownership rules, with one regression owner per contract.
Validation:
- Clean local baseline: 123 planner tests passed, with one ignored instrument. The four engine targets passed 104 tests, with two ignored tests. These include replay, scrubbed replay, and the Lance surface guards.
- Temporary probes confirmed that extra
LimitandPagenodes fail both at the root and inside a correlated block. The capped-sort replay returned the wrong rows. A focused rejection control refused that replay. - Disabling the arena release reproduced the 68,717,622-byte retained payload and failed the new retained-node regression. I restored all temporary edits. The restored planner and replay suites passed, and the checkout is clean.
- Formatting, AGENTS links, and documentation checks passed locally. Workspace CI passed on merge commit
13154c7f7dea2087c508cafaccf137a552e21afd, which includes this head. Its log records all three new regressions as passed. - The complete upstream search documentation and pinned Lance 11 implementation remain consistent with the earlier review. The dependency pin and scan adapter did not change. The reproduced cut occurs in OmniGraph's own sort operator.
The merge CI documentation check still fails because its release-note snapshot is stale. The exact-head documentation check passes locally. Refresh that merge snapshot before merge. I did not rerun the full workspace, HTTP, or cloud suites locally, or measure production performance.
| let cuts = plan | ||
| .live() | ||
| .filter(|(_, node)| { | ||
| matches!(node, PhysicalNode::Limit { .. } | PhysicalNode::Page { .. }) |
There was a problem hiding this comment.
[P2] Include capped sorts in the row-cut check
This count misses PhysicalNode::Sort { fetch: Some(_), .. }. The later check compares only the final sort's fetch, so another capped sort below it can still remove required rows.
I reproduced this in the existing replay owner with PEOPLE_QUERIES::liked. The query traverses Likes, orders by person and document, and has no limit. It returns three rows. Insert a sort with fetch: Some(0) beneath its existing final sort, preserve the input properties, and replay the envelope. Acceptance succeeds under invariants_only, and replay returns zero rows. A temporary control that rejects capped sorts when the query has no limit makes the same replay fail with row cut.
Please validate every capped sort and its position, including additional sorts below a legitimate final sort. Keep the final sort's valid top-k optimization when the query requires it. Extend the existing planner and replay regressions with this shape. This is an acceptance/replay defect, not evidence that the current optimizer generates the bad plan.
There was a problem hiding this comment.
Fixed in 3426d7c, by design rather than by adding Sort to the count.
PhysicalNode::row_cut() now names each node's count cut in one match over every variant: a Limit's or Page's rows, a Sort's fetch, a RankFuse's limit, and a ranked scan's candidate cap. A node kind that gains a cut cannot compile without declaring it there.
check_row_cuts runs right after the search check. It walks every live node and accepts a declared cut only where the query requires it:
- the root
Limitat the query'slimit; - the final sort's
fetchat the query'slimit(the final sort is the one below the rootLimitand its projections, so its top-k stays); - the fusion at that
limit; - a ranked scan's cap, which
check_accessalready matches to its declared policy.
It refuses every other cut as row cut, including a capped sort below the final one and any cut in a correlated block's tree. It replaces the earlier Limit/Page count.
Regressions:
validate/tests.rs::an_unwritten_row_cut_fails_the_row_cutnow also splices aSort { fetch: Some(0) }below the final sort of an ordered query and of a limited query. It requires the planned plan to be accepted and the spliced one refused withrow cut.engine_v2_plan_replay.rs::an_unwritten_row_cut_refuses_the_replaycovers yourlikedshape (three rows, capped sort below the final sort) beside the count's rootLimit(0), and asserts thatlikedtakes the sort arm.
The planner suite, the replay, engine_v2, search and query owners, the GQT corpus, clippy and docs all pass. RFC 0047's decision log and execution.md now describe declared cuts.
v0.12.0 is tagged and its snapshot generated, but release.json still named it, so the documentation check compared every note added since with that published snapshot and failed. The release procedure's post-publication step moves the configuration to the next version on the published base; this is the same change ModernRelay#871 and ModernRelay#878 carry.
The row-cut check counted `Limit` and `Page` nodes, so a capped `Sort` below the final sort cut rows that acceptance never saw: a saved ordered plan with such a sort replayed as no rows. `PhysicalNode::row_cut` now names each node's count cut in one match over every variant (a `Limit`'s or `Page`'s rows, a `Sort`'s `fetch`, a `RankFuse`'s limit, a ranked scan's candidate cap), so a node kind that gains a cut cannot compile without declaring it. `check_row_cuts` walks every live node and accepts a declared cut only where the query requires it: the root `Limit` and the final sort's `fetch` at the query's `limit`, the fusion at that `limit`, and a ranked scan's cap, which the search check already matches to its declared policy. Every other cut is refused, the final sort keeping its top-k. Regressions: validate/tests.rs::an_unwritten_row_cut_fails_the_row_cut (capped sorts below the final sort of an ordered and of a limited query) and engine_v2_plan_replay.rs::an_unwritten_row_cut_refuses_the_replay (the count's root cut and `liked`'s capped sort).
ragnorc
left a comment
There was a problem hiding this comment.
Recommendation: approve. The remaining P2 capped-sort defect is fixed. I found no new blocking defect. The inline P3 wording note is optional. This is a COMMENT review because the authenticated account is also the PR author.
I reviewed 3426d7cbb8b0eebf86c14ff4b81eb4f7534801d8, including the changes since my previous review.
This PR checks a read plan against the user's query before execution. That protects users from translation errors and invalid saved plans. The earlier check missed limits stored inside sort nodes. A saved traversal plan could pass acceptance, then return no rows instead of three. This fix gives each physical node one place to declare its row cap. The checker then checks the cap's size and position against the query. It rejects the hidden sort and preserves the final sort's valid top-k optimization.
The node classification covers Limit, Page, Sort, RankFuse, and ranked Scan. The shared check replaces the incomplete count of Limit and Page nodes. Existing search checks still own candidate caps. Existing order checks still require the query's root limit. This addresses the cause across node kinds.
For frequent small reads, acceptance costs planning work before useful execution. For larger scans, a misplaced cap can discard required answers. Embedded and server execution need the same contract on file, S3, and Azure storage. This correction changes shared plan acceptance and requires no new storage behavior.
The tradeoffs and liability are:
- Correctness before optimizer freedom. Only justified positions may carry a cap. Future rewrites that move caps need matching evidence and acceptance rules.
- Work follows plan size. The checker replaces a charged node walk with classification and placement checks. It does not scan stored rows. I did not measure production latency or throughput.
- One classification reduces omissions. The fix adds a Rust helper and extends existing regression owners. It adds no durable state, cache, execution primitive, or wire format. The follow-up adds 216 lines and removes 58 across six files. Despite that increase, it reduces maintenance liability by replacing an incomplete rule with a shared classification. Five similar extensions should use this classification and the same acceptance boundary.
- The guarantee remains bounded.
invariants_onlystill provides selected checks, not full plan equivalence. ANN candidate caps retain their declared search semantics. The broader PR's evidence and replay maintenance costs from the earlier review remain.
Validation:
- Exact-head local baseline: 123 planner tests passed, with one ignored instrument.
engine_v2,engine_v2_plan_replay, andsearchpassed all 64 tests. - I temporarily restored only the old validator, keeping the new tests. The replay regression failed because
likedstill accepted the hidden capped sort. The planner regression also failed: its exact-subset check caught the added sort, but the required sharedrow cutcheck did not. - I restored the exact reviewed source. Both focused regressions passed again. The checkout is clean. Formatting, whitespace, AGENTS links, and documentation checks passed locally.
- Workspace CI passed on merge commit
04a0813, which includes this head. Its log records both updated regressions as passed. GQ Logic Tests and Clippy also passed in CI. - I checked the relevant upstream documentation and the pinned Lance 11 scanner. The dependency pin and scan adapter are unchanged. Lance's query limit and nearest-candidate count remain distinct from OmniGraph's local sort cap.
I did not rerun the full workspace or cloud suites locally. The earlier memory fix remains unchanged. This follow-up closes the remaining reproduced acceptance defect.
| impl PhysicalNode { | ||
| /// The most rows this node keeps of its input whatever any predicate | ||
| /// holds: its count cut. Every variant is listed, so a node kind that | ||
| /// gains a cut cannot compile without declaring it here, and plan |
There was a problem hiding this comment.
[P3] Narrow the compile-time guarantee to new variants
Optional: say that adding a new node variant requires an explicit classification here. The exhaustive match gives that guarantee. It does not force a change when an existing variant gains a field: patterns such as Self::Expand { .. } still compile and return None. The same stronger claim appears in RFC 0047's new decision entry. Narrowing the wording keeps future reviewers aware that changes to existing operators still need a cut audit. The current listed cuts are handled correctly.
There was a problem hiding this comment.
Narrowed in d76469b: the doc comment on row_cut() and RFC 0047's decision-log entry now say the exhaustive match forces a classification only for a new node variant, and that a field giving an existing variant a cut still needs its arm audited by hand.
The match forces a classification for a new node variant; a field that gives an existing variant a cut compiles unchanged, so its arm still needs a hand audit. The doc comment and RFC 0047's decision log claimed more.
ragnorc
left a comment
There was a problem hiding this comment.
Recommendation: approve at d76469b511d821648a690da575ef5357ebd5d67d. The optional wording finding is addressed in both places. No new blocking defect or optional change to request. This is a COMMENT review because the authenticated account owns the PR.
This PR checks a read plan against the checked query before execution, including when a saved plan is replayed. Its row-cap check prevents an unjustified limit from silently removing answers. This follow-up corrects what the compiler guarantees when developers extend that check. It changes documentation, not query results.
The helper’s comment and RFC decision entry now match the actual mechanism. The exhaustive match requires an arm for a new PhysicalNode variant. A field added to an existing variant can still match { .. }. A developer must therefore audit that arm when the field adds a row cap. Correcting both claims removes the false assurance at its source.
The shared validator still checks every declared cap against its allowed size and position. The existing planner and replay regressions still cover the hidden capped sort. The Lance 11.0.0 pin, scan adapter, callers, and deployment boundaries are unchanged from the previous review.
Liability is lower: maintainers now see the manual obligation instead of relying on a guarantee Rust does not provide. The change adds no API, state, invariant, abstraction, or test fixture. Five similar operator changes can use the same classification and acceptance check. They still require an audit of any new fields. For ordinary reads and saved-plan replay, the existing correctness-versus-planning-cost tradeoff is unchanged. This revision makes no performance improvement claim.
Validation on the clean isolated head:
- Compared against parent
3426d7cbb8b0eebf86c14ff4b81eb4f7534801d8. Only the two passages above differ.physical.rsis identical after excluding doc-comment lines. All executable code, tests, and dependencies are unchanged. /tmp/review-916-docs/bin/python scripts/check-docs.pypassed for 207 Markdown files, using the existing documentation environment.bash scripts/check-agents-md.sh,cargo +1.97.1 fmt --all --check, andgit diff --check HEAD^passed.
GQT cannot observe a documentation guarantee or inject the malformed saved plan used by the existing replay regression. No new behavioral regression is appropriate for this wording-only revision. The previous review records the runtime defect’s failure proof and passing Rust owners at the parent. Those are retained evidence, not fresh test runs at this head.
The current head reports successful PR Title and Fix Regression Gate checks. I found no fresh workspace or GQT CI result for this head, and did not rerun those suites locally. No source edits were retained. No new storage or performance claim was tested.
RFC 0047 step 2a: every read plan is accepted against its checked query before it runs.
Builds on #815 (step 2, planner refusal diagnostics); its two commits appear here until it merges.
What changes
One acceptance door.
omnigraph-planner/src/validate/constructs the only executable plan type,AcceptedPlan, whose fields are private. The ordinary run,Session::explain_query, theexplainstatement and the inspected run all callaccept_query/accept_query_explained, which share one traced planning run (gate::plan_traced), so explain validates exactly what a run validates.bindtakes anAcceptedPlan;executetakes anAcceptedBoundPlan. A fresh plan failing a check is a planner defect (500); an exhausted validation limit isResourceLimitExceeded(413); a refusal by design keeps its diagnostic (400).Requirements from the checked query, not the IR. The compiled-query cache keeps the
CheckedQuery(declaration and type context) beside the IR. The validator derives binding identity, search identity and approximation, eligibility conjuncts (checked perrrfarm), correlated blocks, the projection and score origin, order and cut, and the declared search policies, and relates written expressions to the IR through the compiler's lowering patterns (constant folding is checked against the engine's own evaluator). Order is recomputed from the operators over typedOrderKeys; a Sort's declared ordering is now its whole comparator.Exact fragment. A single-binding query with Boolean/integer/String eligibility, an optional leading
bm25()over a String property, property sort keys, a limit and a projection of properties and the score is a member. The optimizer records the rules it applies (absorb_scan_filter,prune_scan_columns,lower,rank_bm25_scan); the validator checks the IR is the declaration's lowering, rebuilds the canonical chain, re-applies every rule against its precondition and requires the plan to match. Members are accepted asexact_subset, everything else asinvariants_only;explainreports it asvalidation.scope, and GQT claims it withvalidation scope <scope>.Declared search policy. A
nearestscan declares itsNearestPolicy(probe factor, flat rescan of unreached rows, uncapped fail-closed rescan, flat start); every pre-pass declareson_emptyandcoverage_admits. The ladder and both gates read them from the plan (the rrf gate's coverage guard is now the recorded fact, not a run-time dataset read). The report and the profile record each gate verdict with its counts and each probe attempt per rung.Replay through acceptance.
Session::replay_bound_plantakes a serializedReplayEnvelope(query source and name, scope, schema digest, bound plan, a member's derivation), checks its byte limit and versions before decoding, re-establishes every recorded full-text coverage fact from the pinned snapshot, recompiles the query, and accepts the plan again. Version mismatch, another schema and an unpinned dataset are conflicts (409); an edited plan, derivation or coverage claim is invalid evidence (400).Behavior changes
cases/v2/bm25_score_ignores_a_filter_over_an_unindexed_fragment.gqt(the old prefilter scored the tail row 0.41299206 under a filter, 0.3903352 without), Lance guardfts_prefilter_changes_unindexed_scores_and_postfilter_keeps_them.__anon_*) before named bindings, contrary to RFC 0047's comparator; the validator refused that plan. Regressioncases/v2/anonymous_endpoint_ties_order_by_declared_bindings.gqt.explaingainsvalidation, a bm25 scan'seligibility, a nearest scan'spolicyand the assumptions'full_textcoverage; saved plans move tobound_plan_version2.wildcard_replay_keeps_captured_members_and_pins_every_member_issue_659now asserts that refusal.RFC 0047 amendments
Recorded in the RFC's text and decision log: the canonical form is the planner's resolve order; the derivation stores rule applications, not successors; the rule catalogue as built; BM25 eligibility before scoring only under full coverage; three tie-break omissions as comparator equivalences; correlated blocks required as blocks; evidence memory bounded by byte and node limits (no planning pool exists); the fusion and search-ordered aggregate order checks land with step 5, which implements that order.
Verification
cargo test --workspace --exclude omnigraph-gqt --exclude omnigraph-dst --locked --features omnigraph-engine/failpoints,omnigraph-cluster/failpoints: 122 test targets green (run before the last review fix; the planner suite, the replay owners and clippy pass after it)cargo test -p omnigraph-gqt --locked: 238 of 239 cases pass locally;issue_782_multi_hop_limit_stops_earlyhits its 10 s budget under local machine load (it takes 8.33 s inmain's own CI, so it is near its budget there too)cargo clippy --workspace --all-targets --locked -- -D warnings -W clippy::dbg_macro,cargo fmt --all --check,scripts/check-docs.py,scripts/check-agents-md.sh,typos: cleanvalidate/tests.rs): one broken plan per check, membership over admitted and excluded forms, derivation rejections, entry parity under an exhausted limit;derivation_cost_grows_with_the_query(instrument:) prints evidence growth: 256 conjuncts take about 58 KB, 261 rule applications and 11 ms.cases/v2/order_key_bound_to_an_equally_lowered_return_item.gqt), and a replay trusted a forged full-text coverage claim (a_replay_rechecks_recorded_full_text_coverage). A second review of the fix found nothing.main445f50f) found two more, both fixed with regressions. A constant order key thatreturnprojects under an alias (return { $x as x } order { $x }, alsonow()and a single edge type's grouped@type) failed acceptance as a planner defect, because the planner binds the key to the alias and the order check did not resolve the alias before discounting constants; it now resolves an alias to thereturnitem it names on the written side and on the planned side alike, soorder { x }andorder { $x }are judged the same way (cases/v2/constant_order_key_bound_to_a_return_alias.gqt,validate/tests.rs::a_constant_key_bound_to_a_return_alias_is_accepted). And a fusion returned without recording itsnearestarms' probe attempts, so inspection and replay could not see them; every nearest scan now reports under its own plan node and a fusion records each arm's attempts after its gate (engine_v2_plan_replay.rs::a_fusion_replays, extended with a two-armrrf(nearest, nearest)).