@flowsilver/algoviz 0.0.0-stage → 0.4.0

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/package.json CHANGED
@@ -1,6 +1,42 @@
1
- {
2
- "name": "@flowsilver/algoviz",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "@flowsilver/algoviz",
3
+ "version": "0.4.0",
4
+ "description": "Validate, replay, and compare algorithm traces in a self-contained offline viewer.",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/FlowSilver/AlgoViz.git"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "files": [
14
+ "apps/inspector/",
15
+ "packages/",
16
+ "schemas/",
17
+ "examples/frozenlake/",
18
+ "docs/protocol.md"
19
+ ],
20
+ "type": "module",
21
+ "bin": {
22
+ "algoviz": "packages/cli/index.js"
23
+ },
24
+ "engines": {
25
+ "node": ">=22"
26
+ },
27
+ "scripts": {
28
+ "algoviz": "node packages/cli/index.js",
29
+ "typecheck": "tsc -p tsconfig.json",
30
+ "test": "node --test",
31
+ "test:browser": "node --test scripts/check-preview.js scripts/check-comparison.js"
32
+ },
33
+ "dependencies": {
34
+ "ajv": "^8.20.0",
35
+ "esbuild": "^0.28.2",
36
+ "jsonc-parser": "^3.3.1"
37
+ },
38
+ "devDependencies": {
39
+ "playwright": "^1.65.0",
40
+ "typescript": "^7.0.2"
41
+ }
42
+ }
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, mkdtemp, readdir, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { basename, join, resolve } from "node:path";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import { parseArgs } from "node:util";
7
+ import { build } from "esbuild";
8
+ import { indexRun } from "../core/comparison.js";
9
+ import { loadReplay } from "../core/index.js";
10
+ import { loadViews } from "../core/protocol.js";
11
+
12
+ const inspector = new URL("../../apps/inspector/", import.meta.url);
13
+ const projectRoot = fileURLToPath(new URL("../../", import.meta.url));
14
+ const usage = `Usage:
15
+ algoviz validate <visualization.json> <trace.jsonl>
16
+ algoviz preview <visualization.json> <trace.jsonl> [--output <preview.html>] [--renderer <name=file>]
17
+ algoviz compare <left-spec> <left-trace> <right-spec> <right-trace> [--output <preview.html>] [--renderer <name=file>]
18
+ `;
19
+
20
+ async function readUtf8(path) {
21
+ const bytes = await readFile(path);
22
+ try {
23
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
24
+ } catch {
25
+ throw new Error(`${path}: expected UTF-8 text`);
26
+ }
27
+ }
28
+
29
+ async function bundleLicenses(inputs) {
30
+ const packages = new Set();
31
+ for (const input of Object.keys(inputs)) {
32
+ const path = resolve(projectRoot, input).replaceAll("\\", "/");
33
+ const packageDirectory = /^(.*\/node_modules\/(?:@[^/]+\/)?[^/]+)\//.exec(path)?.[1];
34
+ if (packageDirectory) packages.add(packageDirectory);
35
+ }
36
+ const licenses = [await readFile(join(projectRoot, "LICENSE"), "utf8")];
37
+ for (const directory of [...packages].sort()) {
38
+ const files = (await readdir(directory)).filter((file) => /^licen[cs]e(?:\..+)?$/i.test(file));
39
+ for (const file of files.sort()) {
40
+ licenses.push(`${basename(directory)}\n\n${await readFile(join(directory, file), "utf8")}`);
41
+ }
42
+ }
43
+ return licenses.join("\n\n").replaceAll("-->", "-- >");
44
+ }
45
+
46
+ async function previewHtml(data, renderer) {
47
+ const entry = (path) => JSON.stringify(path.replaceAll("\\", "/"));
48
+ const inspectorEntry = entry(fileURLToPath(new URL("index.js", inspector)));
49
+ const plugin = renderer ? `import render from ${entry(renderer.path)};\n` : "";
50
+ const registry = renderer ? `new Map([[${JSON.stringify(renderer.name)}, render]])` : "new Map()";
51
+ const [template, styles, bundle] = await Promise.all([
52
+ readFile(new URL("index.html", inspector), "utf8"),
53
+ readFile(new URL("style.css", inspector), "utf8"),
54
+ build({
55
+ absWorkingDir: projectRoot,
56
+ stdin: {
57
+ contents: `${plugin}import { start } from ${inspectorEntry};\nstart(${registry});`,
58
+ resolveDir: projectRoot,
59
+ },
60
+ platform: "browser",
61
+ format: "iife",
62
+ bundle: true,
63
+ minify: true,
64
+ legalComments: "inline",
65
+ metafile: true,
66
+ write: false,
67
+ }),
68
+ ]);
69
+ const parts = {
70
+ STYLES: styles,
71
+ DATA: JSON.stringify(data).replaceAll("<", "\\u003c"),
72
+ SCRIPT: bundle.outputFiles[0].text,
73
+ LICENSES: await bundleLicenses(bundle.metafile.inputs),
74
+ };
75
+ return template.replace(/%(STYLES|DATA|SCRIPT|LICENSES)%/g, (_, name) => parts[name]);
76
+ }
77
+
78
+ function readRenderer(value) {
79
+ if (value === undefined) return undefined;
80
+ const match = /^([a-z][a-z0-9-]*)=(.+)$/.exec(value);
81
+ if (!match) throw new Error("Expected --renderer name=path-to-module");
82
+ return { name: match[1], path: resolve(match[2]) };
83
+ }
84
+
85
+ async function readRun(specPath, tracePath) {
86
+ const [specText, traceText] = await Promise.all([readUtf8(specPath), readUtf8(tracePath)]);
87
+ return { specText, traceText, traceSource: tracePath, specSource: specPath };
88
+ }
89
+
90
+ async function main() {
91
+ const { positionals, values } = parseArgs({
92
+ allowPositionals: true,
93
+ options: { help: { type: "boolean", short: "h" }, output: { type: "string" }, renderer: { type: "string" } },
94
+ });
95
+ if (values.help) {
96
+ console.log(usage.trimEnd());
97
+ return;
98
+ }
99
+ const [command, ...paths] = positionals;
100
+ if (!["validate", "preview", "compare"].includes(command)
101
+ || paths.length !== (command === "compare" ? 4 : 2)
102
+ || (command === "validate" && (values.output !== undefined || values.renderer !== undefined))) {
103
+ throw new Error(usage.trimEnd());
104
+ }
105
+
106
+ const renderer = readRenderer(values.renderer);
107
+ const runs = await Promise.all([readRun(paths[0], paths[1]), ...(command === "compare" ? [readRun(paths[2], paths[3])] : [])]);
108
+ for (const run of runs) {
109
+ if (command !== "validate") {
110
+ for (const view of loadViews(run.specText, run.specSource)) {
111
+ if (view.type === "custom" && view.renderer !== renderer?.name) {
112
+ throw new Error(`${run.specSource}: renderer "${view.renderer}" requires --renderer ${view.renderer}=<file>`);
113
+ }
114
+ }
115
+ }
116
+ if (command === "compare") {
117
+ indexRun(run);
118
+ } else {
119
+ const replay = loadReplay(run.traceText, run);
120
+ replay.seek(replay.lastStep);
121
+ if (command === "validate") {
122
+ const count = replay.lastStep + 1;
123
+ console.log(`Validated ${count} replay ${count === 1 ? "state" : "states"}.`);
124
+ return;
125
+ }
126
+ }
127
+ }
128
+
129
+ const html = await previewHtml({ runs: runs.map((run) => ({
130
+ ...run, traceSource: basename(run.traceSource), specSource: basename(run.specSource),
131
+ })) }, renderer);
132
+ const destination = values.output === undefined
133
+ ? join(await mkdtemp(join(tmpdir(), "algoviz-")), "preview.html")
134
+ : resolve(values.output);
135
+ await writeFile(destination, html, { encoding: "utf8", flag: "wx" });
136
+ console.log(`Open ${pathToFileURL(destination).href}`);
137
+ }
138
+
139
+ try {
140
+ await main();
141
+ } catch (error) {
142
+ console.error(error.code === "EEXIST" ? `Preview already exists at ${error.path}. Choose a new output path.` : error.message);
143
+ process.exitCode = 1;
144
+ }
@@ -0,0 +1,43 @@
1
+ import { loadReplay, ReplayError } from "./index.js";
2
+
3
+ /** @import { ReplayOptions } from "./types.js" */
4
+
5
+ /**
6
+ * Index a run by its zero-based episode and step coordinates, validating each frame.
7
+ * @param {ReplayOptions & { traceText: string }} input
8
+ */
9
+ export function indexRun({ traceText, ...options }) {
10
+ const replay = loadReplay(traceText, { ...options, checkpointInterval: 128 });
11
+ /** @type {{ start: number, actions: (string | undefined)[] }[]} */
12
+ const episodes = [];
13
+ let frame = replay.current;
14
+ while (true) {
15
+ const { episode, t, last } = frame.state;
16
+ const current = episodes.at(-1);
17
+ if (episode === episodes.length && t === 0) {
18
+ episodes.push({ start: frame.step, actions: [undefined] });
19
+ } else if (current && episode === episodes.length - 1 && t === current.actions.length) {
20
+ const action = last && typeof last === "object" && !Array.isArray(last) ? last.action : undefined;
21
+ current.actions.push(typeof action === "string" ? action : undefined);
22
+ } else {
23
+ throw new ReplayError("Expected consecutive zero-based episode and t values", {
24
+ source: options.traceSource ?? "trace.jsonl", line: frame.step + 1,
25
+ });
26
+ }
27
+ if (frame.step === replay.lastStep) return { replay, episodes };
28
+ frame = replay.stepForward();
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Find the first transition with a different recorded action in the shared part of an episode.
34
+ * @param {ReturnType<typeof indexRun>[]} runs
35
+ * @param {number} episode
36
+ */
37
+ export function firstDifferentAction(runs, episode) {
38
+ const [a, b] = runs.map((run) => run.episodes[episode]?.actions ?? []);
39
+ for (let step = 1; step < Math.min(a.length, b.length); step++) {
40
+ if (a[step] !== undefined && b[step] !== undefined && a[step] !== b[step]) return step;
41
+ }
42
+ return undefined;
43
+ }
@@ -0,0 +1,124 @@
1
+ import { applyUpdate } from "./json-pointer.js";
2
+ import { checkBindings, loadTrace, loadViews, ReplayError } from "./protocol.js";
3
+ import { diffStates } from "./state-changes.js";
4
+
5
+ /** @import { JsonObject, ReplayFrame, ReplayOptions, StateChange, TraceRecords, ViewSpec } from "./types.js" */
6
+
7
+ export { ReplayError } from "./protocol.js";
8
+
9
+ /**
10
+ * @param {string} traceText
11
+ * @param {ReplayOptions} [options]
12
+ */
13
+ export function loadReplay(traceText, {
14
+ traceSource = "trace.jsonl",
15
+ specText,
16
+ specSource = "visualization.json",
17
+ checkpointInterval,
18
+ } = {}) {
19
+ const records = loadTrace(traceText, traceSource);
20
+ const views = specText === undefined ? [] : loadViews(specText, specSource);
21
+ return new TraceReplay(records, views, traceSource, checkpointInterval);
22
+ }
23
+
24
+ class TraceReplay {
25
+ #records;
26
+ #views;
27
+ #source;
28
+ #checkpointInterval;
29
+ #lastCheckpoint = 0;
30
+ /** @type {Map<number, JsonObject>} */
31
+ #checkpoints = new Map();
32
+ /** @type {ReplayFrame} */
33
+ #frame;
34
+ /** @type {JsonObject | undefined} */
35
+ #previousState;
36
+
37
+ /**
38
+ * @param {TraceRecords} records
39
+ * @param {ViewSpec[]} views
40
+ * @param {string} source
41
+ * @param {number} [checkpointInterval]
42
+ */
43
+ constructor(records, views, source, checkpointInterval) {
44
+ if (checkpointInterval !== undefined && (!Number.isSafeInteger(checkpointInterval) || checkpointInterval < 1)) {
45
+ throw new RangeError("Checkpoint interval must be a positive integer");
46
+ }
47
+ this.#records = records;
48
+ this.#views = views;
49
+ this.#source = source;
50
+ this.#checkpointInterval = checkpointInterval;
51
+ const state = records[0].state;
52
+ checkBindings(state, views, source, 1);
53
+ this.#frame = { step: 0, state, events: [] };
54
+ }
55
+
56
+ get current() {
57
+ return structuredClone(this.#frame);
58
+ }
59
+
60
+ get lastStep() {
61
+ return this.#records.length - 1;
62
+ }
63
+
64
+ /** @returns {StateChange[]} */
65
+ get changes() {
66
+ return this.#previousState ? structuredClone(diffStates(this.#previousState, this.#frame.state)) : [];
67
+ }
68
+
69
+ stepForward() {
70
+ return this.seek(Math.min(this.#frame.step + 1, this.lastStep));
71
+ }
72
+
73
+ stepBackward() {
74
+ return this.seek(Math.max(this.#frame.step - 1, 0));
75
+ }
76
+
77
+ /** @param {number} step */
78
+ seek(step) {
79
+ if (!Number.isInteger(step) || step < 0 || step > this.lastStep) {
80
+ throw new RangeError(`Step must be an integer from 0 through ${this.lastStep}`);
81
+ }
82
+ if (step === this.#frame.step) return this.current;
83
+
84
+ const interval = this.#checkpointInterval;
85
+ // Start before the target to reconstruct the Changes panel's preceding state.
86
+ const checkpoint = interval ? Math.min(this.#lastCheckpoint, Math.floor((step - 1) / interval) * interval) : 0;
87
+ const start = this.#frame.step < step && this.#frame.step > checkpoint
88
+ ? this.#frame : { step: Math.max(checkpoint, 0), state: this.#checkpoints.get(checkpoint) ?? this.#records[0].state };
89
+ const candidate = structuredClone(start.state);
90
+ /** @type {[number, JsonObject][]} */
91
+ const checkpoints = [];
92
+ /** @type {JsonObject | undefined} */
93
+ let previousState;
94
+ for (let next = start.step + 1; next <= step; next++) {
95
+ const line = next + 1;
96
+ const record = this.#records[next];
97
+ if (record.type !== "step") throw new Error(`Missing step record at line ${line}`);
98
+ if (next === step) previousState = next === start.step + 1 ? start.state : structuredClone(candidate);
99
+ for (const [operationIndex, update] of record.updates.entries()) {
100
+ try {
101
+ applyUpdate(candidate, update);
102
+ } catch (error) {
103
+ throw new ReplayError(error instanceof Error ? error.message : String(error), {
104
+ source: this.#source, line, operationIndex, path: update.path,
105
+ });
106
+ }
107
+ }
108
+ checkBindings(candidate, this.#views, this.#source, line);
109
+ if (interval && next > this.#lastCheckpoint && next % interval === 0) {
110
+ checkpoints.push([next, structuredClone(candidate)]);
111
+ }
112
+ }
113
+
114
+ const record = this.#records[step];
115
+ // Commit the frame and its comparison baseline only after successful validation.
116
+ this.#frame = { step, state: candidate, events: record.type === "step" ? record.events ?? [] : [] };
117
+ this.#previousState = previousState;
118
+ for (const [index, state] of checkpoints) {
119
+ this.#checkpoints.set(index, state);
120
+ this.#lastCheckpoint = index;
121
+ }
122
+ return this.current;
123
+ }
124
+ }
@@ -0,0 +1,94 @@
1
+ /** @import { ItemBinding, JsonObject, JsonValue, StateUpdate } from "./types.js" */
2
+
3
+ /** @param {string} pointer */
4
+ function tokens(pointer) {
5
+ if (pointer === "") return [];
6
+ return pointer.slice(1).split("/").map((token) => token.replaceAll("~1", "/").replaceAll("~0", "~"));
7
+ }
8
+
9
+ /**
10
+ * @param {JsonValue[]} array
11
+ * @param {string} token
12
+ * @param {boolean} [inserting]
13
+ */
14
+ function arrayIndex(array, token, inserting = false) {
15
+ if (inserting && token === "-") return array.length;
16
+ if (!/^(?:0|[1-9][0-9]*)$/.test(token)) {
17
+ throw new Error(`Invalid array index ${JSON.stringify(token)}`);
18
+ }
19
+
20
+ const index = Number(token);
21
+ const lastIndex = inserting ? array.length : array.length - 1;
22
+ if (index > lastIndex) throw new Error(`Array index ${token} is out of range`);
23
+ return index;
24
+ }
25
+
26
+ /**
27
+ * @param {JsonValue} parent
28
+ * @param {string} key
29
+ * @returns {JsonValue}
30
+ */
31
+ function readMember(parent, key) {
32
+ if (Array.isArray(parent)) return parent[arrayIndex(parent, key)];
33
+ if (parent === null || typeof parent !== "object") {
34
+ throw new Error(`Cannot read member ${JSON.stringify(key)} from a scalar`);
35
+ }
36
+ if (!Object.hasOwn(parent, key)) throw new Error(`Missing member ${JSON.stringify(key)}`);
37
+ return parent[key];
38
+ }
39
+
40
+ /**
41
+ * @param {JsonValue} state
42
+ * @param {string} pointer
43
+ */
44
+ export function resolvePointer(state, pointer) {
45
+ return tokens(pointer).reduce(readMember, state);
46
+ }
47
+
48
+ /**
49
+ * @param {JsonValue} entry
50
+ * @param {ItemBinding} binding
51
+ */
52
+ export function resolveArrayItem(entry, binding) {
53
+ const label = resolvePointer(entry, binding.label);
54
+ if (typeof label !== "string") {
55
+ throw new Error(`Expected a string label at ${JSON.stringify(binding.label)}`);
56
+ }
57
+ return { label, value: resolvePointer(entry, binding.value) };
58
+ }
59
+
60
+ /**
61
+ * @param {JsonObject} state
62
+ * @param {StateUpdate} update
63
+ */
64
+ export function applyUpdate(state, { op, path, value }) {
65
+ const parents = tokens(path);
66
+ const key = parents.pop();
67
+ if (key === undefined) throw new Error("Update path must name a state member");
68
+ const parent = parents.reduce(readMember, state);
69
+
70
+ if (Array.isArray(parent)) {
71
+ const index = arrayIndex(parent, key, op === "add");
72
+ if (op === "add") parent.splice(index, 0, structuredClone(value));
73
+ else if (op === "remove") parent.splice(index, 1);
74
+ else parent[index] = structuredClone(value);
75
+ return;
76
+ }
77
+
78
+ if (parent === null || typeof parent !== "object") {
79
+ throw new Error(`Cannot update member ${JSON.stringify(key)} on a scalar`);
80
+ }
81
+ if (op !== "add") readMember(parent, key);
82
+
83
+ if (op === "remove") {
84
+ delete parent[key];
85
+ } else {
86
+ // JSON keys such as __proto__ must become ordinary own properties.
87
+ Object.defineProperty(parent, key, {
88
+ value: structuredClone(value),
89
+ enumerable: true,
90
+ writable: true,
91
+ configurable: true,
92
+ });
93
+ }
94
+ }
@@ -0,0 +1,188 @@
1
+ import { Ajv2020 } from "ajv/dist/2020.js";
2
+ import { visit } from "jsonc-parser";
3
+ import graphSchema from "../../schemas/graph-view-data.schema.json" with { type: "json" };
4
+ import traceSchema from "../../schemas/trace-record.schema.json" with { type: "json" };
5
+ import specSchema from "../../schemas/visualization-spec.schema.json" with { type: "json" };
6
+ import { resolveArrayItem, resolvePointer } from "./json-pointer.js";
7
+
8
+ /** @import { ValidateFunction, ErrorObject } from "ajv" */
9
+ /** @import { GraphViewData, InitialRecord, ItemBinding, JsonObject, JsonValue, ReplayLocation, StepRecord, TraceRecords, ViewSpec, VisualizationSpec } from "./types.js" */
10
+
11
+ const ajv = new Ajv2020({ ownProperties: true });
12
+ ajv.addSchema(traceSchema, "trace-record");
13
+ const validateInitial = /** @type {ValidateFunction<InitialRecord>} */ (recordValidator("initial"));
14
+ const validateStep = /** @type {ValidateFunction<StepRecord>} */ (recordValidator("step"));
15
+ /** @type {ValidateFunction<VisualizationSpec>} */
16
+ const validateSpec = ajv.compile(specSchema);
17
+ /** @type {ValidateFunction<GraphViewData>} */
18
+ const validateGraph = ajv.compile(graphSchema);
19
+
20
+ /** @param {"initial" | "step"} type */
21
+ function recordValidator(type) {
22
+ const validate = ajv.getSchema(`trace-record#/$defs/${type}`);
23
+ if (!validate) throw new Error(`Missing trace schema for ${type}`);
24
+ return validate;
25
+ }
26
+
27
+ export class ReplayError extends Error {
28
+ /**
29
+ * @param {string} message
30
+ * @param {ReplayLocation} location
31
+ */
32
+ constructor(message, { source, line, operationIndex, path, viewId }) {
33
+ const location = line === undefined ? source : `${source}:${line}`;
34
+ const subject = operationIndex !== undefined ? ` update ${operationIndex}`
35
+ : viewId !== undefined ? ` view ${JSON.stringify(viewId)}` : "";
36
+ const pointer = path === undefined ? "" : ` at ${JSON.stringify(path)}`;
37
+ super(`${location}${subject}${pointer}: ${message}`);
38
+ this.name = "ReplayError";
39
+ this.source = source;
40
+ this.line = line;
41
+ this.operationIndex = operationIndex;
42
+ this.path = path;
43
+ this.viewId = viewId;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * @param {string} text
49
+ * @param {ReplayLocation} location
50
+ * @returns {unknown}
51
+ */
52
+ function parseJson(text, location) {
53
+ try {
54
+ const value = JSON.parse(text, (_key, entry) => {
55
+ if (typeof entry === "number" && !Number.isFinite(entry)) {
56
+ throw new Error("Number is outside the finite JavaScript range");
57
+ }
58
+ return entry;
59
+ });
60
+ /** @type {Set<string>[]} */
61
+ const keys = [];
62
+ // JSON.parse checks syntax, while the visitor sees keys before duplicates are discarded.
63
+ visit(text, {
64
+ onObjectBegin() { keys.push(new Set()); },
65
+ onObjectEnd() { keys.pop(); },
66
+ onObjectProperty(key) {
67
+ const objectKeys = keys.at(-1);
68
+ if (!objectKeys) throw new Error("Invalid JSON object");
69
+ if (objectKeys.has(key)) throw new Error(`Duplicate member ${JSON.stringify(key)}`);
70
+ objectKeys.add(key);
71
+ },
72
+ });
73
+ return value;
74
+ } catch (error) {
75
+ throw new ReplayError(error instanceof Error ? error.message : String(error), location);
76
+ }
77
+ }
78
+
79
+ /** @param {ErrorObject | undefined} problem */
80
+ function schemaMessage(problem) {
81
+ return [problem?.instancePath, problem?.message ?? "Invalid input"].filter(Boolean).join(" ");
82
+ }
83
+
84
+ /**
85
+ * @template T
86
+ * @param {string} lineText
87
+ * @param {ReplayLocation} location
88
+ * @param {"initial" | "step"} expected
89
+ * @param {ValidateFunction<T>} validate
90
+ * @returns {T}
91
+ */
92
+ function readRecord(lineText, location, expected, validate) {
93
+ if (lineText.trim() === "") throw new ReplayError("Blank trace line", location);
94
+ const record = parseJson(lineText, location);
95
+ if (typeof record !== "object" || record === null || !("type" in record) || record.type !== expected) {
96
+ throw new ReplayError(`Expected a record with type ${JSON.stringify(expected)}`, location);
97
+ }
98
+ if (validate(record)) return record;
99
+
100
+ const problem = validate.errors?.[0];
101
+ const operation = /^\/updates\/(\d+)(?:\/|$)/.exec(problem?.instancePath ?? "");
102
+ if (operation && "updates" in record && Array.isArray(record.updates)) {
103
+ location.operationIndex = Number(operation[1]);
104
+ const path = record.updates[location.operationIndex]?.path;
105
+ if (typeof path === "string") location.path = path;
106
+ }
107
+ throw new ReplayError(schemaMessage(problem), location);
108
+ }
109
+
110
+ /**
111
+ * @param {string} text
112
+ * @param {string} source
113
+ * @returns {TraceRecords}
114
+ */
115
+ export function loadTrace(text, source) {
116
+ if (typeof text !== "string") throw new TypeError("Trace input must be decoded JSONL text");
117
+ const lines = text.split(/\r?\n/);
118
+ if (lines.at(-1) === "") lines.pop();
119
+ const first = lines.shift();
120
+ if (first === undefined) throw new ReplayError("Expected an initial record", { source, line: 1 });
121
+ const initial = readRecord(first, { source, line: 1 }, "initial", validateInitial);
122
+ return [initial, ...lines.map((text, index) => readRecord(text, { source, line: index + 2 }, "step", validateStep))];
123
+ }
124
+
125
+ /**
126
+ * @param {string} text
127
+ * @param {string} source
128
+ */
129
+ export function loadViews(text, source) {
130
+ if (typeof text !== "string") throw new TypeError("Spec input must be decoded JSON text");
131
+ const spec = parseJson(text, { source });
132
+ if (!validateSpec(spec)) throw new ReplayError(schemaMessage(validateSpec.errors?.[0]), { source });
133
+
134
+ const ids = new Set();
135
+ for (const view of spec.views) {
136
+ if (ids.has(view.id)) throw new ReplayError("Duplicate view ID", { source, viewId: view.id });
137
+ ids.add(view.id);
138
+ }
139
+ return spec.views;
140
+ }
141
+
142
+ /** @param {JsonValue} graph */
143
+ function checkGraph(graph) {
144
+ if (!validateGraph(graph)) throw new Error(schemaMessage(validateGraph.errors?.[0]));
145
+ const nodeIds = new Set(graph.nodes.map((node) => node.id));
146
+ const edgeIds = new Set(graph.edges.map((edge) => edge.id));
147
+ if (nodeIds.size !== graph.nodes.length) throw new Error("Graph node IDs must be unique");
148
+ if (edgeIds.size !== graph.edges.length) throw new Error("Graph edge IDs must be unique");
149
+ for (const edge of graph.edges) {
150
+ if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
151
+ throw new Error(`Edge ${JSON.stringify(edge.id)} refers to a missing node`);
152
+ }
153
+ }
154
+ }
155
+
156
+ /**
157
+ * @param {JsonValue} array
158
+ * @param {ItemBinding | undefined} binding
159
+ */
160
+ function checkArray(array, binding) {
161
+ if (!Array.isArray(array)) throw new Error("Expected an array");
162
+ if (!binding) return;
163
+ for (const [index, entry] of array.entries()) {
164
+ try {
165
+ resolveArrayItem(entry, binding);
166
+ } catch (error) {
167
+ throw new Error(`Array item ${index}: ${error instanceof Error ? error.message : String(error)}`);
168
+ }
169
+ }
170
+ }
171
+
172
+ /**
173
+ * @param {JsonObject} state
174
+ * @param {ViewSpec[]} views
175
+ * @param {string} source
176
+ * @param {number} line
177
+ */
178
+ export function checkBindings(state, views, source, line) {
179
+ for (const view of views) {
180
+ try {
181
+ const value = resolvePointer(state, view.source);
182
+ if (view.type === "graph") checkGraph(value);
183
+ else if (view.type === "array") checkArray(value, view.item);
184
+ } catch (error) {
185
+ throw new ReplayError(error instanceof Error ? error.message : String(error), { source, line, viewId: view.id, path: view.source });
186
+ }
187
+ }
188
+ }