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}