chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:34 +08:00
commit a789495a98
1551 changed files with 718128 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "jcode-tui-core"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
crossterm = "0.29"
serde = { version = "1", features = ["derive"] }
jcode-memory-types = { path = "../jcode-memory-types" }
@@ -0,0 +1,701 @@
//! Anchor-stability analysis for rendered transcript frames.
//!
//! Quantifies the most jarring kinds of visual motion in the chat transcript:
//! content that *repositions* relative to where it was anchored, content pushed
//! down by insertions above it, large blocks popping in within a single frame,
//! rows blinking out and back, and whole-screen reflows. Expected motion (user
//! scrolling, resizes, the uniform upward scroll while following the live
//! tail) is excluded so the report isolates surprises.
//!
//! The input is cheap: a per-row content hash of the messages area for each
//! rendered frame. Consecutive frames are aligned by voting on the vertical
//! offset of rows whose hash is unique in both frames (duplicate hashes, e.g.
//! blank or repeated lines, do not get to vote). The winning offset is the
//! "dominant shift"; rows that match at other offsets are *displaced* and rows
//! that vanished/appeared feed the pop/blink/reflow metrics.
use serde::Serialize;
use std::collections::{HashMap, VecDeque};
use std::time::Instant;
/// Hash value used for a visually blank row. Blank rows are ignored for
/// alignment and change accounting (they carry no anchor information).
pub const BLANK_ROW_HASH: u64 = 0;
/// Maximum jarring events retained in the report log.
const EVENT_LOG_CAP: usize = 256;
/// Appeared-block size at or above which a single-frame insertion is flagged
/// as a "big pop" (rows appearing at once feel sharp past a few lines).
const BIG_POP_ROWS: usize = 5;
/// Maximum per-frame deviation from the dominant shift that still reads as
/// smooth motion. Collapse/expand animations move nearby content a row or two
/// per frame, which the eye tracks as a slide; anything farther is a jump.
const SLIDE_TOLERANCE: i32 = 2;
/// Fraction of previously visible rows that must survive a frame for it not to
/// be considered a mass reflow.
const MASS_CHANGE_SURVIVOR_FRACTION: f64 = 0.5;
/// Minimum non-blank rows in the previous frame before mass-reflow checking is
/// meaningful.
const MASS_CHANGE_MIN_ROWS: usize = 8;
/// One captured frame of the transcript viewport.
#[derive(Debug, Clone)]
pub struct AnchorFrame {
/// Content hash per visible row, top to bottom. [`BLANK_ROW_HASH`] = blank.
pub rows: Vec<u64>,
/// Viewport width in cells (width changes imply rewrap; diffs are skipped).
pub width: u16,
/// App scroll offset (changes imply user scrolling; diffs are skipped).
pub scroll_offset: usize,
/// Whether the view is following the live tail (auto-scroll).
pub following_tail: bool,
/// Capture time.
pub at: Instant,
}
/// Classified jarring event kinds, ordered roughly by how disruptive they are.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum JarringKind {
/// Rows moved to a different position out of step with the dominant shift:
/// content reordered relative to its anchor (e.g. a block jumping from the
/// middle of the transcript back to the bottom).
Reposition,
/// The dominant shift was downward: something was inserted *above* the
/// content the user was reading, pushing everything down.
InsertionAbove,
/// A large contiguous block of rows appeared in a single frame.
BigPop,
/// Rows disappeared for one frame and came back (flicker/blink).
Blink,
/// Most of the screen changed at once without a resize or scroll.
MassReflow,
}
/// One logged jarring event.
#[derive(Debug, Clone, Serialize)]
pub struct JarringEvent {
pub kind: JarringKind,
/// Milliseconds since the recorder was created/reset.
pub at_ms: u64,
/// Rows involved (displaced rows, popped block size, blinked rows, ...).
pub rows: usize,
/// Dominant shift at the time (negative = content moved up).
pub dominant_shift: i32,
}
/// Per-frame alignment result between two consecutive frames.
#[derive(Debug, Clone, Default, Serialize)]
pub struct AnchorDiff {
/// Winning vertical offset for surviving rows (negative = moved up).
pub dominant_shift: i32,
/// Non-blank rows that matched at the dominant shift.
pub matched_rows: usize,
/// Non-blank rows that matched at a *different* offset (repositioned).
pub displaced_rows: usize,
/// Rows that moved within [`SLIDE_TOLERANCE`] of the dominant shift:
/// smooth animation motion (collapse/expand), tracked but not jarring.
pub sliding_rows: usize,
/// Rows that stayed at the same screen position while the dominant shift
/// was nonzero: viewport-pinned UI (footers, status rows). Tracked
/// separately because staying still is usually intentional, unlike
/// `displaced_rows` which truly jumped.
pub stationary_rows: usize,
/// Non-blank rows newly visible this frame.
pub appeared_rows: usize,
/// Largest contiguous block of appeared rows.
pub largest_appeared_block: usize,
/// Non-blank rows from the previous frame no longer visible.
pub removed_rows: usize,
/// Rows present two frames ago, gone last frame, back this frame.
pub blinked_rows: usize,
/// True when the diff was skipped (resize, scroll, first frame).
pub skipped: bool,
}
/// Aggregated report across all observed frames.
#[derive(Debug, Clone, Serialize)]
pub struct AnchorStabilityReport {
pub frames_observed: u64,
pub frames_compared: u64,
pub frames_skipped_scroll: u64,
pub frames_skipped_resize: u64,
/// Frames in which any row changed at all (activity level).
pub frames_with_changes: u64,
pub reposition_events: u64,
pub reposition_rows_total: u64,
/// Rows that stayed screen-pinned while content scrolled (footers, status
/// rows). Expected UI behavior; tracked to confirm they are excluded from
/// reposition events.
pub stationary_rows_total: u64,
/// Rows that slid within tolerance of the dominant shift (animations).
pub sliding_rows_total: u64,
pub insertion_above_events: u64,
pub big_pop_events: u64,
pub blink_events: u64,
pub mass_reflow_events: u64,
/// Appeared-rows-per-changed-frame statistics: how big insertions are.
pub appeared_rows_mean: f64,
pub appeared_rows_p95: usize,
pub appeared_rows_max: usize,
/// Largest single appeared block seen.
pub largest_appeared_block: usize,
/// Observation span in milliseconds.
pub span_ms: u64,
/// Jarring events per minute of observed span (0 when span is tiny).
pub jarring_events_per_minute: f64,
/// Most recent jarring events, oldest first.
pub recent_events: Vec<JarringEvent>,
}
/// Streaming recorder: feed it one [`AnchorFrame`] per rendered frame.
pub struct AnchorStabilityRecorder {
started: Instant,
prev: Option<AnchorFrame>,
/// Unique non-blank hashes of the frame before `prev` (for blink checks).
prev_prev_hashes: Option<HashMap<u64, usize>>,
frames_observed: u64,
frames_compared: u64,
frames_skipped_scroll: u64,
frames_skipped_resize: u64,
frames_with_changes: u64,
reposition_events: u64,
reposition_rows_total: u64,
stationary_rows_total: u64,
sliding_rows_total: u64,
insertion_above_events: u64,
big_pop_events: u64,
blink_events: u64,
mass_reflow_events: u64,
appeared_sizes: Vec<usize>,
largest_appeared_block: usize,
events: VecDeque<JarringEvent>,
}
impl Default for AnchorStabilityRecorder {
fn default() -> Self {
Self::new()
}
}
impl AnchorStabilityRecorder {
pub fn new() -> Self {
Self {
started: Instant::now(),
prev: None,
prev_prev_hashes: None,
frames_observed: 0,
frames_compared: 0,
frames_skipped_scroll: 0,
frames_skipped_resize: 0,
frames_with_changes: 0,
reposition_events: 0,
reposition_rows_total: 0,
stationary_rows_total: 0,
sliding_rows_total: 0,
insertion_above_events: 0,
big_pop_events: 0,
blink_events: 0,
mass_reflow_events: 0,
appeared_sizes: Vec::new(),
largest_appeared_block: 0,
events: VecDeque::new(),
}
}
/// Observe the next rendered frame. Returns the diff against the previous
/// frame when one was comparable.
pub fn observe(&mut self, frame: AnchorFrame) -> Option<AnchorDiff> {
self.frames_observed += 1;
let prev = match self.prev.take() {
Some(prev) => prev,
None => {
self.prev = Some(frame);
return None;
}
};
// Expected-motion exclusions: resizes rewrap everything; scrolls move
// everything deliberately. Track them but do not classify them.
let mut diff = AnchorDiff::default();
let comparable = if frame.width != prev.width {
self.frames_skipped_resize += 1;
false
} else if frame.scroll_offset != prev.scroll_offset {
self.frames_skipped_scroll += 1;
false
} else {
true
};
if !comparable {
diff.skipped = true;
self.prev_prev_hashes = Some(unique_nonblank_hashes(&prev.rows));
self.prev = Some(frame);
return Some(diff);
}
self.frames_compared += 1;
diff = align_frames(&prev.rows, &frame.rows);
let changed = diff.appeared_rows > 0
|| diff.removed_rows > 0
|| diff.displaced_rows > 0
|| diff.dominant_shift != 0;
if changed {
self.frames_with_changes += 1;
}
let at_ms = self.started.elapsed().as_millis() as u64;
// Blink: rows present two frames ago, missing last frame, back now.
if let Some(pp) = &self.prev_prev_hashes {
let prev_unique = unique_nonblank_hashes(&prev.rows);
let cur_unique = unique_nonblank_hashes(&frame.rows);
let blinked = cur_unique
.keys()
.filter(|h| pp.contains_key(*h) && !prev_unique.contains_key(*h))
.count();
diff.blinked_rows = blinked;
if blinked > 0 {
self.blink_events += 1;
self.push_event(JarringEvent {
kind: JarringKind::Blink,
at_ms,
rows: blinked,
dominant_shift: diff.dominant_shift,
});
}
}
if diff.displaced_rows > 0 {
self.reposition_events += 1;
self.reposition_rows_total += diff.displaced_rows as u64;
self.push_event(JarringEvent {
kind: JarringKind::Reposition,
at_ms,
rows: diff.displaced_rows,
dominant_shift: diff.dominant_shift,
});
}
self.stationary_rows_total += diff.stationary_rows as u64;
self.sliding_rows_total += diff.sliding_rows as u64;
if diff.dominant_shift > 0 && diff.matched_rows > 0 {
self.insertion_above_events += 1;
self.push_event(JarringEvent {
kind: JarringKind::InsertionAbove,
at_ms,
rows: diff.dominant_shift.unsigned_abs() as usize,
dominant_shift: diff.dominant_shift,
});
}
if diff.appeared_rows > 0 {
self.appeared_sizes.push(diff.appeared_rows);
self.largest_appeared_block =
self.largest_appeared_block.max(diff.largest_appeared_block);
if diff.largest_appeared_block >= BIG_POP_ROWS {
self.big_pop_events += 1;
self.push_event(JarringEvent {
kind: JarringKind::BigPop,
at_ms,
rows: diff.largest_appeared_block,
dominant_shift: diff.dominant_shift,
});
}
}
let prev_nonblank = prev.rows.iter().filter(|h| **h != BLANK_ROW_HASH).count();
if prev_nonblank >= MASS_CHANGE_MIN_ROWS {
let survivors = diff.matched_rows + diff.displaced_rows;
if (survivors as f64) < (prev_nonblank as f64) * MASS_CHANGE_SURVIVOR_FRACTION {
self.mass_reflow_events += 1;
self.push_event(JarringEvent {
kind: JarringKind::MassReflow,
at_ms,
rows: prev_nonblank - survivors,
dominant_shift: diff.dominant_shift,
});
}
}
self.prev_prev_hashes = Some(unique_nonblank_hashes(&prev.rows));
self.prev = Some(frame);
Some(diff)
}
fn push_event(&mut self, event: JarringEvent) {
if self.events.len() >= EVENT_LOG_CAP {
self.events.pop_front();
}
self.events.push_back(event);
}
pub fn report(&self) -> AnchorStabilityReport {
let span_ms = self.started.elapsed().as_millis() as u64;
let jarring_total = self.reposition_events
+ self.insertion_above_events
+ self.big_pop_events
+ self.blink_events
+ self.mass_reflow_events;
let per_minute = if span_ms >= 1_000 {
jarring_total as f64 / (span_ms as f64 / 60_000.0)
} else {
0.0
};
let mut sorted = self.appeared_sizes.clone();
sorted.sort_unstable();
let p95 = if sorted.is_empty() {
0
} else {
sorted[((sorted.len() as f64 - 1.0) * 0.95).round() as usize]
};
AnchorStabilityReport {
frames_observed: self.frames_observed,
frames_compared: self.frames_compared,
frames_skipped_scroll: self.frames_skipped_scroll,
frames_skipped_resize: self.frames_skipped_resize,
frames_with_changes: self.frames_with_changes,
reposition_events: self.reposition_events,
reposition_rows_total: self.reposition_rows_total,
stationary_rows_total: self.stationary_rows_total,
sliding_rows_total: self.sliding_rows_total,
insertion_above_events: self.insertion_above_events,
big_pop_events: self.big_pop_events,
blink_events: self.blink_events,
mass_reflow_events: self.mass_reflow_events,
appeared_rows_mean: if self.appeared_sizes.is_empty() {
0.0
} else {
self.appeared_sizes.iter().sum::<usize>() as f64 / self.appeared_sizes.len() as f64
},
appeared_rows_p95: p95,
appeared_rows_max: sorted.last().copied().unwrap_or(0),
largest_appeared_block: self.largest_appeared_block,
span_ms,
jarring_events_per_minute: per_minute,
recent_events: self.events.iter().cloned().collect(),
}
}
}
/// Map of hash -> row index for hashes appearing exactly once (non-blank).
fn unique_nonblank_hashes(rows: &[u64]) -> HashMap<u64, usize> {
let mut counts: HashMap<u64, (usize, usize)> = HashMap::new();
for (idx, h) in rows.iter().enumerate() {
if *h == BLANK_ROW_HASH {
continue;
}
let entry = counts.entry(*h).or_insert((0, idx));
entry.0 += 1;
}
counts
.into_iter()
.filter(|(_, (count, _))| *count == 1)
.map(|(h, (_, idx))| (h, idx))
.collect()
}
/// Align two row-hash frames: vote on the dominant vertical offset using rows
/// whose hash is unique in both frames, then classify every previous non-blank
/// row as matched (dominant offset), displaced (other offset), or removed, and
/// every new non-blank row as appeared.
fn align_frames(prev: &[u64], cur: &[u64]) -> AnchorDiff {
let prev_unique = unique_nonblank_hashes(prev);
let cur_unique = unique_nonblank_hashes(cur);
// Offset votes from rows unique in both frames.
let mut votes: HashMap<i32, usize> = HashMap::new();
for (h, prev_idx) in &prev_unique {
if let Some(cur_idx) = cur_unique.get(h) {
let offset = *cur_idx as i32 - *prev_idx as i32;
*votes.entry(offset).or_insert(0) += 1;
}
}
// Dominant offset: most votes, ties broken toward zero (no motion).
let dominant_shift = votes
.iter()
.max_by_key(|(offset, count)| (**count, std::cmp::Reverse(offset.unsigned_abs())))
.map(|(offset, _)| *offset)
.unwrap_or(0);
let mut diff = AnchorDiff {
dominant_shift,
..Default::default()
};
// Classify previous rows. Rows with duplicate hashes are checked only
// against the dominant offset (ambiguous matches must not count as
// displacement).
let mut matched_cur_rows = vec![false; cur.len()];
for (prev_idx, h) in prev.iter().enumerate() {
if *h == BLANK_ROW_HASH {
continue;
}
let dominant_target = prev_idx as i32 + dominant_shift;
let at_dominant = dominant_target >= 0
&& (dominant_target as usize) < cur.len()
&& cur[dominant_target as usize] == *h;
if at_dominant {
diff.matched_rows += 1;
matched_cur_rows[dominant_target as usize] = true;
continue;
}
// Unique-in-both rows found elsewhere are classified by how far they
// moved relative to the dominant shift; everything else (changed
// content, duplicates) counts as removed. Rows that stayed at the
// *same screen position* while everything else shifted are
// viewport-pinned UI (footers, status rows). Rows within
// SLIDE_TOLERANCE of the dominant shift are smooth animation motion.
// Only rows beyond that are true jumps (displaced).
if prev_unique.contains_key(h)
&& let Some(cur_idx) = cur_unique.get(h)
{
let offset = *cur_idx as i32 - prev_idx as i32;
if offset == 0 && dominant_shift != 0 {
diff.stationary_rows += 1;
} else if (offset - dominant_shift).abs() <= SLIDE_TOLERANCE {
diff.sliding_rows += 1;
} else {
diff.displaced_rows += 1;
}
matched_cur_rows[*cur_idx] = true;
continue;
}
diff.removed_rows += 1;
}
// Appeared rows and the largest contiguous appeared block.
let mut run = 0usize;
for (idx, h) in cur.iter().enumerate() {
if *h != BLANK_ROW_HASH && !matched_cur_rows[idx] {
diff.appeared_rows += 1;
run += 1;
diff.largest_appeared_block = diff.largest_appeared_block.max(run);
} else {
run = 0;
}
}
diff
}
#[cfg(test)]
mod tests {
use super::*;
fn frame(rows: Vec<u64>) -> AnchorFrame {
AnchorFrame {
rows,
width: 80,
scroll_offset: 0,
following_tail: true,
at: Instant::now(),
}
}
fn hashes(range: std::ops::Range<u64>) -> Vec<u64> {
range.collect()
}
#[test]
fn first_frame_yields_no_diff() {
let mut rec = AnchorStabilityRecorder::new();
assert!(rec.observe(frame(hashes(1..10))).is_none());
}
#[test]
fn identical_frames_report_no_motion() {
let mut rec = AnchorStabilityRecorder::new();
rec.observe(frame(hashes(1..10)));
let diff = rec.observe(frame(hashes(1..10))).unwrap();
assert_eq!(diff.dominant_shift, 0);
assert_eq!(diff.displaced_rows, 0);
assert_eq!(diff.appeared_rows, 0);
assert_eq!(diff.removed_rows, 0);
let report = rec.report();
assert_eq!(report.reposition_events, 0);
assert_eq!(report.frames_with_changes, 0);
}
#[test]
fn tail_append_scrolls_up_without_jarring_events() {
// Following the tail: rows shift up by 2, two new rows at the bottom.
let mut rec = AnchorStabilityRecorder::new();
rec.observe(frame(hashes(1..11))); // rows 1..=10
let diff = rec.observe(frame(hashes(3..13))).unwrap(); // rows 3..=12
assert_eq!(diff.dominant_shift, -2);
assert_eq!(diff.displaced_rows, 0);
assert_eq!(diff.appeared_rows, 2);
let report = rec.report();
assert_eq!(report.reposition_events, 0);
assert_eq!(report.insertion_above_events, 0);
}
#[test]
fn block_jumping_to_bottom_is_a_reposition() {
// Rows 1..8 stay anchored; block [100,101] moves from the middle to the
// bottom (like a reasoning block jumping below new output). Rows 4/5/6
// shift up by only 2 (within slide tolerance), but the block jumps +3.
let mut rec = AnchorStabilityRecorder::new();
rec.observe(frame(vec![1, 2, 3, 100, 101, 4, 5, 6]));
let diff = rec
.observe(frame(vec![1, 2, 3, 4, 5, 6, 100, 101]))
.unwrap();
assert_eq!(diff.dominant_shift, 0, "anchored rows must win the vote");
assert_eq!(diff.displaced_rows, 2, "100/101 jump beyond tolerance");
assert_eq!(diff.sliding_rows, 3, "4/5/6 slide up within tolerance");
let report = rec.report();
assert_eq!(report.reposition_events, 1);
}
#[test]
fn screen_pinned_footer_rows_are_stationary_not_repositioned() {
// While the transcript scrolls up (tail-follow), bottom-pinned UI rows
// (status footer, TPS line) stay at the same screen position. That is
// intentional viewport-pinned behavior and must not count as a
// reposition event.
let mut rec = AnchorStabilityRecorder::new();
// 8 transcript rows + 2 pinned footer rows (900, 901) at the bottom.
let mut rows = hashes(1..9);
rows.extend([900, 901]);
rec.observe(frame(rows));
// Transcript scrolls up by 2 (rows 3..=10 now visible); footer stays.
let mut rows = hashes(3..11);
rows.extend([900, 901]);
let diff = rec.observe(frame(rows)).unwrap();
assert_eq!(diff.dominant_shift, -2);
assert_eq!(diff.stationary_rows, 2, "footer rows are stationary");
assert_eq!(diff.displaced_rows, 0, "no true repositions");
let report = rec.report();
assert_eq!(report.reposition_events, 0);
assert_eq!(report.stationary_rows_total, 2);
}
#[test]
fn insertion_above_pushes_content_down() {
let mut rec = AnchorStabilityRecorder::new();
rec.observe(frame(hashes(1..11)));
// Three new rows appear on top; everything else pushed down.
let mut rows = vec![100, 101, 102];
rows.extend(hashes(1..8));
let diff = rec.observe(frame(rows)).unwrap();
assert_eq!(diff.dominant_shift, 3);
let report = rec.report();
assert_eq!(report.insertion_above_events, 1);
}
#[test]
fn large_single_frame_block_is_a_big_pop() {
let mut rec = AnchorStabilityRecorder::new();
rec.observe(frame(hashes(1..5)));
let mut rows = hashes(1..5);
rows.extend(hashes(100..108)); // 8 new contiguous rows at once
let diff = rec.observe(frame(rows)).unwrap();
assert_eq!(diff.largest_appeared_block, 8);
let report = rec.report();
assert_eq!(report.big_pop_events, 1);
}
#[test]
fn small_incremental_appends_are_not_big_pops() {
let mut rec = AnchorStabilityRecorder::new();
rec.observe(frame(hashes(1..5)));
let mut rows = hashes(1..5);
rows.push(100);
rows.push(101);
rec.observe(frame(rows)).unwrap();
let report = rec.report();
assert_eq!(report.big_pop_events, 0);
}
#[test]
fn blink_detects_row_disappearing_and_returning() {
let mut rec = AnchorStabilityRecorder::new();
rec.observe(frame(vec![1, 2, 3, 4]));
rec.observe(frame(vec![1, 2, 4, 0])); // row 3 vanishes
let diff = rec.observe(frame(vec![1, 2, 3, 4])).unwrap(); // row 3 returns
assert_eq!(diff.blinked_rows, 1);
let report = rec.report();
assert_eq!(report.blink_events, 1);
}
#[test]
fn scroll_change_skips_classification() {
let mut rec = AnchorStabilityRecorder::new();
rec.observe(frame(hashes(1..11)));
let mut scrolled = frame(hashes(50..60));
scrolled.scroll_offset = 20;
let diff = rec.observe(scrolled).unwrap();
assert!(diff.skipped);
let report = rec.report();
assert_eq!(report.frames_skipped_scroll, 1);
assert_eq!(report.reposition_events, 0);
assert_eq!(report.mass_reflow_events, 0);
}
#[test]
fn resize_skips_classification() {
let mut rec = AnchorStabilityRecorder::new();
rec.observe(frame(hashes(1..11)));
let mut resized = frame(hashes(100..110));
resized.width = 120;
let diff = rec.observe(resized).unwrap();
assert!(diff.skipped);
let report = rec.report();
assert_eq!(report.frames_skipped_resize, 1);
assert_eq!(report.mass_reflow_events, 0);
}
#[test]
fn mass_reflow_detected_when_most_rows_change() {
let mut rec = AnchorStabilityRecorder::new();
rec.observe(frame(hashes(1..21))); // 20 rows
let diff = rec.observe(frame(hashes(100..120))).unwrap(); // all different
assert_eq!(diff.matched_rows, 0);
let report = rec.report();
assert_eq!(report.mass_reflow_events, 1);
}
#[test]
fn duplicate_hashes_do_not_vote_or_count_as_displaced() {
// Blank-like duplicate rows (same hash) must not produce false
// reposition events.
let mut rec = AnchorStabilityRecorder::new();
rec.observe(frame(vec![7, 7, 1, 2, 7, 7]));
let diff = rec.observe(frame(vec![7, 7, 1, 2, 7, 7])).unwrap();
assert_eq!(diff.displaced_rows, 0);
assert_eq!(diff.dominant_shift, 0);
}
#[test]
fn blank_rows_are_ignored_entirely() {
let mut rec = AnchorStabilityRecorder::new();
rec.observe(frame(vec![BLANK_ROW_HASH, 1, BLANK_ROW_HASH, 2]));
let diff = rec
.observe(frame(vec![1, BLANK_ROW_HASH, 2, BLANK_ROW_HASH]))
.unwrap();
// Both rows shifted up by one (blank movement does not matter).
assert_eq!(diff.dominant_shift, -1);
assert_eq!(diff.displaced_rows, 0);
}
#[test]
fn report_counts_appeared_stats() {
let mut rec = AnchorStabilityRecorder::new();
rec.observe(frame(hashes(1..5)));
let mut rows = hashes(1..5);
rows.extend([100, 101, 102]);
rec.observe(frame(rows.clone()));
rows.extend([103]);
rec.observe(frame(rows));
let report = rec.report();
assert_eq!(report.appeared_rows_max, 3);
assert!(report.appeared_rows_mean > 0.0);
}
}
@@ -0,0 +1,51 @@
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CopySelectionPane {
Chat,
SidePane,
/// The prompt composer (input box) where the user types the next message.
Input,
}
impl CopySelectionPane {
pub fn label(self) -> &'static str {
match self {
Self::Chat => "Chat",
Self::SidePane => "Side pane",
Self::Input => "Input",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CopySelectionPoint {
pub pane: CopySelectionPane,
pub abs_line: usize,
pub column: usize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CopySelectionRange {
pub start: CopySelectionPoint,
pub end: CopySelectionPoint,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CopySelectionStatus {
pub pane: CopySelectionPane,
pub has_action: bool,
pub selected_chars: usize,
pub selected_lines: usize,
pub dragging: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pane_labels_match_ui_copy() {
assert_eq!(CopySelectionPane::Chat.label(), "Chat");
assert_eq!(CopySelectionPane::SidePane.label(), "Side pane");
assert_eq!(CopySelectionPane::Input.label(), "Input");
}
}
+336
View File
@@ -0,0 +1,336 @@
#[derive(Debug, Clone)]
pub struct GraphNode {
/// Stable node ID from memory graph (mem:*, tag:*, cluster:*)
pub id: String,
/// Human-readable display label
pub label: String,
/// Category: "fact", "preference", "correction", "tag"
pub kind: String,
/// Whether this node is a memory (vs tag/cluster)
pub is_memory: bool,
/// Whether this node is active (superseded memories are inactive)
pub is_active: bool,
/// Effective confidence score (0.0-1.0)
pub confidence: f32,
/// Number of connections (degree)
pub degree: usize,
}
#[derive(Debug, Clone)]
pub struct GraphEdge {
/// Source index into MemoryInfo::graph_nodes
pub source: usize,
/// Target index into MemoryInfo::graph_nodes
pub target: usize,
/// Edge kind (has_tag, supersedes, contradicts, ...)
pub kind: String,
}
fn truncate_chars(s: &str, max_chars: usize) -> &str {
match s.char_indices().nth(max_chars) {
Some((idx, _)) => &s[..idx],
None => s,
}
}
fn truncate_smart(s: &str, max_len: usize) -> String {
let char_len = s.chars().count();
if char_len <= max_len {
return s.to_string();
}
if max_len <= 3 {
return "...".to_string();
}
let target = max_len - 3;
let prefix = truncate_chars(s, target);
if let Some(pos) = prefix.rfind(' ') {
let before = &prefix[..pos];
let pos_chars = before.chars().count();
if pos_chars > target / 2 {
return format!("{}...", before);
}
}
format!("{}...", prefix)
}
use jcode_memory_types::{EdgeKind, MemoryGraph};
use std::collections::{HashMap, HashSet};
/// Build graph topology (nodes + edges) from a MemoryGraph for visualization.
/// Combines project and global graphs, sampling nodes if there are too many.
pub fn build_graph_topology(
project: Option<&MemoryGraph>,
global: Option<&MemoryGraph>,
) -> (Vec<GraphNode>, Vec<GraphEdge>) {
let mut nodes = Vec::new();
let mut edges = Vec::new();
let mut id_to_idx: HashMap<String, usize> = HashMap::new();
// Collect all memory nodes from both graphs.
// Sort keys for deterministic iteration order (HashMap order is random,
// which causes the graph layout to jitter on every frame redraw).
let graphs: Vec<&MemoryGraph> = [project, global].into_iter().flatten().collect();
for graph in &graphs {
collect_memory_nodes(graph, &mut nodes, &mut id_to_idx);
collect_tag_nodes(graph, &mut nodes, &mut id_to_idx);
collect_cluster_nodes(graph, &mut nodes, &mut id_to_idx);
}
collect_edges(&graphs, &id_to_idx, &mut nodes, &mut edges);
bound_topology_size(nodes, edges)
}
fn collect_memory_nodes(
graph: &MemoryGraph,
nodes: &mut Vec<GraphNode>,
id_to_idx: &mut HashMap<String, usize>,
) {
let mut memory_ids: Vec<&String> = graph.memories.keys().collect();
memory_ids.sort();
for id in memory_ids {
let entry = &graph.memories[id];
if id_to_idx.contains_key(id) {
continue;
}
let idx = nodes.len();
id_to_idx.insert(id.clone(), idx);
nodes.push(GraphNode {
id: id.clone(),
label: truncate_smart(&entry.content, 30),
kind: entry.category.to_string(),
is_memory: true,
is_active: entry.active,
confidence: entry.effective_confidence(),
degree: 0,
});
}
}
fn collect_tag_nodes(
graph: &MemoryGraph,
nodes: &mut Vec<GraphNode>,
id_to_idx: &mut HashMap<String, usize>,
) {
let mut tag_ids: Vec<&String> = graph.tags.keys().collect();
tag_ids.sort();
for id in tag_ids {
if id_to_idx.contains_key(id) {
continue;
}
let idx = nodes.len();
let label = graph
.tags
.get(id)
.map(|tag| truncate_smart(&tag.name, 22))
.unwrap_or_else(|| id.trim_start_matches("tag:").to_string());
id_to_idx.insert(id.clone(), idx);
nodes.push(GraphNode {
id: id.clone(),
label,
kind: "tag".to_string(),
is_memory: false,
is_active: true,
confidence: 1.0,
degree: 0,
});
}
}
fn collect_cluster_nodes(
graph: &MemoryGraph,
nodes: &mut Vec<GraphNode>,
id_to_idx: &mut HashMap<String, usize>,
) {
let mut cluster_ids: Vec<&String> = graph.clusters.keys().collect();
cluster_ids.sort();
for id in cluster_ids {
if id_to_idx.contains_key(id) {
continue;
}
let idx = nodes.len();
let label = graph
.clusters
.get(id)
.and_then(|cluster| cluster.name.clone())
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| id.trim_start_matches("cluster:").to_string());
id_to_idx.insert(id.clone(), idx);
nodes.push(GraphNode {
id: id.clone(),
label: truncate_smart(&label, 22),
kind: "cluster".to_string(),
is_memory: false,
is_active: true,
confidence: 1.0,
degree: 0,
});
}
}
fn collect_edges(
graphs: &[&MemoryGraph],
id_to_idx: &HashMap<String, usize>,
nodes: &mut [GraphNode],
edges: &mut Vec<GraphEdge>,
) {
let mut edge_seen: HashSet<(usize, usize, String)> = HashSet::new();
for graph in graphs {
let mut edge_src_ids: Vec<&String> = graph.edges.keys().collect();
edge_src_ids.sort();
for src_id in edge_src_ids {
let edge_list = &graph.edges[src_id];
let Some(&src_idx) = id_to_idx.get(src_id) else {
continue;
};
let mut sorted_edges = edge_list.clone();
sorted_edges.sort_by(|a, b| {
a.target
.cmp(&b.target)
.then_with(|| edge_kind_name(&a.kind).cmp(edge_kind_name(&b.kind)))
});
for edge in sorted_edges {
let Some(&tgt_idx) = id_to_idx.get(&edge.target) else {
continue;
};
if src_idx == tgt_idx {
continue;
}
let kind = edge_kind_name(&edge.kind).to_string();
if !edge_seen.insert((src_idx, tgt_idx, kind.clone())) {
continue;
}
edges.push(GraphEdge {
source: src_idx,
target: tgt_idx,
kind,
});
if src_idx < nodes.len() {
nodes[src_idx].degree += 1;
}
if tgt_idx < nodes.len() {
nodes[tgt_idx].degree += 1;
}
}
}
}
}
fn bound_topology_size(
mut nodes: Vec<GraphNode>,
edges: Vec<GraphEdge>,
) -> (Vec<GraphNode>, Vec<GraphEdge>) {
// Bound topology size for stable redraw cost while preserving enough
// neighborhood signal for contextual subgraph selection.
const MAX_NODES: usize = 96;
if nodes.len() <= MAX_NODES {
return (nodes, edges);
}
let mut indices: Vec<usize> = (0..nodes.len()).collect();
indices.sort_by(|&a, &b| {
graph_node_score(&nodes[b])
.partial_cmp(&graph_node_score(&nodes[a]))
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| b.cmp(&a))
});
let keep: HashSet<usize> = indices.into_iter().take(MAX_NODES).collect();
let mut new_nodes = Vec::new();
let mut old_to_new: HashMap<usize, usize> = HashMap::new();
for (old_idx, node) in nodes.drain(..).enumerate() {
if keep.contains(&old_idx) {
let new_idx = new_nodes.len();
old_to_new.insert(old_idx, new_idx);
new_nodes.push(node);
}
}
let new_edges = edges
.into_iter()
.filter_map(|edge| {
let source = *old_to_new.get(&edge.source)?;
let target = *old_to_new.get(&edge.target)?;
Some(GraphEdge {
source,
target,
kind: edge.kind,
})
})
.collect();
(new_nodes, new_edges)
}
fn edge_kind_name(kind: &EdgeKind) -> &'static str {
match kind {
EdgeKind::HasTag => "has_tag",
EdgeKind::InCluster => "in_cluster",
EdgeKind::RelatesTo { .. } => "relates_to",
EdgeKind::Supersedes => "supersedes",
EdgeKind::Contradicts => "contradicts",
EdgeKind::DerivedFrom => "derived_from",
}
}
pub fn graph_node_score(node: &GraphNode) -> f32 {
let memory_bias = if node.is_memory { 2.0 } else { 0.0 };
let active_bias = if node.is_active { 1.0 } else { 0.0 };
node.degree as f32 + memory_bias + active_bias + node.confidence * 2.0
}
#[cfg(test)]
mod tests {
use super::build_graph_topology;
use jcode_memory_types::{Edge, EdgeKind, MemoryCategory, MemoryEntry, MemoryGraph};
#[test]
fn build_graph_topology_deduplicates_nodes_across_project_and_global_graphs() {
let mut graph = MemoryGraph::new();
let mut entry = MemoryEntry::new(MemoryCategory::Fact, "Rust uses cargo workspaces");
entry.tags.push("rust".to_string());
let memory_id = graph.add_memory(entry);
graph
.edges
.entry(memory_id.clone())
.or_default()
.push(Edge::new("tag:rust", EdgeKind::HasTag));
let (nodes, edges) = build_graph_topology(Some(&graph), Some(&graph));
assert_eq!(nodes.len(), 2);
assert_eq!(edges.len(), 1);
}
#[test]
fn build_graph_topology_caps_large_graphs_for_stable_rendering() {
let mut graph = MemoryGraph::new();
for i in 0..120 {
graph.add_memory(MemoryEntry::new(
MemoryCategory::Fact,
format!("Fact {i}: topology remains bounded"),
));
}
let (nodes, _) = build_graph_topology(Some(&graph), None);
assert_eq!(nodes.len(), 96);
}
}
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
pub mod copy_selection;
pub mod graph_topology;
pub use copy_selection::{
CopySelectionPane, CopySelectionPoint, CopySelectionRange, CopySelectionStatus,
};
pub use graph_topology::{GraphEdge, GraphNode, build_graph_topology, graph_node_score};
pub mod anchor_stability;
pub mod keybind;
pub mod stream_buffer;
pub use anchor_stability::{
AnchorDiff, AnchorFrame, AnchorStabilityRecorder, AnchorStabilityReport, BLANK_ROW_HASH,
JarringEvent, JarringKind,
};
pub use stream_buffer::{
SeriesStats, StreamBuffer, StreamBufferMemoryProfile, StreamJitterProfile, StreamKind, StreamOp,
};
+844
View File
@@ -0,0 +1,844 @@
//! Semantic stream buffer - paces streaming text reveal at a smooth rate.
//!
//! Providers feed text deltas with wildly different cadences. OpenAI emits many
//! tiny token-level deltas (a few chars every ~10-15ms), which already looks
//! smooth. Anthropic coalesces `content_block_delta` events into larger chunks
//! that arrive in bursts with gaps (e.g. 20-40 chars every ~80-100ms). If we
//! reveal each burst the instant it arrives, the UI stair-steps: a clump of
//! text pops in, then nothing for several frames, then another clump.
//!
//! To make every provider look the same, this buffer decouples *arrival* from
//! *reveal*. Incoming content accumulates in an ordered backlog, and a
//! time-paced proportional controller drips it out: the reveal rate rises with
//! the backlog so we never fall far behind a fast model, yet a lone burst is
//! spread over several frames instead of dumped in one. The elapsed-time step
//! is clamped so an idle gap (connect latency, tool pauses) cannot bank budget
//! that would instantly dump the next burst.
//!
//! The backlog is *segment-aware*: reasoning text and normal answer text are
//! queued as ordered segments of one stream (plus zero-width
//! "close reasoning region" markers), so both kinds share the same smoothing
//! controller and reveal strictly in arrival order. Historically only answer
//! text was paced while reasoning deltas were appended raw, which made
//! reasoning pop in provider-sized clumps and forced ordering flushes that
//! defeated the answer-text pacing too.
use serde::Serialize;
use std::collections::VecDeque;
use std::time::{Duration, Instant};
/// Steady-state reveal rate (chars/sec) when the backlog is empty. This sets the
/// floor cadence and how the trailing characters of a burst drain out.
const BASE_REVEAL_CPS: f32 = 180.0;
/// Additional reveal rate per buffered character. The controller speeds up as the
/// backlog grows so we track fast models with bounded latency: at steady incoming
/// rate `R`, the backlog settles near `(R - BASE_REVEAL_CPS) / REVEAL_BACKLOG_GAIN`.
const REVEAL_BACKLOG_GAIN: f32 = 3.0;
/// Maximum elapsed time credited to a single reveal step. Without this, a long
/// idle gap before the first/next burst would bank a huge budget and dump the
/// whole burst at once, reintroducing the choppiness we are trying to remove.
const MAX_REVEAL_STEP: Duration = Duration::from_millis(50);
/// Maximum jitter-recorder events retained per series (arrivals / reveals).
const JITTER_EVENT_CAP: usize = 4096;
/// Kind of streamed content moving through the buffer.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum StreamKind {
/// Normal assistant answer text.
Text,
/// Reasoning ("thinking") text, rendered dim+italic by the app.
Reasoning,
}
/// A revealed operation, in arrival order. Callers apply these to the UI:
/// `Text` appends answer text, `Reasoning` appends reasoning text, and
/// `CloseReasoning` ends the live reasoning region (exactly after the final
/// buffered reasoning character it followed).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StreamOp {
Text(String),
Reasoning(String),
CloseReasoning,
}
/// One queued backlog entry.
#[derive(Debug)]
enum QueuedOp {
Chunk { kind: StreamKind, text: String },
CloseReasoning,
}
/// Buffer that accumulates streaming content and reveals it at a smooth, paced
/// rate, preserving the arrival order of answer text, reasoning text, and
/// reasoning-region boundaries.
pub struct StreamBuffer {
queue: VecDeque<QueuedOp>,
/// Cached total chars across queued chunks (markers cost nothing).
backlog_chars: usize,
last_reveal: Instant,
/// Fractional reveal budget carried between steps so slow rates still make
/// progress instead of rounding down to zero forever.
carry: f32,
/// Whether reasoning pushed through this buffer is still "open" (no close
/// marker queued since the last reasoning chunk).
reasoning_open: bool,
base_cps: f32,
backlog_gain: f32,
max_step: Duration,
jitter: JitterRecorder,
}
#[derive(Debug, Clone, Serialize)]
pub struct StreamBufferMemoryProfile {
pub buffered_text_bytes: usize,
pub base_reveal_cps: u32,
}
impl Default for StreamBuffer {
fn default() -> Self {
Self::new()
}
}
impl StreamBuffer {
pub fn new() -> Self {
Self {
queue: VecDeque::new(),
backlog_chars: 0,
last_reveal: Instant::now(),
carry: 0.0,
reasoning_open: false,
base_cps: BASE_REVEAL_CPS,
backlog_gain: REVEAL_BACKLOG_GAIN,
max_step: MAX_REVEAL_STEP,
jitter: JitterRecorder::default(),
}
}
/// Push answer text into the buffer, returning any paced ops ready to apply
/// now. If a reasoning region is open in the backlog and this text contains
/// non-whitespace, a `CloseReasoning` marker is queued first so the region
/// closes (in order) before the answer text reveals.
pub fn push_text(&mut self, text: &str) -> Vec<StreamOp> {
if text.is_empty() {
return self.reveal_now(Instant::now());
}
if self.reasoning_open && !text.trim().is_empty() {
self.queue.push_back(QueuedOp::CloseReasoning);
self.reasoning_open = false;
}
self.push_chunk(StreamKind::Text, text);
self.reveal_now(Instant::now())
}
/// Push reasoning text into the buffer, returning any paced ops ready to
/// apply now. Marks the reasoning region open until a close marker is queued.
pub fn push_reasoning(&mut self, text: &str) -> Vec<StreamOp> {
if text.is_empty() {
return self.reveal_now(Instant::now());
}
self.reasoning_open = true;
self.push_chunk(StreamKind::Reasoning, text);
self.reveal_now(Instant::now())
}
/// Queue a reasoning-region close marker (no-op when no reasoning is open in
/// the backlog), returning any paced ops ready to apply now. The marker
/// reveals exactly after the final buffered reasoning character.
pub fn push_close_reasoning(&mut self) -> Vec<StreamOp> {
if self.reasoning_open {
self.queue.push_back(QueuedOp::CloseReasoning);
self.reasoning_open = false;
}
self.reveal_now(Instant::now())
}
/// Force flush the entire backlog (call on message end, commit, or
/// interrupt). Returns every remaining op in order.
pub fn flush(&mut self) -> Vec<StreamOp> {
self.carry = 0.0;
self.last_reveal = Instant::now();
let ops = self.drain_ops(self.backlog_chars, true);
debug_assert!(self.queue.is_empty());
self.backlog_chars = 0;
self.reasoning_open = false;
ops
}
/// Reveal one paced frame worth of buffered content. Called from the
/// periodic redraw tick so the backlog drains smoothly even when no new
/// delta arrived this frame. Finalization paths should still call [`flush`]
/// to avoid leaving content buffered at message boundaries.
pub fn flush_smooth_frame(&mut self) -> Vec<StreamOp> {
self.reveal_now(Instant::now())
}
/// Check if the backlog is empty (no chunks and no markers).
pub fn is_empty(&self) -> bool {
self.queue.is_empty()
}
/// Clear the backlog without returning content.
pub fn clear(&mut self) {
self.queue.clear();
self.backlog_chars = 0;
self.carry = 0.0;
self.reasoning_open = false;
self.last_reveal = Instant::now();
}
pub fn debug_memory_profile(&self) -> StreamBufferMemoryProfile {
let buffered_text_bytes = self
.queue
.iter()
.map(|op| match op {
QueuedOp::Chunk { text, .. } => text.len(),
QueuedOp::CloseReasoning => 0,
})
.sum();
StreamBufferMemoryProfile {
buffered_text_bytes,
base_reveal_cps: self.base_cps as u32,
}
}
/// Arrival-vs-reveal smoothness statistics. See [`StreamJitterProfile`].
pub fn jitter_profile(&self) -> StreamJitterProfile {
self.jitter.profile()
}
/// Reset the jitter recorder (e.g. to measure one turn in isolation).
pub fn reset_jitter(&mut self) {
self.jitter = JitterRecorder::default();
}
/// Append a chunk, coalescing with the previous queue entry when it has the
/// same kind so the queue stays short under token-level feeds.
fn push_chunk(&mut self, kind: StreamKind, text: &str) {
self.backlog_chars += text.chars().count();
self.jitter.record_arrival(kind, text.chars().count());
if let Some(QueuedOp::Chunk {
kind: last_kind,
text: last_text,
}) = self.queue.back_mut()
&& *last_kind == kind
{
last_text.push_str(text);
return;
}
self.queue.push_back(QueuedOp::Chunk {
kind,
text: text.to_string(),
});
}
/// Proportional, time-paced reveal. Advances the budget by the (clamped)
/// elapsed time times a backlog-scaled rate, then drains that many chars
/// (and any zero-cost markers reached along the way).
fn reveal_now(&mut self, now: Instant) -> Vec<StreamOp> {
if self.backlog_chars == 0 {
// No chunk backlog: reset so an idle gap cannot bank reveal budget.
self.carry = 0.0;
self.last_reveal = now;
// Any queued entries are markers only; emit them immediately.
return self.drain_ops(0, true);
}
let dt = now
.saturating_duration_since(self.last_reveal)
.min(self.max_step)
.as_secs_f32();
self.last_reveal = now;
let cps = self.base_cps + self.backlog_chars as f32 * self.backlog_gain;
self.carry += dt * cps;
let mut reveal = self.carry.floor() as usize;
if reveal == 0 {
// Budget hasn't reached a whole char yet; keep accumulating. Leading
// markers (if any) still emit so region boundaries are not delayed.
return self.drain_ops(0, false);
}
reveal = reveal.min(self.backlog_chars);
self.carry -= reveal as f32;
self.drain_ops(reveal, false)
}
/// Drain up to `char_count` chunk characters from the front of the queue (on
/// UTF-8 boundaries), emitting markers whenever they reach the front. When
/// `drain_all_markers` is set, trailing markers behind the final drained
/// chunk are emitted even if the char budget is exhausted (used by flush).
fn drain_ops(&mut self, mut char_count: usize, drain_all_markers: bool) -> Vec<StreamOp> {
let mut ops: Vec<StreamOp> = Vec::new();
loop {
match self.queue.front_mut() {
None => break,
Some(QueuedOp::CloseReasoning) => {
self.queue.pop_front();
ops.push(StreamOp::CloseReasoning);
}
Some(QueuedOp::Chunk { kind, text }) => {
if char_count == 0 {
if drain_all_markers {
// flush() always passes the full backlog as budget,
// so a chunk here means budget accounting drifted.
debug_assert!(false, "flush budget must cover the backlog");
}
break;
}
let kind = *kind;
let available = text.chars().count();
let take = char_count.min(available);
let chunk = if take == available {
let QueuedOp::Chunk { text, .. } = self.queue.pop_front().expect("front")
else {
unreachable!()
};
text
} else {
let end = text
.char_indices()
.nth(take)
.map(|(idx, _)| idx)
.unwrap_or(text.len());
let chunk = text[..end].to_string();
text.replace_range(..end, "");
chunk
};
char_count -= take;
self.backlog_chars = self.backlog_chars.saturating_sub(take);
self.jitter.record_reveal(kind, take);
match kind {
StreamKind::Text => ops.push(StreamOp::Text(chunk)),
StreamKind::Reasoning => ops.push(StreamOp::Reasoning(chunk)),
}
}
}
}
ops
}
}
// ---------------------------------------------------------------------------
// Jitter metrics
// ---------------------------------------------------------------------------
/// One recorded event: when it happened and how many chars moved.
#[derive(Debug, Clone, Copy)]
struct JitterEvent {
at: Instant,
chars: usize,
kind: StreamKind,
}
/// Records arrival (provider burst) and reveal (paced UI) events so choppiness
/// can be quantified: a smooth reveal stream has low variance in chars-per-time
/// regardless of how bursty the arrivals were.
#[derive(Debug, Default)]
struct JitterRecorder {
arrivals: VecDeque<JitterEvent>,
reveals: VecDeque<JitterEvent>,
}
impl JitterRecorder {
fn record_arrival(&mut self, kind: StreamKind, chars: usize) {
Self::record(&mut self.arrivals, kind, chars);
}
fn record_reveal(&mut self, kind: StreamKind, chars: usize) {
Self::record(&mut self.reveals, kind, chars);
}
fn record(series: &mut VecDeque<JitterEvent>, kind: StreamKind, chars: usize) {
if chars == 0 {
return;
}
if series.len() >= JITTER_EVENT_CAP {
series.pop_front();
}
series.push_back(JitterEvent {
at: Instant::now(),
chars,
kind,
});
}
fn profile(&self) -> StreamJitterProfile {
StreamJitterProfile {
arrivals: SeriesStats::compute(&self.arrivals, None),
reveals: SeriesStats::compute(&self.reveals, None),
reasoning_arrivals: SeriesStats::compute(&self.arrivals, Some(StreamKind::Reasoning)),
reasoning_reveals: SeriesStats::compute(&self.reveals, Some(StreamKind::Reasoning)),
text_arrivals: SeriesStats::compute(&self.arrivals, Some(StreamKind::Text)),
text_reveals: SeriesStats::compute(&self.reveals, Some(StreamKind::Text)),
}
}
}
/// Smoothness statistics for one event series. The headline number is
/// `bucket_100ms_cv`: the coefficient of variation of chars revealed per 100ms
/// bucket over the active span. Bursty (choppy) streams have a high CV; a
/// perfectly smooth drip approaches 0. Comparing `arrivals` vs `reveals` shows
/// how much smoothing the buffer added.
#[derive(Debug, Clone, Serialize)]
pub struct SeriesStats {
pub events: usize,
pub total_chars: usize,
pub mean_chunk: f64,
pub max_chunk: usize,
pub p95_chunk: usize,
/// Coefficient of variation (stddev/mean) of per-event chunk sizes.
pub chunk_cv: f64,
pub mean_gap_ms: f64,
pub p95_gap_ms: f64,
pub max_gap_ms: f64,
/// Coefficient of variation of chars-per-100ms buckets across the span.
pub bucket_100ms_cv: f64,
pub bucket_100ms_max_chars: usize,
pub span_ms: f64,
}
impl SeriesStats {
fn compute(series: &VecDeque<JitterEvent>, kind: Option<StreamKind>) -> Self {
let events: Vec<&JitterEvent> = series
.iter()
.filter(|e| kind.is_none_or(|k| e.kind == k))
.collect();
let mut stats = SeriesStats {
events: events.len(),
total_chars: events.iter().map(|e| e.chars).sum(),
mean_chunk: 0.0,
max_chunk: events.iter().map(|e| e.chars).max().unwrap_or(0),
p95_chunk: 0,
chunk_cv: 0.0,
mean_gap_ms: 0.0,
p95_gap_ms: 0.0,
max_gap_ms: 0.0,
bucket_100ms_cv: 0.0,
bucket_100ms_max_chars: 0,
span_ms: 0.0,
};
if events.is_empty() {
return stats;
}
let chunks: Vec<f64> = events.iter().map(|e| e.chars as f64).collect();
stats.mean_chunk = mean(&chunks);
stats.p95_chunk = percentile_usize(events.iter().map(|e| e.chars), 0.95);
stats.chunk_cv = coefficient_of_variation(&chunks);
if events.len() >= 2 {
let gaps: Vec<f64> = events
.windows(2)
.map(|w| w[1].at.duration_since(w[0].at).as_secs_f64() * 1000.0)
.collect();
stats.mean_gap_ms = mean(&gaps);
stats.p95_gap_ms = percentile_f64(&gaps, 0.95);
stats.max_gap_ms = gaps.iter().copied().fold(0.0_f64, f64::max);
let start = events.first().expect("non-empty").at;
let span = events.last().expect("non-empty").at.duration_since(start);
stats.span_ms = span.as_secs_f64() * 1000.0;
let bucket_count = (span.as_millis() as usize / 100).max(1) + 1;
let mut buckets = vec![0.0_f64; bucket_count];
for e in &events {
let idx = (e.at.duration_since(start).as_millis() as usize / 100)
.min(bucket_count.saturating_sub(1));
buckets[idx] += e.chars as f64;
}
stats.bucket_100ms_cv = coefficient_of_variation(&buckets);
stats.bucket_100ms_max_chars = buckets.iter().copied().fold(0.0_f64, f64::max) as usize;
}
stats
}
}
/// Arrival-vs-reveal smoothness report. `reveals.bucket_100ms_cv` should be
/// substantially lower than `arrivals.bucket_100ms_cv` when pacing is working.
#[derive(Debug, Clone, Serialize)]
pub struct StreamJitterProfile {
pub arrivals: SeriesStats,
pub reveals: SeriesStats,
pub reasoning_arrivals: SeriesStats,
pub reasoning_reveals: SeriesStats,
pub text_arrivals: SeriesStats,
pub text_reveals: SeriesStats,
}
fn mean(values: &[f64]) -> f64 {
if values.is_empty() {
return 0.0;
}
values.iter().sum::<f64>() / values.len() as f64
}
fn coefficient_of_variation(values: &[f64]) -> f64 {
let m = mean(values);
if m == 0.0 || values.len() < 2 {
return 0.0;
}
let var = values.iter().map(|v| (v - m).powi(2)).sum::<f64>() / values.len() as f64;
var.sqrt() / m
}
fn percentile_f64(values: &[f64], p: f64) -> f64 {
if values.is_empty() {
return 0.0;
}
let mut sorted = values.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let idx = ((sorted.len() as f64 - 1.0) * p).round() as usize;
sorted[idx.min(sorted.len() - 1)]
}
fn percentile_usize(values: impl Iterator<Item = usize>, p: f64) -> usize {
let mut sorted: Vec<usize> = values.collect();
if sorted.is_empty() {
return 0;
}
sorted.sort_unstable();
let idx = ((sorted.len() as f64 - 1.0) * p).round() as usize;
sorted[idx.min(sorted.len() - 1)]
}
#[cfg(test)]
mod tests {
use super::*;
/// Sum chars of text+reasoning chunks in a batch of ops.
fn op_chars(ops: &[StreamOp]) -> usize {
ops.iter()
.map(|op| match op {
StreamOp::Text(t) | StreamOp::Reasoning(t) => t.chars().count(),
StreamOp::CloseReasoning => 0,
})
.sum()
}
/// Drain the buffer to empty using fixed-cadence redraw frames, returning the
/// per-frame reveal sizes (in chars).
fn drain_frames(buf: &mut StreamBuffer, start: Instant, frame: Duration) -> Vec<usize> {
let mut sizes = Vec::new();
let mut t = start;
let mut guard = 0;
while !buf.is_empty() {
t += frame;
let ops = buf.reveal_now(t);
let chars = op_chars(&ops);
if chars > 0 {
sizes.push(chars);
}
guard += 1;
assert!(guard < 100_000, "drain did not converge");
}
sizes
}
/// Concatenate ops into a flat (kind, text) trace for ordering assertions,
/// merging adjacent same-kind chunks.
fn flatten(ops: impl IntoIterator<Item = StreamOp>) -> Vec<(char, String)> {
let mut out: Vec<(char, String)> = Vec::new();
for op in ops {
let (tag, text) = match op {
StreamOp::Text(t) => ('t', t),
StreamOp::Reasoning(t) => ('r', t),
StreamOp::CloseReasoning => ('c', String::new()),
};
if tag != 'c'
&& let Some((last_tag, last_text)) = out.last_mut()
&& *last_tag == tag
{
last_text.push_str(&text);
continue;
}
out.push((tag, text));
}
out
}
#[test]
fn flush_drains_everything() {
let mut buf = StreamBuffer::new();
buf.push_chunk(StreamKind::Text, "remaining content");
let ops = buf.flush();
assert_eq!(ops, vec![StreamOp::Text("remaining content".to_string())]);
assert!(buf.is_empty());
}
#[test]
fn empty_push_reveals_nothing() {
let mut buf = StreamBuffer::new();
assert!(buf.push_text("").is_empty());
assert!(buf.push_reasoning("").is_empty());
assert!(buf.is_empty());
}
#[test]
fn paced_reveal_spreads_a_burst_over_multiple_frames() {
let start = Instant::now();
let mut buf = StreamBuffer::new();
buf.last_reveal = start;
buf.push_chunk(StreamKind::Text, &"a".repeat(40));
let sizes = drain_frames(&mut buf, start, Duration::from_millis(16));
let total: usize = sizes.iter().sum();
assert_eq!(total, 40);
assert!(
sizes.len() >= 3,
"a 40-char burst should reveal across multiple frames, got {sizes:?}"
);
// No single 16ms frame should dump the whole burst.
assert!(
sizes.iter().all(|&n| n < 40),
"no frame should reveal the entire burst, got {sizes:?}"
);
}
#[test]
fn reasoning_burst_is_paced_like_text() {
let start = Instant::now();
let mut buf = StreamBuffer::new();
buf.last_reveal = start;
buf.push_chunk(StreamKind::Reasoning, &"r".repeat(40));
let sizes = drain_frames(&mut buf, start, Duration::from_millis(16));
assert_eq!(sizes.iter().sum::<usize>(), 40);
assert!(
sizes.len() >= 3 && sizes.iter().all(|&n| n < 40),
"reasoning bursts must be paced, got {sizes:?}"
);
}
#[test]
fn idle_gap_does_not_dump_the_next_burst() {
let start = Instant::now();
let mut buf = StreamBuffer::new();
buf.last_reveal = start;
// Simulate a long connect/tool pause, then a burst arrives.
let arrival = start + Duration::from_secs(5);
buf.push_chunk(StreamKind::Text, &"b".repeat(30));
let first = op_chars(&buf.reveal_now(arrival));
assert!(
first < 30,
"the idle gap must not bank budget that dumps the burst, revealed {first}"
);
// The remainder still drains over subsequent frames.
let sizes = drain_frames(&mut buf, arrival, Duration::from_millis(16));
assert_eq!(first + sizes.iter().sum::<usize>(), 30);
}
#[test]
fn bursty_and_steady_feeds_reveal_at_similar_smoothness() {
// Steady (OpenAI-like): 4 chars every frame.
let start = Instant::now();
let frame = Duration::from_millis(16);
let mut steady = StreamBuffer::new();
steady.last_reveal = start;
let mut steady_sizes = Vec::new();
let mut t = start;
for _ in 0..40 {
t += frame;
steady.push_chunk(StreamKind::Text, "abcd");
let chars = op_chars(&steady.reveal_now(t));
if chars > 0 {
steady_sizes.push(chars);
}
}
steady_sizes.extend(drain_frames(&mut steady, t, frame));
// Bursty (Anthropic-like): 24 chars every 6th frame.
let mut bursty = StreamBuffer::new();
bursty.last_reveal = start;
let mut bursty_sizes = Vec::new();
let mut t = start;
for i in 0..60 {
t += frame;
if i % 6 == 0 {
bursty.push_chunk(StreamKind::Text, &"x".repeat(24));
}
let chars = op_chars(&bursty.reveal_now(t));
if chars > 0 {
bursty_sizes.push(chars);
}
}
bursty_sizes.extend(drain_frames(&mut bursty, t, frame));
let max_burst = *bursty_sizes.iter().max().unwrap();
// The whole 24-char clump must never appear in a single frame; pacing
// should break it into smaller per-frame reveals like the steady feed.
assert!(
max_burst < 24,
"bursty feed should be smoothed, max frame reveal was {max_burst} ({bursty_sizes:?})"
);
}
#[test]
fn bursty_reasoning_feed_is_smoothed() {
let start = Instant::now();
let frame = Duration::from_millis(16);
let mut buf = StreamBuffer::new();
buf.last_reveal = start;
let mut sizes = Vec::new();
let mut t = start;
for i in 0..60 {
t += frame;
if i % 6 == 0 {
buf.push_chunk(StreamKind::Reasoning, &"y".repeat(24));
}
let chars = op_chars(&buf.reveal_now(t));
if chars > 0 {
sizes.push(chars);
}
}
sizes.extend(drain_frames(&mut buf, t, frame));
let max_burst = *sizes.iter().max().unwrap();
assert!(
max_burst < 24,
"bursty reasoning should be smoothed, max frame reveal was {max_burst} ({sizes:?})"
);
}
#[test]
fn ordering_is_preserved_across_kinds_and_markers() {
let mut buf = StreamBuffer::new();
let mut ops = Vec::new();
ops.extend(buf.push_reasoning("think think"));
ops.extend(buf.push_close_reasoning());
ops.extend(buf.push_text("answer one"));
ops.extend(buf.push_reasoning("more thinking"));
ops.extend(buf.push_text("answer two"));
ops.extend(buf.flush());
let trace = flatten(ops);
assert_eq!(
trace,
vec![
('r', "think think".to_string()),
('c', String::new()),
('t', "answer one".to_string()),
('r', "more thinking".to_string()),
// push_text auto-queued the close marker for the reopened region.
('c', String::new()),
('t', "answer two".to_string()),
]
);
assert!(buf.is_empty());
}
#[test]
fn close_marker_waits_for_buffered_reasoning() {
let start = Instant::now();
let mut buf = StreamBuffer::new();
buf.last_reveal = start;
buf.push_chunk(StreamKind::Reasoning, &"z".repeat(60));
buf.reasoning_open = true;
// Marker queued behind a large backlog: nothing closes yet.
let immediate = buf.push_close_reasoning();
assert!(
!immediate.contains(&StreamOp::CloseReasoning),
"close must not jump ahead of buffered reasoning"
);
// Drain everything; the close marker must come after all reasoning chars.
let mut all = immediate;
let mut t = start;
let mut guard = 0;
while !buf.is_empty() {
t += Duration::from_millis(16);
all.extend(buf.reveal_now(t));
guard += 1;
assert!(guard < 100_000);
}
let close_idx = all
.iter()
.position(|op| matches!(op, StreamOp::CloseReasoning))
.expect("close marker must drain");
assert_eq!(close_idx, all.len() - 1);
let reasoning_chars: usize = all
.iter()
.map(|op| match op {
StreamOp::Reasoning(t) => t.chars().count(),
_ => 0,
})
.sum();
assert_eq!(reasoning_chars, 60);
}
#[test]
fn whitespace_text_does_not_close_reasoning() {
let mut buf = StreamBuffer::new();
let mut ops = Vec::new();
ops.extend(buf.push_reasoning("thinking"));
ops.extend(buf.push_text("\n"));
ops.extend(buf.push_reasoning(" still thinking"));
ops.extend(buf.flush());
assert!(
!ops.iter().any(|op| matches!(op, StreamOp::CloseReasoning)),
"whitespace-only text must not close the reasoning region: {ops:?}"
);
}
#[test]
fn marker_only_queue_emits_immediately() {
let mut buf = StreamBuffer::new();
buf.reasoning_open = true;
let ops = buf.push_close_reasoning();
assert_eq!(ops, vec![StreamOp::CloseReasoning]);
assert!(buf.is_empty());
}
#[test]
fn reveal_respects_utf8_boundaries() {
let start = Instant::now();
let mut buf = StreamBuffer::new();
buf.last_reveal = start;
buf.push_chunk(StreamKind::Text, &"é".repeat(40));
let sizes = drain_frames(&mut buf, start, Duration::from_millis(16));
assert_eq!(sizes.iter().sum::<usize>(), 40);
}
#[test]
fn small_trailing_text_eventually_drains() {
let start = Instant::now();
let mut buf = StreamBuffer::new();
buf.last_reveal = start;
buf.push_chunk(StreamKind::Text, "hi");
let sizes = drain_frames(&mut buf, start, Duration::from_millis(16));
assert_eq!(sizes.iter().sum::<usize>(), 2);
}
#[test]
fn jitter_profile_shows_reveals_smoother_than_arrivals() {
let start = Instant::now();
let frame = Duration::from_millis(16);
let mut buf = StreamBuffer::new();
buf.last_reveal = start;
let mut t = start;
for i in 0..120 {
t += frame;
if i % 6 == 0 {
buf.push_chunk(StreamKind::Reasoning, &"j".repeat(24));
}
buf.reveal_now(t);
}
drain_frames(&mut buf, t, frame);
let profile = buf.jitter_profile();
assert!(profile.reasoning_arrivals.events > 0);
assert!(profile.reasoning_reveals.events > 0);
assert_eq!(
profile.reasoning_arrivals.total_chars,
profile.reasoning_reveals.total_chars
);
// Reveals must be meaningfully smoother (per-event max far below the
// arrival burst size).
assert!(
profile.reasoning_reveals.max_chunk < profile.reasoning_arrivals.max_chunk,
"reveal max chunk {} should be below arrival burst {}",
profile.reasoning_reveals.max_chunk,
profile.reasoning_arrivals.max_chunk
);
}
}