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}