From 892cdf44c762e96c63f1d9167a47166318a07116 Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:27:39 +0200 Subject: [PATCH 01/31] Add bibliography reference --- docs/reference/bibliography.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/reference/bibliography.rst b/docs/reference/bibliography.rst index c6f1a3a9b..41c4cb690 100644 --- a/docs/reference/bibliography.rst +++ b/docs/reference/bibliography.rst @@ -33,6 +33,7 @@ documentation. .. [Hauert2002] Hauert, Christoph, and Olaf Stenull. "Simple adaptive strategy wins the prisoner's dilemma." Journal of Theoretical Biology 218.3 (2002): 261-272. .. [Hilbe2013] Hilbe, C., Nowak, M.A. and Traulsen, A. (2013). Adaptive dynamics of extortion and compliance, PLoS ONE, 8(11), p. e77886. doi: 10.1371/journal.pone.0077886. .. [Hilbe2017] Hilbe, C., Martinez-Vaquero, L. A., Chatterjee K., Nowak M. A. (2017). Memory-n strategies of direct reciprocity, Proceedings of the National Academy of Sciences May 2017, 114 (18) 4715-4720; doi: 10.1073/pnas.1621239114. +.. [Hutter2023] Hutter, A. (2023). "Balancing Cooperativeness and Adaptiveness in the (Noisy) Iterated Prisoner's Dilemma." Available at: https://arxiv.org/abs/2303.03519 .. [Kuhn2017] Kuhn, Steven, "Prisoner's Dilemma", The Stanford Encyclopedia of Philosophy (Spring 2017 Edition), Edward N. Zalta (ed.), https://plato.stanford.edu/archives/spr2017/entries/prisoner-dilemma/ .. [Kraines1989] Kraines, David, and Vivian Kraines. "Pavlov and the prisoner's dilemma." Theory and decision 26.1 (1989): 47-79. doi:10.1007/BF00134056 .. [Krapohl2020] Krapohl, S., Ocelík, V. & Walentek, D.M. The instability of globalization: applying evolutionary game theory to global trade cooperation. Public Choice 188, 31–51 (2021). https://doi.org/10.1007/s11127-020-00799-1 From 96deb3d01be8206e20896fdbd0a317b75c4bfa6d Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:41:05 +0200 Subject: [PATCH 02/31] LongtermTfT --- axelrod/strategies/cooperate_iso.py | 64 +++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 axelrod/strategies/cooperate_iso.py diff --git a/axelrod/strategies/cooperate_iso.py b/axelrod/strategies/cooperate_iso.py new file mode 100644 index 000000000..bdb0eae86 --- /dev/null +++ b/axelrod/strategies/cooperate_iso.py @@ -0,0 +1,64 @@ +from axelrod.action import Action +from axelrod.player import Player + +C, D = Action.C, Action.D + +class LongtermTfT(Player): + """Noise-tolerant Tit-for-Tat. + + Cooperates by default and mirrors the opponent, but distinguishes + noise-corrupted cooperation from genuine defection using a statistical + test: it compares the opponent's observed defection count against the + binomial null expected from the noise rate (via a z-statistic) and + forgives defections that are consistent with noise. The number of + forgiven defections grows like O(sqrt(N_C)), so the forgiven *rate* + tends to zero — tolerating noise while staying unexploitable in the + long run. Retaliates only when the defection rate is significantly + above what noise alone would explain. + + Names: + - Longterm TFT: [Hutter2023]_ + """ + name = "LongtermTfT" + classifier = { + "memory_depth": float("inf"), + "stochastic": True, + "long_run_time": False, + "inspects_source": False, + "manipulates_source": False, + "manipulates_state": False, + } + + def __init__(self): + super().__init__() + self.n_tft_would_c = 0 + self.n_d_when_tft_would_c = 0 + self.z = 0. + # Estimate of the opponent's rate of playing D after C, taking noise + # into account. + self.opp_pr_d_after_c = 0. + + def receive_match_attributes(self): + self.noise = self.match_attributes["noise"] + + def strategy(self, opponent: Player) -> Action: + if not self.history: + return C + if len(self.history) == 1: + return opponent.history[-1] + if self.history[-2] == C: + self.n_tft_would_c += 1 + if opponent.history[-1] == D: + self.n_d_when_tft_would_c += 1 + n_expected_ds = self.n_tft_would_c * self.noise + std_expected_ds = np.sqrt(self.noise * (1-self.noise) * self.n_tft_would_c) + # This becomes n_d_when_tft_would_c for noise->0 + self.z = (self.n_d_when_tft_would_c - n_expected_ds) / max(1., std_expected_ds) + if self.n_tft_would_c < 5 and self.n_d_when_tft_would_c < 3: + # TfT + return opponent.history[-1] + elif self.z < 2: + return C + else: + # TfT + return opponent.history[-1] From 3b9f0a151b6ca1c1511776e6a9ced777ffe117cd Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:51:48 +0200 Subject: [PATCH 03/31] ISO --- axelrod/strategies/cooperate_iso.py | 190 ++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) diff --git a/axelrod/strategies/cooperate_iso.py b/axelrod/strategies/cooperate_iso.py index bdb0eae86..6c3dc1143 100644 --- a/axelrod/strategies/cooperate_iso.py +++ b/axelrod/strategies/cooperate_iso.py @@ -1,6 +1,13 @@ +import numpy as np + +import torch +from torch import optim + from axelrod.action import Action from axelrod.player import Player + + C, D = Action.C, Action.D class LongtermTfT(Player): @@ -62,3 +69,186 @@ def strategy(self, opponent: Player) -> Action: else: # TfT return opponent.history[-1] + +def are_same_binomial(p1: float, n1: int, p2: float, n2: int, min_abs_z: float = 2.0) -> bool: + p = (n1 * p1 + n2 * p2) / (n1 + n2) + z = (p1 - p2) / np.sqrt(p * (1 - p) * (1 / n1 + 1 / n2)) + return abs(z) < min_abs_z + +def has_greater_mean(ary1: np.ndarray, ary2: np.ndarray, min_z: float = 2.0) -> bool: + """Tests if ary1 has a greater mean than ary2""" + se1 = np.std(ary1) / np.sqrt(len(ary1)) + se2 = np.std(ary2) / np.sqrt(len(ary2)) + if se1 == 0 and se2 == 0: + return ary1.mean() > ary2.mean() + return (ary1.mean() - ary2.mean()) / np.sqrt(se1**2 + se2**2) > min_z + +def find_recent_average(ary, discount_factor: float = 0.99) -> float: + assert len(ary) > 0 + assert 0 < discount_factor <= 1 + ary = np.array(ary) + N = len(ary) + weights = np.array([discount_factor**((N-1)-i) for i in range(N)]) + return (weights * ary).sum() / weights.sum() + +def optimize_constrained(loss_fn, + starting_points=[[0.5, 0.5, 0.5, 0.5]], + lr=0.1, + n_steps=100): + # Constrains all params to [0, 1]. + min_loss_so_far = None + best_params_so_far = None + for point in starting_points: + params = torch.Tensor(point) + params.requires_grad_() + opt = optim.Adam([params], lr=lr) + for i in range(n_steps): + loss = loss_fn(params) + loss_value = loss.detach().numpy().sum() + if min_loss_so_far is None or loss_value < min_loss_so_far: + min_loss_so_far = loss_value + best_params_so_far = params.detach().numpy() + opt.zero_grad() + loss.backward() + opt.step() + with torch.no_grad(): + for param in params: + param.clamp_(0, 1) + return min_loss_so_far, best_params_so_far + +# opp_strategy already includes the effect of noise. +def get_reward(my_strategy: torch.Tensor, + opp_strategy: torch.Tensor, + init_state: torch.Tensor, + p_end: float, + p_noise: float = 0., + RSTP=(3, 0, 5, 1)): + # Apply p_noise only to own strategy, not to opponent. + own = my_strategy + p_noise * (1 - 2 * my_strategy) + # Flip CD/DC for opponent + opp = torch.Tensor( + [opp_strategy[0], opp_strategy[2], opp_strategy[1], opp_strategy[3]]) + T = torch.stack([own * opp, + own * (1 - opp), + (1 - own) * opp, + (1 - own) * (1 - opp)]) + T = torch.transpose(T, 0, 1) + TT = torch.inverse(torch.eye(4) - (1 - p_end) * T) + rewards = torch.tensor(RSTP, dtype=torch.float) + # Don't include init state in summed rewards. + reward = torch.dot(init_state, torch.matmul(TT, rewards) - rewards) + # Avg. reward per step + return p_end * reward / (1 - p_end) + +# The opponent model is assumed to already include the effect of noise. +# init_state_idx in [0, 1, 2, 3] +def optimize_against(opponent: np.ndarray, init_state_idx: int, + p_end: float = 1e-2, p_noise: float = 0) -> np.ndarray: + opp = torch.Tensor(opponent) + assert p_noise < 0.5 + opp.clamp_(min=p_noise, max=1-p_noise) + init_state = [0] * 4 + init_state[init_state_idx] = 1 + init_state = torch.Tensor(init_state) + def loss_fn(strategy: torch.Tensor): + return -1 * get_reward(strategy, opp, init_state, p_end=p_end, p_noise=p_noise) + # Only 50 steps to save time + loss, strat = optimize_constrained(loss_fn, n_steps=50) + return -1 * loss, strat + +class ISO(Player): + """Optimal response against a memory-1 opponent model. + + Estimates the opponent's memory-1 (order-1) conditional cooperation + probabilities, which together with its own memory-1 strategy induce a + Markov chain over outcome pairs. Computes the exact expected discounted + long-term payoff in closed form via the chain's stationary/resolvent + solution, then optimizes its own memory-1 policy to maximize it. A + simplification and refinement of DBS: it replaces bounded-depth tree + search with the exact infinite-horizon value, yielding stronger play + against exploitable opponents at lower complexity. Adaptive only w.r.t. + memory-1 opponents (the model is misspecified for higher-memory play). + + Names: + - ISO: [Hutter2023]_ + """ + name = "ISO" + classifier = { + "memory_depth": float("inf"), + "stochastic": True, + "long_run_time": True, + "inspects_source": False, + "manipulates_source": False, + "manipulates_state": False, + } + + def __init__(self): + super().__init__() + # Opponent's action in certain situations. 1 is C, 0 is D. + # Start by assuming the opponent played in accordance with TfT once. + self.opp_after_CC = [1] + self.opp_after_CD = [1] + self.opp_after_DC = [0] + self.opp_after_DD = [0] + # Recent averages of cooperation rates + self.opp_pr_c_after_CC = np.mean(self.opp_after_CC) + self.opp_pr_c_after_CD = np.mean(self.opp_after_CD) + self.opp_pr_c_after_DC = np.mean(self.opp_after_DC) + self.opp_pr_c_after_DD = np.mean(self.opp_after_DD) + self.opp_model = [1., 0., 1., 0.] + self.my_policy = [1., 0., 1., 0.] + + def receive_match_attributes(self): + self.noise = self.match_attributes["noise"] + + def _update_opponent_model(self, opponent): + if len(self.history) < 2: + return + prev = (self.history[-2], opponent.history[-2]) + opp_act = 1 if opponent.history[-1] == C else 0 + if prev == (C, C): + self.opp_after_CC.append(opp_act) + self.opp_pr_c_after_CC = find_recent_average(self.opp_after_CC) + elif prev == (C, D): + self.opp_after_CD.append(opp_act) + self.opp_pr_c_after_CD = find_recent_average(self.opp_after_CD) + elif prev == (D, C): + self.opp_after_DC.append(opp_act) + self.opp_pr_c_after_DC = find_recent_average(self.opp_after_DC) + elif prev == (D, D): + self.opp_after_DD.append(opp_act) + self.opp_pr_c_after_DD = find_recent_average(self.opp_after_DD) + self.opp_model = [self.opp_pr_c_after_CC, self.opp_pr_c_after_DC, self.opp_pr_c_after_CD, self.opp_pr_c_after_DD] + + def _get_state_idx(self, opponent) -> int: + if not self.history: + # Pretend we started with CC + return 0 + state = (self.history[-1], opponent.history[-1]) + if state == (C, C): + return 0 + elif state == (C, D): + return 1 + elif state == (D, C): + return 2 + elif state == (D, D): + return 3 + return -1 + + def update(self, opponent: Player) -> float: + self._update_opponent_model(opponent) + state_idx = self._get_state_idx(opponent) + expected, my_policy = optimize_against(self.opp_model, + init_state_idx=state_idx, + p_noise=self.noise) + self.my_policy = my_policy + return expected + + def act(self, opponent) -> Action: + state_idx = self._get_state_idx(opponent) + pr_c = self.my_policy[state_idx] + return C if np.random.uniform() < pr_c else D + + def strategy(self, opponent: Player) -> Action: + _ = self.update(opponent) + return self.act(opponent) From 6a83d92bdd4acf5812a9d2f5418af1e0c32d96db Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:48:22 +0200 Subject: [PATCH 04/31] Cosmetics --- axelrod/strategies/cooperate_iso.py | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/axelrod/strategies/cooperate_iso.py b/axelrod/strategies/cooperate_iso.py index 6c3dc1143..5b0d3e7d6 100644 --- a/axelrod/strategies/cooperate_iso.py +++ b/axelrod/strategies/cooperate_iso.py @@ -157,21 +157,6 @@ def loss_fn(strategy: torch.Tensor): return -1 * loss, strat class ISO(Player): - """Optimal response against a memory-1 opponent model. - - Estimates the opponent's memory-1 (order-1) conditional cooperation - probabilities, which together with its own memory-1 strategy induce a - Markov chain over outcome pairs. Computes the exact expected discounted - long-term payoff in closed form via the chain's stationary/resolvent - solution, then optimizes its own memory-1 policy to maximize it. A - simplification and refinement of DBS: it replaces bounded-depth tree - search with the exact infinite-horizon value, yielding stronger play - against exploitable opponents at lower complexity. Adaptive only w.r.t. - memory-1 opponents (the model is misspecified for higher-memory play). - - Names: - - ISO: [Hutter2023]_ - """ name = "ISO" classifier = { "memory_depth": float("inf"), @@ -236,6 +221,9 @@ def _get_state_idx(self, opponent) -> int: return -1 def update(self, opponent: Player) -> float: + """Updates the opponent model and our policy. + + Returns our expected reward per step.""" self._update_opponent_model(opponent) state_idx = self._get_state_idx(opponent) expected, my_policy = optimize_against(self.opp_model, @@ -244,7 +232,7 @@ def update(self, opponent: Player) -> float: self.my_policy = my_policy return expected - def act(self, opponent) -> Action: + def act(self, opponent: Player) -> Action: state_idx = self._get_state_idx(opponent) pr_c = self.my_policy[state_idx] return C if np.random.uniform() < pr_c else D From 20b4d52bd98a5861095b317d19d0d2e454f2b8dd Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:52:55 +0000 Subject: [PATCH 05/31] Add tests for LongtermTFT and ISO --- axelrod/strategies/_strategies.py | 1 + axelrod/strategies/cooperate_iso.py | 6 +- .../tests/strategies/test_cooperate_iso.py | 188 ++++++++++++++++++ 3 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 axelrod/tests/strategies/test_cooperate_iso.py diff --git a/axelrod/strategies/_strategies.py b/axelrod/strategies/_strategies.py index bc80eeccc..6f4271caa 100644 --- a/axelrod/strategies/_strategies.py +++ b/axelrod/strategies/_strategies.py @@ -76,6 +76,7 @@ from .better_and_better import BetterAndBetter from .bush_mosteller import BushMosteller from .calculator import Calculator +from .cooperate_iso import LongtermTfT, ISO from .cooperator import Cooperator, TrickyCooperator from .cycler import ( AntiCycler, diff --git a/axelrod/strategies/cooperate_iso.py b/axelrod/strategies/cooperate_iso.py index 5b0d3e7d6..cde30d4d3 100644 --- a/axelrod/strategies/cooperate_iso.py +++ b/axelrod/strategies/cooperate_iso.py @@ -30,6 +30,7 @@ class LongtermTfT(Player): classifier = { "memory_depth": float("inf"), "stochastic": True, + "makes_use_of": {"noise"}, "long_run_time": False, "inspects_source": False, "manipulates_source": False, @@ -46,7 +47,7 @@ def __init__(self): self.opp_pr_d_after_c = 0. def receive_match_attributes(self): - self.noise = self.match_attributes["noise"] + self.noise = self.match_attributes.get("noise", 0.0) def strategy(self, opponent: Player) -> Action: if not self.history: @@ -161,6 +162,7 @@ class ISO(Player): classifier = { "memory_depth": float("inf"), "stochastic": True, + "makes_use_of": {"noise"}, "long_run_time": True, "inspects_source": False, "manipulates_source": False, @@ -184,7 +186,7 @@ def __init__(self): self.my_policy = [1., 0., 1., 0.] def receive_match_attributes(self): - self.noise = self.match_attributes["noise"] + self.noise = self.match_attributes.get("noise", 0.0) def _update_opponent_model(self, opponent): if len(self.history) < 2: diff --git a/axelrod/tests/strategies/test_cooperate_iso.py b/axelrod/tests/strategies/test_cooperate_iso.py new file mode 100644 index 000000000..6f236e5e8 --- /dev/null +++ b/axelrod/tests/strategies/test_cooperate_iso.py @@ -0,0 +1,188 @@ +import axelrod as axl +from axelrod.action import Action +from axelrod.tests.strategies.test_player import TestPlayer +from axelrod.strategies.cooperate_iso import LongtermTfT, ISO +from unittest.mock import patch + +C, D = Action.C, Action.D + +class TestLongtermTfT(TestPlayer): + name = "LongtermTfT" + player = LongtermTfT + + expected_classifier = { + "memory_depth": float("inf"), + "stochastic": True, + "makes_use_of": {"noise"}, + "long_run_time": False, + "inspects_source": False, + "manipulates_source": False, + "manipulates_state": False, + } + + def test_early_rounds_tit_for_tat(self): + """ + Tests that the strategy strictly defaults to Tit-for-Tat + when the threshold conditions (n_tft_would_c < 5) are active. + """ + # (Player Action, Opponent Action) + expected = [ + (C, C), # T1: No history, defaults to C + (C, D), # T2: Mirrors Opponent's T1 (C) + (D, D), # T3: Mirrors Opponent's T2 (D) + (D, C), # T4: Mirrors Opponent's T3 (D) + (C, C), # T5: Mirrors Opponent's T4 (C) + ] + + self.versus_test( + opponent=axl.MockPlayer(actions=[C, D, D, C, C]), + expected_actions=expected, + match_attributes={"noise": 0.1} + ) + + def test_forgiveness_and_z_score_retaliation(self): + """ + Tests the transition from TfT to the forgiving Z-score phase, + and verifies that it retaliates when Z >= 2. + """ + # (Player Action, Opponent Action) + expected = [ + (C, C), # T1: History len 0 + (C, C), # T2: History len 1 + (C, C), # T3: n_c=1, n_d=0 -> TfT (plays C) + (C, C), # T4: n_c=2, n_d=0 -> TfT (plays C) + (C, C), # T5: n_c=3, n_d=0 -> TfT (plays C) + + # --- Z-Score Phase Begins (n_c reaches 4, about to be 5) --- + (C, D), # T6: n_c=4, n_d=0 -> TfT (plays C). Opp defects. + + # Opponent defected, but Z-score is low (Z=0.5), so player forgives. + (C, D), # T7: n_c=5, n_d=1 -> Forgives (plays C). Opp defects again. + + # Z-score climbs (Z=1.4) but stays < 2. + (C, D), # T8: n_c=6, n_d=2 -> Forgives (plays C). Opp defects 3rd time. + + # Z-score hits Z=2.3 (>= 2). Strategy falls back to TfT and retaliates. + (D, C), # T9: n_c=7, n_d=3 -> Retaliates (plays D). Opp plays C. + + # Mirroring Opponent's C from T9 (Z=2.2, TfT mode). + (C, C), # T10: n_c=8, n_d=3 -> TfT (plays C). + ] + + self.versus_test( + opponent=axl.MockPlayer(actions=[C, C, C, C, C, D, D, D, C, C]), + expected_actions=expected, + match_attributes={"noise": 0.1} + ) + +class TestISO(TestPlayer): + name = "ISO" + player = ISO + + expected_classifier = { + "memory_depth": float("inf"), + "stochastic": True, + "makes_use_of": {"noise"}, + "long_run_time": True, + "inspects_source": False, + "manipulates_source": False, + "manipulates_state": False, + } + + def test_get_state_idx(self): + """Unit test for the state indexing logic mapping history to 0,1,2,3.""" + player = self.player() + opponent = axl.MockPlayer(actions=[C, D, C, D]) + + # T1: No history -> Defaults to 0 (CC) + self.assertEqual(player._get_state_idx(opponent), 0) + + # T2: CC + # History.append(play, coplay) + player.history.append(C, C) + opponent.history.append(C, C) + self.assertEqual(player._get_state_idx(opponent), 0) + + # T3: CD + player.history.append(C, D) + opponent.history.append(D, C) + self.assertEqual(player._get_state_idx(opponent), 1) + + # T4: DC + player.history.append(D, C) + opponent.history.append(C, D) + self.assertEqual(player._get_state_idx(opponent), 2) + + # T5: DD + player.history.append(D, D) + opponent.history.append(D, D) + self.assertEqual(player._get_state_idx(opponent), 3) + + def test_update_opponent_model(self): + """Unit test for the discounted moving average calculation.""" + player = self.player() + opponent = axl.MockPlayer() + + # Manually construct a 2-turn history + # Turn 1: Both played C + player.history.append(C, C) + opponent.history.append(C, C) + + # Turn 2: Player played C, Opponent played D + player.history.append(C, D) + opponent.history.append(D, C) + + player._update_opponent_model(opponent) + + # The opponent played D after CC, so opp_after_CC should append 0 + self.assertEqual(player.opp_after_CC, [1, 0]) + + # Check discount logic: weights = [0.99, 1.0]. + # Mean should be (0.99*1 + 1.0*0) / 1.99 + expected_mean = 0.99 / 1.99 + self.assertAlmostEqual(player.opp_pr_c_after_CC, expected_mean, places=4) + + @patch("axelrod.strategies.cooperate_iso.optimize_against") + def test_strategy_with_mocked_optimizer(self, mock_optimize): + """ + Tests the strategy execution loop deterministically by patching + out the PyTorch optimization step. + """ + # We force the optimizer to return absolute 1.0 (C) or 0.0 (D) policies. + # Policy structure: [P(C|CC), P(C|CD), P(C|DC), P(C|DD)] + # We will make it always cooperate after CC, and always defect otherwise. + mock_optimize.return_value = (3.0, [1.0, 0.0, 0.0, 0.0]) + + # Because we return absolute probabilities, np.random.uniform() < pr_c + # becomes strictly deterministic. + expected = [ + (C, C), # T1: No history -> Defaults to CC (idx 0) -> policy[0] is 1.0 (Plays C) + (C, D), # T2: T1 was (C, C) -> state CC (idx 0) -> policy[0] is 1.0 (Plays C) + (D, D), # T3: T2 was (C, D) -> state CD (idx 1) -> policy[1] is 0.0 (Plays D) + (D, C), # T4: T3 was (D, D) -> state DD (idx 3) -> policy[3] is 0.0 (Plays D) + ] + + self.versus_test( + opponent=axl.MockPlayer(actions=[C, D, D, C]), + expected_actions=expected, + match_attributes={"noise": 0.1} + ) + + def test_pytorch_optimization_runs(self): + """ + Runs an actual match for a few turns to ensure the PyTorch tensors + and Adam optimizer compile and execute without crashing. + """ + player = self.player() + opponent = axl.MockPlayer(actions=[C, D, C]) + + # Play a 3-turn match + match = axl.Match([player, opponent], turns=3) + match.play() + + self.assertEqual(len(player.history), 3) + self.assertEqual(len(player.my_policy), 4) + + # Ensure all resulting policy probabilities are valid bounded floats + for pr_c in player.my_policy: + self.assertTrue(0.0 <= pr_c <= 1.0) \ No newline at end of file From b36a110ee5644088272a755f30fb8e3add6141e3 Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:47:39 +0000 Subject: [PATCH 06/31] Fixes --- axelrod/strategies/cooperate_iso.py | 217 ++++++++++-------- .../tests/strategies/test_cooperate_iso.py | 17 +- 2 files changed, 128 insertions(+), 106 deletions(-) diff --git a/axelrod/strategies/cooperate_iso.py b/axelrod/strategies/cooperate_iso.py index cde30d4d3..c009019e4 100644 --- a/axelrod/strategies/cooperate_iso.py +++ b/axelrod/strategies/cooperate_iso.py @@ -3,6 +3,7 @@ import torch from torch import optim +import axelrod as axl from axelrod.action import Action from axelrod.player import Player @@ -42,9 +43,6 @@ def __init__(self): self.n_tft_would_c = 0 self.n_d_when_tft_would_c = 0 self.z = 0. - # Estimate of the opponent's rate of playing D after C, taking noise - # into account. - self.opp_pr_d_after_c = 0. def receive_match_attributes(self): self.noise = self.match_attributes.get("noise", 0.0) @@ -72,97 +70,109 @@ def strategy(self, opponent: Player) -> Action: return opponent.history[-1] def are_same_binomial(p1: float, n1: int, p2: float, n2: int, min_abs_z: float = 2.0) -> bool: + """ + Tests if two binomial proportions are statistically indistinguishable + using a pooled two-proportion z-test. + """ p = (n1 * p1 + n2 * p2) / (n1 + n2) + if p in (0., 1.): + return p1 == p2 z = (p1 - p2) / np.sqrt(p * (1 - p) * (1 / n1 + 1 / n2)) return abs(z) < min_abs_z def has_greater_mean(ary1: np.ndarray, ary2: np.ndarray, min_z: float = 2.0) -> bool: - """Tests if ary1 has a greater mean than ary2""" + """ + Tests if the mean of the first array is significantly greater than + the second array using a two-sample z-test. + """ se1 = np.std(ary1) / np.sqrt(len(ary1)) se2 = np.std(ary2) / np.sqrt(len(ary2)) if se1 == 0 and se2 == 0: return ary1.mean() > ary2.mean() return (ary1.mean() - ary2.mean()) / np.sqrt(se1**2 + se2**2) > min_z -def find_recent_average(ary, discount_factor: float = 0.99) -> float: - assert len(ary) > 0 - assert 0 < discount_factor <= 1 - ary = np.array(ary) - N = len(ary) - weights = np.array([discount_factor**((N-1)-i) for i in range(N)]) - return (weights * ary).sum() / weights.sum() - -def optimize_constrained(loss_fn, - starting_points=[[0.5, 0.5, 0.5, 0.5]], - lr=0.1, - n_steps=100): - # Constrains all params to [0, 1]. - min_loss_so_far = None - best_params_so_far = None - for point in starting_points: - params = torch.Tensor(point) - params.requires_grad_() - opt = optim.Adam([params], lr=lr) - for i in range(n_steps): - loss = loss_fn(params) - loss_value = loss.detach().numpy().sum() - if min_loss_so_far is None or loss_value < min_loss_so_far: - min_loss_so_far = loss_value - best_params_so_far = params.detach().numpy() - opt.zero_grad() - loss.backward() - opt.step() - with torch.no_grad(): - for param in params: - param.clamp_(0, 1) - return min_loss_so_far, best_params_so_far - -# opp_strategy already includes the effect of noise. -def get_reward(my_strategy: torch.Tensor, - opp_strategy: torch.Tensor, - init_state: torch.Tensor, - p_end: float, - p_noise: float = 0., - RSTP=(3, 0, 5, 1)): - # Apply p_noise only to own strategy, not to opponent. +def get_reward( + my_strategy: torch.Tensor, + opp_strategy: torch.Tensor, + init_state: torch.Tensor, + p_end: float, + p_noise: float, + RPST: tuple[float, float, float, float], +) -> float: + """ + Calculates the expected average reward per step for a given policy + against a specific opponent strategy (including the effect of noise), + utilizing Markov transition matrices. + """ + # Apply p_noise only to own strategy, not to opponent + # (the opponen strategy already includes noise effects). own = my_strategy + p_noise * (1 - 2 * my_strategy) # Flip CD/DC for opponent opp = torch.Tensor( [opp_strategy[0], opp_strategy[2], opp_strategy[1], opp_strategy[3]]) - T = torch.stack([own * opp, - own * (1 - opp), - (1 - own) * opp, - (1 - own) * (1 - opp)]) - T = torch.transpose(T, 0, 1) - TT = torch.inverse(torch.eye(4) - (1 - p_end) * T) - rewards = torch.tensor(RSTP, dtype=torch.float) + trans_mat = torch.stack([own * opp, + own * (1 - opp), + (1 - own) * opp, + (1 - own) * (1 - opp)]) + trans_mat = torch.transpose(trans_mat, 0, 1) + R, P, S, T = RPST + rewards = torch.tensor((R, S, T, P), dtype=torch.float) # Don't include init state in summed rewards. - reward = torch.dot(init_state, torch.matmul(TT, rewards) - rewards) + inv = torch.inverse(torch.eye(4) - (1 - p_end) * trans_mat) + reward = torch.dot(init_state, torch.matmul(inv, rewards) - rewards) # Avg. reward per step return p_end * reward / (1 - p_end) -# The opponent model is assumed to already include the effect of noise. -# init_state_idx in [0, 1, 2, 3] -def optimize_against(opponent: np.ndarray, init_state_idx: int, - p_end: float = 1e-2, p_noise: float = 0) -> np.ndarray: - opp = torch.Tensor(opponent) +def optimize_against( + opponent: np.ndarray, + init_state_idx: int, + p_end: float, + p_noise: float, + RPST: tuple[float, float, float, float], + lr: float = 0.1, + n_steps: float = 50, +) -> tuple[float, np.ndarray]: + """ + Discovers the optimal response strategy (policy) against a fixed opponent + model by maximizing the expected reward from a given starting state + (init_state_idx in [0, 1, 2, 3]). + """ + opp = torch.tensor(opponent, dtype=torch.float32) assert p_noise < 0.5 - opp.clamp_(min=p_noise, max=1-p_noise) - init_state = [0] * 4 - init_state[init_state_idx] = 1 - init_state = torch.Tensor(init_state) - def loss_fn(strategy: torch.Tensor): - return -1 * get_reward(strategy, opp, init_state, p_end=p_end, p_noise=p_noise) - # Only 50 steps to save time - loss, strat = optimize_constrained(loss_fn, n_steps=50) - return -1 * loss, strat + opp.clamp_(min=p_noise, max=1.0 - p_noise) + + init_state = torch.zeros(4, dtype=torch.float32) + init_state[init_state_idx] = 1.0 + + params = torch.tensor([0.5, 0.5, 0.5, 0.5], requires_grad=True) + opt = optim.Adam([params], lr=lr) + + min_loss = float("inf") + best_params = None + + for _ in range(n_steps): + loss = -get_reward(params, opp, init_state, p_end, p_noise, RPST) + loss_val = loss.item() + + if loss_val < min_loss: + min_loss = loss_val + best_params = params.detach().numpy().copy() + + opt.zero_grad() + loss.backward() + opt.step() + + with torch.no_grad(): + params.clamp_(0.0, 1.0) + + return -min_loss, best_params class ISO(Player): name = "ISO" classifier = { "memory_depth": float("inf"), "stochastic": True, - "makes_use_of": {"noise"}, + "makes_use_of": {"noise", "game"}, "long_run_time": True, "inspects_source": False, "manipulates_source": False, @@ -171,41 +181,48 @@ class ISO(Player): def __init__(self): super().__init__() - # Opponent's action in certain situations. 1 is C, 0 is D. - # Start by assuming the opponent played in accordance with TfT once. - self.opp_after_CC = [1] - self.opp_after_CD = [1] - self.opp_after_DC = [0] - self.opp_after_DD = [0] - # Recent averages of cooperation rates - self.opp_pr_c_after_CC = np.mean(self.opp_after_CC) - self.opp_pr_c_after_CD = np.mean(self.opp_after_CD) - self.opp_pr_c_after_DC = np.mean(self.opp_after_DC) - self.opp_pr_c_after_DD = np.mean(self.opp_after_DD) - self.opp_model = [1., 0., 1., 0.] - self.my_policy = [1., 0., 1., 0.] + self.discount_factor = 0.99 + + # Track (numerator, denominator) for each state. + self.ewma_CC = [1.0, 1.0] + self.ewma_CD = [1.0, 1.0] + self.ewma_DC = [0.0, 1.0] + self.ewma_DD = [0.0, 1.0] + + # Initial cooperation probabilities (num / den) + self.opp_model = [1.0, 0.0, 1.0, 0.0] + self.my_policy = [1.0, 0.0, 1.0, 0.0] def receive_match_attributes(self): self.noise = self.match_attributes.get("noise", 0.0) + game = self.match_attributes.get("game", axl.DefaultGame) + self.RPST = game.RPST() + + def _update_single_ewma(self, state_ewma: list[float], action_val: float) -> float: + """Updates the (numerator, denominator) pair in-place and returns the new average.""" + state_ewma[0] = self.discount_factor * state_ewma[0] + action_val + state_ewma[1] = self.discount_factor * state_ewma[1] + 1.0 + return state_ewma[0] / state_ewma[1] - def _update_opponent_model(self, opponent): + def _update_opponent_model(self, opponent: Player): if len(self.history) < 2: return - prev = (self.history[-2], opponent.history[-2]) - opp_act = 1 if opponent.history[-1] == C else 0 - if prev == (C, C): - self.opp_after_CC.append(opp_act) - self.opp_pr_c_after_CC = find_recent_average(self.opp_after_CC) - elif prev == (C, D): - self.opp_after_CD.append(opp_act) - self.opp_pr_c_after_CD = find_recent_average(self.opp_after_CD) - elif prev == (D, C): - self.opp_after_DC.append(opp_act) - self.opp_pr_c_after_DC = find_recent_average(self.opp_after_DC) - elif prev == (D, D): - self.opp_after_DD.append(opp_act) - self.opp_pr_c_after_DD = find_recent_average(self.opp_after_DD) - self.opp_model = [self.opp_pr_c_after_CC, self.opp_pr_c_after_DC, self.opp_pr_c_after_CD, self.opp_pr_c_after_DD] + + prev_state = (self.history[-2], opponent.history[-2]) + opp_act = 1.0 if opponent.history[-1] == C else 0.0 + + if prev_state == (C, C): + pr_c = self._update_single_ewma(self.ewma_CC, opp_act) + self.opp_model[0] = pr_c + elif prev_state == (C, D): + pr_c = self._update_single_ewma(self.ewma_CD, opp_act) + self.opp_model[2] = pr_c + elif prev_state == (D, C): + pr_c = self._update_single_ewma(self.ewma_DC, opp_act) + self.opp_model[1] = pr_c + elif prev_state == (D, D): + pr_c = self._update_single_ewma(self.ewma_DD, opp_act) + self.opp_model[3] = pr_c def _get_state_idx(self, opponent) -> int: if not self.history: @@ -230,14 +247,16 @@ def update(self, opponent: Player) -> float: state_idx = self._get_state_idx(opponent) expected, my_policy = optimize_against(self.opp_model, init_state_idx=state_idx, - p_noise=self.noise) + p_noise=self.noise, + RPST=self.RPST, + p_end=1e-2) self.my_policy = my_policy return expected def act(self, opponent: Player) -> Action: state_idx = self._get_state_idx(opponent) pr_c = self.my_policy[state_idx] - return C if np.random.uniform() < pr_c else D + return self._random.random_choice(pr_c) def strategy(self, opponent: Player) -> Action: _ = self.update(opponent) diff --git a/axelrod/tests/strategies/test_cooperate_iso.py b/axelrod/tests/strategies/test_cooperate_iso.py index 6f236e5e8..51b473e43 100644 --- a/axelrod/tests/strategies/test_cooperate_iso.py +++ b/axelrod/tests/strategies/test_cooperate_iso.py @@ -82,7 +82,7 @@ class TestISO(TestPlayer): expected_classifier = { "memory_depth": float("inf"), "stochastic": True, - "makes_use_of": {"noise"}, + "makes_use_of": {"noise", "game"}, "long_run_time": True, "inspects_source": False, "manipulates_source": False, @@ -123,8 +123,8 @@ def test_update_opponent_model(self): player = self.player() opponent = axl.MockPlayer() - # Manually construct a 2-turn history # Turn 1: Both played C + # history.append(action, coplay) player.history.append(C, C) opponent.history.append(C, C) @@ -134,13 +134,16 @@ def test_update_opponent_model(self): player._update_opponent_model(opponent) - # The opponent played D after CC, so opp_after_CC should append 0 - self.assertEqual(player.opp_after_CC, [1, 0]) + # Check EWMA accumulator state [numerator, denominator] for CC + # Initial state was [1.0, 1.0]; after seeing D (0.0): + # num = 0.99 * 1.0 + 0.0 = 0.99 + # den = 0.99 * 1.0 + 1.0 = 1.99 + self.assertAlmostEqual(player.ewma_CC[0], 0.99, places=6) + self.assertAlmostEqual(player.ewma_CC[1], 1.99, places=6) - # Check discount logic: weights = [0.99, 1.0]. - # Mean should be (0.99*1 + 1.0*0) / 1.99 + # Check discount logic: mean = num / den expected_mean = 0.99 / 1.99 - self.assertAlmostEqual(player.opp_pr_c_after_CC, expected_mean, places=4) + self.assertAlmostEqual(player.opp_model[0], expected_mean, places=4) @patch("axelrod.strategies.cooperate_iso.optimize_against") def test_strategy_with_mocked_optimizer(self, mock_optimize): From cd51442d023191e42b6385df2b476ab5f53a6c68 Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:27:04 +0000 Subject: [PATCH 07/31] Implement CooperateISO --- axelrod/strategies/cooperate_iso.py | 113 ++++++++++++++++++++++++++-- 1 file changed, 107 insertions(+), 6 deletions(-) diff --git a/axelrod/strategies/cooperate_iso.py b/axelrod/strategies/cooperate_iso.py index c009019e4..f6cd1892c 100644 --- a/axelrod/strategies/cooperate_iso.py +++ b/axelrod/strategies/cooperate_iso.py @@ -60,10 +60,7 @@ def strategy(self, opponent: Player) -> Action: std_expected_ds = np.sqrt(self.noise * (1-self.noise) * self.n_tft_would_c) # This becomes n_d_when_tft_would_c for noise->0 self.z = (self.n_d_when_tft_would_c - n_expected_ds) / max(1., std_expected_ds) - if self.n_tft_would_c < 5 and self.n_d_when_tft_would_c < 3: - # TfT - return opponent.history[-1] - elif self.z < 2: + if self.n_tft_would_c >= 5 and self.z < 2: return C else: # TfT @@ -168,6 +165,21 @@ def optimize_against( return -min_loss, best_params class ISO(Player): + """Optimal response against a memory-1 opponent model. + + Estimates the opponent's memory-1 (order-1) conditional cooperation + probabilities, which together with its own memory-1 strategy induce a + Markov chain over outcome pairs. Computes the exact expected discounted + long-term payoff in closed form via the chain's stationary/resolvent + solution, then optimizes its own memory-1 policy to maximize it. A + simplification and refinement of DBS: it replaces bounded-depth tree + search with the exact infinite-horizon value, yielding stronger play + against exploitable opponents at lower complexity. Adaptive only w.r.t. + memory-1 opponents (the model is misspecified for higher-memory play). + + Names: + - ISO: [Hutter2023]_ + """ name = "ISO" classifier = { "memory_depth": float("inf"), @@ -195,8 +207,7 @@ def __init__(self): def receive_match_attributes(self): self.noise = self.match_attributes.get("noise", 0.0) - game = self.match_attributes.get("game", axl.DefaultGame) - self.RPST = game.RPST() + self.RPST = self.match_attributes['game'].RPST() def _update_single_ewma(self, state_ewma: list[float], action_val: float) -> float: """Updates the (numerator, denominator) pair in-place and returns the new average.""" @@ -261,3 +272,93 @@ def act(self, opponent: Player) -> Action: def strategy(self, opponent: Player) -> Action: _ = self.update(opponent) return self.act(opponent) + +class CooperateISO(Player): + """Forgiving cooperation combined with optimal exploitation. + + Seeks to establish and sustain mutual cooperation using LongtermTFT's + noise-robust forgiveness, while switching to ISO to respond optimally + to opponents that can be exploited. In effect: cooperate with + cooperators, exploit the exploitable. This combination is the paper's + tournament-strong strategy, outperforming prior champions against the + Axelrod library across noise levels of 0–10%. + + Names: + - CooperateISO: [Hutter2023]_ + """ + name = "CooperateISO" + classifier = { + "memory_depth": float("inf"), + "stochastic": True, + "makes_use_of": {"noise", "game"}, + "long_run_time": True, + "inspects_source": False, + "manipulates_source": False, + "manipulates_state": False, + } + + def __init__(self): + self.iso_instance = ISO() + super().__init__() + self.n_tft_would_c = 0 + self.n_d_when_tft_would_c = 0 + self.z = 0. + # Estimate of the opponent's rate of playing D after C, taking noise + # into account. + self.opp_pr_d_after_c = 0. + self.playing_iso = False + self.reward_history = [] + + def set_seed(self, seed: int = None): + super().set_seed(seed) + self.iso_instance.set_seed(seed) + + def receive_match_attributes(self): + super().receive_match_attributes() + self.RPST = self.match_attributes['game'].RPST() + self.noise = self.match_attributes['noise'] + self.iso_instance.noise = self.noise + + def _update_reward_history(self, opponent): + R, P, S, T = self.RPST + state = (self.history[-1], opponent.history[-1]) + if state == (C, C): + self.reward_history.append(R) + elif state == (C, D): + self.reward_history.append(S) + elif state == (D, C): + self.reward_history.append(T) + elif state == (D, D): + self.reward_history.append(P) + + def strategy(self, opponent: Player) -> Action: + if not self.history: + return C + self.iso_instance.history.append(self.history[-1], opponent.history[-1]) + if self.playing_iso: + return self.iso_instance.strategy(opponent) + self._update_reward_history(opponent) + expected = self.iso_instance.update(opponent) + if len(self.history) == 1: + return opponent.history[-1] + if self.history[-2] == C: + self.n_tft_would_c += 1 + if opponent.history[-1] == D: + self.n_d_when_tft_would_c += 1 + n_expected_ds = self.n_tft_would_c * self.noise + std_expected_ds = np.sqrt(self.noise * (1-self.noise) * self.n_tft_would_c) + # This becomes n_d_when_tft_would_c for noise->0 + self.z = (self.n_d_when_tft_would_c - n_expected_ds) / max(1., std_expected_ds) + # Should we start playing ISO? + R, P, _, _ = self.RPST + expected_gain = expected - np.mean(self.reward_history) + if len(self.reward_history) >= 10 and \ + expected_gain > 2. * np.std(self.reward_history) / np.sqrt(len(self.reward_history)) and \ + expected_gain > 0.05 * (R - P): + self.playing_iso = True + return self.iso_instance.act(opponent) + if self.n_tft_would_c >= 5 and self.z < 2: + return C + else: + # TfT + return opponent.history[-1] \ No newline at end of file From ab25e10e51a465a00c648e76c2805a0cc76ef0ea Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:38:41 +0000 Subject: [PATCH 08/31] Test for CooperateISO --- .../tests/strategies/test_cooperate_iso.py | 116 +++++++++++++++++- 1 file changed, 113 insertions(+), 3 deletions(-) diff --git a/axelrod/tests/strategies/test_cooperate_iso.py b/axelrod/tests/strategies/test_cooperate_iso.py index 51b473e43..f1805790f 100644 --- a/axelrod/tests/strategies/test_cooperate_iso.py +++ b/axelrod/tests/strategies/test_cooperate_iso.py @@ -1,8 +1,8 @@ import axelrod as axl from axelrod.action import Action from axelrod.tests.strategies.test_player import TestPlayer -from axelrod.strategies.cooperate_iso import LongtermTfT, ISO -from unittest.mock import patch +from axelrod.strategies.cooperate_iso import LongtermTfT, ISO, CooperateISO +from unittest.mock import patch, MagicMock C, D = Action.C, Action.D @@ -188,4 +188,114 @@ def test_pytorch_optimization_runs(self): # Ensure all resulting policy probabilities are valid bounded floats for pr_c in player.my_policy: - self.assertTrue(0.0 <= pr_c <= 1.0) \ No newline at end of file + self.assertTrue(0.0 <= pr_c <= 1.0) + +class TestCooperateISO(TestPlayer): + name = "CooperateISO" + player = CooperateISO + + expected_classifier = { + "memory_depth": float("inf"), + "stochastic": True, + "makes_use_of": {"noise", "game"}, + "long_run_time": True, + "inspects_source": False, + "manipulates_source": False, + "manipulates_state": False, + } + + def test_update_reward_history(self): + """Unit test for ensuring the reward history accurately maps RPST to match states.""" + player = self.player() + player.RPST = (3, 1, 0, 5) + opponent = axl.MockPlayer() + + # Turn 1: Mutual Cooperation (C, C) -> Should append R (3) + player.history.append(C, C) + opponent.history.append(C, C) + player._update_reward_history(opponent) + self.assertEqual(player.reward_history, [3]) + + # Turn 2: Sucker's payoff (C, D) -> Should append S (0) + player.history.append(C, D) + opponent.history.append(D, C) + player._update_reward_history(opponent) + self.assertEqual(player.reward_history, [3, 0]) + + # Turn 3: Temptation (D, C) -> Should append T (5) + player.history.append(D, C) + opponent.history.append(C, D) + player._update_reward_history(opponent) + self.assertEqual(player.reward_history, [3, 0, 5]) + + # Turn 4: Punishment (D, D) -> Should append P (1) + player.history.append(D, D) + opponent.history.append(D, D) + player._update_reward_history(opponent) + self.assertEqual(player.reward_history, [3, 0, 5, 1]) + + @patch("axelrod.strategies.cooperate_iso.ISO.update") + @patch("axelrod.strategies.cooperate_iso.ISO.act") + def test_maintains_tft_when_iso_not_profitable(self, mock_act, mock_update): + """ + Tests that if ISO's expected reward does not beat the historical average, + the strategy maintains LongtermTfT behavior. + """ + # ISO update always returns 0.0 (highly unprofitable) + mock_update.return_value = 0.0 + + expected = [ + (C, C), # T1: No history, defaults to C + (C, D), # T2: Mirrors Opponent's T1 (C) + (D, D), # T3: Mirrors Opponent's T2 (D) + (D, C), # T4: Mirrors Opponent's T3 (D) + (C, C), # T5: Mirrors Opponent's T4 (C) + ] + + self.versus_test( + opponent=axl.MockPlayer(actions=[C, D, D, C, C]), + expected_actions=expected, + match_attributes={"noise": 0.0, "game": axl.DefaultGame} + ) + # Because we never switched to ISO, act() should never have been called + mock_act.assert_not_called() + + @patch("axelrod.strategies.cooperate_iso.ISO.update") + @patch("axelrod.strategies.cooperate_iso.ISO.act") + def test_switches_to_iso_when_profitable(self, mock_act, mock_update): + """ + Tests the switch condition: if we have 10 rounds of history and ISO predicts + a sufficiently high expected gain, the strategy flips to playing ISO. + """ + # We will mock ISO to return D whenever it acts + mock_act.return_value = D + + # We play 11 rounds. + # Turns 1-10: ISO predicts 3.0 (same as average for mutual cooperation, so expected_gain = 0) + # Turn 11: ISO suddenly predicts 5.0. expected_gain (2.0) crosses the threshold. + mock_update.side_effect = [3.0] * 9 + [5.0] + + # T1 to T10: Mutual cooperation (LongtermTfT mirroring) + expected = [(C, C)] * 10 + + # T11: The threshold is crossed, we switch to ISO, which our mock says will return D + expected.append((D, C)) + + self.versus_test( + opponent=axl.MockPlayer(actions=[C] * 11), + expected_actions=expected, + match_attributes={"noise": 0.0, "game": axl.DefaultGame} + ) + + # Verify ISO took over on the final turn + mock_act.assert_called_once() + + def test_set_seed(self): + """Ensures random seeds are passed down to the inner ISO instance.""" + player = self.player() + + # Mock the internal ISO instance's set_seed method + player.iso_instance.set_seed = MagicMock() + + player.set_seed(42) + player.iso_instance.set_seed.assert_called_once_with(42) \ No newline at end of file From 813b441ff67558232991326df5b0e7f69845a097 Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:57:18 +0000 Subject: [PATCH 09/31] Switch optimizer from torch to scipty --- axelrod/strategies/cooperate_iso.py | 101 ++++++++++++++-------------- 1 file changed, 52 insertions(+), 49 deletions(-) diff --git a/axelrod/strategies/cooperate_iso.py b/axelrod/strategies/cooperate_iso.py index f6cd1892c..4b0d3ced2 100644 --- a/axelrod/strategies/cooperate_iso.py +++ b/axelrod/strategies/cooperate_iso.py @@ -1,12 +1,9 @@ import numpy as np -import torch -from torch import optim - -import axelrod as axl from axelrod.action import Action from axelrod.player import Player +from scipy.optimize import minimize C, D = Action.C, Action.D @@ -89,36 +86,44 @@ def has_greater_mean(ary1: np.ndarray, ary2: np.ndarray, min_z: float = 2.0) -> return (ary1.mean() - ary2.mean()) / np.sqrt(se1**2 + se2**2) > min_z def get_reward( - my_strategy: torch.Tensor, - opp_strategy: torch.Tensor, - init_state: torch.Tensor, + my_strategy: np.ndarray, + opp_strategy: np.ndarray, + init_state: np.ndarray, p_end: float, p_noise: float, RPST: tuple[float, float, float, float], ) -> float: """ - Calculates the expected average reward per step for a given policy - against a specific opponent strategy (including the effect of noise), + Calculates the expected average reward per step for a given policy + against a specific opponent strategy (including the effect of noise), utilizing Markov transition matrices. """ # Apply p_noise only to own strategy, not to opponent - # (the opponen strategy already includes noise effects). - own = my_strategy + p_noise * (1 - 2 * my_strategy) - # Flip CD/DC for opponent - opp = torch.Tensor( - [opp_strategy[0], opp_strategy[2], opp_strategy[1], opp_strategy[3]]) - trans_mat = torch.stack([own * opp, - own * (1 - opp), - (1 - own) * opp, - (1 - own) * (1 - opp)]) - trans_mat = torch.transpose(trans_mat, 0, 1) + # (the opponent strategy already includes noise effects). + own = my_strategy + p_noise * (1.0 - 2.0 * my_strategy) + + # Flip CD/DC for opponent using NumPy advanced indexing + opp = opp_strategy[[0, 2, 1, 3]] + + # Build and transpose the transition matrix + trans_mat = np.array([ + own * opp, + own * (1.0 - opp), + (1.0 - own) * opp, + (1.0 - own) * (1.0 - opp) + ]).T + R, P, S, T = RPST - rewards = torch.tensor((R, S, T, P), dtype=torch.float) + rewards = np.array([R, S, T, P], dtype=float) + # Don't include init state in summed rewards. - inv = torch.inverse(torch.eye(4) - (1 - p_end) * trans_mat) - reward = torch.dot(init_state, torch.matmul(inv, rewards) - rewards) + inv = np.linalg.inv(np.eye(4) - (1.0 - p_end) * trans_mat) + + # Calculate expected reward using the @ operator for matrix multiplication + reward = init_state @ (inv @ rewards - rewards) + # Avg. reward per step - return p_end * reward / (1 - p_end) + return p_end * float(reward) / (1.0 - p_end) def optimize_against( opponent: np.ndarray, @@ -126,43 +131,41 @@ def optimize_against( p_end: float, p_noise: float, RPST: tuple[float, float, float, float], - lr: float = 0.1, - n_steps: float = 50, ) -> tuple[float, np.ndarray]: """ - Discovers the optimal response strategy (policy) against a fixed opponent - model by maximizing the expected reward from a given starting state - (init_state_idx in [0, 1, 2, 3]). + Discovers the optimal response strategy (policy) against a fixed opponent + model by maximizing the expected reward from a given starting state. """ - opp = torch.tensor(opponent, dtype=torch.float32) assert p_noise < 0.5 - opp.clamp_(min=p_noise, max=1.0 - p_noise) - init_state = torch.zeros(4, dtype=torch.float32) - init_state[init_state_idx] = 1.0 + # Clamp opponent array directly using NumPy + opp = np.clip(opponent, p_noise, 1.0 - p_noise) - params = torch.tensor([0.5, 0.5, 0.5, 0.5], requires_grad=True) - opt = optim.Adam([params], lr=lr) - - min_loss = float("inf") - best_params = None + # Setup initial state + init_state = np.zeros(4, dtype=np.float32) + init_state[init_state_idx] = 1.0 - for _ in range(n_steps): - loss = -get_reward(params, opp, init_state, p_end, p_noise, RPST) - loss_val = loss.item() + # Define the objective function to minimize (negative reward) + def objective(params: np.ndarray) -> float: + return -get_reward(params, opp, init_state, p_end, p_noise, RPST) - if loss_val < min_loss: - min_loss = loss_val - best_params = params.detach().numpy().copy() + # Initial parameter guess + x0 = np.array([0.5, 0.5, 0.5, 0.5]) - opt.zero_grad() - loss.backward() - opt.step() + # Bounds equivalent to params.clamp_(0.0, 1.0) + bounds = [(0.0, 1.0), (0.0, 1.0), (0.0, 1.0), (0.0, 1.0)] - with torch.no_grad(): - params.clamp_(0.0, 1.0) + # Optimize using L-BFGS-B + result = minimize( + objective, + x0, + method='L-BFGS-B', + bounds=bounds, + options={'maxiter': 50} + ) - return -min_loss, best_params + # result.fun is the minimum loss (-reward), result.x are the optimal parameters + return -result.fun, result.x class ISO(Player): """Optimal response against a memory-1 opponent model. From e7dabb97a7163c5e822feb9dd11d74c509efa0e3 Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:12:50 +0000 Subject: [PATCH 10/31] Add ISO tests against specific strategies --- .../tests/strategies/test_cooperate_iso.py | 44 ++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/axelrod/tests/strategies/test_cooperate_iso.py b/axelrod/tests/strategies/test_cooperate_iso.py index f1805790f..abf60b8aa 100644 --- a/axelrod/tests/strategies/test_cooperate_iso.py +++ b/axelrod/tests/strategies/test_cooperate_iso.py @@ -1,3 +1,6 @@ +import random +import numpy as np + import axelrod as axl from axelrod.action import Action from axelrod.tests.strategies.test_player import TestPlayer @@ -171,11 +174,42 @@ def test_strategy_with_mocked_optimizer(self, mock_optimize): match_attributes={"noise": 0.1} ) - def test_pytorch_optimization_runs(self): - """ - Runs an actual match for a few turns to ensure the PyTorch tensors - and Adam optimizer compile and execute without crashing. - """ + def test_vs_cooperator_defects(self): + """ISO should learn to defect against a pure cooperator to exploit T > R.""" + # 1. Seed both standard random and numpy random + random.seed(42) + np.random.seed(42) + + player = self.player() + opponent = axl.Cooperator() + + match = axl.Match([player, opponent], turns=30) + match.play() + + print(player.my_policy) + for pr_c in player.my_policy: + self.assertLess(pr_c, 0.15), player.my_policy + + self.assertEqual(player.history[-1], D) + + + def test_vs_tit_for_tat_with_noise_cooperates(self): + """Against TitForTat under noise, ISO should learn that cooperation avoids retaliation.""" + # 1. Seed both standard random and numpy random + random.seed(42) + np.random.seed(42) + + player = self.player() + opponent = axl.TitForTat() + + # Run a match with non-zero noise (deterministically) + match = axl.Match([player, opponent], turns=40, noise=0.05) + match.play() + + self.assertGreater(player.my_policy[0], 0.85) + + def test_optimization_runs(self): + """Runs an actual match for a few turns to ensure optimization executes without crashing.""" player = self.player() opponent = axl.MockPlayer(actions=[C, D, C]) From 68a37817c813d070a9f948bf5e7d49073c39cfee Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:22:13 +0000 Subject: [PATCH 11/31] Refine tests --- axelrod/tests/strategies/test_cooperate_iso.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/axelrod/tests/strategies/test_cooperate_iso.py b/axelrod/tests/strategies/test_cooperate_iso.py index abf60b8aa..536b5a8a9 100644 --- a/axelrod/tests/strategies/test_cooperate_iso.py +++ b/axelrod/tests/strategies/test_cooperate_iso.py @@ -175,38 +175,35 @@ def test_strategy_with_mocked_optimizer(self, mock_optimize): ) def test_vs_cooperator_defects(self): - """ISO should learn to defect against a pure cooperator to exploit T > R.""" - # 1. Seed both standard random and numpy random + """ISO should learn to defect against a pure cooperator.""" random.seed(42) np.random.seed(42) player = self.player() opponent = axl.Cooperator() - match = axl.Match([player, opponent], turns=30) + match = axl.Match([player, opponent], turns=40, noise=0.05) match.play() - print(player.my_policy) - for pr_c in player.my_policy: - self.assertLess(pr_c, 0.15), player.my_policy + for pr_c in player.my_policy[:3]: + self.assertLess(pr_c, 0.1), player.my_policy self.assertEqual(player.history[-1], D) def test_vs_tit_for_tat_with_noise_cooperates(self): """Against TitForTat under noise, ISO should learn that cooperation avoids retaliation.""" - # 1. Seed both standard random and numpy random random.seed(42) np.random.seed(42) player = self.player() opponent = axl.TitForTat() - # Run a match with non-zero noise (deterministically) match = axl.Match([player, opponent], turns=40, noise=0.05) match.play() - self.assertGreater(player.my_policy[0], 0.85) + for pr_c in player.my_policy: + self.assertGreater(pr_c, 0.9), player.my_policy def test_optimization_runs(self): """Runs an actual match for a few turns to ensure optimization executes without crashing.""" From ddee85c02433ac1f6b348528d0c066b85c384419 Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:45:30 +0000 Subject: [PATCH 12/31] Refine tests --- axelrod/strategies/cooperate_iso.py | 22 --------------- .../tests/strategies/test_cooperate_iso.py | 27 ++++++++++--------- 2 files changed, 14 insertions(+), 35 deletions(-) diff --git a/axelrod/strategies/cooperate_iso.py b/axelrod/strategies/cooperate_iso.py index 4b0d3ced2..225fb7b6f 100644 --- a/axelrod/strategies/cooperate_iso.py +++ b/axelrod/strategies/cooperate_iso.py @@ -63,28 +63,6 @@ def strategy(self, opponent: Player) -> Action: # TfT return opponent.history[-1] -def are_same_binomial(p1: float, n1: int, p2: float, n2: int, min_abs_z: float = 2.0) -> bool: - """ - Tests if two binomial proportions are statistically indistinguishable - using a pooled two-proportion z-test. - """ - p = (n1 * p1 + n2 * p2) / (n1 + n2) - if p in (0., 1.): - return p1 == p2 - z = (p1 - p2) / np.sqrt(p * (1 - p) * (1 / n1 + 1 / n2)) - return abs(z) < min_abs_z - -def has_greater_mean(ary1: np.ndarray, ary2: np.ndarray, min_z: float = 2.0) -> bool: - """ - Tests if the mean of the first array is significantly greater than - the second array using a two-sample z-test. - """ - se1 = np.std(ary1) / np.sqrt(len(ary1)) - se2 = np.std(ary2) / np.sqrt(len(ary2)) - if se1 == 0 and se2 == 0: - return ary1.mean() > ary2.mean() - return (ary1.mean() - ary2.mean()) / np.sqrt(se1**2 + se2**2) > min_z - def get_reward( my_strategy: np.ndarray, opp_strategy: np.ndarray, diff --git a/axelrod/tests/strategies/test_cooperate_iso.py b/axelrod/tests/strategies/test_cooperate_iso.py index 536b5a8a9..49f70b9cd 100644 --- a/axelrod/tests/strategies/test_cooperate_iso.py +++ b/axelrod/tests/strategies/test_cooperate_iso.py @@ -174,37 +174,38 @@ def test_strategy_with_mocked_optimizer(self, mock_optimize): match_attributes={"noise": 0.1} ) - def test_vs_cooperator_defects(self): - """ISO should learn to defect against a pure cooperator.""" - random.seed(42) - np.random.seed(42) - + def test_vs_random_defects(self): + """ISO should learn to defect against a random player.""" player = self.player() - opponent = axl.Cooperator() + opponent = axl.Random() - match = axl.Match([player, opponent], turns=40, noise=0.05) + match = axl.Match([player, opponent], turns=200, noise=0.05, seed=42) match.play() - for pr_c in player.my_policy[:3]: + print(player.opp_model) + print(player.my_policy) + for pr_c in player.my_policy: self.assertLess(pr_c, 0.1), player.my_policy self.assertEqual(player.history[-1], D) def test_vs_tit_for_tat_with_noise_cooperates(self): - """Against TitForTat under noise, ISO should learn that cooperation avoids retaliation.""" - random.seed(42) - np.random.seed(42) - + """Against TitForTat under noise, ISO should learn that cooperation avoids retaliation.""" player = self.player() opponent = axl.TitForTat() - match = axl.Match([player, opponent], turns=40, noise=0.05) + match = axl.Match([player, opponent], turns=200, noise=0.05, seed=42) match.play() + print(player.opp_model) + print(player.my_policy) for pr_c in player.my_policy: self.assertGreater(pr_c, 0.9), player.my_policy + self.assertEqual(player.history[-1], C) + + def test_optimization_runs(self): """Runs an actual match for a few turns to ensure optimization executes without crashing.""" player = self.player() From 79d068ddac934f33712cc65f06ce4f43e0e00a85 Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:59:13 +0000 Subject: [PATCH 13/31] Update comments --- axelrod/strategies/cooperate_iso.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/axelrod/strategies/cooperate_iso.py b/axelrod/strategies/cooperate_iso.py index 225fb7b6f..f5c11bf28 100644 --- a/axelrod/strategies/cooperate_iso.py +++ b/axelrod/strategies/cooperate_iso.py @@ -176,7 +176,9 @@ def __init__(self): super().__init__() self.discount_factor = 0.99 - # Track (numerator, denominator) for each state. + # Track the opponent's rate of cooperation (numerator, denominator) for each state. + # Assume we have seen the opponent play following TfT once in each state, + # to make the opponent-model well-defined from teh start. self.ewma_CC = [1.0, 1.0] self.ewma_CD = [1.0, 1.0] self.ewma_DC = [0.0, 1.0] @@ -260,7 +262,7 @@ class CooperateISO(Player): Seeks to establish and sustain mutual cooperation using LongtermTFT's noise-robust forgiveness, while switching to ISO to respond optimally to opponents that can be exploited. In effect: cooperate with - cooperators, exploit the exploitable. This combination is the paper's + retaliators, exploit the exploitable. This combination is the paper's tournament-strong strategy, outperforming prior champions against the Axelrod library across noise levels of 0–10%. From 2e0c2529f4bfba34cd7d4b82673779385a203766 Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:08:01 +0000 Subject: [PATCH 14/31] Remove debug prints --- axelrod/tests/strategies/test_cooperate_iso.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/axelrod/tests/strategies/test_cooperate_iso.py b/axelrod/tests/strategies/test_cooperate_iso.py index 49f70b9cd..115bc2183 100644 --- a/axelrod/tests/strategies/test_cooperate_iso.py +++ b/axelrod/tests/strategies/test_cooperate_iso.py @@ -182,8 +182,6 @@ def test_vs_random_defects(self): match = axl.Match([player, opponent], turns=200, noise=0.05, seed=42) match.play() - print(player.opp_model) - print(player.my_policy) for pr_c in player.my_policy: self.assertLess(pr_c, 0.1), player.my_policy @@ -198,8 +196,6 @@ def test_vs_tit_for_tat_with_noise_cooperates(self): match = axl.Match([player, opponent], turns=200, noise=0.05, seed=42) match.play() - print(player.opp_model) - print(player.my_policy) for pr_c in player.my_policy: self.assertGreater(pr_c, 0.9), player.my_policy From 63269bb9d052e09b7970e3737a493f379cd33e6e Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:08:49 +0000 Subject: [PATCH 15/31] Remove redundant test --- axelrod/tests/strategies/test_cooperate_iso.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/axelrod/tests/strategies/test_cooperate_iso.py b/axelrod/tests/strategies/test_cooperate_iso.py index 115bc2183..3f38d39af 100644 --- a/axelrod/tests/strategies/test_cooperate_iso.py +++ b/axelrod/tests/strategies/test_cooperate_iso.py @@ -201,23 +201,6 @@ def test_vs_tit_for_tat_with_noise_cooperates(self): self.assertEqual(player.history[-1], C) - - def test_optimization_runs(self): - """Runs an actual match for a few turns to ensure optimization executes without crashing.""" - player = self.player() - opponent = axl.MockPlayer(actions=[C, D, C]) - - # Play a 3-turn match - match = axl.Match([player, opponent], turns=3) - match.play() - - self.assertEqual(len(player.history), 3) - self.assertEqual(len(player.my_policy), 4) - - # Ensure all resulting policy probabilities are valid bounded floats - for pr_c in player.my_policy: - self.assertTrue(0.0 <= pr_c <= 1.0) - class TestCooperateISO(TestPlayer): name = "CooperateISO" player = CooperateISO From 06b7f2ca4d72ae7d96b56d6a2f0779aa30a18fdd Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:34:05 +0000 Subject: [PATCH 16/31] Add CooperateISO to _strategies --- axelrod/strategies/_strategies.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/axelrod/strategies/_strategies.py b/axelrod/strategies/_strategies.py index 6f4271caa..506eedbcc 100644 --- a/axelrod/strategies/_strategies.py +++ b/axelrod/strategies/_strategies.py @@ -76,7 +76,7 @@ from .better_and_better import BetterAndBetter from .bush_mosteller import BushMosteller from .calculator import Calculator -from .cooperate_iso import LongtermTfT, ISO +from .cooperate_iso import LongtermTfT, ISO, CooperateISO from .cooperator import Cooperator, TrickyCooperator from .cycler import ( AntiCycler, From 35677892cbd70d04b9a6003fb499f09f61832490 Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:49:02 +0000 Subject: [PATCH 17/31] Comments --- axelrod/strategies/cooperate_iso.py | 30 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/axelrod/strategies/cooperate_iso.py b/axelrod/strategies/cooperate_iso.py index f5c11bf28..afb9fdc50 100644 --- a/axelrod/strategies/cooperate_iso.py +++ b/axelrod/strategies/cooperate_iso.py @@ -63,6 +63,8 @@ def strategy(self, opponent: Player) -> Action: # TfT return opponent.history[-1] +# We describe memory-1 strategies as length-4 arrays, quantifying the probability of cooperation in the states [CC, CD, DC, DD]. + def get_reward( my_strategy: np.ndarray, opp_strategy: np.ndarray, @@ -80,10 +82,10 @@ def get_reward( # (the opponent strategy already includes noise effects). own = my_strategy + p_noise * (1.0 - 2.0 * my_strategy) - # Flip CD/DC for opponent using NumPy advanced indexing + # Flip CD/DC for opponent. opp = opp_strategy[[0, 2, 1, 3]] - # Build and transpose the transition matrix + # Build the transition matrix. trans_mat = np.array([ own * opp, own * (1.0 - opp), @@ -97,7 +99,7 @@ def get_reward( # Don't include init state in summed rewards. inv = np.linalg.inv(np.eye(4) - (1.0 - p_end) * trans_mat) - # Calculate expected reward using the @ operator for matrix multiplication + # Calculate expected reward, reward = init_state @ (inv @ rewards - rewards) # Avg. reward per step @@ -116,7 +118,7 @@ def optimize_against( """ assert p_noise < 0.5 - # Clamp opponent array directly using NumPy + # Clamp to possible values, given noise opp = np.clip(opponent, p_noise, 1.0 - p_noise) # Setup initial state @@ -127,19 +129,14 @@ def optimize_against( def objective(params: np.ndarray) -> float: return -get_reward(params, opp, init_state, p_end, p_noise, RPST) - # Initial parameter guess x0 = np.array([0.5, 0.5, 0.5, 0.5]) - - # Bounds equivalent to params.clamp_(0.0, 1.0) bounds = [(0.0, 1.0), (0.0, 1.0), (0.0, 1.0), (0.0, 1.0)] - - # Optimize using L-BFGS-B result = minimize( objective, x0, - method='L-BFGS-B', + method="L-BFGS-B", bounds=bounds, - options={'maxiter': 50} + options={"maxiter": 50} ) # result.fun is the minimum loss (-reward), result.x are the optimal parameters @@ -178,7 +175,7 @@ def __init__(self): # Track the opponent's rate of cooperation (numerator, denominator) for each state. # Assume we have seen the opponent play following TfT once in each state, - # to make the opponent-model well-defined from teh start. + # to make the opponent-model well-defined from the start. self.ewma_CC = [1.0, 1.0] self.ewma_CD = [1.0, 1.0] self.ewma_DC = [0.0, 1.0] @@ -190,7 +187,7 @@ def __init__(self): def receive_match_attributes(self): self.noise = self.match_attributes.get("noise", 0.0) - self.RPST = self.match_attributes['game'].RPST() + self.RPST = self.match_attributes["game"].RPST() def _update_single_ewma(self, state_ewma: list[float], action_val: float) -> float: """Updates the (numerator, denominator) pair in-place and returns the new average.""" @@ -236,7 +233,8 @@ def _get_state_idx(self, opponent) -> int: def update(self, opponent: Player) -> float: """Updates the opponent model and our policy. - Returns our expected reward per step.""" + Returns our expected reward per step. + """ self._update_opponent_model(opponent) state_idx = self._get_state_idx(opponent) expected, my_policy = optimize_against(self.opp_model, @@ -298,8 +296,8 @@ def set_seed(self, seed: int = None): def receive_match_attributes(self): super().receive_match_attributes() - self.RPST = self.match_attributes['game'].RPST() - self.noise = self.match_attributes['noise'] + self.RPST = self.match_attributes["game"].RPST() + self.noise = self.match_attributes["noise"] self.iso_instance.noise = self.noise def _update_reward_history(self, opponent): From 63aadf30666488f69a8ff162d119c42c46b8d85d Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:55:07 +0000 Subject: [PATCH 18/31] Remove obsolete test --- .../tests/strategies/test_cooperate_iso.py | 27 ------------------- 1 file changed, 27 deletions(-) diff --git a/axelrod/tests/strategies/test_cooperate_iso.py b/axelrod/tests/strategies/test_cooperate_iso.py index 3f38d39af..01d2d2aa6 100644 --- a/axelrod/tests/strategies/test_cooperate_iso.py +++ b/axelrod/tests/strategies/test_cooperate_iso.py @@ -148,32 +148,6 @@ def test_update_opponent_model(self): expected_mean = 0.99 / 1.99 self.assertAlmostEqual(player.opp_model[0], expected_mean, places=4) - @patch("axelrod.strategies.cooperate_iso.optimize_against") - def test_strategy_with_mocked_optimizer(self, mock_optimize): - """ - Tests the strategy execution loop deterministically by patching - out the PyTorch optimization step. - """ - # We force the optimizer to return absolute 1.0 (C) or 0.0 (D) policies. - # Policy structure: [P(C|CC), P(C|CD), P(C|DC), P(C|DD)] - # We will make it always cooperate after CC, and always defect otherwise. - mock_optimize.return_value = (3.0, [1.0, 0.0, 0.0, 0.0]) - - # Because we return absolute probabilities, np.random.uniform() < pr_c - # becomes strictly deterministic. - expected = [ - (C, C), # T1: No history -> Defaults to CC (idx 0) -> policy[0] is 1.0 (Plays C) - (C, D), # T2: T1 was (C, C) -> state CC (idx 0) -> policy[0] is 1.0 (Plays C) - (D, D), # T3: T2 was (C, D) -> state CD (idx 1) -> policy[1] is 0.0 (Plays D) - (D, C), # T4: T3 was (D, D) -> state DD (idx 3) -> policy[3] is 0.0 (Plays D) - ] - - self.versus_test( - opponent=axl.MockPlayer(actions=[C, D, D, C]), - expected_actions=expected, - match_attributes={"noise": 0.1} - ) - def test_vs_random_defects(self): """ISO should learn to defect against a random player.""" player = self.player() @@ -187,7 +161,6 @@ def test_vs_random_defects(self): self.assertEqual(player.history[-1], D) - def test_vs_tit_for_tat_with_noise_cooperates(self): """Against TitForTat under noise, ISO should learn that cooperation avoids retaliation.""" player = self.player() From 6889fdf67d79ca10d3c5f12a3f766bc3982c1935 Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:13:18 +0000 Subject: [PATCH 19/31] Format code with Black --- axelrod/strategies/cooperate_iso.py | 81 ++++++++++------- .../tests/strategies/test_cooperate_iso.py | 86 ++++++++++--------- 2 files changed, 97 insertions(+), 70 deletions(-) diff --git a/axelrod/strategies/cooperate_iso.py b/axelrod/strategies/cooperate_iso.py index afb9fdc50..2d9555cec 100644 --- a/axelrod/strategies/cooperate_iso.py +++ b/axelrod/strategies/cooperate_iso.py @@ -5,9 +5,9 @@ from scipy.optimize import minimize - C, D = Action.C, Action.D + class LongtermTfT(Player): """Noise-tolerant Tit-for-Tat. @@ -24,6 +24,7 @@ class LongtermTfT(Player): Names: - Longterm TFT: [Hutter2023]_ """ + name = "LongtermTfT" classifier = { "memory_depth": float("inf"), @@ -39,7 +40,7 @@ def __init__(self): super().__init__() self.n_tft_would_c = 0 self.n_d_when_tft_would_c = 0 - self.z = 0. + self.z = 0.0 def receive_match_attributes(self): self.noise = self.match_attributes.get("noise", 0.0) @@ -54,17 +55,23 @@ def strategy(self, opponent: Player) -> Action: if opponent.history[-1] == D: self.n_d_when_tft_would_c += 1 n_expected_ds = self.n_tft_would_c * self.noise - std_expected_ds = np.sqrt(self.noise * (1-self.noise) * self.n_tft_would_c) + std_expected_ds = np.sqrt( + self.noise * (1 - self.noise) * self.n_tft_would_c + ) # This becomes n_d_when_tft_would_c for noise->0 - self.z = (self.n_d_when_tft_would_c - n_expected_ds) / max(1., std_expected_ds) + self.z = (self.n_d_when_tft_would_c - n_expected_ds) / max( + 1.0, std_expected_ds + ) if self.n_tft_would_c >= 5 and self.z < 2: return C else: # TfT return opponent.history[-1] + # We describe memory-1 strategies as length-4 arrays, quantifying the probability of cooperation in the states [CC, CD, DC, DD]. + def get_reward( my_strategy: np.ndarray, opp_strategy: np.ndarray, @@ -86,12 +93,14 @@ def get_reward( opp = opp_strategy[[0, 2, 1, 3]] # Build the transition matrix. - trans_mat = np.array([ - own * opp, - own * (1.0 - opp), - (1.0 - own) * opp, - (1.0 - own) * (1.0 - opp) - ]).T + trans_mat = np.array( + [ + own * opp, + own * (1.0 - opp), + (1.0 - own) * opp, + (1.0 - own) * (1.0 - opp), + ] + ).T R, P, S, T = RPST rewards = np.array([R, S, T, P], dtype=float) @@ -105,6 +114,7 @@ def get_reward( # Avg. reward per step return p_end * float(reward) / (1.0 - p_end) + def optimize_against( opponent: np.ndarray, init_state_idx: int, @@ -132,16 +142,13 @@ def objective(params: np.ndarray) -> float: x0 = np.array([0.5, 0.5, 0.5, 0.5]) bounds = [(0.0, 1.0), (0.0, 1.0), (0.0, 1.0), (0.0, 1.0)] result = minimize( - objective, - x0, - method="L-BFGS-B", - bounds=bounds, - options={"maxiter": 50} + objective, x0, method="L-BFGS-B", bounds=bounds, options={"maxiter": 50} ) # result.fun is the minimum loss (-reward), result.x are the optimal parameters return -result.fun, result.x + class ISO(Player): """Optimal response against a memory-1 opponent model. @@ -158,6 +165,7 @@ class ISO(Player): Names: - ISO: [Hutter2023]_ """ + name = "ISO" classifier = { "memory_depth": float("inf"), @@ -189,7 +197,9 @@ def receive_match_attributes(self): self.noise = self.match_attributes.get("noise", 0.0) self.RPST = self.match_attributes["game"].RPST() - def _update_single_ewma(self, state_ewma: list[float], action_val: float) -> float: + def _update_single_ewma( + self, state_ewma: list[float], action_val: float + ) -> float: """Updates the (numerator, denominator) pair in-place and returns the new average.""" state_ewma[0] = self.discount_factor * state_ewma[0] + action_val state_ewma[1] = self.discount_factor * state_ewma[1] + 1.0 @@ -237,11 +247,13 @@ def update(self, opponent: Player) -> float: """ self._update_opponent_model(opponent) state_idx = self._get_state_idx(opponent) - expected, my_policy = optimize_against(self.opp_model, - init_state_idx=state_idx, - p_noise=self.noise, - RPST=self.RPST, - p_end=1e-2) + expected, my_policy = optimize_against( + self.opp_model, + init_state_idx=state_idx, + p_noise=self.noise, + RPST=self.RPST, + p_end=1e-2, + ) self.my_policy = my_policy return expected @@ -254,6 +266,7 @@ def strategy(self, opponent: Player) -> Action: _ = self.update(opponent) return self.act(opponent) + class CooperateISO(Player): """Forgiving cooperation combined with optimal exploitation. @@ -267,6 +280,7 @@ class CooperateISO(Player): Names: - CooperateISO: [Hutter2023]_ """ + name = "CooperateISO" classifier = { "memory_depth": float("inf"), @@ -283,10 +297,10 @@ def __init__(self): super().__init__() self.n_tft_would_c = 0 self.n_d_when_tft_would_c = 0 - self.z = 0. + self.z = 0.0 # Estimate of the opponent's rate of playing D after C, taking noise # into account. - self.opp_pr_d_after_c = 0. + self.opp_pr_d_after_c = 0.0 self.playing_iso = False self.reward_history = [] @@ -327,19 +341,28 @@ def strategy(self, opponent: Player) -> Action: if opponent.history[-1] == D: self.n_d_when_tft_would_c += 1 n_expected_ds = self.n_tft_would_c * self.noise - std_expected_ds = np.sqrt(self.noise * (1-self.noise) * self.n_tft_would_c) + std_expected_ds = np.sqrt( + self.noise * (1 - self.noise) * self.n_tft_would_c + ) # This becomes n_d_when_tft_would_c for noise->0 - self.z = (self.n_d_when_tft_would_c - n_expected_ds) / max(1., std_expected_ds) + self.z = (self.n_d_when_tft_would_c - n_expected_ds) / max( + 1.0, std_expected_ds + ) # Should we start playing ISO? R, P, _, _ = self.RPST expected_gain = expected - np.mean(self.reward_history) - if len(self.reward_history) >= 10 and \ - expected_gain > 2. * np.std(self.reward_history) / np.sqrt(len(self.reward_history)) and \ - expected_gain > 0.05 * (R - P): + if ( + len(self.reward_history) >= 10 + and expected_gain + > 2.0 + * np.std(self.reward_history) + / np.sqrt(len(self.reward_history)) + and expected_gain > 0.05 * (R - P) + ): self.playing_iso = True return self.iso_instance.act(opponent) if self.n_tft_would_c >= 5 and self.z < 2: return C else: # TfT - return opponent.history[-1] \ No newline at end of file + return opponent.history[-1] diff --git a/axelrod/tests/strategies/test_cooperate_iso.py b/axelrod/tests/strategies/test_cooperate_iso.py index 01d2d2aa6..f4c55aeb6 100644 --- a/axelrod/tests/strategies/test_cooperate_iso.py +++ b/axelrod/tests/strategies/test_cooperate_iso.py @@ -9,10 +9,11 @@ C, D = Action.C, Action.D + class TestLongtermTfT(TestPlayer): name = "LongtermTfT" player = LongtermTfT - + expected_classifier = { "memory_depth": float("inf"), "stochastic": True, @@ -25,7 +26,7 @@ class TestLongtermTfT(TestPlayer): def test_early_rounds_tit_for_tat(self): """ - Tests that the strategy strictly defaults to Tit-for-Tat + Tests that the strategy strictly defaults to Tit-for-Tat when the threshold conditions (n_tft_would_c < 5) are active. """ # (Player Action, Opponent Action) @@ -36,16 +37,16 @@ def test_early_rounds_tit_for_tat(self): (D, C), # T4: Mirrors Opponent's T3 (D) (C, C), # T5: Mirrors Opponent's T4 (C) ] - + self.versus_test( opponent=axl.MockPlayer(actions=[C, D, D, C, C]), expected_actions=expected, - match_attributes={"noise": 0.1} + match_attributes={"noise": 0.1}, ) def test_forgiveness_and_z_score_retaliation(self): """ - Tests the transition from TfT to the forgiving Z-score phase, + Tests the transition from TfT to the forgiving Z-score phase, and verifies that it retaliates when Z >= 2. """ # (Player Action, Opponent Action) @@ -55,33 +56,35 @@ def test_forgiveness_and_z_score_retaliation(self): (C, C), # T3: n_c=1, n_d=0 -> TfT (plays C) (C, C), # T4: n_c=2, n_d=0 -> TfT (plays C) (C, C), # T5: n_c=3, n_d=0 -> TfT (plays C) - # --- Z-Score Phase Begins (n_c reaches 4, about to be 5) --- (C, D), # T6: n_c=4, n_d=0 -> TfT (plays C). Opp defects. - # Opponent defected, but Z-score is low (Z=0.5), so player forgives. - (C, D), # T7: n_c=5, n_d=1 -> Forgives (plays C). Opp defects again. - + ( + C, + D, + ), # T7: n_c=5, n_d=1 -> Forgives (plays C). Opp defects again. # Z-score climbs (Z=1.4) but stays < 2. - (C, D), # T8: n_c=6, n_d=2 -> Forgives (plays C). Opp defects 3rd time. - + ( + C, + D, + ), # T8: n_c=6, n_d=2 -> Forgives (plays C). Opp defects 3rd time. # Z-score hits Z=2.3 (>= 2). Strategy falls back to TfT and retaliates. (D, C), # T9: n_c=7, n_d=3 -> Retaliates (plays D). Opp plays C. - # Mirroring Opponent's C from T9 (Z=2.2, TfT mode). (C, C), # T10: n_c=8, n_d=3 -> TfT (plays C). ] - + self.versus_test( opponent=axl.MockPlayer(actions=[C, C, C, C, C, D, D, D, C, C]), expected_actions=expected, - match_attributes={"noise": 0.1} + match_attributes={"noise": 0.1}, ) + class TestISO(TestPlayer): name = "ISO" player = ISO - + expected_classifier = { "memory_depth": float("inf"), "stochastic": True, @@ -96,10 +99,10 @@ def test_get_state_idx(self): """Unit test for the state indexing logic mapping history to 0,1,2,3.""" player = self.player() opponent = axl.MockPlayer(actions=[C, D, C, D]) - + # T1: No history -> Defaults to 0 (CC) self.assertEqual(player._get_state_idx(opponent), 0) - + # T2: CC # History.append(play, coplay) player.history.append(C, C) @@ -110,7 +113,7 @@ def test_get_state_idx(self): player.history.append(C, D) opponent.history.append(D, C) self.assertEqual(player._get_state_idx(opponent), 1) - + # T4: DC player.history.append(D, C) opponent.history.append(C, D) @@ -125,25 +128,25 @@ def test_update_opponent_model(self): """Unit test for the discounted moving average calculation.""" player = self.player() opponent = axl.MockPlayer() - + # Turn 1: Both played C # history.append(action, coplay) player.history.append(C, C) opponent.history.append(C, C) - + # Turn 2: Player played C, Opponent played D player.history.append(C, D) opponent.history.append(D, C) - + player._update_opponent_model(opponent) - + # Check EWMA accumulator state [numerator, denominator] for CC # Initial state was [1.0, 1.0]; after seeing D (0.0): # num = 0.99 * 1.0 + 0.0 = 0.99 # den = 0.99 * 1.0 + 1.0 = 1.99 self.assertAlmostEqual(player.ewma_CC[0], 0.99, places=6) self.assertAlmostEqual(player.ewma_CC[1], 1.99, places=6) - + # Check discount logic: mean = num / den expected_mean = 0.99 / 1.99 self.assertAlmostEqual(player.opp_model[0], expected_mean, places=4) @@ -152,32 +155,33 @@ def test_vs_random_defects(self): """ISO should learn to defect against a random player.""" player = self.player() opponent = axl.Random() - + match = axl.Match([player, opponent], turns=200, noise=0.05, seed=42) match.play() for pr_c in player.my_policy: self.assertLess(pr_c, 0.1), player.my_policy - + self.assertEqual(player.history[-1], D) def test_vs_tit_for_tat_with_noise_cooperates(self): - """Against TitForTat under noise, ISO should learn that cooperation avoids retaliation.""" + """Against TitForTat under noise, ISO should learn that cooperation avoids retaliation.""" player = self.player() opponent = axl.TitForTat() - + match = axl.Match([player, opponent], turns=200, noise=0.05, seed=42) match.play() for pr_c in player.my_policy: - self.assertGreater(pr_c, 0.9), player.my_policy + self.assertGreater(pr_c, 0.9), player.my_policy self.assertEqual(player.history[-1], C) + class TestCooperateISO(TestPlayer): name = "CooperateISO" player = CooperateISO - + expected_classifier = { "memory_depth": float("inf"), "stochastic": True, @@ -191,7 +195,7 @@ class TestCooperateISO(TestPlayer): def test_update_reward_history(self): """Unit test for ensuring the reward history accurately maps RPST to match states.""" player = self.player() - player.RPST = (3, 1, 0, 5) + player.RPST = (3, 1, 0, 5) opponent = axl.MockPlayer() # Turn 1: Mutual Cooperation (C, C) -> Should append R (3) @@ -227,7 +231,7 @@ def test_maintains_tft_when_iso_not_profitable(self, mock_act, mock_update): """ # ISO update always returns 0.0 (highly unprofitable) mock_update.return_value = 0.0 - + expected = [ (C, C), # T1: No history, defaults to C (C, D), # T2: Mirrors Opponent's T1 (C) @@ -235,11 +239,11 @@ def test_maintains_tft_when_iso_not_profitable(self, mock_act, mock_update): (D, C), # T4: Mirrors Opponent's T3 (D) (C, C), # T5: Mirrors Opponent's T4 (C) ] - + self.versus_test( opponent=axl.MockPlayer(actions=[C, D, D, C, C]), expected_actions=expected, - match_attributes={"noise": 0.0, "game": axl.DefaultGame} + match_attributes={"noise": 0.0, "game": axl.DefaultGame}, ) # Because we never switched to ISO, act() should never have been called mock_act.assert_not_called() @@ -253,33 +257,33 @@ def test_switches_to_iso_when_profitable(self, mock_act, mock_update): """ # We will mock ISO to return D whenever it acts mock_act.return_value = D - - # We play 11 rounds. + + # We play 11 rounds. # Turns 1-10: ISO predicts 3.0 (same as average for mutual cooperation, so expected_gain = 0) # Turn 11: ISO suddenly predicts 5.0. expected_gain (2.0) crosses the threshold. mock_update.side_effect = [3.0] * 9 + [5.0] # T1 to T10: Mutual cooperation (LongtermTfT mirroring) expected = [(C, C)] * 10 - + # T11: The threshold is crossed, we switch to ISO, which our mock says will return D expected.append((D, C)) self.versus_test( opponent=axl.MockPlayer(actions=[C] * 11), expected_actions=expected, - match_attributes={"noise": 0.0, "game": axl.DefaultGame} + match_attributes={"noise": 0.0, "game": axl.DefaultGame}, ) - + # Verify ISO took over on the final turn mock_act.assert_called_once() def test_set_seed(self): """Ensures random seeds are passed down to the inner ISO instance.""" player = self.player() - + # Mock the internal ISO instance's set_seed method player.iso_instance.set_seed = MagicMock() - + player.set_seed(42) - player.iso_instance.set_seed.assert_called_once_with(42) \ No newline at end of file + player.iso_instance.set_seed.assert_called_once_with(42) From 56a926d0d7123331fb8436177fb512d4d9f148f3 Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:19:07 +0000 Subject: [PATCH 20/31] Fix import ordering with isort --- axelrod/strategies/cooperate_iso.py | 3 +-- axelrod/tests/strategies/test_cooperate_iso.py | 5 +++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/axelrod/strategies/cooperate_iso.py b/axelrod/strategies/cooperate_iso.py index 2d9555cec..d5f3a798a 100644 --- a/axelrod/strategies/cooperate_iso.py +++ b/axelrod/strategies/cooperate_iso.py @@ -1,10 +1,9 @@ import numpy as np +from scipy.optimize import minimize from axelrod.action import Action from axelrod.player import Player -from scipy.optimize import minimize - C, D = Action.C, Action.D diff --git a/axelrod/tests/strategies/test_cooperate_iso.py b/axelrod/tests/strategies/test_cooperate_iso.py index f4c55aeb6..1d2735bd4 100644 --- a/axelrod/tests/strategies/test_cooperate_iso.py +++ b/axelrod/tests/strategies/test_cooperate_iso.py @@ -1,11 +1,12 @@ import random +from unittest.mock import MagicMock, patch + import numpy as np import axelrod as axl from axelrod.action import Action +from axelrod.strategies.cooperate_iso import ISO, CooperateISO, LongtermTfT from axelrod.tests.strategies.test_player import TestPlayer -from axelrod.strategies.cooperate_iso import LongtermTfT, ISO, CooperateISO -from unittest.mock import patch, MagicMock C, D = Action.C, Action.D From 06628f843d57359ba46d461cd74e9abe11b505eb Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:30:12 +0000 Subject: [PATCH 21/31] Trigger CI From 1636a7cce5696311fe332236a6d1674c7f795d0c Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:38:59 +0000 Subject: [PATCH 22/31] Add strategies to `all_strategies` --- axelrod/strategies/_strategies.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/axelrod/strategies/_strategies.py b/axelrod/strategies/_strategies.py index 506eedbcc..4203fdf87 100644 --- a/axelrod/strategies/_strategies.py +++ b/axelrod/strategies/_strategies.py @@ -317,6 +317,7 @@ CautiousQLearner, CollectiveStrategy, ContriteTitForTat, + CooperateISO, Cooperator, CooperatorHunter, CycleHunter, @@ -398,11 +399,13 @@ Hopeless, Inverse, InversePunisher, + ISO, KnowledgeableWorseAndWorse, LevelPunisher, LimitedRetaliate, LimitedRetaliate2, LimitedRetaliate3, + LongtermTfT, MEM2, MathConstantHunter, Michaelos, From 752a2f6abd80e93295b220657bd5248e264bb9b7 Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:51:19 +0000 Subject: [PATCH 23/31] Add to strategy_index.rst --- docs/reference/strategy_index.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/reference/strategy_index.rst b/docs/reference/strategy_index.rst index 9764d3082..d1dc70806 100644 --- a/docs/reference/strategy_index.rst +++ b/docs/reference/strategy_index.rst @@ -34,6 +34,8 @@ Here are the docstrings of all the strategies in the library. :members: .. automodule:: axelrod.strategies.calculator :members: +.. automodule:: axelrod.strategies.cooperate_iso + :members: .. automodule:: axelrod.strategies.cooperator :members: .. automodule:: axelrod.strategies.cycler From c4bbfe213ed21f5a39ddc8780ed1960418514b14 Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:36:39 +0000 Subject: [PATCH 24/31] Add ISO and CooperateISO to long_run_strategies --- axelrod/tests/unit/test_classification.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/axelrod/tests/unit/test_classification.py b/axelrod/tests/unit/test_classification.py index 758cfb4c4..7da99b389 100644 --- a/axelrod/tests/unit/test_classification.py +++ b/axelrod/tests/unit/test_classification.py @@ -302,8 +302,10 @@ def test_inclusion_of_strategy_lists(self): def test_long_run_strategies(self): long_run_time_strategies = [ + axl.CooperateISO, axl.DBS, axl.EvolvedAttention, + axl.ISO, axl.MetaMajority, axl.MetaMajorityFiniteMemory, axl.MetaMajorityLongMemory, From 28014371cd1e4cb71cbb4c4bf76ff805cb3ad008 Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:08:54 +0000 Subject: [PATCH 25/31] Update strategy counts --- axelrod/strategies/cooperate_iso.py | 2 +- axelrod/tests/strategies/test_cooperate_iso.py | 2 +- docs/how-to/classify_strategies.rst | 2 +- docs/index.rst | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/axelrod/strategies/cooperate_iso.py b/axelrod/strategies/cooperate_iso.py index d5f3a798a..bacd7a7cb 100644 --- a/axelrod/strategies/cooperate_iso.py +++ b/axelrod/strategies/cooperate_iso.py @@ -27,7 +27,7 @@ class LongtermTfT(Player): name = "LongtermTfT" classifier = { "memory_depth": float("inf"), - "stochastic": True, + "stochastic": False, "makes_use_of": {"noise"}, "long_run_time": False, "inspects_source": False, diff --git a/axelrod/tests/strategies/test_cooperate_iso.py b/axelrod/tests/strategies/test_cooperate_iso.py index 1d2735bd4..8f1fb0c90 100644 --- a/axelrod/tests/strategies/test_cooperate_iso.py +++ b/axelrod/tests/strategies/test_cooperate_iso.py @@ -17,7 +17,7 @@ class TestLongtermTfT(TestPlayer): expected_classifier = { "memory_depth": float("inf"), - "stochastic": True, + "stochastic": False, "makes_use_of": {"noise"}, "long_run_time": False, "inspects_source": False, diff --git a/docs/how-to/classify_strategies.rst b/docs/how-to/classify_strategies.rst index c529ebc67..e105d6a0f 100644 --- a/docs/how-to/classify_strategies.rst +++ b/docs/how-to/classify_strategies.rst @@ -57,7 +57,7 @@ strategies:: ... } >>> strategies = axl.filtered_strategies(filterset) >>> len(strategies) - 88 + 90 Or, to find out how many strategies only use 1 turn worth of memory to make a decision:: diff --git a/docs/index.rst b/docs/index.rst index 82b9f41b5..fc35607ac 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -53,7 +53,7 @@ Count the number of available players:: >>> import axelrod as axl >>> len(axl.strategies) - 243 + 246 Create matches between two players:: From 3e779d63af958106c331ac78565c8d8634293eec Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:19:10 +0000 Subject: [PATCH 26/31] Achieve 100% test coverage --- .../tests/strategies/test_cooperate_iso.py | 45 ++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/axelrod/tests/strategies/test_cooperate_iso.py b/axelrod/tests/strategies/test_cooperate_iso.py index 8f1fb0c90..f8e0d4508 100644 --- a/axelrod/tests/strategies/test_cooperate_iso.py +++ b/axelrod/tests/strategies/test_cooperate_iso.py @@ -101,30 +101,35 @@ def test_get_state_idx(self): player = self.player() opponent = axl.MockPlayer(actions=[C, D, C, D]) - # T1: No history -> Defaults to 0 (CC) + # No history -> Defaults to 0 (CC) self.assertEqual(player._get_state_idx(opponent), 0) - # T2: CC + # CC # History.append(play, coplay) player.history.append(C, C) opponent.history.append(C, C) self.assertEqual(player._get_state_idx(opponent), 0) - # T3: CD + # CD player.history.append(C, D) opponent.history.append(D, C) self.assertEqual(player._get_state_idx(opponent), 1) - # T4: DC + # DC player.history.append(D, C) opponent.history.append(C, D) self.assertEqual(player._get_state_idx(opponent), 2) - # T5: DD + # DD player.history.append(D, D) opponent.history.append(D, D) self.assertEqual(player._get_state_idx(opponent), 3) + # Invalid values + player.history.append('C', 'C') + opponent.history.append('C', 'C') + self.assertEqual(player._get_state_idx(opponent), -1) + def test_update_opponent_model(self): """Unit test for the discounted moving average calculation.""" player = self.player() @@ -279,6 +284,36 @@ def test_switches_to_iso_when_profitable(self, mock_act, mock_update): # Verify ISO took over on the final turn mock_act.assert_called_once() + @patch("axelrod.strategies.cooperate_iso.ISO.update") + @patch("axelrod.strategies.cooperate_iso.ISO.act") + @patch("axelrod.strategies.cooperate_iso.ISO.strategy") + def test_continues_playing_iso_on_subsequent_turns( + self, mock_strategy, mock_act, mock_update + ): + """ + Tests that once playing_iso is True, strategy() delegates directly + to self.iso_instance.strategy(opponent) on following turns. + """ + mock_act.return_value = D + mock_strategy.return_value = D + + # 9 turns of 3.0, then 5.0 for turns 10 and 11 + mock_update.side_effect = [3.0] * 9 + [5.0, 5.0] + + # T1 to T10: Mutual cooperation + # T11: Switches to ISO (calls act()) + # T12: Already playing ISO (calls strategy()) + expected = [(C, C)] * 10 + [(D, C), (D, C)] + + self.versus_test( + opponent=axl.MockPlayer(actions=[C] * 12), + expected_actions=expected, + match_attributes={"noise": 0.0, "game": axl.DefaultGame}, + ) + + mock_act.assert_called_once() + mock_strategy.assert_called_once() + def test_set_seed(self): """Ensures random seeds are passed down to the inner ISO instance.""" player = self.player() From 1bccc4d03c3809edfe6e51bc0298ac6bb98f200b Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:39:26 +0000 Subject: [PATCH 27/31] Formatting --- axelrod/tests/strategies/test_cooperate_iso.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/axelrod/tests/strategies/test_cooperate_iso.py b/axelrod/tests/strategies/test_cooperate_iso.py index f8e0d4508..c1e0ea933 100644 --- a/axelrod/tests/strategies/test_cooperate_iso.py +++ b/axelrod/tests/strategies/test_cooperate_iso.py @@ -126,8 +126,8 @@ def test_get_state_idx(self): self.assertEqual(player._get_state_idx(opponent), 3) # Invalid values - player.history.append('C', 'C') - opponent.history.append('C', 'C') + player.history.append("C", "C") + opponent.history.append("C", "C") self.assertEqual(player._get_state_idx(opponent), -1) def test_update_opponent_model(self): From 423ccbc082b76fe5b4279af016cfa0c60d1104c6 Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:43:16 +0000 Subject: [PATCH 28/31] Add scipy to requirements --- docs/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/requirements.txt b/docs/requirements.txt index 0f4be075a..1fccb64a7 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,5 @@ docutils>=0.18.1 numpy==1.24.3 # numpy isn't mocked due to complex use in doctests mock>=5.1.0 +scipy>=1.3.3 torch>=2.6.0 \ No newline at end of file From 5948f2f2a5ee4cf68fbc2c67411c27a23ee2f775 Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:07:18 +0000 Subject: [PATCH 29/31] Fix sphinx issues --- docs/conf.py | 9 ++------- docs/requirements.txt | 2 ++ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index c632251b3..edc20d0c9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -17,6 +17,7 @@ import sys import mock +import sphinx_rtd_theme MOCK_MODULES = [ "dask", @@ -126,13 +127,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -on_rtd = os.environ.get("READTHEDOCS", None) == "True" - -if not on_rtd: # only import and set the theme if we're building docs locally - import sphinx_rtd_theme - - html_theme = "sphinx_rtd_theme" - html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] +html_theme = "sphinx_rtd_theme" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the diff --git a/docs/requirements.txt b/docs/requirements.txt index 1fccb64a7..10f0bbf57 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,5 @@ +sphinx>=7.0.0,<9.0.0 +sphinx-rtd-theme>=2.0.0 docutils>=0.18.1 numpy==1.24.3 # numpy isn't mocked due to complex use in doctests mock>=5.1.0 From f29170139984d44f4a5ba66b2c77b628036ae7e1 Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:28:49 +0000 Subject: [PATCH 30/31] Remove scipy from MOCK_MODULES --- docs/conf.py | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index edc20d0c9..fc0b203d0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -34,7 +34,6 @@ "prompt_toolkit.styles", "prompt_toolkit.token", "prompt_toolkit.validation", - "scipy", "scipy.stats", "tqdm", "yaml", From 2ac13294b5fae23802e91ef268197300deaa54bc Mon Sep 17 00:00:00 2001 From: Adrian Hutter <37598197+adrianhutter@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:04:16 +0000 Subject: [PATCH 31/31] Update number of long-running strategies --- docs/how-to/classify_strategies.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/how-to/classify_strategies.rst b/docs/how-to/classify_strategies.rst index e105d6a0f..3fe28e57a 100644 --- a/docs/how-to/classify_strategies.rst +++ b/docs/how-to/classify_strategies.rst @@ -110,7 +110,7 @@ Some strategies have been classified as having a particularly long run time:: ... } >>> strategies = axl.filtered_strategies(filterset) >>> len(strategies) - 19 + 21 Strategies that :code:`manipulate_source`, :code:`manipulate_state` and/or :code:`inspect_source` return :code:`False` for the