Skip to main content

rac_engine/
intent.rs

1//! Deterministic intent analysis (`decided.services.intent`): pure, explainable
2//! checks over a `RepositoryComparison` — measurable requirements becoming
3//! vague, mandatory language weakening, ambiguous wording arriving,
4//! acceptance criteria / success measures disappearing, relationship
5//! impact, and new scope without supporting context. Token-boundary text
6//! matching and parsed-section comparison only; no semantic scoring.
7
8use std::collections::HashMap;
9
10use crate::compare::{ArtifactChange, RepoState, RepositoryComparison, CHANGE_ADDED, CHANGE_MODIFIED, CHANGE_REMOVED};
11use crate::parse::Artifact;
12use crate::pycompat::{is_re_digit, is_re_word, py_strip};
13
14// Stable finding codes (part of the watchkeeper JSON contract, ADR-007).
15pub const SPECIFICITY_REGRESSION: &str = "specificity_regression";
16pub const AMBIGUITY_INTRODUCED: &str = "ambiguity_introduced";
17pub const CONSTRAINT_WEAKENED: &str = "constraint_weakened";
18pub const CONSTRAINT_REMOVED: &str = "constraint_removed";
19pub const ACCEPTANCE_CRITERIA_REMOVED: &str = "acceptance_criteria_removed";
20pub const SUCCESS_MEASURES_REMOVED: &str = "success_measures_removed";
21pub const RELATIONSHIP_IMPACT: &str = "relationship_impact";
22pub const UNLINKED_SCOPE: &str = "unlinked_scope";
23
24pub const SEVERITY_WARNING: &str = "warning";
25pub const SEVERITY_INFO: &str = "info";
26
27/// Pinned by the v0.12.1 implementation contract. Kept in the SORTED order
28/// `_ambiguous_terms` reports (the oracle sorts the matching subset of its
29/// frozenset).
30const AMBIGUITY_TERMS: [&str; 10] = [
31    "easy",
32    "fast",
33    "flexible",
34    "intuitive",
35    "quickly",
36    "robust",
37    "scalable",
38    "seamless",
39    "simple",
40    "user-friendly",
41];
42const MANDATORY_TERMS: [&str; 2] = ["must", "shall"];
43const HEDGE_TERMS: [&str; 3] = ["should", "may", "could"];
44
45// Normalized section headings (Product.sections keys are casefolded).
46const ACCEPTANCE_SECTIONS: [&str; 1] = ["acceptance criteria"];
47const SUCCESS_SECTIONS: [&str; 2] = ["success measures", "success metrics"];
48
49/// One deterministic intent finding about a compared change.
50#[derive(Debug, Clone)]
51pub struct IntentFinding {
52    pub code: &'static str,
53    pub severity: &'static str, // SEVERITY_WARNING | SEVERITY_INFO
54    pub path: String,           // corpus-relative (head side; base side for removals)
55    pub identifier: Option<String>,
56    pub detail: String, // one deterministic human sentence
57    pub evidence: Vec<String>,
58}
59
60/// `re.search(rf"\b{re.escape(token)}\b", text, re.IGNORECASE)` for the
61/// pinned ASCII vocabulary: word boundaries via the Python `\w` table,
62/// ASCII-case-insensitive character match (the vocabulary has no non-ASCII
63/// case pairs to worry about).
64fn has_token(text: &str, token: &str) -> bool {
65    let chars: Vec<char> = text.chars().collect();
66    let tok: Vec<char> = token.chars().collect();
67    let (n, m) = (chars.len(), tok.len());
68    if m == 0 || m > n {
69        return false;
70    }
71    for i in 0..=(n - m) {
72        if i > 0 && is_re_word(chars[i - 1]) {
73            continue; // no leading word boundary here
74        }
75        let matched = (0..m).all(|k| {
76            let tc = chars[i + k];
77            let wc = tok[k];
78            tc == wc || (tc.is_ascii_alphabetic() && tc.to_ascii_lowercase() == wc)
79        });
80        if matched && (i + m == n || !is_re_word(chars[i + m])) {
81            return true;
82        }
83    }
84    false
85}
86
87/// Matching ambiguity terms, sorted (the const table is pre-sorted).
88fn ambiguous_terms(text: &str) -> Vec<&'static str> {
89    AMBIGUITY_TERMS
90        .iter()
91        .copied()
92        .filter(|term| has_token(text, term))
93        .collect()
94}
95
96fn has_digit(text: &str) -> bool {
97    text.chars().any(is_re_digit)
98}
99
100fn has_mandatory(text: &str) -> bool {
101    MANDATORY_TERMS.iter().any(|t| has_token(text, t))
102}
103
104fn has_hedge(text: &str) -> bool {
105    HEDGE_TERMS.iter().any(|t| has_token(text, t))
106}
107
108fn section_filled(artifact: &Artifact, headings: &[&str]) -> bool {
109    headings
110        .iter()
111        .any(|h| !py_strip(artifact.section(h).unwrap_or("")).is_empty())
112}
113
114fn quoted_join(terms: &[&str]) -> String {
115    terms
116        .iter()
117        .map(|t| format!("'{t}'"))
118        .collect::<Vec<_>>()
119        .join(", ")
120}
121
122fn modified_findings(
123    change: &ArtifactChange,
124    base: &Artifact,
125    head: &Artifact,
126) -> Vec<IntentFinding> {
127    let mut findings: Vec<IntentFinding> = Vec::new();
128    let empty = crate::diff::Diff::default();
129    let diff = change.diff.as_ref().unwrap_or(&empty);
130
131    for req_change in &diff.modified_requirements {
132        let evidence = vec![
133            format!("- {}", req_change.old_text),
134            format!("+ {}", req_change.new_text),
135        ];
136        if has_digit(&req_change.old_text) && !has_digit(&req_change.new_text) {
137            findings.push(IntentFinding {
138                code: SPECIFICITY_REGRESSION,
139                severity: SEVERITY_WARNING,
140                path: change.path.clone(),
141                identifier: change.id.clone(),
142                detail: format!("Measurable requirement {} became vague.", req_change.id),
143                evidence: evidence.clone(),
144            });
145        }
146        let new_terms: Vec<&str> = ambiguous_terms(&req_change.new_text)
147            .into_iter()
148            .filter(|term| !has_token(&req_change.old_text, term))
149            .collect();
150        if !new_terms.is_empty() {
151            findings.push(IntentFinding {
152                code: AMBIGUITY_INTRODUCED,
153                severity: SEVERITY_WARNING,
154                path: change.path.clone(),
155                identifier: change.id.clone(),
156                detail: format!(
157                    "Ambiguous wording introduced in {}: {}.",
158                    req_change.id,
159                    quoted_join(&new_terms)
160                ),
161                evidence: evidence.clone(),
162            });
163        }
164        if has_mandatory(&req_change.old_text)
165            && !has_mandatory(&req_change.new_text)
166            && has_hedge(&req_change.new_text)
167        {
168            findings.push(IntentFinding {
169                code: CONSTRAINT_WEAKENED,
170                severity: SEVERITY_WARNING,
171                path: change.path.clone(),
172                identifier: change.id.clone(),
173                detail: format!(
174                    "Mandatory requirement {} weakened to hedged wording.",
175                    req_change.id
176                ),
177                evidence,
178            });
179        }
180    }
181
182    for removed in &diff.removed_requirements {
183        if has_mandatory(&removed.text) {
184            findings.push(IntentFinding {
185                code: CONSTRAINT_REMOVED,
186                severity: SEVERITY_WARNING,
187                path: change.path.clone(),
188                identifier: change.id.clone(),
189                detail: format!("Requirement {} with mandatory wording removed.", removed.id),
190                evidence: vec![format!("- {}", removed.text)],
191            });
192        }
193    }
194
195    if section_filled(base, &ACCEPTANCE_SECTIONS) && !section_filled(head, &ACCEPTANCE_SECTIONS) {
196        findings.push(IntentFinding {
197            code: ACCEPTANCE_CRITERIA_REMOVED,
198            severity: SEVERITY_WARNING,
199            path: change.path.clone(),
200            identifier: change.id.clone(),
201            detail: "Acceptance criteria section removed.".to_string(),
202            evidence: Vec::new(),
203        });
204    }
205    if section_filled(base, &SUCCESS_SECTIONS) && !section_filled(head, &SUCCESS_SECTIONS) {
206        findings.push(IntentFinding {
207            code: SUCCESS_MEASURES_REMOVED,
208            severity: SEVERITY_WARNING,
209            path: change.path.clone(),
210            identifier: change.id.clone(),
211            detail: "Success measures section removed.".to_string(),
212            evidence: Vec::new(),
213        });
214    }
215
216    findings
217}
218
219fn removed_findings(change: &ArtifactChange, base: &RepoState) -> Vec<IntentFinding> {
220    let mut findings = Vec::new();
221    if let Some(entry) = base.entry(&change.path) {
222        for requirement in &entry.artifact.product.requirements {
223            if has_mandatory(&requirement.text) {
224                findings.push(IntentFinding {
225                    code: CONSTRAINT_REMOVED,
226                    severity: SEVERITY_WARNING,
227                    path: change.path.clone(),
228                    identifier: change.id.clone(),
229                    detail: format!(
230                        "Requirement {} with mandatory wording removed.",
231                        requirement.id
232                    ),
233                    evidence: vec![format!("- {}", requirement.text)],
234                });
235            }
236        }
237    }
238    findings
239}
240
241fn added_findings(change: &ArtifactChange, head: &RepoState) -> Vec<IntentFinding> {
242    let mut findings = Vec::new();
243    if let Some(entry) = head.entry(&change.path) {
244        for requirement in &entry.artifact.product.requirements {
245            let terms = ambiguous_terms(&requirement.text);
246            if !terms.is_empty() {
247                findings.push(IntentFinding {
248                    code: AMBIGUITY_INTRODUCED,
249                    severity: SEVERITY_WARNING,
250                    path: change.path.clone(),
251                    identifier: change.id.clone(),
252                    detail: format!(
253                        "Ambiguous wording introduced in {}: {}.",
254                        requirement.id,
255                        quoted_join(&terms)
256                    ),
257                    evidence: vec![format!("+ {}", requirement.text)],
258                });
259            }
260        }
261    }
262    findings
263}
264
265/// Incoming references (target rel-path -> source ids, in relationship
266/// order) and the set of rel-paths that declare any outgoing target.
267fn reference_maps(state: &RepoState) -> (HashMap<String, Vec<String>>, Vec<String>) {
268    let mut incoming: HashMap<String, Vec<String>> = HashMap::new();
269    let mut outgoing: Vec<String> = Vec::new();
270    for relationship in &state.relationships {
271        let source_rel = state.rel_of(&relationship.source_path);
272        if !outgoing.contains(&source_rel) {
273            outgoing.push(source_rel.clone());
274        }
275        if let Some(resolved) = &relationship.resolved_path {
276            let target_rel = state.rel_of(resolved);
277            let source_id = state
278                .entry(&source_rel)
279                .map(|e| e.info.id.clone())
280                .unwrap_or_else(|| source_rel.clone());
281            incoming.entry(target_rel).or_default().push(source_id);
282        }
283    }
284    (incoming, outgoing)
285}
286
287fn impact_finding(
288    change: &ArtifactChange,
289    incoming: &HashMap<String, Vec<String>>,
290    verb: &str,
291) -> Option<IntentFinding> {
292    let mut sources: Vec<String> = incoming.get(&change.path).cloned().unwrap_or_default();
293    sources.sort();
294    sources.dedup();
295    if sources.is_empty() {
296        return None;
297    }
298    Some(IntentFinding {
299        code: RELATIONSHIP_IMPACT,
300        severity: SEVERITY_INFO,
301        path: change.path.clone(),
302        identifier: change.id.clone(),
303        detail: format!(
304            "{verb} artifact is referenced by {} artifact(s).",
305            sources.len()
306        ),
307        evidence: sources,
308    })
309}
310
311/// `analyze_intent(comparison)` — deterministic, stably ordered findings.
312pub fn analyze_intent(comparison: &RepositoryComparison) -> Vec<IntentFinding> {
313    let mut findings: Vec<IntentFinding> = Vec::new();
314    let (base_incoming, _) = reference_maps(&comparison.base);
315    let (head_incoming, head_outgoing) = reference_maps(&comparison.head);
316
317    for change in &comparison.changes {
318        if change.change == CHANGE_MODIFIED {
319            let base_product = comparison.base.entry(&change.path).map(|e| &e.artifact);
320            let head_product = comparison.head.entry(&change.path).map(|e| &e.artifact);
321            if let (Some(base_product), Some(head_product)) = (base_product, head_product) {
322                findings.extend(modified_findings(change, base_product, head_product));
323            }
324            if let Some(impact) = impact_finding(change, &head_incoming, "Modified") {
325                findings.push(impact);
326            }
327        } else if change.change == CHANGE_REMOVED {
328            findings.extend(removed_findings(change, &comparison.base));
329            if let Some(impact) = impact_finding(change, &base_incoming, "Removed") {
330                findings.push(impact);
331            }
332        } else if change.change == CHANGE_ADDED {
333            findings.extend(added_findings(change, &comparison.head));
334            if change.type_name != "unknown"
335                && !head_outgoing.contains(&change.path)
336                && !head_incoming.contains_key(&change.path)
337            {
338                findings.push(IntentFinding {
339                    code: UNLINKED_SCOPE,
340                    severity: SEVERITY_WARNING,
341                    path: change.path.clone(),
342                    identifier: change.id.clone(),
343                    detail: "New artifact declares no relationships and nothing references it."
344                        .to_string(),
345                    evidence: Vec::new(),
346                });
347            }
348        }
349    }
350
351    findings.sort_by(|a, b| {
352        (a.severity != SEVERITY_WARNING, a.code, &a.path, &a.detail).cmp(&(
353            b.severity != SEVERITY_WARNING,
354            b.code,
355            &b.path,
356            &b.detail,
357        ))
358    });
359    findings
360}