@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/LICENSE +202 -0
- package/README.md +146 -2
- package/apps/inspector/comparison.js +95 -0
- package/apps/inspector/index.html +71 -0
- package/apps/inspector/index.js +61 -0
- package/apps/inspector/inspection.js +62 -0
- package/apps/inspector/style.css +85 -0
- package/apps/inspector/views.js +40 -0
- package/docs/protocol.md +105 -0
- package/examples/frozenlake/README.md +96 -0
- package/examples/frozenlake/algoviz_trace.py +69 -0
- package/examples/frozenlake/check.mjs +265 -0
- package/examples/frozenlake/comparison.json +8 -0
- package/examples/frozenlake/expected.json +80 -0
- package/examples/frozenlake/frozenlake.py +138 -0
- package/examples/frozenlake/grid.js +57 -0
- package/examples/frozenlake/record.py +83 -0
- package/examples/frozenlake/runs.json +12 -0
- package/examples/frozenlake/visualization.json +8 -0
- package/package.json +42 -6
- package/packages/cli/index.js +144 -0
- package/packages/core/comparison.js +43 -0
- package/packages/core/index.js +124 -0
- package/packages/core/json-pointer.js +94 -0
- package/packages/core/protocol.js +188 -0
- package/packages/core/state-changes.js +65 -0
- package/packages/core/types.d.ts +29 -0
- package/packages/renderers/index.js +140 -0
- package/schemas/graph-view-data.schema.json +36 -0
- package/schemas/trace-record.schema.json +58 -0
- package/schemas/visualization-spec.schema.json +56 -0
|
@@ -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,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()
|