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 (>80%), verbs in >80% bullets, no vague patterns
53/// - Grade B: Medium bullet ratio (>50%), verbs in >50% bullets, <3 vague patterns
54/// - Grade C: Low bullet ratio (>20%), verbs in >30% bullets, 3-5 vague patterns
55/// - Grade D: Very low bullet ratio (<20%) OR >5 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 (<20%) OR >5 vague patterns
98    if bullet_ratio < 0.20 || vague_count > 5 {
99        return ConfidenceGrade::D;
100    }
101
102    // Grade A: High bullet ratio (>80%), verbs in >80% bullets, no vague patterns
103    if bullet_ratio > 0.80 && verb_ratio > 0.80 && vague_count == 0 {
104        return ConfidenceGrade::A;
105    }
106
107    // Grade B: Medium bullet ratio (>50%), verbs in >50% bullets, <3 vague patterns
108    if bullet_ratio > 0.50 && verb_ratio > 0.50 && vague_count < 3 {
109        return ConfidenceGrade::B;
110    }
111
112    // Grade C: Low bullet ratio (>20%), verbs in >30% bullets, 3-5 vague patterns
113    if bullet_ratio > 0.20 && verb_ratio > 0.30 && (3..=5).contains(&vague_count) {
114        return ConfidenceGrade::C;
115    }
116
117    // Default to C for specs that don't fit clear patterns
118    ConfidenceGrade::C
119}
120
121/// Count bullet lines and paragraph lines in the spec body.
122///
123/// Bullet lines start with `-` or `*` (after trimming).
124/// Paragraph lines are non-empty lines that aren't bullets, headings, or code fences.
125/// Empty bullets (just `-` or `*` with no content) don't count.
126fn count_bullets_and_paragraphs(body: &str) -> (usize, usize) {
127    let mut bullet_count = 0;
128    let mut paragraph_count = 0;
129    let mut in_code_fence = false;
130
131    for line in body.lines() {
132        let trimmed = line.trim();
133
134        // Track code fences
135        if trimmed.starts_with("```") {
136            in_code_fence = !in_code_fence;
137            continue;
138        }
139
140        // Skip empty lines, code blocks, and headings
141        if trimmed.is_empty() || in_code_fence || trimmed.starts_with('#') {
142            continue;
143        }
144
145        // Skip lone bullet markers (just "-" or "*" without space/content)
146        if trimmed == "-" || trimmed == "*" {
147            continue;
148        }
149
150        // Check if it's a bullet
151        if let Some(content) = trimmed
152            .strip_prefix("- ")
153            .or_else(|| trimmed.strip_prefix("* "))
154        {
155            // Only count non-empty bullets
156            if !content.trim().is_empty() {
157                bullet_count += 1;
158            }
159        } else {
160            // It's a paragraph line
161            paragraph_count += 1;
162        }
163    }
164
165    (bullet_count, paragraph_count)
166}
167
168/// Count all instances of vague patterns in the spec body.
169///
170/// Unlike `detect_vague_patterns` which deduplicates, this counts every
171/// occurrence of every vague pattern. Multiple patterns in one line count
172/// separately.
173fn count_all_vague_instances(body: &str) -> usize {
174    let body_lower = body.to_lowercase();
175    let mut count = 0;
176
177    for pattern in super::vague::DEFAULT_VAGUE_PATTERNS {
178        let pattern_lower = pattern.to_lowercase();
179
180        // Count all occurrences of this pattern
181        let mut start = 0;
182        while let Some(pos) = body_lower[start..].find(&pattern_lower) {
183            count += 1;
184            start += pos + pattern_lower.len();
185        }
186    }
187
188    count
189}
190
191/// Count bullets that start with imperative verbs.
192///
193/// A bullet is considered to have an imperative verb if the first word
194/// (after the bullet marker and checkbox if present) matches one of the
195/// known imperative verbs.
196fn count_bullets_with_imperative_verbs(body: &str) -> usize {
197    let mut count = 0;
198    let mut in_code_fence = false;
199
200    for line in body.lines() {
201        let trimmed = line.trim();
202
203        // Track code fences
204        if trimmed.starts_with("```") {
205            in_code_fence = !in_code_fence;
206            continue;
207        }
208
209        // Skip non-bullets
210        if in_code_fence {
211            continue;
212        }
213
214        // Extract content after bullet marker
215        let content = if let Some(c) = trimmed
216            .strip_prefix("- ")
217            .or_else(|| trimmed.strip_prefix("* "))
218        {
219            c
220        } else {
221            continue;
222        };
223
224        // Skip checkbox if present ([ ] or [x])
225        let content = if content.trim_start().starts_with("[") {
226            if let Some(pos) = content.find(']') {
227                &content[pos + 1..]
228            } else {
229                content
230            }
231        } else {
232            content
233        };
234
235        // Get first word
236        let first_word = content.split_whitespace().next();
237
238        // Check if first word is an imperative verb
239        if let Some(word) = first_word {
240            let word_lower = word.to_lowercase();
241            if IMPERATIVE_VERBS.contains(&word_lower.as_str()) {
242                count += 1;
243            }
244        }
245    }
246
247    count
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use crate::spec::{Spec, SpecFrontmatter};
254
255    fn make_config() -> Config {
256        // Create a minimal config for testing
257        Config {
258            project: crate::config::ProjectConfig {
259                name: "test".to_string(),
260                prefix: None,
261                silent: false,
262            },
263            defaults: crate::config::DefaultsConfig::default(),
264            providers: crate::provider::ProviderConfig::default(),
265            parallel: crate::config::ParallelConfig::default(),
266            repos: vec![],
267            enterprise: crate::config::EnterpriseConfig::default(),
268            approval: crate::config::ApprovalConfig::default(),
269            validation: crate::config::OutputValidationConfig::default(),
270            site: crate::config::SiteConfig::default(),
271            lint: crate::config::LintConfig::default(),
272            watch: crate::config::WatchConfig::default(),
273        }
274    }
275
276    #[test]
277    fn test_empty_body_returns_grade_d() {
278        let spec = Spec {
279            id: "test".to_string(),
280            frontmatter: SpecFrontmatter::default(),
281            title: Some("Test".to_string()),
282            body: String::new(),
283        };
284
285        let config = make_config();
286        assert_eq!(calculate_confidence(&spec, &config), ConfidenceGrade::D);
287    }
288
289    #[test]
290    fn test_grade_a_high_bullet_ratio_no_vague() {
291        // 10 bullets, 2 paragraphs = 83% bullet ratio
292        // All bullets have imperative verbs
293        // No vague language
294        let spec = Spec {
295            id: "test".to_string(),
296            frontmatter: SpecFrontmatter::default(),
297            title: Some("Test".to_string()),
298            body: r#"
299## Acceptance Criteria
300
301- [ ] Implement feature A
302- [ ] Add functionality B
303- [ ] Create component C
304- [ ] Update module D
305- [ ] Fix bug E
306- [ ] Remove deprecated code
307- [ ] Test the implementation
308- [ ] Verify the results
309- [ ] Document the changes
310- [ ] Deploy to production
311
312Some paragraph here.
313Another paragraph here.
314"#
315            .to_string(),
316        };
317
318        let config = make_config();
319        assert_eq!(calculate_confidence(&spec, &config), ConfidenceGrade::A);
320    }
321
322    #[test]
323    fn test_grade_b_medium_bullet_ratio_few_vague() {
324        // 5 bullets, 5 paragraphs = 50% bullet ratio (need >50%)
325        // Actually 6 bullets, 4 paragraphs = 60%
326        let spec = Spec {
327            id: "test".to_string(),
328            frontmatter: SpecFrontmatter::default(),
329            title: Some("Test".to_string()),
330            body: r#"
331## Acceptance Criteria
332
333- [ ] Implement feature A
334- [ ] Add functionality B
335- [ ] Create component C
336- [ ] Update module D
337- [ ] Fix bug E
338- [ ] Deploy to production
339
340Some paragraph here.
341Another paragraph here.
342Third paragraph.
343Fourth paragraph with improve here.
344"#
345            .to_string(),
346        };
347
348        let config = make_config();
349        assert_eq!(calculate_confidence(&spec, &config), ConfidenceGrade::B);
350    }
351
352    #[test]
353    fn test_grade_d_low_bullet_ratio() {
354        // 1 bullet, 10 paragraphs = ~9% bullet ratio (< 20%)
355        let spec = Spec {
356            id: "test".to_string(),
357            frontmatter: SpecFrontmatter::default(),
358            title: Some("Test".to_string()),
359            body: r#"
360This is a wall of prose.
361It has many paragraphs.
362But very few bullets.
363This makes it hard to understand.
364Requirements should be clear.
365Bullets help with clarity.
366Paragraphs can be ambiguous.
367We need more structure.
368This spec is poorly written.
369It will get a low grade.
370
371- [ ] Implement something
372"#
373            .to_string(),
374        };
375
376        let config = make_config();
377        assert_eq!(calculate_confidence(&spec, &config), ConfidenceGrade::D);
378    }
379
380    #[test]
381    fn test_grade_d_many_vague_patterns() {
382        // Even with good structure, >5 vague patterns → Grade D
383        let spec = Spec {
384            id: "test".to_string(),
385            frontmatter: SpecFrontmatter::default(),
386            title: Some("Test".to_string()),
387            body: r#"
388## Acceptance Criteria
389
390- [ ] Improve performance as needed
391- [ ] Add features and related functionality
392- [ ] Create tests etc
393- [ ] Update components as needed
394- [ ] Fix bugs and related issues
395- [ ] Similar improvements needed
396"#
397            .to_string(),
398        };
399
400        let config = make_config();
401        assert_eq!(calculate_confidence(&spec, &config), ConfidenceGrade::D);
402    }
403
404    #[test]
405    fn test_count_bullets_and_paragraphs() {
406        let body = r#"
407This is a paragraph.
408
409- [ ] This is a bullet
410- [ ] Another bullet
411
412Another paragraph here.
413
414- This is also a bullet
415
416# This is a heading (not counted)
417
418Final paragraph.
419"#;
420
421        let (bullets, paragraphs) = count_bullets_and_paragraphs(body);
422        assert_eq!(bullets, 3);
423        assert_eq!(paragraphs, 3); // Three paragraph lines
424    }
425
426    #[test]
427    fn test_empty_bullets_not_counted() {
428        let body = r#"
429- [ ] Valid bullet
430-
431- [ ] Another valid bullet
432"#;
433
434        let (bullets, paragraphs) = count_bullets_and_paragraphs(body);
435        assert_eq!(bullets, 2); // Empty bullet not counted
436        assert_eq!(paragraphs, 0);
437    }
438
439    #[test]
440    fn test_count_bullets_with_imperative_verbs() {
441        let body = r#"
442- [ ] Implement feature A
443- [ ] Add functionality B
444- [ ] This does not start with a verb
445- [ ] Create component C
446- Update something without checkbox
447"#;
448
449        let count = count_bullets_with_imperative_verbs(body);
450        assert_eq!(count, 4); // implement, add, create, update
451    }
452
453    #[test]
454    fn test_code_blocks_ignored() {
455        let body = r#"
456- [ ] Implement feature
457
458```rust
459// This is code, not a bullet
460- This looks like a bullet but it's in a code block
461```
462
463- [ ] Add another feature
464"#;
465
466        let (bullets, _) = count_bullets_and_paragraphs(body);
467        assert_eq!(bullets, 2); // Only the two outside code blocks
468    }
469
470    #[test]
471    fn test_case_insensitive_verb_matching() {
472        let body = r#"
473- [ ] IMPLEMENT feature
474- [ ] Add functionality
475- [ ] CrEaTe component
476"#;
477
478        let count = count_bullets_with_imperative_verbs(body);
479        assert_eq!(count, 3); // All should match case-insensitively
480    }
481
482    #[test]
483    fn test_grade_c_with_some_vague_patterns() {
484        // Low-medium bullet ratio, some verbs, 3-5 vague patterns
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}