Skip to content

Gauge theory of agents on a network

Agent phases on the nodes of a network that relax towards the transported phases of their neighbours.

  • Name: gauge_network_agents
  • Field: social/opinion_dynamics
  • Formalism: ordinary differential equations
  • References: Mihaylov and Cacciatori 2025

Select this system

from skcomplex.systems import create_simulation, create_system, describe

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

Factory

@register_system(
    field='social/opinion_dynamics',
    tags=('gauge', 'opinions', 'frustration', 'network', 'cell complex'),
    references=('Mihaylov and Cacciatori 2025',),
    defaults={'until': 100.0, 'dt': 0.5},
    observables=_agent_observables,
)
def gauge_network_agents(
    n_agents: int = 12,
    *,
    flux: float = 0.0,
    K: float = 1.0,
    spread: float = 1.0,
    connections: Sequence[float] | None = None,
    graph: nx.Graph | None = None,
    seed: int = 0,
) -> System:
    """Return agent phases on the nodes of a network that relax towards the transported phases of their neighbours.

    Agent `i` follows `theta_i' = K sum_j sin(theta_j - theta_i - A_ij)` over its neighbours. A connection whose
    oriented sum around a loop is not a multiple of `2 pi` frustrates the loop: the transported differences cannot all
    vanish, so the agents keep a residual energy `sum (1 - cos(theta_j - theta_i - A_ij))` and a twist around the loop.

    Args:
        n_agents:
            The number of agents on a ring, when no graph is given.

        flux:
            The oriented sum of the connection around the first face of the minimum cycle basis, spread evenly over the
            edges of that face.

        K:
            The coupling strength.

        spread:
            The largest absolute initial phase.

        connections:
            The connection of every edge, in place of `flux`, or `None`.

        graph:
            The network, or `None` for a ring of `n_agents`.

        seed:
            The seed of the initial phases.

    Returns:
        system:
            The system.

    Raises:
        ValidationError:
            A flux is given on a network without faces.

    Examples:
        >>> from skcomplex.systems.social.opinion_dynamics import gauge_network_agents
        >>> [element.label for element in gauge_network_agents(4).elements]
        ['agents', 'links']
    """
    network = nx.cycle_graph(n_agents) if graph is None else graph
    space = CellComplexSpace(NetworkSpace(network)).prepare()
    faces = space.incidences_[1]
    if flux and not faces.shape[0] and connections is None:
        error_msg = f'A flux of {flux} needs a loop, but the network has no faces. Give a network with a cycle.'
        raise ValidationError(error_msg)
    if connections is not None:
        connection = np.asarray(connections, dtype=float)
    elif flux:
        first = faces[[0], :].toarray().ravel()
        connection = flux * first / np.abs(first).sum()
    else:
        connection = np.zeros(faces.shape[1])
    phases = np.random.default_rng(seed).uniform(-spread, spread, space.size_)
    agents = GroupElement('agents', element=PhaseAgentElement, space=space, on='sites', values={'theta': phases})
    links = GroupElement(
        'links',
        element=ConnectionElement,
        space=space,
        on='edges',
        values={'A': connection},
    )
    coupling = TransportedCouplingInteraction('coupling', target='agents', links='links', K=K)
    return System(elements=[agents, links], interactions=[coupling])

Components

Elements:

  • PhaseAgentElement, from Element
  • ConnectionElement, from Element

Interactions:

  • TransportedCouplingInteraction, from CouplingInteraction
class PhaseAgentElement(Element):
    """Agent with a phase `theta`.

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

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

        theta:
            The initial phase.

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

    Examples:
        >>> from skcomplex.systems.social.opinion_dynamics import PhaseAgentElement
        >>> PhaseAgentElement('agent', theta=0.5).theta
        0.5
    """

    def __init__(
        self,
        label: str,
        visual: VisualSpec | None = None,
        *,
        theta: Annotated[float, StateSpec(period=2 * pi)] = 0.0,
    ) -> None:
        super().__init__(label, visual=visual)
        self.theta = theta

class ConnectionElement(Element):
    """Edge of a network with a connection `A`, the phase an agent transports along it.

    Args:
        label:
            The label of the edge or the group on the edges.

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

        A:
            The connection along the direction of the edge.

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

    Examples:
        >>> from skcomplex.systems.social.opinion_dynamics import ConnectionElement
        >>> ConnectionElement('edge', A=0.5).A
        0.5
    """

    def __init__(
        self,
        label: str,
        visual: VisualSpec | None = None,
        *,
        A: Annotated[float, ParameterSpec()] = 0.0,
    ) -> None:
        super().__init__(label, visual=visual)
        self.A = A

class TransportedCouplingInteraction(CouplingInteraction):
    """Relaxation of agent phases towards the phases of their neighbours, `theta_i' += K sin(theta_j - theta_i - A_ij)`.

    The connection `A_ij` lives on the edge of the network from `i` to `j`, and `A_ji = -A_ij`.

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

        target:
            The path of the group of agents on the nodes.

        links:
            The path of the group on the edges, whose parameter `A` holds the connection.

        K:
            The coupling strength.

    Examples:
        >>> from skcomplex.systems.social.opinion_dynamics import TransportedCouplingInteraction
        >>> TransportedCouplingInteraction('coupling', target='agents', links='links', K=0.5).K
        0.5
    """

    def __init__(
        self,
        label: str,
        *,
        target: Annotated[str, RoleSpec()],
        links: Annotated[str, RoleSpec()],
        K: Annotated[float, ParameterSpec()] = 1.0,
    ) -> None:
        super().__init__(label, target=target, read='theta', write='theta')
        self.links = links
        self.K = K

    def _get_edge_laws(self, namespace: SimpleNamespace) -> list[BaseLaw]:
        """Return the rates of both ends of an edge, with the connection read along and against its direction."""
        source, target = namespace.source, namespace.target
        flow = namespace.K * sin(target.theta - source.theta - namespace.edge.A)
        return [RateLaw(source.theta, flow), RateLaw(target.theta, -flow)]

Equations