Skip to main content

holographic_memory/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            // Standard entangled generation with groups
50            for e in 0..NUM_ENTANGLEMENTS {
51                let group_seed = hash_u64(seed, e as u64);
52                for p in 0..per_group {
53                    let idx = hash_u64(group_seed, p as u64) % dim as u64;
54                    indices.push(idx as u32);
55                }
56            }
57        } else {
58            // Fallback for small dims: generate active_count indices directly
59            for p in 0..active_count {
60                let idx = hash_u64(seed, p as u64) % dim as u64;
61                indices.push(idx as u32);
62            }
63        }
64
65        indices.sort_unstable();
66        indices.dedup();
67
68        // Birthday collision backfill: if collisions reduced count, fill remaining
69        // slots deterministically until we reach exactly active_count.
70        let max_backfill_attempts = active_count * 10;
71        let mut backfill_counter = 0u64;
72        while indices.len() < active_count {
73            if backfill_counter as usize >= max_backfill_attempts {
74                break;
75            }
76            let idx = (hash_u64(seed.wrapping_add(0xDEAD), backfill_counter) % dim as u64) as u32;
77            backfill_counter += 1;
78            if indices.binary_search(&idx).is_err() {
79                let pos = indices.partition_point(|&x| x < idx);
80                indices.insert(pos, idx);
81            }
82        }
83
84        Self { dim, indices }
85    }
86
87    pub fn new_with_density(dim: usize, density_denom: usize, seed: u64) -> Self {
88        let active_count = (dim / density_denom.max(1)).max(1);
89        let mut indices = Vec::with_capacity(active_count);
90
91        for p in 0..active_count {
92            let idx = hash_u64(seed, p as u64) % dim as u64;
93            indices.push(idx as u32);
94        }
95
96        indices.sort_unstable();
97        indices.dedup();
98
99        let mut backfill_counter = 0u64;
100        while indices.len() < active_count {
101            if backfill_counter as usize >= active_count * 10 {
102                break;
103            }
104            let idx = (hash_u64(seed.wrapping_add(0xDEAD), backfill_counter) % dim as u64) as u32;
105            backfill_counter += 1;
106            if indices.binary_search(&idx).is_err() {
107                let pos = indices.partition_point(|&x| x < idx);
108                indices.insert(pos, idx);
109            }
110        }
111
112        Self { dim, indices }
113    }
114
115    /// Create directly from a set of active indices.
116    /// Indices must be sorted, deduplicated, and within `[0, dim)`.
117    pub fn from_indices(indices: Vec<u32>, dim: usize) -> Self {
118        debug_assert!(
119            indices.windows(2).all(|w| w[0] < w[1]),
120            "from_indices: indices must be sorted and deduplicated"
121        );
122        debug_assert!(
123            indices.last().is_none_or(|&last| (last as usize) < dim),
124            "from_indices: indices must be within [0, dim)"
125        );
126        Self { dim, indices }
127    }
128
129    /// Create from a dense float vector via sparse ternary sketching.
130    /// Selects top rho*dim indices by Achlioptas ternary projection magnitude.
131    pub fn from_dense(dense: &[f32], target_dim: usize) -> Self {
132        let active_count = (target_dim / DEFAULT_RHO_DENOM).max(1);
133        if dense.is_empty() {
134            return Self {
135                dim: target_dim,
136                indices: Vec::new(),
137            };
138        }
139
140        // Compute random projection dot products.
141        // Achlioptas (2003) sparse ternary projection:
142        // P(+1) = P(-1) = 1/6, P(0) = 2/3.
143        // Preserves JL distances with 3x fewer multiplications.
144        // Uses FxHash per (dim, input_pos) pair for deterministic ternary values
145        // instead of StdRng per dimension (avoids target_dim RNG inits).
146        //
147        // The hash pass is branchless: surviving terms are compacted into
148        // `terms` (hash outcomes are unpredictable, so branching mispredicts),
149        // then summed in ascending-j order, which keeps the f64 accumulation
150        // sequence identical to adding each surviving term in index order.
151        let vals: Vec<f64> = dense.iter().map(|&v| f64::from(v)).collect();
152        let mut terms = vec![0.0f64; vals.len()];
153        let mut projections: Vec<(u32, f64)> = (0..target_dim.div_ceil(2))
154            .map(|i| {
155                let mut kept = 0usize;
156                for (j, &val) in vals.iter().enumerate() {
157                    let r = hash_u64(i as u64, j as u64) % 6;
158                    terms[kept] = if r == 5 { -val } else { val };
159                    kept += usize::from((r == 0) | (r == 5));
160                }
161                let mut dot_product = 0.0f64;
162                for &t in &terms[..kept] {
163                    dot_product += t;
164                }
165                let index = i * 2 + usize::from(dot_product < 0.0);
166                (
167                    index.min(target_dim.saturating_sub(1)) as u32,
168                    dot_product.abs(),
169                )
170            })
171            .collect();
172
173        // Select top active_count by magnitude
174        if projections.len() > active_count {
175            projections.select_nth_unstable_by(active_count - 1, |a, b| {
176                b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
177            });
178            projections.truncate(active_count);
179        }
180
181        let mut indices: Vec<u32> = projections.into_iter().map(|(idx, _)| idx).collect();
182        indices.sort_unstable();
183
184        Self {
185            dim: target_dim,
186            indices,
187        }
188    }
189
190    /// Encode a scalar into a sparse hypervector via permuted sliding window.
191    ///
192    /// The normalized value selects a contiguous window of `active_count`
193    /// logical slots in `[0, target_dim - active_count]`. Each logical slot
194    /// is then scattered across the index space by multiplying with a prime
195    /// constant modulo `target_dim` (golden-ratio permutation). This gives:
196    /// - Nearby values share most window slots → high Jaccard
197    /// - Distant values share few slots → low Jaccard
198    /// - Monotonic: similarity decreases with scalar distance
199    pub fn from_scalar(value: f64, min_val: f64, max_val: f64, target_dim: usize) -> Self {
200        let range = max_val - min_val;
201        let normalized = if range.abs() < f64::EPSILON {
202            0.5
203        } else {
204            ((value - min_val) / range).clamp(0.0, 1.0)
205        };
206        let active_count = (target_dim / DEFAULT_RHO_DENOM).max(1);
207
208        // Map normalized value to a starting offset in the index space.
209        // We use (target_dim - active_count) possible start positions.
210        let max_offset = (target_dim - active_count) as f64;
211        let start_offset = (normalized * max_offset).round() as usize;
212
213        // Deterministic permutation for global spread with local overlap monotonicity.
214        // Must be prime so gcd(PERM_PRIME, dim) = 1 for all dim, ensuring a
215        // full-period bijection. (The old golden-ratio constant was divisible by 5,
216        // restricting output to 20% of dimensions for dim % 5 == 0.)
217        const PERM_PRIME: u64 = 0x9E3779B97F4A7C55;
218
219        let mut indices = Vec::with_capacity(active_count);
220        for i in 0..active_count {
221            let logical_idx = (start_offset + i) as u64;
222            // Deterministic shuffle: (logical_idx * prime) % target_dim
223            let idx = (logical_idx.wrapping_mul(PERM_PRIME) % target_dim as u64) as u32;
224            indices.push(idx);
225        }
226
227        indices.sort_unstable();
228        // PERM_PRIME is prime ⇒ gcd(PERM_PRIME, target_dim)=1 ⇒ mapping is injective.
229        // Dedup is a defensive no-op; no backfill needed (unlike random() where
230        // hash collisions across entanglement groups are possible).
231        indices.dedup();
232
233        Self {
234            dim: target_dim,
235            indices,
236        }
237    }
238
239    /// Active bit indices (sorted).
240    pub fn indices(&self) -> &[u32] {
241        &self.indices
242    }
243
244    /// Convert sorted indices to deltas (gaps between consecutive indices).
245    /// First element is the absolute value.
246    pub fn to_deltas(&self) -> Vec<u32> {
247        if self.indices.is_empty() {
248            return Vec::new();
249        }
250        let mut deltas = Vec::with_capacity(self.indices.len());
251        deltas.push(self.indices[0]);
252        for i in 1..self.indices.len() {
253            deltas.push(self.indices[i] - self.indices[i - 1]);
254        }
255        deltas
256    }
257
258    /// Restore absolute indices from deltas.
259    pub fn from_deltas(deltas: &[u32], dim: usize) -> Self {
260        if deltas.is_empty() {
261            return Self {
262                dim,
263                indices: Vec::new(),
264            };
265        }
266        let mut indices = Vec::with_capacity(deltas.len());
267        let mut current: u32 = 0;
268        for &d in deltas {
269            current = match current.checked_add(d) {
270                Some(v) if (v as usize) < dim => v,
271                _ => break,
272            };
273            indices.push(current);
274        }
275        Self { dim, indices }
276    }
277
278    /// Hamming distance via sorted-merge intersection count on sparse index sets.
279    pub fn hamming(&self, other: &Self) -> u32 {
280        let intersection =
281            super::intersection::sparse_intersection_count(&self.indices, &other.indices);
282        (self.indices.len() + other.indices.len() - 2 * intersection) as u32
283    }
284
285    /// Jaccard similarity: |A∩B| / |A∪B|.
286    /// Proper metric for sparse index sets (ρ=1/256).
287    /// The old 1−hamming/dim collapsed to ~0.992 for all sparse pairs.
288    pub fn similarity(&self, other: &Self) -> f64 {
289        let intersection =
290            super::intersection::sparse_intersection_count(&self.indices, &other.indices);
291        let union = self.indices.len() + other.indices.len() - intersection;
292        if union == 0 {
293            return 1.0;
294        }
295        intersection as f64 / union as f64
296    }
297
298    /// Entangled bind: symmetric XOR of index sets.
299    /// For sparse vectors, XOR of active sets = symmetric difference.
300    pub fn bind(&self, other: &Self) -> Self {
301        let indices = sorted_symmetric_difference(&self.indices, &other.indices);
302        Self {
303            dim: self.dim,
304            indices,
305        }
306    }
307
308    /// Permute by shifting all indices by `shifts` modulo dim.
309    pub fn permute(&self, shifts: usize) -> Self {
310        if self.dim == 0 {
311            return self.clone();
312        }
313        let dim = self.dim as u32;
314        let shift = (shifts % self.dim) as u32;
315        let mut indices: Vec<u32> = self
316            .indices
317            .iter()
318            .map(|&idx| (idx + shift) % dim)
319            .collect();
320        indices.sort_unstable();
321        Self {
322            dim: self.dim,
323            indices,
324        }
325    }
326
327    /// Nonlinear index permutation via keyed hash.
328    ///
329    /// Maps each active index through hash(idx, seed) % dim, producing a
330    /// completely scrambled view. Unlike circular shift (permute), this
331    /// destroys spatial correlation between indices, making containment
332    /// values independent across different seeds.
333    ///
334    /// Nearly density-preserving: hash collisions reduce count by ~0.1%
335    /// at density 1/256.
336    pub fn hash_permute(&self, seed: u64) -> Self {
337        if self.dim == 0 {
338            return self.clone();
339        }
340        let d = self.dim as u64;
341        let mut indices: Vec<u32> = self
342            .indices
343            .iter()
344            .map(|&idx| (hash_u64(idx as u64, seed) % d) as u32)
345            .collect();
346        indices.sort_unstable();
347        indices.dedup();
348        Self {
349            dim: self.dim,
350            indices,
351        }
352    }
353
354    /// Bundle multiple sparse vectors with threshold at rho.
355    /// Counts per-index frequency, keeps only those above threshold.
356    pub fn bundle<V: Borrow<Self>>(vectors: &[V]) -> Self {
357        if vectors.is_empty() {
358            return Self {
359                dim: 0,
360                indices: Vec::new(),
361            };
362        }
363        let dim = vectors[0].borrow().dim;
364        let n = vectors.len();
365
366        let mut all_indices: Vec<u32> = vectors
367            .iter()
368            .flat_map(|v| v.borrow().indices.iter().copied())
369            .collect();
370        all_indices.sort_unstable();
371
372        if all_indices.is_empty() {
373            return Self {
374                dim,
375                indices: Vec::new(),
376            };
377        }
378
379        // Run-length count on sorted indices, filtering by threshold while counting.
380        let threshold = (n as u32).div_ceil(2);
381        let mut selected: Vec<(u32, u32)> = Vec::new();
382
383        let mut current = all_indices[0];
384        let mut count: u32 = 1;
385        for &idx in &all_indices[1..] {
386            if idx == current {
387                count += 1;
388            } else {
389                if count >= threshold {
390                    selected.push((current, count));
391                }
392                current = idx;
393                count = 1;
394            }
395        }
396        if count >= threshold {
397            selected.push((current, count));
398        }
399
400        let target_count = (dim / DEFAULT_RHO_DENOM).max(1);
401
402        // If too many pass threshold, keep top target_count by count
403        if selected.len() > target_count {
404            selected.select_nth_unstable_by(target_count - 1, |a, b| b.1.cmp(&a.1));
405            selected.truncate(target_count);
406        }
407
408        let mut indices: Vec<u32> = selected.into_iter().map(|(idx, _)| idx).collect();
409        indices.sort_unstable();
410
411        Self { dim, indices }
412    }
413
414    /// Bloom-filter bundling: set UNION of all input vectors.
415    ///
416    /// Instead of majority vote (which fails at K≥3 for sparse vectors with
417    /// density 1/256), takes the union of all active indices. The bundle grows
418    /// denser with more items. Use `containment_similarity` to query.
419    ///
420    /// Validated by HyperCam (arxiv 2501.10547): Bloom filter bundling achieves
421    /// 2 orders of magnitude reduction in encoding operations for sparse HDC.
422    pub fn bundle_bloom<V: Borrow<Self>>(vectors: &[V]) -> Self {
423        if vectors.is_empty() {
424            return Self {
425                dim: 0,
426                indices: Vec::new(),
427            };
428        }
429        let dim = vectors[0].borrow().dim;
430
431        let mut all_indices: Vec<u32> = vectors
432            .iter()
433            .flat_map(|v| v.borrow().indices.iter().copied())
434            .collect();
435        all_indices.sort_unstable();
436        all_indices.dedup();
437
438        Self {
439            dim,
440            indices: all_indices,
441        }
442    }
443
444    /// Containment similarity: |A∩B| / |A|.
445    ///
446    /// Measures what fraction of `self`'s active indices appear in `other`.
447    /// The correct metric for Bloom-filter bundles: if A was bundled into B,
448    /// containment(A, B) ≈ 1.0 regardless of how many other items are in B.
449    pub fn containment_similarity(&self, other: &Self) -> f64 {
450        if self.indices.is_empty() {
451            return 1.0;
452        }
453        let intersection =
454            super::intersection::sparse_intersection_count(&self.indices, &other.indices);
455        intersection as f64 / self.indices.len() as f64
456    }
457
458    /// Density-corrected containment: (raw - density) / (1 - density).
459    ///
460    /// Subtracts the expected noise floor (bundle density) and normalizes.
461    /// Members: raw=1.0, corrected=1.0. Non-members: raw≈density, corrected≈0.
462    /// This is the correct metric for large Bloom bundles where raw containment
463    /// has a high floor due to bundle density.
464    pub fn corrected_containment(&self, bloom_bundle: &Self) -> f64 {
465        let raw = self.containment_similarity(bloom_bundle);
466        let density = bloom_bundle.indices.len() as f64 / bloom_bundle.dim.max(1) as f64;
467        if density >= 1.0 - 1e-15 {
468            return 0.0;
469        }
470        ((raw - density) / (1.0 - density)).max(0.0)
471    }
472
473    /// Non-commutative binding: permute(self, 1) XOR other.
474    ///
475    /// Captures order: bind_ordered(A, B) ≠ bind_ordered(B, A).
476    /// ~2x cost of plain XOR (one permute + one XOR). Inverse: unbind
477    /// B first (XOR), then inverse-permute to recover A.
478    pub fn bind_ordered(&self, other: &Self) -> Self {
479        self.permute(1).bind(other)
480    }
481
482    /// Inverse of bind_ordered: given C = bind_ordered(A, B), recover A.
483    /// A = permute_inv(C XOR B, 1)
484    pub fn unbind_ordered_left(&self, other: &Self) -> Self {
485        let xored = self.bind(other);
486        xored.permute(self.dim - 1)
487    }
488
489    /// Fixed-domain, contribution-clipped histogram followed by noisy top-k.
490    /// The dimension must be public; prefer HmsCore::bundle for empty datasets.
491    pub fn bundle_dp<V: Borrow<Self>>(vectors: &[V], epsilon: f64) -> Self {
492        let dim = vectors.first().map_or(0, |v| v.borrow().dim);
493        Self::bundle_dp_in_space(vectors, epsilon, dim)
494    }
495
496    pub(crate) fn bundle_dp_in_space<V: Borrow<Self>>(
497        vectors: &[V],
498        epsilon: f64,
499        dim: usize,
500    ) -> Self {
501        assert!(
502            epsilon.is_finite() && epsilon > 0.0,
503            "epsilon must be finite and positive"
504        );
505        assert!(
506            vectors.iter().all(|v| v.borrow().dim == dim),
507            "bundle dimensions must match"
508        );
509        if dim == 0 {
510            return Self::from_indices(Vec::new(), 0);
511        }
512        let contribution_limit = (dim / DEFAULT_RHO_DENOM).max(1);
513        let mut counts = vec![0.0; dim];
514        for vector in vectors {
515            for &index in vector.borrow().indices.iter().take(contribution_limit) {
516                counts[index as usize] += 1.0;
517            }
518        }
519        // IMPORTANT: add/remove adjacency has L1 sensitivity contribution_limit.
520        // Every public-domain bin receives noise, including unobserved bins.
521        let scale = contribution_limit as f64 / epsilon;
522        assert!(
523            scale.is_finite() && scale > 0.0,
524            "epsilon is outside the supported numeric range"
525        );
526        let mut rng = rand::rng();
527        let mut scores: Vec<(u32, f64)> = counts
528            .into_iter()
529            .enumerate()
530            .map(|(i, count)| (i as u32, count + laplace_sample(&mut rng, scale)))
531            .collect();
532        scores.select_nth_unstable_by(contribution_limit - 1, |a, b| {
533            b.1.total_cmp(&a.1).then(a.0.cmp(&b.0))
534        });
535        scores.truncate(contribution_limit);
536        let mut indices: Vec<u32> = scores.into_iter().map(|(index, _)| index).collect();
537        indices.sort_unstable();
538        Self::from_indices(indices, dim)
539    }
540}
541
542/// Sample from Laplace(0, scale) using inverse CDF.
543fn laplace_sample(rng: &mut impl rand::RngExt, scale: f64) -> f64 {
544    let u = loop {
545        let sample = rng.random::<f64>() - 0.5;
546        if sample.abs() < 0.5 {
547            break sample;
548        }
549    };
550    -scale * u.signum() * (1.0 - 2.0 * u.abs()).ln()
551}
552
553/// Splitmix64-based mixer with full avalanche. Two inputs are combined via
554/// wrapping arithmetic then passed through the splitmix64 finalizer.
555/// Replaces FxHasher which has poor avalanche for close seeds.
556pub fn hash_u64(a: u64, b: u64) -> u64 {
557    let mut z = a.wrapping_mul(0x9E3779B97F4A7C15).wrapping_add(b);
558    z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
559    z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
560    z ^ (z >> 31)
561}
562
563/// Symmetric difference of two sorted slices (XOR for index sets).
564pub(crate) fn sorted_symmetric_difference(a: &[u32], b: &[u32]) -> Vec<u32> {
565    let mut result = Vec::with_capacity(a.len() + b.len());
566    let mut i = 0;
567    let mut j = 0;
568    while i < a.len() && j < b.len() {
569        match a[i].cmp(&b[j]) {
570            std::cmp::Ordering::Less => {
571                result.push(a[i]);
572                i += 1;
573            }
574            std::cmp::Ordering::Greater => {
575                result.push(b[j]);
576                j += 1;
577            }
578            std::cmp::Ordering::Equal => {
579                // In both → cancel (XOR)
580                i += 1;
581                j += 1;
582            }
583        }
584    }
585    result.extend_from_slice(&a[i..]);
586    result.extend_from_slice(&b[j..]);
587    result
588}
589
590/// Count of symmetric difference (without collecting).
591pub(crate) fn sorted_symmetric_difference_count(a: &[u32], b: &[u32]) -> usize {
592    let mut count = 0;
593    let mut i = 0;
594    let mut j = 0;
595    while i < a.len() && j < b.len() {
596        match a[i].cmp(&b[j]) {
597            std::cmp::Ordering::Less => {
598                count += 1;
599                i += 1;
600            }
601            std::cmp::Ordering::Greater => {
602                count += 1;
603                j += 1;
604            }
605            std::cmp::Ordering::Equal => {
606                i += 1;
607                j += 1;
608            }
609        }
610    }
611    count += a.len() - i;
612    count += b.len() - j;
613    count
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619
620    #[test]
621    fn test_entangled_self_similarity() {
622        let v = EntangledHVec::new_deterministic(16384, 42);
623        assert!(
624            (v.similarity(&v) - 1.0).abs() < 0.0001,
625            "Self-similarity should be 1.0"
626        );
627    }
628
629    #[test]
630    fn test_entangled_random_pair_distance() {
631        // Two random sparse vectors should have near-zero Jaccard overlap
632        let a = EntangledHVec::new_deterministic(16384, 1);
633        let b = EntangledHVec::new_deterministic(16384, 2);
634        let sim = a.similarity(&b);
635        // Jaccard for random sparse pairs: ~64*64/16384 intersect / ~128 union ≈ 0.002
636        assert!(
637            sim < 0.05,
638            "Random sparse pair Jaccard {:.4} should be near 0",
639            sim
640        );
641    }
642
643    #[test]
644    fn test_entangled_bind_involution() {
645        let a = EntangledHVec::new_deterministic(16384, 1);
646        let b = EntangledHVec::new_deterministic(16384, 2);
647        let ab = a.bind(&b);
648        let recovered = ab.bind(&b);
649        // XOR is involutory: (A⊕B)⊕B = A
650        assert_eq!(recovered.indices, a.indices);
651    }
652
653    #[test]
654    fn test_entangled_bundle_majority() {
655        let dim = 16384;
656        let base = EntangledHVec::new_deterministic(dim, 100);
657        // Create 5 copies of base + 2 random noise
658        let mut vecs = vec![base.clone(); 5];
659        vecs.push(EntangledHVec::new_deterministic(dim, 200));
660        vecs.push(EntangledHVec::new_deterministic(dim, 300));
661        let bundled = EntangledHVec::bundle(&vecs);
662        // Bundled should be most similar to base (majority)
663        let sim_base = bundled.similarity(&base);
664        let sim_random = bundled.similarity(&EntangledHVec::new_deterministic(dim, 400));
665        assert!(
666            sim_base > sim_random,
667            "Bundle should be closer to majority element"
668        );
669    }
670
671    #[test]
672    fn test_from_dense_produces_sparse() {
673        let dense: Vec<f32> = (0..128).map(|i| (i as f32 - 64.0) / 64.0).collect();
674        let e = EntangledHVec::from_dense(&dense, 10000);
675        assert_eq!(e.dim, 10000);
676        // Should have ~dim/256 active indices
677        let expected = 10000 / 256;
678        assert_eq!(e.indices.len(), expected);
679    }
680
681    #[test]
682    fn test_from_scalar() {
683        let e = EntangledHVec::from_scalar(0.5, 0.0, 1.0, 10000);
684        assert_eq!(e.dim, 10000);
685        assert!(!e.indices.is_empty());
686    }
687
688    #[test]
689    fn test_bundle_dp_produces_valid_output() {
690        let dim = 16384;
691        let base = EntangledHVec::new_deterministic(dim, 100);
692        let vecs = vec![base.clone(); 5];
693        let bundled = EntangledHVec::bundle_dp(&vecs, 1.0);
694        assert_eq!(bundled.dim, dim);
695        assert!(!bundled.indices.is_empty());
696        assert!(bundled.indices.windows(2).all(|w| w[0] < w[1]));
697    }
698
699    #[test]
700    fn test_bundle_dp_high_epsilon_matches_deterministic() {
701        let dim = 16384;
702        let base = EntangledHVec::new_deterministic(dim, 100);
703        let vecs = vec![base.clone(); 7];
704        let det = EntangledHVec::bundle(&vecs);
705        // Very high epsilon = almost no noise
706        let dp = EntangledHVec::bundle_dp(&vecs, 1000.0);
707        let sim = det.similarity(&dp);
708        assert!(
709            sim > 0.8,
710            "High-epsilon DP bundle should closely match deterministic: {:.4}",
711            sim
712        );
713    }
714
715    #[test]
716    fn test_bundle_dp_low_epsilon_adds_noise() {
717        let dim = 16384;
718        let base = EntangledHVec::new_deterministic(dim, 100);
719        let noise = EntangledHVec::new_deterministic(dim, 200);
720        let vecs = vec![base.clone(), base.clone(), base.clone(), noise];
721        let det = EntangledHVec::bundle(&vecs);
722        // Run multiple low-epsilon bundles and check they differ from deterministic
723        let mut diffs = 0;
724        for _ in 0..5 {
725            let dp = EntangledHVec::bundle_dp(&vecs, 0.1);
726            if dp.indices != det.indices {
727                diffs += 1;
728            }
729        }
730        assert!(diffs > 0, "Low-epsilon DP should produce varied outputs");
731    }
732
733    #[test]
734    fn test_bloom_bundle_capacity() {
735        let dim = 16384;
736        let vecs: Vec<EntangledHVec> = (0..200)
737            .map(|i| EntangledHVec::new_deterministic(dim, i * 100))
738            .collect();
739        let bundle = EntangledHVec::bundle_bloom(&vecs);
740        assert_eq!(bundle.dim, dim);
741        for (i, v) in vecs.iter().enumerate() {
742            let sim = v.containment_similarity(&bundle);
743            assert!(
744                sim > 0.9,
745                "Item {} containment in bundle of 200 should be >0.9, got {:.4}",
746                i,
747                sim
748            );
749        }
750        let noise = EntangledHVec::new_deterministic(dim, 999999);
751        let noise_sim = noise.containment_similarity(&bundle);
752        assert!(
753            noise_sim < 0.9,
754            "Non-member containment should be low, got {:.4}",
755            noise_sim
756        );
757    }
758
759    #[test]
760    fn test_containment_self() {
761        let v = EntangledHVec::new_deterministic(16384, 42);
762        assert!(
763            (v.containment_similarity(&v) - 1.0).abs() < 0.0001,
764            "Self-containment should be 1.0"
765        );
766    }
767
768    #[test]
769    fn test_bind_ordered_non_commutative() {
770        let a = EntangledHVec::new_deterministic(16384, 1);
771        let b = EntangledHVec::new_deterministic(16384, 2);
772        let ab = a.bind_ordered(&b);
773        let ba = b.bind_ordered(&a);
774        assert_ne!(
775            ab.indices, ba.indices,
776            "bind_ordered must be non-commutative"
777        );
778    }
779
780    #[test]
781    fn test_bind_ordered_invertible() {
782        let a = EntangledHVec::new_deterministic(16384, 1);
783        let b = EntangledHVec::new_deterministic(16384, 2);
784        let c = a.bind_ordered(&b);
785        let recovered = c.unbind_ordered_left(&b);
786        assert_eq!(
787            recovered.indices, a.indices,
788            "unbind_ordered_left should recover A"
789        );
790    }
791
792    #[test]
793    fn test_from_scalar_locality() {
794        let dim = 16384;
795        let base_val = 0.5;
796        let v_base = EntangledHVec::from_scalar(base_val, 0.0, 1.0, dim);
797
798        let mut prev_sim = 1.0;
799        // Check points moving away from 0.5
800        for i in 1..20 {
801            let offset = i as f64 * 0.02;
802            let v_curr = EntangledHVec::from_scalar(base_val + offset, 0.0, 1.0, dim);
803            let sim = v_base.similarity(&v_curr);
804
805            assert!(
806                sim <= prev_sim,
807                "Monotonicity violation at offset {}: sim={:.4} was prev={:.4}",
808                offset,
809                sim,
810                prev_sim
811            );
812            prev_sim = sim;
813        }
814    }
815
816    /// Verbatim pre-optimisation `from_dense` (encoder "signed-projection-v2").
817    fn from_dense_reference(dense: &[f32], target_dim: usize) -> EntangledHVec {
818        let active_count = (target_dim / DEFAULT_RHO_DENOM).max(1);
819        if dense.is_empty() {
820            return EntangledHVec {
821                dim: target_dim,
822                indices: Vec::new(),
823            };
824        }
825        let mut projections: Vec<(u32, f64)> = (0..target_dim.div_ceil(2))
826            .map(|i| {
827                let mut dot_product = 0.0f64;
828                for (j, &val) in dense.iter().enumerate() {
829                    let r = hash_u64(i as u64, j as u64) % 6;
830                    if r == 0 {
831                        dot_product += val as f64;
832                    } else if r == 5 {
833                        dot_product -= val as f64;
834                    }
835                }
836                let index = i * 2 + usize::from(dot_product < 0.0);
837                (
838                    index.min(target_dim.saturating_sub(1)) as u32,
839                    dot_product.abs(),
840                )
841            })
842            .collect();
843        if projections.len() > active_count {
844            projections.select_nth_unstable_by(active_count - 1, |a, b| {
845                b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
846            });
847            projections.truncate(active_count);
848        }
849        let mut indices: Vec<u32> = projections.into_iter().map(|(idx, _)| idx).collect();
850        indices.sort_unstable();
851        EntangledHVec {
852            dim: target_dim,
853            indices,
854        }
855    }
856
857    #[test]
858    fn from_dense_matches_reference() {
859        let mut state = 0x1234_5678_9ABC_DEF0u64;
860        let mut next = move || {
861            state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
862            hash_u64(state, 0)
863        };
864        let unit = |x: u64| (x >> 11) as f64 / (1u64 << 53) as f64;
865        let mut cases = 0usize;
866        for emb in [64usize, 384, 768, 1536] {
867            for (d, n) in [(4096usize, 100usize), (16384, 30), (65536, 3)] {
868                for k in 0..n {
869                    let v: Vec<f32> = (0..emb)
870                        .map(|_| {
871                            let r = next();
872                            match k % 6 {
873                                // continuous
874                                0 | 1 => (unit(r) * 2.0 - 1.0) as f32,
875                                // all zero
876                                2 if k < 6 => 0.0,
877                                // heavy ties: few distinct values
878                                2 | 3 => (r % 3) as f32 - 1.0,
879                                // constant
880                                4 => 0.5,
881                                // extremes and subnormals, finite only
882                                _ => match r % 5 {
883                                    0 => f32::MAX,
884                                    1 => f32::MIN,
885                                    2 => f32::MIN_POSITIVE / 4.0,
886                                    3 => -0.0,
887                                    _ => 0.0,
888                                },
889                            }
890                        })
891                        .collect();
892                    let got = EntangledHVec::from_dense(&v, d);
893                    let want = from_dense_reference(&v, d);
894                    assert_eq!(got.dim, want.dim);
895                    assert_eq!(got.indices, want.indices, "emb={emb} D={d} case={k}");
896                    cases += 1;
897                }
898            }
899        }
900        assert!(cases >= 500);
901    }
902}