d890ac97
feat: bytes-on-wire bench gates the M4 kill criterion; docs
a73x 2026-08-08 14:08
Commit message
Makefile
| Old | New | ||
|---|---|---|---|
| @@ -2,7 +2,7 @@ | |||
| 2 | # default zig is 0.17-dev. Override with ZIG=... if yours lives elsewhere. | 2 | # default zig is 0.17-dev. Override with ZIG=... if yours lives elsewhere. |
| 3 | ZIG ?= $(HOME)/Downloads/zig-x86_64-linux-0.15.2/zig | 3 | ZIG ?= $(HOME)/Downloads/zig-x86_64-linux-0.15.2/zig |
| 4 | 4 | ||
| 5 | .PHONY: build test e2e clean | 5 | .PHONY: build test e2e bench clean |
| 6 | 6 | ||
| 7 | build: | 7 | build: |
| 8 | $(ZIG) build | 8 | $(ZIG) build |
| @@ -13,5 +13,8 @@ test: | |||
| 13 | e2e: | 13 | e2e: |
| 14 | $(ZIG) build e2e | 14 | $(ZIG) build e2e |
| 15 | 15 | ||
| 16 | bench: | ||
| 17 | $(ZIG) build bench | ||
| 18 | |||
| 16 | clean: | 19 | clean: |
| 17 | rm -rf zig-out .zig-cache | 20 | rm -rf zig-out .zig-cache |
README.md
| Old | New | ||
|---|---|---|---|
| @@ -5,7 +5,7 @@ authoritatively in a daemon and replicated in the client — state sync | |||
| 5 | instead of escape-sequence replay. See `docs/handoff.md` for the design | 5 | instead of escape-sequence replay. See `docs/handoff.md` for the design |
| 6 | and `docs/decisions.md` for decisions made. | 6 | and `docs/decisions.md` for decisions made. |
| 7 | 7 | ||
| 8 | Status: **M3 — the promise.** | 8 | Status: **M4 — deltas.** |
| 9 | 9 | ||
| 10 | Requires Zig 0.15.x (ghostty pin); the Makefile points at the pinned | 10 | Requires Zig 0.15.x (ghostty pin); the Makefile points at the pinned |
| 11 | toolchain, override with `make ZIG=...`. | 11 | toolchain, override with `make ZIG=...`. |
| @@ -15,6 +15,8 @@ toolchain, override with `make ZIG=...`. | |||
| 15 | ./zig-out/bin/muxd run & # daemon | 15 | ./zig-out/bin/muxd run & # daemon |
| 16 | ./zig-out/bin/mux # attach (Ctrl-\ detach, Shift+PgUp scroll) | 16 | ./zig-out/bin/mux # attach (Ctrl-\ detach, Shift+PgUp scroll) |
| 17 | ./zig-out/bin/muxd dump [--vt] # debug: print the authoritative grid | 17 | ./zig-out/bin/muxd dump [--vt] # debug: print the authoritative grid |
| 18 | ./zig-out/bin/muxd stats # wire stats: deltas vs snapshot bytes | ||
| 19 | make bench # typing-workload byte-ratio measurement | ||
| 18 | 20 | ||
| 19 | Detach, or kill the client outright — the session survives and `mux` | 21 | Detach, or kill the client outright — the session survives and `mux` |
| 20 | resumes it from a state snapshot, including TUI screens and the primary | 22 | resumes it from a state snapshot, including TUI screens and the primary |
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -92,4 +92,10 @@ pub fn build(b: *std.Build) void { | |||
| 92 | e2e.addArtifactArg(mux_exe); | 92 | e2e.addArtifactArg(mux_exe); |
| 93 | const e2e_step = b.step("e2e", "Run end-to-end test"); | 93 | const e2e_step = b.step("e2e", "Run end-to-end test"); |
| 94 | e2e_step.dependOn(&e2e.step); | 94 | e2e_step.dependOn(&e2e.step); |
| 95 | |||
| 96 | const bench = b.addSystemCommand(&.{"test/bench.sh"}); | ||
| 97 | bench.addArtifactArg(exe); | ||
| 98 | bench.addArtifactArg(mux_exe); | ||
| 99 | const bench_step = b.step("bench", "Measure delta vs snapshot bytes"); | ||
| 100 | bench_step.dependOn(&bench.step); | ||
| 95 | } | 101 | } |
docs/decisions.md
| Old | New | ||
|---|---|---|---|
| @@ -94,10 +94,73 @@ | |||
| 94 | indexed form (31 -> 38;5;1). Semantically identical; tests assert the | 94 | indexed form (31 -> 38;5;1). Semantically identical; tests assert the |
| 95 | canonical form. | 95 | canonical form. |
| 96 | 96 | ||
| 97 | ## 2026-08-07 (M4) | ||
| 98 | |||
| 99 | - **Deltas are row-granular.** Damage = set of viewport rows whose styled | ||
| 100 | dump hash (Wyhash) changed, sent as self-contained row repaints | ||
| 101 | (CUP+EL+content composed by protocol.composeDelta on the client). Chosen | ||
| 102 | over cell-level damage rects: rows are the natural unit of the canonical | ||
| 103 | formatter, and the measured win already clears the kill criterion. | ||
| 104 | - **Wire format: still hand-rolled, deliberately.** The handoff says | ||
| 105 | "msgpack or protobuf, do not invent one"; M4's payloads are row-keyed | ||
| 106 | byte blobs plus fixed-width integers, and no serialization library is | ||
| 107 | proven on Zig 0.15.2. The judgment flips when payloads become truly | ||
| 108 | structured (cell runs, multi-rect damage, negotiation) — msgpack is due | ||
| 109 | then, and this entry is the tripwire. | ||
| 110 | - **Snapshot-vs-delta threshold:** a client is served a delta iff its | ||
| 111 | have_seq is >= the tracker's reset_seq (last resize/screen-switch/init | ||
| 112 | discontinuity), <= the current seq, and the viewport size matches; | ||
| 113 | otherwise snapshot. Per-row last-change seqs advance even while | ||
| 114 | detached — once per coalesced PTY read burst; nothing at all while idle | ||
| 115 | — so reattach-after-a-gap resolves by delta when nothing discontinuous | ||
| 116 | happened. All OOM paths in the delta machinery collapse to snapshot | ||
| 117 | resync (retry-until-works, no flags). | ||
| 118 | - **Diffing is content-hash based,** not ghostty RenderState dirty | ||
| 119 | tracking: deterministic, no new engine API surface, O(rows) styled row | ||
| 120 | dumps per coalesced update. RenderState remains the optimization path if | ||
| 121 | hashing ever shows up in a profile. | ||
| 122 | - **row_count is authoritative and validated.** composeDelta rejects any | ||
| 123 | delta whose header count disagrees with the rows present; a client | ||
| 124 | receiving a malformed delta resyncs via re-attach rather than skipping | ||
| 125 | it (a skipped delta would desync the replica permanently). | ||
| 126 | - **DECOM is a known gap.** Origin mode (CSI ?6h) is not treated as a | ||
| 127 | discontinuity, and under it the replica's row addressing would shift | ||
| 128 | relative to the daemon's. Rare in practice — vim/less/htop are | ||
| 129 | unaffected because CUP is absolute under DECSTBM — so it is recorded | ||
| 130 | rather than fixed. | ||
| 131 | - **Measured (bench.sh, 120-char typing workload):** delta bytes were | ||
| 132 | 7751 against a snapshot-equivalent of 689053 — 1%. The workload | ||
| 133 | produced exactly one delta per typed character (120 deltas, ~65 bytes | ||
| 134 | each) against one initial snapshot. The M4 kill criterion (delta | ||
| 135 | traffic must scale down meaningfully) is cleared. | ||
| 136 | - **The win survives 100% damage.** Adversarial check: sustained | ||
| 137 | full-screen scrolling (`seq` over 400k lines, all 24 rows changing | ||
| 138 | every update) still measured ~366-byte deltas against ~5775-byte | ||
| 139 | snapshot-equivalents, ~6%. Row repaints beat snapshots even with no | ||
| 140 | unchanged rows to skip, because a snapshot also carries palette and | ||
| 141 | mode state that a row repaint does not. | ||
| 142 | - **No session epoch in the protocol.** have_seq is meaningful only | ||
| 143 | within one daemon instance; a client reconnecting to a restarted daemon | ||
| 144 | with overlapping counters would accept a delta belonging to a different | ||
| 145 | session. Unreachable today — the shipped client always attaches with | ||
| 146 | have_seq=0 — but a generation/epoch field is due before any real | ||
| 147 | network transport. | ||
| 148 | - **Stats counterfactual costs a dumpState per delta send** while a client | ||
| 149 | is attached — deliberate prototype instrumentation; gate behind an | ||
| 150 | option if it ever matters for performance comparisons. | ||
| 151 | - **Banked cleanup (post-M4), from the review cycle:** shared frame | ||
| 152 | applier in protocol.zig so the server fidelity test exercises the real | ||
| 153 | client apply path; Client struct decomposition of client.zig's attach | ||
| 154 | loop; resync_pending bound on the client's malformed-delta re-attach; | ||
| 155 | snapshot_prefix_len constant + read/write helpers; consistent | ||
| 156 | malformed-frame policy (short snapshots currently ignored, bad deltas | ||
| 157 | resync); timer-based test deadlines + generalized poll helper in | ||
| 158 | server tests; DeltaTracker split into src/delta.zig; naming | ||
| 159 | (resyncSnapshot/sendResync, DeltaTracker.update); paintDelta golden | ||
| 160 | test. | ||
| 161 | |||
| 97 | ## Open (owed by later milestones) | 162 | ## Open (owed by later milestones) |
| 98 | 163 | ||
| 99 | - Resize policy under multiple clients (M5) | 164 | - Resize policy under multiple clients (M5) |
| 100 | - Snapshot-vs-delta threshold (M4) | 165 | - Scrollback retention/eviction limits (post-prototype) |
| 101 | - Scrollback retention/eviction limits (M3/M4) | ||
| 102 | - Daemon lifetime across logout/reboot (M2) | 166 | - Daemon lifetime across logout/reboot (M2) |
| 103 | - Wire format msgpack vs protobuf + versioning (M2/M4) | ||
docs/superpowers/plans/2026-08-07-m4-deltas.md
| Old | New | ||
|---|---|---|---|
| @@ -370,7 +370,7 @@ git commit -m "feat: engine per-row styled dumps and alt-screen accessor" | |||
| 370 | 370 | ||
| 371 | This is the core task. The daemon currently calls `sendSnapshot()` after every engine update (see `pumpOnce`, the `self.sendSnapshot()` call in the PTY branch) and on attach/resize (in `serviceClient`/`serviceObserver`). After this task: per-update it calls `sendUpdate()` (tracker diff → delta or discontinuity snapshot); attach/resize call `sendResync(have_seq)`. | 371 | This is the core task. The daemon currently calls `sendSnapshot()` after every engine update (see `pumpOnce`, the `self.sendSnapshot()` call in the PTY branch) and on attach/resize (in `serviceClient`/`serviceObserver`). After this task: per-update it calls `sendUpdate()` (tracker diff → delta or discontinuity snapshot); attach/resize call `sendResync(have_seq)`. |
| 372 | 372 | ||
| 373 | - [ ] **Step 1: Add failing tests (append to `src/server.zig`)** | 373 | - [x] **Step 1: Add failing tests (append to `src/server.zig`)** |
| 374 | 374 | ||
| 375 | ```zig | 375 | ```zig |
| 376 | test "Server: typing produces deltas, not snapshots; stats track both" { | 376 | test "Server: typing produces deltas, not snapshots; stats track both" { |
| @@ -574,9 +574,9 @@ Also update the existing fidelity test (`test "Server: replica rebuilt from snap | |||
| 574 | 574 | ||
| 575 | (Adapt to each loop's structure; the second loop's non-dump frames must apply the same way.) Also change its attach to `proto.encodeAttach(100, 30, 0)`, and the takeover/kill/scrollback tests' attaches to `proto.encodeAttach(80, 24, 0)`. | 575 | (Adapt to each loop's structure; the second loop's non-dump frames must apply the same way.) Also change its attach to `proto.encodeAttach(100, 30, 0)`, and the takeover/kill/scrollback tests' attaches to `proto.encodeAttach(80, 24, 0)`. |
| 576 | 576 | ||
| 577 | - [ ] **Step 2: Run to verify failure** — `make test`; expected: compile errors (`encodeAttach` used with old decode on the server; new tests reference `sendResync` behavior that doesn't exist). | 577 | - [x] **Step 2: Run to verify failure** — `make test`; expected: compile errors (`encodeAttach` used with old decode on the server; new tests reference `sendResync` behavior that doesn't exist). |
| 578 | 578 | ||
| 579 | - [ ] **Step 3: Implement** | 579 | - [x] **Step 3: Implement** |
| 580 | 580 | ||
| 581 | Add near the top of `server.zig` (after `max_observers`): | 581 | Add near the top of `server.zig` (after `max_observers`): |
| 582 | 582 | ||
| @@ -810,9 +810,9 @@ Frame-handling changes: | |||
| 810 | - `serviceObserver` `.attach` arm: decode with `proto.decodeAttach(frame.payload) catch { self.dropObserver(i); return; };` — after promotion, compute `const size_changed = (sz.cols != self.colsNow() or sz.rows != self.rowsNow());`, then `self.applySize(sz.cols, sz.rows);` and `self.sendResync(sz.have_seq, size_changed);` instead of `sendSnapshot`. | 810 | - `serviceObserver` `.attach` arm: decode with `proto.decodeAttach(frame.payload) catch { self.dropObserver(i); return; };` — after promotion, compute `const size_changed = (sz.cols != self.colsNow() or sz.rows != self.rowsNow());`, then `self.applySize(sz.cols, sz.rows);` and `self.sendResync(sz.have_seq, size_changed);` instead of `sendSnapshot`. |
| 811 | - Also in `pumpOnce` — while DETACHED the tracker must keep advancing so reattach-by-delta works: in the PTY branch, `sendUpdate` already runs unconditionally (it no-ops the send when `self.client == null` but still advances the tracker — note the `.delta` arm frees the payload and skips sending; the tracker was advanced inside `update`). Confirm this reading of the code: `tracker.update` advances state regardless of clients; only the socket write is conditional. That is the intended behavior. | 811 | - Also in `pumpOnce` — while DETACHED the tracker must keep advancing so reattach-by-delta works: in the PTY branch, `sendUpdate` already runs unconditionally (it no-ops the send when `self.client == null` but still advances the tracker — note the `.delta` arm frees the payload and skips sending; the tracker was advanced inside `update`). Confirm this reading of the code: `tracker.update` advances state regardless of clients; only the socket write is conditional. That is the intended behavior. |
| 812 | 812 | ||
| 813 | - [ ] **Step 4: Run tests** — `make test`. All server tests (old and new) must pass. The delta test's `row_count <= 3` may be violated if the shell redraws its whole prompt line region — if observed, print the actual count; up to 5 is acceptable with a comment; more means the differ is broken (investigate, don't relax). | 813 | - [x] **Step 4: Run tests** — `make test`. All server tests (old and new) must pass. The delta test's `row_count <= 3` may be violated if the shell redraws its whole prompt line region — if observed, print the actual count; up to 5 is acceptable with a comment; more means the differ is broken (investigate, don't relax). |
| 814 | 814 | ||
| 815 | - [ ] **Step 5: Commit** | 815 | - [x] **Step 5: Commit** |
| 816 | 816 | ||
| 817 | ```bash | 817 | ```bash |
| 818 | git add src/server.zig | 818 | git add src/server.zig |
| @@ -826,7 +826,7 @@ git commit -m "feat: row-granular deltas with seq tracking, have_seq resync, wir | |||
| 826 | **Files:** | 826 | **Files:** |
| 827 | - Modify: `src/client.zig`, `src/main.zig` | 827 | - Modify: `src/client.zig`, `src/main.zig` |
| 828 | 828 | ||
| 829 | - [ ] **Step 1: Client changes (`src/client.zig`)** | 829 | - [x] **Step 1: Client changes (`src/client.zig`)** |
| 830 | 830 | ||
| 831 | Replace the attach send: | 831 | Replace the attach send: |
| 832 | 832 | ||
| @@ -903,7 +903,7 @@ test "paintDelta wraps composed bytes in sync-output brackets" { | |||
| 903 | } | 903 | } |
| 904 | ``` | 904 | ``` |
| 905 | 905 | ||
| 906 | - [ ] **Step 2: `muxd stats` subcommand (`src/main.zig`)** | 906 | - [x] **Step 2: `muxd stats` subcommand (`src/main.zig`)** |
| 907 | 907 | ||
| 908 | In `main`, add after the `dump` dispatch: | 908 | In `main`, add after the `dump` dispatch: |
| 909 | 909 | ||
| @@ -932,9 +932,9 @@ fn stats(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | |||
| 932 | } | 932 | } |
| 933 | ``` | 933 | ``` |
| 934 | 934 | ||
| 935 | - [ ] **Step 3: Run everything** — `make test && make build && make e2e`, all green. e2e exercises attach/detach/kill through the new frames end to end. | 935 | - [x] **Step 3: Run everything** — `make test && make build && make e2e`, all green. e2e exercises attach/detach/kill through the new frames end to end. |
| 936 | 936 | ||
| 937 | - [ ] **Step 4: Commit** | 937 | - [x] **Step 4: Commit** |
| 938 | 938 | ||
| 939 | ```bash | 939 | ```bash |
| 940 | git add src/client.zig src/main.zig | 940 | git add src/client.zig src/main.zig |
| @@ -949,7 +949,7 @@ git commit -m "feat: client applies row deltas with damage-only paints; muxd sta | |||
| 949 | - Create: `test/bench.sh` | 949 | - Create: `test/bench.sh` |
| 950 | - Modify: `test/e2e.sh`, `build.zig`, `docs/decisions.md`, `README.md` | 950 | - Modify: `test/e2e.sh`, `build.zig`, `docs/decisions.md`, `README.md` |
| 951 | 951 | ||
| 952 | - [ ] **Step 1: Write `test/bench.sh`** | 952 | - [x] **Step 1: Write `test/bench.sh`** |
| 953 | 953 | ||
| 954 | ```sh | 954 | ```sh |
| 955 | #!/bin/sh | 955 | #!/bin/sh |
| @@ -1014,11 +1014,11 @@ bench: | |||
| 1014 | 1014 | ||
| 1015 | (add `bench` to the `.PHONY` line too). | 1015 | (add `bench` to the `.PHONY` line too). |
| 1016 | 1016 | ||
| 1017 | - [ ] **Step 2: Run it** — `make bench`. Expected: prints the stats line, the ratio, and `bench OK` with a ratio well under 50% (single-char echoes touch 1–2 rows of 24; expect single-digit percent). **This number is the M4 kill-criterion verdict — record the observed ratio in decisions.md in Step 4.** | 1017 | - [x] **Step 2: Run it** — `make bench`. Expected: prints the stats line, the ratio, and `bench OK` with a ratio well under 50% (single-char echoes touch 1–2 rows of 24; expect single-digit percent). **This number is the M4 kill-criterion verdict — record the observed ratio in decisions.md in Step 4.** |
| 1018 | 1018 | ||
| 1019 | - [ ] **Step 3: e2e still green** — `make e2e`. The existing scenarios must pass unmodified (they assert content, not frame types). | 1019 | - [x] **Step 3: e2e still green** — `make e2e`. The existing scenarios must pass unmodified (they assert content, not frame types). |
| 1020 | 1020 | ||
| 1021 | - [ ] **Step 4: Update `docs/decisions.md`** — append (fill the measured ratio in): | 1021 | - [x] **Step 4: Update `docs/decisions.md`** — append (fill the measured ratio in): |
| 1022 | 1022 | ||
| 1023 | ```markdown | 1023 | ```markdown |
| 1024 | ## 2026-08-07 (M4) | 1024 | ## 2026-08-07 (M4) |
| @@ -1052,14 +1052,14 @@ bench: | |||
| 1052 | concern only. | 1052 | concern only. |
| 1053 | ``` | 1053 | ``` |
| 1054 | 1054 | ||
| 1055 | - [ ] **Step 5: Update `README.md`** — status line to `Status: **M4 — deltas.**`, and add to the command block: | 1055 | - [x] **Step 5: Update `README.md`** — status line to `Status: **M4 — deltas.**`, and add to the command block: |
| 1056 | 1056 | ||
| 1057 | ```markdown | 1057 | ```markdown |
| 1058 | ./zig-out/bin/muxd stats # wire stats: deltas vs snapshot bytes | 1058 | ./zig-out/bin/muxd stats # wire stats: deltas vs snapshot bytes |
| 1059 | make bench # typing-workload byte-ratio measurement | 1059 | make bench # typing-workload byte-ratio measurement |
| 1060 | ``` | 1060 | ``` |
| 1061 | 1061 | ||
| 1062 | - [ ] **Step 6: Commit** | 1062 | - [x] **Step 6: Commit** |
| 1063 | 1063 | ||
| 1064 | ```bash | 1064 | ```bash |
| 1065 | git add test/bench.sh test/e2e.sh build.zig Makefile docs/decisions.md README.md | 1065 | git add test/bench.sh test/e2e.sh build.zig Makefile docs/decisions.md README.md |
src/server.zig
| Old | New | ||
|---|---|---|---|
| @@ -464,6 +464,9 @@ pub const Server = struct { | |||
| 464 | return; | 464 | return; |
| 465 | }; | 465 | }; |
| 466 | self.stats.deltas += 1; | 466 | self.stats.deltas += 1; |
| 467 | // Payload bytes only: the 5-byte frame header is excluded here and | ||
| 468 | // in the counterfactual below, so the ratio stays honest even though | ||
| 469 | // it understates delta wire cost by ~8% at a typical delta size. | ||
| 467 | self.stats.delta_bytes += payload.len; | 470 | self.stats.delta_bytes += payload.len; |
| 468 | // Counterfactual: what M2 would have sent for this same update. | 471 | // Counterfactual: what M2 would have sent for this same update. |
| 469 | // This deliberately pays for a full snapshot serialization per | 472 | // This deliberately pays for a full snapshot serialization per |
test/bench.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,58 @@ | |||
| 1 | #!/bin/sh | ||
| 2 | # M4 bytes-on-wire measurement: a typing-heavy workload, then compare | ||
| 3 | # delta bytes actually sent against the measured full-snapshot equivalent. | ||
| 4 | set -eu | ||
| 5 | MUXD="$1" | ||
| 6 | MUX="$2" | ||
| 7 | SOCK="${TMPDIR:-/tmp}/muxd-bench-$$.sock" | ||
| 8 | |||
| 9 | cleanup() { kill "$DPID" 2>/dev/null || true; rm -f "$SOCK"; } | ||
| 10 | trap cleanup EXIT INT TERM | ||
| 11 | |||
| 12 | "$MUXD" run --sock "$SOCK" --shell /bin/sh & | ||
| 13 | DPID=$! | ||
| 14 | i=0 | ||
| 15 | while [ ! -S "$SOCK" ] && [ "$i" -lt 50 ]; do sleep 0.1; i=$((i+1)); done | ||
| 16 | [ -S "$SOCK" ] || { echo "bench FAIL: socket never appeared"; exit 1; } | ||
| 17 | |||
| 18 | # Steady-state typing: 120 single characters with small gaps (no newlines, | ||
| 19 | # so no scroll; this is the workload the handoff's done-criterion names). | ||
| 20 | { | ||
| 21 | sleep 0.5 | ||
| 22 | j=0 | ||
| 23 | while [ "$j" -lt 120 ]; do printf 'x'; sleep 0.05; j=$((j+1)); done | ||
| 24 | sleep 0.5 | ||
| 25 | printf '\034' | ||
| 26 | } | "$MUX" --sock "$SOCK" > /dev/null | ||
| 27 | |||
| 28 | STATS="$("$MUXD" stats --sock "$SOCK")" | ||
| 29 | echo "$STATS" | ||
| 30 | |||
| 31 | DELTA=$(echo "$STATS" | sed -n 's/.*[^_]delta_bytes=\([0-9]*\).*/\1/p') | ||
| 32 | EQUIV=$(echo "$STATS" | sed -n 's/.*snapshot_equiv_bytes=\([0-9]*\).*/\1/p') | ||
| 33 | DELTAS=$(echo "$STATS" | sed -n 's/.*[^_]deltas=\([0-9]*\).*/\1/p') | ||
| 34 | SNAPS=$(echo "$STATS" | sed -n 's/.*snapshots=\([0-9]*\).*/\1/p') | ||
| 35 | [ -n "$DELTA" ] && [ -n "$EQUIV" ] && [ -n "$DELTAS" ] && [ -n "$SNAPS" ] && | ||
| 36 | [ "$EQUIV" -gt 0 ] || { | ||
| 37 | echo "bench FAIL: could not parse stats"; exit 1; | ||
| 38 | } | ||
| 39 | |||
| 40 | # A delta path that sent nothing would score a perfect 0% ratio. The | ||
| 41 | # workload must actually have produced deltas, and exactly one snapshot | ||
| 42 | # (the attach) — a mid-run resync means the differ fell back, and the | ||
| 43 | # ratio would be measuring the fallback rather than the deltas. | ||
| 44 | [ "$DELTAS" -gt 0 ] || { | ||
| 45 | echo "bench FAIL: no deltas sent; the delta path is dead, not efficient" | ||
| 46 | exit 1 | ||
| 47 | } | ||
| 48 | [ "$SNAPS" -eq 1 ] || { | ||
| 49 | echo "bench FAIL: expected exactly 1 snapshot (the attach), got $SNAPS" | ||
| 50 | exit 1 | ||
| 51 | } | ||
| 52 | RATIO=$(( DELTA * 100 / EQUIV )) | ||
| 53 | echo "delta bytes: $DELTA snapshot-equivalent: $EQUIV ratio: ${RATIO}%" | ||
| 54 | if [ "$RATIO" -ge 50 ]; then | ||
| 55 | echo "bench FAIL: deltas are not meaningfully smaller (M4 kill criterion)" | ||
| 56 | exit 1 | ||
| 57 | fi | ||
| 58 | echo "bench OK" | ||