Skip to content

ITER-0002: compressed postings codecs (DeltaVarint + BlockDelta) - #12

Merged
nnunley merged 7 commits into
forest-rs:mainfrom
nnunley:postings-codecs
Jul 15, 2026
Merged

ITER-0002: compressed postings codecs (DeltaVarint + BlockDelta)#12
nnunley merged 7 commits into
forest-rs:mainfrom
nnunley:postings-codecs

Conversation

@nnunley

@nnunley nnunley commented May 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

Phase 2 compressed-postings codec layer — the third iteration in the Phase 2 stack (after #10 wind tunnel and #11 decisions + core IDs). Adds a Codec trait with two implementations over a stable v1 block format, plus the layout decisions that govern it.

Stacked on #11 (phase2-decisions). Until #10/#11 merge, this PR's diff against main includes their commits; review only the leit_postings codec + leit_core TermFreq + DEC-11/12 changes here.

What's in it

  • Codec trait + CodecId marker + structured CodecError (crates/leit_postings/src/codec.rs).
  • DeltaVarintCodec (CodecId 0): single-stream delta-encoded doc IDs + LEB128 varint TFs.
  • BlockDeltaCodec (CodecId 1): fixed 128-doc blocks (DEC-11), each self-contained (first doc absolute) and independently decodable via a per-block header (doc_count, first_doc, last_doc, doc_bytes_len); the header doc-range is validated on decode.
  • Hand-rolled LEB128 varint into a type-enforced [u8; 5] buffer. no_std + alloc, no new deps.
  • API speaks named segment-resident types SegmentLocalDocId + new leit_core::TermFreq (no anonymous u32); the generic EntityId stays the in-memory abstraction and lowers to SegmentLocalDocId at the segment-write boundary.
  • Decode writes into caller-provided &mut Vec<…> buffers — scratch-ownership-agnostic, so the DecodeScratch ownership decision is left to the cursor iteration (marked TODO(ITER-0003)).
  • Doc-sorted precondition enforced via checked_sub (deterministic panic, no silent corruption).
  • Decisions of record DEC-11 (block boundary strategy) and DEC-12 (v1 layout), grounded in the wind-tunnel Zipfian baseline.

Evidence

  • 36 leit_postings tests (cargo test -p leit_postings codec): lossless round-trip for both codecs incl. multi-block (128/129/256/300 docs), byte-size reduction, varint edge cases (u32::MAX, truncation, over-long), bad-marker / corrupt-header rejection, unsorted-input panic.
  • no_std verified on aarch64-unknown-none; workspace clippy/fmt clean.

Deferred (carried forward, not dropped)

  • advance_to block-skip (cursor layer) and the e2e ranking-equivalence proof → ITER-0003.
  • Segment-format reservation of the CodecId marker field → ITER-0004.

🤖 Generated with Claude Code

@waywardmonkeys waywardmonkeys left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should Fix

  • crates/leit_postings/src/codec.rs:460-494 and :547-579: block decode mutates out_docs before full validation finishes. On malformed input, callers can observe partially decoded docs or non-parallel docs/tfs after Err. That is risky with reusable decode scratch. Either document that output buffers are unspecified on error, or decode a block into temporary lengths and truncate/clear on error.

  • crates/leit_postings/src/codec.rs:447 and :543: prefer checked arithmetic for pos + doc_bytes_len. The decoded length is attacker-controlled. On 64-bit this is mostly theoretical due slice-size limits, but this is a malformed-input hardening patch in a no_std crate, so checked_add(...).filter(|end| *end <= bytes.len()) is the calmer invariant.

Could Improve

  • decode_block_doc_count is a good extraction, but it advances pos before validating the count. It is only local state today, so fine. A future helper that validates before committing the cursor position would be easier to reason about.

  • Add a short doc note to Codec::decode / BlockDecoder::decode_block: “on error, output buffers are unspecified” or “on error, output buffers are cleared.” Pick one and enforce it.

nnunley and others added 7 commits July 15, 2026 07:46
… (STORY-0096)

ITER-0001 dependency hygiene per the usage-site rule: the leit_wind_tunnel
harness uses only rapidhash in its library surface; leit_core/leit_index/
leit_text are used solely by its #[cfg(test)] integration tests, so they
move to [dev-dependencies] and no longer appear in the harness's production
dependency graph. The bench crates were already correct (empty lib; all deps
dev). Library build, 17 unit tests, and both bench crates verified green.
… (STORY-0112)

ITER-0001: BlockId, FilterExprId, SegmentOrd, SegmentLocalDocId in leit_core,
each a #[repr(transparent)] newtype over a [u8; 4] little-endian inner deriving
bytemuck Pod/Zeroable. The on-disk form is the in-memory form: a &[u8] slice
from an mmap'd buffer casts in place to &[Id] with no allocation or
deserialization (zero-copy), stable across host endianness; ordering is numeric.

bytemuck chosen over zerocopy because zerocopy's derives emit internal
#[allow(non_ascii_idents)]/#[allow(non_local_definitions)] that conflict with the
workspace's forbid-level Linebender lints (E0453); bytemuck is no_std and
lint-clean under the same forbid set. Proven by SCENARIO-0005 (6 unit tests:
value + slice + unaligned round-trip, numeric ordering, LE byte layout).
Records the design-decidable decisions for the Phase 2 segment format
(DEC-01..10) with rationale, a Phase 3 forward-compatibility audit, and
decision->enforcement traceability. Human-confirmed key calls:

- DEC-01 segment offsets: u64 (no size cap; removes the only Phase 3
  format-migration risk)
- DEC-10 integrity: single footer checksum, verified in Full validation mode
- DEC-06 block-aware API: public dedicated BlockCursor trait (Phase 3 WAND
  consumes it without a format/API break)
- DEC-05 header: fixed-layout little-endian POD, absolute u64 section offsets,
  magic + version + format_flags, reserved stored-fields/columnar slots

Decision-documentation ACs of STORY-0078/0081-0084/0090/0043-0047 are satisfied
here (decided:ITER-0001); their code-enforcement ACs are deferred to
ITER-0003/0004. Forward constraint recorded for ITER-0005: block-metadata schema
must carry per-block max_score + doc-range for Phase 3 WAND/MaxScore.
…ORY-0112 AC-2)

