Skip to content

perf: O(log) cold one-shot maximal exact match (forward + backward) - #23

Merged
nh13 merged 1 commit into
mainfrom
feat/v0.2-olog
Jun 11, 2026
Merged

perf: O(log) cold one-shot maximal exact match (forward + backward)#23
nh13 merged 1 commit into
mainfrom
feat/v0.2-olog

Conversation

@nh13

@nh13 nh13 commented Jun 8, 2026

Copy link
Copy Markdown

PR #10 of the v0.2 stack (carries 8f85620, with 65b99c4 folded in per the plan). Base is feat/v0.2-gallop (#22). Tracked in V0.2_PR_STACK.md row #10.

What this does

O(log n) cold one-shot maximal exact match for both directions (BWA-MEME's cold-search cost), replacing the per-base O(extension-length) walk in mem_search/mem_search_backward when no .kmt is loaded:

  • Forward (65b99c4) — forward_maximal_len finds the maximal match length with one O(log) maximal-prefix search, then recovers the interval once.
  • Backward (8f85620) — the longest left extension is the longest prefix of the anchor's reverse-complement that occurs (the 2× index already holds RC suffixes; occ is strand-symmetric), so one O(log) RC-strand search finds it. Plus a unique-anchor (occ==1) probe-free fast path via mem_search_backward_from_hint.
  • Both share forward_maximal_len, so 65b99c4 is folded into 8f85620 (one commit). Depends on mem_search_backward_from_hint from feat(prmi): cleanroom trainer (uniform weighting) + shared lookup math #6 (3acd729) — satisfied (it's below this in the stack).

Byte-identical by construction, and gated by the oracle tests: mem_search_equals_maximal_forward_step, mem_search_backward_equals_maximal_backward_step, mem_search_*_hint_equals_unhinted, and the FFI parity tests.

Conflict resolution

spectrum.rs (both commits): #10 rewrites the bodies of mem_search/mem_search_backward that #5 had refactored to the sink form. Took #10's complete O(log) rewrite for both (it supersedes the sink-based one-shot); the carried packed/bounds guards in the spectrum cores are untouched (non-conflicting regions).

Green-pass

  • build ✅ · clippy -D warnings ✅ · nightly fmt ✅ · cargo test --workspace ✅ (162 lib + all FFI; the byte-identity + oracle proptests above; only Plan-3 deferrals ignored).

Summary by CodeRabbit

  • Tests

    • Added a collapse scenario to backward-search benchmarks and tests ensuring searches fail closed on undersized inputs and reject invalid anchor lengths.
  • Refactor

    • Introduced a new O(log n) path for maximal exact-match searches to speed one-shot searches and streamlined backward-search logic with a fast-path for unique anchors.
  • Bug Fixes

    • Added bounds and safety guards to prevent overflows and invalid memory-size cases.
  • Documentation

    • Updated docs and example to reflect the backward-search behavior changes.

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 617dadde-8be8-422f-bae5-e532e94bdabc

📥 Commits

Reviewing files that changed from the base of the PR and between 64425b3 and 2f61667.

📒 Files selected for processing (2)
  • prmi/examples/probe_audit.rs
  • prmi/src/index/spectrum.rs
✅ Files skipped from review due to trivial changes (1)
  • prmi/examples/probe_audit.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • prmi/src/index/spectrum.rs

📝 Walkthrough

Walkthrough

Refactors forward/backward SMEM search to use O(log n) maximal-prefix probes via new LCP helpers; mem_search gains a table-less maximal forward path, mem_search_backward uses an RC-based one-shot maximal extension with bounds checks, plus tests and a probe_audit collapse corpus example.

Changes

SMEM Spectrum Maximal Search Optimization

Layer / File(s) Summary
Forward maximal length helpers
prmi/src/index/spectrum.rs
New forward_maximal_len computes O(log n) maximal exact prefix length via model-window insertion and neighbor LCP probes; lcp_at provides single-neighbor LCP lookup.
mem_search forward maximal optimization
prmi/src/index/spectrum.rs
When no k-mer table is loaded, mem_search validates packed-pac sizing, computes maximal forward match via forward_maximal_len, and recovers SA interval with two find_boundary calls; retains .kmt path and uses a shared zero MemMatch for no-match.
mem_search_backward RC-based maximal refactor
prmi/src/index/spectrum.rs
Adds packed-pac fail-closed guards and a unique-anchor fast path delegating to mem_search_backward_from_hint. For ambiguous anchors, replaces per-base backward stepping with a reverse-complement prefix maximal search using forward_maximal_len; only when extension exceeds anchor_len it recovers the forward pattern interval via two find_boundary calls. Adds overflow/bounds checks and updates docs.
Fail-closed and bounds tests
prmi/src/index/spectrum.rs
Unit tests ensuring mem_search and mem_search_backward return zero (not panic) on undersized packed pac inputs and that backward path rejects overflowing anchor_len.
Backward-corpus collapse example and benchmarking
prmi/examples/probe_audit.rs
Adds a third "collapse" backward-corpus built over a repeat-centered window; updates backward summary printing to include collapse corpus size and mean anchor occupancy and adds bwd_maximal("collapse:", &bwork_collapse) to benchmarks.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • fg-labs/prmi#17: Overlapping changes to mem_search / mem_search_backward maximal-match logic.
  • fg-labs/prmi#20: Related probe_audit example additions and benchmarking harness used for backward-corpus shapes.

"🐰 I hopped through indices late at night,
sniffed LCPs by the pale moonlight;
reverse complements hummed a tuneful song,
maximal matches now leap along—hop on!"

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main change: introducing O(log n) cold path optimization for maximal exact match searches in both forward and backward directions.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@nh13
nh13 force-pushed the feat/v0.2-gallop branch from c55fd44 to e69c7aa Compare June 8, 2026 04:16
@nh13
nh13 force-pushed the feat/v0.2-olog branch from b45e567 to b03d107 Compare June 8, 2026 04:17
@nh13
nh13 force-pushed the feat/v0.2-gallop branch from e69c7aa to 97a3d91 Compare June 8, 2026 18:44
@nh13
nh13 force-pushed the feat/v0.2-olog branch from b03d107 to 982498b Compare June 8, 2026 18:44
@nh13
nh13 force-pushed the feat/v0.2-gallop branch from 97a3d91 to aee9e6e Compare June 8, 2026 21:13
@nh13
nh13 force-pushed the feat/v0.2-olog branch from 982498b to 7846639 Compare June 8, 2026 21:14
@nh13
nh13 force-pushed the feat/v0.2-gallop branch from aee9e6e to a6f30dc Compare June 8, 2026 22:45
@nh13
nh13 force-pushed the feat/v0.2-olog branch from 7846639 to 55959bc Compare June 8, 2026 22:46
@nh13
nh13 force-pushed the feat/v0.2-gallop branch from a6f30dc to 65a0e07 Compare June 9, 2026 15:53
@nh13
nh13 force-pushed the feat/v0.2-olog branch from 55959bc to 1cd00ed Compare June 9, 2026 15:53
@nh13
nh13 force-pushed the feat/v0.2-gallop branch from 65a0e07 to f17af9a Compare June 9, 2026 19:56
@nh13
nh13 force-pushed the feat/v0.2-olog branch from 1cd00ed to 7567dea Compare June 9, 2026 19:58
@nh13
nh13 force-pushed the feat/v0.2-gallop branch from f17af9a to f4b3e03 Compare June 10, 2026 00:21
@nh13
nh13 force-pushed the feat/v0.2-olog branch from 7567dea to 85b167e Compare June 10, 2026 00:22
@nh13
nh13 force-pushed the feat/v0.2-gallop branch from f4b3e03 to 399c6e1 Compare June 10, 2026 02:26
@nh13
nh13 force-pushed the feat/v0.2-olog branch from 85b167e to 7ee7fcd Compare June 10, 2026 02:27
@nh13
nh13 force-pushed the feat/v0.2-gallop branch from 399c6e1 to f2f09d1 Compare June 10, 2026 03:42
@nh13
nh13 force-pushed the feat/v0.2-olog branch from 7ee7fcd to d5baeaa Compare June 10, 2026 03:43
@nh13
nh13 force-pushed the feat/v0.2-gallop branch from f2f09d1 to 205717e Compare June 10, 2026 16:43
@nh13
nh13 force-pushed the feat/v0.2-olog branch from d5baeaa to c35cf62 Compare June 10, 2026 16:43
@nh13
nh13 force-pushed the feat/v0.2-gallop branch from 205717e to 89fa2e5 Compare June 10, 2026 18:28
@nh13
nh13 force-pushed the feat/v0.2-olog branch from c35cf62 to 3b40ea5 Compare June 10, 2026 18:29
@nh13
nh13 force-pushed the feat/v0.2-gallop branch from 89fa2e5 to 7c8ae2a Compare June 10, 2026 20:36
@nh13
nh13 force-pushed the feat/v0.2-olog branch from 3b40ea5 to faf5794 Compare June 10, 2026 20:40
Base automatically changed from feat/v0.2-gallop to main June 10, 2026 23:34
@nh13
nh13 force-pushed the feat/v0.2-olog branch from faf5794 to b9288d2 Compare June 10, 2026 23:35

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@prmi/src/index/spectrum.rs`:
- Around line 1463-1486: The new one-shot branches under the self.kmt.is_none()
path bypass forward_spectrum_* / backward_spectrum_* and thus must reintroduce
the packed-PAC fail-closed guard: before calling
tokenize_32mer/forward_maximal_len/lookup/find_boundary, check the PacEncoding
variant (PacEncoding::Packed) and validate the PAC size/stride the same way
forward_spectrum_* does (so packed PACs that are undersized cause an early
return of the zero/empty MemMatch rather than proceeding into
fill_doubled_chunk/pac_base_at(...).unwrap()). Implement this guard at the start
of the block (the branch that computes match_len, qm, pred, lower/upper) and
return zero if the packed check fails; reference the functions/paths involved:
forward_maximal_len, lookup, find_boundary, ref_less, shares_prefix,
fill_doubled_chunk, and pac_base_at to locate the relevant logic to mirror.
- Around line 1634-1643: In mem_search_backward: anchor_end is computed with
pivot + anchor_len as usize which truncates a u64 and can overflow; change to
try converting anchor_len to usize with usize::try_from and then use
pivot.checked_add to compute anchor_end, returning the zero-length MemMatch
early on conversion failure or overflow (same pattern used in
mem_search_backward_from_hint and the backward spectrum prototypes); update all
uses of anchor_end, anchor_len, and pivot in mem_search_backward to follow that
fail-closed behavior to avoid panics/wraps.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a08c8617-2e62-44f0-b953-b64afdfba49c

📥 Commits

Reviewing files that changed from the base of the PR and between df1f820 and b9288d2.

📒 Files selected for processing (2)
  • prmi/examples/probe_audit.rs
  • prmi/src/index/spectrum.rs

Comment thread prmi/src/index/spectrum.rs
Comment thread prmi/src/index/spectrum.rs
@nh13
nh13 force-pushed the feat/v0.2-olog branch from b9288d2 to 64425b3 Compare June 10, 2026 23:52
@nh13

nh13 commented Jun 11, 2026

Copy link
Copy Markdown
Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
prmi/src/index/spectrum.rs (1)

1697-1701: Clarify overflow risk in mem_search_backward depends on the 2-bit input contract

mem_search_backward operates on 2-bit encoded bases (0..=3): callers/docstrings for the spectrum APIs assume this, and tokenize_32mer debug-asserts v <= 3. Under that contract, 3 - b at line 1700 cannot overflow, and it’s the correct 2-bit reverse-complement. If malformed (bytes >3) inputs must be handled defensively, this code currently lacks a guard for the anchor span; mask/complement like (b & 0x3) ^ 0x3 (same semantics as reverse_complement_2bit) or validate and fail closed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@prmi/src/index/spectrum.rs` around lines 1697 - 1701, The slice reversal uses
3 - b assuming 2-bit bases, which is safe only if callers uphold that contract
(see mem_search_backward and tokenize_32mer); to be defensive either (a)
mask-and-complement each byte to 2 bits like (b & 0x3) ^ 0x3 (matching
reverse_complement_2bit semantics) when building q, or (b) validate the anchor
bytes are <= 3 and return an error/None on violation; update the code that
computes q (the read[q_start..anchor_end].iter().rev().map(...) chain) to use
one of these two approaches and ensure callers of
mem_search_backward/documentation reflect the chosen behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@prmi/src/index/spectrum.rs`:
- Around line 1697-1701: The slice reversal uses 3 - b assuming 2-bit bases,
which is safe only if callers uphold that contract (see mem_search_backward and
tokenize_32mer); to be defensive either (a) mask-and-complement each byte to 2
bits like (b & 0x3) ^ 0x3 (matching reverse_complement_2bit semantics) when
building q, or (b) validate the anchor bytes are <= 3 and return an error/None
on violation; update the code that computes q (the
read[q_start..anchor_end].iter().rev().map(...) chain) to use one of these two
approaches and ensure callers of mem_search_backward/documentation reflect the
chosen behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 371ecfc9-8d6c-49d4-9aca-6c69f07d6160

📥 Commits

Reviewing files that changed from the base of the PR and between b9288d2 and 64425b3.

📒 Files selected for processing (2)
  • prmi/examples/probe_audit.rs
  • prmi/src/index/spectrum.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • prmi/examples/probe_audit.rs

mem_search_backward routed every call through a per-base re-search loop — a
model launch plus two bounded searches at every left base, O(extension-length).
The consumer's zigzag calls it with a length-1 pivot anchor (occ ~ n/4), so the
interval is maximally ambiguous and the loop walks the entire ~16-20 bp head one
base at a time. BWA-MEME uses the same length-1 anchor yet costs ~3 probes/call,
because its extension is a single O(log) bounded search.

Two fast paths replace the loop:

- occ_count == 1: the 1-wide interval pins the genomic locus, so the maximal
  left extension is a probe-free leftward reference walk (doubled_base_at) plus
  one interval recovery — sa_start is itself a valid est_hint (422 -> 7
  probes/call).
- occ_count > 1: a single bounded LCP search. The maximal left extension is the
  longest SUFFIX of read[..anchor_end] that occurs, which is the longest PREFIX
  of its reverse-complement that occurs — and prefixes nest, so one maximal-
  prefix search finds the whole extension regardless of length. The 2x index
  already holds the RC suffixes and occ is strand-symmetric (occ(RC(P)) ==
  occ(P)), so match_len and occ are exact. forward_maximal_len finds the length
  (a model-launched insertion-point search plus the two neighbor LCPs — the
  deepest match is always adjacent to where the query sorts); the forward
  interval of the maximal pattern is then recovered for sa_start/occ.

Synthetic cold backward one-shot: genuine repeat (occ ~2028) 1880 -> 53,
collapse 16 -> 26, unique 422 -> 7. O(log n) for every occ>1 case, independent
of extension length. The per-base backward TRACE (backward_spectrum) is
unchanged; add the probe_audit "collapse" corpus to exercise the
ambiguous-head/unique-tail shape.

Byte-identical: match_len is the same maximal span and the interval is recovered
for the same pattern, proven against two independent oracles
(mem_search_backward_equals_maximal_backward_step,
mem_search_backward_hint_equals_unhinted). On-disk index format unchanged.
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