Insider sabotage
The insider sabotage model of employees whose risk of sabotage builds up over the days of a year.
- Name:
insider_sabotage - Field:
social/organizations - Formalism: agent-based model
- References: Ardiles Cruz et al. 2019; Carmichael et al. 2019
Factory
@register_model(
field='social/organizations',
tags=('organizations', 'agents', 'grid', 'insider threat'),
references=('Ardiles Cruz et al. 2019', 'Carmichael et al. 2019'),
defaults={'until': 365},
)
def insider_sabotage(
size: int = 20,
*,
n_employees: int = 130,
n_supervisors: int = 3,
turnover: float = 0.15,
random_state: int | np.random.Generator | None = 0,
) -> System:
"""Return the insider sabotage model of employees whose risk of sabotage builds up over the days of a year.
Args:
size:
The number of rows and columns of the torus.
n_employees:
The number of employees.
n_supervisors:
The number of supervisors.
turnover:
The yearly share of employees who leave.
random_state:
The seed or generator of the predispositions and affects.
Returns:
system:
The model, with the groups `employees` and `supervisors`.
Examples:
>>> from skcomplex.models.social.organizations import insider_sabotage
>>> [group.size for group in insider_sabotage(n_employees=20, n_supervisors=2).elements]
[20, 2]
"""
rng = np.random.default_rng(random_state)
predisposed = (rng.random(n_employees) < PREDISPOSED).astype(float)
affect = rng.choice(AFFECTS, n_employees, p=AFFECT_SHARES)
employees = GroupElement(
'employees',
element=EmployeeElement,
size=n_employees,
values={'predisposed': predisposed, 'affect': affect, 'risk': predisposed + affect, 'turnover': turnover},
)
supervisors = GroupElement('supervisors', element=SupervisorElement, size=n_supervisors)
wandering = MovementInteraction('wandering', target='supervisors', schedule='member')
maps = [
('feeling', _feel, 'replace'),
('first', _remember_first, 'replace'),
('disgruntlement', _disgruntle, 'replace'),
('days', _count_day, 'increment'),
('mean_feeling', _average, 'increment'),
('expectation', _expect, 'replace'),
('stress', _stress, 'increment'),
('skill', _learn, 'increment'),
]
work = [
MovementInteraction('working', target='employees', schedule='member'),
*(
MapInteraction(
state,
target='employees',
state=state,
function=function,
mode=mode,
schedule='member',
form='agent',
)
for state, function, mode in maps
),
LifeCycleInteraction('turnover', target='employees', death=_leave, renew=True, values=_hire, schedule='member'),
MapInteraction('risk', target='employees', state='risk', function=_sum_risk, schedule='member', form='agent'),
]
return System(
elements=[employees, supervisors],
interactions=[wandering, *work],
space=LatticeSpace((size, size), boundary='periodic', neighborhood='von_neumann'),
)
Components
class EmployeeElement(Element):
"""Employee of the insider sabotage model, whose risk is the sum of five factors.
The risk is `R = P + D + S + K + A`: the predisposition `P`, the disgruntlement `D` of predisposed employees
whose daily feeling about the organization falls short of their expectation, the stress `S` raised by seeing a
supervisor, the skill `K` that grows every day, and the fixed affect `A`. The expectation weighs the first, the
mean, and the latest feeling. Every day an employee wanders to an adjacent cell and leaves with probability
`turnover / 365`, replaced by a new hire. In `insider_sabotage`, a movement, a map of every state, and a life cycle
that renews the employees who leave run these steps in turn in the activation of every employee.
Args:
label:
The label of the agent or group.
visual:
How the agent is drawn, or `None` for `default_visual_`.
predisposed:
Whether the employee is predisposed to sabotage, 1, or not, 0.
disgruntlement:
The initial disgruntlement `D`.
stress:
The initial stress `S`.
skill:
The initial skill `K`.
affect:
The affect `A`.
risk:
The initial risk `R`.
days:
The number of days the employee has felt about the organization.
first:
The first feeling.
mean_feeling:
The mean feeling.
expectation:
The expected feeling.
feeling:
The latest feeling.
turnover:
The yearly share of employees who leave.
culture:
The mean daily feeling about the organization.
change:
The variance of the daily feeling.
primacy:
The weight of the first feeling in the expectation.
consistency:
The weight of the mean feeling in the expectation.
recency:
The weight of the latest feeling in the expectation.
affect_weight:
The factor of the shortfall of a feeling in the disgruntlement.
alpha_seen:
The learning rate of stress when a supervisor is in sight.
lambda_seen:
The level stress approaches when a supervisor is in sight.
alpha_unseen:
The learning rate of stress otherwise.
lambda_unseen:
The level stress approaches otherwise.
skill_growth:
The mean daily growth of skill.
skill_variance:
The variance of the daily growth of skill.
Examples:
>>> from skcomplex.core.elements import GroupElement
>>> from skcomplex.models.social.organizations import EmployeeElement
>>> group = GroupElement('employees', element=EmployeeElement, size=10, values={'affect': 0.5})
>>> group.label, group.element.__name__, group.size
('employees', 'EmployeeElement', 10)
"""
def __init__(
self,
label: str,
visual: VisualSpec | None = VisualSpec('agents', {'color_by': 'risk', 'color_range': (0.0, 5.0)}),
*,
predisposed: Annotated[float, StateSpec(domain=FiniteEnumeratedSet([0, 1]))] = 0.0,
disgruntlement: Annotated[float, StateSpec()] = 0.0,
stress: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 0.0,
skill: Annotated[float, StateSpec()] = 0.0,
affect: Annotated[float, StateSpec(domain=RealSet.closed_open(0, oo))] = 0.5,
risk: Annotated[float, StateSpec()] = 0.5,
days: Annotated[float, StateSpec(domain=NN)] = 0.0,
first: Annotated[float, StateSpec()] = 0.0,
mean_feeling: Annotated[float, StateSpec()] = 0.0,
expectation: Annotated[float, StateSpec()] = 0.0,
feeling: Annotated[float, StateSpec()] = 0.0,
turnover: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.15,
culture: Annotated[float, ParameterSpec()] = 0.8,
change: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.05,
primacy: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 1.0,
consistency: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 1.0,
recency: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 1.0,
affect_weight: Annotated[float, ParameterSpec()] = 1.0,
alpha_seen: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.5,
lambda_seen: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 1.0,
alpha_unseen: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.25,
lambda_unseen: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.0,
skill_growth: Annotated[float, ParameterSpec()] = 0.001,
skill_variance: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.0005,
) -> None:
super().__init__(label, visual=visual)
self.predisposed = predisposed
self.disgruntlement = disgruntlement
self.stress = stress
self.skill = skill
self.affect = affect
self.risk = risk
self.days = days
self.first = first
self.mean_feeling = mean_feeling
self.expectation = expectation
self.feeling = feeling
self.turnover = turnover
self.culture = culture
self.change = change
self.primacy = primacy
self.consistency = consistency
self.recency = recency
self.affect_weight = affect_weight
self.alpha_seen = alpha_seen
self.lambda_seen = lambda_seen
self.alpha_unseen = alpha_unseen
self.lambda_unseen = lambda_unseen
self.skill_growth = skill_growth
self.skill_variance = skill_variance
@classmethod
def _get_view(cls) -> type[EmployeeView]:
"""Return the class of the views of the agents, whose attributes are the states and parameters."""
return EmployeeView
class SupervisorElement(Element):
"""Supervisor of the insider sabotage model, who wanders between adjacent cells.
The wandering is a `MovementInteraction` to a random adjacent cell, which `insider_sabotage` adds.
Args:
label:
The label of the agent or group.
Examples:
>>> from skcomplex.core.elements import GroupElement
>>> from skcomplex.models.social.organizations import SupervisorElement
>>> group = GroupElement('supervisors', element=SupervisorElement, size=3)
>>> group.label, group.element.__name__, group.size
('supervisors', 'SupervisorElement', 3)
"""
class MovementInteraction(Interaction):
"""Movement of the agents of a group by steps, every step of discrete time.
A step is one of five kinds:
- `'neighbor'` moves an agent to a uniformly drawn cell or node next to its own, on a lattice or a network.
- `'gaussian'` adds to the position of an agent a normal vector whose components have the standard deviation
`scale`, in a continuous space.
- `'levy'` moves an agent in a uniformly drawn direction by a Pareto length, whose least value is `scale` and whose
tail exponent is `exponent`, in a continuous space.
- `'best'` moves an agent to the cell or node next to its own with the highest `score(agent, cell, model)`. With a
`vision`, a number or a function of the agent and the model view, it looks instead along every direction of the
neighbourhood of a lattice up to that many cells, and at the cell of the agent, in the order of the axes, the
positive direction first. Among cells of equal highest score, the ties `'random'` draw one uniformly, `'first'`
take the first in that order, and `'nearest'` take the nearest, the first of them among equal distances. By
default the ties are `'random'` without a vision and `'nearest'` with one.
- A function `step(agent, model)` returns the destination, or `None` to stay.
An agent for which `condition(agent, model)` holds attempts one step every step of time, or a Poisson number of
steps with the mean `rate`, and takes each attempt with the probability `probability` and then with the probability
`accept(agent, destination, model)`. An exclusive movement never moves an agent to a cell another agent of the group
holds. The agents move one after the other, in their order under the schedule `'sequential'`, in a new random order
every step under `'random-sequential'`, or each in its own activation under `'member'`, together with the rules of
its element and the other member interactions, in the order the system lists them. Every step taken charges its
`cost`, a `TransferInteraction` from the agent to itself or a function of the agent and the model view. The
movement is an agent rule, so the system runs in discrete time.
Args:
label:
The label of the interaction, unique among the parts of its system.
target:
The path of the group whose agents move.
step:
The kind of step, `'neighbor'`, `'gaussian'`, `'levy'`, or `'best'`, or a function of the agent and the
model view that returns the destination.
probability:
The probability that an agent takes an attempted step.
rate:
The mean number of attempted steps of an agent in one unit of time, or `None` for one attempt every step.
condition:
A function of the agent and the model view that returns whether the agent moves, or `None` for every agent.
accept:
A function of the agent, its destination, and the model view that returns the probability of taking the
step, or `None` to take every step.
exclusive:
Whether an agent moves only to cells or nodes no other agent of the group holds.
score:
The function of the agent, a neighbouring cell, and the model view that the step `'best'` maximizes.
scale:
The standard deviation of every component of a Gaussian step, or the least length of a Lévy step.
exponent:
The tail exponent of the length of a Lévy step.
cost:
A `TransferInteraction` that the agent pays to itself or to the environment for every step taken, a function
of the agent and the model view run for every step taken, or `None`.
vision:
The number of cells the step `'best'` looks along every direction of a lattice, a number or a function of
the agent and the model view, or `None` for the cells next to the agent.
schedule:
The order of the agents, `'sequential'`, `'random-sequential'`, or `'member'`.
phase:
The phase of the step the movement runs in, or `None` for the default phase.
ties:
The cell the step `'best'` takes among cells of equal highest score, `'random'`, `'first'`, or `'nearest'`,
or `None` for `'random'` without a vision and `'nearest'` with one.
Raises:
ValidationError:
The step, the schedule, or the ties are unknown, the rate is not positive, a function is not callable, or
the step `'best'` has no score, raised by `check`.
Examples:
>>> from skcomplex.core.elements import Element, GroupElement
>>> from skcomplex.core.interactions import MovementInteraction
>>> from skcomplex.core.spaces import LatticeSpace
>>> from skcomplex.core.system import System
>>> from skcomplex.engine.runtime import Simulation
>>> walkers = GroupElement('walkers', element=Element, size=2, location=[(2, 2), (2, 2)])
>>> walk = MovementInteraction('walk', target='walkers', step='neighbor')
>>> space = LatticeSpace((5, 5), neighborhood='von_neumann')
>>> system = System(elements=[walkers], interactions=[walk], space=space)
>>> trajectory = Simulation(system, random_state=0).check().prepare().run(until=1).trajectory_
>>> [bool(abs(cell - 2).sum() == 1) for cell in trajectory.agents('walkers', 1).locations]
[True, True]
"""
def __init__(
self,
label: str,
*,
target: Annotated[str, RoleSpec()],
step: Step = 'neighbor',
probability: Annotated[float, ParameterSpec(domain=RealSet.closed(0, 1))] = 1.0,
rate: float | None = None,
condition: Callable[[AgentViewProtocol, ModelViewProtocol], bool] | None = None,
accept: Callable[[AgentViewProtocol, object, ModelViewProtocol], float] | None = None,
exclusive: bool = False,
score: Callable[[AgentViewProtocol, object, ModelViewProtocol], float] | None = None,
scale: Annotated[float, ParameterSpec(domain=RealSet.open(0, oo))] = 1.0,
exponent: Annotated[float, ParameterSpec(domain=RealSet.open(0, oo))] = 1.5,
cost: Cost | None = None,
vision: Vision | None = None,
schedule: str = 'random-sequential',
phase: str | None = None,
ties: str | None = None,
) -> None:
super().__init__(label)
self.target = target
self.step = step
self.probability = probability
self.rate = rate
self.condition = condition
self.accept = accept
self.exclusive = exclusive
self.score = score
self.scale = scale
self.exponent = exponent
self.cost = cost
self.vision = vision
self.schedule = schedule
self.phase = phase
self.ties = ties
def _check_functions(self) -> None:
"""Raise an error for a condition, an acceptance, a score, or a cost that cannot be called."""
for name in ('condition', 'accept', 'score'):
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, or None.'
raise ValidationError(error_msg)
if isinstance(self.cost, TransferInteraction):
self.cost.check()
elif self.cost is not None and not callable(self.cost):
error_msg = f'The cost of {self.label!r} is {self.cost!r}. Give a TransferInteraction, a function, or None.'
raise ValidationError(error_msg)
def _check_space(self, space: BaseSpace | None) -> None:
"""Raise an error for a step to a neighbouring cell or node in a continuous space."""
if isinstance(self.step, str) and self.step in GRID_STEPS:
self._check_grid('step', self.step, space, "step='gaussian' or step='levy', or a step function")
def _check(self) -> None:
"""Raise an error for an unknown step, a rate that is not positive, or a function that cannot be called.
Raises:
ValidationError:
The step, the schedule, or the ties are unknown, the rate is not positive, a function is not callable,
or the step `'best'` has no score.
"""
super()._check()
self._check_variant('step', self.step, STEPS, functions=True)
if self.rate is not None and (isinstance(self.rate, bool) or not self.rate > 0):
error_msg = f'The rate of {self.label!r} is {self.rate!r}. Give a positive number, or None.'
raise ValidationError(error_msg)
if self.schedule not in SCHEDULES:
error_msg = f'Unknown schedule {self.schedule!r} of {self.label!r}. Use one of {", ".join(SCHEDULES)}.'
raise ValidationError(error_msg)
if self.ties is not None:
self._check_variant('ties', self.ties, TIES)
if self.vision is not None and not callable(self.vision) and not isinstance(self.vision, int | float):
error_msg = f'The vision of {self.label!r} is {self.vision!r}. Give a number, a function, or None.'
raise ValidationError(error_msg)
if self.step == 'best' and self.score is None:
error_msg = f'The step `best` of {self.label!r} maximizes a score. Pass `score(agent, cell, model)`.'
raise ValidationError(error_msg)
self._check_functions()
def _count_attempts(self, model: ModelViewProtocol) -> int:
"""Return the number of steps an agent attempts in one step of time."""
return 1 if self.rate is None else int(model.rng.poisson(self.rate))
def _get_cells(self, agent: AgentViewProtocol, occupied: set[object]) -> list[object]:
"""Return the cells or nodes around an agent that it may move to."""
cells = agent.neighbor_cells()
if not self.exclusive:
return cells
return [cell for cell in cells if cell not in occupied]
def _get_best(
self,
agent: AgentViewProtocol,
cells: list[object],
model: ModelViewProtocol,
distances: list[int] | None = None,
) -> object:
"""Return the cell of the highest score, chosen among ties by the tie rule."""
if self.score is None or not cells:
return None
scores = [self.score(agent, cell, model) for cell in cells]
ties = self.ties or ('random' if distances is None else 'nearest')
if ties != 'nearest':
return cells[self._pick_best(scores, ties, model)]
values = np.asarray(scores, dtype=float)
best = np.flatnonzero(values == values.max())
near = np.asarray([1 if distances is None else distances[int(index)] for index in best])
return cells[int(best[near == near.min()][0])]
@staticmethod
def _shift(here: tuple[int, ...], cell: tuple[int, ...], model: ModelViewProtocol) -> tuple[int, ...]:
"""Return the unit step from a cell to a neighbouring cell, across the wrapped edge of a torus."""
shift = [b - a for a, b in zip(here, cell, strict=True)]
if model.torus:
sizes = [int(item) for item in model.dimensions or ()]
shift = [d - round(d / n) * n for d, n in zip(shift, sizes, strict=True)]
return tuple(shift)
def _get_sight(
self,
agent: AgentViewProtocol,
occupied: set[object],
model: ModelViewProtocol,
) -> tuple[list[object], list[int]]:
"""Return the cells in sight of an agent along every direction of a lattice, its own first, with distances."""
vision = self.vision(agent, model) if callable(self.vision) else self.vision
reach = int(vision or 0)
here = tuple(int(item) for item in np.atleast_1d(np.asarray(agent.location_key)))
sizes = [int(item) for item in model.dimensions or ()]
cells: list[object] = [agent.location_key]
distances = [0]
shifts = [
self._shift(here, tuple(int(item) for item in np.atleast_1d(np.asarray(neighbor))), model)
for neighbor in agent.neighbor_cells()
]
for shift in sorted(shifts, key=lambda step: tuple((item == 0, -item) for item in step)):
for distance in range(1, reach + 1):
point = [a + distance * d for a, d in zip(here, shift, strict=True)]
if model.torus:
point = [item % size for item, size in zip(point, sizes, strict=True)]
elif any(not 0 <= item < size for item, size in zip(point, sizes, strict=True)):
break
cell = tuple(point)
if not self.exclusive or cell not in occupied:
cells.append(cell)
distances.append(distance)
return cells, distances
def _get_destination(self, agent: AgentViewProtocol, model: ModelViewProtocol, occupied: set[object]) -> object:
"""Return the location a step of an agent leads to, or `None` to stay."""
rng = model.rng
if callable(self.step):
return self.step(agent, model)
if self.step == 'best' and self.vision is not None:
cells, distances = self._get_sight(agent, occupied, model)
return self._get_best(agent, cells, model, distances)
if self.step in ('neighbor', 'best'):
cells = self._get_cells(agent, occupied)
if self.step == 'best':
return self._get_best(agent, cells, model)
return cells[int(rng.integers(len(cells)))] if cells else None
position = np.asarray(agent.location, dtype=float)
if self.step == 'gaussian':
return position + rng.normal(0.0, self._get_parameter_value('scale', model), position.shape)
direction = rng.normal(size=position.shape)
length = self._get_parameter_value('scale', model) * (
1.0 + rng.pareto(self._get_parameter_value('exponent', model))
)
return position + length * direction / np.linalg.norm(direction)
def _charge(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> None:
"""Charge the cost of one step taken to an agent."""
if isinstance(self.cost, TransferInteraction):
self.cost.transfer(agent, agent, model)
elif self.cost is not None:
self.cost(agent, model)
def _take(self, agent: AgentViewProtocol, destination: object, occupied: set[object]) -> bool:
"""Move an agent to a destination unless an exclusive movement finds it held, and return whether it moved."""
if not self.exclusive:
agent.move_to(destination)
return True
if destination == agent.location_key:
return True
if destination in occupied:
return False
occupied.discard(agent.location_key)
agent.move_to(destination)
occupied.add(agent.location_key)
return True
def _move(
self,
agent: AgentViewProtocol,
model: ModelViewProtocol,
probability: float,
occupied: set[object],
) -> None:
"""Take the attempted steps of one agent, each with its probability and acceptance, charging every step."""
for _ in range(self._count_attempts(model)):
if probability < 1.0 and model.rng.random() >= probability:
continue
destination = self._get_destination(agent, model, occupied)
if destination is None:
continue
if self.accept is not None and model.rng.random() >= self.accept(agent, destination, model):
continue
if self._take(agent, destination, occupied):
self._charge(agent, model)
def _move_member(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> None:
"""Move one agent whose condition holds, in its own activation."""
if agent.alive and (self.condition is None or self.condition(agent, model)):
occupied = (
{other.location_key for other in model.agents(self._get_role('target'))} if self.exclusive else set()
)
self._move(agent, model, self._get_parameter_value('probability', model), occupied)
def _move_all(self, model: ModelViewProtocol) -> None:
"""Move every agent of the group whose condition holds, in their order or in random order."""
agents = model.agents(self._get_role('target'))
occupied = {agent.location_key for agent in agents} if self.exclusive else set()
probability = self._get_parameter_value('probability', model)
order = model.rng.permutation(len(agents)) if self.schedule == 'random-sequential' else range(len(agents))
for index in order:
agent = agents[int(index)]
if agent.alive and (self.condition is None or self.condition(agent, model)):
self._move(agent, model, probability, occupied)
def _get_laws(self, namespace: SimpleNamespace) -> list[BaseLaw]:
"""Return the rule of the movement.
Args:
namespace:
The namespace of the symbols the laws can use.
Returns:
laws:
The rule that moves the agents every step.
"""
if self.schedule == 'member':
return [RuleLaw(self._move_member, phase=self.phase, members=self._get_role('target'))]
return [RuleLaw(self._move_all, phase=self.phase)]
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())]
class LifeCycleInteraction(Interaction):
"""Life cycle of the agents of a group, which give birth to offspring and die, every step of discrete time.
Every step the agents act once each, in their order under the schedule `'sequential'`, in a new random order under
`'random-sequential'`, or each in its own activation under `'member'`, together with the rules of its element and
the other member interactions, in the order the system lists them, without the reports of the step and without
arrivals. An agent dies with the probability `death`, and an agent that survives gives birth with the probability
`birth`, or as many times as a Poisson number of the mean `rate`. A probability is a number or a function of the
agent and the model view, and a function that returns a condition gives probability one where it holds. Every birth
creates `offspring` new agents, a number or a function of the parent and the model view, such as a Poisson draw, of
the group `group`, or of the parent's group without one. A new agent stands in the cell of its parent, with the
placement `'here'`, in a neighbouring cell that no agent of the group holds, with the placement `'neighbor'`, or at
the location a function of the parent and the model view returns, where `None` means no place, and a birth without a
place creates fewer agents.
A new agent copies the states `inherit` of its parent and takes the values that `values(parent, model)` returns,
such as a generation one higher or drawn states. The parent splits each state of `split` with its offspring,
keeping the share `keep`, a number or a function of the parent and the model view such as a random cut, or else an
equal share, and the offspring share the rest equally. Then `mutate(values, model)` returns the values of a new
agent from those it would get, and the other states take their defaults. A parent that gave birth takes the values
`reset`, and with `replace` it is removed, so its offspring replace it, also when the birth created none. With
`renew` an agent that dies is renewed in place instead of removed: it keeps its identity, its place, and the states
the new values leave out, and takes the values of an offspring of its own. With the probability `cull` an agent
that does not die removes, after its births, one member of `group`, or of the target without a group, for which
`kin(agent, member)` holds, the first in the order of the group under the pick `'first'`, such as one it owns, or
a random one under `'random'`. Every
step a Poisson number of the mean `arrival`, a number or a function of the model view, of new agents arrive without
a parent, at the location of the placement function or else at a random place, with the values
`values(None, model)`. The life cycle reports the `births`, `deaths`, and `arrivals` of every step. It is an agent
rule, so the system runs in discrete time.
Args:
label:
The label of the interaction, unique among the parts of its system.
target:
The path of the variable group whose agents give birth and die.
birth:
The probability that an agent gives birth in a step, or `None` for no births by probability.
death:
The probability that an agent dies in a step, or `None` for no deaths.
rate:
The mean number of births of an agent in a step, a number or a function, or `None` for no births by rate.
offspring:
The number of new agents of a birth.
placement:
Where a new agent stands, `'here'`, `'neighbor'`, or a function of the parent, `None` for an arrival, and
the model view that returns its location.
inherit:
The names of the states a new agent copies from its parent.
split:
The names of the states the parent shares with its offspring.
mutate:
A function of the values of a new agent and the model view that returns its values, or `None`.
schedule:
The order of the agents, `'sequential'`, `'random-sequential'`, or `'member'`.
phase:
The phase of the step the life cycle runs in, or `None` for the default phase.
group:
The path of the variable group the new agents join, or `None` for the group of the target.
values:
A function of the parent, `None` for an arrival, and the model view that returns values of a new agent by
state name, or `None`.
keep:
The share of every split state the parent keeps, a number or a function, or `None` for an equal share.
reset:
The values a parent takes after it gives birth, by state name, or `None`.
replace:
Whether a parent that gives birth is removed.
renew:
Whether an agent that dies is renewed in place instead of removed.
cull:
The probability that an agent removes one member of `group`, or of the target without a group, in a
step, or `None` for no removals.
kin:
A function of an agent and a member of `group` that returns whether the agent may remove the member, or
`None` for every member.
arrival:
The mean number of new agents that arrive in a step without a parent, or `None` for no arrivals.
pick:
Which member of `group` a removal takes among those it may remove, `'first'` or `'random'`.
Raises:
ValidationError:
The placement, the schedule, or the pick is unknown, both a birth probability and a rate are given, a
function is not callable, or the schedule `'member'` has arrivals, raised by `check`.
Examples:
>>> from typing import Annotated
>>> from skcomplex.core.elements import Element, GroupElement
>>> from skcomplex.core.interactions import LifeCycleInteraction
>>> from skcomplex.core.spaces import LatticeSpace
>>> from skcomplex.core.system import System
>>> from skcomplex.engine.runtime import Simulation
>>> from skcomplex.specs import StateSpec
>>> class CellElement(Element):
... def __init__(self, label, visual=None, *, size: Annotated[float, StateSpec()] = 0.0):
... super().__init__(label, visual=visual)
... self.size = size
>>> cells = GroupElement('cells', element=CellElement, size=1, values={'size': 8.0}, variable=True)
>>> division = LifeCycleInteraction('division', target='cells', birth=1.0, split=('size',))
>>> system = System(elements=[cells], interactions=[division], space=LatticeSpace((3, 3)))
>>> trajectory = Simulation(system, random_state=0).check().prepare().run(until=2).trajectory_
>>> trajectory.agents('cells', 2).values['size'].tolist()
[2.0, 2.0, 2.0, 2.0]
"""
reports: ClassVar[tuple[str, ...]] = ('births', 'deaths', 'arrivals')
def __init__(
self,
label: str,
*,
target: Annotated[str, RoleSpec()],
birth: Chance | None = None,
death: Chance | None = None,
rate: Chance | None = None,
offspring: Count = 1,
placement: Placement = 'here',
inherit: Sequence[str] = (),
split: Sequence[str] = (),
mutate: Mutation | None = None,
schedule: str = 'random-sequential',
phase: str | None = None,
group: Annotated[str | None, RoleSpec(optional=True)] = None,
values: Values | None = None,
keep: Chance | None = None,
reset: Mapping[str, object] | None = None,
replace: bool = False,
renew: bool = False,
cull: Chance | None = None,
kin: Kinship | None = None,
arrival: Arrival | None = None,
pick: str = 'first',
) -> None:
super().__init__(label)
self.target = target
self.birth = birth
self.death = death
self.rate = rate
self.offspring = offspring
self.placement = placement
self.inherit = inherit
self.split = split
self.mutate = mutate
self.schedule = schedule
self.phase = phase
self.group = group
self.values = values
self.keep = keep
self.reset = reset
self.replace = replace
self.renew = renew
self.cull = cull
self.kin = kin
self.arrival = arrival
self.pick = pick
def _check_variants(self) -> None:
"""Raise an error for an unknown placement, schedule, or pick, or arrivals of a member schedule."""
self._check_variant('placement', self.placement, PLACEMENTS, functions=True)
self._check_variant('schedule', self.schedule, SCHEDULES)
self._check_variant('pick', self.pick, PICKS)
if self.schedule == 'member' and self.arrival is not None:
error_msg = f'{self.label!r} runs member by member, so no agent brings arrivals. Give `arrival=None`.'
raise ValidationError(error_msg)
def _check_space(self, space: BaseSpace | None) -> None:
"""Raise an error for a placement in a neighbouring cell or node in a continuous space."""
if self.placement == 'neighbor':
self._check_grid('placement', self.placement, space, "placement='here', or a placement function")
def _check(self) -> None:
"""Raise an error for an unknown placement, a birth by both probability and rate, or a bad function.
Raises:
ValidationError:
The placement, the schedule, or the pick is unknown, both a birth probability and a rate are given,
a value is neither a number nor a function, or the schedule `'member'` has arrivals.
"""
super()._check()
self._check_variants()
if self.birth is not None and self.rate is not None:
error_msg = f'{self.label!r} gives births by a probability and by a rate. Give one of them.'
raise ValidationError(error_msg)
for name in ('birth', 'death', 'rate', 'offspring', 'keep', 'cull', 'arrival'):
value = getattr(self, name)
if value is not None and not callable(value) and not isinstance(value, int | float):
error_msg = f'The {name} of {self.label!r} is {value!r}. Give a number or a function.'
raise ValidationError(error_msg)
for name in ('mutate', 'values', 'kin'):
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, or None.'
raise ValidationError(error_msg)
@staticmethod
def _evaluate(value: Chance | Count, agent: AgentViewProtocol, model: ModelViewProtocol) -> float:
"""Return a number, or the value of a function of an agent and the model view, as a float."""
return float(value(agent, model) if callable(value) else value)
def _happens(self, chance: Chance | None, agent: AgentViewProtocol, model: ModelViewProtocol) -> bool:
"""Return whether an event of a probability happens to an agent in this step."""
if chance is None:
return False
probability = self._evaluate(chance, agent, model)
return probability >= 1.0 or (probability > 0.0 and model.rng.random() < probability)
def _count_births(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> int:
"""Return the number of births of a parent in this step."""
if self.rate is not None:
return int(model.rng.poisson(self._evaluate(self.rate, agent, model)))
return int(self._happens(self.birth, agent, model))
def _get_places(
self,
agent: AgentViewProtocol,
count: int,
occupied: set[object],
model: ModelViewProtocol,
) -> list[object]:
"""Return the locations of the new agents of a parent, fewer when places run out."""
if callable(self.placement):
places = [self.placement(agent, model) for _ in range(count)]
return [place for place in places if place is not None]
if self.placement == 'here':
return [agent.location] * count
free = [cell for cell in agent.neighbor_cells() if cell not in occupied]
chosen = [free[int(index)] for index in model.rng.permutation(len(free))[:count]]
occupied.update(chosen)
return chosen
def _get_values(self, agent: AgentViewProtocol | None, model: ModelViewProtocol) -> dict[str, object]:
"""Return the inherited and given values of a new agent of a parent, or of an arrival."""
values: dict[str, object] = {} if agent is None else {name: getattr(agent, name) for name in self.inherit}
if self.values is not None:
values.update(self.values(agent, model))
return values
def _create(
self,
agent: AgentViewProtocol | None,
place: object,
values: dict[str, object],
model: ModelViewProtocol,
) -> None:
"""Create a new agent with mutated values at a place, `None` for a random one, in the group of the cycle."""
if self.mutate is not None:
values = self.mutate(values, model)
if self.group is not None:
model.create(self._get_role('group'), place, **values)
elif agent is not None:
agent.create(None, place, **values)
else:
model.create(self._get_role('target'), place, **values)
def _bear(self, agent: AgentViewProtocol, places: list[object], model: ModelViewProtocol) -> None:
"""Create the new agents of a parent at their places, with inherited, given, split, and mutated values."""
kept = 1 / (len(places) + 1) if self.keep is None else self._evaluate(self.keep, agent, model)
totals = {name: float(getattr(agent, name)) for name in self.split}
for name, amount in totals.items():
setattr(agent, name, amount * kept)
shares = {name: (amount - amount * kept) / max(len(places), 1) for name, amount in totals.items()}
for place in places:
values = self._get_values(agent, model)
values.update(shares)
self._create(agent, place, values, model)
def _renew(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> None:
"""Give an agent that dies the values of an offspring of its own, in place."""
values = self._get_values(agent, model)
if self.mutate is not None:
values = self.mutate(values, model)
for name, value in values.items():
setattr(agent, name, value)
def _remove_kin(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> None:
"""Remove the first or a random member of the group of the cycle that is kin of an agent, if there is one."""
kin = self.kin
group = self._get_role('target' if self.group is None else 'group')
members = (item for item in model.agents(group) if item.alive and (kin is None or kin(agent, item)))
if self.pick == 'random':
candidates = list(members)
if candidates:
candidates[int(model.rng.integers(len(candidates)))].remove()
return
member = next(members, None)
if member is not None:
member.remove()
def _die(self, agent: AgentViewProtocol, occupied: set[object], model: ModelViewProtocol) -> None:
"""Renew an agent in place, or remove it and free its cell."""
if self.renew:
self._renew(agent, model)
return
occupied.discard(agent.location_key)
agent.remove()
def _act(self, agent: AgentViewProtocol, occupied: set[object], model: ModelViewProtocol) -> tuple[int, int]:
"""Let an agent die, or give birth and remove kin, and return its numbers of new agents and deaths."""
if self._happens(self.death, agent, model):
self._die(agent, occupied, model)
return 0, 1
born, died = self._give_birth(agent, occupied, model)
if self._happens(self.cull, agent, model):
self._remove_kin(agent, model)
return born, died
def _give_birth(self, agent: AgentViewProtocol, occupied: set[object], model: ModelViewProtocol) -> tuple[int, int]:
"""Let an agent give birth, and return its number of new agents and whether it was replaced."""
born = 0
events = self._count_births(agent, model)
if events > 0:
births = events * int(self._evaluate(self.offspring, agent, model))
places = self._get_places(agent, births, occupied, model) if births else []
if places or self.split:
self._bear(agent, places, model)
born = len(places)
for name, value in (self.reset or {}).items():
setattr(agent, name, value)
if self.replace:
occupied.discard(agent.location_key)
agent.remove()
return born, 1
return born, 0
def _arrive(self, model: ModelViewProtocol) -> int:
"""Create the agents that arrive without a parent in this step, and return their number."""
if self.arrival is None:
return 0
mean = self.arrival(model) if callable(self.arrival) else self.arrival
count = int(model.rng.poisson(float(mean)))
for _ in range(count):
place = self.placement(None, model) if callable(self.placement) else None
self._create(None, place, self._get_values(None, model), model)
return count
def _live_member(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> None:
"""Run the life cycle of one agent in its own activation."""
if not agent.alive:
return
neighbor = self.placement == 'neighbor'
occupied = {other.location_key for other in model.agents(self._get_role('target'))} if neighbor else set()
self._act(agent, occupied, model)
def _live(self, model: ModelViewProtocol) -> None:
"""Remove or renew the agents that die and create the offspring of those that give birth, by the schedule."""
agents = model.agents(self._get_role('target'))
occupied = {agent.location_key for agent in agents} if self.placement == 'neighbor' else set()
births = deaths = 0
order = model.rng.permutation(len(agents)) if self.schedule == 'random-sequential' else range(len(agents))
for index in order:
agent = agents[int(index)]
if not agent.alive:
continue
born, died = self._act(agent, occupied, model)
births, deaths = births + born, deaths + died
model.report('births', births)
model.report('deaths', deaths)
model.report('arrivals', self._arrive(model))
def _get_laws(self, namespace: SimpleNamespace) -> list[BaseLaw]:
"""Return the rule of the life cycle.
Args:
namespace:
The namespace of the symbols the laws can use.
Returns:
laws:
The rule that removes and creates agents every step.
"""
if self.schedule == 'member':
return [RuleLaw(self._live_member, phase=self.phase, members=self._get_role('target'))]
return [RuleLaw(self._live, phase=self.phase)]