a73x

d725cafd

Plan 1 of 3 for scenario runner: imgdiff extraction

a73x   2026-04-19 08:40

Commit message
Plan 1 of 3 for scenario runner: imgdiff extraction

Small refactor — move pure RMSE + max-pixel + makeDiffImage out
of the CLI into src/imgdiff.zig so the scenario runner (plans 2
and 3) can call the same code.

Zero behavior change for the existing CLI. test-render continues
to work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

docs/superpowers/plans/2026-04-19-imgdiff-extraction.md
Old New
@@ -0,0 +1,434 @@
1 # Imgdiff Extraction Implementation Plan
2
3 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5 **Goal:** Extract the pure RMSE + max-pixel + diff-heatmap math from `src/tools/imgdiff.zig` into a new reusable module `src/imgdiff.zig`, so the scenario runner (later plans) can call the same code as the existing CLI.
6
7 **Architecture:** Pure-math module with `compare`, `makeDiffImage`, `DiffResult`, and the two default threshold constants (`RMSE_DEFAULT`, `PIXEL_MAX_DEFAULT`). The existing CLI at `src/tools/imgdiff.zig` shrinks to a thin `main` + env-var parsing that imports the new module. Zero behavior change for the CLI.
8
9 **Tech Stack:** Zig 0.15, existing `png` module.
10
11 **Reference spec:** `docs/superpowers/specs/2026-04-19-scenario-runner-design.md` — implementation phasing step 1 of 3.
12
13 ---
14
15 ## Reference facts (grep once, reuse)
16
17 - Existing CLI + math lives in `src/tools/imgdiff.zig` (152 lines). The pure math is the `compare` function + `DiffResult` struct + `makeDiffImage` helper. The CLI-side logic is `main`, `readFloatEnv`, env-var defaults (`RMSE` default `0.005`, `PIXEL_MAX` default `0.125`), argument parsing, PNG encode/decode, and stderr reporting.
18 - Build wiring in `build.zig:368-388` creates `imgdiff_mod` (executable) and `imgdiff_test_mod` (tests). Both use `src/tools/imgdiff.zig` as `root_source_file`.
19 - `png.Image` struct is `{ width: u32, height: u32, pixels: []u8 }` (BGRA/RGBA 4 bytes-per-pixel).
20 - This plan has no Vulkan, no Wayland, no main-loop touchpoints. Pure refactor.
21 - Zig's `zig build test` runs all test modules registered on `test_step`. The existing `imgdiff_tests` contributes two tests; this plan adds one more to the new module.
22
23 ---
24
25 ## File structure after this plan
26
27 - **`src/imgdiff.zig`** (NEW) — pure module. Public: `DiffResult`, `compare(a, b)`, `makeDiffImage(alloc, a, b)`, `RMSE_DEFAULT`, `PIXEL_MAX_DEFAULT`. Inline unit tests stay with the code.
28 - **`src/tools/imgdiff.zig`** (MODIFIED, shrunk) — CLI only. `main`, `readFloatEnv`. Imports `imgdiff` module for the math + constants.
29 - **`build.zig`** (MODIFIED) — add a new `imgdiff_mod` module (name collision with the existing variable; we'll rename — see task 2); wire it as an import on both the CLI exe and its test module.
30
31 ---
32
33 ## Task 1: Create `src/imgdiff.zig` as a pure module
34
35 **Files:**
36 - Create: `src/imgdiff.zig`
37 - Modify: `build.zig:368-388` — register the new module and its tests
38
39 **Goal of this task:** Stand up the new module alongside the existing CLI. The CLI keeps working unchanged. The new module has its own tests and runs green.
40
41 - [ ] **Step 1: Create `src/imgdiff.zig` with the pure math + constants + three tests.**
42
43 ```zig
44 //! Pure-math PNG image diff used by both the `imgdiff` CLI
45 //! (src/tools/imgdiff.zig) and the scenario runner (to be added later).
46 //! Threshold constants live here so both callers agree.
47
48 const std = @import("std");
49 const png = @import("png");
50
51 pub const RMSE_DEFAULT: f64 = 0.005;
52 pub const PIXEL_MAX_DEFAULT: f64 = 0.125;
53
54 pub const DiffResult = struct {
55 rmse: f64, // [0, 1]
56 max_pixel: f64, // [0, 1]
57 pixel_count: usize,
58 };
59
60 pub fn compare(a: png.Image, b: png.Image) !DiffResult {
61 if (a.width != b.width or a.height != b.height) return error.DimensionsDiffer;
62 std.debug.assert(a.pixels.len == b.pixels.len);
63
64 const px_count = @as(usize, a.width) * a.height;
65 var sum_sq: f64 = 0;
66 var max_d: f64 = 0;
67
68 var i: usize = 0;
69 while (i < px_count) : (i += 1) {
70 const off = i * 4;
71 const dr = (@as(f64, @floatFromInt(a.pixels[off + 0])) - @as(f64, @floatFromInt(b.pixels[off + 0]))) / 255.0;
72 const dg = (@as(f64, @floatFromInt(a.pixels[off + 1])) - @as(f64, @floatFromInt(b.pixels[off + 1]))) / 255.0;
73 const db = (@as(f64, @floatFromInt(a.pixels[off + 2])) - @as(f64, @floatFromInt(b.pixels[off + 2]))) / 255.0;
74 const d_sq = (dr * dr + dg * dg + db * db) / 3.0;
75 sum_sq += d_sq;
76 const d = @sqrt(d_sq);
77 if (d > max_d) max_d = d;
78 }
79
80 return .{
81 .rmse = @sqrt(sum_sq / @as(f64, @floatFromInt(px_count))),
82 .max_pixel = max_d,
83 .pixel_count = px_count,
84 };
85 }
86
87 pub fn makeDiffImage(alloc: std.mem.Allocator, a: png.Image, b: png.Image) !png.Image {
88 // Side-by-side: [actual | reference | delta-heatmap]
89 const w = a.width * 3;
90 const h = a.height;
91 const pixels = try alloc.alloc(u8, w * h * 4);
92 var y: u32 = 0;
93 while (y < h) : (y += 1) {
94 const row_off = @as(usize, y) * w * 4;
95 const a_off = @as(usize, y) * a.width * 4;
96 @memcpy(pixels[row_off .. row_off + a.width * 4], a.pixels[a_off .. a_off + a.width * 4]);
97 @memcpy(pixels[row_off + a.width * 4 .. row_off + 2 * a.width * 4], b.pixels[a_off .. a_off + a.width * 4]);
98 var x: u32 = 0;
99 while (x < a.width) : (x += 1) {
100 const off = a_off + x * 4;
101 const dr = (@as(f64, @floatFromInt(a.pixels[off + 0])) - @as(f64, @floatFromInt(b.pixels[off + 0]))) / 255.0;
102 const dg = (@as(f64, @floatFromInt(a.pixels[off + 1])) - @as(f64, @floatFromInt(b.pixels[off + 1]))) / 255.0;
103 const db = (@as(f64, @floatFromInt(a.pixels[off + 2])) - @as(f64, @floatFromInt(b.pixels[off + 2]))) / 255.0;
104 const d = @sqrt((dr * dr + dg * dg + db * db) / 3.0);
105 const brightness: u8 = @intFromFloat(@min(255.0, d * 255.0 * 2.0));
106 const dst = row_off + 2 * a.width * 4 + x * 4;
107 pixels[dst + 0] = brightness;
108 pixels[dst + 1] = brightness;
109 pixels[dst + 2] = brightness;
110 pixels[dst + 3] = 255;
111 }
112 }
113 return .{ .width = w, .height = h, .pixels = pixels };
114 }
115
116 test "identical images produce zero RMSE" {
117 var pixels_a = [_]u8{ 10, 20, 30, 255, 40, 50, 60, 255 };
118 var pixels_b = [_]u8{ 10, 20, 30, 255, 40, 50, 60, 255 };
119 const a = png.Image{ .width = 2, .height = 1, .pixels = &pixels_a };
120 const b = png.Image{ .width = 2, .height = 1, .pixels = &pixels_b };
121 const r = try compare(a, b);
122 try std.testing.expectEqual(@as(f64, 0.0), r.rmse);
123 try std.testing.expectEqual(@as(f64, 0.0), r.max_pixel);
124 }
125
126 test "fully saturated difference produces rmse=1.0 and max=1.0" {
127 var pixels_a = [_]u8{ 0, 0, 0, 255 };
128 var pixels_b = [_]u8{ 255, 255, 255, 255 };
129 const a = png.Image{ .width = 1, .height = 1, .pixels = &pixels_a };
130 const b = png.Image{ .width = 1, .height = 1, .pixels = &pixels_b };
131 const r = try compare(a, b);
132 try std.testing.expectApproxEqAbs(@as(f64, 1.0), r.rmse, 1e-9);
133 try std.testing.expectApproxEqAbs(@as(f64, 1.0), r.max_pixel, 1e-9);
134 }
135
136 test "makeDiffImage: width triples, heatmap brighter where delta is larger" {
137 const alloc = std.testing.allocator;
138 // 2x1 image pair where one column differs more than the other.
139 var pixels_a = [_]u8{ 0, 0, 0, 255, 100, 100, 100, 255 };
140 var pixels_b = [_]u8{ 0, 0, 0, 255, 200, 200, 200, 255 };
141 const a = png.Image{ .width = 2, .height = 1, .pixels = &pixels_a };
142 const b = png.Image{ .width = 2, .height = 1, .pixels = &pixels_b };
143 const out = try makeDiffImage(alloc, a, b);
144 defer alloc.free(out.pixels);
145 try std.testing.expectEqual(@as(u32, 6), out.width);
146 try std.testing.expectEqual(@as(u32, 1), out.height);
147 // Heatmap is the last third. Column 0 (no delta) should be black;
148 // column 1 (delta) should be non-zero.
149 const heatmap_offset = 4 * 4; // bytes before heatmap (2 images × 2 pixels × 4 bytes = 16)
150 try std.testing.expectEqual(@as(u8, 0), out.pixels[heatmap_offset + 0]); // R at heatmap col 0
151 try std.testing.expect(out.pixels[heatmap_offset + 4] > 0); // R at heatmap col 1
152 }
153 ```
154
155 - [ ] **Step 2: Wire `src/imgdiff.zig` as a module in `build.zig`.**
156
157 Current state of `build.zig:368-388`:
158
159 ```zig
160 // imgdiff — standalone PNG comparison tool
161 const imgdiff_mod = b.createModule(.{
162 .root_source_file = b.path("src/tools/imgdiff.zig"),
163 .target = target,
164 .optimize = optimize,
165 });
166 imgdiff_mod.addImport("png", png_mod);
167 const imgdiff_exe = b.addExecutable(.{
168 .name = "imgdiff",
169 .root_module = imgdiff_mod,
170 });
171 b.installArtifact(imgdiff_exe);
172
173 const imgdiff_test_mod = b.createModule(.{
174 .root_source_file = b.path("src/tools/imgdiff.zig"),
175 .target = target,
176 .optimize = optimize,
177 });
178 imgdiff_test_mod.addImport("png", png_mod);
179 const imgdiff_tests = b.addTest(.{ .root_module = imgdiff_test_mod });
180 test_step.dependOn(&b.addRunArtifact(imgdiff_tests).step);
181 ```
182
183 Replace with:
184
185 ```zig
186 // imgdiff — pure library used by the CLI + the scenario runner
187 const imgdiff_lib_mod = b.createModule(.{
188 .root_source_file = b.path("src/imgdiff.zig"),
189 .target = target,
190 .optimize = optimize,
191 });
192 imgdiff_lib_mod.addImport("png", png_mod);
193
194 const imgdiff_lib_test_mod = b.createModule(.{
195 .root_source_file = b.path("src/imgdiff.zig"),
196 .target = target,
197 .optimize = optimize,
198 });
199 imgdiff_lib_test_mod.addImport("png", png_mod);
200 const imgdiff_lib_tests = b.addTest(.{ .root_module = imgdiff_lib_test_mod });
201 test_step.dependOn(&b.addRunArtifact(imgdiff_lib_tests).step);
202
203 // imgdiff — standalone PNG comparison CLI
204 const imgdiff_mod = b.createModule(.{
205 .root_source_file = b.path("src/tools/imgdiff.zig"),
206 .target = target,
207 .optimize = optimize,
208 });
209 imgdiff_mod.addImport("png", png_mod);
210 imgdiff_mod.addImport("imgdiff", imgdiff_lib_mod);
211 const imgdiff_exe = b.addExecutable(.{
212 .name = "imgdiff",
213 .root_module = imgdiff_mod,
214 });
215 b.installArtifact(imgdiff_exe);
216
217 const imgdiff_test_mod = b.createModule(.{
218 .root_source_file = b.path("src/tools/imgdiff.zig"),
219 .target = target,
220 .optimize = optimize,
221 });
222 imgdiff_test_mod.addImport("png", png_mod);
223 imgdiff_test_mod.addImport("imgdiff", imgdiff_lib_mod);
224 const imgdiff_tests = b.addTest(.{ .root_module = imgdiff_test_mod });
225 test_step.dependOn(&b.addRunArtifact(imgdiff_tests).step);
226 ```
227
228 The library module (`imgdiff_lib_mod`) is a separate build unit from the CLI module (`imgdiff_mod`). Both reach `png`; the CLI additionally imports the library. Both have their tests registered.
229
230 At this point the CLI still has its own copy of `compare` / `makeDiffImage` / tests — Task 2 removes them. This task only *adds* the new module. Intermediate state: duplication between `src/imgdiff.zig` and `src/tools/imgdiff.zig`. That's fine for one commit; Task 2 removes the duplication.
231
232 - [ ] **Step 3: Build and run tests to verify the new module compiles and its tests pass.**
233
234 Run:
235 ```bash
236 cd /home/xanderle/code/rad/waystty
237 zig build test 2>&1 | tail -30
238 ```
239
240 Expected:
241 - Build succeeds.
242 - All existing tests still pass.
243 - Three new tests (from `src/imgdiff.zig`) show up in the count.
244
245 If the test runner reports a test-count mismatch relative to before, that's the expected addition of three new tests. If there are *fewer* tests, the old `src/tools/imgdiff.zig` tests didn't get picked up — investigate before committing.
246
247 - [ ] **Step 4: Commit.**
248
249 ```bash
250 git add src/imgdiff.zig build.zig
251 git commit -m "$(cat <<'EOF'
252 imgdiff: add src/imgdiff.zig pure-math module
253
254 Copies compare + DiffResult + makeDiffImage into a standalone
255 module so it can be reused by the upcoming scenario runner.
256 CLI at src/tools/imgdiff.zig is untouched this commit; dedup
257 happens in the next commit.
258
259 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
260 EOF
261 )"
262 ```
263
264 ---
265
266 ## Task 2: Shrink `src/tools/imgdiff.zig` to a thin CLI
267
268 **Files:**
269 - Modify: `src/tools/imgdiff.zig` — remove `DiffResult`, `compare`, `makeDiffImage`, inline tests, replace with `@import("imgdiff")` usage.
270
271 - [ ] **Step 1: Replace `src/tools/imgdiff.zig` with a CLI-only version.**
272
273 Full new contents of `src/tools/imgdiff.zig`:
274
275 ```zig
276 const std = @import("std");
277 const png = @import("png");
278 const imgdiff = @import("imgdiff");
279
280 pub fn main() !void {
281 var gpa: std.heap.DebugAllocator(.{}) = .init;
282 defer _ = gpa.deinit();
283 const alloc = gpa.allocator();
284
285 const args = try std.process.argsAlloc(alloc);
286 defer std.process.argsFree(alloc, args);
287
288 if (args.len < 3) {
289 std.debug.print("usage: imgdiff <actual.png> <reference.png> [diff.png]\n", .{});
290 std.process.exit(2);
291 }
292 const actual_path = args[1];
293 const reference_path = args[2];
294 const diff_path: ?[]const u8 = if (args.len >= 4) args[3] else null;
295
296 const rmse_max = readFloatEnv("WAYSTTY_TEST_RMSE_MAX", imgdiff.RMSE_DEFAULT);
297 const pixel_max = readFloatEnv("WAYSTTY_TEST_PIXEL_MAX", imgdiff.PIXEL_MAX_DEFAULT);
298
299 const actual_bytes = try std.fs.cwd().readFileAlloc(alloc, actual_path, 64 * 1024 * 1024);
300 defer alloc.free(actual_bytes);
301 const reference_bytes = try std.fs.cwd().readFileAlloc(alloc, reference_path, 64 * 1024 * 1024);
302 defer alloc.free(reference_bytes);
303
304 var actual = try png.decode(alloc, actual_bytes);
305 defer actual.deinit(alloc);
306 var reference = try png.decode(alloc, reference_bytes);
307 defer reference.deinit(alloc);
308
309 if (actual.width != reference.width or actual.height != reference.height) {
310 std.debug.print("FAIL: dimensions differ ({}x{} vs {}x{})\n", .{ actual.width, actual.height, reference.width, reference.height });
311 std.process.exit(3);
312 }
313
314 const r = try imgdiff.compare(actual, reference);
315 const pass = r.rmse <= rmse_max and r.max_pixel <= pixel_max;
316
317 if (pass) {
318 std.debug.print("OK: {s} RMSE={d:.4}% worst={d:.4}%\n", .{ reference_path, r.rmse * 100.0, r.max_pixel * 100.0 });
319 std.process.exit(0);
320 }
321
322 std.debug.print("FAIL: {s}\n RMSE: {d:.4}% (max {d:.4}%)\n worst pixel: {d:.4}% (max {d:.4}%)\n", .{ reference_path, r.rmse * 100.0, rmse_max * 100.0, r.max_pixel * 100.0, pixel_max * 100.0 });
323
324 if (diff_path) |p| {
325 const diff_img = try imgdiff.makeDiffImage(alloc, actual, reference);
326 defer alloc.free(diff_img.pixels);
327
328 var buf: std.ArrayList(u8) = .empty;
329 defer buf.deinit(alloc);
330 try png.encode(alloc, diff_img, buf.writer(alloc));
331
332 const out = try std.fs.cwd().createFile(p, .{ .truncate = true });
333 defer out.close();
334 try out.writeAll(buf.items);
335
336 std.debug.print(" diff: {s}\n", .{p});
337 }
338 std.debug.print(" actual: {s}\n", .{actual_path});
339 std.process.exit(1);
340 }
341
342 fn readFloatEnv(name: []const u8, default: f64) f64 {
343 const val = std.posix.getenv(name) orelse return default;
344 return std.fmt.parseFloat(f64, val) catch default;
345 }
346 ```
347
348 What's gone from the old file: `pub const DiffResult`, `pub fn compare`, `fn makeDiffImage`, both `test "identical images ..."` and `test "fully saturated ..."` blocks. What's new: the `imgdiff` import and calls to `imgdiff.compare` / `imgdiff.makeDiffImage` / `imgdiff.RMSE_DEFAULT` / `imgdiff.PIXEL_MAX_DEFAULT`.
349
350 - [ ] **Step 2: Build everything.**
351
352 Run:
353 ```bash
354 cd /home/xanderle/code/rad/waystty
355 zig build 2>&1 | tail -20
356 ```
357
358 Expected: build succeeds, `imgdiff` binary still links.
359
360 - [ ] **Step 3: Run unit tests.**
361
362 Run:
363 ```bash
364 zig build test 2>&1 | tail -30
365 ```
366
367 Expected: all tests pass. Note: the `imgdiff_tests` test module (the CLI's test module) now has zero tests inside it because we moved them all — that's fine; it just means the test runner reports zero tests for that module while the new `imgdiff_lib_tests` reports three. The total count should be **unchanged minus 2 plus 3 = net +1** relative to before Task 1.
368
369 - [ ] **Step 4: Smoke test the CLI against an existing golden.**
370
371 Run:
372 ```bash
373 cd /home/xanderle/code/rad/waystty
374 zig build
375 ./zig-out/bin/imgdiff tests/golden/reference/basic_ascii.png tests/golden/reference/basic_ascii.png
376 ```
377
378 Expected: `OK: tests/golden/reference/basic_ascii.png RMSE=0.0000% worst=0.0000%`, exit code 0. Identical image to itself → zero diff.
379
380 Then a failing-diff smoke:
381 ```bash
382 ./zig-out/bin/imgdiff tests/golden/reference/basic_ascii.png tests/golden/reference/bold_colors.png || echo "exit=$?"
383 ```
384
385 Expected: `FAIL: ...` output, exit code 1 (or 3 if dimensions differ — `basic_ascii.png` and `bold_colors.png` are both 80×24 grids so dimensions match; exit 1 is the normal-diff fail).
386
387 If both these smoke cases behave the same as before the refactor, the CLI has not regressed.
388
389 - [ ] **Step 5: Run the full render-test suite (which uses imgdiff CLI).**
390
391 ```bash
392 zig build test-render 2>&1 | tail -20
393 ```
394
395 Expected: all golden scripts pass (`basic_ascii`, `bold_colors`, `box_drawing` at least, as of spec-writing time). If this step fails, the CLI refactor broke the existing regression gate — investigate before committing.
396
397 - [ ] **Step 6: Commit.**
398
399 ```bash
400 git add src/tools/imgdiff.zig
401 git commit -m "$(cat <<'EOF'
402 imgdiff: shrink CLI to thin wrapper over src/imgdiff.zig
403
404 Removes duplicated pure-math (compare, makeDiffImage, DiffResult,
405 inline tests) from the CLI. CLI now imports the library module
406 for both the comparison and the default threshold constants.
407
408 No behavior change. test-render still passes against existing
409 golden PNGs.
410
411 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
412 EOF
413 )"
414 ```
415
416 ---
417
418 ## Post-task verification
419
420 - [ ] `zig build` passes with no warnings.
421 - [ ] `zig build test` passes (three `imgdiff_lib_tests` + zero `imgdiff_tests` + everything else unchanged).
422 - [ ] `zig build test-render` still passes against existing goldens.
423 - [ ] `./zig-out/bin/imgdiff` CLI output format is byte-identical to pre-refactor (re-run the two smoke cases in Task 2 Step 4 if unsure).
424 - [ ] `src/imgdiff.zig` is importable by future code as `@import("imgdiff")` on any module that declares it — this is what plan 2 (scenario parser) will rely on.
425
426 ---
427
428 ## Self-review coverage check
429
430 Spec "Implementation phasing" step 1 says: move pure math into `src/imgdiff.zig`, keep CLI as thin wrapper. Task 1 adds the module and its tests; Task 2 shrinks the CLI. Post-task verification confirms no CLI behavior change. ✅ covered.
431
432 Spec "Files touched" lists `src/imgdiff.zig` as new and `src/tools/imgdiff.zig` as the thin wrapper. ✅ matches.
433
434 Spec doesn't require any changes outside imgdiff for plan 1 — plan 2 and plan 3 will consume `src/imgdiff.zig`.