Skip to content

perf(engine): indexed top-k ORDER BY for star BGPs, with #6465 regressions fixed (carries #5983) - #6660

Merged
jeswr merged 16 commits into
mainfrom
fix/indexed-topk-5983
Oct 5, 2026
Merged

jeswr merged 16 commits into
mainfrom
fix/indexed-topk-5983

Conversation

@jeswr

@jeswr jeswr commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Requested by Jesse · project thread

Before: #5983, from @KamiQuasi (Luke Dary), adds an indexed top-k path for star-shaped ORDER BY … LIMIT queries, a txn retry helper, and a PreparedGraphApplier for claim-queue workloads. #6465 reported two regressions in it:

  • SELECT ?x WHERE { ?x <urn:p> ?x } ORDER BY ?x LIMIT 1 returned a row it must not.
  • A query under max_rows succeeded instead of raising the intermediate-row limit error.

After: this PR carries #5983's commits unchanged, merged with current main, plus one fix commit. In that commit the indexed path declines in four more cases, which then use the existing evaluator:

  • a pattern repeats a variable;
  • a query budget is active;
  • a probe is multi-valued;
  • the leading tie group is larger than the existing block cap.

It also prepares later probes only when a candidate reaches them, and applies the cardinality admission rule per probe. The fix is recovered from the maintainer-side review branches codex/perf-indexed-topk-recovery and codex/perf-topk-lazy-probes; the harness-only bench/indexed-topk from those branches is left out.

Closes #6465. Supersedes #5983: the bot can't push to the contributor's fork, so the commits are carried here with their authorship intact.

How. The fix commit changes try_topk_orderby_indexed in crates/sparq-engine/src/exec.rs and extends tests/topk_orderby_indexed_differential.rs.

Verified locally:

  • The top-k differential passes (31 tests).
  • topk_orderby and orderby_limit_zero pass.
  • The new preparation unit tests pass.
  • The txn lib tests pass, as do the sparq-serve --features params tests.
  • Clippy with -D warnings is clean, default and with txn,params.
  • scripts/preflight.py passes.

The PR makes no performance claim. Because the path now declines under any active budget, it does not fire on the HTTP server's deadline-budgeted queries.

Benchmark

Local sparq-cli bench, operators suite (2,000 entities, 16k triples), release-fast binaries of main 03775daee2 and this PR. Best of 5 iterations per round, minimum over 5 interleaved rounds; row counts match on every query. The indexed top-k path takes q17_orderby_limit_offset from 300 µs to 19 µs, about 16x faster. Every other query is within noise, and the geomean is 0.897.

query main (µs) PR (µs) PR/main
q01_bgp 3.2 3.1 0.969
q02_star3 10.0 10.1 1.010
q03_chain 272.9 265.7 0.974
q04_triangle 4724.1 4674.9 0.990
q05_union 6.6 6.6 1.000
q06_optional 55.4 56.1 1.013
q07_optional_notbound 31.5 31.3 0.994
q08_minus 15.2 15.2 1.000
q09_filter_numeric 186.5 185.4 0.994
q10_filter_string 876.4 880.4 1.005
q11_filter_in 279.5 289.7 1.036
q12_filter_exists 492.3 495.5 1.007
q13_bind 891.7 896.2 1.005
q14_values 9.9 9.8 0.990
q15_agg_group_having 338.2 332.7 0.984
q16_distinct 9.8 9.8 1.000
q17_orderby_limit_offset 300.2 18.9 0.063
q18_path_plus 682.3 689.6 1.011
q19_path_star 1122.7 1114.5 0.993
q20_path_opt 7.0 6.9 0.986
q21_path_seq 10.1 9.7 0.960
q22_path_alt 6.3 6.1 0.968
q23_path_inverse 7.1 6.6 0.930
q24_path_negated_pset 6.9 6.4 0.928
q25_subquery 456.6 457.2 1.001
q26_ask 79.4 80.3 1.011
q27_construct 14.4 14.1 0.979
q28_describe 6.0 6.0 1.000
geomean 0.897

🤖 Generated with Claude Code

https://claude.ai/code/session_01C1gMf8RJim1LzuKapZNpZR


Generated by Claude Code


Generated by Claude Code

KamiQuasi and others added 13 commits August 19, 2026 17:32
…ted backoff to txn

