a73x

1f681745

scenario: add cell-region predicate evaluator + wire into tick

a73x   2026-04-19 09:35

Pure predicate evaluation over a captured PNG. Predicates
cover: cell-empty, cursor-block/bar/underline-at, and
cell-matches-golden (stubbed pass=false until Plan 3
loads goldens from disk).

Tick's assert-cell and assert-cell-at branches now invoke
the evaluator and return AssertFailed / PredicateOnMissingLabel
on failure. ScenarioState gains last_capture_label to track
the most recent capture for assert-cell resolution.

TestIO.captureCb updated to return a 640×384 all-white PNG
(80 cols × 8px, 24 rows × 16px) so end-to-end predicate
tests can address any cell in the grid.

46 tests pass (33 Task 1+2 + 11 predicate unit + 2 end-to-end).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

diff --git a/build.zig b/build.zig
index 880ec3a..479c8c1 100644
--- a/build.zig
+++ b/build.zig
@@ -400,6 +400,8 @@ pub fn build(b: *std.Build) void {
    scenario_test_mod.addImport("imgdiff", imgdiff_lib_mod);
    const scenario_tests = b.addTest(.{ .root_module = scenario_test_mod });
    test_step.dependOn(&b.addRunArtifact(scenario_tests).step);
    const scenario_test_step = b.step("test-scenario", "Run scenario unit tests only (no Vulkan)");
    scenario_test_step.dependOn(&b.addRunArtifact(scenario_tests).step);

    // imgdiff — standalone PNG comparison CLI
    const imgdiff_mod = b.createModule(.{
diff --git a/src/scenario.zig b/src/scenario.zig
index 42c766c..042249e 100644
--- a/src/scenario.zig
+++ b/src/scenario.zig
@@ -798,6 +798,7 @@ pub const ScenarioState = struct {
    scheduled_offset_ns: i128, // "next directive may execute once (now - origin) >= this"
    sleep_until_flip_started_ns: ?i128, // set when entering a sleep-until-flip directive
    captures: std.StringHashMapUnmanaged(png.Image), // label → captured PNG
    last_capture_label: ?[]const u8, // most recently captured label (owned by alloc)
    alloc: std.mem.Allocator,

    pub fn init(
@@ -813,6 +814,7 @@ pub const ScenarioState = struct {
            .scheduled_offset_ns = 0,
            .sleep_until_flip_started_ns = null,
            .captures = .{},
            .last_capture_label = null,
            .alloc = alloc,
        };
    }
@@ -824,6 +826,7 @@ pub const ScenarioState = struct {
            self.alloc.free(entry.value_ptr.pixels);
        }
        self.captures.deinit(self.alloc);
        if (self.last_capture_label) |lbl| self.alloc.free(lbl);
    }

    pub fn isDone(self: *const ScenarioState) bool {
@@ -884,10 +887,41 @@ pub const ScenarioState = struct {
                    const label_copy = try self.alloc.dupe(u8, label);
                    errdefer self.alloc.free(label_copy);
                    try self.captures.put(self.alloc, label_copy, img);
                    // Update last_capture_label: free previous, dupe new from original label.
                    if (self.last_capture_label) |old| self.alloc.free(old);
                    self.last_capture_label = try self.alloc.dupe(u8, label);
                    self.cursor += 1;
                },
                .assert_cell => |ac| {
                    const lbl = self.last_capture_label orelse {
                        std.debug.print("scenario: assert-cell with no prior capture\n", .{});
                        return error.AssertFailed;
                    };
                    const img = self.captures.get(lbl).?;
                    const geom = CellGeom{ .cell_w_px = 8, .cell_h_px = 16 };
                    const result = evalPredicate(img, ac.row, ac.col, geom, ac.pred, null);
                    if (!result.pass) {
                        std.debug.print("scenario: assert-cell ({d},{d}) {s} failed: {s}\n", .{
                            ac.row, ac.col, @tagName(ac.pred), result.reason,
                        });
                        return error.AssertFailed;
                    }
                    self.cursor += 1;
                },
                .assert_cell_at => |aca| {
                    const img = self.captures.get(aca.label) orelse {
                        return error.PredicateOnMissingLabel;
                    };
                    const geom = CellGeom{ .cell_w_px = 8, .cell_h_px = 16 };
                    const result = evalPredicate(img, aca.row, aca.col, geom, aca.pred, null);
                    if (!result.pass) {
                        std.debug.print("scenario: assert-cell-at {s} ({d},{d}) {s} failed: {s}\n", .{
                            aca.label, aca.row, aca.col, @tagName(aca.pred), result.reason,
                        });
                        return error.AssertFailed;
                    }
                    self.cursor += 1;
                },
                .assert_cell => unreachable, // Task 3 wires the predicate evaluator.
                .assert_cell_at => unreachable, // Task 3 wires the predicate evaluator.
            }
        }

@@ -914,13 +948,13 @@ const TestIO = struct {
        const self: *TestIO = @ptrCast(@alignCast(ctx));
        const label_copy = try self.alloc.dupe(u8, label);
        try self.captures_called.append(self.alloc, label_copy);
        // Return a 1x1 white pixel that the Scenario takes ownership of.
        const pixels = try self.alloc.alloc(u8, 4);
        pixels[0] = 255;
        pixels[1] = 255;
        pixels[2] = 255;
        pixels[3] = 255;
        return .{ .width = 1, .height = 1, .pixels = pixels };
        // Return a 640×384 all-white PNG (80 cols × 8px, 24 rows × 16px).
        // Large enough for predicate tests on any cell in the 80×24 grid.
        const w: u32 = 640;
        const h: u32 = 384;
        const pixels = try self.alloc.alloc(u8, @as(usize, w) * h * 4);
        @memset(pixels, 255);
        return .{ .width = w, .height = h, .pixels = pixels };
    }

    pub fn flipCb(ctx: *anyopaque) bool {
@@ -1132,3 +1166,374 @@ test "tick: write_bytes callback failure surfaces as CallbackFailed" {
    const r = state.tick(0, io);
    try std.testing.expectError(error.CallbackFailed, r);
}

// ---------------------------------------------------------------
// Cell-region predicate evaluator
// ---------------------------------------------------------------

pub const EvalResult = struct {
    pass: bool,
    /// Short diagnostic for failure case (static string where possible).
    reason: []const u8 = "",
};

pub const CellGeom = struct {
    cell_w_px: u32,
    cell_h_px: u32,
};

/// Pixel brightness in [0, 1]: mean of R, G, B channels.
fn pixelBrightness(r: u8, g: u8, b: u8) f64 {
    return (@as(f64, @floatFromInt(r)) + @as(f64, @floatFromInt(g)) + @as(f64, @floatFromInt(b))) / (3.0 * 255.0);
}

/// `golden_for_cell_matches` is only consulted by `cell_matches_golden`;
/// callers may pass null otherwise. When null and pred needs it, result
/// is pass=false with reason="missing golden".
pub fn evalPredicate(
    image: png.Image,
    row: u16,
    col: u16,
    geom: CellGeom,
    pred: Predicate,
    golden_for_cell_matches: ?png.Image,
) EvalResult {
    const x0: u32 = @as(u32, col) * geom.cell_w_px;
    const y0: u32 = @as(u32, row) * geom.cell_h_px;

    // Bounds check: if cell rect extends past image, fail gracefully.
    if (x0 + geom.cell_w_px > image.width or y0 + geom.cell_h_px > image.height) {
        return .{ .pass = false, .reason = "cell out of bounds" };
    }

    switch (pred) {
        .cell_empty => {
            // Pass if every pixel in the cell has brightness ≤ 0.1.
            var y: u32 = y0;
            while (y < y0 + geom.cell_h_px) : (y += 1) {
                var x: u32 = x0;
                while (x < x0 + geom.cell_w_px) : (x += 1) {
                    const off = (@as(usize, y) * image.width + x) * 4;
                    const brightness = pixelBrightness(image.pixels[off], image.pixels[off + 1], image.pixels[off + 2]);
                    if (brightness > 0.1) {
                        return .{ .pass = false, .reason = "bright pixel in supposedly empty cell" };
                    }
                }
            }
            return .{ .pass = true };
        },
        .cursor_block_at => {
            // Pass if mean brightness over the cell rect is ≥ 0.5.
            var sum: f64 = 0;
            const total_px: u32 = geom.cell_w_px * geom.cell_h_px;
            var y: u32 = y0;
            while (y < y0 + geom.cell_h_px) : (y += 1) {
                var x: u32 = x0;
                while (x < x0 + geom.cell_w_px) : (x += 1) {
                    const off = (@as(usize, y) * image.width + x) * 4;
                    sum += pixelBrightness(image.pixels[off], image.pixels[off + 1], image.pixels[off + 2]);
                }
            }
            const mean = sum / @as(f64, @floatFromInt(total_px));
            if (mean >= 0.5) {
                return .{ .pass = true };
            }
            return .{ .pass = false, .reason = "cell too dark for block cursor" };
        },
        .cursor_bar_at => {
            // Pass if centroid x of bright pixels (brightness ≥ 0.5) is in left third
            // AND bright pixel count ≥ cell_h_px * 2 (matching 2px bar width).
            var bright_count: u32 = 0;
            var centroid_x_sum: f64 = 0;
            var y: u32 = y0;
            while (y < y0 + geom.cell_h_px) : (y += 1) {
                var x: u32 = x0;
                while (x < x0 + geom.cell_w_px) : (x += 1) {
                    const off = (@as(usize, y) * image.width + x) * 4;
                    const brightness = pixelBrightness(image.pixels[off], image.pixels[off + 1], image.pixels[off + 2]);
                    if (brightness >= 0.5) {
                        bright_count += 1;
                        // x relative to cell origin
                        centroid_x_sum += @as(f64, @floatFromInt(x - x0));
                    }
                }
            }
            const threshold: u32 = geom.cell_h_px * 2;
            if (bright_count < threshold) {
                return .{ .pass = false, .reason = "bar cursor centroid not at cell left" };
            }
            const centroid_x = centroid_x_sum / @as(f64, @floatFromInt(bright_count));
            const left_third = @as(f64, @floatFromInt(geom.cell_w_px)) / 3.0;
            if (centroid_x < left_third) {
                return .{ .pass = true };
            }
            return .{ .pass = false, .reason = "bar cursor centroid not at cell left" };
        },
        .cursor_underline_at => {
            // Pass if centroid y of bright pixels is in bottom third
            // AND bright pixel count ≥ cell_w_px * 2.
            var bright_count: u32 = 0;
            var centroid_y_sum: f64 = 0;
            var y: u32 = y0;
            while (y < y0 + geom.cell_h_px) : (y += 1) {
                var x: u32 = x0;
                while (x < x0 + geom.cell_w_px) : (x += 1) {
                    const off = (@as(usize, y) * image.width + x) * 4;
                    const brightness = pixelBrightness(image.pixels[off], image.pixels[off + 1], image.pixels[off + 2]);
                    if (brightness >= 0.5) {
                        bright_count += 1;
                        // y relative to cell origin
                        centroid_y_sum += @as(f64, @floatFromInt(y - y0));
                    }
                }
            }
            const threshold: u32 = geom.cell_w_px * 2;
            if (bright_count < threshold) {
                return .{ .pass = false, .reason = "underline cursor centroid not at cell bottom" };
            }
            const centroid_y = centroid_y_sum / @as(f64, @floatFromInt(bright_count));
            const bottom_third_start = @as(f64, @floatFromInt(geom.cell_h_px)) * 2.0 / 3.0;
            if (centroid_y >= bottom_third_start) {
                return .{ .pass = true };
            }
            return .{ .pass = false, .reason = "underline cursor centroid not at cell bottom" };
        },
        .cell_matches_golden => {
            const golden = golden_for_cell_matches orelse {
                return .{ .pass = false, .reason = "missing golden" };
            };
            // Compute RMSE over the cell rect inline.
            var sum_sq: f64 = 0;
            const total_px: u32 = geom.cell_w_px * geom.cell_h_px;
            var y: u32 = y0;
            while (y < y0 + geom.cell_h_px) : (y += 1) {
                var x: u32 = x0;
                while (x < x0 + geom.cell_w_px) : (x += 1) {
                    const off = (@as(usize, y) * image.width + x) * 4;
                    // Also bounds-check golden image.
                    if (off + 3 >= golden.pixels.len) {
                        return .{ .pass = false, .reason = "golden image too small for cell rect" };
                    }
                    const dr = (@as(f64, @floatFromInt(image.pixels[off + 0])) - @as(f64, @floatFromInt(golden.pixels[off + 0]))) / 255.0;
                    const dg = (@as(f64, @floatFromInt(image.pixels[off + 1])) - @as(f64, @floatFromInt(golden.pixels[off + 1]))) / 255.0;
                    const db = (@as(f64, @floatFromInt(image.pixels[off + 2])) - @as(f64, @floatFromInt(golden.pixels[off + 2]))) / 255.0;
                    sum_sq += (dr * dr + dg * dg + db * db) / 3.0;
                }
            }
            const rmse = @sqrt(sum_sq / @as(f64, @floatFromInt(total_px)));
            if (rmse <= imgdiff.RMSE_DEFAULT) {
                return .{ .pass = true };
            }
            return .{ .pass = false, .reason = "cell RMSE exceeds threshold" };
        },
    }
}

// ---------------------------------------------------------------
// Predicate evaluator tests
// ---------------------------------------------------------------

/// Build a width×height PNG where every pixel is `color` (RGBA).
fn makeSolid(alloc: std.mem.Allocator, w: u32, h: u32, color: [4]u8) !png.Image {
    const pixels = try alloc.alloc(u8, @as(usize, w) * h * 4);
    var i: usize = 0;
    while (i < pixels.len) : (i += 4) {
        pixels[i + 0] = color[0];
        pixels[i + 1] = color[1];
        pixels[i + 2] = color[2];
        pixels[i + 3] = color[3];
    }
    return .{ .width = w, .height = h, .pixels = pixels };
}

fn fillCell(img: *png.Image, row: u16, col: u16, geom: CellGeom, color: [4]u8) void {
    const start_x: u32 = @as(u32, col) * geom.cell_w_px;
    const start_y: u32 = @as(u32, row) * geom.cell_h_px;
    var y: u32 = start_y;
    while (y < start_y + geom.cell_h_px) : (y += 1) {
        var x: u32 = start_x;
        while (x < start_x + geom.cell_w_px) : (x += 1) {
            const off = (@as(usize, y) * img.width + x) * 4;
            img.pixels[off + 0] = color[0];
            img.pixels[off + 1] = color[1];
            img.pixels[off + 2] = color[2];
            img.pixels[off + 3] = color[3];
        }
    }
}

test "evalPredicate: cell out of bounds returns pass=false" {
    const alloc = std.testing.allocator;
    const img = try makeSolid(alloc, 8, 16, .{ 0, 0, 0, 255 });
    defer alloc.free(img.pixels);
    // Cell (0,1) would require x=8..16 but image is only 8 wide.
    const r = evalPredicate(img, 0, 1, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_empty, null);
    try std.testing.expect(!r.pass);
    try std.testing.expect(std.mem.indexOf(u8, r.reason, "out of bounds") != null);
}

test "evalPredicate: cell-empty passes on all-black image" {
    const alloc = std.testing.allocator;
    const img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
    defer alloc.free(img.pixels);
    const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_empty, null);
    try std.testing.expect(r.pass);
}

test "evalPredicate: cell-empty fails on image with bright cell" {
    const alloc = std.testing.allocator;
    var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
    defer alloc.free(img.pixels);
    // Make cell (0,0)'s first pixel bright.
    img.pixels[0] = 255;
    img.pixels[1] = 255;
    img.pixels[2] = 255;
    const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_empty, null);
    try std.testing.expect(!r.pass);
}

test "evalPredicate: cursor-block-at passes when cell is mostly bright" {
    const alloc = std.testing.allocator;
    var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
    defer alloc.free(img.pixels);
    // Fill cell (0,0)'s 8x16 rect with white.
    fillCell(&img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .{ 255, 255, 255, 255 });
    const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_block_at, null);
    try std.testing.expect(r.pass);
}

