Skip to main content

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}