Skip to content

Iterated prisoner's dilemma tournament

The evolution of strategies of the iterated prisoner's dilemma from random genomes.

  • Name: prisoners_dilemma_tournament
  • Field: social/cooperation
  • Formalism: agent-based model
  • References: Axelrod 1984; Downey 2018

The prisoners_dilemma_tournament example

Example

Factory

@register_model(
    field='social/cooperation',
    tags=('agents', 'games', 'cooperation', 'evolution'),
    references=('Axelrod 1984', 'Downey 2018'),
    defaults={'until': 50},
)
def prisoners_dilemma_tournament(
    n_agents: int = 100,
    *,
    rounds: int = 6,
    death: float = 0.1,
    mutation: float = 0.05,
    random_state: int | np.random.Generator | None = 0,
) -> System:
    """Return the evolution of strategies of the iterated prisoner's dilemma from random genomes.

    Args:
        n_agents:
            The number of agents.

        rounds:
            The number of rounds of every game.

        death:
            The probability that an agent dies every step.

        mutation:
            The probability that an offspring has one gene flipped.

        random_state:
            The seed or generator of the initial genomes.

    Returns:
        system:
            The model, with the group `agents`, the tournament `tournament`, and the death-birth replacement
            `selection`.

    Examples:
        >>> from skcomplex.models.social.cooperation import prisoners_dilemma_tournament
        >>> prisoners_dilemma_tournament(10, random_state=0).elements[0].check().prepare().size_
        10
    """
    genomes = np.random.default_rng(random_state).integers(0, 2, (n_agents, GENES)).astype(float)
    visual = VisualSpec('histograms', {'color_by': 'fitness'})
    agents = GroupElement(
        'agents',
        element=StrategistElement,
        size=n_agents,
        values={'genome': genomes},
        visual=visual,
    )
    return System(
        elements=[agents],
        interactions=[
            TournamentInteraction('tournament', target='agents', rounds=rounds),
            EvolutionInteraction(
                'selection',
                target='agents',
                strategy='genome',
                selection='proportional',
                replacement='death-birth',
                mutation=mutation,
                death=death,
                change='one',
                parents='all',
            ),
        ],
    )

Components

class StrategistElement(Element):
    """Agent of the prisoner's dilemma tournament, with a genome of responses and the fitness of its last games.

    Args:
        label:
            The label of the agent or group.

        visual:
            How the agent is drawn, or `None` for `default_visual_`.

        genome:
            The seven moves, 1 to cooperate and 0 to defect, for the opponent's last two moves: none, C, D, CC, CD,
            DC, and DD.

        fitness:
            The mean score per round of the last tournament.

    Examples:
        >>> from skcomplex.core.elements import GroupElement
        >>> from skcomplex.models.social.cooperation import StrategistElement
        >>> group = GroupElement('agents', element=StrategistElement, size=10, values={'genome': [1.0] * 7})
        >>> group.label, group.element.__name__, group.size
        ('agents', 'StrategistElement', 10)
    """

    def __init__(
        self,
        label: str,
        visual: VisualSpec | None = None,
        *,
        genome: Annotated[npt.ArrayLike, StateSpec(vector=True)] = 1.0,
        fitness: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 0.0,
    ) -> None:
        super().__init__(label, visual=visual)
        self.genome = genome
        self.fitness = fitness

    @classmethod
    def _get_view(cls) -> type[StrategistView]:
        """Return the class of the views of the agents, whose attributes are the states and parameters."""
        return StrategistView

class TournamentInteraction(GroupingInteraction):
    """Tournament of strategies of the iterated prisoner's dilemma.

    Every step the agents stand in a random ring, and every agent forms a group with its two neighbours in the ring,
    whom it plays. Its fitness is its mean score per round.

    Args:
        label:
            The label of the interaction.

        target:
            The path of the group of strategists.

        rounds:
            The number of rounds of every game.

        phase:
            The phase of the step the tournament runs in, or `None` for the default phase.

    Examples:
        >>> from skcomplex.models.social.cooperation import TournamentInteraction
        >>> TournamentInteraction('tournament', target='agents').rounds
        6
    """

    def __init__(
        self,
        label: str,
        *,
        target: Annotated[str, RoleSpec()],
        rounds: int = 6,
        phase: str | None = None,
    ) -> None:
        super().__init__(label, target=target, groups='neighborhood', phase=phase)
        self.rounds = rounds

    def _get_groups(self, model: ModelViewProtocol) -> list[list[AgentViewProtocol]]:
        """Return every agent of a random ring followed by its two neighbours in the ring."""
        agents = [agent for agent in model.agents('target') if agent.alive]
        if len(agents) < PAIR:
            return []
        ring = [agents[int(index)] for index in model.rng.permutation(len(agents))]
        return [[agent, ring[position - 1], ring[(position + 1) % len(ring)]] for position, agent in enumerate(ring)]

    def act_on(self, members: list[AgentViewProtocol], model: ModelViewProtocol) -> None:
        """Set the fitness of the first member to its mean score per round against the two others.

        Args:
            members:
                The agent, followed by its two neighbours in the ring.

            model:
                The model view of the rule.
        """
        agent, *opponents = [member for member in members if isinstance(member, StrategistView)]
        total = sum(play(agent.genome, opponent.genome, self.rounds)[0] for opponent in opponents)
        agent.fitness = total / (PAIR * self.rounds)

