use ndarray::{Array1, Array2, ArrayView1};
use rand::SeedableRng;
use rand::rngs::StdRng;
use std::collections::HashMap;
const INDEX_HYPERPLANE_SALT: u64 = 0x9E37_79B9_7F4A_7C15;
const DIRECTION_NORM_FLOOR: f64 = 1e-12;
pub const CANDIDATE_BUDGET_MIN: usize = 32;
pub const CANDIDATE_BUDGET_MAX: usize = 128;
pub trait AtomFrameSketch {
fn sketch_dim(&self) -> usize;
fn output_dim(&self) -> usize;
fn num_atoms(&self) -> usize;
fn atom_sketch(&self, atom_id: usize) -> Array1<f64>;
fn atom_bucket_sketches(&self, atom_id: usize) -> Vec<Array1<f64>> {
vec![self.atom_sketch(atom_id)]
}
fn project_direction(&self, atom_id: usize, direction: ArrayView1<f64>) -> Array1<f64>;
fn alignment(&self, atom_id: usize, direction: ArrayView1<f64>) -> f64;
fn query_sketch(&self, direction: ArrayView1<f64>) -> Array1<f64>;
}
pub struct RandomProjectionFrameSketch {
frames: Vec<Array2<f64>>,
projection: Array2<f64>,
output_dim: usize,
sketch_dim: usize,
}
impl RandomProjectionFrameSketch {
fn in_range_component(&self, atom_id: usize, direction: ArrayView1<f64>) -> Array1<f64> {
let frame = &self.frames[atom_id];
let mut comp = Array1::<f64>::zeros(self.output_dim);
for col in 0..frame.ncols() {
let u = frame.column(col);
let coord: f64 = u.iter().zip(direction.iter()).map(|(&a, &b)| a * b).sum();
for (c, &uval) in comp.iter_mut().zip(u.iter()) {
*c += coord * uval;
}
}
comp
}
}
impl AtomFrameSketch for RandomProjectionFrameSketch {
fn sketch_dim(&self) -> usize {
self.sketch_dim
}
fn output_dim(&self) -> usize {
self.output_dim
}
fn num_atoms(&self) -> usize {
self.frames.len()
}
fn atom_sketch(&self, atom_id: usize) -> Array1<f64> {
let frame = &self.frames[atom_id];
if frame.ncols() == 0 {
let mut s = self.projection.column(0).to_owned();
normalize_in_place(&mut s);
return s;
}
let u0 = frame.column(0);
let mut s = mat_vec(&self.projection, u0);
normalize_in_place(&mut s);
s
}
fn atom_bucket_sketches(&self, atom_id: usize) -> Vec<Array1<f64>> {
let frame = &self.frames[atom_id];
if frame.ncols() == 0 {
return vec![self.atom_sketch(atom_id)];
}
let r = frame.ncols();
let mut sketches = Vec::with_capacity(r * r);
for col in 0..r {
let mut sk = mat_vec(&self.projection, frame.column(col));
normalize_in_place(&mut sk);
sketches.push(sk);
}
for i in 0..r {
for j in (i + 1)..r {
for &sign in &[1.0_f64, -1.0] {
let mut dir = frame.column(i).to_owned();
dir.scaled_add(sign, &frame.column(j));
let mut sk = mat_vec(&self.projection, dir.view());
normalize_in_place(&mut sk);
sketches.push(sk);
}
}
}
sketches
}
fn project_direction(&self, atom_id: usize, direction: ArrayView1<f64>) -> Array1<f64> {
let comp = self.in_range_component(atom_id, direction);
mat_vec(&self.projection, comp.view())
}
fn query_sketch(&self, direction: ArrayView1<f64>) -> Array1<f64> {
let mut s = mat_vec(&self.projection, direction);
normalize_in_place(&mut s);
s
}
fn alignment(&self, atom_id: usize, direction: ArrayView1<f64>) -> f64 {
let dnorm = vec_norm(direction);
if dnorm < DIRECTION_NORM_FLOOR {
return 0.0;
}
let comp = self.in_range_component(atom_id, direction);
(vec_norm(comp.view()) / dnorm).clamp(0.0, 1.0)
}
}
pub struct SaeCandidateIndex {
num_atoms: usize,
}
#[derive(Clone, Copy, Debug)]
pub struct IndexConfig {
pub num_tables: usize,
pub bits_per_table: usize,
pub multiprobe: bool,
pub seed: u64,
}
impl IndexConfig {
pub fn auto(sketch_dim: usize, num_atoms: usize, seed: u64) -> Self {
let log2 = |n: usize| -> usize {
if n <= 1 {
1
} else {
(usize::BITS - (n - 1).leading_zeros()) as usize
}
};
let bits = log2(num_atoms.max(2)).clamp(1, sketch_dim.max(1).min(63));
let num_tables = log2(num_atoms.max(2)).clamp(4, 16);
Self {
num_tables,
bits_per_table: bits,
multiprobe: true,
seed,
}
}
}
impl SaeCandidateIndex {
pub fn build<S: AtomFrameSketch>(sketch: &S, config: IndexConfig) -> Result<Self, String> {
let sketch_dim = sketch.sketch_dim();
if sketch_dim == 0 {
return Err("SaeCandidateIndex: sketch_dim must be positive".into());
}
if config.num_tables == 0 || config.bits_per_table == 0 {
return Err("SaeCandidateIndex: num_tables and bits_per_table must be positive".into());
}
if config.bits_per_table > 63 {
return Err(format!(
"SaeCandidateIndex: bits_per_table {} exceeds 63 (u64 signature limit)",
config.bits_per_table
));
}
let num_atoms = sketch.num_atoms();
let hyperplanes: Vec<Array2<f64>> = (0..config.num_tables)
.map(|t| {
let table_seed = mix_seed(config.seed ^ INDEX_HYPERPLANE_SALT, t as u64);
gaussian_projection(config.bits_per_table, sketch_dim, table_seed)
})
.collect();
let mut tables: Vec<HashMap<u64, Vec<usize>>> =
(0..config.num_tables).map(|_| HashMap::new()).collect();
for atom_id in 0..num_atoms {
let bucket_sketches = sketch.atom_bucket_sketches(atom_id);
if bucket_sketches.is_empty() {
return Err(format!(
"SaeCandidateIndex: atom {atom_id} produced no bucket representatives"
));
}
for s in &bucket_sketches {
if s.len() != sketch_dim {
return Err(format!(
"SaeCandidateIndex: atom {atom_id} sketch length {} != sketch_dim {sketch_dim}",
s.len()
));
}
for (table, bank) in tables.iter_mut().zip(hyperplanes.iter()) {
let sig = sign_signature(bank, s.view());
let bucket = table.entry(sig).or_default();
if bucket.last() != Some(&atom_id) {
bucket.push(atom_id);
}
}
}
}
Ok(Self {
num_atoms,
})
}
pub fn num_atoms(&self) -> usize {
self.num_atoms
}
}
pub const ROUTING_ALIGNMENT_UPPER_BOUND: f64 = 1.0;
pub const ROUTING_CERT_EPS: f64 = 1e-12;
#[derive(Clone, Copy, Debug)]
pub struct ExactRoute {
pub atom: usize,
pub alignment: f64,
pub lsh_certified: bool,
pub lsh_agreed: bool,
pub did_full_scan: bool,
}
#[derive(Clone, Debug)]
pub struct Proposal {
pub proposed: Vec<usize>,
pub dropped_for_budget: Vec<usize>,
pub gathered_count: usize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MissReason {
NotGathered,
TruncatedByBudget,
}
#[derive(Clone, Copy, Debug)]
pub struct RecallMiss {
pub row: usize,
pub atom: usize,
pub alignment: f64,
pub reason: MissReason,
}
#[derive(Clone, Debug)]
pub struct RecallReport {
pub candidate_budget: usize,
pub num_rows: usize,
pub total_planted: usize,
pub total_recovered: usize,
pub recall: f64,
pub avg_candidates_gathered: f64,
pub num_atoms: usize,
pub misses: Vec<RecallMiss>,
}
#[derive(Clone, Debug)]
pub struct ProposalRecallReport {
pub candidate_budget: usize,
pub top_s: usize,
pub num_rows: usize,
pub total_true: usize,
pub total_recovered: usize,
pub recall: f64,
pub avg_candidates_gathered: f64,
pub num_atoms: usize,
pub misses: Vec<RecallMiss>,
}
#[inline]
fn mix_seed(base: u64, idx: u64) -> u64 {
let mut state = base
.wrapping_add(idx.wrapping_mul(0x9E37_79B9_7F4A_7C15))
.wrapping_sub(0x9E37_79B9_7F4A_7C15);
gam_linalg::utils::splitmix64(&mut state)
}
fn gaussian_projection(rows: usize, cols: usize, seed: u64) -> Array2<f64> {
use rand::RngExt as _;
let mut rng = StdRng::seed_from_u64(seed);
let mut m = Array2::<f64>::zeros((rows, cols));
for r in 0..rows {
for c in 0..cols {
let u1 = rng.random::<f64>().max(1e-16);
let u2 = rng.random::<f64>();
m[(r, c)] = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
}
}
m
}
fn mat_vec(m: &Array2<f64>, v: ArrayView1<f64>) -> Array1<f64> {
let mut out = Array1::<f64>::zeros(m.nrows());
for r in 0..m.nrows() {
let row = m.row(r);
out[r] = row.iter().zip(v.iter()).map(|(&a, &b)| a * b).sum();
}
out
}
#[inline]
fn vec_norm(v: ArrayView1<f64>) -> f64 {
v.iter().map(|&x| x * x).sum::<f64>().sqrt()
}
#[inline]
fn normalize_in_place(v: &mut Array1<f64>) {
let n = vec_norm(v.view());
if n > DIRECTION_NORM_FLOOR {
for x in v.iter_mut() {
*x /= n;
}
}
}
fn canonical_signature(sig: u64, bits: usize) -> u64 {
let mask = if bits >= 64 {
u64::MAX
} else {
(1u64 << bits) - 1
};
let complement = (!sig) & mask;
sig.min(complement)
}
fn sign_signature(bank: &Array2<f64>, s: ArrayView1<f64>) -> u64 {
let mut sig = 0u64;
for r in 0..bank.nrows() {
let row = bank.row(r);
let dot: f64 = row.iter().zip(s.iter()).map(|(&a, &b)| a * b).sum();
if dot >= 0.0 {
sig |= 1u64 << r;
}
}
canonical_signature(sig, bank.nrows())
}