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                    && sr != "generated"
75                    && !known_ids.contains(sr.as_str())
76                {
77                    unresolved.push(format!("term/{}: {sr}", t.id));
78                }
79            }
80        }
81        if let Ok(Some(rf)) = pack.load_rules() {
82            for r in &rf.rules {
83                if let Some(ref sr) = r.source_ref
84                    && sr != "generated"
85                    && !known_ids.contains(sr.as_str())
86                {
87                    unresolved.push(format!("rule/{}: {sr}", r.id));
88                }
89            }
90        }
91        if let Ok(Some(cf)) = pack.load_constraints() {
92            for c in cf.all_constraints() {
93                if let Some(ref sr) = c.source_ref
94                    && sr != "generated"
95                    && !known_ids.contains(sr.as_str())
96                {
97                    unresolved.push(format!("constraint/{}: {sr}", c.id));
98                }
99            }
100        }
101
102        if unresolved.is_empty() {
103            checks.push(CheckResult::pass("source_ref resolution"));
104        } else {
105            checks.push(CheckResult::fail(
106                "source_ref resolution",
107                format!("unresolved refs: {}", unresolved.join(", ")),
108            ));
109        }
110    }
111
112    // knowledge_graph edge resolution
113    if pack.has_knowledge_graph() {
114        match pack.load_graph() {
115            Ok(Some(graph)) => {
116                let node_ids: HashSet<&str> = graph.nodes.iter().map(|n| n.id.as_str()).collect();
117                let broken: Vec<String> = graph
118                    .edges
119                    .iter()
120                    .filter_map(|e| {
121                        let src_ok = node_ids.contains(e.source.as_str());
122                        let tgt_ok = node_ids.contains(e.target.as_str());
123                        if !src_ok || !tgt_ok {
124                            Some(format!("{}->{}", e.source, e.target))
125                        } else {
126                            None
127                        }
128                    })
129                    .collect();
130                if broken.is_empty() {
131                    checks.push(CheckResult::pass("knowledge_graph edge resolution"));
132                } else {
133                    checks.push(CheckResult::fail(
134                        "knowledge_graph edge resolution",
135                        format!("broken edges: {}", broken.join(", ")),
136                    ));
137                }
138            }
139            Ok(None) => checks.push(CheckResult::skip("knowledge_graph.json (not present)")),
140            Err(e) => checks.push(CheckResult::fail(
141                "knowledge_graph.json parses",
142                e.to_string(),
143            )),
144        }
145    }
146
147    // Procedure completeness when machine/procedures/ is non-empty
148    if pack.has_procedures() {
149        match procedures::validate_all(pack) {
150            Ok(errors) if errors.is_empty() => {
151                checks.push(CheckResult::pass("machine/procedures/ completeness"));
152            }
153            Ok(errors) => {
154                for e in &errors {
155                    checks.push(CheckResult::fail(
156                        "machine/procedures/ completeness",
157                        e.clone(),
158                    ));
159                }
160            }
161            Err(e) => {
162                checks.push(CheckResult::fail(
163                    "machine/procedures/ completeness",
164                    e.to_string(),
165                ));
166            }
167        }
168    }
169
170    // MCP manifest when mcp block is present
171    if pack.manifest.mcp.is_some() {
172        if pack.machine_file("mcp_manifest.json").exists() {
173            checks.push(CheckResult::pass(
174                "machine/mcp_manifest.json present (mcp configured)",
175            ));
176        } else {
177            checks.push(CheckResult::fail(
178                "machine/mcp_manifest.json present (mcp configured)",
179                "manifest.mcp is set but machine/mcp_manifest.json is missing",
180            ));
181        }
182    }
183
184    let failed = checks.iter().any(|c| c.status == GateStatus::Fail);
185    GateResult {
186        gate: 4,
187        status: if failed {
188            GateStatus::Fail
189        } else {
190            GateStatus::Pass
191        },
192        checks,
193        message: None,
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use tempfile::TempDir;
201
202    fn minimal_manifest_json() -> &'static str {
203        r#"{
204            "spec": "1.0.0",
205            "name": "test-pack",
206            "version": "1.0.0",
207            "domain": "testing",
208            "audience": "internal",
209            "intended_use": "unit tests",
210            "known_limitations": "none",
211            "update_date": "2026-01-01"
212        }"#
213    }
214
215    /// Builds a pack directory with all gate 4 required machine files present
216    /// and valid, returning the opened `Pack`.
217    fn complete_pack(tmp: &TempDir) -> Pack {
218        std::fs::write(tmp.path().join("manifest.json"), minimal_manifest_json()).unwrap();
219        let machine = tmp.path().join("machine");
220        std::fs::create_dir_all(&machine).unwrap();
221        std::fs::write(machine.join("system_prompt.md"), "Be helpful.").unwrap();
222        std::fs::write(machine.join("glossary.json"), r#"{"terms": []}"#).unwrap();
223        std::fs::write(machine.join("ontology.json"), r#"{"entity_types": []}"#).unwrap();
224        std::fs::write(machine.join("rules.json"), r#"{"rules": []}"#).unwrap();
225        std::fs::write(
226            machine.join("constraints.json"),
227            r#"{"edge_cases": [], "anti_patterns": [], "hard_limits": []}"#,
228        )
229        .unwrap();
230        std::fs::write(machine.join("retrieval_chunks.jsonl"), "").unwrap();
231        Pack::open(tmp.path()).unwrap()
232    }
233
234    #[test]
235    fn all_required_files_present_and_valid_passes() {
236        let tmp = TempDir::new().unwrap();
237        let pack = complete_pack(&tmp);
238        let result = run(&pack);
239        assert_eq!(result.status, GateStatus::Pass);
240        assert_eq!(result.gate, 4);
241    }
242
243    #[test]
244    fn missing_required_file_fails() {
245        let tmp = TempDir::new().unwrap();
246        let pack = complete_pack(&tmp);
247        std::fs::remove_file(pack.machine_file("glossary.json")).unwrap();
248
249        let result = run(&pack);
250        assert_eq!(result.status, GateStatus::Fail);
251        assert!(
252            result
253                .checks
254                .iter()
255                .any(|c| c.description.contains("glossary.json") && c.status == GateStatus::Fail)
256        );
257    }
258
259    #[test]
260    fn invalid_json_in_machine_file_fails() {
261        let tmp = TempDir::new().unwrap();
262        let pack = complete_pack(&tmp);
263        std::fs::write(pack.machine_file("rules.json"), "{ not valid").unwrap();
264
265        let result = run(&pack);
266        assert_eq!(result.status, GateStatus::Fail);
267        assert!(
268            result
269                .checks
270                .iter()
271                .any(|c| c.description.contains("rules.json") && c.status == GateStatus::Fail)
272        );
273    }
274
275    #[test]
276    fn sources_csv_absent_skips_source_ref_check() {
277        let tmp = TempDir::new().unwrap();
278        let pack = complete_pack(&tmp);
279        let result = run(&pack);
280        assert!(
281            !result
282                .checks
283                .iter()
284                .any(|c| c.description == "source_ref resolution")
285        );
286    }
287
288    #[test]
289    fn unresolved_source_ref_fails() {
290        let tmp = TempDir::new().unwrap();
291        let pack = complete_pack(&tmp);
292        std::fs::write(
293            pack.machine_file("glossary.json"),
294            r#"{"terms": [{"id": "t1", "term": "Term", "definition": "def", "source_ref": "src-1"}]}"#,
295        )
296        .unwrap();
297        std::fs::create_dir_all(pack.evidence_dir()).unwrap();
298        std::fs::write(pack.evidence_file("sources.csv"), "id,title\n").unwrap();
299
300        let result = run(&pack);
301        assert_eq!(result.status, GateStatus::Fail);
302        assert!(
303            result
304                .checks
305                .iter()
306                .any(|c| c.description == "source_ref resolution" && c.status == GateStatus::Fail)
307        );
308    }
309
310    #[test]
311    fn source_ref_generated_is_always_allowed() {
312        let tmp = TempDir::new().unwrap();
313        let pack = complete_pack(&tmp);
314        std::fs::write(
315            pack.machine_file("glossary.json"),
316            r#"{"terms": [{"id": "t1", "term": "Term", "definition": "def", "source_ref": "generated"}]}"#,
317        )
318        .unwrap();
319        std::fs::create_dir_all(pack.evidence_dir()).unwrap();
320        std::fs::write(pack.evidence_file("sources.csv"), "id,title\n").unwrap();
321
322        let result = run(&pack);
323        assert_eq!(result.status, GateStatus::Pass);
324    }
325
326    #[test]
327    fn resolved_source_ref_passes() {
328        let tmp = TempDir::new().unwrap();
329        let pack = complete_pack(&tmp);
330        std::fs::write(
331            pack.machine_file("glossary.json"),
332            r#"{"terms": [{"id": "t1", "term": "Term", "definition": "def", "source_ref": "src-1"}]}"#,
333        )
334        .unwrap();
335        std::fs::create_dir_all(pack.evidence_dir()).unwrap();
336        std::fs::write(
337            pack.evidence_file("sources.csv"),
338            "id,title\nsrc-1,Some Source\n",
339        )
340        .unwrap();
341
342        let result = run(&pack);
343        assert_eq!(result.status, GateStatus::Pass);
344    }
345
346    #[test]
347    fn knowledge_graph_broken_edge_fails() {
348        let tmp = TempDir::new().unwrap();
349        let pack = complete_pack(&tmp);
350        std::fs::write(
351            pack.machine_file("knowledge_graph.json"),
352            r#"{"nodes": [{"id": "n1", "node_type": "concept", "label": "N1"}], "edges": [{"source": "n1", "relation": "see-also", "target": "missing"}]}"#,
353        )
354        .unwrap();
355
356        let result = run(&pack);
357        assert_eq!(result.status, GateStatus::Fail);
358        assert!(result.checks.iter().any(|c| {
359            c.description.contains("knowledge_graph edge resolution")
360                && c.status == GateStatus::Fail
361        }));
362    }
363
364    #[test]
365    fn knowledge_graph_resolved_edges_pass() {
366        let tmp = TempDir::new().unwrap();
367        let pack = complete_pack(&tmp);
368        std::fs::write(
369            pack.machine_file("knowledge_graph.json"),
370            r#"{"nodes": [{"id": "n1", "node_type": "concept", "label": "N1"}, {"id": "n2", "node_type": "concept", "label": "N2"}], "edges": [{"source": "n1", "relation": "see-also", "target": "n2"}]}"#,
371        )
372        .unwrap();
373
374        let result = run(&pack);
375        assert_eq!(result.status, GateStatus::Pass);
376    }
377}