The opt-in txn feature's first-committer-wins OCC has no retry helper today,
so every caller writes its own retry loop. Add execute_txn_with_retry (owns
the begin/update/commit lifecycle, retries only CommitError::Conflict, never
an update error) and decorrelated_jitter_backoff (dependency-free, keeps the
feature's zero-new-deps contract). Motivated by the Artifact Keeper datastore
migration spike's job-queue coordination pattern (claim one row per txn;
jittered backoff decorrelates concurrent retries on a hot row).

Updates skills/sparql-query/SKILL.md per the maintenance rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…+LIMIT

try_topk_orderby's bounded-heap optimization still fully materialises the
inner pattern via eval_modified before selecting the top-k rows -- O(n) in
the number of rows currently matching the BGP's equality filters, not in
the row budget. That's fine when some pattern is selective, but for a
"star" BGP where every pattern matches nearly the same rows (e.g. a
job-queue claim: same peer + status, ORDER BY priority LIMIT 1), there is
no cheaper seed for the cardinality-based planner to pick, and the cost
scales with the whole candidate set on every claim.

Profiled via EXPLAIN ANALYZE against a synthetic claim-queue workload
(the artifact-keeper/sparq migration's throughput investigation): the BGP
operator was confirmed to touch every matching row (rows=800 at a queue
depth of 800) before OrderBy/Slice could pick the top-1.

Adds try_topk_orderby_indexed, tried first inside try_topk_orderby's
OrderBy arm: for the narrow, syntactically-verified shape where the
primary ORDER BY key is a bare variable bound as one pattern's object
(constant predicate, variable subject -- the join "hub"), with every
other pattern sharing that hub as its subject, it walks that pattern's
value-sorted permutation scan directly (guarded to inline-integer
objects only, the one case where ascending dictionary-id order is proven
to equal ascending SPARQL value() order), joining each candidate against
the other patterns via index-nested-loop, and stopping once a complete
sort-key group has produced enough confirmed results. Declines (Ok(None))
at the first sign of anything outside that shape, so the existing
materialize-then-select path is always the correctness fallback.

Block sizing starts near row_budget (not the 1024-row CAPPED_SEED_BLOCK
used for bare LIMIT) and grows geometrically: a first attempt at
CAPPED_SEED_BLOCK-sized first blocks regressed moderate-n/small-k cases,
since each candidate here pays a real per-pattern index-probe cost that a
bulk merge join amortises away -- caught by re-profiling after the first
cut, not assumed.

Measured (synthetic claim-queue benchmark, single-node, in-memory graph):
~130us -> ~15-18us at a queue depth of 800, and latency stays ~flat
(doubling ratio ~1.0-1.2x) instead of climbing toward 2x (O(n)) as queue
depth grows to 3200.

Verified via a new differential test suite
(tests/topk_orderby_indexed_differential.rs) comparing this path's output
against an independently-computed ground truth (fetch the full unordered
relation, sort in test code, compare) across unique/tied priorities, both
sort directions, multi-peer isolation, a fully-bound status filter, empty/
under-full queues, non-inline priority values (exercises the decline
guard), an extra star pattern, and a 40-trial randomized sweep varying n,
tie density, and k. A first cut had a real direction bug for DESC order
(letting a naive block-slice-then-reverse silently return the LOWEST
priorities first for N > 1024) -- caught by the block-escalation test,
fixed by processing all blocks through a single logical best-first index
regardless of scan direction, not by special-casing per direction.

Full sparq-engine test suite (310 unit + all integration targets) and
full-workspace clippy -D warnings pass; the one storage-differential
failure encountered is confirmed pre-existing on unmodified main
(compressed dict-spill build, unrelated to this change).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Root-causes a reported regression: an independent re-run of the S3
claim-contention benchmark (8-way concurrent claim on a shared queue,
the exact workload this optimization targets) found throughput DROPPED
after the previous commit -- sparq-serve claim throughput 1516->1090
ops/s (~27% lower), p50 5.3ms->8.0ms -- despite this function's own
isolated single-query profiling showing a large win (~130us->~15-18us).

Root cause, confirmed empirically (not assumed): that isolated profiling
used near-unique pseudo-random priorities. A real job-queue is much more
likely to use a small number of discrete priority TIERS (e.g. "urgent/
high/normal/low"), which produces large tie-groups. This function cannot
split a tie group across its early-stop boundary, so a low-cardinality
priority distribution forces it to fully process the group via
per-candidate index-nested-loop probes -- which have real per-probe
overhead (Store::choose + a binary search per pattern) that a bulk
merge-join amortises away over a whole scan. Verified directly: at a
queue depth of 1600, a tie-group of ~100 still beat the fallback (~47us
vs ~269us baseline), but ~320 was worse (~175-283us) and a single tie
covering the whole queue was far worse (~870us before this fix).

Also confirmed the OTHER hypothesis this regression could have had was
NOT the cause: instantiate_templates (the DELETE/INSERT WHERE path) calls
the exact same eval_select entry point a plain SELECT does, so the
optimization is reached identically from an UPDATE's WHERE clause -- this
was a real algorithmic cost under a specific data shape, not a dead code
path.

Fix: MAX_INDEXED_GROUP (128, scaled up for a larger row_budget) caps how
large a single escalation block may grow. Exceeding it declines
(Ok(None)) and defers to the fallback for the WHOLE query, rather than
partially applying the fast path -- the fallback's cost at that point is
only the pre-existing ~2-5x gap already accepted before this feature
existed, not a new regression. Re-measured after the fix: low/moderate
tie-group workloads keep the win (13-47us), high-tie-density workloads
now measure ~274-283us -- statistically indistinguishable from the
ORIGINAL unmodified baseline (~269us) at the same queue depth, i.e. the
regression is eliminated, not just reduced.

Adds large_tie_group_still_correct (tiers in {1,2,5,16} over n=600) to
the differential suite -- correctness only; the regression this fixes
was a performance issue, verified separately via profiling, not asserted
as a timing test to avoid flakiness. Full sparq-engine test suite and
full-workspace clippy -D warnings still pass (same pre-existing storage
flake as before, confirmed unrelated).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…_orderby_indexed

The previous commit (cap tie-group size in try_topk_orderby_indexed) fixed a
real regression by declining above a flat MAX_INDEXED_GROUP=128 -- safe, but
conservative: it gave up the optimization's benefit entirely for any
moderately-tiered priority distribution, which is the realistic shape for a
job-queue (a handful of priority tiers, not near-unique values). Investigated
further to see how much of that gap was actually closeable rather than just
declined around.

Two real per-candidate costs were still present and are removed here:

1. Repeated permutation/bound resolution. Each (candidate, other-pattern)
   probe called `graph.store.scan(&probe_pat)` fresh, re-running
   `Store::choose`'s permutation selection every time even though the choice
   is IDENTICAL across all candidates for a given pattern (only the subject
   varies). Fix: resolve each other pattern's SUBJECT-sorted scan ONCE
   (`other_pats`, built before the block loop) and binary-search the
   resulting sorted slice per candidate instead -- O(log n) with no repeated
   setup. Falls back correctly (`Ok(None)`) when a store build can't provide
   a subject-sorted scan for a pattern (no PSO permutation under
   `compact-index`/wasm).

2. Per-candidate heap allocation from generality the common case doesn't
   need. The cartesian-expansion machinery (`Vec<SmallVec>`, cloning per
   combination, rebuilding the `cols` variable list from scratch every
   candidate) is only needed for a genuinely multi-valued predicate, which is
   rare (a real job-queue schema is single-valued: one status, one priority,
   one seq per task). Fix: hoist `cols`/the out-vars-to-columns mapping out of
   the per-candidate loop (computed once), and build each candidate's row
   directly into a stack-based SmallVec with zero heap allocation, falling
   back to explicit (and now genuinely rare) cartesian expansion only when a
   pattern actually yields more than one match for a given candidate.

Re-measured (queue depth 1600 / 8000, varying priority-tier count = tie-group
size): a tie-group of 320 dropped from ~146us to ~95us; 800 from ~367us
(a regression vs. the ~269us fallback) to ~228us (now a clear win); at queue
depth 8000, a tie-group of 4000 (half the queue) is ~1.3ms, roughly on par
with the fallback's own cost at that n.

This also changes what the SAFE threshold is: per-candidate cost is now
roughly CONSTANT, while the fallback's bulk-join cost is roughly constant per
`n` regardless of tie structure -- so the crossover is a FRACTION of `n`
(empirically ~0.5-0.75), not a fixed absolute count. Replaces
MAX_INDEXED_GROUP=128 with max_group = max(n/2, 256, row_budget*2): a
tie-group covering the WHOLE candidate pool still correctly declines and
matches the fallback's own cost (measured: ~272us at n=1600, matching the
original ~269us baseline almost exactly -- confirming the decline path
introduces no regression even in the worst case), while realistic partial-tier
distributions now benefit up to a much larger group size than before.

Also fixes two clippy::needless_range_loop lints from the rewrite (index-only
for loops replaced with slice iteration).

Verified: the full differential test suite (13 tests: unique/tied priorities,
both directions, multi-peer isolation, status filtering, non-inline fallback,
extra patterns, a 40-trial randomized sweep, and the large-tie-group
regression test) passes unchanged -- this is a pure performance change, no
new test cases needed since correctness was never in question, only cost.
Full sparq-engine test suite and full-workspace clippy -D warnings pass
(same pre-existing storage flake as before, confirmed unrelated in an earlier
commit on this branch).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…exed

Root-causes a real, significant remaining cost this function's own
isolated-query profiling never surfaced: for a queue drained strictly in
priority order (claim highest-priority-first, the realistic shape), the
seed's priority-sorted scan accumulates an ever-growing CONTIGUOUS prefix
of already-claimed (status no longer "pending") rows at its head. Every
one of those still costs a real per-pattern probe before being rejected --
this function degrading toward the exact O(n) cost it exists to avoid,
just counting REJECTED candidates instead of matched ones.

Measured directly (single fresh graph, no chaining/compaction involved at
all, isolating this from every other hypothesis): query cost grew from
~13-22us at 0 already-claimed to ~200-270us at 400-1400 already-claimed
out of n=1600 -- an ~15-20x degradation purely as a function of
already-claimed prefix size. This also resolves an earlier open question
from this investigation: a "fresh load vs. many chained fork+compact
cycles" comparison had appeared to show cost driven by generation-chain
depth independent of data, but that test confounded chain depth with
prefix structure (the "fresh" comparison claimed a scattered subset by
seq order; the "chained" comparison claimed a contiguous top-priority
block, since real claims always take the highest remaining priority) --
there was never a separate compaction-driven phenomenon, just this same
skip-prefix cost measured under a different, confounded label.

Two guards, addressing two different aspects of the same problem:

1. An UPFRONT check, before touching a single candidate: each `other_pats`
   scan's row count is the EXACT global cardinality of that pattern's own
   constraint (e.g. how many tasks are still genuinely `pending`, overall).
   If any other pattern is already meaningfully more selective than the
   seed's own scan, the fallback's ordinary smallest-estimate seed
   selection will pick THAT pattern as its seed and win outright with no
   per-candidate cost at all -- decline immediately rather than discover
   this the expensive way.

2. A reactive cumulative-failure counter as a safety net for skip-prefix
   shapes the upfront check can't see (cardinality alone doesn't capture
   every possible correlation between scan order and other-pattern
   membership). Deliberately sized LARGE (matching the existing
   `max_group` tie-group cap, not a small constant): an early attempt at a
   tight budget (64) "solved" the worst case but broke the far more common
   moderate case (a few hundred already-claimed) by bailing into a full
   fallback when simply finishing the walk would have been far cheaper --
   caught by re-testing across the whole range, not just the case being
   fixed. Every failed probe before declining is pure waste stacked on top
   of the fallback's own cost, so the budget trades a bounded, modest
   worst-case overhead (~30-50% over a clean fallback, only when the
   upfront check misses) for finishing the much more common moderate case
   without ever falling back at all.

