Skip to main content

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}