Skip to main content

asap_ranking/
lib.rs

1use std::collections::HashMap;
2use std::fmt::{self, Debug, Display};
3use std::hash::Hash;
4use thiserror::Error;
5
6/// Represents a pairwise comparison between two items
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct Comparison<T: Display> {
9    pub winner: T,
10    pub loser: T,
11}
12
13impl<T: Display> fmt::Display for Comparison<T> {
14    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
15        write!(f, "{} > {}", self.winner, self.loser)
16    }
17}
18
19/// Error types for ASAP operations
20#[derive(Error, Debug)]
21pub enum AsapError<T: Display + Debug> {
22    #[error("Item not found: {0}")]
23    ItemNotFound(T),
24    #[error("Item already exists: {0}")]
25    ItemAlreadyExists(T),
26    #[error("Invalid comparison: both items are the same")]
27    InvalidComparison,
28    #[error("Not enough comparisons to compute scores")]
29    NotEnoughComparisons,
30    #[error("Internal algorithm error: {0}")]
31    InternalError(String),
32    #[error("Serialization error: {0}")]
33    SerializationError(String),
34}
35
36/// Matrix storing pairwise comparison results between items
37#[derive(Debug, Clone)]
38#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
39#[cfg_attr(
40    feature = "serde",
41    serde(bound(
42        serialize = "T: Clone + Debug + Eq + Hash + Send + Sync + 'static + serde::Serialize",
43        deserialize = "T: Clone + Debug + Eq + Hash + Send + Sync + 'static + serde::de::DeserializeOwned"
44    ))
45)]
46pub struct ComparisonMatrix<T: Clone + Debug + Eq + Hash + Send + Sync + 'static> {
47    item_indices: HashMap<T, usize>,
48    index_to_item: Vec<T>,
49    win_counts: Vec<Vec<usize>>,
50    comparison_count: usize,
51}
52
53impl<T: Clone + Debug + Eq + Hash + Display + Send + Sync + 'static> ComparisonMatrix<T> {
54    pub fn new(items: &[T]) -> Self {
55        let n = items.len();
56        let mut item_indices = HashMap::with_capacity(n);
57        let mut index_to_item = Vec::with_capacity(n);
58
59        for (idx, item) in items.iter().enumerate() {
60            item_indices.insert(item.clone(), idx);
61            index_to_item.push(item.clone());
62        }
63
64        let win_counts = vec![vec![0; n]; n];
65
66        ComparisonMatrix {
67            item_indices,
68            index_to_item,
69            win_counts,
70            comparison_count: 0,
71        }
72    }
73
74    pub fn add_comparison(&mut self, comparison: &Comparison<T>) -> Result<(), AsapError<T>> {
75        if comparison.winner == comparison.loser {
76            return Err(AsapError::InvalidComparison);
77        }
78
79        let winner_idx = self
80            .item_indices
81            .get(&comparison.winner)
82            .ok_or_else(|| AsapError::ItemNotFound(comparison.winner.clone()))?;
83        let loser_idx = self
84            .item_indices
85            .get(&comparison.loser)
86            .ok_or_else(|| AsapError::ItemNotFound(comparison.loser.clone()))?;
87
88        self.win_counts[*winner_idx][*loser_idx] += 1;
89        self.comparison_count += 1;
90
91        Ok(())
92    }
93
94    pub fn get_win_count(&self, item_i: &T, item_j: &T) -> Result<usize, AsapError<T>> {
95        let i_idx = self
96            .item_indices
97            .get(item_i)
98            .ok_or_else(|| AsapError::ItemNotFound(item_i.clone()))?;
99        let j_idx = self
100            .item_indices
101            .get(item_j)
102            .ok_or_else(|| AsapError::ItemNotFound(item_j.clone()))?;
103
104        Ok(self.win_counts[*i_idx][*j_idx])
105    }
106
107    pub fn get_comparison_count(&self, item_i: &T, item_j: &T) -> Result<usize, AsapError<T>> {
108        let i_idx = self
109            .item_indices
110            .get(item_i)
111            .ok_or_else(|| AsapError::ItemNotFound(item_i.clone()))?;
112        let j_idx = self
113            .item_indices
114            .get(item_j)
115            .ok_or_else(|| AsapError::ItemNotFound(item_j.clone()))?;
116
117        Ok(self.win_counts[*i_idx][*j_idx] + self.win_counts[*j_idx][*i_idx])
118    }
119
120    pub fn item_count(&self) -> usize {
121        self.index_to_item.len()
122    }
123
124    pub fn total_comparisons(&self) -> usize {
125        self.comparison_count
126    }
127
128    pub fn items(&self) -> Vec<T> {
129        self.index_to_item.clone()
130    }
131
132    pub fn get_item_index(&self, item: &T) -> Result<usize, AsapError<T>> {
133        self.item_indices
134            .get(item)
135            .copied()
136            .ok_or_else(|| AsapError::ItemNotFound(item.clone()))
137    }
138
139    pub fn get_item_from_index(&self, index: usize) -> Option<T> {
140        self.index_to_item.get(index).cloned()
141    }
142
143    pub fn add_item(&mut self, item: T) -> Result<(), AsapError<T>> {
144        if self.item_indices.contains_key(&item) {
145            return Err(AsapError::ItemAlreadyExists(item));
146        }
147
148        let new_idx = self.index_to_item.len();
149
150        self.item_indices.insert(item.clone(), new_idx);
151        self.index_to_item.push(item);
152
153        self.win_counts.push(vec![0; new_idx + 1]);
154
155        for row in &mut self.win_counts[0..new_idx] {
156            row.push(0);
157        }
158
159        Ok(())
160    }
161
162    pub fn remove_item(&mut self, item_to_remove: &T) -> Result<(), AsapError<T>> {
163        let removed_idx = match self.item_indices.get(item_to_remove) {
164            Some(&idx) => idx,
165            None => return Err(AsapError::ItemNotFound(item_to_remove.clone())),
166        };
167
168        let n_before_removal = self.index_to_item.len();
169        let mut comps_to_remove = 0;
170
171        // Calculate comparisons involving the removed item
172        for j in 0..n_before_removal {
173            if j == removed_idx {
174                // Sum wins by the removed item over others
175                for k in 0..n_before_removal {
176                    if k != removed_idx {
177                        comps_to_remove += self.win_counts[removed_idx][k];
178                    }
179                }
180            } else {
181                // Sum wins by other items over the removed item
182                comps_to_remove += self.win_counts[j][removed_idx];
183            }
184        }
185
186        // Remove the item from index_to_item
187        self.index_to_item.remove(removed_idx);
188
189        // Remove the corresponding row and column from win_counts
190        self.win_counts.remove(removed_idx);
191        for row in &mut self.win_counts {
192            row.remove(removed_idx);
193        }
194
195        // Rebuild item_indices
196        self.item_indices.clear();
197        for (idx, item) in self.index_to_item.iter().enumerate() {
198            self.item_indices.insert(item.clone(), idx);
199        }
200
201        self.comparison_count -= comps_to_remove;
202
203        Ok(())
204    }
205}
206
207/// Main ASAP ranking model for inferring scores from pairwise comparisons
208#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
209#[cfg_attr(
210    feature = "serde",
211    serde(bound(
212        serialize = "T: Clone + Debug + Eq + Hash + Display + Send + Sync + 'static + serde::Serialize",
213        deserialize = "T: Clone + Debug + Eq + Hash + Display + Send + Sync + 'static + serde::de::DeserializeOwned"
214    ))
215)]
216pub struct RankingModel<T: Clone + Debug + Eq + Hash + Display + Send + Sync + 'static> {
217    pub data: ComparisonMatrix<T>,
218    pub scores: Option<HashMap<T, f64>>,
219    approximate: bool,
220    #[allow(dead_code)]
221    selective_eig: bool,
222}
223
224impl<T: Clone + Debug + Eq + Hash + Display + Send + Sync + 'static> RankingModel<T> {
225    /// Create a new RankingModel with the given items
226    pub fn new(items: &[T]) -> Self {
227        RankingModel {
228            data: ComparisonMatrix::new(items),
229            scores: None,
230            approximate: false,
231            selective_eig: false,
232        }
233    }
234
235    /// Create a new RankingModel with custom options
236    pub fn new_with_options(items: &[T], approximate: bool, selective_eig: bool) -> Self {
237        RankingModel {
238            data: ComparisonMatrix::new(items),
239            scores: None,
240            approximate,
241            selective_eig,
242        }
243    }
244
245    /// Add a pairwise comparison result
246    pub fn add_comparison(&mut self, comparison: Comparison<T>) -> Result<(), AsapError<T>> {
247        self.data.add_comparison(&comparison)?;
248        self.scores = None;
249        Ok(())
250    }
251
252    pub fn add_item(&mut self, item: T) -> Result<(), AsapError<T>> {
253        self.data.add_item(item)?;
254        self.scores = None;
255        Ok(())
256    }
257
258    pub fn remove_item(&mut self, item: &T) -> Result<(), AsapError<T>> {
259        self.data.remove_item(item)?;
260        self.scores = None; // Scores become invalid
261        Ok(())
262    }
263
264    /// Get the items ordered by their inferred scores (highest to lowest)
265    pub fn get_ordering(&mut self) -> Result<Vec<T>, AsapError<T>> {
266        let scores = self.get_scores()?;
267
268        let mut items_with_scores: Vec<_> = scores.iter().collect();
269
270        items_with_scores.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal));
271
272        Ok(items_with_scores
273            .into_iter()
274            .map(|(item, _)| item.clone())
275            .collect())
276    }
277
278    /// Get the inferred scores for all items
279    pub fn get_scores(&mut self) -> Result<HashMap<T, f64>, AsapError<T>> {
280        if let Some(ref scores) = self.scores {
281            return Ok(scores.clone());
282        }
283
284        let scores = if self.approximate {
285            self.compute_approximate_scores()?
286        } else {
287            self.compute_accurate_scores()?
288        };
289
290        self.scores = Some(scores.clone());
291        Ok(scores)
292    }
293
294    /// Suggest the most informative comparisons to perform next
295    pub fn suggest_comparisons(&self, max: usize) -> Result<Vec<(T, T)>, AsapError<T>> {
296        if self.data.item_count() < 2 {
297            return Err(AsapError::NotEnoughComparisons);
298        }
299
300        let mut pairs_with_gain = Vec::new();
301        let items = self.data.items();
302        let n = items.len();
303
304        let current_scores = match self.scores {
305            Some(ref s) => s.clone(),
306            None => {
307                // Calculate temporary scores if not available (e.g., simple win ratio)
308                // This part might need a mutable self to call get_scores, or a non-mutating score estimation
309                // For now, let's assume if scores are None, we compute them temporarily or use a default.
310                // A proper solution might involve ensuring scores are computed or using a lightweight estimation.
311                // The original code used a simple win ratio if scores were not present.
312                let mut temp_scores = HashMap::new();
313                for i_idx in 0..n {
314                    let item_i = self.data.get_item_from_index(i_idx).ok_or_else(|| {
315                        AsapError::InternalError(
316                            "Invalid item index in suggest_comparisons".to_string(),
317                        )
318                    })?;
319                    let mut wins = 0;
320                    let mut total_comps = 0;
321                    for j_idx in 0..n {
322                        if i_idx == j_idx {
323                            continue;
324                        }
325                        let item_j = self.data.get_item_from_index(j_idx).ok_or_else(|| {
326                            AsapError::InternalError(
327                                "Invalid item index in suggest_comparisons".to_string(),
328                            )
329                        })?;
330                        wins += self.data.get_win_count(&item_i, &item_j)?;
331                        total_comps += self.data.get_comparison_count(&item_i, &item_j)?;
332                    }
333                    temp_scores.insert(
334                        item_i.clone(),
335                        if total_comps > 0 {
336                            wins as f64 / total_comps as f64
337                        } else {
338                            0.5
339                        },
340                    );
341                }
342                temp_scores
343            }
344        };
345
346        for i in 0..n {
347            for j in (i + 1)..n {
348                let item_i = &items[i];
349                let item_j = &items[j];
350
351                let score_i = *current_scores.get(item_i).unwrap_or(&0.5);
352                let score_j = *current_scores.get(item_j).unwrap_or(&0.5);
353
354                let prob_i_wins = 1.0 / (1.0 + (-10.0 * (score_i - score_j)).exp());
355
356                let info_gain = -(prob_i_wins * prob_i_wins.ln()
357                    + (1.0 - prob_i_wins) * (1.0 - prob_i_wins).ln());
358
359                let comparison_count = self.data.get_comparison_count(item_i, item_j)?;
360                let adjusted_gain = info_gain / (1.0 + 0.1 * comparison_count as f64);
361
362                pairs_with_gain.push((adjusted_gain, (item_i.clone(), item_j.clone())));
363            }
364        }
365
366        pairs_with_gain.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
367
368        let result = pairs_with_gain
369            .into_iter()
370            .take(max)
371            .map(|(_, pair)| pair)
372            .collect();
373
374        Ok(result)
375    }
376
377    /// Confidence metric calibrated for Bradley-Terry MLE.
378    ///
379    /// Measures two things:
380    /// 1. **Coverage** (50% weight): fraction of items with >= MIN_COMPARISONS_PER_ITEM
381    ///    comparisons. BT needs ~5-10 per item, not N² total.
382    /// 2. **Discrimination** (50% weight): whether scores are spread out enough to
383    ///    distinguish items. Uses coefficient of variation of the BT strengths.
384    pub fn ranking_confidence(&self) -> Result<f64, AsapError<T>> {
385        let n = self.data.item_count();
386        if n <= 1 {
387            return Ok(1.0);
388        }
389
390        const MIN_COMPARISONS_PER_ITEM: usize = 5;
391
392        // Coverage: fraction of items with enough comparisons
393        let items = self.data.items();
394        let mut well_compared = 0usize;
395        for i in 0..n {
396            let item_i = self
397                .data
398                .get_item_from_index(i)
399                .ok_or_else(|| AsapError::InternalError("Invalid item index".to_string()))?;
400            let mut total = 0usize;
401            for j in 0..n {
402                if i == j {
403                    continue;
404                }
405                let item_j = self
406                    .data
407                    .get_item_from_index(j)
408                    .ok_or_else(|| AsapError::InternalError("Invalid item index".to_string()))?;
409                total += self.data.get_comparison_count(&item_i, &item_j)?;
410            }
411            if total >= MIN_COMPARISONS_PER_ITEM {
412                well_compared += 1;
413            }
414        }
415        let coverage = well_compared as f64 / n as f64;
416
417        // Discrimination: do scores actually differentiate items?
418        let current_scores = match self.scores {
419            Some(ref s) => s.clone(),
420            None => {
421                // No scores computed yet — confidence based on coverage alone
422                return Ok(coverage * 0.5);
423            }
424        };
425
426        let score_vec: Vec<f64> = items
427            .iter()
428            .map(|item| *current_scores.get(item).unwrap_or(&0.0))
429            .collect();
430
431        let mean = score_vec.iter().sum::<f64>() / n as f64;
432        let variance = score_vec
433            .iter()
434            .map(|s| (s - mean) * (s - mean))
435            .sum::<f64>()
436            / n as f64;
437        let std_dev = variance.sqrt();
438
439        // CV > 0.5 means good discrimination; saturates via sigmoid
440        let cv = if mean.abs() > 1e-10 {
441            std_dev / mean.abs()
442        } else {
443            std_dev
444        };
445        let discrimination = 1.0 / (1.0 + (-5.0 * (cv - 0.3)).exp());
446
447        let confidence = 0.5 * coverage + 0.5 * discrimination;
448        Ok(confidence)
449    }
450
451    /// Check if the current ranking has sufficient confidence (threshold: 0.0 to 1.0)
452    pub fn is_sufficiently_confident(&self, threshold: f64) -> Result<bool, AsapError<T>> {
453        let confidence = self.ranking_confidence()?;
454        Ok(confidence >= threshold)
455    }
456
457    /// Bradley-Terry MLE via the MM (minorization-maximization) algorithm.
458    /// Iterates until convergence, producing scores that are independent of
459    /// item ordering. P(i beats j) = score_i / (score_i + score_j).
460    /// Final scores are converted to log-scale for easier interpretation.
461    fn compute_accurate_scores(&self) -> Result<HashMap<T, f64>, AsapError<T>> {
462        let n = self.data.item_count();
463        let mut scores = HashMap::new();
464
465        if n == 0 {
466            return Ok(scores);
467        }
468
469        // Build win matrix and total-games-against matrix
470        let mut wins = vec![vec![0usize; n]; n];
471        let mut total_wins = vec![0usize; n]; // total wins for item i
472        let mut games_against = vec![vec![0usize; n]; n]; // n_ij = games between i and j
473
474        for i in 0..n {
475            let item_i = self
476                .data
477                .get_item_from_index(i)
478                .ok_or_else(|| AsapError::InternalError("Invalid item index".to_string()))?;
479            for j in (i + 1)..n {
480                let item_j = self
481                    .data
482                    .get_item_from_index(j)
483                    .ok_or_else(|| AsapError::InternalError("Invalid item index".to_string()))?;
484
485                let w_ij = self.data.get_win_count(&item_i, &item_j)?;
486                let w_ji = self.data.get_win_count(&item_j, &item_i)?;
487
488                wins[i][j] = w_ij;
489                wins[j][i] = w_ji;
490                total_wins[i] += w_ij;
491                total_wins[j] += w_ji;
492                let n_ij = w_ij + w_ji;
493                games_against[i][j] = n_ij;
494                games_against[j][i] = n_ij;
495            }
496        }
497
498        // Initialize all strengths to 1.0
499        let mut p = vec![1.0f64; n];
500
501        const MAX_ITER: usize = 1000;
502        const TOL: f64 = 1e-8;
503
504        for _iter in 0..MAX_ITER {
505            let mut p_new = vec![0.0f64; n];
506            let mut max_change = 0.0f64;
507
508            for i in 0..n {
509                if total_wins[i] == 0 {
510                    // Item never won — assign a small strength to avoid zero
511                    p_new[i] = TOL;
512                    continue;
513                }
514
515                // MM update: p_i = w_i / sum_j(n_ij / (p_i + p_j))
516                let mut denom = 0.0f64;
517                for j in 0..n {
518                    if i == j || games_against[i][j] == 0 {
519                        continue;
520                    }
521                    denom += games_against[i][j] as f64 / (p[i] + p[j]);
522                }
523
524                if denom > 0.0 {
525                    p_new[i] = total_wins[i] as f64 / denom;
526                } else {
527                    p_new[i] = p[i];
528                }
529            }
530
531            // Normalize so geometric mean = 1 (prevents drift)
532            let log_sum: f64 = p_new.iter().map(|x| x.max(TOL).ln()).sum();
533            let log_mean = log_sum / n as f64;
534            let scale = (-log_mean).exp();
535            for x in &mut p_new {
536                *x *= scale;
537            }
538
539            // Check convergence
540            for i in 0..n {
541                let change = ((p_new[i] - p[i]) / p[i].max(TOL)).abs();
542                if change > max_change {
543                    max_change = change;
544                }
545            }
546
547            p = p_new;
548
549            if max_change < TOL {
550                break;
551            }
552        }
553
554        // Convert to log-scale scores for consistency with the rest of the system
555        for (i, &pi) in p.iter().enumerate().take(n) {
556            let item = self
557                .data
558                .get_item_from_index(i)
559                .ok_or_else(|| AsapError::InternalError("Invalid item index".to_string()))?;
560            scores.insert(item.clone(), pi.max(TOL).ln());
561        }
562
563        Ok(scores)
564    }
565
566    /// Approximate scoring: same Bradley-Terry MM algorithm but with fewer
567    /// iterations for speed. Still order-independent unlike the old TrueSkill approach.
568    fn compute_approximate_scores(&self) -> Result<HashMap<T, f64>, AsapError<T>> {
569        let n = self.data.item_count();
570        let mut scores = HashMap::new();
571
572        if n == 0 {
573            return Ok(scores);
574        }
575
576        // Build win/games matrices (same as accurate)
577        let mut total_wins = vec![0usize; n];
578        let mut games_against = vec![vec![0usize; n]; n];
579
580        for i in 0..n {
581            let item_i = self
582                .data
583                .get_item_from_index(i)
584                .ok_or_else(|| AsapError::InternalError("Invalid item index".to_string()))?;
585            for j in (i + 1)..n {
586                let item_j = self
587                    .data
588                    .get_item_from_index(j)
589                    .ok_or_else(|| AsapError::InternalError("Invalid item index".to_string()))?;
590
591                let w_ij = self.data.get_win_count(&item_i, &item_j)?;
592                let w_ji = self.data.get_win_count(&item_j, &item_i)?;
593
594                total_wins[i] += w_ij;
595                total_wins[j] += w_ji;
596                let n_ij = w_ij + w_ji;
597                games_against[i][j] = n_ij;
598                games_against[j][i] = n_ij;
599            }
600        }
601
602        let mut p = vec![1.0f64; n];
603        const MAX_ITER: usize = 100; // Fewer iterations than accurate
604        const TOL: f64 = 1e-6;
605
606        for _iter in 0..MAX_ITER {
607            let mut p_new = vec![0.0f64; n];
608            let mut max_change = 0.0f64;
609
610            for i in 0..n {
611                if total_wins[i] == 0 {
612                    p_new[i] = TOL;
613                    continue;
614                }
615
616                let mut denom = 0.0f64;
617                for j in 0..n {
618                    if i == j || games_against[i][j] == 0 {
619                        continue;
620                    }
621                    denom += games_against[i][j] as f64 / (p[i] + p[j]);
622                }
623
624                if denom > 0.0 {
625                    p_new[i] = total_wins[i] as f64 / denom;
626                } else {
627                    p_new[i] = p[i];
628                }
629            }
630
631            let log_sum: f64 = p_new.iter().map(|x| x.max(TOL).ln()).sum();
632            let log_mean = log_sum / n as f64;
633            let scale = (-log_mean).exp();
634            for x in &mut p_new {
635                *x *= scale;
636            }
637
638            for i in 0..n {
639                let change = ((p_new[i] - p[i]) / p[i].max(TOL)).abs();
640                if change > max_change {
641                    max_change = change;
642                }
643            }
644
645            p = p_new;
646
647            if max_change < TOL {
648                break;
649            }
650        }
651
652        for (i, &pi) in p.iter().enumerate().take(n) {
653            let item = self
654                .data
655                .get_item_from_index(i)
656                .ok_or_else(|| AsapError::InternalError("Invalid item index".to_string()))?;
657            scores.insert(item.clone(), pi.max(TOL).ln());
658        }
659
660        Ok(scores)
661    }
662}
663
664#[cfg(feature = "serde")]
665impl<
666    T: Clone
667        + Debug
668        + Eq
669        + Hash
670        + Display
671        + Send
672        + Sync
673        + 'static
674        + serde::Serialize
675        + serde::de::DeserializeOwned,
676> RankingModel<T>
677{
678    pub fn to_json(&self) -> Result<String, AsapError<T>> {
679        serde_json::to_string(self)
680            .map_err(|e| AsapError::SerializationError(format!("Failed to serialize: {}", e)))
681    }
682
683    pub fn from_json(json: &str) -> Result<Self, AsapError<T>> {
684        serde_json::from_str(json)
685            .map_err(|e| AsapError::SerializationError(format!("Failed to deserialize: {}", e)))
686    }
687
688    pub fn save_to_file(&self, path: &str) -> Result<(), AsapError<T>> {
689        let json = self.to_json()?;
690        std::fs::write(path, json)
691            .map_err(|e| AsapError::SerializationError(format!("Failed to write file: {}", e)))
692    }
693
694    pub fn load_from_file(path: &str) -> Result<Self, AsapError<T>> {
695        let json = std::fs::read_to_string(path)
696            .map_err(|e| AsapError::SerializationError(format!("Failed to read file: {}", e)))?;
697        Self::from_json(&json)
698    }
699}
700
701#[cfg(not(feature = "serde"))]
702impl<T: Clone + Debug + Eq + Hash + Display + Send + Sync + 'static> RankingModel<T> {
703    // Define stubs or error-out for non-serde builds if these methods are called.
704    // Or, conditionally compile these methods only when "serde" feature is enabled.
705    // For now, these methods will only be available if "serde" is on, as per the cfg above.
706    // If to_json etc. must exist always, they should return an error when serde is off.
707    // Example stub (would require AsapError to be non-generic or handle this):
708    // pub fn to_json(&self) -> Result<String, AsapError<T>> {
709    //     Err(AsapError::SerializationError("Serde feature not enabled".to_string()))
710    // }
711}
712
713#[cfg(test)]
714mod tests {
715    use super::*;
716
717    #[test]
718    fn test_comparison_matrix_new() {
719        let items = vec!["A".to_string(), "B".to_string(), "C".to_string()];
720        let matrix = ComparisonMatrix::<String>::new(&items);
721
722        assert_eq!(matrix.item_count(), 3);
723        assert_eq!(matrix.total_comparisons(), 0);
724    }
725
726    #[test]
727    fn test_add_comparison() {
728        let items = vec!["A".to_string(), "B".to_string(), "C".to_string()];
729        let mut matrix = ComparisonMatrix::<String>::new(&items);
730
731        let comparison = Comparison {
732            winner: "A".to_string(),
733            loser: "B".to_string(),
734        };
735
736        matrix.add_comparison(&comparison).unwrap();
737
738        assert_eq!(
739            matrix
740                .get_win_count(&"A".to_string(), &"B".to_string())
741                .unwrap(),
742            1
743        );
744        assert_eq!(
745            matrix
746                .get_win_count(&"B".to_string(), &"A".to_string())
747                .unwrap(),
748            0
749        );
750        assert_eq!(matrix.total_comparisons(), 1);
751    }
752
753    #[test]
754    fn test_comparison_matrix_add_item() {
755        let items = vec!["A".to_string(), "B".to_string()];
756        let mut matrix = ComparisonMatrix::<String>::new(&items);
757
758        matrix.add_item("C".to_string()).unwrap();
759
760        assert_eq!(matrix.item_count(), 3);
761        assert_eq!(
762            matrix
763                .get_win_count(&"A".to_string(), &"C".to_string())
764                .unwrap(),
765            0
766        );
767        assert_eq!(
768            matrix
769                .get_win_count(&"C".to_string(), &"A".to_string())
770                .unwrap(),
771            0
772        );
773
774        let result = matrix.add_item("A".to_string());
775        assert!(result.is_err());
776
777        let comparison = Comparison {
778            winner: "C".to_string(),
779            loser: "A".to_string(),
780        };
781        matrix.add_comparison(&comparison).unwrap();
782
783        assert_eq!(
784            matrix
785                .get_win_count(&"C".to_string(), &"A".to_string())
786                .unwrap(),
787            1
788        );
789    }
790
791    #[test]
792    fn test_ranking_model_new() {
793        let items = vec!["A".to_string(), "B".to_string(), "C".to_string()];
794        let model = RankingModel::<String>::new(&items);
795
796        assert_eq!(model.data.item_count(), 3);
797        assert!(model.scores.is_none());
798    }
799
800    #[test]
801    fn test_ranking_model_add_comparison() {
802        let items = vec!["A".to_string(), "B".to_string(), "C".to_string()];
803        let mut model = RankingModel::<String>::new(&items);
804
805        let comparison = Comparison {
806            winner: "A".to_string(),
807            loser: "B".to_string(),
808        };
809
810        model.add_comparison(comparison).unwrap();
811
812        assert_eq!(model.data.total_comparisons(), 1);
813        assert!(model.scores.is_none());
814    }
815
816    #[test]
817    fn test_ranking_model_add_item() {
818        let items = vec!["A".to_string(), "B".to_string()];
819        let mut model = RankingModel::<String>::new(&items);
820
821        model
822            .add_comparison(Comparison {
823                winner: "A".to_string(),
824                loser: "B".to_string(),
825            })
826            .unwrap();
827        model
828            .add_comparison(Comparison {
829                winner: "A".to_string(),
830                loser: "B".to_string(),
831            })
832            .unwrap();
833
834        let scores_before = model.get_scores().unwrap();
835        assert!(scores_before.contains_key(&"A".to_string()));
836        assert!(scores_before.contains_key(&"B".to_string()));
837
838        model.add_item("C".to_string()).unwrap();
839
840        assert!(model.scores.is_none());
841
842        model
843            .add_comparison(Comparison {
844                winner: "C".to_string(),
845                loser: "A".to_string(),
846            })
847            .unwrap();
848
849        let scores_after = model.get_scores().unwrap();
850        assert!(scores_after.contains_key(&"A".to_string()));
851        assert!(scores_after.contains_key(&"B".to_string()));
852        assert!(scores_after.contains_key(&"C".to_string()));
853
854        assert!(
855            scores_after.get(&"C".to_string()).unwrap()
856                > scores_after.get(&"A".to_string()).unwrap()
857        );
858    }
859
860    #[test]
861    #[cfg(feature = "serde")]
862    fn test_serialization_deserialization() {
863        let items = vec!["A".to_string(), "B".to_string(), "C".to_string()];
864        let mut model = RankingModel::<String>::new(&items);
865
866        model
867            .add_comparison(Comparison {
868                winner: "A".to_string(),
869                loser: "B".to_string(),
870            })
871            .unwrap();
872        model
873            .add_comparison(Comparison {
874                winner: "B".to_string(),
875                loser: "C".to_string(),
876            })
877            .unwrap();
878        model
879            .add_comparison(Comparison {
880                winner: "A".to_string(),
881                loser: "C".to_string(),
882            })
883            .unwrap();
884
885        let original_scores = model.get_scores().unwrap();
886
887        let json = model.to_json().unwrap();
888
889        let mut deserialized_model = RankingModel::<String>::from_json(&json).unwrap();
890
891        assert_eq!(
892            deserialized_model.data.item_count(),
893            model.data.item_count()
894        );
895        assert_eq!(
896            deserialized_model.data.total_comparisons(),
897            model.data.total_comparisons()
898        );
899
900        let deserialized_scores = deserialized_model.get_scores().unwrap();
901        for (item, score) in &original_scores {
902            assert!(deserialized_scores.contains_key(item));
903            assert!((deserialized_scores.get(item).unwrap() - score).abs() < 1e-6);
904        }
905    }
906
907    #[test]
908    fn test_comparison_matrix_remove_item() {
909        let items_initial = vec!["A".to_string(), "B".to_string(), "C".to_string()];
910        let mut matrix = ComparisonMatrix::<String>::new(&items_initial);
911
912        matrix
913            .add_comparison(&Comparison {
914                winner: "A".to_string(),
915                loser: "B".to_string(),
916            })
917            .unwrap(); // A > B (1)
918        matrix
919            .add_comparison(&Comparison {
920                winner: "B".to_string(),
921                loser: "C".to_string(),
922            })
923            .unwrap(); // B > C (2)
924        matrix
925            .add_comparison(&Comparison {
926                winner: "A".to_string(),
927                loser: "C".to_string(),
928            })
929            .unwrap(); // A > C (3)
930
931        assert_eq!(matrix.item_count(), 3);
932        assert_eq!(matrix.total_comparisons(), 3);
933
934        // Remove "B"
935        matrix.remove_item(&"B".to_string()).unwrap();
936
937        assert_eq!(matrix.item_count(), 2);
938        // Comparisons involving B (A>B, B>C) are removed. A>C remains.
939        // A>B means win_counts[A][B] = 1. B>C means win_counts[B][C] = 1.
940        // comps_to_remove for B:
941        // wins by B: win_counts[B_old_idx][C_old_idx] = 1
942        // wins over B: win_counts[A_old_idx][B_old_idx] = 1
943        // Total comps_to_remove = 1 (A>B) + 1 (B>C) = 2.
944        // So, 3 - 2 = 1 comparison should remain.
945        assert_eq!(matrix.total_comparisons(), 1);
946
947        assert!(matrix.get_item_index(&"A".to_string()).is_ok());
948        assert!(matrix.get_item_index(&"C".to_string()).is_ok());
949        assert!(matrix.get_item_index(&"B".to_string()).is_err());
950
951        // Check remaining win count A > C
952        assert_eq!(
953            matrix
954                .get_win_count(&"A".to_string(), &"C".to_string())
955                .unwrap(),
956            1
957        );
958
959        // Check indices are updated
960        let a_idx = matrix.get_item_index(&"A".to_string()).unwrap();
961        let c_idx = matrix.get_item_index(&"C".to_string()).unwrap();
962        assert!((a_idx == 0 && c_idx == 1) || (a_idx == 1 && c_idx == 0));
963
964        // Try removing a non-existent item
965        let result = matrix.remove_item(&"D".to_string());
966        assert!(matches!(result, Err(AsapError::ItemNotFound(_))));
967
968        // Remove "A"
969        matrix.remove_item(&"A".to_string()).unwrap();
970        assert_eq!(matrix.item_count(), 1);
971        assert_eq!(matrix.total_comparisons(), 0);
972        assert!(matrix.get_item_index(&"C".to_string()).is_ok());
973
974        // Remove "C" (last item)
975        matrix.remove_item(&"C".to_string()).unwrap();
976        assert_eq!(matrix.item_count(), 0);
977        assert_eq!(matrix.total_comparisons(), 0);
978    }
979
980    #[test]
981    fn test_ranking_model_remove_item() {
982        let items = vec!["A".to_string(), "B".to_string(), "C".to_string()];
983        let mut model = RankingModel::<String>::new(&items);
984
985        model
986            .add_comparison(Comparison {
987                winner: "A".to_string(),
988                loser: "B".to_string(),
989            })
990            .unwrap();
991        model
992            .add_comparison(Comparison {
993                winner: "B".to_string(),
994                loser: "C".to_string(),
995            })
996            .unwrap();
997
998        // Populate scores
999        let scores_before_removal = model.get_scores().unwrap();
1000        assert!(scores_before_removal.contains_key(&"A".to_string()));
1001        assert!(scores_before_removal.contains_key(&"B".to_string()));
1002        assert!(scores_before_removal.contains_key(&"C".to_string()));
1003        assert!(model.scores.is_some());
1004
1005        // Remove "B"
1006        model.remove_item(&"B".to_string()).unwrap();
1007
1008        assert_eq!(model.data.item_count(), 2);
1009        assert!(model.scores.is_none()); // Scores should be cleared
1010
1011        // Get scores again
1012        let scores_after_removal = model.get_scores().unwrap();
1013        assert!(scores_after_removal.contains_key(&"A".to_string()));
1014        assert!(scores_after_removal.contains_key(&"C".to_string()));
1015        assert!(!scores_after_removal.contains_key(&"B".to_string()));
1016
1017        // Check ordering
1018        let ordering = model.get_ordering().unwrap();
1019        assert_eq!(ordering.len(), 2);
1020        assert!(ordering.contains(&"A".to_string()));
1021        assert!(ordering.contains(&"C".to_string()));
1022
1023        // Try removing a non-existent item
1024        let result = model.remove_item(&"D".to_string());
1025        assert!(matches!(result, Err(AsapError::ItemNotFound(_))));
1026    }
1027}