Growing simplicial complex by triangle attachment
A simplicial complex that grows by gluing one triangle per step along an edge drawn uniformly.
- Name:
growing_simplicial_complex - Field:
mathematics/networks - Formalism: discrete map
- References: Bianconi and Rahmede 2016; Majhi et al. 2022
Factory
@register_model(
field='mathematics/networks',
tags=('simplicial complex', 'hypergraph', 'growth', 'network geometry', 'scale-free'),
references=('Bianconi and Rahmede 2016', 'Majhi et al. 2022'),
defaults={'until': 300},
)
def growing_simplicial_complex(n_nodes: int = 303) -> System:
"""Return a simplicial complex that grows by gluing one triangle per step along an edge drawn uniformly.
The complex starts as one triangle, and every step one node joins and forms a triangle with the ends of an edge
drawn uniformly, adding two edges, as the network geometry with flavor `s = 0` of Bianconi and Rahmede in two
dimensions. A node gains triangles in proportion to its degree, so the degrees follow the power law
`P(k) ~ k^-3` of preferential attachment, and every triangle ages by one per step.
Args:
n_nodes:
The number of nodes the complex holds room for.
Returns:
system:
The model, with the groups `nodes`, `links`, and `triangles`, and the arrival `growth`.
Examples:
>>> from skcomplex.engine.runtime import Simulation
>>> from skcomplex.models.mathematics.networks import growing_simplicial_complex
>>> simulation = Simulation(growing_simplicial_complex(13), random_state=0).check().prepare()
>>> simulation.run(until=10).trajectory_.network(-1).hyperedges.shape
(11, 3)
"""
nodes = GroupElement(
'nodes',
element=VertexElement,
on='sites',
variable=True,
capacity=n_nodes,
values={'age': 1.0},
)
links = GroupElement('links', element=LinkElement, on='edges', variable=True, capacity=2 * n_nodes)
triangles = GroupElement('triangles', element=SimplexElement, on='hyperedges', variable=True, capacity=n_nodes)
growth = TopologyInteraction('growth', action='add-node', hyperedge=3)
ageing = SimplexAgeingInteraction('triangle_ageing', nodes='nodes', triangles='triangles')
return System(
elements=[nodes, links, triangles],
interactions=[growth, ageing, *_get_bookkeeping()],
space=NetworkSpace(nx.complete_graph(3), hyperedges=[[0, 1, 2]]),
time=DiscreteTime(),
)
Components
class VertexElement(Element):
"""Node of a growing network, with its age in steps and its degree.
New nodes start with age 0, and the maps of the model age the node and read its degree from the edges at the start
of every step.
Args:
label:
The label of the node group.
visual:
How the element is drawn, or `None` for `default_visual_`.
age:
The number of steps since the node joined.
degree:
The number of edges of the node.
Examples:
>>> from skcomplex.models.mathematics.networks import VertexElement
>>> VertexElement('nodes').age
0.0
"""
def __init__(
self,
label: str,
visual: VisualSpec | None = DEGREE_VISUAL,
*,
age: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 0.0,
degree: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 0.0,
) -> None:
super().__init__(label, visual=visual)
self.age = age
self.degree = degree
class LinkElement(Element):
"""Edge of a growing network, which holds no state of its own.
Args:
label:
The label of the edge group.
Examples:
>>> from skcomplex.models.mathematics.networks import LinkElement
>>> LinkElement('links').label
'links'
"""
class SimplexElement(Element):
"""Triangle of a growing simplicial complex, with its age in steps.
Args:
label:
The label of the triangle or the group on the triangles.
visual:
How the triangle is drawn, or `None` for `default_visual_`.
age:
The number of steps since the triangle joined.
Attributes:
default_visual_:
The visual of the triangle when it sets none.
Examples:
>>> from skcomplex.models.mathematics.networks import SimplexElement
>>> SimplexElement('triangles').age
0.0
"""
def __init__(
self,
label: str,
visual: VisualSpec | None = None,
*,
age: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 0.0,
) -> None:
super().__init__(label, visual=visual)
self.age = age
class TopologyInteraction(NeighborInteraction):
"""Change of the topology of a network: nodes and edges that are added or removed, and edges that are rewired.
The action runs for every node of the group a role binds, `'add-node'`, `'remove-node'`, `'replace-node'`, which
removes the node and adds a new one, and `'add-edge'`, or for every edge of its network, `'remove-edge'` and
`'rewire'`. Without a target, `'add-node'` runs once per step, adding one node from outside the network to the one
network whose groups are variable, as in growing networks. It fires at a `rate`, with a `probability` per step, or
with probability one, and only where the `condition` holds. The rate, the probability, and the condition are numbers
or functions of the namespace of an edge, whose ends are `namespace.source` and `namespace.target`, and an action of
the nodes reads the node as the source. A new node joins `edges` other nodes, a node adding edges joins `edges`
other nodes, and a rewiring keeps the end `keep` of its edge. New ends are drawn with probability proportional to
`toward(node)`, a function of the namespace of a candidate node, or uniformly. A new node or edge takes the
`values`, each a value or a function of the random number generator that draws it. The groups on the nodes and the
edges of the network are variable. With `hyperedge`, a new node instead forms a hyperedge of that size with
`hyperedge - 1` existing nodes and joins each of them by an edge: the first two are the ends of an edge drawn
uniformly, so a size of three attaches a triangle along an edge, and further members are drawn by `toward`. The
new hyperedge is a new member of the variable group on the hyperedges of the network.
Args:
label:
The label of the interaction, unique among the parts of its system.
target:
The path of the group on the nodes of the network, or `None` for an addition of one node per step.
action:
The change, `'add-node'`, `'remove-node'`, `'replace-node'`, `'add-edge'`, `'remove-edge'`, or
`'rewire'`.
rate:
The rate of the change, a number or a function, or `None` for a probability per step.
probability:
The probability of the change per step, a number or a function, or `None` for probability one.
condition:
A function of the namespace that returns where the change may fire, or `None` everywhere.
toward:
A function of the namespace of a candidate node that returns its weight as a new end, or `None` for equal
weights.
edges:
The number of edges a new node or a node adding edges joins.
keep:
The end of an edge a rewiring keeps, `'source'` or `'target'`.
values:
The initial states of a new node or edge by name, each a value or a function of the random number
generator that draws it, which default to the defaults of the element type.
layer:
The layer of the network whose edges change, or `None` for the graph of the network.
hyperedge:
The size of the hyperedge every new node forms with existing nodes, or `None` for none.
Raises:
ValidationError:
The action or the kept end is unknown, both a rate and a probability are given, the number of edges is
not a whole number, an action other than `'add-node'` has no target, or a hyperedge is given to an action
other than `'add-node'` or has fewer than two members, raised by `check`.
Examples:
>>> import networkx as nx
>>> from skcomplex.core.elements import Element, GroupElement
>>> from skcomplex.core.interactions import TopologyInteraction
>>> from skcomplex.core.spaces import NetworkSpace
>>> from skcomplex.core.system import System
>>> from skcomplex.engine.runtime import Simulation
>>> nodes = GroupElement('nodes', element=Element, on='sites', variable=True, capacity=20)
>>> links = GroupElement('links', element=Element, on='edges', variable=True, capacity=20)
>>> expiry = TopologyInteraction('expiry', target='nodes', action='remove-edge')
>>> system = System(elements=[nodes, links], interactions=[expiry], space=NetworkSpace(nx.cycle_graph(5)))
>>> trajectory = Simulation(system, random_state=0).check().prepare().run(until=1).trajectory_
>>> len(trajectory.network(0).sources), len(trajectory.network(1).sources)
(5, 0)
A network that grows by triangles from one triangle:
>>> import xgi
>>> from skcomplex.core.spaces import DiscreteTime
>>> triangles = GroupElement('triangles', element=Element, on='hyperedges', variable=True, capacity=8)
>>> growth = TopologyInteraction('growth', action='add-node', hyperedge=3)
>>> space = NetworkSpace(nx.complete_graph(3), hyperedges=[[0, 1, 2]])
>>> system = System(
... elements=[nodes, links, triangles], interactions=[growth], space=space, time=DiscreteTime()
... )
>>> trajectory = Simulation(system, random_state=0).check().prepare().run(until=5).trajectory_
>>> trajectory.network(-1).hyperedges.shape
(6, 3)
"""
def __init__(
self,
label: str,
*,
target: Annotated[str | None, RoleSpec(optional=True)] = None,
action: str = 'add-edge',
rate: Chance | None = None,
probability: Chance | None = None,
condition: Callable[[SimpleNamespace], object] | None = None,
toward: Callable[[SimpleNamespace], object] | None = None,
edges: int = 1,
keep: str = 'source',
values: Mapping[str, object] | None = None,
layer: str | None = None,
hyperedge: int | None = None,
) -> None:
super().__init__(label, layer=layer)
self.target = target
self.action = action
self.rate = rate
self.probability = probability
self.condition = condition
self.toward = toward
self.edges = edges
self.keep = keep
self.values = values
self.hyperedge = hyperedge
def _check(self) -> None:
"""Raise an error for an unknown action or kept end, a rate next to a probability, or a bad number of edges.
Raises:
ValidationError:
The action or the kept end is unknown, both a rate and a probability are given, a condition or a
weight cannot be called, the number of edges is not a whole number of at least 0, an action other
than `'add-node'` has no target, or a hyperedge is given to an action other than `'add-node'`.
"""
super()._check()
self._check_variant('action', self.action, ACTIONS, (BaseSpec,))
if self.rate is not None and self.probability is not None:
error_msg = f'{self.label!r} fires at a rate and with a probability. Give one of them.'
raise ValidationError(error_msg)
if self.target is None and self.action != 'add-node':
error_msg = f'{self.label!r} runs {self.action!r} without a target. Give the group on the nodes.'
raise ValidationError(error_msg)
if self.hyperedge is not None and self.action != 'add-node':
error_msg = f'{self.label!r} runs {self.action!r} with a hyperedge. Only new nodes form hyperedges.'
raise ValidationError(error_msg)
for name in ('condition', 'toward'):
value = getattr(self, name)
if value is not None and not callable(value):
error_msg = f'The {name} of {self.label!r} is {value!r}. Give a function of the namespace, or None.'
raise ValidationError(error_msg)
for request in self._get_requests():
request.check()
def _get_requests(self) -> list[TopologyRequestSpec]:
"""Return the requests of the action, two for a replacement of a node."""
if self.action not in ACTIONS:
raise self._get_variant_error(self.action, 'action', '_get_requests')
values = tuple((self.values or {}).items())
kinds = ['add-node', 'remove-node'] if self.action == 'replace-node' else [self.action]
return [
TopologyRequestSpec(
kind,
self.edges,
self.toward,
self.keep,
self.layer,
values,
self.hyperedge if kind == 'add-node' else None,
)
for kind in kinds
]
@staticmethod
def _evaluate(value: Chance, namespace: SimpleNamespace) -> object:
"""Return a number, or the value of a function of the namespace."""
return value(namespace) if callable(value) else value
def _get_laws(self, namespace: SimpleNamespace) -> list[BaseLaw]:
"""Return the requests of the action, fired at the rate or with the probability where the condition holds.
Args:
namespace:
The namespace of an edge, with `namespace.source` and `namespace.target`.
Returns:
laws:
A jump law per request at a rate, or else an update law per request with its probability.
"""
chance: object = 1
if self.rate is not None:
chance = self._evaluate(self.rate, namespace)
elif self.probability is not None:
chance = self._evaluate(self.probability, namespace)
if self.condition is not None:
chance = where(self.condition(namespace), chance, 0)
if self.rate is not None:
return [JumpLaw(chance, {request: 1}) for request in self._get_requests()]
return [UpdateLaw(request, chance) for request in self._get_requests()]
class SimplexAgeingInteraction(HyperedgeInteraction):
"""Ageing of every triangle of a simplicial complex by one per step.
Args:
label:
The label of the interaction, unique among the parts of its system.
nodes:
The path of the group on the nodes.
triangles:
The path of the group on the triangles.
Examples:
>>> from skcomplex.models.mathematics.networks import SimplexAgeingInteraction
>>> SimplexAgeingInteraction('ageing', nodes='nodes', triangles='triangles').size
3
"""
def __init__(
self,
label: str,
*,
nodes: Annotated[str, RoleSpec()],
triangles: Annotated[str, RoleSpec()],
) -> None:
super().__init__(label, size=3)
self.nodes = nodes
self.triangles = triangles
def _get_laws(self, s: SimpleNamespace) -> list[BaseLaw]:
"""Return the ageing of the triangle."""
return [UpdateLaw(s.hyperedge.age, s.hyperedge.age + 1)]
class MapInteraction(Interaction):
"""Map of a state of discrete time, whose next value is a function, or the state plus it.
In the mode `'replace'` the next value of the state is `f`, and in the mode `'increment'` it is the state plus `f`.
Under the `'synchronous'` schedule every member of a bound group updates at once, from the states at the start of
the step. Under the `'sequential'` schedule the members update one after the other, in their order, and under the
`'random-sequential'` schedule in a new random order every step, each member reading the values the members before
it set. Under the `'member'` schedule of the agent form, every member updates in its own activation, together with
the rules of its element and the other member interactions, in the order the system lists them.
In the `'symbolic'` form, `function(namespace)` returns a symbolic expression, or a vector of them for a vector
state, where `namespace.target` holds the states and parameters of the target, `namespace.sources` the namespaces
of the sources, and `namespace.t` the time, and may hold draws such as `normal('noise')`. In the `'agent'` form,
the target is an agent group and `function(agent, model)` returns the value as a number, or as an array of the
shape of a vector state, so it can read the agent, its neighbours, and the history and storage of the model view.
Args:
label:
The label of the interaction, unique among the parts of its system.
target:
The path of the element or group whose state the map sets.
state:
The name of the state of the target.
function:
The function `f(namespace)` in the symbolic form, or `f(agent, model)` in the agent form.
sources:
The paths of the elements, or members of groups such as `cells[0]`, that the function reads.
mode:
`'replace'` or `'increment'`.
schedule:
`'synchronous'`, `'sequential'`, `'random-sequential'`, or `'member'` for the agent form.
form:
`'symbolic'` for update laws of the states of the target, or `'agent'` for a rule over an agent group.
phase:
The phase of the step the agent form runs in, or `None` for the default phase.
Raises:
ValidationError:
The mode, the schedule, or the form is unknown, the symbolic form has the schedule `'member'`, the
function is not callable, or the agent form returns a value that does not match the state, raised by
`run` of the simulation.
Examples:
>>> from skcomplex.core.elements import Element
>>> from skcomplex.core.interactions import MapInteraction
>>> from skcomplex.core.spaces import DiscreteTime
>>> from skcomplex.core.system import System
>>> from skcomplex.engine.runtime import Simulation
>>> from skcomplex.specs import StateSpec
>>> from typing import Annotated, SupportsFloat
>>> class ValueElement(Element):
... def __init__(self, label, x: Annotated[float, StateSpec()] = 0.5):
... super().__init__(label)
... self.x = x
>>> logistic = MapInteraction(
... 'map', target='value', state='x', function=lambda s: 3 * s.target.x * (1 - s.target.x)
... )
>>> system = System(elements=[ValueElement('value')], interactions=[logistic], time=DiscreteTime())
>>> Simulation(system).check().prepare().run(until=2).trajectory_.values('value.x').tolist()
[0.5, 0.75, 0.5625]
>>> agents = MapInteraction('map', target='walkers', state='x', function=lambda a, m: a.x + 1, form='agent')
>>> type(agents.check().prepare().run(SimpleNamespace()).laws_[0]).__name__
'RuleLaw'
>>> from skcomplex.core.elements import GroupElement
>>> class WeightsElement(Element):
... def __init__(self, label, visual=None, *, w: Annotated[object, StateSpec(vector=True)] = 0.0):
... super().__init__(label, visual=visual)
... self.w = w
>>> group = GroupElement('weights', element=WeightsElement, size=1, values={'w': [1.0, 2.0]})
>>> doubling = MapInteraction(
... 'double', target='weights', state='w', function=lambda a, m: [2 * x for x in a.w], form='agent'
... )
>>> trajectory = Simulation(System(elements=[group], interactions=[doubling])).check().prepare().run(until=2)
>>> trajectory.trajectory_.agents('weights', 2).values['w'].tolist()
[[4.0, 8.0]]
"""
def __init__(
self,
label: str,
*,
target: Annotated[str, RoleSpec()],
state: str,
function: Callable[..., object],
sources: Annotated[Sequence[str], RoleSpec(variadic=True)] = (),
mode: str = 'replace',
schedule: str = 'synchronous',
form: str = 'symbolic',
phase: str | None = None,
) -> None:
super().__init__(label)
self.target = target
self.state = state
self.function = function
self.sources = sources
self.mode = mode
self.schedule = schedule
self.form = form
self.phase = phase
def _check(self) -> None:
"""Raise an error for an unknown mode, schedule, or form, or a function that cannot be called.
Raises:
ValidationError:
The mode, the schedule, or the form is unknown, the symbolic form has the schedule `'member'`, or the
function is not callable.
"""
super()._check()
self._check_variant('mode', self.mode, MODES, (BaseSpec,))
for name, value, variants in (
('schedule', self.schedule, SCHEDULES),
('form', self.form, FORMS),
):
if value not in variants:
error_msg = f'The {name} of map {self.label!r} is {value!r}. Use one of {", ".join(variants)}.'
raise ValidationError(error_msg)
if self.schedule == 'member' and self.form != 'agent':
error_msg = (
f'The map {self.label!r} updates every member in its activation, which only the agent form does.'
)
raise ValidationError(error_msg)
if not callable(self.function):
error_msg = f'The function of map {self.label!r} must be callable, but it is {self.function!r}.'
raise ValidationError(error_msg)
def _apply_mode(self, current: Value, value: Value) -> Value:
"""Return the next value of a state from its current value and the value of the function, by the mode."""
if self.mode == 'increment':
return current + value
if self.mode == 'replace':
return value
raise self._get_variant_error(self.mode, 'mode', '_apply_mode')
def _get_vector(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> Value:
"""Return the next value of a vector state of an agent."""
current = np.asarray(getattr(agent, self.state), dtype=np.float64)
value = np.asarray(self.function(agent, model), dtype=np.float64)
if value.shape != current.shape:
error_msg = (
f'The function of map {self.label!r} returns the shape {value.shape} for the state {self.state!r} of '
f'the shape {current.shape}. Return an array of the shape of the state.'
)
raise ValidationError(error_msg)
return self._apply_mode(current, value)
def _get_value(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> Value:
"""Return the next value of the state of an agent, a number or the array of a vector state."""
if np.ndim(getattr(agent, self.state)) > 0:
return self._get_vector(agent, model)
found = self.function(agent, model)
if not isinstance(found, SupportsFloat):
error_msg = f'The function of map {self.label!r} returns {found!r} for an agent. Return a number.'
raise ValidationError(error_msg)
value = float(found)
return self._apply_mode(float(getattr(agent, self.state)), value)
def _set_value(self, agent: AgentViewProtocol, value: Value) -> None:
"""Set the state of an agent to its next value."""
setattr(agent, self.state, value)
def _update_member(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> None:
"""Set the state of one member to its next value."""
if agent.alive:
self._set_value(agent, self._get_value(agent, model))
def update(self, model: ModelViewProtocol) -> None:
"""Set the state of every agent of the target to its next value, by the schedule.
Args:
model:
The model view of the interaction.
"""
self._update_agents(model, self.schedule, self._get_value, self._set_value)
def _get_order(self) -> object:
"""Return the order of the members of the symbolic form: none, their own order, or a uniform draw."""
if self.schedule == 'synchronous':
return None
if self.schedule == 'sequential':
return SR(0)
return uniform(re.sub(r'\W', '_', f'order_{self.label}'))
def _get_laws(self, namespace: SimpleNamespace) -> list[BasePart]:
"""Return the update of the state to its next value, or the rule of the agent form.
Args:
namespace:
The namespace of the symbols the laws can use, with `namespace.target` and `namespace.sources`.
Returns:
laws:
The update law of the state, or the rule that runs the map of the agents.
"""
if self.form == 'agent' and self.schedule == 'member':
return [RuleLaw(self._update_member, phase=self.phase, members=self._get_role('target'))]
if self.form == 'agent':
return [RuleLaw(self.update, phase=self.phase)]
value = getattr(self._get_member(namespace, 'target'), self.state)
mapped = cast(Value, self.function(namespace))
next_value = self._apply_mode(value, mapped)
return [UpdateLaw(value, next_value, increment=self.mode == 'increment', order=self._get_order())]
Equations
nodes.age[i]: age_nodes(t + 1) == active_nodes_topology*(age_nodes(t) + 1) - (active_nodes_topology - 1)*age_nodes(t)
nodes.degree[i]: degree_nodes(t + 1) == _neighbours_0*active_nodes_topology - (active_nodes_topology - 1)*degree_nodes(t)
triangles.age[i]: age_triangles(t + 1) == age_triangles__hyperedge + 1