In death-birth mode, MoranProcess._matchup_indices() (axelrod/moran.py:349-366) builds pairs from two different index spaces, so score_all() plays and credits the wrong players. Nothing raises, which is probably why it went unnoticed. Tested on master @ 153335c (axelrod 4.14.0, Python 3.14).
if self.mode == "db":
source = self.index[self.dead] # :352 self.dead is a player index, not a vertex
sources = sorted(self.interaction_graph.out_vertices(source))
else:
sources = sorted(self.locations)
for i, source in enumerate(sources): # :357 i = position in the neighbour list
for target in sorted(self.interaction_graph.out_vertices(source)):
j = self.index[target] # :359 j = global player index
...
indices.add((i, j))
In bd mode sources is every vertex, so the enumerate() counter happens to equal self.index[source]. In db mode sources is only the dead node's neighbours, so i is wrong.
Reproduction
import axelrod as axl
players = [axl.Cooperator(), axl.Defector(), axl.TitForTat(),
axl.Cooperator(), axl.Defector()]
mp = axl.MoranProcess(players, mode="db", seed=1,
interaction_graph=axl.graph.cycle(5))
# state that __next__() creates before score_all()
mp.dead = 2
mp.players[2] = None
print(mp._matchup_indices())
print(mp.score_all())
Actual:
{(1, 4), (0, 0)}
[6.0, 1.0, 0, 0, 1.0]
Expected (the dead node's neighbours are 1 and 3; on the cycle they play 0 and 4 respectively):
{(1, 0), (3, 4)}
[0.0, 5.0, 0, 0.0, 5.0]
Player 0 is matched against itself (the cycle has no loops) and becomes the fittest individual of the round with 6.0, while the edges 1-0 and 3-4 are never played. Every dead node on this cycle gives a wrong set, and so do 3 of the 5 dead nodes on the default complete graph (MoranProcess(players, mode="db") with no graph), where the output also contains self-pairs. On this 5-player cycle, 24 of 30 seeds give a different trajectory once the index is fixed, and 11 a different fixation winner.
Second symptom, same root cause
Line 352 looks the player index self.dead up in self.index, which maps vertex → player. That only works when vertices are labelled 0..N-1. On any other labelling, db raises while bd on the same graph is fine:
g = axl.graph.Graph([(10, 11), (11, 12), (12, 13), (13, 14), (14, 10)], directed=False)
next(axl.MoranProcess(players, mode="bd", seed=1, interaction_graph=g)) # fine
next(axl.MoranProcess(players, mode="db", seed=1, interaction_graph=g)) # KeyError: 4
The library's own axl.graph.attached_complete_graphs(...) (string vertex labels) hits the same KeyError in db mode.
Suggested fix
if self.mode == "db":
- source = self.index[self.dead]
+ source = self.locations[self.dead]
sources = sorted(self.interaction_graph.out_vertices(source))
else:
# birth-death is global
sources = sorted(self.locations)
- for i, source in enumerate(sources):
+ for source in sources:
+ i = self.index[source]
for target in sorted(self.interaction_graph.out_vertices(source)):
For bd mode this is a no-op. With it applied, test_moran.py still passes (43 passed), and the reproduction above gives the expected output.
Why the tests don't catch it
test_matchup_indices is the only test that checks pair contents, and it only uses bd. The db tests either use 2–3 players on the complete graph, where the correct and the buggy pair sets happen to coincide, or only compare the winners of bd and db for fixed seeds. A regression test for db pairs on cycle(5) with every dead node could be:
EXPECTED = {0: {(1, 2), (4, 3)}, 1: {(0, 4), (2, 3)}, 2: {(1, 0), (3, 4)},
3: {(2, 1), (4, 0)}, 4: {(0, 1), (3, 2)}}
def test_death_birth_matchup_indices(self):
for dead, expected in EXPECTED.items():
players = [axl.Cooperator(), axl.Defector(), axl.TitForTat(),
axl.Cooperator(), axl.Defector()]
mp = MoranProcess(players, mode="db", seed=1,
interaction_graph=axl.graph.cycle(5))
mp.dead = dead
mp.players[dead] = None
self.assertEqual(mp._matchup_indices(), expected)
I'm happy to open a PR with the fix and this test if that's helpful.
In death-birth mode,
MoranProcess._matchup_indices()(axelrod/moran.py:349-366) builds pairs from two different index spaces, soscore_all()plays and credits the wrong players. Nothing raises, which is probably why it went unnoticed. Tested onmaster@153335c(axelrod 4.14.0, Python 3.14).In
bdmodesourcesis every vertex, so theenumerate()counter happens to equalself.index[source]. Indbmodesourcesis only the dead node's neighbours, soiis wrong.Reproduction
Actual:
Expected (the dead node's neighbours are 1 and 3; on the cycle they play 0 and 4 respectively):
Player 0 is matched against itself (the cycle has no loops) and becomes the fittest individual of the round with 6.0, while the edges 1-0 and 3-4 are never played. Every dead node on this cycle gives a wrong set, and so do 3 of the 5 dead nodes on the default complete graph (
MoranProcess(players, mode="db")with no graph), where the output also contains self-pairs. On this 5-player cycle, 24 of 30 seeds give a different trajectory once the index is fixed, and 11 a different fixation winner.Second symptom, same root cause
Line 352 looks the player index
self.deadup inself.index, which maps vertex → player. That only works when vertices are labelled0..N-1. On any other labelling,dbraises whilebdon the same graph is fine:The library's own
axl.graph.attached_complete_graphs(...)(string vertex labels) hits the sameKeyErrorindbmode.Suggested fix
For
bdmode this is a no-op. With it applied,test_moran.pystill passes (43 passed), and the reproduction above gives the expected output.Why the tests don't catch it
test_matchup_indicesis the only test that checks pair contents, and it only usesbd. Thedbtests either use 2–3 players on the complete graph, where the correct and the buggy pair sets happen to coincide, or only compare the winners ofbdanddbfor fixed seeds. A regression test fordbpairs oncycle(5)with every dead node could be:I'm happy to open a PR with the fix and this test if that's helpful.