test "evalPredicate: cursor-block-at fails when cell is dark" {
    const alloc = std.testing.allocator;
    const img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
    defer alloc.free(img.pixels);
    const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_block_at, null);
    try std.testing.expect(!r.pass);
}

test "evalPredicate: cursor-bar-at passes when bright pixels are at cell left" {
    const alloc = std.testing.allocator;
    var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
    defer alloc.free(img.pixels);
    // Paint only the leftmost 2 pixels of cell (0,0), full height.
    var y: u32 = 0;
    while (y < 16) : (y += 1) {
        var x: u32 = 0;
        while (x < 2) : (x += 1) {
            const off = (@as(usize, y) * 80 + x) * 4;
            img.pixels[off + 0] = 255;
            img.pixels[off + 1] = 255;
            img.pixels[off + 2] = 255;
        }
    }
    const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_bar_at, null);
    try std.testing.expect(r.pass);
}

test "evalPredicate: cursor-bar-at fails when bright pixels are at cell right" {
    const alloc = std.testing.allocator;
    var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
    defer alloc.free(img.pixels);
    // Paint only the rightmost 2 pixels of cell (0,0), full height.
    var y: u32 = 0;
    while (y < 16) : (y += 1) {
        var x: u32 = 6;
        while (x < 8) : (x += 1) {
            const off = (@as(usize, y) * 80 + x) * 4;
            img.pixels[off + 0] = 255;
            img.pixels[off + 1] = 255;
            img.pixels[off + 2] = 255;
        }
    }
    const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_bar_at, null);
    try std.testing.expect(!r.pass);
}