Re-measured after both guards (single-query, across the full
already-claimed range 0..1599 out of n=1600): consistently AT OR BELOW
the fallback's own cost everywhere, with strong wins in the common cases
(12-70us vs. a ~90-390us fallback) and modest wins even in the harder
cases (82-250us vs. 89-320us) -- no regression anywhere in the tested
range, unlike either guard alone.

End-to-end effect (8-way concurrent Writer claim benchmark, n=1600,
combined with the already-tuned compact_threshold=512 from the prior
config-only finding): throughput ~3,357-3,400 -> ~5,310 ops/s (+56-58%
over the pre-this-session baseline), narrowing the gap to Postgres's
SKIP LOCKED (~32,000-34,700 ops/s in the original S3 benchmark) from
~20-25x to roughly ~6x on this workload shape.

Adds large_already_claimed_prefix_still_correct (already_claimed in
{0,50,100,400,800,1200,1599} out of n=1600) to the differential suite.
Full sparq-engine test suite (310 unit + all integration targets, same
pre-existing unrelated storage flake as every prior commit on this
branch) and full-workspace clippy -D warnings pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…-parse

GraphApplier::apply calls sparq_engine::update_in_place(working, &String),
which re-parses the update's FULL TEXT on every single commit -- for a
caller resubmitting the SAME update template repeatedly with only a bound
value changing (a job-queue claim, an upsert), that's pure waste every time.

