Skip to content

Segment merge, durable serialization, and reusable execution infrastructure - #18

Open
nnunley wants to merge 8 commits into
forest-rs:mainfrom
nnunley:typed-query-program
Open

Segment merge, durable serialization, and reusable execution infrastructure#18
nnunley wants to merge 8 commits into
forest-rs:mainfrom
nnunley:typed-query-program

Conversation

@nnunley

@nnunley nnunley commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

What

Eight commits, each one idea, resequenced so the branch can be reviewed a commit at a time.

Reading order

# Commit What to look for
1 test(leit_index): create test segment files with tempfile Pre-existing cleanup, first because everything after it inherits the habit. The mmap tests composed temp paths from pid + clock + counter and deleted the file at the end of the test body; now TempDir gives uniqueness from an exclusive create by the OS and cleanup on drop, including on panic. Net −35 lines.
2 feat(leit_text): add analysis schema identity The shared primitive. AnalysisSchemaId + schema-aware FieldAnalyzers accessors, factored out ahead of both consumers.
3 feat(leit_index): merge segments deterministically Merge policy, input validation, deterministic doc-id remap planning, merge execution, and a scoring oracle proving a merged segment scores identically to its sources.
4 feat(leit_index): serialize merged segments durably On-disk form for compressed segments, rebuilt block metadata, and postings-encoding validation on full open. Ends with a merge → serialize → reopen → rescore round trip.
5 docs: design reusable execution infrastructure Design and staged plan, committed before the implementation so commits 6–7 read against a stated intent.
6 test(index): add reference oracle and allocation measurement The apparatus, before any optimization: a naive reference execution index used as a correctness oracle (with a feature-boundary test that compiles a consumer crate to prove bench-internals does not leak), plus a scoped allocation counter so tests assert exact allocation budgets.
7 feat(index): reuse execution allocations across queries The optimization: finish_into for top-k sinks, a reusable preplanned execution scratch on ExecutionWorkspace, and compressed decode scratch moved out of the cursor. Each step pinned by the counter from commit 6, results held constant by the oracle. Ends with recorded allocation baselines.
8 feat(query): typed query pipeline plan_program / search_program.

On commit 7

Planner::plan_program lowers a UserQueryProgram directly to an ExecutionPlan; ExecutionWorkspace gains plan_program/search_program alongside the textual variants, and leit_index re-exports the builder types so consumers need no direct leit_query dependency.

Motivation: callers routing natural-language prose through the textual parser hit hard parse failures on operator-looking input — colons read as field qualifiers, stray parens. A typed program lets a front-end guarantee totality on arbitrary user input and treats the string syntax as one client among several. Downstream consumer: graphrag-rs builds UserQueryProgram from a total parser and calls search_program; that change rides separately in its own repo.

  • The term arm of the textual lowering is extracted into a shared helper, so field resolution, default-field expansion, and boost * default_boost composition are literally the same code on both paths. Parity is pinned by plan-equality and end-to-end hit-equality tests.
  • Same max_depth/max_nodes guards as the textual path. Depth uses an iterative tri-color DFS with memoized depths: linear on shared-child DAGs, rejects over-deep chains mid-traversal instead of overflowing, and rejects cyclic hand-built arenas.
  • Boost factors are validated at the typed boundary — NaN, infinite, and negative factors and composed-product overflow reject with QueryError::InvalidBoost. The textual path cannot produce these inputs, so this surface is typed-path-only.
  • Phrase nodes lower to AND of their terms; Phase 1 has no positional data, so slop is carried but not enforced. The doc comment records the cross-field caveat explicitly: when positions land, lowering must OR per-field phrase nodes rather than just swap the AND node.

The textual parser is untouched.

Verification

  • Every gate at every one of the eight commits, 8/8 clean: cargo fmt --all --check, taplo fmt --check, cargo clippy --workspace --all-targets --all-features --locked, and cargo test --workspace --locked --all-features. The branch is bisectable and each commit satisfies the repo's formatting and lint rules on its own, not just at the tip.
  • Tip: 524 tests pass. prek run --all-files --hook-stage pre-push: 21/21 hooks pass, including cargo fmt, taplo fmt, all four clippy variants, --doc, and cargo doc.

Changes since the previous push

Relative to the previous push of this branch (03083893), three fixes, each folded into the commit that introduced the affected lines rather than added on top:

  1. cargo fmt --check failed on the previous head — pub use ordering in leit_index/src/lib.rs plus call formatting in planner.rs, program_search.rs, plan_program.rs.

  2. taplo fmt --check failed — three feature arrays needed sorting in leit_wind_tunnel and leit_wind_tunnel_query.

  3. A real test bug in reference_feature_boundary: the temp directory was built by hand from pid + as_nanos(), but both tests in that file run in parallel in the same process, so when they started inside one clock tick they got the identical path — one test then read the other's main.rs while the other's Drop deleted the tree mid-build. Now uses tempfile::TempDir, so uniqueness comes from an exclusive create by the OS rather than from a name predicted out of the process id and the clock, and cleanup survives a panicking test. tempfile was already in Cargo.lock transitively via proptest, so this adds no new crate to the build graph. Separately, the nested generate-lockfile call now sets its own CARGO_TARGET_DIR instead of inheriting an ambient one.

    The same hand-rolled helper existed in leit_index/src/segment_format/mmap.rs on main. That is fixed here too, in commit 1.

Provenance

Implemented and reviewed with LLM assistance under red-first TDD; an independent cross-model review produced three robustness findings in the typed query work — parser recursion bound, DAG traversal memoization, boost validation — all fixed and covered by tests here.

