Skip to content

Savings accounting credits rtk with the agent's own head/tail filtering — repro: 2.1M tokens claimed '100% saved' vs ~195 real (>10,000×); likely root cause of #2762 #2805

Description

@bensonwong

TL;DR — this is a hard one, please read it all the way through

rtk's savings accounting credits rtk with filtering the agent already did itself. One sandboxed command on develop (31f9d43) reproduces a >10,000× overstatement: rtk gain reports 2.1M tokens saved (100.0%) for a command whose real marginal saving is ~195 tokens.

We're raising this assuming good faith all the way down — this looks like a classic Goodhart trap (the metric was easy to record, so it became the product), not anything intentional. But the consequence is bigger than a bug: the headline numbers — the README "Token Savings (30-min Claude Code Session)" table, the -99% site demo, and every rtk gain screenshot — inherit this baseline and are not defensible in their current form. Fixing this properly means fixing the accounting and remeasuring, then updating README.md and the marketing copy on rtk-ai.app to match what rtk actually delivers. We think there is a real, honest value prop underneath (details at the bottom), but it is much smaller than currently claimed, and the current claims are costing you trust with exactly the users who measure.

This is very likely the root cause of #2762 ("~90M tokens saved (100%), mostly rtk read, 0% reproducible compression").

Reproduction (sandboxed, develop @ 31f9d43)

# 100k-line log file, 8.3 MB
seq -f "2026-07-03T10:00:00 INFO service=api request completed status=200 latency_ms=%.0f" 1 100000 > big.log
export RTK_DB_PATH="$PWD/rtk-repro.db"   # isolated tracking DB

# 1. The agent runs `head -20 big.log`. The hook rewrites it:
rtk rewrite "head -20 big.log"
# → rtk read big.log --max-lines 20   (exit 3: rewrite + ask)

# 2. What the agent would have consumed WITHOUT rtk (its own filter):
head -20 big.log | wc -c                   # 1591 bytes  ≈ 398 tokens

# 3. What rtk emits:
rtk read big.log --max-lines 20 | wc -c    # 809 bytes   ≈ 203 tokens

# 4. What rtk claims it saved:
rtk gain
Total commands:    1
Input tokens:      2.1M
Output tokens:     203
Tokens saved:      2.1M (100.0%)
Efficiency meter: ████████████████████████ 100.0%

Real marginal saving: 398 − 203 ≈ 195 tokens. Claimed: ~2,072,000. The agent was never going to read the other 99,980 lines — its own head -20 guaranteed that before rtk was involved.

