From 0c7ccd29bcf8492bbb80399e51d4fb743f1a574b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:35:24 -0300 Subject: [PATCH 1/4] refactor(storage): dedupe PayloadBuffer proofs by subsumption on push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintain each `PayloadBuffer` entry as an antichain under the subset relation on participants. On push: - skip if incoming participants are a subset (incl. equal) of any existing proof (adds no coverage); - otherwise remove existing proofs whose participants are a strict subset of the incoming one, then insert. This generalises the prior equality-only dedup and eliminates redundant proofs that otherwise accumulate after aggregation produces a superset of its child proofs, or when a block embeds a proof subsumed by one already in `known_payloads`. Validator coverage is monotonic across pushes, so `update_head`'s reads of `known_payloads` are never temporarily degraded. Affects every insert path transparently — `apply_aggregated_group`, `on_gossip_aggregated_attestation`, `on_block_core`, and `promote_new_aggregated_payloads` — without touching their call sites. --- crates/storage/src/store.rs | 258 ++++++++++++++++++++++++++++++++++-- 1 file changed, 250 insertions(+), 8 deletions(-) diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index c15efe0..0c22b05 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -126,18 +126,45 @@ impl PayloadBuffer { } } - /// Insert proofs for an attestation, FIFO-evicting oldest data_roots when total proofs reach capacity. + /// Insert a proof, maintaining the antichain invariant per data_root. + /// + /// Each data_root entry holds proofs whose participant sets are pairwise + /// incomparable under the subset relation. On push: + /// + /// - If the incoming proof's participants are a subset (incl. equal) of + /// any existing proof, the incoming proof is redundant and skipped. + /// - Otherwise, any existing proof whose participants are a strict subset + /// of the incoming proof's is removed before inserting. + /// + /// This subsumes exact-equality dedup and keeps buffers bounded when an + /// aggregator produces a proof that supersedes prior children. + /// + /// FIFO-evicts oldest data_roots when `total_proofs` exceeds capacity. fn push(&mut self, hashed: HashedAttestationData, proof: AggregatedSignatureProof) { let (data_root, att_data) = hashed.into_parts(); + let new_set: HashSet = proof.participant_indices().collect(); + if let Some(entry) = self.data.get_mut(&data_root) { - // Skip duplicate proofs (same participants) - if entry - .proofs - .iter() - .any(|p| p.participants == proof.participants) - { - return; + let mut to_remove: Vec = Vec::new(); + for (i, p) in entry.proofs.iter().enumerate() { + let existing_set: HashSet = p.participant_indices().collect(); + // Incoming is subsumed by an existing proof (incl. equal) — skip. + if new_set.is_subset(&existing_set) { + return; + } + // Existing is a strict subset of incoming — mark for removal. + // (Non-strict equality was handled by the check above.) + if existing_set.is_subset(&new_set) { + to_remove.push(i); + } + } + + // Remove subsumed proofs (reverse order so earlier indices stay valid). + for i in to_remove.into_iter().rev() { + entry.proofs.swap_remove(i); + self.total_proofs -= 1; } + entry.proofs.push(proof); self.total_proofs += 1; } else { @@ -1628,6 +1655,17 @@ mod tests { AggregatedSignatureProof::empty(bits) } + /// Create a proof with bits set for every validator in `vids`. + fn make_proof_for_validators(vids: &[u64]) -> AggregatedSignatureProof { + use ethlambda_types::attestation::AggregationBits; + let max = vids.iter().copied().max().unwrap_or(0) as usize; + let mut bits = AggregationBits::with_length(max + 1).unwrap(); + for &v in vids { + bits.set(v as usize, true).unwrap(); + } + AggregatedSignatureProof::empty(bits) + } + fn make_att_data(slot: u64) -> AttestationData { AttestationData { slot, @@ -1751,6 +1789,210 @@ mod tests { assert_eq!(cloned.known_payloads.lock().unwrap().len(), 1); } + #[test] + fn payload_buffer_push_superset_removes_strict_subset() { + let mut buf = PayloadBuffer::new(10); + let data = make_att_data(1); + let data_root = data.hash_tree_root(); + + buf.push( + HashedAttestationData::new(data.clone()), + make_proof_for_validators(&[1, 2]), + ); + buf.push( + HashedAttestationData::new(data), + make_proof_for_validators(&[1, 2, 3]), + ); + + assert_eq!(buf.total_proofs, 1); + assert_eq!(buf.data[&data_root].proofs.len(), 1); + let kept: HashSet = buf.data[&data_root].proofs[0] + .participant_indices() + .collect(); + assert_eq!(kept, HashSet::from([1, 2, 3])); + } + + #[test] + fn payload_buffer_push_subset_is_skipped() { + let mut buf = PayloadBuffer::new(10); + let data = make_att_data(1); + let data_root = data.hash_tree_root(); + + buf.push( + HashedAttestationData::new(data.clone()), + make_proof_for_validators(&[1, 2, 3]), + ); + buf.push( + HashedAttestationData::new(data), + make_proof_for_validators(&[1, 2]), + ); + + assert_eq!(buf.total_proofs, 1); + assert_eq!(buf.data[&data_root].proofs.len(), 1); + let kept: HashSet = buf.data[&data_root].proofs[0] + .participant_indices() + .collect(); + assert_eq!(kept, HashSet::from([1, 2, 3])); + } + + #[test] + fn payload_buffer_push_equal_participants_is_skipped() { + let mut buf = PayloadBuffer::new(10); + let data = make_att_data(1); + let data_root = data.hash_tree_root(); + + buf.push( + HashedAttestationData::new(data.clone()), + make_proof_for_validators(&[1, 2]), + ); + buf.push( + HashedAttestationData::new(data), + make_proof_for_validators(&[1, 2]), + ); + + assert_eq!(buf.total_proofs, 1); + assert_eq!(buf.data[&data_root].proofs.len(), 1); + } + + #[test] + fn payload_buffer_push_incomparable_proofs_coexist() { + let mut buf = PayloadBuffer::new(10); + let data = make_att_data(1); + let data_root = data.hash_tree_root(); + + buf.push( + HashedAttestationData::new(data.clone()), + make_proof_for_validators(&[1, 2]), + ); + buf.push( + HashedAttestationData::new(data), + make_proof_for_validators(&[3, 4]), + ); + + assert_eq!(buf.total_proofs, 2); + assert_eq!(buf.data[&data_root].proofs.len(), 2); + } + + #[test] + fn payload_buffer_push_superset_absorbs_multiple_subsets() { + let mut buf = PayloadBuffer::new(10); + let data = make_att_data(1); + let data_root = data.hash_tree_root(); + + // Three pairwise-incomparable singletons: all retained. + buf.push( + HashedAttestationData::new(data.clone()), + make_proof_for_validators(&[1]), + ); + buf.push( + HashedAttestationData::new(data.clone()), + make_proof_for_validators(&[2]), + ); + buf.push( + HashedAttestationData::new(data.clone()), + make_proof_for_validators(&[3]), + ); + assert_eq!(buf.total_proofs, 3); + + // Superset push absorbs all three at once. + buf.push( + HashedAttestationData::new(data), + make_proof_for_validators(&[1, 2, 3]), + ); + + assert_eq!(buf.total_proofs, 1); + assert_eq!(buf.data[&data_root].proofs.len(), 1); + // `order` still contains the single entry. + assert_eq!(buf.order.len(), 1); + assert_eq!(buf.order.front().copied(), Some(data_root)); + } + + #[test] + fn payload_buffer_push_mixed_kept_and_removed() { + let mut buf = PayloadBuffer::new(10); + let data = make_att_data(1); + let data_root = data.hash_tree_root(); + + buf.push( + HashedAttestationData::new(data.clone()), + make_proof_for_validators(&[1, 2]), + ); + buf.push( + HashedAttestationData::new(data.clone()), + make_proof_for_validators(&[5, 6]), + ); + buf.push( + HashedAttestationData::new(data), + make_proof_for_validators(&[1, 2, 3]), + ); + + assert_eq!(buf.total_proofs, 2); + + let sets: HashSet> = buf.data[&data_root] + .proofs + .iter() + .map(|p| { + let mut v: Vec = p.participant_indices().collect(); + v.sort_unstable(); + v + }) + .collect(); + assert!(sets.contains(&vec![5, 6])); + assert!(sets.contains(&vec![1, 2, 3])); + } + + #[test] + fn payload_buffer_push_cross_data_root_independence() { + let mut buf = PayloadBuffer::new(10); + let data_a = make_att_data(1); + let data_b = make_att_data(2); + let root_a = data_a.hash_tree_root(); + let root_b = data_b.hash_tree_root(); + + buf.push( + HashedAttestationData::new(data_a), + make_proof_for_validators(&[1, 2, 3]), + ); + buf.push( + HashedAttestationData::new(data_b), + make_proof_for_validators(&[1, 2]), + ); + + // Different data_roots → no cross-entry subsumption. + assert_eq!(buf.total_proofs, 2); + assert_eq!(buf.data[&root_a].proofs.len(), 1); + assert_eq!(buf.data[&root_b].proofs.len(), 1); + } + + #[test] + fn payload_buffer_push_fifo_eviction_uses_total_proofs() { + let mut buf = PayloadBuffer::new(2); + let data_a = make_att_data(1); + let data_b = make_att_data(2); + let data_c = make_att_data(3); + let root_a = data_a.hash_tree_root(); + let root_c = data_c.hash_tree_root(); + + buf.push( + HashedAttestationData::new(data_a), + make_proof_for_validators(&[1]), + ); + buf.push( + HashedAttestationData::new(data_b), + make_proof_for_validators(&[2, 3]), + ); + // total_proofs == 3, over capacity → evict oldest (root_a). + // Pushing a third distinct data_root triggers eviction via capacity. + buf.push( + HashedAttestationData::new(data_c), + make_proof_for_validators(&[4]), + ); + + assert!(!buf.data.contains_key(&root_a)); + assert!(buf.data.contains_key(&root_c)); + assert_eq!(buf.total_proofs, 2); + } + // ============ GossipSignatureBuffer Tests ============ fn make_dummy_sig() -> ValidatorSignature { From edcd9c9c095f75b2f4032290e0d66b9572731e44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:21:56 -0300 Subject: [PATCH 2/4] refactor(storage): bytewise subset check in PayloadBuffer::push Replace the per-push `HashSet` allocations in `PayloadBuffer::push` with a byte-level subset check on `AggregationBits` (`bits_is_subset`). The push path runs once per gossip-received aggregate on the aggregated-attestation ingest path, where allocating two HashSets per existing proof was unnecessary heap pressure on a peer-fed code path. The bytewise variant compares raw bitfield bytes directly: SSZ decoding already rejects non-zero padding above the delimiter, so unset bits read as zero in `as_bytes()` without further normalization. Also document the antichain invariant that lets the early-return path skip the removal loop, and add a corner-case test for empty-participant proofs (subsumed by anything, so trivially absorbed or skipped). Addresses review feedback on PR #313. --- crates/common/types/src/attestation.rs | 21 +++++++++++ crates/storage/src/store.rs | 49 ++++++++++++++++++++++---- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/crates/common/types/src/attestation.rs b/crates/common/types/src/attestation.rs index 254ac16..c9469a9 100644 --- a/crates/common/types/src/attestation.rs +++ b/crates/common/types/src/attestation.rs @@ -86,6 +86,27 @@ pub fn validator_indices(bits: &AggregationBits) -> impl Iterator + }) } +/// Returns `true` iff every bit set in `a` is also set in `b` (i.e., participants(a) ⊆ participants(b)). +/// +/// Operates byte-wise on the raw bitfield representation, avoiding the per-call +/// `HashSet` allocation that index-iteration would require. Safe because SSZ +/// decoding rejects bitlists with non-zero padding above the delimiter, so any +/// bit not in `b` reliably reads as zero in `b.as_bytes()`. +pub fn bits_is_subset(a: &AggregationBits, b: &AggregationBits) -> bool { + let a_bytes = a.as_bytes(); + let b_bytes = b.as_bytes(); + for (i, &a_byte) in a_bytes.iter().enumerate() { + if a_byte == 0 { + continue; + } + let b_byte = b_bytes.get(i).copied().unwrap_or(0); + if a_byte & !b_byte != 0 { + return false; + } + } + true +} + /// Aggregated attestation with its signature proof, used for gossip on the aggregation topic. #[derive(Debug, Clone, SszEncode, SszDecode, HashTreeRoot)] pub struct SignedAggregatedAttestation { diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index cde5845..0f74dd8 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -10,7 +10,7 @@ static EMPTY_BODY_ROOT: LazyLock = LazyLock::new(|| BlockBody::default().h use crate::api::{StorageBackend, StorageWriteBatch, Table}; use ethlambda_types::{ - attestation::{AttestationData, HashedAttestationData}, + attestation::{AttestationData, HashedAttestationData, bits_is_subset}, block::{ AggregatedSignatureProof, Block, BlockBody, BlockHeader, BlockSignatures, SignedBlock, }, @@ -142,19 +142,24 @@ impl PayloadBuffer { /// FIFO-evicts oldest data_roots when `total_proofs` exceeds capacity. fn push(&mut self, hashed: HashedAttestationData, proof: AggregatedSignatureProof) { let (data_root, att_data) = hashed.into_parts(); - let new_set: HashSet = proof.participant_indices().collect(); if let Some(entry) = self.data.get_mut(&data_root) { + // Subset checks operate byte-wise on AggregationBits to avoid per-call + // HashSet allocation on the aggregated-attestation ingest path. + // + // Antichain invariant on `entry.proofs`: surviving proofs are pairwise + // incomparable. Hence the early-return path below cannot have populated + // `to_remove`: if some `X ⊆ new ⊆ existing` had been marked, then + // `X ⊆ existing` would already violate the invariant. let mut to_remove: Vec = Vec::new(); for (i, p) in entry.proofs.iter().enumerate() { - let existing_set: HashSet = p.participant_indices().collect(); // Incoming is subsumed by an existing proof (incl. equal) — skip. - if new_set.is_subset(&existing_set) { + if bits_is_subset(&proof.participants, &p.participants) { return; } // Existing is a strict subset of incoming — mark for removal. - // (Non-strict equality was handled by the check above.) - if existing_set.is_subset(&new_set) { + // (Non-strict equality was ruled out by the check above.) + if bits_is_subset(&p.participants, &proof.participants) { to_remove.push(i); } } @@ -1918,6 +1923,38 @@ mod tests { assert!(sets.contains(&vec![1, 2, 3])); } + #[test] + fn payload_buffer_push_empty_participants_subsumed_by_anything() { + let mut buf = PayloadBuffer::new(10); + let data = make_att_data(1); + let data_root = data.hash_tree_root(); + + // Empty-participant proof inserted first: anything that follows absorbs it. + buf.push( + HashedAttestationData::new(data.clone()), + make_proof_for_validators(&[]), + ); + assert_eq!(buf.total_proofs, 1); + buf.push( + HashedAttestationData::new(data.clone()), + make_proof_for_validators(&[1, 2]), + ); + assert_eq!(buf.total_proofs, 1); + assert_eq!( + buf.data[&data_root].proofs[0] + .participant_indices() + .collect::>(), + vec![1, 2] + ); + + // Empty-participant proof pushed against existing non-empty: incoming is subsumed, skipped. + buf.push( + HashedAttestationData::new(data), + make_proof_for_validators(&[]), + ); + assert_eq!(buf.total_proofs, 1); + } + #[test] fn payload_buffer_push_cross_data_root_independence() { let mut buf = PayloadBuffer::new(10); From 88d30492946924a2a88b88ddb803aa7d9d0d129c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:09:55 -0300 Subject: [PATCH 3/4] docs: simplify new docs --- crates/common/types/src/attestation.rs | 7 +------ crates/storage/src/store.rs | 24 ++++++------------------ 2 files changed, 7 insertions(+), 24 deletions(-) diff --git a/crates/common/types/src/attestation.rs b/crates/common/types/src/attestation.rs index c9469a9..b619fe3 100644 --- a/crates/common/types/src/attestation.rs +++ b/crates/common/types/src/attestation.rs @@ -86,12 +86,7 @@ pub fn validator_indices(bits: &AggregationBits) -> impl Iterator + }) } -/// Returns `true` iff every bit set in `a` is also set in `b` (i.e., participants(a) ⊆ participants(b)). -/// -/// Operates byte-wise on the raw bitfield representation, avoiding the per-call -/// `HashSet` allocation that index-iteration would require. Safe because SSZ -/// decoding rejects bitlists with non-zero padding above the delimiter, so any -/// bit not in `b` reliably reads as zero in `b.as_bytes()`. +/// Returns `true` iff every bit set in `a` is also set in `b` (i.e., `a` is a subset of `b`). pub fn bits_is_subset(a: &AggregationBits, b: &AggregationBits) -> bool { let a_bytes = a.as_bytes(); let b_bytes = b.as_bytes(); diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index 0f74dd8..7ac7db5 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -126,38 +126,26 @@ impl PayloadBuffer { } } - /// Insert a proof, maintaining the antichain invariant per data_root. - /// - /// Each data_root entry holds proofs whose participant sets are pairwise - /// incomparable under the subset relation. On push: + /// Insert a proof for an attestation, FIFO-evicting oldest data_roots + /// when total proofs reach capacity. Also ensures the buffer doesn't + /// include proofs which are a subset of other proofs for the same + /// attestation data: /// /// - If the incoming proof's participants are a subset (incl. equal) of /// any existing proof, the incoming proof is redundant and skipped. /// - Otherwise, any existing proof whose participants are a strict subset /// of the incoming proof's is removed before inserting. - /// - /// This subsumes exact-equality dedup and keeps buffers bounded when an - /// aggregator produces a proof that supersedes prior children. - /// - /// FIFO-evicts oldest data_roots when `total_proofs` exceeds capacity. fn push(&mut self, hashed: HashedAttestationData, proof: AggregatedSignatureProof) { let (data_root, att_data) = hashed.into_parts(); if let Some(entry) = self.data.get_mut(&data_root) { - // Subset checks operate byte-wise on AggregationBits to avoid per-call - // HashSet allocation on the aggregated-attestation ingest path. - // - // Antichain invariant on `entry.proofs`: surviving proofs are pairwise - // incomparable. Hence the early-return path below cannot have populated - // `to_remove`: if some `X ⊆ new ⊆ existing` had been marked, then - // `X ⊆ existing` would already violate the invariant. let mut to_remove: Vec = Vec::new(); for (i, p) in entry.proofs.iter().enumerate() { - // Incoming is subsumed by an existing proof (incl. equal) — skip. + // Incoming is subsumed by an existing proof (incl. equal). Skip. if bits_is_subset(&proof.participants, &p.participants) { return; } - // Existing is a strict subset of incoming — mark for removal. + // Existing is a strict subset of incoming. Mark for removal. // (Non-strict equality was ruled out by the check above.) if bits_is_subset(&p.participants, &proof.participants) { to_remove.push(i); From ab80e2eb43a1d27407175993d8ce7b8fdd605b1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:35:27 -0300 Subject: [PATCH 4/4] test: add unit tests for bits_is_subset --- crates/common/types/src/attestation.rs | 117 +++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/crates/common/types/src/attestation.rs b/crates/common/types/src/attestation.rs index b619fe3..10fd7d8 100644 --- a/crates/common/types/src/attestation.rs +++ b/crates/common/types/src/attestation.rs @@ -146,3 +146,120 @@ impl From for HashedAttestationData { Self::new(data) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Build an `AggregationBits` of `len` bits with the indices in `set` flipped on. + fn bits(len: usize, set: &[usize]) -> AggregationBits { + let mut b = AggregationBits::with_length(len).unwrap(); + for &i in set { + b.set(i, true).unwrap(); + } + b + } + + #[test] + fn subset_empty_bitlists() { + let empty = AggregationBits::new(); + assert!(bits_is_subset(&empty, &empty)); + } + + #[test] + fn subset_empty_a_is_subset_of_anything() { + let empty = AggregationBits::new(); + let b = bits(8, &[0, 3, 7]); + assert!(bits_is_subset(&empty, &b)); + } + + #[test] + fn subset_zero_bits_a_is_subset_of_empty() { + // a has length but no set bits: byte iteration skips zero bytes, so it's a subset of empty. + let a = bits(8, &[]); + let empty = AggregationBits::new(); + assert!(bits_is_subset(&a, &empty)); + } + + #[test] + fn subset_nonempty_a_is_not_subset_of_empty() { + let a = bits(8, &[2]); + let empty = AggregationBits::new(); + assert!(!bits_is_subset(&a, &empty)); + } + + #[test] + fn subset_reflexive_equal_bitlists() { + let a = bits(16, &[0, 1, 5, 9, 15]); + let b = bits(16, &[0, 1, 5, 9, 15]); + assert!(bits_is_subset(&a, &b)); + assert!(bits_is_subset(&b, &a)); + } + + #[test] + fn subset_strict_subset_returns_true() { + let a = bits(8, &[1, 4]); + let b = bits(8, &[1, 4, 6]); + assert!(bits_is_subset(&a, &b)); + assert!(!bits_is_subset(&b, &a)); + } + + #[test] + fn subset_disjoint_bits_returns_false() { + let a = bits(8, &[0, 2]); + let b = bits(8, &[1, 3]); + assert!(!bits_is_subset(&a, &b)); + assert!(!bits_is_subset(&b, &a)); + } + + #[test] + fn subset_partial_overlap_returns_false() { + // a shares bit 1 with b but also has bit 5 that b lacks. + let a = bits(8, &[1, 5]); + let b = bits(8, &[0, 1, 2]); + assert!(!bits_is_subset(&a, &b)); + } + + #[test] + fn subset_a_shorter_than_b_with_bits_in_b() { + // a has 8 bits, b has 16 bits. a's set bits are all present in b. + let a = bits(8, &[1, 4]); + let b = bits(16, &[1, 4, 11]); + assert!(bits_is_subset(&a, &b)); + } + + #[test] + fn subset_a_longer_than_b_with_zero_tail_is_subset() { + // a has 16 bits but only sets bit 2; b has 8 bits with the same bit set. + // a's tail byte is zero, so the loop skips it. + let a = bits(16, &[2]); + let b = bits(8, &[2]); + assert!(bits_is_subset(&a, &b)); + } + + #[test] + fn subset_a_longer_than_b_with_set_bit_past_b_returns_false() { + // a sets bit 9 (byte 1) but b only has 8 bits (1 byte). Missing bytes in b are + // treated as zero, so any bit in a's tail breaks the subset relation. + let a = bits(16, &[9]); + let b = bits(8, &[]); + assert!(!bits_is_subset(&a, &b)); + } + + #[test] + fn subset_multi_byte_bitlists() { + // Spans multiple bytes (24 bits = 3 bytes) to exercise the byte-by-byte loop. + let a = bits(24, &[0, 8, 16]); + let b = bits(24, &[0, 1, 8, 9, 16, 17]); + assert!(bits_is_subset(&a, &b)); + assert!(!bits_is_subset(&b, &a)); + } + + #[test] + fn subset_violation_in_later_byte_returns_false() { + // a's first byte matches b, but bit 17 (in byte 2) is set in a only. + let a = bits(24, &[0, 1, 17]); + let b = bits(24, &[0, 1, 16]); + assert!(!bits_is_subset(&a, &b)); + } +}