@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,265 @@
1
+ #!/usr/bin/env node
2
+ // Validate recorded FrozenLake Q-learning traces independently of the Python recorder.
3
+ // The environment rules and Q-learning update below are a separate implementation of
4
+ // Gymnasium FrozenLake-v1 with is_slippery=False.
5
+ import { execFileSync } from "node:child_process";
6
+ import { createHash } from "node:crypto";
7
+ import { readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { isDeepStrictEqual, parseArgs } from "node:util";
11
+ import { loadReplay } from "../../packages/core/index.js";
12
+
13
+ const here = fileURLToPath(new URL(".", import.meta.url));
14
+ const MAPS = {
15
+ "4x4": ["SFFF", "FHFH", "FFFH", "HFFG"],
16
+ "8x8": ["SFFFFFFF", "FFFFFFFF", "FFFHFFFF", "FFFFFHFF", "FFFHFFFF", "FHHFFFHF", "FHFFHFHF", "FFFHFFFG"],
17
+ };
18
+ const TIME_LIMITS = { "4x4": 100, "8x8": 200 };
19
+ const ACTIONS = ["LEFT", "DOWN", "RIGHT", "UP"];
20
+ const MOVES = { LEFT: [0, -1], DOWN: [1, 0], RIGHT: [0, 1], UP: [-1, 0] };
21
+ const REWARDS = { G: 1, H: 0, F: 0, S: 0 };
22
+ const TOTALS = { goal: "goals", hole: "holes", truncated: "truncations" };
23
+ const STATE_KEYS = ["run", "map", "episode", "t", "cell", "status", "episodeReturn", "path", "last", "q", "totals"];
24
+ const LAST_KEYS = ["from", "action", "explored", "to", "reward", "terminated", "truncated"];
25
+
26
+ export class FixtureError extends Error {
27
+ /**
28
+ * @param {string} message
29
+ * @param {{ run: string, source: string, line?: number, episode?: number, step?: number }} location
30
+ */
31
+ constructor(message, { run, source, line, episode, step }) {
32
+ const where = [`${run} ${line === undefined ? source : `${source}:${line}`}`];
33
+ if (episode !== undefined) where.push(`episode ${episode}`);
34
+ if (step !== undefined) where.push(`step ${step}`);
35
+ super(`${where.join(" ")}: ${message}`);
36
+ this.name = "FixtureError";
37
+ Object.assign(this, { run, source, line, episode, step });
38
+ }
39
+ }
40
+
41
+ const show = (value) => JSON.stringify(value);
42
+
43
+ function argmax(values) {
44
+ const best = Math.max(...values);
45
+ return ACTIONS.filter((_, index) => values[index] === best);
46
+ }
47
+
48
+ function outcomeOf(tile, terminated, truncated) {
49
+ if (terminated) return tile === "G" ? "goal" : "hole";
50
+ return truncated ? "truncated" : "running";
51
+ }
52
+
53
+ /** Compare the recorded state with the expected one, field by field, in a fixed order. */
54
+ function compareState(actual, expected) {
55
+ const extra = Object.keys(actual).filter((key) => !STATE_KEYS.includes(key));
56
+ if (extra.length) return `unexpected state field ${show(extra[0])}`;
57
+ for (const key of STATE_KEYS) {
58
+ if (key === "last" && expected.last && actual.last && typeof actual.last === "object") {
59
+ for (const field of LAST_KEYS) {
60
+ if (!isDeepStrictEqual(actual.last[field], expected.last[field])) {
61
+ return `${field} is ${show(actual.last[field])}, expected ${show(expected.last[field])}`;
62
+ }
63
+ }
64
+ const extraLast = Object.keys(actual.last).filter((field) => !LAST_KEYS.includes(field));
65
+ if (extraLast.length) return `unexpected last-transition field ${show(extraLast[0])}`;
66
+ continue;
67
+ }
68
+ if (!isDeepStrictEqual(actual[key], expected[key])) {
69
+ if (key === "q") {
70
+ for (const [cell, row] of expected.q.entries()) {
71
+ for (const [index, value] of row.entries()) {
72
+ const found = actual.q?.[cell]?.[index];
73
+ if (!Object.is(found, value)) return `Q[${cell}][${ACTIONS[index]}] is ${show(found)}, expected ${show(value)}`;
74
+ }
75
+ }
76
+ }
77
+ return `${key} is ${show(actual[key])}, expected ${show(expected[key])}`;
78
+ }
79
+ }
80
+ return undefined;
81
+ }
82
+
83
+ /**
84
+ * Validate one recorded run and return its per-episode summaries and transitions.
85
+ * @param {{ traceText: string, specText?: string, source?: string, settings: any, run: any }} input
86
+ */
87
+ export function checkRun({ traceText, specText, source = "trace.jsonl", settings, run }) {
88
+ const map = MAPS[settings.map];
89
+ const limit = TIME_LIMITS[settings.map];
90
+ if (!map) throw new Error(`Unsupported FrozenLake map ${show(settings.map)}`);
91
+ const ncol = map[0].length;
92
+ const tile = (cell) => map[Math.floor(cell / ncol)][cell % ncol];
93
+ const move = (cell, action) => {
94
+ const [dr, dc] = MOVES[action];
95
+ const row = Math.min(Math.max(Math.floor(cell / ncol) + dr, 0), map.length - 1);
96
+ const col = Math.min(Math.max((cell % ncol) + dc, 0), ncol - 1);
97
+ return row * ncol + col;
98
+ };
99
+
100
+ const replay = loadReplay(traceText, { specText, traceSource: source });
101
+ const fail = (message, frame) => {
102
+ throw new FixtureError(message, {
103
+ run: run.id, source, line: frame.step + 1, episode: frame.state.episode, step: frame.state.t,
104
+ });
105
+ };
106
+
107
+ const initial = {
108
+ run: { id: run.id, seed: run.seed, epsilon: run.epsilon, alpha: settings.alpha, gamma: settings.gamma },
109
+ map: [...map],
110
+ episode: 0, t: 0, cell: 0, status: "running", episodeReturn: 0, path: [0], last: null,
111
+ q: Array.from({ length: map.length * ncol }, () => ACTIONS.map(() => 0)),
112
+ totals: { transitions: 0, goals: 0, holes: 0, truncations: 0 },
113
+ };
114
+ let frame = replay.current;
115
+ const initialError = compareState(frame.state, initial);
116
+ if (initialError) fail(`initial state: ${initialError}`, frame);
117
+
118
+ const episodes = [];
119
+ /** @type {any[]} */
120
+ let transitions = [];
121
+ for (let index = 1; index <= replay.lastStep; index++) {
122
+ const previous = frame.state;
123
+ frame = replay.stepForward();
124
+ const state = frame.state;
125
+ let expected;
126
+ if (previous.status === "running") {
127
+ const action = state.last?.action;
128
+ if (!ACTIONS.includes(action)) fail(`action is ${show(action)}, expected one of ${ACTIONS.join(", ")}`, frame);
129
+ if (typeof state.last.explored !== "boolean") fail(`explored is ${show(state.last.explored)}, expected a boolean`, frame);
130
+ const t = previous.t + 1;
131
+ const to = move(previous.cell, action);
132
+ const reward = REWARDS[tile(to)];
133
+ const terminated = "GH".includes(tile(to));
134
+ // Gymnasium's TimeLimit wrapper truncates at the limit even when the step also terminates.
135
+ const truncated = t >= limit;
136
+ const status = outcomeOf(tile(to), terminated, truncated);
137
+ const a = ACTIONS.indexOf(action);
138
+ const q = structuredClone(previous.q);
139
+ const target = terminated ? reward : reward + settings.gamma * Math.max(...previous.q[to]);
140
+ q[previous.cell][a] = previous.q[previous.cell][a] + settings.alpha * (target - previous.q[previous.cell][a]);
141
+ const totals = { ...previous.totals, transitions: previous.totals.transitions + 1 };
142
+ if (status !== "running") totals[TOTALS[status]] += 1;
143
+ expected = {
144
+ ...previous, episode: previous.episode, t, cell: to, status,
145
+ episodeReturn: previous.episodeReturn + reward, path: [...previous.path, to],
146
+ last: { from: previous.cell, action, explored: state.last.explored, to, reward, terminated, truncated },
147
+ q, totals,
148
+ };
149
+ const error = compareState(state, expected);
150
+ if (error) fail(error, frame);
151
+ const greedy = argmax(previous.q[previous.cell]);
152
+ if (!state.last.explored && !greedy.includes(action)) {
153
+ fail(`greedy action ${action} is not among the highest-valued actions ${greedy.join(", ")}`, frame);
154
+ }
155
+ transitions.push({
156
+ traceStep: frame.step, t, from: previous.cell, values: previous.q[previous.cell], greedy, action, explored: state.last.explored,
157
+ cell: to, reward, status, episodeReturn: expected.episodeReturn,
158
+ });
159
+ if (status !== "running") {
160
+ episodes.push({
161
+ episode: state.episode, steps: t, return: expected.episodeReturn, outcome: status,
162
+ actions: transitions.map((step) => step.action[0]).join(""), endStep: frame.step, transitions,
163
+ });
164
+ transitions = [];
165
+ }
166
+ } else {
167
+ expected = { ...previous, episode: previous.episode + 1, t: 0, cell: 0, status: "running", episodeReturn: 0, path: [0], last: null };
168
+ if (state.cell !== 0) fail(`reset placed the agent at cell ${show(state.cell)}, expected the start cell 0`, frame);
169
+ const error = compareState(state, expected);
170
+ if (error) fail(`reset: ${error}`, frame);
171
+ }
172
+ }
173
+ if (frame.state.status === "running") fail("the trace ends before the episode terminates or truncates", frame);
174
+ const total = frame.state.totals.transitions;
175
+ if (total < settings.minTransitions) fail(`recorded ${total} transitions, expected at least ${settings.minTransitions}`, frame);
176
+ return { run: run.id, states: replay.lastStep + 1, transitions: total, episodes };
177
+ }
178
+
179
+ /** Compare trace-derived episode summaries with those of a separate uninstrumented run. */
180
+ export function checkSummaries(result, uninstrumented, source = "trace.jsonl") {
181
+ const recorded = result.episodes;
182
+ for (let index = 0; index < Math.max(recorded.length, uninstrumented.length); index++) {
183
+ const episode = recorded[index];
184
+ const expected = uninstrumented[index];
185
+ const summary = episode && { episode: episode.episode, steps: episode.steps, return: episode.return, outcome: episode.outcome, actions: episode.actions };
186
+ if (!isDeepStrictEqual(summary, expected)) {
187
+ throw new FixtureError(
188
+ `trace summary ${show(summary ?? "missing")} differs from the uninstrumented run ${show(expected ?? "missing")}`,
189
+ { run: result.run, source, line: episode && episode.endStep + 1, episode: episode?.episode ?? expected?.episode },
190
+ );
191
+ }
192
+ }
193
+ }
194
+
195
+ /** Align one episode of two checked runs by step within the episode and find the first differing action. */
196
+ export function compareEpisode(results, episode) {
197
+ const [a, b] = results.map((result) => {
198
+ const found = result.episodes[episode];
199
+ if (!found) throw new Error(`${result.run} has no episode ${episode}`);
200
+ return found;
201
+ });
202
+ let step = 1;
203
+ while (step <= Math.min(a.steps, b.steps) && a.transitions[step - 1].action === b.transitions[step - 1].action) step++;
204
+ const describe = (result, found) => {
205
+ const { traceStep, ...transition } = found.transitions[step - 1] ?? {};
206
+ return [result.run, found.transitions[step - 1] ? { traceStep, ...transition } : { ended: true, steps: found.steps, outcome: found.outcome }];
207
+ };
208
+ return {
209
+ episode,
210
+ firstDifference: step > Math.max(a.steps, b.steps) ? null : {
211
+ step,
212
+ runs: Object.fromEntries([describe(results[0], a), describe(results[1], b)]),
213
+ },
214
+ outcomes: Object.fromEntries(results.map((result, index) => {
215
+ const found = [a, b][index];
216
+ const resetStep = found.endStep - found.steps;
217
+ return [result.run, { resetStep, steps: found.steps, return: found.return, outcome: found.outcome, actions: found.actions }];
218
+ })),
219
+ };
220
+ }
221
+
222
+ function uninstrumentedSummaries(runId, runsPath) {
223
+ const python = process.env.ALGOVIZ_PYTHON ?? "python";
224
+ const output = execFileSync(python, ["-B", join(here, "frozenlake.py"), runId, "--runs", runsPath], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
225
+ return JSON.parse(output).episodes;
226
+ }
227
+
228
+ /**
229
+ * Check every configured run recorded under `directory` and compare them at `episode`.
230
+ * @param {string} directory
231
+ * @param {{ runsPath?: string, episode?: number }} [options]
232
+ */
233
+ export function checkRecording(directory, { runsPath = join(here, "runs.json"), episode } = {}) {
234
+ const config = JSON.parse(readFileSync(runsPath, "utf8"));
235
+ const results = config.runs.map((run) => {
236
+ const source = `${run.id}/trace.jsonl`;
237
+ const traceBytes = readFileSync(join(directory, run.id, "trace.jsonl"));
238
+ const specText = readFileSync(join(directory, run.id, "visualization.json"), "utf8");
239
+ const result = checkRun({ traceText: traceBytes.toString("utf8"), specText, source, settings: config.settings, run });
240
+ checkSummaries(result, uninstrumentedSummaries(run.id, runsPath), source);
241
+ return { ...result, sha256: createHash("sha256").update(traceBytes).digest("hex") };
242
+ });
243
+ return {
244
+ runs: Object.fromEntries(results.map(({ run, states, transitions, episodes, sha256 }) => [run, { states, transitions, episodes: episodes.length, sha256 }])),
245
+ comparison: episode === undefined ? undefined : compareEpisode(results, episode),
246
+ };
247
+ }
248
+
249
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
250
+ const { positionals, values } = parseArgs({ allowPositionals: true, options: { runs: { type: "string" }, episode: { type: "string" } } });
251
+ if (positionals.length !== 1) {
252
+ console.error("Usage: node examples/frozenlake/check.mjs <recorded-directory> [--episode <n>] [--runs <runs.json>]");
253
+ process.exit(2);
254
+ }
255
+ const expected = JSON.parse(readFileSync(join(here, "expected.json"), "utf8"));
256
+ try {
257
+ const report = checkRecording(positionals[0], {
258
+ runsPath: values.runs, episode: values.episode === undefined ? expected.comparison.episode : Number(values.episode),
259
+ });
260
+ console.log(JSON.stringify(report, null, 2));
261
+ } catch (error) {
262
+ console.error(error instanceof Error ? error.message : error);
263
+ process.exitCode = 1;
264
+ }
265
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "version": 1,
3
+ "layout": "column",
4
+ "views": [
5
+ { "id": "frozenlake-grid", "type": "custom", "renderer": "frozenlake", "source": "" },
6
+ { "id": "episode-path", "type": "array", "source": "/path" }
7
+ ]
8
+ }
@@ -0,0 +1,80 @@
1
+ {
2
+ "runs": {
3
+ "epsilon-0.1": {
4
+ "states": 11557,
5
+ "transitions": 10000,
6
+ "episodes": 1557,
7
+ "sha256": "0ac9fe0b4507c12d2e72f773a2ec3d2750d0c60f7a095cf8a38c60a8bc9b6270"
8
+ },
9
+ "epsilon-0.3": {
10
+ "states": 11448,
11
+ "transitions": 10000,
12
+ "episodes": 1448,
13
+ "sha256": "a37137cc8e8f5bda15ffca4a68cf0d6d51cd2ce83102ab3a29bcddb56b5224ec"
14
+ }
15
+ },
16
+ "comparison": {
17
+ "episode": 1008,
18
+ "firstDifference": {
19
+ "step": 5,
20
+ "runs": {
21
+ "epsilon-0.1": {
22
+ "traceStep": 7517,
23
+ "t": 5,
24
+ "from": 13,
25
+ "values": [
26
+ 0,
27
+ 0.902499973103404,
28
+ 0.95,
29
+ 0.8573749743000758
30
+ ],
31
+ "greedy": [
32
+ "RIGHT"
33
+ ],
34
+ "action": "RIGHT",
35
+ "explored": false,
36
+ "cell": 14,
37
+ "reward": 0,
38
+ "status": "running",
39
+ "episodeReturn": 0
40
+ },
41
+ "epsilon-0.3": {
42
+ "traceStep": 8089,
43
+ "t": 5,
44
+ "from": 13,
45
+ "values": [
46
+ 0,
47
+ 0.9024999999999999,
48
+ 0.95,
49
+ 0.8573749999999998
50
+ ],
51
+ "greedy": [
52
+ "RIGHT"
53
+ ],
54
+ "action": "LEFT",
55
+ "explored": true,
56
+ "cell": 12,
57
+ "reward": 0,
58
+ "status": "hole",
59
+ "episodeReturn": 0
60
+ }
61
+ }
62
+ },
63
+ "outcomes": {
64
+ "epsilon-0.1": {
65
+ "resetStep": 7512,
66
+ "steps": 6,
67
+ "return": 1,
68
+ "outcome": "goal",
69
+ "actions": "DDRDRR"
70
+ },
71
+ "epsilon-0.3": {
72
+ "resetStep": 8084,
73
+ "steps": 5,
74
+ "return": 0,
75
+ "outcome": "hole",
76
+ "actions": "DDRDL"
77
+ }
78
+ }
79
+ }
80
+ }
@@ -0,0 +1,138 @@
1
+ """Deterministic FrozenLake and tabular Q-learning using only the Python standard library.
2
+
3
+ The environment follows Gymnasium's FrozenLake-v1 with ``is_slippery=False``: the
4
+ default maps, action encoding, observation ``row * ncol + col``, reward schedule
5
+ ``(1, 0, 0)``, termination on a hole or the goal, and truncation by the time limit.
6
+ A move off the grid leaves the agent in place.
7
+
8
+ Run this file directly for the uninstrumented summaries of one configured run.
9
+ """
10
+
11
+ import argparse
12
+ import json
13
+ import random
14
+ from pathlib import Path
15
+
16
+
17
+ MAPS = {
18
+ "4x4": ("SFFF", "FHFH", "FFFH", "HFFG"),
19
+ "8x8": (
20
+ "SFFFFFFF", "FFFFFFFF", "FFFHFFFF", "FFFFFHFF",
21
+ "FFFHFFFF", "FHHFFFHF", "FHFFHFHF", "FFFHFFFG",
22
+ ),
23
+ }
24
+ MAX_EPISODE_STEPS = {"4x4": 100, "8x8": 200}
25
+ ACTIONS = ("LEFT", "DOWN", "RIGHT", "UP")
26
+ REWARD_SCHEDULE = {"G": 1, "H": 0, "F": 0, "S": 0}
27
+
28
+ HERE = Path(__file__).resolve().parent
29
+
30
+
31
+ class FrozenLake:
32
+ def __init__(self, map_name):
33
+ self.desc = MAPS[map_name]
34
+ self.nrow = len(self.desc)
35
+ self.ncol = len(self.desc[0])
36
+ self.max_episode_steps = MAX_EPISODE_STEPS[map_name]
37
+ self.state = 0
38
+ self.elapsed = 0
39
+
40
+ def reset(self):
41
+ self.state = 0
42
+ self.elapsed = 0
43
+ return self.state
44
+
45
+ def step(self, action):
46
+ row, col = divmod(self.state, self.ncol)
47
+ if action == 0:
48
+ col = max(col - 1, 0)
49
+ elif action == 1:
50
+ row = min(row + 1, self.nrow - 1)
51
+ elif action == 2:
52
+ col = min(col + 1, self.ncol - 1)
53
+ elif action == 3:
54
+ row = max(row - 1, 0)
55
+ else:
56
+ raise ValueError(f"invalid action {action!r}")
57
+ self.state = row * self.ncol + col
58
+ tile = self.desc[row][col]
59
+ self.elapsed += 1
60
+ terminated = tile in "GH"
61
+ # Gymnasium's TimeLimit wrapper truncates at the limit even on a terminal step.
62
+ truncated = self.elapsed >= self.max_episode_steps
63
+ return self.state, REWARD_SCHEDULE[tile], terminated, truncated
64
+
65
+
66
+ def outcome(env, state, terminated, truncated):
67
+ if terminated:
68
+ return "goal" if env.desc[state // env.ncol][state % env.ncol] == "G" else "hole"
69
+ return "truncated" if truncated else "running"
70
+
71
+
72
+ def train(settings, run, observer=None):
73
+ """Train until at least ``minTransitions`` transitions, finishing the last episode.
74
+
75
+ ``observer`` receives read-only copies of each reset and transition. It must not
76
+ draw from the random generator or change the Q-table.
77
+ """
78
+ rng = random.Random(run["seed"])
79
+ env = FrozenLake(settings["map"])
80
+ alpha, gamma, epsilon = settings["alpha"], settings["gamma"], run["epsilon"]
81
+ q = [[0.0] * len(ACTIONS) for _ in range(env.nrow * env.ncol)]
82
+ summaries = []
83
+ transitions = 0
84
+ while transitions < settings["minTransitions"]:
85
+ episode = len(summaries)
86
+ state = env.reset()
87
+ episode_return = 0
88
+ actions = []
89
+ if observer:
90
+ observer.reset(episode, state)
91
+ while True:
92
+ explored = rng.random() < epsilon
93
+ if explored:
94
+ action = rng.randrange(len(ACTIONS))
95
+ else:
96
+ best = max(q[state])
97
+ action = rng.choice([index for index, value in enumerate(q[state]) if value == best])
98
+ next_state, reward, terminated, truncated = env.step(action)
99
+ target = reward if terminated else reward + gamma * max(q[next_state])
100
+ q[state][action] += alpha * (target - q[state][action])
101
+ episode_return += reward
102
+ actions.append(ACTIONS[action][0])
103
+ transitions += 1
104
+ result = outcome(env, next_state, terminated, truncated)
105
+ if observer:
106
+ observer.transition({
107
+ "episode": episode, "t": env.elapsed, "from": state, "action": action,
108
+ "explored": explored, "to": next_state, "reward": reward,
109
+ "terminated": terminated, "truncated": truncated, "outcome": result,
110
+ "q": q[state][action],
111
+ })
112
+ state = next_state
113
+ if terminated or truncated:
114
+ break
115
+ summaries.append({"episode": episode, "steps": env.elapsed, "return": episode_return,
116
+ "outcome": result, "actions": "".join(actions)})
117
+ return summaries
118
+
119
+
120
+ def load_runs(path=HERE / "runs.json"):
121
+ return json.loads(Path(path).read_text(encoding="utf-8"))
122
+
123
+
124
+ def main():
125
+ parser = argparse.ArgumentParser(description="Print per-episode summaries of one uninstrumented run.")
126
+ parser.add_argument("run", help="run id from runs.json")
127
+ parser.add_argument("--runs", type=Path, default=HERE / "runs.json")
128
+ args = parser.parse_args()
129
+ config = load_runs(args.runs)
130
+ runs = {run["id"]: run for run in config["runs"]}
131
+ if args.run not in runs:
132
+ parser.error(f"unknown run {args.run!r}; choose from {', '.join(runs)}")
133
+ summaries = train(config["settings"], runs[args.run])
134
+ print(json.dumps({"run": args.run, "episodes": summaries}, separators=(",", ":")))
135
+
136
+
137
+ if __name__ == "__main__":
138
+ main()
@@ -0,0 +1,57 @@
1
+ const tiles = { S: "Start", F: "Ice", H: "Hole", G: "Goal" };
2
+ const actions = ["Left", "Down", "Right", "Up"];
3
+ const styles = `
4
+ .lake { padding: 1rem; }
5
+ .lake-grid { display: grid; gap: .3rem; max-width: 24rem; margin: 0 auto; }
6
+ .lake-cell { display: grid; place-content: center; gap: .1rem; min-height: 2.7rem; text-align: center;
7
+ border: 1px solid #cbd9df; border-radius: .35rem; background: #eff7fa; color: #214155; }
8
+ .lake-cell[data-tile="S"] { background: #f4f1e8; }
9
+ .lake-cell[data-tile="G"] { background: #dff3db; }
10
+ .lake-cell[data-tile="H"] { background: #e1e7ed; }
11
+ .lake-cell[data-visited="true"] { box-shadow: inset 0 -3px #51a6b6; }
12
+ .lake-cell[data-agent="true"] { outline: 2px solid #135974; outline-offset: -2px; }
13
+ .lake-cell small { font: .72rem var(--mono, monospace); }
14
+ .lake-path { overflow-wrap: anywhere; color: var(--muted); font: .78rem/1.5 var(--mono, monospace); }
15
+ .lake-values { display: flex; flex-wrap: wrap; gap: .35rem .8rem; margin: 0; font: .8rem var(--mono, monospace); }
16
+ .lake-values div { display: flex; gap: .35rem; }
17
+ .lake-values dd { margin: 0; font-weight: 700; }
18
+ `;
19
+
20
+ function element(tag, text, className) {
21
+ const node = document.createElement(tag);
22
+ if (className) node.className = className;
23
+ if (text !== undefined) node.textContent = text;
24
+ return node;
25
+ }
26
+
27
+ export default function renderFrozenLake({ map, cell, path, q }) {
28
+ const wrapper = element("div", undefined, "lake");
29
+ wrapper.append(element("style", styles));
30
+ const grid = element("div", undefined, "lake-grid");
31
+ grid.setAttribute("role", "group");
32
+ grid.setAttribute("aria-label", "FrozenLake grid");
33
+ grid.style.gridTemplateColumns = `repeat(${map[0].length}, minmax(0, 1fr))`;
34
+ const visited = new Set(path);
35
+ for (const [index, tile] of Array.from(map.join("")).entries()) {
36
+ const atAgent = index === cell;
37
+ const square = element("div", undefined, "lake-cell");
38
+ Object.assign(square.dataset, { cell: index, tile, visited: visited.has(index), agent: atAgent });
39
+ square.append(element("small", index), element("small", atAgent ? `Agent · ${tiles[tile]}` : tiles[tile]));
40
+ grid.append(square);
41
+ }
42
+ const pathLabel = element("p", `Path ${path.join(" → ")}`, "lake-path");
43
+ pathLabel.dataset.path = JSON.stringify(path);
44
+ const values = element("dl", undefined, "lake-values");
45
+ values.setAttribute("aria-label", `Action values at cell ${cell}`);
46
+ for (const [index, action] of actions.entries()) {
47
+ const value = q[cell][index];
48
+ const entry = element("div");
49
+ const label = element("dt", action);
50
+ const display = element("dd", Number(value.toFixed(4)));
51
+ Object.assign(display.dataset, { action: action.toUpperCase(), q: value });
52
+ entry.append(label, display);
53
+ values.append(entry);
54
+ }
55
+ wrapper.append(grid, pathLabel, values);
56
+ return wrapper;
57
+ }
@@ -0,0 +1,83 @@
1
+ """Record each configured FrozenLake Q-learning run as an AlgoViz v1 trace."""
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from algoviz_trace import TraceWriter
8
+ from frozenlake import ACTIONS, MAPS, HERE, load_runs, train
9
+
10
+
11
+ class RunObserver:
12
+ """Mirror resets and transitions into the observable state without touching the learner."""
13
+
14
+ def __init__(self, file, settings, run):
15
+ self.file = file
16
+ self.writer = None
17
+ self.state = {
18
+ "run": {"id": run["id"], "seed": run["seed"], "epsilon": run["epsilon"],
19
+ "alpha": settings["alpha"], "gamma": settings["gamma"]},
20
+ "map": list(MAPS[settings["map"]]),
21
+ "episode": 0,
22
+ "t": 0,
23
+ "cell": 0,
24
+ "status": "running",
25
+ "episodeReturn": 0,
26
+ "path": [0],
27
+ "last": None,
28
+ "q": [[0.0] * len(ACTIONS) for _ in range(len(MAPS[settings["map"]]) * len(MAPS[settings["map"]][0]))],
29
+ "totals": {"transitions": 0, "goals": 0, "holes": 0, "truncations": 0},
30
+ }
31
+
32
+ def reset(self, episode, cell):
33
+ state = self.state
34
+ state.update(episode=episode, t=0, cell=cell, status="running", episodeReturn=0, path=[cell], last=None)
35
+ if self.writer is None:
36
+ self.writer = TraceWriter(self.file, state)
37
+ else:
38
+ self.writer.step(state, [{"type": "episode.reset", "data": {"episode": episode}}])
39
+
40
+ def transition(self, change):
41
+ state = self.state
42
+ state["t"] = change["t"]
43
+ state["cell"] = change["to"]
44
+ state["status"] = change["outcome"]
45
+ state["episodeReturn"] += change["reward"]
46
+ state["path"].append(change["to"])
47
+ state["last"] = {
48
+ "from": change["from"], "action": ACTIONS[change["action"]], "explored": change["explored"],
49
+ "to": change["to"], "reward": change["reward"],
50
+ "terminated": change["terminated"], "truncated": change["truncated"],
51
+ }
52
+ state["q"][change["from"]][change["action"]] = change["q"]
53
+ totals = state["totals"]
54
+ totals["transitions"] += 1
55
+ events = None
56
+ if change["outcome"] != "running":
57
+ totals[{"goal": "goals", "hole": "holes", "truncated": "truncations"}[change["outcome"]]] += 1
58
+ events = [{"type": "episode.end", "data": {"episode": change["episode"], "outcome": change["outcome"]}}]
59
+ self.writer.step(state, events)
60
+
61
+
62
+ def main():
63
+ parser = argparse.ArgumentParser(description=__doc__)
64
+ parser.add_argument("output", type=Path, help="directory that receives one folder per run")
65
+ parser.add_argument("--runs", type=Path, default=HERE / "runs.json")
66
+ args = parser.parse_args()
67
+ config = load_runs(args.runs)
68
+ spec = (HERE / "visualization.json").read_text(encoding="utf-8")
69
+ report = []
70
+ for run in config["runs"]:
71
+ directory = args.output / run["id"]
72
+ directory.mkdir(parents=True, exist_ok=True)
73
+ (directory / "visualization.json").write_text(spec, encoding="utf-8", newline="\n")
74
+ with (directory / "trace.jsonl").open("w", encoding="utf-8", newline="\n") as file:
75
+ observer = RunObserver(file, config["settings"], run)
76
+ summaries = train(config["settings"], run, observer)
77
+ report.append({"run": run["id"], "episodes": len(summaries),
78
+ "transitions": observer.state["totals"]["transitions"], "states": observer.writer.steps + 1})
79
+ print(json.dumps(report))
80
+
81
+
82
+ if __name__ == "__main__":
83
+ main()
@@ -0,0 +1,12 @@
1
+ {
2
+ "settings": {
3
+ "map": "4x4",
4
+ "alpha": 0.5,
5
+ "gamma": 0.95,
6
+ "minTransitions": 10000
7
+ },
8
+ "runs": [
9
+ { "id": "epsilon-0.1", "seed": 7, "epsilon": 0.1 },
10
+ { "id": "epsilon-0.3", "seed": 7, "epsilon": 0.3 }
11
+ ]
12
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "version": 1,
3
+ "layout": "row",
4
+ "views": [
5
+ { "id": "episode-path", "type": "array", "source": "/path" },
6
+ { "id": "action-values", "type": "array", "source": "/q" }
7
+ ]
8
+ }