Skip to main content

mif_rh/
harness_assert_graph.rs

1//! Knowledge-graph MIF-substrate assertion (rht Category B, Story #287).
2//!
3//! Ports rht's `scripts/assert-graph-mif.sh`: proves a `knowledge-graph.json`
4//! is built from MIF entities and typed relationships, not tag co-occurrence
5//! (Milestone 4 acceptance gate).
6
7use std::path::Path;
8
9use serde_json::Value;
10
11use crate::error::MifRhError;
12use crate::harness_project::read_json;
13
14/// One named boolean assertion and whether it held.
15#[derive(Debug, Clone)]
16pub struct CheckResult {
17    /// The human-readable description of what was checked (matches the
18    /// original script's per-check message).
19    pub message: String,
20    /// Whether the check passed.
21    pub passed: bool,
22}
23
24/// The full result of an [`assert_graph_mif`] run: every individual check,
25/// in the same fixed order as the original script, plus the overall verdict.
26#[derive(Debug, Clone)]
27pub struct GraphAssertion {
28    /// Every check, in order.
29    pub checks: Vec<CheckResult>,
30    /// Whether every check passed.
31    pub passed: bool,
32}
33
34fn is_urn_mif(value: &Value) -> bool {
35    value.as_str().is_some_and(|s| s.starts_with("urn:mif:"))
36}
37
38fn check(message: &str, passed: bool) -> CheckResult {
39    CheckResult {
40        message: message.to_string(),
41        passed,
42    }
43}
44
45/// Proves `graph`'s nodes/edges are MIF-derived.
46///
47/// Every id is a `urn:mif:` identifier, at least one edge is a typed
48/// relationship, every referenced MIF entity has a corresponding entity node
49/// (when any entity-mention edge exists), and every edge target resolves to
50/// a real node.
51#[must_use]
52pub fn assert_graph_mif(graph: &Value) -> GraphAssertion {
53    let nodes: Vec<&Value> = graph
54        .get("nodes")
55        .and_then(Value::as_array)
56        .into_iter()
57        .flatten()
58        .collect();
59    let edges: Vec<&Value> = graph
60        .get("edges")
61        .and_then(Value::as_array)
62        .into_iter()
63        .flatten()
64        .collect();
65
66    let node_ids: std::collections::HashSet<&str> = nodes
67        .iter()
68        .filter_map(|n| n.get("id").and_then(Value::as_str))
69        .collect();
70
71    let entity_node_ids: std::collections::HashSet<&str> = nodes
72        .iter()
73        .filter(|n| n.get("kind").and_then(Value::as_str) == Some("entity"))
74        .filter_map(|n| n.get("id").and_then(Value::as_str))
75        .filter(|id| id.starts_with("urn:mif:entity:"))
76        .collect();
77    // Stricter than the original bash+jq check this ports, which only asked
78    // "does ANY conforming entity node exist anywhere" — that let some
79    // entity-mention edges point at missing/non-entity targets as long as
80    // one other edge's target happened to resolve. Checking EVERY
81    // entity-mention edge's OWN target only tightens the gate: any graph
82    // that passed the old check still passes this one.
83    let every_entity_edge_resolves = edges
84        .iter()
85        .filter(|e| e.get("via").and_then(Value::as_str) == Some("entity"))
86        .all(|e| {
87            e.get("target")
88                .and_then(Value::as_str)
89                .is_some_and(|t| entity_node_ids.contains(t))
90        });
91
92    let checks = vec![
93        check("graph has nodes", !nodes.is_empty()),
94        check("graph has edges", !edges.is_empty()),
95        check(
96            "every node id is a urn:mif: identifier (not a tag)",
97            nodes.iter().all(|n| is_urn_mif(&n["id"])),
98        ),
99        check(
100            "every edge source is a urn:mif: concept",
101            edges.iter().all(|e| is_urn_mif(&e["source"])),
102        ),
103        check(
104            "every edge target is a urn:mif: id",
105            edges.iter().all(|e| is_urn_mif(&e["target"])),
106        ),
107        check(
108            "at least one edge derives from a typed MIF relationship",
109            edges
110                .iter()
111                .any(|e| e.get("via").and_then(Value::as_str) == Some("relationship")),
112        ),
113        check(
114            "every referenced MIF entity is an entity node",
115            every_entity_edge_resolves,
116        ),
117        check(
118            "every edge target resolves to a node in the graph",
119            edges.iter().all(|e| {
120                e.get("target")
121                    .and_then(Value::as_str)
122                    .is_some_and(|t| node_ids.contains(t))
123            }),
124        ),
125    ];
126
127    let passed = checks.iter().all(|c| c.passed);
128    GraphAssertion { checks, passed }
129}
130
131/// Reads `graph_path` as JSON and runs [`assert_graph_mif`] against it.
132///
133/// # Errors
134///
135/// Returns [`MifRhError::Io`] if `graph_path` cannot be read, and
136/// [`MifRhError::Json`] if it is not valid JSON.
137pub fn assert_graph_mif_file(graph_path: &Path) -> Result<GraphAssertion, MifRhError> {
138    let graph = read_json(graph_path)?;
139    Ok(assert_graph_mif(&graph))
140}
141
142#[cfg(test)]
143mod tests {
144    use super::assert_graph_mif;
145    use serde_json::json;
146
147    #[test]
148    fn passes_a_well_formed_mif_graph() {
149        let graph = json!({
150            "nodes": [
151                {"id": "urn:mif:f1", "kind": "concept"},
152                {"id": "urn:mif:entity:e1", "kind": "entity"}
153            ],
154            "edges": [
155                {"source": "urn:mif:f1", "target": "urn:mif:f1", "via": "relationship"},
156                {"source": "urn:mif:f1", "target": "urn:mif:entity:e1", "via": "entity"}
157            ]
158        });
159
160        let assertion = assert_graph_mif(&graph);
161        assert!(assertion.passed, "{:?}", assertion.checks);
162    }
163
164    #[test]
165    fn fails_when_a_node_id_is_a_bare_tag() {
166        let graph = json!({
167            "nodes": [{"id": "some-tag", "kind": "concept"}],
168            "edges": [{"source": "some-tag", "target": "some-tag", "via": "relationship"}]
169        });
170
171        let assertion = assert_graph_mif(&graph);
172        assert!(!assertion.passed);
173        assert!(
174            !assertion.checks[2].passed,
175            "node-id-is-urn check should fail"
176        );
177    }
178
179    #[test]
180    fn fails_when_no_edge_is_a_typed_relationship() {
181        let graph = json!({
182            "nodes": [{"id": "urn:mif:f1", "kind": "concept"}],
183            "edges": [{"source": "urn:mif:f1", "target": "urn:mif:f1", "via": "entity"}]
184        });
185
186        let assertion = assert_graph_mif(&graph);
187        assert!(!assertion.checks[5].passed);
188    }
189
190    #[test]
191    fn requires_an_entity_node_only_when_an_entity_mention_edge_exists() {
192        // No entity-mention edges at all: the entity-node check is
193        // vacuously satisfied even with zero entity nodes.
194        let graph = json!({
195            "nodes": [{"id": "urn:mif:f1", "kind": "concept"}],
196            "edges": [{"source": "urn:mif:f1", "target": "urn:mif:f1", "via": "relationship"}]
197        });
198        let assertion = assert_graph_mif(&graph);
199        assert!(assertion.checks[6].passed);
200    }
201
202    #[test]
203    fn fails_when_an_entity_mention_edge_has_no_matching_entity_node() {
204        let graph = json!({
205            "nodes": [{"id": "urn:mif:f1", "kind": "concept"}],
206            "edges": [
207                {"source": "urn:mif:f1", "target": "urn:mif:f1", "via": "relationship"},
208                {"source": "urn:mif:f1", "target": "urn:mif:entity:missing", "via": "entity"}
209            ]
210        });
211        let assertion = assert_graph_mif(&graph);
212        assert!(!assertion.checks[6].passed);
213    }
214
215    #[test]
216    fn fails_when_one_of_several_entity_mention_edges_targets_a_non_entity_node() {
217        // A valid entity node exists (satisfying a loose "does any entity node
218        // exist" check), but a SECOND entity-mention edge targets a node that
219        // is not an entity at all — this must still fail, not pass on the
220        // strength of the first edge's valid target.
221        let graph = json!({
222            "nodes": [
223                {"id": "urn:mif:f1", "kind": "concept"},
224                {"id": "urn:mif:entity:acme", "kind": "entity"}
225            ],
226            "edges": [
227                {"source": "urn:mif:f1", "target": "urn:mif:entity:acme", "via": "entity"},
228                {"source": "urn:mif:f1", "target": "urn:mif:f1", "via": "entity"}
229            ]
230        });
231        let assertion = assert_graph_mif(&graph);
232        assert!(!assertion.checks[6].passed);
233    }
234
235    #[test]
236    fn fails_when_an_edge_target_does_not_resolve_to_a_node() {
237        let graph = json!({
238            "nodes": [{"id": "urn:mif:f1", "kind": "concept"}],
239            "edges": [
240                {"source": "urn:mif:f1", "target": "urn:mif:f1", "via": "relationship"},
241                {"source": "urn:mif:f1", "target": "urn:mif:ghost", "via": "relationship"}
242            ]
243        });
244        let assertion = assert_graph_mif(&graph);
245        assert!(!assertion.checks[7].passed);
246    }
247
248    #[test]
249    fn fails_closed_on_an_empty_graph() {
250        let graph = json!({"nodes": [], "edges": []});
251        let assertion = assert_graph_mif(&graph);
252        assert!(!assertion.passed);
253        assert!(!assertion.checks[0].passed);
254        assert!(!assertion.checks[1].passed);
255    }
256}