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}