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
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:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user