1pub mod adam;
16pub mod config;
17pub mod error;
18pub mod gradient;
19#[cfg(feature = "cubecl")]
20pub mod gpu;
21pub mod knn;
22pub mod pairs;
23pub mod pca;
24pub mod weights;
25
26pub use config::{
27 DistanceMetric, HnswParams, Init, KnnMethod, OptimizeBackend, PaCMAPConfig, Quantization,
28};
29pub use error::PaCMAPError;
30pub use knn::{KnnGraph, NeighborList, compute_knn, read_knn_graph, validate_knn_for_pacmap, write_knn_graph};
31
32use adam::{AdamState, adam_step};
33use gradient::compute_gradient;
34use pairs::build_pairs;
35use pca::pca_init;
36use weights::weights_at;
37
38use std::sync::{
39 Arc,
40 atomic::{AtomicBool, Ordering},
41 mpsc,
42};
43
44#[derive(Debug, Clone)]
46pub struct PaCMAPProgress {
47 pub phase: u8,
49 pub iter: usize,
51 pub total_iters: usize,
53 pub loss: f32,
55}
56
57pub fn fit_transform(
78 data: &[f32],
79 n: usize,
80 d: usize,
81 config: PaCMAPConfig,
82 knn: Option<&KnnGraph>,
83 progress: Option<mpsc::Sender<PaCMAPProgress>>,
84 cancel: Option<Arc<AtomicBool>>,
85) -> Result<Vec<[f32; 2]>, PaCMAPError> {
86 if n < 2 {
88 return Err(PaCMAPError::DatasetTooSmall { n });
89 }
90 if data.len() != n * d {
91 return Err(PaCMAPError::DimensionMismatch { len: data.len(), d });
92 }
93 if n > u32::MAX as usize {
94 return Err(PaCMAPError::DatasetTooLarge { n });
95 }
96
97 let n_nb = config.n_neighbors.min(n - 1);
98 let n_mn = config.n_mn();
99 let n_fp = config.n_fp();
100 let total_iters = config.total_iters();
101
102 let pairs = {
106 let k_candidates = KnnGraph::required_k_for_pacmap(n, config.n_neighbors);
107 let owned_knn;
108 let knn_graph: &KnnGraph = if let Some(graph) = knn {
109 validate_knn_for_pacmap(graph, n, config.n_neighbors, config.distance_metric)?;
110 graph
111 } else {
112 owned_knn = compute_knn(
113 data,
114 n,
115 d,
116 k_candidates,
117 &config.knn_method,
118 config.distance_metric,
119 )?;
120 &owned_knn
121 };
122 build_pairs(
123 &knn_graph.neighbors,
124 data,
125 n,
126 d,
127 n_nb,
128 n_mn,
129 n_fp,
130 config.seed,
131 )?
132 };
133
134 let mut embedding: Vec<[f32; 2]> = match &config.init {
136 Init::Pca => pca_init(data, n, d)?,
137 Init::Random(seed) => {
138 use rand::{RngExt, SeedableRng, rngs::SmallRng};
139 let mut rng = match seed {
140 Some(s) => SmallRng::seed_from_u64(*s),
141 None => rand::make_rng::<SmallRng>(),
142 };
143 let scale = (1e-4_f32).sqrt();
144 (0..n)
145 .map(|_| [rng.random::<f32>() * scale, rng.random::<f32>() * scale])
146 .collect()
147 }
148 };
149
150 #[cfg(feature = "cubecl")]
152 if matches!(config.optimize_backend, OptimizeBackend::Gpu) {
153 crate::gpu::optimize_embedding_gpu(
154 &mut embedding,
155 &pairs.near,
156 &pairs.mid_near,
157 &pairs.further,
158 &config.phase_iters,
159 config.learning_rate,
160 cancel,
161 )?;
162 return Ok(embedding);
163 }
164
165 let mut adam = AdamState::new(n);
166 let mut global_iter = 0usize;
167
168 for (phase_idx, &phase_len) in config.phase_iters.iter().enumerate() {
169 let phase = (phase_idx + 1) as u8;
170
171 for local_iter in 0..phase_len {
172 if let Some(ref cancel) = cancel
174 && cancel.load(Ordering::Relaxed)
175 {
176 return Err(PaCMAPError::Cancelled);
177 }
178
179 global_iter += 1;
180 let w = weights_at(global_iter, &config.phase_iters);
181
182 let (grad, loss) = compute_gradient(
183 &embedding,
184 &pairs.near,
185 &pairs.mid_near,
186 &pairs.further,
187 &w,
188 n,
189 );
190
191 adam_step(
192 &mut embedding,
193 &grad,
194 &mut adam,
195 global_iter,
196 config.learning_rate,
197 );
198
199 if let Some(ref tx) = progress
201 && (local_iter == 0
202 || local_iter == phase_len - 1
203 || global_iter.is_multiple_of(10))
204 {
205 let _ = tx.send(PaCMAPProgress {
206 phase,
207 iter: global_iter,
208 total_iters,
209 loss,
210 });
211 }
212 }
213 }
214
215 Ok(embedding)
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 fn two_cluster_data(n_per_cluster: usize, d: usize) -> (Vec<f32>, usize) {
223 use rand::{RngExt, SeedableRng, rngs::SmallRng};
224 let mut rng = SmallRng::seed_from_u64(0);
225 let n = n_per_cluster * 2;
226 let mut data = Vec::with_capacity(n * d);
227 for _ in 0..n_per_cluster {
228 for _ in 0..d {
229 data.push(rng.random::<f32>() * 0.5);
230 }
231 }
232 for _ in 0..n_per_cluster {
233 for _ in 0..d {
234 data.push(5.0 + rng.random::<f32>() * 0.5);
235 }
236 }
237 (data, n)
238 }
239
240 fn base_config() -> PaCMAPConfig {
241 PaCMAPConfig {
242 n_neighbors: 5,
243 phase_iters: [50, 50, 100],
244 knn_method: KnnMethod::Exact,
245 init: Init::Random(Some(42)),
246 seed: Some(7),
247 ..Default::default()
248 }
249 }
250
251 #[test]
254 fn two_cluster_separation() {
255 let n_per_cluster = 100;
256 let d = 10;
257 let (data, n) = two_cluster_data(n_per_cluster, d);
258 let config = base_config();
259
260 let emb = fit_transform(&data, n, d, config, None, None, None).unwrap();
261 assert_eq!(emb.len(), n);
262
263 let c_a: [f32; 2] = {
264 let sx: f32 = emb[..n_per_cluster].iter().map(|p| p[0]).sum();
265 let sy: f32 = emb[..n_per_cluster].iter().map(|p| p[1]).sum();
266 [sx / n_per_cluster as f32, sy / n_per_cluster as f32]
267 };
268 let c_b: [f32; 2] = {
269 let sx: f32 = emb[n_per_cluster..].iter().map(|p| p[0]).sum();
270 let sy: f32 = emb[n_per_cluster..].iter().map(|p| p[1]).sum();
271 [sx / n_per_cluster as f32, sy / n_per_cluster as f32]
272 };
273 let sep = ((c_a[0] - c_b[0]).powi(2) + (c_a[1] - c_b[1]).powi(2)).sqrt();
274 assert!(
275 sep > 0.5,
276 "cluster centroids should be separated in embedding (got {sep:.3})"
277 );
278 }
279
280 #[test]
281 fn precomputed_knn_matches_internal() {
282 let (data, n) = two_cluster_data(40, 8);
283 let mut config = base_config();
284 config.knn_method = KnnMethod::Exact;
285 let k = KnnGraph::required_k_for_pacmap(n, config.n_neighbors);
286 let graph = compute_knn(
287 &data,
288 n,
289 8,
290 k,
291 &KnnMethod::Exact,
292 Default::default(),
293 )
294 .unwrap();
295 let a = fit_transform(&data, n, 8, config.clone(), None, None, None).unwrap();
296 let b = fit_transform(&data, n, 8, config, Some(&graph), None, None).unwrap();
297 for (p, q) in a.iter().zip(b.iter()) {
298 assert!((p[0] - q[0]).abs() < 1e-4 && (p[1] - q[1]).abs() < 1e-4);
299 }
300 }
301
302 #[cfg(feature = "cubecl")]
303 #[test]
304 fn gpu_optimize_runs_when_adapter_available() {
305 if !crate::gpu::gpu_context_available() {
306 eprintln!("skipping gpu_optimize_runs_when_adapter_available: no WGPU adapter");
307 return;
308 }
309 let (data, n) = two_cluster_data(80, 6);
310 let mut config = base_config();
311 config.phase_iters = [8, 8, 16];
312 config.optimize_backend = OptimizeBackend::Gpu;
313 let emb = fit_transform(&data, n, 6, config, None, None, None).unwrap();
314 assert_eq!(emb.len(), n);
315 assert!(emb.iter().all(|p| p[0].is_finite() && p[1].is_finite()));
316 }
317
318 #[cfg(feature = "cubecl")]
319 #[test]
320 fn gpu_and_cpu_embeddings_correlate() {
321 if !crate::gpu::gpu_context_available() {
322 eprintln!("skipping gpu_and_cpu_embeddings_correlate: no WGPU adapter");
323 return;
324 }
325 let (data, n) = two_cluster_data(60, 5);
326 let mut cpu_cfg = base_config();
327 cpu_cfg.phase_iters = [20, 20, 40];
328 cpu_cfg.optimize_backend = OptimizeBackend::Cpu;
329 let mut gpu_cfg = cpu_cfg.clone();
330 gpu_cfg.optimize_backend = OptimizeBackend::Gpu;
331
332 let cpu = fit_transform(&data, n, 5, cpu_cfg, None, None, None).unwrap();
333 let gpu = fit_transform(&data, n, 5, gpu_cfg, None, None, None).unwrap();
334
335 let mut max_delta = 0.0f32;
337 for (a, b) in cpu.iter().zip(gpu.iter()) {
338 max_delta = max_delta.max((a[0] - b[0]).abs()).max((a[1] - b[1]).abs());
339 }
340 assert!(
341 max_delta < 2.0,
342 "cpu vs gpu embedding max |Δ|={max_delta} (expected loose agreement)"
343 );
344 }
345
346 #[test]
347 fn precomputed_knn_rejects_bad_n_and_k() {
348 let (data, n) = two_cluster_data(30, 4);
349 let d = 4;
350 let config = base_config();
351 let k = KnnGraph::required_k_for_pacmap(n, config.n_neighbors);
352 let graph = compute_knn(
353 &data,
354 n,
355 d,
356 k,
357 &KnnMethod::Exact,
358 DistanceMetric::Euclidean,
359 )
360 .unwrap();
361
362 let mut bad_n = graph.clone();
363 bad_n.n = n + 1;
364 assert!(matches!(
365 fit_transform(&data, n, d, config.clone(), Some(&bad_n), None, None),
366 Err(PaCMAPError::KnnGraphSizeMismatch { .. })
367 ));
368
369 let mut bad_k = graph.clone();
370 bad_k.k = 1;
371 assert!(matches!(
372 fit_transform(&data, n, d, config.clone(), Some(&bad_k), None, None),
373 Err(PaCMAPError::KnnGraphInsufficientK { .. })
374 ));
375
376 let mut bad_metric = graph;
377 bad_metric.metric = DistanceMetric::Cosine;
378 assert!(matches!(
379 fit_transform(&data, n, d, config, Some(&bad_metric), None, None),
380 Err(PaCMAPError::KnnGraphMetricMismatch { .. })
381 ));
382 }
383
384 #[test]
385 fn one_knn_graph_reused_across_configs() {
386 let (data, n) = two_cluster_data(30, 4);
387 let d = 4;
388 let n_neighbors = 5;
389 let k = KnnGraph::required_k_for_pacmap(n, n_neighbors);
390 let graph = compute_knn(
391 &data,
392 n,
393 d,
394 k,
395 &KnnMethod::Exact,
396 DistanceMetric::Euclidean,
397 )
398 .unwrap();
399
400 let config_a = PaCMAPConfig {
401 n_neighbors,
402 phase_iters: [20, 20, 40],
403 knn_method: KnnMethod::Exact,
404 init: Init::Random(Some(1)),
405 seed: Some(11),
406 learning_rate: 1.0,
407 ..Default::default()
408 };
409 let config_b = PaCMAPConfig {
410 n_neighbors,
411 phase_iters: [10, 10, 20],
412 knn_method: KnnMethod::Exact,
413 init: Init::Random(Some(2)),
414 seed: Some(22),
415 learning_rate: 0.5,
416 ..Default::default()
417 };
418
419 let emb_a = fit_transform(&data, n, d, config_a, Some(&graph), None, None).unwrap();
420 let emb_b = fit_transform(&data, n, d, config_b, Some(&graph), None, None).unwrap();
421 assert_eq!(emb_a.len(), n);
422 assert_eq!(emb_b.len(), n);
423 let differs = emb_a
425 .iter()
426 .zip(emb_b.iter())
427 .any(|(a, b)| (a[0] - b[0]).abs() > 1e-3 || (a[1] - b[1]).abs() > 1e-3);
428 assert!(differs, "distinct configs should not yield identical embeddings");
429 }
430}