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 sites;
24pub mod symbols;
25
26use std::collections::{HashMap, HashSet};
27
28use crate::plan::HunkId;
29use crate::schema;
30
31/// One class, resolved: everything a caller needs to describe it.
32pub struct ClassView<'d> {
33    pub class: &'d schema::ClassEntry,
34    /// Member hunks, in class order.
35    pub members: Vec<&'d schema::HunkEntry>,
36    /// The member a reviewer reads to verify the whole class.
37    pub exemplar: &'d schema::HunkEntry,
38    /// Distinct paths the class touches, in first-seen order.
39    pub files: Vec<&'d str>,
40    /// Disposition of the exemplar's file.
41    pub kind: schema::Disposition,
42}
43
44impl ClassView<'_> {
45    /// `path:line` for the exemplar — where to go and look.
46    pub fn exemplar_at(&self) -> String {
47        format!("{}:{}", self.exemplar.file, self.exemplar.new_start.max(1))
48    }
49}
50
51/// Every class the model is asked to group, largest first — the order the class
52/// ids already carry.
53///
54/// **This is the whole read path.** There were four more — one class by id, the
55/// classes touching a path, the classes defining a symbol, and every class
56/// generated included. Each was a lookup into this list, at a model turn per
57/// call, and the list is 72KB for a 196-class change. So the list goes out
58/// whole and the lookups go.
59///
60/// **Generated content is left out**, exactly as the grouping stage leaves it
61/// out of the prompt (`plan::class_is_generated`, ADR 0006). Listing a class the
62/// model may not name would invite it to name one, and the audit would throw
63/// that whole group away as a hallucination.
64///
65/// **Nothing printed here touches a generated file at all.** `generated` is part
66/// of the shape-class key (`shape::shape_hash`), so a class is wholly generated
67/// or wholly not, and this filter therefore removes every generated hunk rather
68/// than every class that happens to be entirely generated. The noise tier still
69/// folds rather than hides: `git diff` reaches any path at all.
70pub fn index(doc: &schema::PlanDocument) -> Vec<ClassView<'_>> {
71    let generated = crate::plan::generated_files(doc);
72    // Both prepared once. Every class asks the same two questions of the same
73    // file list, and answering each by scanning it made listing a 196-class
74    // document quadratic in the thing it was listing.
75    let disposition: HashMap<&str, schema::Disposition> = doc
76        .files
77        .iter()
78        .map(|f| (f.path.as_str(), f.disposition))
79        .collect();
80    doc.classes
81        .iter()
82        .filter_map(|c| view(doc, &disposition, c))
83        .filter(|v| !crate::plan::class_is_generated(doc, &generated, v.class))
84        .collect()
85}
86
87fn view<'d>(
88    doc: &'d schema::PlanDocument,
89    disposition: &HashMap<&'d str, schema::Disposition>,
90    class: &'d schema::ClassEntry,
91) -> Option<ClassView<'d>> {
92    let members = hunks_of(doc, &class.hunk_ids);
93    let exemplar = doc
94        .hunks
95        .get(HunkId::parse(&class.exemplar).ok()?.index())?;
96    // Distinct paths, first-seen order. The order is what the prompt prints,
97    // so it stays; only the membership test stopped being a linear scan.
98    let mut seen: HashSet<&str> = HashSet::new();
99    let files: Vec<&str> = members
100        .iter()
101        .map(|m| m.file.as_str())
102        .filter(|p| seen.insert(p))
103        .collect();
104    Some(ClassView {
105        kind: *disposition.get(exemplar.file.as_str())?,
106        class,
107        members,
108        exemplar,
109        files,
110    })
111}
112
113fn hunks_of<'d>(doc: &'d schema::PlanDocument, ids: &[String]) -> Vec<&'d schema::HunkEntry> {
114    ids.iter()
115        .filter_map(|hid| HunkId::parse(hid).ok())
116        .filter_map(|h: HunkId| doc.hunks.get(h.index()))
117        .collect()
118}