Measured directly, isolating the parse cost specifically: PreparedUpdate::
parse on a representative claim template costs ~13.5us, while
PreparedUpdate::bind (structural placeholder substitution over the
already-parsed algebra, never string concatenation) costs under 1us. End
to end: raw-text update_in_place averages ~21.2us/update; parse-once +
bind + update_in_place_prepared averages ~11.3us -- roughly half.

Adds PreparedGraphApplier: an ApplyUpdates strategy identical to
GraphApplier (same structural fork, same threshold-compaction seal policy)
but with Update = sparq_engine::PreparedUpdate and apply() calling
update_in_place_prepared instead. A caller parses its update template ONCE
(e.g. at startup) and binds a fresh value per submission via
PreparedUpdate::bind, instead of formatting a new SPARQL string and paying
a fresh parse on the writer thread every time.

footprint() returns Footprint::Barrier unconditionally: there's no
parsed-algebra entry point into Footprint::of_sparql (text-only), and
re-serialising the already-parsed update back to text just to re-parse it
would defeat the whole point. This only affects the opt-in CommuteGroup
commit granularity (degrades to one generation per update, still correct)
-- orthogonal to this applier's goal, since the default Window granularity
never calls footprint() at all.

Gated behind a new opt-in `params` feature (forwarding to sparq-engine's
already-dependency-free `params`), OFF by default -- the serving core is
fully buildable without it, byte-identical to before.

