Skip to main content

scirs2_optimize/game_theory/
normal_form.rs

1//! Normal-form game analysis and Nash equilibrium computation.
2//!
3//! This module implements algorithms for analyzing two-player normal-form games,
4//! including Nash equilibrium finding via support enumeration, iterated elimination
5//! of dominated strategies, replicator dynamics, and evolutionary stability.
6
7use scirs2_core::ndarray::{Array2, ArrayView2};
8
9use crate::error::OptimizeError;
10
11/// A 2-player normal-form game defined by payoff matrices.
12///
13/// Player 1 has `n_strategies_1` strategies and player 2 has `n_strategies_2` strategies.
14/// `payoff_matrix_1[i, j]` is player 1's payoff when player 1 plays strategy `i` and
15/// player 2 plays strategy `j`. Analogously for `payoff_matrix_2`.
16#[derive(Debug, Clone)]
17pub struct NormalFormGame {
18    /// Payoff matrix for player 1: shape (n_strategies_1, n_strategies_2)
19    pub payoff_matrix_1: Array2<f64>,
20    /// Payoff matrix for player 2: shape (n_strategies_1, n_strategies_2)
21    pub payoff_matrix_2: Array2<f64>,
22    /// Number of pure strategies available to player 1
23    pub n_strategies_1: usize,
24    /// Number of pure strategies available to player 2
25    pub n_strategies_2: usize,
26}
27
28impl NormalFormGame {
29    /// Create a new normal-form game from payoff matrices.
30    ///
31    /// # Arguments
32    /// * `payoff_1` - Payoff matrix for player 1 (shape: n1 × n2)
33    /// * `payoff_2` - Payoff matrix for player 2 (shape: n1 × n2)
34    ///
35    /// # Errors
36    /// Returns `OptimizeError::ValueError` if matrix shapes do not match.
37    pub fn new(payoff_1: Array2<f64>, payoff_2: Array2<f64>) -> Result<Self, OptimizeError> {
38        if payoff_1.shape() != payoff_2.shape() {
39            return Err(OptimizeError::ValueError(format!(
40                "Payoff matrix shapes must match: {:?} != {:?}",
41                payoff_1.shape(),
42                payoff_2.shape()
43            )));
44        }
45        let n1 = payoff_1.nrows();
46        let n2 = payoff_1.ncols();
47        if n1 == 0 || n2 == 0 {
48            return Err(OptimizeError::ValueError(
49                "Payoff matrices must be non-empty".to_string(),
50            ));
51        }
52        Ok(Self {
53            n_strategies_1: n1,
54            n_strategies_2: n2,
55            payoff_matrix_1: payoff_1,
56            payoff_matrix_2: payoff_2,
57        })
58    }
59
60    /// Construct a zero-sum game from a single payoff matrix.
61    ///
62    /// Player 2's payoff is the negative of player 1's payoff.
63    pub fn zero_sum(payoff: Array2<f64>) -> Self {
64        let n1 = payoff.nrows();
65        let n2 = payoff.ncols();
66        let payoff_2 = -payoff.clone();
67        Self {
68            n_strategies_1: n1,
69            n_strategies_2: n2,
70            payoff_matrix_1: payoff,
71            payoff_matrix_2: payoff_2,
72        }
73    }
74
75    /// Construct a symmetric game from a single payoff matrix.
76    ///
77    /// In a symmetric game, both players have the same strategy set and the payoff
78    /// matrix is square. Player 2's payoff is the transpose of player 1's.
79    ///
80    /// # Errors
81    /// Returns `OptimizeError::ValueError` if the matrix is not square.
82    pub fn symmetric(payoff: Array2<f64>) -> Result<Self, OptimizeError> {
83        let n1 = payoff.nrows();
84        let n2 = payoff.ncols();
85        if n1 != n2 {
86            return Err(OptimizeError::ValueError(format!(
87                "Symmetric game requires a square payoff matrix, got {}×{}",
88                n1, n2
89            )));
90        }
91        let payoff_2 = payoff.t().to_owned();
92        Ok(Self {
93            n_strategies_1: n1,
94            n_strategies_2: n2,
95            payoff_matrix_1: payoff,
96            payoff_matrix_2: payoff_2,
97        })
98    }
99
100    /// Get the payoff pair for a pure strategy profile.
101    ///
102    /// # Returns
103    /// `(payoff_1, payoff_2)` for the given strategy indices.
104    pub fn payoff(&self, s1: usize, s2: usize) -> (f64, f64) {
105        (
106            self.payoff_matrix_1[[s1, s2]],
107            self.payoff_matrix_2[[s1, s2]],
108        )
109    }
110
111    /// Compute expected payoffs for mixed strategies.
112    ///
113    /// # Arguments
114    /// * `mixed_1` - Probability distribution over player 1's strategies
115    /// * `mixed_2` - Probability distribution over player 2's strategies
116    ///
117    /// # Errors
118    /// Returns `OptimizeError::ValueError` if the strategy lengths are inconsistent
119    /// or probabilities do not sum to 1.
120    pub fn expected_payoff(
121        &self,
122        mixed_1: &[f64],
123        mixed_2: &[f64],
124    ) -> Result<(f64, f64), OptimizeError> {
125        if mixed_1.len() != self.n_strategies_1 {
126            return Err(OptimizeError::ValueError(format!(
127                "mixed_1 length {} != n_strategies_1 {}",
128                mixed_1.len(),
129                self.n_strategies_1
130            )));
131        }
132        if mixed_2.len() != self.n_strategies_2 {
133            return Err(OptimizeError::ValueError(format!(
134                "mixed_2 length {} != n_strategies_2 {}",
135                mixed_2.len(),
136                self.n_strategies_2
137            )));
138        }
139
140        let tol = 1e-9;
141        let sum1: f64 = mixed_1.iter().sum();
142        if (sum1 - 1.0).abs() > tol {
143            return Err(OptimizeError::ValueError(format!(
144                "mixed_1 must sum to 1.0, got {}",
145                sum1
146            )));
147        }
148        let sum2: f64 = mixed_2.iter().sum();
149        if (sum2 - 1.0).abs() > tol {
150            return Err(OptimizeError::ValueError(format!(
151                "mixed_2 must sum to 1.0, got {}",
152                sum2
153            )));
154        }
155
156        let mut ep1 = 0.0_f64;
157        let mut ep2 = 0.0_f64;
158        for i in 0..self.n_strategies_1 {
159            for j in 0..self.n_strategies_2 {
160                let prob = mixed_1[i] * mixed_2[j];
161                ep1 += prob * self.payoff_matrix_1[[i, j]];
162                ep2 += prob * self.payoff_matrix_2[[i, j]];
163            }
164        }
165        Ok((ep1, ep2))
166    }
167}
168
169/// A Nash equilibrium in a two-player normal-form game.
170#[derive(Debug, Clone)]
171pub struct NashEquilibrium {
172    /// Mixed strategy (probability distribution) for player 1
173    pub strategy_1: Vec<f64>,
174    /// Mixed strategy (probability distribution) for player 2
175    pub strategy_2: Vec<f64>,
176    /// Expected payoff for player 1
177    pub payoff_1: f64,
178    /// Expected payoff for player 2
179    pub payoff_2: f64,
180    /// Whether this is a pure strategy equilibrium
181    pub is_pure: bool,
182}
183
184/// Find all pure strategy Nash equilibria.
185///
186/// A pure Nash equilibrium is a strategy profile `(i*, j*)` where neither player
187/// can profitably deviate: `payoff_1(i*, j*) >= payoff_1(i, j*)` for all `i` and
188/// `payoff_2(i*, j*) >= payoff_2(i*, j)` for all `j`.
189pub fn find_pure_nash_equilibria(game: &NormalFormGame) -> Vec<NashEquilibrium> {
190    let n1 = game.n_strategies_1;
191    let n2 = game.n_strategies_2;
192    let mut results = Vec::new();
193
194    for i in 0..n1 {
195        for j in 0..n2 {
196            // Check if player 1 is best-responding to j
197            let p1_val = game.payoff_matrix_1[[i, j]];
198            let p1_br = (0..n1).all(|i2| game.payoff_matrix_1[[i2, j]] <= p1_val + 1e-12);
199
200            // Check if player 2 is best-responding to i
201            let p2_val = game.payoff_matrix_2[[i, j]];
202            let p2_br = (0..n2).all(|j2| game.payoff_matrix_2[[i, j2]] <= p2_val + 1e-12);
203
204            if p1_br && p2_br {
205                let mut s1 = vec![0.0; n1];
206                let mut s2 = vec![0.0; n2];
207                s1[i] = 1.0;
208                s2[j] = 1.0;
209                results.push(NashEquilibrium {
210                    strategy_1: s1,
211                    strategy_2: s2,
212                    payoff_1: p1_val,
213                    payoff_2: p2_val,
214                    is_pure: true,
215                });
216            }
217        }
218    }
219    results
220}
221
222/// Find all Nash equilibria via support enumeration.
223///
224/// This algorithm enumerates all possible support sets for both players and checks
225/// whether each pair of supports can sustain a Nash equilibrium. For each support
226/// pair, it solves the system of indifference equations.
227///
228/// # Arguments
229/// * `game` - The normal-form game
230/// * `tol` - Tolerance for numerical comparisons (e.g., 1e-9)
231///
232/// # Errors
233/// Returns `OptimizeError::ComputationError` if the underlying linear system fails.
234pub fn find_all_nash_equilibria(
235    game: &NormalFormGame,
236    tol: f64,
237) -> Result<Vec<NashEquilibrium>, OptimizeError> {
238    let n1 = game.n_strategies_1;
239    let n2 = game.n_strategies_2;
240
241    // Start with pure strategy Nash equilibria
242    let mut results = find_pure_nash_equilibria(game);
243
244    // Enumerate all non-empty subsets for player 1 and player 2
245    // For each pair of supports (S1, S2), try to find a completely mixed NE
246    // on those supports.
247    for supp1_mask in 1u64..(1u64 << n1) {
248        let supp1: Vec<usize> = (0..n1).filter(|&i| (supp1_mask >> i) & 1 == 1).collect();
249
250        for supp2_mask in 1u64..(1u64 << n2) {
251            let supp2: Vec<usize> = (0..n2).filter(|&j| (supp2_mask >> j) & 1 == 1).collect();
252
253            let k1 = supp1.len();
254            let k2 = supp2.len();
255
256            // Pure strategy Nash already handled
257            if k1 == 1 && k2 == 1 {
258                continue;
259            }
260
261            // Attempt to find a completely mixed NE on (supp1, supp2)
262            if let Some(ne) = solve_support_ne(game, &supp1, &supp2, tol) {
263                // Check that this NE is not a duplicate of an existing one
264                let is_duplicate = results.iter().any(|existing| {
265                    strategies_approx_equal(&existing.strategy_1, &ne.strategy_1, tol)
266                        && strategies_approx_equal(&existing.strategy_2, &ne.strategy_2, tol)
267                });
268                if !is_duplicate {
269                    results.push(ne);
270                }
271            }
272        }
273    }
274
275    Ok(results)
276}
277
278/// Attempt to find a Nash equilibrium with exactly the given supports.
279///
280/// Solves the indifference conditions for player 1 (all strategies in supp2 give
281/// equal expected payoff for player 1's distribution q) and analogously for player 2.
282fn solve_support_ne(
283    game: &NormalFormGame,
284    supp1: &[usize],
285    supp2: &[usize],
286    tol: f64,
287) -> Option<NashEquilibrium> {
288    let k1 = supp1.len();
289    let k2 = supp2.len();
290
291    // Solve for q (player 2's strategy on supp2) such that player 1 is indifferent
292    // across strategies in supp1.
293    // Indifference: for all i, i' in supp1:
294    //   sum_{j in supp2} A[i,j] q[j] = sum_{j in supp2} A[i',j] q[j]
295    // plus: sum_{j in supp2} q[j] = 1
296    let q = solve_indifference(&game.payoff_matrix_1, supp1, supp2, tol)?;
297
298    // Solve for p (player 1's strategy on supp1) such that player 2 is indifferent
299    // across strategies in supp2.
300    let p = solve_indifference(&game.payoff_matrix_2.t().to_owned(), supp2, supp1, tol)?;
301
302    // Verify that no out-of-support strategy is a profitable deviation
303    // For player 1: all i not in supp1 should have expected payoff <= v1
304    let v1: f64 = supp1
305        .iter()
306        .enumerate()
307        .map(|(idx, &i)| {
308            supp2
309                .iter()
310                .enumerate()
311                .map(|(jdx, &j)| game.payoff_matrix_1[[i, j]] * q[jdx])
312                .sum::<f64>()
313                * p[idx]
314        })
315        .sum();
316
317    for i in 0..game.n_strategies_1 {
318        if !supp1.contains(&i) {
319            let dev_payoff: f64 = supp2
320                .iter()
321                .enumerate()
322                .map(|(jdx, &j)| game.payoff_matrix_1[[i, j]] * q[jdx])
323                .sum();
324            if dev_payoff > v1 + tol {
325                return None;
326            }
327        }
328    }
329
330    let v2: f64 = supp2
331        .iter()
332        .enumerate()
333        .map(|(jdx, &j)| {
334            supp1
335                .iter()
336                .enumerate()
337                .map(|(idx, &i)| game.payoff_matrix_2[[i, j]] * p[idx])
338                .sum::<f64>()
339                * q[jdx]
340        })
341        .sum();
342
343    for j in 0..game.n_strategies_2 {
344        if !supp2.contains(&j) {
345            let dev_payoff: f64 = supp1
346                .iter()
347                .enumerate()
348                .map(|(idx, &i)| game.payoff_matrix_2[[i, j]] * p[idx])
349                .sum();
350            if dev_payoff > v2 + tol {
351                return None;
352            }
353        }
354    }
355
356    // Build full mixed strategies
357    let n1 = game.n_strategies_1;
358    let n2 = game.n_strategies_2;
359    let mut strategy_1 = vec![0.0; n1];
360    let mut strategy_2 = vec![0.0; n2];
361    for (idx, &i) in supp1.iter().enumerate() {
362        strategy_1[i] = p[idx];
363    }
364    for (jdx, &j) in supp2.iter().enumerate() {
365        strategy_2[j] = q[jdx];
366    }
367
368    let is_pure = k1 == 1 && k2 == 1;
369
370    Some(NashEquilibrium {
371        strategy_1,
372        strategy_2,
373        payoff_1: v1,
374        payoff_2: v2,
375        is_pure,
376    })
377}
378
379/// Solve the indifference system: given payoff matrix A and support sets,
380/// find a probability distribution `q` on `supp_col` such that all strategies
381/// in `supp_row` yield equal expected payoff.
382///
383/// The system is:
384///   A[i, supp_col] · q = v  for all i in supp_row
385///   sum(q) = 1
386///   q >= 0
387fn solve_indifference(
388    payoff: &Array2<f64>,
389    supp_row: &[usize],
390    supp_col: &[usize],
391    tol: f64,
392) -> Option<Vec<f64>> {
393    let k_col = supp_col.len();
394    let k_row = supp_row.len();
395
396    if k_col == 1 {
397        // Only one strategy: trivially q = [1.0]
398        return Some(vec![1.0]);
399    }
400
401    // Build system of (k_row - 1) indifference equations + 1 simplex equation
402    // = k_col unknowns
403    // We use: A[supp_row[0], :] - A[supp_row[i], :] = 0 for i = 1..k_row
404    // plus: sum q = 1
405
406    // Number of equations = k_row - 1 + 1 = k_row
407    // Number of unknowns = k_col
408    // For a generic solution we need k_row == k_col (square system)
409    if k_row != k_col {
410        // Over/under determined system; we attempt least-squares via normal equations
411        // but require exact solution for equilibrium validity.
412        // We solve the minimum-norm least-squares solution and verify later.
413    }
414
415    // Build matrix M and rhs b for the system M·q = b
416    // Row 0..k_row-1: indifference (A_i0 - A_ij) for reference strategy i0 = supp_row[0]
417    // Row k_row-1: sum(q) = 1
418    let n_eq = k_row; // k_row - 1 indifference + 1 simplex
419    let n_var = k_col;
420
421    let mut mat = vec![0.0_f64; n_eq * n_var];
422    let mut rhs = vec![0.0_f64; n_eq];
423
424    // Indifference rows: for i = 1..k_row, (A[supp_row[0], col] - A[supp_row[i], col]) * q = 0
425    for eq_idx in 0..(k_row - 1) {
426        let i0 = supp_row[0];
427        let i1 = supp_row[eq_idx + 1];
428        for (col_idx, &j) in supp_col.iter().enumerate() {
429            mat[eq_idx * n_var + col_idx] = payoff[[i0, j]] - payoff[[i1, j]];
430        }
431        rhs[eq_idx] = 0.0;
432    }
433
434    // Simplex constraint row: sum(q) = 1
435    let simplex_row = k_row - 1;
436    for col_idx in 0..k_col {
437        mat[simplex_row * n_var + col_idx] = 1.0;
438    }
439    rhs[simplex_row] = 1.0;
440
441    // Solve via Gaussian elimination (square or least-squares)
442    let q = if n_eq == n_var {
443        gaussian_elimination(&mat, &rhs, n_var)?
444    } else {
445        // Normal equations: M^T M q = M^T b
446        least_squares_solve(&mat, &rhs, n_eq, n_var)?
447    };
448
449    // Verify non-negativity and sum = 1
450    if q.iter().any(|&v| v < -tol) {
451        return None;
452    }
453    let s: f64 = q.iter().sum();
454    if (s - 1.0).abs() > tol * 10.0 {
455        return None;
456    }
457
458    // Clamp small negatives
459    let q_clamped: Vec<f64> = q.iter().map(|&v| v.max(0.0)).collect();
460    let s2: f64 = q_clamped.iter().sum();
461    if s2 < tol {
462        return None;
463    }
464    let q_normalized: Vec<f64> = q_clamped.iter().map(|&v| v / s2).collect();
465
466    // Verify all probabilities are positive (support condition)
467    if q_normalized.iter().any(|&v| v < tol) {
468        return None;
469    }
470
471    Some(q_normalized)
472}
473
474/// Gaussian elimination with partial pivoting to solve Ax = b.
475/// Returns None if the system is singular.
476fn gaussian_elimination(mat: &[f64], rhs: &[f64], n: usize) -> Option<Vec<f64>> {
477    let mut a = mat.to_vec();
478    let mut b = rhs.to_vec();
479
480    for col in 0..n {
481        // Find pivot
482        let pivot_row = (col..n).max_by(|&r1, &r2| {
483            a[r1 * n + col]
484                .abs()
485                .partial_cmp(&a[r2 * n + col].abs())
486                .unwrap_or(std::cmp::Ordering::Equal)
487        })?;
488
489        if a[pivot_row * n + col].abs() < 1e-14 {
490            return None;
491        }
492
493        // Swap rows
494        if pivot_row != col {
495            for k in 0..n {
496                a.swap(col * n + k, pivot_row * n + k);
497            }
498            b.swap(col, pivot_row);
499        }
500
501        let pivot = a[col * n + col];
502        for row in (col + 1)..n {
503            let factor = a[row * n + col] / pivot;
504            for k in col..n {
505                let val = a[col * n + k] * factor;
506                a[row * n + k] -= val;
507            }
508            b[row] -= b[col] * factor;
509        }
510    }
511
512    // Back substitution
513    let mut x = vec![0.0_f64; n];
514    for i in (0..n).rev() {
515        let mut sum = b[i];
516        for j in (i + 1)..n {
517            sum -= a[i * n + j] * x[j];
518        }
519        if a[i * n + i].abs() < 1e-14 {
520            return None;
521        }
522        x[i] = sum / a[i * n + i];
523    }
524    Some(x)
525}
526
527/// Least-squares solve via normal equations M^T M x = M^T b.
528fn least_squares_solve(mat: &[f64], rhs: &[f64], n_eq: usize, n_var: usize) -> Option<Vec<f64>> {
529    // Build M^T M
530    let mut ata = vec![0.0_f64; n_var * n_var];
531    for i in 0..n_var {
532        for j in 0..n_var {
533            for k in 0..n_eq {
534                ata[i * n_var + j] += mat[k * n_var + i] * mat[k * n_var + j];
535            }
536        }
537    }
538    // Build M^T b
539    let mut atb = vec![0.0_f64; n_var];
540    for i in 0..n_var {
541        for k in 0..n_eq {
542            atb[i] += mat[k * n_var + i] * rhs[k];
543        }
544    }
545    gaussian_elimination(&ata, &atb, n_var)
546}
547
548/// Check if two strategy vectors are approximately equal.
549fn strategies_approx_equal(a: &[f64], b: &[f64], tol: f64) -> bool {
550    if a.len() != b.len() {
551        return false;
552    }
553    a.iter()
554        .zip(b.iter())
555        .all(|(x, y)| (x - y).abs() < tol * 10.0)
556}
557
558/// Find the best responses for player 1 given player 2's mixed strategy.
559///
560/// Returns the indices of all strategies that maximize player 1's expected payoff.
561pub fn best_response_1(game: &NormalFormGame, opponent_mixed: &[f64]) -> Vec<usize> {
562    let n1 = game.n_strategies_1;
563    let n2 = game.n_strategies_2;
564
565    if opponent_mixed.len() != n2 {
566        return Vec::new();
567    }
568
569    let payoffs: Vec<f64> = (0..n1)
570        .map(|i| {
571            (0..n2)
572                .map(|j| game.payoff_matrix_1[[i, j]] * opponent_mixed[j])
573                .sum::<f64>()
574        })
575        .collect();
576
577    let max_payoff = payoffs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
578    (0..n1)
579        .filter(|&i| (payoffs[i] - max_payoff).abs() < 1e-9)
580        .collect()
581}
582
583/// Find the best responses for player 2 given player 1's mixed strategy.
584///
585/// Returns the indices of all strategies that maximize player 2's expected payoff.
586pub fn best_response_2(game: &NormalFormGame, opponent_mixed: &[f64]) -> Vec<usize> {
587    let n1 = game.n_strategies_1;
588    let n2 = game.n_strategies_2;
589
590    if opponent_mixed.len() != n1 {
591        return Vec::new();
592    }
593
594    let payoffs: Vec<f64> = (0..n2)
595        .map(|j| {
596            (0..n1)
597                .map(|i| game.payoff_matrix_2[[i, j]] * opponent_mixed[i])
598                .sum::<f64>()
599        })
600        .collect();
601
602    let max_payoff = payoffs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
603    (0..n2)
604        .filter(|&j| (payoffs[j] - max_payoff).abs() < 1e-9)
605        .collect()
606}
607
608/// Iterated elimination of strictly dominated strategies (IESDS).
609///
610/// Repeatedly removes strategies that are strictly dominated by some mixed strategy
611/// until no more can be removed. Returns the indices of surviving strategies for
612/// each player.
613///
614/// # Returns
615/// `(surviving_1, surviving_2)` — indices of undominated strategies for each player.
616pub fn iterated_elimination(game: &mut NormalFormGame) -> (Vec<usize>, Vec<usize>) {
617    let n1 = game.n_strategies_1;
618    let n2 = game.n_strategies_2;
619
620    let mut active_1: Vec<usize> = (0..n1).collect();
621    let mut active_2: Vec<usize> = (0..n2).collect();
622
623    let mut changed = true;
624    while changed {
625        changed = false;
626
627        // Eliminate strictly dominated strategies for player 1
628        let mut to_remove_1: Vec<usize> = Vec::new();
629        for &i in &active_1 {
630            if is_strictly_dominated_1(&game.payoff_matrix_1, i, &active_1, &active_2) {
631                to_remove_1.push(i);
632                changed = true;
633            }
634        }
635        active_1.retain(|i| !to_remove_1.contains(i));
636
637        // Eliminate strictly dominated strategies for player 2
638        let mut to_remove_2: Vec<usize> = Vec::new();
639        for &j in &active_2 {
640            if is_strictly_dominated_2(&game.payoff_matrix_2, j, &active_1, &active_2) {
641                to_remove_2.push(j);
642                changed = true;
643            }
644        }
645        active_2.retain(|j| !to_remove_2.contains(j));
646    }
647
648    (active_1, active_2)
649}
650
651/// Check if strategy `i` for player 1 is strictly dominated on the active sub-game.
652fn is_strictly_dominated_1(
653    payoff: &Array2<f64>,
654    i: usize,
655    active_1: &[usize],
656    active_2: &[usize],
657) -> bool {
658    let k1 = active_1.len();
659    // A strategy i is strictly dominated if there exists a (possibly mixed) strategy
660    // that strictly beats it against all opponent strategies.
661    // For each j in active_2: sum_{i' in active_1} alpha[i'] * A[i', j] > A[i, j]
662    // We check via a simple linear-search over pure strategies first for efficiency,
663    // then skip mixed (full LP would be needed for mixed domination — simplified here).
664    for &i2 in active_1 {
665        if i2 == i {
666            continue;
667        }
668        let dominates = active_2.iter().all(|&j| payoff[[i2, j]] > payoff[[i, j]]);
669        if dominates {
670            return true;
671        }
672    }
673    // Check uniform mixture as a heuristic for mixed domination
674    if k1 > 1 {
675        let alpha = 1.0 / (k1 - 1) as f64;
676        for &j in active_2 {
677            let mixed_val: f64 = active_1
678                .iter()
679                .filter(|&&i2| i2 != i)
680                .map(|&i2| payoff[[i2, j]] * alpha)
681                .sum();
682            if mixed_val <= payoff[[i, j]] {
683                return false;
684            }
685        }
686        // All opponent strategies see higher payoff from uniform mix
687        if active_1.len() > 1 {
688            let all_dominated = active_2.iter().all(|&j| {
689                let mixed_val: f64 = active_1
690                    .iter()
691                    .filter(|&&i2| i2 != i)
692                    .map(|&i2| payoff[[i2, j]] * alpha)
693                    .sum();
694                mixed_val > payoff[[i, j]]
695            });
696            if all_dominated {
697                return true;
698            }
699        }
700    }
701    false
702}
703
704/// Check if strategy `j` for player 2 is strictly dominated on the active sub-game.
705fn is_strictly_dominated_2(
706    payoff: &Array2<f64>,
707    j: usize,
708    active_1: &[usize],
709    active_2: &[usize],
710) -> bool {
711    let k2 = active_2.len();
712    for &j2 in active_2 {
713        if j2 == j {
714            continue;
715        }
716        let dominates = active_1.iter().all(|&i| payoff[[i, j2]] > payoff[[i, j]]);
717        if dominates {
718            return true;
719        }
720    }
721    if k2 > 1 {
722        let alpha = 1.0 / (k2 - 1) as f64;
723        let all_dominated = active_1.iter().all(|&i| {
724            let mixed_val: f64 = active_2
725                .iter()
726                .filter(|&&j2| j2 != j)
727                .map(|&j2| payoff[[i, j2]] * alpha)
728                .sum();
729            mixed_val > payoff[[i, j]]
730        });
731        if all_dominated {
732            return true;
733        }
734    }
735    false
736}
737
738/// Find a dominant strategy equilibrium if one exists.
739///
740/// A dominant strategy equilibrium exists when each player has a strategy that
741/// strictly dominates all others regardless of the opponent's strategy.
742///
743/// # Returns
744/// `Some((i*, j*))` if a dominant strategy equilibrium exists, `None` otherwise.
745pub fn dominant_strategy_equilibrium(game: &NormalFormGame) -> Option<(usize, usize)> {
746    let n1 = game.n_strategies_1;
747    let n2 = game.n_strategies_2;
748
749    // Find dominant strategy for player 1
750    let ds1 = (0..n1).find(|&i| {
751        (0..n1)
752            .filter(|&i2| i2 != i)
753            .all(|i2| (0..n2).all(|j| game.payoff_matrix_1[[i, j]] > game.payoff_matrix_1[[i2, j]]))
754    });
755
756    // Find dominant strategy for player 2
757    let ds2 = (0..n2).find(|&j| {
758        (0..n2)
759            .filter(|&j2| j2 != j)
760            .all(|j2| (0..n1).all(|i| game.payoff_matrix_2[[i, j]] > game.payoff_matrix_2[[i, j2]]))
761    });
762
763    match (ds1, ds2) {
764        (Some(i), Some(j)) => Some((i, j)),
765        _ => None,
766    }
767}
768
769/// Simulate replicator dynamics for an evolutionary symmetric game.
770///
771/// The replicator equation is: `dx_i/dt = x_i * ((Ax)_i - x^T A x)`, where `A` is
772/// the payoff matrix and `x` is the population state (mixed strategy distribution).
773///
774/// # Arguments
775/// * `payoff_matrix` - Symmetric game payoff matrix (n × n)
776/// * `initial_population` - Initial population state (probability distribution)
777/// * `dt` - Time step for numerical integration
778/// * `n_steps` - Number of integration steps
779///
780/// # Returns
781/// Trajectory of population states, one per time step.
782pub fn replicator_dynamics(
783    payoff_matrix: ArrayView2<f64>,
784    initial_population: &[f64],
785    dt: f64,
786    n_steps: usize,
787) -> Vec<Vec<f64>> {
788    let n = payoff_matrix.nrows();
789    if n == 0 || initial_population.len() != n {
790        return Vec::new();
791    }
792
793    let mut x = initial_population.to_vec();
794    // Normalize
795    let s: f64 = x.iter().sum();
796    if s > 0.0 {
797        x.iter_mut().for_each(|v| *v /= s);
798    }
799
800    let mut trajectory = vec![x.clone()];
801
802    for _ in 0..n_steps {
803        // Compute Ax
804        let ax: Vec<f64> = (0..n)
805            .map(|i| (0..n).map(|j| payoff_matrix[[i, j]] * x[j]).sum::<f64>())
806            .collect();
807
808        // Compute x^T A x
809        let mean_fitness: f64 = x.iter().zip(ax.iter()).map(|(xi, axi)| xi * axi).sum();
810
811        // Compute dx/dt and update
812        let mut x_new: Vec<f64> = x
813            .iter()
814            .zip(ax.iter())
815            .map(|(&xi, &axi)| xi + dt * xi * (axi - mean_fitness))
816            .collect();
817
818        // Project back to simplex (clamp negatives and renormalize)
819        x_new.iter_mut().for_each(|v| {
820            if *v < 0.0 {
821                *v = 0.0;
822            }
823        });
824        let total: f64 = x_new.iter().sum();
825        if total > 1e-15 {
826            x_new.iter_mut().for_each(|v| *v /= total);
827        }
828
829        x = x_new.clone();
830        trajectory.push(x_new);
831    }
832
833    trajectory
834}
835
836/// Find evolutionarily stable strategies (ESS) in a symmetric game.
837///
838/// A strategy `p*` is an ESS if for all mutants `q ≠ p*`:
839/// 1. `u(p*, p*) > u(q, p*)` (strict Nash condition), or
840/// 2. `u(p*, p*) = u(q, p*)` and `u(p*, q) > u(q, q)` (stability condition)
841///
842/// This implementation checks pure strategy ESS candidates and uses replicator
843/// dynamics to identify attracting fixed points.
844///
845/// # Returns
846/// A list of ESS probability distributions.
847pub fn find_ess(payoff_matrix: ArrayView2<f64>) -> Vec<Vec<f64>> {
848    let n = payoff_matrix.nrows();
849    if n == 0 {
850        return Vec::new();
851    }
852
853    let mut ess_list = Vec::new();
854
855    // Check pure strategies
856    for i in 0..n {
857        let mut p = vec![0.0; n];
858        p[i] = 1.0;
859        if is_ess_pure(payoff_matrix, i, n) {
860            ess_list.push(p);
861        }
862    }
863
864    // Find interior fixed points via replicator dynamics from multiple starting points
865    // Use a grid of starting points for n <= 3, or random samples for larger n
866    let fixed_points = find_interior_ess_candidates(payoff_matrix, n);
867    for fp in fixed_points {
868        if !ess_list
869            .iter()
870            .any(|e: &Vec<f64>| e.iter().zip(fp.iter()).all(|(a, b)| (a - b).abs() < 1e-6))
871        {
872            ess_list.push(fp);
873        }
874    }
875
876    ess_list
877}
878
879/// Check whether pure strategy `i` is an ESS.
880fn is_ess_pure(payoff: ArrayView2<f64>, i: usize, n: usize) -> bool {
881    let u_ii = payoff[[i, i]];
882    for j in 0..n {
883        if j == i {
884            continue;
885        }
886        let u_ji = payoff[[j, i]];
887        if u_ji > u_ii + 1e-12 {
888            // Mutant j invades i
889            return false;
890        }
891        if (u_ji - u_ii).abs() < 1e-12 {
892            // Neutrally stable — check second condition
893            let u_ij = payoff[[i, j]];
894            let u_jj = payoff[[j, j]];
895            if u_jj >= u_ij + 1e-12 {
896                return false;
897            }
898        }
899    }
900    true
901}
902
903/// Find interior ESS candidates by running replicator dynamics from multiple
904/// initial conditions and identifying stable fixed points.
905fn find_interior_ess_candidates(payoff: ArrayView2<f64>, n: usize) -> Vec<Vec<f64>> {
906    if n > 4 {
907        return Vec::new(); // Too expensive for large games
908    }
909
910    let mut candidates: Vec<Vec<f64>> = Vec::new();
911    let tol = 1e-6;
912
913    // Generate grid of starting points
914    let grid_points = if n == 2 {
915        (1..10)
916            .map(|k| {
917                let p = k as f64 / 10.0;
918                vec![p, 1.0 - p]
919            })
920            .collect::<Vec<_>>()
921    } else if n == 3 {
922        let mut pts = Vec::new();
923        for a in 1..9 {
924            for b in 1..(10 - a) {
925                let c = 10 - a - b;
926                if c > 0 {
927                    pts.push(vec![a as f64 / 10.0, b as f64 / 10.0, c as f64 / 10.0]);
928                }
929            }
930        }
931        pts
932    } else {
933        // n == 4: use fewer points
934        vec![
935            vec![0.25, 0.25, 0.25, 0.25],
936            vec![0.4, 0.2, 0.2, 0.2],
937            vec![0.1, 0.5, 0.3, 0.1],
938        ]
939    };
940
941    for start in grid_points {
942        let traj = replicator_dynamics(payoff, &start, 0.01, 10000);
943        if let Some(final_state) = traj.last() {
944            // Check if it's a fixed point (interior)
945            let is_interior = final_state.iter().all(|&v| v > tol);
946            if is_interior {
947                // Verify it's actually a fixed point
948                let ax: Vec<f64> = (0..n)
949                    .map(|i| (0..n).map(|j| payoff[[i, j]] * final_state[j]).sum::<f64>())
950                    .collect();
951                let mean_f: f64 = final_state
952                    .iter()
953                    .zip(ax.iter())
954                    .map(|(xi, axi)| xi * axi)
955                    .sum();
956                let is_fp = final_state
957                    .iter()
958                    .zip(ax.iter())
959                    .all(|(&xi, &axi)| (xi * (axi - mean_f)).abs() < 1e-5);
960
961                if is_fp {
962                    // Check it's not already found
963                    let is_dup = candidates.iter().any(|c: &Vec<f64>| {
964                        c.iter()
965                            .zip(final_state.iter())
966                            .all(|(a, b)| (a - b).abs() < 1e-5)
967                    });
968                    if !is_dup {
969                        // Verify ESS stability condition
970                        if verify_ess_stability(payoff, final_state, n, tol) {
971                            candidates.push(final_state.clone());
972                        }
973                    }
974                }
975            }
976        }
977    }
978
979    candidates
980}
981
982/// Verify the ESS stability condition for a candidate mixed strategy `p*`.
983fn verify_ess_stability(payoff: ArrayView2<f64>, p_star: &[f64], n: usize, tol: f64) -> bool {
984    // u(p*, p*) computed
985    let u_pp: f64 = (0..n)
986        .map(|i| {
987            (0..n)
988                .map(|j| p_star[i] * payoff[[i, j]] * p_star[j])
989                .sum::<f64>()
990        })
991        .sum();
992
993    // Check for a sample of perturbations that the ESS condition holds
994    for i in 0..n {
995        if p_star[i] < tol {
996            continue;
997        }
998        for j in 0..n {
999            if j == i || p_star[j] < tol {
1000                continue;
1001            }
1002            // Construct small mutant q = (1-eps)*p* + eps*e_j
1003            let eps = 0.01;
1004            let q: Vec<f64> = p_star
1005                .iter()
1006                .enumerate()
1007                .map(|(k, &pk)| {
1008                    if k == j {
1009                        pk + eps * (1.0 - pk)
1010                    } else {
1011                        pk * (1.0 - eps)
1012                    }
1013                })
1014                .collect();
1015            // Normalize
1016            let s: f64 = q.iter().sum();
1017            let q: Vec<f64> = q.iter().map(|v| v / s).collect();
1018
1019            // u(q, q) and u(p*, q)
1020            let u_qq: f64 = (0..n)
1021                .map(|a| (0..n).map(|b| q[a] * payoff[[a, b]] * q[b]).sum::<f64>())
1022                .sum();
1023            let u_pq: f64 = (0..n)
1024                .map(|a| {
1025                    (0..n)
1026                        .map(|b| p_star[a] * payoff[[a, b]] * q[b])
1027                        .sum::<f64>()
1028                })
1029                .sum();
1030
1031            // ESS: u(p*, p*) >= u(q, p*) for all q, and if equal then u(p*, q) > u(q, q)
1032            let u_qp: f64 = (0..n)
1033                .map(|a| {
1034                    (0..n)
1035                        .map(|b| q[a] * payoff[[a, b]] * p_star[b])
1036                        .sum::<f64>()
1037                })
1038                .sum();
1039
1040            if u_qp > u_pp + tol {
1041                return false;
1042            }
1043            if (u_qp - u_pp).abs() < tol && u_qq >= u_pq - tol {
1044                return false;
1045            }
1046        }
1047    }
1048    true
1049}
1050
1051#[cfg(test)]
1052mod tests {
1053    use super::*;
1054    use approx::assert_relative_eq;
1055    use scirs2_core::ndarray::array;
1056
1057    #[test]
1058    fn test_normal_form_game_construction() {
1059        let p1 = array![[3.0, 0.0], [5.0, 1.0]];
1060        let p2 = array![[3.0, 5.0], [0.0, 1.0]];
1061        let game = NormalFormGame::new(p1, p2).expect("valid game");
1062        assert_eq!(game.n_strategies_1, 2);
1063        assert_eq!(game.n_strategies_2, 2);
1064    }
1065
1066    #[test]
1067    fn test_normal_form_shape_mismatch() {
1068        let p1 = array![[1.0, 2.0]];
1069        let p2 = array![[1.0], [2.0]];
1070        assert!(NormalFormGame::new(p1, p2).is_err());
1071    }
1072
1073    #[test]
1074    fn test_zero_sum_constructor() {
1075        let payoff = array![[1.0, -1.0], [-1.0, 1.0]];
1076        let game = NormalFormGame::zero_sum(payoff);
1077        // Player 2 gets negative of player 1's payoffs
1078        assert_eq!(game.payoff_matrix_2[[0, 0]], -1.0);
1079        assert_eq!(game.payoff_matrix_2[[0, 1]], 1.0);
1080    }
1081
1082    #[test]
1083    fn test_symmetric_game() {
1084        let payoff = array![[0.0, -1.0, 1.0], [1.0, 0.0, -1.0], [-1.0, 1.0, 0.0]];
1085        let game = NormalFormGame::symmetric(payoff).expect("valid symmetric game");
1086        // payoff_2 should be transpose of payoff_1
1087        assert_eq!(game.payoff_matrix_2[[1, 0]], game.payoff_matrix_1[[0, 1]]);
1088    }
1089
1090    #[test]
1091    fn test_payoff_lookup() {
1092        let p1 = array![[1.0, 2.0], [3.0, 4.0]];
1093        let p2 = array![[5.0, 6.0], [7.0, 8.0]];
1094        let game = NormalFormGame::new(p1, p2).expect("valid");
1095        let (a, b) = game.payoff(1, 0);
1096        assert_relative_eq!(a, 3.0);
1097        assert_relative_eq!(b, 7.0);
1098    }
1099
1100    #[test]
1101    fn test_expected_payoff() {
1102        // Matching pennies
1103        let p1 = array![[1.0, -1.0], [-1.0, 1.0]];
1104        let p2 = array![[-1.0, 1.0], [1.0, -1.0]];
1105        let game = NormalFormGame::new(p1, p2).expect("valid");
1106        let (ep1, ep2) = game
1107            .expected_payoff(&[0.5, 0.5], &[0.5, 0.5])
1108            .expect("valid mixed strategies");
1109        assert_relative_eq!(ep1, 0.0, epsilon = 1e-10);
1110        assert_relative_eq!(ep2, 0.0, epsilon = 1e-10);
1111    }
1112
1113    #[test]
1114    fn test_expected_payoff_invalid_length() {
1115        let p1 = array![[1.0, -1.0], [-1.0, 1.0]];
1116        let p2 = array![[-1.0, 1.0], [1.0, -1.0]];
1117        let game = NormalFormGame::new(p1, p2).expect("valid");
1118        assert!(game.expected_payoff(&[1.0], &[0.5, 0.5]).is_err());
1119    }
1120
1121    #[test]
1122    fn test_prisoners_dilemma_pure_nash() {
1123        // Prisoner's Dilemma: Cooperate=0, Defect=1
1124        // Each player prefers to defect regardless of the other
1125        let p1 = array![[-1.0, -3.0], [0.0, -2.0]];
1126        let p2 = array![[-1.0, 0.0], [-3.0, -2.0]];
1127        let game = NormalFormGame::new(p1, p2).expect("valid");
1128        let pure_ne = find_pure_nash_equilibria(&game);
1129        assert_eq!(pure_ne.len(), 1);
1130        assert_eq!(pure_ne[0].strategy_1, vec![0.0, 1.0]); // Defect
1131        assert_eq!(pure_ne[0].strategy_2, vec![0.0, 1.0]); // Defect
1132        assert!(pure_ne[0].is_pure);
1133    }
1134
1135    #[test]
1136    fn test_coordination_game_multiple_pure_nash() {
1137        // Stag Hunt / Battle of the Sexes style
1138        let p1 = array![[2.0, 0.0], [0.0, 1.0]];
1139        let p2 = array![[2.0, 0.0], [0.0, 1.0]];
1140        let game = NormalFormGame::new(p1, p2).expect("valid");
1141        let pure_ne = find_pure_nash_equilibria(&game);
1142        assert_eq!(pure_ne.len(), 2);
1143    }
1144
1145    #[test]
1146    fn test_matching_pennies_mixed_nash() {
1147        // Matching pennies has no pure NE, only mixed (0.5, 0.5)
1148        let p1 = array![[1.0, -1.0], [-1.0, 1.0]];
1149        let p2 = array![[-1.0, 1.0], [1.0, -1.0]];
1150        let game = NormalFormGame::new(p1, p2).expect("valid");
1151        let pure_ne = find_pure_nash_equilibria(&game);
1152        assert_eq!(pure_ne.len(), 0);
1153
1154        let all_ne = find_all_nash_equilibria(&game, 1e-9).expect("success");
1155        // Should find exactly one mixed NE at (0.5, 0.5)
1156        assert_eq!(all_ne.len(), 1);
1157        assert_relative_eq!(all_ne[0].strategy_1[0], 0.5, epsilon = 1e-6);
1158        assert_relative_eq!(all_ne[0].strategy_2[0], 0.5, epsilon = 1e-6);
1159    }
1160
1161    #[test]
1162    fn test_best_response_1() {
1163        // In Prisoner's Dilemma, if opponent defects (j=1), player 1 should defect
1164        let p1 = array![[-1.0, -3.0], [0.0, -2.0]];
1165        let p2 = array![[-1.0, 0.0], [-3.0, -2.0]];
1166        let game = NormalFormGame::new(p1, p2).expect("valid");
1167        let br = best_response_1(&game, &[0.0, 1.0]);
1168        assert!(br.contains(&1)); // Defect
1169    }
1170
1171    #[test]
1172    fn test_best_response_2() {
1173        let p1 = array![[-1.0, -3.0], [0.0, -2.0]];
1174        let p2 = array![[-1.0, 0.0], [-3.0, -2.0]];
1175        let game = NormalFormGame::new(p1, p2).expect("valid");
1176        let br = best_response_2(&game, &[0.0, 1.0]);
1177        assert!(br.contains(&1)); // Defect
1178    }
1179
1180    #[test]
1181    fn test_dominant_strategy_equilibrium() {
1182        // Prisoner's Dilemma: (Defect, Defect) is dominant
1183        let p1 = array![[-1.0, -3.0], [0.0, -2.0]];
1184        let p2 = array![[-1.0, 0.0], [-3.0, -2.0]];
1185        let game = NormalFormGame::new(p1, p2).expect("valid");
1186        let ds = dominant_strategy_equilibrium(&game);
1187        assert_eq!(ds, Some((1, 1)));
1188    }
1189
1190    #[test]
1191    fn test_dominant_strategy_equilibrium_none() {
1192        // Matching pennies has no dominant strategy equilibrium
1193        let p1 = array![[1.0, -1.0], [-1.0, 1.0]];
1194        let p2 = array![[-1.0, 1.0], [1.0, -1.0]];
1195        let game = NormalFormGame::new(p1, p2).expect("valid");
1196        let ds = dominant_strategy_equilibrium(&game);
1197        assert!(ds.is_none());
1198    }
1199
1200    #[test]
1201    fn test_iterated_elimination() {
1202        // In Prisoner's Dilemma, after IESDS only (Defect, Defect) remains
1203        let p1 = array![[-1.0, -3.0], [0.0, -2.0]];
1204        let p2 = array![[-1.0, 0.0], [-3.0, -2.0]];
1205        let mut game = NormalFormGame::new(p1, p2).expect("valid");
1206        let (s1, s2) = iterated_elimination(&mut game);
1207        assert_eq!(s1, vec![1]);
1208        assert_eq!(s2, vec![1]);
1209    }
1210
1211    #[test]
1212    fn test_replicator_dynamics_convergence() {
1213        // Hawk-Dove game: stable interior mixed strategy
1214        // A = [[0, 3], [1, 2]] — Hawk vs Dove
1215        let payoff = array![[0.0, 3.0], [1.0, 2.0]];
1216        let initial = vec![0.5, 0.5];
1217        let traj = replicator_dynamics(payoff.view(), &initial, 0.01, 1000);
1218        assert!(!traj.is_empty());
1219        // Should converge to some fixed point
1220        let final_state = traj.last().expect("non-empty trajectory");
1221        let total: f64 = final_state.iter().sum();
1222        assert_relative_eq!(total, 1.0, epsilon = 1e-6);
1223    }
1224
1225    #[test]
1226    fn test_find_ess_pure() {
1227        // Rock-Paper-Scissors has no pure ESS
1228        let payoff = array![[0.0, -1.0, 1.0], [1.0, 0.0, -1.0], [-1.0, 1.0, 0.0]];
1229        let ess = find_ess(payoff.view());
1230        // No pure ESS in RPS
1231        let pure_ess: Vec<_> = ess
1232            .iter()
1233            .filter(|e| e.iter().filter(|&&v| v > 0.01).count() == 1)
1234            .collect();
1235        assert_eq!(pure_ess.len(), 0);
1236    }
1237
1238    #[test]
1239    fn test_symmetric_game_invalid_shape() {
1240        let payoff = array![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
1241        assert!(NormalFormGame::symmetric(payoff).is_err());
1242    }
1243}