Skip to main content

dejavu/reduce/
mod.rs

1//! Output reduction. The `reduce()` facade normalizes, hashes, finds a
2//! comparable prior run, classifies, and builds the compact output the agent
3//! sees — without writing anything (the runtime persists logs + the record).
4
5pub mod classify;
6pub mod compare;
7pub mod envelope;
8pub mod generic;
9pub mod normalize;
10pub mod parsers;
11pub mod redact;
12
13pub use classify::Classification;
14
15use crate::config::Config;
16use crate::store::{Db, RunRecord};
17use crate::util::{estimate_tokens, sha256_hex, short_id};
18use envelope::Envelope;
19
20pub struct ReduceInput<'a> {
21    pub run_id: &'a str,
22    pub created_at: &'a str,
23    pub repo_root: &'a str,
24    pub cwd: &'a str,
25    pub shim_name: &'a str,
26    pub command_original: &'a str,
27    pub command_family: &'a str,
28    pub command_key: &'a str,
29    pub exit_code: i32,
30    pub git_head: Option<&'a str>,
31    pub git_worktree_hash: Option<&'a str>,
32    pub redacted_stdout: &'a [u8],
33    pub redacted_stderr: &'a [u8],
34}
35
36pub struct ReduceOutput {
37    pub classification: Classification,
38    pub normalized: String,
39    pub normalized_hash: String,
40    pub comparison_base_run_id: Option<String>,
41    pub comparison_result: String,
42    pub summary: Option<String>,
43    pub emit_stdout: Vec<u8>,
44    pub emit_stderr: Vec<u8>,
45    pub estimated_raw_tokens: i64,
46    pub estimated_emitted_tokens: i64,
47    pub estimated_saved_tokens: i64,
48}
49
50/// Reduce captured (already-redacted) output into the compact form + metadata.
51pub fn reduce(db: &Db, cfg: &Config, input: &ReduceInput) -> anyhow::Result<ReduceOutput> {
52    let combined_raw = combined(input.redacted_stdout, input.redacted_stderr);
53    let raw_tokens = estimate_tokens(&combined_raw) as i64;
54
55    let normalized = normalize::normalize(&combined_raw);
56    let normalized_hash = sha256_hex(normalized.as_bytes());
57
58    // Hybrid comparability (decision #3): match on repo/cwd/family/key only.
59    let prior = db.find_comparable_prior(
60        input.repo_root,
61        input.cwd,
62        input.command_family,
63        input.command_key,
64        input.created_at,
65    )?;
66    let prior_normalized = prior
67        .as_ref()
68        .and_then(|p| p.normalized_path.as_ref())
69        .and_then(|path| std::fs::read_to_string(path).ok());
70
71    let classification = classify_state(
72        cfg,
73        prior.as_ref(),
74        &normalized,
75        &normalized_hash,
76        &prior_normalized,
77    );
78
79    let emit = build_emit(
80        cfg,
81        input,
82        &normalized,
83        prior.as_ref(),
84        prior_normalized.as_deref(),
85        classification,
86        raw_tokens,
87    );
88
89    let comparison_base_run_id = match classification {
90        Classification::FirstSeen => None,
91        _ => prior.as_ref().map(|p| p.id.clone()),
92    };
93
94    Ok(ReduceOutput {
95        classification,
96        normalized,
97        normalized_hash,
98        comparison_base_run_id,
99        comparison_result: classification.as_str().to_string(),
100        summary: emit.summary,
101        emit_stdout: emit.stdout,
102        emit_stderr: emit.stderr,
103        estimated_raw_tokens: raw_tokens,
104        estimated_emitted_tokens: emit.emitted_tokens,
105        estimated_saved_tokens: emit.saved_tokens,
106    })
107}
108
109fn classify_state(
110    cfg: &Config,
111    prior: Option<&RunRecord>,
112    normalized: &str,
113    normalized_hash: &str,
114    prior_normalized: &Option<String>,
115) -> Classification {
116    let (Some(prior), Some(prior_norm)) = (prior, prior_normalized) else {
117        return Classification::FirstSeen;
118    };
119    if prior.normalized_hash.as_deref() == Some(normalized_hash) {
120        return Classification::Unchanged;
121    }
122    let stats = compare::diff_stats(prior_norm, normalized);
123    if stats.changed_lines <= cfg.small_delta_max_changed_lines
124        && stats.ratio <= cfg.small_delta_max_changed_ratio
125    {
126        Classification::SmallDelta
127    } else {
128        Classification::LargeDelta
129    }
130}
131
132struct Emit {
133    stdout: Vec<u8>,
134    stderr: Vec<u8>,
135    emitted_tokens: i64,
136    saved_tokens: i64,
137    summary: Option<String>,
138}
139
140#[allow(clippy::too_many_arguments)]
141fn build_emit(
142    cfg: &Config,
143    input: &ReduceInput,
144    normalized: &str,
145    prior: Option<&RunRecord>,
146    prior_normalized: Option<&str>,
147    classification: Classification,
148    raw_tokens: i64,
149) -> Emit {
150    use Classification::*;
151
152    // Below the reduction threshold, emit the raw output with no envelope — for
153    // ANY classification. Wrapping a tiny output in a "dejavu: unchanged since…"
154    // envelope would emit more tokens than it saves and, worse, would hand
155    // corrupted output to any program that parses it (shell prompts, IDE SCM).
156    if raw_tokens < cfg.min_raw_tokens_to_reduce as i64 {
157        return Emit {
158            emitted_tokens: raw_tokens,
159            saved_tokens: 0,
160            stdout: input.redacted_stdout.to_vec(),
161            stderr: input.redacted_stderr.to_vec(),
162            summary: generic::summarize(normalized, input.exit_code),
163        };
164    }
165
166    let prev_short = prior.map(|p| short_id(&p.id).to_string());
167    let run_short = short_id(input.run_id).to_string();
168    let max_lines = match classification {
169        SmallDelta => cfg.max_emitted_lines_small_delta,
170        LargeDelta => cfg.max_emitted_lines_large_delta,
171        _ => cfg.max_emitted_lines_first_seen,
172    };
173
174    // Try a specialized reducer; fall back to the generic one.
175    let special = parsers::dispatch(
176        input.command_family,
177        input.shim_name,
178        input.command_key,
179        normalized,
180        prior_normalized,
181        classification,
182        input.exit_code,
183        prev_short.as_deref(),
184        max_lines,
185    );
186
187    let (mut status, mut body, full_label, summary) = match special {
188        Some(sp) => (sp.status, sp.body, sp.full_label, Some(sp.summary)),
189        None => {
190            let body = match classification {
191                SmallDelta => compare::unified_diff(
192                    prior_normalized.unwrap_or(""),
193                    normalized,
194                    3,
195                    cfg.max_emitted_lines_small_delta,
196                ),
197                Unchanged => prior
198                    .and_then(|p| p.summary.clone())
199                    .unwrap_or_else(|| generic::extract(normalized, max_lines, input.exit_code)),
200                FirstSeen | LargeDelta => generic::extract(normalized, max_lines, input.exit_code),
201            };
202            let status = generic_status(classification, input.exit_code, prev_short.as_deref());
203            (
204                status,
205                body,
206                "Full output",
207                generic::summarize(normalized, input.exit_code),
208            )
209        }
210    };
211
212    // Hard ceiling on any reduced body: IDE agents capture terminal output
213    // inline only up to a limit (VS Code Copilot: 20K chars; beyond that the
214    // output spills to a file and the model sees a preview). Keeping the whole
215    // envelope under ~15K chars / 500 lines guarantees inline delivery.
216    const BODY_MAX_CHARS: usize = 14_000;
217    const BODY_MAX_LINES: usize = 500;
218    body = clamp_body(body, BODY_MAX_CHARS, BODY_MAX_LINES);
219
220    // An `unchanged` run replays the stored summary: cap it — the agent has
221    // already seen the full version, a reminder needs only the head.
222    const UNCHANGED_REPLAY_MAX_LINES: usize = 10;
223    if classification == Unchanged {
224        let n = body.lines().count();
225        if n > UNCHANGED_REPLAY_MAX_LINES {
226            let kept: Vec<&str> = body.lines().take(UNCHANGED_REPLAY_MAX_LINES).collect();
227            body = format!(
228                "{}\n... (+{} more identical lines)",
229                kept.join("\n"),
230                n - UNCHANGED_REPLAY_MAX_LINES
231            );
232        }
233    }
234
235    // Exit-code change emphasis (spec §12.6); fail -> pass overrides the status
236    // and shrinks the body to what matters: what USED to fail, plus the tail.
237    let exit_changed = prior
238        .map(|p| p.exit_code as i32 != input.exit_code)
239        .unwrap_or(false);
240    let headline = exit_changed.then(|| {
241        let p = prior.unwrap();
242        format!("Exit code changed: {} -> {}", p.exit_code, input.exit_code)
243    });
244    if let Some(p) = prior {
245        if p.exit_code != 0 && input.exit_code == 0 {
246            status = "command now passes.".to_string();
247            if let Some(prev_sum) = p.summary.as_deref() {
248                let capped: Vec<&str> = prev_sum.lines().take(8).collect();
249                if !capped.is_empty() {
250                    body = format!(
251                        "Previously failing summary:\n{}\n\n{}",
252                        capped.join("\n"),
253                        body
254                    );
255                }
256            }
257        }
258    }
259
260    let note = git_note(
261        prior,
262        input.git_head,
263        input.git_worktree_hash,
264        classification,
265    );
266
267    let body_tokens = estimate_tokens(&body) as i64;
268    let suppressed = (raw_tokens - body_tokens).max(0);
269
270    let rendered = envelope::render(&Envelope {
271        status: &status,
272        command: input.command_original,
273        exit_code: input.exit_code,
274        headline: headline.as_deref(),
275        note: note.as_deref(),
276        body: &body,
277        suppressed_tokens: suppressed,
278        run_id_short: &run_short,
279        prev_id_short: prev_short.as_deref(),
280        full_label,
281    });
282
283    let emitted_tokens = estimate_tokens(&rendered) as i64;
284    let saved_tokens = (raw_tokens - emitted_tokens).max(0);
285    Emit {
286        stdout: rendered.into_bytes(),
287        stderr: Vec::new(),
288        emitted_tokens,
289        saved_tokens,
290        summary,
291    }
292}
293
294/// Truncate a reduced body to hard line/char ceilings, on char boundaries,
295/// with an explicit marker (the full output stays one `dejavu show` away).
296fn clamp_body(body: String, max_chars: usize, max_lines: usize) -> String {
297    let over_lines = body.lines().count() > max_lines;
298    let over_chars = body.chars().count() > max_chars;
299    if !over_lines && !over_chars {
300        return body;
301    }
302    let mut kept: String = body.lines().take(max_lines).collect::<Vec<_>>().join("\n");
303    if kept.chars().count() > max_chars {
304        kept = kept.chars().take(max_chars).collect();
305    }
306    kept.push_str("\n... (output clamped)");
307    kept
308}
309
310fn generic_status(classification: Classification, exit_code: i32, prev: Option<&str>) -> String {
311    use Classification::*;
312    let prev = prev.unwrap_or("?");
313    match classification {
314        FirstSeen => {
315            if exit_code != 0 {
316                "command failed.".to_string()
317            } else {
318                "command output.".to_string()
319            }
320        }
321        Unchanged => format!("output unchanged since run {prev}."),
322        SmallDelta => format!("output changed slightly since run {prev}."),
323        LargeDelta => format!("output changed significantly since run {prev}."),
324    }
325}
326
327/// Git-state annotation (decision #3).
328fn git_note(
329    prior: Option<&RunRecord>,
330    curr_head: Option<&str>,
331    curr_worktree: Option<&str>,
332    classification: Classification,
333) -> Option<String> {
334    let prior = prior?;
335    let git_differs = prior.git_head.as_deref() != curr_head
336        || prior.git_worktree_hash.as_deref() != curr_worktree;
337    match classification {
338        Classification::SmallDelta | Classification::LargeDelta => Some(if git_differs {
339            "Note: output changed across code changes (git state differs).".to_string()
340        } else {
341            "Note: output changed with no code change — possibly flaky/nondeterministic."
342                .to_string()
343        }),
344        Classification::Unchanged if git_differs => {
345            Some("Note: output unchanged across code changes.".to_string())
346        }
347        _ => None,
348    }
349}
350
351fn combined(stdout: &[u8], stderr: &[u8]) -> String {
352    let mut s = String::from_utf8_lossy(stdout).into_owned();
353    if !stderr.is_empty() {
354        if !s.is_empty() && !s.ends_with('\n') {
355            s.push('\n');
356        }
357        s.push_str(&String::from_utf8_lossy(stderr));
358    }
359    s
360}