differential_engine/grouping/
mod.rs1mod 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 pub backend: Option<&'a dyn LlmBackend>,
32 pub cache_dir: Option<&'a Path>,
35}
36
37pub(crate) struct ClassInfo {
40 pub id: String,
41 pub n_hunks: usize,
42 pub files: Vec<String>,
44 pub kind: char,
45 pub exemplar: usize,
47 pub all_generated: bool,
49 pub rename_gated: bool,
51 pub rename_note: Option<String>,
53 pub digests: Vec<String>,
55}
56
57pub(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
69pub 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 pub coverage: f64,
111}
112
113fn 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
189fn 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
233fn 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
295fn 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}