-
Notifications
You must be signed in to change notification settings - Fork 7
fix: platform-stable Hash for Seq, SeqSlice, and Kmer (#15) #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| // 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. | ||
|
|
||
| //! Platform-stable hashing for bit-packed sequences. | ||
|
|
||
| use crate::Bs; | ||
| use bitvec::field::BitField; | ||
| use core::hash::Hasher; | ||
|
|
||
| /// Feed a bit slice into a hasher as a stable, platform-independent byte stream. | ||
| /// | ||
| /// Bits are packed least-significant-first into bytes, matching the in-memory | ||
| /// `Lsb0` layout. Sidestepping `bitvec`'s `Hash` impl (which routes bits | ||
| /// through `bool::hash`, one hasher write per bit) and the `usize` storage | ||
| /// width keeps the byte stream identical on 32- and 64-bit targets. | ||
| #[inline] | ||
| pub(crate) fn hash_bits<H: Hasher>(bs: &Bs, state: &mut H) { | ||
| // Extract whole bytes via `BitField::load_le::<u8>()`, which lets bitvec | ||
| // do the bit-shuffle on the underlying storage word. Buffer them so the | ||
| // hasher sees one bulk write per 64-byte block instead of one per byte. | ||
| // `load_le` zero-pads the high bits of any partial trailing chunk. | ||
| let mut buf = [0u8; 64]; | ||
| let mut len = 0; | ||
| for chunk in bs.chunks(8) { | ||
| buf[len] = chunk.load_le::<u8>(); | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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! |
||
| len += 1; | ||
| if len == buf.len() { | ||
| state.write(&buf); | ||
| len = 0; | ||
| } | ||
| } | ||
| if len > 0 { | ||
| state.write(&buf[..len]); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1199,6 +1199,61 @@ mod tests { | |
| assert_eq!(hash1, hash2); | ||
| } | ||
|
|
||
| #[derive(Default)] | ||
| struct RecordingHasher(Vec<u8>); | ||
|
|
||
| impl Hasher for RecordingHasher { | ||
| fn finish(&self) -> u64 { 0 } | ||
| fn write(&mut self, bytes: &[u8]) { self.0.extend_from_slice(bytes); } | ||
| } | ||
|
Comment on lines
+1205
to
+1208
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
| fn record<T: Hash>(value: &T) -> Vec<u8> { | ||
| let mut h = RecordingHasher::default(); | ||
| value.hash(&mut h); | ||
| h.0 | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_hash_byte_stream_invariants() { | ||
| // Same logical content must produce the same byte stream regardless of | ||
| // how it is spelt (Seq vs &SeqSlice) and across a range of codecs and | ||
| // lengths — including ones with partial trailing bytes and ones long | ||
| // enough to cross the internal flush buffer in `hash_bits`. | ||
| fn check<A: Codec>(s: &str) { | ||
| let seq: Seq<A> = s.try_into().unwrap_or_else(|_| panic!("parse {s:?}")); | ||
| let bytes = record(&seq); | ||
| assert_eq!(record::<&SeqSlice<A>>(&&seq[..]), bytes); | ||
|
|
||
| // Structural: 8-byte LE length prefix, then ceil(bits / 8) bytes. | ||
| let n = seq.len(); | ||
| let body = (n * A::BITS as usize).div_ceil(8); | ||
| assert_eq!(bytes.len(), 8 + body); | ||
| assert_eq!(&bytes[..8], &(n as u64).to_le_bytes()); | ||
| } | ||
| check::<Dna>("A"); | ||
| check::<Dna>("ACGT"); | ||
| check::<Dna>("ACGTA"); | ||
| check::<Dna>(&"ACGT".repeat(80)); // > 64-byte flush in `hash_bits` | ||
| check::<Iupac>("AC"); | ||
| check::<Iupac>("ACG"); | ||
| check::<Iupac>("NRYKBDHV"); | ||
| check::<Amino>("MWLLP"); // 6-bit codec, partial trailing byte | ||
| check::<text::Dna>("ACGT"); // 8-bit codec, byte-aligned | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_kmer_hash_independent_of_storage_width() { | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 |
||
| let seq: Seq<Dna> = "ACGTACGTAC".try_into().unwrap(); | ||
| let slice: &SeqSlice<Dna> = &seq[..]; | ||
| let bytes = record(&slice); | ||
| let km_usize: Kmer<Dna, 10, usize> = slice.try_into().unwrap(); | ||
| let km_u64: Kmer<Dna, 10, u64> = slice.try_into().unwrap(); | ||
| let km_u128: Kmer<Dna, 10, u128> = slice.try_into().unwrap(); | ||
| assert_eq!(record(&km_usize), bytes); | ||
| assert_eq!(record(&km_u64), bytes); | ||
| assert_eq!(record(&km_u128), bytes); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_prepend() { | ||
| let mut seq1 = | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't need this ;)