differential_engine/grouping/
mod.rs1mod assemble;
13mod key;
14mod parse;
15mod payload;
16
17use std::collections::{HashMap, HashSet};
18
19use crate::llm::LlmBackend;
20use crate::ports::GroupingCache;
21use crate::schema;
22
23use crate::EngineError;
24use crate::model::DiffView;
25
26pub use payload::PROMPT_VERSION;
27
28pub struct GroupingOptions<'a, C: GroupingCache> {
29 pub backend: &'a dyn LlmBackend,
36 pub cache: &'a C,
39 pub progress: Option<&'a (dyn Fn(Progress) + Send + Sync)>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum Progress {
49 Enumerating,
50 Classifying,
51 Grouping { backend: String, cached: bool },
52 Ordering,
53 Done,
54}
55
56pub(crate) struct ClassInfo {
59 pub id: String,
60 pub n_hunks: usize,
61 pub files: Vec<String>,
63 pub kind: char,
64 pub exemplar: usize,
66 pub all_generated: bool,
68 pub rename_gated: bool,
70 pub rename_note: Option<String>,
72 pub digests: Vec<String>,
74}
75
76pub(crate) struct WorkGroup {
78 pub label: String,
79 pub description: String,
80 pub reason: String,
81 pub skim: bool,
82 pub class_ids: Vec<String>,
83 pub backfill: bool,
84}
85
86const RELOCATION_THRESHOLD: u8 = 95;
87
88pub fn run<C: GroupingCache>(
92 doc: &schema::PlanDocument,
93 view: &DiffView,
94 backend: &dyn LlmBackend,
95 cache: &C,
96 lang_fingerprint: &str,
97 progress: Option<&(dyn Fn(Progress) + Send + Sync)>,
98) -> Result<schema::PlanDocument, EngineError> {
99 let infos = class_infos(doc);
100
101 let (noise, offered): (Vec<&ClassInfo>, Vec<&ClassInfo>) =
102 infos.iter().partition(|c| c.all_generated);
103
104 let mut audited = if offered.is_empty() {
105 Audited {
106 groups: Vec::new(),
107 missing: Vec::new(),
108 dupes: Vec::new(),
109 halluc: Vec::new(),
110 coverage: 1.0,
111 }
112 } else {
113 let prompt = payload::build_prompt(&offered, view);
114 let response = fetch_response(
115 &prompt,
116 &offered,
117 backend,
118 cache,
119 lang_fingerprint,
120 progress,
121 )?;
122 let raw = parse::parse_response(&response)?;
123 audit(raw, &offered)
124 };
125
126 apply_relocation_gate(&mut audited.groups, &infos);
127
128 Ok(assemble::assemble(doc, &infos, &noise, audited))
129}
130
131pub(crate) struct Audited {
132 pub groups: Vec<WorkGroup>,
133 pub missing: Vec<String>,
134 pub dupes: Vec<String>,
135 pub halluc: Vec<String>,
136 pub coverage: f64,
138}
139
140fn audit(raw: parse::RawGroups, offered: &[&ClassInfo]) -> Audited {
143 let known: HashMap<&str, &ClassInfo> = offered.iter().map(|c| (c.id.as_str(), *c)).collect();
144
145 let mut claimed: HashSet<String> = HashSet::new();
146 let mut dupes = Vec::new();
147 let mut halluc = Vec::new();
148 let mut groups = Vec::new();
149
150 for g in raw.groups {
151 let mut kept = Vec::new();
152 for cid in g.classes {
153 if !known.contains_key(cid.as_str()) {
154 if !halluc.contains(&cid) {
155 halluc.push(cid);
156 }
157 } else if claimed.contains(&cid) {
158 if !dupes.contains(&cid) {
159 dupes.push(cid);
160 }
161 } else {
162 claimed.insert(cid.clone());
163 kept.push(cid);
164 }
165 }
166 if !kept.is_empty() {
167 groups.push(WorkGroup {
168 label: g.label,
169 description: g.description,
170 reason: g.reason,
171 skim: g.effort == "skim",
172 class_ids: kept,
173 backfill: false,
174 });
175 }
176 }
177
178 let missing: Vec<String> = offered
179 .iter()
180 .filter(|c| !claimed.contains(&c.id))
181 .map(|c| c.id.clone())
182 .collect();
183
184 let offered_hunks: usize = offered.iter().map(|c| c.n_hunks).sum();
185 let assigned_hunks: usize = offered
186 .iter()
187 .filter(|c| claimed.contains(&c.id))
188 .map(|c| c.n_hunks)
189 .sum();
190 let coverage = if offered_hunks == 0 {
191 1.0
192 } else {
193 assigned_hunks as f64 / offered_hunks as f64
194 };
195
196 if !missing.is_empty() {
197 groups.push(WorkGroup {
198 label: "Carried by no group".to_string(),
199 description: "Classes the model omitted; recovered by the coverage audit.".to_string(),
200 reason: "Not triaged — must be read.".to_string(),
201 skim: false,
202 class_ids: missing.clone(),
203 backfill: true,
204 });
205 }
206
207 Audited {
208 groups,
209 missing,
210 dupes,
211 halluc,
212 coverage,
213 }
214}
215
216fn apply_relocation_gate(groups: &mut Vec<WorkGroup>, infos: &[ClassInfo]) {
220 let gated: HashSet<&str> = infos
221 .iter()
222 .filter(|c| c.rename_gated)
223 .map(|c| c.id.as_str())
224 .collect();
225 if gated.is_empty() {
226 return;
227 }
228
229 let mut extracted = Vec::new();
230 for g in groups.iter_mut() {
231 if !g.skim {
232 continue;
233 }
234 let (out, kept): (Vec<String>, Vec<String>) = g
235 .class_ids
236 .drain(..)
237 .partition(|cid| gated.contains(cid.as_str()));
238 g.class_ids = kept;
239 extracted.extend(out);
240 }
241 groups.retain(|g| !g.class_ids.is_empty());
242
243 if !extracted.is_empty() {
244 groups.push(WorkGroup {
245 label: "Modified during move".to_string(),
246 description: format!(
247 "Renamed files below the {RELOCATION_THRESHOLD}% relocation threshold: \
248 rewritten during the move, not relocated verbatim."
249 ),
250 reason: "Rename-similarity gate: a low-similarity rename is a modification and \
251 is never skim-eligible."
252 .to_string(),
253 skim: false,
254 class_ids: extracted,
255 backfill: false,
256 });
257 }
258}
259
260fn class_infos(doc: &schema::PlanDocument) -> Vec<ClassInfo> {
262 let file_by_path: HashMap<&str, &schema::FileEntry> =
263 doc.files.iter().map(|f| (f.path.as_str(), f)).collect();
264 let hunk_by_id: HashMap<&str, (usize, &schema::HunkEntry)> = doc
265 .hunks
266 .iter()
267 .enumerate()
268 .map(|(i, h)| (h.id.as_str(), (i, h)))
269 .collect();
270
271 doc.classes
272 .iter()
273 .map(|c| {
274 let members: Vec<(usize, &schema::HunkEntry)> = c
275 .hunk_ids
276 .iter()
277 .map(|hid| hunk_by_id[hid.as_str()])
278 .collect();
279 let mut files: Vec<String> = members.iter().map(|(_, h)| h.file.clone()).collect();
280 files.sort_unstable();
281 files.dedup();
282
283 let entries: Vec<&schema::FileEntry> =
284 files.iter().map(|p| file_by_path[p.as_str()]).collect();
285 let all_generated = entries.iter().all(|f| f.generated);
286 let rename_gated = entries.iter().any(|f| {
287 f.rename_similarity
288 .is_some_and(|s| s < RELOCATION_THRESHOLD)
289 });
290 let rename_note = entries.iter().find_map(|f| {
291 let sim = f.rename_similarity?;
292 let old = f.old_path.as_deref()?;
293 Some(format!("renamed from {old}, {sim}% similar"))
294 });
295
296 let exemplar_id = c.exemplar.as_str();
297 let (exemplar_idx, exemplar_hunk) = hunk_by_id[exemplar_id];
298 let kind = match file_by_path[exemplar_hunk.file.as_str()].disposition {
299 schema::Disposition::A => 'A',
300 schema::Disposition::D => 'D',
301 schema::Disposition::M => 'M',
302 };
303
304 let mut digests: Vec<String> = members.iter().map(|(_, h)| h.digest.clone()).collect();
305 digests.sort_unstable();
306
307 ClassInfo {
308 id: c.id.clone(),
309 n_hunks: members.len(),
310 files,
311 kind,
312 exemplar: exemplar_idx,
313 all_generated,
314 rename_gated,
315 rename_note,
316 digests,
317 }
318 })
319 .collect()
320}
321
322fn fetch_response<C: GroupingCache>(
325 prompt: &str,
326 offered: &[&ClassInfo],
327 backend: &dyn LlmBackend,
328 cache: &C,
329 lang_fingerprint: &str,
330 progress: Option<&(dyn Fn(Progress) + Send + Sync)>,
331) -> Result<String, EngineError> {
332 let key = key::cache_key(offered, backend.name(), lang_fingerprint);
333 let report = |cached: bool| {
334 if let Some(f) = progress {
335 f(Progress::Grouping {
336 backend: backend.name().to_string(),
337 cached,
338 });
339 }
340 };
341 if let Some(hit) = cache.get(&key)? {
342 report(true);
343 return Ok(hit);
344 }
345 report(false);
346 let response = backend.complete(prompt)?;
347 cache.put(&key, &response)?;
348 Ok(response)
349}