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/blocks.py ADDED
@@ -0,0 +1,275 @@
1
+ """
2
+ fluidlayer.blocks — the FluidLayer block.
3
+
4
+ One block, five stages. Every stage exists to fix a named weakness of the
5
+ naive "replace attention with diffusion" idea:
6
+
7
+ ROUTER top-k content routing -> semantic edges
8
+ TOPOLOGY semantic edges + temporal chain -> the complex
9
+ SHEAF learned signed restriction maps -> content-adaptive geometry
10
+ FREEZE multi-scale band filter bank (exp(-t_j L) - exp(-t_{j+1} L))
11
+ READ channel mixing + ONE nonlinearity + residual + sharp route head
12
+
13
+ Stage notes
14
+ -----------
15
+ * The signed restriction maps make the G-balance phase transition learnable:
16
+ a balanced component freezes onto a non-trivial sign pattern, an unbalanced
17
+ one decays to zero. `tanh` keeps |alpha| <= 1 so L stays bounded and
18
+ gradients cannot explode.
19
+ * The band filter bank (not a single exponential) is what prevents the
20
+ "frozen state has rank c" information loss.
21
+ * The route head is a hard top-k gather (winner-take-all). It is the sharp
22
+ retrieval path the smooth fluid cannot provide -- this is what keeps the
23
+ model from being a pure smoother.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import numpy as np
29
+
30
+ from .autodiff import Tensor, concat, softmax_rows, softplus
31
+ from .diffusion import (
32
+ band_times,
33
+ cheb_coeffs_exp,
34
+ cheb_coeffs_exp_autodiff,
35
+ power_iteration_lmax,
36
+ spectral_bound_autodiff,
37
+ )
38
+ from .topology import (
39
+ chain_edges,
40
+ connected_components,
41
+ edges_from_topk,
42
+ topo_descriptor,
43
+ )
44
+
45
+ __all__ = ["FluidLayerBlock", "RMSNorm", "softplus"]
46
+
47
+
48
+ class RMSNorm:
49
+ def __init__(self, d: int, seed: int = 0):
50
+ self.g = Tensor(np.ones(d))
51
+ self.eps = 1e-6
52
+
53
+ def __call__(self, x: Tensor) -> Tensor:
54
+ ms = (x * x).mean()
55
+ inv = (ms + self.eps).pow(-0.5)
56
+ n = x * inv
57
+ return n * self.g
58
+
59
+
60
+ class FluidLayerBlock:
61
+ def __init__(self, d: int, k_neighbors: int = 8, bands: int = 6, cheb_m: int = 12,
62
+ t_min: float = 1e-3, t_max: float = 1e3, edge_hidden: int = 16,
63
+ use_route_head: bool = True, seed: int = 0,
64
+ alpha_min: float = 0.05, alpha_max: float | None = None):
65
+ rng = np.random.default_rng(seed)
66
+ self.d = d
67
+ self.k = int(k_neighbors)
68
+ self.B = int(bands)
69
+ self.m = int(cheb_m)
70
+ # Bands live in SPECTRAL time units alpha = t*lam_max/2 so that the
71
+ # Chebyshev degree m stays sufficient for any graph scale.
72
+ self.alpha_min = alpha_min
73
+ self.alpha_max = alpha_max if alpha_max is not None else 0.75 * self.m
74
+ self.ts = np.geomspace(t_min, t_max, self.B + 1) # fallback / reference
75
+ self.use_route_head = use_route_head
76
+
77
+ s = d ** -0.5
78
+ self.Wq = Tensor(rng.normal(0, s, (d, d)))
79
+ self.Wk = Tensor(rng.normal(0, s, (d, d)))
80
+ self.Wv = Tensor(rng.normal(0, s, (d, d)))
81
+ self.Wo = Tensor(rng.normal(0, s, (d, d)))
82
+ self.Wmix = Tensor(rng.normal(0, s, (d, d)))
83
+ self.Wr = Tensor(rng.normal(0, s, (d, d))) if use_route_head else None
84
+ self.edge1 = Tensor(rng.normal(0, (2 * d) ** -0.5, (2 * d, edge_hidden)))
85
+ self.edge2 = Tensor(rng.normal(0, edge_hidden ** -0.5, (edge_hidden, 1)))
86
+ self.beta_chain = Tensor(np.array([[0.3]])) # order / temporal edges
87
+ # Band gates start NEAR ZERO (softplus(-3) ~ 0.05): the transient is
88
+ # much larger in magnitude than the harmonic component, so starting
89
+ # with the bands "open" would swamp the frozen state.
90
+ self.band_g = Tensor(np.full((self.B, 1), -3.0)) # band gates (softplus)
91
+ self.harm_g = Tensor(np.array([[1.0]])) # gate on the HARMONIC branch
92
+ self.norm = RMSNorm(d)
93
+ self.diag = {}
94
+
95
+ # ------------------------------------------------------------- helpers
96
+ def _laplacian(self, n: int, edges: np.ndarray, alpha: Tensor) -> Tensor:
97
+ """Differentiable signed sheaf Laplacian L = D_abs - A."""
98
+ m = edges.shape[0]
99
+ if m == 0:
100
+ return Tensor(np.zeros((n, n)))
101
+ idx_uv = (edges[:, 0].astype(np.int64) * n + edges[:, 1].astype(np.int64))
102
+ idx_vu = (edges[:, 1].astype(np.int64) * n + edges[:, 0].astype(np.int64))
103
+ Z = Tensor(np.zeros(n * n))
104
+ # two SEPARATE scatter bases, then added: index_add starts from zeros,
105
+ # so chaining it would silently discard the first contribution.
106
+ A = (Z.index_add(idx_uv, alpha) + Z.index_add(idx_vu, alpha)).reshape(n, n)
107
+ deg = A.abs().sum(axis=1) # (n,)
108
+ diag_idx = np.arange(n) * n + np.arange(n)
109
+ D = Tensor(np.zeros(n * n)).index_add(diag_idx, deg).reshape(n, n)
110
+ return D - A
111
+
112
+ def _harmonic(self, x: Tensor, edges: np.ndarray):
113
+ """Exact Hodge projection of the graph Laplacian: the PER-COMPONENT MEAN.
114
+
115
+ ker(L) for a graph Laplacian is the space of per-component constants,
116
+ so the frozen state of the diffusion is the component mean. Cost
117
+ O(n + m): union-find for the components, then a scatter-add and a
118
+ gather. This is the branch that lets the block represent a GLOBAL
119
+ aggregate -- the band bank alone only carries the transient.
120
+ """
121
+ n, d = x.shape[0], x.shape[1]
122
+ labels, ncomp = connected_components(n, edges)
123
+ if ncomp <= 0:
124
+ return Tensor(np.zeros((n, d)))
125
+ sums = Tensor(np.zeros((ncomp, d))).index_add(labels, x)
126
+ ones = Tensor(np.ones(n))
127
+ counts = Tensor(np.zeros(ncomp)).index_add(labels, ones).reshape(ncomp, 1)
128
+ comp_mean = sums / counts
129
+ return comp_mean.gather(labels)
130
+
131
+ def _bands(self, L: Tensor, lam_max: float, X: Tensor, lam_max_t=None):
132
+ """Chebyshev band filter bank; the basis T_k(Ltilde)X is computed ONCE."""
133
+ n = L.shape[0]
134
+ inv = (2.0 / lam_max) if lam_max_t is None else (2.0 / lam_max_t)
135
+ Y = [X]
136
+ if self.m >= 1:
137
+ Lt = inv * L - Tensor(np.eye(n))
138
+ Y.append(Lt @ X)
139
+ for k in range(2, self.m + 1):
140
+ Y.append(2.0 * (Lt @ Y[k - 1]) - Y[k - 2])
141
+ ts = band_times(lam_max, self.m, self.B, self.alpha_min, self.alpha_max)
142
+ out = []
143
+ for j in range(self.B):
144
+ if lam_max_t is not None:
145
+ a = cheb_coeffs_exp_autodiff(ts[j], lam_max_t, self.m)
146
+ b = cheb_coeffs_exp_autodiff(ts[j + 1], lam_max_t, self.m)
147
+ coef = a - b
148
+ acc = None
149
+ for k in range(self.m + 1):
150
+ if abs(float(coef.data[k])) > 0.0:
151
+ term = Y[k] * coef[k]
152
+ acc = term if acc is None else acc + term
153
+ else:
154
+ a = cheb_coeffs_exp(ts[j], lam_max, self.m)
155
+ b = cheb_coeffs_exp(ts[j + 1], lam_max, self.m)
156
+ coef = a - b
157
+ acc = None
158
+ for k in range(self.m + 1):
159
+ if coef[k] != 0.0:
160
+ term = Y[k] * float(coef[k])
161
+ acc = term if acc is None else acc + term
162
+ out.append(acc)
163
+ self.last_ts = ts
164
+ return out
165
+
166
+ # -------------------------------------------------------------- forward
167
+ def __call__(self, x: Tensor):
168
+ n = x.shape[0]
169
+ d = self.d
170
+ xn = self.norm(x)
171
+
172
+ # ---- ROUTER --------------------------------------------------------
173
+ q = xn @ self.Wq
174
+ k = xn @ self.Wk
175
+ scores = (q @ k.T) * (d ** -0.5)
176
+ causal = np.triu(np.ones((n, n), dtype=bool), k=1)
177
+ sc_masked = scores.data - 1e9 * causal
178
+ idx = np.argsort(-sc_masked, axis=1)[:, : self.k] # (n, k) neighbours
179
+ row = np.arange(n)[:, None]
180
+ sel = scores[row, idx] # Tensor (n, k)
181
+
182
+ # ---- TOPOLOGY: semantic edges + temporal chain ----------------------
183
+ sem = edges_from_topk(idx)
184
+ chain = chain_edges(n)
185
+
186
+ # ---- SHEAF: content-adaptive signed restriction maps -----------------
187
+ if sem.shape[0] > 0:
188
+ eu = xn[sem[:, 0]]
189
+ ev = xn[sem[:, 1]]
190
+ feat = concat([eu, ev], axis=1)
191
+ h = (feat @ self.edge1).tanh()
192
+ alpha = (h @ self.edge2).reshape(-1) # (m,) in (-1, 1)
193
+ else:
194
+ alpha = Tensor(np.zeros(0))
195
+ L_sem = self._laplacian(n, sem, alpha)
196
+
197
+ if chain.shape[0] > 0:
198
+ beta = softplus(self.beta_chain) # (1,1) > 0
199
+ a_ch = beta.reshape(1) * Tensor(np.ones(chain.shape[0]))
200
+ L_chain = self._laplacian(n, chain, a_ch)
201
+ else:
202
+ L_chain = Tensor(np.zeros((n, n)))
203
+ L = L_sem + L_chain
204
+
205
+ # Spectral scale. We use a GUARANTEED UPPER BOUND (Gershgorin +
206
+ # smooth max) for the Chebyshev normalisation: an estimate that
207
+ # undershoots lambda_max makes the recurrence explode. The bound is
208
+ # differentiable, so no gradient is lost.
209
+ lam_bound_t = spectral_bound_autodiff(L)
210
+ lam_est = power_iteration_lmax(L.data, iters=40)
211
+ lam_max_t = lam_bound_t
212
+ lam_max = float(lam_max_t.data)
213
+ if lam_max < 1e-8:
214
+ lam_max = 1e-8
215
+ lam_max_t = Tensor(np.array([[lam_max]]))
216
+
217
+ # ---- FREEZE: multi-scale band filter bank --------------------------
218
+ bands = self._bands(L, lam_max, xn, lam_max_t)
219
+ gates = softplus(self.band_g).reshape(self.B)
220
+ z = None
221
+ for j in range(self.B):
222
+ term = bands[j] * gates[j]
223
+ z = term if z is None else z + term
224
+
225
+ # ---- HARMONIC BRANCH: the frozen state (exact Hodge projection) -----
226
+ # For a graph Laplacian, ker(L) = per-component constants, so the
227
+ # Hodge limit of the diffusion is the PER-COMPONENT MEAN. The band
228
+ # bank only carries the transient, so without this branch the block
229
+ # cannot represent a global aggregate at all (measured: sign acc =
230
+ # chance on the global-mean task). Cost: O(n + m) via union-find.
231
+ all_edges = (
232
+ np.concatenate([sem, chain], axis=0)
233
+ if sem.shape[0] or chain.shape[0]
234
+ else np.zeros((0, 2), dtype=np.int64)
235
+ )
236
+ harmonic = self._harmonic(xn, all_edges)
237
+ _, diag_ncomp = connected_components(n, all_edges)
238
+ z = z + self.harm_g * harmonic
239
+
240
+ # ---- READ: mix channels, one nonlinearity, residual -----------------
241
+ z = z @ self.Wmix
242
+ z = z.gelu()
243
+ out = x + z @ self.Wo
244
+
245
+ # ---- sharp retrieval path (winner-take-all over the routed edges) ---
246
+ if self.use_route_head:
247
+ v = xn @ self.Wv
248
+ vg = v.gather_rows(idx) # (n, k, d)
249
+ w = softmax_rows(sel) # (n, k)
250
+ sr = (vg * w[:, :, None]).sum(axis=1) # (n, d)
251
+ out = out + sr @ self.Wr
252
+
253
+ # ---- diagnostics ---------------------------------------------------
254
+ try:
255
+ desc = topo_descriptor(n, sem, np.abs(alpha.data) + 1e-9)
256
+ except Exception:
257
+ desc = np.zeros(9)
258
+ self.diag = {
259
+ "n_edges": int(sem.shape[0]),
260
+ "n_components": int(diag_ncomp),
261
+ "lam_max": float(lam_max),
262
+ "lam_est": float(lam_est),
263
+ "alpha_abs_mean": (
264
+ float(np.abs(alpha.data).mean()) if alpha.data.size else 0.0
265
+ ),
266
+ "alpha_neg_frac": (
267
+ float((alpha.data < 0).mean()) if alpha.data.size else 0.0
268
+ ),
269
+ "band_energy": np.array(
270
+ [float((band.data**2).sum()) for band in bands]
271
+ ),
272
+ "topo": desc,
273
+ "balance_res": float(np.max(np.abs(L.data.sum(axis=1)))),
274
+ }
275
+ return out
fluidlayer/bpe.py ADDED
@@ -0,0 +1,173 @@
1
+ """
2
+ fluidlayer/bpe.py — a byte-level BPE tokenizer, in pure Python (no downloads).
3
+
4
+ WHY THIS EXISTS
5
+ ---------------
6
+ Every experiment so far ran at the CHARACTER level (vocab 76). That is
7
+ convenient but it is not what a real deployment looks like: real LLM
8
+ tokenizers are subword, with a vocabulary of 32k-256k and a very different
9
+ redundancy structure. The character-level corpus is *maximally* redundant
10
+ (a 12-gram of characters repeats constantly), which flatters a
11
+ content-keyed store. A subword corpus repeats less.
12
+
13
+ The point of this file is therefore not "a tokenizer" -- it is the ability
14
+ to re-run every measurement at a realistic token granularity, on CPU,
15
+ without downloading anything.
16
+
17
+ THE ALGORITHM
18
+ -------------
19
+ 1. split the text on a word-ish regex,
20
+ 2. count word frequencies,
21
+ 3. represent every word as its UTF-8 BYTES (so the tokenizer is lossless
22
+ for any input, including text the training corpus never saw),
23
+ 4. repeatedly merge the most frequent adjacent symbol pair,
24
+ 5. emit a vocabulary: the 256 byte tokens + every learned merge.
25
+
26
+ `encode` is then exact and reversible (`decode(encode(s)) == s` for any
27
+ string, because the byte fallback is always available).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import os
34
+ import re
35
+ from collections import Counter
36
+
37
+ __all__ = ["BPETokenizer", "train_bpe"]
38
+
39
+ _WORD = re.compile(r"\S+|\s+")
40
+
41
+
42
+ class BPETokenizer:
43
+ """A byte-level BPE tokenizer."""
44
+
45
+ def __init__(self, merges: dict, id_bytes: dict):
46
+ # merges: {(a, b): new_id} -- the merge ORDER matters
47
+ # id_bytes: {id: bytes} -- every id, bytes and merged alike
48
+ self.merges = merges
49
+ self.ranks = {p: i for i, p in enumerate(merges.keys())}
50
+ self.id_to_bytes = dict(id_bytes)
51
+
52
+ # ------------------------------------------------------------------ size
53
+ @property
54
+ def vocab_size(self) -> int:
55
+ return len(self.id_to_bytes)
56
+
57
+ # --------------------------------------------------------------- encode
58
+ def _word_symbols(self, word: str):
59
+ b = word.encode("utf-8")
60
+ return tuple(b)
61
+
62
+ def _merge_word(self, symbols):
63
+ while len(symbols) > 1:
64
+ best = None
65
+ best_i = -1
66
+ for i in range(len(symbols) - 1):
67
+ r = self.ranks.get((symbols[i], symbols[i + 1]))
68
+ if r is not None and (best is None or r < best):
69
+ best = r
70
+ best_i = i
71
+ if best is None:
72
+ break
73
+ a, b = symbols[best_i], symbols[best_i + 1]
74
+ new = self.merges.get((a, b))
75
+ if new is None:
76
+ break
77
+ symbols = symbols[:best_i] + (new,) + symbols[best_i + 2:]
78
+ return symbols
79
+
80
+ def encode(self, text: str):
81
+ ids = []
82
+ for w in _WORD.findall(text):
83
+ ids.extend(self._merge_word(self._word_symbols(w)))
84
+ return ids
85
+
86
+ def decode(self, ids) -> str:
87
+ out = bytearray()
88
+ for i in ids:
89
+ out += self.id_to_bytes[int(i)]
90
+ return out.decode("utf-8", errors="replace")
91
+
92
+ # ------------------------------------------------------------------ io
93
+ def save(self, path: str):
94
+ with open(path, "w", encoding="utf-8") as f:
95
+ json.dump({
96
+ "merges": [[list(k), v] for k, v in self.merges.items()],
97
+ "id_bytes": {str(k): list(v) for k, v in self.id_to_bytes.items()},
98
+ }, f)
99
+
100
+ @staticmethod
101
+ def load(path: str) -> BPETokenizer:
102
+ with open(path, encoding="utf-8") as f:
103
+ d = json.load(f)
104
+ merges = {tuple(m[0]): m[1] for m in d["merges"]}
105
+ id_bytes = {int(k): bytes(v) for k, v in d["id_bytes"].items()}
106
+ return BPETokenizer(merges, id_bytes)
107
+
108
+
109
+ def train_bpe(text: str, vocab_size: int = 1024, verbose: bool = True):
110
+ """Train a byte-level BPE on `text`. Deterministic, no randomness."""
111
+ words = Counter(_WORD.findall(text))
112
+ # each word -> its symbol tuple; count pairs over the whole corpus
113
+ word_syms = {w: tuple(w.encode("utf-8")) for w in words}
114
+ merges: dict = {}
115
+ id_bytes: dict = {i: bytes([i]) for i in range(256)}
116
+
117
+ def recount():
118
+ pairs = Counter()
119
+ for w, s in word_syms.items():
120
+ c = words[w]
121
+ for i in range(len(s) - 1):
122
+ pairs[(s[i], s[i + 1])] += c
123
+ return pairs
124
+
125
+ n_merges = vocab_size - 256
126
+ for step in range(n_merges):
127
+ pairs = recount()
128
+ if not pairs:
129
+ break
130
+ # deterministic tie-break: the highest count, then the lowest ids
131
+ best = max(pairs.items(), key=lambda kv: (kv[1], -kv[0][0], -kv[0][1]))
132
+ pair, cnt = best
133
+ if cnt < 2:
134
+ break
135
+ new_id = 256 + len(merges)
136
+ merges[pair] = new_id
137
+ id_bytes[new_id] = id_bytes[pair[0]] + id_bytes[pair[1]]
138
+ # rewrite every word
139
+ for w in list(word_syms):
140
+ s = word_syms[w]
141
+ out = []
142
+ i = 0
143
+ while i < len(s):
144
+ if i + 1 < len(s) and (s[i], s[i + 1]) == pair:
145
+ out.append(new_id)
146
+ i += 2
147
+ else:
148
+ out.append(s[i])
149
+ i += 1
150
+ word_syms[w] = tuple(out)
151
+ if verbose and step % 100 == 0:
152
+ print(f" merge {step}: {pair} -> {new_id} (count {cnt})")
153
+ return BPETokenizer(merges, id_bytes)
154
+
155
+
156
+ if __name__ == "__main__":
157
+ import argparse
158
+ ap = argparse.ArgumentParser()
159
+ ap.add_argument("--corpus", default="corpus.txt")
160
+ ap.add_argument("--vocab", type=int, default=1024)
161
+ ap.add_argument("--out", default="prior/bpe_1024.json")
162
+ args = ap.parse_args()
163
+ with open(args.corpus, encoding="utf-8", errors="ignore") as f:
164
+ text = f.read()
165
+ tok = train_bpe(text, args.vocab)
166
+ os.makedirs(os.path.dirname(args.out) or ".", exist_ok=True)
167
+ tok.save(args.out)
168
+ ids = tok.encode("Alice was beginning to get very tired of sitting by "
169
+ "her sister on the bank, and of having nothing to do.")
170
+ print(f" vocab {tok.vocab_size} tokens {len(ids)} "
171
+ f"-> {ids[:12]} ...")
172
+ print(f" round-trip ok: {tok.decode(ids)[:60]!r}")
173
+ print(f" wrote {args.out}")
fluidlayer/data.py ADDED
@@ -0,0 +1,92 @@
1
+ """
2
+ fluidlayer.data — tiny corpora + char-level tokenizer.
3
+
4
+ Prefers a real public-domain text (Project Gutenberg). If the sandbox has no
5
+ outbound network access it falls back to a deterministic synthetic corpus so
6
+ that the A/B training run is always reproducible.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import urllib.request
13
+
14
+ import numpy as np
15
+
16
+ __all__ = ["get_corpus", "CharTokenizer", "batchify", "GUTENBERG_URLS"]
17
+
18
+ GUTENBERG_URLS = [
19
+ "https://www.gutenberg.org/files/11/11-0.txt", # Alice in Wonderland
20
+ "https://www.gutenberg.org/cache/epub/11/pg11.txt",
21
+ ]
22
+
23
+
24
+ def get_corpus(max_chars: int = 120_000, cache: str | None = None) -> str:
25
+ """Return a text corpus (>= max_chars if possible)."""
26
+ cache = cache or os.path.join(os.path.dirname(__file__), "..", "corpus.txt")
27
+ if os.path.exists(cache):
28
+ with open(cache, encoding="utf-8", errors="ignore") as f:
29
+ t = f.read()
30
+ if len(t) >= 1000:
31
+ return t[:max_chars]
32
+ for url in GUTENBERG_URLS:
33
+ try:
34
+ with urllib.request.urlopen(url, timeout=12) as r:
35
+ raw = r.read().decode("utf-8", errors="ignore")
36
+ start = raw.find("*** START")
37
+ if start >= 0:
38
+ raw = raw[start:]
39
+ end = raw.find("*** END")
40
+ if end > 0:
41
+ raw = raw[:end]
42
+ raw = raw.replace("\r\n", "\n")
43
+ if len(raw) > 2000:
44
+ try:
45
+ os.makedirs(os.path.dirname(os.path.abspath(cache)), exist_ok=True)
46
+ with open(cache, "w", encoding="utf-8") as f:
47
+ f.write(raw)
48
+ except OSError:
49
+ pass
50
+ return raw[:max_chars]
51
+ except Exception:
52
+ continue
53
+ return _synthetic_corpus(max_chars)
54
+
55
+
56
+ def _synthetic_corpus(n: int) -> str:
57
+ """Deterministic fallback: a Zipfian character process with word structure."""
58
+ rng = np.random.default_rng(1234)
59
+ words = ["the", "cat", "sat", "on", "a", "mat", "and", "then", "said", "little",
60
+ "queen", "of", "hearts", "alice", "rabbit", "hole", "tea", "party",
61
+ "night", "tree", "house", "river", "cloud", "stone", "light", "voice"]
62
+ p = np.arange(1, len(words) + 1) ** -1.1
63
+ p /= p.sum()
64
+ out = []
65
+ while sum(len(w) + 1 for w in out) < n:
66
+ k = rng.choice(len(words), size=rng.integers(4, 14), p=p)
67
+ out.extend(words[i] for i in k)
68
+ s = " ".join(out)
69
+ return s[:n]
70
+
71
+
72
+ class CharTokenizer:
73
+ def __init__(self, text: str):
74
+ self.chars = sorted(set(text))
75
+ self.stoi = {c: i for i, c in enumerate(self.chars)}
76
+ self.itos = self.chars
77
+
78
+ def encode(self, text: str) -> np.ndarray:
79
+ return np.array([self.stoi[c] for c in text], dtype=np.int64)
80
+
81
+ def decode(self, ids) -> str:
82
+ return "".join(self.itos[int(i)] for i in ids)
83
+
84
+ @property
85
+ def vocab(self) -> int:
86
+ return len(self.chars)
87
+
88
+
89
+ def batchify(tokens: np.ndarray, batch_size: int) -> np.ndarray:
90
+ """(N,) -> (batch_size, N//batch_size) contiguous split."""
91
+ nb = len(tokens) // batch_size
92
+ return tokens[: nb * batch_size].reshape(batch_size, nb)