use data_beans::sparse_io::create_sparse_from_triplets;
use data_beans::sparse_io_vector::SparseIoVec;
use data_beans::alg::collapse_data::{collapse_columns_multilevel_with_hierarchy, MultilevelParams};
use data_beans::alg::random_projection::RandProjOps;
use legume_numeric::param::traits::Inference;
const D: usize = 8;
const N_PER_BATCH: usize = 40;
fn type_profile(i: usize) -> Vec<f32> {
if i.is_multiple_of(2) {
(0..D).map(|g| 10.0 + 3.0 * (g % 2) as f32).collect()
} else {
(0..D).map(|g| 4.0 + 2.0 * ((g + 1) % 2) as f32).collect()
}
}
const MIX: [f32; 4] = [0.9, 0.7, 0.3, 0.1];
fn bulk_profile(j: usize) -> Vec<f32> {
let a = type_profile(0);
let b = type_profile(1);
let m = MIX[j];
a.iter()
.zip(b.iter())
.map(|(&x, &y)| m * x + (1.0 - m) * y)
.collect()
}
fn cohort(tag: &str) -> (SparseIoVec, Vec<&'static str>) {
let platform: Vec<f32> = (0..D).map(|g| if g < D / 2 { 2.0 } else { 0.5 }).collect();
let mut cols: Vec<Vec<f32>> = Vec::new();
let mut batches: Vec<&'static str> = Vec::new();
for i in 0..2 * N_PER_BATCH {
let wiggle = 1.0 + 0.05 * ((i % 5) as f32 - 2.0);
cols.push(type_profile(i).into_iter().map(|v| v * wiggle).collect());
batches.push("cells");
}
for j in 0..MIX.len() {
cols.push(
bulk_profile(j)
.into_iter()
.zip(platform.iter())
.map(|(v, &p)| v * p)
.collect(),
);
batches.push("blk");
}
let dir = std::env::temp_dir().join(format!("dba_bulk_{tag}_{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("mkdir");
let path = dir.join("bulk.zarr");
let _ = std::fs::remove_dir_all(&path);
let mut triplets: Vec<(u64, u64, f32)> = Vec::new();
for (j, col) in cols.iter().enumerate() {
for (g, &v) in col.iter().enumerate() {
if v != 0.0 {
triplets.push((g as u64, j as u64, v));
}
}
}
let shape = (D, cols.len(), triplets.len());
let mut b = create_sparse_from_triplets(
&triplets,
shape,
Some(path.to_str().expect("utf8")),
Some(&data_beans::sparse_io::SparseIoBackend::Zarr),
)
.expect("create backend");
b.register_row_names_vec(
&(0..D)
.map(|g| format!("g{g}").into_boxed_str())
.collect::<Vec<_>>(),
);
b.register_column_names_vec(
&(0..cols.len())
.map(|c| format!("c{c}").into_boxed_str())
.collect::<Vec<_>>(),
);
let mut v = SparseIoVec::new();
v.push(std::sync::Arc::from(b), None).expect("push");
(v, batches)
}
fn mean_abs_log_delta(out: &data_beans::alg::collapse_data::CollapsedOut, b: usize) -> f32 {
let delta = out.delta.as_ref().expect("delta").posterior_mean();
let col = delta.column(b);
let logs: Vec<f32> = col
.iter()
.filter(|&&v| v > 0.0)
.map(|&v| v.ln().abs())
.collect();
logs.iter().sum::<f32>() / logs.len().max(1) as f32
}
#[test]
fn bulk_composition_does_not_leak_into_delta() {
let (mut v, batches) = cohort("delta");
let proj = v
.project_columns_with_batch_correction(4, None, Some(&batches))
.expect("proj")
.proj;
let params = MultilevelParams {
knn_pb_samples: 3,
num_levels: 1,
sort_dim: 3,
num_opt_iter: 30,
refine: data_beans::alg::refine_multilevel::RefineParams::default(),
output_calibration: legume_numeric::param::traits::CalibrateTarget::All,
anchor_batches: None,
bulk_batches: Some(vec!["blk".into()]),
observe_panels: true,
keep_finest_stats: true,
pb_tree: None,
strata: None,
};
let out = collapse_columns_multilevel_with_hierarchy(&mut v, &proj, &batches, ¶ms)
.expect("collapse");
let finest = &out.levels[0];
let membership = &out.cell_to_pb_per_level[0];
let k = finest.mu_observed.posterior_mean().ncols();
let n_cells = 2 * N_PER_BATCH;
let k_new = k - MIX.len();
for j in 0..MIX.len() {
assert_eq!(
membership[n_cells + j],
k_new + j,
"bulk column {j} not in its own appended group"
);
}
for (c, &g) in membership.iter().take(n_cells).enumerate() {
assert!(g < k_new, "cell {c} landed in a bulk group {g}");
}
for j in 0..MIX.len() {
let platform: Vec<f32> = (0..D).map(|g| if g < D / 2 { 2.0 } else { 0.5 }).collect();
for (g, expected) in bulk_profile(j)
.into_iter()
.zip(platform.iter())
.map(|(v, &p)| v * p)
.enumerate()
{
let back = finest.mu_observed.evidence_mean(g, k_new + j);
assert!(
(back - expected).abs() <= 1e-4 * expected.abs(),
"bulk {j} gene {g}: expected {expected}, evidence {back}"
);
}
}
let adj = finest
.mu_adjusted
.as_ref()
.expect("adjusted")
.posterior_mean();
let obs = finest.mu_observed.posterior_mean();
let moved = (0..MIX.len()).any(|j| {
(0..D).any(|g| {
let (a, o) = (adj[(g, k_new + j)], obs[(g, k_new + j)]);
(a - o).abs() > 1e-3 * o.abs().max(1e-6)
})
});
assert!(
moved,
"no bulk column was adjusted — greedy correction did not engage"
);
let names = v.batch_names().expect("batch names");
let idx = |n: &str| {
names
.iter()
.position(|x| x.as_ref() == n)
.expect("batch present")
};
for b in ["cells", "blk"] {
let d = mean_abs_log_delta(finest, idx(b));
assert!(d.is_finite(), "delta for `{b}` is not finite: {d}");
}
}