differential_engine/artefact/mod.rs
1//! What the model is given (ADR 0022).
2//!
3//! The grouping stage used to hand the model a fixed string: one block per
4//! shape class, eight lines of diff from the exemplar, six basenames. So a
5//! class of nine hunks was rated `skim` — "read one, trust the rest" — on the
6//! evidence of one hunk, and the prompt had a character cap that silently
7//! truncated large changes.
8//!
9//! Now the engine writes the pre-group document to a file and the model
10//! **fetches** the whole class table from it in one call. Its job is unchanged:
11//! it merges class ids, labels and rates, never touching hunks (ADR 0001). What
12//! changed is the context it has to do that job with.
13//!
14//! Every answer here comes from the document. A hunk entry records where a hunk
15//! is, never what it says, and the text is `git diff`'s job — so nothing in
16//! this module reaches a repository.
17//!
18//! This module owns all of it — building the class graph ([`graph`]), and
19//! answering the one question behind `dfr agent`. It returns data; rendering it
20//! as text is `crates/cli`'s job, the same as for every other consumer.
21
22pub mod graph;
23pub mod symbols;
24
25use std::collections::{HashMap, HashSet};
26
27use crate::plan::HunkId;
28use crate::schema;
29
30/// One class, resolved: everything a caller needs to describe it.
31pub struct ClassView<'d> {
32 pub class: &'d schema::ClassEntry,
33 /// Member hunks, in class order.
34 pub members: Vec<&'d schema::HunkEntry>,
35 /// The member a reviewer reads to verify the whole class.
36 pub exemplar: &'d schema::HunkEntry,
37 /// Distinct paths the class touches, in first-seen order.
38 pub files: Vec<&'d str>,
39 /// Disposition of the exemplar's file.
40 pub kind: schema::Disposition,
41}
42
43impl ClassView<'_> {
44 /// `path:line` for the exemplar — where to go and look.
45 pub fn exemplar_at(&self) -> String {
46 format!("{}:{}", self.exemplar.file, self.exemplar.new_start.max(1))
47 }
48}
49
50/// Every class the model is asked to group, largest first — the order the class
51/// ids already carry.
52///
53/// **This is the whole read path.** There were four more — one class by id, the
54/// classes touching a path, the classes defining a symbol, and every class
55/// generated included. Each was a lookup into this list, at a model turn per
56/// call, and the list is 72KB for a 196-class change. So the list goes out
57/// whole and the lookups go.
58///
59/// **Generated content is left out**, exactly as the grouping stage leaves it
60/// out of the prompt (`plan::class_is_generated`, ADR 0006). Listing a class the
61/// model may not name would invite it to name one, and the audit would throw
62/// that whole group away as a hallucination.
63///
64/// **Nothing printed here touches a generated file at all.** `generated` is part
65/// of the shape-class key (`shape::shape_hash`), so a class is wholly generated
66/// or wholly not, and this filter therefore removes every generated hunk rather
67/// than every class that happens to be entirely generated. The noise tier still
68/// folds rather than hides: `git diff` reaches any path at all.
69pub fn index(doc: &schema::PlanDocument) -> Vec<ClassView<'_>> {
70 let generated = crate::plan::generated_files(doc);
71 // Both prepared once. Every class asks the same two questions of the same
72 // file list, and answering each by scanning it made listing a 196-class
73 // document quadratic in the thing it was listing.
74 let disposition: HashMap<&str, schema::Disposition> = doc
75 .files
76 .iter()
77 .map(|f| (f.path.as_str(), f.disposition))
78 .collect();
79 doc.classes
80 .iter()
81 .filter_map(|c| view(doc, &disposition, c))
82 .filter(|v| !crate::plan::class_is_generated(doc, &generated, v.class))
83 .collect()
84}
85
86fn view<'d>(
87 doc: &'d schema::PlanDocument,
88 disposition: &HashMap<&'d str, schema::Disposition>,
89 class: &'d schema::ClassEntry,
90) -> Option<ClassView<'d>> {
91 let members = hunks_of(doc, &class.hunk_ids);
92 let exemplar = doc
93 .hunks
94 .get(HunkId::parse(&class.exemplar).ok()?.index())?;
95 // Distinct paths, first-seen order. The order is what the prompt prints,
96 // so it stays; only the membership test stopped being a linear scan.
97 let mut seen: HashSet<&str> = HashSet::new();
98 let files: Vec<&str> = members
99 .iter()
100 .map(|m| m.file.as_str())
101 .filter(|p| seen.insert(p))
102 .collect();
103 Some(ClassView {
104 kind: *disposition.get(exemplar.file.as_str())?,
105 class,
106 members,
107 exemplar,
108 files,
109 })
110}
111
112fn hunks_of<'d>(doc: &'d schema::PlanDocument, ids: &[String]) -> Vec<&'d schema::HunkEntry> {
113 ids.iter()
114 .filter_map(|hid| HunkId::parse(hid).ok())
115 .filter_map(|h: HunkId| doc.hunks.get(h.index()))
116 .collect()
117}