206 lines
8.1 KiB
Zig
206 lines
8.1 KiB
Zig
//! Gate harness: drives the inbox app core through a scalar C ABI and
|
|
//! measures correctness, determinism, and keystroke-path latency. The run1k
|
|
//! mode writes the snapshot+effect log whose digest is the transpiler gate.
|
|
//!
|
|
//! Modes:
|
|
//! smoke — fixed short sequence, print snapshot hex (cross-impl oracle)
|
|
//! run1k <out-file> — deterministic 1k-message sequence; write snapshot+effect log
|
|
//! bench10k — 10k keystroke-path dispatches; latency stats (ns)
|
|
|
|
const std = @import("std");
|
|
const impl = @import("impl");
|
|
|
|
fn snapshotHex(alloc: std.mem.Allocator) ![]u8 {
|
|
const len: usize = @intFromFloat(impl.snapshot());
|
|
const hex = try alloc.alloc(u8, len * 2);
|
|
const digits = "0123456789abcdef";
|
|
for (0..len) |i| {
|
|
const b: u8 = @intFromFloat(impl.snapshotByte(@floatFromInt(i)));
|
|
hex[i * 2] = digits[b >> 4];
|
|
hex[i * 2 + 1] = digits[b & 0xf];
|
|
}
|
|
return hex;
|
|
}
|
|
|
|
fn smoke(alloc: std.mem.Allocator) !void {
|
|
impl.reset();
|
|
impl.pushText(104);
|
|
impl.pushText(105);
|
|
_ = impl.dispatch(4, 0, 0, 0, 0, 0);
|
|
_ = impl.dispatch(0, 0, 0, 0, 0, 0);
|
|
_ = impl.dispatch(1, 4, 0, 0, 0, 0);
|
|
_ = impl.dispatch(2, 1, 0, 0, 0, 0);
|
|
_ = impl.dispatch(5, 0, 0, 0, 0, 28.5);
|
|
const hex = try snapshotHex(alloc);
|
|
std.debug.print("smoke snapshot {s}\n", .{hex});
|
|
std.debug.print("smoke effects {d}\n", .{@as(u64, @intFromFloat(impl.effectLen()))});
|
|
}
|
|
|
|
const SplitMix = struct {
|
|
state: u64,
|
|
fn next(self: *SplitMix) u64 {
|
|
self.state +%= 0x9e3779b97f4a7c15;
|
|
var z = self.state;
|
|
z = (z ^ (z >> 30)) *% 0xbf58476d1ce4e5b9;
|
|
z = (z ^ (z >> 27)) *% 0x94d049bb133111eb;
|
|
return z ^ (z >> 31);
|
|
}
|
|
fn below(self: *SplitMix, n: u64) u64 {
|
|
return self.next() % n;
|
|
}
|
|
};
|
|
|
|
// One deterministic pseudo-random message; exercises every Msg arm,
|
|
// including multi-byte UTF-8 inserts, compositions, invalid toggle ids,
|
|
// and fractional chrome insets.
|
|
fn sendRandomMsg(rng: *SplitMix) void {
|
|
const roll = rng.below(100);
|
|
if (roll < 45) {
|
|
// draft edits dominate, like real typing
|
|
const edit = rng.below(11);
|
|
switch (edit) {
|
|
0, 1, 2, 3 => { // insert 1-3 chars, sometimes multi-byte UTF-8
|
|
const n = 1 + rng.below(3);
|
|
for (0..n) |_| {
|
|
if (rng.below(8) == 0) {
|
|
// U+00E9 é = 0xC3 0xA9
|
|
impl.pushText(0xc3);
|
|
impl.pushText(0xa9);
|
|
} else {
|
|
impl.pushText(@floatFromInt(0x61 + rng.below(26)));
|
|
}
|
|
}
|
|
_ = impl.dispatch(4, 0, 0, 0, 0, 0);
|
|
},
|
|
4 => _ = impl.dispatch(4, 1, 0, 0, 0, 0), // delete_backward
|
|
5 => _ = impl.dispatch(4, 2, 0, 0, 0, 0), // delete_forward
|
|
6 => _ = impl.dispatch(4, @floatFromInt(3 + rng.below(2)), 0, 0, 0, 0), // word deletes
|
|
7 => _ = impl.dispatch(4, 6, @floatFromInt(rng.below(6)), @floatFromInt(rng.below(2)), 0, 0), // move_caret
|
|
8 => _ = impl.dispatch(4, 7, @floatFromInt(rng.below(40)), @floatFromInt(rng.below(40)), 0, 0), // set_selection
|
|
9 => { // set_composition then commit or cancel
|
|
const n = rng.below(4);
|
|
for (0..n) |_| impl.pushText(@floatFromInt(0x61 + rng.below(26)));
|
|
const cursor: f64 = if (rng.below(2) == 0) -1 else @floatFromInt(rng.below(5));
|
|
_ = impl.dispatch(4, 8, cursor, 0, 0, 0);
|
|
_ = impl.dispatch(4, if (rng.below(2) == 0) 9 else 10, 0, 0, 0, 0);
|
|
},
|
|
else => _ = impl.dispatch(4, 5, 0, 0, 0, 0), // clear
|
|
}
|
|
} else if (roll < 65) {
|
|
_ = impl.dispatch(0, 0, 0, 0, 0, 0); // add
|
|
} else if (roll < 80) {
|
|
_ = impl.dispatch(1, @floatFromInt(rng.below(80)), 0, 0, 0, 0); // toggle (some ids invalid)
|
|
} else if (roll < 88) {
|
|
_ = impl.dispatch(2, @floatFromInt(rng.below(3)), 0, 0, 0, 0); // set_filter
|
|
} else if (roll < 94) {
|
|
_ = impl.dispatch(3, 0, 0, 0, 0, 0); // clear_done
|
|
} else {
|
|
// chrome_changed with fractional insets
|
|
const left: f64 = @as(f64, @floatFromInt(rng.below(120))) * 0.5;
|
|
const top: f64 = @as(f64, @floatFromInt(rng.below(160))) * 0.5;
|
|
_ = impl.dispatch(5, 0, 0, 0, left, top);
|
|
}
|
|
}
|
|
|
|
fn run1k(alloc: std.mem.Allocator, io: std.Io, out_path: []const u8) !void {
|
|
impl.reset();
|
|
var rng = SplitMix{ .state = 0x5eed_ba5e_0000_1234 };
|
|
for (0..1000) |_| sendRandomMsg(&rng);
|
|
|
|
var out: std.ArrayList(u8) = .empty;
|
|
defer out.deinit(alloc);
|
|
const hex = try snapshotHex(alloc);
|
|
try out.appendSlice(alloc, "snapshot ");
|
|
try out.appendSlice(alloc, hex);
|
|
try out.appendSlice(alloc, "\neffects ");
|
|
const eff_len: usize = @intFromFloat(impl.effectLen());
|
|
const digits = "0123456789abcdef";
|
|
for (0..eff_len) |i| {
|
|
const b: u8 = @intFromFloat(impl.effectByte(@floatFromInt(i)));
|
|
try out.append(alloc, digits[b >> 4]);
|
|
try out.append(alloc, digits[b & 0xf]);
|
|
}
|
|
try out.appendSlice(alloc, "\n");
|
|
try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = out_path, .data = out.items });
|
|
std.debug.print("run1k wrote {s} ({d} bytes, {d} effect bytes)\n", .{ out_path, out.items.len, eff_len });
|
|
}
|
|
|
|
// Raw monotonic nanoseconds (darwin). ~41ns granularity on Apple Silicon.
|
|
extern "c" fn clock_gettime_nsec_np(clock_id: c_int) u64;
|
|
const CLOCK_UPTIME_RAW: c_int = 8;
|
|
inline fn nowNs() u64 {
|
|
return clock_gettime_nsec_np(CLOCK_UPTIME_RAW);
|
|
}
|
|
|
|
fn bench10k(alloc: std.mem.Allocator) !void {
|
|
impl.reset();
|
|
var rng = SplitMix{ .state = 0xdead_beef_cafe_f00d };
|
|
const iters = 10_000;
|
|
const samples = try alloc.alloc(u64, iters);
|
|
|
|
const bench_start = nowNs();
|
|
for (0..iters) |i| {
|
|
// Keystroke-shaped mix: mostly single-char inserts.
|
|
const roll = rng.below(1000);
|
|
const t0 = nowNs();
|
|
if (roll < 700) {
|
|
impl.pushText(@floatFromInt(0x61 + rng.below(26)));
|
|
_ = impl.dispatch(4, 0, 0, 0, 0, 0);
|
|
} else if (roll < 800) {
|
|
_ = impl.dispatch(4, 1, 0, 0, 0, 0); // backspace
|
|
} else if (roll < 870) {
|
|
_ = impl.dispatch(4, 6, @floatFromInt(rng.below(6)), 0, 0, 0); // caret move
|
|
} else if (roll < 920) {
|
|
_ = impl.dispatch(0, 0, 0, 0, 0, 0); // add (submit)
|
|
} else if (roll < 970) {
|
|
_ = impl.dispatch(1, @floatFromInt(rng.below(80)), 0, 0, 0, 0); // toggle
|
|
} else if (roll < 990) {
|
|
_ = impl.dispatch(2, @floatFromInt(rng.below(3)), 0, 0, 0, 0); // filter
|
|
} else {
|
|
_ = impl.dispatch(3, 0, 0, 0, 0, 0); // clear_done
|
|
}
|
|
samples[i] = nowNs() - t0;
|
|
}
|
|
const wall_total = nowNs() - bench_start;
|
|
|
|
std.mem.sort(u64, samples, {}, std.sort.asc(u64));
|
|
var total: u64 = 0;
|
|
for (samples) |s| total += s;
|
|
std.debug.print("bench10k wall total ns: {d} (mean/dispatch incl. harness: {d})\n", .{ wall_total, wall_total / iters });
|
|
std.debug.print(
|
|
"bench10k ns: min={d} p50={d} p90={d} p99={d} p999={d} max={d} mean={d}\n",
|
|
.{
|
|
samples[0],
|
|
samples[iters / 2],
|
|
samples[(iters * 90) / 100],
|
|
samples[(iters * 99) / 100],
|
|
samples[(iters * 999) / 1000],
|
|
samples[iters - 1],
|
|
total / iters,
|
|
},
|
|
);
|
|
// Top 5 outliers for GC-pause inspection.
|
|
std.debug.print("bench10k top5: {d} {d} {d} {d} {d}\n", .{
|
|
samples[iters - 1], samples[iters - 2], samples[iters - 3],
|
|
samples[iters - 4], samples[iters - 5],
|
|
});
|
|
}
|
|
|
|
pub fn main(init: std.process.Init) !void {
|
|
const alloc = init.arena.allocator();
|
|
impl.init();
|
|
|
|
const argv = init.minimal.args.vector;
|
|
const mode: []const u8 = if (argv.len > 1) std.mem.span(argv[1]) else "smoke";
|
|
|
|
if (std.mem.eql(u8, mode, "smoke")) {
|
|
try smoke(alloc);
|
|
} else if (std.mem.eql(u8, mode, "run1k")) {
|
|
try run1k(alloc, init.io, std.mem.span(argv[2]));
|
|
} else if (std.mem.eql(u8, mode, "bench10k")) {
|
|
try bench10k(alloc);
|
|
} else {
|
|
std.debug.print("unknown mode {s}\n", .{mode});
|
|
}
|
|
}
|