Skip to content

Architectures

The architecture helpers are ordinary torch.nn.Module classes built from SILVA equilibrium layers. They expose the same knobs that appear in an experiment: state width, number of layers, local operator, global operator, optional learned self term, solver family, solver parameters, readout head, task mode, pooling rule, and device placement.

The stacked equilibrium interpretation follows DEQ [4]; multiscale stacks connect to MDEQ [5], and graph/set readouts use the corresponding graph and invariant-set sources [15] [18].

Stack Recurrence

For a stack with \(K\) equilibrium layers, define

\[ h_0=x. \]

Layer \(k\) solves

\[ z_k^\star = f_{\theta_k}(z_k^\star,h_{k-1}), \]

then passes

\[ h_k=z_k^\star \]

to the next layer. A scalar hidden_dims=64 repeats the same state dimension in each layer. A list such as hidden_dims=[64, 48, 32] gives each layer its own width.

from silva_networks import SILVAStack, SolverConfig

stack = SILVAStack(
    in_dim=8,
    hidden_dims=[32, 32, 16],
    config=[
        SolverConfig(solver="picard", max_iter=10, alpha=0.5),
        SolverConfig(solver="anderson", max_iter=10, alpha=0.4, history=4, ridge=1e-4),
        SolverConfig(solver="broyden", max_iter=6, alpha=0.3),
    ],
    local=["graph", "topk", "graph_attention"],
    local_kwargs=[None, {"k": 8}, {"heads": 2}],
    global_term=["mean", "simple", "topk_attention"],
    global_kwargs=[None, None, {"k": 12}],
    self_term=[None, "linear", None],
)

When config is a single SolverConfig, the same solver settings are reused in every layer. When config is a list, each layer receives its own solver family and parameters.

The same rule applies to operator kwargs. A single dictionary is reused for every built-in operator. A list such as [None, {"k": 8}, {"heads": 2}] passes settings to each layer separately.

Control Surface

Control Argument Typical values
Input width in_dim Feature columns, node features, stem output width
State width hidden_dims 64, [64, 64], [128, 64, 32]
Stack depth num_layers or len(hidden_dims) One layer through deep multistacks
Local structure local "graph", "gat", "topk", custom nn.Module
Global context global_term "mean", "simple", "topk_attention", custom nn.Module
Learned self branch self_term None, "linear", "identity", custom nn.Module
Solver config.solver "picard", "anderson", "broyden"
Solver damping config.alpha One scalar per layer
Solver budget config.max_iter, config.tol Iteration cap and residual tolerance
Anderson controls config.history, config.ridge, config.beta Memory, regularization, mixing
Graph edges edge_index Shape (2, edges)
Edge features edge_attr Shape (edges, edge_dim) for edge-aware custom or GAT branches
Minibatch grouping batch Shape (entities,)
Prediction mode task, pooling Node prediction or graph prediction
Readout capacity head_hidden_dims, dropout MLP head depth and regularization

Strings select built-in operators. Lists select one operator per layer. Factories receive (dim, index) when they accept two positional arguments, so a stack can create width-specific modules automatically:

import torch
from silva_networks import SILVAGraphNetwork

class SignedLocal(torch.nn.Module):
    def __init__(self, dim: int, sign: float):
        super().__init__()
        self.sign = sign
        self.proj = torch.nn.Linear(dim, dim, bias=False)

    def forward(self, z, edge_index=None, edge_attr=None):
        return self.sign * torch.tanh(self.proj(z))

model = SILVAGraphNetwork(
    in_dim=12,
    hidden_dims=[64, 48, 32],
    out_dim=5,
    local=lambda dim, index: SignedLocal(dim, sign=(-1.0) ** index),
    global_term="simple",
)

Cortex Composition

SILVACortexLayer exposes a more general composition point than SILVAStack. It is designed for SILVA cortex hierarchies and for user-defined architectures where a single equilibrium point contains several trainable submodules.

One cortex point computes

\[ u=R_\phi(x), \qquad z^\star = \Psi\!\left[ u+B_\theta(a(z^\star)) +H_\theta(a(z^\star)) +L_\theta(a(z^\star),E) +G_\theta(a(z^\star),b) \right]. \]

The damped solver step is

