use std::collections::BTreeMap;
use crate::dictionary::Dictionary;
use crate::meta::{ClassNode, ClassRelation, CommunityDescriptor, LevelLinks, LevelRollup};
use crate::pyramid::{Dendrogram, Partition};
use crate::RDF_TYPE;
const RDFS_SUBCLASS_OF: &str = "<http://www.w3.org/2000/01/rdf-schema#subClassOf>";
const OWL_DISJOINT_WITH: &str = "<http://www.w3.org/2002/07/owl#disjointWith>";
const OWL_EQUIVALENT_CLASS: &str = "<http://www.w3.org/2002/07/owl#equivalentClass>";
const WGS_LAT: &str = "<http://www.w3.org/2003/01/geo/wgs84_pos#lat>";
const WGS_LONG: &str = "<http://www.w3.org/2003/01/geo/wgs84_pos#long>";
const MAX_LEVELS: usize = 6;
const TOP_CLASSES: usize = 64;
const TOP_LINKS: usize = 128;
const MAX_HIERARCHY: usize = 512;
const TOP_DESCRIPTORS: usize = 256;
const MAX_CHAIN: u16 = 64;
#[derive(Debug, Clone, Default)]
pub struct SchemaPyramid {
pub class_hierarchy: Vec<ClassNode>,
pub level_rollups: Vec<LevelRollup>,
pub level_links: Vec<LevelLinks>,
pub descriptors: Vec<CommunityDescriptor>,
pub subclass_cycles: Vec<Vec<String>>,
pub disjoint_pairs: Vec<(String, String)>,
pub equivalent_pairs: Vec<(String, String)>,
}
fn pick_type_predicate(
dict: &Dictionary,
triples: &[(u32, u32, u32)],
type_override: Option<&str>,
) -> Option<u32> {
if let Some(tp) = type_override {
return dict.predicate_id(tp);
}
if let Some(pid) = dict.predicate_id(RDF_TYPE) {
if triples.iter().any(|&(_, p, _)| p == pid) {
return Some(pid);
}
}
use std::collections::{HashMap, HashSet};
let total_subjects: HashSet<u32> = triples.iter().map(|&(s, _, _)| s).collect();
if total_subjects.is_empty() {
return None;
}
let mut subs: HashMap<u32, HashSet<u32>> = HashMap::new();
let mut objs: HashMap<u32, HashSet<u32>> = HashMap::new();
for &(s, p, o) in triples {
if dict
.object_term(o)
.map(|t| !t.starts_with('"'))
.unwrap_or(false)
{
subs.entry(p).or_default().insert(s);
objs.entry(p).or_default().insert(o);
}
}
let mut best: Option<(u32, usize)> = None;
for (&p, ss) in &subs {
let no = objs.get(&p).map(|o| o.len()).unwrap_or(0);
if no == 0 {
continue;
}
if ss.len() / no >= 8
&& ss.len() * 2 >= total_subjects.len()
&& best.is_none_or(|(_, c)| ss.len() > c)
{
best = Some((p, ss.len()));
}
}
best.map(|(p, _)| p)
}
pub fn build_type_dendrogram(
dict: &Dictionary,
triples: &[(u32, u32, u32)],
type_override: Option<&str>,
) -> Option<Dendrogram> {
use std::collections::{BTreeMap, BTreeSet, HashMap};
let type_pid = pick_type_predicate(dict, triples, type_override)?;
let mut class_of_subject: HashMap<u32, u32> = HashMap::new();
let mut classes: BTreeSet<u32> = BTreeSet::new();
for &(s, p, o) in triples {
if p == type_pid {
class_of_subject.entry(s).or_insert(o);
classes.insert(o);
}
}
if classes.is_empty() {
return None;
}
let class_comm: HashMap<u32, usize> =
classes.iter().enumerate().map(|(i, &c)| (c, i)).collect();
let untyped = classes.len();
let n = dict.node_count() as usize;
let mut comm = vec![untyped; n];
for (&sid, &cid) in &class_of_subject {
comm[dict.subject_node(sid) as usize] = class_comm[&cid];
}
let mut remap: BTreeMap<usize, usize> = BTreeMap::new();
for c in &mut comm {
let next = remap.len();
*c = *remap.entry(*c).or_insert(next);
}
Some(Dendrogram {
levels: vec![Partition {
comm,
count: remap.len(),
}],
})
}
pub fn build_schema_pyramid(
dict: &Dictionary,
triples: &[(u32, u32, u32)],
dend: &Dendrogram,
round: usize,
) -> SchemaPyramid {
build_schema_pyramid_with(dict, triples, dend, round, None)
}
pub fn build_schema_pyramid_with(
dict: &Dictionary,
triples: &[(u32, u32, u32)],
dend: &Dendrogram,
round: usize,
type_override: Option<&str>,
) -> SchemaPyramid {
let type_pid = match pick_type_predicate(dict, triples, type_override) {
Some(p) => p,
None => return SchemaPyramid::default(),
};
let subclass_pid = dict.predicate_id(RDFS_SUBCLASS_OF);
let lat_pid = dict.predicate_id(WGS_LAT);
let long_pid = dict.predicate_id(WGS_LONG);
let mut instance_counts: BTreeMap<String, u64> = BTreeMap::new();
let mut subject_class: BTreeMap<u32, String> = BTreeMap::new();
let mut class_of_iri: BTreeMap<String, String> = BTreeMap::new();
for &(s, p, o) in triples {
if p == type_pid {
if let Some(class) = dict.object_term(o) {
*instance_counts.entry(class.clone()).or_default() += 1;
subject_class.entry(s).or_insert_with(|| class.clone());
if let Some(iri) = dict.subject_term(s) {
class_of_iri.entry(iri).or_insert(class);
}
}
}
}
if instance_counts.is_empty() {
return SchemaPyramid::default();
}
let mut parents: BTreeMap<String, std::collections::BTreeSet<String>> = BTreeMap::new();
let mut self_loops: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
if let Some(sc_pid) = subclass_pid {
for &(s, p, o) in triples {
if p == sc_pid {
if let (Some(child), Some(parent)) = (dict.subject_term(s), dict.object_term(o)) {
if child != parent {
parents.entry(child).or_default().insert(parent);
} else {
self_loops.insert(child);
}
}
}
}
}
let pair_set = |pid: Option<u32>| -> Vec<(String, String)> {
let mut set: std::collections::BTreeSet<(String, String)> =
std::collections::BTreeSet::new();
if let Some(pid) = pid {
for &(s, p, o) in triples {
if p == pid {
if let (Some(a), Some(b)) = (dict.subject_term(s), dict.object_term(o)) {
if a != b {
let (lo, hi) = if a < b { (a, b) } else { (b, a) };
set.insert((lo, hi));
}
}
}
}
}
set.into_iter().collect()
};
let disjoint_pairs = pair_set(dict.predicate_id(OWL_DISJOINT_WITH));
let equivalent_pairs = pair_set(dict.predicate_id(OWL_EQUIVALENT_CLASS));
let subclass_cycles = subclass_cycles_of(&parents, &self_loops);
let canonical: BTreeMap<String, String> = parents
.iter()
.filter_map(|(c, ps)| ps.iter().next().map(|p| (c.clone(), p.clone())))
.collect();
let mut classes: std::collections::BTreeSet<String> = instance_counts.keys().cloned().collect();
for (c, ps) in &parents {
classes.insert(c.clone());
for p in ps {
classes.insert(p.clone());
}
}
let depth: BTreeMap<String, u16> = classes
.iter()
.map(|c| (c.clone(), depth_of(c, &canonical)))
.collect();
let max_depth = depth.values().copied().max().unwrap_or(0);
let mut class_hierarchy: Vec<ClassNode> = classes
.iter()
.map(|c| ClassNode {
class: c.clone(),
parents: parents
.get(c)
.map(|ps| ps.iter().cloned().collect())
.unwrap_or_default(),
depth: depth.get(c).copied().unwrap_or(0),
})
.collect();
class_hierarchy.sort_by(|a, b| a.depth.cmp(&b.depth).then_with(|| a.class.cmp(&b.class)));
class_hierarchy.truncate(MAX_HIERARCHY);
let classify_obj = |term: &str| -> String {
if term.starts_with('"') {
"(literal)".to_string()
} else {
class_of_iri
.get(term)
.cloned()
.unwrap_or_else(|| "(untyped)".to_string())
}
};
let mut leaf_links: BTreeMap<(String, String, String), u64> = BTreeMap::new();
for &(s, p, o) in triples {
if p == type_pid || Some(p) == subclass_pid {
continue;
}
let (s_iri, o_term, pred) = match (
dict.subject_term(s),
dict.object_term(o),
dict.predicate_term(p),
) {
(Some(a), Some(b), Some(c)) => (a, b, c),
_ => continue,
};
let sc = class_of_iri
.get(&s_iri)
.cloned()
.unwrap_or_else(|| "(untyped)".to_string());
let oc = classify_obj(&o_term);
*leaf_links.entry((sc, pred, oc)).or_default() += 1;
}
let n_levels = (max_depth as usize + 1).clamp(1, MAX_LEVELS);
let dend_rounds = dend.rounds();
let mut level_rollups = Vec::with_capacity(n_levels);
let mut level_links = Vec::with_capacity(n_levels);
for i in 0..n_levels {
let target_depth = if n_levels == 1 {
0
} else {
((i * max_depth as usize) / (n_levels - 1)) as u16
};
let round_align = if dend_rounds > 1 && n_levels > 1 {
(((n_levels - 1 - i) * (dend_rounds - 1)) / (n_levels - 1)) as u32
} else {
round as u32
};
let mut roll: BTreeMap<String, u64> = BTreeMap::new();
for (class, &count) in &instance_counts {
*roll
.entry(ancestor_at(class, target_depth, &depth, &canonical))
.or_default() += count;
}
let mut hist: Vec<(String, u64)> = roll.into_iter().collect();
hist.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
hist.truncate(TOP_CLASSES);
level_rollups.push(LevelRollup {
round: round_align,
depth: target_depth,
classes: hist,
});
let mut rel: BTreeMap<(String, String, String), u64> = BTreeMap::new();
for ((sc, pred, oc), &count) in &leaf_links {
let sa = ancestor_at(sc, target_depth, &depth, &canonical);
let oa = ancestor_at(oc, target_depth, &depth, &canonical);
*rel.entry((sa, pred.clone(), oa)).or_default() += count;
}
let mut links: Vec<ClassRelation> = rel
.into_iter()
.map(|((s, p, o), count)| ClassRelation {
s_class: s,
predicate: p,
o_class: o,
count,
})
.collect();
links.sort_by(|a, b| {
b.count
.cmp(&a.count)
.then_with(|| a.s_class.cmp(&b.s_class))
.then_with(|| a.predicate.cmp(&b.predicate))
.then_with(|| a.o_class.cmp(&b.o_class))
});
links.truncate(TOP_LINKS);
level_links.push(LevelLinks {
round: round_align,
depth: target_depth,
links,
});
}
let descriptors = build_descriptors(
dict,
triples,
dend,
round,
&subject_class,
lat_pid,
long_pid,
type_pid,
);
SchemaPyramid {
class_hierarchy,
level_rollups,
level_links,
descriptors,
subclass_cycles,
disjoint_pairs,
equivalent_pairs,
}
}
fn subclass_cycles_of(
parents: &BTreeMap<String, std::collections::BTreeSet<String>>,
self_loops: &std::collections::BTreeSet<String>,
) -> Vec<Vec<String>> {
let mut node_set: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
for (c, ps) in parents {
node_set.insert(c.as_str());
for p in ps {
node_set.insert(p.as_str());
}
}
let nodes: Vec<&str> = node_set.into_iter().collect();
let idx_of: BTreeMap<&str, usize> = nodes.iter().enumerate().map(|(i, &n)| (n, i)).collect();
let adj: Vec<Vec<usize>> = nodes
.iter()
.map(|&n| {
let mut v: Vec<usize> = parents
.get(n)
.map(|ps| ps.iter().map(|p| idx_of[p.as_str()]).collect())
.unwrap_or_default();
v.sort_unstable();
v
})
.collect();
let n = nodes.len();
let mut index = vec![usize::MAX; n];
let mut low = vec![0usize; n];
let mut on_stack = vec![false; n];
let mut stack: Vec<usize> = Vec::new();
let mut next = 0usize;
let mut comps: Vec<Vec<String>> = Vec::new();
for start in 0..n {
if index[start] != usize::MAX {
continue;
}
index[start] = next;
low[start] = next;
next += 1;
stack.push(start);
on_stack[start] = true;
let mut call: Vec<(usize, usize)> = vec![(start, 0)];
while let Some(&(v, ci)) = call.last() {
if ci < adj[v].len() {
call.last_mut().unwrap().1 += 1;
let w = adj[v][ci];
if index[w] == usize::MAX {
index[w] = next;
low[w] = next;
next += 1;
stack.push(w);
on_stack[w] = true;
call.push((w, 0));
} else if on_stack[w] {
low[v] = low[v].min(index[w]);
}
} else {
if low[v] == index[v] {
let mut comp: Vec<String> = Vec::new();
loop {
let w = stack.pop().unwrap();
on_stack[w] = false;
comp.push(nodes[w].to_string());
if w == v {
break;
}
}
if comp.len() > 1 {
comp.sort();
comps.push(comp);
}
}
call.pop();
if let Some(&(parent, _)) = call.last() {
low[parent] = low[parent].min(low[v]);
}
}
}
}
for s in self_loops {
comps.push(vec![s.clone()]);
}
comps.sort();
comps.dedup();
comps.truncate(TOP_CLASSES);
comps
}
fn depth_of(class: &str, parent: &BTreeMap<String, String>) -> u16 {
let mut d = 0u16;
let mut cur = class.to_string();
let mut seen = std::collections::BTreeSet::new();
loop {
if !seen.insert(cur.clone()) || d >= MAX_CHAIN {
break;
}
match parent.get(&cur) {
Some(p) if p != &cur => {
d = d.saturating_add(1);
cur = p.clone();
}
_ => break,
}
}
d
}
fn ancestor_at(
class: &str,
target: u16,
depth: &BTreeMap<String, u16>,
parent: &BTreeMap<String, String>,
) -> String {
let mut cur = class.to_string();
let mut steps = 0u16;
while depth.get(&cur).copied().unwrap_or(0) > target && steps < MAX_CHAIN {
match parent.get(&cur) {
Some(p) if p != &cur => cur = p.clone(),
_ => break,
}
steps += 1;
}
cur
}
#[allow(clippy::too_many_arguments)]
fn build_descriptors(
dict: &Dictionary,
triples: &[(u32, u32, u32)],
dend: &Dendrogram,
round: usize,
subject_class: &BTreeMap<u32, String>,
lat_pid: Option<u32>,
long_pid: Option<u32>,
type_pid: u32,
) -> Vec<CommunityDescriptor> {
let comm_of = |sid: u32| -> usize {
if dend.rounds() == 0 {
0
} else {
dend.base_community(dict.subject_node(sid) as usize, round)
}
};
#[derive(Default)]
struct Acc {
class_counts: BTreeMap<String, u64>,
members: u64,
lat: Option<(f64, f64)>,
lon: Option<(f64, f64)>,
time: Option<(String, String)>,
}
let mut acc: BTreeMap<usize, Acc> = BTreeMap::new();
for (&sid, class) in subject_class {
let a = acc.entry(comm_of(sid)).or_default();
*a.class_counts.entry(class.clone()).or_default() += 1;
a.members += 1;
}
for &(s, p, o) in triples {
if p == type_pid {
continue;
}
let term = match dict.object_term(o) {
Some(t) => t,
None => continue,
};
if Some(p) == lat_pid {
if let Some(v) = literal_f64(&term) {
let a = acc.entry(comm_of(s)).or_default();
a.lat = Some(merge_min_max(a.lat, v));
}
} else if Some(p) == long_pid {
if let Some(v) = literal_f64(&term) {
let a = acc.entry(comm_of(s)).or_default();
a.lon = Some(merge_min_max(a.lon, v));
}
} else if let Some(val) = literal_temporal(&term) {
let a = acc.entry(comm_of(s)).or_default();
a.time = Some(match a.time.take() {
Some((lo, hi)) => (
if val < lo.as_str() {
val.to_string()
} else {
lo
},
if val > hi.as_str() {
val.to_string()
} else {
hi
},
),
None => (val.to_string(), val.to_string()),
});
}
}
let mut out: Vec<CommunityDescriptor> = acc
.into_iter()
.filter(|(_, a)| a.members > 0)
.map(|(community, a)| {
let dominant_class = a
.class_counts
.iter()
.max_by(|x, y| x.1.cmp(y.1).then_with(|| y.0.cmp(x.0)))
.map(|(c, _)| c.clone());
let mut class_counts: Vec<(String, u64)> = a.class_counts.into_iter().collect();
class_counts.sort_by(|x, y| y.1.cmp(&x.1).then_with(|| x.0.cmp(&y.0)));
let bbox = match (a.lat, a.lon) {
(Some((min_lat, max_lat)), Some((min_lon, max_lon))) => {
Some([min_lon, min_lat, max_lon, max_lat])
}
_ => None,
};
CommunityDescriptor {
community: community as u32,
dominant_class,
class_counts,
bbox,
time_range: a.time,
}
})
.collect();
out.sort_by(|a, b| {
let am: u64 = a.class_counts.iter().map(|(_, c)| c).sum();
let bm: u64 = b.class_counts.iter().map(|(_, c)| c).sum();
bm.cmp(&am).then_with(|| a.community.cmp(&b.community))
});
out.truncate(TOP_DESCRIPTORS);
out.sort_by_key(|d| d.community);
out
}
fn merge_min_max(cur: Option<(f64, f64)>, v: f64) -> (f64, f64) {
match cur {
Some((lo, hi)) => (lo.min(v), hi.max(v)),
None => (v, v),
}
}
fn literal_f64(term: &str) -> Option<f64> {
literal_value(term)?.parse::<f64>().ok()
}
fn literal_temporal(term: &str) -> Option<&str> {
let val = literal_value(term)?;
let is_date_dt = term.contains("XMLSchema#date")
|| term.contains("XMLSchema#dateTime")
|| term.contains("XMLSchema#gYear");
let is_year_shaped = {
let head = val.strip_prefix('-').unwrap_or(val);
head.len() >= 4 && head.as_bytes()[..4].iter().all(u8::is_ascii_digit)
};
if is_date_dt || is_year_shaped {
Some(val)
} else {
None
}
}
fn literal_value(term: &str) -> Option<&str> {
let bytes = term.as_bytes();
if bytes.first() != Some(&b'"') {
return None;
}
let mut i = 1;
let mut esc = false;
while i < bytes.len() {
match bytes[i] {
b'\\' => esc = !esc,
b'"' if !esc => return Some(&term[1..i]),
_ => esc = false,
}
i += 1;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dictionary::DictionaryBuilder;
use crate::pyramid::{build_dendrogram, project_graph};
const TYPE: &str = RDF_TYPE;
const SUB: &str = RDFS_SUBCLASS_OF;
fn build(triples: &[(&str, &str, &str)]) -> (Dictionary, Vec<(u32, u32, u32)>, Dendrogram) {
let mut db = DictionaryBuilder::new();
for (s, p, o) in triples {
db.observe(s, p, o);
}
let dict = db.build();
let ids: Vec<_> = triples
.iter()
.map(|(s, p, o)| dict.encode(s, p, o).unwrap())
.collect();
let g = project_graph(&dict, &ids);
let dend = build_dendrogram(&g);
(dict, ids, dend)
}
#[test]
fn type_dendrogram_partitions_by_class() {
let triples = vec![
("<a>", TYPE, "<Concept>"),
("<b>", TYPE, "<Concept>"),
("<r>", TYPE, "<Relation>"),
("<r>", "<cause>", "<a>"),
("<r>", "<effect>", "<b>"),
("<a>", "<label>", "\"a\""), ];
let (dict, ids, _louvain) = build(&triples);
let dend =
build_type_dendrogram(&dict, &ids, None).expect("typed graph yields a dendrogram");
assert_eq!(dend.rounds(), 1, "flat single-level type partition");
let comm = |t: &str| {
let sid = dict.subject_id(t).unwrap();
dend.base_community(dict.subject_node(sid) as usize, 0)
};
assert_eq!(comm("<a>"), comm("<b>"), "same class → same community");
assert_ne!(
comm("<a>"),
comm("<r>"),
"different class → different community"
);
let descriptors = build_schema_pyramid(&dict, &ids, &dend, 0).descriptors;
let dom = |c: u32| {
descriptors
.iter()
.find(|d| d.community == c)
.and_then(|d| d.dominant_class.clone())
};
assert_eq!(dom(comm("<a>") as u32).as_deref(), Some("<Concept>"));
assert_eq!(dom(comm("<r>") as u32).as_deref(), Some("<Relation>"));
}
#[test]
fn type_dendrogram_none_without_types() {
let triples = vec![("<a>", "<knows>", "<b>"), ("<b>", "<knows>", "<c>")];
let (dict, ids, _l) = build(&triples);
assert!(build_type_dendrogram(&dict, &ids, None).is_none());
}
#[test]
fn detects_custom_type_predicate_when_no_rdf_type() {
let mut owned: Vec<(String, String, String)> = Vec::new();
for i in 0..20 {
owned.push((format!("<e{i}>"), "<P31>".into(), "<Human>".into()));
}
for i in 20..32 {
owned.push((format!("<e{i}>"), "<P31>".into(), "<City>".into()));
}
for i in 0..32 {
owned.push((format!("<e{i}>"), "<label>".into(), format!("\"name{i}\"")));
}
let refs: Vec<(&str, &str, &str)> = owned
.iter()
.map(|(s, p, o)| (s.as_str(), p.as_str(), o.as_str()))
.collect();
let (dict, ids, dend) = build(&refs);
let sp = build_schema_pyramid(&dict, &ids, &dend, 0);
let fine = sp
.level_rollups
.last()
.expect("custom type predicate should yield a schema pyramid");
let cls: std::collections::HashMap<&str, u64> =
fine.classes.iter().map(|(c, n)| (c.as_str(), *n)).collect();
assert_eq!(cls.get("<Human>"), Some(&20));
assert_eq!(cls.get("<City>"), Some(&12));
assert!(!cls.contains_key("<label>"));
}
#[test]
fn coarse_levels_are_ancestors_of_fine_levels() {
let triples = vec![
("<Scientist>", SUB, "<Person>"),
("<Person>", SUB, "<Agent>"),
("<Astronomer>", SUB, "<Scientist>"),
("<a>", TYPE, "<Astronomer>"),
("<b>", TYPE, "<Astronomer>"),
("<c>", TYPE, "<Person>"),
("<a>", "<knows>", "<b>"),
];
let (dict, ids, dend) = build(&triples);
let sp = build_schema_pyramid(&dict, &ids, &dend, 0);
let hierarchy = sp.class_hierarchy;
let rollups = sp.level_rollups;
let depth = |c: &str| hierarchy.iter().find(|n| n.class == c).map(|n| n.depth);
assert_eq!(depth("<Agent>"), Some(0));
assert_eq!(depth("<Person>"), Some(1));
assert_eq!(depth("<Astronomer>"), Some(3));
assert!(rollups.len() >= 2, "multi-level pyramid");
let coarse = &rollups[0];
assert_eq!(coarse.depth, 0);
assert!(coarse
.classes
.iter()
.any(|(c, n)| c == "<Agent>" && *n == 3));
let fine = rollups.last().unwrap();
assert!(fine.depth >= coarse.depth);
assert!(fine.classes.iter().any(|(c, _)| c == "<Astronomer>"));
for (cc, _) in &coarse.classes {
assert!(
fine.classes
.iter()
.any(|(fc, _)| is_ancestor_or_equal(cc, fc, &hierarchy)),
"coarse class {cc} should be an ancestor of a fine class"
);
}
assert!(
rollups[0].round >= rollups.last().unwrap().round,
"abstract level should align with a coarser (>=) round"
);
}
#[test]
fn non_exclusive_hierarchy_keeps_all_parents() {
let triples = vec![
("<Scientist>", SUB, "<Person>"),
("<Explorer>", SUB, "<Person>"),
("<Astronaut>", SUB, "<Scientist>"),
("<Astronaut>", SUB, "<Explorer>"),
("<x>", TYPE, "<Astronaut>"),
("<y>", TYPE, "<Astronaut>"),
("<x>", "<knows>", "<y>"),
];
let (dict, ids, dend) = build(&triples);
let sp = build_schema_pyramid(&dict, &ids, &dend, 0);
let astro = sp
.class_hierarchy
.iter()
.find(|n| n.class == "<Astronaut>")
.unwrap();
assert_eq!(
astro.parents,
vec!["<Explorer>".to_string(), "<Scientist>".to_string()]
);
assert_eq!(
astro.canonical_parent().map(String::as_str),
Some("<Explorer>")
);
}
#[test]
fn level_links_roll_relations_up_the_hierarchy() {
let triples = vec![
("<Person>", SUB, "<Agent>"),
("<Organisation>", SUB, "<Agent>"),
("<ada>", TYPE, "<Person>"),
("<bob>", TYPE, "<Person>"),
("<nasa>", TYPE, "<Organisation>"),
("<ada>", "<memberOf>", "<nasa>"),
("<bob>", "<memberOf>", "<nasa>"),
];
let (dict, ids, dend) = build(&triples);
let sp = build_schema_pyramid(&dict, &ids, &dend, 0);
assert!(!sp.level_links.is_empty(), "lateral relations present");
let leaf = sp.level_links.last().unwrap();
assert!(leaf.links.iter().any(|r| r.s_class == "<Person>"
&& r.predicate == "<memberOf>"
&& r.o_class == "<Organisation>"
&& r.count == 2));
let coarse = &sp.level_links[0];
assert_eq!(coarse.depth, 0);
assert!(coarse.links.iter().any(|r| r.s_class == "<Agent>"
&& r.predicate == "<memberOf>"
&& r.o_class == "<Agent>"
&& r.count == 2));
}
#[test]
fn per_descriptor_counts_sum_to_community_members() {
let names = ["A", "B", "C", "D", "E", "F"];
let edges = [
("A", "B"),
("B", "C"),
("A", "C"),
("D", "E"),
("E", "F"),
("D", "F"),
("C", "D"),
];
let mut triples: Vec<(String, String, String)> = names
.iter()
.map(|n| (format!("<{n}>"), TYPE.to_string(), format!("<T{n}>")))
.collect();
for (s, o) in edges {
triples.push((format!("<{s}>"), "<knows>".into(), format!("<{o}>")));
}
let refs: Vec<(&str, &str, &str)> = triples
.iter()
.map(|(s, p, o)| (s.as_str(), p.as_str(), o.as_str()))
.collect();
let (dict, ids, dend) = build(&refs);
let descriptors = build_schema_pyramid(&dict, &ids, &dend, 0).descriptors;
let type_pid = dict.predicate_id(RDF_TYPE).unwrap();
let comm_of = |sid: u32| -> u32 {
if dend.rounds() == 0 {
0
} else {
dend.base_community(dict.subject_node(sid) as usize, 0) as u32
}
};
let mut expected: BTreeMap<u32, u64> = BTreeMap::new();
let mut seen = std::collections::BTreeSet::new();
for &(s, p, _o) in &ids {
if p == type_pid && seen.insert(s) {
*expected.entry(comm_of(s)).or_default() += 1;
}
}
assert!(!descriptors.is_empty());
for d in &descriptors {
let sum: u64 = d.class_counts.iter().map(|(_, c)| c).sum();
assert_eq!(
sum, expected[&d.community],
"descriptor {} counts must sum to its members",
d.community
);
}
let total: u64 = descriptors
.iter()
.flat_map(|d| d.class_counts.iter().map(|(_, c)| *c))
.sum();
assert_eq!(total, 6);
}
#[test]
fn literal_value_handles_escaped_quotes() {
assert_eq!(literal_value(r#""1850""#), Some("1850"));
assert_eq!(literal_value(r#""a\"b""#), Some(r#"a\"b"#));
assert_eq!(
literal_value(r#""1850"^^<http://www.w3.org/2001/XMLSchema#gYear>"#),
Some("1850")
);
assert_eq!(literal_value("<http://ex/x>"), None);
assert_eq!(literal_temporal(r#""1850-01-02""#), Some("1850-01-02"));
assert_eq!(literal_temporal(r#""hello""#), None);
}
fn is_ancestor_or_equal(anc: &str, c: &str, h: &[ClassNode]) -> bool {
let mut cur = c.to_string();
for _ in 0..64 {
if cur == anc {
return true;
}
match h
.iter()
.find(|n| n.class == cur)
.and_then(|n| n.canonical_parent().cloned())
{
Some(p) => cur = p,
None => return false,
}
}
false
}
#[test]
fn no_hierarchy_degrades_to_flat_histogram() {
let triples = vec![
("<a>", TYPE, "<Person>"),
("<b>", TYPE, "<Person>"),
("<c>", TYPE, "<City>"),
("<a>", "<knows>", "<b>"),
];
let (dict, ids, dend) = build(&triples);
let sp = build_schema_pyramid(&dict, &ids, &dend, 0);
let hierarchy = sp.class_hierarchy;
let rollups = sp.level_rollups;
assert!(hierarchy
.iter()
.all(|n| n.depth == 0 && n.parents.is_empty()));
assert_eq!(rollups.len(), 1, "flat = one depth-0 level");
let flat = &rollups[0];
assert_eq!(flat.depth, 0);
assert!(flat.classes.contains(&("<Person>".to_string(), 2)));
assert!(flat.classes.contains(&("<City>".to_string(), 1)));
}
#[test]
fn descriptor_class_counts_sum_to_typed_subjects() {
let triples = vec![
("<a>", TYPE, "<Person>"),
("<b>", TYPE, "<Person>"),
(
"<a>",
WGS_LAT,
"\"41.9\"^^<http://www.w3.org/2001/XMLSchema#double>",
),
(
"<a>",
WGS_LONG,
"\"12.5\"^^<http://www.w3.org/2001/XMLSchema#double>",
),
(
"<b>",
WGS_LAT,
"\"45.0\"^^<http://www.w3.org/2001/XMLSchema#double>",
),
(
"<b>",
WGS_LONG,
"\"9.0\"^^<http://www.w3.org/2001/XMLSchema#double>",
),
(
"<a>",
"<born>",
"\"1850\"^^<http://www.w3.org/2001/XMLSchema#gYear>",
),
(
"<b>",
"<born>",
"\"1875\"^^<http://www.w3.org/2001/XMLSchema#gYear>",
),
("<a>", "<knows>", "<b>"),
];
let (dict, ids, dend) = build(&triples);
let descriptors = build_schema_pyramid(&dict, &ids, &dend, 0).descriptors;
assert!(!descriptors.is_empty());
let total: u64 = descriptors
.iter()
.flat_map(|d| d.class_counts.iter().map(|(_, c)| *c))
.sum();
assert_eq!(total, 2, "every typed subject counted once");
let boxes: Vec<[f64; 4]> = descriptors.iter().filter_map(|d| d.bbox).collect();
assert!(!boxes.is_empty(), "geometry descriptors present");
for b in &boxes {
assert!(b[0] <= b[2] && b[1] <= b[3], "valid box {b:?}");
}
let gmin_lon = boxes.iter().map(|b| b[0]).fold(f64::INFINITY, f64::min);
let gmax_lon = boxes.iter().map(|b| b[2]).fold(f64::NEG_INFINITY, f64::max);
let gmin_lat = boxes.iter().map(|b| b[1]).fold(f64::INFINITY, f64::min);
let gmax_lat = boxes.iter().map(|b| b[3]).fold(f64::NEG_INFINITY, f64::max);
assert!(
gmin_lon <= 9.0 && gmax_lon >= 12.5,
"lon union covers points"
);
assert!(
gmin_lat <= 41.9 && gmax_lat >= 45.0,
"lat union covers points"
);
let times: Vec<(String, String)> = descriptors
.iter()
.filter_map(|d| d.time_range.clone())
.collect();
assert!(!times.is_empty(), "temporal descriptors present");
let gfrom = times.iter().map(|(f, _)| f.as_str()).min().unwrap();
let gto = times.iter().map(|(_, t)| t.as_str()).max().unwrap();
assert!(gfrom <= "1850" && gto >= "1875", "time union covers years");
}
}