-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtechnical-design.html
More file actions
2722 lines (2509 loc) · 181 KB
/
Copy pathtechnical-design.html
File metadata and controls
2722 lines (2509 loc) · 181 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
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<title>NeuralLab — Technical Design & UI Specification</title>
<style>
:root{
--paper:#F1F0F3; --paper-2:#E8E6EC; --surface:#FBFAFC;
--ink:#1A1823; --ink-2:#4A4757; --ink-3:#7C7889;
--rule:#D4D1DC; --rule-2:#C2BECD;
--accent:#C77E12; --accent-fill:#E9A13B; --accent-soft:#F6E3C4;
--cyan:#2E7E99; --violet:#6B5CC4; --ok:#2C7D60; --bad:#AD3F3A;
--shadow:0 1px 2px rgba(26,24,35,.06),0 8px 28px -12px rgba(26,24,35,.22);
--display:"Bahnschrift","DIN Alternate","Avenir Next Condensed","Roboto Condensed","Segoe UI",system-ui,sans-serif;
--body:"Sitka Text",Charter,"Bitstream Charter",Cambria,Georgia,serif;
--mono:"Cascadia Mono","SF Mono","JetBrains Mono",Consolas,"Liberation Mono",monospace;
}
@media (prefers-color-scheme:dark){
:root:not([data-theme="light"]){
--paper:#14131C; --paper-2:#1B1A25; --surface:#191822;
--ink:#EDECF2; --ink-2:#B2AEBF; --ink-3:#807C93;
--rule:#2B2937; --rule-2:#3A3849;
--accent:#E9A13B; --accent-fill:#E9A13B; --accent-soft:#3A2E1B;
--cyan:#5FB4D0; --violet:#9585E6; --ok:#4FB48C; --bad:#D9625C;
--shadow:0 1px 2px rgba(0,0,0,.4),0 10px 32px -14px rgba(0,0,0,.7);
}
}
:root[data-theme="dark"]{
--paper:#14131C; --paper-2:#1B1A25; --surface:#191822;
--ink:#EDECF2; --ink-2:#B2AEBF; --ink-3:#807C93;
--rule:#2B2937; --rule-2:#3A3849;
--accent:#E9A13B; --accent-fill:#E9A13B; --accent-soft:#3A2E1B;
--cyan:#5FB4D0; --violet:#9585E6; --ok:#4FB48C; --bad:#D9625C;
--shadow:0 1px 2px rgba(0,0,0,.4),0 10px 32px -14px rgba(0,0,0,.7);
}
/* App mockups keep a fixed dark instrument skin in both page themes — the same trick
Evolab's design document uses, and for the same reason: the product is a dark
instrument, and re-tinting a screenshot of it per theme would be a lie about the UI. */
.app{
--a-bg:#0E0D15; --a-pnl:#16151F; --a-pnl2:#1F1E2A; --a-line:#2C2A3A;
--a-tx:#E4E2EC; --a-dim:#8C8899; --a-faint:#5C5871;
--a-am:#E9A13B; --a-cy:#4EA8C4; --a-vi:#8B7BD8; --a-ok:#4FB48C; --a-bad:#D9625C;
}
*{box-sizing:border-box}
body{margin:0;background:var(--paper);color:var(--ink);font-family:var(--body);
font-size:17px;line-height:1.62;-webkit-font-smoothing:antialiased}
img,svg,canvas{max-width:100%}
a{color:var(--accent);text-underline-offset:3px}
:focus-visible{outline:2px solid var(--accent-fill);outline-offset:3px;border-radius:2px}
h1,h2,h3,h4{font-family:var(--display);font-weight:600;text-wrap:balance;margin:0;line-height:1.14;
letter-spacing:.005em}
p{margin:0}
code,kbd{font-family:var(--mono);font-size:.86em;background:var(--paper-2);
padding:.1em .34em;border-radius:3px;border:1px solid var(--rule)}
.eyebrow{font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;
color:var(--ink-3);font-weight:500}
.wrap{max-width:1500px;margin:0 auto;padding:0 clamp(16px,4vw,44px)}
.layout{display:grid;grid-template-columns:186px minmax(0,1fr);gap:clamp(24px,4vw,64px);
align-items:start}
@media (max-width:1080px){.layout{grid-template-columns:1fr}}
.rail{position:sticky;top:0;padding:40px 0;max-height:100vh;overflow-y:auto}
@media (max-width:1080px){.rail{display:none}}
.rail ol{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:1px}
.rail a{display:flex;gap:9px;padding:5px 8px 5px 10px;text-decoration:none;color:var(--ink-3);
font-family:var(--display);font-size:14.5px;border-left:2px solid var(--rule);
transition:color .15s,border-color .15s}
.rail a:hover{color:var(--ink)}
.rail a.on{color:var(--ink);border-left-color:var(--accent-fill);font-weight:600}
.rail .num{font-family:var(--mono);font-size:10.5px;padding-top:3px;opacity:.65;
font-variant-numeric:tabular-nums}
.doc{padding:40px 0 120px;min-width:0}
section{padding-top:14px;margin-top:52px;border-top:1px solid var(--rule)}
section:first-of-type{border-top:0;margin-top:0}
.col{max-width:68ch}
.stack{display:flex;flex-direction:column;gap:18px}
.stack-s{display:flex;flex-direction:column;gap:10px}
h2{font-size:clamp(27px,3.4vw,36px);margin-bottom:4px}
h3{font-size:20px;color:var(--ink);margin-top:8px}
h4{font-size:15.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--ink-2)}
.lede{font-size:19.5px;color:var(--ink-2);line-height:1.55}
.sechead{display:flex;flex-direction:column;gap:6px;margin-bottom:22px}
.mast{padding:56px 0 30px}
.mast h1{font-size:clamp(44px,8vw,86px);letter-spacing:-.012em;line-height:.95}
.mast h1 em{font-style:normal;color:var(--accent)}
.mast .sub{font-size:clamp(18px,2.4vw,23px);color:var(--ink-2);max-width:58ch;margin-top:16px}
.meta{display:flex;flex-wrap:wrap;gap:0;margin-top:26px;border-top:1px solid var(--rule);
border-bottom:1px solid var(--rule)}
.meta div{flex:1 1 150px;padding:11px 16px 12px 0;display:flex;flex-direction:column;gap:2px}
.meta dt{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;
color:var(--ink-3)}
.meta dd{margin:0;font-family:var(--display);font-size:15px;color:var(--ink)}
.grid{display:grid;gap:14px}
.g2{grid-template-columns:repeat(2,minmax(0,1fr))}
.g3{grid-template-columns:repeat(3,minmax(0,1fr))}
.g4{grid-template-columns:repeat(4,minmax(0,1fr))}
@media (max-width:900px){.g3,.g4{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media (max-width:600px){.g2,.g3,.g4{grid-template-columns:1fr}}
.card{border:1px solid var(--rule);background:var(--surface);border-radius:3px;padding:16px 18px;
display:flex;flex-direction:column;gap:7px}
.card h4{margin:0;font-size:13px}
.card p{font-size:15px;color:var(--ink-2);line-height:1.5}
.card .k{font-family:var(--mono);font-size:11px;color:var(--ink-3)}
.pick{border-left:2px solid var(--accent-fill);background:var(--accent-soft);padding:13px 16px;
border-radius:0 3px 3px 0;font-size:15.5px}
.pick strong{font-family:var(--display);letter-spacing:.02em}
.risk{border:1px solid var(--rule);border-left:2px solid var(--violet);background:var(--surface);
padding:13px 16px;border-radius:0 3px 3px 0;font-size:15.5px}
.risk > .lbl{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;
color:var(--violet);display:block;margin-bottom:7px}
.risk p{margin:0 0 10px}
.risk p:last-child{margin-bottom:0}
.risk strong{font-family:var(--display);letter-spacing:.02em;color:var(--ink)}
.tbl{width:100%;overflow-x:auto;border:1px solid var(--rule);border-radius:3px;background:var(--surface)}
table{border-collapse:collapse;width:100%;font-size:14.5px;min-width:560px}
th{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;
color:var(--ink-3);text-align:left;padding:10px 14px;border-bottom:1px solid var(--rule-2);
font-weight:500;white-space:nowrap}
td{padding:10px 14px;border-bottom:1px solid var(--rule);vertical-align:top;color:var(--ink-2)}
tr:last-child td{border-bottom:0}
td:first-child{color:var(--ink);font-family:var(--display);font-size:15.5px}
td.num{font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:13px;white-space:nowrap}
.tag{font-family:var(--mono);font-size:10px;letter-spacing:.08em;text-transform:uppercase;
padding:2px 6px;border-radius:2px;border:1px solid currentColor;white-space:nowrap}
.t-ok{color:var(--ok)} .t-no{color:var(--bad)} .t-am{color:var(--accent)}
.t-vi{color:var(--violet)} .t-cy{color:var(--cyan)}
ul.list{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:9px}
ul.list li{padding-left:18px;position:relative;color:var(--ink-2);font-size:16px}
ul.list li::before{content:"";position:absolute;left:2px;top:.62em;width:6px;height:1px;
background:var(--rule-2)}
ul.list li strong{color:var(--ink);font-family:var(--display);font-weight:600;letter-spacing:.01em}
ol.steps{margin:0;padding-left:22px;display:flex;flex-direction:column;gap:9px}
ol.steps li{color:var(--ink-2);font-size:16px}
ol.steps li strong{color:var(--ink);font-family:var(--display);font-weight:600}
pre{margin:0;overflow-x:auto;background:var(--surface);border:1px solid var(--rule);border-radius:3px;
padding:14px 16px;font-family:var(--mono);font-size:12.5px;line-height:1.65;color:var(--ink-2)}
pre b{color:var(--accent);font-weight:400}
pre i{color:var(--ink-3);font-style:normal}
pre s{color:var(--violet);text-decoration:none}
figure{margin:0;display:flex;flex-direction:column;gap:0}
.mock{border:1px solid var(--rule-2);border-radius:5px;overflow:hidden;box-shadow:var(--shadow);
background:#0E0D15}
.chrome{display:flex;align-items:center;gap:10px;padding:8px 12px;background:#0A0910;
border-bottom:1px solid #2C2A3A}
.dots{display:flex;gap:5px}
.dots i{width:8px;height:8px;border-radius:50%;background:#2C2A3A;display:block}
.chrome .path{font-family:var(--mono);font-size:10.5px;color:#6E6A84;letter-spacing:.04em}
figcaption{display:flex;gap:10px;padding:10px 2px 0;font-size:14px;color:var(--ink-2);
align-items:baseline}
figcaption b{font-family:var(--mono);font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;
color:var(--accent);white-space:nowrap;padding-top:2px}
.notes{display:grid;gap:8px 20px;margin-top:12px;grid-template-columns:repeat(auto-fit,minmax(250px,1fr))}
/* The badge is positioned rather than made a flex item, so that <code> and <em> inside a
note stay inline. As flex children they were blockified, and `|w|` rendered as a
full-width panel in the middle of a sentence. */
.notes div{font-size:14px;color:var(--ink-2);line-height:1.45;position:relative;padding-left:25px}
.notes span{position:absolute;left:0;top:3px;font-family:var(--mono);font-size:10px;
color:var(--paper);background:var(--ink-3);border-radius:2px;height:16px;min-width:16px;
display:inline-flex;align-items:center;justify-content:center}
.scrollx{overflow-x:auto}
</style>
<style>
/* ---------- app UI kit (dark, fixed) — mirrors apps/web/index.html ---------- */
.app{background:var(--a-bg);color:var(--a-tx);font-family:var(--display);font-size:13px;
display:grid;user-select:none;min-width:820px}
.app *{box-sizing:border-box}
.app .mono{font-family:var(--mono)}
.tb{display:flex;align-items:center;gap:10px;padding:0 12px;height:42px;background:var(--a-pnl);
border-bottom:1px solid var(--a-line);grid-column:1/-1;flex-wrap:nowrap;overflow:hidden}
.tb .mono,.tb .chip{white-space:nowrap}
.tb .brand{font-weight:600;letter-spacing:.1em;text-transform:uppercase;font-size:12px;flex:none}
.tb .brand em{font-style:normal;color:var(--a-am)}
.tb .sp{flex:1}
.btn{padding:4px 11px;border-radius:3px;border:1px solid var(--a-line);color:var(--a-tx);
background:var(--a-pnl2);font-size:12px;font-family:var(--display);letter-spacing:.02em;flex:none;
white-space:nowrap}
.btn.pri{background:var(--a-am);border-color:var(--a-am);color:#17140C;font-weight:600}
.btn.gh{background:transparent;color:var(--a-dim)}
.seg{display:flex;border:1px solid var(--a-line);border-radius:3px;overflow:hidden;flex:none}
.seg span{text-align:center;padding:3px 10px;font-size:11.5px;color:var(--a-dim);white-space:nowrap}
.seg span.on{background:var(--a-pnl2);color:var(--a-am)}
.seg.vi span.on{color:var(--a-vi)}
.seg.cy span.on{color:var(--a-cy)}
.seg.w span{flex:1}
.pnl{background:var(--a-pnl);display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden}
.pnl.l{border-right:1px solid var(--a-line)}
.pnl.r{border-left:1px solid var(--a-line)}
.ph{display:flex;align-items:center;gap:8px;padding:0 10px;height:28px;flex:none;
border-bottom:1px solid var(--a-line);font-size:10px;letter-spacing:.14em;text-transform:uppercase;
color:var(--a-dim);font-family:var(--mono);white-space:nowrap}
.ph .sp{flex:1}
.ph em{font-style:normal;color:var(--a-faint);letter-spacing:0;text-transform:none;font-size:11px}
.ph u{text-decoration:none;color:var(--a-faint);font-size:11px}
/* flex:none matters. As a shrinkable item the readout block was compressed below its own
content and the rows drew straight over the next panel header, while .pnl's overflow:hidden
clipped the evidence so scrollHeight still reported a clean fit. */
.pb{padding:9px 10px;display:flex;flex-direction:column;gap:8px;min-height:0;flex:none}
.tree{display:flex;flex-direction:column;gap:1px;font-size:12.5px}
.tree div{display:flex;align-items:center;gap:7px;padding:3px 6px;border-radius:3px;color:var(--a-dim)}
.tree div.on{background:var(--a-pnl2);color:var(--a-tx)}
.tree div.on s{background:var(--a-am)}
.tree s{width:5px;height:5px;border-radius:1px;background:var(--a-faint);flex:none;text-decoration:none}
.tree u{text-decoration:none;margin-left:auto;font-family:var(--mono);font-size:10px;color:var(--a-faint)}
.fld{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--a-dim)}
.fld label{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.fld .in{font-family:var(--mono);font-size:11.5px;color:var(--a-tx);background:var(--a-bg);
border:1px solid var(--a-line);border-radius:3px;padding:2px 7px;min-width:58px;text-align:right;
font-variant-numeric:tabular-nums}
.fld .in.am{color:var(--a-am);border-color:#4A3B1F}
.fld .in.bad{color:var(--a-bad);border-color:#4A2523}
.slider{height:3px;border-radius:2px;background:var(--a-line);position:relative;flex:1.1}
.slider i{position:absolute;left:0;top:0;bottom:0;background:var(--a-cy);border-radius:2px}
.slider b{position:absolute;top:-4px;width:11px;height:11px;border-radius:50%;background:var(--a-tx);
transform:translateX(-50%);box-shadow:0 0 0 2px var(--a-pnl)}
.sl-head{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;
color:var(--a-dim);padding-bottom:3px;border-bottom:1px solid var(--a-line);margin-top:2px}
.divide{height:1px;background:var(--a-line);margin:2px 0}
.kv{display:flex;justify-content:space-between;gap:10px;font-family:var(--mono);font-size:11px;
color:var(--a-dim);font-variant-numeric:tabular-nums}
.kv b{color:var(--a-tx);font-weight:400}
.kv b.am{color:var(--a-am)} .kv b.ok{color:var(--a-ok)} .kv b.bad{color:var(--a-bad)}
.kv b.cy{color:var(--a-cy)} .kv b.vi{color:var(--a-vi)}
.chip{font-family:var(--mono);font-size:9.5px;letter-spacing:.08em;text-transform:uppercase;
padding:2px 6px;border-radius:2px;border:1px solid var(--a-line);color:var(--a-dim);white-space:nowrap}
.chip.am{color:var(--a-am);border-color:#4A3B1F;background:#221A0E}
.chip.ok{color:var(--a-ok);border-color:#1E4638;background:#0F231C}
.chip.bad{color:var(--a-bad);border-color:#4A2523;background:#241312}
.chip.cy{color:var(--a-cy);border-color:#1E3B46;background:#0D1D23}
.chip.vi{color:var(--a-vi);border-color:#332C55;background:#17142A}
.chip.live{color:var(--a-am);border-color:#4A3B1F}
.chip.live::before{content:"";width:5px;height:5px;border-radius:50%;background:var(--a-am);
display:inline-block;margin-right:5px;vertical-align:middle}
.stage{position:relative;background:radial-gradient(120% 90% at 50% 8%,#191826 0%,#0B0A11 78%);
min-height:0;overflow:hidden}
.stage svg{display:block;width:100%;height:100%}
.hud{position:absolute;display:flex;gap:6px;flex-wrap:wrap}
.hud.tl{top:10px;left:10px} .hud.tr{top:10px;right:10px} .hud.bl{bottom:10px;left:10px}
.hud.br{bottom:10px;right:10px}
.hud .box{background:rgba(14,13,21,.86);border:1px solid var(--a-line);border-radius:3px;
padding:6px 9px;display:flex;flex-direction:column;gap:3px}
.hud .box .lb{font-family:var(--mono);font-size:9px;letter-spacing:.12em;text-transform:uppercase;
color:var(--a-faint)}
.hud .box .vl{font-family:var(--mono);font-size:15px;color:var(--a-tx);font-variant-numeric:tabular-nums}
.hud .box .vl.am{color:var(--a-am)} .hud .box .vl.bad{color:var(--a-bad)}
.bar{display:flex;align-items:center;gap:10px;padding:0 10px;height:34px;background:var(--a-pnl);
border-top:1px solid var(--a-line);flex:none;grid-column:1/-1}
.trk{flex:1;height:18px;position:relative;background:var(--a-bg);border:1px solid var(--a-line);
border-radius:3px;overflow:hidden}
.trk i{position:absolute;top:0;bottom:0;left:0;background:#1C2A31}
.trk b{position:absolute;top:-2px;bottom:-2px;width:2px;background:var(--a-am)}
.gauge{height:5px;border-radius:3px;background:var(--a-line);overflow:hidden;flex:none}
.gauge i{display:block;height:100%;border-radius:3px;background:var(--a-am)}
.legend{display:flex;gap:12px;padding:5px 10px;font-family:var(--mono);font-size:10px;
color:var(--a-faint);border-bottom:1px solid var(--a-line);flex:none;flex-wrap:wrap}
.note{font-size:11.5px;color:var(--a-faint);line-height:1.45;padding:2px 10px 8px}
.note em{font-style:normal;color:var(--a-dim)}
/* stepper */
.st-stage{display:flex;flex-direction:column;gap:1px;padding:7px 11px;border-left:2px solid transparent;
color:var(--a-faint)}
.st-stage b{font-weight:400;font-size:13px}
.st-stage span{font-family:var(--mono);font-size:9.5px;letter-spacing:.06em}
.st-stage.done{color:var(--a-dim);border-left-color:var(--a-line)}
.st-stage.on{color:var(--a-tx);border-left-color:var(--a-am);background:var(--a-pnl2)}
.st-stage.on span{color:var(--a-am)}
.st-sub{font-family:var(--mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;
color:var(--a-faint);margin:10px 0 3px;padding-bottom:3px;border-bottom:1px solid var(--a-line)}
.st-sub:first-child{margin-top:0}
.st-row{display:grid;grid-template-columns:96px minmax(0,1fr) 96px;align-items:center;gap:9px}
.st-lb{font-family:var(--mono);font-size:10.5px;color:var(--a-dim)}
.st-nt{font-family:var(--mono);font-size:10px;color:var(--a-faint);text-align:right}
.prose{font-size:13px;line-height:1.55;color:var(--a-tx)}
.prose.dim{color:var(--a-dim)}
.prose b{color:var(--a-am);font-weight:400}
/* challenge cards */
.ch{display:flex;flex-direction:column;border-bottom:1px solid var(--a-line);padding:7px 11px;gap:5px}
.ch.open{background:var(--a-pnl2)}
.ch.ahead{opacity:.45}
.ch-h{display:grid;grid-template-columns:18px 1fr 14px;align-items:center;gap:8px;font-size:12.5px}
.ch-n{font-family:var(--mono);font-size:10px;color:var(--a-faint);border:1px solid var(--a-line);
border-radius:2px;text-align:center;line-height:15px}
.ch.done .ch-n{color:var(--a-ok);border-color:#2B4C40}
.ch-t{color:var(--a-tx)}
.ch-b{font-size:11.5px;color:var(--a-dim);line-height:1.45}
.ch-c{display:flex;flex-wrap:wrap;gap:4px}
.ch-phase{font-family:var(--mono);font-size:9.5px;letter-spacing:.13em;text-transform:uppercase;
color:var(--a-faint);padding:11px 11px 4px;border-bottom:1px solid var(--a-line)}
/* narrow-chassis mockup */
.drawer{position:absolute;top:0;bottom:0;left:0;width:62%;z-index:3;box-shadow:0 0 30px rgba(0,0,0,.6)}
.scrim{position:absolute;inset:0;background:rgba(8,7,13,.55);z-index:2}
</style>
<div class="wrap">
<div class="layout">
<nav class="rail" aria-label="Sections">
<ol id="toc">
<li><a href="#thesis"><span class="num">01</span> Thesis</a></li>
<li><a href="#arch"><span class="num">02</span> Architecture</a></li>
<li><a href="#kernels"><span class="num">03</span> The two kernels</a></li>
<li><a href="#numerics"><span class="num">04</span> Numerics</a></li>
<li><a href="#threads"><span class="num">05</span> Threads & protocol</a></li>
<li><a href="#teaching"><span class="num">06</span> Teaching</a></li>
<li><a href="#render"><span class="num">07</span> Rendering</a></li>
<li><a href="#ui"><span class="num">08</span> UI specification</a></li>
<li><a href="#chassis"><span class="num">09</span> Responsive chassis</a></li>
<li><a href="#server"><span class="num">10</span> The server</a></li>
<li><a href="#slices"><span class="num">11</span> Slices</a></li>
<li><a href="#stack"><span class="num">12</span> Stack & choices</a></li>
<li><a href="#wrong"><span class="num">13</span> What will go wrong</a></li>
</ol>
</nav>
<main class="doc">
<header class="mast">
<p class="eyebrow">Technical design & UI specification · v1 · pre-slice-0</p>
<h1>Neural<em>Lab</em></h1>
<p class="sub">A browser workbench for building a Kohonen map and a multilayer perceptron,
training them on data you can see, and learning how both work by watching them happen.
Personal project, built in slices. Teaching tool first, framework never.</p>
<div class="meta">
<div><dt>Sibling</dt><dd>Evolab — same chassis, same rules</dd></div>
<div><dt>Networks</dt><dd>Kohonen SOM · MLP + backprop</dd></div>
<div><dt>Runtime</dt><dd>TypeScript, no ML framework</dd></div>
<div><dt>Slices</dt><dd>0 – 16</dd></div>
</div>
</header>
<!-- ==================== 1 ==================== -->
<section id="thesis">
<div class="sechead"><p class="eyebrow">01</p><h2>Two algorithms, one instrument</h2></div>
<div class="col stack">
<p class="lede">Most neural-network teaching material shows you a diagram of backpropagation
and then hands you a library that does it for you. The diagram and the library never meet.
NeuralLab closes that gap: the arrows in the diagram are the numbers on screen, and the
numbers on screen come from the code that is actually training.</p>
<p>Evolab made a genetic algorithm watchable by pausing it between operators and showing each
one acting on real genomes. NeuralLab does the same thing twice, because there are two
algorithms here and they fail in opposite ways:</p>
<ul class="list">
<li><strong>The multilayer perceptron</strong> is supervised. It is told the answer, measures
how wrong it was, and pushes that error backwards through the network. Its failures are
numerical — gradients that vanish, steps that overshoot, a model that memorises its
training set.</li>
<li><strong>The Kohonen self-organising map</strong> is unsupervised. Nobody tells it
anything. A grid of nodes competes to be nearest each sample, and the winner drags its
neighbours with it, so the grid ends up folded through the data like a net. Its failures are
structural — a map that stays twisted, a schedule that cools too fast, a lattice that tears.</li>
</ul>
<p>Putting them in one app is not a convenience. It is the last lesson: the same dataset,
seen once through a boundary the network was told to find and once through a structure it
found on its own.</p>
<div class="pick"><strong>The one rule.</strong> Every screen that explains the algorithm is
driven by the algorithm. Not an animation of backprop — backprop, paused. If a panel would
need its own copy of the maths to draw itself, the panel is wrong, not the architecture.</div>
<h3>Who it is for</h3>
<p>Somebody who can program, has heard of neural networks, and has never seen one learn.
They should be able to open the page, press one button, and watch a network go from a
scribble to a boundary in under ten seconds — and then be able to break it on purpose.</p>
<h3>What it is not</h3>
<ul class="list">
<li><strong>Not a framework.</strong> No autodiff, no graph compiler, no GPU kernels. Every
gradient in this project is written out by hand next to the forward pass it belongs to,
because a reader has to be able to follow it.</li>
<li><strong>Not a benchmark.</strong> Networks here are tens to low thousands of weights.
That is not a limitation to apologise for; it is the size at which a network is legible.</li>
<li><strong>Not a notebook.</strong> No code cells. The controls are the API.</li>
</ul>
</div>
</section>
<!-- ==================== 2 ==================== -->
<section id="arch">
<div class="sechead"><p class="eyebrow">02</p><h2>Layers, and which way they point</h2></div>
<div class="col stack">
<p>Four packages and an app. Dependencies point one way only, and the reason is testability:
everything below <code>apps/web</code> must run under Node in a Vitest file with no DOM,
no canvas, and no timers. If it cannot, the visualisation and the algorithm have grown into
each other, which is the failure mode this whole layout exists to prevent.</p>
<pre>packages/core/ <i>Rng, Float32 helpers, Dataset, split, normalise, metrics</i>
packages/mlp/ <i>layers, init, forward, <b>backward</b>, optimisers, losses, trace</i>
packages/som/ <i>lattice, bmu, neighbourhood, schedules, u-matrix, qe/te</i>
packages/data/ <i>dataset generators — spirals, moons, blobs, colours, iris, digits</i>
apps/web/ <i>Vite app — canvas render, Three.js, workers, panels</i>
server/ <i>ASP.NET Core + SQLite — save/share, slice 15</i>
docs/ <i>this document, the implementation guide, the code guide</i>
<s>core</s>
/ | \
<s>mlp</s> <s>som</s> <s>data</s> none of these four import each other
\ | /
<s>apps/web</s> the only place that knows what a canvas is</pre>
<p><code>mlp</code> and <code>som</code> never import one another. They share <code>core</code>'s
<code>Rng</code> and <code>Dataset</code>, and that is the entire overlap. A shared
<code>Trainer</code> abstraction was considered and rejected in about five minutes: the two
loops have different inputs, different state, different stopping conditions and different
failure modes, and the only thing an abstraction over them could express is that they both
have a step function.</p>
<h3>Invariants</h3>
<p>These must survive between sessions. Breaking one is a bug even when the app still works.</p>
<ol class="steps">
<li><strong>Seeded RNG only.</strong> No <code>Math.random()</code> anywhere in
<code>packages/</code>. Weight initialisation, batch shuffling, dropout masks and dataset
generation all draw from an <code>Rng</code> instance threaded through as a parameter.
This is what makes runs reproducible and the golden test possible.</li>
<li><strong>Fixed step granularity.</strong> Training advances in whole minibatch steps.
The render loop drains steps that have completed; it never trains "for a frame". The same
rule as Evolab's fixed physics timestep, for the same reason — a loop that does variable
work per frame produces a different answer on a different machine.</li>
<li><strong>Weights are flat <code>Float32Array</code>, row-major, owned by their layer.</strong>
No arrays of neuron objects, no nested arrays. Three consequences, all wanted: they post to a
worker without a structured clone, the renderer indexes them directly, and a whole network
serialises as one buffer.</li>
<li><strong>Packages stay pure.</strong> No DOM, no <code>window</code>, no timers, no I/O,
no <code>console</code>. If something needs to report progress it takes a callback.</li>
<li><strong>Nothing browser-specific below <code>apps/web</code>.</strong> Workers, canvas,
Three.js, storage, URL parsing — all live in the app.</li>
<li><strong>No autodiff, and no library that provides one.</strong> This is the load-bearing
decision of the whole project and it is discussed in §12. A hand-written backward pass is the
artefact being taught.</li>
<li><strong>Tests are never deleted or loosened to make a change pass.</strong> If one fails,
either the change is wrong or the change is deliberate — say which, in the commit message,
and update the expected value in the same commit.</li>
</ol>
<h3>Two tests that are not like the others</h3>
<p>Every project has unit tests. Two here do a different job and neither is optional.</p>
<div class="grid g2" style="max-width:none">
<div class="card">
<h4>Gradient check</h4>
<p class="k">packages/mlp/__tests__/gradcheck.test.ts</p>
<p>For every layer type and every activation, compare the analytic gradient against a
central finite difference at <code>h = 1e-4</code>, on a fixed seed, and require agreement
to a relative <code>1e-5</code>.</p>
<p>This exists because <em>a wrong gradient still trains.</em> A sign error in one term or a
missing transpose typically slows learning rather than stopping it, so the loss curve
still goes down and nothing looks broken. Finite differences do not care how plausible
the curve looks.</p>
</div>
<div class="card">
<h4>Golden run</h4>
<p class="k">packages/mlp/__tests__/golden.test.ts</p>
<p>Seed 4417, two-moons at 240 samples, 2–8–8–2 with tanh, SGD at 0.1,
batch 16, 400 steps. Pin the final training loss to four decimals and the final weight
checksum to one integer.</p>
<p>Gradient check proves the maths is <em>right</em>. This proves it is <em>unchanged</em>.
Refactoring the inner loop is safe exactly when this number does not move, and every
"harmless" tidy-up gets checked against it.</p>
</div>
</div>
</div>
</section>
<!-- ==================== 3 ==================== -->
<section id="kernels">
<div class="sechead"><p class="eyebrow">03</p><h2>What is actually being learned</h2></div>
<div class="col stack">
<h3>The perceptron kernel</h3>
<p>A network is an array of dense layers. Each layer owns two buffers and nothing else.</p>
<pre>interface Dense {
readonly inputs: number;
readonly units: number;
readonly act: <b>'relu' | 'tanh' | 'sigmoid' | 'linear' | 'softmax'</b>;
W: Float32Array; <i>// units * inputs, row-major: W[u * inputs + i]</i>
b: Float32Array; <i>// units</i>
}
interface Net {
readonly layers: Dense[];
readonly loss: <b>'mse' | 'crossEntropy'</b>;
}</pre>
<p>Row-major with the unit as the outer index is chosen so that the forward pass walks
<code>W</code> linearly — one unit's weights are contiguous — and so that the weight-matrix
heatmap in §7 is a straight <code>ImageData</code> blit with no transpose.</p>
<p>The backward pass is written as its own function per activation, immediately below the
forward one, sharing the file and the naming. There is no <code>Layer</code> class with a
virtual <code>backward()</code>: the whole point is that a reader can put a finger on
<code>dz = da * (1 - a * a)</code> and see it is the derivative of <code>tanh</code>.</p>
<p>Optimisers are plain functions over a layer's buffers plus their own state:
<strong>SGD</strong>, <strong>SGD with momentum</strong>, <strong>Adam</strong>. Adam is in
from slice 7 rather than later because its bias-correction terms are the classic
"it still trains, it is just wrong" bug, and the gradient check does not catch optimiser bugs
— a dedicated test compares one Adam step against a hand-computed value.</p>
<h3>The Kohonen kernel</h3>
<pre>interface Som {
readonly cols: number; readonly rows: number;
readonly dim: number; <i>// input dimensionality</i>
readonly topology: <b>'hex' | 'rect'</b>; <i>// hex is the default — see below</i>
W: Float32Array; <i>// cols * rows * dim, node-major</i>
hits: Int32Array; <i>// samples won, per node</i>
<i>/** Neighbour indices per node, built once per topology. Never derived inline. */</i>
neighbours: <b>Int32Array</b>; <i>// cols * rows * 6, -1 where the lattice ends</i>
}
interface Schedule {
readonly alpha0: number; readonly sigma0: number;
readonly decay: <b>'exponential' | 'linear' | 'inverse'</b>;
readonly steps: number; <i>// the horizon both decays are written against</i>
}</pre>
<p>One step is: draw a sample, find the best matching unit by squared Euclidean distance over
all nodes, then move every node toward the sample by
<code>α(t) · h(d, t)</code>, where <code>d</code> is the distance
<em>in the lattice</em>, not in the data. That distinction is the whole algorithm and the UI
is built to make it unmissable — §8's SOM stepper draws the lattice neighbourhood and the input
space side by side.</p>
<div class="pick"><strong>The lattice is hexagonal by default, and that is not decoration.</strong>
On a rectangular lattice a node has four neighbours at distance 1 and four more at
√2, so <code>h(d, t)</code> pulls harder along the axes than along the diagonals and
the map contracts anisotropically. Hex gives six neighbours all at distance 1 and a genuinely
circular neighbourhood. Rectangular stays selectable, because putting the two side by side is
itself a lesson.</div>
<p><strong>The consequence to design around: lattice distance is not Euclidean on
(col, row).</strong> Offset rows have to be converted to axial coordinates before
measuring, and getting that wrong makes every neighbourhood subtly the wrong shape on
alternate rows — a map that still trains, still looks plausible, and is quietly not a SOM.
That is why <code>neighbours</code> is a table built once per topology and tested against a
hand-counted 3 × 3, rather than a distance computed inline wherever it is
needed.</p>
<p>Both decays are written against an explicit <code>steps</code> horizon rather than a
per-step multiplier. A multiplier makes "how fast does it cool" depend on how long you happen
to run, which is exactly the confusion challenge 10 exists to create deliberately.</p>
<h4 style="margin-top:6px">Measuring a map that nobody labelled</h4>
<ul class="list">
<li><strong>Quantisation error</strong> — mean distance from a sample to its BMU. Goes down
as the map fits the data. Charted per epoch, the SOM's answer to a loss curve.</li>
<li><strong>Topographic error</strong> — the fraction of samples whose best and second-best
nodes are <em>not</em> lattice neighbours. This is the number that catches a twisted map, and
it is the one a loss curve cannot see. Watch challenge 10 drive QE down while TE climbs.</li>
<li><strong>U-matrix</strong> — for each node, the mean distance to its lattice neighbours.
Drawn as a heatmap it shows ridges where the data has gaps, which is cluster structure
recovered without a single label.</li>
<li><strong>Component planes</strong> — one heatmap per input dimension, showing how that
feature varies across the map. This is how a 13-dimensional map becomes readable.</li>
</ul>
<div class="risk" style="margin-top:16px"><span class="lbl">Amendment · slice 9 · built and measured</span>
<p><strong>Quantisation error does not fall below its random-init reading on the colour
cube, and that is measured, not a bug.</strong> <code>createSom</code> draws weights uniform
in <code>[0, 1)</code>, deliberately matching the colour cube's own range so a fresh
map starts inside the data rather than having to travel to it. That choice means a random
map is 144 points drawn from the <em>exact</em> data distribution — an unusually strong
quantiser with no structure behind it at all: topographic error at step 0 measures 0.97,
essentially every sample's best and second-best nodes unrelated. Training pulls the lattice
into a coherent sheet, which costs some of that raw quantising power in exchange for the
property a SOM actually promises. Sampling QE at nine checkpoints across a 3 000-step run
shows the real shape of it: a spike to 0.36 as soon as topology starts to matter, then a
monotonic fall to 0.12 — "goes down as the map fits the data" is true, just measured from
after the initial reorganisation rather than from the random start. The golden test pins QE
from step 300 rather than step 0 for exactly this reason, and separately asserts the random
baseline's own topographic error, so the exception is checked rather than quietly assumed
away.</p>
<p><strong>The exponential schedule needed retuning once this was visible.</strong>
<code>v0 · e⁻¹</code> remaining at the horizon — the textbook
constant — measured visibly under-converged: a 12×12 map still had a <em>higher</em>
QE after 3 000 steps than its own random init, because σ was still 2.2 hex-units wide
at the final step, wide enough to keep the lattice smoothed together rather than letting
individual nodes settle onto the data. Retuned to
<code>v0 · e⁻³</code> (about 5% remaining), which is what the
golden numbers are pinned against. The linear and inverse decays were unaffected — linear
already reaches its floor at the horizon by construction, and inverse's <code>v0/2</code> at
the horizon was never the textbook constant to begin with.</p>
<p><strong>A step is one sample, not a minibatch</strong> — the MLP batches because a
gradient is an average over rows, and a SOM update has no such average to take. Samples are
drawn <em>with replacement</em>, the classical Kohonen loop, rather than a shuffled epoch:
tying "how many times has this row been seen" to the row count would fight the schedule's
own <code>steps</code> horizon. Invariant 2's actual rule — training advances in whole, fixed
units of work — holds regardless of what the unit is.</p>
<p><strong>Built pure and headless, exactly as far as slice 9 was scoped.</strong> No app
wiring, no Explorer panel, no network switch yet — <code>net-som</code>'s disabled button
is corrected to say so rather than left naming a slice number now in the past. <code>npm run
som</code> plays the role <code>scripts/train.ts</code> played through slice 2: a golden run
pinned in a test, replayed headlessly, printed where a reader can see it — here as two
12×12 grids of real 24-bit terminal colour, before training (scattered noise) and
after (a smooth gradient across the lattice), the same evidentiary role
<code>scripts/data.ts</code>'s ASCII scatter has played since slice 0.</p>
</div>
<h3>Datasets</h3>
<p><code>packages/data</code> generates everything from a seed; nothing is fetched at runtime
except the digits blob.</p>
<div class="tbl"><table>
<thead><tr><th>Set</th><th>Dim</th><th>Size</th><th>For</th><th>Teaches</th></tr></thead>
<tbody>
<tr><td>XOR</td><td class="num">2</td><td class="num">4 – 400</td><td><span class="tag t-vi">MLP</span></td><td>why one layer is not enough</td></tr>
<tr><td>Two moons</td><td class="num">2</td><td class="num">240</td><td><span class="tag t-vi">MLP</span></td><td>a curved boundary; the default</td></tr>
<tr><td>Concentric circles</td><td class="num">2</td><td class="num">240</td><td><span class="tag t-vi">MLP</span></td><td>a boundary no line can make</td></tr>
<tr><td>Two spirals</td><td class="num">2</td><td class="num">400</td><td><span class="tag t-vi">MLP</span></td><td>capacity, and patience — needs 20 k steps <span class="tag t-vi">measured</span></td></tr>
<tr><td>Gaussian blobs</td><td class="num">2</td><td class="num">k × 80</td><td><span class="tag t-vi">MLP</span> <span class="tag t-cy">SOM</span></td><td>clusters both ways</td></tr>
<tr><td>Colour cube</td><td class="num">3</td><td class="num">1 500</td><td><span class="tag t-cy">SOM</span></td><td>the classic — the map <em>is</em> the picture</td></tr>
<tr><td>Animals</td><td class="num">13</td><td class="num">16</td><td><span class="tag t-cy">SOM</span></td><td>Kohonen's own semantic map, labelled</td></tr>
<tr><td>Iris</td><td class="num">4</td><td class="num">150</td><td><span class="tag t-cy">SOM</span> <span class="tag t-vi">MLP</span></td><td>component planes on real data</td></tr>
<tr><td>Digits 8×8</td><td class="num">64</td><td class="num">1 200</td><td><span class="tag t-cy">SOM</span> <span class="tag t-vi">MLP</span></td><td>confusion matrix; a map of handwriting</td></tr>
</tbody>
</table></div>
<p style="margin-top:14px">The colour cube earns its place twice: it is the demonstration
everyone has seen, and it is the only dataset where the node's weight vector can be drawn
<em>as itself</em> — a node's three weights are literally a colour. Every other visualisation
in the SOM half is a way of recovering that legibility for data that is not already a colour.</p>
<p>Digits ships as a base64 blob of roughly 78 kB (1200 × 64 bytes) inside the
module. That is a real cost against a bundle that is otherwise tiny, and it buys the two
screens — a confusion matrix and a SOM of handwriting — that make the app look like it is
about machine learning rather than about toy data. It arrives in slice 16 so the decision can
be reversed cheaply.</p>
<div class="risk" style="margin-top:16px"><span class="lbl">Amendment · slice 16</span>
<p><strong>The blob is the real UCI set, confirmed rather than assumed.</strong> This
section's own reasoning — that Digits exists specifically to escape "toy data" — would have
been undermined by a procedurally generated lookalike, so the sourcing question was put to
the user directly rather than decided silently. E. Alpaydin and C. Kaynak's 1998 <em>Optical
Recognition of Handwritten Digits</em>, the same bytes scikit-learn ships as
<code>load_digits</code>, public domain: 1 200 rows, 120 per class, taken from the UCI
repository's own <code>optdigits.tra</code>/<code>.tes</code> files and packed 65 bytes a
row (64 pixels, then the label) — 78 000 raw bytes, matching this section's own figure
almost exactly once the label byte is folded in.</p>
<p><strong>A genuinely surprising, measured result: a flat network — no hidden layer at
all — still clears 93–97% on Digits.</strong> Every other dataset in this project uses
"no hidden layer" as a clean failure mode; XOR's 75% ceiling is the canonical one. Digits'
64-dimensional pixel space turns out to be close enough to linearly separable that logistic
regression alone does most of the job. Zero initialisation still fails completely (exactly
10.0%, chance among ten classes — symmetry never breaks regardless of how easy the
underlying problem is) and a destructive learning rate still degrades hard, so the dataset
still teaches something about a flat network; it is just a different lesson than the one
every 2D set teaches, recorded here rather than smoothed over.</p>
<p><strong>The scorecard's 0.85 badge threshold is measured against five configurations
tried by hand, not chosen to feel right.</strong> At <code>SCORECARD_STEPS</code> (600,
itself measured — adam at 0.005 on a [128,128] network is flat by then across every seed
tried): every sane configuration clears 0.90 or better on its worst seed, zero-init never
leaves 0.10, and a destructive learning rate lands in the 0.70s. 0.85 sits cleanly between —
earnable by a reasonable choice, not by accident — and the badge reads the <em>worst</em> of
the five seeds, never the mean, so one unlucky initialisation cannot hide behind four good
ones.</p>
</div>
</div>
</section>
<!-- ==================== 4 ==================== -->
<section id="numerics">
<div class="sechead"><p class="eyebrow">04</p><h2>Determinism, scoped to what it is for</h2></div>
<div class="col stack">
<p>The golden test and the "reopen a saved run" feature both assume a run replays. It is worth
being exact about how far that assumption reaches, because the honest answer is narrower than
it looks and finding out later would be expensive.</p>
<ul class="list">
<li><strong>What is exactly specified.</strong> IEEE-754 <code>+ − × ÷</code>
and <code>Math.sqrt</code> are correctly rounded and identical on every conforming engine.
A dot product therefore reproduces bit-for-bit, <em>provided the loop order is fixed</em> —
which invariant 3's flat row-major layout guarantees.</li>
<li><strong>What is not.</strong> <code>Math.exp</code>, <code>Math.tanh</code>,
<code>Math.log</code> and <code>Math.pow</code> are implementation-defined in ECMAScript.
V8, SpiderMonkey and JavaScriptCore may differ in the last unit in the last place, and V8 has
changed its own implementation between versions.</li>
</ul>
<div class="pick"><strong>So determinism is scoped to one engine.</strong> A run replays
bit-identically on the same browser and version. The golden test runs under Node in CI, where
the engine is pinned, and its comment says all of this so that the first person to see a browser
disagree in the fifth decimal does not go looking for a bug in backprop.</div>
<p>The alternative — shipping our own polynomial <code>exp</code> and <code>tanh</code> — was
considered and rejected. It would buy cross-engine reproducibility for a project where nobody
is comparing two browsers, at the cost of putting an approximation nobody can check between the
reader and the activation function they came here to understand.</p>
<h3>Float32 storage, Float64 accumulation</h3>
<p>Weights are stored as <code>Float32Array</code> and every intermediate is a plain JavaScript
number, which is a double. So a dot product accumulates at full double precision and rounds once
on store. This is deliberate and it is what the mainstream frameworks do; the alternative,
rounding after every multiply–add, would be both slower and less accurate.</p>
<div class="risk"><span class="lbl">Amendment · slice 2 · what "intermediate" means</span>
<p>This section always said every intermediate is a double, and slice 1 nonetheless stored
the per-layer activations in a <code>Float32Array</code> — reading invariant 3 as
"float32 everywhere" when it is specifically about <em>weights</em>, which are state and get
transferred. Activations are not state; nothing posts them anywhere.</p>
<p><strong>It cost more than it looks.</strong> Rounding twice per layer put roughly 1e-7 of
relative noise on the loss, which put a floor of <strong>2.5e-3</strong> under the slice-2
gradient check — small enough to look like a subtly wrong gradient rather than a
measurement limit, and the backward pass was in fact perfect. As doubles the same check
agrees to <strong>9.5e-10</strong>. Scratch and gradient buffers are
<code>Float64Array</code>; <code>W</code> and <code>b</code> stay <code>Float32Array</code>.</p>
</div>
<p>The consequence to remember: <code>W[i] += dw</code> on a <code>Float32Array</code> rounds.
Very small updates — a learning rate of 1e−7 against a weight near 1 — are silently lost,
because the increment falls below the ULP. That is a real effect, it is a good lesson, and
challenge 4 puts a reader in front of it with a diagnostic that names it rather than
leaving them to conclude the app is broken.</p>
<h3>Initialisation</h3>
<div class="tbl"><table>
<thead><tr><th>Scheme</th><th>Draw</th><th>Default for</th><th>Why it is offered</th></tr></thead>
<tbody>
<tr><td>He</td><td class="num">N(0, 2/n_in)</td><td>relu</td><td>keeps activation variance stable through depth</td></tr>
<tr><td>Glorot</td><td class="num">U(±√(6/(n_in+n_out)))</td><td>tanh, sigmoid</td><td>the standard for saturating units</td></tr>
<tr><td>Small random</td><td class="num">U(±0.05)</td><td>—</td><td>the naive choice; watch it fail with depth</td></tr>
<tr><td>Zeros</td><td class="num">0</td><td>—</td><td><span class="tag t-am">teaching</span> symmetry never breaks</td></tr>
</tbody>
</table></div>
<p style="margin-top:14px">Zeros is in the list on purpose. With every weight identical, every
hidden unit computes the same thing and receives the same gradient forever, and the network
graph shows it directly: every edge into row 2 is the same colour as every edge into row 5,
and stays that way. It is the fastest way to make "symmetry breaking" mean something.</p>
</div>
</section>
<!-- ==================== 5 ==================== -->
<section id="threads">
<div class="sechead"><p class="eyebrow">05</p><h2>Two threads, and what crosses between them</h2></div>
<div class="col stack">
<p>Training runs in one Web Worker. The main thread renders and never trains — not even one
step, not even while paused, because a training call on the main thread is a dropped frame and
the fixed-step invariant then has two implementations.</p>
<p>Evolab needed four workers because a genetic algorithm is embarrassingly parallel. Nothing
here is: gradient descent is a sequential chain, and a 2–16–16–2 network is
small enough that the interesting bottleneck is not arithmetic at all. One worker, and the
second core goes to the renderer.</p>
<pre>type ToWorker =
| { type: 'init'; setup: TrainSetup }
| { type: 'run'; untilStep: number }
| { type: 'pause' }
| { type: 'trace'; requestId: number; sampleIndex: number } <i>// one stepper frame</i>
| { type: 'probe'; requestId: number; res: number } <i>// decision field</i>
| { type: 'poke'; layer: number; index: number; value: number }
type FromWorker =
| { type: 'ready'; initMs: number }
| { type: 'step'; metrics: StepMetrics; weights: Float32Array; diag: Diagnostics }
| { type: 'trace'; requestId: number; frames: TraceFrame[] }
| { type: 'probe'; requestId: number; res: number; field: <b>Float32Array</b> }
| { type: 'paused'; step: number }
| { type: 'error'; message: string }</pre>
<h3>What gets copied and what gets transferred</h3>
<p>Evolab transferred everything, because its payload was a 576-cell archive that it could not
afford to clone. Here the arithmetic points the other way for one message and the same way for
the other, so the rule is stated per message rather than adopted wholesale:</p>
<ul class="list">
<li><strong>Weights are copied.</strong> A 2–16–16–2 network is 354 floats —
1.4 kB. Even a 64–128–128–10 network is 26 k floats, 106 kB. A
transfer would detach the worker's own buffer, so the worker would have to allocate a fresh
copy anyway; delta-encoding would cost more code than the copy costs bandwidth. The worker
posts the whole vector, and reports are <em>throttled to about 20 Hz</em> rather than sent
every step, because at 4000 steps/s nothing on screen can consume them.</li>
<li><strong>The decision field is transferred.</strong> At 128 × 128 it is
16 384 floats, and it is regenerated from scratch every time, so the worker has no use
for the buffer after posting. This is the one place the transfer list is worth the care.</li>
</ul>
<h3>The decision field is the expensive thing, not training</h3>
<p>This is the throughput result that shapes the whole UI, and it is worth showing the
arithmetic. Figures are a <em>budget</em>, not a measurement — nothing is built yet.</p>
<div class="tbl"><table>
<thead><tr><th>Work</th><th>Multiply–adds</th><th>Budget @ ~150 M MAC/s</th><th>Verdict</th></tr></thead>
<tbody>
<tr><td>One training step<br><span style="font-size:13px;color:var(--ink-3)">batch 16, 2–16–16–2</span></td><td class="num">16 × 320 × 3 ≈ 15 k</td><td class="num">0.1 ms</td><td><span class="tag t-ok">free</span></td></tr>
<tr><td>Decision field @ 64²</td><td class="num">4 096 × 320 ≈ 1.3 M</td><td class="num">~9 ms</td><td><span class="tag t-am">throttle</span></td></tr>
<tr><td>Decision field @ 128²</td><td class="num">16 384 × 320 ≈ 5.2 M</td><td class="num">~35 ms</td><td><span class="tag t-am">paused only</span></td></tr>
<tr><td>Decision field @ 256²</td><td class="num">65 536 × 320 ≈ 21 M</td><td class="num">~140 ms</td><td><span class="tag t-no">export only</span></td></tr>
<tr><td>SOM step, 12×12 lattice, dim 3</td><td class="num">144 × 3 × 2 ≈ 0.9 k</td><td class="num">< 0.01 ms</td><td><span class="tag t-ok">free</span></td></tr>
<tr><td>U-matrix, 12×12</td><td class="num">144 × 4 × 3 ≈ 1.7 k</td><td class="num">< 0.01 ms</td><td><span class="tag t-ok">every step</span></td></tr>
</tbody>
</table></div>
<p style="margin-top:14px">So: the field renders at 64² while training, on a
150 ms throttle, and re-renders at 128² the moment the run pauses. The
resolution is printed next to it, because a reader who notices the boundary getting crisper
when they hit pause deserves to know it is the drawing and not the network.</p>
<div class="risk" style="margin-top:16px"><span class="lbl">Amendment · slice 3 · measured</span>
<p><strong>The field is four to six times cheaper than the table above.</strong> Built and
timed in the browser: 64² costs <strong>2.6 ms</strong> against an estimated 9,
and 128² costs <strong>5.6 ms</strong> against an estimated 35. The 150 M
MAC/s figure was too pessimistic — V8 does considerably better on a loop this shape.</p>
<p><strong>The throughput cost is real but modest.</strong> 12 274 steps/s with the
field on against 15 083 without, on 2-8-8-2 — about 19%. So §13's "the field will fight
the training loop" happened, and did not need the escape hatch that section proposed.</p>
<p>256² is therefore viable on pause rather than export-only. The ladder is left at
64/128 because nothing yet needs the detail, and a resolution nobody asked for is 21 M
multiply–adds nobody asked for.</p>
</div>
<p>The SOM half needs no worker at all by this arithmetic. It gets one anyway, so that the
pause/step/trace protocol has exactly one implementation and the stepper does not become two
different mechanisms wearing the same button.</p>
<div class="risk" style="margin-bottom:16px"><span class="lbl">Amendment · slice 4 · built and measured</span>
<p><strong>Moving training off the main thread is worth 88% on the same run.</strong>
Spirals, 20 000 steps at 2-16-16-2: <strong>6 377 steps/s</strong> on the main thread in
slice 3 against <strong>11 960</strong> in the worker. Moons at the same shape reaches
28 500. The decision field costs what it always did, but it now costs the worker's time
rather than the renderer's, so nothing on screen stutters while it is drawn.</p>
<p><strong>Two message-ordering bugs, and both were races rather than logic.</strong> A
rebuild does not cancel messages already in flight: pressing Train during one sent
<code>run</code> against a session the page had discarded, and a report arriving after one
applied the previous network's weights to the rebuilt mirror. The second threw
<code>weight buffer is 114, expected 354</code> only because the architecture had changed
as well — a rebuild that changes only the dataset keeps the shape, and there it would
have applied cleanly and been silently wrong. <strong>Every session carries a generation
now, echoed on every message.</strong> This is the rule the stepper's trace has to follow
in slice 5.</p>
<p>The protocol table above is otherwise as built, including the copy/transfer split: weights
are copied both ways, and the field is the one payload that transfers.</p>
</div>
<div class="risk"><span class="lbl">Consequence worth stating</span>
<p>Reports at 20 Hz mean the loss chart is sampled, not complete — at 4000 steps/s it draws
one point in two hundred. <strong>Per-step loss is therefore accumulated in the worker and
posted as a min/mean/max band</strong>, not as a single sampled value. A chart that samples
a noisy series and draws it as a line is a chart that invents smoothness the run did not have,
and minibatch loss is very noisy.</p>
</div>
</div>
</section>
<!-- ==================== 6 ==================== -->
<section id="teaching">
<div class="sechead"><p class="eyebrow">06</p><h2>Teaching by instrument</h2></div>
<div class="col stack">
<p>Two stages of the same application, switched in the toolbar, nothing locked in either
direction. A stage decides which panels are present — <code>data-stage</code> on
<code><body></code>, <code>.guided-hide</code> and <code>.guided-only</code> in CSS — and
nothing else. Switching never restarts a run. A planned third stage, Lab, is retired below —
it never had content that needed to differ from Explorer.</p>
<div class="grid g2" style="max-width:none">
<div class="card"><h4>Guided</h4><p class="k">the first ten seconds</p>
<p><strong>One flow per network</strong>, four steps each, no hyperparameters at all. The
perceptron's: pick data, pick a shape, watch it learn, see what changed — ending by
replaying its very first guess against its last. The map's: pick data, watch a flat sheet
fold into it, see what it kept apart, label it.</p></div>
<div class="card"><h4>Explorer</h4><p class="k">the working surface</p>
<p>Full controls, the architecture editor and its parameter budget, the diagnostics
column, the decision field, the challenge track. This is where somebody spends an
hour — the only stage past Guided there is.</p></div>
</div>
<div class="risk" style="margin-top:16px"><span class="lbl">Decided · slice 8</span>
<p><strong>Two stages, not three. Lab is retired.</strong> The original three-card split
gave Lab the architecture editor, optimiser internals, gradient statistics, weight matrices
and throughput counters — everything that sounded like it needed its own workbench before
any of it existed. By slice 8 all of it had landed in Explorer instead (diagnostics in
slice 7, the parameter budget in slice 8), on the same reasoning each time: nothing about
the feature actually needed a separate stage to differ from Explorer, so gating one would
have meant inventing a distinction that did not exist. The toolbar button and the
<code>lab</code> value of <code>AppStage</code> are gone; a bookmarked <code>?stage=lab</code>
link still opens, downgraded to Explorer rather than rejected, the same defensive-URL rule
§12 states for every other query parameter.</p>
</div>
<div class="risk" style="margin-top:16px"><span class="lbl">Amendment · slice 10 · built and measured</span>
<p><strong>The Kohonen switch has a real Explorer behind it, built the same way Guided and
Explorer already were: two DOM trees in the same grid cells, gated by
<code>body[data-net]</code> instead of <code>body[data-stage]</code>, neither ever destroyed
by switching.</strong> The lattice draws hex or rect with nodes filled by their own weight
vector, a QE/TE chart, the U-matrix and component planes as <code>ImageData</code> blits —
the same technique §7's table already named for them — and controls for dataset, lattice
size, topology and schedule.</p>
<p><strong>Trained on the main thread, and measured before deciding that, not assumed.</strong>
A SOM step has no backward pass, and benchmarks at roughly 240 000 steps/s on a 12×12
map — about twenty times the MLP's own pre-worker throughput, and fast enough that a full
20 000-step run finishes in under 100 ms of raw compute. Nothing here would be bought
back by a worker; the visible pacing (a run organising over about two seconds regardless of
its step count) is a fixed 120-tick schedule, not a performance constraint.</p>
<p><strong>That decision re-triggered slice 3's exact bug, and running it live is what caught
it.</strong> The first version paced ticks with <code>requestAnimationFrame</code>, which does
not fire in a hidden tab — precisely the limitation slice 4's worker exists to escape for the
MLP, reintroduced here by choosing not to build one. Fixed by switching to
<code>setTimeout</code>, throttled rather than suspended in the background: a run paces
slower while nobody is watching and still finishes, rather than never finishing at all.</p>
<p>Two smaller bugs, both found the same way — running it rather than reading it back.
<strong>The "finished" render ran one step early</strong>: <code>running</code> was cleared
after the pump's own final render rather than before it, so the last frame of a completed run
painted with the Train button still reading "Pause". <strong>The lattice canvas was invisible
at its actual size</strong>: <code>resize()</code> measures a canvas's CSS box, and the rule
giving <code>#stage</code>/<code>#graph</code> <code>width/height: 100%</code> was scoped to
those two ids specifically, so a new canvas id defaulted to the browser's 300×150 and
drew correctly into a box a tenth the size of its panel. Neither is the kind of bug a test
catches; both were caught by looking at the screen.</p>
<p><strong>SOM datasets are mostly the MLP's own generators, reused unsupervised</strong> —
moons, circles, blobs, spirals and XOR all already produce a <code>Dataset</code>, and a SOM
simply never reads <code>y</code>, so five new generators were not needed to give the picker
real variety beside the colour cube. Weight-to-colour is literal at three dimensions and a
documented mid-tone stand-in below it; nothing in the current roster exceeds three, so a real
projection is deferred rather than built against no data that needs it yet.</p>
</div>
<h3>The stepper</h3>
<p>The teaching screen, and the single most important thing in the project. A full-screen
view that pauses the algorithm between operators and shows each one acting on real values.</p>
<p>It drives <code>trainStep(net, batch, { trace: {...} })</code> — the same function the worker
drains at full speed. A test asserts that traced and untraced runs produce bit-identical
weights, because the moment that stops being true the screen becomes a lie. This is Evolab's
rule, lifted intact, and it is the reason the trace flag lives inside the kernel rather than
in a parallel "explain" implementation.</p>
<div class="risk"><span class="lbl">Amendment · slice 5 · built and measured</span>
<p><strong>One trace covers a whole step, not one operator.</strong> Stepping through the
stages is a client-side cursor into a single <code>StepTrace</code> — forward through
every layer, backward through every layer, the update already applied — rather than a
re-run of the network one layer at a time. A new trace is only requested from the worker
when the reader pages past the <em>last</em> stage of the one on screen.</p>
<p><strong>The output layer's backward step has no stage of its own.</strong> Under softmax
+ cross-entropy the Jacobian cancels and <code>dz = a − onehot(target)</code> directly
— the one place in backprop where the chain rule does not appear as two visible
factors. So for 2-8-8-2 the count is <strong>seven</strong> stages, not the eight
"forward × 3, backward × 3, update" would suggest: sample, forward × 2,
output-and-loss (which folds in that fused backward step), backward × 2, update. The
screen says why, rather than leaving a reader hunting for a δ × a′ that
was never computed.</p>
<p>Verified live: the golden run reached its pinned 0.1007 / 0.9702 / 38 epochs after two
stepper steps had run at the start of the same session, and a test runs 200 traced steps
against 200 untraced from the same seed and checks every weight agrees exactly.</p>
</div>
<div class="grid g2" style="max-width:none">
<div class="card"><h4>MLP — seven stages</h4>
<p class="k">sample → forward × n → loss → backward × n → update</p>
<p>One sample at a time. Each forward stage shows the layer's weight row, the pre-activation
<code>z</code>, and the activation <code>a</code>. Each backward stage shows
<code>δ</code> arriving, the local derivative, and <code>δ</code> leaving. The
update stage shows <code>Δw</code> per weight with the learning rate applied and the
sign coloured.</p></div>
<div class="card"><h4>SOM — five stages</h4>
<p class="k">sample → distances → BMU → neighbourhood → update</p>
<p>Distances shown as the lattice heat-mapped by <code>‖x − w‖</code>.
BMU marked. The neighbourhood stage draws <code>h(d,t)</code> falling off across the lattice
with the current σ printed, and the update stage shows every node's movement as a
vector in input space, scaled by its own <code>h</code>.</p></div>
</div>
<div class="risk" style="margin-top:16px"><span class="lbl">Amendment · slice 11 · built and measured</span>
<p><strong>No worker round trip, because slice 10 already found there is nothing on the
other side of one.</strong> The MLP stepper requests a trace and waits for <code>onTrace</code>;
the SOM stepper calls <code>somStep(trainer, ds, { trace: true })</code> directly and reads
the result synchronously. <code>SomStepTrace</code> is built <em>inside</em>
<code>somStep</code> rather than by a sibling function, because the per-node loop the update
already runs is the same loop that has to visit every node to report a distance and a
strength — splitting it would either duplicate the loop or force an awkward second pass. A
test proves tracing changes nothing: 200 traced steps and 200 untraced steps from the same
seed land on bit-identical weights.</p>
<p><strong>Two pictures, not one, and neither alone is the algorithm.</strong> The lattice
view is built exactly as this section specifies. Beside it, an <strong>input-space
view</strong> — every node's weight vector as a point, connected to its lattice neighbours by
a line, the net the map actually is, drawn where the data lives rather than where the nodes
sit on screen. Built once, in <code>render/inputspace.ts</code>, rather than as a
stepper-only view: the guided flow's own step 2 ("watch a flat sheet fold into it") is the
identical drawing at a different moment, and building it twice was never on the table.</p>
</div>
<h3>The concept ladder</h3>
<p>Twelve cards in the left column, each naming the idea it teaches and each configuring the
app in one click. Nothing is locked; cards past the frontier are dimmed as guidance and stay
clickable, because a reader who already knows the material starts wherever they like.</p>
<div class="tbl"><table>
<thead><tr><th>#</th><th>Card</th><th>What it sets up</th><th>Concept</th></tr></thead>
<tbody>
<tr><td class="num">1</td><td>One line is not enough</td><td>no hidden layer, XOR</td><td>linear separability <span class="tag t-vi">amended</span></td></tr>