\[ z_{k+1}=(1-\alpha)z_k+\alpha F_\theta(z_k,x). \]

Several cortex points can be linked:

from silva_networks import SILVACortexLayer, SILVACortexNetwork, SolverConfig

layer1 = SILVACortexLayer(
    input_dim=5,
    state_dim=14,
    state_network=torch.nn.Sequential(
        torch.nn.Linear(14, 14),
        torch.nn.Tanh(),
        torch.nn.Linear(14, 14),
    ),
    config=SolverConfig(solver="picard", alpha=0.5, max_iter=10),
)

layer2 = SILVACortexLayer(
    input_encoder=torch.nn.Linear(14, 10),
    state_dim=10,
    state_network=torch.nn.Sequential(
        torch.nn.Linear(10, 20),
        torch.nn.GELU(),
        torch.nn.Linear(20, 10),
    ),
    config=SolverConfig(solver="anderson", alpha=0.2, max_iter=10, history=3),
    normalize=False,
)

model = SILVACortexNetwork([layer1, layer2], links="tanh", head=torch.nn.Linear(10, 2))

Custom modules may accept z, stimulus, x, edge_index, edge_attr, or batch. Only the supported arguments are passed to each module. This keeps ordinary PyTorch modules usable while still allowing graph-aware and context-aware interaction branches.

The internal modules may be MLPs, convolutions, residual networks, U-Nets, attention blocks, or graph modules. Intermediate representations may change shape, but the completed transition must return exactly the equilibrium-state shape. Interaction fields may broadcast into that shape. A shape mismatch raises an error naming the responsible transition or branch before the solver continues.

Use normalizer=torch.nn.GroupNorm(...) for (batch, channels, height, width) states. The default LayerNorm(state_dim) is intended for states whose final dimension is the feature width.

Graph Readout

For graph-level prediction, entity states are pooled:

\[ h_g=\frac{1}{|\mathcal V_g|} \sum_{i\in\mathcal V_g}z_i^\star. \]

The readout head maps \(h_g\) to logits or regression outputs:

\[ \hat y_g=R_\phi(h_g). \]

For node-level prediction, the readout is applied to every node state:

\[ \hat y_i=R_\phi(z_i^\star). \]

The pooling mode can be "mean", "sum", or "max". A custom readout can be attached by replacing model.head with any PyTorch module whose input width matches the final equilibrium state.

Reference Stacks

SILVAGraphPresetNetwork, SILVAVisionVectorClassifier, SILVAConvVisionClassifier, and SILVAMolecularRegressor keep the SILVA paper defaults available through direct constructor arguments:

from silva_networks import SILVAGraphPresetNetwork

model = SILVAGraphPresetNetwork(
    in_dim=dataset_num_features,
    hidden_dim=[64, 48],
    out_dim=num_classes,
    task="node",
    attention_mode="simple",
    graph_mode="GAT",
    num_heads=4,
    k_neighbors=16,
    local_depth=2,
    stack_alphas=[0.5, 0.2],
    max_iter=15,
    solver="picard",
)

The same pattern works for molecules. Categorical atom and bond ids are embedded directly. Continuous features can be projected with atom_feature_dim and bond_feature_dim:

from silva_networks import SILVAMolecularRegressor

model = SILVAMolecularRegressor(
    hidden_dim=[128, 64],
    atom_feature_dim=9,
    bond_feature_dim=4,
    num_heads=4,
    alphas=(0.5, 0.2),
    max_iter=20,
)

Device Contract

Move the model and all tensors to the same device:

from silva_networks import move_to_device, resolve_device

device = resolve_device("auto")
model = model.to(device)
batch = move_to_device(batch, device)

Internal tensors created by solvers and layers follow the input state's device and dtype. CUDA, MPS, and CPU use the same public API; the installed PyTorch wheel determines which accelerators are available.

SILVACortexLayer

Bases: Module

Flexible SILVA equilibrium point with arbitrary internal modules.

A cortex layer first encodes the incoming object into a stimulus tensor,

\[ u = R_\phi(x), \]

then solves one equilibrium point

\[ z^\star = \Psi\!\left[ u + B_\theta(a(z^\star), u, x) + \sum_m I_{m,\theta}(a(z^\star), u, x, E, b) \right]. \]

