Skip to main content

differential_engine/plan/
tiers.rs

1//! The effort-tier reading rule (ADR 0006), in one place.
2//!
3//! What a reviewer is asked to read in a group, and what is deliberately
4//! withheld, is domain policy — but it was implemented twice, nearly
5//! character-for-character, in the TUI's row builder and the stack renderer.
6//! Two copies of a rule is two rules; these two had already drifted apart on
7//! how they treat a back-filled group.
8
9use crate::plan::HunkId;
10use crate::plan::view::{GroupView, ReviewView};
11use std::collections::HashSet;
12
13use crate::schema;
14
15/// Whether the deferrable half of a group is currently hidden.
16///
17/// The TUI toggles this with `z`. The stack is always `Folded`: its way of
18/// unfolding is the next commit in the series.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Fold {
21    Folded,
22    Unfolded,
23}
24
25/// Why the deferred half is deferred. A renderer's fold line or commit subject
26/// depends on this and on nothing else.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Deferral {
29    /// Nothing is withheld: the focus tier, an unfolded group, or a skim group
30    /// whose every shape class is a singleton (so the exemplars *are* the
31    /// group).
32    None,
33    /// Remaining members of the shapes the shown exemplars verify.
34    SkimRemainder,
35    /// Generated content, folded whole — there is no exemplar worth reading.
36    FoldedNoise,
37}
38
39/// Whether a class is generated content: every member hunk lives in a file the
40/// classification pass marked generated.
41///
42/// This is the noise tier's whole definition (ADR 0006), and it decides two
43/// separate things that must agree. The grouping stage uses it to choose what
44/// never reaches the model. `dfr agent` uses it to choose what the model is not
45/// shown when it asks without naming ids (ADR 0022). If those drift, the model
46/// reads a class it is then not allowed to name, and the audit throws its
47/// answer away as a hallucination.
48///
49/// **There is no mixed case any more.** `generated` is part of the shape-class
50/// key (`shape::shape_hash`), so a class is wholly generated or wholly not and
51/// this test cannot come back half true. It used to: one shaped edit made in
52/// both a lockfile and a source file was one class, which answered "no" here
53/// and went to the model, taking its lockfile hunk into whatever group the
54/// model chose.
55pub fn class_is_generated(
56    doc: &schema::PlanDocument,
57    generated: &HashSet<&str>,
58    class: &schema::ClassEntry,
59) -> bool {
60    class
61        .hunk_ids
62        .iter()
63        .filter_map(|hid| HunkId::parse(hid).ok())
64        .filter_map(|h| doc.hunks.get(h.index()))
65        .all(|h| generated.contains(h.file.as_str()))
66}
67
68/// The paths `doc` marks generated, prepared once for `class_is_generated`.
69///
70/// Passed in rather than found per hunk: the test above asks about every
71/// member of every class, and it used to answer each one by scanning
72/// `doc.files` for the path. That is O(classes x members x files) for a
73/// question with O(files) worth of input, on the two paths — the grouping
74/// stage and `dfr agent` — that ask it about a whole document at once. Both
75/// already built a path index and neither could hand it over.
76pub fn generated_files(doc: &schema::PlanDocument) -> HashSet<&str> {
77    doc.files
78        .iter()
79        .filter(|f| f.generated)
80        .map(|f| f.path.as_str())
81        .collect()
82}
83
84/// One group split into what to read and what to defer.
85///
86/// Both halves are in class order (`class_ids` order, then `hunk_ids` order
87/// within a class), which is the order both renderers already emitted.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct ReadingSplit {
90    pub shown: Vec<HunkId>,
91    pub deferred: Vec<HunkId>,
92    pub deferral: Deferral,
93}
94
95impl ReadingSplit {
96    /// Every hunk the group carries, shown first.
97    ///
98    /// The stack needs this: a noise group is still one commit carrying every
99    /// hunk, even though a reviewer is asked to read none of them.
100    pub fn all(&self) -> Vec<HunkId> {
101        let mut out = self.shown.clone();
102        out.extend_from_slice(&self.deferred);
103        out
104    }
105}
106
107/// Split a group into its read and deferred halves.
108///
109/// - focus: everything is read.
110/// - skim, folded: one exemplar per shape class; the rest is deferred, because
111///   verifying the exemplar verifies the shape.
112/// - noise, folded: nothing is read.
113/// - anything unfolded: everything is read.
114///
115/// Takes the PROJECTION, not a `PlanIndex`. Both renderers already hold one,
116/// and a `PlanIndex` borrows the document — so the TUI was building a fresh
117/// one on every keypress purely to reach the two class questions below.
118pub fn reading_split(view: &ReviewView, group: &GroupView, fold: Fold) -> ReadingSplit {
119    let everything = || ReadingSplit {
120        shown: group.hunks.clone(),
121        deferred: Vec::new(),
122        deferral: Deferral::None,
123    };
124
125    if fold == Fold::Unfolded {
126        return everything();
127    }
128
129    match group.effort {
130        schema::Effort::Focus => everything(),
131
132        schema::Effort::Noise => ReadingSplit {
133            shown: Vec::new(),
134            deferred: group.hunks.clone(),
135            deferral: Deferral::FoldedNoise,
136        },
137
138        schema::Effort::Skim => {
139            let shown: Vec<HunkId> = group
140                .class_ids
141                .iter()
142                .map(|c| view.class(c).exemplar)
143                .collect();
144            let deferred: Vec<HunkId> = group
145                .class_ids
146                .iter()
147                .flat_map(|c| {
148                    let class = view.class(c);
149                    let exemplar = class.exemplar;
150                    class.hunks.iter().copied().filter(move |h| *h != exemplar)
151                })
152                .collect();
153            // Singleton classes leave nothing to defer, and a renderer must
154            // not offer to unfold an empty remainder.
155            let deferral = if deferred.is_empty() {
156                Deferral::None
157            } else {
158                Deferral::SkimRemainder
159            };
160            ReadingSplit {
161                shown,
162                deferred,
163                deferral,
164            }
165        }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::plan::test_support::{doc_with, group, hunk_ids};
173
174    /// Two classes: a 3-member shape and a singleton.
175    fn two_classes() -> schema::PlanDocument {
176        doc_with(
177            &[("C0", &["h0", "h1", "h2"], "h0"), ("C1", &["h3"], "h3")],
178            &[],
179        )
180    }
181
182    fn split(effort: schema::Effort, fold: Fold) -> ReadingSplit {
183        let mut doc = two_classes();
184        doc.groups = Some(vec![group("g0", effort, &["C0", "C1"])]);
185        let view = ReviewView::project(&doc).unwrap();
186        reading_split(&view, &view.groups[0], fold)
187    }
188
189    #[test]
190    fn focus_reads_everything_folded_or_not() {
191        for fold in [Fold::Folded, Fold::Unfolded] {
192            let s = split(schema::Effort::Focus, fold);
193            assert_eq!(hunk_ids(&s.shown), ["h0", "h1", "h2", "h3"]);
194            assert!(s.deferred.is_empty());
195            assert_eq!(s.deferral, Deferral::None);
196        }
197    }
198
199    #[test]
200    fn folded_skim_shows_one_exemplar_per_class_and_defers_the_rest() {
201        let s = split(schema::Effort::Skim, Fold::Folded);
202        assert_eq!(hunk_ids(&s.shown), ["h0", "h3"]);
203        assert_eq!(hunk_ids(&s.deferred), ["h1", "h2"]);
204        assert_eq!(s.deferral, Deferral::SkimRemainder);
205    }
206
207    #[test]
208    fn folded_noise_defers_everything_and_shows_nothing() {
209        let s = split(schema::Effort::Noise, Fold::Folded);
210        assert!(s.shown.is_empty());
211        assert_eq!(hunk_ids(&s.deferred), ["h0", "h1", "h2", "h3"]);
212        assert_eq!(s.deferral, Deferral::FoldedNoise);
213    }
214
215    #[test]
216    fn unfolding_reads_everything_whatever_the_tier() {
217        for effort in [schema::Effort::Skim, schema::Effort::Noise] {
218            let s = split(effort, Fold::Unfolded);
219            assert_eq!(hunk_ids(&s.shown), ["h0", "h1", "h2", "h3"]);
220            assert_eq!(s.deferral, Deferral::None);
221        }
222    }
223
224    /// A skim group of singletons has nothing behind the fold, so a renderer
225    /// must not offer to unfold one.
226    #[test]
227    fn a_skim_group_of_singletons_defers_nothing() {
228        let mut doc = doc_with(&[("C0", &["h0"], "h0"), ("C1", &["h1"], "h1")], &[]);
229        doc.groups = Some(vec![group("g0", schema::Effort::Skim, &["C0", "C1"])]);
230        let view = ReviewView::project(&doc).unwrap();
231        let s = reading_split(&view, &view.groups[0], Fold::Folded);
232
233        assert_eq!(hunk_ids(&s.shown), ["h0", "h1"]);
234        assert!(s.deferred.is_empty());
235        assert_eq!(
236            s.deferral,
237            Deferral::None,
238            "nothing is withheld, so there is nothing to unfold"
239        );
240    }
241
242    /// The stack commits every hunk in a noise group even though a reviewer
243    /// reads none of them — coverage is structural, not a function of effort.
244    #[test]
245    fn all_carries_every_hunk_shown_first() {
246        let s = split(schema::Effort::Skim, Fold::Folded);
247        assert_eq!(hunk_ids(&s.all()), ["h0", "h3", "h1", "h2"]);
248
249        let noise = split(schema::Effort::Noise, Fold::Folded);
250        assert_eq!(hunk_ids(&noise.all()), ["h0", "h1", "h2", "h3"]);
251    }
252}