Skip to main content

datasynth_audit_optimizer/
constrained.rs

1//! Constraint-based path optimization.
2
3use std::collections::{HashMap, HashSet};
4
5use serde::Serialize;
6
7use datasynth_audit_fsm::schema::AuditBlueprint;
8
9use crate::shortest_path::{analyze_shortest_paths, ProcedurePath, ShortestPathReport};
10
11// ---------------------------------------------------------------------------
12// Result type
13// ---------------------------------------------------------------------------
14
15/// Output of a constrained-path analysis.
16#[derive(Debug, Clone, Serialize)]
17pub struct ConstrainedPathResult {
18    /// The full set of required procedure ids (must-visit + transitive
19    /// preconditions), sorted for deterministic output.
20    pub required_procedures: Vec<String>,
21    /// Total transitions across all required-procedure paths.
22    pub total_transitions: usize,
23    /// Shortest-path report filtered to only the required procedures.
24    pub paths: ShortestPathReport,
25}
26
27// ---------------------------------------------------------------------------
28// Analysis
29// ---------------------------------------------------------------------------
30
31/// Compute the shortest paths for a constrained set of procedures.
32///
33/// Starting from `must_visit`, the function expands the required set by
34/// following `preconditions` transitively (DFS / BFS over the precondition
35/// graph).  It then delegates to [`analyze_shortest_paths`] for the full
36/// blueprint and returns only the entries that belong to the required set.
37///
38/// # Arguments
39/// * `blueprint`      – The audit blueprint to analyse.
40/// * `must_visit`     – Procedure ids that must be covered.
41/// * `preconditions`  – Map from procedure id to its direct preconditions.
42pub fn constrained_path(
43    blueprint: &AuditBlueprint,
44    must_visit: &[String],
45    preconditions: &HashMap<String, Vec<String>>,
46) -> ConstrainedPathResult {
47    // ------------------------------------------------------------------
48    // Expand must-visit with transitive preconditions (iterative DFS).
49    // ------------------------------------------------------------------
50    let mut required: HashSet<String> = must_visit.iter().cloned().collect();
51    let mut queue: Vec<String> = must_visit.to_vec();
52
53    while let Some(proc_id) = queue.pop() {
54        if let Some(deps) = preconditions.get(&proc_id) {
55            for dep in deps {
56                if required.insert(dep.clone()) {
57                    queue.push(dep.clone());
58                }
59            }
60        }
61    }
62
63    // ------------------------------------------------------------------
64    // Run full shortest-path analysis and filter to the required set.
65    // ------------------------------------------------------------------
66    let full_paths = analyze_shortest_paths(blueprint);
67    let filtered: HashMap<String, ProcedurePath> = full_paths
68        .procedure_paths
69        .into_iter()
70        .filter(|(k, _)| required.contains(k))
71        .collect();
72
73    let total = filtered.values().map(|p| p.transition_count).sum();
74
75    let mut required_sorted: Vec<String> = required.into_iter().collect();
76    required_sorted.sort();
77
78    ConstrainedPathResult {
79        required_procedures: required_sorted,
80        total_transitions: total,
81        paths: ShortestPathReport {
82            procedure_paths: filtered,
83            total_minimum_transitions: total,
84        },
85    }
86}
87
88// ---------------------------------------------------------------------------
89// Tests
90// ---------------------------------------------------------------------------
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use datasynth_audit_fsm::loader::BlueprintWithPreconditions;
96
97    fn load_fsa() -> BlueprintWithPreconditions {
98        BlueprintWithPreconditions::load_builtin_fsa().expect("builtin FSA blueprint should load")
99    }
100
101    /// `form_opinion` depends on `going_concern` and `subsequent_events`.
102    /// Constraining to `["form_opinion"]` must therefore include all three.
103    #[test]
104    fn test_constrained_path_expands_preconditions() {
105        let bwp = load_fsa();
106        let must_visit = vec!["form_opinion".to_string()];
107
108        let result = constrained_path(&bwp.blueprint, &must_visit, &bwp.preconditions);
109
110        assert!(
111            result
112                .required_procedures
113                .contains(&"form_opinion".to_string()),
114            "form_opinion must be in required_procedures"
115        );
116        assert!(
117            result
118                .required_procedures
119                .contains(&"going_concern".to_string()),
120            "going_concern must be transitively included"
121        );
122        assert!(
123            result
124                .required_procedures
125                .contains(&"subsequent_events".to_string()),
126            "subsequent_events must be transitively included"
127        );
128        assert!(
129            result.required_procedures.len() >= 3,
130            "expected at least 3 required procedures, got {}",
131            result.required_procedures.len()
132        );
133    }
134
135    #[test]
136    fn test_constrained_path_paths_are_filtered() {
137        let bwp = load_fsa();
138        let must_visit = vec!["form_opinion".to_string()];
139
140        let result = constrained_path(&bwp.blueprint, &must_visit, &bwp.preconditions);
141
142        // Every key in the paths map must appear in required_procedures.
143        for key in result.paths.procedure_paths.keys() {
144            assert!(
145                result.required_procedures.contains(key),
146                "path key '{}' is not in required_procedures",
147                key
148            );
149        }
150    }
151
152    #[test]
153    fn test_constrained_path_total_transitions_consistent() {
154        let bwp = load_fsa();
155        let must_visit = vec!["form_opinion".to_string()];
156
157        let result = constrained_path(&bwp.blueprint, &must_visit, &bwp.preconditions);
158
159        let expected_total: usize = result
160            .paths
161            .procedure_paths
162            .values()
163            .map(|p| p.transition_count)
164            .sum();
165        assert_eq!(
166            result.total_transitions, expected_total,
167            "total_transitions should equal sum of per-procedure transition counts"
168        );
169        assert_eq!(
170            result.paths.total_minimum_transitions, expected_total,
171            "ShortestPathReport.total_minimum_transitions should match"
172        );
173    }
174
175    #[test]
176    fn test_constrained_path_empty_must_visit() {
177        let bwp = load_fsa();
178        let result = constrained_path(&bwp.blueprint, &[], &bwp.preconditions);
179
180        assert!(
181            result.required_procedures.is_empty(),
182            "empty must_visit should produce empty required_procedures"
183        );
184        assert_eq!(result.total_transitions, 0);
185    }
186
187    #[test]
188    fn test_constrained_path_serializes() {
189        let bwp = load_fsa();
190        let must_visit = vec!["form_opinion".to_string()];
191        let result = constrained_path(&bwp.blueprint, &must_visit, &bwp.preconditions);
192
193        let json = serde_json::to_string(&result).expect("should serialize to JSON");
194        assert!(json.contains("required_procedures"));
195        assert!(json.contains("total_transitions"));
196    }
197}