The state_network term \(B_\theta\) may be a deep nn.Sequential or a list of modules. The interaction terms may be local, global, self, or any user-defined PyTorch modules. This covers the SILVA cortex hierarchy: a convolutional or linear front end, a fast first equilibrium point, a slower second equilibrium point, and different internal transition architectures at each point.

Reference: Jose Luis Silva, "SILVA Networks as Structured Implicit Layers and Vector Attractors via Dynamic Interaction Fields", arXiv:2607.28989.

Parameters:

Name Type Description Default
input_dim int | None

Input width for the default linear encoder.

None
state_dim int | None

State width. Required when input_encoder is omitted or when normalize=True.

None
input_encoder Module | None

Module mapping the incoming tensor to the recurrent state shape. If omitted, nn.Linear(input_dim, state_dim) is used.

None
state_network CortexModuleSpec

Module or sequence applied to the activated state inside each solver step.

None
self_terms CortexModuleSpec

Modules added as self-interaction branches.

None
local_terms CortexModuleSpec

Modules added as local interaction branches.

None
global_terms CortexModuleSpec

Modules added as global interaction branches.

None
interaction_terms CortexModuleSpec

Additional state-shaped interaction branches.

None
output_network Module | None

Optional module applied after summing the stimulus and interactions and before the outer activation.

None
normalizer Module | None

Optional normalization module. If omitted and normalize=True, LayerNorm(state_dim) is used.

None
config SolverConfig | None

Fixed-point solver configuration.

None
activation Callable[[Tensor], Tensor]

State activation \(a\) applied before interactions.

tanh
output_activation Callable[[Tensor], Tensor]

Outer nonlinearity \(\Psi\).

tanh
initializer CortexInitializer

zeros starts from zeros_like(u); stimulus starts from u.

