Skip to content

MoranProcess death-birth mode pairs and scores the wrong players: _matchup_indices() mixes neighbour-list positions with global player indices #1499

Description

@faithpricejp-source

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions