use rand::RngExt;
use rand::SeedableRng;
use rand::rngs::StdRng;
use std::collections::BTreeMap;
use crate::atom_codes::SparseAtomCodes;
pub const NULL_REPLICATES: usize = 200;
pub struct CurveballSampler {
rows: Vec<Vec<usize>>,
n_atoms: usize,
rng: StdRng,
}
impl CurveballSampler {
pub fn from_codes(codes: &SparseAtomCodes) -> Self {
let n_atoms = codes.k_atoms();
let mut rows: Vec<Vec<usize>> = Vec::with_capacity(codes.n_obs());
let mut seed = gam_linalg::utils::splitmix64_hash(
(codes.n_obs() as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ (n_atoms as u64),
);
for code in codes.iter() {
let active: Vec<usize> = code.active_mask.iter_ones().collect();
for &a in &active {
seed = gam_linalg::utils::splitmix64_hash(seed ^ (a as u64).wrapping_add(1));
}
seed = gam_linalg::utils::splitmix64_hash(seed ^ 0xD1B5_4A32_D192_ED03);
rows.push(active);
}
Self {
rows,
n_atoms,
rng: StdRng::seed_from_u64(seed),
}
}
pub fn n_ones(&self) -> usize {
self.rows.iter().map(|r| r.len()).sum()
}
pub fn n_rows(&self) -> usize {
self.rows.len()
}
pub fn trade(&mut self) {
let n = self.rows.len();
if n < 2 {
return;
}
let i = self.rng.random_range(0..n);
let mut j = self.rng.random_range(0..n - 1);
if j >= i {
j += 1;
}
let (a, b) = (&self.rows[i], &self.rows[j]);
let mut shared_i: Vec<usize> = Vec::new();
let mut pool: Vec<usize> = Vec::new();
let (mut p, mut q) = (0usize, 0usize);
let mut n_from_i = 0usize;
while p < a.len() && q < b.len() {
match a[p].cmp(&b[q]) {
std::cmp::Ordering::Equal => {
shared_i.push(a[p]);
p += 1;
q += 1;
}
std::cmp::Ordering::Less => {
pool.push(a[p]);
n_from_i += 1;
p += 1;
}
std::cmp::Ordering::Greater => {
pool.push(b[q]);
q += 1;
}
}
}
while p < a.len() {
pool.push(a[p]);
n_from_i += 1;
p += 1;
}
while q < b.len() {
pool.push(b[q]);
q += 1;
}
if pool.is_empty() || n_from_i == 0 || n_from_i == pool.len() {
return;
}
let m = pool.len();
for t in 0..n_from_i {
let swap = t + self.rng.random_range(0..(m - t));
pool.swap(t, swap);
}
let build = |shared: &[usize], extra: &[usize]| -> Vec<usize> {
let mut v: Vec<usize> = Vec::with_capacity(shared.len() + extra.len());
v.extend_from_slice(shared);
v.extend_from_slice(extra);
v.sort_unstable();
v
};
let new_i = build(&shared_i, &pool[..n_from_i]);
let new_j = build(&shared_i, &pool[n_from_i..]);
self.rows[i] = new_i;
self.rows[j] = new_j;
}
pub fn mix(&mut self, trades: usize) {
for _ in 0..trades {
self.trade();
}
}
fn accumulate(&self, joint: &mut [f64], marg: &mut [f64]) {
let g = self.n_atoms;
for row in &self.rows {
for (idx, &u) in row.iter().enumerate() {
marg[u] += 1.0;
for &v in &row[idx + 1..] {
let (lo, hi) = if u < v { (u, v) } else { (v, u) };
joint[lo * g + hi] += 1.0;
}
}
}
}
fn accumulate_selected(
&self,
pair_to_pos: &BTreeMap<(usize, usize), usize>,
joint: &mut [f64],
) {
for row in &self.rows {
for (idx, &u) in row.iter().enumerate() {
for &v in &row[idx + 1..] {
let key = if u < v { (u, v) } else { (v, u) };
if let Some(&pos) = pair_to_pos.get(&key) {
joint[pos] += 1.0;
}
}
}
}
}
}
#[derive(Clone, Debug)]
pub struct CoactivationExceedance {
g: usize,
n_obs: usize,
obs: Vec<f64>, null_mean: Vec<f64>,
z: Vec<f64>,
}
impl CoactivationExceedance {
fn idx(&self, a: usize, b: usize) -> usize {
let (lo, hi) = if a < b { (a, b) } else { (b, a) };
lo * self.g + hi
}
pub fn excess_z(&self, a: usize, b: usize) -> f64 {
if a == b || a >= self.g || b >= self.g {
return 0.0;
}
self.z[self.idx(a, b)]
}
pub fn observed_joint(&self, a: usize, b: usize) -> f64 {
if a == b || a >= self.g || b >= self.g {
return 0.0;
}
self.obs[self.idx(a, b)]
}
pub fn null_mean_joint(&self, a: usize, b: usize) -> f64 {
if a == b || a >= self.g || b >= self.g {
return 0.0;
}
self.null_mean[self.idx(a, b)]
}
pub fn n_obs(&self) -> usize {
self.n_obs
}
}
const NULL_SD_FLOOR: f64 = 1e-9;
pub fn coactivation_exceedance(
codes: &SparseAtomCodes,
replicates: usize,
) -> CoactivationExceedance {
let g = codes.k_atoms();
let n_obs = codes.n_obs();
let size = g * g;
let mut obs = vec![0.0_f64; size];
let mut obs_marg = vec![0.0_f64; g];
{
let sampler = CurveballSampler::from_codes(codes);
sampler.accumulate(&mut obs, &mut obs_marg);
}
let mut null_mean = vec![0.0_f64; size];
let mut z = vec![0.0_f64; size];
if g < 2 || n_obs < 2 || replicates == 0 {
return CoactivationExceedance {
g,
n_obs,
obs,
null_mean,
z,
};
}
let mut sampler = CurveballSampler::from_codes(codes);
let sweep = sampler.n_ones().max(sampler.n_rows());
sampler.mix(sweep);
let mut mean = vec![0.0_f64; size];
let mut m2 = vec![0.0_f64; size];
let mut scratch = vec![0.0_f64; size];
let mut scratch_marg = vec![0.0_f64; g];
for r in 0..replicates {
sampler.mix(sweep); for v in scratch.iter_mut() {
*v = 0.0;
}
for v in scratch_marg.iter_mut() {
*v = 0.0;
}
sampler.accumulate(&mut scratch, &mut scratch_marg);
let count = (r + 1) as f64;
for u in 0..g {
for w in (u + 1)..g {
let idx = u * g + w;
let x = scratch[idx];
let delta = x - mean[idx];
mean[idx] += delta / count;
m2[idx] += delta * (x - mean[idx]);
}
}
}
let denom = (replicates.saturating_sub(1)).max(1) as f64;
for u in 0..g {
for w in (u + 1)..g {
let idx = u * g + w;
null_mean[idx] = mean[idx];
let var = m2[idx] / denom;
let sd = var.max(0.0).sqrt();
z[idx] = if sd > NULL_SD_FLOOR {
(obs[idx] - mean[idx]) / sd
} else {
0.0
};
}
}
CoactivationExceedance {
g,
n_obs,
obs,
null_mean,
z,
}
}
pub fn coactivation_exceedance_for_pairs(
codes: &SparseAtomCodes,
pairs: &[(usize, usize)],
replicates: usize,
) -> Vec<f64> {
let g = codes.k_atoms();
let n_obs = codes.n_obs();
let mut pair_to_pos = BTreeMap::new();
let mut canonical = Vec::with_capacity(pairs.len());
for &(a, b) in pairs {
if a == b || a >= g || b >= g {
canonical.push(None);
continue;
}
let key = if a < b { (a, b) } else { (b, a) };
let next = pair_to_pos.len();
let pos = *pair_to_pos.entry(key).or_insert(next);
canonical.push(Some(pos));
}
let m = pair_to_pos.len();
if m == 0 {
return vec![0.0; pairs.len()];
}
let mut obs = vec![0.0_f64; m];
let sampler = CurveballSampler::from_codes(codes);
sampler.accumulate_selected(&pair_to_pos, &mut obs);
if g < 2 || n_obs < 2 || replicates == 0 {
return vec![0.0; pairs.len()];
}
let mut sampler = CurveballSampler::from_codes(codes);
let sweep = sampler.n_ones().max(sampler.n_rows());
sampler.mix(sweep);
let mut mean = vec![0.0_f64; m];
let mut m2 = vec![0.0_f64; m];
let mut scratch = vec![0.0_f64; m];
for r in 0..replicates {
sampler.mix(sweep);
for value in scratch.iter_mut() {
*value = 0.0;
}
sampler.accumulate_selected(&pair_to_pos, &mut scratch);
let count = (r + 1) as f64;
for pos in 0..m {
let x = scratch[pos];
let delta = x - mean[pos];
mean[pos] += delta / count;
m2[pos] += delta * (x - mean[pos]);
}
}
let denom = (replicates.saturating_sub(1)).max(1) as f64;
let mut sparse_z = vec![0.0_f64; m];
for pos in 0..m {
let var = m2[pos] / denom;
let sd = var.max(0.0).sqrt();
sparse_z[pos] = if sd > NULL_SD_FLOOR {
(obs[pos] - mean[pos]) / sd
} else {
0.0
};
}
canonical
.into_iter()
.map(|pos| pos.map_or(0.0, |idx| sparse_z[idx]))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn curveball_preserves_both_margins() {
let n = 60usize;
let g = 12usize;
let mut codes = SparseAtomCodes::empty(n, g);
for row in 0..n {
let start = (row * 5) % g;
for off in 0..3 {
codes.row_mut(row).assign((start + off) % g, 1.0);
}
}
let row_sums: Vec<usize> = (0..n).map(|r| codes.row(r).n_active()).collect();
let mut col_sums = vec![0usize; g];
for r in 0..n {
for c in codes.row(r).active_mask.iter_ones() {
col_sums[c] += 1;
}
}
let mut s = CurveballSampler::from_codes(&codes);
s.mix(2000);
for (r, &want) in row_sums.iter().enumerate() {
assert_eq!(s.rows[r].len(), want, "row {r} sum changed");
for w in s.rows[r].windows(2) {
assert!(w[0] < w[1], "row {r} not sorted/unique");
}
}
let mut got_col = vec![0usize; g];
for row in &s.rows {
for &c in row {
got_col[c] += 1;
}
}
assert_eq!(got_col, col_sums, "column sums changed");
}
#[test]
fn top_k_noise_gives_no_exceedance_but_planted_block_does() {
use rand::SeedableRng;
use rand::rngs::StdRng;
use rand::seq::SliceRandom;
let n = 500usize;
let g = 20usize;
let k = 14usize;
let mut rng = StdRng::seed_from_u64(0xA11CE);
let mut noise = SparseAtomCodes::empty(n, g);
let mut atoms: Vec<usize> = (0..g).collect();
for row in 0..n {
atoms.shuffle(&mut rng);
for &a in &atoms[..k] {
noise.row_mut(row).assign(a, 1.0);
}
}
let raw_fires = {
let mut c = 0usize;
for a in 0..g {
for b in (a + 1)..g {
if noise.coactivation(a, b).dependence() >= 0.6 {
c += 1;
}
}
}
c
};
assert!(
raw_fires > 50,
"raw dependence must fire on many top-k-noise pairs; got {raw_fires}"
);
let ex = coactivation_exceedance(&noise, NULL_REPLICATES);
let null_fires = {
let mut c = 0usize;
for a in 0..g {
for b in (a + 1)..g {
if ex.excess_z(a, b) >= 3.0 {
c += 1;
}
}
}
c
};
let total_pairs = g * (g - 1) / 2;
assert!(
null_fires <= total_pairs / 20,
"null-corrected exceedance must fire on ~zero top-k-noise pairs; got \
{null_fires} of {total_pairs}"
);
let mut planted = SparseAtomCodes::empty(n, g);
let block = [0usize, 1, 2, 3];
let mut non_block: Vec<usize> = (block.len()..g).collect();
for row in 0..n {
if row % 5 == 0 {
for &a in &block {
planted.row_mut(row).assign(a, 1.0);
}
non_block.shuffle(&mut rng);
for &a in &non_block[..(k - block.len())] {
planted.row_mut(row).assign(a, 1.0);
}
} else {
non_block.shuffle(&mut rng);
for &a in &non_block[..k] {
planted.row_mut(row).assign(a, 1.0);
}
}
}
let ex_p = coactivation_exceedance(&planted, NULL_REPLICATES);
for a in 0..block.len() {
for b in (a + 1)..block.len() {
let z = ex_p.excess_z(block[a], block[b]);
assert!(
z >= 3.0,
"planted block pair ({},{}) must exceed the fixed-margin null; z={z}",
block[a],
block[b]
);
}
}
}
}