M0: compilable skeleton — Kigi 0.1.0 fork surgery

Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
ZacharyZhang-NY committed 2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 files changed
+1353757

No files matched your search

@@ -0,0 +1,403 @@
//! The cell table: terminal-class × config × gesture rows the matrix runs.
//!
//! Classes are `ScrollConfig` equivalence classes, not brands — every brand
//! sharing a profile is represented once (`from_terminal_context` in the
//! pager's `mouse.rs` is the source of truth):
//!
//! | class | env | ept | wheel_lpt | trackpad_lpt |
//! |-------|----------------------------|-----|-----------|--------------|
//! | C1 | none (harness strips) | 3 | 3 | 3 |
//! | C2 | `TERM_PROGRAM=iTerm.app` | 1 | 1 | 3 |
//! | C3 | `TERM_PROGRAM=zed` | 1 | 3 | 3 |
//! | C4 | `TERM_PROGRAM=vscode` | 1 | 3 | 15 |
//! | C5 | `TMUX=…` (remuxed) | 1 | 1 | 3 |
//!
//! Mux honesty: C5's env only exercises profile *selection* (the pager
//! can't tell a fake `TMUX` from a real one); real tmux event-mangling is
//! simulated by the G9 gesture shapes, and a real-tmux tier stays local.
//!
//! Trim note: the design sketched ~40 rows; this table ships the curated 8
//! plus 17 representative full-tier rows (every class, every gesture, every
//! config knob at least once) to hold the A12 size budget — growing the
//! full tier is additive row work.
use super::gestures::GestureId;
use super::invariants::InvariantId;
use super::session::SessionKind;
/// The config echo a cell expects on every `stream_start` (I-CFG) and the
/// pricing inputs the consistency invariants use.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ExpectedProfile {
/// `mode` label: `auto` | `wheel` | `trackpad`.
pub mode: &'static str,
pub ept: u16,
pub wheel_lpt: u16,
pub trackpad_lpt: u16,
pub invert: bool,
/// Speed multiplier (NOT the 1-100 setting): `KIGI_SCROLL_SPEED=100`
/// echoes 6.0 via the pager's `speed_to_multiplier`.
pub speed: f32,
}
/// Execution tier: `Curated` runs in CI (A13's runner test); `Full` only in
/// the local full sweep.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Tier {
Curated,
Full,
}
/// One matrix cell: environment in, gesture replayed, invariants judged.
#[derive(Clone, Copy, Debug)]
pub struct MatrixCell {
pub id: &'static str,
pub tier: Tier,
/// Pager env pairs (terminal-class markers + config vars). The runner
/// appends `KIGI_SCROLL_LOG`; the harness's env strips guarantee the
/// host terminal can't leak competing markers underneath these.
pub env: &'static [(&'static str, &'static str)],
pub expected: ExpectedProfile,
pub gesture: GestureId,
pub session: SessionKind,
/// Invariants judged for this cell (harness-side ids included; the
/// runner routes by `InvariantId::is_log_side`).
pub invariants: &'static [InvariantId],
/// Invariants expected to VIOLATE on current code (known bugs, e.g. the
/// G4 jerk until A13's finalize-decel fix). Must be ⊆ `invariants`; the
/// runner fails a cell on any non-xfail violation AND on an xfail PASS
/// (a fixed bug must be promoted out of xfail, not silently absorbed).
pub xfail: &'static [InvariantId],
}
const C1: ExpectedProfile = ExpectedProfile {
mode: "auto",
ept: 3,
wheel_lpt: 3,
trackpad_lpt: 3,
invert: false,
speed: 1.0,
};
const C2: ExpectedProfile = ExpectedProfile {
ept: 1,
wheel_lpt: 1,
..C1
};
const C3: ExpectedProfile = ExpectedProfile { ept: 1, ..C1 };
const C4: ExpectedProfile = ExpectedProfile {
ept: 1,
trackpad_lpt: 15,
..C1
};
/// Remuxed conservative profile — identical numbers to C2 by design
/// (`multiplexer_reencodes_mouse` forces ept=1/wheel_lpt=1).
const C5: ExpectedProfile = C2;
const ITERM: (&str, &str) = ("TERM_PROGRAM", "iTerm.app");
const ZED: (&str, &str) = ("TERM_PROGRAM", "zed");
const VSCODE: (&str, &str) = ("TERM_PROGRAM", "vscode");
const TMUX: (&str, &str) = ("TMUX", "/tmp/tmux-0/default,1,0");
const SPEED100: (&str, &str) = ("KIGI_SCROLL_SPEED", "100");
const MODE_WHEEL: (&str, &str) = ("KIGI_SCROLL_MODE", "wheel");
const MODE_TRACKPAD: (&str, &str) = ("KIGI_SCROLL_MODE", "trackpad");
const LINES1: (&str, &str) = ("KIGI_SCROLL_LINES", "1");
const INVERT: (&str, &str) = ("KIGI_INVERT_SCROLL", "1");
use InvariantId::*;
/// Core log-side suite for auto-mode cells.
const AUTO: &[InvariantId] = &[Ord, Cap, DropEq, Cadence, ConsA, Accel, Carry, Cfg];
const AUTO_NODROP: &[InvariantId] = &[Ord, Cap, DropEq, Cadence, ConsA, Accel, Carry, Cfg, NoDrop];
/// Floods keep the core suite (drops are legitimate) + post-gesture quiet.
const AUTO_QUIET: &[InvariantId] = &[Ord, Cap, DropEq, Cadence, ConsA, Accel, Carry, Cfg, Quiet];
/// Forced wheel: exact totals; nothing may drop on notch-scale gestures.
const WHEEL: &[InvariantId] = &[Ord, Cap, DropEq, Cadence, ConsW, Accel, Carry, Cfg, NoDrop];
const WHEEL_MUX: &[InvariantId] = &[
Ord, Cap, DropEq, Cadence, ConsW, Accel, Carry, Cfg, NoDrop, MuxNoOver,
];
const AUTO_MUX_NODROP: &[InvariantId] = &[
Ord, Cap, DropEq, Cadence, ConsA, Accel, Carry, Cfg, NoDrop, MuxNoOver,
];
const AUTO_SCREEN: &[InvariantId] = &[Ord, Cap, DropEq, Cadence, ConsA, Accel, Carry, Cfg, Screen];
/// The jerk suite: core + the two smoothness invariants the finalize-decel
/// fix made hold (formerly this cell's xfail set).
const JERK: &[InvariantId] = &[
Ord,
Cap,
DropEq,
Cadence,
ConsA,
Accel,
Carry,
Cfg,
SmoothCoast,
NoDrop,
];
const fn cell(
id: &'static str,
tier: Tier,
env: &'static [(&'static str, &'static str)],
expected: ExpectedProfile,
gesture: GestureId,
session: SessionKind,
invariants: &'static [InvariantId],
) -> MatrixCell {
MatrixCell {
id,
tier,
env,
expected,
gesture,
session,
invariants,
xfail: &[],
}
}
/// The matrix. Ids are `<class>_<config>_<gesture>[_qualifier]`.
#[rustfmt::skip]
pub const CELLS: &[MatrixCell] = &[
// ── Curated (CI tier, 8 cells) ─────────────────────────────────────
cell("c1_auto_g3_flood_speed100", Tier::Curated, &[SPEED100],
ExpectedProfile { speed: 6.0, ..C1 }, GestureId::G3Flood, SessionKind::Settled, AUTO_QUIET),
cell("c2_auto_g3_flood_speed100", Tier::Curated, &[ITERM, SPEED100],
ExpectedProfile { speed: 6.0, ..C2 }, GestureId::G3Flood, SessionKind::Settled, AUTO_QUIET),
cell("c3_wheel_lines1_g1", Tier::Curated, &[ZED, MODE_WHEEL, LINES1],
ExpectedProfile { mode: "wheel", wheel_lpt: 1, trackpad_lpt: 1, ..C3 },
GestureId::G1Notch, SessionKind::Settled, WHEEL),
cell("c4_auto_g10_ambiguous", Tier::Curated, &[VSCODE],
C4, GestureId::G10AmbiguousSlow, SessionKind::Settled, AUTO_NODROP),
cell("c5_tmux_g9a", Tier::Curated, &[TMUX],
C5, GestureId::G9aMuxSingles, SessionKind::Settled, AUTO_MUX_NODROP),
cell("c5_tmux_g9b", Tier::Curated, &[TMUX],
C5, GestureId::G9bMuxBatch, SessionKind::Settled, AUTO_MUX_NODROP),
cell("c1_auto_g8_midstream", Tier::Curated, &[],
C1, GestureId::G8MidStreamTrain, SessionKind::Streaming, AUTO),
// Id kept for artifact/test continuity: the cell pinned the G4 jerk as
// xfail until the finalize-decel fix; its former xfail rows
// (I-SMOOTH-COAST, I-NO-DROP) are ordinary pass rows now.
cell("c1_auto_g4_jerk_xfail", Tier::Curated, &[],
C1, GestureId::G4Jerk, SessionKind::Settled, JERK),
// ── Full tier (local sweep; representative subset — see trim note) ─
cell("c1_auto_g1", Tier::Full, &[], C1,
GestureId::G1Notch, SessionKind::Settled, AUTO_NODROP),
cell("c1_auto_g2", Tier::Full, &[], C1,
GestureId::G2NotchTrain, SessionKind::Settled, AUTO_NODROP),
cell("c1_auto_g5_ghostty_dup", Tier::Full, &[], C1,
GestureId::G5GhosttyDup, SessionKind::Settled, AUTO_NODROP),
cell("c1_auto_g6_flip", Tier::Full, &[], C1,
GestureId::G6Flip, SessionKind::Settled, AUTO),
cell("c1_auto_g7_overscroll", Tier::Full, &[], C1,
GestureId::G7Overscroll, SessionKind::BottomPinned, AUTO_SCREEN),
cell("c1_wheel_g2", Tier::Full, &[MODE_WHEEL], ExpectedProfile { mode: "wheel", ..C1 },
GestureId::G2NotchTrain, SessionKind::Settled, WHEEL),
cell("c1_trackpad_g3_flood", Tier::Full, &[MODE_TRACKPAD],
ExpectedProfile { mode: "trackpad", ..C1 },
GestureId::G3Flood, SessionKind::Settled, AUTO_QUIET),
cell("c1_auto_g11_carry", Tier::Full, &[], C1,
GestureId::G11Carry, SessionKind::Settled, AUTO_NODROP),
cell("c1_invert_g1", Tier::Full, &[INVERT], ExpectedProfile { invert: true, ..C1 },
GestureId::G1Notch, SessionKind::Settled, AUTO_NODROP),
cell("c2_auto_g1", Tier::Full, &[ITERM], C2,
GestureId::G1Notch, SessionKind::Settled, AUTO_NODROP),
cell("c2_wheel_g1", Tier::Full, &[ITERM, MODE_WHEEL],
ExpectedProfile { mode: "wheel", ..C2 },
GestureId::G1Notch, SessionKind::Settled, WHEEL),
cell("c3_auto_g1", Tier::Full, &[ZED], C3,
GestureId::G1Notch, SessionKind::Settled, AUTO_NODROP),
cell("c3_auto_g10_repricing", Tier::Full, &[ZED], C3,
GestureId::G10AmbiguousSlow, SessionKind::Settled, AUTO_NODROP),
cell("c4_auto_g3_flood", Tier::Full, &[VSCODE], C4,
GestureId::G3Flood, SessionKind::Settled, AUTO_QUIET),
cell("c4_lines1_g10", Tier::Full, &[VSCODE, LINES1],
ExpectedProfile { wheel_lpt: 1, trackpad_lpt: 1, ..C4 },
GestureId::G10AmbiguousSlow, SessionKind::Settled, AUTO_NODROP),
cell("c5_wheel_g9b", Tier::Full, &[TMUX, MODE_WHEEL],
ExpectedProfile { mode: "wheel", ..C5 },
GestureId::G9bMuxBatch, SessionKind::Settled, WHEEL_MUX),
cell("c5_auto_g6_flip", Tier::Full, &[TMUX], C5,
GestureId::G6Flip, SessionKind::Settled, AUTO),
];
/// The CI subset.
pub fn curated() -> impl Iterator<Item = &'static MatrixCell> {
CELLS.iter().filter(|c| c.tier == Tier::Curated)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
/// Re-derive the expected profile from a cell's env pairs the way the
/// pager would (`from_terminal_context` + the `KIGI_SCROLL_*` env
/// overrides) — a tripwire against rows drifting from `mouse.rs`.
fn derive_expected(env: &'static [(&'static str, &'static str)]) -> ExpectedProfile {
let get = |k: &str| env.iter().find(|(key, _)| *key == k).map(|(_, v)| *v);
let remuxed = get("TMUX").is_some();
let (ept, mut wheel_lpt, mut trackpad_lpt) = if remuxed {
(1, 1, 3)
} else {
match get("TERM_PROGRAM") {
Some("iTerm.app") => (1, 1, 3),
Some("zed") => (1, 3, 3),
Some("vscode") => (1, 3, 15),
None => (3, 3, 3),
Some(other) => panic!("unmapped TERM_PROGRAM {other:?}"),
}
};
if let Some(lines) = get("KIGI_SCROLL_LINES") {
let lines: u16 = lines.parse().unwrap();
wheel_lpt = lines;
trackpad_lpt = lines; // one knob overrides both paths
}
// speed_to_multiplier re-derivation for the settings used in rows.
let speed = match get("KIGI_SCROLL_SPEED") {
None | Some("50") => 1.0,
Some("100") => 6.0,
Some(other) => panic!("unmapped KIGI_SCROLL_SPEED {other:?}"),
};
ExpectedProfile {
mode: get("KIGI_SCROLL_MODE").unwrap_or("auto"),
ept,
wheel_lpt,
trackpad_lpt,
invert: get("KIGI_INVERT_SCROLL") == Some("1"),
speed,
}
}
#[test]
fn ids_are_unique() {
let mut seen = HashSet::new();
for cell in CELLS {
assert!(seen.insert(cell.id), "duplicate cell id {}", cell.id);
}
}
#[test]
fn curated_tier_is_the_designed_eight() {
let ids: Vec<&str> = curated().map(|c| c.id).collect();
assert_eq!(
ids,
[
"c1_auto_g3_flood_speed100",
"c2_auto_g3_flood_speed100",
"c3_wheel_lines1_g1",
"c4_auto_g10_ambiguous",
"c5_tmux_g9a",
"c5_tmux_g9b",
"c1_auto_g8_midstream",
"c1_auto_g4_jerk_xfail",
]
);
}
#[test]
fn xfail_is_a_subset_of_the_cell_invariants() {
for cell in CELLS {
for id in cell.xfail {
assert!(
cell.invariants.contains(id),
"{}: xfail {} not in the invariant list",
cell.id,
id.as_str()
);
}
}
// The finalize-decel fix promoted the jerk cell's xfail rows
// (I-SMOOTH-COAST, I-NO-DROP) into ordinary pass rows: the table
// must carry no xfail anywhere until the next pinned bug.
let jerk = CELLS
.iter()
.find(|c| c.id == "c1_auto_g4_jerk_xfail")
.unwrap();
assert!(
jerk.xfail.is_empty(),
"the jerk is fixed; xfail must stay empty"
);
assert!(
[InvariantId::SmoothCoast, InvariantId::NoDrop]
.iter()
.all(|id| jerk.invariants.contains(id)),
"the promoted invariants must remain in the pass set"
);
}
#[test]
fn expected_profiles_agree_with_env() {
for cell in CELLS {
assert_eq!(
cell.expected,
derive_expected(cell.env),
"{}: expected profile drifted from its env",
cell.id
);
}
}
#[test]
fn invariant_lists_are_coherent() {
for cell in CELLS {
let mut seen = HashSet::new();
for id in cell.invariants {
assert!(seen.insert(id), "{}: duplicate {}", cell.id, id.as_str());
}
// Consistency invariants match the forced mode.
assert_eq!(
cell.invariants.contains(&InvariantId::ConsW),
cell.expected.mode == "wheel",
"{}: I-CONS-W ⇔ forced wheel",
cell.id
);
assert_eq!(
cell.invariants.contains(&InvariantId::ConsA),
cell.expected.mode != "wheel",
"{}: I-CONS-A ⇔ not forced wheel",
cell.id
);
// Mux over-scroll bound only makes sense on the remuxed class,
// and only on its accel-free G9 gestures.
if cell.invariants.contains(&InvariantId::MuxNoOver) {
assert!(
matches!(
cell.gesture,
GestureId::G9aMuxSingles | GestureId::G9bMuxBatch
) && cell.env.iter().any(|(k, _)| *k == "TMUX"),
"{}: I-MUX-NO-OVER outside the mux/G9 envelope",
cell.id
);
}
}
}
#[test]
fn sessions_and_gestures_pair_correctly() {
for cell in CELLS {
// TMUX ⇒ the conservative remuxed profile.
if cell.env.iter().any(|(k, _)| *k == "TMUX") {
assert_eq!(
(cell.expected.ept, cell.expected.wheel_lpt),
(1, 1),
"{}",
cell.id
);
}
// Streaming sessions exist exactly for the mid-stream gesture;
// the bottom-pin matters exactly for the overscroll gesture.
assert_eq!(
cell.session == SessionKind::Streaming,
cell.gesture == GestureId::G8MidStreamTrain,
"{}",
cell.id
);
assert_eq!(
cell.session == SessionKind::BottomPinned,
cell.gesture == GestureId::G7Overscroll,
"{}",
cell.id
);
}
}
}
@@ -0,0 +1,371 @@
//! Gesture step tables G1–G11: the timed SGR wheel-report shapes every
//! matrix cell replays.
//!
//! A gesture is a `&'static [WheelStep]`; the A13 runner emits each step's
//! report after sleeping `pre_delay_ms` (never before the first step, never
//! after the last — the `send_wheel_sequence` contract in the pager's
//! `tests/pty_e2e/scroll.rs`). Delays are HOST-side lower bounds: scheduler
//! jitter can only stretch gaps, which is why the invariant suite
//! ([`super::invariants`]) judges timing from the recorder's own clock.
//!
//! Timing thresholds below mirror the pager's `src/input/mouse.rs` (the
//! harness deliberately has no pager dependency — same drift-tripwire
//! stance as [`super::log`]): a table's shape is meaningful only relative
//! to those constants, e.g. G2's 50ms notch gap sits under `STREAM_GAP_MS`
//! (one stream) but over `WHEEL_TICK_DETECT_MAX_MS` (no wheel promotion of
//! the train as one tick).
use crate::scripted::{SGR_SCROLL_DOWN, SGR_SCROLL_UP};
/// Mirror of `mouse.rs` `REDRAW_CADENCE_MS`: minimum flush spacing.
pub const REDRAW_CADENCE_MS: u64 = 16;
/// Mirror of `mouse.rs` `STREAM_GAP_MS`: idle gap that finalizes a stream.
pub const STREAM_GAP_MS: u64 = 80;
/// Mirror of `mouse.rs` `DEFAULT_WHEEL_TICK_DETECT_MAX_MS`: an ept≥2 stream
/// promotes to wheel only when the first tick completes within this window.
pub const WHEEL_TICK_DETECT_MAX_MS: u64 = 12;
/// Mirror of `mouse.rs` `ACCEL_MIN_INTERVAL_MS`: sub-6ms inter-event
/// intervals are terminal batching artifacts and stay out of the
/// accel/detection interval window.
pub const ACCEL_MIN_INTERVAL_MS: f64 = 6.0;
/// Mirror of `mouse.rs` `DEFAULT_TRACKPAD_ACCEL_MAX`: accel clamp ceiling.
pub const TRACKPAD_ACCEL_MAX: f64 = 3.0;
/// Mirror of `mouse.rs` `MIN_LINES_PER_WHEEL_STREAM`.
pub const MIN_LINES_PER_WHEEL_STREAM: i64 = 1;
/// One SGR wheel report: sleep `pre_delay_ms`, then emit `button`.
///
/// `button` is [`SGR_SCROLL_UP`]/[`SGR_SCROLL_DOWN`] — `u16` because those
/// harness consts are `u16` (the design sketch said `u8`; deviating keeps
/// one shared definition instead of a cast at every emission site).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct WheelStep {
/// Host-side sleep before emitting this report (0 for the first step).
pub pre_delay_ms: u64,
/// SGR wheel button code (64 up / 65 down).
pub button: u16,
}
/// `count` same-direction reports, `interval_ms` between consecutive ones.
const fn burst<const N: usize>(interval_ms: u64, button: u16) -> [WheelStep; N] {
let mut steps = [WheelStep {
pre_delay_ms: 0,
button,
}; N];
let mut i = 1;
while i < N {
steps[i].pre_delay_ms = interval_ms;
i += 1;
}
steps
}
/// Notches of `events_per_notch` back-to-back reports, `notch_gap_ms` apart.
const fn notch_train<const N: usize>(
events_per_notch: usize,
notch_gap_ms: u64,
button: u16,
) -> [WheelStep; N] {
let mut steps = burst::<N>(0, button);
let mut i = events_per_notch;
while i < N {
if i.is_multiple_of(events_per_notch) {
steps[i].pre_delay_ms = notch_gap_ms;
}
i += 1;
}
steps
}
/// G1 single notch, ept=3 brands: 3 back-to-back reports (first tick lands
/// inside the 12ms window → wheel promotion in Auto mode).
pub const G1_NOTCH_EPT3: [WheelStep; 3] = burst::<3>(0, SGR_SCROLL_UP);
/// G1 single notch, ept=1 brands (iTerm2/zed/vscode/mux): one report.
pub const G1_NOTCH_EPT1: [WheelStep; 1] = burst::<1>(0, SGR_SCROLL_UP);
/// G2 notch train: 5 notches 50ms apart (< `STREAM_GAP_MS` → one stream).
pub const G2_NOTCH_TRAIN_EPT3: [WheelStep; 15] = notch_train::<15>(3, 50, SGR_SCROLL_UP);
pub const G2_NOTCH_TRAIN_EPT1: [WheelStep; 5] = burst::<5>(50, SGR_SCROLL_UP);
/// G3 flood: 60 back-to-back reports (cap/pacing exercise — A4 teleport).
pub const G3_FLOOD: [WheelStep; 60] = burst::<60>(0, SGR_SCROLL_UP);
/// G4 jerk repro: a 3-event anti-promotion head at 8ms, 57 dense reports,
/// then a decelerating 6-event tail (gaps growing 40→70ms, all under the
/// 80ms stream gap).
///
/// Two shape details make the repro real under a PTY (verified against the
/// live recorder):
/// - **The head.** Back-to-back writes arrive batched, so a fully dense
/// burst completes its first ept=3 tick inside the 12ms window and
/// promotes to WHEEL pricing — which never re-prices at finalize and
/// never jerks. The 8ms head lands the first tick past the window, so
/// the stream stays Unknown (priced ~1 line/event, accel window seeded
/// in the fast band).
/// - **The 40ms+ tail gaps.** They open cadence slots with no new events
/// while the dense backlog is still draining: capped `events_since_flush
/// == 0` coast flushes (the I-SMOOTH-COAST signature). Tighter gaps ride
/// every slot and mask the coast.
///
/// At the gap finalize the Unknown→Trackpad re-price (accel-weighted,
/// ~2.5× the mid-stream pricing) then bursts one more capped flush and
/// drops the rest — the I-NO-DROP half of the jerk (xfail cell until the
/// finalize-decel fix).
pub const G4_JERK: [WheelStep; 66] = {
let mut steps = burst::<66>(0, SGR_SCROLL_UP);
steps[1].pre_delay_ms = 8;
steps[2].pre_delay_ms = 8;
let tail = [40, 44, 50, 55, 60, 70];
let mut i = 0;
while i < tail.len() {
steps[60 + i].pre_delay_ms = tail[i];
i += 1;
}
steps
};
/// G5 ghostty dup: 10 notches 60ms apart, each report duplicated 4ms later
/// (ghostty emits ≥2 SGR reports per physical notch ~4ms apart). The 4ms
/// dups sit under `ACCEL_MIN_INTERVAL_MS` and must stay out of the
/// interval window — the I-ACCEL G5 clause.
pub const G5_GHOSTTY_DUP: [WheelStep; 20] = {
let mut steps = burst::<20>(0, SGR_SCROLL_UP);
let mut i = 1;
while i < 20 {
steps[i].pre_delay_ms = if i % 2 == 1 { 4 } else { 60 };
i += 1;
}
steps
};
/// G6 flip: 10×8ms up then 10×8ms down — the direction flip finalizes
/// stream 1 and opens stream 2 at the same instant (two streams).
pub const G6_FLIP: [WheelStep; 20] = {
let mut steps = burst::<20>(8, SGR_SCROLL_UP);
let mut i = 10;
while i < 20 {
steps[i].button = SGR_SCROLL_DOWN;
i += 1;
}
steps
};
/// G7 overscroll: bottom-pinned 10×8ms down (viewport must clamp — the
/// harness-side I-SCREEN check) then 3 up (must move again). Two streams.
pub const G7_OVERSCROLL: [WheelStep; 13] = {
let mut steps = burst::<13>(8, SGR_SCROLL_DOWN);
let mut i = 10;
while i < 13 {
steps[i].button = SGR_SCROLL_UP;
i += 1;
}
steps
};
/// G9a mux re-chunk 1:1: 8 single reports 55ms apart (tmux re-emitting one
/// event per notch; 55ms > the 30ms ept=1 trackpad-detect window → never
/// promotes to trackpad mid-stream).
pub const G9A_MUX_SINGLES: [WheelStep; 8] = burst::<8>(55, SGR_SCROLL_UP);
/// G9b mux re-chunk batch: 8 notches 55ms apart × 3 back-to-back events
/// (tmux passing through an inner ept=3 chunking, re-timed).
pub const G9B_MUX_BATCH: [WheelStep; 24] = notch_train::<24>(3, 55, SGR_SCROLL_UP);
/// G10 ambiguous slow roll: 12 reports 40ms apart — inside the vscode-embed
/// 60ms trackpad-detect window, outside the default 30ms one.
pub const G10_AMBIGUOUS_SLOW: [WheelStep; 12] = burst::<12>(40, SGR_SCROLL_UP);
/// G11 carry: one notch, a 120ms wait (> `STREAM_GAP_MS` → finalize), one
/// notch — the sub-line carry handoff across same-direction streams.
pub const G11_CARRY_EPT3: [WheelStep; 6] = notch_train::<6>(3, 120, SGR_SCROLL_UP);
pub const G11_CARRY_EPT1: [WheelStep; 2] = burst::<2>(120, SGR_SCROLL_UP);
/// Gesture identifier a [`super::cells::MatrixCell`] references.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum GestureId {
G1Notch,
G2NotchTrain,
G3Flood,
G4Jerk,
G5GhosttyDup,
G6Flip,
G7Overscroll,
/// G2's step table replayed while the turn is still streaming
/// (`SessionKind::Streaming`); the shape lives in the session, not here.
G8MidStreamTrain,
G9aMuxSingles,
G9bMuxBatch,
G10AmbiguousSlow,
G11Carry,
}
impl GestureId {
/// Every gesture, for exhaustive table sweeps (tests, the A13 runner).
pub const ALL: [GestureId; 12] = [
GestureId::G1Notch,
GestureId::G2NotchTrain,
GestureId::G3Flood,
GestureId::G4Jerk,
GestureId::G5GhosttyDup,
GestureId::G6Flip,
GestureId::G7Overscroll,
GestureId::G8MidStreamTrain,
GestureId::G9aMuxSingles,
GestureId::G9bMuxBatch,
GestureId::G10AmbiguousSlow,
GestureId::G11Carry,
];
/// Step table for this gesture on a brand with `ept` events per notch.
/// Only the notch-based gestures (G1/G2/G8/G11) vary by class; the rest
/// are fixed event shapes (G9b is deliberately 3-per-notch even on the
/// ept=1 mux profile — it simulates the mux passing re-chunked input).
pub fn steps(self, ept: u16) -> &'static [WheelStep] {
let ept3 = ept >= 2;
match self {
GestureId::G1Notch => {
if ept3 {
&G1_NOTCH_EPT3
} else {
&G1_NOTCH_EPT1
}
}
GestureId::G2NotchTrain | GestureId::G8MidStreamTrain => {
if ept3 {
&G2_NOTCH_TRAIN_EPT3
} else {
&G2_NOTCH_TRAIN_EPT1
}
}
GestureId::G3Flood => &G3_FLOOD,
GestureId::G4Jerk => &G4_JERK,
GestureId::G5GhosttyDup => &G5_GHOSTTY_DUP,
GestureId::G6Flip => &G6_FLIP,
GestureId::G7Overscroll => &G7_OVERSCROLL,
GestureId::G9aMuxSingles => &G9A_MUX_SINGLES,
GestureId::G9bMuxBatch => &G9B_MUX_BATCH,
GestureId::G10AmbiguousSlow => &G10_AMBIGUOUS_SLOW,
GestureId::G11Carry => {
if ept3 {
&G11_CARRY_EPT3
} else {
&G11_CARRY_EPT1
}
}
}
}
/// Streams (finalize records) this gesture produces: 1, plus one per
/// direction flip or >`STREAM_GAP_MS` intra-gesture pause. The A13
/// runner's `wait_for_finalize_count` target.
pub fn expected_streams(self) -> usize {
match self {
GestureId::G6Flip | GestureId::G7Overscroll | GestureId::G11Carry => 2,
_ => 1,
}
}
}
/// `(up, down)` report counts — direction-sum test primitive.
pub fn direction_counts(steps: &[WheelStep]) -> (usize, usize) {
let up = steps.iter().filter(|s| s.button == SGR_SCROLL_UP).count();
(up, steps.len() - up)
}
#[cfg(test)]
mod tests {
use super::*;
/// Streams split on gaps > STREAM_GAP_MS or direction flips — recompute
/// from the table and compare to the declared `expected_streams`.
fn streams_in(steps: &[WheelStep]) -> usize {
let mut streams = 1;
for pair in steps.windows(2) {
if pair[1].pre_delay_ms > STREAM_GAP_MS || pair[1].button != pair[0].button {
streams += 1;
}
}
streams
}
#[test]
fn table_counts_and_direction_sums() {
assert_eq!(G1_NOTCH_EPT3.len(), 3);
assert_eq!(G1_NOTCH_EPT1.len(), 1);
assert_eq!(G2_NOTCH_TRAIN_EPT3.len(), 15);
assert_eq!(G2_NOTCH_TRAIN_EPT1.len(), 5);
assert_eq!(G3_FLOOD.len(), 60);
assert_eq!(G4_JERK.len(), 66);
assert_eq!(G5_GHOSTTY_DUP.len(), 20);
assert_eq!(G9B_MUX_BATCH.len(), 24);
assert_eq!(G10_AMBIGUOUS_SLOW.len(), 12);
assert_eq!(direction_counts(&G3_FLOOD), (60, 0));
assert_eq!(direction_counts(&G6_FLIP), (10, 10), "flip nets to zero");
assert_eq!(direction_counts(&G7_OVERSCROLL), (3, 10));
assert_eq!(direction_counts(&G9A_MUX_SINGLES), (8, 0));
}
#[test]
fn notch_structure_and_gaps() {
// G2/ept3: notch starts every 3 events carry the 50ms gap, intra-notch 0.
for (i, step) in G2_NOTCH_TRAIN_EPT3.iter().enumerate() {
let expected = if i > 0 && i % 3 == 0 { 50 } else { 0 };
assert_eq!(step.pre_delay_ms, expected, "G2 ept3 step {i}");
}
// G9b: same shape at 55ms — under the 80ms gap, one stream.
let notch_gaps = G9B_MUX_BATCH
.iter()
.filter(|s| s.pre_delay_ms == 55)
.count();
assert_eq!(notch_gaps, 7, "8 notches → 7 inter-notch gaps");
// G5: dup 4ms after each notch head, notch heads 60ms apart.
for (i, step) in G5_GHOSTTY_DUP.iter().enumerate() {
let expected = if i == 0 {
0
} else if i % 2 == 1 {
4
} else {
60
};
assert_eq!(step.pre_delay_ms, expected, "G5 step {i}");
}
}
#[test]
fn jerk_head_blocks_promotion_and_tail_decays_monotonically() {
// Anti-promotion head: the first ept=3 tick must complete strictly
// after the 12ms wheel-promotion window even with zero jitter
// (sleeps only stretch), or PTY batching wheel-promotes the burst
// and the finalize re-price under test never happens.
let head_span: u64 = G4_JERK[..3].iter().map(|s| s.pre_delay_ms).sum();
assert!(head_span > WHEEL_TICK_DETECT_MAX_MS);
let tail: Vec<u64> = G4_JERK[60..].iter().map(|s| s.pre_delay_ms).collect();
assert_eq!(tail, vec![40, 44, 50, 55, 60, 70]);
assert!(
tail.windows(2).all(|w| w[0] < w[1]),
"strictly decelerating"
);
// Coast window: every tail gap opens ≥2 empty 16ms cadence slots so
// the dense backlog drains as events_since_flush == 0 flushes.
assert!(tail.iter().all(|&gap| gap >= 2 * REDRAW_CADENCE_MS));
assert!(G4_JERK[3..60].iter().all(|s| s.pre_delay_ms == 0));
}
#[test]
fn delays_agree_with_stream_gap_thresholds() {
// Single-stream gestures never pause past the 80ms finalize gap and
// never flip; multi-stream ones split exactly as declared.
for gesture in GestureId::ALL {
for ept in [1u16, 3] {
let steps = gesture.steps(ept);
assert!(!steps.is_empty());
assert_eq!(steps[0].pre_delay_ms, 0, "{gesture:?} first step");
assert_eq!(
streams_in(steps),
gesture.expected_streams(),
"{gesture:?} ept={ept}: table shape vs declared stream count"
);
}
}
// The G11 pause is what splits it: strictly past the finalize gap.
assert!(G11_CARRY_EPT3[3].pre_delay_ms > STREAM_GAP_MS);
assert!(G11_CARRY_EPT1[1].pre_delay_ms > STREAM_GAP_MS);
// G1/ept3 is a first tick inside the wheel-promotion window.
let g1_span: u64 = G1_NOTCH_EPT3.iter().map(|s| s.pre_delay_ms).sum();
assert!(g1_span <= WHEEL_TICK_DETECT_MAX_MS);
// G5's dup spacing must sit under the interval-window floor.
assert!((G5_GHOSTTY_DUP[1].pre_delay_ms as f64) < ACCEL_MIN_INTERVAL_MS);
}
}
@@ -0,0 +1,800 @@
//! The invariant suite: predicates over grouped `KIGI_SCROLL_LOG` streams.
//!
//! Stall-safety: every timing predicate reads the RECORDER's clock
//! (`ts_ms`, `ms_since_prev_flush`, `avg_interval_ms`), never the test
//! process's — CI load can stretch host-side gesture delays but can only
//! ever *widen* the producer-measured spacings, so no invariant here can
//! false-fail on a loaded machine.
//!
//! Two invariants are declared here but checked by the A13 matrix runner,
//! not this module: [`InvariantId::Screen`] (the viewport visibly
//! moved/clamped — needs `PtyHarness` marker positions) and
//! [`InvariantId::Quiet`] (no repaint churn after finalize — needs the
//! harness frame watermark). They exist in the id enum so cells can declare
//! them and the runner can route by [`InvariantId::is_log_side`];
//! [`check_log_invariant`] panics if asked to evaluate them.
use super::cells::ExpectedProfile;
use super::gestures::{
ACCEL_MIN_INTERVAL_MS, MIN_LINES_PER_WHEEL_STREAM, REDRAW_CADENCE_MS, TRACKPAD_ACCEL_MAX,
};
use super::log::{ScrollLogLine, StreamGroup};
/// Float slack for f32-serialized fields (accel/speed/carry comparisons).
const F32_TOLERANCE: f64 = 0.01;
/// Invariant identifier; `as_str` is the design's I-* vocabulary.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum InvariantId {
/// I-ORD: `ts_ms` is non-decreasing across the capture (flip
/// boundaries legitimately share a timestamp).
Ord,
/// I-CAP: every record's `|flushed| ≤ cap` — the A4 teleport guard.
Cap,
/// I-DROP-EQ: finalize carries `dropped == backlog_after` (the producer
/// constructs it that way; a mismatch is producer drift).
DropEq,
/// I-CADENCE: intra-stream flush spacings ≥ `REDRAW_CADENCE_MS − 1` —
/// the A2 busy-spin guard. Skips `promotion`-triggered records (they
/// flush immediately by design) and `finalize` records (the finalize
/// flush deliberately ignores the cadence gate — `finalize_stream_at`
/// in `mouse.rs` — and a flip finalize can land mid-slot; a gap
/// finalize is ≥80ms anyway, so nothing real is lost). The design
/// sketch listed only the promotion skip; the finalize skip is the
/// code-verified correction.
Cadence,
/// I-CONS-W: forced-wheel totals are exact —
/// `|applied+dropped| == trunc(events × wheel_lpt/ept × speed)` ±1,
/// with the `MIN_LINES_PER_WHEEL_STREAM` substitution when the raw
/// pricing truncates to zero. Wheel pricing never includes carry.
ConsW,
/// I-CONS-A: auto/trackpad totals are bounded —
/// per-event pricing lies in `[min(wheel_lpt/ept, tp_lpt/3),
/// max(wheel_lpt/ept, ACCEL_MAX × tp_lpt/3)] × speed` (trackpad divisor
/// is the normalized 3, accel ceiling 3.0 = `trackpad_accel_max`;
/// effective accel tops at 2.5 so the bound is loose-but-sound). Checks
/// the finalize's `desired` within `[lo, hi]` and the delivered
/// `|applied+dropped| ≤ hi`; ±1 slack absorbs carry/trunc.
ConsA,
/// I-ACCEL: `1.0 ≤ accel ≤ 3.0` on every record, and `avg_interval_ms`,
/// when present, is ≥ `ACCEL_MIN_INTERVAL_MS` (6). The G5 clause: even
/// ghostty-style 4ms duplicate reports must never drag the average
/// under 6 — the producer excludes sub-6ms intervals from the window,
/// so a lower value means that artifact guard regressed.
Accel,
/// I-CARRY: `|carry| < 1.0` everywhere (only sub-line remainders ride
/// across streams), and a wheel-kind finalize zeroes it — the NEXT
/// `stream_start` must echo `carry == 0`. The log carries no direction,
/// but the machine zeroes carry at wheel finalize and resets it on
/// direction change, so the next-start check holds for both same- and
/// opposite-direction successors (direction-agnostic strengthening of
/// the design's same-direction phrasing).
Carry,
/// I-CFG: the `stream_start` config echo matches the cell's expected
/// profile (mode/ept/wheel_lpt/trackpad_lpt/invert/speed) — the env →
/// profile plumbing witness.
Cfg,
/// I-MUX-NO-OVER: delivered total per stream ≤ `events × speed + 1` —
/// the conservative remuxed profile (ept=1/wheel_lpt=1) prices at most
/// one line per event. Attach only to accel-free gestures (>20ms
/// spacing — exactly 20ms still interpolates to 1.6× in the accel band;
/// G9's 55ms clears it): a fast trackpad-classified mux stream may
/// legitimately exceed it via accel.
MuxNoOver,
/// I-SMOOTH-COAST: per stream, `Σ|flushed|` over flush-bearing records
/// with `events_since_flush == 0` ≤ cap — motion delivered after input
/// stopped is at most one capped catch-up. The jerk's coast-drain +
/// finalize re-price burst exceeds it (xfail until A13's decel fix).
SmoothCoast,
/// I-NO-DROP: every finalize has `dropped == 0`. Attach to gestures the
/// cap can keep up with; floods legitimately drop.
NoDrop,
/// I-SCREEN (harness-side, A13): viewport marker delta matches the
/// gesture — moved on scroll, clamped at the bottom pin (G7).
Screen,
/// I-QUIET (harness-side, A13): frame watermark stays put after the
/// last finalize — no post-gesture repaint churn (A2's symptom).
Quiet,
}
impl InvariantId {
/// Design vocabulary label.
pub fn as_str(self) -> &'static str {
match self {
InvariantId::Ord => "I-ORD",
InvariantId::Cap => "I-CAP",
InvariantId::DropEq => "I-DROP-EQ",
InvariantId::Cadence => "I-CADENCE",
InvariantId::ConsW => "I-CONS-W",
InvariantId::ConsA => "I-CONS-A",
InvariantId::Accel => "I-ACCEL",
InvariantId::Carry => "I-CARRY",
InvariantId::Cfg => "I-CFG",
InvariantId::MuxNoOver => "I-MUX-NO-OVER",
InvariantId::SmoothCoast => "I-SMOOTH-COAST",
InvariantId::NoDrop => "I-NO-DROP",
InvariantId::Screen => "I-SCREEN",
InvariantId::Quiet => "I-QUIET",
}
}
/// Whether [`check_log_invariant`] can evaluate this id from the log
/// alone. `false` = harness-side (screen/frame state), owned by the
/// A13 runner.
pub fn is_log_side(self) -> bool {
!matches!(self, InvariantId::Screen | InvariantId::Quiet)
}
}
/// Verdict of one invariant over one cell's captured streams.
#[derive(Clone, Debug, PartialEq)]
pub enum InvariantResult {
Pass,
Violated { detail: String },
}
impl InvariantResult {
pub fn is_pass(&self) -> bool {
matches!(self, InvariantResult::Pass)
}
}
fn violated(detail: String) -> InvariantResult {
InvariantResult::Violated { detail }
}
/// All records of all groups, in capture order.
fn records<'a>(groups: &'a [StreamGroup<'a>]) -> impl Iterator<Item = &'a ScrollLogLine> {
groups.iter().flat_map(|g| {
std::iter::once(g.start)
.chain(g.flushes.iter().copied())
.chain(g.finalize)
})
}
/// Signed delivered total of a finalized stream: applied + discarded.
fn delivered(finalize: &ScrollLogLine) -> i64 {
finalize.applied_total + finalize.dropped.unwrap_or(0)
}
/// Evaluate one log-side invariant. Panics on harness-side ids
/// ([`InvariantId::is_log_side`] == false) — routing those here is a
/// runner bug, not a cell verdict.
pub fn check_log_invariant(
id: InvariantId,
expected: &ExpectedProfile,
groups: &[StreamGroup<'_>],
) -> InvariantResult {
match id {
InvariantId::Ord => check_ord(groups),
InvariantId::Cap => check_cap(groups),
InvariantId::DropEq => check_drop_eq(groups),
InvariantId::Cadence => check_cadence(groups),
InvariantId::ConsW => check_cons_w(expected, groups),
InvariantId::ConsA => check_cons_a(expected, groups),
InvariantId::Accel => check_accel(groups),
InvariantId::Carry => check_carry(groups),
InvariantId::Cfg => check_cfg(expected, groups),
InvariantId::MuxNoOver => check_mux_no_over(expected, groups),
InvariantId::SmoothCoast => check_smooth_coast(groups),
InvariantId::NoDrop => check_no_drop(groups),
InvariantId::Screen | InvariantId::Quiet => panic!(
"{} is harness-side (needs PtyHarness screen/frame state); the A13 matrix \
runner checks it — route by InvariantId::is_log_side",
id.as_str()
),
}
}
fn check_ord(groups: &[StreamGroup<'_>]) -> InvariantResult {
let mut prev = f64::NEG_INFINITY;
for rec in records(groups) {
if rec.ts_ms < prev {
return violated(format!(
"ts_ms went backwards: {} after {prev} ({} record)",
rec.ts_ms, rec.evt
));
}
prev = rec.ts_ms;
}
InvariantResult::Pass
}
fn check_cap(groups: &[StreamGroup<'_>]) -> InvariantResult {
for rec in records(groups) {
if rec.flushed.abs() > rec.cap {
return violated(format!(
"flushed {} exceeds cap {} at ts_ms={} (trigger {})",
rec.flushed, rec.cap, rec.ts_ms, rec.trigger
));
}
}
InvariantResult::Pass
}
fn check_drop_eq(groups: &[StreamGroup<'_>]) -> InvariantResult {
for group in groups {
let Some(fin) = group.finalize else { continue };
match fin.dropped {
Some(dropped) if dropped == fin.backlog_after => {}
Some(dropped) => {
return violated(format!(
"finalize at ts_ms={} has dropped={dropped} but backlog_after={}",
fin.ts_ms, fin.backlog_after
));
}
None => {
return violated(format!("finalize at ts_ms={} lacks dropped", fin.ts_ms));
}
}
}
InvariantResult::Pass
}
fn check_cadence(groups: &[StreamGroup<'_>]) -> InvariantResult {
// A stream's first flush-bearing record is skipped: its spacing is
// global (measured from the previous stream — see
// `intra_stream_flush_spacings_ms`).
let floor = (REDRAW_CADENCE_MS - 1) as f64;
for group in groups {
for rec in group.flush_bearing().skip(1) {
if rec.trigger == "promotion" || rec.trigger == "finalize" {
continue;
}
if let Some(spacing) = rec.ms_since_prev_flush
&& spacing < floor
{
return violated(format!(
"{}ms flush spacing < {floor}ms at ts_ms={} (trigger {})",
spacing, rec.ts_ms, rec.trigger
));
}
}
}
InvariantResult::Pass
}
fn check_cons_w(expected: &ExpectedProfile, groups: &[StreamGroup<'_>]) -> InvariantResult {
if expected.mode != "wheel" {
return violated(format!(
"I-CONS-W attached to a mode={} cell (needs forced wheel)",
expected.mode
));
}
let rate = f64::from(expected.wheel_lpt) / f64::from(expected.ept.max(1));
for group in groups {
let Some(fin) = group.finalize else { continue };
let raw = (fin.events_total as f64 * rate * f64::from(expected.speed)).trunc() as i64;
let want = if fin.events_total > 0 {
raw.max(MIN_LINES_PER_WHEEL_STREAM)
} else {
0
};
let got = delivered(fin).abs();
if (got - want).abs() > 1 {
return violated(format!(
"wheel stream at ts_ms={}: delivered {got} lines for {} events, expected {want}±1",
fin.ts_ms, fin.events_total
));
}
}
InvariantResult::Pass
}
fn check_cons_a(expected: &ExpectedProfile, groups: &[StreamGroup<'_>]) -> InvariantResult {
if expected.mode == "wheel" {
return violated("I-CONS-A attached to a forced-wheel cell (use I-CONS-W)".into());
}
let wheel_rate = f64::from(expected.wheel_lpt) / f64::from(expected.ept.max(1));
let tp_rate = f64::from(expected.trackpad_lpt) / 3.0;
// Forced trackpad never prices via the wheel table.
let (min_rate, max_rate) = if expected.mode == "trackpad" {
(tp_rate, TRACKPAD_ACCEL_MAX * tp_rate)
} else {
(
wheel_rate.min(tp_rate),
wheel_rate.max(TRACKPAD_ACCEL_MAX * tp_rate),
)
};
for group in groups {
let Some(fin) = group.finalize else { continue };
let events = fin.events_total as f64;
let speed = f64::from(expected.speed);
let lo = events * min_rate * speed - 1.0;
let hi = events * max_rate * speed + 1.0;
let desired = f64::from(fin.desired).abs();
if desired < lo || desired > hi {
return violated(format!(
"stream at ts_ms={}: |desired|={desired:.2} outside [{lo:.2}, {hi:.2}] \
for {} events",
fin.ts_ms, fin.events_total
));
}
let got = delivered(fin).abs() as f64;
if got > hi {
return violated(format!(
"stream at ts_ms={}: delivered {got} lines > bound {hi:.2} for {} events",
fin.ts_ms, fin.events_total
));
}
}
InvariantResult::Pass
}
fn check_accel(groups: &[StreamGroup<'_>]) -> InvariantResult {
for rec in records(groups) {
let accel = f64::from(rec.accel);
if !(1.0 - F32_TOLERANCE..=TRACKPAD_ACCEL_MAX + F32_TOLERANCE).contains(&accel) {
return violated(format!(
"accel {accel} outside [1.0, {TRACKPAD_ACCEL_MAX}] at ts_ms={}",
rec.ts_ms
));
}
if let Some(avg) = rec.avg_interval_ms
&& avg < ACCEL_MIN_INTERVAL_MS - F32_TOLERANCE
{
return violated(format!(
"avg_interval_ms {avg} < 6 at ts_ms={} — sub-6ms batching artifacts \
(ghostty dups) leaked into the interval window",
rec.ts_ms
));
}
}
InvariantResult::Pass
}
fn check_carry(groups: &[StreamGroup<'_>]) -> InvariantResult {
for rec in records(groups) {
if f64::from(rec.carry).abs() >= 1.0 {
return violated(format!(
"|carry| {} ≥ 1.0 at ts_ms={} — whole lines leaked across streams",
rec.carry, rec.ts_ms
));
}
}
for pair in groups.windows(2) {
let Some(fin) = pair[0].finalize else {
continue;
};
if fin.kind == "wheel" && f64::from(pair[1].start.carry).abs() > F32_TOLERANCE {
return violated(format!(
"stream_start at ts_ms={} carries {} after a wheel finalize (must be 0)",
pair[1].start.ts_ms, pair[1].start.carry
));
}
}
InvariantResult::Pass
}
fn check_cfg(expected: &ExpectedProfile, groups: &[StreamGroup<'_>]) -> InvariantResult {
for group in groups {
let start = group.start;
let echo = (
start.mode.as_deref(),
start.ept,
start.wheel_lpt,
start.trackpad_lpt,
start.invert,
);
let want = (
Some(expected.mode),
Some(expected.ept),
Some(expected.wheel_lpt),
Some(expected.trackpad_lpt),
Some(expected.invert),
);
let speed_ok = start
.speed
.is_some_and(|s| (f64::from(s) - f64::from(expected.speed)).abs() <= F32_TOLERANCE);
if echo != want || !speed_ok {
return violated(format!(
"config echo at ts_ms={} is {:?} speed={:?}, cell expects \
mode={} ept={} wheel_lpt={} trackpad_lpt={} invert={} speed={}",
start.ts_ms,
echo,
start.speed,
expected.mode,
expected.ept,
expected.wheel_lpt,
expected.trackpad_lpt,
expected.invert,
expected.speed,
));
}
}
InvariantResult::Pass
}
fn check_mux_no_over(expected: &ExpectedProfile, groups: &[StreamGroup<'_>]) -> InvariantResult {
for group in groups {
let Some(fin) = group.finalize else { continue };
let bound = fin.events_total as f64 * f64::from(expected.speed) + 1.0;
let got = delivered(fin).abs() as f64;
if got > bound {
return violated(format!(
"remuxed stream at ts_ms={}: delivered {got} lines for {} events \
(> {bound:.2} — over-scroll on the conservative mux profile)",
fin.ts_ms, fin.events_total
));
}
}
InvariantResult::Pass
}
fn check_smooth_coast(groups: &[StreamGroup<'_>]) -> InvariantResult {
for group in groups {
let coast: i64 = group
.flush_bearing()
.filter(|rec| rec.events_since_flush == 0)
.map(|rec| rec.flushed.abs())
.sum();
let cap = group
.flush_bearing()
.map(|rec| rec.cap)
.max()
.unwrap_or(i64::MAX);
if coast > cap {
return violated(format!(
"stream at ts_ms={}: {coast} coast lines (flushes with no new events) \
> cap {cap} — post-input motion beyond one capped catch-up (the jerk)",
group.start.ts_ms
));
}
}
InvariantResult::Pass
}
fn check_no_drop(groups: &[StreamGroup<'_>]) -> InvariantResult {
for group in groups {
let Some(fin) = group.finalize else { continue };
let dropped = fin.dropped.unwrap_or(0);
if dropped != 0 {
return violated(format!(
"finalize at ts_ms={} dropped {dropped} lines",
fin.ts_ms
));
}
}
InvariantResult::Pass
}
#[cfg(test)]
mod tests {
use super::super::log::{group_streams, parse_jsonl_str};
use super::*;
// ── JSONL fixture builders ─────────────────────────────────────────
// Raw strings through parse_jsonl_str so every fixture also exercises
// the wire schema (same stance as log.rs's producer-shaped constants).
const C1: ExpectedProfile = ExpectedProfile {
mode: "auto",
ept: 3,
wheel_lpt: 3,
trackpad_lpt: 3,
invert: false,
speed: 1.0,
};
const C1_WHEEL: ExpectedProfile = ExpectedProfile {
mode: "wheel",
..C1
};
// Floats are formatted with `{:?}` so whole values keep their decimal
// point (`32.0`, not `32`) — the mutation `.replace()`s below match on
// that spelling, and it mirrors serde_json's f64 output for round
// numbers.
fn start(ts: f64, carry: f32, mode: &str, speed: f32) -> String {
format!(
r#"{{"ts_ms":{ts:?},"evt":"stream_start","trigger":"event","kind":"unknown","events_total":0,"events_since_flush":0,"accel":1.0,"desired":0.0,"applied_total":0,"flushed":0,"backlog_after":0,"carry":{carry:?},"cap":25,"mode":"{mode}","ept":3,"wheel_lpt":3,"trackpad_lpt":3,"invert":false,"speed":{speed:?},"viewport_height":50}}"#
)
}
#[allow(clippy::too_many_arguments)]
fn row(
evt: &str,
ts: f64,
trigger: &str,
kind: &str,
events: (u64, u64),
accel_avg: (f32, Option<f64>),
lines: (f32, i64, i64, i64), // desired, applied_total, flushed, backlog_after
msf: Option<f64>,
dropped: Option<i64>,
) -> String {
let avg = accel_avg
.1
.map_or(String::new(), |v| format!(r#""avg_interval_ms":{v:?},"#));
let msf = msf.map_or(String::new(), |v| {
format!(r#","ms_since_prev_flush":{v:?}"#)
});
let dropped = dropped.map_or(String::new(), |v| format!(r#","dropped":{v}"#));
format!(
r#"{{"ts_ms":{ts:?},"evt":"{evt}","trigger":"{trigger}","kind":"{kind}","events_total":{},"events_since_flush":{},{avg}"accel":{:?},"desired":{:?},"applied_total":{},"flushed":{},"backlog_after":{},"carry":0.0,"cap":25{msf}{dropped}}}"#,
events.0, events.1, accel_avg.0, lines.0, lines.1, lines.2, lines.3,
)
}
fn check(id: InvariantId, expected: &ExpectedProfile, jsonl: &[String]) -> InvariantResult {
let raw = jsonl.join("\n");
let records = parse_jsonl_str(&raw).expect("fixture must parse");
let groups = group_streams(&records).expect("fixture must group");
check_log_invariant(id, expected, &groups)
}
fn assert_violated(result: InvariantResult, needle: &str) {
match result {
InvariantResult::Violated { detail } => {
assert!(
detail.contains(needle),
"detail {detail:?} lacks {needle:?}"
);
}
InvariantResult::Pass => panic!("expected violation mentioning {needle:?}"),
}
}
/// Canonical clean capture: a trackpad stream (sub-line carry out), a
/// wheel stream (carry zeroed), a third stream echoing that zero.
/// One record per line (rustfmt::skip) — reads like the JSONL it builds.
#[rustfmt::skip]
fn canonical() -> Vec<String> {
vec![
start(0.0, 0.0, "auto", 1.0),
row("flush", 16.0, "event", "trackpad", (6, 6), (1.0, Some(10.0)), (6.4, 6, 6, 0), None, None),
row("flush", 32.0, "tick", "trackpad", (9, 3), (1.0, Some(10.0)), (9.4, 9, 3, 0), Some(16.0), None),
row("finalize", 120.0, "finalize", "trackpad", (9, 0), (1.0, Some(10.0)), (9.4, 9, 0, 0), Some(88.0), Some(0)),
start(500.0, 0.4, "auto", 1.0),
row("flush", 505.0, "promotion", "wheel", (3, 3), (1.0, None), (3.0, 3, 3, 0), Some(385.0), None),
row("finalize", 600.0, "finalize", "wheel", (3, 0), (1.0, None), (3.0, 3, 0, 0), Some(95.0), Some(0)),
start(700.0, 0.0, "auto", 1.0),
row("flush", 716.0, "event", "unknown", (6, 6), (1.0, Some(8.0)), (6.2, 6, 6, 0), Some(116.0), None),
row("finalize", 800.0, "finalize", "trackpad", (6, 0), (1.0, Some(8.0)), (6.2, 6, 0, 0), Some(84.0), Some(0)),
]
}
/// The real G4 signature: coast drain + finalize re-price burst with a
/// drop — mid-stream priced as unknown (~1 line/event), re-priced ×2.5
/// at the trackpad finalize, backlog beyond one capped flush discarded.
#[rustfmt::skip]
fn jerk() -> Vec<String> {
vec![
start(0.0, 0.0, "auto", 1.0),
row("flush", 16.0, "event", "unknown", (40, 40), (2.5, Some(6.0)), (40.0, 25, 25, 15), None, None),
row("flush", 32.0, "tick", "unknown", (66, 26), (2.5, Some(6.0)), (66.0, 50, 25, 16), Some(16.0), None),
row("flush", 48.0, "tick", "unknown", (66, 0), (2.5, Some(6.0)), (66.0, 66, 16, 0), Some(16.0), None),
row("finalize", 146.0, "finalize", "trackpad", (66, 0), (2.5, Some(6.0)), (165.0, 91, 25, 74), Some(98.0), Some(74)),
]
}
#[test]
fn every_log_side_invariant_passes_on_the_canonical_capture() {
// All log-side ids except I-CONS-W, which requires a forced-wheel
// profile and has its own pass fixture.
let fixture = canonical();
for id in [
InvariantId::Ord,
InvariantId::Cap,
InvariantId::DropEq,
InvariantId::Cadence,
InvariantId::ConsA,
InvariantId::Accel,
InvariantId::Carry,
InvariantId::Cfg,
InvariantId::MuxNoOver,
InvariantId::SmoothCoast,
InvariantId::NoDrop,
] {
let result = check(id, &C1, &fixture);
assert!(result.is_pass(), "{} on canonical: {result:?}", id.as_str());
}
}
#[test]
fn ord_rejects_backwards_timestamps() {
let mut fixture = canonical();
fixture[2] = fixture[2].replace(r#""ts_ms":32.0"#, r#""ts_ms":12.0"#);
assert_violated(check(InvariantId::Ord, &C1, &fixture), "backwards");
}
/// A4 teleport: one flush delivering more than the per-flush cap.
#[test]
fn cap_rejects_over_cap_flush() {
let mut fixture = canonical();
fixture[1] = fixture[1].replace(r#""flushed":6"#, r#""flushed":40"#);
assert_violated(check(InvariantId::Cap, &C1, &fixture), "exceeds cap");
}
#[test]
fn drop_eq_rejects_mismatched_finalize_accounting() {
let mut fixture = canonical();
fixture[3] = fixture[3].replace(r#""backlog_after":0,"#, r#""backlog_after":9,"#);
fixture[3] = fixture[3].replace(r#""dropped":0"#, r#""dropped":5"#);
assert_violated(check(InvariantId::DropEq, &C1, &fixture), "dropped=5");
}
/// A2 busy-spin: an 8ms tick-flush spacing violates; the same spacing on
/// a promotion-triggered record is skipped (promotion flushes bypass the
/// cadence gate by design), as are finalize records (flip finalizes).
#[test]
fn cadence_rejects_sub_16ms_spacing_but_skips_promotion_and_finalize() {
let mut fixture = canonical();
fixture[2] = fixture[2].replace(
r#""ms_since_prev_flush":16.0"#,
r#""ms_since_prev_flush":8.0"#,
);
assert_violated(check(InvariantId::Cadence, &C1, &fixture), "8ms");
let mut skipped = canonical();
skipped[2] = skipped[2]
.replace(r#""trigger":"tick""#, r#""trigger":"promotion""#)
.replace(
r#""ms_since_prev_flush":16.0"#,
r#""ms_since_prev_flush":8.0"#,
);
// Flip-style finalize 8ms after the previous flush: also skipped.
skipped[3] = skipped[3].replace(
r#""ms_since_prev_flush":88.0"#,
r#""ms_since_prev_flush":8.0"#,
);
assert!(check(InvariantId::Cadence, &C1, &skipped).is_pass());
}
/// A3 under-travel: a forced-wheel stream delivering two lines short.
#[test]
fn cons_w_exact_totals_with_min_lines_substitution() {
// 6 events × (3/3) × 1.0 = 6 lines, delivered exactly; a second
// 1-event stream prices to 1.0 → still ≥ the MIN_LINES floor.
#[rustfmt::skip]
let pass = vec![
start(0.0, 0.0, "wheel", 1.0),
row("flush", 16.0, "event", "wheel", (6, 6), (1.0, None), (6.0, 6, 6, 0), None, None),
row("finalize", 100.0, "finalize", "wheel", (6, 0), (1.0, None), (6.0, 6, 0, 0), Some(84.0), Some(0)),
start(300.0, 0.0, "wheel", 1.0),
row("finalize", 400.0, "finalize", "wheel", (1, 1), (1.0, None), (1.0, 1, 1, 0), Some(300.0), Some(0)),
];
assert!(check(InvariantId::ConsW, &C1_WHEEL, &pass).is_pass());
let mut short = pass.clone();
short[1] = short[1].replace(
r#""applied_total":6,"flushed":6"#,
r#""applied_total":4,"flushed":4"#,
);
short[2] = short[2].replace(r#""applied_total":6"#, r#""applied_total":4"#);
assert_violated(check(InvariantId::ConsW, &C1_WHEEL, &short), "expected 6±1");
// Attached to a non-wheel cell = cell-table bug, not a pass.
assert_violated(check(InvariantId::ConsW, &C1, &pass), "needs forced wheel");
}
#[test]
fn cons_a_bounds_auto_totals() {
// 3 events on the C1 profile can never desire 100 lines
// (hi = 3 × max(1, 3×1) × 1 + 1 = 10).
let mut fixture = canonical();
fixture[6] = fixture[6].replace(r#""desired":3.0"#, r#""desired":100.0"#);
assert_violated(check(InvariantId::ConsA, &C1, &fixture), "outside");
assert_violated(
check(InvariantId::ConsA, &C1_WHEEL, &canonical()),
"use I-CONS-W",
);
}
/// G5 clause: a sub-6ms average means ghostty-style duplicate reports
/// leaked into the interval window; out-of-range accel is the same
/// regression on the multiplier side.
#[test]
fn accel_rejects_out_of_band_multiplier_and_sub_6ms_average() {
let mut fixture = canonical();
fixture[1] = fixture[1].replace(r#""accel":1.0"#, r#""accel":4.5"#);
assert_violated(check(InvariantId::Accel, &C1, &fixture), "accel 4.5");
let mut dup = canonical();
dup[1] = dup[1].replace(r#""avg_interval_ms":10.0"#, r#""avg_interval_ms":3.0"#);
assert_violated(check(InvariantId::Accel, &C1, &dup), "interval window");
}
#[test]
fn carry_rejects_whole_line_carry_and_nonzero_start_after_wheel_finalize() {
let mut fixture = canonical();
fixture[4] = start(500.0, 1.4, "auto", 1.0);
assert_violated(check(InvariantId::Carry, &C1, &fixture), "≥ 1.0");
// Wheel finalize (stream 2) must zero the carry into stream 3.
let mut leak = canonical();
leak[7] = start(700.0, 0.4, "auto", 1.0);
assert_violated(check(InvariantId::Carry, &C1, &leak), "wheel finalize");
}
#[test]
fn cfg_rejects_echo_profile_mismatch() {
assert!(check(InvariantId::Cfg, &C1, &canonical()).is_pass());
let mut fixture = canonical();
fixture[0] = start(0.0, 0.0, "auto", 6.0); // speed echo ≠ expected 1.0
assert_violated(check(InvariantId::Cfg, &C1, &fixture), "speed");
let expected_wheel = ExpectedProfile {
mode: "wheel",
..C1
};
assert_violated(
check(InvariantId::Cfg, &expected_wheel, &canonical()),
"mode",
);
}
/// A4-class over-scroll on the conservative remuxed profile: more
/// delivered lines than events at speed 1.0.
#[test]
fn mux_no_over_rejects_over_delivery() {
let mut fixture = canonical();
fixture[3] = fixture[3].replace(r#""applied_total":9"#, r#""applied_total":20"#);
// Keep DropEq-independent: only this invariant is under test.
assert_violated(check(InvariantId::MuxNoOver, &C1, &fixture), "over-scroll");
}
/// The jerk fixture violates exactly the two xfail invariants of
/// `c1_auto_g4_jerk_xfail` — and nothing else in the core suite, which
/// is what confines the expected failure to those rows.
#[test]
fn jerk_shape_violates_smooth_coast_and_no_drop_only() {
let fixture = jerk();
assert_violated(check(InvariantId::SmoothCoast, &C1, &fixture), "coast");
assert_violated(check(InvariantId::NoDrop, &C1, &fixture), "dropped 74");
for id in [
InvariantId::Ord,
InvariantId::Cap,
InvariantId::DropEq,
InvariantId::Cadence,
InvariantId::ConsA,
InvariantId::Accel,
InvariantId::Carry,
InvariantId::Cfg,
] {
let result = check(id, &C1, &fixture);
assert!(result.is_pass(), "{} on jerk: {result:?}", id.as_str());
}
}
/// One capped catch-up flush after input stops is legitimate (that is
/// the finalize contract) — coast ≤ cap passes.
#[test]
fn smooth_coast_allows_a_single_capped_catchup() {
#[rustfmt::skip]
let fixture = vec![
start(0.0, 0.0, "auto", 1.0),
row("flush", 16.0, "event", "unknown", (30, 30), (1.0, Some(6.0)), (30.0, 25, 25, 5), None, None),
row("finalize", 100.0, "finalize", "trackpad", (30, 0), (1.0, Some(6.0)), (30.0, 30, 5, 0), Some(84.0), Some(0)),
];
assert!(check(InvariantId::SmoothCoast, &C1, &fixture).is_pass());
assert!(check(InvariantId::NoDrop, &C1, &fixture).is_pass());
}
#[test]
#[should_panic(expected = "harness-side")]
fn harness_side_ids_panic_in_the_log_checker() {
let _ = check_log_invariant(InvariantId::Screen, &C1, &[]);
}
#[test]
fn log_side_partition_matches_the_a13_split() {
for id in [InvariantId::Screen, InvariantId::Quiet] {
assert!(!id.is_log_side(), "{}", id.as_str());
}
for id in [
InvariantId::Ord,
InvariantId::Cap,
InvariantId::DropEq,
InvariantId::Cadence,
InvariantId::ConsW,
InvariantId::ConsA,
InvariantId::Accel,
InvariantId::Carry,
InvariantId::Cfg,
InvariantId::MuxNoOver,
InvariantId::SmoothCoast,
InvariantId::NoDrop,
] {
assert!(id.is_log_side(), "{}", id.as_str());
}
}
}
@@ -0,0 +1,502 @@
//! `KIGI_SCROLL_LOG` JSONL parsing, per-stream grouping, and finalize
//! synchronization.
//!
//! Wire schema source of truth: the pager's `ScrollLogRecord` in
//! `kigi-tui/src/input/scroll_log.rs`. [`ScrollLogLine`] mirrors it
//! field-for-field with every always-emitted field **required** (see the
//! module docs in [`super`] for the drift-tripwire rationale).
use std::path::Path;
use std::time::{Duration, Instant};
use anyhow::{Context, Result, bail};
use serde::Deserialize;
/// `evt` value of a stream-start record (config echo rides these only).
pub const EVT_STREAM_START: &str = "stream_start";
/// `evt` value of a mid-stream line-delivering flush record.
pub const EVT_FLUSH: &str = "flush";
/// `evt` value of a stream-finalize record (`dropped` rides these only).
pub const EVT_FINALIZE: &str = "finalize";
/// One parsed flight-recorder line.
///
/// Field names and required/optional split mirror the producer's
/// `ScrollLogRecord` (`scroll_log.rs`): the producer always emits the
/// non-`Option` fields, `#[serde(skip_serializing_if)]`s the `Option`
/// bookkeeping fields, and `#[serde(flatten)]`s the config echo onto
/// `stream_start` records only. Unknown fields are tolerated (additive
/// producer changes must not break older matrix code); missing required
/// fields fail loudly with the line number via [`parse_jsonl`].
#[derive(Debug, Clone, Deserialize)]
pub struct ScrollLogLine {
/// Monotonic ms since recorder start (the state machine's timeline).
pub ts_ms: f64,
/// Record type: [`EVT_STREAM_START`] | [`EVT_FLUSH`] | [`EVT_FINALIZE`].
pub evt: String,
/// Emitting code path: `event` | `tick` | `promotion` | `finalize`.
pub trigger: String,
/// Stream classification (`unknown` until promotion/finalize).
pub kind: String,
/// Events accumulated in the stream so far.
pub events_total: u64,
/// Arrivals since the last logged flush/finalize of this stream.
pub events_since_flush: u64,
/// Acceleration multiplier in effect.
pub accel: f32,
/// Fractional target lines (post accel/speed multipliers, carry included).
pub desired: f32,
/// Whole lines delivered for this stream so far (post-flush).
pub applied_total: i64,
/// Lines this record's flush delivered (0 on stream_start).
pub flushed: i64,
/// Whole-line backlog remaining after this record's flush.
pub backlog_after: i64,
/// Sub-line remainder included in `desired`.
pub carry: f32,
/// Per-flush delta cap in effect.
pub cap: i64,
/// Rolling average inter-event interval (ms); absent until two
/// accel-countable events arrived.
pub avg_interval_ms: Option<f64>,
/// Spacing from the previous flush-bearing record; absent before the
/// first. **Global, not per-stream** — the producer's `last_flush_at`
/// never resets at stream boundaries, so on a stream's first
/// flush-bearing record this measures from the *previous stream*. Use
/// [`StreamGroup::intra_stream_flush_spacings_ms`] for per-stream
/// cadence.
pub ms_since_prev_flush: Option<f64>,
/// Finalize only: whole lines discarded with the stream.
pub dropped: Option<i64>,
// Config echo, flattened onto stream_start records only.
/// Scroll input mode label in effect (`auto` | `wheel` | `trackpad`).
pub mode: Option<String>,
/// Events per tick.
pub ept: Option<u16>,
/// Wheel lines per tick.
pub wheel_lpt: Option<u16>,
/// Trackpad lines per tick.
pub trackpad_lpt: Option<u16>,
/// Direction inversion.
pub invert: Option<bool>,
/// Speed multiplier.
pub speed: Option<f32>,
/// Viewport height stamped on the config.
pub viewport_height: Option<u16>,
}
impl ScrollLogLine {
/// Whether this is a stream-start record.
pub fn is_stream_start(&self) -> bool {
self.evt == EVT_STREAM_START
}
/// Whether this is a mid-stream flush record.
pub fn is_flush(&self) -> bool {
self.evt == EVT_FLUSH
}
/// Whether this is a finalize record.
pub fn is_finalize(&self) -> bool {
self.evt == EVT_FINALIZE
}
}
/// Parse a `KIGI_SCROLL_LOG` JSONL file into records.
///
/// Every line must parse — errors carry the 1-based line number and the
/// offending line. Call after the capture is quiescent (the producer
/// force-flushes on finalize, so a file whose last gesture finalized ends
/// on a record boundary); mid-write reads can see a torn tail line, which
/// fails here by design — synchronize with [`wait_for_finalize_count`]
/// first.
pub fn parse_jsonl(path: &Path) -> Result<Vec<ScrollLogLine>> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("failed to read scroll log {}", path.display()))?;
parse_jsonl_str(&raw).with_context(|| format!("scroll log {}", path.display()))
}
/// [`parse_jsonl`] over an in-memory JSONL string (fixtures, pre-read files).
pub fn parse_jsonl_str(raw: &str) -> Result<Vec<ScrollLogLine>> {
raw.lines()
.enumerate()
.map(|(idx, line)| {
serde_json::from_str(line)
.with_context(|| format!("line {}: failed to parse {line:?}", idx + 1))
})
.collect()
}
/// One recorded gesture: `stream_start` → `flush`* → `finalize`.
///
/// `finalize` is `None` only for a trailing stream still in flight when the
/// capture ended: finalize rides the 80ms-gap and direction-flip
/// transitions, so a capture cut mid-gesture leaves the last stream open.
#[derive(Debug, Clone)]
pub struct StreamGroup<'a> {
/// The `stream_start` record (carries the config echo).
pub start: &'a ScrollLogLine,
/// Mid-stream `flush` records, in file order.
pub flushes: Vec<&'a ScrollLogLine>,
/// The `finalize` record; `None` for a trailing in-flight stream.
pub finalize: Option<&'a ScrollLogLine>,
}
impl<'a> StreamGroup<'a> {
/// Whether the stream's finalize record was captured.
pub fn is_finalized(&self) -> bool {
self.finalize.is_some()
}
/// Flush-bearing records (flushes, then finalize if present), in order.
pub fn flush_bearing(&self) -> impl Iterator<Item = &'a ScrollLogLine> + '_ {
self.flushes.iter().copied().chain(self.finalize)
}
/// Intra-stream flush spacings (ms), in order.
///
/// Skips the stream's **first** flush-bearing record: the producer's
/// `ms_since_prev_flush` is global (its `last_flush_at` never resets at
/// stream boundaries — see `scroll_log.rs`), so the first value
/// measures from the previous stream's last flush/finalize and says
/// nothing about this stream's cadence. Every subsequent flush-bearing
/// record's spacing is intra-stream by construction.
pub fn intra_stream_flush_spacings_ms(&self) -> Vec<f64> {
self.flush_bearing()
.skip(1)
.filter_map(|record| record.ms_since_prev_flush)
.collect()
}
}
/// Group parsed records into per-gesture [`StreamGroup`]s.
///
/// Expects the shape the pager emits when the recorder exists for the whole
/// session (`KIGI_SCROLL_LOG` set at spawn): `stream_start` → `flush`* →
/// `finalize`, repeated, with at most one trailing unfinalized stream. A
/// direction flip emits `finalize` and the next `stream_start` at the same
/// `ts_ms`; that boundary is a plain group boundary here.
///
/// Malformed shapes (flush/finalize before any start, start while a stream
/// is open) are errors: with a spawn-time recorder they indicate producer
/// drift. Caveat: a `/debug log` *runtime-toggled* recorder can begin
/// mid-stream and legitimately open with an orphan flush/finalize — the
/// matrix never does that, so it is rejected rather than silently grouped.
pub fn group_streams(records: &[ScrollLogLine]) -> Result<Vec<StreamGroup<'_>>> {
let mut groups: Vec<StreamGroup<'_>> = Vec::new();
let mut open: Option<StreamGroup<'_>> = None;
for (idx, record) in records.iter().enumerate() {
let record_no = idx + 1;
match record.evt.as_str() {
EVT_STREAM_START => {
if open.is_some() {
bail!(
"record {record_no}: stream_start at ts_ms={} while the previous \
stream is still open (producer emits finalize before the next start)",
record.ts_ms
);
}
open = Some(StreamGroup {
start: record,
flushes: Vec::new(),
finalize: None,
});
}
EVT_FLUSH => match open.as_mut() {
Some(group) => group.flushes.push(record),
None => bail!(
"record {record_no}: flush at ts_ms={} with no open stream \
(missing stream_start)",
record.ts_ms
),
},
EVT_FINALIZE => match open.take() {
Some(mut group) => {
group.finalize = Some(record);
groups.push(group);
}
None => bail!(
"record {record_no}: finalize at ts_ms={} with no open stream \
(missing stream_start)",
record.ts_ms
),
},
other => bail!("record {record_no}: unknown evt {other:?}"),
}
}
// A trailing stream still in flight at capture end is legitimate.
groups.extend(open);
Ok(groups)
}
/// Poll interval for [`wait_for_finalize_count`]. Short enough that the
/// wait adds at most ~10ms latency past the write, long enough not to spin.
const FINALIZE_POLL_INTERVAL: Duration = Duration::from_millis(10);
/// Block until `path` contains at least `n` finalize records, or `timeout`
/// expires.
///
/// The producer force-flushes its `BufWriter` on every finalize record
/// (gesture boundary — the `tail -f` contract in `scroll_log.rs`), so
/// polling the file is race-free for finalize counting: once the flush
/// lands, the line is fully present. Counting uses a raw substring match
/// (`"evt":"finalize"` — serde_json's compact encoding) rather than a full
/// parse so a torn non-finalize tail mid-write can't fail the wait. A
/// not-yet-created file (the recorder opens lazily on the first record)
/// counts as zero.
pub fn wait_for_finalize_count(path: &Path, n: usize, timeout: Duration) -> Result<()> {
let deadline = Instant::now() + timeout;
loop {
let count = count_finalize_lines(path)?;
if count >= n {
return Ok(());
}
if Instant::now() >= deadline {
bail!(
"timed out after {timeout:?} waiting for {n} finalize record(s) in {}: found {count}",
path.display()
);
}
std::thread::sleep(FINALIZE_POLL_INTERVAL);
}
}
/// Count finalize records by raw substring; missing file counts as zero.
fn count_finalize_lines(path: &Path) -> Result<usize> {
match std::fs::read_to_string(path) {
Ok(raw) => Ok(raw.matches("\"evt\":\"finalize\"").count()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(0),
Err(err) => {
Err(err).with_context(|| format!("failed to read scroll log {}", path.display()))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// Fixture lines shaped exactly like the producer's serde output
// (compact JSON, snake_case evt/trigger, config echo flattened onto
// stream_start, skip-if-None optionals) — copied from the wire format
// pinned by `scroll_log_records_flood_flushes_and_capped_finalize_drop`
// in `kigi-tui/src/input/mouse/tests.rs`.
const START: &str = r#"{"ts_ms":0.0,"evt":"stream_start","trigger":"event","kind":"unknown","events_total":0,"events_since_flush":0,"accel":1.0,"desired":0.0,"applied_total":0,"flushed":0,"backlog_after":0,"carry":0.0,"cap":6,"mode":"trackpad","ept":3,"wheel_lpt":3,"trackpad_lpt":3,"invert":false,"speed":1.0,"viewport_height":40}"#;
const FLUSH_FIRST: &str = r#"{"ts_ms":16.0,"evt":"flush","trigger":"event","kind":"trackpad","events_total":9,"events_since_flush":9,"avg_interval_ms":2.0,"accel":1.0,"desired":9.4,"applied_total":6,"flushed":6,"backlog_after":3,"carry":0.4,"cap":6}"#;
const FLUSH_SECOND: &str = r#"{"ts_ms":32.0,"evt":"flush","trigger":"tick","kind":"trackpad","events_total":17,"events_since_flush":8,"avg_interval_ms":2.0,"accel":1.0,"desired":17.4,"applied_total":12,"flushed":6,"backlog_after":5,"carry":0.4,"cap":6,"ms_since_prev_flush":16.0}"#;
const FINALIZE: &str = r#"{"ts_ms":114.0,"evt":"finalize","trigger":"finalize","kind":"trackpad","events_total":50,"events_since_flush":33,"avg_interval_ms":2.0,"accel":1.0,"desired":50.0,"applied_total":18,"flushed":6,"backlog_after":26,"carry":0.0,"cap":6,"ms_since_prev_flush":82.0,"dropped":26}"#;
fn jsonl(lines: &[&str]) -> String {
let mut out = lines.join("\n");
out.push('\n'); // producer's writeln! always terminates lines
out
}
#[test]
fn parses_producer_shaped_fixture_lines() {
let records =
parse_jsonl_str(&jsonl(&[START, FLUSH_FIRST, FLUSH_SECOND, FINALIZE])).expect("parse");
assert_eq!(records.len(), 4);
let start = &records[0];
assert!(start.is_stream_start());
assert_eq!(start.trigger, "event");
assert_eq!(start.kind, "unknown");
assert_eq!(start.events_total, 0);
assert_eq!(start.cap, 6);
// Config echo rides stream_start only.
assert_eq!(start.mode.as_deref(), Some("trackpad"));
assert_eq!(start.ept, Some(3));
assert_eq!(start.wheel_lpt, Some(3));
assert_eq!(start.trackpad_lpt, Some(3));
assert_eq!(start.invert, Some(false));
assert_eq!(start.speed, Some(1.0));
assert_eq!(start.viewport_height, Some(40));
assert!(start.dropped.is_none());
assert!(start.ms_since_prev_flush.is_none());
let first_flush = &records[1];
assert!(first_flush.is_flush());
assert_eq!(first_flush.flushed, 6);
assert_eq!(first_flush.avg_interval_ms, Some(2.0));
// No flush-bearing record precedes it in this capture.
assert!(first_flush.ms_since_prev_flush.is_none());
assert!(first_flush.mode.is_none(), "flushes skip the config echo");
let finalize = &records[3];
assert!(finalize.is_finalize());
assert_eq!(finalize.trigger, "finalize");
assert_eq!(finalize.dropped, Some(26));
assert_eq!(finalize.backlog_after, 26);
assert_eq!(finalize.ms_since_prev_flush, Some(82.0));
assert_eq!(finalize.ts_ms, 114.0);
}
#[test]
fn unknown_fields_are_tolerated_for_additive_producer_changes() {
let with_future_field = FLUSH_SECOND.replace(
"\"ms_since_prev_flush\":16.0",
"\"ms_since_prev_flush\":16.0,\"future_field\":true",
);
let records = parse_jsonl_str(&jsonl(&[START, &with_future_field]))
.expect("additive fields must not break ingestion");
assert_eq!(records.len(), 2);
}
#[test]
fn missing_required_field_fails_with_line_number() {
// Simulate a producer-side rename: `carry` disappears from line 2.
let renamed = FLUSH_FIRST.replace("\"carry\":0.4,", "");
let err = parse_jsonl_str(&jsonl(&[START, &renamed])).expect_err("drift must fail");
let chain = format!("{err:#}");
assert!(chain.contains("line 2"), "no line number in: {chain}");
assert!(chain.contains("carry"), "no missing-field name in: {chain}");
}
#[test]
fn groups_flip_boundary_and_trailing_unfinalized_stream() {
// Direction flip: finalize and the next stream_start share ts_ms
// (both emitted from the same on_scroll_event call). The capture
// ends with stream 2 still in flight (no finalize).
let flip_finalize = FINALIZE.replace("\"ts_ms\":114.0", "\"ts_ms\":40.0");
let flip_start = START.replace("\"ts_ms\":0.0", "\"ts_ms\":40.0");
let records = parse_jsonl_str(&jsonl(&[
START,
FLUSH_FIRST,
FLUSH_SECOND,
&flip_finalize,
&flip_start,
&FLUSH_SECOND.replace("\"ts_ms\":32.0", "\"ts_ms\":56.0"),
]))
.expect("parse");
let groups = group_streams(&records).expect("well-formed grouping");
assert_eq!(groups.len(), 2);
let first = &groups[0];
assert!(first.is_finalized());
assert_eq!(first.flushes.len(), 2);
assert_eq!(first.finalize.expect("finalized").ts_ms, 40.0);
let trailing = &groups[1];
assert!(!trailing.is_finalized(), "in-flight at capture end");
assert_eq!(trailing.start.ts_ms, 40.0, "flip start shares finalize ts");
assert_eq!(trailing.flushes.len(), 1);
}
#[test]
fn orphan_records_and_double_start_are_rejected() {
let records = parse_jsonl_str(&jsonl(&[FLUSH_FIRST])).expect("parse");
let err = group_streams(&records).expect_err("orphan flush");
assert!(format!("{err:#}").contains("no open stream"));
let records = parse_jsonl_str(&jsonl(&[FINALIZE])).expect("parse");
let err = group_streams(&records).expect_err("orphan finalize");
assert!(format!("{err:#}").contains("no open stream"));
let records = parse_jsonl_str(&jsonl(&[START, FLUSH_FIRST, START])).expect("parse");
let err = group_streams(&records).expect_err("start while open");
assert!(format!("{err:#}").contains("still open"));
}
#[test]
fn per_stream_spacing_skips_the_global_first_flush_record() {
// Stream 2's first flush carries ms_since_prev_flush measured from
// stream 1's finalize (producer's last_flush_at is global) — 500ms
// of inter-gesture idle that is NOT stream-2 cadence and must be
// skipped; the later flush (16ms) and finalize (82ms) are kept.
let s2_start = START.replace("\"ts_ms\":0.0", "\"ts_ms\":600.0");
let s2_flush_global = FLUSH_SECOND
.replace("\"ts_ms\":32.0", "\"ts_ms\":616.0")
.replace(
"\"ms_since_prev_flush\":16.0",
"\"ms_since_prev_flush\":500.0",
);
let s2_flush_intra = FLUSH_SECOND.replace("\"ts_ms\":32.0", "\"ts_ms\":632.0");
let s2_finalize = FINALIZE.replace("\"ts_ms\":114.0", "\"ts_ms\":714.0");
let records = parse_jsonl_str(&jsonl(&[
START,
FLUSH_FIRST,
FINALIZE,
&s2_start,
&s2_flush_global,
&s2_flush_intra,
&s2_finalize,
]))
.expect("parse");
let groups = group_streams(&records).expect("grouping");
assert_eq!(groups.len(), 2);
assert_eq!(
groups[1].intra_stream_flush_spacings_ms(),
vec![16.0, 82.0],
"the 500ms cross-stream value must be skipped"
);
// Stream 1: first flush has no spacing at all (recorder start);
// only the finalize's intra-stream spacing remains.
assert_eq!(groups[0].intra_stream_flush_spacings_ms(), vec![82.0]);
}
#[test]
fn wait_for_finalize_count_write_then_check_phases() {
use std::io::Write;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("scroll-log.jsonl");
// Lazy-open recorder: the file does not exist yet — n=0 succeeds.
wait_for_finalize_count(&path, 0, Duration::ZERO).expect("n=0 on missing file");
// …but n=1 times out, reporting the current count.
let err = wait_for_finalize_count(&path, 1, Duration::from_millis(30))
.expect_err("missing file cannot satisfy n=1");
assert!(format!("{err:#}").contains("found 0"), "err: {err:#}");
let mut file = std::fs::File::create(&path).expect("create");
writeln!(file, "{START}").expect("write");
writeln!(file, "{FLUSH_FIRST}").expect("write");
writeln!(file, "{FINALIZE}").expect("write");
file.flush().expect("flush");
wait_for_finalize_count(&path, 1, Duration::from_secs(5)).expect("one finalize present");
// A torn tail mid-write (BufWriter fill boundary) must not fail the
// wait — counting is a raw substring match, not a parse.
write!(file, "{{\"ts_ms\":9").expect("torn tail");
file.flush().expect("flush");
wait_for_finalize_count(&path, 1, Duration::from_secs(5)).expect("torn tail tolerated");
let err = wait_for_finalize_count(&path, 2, Duration::from_millis(30))
.expect_err("only one finalize so far");
assert!(format!("{err:#}").contains("found 1"), "err: {err:#}");
// Completing the torn record into a second finalize satisfies n=2.
writeln!(file, ".0,\"evt\":\"finalize\"}}").expect("complete tail");
file.flush().expect("flush");
wait_for_finalize_count(&path, 2, Duration::from_secs(5)).expect("two finalizes");
}
#[test]
fn wait_for_finalize_count_observes_concurrent_appends() {
use std::io::Write;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("scroll-log.jsonl");
let writer_path = path.clone();
// Bounded writer delay, generous deadline: the wait must return as
// soon as the poll sees the flushed finalize, not at the deadline.
let writer = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(20));
let mut file = std::fs::File::create(&writer_path).expect("create");
writeln!(file, "{START}").expect("write");
writeln!(file, "{FINALIZE}").expect("write");
file.flush().expect("flush");
});
wait_for_finalize_count(&path, 1, Duration::from_secs(10))
.expect("poll must observe the concurrent append");
writer.join().expect("writer thread");
}
}
@@ -0,0 +1,52 @@
//! Scroll validation matrix.
//!
//! The scroll matrix drives the pager binary in a PTY with
//! `KIGI_SCROLL_LOG` pointed at a tempfile, then validates the pager's
//! flight-recorder JSONL (producer:
//! `kigi-tui/src/input/scroll_log.rs`) against gesture invariants.
//!
//! Layers (per-cell flow, executed by [`runner::run_cell`]):
//! [`session`] spawns the primed pager → the cell's [`gestures`] table is
//! replayed as timed SGR reports → [`log`] parses and groups the recorder
//! JSONL (finalize-synchronized, no fixed sleeps) → the cell's
//! [`invariants`] judge the [`cells`] row's verdict, xfail-aware, and
//! [`report`] renders the verdicts (table, `report.json`, exit code).
//! Entry points: the curated CI tier (`tests/scroll_matrix_curated.rs`) and
//! the local full sweep (`src/bin/scroll_matrix.rs`).
//!
//! ## No pager dependency
//!
//! The harness reaches the pager **only** through the spawned binary
//! (`PAGER_BINARY` / `env::pager_binary`); this module re-declares the wire
//! schema instead of importing pager types. That duplication is deliberate:
//! [`log::ScrollLogLine`] keeps every always-emitted producer field
//! **required**, so a rename/removal on the pager side fails deserialization
//! loudly here (schema-drift tripwire), while unknown fields are tolerated
//! so additive producer changes don't break older matrix code. The
//! producer-side twin lives in `kigi-tui/src/input/mouse/tests.rs`
//! (wire-format fixture test asserting the same key set on raw JSON).
pub mod cells;
pub mod gestures;
pub mod invariants;
pub mod log;
pub mod report;
pub mod runner;
pub mod session;
pub use cells::{CELLS, ExpectedProfile, MatrixCell, Tier, curated};
pub use gestures::{GestureId, WheelStep, direction_counts};
pub use invariants::{InvariantId, InvariantResult, check_log_invariant};
pub use log::{
EVT_FINALIZE, EVT_FLUSH, EVT_STREAM_START, ScrollLogLine, StreamGroup, group_streams,
parse_jsonl, parse_jsonl_str, wait_for_finalize_count,
};
pub use report::{
CellReport, CellStatus, InvariantReport, InvariantStatus, exit_code, summary_table,
write_report_json,
};
pub use runner::run_cell;
pub use session::{
SessionKind, marker_line, marker_response, marker_screen_row, spawn_marker_session,
spawn_settled_marker_session, spawn_streaming_marker_session, topmost_visible_marker,
};
@@ -0,0 +1,283 @@
//! Cell verdicts: the `report.json` artifact, the stdout summary table, and
//! the exit-code policy shared by the curated CI tests and the
//! `scroll-matrix` sweep binary.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::Serialize;
/// Verdict of one invariant row within a cell run.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum InvariantStatus {
/// Held, and was expected to hold.
Pass,
/// Violated outside the cell's xfail set.
Fail,
/// Violated inside the xfail set (the declared known bug).
XFail,
/// Held despite being in the xfail set — the bug got fixed or the cell
/// rotted; either way the cell must be promoted out of xfail, so this
/// fails the run exactly like [`InvariantStatus::Fail`].
XPass,
}
/// Cell-level verdict: the worst of its invariant rows
/// (`Fail > XPass > XFail > Pass` — see `runner::classify`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CellStatus {
Pass,
Fail,
XFail,
XPass,
}
impl CellStatus {
/// Fixed-width table label.
pub fn as_str(self) -> &'static str {
match self {
CellStatus::Pass => "PASS",
CellStatus::Fail => "FAIL",
CellStatus::XFail => "XFAIL",
CellStatus::XPass => "XPASS",
}
}
}
/// One invariant row of a [`CellReport`].
#[derive(Clone, Debug, Serialize)]
pub struct InvariantReport {
/// Design vocabulary id (`I-ORD`, …).
pub id: String,
pub status: InvariantStatus,
/// Violation detail, or the promote-out-of-xfail note on XPass.
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
/// Verdict of one matrix cell run.
#[derive(Clone, Debug, Serialize)]
pub struct CellReport {
pub cell_id: String,
/// `curated` | `full`.
pub tier: String,
pub status: CellStatus,
/// One row per declared invariant; empty when the run aborted before
/// evaluation (see `note`).
pub invariants: Vec<InvariantReport>,
/// The cell's `KIGI_SCROLL_LOG` capture (kept for post-mortems).
pub log_path: String,
/// Streams grouped out of the capture (finalized + trailing in-flight).
pub streams: usize,
pub duration_ms: u64,
/// Phase note for runs that never reached invariant evaluation (setup
/// panic, finalize-wait timeout, the per-cell hard cap).
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
/// Exit-code policy: nonzero iff any cell **Fail** or **XPass** — expected
/// failures (XFail) are green, but a fixed-or-rotted xfail cell must break
/// the run so it gets promoted instead of silently absorbed.
pub fn exit_code(reports: &[CellReport]) -> u8 {
let failed = reports
.iter()
.any(|r| matches!(r.status, CellStatus::Fail | CellStatus::XPass));
u8::from(failed)
}
/// Write `report.json` (pretty, array of [`CellReport`]) into `dir`,
/// creating it as needed; returns the file path.
pub fn write_report_json(reports: &[CellReport], dir: &Path) -> Result<PathBuf> {
std::fs::create_dir_all(dir)
.with_context(|| format!("create artifacts dir {}", dir.display()))?;
let path = dir.join("report.json");
let json = serde_json::to_string_pretty(reports).context("serialize cell reports")?;
std::fs::write(&path, json).with_context(|| format!("write {}", path.display()))?;
Ok(path)
}
/// Detail column source: the run's phase note, else the first non-Pass
/// invariant row's `id: detail`.
fn detail_for(report: &CellReport) -> String {
if let Some(note) = &report.note {
return note.clone();
}
report
.invariants
.iter()
.find(|row| row.status != InvariantStatus::Pass)
.map(|row| {
let detail = row.detail.as_deref().unwrap_or("");
format!("{}: {detail}", row.id)
})
.unwrap_or_else(|| "-".to_owned())
}
/// Truncation width for the table's detail column (full text lives in
/// `report.json`).
const DETAIL_WIDTH: usize = 72;
/// Render the aligned one-row-per-cell summary table. Plain ASCII, no
/// ANSI/TTY dependence — safe for CI logs and `| tee`.
pub fn summary_table(reports: &[CellReport]) -> String {
let mut rows: Vec<[String; 6]> = vec![[
"CELL".into(),
"TIER".into(),
"STATUS".into(),
"STREAMS".into(),
"TIME".into(),
"DETAIL".into(),
]];
for report in reports {
let mut detail = detail_for(report).replace(['\n', '\r'], " ");
if detail.len() > DETAIL_WIDTH {
let cut = (0..=DETAIL_WIDTH.saturating_sub(3))
.rev()
.find(|&i| detail.is_char_boundary(i))
.unwrap_or(0);
detail.truncate(cut);
detail.push_str("...");
}
rows.push([
report.cell_id.clone(),
report.tier.clone(),
report.status.as_str().to_owned(),
report.streams.to_string(),
format!("{}ms", report.duration_ms),
detail,
]);
}
let mut widths = [0usize; 6];
for row in &rows {
for (width, cell) in widths.iter_mut().zip(row) {
*width = (*width).max(cell.len());
}
}
let mut out = String::new();
for row in &rows {
let mut line = String::new();
for (width, cell) in widths.iter().zip(row) {
line.push_str(&format!("{cell:<width$} "));
}
out.push_str(line.trim_end());
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn report(cell_id: &str, status: CellStatus, rows: Vec<InvariantReport>) -> CellReport {
CellReport {
cell_id: cell_id.to_owned(),
tier: "curated".to_owned(),
status,
invariants: rows,
log_path: format!("/tmp/{cell_id}.jsonl"),
streams: 1,
duration_ms: 1234,
note: None,
}
}
fn row(id: &str, status: InvariantStatus, detail: Option<&str>) -> InvariantReport {
InvariantReport {
id: id.to_owned(),
status,
detail: detail.map(str::to_owned),
}
}
#[test]
fn exit_code_is_nonzero_iff_fail_or_xpass() {
let pass = report("a", CellStatus::Pass, vec![]);
let xfail = report("b", CellStatus::XFail, vec![]);
let fail = report("c", CellStatus::Fail, vec![]);
let xpass = report("d", CellStatus::XPass, vec![]);
assert_eq!(exit_code(&[]), 0);
assert_eq!(
exit_code(&[pass.clone(), xfail.clone()]),
0,
"XFail is green"
);
assert_eq!(exit_code(&[pass.clone(), fail]), 1);
// The fixed-bug tripwire: an xfail cell that PASSES must break the run.
assert_eq!(exit_code(&[pass, xfail, xpass]), 1, "XPass must be nonzero");
}
#[test]
fn report_json_shape_and_optional_fields() {
let full = report(
"cell_x",
CellStatus::XFail,
vec![row("I-NO-DROP", InvariantStatus::XFail, Some("dropped 74"))],
);
let value = serde_json::to_value(&full).expect("serialize");
assert_eq!(value["cell_id"], "cell_x");
assert_eq!(value["tier"], "curated");
assert_eq!(value["status"], "x_fail", "snake_case enum labels");
assert_eq!(value["streams"], 1);
assert_eq!(value["duration_ms"], 1234);
assert_eq!(value["invariants"][0]["id"], "I-NO-DROP");
assert_eq!(value["invariants"][0]["status"], "x_fail");
assert_eq!(value["invariants"][0]["detail"], "dropped 74");
assert!(
value.get("note").is_none(),
"None note must not serialize: {value}"
);
let pass_row = serde_json::to_value(row("I-ORD", InvariantStatus::Pass, None)).unwrap();
assert!(pass_row.get("detail").is_none(), "None detail skipped");
}
#[test]
fn write_report_json_creates_dir_and_file() {
let dir = tempfile::tempdir().expect("tempdir");
let nested = dir.path().join("artifacts");
let path = write_report_json(&[report("a", CellStatus::Pass, vec![])], &nested)
.expect("write report");
assert_eq!(path, nested.join("report.json"));
let raw = std::fs::read_to_string(&path).expect("read back");
let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid json");
assert_eq!(parsed[0]["cell_id"], "a");
}
#[test]
fn summary_table_aligns_columns_and_stays_single_line_per_cell() {
let reports = vec![
report("short", CellStatus::Pass, vec![]),
report(
"a_much_longer_cell_identifier",
CellStatus::Fail,
vec![row(
"I-CAP",
InvariantStatus::Fail,
Some(&"flushed 40 exceeds cap 25\nwith a newline".repeat(4)),
)],
),
];
let table = summary_table(&reports);
let lines: Vec<&str> = table.lines().collect();
assert_eq!(lines.len(), 3, "header + one row per cell:\n{table}");
// Every row's STATUS column starts where the header's does.
let status_col = lines[0].find("STATUS").expect("header STATUS");
assert_eq!(&lines[1][status_col..status_col + 4], "PASS");
assert_eq!(&lines[2][status_col..status_col + 4], "FAIL");
// Failure detail is carried (truncated, newlines flattened).
assert!(lines[2].contains("I-CAP: flushed 40"), "table:\n{table}");
assert!(lines[2].contains("..."), "long detail truncated:\n{table}");
assert!(
!table.contains('\x1b'),
"table must be plain ASCII (non-TTY-safe)"
);
}
}
@@ -0,0 +1,465 @@
//! The per-cell executor: spawn the primed pager, replay the gesture,
//! synchronize on the recorder, judge the invariants, classify the verdict.
//!
//! ## Blocking model
//!
//! A cell's body ([`run_cell_inner`]) is host-paced end to end — PTY drains,
//! `thread::sleep` gesture gaps, finalize polling — so [`run_cell`] runs it
//! on `spawn_blocking` (driving the async session spawns via
//! `Handle::block_on`, which requires a **multi-thread** runtime) and applies
//! the per-cell hard cap with `tokio::time::timeout` on the join handle.
//! That also converts setup panics (the session preambles assert with the
//! screen contents) into a `Fail` report instead of killing the whole sweep.
//! A capped cell's blocking task cannot be aborted mid-syscall: it is left
//! to unwind on its own bounded waits (its `Drop`s kill the pager child and
//! mock server), while the sweep moves on.
//!
//! ## Harness-side invariants
//!
//! [`InvariantId::Screen`] and [`InvariantId::Quiet`] are checked here (the
//! log alone can't see the viewport or repaints — `invariants.rs` panics on
//! them by contract): I-QUIET counts frames in a post-finalize watermark
//! window, I-SCREEN replays the per-stream `applied_total`s through a
//! bottom-clamped travel simulation and compares the topmost visible marker
//! against the session baseline.
use std::path::Path;
use std::time::{Duration, Instant};
use anyhow::{Context, Result, bail};
use super::cells::{MatrixCell, Tier};
use super::invariants::{InvariantId, InvariantResult, check_log_invariant};
use super::log::{StreamGroup, group_streams, parse_jsonl, wait_for_finalize_count};
use super::report::{CellReport, CellStatus, InvariantReport, InvariantStatus};
use super::session::{
SessionKind, WHEEL_COL, WHEEL_ROW, spawn_marker_session, topmost_visible_marker,
};
/// Transcript height for every cell: comfortably taller than the 50-row PTY
/// (the preamble's scrollable-baseline guard) with enough headroom that the
/// small gestures judged by I-SCREEN never clamp at the transcript top.
/// Deliberate travel clamping (floods can deliver hundreds of rows) is fine:
/// no log-side invariant reads the viewport.
const MARKER_COUNT: usize = 400;
/// Recorder-synchronization budget: every gesture table spans < 1.5s and a
/// stream finalizes 80ms after its last event, so 5s only trips on a real
/// wedge (or a stall so long the cell is unusable anyway).
const FINALIZE_TIMEOUT: Duration = Duration::from_secs(5);
/// Post-finalize settle: consume the gesture-era PTY backlog so the quiet
/// window below counts only NEW frames (the harness parses chunks lazily in
/// `update`, not at arrival).
const PIPELINE_DRAIN: Duration = Duration::from_millis(300);
/// I-QUIET observation window after the drain + watermark reset.
const QUIET_WINDOW: Duration = Duration::from_millis(500);
/// I-QUIET allowance: a straggler cadence/finalize paint mid-pipeline may
/// land after the watermark; repaint CHURN (the A2 symptom) paints dozens.
const QUIET_MAX_FRAMES: u64 = 2;
/// Streaming-session teardown budget: the paced tail is ~7s at spawn time,
/// so the released turn completes well inside this.
const COMPLETION_TIMEOUT: Duration = Duration::from_secs(30);
/// Per-cell hard cap (spawn → verdict). The slowest legitimate cell — the
/// streaming preamble plus its post-gesture tail drain — finishes in ~20s.
const CELL_HARD_CAP: Duration = Duration::from_secs(60);
/// `tier` label for [`CellReport`].
fn tier_label(tier: Tier) -> &'static str {
match tier {
Tier::Curated => "curated",
Tier::Full => "full",
}
}
/// One SGR (DECSET 1006) wheel press report at the shared in-scrollback
/// position — 0-based [`WHEEL_ROW`]/[`WHEEL_COL`] encode 1-based on the wire
/// (same bytes as the pager e2e `sgr_mouse` helper).
fn sgr_wheel_report(button: u16) -> String {
format!("\x1b[<{button};{};{}M", WHEEL_COL + 1, WHEEL_ROW + 1)
}
/// Run one matrix cell against `binary`, capturing the recorder JSONL to
/// `artifacts_dir/<cell_id>.jsonl` (kept for post-mortems). Never panics and
/// never hangs past [`CELL_HARD_CAP`]; every abnormality becomes a `Fail`
/// report with a phase note. Requires a multi-thread tokio runtime (see the
/// module docs).
pub async fn run_cell(cell: &MatrixCell, binary: &Path, artifacts_dir: &Path) -> CellReport {
let started = Instant::now();
let cell = *cell;
let log_path = artifacts_dir.join(format!("{}.jsonl", cell.id));
let outcome = match std::fs::create_dir_all(artifacts_dir)
.with_context(|| format!("create artifacts dir {}", artifacts_dir.display()))
{
Err(err) => Err(format!("artifacts setup: {err:#}")),
Ok(()) => {
let handle = tokio::runtime::Handle::current();
let (binary, inner_log) = (binary.to_path_buf(), log_path.clone());
let task = tokio::task::spawn_blocking(move || {
handle.block_on(run_cell_inner(cell, &binary, &inner_log))
});
match tokio::time::timeout(CELL_HARD_CAP, task).await {
Err(_) => Err(format!(
"hard cap: cell still running after {CELL_HARD_CAP:?} (phase unknown; \
the cell task is left to unwind on its own bounded waits)"
)),
Ok(Err(join_err)) => Err(format!("panic: {}", panic_message(join_err))),
Ok(Ok(Err(err))) => Err(format!("{err:#}")),
Ok(Ok(Ok(run))) => Ok(run),
}
}
};
let (status, invariants, streams, note) = match outcome {
Ok(run) => {
let (status, invariants) = classify(&run.outcomes, cell.xfail);
(status, invariants, run.streams, None)
}
Err(note) => (CellStatus::Fail, Vec::new(), 0, Some(note)),
};
CellReport {
cell_id: cell.id.to_owned(),
tier: tier_label(cell.tier).to_owned(),
status,
invariants,
log_path: log_path.display().to_string(),
streams,
duration_ms: started.elapsed().as_millis() as u64,
note,
}
}
fn panic_message(err: tokio::task::JoinError) -> String {
match err.try_into_panic() {
Ok(payload) => payload
.downcast_ref::<&str>()
.map(|s| (*s).to_owned())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "non-string panic payload".to_owned()),
Err(err) => format!("cell task failed without panicking: {err}"),
}
}
/// Everything [`run_cell`] needs from a completed (non-aborted) cell body.
struct CellRun {
outcomes: Vec<(InvariantId, InvariantResult)>,
streams: usize,
}
async fn run_cell_inner(cell: MatrixCell, binary: &Path, log_path: &Path) -> Result<CellRun> {
// Stale-capture guard: the recorder opens its file lazily on the first
// record, so a leftover capture from a previous run would satisfy the
// finalize wait with the OLD gesture's records.
if log_path.exists() {
std::fs::remove_file(log_path)
.with_context(|| format!("setup: remove stale capture {}", log_path.display()))?;
}
let log_value = log_path
.to_str()
.context("setup: artifacts path is not UTF-8")?;
let mut env: Vec<(&str, &str)> = cell.env.to_vec();
env.push(("KIGI_SCROLL_LOG", log_value));
// Live bindings on purpose: `content` owns the mock server (and the
// streaming completion gate) — see the session module's footgun docs.
let (mut harness, content, baseline) =
spawn_marker_session(binary, cell.session, MARKER_COUNT, &env).await;
// Replay the gesture table: sleep each step's pre-delay (host-side lower
// bound — jitter only stretches gaps), then emit its report. Port of the
// pager e2e `send_wheel_sequence` loop onto `WheelStep`.
for step in cell.gesture.steps(cell.expected.ept) {
if step.pre_delay_ms > 0 {
std::thread::sleep(Duration::from_millis(step.pre_delay_ms));
}
harness
.inject_keys(sgr_wheel_report(step.button).as_bytes())
.context("gesture: inject wheel report")?;
}
wait_for_finalize_count(log_path, cell.gesture.expected_streams(), FINALIZE_TIMEOUT)
.context("gesture: finalize wait")?;
// Drain the gesture-era backlog, then watermark → the quiet window
// counts only post-finalize frames; the marker read afterwards sees the
// fully painted final viewport (I-SCREEN's input).
harness.update(PIPELINE_DRAIN);
harness.reset_timing();
harness.update(QUIET_WINDOW);
let quiet_frames = harness.frame_count();
let marker_after = topmost_visible_marker(&harness);
// Streaming teardown: the CALLER owns the gate release (session-module
// contract) — release after the gesture so the pager exits a completed
// turn, and prove the release took (the held gate is the alternative
// explanation for almost any streaming-cell wedge).
if cell.session == SessionKind::Streaming {
content.release_agent_completions();
let deadline = Instant::now() + COMPLETION_TIMEOUT;
while harness.contains_text("Responding") {
if Instant::now() >= deadline {
bail!("teardown: turn never completed after the gate release");
}
harness.update(Duration::from_millis(200));
}
}
harness.quit().context("teardown: quit pager")?;
drop(content);
// The pager exited (recorder flushed + closed), so the capture is
// complete and torn-tail-free by construction.
let records = parse_jsonl(log_path).context("verdict: parse capture")?;
let groups = group_streams(&records).context("verdict: group streams")?;
let outcomes = cell
.invariants
.iter()
.map(|&id| {
let result = if id.is_log_side() {
check_log_invariant(id, &cell.expected, &groups)
} else {
match id {
InvariantId::Screen => {
check_screen(cell.session, baseline, marker_after, &groups)
}
InvariantId::Quiet => check_quiet(quiet_frames),
_ => unreachable!("is_log_side covers every other id"),
}
};
(id, result)
})
.collect();
Ok(CellRun {
outcomes,
streams: groups.len(),
})
}
/// I-QUIET: no repaint churn after the last finalize — at most
/// [`QUIET_MAX_FRAMES`] frames land in the watermark window.
fn check_quiet(quiet_frames: u64) -> InvariantResult {
if quiet_frames > QUIET_MAX_FRAMES {
return InvariantResult::Violated {
detail: format!(
"{quiet_frames} frames painted in the {QUIET_WINDOW:?} post-finalize window \
(> {QUIET_MAX_FRAMES}) — repaint churn after the gesture ended"
),
};
}
InvariantResult::Pass
}
/// Net signed lines a stream delivered: its last flush-bearing record's
/// cumulative `applied_total` (finalize when present, else the last flush of
/// a trailing in-flight stream).
fn stream_applied(group: &StreamGroup<'_>) -> i64 {
group
.flush_bearing()
.last()
.map_or(0, |record| record.applied_total)
}
/// Replay per-stream deliveries through the viewport's clamps: `0` is the
/// bottom pin (down-deliveries there don't move — G7's point), and the
/// return is clamped to `-baseline` because travel above marker 0 pins the
/// topmost visible marker at 0.
fn simulate_clamped_travel(baseline: usize, applied: impl IntoIterator<Item = i64>) -> i64 {
let mut pos: i64 = 0;
for delta in applied {
pos = (pos + delta).min(0);
}
pos.max(-(baseline as i64))
}
/// I-SCREEN: the viewport visibly moved/clamped exactly as the recorder
/// says it should have — topmost-marker delta equals the bottom-clamped
/// replay of the per-stream `applied_total`s (up is negative, matching the
/// producer's `ScrollDirection` sign and the marker index direction).
fn check_screen(
session: SessionKind,
baseline: usize,
marker_after: Option<usize>,
groups: &[StreamGroup<'_>],
) -> InvariantResult {
if session == SessionKind::Streaming {
// The live bottom keeps growing mid-stream, so "marker delta ==
// applied" has no stable frame of reference — a cell-table bug.
return InvariantResult::Violated {
detail: "I-SCREEN attached to a streaming session (no stable baseline)".to_owned(),
};
}
let Some(after) = marker_after else {
return InvariantResult::Violated {
detail: "no marker visible after the gesture".to_owned(),
};
};
let expected =
baseline as i64 + simulate_clamped_travel(baseline, groups.iter().map(stream_applied));
if after as i64 != expected {
return InvariantResult::Violated {
detail: format!(
"topmost marker {baseline} -> {after} after the gesture, but the clamped \
replay of the streams' applied totals lands at {expected}"
),
};
}
InvariantResult::Pass
}
/// XPASS row detail: the actionable half of the xfail contract.
const XPASS_DETAIL: &str = "expected to violate (xfail) but PASSED — the pinned bug got fixed \
or the cell rotted; promote the invariant out of the xfail set";
/// Classify evaluated invariants into per-row statuses and the cell verdict.
/// Precedence: any `Fail` (non-xfail violation) fails the cell; else any
/// `XPass` fails it (fixed/rotted xfail must be promoted, not absorbed);
/// else any `XFail` marks the expected failure; else `Pass`.
fn classify(
outcomes: &[(InvariantId, InvariantResult)],
xfail: &[InvariantId],
) -> (CellStatus, Vec<InvariantReport>) {
let rows: Vec<InvariantReport> = outcomes
.iter()
.map(|(id, result)| {
let expected_to_fail = xfail.contains(id);
let (status, detail) = match (result, expected_to_fail) {
(InvariantResult::Pass, false) => (InvariantStatus::Pass, None),
(InvariantResult::Pass, true) => {
(InvariantStatus::XPass, Some(XPASS_DETAIL.to_owned()))
}
(InvariantResult::Violated { detail }, false) => {
(InvariantStatus::Fail, Some(detail.clone()))
}
(InvariantResult::Violated { detail }, true) => {
(InvariantStatus::XFail, Some(detail.clone()))
}
};
InvariantReport {
id: id.as_str().to_owned(),
status,
detail,
}
})
.collect();
let has = |status: InvariantStatus| rows.iter().any(|row| row.status == status);
let status = if has(InvariantStatus::Fail) {
CellStatus::Fail
} else if has(InvariantStatus::XPass) {
CellStatus::XPass
} else if has(InvariantStatus::XFail) {
CellStatus::XFail
} else {
CellStatus::Pass
};
(status, rows)
}
#[cfg(test)]
mod tests {
use super::*;
fn violated(detail: &str) -> InvariantResult {
InvariantResult::Violated {
detail: detail.to_owned(),
}
}
/// The wire bytes of the shared wheel position, pinned to the
/// `40;12` encoding documented on `WHEEL_ROW`/`WHEEL_COL`.
#[test]
fn sgr_report_encodes_one_based_wire_coords() {
assert_eq!(sgr_wheel_report(64), "\x1b[<64;40;12M");
assert_eq!(sgr_wheel_report(65), "\x1b[<65;40;12M");
}
#[test]
fn clamped_travel_models_bottom_pin_and_top_clamp() {
// G7's shape: down-deliveries at the pin don't move, the up tail does.
assert_eq!(simulate_clamped_travel(300, [10, -7]), -7);
// Up then partially back down: net movement, no clamp involved.
assert_eq!(simulate_clamped_travel(300, [-5, 3]), -2);
// Pure down at the pin stays put.
assert_eq!(simulate_clamped_travel(300, [10]), 0);
// Down past the pin then up: the overshoot must not bank as credit.
assert_eq!(simulate_clamped_travel(300, [25, -4]), -4);
// Travel beyond the transcript top pins the topmost marker at 0.
assert_eq!(simulate_clamped_travel(30, [-500]), -30);
}
#[test]
fn quiet_allows_the_straggler_allowance_only() {
assert!(check_quiet(0).is_pass());
assert!(check_quiet(QUIET_MAX_FRAMES).is_pass());
let result = check_quiet(QUIET_MAX_FRAMES + 1);
assert!(
matches!(result, InvariantResult::Violated { ref detail } if detail.contains("churn"))
);
}
#[test]
fn screen_rejects_streaming_sessions_and_marker_loss() {
let streaming = check_screen(SessionKind::Streaming, 100, Some(100), &[]);
assert!(
matches!(streaming, InvariantResult::Violated { ref detail } if detail.contains("streaming"))
);
let lost = check_screen(SessionKind::BottomPinned, 100, None, &[]);
assert!(
matches!(lost, InvariantResult::Violated { ref detail } if detail.contains("no marker"))
);
// Empty capture ⇒ no movement expected; a matching marker passes.
assert!(check_screen(SessionKind::BottomPinned, 100, Some(100), &[]).is_pass());
let moved = check_screen(SessionKind::BottomPinned, 100, Some(97), &[]);
assert!(matches!(moved, InvariantResult::Violated { .. }));
}
#[test]
fn classify_precedence_fail_over_xpass_over_xfail_over_pass() {
use InvariantId::{Cap, NoDrop, Ord, SmoothCoast};
// All pass, nothing xfailed → Pass.
let (status, rows) = classify(&[(Ord, InvariantResult::Pass)], &[]);
assert_eq!(status, CellStatus::Pass);
assert_eq!(rows[0].status, InvariantStatus::Pass);
assert_eq!(rows[0].id, "I-ORD");
// The declared bug violates, everything else passes → XFail.
let jerk = [
(Ord, InvariantResult::Pass),
(SmoothCoast, violated("coast")),
(NoDrop, violated("dropped 74")),
];
let (status, rows) = classify(&jerk, &[SmoothCoast, NoDrop]);
assert_eq!(status, CellStatus::XFail);
assert_eq!(rows[1].status, InvariantStatus::XFail);
assert_eq!(rows[1].detail.as_deref(), Some("coast"));
// One xfail row passing flips the cell to XPass (fixed-bug tripwire)…
let half_fixed = [
(SmoothCoast, InvariantResult::Pass),
(NoDrop, violated("dropped 74")),
];
let (status, rows) = classify(&half_fixed, &[SmoothCoast, NoDrop]);
assert_eq!(status, CellStatus::XPass);
assert!(rows[0].detail.as_deref().unwrap().contains("promote"));
// …but any non-xfail violation dominates everything.
let broken = [
(Cap, violated("flushed 40 exceeds cap 25")),
(SmoothCoast, InvariantResult::Pass),
];
let (status, _) = classify(&broken, &[SmoothCoast]);
assert_eq!(status, CellStatus::Fail);
}
#[test]
fn tier_labels_match_the_report_vocabulary() {
assert_eq!(tier_label(Tier::Curated), "curated");
assert_eq!(tier_label(Tier::Full), "full");
}
}
@@ -0,0 +1,318 @@
//! Marker-session preambles: spawn the pager into the primed state a matrix
//! cell's gesture assumes, ported from the pager's `tests/pty_e2e/scroll.rs`
//! (`spawn_bottom_pinned_marker_scrollback[_with_env]` /
//! `spawn_streaming_marker_turn`) onto this crate's public API so the A13
//! runner — which lives here, not in the pager's test tree — can reuse the
//! proven construction.
//!
//! ## Controller-drop footgun (kept from the originals)
//!
//! Destructure the returned controller into a LIVE binding (`content` /
//! `_content`), never `_` — a `_` binding drops it immediately, killing the
//! mock server mid-session and surfacing as a confusing 60s stream timeout
//! instead of an obvious failure.
//!
//! ## Gate-release ownership (streaming sessions)
//!
//! [`spawn_streaming_marker_session`] holds every agent completion
//! (`hold_agent_completions`) and paces deltas, so the turn provably cannot
//! finish while the gesture runs. The CALLER owns the release: call
//! `content.release_agent_completions()` after the gesture (before quitting,
//! so the pager exits a completed turn rather than an aborted stream).
use std::path::Path;
use std::time::Duration;
use crate::PtyHarness;
use crate::content::ContentController;
/// Transcript state a cell's gesture starts from.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SessionKind {
/// Response fully streamed, viewport bottom-pinned, scrollback focused.
Settled,
/// Same preamble as [`SessionKind::Settled`] — the settle leaves the
/// viewport pinned to the bottom. A separate kind because the cell's
/// point is the pin itself (down-scroll must clamp: G7 / I-SCREEN),
/// not just "there is history above".
BottomPinned,
/// Turn still streaming (paced deltas + held completion gate) when the
/// gesture runs — mid-stream by construction (G8).
Streaming,
}
/// PTY geometry shared by every matrix session (the pager e2e default:
/// large enough that the welcome screen never wraps).
pub const SESSION_ROWS: u16 = 50;
pub const SESSION_COLS: u16 = 120;
/// Wheel-report position, 0-based (row, col): inside the scrollback pane at
/// the [`SESSION_ROWS`]×[`SESSION_COLS`] PTY (wire encodes as SGR `40;12`).
pub const WHEEL_ROW: u16 = 11;
pub const WHEEL_COL: u16 = 39;
/// Welcome-screen sentinel: the menu label `Quit` (case-sensitive, so the
/// lowercase authenticating hint can't match early).
const WELCOME_SCREEN_SENTINEL: &str = "Quit";
const WELCOME_TIMEOUT: Duration = Duration::from_secs(20);
/// Prompt submitted to the agent — short enough never to wrap.
const PROMPT: &str = "go";
/// First line inside the marker code block (the mock response sentinel).
const MOCK_RESPONSE_SENTINEL: &str = "MOCKRESPONSE";
/// Prefix shared by [`marker_line`] and the screen parses.
const MARKER_PREFIX: &str = "MARKER-";
/// End marker of a streaming session's tail: the final streamed word, kept
/// off-screen while the tail is in flight (witness for "still streaming").
pub const STREAM_END_SENTINEL: &str = "STREAMDONE";
/// Unique numbered marker line (`MARKER-0042`), zero-padded so no marker is
/// a substring of another within a [`marker_response`] transcript.
pub fn marker_line(n: usize) -> String {
format!("{MARKER_PREFIX}{n:04}")
}
/// `count` numbered marker lines inside a fenced code block — markdown
/// renders exactly one row per marker with no soft-wrap reflow, so marker
/// index deltas equal row deltas.
pub fn marker_response(count: usize) -> String {
let mut s = String::with_capacity(count * 16 + 64);
s.push_str("```\n");
s.push_str(MOCK_RESPONSE_SENTINEL);
s.push('\n');
for i in 0..count {
s.push_str(&marker_line(i));
s.push('\n');
}
s.push_str("```\n");
s
}
/// Current screen row (0-based) of `marker`, or `None` when off-screen.
pub fn marker_screen_row(harness: &PtyHarness, marker: &str) -> Option<u16> {
harness
.screen_contents()
.lines()
.position(|line| line.contains(marker))
.map(|row| row as u16)
}
/// Index of the topmost marker on screen (`None` when none visible;
/// malformed hits skipped). Scrolling UP strictly decreases it by the rows
/// scrolled — the "viewport moved by K rows" primitive (I-SCREEN's input).
pub fn topmost_visible_marker(harness: &PtyHarness) -> Option<usize> {
topmost_marker_in(&harness.screen_contents())
}
fn topmost_marker_in(screen: &str) -> Option<usize> {
screen.lines().find_map(|line| {
let digits_at = line.find(MARKER_PREFIX)? + MARKER_PREFIX.len();
line.get(digits_at..digits_at + 4)?.parse().ok()
})
}
/// Streaming-session defaults: the marker block rides the FIRST delta; a
/// 240-word tail at 30ms per SSE event then streams for ≥7s — wider than
/// any gesture table (G9b, the longest, spans ~0.4s) plus its finalize
/// wait, so the gesture provably overlaps the stream. Values proven by the
/// pager's `wheel_*_mid_stream` e2e tests.
pub const STREAMING_TAIL_WORDS: usize = 240;
pub const STREAMING_CHUNK_DELAY: Duration = Duration::from_millis(30);
/// Spawn `binary` (the pager under test — the matrix runner plumbs its
/// `--binary` override here; tests pass `env::pager_binary()`) for `kind`
/// over a `marker_count`-marker transcript with `extra_env` appended to the
/// mock's pager env (terminal-class markers, `KIGI_SCROLL_*`,
/// `KIGI_SCROLL_LOG` — the PTY spawn strips host-terminal identity first,
/// so injected markers always win).
///
/// Returns `(harness, controller, baseline)` where `baseline` is the
/// topmost visible marker index — see the footgun notes in the module docs
/// before binding the controller.
pub async fn spawn_marker_session(
binary: &Path,
kind: SessionKind,
marker_count: usize,
extra_env: &[(&str, &str)],
) -> (PtyHarness, ContentController, usize) {
match kind {
SessionKind::Settled | SessionKind::BottomPinned => {
spawn_settled_marker_session(binary, marker_count, extra_env).await
}
SessionKind::Streaming => {
spawn_streaming_marker_session(
binary,
marker_count,
STREAMING_TAIL_WORDS,
STREAMING_CHUNK_DELAY,
extra_env,
)
.await
}
}
}
/// Shared spawn: mock content up, pager in a [`SESSION_ROWS`]×
/// [`SESSION_COLS`] PTY with content env + `extra_env`, welcome waited.
fn spawn_pager(
binary: &Path,
content: &ContentController,
extra_env: &[(&str, &str)],
) -> PtyHarness {
let content_env = content.env_for_pager();
let mut env: Vec<(&str, &str)> = content_env
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
env.extend_from_slice(extra_env);
let mut harness = PtyHarness::new(binary, SESSION_ROWS, SESSION_COLS, &[], &env)
.expect("spawn pager with content");
harness
.wait_for_text(WELCOME_SCREEN_SENTINEL, WELCOME_TIMEOUT)
.expect("welcome text");
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
}
/// Panic (with the screen) unless the transcript overflows the viewport:
/// marker 0 off-screen-top and some later marker visible — otherwise the
/// gesture has nothing to scroll into view. Returns the movement baseline.
fn assert_scrollable_baseline(harness: &PtyHarness, context: &str) -> usize {
assert!(
marker_screen_row(harness, &marker_line(0)).is_none(),
"setup: {} already visible → transcript not taller than the screen ({context})\nscreen:\n{}",
marker_line(0),
harness.screen_contents()
);
topmost_visible_marker(harness).unwrap_or_else(|| {
panic!(
"setup: no marker visible ({context})\nscreen:\n{}",
harness.screen_contents()
)
})
}
/// [`SessionKind::Settled`]/[`SessionKind::BottomPinned`] preamble: stream
/// the whole marker transcript, land bottom-pinned in follow mode, focus
/// the scrollback via Tab (Esc would silently arm the rewind picker, whose
/// ~800ms expiry redraw pollutes frame captures; wheel reports are
/// position-routed regardless of focus), quiesce, reset the frame-timing
/// watermark — counted frames then come only from the caller's gesture.
pub async fn spawn_settled_marker_session(
binary: &Path,
marker_count: usize,
extra_env: &[(&str, &str)],
) -> (PtyHarness, ContentController, usize) {
let content = ContentController::start().await.expect("start content");
content.set_response(marker_response(marker_count));
let mut harness = spawn_pager(binary, &content, extra_env);
// The LAST marker is the last thing streamed → the whole transcript is in.
harness
.wait_for_text(&marker_line(marker_count - 1), Duration::from_secs(60))
.expect("response finished streaming");
harness.update(Duration::from_millis(500));
let baseline = assert_scrollable_baseline(&harness, "settled");
harness.inject_keys(b"\t").expect("focus scrollback (tab)");
harness.update(Duration::from_millis(500));
// Footer wait is best-effort (skin-dependent), as in the original.
let _ = harness.wait_for_text("Space:prompt", Duration::from_secs(5));
harness.update(Duration::from_millis(300));
harness.reset_timing();
(harness, content, baseline)
}
/// [`SessionKind::Streaming`] preamble: the whole fenced marker block rides
/// the first delta (the mock splits deltas on single spaces and the block
/// contains none), the space-separated tail streams word-by-word at
/// `chunk_delay`, and the completion gate holds the turn's terminal event —
/// mid-turn by construction until the caller releases the gate (see the
/// module docs). Setup guards: transcript overflows the viewport and
/// [`STREAM_END_SENTINEL`] is not on screen (bottom-pinned follow would
/// render it if the stream had finished).
pub async fn spawn_streaming_marker_session(
binary: &Path,
marker_count: usize,
tail_words: usize,
chunk_delay: Duration,
extra_env: &[(&str, &str)],
) -> (PtyHarness, ContentController, usize) {
let content = ContentController::start().await.expect("start content");
content.set_chunk_delay(Some(chunk_delay));
content.hold_agent_completions();
// set_turns (not set_response): only agent turns ride the completion
// gate; aux title/classifier requests fall through untouched.
let mut turn = marker_response(marker_count);
for i in 0..tail_words {
turn.push_str(&format!("TAIL-{i:04} "));
}
turn.push_str(STREAM_END_SENTINEL);
content.set_turns([turn]);
let mut harness = spawn_pager(binary, &content, extra_env);
// The last marker rides the first delta, so this waits only for the
// stream to START; the tail is still in flight afterwards.
harness
.wait_for_text(&marker_line(marker_count - 1), Duration::from_secs(30))
.expect("marker block streamed");
harness.update(Duration::from_millis(300));
assert!(
!harness.contains_text(STREAM_END_SENTINEL),
"setup: {STREAM_END_SENTINEL} already on screen — the paced tail finished \
before the gesture could overlap the stream\nscreen:\n{}",
harness.screen_contents()
);
let baseline = assert_scrollable_baseline(&harness, "mid-stream");
(harness, content, baseline)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn marker_transcript_shape() {
assert_eq!(marker_line(42), "MARKER-0042");
let response = marker_response(3);
assert!(response.starts_with("```\nMOCKRESPONSE\n"));
assert!(response.ends_with("MARKER-0002\n```\n"));
assert_eq!(response.matches(MARKER_PREFIX).count(), 3);
}
#[test]
fn topmost_marker_parses_top_down_and_skips_malformed_hits() {
let screen = "noise\nMARKER-12 truncated\n x MARKER-0007 y\nMARKER-0003\n";
assert_eq!(topmost_marker_in(screen), Some(7));
assert_eq!(topmost_marker_in("no markers here"), None);
}
/// The streaming defaults must dwarf the longest gesture: every step
/// table finishes (delays summed) well inside the paced tail's window,
/// or the "still streaming" guard could race the gesture.
#[test]
fn streaming_window_covers_every_gesture_table() {
use super::super::gestures::{GestureId, STREAM_GAP_MS};
let tail_ms = STREAMING_TAIL_WORDS as u64 * STREAMING_CHUNK_DELAY.as_millis() as u64;
for gesture in GestureId::ALL {
for ept in [1u16, 3] {
let span: u64 = gesture.steps(ept).iter().map(|s| s.pre_delay_ms).sum();
// Gesture + every stream's finalize gap, ×4 headroom.
let with_finalize = (span + gesture.expected_streams() as u64 * STREAM_GAP_MS) * 4;
assert!(
with_finalize < tail_ms,
"{gesture:?} (ept={ept}) span {with_finalize}ms crowds the \
{tail_ms}ms streaming window"
);
}
}
}
}