Skip to main content

writerslogic_warm/core/
entangled.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use serde::{Deserialize, Serialize};
5use std::borrow::Borrow;
6
7/// Default sparsity denominator: rho = 1/256 of dimensions are active.
8///
9/// At D=16384 this yields ~64 active indices. This is an engineering choice
10/// balancing memory (256x smaller than dense) against discrimination; the
11/// cited dense ±1 papers (Laiho 2015, Frady 2021) use different representations.
12pub const DEFAULT_RHO_DENOM: usize = 256;
13
14/// Number of entanglement groups (hash-seeded, not learned).
15///
16/// 64 groups at D=16384 gives ~1 active index per group on average.
17/// Groups hash into the full `[0, dim)` range (they do NOT partition the
18/// dimension space — collisions across groups are possible).
19const NUM_ENTANGLEMENTS: usize = 64;
20
21/// Entangled Sparse Hypervector.
22///
23/// Instead of a dense bitvec (~50% active), stores only sorted active indices.
24/// Sparsity rho = 1/256 means ~D/256 active bits out of D total.
25/// Indices are grouped into `NUM_ENTANGLEMENTS` entanglement groups,
26/// each generated from a seeded hash.
27///
28/// Benefits:
29/// - 256x less memory per vector at D=16384
30/// - Hamming via sorted merge (O(k) where k = active count)
31#[derive(Clone, Debug, Serialize, Deserialize)]
32pub struct EntangledHVec {
33    pub(crate) dim: usize,
34    /// Sorted active bit indices (rho * dim total).
35    pub(crate) indices: Vec<u32>,
36}
37
38impl EntangledHVec {
39    /// Create a random entangled sparse hypervector.
40    /// Uses entanglement groups when dim is large enough, otherwise direct hash generation.
41    /// After dedup, if birthday collisions reduced the count below `active_count`,
42    /// deterministic backfill slots are generated until the exact count is reached.
43    pub fn new_deterministic(dim: usize, seed: u64) -> Self {
44        let active_count = (dim / DEFAULT_RHO_DENOM).max(1);
45        let mut indices = Vec::with_capacity(active_count);
46
47        let per_group = active_count / NUM_ENTANGLEMENTS;
48        if per_group > 0 {
49            for e in 0..NUM_ENTANGLEMENTS {
50                let group_seed = hash_u64(seed, e as u64);
51                for p in 0..per_group {
52                    let idx = hash_u64(group_seed, p as u64) % dim as u64;
53                    indices.push(idx as u32);
54                }
55            }
56        } else {
57            // Too few active indices to give every group one.
58            for p in 0..active_count {
59                let idx = hash_u64(seed, p as u64) % dim as u64;
60                indices.push(idx as u32);
61            }
62        }
63
64        indices.sort_unstable();
65        indices.dedup();
66
67        backfill_collisions(&mut indices, active_count, dim, seed);
68
69        Self { dim, indices }
70    }
71
72    pub fn new_with_density(dim: usize, density_denom: usize, seed: u64) -> Self {
73        let active_count = (dim / density_denom.max(1)).max(1);
74        let mut indices = Vec::with_capacity(active_count);
75
76        for p in 0..active_count {
77            let idx = hash_u64(seed, p as u64) % dim as u64;
78            indices.push(idx as u32);
79        }
80
81        indices.sort_unstable();
82        indices.dedup();
83
84        backfill_collisions(&mut indices, active_count, dim, seed);
85
86        Self { dim, indices }
87    }
88
89    /// Create directly from a set of active indices.
90    /// Indices must be sorted, deduplicated, and within `[0, dim)`.
91    pub fn from_indices(indices: Vec<u32>, dim: usize) -> Self {
92        debug_assert!(
93            indices.windows(2).all(|w| w[0] < w[1]),
94            "from_indices: indices must be sorted and deduplicated"
95        );
96        debug_assert!(
97            indices.last().is_none_or(|&last| (last as usize) < dim),
98            "from_indices: indices must be within [0, dim)"
99        );
100        Self { dim, indices }
101    }
102
103    /// Create from a dense float vector via sparse ternary sketching.
104    /// Selects top rho*dim indices by Achlioptas ternary projection magnitude.
105    pub fn from_dense(dense: &[f32], target_dim: usize) -> Self {
106        let active_count = (target_dim / DEFAULT_RHO_DENOM).max(1);
107        if dense.is_empty() {
108            return Self {
109                dim: target_dim,
110                indices: Vec::new(),
111            };
112        }
113
114        // Achlioptas (2003) sparse ternary projection: P(+1) = P(-1) = 1/6, P(0) = 2/3,
115        // which preserves JL distances with 3x fewer multiplications. Each ternary value is
116        // `hash_u64(output dimension, input position)`, so no RNG is seeded per dimension.
117        //
118        // The hash pass is branchless: surviving terms are compacted into
119        // `terms` (hash outcomes are unpredictable, so branching mispredicts),
120        // then summed in ascending-j order, which keeps the f64 accumulation
121        // sequence identical to adding each surviving term in index order.
122        let vals: Vec<f64> = dense.iter().map(|&v| f64::from(v)).collect();
123        let mut terms = vec![0.0f64; vals.len()];
124        let mut projections: Vec<(u32, f64)> = (0..target_dim.div_ceil(2))
125            .map(|i| {
126                let mut kept = 0usize;
127                for (j, &val) in vals.iter().enumerate() {
128                    let r = hash_u64(i as u64, j as u64) % 6;
129                    terms[kept] = if r == 5 { -val } else { val };
130                    kept += usize::from((r == 0) | (r == 5));
131                }
132                let mut dot_product = 0.0f64;
133                for &t in &terms[..kept] {
134                    dot_product += t;
135                }
136                let index = i * 2 + usize::from(dot_product < 0.0);
137                (
138                    index.min(target_dim.saturating_sub(1)) as u32,
139                    dot_product.abs(),
140                )
141            })
142            .collect();
143
144        if projections.len() > active_count {
145            projections.select_nth_unstable_by(active_count - 1, |a, b| {
146                b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
147            });
148            projections.truncate(active_count);
149        }
150
151        let mut indices: Vec<u32> = projections.into_iter().map(|(idx, _)| idx).collect();
152        indices.sort_unstable();
153
154        Self {
155            dim: target_dim,
156            indices,
157        }
158    }
159
160    /// Encode a scalar into a sparse hypervector via permuted sliding window.
161    ///
162    /// The normalized value selects a contiguous window of `active_count`
163    /// logical slots in `[0, target_dim - active_count]`. Each logical slot
164    /// is then scattered across the index space by multiplying with a prime
165    /// constant modulo `target_dim` (golden-ratio permutation). This gives:
166    /// - Nearby values share most window slots → high Jaccard
167    /// - Distant values share few slots → low Jaccard
168    /// - Monotonic: similarity decreases with scalar distance
169    pub fn from_scalar(value: f64, min_val: f64, max_val: f64, target_dim: usize) -> Self {
170        let range = max_val - min_val;
171        let normalized = if range.abs() < f64::EPSILON {
172            0.5
173        } else {
174            ((value - min_val) / range).clamp(0.0, 1.0)
175        };
176        let active_count = (target_dim / DEFAULT_RHO_DENOM).max(1);
177
178        let max_offset = (target_dim - active_count) as f64;
179        let start_offset = (normalized * max_offset).round() as usize;
180
181        // Scatters adjacent window slots across the index space; nearby values still share
182        // most slots, so similarity falls monotonically with distance. A constant divisible
183        // by 5 would confine output to a fifth of the dimensions when `dim % 5 == 0`.
184        const PERM_PRIME: u64 = 0x9E3779B97F4A7C55;
185
186        let mut indices = Vec::with_capacity(active_count);
187        for i in 0..active_count {
188            let logical_idx = (start_offset + i) as u64;
189            let idx = (logical_idx.wrapping_mul(PERM_PRIME) % target_dim as u64) as u32;
190            indices.push(idx);
191        }
192
193        indices.sort_unstable();
194        // The wrapping product is not guaranteed injective modulo `target_dim`.
195        indices.dedup();
196
197        Self {
198            dim: target_dim,
199            indices,
200        }
201    }
202
203    /// Active bit indices (sorted).
204    pub fn indices(&self) -> &[u32] {
205        &self.indices
206    }
207
208    /// Convert sorted indices to deltas (gaps between consecutive indices).
209    /// First element is the absolute value.
210    pub fn to_deltas(&self) -> Vec<u32> {
211        if self.indices.is_empty() {
212            return Vec::new();
213        }
214        let mut deltas = Vec::with_capacity(self.indices.len());
215        deltas.push(self.indices[0]);
216        for i in 1..self.indices.len() {
217            deltas.push(self.indices[i] - self.indices[i - 1]);
218        }
219        deltas
220    }
221
222    /// Restore absolute indices from deltas.
223    pub fn from_deltas(deltas: &[u32], dim: usize) -> Self {
224        if deltas.is_empty() {
225            return Self {
226                dim,
227                indices: Vec::new(),
228            };
229        }
230        let mut indices = Vec::with_capacity(deltas.len());
231        let mut current: u32 = 0;
232        for &d in deltas {
233            current = match current.checked_add(d) {
234                Some(v) if (v as usize) < dim => v,
235                _ => break,
236            };
237            indices.push(current);
238        }
239        Self { dim, indices }
240    }
241
242    /// Hamming distance via sorted-merge intersection count on sparse index sets.
243    pub fn hamming(&self, other: &Self) -> u32 {
244        let intersection =
245            super::intersection::sparse_intersection_count(&self.indices, &other.indices);
246        (self.indices.len() + other.indices.len() - 2 * intersection) as u32
247    }
248
249    /// Jaccard similarity: |A∩B| / |A∪B|.
250    /// Proper metric for sparse index sets (ρ=1/256).
251    /// The old 1−hamming/dim collapsed to ~0.992 for all sparse pairs.
252    pub fn similarity(&self, other: &Self) -> f64 {
253        let intersection =
254            super::intersection::sparse_intersection_count(&self.indices, &other.indices);
255        let union = self.indices.len() + other.indices.len() - intersection;
256        if union == 0 {
257            return 1.0;
258        }
259        intersection as f64 / union as f64
260    }
261
262    /// Entangled bind: symmetric XOR of index sets.
263    /// For sparse vectors, XOR of active sets = symmetric difference.
264    pub fn bind(&self, other: &Self) -> Self {
265        let indices = sorted_symmetric_difference(&self.indices, &other.indices);
266        Self {
267            dim: self.dim,
268            indices,
269        }
270    }
271
272    /// Permute by shifting all indices by `shifts` modulo dim.
273    pub fn permute(&self, shifts: usize) -> Self {
274        if self.dim == 0 {
275            return self.clone();
276        }
277        let dim = self.dim as u32;
278        let shift = (shifts % self.dim) as u32;
279        let mut indices: Vec<u32> = self
280            .indices
281            .iter()
282            .map(|&idx| (idx + shift) % dim)
283            .collect();
284        indices.sort_unstable();
285        Self {
286            dim: self.dim,
287            indices,
288        }
289    }
290
291    /// Nonlinear index permutation via keyed hash.
292    ///
293    /// Maps each active index through hash(idx, seed) % dim, producing a
294    /// completely scrambled view. Unlike circular shift (permute), this
295    /// destroys spatial correlation between indices, making containment
296    /// values independent across different seeds.
297    ///
298    /// Nearly density-preserving: hash collisions reduce count by ~0.1%
299    /// at density 1/256.
300    pub fn hash_permute(&self, seed: u64) -> Self {
301        if self.dim == 0 {
302            return self.clone();
303        }
304        let d = self.dim as u64;
305        let mut indices: Vec<u32> = self
306            .indices
307            .iter()
308            .map(|&idx| (hash_u64(idx as u64, seed) % d) as u32)
309            .collect();
310        indices.sort_unstable();
311        indices.dedup();
312        Self {
313            dim: self.dim,
314            indices,
315        }
316    }
317
318    /// Bundle multiple sparse vectors with threshold at rho.
319    /// Counts per-index frequency, keeps only those above threshold.
320    pub fn bundle<V: Borrow<Self>>(vectors: &[V]) -> Self {
321        if vectors.is_empty() {
322            return Self {
323                dim: 0,
324                indices: Vec::new(),
325            };
326        }
327        let dim = vectors[0].borrow().dim;
328        let n = vectors.len();
329        let threshold = (n as u32).div_ceil(2);
330        let target_count = (dim / DEFAULT_RHO_DENOM).max(1);
331
332        if let Some(selected) = count_majority(vectors, dim, threshold) {
333            return Self::from_selected(selected, dim, target_count);
334        }
335
336        let mut all_indices: Vec<u32> = vectors
337            .iter()
338            .flat_map(|v| v.borrow().indices.iter().copied())
339            .collect();
340        all_indices.sort_unstable();
341
342        if all_indices.is_empty() {
343            return Self {
344                dim,
345                indices: Vec::new(),
346            };
347        }
348
349        // Run-length count on sorted indices, filtering by threshold while counting.
350        let mut selected: Vec<(u32, u32)> = Vec::new();
351
352        let mut current = all_indices[0];
353        let mut count: u32 = 1;
354        for &idx in &all_indices[1..] {
355            if idx == current {
356                count += 1;
357            } else {
358                if count >= threshold {
359                    selected.push((current, count));
360                }
361                current = idx;
362                count = 1;
363            }
364        }
365        if count >= threshold {
366            selected.push((current, count));
367        }
368        Self::from_selected(selected, dim, target_count)
369    }
370
371    /// The bundle of the `(index, count)` pairs that reached the majority threshold, in
372    /// ascending index order: the `target_count` most frequent, ties resolved by the position
373    /// `select_nth_unstable_by` leaves them in, so the order of `selected` must not change.
374    pub(crate) fn from_selected(
375        mut selected: Vec<(u32, u32)>,
376        dim: usize,
377        target_count: usize,
378    ) -> Self {
379        if selected.len() > target_count {
380            selected.select_nth_unstable_by(target_count - 1, |a, b| b.1.cmp(&a.1));
381            selected.truncate(target_count);
382        }
383
384        let mut indices: Vec<u32> = selected.into_iter().map(|(idx, _)| idx).collect();
385        indices.sort_unstable();
386
387        Self { dim, indices }
388    }
389
390    /// Bloom-filter bundling: set UNION of all input vectors.
391    ///
392    /// Instead of majority vote (which fails at K≥3 for sparse vectors with
393    /// density 1/256), takes the union of all active indices. The bundle grows
394    /// denser with more items. Use `containment_similarity` to query.
395    ///
396    /// Validated by HyperCam (arxiv 2501.10547): Bloom filter bundling achieves
397    /// 2 orders of magnitude reduction in encoding operations for sparse HDC.
398    pub fn bundle_bloom<V: Borrow<Self>>(vectors: &[V]) -> Self {
399        if vectors.is_empty() {
400            return Self {
401                dim: 0,
402                indices: Vec::new(),
403            };
404        }
405        let dim = vectors[0].borrow().dim;
406
407        let mut all_indices: Vec<u32> = vectors
408            .iter()
409            .flat_map(|v| v.borrow().indices.iter().copied())
410            .collect();
411        all_indices.sort_unstable();
412        all_indices.dedup();
413
414        Self {
415            dim,
416            indices: all_indices,
417        }
418    }
419
420    /// Containment similarity: |A∩B| / |A|.
421    ///
422    /// Measures what fraction of `self`'s active indices appear in `other`.
423    /// The correct metric for Bloom-filter bundles: if A was bundled into B,
424    /// containment(A, B) ≈ 1.0 regardless of how many other items are in B.
425    pub fn containment_similarity(&self, other: &Self) -> f64 {
426        if self.indices.is_empty() {
427            return 1.0;
428        }
429        let intersection =
430            super::intersection::sparse_intersection_count(&self.indices, &other.indices);
431        intersection as f64 / self.indices.len() as f64
432    }
433
434    /// Density-corrected containment: (raw - density) / (1 - density).
435    ///
436    /// Subtracts the expected noise floor (bundle density) and normalizes.
437    /// Members: raw=1.0, corrected=1.0. Non-members: raw≈density, corrected≈0.
438    /// This is the correct metric for large Bloom bundles where raw containment
439    /// has a high floor due to bundle density.
440    pub fn corrected_containment(&self, bloom_bundle: &Self) -> f64 {
441        let raw = self.containment_similarity(bloom_bundle);
442        let density = bloom_bundle.indices.len() as f64 / bloom_bundle.dim.max(1) as f64;
443        if density >= 1.0 - 1e-15 {
444            return 0.0;
445        }
446        ((raw - density) / (1.0 - density)).max(0.0)
447    }
448
449    /// Non-commutative binding: permute(self, 1) XOR other.
450    ///
451    /// Captures order: bind_ordered(A, B) ≠ bind_ordered(B, A).
452    /// ~2x cost of plain XOR (one permute + one XOR). Inverse: unbind
453    /// B first (XOR), then inverse-permute to recover A.
454    pub fn bind_ordered(&self, other: &Self) -> Self {
455        self.permute(1).bind(other)
456    }
457
458    /// Inverse of bind_ordered: given C = bind_ordered(A, B), recover A.
459    /// A = permute_inv(C XOR B, 1)
460    pub fn unbind_ordered_left(&self, other: &Self) -> Self {
461        let xored = self.bind(other);
462        xored.permute(self.dim - 1)
463    }
464
465    /// Fixed-domain, contribution-clipped histogram followed by noisy top-k.
466    /// The dimension must be public; prefer WarmCore::bundle for empty datasets.
467    pub fn bundle_dp<V: Borrow<Self>>(vectors: &[V], epsilon: f64) -> Self {
468        let dim = vectors.first().map_or(0, |v| v.borrow().dim);
469        Self::bundle_dp_in_space(vectors, epsilon, dim)
470    }
471
472    pub(crate) fn dp_scale(dim: usize, epsilon: f64) -> Option<f64> {
473        let scale = (dim / DEFAULT_RHO_DENOM).max(1) as f64 / epsilon;
474        // The inverse-CDF sampler's 53-bit uniform draw has |ln(tail)| < 64.
475        (epsilon.is_finite() && epsilon > 0.0 && scale > 0.0 && scale <= f64::MAX / 64.0)
476            .then_some(scale)
477    }
478
479    pub(crate) fn bundle_dp_in_space<V: Borrow<Self>>(
480        vectors: &[V],
481        epsilon: f64,
482        dim: usize,
483    ) -> Self {
484        assert!(
485            epsilon.is_finite() && epsilon > 0.0,
486            "epsilon must be finite and positive"
487        );
488        assert!(
489            vectors.iter().all(|v| v.borrow().dim == dim),
490            "bundle dimensions must match"
491        );
492        if dim == 0 {
493            return Self::from_indices(Vec::new(), 0);
494        }
495        let scale =
496            Self::dp_scale(dim, epsilon).expect("epsilon is outside the supported numeric range");
497        let contribution_limit = (dim / DEFAULT_RHO_DENOM).max(1);
498        let mut counts = vec![0.0; dim];
499        for vector in vectors {
500            for &index in vector.borrow().indices.iter().take(contribution_limit) {
501                counts[index as usize] += 1.0;
502            }
503        }
504        use rand::SeedableRng;
505        let mut rng = rand::rngs::StdRng::try_from_rng(&mut rand::rngs::SysRng)
506            .expect("operating system randomness unavailable");
507        let mut scores: Vec<(u32, f64)> = counts
508            .into_iter()
509            .enumerate()
510            .map(|(i, count)| (i as u32, count + laplace_sample(&mut rng, scale)))
511            .collect();
512        scores.select_nth_unstable_by(contribution_limit - 1, |a, b| {
513            b.1.total_cmp(&a.1).then(a.0.cmp(&b.0))
514        });
515        scores.truncate(contribution_limit);
516        let mut indices: Vec<u32> = scores.into_iter().map(|(index, _)| index).collect();
517        indices.sort_unstable();
518        Self::from_indices(indices, dim)
519    }
520}
521
522/// Above this dimension [`EntangledHVec::bundle`] sorts the concatenated indices instead of
523/// counting them in a per-thread histogram of `dim` slots (at most 256 KiB).
524const BUNDLE_COUNTING_MAX_DIM: usize = 1 << 16;
525
526thread_local! {
527    /// [`count_majority`]'s histogram: all-zero between calls.
528    static BUNDLE_COUNTS: std::cell::RefCell<Vec<u32>> = const { std::cell::RefCell::new(Vec::new()) };
529}
530
531/// The `(index, count)` pairs of `vectors` whose count reaches `threshold`, in ascending index
532/// order (what the run-length count over the sorted multiset yields), counted in a histogram.
533/// `None` when `dim` is above [`BUNDLE_COUNTING_MAX_DIM`] or an index lies outside `[0, dim)`;
534/// the caller then sorts.
535fn count_majority<V: Borrow<EntangledHVec>>(
536    vectors: &[V],
537    dim: usize,
538    threshold: u32,
539) -> Option<Vec<(u32, u32)>> {
540    if dim > BUNDLE_COUNTING_MAX_DIM {
541        return None;
542    }
543    let mut total = 0usize;
544    for v in vectors {
545        let indices = &v.borrow().indices;
546        // Checked in full rather than by the last index: deserialized vectors need not be
547        // sorted, and an out-of-range index must fall back rather than panic.
548        if indices.iter().any(|&idx| idx as usize >= dim) {
549            return None;
550        }
551        total += indices.len();
552    }
553    BUNDLE_COUNTS.with(|counts| {
554        let mut counts = counts.borrow_mut();
555        if counts.len() < dim {
556            counts.resize(dim, 0);
557        }
558        let counts = &mut counts[..dim];
559        debug_assert!(counts.iter().all(|&c| c == 0));
560        for v in vectors {
561            for &idx in &v.borrow().indices {
562                counts[idx as usize] += 1;
563            }
564        }
565        let mut selected = Vec::new();
566        if total < dim {
567            // Visit only the counted slots, zeroing each so its index is taken once, then
568            // order the few that reached the threshold.
569            for v in vectors {
570                for &idx in &v.borrow().indices {
571                    let n = std::mem::take(&mut counts[idx as usize]);
572                    if n >= threshold {
573                        selected.push((idx, n));
574                    }
575                }
576            }
577            selected.sort_unstable_by_key(|&(idx, _)| idx);
578        } else {
579            for (idx, slot) in counts.iter_mut().enumerate() {
580                let n = std::mem::take(slot);
581                if n >= threshold {
582                    selected.push((idx as u32, n));
583                }
584            }
585        }
586        Some(selected)
587    })
588}
589
590/// Sample from Laplace(0, scale) using inverse CDF.
591fn laplace_sample(rng: &mut impl rand::RngExt, scale: f64) -> f64 {
592    let u = loop {
593        let sample = rng.random::<f64>() - 0.5;
594        if sample.abs() < 0.5 {
595            break sample;
596        }
597    };
598    -scale * u.signum() * (1.0 - 2.0 * u.abs()).ln()
599}
600
601/// Fills slots lost to birthday collisions with deterministic indices until `indices` holds
602/// `active_count` values, giving up after `10 * active_count` probes. `indices` must be sorted
603/// and deduplicated, and stays so.
604fn backfill_collisions(indices: &mut Vec<u32>, active_count: usize, dim: usize, seed: u64) {
605    let mut counter = 0u64;
606    while indices.len() < active_count && (counter as usize) < active_count * 10 {
607        let idx = (hash_u64(seed.wrapping_add(0xDEAD), counter) % dim as u64) as u32;
608        counter += 1;
609        if let Err(pos) = indices.binary_search(&idx) {
610            indices.insert(pos, idx);
611        }
612    }
613}
614
615/// Splitmix64-based mixer with full avalanche. Two inputs are combined via
616/// wrapping arithmetic then passed through the splitmix64 finalizer.
617/// Close seeds give unrelated outputs (Fx hashing's avalanche is too weak for that).
618pub fn hash_u64(a: u64, b: u64) -> u64 {
619    let mut z = a.wrapping_mul(0x9E3779B97F4A7C15).wrapping_add(b);
620    z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
621    z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
622    z ^ (z >> 31)
623}
624
625/// Symmetric difference of two sorted slices (XOR for index sets).
626pub(crate) fn sorted_symmetric_difference(a: &[u32], b: &[u32]) -> Vec<u32> {
627    let mut result = Vec::with_capacity(a.len() + b.len());
628    let mut i = 0;
629    let mut j = 0;
630    while i < a.len() && j < b.len() {
631        match a[i].cmp(&b[j]) {
632            std::cmp::Ordering::Less => {
633                result.push(a[i]);
634                i += 1;
635            }
636            std::cmp::Ordering::Greater => {
637                result.push(b[j]);
638                j += 1;
639            }
640            std::cmp::Ordering::Equal => {
641                // In both → cancel (XOR)
642                i += 1;
643                j += 1;
644            }
645        }
646    }
647    result.extend_from_slice(&a[i..]);
648    result.extend_from_slice(&b[j..]);
649    result
650}
651
652/// Count of symmetric difference (without collecting).
653pub(crate) fn sorted_symmetric_difference_count(a: &[u32], b: &[u32]) -> usize {
654    let mut count = 0;
655    let mut i = 0;
656    let mut j = 0;
657    while i < a.len() && j < b.len() {
658        match a[i].cmp(&b[j]) {
659            std::cmp::Ordering::Less => {
660                count += 1;
661                i += 1;
662            }
663            std::cmp::Ordering::Greater => {
664                count += 1;
665                j += 1;
666            }
667            std::cmp::Ordering::Equal => {
668                i += 1;
669                j += 1;
670            }
671        }
672    }
673    count += a.len() - i;
674    count += b.len() - j;
675    count
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681
682    #[test]
683    fn self_similarity_and_containment_are_one() {
684        let v = EntangledHVec::new_deterministic(16384, 42);
685        assert!(
686            (v.similarity(&v) - 1.0).abs() < 0.0001,
687            "Self-similarity should be 1.0"
688        );
689        assert!(
690            (v.containment_similarity(&v) - 1.0).abs() < 0.0001,
691            "Self-containment should be 1.0"
692        );
693    }
694
695    #[test]
696    fn test_entangled_random_pair_distance() {
697        let a = EntangledHVec::new_deterministic(16384, 1);
698        let b = EntangledHVec::new_deterministic(16384, 2);
699        let sim = a.similarity(&b);
700        // Jaccard for random sparse pairs: ~64*64/16384 intersect / ~128 union ≈ 0.002
701        assert!(
702            sim < 0.05,
703            "Random sparse pair Jaccard {:.4} should be near 0",
704            sim
705        );
706    }
707
708    #[test]
709    fn test_entangled_bind_involution() {
710        let a = EntangledHVec::new_deterministic(16384, 1);
711        let b = EntangledHVec::new_deterministic(16384, 2);
712        let ab = a.bind(&b);
713        let recovered = ab.bind(&b);
714        // XOR is involutory: (A⊕B)⊕B = A
715        assert_eq!(recovered.indices, a.indices);
716    }
717
718    #[test]
719    fn test_entangled_bundle_majority() {
720        let dim = 16384;
721        let base = EntangledHVec::new_deterministic(dim, 100);
722        let mut vecs = vec![base.clone(); 5];
723        vecs.push(EntangledHVec::new_deterministic(dim, 200));
724        vecs.push(EntangledHVec::new_deterministic(dim, 300));
725        let bundled = EntangledHVec::bundle(&vecs);
726        let sim_base = bundled.similarity(&base);
727        let sim_random = bundled.similarity(&EntangledHVec::new_deterministic(dim, 400));
728        assert!(
729            sim_base > sim_random,
730            "Bundle should be closer to majority element"
731        );
732    }
733
734    #[test]
735    fn test_from_dense_produces_sparse() {
736        let dense: Vec<f32> = (0..128).map(|i| (i as f32 - 64.0) / 64.0).collect();
737        let e = EntangledHVec::from_dense(&dense, 10000);
738        assert_eq!(e.dim, 10000);
739        let expected = 10000 / 256;
740        assert_eq!(e.indices.len(), expected);
741    }
742
743    #[test]
744    fn test_bundle_dp_produces_valid_output() {
745        let dim = 16384;
746        let base = EntangledHVec::new_deterministic(dim, 100);
747        let vecs = vec![base.clone(); 5];
748        let bundled = EntangledHVec::bundle_dp(&vecs, 1.0);
749        assert_eq!(bundled.dim, dim);
750        assert!(!bundled.indices.is_empty());
751        assert!(bundled.indices.windows(2).all(|w| w[0] < w[1]));
752    }
753
754    #[test]
755    #[should_panic(expected = "epsilon is outside the supported numeric range")]
756    fn dp_rejects_finite_scales_whose_inverse_cdf_can_overflow() {
757        let vectors = [EntangledHVec::from_indices(vec![1], 256)];
758        EntangledHVec::bundle_dp(&vectors, 1e-308);
759    }
760
761    #[test]
762    fn test_bundle_dp_high_epsilon_matches_deterministic() {
763        let dim = 16384;
764        let base = EntangledHVec::new_deterministic(dim, 100);
765        let vecs = vec![base.clone(); 7];
766        let det = EntangledHVec::bundle(&vecs);
767        // Very high epsilon = almost no noise
768        let dp = EntangledHVec::bundle_dp(&vecs, 1000.0);
769        let sim = det.similarity(&dp);
770        assert!(
771            sim > 0.8,
772            "High-epsilon DP bundle should closely match deterministic: {:.4}",
773            sim
774        );
775    }
776
777    #[test]
778    fn test_bundle_dp_low_epsilon_adds_noise() {
779        let dim = 16384;
780        let base = EntangledHVec::new_deterministic(dim, 100);
781        let noise = EntangledHVec::new_deterministic(dim, 200);
782        let vecs = vec![base.clone(), base.clone(), base.clone(), noise];
783        let det = EntangledHVec::bundle(&vecs);
784        let mut diffs = 0;
785        for _ in 0..5 {
786            let dp = EntangledHVec::bundle_dp(&vecs, 0.1);
787            if dp.indices != det.indices {
788                diffs += 1;
789            }
790        }
791        assert!(diffs > 0, "Low-epsilon DP should produce varied outputs");
792    }
793
794    #[test]
795    fn test_bloom_bundle_capacity() {
796        let dim = 16384;
797        let vecs: Vec<EntangledHVec> = (0..200)
798            .map(|i| EntangledHVec::new_deterministic(dim, i * 100))
799            .collect();
800        let bundle = EntangledHVec::bundle_bloom(&vecs);
801        assert_eq!(bundle.dim, dim);
802        for (i, v) in vecs.iter().enumerate() {
803            let sim = v.containment_similarity(&bundle);
804            assert!(
805                sim > 0.9,
806                "Item {} containment in bundle of 200 should be >0.9, got {:.4}",
807                i,
808                sim
809            );
810        }
811        let noise = EntangledHVec::new_deterministic(dim, 999999);
812        let noise_sim = noise.containment_similarity(&bundle);
813        assert!(
814            noise_sim < 0.9,
815            "Non-member containment should be low, got {:.4}",
816            noise_sim
817        );
818    }
819
820    #[test]
821    fn test_bind_ordered_non_commutative() {
822        let a = EntangledHVec::new_deterministic(16384, 1);
823        let b = EntangledHVec::new_deterministic(16384, 2);
824        let ab = a.bind_ordered(&b);
825        let ba = b.bind_ordered(&a);
826        assert_ne!(
827            ab.indices, ba.indices,
828            "bind_ordered must be non-commutative"
829        );
830    }
831
832    #[test]
833    fn test_bind_ordered_invertible() {
834        let a = EntangledHVec::new_deterministic(16384, 1);
835        let b = EntangledHVec::new_deterministic(16384, 2);
836        let c = a.bind_ordered(&b);
837        let recovered = c.unbind_ordered_left(&b);
838        assert_eq!(
839            recovered.indices, a.indices,
840            "unbind_ordered_left should recover A"
841        );
842    }
843
844    #[test]
845    fn test_from_scalar_locality() {
846        let dim = 16384;
847        let base_val = 0.5;
848        let v_base = EntangledHVec::from_scalar(base_val, 0.0, 1.0, dim);
849        assert_eq!(v_base.dim, dim);
850        assert!(!v_base.indices.is_empty());
851
852        let mut prev_sim = 1.0;
853        for i in 1..20 {
854            let offset = i as f64 * 0.02;
855            let v_curr = EntangledHVec::from_scalar(base_val + offset, 0.0, 1.0, dim);
856            let sim = v_base.similarity(&v_curr);
857
858            assert!(
859                sim <= prev_sim,
860                "Monotonicity violation at offset {}: sim={:.4} was prev={:.4}",
861                offset,
862                sim,
863                prev_sim
864            );
865            prev_sim = sim;
866        }
867    }
868
869    /// Verbatim pre-optimisation `from_dense` (encoder "signed-projection-v2").
870    fn from_dense_reference(dense: &[f32], target_dim: usize) -> EntangledHVec {
871        let active_count = (target_dim / DEFAULT_RHO_DENOM).max(1);
872        if dense.is_empty() {
873            return EntangledHVec {
874                dim: target_dim,
875                indices: Vec::new(),
876            };
877        }
878        let mut projections: Vec<(u32, f64)> = (0..target_dim.div_ceil(2))
879            .map(|i| {
880                let mut dot_product = 0.0f64;
881                for (j, &val) in dense.iter().enumerate() {
882                    let r = hash_u64(i as u64, j as u64) % 6;
883                    if r == 0 {
884                        dot_product += val as f64;
885                    } else if r == 5 {
886                        dot_product -= val as f64;
887                    }
888                }
889                let index = i * 2 + usize::from(dot_product < 0.0);
890                (
891                    index.min(target_dim.saturating_sub(1)) as u32,
892                    dot_product.abs(),
893                )
894            })
895            .collect();
896        if projections.len() > active_count {
897            projections.select_nth_unstable_by(active_count - 1, |a, b| {
898                b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
899            });
900            projections.truncate(active_count);
901        }
902        let mut indices: Vec<u32> = projections.into_iter().map(|(idx, _)| idx).collect();
903        indices.sort_unstable();
904        EntangledHVec {
905            dim: target_dim,
906            indices,
907        }
908    }
909
910    /// `EntangledHVec::bundle` as it was before the histogram: sort the multiset, count runs.
911    fn bundle_reference(vectors: &[EntangledHVec]) -> Vec<u32> {
912        let dim = vectors[0].dim;
913        let threshold = (vectors.len() as u32).div_ceil(2);
914        let mut all: Vec<u32> = vectors
915            .iter()
916            .flat_map(|v| v.indices.iter().copied())
917            .collect();
918        all.sort_unstable();
919        let mut selected: Vec<(u32, u32)> = Vec::new();
920        for run in all.chunk_by(|a, b| a == b) {
921            if run.len() as u32 >= threshold {
922                selected.push((run[0], run.len() as u32));
923            }
924        }
925        let target = (dim / DEFAULT_RHO_DENOM).max(1);
926        if selected.len() > target {
927            selected.select_nth_unstable_by(target - 1, |a, b| b.1.cmp(&a.1));
928            selected.truncate(target);
929        }
930        let mut indices: Vec<u32> = selected.into_iter().map(|(i, _)| i).collect();
931        indices.sort_unstable();
932        indices
933    }
934
935    #[test]
936    fn bundle_matches_sorting_reference() {
937        // 65535..=65537 straddle BUNDLE_COUNTING_MAX_DIM; small dims with many vectors make
938        // the multiset outgrow dim (dense scan) and force ties at the target count.
939        for dim in [1usize, 2, 255, 256, 257, 1024, 16384, 65535, 65536, 65537] {
940            for n in [1usize, 2, 3, 4, 7, 10, 64] {
941                for overlap in [0u64, 3] {
942                    let vectors: Vec<EntangledHVec> = (0..n as u64)
943                        .map(|i| {
944                            let seed = if overlap > 0 { i % overlap } else { i };
945                            EntangledHVec::new_with_density(dim, 16, seed * 7919 + dim as u64)
946                        })
947                        .collect();
948                    assert_eq!(
949                        EntangledHVec::bundle(&vectors).indices,
950                        bundle_reference(&vectors),
951                        "dim {dim} n {n} overlap {overlap}"
952                    );
953                }
954            }
955        }
956        // An index outside [0, dim) falls back to sorting instead of panicking.
957        let bad = vec![
958            EntangledHVec {
959                dim: 8,
960                indices: vec![1, 9],
961            },
962            EntangledHVec {
963                dim: 8,
964                indices: vec![1, 9],
965            },
966        ];
967        assert_eq!(EntangledHVec::bundle(&bad).indices, bundle_reference(&bad));
968    }
969
970    #[test]
971    fn from_dense_matches_reference() {
972        let mut state = 0x1234_5678_9ABC_DEF0u64;
973        let mut next = move || {
974            state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
975            hash_u64(state, 0)
976        };
977        let unit = |x: u64| (x >> 11) as f64 / (1u64 << 53) as f64;
978        let mut cases = 0usize;
979        for emb in [64usize, 384, 768, 1536] {
980            for (d, n) in [(4096usize, 100usize), (16384, 30), (65536, 3)] {
981                for k in 0..n {
982                    let v: Vec<f32> = (0..emb)
983                        .map(|_| {
984                            let r = next();
985                            match k % 6 {
986                                // continuous
987                                0 | 1 => (unit(r) * 2.0 - 1.0) as f32,
988                                // all zero
989                                2 if k < 6 => 0.0,
990                                // heavy ties: few distinct values
991                                2 | 3 => (r % 3) as f32 - 1.0,
992                                // constant
993                                4 => 0.5,
994                                // extremes and subnormals, finite only
995                                _ => match r % 5 {
996                                    0 => f32::MAX,
997                                    1 => f32::MIN,
998                                    2 => f32::MIN_POSITIVE / 4.0,
999                                    3 => -0.0,
1000                                    _ => 0.0,
1001                                },
1002                            }
1003                        })
1004                        .collect();
1005                    let got = EntangledHVec::from_dense(&v, d);
1006                    let want = from_dense_reference(&v, d);
1007                    assert_eq!(got.dim, want.dim);
1008                    assert_eq!(got.indices, want.indices, "emb={emb} D={d} case={k}");
1009                    cases += 1;
1010                }
1011            }
1012        }
1013        assert!(cases >= 500);
1014    }
1015}