@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.
@@ -0,0 +1,85 @@
1
+ :root {
2
+ color-scheme: light;
3
+ font-family: "Geist", "Inter", system-ui, sans-serif;
4
+ color: #182922;
5
+ background: #eef1ea;
6
+ --border: #cbd5cb;
7
+ --green: #176249;
8
+ --muted: #50635a;
9
+ --mono: "IBM Plex Mono", ui-monospace, monospace;
10
+ }
11
+
12
+ * { box-sizing: border-box; }
13
+ [hidden] { display: none !important; }
14
+ body { margin: 0; }
15
+ main { max-width: 75rem; margin: auto; padding: 2.4rem clamp(1rem, 4vw, 3rem); }
16
+ .header { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: .5rem 1rem; margin-bottom: 1.5rem; }
17
+ h1 { margin: 0; font-size: clamp(2rem, 6vw, 3rem); letter-spacing: -.07em; }
18
+ #source { color: var(--muted); font: .8rem var(--mono); overflow-wrap: anywhere; }
19
+ .timeline { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; padding: .85rem; border: 1px solid var(--border); border-radius: .85rem; background: #fff; margin-bottom: 1rem; }
20
+ .buttons { display: flex; gap: .5rem; }
21
+ button { min-height: 2.65rem; padding: .5rem .9rem; border: 1px solid var(--border); border-radius: .5rem; background: #f8faf7; color: inherit; font: inherit; font-weight: 650; cursor: pointer; }
22
+ #next { background: var(--green); border-color: var(--green); color: #fff; }
23
+ button:disabled { opacity: .45; cursor: default; }
24
+ button:focus-visible, input:focus-visible { outline: 2px solid var(--green); outline-offset: 3px; }
25
+ .seek { display: flex; flex: 1 1 12rem; align-items: center; gap: .7rem; font-size: .82rem; color: var(--muted); }
26
+ .seek input { flex: 1; min-width: 4rem; accent-color: var(--green); }
27
+ #step { font: .8rem var(--mono); font-variant-numeric: tabular-nums; }
28
+ #error { padding: .75rem; border: 1px solid #b75242; border-radius: .5rem; background: #fff4f0; color: #7f2c21; }
29
+ .views { display: flex; flex-wrap: wrap; align-items: stretch; gap: 1rem; }
30
+ .views[data-layout="column"] { display: grid; }
31
+ .view { flex: 1 1 18rem; min-width: 0; border: 1px solid var(--border); border-radius: .85rem; background: #fff; overflow: hidden; }
32
+ .view--graph { flex: 2 1 27rem; }
33
+ .view :is(h2, h3) { margin: 0; padding: .95rem 1rem; border-bottom: 1px solid #e5ebe4; font-size: 1rem; }
34
+ .graph { display: block; width: 100%; max-height: min(28rem, 54vh); min-height: 12rem; background: #f9fbf8; }
35
+ .graph-edge { fill: none; stroke: #6c8074; stroke-width: 1.5; }
36
+ .graph-arrow { fill: #6c8074; }
37
+ .graph-edge-label { fill: #253e33; font: 650 11px var(--mono); text-anchor: middle; dominant-baseline: central; paint-order: stroke; stroke: #f9fbf8; stroke-width: 4px; stroke-linejoin: round; pointer-events: none; }
38
+ .graph-node circle { fill: #f1f4ef; stroke: #98ac9d; stroke-width: 1.5; }
39
+ .graph-node[data-status="queued"] circle { fill: #f9ead1; stroke: #a87729; }
40
+ .graph-node[data-status="visited"] circle, .graph-node[data-status="settled"] circle { fill: #d9efe1; stroke: var(--green); }
41
+ .graph-node text { fill: #182922; font: 650 12px var(--mono); text-anchor: middle; }
42
+ .graph-node .graph-status { fill: var(--muted); font-size: 10px; font-weight: 500; paint-order: stroke; stroke: #f9fbf8; stroke-width: 3px; stroke-linejoin: round; }
43
+ .array { display: flex; flex-wrap: wrap; gap: .55rem; list-style: none; padding: 1rem; margin: 0; }
44
+ .array li { min-width: 2.6rem; max-width: 100%; padding: .5rem .7rem; border: 1px solid #cadbcf; border-radius: .5rem; background: #edf6ef; overflow-wrap: anywhere; }
45
+ .array code { font: .9rem var(--mono); white-space: pre-wrap; }
46
+ .array--labeled li { display: grid; gap: .15rem; min-width: 3.15rem; }
47
+ .array-label { color: var(--muted); font-size: .75rem; }
48
+ .empty { margin: 0; padding: 1rem; color: var(--muted); }
49
+
50
+ .inspection { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; gap: 1rem; margin-top: 1rem; }
51
+ .inspection details { min-width: 0; border: 1px solid var(--border); border-radius: .85rem; background: #fff; }
52
+ .inspection summary { padding: .95rem 1rem; font-weight: 650; cursor: pointer; }
53
+ .inspection details[open] summary { border-bottom: 1px solid #e5ebe4; }
54
+ [data-state-json], [data-change-list] { max-height: 22rem; overflow: auto; margin: 0; }
55
+ [data-state-json] { padding: 1rem; background: #f9fbf8; font: .82rem/1.5 var(--mono); border-radius: 0 0 .85rem .85rem; }
56
+ .changes { margin: 0; padding: 0; list-style: none; }
57
+ .changes li { padding: .75rem 1rem; border-bottom: 1px solid #e5ebe4; }
58
+ .changes li:last-child { border-bottom: 0; }
59
+ .change-heading { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: .3rem; }
60
+ .change-heading code { overflow-wrap: anywhere; font: .8rem var(--mono); }
61
+ .change-kind, .change-value-label { color: var(--muted); font-size: .75rem; }
62
+ .change-values { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .65rem; margin-top: .55rem; }
63
+ .change-values pre { min-height: 2rem; max-height: 6rem; overflow: auto; margin: .15rem 0 0; padding: .45rem; border-radius: .35rem; background: #f1f5ee; font: .78rem var(--mono); }
64
+ .change-values .change-absent { color: var(--muted); }
65
+
66
+ .episode-form, .episode-form label { display: flex; align-items: center; gap: .5rem; }
67
+ .episode-form label { color: var(--muted); font-size: .82rem; }
68
+ .episode-form input { width: 5.5rem; min-height: 2.65rem; border: 1px solid var(--border); border-radius: .5rem; padding: .5rem; font: 1rem var(--mono); }
69
+ #comparison { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; align-items: start; }
70
+ .comparison-run { min-width: 0; }
71
+ .comparison-run > header { margin-bottom: .8rem; }
72
+ .comparison-run > header h2 { margin: 0; overflow-wrap: anywhere; font-size: 1.25rem; }
73
+ [data-run-status] { margin: .3rem 0 0; color: var(--muted); font: .84rem var(--mono); }
74
+ .comparison-run .inspection { gap: .6rem; }
75
+
76
+ @media (max-width: 48rem) {
77
+ .inspection { grid-template-columns: 1fr; }
78
+ #comparison { grid-template-columns: 1fr; }
79
+ }
80
+
81
+ @media (max-width: 34rem) {
82
+ .timeline { gap: .7rem; }
83
+ .seek { order: 1; flex-basis: 100%; }
84
+ #step { margin-left: auto; }
85
+ }
@@ -0,0 +1,40 @@
1
+ import { resolvePointer } from "../../packages/core/json-pointer.js";
2
+ import { renderArray, renderGraph } from "../../packages/renderers/index.js";
3
+
4
+ function viewTitle(id) {
5
+ const title = id.replaceAll(/[-_]+/g, " ").trim() || id;
6
+ return title.charAt(0).toUpperCase() + title.slice(1);
7
+ }
8
+
9
+ export function attachViews(container, specText, renderers, runIndex, headingTag = "h2") {
10
+ const spec = JSON.parse(specText);
11
+ container.dataset.layout = spec.layout;
12
+ const panels = spec.views.map((view, index) => {
13
+ const title = viewTitle(view.id);
14
+ const key = `${runIndex}-${index}`;
15
+ const panel = document.createElement("section");
16
+ panel.className = `view view--${view.type}`;
17
+ panel.dataset.viewId = view.id;
18
+ const heading = document.createElement(headingTag);
19
+ heading.id = `view-heading-${key}`;
20
+ heading.textContent = title;
21
+ panel.setAttribute("aria-labelledby", heading.id);
22
+ const content = document.createElement("div");
23
+ panel.append(heading, content);
24
+ container.append(panel);
25
+ let render;
26
+ if (view.type === "graph") render = (data) => renderGraph(data, title, key);
27
+ if (view.type === "array") render = (data) => renderArray(data, view.item);
28
+ if (view.type === "custom") {
29
+ const custom = renderers.get(view.renderer);
30
+ if (typeof custom !== "function") throw new Error(`Missing renderer "${view.renderer}"`);
31
+ render = (data) => custom(data, { id: view.id, title });
32
+ }
33
+ return { view, render, content };
34
+ });
35
+
36
+ return (frame) => {
37
+ const nodes = panels.map(({ view, render }) => render(resolvePointer(frame.state, view.source)));
38
+ for (const [index, panel] of panels.entries()) panel.content.replaceChildren(nodes[index]);
39
+ };
40
+ }
@@ -0,0 +1,105 @@
1
+ # AlgoViz protocol v1
2
+
3
+ The inspector consumes a JSON Visualization Specification and a JSONL Execution Trace for one run. The spec and the trace's initial record both declare `version: 1`. Reject missing, unsupported, or mismatched versions. Encode both files as UTF-8 without a byte order mark. Reject duplicate member names in JSON objects so each binding and update has a single interpretation.
4
+
5
+ ## Visualization Specification
6
+
7
+ Validate the spec against the [Visualization Specification schema](../schemas/visualization-spec.schema.json). The `views` list determines display order. The `layout` field arranges views left to right (`row`) or top to bottom (`column`). Each view has a unique `id`, a `type`, and a `source` pointer into the state of the current step. The empty pointer binds the whole state.
8
+
9
+ | View type | Data at `source` |
10
+ | --- | --- |
11
+ | `graph` | A graph with `nodes` and `edges` arrays. Nodes have stable `id` values and optional `label` and `status` text. Each edge has a stable `id` plus `source` and `target` node IDs. Starting in `v0.2.0`, text and numeric values in `edge.label` appear beside the edge. Other values remain valid graph metadata. [Graph view data schema](../schemas/graph-view-data.schema.json) |
12
+ | `array` | A JSON array in display order. For a frontier queue, index `0` holds the next item. Starting in `v0.2.0`, an optional `item` binding displays a label and value for each entry. |
13
+ | `custom` | A JSON value passed to a renderer registered explicitly when building the offline preview. The required `renderer` field names that registration. |
14
+
15
+ Use unique node IDs and unique edge IDs within a graph. Both endpoints of each edge must resolve to nodes in that graph at the same step. Keep an element's ID stable as its data changes. Resolve and validate every binding at step 0 and after applying all updates in each subsequent step. Each view reads the same committed state.
16
+
17
+ ### Labeled Array items
18
+
19
+ An Array view's `item.label` and `item.value` are JSON Pointers evaluated against each array entry. The label resolves to a string and the value resolves to any JSON value. Both pointers must resolve in every frame. `item.nullLabel` sets the display text for an explicit `null` value. It defaults to `—`.
20
+
21
+ ```json
22
+ {
23
+ "id": "tentative-distances",
24
+ "type": "array",
25
+ "source": "/distances",
26
+ "item": { "label": "/vertex", "value": "/distance", "nullLabel": "∞" }
27
+ }
28
+ ```
29
+
30
+ The entry `{"vertex":"S","distance":0}` displays `S` with value `0`. The entry `{"vertex":"U","distance":null}` displays `U` with `∞`. Array views without `item` display their entries directly, including the original `v0.1.0` specs.
31
+
32
+ ### Custom renderers
33
+
34
+ A view such as `{"id":"lake","type":"custom","renderer":"frozenlake","source":""}` binds the whole frame to the named renderer. Names start with a lowercase letter and can contain lowercase letters, digits, and hyphens.
35
+
36
+ Pass `--renderer frozenlake=./grid.js` to `preview` or `compare` to register one local browser module. Its default export is a synchronous function `(data, { id, title }) => Node` called for each displayed frame. Return a DOM node or fragment. The bound data comes from a detached replay frame and should be treated as read-only. The replay engine checks that the pointer resolves. The renderer defines the expected shape of its data.
37
+
38
+ The CLI bundles the module and its browser-compatible imports into the output HTML. The spec selects a registered renderer by name. Registration and the module path come from the CLI caller. The bundled module executes as JavaScript with access to the preview page and its embedded traces, so register code you trust before opening or sharing the HTML. [FrozenLake renderer](../examples/frozenlake/grid.js)
39
+
40
+ ### Comparing two runs
41
+
42
+ `algoviz compare <left-spec> <left-trace> <right-spec> <right-trace>` reads two v1 runs. Each state has zero-based integer `episode` and `t` fields. Both start at zero. Within an episode, `t` increases by one per trace step. A reset advances `episode` by one and sets `t` to zero. When both runs record a string at `/last/action`, the viewer can jump to the first differing action in the selected episode.
43
+
44
+ The common position is the episode and its step, with steps available up to the longer run's episode length. A shorter run shows where the episode ended or that the episode is unavailable. Each run's Changes panel compares its selected state with its own preceding recorded step. [FrozenLake example](../examples/frozenlake/README.md#compare-the-runs)
45
+
46
+ ## Execution Trace
47
+
48
+ Write one JSON record per line, using LF or CRLF with an optional final newline. Reject empty files and blank lines. Validate each record against the [trace record schema](../schemas/trace-record.schema.json).
49
+
50
+ | Record | Position | Meaning |
51
+ | --- | --- | --- |
52
+ | `initial` | First line, exactly once | Holds `version` and the initial `state` object. This is step 0. |
53
+ | `step` | Every subsequent line | Holds an `updates` array and optional `events`. Each record advances the step index by one, even when `updates` is empty. |
54
+
55
+ Apply each `updates` array in order to a candidate state starting from the last committed state. An operation sees the results of earlier operations in its batch. Publish the new state and its events together after every operation and binding check succeeds. On failure, reject the whole step, preserve the previous committed state, and stop at the failed line. Semantic events can inform presentation. Only state updates determine the next state, and replay works when an event type is unknown.
56
+
57
+ ### State updates
58
+
59
+ Use `add`, `remove`, and `replace` from JSON Patch. `path` is a JSON Pointer to a member or array element within the state object. Updates to the root are invalid. Pointer tokens decode `~1` to `/`, then `~0` to `~`. Resolve each intermediate token against an existing object member or array element. Array indices are zero-based decimal integers with no leading zeroes except `0`. [JSON Patch](https://www.rfc-editor.org/rfc/rfc6902#section-4) · [JSON Pointer](https://www.rfc-editor.org/rfc/rfc6901#section-4)
60
+
61
+ | Operation | Object member | Array element |
62
+ | --- | --- | --- |
63
+ | `add` | Set the member to `value`, creating it when absent. | Insert `value` at an index from `0` through the current length. Use `-` to append. |
64
+ | `remove` | Delete an existing member. | Delete an existing element and shift later elements left. |
65
+ | `replace` | Set an existing member to `value`. | Set an existing element to `value` without changing the array length. |
66
+
67
+ `add` and `replace` require `value`, which can be any JSON value, including `null`. `remove` takes only `op` and `path`. Report an error for a missing or scalar parent, a missing `remove` or `replace` target, or an array index outside the operation's allowed range. Treat object keys as literal member names. For example, `/frontier/00` fails when `frontier` is an array. The array append token `-` is valid only as the final array token of `add`.
68
+
69
+ ### Validation errors
70
+
71
+ For an invalid spec, report the file, the location of the offending field when available, and the reason. For an invalid trace, report the file, one-based line number, and reason. For a failing update, also report the zero-based operation index and the update path. For a failing view binding, include the view ID and source pointer. Schema validation checks each document or record's shape. The reader must also enforce record order, apply state updates, and validate bindings and graph references at each step.
72
+
73
+ ## Graph-search fixture
74
+
75
+ The example traces breadth-first search from node `A`. It visits outgoing neighbors in the order shown in the graph, queues each node at discovery, and records a step after each node is processed. `B` discovers `D`, so processing `C` leaves `D` in the queue once.
76
+
77
+ - [Spec](../examples/graph-search/visualization.json) binds the views to `/graph` and `/frontier`.
78
+ - [Trace](../examples/graph-search/trace.jsonl) records the initial state and four steps.
79
+ - [Expected states](../examples/graph-search/expected-states.json) lists the fixed edge set, each node's status, and the ordered frontier at every step. Compare against these values after committing each step.
80
+
81
+ ## Replay API
82
+
83
+ The [replay core](../packages/core/index.js) takes decoded JSONL text. Supply `specText` to validate the spec and its bindings at each reconstructed step. Optional `traceSource` and `specSource` names appear in `ReplayError` diagnostics.
84
+
85
+ ```js
86
+ import { readFileSync } from "node:fs";
87
+ import { loadReplay } from "./packages/core/index.js";
88
+
89
+ const replay = loadReplay(readFileSync("examples/graph-search/trace.jsonl", "utf8"), {
90
+ specText: readFileSync("examples/graph-search/visualization.json", "utf8"),
91
+ });
92
+
93
+ console.log(replay.current.state.frontier); // ["A"]
94
+ console.log(replay.stepForward().state.frontier); // ["B", "C"]
95
+ console.log(replay.stepBackward().state.frontier); // ["A"]
96
+ console.log(replay.seek(replay.lastStep).state.frontier); // []
97
+ ```
98
+
99
+ `current`, `stepForward()`, `stepBackward()`, and `seek(step)` return detached frames containing `step`, `state`, and `events`. Stepping at an endpoint returns its current frame. Seeking outside `0` through `lastStep` throws a `RangeError`.
100
+
101
+ The offline preview's State panel shows the selected frame's JSON state. The Changes panel compares it with the preceding recorded step using JSON Pointer paths. Step 0 is the initial state. Added or removed values display `Absent` on the missing side, while an explicit JSON `null` displays as `null`. Array entries are compared by index.
102
+
103
+ `replay.changes` returns the same net comparison as detached `added`, `removed`, and `changed` records. Each record includes its path and the applicable `before` and `after` values. The comparison follows recorded step order regardless of navigation direction. Writes that cancel within a batch produce no change for that path.
104
+
105
+ Loading validates record structure and the initial view bindings when a spec is supplied. Navigation checks update targets and view bindings as it reconstructs steps. A failed step or seek throws a `ReplayError` and preserves the current frame and its change baseline. Replay keeps the initial state, the selected state, and its preceding state when available. An optional positive integer `checkpointInterval` also caches reconstructed states at that interval for seeking. The comparison uses checkpoints every 128 trace steps. Decoding bytes is the caller's responsibility. The JavaScript reader rejects numbers that decode outside its finite range.
@@ -0,0 +1,96 @@
1
+ # Compare two FrozenLake Q-learning runs
2
+
3
+ Why did two Q-learning runs with different exploration rates take different paths through the same grid? This fixture records two tabular Q-learning runs in deterministic FrozenLake. It then checks the traces independently and finds the first action where a selected episode diverges.
4
+
5
+ Both runs use the default 4×4 map, seed `7`, learning rate `0.5`, and discount `0.95`. Only the exploration rate differs: `ε = 0.1` and `ε = 0.3`. Each run trains until it has recorded at least 10,000 transitions and then finishes its current episode. [Run settings](runs.json)
6
+
7
+ | Run | Episodes | Transitions | Trace states |
8
+ | --- | ---: | ---: | ---: |
9
+ | `epsilon-0.1` | 1,557 | 10,000 | 11,557 |
10
+ | `epsilon-0.3` | 1,448 | 10,000 | 11,448 |
11
+
12
+ Both runs happen to finish an episode on exactly the 10,000th transition.
13
+
14
+ ## Environment
15
+
16
+ [frozenlake.py](frozenlake.py) implements Gymnasium's `FrozenLake-v1` with `is_slippery=False` using only the Python standard library. It follows the documented map, the action encoding `0 = LEFT`, `1 = DOWN`, `2 = RIGHT`, `3 = UP`, the observation `row * ncol + col`, the reward schedule `(1, 0, 0)`, and the 100-step time limit. A move off the grid leaves the agent in place. [FrozenLake documentation](https://gymnasium.farama.org/environments/toy_text/frozen_lake/)
17
+
18
+ The learner chooses a uniformly random action with probability ε. Otherwise it picks the highest-valued action, breaking ties at random. With equal seeds, the two runs make identical choices through episode 115. When the action values at a cell are tied, a greedy choice and an exploratory choice consume the same random draws. The runs first take different actions in episode 116.
19
+
20
+ ## Record and check the runs
21
+
22
+ From the AlgoViz checkout, use Node.js 22 or later and Python 3.10 or later:
23
+
24
+ ```sh
25
+ npm ci
26
+ python -B examples/frozenlake/record.py frozenlake-runs
27
+ node examples/frozenlake/check.mjs frozenlake-runs
28
+ ```
29
+
30
+ [record.py](record.py) writes `frozenlake-runs/<run>/trace.jsonl` and a `visualization.json` for each run. It mirrors the learner's resets and transitions into the observable state and passes each state to [algoviz_trace.py](algoviz_trace.py). This small standard-library recorder writes the v1 `add`, `remove`, and `replace` updates that reproduce the state. Each trace is about 4.5 MB.
31
+
32
+ Step 0 is the reset for episode 0. Each transition and each later reset advances the trace by one step. In every state, `episode` and `t` locate the step within its episode, `cell` is the observation, and `last` holds the transition that produced it. `q` holds the action values in `LEFT`, `DOWN`, `RIGHT`, `UP` order.
33
+
34
+ [check.mjs](check.mjs) replays each trace with the AlgoViz core and checks it against a separate JavaScript implementation of the environment and learner. It does not share code with the recorder. For every recorded step, it checks the following:
35
+
36
+ - **Transitions.** Each action moves the agent to the cell, reward, termination, and truncation that FrozenLake gives. The episode return, path, totals, and the single updated action value follow the Q-learning update. A greedy action must be one of the highest-valued actions.
37
+ - **Resets.** A new episode follows every terminated or truncated episode and only those. It returns the agent to cell 0 with step 0, return 0, and the learned values unchanged. The trace ends with a finished episode.
38
+ - **Separate run.** Running [frozenlake.py](frozenlake.py) without the recorder produces summaries of the same runs. The checker compares each episode's length, return, outcome, and action sequence with them.
39
+
40
+ The checker prints each trace's SHA-256 digest, along with the comparison below. [expected.json](expected.json) holds the expected report. The tests regenerate both runs and require an exact match.
41
+
42
+ ## Compare the runs
43
+
44
+ Build the offline comparison from the repository root after recording both traces:
45
+
46
+ ```sh
47
+ npm run algoviz -- compare examples/frozenlake/comparison.json frozenlake-runs/epsilon-0.1/trace.jsonl examples/frozenlake/comparison.json frozenlake-runs/epsilon-0.3/trace.jsonl --renderer frozenlake=examples/frozenlake/grid.js --output frozenlake.html
48
+ ```
49
+
50
+ Open the printed file URL. Enter episode **1008**, select **Go**, then select **First differing action**. The grid, action values, episode path, State, and Changes all follow the selected position. At step 6, the right run shows that it ended at step 5. At episode 1500, only the left run has data. [Renderer contract](../../docs/protocol.md#custom-renderers)
51
+
52
+ ## Run from an npm installation
53
+
54
+ The package includes the Python recorder, its FrozenLake adapter, and the comparison spec and renderer. From an empty directory with Node.js 22+ and Python 3.10+, install `@flowsilver/algoviz` when the package is available on npm. To verify an unpublished release candidate, install its packed `.tgz` in place of the registry name.
55
+
56
+ ```sh
57
+ npm install @flowsilver/algoviz
58
+ python -B node_modules/@flowsilver/algoviz/examples/frozenlake/record.py frozenlake-runs
59
+ node node_modules/@flowsilver/algoviz/examples/frozenlake/check.mjs frozenlake-runs
60
+ npm exec -- algoviz compare node_modules/@flowsilver/algoviz/examples/frozenlake/comparison.json frozenlake-runs/epsilon-0.1/trace.jsonl node_modules/@flowsilver/algoviz/examples/frozenlake/comparison.json frozenlake-runs/epsilon-0.3/trace.jsonl --renderer frozenlake=node_modules/@flowsilver/algoviz/examples/frozenlake/grid.js --output frozenlake.html
61
+ ```
62
+
63
+ The Python module `algoviz_trace.py` has no third-party dependencies. Reuse its `TraceWriter` in an adapter by giving it a writable UTF-8 file and the initial state, then calling `step(state)` with the observed state after each transition.
64
+
65
+ ## Episode 1008
66
+
67
+ In episode 1008, the `ε = 0.1` run reaches the goal and the `ε = 0.3` run falls into a hole. Both runs take `DOWN`, `DOWN`, `RIGHT`, `DOWN` and reach cell 13. In both runs, the learned values there favor `RIGHT`.
68
+
69
+ | Step 5 | `epsilon-0.1` | `epsilon-0.3` |
70
+ | --- | --- | --- |
71
+ | Trace step | 7517 | 8089 |
72
+ | Values at cell 13 (L, D, R, U) | 0, 0.9025, 0.95, 0.8574 | 0, 0.9025, 0.95, 0.8574 |
73
+ | Action | `RIGHT`, greedy | `LEFT`, exploratory |
74
+ | Resulting cell and reward | 14, 0 | 12 (hole), 0 |
75
+ | Episode outcome | Goal after 6 steps, return 1 | Hole after 5 steps, return 0 |
76
+
77
+ The values are rounded here. The `ε = 0.3` run did not learn a different preference at cell 13. It took an exploratory action. `LEFT` still has value 0 in both runs because a hole gives the same reward as an unexplored move. The `ε = 0.3` run ends one step earlier. Episode 1008 starts at trace step 7512 in `epsilon-0.1` and 8084 in `epsilon-0.3`.
78
+
79
+ To inspect one run now, preview it and seek to its trace step:
80
+
81
+ ```sh
82
+ npm run algoviz -- preview frozenlake-runs/epsilon-0.3/visualization.json frozenlake-runs/epsilon-0.3/trace.jsonl
83
+ ```
84
+
85
+ Expand **State** to read `last`, `cell`, `status`, and `q` at the selected step.
86
+
87
+ ## Fault injection
88
+
89
+ The tests change one trace line, then confirm that the altered trace still passes v1 replay and that the checker rejects it. The checker reports the run, line, episode, and step within the episode:
90
+
91
+ ```text
92
+ epsilon-0.1 epsilon-0.1/trace.jsonl:7519 episode 1008 step 6: reward is 0, expected 1
93
+ epsilon-0.3 epsilon-0.3/trace.jsonl:8085 episode 1008 step 0: reset placed the agent at cell 1, expected the start cell 0
94
+ ```
95
+
96
+ The first line comes from setting the goal reward to 0. The second comes from resetting the agent to cell 1. A third test checks a trace against a separate run with another seed and reports the first episode whose summary differs.
@@ -0,0 +1,69 @@
1
+ """Write AlgoViz v1 execution traces from Python using only the standard library.
2
+
3
+ Pass the complete observable state to ``TraceWriter.step``. The writer compares it
4
+ with the previous state and records the JSON Patch ``add``, ``remove``, and
5
+ ``replace`` operations that reproduce it.
6
+ """
7
+
8
+ import json
9
+
10
+
11
+ def _token(key):
12
+ return str(key).replace("~", "~0").replace("/", "~1")
13
+
14
+
15
+ def _same(before, after):
16
+ return type(before) is type(after) and before == after
17
+
18
+
19
+ def diff(before, after, path=""):
20
+ """Return v1 state updates that turn ``before`` into ``after``."""
21
+ if isinstance(before, dict) and isinstance(after, dict):
22
+ updates = [{"op": "remove", "path": f"{path}/{_token(key)}"} for key in before if key not in after]
23
+ for key, value in after.items():
24
+ member = f"{path}/{_token(key)}"
25
+ if key not in before:
26
+ updates.append({"op": "add", "path": member, "value": value})
27
+ else:
28
+ updates.extend(diff(before[key], value, member))
29
+ return updates
30
+ if isinstance(before, list) and isinstance(after, list):
31
+ if len(after) == len(before):
32
+ return [update for index, (old, new) in enumerate(zip(before, after)) for update in diff(old, new, f"{path}/{index}")]
33
+ if len(after) > len(before) and all(_same(old, new) for old, new in zip(before, after)):
34
+ return [{"op": "add", "path": f"{path}/-", "value": value} for value in after[len(before):]]
35
+ if _same(before, after) and not isinstance(before, (dict, list)):
36
+ return []
37
+ if not path:
38
+ raise ValueError("the trace state must remain a JSON object")
39
+ return [{"op": "replace", "path": path, "value": after}]
40
+
41
+
42
+ def _encode(record):
43
+ return json.dumps(record, separators=(",", ":"), ensure_ascii=False, allow_nan=False) + "\n"
44
+
45
+
46
+ def _snapshot(state):
47
+ return json.loads(json.dumps(state, allow_nan=False))
48
+
49
+
50
+ class TraceWriter:
51
+ """Record one run as a v1 JSONL trace."""
52
+
53
+ def __init__(self, file, state):
54
+ if not isinstance(state, dict):
55
+ raise TypeError("the initial state must be a dict")
56
+ self._file = file
57
+ self._state = _snapshot(state)
58
+ self.steps = 0
59
+ file.write(_encode({"type": "initial", "version": 1, "state": self._state}))
60
+
61
+ def step(self, state, events=None):
62
+ """Record the next state and optional ``{"type": ..., "data": {...}}`` events."""
63
+ after = _snapshot(state)
64
+ record = {"type": "step", "updates": diff(self._state, after)}
65
+ if events:
66
+ record["events"] = events
67
+ self._file.write(_encode(record))
68
+ self._state = after
69
+ self.steps += 1