perf(judge): earlier check outputs shrink to a reminder before the next round
deploy / test (push) Successful in 5m26s
deploy / build (push) Successful in 5m32s

Measured on prod: 7 of 9 verdicts ran to the 12-check cap. Every round
resends the whole history, and each check's output is bounded at 12 KB — so
by the last round the judge was paying for ~144 KB of outputs it had already
read, on top of up to 120 KB of evidence, and it paid that on every round.
That is the quadratic term in a verdict's cost, and the reason a single
blocked phase could empty a weekly plan.

Before this round's results go in, every earlier tool result compacts to an
800-byte head plus a marker saying the rest was shown when the check ran.
The round that just ran stays whole; a result already carrying the marker is
left alone. The budget of checks is unchanged — each one is cheaper to
remember, not fewer to run.

Also: docs/NEXT-SESSION.md rewritten for the state as of today.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
This commit is contained in:
Omar Sobh
2026-09-14 08:17:20 -05:00
co-authored by Claude Opus 5
parent 483de9f88a
commit 93a386e706
2 changed files with 199 additions and 110 deletions
+76
View File
@@ -767,6 +767,9 @@ async fn judge_with_tools(
content: Value::String(evidence),
});
}
// Everything the judge has already read shrinks to a reminder before
// this round's results go in full. See `compact_earlier_results`.
compact_earlier_results(&mut messages);
messages.push(ChatMessage {
role: ChatRole::User,
parts: results,
@@ -775,6 +778,44 @@ async fn judge_with_tools(
Err("evaluator exceeded its verification budget without reaching a verdict".into())
}
/// How much of an earlier check's output stays in the history.
///
/// Enough to recognise the command and its outcome — a test summary line, a
/// grep hit, an error — not enough to re-read the whole thing, which the judge
/// already did in the round it arrived.
const KEPT_OF_EARLIER_RESULT: usize = 800;
/// Shrink every tool result from EARLIER rounds to a short head.
///
/// The judge's history is resent whole on every round, and each check's
/// output is bounded at `evaluator_tools::MAX_OUTPUT_BYTES` (12 KB). Measured
/// on prod, 7 of 9 verdicts ran to the 12-check cap, so by the last round the
/// history carried ~144 KB of outputs the judge had already read, on top of
/// up to 120 KB of evidence — and every round paid for all of it again. That
/// is the quadratic term in a verdict's cost, and it is why one blocked phase
/// could empty a weekly plan.
///
/// The round that just ran keeps its results in full; only what came before
/// is compacted, and it is compacted once — a result already carrying the
/// marker is left alone. The judge's budget of checks is unchanged: this
/// makes each check cheaper to remember, not fewer to run.
fn compact_earlier_results(messages: &mut [cm_llm::ChatMessage]) {
use cm_llm::ContentPart;
const MARKER: &str = "\n[… output elided here — it was shown in full when this check ran]";
for m in messages.iter_mut() {
for part in m.parts.iter_mut() {
if let ContentPart::ToolResult { content, .. } = part {
if let Some(text) = content.as_str() {
if text.len() > KEPT_OF_EARLIER_RESULT && !text.ends_with(MARKER) {
let kept = head(text, KEPT_OF_EARLIER_RESULT);
*content = Value::String(format!("{kept}{MARKER}"));
}
}
}
}
}
}
/// Parse the model's reply into a verdict, failing closed.
fn parse_verdict(model: &str, text: &str) -> Verdict {
let trimmed = text.trim();
@@ -925,6 +966,41 @@ pub async fn latest(
#[cfg(test)]
mod cross_provider_tests {
/// The quadratic term: earlier outputs resent whole every round.
#[test]
fn earlier_tool_results_shrink_and_the_latest_stays_whole() {
use cm_llm::{ChatMessage, ChatRole, ContentPart};
let big = "line of output\n".repeat(900); // ~13 KB
let mut messages = vec![
ChatMessage {
role: ChatRole::User,
parts: vec![ContentPart::text("judge this")],
},
ChatMessage {
role: ChatRole::User,
parts: vec![
ContentPart::ToolResult { tool_use_id: "a".into(), content: Value::String(big.clone()) },
ContentPart::ToolResult { tool_use_id: "b".into(), content: Value::String(big.clone()) },
],
},
];
compact_earlier_results(&mut messages);
for part in &messages[1].parts {
let ContentPart::ToolResult { content, .. } = part else { panic!() };
let s = content.as_str().unwrap();
assert!(s.len() < KEPT_OF_EARLIER_RESULT + 120, "not compacted: {} bytes", s.len());
assert!(s.starts_with("line of output"), "the head survives");
assert!(s.contains("elided"), "and says so");
}
// Idempotent: a second pass must not shrink the reminder further.
let once: Vec<String> = messages[1].parts.iter().map(|p| serde_json::to_string(p).unwrap()).collect();
compact_earlier_results(&mut messages);
let twice: Vec<String> = messages[1].parts.iter().map(|p| serde_json::to_string(p).unwrap()).collect();
assert_eq!(once, twice);
// Plain text parts are untouched.
assert!(matches!(&messages[0].parts[0], ContentPart::Text { text } if text == "judge this"));
}
/// `LlmEvent::Usage` arrives on every provider call. It was matched by
/// `Ok(_) => {}` and dropped, which is how two plan exhaustions happened
/// with no row anywhere saying a judge token was spent.