Measured end-to-end (8-way concurrent Writer claim benchmark, n=1600,
compact_threshold=512): ~5,150-5,370 ops/s (raw text) -> ~5,730-5,840
ops/s (prepared), a consistent ~8-13% improvement across three runs.
Combined with this session's other fixes (the indexed ORDER BY seed, its
tie-group and skip-prefix corrections, and the compact_threshold tuning),
overall throughput on this workload has gone from the pre-session baseline
of ~3,357-3,400 ops/s to ~5,730-5,840 ops/s (+70-74%), narrowing the gap
to Postgres's SKIP LOCKED (~32,000-34,700 ops/s, original S3 benchmark)
from ~20-25x to roughly ~5.5x.

Adds prepared_applier.rs (2 tests, only compiled under `params`): proves a
bound PreparedUpdate applies correctly through the real sequenced writer
(structural fork + delta-overlay + compaction, identical to GraphApplier's
path), and that repeated submissions of the same parsed-once template
drain a queue correctly across many generations with no duplicate/lost
claims. Documents the feature in sparq-serve's README (kept in sync with
Cargo.toml per its own house rule). Full sparq-serve test suite and
clippy -D warnings pass in BOTH feature states (with and without
`params`), and full-workspace build + clippy (default features) passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Profiled the current best configuration end-to-end (8-way concurrent
Writer, PreparedGraphApplier, compact_threshold=512) by instrumenting
fork/apply/seal individually: sum(fork+apply+seal) was ~99% of wall-clock
elapsed (the writer thread has essentially no idle/channel overhead left
to chase), and apply_time -- dominated by the skip-prefix walk the prior
two commits made safe but not free -- was the remaining cost, averaging
~161us despite the isolated single-candidate cost being far lower.

Root cause: each per-candidate other-pattern check did
`op.scan.rows.partition_point(|r| op.scan.to_spo(r)[0] < hub_id)` --
`to_spo` reconstructs the full permutation-order-independent [S,P,O]
triple, and `partition_point`'s binary search calls that closure on EVERY
comparison step (~log(m) times), even though the comparison only ever
needs column 0. For a long skip-prefix (many candidates checked and
rejected in a row -- exactly the case the prior two commits made safe,
not fast), that's `O(log m)` wasted triple reconstructions per candidate
per other-pattern, on top of the (already-fixed) per-call setup cost.