Root cause

  1. The hook rewrites head -N <file>rtk read <file> --max-lines N (registry.rs rewrite_line_range).
  2. rtk read reads the entire file (read.rs#L25), applies the line window only to the output side (read.rs#L67), then tracks the full file content as the baseline (read.rs#L79-L84).
  3. The tracking record labels the counterfactual cat <file> (read.rs#L80) — a command the caller never issued. The real command (head -20 …) is not stored, so rtk gain --history cannot be audited against what actually ran. That's why rtk gain reports unreproducible savings — read/grep/tail show 0% compression #2762 is stuck at "needs-reproduction": the books don't contain the information needed to reproduce them.

Same flaw, second path: pipes

Pipes rewrite left-only — git log | head -20rtk git log | head -20 (registry.rs#L544). The wrapped rtk process can't see its downstream consumer, so it records full-raw vs full-filtered (e.g. git.rs#L201-L210) while the agent's own | head -20 bounds what reaches the model either way. Recorded savings are unbounded; real marginal savings ≈ 0.

This generalizes: coding agents rarely run raw commands. They self-limit with | head, | tail, -n 10, --stat, rg -m — and rtk's accounting books that pre-existing filtering as rtk savings whenever a rewrite fires.

The same repro also explains the drift users report

In the reproduction above, the agent asked for 20 lines. rtk delivered 10 plus a [99990 more lines] marker:

$ rtk read big.log --max-lines 20 | wc -l
10        # native head -20 → 20

The agent's mental model of the file no longer matches the file. Line offsets are wrong, edit anchors fail to match, and the model re-reads or retries — extra turns. This is the drift benchmark runners and real users keep describing: rtk filters content before (and differently than) the AI intended it to be filtered, on top of filtering the AI had already done deliberately. Each recovery turn costs a full context re-read plus output tokens, which can exceed an entire session's genuine rtk savings — and because the re-runs also pass through rtk, they get booked as additional savings. The dashboard improves as the experience degrades; the metric can't see its own failure mode.

Why this reaches the headline claims, not just rtk gain

  • The README table ("Token Savings (30-min Claude Code Session)", −80%) counts cat/read (40,000), grep/rg (16,000), and ls/tree (2,000) — 58,000 of the 118,000-token baseline — for operations Claude Code performs via its built-in Read/Grep/Glob tools, which bypass the hook entirely (per the README's own scope note). The remaining rows assume raw, unpiped commands, which is not how agents behave.
  • The -99% site demo is the all-tests-pass cargo test case, where output collapses to one line — the case where output detail matters least.
  • "Zero token overhead" for the hook doesn't count the ~5.2 KB RTK.md (~1,300 tokens) imported into every session via @RTK.md in CLAUDE.md.
  • rtk gain reports unreproducible savings — read/grep/tail show 0% compression #2762's signature (16 rtk read calls → ~90M tokens "saved (100%)") is exactly what windowed reads of large files produce under this accounting.

We'd respectfully suggest that patching the code without remeasuring and updating the README table and rtk-ai.app copy would leave the project in the same position with the next user who measures.

What survives — the honest value prop

Genuine, defensible wins exist and are worth marketing as measured:

  • Verbose failure-heavy output (test runners, build logs, git ceremony) really does compress, against an honest baseline of what the agent's own command would have emitted.
  • In this very repro, rtk's window beat native head -20 by ~49% (203 vs 398 tokens) — that's a real number, just 4 orders of magnitude smaller than the booked one.

An rtk-off vs rtk-on A/B over real sessions (ccusage-based — cc_economics already has the plumbing) would produce numbers you can defend.

Suggested remediation

  • Baseline = output of the caller's actual command: window the raw content before tracking, and record the true counterfactual (head -N <file>, not cat <file>). Minimal validated patch below — accounting only, zero change to displayed output; full suite green (2,363 passed; one pre-existing flaky dotnet_trx mtime test, fails intermittently on unmodified HEAD too).
  • Pipe rewrites: the hook knows the original command and the surviving downstream filter — mark those records non-attributable (or pass the original command through, e.g. an env var) instead of booking full-raw baselines.
  • Audit the other ~100 timer.track call sites for the same full-raw-baseline pattern.
  • Preserve literal head/tail semantics on rewrite (the agent asked for 20 lines; delivering 10 + marker is where the drift starts). Or don't rewrite explicit line-window commands at all — the agent has already stated its filtering intent.
  • Remeasure with rtk-off vs rtk-on session A/Bs and update the README savings table and rtk-ai.app claims to the measured numbers.
Validated minimal patch for the read path (accounting only)
diff --git a/src/cmds/system/read.rs b/src/cmds/system/read.rs
index 141d55a..87c76c5 100644
--- a/src/cmds/system/read.rs
+++ b/src/cmds/system/read.rs
@@ -76,15 +76,48 @@ pub fn run(
     };
     let shown = never_worse(&raw, &rtk_output);
     print!("{}", shown);
+    // Savings baseline: what the caller's own command would have emitted.
+    // A `head -N`/`tail -N` rewritten to a windowed read was never going to
+    // emit past its window; counting the whole file books savings the caller
+    // already had without rtk (#2762).
+    let baseline = line_window_baseline(&raw, max_lines, tail_lines);
     timer.track(
-        &format!("cat {}", file.display()),
+        &baseline_cmd(file, max_lines, tail_lines),
         "rtk read",
-        &raw,
+        &baseline,
         shown,
     );
     Ok(())
 }
 
+/// The native command a windowed read replaces, for honest tracking labels.
+fn baseline_cmd(file: &Path, max_lines: Option<usize>, tail_lines: Option<usize>) -> String {
+    match (tail_lines, max_lines) {
+        (Some(n), _) => format!("tail -{} {}", n, file.display()),
+        (None, Some(n)) => format!("head -{} {}", n, file.display()),
+        (None, None) => format!("cat {}", file.display()),
+    }
+}
+
+/// Literal head/tail semantics — the output the native command would produce.
+/// Mirrors `apply_line_window`'s precedence (tail wins) but without rtk's
+/// smart-truncation markers, since the baseline must not include rtk output.
+fn line_window_baseline(
+    content: &str,
+    max_lines: Option<usize>,
+    tail_lines: Option<usize>,
+) -> String {
+    match (tail_lines, max_lines) {
+        (Some(n), _) => {
+            let lines: Vec<&str> = content.lines().collect();
+            let start = lines.len().saturating_sub(n);
+            lines[start..].join("\n")
+        }
+        (None, Some(n)) => content.lines().take(n).collect::<Vec<_>>().join("\n"),
+        (None, None) => content.to_string(),
+    }
+}
+
 pub fn run_stdin(
     level: FilterLevel,
     max_lines: Option<usize>,
@@ -191,6 +224,40 @@ mod tests {
     use std::io::Write;
     use tempfile::NamedTempFile;
 
+    #[test]
+    fn test_baseline_excludes_lines_past_head_window() {
+        // 100-line file windowed to 5 lines: the baseline is the 5 lines the
+        // caller's `head -5` would have emitted, not the whole file (#2762).
+        let content: String = (1..=100).map(|i| format!("line {}\n", i)).collect();
+        let baseline = line_window_baseline(&content, Some(5), None);
+        assert_eq!(baseline.lines().count(), 5);
+        assert!(baseline.starts_with("line 1"));
+        assert!(baseline.ends_with("line 5"));
+    }
+
+    #[test]
+    fn test_baseline_excludes_lines_before_tail_window() {
+        let content: String = (1..=100).map(|i| format!("line {}\n", i)).collect();
+        let baseline = line_window_baseline(&content, None, Some(3));
+        assert_eq!(baseline.lines().count(), 3);
+        assert!(baseline.starts_with("line 98"));
+        assert!(baseline.ends_with("line 100"));
+    }
+
+    #[test]
+    fn test_baseline_unwindowed_is_full_content() {
+        let content = "a\nb\nc\n";
+        assert_eq!(line_window_baseline(content, None, None), content);
+    }
+
+    #[test]
+    fn test_baseline_cmd_reflects_original_command() {
+        let f = Path::new("app.log");
+        assert_eq!(baseline_cmd(f, Some(20), None), "head -20 app.log");
+        assert_eq!(baseline_cmd(f, None, Some(50)), "tail -50 app.log");
+        assert_eq!(baseline_cmd(f, None, None), "cat app.log");
+    }
+
     #[test]
     fn test_read_rust_file() -> Result<()> {
         let mut file = NamedTempFile::with_suffix(".rs")?;

Environment: built from develop @ 31f9d43, cargo 1.95.0, Linux (WSL2). Repro uses RTK_DB_PATH so it never touches a real tracking DB.

Happy to turn the patch into a PR if you want it, but the accounting fix is the easy part — the ask here is the remeasurement and the README/site update. We say all this as people who want a tool like rtk to exist: the honest number is worth more than the big one.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions