Skip to content

fix: platform-stable Hash for Seq, SeqSlice, and Kmer (#15) - #16

Merged
jeff-k merged 1 commit into
jeff-k:devfrom
werner291:fix/15-platform-stable-hash
Jul 26, 2026
Merged

fix: platform-stable Hash for Seq, SeqSlice, and Kmer (#15)#16
jeff-k merged 1 commit into
jeff-k:devfrom
werner291:fix/15-platform-stable-hash

Conversation

@werner291

@werner291 werner291 commented Apr 30, 2026

Copy link
Copy Markdown

Closes #15.

Fix

The bitvec Hash impl flagged in the issue is part of it —
bypassing it removes the per-bit hasher-write pattern. The remaining
platform variance comes from two paths in our own code:

  1. self.len().hash(state) and K.hash(state) route a usize
    through Hasher::write_usize — 4 bytes on 32-bit, 8 on 64-bit.
  2. Kmer::hash also hashed the entire backing BitArray, so
    unused high bits of the storage word were included in the digest.

Using u64::hash directly would still be endian-dependent (its
default write_u64 writes native-endian bytes), so the length goes
in as (len as u64).to_le_bytes() explicitly — fixing both the
pointer-width and endianness axes.

For the bit content, a new hash_bits helper extracts whole bytes
via BitField::load_le::<u8> over bs.chunks(8), batching writes
through a 64-byte buffer. Kmer::hash slices its bitarray to
K * A::BITS first so storage padding can't leak in.

Behavioral change: hash values for Seq/SeqSlice/Kmer change.
The Hash contract (equal values → equal hashes) is preserved;
existing equality-based hash tests still pass.

Tests

A small RecordingHasher captures every byte fed to the hasher.
Two new tests:

  • test_hash_byte_stream_invariants: for Dna (2-bit), Iupac
    (4-bit), Amino (6-bit), and text::Dna (8-bit), at lengths
    covering byte-aligned, partial-trailing-byte, and long inputs —
    Seq<A> and &SeqSlice<A> produce the same byte stream, and
    the stream's length and prefix match the structural contract.
  • test_kmer_hash_independent_of_storage_width: Kmer<Dna, 10, _>
    with usize/u64/u128 storage all hash identically and match
    the equivalent &SeqSlice.

Out of scope

Kmer<_, _, simd::Simd<u64, 4>> (avx2) and Kmer<_, _, wasm::v128>
still have todo!() Hash impls — pre-existing, would get the same
treatment once implemented. Happy to do a follow-up.

Happy to adjust the byte layout (length-prefix size, ordering, etc.)
if you'd prefer a different convention.

Hash for SeqSlice and Kmer fed `usize`-typed lengths and bitvec's
per-bit Hash to the hasher, producing different digests on 32- vs.
64-bit targets. Replace both impls with an explicit u64 LE length
prefix and a `hash_bits` helper that extracts whole bytes via
`BitField::load_le::<u8>`, sidestepping bitvec's Hash and the usize
storage width. Truncate Kmer's backing bitarray to K * A::BITS so
storage padding can't leak into the digest.

Tests use a RecordingHasher to capture the byte stream and assert
contract-level invariants (cross-route equivalence between Seq /
&SeqSlice / Kmer, structural length, padding-independence across
storage widths) over a curated set of codecs and lengths.
@werner291
werner291 force-pushed the fix/15-platform-stable-hash branch from 19ef947 to bea4368 Compare April 30, 2026 19:04

@jeff-k jeff-k left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks for the PR!

Comment thread bio-seq/src/hash.rs
Comment on lines +1 to +5
// Copyright 2021-2024 Jeff Knaggs
// Licensed under the MIT license (http://opensource.org/licenses/MIT)
// This file may not be copied, modified, or distributed
// except according to those terms.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Don't need this ;)

Comment thread bio-seq/src/hash.rs
let mut buf = [0u8; 64];
let mut len = 0;
for chunk in bs.chunks(8) {
buf[len] = chunk.load_le::<u8>();

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I wonder if it might make sense to always use u64 and treat u32 as a special case. But this is certainly better than writing bits!

Comment thread bio-seq/src/seq.rs
}

#[test]
fn test_kmer_hash_independent_of_storage_width() {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

👍

Comment thread bio-seq/src/seq.rs
Comment on lines +1205 to +1208
impl Hasher for RecordingHasher {
fn finish(&self) -> u64 { 0 }
fn write(&mut self, bytes: &[u8]) { self.0.extend_from_slice(bytes); }
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

this is handy for the testing, maybe we could find a way to incorporate it into the storage type more closely and design an API that make rolling hashes more natural to express while we're at it

@codecov-commenter

Copy link
Copy Markdown

Welcome to Codecov 🎉

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

Thanks for integrating Codecov - We've got you covered ☂️

@jeff-k
jeff-k changed the base branch from main to dev May 17, 2026 11:16
@jeff-k
jeff-k merged commit 671b207 into jeff-k:dev Jul 26, 2026
2 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.

Hashing gives different results on 32-bit platforms

3 participants