Fix: precompute each other-pattern's subject ids into a plain `Vec<Id>`
ONCE (when `other_pats` is built, not per candidate), in the same sorted
order as the underlying scan. The per-candidate binary search then runs
over a flat `&[Id]` with a trivial integer compare per step -- `to_spo`
is only called after a MATCH is found (to extract an object value), i.e.
at most `match_count` times (typically 1), never during the search itself.

Re-measured (same 8-way concurrent Writer benchmark, n=1600,
compact_threshold=512, PreparedGraphApplier): avg apply ~161us -> ~125-137us
(-15 to -22%), throughput ~5,850 ops/s -> ~6,800-7,440 ops/s (+16-27%,
consistent across three runs). Combined with every fix this session,
overall throughput has gone from the pre-session baseline of ~3,357-3,400
ops/s to ~6,800-7,440 ops/s -- more than DOUBLED -- narrowing the gap to
Postgres's SKIP LOCKED (~32,000-34,700 ops/s, original S3 benchmark) from
~20-25x to roughly ~4.6-5x.

No behavior change, no new test cases needed: the full differential suite
(14 tests) already covers this code path and passes unchanged; this is a
pure internal representation change (search over `Vec<Id>` instead of
`Scan::rows` via `to_spo`) with byte-identical output. Full sparq-engine
test suite and full-workspace clippy -D warnings pass (same pre-existing
unrelated storage flake as every prior commit on this branch).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…f number

Two preflight findings from scripts/preflight.py, fixed before opening the PR:

1. The params/PreparedGraphApplier bullet added in 7a89065 pushed the README
   past the 120-line template cap (the file was already exactly at 120 before
   that change, with zero margin) -- trimmed it and tightened an adjacent
   blockquote to make room, no content lost.
2. That bullet also hard-coded a specific measured number (~13.5us of ~21.2us,
   ~63%) directly in the README -- against this repo's own "no hard-coded
   performance numbers in markdown" rule (CLAUDE.md, and the PR template's own
   checklist item). Replaced with the qualitative claim the number was
   illustrating (avoids the per-commit re-parse), which stays true regardless
   of hardware/measurement drift.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Every existing test in this file checks CORRECTNESS -- which the fallback
path (eval_modified + order_bindings) satisfies on its own. None of them
would fail if try_topk_orderby_indexed were deleted entirely: the whole
feature could regress to a no-op and the suite would stay green. Per
scripts/preflight.py's mandatory (non-automatable) obligation to mutate
the headline guard and confirm it's not vacuous, verified directly.

Adds fast_path_actually_engages_not_just_correct, using the engine's own
EXPLAIN ANALYZE instrumentation. First attempt checked for the ABSENCE of
any "BGP [" substring in the output -- this was itself vacuous: the
"Plan:" section is a static description of the general planner's choice
and always names a "BGP [...]" step regardless of which path actually
executes, so that assertion failed even with the fast path correctly
firing. Fixed to check the "Execution trace" section specifically: it
only emits a "BGP [binary GOO] (... patterns ...) rows=N" line when the
general BGP evaluator was ACTUALLY EXECUTED (materializing and reporting
the touched row count) -- the indexed fast path bypasses that evaluator
entirely, so the line's presence is a genuine, structural signal of which
path ran, not a name/type/marker string.

Verified non-vacuous by mutation, per the script's explicit instruction
("do not reason about it; execute it"): temporarily forced
try_topk_orderby_indexed to return Ok(None) unconditionally (disabling
it), re-ran the suite, and confirmed EXACTLY this new test went red while
all 14 other (correctness-only) tests stayed green -- proving both that
this guard actually detects the fast path being disabled, and that the
existing correctness tests genuinely do not (by design: they check
answers, not which code path produced them). Reverted the mutation
immediately after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
scripts/check-feature-test-execution.py --check caught this before it
became a silent gap: PreparedGraphApplier's tests/prepared_applier.rs is
behind the default-OFF params feature, so ci.yml's default-feature
nextest run compiles it EMPTY and would have run ZERO of its tests --
exactly the class of gap #1171 and the sq-vya1 tier ratchet exist to
catch (this is the same pattern the existing change-stream/change-sink
legs in this fragment close for their own features).