ITER-0001 audit corrective: SCENARIO-0005 now also exercises try_from_bytes/
try_cast_slice (Ok on well-formed, Err on malformed) per AC-2's validated-read
obligation.
…elta) [ITER-0002]

Codec layer for ITER-0002. A Codec trait with two implementations over a stable v1
block format, plus the layout decisions (DEC-11 fixed 128-doc blocks, DEC-12 layout)
and a new TermFreq segment-resident type.

- DeltaVarint (CodecId 0) + BlockDelta (CodecId 1, 128-doc independently-decodable
  blocks with validated first/last-doc header range).
- Hand-rolled LEB128 varint into a type-enforced [u8;5]; no_std + alloc; no new deps.
- API speaks named segment-resident types SegmentLocalDocId + TermFreq (no anonymous
  u32 drift); EntityId stays the in-memory abstraction, lowered at the segment boundary.
- Decode into caller-provided &mut Vec<..> — scratch-ownership-agnostic (TODO(ITER-0003)
  / STORY-0079). Doc-sorted precondition enforced via checked_sub (deterministic panic).
- CodecId marker per list; segment-format reservation deferred:ITER-0004.

Stories: STORY-0002/0003/0004/0005(AC1-2)/0009 done; STORY-0087/0088 decided.
Proof: SCENARIO-0006 (36 leit_postings tests). PAR spec + quality reviewed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@nnunley

nnunley commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the requested malformed-input invariants: checked section arithmetic, block-count and varint hardening, full doc-stream consumption, and transactional decode outputs. Codec and BlockDecoder APIs now guarantee both output buffers are empty on error.

@nnunley
nnunley merged commit 16c1f13 into forest-rs:main Jul 15, 2026
14 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.

2 participants