'zeros'
Source code in src/silva_networks/architectures.py
class SILVACortexLayer(nn.Module):
    r"""Flexible SILVA equilibrium point with arbitrary internal modules.

    A cortex layer first encodes the incoming object into a stimulus tensor,

    $$
    u = R_\phi(x),
    $$

    then solves one equilibrium point

    $$
    z^\star
    =
    \Psi\!\left[
      u
      + B_\theta(a(z^\star), u, x)
      + \sum_m I_{m,\theta}(a(z^\star), u, x, E, b)
    \right].
    $$

    The `state_network` term \(B_\theta\) may be a deep `nn.Sequential` or a
    list of modules. The interaction terms may be local, global, self, or any
    user-defined PyTorch modules. This covers the SILVA cortex hierarchy:
    a convolutional or linear front end, a fast first equilibrium point, a
    slower second equilibrium point, and different internal transition
    architectures at each point.

    Reference: Jose Luis Silva, "SILVA Networks as Structured Implicit Layers
    and Vector Attractors via Dynamic Interaction Fields", arXiv:2607.28989.

    Args:
        input_dim: Input width for the default linear encoder.
        state_dim: State width. Required when `input_encoder` is omitted or
            when `normalize=True`.
        input_encoder: Module mapping the incoming tensor to the recurrent
            state shape. If omitted, `nn.Linear(input_dim, state_dim)` is used.
        state_network: Module or sequence applied to the activated state inside
            each solver step.
        self_terms: Modules added as self-interaction branches.
        local_terms: Modules added as local interaction branches.
        global_terms: Modules added as global interaction branches.
        interaction_terms: Additional state-shaped interaction branches.
        output_network: Optional module applied after summing the stimulus and
            interactions and before the outer activation.
        normalizer: Optional normalization module. If omitted and
            `normalize=True`, `LayerNorm(state_dim)` is used.
        config: Fixed-point solver configuration.
        activation: State activation \(a\) applied before interactions.
        output_activation: Outer nonlinearity \(\Psi\).
        initializer: `zeros` starts from `zeros_like(u)`; `stimulus` starts
            from `u`.
    """

    def __init__(
        self,
        input_dim: int | None = None,
        state_dim: int | None = None,
        *,
        input_encoder: nn.Module | None = None,
        state_network: CortexModuleSpec = None,
        self_terms: CortexModuleSpec = None,
        local_terms: CortexModuleSpec = None,
        global_terms: CortexModuleSpec = None,
        interaction_terms: CortexModuleSpec = None,
        output_network: nn.Module | None = None,
        normalizer: nn.Module | None = None,
        config: SolverConfig | None = None,
        activation: Callable[[Tensor], Tensor] = torch.tanh,
        output_activation: Callable[[Tensor], Tensor] = torch.tanh,
        normalize: bool = True,
        initializer: CortexInitializer = "zeros",
    ):
        super().__init__()
        if input_encoder is None:
            if input_dim is None or state_dim is None:
                raise ValueError(
                    "input_dim and state_dim are required when input_encoder is omitted"
                )
            input_encoder = nn.Linear(input_dim, state_dim)
        if normalize and normalizer is None and state_dim is None:
            raise ValueError(
                "state_dim is required when normalize=True and no normalizer is supplied"
            )
        if initializer not in {"zeros", "stimulus"}:
            raise ValueError("initializer must be 'zeros' or 'stimulus'")

        self.input_encoder = input_encoder
        self.state_network = _normalize_cortex_modules(state_network)
        self.self_terms = _normalize_cortex_modules(self_terms)
        self.local_terms = _normalize_cortex_modules(local_terms)
        self.global_terms = _normalize_cortex_modules(global_terms)
        self.interaction_terms = _normalize_cortex_modules(interaction_terms)
        self.output_network = output_network or nn.Identity()
        self.normalizer = (
            normalizer
            if normalizer is not None
            else (nn.LayerNorm(state_dim) if normalize else nn.Identity())
        )
        self.config = config or SolverConfig(alpha=0.5, max_iter=20)
        self.activation = activation
        self.output_activation = output_activation
        self.initializer = initializer
        self.input_dim = input_dim
        self.state_dim = state_dim

    def encode(self, x: Tensor) -> Tensor:
        """Encode the incoming tensor into the equilibrium stimulus shape."""

        return self.input_encoder(x)

    def initial_state(self, stimulus: Tensor, z0: Tensor | None = None) -> Tensor:
        """Return the initial solver state."""

        if z0 is not None:
            return z0
        if self.initializer == "stimulus":
            return stimulus
        return torch.zeros_like(stimulus)

    def f(
        self,
        z: Tensor,
        stimulus: Tensor,
        x: Tensor | None = None,
        edge_index: Tensor | None = None,
        edge_attr: Tensor | None = None,
        batch: Tensor | None = None,
    ) -> Tensor:
        """Evaluate the undamped cortex transition."""

        signal = self.activation(z)
        total = stimulus
        if len(self.state_network) > 0:
            state_field = _run_cortex_sequence(
                self.state_network,
                signal,
                stimulus=stimulus,
                x=x,
                edge_index=edge_index,
                edge_attr=edge_attr,
                batch=batch,
            )
            total = _add_cortex_field(
                total,
                state_field,
                z,
                source="state_network",
            )
        for module in (
            *self.self_terms,
            *self.local_terms,
            *self.global_terms,
            *self.interaction_terms,
        ):
            field = _call_cortex_module(
                module,
                signal,
                stimulus=stimulus,
                x=x,
                edge_index=edge_index,
                edge_attr=edge_attr,
                batch=batch,
            )
            total = _add_cortex_field(
                total,
                field,
                z,
                source=module.__class__.__name__,
            )
        total = _call_cortex_module(
            self.output_network,
            total,
            stimulus=stimulus,
            x=x,
            edge_index=edge_index,
            edge_attr=edge_attr,
            batch=batch,
        )
        output = self.normalizer(self.output_activation(total))
        if output.shape != z.shape:
            raise ValueError(
                "cortex transition must preserve the equilibrium-state shape: "
                f"expected {tuple(z.shape)}, received {tuple(output.shape)}"
            )
        return output

    def forward(
        self,
        x: Tensor,
        edge_index: Tensor | None = None,
        edge_attr: Tensor | None = None,
        batch: Tensor | None = None,
        z0: Tensor | None = None,
        return_result: bool = False,
    ):
        stimulus = self.encode(x)
        z_init = self.initial_state(stimulus, z0=z0)

        def transition(z: Tensor) -> Tensor:
            return self.f(
                z,
                stimulus,
                x=x,
                edge_index=edge_index,
                edge_attr=edge_attr,
                batch=batch,
            )

        result = solve_equilibrium(
            transition,
            z_init,
            self.config,
            params=tuple(self.parameters()),
            tensors=_differentiable_tensors(x, edge_attr),
        )
        return result if return_result else result.z

