Skip to content

feat(query): a ranked read reports its ranking - #1130

Merged
ragnorc merged 11 commits into
ModernRelay:mainfrom
ragnorc:feat/read-descriptors
Oct 29, 2026
Merged

ragnorc merged 11 commits into
ModernRelay:mainfrom
ragnorc:feat/read-descriptors

Conversation

@ragnorc

@ragnorc ragnorc commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

RFC 0047 rollout step 6: a ranked read reports how it ordered its rows.

The read envelope (POST /query, stored-query calls, query --json, and the --format jsonl metadata line) gains one additive object, absent when the read's order is no search:

"ranking": { "by": "nearest", "order": "ascending", "exhaustive": false, "score_columns": ["d._distance"] }
  • by: the search function the order names, nearest, bm25 or rrf. Acceptance admits one ranking per read (the leading order key; T33 keeps an rrf() arm's own score out of return), so one object describes it.
  • order: ascending for a nearest distance, descending for a bm25 or fused score, so a client knows which value is better without knowing each function.
  • exhaustive: what this run did, not a per-function label. true means no row the search admitted and did not return outranks a returned one. bm25 scores every match, so it is always exhaustive. A nearest scan records whether its last attempt ranked exactly: proven empty, flat (the ladder's choice, the exact overfetch pass, or no index covering the column, which Lance reports by omitting both partition counters), every partition of the index searched, or every admitted row returned, none left unreached at +inf. rrf is exhaustive when its nearest arms are. A scan the run never reached makes the ranking inexact.
  • score_columns: the returned columns that hold the ranking's own score, in return order. Two columns returning one score both appear; a computed column (bm25(...) > 0.0 as hit) and a legacy stored property named _score do not.

Why "every partition searched" is exact. Every vector index the engine has ever built is IVF_FLAT (back to the first public commit), which scores every row of a partition it searches exactly. A new source guard, forbidden_apis.rs::vector_indexes_are_ivf_flat, pins the constructor, so a quantized or HNSW index cannot quietly make exhaustive claim too much.

Where it is computed. omnigraph_planner::planned_ranking derives by, score_columns and the ranked nearest scans from the accepted plan. engine::execute adds exhaustive after the run from the reports of the pass whose rows it returns, so the ordinary, inspected and replay doors report the same ranking. Replay repeats the run's probe decisions, and the replay test requires the same ranking.

This replaces the earlier version of this PR, which followed the RFC's original retrievals/metrics arrays (plan-local producer ids, arm references, a per-kind recall label, an always-unknown embedding_coverage). The RFC body is amended to match; its decision log names each superseded sentence and gives the reasons: producer ids named nothing a client could address separately, a per-function recall label repeated the function, and coverage was never known.

The RFC's rollout table also records steps 3 (#1066) and 4 (#1069) as merged.

Tests

  • Engine search.rs, on the four-partition IVF_FLAT fixture: issue_567_unfiltered_nearest_keeps_the_probe_cap (a cap of one leaves three partitions unsearched, so the answer is not exhaustive), issue_567_unfiltered_nearest_climbs_the_ladder_to_the_whole_corpus (the last rung searched every partition, so it is), issue_567_ladder_stops_when_the_prefilter_admits_fewer_rows_than_k (a flat scan is exhaustive).
  • Engine engine_v2_plan_replay.rs::a_read_reports_its_ranking: two bm25 arms with two columns returning one fused score, a nearest with its distance projected, a mixed fusion, a return distinct score key, a computed score, an unranked read; the inspected, ordinary and replayed runs agree. The shared replayed helper requires every replay to report its run's ranking.
  • Engine forbidden_apis.rs::vector_indexes_are_ivf_flat.
  • Planner: bound_plan.rs (a fused plan read back reports the same ranking; its nearest arm is the only scan deciding exhaustiveness; a broken arm is an internal error); in-source ranking.rs (a stored _score property beside a nearest order is no score column, and exhaustive follows the named scan).
  • Server: data_routes.rs::query_envelope_reports_its_ranking (exact wire object, no parameter values, absence on an unranked read); the remote nearest test checks its ranking; stored_queries.rs::invoke_stored_read_returns_rows covers the stored-query door.
  • CLI: cli_data.rs::ranked_read_reports_its_ranking_in_json_and_jsonl; parity_matrix.rs::parity_ranked_query_ranking; the in-source jsonl test covers presence and absence.
  • api-types: read_ranking_renders_and_round_trips (wire shape per function, decode/encode identity for the remote CLI, absence).
  • openapi.json regenerated; the vocabulary inventory classifies the new occurrences.

Docs

Search guide "What a ranked read reports", the CLI reference's machine-readable positions, the execution guide, the Lance compatibility table's KNN probe budget row (the partition counters now also decide exhaustive), the RFC 0047 amendment, and a changelog note. The MCP query and stored-read tool descriptions now name ranking, which MCP already passes through with the rest of the envelope.

A ranked read's JSON envelope gains two additive arrays derived from the
accepted plan: retrievals (one per ranked scan, per rrf() fusion and per
fusion arm, ids in plan preorder with a fusion before its arms and the
arms in argument order) and metrics (one per returned column whose value
is a score a retrieval of the plan writes, a return distinct group key
included). The planner derives them; execution attaches them to the query
result before the plan runs, so every read door and a replay report the
same ids for one plan. No parameter value is included, and a stored
property named like a score column on an older schema is no metric.

ragnorc commented Oct 22, 2026

Copy link
Copy Markdown
Contributor Author

Recommendation

Do not merge this head yet. The read-descriptor behavior matches RFC 0047. Test Workspace on this commit fails one test, openapi_inventory_matches. Classify the new OpenAPI words, then merge.

This review is a comment. It is not an approval.

What this change does

A ranked read now reports the retrievals that produced its order, and the returned columns that hold a score.

The planner derives that report from the accepted physical plan (read_descriptors in crates/omnigraph-planner/src/descriptors.rs). execute in crates/omnigraph/src/engine/mod.rs attaches it to the query result before the plan runs. The ordinary door, the inspected door, and replay therefore share one set of ids. JSON envelopes gain retrievals and metrics. The surfaces are POST /query, stored-query calls, query --json, and the --format jsonl metadata line. Both arrays are omitted when empty. Old payloads still decode. QueryResult::to_arrow_ipc writes batches only and carries neither array.

recall is the retrieval kind's contract. nearest is approximate even when a run scores every row. bm25 is exact. rrf is approximate when either arm is. embedding_coverage on a nearest retrieval is always {"state":"unknown","reason":"not_computed"}. No parameter value is included. A stored property named _score on an unranked read is not a metric.

Legal queries keep the same rows. This change does not write Lance data, and it does not change index use. Lance ANN search stays approximate. A flat scan stays an execution choice. The declared recall follows the kind, so a client does not depend on a guarantee that disappears when an index is built.

Blocking

  1. The OpenAPI vocabulary inventory does not classify the new guarded words. tests::openapi_inventory_matches panics. The shipped spec has ten unclassified occurrences:
  • MetricKindOutput description, the word column
  • MetricOutput description, the word column, twice
  • MetricOutput.column as a property name and as a required name
  • ReadOutput.metrics description, the word column, twice
  • RecallOutput description, the word row
  • RetrievalKindOutput description, the word column
  • the embedding_coverage field description, the word rows

Add one inventory row per occurrence in tools/omnigraph-vocabulary-guard/graph-vocabulary-inventory.tsv. Keep the wire name column. Classify these rows as retain and result_projection, the same class as ReadOutput.columns and ReadOutput.rows. Put a rationale on each row. Keep the file sorted by occurrence id. A prose edit changes the occurrence hash, so classify the text you intend to ship. Then this command must pass:

cargo test -p omnigraph-vocabulary-guard --locked --lib openapi_inventory_matches

The Graph Vocabulary Guard job is skipped on pull requests, and VOCABULARY_AUDIT_ENABLED is false. The workspace unit test is the check that ran. It fails on this head.

Optional

  1. The field doc on RetrievalOutput.embedding_coverage says the field reports how many ranked rows hold an embedding. EmbeddingCoverage has one variant, NotComputed. Every nearest retrieval emits unknown with reason not_computed. The type doc on EmbeddingCoverageOutput already says a read establishes no embedding count. Use that sentence on the field. The word rows in the current sentence is one of the ten inventory hits. Fix the sentence before you classify that row.

  2. A column computed from a score is not a metric, because the walk keeps only a bare property access. The stored-property case has the unit test a_stored_property_named_like_a_score_is_no_metric. The computed-column case has no execution test. A small case such as bm25(...) > 0 as hit would lock that reading. This is a coverage gap. I could not show a wrong metric for that shape from the code as written.

Tradeoff and liability

Nearest recall stays approximate on the exact overfetch pass. A client must not treat a flat pass as a promise. That promise would disappear when an index is built. This matches RFC 0047 and the Lance alignment fence.

The report is one derived view of the plan. There is no second stored description. Query semantics stay in the plan. Index layout stays derived state.

The new cost is a stable wire surface. Each later envelope array should use the same door. Derive it from the plan, attach it in execute, and render it in read_output. Each new OpenAPI description that uses a guarded word (column, row, rows, table, and the rest of that list) needs an inventory row. Five similar arrays would add wire fields and inventory rows. They would not add a second source of truth, as long as they stay derived.

A future score carrier that is not a bare property access would omit a metric. A cast, or an alias of a group key, is that shape. It is not a legal return today. T9 refuses a rank expression beside an aggregate. T56 refuses distinct plus aggregates. A return distinct of a score stays a property access on the aggregate. The engine test covers that shape.

Other open work

This head conflicts with current main (60bfd19db780fdff3a5178f959c1204143420114). Both sides changed these files:

  • crates/omnigraph-api-types/src/lib.rs
  • crates/omnigraph-cli/tests/integration/cli_data.rs
  • crates/omnigraph-server/tests/integration/data_routes.rs
  • crates/omnigraph/src/engine/mod.rs
  • docs/dev/execution.md
  • docs/user/cli/reference.md
  • docs/user/search/index.md
  • openapi.json

On main, execute still returns the plan run directly. Keep the descriptor attachment on that one function when you rebase.

Open PR #1012 also edits ReadOutput constructors, openapi.json, cli_data.rs, data_routes.rs, and the CLI reference. New ReadOutput values there need empty retrievals and metrics. Open PR #1137 edits cli_data.rs and the CLI reference. Open PR #1140 edits openapi.json and the crate manifests. Open PR #1139 edits operations.rs in omnigraph-api-types and does not define a second read schema. A query tool that returns the route JSON will carry these arrays.

Validation

Reviewed head: 9383fc8f693cbf0b97cb69d359d6aec7f65f856f. Merge base with origin/main: e60eb016941c6a4d41e03e74aa40c923a4ee4c01.

Local, checkout /tmp/pr1130, CARGO_TARGET_DIR=/workspace/target:

cargo test --locked -p omnigraph-planner --lib a_stored_property_named_like_a_score_is_no_metric

Result: 1 passed, descriptors::tests::a_stored_property_named_like_a_score_is_no_metric.

Local, same checkout, CARGO_TARGET_DIR=/tmp/pr1130-vocab-target:

cargo test --locked -p omnigraph-vocabulary-guard --lib openapi_inventory_matches -- --nocapture

Result: tests::openapi_inventory_matches failed. 0 passed, 1 failed, 51 filtered out. The panic lists the same ten occurrences as CI. The shell line after tee printed exit 0 because pipefail was off. The test summary is the result.

Exact-commit CI, run 39746533611, Test Workspace job 119716311280: 4927 tests run, 4926 passed, 1 failed, 40 skipped. The failure is omnigraph-vocabulary-guard tests::openapi_inventory_matches at tools/omnigraph-vocabulary-guard/src/lib.rs:1919. These passed in that job:

  • omnigraph-planner descriptors::tests::a_stored_property_named_like_a_score_is_no_metric
  • omnigraph-planner::integration bound_plan::a_nearest_plan_with_its_vector_reads_back_equal
  • omnigraph-api-types tests::read_descriptors_render_and_round_trip
  • omnigraph-engine::integration engine_v2_plan_replay::a_read_describes_its_retrievals_and_score_columns
  • omnigraph-server::integration data_routes::query_envelope_describes_retrievals_and_score_columns
  • omnigraph-cli read_format::tests::jsonl_format_emits_metadata_first
  • omnigraph-cli::integration cli_data::ranked_read_describes_its_retrieval_in_json_and_jsonl
  • omnigraph-cli::integration parity_matrix::parity_ranked_query_descriptors

GQT ordinary, GQT dst, GQT served, and the DST pinned suite passed on this commit. rustfmt and clippy passed. RustFS S3, default and failpoints, passed. omnigraph-server --features aws passed. The Graph Vocabulary Guard job was skipped, because the workflow skips that job on pull requests.

I did not finish a local rerun of the engine, server, or CLI integration tests. Those passed on CI for this commit. I did not measure performance. This change does not alter storage traffic, so the DST cost golden is unchanged. I did not run S3 or Azure locally.

Regression proof

No GQT before-and-after pair applies. This is a new envelope contract. GQT records rows, counts, result column types, and errors. It has no expectation for retrievals or metrics. Legal queries keep their rows. The arm-count error inside read_descriptors is an internal error for a plan that acceptance should have refused. It is not a user query shape. The Rust tests above are the owners for envelope metadata.

Inline: crates/omnigraph-api-types/src/lib.rs:691 (MetricOutput.column)

Blocking. This property name, and the new OpenAPI descriptions around it, are unclassified vocabulary. openapi_inventory_matches fails on this commit with ten occurrences of column, row, and rows. Keep the wire name column. Add one retain / result_projection row per occurrence in tools/omnigraph-vocabulary-guard/graph-vocabulary-inventory.tsv, sorted by occurrence id. The full list is in the review body.

Inline: crates/omnigraph-api-types/src/lib.rs:654 (embedding_coverage)

Optional. This sentence says the field reports how many ranked rows hold an embedding. The only value the code emits is {"state":"unknown","reason":"not_computed"}. The doc on EmbeddingCoverageOutput already says a read establishes no embedding count. Use that sentence here. The word rows is also one of the unclassified inventory hits, so change this text before you classify that row.

@cursor

cursor Bot commented Oct 24, 2026

Copy link
Copy Markdown

Recommendation

Do not merge this head yet. The read-descriptor behavior still matches RFC 0047. One OpenAPI naming rule from current main fails on this commit. Rename the four string-enum schemas, regenerate the spec, move the inventory rows, then merge.

This review is a comment. It is not an approval.

What this change does

A ranked read reports the retrievals that produced its order, and the returned columns that hold a score.

The planner derives that report from the accepted physical plan (read_descriptors in crates/omnigraph-planner/src/descriptors.rs). execute in crates/omnigraph/src/engine/mod.rs derives the report before the plan runs and attaches it to the query result after a successful run. The ordinary door, the inspected door, and replay share one set of ids. JSON envelopes gain retrievals and metrics on POST /query, stored-query calls, query --json, and the --format jsonl metadata line. Both arrays are omitted when empty. Old payloads still decode. Arrow IPC carries neither array.

recall is the retrieval kind's contract. nearest is approximate. bm25 is exact. rrf is approximate when either arm is. embedding_coverage on a nearest retrieval is {"state":"unknown","reason":"not_computed"}. No parameter value is included.

Legal queries keep the same rows. This change does not write Lance data. Lance ANN search stays approximate. A scan without an index can score every vector, and this report still says approximate for nearest. The client then stays on the contract that remains after an index is built. Lance's vector-search quickstart documents that split: a full scan scores every vector, and an ANN index trades recall for speed. Full-text search uses BM25 and stays the exact arm.

Earlier findings

  1. OpenAPI vocabulary inventory. Resolved. The merge adds nine retain / result_projection rows in tools/omnigraph-vocabulary-guard/graph-vocabulary-inventory.tsv. The tenth hit, the word rows on embedding_coverage, is gone because that sentence changed. The local command below passed. Test Workspace did not reach openapi_inventory_matches: fail-fast stopped after the convention failure and left 254 tests unrun.

  2. embedding_coverage field doc. Resolved. The field now says a read establishes no embedding count, and that the value is always unknown. The type doc on EmbeddingCoverageOutput already said that. openapi.json carries the same sentence.

  3. A column computed from a score. Resolved. a_read_describes_its_retrievals_and_score_columns now runs bm25(...) > 0.0 as hit beside bm25(...) as relevance and expects a metric only for relevance. That test passed on this commit in CI.

Blocking

  1. Four new string-enum schemas end in Output. Current main forbids that suffix on a string enum. docs/dev/http-api.md sets enum-schema-suffixes to Request Output Response. spec_follows_the_api_conventions in crates/omnigraph-server/tests/integration/openapi/conventions.rs checks it. The AWS job on this commit fails with:
  • CoverageUnknownReasonOutput
  • MetricKindOutput
  • RecallOutput
  • RetrievalKindOutput

conventions_reject_new_deviations fails on the same check.

Give each string enum a schema name with no transport suffix. BranchMergeOutcome and EntityKind already follow that rule. #[schema(as = LoadMode)] on LoadModeSchema is the existing way to set the schema name when the Rust name must stay. The compiler result types already use RetrievalKind, Recall, and Metric, so a Rust rename in omnigraph-api-types needs a distinct name or a schema as attribute. Use schema names RetrievalKind, Recall, MetricKind, and CoverageUnknownReason.

Regenerate openapi.json. Move the nine inventory rows onto the new schema paths. A path change changes the occurrence id. Keep each row retain / result_projection, and keep the file sorted by occurrence id.

The JSON values stay nearest, bm25, rrf, exact, approximate, distance, score, and not_computed. Only the component names change. HTTP contract 0.14 is still unreleased, so this is the time to set the names.

Object schemas keep Output. RetrievalOutput, MetricOutput, and EmbeddingCoverageOutput are objects. The failing test listed only the four string enums.

Merge with main

The second parent is current main, 7711e4a3def5c179cf95ec2525a6263819155916. A clean auto-merge conflicts only in openapi.json. The committed tree differs from that auto-merge in four places:

  • openapi.json drops the conflict markers, keeps CoverageUnknownReasonOutput, and takes main's DeploymentOutput.
  • The embedding_coverage description matches the new field doc.
  • The engine test gains the computed-score query.
  • The vocabulary inventory gains the nine rows.

No conflict markers remain. execute still attaches the descriptors on the one read function. Main's edits on that file add the CSV slice size for loads. Main's physical-plan edits add validation removal and CSV source partitions. Those nodes are not ranked scans, fusions, or return projections, so the descriptor walk still sees the same rank shape.

Other open pull requests

#1137 and #1139 merge cleanly onto this head. Both use 7711e4a3 as their merge base with this commit. #1139 calls handlers::server_query for the query tool and returns that JSON, so retrievals and metrics ride along. The tool description still says the result is rows, row_count, and graph_commit_id. Update that sentence when the two changes meet. #1139 does not define a second read schema.

#1012 still conflicts with this head in crates/omnigraph-cli/src/client.rs, crates/omnigraph-cli/tests/integration/cli_data.rs, crates/omnigraph-server/src/handlers.rs, docs/user/operations/server.md, and openapi.json. Any new ReadOutput value on that branch needs empty retrievals and metrics.

Tradeoff and liability

The report stays one derived view of the accepted plan. There is no second stored description. Query semantics stay in the plan. Index layout stays derived state. Nearest recall stays approximate on a full scan, so a client does not depend on a guarantee that disappears when an index is built.

The new cost is a stable wire surface plus the OpenAPI naming rule main now enforces. Each later envelope array should use the same door: derive it from the plan, attach it in execute, and render it in read_output. Each new string enum needs a suffix-free schema name. Each new description that uses a guarded word needs an inventory row. Five similar arrays would add wire fields, schema names, and inventory rows. They would not add a second source of truth while they stay derived.

This merge closes the vocabulary gap and the two coverage gaps from the previous review. It leaves the enum-name rule that main added after the feature was written. Setting the names before merge avoids a later client rename. Shipping the Output suffix would make the next similar enum copy a name the convention test rejects.

Validation

Reviewed head: d0913a76a5355250a443551e4ea1fa41e6ce6ca7. Parents: 9383fc8f693cbf0b97cb69d359d6aec7f65f856f and 7711e4a3def5c179cf95ec2525a6263819155916. origin/main at review time is that second parent.

Exact-commit CI, run 40101672643:

  • Test Workspace job 120894971507: 4707 passed, 1 failed, 40 skipped, 254 not run after fail-fast. Failure: openapi::conventions::conventions_reject_new_deviations. These passed in that job: read_descriptors_render_and_round_trip, ranked_read_describes_its_retrieval_in_json_and_jsonl, parity_ranked_query_descriptors, a_read_describes_its_retrievals_and_score_columns, a_stored_property_named_like_a_score_is_no_metric, query_envelope_describes_retrievals_and_score_columns, jsonl_format_emits_metadata_first, a_nearest_plan_with_its_vector_reads_back_equal.
  • Test omnigraph-server --features aws job 120894971585: spec_follows_the_api_conventions and conventions_reject_new_deviations failed. The first panic lists the four enum names above. invoke_stored_read_returns_rows passed in that job.
  • GQ Logic Tests (ordinary, served, dst, dst-clippy), the DST pinned suite, rustfmt, clippy, RustFS S3 default and failpoints, and Azurite passed.
  • The Graph Vocabulary Guard job was skipped on the pull request, as before.
  • Dependency Guard failed on RUSTSEC-2026-0387 (crypto-bigint 0.5.5 through jsonwebtoken). The nightly dependency guard on main at 7711e4a3 fails the same advisory (run 40060793527). This pull request adds one lock line, the arrow-schema dev-dependency. That advisory is outside this change.
  • CI Gate failed because those jobs failed.

Local, checkout /tmp/pr1130, CARGO_TARGET_DIR=/workspace/target:

cargo test --locked -p omnigraph-vocabulary-guard --lib openapi_inventory_matches

Result: tests::openapi_inventory_matches passed. 1 passed, 51 filtered out.

cargo test --locked -p omnigraph-planner --lib a_stored_property_named_like_a_score_is_no_metric

Result: 1 passed.

I did not finish a second local run of the api-types, engine, server, or CLI tests. Those descriptor tests passed on CI for this commit. I did not measure performance. This change does not alter storage traffic, so the DST cost golden is unchanged. I did not run S3 or Azure locally. I did not rerun openapi_spec_is_up_to_date. Test Workspace cancelled it. The committed embedding_coverage description matches the Rust field doc.

Regression proof

No GQT before-and-after pair applies. This is a new envelope contract. GQT records rows, counts, result column types, errors, and plan facts. It has no expectation for retrievals or metrics. The replay door is a Rust owner (engine_v2_plan_replay.rs). Legal queries keep their rows. The computed-score reading is covered by the engine test that passed on this commit. I added no temporary GQT case.

Inline: crates/omnigraph-api-types/src/lib.rs:623 (RetrievalKindOutput and the three sibling string enums)

Blocking. RetrievalKindOutput (line 623), RecallOutput (line 634), CoverageUnknownReasonOutput (line 670), and MetricKindOutput (line 677) generate string-enum schemas whose names end in Output. Rename those schemas, regenerate openapi.json, and move the inventory rows. The JSON values stay as they are.

The four string enums of the read descriptors become RetrievalKind,
Recall, MetricKind and CoverageUnknownReason, as the OpenAPI conventions
require; their JSON values are unchanged. The vocabulary inventory rows
for their descriptions move to the new schema paths.
@cursor

cursor Bot commented Oct 24, 2026

Copy link
Copy Markdown

Recommendation

Merge this head. The OpenAPI suffix blocker is resolved. This review finds no new blocker.

This review is a comment. It is not a GitHub approval.

What this change does

A ranked read reports the retrievals that set its order. It also reports each returned column that holds a score.

The planner derives both arrays from the accepted plan. execute attaches them to the query result. The ordinary door, the inspected door, and replay share one set of ids.

This commit only renames four string-enum schemas. The names are RetrievalKind, Recall, MetricKind, and CoverageUnknownReason. The JSON values stay nearest, bm25, rrf, exact, approximate, distance, score, and not_computed. Object schemas keep the Output suffix. RetrievalOutput, MetricOutput, and EmbeddingCoverageOutput stay objects.

Earlier findings

  1. OpenAPI vocabulary inventory. Resolved. The nine retain / result_projection rows remain. Three description rows now use the new schema paths. openapi_inventory_matches passes on this head.

  2. embedding_coverage field text. Resolved. This commit does not change that sentence. The field still says a read establishes no embedding count.

  3. A column computed from a score. Resolved. This commit does not change a_read_describes_its_retrievals_and_score_columns. That test passed on this commit in CI.

  4. Four string-enum schemas ended in Output. Resolved. docs/dev/http-api.md sets enum-schema-suffixes to Request Output Response. spec_follows_the_api_conventions checks that rule. The four names no longer use those suffixes. No schema that has an enum array ends in Request, Output, or Response.

Blocking

None.

Optional

None.

Other open pull requests

#1137 and #1139 merge cleanly onto this head. #1139 returns the query-route JSON from the query tool, so retrievals and metrics are in that JSON. The tool text still says the result is rows, row_count, and graph_commit_id. Update that sentence when the two changes meet. #1139 does not define a second read schema.

#1150 merges cleanly. It changes the description of FullTextIndexRequiredOutput only. It does not touch these enums.

#1151 merges cleanly. The merged lock keeps crypto-bigint 0.7.6 and keeps the arrow-schema line on omnigraph-api-types.

#1012 still conflicts in crates/omnigraph-cli/src/client.rs, crates/omnigraph-cli/tests/integration/cli_data.rs, crates/omnigraph-server/src/handlers.rs, docs/user/operations/server.md, and openapi.json. A new ReadOutput value on that branch needs empty retrievals and metrics.

Tradeoff and liability

The report stays one derived view of the accepted plan. This commit adds no second description. It sets the string-enum names before HTTP contract 0.14 ships, so a later client does not rename these schemas. The JSON contract is unchanged.

Nearest recall stays approximate on a full scan. Lance documents that split: a scan with no index scores every vector, and an ANN index trades recall for speed. Full-text search uses BM25. This commit does not change that contract.

Five similar arrays would add wire fields, schema names, and inventory rows. They add no second source of truth while they stay derived from the plan. Each new string enum needs a name with no transport suffix.

Validation

Reviewed head: bbd0cd8c66559adba32b96810789237301b6c887. Parent: d0913a76a5355250a443551e4ea1fa41e6ce6ca7. origin/main at review time is 7711e4a3def5c179cf95ec2525a6263819155916. The pull request is open and mergeable. The head did not move during this review.

The delta is four files: crates/omnigraph-api-types/src/lib.rs, crates/omnigraph-cli/src/read_format.rs, openapi.json, and tools/omnigraph-vocabulary-guard/graph-vocabulary-inventory.tsv. The Rust match from compiler kinds to wire kinds is the same mapping under the new names. No user document names the old schema names.

Local, checkout /tmp/pr1130-rereview at that head, CARGO_TARGET_DIR=/workspace/target:

cargo test --locked -p omnigraph-vocabulary-guard --lib openapi_inventory_matches

Result: tests::openapi_inventory_matches passed. 1 passed, 51 filtered out.

cargo test --locked -p omnigraph-server --test integration openapi::

Result: 106 passed, 0 failed. This includes openapi::conventions::spec_follows_the_api_conventions, openapi::conventions::conventions_reject_new_deviations, and openapi::openapi_spec_is_up_to_date.

A direct read of openapi.json shows RetrievalKind = nearest|bm25|rrf, Recall = exact|approximate, MetricKind = distance|score|rrf, and CoverageUnknownReason = not_computed. The four *Output names are absent.

Exact-commit CI, run 40123494256:

  • Test Workspace job 120959915031 succeeded. Its JUnit report has 4962 tests and 0 failures. These passed there: spec_follows_the_api_conventions, conventions_reject_new_deviations, openapi_spec_is_up_to_date, openapi_inventory_matches, read_descriptors_render_and_round_trip, a_read_describes_its_retrievals_and_score_columns, a_stored_property_named_like_a_score_is_no_metric, query_envelope_describes_retrievals_and_score_columns, invoke_stored_read_returns_rows, ranked_read_describes_its_retrieval_in_json_and_jsonl, parity_ranked_query_descriptors, jsonl_format_emits_metadata_first, and a_nearest_plan_with_its_vector_reads_back_equal.
  • Test omnigraph-server --features aws succeeded.
  • GQ Logic Tests (ordinary, served, dst, dst-clippy), the DST pinned suite, rustfmt, clippy, RustFS S3 (default and failpoints), Azurite, the V5 to V10 format fence, and Storage Upgrade Compatibility succeeded.
  • The Graph Vocabulary Guard job was skipped on the pull request, as before. The workspace unit test is the check that ran.
  • Dependency Guard failed on RUSTSEC-2026-0387 (crypto-bigint 0.5.5). This commit does not change Cargo.lock. The advisory is patched in >= 0.7.6. fix(server): remove crypto-bigint 0.5 for RUSTSEC-2026-0387 #1151 sets 0.7.6. CI Gate failed because Dependency Guard failed. This is not a finding of this pull request.

I did not rerun the engine, route, or CLI descriptor tests locally. They passed in the Test Workspace report for this commit. I did not measure performance. This commit does not change storage traffic, so the DST cost golden is unchanged. I did not run S3 or Azure locally. Those CI jobs passed.

Regression proof

No GQT before-and-after pair applies. This commit renames schemas. It does not change rows, counts, result column types, errors, or JSON values. GQT records those facts and plan facts. It has no expectation for retrievals or metrics. The envelope owners are the Rust tests named above. I added no temporary GQT case.

Replace the read descriptors (`retrievals` and `metrics`, with plan-local
producer ids, arm references, a per-kind recall label and an always-unknown
embedding coverage) with one `ranking` object: the search function the
order names, the direction its score ranks, whether this run's search was
exhaustive, and the returned columns holding the score.

`exhaustive` is a per-run fact. A `nearest` scan records whether its last
attempt ranked exactly: proven empty, flat, every partition of the IVF_FLAT
index searched, or every admitted row returned, none unreached. `bm25`
scores every match. A source guard pins IVF_FLAT as the only vector index
the engine builds. RFC 0047 is amended to match.
@ragnorc ragnorc changed the title feat(query): a read describes its retrievals and score columns feat(query): a ranked read reports its ranking Oct 28, 2026
The checkpoint counted itself entered before it marked itself waiting,
so a test that waited for `entered()` and then asserted `is_paused()`
could see the gap on a busy runner. The first body now claims the pause,
marks it waiting and only then entered.

ragnorc commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Recommendation

Ready to merge. This review finds no blocking defect.

This review is a comment. It is not a GitHub approval.

Why, what, how, impact

A caller of a top-k search cannot tell, from the rows alone, whether a nearer or more relevant row was left out, or which returned columns hold the score.

This head adds one ranking object to the JSON read envelope. The object is present when the read's order is a search. It names the search (nearest, bm25, or rrf), the direction of that score, whether this run's search was exhaustive, and the returned columns that hold the score. POST /query, a stored-query call, omnigraph query --json, and the --format jsonl metadata line carry it. Arrow IPC does not. An old client ignores the new field. A response with no search order has no ranking.

The planner reads the accepted plan and derives the search, the score columns, and the nearest scans. The run sets exhaustive from the scan report of the pass whose rows come back. A replay executes the plan again, so it reports the ranking of that run. Nothing is stored beside the plan.

order is the score direction. A nearest distance ranks the smaller value first. A bm25 score or a fused score ranks the larger value first. The planner sets that direction from the search function. For a row read, the rows follow it. For an aggregate, the planner drops the leading search key, and the groups stay unordered when no key remains. The field still says which score value is better. The user guide and RFC 0047 say the same thing.

exhaustive means that no admitted row that this response left out outranks a returned row. A bm25 ranking is exhaustive because the full-text query is built with no candidate limit, so Lance scores every match and the later sort and limit only cut the result. A nearest ranking is exhaustive when the last attempt was proven empty, ran flat, searched every partition of the index, or returned every admitted row, and no admitted row came back at +inf. An rrf ranking is exhaustive when every nearest arm is. A nearest scan the run never reached leaves the ranking inexact.

Every vector index this engine builds is IVF_FLAT. Lance scores every vector inside a searched IVF_FLAT partition. vector_indexes_are_ivf_flat fails the build if a quantized or graph index constructor appears. Lance omits both partition counters when no index covers the column, and that path is a flat exact scan. One counter alone is not exact. The probe ladder already uses these counters. docs/dev/lance.md now states the same fence for this field.

Earlier review

The review of bbd0cd8c on 2026-10-24 recommended merge and found no blocker. That head described retrievals and metrics. This head replaces that design with ranking. The vocabulary rows, the computed-score case, and the suffix-free string enums are in the new object. This review does not repeat those findings.

Blocking

None.

Optional

  1. docs/user/search/index.md line 216 says "its nearest arm". The code (PlannedRanking::ranking uses all), RFC 0047, and the OpenAPI text require every nearest arm. An rrf() may rank two nearest arms. Please say "arms".
  2. crates/omnigraph/src/engine/operators/scan/mod.rs publishes the scan report when mode.nearest is set. That is the right door for a proven-empty nearest: execute_node_scan sets exact before any Lance scan, and a missing report would make exhaustive false. On this head, issue_567_proven_empty_eligible_set_runs_no_scan_and_no_overfetch returned zero rows with ranking nearest and exhaustive == true. Please keep that assertion in the test. The test checks that no scan ran, and it does not check the public bit. I added the assertion only long enough to run it, then removed it.

The pause change

ce0c276b closes a real gap in the test checkpoint. The old body incremented entered and only then stored waiting. A test that had observed entered() could read is_paused() in that gap and fail on a busy runner. The first body now claims the pause, stores waiting, then stores entered, then waits. Both stores use sequential consistency, so a load that sees entered also sees waiting until the test releases the pause or the five-second wait ends. A second body still returns without waiting. The callers still wait for entered() and still assert is_paused(). The assertions remain. The new store order closes the gap.

Tradeoff and liability

The object removes a client-side guess. One response states the search, the better score direction, the exactness of this run, and the score columns.

It adds a public field that a client may trust. exhaustive is exact while every vector index stays IVF_FLAT and while Lance 13 reports the two partition counters as it does now. The source guard owns the index-type assumption. A later index type fails that guard, so the claim cannot grow silently. The counter rule is the ladder's existing rule, written down for this field.

Five more facts of this kind should extend this one object. The plan derives the shape. The run supplies the evidence. The API renders it. A second object, or a copy stored beside the plan, would drift. order is derived from SearchFunction::descending at the API boundary. The compiler ranking does not store a second direction.

Open pull requests that meet this change: #1198 adds a TOON format and conflicts in the read formatter, so that format must carry ranking or say that it does not. #1012 still conflicts with this head. #1156 can change index work later; the IVF_FLAT guard is the fence for this claim. #793 is RFC 0048, and embedding coverage stays there, as RFC 0047 says.

Validation

Reviewed head: ce0c276b564a87c9bf14558f59798769e4ff37c6. Merge base with origin/main: 9d6accc8650139e4171074600ce013466a003837.

GQT cannot express an envelope field. The owner is the existing capped-nearest test. I added one temporary assertion to issue_567_unfiltered_nearest_keeps_the_probe_cap on both commits. The assertion requires the result debug text to contain ranking: Some and exhaustive: false. It uses Debug, so both commits compile it.

Base, /tmp/pr1130-base at 9d6accc8:

CARGO_TARGET_DIR=/workspace/target cargo test -p omnigraph-engine --features failpoints --test search issue_567_unfiltered_nearest_keeps_the_probe_cap --locked -- --exact

Result: FAILED, 1 failed. The rows were n00000 through n00009. The debug text had the schema and the batches only. It had no ranking. The rows are the same top ten. The missing object is the new contract.

Head, /tmp/pr1130 at ce0c276b, same command and the same assertion: ok, 1 passed.

I then removed the temporary assertion. The clean test, which already asserts !result.ranking().unwrap().exhaustive, passed (1 passed).

On the head only, I also asserted that issue_567_proven_empty_eligible_set_runs_no_scan_and_no_overfetch reports nearest and exhaustive == true. Result: ok, 1 passed. I removed that assertion. Both worktrees are clean. Nothing was pushed.

Other local commands at the head, each ok with 1 passed:

  • issue_567_unfiltered_nearest_climbs_the_ladder_to_the_whole_corpus (exhaustive true)
  • issue_567_ladder_stops_when_the_prefilter_admits_fewer_rows_than_k (flat, exhaustive true)
  • engine_v2_plan_replay::a_read_reports_its_ranking
  • ranking::tests::a_stored_property_named_like_a_score_is_no_score_column
  • vector_indexes_are_ivf_flat
  • tests::read_ranking_renders_and_round_trips
  • tests::openapi_inventory_matches
  • engine::operators::csv_source::tests::cancelled_csv_producer_settles_and_releases_its_admission
  • data_routes::query_envelope_reports_its_ranking
  • cli_data::ranked_read_reports_its_ranking_in_json_and_jsonl

Commands were cargo test -p <package> --locked with the filters above, from /tmp/pr1130, CARGO_TARGET_DIR=/workspace/target. Engine tests used --features failpoints where the test requires it.

CI on this commit, run 40769972614: Test Workspace pass (11m1s), clippy pass, rustfmt pass, GQT ordinary, served, and dst pass, DST pinned pass, RustFS and Azurite pass, omnigraph-server --features aws pass, CI Gate pass. The Graph Vocabulary Guard job skips on pull requests. The local inventory test is the check that job runs. Devin Review skipped because the trial has expired.

Limitations

I did not measure latency or memory. The added value is one small JSON object on a ranked read. I did not run the full workspace suite, S3, or Azure locally. Those jobs passed on this commit in CI. I read Lance 13.0.0 for the partition counters and for exact scores inside a searched IVF_FLAT partition. I did not run a later Lance version.

@ragnorc
ragnorc added this pull request to the merge queue Oct 29, 2026
Merged via the queue into ModernRelay:main with commit 71219d1 Oct 29, 2026
32 checks passed
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.

1 participant