Skip to content

Jain-Krishna autocatalytic networks

The system of Jain and Krishna, where selection of the fittest builds autocatalytic sets of molecules.

  • Name: jain_krishna
  • Field: biology/evolution
  • Formalism: discrete map
  • References: Jain and Krishna 1998; Jain and Krishna 2001; Thurner et al. 2018

Select this system

from skcomplex.systems import create_simulation, create_system, describe

system = create_system('jain_krishna')
entry = describe('jain_krishna')
trajectory = create_simulation('jain_krishna').prepare().run().trajectory_

Factory

@register_system(
    field='biology/evolution',
    tags=('adaptive network', 'autocatalytic set', 'Perron eigenvalue', 'origin of life', 'stochastic map'),
    references=('Jain and Krishna 1998', 'Jain and Krishna 2001', 'Thurner et al. 2018'),
    defaults={'until': 1500, 'dt': 5.0},
)
def jain_krishna(
    n: int = 40,
    *,
    p: float = 0.02,
    h: float = 0.5,
    period: int = 10,
    seed: int | None = 1,
) -> System:
    """Return the system of Jain and Krishna, where selection of the fittest builds autocatalytic sets of molecules.

    The populations of `n` molecular species approach the Perron eigenvector of their catalytic network between graph
    updates, every `period` steps. At every graph update the least populated species is replaced by a new one, linked
    to the others with probability `p`. Once a cycle of catalysts forms by chance, the species it supports win, and the
    autocatalytic set grows until it spans the network, so the number of links and the populated species jump.

    Args:
        n:
            The number of species.

        p:
            The probability of every catalytic link.

        h:
            The growth of a step from the catalysts.

        period:
            The number of steps between two graph updates.

        seed:
            The seed of the initial network.

    Returns:
        system:
            The system, with the populations `species.x` of the variable group `species` on the nodes and the variable
            group `links` on the edges, where an edge from `i` to `j` means that `j` catalyses `i`.

    Examples:
        >>> from skcomplex.systems.biology.evolution import jain_krishna
        >>> round(float(jain_krishna(n=10).elements[0].values['x'].sum()), 6)
        1.0
    """
    rng = np.random.default_rng(seed)
    graph = nx.gnp_random_graph(n, p, seed=int(rng.integers(2**31)), directed=True)
    space = NetworkSpace(graph)
    species = GroupElement(
        'species',
        element=ChemicalElement,
        space=space,
        on='sites',
        variable=True,
        capacity=2 * n,
        values={
            'x': np.full(n, 1 / n),
            'tie': rng.random(n),
            'fresh': 0.0,
            'h': h,
            'p': p,
            'period': float(period),
            'k': max(1, round(p * n)),
        },
        visual=VisualSpec('sites', {'states': ['x']}),
    )
    links = GroupElement(
        'links',
        element=Element,
        space=space,
        on='edges',
        variable=True,
        capacity=n * n,
        visual=HIDDEN,
    )
    return System(elements=[species, links], time=DiscreteTime())

Components

Elements:

  • ChemicalElement, from Element
  • Element