encode

encode(x)

Encode the incoming tensor into the equilibrium stimulus shape.

Source code in src/silva_networks/architectures.py
def encode(self, x: Tensor) -> Tensor:
    """Encode the incoming tensor into the equilibrium stimulus shape."""

    return self.input_encoder(x)

f

f(z, stimulus, x=None, edge_index=None, edge_attr=None, batch=None)

Evaluate the undamped cortex transition.

Source code in src/silva_networks/architectures.py
def f(
    self,
    z: Tensor,
    stimulus: Tensor,
    x: Tensor | None = None,
    edge_index: Tensor | None = None,
    edge_attr: Tensor | None = None,
    batch: Tensor | None = None,
) -> Tensor:
    """Evaluate the undamped cortex transition."""

    signal = self.activation(z)
    total = stimulus
    if len(self.state_network) > 0:
        state_field = _run_cortex_sequence(
            self.state_network,
            signal,
            stimulus=stimulus,
            x=x,
            edge_index=edge_index,
            edge_attr=edge_attr,
            batch=batch,
        )
        total = _add_cortex_field(
            total,
            state_field,
            z,
            source="state_network",
        )
    for module in (
        *self.self_terms,
        *self.local_terms,
        *self.global_terms,
        *self.interaction_terms,
    ):
        field = _call_cortex_module(
            module,
            signal,
            stimulus=stimulus,
            x=x,
            edge_index=edge_index,
            edge_attr=edge_attr,
            batch=batch,
        )
        total = _add_cortex_field(
            total,
            field,
            z,
            source=module.__class__.__name__,
        )
    total = _call_cortex_module(
        self.output_network,
        total,
        stimulus=stimulus,
        x=x,
        edge_index=edge_index,
        edge_attr=edge_attr,
        batch=batch,
    )
    output = self.normalizer(self.output_activation(total))
    if output.shape != z.shape:
        raise ValueError(
            "cortex transition must preserve the equilibrium-state shape: "
            f"expected {tuple(z.shape)}, received {tuple(output.shape)}"
        )
    return output

initial_state

initial_state(stimulus, z0=None)

Return the initial solver state.

Source code in src/silva_networks/architectures.py
def initial_state(self, stimulus: Tensor, z0: Tensor | None = None) -> Tensor:
    """Return the initial solver state."""

    if z0 is not None:
        return z0
    if self.initializer == "stimulus":
        return stimulus
    return torch.zeros_like(stimulus)

SILVACortexNetwork

Bases: Module

Link several SILVACortexLayer equilibrium points in one PyTorch model.

Each layer may have its own encoder, internal transition network, interaction terms, and solver configuration. The link between equilibrium points is configurable; the SILVA fast/slow hierarchy uses links="tanh" with different SolverConfig.alpha values per layer.

Source code in src/silva_networks/architectures.py
class SILVACortexNetwork(nn.Module):
    """Link several `SILVACortexLayer` equilibrium points in one PyTorch model.

    Each layer may have its own encoder, internal transition network,
    interaction terms, and solver configuration. The link between equilibrium
    points is configurable; the SILVA fast/slow hierarchy uses
    `links="tanh"` with different `SolverConfig.alpha` values per layer.
    """

    def __init__(
        self,
        layers: Sequence[SILVACortexLayer],
        *,
        links: CortexLink | nn.Module | Sequence[CortexLink | nn.Module] = "tanh",
        head: nn.Module | None = None,
    ):
        super().__init__()
        if len(layers) < 1:
            raise ValueError("SILVACortexNetwork needs at least one cortex layer")
        self.layers = nn.ModuleList(layers)
        self.links = _normalize_cortex_links(links, len(layers) - 1)
        self.head = head or nn.Identity()

    def forward(
        self,
        x: Tensor,
        edge_index: Tensor | None = None,
        edge_attr: Tensor | None = None,
        batch: Tensor | None = None,
        return_state: bool = False,
        return_results: bool = False,
    ):
        state = x
        states: list[Tensor] = []
        results: list[SolverResult] = []
        for index, layer in enumerate(self.layers):
            result = layer(
                state,
                edge_index=edge_index,
                edge_attr=edge_attr,
                batch=batch,
                return_result=True,
            )
            state = result.z
            states.append(state)
            results.append(result)
            if index < len(self.layers) - 1:
                state = _apply_cortex_link(self.links[index], state)
        output = self.head(state)
        if return_state or return_results:
            return SILVACortexOutput(
                output=output,
                state=state,
                states=states,
                solver_results=results,
            )
        return output

