a73x

aac3c2da

feat: prediction hides itself on a path too fast to see it

a73x   2026-08-23 11:14

Commit message
feat: prediction hides itself on a path too fast to see it

Over a unix socket to a daemon on the same box a confirm lands in about a
millisecond, inside the frame the keystroke was painted in. A correct
prediction is therefore invisible by construction, and the only ones the
eye can catch are the wrong ones: sitting underlined until they expire, and
charging a full repaint on the way out. The operator's "bbb" in a vim-mode
input box (collab 86159ab0) was every phantom and no benefit.

The overlay now measures its own keystroke-to-confirm round trip (smoothed
1/8, the whole path rather than a transport ping) and stops SHOWING once it
drops below 20ms, showing again above 30ms — mosh's pair, with the
hysteresis that keeps a jittery path from flapping. Predictions are still
queued and judged while hidden, so the estimate keeps moving and display is
still earned: a box that gets slow gets its overlay back.

`confident` is now the policy's answer and `visible()` the gate, for
predictAt and the painter alike. The stats line grows a `local` counter —
the number that says the gate, not the policy, is what is keeping an
overlay quiet. An unmeasured path is not local, which leaves a fresh WAN
attach on exactly the behaviour decisions.md's numbers were taken against.

The e2e raw-mode leg moves behind delaypipe to keep asserting the
earn/lose policy, and a new leg runs the same keys on the bare socket:
displayed=0, local=2, no underline ever reaches the screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

