fynch/lib.rs
1//! # fynch
2//!
3//! Fenchel-Young losses and differentiable sorting/ranking.
4//!
5//! The name comes from **Fenchel-Young losses** (Blondel et al. 2020, JMLR),
6//! a unifying framework connecting prediction functions and loss functions
7//! through convex duality.
8//!
9//! ## The Fenchel-Young Framework
10//!
11//! Given a regularizer Ω, the **Fenchel-Young loss** is:
12//!
13//! ```text
14//! L_Ω(θ; y) = Ω*(θ) - ⟨θ, y⟩ + Ω(y)
15//! ```
16//!
17//! where Ω* is the Fenchel conjugate. The **prediction function** is:
18//!
19//! ```text
20//! ŷ_Ω(θ) = ∇Ω*(θ) = argmax_p { ⟨θ, p⟩ - Ω(p) }
21//! ```
22//!
23//! Different regularizers give different behaviors:
24//!
25//! | Regularizer Ω | Prediction ŷ_Ω | Loss L_Ω | Sparsity |
26//! |---------------|----------------|----------|----------|
27//! | Shannon negentropy | softmax | cross-entropy | Dense |
28//! | ½‖·‖² (squared L2) | sparsemax | sparsemax loss | Sparse |
29//! | Tsallis α-entropy | α-entmax | entmax loss | Tunable |
30//!
31//! See the [`fenchel`] module for the generic framework.
32//!
33//! ## Differentiable Sorting/Ranking
34//!
35//! Sorting and ranking are discontinuous—small input changes can cause large
36//! output changes (rank swaps). This breaks gradient-based optimization.
37//!
38//! | Approach | Module | Regularization | Complexity |
39//! |----------|--------|---------------|------------|
40//! | PAVA + Sigmoid | Root | L2 | O(n) / O(n²) |
41//! | Sinkhorn OT | [`sinkhorn`] | Entropy (Shannon Ω) | O(n² × iter) |
42//! | LapSum | [`lapsum`] | Laplacian kernel | O(n² + n log n) |
43//!
44//! Sinkhorn sorting is exactly FY with Shannon regularization applied to
45//! the permutation polytope (Birkhoff polytope).
46//!
47//! ## Key Functions
48//!
49//! | Function | Purpose | Module |
50//! |----------|---------|--------|
51//! | [`pava`] | Isotonic regression | Root |
52//! | [`soft_rank`] | Continuous ranks | Root |
53//! | [`soft_sort`] | Continuous sorting | Root |
54//! | [`fenchel::softmax`] | Dense prediction | [`fenchel`] |
55//! | [`fenchel::sparsemax`] | Sparse prediction | [`fenchel`] |
56//! | [`fenchel::entmax`] | Tunable sparsity | [`fenchel`] |
57//! | [`lapsum_sort`] | Laplacian soft sort | [`lapsum`] |
58//! | [`lapsum_rank`] | Laplacian soft rank | [`lapsum`] |
59//! | [`lapsum_topk`] | Laplacian soft top-k | [`lapsum`] |
60//!
61//! ## Quick Start
62//!
63//! ### Fenchel-Young Predictions
64//!
65//! ```rust
66//! use fynch::fenchel::{softmax, sparsemax, entmax};
67//!
68//! let theta = [2.0, 1.0, 0.1];
69//!
70//! // Dense (softmax)
71//! let p_soft = softmax(&theta);
72//! assert!(p_soft.iter().all(|&x| x > 0.0));
73//!
74//! // Sparse (sparsemax)
75//! let p_sparse = sparsemax(&theta);
76//! assert!(p_sparse.iter().any(|&x| x == 0.0));
77//!
78//! // Tunable (1.5-entmax)
79//! let p_ent = entmax(&theta, 1.5);
80//! ```
81//!
82//! ### Fenchel-Young Losses
83//!
84//! ```rust
85//! use fynch::fenchel::{Regularizer, Shannon, SquaredL2, Tsallis};
86//!
87//! let theta = [2.0, 1.0, 0.1];
88//! let y = [1.0, 0.0, 0.0]; // one-hot target
89//!
90//! // Cross-entropy (Shannon)
91//! let loss_ce = Shannon.loss(&theta, &y);
92//!
93//! // Sparsemax-style FY loss via squared L2 regularizer
94//! let loss_sp = SquaredL2.loss(&theta, &y);
95//!
96//! // 1.5-entmax loss
97//! let loss_ent = Tsallis::entmax15().loss(&theta, &y);
98//! ```
99//!
100//! ### Differentiable Sorting
101//!
102//! ```rust
103//! use fynch::{pava, soft_rank, soft_sort};
104//!
105//! // PAVA: isotonic regression
106//! let y = [3.0, 1.0, 2.0, 5.0, 4.0];
107//! let monotonic = pava(&y); // [2.0, 2.0, 2.0, 4.5, 4.5]
108//!
109//! // Soft ranking
110//! let scores = [0.5, 0.2, 0.8, 0.1];
111//! let ranks = soft_rank(&scores, 0.1).unwrap();
112//! ```
113//!
114//! ### Learning to Rank
115//!
116//! ```rust
117//! use fynch::loss::{spearman_loss, listnet_loss};
118//!
119//! let pred = [0.9, 0.1, 0.5];
120//! let target = [3.0, 1.0, 2.0];
121//! let loss = spearman_loss(&pred, &target, 0.1);
122//! ```
123//!
124//! ## Modules
125//!
126//! | Module | Contents |
127//! |--------|----------|
128//! | [`fenchel`] | Generic FY framework: regularizers, predictions, losses |
129//! | [`sinkhorn`] | Entropic OT for soft permutations |
130//! | [`lapsum`] | LapSum unified sort/rank/top-k |
131//! | [`loss`] | Learning-to-rank losses |
132//! | [`metrics`] | IR evaluation: MRR, NDCG, Hits@k |
133//!
134//! ## Connections
135//!
136//! - [`wass`](../wass): Sinkhorn OT is the same algorithm
137//! - [`logp`](../logp): Shannon entropy connects to information theory
138//! - [information retrieval ecosystem](../cerno): More comprehensive IR evaluation
139//!
140//! ## What Can Go Wrong
141//!
142//! 1. **Temperature too low**: Numerical instability, vanishing gradients
143//! 2. **Temperature too high**: Predictions become uniform, lose signal
144//! 3. **Sinkhorn not converging**: Increase max_iter or epsilon
145//! 4. **Wrong regularizer**: Use sparsemax for top-k, softmax for soft attention
146//!
147//! ## References
148//!
149//! - Blondel, Martins, Niculae (2020). "Learning with Fenchel-Young Losses" (JMLR)
150//! - Martins & Astudillo (2016). "From Softmax to Sparsemax"
151//! - Blondel et al. (2020). "Fast Differentiable Sorting and Ranking"
152//! - Cuturi et al. (2019). "Differentiable Ranking via Optimal Transport"
153
154#[cfg(feature = "logp")]
155mod bregman;
156pub mod curvature;
157pub mod fenchel;
158pub mod lapsum;
159pub mod loss;
160pub mod metrics;
161pub mod sigmoid;
162pub mod sinkhorn;
163pub mod sorting_network;
164pub mod sparsemap;
165pub mod topk;
166
167use thiserror::Error;
168
169pub use fenchel::{
170 entmax, entropy_bits, entropy_nats, softmax, softmax_with_temperature, sparsemax, Regularizer,
171 Shannon, SquaredL2, Tsallis,
172};
173#[allow(deprecated)]
174pub use metrics::{compute_rank, hits_at_k, mean_rank, mrr, ndcg, ndcg_at_k, RankingMetrics};
175pub use sigmoid::{sigmoid, sigmoid_derivative};
176pub use sinkhorn::{sinkhorn_rank, sinkhorn_sort, SinkhornConfig};
177pub use sparsemap::{
178 sparsemap_explicit, sparsemap_loss_explicit, SparseMapPrediction, SparseMapWeight,
179};
180pub use topk::{
181 differentiable_bottomk, differentiable_topk, sparse_topk, sparse_topk_matrix, topk_ce_loss,
182 topk_cross_entropy_loss,
183};
184
185pub use lapsum::{lapsum_permutation, lapsum_rank, lapsum_sort, lapsum_topk};
186pub use sorting_network::{
187 bitonic_sort, odd_even_sort, ranks_from_permutation, DiffSortNet, NetworkType, RelaxDist,
188};
189
190#[derive(Debug, Error)]
191#[non_exhaustive]
192pub enum Error {
193 #[error("empty input")]
194 EmptyInput,
195
196 #[error("temperature must be positive: {0}")]
197 InvalidTemperature(f64),
198
199 #[error("weights must be positive")]
200 InvalidWeights,
201
202 #[error("length mismatch: {0} vs {1}")]
203 LengthMismatch(usize, usize),
204}
205
206pub type Result<T> = std::result::Result<T, Error>;
207
208/// Pool Adjacent Violators Algorithm (PAVA) for isotonic regression.
209///
210/// Finds the monotonically non-decreasing sequence ŷ minimizing Σ(yᵢ - ŷᵢ)².
211///
212/// # Algorithm
213///
214/// ```text
215/// 1. Initialize blocks: each element is its own block
216/// 2. Scan left-to-right:
217/// - If block[i] > block[i+1], merge them (average values)
218/// - Backtrack: merged block might violate with previous
219/// 3. Repeat until no violations
220/// ```
221///
222/// # Complexity
223///
224/// - Time: O(n)
225/// - Space: O(n) for output
226///
227/// # Example
228///
229/// ```rust
230/// use fynch::pava;
231///
232/// let y = [3.0, 1.0, 2.0, 5.0, 4.0];
233/// let result = pava(&y);
234/// // result ≈ [2.0, 2.0, 2.0, 4.5, 4.5]
235///
236/// // Verify monotonicity
237/// for i in 1..result.len() {
238/// assert!(result[i] >= result[i-1]);
239/// }
240/// ```
241pub fn pava(y: &[f64]) -> Vec<f64> {
242 if y.is_empty() {
243 return vec![];
244 }
245
246 let n = y.len();
247 let mut result = y.to_vec();
248
249 // Block representation: (start_index, sum, count)
250 let mut blocks: Vec<(usize, f64, usize)> = Vec::with_capacity(n);
251
252 for (i, &val) in y.iter().enumerate() {
253 // Add new block
254 blocks.push((i, val, 1));
255
256 // Pool while violation exists
257 while blocks.len() > 1 {
258 let len = blocks.len();
259 let (_, sum1, cnt1) = blocks[len - 2];
260 let (_, sum2, cnt2) = blocks[len - 1];
261
262 let mean1 = sum1 / cnt1 as f64;
263 let mean2 = sum2 / cnt2 as f64;
264
265 if mean1 > mean2 {
266 // Merge last two blocks
267 blocks.pop();
268 let Some(last) = blocks.last_mut() else {
269 // Safety: blocks.len() > 1 before pop, so one block must remain.
270 debug_assert!(false, "blocks unexpectedly empty after pop");
271 break;
272 };
273 last.1 += sum2;
274 last.2 += cnt2;
275 } else {
276 break;
277 }
278 }
279 }
280
281 // Expand blocks back to result
282 for (start, sum, count) in blocks {
283 let mean = sum / count as f64;
284 for r in result.iter_mut().skip(start).take(count) {
285 *r = mean;
286 }
287 }
288
289 result
290}
291
292/// Weighted PAVA with custom weights.
293///
294/// Finds monotonic ŷ minimizing Σ wᵢ(yᵢ - ŷᵢ)².
295///
296/// # Example
297///
298/// ```rust
299/// use fynch::pava_weighted;
300///
301/// let y = [3.0, 1.0, 2.0];
302/// let w = [1.0, 2.0, 1.0]; // Middle point has more weight
303/// let result = pava_weighted(&y, &w).unwrap();
304/// ```
305pub fn pava_weighted(y: &[f64], weights: &[f64]) -> Result<Vec<f64>> {
306 if y.is_empty() {
307 return Ok(vec![]);
308 }
309 if y.len() != weights.len() {
310 return Err(Error::LengthMismatch(y.len(), weights.len()));
311 }
312 if weights.iter().any(|&w| w <= 0.0) {
313 return Err(Error::InvalidWeights);
314 }
315
316 let n = y.len();
317 let mut result = y.to_vec();
318
319 // Block: (start, weighted_sum, total_weight)
320 let mut blocks: Vec<(usize, f64, f64)> = Vec::with_capacity(n);
321
322 for (i, (&val, &w)) in y.iter().zip(weights.iter()).enumerate() {
323 blocks.push((i, val * w, w));
324
325 while blocks.len() > 1 {
326 let len = blocks.len();
327 let (_, wsum1, w1) = blocks[len - 2];
328 let (_, wsum2, w2) = blocks[len - 1];
329
330 let mean1 = wsum1 / w1;
331 let mean2 = wsum2 / w2;
332
333 if mean1 > mean2 {
334 blocks.pop();
335 let Some(last) = blocks.last_mut() else {
336 // Safety: blocks.len() > 1 before pop, so one block must remain.
337 debug_assert!(false, "blocks unexpectedly empty after pop");
338 break;
339 };
340 last.1 += wsum2;
341 last.2 += w2;
342 } else {
343 break;
344 }
345 }
346 }
347
348 // Determine block boundaries
349 for (block_idx, (start, wsum, total_w)) in blocks.iter().enumerate() {
350 let mean = wsum / total_w;
351 // Find how many elements this block covers
352 let end = if block_idx + 1 < blocks.len() {
353 blocks[block_idx + 1].0
354 } else {
355 n
356 };
357 for r in result.iter_mut().skip(*start).take(end - *start) {
358 *r = mean;
359 }
360 }
361
362 Ok(result)
363}
364
365/// Soft ranking with temperature parameter.
366///
367/// Returns continuous approximation to ranks. As τ → 0, converges to hard ranks.
368///
369/// # Algorithm
370///
371/// Uses the soft-rank formulation via pairwise comparisons:
372/// ```text
373/// soft_rank(x)ᵢ = 1 + Σⱼ σ((xⱼ - xᵢ)/τ)
374/// ```
375/// where σ is the sigmoid function.
376///
377/// # Example
378///
379/// ```rust
380/// use fynch::soft_rank;
381///
382/// let scores = [0.5, 0.2, 0.8, 0.1];
383/// let ranks = soft_rank(&scores, 0.1);
384/// // Approximately [2, 3, 1, 4] but continuous
385/// ```
386pub fn soft_rank(x: &[f64], temperature: f64) -> Result<Vec<f64>> {
387 if x.is_empty() {
388 return Err(Error::EmptyInput);
389 }
390 if temperature <= 0.0 {
391 return Err(Error::InvalidTemperature(temperature));
392 }
393
394 let n = x.len();
395 let mut ranks = vec![1.0; n];
396
397 // Exploit symmetry: sigmoid(d) + sigmoid(-d) = 1, so each pair (i,j)
398 // contributes to both ranks[i] and ranks[j] with a single exp() call.
399 // This halves the number of exp() calls vs the naive double loop.
400 let inv_temp = 1.0 / temperature;
401 for i in 0..n {
402 for j in (i + 1)..n {
403 // sigmoid((x[j] - x[i]) / temperature)
404 let diff = (x[j] - x[i]) * inv_temp;
405 let s = 1.0 / (1.0 + (-diff).exp());
406 ranks[i] += s;
407 ranks[j] += 1.0 - s;
408 }
409 }
410
411 Ok(ranks)
412}
413
414/// Soft sorting with temperature parameter.
415///
416/// Returns a continuous approximation to sorted values using Sinkhorn
417/// optimal transport. As temperature → 0, converges to hard sorting.
418///
419/// This is a convenience wrapper around [`sinkhorn::sinkhorn_sort`].
420///
421/// # Arguments
422///
423/// * `x` - Input values
424/// * `temperature` - Regularization strength (epsilon). Smaller = sharper.
425///
426/// # Example
427///
428/// ```rust
429/// use fynch::soft_sort;
430///
431/// let x = [3.0, 1.0, 2.0];
432/// let sorted = soft_sort(&x, 0.1).unwrap();
433/// // Approximately [1.0, 2.0, 3.0] but smooth
434/// ```
435pub fn soft_sort(x: &[f64], temperature: f64) -> Result<Vec<f64>> {
436 sinkhorn::sinkhorn_sort(x, temperature)
437}
438
439/// Non-increasing isotonic regression: argmin over v₁ ≥ ... ≥ vₙ of ‖v − z‖².
440///
441/// [`pava`] fits the non-decreasing direction; negating input and output
442/// flips it. The reference isotonic in Blondel et al.'s code solves this
443/// non-increasing form.
444fn isotonic_l2_decreasing(z: &[f64]) -> Vec<f64> {
445 let negated: Vec<f64> = z.iter().map(|v| -v).collect();
446 pava(&negated).into_iter().map(|v| -v).collect()
447}
448
449/// Fast Differentiable Sorting ($O(n \log n)$), ascending, L2 regularization.
450///
451/// Implements soft sort from Blondel et al. (2020), "Fast Differentiable
452/// Sorting and Ranking" (arXiv:2002.08871). Unlike Sinkhorn ($O(n^2)$), this
453/// scales to large inputs. Mirrors the reference implementation
454/// (google-research/fast-soft-sort `SoftSort`, `direction="ASCENDING"`,
455/// `regularization="l2"`): with $w = (n, ..., 1)/\varepsilon$ and $s$ the
456/// negated input sorted descending, the result is
457/// $-(w - \text{isotonic}_{\searrow}(w - s))$.
458///
459/// As `regularization_strength` → 0 the output converges to the hard-sorted
460/// values (ascending); large strengths pull the output toward the mean.
461/// Verified against reference outputs in `tests/blondel_reference.rs`.
462pub fn fast_soft_sort(x: &[f64], regularization_strength: f64) -> Vec<f64> {
463 if x.is_empty() {
464 return vec![];
465 }
466 let n = x.len();
467
468 // ASCENDING direction in the reference flips the sign of the input.
469 let mut s: Vec<f64> = x.iter().map(|v| -v).collect();
470 s.sort_by(|a, b| b.total_cmp(a));
471
472 let w: Vec<f64> = (1..=n)
473 .rev()
474 .map(|i| i as f64 / regularization_strength)
475 .collect();
476
477 let diff: Vec<f64> = w.iter().zip(&s).map(|(wi, si)| wi - si).collect();
478 let res = isotonic_l2_decreasing(&diff);
479
480 w.iter().zip(&res).map(|(wi, ri)| -(wi - ri)).collect()
481}
482
483/// Fast Differentiable Ranking ($O(n \log n)$), ascending, L2 regularization.
484///
485/// Implements soft rank from Blondel et al. (2020) via the permutahedron
486/// projection $P(x/\varepsilon)$ with $\rho = (n, ..., 1)$, mirroring the
487/// reference implementation (google-research/fast-soft-sort `SoftRank`,
488/// `direction="ASCENDING"`, `regularization="l2"`). Returns one soft rank per
489/// input position, in input order; ranks are 1-based and ascending (the
490/// smallest value tends to rank 1 as `regularization_strength` → 0).
491///
492/// Unlike the $O(n^2)$ pairwise-sigmoid [`soft_rank`], this is the exact
493/// projection formulation; the two do not compute the same relaxation.
494/// Verified against reference outputs in `tests/blondel_reference.rs`.
495pub fn fast_soft_rank(x: &[f64], regularization_strength: f64) -> Result<Vec<f64>> {
496 if x.is_empty() {
497 return Err(Error::EmptyInput);
498 }
499 if regularization_strength <= 0.0 {
500 return Err(Error::InvalidTemperature(regularization_strength));
501 }
502 let n = x.len();
503 let scale = 1.0 / regularization_strength;
504 let theta: Vec<f64> = x.iter().map(|v| v * scale).collect();
505
506 // argsort descending; stable sort keeps tie handling deterministic.
507 let mut perm: Vec<usize> = (0..n).collect();
508 perm.sort_by(|&a, &b| theta[b].total_cmp(&theta[a]));
509 let s: Vec<f64> = perm.iter().map(|&i| theta[i]).collect();
510 let w: Vec<f64> = (1..=n).rev().map(|i| i as f64).collect();
511
512 // Projection onto the permutahedron of w: primal = s - isotonic_dec(s - w),
513 // undone back to input order.
514 let diff: Vec<f64> = s.iter().zip(&w).map(|(si, wi)| si - wi).collect();
515 let dual = isotonic_l2_decreasing(&diff);
516
517 let mut out = vec![0.0; n];
518 for (sorted_pos, &orig_idx) in perm.iter().enumerate() {
519 out[orig_idx] = s[sorted_pos] - dual[sorted_pos];
520 }
521 Ok(out)
522}
523
524/// Reciprocal Rank Fusion (RRF).
525///
526/// Combines multiple ranked lists into a single ranking using the formula:
527/// `RRFscore(d) = Σ_r 1 / (k + rank_r(d))`
528///
529/// # Arguments
530/// * `rankings` - A slice of ranked lists (each list is a Vec of doc IDs)
531/// * `k` - Hyperparameter (default 60 per Cormack et al.)
532///
533/// # Deprecation
534///
535/// RRF belongs with the other fusion algorithms. Use `rankops::rrf` (for two lists),
536/// `rankops::rrf_multi` (for 3+ lists), or `rankops::rrf_weighted` instead.
537/// Those variants also support top-k truncation, explainability, and `(item, score)` input.
538#[deprecated(
539 since = "0.1.2",
540 note = "use rankops::rrf / rankops::rrf_multi instead"
541)]
542pub fn reciprocal_rank_fusion<T: std::hash::Hash + Eq + Clone>(
543 rankings: &[Vec<T>],
544 k: usize,
545) -> Vec<(T, f64)> {
546 use std::collections::HashMap;
547 let mut scores = HashMap::new();
548
549 for ranking in rankings {
550 for (rank, id) in ranking.iter().enumerate() {
551 let score = 1.0 / (k as f64 + (rank + 1) as f64);
552 *scores.entry(id.clone()).or_insert(0.0) += score;
553 }
554 }
555
556 let mut fused: Vec<_> = scores.into_iter().collect();
557 fused.sort_by(|a, b| b.1.total_cmp(&a.1));
558 fused
559}
560
561/// Isotonic regression with L2 loss.
562///
563/// Alias for [`pava`] with clearer naming.
564#[deprecated(since = "0.1.2", note = "use pava instead")]
565pub fn isotonic_l2(y: &[f64]) -> Vec<f64> {
566 pava(y)
567}
568
569/// Compute soft top-k indicator.
570///
571/// Returns smooth approximation to 1{rank(xᵢ) ≤ k}.
572///
573/// # Example
574///
575/// ```rust
576/// use fynch::soft_topk_indicator;
577///
578/// let scores = [0.5, 0.2, 0.8, 0.1, 0.9];
579/// let indicator = soft_topk_indicator(&scores, 2, 0.1).unwrap();
580/// // High values for indices 2, 4 (top 2 scores)
581/// ```
582pub fn soft_topk_indicator(x: &[f64], k: usize, temperature: f64) -> Result<Vec<f64>> {
583 let ranks = soft_rank(x, temperature)?;
584 let k_f = k as f64;
585
586 // Soft indicator: σ((k + 0.5 - rank) / τ)
587 Ok(ranks
588 .iter()
589 .map(|&r| {
590 let z = (k_f + 0.5 - r) / temperature;
591 1.0 / (1.0 + (-z).exp())
592 })
593 .collect())
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599 use approx::assert_relative_eq;
600
601 #[test]
602 fn test_pava_simple() {
603 let y = [3.0, 1.0, 2.0, 5.0, 4.0];
604 let result = pava(&y);
605
606 // Check monotonicity
607 for i in 1..result.len() {
608 assert!(result[i] >= result[i - 1], "Not monotonic at {}", i);
609 }
610
611 // First three should be pooled
612 assert_relative_eq!(result[0], result[1], epsilon = 1e-10);
613 assert_relative_eq!(result[1], result[2], epsilon = 1e-10);
614
615 // Last two should be pooled
616 assert_relative_eq!(result[3], result[4], epsilon = 1e-10);
617 }
618
619 #[test]
620 fn test_pava_already_monotonic() {
621 let y = [1.0, 2.0, 3.0, 4.0, 5.0];
622 let result = pava(&y);
623 assert_eq!(result, y);
624 }
625
626 #[test]
627 fn test_pava_reverse() {
628 let y = [5.0, 4.0, 3.0, 2.0, 1.0];
629 let result = pava(&y);
630
631 // Should all be pooled to the mean
632 let mean = 3.0;
633 for &r in &result {
634 assert_relative_eq!(r, mean, epsilon = 1e-10);
635 }
636 }
637
638 #[test]
639 fn test_soft_rank_ordering() {
640 let x = [0.1, 0.5, 0.2, 0.9];
641 let ranks = soft_rank(&x, 0.01).unwrap();
642
643 // With low temperature, should approximate hard ranks
644 // x[3]=0.9 should have lowest rank (~1)
645 // x[0]=0.1 should have highest rank (~4)
646 assert!(ranks[3] < ranks[1]); // 0.9 ranks higher than 0.5
647 assert!(ranks[1] < ranks[2]); // 0.5 ranks higher than 0.2
648 assert!(ranks[2] < ranks[0]); // 0.2 ranks higher than 0.1
649 }
650
651 #[test]
652 fn test_soft_sort_approximates_sort() {
653 let x = [3.0, 1.0, 4.0, 1.0, 5.0, 9.0, 2.0, 6.0];
654 // Very small epsilon can underflow exp(-C/ε) in naive Sinkhorn implementations.
655 // We keep this test in the “reasonable regime” where the operator is stable.
656 let sorted = soft_sort(&x, 0.1).unwrap();
657
658 // Should be approximately sorted
659 for i in 1..sorted.len() {
660 assert!(
661 sorted[i] >= sorted[i - 1] - 0.5,
662 "Not approximately sorted at {}: {} < {}",
663 i,
664 sorted[i],
665 sorted[i - 1]
666 );
667 }
668 }
669
670 #[test]
671 fn test_soft_topk() {
672 let x = [0.1, 0.9, 0.5, 0.8, 0.2];
673 let indicator = soft_topk_indicator(&x, 2, 0.01).unwrap();
674
675 // Indices 1 (0.9) and 3 (0.8) should have high indicator
676 assert!(indicator[1] > 0.5);
677 assert!(indicator[3] > 0.5);
678
679 // Others should have low indicator
680 assert!(indicator[0] < 0.5);
681 assert!(indicator[2] < 0.5);
682 assert!(indicator[4] < 0.5);
683 }
684
685 #[test]
686 fn test_empty_input() {
687 assert!(pava(&[]).is_empty());
688 assert!(soft_rank(&[], 0.1).is_err());
689 assert!(soft_sort(&[], 0.1).is_err());
690 }
691
692 #[test]
693 fn test_invalid_temperature() {
694 let x = [1.0, 2.0];
695 assert!(soft_rank(&x, 0.0).is_err());
696 assert!(soft_rank(&x, -1.0).is_err());
697 }
698}