bra0-kg 0.2.1

bra0 Knowledge Graph core — RDF transformations (sophia) + KgStore trait (NextGraph-First)
Documentation
//! OWL axiom extraction via sophia — feeds OWL 2 RL materialization
//!
//! Capabilities served: SL-3 (OWL Reasoning), NS-7 (OWL Reasoning Rust)
//!
//! Pipeline:
//!   1. sophia parses TBox (Turtle) into LightGraph
//!   2. This module extracts OWL axioms (subClassOf, inverseOf, etc.)
//!   3. Generates 9 SPARQL CONSTRUCT rules (one per OWL 2 RL axiom type)
//!   4. KgStore executes rules in fixpoint loop until no new triples
//!
//! Reference: story-NS7-owl-reasoning-rust.md

/// The 9 OWL 2 RL SPARQL CONSTRUCT rules.
///
/// Each rule is a SPARQL CONSTRUCT query that materializes inferred triples.
/// Executed iteratively by oxigraph until fixpoint (no new triples produced).
pub const OWL2RL_RULES: [(&str, &str); 9] = [
    // prp-spo1: subPropertyOf propagation
    (
        "prp-spo1",
        r#"CONSTRUCT { ?x ?p2 ?y }
WHERE {
    ?p1 <http://www.w3.org/2000/01/rdf-schema#subPropertyOf> ?p2 .
    ?x ?p1 ?y .
    FILTER (?p1 != ?p2)
}"#,
    ),
    // prp-inv1: inverseOf (forward direction)
    (
        "prp-inv1",
        r#"CONSTRUCT { ?y ?p2 ?x }
WHERE {
    ?p1 <http://www.w3.org/2002/07/owl#inverseOf> ?p2 .
    ?x ?p1 ?y .
}"#,
    ),
    // prp-inv2: inverseOf (backward direction)
    (
        "prp-inv2",
        r#"CONSTRUCT { ?y ?p1 ?x }
WHERE {
    ?p1 <http://www.w3.org/2002/07/owl#inverseOf> ?p2 .
    ?x ?p2 ?y .
}"#,
    ),
    // cax-sco: subClassOf propagation
    (
        "cax-sco",
        r#"CONSTRUCT { ?x <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> ?c2 }
WHERE {
    ?c1 <http://www.w3.org/2000/01/rdf-schema#subClassOf> ?c2 .
    ?x <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> ?c1 .
    FILTER (?c1 != ?c2)
    FILTER (!isBlank(?c2))
}"#,
    ),
    // prp-dom: domain inference
    (
        "prp-dom",
        r#"CONSTRUCT { ?x <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> ?c }
WHERE {
    ?p <http://www.w3.org/2000/01/rdf-schema#domain> ?c .
    ?x ?p ?y .
    FILTER (!isBlank(?c))
}"#,
    ),
    // prp-rng: range inference
    (
        "prp-rng",
        r#"CONSTRUCT { ?y <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> ?c }
WHERE {
    ?p <http://www.w3.org/2000/01/rdf-schema#range> ?c .
    ?x ?p ?y .
    FILTER (!isBlank(?y))
    FILTER (!isBlank(?c))
}"#,
    ),
    // prp-trp: transitive property closure
    (
        "prp-trp",
        r#"CONSTRUCT { ?x ?p ?z }
WHERE {
    ?p <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://www.w3.org/2002/07/owl#TransitiveProperty> .
    ?x ?p ?y .
    ?y ?p ?z .
    FILTER (?x != ?z)
}"#,
    ),
    // prp-symp: symmetric property inference
    (
        "prp-symp",
        r#"CONSTRUCT { ?y ?p ?x }
WHERE {
    ?p <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://www.w3.org/2002/07/owl#SymmetricProperty> .
    ?x ?p ?y .
}"#,
    ),
    // eq-cls: equivalentClass bidirectional subClassOf
    (
        "eq-cls",
        r#"CONSTRUCT {
    ?c1 <http://www.w3.org/2000/01/rdf-schema#subClassOf> ?c2 .
    ?c2 <http://www.w3.org/2000/01/rdf-schema#subClassOf> ?c1 .
}
WHERE {
    ?c1 <http://www.w3.org/2002/07/owl#equivalentClass> ?c2 .
    FILTER (?c1 != ?c2)
}"#,
    ),
];

/// Named graph for inferred triples. Downstream code can filter these out
/// during export to avoid serializing inferred triples back to source.
pub const INFERRED_GRAPH: &str = "urn:bra0:inferred";

