Skip to main content

differential_engine/
shape.rs

1//! Shape classes and hunk digests.
2//!
3//! A shape class groups hunks whose diff text is identical after normalising
4//! away identifiers, string and numeric literals — on BOTH sides (ADR 0004:
5//! hashing only added lines collapses every deletion-only hunk into one class,
6//! turning "same shapes, skippable" into a lie).
7//!
8//! Line normalisation is pluggable per language (ADR 0015, `crate::lang`); the
9//! framing here — sigil prefixes, sorting, disposition in the key, sha1/12-hex —
10//! is language-independent and deliberately identical to the validated
11//! prototype, so class populations stay comparable with its recorded outputs.
12
13use std::cmp::Reverse;
14use std::collections::HashMap;
15
16use sha1::{Digest, Sha1};
17
18use crate::lang::{Language, LanguageRegistry};
19use crate::model::{DiffView, Hunk};
20
21/// Normalise one side's lines: language normalisation, sigil prefixed, sorted.
22pub fn norm_lines(lines: &[Vec<u8>], sigil: u8, lang: &dyn Language) -> Vec<Vec<u8>> {
23    let mut out: Vec<Vec<u8>> = lines
24        .iter()
25        .map(|l| {
26            let norm = lang.normalize_line(l);
27            let mut v = Vec::with_capacity(norm.len() + 1);
28            v.push(sigil);
29            v.extend_from_slice(&norm);
30            v
31        })
32        .collect();
33    out.sort_unstable();
34    out
35}
36
37/// Shape key: normalised removed + added lines, the file disposition, and
38/// whether the file is generated. Returns the 12-hex sha1 used as the class
39/// key.
40///
41/// The disposition is in the key because a whole-file-add hunk and a
42/// modification with identical text are different shapes.
43///
44/// **`generated` is in the key because a class is a unit of routing, not only a
45/// unit of text.** A lockfile line and a source line can normalise to the same
46/// shape, and without this they became one class — a *mixed* class, neither
47/// generated nor not. The noise tier could then only ask "is every member
48/// generated?", which such a class answers no, so it went to the model and its
49/// lockfile hunks went wherever the model put the class. That was how a
50/// generated hunk reached a focus group.
51///
52/// With the flag in the key, every class is wholly generated or wholly not.
53/// `plan::class_is_generated` becomes exact rather than a membership test that
54/// fails on the one case that matters.
55///
56/// The cost is stated plainly: `generated` is a *hint* (built-in list,
57/// gitattributes, repo config), so a `[classify]` glob now moves a class
58/// boundary rather than only a routing decision. That is config tuning
59/// classification, which is what config is for (ADR 0012); it still cannot add
60/// or remove a hunk. The gain is that the routing decision is exact.
61pub fn shape_hash(
62    hunk: &Hunk,
63    disposition_letter: u8,
64    generated: bool,
65    lang: &dyn Language,
66) -> String {
67    let mut parts = norm_lines(&hunk.removed, b'-', lang);
68    parts.extend(norm_lines(&hunk.added, b'+', lang));
69    let mut hasher = Sha1::new();
70    for (i, p) in parts.iter().enumerate() {
71        if i > 0 {
72            hasher.update(b"\n");
73        }
74        hasher.update(p);
75    }
76    hasher.update(b"|");
77    hasher.update([disposition_letter]);
78    hasher.update(b"|");
79    hasher.update([if generated { b'g' } else { b'.' }]);
80    hex::encode(hasher.finalize())[..12].to_string()
81}
82
83/// Exact content digest — NOT normalised and NOT language-dependent. The stable
84/// anchor that lets comments and review state survive regeneration (positional
85/// ids do not).
86pub fn hunk_digest(hunk: &Hunk) -> String {
87    let mut hasher = Sha1::new();
88    for l in &hunk.removed {
89        hasher.update(b"-");
90        hasher.update(l);
91        hasher.update(b"\n");
92    }
93    for l in &hunk.added {
94        hasher.update(b"+");
95        hasher.update(l);
96        hasher.update(b"\n");
97    }
98    hasher.update([hunk.nonl_old as u8, hunk.nonl_new as u8]);
99    hex::encode(hasher.finalize())
100}
101
102/// True iff, after erasing identifiers and literals, removed and added lines
103/// match: a structure-free substitution. Insertion-only and deletion-only hunks
104/// are never pure. This is a property of the shape, so it is computed once per
105/// class from the exemplar. Computed, never claimed — there is no setter.
106pub fn pure_substitution(hunk: &Hunk, lang: &dyn Language) -> bool {
107    if hunk.removed.is_empty() || hunk.added.is_empty() {
108        return false;
109    }
110    // Sigil-free normalisation, so the two sides are comparable.
111    norm_lines(&hunk.removed, b' ', lang) == norm_lines(&hunk.added, b' ', lang)
112}
113
114/// The mechanical partition: every hunk assigned to a shape class.
115/// Classes are ordered by descending member count (ties broken by first
116/// appearance, so the ordering is deterministic); `class_of[i]` is the class
117/// index of canonical hunk `i`. Coverage is total by construction.
118pub struct Partition {
119    /// Hunk indices per class, in canonical order within each class.
120    pub classes: Vec<Vec<usize>>,
121    /// Canonical hunk index -> class index.
122    pub class_of: Vec<usize>,
123    /// Per class: is the exemplar a pure substitution?
124    pub pure: Vec<bool>,
125}
126
127pub fn partition(view: &DiffView, langs: &LanguageRegistry) -> Partition {
128    let mut by_hash: HashMap<String, Vec<usize>> = HashMap::new();
129    let mut first_seen: HashMap<String, usize> = HashMap::new();
130    for (i, h) in view.hunks.iter().enumerate() {
131        let file = view.file_of(h);
132        let lang = langs.detect(&file.path);
133        let key = shape_hash(h, file.disposition.letter(), file.generated.is_some(), lang);
134        first_seen.entry(key.clone()).or_insert(i);
135        by_hash.entry(key).or_default().push(i);
136    }
137
138    let mut keys: Vec<&String> = by_hash.keys().collect();
139    keys.sort_by_key(|k| (Reverse(by_hash[*k].len()), first_seen[*k]));
140
141    let mut classes = Vec::with_capacity(keys.len());
142    let mut class_of = vec![0usize; view.hunks.len()];
143    let mut pure = Vec::with_capacity(keys.len());
144    for (ci, k) in keys.iter().enumerate() {
145        let members = by_hash[*k].clone();
146        for &hi in &members {
147            class_of[hi] = ci;
148        }
149        let exemplar = &view.hunks[members[0]];
150        let lang = langs.detect(&view.file_of(exemplar).path);
151        pure.push(pure_substitution(exemplar, lang));
152        classes.push(members);
153    }
154    Partition {
155        classes,
156        class_of,
157        pure,
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::lang::Generic;
165    use crate::model::Hunk;
166
167    const G: &Generic = &Generic;
168
169    fn hunk(removed: &[&[u8]], added: &[&[u8]]) -> Hunk {
170        Hunk {
171            file: 0,
172            old_start: 1,
173            old_count: removed.len() as u32,
174            new_start: 1,
175            new_count: added.len() as u32,
176            removed: removed.iter().map(|l| l.to_vec()).collect(),
177            added: added.iter().map(|l| l.to_vec()).collect(),
178            nonl_old: false,
179            nonl_new: false,
180        }
181    }
182
183    #[test]
184    fn identifier_renames_share_a_shape() {
185        let a = hunk(
186            &[b"    let total_count = compute_total(items);"],
187            &[b"    let total_count = compute_sum(items);"],
188        );
189        let b = hunk(
190            &[b"  let grand_total = derive_total(rows);"],
191            &[b"  let grand_total = derive_result(rows);"],
192        );
193        assert_eq!(
194            shape_hash(&a, b'M', false, G),
195            shape_hash(&b, b'M', false, G)
196        );
197    }
198
199    #[test]
200    fn literals_are_normalised() {
201        let a = hunk(
202            &[br#"    retry(5, "backoff")"#],
203            &[br#"    retry(9, "linear")"#],
204        );
205        let b = hunk(&[br#"  retry(12, "other")"#], &[br#"  retry(3, "words")"#]);
206        assert_eq!(
207            shape_hash(&a, b'M', false, G),
208            shape_hash(&b, b'M', false, G)
209        );
210    }
211
212    #[test]
213    fn deletion_only_hunks_with_different_content_differ() {
214        // ADR 0004: both sides contribute; different deletions are not one shape.
215        let a = hunk(&[b"fn compute_interest(rate: f64) -> f64 {"], &[]);
216        let b = hunk(&[b"const RETRY_LIMIT: usize = 5;"], &[]);
217        assert_ne!(
218            shape_hash(&a, b'M', false, G),
219            shape_hash(&b, b'M', false, G)
220        );
221    }
222
223    #[test]
224    fn disposition_is_part_of_the_key() {
225        let a = hunk(&[], &[b"content line here"]);
226        assert_ne!(
227            shape_hash(&a, b'A', false, G),
228            shape_hash(&a, b'M', false, G)
229        );
230    }
231
232    #[test]
233    fn a_generated_file_never_shares_a_class_with_a_source_file() {
234        // The one case this component exists for: identical text, one side
235        // generated. Sharing a class made it neither generated nor not, and the
236        // noise tier could only route a class that was wholly one.
237        let a = hunk(&[b"old = 1"], &[b"new = 2"]);
238        assert_ne!(
239            shape_hash(&a, b'M', true, G),
240            shape_hash(&a, b'M', false, G)
241        );
242    }
243
244    #[test]
245    fn crlf_agnostic_normalisation() {
246        let unix = hunk(&[b"old_value_name = 1"], &[b"new_value_name = 1"]);
247        let dos = hunk(&[b"old_value_name = 1\r"], &[b"new_value_name = 1\r"]);
248        assert_eq!(
249            shape_hash(&unix, b'M', false, G),
250            shape_hash(&dos, b'M', false, G)
251        );
252    }
253
254    #[test]
255    fn short_identifiers_survive_normalisation() {
256        // The identifier regex needs length >= 4; `x` and `y` stay distinct.
257        let a = hunk(&[b"x = 1"], &[b"y = 1"]);
258        let b = hunk(&[b"y = 1"], &[b"x = 1"]);
259        assert_ne!(
260            shape_hash(&a, b'M', false, G),
261            shape_hash(&b, b'M', false, G)
262        );
263    }
264
265    #[test]
266    fn pure_substitution_detects_rename() {
267        let h = hunk(
268            &[b"if !self.mail_service.is_enabled() {"],
269            &[b"if !self.system_notifier.is_enabled() {"],
270        );
271        assert!(pure_substitution(&h, G));
272    }
273
274    #[test]
275    fn structural_change_is_not_pure() {
276        let h = hunk(
277            &[b"send(user_address)"],
278            &[b"send(user_address, RetryPolicy::default())"],
279        );
280        assert!(!pure_substitution(&h, G));
281    }
282
283    #[test]
284    fn insertion_only_is_never_pure() {
285        let h = hunk(&[], &[b"brand_new_line()"]);
286        assert!(!pure_substitution(&h, G));
287    }
288
289    #[test]
290    fn digest_is_exact_not_normalised() {
291        let a = hunk(&[b"alpha_name = 1"], &[b"beta_name = 1"]);
292        let b = hunk(&[b"gamma_name = 1"], &[b"delta_name = 1"]);
293        // Same shape, different digests.
294        assert_eq!(
295            shape_hash(&a, b'M', false, G),
296            shape_hash(&b, b'M', false, G)
297        );
298        assert_ne!(hunk_digest(&a), hunk_digest(&b));
299    }
300
301    #[test]
302    fn digest_covers_nonl_flags() {
303        let a = hunk(&[b"line"], &[b"line"]);
304        let mut b = a.clone();
305        b.nonl_new = true;
306        assert_ne!(hunk_digest(&a), hunk_digest(&b));
307    }
308
309    #[test]
310    fn language_override_changes_classification_but_not_digest() {
311        use crate::lang::Language;
312        struct Flattener;
313        impl Language for Flattener {
314            fn id(&self) -> &'static str {
315                "flatten-v1"
316            }
317            fn claims(&self, _p: &[u8]) -> bool {
318                true
319            }
320            fn normalize_line(&self, _l: &[u8]) -> Vec<u8> {
321                b"X".to_vec()
322            }
323        }
324        let a = hunk(&[b"completely unlike"], &[b"anything else at all"]);
325        let b = hunk(&[b"nothing shared here"], &[b"with the other hunk"]);
326        // Generic: different shapes. Flattener: same shape. Digests: unmoved.
327        assert_ne!(
328            shape_hash(&a, b'M', false, G),
329            shape_hash(&b, b'M', false, G)
330        );
331        assert_eq!(
332            shape_hash(&a, b'M', false, &Flattener),
333            shape_hash(&b, b'M', false, &Flattener)
334        );
335        assert_ne!(hunk_digest(&a), hunk_digest(&b));
336    }
337}