groupoid 0.1.0.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
groupoid/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """GROUPOID: Groupoid-based federated learning with Riemannian geometry."""
2
+
3
+ __version__ = "0.1.0.dev1"
4
+
5
+ from groupoid.aggregation import (
6
+ DisconnectedClientGraphError,
7
+ FederatedRound,
8
+ TransportGroupoidAggregator,
9
+ )
10
+ from groupoid.cohomology import IncompleteCocycleError, compute_h1
11
+ from groupoid.groupoid import CompositionError, Morphism, compose, inverse
12
+ from groupoid.laplacian import SpectralSummary, spectral_analysis
13
+ from groupoid.manifold import karcher_mean
14
+ from groupoid.sheaf import Sheaf
15
+
16
+ __all__ = [
17
+ "__version__",
18
+ "CompositionError",
19
+ "DisconnectedClientGraphError",
20
+ "FederatedRound",
21
+ "IncompleteCocycleError",
22
+ "Morphism",
23
+ "Sheaf",
24
+ "SpectralSummary",
25
+ "TransportGroupoidAggregator",
26
+ "compose",
27
+ "compute_h1",
28
+ "inverse",
29
+ "karcher_mean",
30
+ "spectral_analysis",
31
+ ]
@@ -0,0 +1,234 @@
1
+ """Federated aggregation via groupoid transport and Karcher mean.
2
+
3
+ This module ties together the transport groupoid, sheaf consistency,
4
+ and Riemannian aggregation into a complete federated learning round.
5
+ The key insight is that naive parameter averaging fails when client
6
+ models live on different tangent spaces. Instead, we:
7
+
8
+ 1. Transport all client parameters to a common base point via the
9
+ groupoid morphisms.
10
+ 2. Check cohomological consistency (H^1) to detect irreconcilable
11
+ disagreements before aggregation.
12
+ 3. Compute the Karcher mean on the manifold of the transported
13
+ parameters.
14
+ 4. Transport the aggregated model back to each client's local frame.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass, field
20
+
21
+ import networkx as nx
22
+ import numpy as np
23
+ import numpy.typing as npt
24
+ from loguru import logger
25
+
26
+ from groupoid.cohomology import compute_h1
27
+ from groupoid.groupoid import Morphism, compose, inverse
28
+ from groupoid.manifold import karcher_mean
29
+
30
+
31
+ class DisconnectedClientGraphError(Exception):
32
+ """Raised when the client graph has no transport path to the base node.
33
+
34
+ Aggregation transports every client's parameters to the base node along
35
+ a path in the client graph. If a client lies in a different connected
36
+ component from the base node, no such path exists and the parameters
37
+ cannot be aligned. This error replaces the raw networkx
38
+ ``NetworkXNoPath`` with a domain-meaningful message naming the
39
+ unreachable node and the base.
40
+ """
41
+
42
+
43
+ @dataclass
44
+ class FederatedRound:
45
+ """Result of a single federated aggregation round."""
46
+
47
+ global_params: npt.NDArray[np.float64]
48
+ local_updates: dict[str, npt.NDArray[np.float64]]
49
+ h1_norm: float
50
+ is_consistent: bool
51
+ transport_residuals: dict[str, float]
52
+ round_idx: int = 0
53
+
54
+
55
+ @dataclass
56
+ class TransportGroupoidAggregator:
57
+ """Federated aggregator using groupoid transport and Riemannian geometry.
58
+
59
+ Given a network of clients with local model parameters on a Riemannian
60
+ manifold, this aggregator:
61
+ - Uses parallel transport (groupoid morphisms) to align parameters
62
+ - Checks cohomological consistency before aggregation
63
+ - Computes intrinsic Karcher mean on the parameter manifold
64
+ - Distributes the result back via inverse transport
65
+
66
+ Parameters
67
+ ----------
68
+ manifold
69
+ A geomstats manifold instance for the parameter space.
70
+ graph
71
+ Directed graph of the client network.
72
+ base_node
73
+ The node to which all parameters are transported for aggregation.
74
+ consistency_threshold
75
+ Maximum H^1 norm before raising a consistency warning.
76
+ """
77
+
78
+ manifold: object
79
+ graph: nx.DiGraph
80
+ base_node: str
81
+ consistency_threshold: float = 1e-6
82
+ morphisms: dict[tuple[str, str], Morphism] = field(default_factory=dict)
83
+ _round_idx: int = field(default=0, init=False)
84
+
85
+ def register_transport(
86
+ self, source: str, target: str, matrix: npt.NDArray[np.float64]
87
+ ) -> None:
88
+ """Register a transport map between two clients."""
89
+ self.morphisms[(source, target)] = Morphism(
90
+ source=source,
91
+ target=target,
92
+ transport_map=matrix,
93
+ )
94
+ logger.debug("Registered transport {} -> {}", source, target)
95
+
96
+ def _get_transport_to_base(self, node: str) -> npt.NDArray[np.float64] | None:
97
+ """Compute the composite transport map from node to base_node.
98
+
99
+ Uses shortest path in the graph and composes morphisms along it.
100
+ Returns None if node == base_node (identity transport).
101
+ """
102
+ if node == self.base_node:
103
+ return None
104
+
105
+ try:
106
+ path = nx.shortest_path(self.graph.to_undirected(), node, self.base_node)
107
+ except nx.NetworkXNoPath as e:
108
+ raise DisconnectedClientGraphError(
109
+ f"client graph is disconnected: no transport path from "
110
+ f"{node} to base {self.base_node}"
111
+ ) from e
112
+ composite: Morphism | None = None
113
+
114
+ for i in range(len(path) - 1):
115
+ src, tgt = path[i], path[i + 1]
116
+ if (src, tgt) in self.morphisms:
117
+ m = self.morphisms[(src, tgt)]
118
+ elif (tgt, src) in self.morphisms:
119
+ m = inverse(self.morphisms[(tgt, src)])
120
+ else:
121
+ raise ValueError(f"No transport map for edge ({src}, {tgt})")
122
+
123
+ composite = m if composite is None else compose(composite, m)
124
+
125
+ return composite.transport_map if composite is not None else None
126
+
127
+ def check_consistency(self, client_params: dict[str, npt.NDArray[np.float64]]) -> float:
128
+ """Check cohomological consistency of current transport maps.
129
+
130
+ Returns the H^1 norm. A value near zero indicates that the
131
+ local models can be consistently aggregated.
132
+ """
133
+ transport_maps = {(m.source, m.target): m.transport_map for m in self.morphisms.values()}
134
+ h1 = compute_h1(self.graph, transport_maps)
135
+ logger.info("Cohomological consistency check: H^1 = {:.2e}", h1)
136
+ return h1
137
+
138
+ def aggregate(
139
+ self,
140
+ client_params: dict[str, npt.NDArray[np.float64]],
141
+ weights: dict[str, float] | None = None,
142
+ ) -> FederatedRound:
143
+ """Run one round of groupoid-aware federated aggregation.
144
+
145
+ Parameters
146
+ ----------
147
+ client_params
148
+ Map from client node ID to local model parameters.
149
+ weights
150
+ Optional per-client weights for the Karcher mean.
151
+
152
+ Returns
153
+ -------
154
+ FederatedRound
155
+ The aggregation result including global params, consistency
156
+ metrics, and per-client local updates.
157
+ """
158
+ self._round_idx += 1
159
+ logger.info("Starting aggregation round {}", self._round_idx)
160
+
161
+ h1 = self.check_consistency(client_params)
162
+ is_consistent = h1 < self.consistency_threshold
163
+
164
+ if not is_consistent:
165
+ logger.warning(
166
+ "H^1 = {:.2e} exceeds threshold {:.2e}, "
167
+ "aggregation may produce inconsistent results",
168
+ h1,
169
+ self.consistency_threshold,
170
+ )
171
+
172
+ # Transport all client params to base node frame
173
+ transported = {}
174
+ transport_residuals = {}
175
+ for node, params in client_params.items():
176
+ if node == self.base_node:
177
+ transported[node] = params
178
+ transport_residuals[node] = 0.0
179
+ else:
180
+ # _get_transport_to_base returns None only for node == base_node
181
+ # (excluded by this else); a disconnected graph raises
182
+ # NetworkXNoPath. This guard is therefore defensively
183
+ # unreachable.
184
+ T = self._get_transport_to_base(node)
185
+ if T is None: # pragma: no cover - unreachable defensive guard (see above)
186
+ raise ValueError(f"No transport path from {node} to {self.base_node}")
187
+ transported_params = T @ params
188
+ transported[node] = transported_params
189
+ transport_residuals[node] = float(
190
+ np.linalg.norm(T @ T.T - np.eye(T.shape[0]), "fro")
191
+ )
192
+
193
+ # Stack transported params and compute Karcher mean
194
+ nodes = sorted(transported.keys())
195
+ param_stack = np.stack([transported[n] for n in nodes])
196
+
197
+ if weights is not None:
198
+ w = np.array([weights.get(n, 1.0) for n in nodes])
199
+ w = w / w.sum()
200
+ else:
201
+ w = None
202
+
203
+ global_params = karcher_mean(self.manifold, param_stack, weights=w)
204
+
205
+ # Transport global params back to each client's local frame
206
+ local_updates = {}
207
+ for node in client_params:
208
+ if node == self.base_node:
209
+ local_updates[node] = global_params
210
+ else:
211
+ # Same defensively-unreachable guard as in the forward
212
+ # transport loop above.
213
+ T = self._get_transport_to_base(node)
214
+ if T is None: # pragma: no cover - unreachable defensive guard (see above)
215
+ raise ValueError(f"No transport path from {node} to {self.base_node}")
216
+ T_inv = np.linalg.inv(T)
217
+ local_updates[node] = T_inv @ global_params
218
+
219
+ result = FederatedRound(
220
+ global_params=global_params,
221
+ local_updates=local_updates,
222
+ h1_norm=h1,
223
+ is_consistent=is_consistent,
224
+ transport_residuals=transport_residuals,
225
+ round_idx=self._round_idx,
226
+ )
227
+
228
+ logger.info(
229
+ "Round {} complete: H^1={:.2e}, consistent={}",
230
+ self._round_idx,
231
+ h1,
232
+ is_consistent,
233
+ )
234
+ return result
groupoid/cohomology.py ADDED
@@ -0,0 +1,99 @@
1
+ """First cohomology of transport groupoids."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import networkx as nx
6
+ import numpy as np
7
+ import numpy.typing as npt
8
+ from loguru import logger
9
+
10
+
11
+ class IncompleteCocycleError(Exception):
12
+ """Raised when a cocycle is incompletely specified.
13
+
14
+ The holonomy around a cycle is the ordered product of the transport
15
+ maps on *every* edge of that cycle. If any edge on a cycle has no
16
+ transport map (in either direction), the holonomy is undefined: a
17
+ product over a strict subset of the cycle's edges is mathematically
18
+ meaningless and must not be reported as an H^1 (in)consistency. This
19
+ error names the missing edge so the caller can supply it.
20
+ """
21
+
22
+
23
+ def compute_h1(
24
+ graph: nx.DiGraph,
25
+ transport_maps: dict[tuple[str, str], npt.NDArray[np.float64]],
26
+ ) -> float:
27
+ """Compute the H^1 cohomology norm of a transport cocycle.
28
+
29
+ Returns 0.0 (up to numerical tolerance) when the cocycle is a
30
+ coboundary, meaning the local data is globally consistent.
31
+
32
+ The H^1 obstruction on a cycle is the deviation from the identity of
33
+ the holonomy, i.e. the ordered product of the restriction/transport
34
+ maps around the *entire* cycle. The cocycle must therefore be fully
35
+ specified: every edge of every basis cycle needs a transport map in
36
+ one direction or the other (the reverse direction is inverted). A
37
+ missing edge map leaves the holonomy undefined, so this function
38
+ raises :class:`IncompleteCocycleError` rather than silently forming a
39
+ partial product.
40
+
41
+ Parameters
42
+ ----------
43
+ graph : nx.DiGraph
44
+ The groupoid nerve (directed graph of nodes/clients).
45
+ transport_maps : dict
46
+ Maps (source, target) edge tuples to transport matrices.
47
+
48
+ Returns
49
+ -------
50
+ float
51
+ The cohomology norm. Zero indicates global consistency.
52
+
53
+ Raises
54
+ ------
55
+ IncompleteCocycleError
56
+ If any edge on a basis cycle has no transport map in either
57
+ direction, so the cycle's holonomy is undefined.
58
+ """
59
+ undirected = graph.to_undirected()
60
+ cycles = nx.cycle_basis(undirected)
61
+
62
+ if not cycles:
63
+ logger.debug("No cycles in graph, H^1 = 0 trivially")
64
+ return 0.0
65
+
66
+ max_holonomy_norm = 0.0
67
+
68
+ for cycle in cycles:
69
+ n = len(cycle)
70
+ # Resolve every edge's transport map first, raising on the first
71
+ # missing one so a partial product is never formed. The reverse
72
+ # orientation is inverted. Every cycle-basis cycle has length >= 3, so
73
+ # the resulting product is always fully formed.
74
+ edge_maps = []
75
+ for i in range(n):
76
+ u = cycle[i]
77
+ v = cycle[(i + 1) % n]
78
+
79
+ if (u, v) in transport_maps:
80
+ edge_maps.append(transport_maps[(u, v)])
81
+ elif (v, u) in transport_maps:
82
+ edge_maps.append(np.linalg.inv(transport_maps[(v, u)]))
83
+ else:
84
+ raise IncompleteCocycleError(
85
+ f"Incomplete cocycle: no transport map for edge ({u}, {v}) "
86
+ f"on cycle {cycle}; holonomy is undefined. Supply the edge "
87
+ f"map in either direction before computing H^1."
88
+ )
89
+
90
+ holonomy = edge_maps[0]
91
+ for T in edge_maps[1:]:
92
+ holonomy = T @ holonomy
93
+
94
+ dim = holonomy.shape[0]
95
+ deviation = float(np.linalg.norm(holonomy - np.eye(dim), ord="fro"))
96
+ max_holonomy_norm = max(max_holonomy_norm, deviation)
97
+
98
+ logger.debug("H^1 norm = {:.6e}", max_holonomy_norm)
99
+ return max_holonomy_norm
groupoid/groupoid.py ADDED
@@ -0,0 +1,85 @@
1
+ """Groupoid structure for federated learning transport maps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import numpy.typing as npt
7
+ from loguru import logger
8
+ from pydantic import BaseModel, ConfigDict
9
+
10
+
11
+ class Morphism(BaseModel):
12
+ """A morphism in the transport groupoid.
13
+
14
+ Represents a transport map between two nodes (clients) in the
15
+ federated learning network.
16
+ """
17
+
18
+ model_config = ConfigDict(arbitrary_types_allowed=True)
19
+
20
+ source: str
21
+ target: str
22
+ transport_map: npt.NDArray[np.float64]
23
+
24
+ def __repr__(self) -> str:
25
+ return f"Morphism({self.source} -> {self.target})"
26
+
27
+
28
+ class CompositionError(Exception):
29
+ """Raised when morphism composition is not defined."""
30
+
31
+
32
+ def compose(f: Morphism, g: Morphism) -> Morphism:
33
+ """Compose two morphisms f and g (f followed by g).
34
+
35
+ Requires f.target == g.source (category composition law).
36
+
37
+ Parameters
38
+ ----------
39
+ f : Morphism
40
+ First morphism (applied first).
41
+ g : Morphism
42
+ Second morphism (applied second).
43
+
44
+ Returns
45
+ -------
46
+ Morphism
47
+ The composed morphism from f.source to g.target.
48
+
49
+ Raises
50
+ ------
51
+ CompositionError
52
+ If f.target != g.source.
53
+ """
54
+ if f.target != g.source:
55
+ raise CompositionError(f"Cannot compose: {f.target} != {g.source}")
56
+
57
+ logger.debug("Composing {} with {}", f, g)
58
+ composed_map = g.transport_map @ f.transport_map
59
+ return Morphism(
60
+ source=f.source,
61
+ target=g.target,
62
+ transport_map=composed_map,
63
+ )
64
+
65
+
66
+ def inverse(f: Morphism) -> Morphism:
67
+ """Compute the inverse of a morphism.
68
+
69
+ Parameters
70
+ ----------
71
+ f : Morphism
72
+ The morphism to invert.
73
+
74
+ Returns
75
+ -------
76
+ Morphism
77
+ The inverse morphism from f.target to f.source.
78
+ """
79
+ logger.debug("Inverting {}", f)
80
+ inv_map = np.linalg.inv(f.transport_map)
81
+ return Morphism(
82
+ source=f.target,
83
+ target=f.source,
84
+ transport_map=inv_map,
85
+ )
groupoid/laplacian.py ADDED
@@ -0,0 +1,201 @@
1
+ """Sheaf Laplacian for federated consensus and spectral analysis.
2
+
3
+ The sheaf Laplacian generalizes the graph Laplacian by incorporating
4
+ the restriction maps of a cellular sheaf. Its spectrum reveals the
5
+ structure of the agreement space:
6
+
7
+ - Kernel of L = space of global sections (consistent models)
8
+ - Smallest nonzero eigenvalue = algebraic connectivity of the sheaf
9
+ (how fast consensus can be reached)
10
+ - Spectral gap = robustness of the consensus to perturbation
11
+
12
+ In federated learning, the sheaf Laplacian governs the diffusion
13
+ process that drives local models toward global consistency.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+
20
+ import numpy as np
21
+ import numpy.typing as npt
22
+ from loguru import logger
23
+
24
+ from groupoid.sheaf import Sheaf
25
+
26
+
27
+ @dataclass
28
+ class SpectralSummary:
29
+ """Spectral decomposition of the sheaf Laplacian."""
30
+
31
+ eigenvalues: npt.NDArray[np.float64]
32
+ eigenvectors: npt.NDArray[np.float64]
33
+ spectral_gap: float
34
+ algebraic_connectivity: float
35
+ kernel_dimension: int
36
+ consensus_rate: float
37
+
38
+
39
+ def build_sheaf_laplacian(sheaf: Sheaf, stalk_dim: int) -> npt.NDArray[np.float64]:
40
+ """Build the sheaf Laplacian matrix.
41
+
42
+ For a sheaf F on graph G with n nodes and stalk dimension d,
43
+ the sheaf Laplacian is a (n*d) x (n*d) block matrix defined as:
44
+
45
+ L_F = delta^T @ delta
46
+
47
+ where delta is the connection coboundary. For each edge (u, v) with
48
+ restriction (transport) map R = R_{uv}: stalk(u) -> stalk(v), the
49
+ coboundary acts as (delta x)_{(u,v)} = x_v - R_{uv} x_u, so
50
+ L = delta^T @ delta has blocks (summed over incident edges):
51
+
52
+ L[u,u] += R_{uv}^T @ R_{uv} (source diagonal)
53
+ L[v,v] += I (target diagonal)
54
+ L[u,v] += -R_{uv}^T (off-diagonal)
55
+ L[v,u] += -R_{uv} (off-diagonal)
56
+
57
+ L is symmetric positive semi-definite for ANY restriction maps (it is
58
+ delta^T delta); its kernel is the space of transport-consistent global
59
+ sections (x_v = R_{uv} x_u on every edge).
60
+
61
+ Parameters
62
+ ----------
63
+ sheaf
64
+ A Sheaf instance with restriction maps set.
65
+ stalk_dim
66
+ Dimension of each stalk (vector space at each node).
67
+
68
+ Returns
69
+ -------
70
+ np.ndarray
71
+ The sheaf Laplacian matrix of shape (n*d, n*d).
72
+ """
73
+ nodes = sorted(sheaf.graph.nodes())
74
+ n = len(nodes)
75
+ node_idx = {node: i for i, node in enumerate(nodes)}
76
+ N = n * stalk_dim
77
+
78
+ L = np.zeros((N, N))
79
+
80
+ for u, v in sheaf.graph.edges():
81
+ i, j = node_idx[u], node_idx[v]
82
+ R = sheaf.get_restriction_map(u, v)
83
+ i_slice = slice(i * stalk_dim, (i + 1) * stalk_dim)
84
+ j_slice = slice(j * stalk_dim, (j + 1) * stalk_dim)
85
+
86
+ # L = delta^T delta for coboundary (delta x)_(u,v) = x_v - R_uv x_u:
87
+ L[i_slice, i_slice] += R.T @ R # source diagonal: R^T R
88
+ L[j_slice, j_slice] += np.eye(stalk_dim) # target diagonal: I
89
+ L[i_slice, j_slice] += -R.T # off-diagonal: -R^T
90
+ L[j_slice, i_slice] += -R # off-diagonal: -R
91
+
92
+ logger.debug("Built sheaf Laplacian: {}x{} ({} nodes, stalk_dim={})", N, N, n, stalk_dim)
93
+ return L
94
+
95
+
96
+ def spectral_analysis(
97
+ sheaf: Sheaf,
98
+ stalk_dim: int,
99
+ tol: float = 1e-10,
100
+ ) -> SpectralSummary:
101
+ """Compute spectral decomposition of the sheaf Laplacian.
102
+
103
+ Parameters
104
+ ----------
105
+ sheaf
106
+ A Sheaf instance with restriction maps.
107
+ stalk_dim
108
+ Dimension of each stalk.
109
+ tol
110
+ Tolerance for identifying zero eigenvalues.
111
+
112
+ Returns
113
+ -------
114
+ SpectralSummary
115
+ Full spectral summary including connectivity and consensus rate.
116
+ """
117
+ L = build_sheaf_laplacian(sheaf, stalk_dim)
118
+ eigenvalues, eigenvectors = np.linalg.eigh(L)
119
+
120
+ # Sort by magnitude
121
+ idx = np.argsort(eigenvalues)
122
+ eigenvalues = eigenvalues[idx]
123
+ eigenvectors = eigenvectors[:, idx]
124
+
125
+ # Kernel dimension (number of zero eigenvalues)
126
+ kernel_dim = int(np.sum(np.abs(eigenvalues) < tol))
127
+
128
+ # Spectral gap and algebraic connectivity
129
+ nonzero_eigs = eigenvalues[np.abs(eigenvalues) >= tol]
130
+ if len(nonzero_eigs) > 0:
131
+ algebraic_connectivity = float(nonzero_eigs[0])
132
+ spectral_gap = float(nonzero_eigs[0])
133
+ else:
134
+ algebraic_connectivity = 0.0
135
+ spectral_gap = 0.0
136
+
137
+ # Consensus rate: exponential convergence rate of sheaf diffusion
138
+ # x(t+1) = (I - epsilon * L) @ x(t), converges as exp(-lambda_1 * t)
139
+ consensus_rate = algebraic_connectivity
140
+
141
+ logger.info(
142
+ "Spectral analysis: kernel_dim={}, spectral_gap={:.4f}, connectivity={:.4f}",
143
+ kernel_dim,
144
+ spectral_gap,
145
+ algebraic_connectivity,
146
+ )
147
+
148
+ return SpectralSummary(
149
+ eigenvalues=eigenvalues,
150
+ eigenvectors=eigenvectors,
151
+ spectral_gap=spectral_gap,
152
+ algebraic_connectivity=algebraic_connectivity,
153
+ kernel_dimension=kernel_dim,
154
+ consensus_rate=consensus_rate,
155
+ )
156
+
157
+
158
+ def sheaf_diffusion_step(
159
+ sheaf: Sheaf,
160
+ sections: dict[str, npt.NDArray[np.float64]],
161
+ stalk_dim: int,
162
+ step_size: float = 0.1,
163
+ ) -> dict[str, npt.NDArray[np.float64]]:
164
+ """One step of sheaf diffusion (Laplacian smoothing).
165
+
166
+ Drives local sections toward global consistency by flowing along
167
+ the negative gradient of the sheaf Laplacian energy:
168
+
169
+ E(x) = x^T L_F x = sum_{(i,j)} ||R_{ij} x_i - x_j||^2
170
+
171
+ Parameters
172
+ ----------
173
+ sheaf
174
+ Sheaf with restriction maps.
175
+ sections
176
+ Current section values at each node.
177
+ stalk_dim
178
+ Dimension of each stalk.
179
+ step_size
180
+ Diffusion step size (must be < 1/lambda_max for stability).
181
+
182
+ Returns
183
+ -------
184
+ dict[str, npt.NDArray[np.float64]]
185
+ Updated section values after one diffusion step.
186
+ """
187
+ L = build_sheaf_laplacian(sheaf, stalk_dim)
188
+ nodes = sorted(sheaf.graph.nodes())
189
+
190
+ # Stack sections into vector
191
+ x = np.concatenate([sections[n] for n in nodes])
192
+
193
+ # Diffusion step: x' = x - step_size * L @ x
194
+ x_new = x - step_size * L @ x
195
+
196
+ # Unstack
197
+ result = {}
198
+ for idx, node in enumerate(nodes):
199
+ result[node] = x_new[idx * stalk_dim : (idx + 1) * stalk_dim]
200
+
201
+ return result