/// Run OWL 2 RL fixpoint materialization on a KgStore.
///
/// Executes the 9 CONSTRUCT rules iteratively until no new triples are produced.
/// Inferred triples are written to the `<urn:bra0:inferred>` named graph.
/// Returns the total number of new triples materialized.
#[cfg(feature = "standalone")]
pub fn materialize_owl2rl(store: &mut crate::store_oxigraph::OxigraphStore) -> Result<usize, Box<dyn std::error::Error>> {
    use crate::store::KgStore;

    let mut total_new = 0usize;
    let mut iteration = 0;
    const MAX_ITERATIONS: usize = 50;

    loop {
        iteration += 1;
        if iteration > MAX_ITERATIONS {
            return Err(format!("OWL 2 RL fixpoint did not converge after {} iterations", MAX_ITERATIONS).into());
        }

        let mut new_this_round = 0usize;

        for (_name, rule) in &OWL2RL_RULES {
            let result = store.sparql_construct(rule)?;
            if !result.is_empty() {
                // Load into default graph (required for fixpoint — rules read default graph)
                let loaded = store.load_turtle(&result, None)?;
                new_this_round += loaded;
            }
        }

        if new_this_round == 0 {
            break;
        }
        total_new += new_this_round;
    }

    Ok(total_new)
}

#[cfg(test)]
#[cfg(feature = "standalone")]
mod tests {
    use super::*;
    use crate::store::KgStore;
    use crate::store_oxigraph::OxigraphStore;

    #[test]
    fn test_subclass_materialization() {
        let mut store = OxigraphStore::new_memory().unwrap();
        let ttl = r#"
            @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
            @prefix : <http://example.org/> .
            :Cat rdfs:subClassOf :Animal .
            :Animal rdfs:subClassOf :LivingThing .
            :felix a :Cat .
        "#;
        store.load_turtle(ttl, None).unwrap();

        let new_triples = materialize_owl2rl(&mut store).unwrap();
        assert!(new_triples > 0, "Should materialize at least felix a :Animal");

        // felix should now be a :Animal and a :LivingThing
        let result = store.sparql_query(
            "ASK { <http://example.org/felix> a <http://example.org/Animal> }"
        ).unwrap();
        assert_eq!(result, "true");

        let result = store.sparql_query(
            "ASK { <http://example.org/felix> a <http://example.org/LivingThing> }"
        ).unwrap();
        assert_eq!(result, "true");
    }

    #[test]
    fn test_inverse_of_materialization() {
        let mut store = OxigraphStore::new_memory().unwrap();
        let ttl = r#"
            @prefix owl: <http://www.w3.org/2002/07/owl#> .
            @prefix : <http://example.org/> .
            :hasParent owl:inverseOf :hasChild .
            :alice :hasParent :bob .
        "#;
        store.load_turtle(ttl, None).unwrap();

        materialize_owl2rl(&mut store).unwrap();

        let result = store.sparql_query(
            "ASK { <http://example.org/bob> <http://example.org/hasChild> <http://example.org/alice> }"
        ).unwrap();
        assert_eq!(result, "true");
    }

    #[test]
    fn test_symmetric_property() {
        let mut store = OxigraphStore::new_memory().unwrap();
        let ttl = r#"
            @prefix owl: <http://www.w3.org/2002/07/owl#> .
            @prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
            @prefix : <http://example.org/> .
            :friendOf rdf:type owl:SymmetricProperty .
            :alice :friendOf :bob .
        "#;
        store.load_turtle(ttl, None).unwrap();

        materialize_owl2rl(&mut store).unwrap();

        let result = store.sparql_query(
            "ASK { <http://example.org/bob> <http://example.org/friendOf> <http://example.org/alice> }"
        ).unwrap();
        assert_eq!(result, "true");
    }

    #[test]
    fn test_transitive_property() {
        let mut store = OxigraphStore::new_memory().unwrap();
        let ttl = r#"
            @prefix owl: <http://www.w3.org/2002/07/owl#> .
            @prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
            @prefix : <http://example.org/> .
            :partOf rdf:type owl:TransitiveProperty .
            :wheel :partOf :car .
            :car :partOf :fleet .
        "#;
        store.load_turtle(ttl, None).unwrap();

        materialize_owl2rl(&mut store).unwrap();

        let result = store.sparql_query(
            "ASK { <http://example.org/wheel> <http://example.org/partOf> <http://example.org/fleet> }"
        ).unwrap();
        assert_eq!(result, "true");
    }

    /// SPIKE-01 exit criterion: materialization on real ontology (ontoLEDGY) < 500ms
    #[test]
    fn test_ontoledgy_materialization_performance() {
        let ontology = std::fs::read_to_string(
            std::env::var("BRA0_META")
                .unwrap_or_else(|_| "/Users/admin/Documents/bra0_meta".to_string())
                + "/ontologies/enterprise/ontoledgy_v1.0-draft.ttl"
        );
        // Skip if file not found (CI environments)
        let ontology = match ontology {
            Ok(o) => o,
            Err(_) => { eprintln!("SKIP: ontoLEDGY not found"); return; }
        };

        let mut store = OxigraphStore::new_memory().unwrap();
        store.load_turtle(&ontology, None).unwrap();

        let start = std::time::Instant::now();
        let new_triples = materialize_owl2rl(&mut store).unwrap();
        let elapsed = start.elapsed();

        eprintln!("ontoLEDGY: {} new triples materialized in {:?}", new_triples, elapsed);
        assert!(elapsed.as_millis() < 500, "Materialization took {:?} — must be < 500ms", elapsed);
        assert!(new_triples > 0, "Should materialize at least some triples from OWL axioms");
    }
}