Skip to main content

ought_analysis/
audit.rs

1use std::collections::HashMap;
2use std::time::Duration;
3
4use ought_spec::{Clause, Keyword, Section, SpecGraph, Temporal};
5
6use crate::types::{AuditFinding, AuditFindingKind, AuditResult};
7
8/// Analyze all specs for contradictions, gaps, and coherence issues.
9///
10/// Detects: contradictory clauses, MUST BY deadline conflicts,
11/// MUST ALWAYS invariant conflicts, overlapping GIVEN conditions
12/// with contradictory obligations, and missing OTHERWISE chains.
13pub fn audit(specs: &SpecGraph) -> anyhow::Result<AuditResult> {
14    let mut findings: Vec<AuditFinding> = Vec::new();
15
16    // Collect all clauses from all specs with their section paths.
17    let mut all_clauses: Vec<ClauseWithContext> = Vec::new();
18    for spec in specs.specs() {
19        collect_clauses_with_context(&spec.sections, &spec.name, &mut all_clauses);
20    }
21
22    // Check 1: MUST BY deadline conflicts.
23    check_deadline_conflicts(&all_clauses, &mut findings);
24
25    // Check 2: MUST ALWAYS invariant conflicts.
26    check_invariant_conflicts(&all_clauses, &mut findings);
27
28    // Check 3: Missing OTHERWISE on network-dependent MUST clauses.
29    check_missing_otherwise(&all_clauses, &mut findings);
30
31    // Check 4: Duplicate/near-duplicate clauses (redundancy).
32    check_redundancy(&all_clauses, &mut findings);
33
34    // Check 5: Overlapping GIVEN conditions with contradictory obligations.
35    check_given_overlaps(&all_clauses, &mut findings);
36
37    Ok(AuditResult { findings })
38}
39
40/// A clause with its section path context for analysis.
41struct ClauseWithContext {
42    clause: Clause,
43    #[allow(dead_code)]
44    section_path: String,
45}
46
47fn collect_clauses_with_context(
48    sections: &[Section],
49    parent_path: &str,
50    out: &mut Vec<ClauseWithContext>,
51) {
52    for section in sections {
53        let section_path = format!("{} > {}", parent_path, section.title);
54        for clause in &section.clauses {
55            // Pending clauses are declared-but-deferred. Audit checks
56            // (contradictions, deadline conflicts, missing OTHERWISE, etc.)
57            // are meaningless for work that hasn't been committed to yet, so
58            // we skip the whole subtree including its OTHERWISE chain.
59            if clause.pending {
60                continue;
61            }
62            out.push(ClauseWithContext {
63                clause: clause.clone(),
64                section_path: section_path.clone(),
65            });
66            // Also collect OTHERWISE children.
67            for ow in &clause.otherwise {
68                out.push(ClauseWithContext {
69                    clause: ow.clone(),
70                    section_path: section_path.clone(),
71                });
72            }
73        }
74        collect_clauses_with_context(&section.subsections, &section_path, out);
75    }
76}
77
78/// Check for MUST BY deadline conflicts: if a parent operation has a deadline
79/// shorter than a sub-operation it calls.
80fn check_deadline_conflicts(clauses: &[ClauseWithContext], findings: &mut Vec<AuditFinding>) {
81    let deadline_clauses: Vec<(&ClauseWithContext, Duration)> = clauses
82        .iter()
83        .filter_map(|c| {
84            if let Some(Temporal::Deadline(dur)) = &c.clause.temporal {
85                Some((c, *dur))
86            } else {
87                None
88            }
89        })
90        .collect();
91
92    // Compare each pair: if they share a section path prefix (implying nesting)
93    // and the parent deadline is shorter, flag it.
94    for i in 0..deadline_clauses.len() {
95        for j in (i + 1)..deadline_clauses.len() {
96            let (a, dur_a) = &deadline_clauses[i];
97            let (b, dur_b) = &deadline_clauses[j];
98
99            // Check if one could be a parent of the other by section path or text reference.
100            let a_text_lower = a.clause.text.to_lowercase();
101            let b_text_lower = b.clause.text.to_lowercase();
102
103            // Heuristic: if clause A's text mentions something clause B specifies (or vice versa).
104            let a_mentions_b = clause_text_overlaps(&a_text_lower, &b_text_lower);
105            let b_mentions_a = clause_text_overlaps(&b_text_lower, &a_text_lower);
106
107            if a_mentions_b && dur_a < dur_b {
108                findings.push(AuditFinding {
109                    kind: AuditFindingKind::Contradiction,
110                    description: format!(
111                        "{} MUST BY {:?} but references {} MUST BY {:?} -- sub-operation deadline exceeds parent deadline",
112                        a.clause.text, dur_a, b.clause.text, dur_b
113                    ),
114                    clauses: vec![a.clause.id.clone(), b.clause.id.clone()],
115                    suggestion: Some(format!(
116                        "Reduce the sub-operation deadline below {:?} or increase the parent deadline",
117                        dur_a
118                    )),
119                    confidence: Some(0.85),
120                });
121            } else if b_mentions_a && dur_b < dur_a {
122                findings.push(AuditFinding {
123                    kind: AuditFindingKind::Contradiction,
124                    description: format!(
125                        "{} MUST BY {:?} but references {} MUST BY {:?} -- sub-operation deadline exceeds parent deadline",
126                        b.clause.text, dur_b, a.clause.text, dur_a
127                    ),
128                    clauses: vec![b.clause.id.clone(), a.clause.id.clone()],
129                    suggestion: Some(format!(
130                        "Reduce the sub-operation deadline below {:?} or increase the parent deadline",
131                        dur_b
132                    )),
133                    confidence: Some(0.85),
134                });
135            }
136        }
137    }
138}
139
140/// Simple word overlap check between two clause texts.
141fn clause_text_overlaps(a: &str, b: &str) -> bool {
142    let a_words: Vec<&str> = a.split_whitespace().filter(|w| w.len() > 3).collect();
143    let b_words: Vec<&str> = b.split_whitespace().filter(|w| w.len() > 3).collect();
144    let overlap = a_words.iter().filter(|w| b_words.contains(w)).count();
145    overlap >= 2
146}
147
148/// Check for MUST ALWAYS invariant conflicts: two invariants that cannot both hold.
149fn check_invariant_conflicts(clauses: &[ClauseWithContext], findings: &mut Vec<AuditFinding>) {
150    let invariant_clauses: Vec<&ClauseWithContext> = clauses
151        .iter()
152        .filter(|c| matches!(c.clause.temporal, Some(Temporal::Invariant)))
153        .collect();
154
155    for i in 0..invariant_clauses.len() {
156        for j in (i + 1)..invariant_clauses.len() {
157            let a = invariant_clauses[i];
158            let b = invariant_clauses[j];
159
160            // Check if they have contradictory keywords or opposing text.
161            let contradicts = is_contradictory(&a.clause, &b.clause);
162            if contradicts {
163                findings.push(AuditFinding {
164                    kind: AuditFindingKind::Contradiction,
165                    description: format!(
166                        "MUST ALWAYS {} conflicts with MUST ALWAYS {}",
167                        a.clause.text, b.clause.text
168                    ),
169                    clauses: vec![a.clause.id.clone(), b.clause.id.clone()],
170                    suggestion: Some(
171                        "Reconcile invariants by choosing one consistent model".to_string(),
172                    ),
173                    confidence: Some(0.80),
174                });
175            }
176        }
177    }
178}
179
180/// Heuristic: check if two clauses are contradictory.
181fn is_contradictory(a: &Clause, b: &Clause) -> bool {
182    // Different polarity keywords on similar topics.
183    let a_positive = matches!(
184        a.keyword,
185        Keyword::Must | Keyword::Should | Keyword::MustAlways | Keyword::MustBy
186    );
187    let b_positive = matches!(
188        b.keyword,
189        Keyword::Must | Keyword::Should | Keyword::MustAlways | Keyword::MustBy
190    );
191    let a_negative = matches!(a.keyword, Keyword::MustNot | Keyword::ShouldNot | Keyword::Wont);
192    let b_negative = matches!(b.keyword, Keyword::MustNot | Keyword::ShouldNot | Keyword::Wont);
193
194    // If one is positive and the other negative on similar text.
195    if (a_positive && b_negative) || (a_negative && b_positive) {
196        let overlap = clause_text_overlaps(
197            &a.text.to_lowercase(),
198            &b.text.to_lowercase(),
199        );
200        if overlap {
201            return true;
202        }
203    }
204
205    // Check for opposing terms in text.
206    let a_lower = a.text.to_lowercase();
207    let b_lower = b.text.to_lowercase();
208
209    let opposites = [
210        ("single", "multiple"),
211        ("one", "many"),
212        ("exactly one", "concurrent"),
213        ("block", "allow"),
214        ("deny", "permit"),
215        ("reject", "accept"),
216        ("disable", "enable"),
217        ("synchronous", "asynchronous"),
218    ];
219
220    for (pos, neg) in &opposites {
221        if (a_lower.contains(pos) && b_lower.contains(neg))
222            || (a_lower.contains(neg) && b_lower.contains(pos))
223        {
224            // Check they're about the same topic.
225            if clause_text_overlaps(&a_lower, &b_lower) {
226                return true;
227            }
228        }
229    }
230
231    false
232}
233
234/// Check for MUST clauses that mention network/remote operations without OTHERWISE.
235fn check_missing_otherwise(clauses: &[ClauseWithContext], findings: &mut Vec<AuditFinding>) {
236    let network_hints = [
237        "request", "api", "fetch", "remote", "server", "http", "endpoint", "network", "download",
238        "upload", "connect", "socket", "tcp", "udp", "grpc", "webhook",
239    ];
240
241    for c in clauses {
242        if c.clause.keyword != Keyword::Must && c.clause.keyword != Keyword::MustBy {
243            continue;
244        }
245        if !c.clause.otherwise.is_empty() {
246            continue;
247        }
248        let text_lower = c.clause.text.to_lowercase();
249        let is_network = network_hints.iter().any(|h| text_lower.contains(h));
250        if is_network {
251            findings.push(AuditFinding {
252                kind: AuditFindingKind::Gap,
253                description: format!(
254                    "MUST {} has no OTHERWISE fallback but appears to depend on network/remote operations",
255                    c.clause.text
256                ),
257                clauses: vec![c.clause.id.clone()],
258                suggestion: Some(
259                    "Add an OTHERWISE clause specifying fallback behavior when the network operation fails"
260                        .to_string(),
261                ),
262                confidence: Some(0.75),
263            });
264        }
265    }
266}
267
268/// Check for redundant/near-duplicate clauses.
269fn check_redundancy(clauses: &[ClauseWithContext], findings: &mut Vec<AuditFinding>) {
270    for i in 0..clauses.len() {
271        for j in (i + 1)..clauses.len() {
272            let a = &clauses[i];
273            let b = &clauses[j];
274
275            // Skip Otherwise children compared to their parent.
276            if a.clause.keyword == Keyword::Otherwise || b.clause.keyword == Keyword::Otherwise {
277                continue;
278            }
279
280            let similarity = text_similarity(&a.clause.text, &b.clause.text);
281            if similarity > 0.8 && a.clause.keyword == b.clause.keyword {
282                findings.push(AuditFinding {
283                    kind: AuditFindingKind::Redundancy,
284                    description: format!(
285                        "Clauses appear to express the same obligation: \"{}\" and \"{}\"",
286                        a.clause.text, b.clause.text
287                    ),
288                    clauses: vec![a.clause.id.clone(), b.clause.id.clone()],
289                    suggestion: Some("Consider merging these clauses into one".to_string()),
290                    confidence: Some(similarity),
291                });
292            }
293        }
294    }
295}
296
297/// Simple Jaccard similarity on word sets.
298fn text_similarity(a: &str, b: &str) -> f64 {
299    let a_owned: std::collections::HashSet<String> = a
300        .to_lowercase()
301        .split_whitespace()
302        .filter(|w| w.len() > 2)
303        .map(|w| w.to_string())
304        .collect();
305    let b_owned: std::collections::HashSet<String> = b
306        .to_lowercase()
307        .split_whitespace()
308        .filter(|w| w.len() > 2)
309        .map(|w| w.to_string())
310        .collect();
311
312    if a_owned.is_empty() && b_owned.is_empty() {
313        return 1.0;
314    }
315
316    let intersection = a_owned.intersection(&b_owned).count();
317    let union = a_owned.union(&b_owned).count();
318
319    if union == 0 {
320        return 0.0;
321    }
322
323    intersection as f64 / union as f64
324}
325
326/// Check for overlapping GIVEN conditions with contradictory obligations.
327fn check_given_overlaps(clauses: &[ClauseWithContext], findings: &mut Vec<AuditFinding>) {
328    // Group clauses by their GIVEN condition.
329    let mut given_groups: HashMap<String, Vec<&ClauseWithContext>> = HashMap::new();
330
331    for c in clauses {
332        if let Some(ref condition) = c.clause.condition {
333            given_groups
334                .entry(condition.to_lowercase())
335                .or_default()
336                .push(c);
337        }
338    }
339
340    // For clauses under different GIVEN conditions, check if the conditions overlap
341    // and the obligations contradict.
342    let conditions: Vec<String> = given_groups.keys().cloned().collect();
343    for i in 0..conditions.len() {
344        for j in (i + 1)..conditions.len() {
345            let cond_a = &conditions[i];
346            let cond_b = &conditions[j];
347
348            // Check if conditions might overlap (heuristic: shared significant words).
349            if clause_text_overlaps(cond_a, cond_b) {
350                let clauses_a = &given_groups[cond_a];
351                let clauses_b = &given_groups[cond_b];
352
353                for ca in clauses_a {
354                    for cb in clauses_b {
355                        if is_contradictory(&ca.clause, &cb.clause) {
356                            findings.push(AuditFinding {
357                                kind: AuditFindingKind::Contradiction,
358                                description: format!(
359                                    "GIVEN {} ({}) overlaps with GIVEN {} ({}) -- contradictory obligations",
360                                    cond_a, ca.clause.text, cond_b, cb.clause.text
361                                ),
362                                clauses: vec![ca.clause.id.clone(), cb.clause.id.clone()],
363                                suggestion: Some(
364                                    "Make GIVEN conditions mutually exclusive or add explicit precedence rules"
365                                        .to_string(),
366                                ),
367                                confidence: Some(0.70),
368                            });
369                        }
370                    }
371                }
372            }
373        }
374    }
375}