use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use rete_core::{Header, RDF_TYPE};
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub(crate) struct DatasetCard {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created: Option<String>,
pub triple_count: u64,
pub quad_count: u64,
pub named_graph_count: u64,
pub term_count: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub predicates: Vec<(String, u64)>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub classes: Vec<(String, u64)>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub vocabularies: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub example_queries: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub datatypes: Vec<(String, u64)>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub languages: Vec<(String, u64)>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub class_links: Vec<ClassLink>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub top_hubs: Vec<(String, u64)>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub in_hubs: Vec<(String, u64)>,
#[serde(default, skip_serializing_if = "Signals::is_empty")]
pub signals: Signals,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub queries: Vec<ExampleQuery>,
#[serde(default, skip_serializing_if = "Coherence::is_empty")]
pub coherence: Coherence,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub truncated: bool,
pub format_version: u8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum Tier {
Card,
Summary,
Index,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct ExampleQuery {
pub id: String,
pub title: String,
pub dimension: String,
pub question: String,
pub sparql: String,
pub tier: Tier,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub requires: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct ClassLink {
pub s_class: String,
pub predicate: String,
pub o_class: String,
pub count: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub(crate) struct Signals {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label_predicate: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_lang: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_iri: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub time_predicates: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub numeric_predicates: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub link_predicates: Vec<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub geo_wkt: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub geo_latlong: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub temporal_extent: Option<(String, String)>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spatial_bbox: Option<[f64; 4]>,
}
impl Signals {
fn is_empty(&self) -> bool {
*self == Signals::default()
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub(crate) struct Coherence {
pub coherent: bool,
pub inconsistency_count: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub by_kind: Vec<(String, u32)>,
pub scope: String,
pub rules: String,
pub materialized: bool,
}
impl Coherence {
pub(crate) fn is_empty(&self) -> bool {
*self == Coherence::default()
}
pub(crate) fn from_reasoning(r: &rete_core::Reasoning, materialized: bool) -> Self {
let mut hist: BTreeMap<&str, u32> = BTreeMap::new();
for inc in &r.inconsistencies {
*hist.entry(inc.kind).or_default() += 1;
}
Coherence {
coherent: r.inconsistencies.is_empty(),
inconsistency_count: r.inconsistencies.len() as u32,
by_kind: hist.into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
scope: "default".to_string(),
rules: rete_core::REASON_RULESET.to_string(),
materialized,
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
pub(crate) struct CardInput {
pub title: Option<String>,
pub description: Option<String>,
pub license: Option<String>,
pub source: Option<String>,
pub created: Option<String>,
#[serde(default)]
pub example_queries: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct CardArgs {
pub enabled: bool,
pub file: Option<String>,
pub title: Option<String>,
pub license: Option<String>,
pub source: Option<String>,
pub description: Option<String>,
pub created: Option<String>,
}
impl CardArgs {
pub fn requested(&self) -> bool {
self.enabled
|| self.file.is_some()
|| self.title.is_some()
|| self.license.is_some()
|| self.source.is_some()
|| self.description.is_some()
|| self.created.is_some()
}
}
impl DatasetCard {
pub(crate) fn to_json_bytes(&self) -> Vec<u8> {
serde_json::to_vec(self).expect("DatasetCard serializes")
}
pub(crate) fn from_json_bytes(b: &[u8]) -> anyhow::Result<Self> {
serde_json::from_slice(b).map_err(|e| anyhow::anyhow!("malformed dataset card: {e}"))
}
pub(crate) fn with_coherence(mut self, r: &rete_core::Reasoning, materialized: bool) -> Self {
self.coherence = Coherence::from_reasoning(r, materialized);
self
}
}
pub(crate) fn card_json(card: &DatasetCard) -> serde_json::Value {
let mut value = serde_json::to_value(card).expect("DatasetCard serializes");
value
.as_object_mut()
.expect("DatasetCard JSON is an object")
.insert(
"schemaVersion".into(),
serde_json::json!(crate::JSON_SCHEMA_VERSION),
);
value
}
pub(crate) fn load_curated(args: &CardArgs) -> anyhow::Result<CardInput> {
let mut c = match &args.file {
Some(path) => {
let text = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("reading --card-file {path}: {e}"))?;
serde_json::from_str(&text)
.map_err(|e| anyhow::anyhow!("parsing --card-file {path}: {e}"))?
}
None => CardInput::default(),
};
if args.title.is_some() {
c.title = args.title.clone();
}
if args.license.is_some() {
c.license = args.license.clone();
}
if args.source.is_some() {
c.source = args.source.clone();
}
if args.description.is_some() {
c.description = args.description.clone();
}
if args.created.is_some() {
c.created = args.created.clone();
}
Ok(c)
}
const CARD_TOP_N: usize = 100;
const RDFS_LABEL: &str = "<http://www.w3.org/2000/01/rdf-schema#label>";
const SKOS_PREFLABEL: &str = "<http://www.w3.org/2004/02/skos/core#prefLabel>";
const SCHEMA_NAME: &str = "<http://schema.org/name>";
const FOAF_NAME: &str = "<http://xmlns.com/foaf/0.1/name>";
const DCT_TITLE: &str = "<http://purl.org/dc/terms/title>";
const OWL_SAMEAS: &str = "<http://www.w3.org/2002/07/owl#sameAs>";
const SKOS_EXACTMATCH: &str = "<http://www.w3.org/2004/02/skos/core#exactMatch>";
const SKOS_CLOSEMATCH: &str = "<http://www.w3.org/2004/02/skos/core#closeMatch>";
const RDFS_SEEALSO: &str = "<http://www.w3.org/2000/01/rdf-schema#seeAlso>";
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 GEO_ASWKT: &str = "<http://www.opengis.net/ont/geosparql#asWKT>";
const GEO_HASGEOMETRY: &str = "<http://www.opengis.net/ont/geosparql#hasGeometry>";
const GEO_WKTLITERAL: &str = "http://www.opengis.net/ont/geosparql#wktLiteral";
const XSD_STRING: &str = "http://www.w3.org/2001/XMLSchema#string";
const RDF_LANGSTRING: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString";
const LABEL_PREDICATES: &[&str] = &[
SKOS_PREFLABEL,
RDFS_LABEL,
SCHEMA_NAME,
FOAF_NAME,
DCT_TITLE,
];
const LINK_PREDICATES: &[&str] = &[OWL_SAMEAS, SKOS_EXACTMATCH, SKOS_CLOSEMATCH, RDFS_SEEALSO];
pub(crate) trait CardTripleSource {
fn replay(&self, f: &mut dyn FnMut(&str, &str, &str));
}
pub(crate) fn curated_counts_card(
statements: u64,
term_count: u64,
curated: CardInput,
) -> DatasetCard {
DatasetCard {
title: curated.title,
description: curated.description,
license: curated.license,
source: curated.source,
created: curated.created,
example_queries: curated.example_queries,
triple_count: statements,
quad_count: statements,
named_graph_count: 0,
term_count,
format_version: rete_core::format::CURRENT_FORMAT_VERSION,
..DatasetCard::default()
}
}
pub(crate) fn derive_card(
quads: &[(String, String, String, Option<String>)],
term_count: u64,
named_graph_count: u64,
curated: CardInput,
) -> DatasetCard {
struct Quads<'a>(&'a [(String, String, String, Option<String>)]);
impl CardTripleSource for Quads<'_> {
fn replay(&self, f: &mut dyn FnMut(&str, &str, &str)) {
for (s, p, o, g) in self.0 {
if g.is_none() {
f(s, p, o);
}
}
}
}
derive_card_from(
&Quads(quads),
quads.len() as u64,
term_count,
named_graph_count,
curated,
)
}
pub(crate) fn derive_card_encoded(
dict: &rete_core::Dictionary,
triples: &[(u32, u32, u32)],
quad_count: u64,
term_count: u64,
named_graph_count: u64,
curated: CardInput,
) -> DatasetCard {
struct Encoded<'a> {
dict: &'a rete_core::Dictionary,
triples: &'a [(u32, u32, u32)],
}
impl CardTripleSource for Encoded<'_> {
fn replay(&self, f: &mut dyn FnMut(&str, &str, &str)) {
for &(s, p, o) in self.triples {
let st = self.dict.subject_term(s).unwrap_or_default();
let pt = self.dict.predicate_term(p).unwrap_or_default();
let ot = self.dict.object_term(o).unwrap_or_default();
f(&st, &pt, &ot);
}
}
}
derive_card_from(
&Encoded { dict, triples },
quad_count,
term_count,
named_graph_count,
curated,
)
}
fn derive_card_from(
src: &dyn CardTripleSource,
quad_count: u64,
term_count: u64,
named_graph_count: u64,
curated: CardInput,
) -> DatasetCard {
let mut class_of: std::collections::HashMap<String, String> = std::collections::HashMap::new();
src.replay(&mut |s, p, o| {
if p == RDF_TYPE {
class_of.insert(s.to_string(), o.to_string());
}
});
let classify = |t: &str| -> String {
if let Some(c) = class_of.get(t) {
c.to_string()
} else if t.starts_with('"') {
"(literal)".to_string()
} else {
"(untyped)".to_string()
}
};
let mut pred_counts: BTreeMap<String, u64> = BTreeMap::new();
let mut class_counts: BTreeMap<String, u64> = BTreeMap::new();
let mut datatype_counts: BTreeMap<String, u64> = BTreeMap::new();
let mut lang_counts: BTreeMap<String, u64> = BTreeMap::new();
let mut out_degree: BTreeMap<String, u64> = BTreeMap::new();
let mut in_degree: BTreeMap<String, u64> = BTreeMap::new();
let mut link_rows: BTreeMap<(String, String, String), u64> = BTreeMap::new();
let mut subject_ns: BTreeMap<String, u64> = BTreeMap::new();
let mut time_obj_hits: BTreeMap<String, u64> = BTreeMap::new();
let mut num_obj_hits: BTreeMap<String, u64> = BTreeMap::new();
let mut time_values: BTreeMap<String, (Option<String>, Option<String>)> = BTreeMap::new();
let mut time_value_dt: BTreeMap<String, String> = BTreeMap::new();
let mut lat_lo = f64::INFINITY;
let mut lat_hi = f64::NEG_INFINITY;
let mut lon_lo = f64::INFINITY;
let mut lon_hi = f64::NEG_INFINITY;
let mut have_lat = false;
let mut have_lon = false;
let mut geo_wkt = false;
let mut triple_count = 0u64;
src.replay(&mut |s, p, o| {
triple_count += 1;
*pred_counts.entry(p.to_string()).or_default() += 1;
*out_degree.entry(s.to_string()).or_default() += 1;
if let Some(ns) = split_namespace(s) {
*subject_ns.entry(ns).or_default() += 1;
}
if !o.starts_with('"') {
*in_degree.entry(o.to_string()).or_default() += 1;
}
if p == RDF_TYPE {
*class_counts.entry(o.to_string()).or_default() += 1;
return; }
*link_rows
.entry((classify(s), p.to_string(), classify(o)))
.or_default() += 1;
if p == GEO_ASWKT || p == GEO_HASGEOMETRY {
geo_wkt = true;
}
if let Some(lit) = parse_literal(o) {
*datatype_counts
.entry(format!("<{}>", lit.datatype))
.or_default() += 1;
*lang_counts.entry(lit.lang.clone()).or_default() += 1;
if lit.datatype == GEO_WKTLITERAL {
geo_wkt = true;
}
if is_temporal_datatype(&lit.datatype) || looks_like_year(&lit.value) {
*time_obj_hits.entry(p.to_string()).or_default() += 1;
update_time_extent(p, &lit, &mut time_values, &mut time_value_dt);
} else if is_numeric_datatype(&lit.datatype) {
*num_obj_hits.entry(p.to_string()).or_default() += 1;
}
if p == WGS_LAT {
if let Ok(v) = lit.value.parse::<f64>() {
have_lat = true;
lat_lo = lat_lo.min(v);
lat_hi = lat_hi.max(v);
}
} else if p == WGS_LONG {
if let Ok(v) = lit.value.parse::<f64>() {
have_lon = true;
lon_lo = lon_lo.min(v);
lon_hi = lon_hi.max(v);
}
}
}
});
let pred_count = |iri: &str| pred_counts.get(iri).copied().unwrap_or(0);
let label_predicate = LABEL_PREDICATES
.iter()
.filter(|&&iri| pred_count(iri) > 0)
.max_by_key(|&&iri| pred_count(iri))
.map(|s| s.to_string());
let link_predicates: Vec<String> = LINK_PREDICATES
.iter()
.filter(|&&iri| pred_count(iri) > 0)
.map(|s| s.to_string())
.collect();
let mut truncated = false;
let predicates = cap(sort_desc_owned(pred_counts), &mut truncated);
let classes = cap(sort_desc_owned(class_counts), &mut truncated);
let datatypes = cap(sort_desc_owned(datatype_counts), &mut truncated);
let languages = cap(sort_desc_owned(lang_counts), &mut truncated);
let top_hubs = cap(sort_desc_owned(out_degree), &mut truncated);
let in_hubs = cap(sort_desc_owned(in_degree), &mut truncated);
let class_links = cap(sort_links(link_rows), &mut truncated);
let mut vocab: BTreeSet<String> = BTreeSet::new();
for (iri, _) in predicates.iter().chain(classes.iter()) {
if let Some(ns) = split_namespace(iri) {
vocab.insert(ns);
}
}
let vocabularies = cap(vocab.into_iter().collect(), &mut truncated);
let default_lang = languages
.iter()
.find(|(l, _)| !l.is_empty())
.map(|(l, _)| l.clone());
let base_iri = derive_base_iri(&subject_ns);
let mut time_ranked: Vec<(String, u64)> = time_obj_hits.into_iter().collect();
time_ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
let time_predicates = cap(
time_ranked.iter().map(|(p, _)| p.to_string()).collect(),
&mut truncated,
);
let mut num_ranked: Vec<(String, u64)> = num_obj_hits.into_iter().collect();
num_ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
let numeric_predicates = cap(
num_ranked.iter().map(|(p, _)| p.to_string()).collect(),
&mut truncated,
);
let geo_latlong = have_lat && have_lon;
let temporal_extent = time_ranked
.first()
.and_then(|(p, _)| time_values.get(p))
.and_then(|(lo, hi)| match (lo, hi) {
(Some(lo), Some(hi)) => Some((lo.clone(), hi.clone())),
_ => None,
});
let spatial_bbox = if geo_latlong && lon_lo.is_finite() && lat_lo.is_finite() {
Some([lon_lo, lat_lo, lon_hi, lat_hi])
} else {
None
};
let signals = Signals {
label_predicate,
default_lang,
base_iri,
time_predicates,
numeric_predicates,
link_predicates,
geo_wkt,
geo_latlong,
temporal_extent,
spatial_bbox,
};
let mut card = DatasetCard {
title: curated.title,
description: curated.description,
license: curated.license,
source: curated.source,
created: curated.created,
triple_count,
quad_count,
named_graph_count,
term_count,
predicates,
classes,
vocabularies,
example_queries: curated.example_queries,
datatypes,
languages,
class_links,
top_hubs,
in_hubs,
signals,
queries: Vec::new(),
coherence: Coherence::default(),
truncated,
format_version: rete_core::CURRENT_FORMAT_VERSION,
};
card.queries = super::queries::generate(&card);
card
}
fn cap<T>(mut v: Vec<T>, truncated: &mut bool) -> Vec<T> {
if v.len() > CARD_TOP_N {
v.truncate(CARD_TOP_N);
*truncated = true;
}
v
}
fn sort_desc_owned(counts: BTreeMap<String, u64>) -> Vec<(String, u64)> {
let mut v: Vec<(String, u64)> = counts.into_iter().collect();
v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
v
}
fn sort_links(rows: BTreeMap<(String, String, String), u64>) -> Vec<ClassLink> {
let mut v: Vec<ClassLink> = rows
.into_iter()
.map(|((s, p, o), count)| ClassLink {
s_class: s,
predicate: p,
o_class: o,
count,
})
.collect();
v.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))
});
v
}
fn derive_base_iri(ns: &BTreeMap<String, u64>) -> Option<String> {
if ns.is_empty() {
return None;
}
let dominant = ns
.iter()
.max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
.map(|(k, _)| k.clone())?;
let mut lcp_len = dominant.len();
for k in ns.keys() {
let common = dominant
.bytes()
.zip(k.bytes())
.take_while(|(a, b)| a == b)
.count();
lcp_len = lcp_len.min(common);
}
let lcp = &dominant[..lcp_len];
let base = match lcp.rfind(['/', '#']) {
Some(i) => &lcp[..=i],
None => "",
};
if base.matches('/').count() >= 3 {
Some(base.to_string())
} else {
Some(dominant)
}
}
struct Literal {
value: String,
datatype: String,
lang: String,
}
fn parse_literal(term: &str) -> Option<Literal> {
let bytes = term.as_bytes();
if bytes.first() != Some(&b'"') {
return None;
}
let mut i = 1usize;
let mut esc = false;
let mut close = None;
while i < bytes.len() {
match bytes[i] {
b'\\' => esc = !esc,
b'"' if !esc => {
close = Some(i);
break;
}
_ => esc = false,
}
i += 1;
}
let close = close?;
let value = term[1..close].to_string();
let suffix = &term[close + 1..];
if let Some(lang) = suffix.strip_prefix('@') {
Some(Literal {
value,
datatype: RDF_LANGSTRING.to_string(),
lang: lang.to_string(),
})
} else if let Some(dt) = suffix.strip_prefix("^^") {
let dt = dt.trim_start_matches('<').trim_end_matches('>').to_string();
Some(Literal {
value,
datatype: dt,
lang: String::new(),
})
} else {
Some(Literal {
value,
datatype: XSD_STRING.to_string(),
lang: String::new(),
})
}
}
fn is_temporal_datatype(dt: &str) -> bool {
matches!(
dt,
"http://www.w3.org/2001/XMLSchema#date"
| "http://www.w3.org/2001/XMLSchema#dateTime"
| "http://www.w3.org/2001/XMLSchema#gYear"
| "http://www.w3.org/2001/XMLSchema#gYearMonth"
| "http://www.w3.org/2001/XMLSchema#gMonthDay"
)
}
fn is_numeric_datatype(dt: &str) -> bool {
matches!(
dt,
"http://www.w3.org/2001/XMLSchema#integer"
| "http://www.w3.org/2001/XMLSchema#decimal"
| "http://www.w3.org/2001/XMLSchema#double"
| "http://www.w3.org/2001/XMLSchema#float"
| "http://www.w3.org/2001/XMLSchema#long"
| "http://www.w3.org/2001/XMLSchema#int"
| "http://www.w3.org/2001/XMLSchema#short"
| "http://www.w3.org/2001/XMLSchema#nonNegativeInteger"
| "http://www.w3.org/2001/XMLSchema#positiveInteger"
)
}
fn looks_like_year(value: &str) -> bool {
let v = value.strip_prefix('-').unwrap_or(value);
let head: String = v.chars().take(4).collect();
head.len() == 4 && head.chars().all(|c| c.is_ascii_digit())
}
fn update_time_extent(
pred: &str,
lit: &Literal,
values: &mut BTreeMap<String, (Option<String>, Option<String>)>,
value_dt: &mut BTreeMap<String, String>,
) {
let dt = value_dt
.entry(pred.to_string())
.or_insert_with(|| lit.datatype.clone());
if *dt != lit.datatype {
return; }
let entry = values.entry(pred.to_string()).or_insert((None, None));
match &mut entry.0 {
Some(lo) if lit.value < *lo => *lo = lit.value.clone(),
None => entry.0 = Some(lit.value.clone()),
_ => {}
}
match &mut entry.1 {
Some(hi) if lit.value > *hi => *hi = lit.value.clone(),
None => entry.1 = Some(lit.value.clone()),
_ => {}
}
}
fn split_namespace(term: &str) -> Option<String> {
let iri = term.strip_prefix('<')?.strip_suffix('>')?;
let cut = iri.rfind(['#', '/'])?;
Some(iri[..=cut].to_string())
}
pub(crate) fn hex16(bytes: &[u8; 16]) -> String {
let mut s = String::with_capacity(32);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
pub(crate) fn load_card(bytes: &[u8]) -> anyhow::Result<Option<DatasetCard>> {
let header = Header::from_bytes(bytes)?;
if header.metadata_len == 0 {
return Ok(None);
}
let start = header.metadata_offset as usize;
let end = start
.checked_add(header.metadata_len as usize)
.filter(|&e| e <= bytes.len())
.ok_or_else(|| anyhow::anyhow!("metadata section out of bounds"))?;
Ok(Some(DatasetCard::from_json_bytes(&bytes[start..end])?))
}
pub(crate) fn load_card_ranged<R: rete_core::RangeReader>(
reader: &R,
) -> anyhow::Result<Option<DatasetCard>> {
match rete_core::read_metadata_ranged(reader)? {
None => Ok(None),
Some(bytes) => Ok(Some(DatasetCard::from_json_bytes(&bytes)?)),
}
}
pub(crate) fn format_card(card: &DatasetCard, checksum: &str) -> String {
use std::fmt::Write;
let mut out = String::new();
let _ = writeln!(out, "Dataset Card");
let field = |out: &mut String, label: &str, value: &Option<String>| {
if let Some(v) = value {
let _ = writeln!(out, " {label:<13}: {v}");
}
};
field(&mut out, "title", &card.title);
field(&mut out, "description", &card.description);
field(&mut out, "license", &card.license);
field(&mut out, "source", &card.source);
field(&mut out, "created", &card.created);
let _ = writeln!(out, " {:<13}: {}", "triples", card.triple_count);
if card.named_graph_count > 0 {
let _ = writeln!(
out,
" {:<13}: {} total, {} named graph(s)",
"quads", card.quad_count, card.named_graph_count
);
}
let _ = writeln!(out, " {:<13}: {}", "terms", card.term_count);
let _ = writeln!(
out,
" {:<13}: {checksum} (blake3-16 content hash)",
"checksum"
);
if !card.vocabularies.is_empty() {
let _ = writeln!(out, " {:<13}: {}", "vocabularies", card.vocabularies.len());
for ns in &card.vocabularies {
let _ = writeln!(out, " {ns}");
}
}
if !card.predicates.is_empty() {
let _ = writeln!(out, " predicates ({}):", card.predicates.len());
for (iri, count) in &card.predicates {
let _ = writeln!(out, " {count:>8} {iri}");
}
}
if !card.classes.is_empty() {
let _ = writeln!(out, " classes ({}):", card.classes.len());
for (iri, count) in &card.classes {
let _ = writeln!(out, " {count:>8} {iri}");
}
}
if !card.datatypes.is_empty() {
let _ = writeln!(out, " datatypes ({}):", card.datatypes.len());
for (dt, count) in &card.datatypes {
let _ = writeln!(out, " {count:>8} {dt}");
}
}
if !card.languages.is_empty() {
let _ = writeln!(out, " languages ({}):", card.languages.len());
for (lang, count) in &card.languages {
let shown = if lang.is_empty() { "(untagged)" } else { lang };
let _ = writeln!(out, " {count:>8} {shown}");
}
}
if !card.class_links.is_empty() {
let _ = writeln!(out, " class links ({}):", card.class_links.len());
for l in &card.class_links {
let _ = writeln!(
out,
" {:>8} {} --{}-> {}",
l.count, l.s_class, l.predicate, l.o_class
);
}
}
if !card.top_hubs.is_empty() {
let _ = writeln!(out, " top hubs (out-degree):");
for (iri, d) in &card.top_hubs {
let _ = writeln!(out, " {d:>8} {iri}");
}
}
if !card.in_hubs.is_empty() {
let _ = writeln!(out, " top hubs (in-degree):");
for (iri, d) in &card.in_hubs {
let _ = writeln!(out, " {d:>8} {iri}");
}
}
if !card.signals.is_empty() {
let s = &card.signals;
let _ = writeln!(out, " signals:");
let opt = |out: &mut String, label: &str, v: &Option<String>| {
if let Some(v) = v {
let _ = writeln!(out, " {label:<11}: {v}");
}
};
opt(&mut out, "label pred", &s.label_predicate);
opt(&mut out, "base IRI", &s.base_iri);
opt(&mut out, "default lang", &s.default_lang);
if !s.time_predicates.is_empty() {
let _ = writeln!(out, " time preds : {}", s.time_predicates.join(", "));
}
if !s.link_predicates.is_empty() {
let _ = writeln!(out, " link preds : {}", s.link_predicates.join(", "));
}
if let Some((from, to)) = &s.temporal_extent {
let _ = writeln!(out, " time extent: {from} … {to}");
}
if let Some([min_lon, min_lat, max_lon, max_lat]) = s.spatial_bbox {
let _ = writeln!(
out,
" bbox : lon [{min_lon}, {max_lon}], lat [{min_lat}, {max_lat}] (CRS84 lon/lat)"
);
}
if s.geo_wkt {
let _ = writeln!(out, " geometry : geo:asWKT present");
}
}
if !card.coherence.is_empty() {
let c = &card.coherence;
let verdict = if c.coherent {
"coherent".to_string()
} else {
format!("{} incoherent point(s)", c.inconsistency_count)
};
let _ = writeln!(out, " coherence:");
let _ = writeln!(out, " verdict : {verdict}");
if !c.by_kind.is_empty() {
let kinds: Vec<String> = c.by_kind.iter().map(|(k, n)| format!("{k}×{n}")).collect();
let _ = writeln!(out, " by kind : {}", kinds.join(", "));
}
let _ = writeln!(
out,
" scope : {} · rules {} · {}",
c.scope,
c.rules,
if c.materialized {
"materialized"
} else {
"not materialized"
}
);
}
if !card.queries.is_empty() {
let _ = writeln!(out, " starter queries ({}):", card.queries.len());
for q in &card.queries {
let tier = match q.tier {
Tier::Card => "card",
Tier::Summary => "summary",
Tier::Index => "index",
};
let _ = writeln!(out, " [{tier:^7}] {} — {}", q.id, q.question);
}
}
if !card.example_queries.is_empty() {
let _ = writeln!(out, " curated queries:");
for q in &card.example_queries {
let _ = writeln!(out, " {q}");
}
}
while out.ends_with('\n') {
out.pop();
}
out
}
pub(crate) fn card_cmd(file: &str, json: bool) -> anyhow::Result<()> {
let reader = crate::commands::range_source::LocalRangeReader::open(file)?;
let head = rete_core::RangeReader::read_at(&reader, 0, rete_core::HEADER_LEN as u64)?;
let header = Header::from_bytes(&head)?;
match load_card_ranged(&reader)? {
None => println!("(no dataset card)"),
Some(card) => {
if json {
println!("{}", serde_json::to_string_pretty(&card_json(&card))?);
} else {
println!("{}", format_card(&card, &hex16(&header.content_hash)));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const TYPE: &str = RDF_TYPE;
fn q(s: &str, p: &str, o: &str) -> (String, String, String, Option<String>) {
(s.into(), p.into(), o.into(), None)
}
#[test]
fn card_json_round_trips() {
let card = DatasetCard {
title: Some("Demo".into()),
license: Some("CC0".into()),
triple_count: 3,
quad_count: 3,
term_count: 5,
predicates: vec![("<http://ex/p>".into(), 2)],
vocabularies: vec!["http://ex/".into()],
format_version: 1,
..Default::default()
};
let bytes = card.to_json_bytes();
let back = DatasetCard::from_json_bytes(&bytes).unwrap();
assert_eq!(card, back);
let text = String::from_utf8(bytes).unwrap();
assert!(!text.contains("description"));
assert!(!text.contains("classes"));
}
#[test]
fn split_namespace_handles_hash_slash_and_neither() {
assert_eq!(
split_namespace("<http://xmlns.com/foaf/0.1/name>").as_deref(),
Some("http://xmlns.com/foaf/0.1/")
);
assert_eq!(
split_namespace("<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>").as_deref(),
Some("http://www.w3.org/1999/02/22-rdf-syntax-ns#")
);
assert_eq!(split_namespace("<urn:isbn:12345>"), None);
assert_eq!(split_namespace("\"literal\""), None);
assert_eq!(split_namespace("_:b0"), None);
}
#[test]
fn derive_counts_and_orders() {
let quads = vec![
q("<http://ex/a>", TYPE, "<http://ex/Person>"),
q("<http://ex/b>", TYPE, "<http://ex/Person>"),
q("<http://ex/c>", TYPE, "<http://ex/City>"),
q("<http://ex/a>", "<http://ex/knows>", "<http://ex/b>"),
q("<http://ex/a>", "<http://ex/knows>", "<http://ex/c>"),
q("<http://ex/a>", "<http://ex/name>", "\"A\""),
];
let card = derive_card(&quads, 9, 0, CardInput::default());
assert_eq!(card.triple_count, 6);
assert_eq!(card.quad_count, 6);
assert_eq!(card.term_count, 9);
assert_eq!(card.predicates[0], (TYPE.to_string(), 3));
assert!(card
.predicates
.contains(&("<http://ex/knows>".to_string(), 2)));
assert_eq!(
card.classes,
vec![
("<http://ex/Person>".to_string(), 2),
("<http://ex/City>".to_string(), 1)
]
);
assert!(card.vocabularies.contains(&"http://ex/".to_string()));
assert!(card
.vocabularies
.iter()
.any(|v| v.ends_with("rdf-syntax-ns#")));
}
#[test]
fn named_graph_triples_excluded_from_default_stats() {
let quads = vec![
q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>"),
(
"<http://ex/c>".into(),
"<http://ex/p>".into(),
"<http://ex/d>".into(),
Some("<http://ex/g1>".into()),
),
];
let card = derive_card(&quads, 5, 1, CardInput::default());
assert_eq!(card.triple_count, 1, "only the default-graph triple counts");
assert_eq!(card.quad_count, 2);
assert_eq!(card.named_graph_count, 1);
assert_eq!(card.predicates[0].1, 1);
}
const LABEL: &str = "<http://www.w3.org/2000/01/rdf-schema#label>";
const SAMEAS: &str = "<http://www.w3.org/2002/07/owl#sameAs>";
const XSD_INT: &str = "http://www.w3.org/2001/XMLSchema#integer";
const XSD_GYEAR: &str = "http://www.w3.org/2001/XMLSchema#gYear";
fn enriched_fixture() -> Vec<(String, String, String, Option<String>)> {
vec![
q("<http://ex/a>", TYPE, "<http://ex/Person>"),
q("<http://ex/b>", TYPE, "<http://ex/Person>"),
q("<http://ex/c>", TYPE, "<http://ex/City>"),
q("<http://ex/a>", LABEL, "\"Alice\"@en"),
q("<http://ex/b>", LABEL, "\"Bob\"@en"),
q(
"<http://ex/a>",
"<http://ex/age>",
&format!("\"30\"^^<{XSD_INT}>"),
),
q(
"<http://ex/a>",
"<http://ex/birthYear>",
&format!("\"1850\"^^<{XSD_GYEAR}>"),
),
q(
"<http://ex/b>",
"<http://ex/birthYear>",
&format!("\"1875\"^^<{XSD_GYEAR}>"),
),
q("<http://ex/a>", "<http://ex/knows>", "<http://ex/b>"),
q("<http://ex/a>", "<http://ex/livesIn>", "<http://ex/c>"),
q("<http://ex/a>", SAMEAS, "<http://other/a>"),
]
}
#[test]
fn enriched_profile_is_populated() {
let quads = enriched_fixture();
let card = derive_card(&quads, 12, 0, CardInput::default());
assert!(!card.datatypes.is_empty(), "datatypes derived");
assert!(!card.languages.is_empty(), "languages derived");
assert!(!card.class_links.is_empty(), "class_links derived");
assert!(!card.top_hubs.is_empty(), "out-degree hubs derived");
assert!(!card.in_hubs.is_empty(), "in-degree hubs derived");
assert!(card
.class_links
.iter()
.any(|l| l.s_class == "<http://ex/Person>"
&& l.predicate == "<http://ex/knows>"
&& l.o_class == "<http://ex/Person>"
&& l.count == 1));
assert!(card
.class_links
.iter()
.any(|l| l.o_class == "<http://ex/City>"));
assert!(card.class_links.iter().any(|l| l.o_class == "(untyped)"));
assert!(card.datatypes.iter().any(|(d, _)| d.contains("integer")));
assert!(card.datatypes.iter().any(|(d, _)| d.contains("gYear")));
assert!(card.datatypes.iter().any(|(d, _)| d.contains("langString")));
assert!(card.languages.iter().any(|(l, _)| l == "en"));
let s = &card.signals;
assert_eq!(s.label_predicate.as_deref(), Some(LABEL));
assert_eq!(s.default_lang.as_deref(), Some("en"));
assert_eq!(s.base_iri.as_deref(), Some("http://ex/"));
assert!(s.link_predicates.iter().any(|p| p.contains("sameAs")));
assert!(s.time_predicates.iter().any(|p| p.contains("birthYear")));
assert_eq!(
s.temporal_extent,
Some(("1850".to_string(), "1875".to_string())),
"extent over the dominant time predicate"
);
assert!(!s.geo_wkt && !s.geo_latlong);
assert!(s.spatial_bbox.is_none());
}
#[test]
fn signals_detect_geometry() {
let wkt = "http://www.opengis.net/ont/geosparql#wktLiteral";
let quads = vec![
q("<http://ex/p>", TYPE, "<http://ex/Place>"),
q(
"<http://ex/p>",
"<http://www.w3.org/2003/01/geo/wgs84_pos#lat>",
"\"41.9\"^^<http://www.w3.org/2001/XMLSchema#double>",
),
q(
"<http://ex/p>",
"<http://www.w3.org/2003/01/geo/wgs84_pos#long>",
"\"12.5\"^^<http://www.w3.org/2001/XMLSchema#double>",
),
q(
"<http://ex/p>",
"<http://www.opengis.net/ont/geosparql#asWKT>",
&format!("\"POINT(12.5 41.9)\"^^<{wkt}>"),
),
];
let card = derive_card(&quads, 6, 0, CardInput::default());
assert!(card.signals.geo_wkt, "asWKT/wktLiteral detected");
assert!(card.signals.geo_latlong, "wgs84 lat+long detected");
let bbox = card.signals.spatial_bbox.expect("bbox derived");
assert_eq!(
bbox,
[12.5, 41.9, 12.5, 41.9],
"[minLon,minLat,maxLon,maxLat]"
);
}
#[test]
fn class_links_match_schema_summary() {
use rete_core::{ingest, schema_summary, Rete};
let quads = enriched_fixture();
let card = derive_card(&quads, 12, 0, CardInput::default());
let (bytes, _) =
ingest::assemble_dataset_with_opts(quads, true, false, None, |_, _| Vec::new());
let rete = Rete::open(&bytes).unwrap();
let reference: BTreeSet<(String, String, String, u64)> = schema_summary(&rete)
.into_iter()
.map(|(s, p, o, c)| (s, p, o, c as u64))
.collect();
let derived: BTreeSet<(String, String, String, u64)> = card
.class_links
.iter()
.map(|l| {
(
l.s_class.clone(),
l.predicate.clone(),
l.o_class.clone(),
l.count,
)
})
.collect();
assert_eq!(
derived, reference,
"card class_links must equal schema_summary's quotient row-for-row"
);
}
#[test]
fn derive_is_deterministic() {
let quads = enriched_fixture();
let a = derive_card(&quads, 12, 0, CardInput::default()).to_json_bytes();
let b = derive_card(&quads, 12, 0, CardInput::default()).to_json_bytes();
assert_eq!(a, b, "enriched card derivation must be deterministic");
assert!(!a.is_empty());
}
#[test]
fn old_plain_string_card_still_parses() {
let json = r#"{
"title":"Legacy",
"triple_count":3,"quad_count":3,"named_graph_count":0,"term_count":5,
"predicates":[["<http://ex/p>",2]],
"example_queries":["SELECT * WHERE { ?s ?p ?o }"],
"format_version":1
}"#;
let card = DatasetCard::from_json_bytes(json.as_bytes()).unwrap();
assert_eq!(card.title.as_deref(), Some("Legacy"));
assert_eq!(card.example_queries.len(), 1);
assert!(card.datatypes.is_empty());
assert!(card.languages.is_empty());
assert!(card.class_links.is_empty());
assert!(card.top_hubs.is_empty());
assert!(card.in_hubs.is_empty());
assert!(card.queries.is_empty());
assert!(card.signals.is_empty());
assert!(!card.truncated);
}
#[test]
fn coherence_stamp_is_deterministic_and_additive() {
let dis = "<http://www.w3.org/2002/07/owl#disjointWith>";
let rt = "<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>";
let base = vec![
("<http://ex/C>".into(), dis.into(), "<http://ex/D>".into()),
("<http://ex/x>".into(), rt.into(), "<http://ex/C>".into()),
("<http://ex/x>".into(), rt.into(), "<http://ex/D>".into()),
];
let r = rete_core::reason(&base);
let c1 = Coherence::from_reasoning(&r, false);
assert_eq!(c1, Coherence::from_reasoning(&r, false));
assert!(!c1.coherent);
assert_eq!(c1.inconsistency_count, 1);
assert_eq!(c1.by_kind, vec![("disjoint-classes".to_string(), 1)]);
assert_eq!(c1.rules, rete_core::REASON_RULESET);
let json = DatasetCard::default()
.with_coherence(&r, false)
.to_json_bytes();
let text = std::str::from_utf8(&json).unwrap();
assert!(text.contains("coherence"));
assert_eq!(DatasetCard::from_json_bytes(&json).unwrap().coherence, c1);
let plain = DatasetCard::default().to_json_bytes();
assert!(!std::str::from_utf8(&plain).unwrap().contains("coherence"));
assert!(DatasetCard::default().coherence.is_empty());
}
#[test]
fn base_iri_is_common_prefix_with_fallback() {
let mut ns = BTreeMap::new();
ns.insert("http://ex/person/".to_string(), 2000u64);
ns.insert("http://ex/place/".to_string(), 600);
ns.insert("http://ex/org/".to_string(), 120);
assert_eq!(derive_base_iri(&ns).as_deref(), Some("http://ex/"));
let mut one = BTreeMap::new();
one.insert("http://data.example.org/id/".to_string(), 10u64);
assert_eq!(
derive_base_iri(&one).as_deref(),
Some("http://data.example.org/id/")
);
let mut multi = BTreeMap::new();
multi.insert("http://a.example/".to_string(), 100u64);
multi.insert("http://b.other/".to_string(), 5);
assert_eq!(
derive_base_iri(&multi).as_deref(),
Some("http://a.example/")
);
assert_eq!(derive_base_iri(&BTreeMap::new()), None);
}
#[test]
fn parse_literal_decomposes_forms() {
let plain = parse_literal("\"hello\"").unwrap();
assert_eq!(plain.value, "hello");
assert!(plain.datatype.ends_with("XMLSchema#string"));
assert_eq!(plain.lang, "");
let tagged = parse_literal("\"bonjour\"@fr").unwrap();
assert_eq!(tagged.lang, "fr");
assert!(tagged.datatype.ends_with("#langString"));
let typed = parse_literal("\"42\"^^<http://www.w3.org/2001/XMLSchema#integer>").unwrap();
assert_eq!(typed.value, "42");
assert_eq!(typed.datatype, "http://www.w3.org/2001/XMLSchema#integer");
let esc = parse_literal("\"a\\\"b\"@en").unwrap();
assert_eq!(esc.value, "a\\\"b");
assert_eq!(esc.lang, "en");
assert!(parse_literal("<http://ex/x>").is_none());
assert!(parse_literal("_:b0").is_none());
}
#[test]
fn flags_override_card_file_fields() {
let args = CardArgs {
title: Some("Flag Title".into()),
license: Some("MIT".into()),
..Default::default()
};
assert!(args.requested());
let curated = load_curated(&args).unwrap();
assert_eq!(curated.title.as_deref(), Some("Flag Title"));
assert_eq!(curated.license.as_deref(), Some("MIT"));
assert!(!CardArgs::default().requested());
}
#[test]
fn hex16_formats_lowercase_padded() {
let mut b = [0u8; 16];
b[0] = 0x0a;
b[15] = 0xff;
let h = hex16(&b);
assert_eq!(h.len(), 32);
assert!(h.starts_with("0a"));
assert!(h.ends_with("ff"));
}
}