SILVACortexOutput dataclass

Structured output for linked cortex-style equilibrium points.

Attributes:

Name Type Description
output Tensor

Final tensor after the optional readout head.

state Tensor

Final equilibrium state.

states list[Tensor]

Equilibrium state produced by each cortex point.

solver_results list[SolverResult]

Solver metadata for each cortex point.

Source code in src/silva_networks/architectures.py
@dataclass
class SILVACortexOutput:
    """Structured output for linked cortex-style equilibrium points.

    Attributes:
        output: Final tensor after the optional readout head.
        state: Final equilibrium state.
        states: Equilibrium state produced by each cortex point.
        solver_results: Solver metadata for each cortex point.
    """

    output: Tensor
    state: Tensor
    states: list[Tensor]
    solver_results: list[SolverResult]

SILVAGraphNetwork

Bases: Module

End-to-end graph or node model built from a SILVA stack and readout head.

Source code in src/silva_networks/architectures.py
class SILVAGraphNetwork(nn.Module):
    """End-to-end graph or node model built from a SILVA stack and readout head."""

    def __init__(
        self,
        in_dim: int,
        hidden_dims: int | Sequence[int],
        out_dim: int,
        num_layers: int | None = None,
        task: Task = "node",
        pooling: Pooling = "mean",
        config: SolverConfig | Sequence[SolverConfig] | None = None,
        local: TermSpec = "graph",
        global_term: TermSpec = "mean",
        self_term: TermSpec = None,
        head_hidden_dims: Sequence[int] = (),
        dropout: float = 0.0,
        normalize: bool = True,
        local_kwargs: KwargsSpec = None,
        global_kwargs: KwargsSpec = None,
        self_kwargs: KwargsSpec = None,
        encoder: nn.Module | None = None,
        head: nn.Module | None = None,
    ):
        super().__init__()
        if task not in {"node", "graph"}:
            raise ValueError("task must be either 'node' or 'graph'")
        self.encoder = encoder or SILVAStack(
            in_dim=in_dim,
            hidden_dims=hidden_dims,
            num_layers=num_layers,
            config=config,
            local=local,
            global_term=global_term,
            self_term=self_term,
            normalize=normalize,
            local_kwargs=local_kwargs,
            global_kwargs=global_kwargs,
            self_kwargs=self_kwargs,
        )
        encoder_out_dim = getattr(self.encoder, "out_dim", None)
        if head is None and encoder_out_dim is None:
            raise ValueError("a custom encoder without out_dim requires a custom head")
        self.head = head or build_mlp_head(
            encoder_out_dim, out_dim, head_hidden_dims, dropout
        )
        self.task = task
        self.pooling = pooling

    def forward(
        self,
        x: Tensor,
        edge_index: Tensor | None = None,
        edge_attr: Tensor | None = None,
        batch: Tensor | None = None,
        return_state: bool = False,
        return_results: bool = False,
    ):
        if return_results:
            state, results = self.encoder(
                x,
                edge_index=edge_index,
                edge_attr=edge_attr,
                batch=batch,
                return_results=True,
            )
        else:
            state = self.encoder(x, edge_index=edge_index, edge_attr=edge_attr, batch=batch)
            results = None

        features = (
            state if self.task == "node" else pool_entities(state, batch=batch, mode=self.pooling)
        )
        output = self.head(features)
        if return_state or return_results:
            return SILVANetworkOutput(output=output, state=state, solver_results=results)
        return output

SILVAImageClassifier

Bases: Module

Image classifier using one or more SILVA image equilibrium layers.