class EvolutionInteraction(Interaction):
    """Replacement of the strategies of an agent group by offspring selected on a fitness, with crossover and mutation.

    Every `period` steps, or rounds of its phase, the interaction selects parents by tournaments of `tournament`
    entrants, by roulette with weights of the fitness minus its minimum, in proportion to the fitness itself
    (`'proportional'`), or by rank with weights `1` for the least fit to `n` for the fittest of `n` members. The
    `'generational'` replacement selects as many parents as there are members, each pair of which exchanges the tails
    after a random cut with probability `crossover`, and passes the `elite` fittest strategies unchanged. The `'moran'`
    replacement runs as many birth-death events as there are members, one after the other, each copying a selected
    parent onto a member drawn uniformly, and the copy takes the fitness of its parent for the later events of the
    step. The `'death-birth'` replacement removes every member with probability `death` and fills its place with the
    offspring of a parent selected among the survivors, or among all members when none survives or `parents` is
    `'all'`. The `'truncation'` replacement fills the places of the `fraction` least fit members with the offspring of
    parents selected among the others. The `'best-mutant'` replacement lets every member draw a mutant of its strategy,
    and every member takes the fittest mutant when it is fitter than the fittest strategy, or when every member
    mutated, so a population of one strategy climbs a landscape as a whole.

    Offspring mutate: each gene with probability `mutation` changes by the rule `change`, to another symbol of an
    alphabet of `alphabet` symbols (`'other'`), to a symbol drawn anew from the alphabet (`'any'`), one step up or down
    (`'step'`), or to a label one above the largest the members hold (`'new'`), or with probability `mutation` one
    gene drawn at random of every offspring changes to another symbol (`'one'`). When `alphabet` is `None`, the
    `'other'` and `'any'` rules add Gaussian noise of deviation `scale`. A function `mutation(strategy, rng)` returns
    the mutated copy of a strategy instead. The fitness is a state of the members, or the function
    `evaluate(strategies, model)` of the strategies, one row per member, which the replacement then writes into the
    fitness state. The probabilities are parameters of the model when a subclass declares them with a
    `ParameterSpec`, as the base does for `crossover`, `death`, and `fraction`. The draws come from the random stream
    of the model.

    Args:
        label:
            The label of the interaction, unique among the parts of its system.

        target:
            The path of the agent group whose strategies evolve.

        strategy:
            The name of the state of the members that holds the strategy, a sequence of genes, or the names of
            several states, one gene each.

        fitness:
            The name of the state of the members that holds the fitness, or `None` for a neutral population of equal
            fitness when no `evaluate` is given.

        selection:
            The selection of parents, `'tournament'`, `'roulette'`, `'proportional'`, or `'rank'`.

        replacement:
            The replacement of strategies, `'generational'`, `'moran'`, `'death-birth'`, `'truncation'`, or
            `'best-mutant'`.

        crossover:
            The probability that a pair of parents exchanges the tails of their strategies, in the generational
            replacement.

        mutation:
            The probability that a gene mutates, or a function `mutation(strategy, rng)` that returns the mutated copy
            of a strategy.

        elite:
            The number of fittest strategies copied unchanged, in the generational replacement.

        tournament:
            The number of entrants of every tournament.

        alphabet:
            The number of symbols of a discrete strategy, or `None` for a real strategy.

        scale:
            The standard deviation of the mutation of a real strategy.

        death:
            The probability that a member is replaced in a step, in the death-birth replacement.

        change:
            How a gene mutates, `'other'`, `'any'`, `'one'`, `'step'`, or `'new'`.

        parents:
            The members the death-birth replacement selects parents among, `'survivors'` or `'all'`.

        fraction:
            The fraction of least fit members that the truncation replacement replaces.

        period:
            The number of steps, or rounds of the phase, between two replacements.

        evaluate:
            The function `evaluate(strategies, model)` that returns the fitness of every row of strategies, or `None`
            to read the fitness state.

        reset:
            The values of states that every replaced member takes, or `None` for no reset.

        phase:
            The phase of the step the generation runs in, or `None` for the default phase.

    Raises:
        ValidationError:
            A variant is unknown, a probability is outside `[0, 1]`, the mutation is neither a probability nor a
            function, a count is invalid, or the evaluation is not callable.

    Examples:
        >>> from types import SimpleNamespace
        >>> from skcomplex.core.interactions import EvolutionInteraction
        >>> evolution = EvolutionInteraction('evolution', target='agents', selection='roulette', phase='breeding')
        >>> evolution.check().prepare().run(SimpleNamespace()).laws_[0].phase
        'breeding'
        >>> EvolutionInteraction('evolution', target='agents', selection='rank', replacement='moran').check().selection
        'rank'
        >>> EvolutionInteraction(
        ...     'evolution', target='agents', replacement='truncation', fraction=0.2, change='any', period=50
        ... ).check().period
        50
    """

    def __init__(
        self,
        label: str,
        *,
        target: Annotated[str, RoleSpec()],
        strategy: str | Sequence[str] = 'strategy',
        fitness: str | None = 'fitness',
        selection: str = 'tournament',
        replacement: str = 'generational',
        crossover: Annotated[float, ParameterSpec(domain=RealSet.closed(0, 1))] = 0.7,
        mutation: float | Callable[[Array, np.random.Generator], Array] = 0.01,
        elite: int = 0,
        tournament: int = 2,
        alphabet: int | None = 2,
        scale: float = 0.1,
        death: Annotated[float, ParameterSpec(domain=RealSet.closed(0, 1))] = 0.1,
        change: str = 'other',
        parents: str = 'survivors',
        fraction: Annotated[float, ParameterSpec(domain=RealSet.closed(0, 1))] = 0.1,
        period: int = 1,
        evaluate: Callable[[Array, ModelViewProtocol], Array] | None = None,
        reset: Mapping[str, float] | None = None,
        phase: str | None = None,
    ) -> None:
        super().__init__(label)
        self.target = target
        self.strategy = strategy
        self.fitness = fitness
        self.selection = selection
        self.replacement = replacement
        self.crossover = crossover
        self.mutation = mutation
        self.elite = elite
        self.tournament = tournament
        self.alphabet = alphabet
        self.scale = scale
        self.death = death
        self.change = change
        self.parents = parents
        self.fraction = fraction
        self.period = period
        self.evaluate = evaluate
        self.reset = reset
        self.phase = phase

    def _check_counts(self) -> None:
        """Raise an error for a negative elite, a tournament without entrants, an alphabet of one, or no period."""
        if self.elite < 0 or self.tournament < 1:
            error_msg = (
                f'{self.label!r} has elite {self.elite} and tournament {self.tournament}. Give an elite of at least 0 '
                'and a tournament of at least 1.'
            )
            raise ValidationError(error_msg)
        if self.alphabet is not None and self.alphabet < MIN_ALPHABET:
            error_msg = (
                f'{self.label!r} has an alphabet of {self.alphabet} symbols. Give at least {MIN_ALPHABET}, or `None`.'
            )
            raise ValidationError(error_msg)
        if self.period < 1:
            error_msg = f'{self.label!r} has the period {self.period}. Give a period of at least 1.'
            raise ValidationError(error_msg)

    def _check_probabilities(self) -> None:
        """Raise an error for a probability outside `[0, 1]` or a mutation neither a probability nor callable."""
        probabilities = [('crossover', self.crossover), ('death', self.death), ('fraction', self.fraction)]
        if not callable(self.mutation):
            probabilities.append(('mutation', self.mutation))
        for name, value in probabilities:
            if not isinstance(value, int | float) or not 0 <= value <= 1:
                error_msg = f'The {name} probability of {self.label!r} is {value!r}. Give a value in [0, 1].'
                raise ValidationError(error_msg)

    def _check(self) -> None:
        """Raise an error for an unknown variant, a probability outside `[0, 1]`, or an invalid count.

        Raises:
            ValidationError:
                The type declares states, a variant is unknown, a probability is outside `[0, 1]`, the mutation is
                neither a probability nor a function, a count is invalid, or the evaluation is not callable.
        """
        super()._check()
        self._check_variant('selection', self.selection, SELECTIONS, (BaseSpec,))
        self._check_variant('replacement', self.replacement, REPLACEMENTS, (BaseSpec,))
        for name, value, variants in (
            ('change', self.change, CHANGES),
            ('parents', self.parents, PARENTS),
        ):
            self._check_variant(name, value, variants)
        if (isinstance(self.strategy, str) and not self.strategy) or not isinstance(self.strategy, str | Sequence):
            error_msg = f'{self.label!r} has the strategy {self.strategy!r}. Give a state name or a sequence of them.'
            raise ValidationError(error_msg)
        if self.evaluate is not None and not callable(self.evaluate):
            error_msg = f'The evaluation of {self.label!r} must be callable, but it is {self.evaluate!r}.'
            raise ValidationError(error_msg)
        self._check_probabilities()
        self._check_counts()

    def _value(self, name: str, model: ModelViewProtocol) -> float:
        """Return a probability, the parameter of the model when the class declares it as one."""
        value = getattr(self, self._get_parameter(name))
        if self._declares(self._get_parameter(name)) or isinstance(value, int | float):
            return self._get_parameter_value(name, model)
        return 0.0

    def _evaluate(self, strategies: Array, model: ModelViewProtocol) -> Array | None:
        """Return the fitness of every row of strategies by the evaluation, or `None` when there is none.

        Args:
            strategies:
                The strategies, one row per member, in the shape the states hold them.

            model:
                The model view of the interaction.

        Returns:
            fitness:
                The fitness of every row, or `None` to read the fitness state.
        """
        if self.evaluate is None:
            return None
        return np.asarray(self.evaluate(strategies, model), dtype=float).reshape(len(strategies))

    def _evaluate_rows(self, flat: Array, genes: tuple[int, ...], model: ModelViewProtocol) -> Array | None:
        """Return the fitness of flat rows of genes, given back the shape of the states before evaluation."""
        return self._evaluate(flat.reshape(len(flat), *genes), model)

    def _select(self, fitness: Array, count: int, rng: np.random.Generator) -> npt.NDArray[np.intp]:
        """Return the indices of a number of parents selected on their fitness."""
        size = len(fitness)
        if self.selection == 'tournament':
            entrants = rng.integers(size, size=(count, self.tournament))
            return entrants[np.arange(count), np.argmax(fitness[entrants], axis=1)]
        if self.selection == 'rank':
            weights = np.empty(size)
            weights[np.argsort(fitness, kind='stable')] = np.arange(1, size + 1)
        elif self.selection == 'proportional':
            weights = fitness.copy()
        elif self.selection != 'roulette':
            raise self._get_variant_error(self.selection, 'selection', '_select')
        else:
            weights = fitness - fitness.min()
        weights = weights if weights.sum() > 0 else np.ones_like(weights)
        return rng.choice(size, size=count, p=weights / weights.sum())

    def _cross(self, parents: Array, probability: float, rng: np.random.Generator) -> Array:
        """Return the parents after one-point crossover of every consecutive pair."""
        children = parents.copy()
        length = children.shape[1]
        for first in range(0, len(children) - 1, 2):
            if length > 1 and rng.random() < probability:
                cut = int(rng.integers(1, length))
                tail = children[first, cut:].copy()
                children[first, cut:] = children[first + 1, cut:]
                children[first + 1, cut:] = tail
        return children

    def _shift(self, children: Array, mutated: npt.NDArray[np.bool_], rng: np.random.Generator) -> Array:
        """Return the children whose mutated genes change to another symbol, or by Gaussian noise."""
        if self.alphabet is None:
            return children + mutated * rng.normal(0.0, self.scale, children.shape)
        shift = rng.integers(1, self.alphabet, children.shape)
        return np.where(mutated, (children + shift) % self.alphabet, children)

    def _redraw(self, children: Array, mutated: npt.NDArray[np.bool_], rng: np.random.Generator) -> Array:
        """Return the children whose mutated genes are drawn anew from the alphabet, or by Gaussian noise."""
        if self.alphabet is None:
            return children + mutated * rng.normal(0.0, self.scale, children.shape)
        return np.where(mutated, rng.integers(0, self.alphabet, children.shape), children)

    def _step(self, children: Array, mutated: npt.NDArray[np.bool_], rng: np.random.Generator) -> Array:
        """Return the children whose mutated genes move one step up or down, within the alphabet when it is finite."""
        steps = np.where(rng.random(children.shape) < HALF, 1.0, -1.0)
        stepped = children + mutated * steps
        return stepped if self.alphabet is None else stepped % self.alphabet

    def _mutate(self, children: Array, probability: float, rng: np.random.Generator, population: Array) -> Array:
        """Return the children mutated by the rule of the change, or by the mutation function.

        Args:
            children:
                The strategies of the offspring, one row per offspring.

            probability:
                The probability of mutation.

            rng:
                The random number generator.

            population:
                The strategies the members hold, whose largest label the rule `'new'` exceeds.

        Returns:
            children:
                The mutated strategies.
        """
        if callable(self.mutation):
            mutation = self.mutation
            return np.array([np.ravel(mutation(child.copy(), rng)) for child in children], dtype=float)
        if self.change == 'one':
            mutated = np.zeros(children.shape, dtype=bool)
            chosen = rng.random(len(children)) < probability
            mutated[np.arange(len(children)), rng.integers(children.shape[1], size=len(children))] = chosen
            return self._shift(children, mutated, rng)
        mutated = rng.random(children.shape) < probability
        if self.change == 'any':
            return self._redraw(children, mutated, rng)
        if self.change == 'step':
            return self._step(children, mutated, rng)
        if self.change == 'new':
            labels = population.max(initial=0.0) + np.cumsum(mutated).reshape(children.shape)
            return np.where(mutated, labels, children)
        return self._shift(children, mutated, rng)

    def _breed_generation(self, flat: Array, fitness: Array, model: ModelViewProtocol) -> Array:
        """Return the strategies of the next generation, one row per member."""
        rng = model.rng
        parents = flat[self._select(fitness, len(flat), rng)]
        children = self._cross(parents, self._value('crossover', model), rng)
        children = self._mutate(children, self._value('mutation', model), rng, flat)
        if self.elite:
            best = np.argsort(-fitness, kind='stable')[: self.elite]
            children[: len(best)] = flat[best]
        return children

    def _breed_moran(
        self,
        flat: Array,
        fitness: Array,
        model: ModelViewProtocol,
        genes: tuple[int, ...],
    ) -> tuple[Array, Array]:
        """Return the strategies and the fitness after one birth-death event per member, one after the other."""
        rng, probability = model.rng, self._value('mutation', model)
        strategies, fitness = flat.copy(), fitness.copy()
        for _ in range(len(strategies)):
            parent = int(self._select(fitness, 1, rng)[0])
            dying = int(rng.integers(len(strategies)))
            child = self._mutate(strategies[parent : parent + 1], probability, rng, strategies)
            evaluated = self._evaluate_rows(child, genes, model)
            strategies[dying] = child[0]
            fitness[dying] = fitness[parent] if evaluated is None else evaluated[0]
        return strategies, fitness

    def _breed_death_birth(
        self,
        flat: Array,
        fitness: Array,
        model: ModelViewProtocol,
    ) -> tuple[Array, npt.NDArray[np.bool_]]:
        """Return the strategies after the dead members are filled by the offspring of the survivors, and the dead."""
        rng = model.rng
        dead = rng.random(len(flat)) < self._value('death', model)
        if not dead.any():
            return flat, dead
        everyone = self.parents == 'all' or dead.all()
        survivors = np.arange(len(flat)) if everyone else np.flatnonzero(~dead)
        parents = survivors[self._select(fitness[survivors], int(dead.sum()), rng)]
        strategies = flat.copy()
        strategies[dead] = self._mutate(flat[parents], self._value('mutation', model), rng, flat)
        return strategies, dead

    def _breed_truncation(
        self,
        flat: Array,
        fitness: Array,
        model: ModelViewProtocol,
    ) -> tuple[Array, npt.NDArray[np.bool_]]:
        """Return the strategies after the least fit are filled by the offspring of the others, and the replaced."""
        count = round(self._value('fraction', model) * len(flat))
        replaced = np.zeros(len(flat), dtype=bool)
        replaced[np.argsort(fitness, kind='stable')[:count]] = True
        if not count:
            return flat, replaced
        others = np.arange(len(flat)) if replaced.all() else np.flatnonzero(~replaced)
        parents = others[self._select(fitness[others], count, model.rng)]
        strategies = flat.copy()
        strategies[replaced] = self._mutate(flat[parents], self._value('mutation', model), model.rng, flat)
        return strategies, replaced

    def _breed_best_mutant(
        self,
        flat: Array,
        fitness: Array,
        model: ModelViewProtocol,
        genes: tuple[int, ...],
    ) -> Array:
        """Return the strategies after every member takes the fittest mutant, when it is fitter or all mutated."""
        mutants = self._mutate(flat, self._value('mutation', model), model.rng, flat)
        mutated = np.any(mutants != flat, axis=1)
        if not mutated.any():
            return flat
        evaluated = self._evaluate_rows(mutants[mutated], genes, model)
        if evaluated is None:
            error_msg = f'{self.label!r} replaces by the best mutant, which needs an evaluation of the strategies.'
            raise SimulationError(error_msg)
        best = int(np.argmax(evaluated))
        if evaluated[best] <= fitness.max() and not mutated.all():
            return flat
        return np.repeat(mutants[mutated][best : best + 1], len(flat), axis=0)

    def _breed(
        self,
        strategies: Array,
        fitness: Array,
        model: ModelViewProtocol,
    ) -> tuple[Array, Array | None, npt.NDArray[np.bool_]]:
        """Return the strategies after the replacement, their evaluated fitness or `None`, and the replaced."""
        flat, genes = strategies.reshape(len(strategies), -1), strategies.shape[1:]
        replaced = np.ones(len(flat), dtype=bool)
        if self.replacement == 'moran':
            children, fitness = self._breed_moran(flat, fitness, model, genes)
        elif self.replacement == 'death-birth':
            children, replaced = self._breed_death_birth(flat, fitness, model)
        elif self.replacement == 'truncation':
            children, replaced = self._breed_truncation(flat, fitness, model)
        elif self.replacement == 'best-mutant':
            children = self._breed_best_mutant(flat, fitness, model, genes)
        elif self.replacement != 'generational':
            raise self._get_variant_error(self.replacement, 'replacement', '_breed')
        else:
            children = self._breed_generation(flat, fitness, model)
        children = children.reshape(strategies.shape)
        return children, self._evaluate(children, model), replaced

    def _read(self, agents: Sequence[AgentViewProtocol], model: ModelViewProtocol) -> tuple[Array, Array]:
        """Return the strategies, one row per member, and their fitness."""
        if isinstance(self.strategy, str):
            strategies = np.array([getattr(agent, self.strategy) for agent in agents], dtype=float)
        else:
            strategies = np.array([[getattr(agent, name) for name in self.strategy] for agent in agents], dtype=float)
        evaluated = self._evaluate(strategies, model)
        if evaluated is not None:
            return strategies, evaluated
        if self.fitness is None:
            return strategies, np.ones(len(agents))
        return strategies, np.array([getattr(agent, self.fitness) for agent in agents], dtype=float)

    def _write(
        self,
        agents: Sequence[AgentViewProtocol],
        children: Array,
        fitness: Array | None,
        replaced: npt.NDArray[np.bool_],
    ) -> None:
        """Write the strategies, the evaluated fitness, and the reset states of the replaced members."""
        names = [self.strategy] if isinstance(self.strategy, str) else list(self.strategy)
        for index, (agent, child, new) in enumerate(zip(agents, children, replaced, strict=True)):
            genes = [child] if isinstance(self.strategy, str) else list(child)
            for name, gene in zip(names, genes, strict=True):
                setattr(agent, name, gene if np.ndim(gene) else float(gene))
            if fitness is not None and self.fitness is not None:
                setattr(agent, self.fitness, float(fitness[index]))
            for name, value in (self.reset or {}).items() if new else ():
                setattr(agent, name, value)

    def _evolve(self, model: ModelViewProtocol) -> None:
        """Replace the strategies of the members by their offspring, once every period."""
        calls = model.storage.get('calls', 0)
        calls = calls + 1 if isinstance(calls, int) else 1
        model.storage['calls'] = calls
        agents = model.agents(self._get_role('target'))
        if not agents or calls % self.period:
            return
        strategies, fitness = self._read(agents, model)
        self._write(agents, *self._breed(strategies, fitness, model))

    def _get_laws(self, namespace: SimpleNamespace) -> list[BaseLaw]:
        """Return the rule of one generation.

        Args:
            namespace:
                The namespace of the symbols the laws can use, which the rule does not read.

        Returns:
            laws:
                The rule that breeds the next generation.
        """
        return [RuleLaw(self._evolve, phase=self.phase)]