use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::{Header, RDF_TYPE};
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub 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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub creators: Vec<Creator>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publisher: Option<Publisher>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub canonical_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sparql_endpoint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_date: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub derived_from: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doi: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cite_as: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub keywords: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub theme: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, serde_json::Value>,
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,
#[serde(default, skip_serializing_if = "is_zero_u32")]
pub top_n: u32,
pub format_version: u8,
}
fn is_zero_u32(v: &u32) -> bool {
*v == 0
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Creator {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub orcid: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Publisher {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ror: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Tier {
Card,
Summary,
Index,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub 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 struct ClassLink {
pub s_class: String,
pub predicate: String,
pub o_class: String,
pub count: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub 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 = "Vec::is_empty")]
pub annotation_predicates: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub quoting_predicates: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub annotated_statement: Option<(String, 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]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_index: Option<TextIndexSignal>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label_index: Option<LabelIndexSignal>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permutations: Option<PermutationsSignal>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quoted_triples: Option<QuotedTriplesSignal>,
}
impl Signals {
pub fn is_empty(&self) -> bool {
*self == Signals::default()
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TextIndexSignal {
pub present: bool,
#[serde(default, skip_serializing_if = "is_zero_u64")]
pub bytes: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_table_bytes: Option<u64>,
}
fn is_zero_u64(v: &u64) -> bool {
*v == 0
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PermutationsSignal {
pub count: u8,
pub names: Vec<String>,
pub merge_join: bool,
}
impl PermutationsSignal {
pub fn probe(header: &Header) -> Self {
let perms = header.perms;
PermutationsSignal {
count: perms.len() as u8,
names: perms.names().into_iter().map(str::to_string).collect(),
merge_join: perms.has_merge_orders(),
}
}
pub fn describe(&self) -> String {
if self.merge_join {
format!(
"{} index permutations ({}) — sort-merge joins available",
self.count,
self.names.join("/")
)
} else {
format!(
"{} index permutations ({}) — same rows, same routing; \
no sort-merge join (hash/probe answers instead)",
self.count,
self.names.join("/")
)
}
}
}
pub const QUOTED_TRIPLE_SURFACE_RDF12: &str = "rdf12";
pub const QUOTED_TRIPLE_SURFACE_RDF_STAR: &str = "rdf-star";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuotedTriplesSignal {
pub present: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub export_surfaces: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub export_default: Option<String>,
}
impl QuotedTriplesSignal {
pub fn probe(header: &Header) -> Self {
if !header.has_quoted_triples() {
return QuotedTriplesSignal {
present: false,
export_surfaces: Vec::new(),
export_default: None,
};
}
QuotedTriplesSignal {
present: true,
export_surfaces: vec![
QUOTED_TRIPLE_SURFACE_RDF12.to_string(),
QUOTED_TRIPLE_SURFACE_RDF_STAR.to_string(),
],
export_default: Some(QUOTED_TRIPLE_SURFACE_RDF12.to_string()),
}
}
pub fn describe(&self) -> Option<String> {
if !self.present {
return None;
}
Some(format!(
"present — `rete export` writes RDF 1.2 triple terms `<<( s p o )>>` \
by default; `--quoted-triple-syntax {QUOTED_TRIPLE_SURFACE_RDF_STAR}` \
writes `<<s p o>>`"
))
}
}
impl TextIndexSignal {
pub fn probe<R: crate::RangeReader + ?Sized>(reader: &R, header: &Header) -> Self {
if header.text_index_len == 0 {
return TextIndexSignal::default();
}
TextIndexSignal {
present: true,
bytes: header.text_index_len,
token_table_bytes: crate::read_text_index_token_table_len_ranged(reader, header),
}
}
pub fn describe(&self) -> String {
if !self.present {
return "no TEXT_INDEX section — CONTAINS/regex still answer, by full scan".to_string();
}
match self.token_table_bytes {
Some(tt) => format!(
"TEXT_INDEX present — {} bytes ({tt} of them the token table a first search reads)",
self.bytes
),
None => format!("TEXT_INDEX present — {} bytes", self.bytes),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LabelIndexSignal {
pub present: bool,
#[serde(default, skip_serializing_if = "is_zero_u64")]
pub bytes: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entries: Option<u64>,
}
impl LabelIndexSignal {
pub fn probe<R: crate::RangeReader + ?Sized>(reader: &R, header: &Header) -> Self {
let Some(s) = header.label_index_section() else {
return LabelIndexSignal::default();
};
LabelIndexSignal {
present: true,
bytes: s.length,
entries: crate::label_index::read_label_index_entries_ranged(reader, header),
}
}
pub fn describe(&self) -> String {
if !self.present {
return "no LABEL_INDEX section — a prefix search finds only what a pyramid's \
legacy label block holds, if any"
.to_string();
}
match self.entries {
Some(n) => format!("LABEL_INDEX present — {n} labels, {} bytes", self.bytes),
None => format!("LABEL_INDEX present — {} bytes", self.bytes),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub 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 fn is_empty(&self) -> bool {
*self == Coherence::default()
}
pub fn from_reasoning(r: &crate::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: crate::REASON_RULESET.to_string(),
materialized,
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CardInput {
pub title: Option<String>,
#[serde(default, deserialize_with = "de_description")]
pub description: Option<String>,
pub license: Option<String>,
pub source: Option<String>,
pub created: Option<String>,
pub version: Option<String>,
#[serde(default)]
pub creators: Vec<Creator>,
pub publisher: Option<Publisher>,
pub canonical_url: Option<String>,
pub sparql_endpoint: Option<String>,
pub source_date: Option<String>,
#[serde(default)]
pub derived_from: Vec<String>,
pub doi: Option<String>,
pub cite_as: Option<String>,
#[serde(default)]
pub keywords: Vec<String>,
#[serde(default)]
pub theme: Vec<String>,
#[serde(default)]
pub extra: BTreeMap<String, serde_json::Value>,
#[serde(default)]
pub example_queries: Vec<String>,
}
fn de_description<'de, D>(d: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error as _;
let v = Option::<serde_json::Value>::deserialize(d)?;
match v {
None | Some(serde_json::Value::Null) => Ok(None),
Some(v) => crate::card_input::normalize_description(&v)
.map(Some)
.map_err(D::Error::custom),
}
}
impl CardInput {
pub fn from_card(card: &DatasetCard) -> Self {
CardInput {
title: card.title.clone(),
description: card.description.clone(),
license: card.license.clone(),
source: card.source.clone(),
created: card.created.clone(),
version: card.version.clone(),
creators: card.creators.clone(),
publisher: card.publisher.clone(),
canonical_url: card.canonical_url.clone(),
sparql_endpoint: card.sparql_endpoint.clone(),
source_date: card.source_date.clone(),
derived_from: card.derived_from.clone(),
doi: card.doi.clone(),
cite_as: card.cite_as.clone(),
keywords: card.keywords.clone(),
theme: card.theme.clone(),
extra: card.extra.clone(),
example_queries: card.example_queries.clone(),
}
}
pub fn is_empty(&self) -> bool {
self.title.is_none()
&& self.description.is_none()
&& self.license.is_none()
&& self.source.is_none()
&& self.created.is_none()
&& self.version.is_none()
&& self.creators.is_empty()
&& self.publisher.is_none()
&& self.canonical_url.is_none()
&& self.sparql_endpoint.is_none()
&& self.source_date.is_none()
&& self.derived_from.is_empty()
&& self.doi.is_none()
&& self.cite_as.is_none()
&& self.keywords.is_empty()
&& self.theme.is_empty()
&& self.extra.is_empty()
&& self.example_queries.is_empty()
}
fn fill(self, card: &mut DatasetCard) {
card.title = self.title;
card.description = self.description;
card.license = self.license;
card.source = self.source;
card.created = self.created;
card.version = self.version;
card.creators = self.creators;
card.publisher = self.publisher;
card.canonical_url = self.canonical_url;
card.sparql_endpoint = self.sparql_endpoint;
card.source_date = self.source_date;
card.derived_from = self.derived_from;
card.doi = self.doi;
card.cite_as = self.cite_as;
card.keywords = self.keywords;
card.theme = self.theme;
card.extra = self.extra;
card.example_queries = self.example_queries;
}
pub fn from_json_str(text: &str) -> Result<Self, String> {
let parsed: CardInput = serde_json::from_str(text).map_err(|e| {
let hint = if e.to_string().contains("unknown field") {
crate::card_input::UNKNOWN_FIELD_HINT
} else {
""
};
format!("{e}{hint}")
})?;
parsed.normalize()
}
pub fn normalize(mut self) -> Result<Self, String> {
if let Some(d) = &self.description {
crate::card_input::check_description_len(d)?;
}
self.keywords = crate::card_input::normalize_string_list(
"keywords",
std::mem::take(&mut self.keywords),
)?;
self.theme = crate::card_input::normalize_themes(std::mem::take(&mut self.theme))?;
let bag: serde_json::Map<String, serde_json::Value> =
std::mem::take(&mut self.extra).into_iter().collect();
self.extra = crate::card_input::normalize_extra(bag)?
.into_iter()
.collect();
Ok(self)
}
}
impl DatasetCard {
pub fn to_json_bytes(&self) -> Vec<u8> {
if self.signals.text_index.is_none()
&& self.signals.label_index.is_none()
&& self.signals.permutations.is_none()
&& self.signals.quoted_triples.is_none()
{
return serde_json::to_vec(self).expect("DatasetCard serializes");
}
let mut stored = self.clone();
stored.signals.text_index = None;
stored.signals.label_index = None;
stored.signals.permutations = None;
stored.signals.quoted_triples = None;
serde_json::to_vec(&stored).expect("DatasetCard serializes")
}
pub fn observe_text_index(&mut self, measured: TextIndexSignal) -> Option<TextIndexSignal> {
self.signals.text_index.replace(measured)
}
pub fn observe_label_index(&mut self, measured: LabelIndexSignal) -> Option<LabelIndexSignal> {
self.signals.label_index.replace(measured)
}
pub fn observe_permutations(
&mut self,
measured: PermutationsSignal,
) -> Option<PermutationsSignal> {
self.signals.permutations.replace(measured)
}
pub fn observe_quoted_triples(
&mut self,
measured: QuotedTriplesSignal,
) -> Option<QuotedTriplesSignal> {
self.signals.quoted_triples.replace(measured)
}
pub fn from_json_bytes(b: &[u8]) -> Result<Self, String> {
serde_json::from_slice(b).map_err(|e| format!("malformed dataset card: {e}"))
}
pub fn with_coherence(mut self, r: &crate::Reasoning, materialized: bool) -> Self {
self.coherence = Coherence::from_reasoning(r, materialized);
self
}
pub fn with_final_counts(mut self, counts: crate::ingest::FinalCounts) -> Self {
self.triple_count = counts.default_triples;
self.quad_count = counts.quads;
self
}
}
pub const CARD_TOP_N: usize = 100;
const ANNOTATION_WITNESS_SAMPLE: usize = 4096;
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>";
pub(crate) const WGS_LAT: &str = "<http://www.w3.org/2003/01/geo/wgs84_pos#lat>";
pub(crate) const WGS_LONG: &str = "<http://www.w3.org/2003/01/geo/wgs84_pos#long>";
pub const GEO_ASWKT: &str = "<http://www.opengis.net/ont/geosparql#asWKT>";
pub 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";
pub const O_LITERAL: &str = "(literal)";
const LABEL_PREDICATES: &[&str] = &[
SKOS_PREFLABEL,
RDFS_LABEL,
SCHEMA_NAME,
FOAF_NAME,
DCT_TITLE,
];
pub(crate) const LINK_PREDICATES: &[&str] =
&[OWL_SAMEAS, SKOS_EXACTMATCH, SKOS_CLOSEMATCH, RDFS_SEEALSO];
pub trait CardTripleSource {
fn replay(&self, f: &mut dyn FnMut(&str, &str, &str));
}
pub fn curated_counts_card(statements: u64, term_count: u64, curated: CardInput) -> DatasetCard {
let mut card = DatasetCard {
triple_count: statements,
quad_count: statements,
named_graph_count: 0,
term_count,
format_version: crate::format::CURRENT_FORMAT_VERSION,
..DatasetCard::default()
};
curated.fill(&mut card);
card
}
pub fn derive_card(
quads: &[(String, String, String, Option<String>)],
term_count: u64,
named_graph_count: u64,
curated: CardInput,
) -> DatasetCard {
if crate::build_progress::listening() {
let default = quads.iter().filter(|q| q.3.is_none()).count() as u64;
crate::build_progress::set_total(Some(2 * default), crate::build_progress::Unit::Records);
}
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 fn derive_card_encoded(
dict: &crate::Dictionary,
triples: &[(u32, u32, u32)],
quad_count: u64,
term_count: u64,
named_graph_count: u64,
curated: CardInput,
) -> DatasetCard {
crate::build_progress::set_total(
Some(2 * triples.len() as u64),
crate::build_progress::Unit::Records,
);
struct Encoded<'a> {
dict: &'a crate::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,
)
}
pub 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();
let mut quoted_subject_preds: BTreeSet<String> = BTreeSet::new();
let mut annotated_tokens: BTreeMap<String, String> = BTreeMap::new();
let mut ticker = crate::build_progress::Ticker::new();
src.replay(&mut |s, p, o| {
ticker.tick(1);
if p == RDF_TYPE {
class_of.insert(s.to_string(), o.to_string());
}
if crate::terms::is_quoted_triple(s) {
if let Some((_, quoted_p, _)) = crate::ingest::quoted_triple_parts(s) {
quoted_subject_preds.insert(quoted_p);
}
if annotated_tokens.len() < ANNOTATION_WITNESS_SAMPLE {
annotated_tokens
.entry(s.to_string())
.or_insert_with(|| p.to_string());
}
}
});
let classify = |t: &str| -> String {
if let Some(c) = class_of.get(t) {
c.to_string()
} else if t.starts_with('"') {
O_LITERAL.to_string()
} else {
O_UNTYPED.to_string()
}
};
let mut prof = Profile::default();
let mut out_degree: BTreeMap<String, u64> = BTreeMap::new();
let mut in_degree: BTreeMap<String, u64> = BTreeMap::new();
src.replay(&mut |s, p, o| {
ticker.tick(1);
prof.triple_count += 1;
*prof.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) {
*prof.subject_ns.entry(ns).or_default() += 1;
}
if !o.starts_with('"') {
*in_degree.entry(o.to_string()).or_default() += 1;
}
if crate::terms::is_quoted_triple(s) {
*prof.annotation_hits.entry(p.to_string()).or_default() += 1;
}
if crate::terms::is_quoted_triple(o) {
*prof.quoting_hits.entry(p.to_string()).or_default() += 1;
}
if !quoted_subject_preds.is_empty() && quoted_subject_preds.contains(p) {
if let Some(about) = annotated_tokens.get(&format!("<<{s} {p} {o}>>")) {
*prof
.witnessed_pairs
.entry((p.to_string(), about.clone()))
.or_default() += 1;
}
}
if p == RDF_TYPE {
*prof.class_counts.entry(o.to_string()).or_default() += 1;
return; }
*prof
.link_rows
.entry((classify(s), p.to_string(), classify(o)))
.or_default() += 1;
if p == GEO_ASWKT || p == GEO_HASGEOMETRY {
prof.geo_wkt = true;
}
if let Some(lit) = parse_literal(o) {
prof.literal(p, &lit, 1);
}
});
let mut truncated = false;
let top_hubs = cap(sort_desc_owned(out_degree), &mut truncated);
let in_hubs = cap(sort_desc_owned(in_degree), &mut truncated);
finish(
prof,
Hubs {
top_hubs,
in_hubs,
truncated,
},
quad_count,
term_count,
named_graph_count,
curated,
)
}
const O_UNTYPED: &str = "(untyped)";
#[derive(Default)]
struct Profile {
triple_count: u64,
pred_counts: BTreeMap<String, u64>,
class_counts: BTreeMap<String, u64>,
datatype_counts: BTreeMap<String, u64>,
lang_counts: BTreeMap<String, u64>,
link_rows: BTreeMap<(String, String, String), u64>,
subject_ns: BTreeMap<String, u64>,
time_obj_hits: BTreeMap<String, u64>,
num_obj_hits: BTreeMap<String, u64>,
annotation_hits: BTreeMap<String, u64>,
quoting_hits: BTreeMap<String, u64>,
witnessed_pairs: BTreeMap<(String, String), u64>,
time_extent: BTreeMap<String, (String, String)>,
lat: Option<(f64, f64)>,
lon: Option<(f64, f64)>,
geo_wkt: bool,
}
impl Profile {
fn literal(&mut self, p: &str, lit: &Literal, weight: u64) {
*self
.datatype_counts
.entry(format!("<{}>", lit.datatype))
.or_default() += weight;
*self.lang_counts.entry(lit.lang.clone()).or_default() += weight;
if lit.datatype == GEO_WKTLITERAL {
self.geo_wkt = true;
}
if is_temporal(p, lit) {
*self.time_obj_hits.entry(p.to_string()).or_default() += weight;
update_time_extent(p, &lit.value, &mut self.time_extent);
} else if is_numeric_datatype(&lit.datatype) {
*self.num_obj_hits.entry(p.to_string()).or_default() += weight;
}
if p == WGS_LAT {
if let Ok(v) = lit.value.parse::<f64>() {
widen(&mut self.lat, v);
}
} else if p == WGS_LONG {
if let Ok(v) = lit.value.parse::<f64>() {
widen(&mut self.lon, v);
}
}
}
}
fn widen(extent: &mut Option<(f64, f64)>, v: f64) {
*extent = Some(match *extent {
None => (v, v),
Some((lo, hi)) => (lo.min(v), hi.max(v)),
});
}
struct Hubs {
top_hubs: Vec<(String, u64)>,
in_hubs: Vec<(String, u64)>,
truncated: bool,
}
fn finish(
p: Profile,
hubs: Hubs,
quad_count: u64,
term_count: u64,
named_graph_count: u64,
curated: CardInput,
) -> DatasetCard {
let Profile {
triple_count,
pred_counts,
class_counts,
datatype_counts,
lang_counts,
link_rows,
subject_ns,
time_obj_hits,
num_obj_hits,
annotation_hits,
quoting_hits,
witnessed_pairs,
time_extent,
lat,
lon,
geo_wkt,
} = p;
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 = hubs.truncated;
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 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 annotation_predicates = cap(
sort_desc_owned(annotation_hits)
.into_iter()
.map(|(p, _)| p)
.collect(),
&mut truncated,
);
let quoting_predicates = cap(
sort_desc_owned(quoting_hits)
.into_iter()
.map(|(p, _)| p)
.collect(),
&mut truncated,
);
let annotated_statement = witnessed_pairs
.into_iter()
.max_by(|a, b| a.1.cmp(&b.1).then_with(|| b.0.cmp(&a.0)))
.map(|(pair, _)| pair);
let geo_latlong = lat.is_some() && lon.is_some();
let temporal_extent = time_ranked
.first()
.and_then(|(p, _)| time_extent.get(p))
.cloned();
let spatial_bbox = match (lat, lon) {
(Some((lat_lo, lat_hi)), Some((lon_lo, lon_hi)))
if lon_lo.is_finite() && lat_lo.is_finite() =>
{
Some([lon_lo, lat_lo, lon_hi, lat_hi])
}
_ => None,
};
let signals = Signals {
label_predicate,
default_lang,
base_iri,
time_predicates,
numeric_predicates,
link_predicates,
annotation_predicates,
quoting_predicates,
annotated_statement,
geo_wkt,
geo_latlong,
temporal_extent,
spatial_bbox,
text_index: None,
label_index: None,
permutations: None,
quoted_triples: None,
};
let mut card = DatasetCard {
triple_count,
quad_count,
named_graph_count,
term_count,
predicates,
classes,
vocabularies,
datatypes,
languages,
class_links,
top_hubs: hubs.top_hubs,
in_hubs: hubs.in_hubs,
signals,
queries: Vec::new(),
coherence: Coherence::default(),
truncated,
top_n: CARD_TOP_N as u32,
format_version: crate::CURRENT_FORMAT_VERSION,
..DatasetCard::default()
};
curated.fill(&mut card);
card.queries = crate::card_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#dateTimeStamp"
| "http://www.w3.org/2001/XMLSchema#gYear"
| "http://www.w3.org/2001/XMLSchema#gYearMonth"
)
}
const DATE_PREDICATES: &[&str] = &[
"<http://purl.org/dc/terms/date>",
"<http://purl.org/dc/terms/issued>",
"<http://purl.org/dc/terms/created>",
"<http://purl.org/dc/terms/modified>",
"<http://purl.org/dc/terms/available>",
"<http://purl.org/dc/terms/dateAccepted>",
"<http://purl.org/dc/terms/dateCopyrighted>",
"<http://purl.org/dc/terms/dateSubmitted>",
"<http://purl.org/dc/elements/1.1/date>",
"<http://schema.org/datePublished>",
"<http://schema.org/dateCreated>",
"<http://schema.org/dateModified>",
"<http://schema.org/startDate>",
"<http://schema.org/endDate>",
"<http://schema.org/birthDate>",
"<http://schema.org/deathDate>",
"<http://schema.org/foundingDate>",
"<http://schema.org/uploadDate>",
"<https://schema.org/datePublished>",
"<https://schema.org/dateCreated>",
"<https://schema.org/dateModified>",
"<https://schema.org/startDate>",
"<https://schema.org/endDate>",
"<https://schema.org/birthDate>",
"<https://schema.org/deathDate>",
"<http://prismstandard.org/namespaces/basic/2.0/publicationDate>",
"<http://prismstandard.org/namespaces/basic/2.0/coverDate>",
"<http://www.w3.org/ns/prov#generatedAtTime>",
"<http://www.w3.org/ns/prov#invalidatedAtTime>",
"<http://www.w3.org/ns/prov#startedAtTime>",
"<http://www.w3.org/ns/prov#endedAtTime>",
"<http://www.w3.org/ns/prov#atTime>",
"<http://www.w3.org/2006/time#inXSDDate>",
"<http://www.w3.org/2006/time#inXSDDateTime>",
"<http://www.w3.org/2006/time#inXSDDateTimeStamp>",
"<http://www.w3.org/2006/time#inXSDgYear>",
"<http://www.w3.org/2006/time#inXSDgYearMonth>",
"<http://dbpedia.org/ontology/birthDate>",
"<http://dbpedia.org/ontology/deathDate>",
"<http://dbpedia.org/ontology/releaseDate>",
"<http://www.wikidata.org/prop/direct/P569>",
"<http://www.wikidata.org/prop/direct/P570>",
"<http://www.wikidata.org/prop/direct/P571>",
"<http://www.wikidata.org/prop/direct/P577>",
"<http://www.wikidata.org/prop/direct/P580>",
"<http://www.wikidata.org/prop/direct/P582>",
"<http://www.wikidata.org/prop/direct/P585>",
];
fn is_temporal(p: &str, lit: &Literal) -> bool {
if is_temporal_datatype(&lit.datatype) {
return date_shaped(&lit.value);
}
DATE_PREDICATES.contains(&p) && date_shaped(&lit.value)
}
fn date_shaped(v: &str) -> bool {
let b = v.as_bytes();
let digits =
|at: usize, n: usize| b.len() >= at + n && b[at..at + n].iter().all(u8::is_ascii_digit);
let two_in = |at: usize, lo: u8, hi: u8| {
digits(at, 2) && {
let n = (b[at] - b'0') * 10 + (b[at + 1] - b'0');
(lo..=hi).contains(&n)
}
};
let mut i = usize::from(b.first() == Some(&b'-'));
if !digits(i, 4) {
return false;
}
i += 4;
if b.get(i) == Some(&b'-') {
if !two_in(i + 1, 1, 12) {
return false;
}
i += 3;
if b.get(i) == Some(&b'-') {
if !two_in(i + 1, 1, 31) {
return false;
}
i += 3;
if b.get(i) == Some(&b'T') {
if !(two_in(i + 1, 0, 24) && b.get(i + 3) == Some(&b':') && two_in(i + 4, 0, 59)) {
return false;
}
i += 6;
if b.get(i) == Some(&b':') {
if !two_in(i + 1, 0, 60) {
return false;
}
i += 3;
if b.get(i) == Some(&b'.') {
let start = i + 1;
i = start;
while b.get(i).is_some_and(u8::is_ascii_digit) {
i += 1;
}
if i == start {
return false;
}
}
}
}
}
}
match &b[i..] {
[] | [b'Z'] => true,
[sign, h1, h2, b':', m1, m2] => {
(*sign == b'+' || *sign == b'-') && [h1, h2, m1, m2].iter().all(|c| c.is_ascii_digit())
}
_ => false,
}
}
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 temporal_key(v: &str) -> (i64, &str) {
let (neg, rest) = match v.strip_prefix('-') {
Some(r) => (true, r),
None => (false, v),
};
let n = rest.bytes().take_while(u8::is_ascii_digit).count();
let year: i64 = rest[..n].parse().unwrap_or(0);
(if neg { -year } else { year }, &rest[n..])
}
fn update_time_extent(pred: &str, value: &str, extent: &mut BTreeMap<String, (String, String)>) {
match extent.get_mut(pred) {
None => {
extent.insert(pred.to_string(), (value.to_string(), value.to_string()));
}
Some((lo, hi)) => {
if temporal_key(value) < temporal_key(lo) {
*lo = value.to_string();
}
if temporal_key(value) > temporal_key(hi) {
*hi = value.to_string();
}
}
}
}
#[derive(Debug, Clone, Default)]
pub struct StreamReport {
pub statements: u64,
pub subjects: u64,
pub literal_objects: u64,
pub classes: u64,
pub class_map_bytes: u64,
}
enum ClassMap {
U8(Vec<u8>),
U16(Vec<u16>),
U32(Vec<u32>),
}
impl ClassMap {
fn new(subjects: u64) -> Self {
ClassMap::U8(vec![0; subjects as usize + 1])
}
fn get(&self, id: u32) -> u32 {
let i = id as usize;
match self {
ClassMap::U8(v) => v.get(i).map_or(0, |&c| c as u32),
ClassMap::U16(v) => v.get(i).map_or(0, |&c| c as u32),
ClassMap::U32(v) => v.get(i).copied().unwrap_or(0),
}
}
fn set(&mut self, id: u32, code: u32) {
if code > u8::MAX as u32 {
if let ClassMap::U8(v) = self {
*self = ClassMap::U16(v.iter().map(|&c| c as u16).collect());
}
}
if code > u16::MAX as u32 {
if let ClassMap::U16(v) = self {
*self = ClassMap::U32(v.iter().map(|&c| c as u32).collect());
}
}
let i = id as usize;
match self {
ClassMap::U8(v) => {
if let Some(slot) = v.get_mut(i) {
*slot = code as u8;
}
}
ClassMap::U16(v) => {
if let Some(slot) = v.get_mut(i) {
*slot = code as u16;
}
}
ClassMap::U32(v) => {
if let Some(slot) = v.get_mut(i) {
*slot = code;
}
}
}
}
fn bytes(&self) -> u64 {
match self {
ClassMap::U8(v) => v.len() as u64,
ClassMap::U16(v) => v.len() as u64 * 2,
ClassMap::U32(v) => v.len() as u64 * 4,
}
}
}
struct TopN {
heap: std::collections::BinaryHeap<std::cmp::Reverse<(u64, std::cmp::Reverse<u32>)>>,
offered: u64,
}
impl TopN {
fn new() -> Self {
TopN {
heap: std::collections::BinaryHeap::with_capacity(CARD_TOP_N + 1),
offered: 0,
}
}
fn offer(&mut self, id: u32, n: u64) {
use std::cmp::Reverse;
self.offered += 1;
let key = Reverse((n, Reverse(id)));
if self.heap.len() < CARD_TOP_N {
self.heap.push(key);
} else if let Some(worst) = self.heap.peek() {
if (n, Reverse(id)) > worst.0 {
self.heap.pop();
self.heap.push(key);
}
}
}
fn into_parts(self) -> (Vec<(u32, u64)>, bool) {
let cut = self.offered > CARD_TOP_N as u64;
let v = self
.heap
.into_iter()
.map(|std::cmp::Reverse((n, std::cmp::Reverse(id)))| (id, n))
.collect();
(v, cut)
}
}
fn top_terms(
tops: [TopN; 2],
terms: &dyn Fn(&[u32]) -> Vec<Option<String>>,
) -> (Vec<(String, u64)>, bool) {
let mut offered = 0u64;
let mut ids: Vec<(u32, u64)> = Vec::new();
for top in tops {
offered += top.offered;
ids.extend(top.into_parts().0);
}
let names = terms(&ids.iter().map(|&(id, _)| id).collect::<Vec<_>>());
let mut v: Vec<(String, u64)> = ids
.iter()
.zip(names)
.filter_map(|(&(_, n), t)| t.map(|t| (t, n)))
.collect();
v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
v.truncate(CARD_TOP_N);
(v, offered > CARD_TOP_N as u64)
}
#[derive(Default, Clone, Copy)]
struct IdHasher(u64);
impl std::hash::Hasher for IdHasher {
fn finish(&self) -> u64 {
self.0
}
fn write(&mut self, bytes: &[u8]) {
for &b in bytes {
self.0 = (self.0.rotate_left(5) ^ b as u64).wrapping_mul(0x517c_c1b7_2722_0a95);
}
}
fn write_u32(&mut self, n: u32) {
self.0 = (self.0.rotate_left(5) ^ n as u64).wrapping_mul(0x517c_c1b7_2722_0a95);
}
}
type IdBuild = std::hash::BuildHasherDefault<IdHasher>;
fn first_at_or_above(lo: u32, hi: u32, key: &str, term: &dyn Fn(u32) -> Option<String>) -> u32 {
let (mut a, mut b) = (lo as u64, hi as u64 + 1);
while a < b {
let mid = a + (b - a) / 2;
match term(mid as u32) {
Some(t) if t.as_str() < key => a = mid + 1,
_ => b = mid,
}
}
a as u32
}
fn id_range(
lo: u32,
hi: u32,
from: &str,
to: &str,
term: &dyn Fn(u32) -> Option<String>,
) -> (u32, u32) {
if hi < lo {
return (lo, lo);
}
(
first_at_or_above(lo, hi, from, term),
first_at_or_above(lo, hi, to, term),
)
}
fn in_any(id: u32, ranges: &[(u32, u32)]) -> bool {
ranges.iter().any(|&(a, b)| a <= id && id < b)
}
pub fn derive_card_streaming(
rete: &crate::Rete,
curated: CardInput,
progress: &mut dyn FnMut(&str),
) -> Result<(DatasetCard, StreamReport), String> {
use crate::index::IndexPermutation;
let header = rete.header();
let dict = rete.dictionary();
let index = rete.default_index();
let shared = dict.shared_count();
let subjects_end = shared as u64 + dict.subject_only_count() as u64;
let objects_end = shared as u64 + dict.object_only_count() as u64;
let n_preds = dict.predicate_count() as usize;
let pred_terms: Vec<String> = (0..=n_preds as u32)
.map(|p| {
if p == 0 {
String::new()
} else {
dict.predicate_term(p).unwrap_or_default()
}
})
.collect();
let type_id = dict.predicate_id(RDF_TYPE);
let object_term = |id: u32| dict.object_term(id);
let subject_term = |id: u32| dict.subject_term(id);
let s_end = subjects_end.min(u32::MAX as u64) as u32;
let o_end = objects_end.min(u32::MAX as u64) as u32;
let literal_ranges = [
id_range(1, shared, "\"", "#", &object_term),
id_range(shared + 1, o_end, "\"", "#", &object_term),
];
let is_literal = |o: u32| in_any(o, &literal_ranges);
let quoted = header.has_quoted_triples();
let (quoted_subjects, quoted_objects) = if quoted {
(
vec![
id_range(1, shared, "<<", "<=", &subject_term),
id_range(shared + 1, s_end, "<<", "<=", &subject_term),
],
vec![
id_range(1, shared, "<<", "<=", &object_term),
id_range(shared + 1, o_end, "<<", "<=", &object_term),
],
)
} else {
(Vec::new(), Vec::new())
};
let mut report = StreamReport::default();
progress("pass 1/3: classes (the rdf:type range of POS)");
let mut class_map = ClassMap::new(subjects_end);
let mut class_code: std::collections::HashMap<u32, u32, IdBuild> = Default::default();
let mut class_ids: Vec<u32> = vec![0];
let mut class_counts: Vec<u64> = vec![0];
if let Some(t) = type_id {
for (s, _, o) in index.scan_iter((None, Some(t), None)) {
let next = class_ids.len() as u32;
let code = *class_code.entry(o).or_insert_with(|| {
class_ids.push(o);
class_counts.push(0);
next
});
class_counts[code as usize] += 1;
class_map.set(s, code);
}
}
report.classes = class_ids.len() as u64 - 1;
report.class_map_bytes = class_map.bytes();
progress("pass 2/3: subjects (SPO)");
const LITERAL_CODE: u32 = u32::MAX;
const WINDOW: usize = 1 << 16;
let mut prof = Profile::default();
let mut pred_counts = vec![0u64; n_preds + 1];
let mut annotation_hits = vec![0u64; n_preds + 1];
let mut quoting_hits = vec![0u64; n_preds + 1];
let mut links: std::collections::HashMap<(u32, u32, u32), u64, IdBuild> = Default::default();
let mut out_top = [TopN::new(), TopN::new()];
let mut sample: Vec<(u32, u32)> = Vec::new();
let mut window: Vec<(u32, u64)> = Vec::with_capacity(WINDOW);
let mut subject_ns: BTreeMap<String, u64> = BTreeMap::new();
let flush_ns = |window: &mut Vec<(u32, u64)>, subject_ns: &mut BTreeMap<String, u64>| {
let ids: Vec<u32> = window.iter().map(|&(s, _)| s).collect();
let terms = dict.subject_terms(&ids);
let mut run: Option<(String, u64)> = None;
for (term, &(_, n)) in terms.iter().zip(window.iter()) {
let Some(ns) = term.as_deref().and_then(split_namespace) else {
continue;
};
match &mut run {
Some((cur, total)) if *cur == ns => *total += n,
_ => {
if let Some((cur, total)) = run.take() {
*subject_ns.entry(cur).or_default() += total;
}
run = Some((ns, n));
}
}
}
if let Some((cur, total)) = run {
*subject_ns.entry(cur).or_default() += total;
}
window.clear();
};
let mut cur_s = 0u32;
let mut cur_n = 0u64;
let mut ticker = crate::build_progress::Ticker::new();
let stored = index.for_each_in_order(IndexPermutation::Spo, &mut |s, p, o| {
prof.triple_count += 1;
ticker.tick(1);
if let Some(c) = pred_counts.get_mut(p as usize) {
*c += 1;
}
if s != cur_s {
if cur_n > 0 {
out_top[usize::from(cur_s > shared)].offer(cur_s, cur_n);
window.push((cur_s, cur_n));
if window.len() >= WINDOW {
flush_ns(&mut window, &mut subject_ns);
}
}
if quoted && in_any(s, "ed_subjects) && sample.len() < ANNOTATION_WITNESS_SAMPLE {
sample.push((s, p));
}
cur_s = s;
cur_n = 0;
}
cur_n += 1;
if quoted {
if in_any(s, "ed_subjects) {
if let Some(c) = annotation_hits.get_mut(p as usize) {
*c += 1;
}
}
if in_any(o, "ed_objects) {
if let Some(c) = quoting_hits.get_mut(p as usize) {
*c += 1;
}
}
}
if Some(p) == type_id {
return;
}
let sc = class_map.get(s);
let oc = if is_literal(o) {
LITERAL_CODE
} else if o <= shared {
class_map.get(o)
} else {
0
};
*links.entry((sc, p, oc)).or_default() += 1;
});
if !stored {
return Err("the file stores no SPO permutation".to_string());
}
if cur_n > 0 {
out_top[usize::from(cur_s > shared)].offer(cur_s, cur_n);
window.push((cur_s, cur_n));
}
if !window.is_empty() {
flush_ns(&mut window, &mut subject_ns);
}
report.statements = prof.triple_count;
report.subjects = out_top[0].offered + out_top[1].offered;
drop(class_map);
progress("pass 3/3: objects (OSP)");
let mut in_top = [TopN::new(), TopN::new()];
let mut lit_window: Vec<(u32, Vec<(u32, u64)>)> = Vec::with_capacity(WINDOW);
let mut lit_objects = 0u64;
let flush_lits = |lit_window: &mut Vec<(u32, Vec<(u32, u64)>)>, prof: &mut Profile| {
let ids: Vec<u32> = lit_window.iter().map(|(o, _)| *o).collect();
let terms = dict.object_terms(&ids);
for (term, (_, preds)) in terms.iter().zip(lit_window.iter()) {
let Some(lit) = term.as_deref().and_then(parse_literal) else {
continue;
};
for &(p, n) in preds {
if let Some(pt) = pred_terms.get(p as usize) {
prof.literal(pt, &lit, n);
}
}
}
lit_window.clear();
};
let mut cur_o = 0u32;
let mut cur_n = 0u64;
let mut cur_preds: Vec<(u32, u64)> = Vec::new();
index.for_each_in_order(IndexPermutation::Osp, &mut |o, _s, p| {
ticker.tick(1);
if o != cur_o {
if cur_n > 0 {
if is_literal(cur_o) {
lit_objects += 1;
lit_window.push((cur_o, std::mem::take(&mut cur_preds)));
if lit_window.len() >= WINDOW {
flush_lits(&mut lit_window, &mut prof);
}
} else {
in_top[usize::from(cur_o > shared)].offer(cur_o, cur_n);
}
}
cur_o = o;
cur_n = 0;
cur_preds.clear();
}
cur_n += 1;
if is_literal(o) {
match cur_preds.iter_mut().find(|(q, _)| *q == p) {
Some((_, n)) => *n += 1,
None => cur_preds.push((p, 1)),
}
}
});
if cur_n > 0 {
if is_literal(cur_o) {
lit_objects += 1;
lit_window.push((cur_o, std::mem::take(&mut cur_preds)));
} else {
in_top[usize::from(cur_o > shared)].offer(cur_o, cur_n);
}
}
if !lit_window.is_empty() {
flush_lits(&mut lit_window, &mut prof);
}
report.literal_objects = lit_objects;
if rete.index_incomplete() {
return Err("a read failed part-way through the file; the card would be incomplete".into());
}
progress("resolving the profile's terms");
let class_terms: Vec<String> = class_ids
.iter()
.map(|&o| {
if o == 0 {
O_UNTYPED.to_string()
} else {
dict.object_term(o).unwrap_or_default()
}
})
.collect();
let class_name = |code: u32| -> String {
if code == LITERAL_CODE {
O_LITERAL.to_string()
} else {
class_terms
.get(code as usize)
.cloned()
.unwrap_or_else(|| O_UNTYPED.to_string())
}
};
for (p, &n) in pred_counts.iter().enumerate() {
if n > 0 {
prof.pred_counts.insert(pred_terms[p].clone(), n);
}
}
for (code, &n) in class_counts.iter().enumerate().skip(1) {
if n > 0 {
*prof
.class_counts
.entry(class_terms[code].clone())
.or_default() += n;
}
}
for ((sc, p, oc), n) in links {
let Some(pt) = pred_terms.get(p as usize) else {
continue;
};
*prof
.link_rows
.entry((class_name(sc), pt.clone(), class_name(oc)))
.or_default() += n;
}
for (p, &n) in annotation_hits.iter().enumerate() {
if n > 0 {
prof.annotation_hits.insert(pred_terms[p].clone(), n);
}
}
for (p, &n) in quoting_hits.iter().enumerate() {
if n > 0 {
prof.quoting_hits.insert(pred_terms[p].clone(), n);
}
}
prof.subject_ns = subject_ns;
if pred_counts
.iter()
.enumerate()
.any(|(p, &n)| n > 0 && (pred_terms[p] == GEO_ASWKT || pred_terms[p] == GEO_HASGEOMETRY))
{
prof.geo_wkt = true;
}
let sample_ids: Vec<u32> = sample.iter().map(|&(s, _)| s).collect();
for (term, &(_, about)) in dict.subject_terms(&sample_ids).iter().zip(sample.iter()) {
let Some((a, qp, b)) = term.as_deref().and_then(crate::ingest::quoted_triple_parts) else {
continue;
};
if let Some((sid, pid, oid)) = dict.encode(&a, &qp, &b) {
if index
.scan_iter((Some(sid), Some(pid), Some(oid)))
.next()
.is_some()
{
if let Some(about) = pred_terms.get(about as usize) {
*prof.witnessed_pairs.entry((qp, about.clone())).or_default() += 1;
}
}
}
}
let (top_hubs, cut_out) = top_terms(out_top, &|ids| dict.subject_terms(ids));
let (in_hubs, cut_in) = top_terms(in_top, &|ids| dict.object_terms(ids));
let card = finish(
prof,
Hubs {
top_hubs,
in_hubs,
truncated: cut_out || cut_in,
},
header.quad_count,
header.term_count,
rete.named_graph_count() as u64,
curated,
);
Ok((card, report))
}
fn split_namespace(term: &str) -> Option<String> {
let iri = term.strip_prefix('<')?.strip_suffix('>')?;
let cut = iri.rfind(['#', '/'])?;
Some(iri[..=cut].to_string())
}
pub fn load_card(bytes: &[u8]) -> Result<Option<DatasetCard>, String> {
let header = Header::from_bytes(bytes).map_err(|e| e.to_string())?;
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(|| "metadata section out of bounds".to_string())?;
Ok(Some(DatasetCard::from_json_bytes(&bytes[start..end])?))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::card_input::{CARD_EXTRA_MAX_BYTES, CARD_EXTRA_MAX_KEYS, CARD_EXTRA_MAX_KEY_BYTES};
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 the_permutation_signal_is_stripped_from_the_stored_card() {
let measured = PermutationsSignal {
count: 3,
names: vec!["SPO".into(), "POS".into(), "OSP".into()],
merge_join: false,
};
let mut card = DatasetCard {
title: Some("Lean".into()),
triple_count: 3,
..Default::default()
};
assert_eq!(card.observe_permutations(measured.clone()), None);
assert_eq!(card.signals.permutations, Some(measured.clone()));
let bytes = card.to_json_bytes();
let text = String::from_utf8(bytes.clone()).unwrap();
assert!(
!text.contains("permutations"),
"the signal must not reach the metadata section: {text}"
);
let back = DatasetCard::from_json_bytes(&bytes).unwrap();
assert_eq!(back.signals.permutations, None, "unknown, not `six`");
assert_eq!(back.title, card.title);
let authored = r#"{"title":"x","signals":{"permutations":{"count":6}}}"#;
assert!(
serde_json::from_str::<CardInput>(authored).is_err(),
"a hand-written card must not be able to assert a permutation set"
);
}
#[test]
fn the_permutation_signal_is_derived_from_the_header() {
let quads = vec![
q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>"),
q("<http://ex/b>", "<http://ex/p>", "<http://ex/c>"),
];
let (six, _) = crate::ingest::assemble_dataset_with_perms(
quads.clone(),
false,
false,
None,
crate::PyramidAlgo::Louvain,
crate::PermSet::ALL,
|_, _| Vec::new(),
);
let (three, _) = crate::ingest::assemble_dataset_with_perms(
quads,
false,
false,
None,
crate::PyramidAlgo::Louvain,
crate::PermSet::CORE,
|_, _| Vec::new(),
);
assert_eq!(six[50], 0);
assert_eq!(three[50], 0b0000_0111);
let h6 = crate::Header::from_bytes(&six).unwrap();
let h3 = crate::Header::from_bytes(&three).unwrap();
let s6 = PermutationsSignal::probe(&h6);
let s3 = PermutationsSignal::probe(&h3);
assert_eq!(s6.count, 6);
assert!(s6.merge_join);
assert_eq!(s3.count, 3);
assert!(!s3.merge_join);
assert_eq!(s3.names, vec!["SPO", "POS", "OSP"]);
assert!(s3.describe().contains("no sort-merge join"));
assert!(three.len() < six.len(), "{} vs {}", three.len(), six.len());
assert_eq!(h3.dictionary_len, h6.dictionary_len);
assert!(h3.root_dir_len < h6.root_dir_len);
}
#[test]
fn the_text_index_signal_is_stripped_from_the_stored_card() {
let measured = TextIndexSignal {
present: true,
bytes: 1_879_287_762,
token_table_bytes: Some(193_295_361),
};
let mut card = DatasetCard {
title: Some("Indexed".into()),
triple_count: 3,
..Default::default()
};
assert_eq!(card.observe_text_index(measured), None);
assert_eq!(card.signals.text_index, Some(measured));
let bytes = card.to_json_bytes();
let text = String::from_utf8(bytes.clone()).unwrap();
assert!(
!text.contains("text_index"),
"the signal must not reach the metadata section: {text}"
);
let back = DatasetCard::from_json_bytes(&bytes).unwrap();
assert_eq!(back.title, card.title);
assert_eq!(back.signals.text_index, None);
assert_eq!(card.signals.text_index, Some(measured));
}
#[test]
fn the_text_index_signal_round_trips_through_a_card_document() {
let mut card = DatasetCard {
title: Some("Indexed".into()),
..Default::default()
};
card.observe_text_index(TextIndexSignal {
present: true,
bytes: 80,
token_table_bytes: Some(68),
});
let doc = serde_json::to_vec(&card).unwrap();
let back: DatasetCard = serde_json::from_slice(&doc).unwrap();
assert_eq!(back.signals.text_index, card.signals.text_index);
let mut none = DatasetCard::default();
none.observe_text_index(TextIndexSignal::default());
let text = serde_json::to_string(&none).unwrap();
assert!(text.contains(r#""text_index":{"present":false}"#), "{text}");
let absent = serde_json::to_string(&DatasetCard::default()).unwrap();
assert!(!absent.contains("text_index"), "{absent}");
}
#[test]
fn the_quoted_triple_signal_is_derived_from_the_header() {
let plain = vec![
q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>"),
q("<http://ex/b>", "<http://ex/p>", "<http://ex/c>"),
];
let quoted = vec![
q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>"),
q(
"<http://ex/claim>",
"<http://ex/states>",
"<<<http://ex/a> <http://ex/p> <http://ex/b>>>",
),
];
let (no, _) = crate::ingest::assemble_dataset(plain, &[]);
let (yes, _) = crate::ingest::assemble_dataset(quoted, &[]);
let hn = crate::Header::from_bytes(&no).unwrap();
let hy = crate::Header::from_bytes(&yes).unwrap();
assert!(!hn.has_quoted_triples());
assert!(hy.has_quoted_triples());
let sn = QuotedTriplesSignal::probe(&hn);
let sy = QuotedTriplesSignal::probe(&hy);
assert!(!sn.present);
assert!(sy.present);
assert_eq!(sn.describe(), None);
let line = sy.describe().expect("a file with quoted triples says so");
assert!(line.contains("<<( s p o )>>"), "{line}");
assert!(line.contains(QUOTED_TRIPLE_SURFACE_RDF_STAR), "{line}");
assert_eq!(
sy.export_surfaces,
vec![QUOTED_TRIPLE_SURFACE_RDF12, QUOTED_TRIPLE_SURFACE_RDF_STAR]
);
assert_eq!(
sy.export_default.as_deref(),
Some(QUOTED_TRIPLE_SURFACE_RDF12)
);
assert!(sn.export_surfaces.is_empty());
assert_eq!(sn.export_default, None);
}
#[test]
fn the_quoted_triple_signal_is_stripped_from_the_stored_card() {
let measured = QuotedTriplesSignal {
present: true,
export_surfaces: vec![
QUOTED_TRIPLE_SURFACE_RDF12.to_string(),
QUOTED_TRIPLE_SURFACE_RDF_STAR.to_string(),
],
export_default: Some(QUOTED_TRIPLE_SURFACE_RDF12.to_string()),
};
let mut card = DatasetCard {
title: Some("Annotated".into()),
triple_count: 3,
..Default::default()
};
assert_eq!(card.observe_quoted_triples(measured.clone()), None);
assert_eq!(card.signals.quoted_triples, Some(measured.clone()));
let bytes = card.to_json_bytes();
let text = String::from_utf8(bytes.clone()).unwrap();
assert!(
!text.contains("quoted_triples"),
"the signal must not reach the metadata section: {text}"
);
let back = DatasetCard::from_json_bytes(&bytes).unwrap();
assert_eq!(back.title, card.title);
assert_eq!(back.signals.quoted_triples, None);
assert_eq!(card.signals.quoted_triples, Some(measured));
let authored = r#"{"title":"x","signals":{"quoted_triples":{"present":true}}}"#;
assert!(
serde_json::from_str::<CardInput>(authored).is_err(),
"a hand-written card must not be able to assert a quoted-triple set"
);
}
#[test]
fn the_quoted_triple_signal_round_trips_through_a_card_document() {
let mut card = DatasetCard {
title: Some("Annotated".into()),
..Default::default()
};
card.observe_quoted_triples(QuotedTriplesSignal {
present: true,
export_surfaces: vec![QUOTED_TRIPLE_SURFACE_RDF12.to_string()],
export_default: Some(QUOTED_TRIPLE_SURFACE_RDF12.to_string()),
});
let doc = serde_json::to_vec(&card).unwrap();
let back: DatasetCard = serde_json::from_slice(&doc).unwrap();
assert_eq!(back.signals.quoted_triples, card.signals.quoted_triples);
let mut none = DatasetCard::default();
none.observe_quoted_triples(QuotedTriplesSignal {
present: false,
export_surfaces: Vec::new(),
export_default: None,
});
let text = serde_json::to_string(&none).unwrap();
assert!(
text.contains(r#""quoted_triples":{"present":false}"#),
"{text}"
);
let absent = serde_json::to_string(&DatasetCard::default()).unwrap();
assert!(!absent.contains("quoted_triples"), "{absent}");
}
#[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 crate::{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 card_counts_are_the_deduplicated_counts() {
use crate::ingest::{self, DeferredMetadata};
use crate::Rete;
let g = |s: &str, p: &str, o: &str, graph: &str| {
(
s.to_string(),
p.to_string(),
o.to_string(),
Some(graph.to_string()),
)
};
let quads = vec![
q("<http://ex/a>", "<http://ex/p>", "<http://ex/1>"),
q("<http://ex/b>", "<http://ex/p>", "<http://ex/2>"),
q("<http://ex/a>", "<http://ex/p>", "<http://ex/1>"), g("<http://ex/n>", "<http://ex/q>", "\"v\"", "<http://ex/g>"),
g("<http://ex/n>", "<http://ex/q>", "\"v\"", "<http://ex/g>"), ];
let (bytes, stats) =
ingest::assemble_dataset_with_opts(quads, false, false, None, |stats, quads| {
let card = derive_card(
quads,
stats.terms as u64,
stats.named_graphs as u64,
CardInput::default(),
);
DeferredMetadata::new(move |counts| card.with_final_counts(counts).to_json_bytes())
});
let header = crate::Header::from_bytes(&bytes).unwrap();
let card = load_card(&bytes).unwrap().expect("card embedded");
assert_eq!(header.quad_count, 3, "header records the deduped quads");
assert_eq!(
card.quad_count, header.quad_count,
"card and header must not disagree about the size of the same file"
);
assert_eq!(card.triple_count, 2, "default-graph triples, deduped");
let rete = Rete::open(&bytes).unwrap();
let default_triples = rete.dump(None).len() as u64;
let named: u64 = rete
.graph_names()
.iter()
.map(|name| rete.dump(Some(name)).len() as u64)
.sum();
assert_eq!(default_triples + named, card.quad_count);
assert_eq!(stats.statements, 3);
assert_eq!(stats.default_triples, 2);
}
#[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 curated_identity_fields_round_trip() {
let input: CardInput = serde_json::from_str(
r#"{
"title": "T",
"version": "2026-08-04",
"creators": [{"name":"Ada","orcid":"https://orcid.org/0000-0002-1825-0097"}],
"publisher": {"name":"EPFL","ror":"https://ror.org/02s376052"},
"canonical_url": "https://data.example.org/t.rete",
"sparql_endpoint": "https://example.org/sparql",
"source_date": "2026-07-01",
"derived_from": ["https://example.org/dump.nt"],
"doi": "https://doi.org/10.5281/zenodo.1",
"cite_as": "Ada (2026). T.",
"keywords": ["graphs", "citations"],
"theme": ["http://publications.europa.eu/resource/authority/data-theme/TECH"]
}"#,
)
.unwrap();
let card = curated_counts_card(5, 9, input);
let bytes = card.to_json_bytes();
let back = DatasetCard::from_json_bytes(&bytes).unwrap();
assert_eq!(back.version.as_deref(), Some("2026-08-04"));
assert_eq!(back.creators[0].name, "Ada");
assert_eq!(
back.creators[0].orcid.as_deref(),
Some("https://orcid.org/0000-0002-1825-0097")
);
assert_eq!(back.publisher.as_ref().unwrap().name, "EPFL");
assert_eq!(
back.canonical_url.as_deref(),
Some("https://data.example.org/t.rete")
);
assert_eq!(back.derived_from, vec!["https://example.org/dump.nt"]);
assert_eq!(
back.doi.as_deref(),
Some("https://doi.org/10.5281/zenodo.1")
);
assert_eq!(back.keywords, vec!["graphs", "citations"]);
assert_eq!(
back.theme,
vec!["http://publications.europa.eu/resource/authority/data-theme/TECH"]
);
let plain = curated_counts_card(1, 2, CardInput::default()).to_json_bytes();
let text = String::from_utf8(plain).unwrap();
for absent in [
"\"creators\"",
"\"publisher\"",
"\"canonical_url\"",
"\"sparql_endpoint\"",
"\"derived_from\"",
"\"doi\"",
"\"cite_as\"",
"\"source_date\"",
"\"version\"",
"\"top_n\"",
"\"keywords\"",
"\"theme\"",
"\"extra\"",
] {
assert!(
!text.contains(absent),
"{absent} must be omitted when unset"
);
}
}
#[test]
fn extra_round_trips_and_external_writer_cards_surface_it() {
let input: CardInput = serde_json::from_str(
r#"{"title":"T","extra":{"atlas:layer":84,"review":{"by":"dg","ok":true}}}"#,
)
.unwrap();
let card = curated_counts_card(5, 9, input);
let back = DatasetCard::from_json_bytes(&card.to_json_bytes()).unwrap();
assert_eq!(back.extra, card.extra);
assert_eq!(back.extra.get("atlas:layer"), Some(&serde_json::json!(84)));
let python_shaped = r#"{
"title":"From Python","extra":{"pipeline":"nightly"},
"triple_count":1,"quad_count":1,"named_graph_count":0,"term_count":3,
"format_version":5
}"#;
let card = DatasetCard::from_json_bytes(python_shaped.as_bytes()).unwrap();
assert_eq!(
card.extra.get("pipeline"),
Some(&serde_json::json!("nightly"))
);
}
#[test]
fn extra_limits_bite_exactly_at_the_boundary() {
use crate::card_input::normalize_extra;
use serde_json::{json, Map, Value};
let bag = |v: Vec<(String, Value)>| -> Map<String, Value> { v.into_iter().collect() };
let pad = |n: usize| bag(vec![("pad".into(), json!("x".repeat(n)))]);
let at = pad(CARD_EXTRA_MAX_BYTES - 10);
assert_eq!(serde_json::to_vec(&at).unwrap().len(), CARD_EXTRA_MAX_BYTES);
normalize_extra(at).expect("exactly at the byte cap is accepted");
let over = pad(CARD_EXTRA_MAX_BYTES - 9);
assert_eq!(
serde_json::to_vec(&over).unwrap().len(),
CARD_EXTRA_MAX_BYTES + 1
);
let err = normalize_extra(over).unwrap_err().to_string();
assert!(
err.contains(&format!("{} bytes", CARD_EXTRA_MAX_BYTES + 1)),
"the error states the actual size: {err}"
);
let keys = |n: usize| {
bag((0..n)
.map(|i| (format!("k{i:03}"), json!(1)))
.collect::<Vec<_>>())
};
normalize_extra(keys(CARD_EXTRA_MAX_KEYS)).expect("at the key cap");
assert!(normalize_extra(keys(CARD_EXTRA_MAX_KEYS + 1)).is_err());
normalize_extra(bag(vec![("k".repeat(CARD_EXTRA_MAX_KEY_BYTES), json!(1))]))
.expect("at the key-length cap");
assert!(normalize_extra(bag(vec![(
"k".repeat(CARD_EXTRA_MAX_KEY_BYTES + 1),
json!(1)
)]))
.is_err());
assert!(normalize_extra(bag(vec![(String::new(), json!(1))])).is_err());
normalize_extra(bag(vec![("d".into(), json!({"a": {"b": 1}}))])).expect("under the cap");
normalize_extra(bag(vec![("d".into(), json!([{"w": ["x", "y"]}]))]))
.expect("at the depth cap");
assert!(normalize_extra(bag(vec![("d".into(), json!({"a": {"b": {"c": [1]}}}))])).is_err());
assert!(normalize_extra(bag(vec![("d".into(), json!([[[[1]]]]))])).is_err());
let err = normalize_extra(bag(vec![("@context".into(), json!({}))]))
.unwrap_err()
.to_string();
assert!(err.contains("reserved"), "loud reservation: {err}");
}
#[test]
fn extra_fields_keep_the_content_hash_reproducible() {
let build = |json: &str| {
let input: CardInput = serde_json::from_str::<CardInput>(json)
.unwrap()
.normalize()
.unwrap();
let quads = enriched_fixture();
let card = derive_card(&quads, 12, 0, input);
let (image, _) =
crate::ingest::assemble_dataset_with_opts(quads, true, false, None, |_, _| {
card.to_json_bytes()
});
image
};
let a = build(r#"{"title":"T","extra":{"b":{"y":2,"x":1},"a":[1,2]}}"#);
let b = build(r#"{"title":"T","extra":{"a":[1,2],"b":{"x":1,"y":2}}}"#);
assert_eq!(a, b, "reordered-input builds are byte-identical");
let ha = Header::from_bytes(&a).unwrap().content_hash;
assert_eq!(ha, Header::from_bytes(&b).unwrap().content_hash);
let plain = build(r#"{"title":"T"}"#);
assert_ne!(ha, Header::from_bytes(&plain).unwrap().content_hash);
}
#[test]
fn top_level_custom_keys_are_rejected_not_dropped() {
let err = serde_json::from_str::<CardInput>(r#"{"title":"T","my_field":1}"#)
.unwrap_err()
.to_string();
assert!(err.contains("my_field"), "names the stray key: {err}");
let ok: CardInput =
serde_json::from_str(r#"{"title":"T","extra":{"my_field":1}}"#).unwrap();
assert_eq!(ok.extra.get("my_field"), Some(&serde_json::json!(1)));
}
#[test]
fn description_reads_as_a_string_or_as_lines() {
let from_lines: CardInput = serde_json::from_value(serde_json::json!({
"description": ["## Contents", "", "- a", "- b"]
}))
.unwrap();
assert_eq!(
from_lines.description.as_deref(),
Some("## Contents\n\n- a\n- b")
);
let from_string: CardInput =
serde_json::from_str("{\"description\":\"## Contents\\n\\n- a\\n- b\"}").unwrap();
assert_eq!(from_string.description, from_lines.description);
let none: CardInput = serde_json::from_str(r#"{"title":"T"}"#).unwrap();
assert!(none.description.is_none());
let null: CardInput = serde_json::from_str(r#"{"description":null}"#).unwrap();
assert!(null.description.is_none());
let over = "x".repeat(crate::card::CARD_DESCRIPTION_MAX_BYTES + 1);
let err = serde_json::from_str::<CardInput>(&format!(
"{{\"description\":{}}}",
serde_json::to_string(&over).unwrap()
))
.unwrap_err()
.to_string();
assert!(err.contains("over the"), "{err}");
}
#[test]
fn curated_field_list_matches_the_deny_unknown_fields_struct() {
for f in crate::card::CURATED_CARD_FIELDS {
let doc = format!(r#"{{"{f}":null}}"#);
if let Err(e) = serde_json::from_str::<CardInput>(&doc) {
assert!(
!e.to_string().contains("unknown field"),
"CURATED_CARD_FIELDS lists `{f}`, but CardInput rejects it as unknown: {e}",
);
}
}
let err = serde_json::from_str::<CardInput>(r#"{"nope":1}"#)
.unwrap_err()
.to_string();
for name in err
.split("expected one of ")
.nth(1)
.expect("serde names the expected fields")
.split(" at line ")
.next()
.unwrap()
.split(", ")
.map(|s| s.trim().trim_matches('`'))
.filter(|s| !s.is_empty())
{
assert!(
crate::card::CURATED_CARD_FIELDS.contains(&name),
"CardInput accepts `{name}` but CURATED_CARD_FIELDS does not list it — \
the browser builder would reject a card the CLI accepts",
);
}
}
#[test]
fn a_browser_written_card_deserializes_here() {
let curated = crate::card::validate_curated_card(&serde_json::json!({
"title": "Built in a browser",
"keywords": ["b", "a"],
"theme": ["http://publications.europa.eu/resource/authority/data-theme/GOVE"],
"creators": [{"name": "Ada", "orcid": "https://orcid.org/0000-0002-1825-0097"}],
"extra": {"internal_id": "DS-1"},
}))
.expect("valid curated document");
let composed = crate::card::compose_curated_card(curated, 3, 4, 1, 9, 5);
let text = serde_json::to_string(&composed).unwrap();
let card: DatasetCard = serde_json::from_str(&text).expect("CLI reads a browser card");
assert_eq!(card.title.as_deref(), Some("Built in a browser"));
assert_eq!(card.keywords, vec!["a".to_string(), "b".to_string()]);
assert_eq!(card.creators.len(), 1);
assert_eq!(card.triple_count, 3);
assert_eq!(card.quad_count, 4);
assert_eq!(card.term_count, 9);
assert_eq!(card.format_version, 5);
assert!(card.predicates.is_empty() && card.queries.is_empty());
assert!(!text.contains("\"predicates\""), "{text}");
assert!(!text.contains("\"top_n\""), "{text}");
}
#[test]
fn old_reader_struct_accepts_new_card_json() {
#[derive(serde::Deserialize)]
struct OldCard {
title: Option<String>,
triple_count: u64,
#[serde(default)]
predicates: Vec<(String, u64)>,
format_version: u8,
}
let input: CardInput = serde_json::from_str(
r#"{"title":"New","version":"1","creators":[{"name":"A"}],
"canonical_url":"https://x/y.rete","derived_from":["https://x/d.nt"],
"extra":{"atlas:review":"approved"}}"#,
)
.unwrap();
let quads = vec![q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>")];
let card = derive_card(&quads, 3, 0, input);
assert_eq!(card.top_n, CARD_TOP_N as u32);
let bytes = card.to_json_bytes();
let old: OldCard = serde_json::from_slice(&bytes).expect("old struct parses new JSON");
assert_eq!(old.title.as_deref(), Some("New"));
assert_eq!(old.triple_count, 1);
assert_eq!(old.format_version, card.format_version);
assert!(!old.predicates.is_empty());
}
#[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 = crate::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, crate::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 date_shapes_are_strict() {
for ok in [
"2019",
"-0044",
"2019-05",
"2019-05-01",
"2019-05-01Z",
"2019-05-01+02:00",
"2019-05-01T12:30",
"2019-05-01T12:30:15",
"2019-05-01T12:30:15.250Z",
"1950Z",
] {
assert!(date_shaped(ok), "{ok} is a date");
}
for bad in [
"0000-0001-5001-3721", "9999880", "1234-1240", "123-456",
"2019 annual report", "2019-13",
"2019-05-32",
"2019-05-01T25:00",
"10.1000/182", "",
"-",
"2019-",
] {
assert!(!date_shaped(bad), "{bad} is not a date");
}
}
#[test]
fn temporal_extent_ignores_identifiers_pages_and_titles() {
let value = "<http://www.essepuntato.it/2010/06/literalreification/hasLiteralValue>";
let start = "<http://prismstandard.org/namespaces/basic/2.0/startingPage>";
let end = "<http://prismstandard.org/namespaces/basic/2.0/endingPage>";
let title = "<http://purl.org/dc/terms/title>";
let pubdate = "<http://prismstandard.org/namespaces/basic/2.0/publicationDate>";
let issued = "<http://purl.org/dc/terms/issued>";
let xsd = |t: &str| format!("http://www.w3.org/2001/XMLSchema#{t}");
let mut quads = Vec::new();
for (i, id) in [
"0000-0001-5001-3721",
"0000-0002-1825-0097",
"10.1000/182",
"9999880",
"2019",
]
.iter()
.enumerate()
{
quads.push(q(
&format!("<http://ex/id/{i}>"),
value,
&format!("\"{id}\""),
));
}
quads.push(q("<http://ex/re/1>", start, "\"9999880\""));
quads.push(q("<http://ex/re/1>", end, "\"9999899\""));
quads.push(q("<http://ex/re/2>", start, "\"1234-1240\""));
quads.push(q("<http://ex/br/1>", title, "\"2019 annual report\""));
quads.push(q(
"<http://ex/br/1>",
pubdate,
&format!("\"2019-05-01\"^^<{}>", xsd("date")),
));
quads.push(q(
"<http://ex/br/2>",
pubdate,
&format!("\"1950\"^^<{}>", xsd("gYear")),
));
quads.push(q(
"<http://ex/br/3>",
pubdate,
&format!("\"2021-03\"^^<{}>", xsd("gYearMonth")),
));
quads.push(q(
"<http://ex/br/4>",
pubdate,
&format!("\"0000-0001-5001-3721\"^^<{}>", xsd("date")),
));
quads.push(q("<http://ex/br/5>", issued, "\"1999-12\""));
let card = derive_card(&quads, 40, 0, CardInput::default());
let s = &card.signals;
assert_eq!(
s.time_predicates,
vec![pubdate.to_string(), issued.to_string()],
"only the date predicates are time predicates"
);
assert_eq!(
s.temporal_extent,
Some(("1950".to_string(), "2021-03".to_string())),
"calendar order across xsd:gYear, xsd:date and xsd:gYearMonth"
);
assert!(
s.numeric_predicates.is_empty(),
"{:?}",
s.numeric_predicates
);
assert!(card
.queries
.iter()
.filter(|q| q.dimension == "time")
.all(|q| q.sparql.contains("publicationDate")));
}
#[test]
fn negative_years_order_by_value_not_by_bytes() {
let mut extent = BTreeMap::new();
for v in ["-0044", "-0100", "0001", "-0044-03-15"] {
update_time_extent("<p>", v, &mut extent);
}
assert_eq!(
extent.get("<p>"),
Some(&("-0100".to_string(), "0001".to_string()))
);
}
#[test]
fn an_integer_year_off_a_date_predicate_is_numeric() {
let int = "http://www.w3.org/2001/XMLSchema#integer";
let quads = vec![
q(
"<http://ex/a>",
"<http://ex/year>",
&format!("\"1492\"^^<{int}>"),
),
q(
"<http://ex/b>",
"<http://ex/year>",
&format!("\"1500\"^^<{int}>"),
),
];
let card = derive_card(&quads, 6, 0, CardInput::default());
assert!(card.signals.time_predicates.is_empty());
assert_eq!(
card.signals.numeric_predicates,
vec!["<http://ex/year>".to_string()]
);
}
fn streaming_fixture() -> Vec<(String, String, String, Option<String>)> {
let xsd = |t: &str| format!("http://www.w3.org/2001/XMLSchema#{t}");
let mut v = enriched_fixture();
for i in 0..5 {
let s = format!("<http://ex/place/{i}>");
v.push(q(&s, TYPE, "<http://ex/Place>"));
v.push(q(&s, LABEL, &format!("\"Lieu {i}\"@fr")));
v.push(q(
&s,
"<http://www.w3.org/2003/01/geo/wgs84_pos#lat>",
&format!("\"4{i}.5\"^^<{}>", xsd("double")),
));
v.push(q(
&s,
"<http://www.w3.org/2003/01/geo/wgs84_pos#long>",
&format!("\"{i}.25\"^^<{}>", xsd("double")),
));
v.push(q(
&s,
"<http://www.opengis.net/ont/geosparql#asWKT>",
&format!(
"\"POINT({i}.25 4{i}.5)\"^^<http://www.opengis.net/ont/geosparql#wktLiteral>"
),
));
v.push(q(
&s,
"<http://purl.org/dc/terms/created>",
&format!("\"20{i}0-01-0{}\"^^<{}>", i + 1, xsd("date")),
));
v.push(q(&s, "<http://ex/near>", "<http://elsewhere/undescribed>"));
}
let knows = "<http://ex/a> <http://ex/knows> <http://ex/b>";
let lives = "<http://ex/a> <http://ex/livesIn> <http://ex/c>";
let ghost = "<http://ex/b> <http://ex/knows> <http://ex/c>";
for (t, who) in [(knows, "alice"), (lives, "bob"), (ghost, "carol")] {
v.push(q(
&format!("<<{t}>>"),
"<http://ex/recordedBy>",
&format!("<http://ex/{who}>"),
));
}
v.push(q(
"<http://ex/claim/1>",
"<http://ex/states>",
&format!("<<{knows}>>"),
));
v.push((
"<http://ex/note/1>".into(),
"<http://ex/about>".into(),
"<http://ex/a>".into(),
Some("<http://ex/graph/notes>".into()),
));
v
}
fn both_ways(quads: &[(String, String, String, Option<String>)]) -> (DatasetCard, DatasetCard) {
let (bytes, _) =
crate::ingest::assemble_dataset_with_opts(quads.to_vec(), true, false, None, |_, _| {
Vec::new()
});
let rete = crate::Rete::open(&bytes).unwrap();
let term_count = rete.header().term_count;
let (card, report) =
derive_card_streaming(&rete, CardInput::default(), &mut |_| {}).unwrap();
assert_eq!(report.statements, card.triple_count);
let in_memory = derive_card(
quads,
term_count,
rete.named_graph_count() as u64,
CardInput::default(),
);
(card, in_memory)
}
#[test]
fn streaming_derivation_matches_the_in_memory_card() {
for quads in [enriched_fixture(), streaming_fixture()] {
let (streamed, in_memory) = both_ways(&quads);
assert_eq!(
String::from_utf8(streamed.to_json_bytes()).unwrap(),
String::from_utf8(in_memory.to_json_bytes()).unwrap()
);
}
let (card, _) = both_ways(&streaming_fixture());
assert!(card.signals.annotated_statement.is_some());
assert!(!card.signals.quoting_predicates.is_empty());
assert!(card.signals.spatial_bbox.is_some());
assert!(card.signals.temporal_extent.is_some());
assert_eq!(card.named_graph_count, 1);
}
#[test]
fn streaming_derivation_widens_the_class_map() {
let mut quads = Vec::new();
for i in 0..300 {
let s = format!("<http://ex/thing/{i:03}>");
quads.push(q(&s, TYPE, &format!("<http://ex/Class{i:03}>")));
quads.push(q(
&s,
"<http://ex/next>",
&format!("<http://ex/thing/{:03}>", (i + 1) % 300),
));
}
let (streamed, in_memory) = both_ways(&quads);
assert!(streamed.truncated);
assert_eq!(streamed.classes, in_memory.classes);
assert_eq!(streamed.class_links, in_memory.class_links);
assert_eq!(
String::from_utf8(streamed.to_json_bytes()).unwrap(),
String::from_utf8(in_memory.to_json_bytes()).unwrap()
);
}
#[test]
fn streaming_derivation_counts_the_deduplicated_file() {
let mut quads = enriched_fixture();
quads.push(quads[0].clone());
let (streamed, in_memory) = both_ways(&quads);
assert_eq!(streamed.triple_count, in_memory.triple_count - 1);
let type_count = |c: &DatasetCard| {
c.predicates
.iter()
.find(|(p, _)| p == TYPE)
.map(|(_, n)| *n)
};
assert_eq!(type_count(&streamed), type_count(&in_memory).map(|n| n - 1));
}
#[test]
fn the_curated_half_of_a_card_round_trips() {
let doc = r##"{"title":"T","description":["# Heading","line"],"license":"CC0-1.0",
"source":"https://ex/src","created":"2026-01-01","version":"1",
"creators":[{"name":"Ada","orcid":"https://orcid.org/0000-0002-1825-0097"}],
"publisher":{"name":"P","ror":"https://ror.org/01ggx4157"},
"canonical_url":"https://ex/f.rete","sparql_endpoint":"https://ex/sparql",
"source_date":"2025-12-31","derived_from":["https://ex/dump"],
"doi":"https://doi.org/10.1/x","cite_as":"Cite me","keywords":["b","a"],
"theme":["http://publications.europa.eu/resource/authority/data-theme/EDUC"],
"extra":{"k":[{"words":["x"]}]},"example_queries":["ASK {}"]}"##;
let curated = CardInput::from_json_str(doc).unwrap();
assert!(!curated.is_empty());
let card = derive_card(&enriched_fixture(), 12, 0, curated);
let again = derive_card(&enriched_fixture(), 12, 0, CardInput::from_card(&card));
assert_eq!(again.to_json_bytes(), card.to_json_bytes());
assert!(CardInput::from_card(&derive_card(
&enriched_fixture(),
12,
0,
CardInput::default()
))
.is_empty());
}
}