use crate::owl;
use crate::store::KgStore;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TripleOrigin {
Symbolic,
Neural,
}
#[derive(Debug, Clone)]
pub struct CascadeTriple {
pub subject: String,
pub predicate: String,
pub object: String,
pub origin: TripleOrigin,
pub confidence: f32,
}
#[derive(Debug, Clone)]
pub struct ShaclReport {
pub conforms: bool,
pub violations: usize,
pub warnings: usize,
pub details: String,
}
#[derive(Debug)]
pub struct CascadeResult {
pub symbolic_count: usize,
pub neural_count: usize,
pub dedup_removed: usize,
pub shacl_report: Option<ShaclReport>,
pub triples: Vec<CascadeTriple>,
}
#[derive(Debug, Clone)]
pub struct CascadeConfig {
pub ner_threshold: f32,
pub max_owl_iterations: usize,
pub shacl_shapes: Option<String>,
}
impl Default for CascadeConfig {
fn default() -> Self {
Self {
ner_threshold: 0.85,
max_owl_iterations: 50,
shacl_shapes: None,
}
}
}
#[cfg(feature = "standalone")]
pub fn tier1_symbolic(
store: &mut crate::store_oxigraph::OxigraphStore,
) -> Result<usize, Box<dyn std::error::Error>> {
owl::materialize_owl2rl(store)
}
#[cfg(feature = "ner")]
pub fn tier2_neural(
engine: &crate::ner::NerEngine,
text: &str,
subject_iri: &str,
candidates: &[crate::ner::Candidate],
threshold: f32,
) -> Result<Vec<CascadeTriple>, Box<dyn std::error::Error>> {
let results = engine.zero_shot_ner(text, candidates, threshold)?;
Ok(results
.into_iter()
.map(|r| CascadeTriple {
subject: subject_iri.to_string(),
predicate: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type".to_string(),
object: r.iri,
origin: TripleOrigin::Neural,
confidence: r.similarity,
})
.collect())
}
pub fn merge_dedup(
symbolic: Vec<CascadeTriple>,
neural: Vec<CascadeTriple>,
) -> (Vec<CascadeTriple>, usize) {
use std::collections::HashSet;
let symbolic_set: HashSet<(String, String, String)> = symbolic
.iter()
.map(|t| (t.subject.clone(), t.predicate.clone(), t.object.clone()))
.collect();
let mut merged = symbolic;
let mut dedup_count = 0;
for triple in neural {
let key = (
triple.subject.clone(),
triple.predicate.clone(),
triple.object.clone(),
);
if symbolic_set.contains(&key) {
dedup_count += 1;
} else {
merged.push(triple);
}
}
(merged, dedup_count)
}
pub fn triples_to_turtle(triples: &[CascadeTriple]) -> String {
let mut buf = String::new();
for t in triples {
buf.push_str(&format!("<{}> <{}> <{}> .\n", t.subject, t.predicate, t.object));
}
buf
}
#[cfg(feature = "standalone")]
pub fn validate_shacl(
store: &crate::store_oxigraph::OxigraphStore,
shapes_path: &str,
) -> Result<ShaclReport, Box<dyn std::error::Error>> {
use std::io::Write;
let turtle = store.sparql_construct("CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }")?;
let mut data_file = tempfile::Builder::new().suffix(".nt").tempfile()?;
data_file.write_all(turtle.as_bytes())?;
data_file.flush()?;
let data_path = data_file.path().to_str().unwrap();
let minimal = std::process::Command::new("rudof")
.args([
"shacl-validate",
"-s", shapes_path,
"-t", "ntriples",
data_path,
"--result-format", "minimal",
])
.output()?;
let minimal_out = String::from_utf8_lossy(&minimal.stdout).to_string()
+ &String::from_utf8_lossy(&minimal.stderr);
let details = std::process::Command::new("rudof")
.args([
"shacl-validate",
"-s", shapes_path,
"-t", "ntriples",
data_path,
"--result-format", "details",
])
.output()?;
let details_out = String::from_utf8_lossy(&details.stdout).to_string()
+ &String::from_utf8_lossy(&details.stderr);
parse_shacl_minimal(&minimal_out, details_out)
}
fn parse_shacl_minimal(
minimal: &str,
details: String,
) -> Result<ShaclReport, Box<dyn std::error::Error>> {
let line = minimal.trim();
if line.starts_with("Conforms") {
return Ok(ShaclReport {
conforms: true,
violations: 0,
warnings: 0,
details,
});
}
let mut violations = 0usize;
let mut warnings = 0usize;
for part in line.split(',') {
let part = part.trim();
if let Some(n) = part.strip_suffix(" violations").or_else(|| part.strip_suffix(" violation")) {
violations = n.trim().parse().unwrap_or(0);
} else if let Some(n) = part.strip_suffix(" warnings").or_else(|| part.strip_suffix(" warning")) {
warnings = n.trim().parse().unwrap_or(0);
}
}
Ok(ShaclReport {
conforms: false,
violations,
warnings,
details,
})
}
#[cfg(feature = "standalone")]
pub fn run_cascade(
store: &mut crate::store_oxigraph::OxigraphStore,
#[cfg(feature = "ner")] ner_input: Option<NerInput<'_>>,
_config: &CascadeConfig,
) -> Result<CascadeResult, Box<dyn std::error::Error>> {
let symbolic_count = tier1_symbolic(store)?;
#[cfg(feature = "ner")]
let neural_triples = match ner_input {
Some(input) => tier2_neural(
input.engine,
input.text,
input.subject_iri,
input.candidates,
_config.ner_threshold,
)?,
None => Vec::new(),
};
#[cfg(not(feature = "ner"))]
let neural_triples: Vec<CascadeTriple> = Vec::new();
let neural_count = neural_triples.len();
let mut merged = Vec::new();
let mut dedup_removed = 0;
for triple in neural_triples {
if triple_exists_in_store(store, &triple.subject, &triple.predicate, &triple.object) {
dedup_removed += 1;
} else {
merged.push(triple);
}
}
if !merged.is_empty() {
let turtle: String = merged
.iter()
.map(|t| format!("<{}> <{}> <{}> .\n", t.subject, t.predicate, t.object))
.collect();
store.load_turtle(&turtle, Some(owl::INFERRED_GRAPH))?;
}
let shacl_report = match &_config.shacl_shapes {
Some(shapes_path) => Some(validate_shacl(store, shapes_path)?),
None => None,
};
Ok(CascadeResult {
symbolic_count,
neural_count,
dedup_removed,
shacl_report,
triples: merged,
})
}
#[cfg(feature = "ner")]
pub struct NerInput<'a> {
pub engine: &'a crate::ner::NerEngine,
pub text: &'a str,
pub subject_iri: &'a str,
pub candidates: &'a [crate::ner::Candidate],
}
#[cfg(feature = "standalone")]
fn triple_exists_in_store(
store: &crate::store_oxigraph::OxigraphStore,
subject: &str,
predicate: &str,
object: &str,
) -> bool {
use crate::store::KgStore;
let sparql = format!(
"ASK {{ <{}> <{}> <{}> }}",
subject, predicate, object
);
store.sparql_query(&sparql)
.map(|r| r == "true")
.unwrap_or(false)
}
#[cfg(test)]
#[cfg(feature = "standalone")]
mod tests {
use super::*;
use crate::store::KgStore;
use crate::store_oxigraph::OxigraphStore;
#[test]
fn test_merge_dedup_symbolic_wins() {
let symbolic = vec![CascadeTriple {
subject: "http://ex.org/a".into(),
predicate: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type".into(),
object: "http://ex.org/Cat".into(),
origin: TripleOrigin::Symbolic,
confidence: 1.0,
}];
let neural = vec![
CascadeTriple {
subject: "http://ex.org/a".into(),
predicate: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type".into(),
object: "http://ex.org/Cat".into(),
origin: TripleOrigin::Neural,
confidence: 0.92,
},
CascadeTriple {
subject: "http://ex.org/a".into(),
predicate: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type".into(),
object: "http://ex.org/Pet".into(),
origin: TripleOrigin::Neural,
confidence: 0.87,
},
];
let (merged, dedup) = merge_dedup(symbolic, neural);
assert_eq!(dedup, 1, "One duplicate should be removed");
assert_eq!(merged.len(), 2, "Symbolic + unique neural = 2");
assert_eq!(merged[0].origin, TripleOrigin::Symbolic);
assert_eq!(merged[1].origin, TripleOrigin::Neural);
}
#[test]
fn test_cascade_symbolic_only() {
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 .
:felix a :Cat .
"#;
store.load_turtle(ttl, None).unwrap();
let config = CascadeConfig::default();
let result = run_cascade(
&mut store,
#[cfg(feature = "ner")]
None,
&config,
)
.unwrap();
assert!(result.symbolic_count > 0, "OWL should infer felix a :Animal");
assert_eq!(result.neural_count, 0);
assert_eq!(result.dedup_removed, 0);
assert!(result.shacl_report.is_none(), "No shapes = no SHACL report");
assert!(result.triples.is_empty(), "No neural input = no merged triples");
}
#[test]
fn test_cascade_shacl_post_merge() {
let shapes_content = r#"
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix : <http://example.org/> .
:LabelShape a sh:NodeShape ;
sh:targetClass :Cat ;
sh:property [
sh:path rdfs:label ;
sh:minCount 1 ;
sh:severity sh:Violation ;
sh:message "Cats must have a label" ;
] .
"#;
let mut shapes_file = tempfile::NamedTempFile::new().unwrap();
use std::io::Write;
shapes_file.write_all(shapes_content.as_bytes()).unwrap();
shapes_file.flush().unwrap();
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 .
:felix a :Cat .
"#;
store.load_turtle(ttl, None).unwrap();
let config = CascadeConfig {
shacl_shapes: Some(shapes_file.path().to_str().unwrap().to_string()),
..Default::default()
};
let result = run_cascade(
&mut store,
#[cfg(feature = "ner")]
None,
&config,
)
.unwrap();
let report = result.shacl_report.expect("SHACL report should be present");
assert!(!report.conforms, "Should not conform — :felix has no label");
assert!(report.violations > 0, "Expected at least 1 violation");
}
#[test]
fn test_triple_exists_dedup() {
let mut store = OxigraphStore::new_memory().unwrap();
let ttl = r#"
@prefix : <http://example.org/> .
:felix a :Cat .
"#;
store.load_turtle(ttl, None).unwrap();
assert!(triple_exists_in_store(
&store,
"http://example.org/felix",
"http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
"http://example.org/Cat"
));
assert!(!triple_exists_in_store(
&store,
"http://example.org/felix",
"http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
"http://example.org/Dog"
));
}
#[test]
fn test_triples_to_turtle() {
let triples = vec![CascadeTriple {
subject: "http://ex.org/a".into(),
predicate: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type".into(),
object: "http://ex.org/Cat".into(),
origin: TripleOrigin::Symbolic,
confidence: 1.0,
}];
let turtle = triples_to_turtle(&triples);
assert!(turtle.contains("<http://ex.org/a>"));
assert!(turtle.contains("<http://ex.org/Cat>"));
}
}