Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/probe_typstloop.mjs

10.4 KiB, 1 run

created by r2519314175:99, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// probe_typstloop.mjs — why the live view rebuilds with nothing edited, and what
2// the vendored packages cost the heap.
3//
4// Two reports to settle, on the author's own book:
5//
6// 1. the status bar cycles `Live` / `Rebuilding` every 1-2 s with nothing being
7// written, so something the loop reads is changing when nothing has;
8// 2. the compiler reached 2427 MB and the budget stopped the loop, against the
9// 191 MB dev/TYPST_WATCH.md measured for the same book.
10//
11// So: seed the book, arm the watch through the shipped path, then SIT STILL and
12// count rebuilds; and compile the same project over and over reading the heap after
13// each, with the cetz packages live and with them stubbed out, which is the
14// difference `7194f43` made.
15//
16// It asserts nothing and is not a verifier.
17//
18// eval "$(bash dev/world.sh 5 --env)"
19// node dev/probe_typstloop.mjs # packages live
20// node dev/probe_typstloop.mjs --stub # the two cetz imports removed
21// node dev/probe_typstloop.mjs --idle # the idle-rebuild count only
22import fs from 'node:fs';
23import path from 'node:path';
24import { open, scratch } from './harness.mjs';
25
26const BOOK = process.env.HOME + '/usr/books/elearnity';
27if (!fs.existsSync(BOOK)) { console.log('no book at ' + BOOK); process.exit(0); }
28const STUB = process.argv.includes('--stub');
29const IDLE = process.argv.includes('--idle');
30const REPS = (() => { const i = process.argv.indexOf('--reps');
31 return i > 0 ? parseInt(process.argv[i + 1], 10) : 5; })();
32const CEIL = 1800; // MB of compiler heap at which this stops, to spare the box
33
34function collect(dir, prefix, keep, depth) {
35 const out = [];
36 for (const name of fs.readdirSync(path.join(BOOK, dir))) {
37 if (/^(archive|revision|audit|evaluations|plan|dev|cover|ref|__pycache__)/i.test(name)) continue;
38 const abs = path.join(BOOK, dir, name);
39 const rel = prefix + '/' + name;
40 let st;
41 try { st = fs.statSync(abs); } catch (e) { continue; }
42 if (st.isDirectory()) {
43 if (depth > 0) out.push(...collect(dir + '/' + name, rel, keep, depth - 1));
44 continue;
45 }
46 if (!keep(name)) continue;
47 const buf = fs.readFileSync(abs);
48 out.push([rel, /\.(typ|svg|bib|csv|json|yaml|yml|toml|xml|txt)$/i.test(name)
49 ? buf.toString('utf8') : Array.from(buf)]);
50 }
51 return out;
52}
53const typOnly = (n) => /\.typ$/i.test(n);
54const seed = [
55 ...collect('CheapThinking', 'elearnity/CheapThinking', typOnly, 0),
56 ...collect('style', 'elearnity/style', typOnly, 2),
57 ...collect('assets/svg', 'elearnity/assets/svg', (n) => /\.svg$/i.test(n), 1),
58 ...collect('assets/thinking', 'elearnity/assets/thinking', (n) => /\.svg$/i.test(n), 1),
59 ...collect('assets/fonts', 'elearnity/assets/fonts', (n) => /\.(ttf|otf|ttc|otc)$/i.test(n), 2),
60 ['elearnity/terms.typ', fs.readFileSync(path.join(BOOK, 'terms.typ'), 'utf8')],
61];
62console.log(`seeding ${seed.length} files; packages ${STUB ? 'STUBBED OUT' : 'LIVE'}`);
63
64const MAIN = 'elearnity/CheapThinking/thinking.typ';
65const s = await open({ name: 'typstloop' });
66const p = s.page;
67await p.setViewportSize({ width: 1700, height: 1050 });
68await p.waitForTimeout(1500);
69await p.evaluate(async () => {
70 const m = await import('/pkg/oxedyne_daimond.js');
71 m.set_locked_packs('');
72 m.set_account_ns('d~typstloop');
73});
74for (let i = 0; i < seed.length; i += 20) {
75 await p.evaluate(async (files) => {
76 const m = await import('/pkg/oxedyne_daimond.js');
77 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true);
78 for (const f of files) {
79 if (typeof f[1] === 'string') await m.write_file(f[0], f[1]);
80 else await app.write_bytes(f[0], new Uint8Array(f[1]));
81 }
82 }, seed.slice(i, i + 20));
83}
84if (STUB) {
85 await p.evaluate(async () => {
86 const m = await import('/pkg/oxedyne_daimond.js');
87 let t = await m.read_file('elearnity/style/template/book_template.typ');
88 t = t.split('#import "@preview/cetz:0.3.4"').join('// cetz removed')
89 .split('#import "@preview/cetz-plot:0.1.1": plot, chart').join('// cetz-plot removed');
90 await m.write_file('elearnity/style/template/book_template.typ', t);
91 await m.write_file('elearnity/CheapThinking/thinking_chap_02_figures.typ',
92 '#let fig-ceilings = rect(width: 100%, height: 6cm)\n'
93 + '#let fig-thinking-time = rect(width: 100%, height: 6cm)\n');
94 });
95}
96const sleep = (ms) => new Promise(r => setTimeout(r, ms));
97
98// ── What one compile hands back, over and over ──────────────────────
99console.log('\n=== the same project, compiled repeatedly, nothing edited ===');
100let prevWatch = null;
101for (let i = 0; i < REPS; i++) {
102 const r = await p.evaluate(async (main) => {
103 const m = await import('/pkg/oxedyne_daimond.js');
104 const before = window.DaimondTypst.heapMB();
105 const t = performance.now();
106 const out = await m.typst_compile_project_vector(main);
107 const ms = performance.now() - t;
108 const stamps = out.watch ? Array.from(await m.typst_watch_stamps(Array.from(out.watch))) : [];
109 return { ms, before, after: window.DaimondTypst.heapMB(),
110 watch: out.watch ? Array.from(out.watch) : [],
111 stamps,
112 bytes: out.vector ? out.vector.length : 0,
113 error: out.error ? String(out.error).slice(0, 300) : '' };
114 }, MAIN);
115 const w = r.watch;
116 let drift = '';
117 if (prevWatch) {
118 if (prevWatch.length !== w.length) drift = ` WATCH LENGTH ${prevWatch.length} → ${w.length}`;
119 else {
120 const n = w.filter((x, k) => x !== prevWatch[k]).length;
121 if (n) drift = ` WATCH ORDER MOVED at ${n} of ${w.length} positions`;
122 }
123 }
124 prevWatch = w;
125 console.log(` ${i + 1}: ${r.ms.toFixed(0)} ms, heap ${r.before.toFixed(0)} → ${r.after.toFixed(0)} MB`
126 + ` (+${(r.after - r.before).toFixed(0)}), ${(r.bytes / 1048576).toFixed(1)} MB vector,`
127 + ` watch ${w.length}${drift}${r.error ? ' REFUSED: ' + r.error : ''}`);
128 if (r.error) break;
129 if (r.after > CEIL) { console.log(` stopping: past the ${CEIL} MB self-imposed ceiling`); break; }
130}
131
132// ── The stamps, asked twice with nothing between ────────────────────
133console.log('\n=== the watch stamps, asked twice, nothing written ===');
134const twice = await p.evaluate(async (main) => {
135 const m = await import('/pkg/oxedyne_daimond.js');
136 const out = await m.typst_compile_project_vector(main);
137 const files = Array.from(out.watch || []);
138 const a = Array.from(await m.typst_watch_stamps(files));
139 await new Promise(r => setTimeout(r, 1200));
140 const b = Array.from(await m.typst_watch_stamps(files));
141 const moved = [];
142 for (let i = 0; i < files.length; i++) if (a[i] !== b[i]) moved.push([files[i], a[i], b[i]]);
143 return { n: files.length, moved: moved.slice(0, 10), total: moved.length };
144}, MAIN);
145console.log(` ${twice.n} files; ${twice.total} changed with nothing written`);
146for (const [f, a, b] of twice.moved) console.log(` ${f}: ${a} → ${b}`);
147
148// ── What the heap costs per REAL edit, which is the wall's own slope ─
149//
150// A rebuild on unchanged bytes is free: comemo hashes the text and hands back the
151// layout it already has. An edit is not, and the heap NEVER SHRINKS, so the figure
152// below is the one that decides how many edits a session gets before the budget
153// stops it. 2427 MB divided by it is how long the author had.
154if (process.argv.includes('--edits')) {
155 console.log('\n=== the heap, one real edit at a time ===');
156 const CHAP = 'elearnity/CheapThinking/thinking_chap_07.typ';
157 for (let i = 0; i < 15; i++) {
158 const r = await p.evaluate(async ({ main, chap, word }) => {
159 const m = await import('/pkg/oxedyne_daimond.js');
160 const t = await m.read_file(chap);
161 await m.write_file(chap, t.replace(/\n([A-Z][a-z]+ )/, '\n$1' + word + ' '));
162 const before = window.DaimondTypst.heapMB();
163 const t0 = performance.now();
164 const out = await m.typst_compile_project_vector(main);
165 return { ms: performance.now() - t0, before, after: window.DaimondTypst.heapMB(),
166 error: out.error ? String(out.error).slice(0, 200) : '' };
167 }, { main: MAIN, chap: CHAP, word: 'probe' + i });
168 console.log(` edit ${i + 1}: ${r.ms.toFixed(0)} ms, heap ${r.before.toFixed(0)}`
169 + ` → ${r.after.toFixed(0)} MB (+${(r.after - r.before).toFixed(0)})`
170 + (r.error ? ' REFUSED: ' + r.error : ''));
171 if (r.after > CEIL) { console.log(' stopping: past the ceiling'); break; }
172 }
173}
174
175// ── The shipped loop, armed, and then left alone ────────────────────
176if (IDLE || true) {
177 console.log('\n=== the loop, armed through the button, then left alone ===');
178 await p.evaluate(() => window.DaimondDoc.show('elearnity/CheapThinking/thinking.typ'));
179 await p.waitForTimeout(1200);
180 const st = () => p.evaluate(() => window.DaimondTypstWatch.state());
181 await p.click('[data-act="compile"]', { force: true });
182 for (let i = 0; i < 240 && !(await st()).drawn; i++) await sleep(500);
183 const a = await st();
184 console.log(` armed: ${a.pages} pages, ${a.files} watched, heap ${a.heap.toFixed(0)} MB,`
185 + ` builds ${a.builds}, drawn ${a.drawn}, failed ${a.failed}`);
186 for (let i = 0; i < 8; i++) {
187 await sleep(2500);
188 const n = await st();
189 console.log(` +${((i + 1) * 2.5).toFixed(1)}s mode ${n.mode} building ${n.building}`
190 + ` builds ${n.builds} drawn ${n.drawn} failed ${n.failed}`
191 + ` heap ${n.heap.toFixed(0)} MB rheap ${n.rheap.toFixed(0)} MB`
192 + (n.error ? ' err: ' + n.error.slice(0, 90) : ''));
193 if (n.heap > CEIL) { console.log(' stopping: past the ceiling'); break; }
194 }
195}
196
197// ── And what the reader actually sees, on the pages that were wrong ──
198//
199// The three rendering faults were reported on the CONTENTS page and the copyright
200// page of this book, which is where a `#link` per line and three inline ones are.
201// So those are the pages photographed, rather than a fixture that has neither.
202{
203 const shots = process.argv.includes('--shot')
204 ? process.argv[process.argv.indexOf('--shot') + 1].split(',').map(Number)
205 : [2, 5];
206 const WORK = scratch('typstloop');
207 fs.mkdirSync(WORK, { recursive: true });
208 for (const n of shots) {
209 const r = await p.evaluate((n) => {
210 window.DaimondTypstWatch.goToPage(n);
211 const sc = document.querySelector('#typst-live .tl-scroll').getBoundingClientRect();
212 return { x: sc.x, y: sc.y, w: sc.width, h: sc.height };
213 }, n);
214 await sleep(900);
215 const f = `${WORK}/page-${n}.png`;
216 await p.screenshot({ path: f, clip: { x: Math.floor(r.x), y: Math.floor(r.y),
217 width: Math.floor(r.w), height: Math.floor(Math.min(r.h, 900)) } });
218 console.log(' wrote ' + f);
219 }
220}
221
222await p.evaluate(async () => {
223 const m = await import('/pkg/oxedyne_daimond.js');
224 m.set_account_ns('');
225});
226await s.close();