Adds a leg to .github/feature-matrix.d/sparq-serve.yml: clippy --all-
targets -D warnings with params ON, plus cargo test (both
prepared_applier.rs tests). Per this fragment's own documented scope
rule, adding a leg changes the emitted leg-name set, which is a
two-file change -- updated scripts/tests/feature-matrix-legnames.golden.txt
in the same commit, in the correct sorted position (verified: `diff
<(python3 scripts/assemble-feature-matrix.py --names)
scripts/tests/feature-matrix-legnames.golden.txt` matches).

Verified locally, matching exactly what CI will run for this leg:
`cargo test -p sparq-serve --features params --test prepared_applier`
(2 passed) and `cargo clippy -p sparq-serve --all-targets --features
params -- -D warnings` (clean). Also re-ran
scripts/check-feature-test-execution.py --check (now OK, previously
flagged this exact gap) and scripts/feature-matrix-tiers.py --enforce
(187 legs, 0 violations).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fixes the two regressions reported in #6465 against this PR:
- Repeated variables: decline when a pattern repeats a variable
  (has_intra_triple_repeated_var), so ?x <p> ?x is no longer answered
  without the equality check.
- Intermediate budget: decline whenever a query budget is active, so the
  existing evaluator keeps its row/byte accounting and cancellation.

Also keeps multi-valued probes on the existing evaluator, rejects an
oversized leading tie before preparing probes, prepares later probes only
when a candidate reaches them, and applies the cardinality admission rule
per probe. Recovered from the maintainer-side Codex review branches
(codex/perf-indexed-topk-recovery, codex/perf-topk-lazy-probes) and adds
the feature-off declaration.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1gMf8RJim1LzuKapZNpZR
@jeswr jeswr self-assigned this Oct 5, 2026
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1gMf8RJim1LzuKapZNpZR
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1gMf8RJim1LzuKapZNpZR
@jeswr

jeswr commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

🔎 Codex reviewer — gpt-6.1-sol

Automated review by the Codex reviewer (OpenAI gpt-6.1-sol via Codex CLI) of head abb313b49691. Scope: correctness, security, soundness and design; style nits omitted. A new head gets a fresh review.

Findings

  1. [medium] Indexed path errors on variable-bearing triple terms — crates/sparq-engine/src/exec.rs:3723
    The shape guard accepts probe objects containing nested variables, but prepare_pattern resolves them as ground terms before the normal BGP decomposition. With data <urn:s> <urn:p> 1 ; <urn:q> <<( <urn:a> <urn:r> 2 )>> ., the query SELECT ?s ?x WHERE { ?s <urn:p> ?p ; <urn:q> <<( ?x <urn:r> ?y )>> } ORDER BY ?p LIMIT 1 fails with "variable where a term was expected" instead of returning the matching row. Without LIMIT, the existing evaluator handles it correctly.

    Decline quoted-triple patterns before preparation using the existing has_quoted_triple_term guard, and add a regression test for this ordered, limited query.

Verdict: Fix the triple-term query regression before merging.

…riables

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1gMf8RJim1LzuKapZNpZR

jeswr commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed in bbb3622. The indexed top-k path now declines when any pattern holds a triple term (it reuses has_quoted_triple_term), so the evaluator handles nested variables. The regression test variable_bearing_triple_term_probe_declines runs your example with and without LIMIT; it fails on the previous head and passes now.


Generated by Claude Code

@jeswr

jeswr commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

🔎 Codex reviewer — gpt-6.1-sol

Automated review by the Codex reviewer (OpenAI gpt-6.1-sol via Codex CLI) of head bbb3622c32a1. Scope: correctness, security, soundness and design; style nits omitted. A new head gets a fresh review.

Findings

No correctness, security, soundness or design problems found.

Verdict: The PR appears safe to merge as is.

jeswr commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Local ci-fast gate (GitHub Actions incident; Jesse approved local-gate merges at 20:19Z).

Tested PR head bbb3622c32 merged with main 733ed43ac3, running the same steps as .github/workflows/ci-fast.yml:

merge-with-main: clean (315ae312)
clippy: pass
tests: pass
doctests: pass
conformance: pass (1225 pass + 4 divergence = 1229 >= 1229)
RESULT: GREEN 21:56Z

Main was re-checked immediately before merging.


Generated by Claude Code

@jeswr
jeswr merged commit b1ef23c into main Oct 5, 2026
5 checks passed
@jeswr
jeswr deleted the fix/indexed-topk-5983 branch October 5, 2026 21:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(engine): preserve repeated-variable semantics and budgets in indexed top-k recovery

3 participants