Skip to main content

kopitiam_ai/
preprocess.rs

1//! High-volume, low-judgment preprocessing routed to the **local** model, so
2//! the cloud model never sees the raw volume (token-max card **II-6**;
3//! `kopitiam_token_max.md` §0.5: *local model absorbs volume, cloud model
4//! spends judgment*).
5//!
6//! Everything here takes an injected [`ModelAdapter`] and calls it directly —
7//! the caller is expected to hand in the **local** adapter it already resolved
8//! (in the CLI, `select_adapter().adapter()`), so the work costs **zero cloud
9//! tokens** by construction. Because the surface is `&dyn ModelAdapter`, tests
10//! drive it with the deterministic [`crate::EchoAdapter`] — no weights, no
11//! network, no model download.
12//!
13//! # Be honest about capability (this is the card's central requirement)
14//!
15//! The default local model is SmolLM2-360M-Instruct
16//! (`kopitiam_models::DEFAULT_MODEL_ID`; the CLI's `select_adapter` resolves
17//! it). **A ~360M model cannot be trusted with judgment.** These helpers are for
18//! *filtering* and *compression* only — situations where a wrong call is
19//! **recoverable**: a summary is re-derivable from the untouched source, and a
20//! false negative in triage only means the cloud model reads one extra snippet.
21//! None of them is ever the **final authority on correctness** (§321: *always
22//! report what it dropped; never the final authority*). Two mechanisms enforce
23//! that contract:
24//!
25//! 1. **Every result carries a [`DropReport`]** recording what was removed,
26//!    filtered, or set aside — verbatim where it can be enumerated, so the
27//!    caller can always recover it. [`DropReport::AUTHORITATIVE`] is a
28//!    compile-time `false`: preprocessing output is a filter, not a verdict.
29//! 2. **The echo stub is detected and loudly annotated.** When the injected
30//!    adapter is [`crate::EchoAdapter`] (no `.gguf` on disk), there is *no real
31//!    preprocessing* — the output is a pass-through of the input. Rather than
32//!    pretend it filtered anything, each helper stamps [`ECHO_PASSTHROUGH_NOTE`]
33//!    into the report so a caller can `refuse`/annotate instead of trusting a
34//!    stubbed result (the §281 caveat, enforced at the library layer via
35//!    [`ModelAdapter::name`] rather than the CLI's `is_local`).
36//!
37//! # Measuring the cloud-token saving
38//!
39//! The saving is, by construction, *everything routed through here*: the raw
40//! input volume is sent to the **local** adapter and never to the cloud; only
41//! the compact [`Preprocessed::output`] is handed onward. So:
42//!
43//! ```text
44//! cloud tokens saved  ≈  tokens(raw input routed to local)
45//!                        − tokens(Preprocessed.output handed to the cloud)
46//! ```
47//!
48//! Each [`DropReport`] exposes [`DropReport::input_units`] and
49//! [`DropReport::kept_units`] as a units-level proxy (lines / candidates /
50//! diagnostics in vs. out); pair those with the token-accounting command
51//! (token-max card **II-7**, `tokens <path>`) run over the input text and the
52//! `output` to turn the proxy into a token figure. The unit tests assert the
53//! reduction directly (e.g. summarize takes 5 lines to 2 and reports the 3 it
54//! dropped), which is the deterministic, network-free measurement the card asks
55//! each Part II task to state for itself.
56
57use anyhow::Result;
58use serde::{Deserialize, Serialize};
59
60use crate::{CompletionRequest, Message, ModelAdapter};
61
62/// The name [`crate::EchoAdapter::name`] reports. Used to detect the "no real
63/// model" pass-through case so results can be annotated honestly.
64const ECHO_ADAPTER_NAME: &str = "echo";
65
66/// Stamped into a [`DropReport`] whenever preprocessing ran against the echo
67/// stub instead of a real local model: the "output" is the input echoed back,
68/// **not** a filtered or compressed result, and must not be trusted as one.
69pub const ECHO_PASSTHROUGH_NOTE: &str = "adapter is the echo stub (no local .gguf) — output is a \
70     pass-through of the input, NOT model preprocessing; do not trust it as filtered/compressed.";
71
72/// A preprocessing result: the compact `output`, plus the [`DropReport`] of
73/// what producing it removed or set aside.
74///
75/// Generic over the output shape (a `String` summary, a `Vec<String>` kept
76/// subset, [`DiagnosticBuckets`], ...). Serializable so a machine consumer gets
77/// structure, not prose (§0.2).
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct Preprocessed<T> {
80    /// The compact result to hand onward (e.g. to the cloud model).
81    pub output: T,
82    /// What was dropped/filtered to produce `output`. Never omit this when
83    /// forwarding a result — it is the recoverability guarantee.
84    pub report: DropReport,
85}
86
87impl<T> Preprocessed<T> {
88    fn new(output: T, report: DropReport) -> Self {
89        Self { output, report }
90    }
91}
92
93/// A record of what a preprocessing step removed, filtered, or could not judge.
94///
95/// The honesty contract of this module (§321): a result is never handed on
96/// without one of these. `dropped` holds the removed units **verbatim** wherever
97/// they can be enumerated (exact duplicates, hard-truncated overflow, the
98/// filtered-out candidates), so a false negative is always recoverable.
99#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
100pub struct DropReport {
101    /// Which helper produced this (`"summarize"`, `"triage"`,
102    /// `"classify_diagnostics"`, `"draft_commit_message"`).
103    pub step: String,
104    /// Count of input units seen (source lines / candidate snippets / distinct
105    /// diagnostics), before this step reduced them.
106    pub input_units: usize,
107    /// Count of units represented in the result after the step.
108    pub kept_units: usize,
109    /// The dropped/filtered units, verbatim and in input order, so the caller
110    /// can recover any the model wrongly discarded. Empty when nothing was
111    /// enumerably dropped (e.g. lossy compression that can't be itemized — see
112    /// `notes` for that case).
113    pub dropped: Vec<String>,
114    /// Honest caveats: the lossiness of the step, whether the model's output
115    /// was trusted or a deterministic fallback was used, and (crucially) the
116    /// [`ECHO_PASSTHROUGH_NOTE`] when there was no real model.
117    pub notes: Vec<String>,
118}
119
120impl DropReport {
121    /// Preprocessing in this module is **never the final authority on
122    /// correctness** (§321). A compile-time constant so callers can encode "do
123    /// not gate on this as a verdict" in their own logic and tests.
124    pub const AUTHORITATIVE: bool = false;
125
126    fn new(step: &'static str, input_units: usize) -> Self {
127        Self {
128            step: step.to_string(),
129            input_units,
130            kept_units: 0,
131            dropped: Vec::new(),
132            notes: Vec::new(),
133        }
134    }
135
136    /// Number of units this step dropped (length of [`Self::dropped`]).
137    #[must_use]
138    pub fn dropped_count(&self) -> usize {
139        self.dropped.len()
140    }
141
142    fn note(&mut self, note: impl Into<String>) {
143        self.notes.push(note.into());
144    }
145}
146
147/// `true` when the injected adapter is the deterministic echo stub, i.e. there
148/// is no real local model and any "preprocessing" is a pass-through.
149fn is_echo(adapter: &dyn ModelAdapter) -> bool {
150    adapter.name() == ECHO_ADAPTER_NAME
151}
152
153/// Route one `(system, user)` exchange through the adapter and return the
154/// reply's content. This is the single point volume is handed to the *local*
155/// model — the caller having passed in the local adapter is what makes it cost
156/// zero cloud tokens.
157fn route(adapter: &dyn ModelAdapter, system: &str, user: &str) -> Result<String> {
158    let request =
159        CompletionRequest::new([Message::system(system), Message::user(user)]);
160    Ok(adapter.complete(&request)?.content)
161}
162
163/// Compress `text` to at most `target_lines` lines by routing it through the
164/// local model, then hard-capping the reply at `target_lines`.
165///
166/// **Compression, not judgment.** The full `text` remains available to the
167/// caller; the summary is a lossy, non-authoritative view of it. The model does
168/// the semantic compression (which cannot be enumerated line-by-line, hence the
169/// lossiness note); this function additionally guarantees the line budget by
170/// truncating any overflow, and those overflow lines *are* enumerated in
171/// [`DropReport::dropped`] so nothing vanishes silently.
172///
173/// With the echo stub the reply is `text` verbatim, so you get the first
174/// `target_lines` lines of the source and a report listing the rest — plus
175/// [`ECHO_PASSTHROUGH_NOTE`], because no real compression happened.
176pub fn summarize(
177    adapter: &dyn ModelAdapter,
178    text: &str,
179    target_lines: usize,
180) -> Result<Preprocessed<String>> {
181    let source_lines = text.lines().count();
182    let mut report = DropReport::new("summarize", source_lines);
183
184    let raw = route(
185        adapter,
186        &format!(
187            "Compress the text the user sends to at most {target_lines} lines. Keep the key \
188             facts and drop filler. Output only the compressed lines, nothing else."
189        ),
190        text,
191    )?;
192
193    let reply_lines: Vec<&str> = raw.lines().collect();
194    let kept: Vec<&str> = reply_lines.iter().take(target_lines).copied().collect();
195    let overflow: Vec<String> =
196        reply_lines.iter().skip(target_lines).map(|l| (*l).to_string()).collect();
197
198    report.kept_units = kept.len();
199    report.dropped = overflow;
200    report.note(
201        "summary is a lossy, non-authoritative compression — the source text is unchanged and \
202         re-derivable; do not treat the summary as the source of truth.",
203    );
204    if report.dropped_count() > 0 {
205        report.note(format!(
206            "hard-capped at {target_lines} lines; {} overflow line(s) dropped (listed verbatim, \
207             recoverable).",
208            report.dropped_count()
209        ));
210    }
211    if is_echo(adapter) {
212        report.note(ECHO_PASSTHROUGH_NOTE);
213    }
214
215    Ok(Preprocessed::new(kept.join("\n"), report))
216}
217
218/// Filter `candidates` (e.g. grep-hit snippets) down to the subset plausibly
219/// relevant to `query`, by asking the local model which to keep.
220///
221/// **Filtering where a false negative is recoverable**, never a correctness
222/// verdict. The model is asked to keep anything it is unsure about, and this
223/// function is deliberately **conservative**: if the reply names no valid
224/// candidate (unparseable, or the echo stub, or a model that produced garbage),
225/// it keeps **all** candidates rather than silently dropping any — a false
226/// *positive* only costs the cloud model one extra snippet to read, whereas a
227/// false *negative* could hide the one hit that mattered. Every dropped snippet
228/// is listed verbatim in [`DropReport::dropped`].
229///
230/// The reply is parsed for candidate indices (`0`-based, as presented). Indices
231/// outside range are ignored; the kept set is the parsed indices intersected
232/// with the candidate range. With the echo stub the numbered listing is echoed
233/// back, so every index parses and all candidates are kept (the safe default),
234/// annotated with [`ECHO_PASSTHROUGH_NOTE`].
235pub fn triage(
236    adapter: &dyn ModelAdapter,
237    query: &str,
238    candidates: &[String],
239) -> Result<Preprocessed<Vec<String>>> {
240    let mut report = DropReport::new("triage", candidates.len());
241
242    if candidates.is_empty() {
243        report.note("no candidates to triage.");
244        return Ok(Preprocessed::new(Vec::new(), report));
245    }
246
247    let mut listing = format!("QUERY: {query}\n\nCANDIDATES:\n");
248    for (i, c) in candidates.iter().enumerate() {
249        listing.push_str(&format!("{i}. {c}\n"));
250    }
251
252    let raw = route(
253        adapter,
254        "You are filtering search hits. Reply with only the numbers of the candidates that could \
255         plausibly relate to the QUERY, comma-separated. When unsure, keep it. Reply nothing else.",
256        &listing,
257    )?;
258
259    let mut selected = parse_indices(&raw, candidates.len());
260    let failsafe = selected.is_empty();
261    if failsafe {
262        // Conservative: the model gave us nothing usable, so keep everything
263        // rather than drop blindly. A recoverable over-keep beats a lossy
264        // under-keep.
265        selected = (0..candidates.len()).collect();
266        report.note(
267            "model reply named no valid candidate — kept ALL conservatively (a false negative is \
268             not recoverable here, so we never drop on an unusable reply).",
269        );
270    }
271
272    let mut kept = Vec::new();
273    for (i, c) in candidates.iter().enumerate() {
274        if selected.contains(&i) {
275            kept.push(c.clone());
276        } else {
277            report.dropped.push(c.clone());
278        }
279    }
280    report.kept_units = kept.len();
281    if report.dropped_count() > 0 {
282        report.note(format!(
283            "filtered out {} of {} candidate(s) as implausible (listed verbatim, recoverable — \
284             re-check if a false negative is suspected).",
285            report.dropped_count(),
286            candidates.len()
287        ));
288    }
289    if is_echo(adapter) && !failsafe {
290        report.note(ECHO_PASSTHROUGH_NOTE);
291    }
292
293    Ok(Preprocessed::new(kept, report))
294}
295
296/// One severity bucket of diagnostics: a `label` and the distinct diagnostics
297/// that fell under it.
298#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
299pub struct Bucket {
300    /// The severity label (`"error"`, `"warning"`, `"note"`, `"help"`,
301    /// `"other"`).
302    pub label: String,
303    /// The distinct diagnostics in this bucket, in input order.
304    pub items: Vec<String>,
305}
306
307/// The result of [`classify_diagnostics`]: severity buckets over the
308/// **deduplicated** diagnostics.
309#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
310pub struct DiagnosticBuckets {
311    /// Buckets in a stable severity order, each non-empty.
312    pub buckets: Vec<Bucket>,
313}
314
315/// Bucket raw compiler-diagnostic text by severity, after removing exact
316/// duplicates.
317///
318/// **Deterministic structure + local-model gloss.** The two honest facts here
319/// are that (a) the biggest real reduction is *deduplication* — one bad type
320/// can emit the same diagnostic dozens of times — and (b) severity is
321/// **deterministically parseable** from rustc's prefixes, so per §0.3
322/// (deterministic tool over model call) this function does **not** let a 0.5B
323/// model decide the bucketing. It removes exact-duplicate diagnostics (each
324/// removed copy listed verbatim in [`DropReport::dropped`]) and buckets the
325/// distinct remainder by prefix.
326///
327/// The local model is still *routed the deduplicated volume* — that is the
328/// token-max point, the raw diagnostics never reach the cloud — and its reply is
329/// recorded as a **non-authoritative gloss** in [`DropReport::notes`], never
330/// used to place a diagnostic. With the echo stub the gloss is the input echoed
331/// back and [`ECHO_PASSTHROUGH_NOTE`] is added.
332pub fn classify_diagnostics(
333    adapter: &dyn ModelAdapter,
334    raw: &str,
335) -> Result<Preprocessed<DiagnosticBuckets>> {
336    let blocks = split_diagnostics(raw);
337    let mut report = DropReport::new("classify_diagnostics", blocks.len());
338
339    // Deterministic dedup — the real reduction. Removed copies are recoverable
340    // (they are byte-identical to a kept one) but still reported.
341    let mut distinct: Vec<String> = Vec::new();
342    for b in blocks {
343        if distinct.contains(&b) {
344            report.dropped.push(b);
345        } else {
346            distinct.push(b);
347        }
348    }
349    report.kept_units = distinct.len();
350    if report.dropped_count() > 0 {
351        report.note(format!(
352            "removed {} exact-duplicate diagnostic(s) (recoverable; identical to a kept entry).",
353            report.dropped_count()
354        ));
355    }
356
357    // Route the deduplicated volume to the local model (zero cloud tokens) for a
358    // human-facing gloss only — the buckets themselves stay deterministic.
359    if !distinct.is_empty() {
360        let gloss = route(
361            adapter,
362            "Summarize the compiler diagnostics the user sends in one short sentence naming the \
363             likely root theme. This is advisory only.",
364            &distinct.join("\n\n"),
365        )?;
366        let gloss = first_line(&gloss);
367        if !gloss.is_empty() {
368            report.note(format!("local-model gloss (non-authoritative): {gloss}"));
369        }
370    }
371    report.note(
372        "buckets assigned deterministically by severity prefix (§0.3), NOT by model judgment.",
373    );
374    if is_echo(adapter) {
375        report.note(ECHO_PASSTHROUGH_NOTE);
376    }
377
378    Ok(Preprocessed::new(bucket_by_severity(&distinct), report))
379}
380
381/// Draft a commit-message subject line for `diff` by routing the whole diff
382/// through the local model (optional helper; card II-6).
383///
384/// **A draft, never authoritative.** The message is a lossy summary the human
385/// (or cloud model) must review and rewrite — the diff is not enumerably
386/// reduced here, so [`DropReport::dropped`] is empty and the report carries the
387/// "must be reviewed" caveat instead. The point is purely token-max: the full
388/// diff goes to the *local* model, and only a one-line draft is handed onward.
389/// With the echo stub the draft is the first diff line echoed back, plus
390/// [`ECHO_PASSTHROUGH_NOTE`].
391pub fn draft_commit_message(
392    adapter: &dyn ModelAdapter,
393    diff: &str,
394) -> Result<Preprocessed<String>> {
395    let diff_lines = diff.lines().count();
396    let mut report = DropReport::new("draft_commit_message", diff_lines);
397
398    let raw = route(
399        adapter,
400        "Write ONE concise commit subject line (imperative mood, under 72 chars) summarizing the \
401         diff the user sends. Output only that line.",
402        diff,
403    )?;
404    let subject = first_line(&raw);
405
406    report.kept_units = usize::from(!subject.is_empty());
407    report.note(
408        "draft only — a lossy, non-authoritative summary of the diff; review and rewrite before \
409         committing. The full diff is unchanged.",
410    );
411    if is_echo(adapter) {
412        report.note(ECHO_PASSTHROUGH_NOTE);
413    }
414
415    Ok(Preprocessed::new(subject, report))
416}
417
418/// Split raw diagnostic text into logical blocks: on blank lines when the text
419/// is block-structured, else one block per non-empty line.
420fn split_diagnostics(raw: &str) -> Vec<String> {
421    let by_block: Vec<String> = raw
422        .split("\n\n")
423        .map(|b| b.trim().to_string())
424        .filter(|b| !b.is_empty())
425        .collect();
426    if by_block.len() > 1 {
427        return by_block;
428    }
429    raw.lines()
430        .map(|l| l.trim().to_string())
431        .filter(|l| !l.is_empty())
432        .collect()
433}
434
435/// Deterministic severity of a diagnostic, from the prefix of its first line.
436fn severity_of(block: &str) -> &'static str {
437    let head = block.lines().next().unwrap_or("").trim_start();
438    if head.starts_with("error") {
439        "error"
440    } else if head.starts_with("warning") {
441        "warning"
442    } else if head.starts_with("note") {
443        "note"
444    } else if head.starts_with("help") {
445        "help"
446    } else {
447        "other"
448    }
449}
450
451/// Group distinct diagnostics into buckets in a stable severity order,
452/// preserving input order within each bucket and emitting only non-empty
453/// buckets.
454fn bucket_by_severity(distinct: &[String]) -> DiagnosticBuckets {
455    const ORDER: [&str; 5] = ["error", "warning", "note", "help", "other"];
456    let mut buckets = Vec::new();
457    for label in ORDER {
458        let items: Vec<String> = distinct
459            .iter()
460            .filter(|d| severity_of(d) == label)
461            .cloned()
462            .collect();
463        if !items.is_empty() {
464            buckets.push(Bucket { label: label.to_string(), items });
465        }
466    }
467    DiagnosticBuckets { buckets }
468}
469
470/// The first non-empty, trimmed line of `text` (empty string if none).
471fn first_line(text: &str) -> String {
472    text.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("").to_string()
473}
474
475/// Extract distinct in-range `0`-based indices from a model reply, in ascending
476/// order. Any digit run that parses to `< max` counts; everything else (labels,
477/// punctuation, out-of-range numbers) is ignored.
478fn parse_indices(reply: &str, max: usize) -> Vec<usize> {
479    let mut found: Vec<usize> = Vec::new();
480    let mut digits = String::new();
481    let flush = |digits: &mut String, found: &mut Vec<usize>| {
482        if let Ok(n) = digits.parse::<usize>()
483            && n < max
484            && !found.contains(&n)
485        {
486            found.push(n);
487        }
488        digits.clear();
489    };
490    for ch in reply.chars() {
491        if ch.is_ascii_digit() {
492            digits.push(ch);
493        } else if !digits.is_empty() {
494            flush(&mut digits, &mut found);
495        }
496    }
497    if !digits.is_empty() {
498        flush(&mut digits, &mut found);
499    }
500    found.sort_unstable();
501    found
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    use crate::{CompletionResponse, EchoAdapter};
508    use std::sync::atomic::{AtomicUsize, Ordering};
509
510    /// A non-echo adapter that returns a fixed reply and *counts* calls, so a
511    /// test can prove the preprocessing genuinely routed through the adapter
512    /// (not just string-processed locally) and consumed its reply.
513    struct FixedAdapter {
514        reply: String,
515        calls: AtomicUsize,
516    }
517    impl FixedAdapter {
518        fn new(reply: &str) -> Self {
519            Self { reply: reply.to_string(), calls: AtomicUsize::new(0) }
520        }
521    }
522    impl ModelAdapter for FixedAdapter {
523        fn name(&self) -> &str {
524            "fixed-test"
525        }
526        fn complete(&self, _request: &CompletionRequest) -> Result<CompletionResponse> {
527            self.calls.fetch_add(1, Ordering::SeqCst);
528            Ok(CompletionResponse { content: self.reply.clone(), model: "fixed-test".into() })
529        }
530    }
531
532    /// Compile-time guarantee that preprocessing output is never treated as the
533    /// final authority on correctness (§321).
534    const _: () = assert!(!DropReport::AUTHORITATIVE);
535
536    /// The headline EchoAdapter measurement: summarize routes the text through
537    /// the adapter and hard-caps the reply, and the drop-report enumerates
538    /// exactly the lines it removed. Because the echo reply *is* the input, the
539    /// output being the first `target` source lines proves the adapter's reply
540    /// flowed into the result (routing), and the dropped list proves the
541    /// drop-report (§321). This is the deterministic, network-free token-saving
542    /// measurement: 5 lines in, 2 out, 3 reported dropped.
543    #[test]
544    fn summarize_routes_through_echo_and_reports_every_dropped_line() {
545        let text = "line one\nline two\nline three\nline four\nline five";
546        let pre = summarize(&EchoAdapter, text, 2).unwrap();
547
548        assert_eq!(pre.output, "line one\nline two");
549        assert_eq!(pre.report.input_units, 5);
550        assert_eq!(pre.report.kept_units, 2);
551        assert_eq!(pre.report.dropped, vec!["line three", "line four", "line five"]);
552        // Honest about being a stub: the echo pass-through note must be present.
553        assert!(pre.report.notes.iter().any(|n| n == ECHO_PASSTHROUGH_NOTE));
554    }
555
556    #[test]
557    fn summarize_drops_nothing_when_within_budget() {
558        let pre = summarize(&EchoAdapter, "only\ntwo", 5).unwrap();
559        assert_eq!(pre.output, "only\ntwo");
560        assert_eq!(pre.report.dropped_count(), 0);
561    }
562
563    /// Echo triage is conservative by design: the numbered listing is echoed,
564    /// every index parses, so all candidates are kept and nothing is dropped —
565    /// and the stub is flagged.
566    #[test]
567    fn triage_echo_keeps_all_and_flags_the_stub() {
568        let candidates =
569            vec!["hit a".to_string(), "hit b".to_string(), "hit c".to_string()];
570        let pre = triage(&EchoAdapter, "find the thing", &candidates).unwrap();
571
572        assert_eq!(pre.output, candidates);
573        assert_eq!(pre.report.dropped_count(), 0);
574        assert_eq!(pre.report.kept_units, 3);
575        assert!(pre.report.notes.iter().any(|n| n == ECHO_PASSTHROUGH_NOTE));
576    }
577
578    /// The positive filtering path: a real (test) adapter selects candidates 0
579    /// and 2; triage keeps those and reports candidate 1 as dropped, verbatim.
580    /// The call counter proves the selection came from routing to the adapter.
581    #[test]
582    fn triage_keeps_the_model_selected_subset_and_reports_the_rest() {
583        let adapter = FixedAdapter::new("keep 0, 2");
584        let candidates =
585            vec!["c0".to_string(), "c1".to_string(), "c2".to_string()];
586        let pre = triage(&adapter, "q", &candidates).unwrap();
587
588        assert_eq!(pre.output, vec!["c0".to_string(), "c2".to_string()]);
589        assert_eq!(pre.report.dropped, vec!["c1".to_string()]);
590        assert_eq!(pre.report.kept_units, 2);
591        assert_eq!(adapter.calls.load(Ordering::SeqCst), 1, "must route through the adapter");
592        // Non-echo adapter: no pass-through note.
593        assert!(!pre.report.notes.iter().any(|n| n == ECHO_PASSTHROUGH_NOTE));
594    }
595
596    /// An unusable reply (no valid index) must fail safe to keep-all, never
597    /// drop blindly — a false negative here is not recoverable.
598    #[test]
599    fn triage_fails_safe_to_keep_all_on_unusable_reply() {
600        let adapter = FixedAdapter::new("i have no idea, sorry");
601        let candidates = vec!["c0".to_string(), "c1".to_string()];
602        let pre = triage(&adapter, "q", &candidates).unwrap();
603
604        assert_eq!(pre.output, candidates);
605        assert_eq!(pre.report.dropped_count(), 0);
606        assert!(pre.report.notes.iter().any(|n| n.contains("kept ALL conservatively")));
607    }
608
609    /// classify deduplicates (the real reduction) and buckets by severity
610    /// deterministically. Under echo: the duplicate error is reported dropped,
611    /// buckets are correct, and both the deterministic-bucketing note and the
612    /// echo pass-through note are present.
613    #[test]
614    fn classify_dedups_and_buckets_by_severity() {
615        let raw = "error[E0432]: unresolved import `foo`\n\
616                   error[E0432]: unresolved import `foo`\n\
617                   warning: unused variable `x`\n\
618                   note: `#[warn(unused)]` on by default";
619        let pre = classify_diagnostics(&EchoAdapter, raw).unwrap();
620
621        // Four lines in, one exact duplicate removed -> three distinct.
622        assert_eq!(pre.report.input_units, 4);
623        assert_eq!(pre.report.kept_units, 3);
624        assert_eq!(pre.report.dropped, vec!["error[E0432]: unresolved import `foo`"]);
625
626        let labels: Vec<&str> = pre.output.buckets.iter().map(|b| b.label.as_str()).collect();
627        assert_eq!(labels, vec!["error", "warning", "note"]);
628        assert_eq!(pre.output.buckets[0].items.len(), 1); // dedup collapsed the two errors
629
630        assert!(pre.report.notes.iter().any(|n| n.contains("deterministically by severity")));
631        assert!(pre.report.notes.iter().any(|n| n == ECHO_PASSTHROUGH_NOTE));
632    }
633
634    #[test]
635    fn draft_commit_message_returns_a_subject_and_flags_non_authoritative() {
636        let pre = draft_commit_message(&EchoAdapter, "add preprocess module\nmore detail").unwrap();
637        assert_eq!(pre.output, "add preprocess module");
638        assert!(pre.report.notes.iter().any(|n| n.contains("draft only")));
639        assert!(pre.report.notes.iter().any(|n| n == ECHO_PASSTHROUGH_NOTE));
640    }
641
642    #[test]
643    fn parse_indices_ignores_out_of_range_and_dedups() {
644        assert_eq!(parse_indices("keep 0, 2 and 9", 3), vec![0, 2]);
645        assert_eq!(parse_indices("1 1 1", 3), vec![1]);
646        assert_eq!(parse_indices("none here", 3), Vec::<usize>::new());
647    }
648}