feat(session-stats): added session-stats followups CLI for hashline edits

- Added a `followups` CLI command in `scripts/session-stats` for hashline edit follow-up analysis.
- Added `--max-fix`, `--max-gap`, `--min-dup`, `--pattern`, and `--show` options in `cmd_followups.rs`.
- Added follow-up heuristics for small fixes, tool self-corrections, overlapping re-edits, and payload/anchor duplications.
This commit is contained in:
can1357
2026-05-09 02:34:31 +02:00
parent 0e8b797abd
commit 0319aae382
2 changed files with 974 additions and 0 deletions
+972
View File
@@ -0,0 +1,972 @@
//! `followups` subcommand — heuristic for buggy hashline edits.
//!
//! Premise: when a hashline `edit` introduces a duplicate `}`, drops a line,
//! or otherwise breaks adjacent context, the next thing the agent does is
//! usually a tiny corrective edit on the same file. So we flag, per session,
//! pairs of edits where:
//!
//! - both are hashline `edit` toolCalls (input begins with a `@PATH`),
//! - both target the same file,
//! - the FIRST edit succeeded,
//! - the SECOND edit is small (<= --max-fix lines changed, default 2),
//! - the second edit is the next edit (in the same session) on that file,
//! and there is no other edit in between (so retries-after-failure are
//! not counted).
//!
//! For each hit we classify the small follow-up's pattern: adds a single
//! closing brace/paren/bracket, removes one (likely duplicate), pure single
//! insert, pure single delete, or one-line tweak.
use crate::common::*;
use anyhow::{Context, Result, bail};
use regex::Regex;
use serde::Deserialize;
use std::collections::HashMap;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::sync::LazyLock;
// ---- parsed shapes --------------------------------------------------------
#[derive(Clone, Default)]
struct EditSection {
file: String,
payload_lines: Vec<String>,
/// Payload lines grouped by op. Each inner vec is one op's payload (`+`,
/// `<`, or `=` followed by `~TEXT` lines), in source order. Used to detect
/// intra-block self-similarity (model duplicating an N-line chunk).
payload_blocks: Vec<Vec<String>>,
/// Raw anchor refs (`123ab`) from ops in this section, in source order.
/// Used to detect the same anchor being referenced by multiple ops in the
/// same call.
op_anchors: Vec<String>,
/// Total line count covered by `- A..B` and `= A..B` ranges (range size).
deleted_lines: i64,
op_count: i64,
/// Min/max anchor line touched by ops in this section. `None` for a
/// section whose only ops target BOF/EOF (no concrete line).
min_line: Option<i64>,
max_line: Option<i64>,
}
impl EditSection {
fn change_size(&self) -> i64 {
self.payload_lines.len() as i64 + self.deleted_lines
}
fn touch(&mut self, line: i64) {
self.min_line = Some(self.min_line.map_or(line, |m| m.min(line)));
self.max_line = Some(self.max_line.map_or(line, |m| m.max(line)));
}
}
#[derive(Clone)]
struct EditCall {
ts: i64,
call_id: String,
sections: Vec<EditSection>,
success: bool,
raw_input_len: usize,
/// Tool-emitted warnings extracted from a successful result text. Each
/// entry is one of `auto-rebased` / `auto-absorbed` / `auto-dropped`.
warnings: Vec<&'static str>,
}
#[derive(Deserialize, Default)]
struct EditArgs {
#[serde(default)]
input: Option<String>,
}
// ---- per-section parsing --------------------------------------------------
// Op headers. We parse loosely: anything that doesn't match a known op or a
// `~` payload line is ignored (blank line, comment, etc.).
//
// Range sizes: `LINEhash..LINEhash` -> end_line - start_line + 1 (clamped to
// >= 1). For single-anchor `- A` we count 1.
static RANGE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\s*(\d+)[a-z*]+(?:\.\.(\d+)[a-z*]+)?\s*$").expect("RANGE_RE"));
static SINGLE_ANCHOR_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\s*(\d+)[a-z*]+\s*$").expect("SINGLE_ANCHOR_RE"));
/// Returns (range_size, optional (start_line, end_line)). Size is at least 1.
fn parse_range(raw: &str) -> (i64, Option<(i64, i64)>) {
let Some(caps) = RANGE_RE.captures(raw.trim()) else {
return (1, None);
};
let start: i64 = caps.get(1).and_then(|m| m.as_str().parse().ok()).unwrap_or(0);
let end: i64 = caps
.get(2)
.and_then(|m| m.as_str().parse().ok())
.unwrap_or(start);
let size = (end - start + 1).max(1);
let lines = if start > 0 { Some((start, end.max(start))) } else { None };
(size, lines)
}
fn parse_anchor_line(raw: &str) -> Option<i64> {
let caps = SINGLE_ANCHOR_RE.captures(raw.trim())?;
caps.get(1)?.as_str().parse().ok()
}
/// Parses a single hashline `input` arg into per-file sections. Returns an
/// empty vec if the input doesn't begin with `@PATH` (vim-mode edits etc.).
fn parse_hashline_input(input: &str) -> Vec<EditSection> {
let mut sections: Vec<EditSection> = Vec::new();
let mut cur: Option<EditSection> = None;
// Index of the currently-open payload block in cur.payload_blocks, or
// `None` if no op is awaiting payload.
let mut open_block: Option<usize> = None;
fn open_new_block(s: &mut EditSection, open_block: &mut Option<usize>) {
s.payload_blocks.push(Vec::new());
*open_block = Some(s.payload_blocks.len() - 1);
}
fn close_block(open_block: &mut Option<usize>) {
*open_block = None;
}
for raw_line in input.split('\n') {
let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
// File header: `@<path>`. Always starts a new section.
if let Some(rest) = line.strip_prefix('@') {
if let Some(s) = cur.take() {
sections.push(s);
}
cur = Some(EditSection { file: rest.trim().to_string(), ..Default::default() });
open_block = None;
continue;
}
let Some(s) = cur.as_mut() else {
// Stray content before any `@PATH` — not a hashline input.
continue;
};
// Payload lines (`~...`) belong to whatever op was last opened.
if let Some(payload) = line.strip_prefix('~') {
s.payload_lines.push(payload.to_string());
if open_block.is_none() {
open_new_block(s, &mut open_block);
}
if let Some(idx) = open_block {
s.payload_blocks[idx].push(payload.to_string());
}
continue;
}
let trimmed = line.trim_start();
let op_byte = trimmed.as_bytes().first().copied();
match op_byte {
Some(b'+') | Some(b'<') => {
let body = trimmed[1..].trim_start();
let (anchor_part, tail) = match body.split_once('~') {
Some((a, t)) => (a, Some(t)),
None => (body, None),
};
let anchor_trimmed = anchor_part.trim();
if !anchor_trimmed.is_empty() && anchor_trimmed != "BOF" && anchor_trimmed != "EOF" {
s.op_anchors.push(anchor_trimmed.to_string());
}
if let Some(line) = parse_anchor_line(anchor_part) {
s.touch(line);
}
if let Some(tail) = tail {
// Inline `+ ANCHOR~text`: replaces a single line; not a
// payload-collecting op.
s.payload_lines.push(tail.to_string());
s.deleted_lines += 1;
close_block(&mut open_block);
} else {
open_new_block(s, &mut open_block);
}
s.op_count += 1;
}
Some(b'-') | Some(b'=') => {
let body = trimmed[1..].trim_start();
let (size, lines) = parse_range(body);
s.deleted_lines += size;
if let Some((lo, hi)) = lines {
s.touch(lo);
s.touch(hi);
}
// Collect raw anchor refs (`A` or `A..B`) for dup detection.
for part in body.trim().split("..") {
let t = part.trim();
if !t.is_empty() {
s.op_anchors.push(t.to_string());
}
}
s.op_count += 1;
if op_byte == Some(b'=') {
open_new_block(s, &mut open_block);
} else {
close_block(&mut open_block);
}
}
_ => {
// blank / unrecognized — leave payload state alone.
}
}
}
if let Some(s) = cur.take() {
sections.push(s);
}
sections
}
// ---- success classification ----------------------------------------------
static RE_FAILURE_HEAD: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)^(edit rejected|error\b|failed\b|invalid\b|unrecognized\b|cannot\b|no enclosing|file has been (modified|changed)|file has not been read|permission denied|tool execution was aborted|request was aborted|cancelled|canceled|line \d+:|expected|unexpected|patch failed|no replacements|0 matches)",
)
.expect("RE_FAILURE_HEAD")
});
fn looks_successful(text: &str) -> bool {
let head = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
if head.is_empty() {
return false;
}
!RE_FAILURE_HEAD.is_match(head.trim_start())
}
fn extract_warnings(text: &str) -> Vec<&'static str> {
let mut out: Vec<&'static str> = Vec::new();
for line in text.lines() {
let t = line.trim_start();
if t.starts_with("Auto-rebased anchor") {
out.push("auto-rebased");
} else if t.starts_with("Auto-absorbed") {
out.push("auto-absorbed");
} else if t.starts_with("Auto-dropped") {
out.push("auto-dropped");
}
}
out
}
// ---- followup pattern classification -------------------------------------
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum FixPattern {
AddSingleCloser, // payload contains exactly one line that's pure }/]/)
RemoveSingleCloser, // single delete of a pure }/]/) line
PureInsertOneLine,
PureDeleteOneLine,
OneLineModify,
SmallOther,
}
impl FixPattern {
fn label(&self) -> &'static str {
match self {
FixPattern::AddSingleCloser => "add-single-closer",
FixPattern::RemoveSingleCloser => "remove-single-closer",
FixPattern::PureInsertOneLine => "pure-insert-1",
FixPattern::PureDeleteOneLine => "pure-delete-1",
FixPattern::OneLineModify => "one-line-modify",
FixPattern::SmallOther => "small-other",
}
}
}
fn pattern_priority(p: FixPattern) -> u8 {
match p {
FixPattern::RemoveSingleCloser => 0,
FixPattern::AddSingleCloser => 1,
FixPattern::OneLineModify => 2,
FixPattern::PureDeleteOneLine => 3,
FixPattern::PureInsertOneLine => 4,
FixPattern::SmallOther => 5,
}
}
static CLOSER_LINE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\s*[\])}]+[;,]?\s*$").expect("CLOSER_LINE_RE"));
fn classify_fix(section: &EditSection) -> FixPattern {
let payload = &section.payload_lines;
let deleted = section.deleted_lines;
let payload_is_one_closer = payload.len() == 1 && CLOSER_LINE_RE.is_match(&payload[0]);
if deleted == 0 && payload_is_one_closer {
return FixPattern::AddSingleCloser;
}
if deleted == 1 && payload.is_empty() {
// We don't have the deleted line text, but a single-line delete on a
// sub-1KB follow-up is overwhelmingly the "drop duplicate brace" case
// when paired with the immediately-prior big edit. Mark it so;
// false-positives are easy to triage by hand.
return FixPattern::RemoveSingleCloser;
}
if deleted == 0 && payload.len() == 1 {
return FixPattern::PureInsertOneLine;
}
if deleted == 1 && payload.len() == 1 {
return FixPattern::OneLineModify;
}
if deleted >= 1 && payload.is_empty() {
return FixPattern::PureDeleteOneLine;
}
FixPattern::SmallOther
}
// ---- per-file scanning ---------------------------------------------------
#[derive(Clone)]
struct Hit {
session: String,
file: String,
first_call_id: String,
second_call_id: String,
first_size: i64,
first_input_len: usize,
second_size: i64,
pattern: FixPattern,
second_summary: String,
gap_secs: i64,
}
/// A successful edit whose result text contained a tool self-correction.
#[derive(Clone)]
struct WarningHit {
session: String,
call_id: String,
kind: &'static str, // auto-rebased / auto-absorbed / auto-dropped
files: String, // comma-joined section files
input_len: usize,
}
/// Two consecutive successful edits on the same file whose anchor line
/// ranges overlap (or one is contained in the other). Catches "agent
/// re-edited the same locus" cases that the small-fix detector misses.
#[derive(Clone)]
struct LocusHit {
session: String,
file: String,
first_call_id: String,
second_call_id: String,
first_range: (i64, i64),
second_range: (i64, i64),
first_size: i64,
second_size: i64,
gap_secs: i64,
}
/// A single hashline edit whose payload contains a contiguous N-line sequence
/// that repeats inside the same payload block. Strong signal of "model pasted
/// the same chunk twice" when N is large.
#[derive(Clone)]
struct PayloadDupHit {
session: String,
call_id: String,
file: String,
block_len: usize,
repeat_len: usize,
sample: String,
}
/// A single hashline edit that referenced the same anchor (e.g. `123ab`) from
/// two or more distinct ops. Often benign (insert before + insert after at
/// the same line), occasionally indicates a duplicated op in the input.
#[derive(Clone)]
struct AnchorDupHit {
session: String,
call_id: String,
files: String,
anchor: String,
count: usize,
}
#[derive(Default)]
struct FileReport {
fix_hits: Vec<Hit>,
warning_hits: Vec<WarningHit>,
locus_hits: Vec<LocusHit>,
payload_dups: Vec<PayloadDupHit>,
anchor_dups: Vec<AnchorDupHit>,
/// Total number of successful hashline edits scanned.
total_successful_edits: i64,
}
/// Find the longest contiguous N-line sequence in `block` that occurs at two
/// distinct positions. Returns `(first_index, repeat_len)` if a match of at
/// least `min_len` lines exists with at least half its lines being
/// non-trivial (>= 4 chars after trim) — this filters out e.g. five repeated
/// `}` lines as boilerplate.
fn find_longest_repeat(block: &[String], min_len: usize) -> Option<(usize, usize)> {
let n = block.len();
if n < 2 * min_len {
return None;
}
let mut best: Option<(usize, usize)> = None;
for i in 0..n.saturating_sub(min_len) {
for j in (i + min_len)..=n.saturating_sub(min_len) {
let mut k = 0;
while i + k < j && j + k < n && block[i + k] == block[j + k] {
k += 1;
}
if k < min_len {
continue;
}
let meaningful = block[i..i + k]
.iter()
.filter(|s| s.trim().len() >= 4)
.count();
if meaningful < (k.div_ceil(2)).max(2) {
continue;
}
if best.is_none_or(|(_, bk)| k > bk) {
best = Some((i, k));
}
}
}
best
}
/// Returns the set of `(anchor, count, file)` pairs where the same raw
/// anchor was referenced by `count >= 2` distinct ops within ONE section
/// (i.e. on one file). Two files happening to have the same `LINE+hash`
/// anchor is a coincidence (2-char hashes collide), not a duplicate op.
/// Skips `BOF` / `EOF` and any anchor with a `*` interior-hash.
fn duplicated_anchors(sections: &[EditSection]) -> Vec<(String, usize, String)> {
let mut out: Vec<(String, usize, String)> = Vec::new();
for sec in sections {
let mut counts: HashMap<&str, usize> = HashMap::new();
for a in &sec.op_anchors {
if a == "BOF" || a == "EOF" || a.contains('*') {
continue;
}
*counts.entry(a.as_str()).or_default() += 1;
}
for (anchor, c) in counts {
if c >= 2 {
out.push((anchor.to_string(), c, sec.file.clone()));
}
}
}
out.sort_by(|a, b| b.1.cmp(&a.1));
out
}
fn process_file(path: &Path, max_fix: i64) -> FileReport {
let f = match File::open(path) {
Ok(f) => f,
Err(e) => {
eprintln!("open {}: {e}", path.display());
return FileReport::default();
}
};
let reader = BufReader::with_capacity(64 * 1024, f);
let session = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
// Walk message events in order. For each toolCall name=="edit", parse the
// input. Pair with its toolResult by callId. Keep the chronological list
// of EditCall records.
let mut pending: HashMap<String, EditCall> = HashMap::new();
let mut order: Vec<String> = Vec::new();
let mut calls: HashMap<String, EditCall> = HashMap::new();
for line in reader.lines() {
let Ok(line) = line else { continue };
if line.is_empty() {
continue;
}
let Ok(ev) = serde_json::from_str::<RawEvent>(&line) else {
continue;
};
if ev.kind != "message" {
continue;
}
let Some(msg_raw) = ev.message else { continue };
let Ok(m) = serde_json::from_str::<Message>(msg_raw.get()) else {
continue;
};
let Some(content_raw) = m.content else { continue };
let items = parse_content(&content_raw);
match m.role.as_str() {
"assistant" => {
for it in items {
if it.kind != "toolCall" || it.name != "edit" {
continue;
}
let raw = it.arguments.as_deref();
let Some(raw) = raw else { continue };
let Ok(args) = serde_json::from_str::<EditArgs>(raw.get()) else {
continue;
};
let Some(input) = args.input else { continue };
if !input.trim_start().starts_with('@') {
// Vim-mode edit or other shape — skip.
continue;
}
let sections = parse_hashline_input(&input);
if sections.is_empty() {
continue;
}
let call = EditCall {
ts: parse_ts(&ev.timestamp),
call_id: it.id.clone(),
sections,
success: false, // filled in by toolResult
raw_input_len: input.len(),
warnings: Vec::new(),
};
pending.insert(it.id.clone(), call);
}
}
"toolResult" => {
if m.tool_name != "edit" {
continue;
}
let Some(mut call) = pending.remove(&m.tool_call_id) else {
continue;
};
let text = join_text(&items);
call.success = looks_successful(&text);
if call.success {
call.warnings = extract_warnings(&text);
}
let id = call.call_id.clone();
calls.insert(id.clone(), call);
order.push(id);
}
_ => {}
}
}
// Build per-file edit sequences. A single call can touch multiple files
// (multi-section input); we record an entry per (call, section).
struct Entry<'a> {
section: &'a EditSection,
call: &'a EditCall,
}
let mut by_file: HashMap<&str, Vec<Entry>> = HashMap::new();
for id in &order {
let Some(call) = calls.get(id) else { continue };
for sec in &call.sections {
by_file
.entry(sec.file.as_str())
.or_default()
.push(Entry { section: sec, call });
}
}
let mut report = FileReport::default();
for call in calls.values() {
if call.success {
report.total_successful_edits += 1;
}
if !call.warnings.is_empty() {
// Dedup per kind so a call with two `Auto-rebased` lines counts once.
let mut seen: Vec<&'static str> = Vec::new();
for w in &call.warnings {
if !seen.contains(w) {
seen.push(*w);
}
}
let files: Vec<String> = call.sections.iter().map(|s| s.file.clone()).collect();
for kind in seen {
report.warning_hits.push(WarningHit {
session: session.clone(),
call_id: call.call_id.clone(),
kind,
files: files.join(","),
input_len: call.raw_input_len,
});
}
}
// Only flag detectors on edits that actually applied. Failed edits
// are handled separately and their input is moot.
if call.success {
// Payload self-dup: per section, per block. Threshold is
// intentionally permissive (>=4) — caller filters by repeat_len.
for sec in &call.sections {
for block in &sec.payload_blocks {
if let Some((start, len)) = find_longest_repeat(block, 4) {
let sample = truncate_line(block.get(start).map(String::as_str).unwrap_or(""), 80);
report.payload_dups.push(PayloadDupHit {
session: session.clone(),
call_id: call.call_id.clone(),
file: sec.file.clone(),
block_len: block.len(),
repeat_len: len,
sample,
});
}
}
}
// Anchor dup within a single section's ops.
for (anchor, count, file) in duplicated_anchors(&call.sections) {
report.anchor_dups.push(AnchorDupHit {
session: session.clone(),
call_id: call.call_id.clone(),
files: file,
anchor,
count,
});
}
}
}
for (file, entries) in by_file {
for window in entries.windows(2) {
let a = &window[0];
let b = &window[1];
if !a.call.success || !b.call.success {
continue;
}
if a.call.call_id == b.call.call_id {
continue;
}
let first_size = a.section.change_size();
let second_size = b.section.change_size();
let gap = (b.call.ts - a.call.ts).max(0);
// (1) Small-fix follow-up.
if second_size > 0 && second_size <= max_fix && first_size > 2 {
let pattern = classify_fix(b.section);
let summary = render_section_summary(b.section);
report.fix_hits.push(Hit {
session: session.clone(),
file: file.to_string(),
first_call_id: a.call.call_id.clone(),
second_call_id: b.call.call_id.clone(),
first_size,
first_input_len: a.call.raw_input_len,
second_size,
pattern,
second_summary: summary,
gap_secs: gap,
});
}
// (2) Same-locus re-edit. Skip when both are tiny (those are
// already noisy) and skip when the small-fix branch already
// fired (we don't want to double-count).
if first_size > 2 && second_size > max_fix {
if let (Some(a_lo), Some(a_hi), Some(b_lo), Some(b_hi)) = (
a.section.min_line,
a.section.max_line,
b.section.min_line,
b.section.max_line,
) {
let overlap_lo = a_lo.max(b_lo);
let overlap_hi = a_hi.min(b_hi);
if overlap_lo <= overlap_hi {
report.locus_hits.push(LocusHit {
session: session.clone(),
file: file.to_string(),
first_call_id: a.call.call_id.clone(),
second_call_id: b.call.call_id.clone(),
first_range: (a_lo, a_hi),
second_range: (b_lo, b_hi),
first_size,
second_size,
gap_secs: gap,
});
}
}
}
}
}
report
}
fn render_section_summary(section: &EditSection) -> String {
let mut bits: Vec<String> = Vec::new();
if section.deleted_lines > 0 {
bits.push(format!("-{}", section.deleted_lines));
}
if !section.payload_lines.is_empty() {
bits.push(format!("+{}", section.payload_lines.len()));
}
let mut out = bits.join(" / ");
if !section.payload_lines.is_empty() {
let preview = truncate_line(&section.payload_lines[0], 80);
out.push_str(&format!(" | {preview}"));
}
out
}
// ---- entry point ---------------------------------------------------------
pub fn run(args: Vec<String>) -> Result<()> {
let mut limit: usize = 200;
let mut workers: usize = 0;
let mut max_fix: i64 = 2;
let mut show: usize = 60;
let mut max_gap: i64 = 0;
let mut pattern_filter: Option<String> = None;
let mut min_dup: usize = 8;
let mut iter = args.into_iter();
while let Some(a) = iter.next() {
match a.as_str() {
"-n" => {
limit = iter
.next()
.context("-n requires a value")?
.parse()
.context("-n value")?;
}
"-j" => {
workers = iter
.next()
.context("-j requires a value")?
.parse()
.context("-j value")?;
}
"--max-fix" => {
max_fix = iter
.next()
.context("--max-fix requires a value")?
.parse()
.context("--max-fix value")?;
}
"--show" => {
show = iter
.next()
.context("--show requires a value")?
.parse()
.context("--show value")?;
}
"--max-gap" => {
max_gap = iter
.next()
.context("--max-gap requires seconds")?
.parse()
.context("--max-gap value")?;
}
"--pattern" => {
pattern_filter = Some(iter.next().context("--pattern requires a name")?);
}
"--min-dup" => {
min_dup = iter
.next()
.context("--min-dup requires a value")?
.parse()
.context("--min-dup value")?;
}
"-h" | "--help" => {
eprintln!(
"usage: session-stats followups [-n N] [-j workers] [--max-fix N] [--max-gap S]
[--min-dup K] [--pattern NAME] [--show N]
Five detectors over hashline `edit` calls in the most-recent N sessions:
1. small-fix follow-ups
consecutive successful edits on the same file where the follow-up
changes <= --max-fix lines (default 2). The first edit must be > 2
lines. Brace-related patterns surface first (remove-single-closer,
add-single-closer). --max-gap caps elapsed seconds between the pair.
2. tool self-corrections
warning lines emitted by the tool on otherwise-successful edits:
auto-rebased, auto-absorbed, auto-dropped. These are direct evidence
of the model writing stale anchors or duplicating adjacent context.
3. same-locus re-edits
two consecutive edits on the same file whose anchor line ranges
overlap, where both edits are > --max-fix lines (so they're not
already in (1)).
4. payload self-duplication
within one payload block, a contiguous K-line sequence appears at two
positions. K threshold via --min-dup (default 8).
5. same-anchor reuse
two or more ops in the same section reference the same `LINE+hash`
anchor.
--pattern NAME filters (1) to a single FixPattern label."
);
return Ok(());
}
other => bail!("unknown flag: {other}"),
}
}
let files = collect_sessions(&WalkOpts {
date_filters: Vec::new(),
limit_most_recent: limit,
})?;
eprintln!("scanning {} session files", files.len());
let max_fix_local = max_fix;
let reports: Vec<FileReport> = parallel_collect(&files, workers, 5_000, |p| {
let r = process_file(p, max_fix_local);
let empty = r.fix_hits.is_empty() && r.warning_hits.is_empty() && r.locus_hits.is_empty()
&& r.total_successful_edits == 0;
if empty { None } else { Some(r) }
});
let mut hits: Vec<Hit> = Vec::new();
let mut warning_hits: Vec<WarningHit> = Vec::new();
let mut locus_hits: Vec<LocusHit> = Vec::new();
let mut payload_dups: Vec<PayloadDupHit> = Vec::new();
let mut anchor_dups: Vec<AnchorDupHit> = Vec::new();
let mut total_successful_edits: i64 = 0;
for r in reports {
hits.extend(r.fix_hits);
warning_hits.extend(r.warning_hits);
locus_hits.extend(r.locus_hits);
payload_dups.extend(r.payload_dups);
anchor_dups.extend(r.anchor_dups);
total_successful_edits += r.total_successful_edits;
}
if max_gap > 0 {
hits.retain(|h| h.gap_secs <= max_gap);
locus_hits.retain(|h| h.gap_secs <= max_gap);
}
if let Some(ref name) = pattern_filter {
hits.retain(|h| h.pattern.label() == name);
}
hits.sort_by(|a, b| {
pattern_priority(a.pattern)
.cmp(&pattern_priority(b.pattern))
.then_with(|| b.first_input_len.cmp(&a.first_input_len))
});
locus_hits.sort_by(|a, b| b.first_size.cmp(&a.first_size));
let mut by_pattern: HashMap<&'static str, i64> = HashMap::new();
for h in &hits {
*by_pattern.entry(h.pattern.label()).or_default() += 1;
}
println!("=== heuristic followup hits ===");
println!("total hits: {}", commas(hits.len() as i64));
println!();
println!("by pattern:");
let mut pats: Vec<(&&str, &i64)> = by_pattern.iter().collect();
pats.sort_by(|a, b| b.1.cmp(a.1));
for (label, n) in pats {
println!(" {:<22} {:>6}", label, commas(*n));
}
println!();
let shown = show.min(hits.len());
println!("=== top {shown} hits (by first-edit input size) ===");
for h in hits.iter().take(shown) {
println!(
"[{pat}] {file} first={first_size}L ({first_len}B) → second={second_size}L gap={gap}s",
pat = h.pattern.label(),
file = h.file,
first_size = h.first_size,
first_len = h.first_input_len,
second_size = h.second_size,
gap = h.gap_secs,
);
println!(" session={}", h.session);
println!(" first_call={} second_call={}", h.first_call_id, h.second_call_id);
println!(" fix: {}", h.second_summary);
}
println!();
println!("=== tool self-corrections ===");
println!("(emitted as warnings on otherwise-successful edits — the tool caught what the model wrote)");
let mut warn_by_kind: HashMap<&'static str, i64> = HashMap::new();
for w in &warning_hits {
*warn_by_kind.entry(w.kind).or_default() += 1;
}
let mut warn_kinds: Vec<(&&str, &i64)> = warn_by_kind.iter().collect();
warn_kinds.sort_by(|a, b| b.1.cmp(a.1));
let pct_of = |n: i64| -> String {
if total_successful_edits == 0 {
String::new()
} else {
format!(" ({:.2}% of {} successful edits)", pct(n, total_successful_edits), commas(total_successful_edits))
}
};
for (kind, n) in warn_kinds {
println!(" {:<16} {:>6}{}", kind, commas(*n), pct_of(*n));
}
let warn_show = show.min(warning_hits.len());
if warn_show > 0 {
println!();
println!("--- top {warn_show} self-correction examples (by input size) ---");
let mut sorted_warns = warning_hits.clone();
sorted_warns.sort_by(|a, b| b.input_len.cmp(&a.input_len));
for w in sorted_warns.iter().take(warn_show) {
println!(
"[{kind}] {files} ({len}B)",
kind = w.kind,
files = truncate_line(&w.files, 80),
len = w.input_len,
);
println!(" session={} call={}", w.session, w.call_id);
}
}
println!();
println!("=== same-locus re-edits (overlapping anchor ranges, both > max-fix) ===");
println!("hits: {}", commas(locus_hits.len() as i64));
let locus_show = show.min(locus_hits.len());
for h in locus_hits.iter().take(locus_show) {
println!(
"{file} first={a0}..{a1} ({fs}L) → second={b0}..{b1} ({ss}L) gap={gap}s",
file = h.file,
a0 = h.first_range.0,
a1 = h.first_range.1,
fs = h.first_size,
b0 = h.second_range.0,
b1 = h.second_range.1,
ss = h.second_size,
gap = h.gap_secs,
);
println!(" session={}", h.session);
println!(" first_call={} second_call={}", h.first_call_id, h.second_call_id);
}
println!();
println!("=== payload self-duplication (model pasted same N-line chunk twice in one payload) ===");
payload_dups.retain(|p| p.repeat_len >= min_dup);
payload_dups.sort_by(|a, b| b.repeat_len.cmp(&a.repeat_len));
println!(
"hits with repeat_len >= {}: {} ({:.2}% of {} successful edits)",
min_dup,
commas(payload_dups.len() as i64),
pct(payload_dups.len() as i64, total_successful_edits),
commas(total_successful_edits)
);
let dup_show = show.min(payload_dups.len());
for p in payload_dups.iter().take(dup_show) {
println!(
"k={k} block={blk}L {file}",
k = p.repeat_len,
blk = p.block_len,
file = p.file,
);
println!(" session={} call={}", p.session, p.call_id);
println!(" sample: {}", p.sample);
}
println!();
println!("=== same-anchor reused by multiple ops in one input ===");
anchor_dups.sort_by(|a, b| b.count.cmp(&a.count));
println!("hits: {}", commas(anchor_dups.len() as i64));
let anchor_show = show.min(anchor_dups.len());
for a in anchor_dups.iter().take(anchor_show) {
println!(
"anchor {anchor} referenced {n}x files={files}",
anchor = a.anchor,
n = a.count,
files = truncate_line(&a.files, 80),
);
println!(" session={} call={}", a.session, a.call_id);
}
Ok(())
}
+2
View File
@@ -9,6 +9,7 @@
//! Run with no subcommand for help.
mod cmd_edits;
mod cmd_followups;
mod cmd_tools;
mod common;
@@ -42,6 +43,7 @@ fn main() -> ExitCode {
let rest: Vec<String> = args.collect();
let result = match cmd.as_str() {
"edits" => cmd_edits::run(rest),
"followups" => cmd_followups::run(rest),
"tools" => cmd_tools::run(rest),
"-h" | "--help" | "help" => {
usage();