pub fn saaty_random_index(n: usize) -> Option<f64> {
const RI: [f64; 11] = [
0.0, 0.0, 0.0, 0.58, 0.90, 1.12, 1.24, 1.32, 1.41, 1.45, 1.49, ];
RI.get(n).copied()
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PairwiseMatrix {
a: Vec<Vec<f64>>,
}
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
pub struct AhpResult {
pub priorities: Vec<f64>,
pub lambda_max: f64,
pub consistency_index: f64,
pub consistency_ratio: Option<f64>,
pub acceptable: bool,
}
#[derive(Clone, Copy, Debug)]
pub struct PowerIterCfg {
pub max_iters: usize,
pub tol: f64,
}
impl Default for PowerIterCfg {
fn default() -> Self {
Self {
max_iters: 10_000,
tol: 1e-15,
}
}
}
impl PairwiseMatrix {
pub fn new(a: Vec<Vec<f64>>) -> Result<Self, String> {
let n = a.len();
if n == 0 {
return Err("pairwise matrix is empty".into());
}
for (i, row) in a.iter().enumerate() {
if row.len() != n {
return Err(format!(
"pairwise matrix is not square (row {i} has {} of {n})",
row.len()
));
}
for (j, &v) in row.iter().enumerate() {
if !v.is_finite() || v <= 0.0 {
return Err(format!(
"entry a[{i}][{j}] = {v} must be finite and positive"
));
}
}
}
for (i, row) in a.iter().enumerate() {
if (row[i] - 1.0).abs() > 1e-9 {
return Err(format!("diagonal a[{i}][{i}] = {} must be 1", row[i]));
}
for (j, &aij) in row.iter().enumerate().skip(i + 1) {
let prod = aij * a[j][i];
if (prod - 1.0).abs() > 1e-6 {
return Err(format!(
"entries a[{i}][{j}]·a[{j}][{i}] = {prod} must be reciprocal (≈ 1)"
));
}
}
}
Ok(Self { a })
}
pub fn from_upper(upper: Vec<Vec<f64>>) -> Result<Self, String> {
let n = upper.len() + 1;
let mut a = vec![vec![1.0; n]; n];
for (i, row) in upper.iter().enumerate() {
if row.len() != n - 1 - i {
return Err(format!(
"upper row {i} has {} entries, expected {}",
row.len(),
n - 1 - i
));
}
for (k, &v) in row.iter().enumerate() {
let j = i + 1 + k;
if !v.is_finite() || v <= 0.0 {
return Err(format!(
"upper entry ({i},{j}) = {v} must be finite and positive"
));
}
a[i][j] = v;
a[j][i] = 1.0 / v;
}
}
Self::new(a)
}
pub fn n(&self) -> usize {
self.a.len()
}
pub fn entries(&self) -> &[Vec<f64>] {
&self.a
}
pub fn priority_vector(&self) -> Vec<f64> {
self.priority_vector_cfg(PowerIterCfg::default())
}
pub fn priority_vector_cfg(&self, cfg: PowerIterCfg) -> Vec<f64> {
let n = self.n();
let mut w = vec![1.0 / n as f64; n];
for _ in 0..cfg.max_iters {
let nw = self.normalise(self.mat_vec(&w));
let delta = nw
.iter()
.zip(w.iter())
.map(|(a, b)| (a - b).abs())
.fold(0.0_f64, f64::max);
w = nw;
if delta < cfg.tol {
break;
}
}
w
}
pub fn lambda_max(&self, w: &[f64]) -> f64 {
let aw = self.mat_vec(w);
let n = self.n();
let mut acc = 0.0;
for i in 0..n {
acc += aw[i] / w[i];
}
acc / n as f64
}
pub fn analyse(&self) -> AhpResult {
self.analyse_with(0.10, PowerIterCfg::default())
}
pub fn analyse_with(&self, threshold: f64, cfg: PowerIterCfg) -> AhpResult {
let n = self.n();
let priorities = self.priority_vector_cfg(cfg);
let lambda_max = self.lambda_max(&priorities);
let consistency_index = if n > 2 {
(lambda_max - n as f64) / (n as f64 - 1.0)
} else {
0.0
};
let ri = saaty_random_index(n).filter(|&r| r > 0.0);
let consistency_ratio = ri.map(|r| consistency_index / r);
let acceptable = match consistency_ratio {
Some(cr) => cr < threshold,
None => true, };
AhpResult {
priorities,
lambda_max,
consistency_index,
consistency_ratio,
acceptable,
}
}
fn mat_vec(&self, w: &[f64]) -> Vec<f64> {
self.a
.iter()
.map(|row| row.iter().zip(w.iter()).map(|(a, b)| a * b).sum())
.collect()
}
fn normalise(&self, v: Vec<f64>) -> Vec<f64> {
let s: f64 = v.iter().sum();
if s == 0.0 {
return v;
}
v.into_iter().map(|x| x / s).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn approx(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol
}
#[test]
fn random_index_is_the_canonical_saaty_table() {
let want = [
(1, 0.0),
(2, 0.0),
(3, 0.58),
(4, 0.90),
(5, 1.12),
(6, 1.24),
(7, 1.32),
(8, 1.41),
(9, 1.45),
(10, 1.49),
];
for (n, ri) in want {
assert_eq!(saaty_random_index(n), Some(ri), "RI({n})");
}
assert_eq!(saaty_random_index(11), None);
}
#[test]
fn consistent_matrix_recovers_exact_weights_and_zero_cr() {
let a = PairwiseMatrix::new(vec![
vec![1.0, 2.0, 4.0],
vec![0.5, 1.0, 2.0],
vec![0.25, 0.5, 1.0],
])
.unwrap();
let r = a.analyse();
assert!(approx(r.priorities[0], 4.0 / 7.0, 1e-12));
assert!(approx(r.priorities[1], 2.0 / 7.0, 1e-12));
assert!(approx(r.priorities[2], 1.0 / 7.0, 1e-12));
assert!(approx(r.lambda_max, 3.0, 1e-12));
assert!(approx(r.consistency_index, 0.0, 1e-12));
assert!(approx(r.consistency_ratio.unwrap(), 0.0, 1e-12));
assert!(r.acceptable);
assert!(approx(r.priorities.iter().sum::<f64>(), 1.0, 1e-15));
}
#[test]
fn inconsistent_4x4_matches_lapack_oracle() {
let a = PairwiseMatrix::new(vec![
vec![1.0, 3.0, 7.0, 9.0],
vec![1.0 / 3.0, 1.0, 5.0, 7.0],
vec![1.0 / 7.0, 1.0 / 5.0, 1.0, 3.0],
vec![1.0 / 9.0, 1.0 / 7.0, 1.0 / 3.0, 1.0],
])
.unwrap();
let r = a.analyse();
assert!(approx(r.lambda_max, 4.164576705149029, 1e-9));
let pv = [
0.583088782744487,
0.289529946821866,
0.084896047730756,
0.042485222702891,
];
for (g, w) in r.priorities.iter().zip(pv) {
assert!(approx(*g, w, 1e-9), "priority {g} != {w}");
}
assert!(approx(
r.consistency_ratio.unwrap(),
0.060954335240381,
1e-9
));
assert!(r.acceptable);
}
#[test]
fn highly_inconsistent_matrix_is_rejected() {
let a = PairwiseMatrix::new(vec![
vec![1.0, 9.0, 5.0],
vec![1.0 / 9.0, 1.0, 3.0],
vec![1.0 / 5.0, 1.0 / 3.0, 1.0],
])
.unwrap();
let r = a.analyse();
assert!(r.consistency_ratio.unwrap() > 0.10);
assert!(!r.acceptable);
}
#[test]
fn from_upper_builds_a_reciprocal_matrix() {
let a = PairwiseMatrix::from_upper(vec![vec![2.0, 4.0], vec![2.0]]).unwrap();
let entries = a.entries();
assert!(approx(entries[1][0], 0.5, 1e-15));
assert!(approx(entries[2][0], 0.25, 1e-15));
assert!(approx(entries[2][1], 0.5, 1e-15));
let r = a.analyse();
assert!(approx(r.lambda_max, 3.0, 1e-12));
}
#[test]
fn construction_rejects_non_reciprocal_and_non_positive() {
assert!(PairwiseMatrix::new(vec![vec![1.0, 2.0], vec![2.0, 1.0]]).is_err());
assert!(PairwiseMatrix::new(vec![vec![1.0, -2.0], vec![-0.5, 1.0]]).is_err());
assert!(PairwiseMatrix::new(vec![vec![1.0, 2.0], vec![0.5]]).is_err());
}
}