Source code in src/silva_networks/architectures.py
class SILVAImageClassifier(nn.Module):
    """Image classifier using one or more SILVA image equilibrium layers."""

    def __init__(
        self,
        in_channels: int,
        hidden_channels: int | Sequence[int],
        num_classes: int,
        num_layers: int | None = None,
        config: SolverConfig | Sequence[SolverConfig] | None = None,
        head_hidden_dims: Sequence[int] = (),
        dropout: float = 0.0,
        layers: Sequence[nn.Module] | None = None,
        head: nn.Module | None = None,
    ):
        super().__init__()
        channels = _normalize_hidden_dims(hidden_channels, num_layers)
        configs = _normalize_configs(config, len(channels))
        if layers is not None and len(layers) != len(channels):
            raise ValueError("layers must match the requested number of hidden channels")
        built_layers: list[nn.Module] = []
        previous = in_channels
        for index, hidden in enumerate(channels):
            built_layers.append(
                layers[index]
                if layers is not None
                else SILVAImageLayer(previous, hidden, config=configs[index])
            )
            previous = hidden
        self.layers = nn.ModuleList(built_layers)
        self.head = head or build_mlp_head(
            channels[-1], num_classes, head_hidden_dims, dropout
        )
        self.out_channels = channels[-1]

    def forward(self, x: Tensor, return_state: bool = False, return_results: bool = False):
        state = x
        results: list[SolverResult] = []
        for layer in self.layers:
            result = layer(state, return_result=True)
            state = result.z
            results.append(result)
        features = F.adaptive_avg_pool2d(state, output_size=1).flatten(1)
        output = self.head(features)
        if return_state or return_results:
            return SILVANetworkOutput(
                output=output,
                state=state,
                solver_results=results if return_results else None,
            )
        return output

SILVANetworkOutput dataclass

Optional structured output for models that expose equilibrium states.

Source code in src/silva_networks/architectures.py
@dataclass
class SILVANetworkOutput:
    """Optional structured output for models that expose equilibrium states."""

    output: Tensor
    state: Tensor
    solver_results: list[SolverResult] | None = None

SILVAStack

Bases: Module

Stack multiple trainable SILVA equilibrium layers.

Source code in src/silva_networks/architectures.py
class SILVAStack(nn.Module):
    """Stack multiple trainable SILVA equilibrium layers."""

    def __init__(
        self,
        in_dim: int,
        hidden_dims: int | Sequence[int],
        num_layers: int | None = None,
        config: SolverConfig | Sequence[SolverConfig] | None = None,
        local: TermSpec = "graph",
        global_term: TermSpec = "mean",
        self_term: TermSpec = None,
        normalize: bool = True,
        local_kwargs: KwargsSpec = None,
        global_kwargs: KwargsSpec = None,
        self_kwargs: KwargsSpec = None,
        layers: Sequence[nn.Module] | None = None,
    ):
        super().__init__()
        dims = _normalize_hidden_dims(hidden_dims, num_layers)
        configs = _normalize_configs(config, len(dims))
        local_kwargs_by_layer = _normalize_kwargs(local_kwargs, len(dims), "local_kwargs")
        global_kwargs_by_layer = _normalize_kwargs(global_kwargs, len(dims), "global_kwargs")
        self_kwargs_by_layer = _normalize_kwargs(self_kwargs, len(dims), "self_kwargs")

        if layers is not None and len(layers) != len(dims):
            raise ValueError("layers must match the requested number of hidden dimensions")
        built_layers: list[nn.Module] = []
        previous = in_dim
        for index, hidden in enumerate(dims):
            if layers is not None:
                built_layers.append(layers[index])
            else:
                local_spec = _resolve_term_spec(local, hidden, index)
                global_spec = _resolve_term_spec(global_term, hidden, index)
                self_spec = _resolve_term_spec(self_term, hidden, index)
                built_layers.append(
                    SILVALayer(
                        in_dim=previous,
                        hidden_dim=hidden,
                        local=local_spec,  # type: ignore[arg-type]
                        global_term=global_spec,  # type: ignore[arg-type]
                        self_term=self_spec,  # type: ignore[arg-type]
                        config=configs[index],
                        normalize=normalize,
                        local_kwargs=local_kwargs_by_layer[index]
                        if isinstance(local_spec, str)
                        else None,
                        global_kwargs=global_kwargs_by_layer[index]
                        if isinstance(global_spec, str)
                        else None,
                        self_kwargs=self_kwargs_by_layer[index]
                        if isinstance(self_spec, str)
                        else None,
                    )
                )
            previous = hidden
        self.layers = nn.ModuleList(built_layers)
        self.in_dim = in_dim
        self.hidden_dims = tuple(dims)
        self.out_dim = dims[-1]

    def forward(
        self,
        x: Tensor,
        edge_index: Tensor | None = None,
        edge_attr: Tensor | None = None,
        batch: Tensor | None = None,
        return_results: bool = False,
    ):
        state = x
        results: list[SolverResult] = []
        for layer in self.layers:
            result = layer(
                state,
                edge_index=edge_index,
                edge_attr=edge_attr,
                batch=batch,
                return_result=True,
            )
            state = result.z
            results.append(result)
        return (state, results) if return_results else state

