Skip to main content

thrust_rl/env/games/
n_player_matching_pennies.rs

1//! N-player matching pennies ("majority game") — smoke env.
2//!
3//! N ≥ 2 "majority game" generalization: at each step every agent picks 0 or 1;
4//! agent `i`'s reward is:
5//!
6//! - `+1` if agent `i`'s action matches the strict majority of the *other*
7//!   agents' actions,
8//! - `-1` if it matches against the strict majority,
9//! - `0` if the "others" split evenly (only possible when `N − 1` is even, i.e.
10//!   N is odd).
11//!
12//! # Relationship to 2-player matching pennies
13//!
14//! This is the *symmetric* majority game: every agent wants to match
15//! the majority of the others. For N = 2 it collapses to a pure
16//! *coordination* game (both agents want to agree → both get +1 on
17//! match, both −1 on mismatch) — the symmetric counterpart of the
18//! asymmetric matcher-vs-mismatcher 2-player
19//! [`crate::env::games::matching_pennies::MatchingPennies`] env. The
20//! action-marginal Nash equilibrium is `(0.5, 0.5)` in both cases (the
21//! symmetric mixed Nash on the coordination game; the unique mixed
22//! Nash on the asymmetric zero-sum game), so both envs serve as
23//! mixed-equilibrium smoke tests for PSRO/NFSP.
24//!
25//! For N = 4 the unique symmetric mixed equilibrium is `(0.5, 0.5)`
26//! per agent. The 0-on-tie rule preserves the `(0.5, 0.5)` symmetric
27//! equilibrium for all odd N ≥ 3 as well.
28//!
29//! # Per-agent observation
30//!
31//! Observation is agent-index dependent: `obs_i = [i as f32 / (num_agents − 1)
32//! as f32]` (length 1). This is the minimum-LOC encoding that exercises the
33//! per-agent-observation plumbing from PR #122 — agent 0 sees `[0.0]`,
34//! the last agent sees `[1.0]`, and intermediate agents see linearly
35//! interpolated values. The observation is *stateless* across the
36//! episode; it carries only the agent's identity.
37//!
38//! # Action / reward
39//!
40//! - Action: `0` or `1`, single discrete dim per agent.
41//! - Reward: per-agent majority-of-others rule above.
42//!
43//! # Episode length
44//!
45//! Each `reset_joint` starts a fresh episode of length
46//! [`NPlayerMatchingPennies::EPISODE_LEN`] steps; `step_joint` sets
47//! `done = true` on the final step. Matches the 2-agent
48//! [`crate::env::games::matching_pennies::MatchingPennies`] convention.
49
50use crate::multi_agent::joint::{JointEnv, JointStepResult};
51
52/// N-player matching-pennies "majority game" env (N ≥ 2).
53#[derive(Debug, Clone)]
54pub struct NPlayerMatchingPennies {
55    num_agents: usize,
56    /// Number of steps elapsed in the current episode.
57    step: usize,
58}
59
60impl NPlayerMatchingPennies {
61    /// Episode length used by `step_joint` to fire the `done` flag.
62    pub const EPISODE_LEN: usize = 16;
63
64    /// Observation dimensionality (always 1 — a normalized agent index).
65    pub const OBS_DIM: usize = 1;
66
67    /// Per-agent action cardinality (always 2 — heads/tails).
68    pub const ACTION_DIM: usize = 2;
69
70    /// Construct a fresh N-player env. Requires `num_agents >= 2`.
71    pub fn new(num_agents: usize) -> Self {
72        debug_assert!(num_agents >= 2, "NPlayerMatchingPennies requires num_agents >= 2");
73        Self { num_agents, step: 0 }
74    }
75
76    /// Per-agent observation: `[i / (num_agents − 1)]` (length 1).
77    fn per_agent_obs(&self) -> Vec<Vec<f32>> {
78        let denom = (self.num_agents.saturating_sub(1)).max(1) as f32;
79        (0..self.num_agents).map(|i| vec![(i as f32) / denom]).collect()
80    }
81
82    /// Number of agents this env was constructed for.
83    pub fn num_agents(&self) -> usize {
84        self.num_agents
85    }
86}
87
88impl JointEnv for NPlayerMatchingPennies {
89    fn reset_joint(&mut self, _seed: Option<u64>) -> Vec<Vec<f32>> {
90        self.step = 0;
91        self.per_agent_obs()
92    }
93
94    fn step_joint(&mut self, actions: &[Vec<i64>]) -> JointStepResult {
95        debug_assert_eq!(
96            actions.len(),
97            self.num_agents,
98            "n-player matching pennies requires {} agents, got {}",
99            self.num_agents,
100            actions.len()
101        );
102        for (i, a) in actions.iter().enumerate() {
103            debug_assert_eq!(a.len(), 1, "agent {i} must supply a 1-d action");
104            debug_assert!(a[0] == 0 || a[0] == 1, "agent {i} action must be 0 or 1, got {}", a[0]);
105        }
106
107        // For each agent `i`: compute the count of action-1 picks among
108        // the *other* agents (size N − 1). The majority is action 1 if
109        // count > (N − 1) / 2, action 0 if count < (N − 1) / 2, tie
110        // otherwise. The reward is:
111        //   matches strict majority → +1
112        //   against strict majority → −1
113        //   tied                    →  0
114        let n = self.num_agents;
115        let mut rewards = vec![0.0_f32; n];
116        // Pre-compute total ones to avoid recomputing for every agent.
117        let total_ones: i64 = actions.iter().map(|a| a[0]).sum();
118        for i in 0..n {
119            let others_ones = total_ones - actions[i][0];
120            let n_others = (n - 1) as i64;
121            let n_zeros = n_others - others_ones;
122            let majority: Option<i64> = match others_ones.cmp(&n_zeros) {
123                std::cmp::Ordering::Greater => Some(1),
124                std::cmp::Ordering::Less => Some(0),
125                std::cmp::Ordering::Equal => None,
126            };
127            rewards[i] = match majority {
128                Some(m) if m == actions[i][0] => 1.0,
129                Some(_) => -1.0,
130                None => 0.0,
131            };
132        }
133
134        self.step += 1;
135        let done = self.step >= Self::EPISODE_LEN;
136
137        JointStepResult { rewards, done, observations: self.per_agent_obs() }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    /// For N = 2 the "majority of others" is the single other agent's
146    /// action. Both agents get `+1` on agreement and `−1` on
147    /// disagreement: a symmetric coordination game (NOT the asymmetric
148    /// 2-player matcher-vs-mismatcher game in
149    /// [`crate::env::games::matching_pennies::MatchingPennies`]).
150    /// The mixed Nash on this coordination game is still `(0.5, 0.5)`,
151    /// which is the property PSRO/NFSP relies on as the smoke target.
152    #[test]
153    fn test_n2_coordination_reward_table() {
154        for a0 in 0..2_i64 {
155            for a1 in 0..2_i64 {
156                let mut env = NPlayerMatchingPennies::new(2);
157                env.reset_joint(None);
158                let res = env.step_joint(&[vec![a0], vec![a1]]);
159                let expected = if a0 == a1 { 1.0_f32 } else { -1.0_f32 };
160                assert_eq!(
161                    res.rewards,
162                    vec![expected, expected],
163                    "N=2 coordination reward wrong for (a0={a0}, a1={a1}): {:?}",
164                    res.rewards
165                );
166            }
167        }
168    }
169
170    /// Enumerate all 2^4 = 16 joint actions for N = 4 and verify
171    /// agent 0's reward against a hand-computed table.
172    #[test]
173    fn test_majority_reward_n4() {
174        // For N=4, agent 0's "others" are agents 1, 2, 3 (3 agents).
175        // Strict majority of 3 → 2 or 3 ones means majority = 1; 0 or 1
176        // ones means majority = 0. No ties (3 is odd).
177        for mask in 0..16_u32 {
178            let bits: Vec<i64> = (0..4).map(|i| ((mask >> i) & 1) as i64).collect();
179            let mut env = NPlayerMatchingPennies::new(4);
180            env.reset_joint(None);
181            let actions: Vec<Vec<i64>> = bits.iter().map(|b| vec![*b]).collect();
182            let res = env.step_joint(&actions);
183            // Hand-compute agent 0's expected reward.
184            let others_ones: i64 = bits[1] + bits[2] + bits[3];
185            let majority_other: i64 = if others_ones >= 2 { 1 } else { 0 };
186            let expected_a0: f32 = if bits[0] == majority_other { 1.0 } else { -1.0 };
187            assert_eq!(
188                res.rewards[0], expected_a0,
189                "N=4 agent-0 reward wrong for bits={bits:?}: got {} expected {}",
190                res.rewards[0], expected_a0
191            );
192        }
193    }
194
195    /// Tie-break for odd N: `N = 3` with agent 0's "others" being
196    /// agents 1 and 2 — if they pick `[0, 1]` the others split evenly,
197    /// so agent 0's reward must be 0 regardless of agent 0's action.
198    #[test]
199    fn test_tie_break_returns_zero_on_odd_n_3() {
200        // others = (a1, a2) = (0, 1) ⇒ tie ⇒ agent 0 reward = 0.
201        for a0 in 0..2_i64 {
202            let mut env = NPlayerMatchingPennies::new(3);
203            env.reset_joint(None);
204            let res = env.step_joint(&[vec![a0], vec![0], vec![1]]);
205            assert_eq!(
206                res.rewards[0], 0.0,
207                "N=3 tie should give agent 0 reward 0 (a0={a0}), got {}",
208                res.rewards[0]
209            );
210        }
211    }
212
213    /// Tie-break for odd N = 5: agent 0's "others" are agents
214    /// {1,2,3,4}; if they split 2/2 → tie → agent 0 reward = 0.
215    #[test]
216    fn test_tie_break_returns_zero_on_odd_n_5() {
217        for a0 in 0..2_i64 {
218            let mut env = NPlayerMatchingPennies::new(5);
219            env.reset_joint(None);
220            // others: two 0s and two 1s ⇒ tied.
221            let res = env.step_joint(&[vec![a0], vec![0], vec![0], vec![1], vec![1]]);
222            assert_eq!(
223                res.rewards[0], 0.0,
224                "N=5 tie should give agent 0 reward 0 (a0={a0}), got {}",
225                res.rewards[0]
226            );
227        }
228    }
229
230    /// Per-agent observations must be distinct across agents when
231    /// `num_agents >= 2`.
232    #[test]
233    fn test_per_agent_obs_differ_by_index() {
234        for n in [2_usize, 3, 4, 8] {
235            let mut env = NPlayerMatchingPennies::new(n);
236            let obs = env.reset_joint(None);
237            assert_eq!(obs.len(), n);
238            // All distinct.
239            for i in 0..n {
240                for j in (i + 1)..n {
241                    assert_ne!(
242                        obs[i], obs[j],
243                        "obs for N={n} agents {i} and {j} must differ: {:?} == {:?}",
244                        obs[i], obs[j]
245                    );
246                }
247                // Length 1 each.
248                assert_eq!(obs[i].len(), NPlayerMatchingPennies::OBS_DIM);
249            }
250        }
251    }
252
253    /// Episode `done` flag fires after exactly `EPISODE_LEN` steps,
254    /// matching the 2-agent MatchingPennies convention.
255    #[test]
256    fn test_done_flag_at_episode_len() {
257        let mut env = NPlayerMatchingPennies::new(4);
258        env.reset_joint(None);
259        let actions: Vec<Vec<i64>> = (0..4).map(|_| vec![0]).collect();
260        for step in 0..NPlayerMatchingPennies::EPISODE_LEN {
261            let res = env.step_joint(&actions);
262            let expected_done = step + 1 >= NPlayerMatchingPennies::EPISODE_LEN;
263            assert_eq!(res.done, expected_done, "done flag wrong at step {step}");
264        }
265    }
266
267    /// At N = 4 with all-ones (a global consensus on action 1), every
268    /// agent's "others" are unanimous on 1 → agent matching gets +1
269    /// for everyone.
270    #[test]
271    fn test_n4_all_ones_unanimous_reward() {
272        let mut env = NPlayerMatchingPennies::new(4);
273        env.reset_joint(None);
274        let actions: Vec<Vec<i64>> = (0..4).map(|_| vec![1]).collect();
275        let res = env.step_joint(&actions);
276        for (i, &r) in res.rewards.iter().enumerate() {
277            assert_eq!(r, 1.0, "agent {i} should get +1 in unanimous-1 case, got {r}");
278        }
279    }
280
281    /// Reset restores the step counter.
282    #[test]
283    fn test_reset_restores_step_counter() {
284        let mut env = NPlayerMatchingPennies::new(3);
285        env.reset_joint(None);
286        let actions: Vec<Vec<i64>> = (0..3).map(|_| vec![0]).collect();
287        for _ in 0..NPlayerMatchingPennies::EPISODE_LEN {
288            env.step_joint(&actions);
289        }
290        env.reset_joint(None);
291        for step in 0..NPlayerMatchingPennies::EPISODE_LEN - 1 {
292            let res = env.step_joint(&actions);
293            assert!(!res.done, "should not be done at step {step} after reset");
294        }
295        let last = env.step_joint(&actions);
296        assert!(last.done, "should be done on final step after reset");
297    }
298
299    /// Observation shape is `(num_agents, OBS_DIM=1)` after both reset
300    /// and step.
301    #[test]
302    fn test_observation_shape() {
303        let n = 5;
304        let mut env = NPlayerMatchingPennies::new(n);
305        let obs = env.reset_joint(None);
306        assert_eq!(obs.len(), n);
307        for o in &obs {
308            assert_eq!(o.len(), NPlayerMatchingPennies::OBS_DIM);
309        }
310        let actions: Vec<Vec<i64>> = (0..n).map(|_| vec![0]).collect();
311        let res = env.step_joint(&actions);
312        assert_eq!(res.observations.len(), n);
313        for o in &res.observations {
314            assert_eq!(o.len(), NPlayerMatchingPennies::OBS_DIM);
315        }
316    }
317}