fluidlayer 2.0.0__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.
fluidlayer/model.py ADDED
@@ -0,0 +1,141 @@
1
+ """
2
+ fluidlayer.model — FluidLayerLM and a param-matched transformer baseline.
3
+ Both models share: embedding, learned absolute positions, RMSNorm pre-norm
4
+ residual blocks, tied readout. The only difference is the block body, so the
5
+ A/B in `train.py` isolates exactly one thing: dense attention vs. the
6
+ topological fluid.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+
12
+ from .autodiff import Tensor, concat, softmax_rows
13
+ from .blocks import FluidLayerBlock, RMSNorm
14
+
15
+ __all__ = ["FluidLayerLM", "TinyTransformer"]
16
+
17
+
18
+ def _collect(module, prefix, out):
19
+ for name, val in vars(module).items():
20
+ if isinstance(val, Tensor):
21
+ out[f"{prefix}.{name}"] = val
22
+ return out
23
+
24
+
25
+ class FluidLayerLM:
26
+ def __init__(self, vocab: int, d: int = 64, n_layers: int = 2, n_ctx: int = 128,
27
+ seed: int = 0, **block_kw):
28
+ rng = np.random.default_rng(seed)
29
+ self.vocab, self.d, self.n_ctx = vocab, d, n_ctx
30
+ self.emb = Tensor(rng.normal(0, 0.02, (vocab, d)))
31
+ self.pos = Tensor(rng.normal(0, 0.01, (n_ctx, d)))
32
+ self.blocks = [FluidLayerBlock(d, seed=seed + 17 * (i + 1), **block_kw)
33
+ for i in range(n_layers)]
34
+ self.norm = RMSNorm(d)
35
+
36
+ def forward(self, idx: np.ndarray) -> Tensor:
37
+ idx = np.asarray(idx, dtype=np.int64)
38
+ h = self.emb[idx] + self.pos[: idx.shape[0]]
39
+ for b in self.blocks:
40
+ h = b(h)
41
+ h = self.norm(h)
42
+ return h @ self.emb.T
43
+
44
+ def forward_batch(self, x):
45
+ """x: (B, T) token ids -> logits (B*T, V). The block is sequence-local
46
+ (the diffusion mixes positions inside one sequence), so we loop."""
47
+ outs = [self.forward(x[b]) for b in range(x.shape[0])]
48
+ return concat(outs, axis=0)
49
+
50
+ def params(self) -> dict:
51
+ p = {}
52
+ _collect(self, "emb", p)
53
+ _collect(self, "pos", p)
54
+ for i, b in enumerate(self.blocks):
55
+ _collect(b, f"b{i}", p)
56
+ _collect(b.norm, f"b{i}.norm", p)
57
+ _collect(self.norm, "norm", p)
58
+ return p
59
+
60
+
61
+ class TinyTransformer:
62
+ """Post-GPT transformer block (causal MHA + MLP), numpy/autodiff."""
63
+
64
+ def __init__(self, vocab: int, d: int = 64, n_layers: int = 2, n_ctx: int = 128,
65
+ n_heads: int = 4, d_ff: int | None = None, seed: int = 0):
66
+ rng = np.random.default_rng(seed)
67
+ self.vocab, self.d, self.n_ctx = vocab, d, n_ctx
68
+ self.n_heads = n_heads
69
+ self.d_ff = d_ff or 4 * d
70
+ self.emb = Tensor(rng.normal(0, 0.02, (vocab, d)))
71
+ self.pos = Tensor(rng.normal(0, 0.01, (n_ctx, d)))
72
+ self.layers = []
73
+ for _ in range(n_layers):
74
+ s = d ** -0.5
75
+ self.layers.append({
76
+ "Wq": Tensor(rng.normal(0, s, (d, d))),
77
+ "Wk": Tensor(rng.normal(0, s, (d, d))),
78
+ "Wv": Tensor(rng.normal(0, s, (d, d))),
79
+ "Wo": Tensor(rng.normal(0, s, (d, d))),
80
+ "W1": Tensor(rng.normal(0, (d * self.d_ff) ** -0.5, (d, self.d_ff))),
81
+ "W2": Tensor(rng.normal(0, (d * self.d_ff) ** -0.5, (self.d_ff, d))),
82
+ "n1": RMSNorm(d), "n2": RMSNorm(d),
83
+ })
84
+ self.norm = RMSNorm(d)
85
+
86
+ def forward(self, idx: np.ndarray) -> Tensor:
87
+ idx = np.asarray(idx, dtype=np.int64)
88
+ n = idx.shape[0]
89
+ h = self.emb[idx] + self.pos[:n]
90
+ return self.forward_from_h(h)
91
+
92
+ def forward_from_h(self, h: Tensor, return_hidden: bool = False):
93
+ """Continue the forward pass from a (possibly MODIFIED) residual stream.
94
+
95
+ This is the hook a grafted memory layer needs: the branch rewrites `h`
96
+ and the frozen base model processes the rest. Splitting `forward` into
97
+ "embed" + "forward_from_h" is what makes a *training-free* attachment
98
+ to an already-trained model possible at all.
99
+ """
100
+ n = h.data.shape[0]
101
+ causal = np.triu(np.ones((n, n), dtype=bool), k=1)
102
+ H, dh = self.n_heads, self.d // self.n_heads
103
+ for L in self.layers:
104
+ x = L["n1"](h)
105
+ q = x @ L["Wq"]
106
+ k = x @ L["Wk"]
107
+ v = x @ L["Wv"]
108
+ outs = []
109
+ for hh in range(H):
110
+ sl = slice(hh * dh, (hh + 1) * dh)
111
+ qh = q[:, sl]
112
+ kh = k[:, sl]
113
+ vh = v[:, sl]
114
+ sc = (qh @ kh.T) * (dh ** -0.5)
115
+ a = softmax_rows(sc, mask=causal)
116
+ outs.append(a @ vh)
117
+ o = np.concatenate([o.data for o in outs], axis=1)
118
+ oT = Tensor(o)
119
+ h = h + oT @ L["Wo"]
120
+ y = L["n2"](h)
121
+ h = h + (y @ L["W1"]).gelu() @ L["W2"]
122
+ h = self.norm(h)
123
+ logits = h @ self.emb.T
124
+ return (logits, h) if return_hidden else logits
125
+
126
+ def forward_batch(self, x):
127
+ outs = [self.forward(x[b]) for b in range(x.shape[0])]
128
+ return concat(outs, axis=0)
129
+
130
+ def params(self) -> dict:
131
+ p = {}
132
+ _collect(self, "emb", p)
133
+ _collect(self, "pos", p)
134
+ for i, L in enumerate(self.layers):
135
+ for k, v in L.items():
136
+ if isinstance(v, Tensor):
137
+ p[f"L{i}.{k}"] = v
138
+ p[f"L{i}.n1.g"] = L["n1"].g
139
+ p[f"L{i}.n2.g"] = L["n2"].g
140
+ _collect(self.norm, "norm", p)
141
+ return p
fluidlayer/optim.py ADDED
@@ -0,0 +1,57 @@
1
+ """
2
+ fluidlayer.optim — Adam, implemented locally (no torch in this environment).
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import numpy as np
8
+
9
+
10
+ class Adam:
11
+ """Adam with decoupled weight decay and gradient clipping."""
12
+
13
+ def __init__(self, params: dict, lr: float = 1e-3, betas=(0.9, 0.999),
14
+ eps: float = 1e-8, weight_decay: float = 0.0, clip: float = 1.0):
15
+ self.params = params
16
+ self.lr = lr
17
+ self.b1, self.b2 = betas
18
+ self.eps = eps
19
+ self.wd = weight_decay
20
+ self.clip = clip
21
+ self.t = 0
22
+ # params: name -> Tensor (Tensor has .data and .grad)
23
+ self.m = {k: np.zeros_like(v.data) for k, v in params.items()}
24
+ self.v = {k: np.zeros_like(v.data) for k, v in params.items()}
25
+
26
+ def zero_grad(self):
27
+ for p in self.params.values():
28
+ p.grad = np.zeros_like(p.data)
29
+
30
+ def step(self, lr: float | None = None):
31
+ self.t += 1
32
+ lr = self.lr if lr is None else lr
33
+ b1c = 1 - self.b1 ** self.t
34
+ b2c = 1 - self.b2 ** self.t
35
+ total_sq = 0.0
36
+ for p in self.params.values():
37
+ g = p.grad
38
+ total_sq += float(np.sum(g * g))
39
+ norm = np.sqrt(total_sq)
40
+ scale = 1.0
41
+ if self.clip and norm > self.clip:
42
+ scale = self.clip / (norm + 1e-12)
43
+ for k, p in self.params.items():
44
+ g = p.grad * scale
45
+ if self.wd:
46
+ g = g + self.wd * p.data
47
+ self.m[k] = self.b1 * self.m[k] + (1 - self.b1) * g
48
+ self.v[k] = self.b2 * self.v[k] + (1 - self.b2) * (g * g)
49
+ mhat = self.m[k] / b1c
50
+ vhat = self.v[k] / b2c
51
+ p.data -= lr * mhat / (np.sqrt(vhat) + self.eps)
52
+
53
+ def lr_at(self, step: int, total: int, warmup: int = 200, floor: float = 0.1):
54
+ if step < warmup:
55
+ return self.lr * (step + 1) / warmup
56
+ p = (step - warmup) / max(1, total - warmup)
57
+ return self.lr * (floor + (1 - floor) * 0.5 * (1 + np.cos(np.pi * min(p, 1.0))))
fluidlayer/py.typed ADDED
File without changes
fluidlayer/sheaf.py ADDED
@@ -0,0 +1,147 @@
1
+ """
2
+ fluidlayer.sheaf — Cellular sheaf Laplacians on graphs.
3
+
4
+ The mathematical object that makes "FluidLayer" real.
5
+
6
+ A cellular sheaf F on a graph G assigns:
7
+ * a stalk (vector space) F(v) ~ R^d to every vertex v
8
+ * a restriction map F_{v<-e} : F(v) -> F(e) to every incidence (v, e)
9
+
10
+ Its coboundary delta : C^0(G;F) -> C^1(G;F) induces the **sheaf Laplacian**
11
+
12
+ L_F = delta* delta = delta^T delta (PSD by construction)
13
+
14
+ and the diffusion (the "fluid") is the heat flow on that operator:
15
+
16
+ dX/dt = -L_F X => X(t) = exp(-t L_F) X(0)
17
+
18
+ Two facts we rely on throughout (Hansen & Ghrist 2019; Bodnar et al. 2022):
19
+
20
+ 1. lim_{t->inf} exp(-t L_F) = P_{ker(L_F)} (the Hodge limit: the fluid
21
+ freezes onto the space of *global sections* -- consistent assignments).
22
+ 2. On ker(L_F)^perp the flow contracts at rate exp(-lam_2 t).
23
+
24
+ Implementation note: we use the **signed** sheaf Laplacian with absolute degree
25
+
26
+ L = D_abs - A , D_abs[i,i] = sum_j |A[i,j]| , A[i,j] = alpha_ij
27
+
28
+ which is PSD for any sign pattern, reduces to the ordinary graph Laplacian
29
+ when all signs are positive, and reproduces the G-balance phase transition:
30
+ a *balanced* signed component freezes onto a NON-TRIVIAL sign pattern,
31
+ an *unbalanced* one decays to zero (it "evaporates" instead of freezing).
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import numpy as np
37
+
38
+ __all__ = [
39
+ "build_laplacian", "sheaf_energy", "balance_residual", "is_balanced",
40
+ "laplacian_from_dense_A", "signed_laplacian_from_edges",
41
+ ]
42
+
43
+
44
+ def build_laplacian(n: int, edges: np.ndarray, alpha: np.ndarray) -> np.ndarray:
45
+ """Signed sheaf Laplacian L = D_abs - A.
46
+
47
+ edges : (m, 2) int array of undirected edges (u, v), u != v
48
+ alpha : (m,) float array of signed edge weights (the restriction maps)
49
+ Returns L : (n, n) symmetric PSD.
50
+ """
51
+ L = np.zeros((n, n), dtype=np.float64)
52
+ if edges.shape[0] == 0:
53
+ return L
54
+ u = edges[:, 0].astype(np.int64)
55
+ v = edges[:, 1].astype(np.int64)
56
+ w = np.asarray(alpha, dtype=np.float64)
57
+
58
+ # A contribution (symmetric)
59
+ np.add.at(L, (u, v), w)
60
+ np.add.at(L, (v, u), w)
61
+ # D_abs: sum of |A[i,j]| over j
62
+ absA = np.zeros_like(L)
63
+ np.add.at(absA, (u, v), np.abs(w))
64
+ np.add.at(absA, (v, u), np.abs(w))
65
+ diag = absA.sum(axis=1)
66
+ L = np.diag(diag) - L # L = D_abs - A (PSD)
67
+ return L
68
+
69
+
70
+ def laplacian_from_dense_A(A: np.ndarray) -> np.ndarray:
71
+ """L = D_abs - A for a dense symmetric A."""
72
+ A = np.asarray(A, dtype=np.float64)
73
+ A = 0.5 * (A + A.T)
74
+ D = np.diag(np.abs(A).sum(axis=1))
75
+ return D - A
76
+
77
+
78
+ def signed_laplacian_from_edges(
79
+ n: int, edges: np.ndarray, alpha: np.ndarray
80
+ ) -> np.ndarray:
81
+ """Alias kept for readability at call sites."""
82
+ return build_laplacian(n, edges, alpha)
83
+
84
+
85
+ def sheaf_energy(x: np.ndarray, edges: np.ndarray, alpha: np.ndarray) -> float:
86
+ """Total variation of the field across the sheaf edges.
87
+
88
+ E = sum_e alpha_e^2 * (x_u - x_v)^2 (scalar stalk; x is (n,) or (n,d))
89
+
90
+ This is the quantity the "smoothness" regularizer anneals toward, and it
91
+ is exactly x^T L x / 2 for the signed Laplacian up to the sign convention.
92
+ """
93
+ if edges.shape[0] == 0:
94
+ return 0.0
95
+ u = edges[:, 0]
96
+ v = edges[:, 1]
97
+ w = np.asarray(alpha, dtype=np.float64)
98
+ diff = x[u] - x[v]
99
+ if diff.ndim == 1:
100
+ diff = diff[:, None]
101
+ return float(np.sum((w[:, None] ** 2) * (diff ** 2)))
102
+
103
+
104
+ def balance_residual(L: np.ndarray) -> float:
105
+ """||L 1||_inf for the constant vector 1.
106
+
107
+ Zero <=> the sheaf is (G-)balanced: every node's incident restriction
108
+ maps sum to zero, so the constant field is a global section and the fluid
109
+ has a non-trivial frozen state.
110
+ """
111
+ n = L.shape[0]
112
+ if n == 0:
113
+ return 0.0
114
+ return float(np.max(np.abs(L @ np.ones(n))))
115
+
116
+
117
+ def is_balanced(L: np.ndarray, tol: float = 1e-8) -> bool:
118
+ return balance_residual(L) < tol
119
+
120
+
121
+ def component_balance(L: np.ndarray, adj: np.ndarray) -> np.ndarray:
122
+ """Per-component balance residual.
123
+
124
+ For each connected component (of the underlying unsigned graph) returns
125
+ ||L 1_C||_inf. Zero means that component is balanced (freezes onto a
126
+ sign pattern); non-zero means it is unbalanced (decays to zero).
127
+ """
128
+ n = L.shape[0]
129
+ seen = np.zeros(n, dtype=bool)
130
+ out = []
131
+ for s in range(n):
132
+ if seen[s]:
133
+ continue
134
+ stack, comp = [s], []
135
+ seen[s] = True
136
+ while stack:
137
+ u = stack.pop()
138
+ comp.append(u)
139
+ nb = np.nonzero(adj[u])[0]
140
+ for w in nb:
141
+ if not seen[w]:
142
+ seen[w] = True
143
+ stack.append(w)
144
+ idx = np.array(comp)
145
+ res = L[np.ix_(idx, idx)].sum(axis=1)
146
+ out.append(float(np.max(np.abs(res))))
147
+ return np.array(out)
fluidlayer/topology.py ADDED
@@ -0,0 +1,294 @@
1
+ """
2
+ fluidlayer.topology — building the data's geometry in one forward pass.
3
+
4
+ This module implements the "instant geometry" of the proposal:
5
+
6
+ 1. content routing -> semantic edges (top-k over QK similarity)
7
+ 2. temporal chain -> order edges (position as 1-dimensional topology)
8
+ 3. 0-D persistence -> exact barcode via union-find (Kruskal filtration)
9
+ 4. Betti numbers -> beta_0 (components), beta_1 (cycle rank)
10
+
11
+ (3) and (4) are *exact*, not approximated, and cost O(m log m) — which is why
12
+ the geometry can be recomputed every forward pass instead of being cached
13
+ offline. This is the concrete answer to "jam the data's geometry in one pass".
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import numpy as np
19
+
20
+ __all__ = [
21
+ "route_topk", "edges_from_topk", "chain_edges", "unique_edges",
22
+ "kruskal_barcode", "betti_numbers", "topo_descriptor",
23
+ "connected_components",
24
+ "hodge_relevance", "hodge_embedding", "hodge_edge_participation",
25
+ "local_density", "persistence_landmarks",
26
+ ]
27
+
28
+
29
+ # ------------------------------------------------------- selection criteria
30
+ def hodge_embedding(n: int, edges: np.ndarray, alpha: np.ndarray) -> np.ndarray:
31
+ """The Hodge embedding Psi = (L + (1/n) 11^T)^-1 of the sheaf Laplacian."""
32
+ from .sheaf import build_laplacian
33
+
34
+ laplacian = build_laplacian(n, edges, np.asarray(alpha, dtype=np.float64))
35
+ constant = np.ones((n, 1)) / np.sqrt(max(n, 1))
36
+ matrix = laplacian + constant @ constant.T
37
+ try:
38
+ return np.linalg.inv(matrix)
39
+ except np.linalg.LinAlgError:
40
+ return np.linalg.pinv(matrix)
41
+
42
+
43
+ def hodge_edge_participation(
44
+ Psi: np.ndarray, a: np.ndarray, b: np.ndarray
45
+ ) -> np.ndarray:
46
+ """Return normalized Hodge participation for a set of node pairs."""
47
+ Psi = np.asarray(Psi, dtype=np.float64)
48
+ a = np.asarray(a, dtype=np.int64)
49
+ b = np.asarray(b, dtype=np.int64)
50
+ numerator = np.abs(Psi[a, b])
51
+ denominator = np.sqrt(
52
+ np.maximum(np.abs(Psi[a, a]), 1e-18)
53
+ * np.maximum(np.abs(Psi[b, b]), 1e-18)
54
+ )
55
+ return np.clip(numerator / denominator, 0.0, 1.0)
56
+
57
+
58
+ def hodge_relevance(n: int, edges: np.ndarray, alpha: np.ndarray) -> np.ndarray:
59
+ """Return per-node harmonic participation scores for the sheaf Laplacian."""
60
+ embedding = hodge_embedding(n, edges, alpha)
61
+ participation = np.abs(embedding).sum(axis=1) - np.abs(np.diag(embedding))
62
+ return np.clip(participation, 0.0, None)
63
+
64
+
65
+ def local_density(
66
+ protos: np.ndarray, radius: float | None = None, k: int = 8
67
+ ) -> np.ndarray:
68
+ """Return prototype neighborhood counts or effective top-k density."""
69
+ prototypes = np.asarray(protos, dtype=np.float64)
70
+ n = prototypes.shape[0]
71
+ if radius is not None:
72
+ squared_norms = (prototypes**2).sum(1)
73
+ distances = np.sqrt(
74
+ np.maximum(
75
+ squared_norms[:, None]
76
+ + squared_norms[None, :]
77
+ - 2.0 * (prototypes @ prototypes.T),
78
+ 0.0,
79
+ )
80
+ )
81
+ return (distances <= radius).sum(axis=1).astype(np.float64)
82
+ normalized = prototypes / (
83
+ np.linalg.norm(prototypes, axis=1, keepdims=True) + 1e-12
84
+ )
85
+ similarities = normalized @ normalized.T
86
+ np.fill_diagonal(similarities, -np.inf)
87
+ k = int(min(k, n - 1)) if n > 1 else 1
88
+ nearest = np.partition(similarities, -k, axis=1)[:, -k:]
89
+ maximum = np.abs(nearest).max(axis=1, keepdims=True) + 1e-12
90
+ return (np.abs(nearest) / maximum).sum(axis=1)
91
+
92
+
93
+ def persistence_landmarks(
94
+ n: int, edges: np.ndarray, weights: np.ndarray, k: int = 10
95
+ ) -> np.ndarray:
96
+ """Return node indices whose topological classes persist the longest."""
97
+ edges = np.asarray(edges, dtype=np.int64)
98
+ weights = np.asarray(weights, dtype=np.float64)
99
+ if edges.shape[0] == 0:
100
+ return np.arange(min(k, n), dtype=np.int64)
101
+ parent = list(range(n))
102
+ members = {i: [i] for i in range(n)}
103
+
104
+ def find(a):
105
+ while parent[a] != a:
106
+ parent[a] = parent[parent[a]]
107
+ a = parent[a]
108
+ return a
109
+
110
+ death = np.full(n, -np.inf, dtype=np.float64)
111
+ order = np.argsort(weights, kind="stable")
112
+ for edge_index in order:
113
+ u = int(edges[edge_index, 0])
114
+ v = int(edges[edge_index, 1])
115
+ weight = float(weights[edge_index])
116
+ root_u, root_v = find(u), find(v)
117
+ if root_u == root_v:
118
+ continue
119
+ merged_nodes = members[root_u] + members[root_v]
120
+ for node in merged_nodes:
121
+ death[node] = weight
122
+ if len(members[root_u]) < len(members[root_v]):
123
+ root_u, root_v = root_v, root_u
124
+ parent[root_v] = root_u
125
+ members[root_u] = members[root_u] + members[root_v]
126
+ del members[root_v]
127
+ for root in list(members):
128
+ for node in members[root]:
129
+ death[node] = np.inf
130
+ order_nodes = np.lexsort((np.arange(n), -death))
131
+ return order_nodes[:min(k, n)].astype(np.int64)
132
+
133
+
134
+ def connected_components(n: int, edges: np.ndarray):
135
+ """Return connected-component labels and the number of components."""
136
+ parent = list(range(n))
137
+
138
+ def find(a):
139
+ while parent[a] != a:
140
+ parent[a] = parent[parent[a]]
141
+ a = parent[a]
142
+ return a
143
+
144
+ for edge in edges:
145
+ root_u, root_v = find(int(edge[0])), find(int(edge[1]))
146
+ if root_u != root_v:
147
+ parent[root_v] = root_u
148
+ roots = np.array([find(i) for i in range(n)])
149
+ _, labels = np.unique(roots, return_inverse=True)
150
+ return labels.astype(np.int64), int(labels.max()) + 1 if n else 0
151
+
152
+
153
+ # ------------------------------------------------------------------ routing
154
+ def route_topk(Q: np.ndarray, K: np.ndarray, k: int, causal: bool = True):
155
+ """Return each query's k most similar keys, masking future keys if causal."""
156
+ n = Q.shape[0]
157
+ scores = Q @ K.T / np.sqrt(Q.shape[1])
158
+ if causal:
159
+ mask = np.triu(np.ones((n, n), dtype=bool), k=1)
160
+ scores = np.where(mask, -np.inf, scores)
161
+ k = int(min(k, n))
162
+ idx = np.argpartition(-scores, kth=k - 1, axis=1)[:, :k]
163
+ row = np.arange(n)[:, None]
164
+ logits = scores[row, idx]
165
+ order = np.argsort(-logits, axis=1)
166
+ idx = np.take_along_axis(idx, order, axis=1)
167
+ logits = np.take_along_axis(logits, order, axis=1)
168
+ return idx, logits
169
+
170
+
171
+ def edges_from_topk(idx: np.ndarray) -> np.ndarray:
172
+ """Return unique undirected edges from a (n, k) neighbor-index array."""
173
+ n, k = idx.shape
174
+ u = np.repeat(np.arange(n), k)
175
+ v = idx.reshape(-1)
176
+ mask = u < v
177
+ u, v = u[mask], v[mask]
178
+ if u.size == 0:
179
+ return np.zeros((0, 2), dtype=np.int64)
180
+ key = u.astype(np.int64) * n + v
181
+ _, idx2 = np.unique(key, return_index=True)
182
+ return np.stack([u[idx2], v[idx2]], axis=1)
183
+
184
+
185
+ def chain_edges(n: int) -> np.ndarray:
186
+ """Return temporal-chain edges (i, i+1)."""
187
+ if n < 2:
188
+ return np.zeros((0, 2), dtype=np.int64)
189
+ return np.stack([np.arange(n - 1), np.arange(1, n)], axis=1)
190
+
191
+
192
+ def unique_edges(*edge_sets):
193
+ """Return the union of edge lists with duplicates removed."""
194
+ nonempty = [edges for edges in edge_sets if edges.size]
195
+ if not nonempty:
196
+ return np.zeros((0, 2), dtype=np.int64)
197
+ all_edges = np.concatenate(nonempty, axis=0)
198
+ u = np.minimum(all_edges[:, 0], all_edges[:, 1])
199
+ v = np.maximum(all_edges[:, 0], all_edges[:, 1])
200
+ key = u.astype(np.int64) * (int(max(u.max(), v.max())) + 1) + v
201
+ _, keep = np.unique(key, return_index=True)
202
+ return np.stack([u[keep], v[keep]], axis=1)
203
+
204
+
205
+ # ------------------------------------------------------------- persistence
206
+ def kruskal_barcode(n: int, edges: np.ndarray, weights: np.ndarray):
207
+ """Return the exact 0-dimensional persistence barcode and beta_0."""
208
+ parent = list(range(n))
209
+
210
+ def find(a):
211
+ while parent[a] != a:
212
+ parent[a] = parent[parent[a]]
213
+ a = parent[a]
214
+ return a
215
+
216
+ m = edges.shape[0]
217
+ order = np.argsort(weights) if m else ()
218
+ birth = [0.0] * n
219
+ pairs = []
220
+ for edge_index in order:
221
+ u = int(edges[edge_index, 0])
222
+ v = int(edges[edge_index, 1])
223
+ weight = float(weights[edge_index])
224
+ root_u, root_v = find(u), find(v)
225
+ if root_u == root_v:
226
+ continue
227
+ birth_value = min(birth[root_u], birth[root_v])
228
+ pairs.append((birth_value, weight))
229
+ if birth[root_u] > birth[root_v]:
230
+ root_u, root_v = root_v, root_u
231
+ parent[root_v] = root_u
232
+ birth[root_u] = birth_value
233
+ roots = {find(i) for i in range(n)}
234
+ for root in roots:
235
+ pairs.append((birth[root], float("inf")))
236
+ return pairs, len(roots)
237
+
238
+
239
+ def betti_numbers(n: int, edges: np.ndarray) -> dict:
240
+ """Return beta_0, beta_1, and graph size for a 1-dimensional complex."""
241
+ m = edges.shape[0]
242
+ parent = list(range(n))
243
+
244
+ def find(a):
245
+ while parent[a] != a:
246
+ parent[a] = parent[parent[a]]
247
+ a = parent[a]
248
+ return a
249
+
250
+ for edge in edges:
251
+ root_u, root_v = find(int(edge[0])), find(int(edge[1]))
252
+ if root_u != root_v:
253
+ parent[root_v] = root_u
254
+ components = len({find(i) for i in range(n)})
255
+ beta1 = m - n + components
256
+ return {
257
+ "beta0": components,
258
+ "beta1": int(max(beta1, 0)),
259
+ "m": int(m),
260
+ "n": int(n),
261
+ }
262
+
263
+
264
+ def topo_descriptor(n: int, edges: np.ndarray, weights: np.ndarray) -> np.ndarray:
265
+ """Return a fixed-length topological signature of the routed complex."""
266
+ betti = betti_numbers(n, edges)
267
+ pairs, _ = kruskal_barcode(n, edges, weights)
268
+ finite = [(birth, death) for birth, death in pairs if np.isfinite(death)]
269
+ lifetimes = np.array(
270
+ [death - birth for birth, death in finite], dtype=np.float64
271
+ )
272
+ if lifetimes.size:
273
+ maximum = float(lifetimes.max())
274
+ total = float(lifetimes.sum())
275
+ mean = float(lifetimes.mean())
276
+ longer_quarter = int((lifetimes > 0.25 * maximum).sum())
277
+ longer_half = int((lifetimes > 0.50 * maximum).sum())
278
+ else:
279
+ maximum = total = mean = 0.0
280
+ longer_quarter = longer_half = 0
281
+ return np.array(
282
+ [
283
+ betti["beta0"],
284
+ betti["beta1"],
285
+ total,
286
+ maximum,
287
+ mean,
288
+ longer_quarter,
289
+ longer_half,
290
+ betti["m"] / max(n, 1),
291
+ np.log1p(betti["m"]),
292
+ ],
293
+ dtype=np.float64,
294
+ )