test "evalPredicate: cursor-underline-at passes when bright pixels are at cell bottom" {
    const alloc = std.testing.allocator;
    var img = try makeSolid(alloc, 80, 24, .{ 0, 0, 0, 255 });
    defer alloc.free(img.pixels);
    // Paint only the bottom 2 rows of cell (0,0), full width.
    var y: u32 = 14;
    while (y < 16) : (y += 1) {
        var x: u32 = 0;
        while (x < 8) : (x += 1) {
            const off = (@as(usize, y) * 80 + x) * 4;
            img.pixels[off + 0] = 255;
            img.pixels[off + 1] = 255;
            img.pixels[off + 2] = 255;
        }
    }
    const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cursor_underline_at, null);
    try std.testing.expect(r.pass);
}

test "evalPredicate: cell-matches-golden passes when images match in the cell rect" {
    const alloc = std.testing.allocator;
    // 640×384: 80 cols × 8px wide, 24 rows × 16px tall — enough for cell (5,3).
    const img = try makeSolid(alloc, 640, 384, .{ 10, 20, 30, 255 });
    defer alloc.free(img.pixels);
    const golden = try makeSolid(alloc, 640, 384, .{ 10, 20, 30, 255 });
    defer alloc.free(golden.pixels);
    const r = evalPredicate(img, 5, 3, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_matches_golden, golden);
    try std.testing.expect(r.pass);
}

