1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
//! # SKOS Core Types and Vocabulary
//!
//! Vocabulary constants, error types, the in-memory [`Graph`] triple store and
//! basic concept-tree types shared across the SKOS reasoner and analyzer.
use std::collections::{HashMap, HashSet};
// ---------------------------------------------------------------------------
// SKOS vocabulary constants (mirrors oxirs-core::vocab::skos)
// ---------------------------------------------------------------------------
#[allow(dead_code)]
pub(crate) const SKOS_NS: &str = "http://www.w3.org/2004/02/skos/core#";
pub(crate) const BROADER: &str = "http://www.w3.org/2004/02/skos/core#broader";
pub(crate) const NARROWER: &str = "http://www.w3.org/2004/02/skos/core#narrower";
pub(crate) const BROADER_TRANSITIVE: &str = "http://www.w3.org/2004/02/skos/core#broaderTransitive";
pub(crate) const NARROWER_TRANSITIVE: &str =
"http://www.w3.org/2004/02/skos/core#narrowerTransitive";
pub(crate) const RELATED: &str = "http://www.w3.org/2004/02/skos/core#related";
pub(crate) const EXACT_MATCH: &str = "http://www.w3.org/2004/02/skos/core#exactMatch";
pub(crate) const CLOSE_MATCH: &str = "http://www.w3.org/2004/02/skos/core#closeMatch";
pub(crate) const BROAD_MATCH: &str = "http://www.w3.org/2004/02/skos/core#broadMatch";
pub(crate) const NARROW_MATCH: &str = "http://www.w3.org/2004/02/skos/core#narrowMatch";
pub(crate) const IN_SCHEME: &str = "http://www.w3.org/2004/02/skos/core#inScheme";
pub(crate) const HAS_TOP_CONCEPT: &str = "http://www.w3.org/2004/02/skos/core#hasTopConcept";
pub(crate) const TOP_CONCEPT_OF: &str = "http://www.w3.org/2004/02/skos/core#topConceptOf";
pub(crate) const PREF_LABEL: &str = "http://www.w3.org/2004/02/skos/core#prefLabel";
pub(crate) const ALT_LABEL: &str = "http://www.w3.org/2004/02/skos/core#altLabel";
// ---------------------------------------------------------------------------
// Core data types
// ---------------------------------------------------------------------------
/// A triple using owned string values: (subject IRI, predicate IRI, object IRI or literal)
pub type Triple = (String, String, String);
/// Named node (IRI) string alias for clarity
pub type NamedNode = String;
/// Error type for SKOS reasoning operations
#[derive(Debug, Clone, thiserror::Error)]
pub enum SkosError {
#[error("Cycle detected in SKOS hierarchy involving concept: {0}")]
CycleDetected(String),
#[error("Invalid SKOS graph: {0}")]
InvalidGraph(String),
#[error("Maximum iteration limit ({0}) exceeded during entailment")]
MaxIterationsExceeded(usize),
}
/// Result alias for SKOS operations
pub type SkosResult<T> = Result<T, SkosError>;
// ---------------------------------------------------------------------------
// Graph — a simple in-memory triple store for SKOS reasoning
// ---------------------------------------------------------------------------
/// A lightweight in-memory RDF graph sufficient for SKOS entailment.
///
/// Triples are stored as (subject, predicate, object) string tuples.
/// Subject and object are IRIs; literals are represented as plain strings
/// (for labels, the value is the lexical form, optionally with a `@lang` suffix).
#[derive(Debug, Clone, Default)]
pub struct Graph {
pub(crate) triples: HashSet<Triple>,
}
impl Graph {
/// Create an empty graph.
pub fn new() -> Self {
Self {
triples: HashSet::new(),
}
}
/// Add a triple to the graph. Returns `true` if the triple is new.
pub fn add_triple(
&mut self,
subject: impl Into<String>,
predicate: impl Into<String>,
object: impl Into<String>,
) -> bool {
self.triples
.insert((subject.into(), predicate.into(), object.into()))
}
/// Test membership of a triple.
pub fn contains(&self, subject: &str, predicate: &str, object: &str) -> bool {
self.triples
.contains(&(subject.to_owned(), predicate.to_owned(), object.to_owned()))
}
/// Iterate over all triples with the given predicate.
pub fn triples_with_predicate<'a>(
&'a self,
predicate: &str,
) -> impl Iterator<Item = &'a Triple> {
let predicate = predicate.to_string();
self.triples.iter().filter(move |(_, p, _)| p == &predicate)
}
/// Iterate over all triples whose subject matches `subject`.
pub fn triples_with_subject<'a>(&'a self, subject: &str) -> impl Iterator<Item = &'a Triple> {
let subject = subject.to_string();
self.triples.iter().filter(move |(s, _, _)| s == &subject)
}
/// Iterate over all triples whose object matches `object`.
pub fn triples_with_object<'a>(&'a self, object: &str) -> impl Iterator<Item = &'a Triple> {
let object = object.to_string();
self.triples.iter().filter(move |(_, _, o)| o == &object)
}
/// Return all triples.
pub fn triples(&self) -> impl Iterator<Item = &Triple> {
self.triples.iter()
}
/// Number of triples in the graph.
pub fn len(&self) -> usize {
self.triples.len()
}
/// Returns `true` if the graph has no triples.
pub fn is_empty(&self) -> bool {
self.triples.is_empty()
}
/// Merge another graph's triples into this one.
pub fn merge(&mut self, other: &Graph) {
for triple in &other.triples {
self.triples.insert(triple.clone());
}
}
/// Return an adjacency map for a specific predicate: subject → set of objects.
pub fn adjacency_map(&self, predicate: &str) -> HashMap<String, HashSet<String>> {
let mut map: HashMap<String, HashSet<String>> = HashMap::new();
for (s, p, o) in &self.triples {
if p == predicate {
map.entry(s.clone()).or_default().insert(o.clone());
}
}
map
}
}
// ---------------------------------------------------------------------------
// label_matches helper
// ---------------------------------------------------------------------------
/// Internal helper: test whether an RDF literal object matches a label and
/// optional language tag.
///
/// Literals are stored as `"value"` or `"value"@lang`.
pub(crate) fn label_matches(literal: &str, label: &str, lang: Option<&str>) -> bool {
match lang {
None => {
// Match if the literal value (stripped of language tag) equals label
if let Some(at_pos) = literal.rfind('@') {
&literal[..at_pos] == label
} else {
literal == label
}
}
Some(expected_lang) => {
if let Some(at_pos) = literal.rfind('@') {
let val = &literal[..at_pos];
let lit_lang = &literal[at_pos + 1..];
val == label && lit_lang.eq_ignore_ascii_case(expected_lang)
} else {
// No language tag — only match if caller did not request specific lang
literal == label
}
}
}
}
// ---------------------------------------------------------------------------
// ConceptNode / ConceptTree
// ---------------------------------------------------------------------------
/// A tree node representing a concept and its narrower concepts (children).
#[derive(Debug, Clone)]
pub struct ConceptNode {
/// The IRI of this concept
pub iri: NamedNode,
/// The preferred label (if any)
pub pref_label: Option<String>,
/// Narrower (child) concepts in the hierarchy
pub children: Vec<ConceptNode>,
}
/// The root of a concept tree for a particular `ConceptScheme`.
#[derive(Debug, Clone)]
pub struct ConceptTree {
/// Top-level concepts of the scheme (concepts without a broader concept within the scheme)
pub root_concepts: Vec<ConceptNode>,
}