@0xbuff/throughline 0.0.0-stage → 0.0.2
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.
- package/CHANGELOG.md +26 -0
- package/COPYRIGHT.md +8 -0
- package/LICENSE +201 -0
- package/README.md +189 -2
- package/dist/describe-ZC7v_r7z.js +138 -0
- package/dist/describe-ZC7v_r7z.js.map +1 -0
- package/dist/examples.d.ts +17 -0
- package/dist/examples.js +70 -0
- package/dist/examples.js.map +1 -0
- package/dist/index.d.ts +68 -0
- package/dist/index.js +1134 -0
- package/dist/index.js.map +1 -0
- package/dist/model-Dwy20ibD.d.ts +97 -0
- package/dist/react/index.d.ts +22 -0
- package/dist/react/index.js +810 -0
- package/dist/react/index.js.map +1 -0
- package/package.json +93 -3
- package/src/styles/throughline.css +77 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1134 @@
|
|
|
1
|
+
import { a as getAt, c as setAt, o as overlaps, s as parseTarget, t as describeFrame } from "./describe-ZC7v_r7z.js";
|
|
2
|
+
// src/model.ts
|
|
3
|
+
const VIEWS = [
|
|
4
|
+
"auto",
|
|
5
|
+
"text",
|
|
6
|
+
"number",
|
|
7
|
+
"array",
|
|
8
|
+
"stack",
|
|
9
|
+
"matrix",
|
|
10
|
+
"bits",
|
|
11
|
+
"record"
|
|
12
|
+
];
|
|
13
|
+
const GROUP_LAYOUTS = [
|
|
14
|
+
"row",
|
|
15
|
+
"column",
|
|
16
|
+
"tree"
|
|
17
|
+
];
|
|
18
|
+
var ProgramError = class extends Error {
|
|
19
|
+
diagnostics;
|
|
20
|
+
constructor(diagnostics) {
|
|
21
|
+
super(diagnostics.map(({ message, line, beat }) => `${line ? `Line ${line}: ` : beat !== void 0 ? `Step ${beat + 1}: ` : ""}${message}`).join("\n"));
|
|
22
|
+
this.diagnostics = diagnostics;
|
|
23
|
+
this.name = "ProgramError";
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
// src/limits.ts
|
|
27
|
+
/** Fixed v1 safety ceilings, independent of machine speed or wall-clock timing. */
|
|
28
|
+
const COMPILATION_LIMITS = Object.freeze({
|
|
29
|
+
sourceCharacters: 262144,
|
|
30
|
+
programUnits: 1e6,
|
|
31
|
+
traceUnits: 8e6,
|
|
32
|
+
entities: 200,
|
|
33
|
+
relations: 1e3,
|
|
34
|
+
steps: 2e3,
|
|
35
|
+
events: 1e4
|
|
36
|
+
});
|
|
37
|
+
/**
|
|
38
|
+
* Account for expanded JSON, including repeated references. One unit per
|
|
39
|
+
* UTF-16 code unit in strings/keys, eight per scalar/container. This is a
|
|
40
|
+
* deterministic work/payload budget, not a claim about exact heap bytes.
|
|
41
|
+
* Memoization makes shared immutable snapshots cheap to measure without
|
|
42
|
+
* discounting their cost when serialized. Use a fresh budget for mutable data.
|
|
43
|
+
*/
|
|
44
|
+
function createBudget(name, limit) {
|
|
45
|
+
const sizes = /* @__PURE__ */ new WeakMap();
|
|
46
|
+
const visiting = /* @__PURE__ */ new WeakSet();
|
|
47
|
+
let total = 0;
|
|
48
|
+
const fail = (message) => {
|
|
49
|
+
throw new ProgramError([{ message: `${name}: ${message}` }]);
|
|
50
|
+
};
|
|
51
|
+
const check = (size) => {
|
|
52
|
+
if (size > limit) fail(`exceeds compilation limit (${limit} units).`);
|
|
53
|
+
return size;
|
|
54
|
+
};
|
|
55
|
+
const measure = (value, depth) => {
|
|
56
|
+
if (depth > 64) fail("JSON nesting exceeds the compilation limit.");
|
|
57
|
+
if (value === null || typeof value === "boolean") return 8;
|
|
58
|
+
if (typeof value === "string") return check(8 + value.length);
|
|
59
|
+
if (typeof value === "number" && Number.isFinite(value)) return 8;
|
|
60
|
+
if (typeof value !== "object" || value === null) return fail("expected finite JSON data.");
|
|
61
|
+
if (visiting.has(value)) fail("cyclic values are not JSON data.");
|
|
62
|
+
const cached = sizes.get(value);
|
|
63
|
+
if (cached !== void 0) return cached;
|
|
64
|
+
if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype) fail("expected plain JSON objects.");
|
|
65
|
+
visiting.add(value);
|
|
66
|
+
let size = 8;
|
|
67
|
+
if (Array.isArray(value)) for (const item of value) size = check(size + measure(item, depth + 1));
|
|
68
|
+
else for (const [key, item] of Object.entries(value)) if (item !== void 0) size = check(size + key.length + measure(item, depth + 1));
|
|
69
|
+
visiting.delete(value);
|
|
70
|
+
sizes.set(value, size);
|
|
71
|
+
return size;
|
|
72
|
+
};
|
|
73
|
+
return (value) => {
|
|
74
|
+
total = check(total + measure(value, 0));
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function checkSourceSize(characters) {
|
|
78
|
+
if (characters > COMPILATION_LIMITS.sourceCharacters) throw new ProgramError([{ message: `Source exceeds compilation limit (${COMPILATION_LIMITS.sourceCharacters} characters).` }]);
|
|
79
|
+
}
|
|
80
|
+
// src/compiler.ts
|
|
81
|
+
const owns = (object, key) => Object.hasOwn(object, key);
|
|
82
|
+
const isValue = (value, depth = 0) => {
|
|
83
|
+
if (depth > 32) return false;
|
|
84
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
|
85
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
86
|
+
if (Array.isArray(value)) return value.every((item) => isValue(item, depth + 1));
|
|
87
|
+
return typeof value === "object" && value !== null && Object.getPrototypeOf(value) === Object.prototype && Object.values(value).every((item) => isValue(item, depth + 1));
|
|
88
|
+
};
|
|
89
|
+
const record = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
90
|
+
const identifier = (value) => typeof value === "string" && /^[A-Za-z][\w-]*$/.test(value);
|
|
91
|
+
function validateProgram(input) {
|
|
92
|
+
if (!record(input) || input.version !== 1 || !Array.isArray(input.entities) || !Array.isArray(input.relations) || !Array.isArray(input.beats)) throw new ProgramError([{ message: "Expected version: 1 and entities, relations, beats arrays." }]);
|
|
93
|
+
if (input.entities.length > COMPILATION_LIMITS.entities || input.relations.length > COMPILATION_LIMITS.relations || input.beats.length > COMPILATION_LIMITS.steps) throw new ProgramError([{ message: "Program exceeds limits (200 entities, 1000 relations, 2000 steps)." }]);
|
|
94
|
+
let eventCount = 0;
|
|
95
|
+
for (const beat of input.beats) {
|
|
96
|
+
if (record(beat) && Array.isArray(beat.events)) eventCount += beat.events.length;
|
|
97
|
+
if (eventCount > COMPILATION_LIMITS.events) throw new ProgramError([{ message: `Program exceeds ${COMPILATION_LIMITS.events} events.` }]);
|
|
98
|
+
}
|
|
99
|
+
createBudget("Program", COMPILATION_LIMITS.programUnits)(input);
|
|
100
|
+
const diagnostics = [];
|
|
101
|
+
if (input.groups !== void 0) {
|
|
102
|
+
if (!Array.isArray(input.groups) || input.groups.length > 200 || input.groups.some((group) => !record(group) || typeof group.name !== "string" || !GROUP_LAYOUTS.includes(group.layout))) diagnostics.push({ message: "Groups require a name and a row, column, or tree layout (at most 200)." });
|
|
103
|
+
else if (new Set(input.groups.map((group) => group.name)).size !== input.groups.length) diagnostics.push({ message: "Duplicate group name." });
|
|
104
|
+
}
|
|
105
|
+
const entities = /* @__PURE__ */ new Set();
|
|
106
|
+
const relations = /* @__PURE__ */ new Set();
|
|
107
|
+
for (const entity of input.entities) {
|
|
108
|
+
if (!record(entity) || !identifier(entity.id) || typeof entity.label !== "string" || !owns(entity, "value") || !isValue(entity.value) || entity.hidden !== void 0 && typeof entity.hidden !== "boolean" || entity.lane !== void 0 && typeof entity.lane !== "string" || entity.view !== void 0 && !VIEWS.includes(entity.view)) {
|
|
109
|
+
diagnostics.push({ message: `Each entity needs a valid id, label, JSON value, optional string lane, and optional view (${VIEWS.join(", ")}).` });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (entities.has(entity.id)) diagnostics.push({ message: `Duplicate entity “${entity.id}”.` });
|
|
113
|
+
entities.add(entity.id);
|
|
114
|
+
}
|
|
115
|
+
for (const relation of input.relations) {
|
|
116
|
+
if (!record(relation) || !identifier(relation.id) || typeof relation.from !== "string" || typeof relation.to !== "string" || relation.hidden !== void 0 && typeof relation.hidden !== "boolean" || relation.label !== void 0 && typeof relation.label !== "string") {
|
|
117
|
+
diagnostics.push({ message: "Each relation needs an id, from, to, and optional string label." });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (relations.has(relation.id)) diagnostics.push({ message: `Duplicate relation “${relation.id}”.` });
|
|
121
|
+
relations.add(relation.id);
|
|
122
|
+
if (!entities.has(relation.from) || !entities.has(relation.to)) diagnostics.push({ message: `Relation “${relation.id}” references an unknown entity.` });
|
|
123
|
+
}
|
|
124
|
+
input.beats.forEach((beat, index) => {
|
|
125
|
+
if (!record(beat) || typeof beat.label !== "string" || !Array.isArray(beat.events)) {
|
|
126
|
+
diagnostics.push({
|
|
127
|
+
message: "Each step needs a label and events array.",
|
|
128
|
+
beat: index
|
|
129
|
+
});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const writes = [];
|
|
133
|
+
const visibility = /* @__PURE__ */ new Set();
|
|
134
|
+
for (const event of beat.events) {
|
|
135
|
+
const fail = (message) => diagnostics.push({
|
|
136
|
+
message,
|
|
137
|
+
beat: index,
|
|
138
|
+
line: record(event) && record(event.source) && Number.isInteger(event.source.line) ? Number(event.source.line) : void 0
|
|
139
|
+
});
|
|
140
|
+
if (!record(event)) {
|
|
141
|
+
fail("Expected an event object.");
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (event.op === "show" || event.op === "hide") {
|
|
145
|
+
const ids = event.kind === "node" ? entities : event.kind === "link" ? relations : null;
|
|
146
|
+
if (!ids || typeof event.id !== "string" || !ids.has(event.id)) fail(`Unknown ${String(event.kind)} “${String(event.id)}”.`);
|
|
147
|
+
const key = `${event.kind}:${event.id}`;
|
|
148
|
+
if (visibility.has(key)) fail(`Conflicting visibility for “${String(event.id)}” in one step.`);
|
|
149
|
+
visibility.add(key);
|
|
150
|
+
} else if (event.op === "set") {
|
|
151
|
+
const target = typeof event.target === "string" ? parseTarget(event.target) : null;
|
|
152
|
+
if (!target || !entities.has(target.id)) fail(`Unknown set target “${String(event.target)}”.`);
|
|
153
|
+
if (!owns(event, "value") || !isValue(event.value)) fail("set requires a finite JSON value.");
|
|
154
|
+
if (target) {
|
|
155
|
+
if (writes.some((write) => write.id === target.id && overlaps(write.path, target.path))) fail(`Conflicting writes to “${event.target}” in one step; split into separate steps.`);
|
|
156
|
+
writes.push(target);
|
|
157
|
+
}
|
|
158
|
+
} else if (event.op === "focus") {
|
|
159
|
+
if (!Array.isArray(event.targets) || event.targets.some((text) => {
|
|
160
|
+
const target = typeof text === "string" ? parseTarget(text) : null;
|
|
161
|
+
return !target || !entities.has(target.id);
|
|
162
|
+
})) fail("focus requires known entity ids, optionally with a position such as data[3].");
|
|
163
|
+
} else if (event.op === "send") {
|
|
164
|
+
if (typeof event.relation !== "string" || !relations.has(event.relation)) fail(`Unknown relation “${String(event.relation)}”.`);
|
|
165
|
+
if (owns(event, "value") && !isValue(event.value)) fail("send requires a finite JSON value.");
|
|
166
|
+
} else if (event.op === "note") {
|
|
167
|
+
if (typeof event.text !== "string") fail("note requires text.");
|
|
168
|
+
} else fail(`Unknown event operation “${String(event.op)}”.`);
|
|
169
|
+
if (event.source !== void 0 && (!record(event.source) || !Number.isInteger(event.source.line) || Number(event.source.line) < 1)) fail("Source line must be a positive integer.");
|
|
170
|
+
}
|
|
171
|
+
if (beat.source !== void 0 && (!record(beat.source) || !Number.isInteger(beat.source.line) || Number(beat.source.line) < 1)) diagnostics.push({
|
|
172
|
+
message: "Step source line must be a positive integer.",
|
|
173
|
+
beat: index
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
if (diagnostics.length) throw new ProgramError(diagnostics);
|
|
177
|
+
}
|
|
178
|
+
function reduceBeat(previous, events) {
|
|
179
|
+
const writes = /* @__PURE__ */ new Map();
|
|
180
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
181
|
+
const links = /* @__PURE__ */ new Map();
|
|
182
|
+
for (const event of events) {
|
|
183
|
+
if (event.op === "show" || event.op === "hide") (event.kind === "node" ? nodes : links).set(event.id, event.op === "hide");
|
|
184
|
+
if (event.op !== "set") continue;
|
|
185
|
+
const target = parseTarget(event.target);
|
|
186
|
+
if (!target) throw new Error(`Invalid target “${event.target}”.`);
|
|
187
|
+
writes.set(target.id, [...writes.get(target.id) ?? [], {
|
|
188
|
+
path: target.path,
|
|
189
|
+
value: event.value
|
|
190
|
+
}]);
|
|
191
|
+
}
|
|
192
|
+
const state = {
|
|
193
|
+
entities: previous.entities.map((entity) => {
|
|
194
|
+
const list = writes.get(entity.id);
|
|
195
|
+
return list || nodes.has(entity.id) ? {
|
|
196
|
+
...entity,
|
|
197
|
+
...nodes.has(entity.id) ? { hidden: nodes.get(entity.id) } : {},
|
|
198
|
+
value: list ? list.reduce((value, write) => setAt(value, write.path, write.value), entity.value) : entity.value
|
|
199
|
+
} : entity;
|
|
200
|
+
}),
|
|
201
|
+
relations: previous.relations.map((relation) => links.has(relation.id) ? {
|
|
202
|
+
...relation,
|
|
203
|
+
hidden: links.get(relation.id)
|
|
204
|
+
} : relation),
|
|
205
|
+
active: [...new Set(events.flatMap((event) => event.op === "focus" ? event.targets : event.op === "set" ? [event.target] : []))],
|
|
206
|
+
changed: events.flatMap((event) => event.op === "set" ? [event.target] : []),
|
|
207
|
+
transfers: events.flatMap((event) => event.op === "send" ? [{
|
|
208
|
+
relation: event.relation,
|
|
209
|
+
value: event.value
|
|
210
|
+
}] : []),
|
|
211
|
+
notes: events.flatMap((event) => event.op === "note" ? [event.text] : [])
|
|
212
|
+
};
|
|
213
|
+
for (const transfer of state.transfers) {
|
|
214
|
+
const relation = state.relations.find((candidate) => candidate.id === transfer.relation);
|
|
215
|
+
if (relation.hidden) throw new Error(`Cannot send along hidden link “${relation.id}”.`);
|
|
216
|
+
if (state.entities.some((entity) => entity.hidden && (entity.id === relation.from || entity.id === relation.to))) throw new Error(`Cannot send along “${relation.id}” with a hidden node.`);
|
|
217
|
+
}
|
|
218
|
+
return state;
|
|
219
|
+
}
|
|
220
|
+
function compileProgram(input) {
|
|
221
|
+
validateProgram(input);
|
|
222
|
+
const program = JSON.parse(JSON.stringify(input));
|
|
223
|
+
let state = {
|
|
224
|
+
entities: program.entities,
|
|
225
|
+
relations: program.relations,
|
|
226
|
+
active: [],
|
|
227
|
+
changed: [],
|
|
228
|
+
transfers: [],
|
|
229
|
+
notes: []
|
|
230
|
+
};
|
|
231
|
+
const frames = [{
|
|
232
|
+
index: 0,
|
|
233
|
+
label: "Initial state",
|
|
234
|
+
state,
|
|
235
|
+
events: []
|
|
236
|
+
}];
|
|
237
|
+
const account = createBudget("Trace", COMPILATION_LIMITS.traceUnits);
|
|
238
|
+
account({
|
|
239
|
+
version: 1,
|
|
240
|
+
groups: program.groups,
|
|
241
|
+
frames
|
|
242
|
+
});
|
|
243
|
+
program.beats.forEach((beat, index) => {
|
|
244
|
+
try {
|
|
245
|
+
state = reduceBeat(state, beat.events);
|
|
246
|
+
const frame = {
|
|
247
|
+
index: index + 1,
|
|
248
|
+
label: beat.label,
|
|
249
|
+
state,
|
|
250
|
+
events: beat.events,
|
|
251
|
+
source: beat.source
|
|
252
|
+
};
|
|
253
|
+
account(frame);
|
|
254
|
+
frames.push(frame);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
throw new ProgramError([{
|
|
257
|
+
message: error instanceof Error ? error.message : String(error),
|
|
258
|
+
beat: index,
|
|
259
|
+
line: beat.source?.line
|
|
260
|
+
}]);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
return {
|
|
264
|
+
version: 1,
|
|
265
|
+
frames,
|
|
266
|
+
...program.groups ? { groups: program.groups } : {}
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
// src/domains/lines.ts
|
|
270
|
+
function sourceLine(ast, index = 0) {
|
|
271
|
+
return (ast.source?.line ?? 1) + index + 1;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Non-empty, non-comment fence lines with fence line numbers (header is
|
|
275
|
+
* line 1, first body line is line 2). Blank lines are retained by the parser so diagnostics refer to the
|
|
276
|
+
* original source in every domain.
|
|
277
|
+
*/
|
|
278
|
+
function sourceLines(ast) {
|
|
279
|
+
if (ast.source && (!Number.isSafeInteger(ast.source.line) || ast.source.line < 1)) throw new ProgramError([{ message: "Source line must be a positive safe integer." }]);
|
|
280
|
+
checkSourceSize(ast.lines.reduce((size, line) => size + line.length + 1, ast.kind.length + 4));
|
|
281
|
+
const out = [];
|
|
282
|
+
ast.lines.forEach((raw, index) => {
|
|
283
|
+
const text = raw.trim();
|
|
284
|
+
if (!text || text.startsWith("#")) return;
|
|
285
|
+
out.push({
|
|
286
|
+
text,
|
|
287
|
+
line: sourceLine(ast, index)
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
// src/authoring.ts
|
|
293
|
+
function parseProgram(ast) {
|
|
294
|
+
const program = {
|
|
295
|
+
version: 1,
|
|
296
|
+
entities: [],
|
|
297
|
+
relations: [],
|
|
298
|
+
beats: []
|
|
299
|
+
};
|
|
300
|
+
let beat;
|
|
301
|
+
sourceLines(ast).forEach(({ text, line }) => {
|
|
302
|
+
const source = { line };
|
|
303
|
+
const fail = (message) => {
|
|
304
|
+
throw new ProgramError([{
|
|
305
|
+
message,
|
|
306
|
+
line
|
|
307
|
+
}]);
|
|
308
|
+
};
|
|
309
|
+
const json = (value) => {
|
|
310
|
+
try {
|
|
311
|
+
return JSON.parse(value);
|
|
312
|
+
} catch {
|
|
313
|
+
return fail("Expected a JSON value (quote strings; arrays and objects are supported).");
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
const quoted = (value) => {
|
|
317
|
+
const parsed = json(value);
|
|
318
|
+
return typeof parsed === "string" ? parsed : fail("Expected a quoted label.");
|
|
319
|
+
};
|
|
320
|
+
const group = text.match(/^group\s+("(?:[^"\\]|\\.)*")\s+as\s+([a-z]+)$/);
|
|
321
|
+
if (group) {
|
|
322
|
+
if (beat) fail("Declare groups before the first step.");
|
|
323
|
+
if (!GROUP_LAYOUTS.includes(group[2])) fail(`Unknown group layout “${group[2]}”. Use ${GROUP_LAYOUTS.join(", ")}.`);
|
|
324
|
+
const name = quoted(group[1]);
|
|
325
|
+
program.groups ??= [];
|
|
326
|
+
if (program.groups.some((candidate) => candidate.name === name)) fail(`Duplicate group “${name}”.`);
|
|
327
|
+
program.groups.push({
|
|
328
|
+
name,
|
|
329
|
+
layout: group[2]
|
|
330
|
+
});
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const node = text.match(/^node\s+([\w-]+)\s+("(?:[^"\\]|\\.)*")(?:\s+in\s+("(?:[^"\\]|\\.)*"))?(?:\s+as\s+([a-z]+))?(\s+hidden)?(?:\s*=\s*(.+))?$/);
|
|
334
|
+
if (node) {
|
|
335
|
+
if (beat) fail("Declare nodes and links before the first step.");
|
|
336
|
+
if (node[4] && !VIEWS.includes(node[4])) fail(`Unknown view “${node[4]}”. Use ${VIEWS.join(", ")}.`);
|
|
337
|
+
program.entities.push({
|
|
338
|
+
id: node[1],
|
|
339
|
+
label: quoted(node[2]),
|
|
340
|
+
lane: node[3] ? quoted(node[3]) : void 0,
|
|
341
|
+
...node[4] ? { view: node[4] } : {},
|
|
342
|
+
...node[5] ? { hidden: true } : {},
|
|
343
|
+
value: node[6] ? json(node[6]) : null
|
|
344
|
+
});
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const link = text.match(/^link\s+([\w-]+)\s+([\w-]+)\s*->\s*([\w-]+)(?:\s+("(?:[^"\\]|\\.)*"))?(\s+hidden)?$/);
|
|
348
|
+
if (link) {
|
|
349
|
+
if (beat) fail("Declare nodes and links before the first step.");
|
|
350
|
+
program.relations.push({
|
|
351
|
+
id: link[1],
|
|
352
|
+
from: link[2],
|
|
353
|
+
to: link[3],
|
|
354
|
+
label: link[4] ? quoted(link[4]) : void 0,
|
|
355
|
+
...link[5] ? { hidden: true } : {}
|
|
356
|
+
});
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const step = text.match(/^step\s+("(?:[^"\\]|\\.)*")$/);
|
|
360
|
+
if (step) {
|
|
361
|
+
beat = {
|
|
362
|
+
label: quoted(step[1]),
|
|
363
|
+
events: [],
|
|
364
|
+
source
|
|
365
|
+
};
|
|
366
|
+
program.beats.push(beat);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (!beat) fail("Expected node, link, or step. Events must follow a step.");
|
|
370
|
+
const set = text.match(/^set\s+([^\s=]+)\s*=\s*(.+)$/);
|
|
371
|
+
const send = text.match(/^send\s+([\w-]+)(?:\s*=\s*(.+))?$/);
|
|
372
|
+
const focus = text.match(/^focus(?:\s+(.+))?$/);
|
|
373
|
+
const note = text.match(/^note\s+("(?:[^"\\]|\\.)*")$/);
|
|
374
|
+
const visibility = text.match(/^(show|hide)\s+(node|link)\s+([\w-]+)$/);
|
|
375
|
+
if (visibility) beat?.events.push({
|
|
376
|
+
op: visibility[1],
|
|
377
|
+
kind: visibility[2],
|
|
378
|
+
id: visibility[3],
|
|
379
|
+
source
|
|
380
|
+
});
|
|
381
|
+
else if (set) {
|
|
382
|
+
if (!parseTarget(set[1])) fail(`Invalid target “${set[1]}”. Use an id, optionally followed by [index] or .key.`);
|
|
383
|
+
beat?.events.push({
|
|
384
|
+
op: "set",
|
|
385
|
+
target: set[1],
|
|
386
|
+
value: json(set[2]),
|
|
387
|
+
source
|
|
388
|
+
});
|
|
389
|
+
} else if (send) beat?.events.push({
|
|
390
|
+
op: "send",
|
|
391
|
+
relation: send[1],
|
|
392
|
+
...send[2] ? { value: json(send[2]) } : {},
|
|
393
|
+
source
|
|
394
|
+
});
|
|
395
|
+
else if (focus) {
|
|
396
|
+
const targets = (focus[1] ?? "").split(/[\s,]+/).filter(Boolean);
|
|
397
|
+
const bad = targets.find((target) => !parseTarget(target));
|
|
398
|
+
if (bad) fail(`Invalid target “${bad}”. Use an id, optionally followed by [index] or .key.`);
|
|
399
|
+
beat?.events.push({
|
|
400
|
+
op: "focus",
|
|
401
|
+
targets,
|
|
402
|
+
source
|
|
403
|
+
});
|
|
404
|
+
} else if (note) beat?.events.push({
|
|
405
|
+
op: "note",
|
|
406
|
+
text: quoted(note[1]),
|
|
407
|
+
source
|
|
408
|
+
});
|
|
409
|
+
else fail("Unknown statement. Use node, link, step, set, focus, send, note, show, or hide.");
|
|
410
|
+
});
|
|
411
|
+
validateProgram(program);
|
|
412
|
+
return program;
|
|
413
|
+
}
|
|
414
|
+
// src/dsl.ts
|
|
415
|
+
/**
|
|
416
|
+
* Parse one thl block, optionally fenced with backticks or tildes. Fence
|
|
417
|
+
* lengths/indentation follow CommonMark; document/container parsing belongs
|
|
418
|
+
* to the host's Markdown parser, not a second parser inside the engine.
|
|
419
|
+
*/
|
|
420
|
+
function parseThl(src) {
|
|
421
|
+
checkSourceSize(src.length);
|
|
422
|
+
const lines = src.replace(/^\uFEFF/, "").split(/\r\n?|\n/);
|
|
423
|
+
const start = lines.findIndex((line) => line.trim() !== "");
|
|
424
|
+
if (start < 0) return null;
|
|
425
|
+
const first = lines[start];
|
|
426
|
+
const fence = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(first);
|
|
427
|
+
const header = (fence ? fence[2] : first).trim().split(/\s+/);
|
|
428
|
+
if (header[0] !== "thl") return null;
|
|
429
|
+
const line = start + 1;
|
|
430
|
+
if (header.length !== 2) throw new ProgramError([{
|
|
431
|
+
message: "Expected header: thl <domain>, without extra options.",
|
|
432
|
+
line
|
|
433
|
+
}]);
|
|
434
|
+
let end = lines.length;
|
|
435
|
+
if (fence) {
|
|
436
|
+
const marker = fence[1];
|
|
437
|
+
for (let index = start + 1; index < lines.length; index++) {
|
|
438
|
+
const close = /^ {0,3}(`{3,}|~{3,})[\t ]*$/.exec(lines[index]);
|
|
439
|
+
if (close?.[1][0] === marker[0] && close[1].length >= marker.length) {
|
|
440
|
+
end = index;
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const trailing = lines.findIndex((text, index) => index > end && text.trim() !== "");
|
|
445
|
+
if (trailing >= 0) throw new ProgramError([{
|
|
446
|
+
message: "Expected one Throughline block; found content after its closing fence.",
|
|
447
|
+
line: trailing + 1
|
|
448
|
+
}]);
|
|
449
|
+
}
|
|
450
|
+
return {
|
|
451
|
+
kind: header[1],
|
|
452
|
+
lines: lines.slice(start + 1, end),
|
|
453
|
+
source: { line }
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
// src/tracer.ts
|
|
457
|
+
/** Detach caller-owned data before it becomes part of the recording. */
|
|
458
|
+
function captureData(value) {
|
|
459
|
+
createBudget("Program", COMPILATION_LIMITS.programUnits)(value);
|
|
460
|
+
return JSON.parse(JSON.stringify(value));
|
|
461
|
+
}
|
|
462
|
+
function checkCount(name, count, maximum) {
|
|
463
|
+
if (count > maximum) throw new ProgramError([{ message: `Program exceeds ${maximum} ${name}.` }]);
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Computation adapter: run any ordinary algorithm and narrate it.
|
|
467
|
+
* Values written in a step become visible together, exactly like authored steps.
|
|
468
|
+
*/
|
|
469
|
+
function tracer() {
|
|
470
|
+
const program = {
|
|
471
|
+
version: 1,
|
|
472
|
+
entities: [],
|
|
473
|
+
relations: [],
|
|
474
|
+
beats: []
|
|
475
|
+
};
|
|
476
|
+
let live = /* @__PURE__ */ new Map();
|
|
477
|
+
let eventCount = 0;
|
|
478
|
+
let recording = false;
|
|
479
|
+
const account = createBudget("Program", COMPILATION_LIMITS.programUnits);
|
|
480
|
+
const assertIdle = () => {
|
|
481
|
+
if (recording) throw new Error("Use the step writer while a step is open.");
|
|
482
|
+
};
|
|
483
|
+
const api = {
|
|
484
|
+
group(name, layout) {
|
|
485
|
+
assertIdle();
|
|
486
|
+
checkCount("groups", (program.groups?.length ?? 0) + 1, COMPILATION_LIMITS.entities);
|
|
487
|
+
const group = {
|
|
488
|
+
name,
|
|
489
|
+
layout
|
|
490
|
+
};
|
|
491
|
+
account(group);
|
|
492
|
+
program.groups ??= [];
|
|
493
|
+
program.groups.push(group);
|
|
494
|
+
return api;
|
|
495
|
+
},
|
|
496
|
+
node(id, label, value, options = {}) {
|
|
497
|
+
assertIdle();
|
|
498
|
+
checkCount("entities", program.entities.length + 1, COMPILATION_LIMITS.entities);
|
|
499
|
+
const entity = {
|
|
500
|
+
id,
|
|
501
|
+
label,
|
|
502
|
+
value: captureData(value),
|
|
503
|
+
...options.lane ? { lane: options.lane } : {},
|
|
504
|
+
...options.view ? { view: options.view } : {},
|
|
505
|
+
...options.hidden ? { hidden: true } : {}
|
|
506
|
+
};
|
|
507
|
+
account(entity);
|
|
508
|
+
program.entities.push(entity);
|
|
509
|
+
live.set(id, entity.value);
|
|
510
|
+
return api;
|
|
511
|
+
},
|
|
512
|
+
link(id, from, to, label, options = {}) {
|
|
513
|
+
assertIdle();
|
|
514
|
+
checkCount("relations", program.relations.length + 1, COMPILATION_LIMITS.relations);
|
|
515
|
+
const relation = {
|
|
516
|
+
id,
|
|
517
|
+
from,
|
|
518
|
+
to,
|
|
519
|
+
...label ? { label } : {},
|
|
520
|
+
...options.hidden ? { hidden: true } : {}
|
|
521
|
+
};
|
|
522
|
+
account(relation);
|
|
523
|
+
program.relations.push(relation);
|
|
524
|
+
return api;
|
|
525
|
+
},
|
|
526
|
+
/** Current value of a target as of the previous completed step. */
|
|
527
|
+
get(target) {
|
|
528
|
+
const parsed = parseTarget(target);
|
|
529
|
+
const value = parsed ? getAt(live.get(parsed.id), parsed.path) : void 0;
|
|
530
|
+
return value === void 0 ? void 0 : captureData(value);
|
|
531
|
+
},
|
|
532
|
+
step(label, body) {
|
|
533
|
+
assertIdle();
|
|
534
|
+
checkCount("steps", program.beats.length + 1, COMPILATION_LIMITS.steps);
|
|
535
|
+
const events = [];
|
|
536
|
+
const stepBudget = createBudget("Program step", COMPILATION_LIMITS.programUnits);
|
|
537
|
+
stepBudget({ label });
|
|
538
|
+
let open = true;
|
|
539
|
+
const append = (event) => {
|
|
540
|
+
if (!open) throw new Error("This step writer is closed.");
|
|
541
|
+
checkCount("events", eventCount + events.length + 1, COMPILATION_LIMITS.events);
|
|
542
|
+
stepBudget(event);
|
|
543
|
+
events.push(event);
|
|
544
|
+
};
|
|
545
|
+
recording = true;
|
|
546
|
+
try {
|
|
547
|
+
const result = body({
|
|
548
|
+
set: (target, value) => append({
|
|
549
|
+
op: "set",
|
|
550
|
+
target,
|
|
551
|
+
value: captureData(value)
|
|
552
|
+
}),
|
|
553
|
+
focus: (...targets) => append({
|
|
554
|
+
op: "focus",
|
|
555
|
+
targets
|
|
556
|
+
}),
|
|
557
|
+
send: (relation, value) => append({
|
|
558
|
+
op: "send",
|
|
559
|
+
relation,
|
|
560
|
+
...value === void 0 ? {} : { value: captureData(value) }
|
|
561
|
+
}),
|
|
562
|
+
note: (text) => append({
|
|
563
|
+
op: "note",
|
|
564
|
+
text
|
|
565
|
+
}),
|
|
566
|
+
show: (kind, id) => append({
|
|
567
|
+
op: "show",
|
|
568
|
+
kind,
|
|
569
|
+
id
|
|
570
|
+
}),
|
|
571
|
+
hide: (kind, id) => append({
|
|
572
|
+
op: "hide",
|
|
573
|
+
kind,
|
|
574
|
+
id
|
|
575
|
+
})
|
|
576
|
+
});
|
|
577
|
+
if (result !== null && typeof result === "object" && "then" in result && typeof result.then === "function") {
|
|
578
|
+
Promise.resolve(result).catch(() => {});
|
|
579
|
+
throw new Error("Step callbacks must be synchronous.");
|
|
580
|
+
}
|
|
581
|
+
} finally {
|
|
582
|
+
open = false;
|
|
583
|
+
recording = false;
|
|
584
|
+
}
|
|
585
|
+
const beat = {
|
|
586
|
+
label,
|
|
587
|
+
events
|
|
588
|
+
};
|
|
589
|
+
const pending = new Map(live);
|
|
590
|
+
for (const event of events) {
|
|
591
|
+
if (event.op !== "set") continue;
|
|
592
|
+
const parsed = parseTarget(event.target);
|
|
593
|
+
if (parsed && live.has(parsed.id)) pending.set(parsed.id, setAt(pending.get(parsed.id), parsed.path, event.value));
|
|
594
|
+
}
|
|
595
|
+
account(beat);
|
|
596
|
+
program.beats.push(beat);
|
|
597
|
+
live = pending;
|
|
598
|
+
eventCount += events.length;
|
|
599
|
+
return api;
|
|
600
|
+
},
|
|
601
|
+
program() {
|
|
602
|
+
return captureData(program);
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
return api;
|
|
606
|
+
}
|
|
607
|
+
// src/domains/binary.ts
|
|
608
|
+
const BITS = /^[01]{1,16}$/;
|
|
609
|
+
function parseBinary(ast) {
|
|
610
|
+
const found = /* @__PURE__ */ new Map();
|
|
611
|
+
for (const { text, line } of sourceLines(ast)) {
|
|
612
|
+
const entry = text.match(/^(a|b)\s*=\s*([01]+)\s*$/);
|
|
613
|
+
if (!entry?.[1] || !entry[2]) throw new ProgramError([{
|
|
614
|
+
message: "Expected a = <bits> or b = <bits>.",
|
|
615
|
+
line
|
|
616
|
+
}]);
|
|
617
|
+
const key = entry[1];
|
|
618
|
+
const digits = entry[2];
|
|
619
|
+
if (!BITS.test(digits)) throw new ProgramError([{
|
|
620
|
+
message: "Operands are 1–16 binary digits.",
|
|
621
|
+
line
|
|
622
|
+
}]);
|
|
623
|
+
if (found.has(key)) throw new ProgramError([{
|
|
624
|
+
message: `Duplicate operand "${key}".`,
|
|
625
|
+
line
|
|
626
|
+
}]);
|
|
627
|
+
found.set(key, digits);
|
|
628
|
+
}
|
|
629
|
+
const a = found.get("a");
|
|
630
|
+
const b = found.get("b");
|
|
631
|
+
if (!a || !b) throw new ProgramError([{
|
|
632
|
+
message: "Binary needs both a = <bits> and b = <bits>.",
|
|
633
|
+
line: sourceLine(ast)
|
|
634
|
+
}]);
|
|
635
|
+
return {
|
|
636
|
+
a,
|
|
637
|
+
b
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
function buildBinary({ a, b }) {
|
|
641
|
+
const t = tracer();
|
|
642
|
+
const n = Math.max(a.length, b.length);
|
|
643
|
+
const A = a.padStart(n, "0");
|
|
644
|
+
const B = b.padStart(n, "0");
|
|
645
|
+
t.node("a", "A", Number.parseInt(A, 2), {
|
|
646
|
+
lane: "Binary",
|
|
647
|
+
view: "bits"
|
|
648
|
+
});
|
|
649
|
+
t.node("b", "B", Number.parseInt(B, 2), {
|
|
650
|
+
lane: "Binary",
|
|
651
|
+
view: "bits"
|
|
652
|
+
});
|
|
653
|
+
t.node("sum", "Sum", Array(n + 1).fill("·"), {
|
|
654
|
+
lane: "Binary",
|
|
655
|
+
view: "array"
|
|
656
|
+
});
|
|
657
|
+
t.node("carry", "Carry", 0, {
|
|
658
|
+
lane: "Binary",
|
|
659
|
+
view: "number"
|
|
660
|
+
});
|
|
661
|
+
let carry = 0;
|
|
662
|
+
const sums = [];
|
|
663
|
+
for (let r = 0; r < n; r++) {
|
|
664
|
+
const x = Number(A[n - 1 - r]);
|
|
665
|
+
const y = Number(B[n - 1 - r]);
|
|
666
|
+
const total = x + y + carry;
|
|
667
|
+
const cin = carry;
|
|
668
|
+
const bit = total & 1;
|
|
669
|
+
carry = total >> 1;
|
|
670
|
+
sums.unshift(bit);
|
|
671
|
+
t.step(`bit ${r}`, (s) => {
|
|
672
|
+
s.focus(`a[${r}]`, `b[${r}]`, `sum[${n - r}]`, "carry");
|
|
673
|
+
s.set(`sum[${n - r}]`, bit);
|
|
674
|
+
s.set("carry", carry);
|
|
675
|
+
s.note(`bit ${r}: ${x} + ${y} + carry ${cin} = ${total}, so write ${bit} and carry ${carry}`);
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
const carryOut = carry;
|
|
679
|
+
const result = `${carryOut}${sums.join("")}`;
|
|
680
|
+
t.step("Result", (s) => {
|
|
681
|
+
s.focus("sum", "carry");
|
|
682
|
+
s.set("sum[0]", carryOut);
|
|
683
|
+
s.note(carryOut ? `The last carry becomes the top bit: ${A} + ${B} = ${result}` : `No final carry: ${A} + ${B} = ${result}`);
|
|
684
|
+
});
|
|
685
|
+
return t.program();
|
|
686
|
+
}
|
|
687
|
+
// src/domains/bindings.ts
|
|
688
|
+
const NAME = "[A-Za-z][\\w-]*";
|
|
689
|
+
const isHeapValue = (value) => Array.isArray(value) || typeof value === "object" && value !== null;
|
|
690
|
+
const show = (value) => JSON.stringify(value) ?? "null";
|
|
691
|
+
function parseBindings(ast) {
|
|
692
|
+
const ops = [];
|
|
693
|
+
const declared = /* @__PURE__ */ new Set();
|
|
694
|
+
const lines = sourceLines(ast);
|
|
695
|
+
if (lines.length > COMPILATION_LIMITS.steps) throw new ProgramError([{
|
|
696
|
+
message: `Bindings exceed ${COMPILATION_LIMITS.steps} steps.`,
|
|
697
|
+
line: lines[COMPILATION_LIMITS.steps].line
|
|
698
|
+
}]);
|
|
699
|
+
for (const { text, line } of lines) {
|
|
700
|
+
const fail = (message) => {
|
|
701
|
+
throw new ProgramError([{
|
|
702
|
+
message,
|
|
703
|
+
line
|
|
704
|
+
}]);
|
|
705
|
+
};
|
|
706
|
+
const json = (source) => {
|
|
707
|
+
try {
|
|
708
|
+
return JSON.parse(source);
|
|
709
|
+
} catch {
|
|
710
|
+
return fail("Expected a JSON value (quote strings).");
|
|
711
|
+
}
|
|
712
|
+
};
|
|
713
|
+
const bare = (source) => new RegExp(`^${NAME}$`).test(source) && !/^"(?:[^"\\]|\\.)*"$/.test(source) ? source : null;
|
|
714
|
+
const declare = text.match(new RegExp(`^(?:let|const)\\s+(${NAME})\\s*=\\s*(.+)$`));
|
|
715
|
+
if (declare) {
|
|
716
|
+
const name = declare[1] ?? "";
|
|
717
|
+
const rhs = (declare[2] ?? "").trim();
|
|
718
|
+
if (declared.has(name)) fail(`Duplicate variable "${name}".`);
|
|
719
|
+
const ref = bare(rhs);
|
|
720
|
+
if (ref) {
|
|
721
|
+
if (!declared.has(ref)) fail(`Unknown variable "${ref}".`);
|
|
722
|
+
declared.add(name);
|
|
723
|
+
ops.push({
|
|
724
|
+
kind: "declare",
|
|
725
|
+
name,
|
|
726
|
+
value: null,
|
|
727
|
+
from: ref,
|
|
728
|
+
line
|
|
729
|
+
});
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
declared.add(name);
|
|
733
|
+
ops.push({
|
|
734
|
+
kind: "declare",
|
|
735
|
+
name,
|
|
736
|
+
value: json(rhs),
|
|
737
|
+
line
|
|
738
|
+
});
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
const push = text.match(new RegExp(`^(${NAME})\\.push\\((.*)\\)$`));
|
|
742
|
+
if (push) {
|
|
743
|
+
const name = push[1] ?? "";
|
|
744
|
+
if (!declared.has(name)) fail(`Unknown variable "${name}".`);
|
|
745
|
+
let items;
|
|
746
|
+
try {
|
|
747
|
+
items = JSON.parse(`[${push[2] ?? ""}]`);
|
|
748
|
+
} catch {
|
|
749
|
+
throw new ProgramError([{
|
|
750
|
+
message: "Expected push arguments as JSON values.",
|
|
751
|
+
line
|
|
752
|
+
}]);
|
|
753
|
+
}
|
|
754
|
+
ops.push({
|
|
755
|
+
kind: "push",
|
|
756
|
+
name,
|
|
757
|
+
items,
|
|
758
|
+
line
|
|
759
|
+
});
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
const assign = text.match(new RegExp(`^(${NAME})\\s*=\\s*(.+)$`));
|
|
763
|
+
if (assign) {
|
|
764
|
+
const name = assign[1] ?? "";
|
|
765
|
+
if (!declared.has(name)) fail(`Unknown variable "${name}".`);
|
|
766
|
+
const rhs = (assign[2] ?? "").trim();
|
|
767
|
+
const ref = bare(rhs);
|
|
768
|
+
if (ref) {
|
|
769
|
+
if (!declared.has(ref)) fail(`Unknown variable "${ref}".`);
|
|
770
|
+
ops.push({
|
|
771
|
+
kind: "assign",
|
|
772
|
+
name,
|
|
773
|
+
value: null,
|
|
774
|
+
from: ref,
|
|
775
|
+
line
|
|
776
|
+
});
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
ops.push({
|
|
780
|
+
kind: "assign",
|
|
781
|
+
name,
|
|
782
|
+
value: json(rhs),
|
|
783
|
+
line
|
|
784
|
+
});
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
fail("Unknown statement. Use let <name> = <json>, <name> = <json|name>, or <name>.push(<json>).");
|
|
788
|
+
}
|
|
789
|
+
return { ops };
|
|
790
|
+
}
|
|
791
|
+
function buildBindings(params) {
|
|
792
|
+
const t = tracer();
|
|
793
|
+
t.group("Stack", "column").group("Heap", "column");
|
|
794
|
+
const vars = [];
|
|
795
|
+
const heapViews = /* @__PURE__ */ new Map();
|
|
796
|
+
const slot = /* @__PURE__ */ new Map();
|
|
797
|
+
const edges = [];
|
|
798
|
+
const edgeId = (from, to) => `ref_${from}_${to}`;
|
|
799
|
+
const alloc = (value) => {
|
|
800
|
+
const id = `o${heapViews.size + 1}`;
|
|
801
|
+
heapViews.set(id, Array.isArray(value) ? "array" : "record");
|
|
802
|
+
return id;
|
|
803
|
+
};
|
|
804
|
+
for (const op of params.ops) {
|
|
805
|
+
if (op.kind === "push") {
|
|
806
|
+
const target = slot.get(op.name);
|
|
807
|
+
if (!target || !("heap" in target)) throw new ProgramError([{
|
|
808
|
+
message: `Cannot push to "${op.name}": not an array.`,
|
|
809
|
+
line: op.line
|
|
810
|
+
}]);
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
if (!op.from) {
|
|
814
|
+
const value = op.value;
|
|
815
|
+
if (!vars.includes(op.name)) vars.push(op.name);
|
|
816
|
+
if (isHeapValue(value)) {
|
|
817
|
+
const heap = alloc(value);
|
|
818
|
+
slot.set(op.name, { heap });
|
|
819
|
+
edges.push({
|
|
820
|
+
id: edgeId(op.name, heap),
|
|
821
|
+
from: op.name,
|
|
822
|
+
to: heap
|
|
823
|
+
});
|
|
824
|
+
} else slot.set(op.name, { value });
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
if (!vars.includes(op.name)) vars.push(op.name);
|
|
828
|
+
const from = op.from;
|
|
829
|
+
const source = slot.get(from);
|
|
830
|
+
if (!source) throw new ProgramError([{
|
|
831
|
+
message: `Unknown variable "${from}".`,
|
|
832
|
+
line: op.line
|
|
833
|
+
}]);
|
|
834
|
+
if ("heap" in source) {
|
|
835
|
+
slot.set(op.name, { heap: source.heap });
|
|
836
|
+
edges.push({
|
|
837
|
+
id: edgeId(op.name, source.heap),
|
|
838
|
+
from: op.name,
|
|
839
|
+
to: source.heap
|
|
840
|
+
});
|
|
841
|
+
} else slot.set(op.name, { value: source.value });
|
|
842
|
+
}
|
|
843
|
+
for (const name of vars) t.node(name, name, null, {
|
|
844
|
+
lane: "Stack",
|
|
845
|
+
hidden: true
|
|
846
|
+
});
|
|
847
|
+
for (const [heap, view] of heapViews) t.node(heap, heap, null, {
|
|
848
|
+
lane: "Heap",
|
|
849
|
+
view,
|
|
850
|
+
hidden: true
|
|
851
|
+
});
|
|
852
|
+
const seen = /* @__PURE__ */ new Set();
|
|
853
|
+
for (const edge of edges) {
|
|
854
|
+
if (seen.has(edge.id)) continue;
|
|
855
|
+
seen.add(edge.id);
|
|
856
|
+
t.link(edge.id, edge.from, edge.to, "ref", { hidden: true });
|
|
857
|
+
}
|
|
858
|
+
const cur = /* @__PURE__ */ new Map();
|
|
859
|
+
const heapLive = /* @__PURE__ */ new Map();
|
|
860
|
+
let nextHeap = 0;
|
|
861
|
+
const shownPointers = /* @__PURE__ */ new Map();
|
|
862
|
+
const point = (s, name, heap) => {
|
|
863
|
+
const previous = shownPointers.get(name);
|
|
864
|
+
if (previous && previous !== heap) s.hide("link", edgeId(name, previous));
|
|
865
|
+
if (heap && previous !== heap) s.show("link", edgeId(name, heap));
|
|
866
|
+
if (heap) shownPointers.set(name, heap);
|
|
867
|
+
else shownPointers.delete(name);
|
|
868
|
+
};
|
|
869
|
+
const sharers = (heap, except) => [...cur.entries()].filter(([, binding]) => "heap" in binding && binding.heap === heap).map(([name]) => name).filter((name) => name !== except);
|
|
870
|
+
for (const op of params.ops) {
|
|
871
|
+
if (op.kind === "push") {
|
|
872
|
+
const binding = cur.get(op.name);
|
|
873
|
+
const heap = binding && "heap" in binding ? binding.heap : "";
|
|
874
|
+
const current = heapLive.get(heap);
|
|
875
|
+
if (!Array.isArray(current)) throw new ProgramError([{
|
|
876
|
+
message: `Cannot push to "${op.name}": not an array.`,
|
|
877
|
+
line: op.line
|
|
878
|
+
}]);
|
|
879
|
+
const next = [...current, ...op.items];
|
|
880
|
+
heapLive.set(heap, next);
|
|
881
|
+
const others = sharers(heap, op.name);
|
|
882
|
+
t.step(`${op.name}.push(${op.items.map(show).join(", ")})`, (s) => {
|
|
883
|
+
s.focus(op.name, heap, ...others);
|
|
884
|
+
s.set(heap, next);
|
|
885
|
+
s.note(others.length ? `push mutates the shared object, so ${others.join(", ")} see it too.` : `push appends to ${heap}.`);
|
|
886
|
+
});
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
if (!op.from) {
|
|
890
|
+
const label = op.kind === "declare" ? `let ${op.name} = ${show(op.value)}` : `${op.name} = ${show(op.value)}`;
|
|
891
|
+
if (isHeapValue(op.value)) {
|
|
892
|
+
nextHeap += 1;
|
|
893
|
+
const heap = `o${nextHeap}`;
|
|
894
|
+
const heapValue = op.value;
|
|
895
|
+
cur.set(op.name, { heap });
|
|
896
|
+
heapLive.set(heap, heapValue);
|
|
897
|
+
t.step(label, (s) => {
|
|
898
|
+
s.show("node", op.name);
|
|
899
|
+
s.show("node", heap);
|
|
900
|
+
point(s, op.name, heap);
|
|
901
|
+
s.focus(op.name);
|
|
902
|
+
s.set(op.name, heap);
|
|
903
|
+
s.set(heap, heapValue);
|
|
904
|
+
s.send(edgeId(op.name, heap), heapValue);
|
|
905
|
+
s.note(op.kind === "declare" ? `A new object lives on the heap. ${op.name} holds a reference to it.` : `Rebinding ${op.name} moves its arrow to a new object.`);
|
|
906
|
+
});
|
|
907
|
+
} else if (op.kind === "declare") {
|
|
908
|
+
const prim = op.value;
|
|
909
|
+
cur.set(op.name, { value: prim });
|
|
910
|
+
t.step(label, (s) => {
|
|
911
|
+
s.show("node", op.name);
|
|
912
|
+
s.focus(op.name);
|
|
913
|
+
s.set(op.name, prim);
|
|
914
|
+
s.note(`${op.name} is a primitive. The slot holds ${show(prim)} itself.`);
|
|
915
|
+
});
|
|
916
|
+
} else {
|
|
917
|
+
const prim = op.value;
|
|
918
|
+
cur.set(op.name, { value: prim });
|
|
919
|
+
t.step(label, (s) => {
|
|
920
|
+
point(s, op.name);
|
|
921
|
+
s.focus(op.name);
|
|
922
|
+
s.set(op.name, prim);
|
|
923
|
+
s.note(`Reassigning ${op.name} changes only ${op.name}.`);
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
continue;
|
|
927
|
+
}
|
|
928
|
+
const from = op.from;
|
|
929
|
+
const keyword = op.kind === "declare" ? "let " : "";
|
|
930
|
+
const source = cur.get(from);
|
|
931
|
+
if (source && "heap" in source) {
|
|
932
|
+
const heap = source.heap;
|
|
933
|
+
cur.set(op.name, { heap });
|
|
934
|
+
const heapValue = heapLive.get(heap) ?? null;
|
|
935
|
+
t.step(`${keyword}${op.name} = ${from}`, (s) => {
|
|
936
|
+
s.show("node", op.name);
|
|
937
|
+
point(s, op.name, heap);
|
|
938
|
+
s.focus(op.name, from);
|
|
939
|
+
s.set(op.name, heap);
|
|
940
|
+
s.send(edgeId(op.name, heap), heapValue);
|
|
941
|
+
s.note("Copying a reference copies the arrow, not the array. Two names, one object.");
|
|
942
|
+
});
|
|
943
|
+
} else {
|
|
944
|
+
const value = source && "value" in source ? source.value : null;
|
|
945
|
+
cur.set(op.name, { value });
|
|
946
|
+
t.step(`${keyword}${op.name} = ${from}`, (s) => {
|
|
947
|
+
s.show("node", op.name);
|
|
948
|
+
point(s, op.name);
|
|
949
|
+
s.focus(op.name, from);
|
|
950
|
+
s.set(op.name, value);
|
|
951
|
+
s.note(`${op.name} gets a copy of ${show(value)}. Two independent slots.`);
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
return t.program();
|
|
956
|
+
}
|
|
957
|
+
// src/domains/callstack.ts
|
|
958
|
+
const fact = (k) => k <= 1 ? 1 : k * fact(k - 1);
|
|
959
|
+
function parseCallstack(ast) {
|
|
960
|
+
const lines = sourceLines(ast);
|
|
961
|
+
const line = lines[0]?.line ?? sourceLine(ast);
|
|
962
|
+
if (lines.length !== 1) throw new ProgramError([{
|
|
963
|
+
message: "Callstack takes one line: fact <n>.",
|
|
964
|
+
line
|
|
965
|
+
}]);
|
|
966
|
+
const entry = lines[0]?.text.match(/^fact\s+(\d+)$/);
|
|
967
|
+
if (!entry || entry[1] === void 0) throw new ProgramError([{
|
|
968
|
+
message: "Expected fact <n>.",
|
|
969
|
+
line
|
|
970
|
+
}]);
|
|
971
|
+
const n = Number(entry[1]);
|
|
972
|
+
if (!Number.isInteger(n) || n < 1 || n > 6) throw new ProgramError([{
|
|
973
|
+
message: "Depth n is 1–6.",
|
|
974
|
+
line
|
|
975
|
+
}]);
|
|
976
|
+
return { n };
|
|
977
|
+
}
|
|
978
|
+
function buildCallstack({ n }) {
|
|
979
|
+
const t = tracer();
|
|
980
|
+
const slot = (k) => `slot${n - k + 1}`;
|
|
981
|
+
for (let i = 1; i <= n; i++) t.node(`slot${i}`, `frame ${i}`, null, { lane: "Call stack" });
|
|
982
|
+
t.node("returns", "Returns", Array(n).fill("·"), {
|
|
983
|
+
lane: "Returns",
|
|
984
|
+
view: "array"
|
|
985
|
+
});
|
|
986
|
+
for (let k = 1; k < n; k++) t.link(`calls${k}`, slot(k + 1), slot(k), "calls");
|
|
987
|
+
for (let k = 1; k <= n; k++) t.link(`ret${k}`, slot(k), "returns", "↩");
|
|
988
|
+
for (let k = n; k >= 1; k--) t.step(`call fact(${k})`, (s) => {
|
|
989
|
+
s.focus(slot(k));
|
|
990
|
+
s.set(slot(k), k === 1 ? "fact(1) · base case" : `fact(${k}) · waiting on fact(${k - 1})`);
|
|
991
|
+
if (k < n) s.send(`calls${k}`, `fact(${k})`);
|
|
992
|
+
s.note(k === n ? `fact(${n}) is called. Each call gets its own frame.` : k === 1 ? "Base case: n <= 1. fact(1) returns without another call." : `fact(${k}) can't finish yet — it needs fact(${k - 1}). A new frame is pushed.`);
|
|
993
|
+
});
|
|
994
|
+
for (let k = 1; k <= n; k++) {
|
|
995
|
+
const value = fact(k);
|
|
996
|
+
t.step(`return fact(${k})`, (s) => {
|
|
997
|
+
s.focus(slot(k), `returns[${k - 1}]`);
|
|
998
|
+
s.set(slot(k), null);
|
|
999
|
+
s.set(`returns[${k - 1}]`, value);
|
|
1000
|
+
s.send(`ret${k}`, value);
|
|
1001
|
+
s.note(k === 1 ? "fact(1) returns 1. Its frame is popped." : `fact(${k}) gets ${fact(k - 1)} back and returns ${k} × ${fact(k - 1)} = ${value}.`);
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
t.step("done", (s) => {
|
|
1005
|
+
s.focus("returns");
|
|
1006
|
+
s.note(`The stack is empty again. fact(${n}) = ${fact(n)}.`);
|
|
1007
|
+
});
|
|
1008
|
+
return t.program();
|
|
1009
|
+
}
|
|
1010
|
+
// src/domains/matrix.ts
|
|
1011
|
+
const parseGrid = (source, line) => {
|
|
1012
|
+
let value;
|
|
1013
|
+
try {
|
|
1014
|
+
value = JSON.parse(source);
|
|
1015
|
+
} catch {
|
|
1016
|
+
throw new ProgramError([{
|
|
1017
|
+
message: "Expected a JSON grid like [[1,2],[3,4]].",
|
|
1018
|
+
line
|
|
1019
|
+
}]);
|
|
1020
|
+
}
|
|
1021
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 4 || value.some((row) => !Array.isArray(row) || row.length < 1 || row.length > 4 || row.length !== value[0].length || row.some((cell) => typeof cell !== "number" || !Number.isFinite(cell)))) throw new ProgramError([{
|
|
1022
|
+
message: "Grids are 1–4 rows of 1–4 finite numbers, all rows equal length.",
|
|
1023
|
+
line
|
|
1024
|
+
}]);
|
|
1025
|
+
return value;
|
|
1026
|
+
};
|
|
1027
|
+
function parseMatrix(ast) {
|
|
1028
|
+
const found = /* @__PURE__ */ new Map();
|
|
1029
|
+
for (const { text, line } of sourceLines(ast)) {
|
|
1030
|
+
const entry = text.match(/^(A|B)\s*=\s*(.+)$/);
|
|
1031
|
+
if (!entry?.[1] || entry[2] === void 0) throw new ProgramError([{
|
|
1032
|
+
message: "Expected A = <grid> or B = <grid>.",
|
|
1033
|
+
line
|
|
1034
|
+
}]);
|
|
1035
|
+
if (found.has(entry[1])) throw new ProgramError([{
|
|
1036
|
+
message: `Duplicate operand "${entry[1]}".`,
|
|
1037
|
+
line
|
|
1038
|
+
}]);
|
|
1039
|
+
found.set(entry[1], {
|
|
1040
|
+
grid: parseGrid(entry[2], line),
|
|
1041
|
+
line
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
const a = found.get("A");
|
|
1045
|
+
const b = found.get("B");
|
|
1046
|
+
if (!a || !b) throw new ProgramError([{
|
|
1047
|
+
message: "Matrix needs both A = <grid> and B = <grid>.",
|
|
1048
|
+
line: sourceLine(ast)
|
|
1049
|
+
}]);
|
|
1050
|
+
if (a.grid[0]?.length !== b.grid.length) throw new ProgramError([{
|
|
1051
|
+
message: `Inner dimensions mismatch: A has ${a.grid[0]?.length} columns but B has ${b.grid.length} rows.`,
|
|
1052
|
+
line: b.line
|
|
1053
|
+
}]);
|
|
1054
|
+
return {
|
|
1055
|
+
a: a.grid,
|
|
1056
|
+
b: b.grid
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
function buildMatrix({ a, b }) {
|
|
1060
|
+
const t = tracer();
|
|
1061
|
+
t.group("Matrices", "row").group("Work", "row");
|
|
1062
|
+
const rows = a.length;
|
|
1063
|
+
const cols = b[0]?.length;
|
|
1064
|
+
const inner = b.length;
|
|
1065
|
+
t.node("A", "A", a, {
|
|
1066
|
+
lane: "Matrices",
|
|
1067
|
+
view: "matrix"
|
|
1068
|
+
});
|
|
1069
|
+
t.node("B", "B", b, {
|
|
1070
|
+
lane: "Matrices",
|
|
1071
|
+
view: "matrix"
|
|
1072
|
+
});
|
|
1073
|
+
t.node("C", "C = A × B", Array.from({ length: rows }, () => Array(cols).fill("·")), {
|
|
1074
|
+
lane: "Matrices",
|
|
1075
|
+
view: "matrix"
|
|
1076
|
+
});
|
|
1077
|
+
t.node("work", "Terms", [], {
|
|
1078
|
+
lane: "Work",
|
|
1079
|
+
view: "array"
|
|
1080
|
+
});
|
|
1081
|
+
t.link("readA", "A", "C", "row");
|
|
1082
|
+
t.link("readB", "B", "C", "col");
|
|
1083
|
+
for (let i = 0; i < rows; i++) for (let j = 0; j < cols; j++) {
|
|
1084
|
+
const terms = Array.from({ length: inner }, (_, k) => a[i][k] * b[k][j]);
|
|
1085
|
+
const total = terms.reduce((sum, term) => sum + term, 0);
|
|
1086
|
+
const column = Array.from({ length: inner }, (_, k) => `B[${k}][${j}]`);
|
|
1087
|
+
t.step(`C[${i}][${j}]`, (s) => {
|
|
1088
|
+
s.focus(`A[${i}]`, `C[${i}][${j}]`, ...column);
|
|
1089
|
+
s.set(`C[${i}][${j}]`, total);
|
|
1090
|
+
s.set("work", terms);
|
|
1091
|
+
s.send("readA", a[i]);
|
|
1092
|
+
s.send("readB", b.map((row) => row[j]));
|
|
1093
|
+
s.note(`C[${i}][${j}] = ${terms.join(" + ")} = ${total}. Row ${i} of A dot column ${j} of B.`);
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
return t.program();
|
|
1097
|
+
}
|
|
1098
|
+
// src/domains/registry.ts
|
|
1099
|
+
const SUPPORTED_DOMAINS = [
|
|
1100
|
+
"flow",
|
|
1101
|
+
"bindings",
|
|
1102
|
+
"binary",
|
|
1103
|
+
"matrix",
|
|
1104
|
+
"callstack"
|
|
1105
|
+
];
|
|
1106
|
+
const COMPUTED = {
|
|
1107
|
+
bindings: (ast) => buildBindings(checkParameters(parseBindings(ast))),
|
|
1108
|
+
binary: (ast) => buildBinary(checkParameters(parseBinary(ast))),
|
|
1109
|
+
matrix: (ast) => buildMatrix(checkParameters(parseMatrix(ast))),
|
|
1110
|
+
callstack: (ast) => buildCallstack(checkParameters(parseCallstack(ast)))
|
|
1111
|
+
};
|
|
1112
|
+
/** Adapters may serialize labels before producing a Program. Guard their input first. */
|
|
1113
|
+
function checkParameters(parameters) {
|
|
1114
|
+
createBudget("Program input", COMPILATION_LIMITS.programUnits)(parameters);
|
|
1115
|
+
return parameters;
|
|
1116
|
+
}
|
|
1117
|
+
/** Compile any fenced thl block, routing on the info-string domain. */
|
|
1118
|
+
function compile(src) {
|
|
1119
|
+
const ast = parseThl(src);
|
|
1120
|
+
if (!ast) throw new ProgramError([{ message: "Expected a ```thl <domain> block." }]);
|
|
1121
|
+
return compileAst(ast);
|
|
1122
|
+
}
|
|
1123
|
+
function compileAst(ast) {
|
|
1124
|
+
if (ast.kind === "flow") return compileProgram(parseProgram(ast));
|
|
1125
|
+
const build = Object.hasOwn(COMPUTED, ast.kind) ? COMPUTED[ast.kind] : void 0;
|
|
1126
|
+
if (!build) throw new ProgramError([{
|
|
1127
|
+
message: `Unknown domain "${ast.kind}". Use ${SUPPORTED_DOMAINS.join(", ")}.`,
|
|
1128
|
+
line: ast.source?.line ?? 1
|
|
1129
|
+
}]);
|
|
1130
|
+
return compileProgram(build(ast));
|
|
1131
|
+
}
|
|
1132
|
+
export { COMPILATION_LIMITS, ProgramError, SUPPORTED_DOMAINS, compile, compileAst, compileProgram, describeFrame, parseProgram, parseThl, tracer, validateProgram };
|
|
1133
|
+
|
|
1134
|
+
//# sourceMappingURL=index.js.map
|