Skip to main content

chant/score/
confidence.rs

1//! Confidence scoring based on spec structure, bullet quality, and vague language.
2//!
3//! Analyzes spec quality by examining:
4//! - Bullet-to-prose ratio (structured vs unstructured content)
5//! - Imperative verb usage in bullets (clear actionable items)
6//! - Vague language patterns (unclear requirements)
7
8use crate::config::Config;
9use crate::scoring::ConfidenceGrade;
10use crate::spec::Spec;
11
12/// List of imperative verbs that indicate clear, actionable bullets
13const IMPERATIVE_VERBS: &[&str] = &[
14    "implement",
15    "add",
16    "create",
17    "update",
18    "fix",
19    "remove",
20    "delete",
21    "refactor",
22    "test",
23    "verify",
24    "ensure",
25    "validate",
26    "configure",
27    "setup",
28    "install",
29    "deploy",
30    "build",
31    "run",
32    "execute",
33    "check",
34    "document",
35    "write",
36    "read",
37    "parse",
38    "handle",
39    "process",
40    "calculate",
41    "compute",
42    "convert",
43    "transform",
44    "migrate",
45    "upgrade",
46    "downgrade",
47];
48
49/// Calculate confidence grade based on spec structure, bullet quality, and vague language.
50///
51/// Grading rules:
52/// - Grade A: High bullet ratio (>60%), verbs in >80% bullets, no vague patterns
53/// - Grade B: Medium bullet ratio (>30%), verbs in >50% bullets, <3 vague patterns
54/// - Grade C: Fallthrough between B and D
55/// - Grade D: Very low bullet ratio (<10%) OR >8 vague patterns
56///
57/// Edge cases:
58/// - Specs with no body text default to Grade D
59/// - Empty bullets don't count toward bullet ratio
60///
61/// # Arguments
62///
63/// * `spec` - The spec to analyze
64/// * `config` - Configuration (for potential future customization of vague patterns)
65///
66/// # Returns
67///
68/// A `ConfidenceGrade` based on the spec's structure and clarity
69pub fn calculate_confidence(spec: &Spec, _config: &Config) -> ConfidenceGrade {
70    // Edge case: empty body defaults to Grade D
71    if spec.body.trim().is_empty() {
72        return ConfidenceGrade::D;
73    }
74
75    // Count bullet lines and paragraph lines
76    let (bullet_lines, paragraph_lines) = count_bullets_and_paragraphs(&spec.body);
77
78    // Calculate bullet-to-prose ratio
79    let bullet_ratio = if bullet_lines + paragraph_lines == 0 {
80        0.0
81    } else {
82        bullet_lines as f64 / (bullet_lines + paragraph_lines) as f64
83    };
84
85    // Count bullets with imperative verbs
86    let bullets_with_verbs = count_bullets_with_imperative_verbs(&spec.body);
87    let verb_ratio = if bullet_lines == 0 {
88        0.0
89    } else {
90        bullets_with_verbs as f64 / bullet_lines as f64
91    };
92
93    // Count all instances of vague patterns (not deduplicated)
94    let vague_count = count_all_vague_instances(&spec.body);
95
96    // Apply grading logic (check from best to worst grade)
97    // Grade D: Very low bullet ratio (<10%) OR >8 vague patterns
98    if bullet_ratio < 0.10 || vague_count > 8 {
99        return ConfidenceGrade::D;
100    }
101
102    // Grade A: High bullet ratio (>60%), verbs in >80% bullets, no vague patterns
103    if bullet_ratio > 0.60 && verb_ratio > 0.80 && vague_count == 0 {
104        return ConfidenceGrade::A;
105    }
106
107    // Grade B: Medium bullet ratio (>30%), verbs in >50% bullets, <3 vague patterns
108    if bullet_ratio > 0.30 && verb_ratio > 0.50 && vague_count < 3 {
109        return ConfidenceGrade::B;
110    }
111
112    // Default to C for specs that don't fit clear patterns
113    ConfidenceGrade::C
114}
115
116/// Count bullet lines and paragraph lines in the spec body.
117///
118/// Bullet lines start with `-` or `*` (after trimming).
119/// Paragraph lines are non-empty lines that aren't bullets, headings, or code fences.
120/// Markdown headings are excluded from paragraph count as they are structural.
121/// Empty bullets (just `-` or `*` with no content) don't count.
122fn count_bullets_and_paragraphs(body: &str) -> (usize, usize) {
123    let mut bullet_count = 0;
124    let mut paragraph_count = 0;
125    let mut in_code_fence = false;
126
127    for line in body.lines() {
128        let trimmed = line.trim();
129
130        // Track code fences
131        if trimmed.starts_with("```") {
132            in_code_fence = !in_code_fence;
133            continue;
134        }
135
136        // Skip empty lines, code blocks, and headings (headings are structural, not prose)
137        if trimmed.is_empty() || in_code_fence || trimmed.starts_with('#') {
138            continue;
139        }
140
141        // Skip lone bullet markers (just "-" or "*" without space/content)
142        if trimmed == "-" || trimmed == "*" {
143            continue;
144        }
145
146        // Check if it's a bullet
147        if let Some(content) = trimmed
148            .strip_prefix("- ")
149            .or_else(|| trimmed.strip_prefix("* "))
150        {
151            // Only count non-empty bullets
152            if !content.trim().is_empty() {
153                bullet_count += 1;
154            }
155        } else {
156            // It's a paragraph line
157            paragraph_count += 1;
158        }
159    }
160
161    (bullet_count, paragraph_count)
162}
163
164/// Count all instances of vague patterns in the spec body.
165///
166/// Unlike `detect_vague_patterns` which deduplicates, this counts every
167/// occurrence of every vague pattern. Multiple patterns in one line count
168/// separately.
169fn count_all_vague_instances(body: &str) -> usize {
170    let body_lower = body.to_lowercase();
171    let mut count = 0;
172
173    for pattern in super::vague::DEFAULT_VAGUE_PATTERNS {
174        let pattern_lower = pattern.to_lowercase();
175
176        // Count all occurrences of this pattern
177        let mut start = 0;
178        while let Some(pos) = body_lower[start..].find(&pattern_lower) {
179            count += 1;
180            start += pos + pattern_lower.len();
181        }
182    }
183
184    count
185}
186
187/// Count bullets that start with imperative verbs.
188///
189/// A bullet is considered to have an imperative verb if the first word
190/// (after the bullet marker and checkbox if present) matches one of the
191/// known imperative verbs.
192fn count_bullets_with_imperative_verbs(body: &str) -> usize {
193    let mut count = 0;
194    let mut in_code_fence = false;
195
196    for line in body.lines() {
197        let trimmed = line.trim();
198
199        // Track code fences
200        if trimmed.starts_with("```") {
201            in_code_fence = !in_code_fence;
202            continue;
203        }
204
205        // Skip non-bullets
206        if in_code_fence {
207            continue;
208        }
209
210        // Extract content after bullet marker
211        let content = if let Some(c) = trimmed
212            .strip_prefix("- ")
213            .or_else(|| trimmed.strip_prefix("* "))
214        {
215            c
216        } else {
217            continue;
218        };
219
220        // Skip checkbox if present ([ ] or [x])
221        let content = if content.trim_start().starts_with("[") {
222            if let Some(pos) = content.find(']') {
223                &content[pos + 1..]
224            } else {
225                content
226            }
227        } else {
228            content
229        };
230
231        // Get first word
232        let first_word = content.split_whitespace().next();
233
234        // Check if first word is an imperative verb
235        if let Some(word) = first_word {
236            let word_lower = word.to_lowercase();
237            if IMPERATIVE_VERBS.contains(&word_lower.as_str()) {
238                count += 1;
239            }
240        }
241    }
242
243    count
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use crate::spec::{Spec, SpecFrontmatter};
250
251    fn make_config() -> Config {
252        // Create a minimal config for testing
253        Config {
254            project: crate::config::ProjectConfig {
255                name: "test".to_string(),
256                prefix: None,
257                silent: false,
258            },
259            defaults: crate::config::DefaultsConfig::default(),
260            providers: crate::provider::ProviderConfig::default(),
261            parallel: crate::config::ParallelConfig::default(),
262            repos: vec![],
263            enterprise: crate::config::EnterpriseConfig::default(),
264            approval: crate::config::ApprovalConfig::default(),
265            validation: crate::config::OutputValidationConfig::default(),
266            site: crate::config::SiteConfig::default(),
267            lint: crate::config::LintConfig::default(),
268            watch: crate::config::WatchConfig::default(),
269        }
270    }
271
272    #[test]
273    fn test_empty_body_returns_grade_d() {
274        let spec = Spec {
275            id: "test".to_string(),
276            frontmatter: SpecFrontmatter::default(),
277            title: Some("Test".to_string()),
278            body: String::new(),
279        };
280
281        let config = make_config();
282        assert_eq!(calculate_confidence(&spec, &config), ConfidenceGrade::D);
283    }
284
285    #[test]
286    fn test_grade_a_high_bullet_ratio_no_vague() {
287        // 10 bullets, 2 paragraphs = 83% bullet ratio (>60% needed)
288        // All bullets have imperative verbs
289        // No vague language
290        let spec = Spec {
291            id: "test".to_string(),
292            frontmatter: SpecFrontmatter::default(),
293            title: Some("Test".to_string()),
294            body: r#"
295## Acceptance Criteria
296
297- [ ] Implement feature A
298- [ ] Add functionality B
299- [ ] Create component C
300- [ ] Update module D
301- [ ] Fix bug E
302- [ ] Remove deprecated code
303- [ ] Test the implementation
304- [ ] Verify the results
305- [ ] Document the changes
306- [ ] Deploy to production
307
308Some paragraph here.
309Another paragraph here.
310"#
311            .to_string(),
312        };
313
314        let config = make_config();
315        assert_eq!(calculate_confidence(&spec, &config), ConfidenceGrade::A);
316    }
317
318    #[test]
319    fn test_grade_b_medium_bullet_ratio_few_vague() {
320        // 6 bullets, 4 paragraphs = 60% bullet ratio (>30% needed)
321        // All bullets have imperative verbs (>50% needed)
322        // 1 vague pattern (<3 needed)
323        let spec = Spec {
324            id: "test".to_string(),
325            frontmatter: SpecFrontmatter::default(),
326            title: Some("Test".to_string()),
327            body: r#"
328## Acceptance Criteria
329
330- [ ] Implement feature A
331- [ ] Add functionality B
332- [ ] Create component C
333- [ ] Update module D
334- [ ] Fix bug E
335- [ ] Deploy to production
336
337Some paragraph here.
338Another paragraph here.
339Third paragraph.
340Fourth paragraph with improve here.
341"#
342            .to_string(),
343        };
344
345        let config = make_config();
346        assert_eq!(calculate_confidence(&spec, &config), ConfidenceGrade::B);
347    }
348
349    #[test]
350    fn test_grade_d_low_bullet_ratio() {
351        // 1 bullet, 10 paragraphs = ~9% bullet ratio (< 10%)
352        let spec = Spec {
353            id: "test".to_string(),
354            frontmatter: SpecFrontmatter::default(),
355            title: Some("Test".to_string()),
356            body: r#"
357This is a wall of prose.
358It has many paragraphs.
359But very few bullets.
360This makes it hard to understand.
361Requirements should be clear.
362Bullets help with clarity.
363Paragraphs can be ambiguous.
364We need more structure.
365This spec is poorly written.
366It will get a low grade.
367
368- [ ] Implement something
369"#
370            .to_string(),
371        };
372
373        let config = make_config();
374        assert_eq!(calculate_confidence(&spec, &config), ConfidenceGrade::D);
375    }
376
377    #[test]
378    fn test_grade_d_many_vague_patterns() {
379        // Even with good structure, >8 vague patterns → Grade D
380        // Vague instances: improve(2), as needed(3), etc(2), and related(2), similar(1) = 10 total
381        let spec = Spec {
382            id: "test".to_string(),
383            frontmatter: SpecFrontmatter::default(),
384            title: Some("Test".to_string()),
385            body: r#"
386## Acceptance Criteria
387
388- [ ] Improve performance as needed
389- [ ] Add features and related functionality, etc
390- [ ] Create tests etc as needed
391- [ ] Update components as needed
392- [ ] Fix bugs and related issues
393- [ ] Similar improvements needed
394"#
395            .to_string(),
396        };
397
398        let config = make_config();
399        assert_eq!(calculate_confidence(&spec, &config), ConfidenceGrade::D);
400    }
401
402    #[test]
403    fn test_count_bullets_and_paragraphs() {
404        let body = r#"
405This is a paragraph.
406
407- [ ] This is a bullet
408- [ ] Another bullet
409
410Another paragraph here.
411
412- This is also a bullet
413
414# This is a heading (not counted)
415
416Final paragraph.
417"#;
418
419        let (bullets, paragraphs) = count_bullets_and_paragraphs(body);
420        assert_eq!(bullets, 3);
421        assert_eq!(paragraphs, 3); // Three paragraph lines
422    }
423
424    #[test]
425    fn test_empty_bullets_not_counted() {
426        let body = r#"
427- [ ] Valid bullet
428-
429- [ ] Another valid bullet
430"#;
431
432        let (bullets, paragraphs) = count_bullets_and_paragraphs(body);
433        assert_eq!(bullets, 2); // Empty bullet not counted
434        assert_eq!(paragraphs, 0);
435    }
436
437    #[test]
438    fn test_count_bullets_with_imperative_verbs() {
439        let body = r#"
440- [ ] Implement feature A
441- [ ] Add functionality B
442- [ ] This does not start with a verb
443- [ ] Create component C
444- Update something without checkbox
445"#;
446
447        let count = count_bullets_with_imperative_verbs(body);
448        assert_eq!(count, 4); // implement, add, create, update
449    }
450
451    #[test]
452    fn test_code_blocks_ignored() {
453        let body = r#"
454- [ ] Implement feature
455
456```rust
457// This is code, not a bullet
458- This looks like a bullet but it's in a code block
459```
460
461- [ ] Add another feature
462"#;
463
464        let (bullets, _) = count_bullets_and_paragraphs(body);
465        assert_eq!(bullets, 2); // Only the two outside code blocks
466    }
467
468    #[test]
469    fn test_case_insensitive_verb_matching() {
470        let body = r#"
471- [ ] IMPLEMENT feature
472- [ ] Add functionality
473- [ ] CrEaTe component
474"#;
475
476        let count = count_bullets_with_imperative_verbs(body);
477        assert_eq!(count, 3); // All should match case-insensitively
478    }
479
480    #[test]
481    fn test_grade_c_fallthrough() {
482        // Doesn't meet B criteria (bullet ratio <30% or verb ratio <50% or vague >= 3)
483        // Doesn't meet D criteria (bullet ratio >=10% and vague <= 8)
484        // Falls through to C
485        let spec = Spec {
486            id: "test".to_string(),
487            frontmatter: SpecFrontmatter::default(),
488            title: Some("Test".to_string()),
489            body: r#"
490## Acceptance Criteria
491
492- [ ] Implement feature A
493- [ ] Add functionality B as needed
494- [ ] Create tests etc
495
496Some paragraph here.
497Another paragraph with improve mentioned.
498Third paragraph and related stuff.
499"#
500            .to_string(),
501        };
502
503        let config = make_config();
504        assert_eq!(calculate_confidence(&spec, &config), ConfidenceGrade::C);
505    }
506}