This commit is contained in:
2026-08-12 14:11:47 -04:00
parent 79894f654d
commit f852da4f4e
48 changed files with 4379 additions and 779 deletions
+4
View File
@@ -4,6 +4,10 @@ version = "0.1.0"
edition = "2024"
[dependencies]
gl-inference.workspace = true
gl-search.workspace = true
oxigraph.workspace = true
oxilangtag.workspace = true
thiserror.workspace = true
tracing.workspace = true
+22
View File
@@ -0,0 +1,22 @@
use oxigraph::model::NamedNodeRef;
use crate::vocab;
pub enum Category {
AudioBook = 0,
}
impl Category {
pub fn to_named_node(&self) -> NamedNodeRef<'_> {
match self {
Category::AudioBook => vocab::gl::AUDIO_BOOK,
}
}
pub fn try_from_named_node<'a>(node: impl Into<NamedNodeRef<'a>>) -> Option<Self> {
let node = node.into();
match node {
vocab::gl::AUDIO_BOOK => Some(Category::AudioBook),
_ => None,
}
}
}
+43
View File
@@ -0,0 +1,43 @@
use oxigraph::model::NamedNodeRef;
use crate::vocab;
pub enum Class {
RdfProperty = 0,
RdfsClass = 1,
SkosConcept = 2,
Work = 10001,
Person = 10004,
CorporateBody = 10005,
Expression = 10006,
Manifestation = 10007,
}
impl Class {
pub fn to_named_node(&self) -> NamedNodeRef<'_> {
match self {
Class::RdfProperty => vocab::rdf::PROPERTY,
Class::RdfsClass => vocab::rdfs::CLASS,
Class::SkosConcept => vocab::skos::CONCEPT,
Class::Work => vocab::rdac::WORK,
Class::Person => vocab::rdac::PERSON,
Class::CorporateBody => vocab::rdac::CORPORATE_BODY,
Class::Expression => vocab::rdac::EXPRESSION,
Class::Manifestation => vocab::rdac::MANIFESTATION,
}
}
pub fn try_from_named_node<'a>(node: impl Into<NamedNodeRef<'a>>) -> Option<Self> {
let node = node.into();
match node {
vocab::rdf::PROPERTY => Some(Class::RdfProperty),
vocab::rdfs::CLASS => Some(Class::RdfsClass),
vocab::skos::CONCEPT => Some(Class::SkosConcept),
vocab::rdac::WORK => Some(Class::Work),
vocab::rdac::PERSON => Some(Class::Person),
vocab::rdac::CORPORATE_BODY => Some(Class::CorporateBody),
vocab::rdac::EXPRESSION => Some(Class::Expression),
vocab::rdac::MANIFESTATION => Some(Class::Manifestation),
_ => None,
}
}
}
+45
View File
@@ -0,0 +1,45 @@
use oxigraph::model::{NamedNode, NamedNodeRef, Term, TermRef};
use oxigraph::model::vocab::xsd;
use crate::vocab::rdf;
pub(crate) fn term_to_named_node(term: &Term) -> Option<&NamedNode> {
if let Term::NamedNode(node) = term {
Some(node)
} else {
None
}
}
pub(crate) fn term_ref_as_named_node(term: TermRef<'_>) -> Option<NamedNodeRef<'_>> {
if let TermRef::NamedNode(node) = term {
Some(node)
} else {
None
}
}
pub(crate) fn term_as_str(term: &Term) -> Option<&str> {
if let Term::Literal(literal) = term {
match literal.datatype() {
xsd::STRING | xsd::NORMALIZED_STRING | rdf::LANG_STRING | rdf::DIR_LANG_STRING => {
Some(literal.value())
}
_ => None,
}
} else {
None
}
}
pub(crate) fn term_ref_as_str(term: TermRef<'_>) -> Option<&str> {
if let TermRef::Literal(literal) = term {
match literal.datatype() {
xsd::STRING | xsd::NORMALIZED_STRING | rdf::LANG_STRING | rdf::DIR_LANG_STRING => {
Some(literal.value())
}
_ => None,
}
} else {
None
}
}
+95
View File
@@ -0,0 +1,95 @@
use std::collections::HashMap;
use oxigraph::model::NamedNode;
use oxigraph::sparql::results::{QueryResultsFormat, QueryResultsParser, SliceQueryResultsParserOutput};
use gl_inference::Channel;
use gl_inference::proto::ontology_client::OntologyClient;
use gl_inference::proto::OntologyQueryRequest;
use gl_search::{Field, OwnedValue, Schema};
use crate::class::Class;
use crate::{curie, CurieHelper};
use crate::helpers::{term_as_str, term_to_named_node};
use crate::language::LanguageCondition;
pub async fn index_ontology(
client: &mut OntologyClient<Channel>,
language: &LanguageCondition,
) -> crate::Result<Vec<HashMap<Field, OwnedValue>>> {
let curie_helper = CurieHelper::new((&*curie::PREFIXES).clone());
let label_filter = language.to_filter_expression("label");
let description_filter = language.to_filter_expression("description");
let mut request = OntologyQueryRequest::default();
request.sparql_query = format!(r#"PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
PREFIX skos: <http://www.w3.org/2004/02/skos/core#>
SELECT ?class ?subject ?label ?description WHERE {{
VALUES ?class {{ rdf:Property rdfs:Class skos:Concept }}
?subject a ?class ;
rdfs:label ?label .
{label_filter}
OPTIONAL {{ ?subject rdfs:comment ?comment }}
OPTIONAL {{ ?subject skos:definition ?definition }}
BIND(COALESCE(?definition, ?comment) AS ?description)
{description_filter}
}}"#);
let response = client.query(request).await.unwrap();
let parser_output = QueryResultsParser::from_format(QueryResultsFormat::Json)
.for_slice(&response.get_ref().results)
.unwrap();
let mut results = Vec::new();
if let SliceQueryResultsParserOutput::Solutions(solutions) = parser_output {
let primary_language = language.primary_language();
let label_field = Schema::field("label", primary_language);
let definition_field = Schema::field("definition", primary_language);
for solution in solutions.filter_map(Result::ok) {
let mut document = HashMap::with_capacity(4);
let discriminant = solution
.get("class")
.and_then(term_to_named_node)
.and_then(Class::try_from_named_node)
.map(|doc_type| doc_type as u64)
.map(OwnedValue::U64)
.unwrap_or(OwnedValue::Null);
document.insert(Schema::discriminant_field(), discriminant);
let subject_str = solution
.get("subject")
.and_then(term_to_named_node)
.map(NamedNode::as_str);
let curie = subject_str
.and_then(|subject| curie_helper.abbreviate(None, subject))
.map(OwnedValue::Str)
.unwrap_or(OwnedValue::Null);
document.insert(Schema::curie_field(), curie);
let subject = subject_str
.map(OwnedValue::from)
.unwrap_or(OwnedValue::Null);
document.insert(Schema::iri_field(), subject);
let label = solution
.get("label")
.and_then(term_as_str)
.map(OwnedValue::from)
.unwrap_or(OwnedValue::Null);
document.insert(label_field, label);
let definition = solution
.get("description")
.and_then(term_as_str)
.map(OwnedValue::from)
.unwrap_or(OwnedValue::Null);
document.insert(definition_field, definition);
results.push(document);
}
}
Ok(results)
}
-122
View File
@@ -1,122 +0,0 @@
use oxigraph::model::{Dataset, GraphNameRef, NamedNodeRef, Quad, QuadRef};
use oxigraph::sparql::{QueryResults, SparqlEvaluator};
use oxigraph::store::Store;
use tracing::{debug_span, field};
const RDF_SCHEMA_PREFIX: &str = "http://www.w3.org/2000/01/rdf-schema#";
const GL_PREFIX: &str = "https://graphofliberty.org/";
const INFERENCE_GRAPH: GraphNameRef = GraphNameRef::NamedNode(NamedNodeRef::new_unchecked(
"https://graphofliberty.org/inference",
));
/// `prp-spo1`
const SUB_PROPERTY_OF_QUERY: &str = r#"CONSTRUCT {
?x ?p2 ?y .
} WHERE {
?p1 rdfs:subPropertyOf+ ?p2 .
GRAPH gl:inference { ?x ?p1 ?y . }
}"#;
/// `cax-sco`
const SUB_CLASS_OF_QUERY: &str = r#"CONSTRUCT {
?x a ?c2 .
} WHERE {
?c1 rdfs:subClassOf+ ?c2 .
GRAPH gl:inference { ?x a ?c1 . }
}"#;
/// `prp-dom`
const DOMAIN_QUERY: &str = r#"CONSTRUCT {
?x a ?c .
} WHERE {
?p rdfs:domain ?c .
GRAPH gl:inference { ?x ?p ?y . }
}"#;
/// https://www.w3.org/TR/owl2-profiles/#Reasoning_in_OWL_2_RL_and_RDF_Graphs_using_Rules
#[derive(Clone)]
pub struct InferenceEngine {
ontology: Dataset,
}
fn run_query(name: &str, query: &str, dataset: &Dataset) -> crate::Result<Dataset> {
let span = debug_span!("Query", name).entered();
let mut query = SparqlEvaluator::new()
.with_prefix("rdfs", RDF_SCHEMA_PREFIX)?
.with_prefix("gl", GL_PREFIX)?
.parse_query(query)?;
query.dataset_mut().set_default_graph_as_union();
let inferences =
if let QueryResults::Graph(result) = query.on_queryable_dataset(dataset).execute()? {
result
.filter_map(Result::ok)
.map(|triple| {
Quad::new(
triple.subject,
triple.predicate,
triple.object,
INFERENCE_GRAPH,
)
})
.collect()
} else {
Dataset::new()
};
Ok(inferences)
}
fn run_recursively(mut dataset: Dataset) -> crate::Result<Dataset> {
let sub_property_of_result = run_query("rdfs:subPropertyOf", SUB_PROPERTY_OF_QUERY, &dataset)?;
let sub_class_of_result = run_query("rdfs:subClassOf", SUB_CLASS_OF_QUERY, &dataset)?;
let domain_result = run_query("rdfs:domain", DOMAIN_QUERY, &dataset)?;
let old_count = dataset.len();
dataset.extend(&sub_property_of_result);
dataset.extend(&sub_class_of_result);
dataset.extend(&domain_result);
let new_count = dataset.len();
if new_count > old_count {
run_recursively(dataset)
} else {
Ok(dataset)
}
}
impl InferenceEngine {
pub fn new(store: Store) -> Self {
let mut ontology = Dataset::new();
debug_span!("Load Ontology").in_scope(|| {
ontology.extend(store.iter().filter_map(Result::ok));
});
Self { ontology }
}
pub fn run(&self, dataset: &Dataset) -> crate::Result<Dataset> {
let span = debug_span!("Inference", inferences = field::Empty).entered();
let input = dataset.iter().map(|triple| {
QuadRef::new(
triple.subject,
triple.predicate,
triple.object,
INFERENCE_GRAPH,
)
});
let mut ontology = self.ontology.clone();
ontology.extend(input);
let result: Dataset = run_recursively(ontology)?
.quads_for_graph_name(INFERENCE_GRAPH)
.collect();
span.record("inferences", result.len());
Ok(result)
}
}
+63
View File
@@ -0,0 +1,63 @@
use oxigraph::model::TermRef;
use oxilangtag::LanguageTag;
use std::sync::LazyLock;
pub const ENGLISH_PRIMARY: &str = "en";
pub static ENGLISH_OR_UNTAGGED: LazyLock<LanguageCondition> = LazyLock::new(|| {
LanguageCondition::ExactMatchOrUntagged(
LanguageTag::parse(ENGLISH_PRIMARY.to_string()).unwrap(),
)
});
pub enum LanguageCondition {
ExactMatchOnly(LanguageTag<String>),
ExactMatchOrUntagged(LanguageTag<String>),
UntaggedOnly,
AnyOrNone,
}
impl LanguageCondition {
pub fn primary_matches_term<'a>(&self, term: impl Into<TermRef<'a>>) -> bool {
if let TermRef::Literal(literal) = term.into() {
let tag = literal
.language()
.map(LanguageTag::parse_and_normalize)
.and_then(Result::ok);
match (tag, self) {
(Some(language), LanguageCondition::ExactMatchOnly(expectation))
| (Some(language), LanguageCondition::ExactMatchOrUntagged(expectation)) => {
language.primary_language() == expectation.primary_language()
}
(None, LanguageCondition::ExactMatchOrUntagged(_)) => true,
(None, LanguageCondition::UntaggedOnly) => true,
(_, LanguageCondition::AnyOrNone) => true,
_ => false,
}
} else {
false
}
}
pub fn primary_language(&self) -> Option<&str> {
match self {
LanguageCondition::ExactMatchOnly(tag) => Some(tag.primary_language()),
LanguageCondition::ExactMatchOrUntagged(tag) => Some(tag.primary_language()),
_ => None,
}
}
pub fn to_filter_expression(&self, var: &str) -> String {
match self {
LanguageCondition::ExactMatchOnly(language) => {
format!(r#"FILTER(langMATCHES(LANG(?{var}), "{language}"))"#)
}
LanguageCondition::ExactMatchOrUntagged(language) => {
format!(r#"FILTER(langMATCHES(LANG(?{var}), "{language}") || !hasLANG(?{var}))"#)
}
LanguageCondition::UntaggedOnly => format!("FILTER(!hasLANG(?{var}))"),
LanguageCondition::AnyOrNone => "".to_string(),
}
}
}
+5 -1
View File
@@ -2,8 +2,12 @@ mod curie;
//mod materialize;
mod error;
pub mod inference;
pub mod vocab;
pub mod class;
pub mod category;
pub mod language;
mod helpers;
pub mod index;
pub use curie::{CurieHelper, PREFIXES};
pub use error::{Error, Result};
-114
View File
@@ -1,114 +0,0 @@
use crate::vocab;
use oxigraph::model::{Dataset, GraphName, NamedNodeRef, NamedOrBlankNode, NamedOrBlankNodeRef, Term};
use oxigraph::sparql::SparqlEvaluator;
use oxigraph::store::Store;
use tracing::{debug, debug_span};
const RDF_SCHEMA: NamedNodeRef = NamedNodeRef::new_unchecked("http://www.w3.org/2000/01/rdf-schema#");
pub fn same_as(store: &mut Store) -> crate::Result<()> {
let _span = debug_span!("Materialize owl:sameAs").entered();
let additional_quads = store
.quads_for_pattern(None, Some(vocab::owl::SAME_AS), None, None)
.filter_map(Result::ok)
.fold(Dataset::new(), |mut new_dataset, alias| {
if let NamedOrBlankNode::NamedNode(x) = alias.subject
&& let Term::NamedNode(y) = alias.object
{
for mut quad in store.quads_for_pattern(
Some(NamedOrBlankNodeRef::NamedNode(x.as_ref())),
None,
None,
None,
).filter_map(Result::ok) {
quad.subject = NamedOrBlankNode::NamedNode(y.clone());
quad.graph_name = GraphName::NamedNode(INFERENCE_GRAPH.into_owned());
new_dataset.insert(quad.as_ref());
}
for mut quad in store.quads_for_pattern(
Some(NamedOrBlankNodeRef::NamedNode(y.as_ref())),
None,
None,
None,
).filter_map(Result::ok) {
quad.subject = NamedOrBlankNode::NamedNode(x.clone());
quad.graph_name = GraphName::NamedNode(INFERENCE_GRAPH.into_owned());
new_dataset.insert(quad.as_ref());
}
}
new_dataset
});
let old_size = store.len()?;
store.extend(&additional_quads)?;
let new_size = store.len()?;
if new_size > old_size {
let difference = new_size - old_size;
debug!(?old_size, ?new_size, ?difference, "same_as");
same_as(store)?;
}
Ok(())
}
pub fn super_properties(store: &mut Store) -> crate::Result<()> {
let _span = debug_span!("Materialize rdfs:subPropertyOf").entered();
let update = SparqlEvaluator::new()
.with_prefix("rdfs", RDF_SCHEMA.as_str())?
.with_prefix("inference", INFERENCE_GRAPH.as_str())?
.parse_update(r#"INSERT {
GRAPH inference: {
?property rdfs:subPropertyOf ?parent .
?subject ?parent ?object .
}
} WHERE {
GRAPH ?g1 { ?property rdfs:subPropertyOf+ ?parent }
OPTIONAL { GRAPH ?g2 { ?subject ?property ?object } }
}"#)?;
let old_size = store.len()?;
update.on_store(&store).execute()?;
let new_size = store.len()?;
if new_size > old_size {
let difference = new_size - old_size;
debug!(?old_size, ?new_size, ?difference, "super_properties");
super_properties(store)?;
}
Ok(())
}
pub fn super_classes(store: &mut Store) -> crate::Result<()> {
let _span = debug_span!("Materialize rdfs:subClassOf").entered();
let update = SparqlEvaluator::new()
.with_prefix("rdfs", RDF_SCHEMA.as_str())?
.with_prefix("inference", INFERENCE_GRAPH.as_str())?
.parse_update(r#"INSERT {
GRAPH inference: {
?class rdfs:subClassOf ?parent .
?item a ?parent .
}
} WHERE {
GRAPH ?g1 { ?class rdfs:subClassOf+ ?parent }
OPTIONAL { GRAPH ?g2 { ?item a ?class } }
}"#)?;
let old_size = store.len()?;
update.on_store(&store).execute()?;
let new_size = store.len()?;
if new_size > old_size {
let difference = new_size - old_size;
debug!(?old_size, ?new_size, ?difference, "super_classes");
super_classes(store)?;
}
Ok(())
}
+2 -17
View File
@@ -4,23 +4,8 @@ pub use oxigraph::model::vocab::rdfs;
pub mod gl {
use oxigraph::model::NamedNodeRef;
pub const TEMPLATE: NamedNodeRef =
NamedNodeRef::new_unchecked("https://graphofliberty.org/2026/04/ont/template");
pub const INDEXED_BY_FIELD: NamedNodeRef =
NamedNodeRef::new_unchecked("https://graphofliberty.org/2026/04/ont/indexedByField");
pub const CATEGORY_ID: NamedNodeRef =
NamedNodeRef::new_unchecked("https://graphofliberty.org/2026/04/ont/categoryId");
pub const SEARCHABLE_CLASS: NamedNodeRef =
NamedNodeRef::new_unchecked("https://graphofliberty.org/2026/04/ont/SearchableClass");
pub const ENTITY: NamedNodeRef =
NamedNodeRef::new_unchecked("https://graphofliberty.org/2026/04/ont/Entity");
pub const ASSOCIATED_PROPERTY: NamedNodeRef =
NamedNodeRef::new_unchecked("https://graphofliberty.org/2026/04/ont/associatedProperty");
pub const AUDIO_BOOK: NamedNodeRef =
NamedNodeRef::new_unchecked("https://graphofliberty.org/2026/04/ont/AudioBook");
pub const READ_ONLY: NamedNodeRef =
NamedNodeRef::new_unchecked("https://graphofliberty.org/2026/04/ont/readOnly");