class ChemicalElement(Element):
    """Molecular species of a pool whose relative population grows with the populations of its catalysts.

    The populations follow the catalytic network: every species gains `h` times the populations of the species that
    catalyse it, and the populations are normalised, so they approach the Perron eigenvector of the network. Every
    `period` steps the least populated species, with ties broken at random, is replaced by a new species with `k`
    random catalysts, and every species catalyses the new one with probability `p`.

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

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

        x:
            The relative population.

        tie:
            The random key that breaks ties between the least populated species.

        fresh:
            Whether the species appeared at the previous graph update.

        h:
            The growth of a step from the catalysts.

        p:
            The probability of every catalytic link of a new species.

        period:
            The number of steps between two graph updates.

        k:
            The number of catalysts of a new species.

    Examples:
        >>> from skcomplex.systems.biology.evolution import ChemicalElement
        >>> ChemicalElement('species').period
        10.0
    """

    def __init__(
        self,
        label: str,
        visual: VisualSpec | None = None,
        *,
        x: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 0.0,
        tie: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 0.5,
        fresh: Annotated[float, StateSpec(domain=FiniteEnumeratedSet([0, 1]))] = 1.0,
        h: Annotated[float, ParameterSpec(domain=RealSet.open(0, oo))] = 0.5,
        p: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.02,
        period: Annotated[float, ParameterSpec(domain=RealSet.open(0, oo))] = 10.0,
        k: int = 1,
    ) -> None:
        super().__init__(label, visual=visual)
        self.x = x
        self.tie = tie
        self.fresh = fresh
        self.h = h
        self.p = p
        self.period = period
        self.k = k

    def _get_laws(self, s: SimpleNamespace) -> list[BaseLaw]:
        """Return the catalytic growth, and the replacement of the least populated species at every graph update.

        Args:
            s:
                The namespace of the symbols the laws can use.

        Returns:
            laws:
                The laws of the component.
        """
        grown = s.x + s.h * neighbors(s.other.x) + 1e-6
        key = s.x + 1e-9 * s.tie
        update = mod(s.t, s.period) > s.period - 3 / 2
        least = where(both(key <= minimum(key), update), 1, 0)
        newcomer = where(total(s.fresh) > 1 / 2, 1, 0) * (1 - s.fresh)

        def fresh(node: SimpleNamespace) -> object:
            """Return the weight of a candidate catalysed species, 1 for the new species and 0 otherwise."""
            return node.fresh

        return [
            UpdateLaw(s.x, grown / total(grown)),
            UpdateLaw(s.tie, uniform('tie')),
            UpdateLaw(s.fresh, 0),
            UpdateLaw(TopologyRequestSpec('add-node', edges=self.k), least),
            UpdateLaw(TopologyRequestSpec('remove-node'), least),
            UpdateLaw(TopologyRequestSpec('add-edge', toward=fresh), s.p * newcomer),
        ]

class Element(BaseElement):
    """Base class of entities that carry state, such as particles, populations, agents, or firms.

    A subclass takes its states, its parameters, and `visual` as arguments of its own `__init__`, annotating each state
    with a `StateSpec` and each parameter with a `ParameterSpec` inside `Annotated`, and returns its intrinsic dynamics
    from `_get_laws`, which returns no laws by default. A subclass drawn in its own way gives `visual` a default in its
    signature.

    An element whose laws have the agent trait, such as rules, alone or in a group, is an agent: under discrete time it
    runs on the agent stepper, whose rules receive every member as an agent with its states as attributes. Its states
    then hold any value, not only numbers. A member is removed only when its states tagged with a quantity are settled
    to zero, and its states and parameters cannot take the names of the agent view, such as `location` or `alive`, or
    of the attributes Mesa reads, such as `position` or `system`.

    A subclass that connects to other elements declares its ports in the class variable `ports`, a mapping from the name
    of a port to its `ConnectorSpec`, and takes one algebraic state per variable of a port, named `<port>_<variable>`.
    A flow is positive into the element.

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

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

    Attributes:
        default_visual_:
            The visual of the element when it sets none.

    Examples:
        >>> from typing import Annotated
        >>> from skcomplex.core.components.elements import Element
        >>> from skcomplex.core.specs import StateSpec
        >>> from skcomplex.core.specs import VisualSpec
        >>> from sage.all__sagemath_symbolics import oo
        >>> from sage.sets.real_set import RealSet
        >>> class PopulationElement(Element):
        ...     def __init__(
        ...         self,
        ...         label,
        ...         n: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 1.0,
        ...         visual=VisualSpec('line'),
        ...     ):
        ...         super().__init__(label, visual=visual)
        ...         self.n = n
        >>> PopulationElement('rabbits', n=40)
        PopulationElement(label='rabbits', n=40)
    """

    ports: ClassVar[Mapping[str, ConnectorSpec]] = {}

    @classmethod
    def _get_view(cls) -> type | None:
        """Return the class of the views of the element's agents, or `None` for the engine's plain view.

        An agent element names a subclass of the engine's `AgentView` that annotates its states and parameters, such as
        `class WordView(AgentView): count: float`, so its rules read them with checked types.
        """
        return None

    def _get_laws(self, namespace: SimpleNamespace) -> Sequence[BasePart]:
        """Return the laws of the element for a namespace of symbols, none by default.

        Args:
            namespace:
                The namespace of the symbols the laws can use.

        Returns:
            laws:
                The laws of the element.
        """
        return []

Equations

species.x[i]: x_species(t + 1) == -(active_species_topology - 1)*x_species(t) + (_neighbours_0*h_species + x_species(t) + 1e-06)*active_species_topology/_reduced_0
species.tie[i]: tie_species(t + 1) == active_species_topology*uniform_tie_species - (active_species_topology - 1)*tie_species(t)
species.fresh[i]: fresh_species(t + 1) == -(active_species_topology - 1)*fresh_species(t)