@nnunley
nnunley requested a review from waywardmonkeys August 6, 2026 20:40
@nnunley
nnunley force-pushed the typed-query-program branch from 0308389 to ed404bf Compare August 13, 2026 16:48
@nnunley nnunley changed the title feat(query): typed query pipeline — plan_program and search_program Segment merge, durable serialization, and reusable execution infrastructure Aug 13, 2026
@nnunley
nnunley force-pushed the typed-query-program branch from ed404bf to 5ee64a0 Compare August 13, 2026 18:27
The mmap tests built their temp paths by hand from the process id, the
clock, and a process-wide counter, then removed the file at the end of
the test body.

Both halves were wrong. Composing a name predicts that nobody else
picked it — nothing checks that the path is free, so a leftover file
from a crashed run is silently reused, and the process id does not
separate runs in different PID namespaces. And cleanup at the end of the
body does not run when a test panics, so every failing run leaked a
segment file into the temp directory.

TempDir takes uniqueness from an exclusive create by the operating
system and removes the tree on drop, including on unwind. Each test now
owns a directory rather than a file, so the segment name inside it can
be fixed.

tempfile is already in Cargo.lock through proptest, so this adds no new
crate to the dependency graph.
Introduces AnalysisSchemaId and the schema-aware FieldAnalyzers accessors.
This is the shared primitive that both the segment-merge compatibility check
and the reusable-execution scratch depend on, so it is factored out ahead of
either story.
@nnunley
nnunley force-pushed the typed-query-program branch from 5ee64a0 to 38d4731 Compare August 13, 2026 19:17
Adds logical segment merging: a merge policy that selects candidates and
rejects incompatible inputs, validation of owned merge inputs (including
the analysis-schema compatibility check), deterministic doc-id remapping,
and the merge execution itself.

The remap plan is computed before any writing so the result is a pure
function of the input segment set and its ordering: the same inputs
always produce the same doc-id assignment, which is what makes a merged
index reproducible and diffable.

Closes with a scoring oracle test that proves a merged segment scores
identically to the unmerged sources it replaces.

Squashed from five commits: merge policy, input validation, remap
planning, merge execution, scoring-equivalence test.
Adds the on-disk form for prepared/compressed segments: the writer path
and codec support for compressed postings, rebuilt block metadata so a
reopened segment reconstructs its skip structure, and full validation of
postings encodings when a segment is opened rather than trusting the
header.

Validation is deliberately on the full-open path, not just the
incremental one: a corrupted or truncated encoding is rejected with a
typed error at open time instead of surfacing as a wrong result during
scoring.

Closes with an end-to-end test that merges segments, serializes the
result, reopens it, and round-trips the scoring.

Squashed from four commits: writer/codec preparation, block-metadata
rebuild, full-open encoding validation, merged round-trip test.
Records the design and the staged plan for reusing execution
allocations across queries: what is measured, which scratch buffers are
candidates, the ownership rules that keep reuse sound, and the phasing.

Committed before the implementation so the following commits can be read
against a stated intent.

Squashed from two commits: design and plan.
Puts the measurement apparatus in place before any optimization, so the
reuse work that follows is checked rather than asserted.

Two pieces:

- A reference execution index in leit_index (behind bench-internals): a
  deliberately naive implementation used as a correctness oracle, with
  parity, statistics, and feature-boundary tests. The feature-boundary
  test compiles a consumer crate against the public surface to prove the
  bench-internals feature does not leak.

- A scoped allocation counter in leit_wind_tunnel: counts allocations
  within a scope so a test can assert an exact allocation budget instead
  of eyeballing a benchmark.

Squashed from two commits: reference execution oracle, scoped allocation
counter.
Removes per-query allocation from the hot path, one owner at a time:

- leit_collect exposes finish_into so top-k result sinks are refilled
  rather than reallocated.
- leit_index owns a preplanned execution scratch, reused across queries
  through ExecutionWorkspace instead of rebuilt per search.
- Compressed decode scratch moves out of the cursor and into the same
  reusable memory, so decoding a compressed segment no longer allocates
  per block.

Each step is pinned by the allocation counter from the previous commit,
and the reference oracle keeps results identical throughout.

Closes with the allocation baselines: query-side reuse and the indexing
phases measured separately, plus the recorded baseline numbers.

Squashed from five commits: top-k sink reuse, preplanned execution
scratch, compressed decode scratch, query allocation baseline, indexing
allocation phases.
Add Planner::plan_program, lowering a UserQueryProgram AST directly to an
ExecutionPlan without the textual parser. The term arm of the textual
lowering is extracted into a shared helper so field resolution,
default-field expansion, and boost composition are the same code on both
paths; parity is pinned by plan-equality and hit-equality tests.

ExecutionWorkspace gains plan_program/search_program with the same
filter-slot wrapping as the textual variants, and leit_index re-exports
the query-builder types so consumers need no direct leit_query dep.

Guards: shared max_depth/max_nodes enforcement, iterative tri-color DFS
depth computation (linear on shared-child DAGs, rejects deep chains
mid-traversal instead of overflowing), cycle rejection for hand-built
arenas, and boost validation (NaN/infinite/negative factors and
composed-product overflow reject with QueryError::InvalidBoost).

Phrase nodes lower to AND of their terms for now: Phase 1 has no
positional data. The doc comment records the cross-field caveat so
positional support ORs per-field phrase nodes rather than swapping the
AND node.

Motivation: callers routing natural-language prose through the textual
parser hit hard parse failures on operator-looking prose (colons, parens).
A typed program lets front-ends guarantee totality and treat the string
syntax as one client among several.
@nnunley
nnunley force-pushed the typed-query-program branch from 38d4731 to b99befc Compare August 13, 2026 20:36
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