test "evalPredicate: cell-matches-golden fails on bright delta at the target cell" {
    const alloc = std.testing.allocator;
    var img = try makeSolid(alloc, 640, 384, .{ 10, 20, 30, 255 });
    defer alloc.free(img.pixels);
    const golden = try makeSolid(alloc, 640, 384, .{ 10, 20, 30, 255 });
    defer alloc.free(golden.pixels);
    // Corrupt cell (5,3) in img.
    fillCell(&img, 5, 3, .{ .cell_w_px = 8, .cell_h_px = 16 }, .{ 255, 0, 0, 255 });
    const r = evalPredicate(img, 5, 3, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_matches_golden, golden);
    try std.testing.expect(!r.pass);
}

test "evalPredicate: cell-matches-golden with null golden fails with 'missing golden'" {
    const alloc = std.testing.allocator;
    const img = try makeSolid(alloc, 80, 24, .{ 10, 20, 30, 255 });
    defer alloc.free(img.pixels);
    const r = evalPredicate(img, 0, 0, .{ .cell_w_px = 8, .cell_h_px = 16 }, .cell_matches_golden, null);
    try std.testing.expect(!r.pass);
    try std.testing.expect(std.mem.indexOf(u8, r.reason, "missing golden") != null);
}

// End-to-end tests: parse + state + predicate

test "tick+eval: assert-cell evaluates against last capture" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\capture snap
        \\assert-cell 0 0 cursor-block-at
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0);
    defer state.deinit();

    var tio = TestIO{ .alloc = std.testing.allocator };
    defer tio.deinit();

    // TestIO.captureCb returns a 640×384 all-white PNG; cursor_block_at on cell (0,0)
    // will pass because mean brightness of an all-white cell is 1.0 >= 0.5.
    const r = try state.tick(0, tio.io());
    try std.testing.expectEqual(TickOutcome.done, r);
}

test "tick+eval: assert-cell-at on missing label errors" {
    var s = try parseOk(
        \\size 80 24
        \\timeout 5000ms
        \\assert-cell-at nope 0 0 cell-empty
    );
    defer s.deinit();
    var state = ScenarioState.init(std.testing.allocator, &s, 0);
    defer state.deinit();

    var tio = TestIO{ .alloc = std.testing.allocator };
    defer tio.deinit();

    const r = state.tick(0, tio.io());
    try std.testing.expectError(error.PredicateOnMissingLabel, r);
}