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:
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user