use std::collections::{BTreeSet, HashMap, HashSet};
use oxigraph::{
io::RdfFormat,
model::{BlankNode, GraphName, NamedOrBlankNode, Quad, Term},
sparql::{QueryResults, SparqlEvaluator},
store::Store,
};
use crate::error::{AppError, Result};
mod ontology_batch;
pub use ontology_batch::{OntologyBatchReceipt, TurtleDocument};
const REFINEMENT_ITERATIONS: usize = 5;
pub struct DeterministicGraph {
store: Store,
}
impl DeterministicGraph {
pub fn new() -> Result<Self> {
let store = Store::new()
.map_err(|e| AppError::fm_graph(1, format!("failed to create in-memory store: {e}")))?;
Ok(Self { store })
}
pub fn insert_turtle(&self, ttl: &str) -> Result<usize> {
let before = self
.store
.len()
.map_err(|e| AppError::fm_graph(2, format!("store length unavailable: {e}")))?;
self.store
.load_from_slice(RdfFormat::Turtle, ttl)
.map_err(|e| AppError::fm_graph(2, format!("turtle load failed: {e}")))?;
let after = self
.store
.len()
.map_err(|e| AppError::fm_graph(2, format!("store length unavailable: {e}")))?;
let inserted = after.saturating_sub(before);
self.canonicalize_blank_nodes()?;
Ok(inserted)
}
fn canonicalize_blank_nodes(&self) -> Result<()> {
let quads = self.all_quads()?;
let relabel = blank_node_relabel_map(&quads)?;
if relabel.is_empty() {
return Ok(());
}
let mut to_insert: Vec<Quad> = Vec::new();
for quad in &quads {
let touches_blank = matches!(&quad.subject, NamedOrBlankNode::BlankNode(_))
|| matches!(&quad.object, Term::BlankNode(_))
|| matches!(&quad.graph_name, GraphName::BlankNode(_));
if !touches_blank {
continue;
}
let relabeled = relabel_quad(quad, &relabel);
if &relabeled == quad {
continue;
}
self.store.remove(quad).map_err(|e| {
AppError::fm_graph(
2,
format!("blank-node canonicalization: remove of stale quad failed: {e}"),
)
})?;
to_insert.push(relabeled);
}
if !to_insert.is_empty() {
self.store.extend(to_insert).map_err(|e| {
AppError::fm_graph(
2,
format!("blank-node canonicalization: insert of relabeled quads failed: {e}"),
)
})?;
}
Ok(())
}
pub fn query(&self, sparql: &str) -> Result<QueryResults<'static>> {
if let Ok(parsed) = spargebra::SparqlParser::new().parse_query(sparql) {
if query_has_graph_clause(&parsed) {
return Err(AppError::fm_graph(
8,
"SPARQL GRAPH clause is not supported -- ggen-engine loads all RDF \
content into a single default graph (no named-graph ingestion path \
exists); a GRAPH clause will always match zero triples silently, \
which is worse than refusing. \
Remediation: remove the GRAPH clause, or query without it against \
the default graph.",
));
}
}
SparqlEvaluator::new()
.parse_query(sparql)
.map_err(|e| {
if looks_like_sparql_update(sparql) {
AppError::fm_graph(
9,
format!(
"SPARQL UPDATE is not supported by ggen-engine's read-only \
query pipeline (only SELECT/CONSTRUCT/DESCRIBE/ASK are \
accepted). Remediation: this crate has no mutation path \
through `query()` -- load RDF via `insert_turtle` or \
frontmatter `rdf:`/`rdf_inline:` instead. \
Underlying parse error: {e}"
),
)
} else if let Some(err) = diagnose_bind_rebind(sparql) {
err
} else {
let near = offending_line_hint(sparql, &e);
AppError::fm_graph(3, format!("SPARQL parse failed: {e}{near}"))
}
})?
.on_store(&self.store)
.execute()
.map_err(|e| AppError::fm_graph(3, format!("SPARQL evaluation failed: {e}")))
}
pub fn all_quads(&self) -> Result<Vec<Quad>> {
self.store
.iter()
.collect::<std::result::Result<Vec<Quad>, _>>()
.map_err(|e| AppError::fm_graph(4, format!("store iteration failed: {e}")))
}
pub fn state_hash(&self) -> Result<[u8; 32]> {
let quads = self.all_quads()?;
let lines = canonical_nquad_lines(&quads)?;
Ok(*blake3::hash(lines.join("\n").as_bytes()).as_bytes())
}
}
fn query_has_graph_clause(query: &spargebra::Query) -> bool {
match query {
spargebra::Query::Select { pattern, .. }
| spargebra::Query::Construct { pattern, .. }
| spargebra::Query::Describe { pattern, .. }
| spargebra::Query::Ask { pattern, .. } => graph_pattern_has_graph_clause(pattern),
}
}
fn graph_pattern_has_graph_clause(pattern: &spargebra::algebra::GraphPattern) -> bool {
use spargebra::algebra::GraphPattern as GP;
match pattern {
GP::Graph { .. } => true,
GP::Bgp { .. } | GP::Path { .. } | GP::Values { .. } => false,
GP::Join { left, right }
| GP::Minus { left, right }
| GP::Union { left, right }
| GP::Lateral { left, right } => {
graph_pattern_has_graph_clause(left) || graph_pattern_has_graph_clause(right)
}
GP::LeftJoin {
left,
right,
expression,
} => {
graph_pattern_has_graph_clause(left)
|| graph_pattern_has_graph_clause(right)
|| expression.as_ref().is_some_and(expression_has_graph_clause)
}
GP::Filter { expr, inner } => {
expression_has_graph_clause(expr) || graph_pattern_has_graph_clause(inner)
}
GP::Extend {
inner, expression, ..
} => graph_pattern_has_graph_clause(inner) || expression_has_graph_clause(expression),
GP::OrderBy { inner, .. }
| GP::Project { inner, .. }
| GP::Distinct { inner }
| GP::Reduced { inner }
| GP::Slice { inner, .. }
| GP::Service { inner, .. } => graph_pattern_has_graph_clause(inner),
GP::Group {
inner, aggregates, ..
} => {
graph_pattern_has_graph_clause(inner)
|| aggregates
.iter()
.any(|(_, agg)| aggregate_expression_has_graph_clause(agg))
}
}
}
fn expression_has_graph_clause(expr: &spargebra::algebra::Expression) -> bool {
use spargebra::algebra::Expression as Ex;
match expr {
Ex::NamedNode(_) | Ex::Literal(_) | Ex::Variable(_) | Ex::Bound(_) => false,
Ex::Or(a, b)
| Ex::And(a, b)
| Ex::Equal(a, b)
| Ex::SameTerm(a, b)
| Ex::Greater(a, b)
| Ex::GreaterOrEqual(a, b)
| Ex::Less(a, b)
| Ex::LessOrEqual(a, b)
| Ex::Add(a, b)
| Ex::Subtract(a, b)
| Ex::Multiply(a, b)
| Ex::Divide(a, b) => expression_has_graph_clause(a) || expression_has_graph_clause(b),
Ex::UnaryPlus(inner) | Ex::UnaryMinus(inner) | Ex::Not(inner) => {
expression_has_graph_clause(inner)
}
Ex::In(inner, list) => {
expression_has_graph_clause(inner) || list.iter().any(expression_has_graph_clause)
}
Ex::Exists(pattern) => graph_pattern_has_graph_clause(pattern),
Ex::If(cond, then_branch, else_branch) => {
expression_has_graph_clause(cond)
|| expression_has_graph_clause(then_branch)
|| expression_has_graph_clause(else_branch)
}
Ex::Coalesce(list) => list.iter().any(expression_has_graph_clause),
Ex::FunctionCall(_, args) => args.iter().any(expression_has_graph_clause),
}
}
fn aggregate_expression_has_graph_clause(agg: &spargebra::algebra::AggregateExpression) -> bool {
match agg {
spargebra::algebra::AggregateExpression::CountSolutions { .. } => false,
spargebra::algebra::AggregateExpression::FunctionCall { expr, .. } => {
expression_has_graph_clause(expr)
}
}
}
const LIKELY_UPDATE_KEYWORDS: &[&str] = &[
"INSERT", "DELETE", "WITH", "CLEAR", "DROP", "CREATE", "LOAD", "COPY", "MOVE", "ADD",
];
fn looks_like_sparql_update(sparql: &str) -> bool {
let trimmed = sparql.trim_start();
LIKELY_UPDATE_KEYWORDS.iter().any(|kw| {
trimmed.len() >= kw.len()
&& trimmed[..kw.len()].eq_ignore_ascii_case(kw)
&& trimmed[kw.len()..]
.chars()
.next()
.is_none_or(|c| !c.is_alphanumeric() && c != '_')
})
}
fn diagnose_bind_rebind(sparql: &str) -> Option<AppError> {
let bytes = sparql.as_bytes();
let is_name_char = |c: u8| c.is_ascii_alphanumeric() || c == b'_' || c >= 0x80;
let mut from = 0usize;
while let Some(rel) = sparql[from..]
.to_ascii_lowercase()
.find("bind")
.map(|p| from + p)
{
let kw = rel;
let before_ok = kw == 0 || !is_name_char(bytes[kw - 1]);
let after_ok = bytes.get(kw + 4).is_none_or(|&c| !is_name_char(c));
from = kw + 4;
if !before_ok || !after_ok {
continue;
}
let Some(open_rel) = sparql[kw..].find('(') else {
continue;
};
let open = kw + open_rel;
let mut depth: usize = 0;
let mut as_pos: Option<usize> = None;
let mut i = open;
while i < bytes.len() {
match bytes[i] {
b'(' => depth += 1,
b')' => {
if depth == 1 {
break;
}
depth -= 1;
}
b'A' | b'a'
if depth == 1
&& sparql[i..].len() >= 2
&& sparql[i + 1..].starts_with(['S', 's'])
&& !is_name_char(bytes[i - 1])
&& bytes.get(i + 2).is_none_or(|&c| !is_name_char(c)) =>
{
as_pos = Some(i);
break;
}
_ => {}
}
i += 1;
}
let Some(as_pos) = as_pos else { continue };
let rest = &sparql[as_pos + 2..];
let rest_trimmed = rest.trim_start();
let target_rel = rest.len() - rest_trimmed.len();
let target_abs = as_pos + 2 + target_rel;
let target_name: String = {
let mut cs = sparql[target_abs..].chars();
match cs.next() {
Some('?' | '$') => cs
.by_ref()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect(),
_ => continue,
}
};
if target_name.is_empty() {
continue;
}
let prefix = &sparql[..as_pos];
if var_token_present(prefix, &target_name) {
let line_no = sparql[..kw].matches('\n').count() + 1;
let line_start = prefix_line_start(sparql, kw);
let line_text = sparql[line_start..].lines().next().unwrap_or("").trim();
return Some(AppError::fm_graph(
13,
format!(
"BIND re-binds in-scope variable ?{target_name} (SPARQL 1.1 \
requires a fresh variable); rename the AS target. \
[at line {line_no}: {line_text}]"
),
));
}
}
None
}
fn prefix_line_start(sparql: &str, at: usize) -> usize {
sparql[..at].rfind('\n').map_or(0, |p| p + 1)
}
fn var_token_present(text: &str, name: &str) -> bool {
let needle_q = format!("?{name}");
let needle_d = format!("${name}");
let mut from = 0usize;
while let Some(rel) = text[from..].find(&needle_q).map(|p| from + p) {
let end = rel + needle_q.len();
let boundary = text[end..]
.chars()
.next()
.is_none_or(|c| !(c.is_alphanumeric() || c == '_'));
if boundary {
return true;
}
from = rel + 1;
}
let mut from = 0usize;
while let Some(rel) = text[from..].find(&needle_d).map(|p| from + p) {
let end = rel + needle_d.len();
let boundary = text[end..]
.chars()
.next()
.is_none_or(|c| !(c.is_alphanumeric() || c == '_'));
if boundary {
return true;
}
from = rel + 1;
}
false
}
fn offending_line_hint(sparql: &str, err: &impl std::fmt::Display) -> String {
let rendered = err.to_string();
let Some(at_idx) = rendered.find(" at ") else {
return String::new();
};
let rest = &rendered[at_idx + 4..];
let line_no: Option<usize> = rest
.split(|c: char| !c.is_ascii_digit())
.next()
.and_then(|s| s.parse().ok());
let Some(line_no) = line_no else {
return String::new();
};
match sparql.lines().nth(line_no.saturating_sub(1)) {
Some(line) => format!(" (near line {line_no}: {})", line.trim()),
None => String::new(),
}
}
pub type EngineRow = std::collections::BTreeMap<String, EngineValue>;
#[derive(Debug, Clone, PartialEq)]
pub enum EngineValue {
Bool(bool),
Int(i64),
Float(f64),
String(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EngineTriple {
pub subject: String,
pub predicate: String,
pub object_value: String,
pub ntriples: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum EngineQueryResults {
Boolean(bool),
Solutions(Vec<EngineRow>),
Graph(Vec<EngineTriple>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShaclOutcome {
pub conforms: bool,
pub violations: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MaterializeOutcome {
pub derived: Vec<String>,
pub rules_loaded: usize,
}
pub trait GraphEngine: Send + Sync {
fn insert_turtle(&self, ttl: &str) -> Result<usize>;
fn insert_turtle_documents(
&self, documents: &[TurtleDocument<'_>],
) -> Result<OntologyBatchReceipt>;
fn query(&self, sparql: &str) -> Result<EngineQueryResults>;
fn canonical_quads(&self) -> Result<Vec<String>>;
fn state_hash(&self) -> Result<[u8; 32]> {
let lines = self.canonical_quads()?;
Ok(*blake3::hash(lines.join("\n").as_bytes()).as_bytes())
}
fn load_rules(&self, rules: &str) -> Result<usize>;
fn load_hook_pack(&self, hook_ttl: &str) -> Result<()>;
fn materialize(&self) -> Result<MaterializeOutcome>;
fn validate_shacl(&self, shapes_turtle: &str) -> Result<ShaclOutcome>;
fn validate_shex(
&self, schema_shexc: &str, shape_map: &[(String, String)],
) -> Result<ShaclOutcome>;
fn check_denials(&self) -> Result<Vec<String>>;
}
pub(crate) fn term_value(term: &Term, relabel: &HashMap<BlankNode, BlankNode>) -> String {
match term {
Term::Literal(lit) => lit.value().to_string(),
Term::NamedNode(n) => n.as_str().to_string(),
Term::BlankNode(b) => relabel.get(b).unwrap_or(b).to_string(),
Term::Triple(triple) => triple.to_string(),
}
}
fn term_ntriples_relabeled(term: &Term, relabel: &HashMap<BlankNode, BlankNode>) -> String {
match term {
Term::BlankNode(b) => relabel.get(b).unwrap_or(b).to_string(),
other => other.to_string(),
}
}
fn named_or_blank_ntriples(
node: &NamedOrBlankNode, relabel: &HashMap<BlankNode, BlankNode>,
) -> String {
match node {
NamedOrBlankNode::NamedNode(n) => n.to_string(),
NamedOrBlankNode::BlankNode(b) => relabel.get(b).unwrap_or(b).to_string(),
}
}
const XSD: &str = "http://www.w3.org/2001/XMLSchema#";
const XSD_BOOLEAN: &str = "http://www.w3.org/2001/XMLSchema#boolean";
const XSD_INTEGER_DATATYPES: &[&str] = &[
"integer",
"int",
"long",
"short",
"byte",
"nonNegativeInteger",
"positiveInteger",
"nonPositiveInteger",
"negativeInteger",
"unsignedLong",
"unsignedInt",
"unsignedShort",
"unsignedByte",
];
const XSD_FLOAT_DATATYPES: &[&str] = &["decimal", "double", "float"];
pub(crate) fn term_to_engine_value(
term: &Term, relabel: &HashMap<BlankNode, BlankNode>,
) -> EngineValue {
let Term::Literal(lit) = term else {
return EngineValue::String(term_value(term, relabel));
};
let datatype = lit.datatype().as_str();
let lexical = lit.value();
if datatype == XSD_BOOLEAN {
return match lexical {
"true" | "1" => EngineValue::Bool(true),
"false" | "0" => EngineValue::Bool(false),
_ => {
tracing::warn!(
value = lexical,
datatype,
"malformed xsd:boolean literal; falling back to plain string"
);
EngineValue::String(lexical.to_string())
}
};
}
if let Some(suffix) = datatype.strip_prefix(XSD) {
if XSD_INTEGER_DATATYPES.contains(&suffix) {
return match lexical.parse::<i64>() {
Ok(n) => EngineValue::Int(n),
Err(e) => {
tracing::warn!(
value = lexical,
datatype,
error = %e,
"malformed integer-family literal; falling back to plain string"
);
EngineValue::String(lexical.to_string())
}
};
}
if XSD_FLOAT_DATATYPES.contains(&suffix) {
return match lexical.parse::<f64>() {
Ok(n) if n.is_finite() => EngineValue::Float(n),
Ok(n) => {
tracing::warn!(
value = lexical,
datatype,
parsed = n,
"non-finite decimal/float literal (NaN/Infinity); falling back to plain string"
);
EngineValue::String(lexical.to_string())
}
Err(e) => {
tracing::warn!(
value = lexical,
datatype,
error = %e,
"malformed decimal/float-family literal; falling back to plain string"
);
EngineValue::String(lexical.to_string())
}
};
}
}
EngineValue::String(lexical.to_string())
}
impl GraphEngine for DeterministicGraph {
fn insert_turtle(&self, ttl: &str) -> Result<usize> {
DeterministicGraph::insert_turtle(self, ttl)
}
fn insert_turtle_documents(
&self, documents: &[TurtleDocument<'_>],
) -> Result<OntologyBatchReceipt> {
ontology_batch::insert_documents(self, documents)
}
fn query(&self, sparql: &str) -> Result<EngineQueryResults> {
match DeterministicGraph::query(self, sparql)? {
QueryResults::Boolean(b) => Ok(EngineQueryResults::Boolean(b)),
QueryResults::Solutions(solutions) => {
let relabel = blank_node_relabel_map(&self.all_quads()?)?;
let mut rows = Vec::new();
for solution in solutions {
let solution = solution.map_err(|e| {
AppError::fm_graph(3, format!("SELECT solution iteration failed: {e}"))
})?;
let mut row = EngineRow::new();
for (var, term) in solution.iter() {
row.insert(
var.as_str().to_string(),
term_to_engine_value(term, &relabel),
);
}
rows.push(row);
}
Ok(EngineQueryResults::Solutions(rows))
}
QueryResults::Graph(triples) => {
let relabel = blank_node_relabel_map(&self.all_quads()?)?;
let mut out = Vec::new();
for triple in triples {
let triple = triple.map_err(|e| {
AppError::fm_graph(3, format!("CONSTRUCT triple iteration failed: {e}"))
})?;
out.push(EngineTriple {
subject: named_or_blank_ntriples(&triple.subject, &relabel),
predicate: triple.predicate.to_string(),
object_value: term_value(&triple.object, &relabel),
ntriples: format!(
"{} {} {}",
named_or_blank_ntriples(&triple.subject, &relabel),
triple.predicate,
term_ntriples_relabeled(&triple.object, &relabel)
),
});
}
Ok(EngineQueryResults::Graph(out))
}
}
}
fn canonical_quads(&self) -> Result<Vec<String>> {
canonical_nquad_lines(&self.all_quads()?)
}
fn load_rules(&self, _rules: &str) -> Result<usize> {
Err(AppError::fm_law(
1,
"the oxigraph engine has no N3/Datalog rule support. \
Remediation: run with the default GraphLaw engine.",
))
}
fn materialize(&self) -> Result<MaterializeOutcome> {
Err(AppError::fm_law(
1,
"the oxigraph engine cannot materialize rules. \
Remediation: run with the default GraphLaw engine.",
))
}
fn validate_shacl(&self, _shapes_turtle: &str) -> Result<ShaclOutcome> {
Err(AppError::fm_law(
2,
"the oxigraph engine has no SHACL support. \
Remediation: run with the default GraphLaw engine.",
))
}
fn validate_shex(
&self, _schema_shexc: &str, _shape_map: &[(String, String)],
) -> Result<ShaclOutcome> {
Err(AppError::fm_law(
3,
"the oxigraph engine has no ShEx support. \
Remediation: run with the default GraphLaw engine.",
))
}
fn check_denials(&self) -> Result<Vec<String>> {
Err(AppError::fm_law(
1,
"the oxigraph engine has no denial-rule support. \
Remediation: run with the default GraphLaw engine.",
))
}
fn load_hook_pack(&self, _hook_ttl: &str) -> Result<()> {
Err(AppError::fm_law(
15,
"the oxigraph engine has no knowledge-hook support. \
Remediation: run with the default GraphLaw engine.",
))
}
}
pub struct GraphLawStore {
mirror: DeterministicGraph,
law: std::sync::Mutex<LawState>,
}
#[derive(Default)]
struct LawState {
rules_src: Vec<String>,
hooks_src: Vec<String>,
rules_loaded: usize,
}
impl GraphLawStore {
pub fn new() -> Result<Self> {
Ok(Self {
mirror: DeterministicGraph::new()?,
law: std::sync::Mutex::new(LawState::default()),
})
}
fn law_state(&self) -> Result<std::sync::MutexGuard<'_, LawState>> {
self.law.lock().map_err(|_| {
AppError::fm_law(
4,
"law-state lock poisoned by a prior panic; refusing to continue",
)
})
}
fn mirror_ntriples(&self) -> Result<String> {
use std::fmt::Write as _;
let lines = canonical_nquad_lines(&self.mirror.all_quads()?)?;
Ok(lines.iter().fold(String::new(), |mut doc, l| {
let _ = writeln!(doc, "{l} .");
doc
}))
}
}
impl GraphEngine for GraphLawStore {
fn insert_turtle(&self, ttl: &str) -> Result<usize> {
self.mirror.insert_turtle(ttl)
}
fn insert_turtle_documents(
&self, documents: &[TurtleDocument<'_>],
) -> Result<OntologyBatchReceipt> {
ontology_batch::insert_documents(&self.mirror, documents)
}
fn query(&self, sparql: &str) -> Result<EngineQueryResults> {
GraphEngine::query(&self.mirror, sparql)
}
fn canonical_quads(&self) -> Result<Vec<String>> {
GraphEngine::canonical_quads(&self.mirror)
}
fn load_rules(&self, rules: &str) -> Result<usize> {
let probe = crate::law_engine::n3_run("", &[rules]).map_err(|e| match e {
crate::law_engine::N3Failure::Load(m) | crate::law_engine::N3Failure::Reason(m) => {
AppError::fm_law(6, format!("GraphLaw rule load refused: {m}"))
}
})?;
self.law_state()?.rules_src.push(rules.to_string());
Ok(probe.rules)
}
fn materialize(&self) -> Result<MaterializeOutcome> {
let mut state = self.law_state()?;
let nt = self.mirror_ntriples()?;
let rules: Vec<&str> = state.rules_src.iter().map(String::as_str).collect();
let run = crate::law_engine::n3_run(&nt, &rules).map_err(|e| match e {
crate::law_engine::N3Failure::Load(m) => {
AppError::fm_law(6, format!("GraphLaw rule load refused: {m}"))
}
crate::law_engine::N3Failure::Reason(m) => {
AppError::fm_law(9, format!("Reasoner materialize failed: {m}"))
}
})?;
state.rules_loaded = run.rules;
if let Some(fuse) = run.fuse {
let _ = fuse;
return Ok(MaterializeOutcome {
derived: Vec::new(),
rules_loaded: run.rules,
});
}
let before: std::collections::BTreeSet<String> =
self.canonical_quads()?.into_iter().collect();
if !run.derived_nt.trim().is_empty() {
self.mirror.insert_turtle(&run.derived_nt).map_err(|e| {
AppError::fm_law(
7,
format!(
"derived facts from the GraphLaw reasoner are not valid \
N-Triples/Turtle: {e}. Derived document:\n{}",
run.derived_nt
),
)
})?;
}
if !state.hooks_src.is_empty() {
let facts_now = self.mirror_ntriples()?;
let hook_outcome = crate::law_engine::hooks_apply(&facts_now, &state.hooks_src)
.map_err(|e| AppError::fm_law(9, format!("Reasoner materialize failed: {e}")))?;
if !hook_outcome.denied.is_empty() {
return Err(AppError::fm_law(
16,
format!(
"GraphLaw hook refuse verdict denied: {}",
hook_outcome.denied.join("; ")
),
));
}
self.mirror
.insert_turtle(&hook_outcome.state_nt)
.map_err(|e| {
AppError::fm_law(
7,
format!(
"hook-derived facts from the graphlaw kernel are not valid \
N-Triples/Turtle: {e}. Hook state:\n{}",
hook_outcome.state_nt
),
)
})?;
}
let after = self.canonical_quads()?;
let derived_new: Vec<String> = after.into_iter().filter(|l| !before.contains(l)).collect();
Ok(MaterializeOutcome {
derived: derived_new,
rules_loaded: run.rules,
})
}
fn validate_shacl(&self, shapes_turtle: &str) -> Result<ShaclOutcome> {
let nt = self.mirror_ntriples()?;
crate::law_engine::shacl_check(&nt, shapes_turtle)
.map_err(|e| AppError::fm_law(8, format!("SHACL shapes graph refused: {e}")))
}
fn validate_shex(
&self, schema_shexc: &str, shape_map: &[(String, String)],
) -> Result<ShaclOutcome> {
let nt = self.mirror_ntriples()?;
crate::law_engine::shex_check(&nt, schema_shexc, shape_map)
.map_err(|e| AppError::fm_law(9, format!("ShExC schema refused: {e}")))
}
fn check_denials(&self) -> Result<Vec<String>> {
let state = self.law_state()?;
let nt = self.mirror_ntriples()?;
let rules: Vec<&str> = state.rules_src.iter().map(String::as_str).collect();
let run = crate::law_engine::n3_run(&nt, &rules).map_err(|e| match e {
crate::law_engine::N3Failure::Load(m) => {
AppError::fm_law(6, format!("GraphLaw rule load refused: {m}"))
}
crate::law_engine::N3Failure::Reason(m) => {
AppError::fm_law(9, format!("Reasoner materialize failed: {m}"))
}
})?;
Ok(run.fuse.into_iter().collect())
}
fn load_hook_pack(&self, hook_ttl: &str) -> Result<()> {
crate::law_engine::hook_pack_check(hook_ttl).map_err(|e| {
AppError::fm_law(16, format!("GraphLaw hook pack load refused: {e}"))
})?;
self.law_state()?.hooks_src.push(hook_ttl.to_string());
Ok(())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Delta {
pub additions: Vec<String>,
pub deletions: Vec<String>,
}
impl Delta {
pub fn compute(baseline: &DeterministicGraph, target: &DeterministicGraph) -> Result<Self> {
let base: BTreeSet<String> = canonical_nquad_lines(&baseline.all_quads()?)?
.into_iter()
.collect();
let tgt: BTreeSet<String> = canonical_nquad_lines(&target.all_quads()?)?
.into_iter()
.collect();
Ok(Self {
additions: tgt.difference(&base).cloned().collect(),
deletions: base.difference(&tgt).cloned().collect(),
})
}
pub fn apply(&self, graph: &DeterministicGraph) -> Result<()> {
if !self.deletions.is_empty() {
let quads = graph.all_quads()?;
let pairs = canonical_pairs(&quads)?;
let by_canonical: HashMap<&str, &Quad> =
pairs.iter().map(|(s, q)| (s.as_str(), q)).collect();
for del in &self.deletions {
let quad = by_canonical.get(del.as_str()).ok_or_else(|| {
AppError::fm_graph(
6,
format!("deletion not present in graph (refusing partial apply): {del}"),
)
})?;
graph.store.remove(quad.as_ref()).map_err(|e| {
AppError::fm_graph(6, format!("failed to remove quad `{del}`: {e}"))
})?;
}
}
if !self.additions.is_empty() {
use std::fmt::Write as _;
let doc: String = self.additions.iter().fold(String::new(), |mut doc, a| {
let _ = writeln!(doc, "{a} .");
doc
});
graph
.store
.load_from_slice(RdfFormat::NQuads, doc.as_str())
.map_err(|e| {
AppError::fm_graph(6, format!("failed to insert delta additions: {e}"))
})?;
}
graph.canonicalize_blank_nodes()?;
Ok(())
}
#[must_use]
pub fn inverse(&self) -> Self {
Self {
additions: self.deletions.clone(),
deletions: self.additions.clone(),
}
}
#[must_use]
pub fn compose(&self, other: &Delta) -> Self {
let a1: BTreeSet<&String> = self.additions.iter().collect();
let d1: BTreeSet<&String> = self.deletions.iter().collect();
let a2: BTreeSet<&String> = other.additions.iter().collect();
let d2: BTreeSet<&String> = other.deletions.iter().collect();
let additions: BTreeSet<String> = a1
.iter()
.filter(|s| !d2.contains(**s))
.chain(a2.iter().filter(|s| !d1.contains(**s)))
.map(|s| (**s).clone())
.collect();
let deletions: BTreeSet<String> = d1
.iter()
.filter(|s| !a2.contains(**s))
.chain(d2.iter().filter(|s| !a1.contains(**s)))
.map(|s| (**s).clone())
.collect();
Self {
additions: additions.into_iter().collect(),
deletions: deletions.into_iter().collect(),
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.additions.is_empty() && self.deletions.is_empty()
}
#[must_use]
pub fn hash(&self) -> [u8; 32] {
let mut additions = self.additions.clone();
additions.sort();
let mut deletions = self.deletions.clone();
deletions.sort();
let mut hasher = blake3::Hasher::new();
for a in &additions {
hasher.update(b"+");
hasher.update(a.as_bytes());
}
for d in &deletions {
hasher.update(b"-");
hasher.update(d.as_bytes());
}
*hasher.finalize().as_bytes()
}
}
fn canonical_nquad_lines(quads: &[Quad]) -> Result<Vec<String>> {
Ok(canonical_pairs(quads)?
.into_iter()
.map(|(s, _)| s)
.collect())
}
fn canonical_pairs(quads: &[Quad]) -> Result<Vec<(String, Quad)>> {
let relabel = blank_node_relabel_map(quads)?;
let mut pairs: Vec<(String, Quad)> = quads
.iter()
.map(|q| (relabel_quad(q, &relabel).to_string(), q.clone()))
.collect();
pairs.sort_by(|a, b| a.0.cmp(&b.0));
Ok(pairs)
}
fn blank_node_relabel_map(quads: &[Quad]) -> Result<HashMap<BlankNode, BlankNode>> {
let blank_nodes = collect_blank_nodes(quads);
if blank_nodes.is_empty() {
Ok(HashMap::new())
} else {
canonical_blank_node_map(quads, &blank_nodes)
}
}
fn collect_blank_nodes(quads: &[Quad]) -> HashSet<BlankNode> {
let mut blanks = HashSet::new();
for q in quads {
if let NamedOrBlankNode::BlankNode(b) = &q.subject {
blanks.insert(b.clone());
}
if let Term::BlankNode(b) = &q.object {
blanks.insert(b.clone());
}
if let GraphName::BlankNode(b) = &q.graph_name {
blanks.insert(b.clone());
}
}
blanks
}
fn canonical_blank_node_map(
quads: &[Quad], blank_nodes: &HashSet<BlankNode>,
) -> Result<HashMap<BlankNode, BlankNode>> {
let mut labels: HashMap<BlankNode, String> = blank_nodes
.iter()
.map(|b| (b.clone(), "bnode".to_string()))
.collect();
for _ in 0..REFINEMENT_ITERATIONS {
let mut next: HashMap<BlankNode, String> = HashMap::new();
for bnode in blank_nodes {
let mut neighborhood: Vec<String> = quads
.iter()
.filter(|q| quad_touches(q, bnode))
.map(|q| neighborhood_line(q, bnode, &labels))
.collect();
neighborhood.sort();
let signature = blake3::hash(neighborhood.join("\n").as_bytes())
.to_hex()
.to_string();
next.insert(bnode.clone(), signature);
}
labels = next;
}
let mut ordered: Vec<&BlankNode> = blank_nodes.iter().collect();
ordered.sort_by(|a, b| {
let la = labels.get(*a).map_or("", String::as_str);
let lb = labels.get(*b).map_or("", String::as_str);
la.cmp(lb).then_with(|| a.to_string().cmp(&b.to_string()))
});
let mut map = HashMap::new();
for (idx, bnode) in ordered.into_iter().enumerate() {
let canonical = BlankNode::new(format!("c14n{idx}")).map_err(|e| {
AppError::fm_graph(5, format!("canonical blank node label rejected: {e}"))
})?;
map.insert(bnode.clone(), canonical);
}
Ok(map)
}
fn quad_touches(quad: &Quad, bnode: &BlankNode) -> bool {
matches!(&quad.subject, NamedOrBlankNode::BlankNode(b) if b == bnode)
|| matches!(&quad.object, Term::BlankNode(b) if b == bnode)
|| matches!(&quad.graph_name, GraphName::BlankNode(b) if b == bnode)
}
fn neighborhood_line(
quad: &Quad, bnode: &BlankNode, labels: &HashMap<BlankNode, String>,
) -> String {
let blank_repr = |b: &BlankNode| -> String {
if b == bnode {
"_:self".to_string()
} else {
format!("_:{}", labels.get(b).map_or("", String::as_str))
}
};
let s = match &quad.subject {
NamedOrBlankNode::BlankNode(b) => blank_repr(b),
NamedOrBlankNode::NamedNode(n) => n.to_string(),
};
let o = match &quad.object {
Term::BlankNode(b) => blank_repr(b),
other => other.to_string(),
};
let g = match &quad.graph_name {
GraphName::DefaultGraph => String::new(),
GraphName::NamedNode(n) => n.to_string(),
GraphName::BlankNode(b) => blank_repr(b),
};
format!("{s} {} {o} {g}", quad.predicate)
}
fn relabel_quad(quad: &Quad, map: &HashMap<BlankNode, BlankNode>) -> Quad {
let subject = match &quad.subject {
NamedOrBlankNode::BlankNode(b) => {
NamedOrBlankNode::BlankNode(map.get(b).cloned().unwrap_or_else(|| b.clone()))
}
named @ NamedOrBlankNode::NamedNode(_) => named.clone(),
};
let object = match &quad.object {
Term::BlankNode(b) => Term::BlankNode(map.get(b).cloned().unwrap_or_else(|| b.clone())),
other => other.clone(),
};
let graph_name = match &quad.graph_name {
GraphName::BlankNode(b) => {
GraphName::BlankNode(map.get(b).cloned().unwrap_or_else(|| b.clone()))
}
other => other.clone(),
};
Quad::new(subject, quad.predicate.clone(), object, graph_name)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn delta(adds: &[&str], dels: &[&str]) -> Delta {
Delta {
additions: adds.iter().map(ToString::to_string).collect(),
deletions: dels.iter().map(ToString::to_string).collect(),
}
}
#[test]
fn compose_with_inverse_is_empty_and_hashes_as_empty() {
let d = delta(
&["<http://e/a> <http://e/p> \"1\""],
&["<http://e/b> <http://e/p> \"2\""],
);
let net = d.compose(&d.inverse());
assert!(net.is_empty(), "delta ∘ delta⁻¹ must cancel: {net:?}");
assert_eq!(
net.hash(),
Delta::default().hash(),
"empty-composite hash must equal empty delta hash"
);
}
#[test]
fn inverse_is_an_involution() {
let d = delta(&["a", "b"], &["c"]);
assert_eq!(d.inverse().inverse(), d);
}
#[test]
fn compose_cancels_crosswise_and_keeps_survivors_sorted() {
let d1 = delta(&["x"], &["y"]);
let d2 = delta(&["z", "y"], &["x"]);
let net = d1.compose(&d2);
assert_eq!(net.additions, vec!["z".to_string()]);
assert!(net.deletions.is_empty());
}
#[test]
fn empty_delta_is_empty() {
assert!(Delta::default().is_empty());
assert!(!delta(&["a"], &[]).is_empty());
}
const DOG_TTL: &str = r"
@prefix ex: <http://example.org/> .
ex:rex a ex:Dog .
";
const DOG_RULE: &str = "@prefix ex: <http://example.org/>. {?s a ex:Dog} => {?s a ex:Animal}.";
#[test]
fn graphlaw_materialize_derives_facts_visible_to_sparql() {
let store = GraphLawStore::new().expect("store");
GraphEngine::insert_turtle(&store, DOG_TTL).expect("ttl");
let ask = "ASK { <http://example.org/rex> a <http://example.org/Animal> }";
assert_eq!(
store.query(ask).expect("ask"),
EngineQueryResults::Boolean(false)
);
let rules = store.load_rules(DOG_RULE).expect("rules");
assert_eq!(rules, 1, "one rule in the document");
let outcome = store.materialize().expect("materialize");
assert_eq!(outcome.rules_loaded, 1);
assert_eq!(
outcome.derived.len(),
1,
"one derived fact: {:?}",
outcome.derived
);
assert_eq!(
store.query(ask).expect("ask"),
EngineQueryResults::Boolean(true),
"derived fact must be visible to SPARQL after materialization"
);
}
#[test]
fn graphlaw_state_hash_matches_oxigraph_for_same_facts() {
let law = GraphLawStore::new().expect("law");
GraphEngine::insert_turtle(&law, DOG_TTL).expect("ttl");
let oxi = DeterministicGraph::new().expect("oxi");
oxi.insert_turtle(DOG_TTL).expect("ttl");
assert_eq!(
GraphEngine::state_hash(&law).expect("law hash"),
GraphEngine::state_hash(&oxi).expect("oxi hash"),
);
}
#[test]
fn graphlaw_check_denials_reports_violation() {
let store = GraphLawStore::new().expect("store");
GraphEngine::insert_turtle(
&store,
"@prefix ex: <http://example.org/> . ex:x a ex:Forbidden .",
)
.expect("ttl");
store
.load_rules("@prefix ex: <http://example.org/>. {?s a ex:Forbidden} => false.")
.expect("denial rule");
store.materialize().expect("materialize");
let denials = store.check_denials().expect("denials");
assert_eq!(denials.len(), 1, "one violated denial: {denials:?}");
assert!(denials[0].contains("DENIED"), "{denials:?}");
}
#[test]
fn graphlaw_validate_shacl_flags_focus_node() {
let store = GraphLawStore::new().expect("store");
GraphEngine::insert_turtle(
&store,
"@prefix ex: <http://example.org/> . ex:rex a ex:Dog .",
)
.expect("ttl");
let shapes = r"
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix ex: <http://example.org/> .
ex:DogShape a sh:NodeShape ;
sh:targetClass ex:Dog ;
sh:property [ sh:path ex:name ; sh:minCount 1 ] .
";
let outcome = store.validate_shacl(shapes).expect("shacl");
assert!(!outcome.conforms, "rex has no ex:name");
assert!(
outcome.violations.iter().any(|v| v.contains("rex")),
"violation must name the focus node: {:?}",
outcome.violations
);
GraphEngine::insert_turtle(
&store,
"@prefix ex: <http://example.org/> . ex:rex ex:name \"Rex\" .",
)
.expect("ttl");
let outcome = store.validate_shacl(shapes).expect("shacl");
assert!(outcome.conforms, "{:?}", outcome.violations);
}
#[test]
fn oxigraph_engine_refuses_law_ops_with_typed_fm_law() {
let g = DeterministicGraph::new().expect("graph");
let err =
GraphEngine::load_rules(&g, "{?s ?p ?o} => {?s ?p ?o}.").expect_err("must refuse");
assert!(err.to_string().contains("FM-LAW-001"), "{err}");
let err = GraphEngine::materialize(&g).expect_err("must refuse");
assert!(err.to_string().contains("FM-LAW-001"), "{err}");
let err = GraphEngine::validate_shacl(&g, "").expect_err("must refuse");
assert!(err.to_string().contains("FM-LAW-002"), "{err}");
}
}