src/interact.zig
Old New
@@ -778,7 +778,7 @@ fn paintOverlay(
778 tty: proto.Size, 778 tty: proto.Size,
779 out_fd: std.posix.fd_t, 779 out_fd: std.posix.fd_t,
780 ) void { 780 ) void {
781 if (!overlay.confident or overlay.pendingCount() == 0) return; 781 if (!overlay.visible() or overlay.pendingCount() == 0) return;
782 var paint: std.ArrayList(u8) = .empty; 782 var paint: std.ArrayList(u8) = .empty;
783 defer paint.deinit(alloc); 783 defer paint.deinit(alloc);
784 paint.appendSlice(alloc, paint_mod.sync_begin) catch return; 784 paint.appendSlice(alloc, paint_mod.sync_begin) catch return;
@@ -858,10 +858,10 @@ fn formatPredictStats(buf: []u8, c: predict.Counters) ![]const u8 {
858 return std.fmt.bufPrint( 858 return std.fmt.bufPrint(
859 buf, 859 buf,
860 "predict made={d} displayed={d} confirmed={d} contradicted={d}" ++ 860 "predict made={d} displayed={d} confirmed={d} contradicted={d}" ++
861 " expired={d} abandoned={d} suppressed={d}", 861 " expired={d} abandoned={d} suppressed={d} local={d}",
862 .{ 862 .{
863 c.made, c.displayed, c.confirmed, c.contradicted, 863 c.made, c.displayed, c.confirmed, c.contradicted,
864 c.expired, c.abandoned, c.suppressed, 864 c.expired, c.abandoned, c.suppressed, c.local,
865 }, 865 },
866 ); 866 );
867 } 867 }
@@ -2623,12 +2623,17 @@ test "prediction: a promotion mid-burst counts the cell it makes visible" {
2623 ov.setMode(.{ .icanon = false, .echo = false }); // raw: display is earned 2623 ov.setMode(.{ .icanon = false, .echo = false }); // raw: display is earned
2624 const tty = proto.Size{ .cols = 80, .rows = 24 }; 2624 const tty = proto.Size{ .cols = 80, .rows = 24 };
2625 2625
2626 // The keystrokes are stamped with the real clock, so the frames that
2627 // confirm them land 100ms later: a remote path. At 0 the overlay would
2628 // measure a 0ms round trip and hide the promotion this test is about.
2629 const later = std.time.milliTimestamp() + 100;
2630
2626 // One confirm banked, one short of promotion. 2631 // One confirm banked, one short of promotion.
2627 offerKeystroke(alloc, &ov, replica, "a", tty, null_fd); 2632 offerKeystroke(alloc, &ov, replica, "a", tty, null_fd);
2628 replica.feed("a"); 2633 replica.feed("a");
2629 try std.testing.expectEqual( 2634 try std.testing.expectEqual(
2630 predict.Verdict.confirmed, 2635 predict.Verdict.confirmed,
2631 reconcileOverlay(alloc, &ov, replica, 1, 0), 2636 reconcileOverlay(alloc, &ov, replica, 1, later),
2632 ); 2637 );
2633 2638
2634 // Two more typed while still invisible, and the promoting confirmation 2639 // Two more typed while still invisible, and the promoting confirmation
@@ -2639,9 +2644,9 @@ test "prediction: a promotion mid-burst counts the cell it makes visible" {
2639 replica.feed("b"); 2644 replica.feed("b");
2640 try std.testing.expectEqual( 2645 try std.testing.expectEqual(
2641 predict.Verdict.confirmed, 2646 predict.Verdict.confirmed,
2642 reconcileOverlay(alloc, &ov, replica, 2, 0), 2647 reconcileOverlay(alloc, &ov, replica, 2, later),
2643 ); 2648 );
2644 try std.testing.expect(ov.confident); 2649 try std.testing.expect(ov.visible());
2645 try std.testing.expectEqual(@as(usize, 1), ov.pendingCount()); 2650 try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
2646 // Nothing has been drawn yet: it was queued invisible and no repaint 2651 // Nothing has been drawn yet: it was queued invisible and no repaint
2647 // has happened since the promotion. 2652 // has happened since the promotion.
@@ -2675,6 +2680,7 @@ test "the predict stats line is one greppable row of counters" {
2675 .expired = 1, 2680 .expired = 1,
2676 .abandoned = 7, 2681 .abandoned = 7,
2677 .suppressed = 6, 2682 .suppressed = 6,
2683 .local = 8,
2678 }); 2684 });
2679 // Pinned exactly: test/e2e.sh greps these key=value pairs, so a rename 2685 // Pinned exactly: test/e2e.sh greps these key=value pairs, so a rename
2680 // or a reorder is a broken suite rather than a cosmetic change. The 2686 // or a reorder is a broken suite rather than a cosmetic change. The
@@ -2682,7 +2688,7 @@ test "the predict stats line is one greppable row of counters" {
2682 // why every one of them is on the line rather than a chosen few. 2688 // why every one of them is on the line rather than a chosen few.
2683 try std.testing.expectEqualStrings( 2689 try std.testing.expectEqualStrings(
2684 "predict made=5 displayed=4 confirmed=3 contradicted=2" ++ 2690 "predict made=5 displayed=4 confirmed=3 contradicted=2" ++
2685 " expired=1 abandoned=7 suppressed=6", 2691 " expired=1 abandoned=7 suppressed=6 local=8",
2686 line, 2692 line,
2687 ); 2693 );
2688 } 2694 }
src/predict.zig
Old New
@@ -96,6 +96,11 @@ pub const Counters = struct {
96 /// saw. Not a subset of `made` either — none of these became 96 /// saw. Not a subset of `made` either — none of these became
97 /// predictions. 97 /// predictions.
98 suppressed: u64 = 0, 98 suppressed: u64 = 0,
99 /// PER PREDICTION: queued with display earned, and hidden anyway
100 /// because the path was measured too fast to show it. A subset of
101 /// `made` and disjoint from `displayed`; the number that says whether
102 /// the local gate is what is keeping an overlay quiet.
103 local: u64 = 0,
99 }; 104 };
100 105
101 /// What the pty's mode bits say about predicting here. 106 /// What the pty's mode bits say about predicting here.
@@ -122,6 +127,23 @@ pub const expire_after_frames: u8 = 8;
122 /// the application answers by going quiet. Milliseconds. 127 /// the application answers by going quiet. Milliseconds.
123 pub const expire_after_ms: i64 = 1000; 128 pub const expire_after_ms: i64 = 1000;
124 129
130 /// The round trip below which a prediction is never worth SHOWING. Over a
131 /// unix socket to a daemon on the same box a confirm lands in about a
132 /// millisecond, inside the frame the keystroke was painted in — so a
133 /// correct prediction is invisible by construction and the only ones the
134 /// eye can catch are the wrong ones, sitting underlined until they expire.
135 /// That is the "bbb" the operator saw in a vim-mode input box
136 /// (2026-08-23): every phantom, no benefit, on a path where prediction has
137 /// nothing to buy.
138 ///
139 /// Two triggers rather than one, with the smoothed estimate moving between
140 /// them, so a path with jitter around the line does not flap the overlay
141 /// on and off per keystroke. The 20/30 pair is mosh's, which has had its
142 /// adaptive-display mode gated on srtt the same way for a decade; the
143 /// smoothing is TCP's 1/8.
144 pub const local_below_ms: i64 = 20;
145 pub const local_above_ms: i64 = 30;
146
125 /// Engine-free mirror of the engine's cursor position. 147 /// Engine-free mirror of the engine's cursor position.
126 pub const CursorPos = struct { x: u16 = 0, y: u16 = 0 }; 148 pub const CursorPos = struct { x: u16 = 0, y: u16 = 0 };
127 149
@@ -190,10 +212,21 @@ pub const Overlay = struct {
190 alloc: std.mem.Allocator, 212 alloc: std.mem.Allocator,
191 pending: std.ArrayList(Pred) = .empty, 213 pending: std.ArrayList(Pred) = .empty,
192 ctx: Context = .never, 214 ctx: Context = .never,
193 /// The single gate on painting: true in `.always`, false in `.never`, 215 /// What the POLICY says about painting: true in `.always`, false in
194 /// and earned in `.adaptive`. 216 /// `.never`, and earned in `.adaptive`. Not the gate itself any more —
217 /// that is `visible`, which also asks whether the path is worth it.
195 confident: bool = false, 218 confident: bool = false,
196 streak: u8 = 0, 219 streak: u8 = 0,
220 /// Smoothed keystroke-to-confirm round trip, or null until the first
221 /// confirm has measured one. Counted from the keystroke's own clock to
222 /// the frame that confirmed it, so it is the whole path — tty read,
223 /// wire, daemon, pty, engine, wire back — and not a transport ping.
224 srtt_ms: ?i64 = null,
225 /// The path is too fast to show predictions on. Moves only when
226 /// `srtt_ms` crosses a trigger, so it carries the hysteresis; an
227 /// unmeasured path is not local, which keeps a fresh WAN attach on the
228 /// behaviour the numbers in decisions.md were measured against.
229 local: bool = false,
197 counters: Counters = .{}, 230 counters: Counters = .{},
198 cols: u16, 231 cols: u16,
199 rows: u16, 232 rows: u16,
@@ -310,13 +343,33 @@ pub const Overlay = struct {
310 }) catch return self.suppress(); 343 }) catch return self.suppress();
311 344
312 self.counters.made += 1; 345 self.counters.made += 1;
313 if (self.confident) { 346 if (self.visible()) {
314 self.markPainted(self.pending.items.len - 1); 347 self.markPainted(self.pending.items.len - 1);
315 return .{ .display = cell }; 348 return .{ .display = cell };
316 } 349 }
350 // Queued even when local, deliberately: a hidden prediction is still
351 // judged, so it keeps measuring the path and keeps the streak. That
352 // is what lets the overlay come back on if the box gets slow, and
353 // what made the local-gate tests cheap — no second state machine.
354 if (self.confident) self.counters.local += 1;
317 return .{ .hidden = cell }; 355 return .{ .hidden = cell };
318 } 356 }
319 357
358 /// The gate on painting: earned, AND on a path slow enough to show it.
359 pub fn visible(self: *const Overlay) bool {
360 return self.confident and !self.local;
361 }
362
363 /// Fold one measured round trip into the estimate and move `local`
364 /// if it crossed a trigger. Confirms only — an expiry says nothing
365 /// about how fast the path is, only that the application went quiet.
366 fn samplePath(self: *Overlay, rtt_ms: i64) void {
367 const srtt = if (self.srtt_ms) |s| s + @divTrunc(rtt_ms - s, 8) else rtt_ms;
368 self.srtt_ms = srtt;
369 if (srtt <= local_below_ms) self.local = true;
370 if (srtt >= local_above_ms) self.local = false;
371 }
372
320 fn suppress(self: *Overlay) Outcome { 373 fn suppress(self: *Overlay) Outcome {
321 self.recordSuppressed(); 374 self.recordSuppressed();
322 return .suppressed; 375 return .suppressed;
@@ -359,6 +412,7 @@ pub const Overlay = struct {
359 if (shown != null and shown.? == p.cell.ch) { 412 if (shown != null and shown.? == p.cell.ch) {
360 _ = self.pending.orderedRemove(i); 413 _ = self.pending.orderedRemove(i);
361 self.counters.confirmed += 1; 414 self.counters.confirmed += 1;
415 self.samplePath(now_ms -| p.made_ms);
362 self.streak +|= 1; 416 self.streak +|= 1;
363 if (self.ctx == .adaptive and self.streak >= promote_after) { 417 if (self.ctx == .adaptive and self.streak >= promote_after) {
364 self.confident = true; 418 self.confident = true;
@@ -471,14 +525,17 @@ fn typeAt(ov: *Overlay, x: u16, y: u16, ch: u8) Outcome {
471 }); 525 });
472 } 526 }
473 527
474 /// The clock stands still; tests about time use `seeRowsAt`. 528 /// A frame from a remote path. Tests about time or the gate use `seeRowsAt`.
475 fn seeRows( 529 fn seeRows(
476 alloc: std.mem.Allocator, 530 alloc: std.mem.Allocator,
477 ov: *Overlay, 531 ov: *Overlay,
478 rows: []const []const u8, 532 rows: []const []const u8,
479 seq: u64, 533 seq: u64,
480 ) !Verdict { 534 ) !Verdict {
481 return seeRowsAt(alloc, ov, rows, seq, 0); 535 // 100ms after the keystroke, not 0: at 0 every confirm is a measured
536 // 0ms round trip, the local gate closes, and every `.display` the
537 // policy tests assert goes `.hidden` for a reason they are not about.
538 return seeRowsAt(alloc, ov, rows, seq, 100);
482 } 539 }
483 540
484 fn seeRowsAt( 541 fn seeRowsAt(
@@ -652,6 +709,89 @@ test "adaptive earns the right to display, one confirm at a time" {
652 try std.testing.expectEqual(@as(u64, 1), ov.counters.displayed); 709 try std.testing.expectEqual(@as(u64, 1), ov.counters.displayed);
653 } 710 }
654 711
712 test "a path that answers faster than the eye can see earns display and still hides" {
713 const alloc = std.testing.allocator;
714 var ov = Overlay.init(alloc, 80, 24);
715 defer ov.deinit();
716 ov.setMode(.{ .icanon = false, .echo = false });
717
718 // Two confirms, each 1ms after its keystroke: a unix socket to a daemon
719 // on the same box. Confidence is earned exactly as over a WAN...
720 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
721 try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{"a"}, 1, 1));
722 try std.testing.expect(typeAt(&ov, 1, 0, 'b') == .hidden);
723 try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{"ab"}, 2, 1));
724 try std.testing.expect(ov.confident);
725
726 // ...and the third keystroke is queued but NOT shown: a prediction the
727 // real glyph overtakes within a frame can only ever be seen when it is
728 // wrong. `displayed` stays at zero, which is what the repro counted.
729 try std.testing.expect(typeAt(&ov, 2, 0, 'c') == .hidden);
730 try std.testing.expect(!ov.visible());
731 try std.testing.expectEqual(@as(u64, 3), ov.counters.made);
732 try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed);
733 try std.testing.expectEqual(@as(u64, 1), ov.counters.local);
734 }
735
736 test "the local gate has hysteresis, and an unmeasured path is not local" {
737 const alloc = std.testing.allocator;
738 var ov = Overlay.init(alloc, 80, 24);
739 defer ov.deinit();
740 ov.setMode(.{ .icanon = false, .echo = false });
741 // Before any confirm there is no measurement, and the WAN behaviour
742 // stands unchanged: earn display, show.
743 try std.testing.expect(!ov.local);
744 try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
745 try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{"a"}, 1, 150));
746 try std.testing.expect(typeAt(&ov, 1, 0, 'b') == .hidden);
747 try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{"ab"}, 2, 150));
748 try std.testing.expect(ov.visible());
749 try std.testing.expectEqual(@as(i64, 150), ov.srtt_ms.?);
750
751 // One fast confirm does not flip it: the estimate is smoothed
752 // (150 - 150/8 = 132), and nothing above `local_below_ms` turns it off.
753 try std.testing.expect(typeAt(&ov, 2, 0, 'c') == .display);
754 try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{"abc"}, 3, 0));
755 try std.testing.expectEqual(@as(i64, 132), ov.srtt_ms.?);
756 try std.testing.expect(ov.visible());
757
758 // A run of fast confirms does. The keystroke clock stays at 0 (typeAt),
759 // so each confirm at 0 is a 0ms round trip.
760 var col: u16 = 3;
761 var row = [_]u8{ 'a', 'b', 'c' } ++ [_]u8{' '} ** 60;
762 while (ov.visible()) : (col += 1) {
763 row[col] = 'x';
764 _ = typeAt(&ov, col, 0, 'x');
765 try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{row[0 .. col + 1]}, col + 1, 0));
766 }
767 try std.testing.expect(ov.local);
768 try std.testing.expect(ov.srtt_ms.? <= local_below_ms);
769
770 // Back above the upper trigger and it shows again; the band between the
771 // two is where a jittery path would otherwise flap every keystroke.
772 while (!ov.visible()) : (col += 1) {
773 row[col] = 'x';
774 _ = typeAt(&ov, col, 0, 'x');
775 try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{row[0 .. col + 1]}, col + 1, 400));
776 }
777 try std.testing.expect(ov.srtt_ms.? >= local_above_ms);
778 }
779
780 test "a mode change keeps the path estimate: the wire did not move" {
781 const alloc = std.testing.allocator;
782 var ov = Overlay.init(alloc, 80, 24);
783 defer ov.deinit();
784 ov.setMode(.{ .icanon = false, .echo = false });
785 _ = typeAt(&ov, 0, 0, 'a');
786 try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{"a"}, 1, 1));
787 try std.testing.expect(ov.local);
788 // readline hands the tty back and forth around every command; the
789 // daemon is no further away afterwards.
790 ov.setMode(.{ .icanon = true, .echo = true });
791 try std.testing.expect(ov.local);
792 try std.testing.expect(!ov.visible());
793 }
794
655 test "a contradiction flushes the whole queue, not merely the cell that was wrong" { 795 test "a contradiction flushes the whole queue, not merely the cell that was wrong" {
656 const alloc = std.testing.allocator; 796 const alloc = std.testing.allocator;
657 var ov = Overlay.init(alloc, 80, 24); 797 var ov = Overlay.init(alloc, 80, 24);
test/e2e.sh
Old New
@@ -1353,9 +1353,9 @@ cleanup() {
1353 "$OUT.s8.err" "$OUT.race" "$OUT.g9.err" "$OUT.env1.err" "$OUT.env2.err" \ 1353 "$OUT.s8.err" "$OUT.race" "$OUT.g9.err" "$OUT.env1.err" "$OUT.env2.err" \
1354 "$OUT.m7.err" "$OUT.m7b.err" "$OUT.qc.err" "$OUT.qr.err" "$OUT.qk.err" \ 1354 "$OUT.m7.err" "$OUT.m7b.err" "$OUT.qc.err" "$OUT.qr.err" "$OUT.qk.err" \
1355 "$SOCK5" "$SOCK6" "$SOCK7" "$PWSH" \ 1355 "$SOCK5" "$SOCK6" "$SOCK7" "$PWSH" \
1356 "$OUT.p1" "$OUT.p1.early" "$OUT.pb" "$OUT.pw" "$OUT.rw" "$OUT.pr" \ 1356 "$OUT.p1" "$OUT.p1.early" "$OUT.pb" "$OUT.pw" "$OUT.rw" "$OUT.rwl" "$OUT.pr" \
1357 "$OUT.p1.err" "$OUT.pb.err" "$OUT.pw.err" "$OUT.rw.err" "$OUT.pr.err" \ 1357 "$OUT.p1.err" "$OUT.pb.err" "$OUT.pw.err" "$OUT.rw.err" "$OUT.rwl.err" "$OUT.pr.err" \
1358 "$OUT.p1.d" "$OUT.pw.d" "$OUT.rw.d" "$OUT.pr.d" \ 1358 "$OUT.p1.d" "$OUT.pw.d" "$OUT.rw.d" "$OUT.rwl.d" "$OUT.pr.d" \
1359 "$OUT.d1.d" "$OUT.d2.d" "$OUT.d3a.d" "$OUT.d3b.d" "$OUT.d4.d" \ 1359 "$OUT.d1.d" "$OUT.d2.d" "$OUT.d3a.d" "$OUT.d3b.d" "$OUT.d4.d" \
1360 "$OUT.d9.d" "$OUT.d10.d" "$OUT.d17.d" \ 1360 "$OUT.d9.d" "$OUT.d10.d" "$OUT.d17.d" \
1361 "$SOCK" "$SOCK2" "$SOCK3" "$SOCK4" "$SOCK4.second" "$QKEY" "$QKEY.bad" \ 1361 "$SOCK" "$SOCK2" "$SOCK3" "$SOCK4" "$SOCK4.second" "$QKEY" "$QKEY.bad" \
@@ -2738,14 +2738,23 @@ D6PID=""
2738 # 4. Raw mode: display is earned, then lost to a keystroke the application 2738 # 4. Raw mode: display is earned, then lost to a keystroke the application
2739 # swallows. rawmode echoes like an editor in insert mode until it is sent 2739 # swallows. rawmode echoes like an editor in insert mode until it is sent
2740 # 0x00, after which it consumes input and prints nothing. 2740 # 0x00, after which it consumes input and prints nothing.
2741 #
2742 # Through delaypipe, like leg 1, and for the same reason leg 4b below is
2743 # NOT: the overlay measures its own round trip and hides everything on a
2744 # path faster than `predict.local_below_ms`. This leg is about the
2745 # earn/lose policy, which only a remote path lets through to the screen.
2746 # Keys are 1s apart so each confirm (600ms) lands before the next key —
2747 # at 0.5s `c` would find only `a` confirmed and stay hidden.
2741 "$MUXD" run --sock "$SOCK7" --shell "$RAWMODE" > "$OUT.rw.d" 2>&1 & 2748 "$MUXD" run --sock "$SOCK7" --shell "$RAWMODE" > "$OUT.rw.d" 2>&1 &
2742 D7PID=$! 2749 D7PID=$!
2743 wait_sock "$SOCK7" "$OUT.rw.d" "rawmode daemon never bound" 2750 wait_sock "$SOCK7" "$OUT.rw.d" "rawmode daemon never bound"
2744 2751
2745 set +e 2752 set +e
2746 { sleep 2; for c in a b c; do printf '%s' "$c"; sleep 0.5; done; \ 2753 { sleep 2; for c in a b c; do printf '%s' "$c"; sleep 1; done; \
2747 printf '\000'; sleep 0.5; printf 'j'; sleep 2.5; printf '\034\034'; } | \ 2754 printf '\000'; sleep 1; printf 'j'; sleep 2.5; printf '\034\034'; } | \
2748 MUX_PREDICT_STATS=1 timeout 40 "$MUX" --sock "$SOCK7" > "$OUT.rw" 2> "$OUT.rw.err" 2755 DELAY_MS="$PDELAY" MUX_PREDICT_STATS=1 timeout 40 "$MUX" \
2756 --via "$DELAYPIPE | $MUXD proxy --sock $SOCK7 | $DELAYPIPE" \
2757 > "$OUT.rw" 2> "$OUT.rw.err"
2749 RC=$? 2758 RC=$?
2750 set -e 2759 set -e
2751 [ "$RC" -eq 0 ] || { 2760 [ "$RC" -eq 0 ] || {
@@ -2781,6 +2790,44 @@ RW_ABND=$(predict_stat "$OUT.rw.err" abandoned)
2781 # exactly the phantom the styled half of this check exists to catch. 2790 # exactly the phantom the styled half of this check exists to catch.
2782 assert_converged "$OUT.rw" "$SOCK7" "raw mode" 2791 assert_converged "$OUT.rw" "$SOCK7" "raw mode"
2783 2792
2793 # 4b. The same keystrokes on the bare socket: a local daemon answers in
2794 # about a millisecond, and that is the one path on which a prediction
2795 # can only ever be SEEN when it is wrong. Display is still earned (the
2796 # counters say so) and nothing is painted — `local` is where `c` and
2797 # `j` went instead of `displayed`. `j` still expires: the queue keeps
2798 # judging while hidden, which is how the gate would lift again on a
2799 # box that got slow. The screen check is the point: no phantom `j`,
2800 # because there was never an underlined one to leave behind.
2801 #
2802 # A fresh daemon: the rawmode above has taken its 0x00 and echoes
2803 # nothing now, and the leg needs the insert-mode half back.
2804 kill "$D7PID" 2>/dev/null || true
2805 wait "$D7PID" 2>/dev/null || true
2806 rm -f "$SOCK7"
2807 "$MUXD" run --sock "$SOCK7" --shell "$RAWMODE" > "$OUT.rwl.d" 2>&1 &
2808 D7PID=$!
2809 wait_sock "$SOCK7" "$OUT.rwl.d" "rawmode local daemon never bound"
2810
2811 set +e
2812 { sleep 2; for c in a b c; do printf '%s' "$c"; sleep 0.5; done; \
2813 printf '\000'; sleep 0.5; printf 'j'; sleep 2.5; printf '\034\034'; } | \
2814 MUX_PREDICT_STATS=1 timeout 40 "$MUX" --sock "$SOCK7" > "$OUT.rwl" 2> "$OUT.rwl.err"
2815 RC=$?
2816 set -e
2817 [ "$RC" -eq 0 ] || {
2818 echo "e2e FAIL: rawmode local client exited $RC"
2819 cat "$OUT.rwl" "$OUT.rwl.err" 2>/dev/null; exit 1; }
2820 want_stat "$OUT.rwl.err" made 4 "raw mode local"
2821 want_stat "$OUT.rwl.err" confirmed 3 "raw mode local"
2822 want_stat "$OUT.rwl.err" displayed 0 "raw mode local"
2823 want_stat "$OUT.rwl.err" local 2 "raw mode local"
2824 want_stat "$OUT.rwl.err" expired 1 "raw mode local"
2825 grep -q "$(printf '\033\[4m')" "$OUT.rwl" && {
2826 echo "e2e FAIL: raw mode local: an underlined prediction reached the screen"
2827 cat -v "$OUT.rwl"; exit 1;
2828 }
2829 assert_converged "$OUT.rwl" "$SOCK7" "raw mode local"
2830
2784 kill "$D7PID" 2>/dev/null || true 2831 kill "$D7PID" 2>/dev/null || true
2785 D7PID="" 2832 D7PID=""
2786 2833
@@ -8136,8 +8183,8 @@ DPID=""
8136 echo " a scenario was added (update the pin) or silently lost" 8183 echo " a scenario was added (update the pin) or silently lost"
8137 exit 1 8184 exit 1
8138 } 8185 }
8139 [ "$CONV_COUNT" = "36" ] || { 8186 [ "$CONV_COUNT" = "37" ] || {
8140 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 36" 8187 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 37"
8141 exit 1 8188 exit 1
8142 } 8189 }
8143 echo "e2e OK (65 scenarios, 36 convergence points)" 8190 echo "e2e OK (65 scenarios, 37 convergence points)"