build_mlp_head

build_mlp_head(in_dim, out_dim, hidden_dims=(), dropout=0.0, activation=nn.ReLU)

Build a small readout head for node, graph, or image representations.

Source code in src/silva_networks/architectures.py
def build_mlp_head(
    in_dim: int,
    out_dim: int,
    hidden_dims: Sequence[int] = (),
    dropout: float = 0.0,
    activation: Callable[[], nn.Module] = nn.ReLU,
) -> nn.Sequential:
    """Build a small readout head for node, graph, or image representations."""

    layers: list[nn.Module] = []
    previous = in_dim
    for hidden in hidden_dims:
        layers.append(nn.Linear(previous, hidden))
        layers.append(activation())
        if dropout > 0:
            layers.append(nn.Dropout(dropout))
        previous = hidden
    layers.append(nn.Linear(previous, out_dim))
    return nn.Sequential(*layers)

pool_entities

pool_entities(z, batch=None, mode='mean')

Pool entity states into graph-level or set-level states.

Source code in src/silva_networks/architectures.py
def pool_entities(z: Tensor, batch: Tensor | None = None, mode: Pooling = "mean") -> Tensor:
    """Pool entity states into graph-level or set-level states."""

    if z.dim() != 2:
        raise ValueError("pool_entities expects z with shape (entities, dim)")
    if mode not in {"mean", "sum", "max"}:
        raise ValueError(f"Unknown pooling mode: {mode}")
    if batch is None:
        if mode == "mean":
            return z.mean(dim=0, keepdim=True)
        if mode == "sum":
            return z.sum(dim=0, keepdim=True)
        return z.max(dim=0, keepdim=True).values

    pooled: list[Tensor] = []
    for graph_id in torch.unique(batch, sorted=True):
        values = z[batch == graph_id]
        if mode == "mean":
            pooled.append(values.mean(dim=0))
        elif mode == "sum":
            pooled.append(values.sum(dim=0))
        else:
            pooled.append(values.max(dim=0).values)
    return torch.stack(pooled, dim=0)

silva_cortex_layer

silva_cortex_layer(input_dim=None, state_dim=None, **kwargs)

Create a flexible cortex-style SILVA equilibrium point.

Source code in src/silva_networks/architectures.py
def silva_cortex_layer(
    input_dim: int | None = None,
    state_dim: int | None = None,
    **kwargs,
) -> SILVACortexLayer:
    """Create a flexible cortex-style SILVA equilibrium point."""

    return SILVACortexLayer(input_dim=input_dim, state_dim=state_dim, **kwargs)

silva_cortex_network

silva_cortex_network(layers, **kwargs)

Create a linked hierarchy of cortex-style equilibrium points.

Source code in src/silva_networks/architectures.py
def silva_cortex_network(
    layers: Sequence[SILVACortexLayer],
    **kwargs,
) -> SILVACortexNetwork:
    """Create a linked hierarchy of cortex-style equilibrium points."""

    return SILVACortexNetwork(layers, **kwargs)

Where to Go Next

Question Page
How are linked points derived? Cortex Hierarchies
Where is a hierarchy executed? Cortex Hierarchy Example
Which objects define an individual point? Layers API