-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbuild.py
More file actions
285 lines (247 loc) · 11.2 KB
/
Copy pathbuild.py
File metadata and controls
285 lines (247 loc) · 11.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
# compiles weights.json into model.css + index.html, and writes expected.json
# so a browser run can be diffed against what the math says should happen.
# style.css is hand written and stays out of the blast radius. rerun me after
# every retrain.
#
# how the stylesheet thinks, in one breath: the four previous characters live
# in registered custom properties (--p newest .. --pppp oldest) on each cell's
# parent. @container style() rules branch on those and drop the matching
# embedding rows onto the cell. the cell sums rows, relus, does the output
# matmul in calc(), argmaxes with a sign() trick, and publishes --np. two tiny
# wrapper elements shift the context window along and the next nested cell
# does it all again. depth of nesting = length of generation. no loops needed
# because the dom is the loop, unrolled.
import json
w = json.load(open("weights.json"))
CHARS = w["chars"]
V = len(CHARS)
H = w["H"]
CTX = w["ctx"]
P = w["P"]
T = w["T"] # T[0] newest .. T[3] oldest
b1 = w["b1"]
W2 = w["W2"]
b2 = w["b2"]
N_PARAMS = w["n_params"]
stoi = {c: i for i, c in enumerate(CHARS)}
N_OUT = 100
TIE_EPS = 1e-4 # per glyph nudge so argmax never sees an exact tie
SIGN_EPS = 1e-6 # slack in the sign() equality test
SEEDS = ["yips", "pupp", "good", "ball", "snow", "trea", "bell", "zoom"]
DEFAULT_SEED = "yips"
ROLLS = [("r1", 3, "🎾"), ("r2", 47, "🦴"), ("r3", 101, "🧸"), ("r4", 199, "🍖")]
DEFAULT_ROLL = "r1"
TAMPS = [("sleepy", 1.0), ("waggy", 1.8), ("zoomies", 3.0)]
DEFAULT_TAMP = "waggy"
CTX_VARS = ["--p", "--pp", "--ppp", "--pppp"] # newest first, matches T order
ROW_PREFIX = ["e", "f", "g", "i"] # h is taken by the hidden layer
def fmt(x):
s = f"{x:.5f}".rstrip("0").rstrip(".")
return "0" if s in ("-0", "") else s
def signed(x):
# for tacking a constant onto the end of a sum
return f"- {fmt(-x)}" if x < 0 else f"+ {fmt(x)}"
# --- model.css --------------------------------------------------------------
css = []
css.append(
"/* model.css — generated by build.py, do not hand edit.\n"
f" yipsy herself: {N_PARAMS} parameters, context {CTX}, hidden {H}, vocab {V}.\n"
" everything below is just her weights wearing a stylesheet. */\n"
)
# property registrations. registered types are what keep this whole thing
# alive: values compute to actual numbers at each element instead of
# snowballing into token strings as they inherit down 100 levels.
reg = []
def prop(name, syntax, inherits, initial):
reg.append(
f'@property {name} {{ syntax: "{syntax}"; inherits: {str(inherits).lower()}; initial-value: {initial}; }}'
)
for v_ in CTX_VARS:
prop(v_, "<integer>", True, 26)
for v_ in ("--q", "--qq", "--qqq"):
prop(v_, "<integer>", True, 26)
prop("--np", "<integer>", True, 26)
prop("--s", "<number>", True, 3)
prop("--s1", "<number>", True, 3)
prop("--tamp", "<number>", True, 1.8)
prop("--probe", "<number>", False, 0)
for pfx in ROW_PREFIX:
for j in range(H):
prop(f"--{pfx}{j}", "<number>", False, 0)
for j in range(H):
prop(f"--h{j}", "<number>", False, 0)
for k in range(V):
prop(f"--l{k}", "<number>", False, 0)
prop("--mx", "<number>", False, 0)
css.append("\n".join(reg) + "\n")
# control wiring. radios sit right under <body> so they can reach the whole
# page with the sibling combinator. checking one just sets numbers on .gen.
wire = ["/* controls -> starting context, salt, temperature */"]
for seed in SEEDS:
idx = [stoi[c] for c in seed]
decls = "; ".join(f"{CTX_VARS[j]}: {idx[CTX - 1 - j]}" for j in range(CTX))
wire.append(f"#seed-{seed}:checked ~ .page .gen {{ {decls}; }}")
wire.append(f'#seed-{seed}:checked ~ .page .lead::before {{ content: "{seed}"; }}')
for rid, salt, _ in ROLLS:
wire.append(f"#roll-{rid}:checked ~ .page .gen {{ --s: {salt}; }}")
for tid, tamp in TAMPS:
wire.append(f"#tamp-{tid}:checked ~ .page .gen {{ --tamp: {fmt(tamp)}; }}")
ids = [f"seed-{s}" for s in SEEDS] + [f"roll-{r}" for r, _, _ in ROLLS] + [f"tamp-{t}" for t, _ in TAMPS]
on_sel = ", ".join(f'#{i}:checked ~ .page label[for="{i}"]' for i in ids)
focus_sel = ", ".join(f'#{i}:focus-visible ~ .page label[for="{i}"]' for i in ids)
wire.append(on_sel + " { background: var(--acc); border-color: var(--acc); color: var(--acc-ink); }")
wire.append(focus_sel + " { outline: 2px solid var(--acc); outline-offset: 2px; }")
css.append("\n".join(wire) + "\n")
# embedding tables. one hot times matrix is row selection, and row selection
# is a style query. 4 positions x 28 glyphs = 112 rules.
for pos in range(CTX):
fam = [f"/* layer one rows for context slot {pos} ({CTX_VARS[pos]}) */"]
pfx = ROW_PREFIX[pos]
for c in range(V):
row = "; ".join(f"--{pfx}{j}: {fmt(T[pos][c][j])}" for j in range(H))
fam.append(f"@container style({CTX_VARS[pos]}: {c}) {{ .c {{ {row}; }} }}")
css.append("\n".join(fam) + "\n")
# the actual forward pass, written once, executed by every nesting level
cell = ["/* one decode step. relu, matmul, jitter, argmax */", ".c {", " display: inline;"]
for j in range(H):
cell.append(
f" --h{j}: max(0, var(--e{j}) + var(--f{j}) + var(--g{j}) + var(--i{j}) {signed(b1[j])});"
)
for k in range(V):
terms = " ".join(
("+ " if W2[j][k] >= 0 else "- ") + f"var(--h{j})*{fmt(abs(W2[j][k]))}" for j in range(H)
)
noise = f"var(--tamp) * (mod(var(--s) * {P[k]}, 97) / 96 - 0.5)"
cell.append(f" --l{k}: calc({fmt(b2[k] + k * TIE_EPS)} {terms} + {noise});")
cell.append(" --mx: max(" + ", ".join(f"var(--l{k})" for k in range(V)) + ");")
argmax = " + ".join(
f"{k} * max(0, sign(var(--l{k}) - var(--mx) + {SIGN_EPS}))" for k in range(1, V)
)
cell.append(f" --np: calc({argmax});")
cell.append("}")
css.append("\n".join(cell) + "\n")
# shift register. .x snapshots the old context while it can still see it,
# .y rebuilds the window one step along and advances the jitter lcg.
# two elements because a property can't read its own pre update value.
css.append(
"/* slide the context window one glyph */\n"
".x { --q: var(--p); --qq: var(--pp); --qqq: var(--ppp); --s1: mod(var(--s) * 137 + 29, 251); }\n"
".y { --p: var(--np); --pp: var(--q); --ppp: var(--qq); --pppp: var(--qqq); --s: var(--s1); }\n"
)
# argmax index -> glyph on screen
glyphs = ["/* the part where numbers become letters */"]
for k, ch in enumerate(CHARS):
glyphs.append(f'@container style(--np: {k}) {{ .g::before {{ content: "{ch}"; }} }}')
css.append("\n".join(glyphs) + "\n")
# feature probe: if mod sign max and style queries all work, this hides the
# sad browser notice. if any of them are missing it stays visible.
css.append(
".page { --probe: calc(max(0, sign(mod(3, 2)))); }\n"
"@container style(--probe: 1) { .warn { display: none; } }\n"
)
with open("model.css", "w", encoding="utf-8") as f:
f.write("\n".join(css))
# --- index.html -------------------------------------------------------------
radios = []
for seed in SEEDS:
chk = " checked" if seed == DEFAULT_SEED else ""
radios.append(f'<input class="vh" type="radio" name="seed" id="seed-{seed}"{chk}>')
for rid, _, _ in ROLLS:
chk = " checked" if rid == DEFAULT_ROLL else ""
radios.append(f'<input class="vh" type="radio" name="roll" id="roll-{rid}"{chk}>')
for tid, _ in TAMPS:
chk = " checked" if tid == DEFAULT_TAMP else ""
radios.append(f'<input class="vh" type="radio" name="tamp" id="tamp-{tid}"{chk}>')
seed_pills = "\n ".join(f'<label for="seed-{s}">{s}</label>' for s in SEEDS)
roll_pills = "\n ".join(f'<label for="roll-{r}">{icon}</label>' for r, _, icon in ROLLS)
tamp_pills = "\n ".join(f'<label for="tamp-{t}">{t}</label>' for t, _ in TAMPS)
tree = '<span class="end"></span>'
for _ in range(N_OUT):
tree = f'<span class="c"><span class="g"></span><span class="x"><span class="y">{tree}</span></span></span>'
html = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>yipsy — a puppygirl language model in pure css</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🐶</text></svg>">
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="model.css">
</head>
<body>
{chr(10).join(radios)}
<div class="page">
<header>
<h1>yipsy<span class="paw">🐾</span></h1>
<p class="tag">a {N_PARAMS} parameter puppygirl language model that lives entirely in css.
no javascript anywhere on this page. pick a prompt and she generates
{N_OUT} characters in a single style pass.</p>
</header>
<div class="warn">this browser is missing some css math (mod, sign, @property, or
container style queries). yipsy needs chromium 138+, safari 18+, or firefox 151+ to
think. she is not mad, just quiet.</div>
<section class="controls" aria-label="model controls">
<div class="group"><span class="glabel">prompt</span>
{seed_pills}
</div>
<div class="group"><span class="glabel">reroll</span>
{roll_pills}
</div>
<div class="group"><span class="glabel">temperature</span>
{tamp_pills}
</div>
</section>
<section class="bubble">
<span class="avatar">🐶</span>
<p class="out"><span class="lead"></span><span class="gen">{tree}</span></p>
</section>
<footer>
<p>she is {N_PARAMS} trained parameters wearing a stylesheet. layer one is
container style queries, the matmul is calc(), the argmax is a sign() trick,
the autoregression is {N_OUT} nested spans. temperature is a little linear
congruential generator, also in calc().</p>
<p>weights trained on an original bedtime corpus about a small dog. she
mostly says things about the ball. wtfpup licensed.</p>
</footer>
</div>
</body>
</html>
"""
with open("index.html", "w", encoding="utf-8") as f:
f.write(html)
# --- expected.json ----------------------------------------------------------
# same math as the stylesheet, so the browser can be checked against it
def sample(seed, salt, tamp, n=N_OUT):
ctx = [stoi[c] for c in seed[-CTX:]]
s = salt
out = seed
for _ in range(n):
pre = list(b1)
for j2 in range(CTX):
row = T[j2][ctx[-1 - j2]]
pre = [a + b for a, b in zip(pre, row)]
h = [max(0.0, x) for x in pre]
logits = []
for k in range(V):
acc = b2[k] + k * TIE_EPS
for j2 in range(H):
acc += h[j2] * W2[j2][k]
acc += tamp * (((s * P[k]) % 97) / 96 - 0.5)
logits.append(acc)
k = max(range(V), key=lambda i: logits[i])
out += CHARS[k]
ctx.append(k)
s = (s * 137 + 29) % 251
return out
expected = {}
for seed in SEEDS:
for rid, salt, _ in ROLLS:
for tid, tamp in TAMPS:
expected[f"seed-{seed}|roll-{rid}|tamp-{tid}"] = sample(seed, salt, tamp)
with open("expected.json", "w", encoding="utf-8") as f:
json.dump({"chars": CHARS, "n": N_OUT, "combos": expected}, f, indent=1)
import os
print(f"model.css {os.path.getsize('model.css'):>8,} bytes")
print(f"index.html {os.path.getsize('index.html'):>8,} bytes")
print(f"combos {len(expected)}")
print(f"default {expected[f'seed-{DEFAULT_SEED}|roll-{DEFAULT_ROLL}|tamp-{DEFAULT_TAMP}']}")