Skip to main content

dkp_core/validate/
gate4.rs

1use std::collections::HashSet;
2
3use crate::{
4    pack::loader::Pack,
5    procedures,
6    validate::gates::{CheckResult, GateResult, GateStatus},
7};
8
9const REQUIRED_MACHINE_FILES: &[&str] = &[
10    "system_prompt.md",
11    "glossary.json",
12    "ontology.json",
13    "rules.json",
14    "constraints.json",
15    "retrieval_chunks.jsonl",
16];
17
18/// Gate 4: Machine Usability — required files present, schemas valid, refs resolve.
19pub fn run(pack: &Pack) -> GateResult {
20    let mut checks = Vec::new();
21
22    // Required file presence
23    for file in REQUIRED_MACHINE_FILES {
24        if pack.machine_file(file).exists() {
25            checks.push(CheckResult::pass(format!("machine/{file} present")));
26        } else {
27            checks.push(CheckResult::fail(
28                format!("machine/{file} present"),
29                format!("machine/{file} is required but not found"),
30            ));
31        }
32    }
33
34    // manifest.json present (already opened, so always true if we got here)
35    checks.push(CheckResult::pass("manifest.json present"));
36
37    // Parse each required JSON/JSONL asset — deserialization failure = schema violation
38    for (name, result) in [
39        ("glossary.json", pack.load_glossary().map(|_| ())),
40        ("ontology.json", pack.load_ontology().map(|_| ())),
41        ("rules.json", pack.load_rules().map(|_| ())),
42        ("constraints.json", pack.load_constraints().map(|_| ())),
43        ("retrieval_chunks.jsonl", pack.load_chunks().map(|_| ())),
44    ] {
45        match result {
46            Ok(_) => checks.push(CheckResult::pass(format!("machine/{name} parses"))),
47            Err(e) => checks.push(CheckResult::fail(
48                format!("machine/{name} parses"),
49                e.to_string(),
50            )),
51        }
52    }
53
54    // source_ref resolution against evidence/sources.csv
55    let sources_csv = pack.evidence_file("sources.csv");
56    if sources_csv.exists() {
57        let known_ids: HashSet<String> = std::fs::read_to_string(&sources_csv)
58            .unwrap_or_default()
59            .lines()
60            .skip(1) // header row
61            .filter_map(|line| {
62                line.split(',')
63                    .next()
64                    .map(|s| s.trim().trim_matches('"').to_string())
65            })
66            .filter(|s| !s.is_empty())
67            .collect();
68
69        let mut unresolved: Vec<String> = Vec::new();
70
71        if let Ok(Some(gf)) = pack.load_glossary() {
72            for t in &gf.terms {
73                if let Some(ref sr) = t.source_ref {
74                    if sr != "generated" && !known_ids.contains(sr.as_str()) {
75                        unresolved.push(format!("term/{}: {sr}", t.id));
76                    }
77                }
78            }
79        }
80        if let Ok(Some(rf)) = pack.load_rules() {
81            for r in &rf.rules {
82                if let Some(ref sr) = r.source_ref {
83                    if sr != "generated" && !known_ids.contains(sr.as_str()) {
84                        unresolved.push(format!("rule/{}: {sr}", r.id));
85                    }
86                }
87            }
88        }
89        if let Ok(Some(cf)) = pack.load_constraints() {
90            for c in cf.all_constraints() {
91                if let Some(ref sr) = c.source_ref {
92                    if sr != "generated" && !known_ids.contains(sr.as_str()) {
93                        unresolved.push(format!("constraint/{}: {sr}", c.id));
94                    }
95                }
96            }
97        }
98
99        if unresolved.is_empty() {
100            checks.push(CheckResult::pass("source_ref resolution"));
101        } else {
102            checks.push(CheckResult::fail(
103                "source_ref resolution",
104                format!("unresolved refs: {}", unresolved.join(", ")),
105            ));
106        }
107    }
108
109    // knowledge_graph edge resolution
110    if pack.has_knowledge_graph() {
111        match pack.load_graph() {
112            Ok(Some(graph)) => {
113                let node_ids: HashSet<&str> = graph.nodes.iter().map(|n| n.id.as_str()).collect();
114                let broken: Vec<String> = graph
115                    .edges
116                    .iter()
117                    .filter_map(|e| {
118                        let src_ok = node_ids.contains(e.source.as_str());
119                        let tgt_ok = node_ids.contains(e.target.as_str());
120                        if !src_ok || !tgt_ok {
121                            Some(format!("{}->{}", e.source, e.target))
122                        } else {
123                            None
124                        }
125                    })
126                    .collect();
127                if broken.is_empty() {
128                    checks.push(CheckResult::pass("knowledge_graph edge resolution"));
129                } else {
130                    checks.push(CheckResult::fail(
131                        "knowledge_graph edge resolution",
132                        format!("broken edges: {}", broken.join(", ")),
133                    ));
134                }
135            }
136            Ok(None) => checks.push(CheckResult::skip("knowledge_graph.json (not present)")),
137            Err(e) => checks.push(CheckResult::fail(
138                "knowledge_graph.json parses",
139                e.to_string(),
140            )),
141        }
142    }
143
144    // Procedure completeness when machine/procedures/ is non-empty
145    if pack.has_procedures() {
146        match procedures::validate_all(pack) {
147            Ok(errors) if errors.is_empty() => {
148                checks.push(CheckResult::pass("machine/procedures/ completeness"));
149            }
150            Ok(errors) => {
151                for e in &errors {
152                    checks.push(CheckResult::fail(
153                        "machine/procedures/ completeness",
154                        e.clone(),
155                    ));
156                }
157            }
158            Err(e) => {
159                checks.push(CheckResult::fail(
160                    "machine/procedures/ completeness",
161                    e.to_string(),
162                ));
163            }
164        }
165    }
166
167    // MCP manifest when mcp block is present
168    if pack.manifest.mcp.is_some() {
169        if pack.machine_file("mcp_manifest.json").exists() {
170            checks.push(CheckResult::pass(
171                "machine/mcp_manifest.json present (mcp configured)",
172            ));
173        } else {
174            checks.push(CheckResult::fail(
175                "machine/mcp_manifest.json present (mcp configured)",
176                "manifest.mcp is set but machine/mcp_manifest.json is missing",
177            ));
178        }
179    }
180
181    let failed = checks.iter().any(|c| c.status == GateStatus::Fail);
182    GateResult {
183        gate: 4,
184        status: if failed {
185            GateStatus::Fail
186        } else {
187            GateStatus::Pass
188        },
189        checks,
190        message: None,
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use tempfile::TempDir;
198
199    fn minimal_manifest_json() -> &'static str {
200        r#"{
201            "spec": "1.0.0",
202            "name": "test-pack",
203            "version": "1.0.0",
204            "domain": "testing",
205            "audience": "internal",
206            "intended_use": "unit tests",
207            "known_limitations": "none",
208            "update_date": "2026-01-01"
209        }"#
210    }
211
212    /// Builds a pack directory with all gate 4 required machine files present
213    /// and valid, returning the opened `Pack`.
214    fn complete_pack(tmp: &TempDir) -> Pack {
215        std::fs::write(tmp.path().join("manifest.json"), minimal_manifest_json()).unwrap();
216        let machine = tmp.path().join("machine");
217        std::fs::create_dir_all(&machine).unwrap();
218        std::fs::write(machine.join("system_prompt.md"), "Be helpful.").unwrap();
219        std::fs::write(machine.join("glossary.json"), r#"{"terms": []}"#).unwrap();
220        std::fs::write(machine.join("ontology.json"), r#"{"entity_types": []}"#).unwrap();
221        std::fs::write(machine.join("rules.json"), r#"{"rules": []}"#).unwrap();
222        std::fs::write(
223            machine.join("constraints.json"),
224            r#"{"edge_cases": [], "anti_patterns": [], "hard_limits": []}"#,
225        )
226        .unwrap();
227        std::fs::write(machine.join("retrieval_chunks.jsonl"), "").unwrap();
228        Pack::open(tmp.path()).unwrap()
229    }
230
231    #[test]
232    fn all_required_files_present_and_valid_passes() {
233        let tmp = TempDir::new().unwrap();
234        let pack = complete_pack(&tmp);
235        let result = run(&pack);
236        assert_eq!(result.status, GateStatus::Pass);
237        assert_eq!(result.gate, 4);
238    }
239
240    #[test]
241    fn missing_required_file_fails() {
242        let tmp = TempDir::new().unwrap();
243        let pack = complete_pack(&tmp);
244        std::fs::remove_file(pack.machine_file("glossary.json")).unwrap();
245
246        let result = run(&pack);
247        assert_eq!(result.status, GateStatus::Fail);
248        assert!(result
249            .checks
250            .iter()
251            .any(|c| c.description.contains("glossary.json") && c.status == GateStatus::Fail));
252    }
253
254    #[test]
255    fn invalid_json_in_machine_file_fails() {
256        let tmp = TempDir::new().unwrap();
257        let pack = complete_pack(&tmp);
258        std::fs::write(pack.machine_file("rules.json"), "{ not valid").unwrap();
259
260        let result = run(&pack);
261        assert_eq!(result.status, GateStatus::Fail);
262        assert!(result
263            .checks
264            .iter()
265            .any(|c| c.description.contains("rules.json") && c.status == GateStatus::Fail));
266    }
267
268    #[test]
269    fn sources_csv_absent_skips_source_ref_check() {
270        let tmp = TempDir::new().unwrap();
271        let pack = complete_pack(&tmp);
272        let result = run(&pack);
273        assert!(!result
274            .checks
275            .iter()
276            .any(|c| c.description == "source_ref resolution"));
277    }
278
279    #[test]
280    fn unresolved_source_ref_fails() {
281        let tmp = TempDir::new().unwrap();
282        let pack = complete_pack(&tmp);
283        std::fs::write(
284            pack.machine_file("glossary.json"),
285            r#"{"terms": [{"id": "t1", "term": "Term", "definition": "def", "source_ref": "src-1"}]}"#,
286        )
287        .unwrap();
288        std::fs::create_dir_all(pack.evidence_dir()).unwrap();
289        std::fs::write(pack.evidence_file("sources.csv"), "id,title\n").unwrap();
290
291        let result = run(&pack);
292        assert_eq!(result.status, GateStatus::Fail);
293        assert!(result
294            .checks
295            .iter()
296            .any(|c| c.description == "source_ref resolution" && c.status == GateStatus::Fail));
297    }
298
299    #[test]
300    fn source_ref_generated_is_always_allowed() {
301        let tmp = TempDir::new().unwrap();
302        let pack = complete_pack(&tmp);
303        std::fs::write(
304            pack.machine_file("glossary.json"),
305            r#"{"terms": [{"id": "t1", "term": "Term", "definition": "def", "source_ref": "generated"}]}"#,
306        )
307        .unwrap();
308        std::fs::create_dir_all(pack.evidence_dir()).unwrap();
309        std::fs::write(pack.evidence_file("sources.csv"), "id,title\n").unwrap();
310
311        let result = run(&pack);
312        assert_eq!(result.status, GateStatus::Pass);
313    }
314
315    #[test]
316    fn resolved_source_ref_passes() {
317        let tmp = TempDir::new().unwrap();
318        let pack = complete_pack(&tmp);
319        std::fs::write(
320            pack.machine_file("glossary.json"),
321            r#"{"terms": [{"id": "t1", "term": "Term", "definition": "def", "source_ref": "src-1"}]}"#,
322        )
323        .unwrap();
324        std::fs::create_dir_all(pack.evidence_dir()).unwrap();
325        std::fs::write(
326            pack.evidence_file("sources.csv"),
327            "id,title\nsrc-1,Some Source\n",
328        )
329        .unwrap();
330
331        let result = run(&pack);
332        assert_eq!(result.status, GateStatus::Pass);
333    }
334
335    #[test]
336    fn knowledge_graph_broken_edge_fails() {
337        let tmp = TempDir::new().unwrap();
338        let pack = complete_pack(&tmp);
339        std::fs::write(
340            pack.machine_file("knowledge_graph.json"),
341            r#"{"nodes": [{"id": "n1", "node_type": "concept", "label": "N1"}], "edges": [{"source": "n1", "relation": "see-also", "target": "missing"}]}"#,
342        )
343        .unwrap();
344
345        let result = run(&pack);
346        assert_eq!(result.status, GateStatus::Fail);
347        assert!(result.checks.iter().any(|c| c
348            .description
349            .contains("knowledge_graph edge resolution")
350            && c.status == GateStatus::Fail));
351    }
352
353    #[test]
354    fn knowledge_graph_resolved_edges_pass() {
355        let tmp = TempDir::new().unwrap();
356        let pack = complete_pack(&tmp);
357        std::fs::write(
358            pack.machine_file("knowledge_graph.json"),
359            r#"{"nodes": [{"id": "n1", "node_type": "concept", "label": "N1"}, {"id": "n2", "node_type": "concept", "label": "N2"}], "edges": [{"source": "n1", "relation": "see-also", "target": "n2"}]}"#,
360        )
361        .unwrap();
362
363        let result = run(&pack);
364        assert_eq!(result.status, GateStatus::Pass);
365    }
366}