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/__init__.py +96 -0
- fluidlayer/autodiff.py +472 -0
- fluidlayer/blocks.py +275 -0
- fluidlayer/bpe.py +173 -0
- fluidlayer/data.py +92 -0
- fluidlayer/diffusion.py +264 -0
- fluidlayer/gpt2_model.py +129 -0
- fluidlayer/gpt2_tokenizer.py +127 -0
- fluidlayer/graft.py +741 -0
- fluidlayer/losses.py +86 -0
- fluidlayer/memory.py +525 -0
- fluidlayer/model.py +141 -0
- fluidlayer/optim.py +57 -0
- fluidlayer/py.typed +0 -0
- fluidlayer/sheaf.py +147 -0
- fluidlayer/topology.py +294 -0
- fluidlayer/torch_graft.py +283 -0
- fluidlayer-2.0.0.dist-info/METADATA +376 -0
- fluidlayer-2.0.0.dist-info/RECORD +22 -0
- fluidlayer-2.0.0.dist-info/WHEEL +5 -0
- fluidlayer-2.0.0.dist-info/licenses/LICENSE +21 -0
- fluidlayer-2.0.0.dist-info/top_level.txt +1 -0
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
|
+
)
|