Skip to main content

chant/score/
isolation.rs

1//! Isolation scoring for group specs to measure member independence.
2//!
3//! Analyzes whether a group's members are well-isolated from each other by examining:
4//! - Cross-references between members in body text
5//! - Shared files across multiple members' target_files
6//!
7//! Only applies to group specs with members.
8
9use crate::scoring::IsolationGrade;
10use crate::spec::Spec;
11use crate::spec_group::get_members;
12use regex::Regex;
13use std::collections::HashSet;
14
15/// Calculate isolation grade for group specs to measure member independence.
16///
17/// Grading rules:
18/// - Grade A: >90% isolation, minimal shared files (<20% overlap)
19/// - Grade B: >70% isolation
20/// - Grade C: >50% isolation
21/// - Grade D: ≤50% isolation OR >50% file overlap
22///
23/// Edge cases:
24/// - Returns None for non-group specs (specs without members)
25/// - Groups with 1 member return Grade A (trivially isolated)
26/// - Cross-references detected by "Member N" patterns in member body text
27/// - File overlap calculated as files appearing in multiple members' target_files
28///
29/// # Arguments
30///
31/// * `spec` - The group spec to analyze
32/// * `all_specs` - All available specs (to look up members)
33///
34/// # Returns
35///
36/// * `Some(IsolationGrade)` - For group specs with members
37/// * `None` - For non-group specs or groups without members
38///
39/// # Examples
40///
41/// ```ignore
42/// // Group with 5 members, 0 cross-references, no shared files → Grade A
43/// let grade = calculate_isolation(&driver_spec, &all_specs);
44/// assert_eq!(grade, Some(IsolationGrade::A));
45///
46/// // Group with 6 members, 2 with cross-refs, 1 shared file → Grade B (67% isolation)
47/// let grade = calculate_isolation(&driver_spec, &all_specs);
48/// assert_eq!(grade, Some(IsolationGrade::B));
49///
50/// // Group with 4 members, 3 with cross-refs → Grade D (25% isolation)
51/// let grade = calculate_isolation(&driver_spec, &all_specs);
52/// assert_eq!(grade, Some(IsolationGrade::D));
53/// ```
54pub fn calculate_isolation(spec: &Spec, all_specs: &[Spec]) -> Option<IsolationGrade> {
55    // Get all members of this spec
56    let members = get_members(&spec.id, all_specs);
57
58    // Return None if this is not a group spec or has no members
59    if members.is_empty() {
60        return None;
61    }
62
63    // Edge case: Groups with 1 member are trivially isolated
64    if members.len() == 1 {
65        return Some(IsolationGrade::A);
66    }
67
68    // Count members with cross-references
69    let members_with_cross_refs = count_members_with_cross_references(&members);
70
71    // Calculate isolation percentage
72    let isolation_percentage =
73        ((members.len() - members_with_cross_refs) as f64 / members.len() as f64) * 100.0;
74
75    // Calculate file overlap percentage
76    let file_overlap_percentage = calculate_file_overlap_percentage(&members);
77
78    // Apply grading logic
79    // Grade D: ≤50% isolation OR >50% file overlap
80    if isolation_percentage <= 50.0 || file_overlap_percentage > 50.0 {
81        return Some(IsolationGrade::D);
82    }
83
84    // Grade A: >90% isolation, minimal shared files (<20% overlap)
85    if isolation_percentage > 90.0 && file_overlap_percentage < 20.0 {
86        return Some(IsolationGrade::A);
87    }
88
89    // Grade B: >70% isolation
90    if isolation_percentage > 70.0 {
91        return Some(IsolationGrade::B);
92    }
93
94    // Grade C: >50% isolation (default for remaining cases)
95    Some(IsolationGrade::C)
96}
97
98/// Count how many members have cross-references to other members in their body text.
99///
100/// Detects patterns like "Member N", "Member 1", "member 2", etc. in the body text.
101fn count_members_with_cross_references(members: &[&Spec]) -> usize {
102    // Regex to match "Member N" patterns (case-insensitive)
103    let member_pattern = Regex::new(r"(?i)\bmember\s+\d+\b").unwrap();
104
105    members
106        .iter()
107        .filter(|member| member_pattern.is_match(&member.body))
108        .count()
109}
110
111/// Calculate the percentage of files that appear in multiple members' target_files.
112///
113/// Returns a percentage from 0.0 to 100.0.
114/// If there are no target files, returns 0.0 (no overlap).
115fn calculate_file_overlap_percentage(members: &[&Spec]) -> f64 {
116    // Collect all files and count how many members reference each file
117    let mut file_counts: std::collections::HashMap<String, usize> =
118        std::collections::HashMap::new();
119
120    for member in members {
121        if let Some(target_files) = &member.frontmatter.target_files {
122            // Use a HashSet to avoid counting the same file twice in one member
123            let unique_files: HashSet<_> = target_files.iter().collect();
124            for file in unique_files {
125                *file_counts.entry(file.clone()).or_insert(0) += 1;
126            }
127        }
128    }
129
130    // If no files at all, no overlap
131    if file_counts.is_empty() {
132        return 0.0;
133    }
134
135    // Count how many files appear in more than one member
136    let shared_files = file_counts.values().filter(|&&count| count > 1).count();
137
138    // Calculate percentage
139    (shared_files as f64 / file_counts.len() as f64) * 100.0
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::spec::SpecFrontmatter;
146
147    fn make_spec(id: &str, body: &str, target_files: Option<Vec<String>>) -> Spec {
148        Spec {
149            id: id.to_string(),
150            frontmatter: SpecFrontmatter {
151                target_files,
152                ..Default::default()
153            },
154            title: Some(format!("Test spec {}", id)),
155            body: body.to_string(),
156        }
157    }
158
159    #[test]
160    fn test_non_group_returns_none() {
161        // A spec without members should return None
162        let driver = make_spec("2026-01-30-abc", "Driver spec body", None);
163        let all_specs = vec![driver.clone()];
164
165        assert_eq!(calculate_isolation(&driver, &all_specs), None);
166    }
167
168    #[test]
169    fn test_single_member_returns_grade_a() {
170        // Groups with 1 member are trivially isolated
171        let driver = make_spec("2026-01-30-abc", "Driver spec", None);
172        let member1 = make_spec("2026-01-30-abc.1", "Member 1 body", None);
173        let all_specs = vec![driver.clone(), member1];
174
175        assert_eq!(
176            calculate_isolation(&driver, &all_specs),
177            Some(IsolationGrade::A)
178        );
179    }
180
181    #[test]
182    fn test_grade_a_perfect_isolation() {
183        // 5 members, 0 cross-references, no shared files → Grade A
184        let driver = make_spec("2026-01-30-abc", "Driver spec", None);
185        let member1 = make_spec(
186            "2026-01-30-abc.1",
187            "Implement feature A",
188            Some(vec!["file1.rs".to_string()]),
189        );
190        let member2 = make_spec(
191            "2026-01-30-abc.2",
192            "Implement feature B",
193            Some(vec!["file2.rs".to_string()]),
194        );
195        let member3 = make_spec(
196            "2026-01-30-abc.3",
197            "Implement feature C",
198            Some(vec!["file3.rs".to_string()]),
199        );
200        let member4 = make_spec(
201            "2026-01-30-abc.4",
202            "Implement feature D",
203            Some(vec!["file4.rs".to_string()]),
204        );
205        let member5 = make_spec(
206            "2026-01-30-abc.5",
207            "Implement feature E",
208            Some(vec!["file5.rs".to_string()]),
209        );
210
211        let all_specs = vec![driver.clone(), member1, member2, member3, member4, member5];
212
213        assert_eq!(
214            calculate_isolation(&driver, &all_specs),
215            Some(IsolationGrade::A)
216        );
217    }
218
219    #[test]
220    fn test_grade_b_good_isolation() {
221        // 6 members, 1 with cross-refs, 1 shared file → Grade B (83% isolation)
222        let driver = make_spec("2026-01-30-abc", "Driver spec", None);
223        let member1 = make_spec(
224            "2026-01-30-abc.1",
225            "Implement feature A. See Member 2 for details.",
226            Some(vec!["file1.rs".to_string()]),
227        );
228        let member2 = make_spec(
229            "2026-01-30-abc.2",
230            "Implement feature B independently.",
231            Some(vec!["file2.rs".to_string()]),
232        );
233        let member3 = make_spec(
234            "2026-01-30-abc.3",
235            "Implement feature C",
236            Some(vec!["file3.rs".to_string()]),
237        );
238        let member4 = make_spec(
239            "2026-01-30-abc.4",
240            "Implement feature D",
241            Some(vec!["file4.rs".to_string()]),
242        );
243        let member5 = make_spec(
244            "2026-01-30-abc.5",
245            "Implement feature E",
246            Some(vec!["file5.rs".to_string()]),
247        );
248        let member6 = make_spec(
249            "2026-01-30-abc.6",
250            "Implement feature F",
251            Some(vec!["file1.rs".to_string()]), // Shared with member1
252        );
253
254        let all_specs = vec![
255            driver.clone(),
256            member1,
257            member2,
258            member3,
259            member4,
260            member5,
261            member6,
262        ];
263
264        assert_eq!(
265            calculate_isolation(&driver, &all_specs),
266            Some(IsolationGrade::B)
267        );
268    }
269
270    #[test]
271    fn test_grade_d_low_isolation() {
272        // 4 members, 3 with cross-refs → Grade D (25% isolation)
273        let driver = make_spec("2026-01-30-abc", "Driver spec", None);
274        let member1 = make_spec(
275            "2026-01-30-abc.1",
276            "Implement feature A. See Member 2.",
277            Some(vec!["file1.rs".to_string()]),
278        );
279        let member2 = make_spec(
280            "2026-01-30-abc.2",
281            "Implement feature B. Depends on Member 1 and Member 3.",
282            Some(vec!["file2.rs".to_string()]),
283        );
284        let member3 = make_spec(
285            "2026-01-30-abc.3",
286            "Implement feature C. Uses Member 2.",
287            Some(vec!["file3.rs".to_string()]),
288        );
289        let member4 = make_spec(
290            "2026-01-30-abc.4",
291            "Implement feature D",
292            Some(vec!["file4.rs".to_string()]),
293        );
294
295        let all_specs = vec![driver.clone(), member1, member2, member3, member4];
296
297        assert_eq!(
298            calculate_isolation(&driver, &all_specs),
299            Some(IsolationGrade::D)
300        );
301    }
302
303    #[test]
304    fn test_grade_d_high_file_overlap() {
305        // 3 members, 0 cross-refs, but >50% file overlap → Grade D
306        let driver = make_spec("2026-01-30-abc", "Driver spec", None);
307        let member1 = make_spec(
308            "2026-01-30-abc.1",
309            "Implement feature A",
310            Some(vec!["shared1.rs".to_string(), "shared2.rs".to_string()]),
311        );
312        let member2 = make_spec(
313            "2026-01-30-abc.2",
314            "Implement feature B",
315            Some(vec!["shared1.rs".to_string(), "shared2.rs".to_string()]),
316        );
317        let member3 = make_spec(
318            "2026-01-30-abc.3",
319            "Implement feature C",
320            Some(vec!["file3.rs".to_string()]),
321        );
322
323        let all_specs = vec![driver.clone(), member1, member2, member3];
324
325        // 2 out of 3 files are shared = 66% overlap → Grade D
326        assert_eq!(
327            calculate_isolation(&driver, &all_specs),
328            Some(IsolationGrade::D)
329        );
330    }
331
332    #[test]
333    fn test_cross_reference_detection_case_insensitive() {
334        let driver = make_spec("2026-01-30-abc", "Driver spec", None);
335        let member1 = make_spec(
336            "2026-01-30-abc.1",
337            "See MEMBER 2 for details. Also check member 3.",
338            None,
339        );
340        let member2 = make_spec("2026-01-30-abc.2", "Independent work", None);
341        let member3 = make_spec("2026-01-30-abc.3", "Independent work", None);
342
343        let all_specs = vec![driver.clone(), member1, member2, member3];
344
345        // Member 1 has cross-references, 2 and 3 don't
346        // 2 out of 3 isolated = 67% → Grade C
347        assert_eq!(
348            calculate_isolation(&driver, &all_specs),
349            Some(IsolationGrade::C)
350        );
351    }
352
353    #[test]
354    fn test_no_target_files_no_overlap() {
355        // Members without target_files should have 0% overlap
356        let driver = make_spec("2026-01-30-abc", "Driver spec", None);
357        let member1 = make_spec("2026-01-30-abc.1", "Feature A", None);
358        let member2 = make_spec("2026-01-30-abc.2", "Feature B", None);
359        let member3 = make_spec("2026-01-30-abc.3", "Feature C", None);
360        let member4 = make_spec("2026-01-30-abc.4", "Feature D", None);
361        let member5 = make_spec("2026-01-30-abc.5", "Feature E", None);
362
363        let all_specs = vec![driver.clone(), member1, member2, member3, member4, member5];
364
365        // 5 members, 0 cross-refs, no files = >90% isolation + <20% overlap → Grade A
366        assert_eq!(
367            calculate_isolation(&driver, &all_specs),
368            Some(IsolationGrade::A)
369        );
370    }
371
372    #[test]
373    fn test_grade_c_medium_isolation() {
374        // 5 members, 2 with cross-refs → 60% isolation → Grade C
375        let driver = make_spec("2026-01-30-abc", "Driver spec", None);
376        let member1 = make_spec(
377            "2026-01-30-abc.1",
378            "See Member 2",
379            Some(vec!["file1.rs".to_string()]),
380        );
381        let member2 = make_spec(
382            "2026-01-30-abc.2",
383            "Depends on Member 1",
384            Some(vec!["file2.rs".to_string()]),
385        );
386        let member3 = make_spec(
387            "2026-01-30-abc.3",
388            "Independent",
389            Some(vec!["file3.rs".to_string()]),
390        );
391        let member4 = make_spec(
392            "2026-01-30-abc.4",
393            "Independent",
394            Some(vec!["file4.rs".to_string()]),
395        );
396        let member5 = make_spec(
397            "2026-01-30-abc.5",
398            "Independent",
399            Some(vec!["file5.rs".to_string()]),
400        );
401
402        let all_specs = vec![driver.clone(), member1, member2, member3, member4, member5];
403
404        assert_eq!(
405            calculate_isolation(&driver, &all_specs),
406            Some(IsolationGrade::C)
407        );
408    }
409}