Skip to content

One indexing modality: port of C++ SSHash v6.0.0 (0.7.0, index format 5.0) - #5

Merged
rob-p merged 14 commits into
mainfrom
feat/v6-one-modality
Aug 30, 2026
Merged

rob-p merged 14 commits into
mainfrom
feat/v6-one-modality

Conversation

@rob-p

@rob-p rob-p commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Ports C++ SSHash v6.0.0 (jermp/sshash#93): the minimizer becomes the locus minimizing h(canonical m-mer), with the centre-closest tie-break — mirror-equivariant AND forward, giving canonical answers at plain forward density. One build path, one lookup path, one streaming query (with the v6 same-minimizer memos). Index format (4,0)→(5,0); the header now records the build seed and a hasher-drift guard. --canonical is accepted for one release and warns; the forward-only query family keeps C++ v6 semantics (restrict answers to forward-orientation occurrences).

Also fixes two latent bugs: K≥33 streaming produced wrong results in release builds (u64 truncation of u128 k-mer state — the analogue of C++ 87cab5f, proven wrong against its own point lookups), and non-default build seeds produced indices that returned zero hits (queries hard-coded seed 1).

Validation (details in commit messages and scripts/):

  • property tests vs an independent from-the-spec reference (mirror-equivariance, forwardness, incremental==batch), heavy grid in CI
  • per-k-mer dumps byte-identical to a 0.6.4 oracle over ~30M lookups, and byte-identical to C++ v6 itself at k=31/47/63
  • new save/load-roundtrip, build-determinism, external-sort-path, and u128-streaming tests; first CI workflow (clippy -D warnings clean); MSRV corrected to an honest 1.88
  • downstream: salmon quant.sf and RAD outputs bit-identical vs released v2.6.0, at sample_data and GRCh38×ERR188044 scale

Perf vs 0.6.4 canonical: streaming −33%, build −12%, index bytes −8..13%, point lookups 77.7 vs 79.1 ns/kmer (an initial regression was branch misprediction from the tie tracking; fixed branchlessly).

Release order: this publishes first (./bump_and_publish.sh 0.7.0 --publish --allow-same-version), then piscem-rs 0.10 → piscem 0.23 / salmon → simpleaf 0.29.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NknHxdevZi243yQ5sKaffu

rob-p and others added 14 commits August 29, 2026 20:24
Two K>=33 bugs, both silent until now because nothing streamed wide K:

- try_extend extracted the newest base via to_u64(bits) >> (2*(K-1)):
  to_u64 truncates u128 storage and the shift overflows u64 for K >= 33
  (debug panic, masked garbage in release -> possible false-positive
  extension). Analogue of C++ sshash 87cab5f. Extract via get_base,
  which shifts in storage width.
- append_base built its K-base mask as (1u64 << 2K) - 1, overflowing
  for K >= 33; build it in u128 and truncate through from_u128.

New tests/u128_streaming.rs streams both strands at K in {33,47,63}
and asserts every streamed LookupResult equals the point lookup, with
num_extensions > 0 so the extension path is provably exercised.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NknHxdevZi243yQ5sKaffu
Port of C++ SSHash v6.0.0 (jermp/sshash 6c53ae9 + b1d0706 + 2cafe28 +
c22c897). The minimizer of a k-mer x is now the locus i minimizing
h(kappa(i)), kappa(i) = min(mmer_i, rc(mmer_i)): mirror-equivariant (x
and rc(x) share a bucket, one probe, orientation reported) at plain
forward density 2/(k-m+2). The old canonical mode paid a 4/3 super-kmer
density factor for the same answers; the regular mode answered less.
Both are gone: one build path, one lookup path, one streaming query.

- minimizer.rs: rewritten around canonical_mmer_at (one whole-kmer RC +
  shifts via rc(x_i) = rc(x)_{K-m-i}), shared compute_minimizer batch
  reference, centre-closest tie-break (Cologni & Pibiri Prop. 24 —
  forward AND mirror-equivariant; outlined resolve_tie), incremental
  MinimizerIterator with num_mins tie tracking and a debug assert
  against the batch reference on every call. MinimizerIteratorRc
  deleted.
- builder: both tuple-extraction paths collapse to the single iterator
  (the old canonical branch ran a fresh full-rescan RC iterator per
  k-mer); forwardness is enforced where the index is sized — duplicate
  (minimizer, pos_in_seq) aborts the build ('the minimizer scheme is
  not forward') in classification, external merge, and FileBucketIter;
  all dedupe loops become straight writes.
- dictionary.rs: the regular/canonical dual lookup family (~300 lines)
  becomes one lookup_with_minimizer core — single probe, exactly two
  candidate starts j - p and j - (K-m-p); new m-mer presence check
  (decode_mmer_at) makes minimizer_found reliable for non-heavy
  buckets (heavy stays true, port of the C++ HEAVYLOAD rule); skew
  MPHF keys are canonical unconditionally. Fixes a latent seed bug:
  queries now hash with the build seed instead of hard-coded 1.
- streaming_query.rs: single minimizer iterator (RC k-mer is already
  maintained); same-minimizer memos ported — minimizer-absent negative,
  singleton same-occurrence skip (falls through for self-RC minimizers,
  even m), and a <=8-entry decoded-positions bucket cache verified via
  lookup_at_positions; seed outlined; debug assert streaming == point
  on every call; new counters exposed in StreamingQueryStats.
- serialization: FORMAT_VERSION (4,0) -> (5,0), indices must be
  rebuilt; header gains seed + hasher_magic (recomputed and compared on
  load, so a rapidhash behavior change fails loudly instead of
  silently corrupting minimizer selection — the guard flagged in
  Cargo.toml's rapidhash pin comment).
- API: BuildConfiguration.canonical and Dictionary::canonical()
  deprecated no-ops; StreamingQuery::new keeps its 3-arg signature
  (flag ignored) + new with_seed; Dictionary::new takes seed instead
  of canonical; CLI --canonical warns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NknHxdevZi243yQ5sKaffu
…minism)

- tests/minimizer_properties.rs: independent from-the-spec brute-force
  reference checked against compute_minimizer and MinimizerIterator —
  mirror-equivariance, incremental==batch==reference on every window,
  forwardness (sampled position never decreases), and a ties-actually-
  fire guard at small m so the tie-break cannot silently go untested.
  Default grid runs in ~1s; heavy grid behind --ignored (verified in
  release).
- tests/dictionary_roundtrip.rs: full save/load LookupResult equality
  (both strands + absent probes), non-default-seed roundtrip
  (regression for the seed bug), and (4,0)-era header rejection with a
  'rebuild' message.
- tests/build_paths_and_determinism.rs: in-memory vs forced external-
  sort builds answer identically AND serialize byte-identically; two
  builds of the same input are byte-identical. New hidden
  BuildConfiguration.force_external_sort test hook.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NknHxdevZi243yQ5sKaffu
…ript

- dump now prints all LookupResult fields and supports --point (point
  lookups instead of the streaming engine), making per-kmer dumps
  byte-diffable across versions and implementations.
- scripts/oracle_diff.sh: the old-vs-new equivalence gate. Run against
  a 0.6.4 oracle binary (same dump patch on a throwaway worktree
  branch): builds old-canonical and new indices on shared unitig
  inputs (salmonella/ecoli k31 at m=13/16/20; se.ust k47/k63 at m=21)
  and diffs per-kmer dumps for self-queries and error-injected reads.
  Result on all cases (~30M lines): old point == new point
  byte-identical; new stream == new point on the six semantic fields;
  old stream == new stream for K<=31. At K>31 the OLD streaming was
  producing wrong results (the release-mode wide-K truncation bug this
  port fixes) — verified wrong against its own point lookups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NknHxdevZi243yQ5sKaffu
Compile against a C++ sshash v6.0.0 checkout (include dirs per its
CMakeLists, link libsshash_static + z) to dump per-kmer lookup results
in the same TSV as `sshash dump --point`. Cross-diff result: byte-
identical to the Rust port on salmonella/ecoli k31 (m=16) and
se.ust.k47/k63 (m=21, 128-bit build), self-queries and error reads —
all 9 columns, >10M kmers. Their test_minimizer and build --check
suites also pass on the shared inputs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NknHxdevZi243yQ5sKaffu
…e scan loop

- Point lookups (no bucket cache) verify straight off the offsets array
  instead of zeroing a 512-byte stack buffer per call; the decoded
  locate set is written directly into the streaming cache when one is
  provided.
- compute_minimizer's hot loop matches C++'s shape: the forward m-mer
  comes from a running window (constant shift), only the RC pays a
  variable shift. Byte-identical indices and dumps.

Measured on salmonella k31 m16 vs the 0.6.4 canonical baseline
(best-of-3): build 0.43s -> 0.35s (-19%, peak RSS -14%); streaming
15.1 -> 10.0 ns/kmer on 1%-error reads and 8.9 -> 5.9 on error-free
positive streams (-33%); total index bytes -8..-13% across the case
grid. Point lookups are ~100 ns/kmer vs 79 for the old dual-scan
canonical path (the memo-soundness presence check accounts for ~5 ns;
the rest is the scheme's tie tracking) — still 25% faster than C++ v6
on the same machine/data, and point lookups are not on the piscem/
salmon hot path (streaming is).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NknHxdevZi243yQ5sKaffu
Adds the repo's first test workflow: debug tests (all invariant asserts
active), release tests, the heavy minimizer property grid, and an
informational clippy pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NknHxdevZi243yQ5sKaffu
Addresses the newer-toolchain lints properly instead of softening the
gate: darray enumerate + needless return, manual div_ceil in offsets,
redundant cast, as_bytes-after-slice in the CLI dump, auto-deref in
sshash-py, hex-literal grouping in the prefilter test, and every
in-workspace use of the deprecated canonical field/method (tests build
with the default config now; stale canonical-mode doc comments in
minimizer_tuples updated to describe the unified scheme). The only
remaining note is cargo's future-incompat report for proc-macro-error2
(transitive via sux -> impl-tools; upstream).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NknHxdevZi243yQ5sKaffu
…at 0.6.4

The point-lookup regression (79 -> 100 ns/kmer) was not the scheme's
extra work — the new code executes 10% FEWER instructions than the old
dual-scan canonical path. It was branch misprediction: the old loop's
single 'if hash < min' with three assignments if-converts to cmovs,
but the tie-tracking turned it into an 'if < / else if ==' chain,
which LLVM leaves as real branches on a data-dependent comparison
(measured: IPC 5.10 -> 3.66, branch misses 17.6M -> 114.2M over 24M
k-mers, ~4 extra misses x ~20 cycles = the whole gap).

Writing the update branchlessly (selects -> cmovs, tie/rightmost as
flag arithmetic) in both compute_minimizer and the iterator's rescan:
point lookups 77.7 ns/kmer (old dual-scan: 79.1; C++ v6: 134 on this
machine), streaming 9.9 ns/kmer and build time unchanged, branch
misses back to 13.4M (below the old baseline). Index bytes and
per-kmer dumps are byte-identical; full test suite + property grid
pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NknHxdevZi243yQ5sKaffu
Sweep of options that lost meaning under the unified canonical scheme;
per policy they stay accepted for one release and warn when used:

- sshash-py: forward_only=True on Dictionary.lookup/query/contains and
  the BuildConfig.canonical setter now emit Python DeprecationWarnings
  (results unaffected); docstrings updated. Also fixes the lookup()
  docstring, which claimed the global k-mer ID while the method has
  always returned the absolute base offset (kmer_offset).
- sshash-cli: --forward-only help text now says deprecated/no-effect
  (the runtime warnings for --canonical and --forward-only were
  already in place).
- crates/sshash-py/tests/test_basic.py: self-contained pytest suite
  (build from python, fwd/rc/absent point queries, streaming==point,
  save/load roundtrip, warning assertions) — 7 tests, run via
  maturin develop && pytest.

Runtime-validated against the cross-checked salmonella index: 500 fwd
+ 500 rc python point queries match the oracle dump field-for-field,
streaming matches with extensions active.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NknHxdevZi243yQ5sKaffu
The previous commit wrongly deprecated --forward-only/forward_only= as
no-ops. C++ v6 kept the flag meaningful on the unified index:
check_reverse_complement = false runs the ordinary single-probe lookup
and reports not-found when the match was in backward orientation —
restricting answers to k-mers occurring in forward orientation in the
indexed strings. This also coincides exactly with what the flag meant
on the removed regular mode, so pre-port strand-specific users keep
their semantics. (The old canonical mode documented the flag as
ignored; it is now honored, which is what the name promises.)

- Dictionary::{query_forward, lookup_forward, query_checked,
  lookup_checked, lookup_forward_with_orientation} implement the
  orientation filter instead of aliasing the default query.
- CLI --forward-only un-deprecated (real help text, no warning); the
  --streaming incompatibility error stays.
- sshash-py forward_only= un-deprecated: docstrings describe the
  restriction, no DeprecationWarning (BuildConfig.canonical keeps its
  warning — that one is genuinely dead).
- Unit test asserts fwd-found/rc-filtered through every entry point;
  pytest test_forward_only_restricts_orientation does the same from
  python and asserts no warnings. CLI smoke: fwd k-mer Found:1 both
  ways; rc k-mer Found:1 default, Found:0 with --forward-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NknHxdevZi243yQ5sKaffu
…ADME fixes

- bump_and_publish.sh gains --allow-same-version: the workspace was
  bumped to 0.7.0 ahead of release so downstream [patch.crates-io]
  path overrides resolve during development, which the script's
  same-version guard would otherwise reject; the flag skips the bump
  edits and bump commit but still runs check/publish/tag/push.
- sshash-cli reported a hardcoded 0.1.0 for --version; now
  env!(CARGO_PKG_VERSION).
- README: drop the --canonical build example (always canonical now)
  and the regular/canonical wording in the correctness notes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NknHxdevZi243yQ5sKaffu
- rust-version was declared 1.85 but tiny-dict has used let-chains
  (stable 1.88) since the prefilter work; cargo publish never checks
  MSRV so this shipped silently. Workspace floor is now 1.88 (verified:
  sshash-lib alone still compiles on 1.85; full workspace clean on
  1.88). Downstream salmon's MSRV is 1.91, so nothing tightens there.
- cargo doc --no-deps is now warning-free: six pre-existing unresolved
  intra-doc links fixed (Self:: qualification, a renamed helper, and an
  extras[i] parsed as a link).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NknHxdevZi243yQ5sKaffu
The rust-version bump to 1.88 enabled clippy's let-chain collapsing and
is_multiple_of lints; apply them (dictionary cache block, external-sort
merge loop, config validation, CLI progress check). No behavior change;
clippy -D warnings clean again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NknHxdevZi243yQ5cSaffu
@rob-p
rob-p merged commit 55ef50b into main Aug 30, 2026
1 check 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