Skip to main content

differential_engine/grouping/
mod.rs

1//! The grouping stage: an LLM merges and labels shape-class ids — never hunks
2//! (ADR 0001) — behind the `LlmBackend` abstraction (ADR 0016), with a coverage
3//! audit that back-fills anything the model drops (invariant 5) and a
4//! content-hash cache that pins groupings (ADR 0009).
5//!
6//! Mechanical pieces the model never sees or cannot override:
7//! - classes living entirely in generated files are pre-assigned to the noise
8//!   tier and never reach the payload (ADR 0006);
9//! - classes touching a rename below 95% similarity can never stay in a skim
10//!   group — they are extracted into a synthesized close group (ADR 0003).
11
12mod assemble;
13mod cache;
14mod parse;
15mod payload;
16
17use std::collections::{HashMap, HashSet};
18use std::path::Path;
19
20use differential_llm::LlmBackend;
21use differential_schema as schema;
22
23use crate::EngineError;
24use crate::model::DiffView;
25
26pub use payload::PROMPT_VERSION;
27
28pub struct GroupingOptions<'a> {
29    /// Injected backend; `None` lets the pipeline build one from
30    /// `[grouping].command` (default: the validated claude invocation).
31    pub backend: Option<&'a dyn LlmBackend>,
32    /// Cache directory (spec/persistence.md suggests
33    /// `<git-common-dir>/differential/cache/grouping`). `None` disables caching.
34    pub cache_dir: Option<&'a Path>,
35}
36
37/// Everything the stage needs to know about one shape class, derived from the
38/// core document + the diff view.
39pub(crate) struct ClassInfo {
40    pub id: String,
41    pub n_hunks: usize,
42    /// Sorted unique file paths touched by members.
43    pub files: Vec<String>,
44    pub kind: char,
45    /// Exemplar hunk index into `view.hunks`.
46    pub exemplar: usize,
47    /// Every member hunk lives in a generated file → noise tier.
48    pub all_generated: bool,
49    /// Some member touches a rename below the relocation threshold.
50    pub rename_gated: bool,
51    /// "renamed from <old>, <sim>% similar" annotation for the payload.
52    pub rename_note: Option<String>,
53    /// Sorted member digests, for the cache key.
54    pub digests: Vec<String>,
55}
56
57/// A group between audit and assembly.
58pub(crate) struct WorkGroup {
59    pub label: String,
60    pub description: String,
61    pub reason: String,
62    pub skim: bool,
63    pub class_ids: Vec<String>,
64    pub backfill: bool,
65}
66
67const RELOCATION_THRESHOLD: u8 = 95;
68
69/// Run the grouping stage over a core-only document. Returns the same document
70/// with `groups`, `reading_plan` and the grouping audit fields filled, and
71/// `"group"` appended to `generator.stages`.
72pub fn run(
73    doc: &schema::PlanDocument,
74    view: &DiffView,
75    backend: &dyn LlmBackend,
76    cache_dir: Option<&Path>,
77    lang_fingerprint: &str,
78) -> Result<schema::PlanDocument, EngineError> {
79    let infos = class_infos(doc);
80
81    let (noise, offered): (Vec<&ClassInfo>, Vec<&ClassInfo>) =
82        infos.iter().partition(|c| c.all_generated);
83
84    let mut audited = if offered.is_empty() {
85        Audited {
86            groups: Vec::new(),
87            missing: Vec::new(),
88            dupes: Vec::new(),
89            halluc: Vec::new(),
90            coverage: 1.0,
91        }
92    } else {
93        let prompt = payload::build_prompt(&offered, view);
94        let response = fetch_response(&prompt, &offered, backend, cache_dir, lang_fingerprint)?;
95        let raw = parse::parse_response(&response)?;
96        audit(raw, &offered)
97    };
98
99    apply_relocation_gate(&mut audited.groups, &infos);
100
101    Ok(assemble::assemble(doc, &infos, &noise, audited))
102}
103
104pub(crate) struct Audited {
105    pub groups: Vec<WorkGroup>,
106    pub missing: Vec<String>,
107    pub dupes: Vec<String>,
108    pub halluc: Vec<String>,
109    /// Model-assigned hunks / offered hunks, pre-back-fill. The honest number.
110    pub coverage: f64,
111}
112
113/// The coverage audit — the whole point of merging class ids instead of hunk
114/// indices (ADR 0001): an omitted id is detectable and back-filled, never lost.
115fn audit(raw: parse::RawGroups, offered: &[&ClassInfo]) -> Audited {
116    let known: HashMap<&str, &ClassInfo> = offered.iter().map(|c| (c.id.as_str(), *c)).collect();
117
118    let mut claimed: HashSet<String> = HashSet::new();
119    let mut dupes = Vec::new();
120    let mut halluc = Vec::new();
121    let mut groups = Vec::new();
122
123    for g in raw.groups {
124        let mut kept = Vec::new();
125        for cid in g.classes {
126            if !known.contains_key(cid.as_str()) {
127                if !halluc.contains(&cid) {
128                    halluc.push(cid);
129                }
130            } else if claimed.contains(&cid) {
131                if !dupes.contains(&cid) {
132                    dupes.push(cid);
133                }
134            } else {
135                claimed.insert(cid.clone());
136                kept.push(cid);
137            }
138        }
139        if !kept.is_empty() {
140            groups.push(WorkGroup {
141                label: g.label,
142                description: g.description,
143                reason: g.reason,
144                skim: g.effort == "skim",
145                class_ids: kept,
146                backfill: false,
147            });
148        }
149    }
150
151    let missing: Vec<String> = offered
152        .iter()
153        .filter(|c| !claimed.contains(&c.id))
154        .map(|c| c.id.clone())
155        .collect();
156
157    let offered_hunks: usize = offered.iter().map(|c| c.n_hunks).sum();
158    let assigned_hunks: usize = offered
159        .iter()
160        .filter(|c| claimed.contains(&c.id))
161        .map(|c| c.n_hunks)
162        .sum();
163    let coverage = if offered_hunks == 0 {
164        1.0
165    } else {
166        assigned_hunks as f64 / offered_hunks as f64
167    };
168
169    if !missing.is_empty() {
170        groups.push(WorkGroup {
171            label: "Carried by no group".to_string(),
172            description: "Classes the model omitted; recovered by the coverage audit.".to_string(),
173            reason: "Not triaged — must be read.".to_string(),
174            skim: false,
175            class_ids: missing.clone(),
176            backfill: true,
177        });
178    }
179
180    Audited {
181        groups,
182        missing,
183        dupes,
184        halluc,
185        coverage,
186    }
187}
188
189/// ADR 0003: a class touching a sub-threshold rename is a modification, not a
190/// relocation, and can never stay in a skim group. Deterministic backstop that
191/// runs after the audit, whatever the model claimed.
192fn apply_relocation_gate(groups: &mut Vec<WorkGroup>, infos: &[ClassInfo]) {
193    let gated: HashSet<&str> = infos
194        .iter()
195        .filter(|c| c.rename_gated)
196        .map(|c| c.id.as_str())
197        .collect();
198    if gated.is_empty() {
199        return;
200    }
201
202    let mut extracted = Vec::new();
203    for g in groups.iter_mut() {
204        if !g.skim {
205            continue;
206        }
207        let (out, kept): (Vec<String>, Vec<String>) = g
208            .class_ids
209            .drain(..)
210            .partition(|cid| gated.contains(cid.as_str()));
211        g.class_ids = kept;
212        extracted.extend(out);
213    }
214    groups.retain(|g| !g.class_ids.is_empty());
215
216    if !extracted.is_empty() {
217        groups.push(WorkGroup {
218            label: "Modified during move".to_string(),
219            description: format!(
220                "Renamed files below the {RELOCATION_THRESHOLD}% relocation threshold: \
221                 rewritten during the move, not relocated verbatim."
222            ),
223            reason: "Rename-similarity gate: a low-similarity rename is a modification and \
224                     is never skim-eligible."
225                .to_string(),
226            skim: false,
227            class_ids: extracted,
228            backfill: false,
229        });
230    }
231}
232
233/// Derive per-class facts from the core document + view.
234fn class_infos(doc: &schema::PlanDocument) -> Vec<ClassInfo> {
235    let file_by_path: HashMap<&str, &schema::FileEntry> =
236        doc.files.iter().map(|f| (f.path.as_str(), f)).collect();
237    let hunk_by_id: HashMap<&str, (usize, &schema::HunkEntry)> = doc
238        .hunks
239        .iter()
240        .enumerate()
241        .map(|(i, h)| (h.id.as_str(), (i, h)))
242        .collect();
243
244    doc.classes
245        .iter()
246        .map(|c| {
247            let members: Vec<(usize, &schema::HunkEntry)> = c
248                .hunk_ids
249                .iter()
250                .map(|hid| hunk_by_id[hid.as_str()])
251                .collect();
252            let mut files: Vec<String> = members.iter().map(|(_, h)| h.file.clone()).collect();
253            files.sort_unstable();
254            files.dedup();
255
256            let entries: Vec<&schema::FileEntry> =
257                files.iter().map(|p| file_by_path[p.as_str()]).collect();
258            let all_generated = entries.iter().all(|f| f.generated);
259            let rename_gated = entries.iter().any(|f| {
260                f.rename_similarity
261                    .is_some_and(|s| s < RELOCATION_THRESHOLD)
262            });
263            let rename_note = entries.iter().find_map(|f| {
264                let sim = f.rename_similarity?;
265                let old = f.old_path.as_deref()?;
266                Some(format!("renamed from {old}, {sim}% similar"))
267            });
268
269            let exemplar_id = c.exemplar.as_str();
270            let (exemplar_idx, exemplar_hunk) = hunk_by_id[exemplar_id];
271            let kind = match file_by_path[exemplar_hunk.file.as_str()].disposition {
272                schema::Disposition::A => 'A',
273                schema::Disposition::D => 'D',
274                schema::Disposition::M => 'M',
275            };
276
277            let mut digests: Vec<String> = members.iter().map(|(_, h)| h.digest.clone()).collect();
278            digests.sort_unstable();
279
280            ClassInfo {
281                id: c.id.clone(),
282                n_hunks: members.len(),
283                files,
284                kind,
285                exemplar: exemplar_idx,
286                all_generated,
287                rename_gated,
288                rename_note,
289                digests,
290            }
291        })
292        .collect()
293}
294
295/// Cache-or-call: the cached value is the raw model response, so the audit and
296/// assembly stay pure functions replayed on every load.
297fn fetch_response(
298    prompt: &str,
299    offered: &[&ClassInfo],
300    backend: &dyn LlmBackend,
301    cache_dir: Option<&Path>,
302    lang_fingerprint: &str,
303) -> Result<String, EngineError> {
304    let key = cache::cache_key(offered, backend.name(), lang_fingerprint);
305    if let Some(dir) = cache_dir
306        && let Some(hit) = cache::load(dir, &key)?
307    {
308        return Ok(hit);
309    }
310    let response = backend.complete(prompt)?;
311    if let Some(dir) = cache_dir {
312        cache::store(dir, &key, &response)?;
313    }
314    Ok(response)
315}