use crate::OrdvecError;
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Contingency {
buckets: usize,
counts: Vec<u32>,
}
impl Contingency {
pub fn new(query: &[u8], doc: &[u8], nb: usize) -> Result<Self, OrdvecError> {
if nb == 0 {
return Err(OrdvecError::InvalidParameter {
name: "nb",
message: "bucket count must be > 0".to_string(),
});
}
if nb > u8::MAX as usize + 1 {
return Err(OrdvecError::InvalidParameter {
name: "nb",
message: format!("must be <= 256 (codes are u8); got {nb}"),
});
}
if query.len() != doc.len() {
return Err(OrdvecError::InvalidVectorLength {
name: "doc",
len: doc.len(),
expected: query.len(),
});
}
if query.len() > u32::MAX as usize {
return Err(OrdvecError::InvalidParameter {
name: "dim",
message: format!("must be <= {} (u32 contingency count cap)", u32::MAX),
});
}
let table_len = nb
.checked_mul(nb)
.ok_or_else(|| OrdvecError::InvalidParameter {
name: "nb",
message: "bucket count squared overflows usize".to_string(),
})?;
let mut counts = vec![0u32; table_len];
build_histogram(query, doc, nb, &mut counts)?;
Ok(Self {
buckets: nb,
counts,
})
}
pub fn buckets(&self) -> usize {
self.buckets
}
pub fn counts(&self) -> &[u32] {
&self.counts
}
pub fn count(&self, query_bucket: usize, doc_bucket: usize) -> u32 {
assert!(query_bucket < self.buckets, "query_bucket out of range");
assert!(doc_bucket < self.buckets, "doc_bucket out of range");
self.counts[query_bucket * self.buckets + doc_bucket]
}
pub fn row_sum(&self, query_bucket: usize) -> u32 {
assert!(query_bucket < self.buckets, "query_bucket out of range");
let base = query_bucket * self.buckets;
self.counts[base..base + self.buckets].iter().sum()
}
pub fn column_sum(&self, doc_bucket: usize) -> u32 {
assert!(doc_bucket < self.buckets, "doc_bucket out of range");
(0..self.buckets)
.map(|query_bucket| self.counts[query_bucket * self.buckets + doc_bucket])
.sum()
}
pub fn total_count(&self) -> u32 {
self.counts.iter().copied().sum()
}
pub fn top_overlap(&self) -> u32 {
self.count(self.buckets - 1, self.buckets - 1)
}
pub fn diagonal_agreement(&self) -> u32 {
(0..self.buckets)
.map(|bucket| self.counts[bucket * self.buckets + bucket])
.sum()
}
pub fn band_agreement(&self, radius: usize) -> u32 {
let mut total = 0u32;
for qb in 0..self.buckets {
let base = qb * self.buckets;
let start = qb.saturating_sub(radius);
let end = qb.saturating_add(radius).min(self.buckets - 1);
for db in start..=end {
total += self.counts[base + db];
}
}
total
}
pub fn top_group_overlap(&self, group_width: usize) -> u32 {
assert!(group_width > 0, "group_width must be > 0");
assert!(group_width <= self.buckets, "group_width must be <= nb");
let start = self.buckets - group_width;
let mut total = 0u32;
for qb in start..self.buckets {
let base = qb * self.buckets;
for db in start..self.buckets {
total += self.counts[base + db];
}
}
total
}
pub fn bucket_l1_distance(&self) -> u64 {
let mut total = 0u64;
for qb in 0..self.buckets {
let base = qb * self.buckets;
for db in 0..self.buckets {
total += qb.abs_diff(db) as u64 * u64::from(self.counts[base + db]);
}
}
total
}
pub fn coarsened_counts(&self, groups: usize) -> Vec<u32> {
assert!(groups > 0, "groups must be > 0");
assert!(groups <= self.buckets, "groups must be <= nb");
assert!(
self.buckets.is_multiple_of(groups),
"bucket count must be divisible by groups"
);
let width = self.buckets / groups;
let mut out = vec![0u32; groups * groups];
for qb in 0..self.buckets {
let base = qb * self.buckets;
let qg = qb / width;
for db in 0..self.buckets {
let dg = db / width;
out[qg * groups + dg] += self.counts[base + db];
}
}
out
}
pub fn rankquant_symmetric_score(&self) -> f32 {
let centre = (self.buckets as f32 - 1.0) / 2.0;
let mut score = 0.0f32;
for qb in 0..self.buckets {
let base = qb * self.buckets;
let qw = qb as f32 - centre;
let mut row_sum = 0.0f32;
for db in 0..self.buckets {
row_sum += (db as f32 - centre) * self.counts[base + db] as f32;
}
score += qw * row_sum;
}
score
}
pub fn project(&self, weights: &[f32]) -> f32 {
assert_eq!(
weights.len(),
self.counts.len(),
"weights must be an nb * nb matrix",
);
weights
.iter()
.zip(self.counts.iter())
.map(|(&w, &c)| w * c as f32)
.sum()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Projection {
TopOverlap,
TopGroupOverlap { width: usize },
DiagonalAgreement,
BandAgreement { radius: usize },
BucketL1Distance,
RankQuantSymmetric,
}
impl Projection {
pub fn score(self, contingency: &Contingency) -> f32 {
match self {
Self::TopOverlap => contingency.top_overlap() as f32,
Self::TopGroupOverlap { width } => contingency.top_group_overlap(width) as f32,
Self::DiagonalAgreement => contingency.diagonal_agreement() as f32,
Self::BandAgreement { radius } => contingency.band_agreement(radius) as f32,
Self::BucketL1Distance => contingency.bucket_l1_distance() as f32,
Self::RankQuantSymmetric => contingency.rankquant_symmetric_score(),
}
}
}
fn build_histogram(
query: &[u8],
doc: &[u8],
nb: usize,
counts: &mut [u32],
) -> Result<(), OrdvecError> {
debug_assert_eq!(query.len(), doc.len());
debug_assert_eq!(counts.len(), nb * nb);
if let Some(bad) = find_out_of_range(query, doc, nb) {
return Err(OrdvecError::InvalidParameter {
name: "code",
message: format!("bucket code {bad} out of range (must be < {nb})"),
});
}
#[cfg(target_arch = "x86_64")]
let use_avx512 = is_x86_feature_detected!("avx512f")
&& is_x86_feature_detected!("avx512bw")
&& is_x86_feature_detected!("avx512vpopcntdq")
&& nb <= 16;
#[cfg(not(target_arch = "x86_64"))]
let use_avx512 = false;
if use_avx512 {
#[cfg(target_arch = "x86_64")]
unsafe {
build_histogram_avx512(query, doc, nb, counts);
return Ok(());
}
}
build_histogram_scalar(query, doc, nb, counts);
Ok(())
}
fn find_out_of_range(query: &[u8], doc: &[u8], nb: usize) -> Option<u8> {
if nb > u8::MAX as usize {
return None;
}
let cap = nb as u8;
query.iter().chain(doc.iter()).copied().find(|&c| c >= cap)
}
fn build_histogram_scalar(query: &[u8], doc: &[u8], nb: usize, counts: &mut [u32]) {
for (&qb, &db) in query.iter().zip(doc.iter()) {
let idx = qb as usize * nb + db as usize;
counts[idx] += 1;
}
}
#[cfg(target_arch = "x86_64")]
#[target_feature(enable = "avx512f,avx512bw,avx512vpopcntdq")]
unsafe fn build_histogram_avx512(query: &[u8], doc: &[u8], nb: usize, counts: &mut [u32]) {
use std::arch::x86_64::*;
unsafe {
let len = query.len();
let q_ptr = query.as_ptr();
let d_ptr = doc.as_ptr();
let mut acc = vec![0u64; nb * nb];
let mut splats = [_mm512_setzero_si512(); 16];
for (i, s) in splats.iter_mut().enumerate().take(nb) {
*s = _mm512_set1_epi8(i as i8);
}
let mut off = 0usize;
while off < len {
let rem = len - off;
let (q_vec, d_vec) = if rem >= 64 {
(
_mm512_loadu_si512(q_ptr.add(off) as *const __m512i),
_mm512_loadu_si512(d_ptr.add(off) as *const __m512i),
)
} else {
let load_mask: __mmask64 = (1u64 << rem) - 1;
(
_mm512_maskz_loadu_epi8(load_mask, q_ptr.add(off) as *const i8),
_mm512_maskz_loadu_epi8(load_mask, d_ptr.add(off) as *const i8),
)
};
let live: __mmask64 = if rem >= 64 {
u64::MAX
} else {
(1u64 << rem) - 1
};
for a in 0..nb {
let q_eq: __mmask64 = _mm512_cmpeq_epi8_mask(q_vec, splats[a]) & live;
if q_eq == 0 {
continue;
}
let row = a * nb;
for b in 0..nb {
let d_eq: __mmask64 = _mm512_cmpeq_epi8_mask(d_vec, splats[b]);
acc[row + b] += (q_eq & d_eq).count_ones() as u64;
}
}
off += 64;
}
for (cell, &v) in counts.iter_mut().zip(acc.iter()) {
*cell = v as u32;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parity_counts_bucket_intersections() {
let query = [0u8, 0, 1, 1, 2, 2, 3, 3];
let doc = [3u8, 3, 2, 2, 1, 1, 0, 0];
let c = Contingency::new(&query, &doc, 4).unwrap();
assert_eq!(c.count(0, 3), 2);
assert_eq!(c.count(3, 0), 2);
assert_eq!(c.top_overlap(), 0);
assert_eq!(c.diagonal_agreement(), 0);
}
#[test]
fn parity_projections() {
let query = [0u8, 0, 1, 1, 2, 2, 3, 3];
let doc = [0u8, 1, 1, 2, 2, 3, 3, 0];
let c = Contingency::new(&query, &doc, 4).unwrap();
assert_eq!(Projection::TopOverlap.score(&c), 1.0);
assert_eq!(Projection::DiagonalAgreement.score(&c), 4.0);
assert_eq!(Projection::BandAgreement { radius: 1 }.score(&c), 7.0);
assert_eq!(Projection::TopGroupOverlap { width: 2 }.score(&c), 3.0);
assert_eq!(Projection::BucketL1Distance.score(&c), 6.0);
assert_eq!(Projection::RankQuantSymmetric.score(&c), 4.0);
assert_eq!(c.top_overlap(), 1);
assert_eq!(c.diagonal_agreement(), 4);
assert_eq!(c.band_agreement(1), 7);
assert_eq!(c.top_group_overlap(2), 3);
assert_eq!(c.bucket_l1_distance(), 6);
assert_eq!(c.rankquant_symmetric_score(), 4.0);
}
#[test]
fn band_agreement_saturates_on_huge_radius() {
let query = [0u8, 0, 1, 1, 2, 2, 3, 3];
let doc = [0u8, 1, 1, 2, 2, 3, 3, 0];
let c = Contingency::new(&query, &doc, 4).unwrap();
assert_eq!(c.band_agreement(usize::MAX), c.total_count());
}
#[test]
fn bucket_l1_distance_does_not_overflow_u32() {
let nb = 16usize;
let mut counts = vec![0u32; nb * nb];
counts[nb - 1] = u32::MAX; let c = Contingency {
buckets: nb,
counts,
};
let expected = (nb as u64 - 1) * u64::from(u32::MAX); assert!(
expected > u64::from(u32::MAX),
"fixture must exceed u32 to be a real regression"
);
assert_eq!(c.bucket_l1_distance(), expected);
}
#[test]
fn parity_fixed_margins() {
let query = [0u8, 0, 1, 1, 2, 2, 3, 3];
let doc = [0u8, 1, 1, 2, 2, 3, 3, 0];
let c = Contingency::new(&query, &doc, 4).unwrap();
assert_eq!(c.total_count(), 8);
for bucket in 0..4 {
assert_eq!(c.row_sum(bucket), 2);
assert_eq!(c.column_sum(bucket), 2);
}
}
#[test]
fn parity_rankquant_matches_direct_centered_sum() {
let query = [0u8, 0, 1, 1, 2, 2, 3, 3];
let doc = [0u8, 1, 1, 2, 2, 3, 3, 0];
let c = Contingency::new(&query, &doc, 4).unwrap();
let centre = 1.5f32;
let direct: f32 = query
.iter()
.zip(doc.iter())
.map(|(&q, &d)| (f32::from(q) - centre) * (f32::from(d) - centre))
.sum();
assert_eq!(c.rankquant_symmetric_score(), direct);
}
#[test]
fn parity_coarsened_counts() {
let query = [0u8, 0, 1, 1, 2, 2, 3, 3];
let doc = [0u8, 1, 1, 2, 2, 3, 3, 0];
let c = Contingency::new(&query, &doc, 4).unwrap();
assert_eq!(c.coarsened_counts(2), vec![3, 1, 1, 3]);
assert_eq!(c.coarsened_counts(2).iter().sum::<u32>(), 8);
}
#[test]
fn project_generalises_named_projections() {
let query = [0u8, 0, 1, 1, 2, 2, 3, 3];
let doc = [0u8, 1, 1, 2, 2, 3, 3, 0];
let c = Contingency::new(&query, &doc, 4).unwrap();
let nb = 4;
let mut diag = vec![0.0f32; nb * nb];
for a in 0..nb {
diag[a * nb + a] = 1.0;
}
assert_eq!(c.project(&diag), c.diagonal_agreement() as f32);
let centre = (nb as f32 - 1.0) / 2.0;
let mut outer = vec![0.0f32; nb * nb];
for a in 0..nb {
for b in 0..nb {
outer[a * nb + b] = (a as f32 - centre) * (b as f32 - centre);
}
}
assert_eq!(c.project(&outer), c.rankquant_symmetric_score());
let learned: Vec<f32> = (0..(nb * nb)).map(|i| i as f32 * 0.5).collect();
let expected: f32 = (0..(nb * nb))
.map(|i| learned[i] * c.counts()[i] as f32)
.sum();
assert_eq!(c.project(&learned), expected);
}
#[test]
fn rejects_mismatched_lengths() {
let err = Contingency::new(&[0u8, 1], &[0u8, 1, 2], 4).unwrap_err();
assert!(matches!(err, OrdvecError::InvalidVectorLength { .. }));
}
#[test]
fn rejects_zero_buckets() {
let err = Contingency::new(&[0u8], &[0u8], 0).unwrap_err();
assert!(matches!(
err,
OrdvecError::InvalidParameter { name: "nb", .. }
));
}
#[test]
fn rejects_more_than_256_buckets() {
let err = Contingency::new(&[0u8], &[0u8], 300).unwrap_err();
assert!(matches!(
err,
OrdvecError::InvalidParameter { name: "nb", .. }
));
assert!(Contingency::new(&[0u8], &[0u8], 256).is_ok());
}
#[test]
fn rejects_out_of_range_code() {
let err = Contingency::new(&[0u8, 1, 2, 3], &[0u8, 1, 2, 4], 4).unwrap_err();
assert!(matches!(
err,
OrdvecError::InvalidParameter { name: "code", .. }
));
}
#[test]
fn simd_path_matches_scalar_reference() {
for &len in &[64usize, 200, 256, 1000] {
for nb in [2usize, 4, 8, 16] {
let mut seed = 0x9E3779B9u32 ^ (len as u32).wrapping_mul(2654435761);
let mut next = || {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed
};
let query: Vec<u8> = (0..len).map(|_| (next() as usize % nb) as u8).collect();
let doc: Vec<u8> = (0..len).map(|_| (next() as usize % nb) as u8).collect();
let got = Contingency::new(&query, &doc, nb).unwrap();
let mut want = vec![0u32; nb * nb];
for (&q, &d) in query.iter().zip(doc.iter()) {
want[q as usize * nb + d as usize] += 1;
}
assert_eq!(
got.counts(),
want.as_slice(),
"contingency table mismatch at len={len}, nb={nb}",
);
assert_eq!(got.total_count(), len as u32);
}
}
}
#[test]
fn large_nb_uses_scalar_and_skips_range_scan() {
let query = [0u8, 5, 200, 255];
let doc = [255u8, 200, 5, 0];
let c = Contingency::new(&query, &doc, 256).unwrap();
assert_eq!(c.buckets(), 256);
assert_eq!(c.count(0, 255), 1);
assert_eq!(c.count(255, 0), 1);
assert_eq!(c.count(200, 5), 1);
assert_eq!(c.count(5, 200), 1);
assert_eq!(c.total_count(), 4);
}
}