1use std::collections::{BTreeMap, BTreeSet};
31
32use serde::{Deserialize, Serialize};
33
34use crate::{Header, RDF_TYPE};
35#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
39pub struct DatasetCard {
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub title: Option<String>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub description: Option<String>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub license: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub source: Option<String>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub created: Option<String>,
50
51 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub version: Option<String>,
63 #[serde(default, skip_serializing_if = "Vec::is_empty")]
66 pub creators: Vec<Creator>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub publisher: Option<Publisher>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub canonical_url: Option<String>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub sparql_endpoint: Option<String>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub source_date: Option<String>,
81 #[serde(default, skip_serializing_if = "Vec::is_empty")]
84 pub derived_from: Vec<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub doi: Option<String>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub cite_as: Option<String>,
91 #[serde(default, skip_serializing_if = "Vec::is_empty")]
102 pub keywords: Vec<String>,
103 #[serde(default, skip_serializing_if = "Vec::is_empty")]
110 pub theme: Vec<String>,
111 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
127 pub extra: BTreeMap<String, serde_json::Value>,
128
129 pub triple_count: u64,
130 pub quad_count: u64,
131 pub named_graph_count: u64,
132 pub term_count: u64,
133
134 #[serde(default, skip_serializing_if = "Vec::is_empty")]
135 pub predicates: Vec<(String, u64)>,
136 #[serde(default, skip_serializing_if = "Vec::is_empty")]
137 pub classes: Vec<(String, u64)>,
138 #[serde(default, skip_serializing_if = "Vec::is_empty")]
139 pub vocabularies: Vec<String>,
140 #[serde(default, skip_serializing_if = "Vec::is_empty")]
141 pub example_queries: Vec<String>,
142
143 #[serde(default, skip_serializing_if = "Vec::is_empty")]
150 pub datatypes: Vec<(String, u64)>,
151 #[serde(default, skip_serializing_if = "Vec::is_empty")]
154 pub languages: Vec<(String, u64)>,
155 #[serde(default, skip_serializing_if = "Vec::is_empty")]
159 pub class_links: Vec<ClassLink>,
160 #[serde(default, skip_serializing_if = "Vec::is_empty")]
162 pub top_hubs: Vec<(String, u64)>,
163 #[serde(default, skip_serializing_if = "Vec::is_empty")]
165 pub in_hubs: Vec<(String, u64)>,
166 #[serde(default, skip_serializing_if = "Signals::is_empty")]
169 pub signals: Signals,
170 #[serde(default, skip_serializing_if = "Vec::is_empty")]
174 pub queries: Vec<ExampleQuery>,
175 #[serde(default, skip_serializing_if = "Coherence::is_empty")]
179 pub coherence: Coherence,
180 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
182 pub truncated: bool,
183 #[serde(default, skip_serializing_if = "is_zero_u32")]
187 pub top_n: u32,
188
189 pub format_version: u8,
193}
194
195fn is_zero_u32(v: &u32) -> bool {
197 *v == 0
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub struct Creator {
203 pub name: String,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub orcid: Option<String>,
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211pub struct Publisher {
212 pub name: String,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub ror: Option<String>,
216}
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
222#[serde(rename_all = "lowercase")]
223pub enum Tier {
224 Card,
225 Summary,
226 Index,
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub struct ExampleQuery {
232 pub id: String,
234 pub title: String,
236 pub dimension: String,
238 pub question: String,
240 pub sparql: String,
242 pub tier: Tier,
244 #[serde(default, skip_serializing_if = "Vec::is_empty")]
246 pub requires: Vec<String>,
247}
248
249#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
253pub struct ClassLink {
254 pub s_class: String,
255 pub predicate: String,
256 pub o_class: String,
257 pub count: u64,
258}
259
260#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
265pub struct Signals {
266 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub label_predicate: Option<String>,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub default_lang: Option<String>,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub base_iri: Option<String>,
275 #[serde(default, skip_serializing_if = "Vec::is_empty")]
277 pub time_predicates: Vec<String>,
278 #[serde(default, skip_serializing_if = "Vec::is_empty")]
281 pub numeric_predicates: Vec<String>,
282 #[serde(default, skip_serializing_if = "Vec::is_empty")]
284 pub link_predicates: Vec<String>,
285 #[serde(default, skip_serializing_if = "Vec::is_empty")]
299 pub annotation_predicates: Vec<String>,
300 #[serde(default, skip_serializing_if = "Vec::is_empty")]
305 pub quoting_predicates: Vec<String>,
306 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub annotated_statement: Option<(String, String)>,
325 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
327 pub geo_wkt: bool,
328 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
330 pub geo_latlong: bool,
331 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub temporal_extent: Option<(String, String)>,
334 #[serde(default, skip_serializing_if = "Option::is_none")]
336 pub spatial_bbox: Option<[f64; 4]>,
337 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub text_index: Option<TextIndexSignal>,
348 #[serde(default, skip_serializing_if = "Option::is_none")]
353 pub label_index: Option<LabelIndexSignal>,
354 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub permutations: Option<PermutationsSignal>,
360 #[serde(default, skip_serializing_if = "Option::is_none")]
366 pub quoted_triples: Option<QuotedTriplesSignal>,
367}
368
369impl Signals {
370 pub fn is_empty(&self) -> bool {
372 *self == Signals::default()
373 }
374}
375
376#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
403pub struct TextIndexSignal {
404 pub present: bool,
407 #[serde(default, skip_serializing_if = "is_zero_u64")]
411 pub bytes: u64,
412 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub token_table_bytes: Option<u64>,
418}
419
420fn is_zero_u64(v: &u64) -> bool {
422 *v == 0
423}
424
425#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
445pub struct PermutationsSignal {
446 pub count: u8,
448 pub names: Vec<String>,
452 pub merge_join: bool,
456}
457
458impl PermutationsSignal {
459 pub fn probe(header: &Header) -> Self {
463 let perms = header.perms;
464 PermutationsSignal {
465 count: perms.len() as u8,
466 names: perms.names().into_iter().map(str::to_string).collect(),
467 merge_join: perms.has_merge_orders(),
468 }
469 }
470
471 pub fn describe(&self) -> String {
473 if self.merge_join {
474 format!(
475 "{} index permutations ({}) — sort-merge joins available",
476 self.count,
477 self.names.join("/")
478 )
479 } else {
480 format!(
481 "{} index permutations ({}) — same rows, same routing; \
482 no sort-merge join (hash/probe answers instead)",
483 self.count,
484 self.names.join("/")
485 )
486 }
487 }
488}
489
490pub const QUOTED_TRIPLE_SURFACE_RDF12: &str = "rdf12";
497
498pub const QUOTED_TRIPLE_SURFACE_RDF_STAR: &str = "rdf-star";
501
502#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
531pub struct QuotedTriplesSignal {
532 pub present: bool,
534 #[serde(default, skip_serializing_if = "Vec::is_empty")]
537 pub export_surfaces: Vec<String>,
538 #[serde(default, skip_serializing_if = "Option::is_none")]
541 pub export_default: Option<String>,
542}
543
544impl QuotedTriplesSignal {
545 pub fn probe(header: &Header) -> Self {
548 if !header.has_quoted_triples() {
549 return QuotedTriplesSignal {
550 present: false,
551 export_surfaces: Vec::new(),
552 export_default: None,
553 };
554 }
555 QuotedTriplesSignal {
556 present: true,
557 export_surfaces: vec![
558 QUOTED_TRIPLE_SURFACE_RDF12.to_string(),
559 QUOTED_TRIPLE_SURFACE_RDF_STAR.to_string(),
560 ],
561 export_default: Some(QUOTED_TRIPLE_SURFACE_RDF12.to_string()),
562 }
563 }
564
565 pub fn describe(&self) -> Option<String> {
569 if !self.present {
570 return None;
571 }
572 Some(format!(
573 "present — `rete export` writes RDF 1.2 triple terms `<<( s p o )>>` \
574 by default; `--quoted-triple-syntax {QUOTED_TRIPLE_SURFACE_RDF_STAR}` \
575 writes `<<s p o>>`"
576 ))
577 }
578}
579impl TextIndexSignal {
580 pub fn probe<R: crate::RangeReader + ?Sized>(reader: &R, header: &Header) -> Self {
585 if header.text_index_len == 0 {
586 return TextIndexSignal::default();
587 }
588 TextIndexSignal {
589 present: true,
590 bytes: header.text_index_len,
591 token_table_bytes: crate::read_text_index_token_table_len_ranged(reader, header),
592 }
593 }
594
595 pub fn describe(&self) -> String {
597 if !self.present {
598 return "no TEXT_INDEX section — CONTAINS/regex still answer, by full scan".to_string();
599 }
600 match self.token_table_bytes {
601 Some(tt) => format!(
602 "TEXT_INDEX present — {} bytes ({tt} of them the token table a first search reads)",
603 self.bytes
604 ),
605 None => format!("TEXT_INDEX present — {} bytes", self.bytes),
606 }
607 }
608}
609
610#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
621pub struct LabelIndexSignal {
622 pub present: bool,
625 #[serde(default, skip_serializing_if = "is_zero_u64")]
627 pub bytes: u64,
628 #[serde(default, skip_serializing_if = "Option::is_none")]
631 pub entries: Option<u64>,
632}
633
634impl LabelIndexSignal {
635 pub fn probe<R: crate::RangeReader + ?Sized>(reader: &R, header: &Header) -> Self {
639 let Some(s) = header.label_index_section() else {
640 return LabelIndexSignal::default();
641 };
642 LabelIndexSignal {
643 present: true,
644 bytes: s.length,
645 entries: crate::label_index::read_label_index_entries_ranged(reader, header),
646 }
647 }
648
649 pub fn describe(&self) -> String {
651 if !self.present {
652 return "no LABEL_INDEX section — a prefix search finds only what a pyramid's \
653 legacy label block holds, if any"
654 .to_string();
655 }
656 match self.entries {
657 Some(n) => format!("LABEL_INDEX present — {n} labels, {} bytes", self.bytes),
658 None => format!("LABEL_INDEX present — {} bytes", self.bytes),
659 }
660 }
661}
662
663#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
670pub struct Coherence {
671 pub coherent: bool,
673 pub inconsistency_count: u32,
675 #[serde(default, skip_serializing_if = "Vec::is_empty")]
677 pub by_kind: Vec<(String, u32)>,
678 pub scope: String,
680 pub rules: String,
682 pub materialized: bool,
684}
685
686impl Coherence {
687 pub fn is_empty(&self) -> bool {
689 *self == Coherence::default()
690 }
691
692 pub fn from_reasoning(r: &crate::Reasoning, materialized: bool) -> Self {
696 let mut hist: BTreeMap<&str, u32> = BTreeMap::new();
697 for inc in &r.inconsistencies {
698 *hist.entry(inc.kind).or_default() += 1;
699 }
700 Coherence {
701 coherent: r.inconsistencies.is_empty(),
702 inconsistency_count: r.inconsistencies.len() as u32,
703 by_kind: hist.into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
704 scope: "default".to_string(),
705 rules: crate::REASON_RULESET.to_string(),
706 materialized,
707 }
708 }
709}
710
711#[derive(Debug, Clone, Default, Deserialize)]
723#[serde(deny_unknown_fields)]
724pub struct CardInput {
725 pub title: Option<String>,
726 #[serde(default, deserialize_with = "de_description")]
730 pub description: Option<String>,
731 pub license: Option<String>,
732 pub source: Option<String>,
733 pub created: Option<String>,
734 pub version: Option<String>,
735 #[serde(default)]
736 pub creators: Vec<Creator>,
737 pub publisher: Option<Publisher>,
738 pub canonical_url: Option<String>,
739 pub sparql_endpoint: Option<String>,
740 pub source_date: Option<String>,
741 #[serde(default)]
742 pub derived_from: Vec<String>,
743 pub doi: Option<String>,
744 pub cite_as: Option<String>,
745 #[serde(default)]
747 pub keywords: Vec<String>,
748 #[serde(default)]
750 pub theme: Vec<String>,
751 #[serde(default)]
753 pub extra: BTreeMap<String, serde_json::Value>,
754 #[serde(default)]
755 pub example_queries: Vec<String>,
756}
757
758fn de_description<'de, D>(d: D) -> Result<Option<String>, D::Error>
768where
769 D: serde::Deserializer<'de>,
770{
771 use serde::de::Error as _;
772 let v = Option::<serde_json::Value>::deserialize(d)?;
773 match v {
774 None | Some(serde_json::Value::Null) => Ok(None),
775 Some(v) => crate::card_input::normalize_description(&v)
776 .map(Some)
777 .map_err(D::Error::custom),
778 }
779}
780
781impl CardInput {
782 pub fn from_card(card: &DatasetCard) -> Self {
788 CardInput {
789 title: card.title.clone(),
790 description: card.description.clone(),
791 license: card.license.clone(),
792 source: card.source.clone(),
793 created: card.created.clone(),
794 version: card.version.clone(),
795 creators: card.creators.clone(),
796 publisher: card.publisher.clone(),
797 canonical_url: card.canonical_url.clone(),
798 sparql_endpoint: card.sparql_endpoint.clone(),
799 source_date: card.source_date.clone(),
800 derived_from: card.derived_from.clone(),
801 doi: card.doi.clone(),
802 cite_as: card.cite_as.clone(),
803 keywords: card.keywords.clone(),
804 theme: card.theme.clone(),
805 extra: card.extra.clone(),
806 example_queries: card.example_queries.clone(),
807 }
808 }
809
810 pub fn is_empty(&self) -> bool {
812 self.title.is_none()
813 && self.description.is_none()
814 && self.license.is_none()
815 && self.source.is_none()
816 && self.created.is_none()
817 && self.version.is_none()
818 && self.creators.is_empty()
819 && self.publisher.is_none()
820 && self.canonical_url.is_none()
821 && self.sparql_endpoint.is_none()
822 && self.source_date.is_none()
823 && self.derived_from.is_empty()
824 && self.doi.is_none()
825 && self.cite_as.is_none()
826 && self.keywords.is_empty()
827 && self.theme.is_empty()
828 && self.extra.is_empty()
829 && self.example_queries.is_empty()
830 }
831
832 fn fill(self, card: &mut DatasetCard) {
835 card.title = self.title;
836 card.description = self.description;
837 card.license = self.license;
838 card.source = self.source;
839 card.created = self.created;
840 card.version = self.version;
841 card.creators = self.creators;
842 card.publisher = self.publisher;
843 card.canonical_url = self.canonical_url;
844 card.sparql_endpoint = self.sparql_endpoint;
845 card.source_date = self.source_date;
846 card.derived_from = self.derived_from;
847 card.doi = self.doi;
848 card.cite_as = self.cite_as;
849 card.keywords = self.keywords;
850 card.theme = self.theme;
851 card.extra = self.extra;
852 card.example_queries = self.example_queries;
853 }
854
855 pub fn from_json_str(text: &str) -> Result<Self, String> {
869 let parsed: CardInput = serde_json::from_str(text).map_err(|e| {
870 let hint = if e.to_string().contains("unknown field") {
873 crate::card_input::UNKNOWN_FIELD_HINT
874 } else {
875 ""
876 };
877 format!("{e}{hint}")
878 })?;
879 parsed.normalize()
880 }
881
882 pub fn normalize(mut self) -> Result<Self, String> {
891 if let Some(d) = &self.description {
892 crate::card_input::check_description_len(d)?;
893 }
894 self.keywords = crate::card_input::normalize_string_list(
895 "keywords",
896 std::mem::take(&mut self.keywords),
897 )?;
898 self.theme = crate::card_input::normalize_themes(std::mem::take(&mut self.theme))?;
899 let bag: serde_json::Map<String, serde_json::Value> =
900 std::mem::take(&mut self.extra).into_iter().collect();
901 self.extra = crate::card_input::normalize_extra(bag)?
902 .into_iter()
903 .collect();
904 Ok(self)
905 }
906}
907
908impl DatasetCard {
909 pub fn to_json_bytes(&self) -> Vec<u8> {
920 if self.signals.text_index.is_none()
921 && self.signals.label_index.is_none()
922 && self.signals.permutations.is_none()
923 && self.signals.quoted_triples.is_none()
924 {
925 return serde_json::to_vec(self).expect("DatasetCard serializes");
926 }
927 let mut stored = self.clone();
928 stored.signals.text_index = None;
929 stored.signals.label_index = None;
930 stored.signals.permutations = None;
931 stored.signals.quoted_triples = None;
932 serde_json::to_vec(&stored).expect("DatasetCard serializes")
933 }
934
935 pub fn observe_text_index(&mut self, measured: TextIndexSignal) -> Option<TextIndexSignal> {
942 self.signals.text_index.replace(measured)
943 }
944
945 pub fn observe_label_index(&mut self, measured: LabelIndexSignal) -> Option<LabelIndexSignal> {
948 self.signals.label_index.replace(measured)
949 }
950
951 pub fn observe_permutations(
956 &mut self,
957 measured: PermutationsSignal,
958 ) -> Option<PermutationsSignal> {
959 self.signals.permutations.replace(measured)
960 }
961
962 pub fn observe_quoted_triples(
967 &mut self,
968 measured: QuotedTriplesSignal,
969 ) -> Option<QuotedTriplesSignal> {
970 self.signals.quoted_triples.replace(measured)
971 }
972
973 pub fn from_json_bytes(b: &[u8]) -> Result<Self, String> {
979 serde_json::from_slice(b).map_err(|e| format!("malformed dataset card: {e}"))
980 }
981
982 pub fn with_coherence(mut self, r: &crate::Reasoning, materialized: bool) -> Self {
984 self.coherence = Coherence::from_reasoning(r, materialized);
985 self
986 }
987
988 pub fn with_final_counts(mut self, counts: crate::ingest::FinalCounts) -> Self {
1002 self.triple_count = counts.default_triples;
1003 self.quad_count = counts.quads;
1004 self
1005 }
1006}
1007
1008pub const CARD_TOP_N: usize = 100;
1014
1015const ANNOTATION_WITNESS_SAMPLE: usize = 4096;
1031
1032const RDFS_LABEL: &str = "<http://www.w3.org/2000/01/rdf-schema#label>";
1034const SKOS_PREFLABEL: &str = "<http://www.w3.org/2004/02/skos/core#prefLabel>";
1035const SCHEMA_NAME: &str = "<http://schema.org/name>";
1036const FOAF_NAME: &str = "<http://xmlns.com/foaf/0.1/name>";
1037const DCT_TITLE: &str = "<http://purl.org/dc/terms/title>";
1038const OWL_SAMEAS: &str = "<http://www.w3.org/2002/07/owl#sameAs>";
1039const SKOS_EXACTMATCH: &str = "<http://www.w3.org/2004/02/skos/core#exactMatch>";
1040const SKOS_CLOSEMATCH: &str = "<http://www.w3.org/2004/02/skos/core#closeMatch>";
1041const RDFS_SEEALSO: &str = "<http://www.w3.org/2000/01/rdf-schema#seeAlso>";
1042pub(crate) const WGS_LAT: &str = "<http://www.w3.org/2003/01/geo/wgs84_pos#lat>";
1043pub(crate) const WGS_LONG: &str = "<http://www.w3.org/2003/01/geo/wgs84_pos#long>";
1044pub const GEO_ASWKT: &str = "<http://www.opengis.net/ont/geosparql#asWKT>";
1047pub const GEO_HASGEOMETRY: &str = "<http://www.opengis.net/ont/geosparql#hasGeometry>";
1048const GEO_WKTLITERAL: &str = "http://www.opengis.net/ont/geosparql#wktLiteral";
1050const XSD_STRING: &str = "http://www.w3.org/2001/XMLSchema#string";
1051const RDF_LANGSTRING: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString";
1052
1053pub const O_LITERAL: &str = "(literal)";
1058
1059const LABEL_PREDICATES: &[&str] = &[
1061 SKOS_PREFLABEL,
1062 RDFS_LABEL,
1063 SCHEMA_NAME,
1064 FOAF_NAME,
1065 DCT_TITLE,
1066];
1067pub(crate) const LINK_PREDICATES: &[&str] =
1069 &[OWL_SAMEAS, SKOS_EXACTMATCH, SKOS_CLOSEMATCH, RDFS_SEEALSO];
1070
1071pub trait CardTripleSource {
1078 fn replay(&self, f: &mut dyn FnMut(&str, &str, &str));
1079}
1080
1081pub fn curated_counts_card(statements: u64, term_count: u64, curated: CardInput) -> DatasetCard {
1090 let mut card = DatasetCard {
1091 triple_count: statements,
1092 quad_count: statements,
1093 named_graph_count: 0,
1094 term_count,
1095 format_version: crate::format::CURRENT_FORMAT_VERSION,
1096 ..DatasetCard::default()
1097 };
1098 curated.fill(&mut card);
1099 card
1100}
1101
1102pub fn derive_card(
1103 quads: &[(String, String, String, Option<String>)],
1104 term_count: u64,
1105 named_graph_count: u64,
1106 curated: CardInput,
1107) -> DatasetCard {
1108 if crate::build_progress::listening() {
1109 let default = quads.iter().filter(|q| q.3.is_none()).count() as u64;
1110 crate::build_progress::set_total(Some(2 * default), crate::build_progress::Unit::Records);
1111 }
1112 struct Quads<'a>(&'a [(String, String, String, Option<String>)]);
1113 impl CardTripleSource for Quads<'_> {
1114 fn replay(&self, f: &mut dyn FnMut(&str, &str, &str)) {
1115 for (s, p, o, g) in self.0 {
1116 if g.is_none() {
1117 f(s, p, o);
1118 }
1119 }
1120 }
1121 }
1122 derive_card_from(
1123 &Quads(quads),
1124 quads.len() as u64,
1125 term_count,
1126 named_graph_count,
1127 curated,
1128 )
1129}
1130
1131pub fn derive_card_encoded(
1137 dict: &crate::Dictionary,
1138 triples: &[(u32, u32, u32)],
1139 quad_count: u64,
1140 term_count: u64,
1141 named_graph_count: u64,
1142 curated: CardInput,
1143) -> DatasetCard {
1144 crate::build_progress::set_total(
1145 Some(2 * triples.len() as u64),
1146 crate::build_progress::Unit::Records,
1147 );
1148 struct Encoded<'a> {
1149 dict: &'a crate::Dictionary,
1150 triples: &'a [(u32, u32, u32)],
1151 }
1152 impl CardTripleSource for Encoded<'_> {
1153 fn replay(&self, f: &mut dyn FnMut(&str, &str, &str)) {
1154 for &(s, p, o) in self.triples {
1155 let st = self.dict.subject_term(s).unwrap_or_default();
1156 let pt = self.dict.predicate_term(p).unwrap_or_default();
1157 let ot = self.dict.object_term(o).unwrap_or_default();
1158 f(&st, &pt, &ot);
1159 }
1160 }
1161 }
1162 derive_card_from(
1163 &Encoded { dict, triples },
1164 quad_count,
1165 term_count,
1166 named_graph_count,
1167 curated,
1168 )
1169}
1170
1171pub fn derive_card_from(
1179 src: &dyn CardTripleSource,
1180 quad_count: u64,
1181 term_count: u64,
1182 named_graph_count: u64,
1183 curated: CardInput,
1184) -> DatasetCard {
1185 let mut class_of: std::collections::HashMap<String, String> = std::collections::HashMap::new();
1191 let mut quoted_subject_preds: BTreeSet<String> = BTreeSet::new();
1192 let mut annotated_tokens: BTreeMap<String, String> = BTreeMap::new();
1193 let mut ticker = crate::build_progress::Ticker::new();
1195 src.replay(&mut |s, p, o| {
1196 ticker.tick(1);
1197 if p == RDF_TYPE {
1198 class_of.insert(s.to_string(), o.to_string());
1199 }
1200 if crate::terms::is_quoted_triple(s) {
1201 if let Some((_, quoted_p, _)) = crate::ingest::quoted_triple_parts(s) {
1202 quoted_subject_preds.insert(quoted_p);
1203 }
1204 if annotated_tokens.len() < ANNOTATION_WITNESS_SAMPLE {
1205 annotated_tokens
1206 .entry(s.to_string())
1207 .or_insert_with(|| p.to_string());
1208 }
1209 }
1210 });
1211 let classify = |t: &str| -> String {
1212 if let Some(c) = class_of.get(t) {
1213 c.to_string()
1214 } else if t.starts_with('"') {
1215 O_LITERAL.to_string()
1216 } else {
1217 O_UNTYPED.to_string()
1218 }
1219 };
1220
1221 let mut prof = Profile::default();
1227 let mut out_degree: BTreeMap<String, u64> = BTreeMap::new();
1228 let mut in_degree: BTreeMap<String, u64> = BTreeMap::new();
1229
1230 src.replay(&mut |s, p, o| {
1231 ticker.tick(1);
1232 prof.triple_count += 1;
1233 *prof.pred_counts.entry(p.to_string()).or_default() += 1;
1234 *out_degree.entry(s.to_string()).or_default() += 1;
1235 if let Some(ns) = split_namespace(s) {
1236 *prof.subject_ns.entry(ns).or_default() += 1;
1237 }
1238
1239 if !o.starts_with('"') {
1243 *in_degree.entry(o.to_string()).or_default() += 1;
1244 }
1245
1246 if crate::terms::is_quoted_triple(s) {
1250 *prof.annotation_hits.entry(p.to_string()).or_default() += 1;
1251 }
1252 if crate::terms::is_quoted_triple(o) {
1253 *prof.quoting_hits.entry(p.to_string()).or_default() += 1;
1254 }
1255 if !quoted_subject_preds.is_empty() && quoted_subject_preds.contains(p) {
1262 if let Some(about) = annotated_tokens.get(&format!("<<{s} {p} {o}>>")) {
1263 *prof
1264 .witnessed_pairs
1265 .entry((p.to_string(), about.clone()))
1266 .or_default() += 1;
1267 }
1268 }
1269
1270 if p == RDF_TYPE {
1271 *prof.class_counts.entry(o.to_string()).or_default() += 1;
1272 return; }
1274
1275 *prof
1277 .link_rows
1278 .entry((classify(s), p.to_string(), classify(o)))
1279 .or_default() += 1;
1280
1281 if p == GEO_ASWKT || p == GEO_HASGEOMETRY {
1282 prof.geo_wkt = true;
1283 }
1284
1285 if let Some(lit) = parse_literal(o) {
1287 prof.literal(p, &lit, 1);
1288 }
1289 });
1290
1291 let mut truncated = false;
1292 let top_hubs = cap(sort_desc_owned(out_degree), &mut truncated);
1293 let in_hubs = cap(sort_desc_owned(in_degree), &mut truncated);
1294 finish(
1295 prof,
1296 Hubs {
1297 top_hubs,
1298 in_hubs,
1299 truncated,
1300 },
1301 quad_count,
1302 term_count,
1303 named_graph_count,
1304 curated,
1305 )
1306}
1307
1308const O_UNTYPED: &str = "(untyped)";
1311
1312#[derive(Default)]
1323struct Profile {
1324 triple_count: u64,
1325 pred_counts: BTreeMap<String, u64>,
1326 class_counts: BTreeMap<String, u64>,
1327 datatype_counts: BTreeMap<String, u64>,
1328 lang_counts: BTreeMap<String, u64>,
1329 link_rows: BTreeMap<(String, String, String), u64>,
1330 subject_ns: BTreeMap<String, u64>,
1331 time_obj_hits: BTreeMap<String, u64>,
1333 num_obj_hits: BTreeMap<String, u64>,
1335 annotation_hits: BTreeMap<String, u64>,
1340 quoting_hits: BTreeMap<String, u64>,
1341 witnessed_pairs: BTreeMap<(String, String), u64>,
1342 time_extent: BTreeMap<String, (String, String)>,
1344 lat: Option<(f64, f64)>,
1346 lon: Option<(f64, f64)>,
1347 geo_wkt: bool,
1348}
1349
1350impl Profile {
1351 fn literal(&mut self, p: &str, lit: &Literal, weight: u64) {
1355 *self
1356 .datatype_counts
1357 .entry(format!("<{}>", lit.datatype))
1358 .or_default() += weight;
1359 *self.lang_counts.entry(lit.lang.clone()).or_default() += weight;
1360 if lit.datatype == GEO_WKTLITERAL {
1361 self.geo_wkt = true;
1362 }
1363 if is_temporal(p, lit) {
1364 *self.time_obj_hits.entry(p.to_string()).or_default() += weight;
1365 update_time_extent(p, &lit.value, &mut self.time_extent);
1366 } else if is_numeric_datatype(&lit.datatype) {
1367 *self.num_obj_hits.entry(p.to_string()).or_default() += weight;
1370 }
1371 if p == WGS_LAT {
1373 if let Ok(v) = lit.value.parse::<f64>() {
1374 widen(&mut self.lat, v);
1375 }
1376 } else if p == WGS_LONG {
1377 if let Ok(v) = lit.value.parse::<f64>() {
1378 widen(&mut self.lon, v);
1379 }
1380 }
1381 }
1382}
1383
1384fn widen(extent: &mut Option<(f64, f64)>, v: f64) {
1386 *extent = Some(match *extent {
1387 None => (v, v),
1388 Some((lo, hi)) => (lo.min(v), hi.max(v)),
1389 });
1390}
1391
1392struct Hubs {
1394 top_hubs: Vec<(String, u64)>,
1395 in_hubs: Vec<(String, u64)>,
1396 truncated: bool,
1397}
1398
1399fn finish(
1405 p: Profile,
1406 hubs: Hubs,
1407 quad_count: u64,
1408 term_count: u64,
1409 named_graph_count: u64,
1410 curated: CardInput,
1411) -> DatasetCard {
1412 let Profile {
1413 triple_count,
1414 pred_counts,
1415 class_counts,
1416 datatype_counts,
1417 lang_counts,
1418 link_rows,
1419 subject_ns,
1420 time_obj_hits,
1421 num_obj_hits,
1422 annotation_hits,
1423 quoting_hits,
1424 witnessed_pairs,
1425 time_extent,
1426 lat,
1427 lon,
1428 geo_wkt,
1429 } = p;
1430 let pred_count = |iri: &str| pred_counts.get(iri).copied().unwrap_or(0);
1433 let label_predicate = LABEL_PREDICATES
1434 .iter()
1435 .filter(|&&iri| pred_count(iri) > 0)
1436 .max_by_key(|&&iri| pred_count(iri))
1437 .map(|s| s.to_string());
1438 let link_predicates: Vec<String> = LINK_PREDICATES
1439 .iter()
1440 .filter(|&&iri| pred_count(iri) > 0)
1441 .map(|s| s.to_string())
1442 .collect();
1443
1444 let mut truncated = hubs.truncated;
1445 let predicates = cap(sort_desc_owned(pred_counts), &mut truncated);
1446 let classes = cap(sort_desc_owned(class_counts), &mut truncated);
1447 let datatypes = cap(sort_desc_owned(datatype_counts), &mut truncated);
1448 let languages = cap(sort_desc_owned(lang_counts), &mut truncated);
1449 let class_links = cap(sort_links(link_rows), &mut truncated);
1450
1451 let mut vocab: BTreeSet<String> = BTreeSet::new();
1453 for (iri, _) in predicates.iter().chain(classes.iter()) {
1454 if let Some(ns) = split_namespace(iri) {
1455 vocab.insert(ns);
1456 }
1457 }
1458 let vocabularies = cap(vocab.into_iter().collect(), &mut truncated);
1459
1460 let default_lang = languages
1462 .iter()
1463 .find(|(l, _)| !l.is_empty())
1464 .map(|(l, _)| l.clone());
1465 let base_iri = derive_base_iri(&subject_ns);
1466 let mut time_ranked: Vec<(String, u64)> = time_obj_hits.into_iter().collect();
1467 time_ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1468 let time_predicates = cap(
1470 time_ranked.iter().map(|(p, _)| p.to_string()).collect(),
1471 &mut truncated,
1472 );
1473 let mut num_ranked: Vec<(String, u64)> = num_obj_hits.into_iter().collect();
1474 num_ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1475 let numeric_predicates = cap(
1476 num_ranked.iter().map(|(p, _)| p.to_string()).collect(),
1477 &mut truncated,
1478 );
1479 let annotation_predicates = cap(
1485 sort_desc_owned(annotation_hits)
1486 .into_iter()
1487 .map(|(p, _)| p)
1488 .collect(),
1489 &mut truncated,
1490 );
1491 let quoting_predicates = cap(
1492 sort_desc_owned(quoting_hits)
1493 .into_iter()
1494 .map(|(p, _)| p)
1495 .collect(),
1496 &mut truncated,
1497 );
1498 let annotated_statement = witnessed_pairs
1504 .into_iter()
1505 .max_by(|a, b| a.1.cmp(&b.1).then_with(|| b.0.cmp(&a.0)))
1506 .map(|(pair, _)| pair);
1507 let geo_latlong = lat.is_some() && lon.is_some();
1508 let temporal_extent = time_ranked
1509 .first()
1510 .and_then(|(p, _)| time_extent.get(p))
1511 .cloned();
1512 let spatial_bbox = match (lat, lon) {
1513 (Some((lat_lo, lat_hi)), Some((lon_lo, lon_hi)))
1514 if lon_lo.is_finite() && lat_lo.is_finite() =>
1515 {
1516 Some([lon_lo, lat_lo, lon_hi, lat_hi])
1517 }
1518 _ => None,
1519 };
1520
1521 let signals = Signals {
1522 label_predicate,
1523 default_lang,
1524 base_iri,
1525 time_predicates,
1526 numeric_predicates,
1527 link_predicates,
1528 annotation_predicates,
1529 quoting_predicates,
1530 annotated_statement,
1531 geo_wkt,
1532 geo_latlong,
1533 temporal_extent,
1534 spatial_bbox,
1535 text_index: None,
1539 label_index: None,
1540 permutations: None,
1541 quoted_triples: None,
1542 };
1543
1544 let mut card = DatasetCard {
1545 triple_count,
1546 quad_count,
1547 named_graph_count,
1548 term_count,
1549 predicates,
1550 classes,
1551 vocabularies,
1552 datatypes,
1553 languages,
1554 class_links,
1555 top_hubs: hubs.top_hubs,
1556 in_hubs: hubs.in_hubs,
1557 signals,
1558 queries: Vec::new(),
1559 coherence: Coherence::default(),
1562 truncated,
1563 top_n: CARD_TOP_N as u32,
1566 format_version: crate::CURRENT_FORMAT_VERSION,
1567 ..DatasetCard::default()
1568 };
1569 curated.fill(&mut card);
1570 card.queries = crate::card_queries::generate(&card);
1572 card
1573}
1574
1575fn cap<T>(mut v: Vec<T>, truncated: &mut bool) -> Vec<T> {
1577 if v.len() > CARD_TOP_N {
1578 v.truncate(CARD_TOP_N);
1579 *truncated = true;
1580 }
1581 v
1582}
1583
1584fn sort_desc_owned(counts: BTreeMap<String, u64>) -> Vec<(String, u64)> {
1586 let mut v: Vec<(String, u64)> = counts.into_iter().collect();
1587 v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1588 v
1589}
1590
1591fn sort_links(rows: BTreeMap<(String, String, String), u64>) -> Vec<ClassLink> {
1594 let mut v: Vec<ClassLink> = rows
1595 .into_iter()
1596 .map(|((s, p, o), count)| ClassLink {
1597 s_class: s,
1598 predicate: p,
1599 o_class: o,
1600 count,
1601 })
1602 .collect();
1603 v.sort_by(|a, b| {
1604 b.count
1605 .cmp(&a.count)
1606 .then_with(|| a.s_class.cmp(&b.s_class))
1607 .then_with(|| a.predicate.cmp(&b.predicate))
1608 .then_with(|| a.o_class.cmp(&b.o_class))
1609 });
1610 v
1611}
1612
1613fn derive_base_iri(ns: &BTreeMap<String, u64>) -> Option<String> {
1620 if ns.is_empty() {
1621 return None;
1622 }
1623 let dominant = ns
1625 .iter()
1626 .max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
1627 .map(|(k, _)| k.clone())?;
1628 let mut lcp_len = dominant.len();
1630 for k in ns.keys() {
1631 let common = dominant
1632 .bytes()
1633 .zip(k.bytes())
1634 .take_while(|(a, b)| a == b)
1635 .count();
1636 lcp_len = lcp_len.min(common);
1637 }
1638 let lcp = &dominant[..lcp_len];
1639 let base = match lcp.rfind(['/', '#']) {
1641 Some(i) => &lcp[..=i],
1642 None => "",
1643 };
1644 if base.matches('/').count() >= 3 {
1647 Some(base.to_string())
1648 } else {
1649 Some(dominant)
1650 }
1651}
1652
1653struct Literal {
1655 value: String,
1657 datatype: String,
1660 lang: String,
1662}
1663
1664fn parse_literal(term: &str) -> Option<Literal> {
1667 let bytes = term.as_bytes();
1668 if bytes.first() != Some(&b'"') {
1669 return None;
1670 }
1671 let mut i = 1usize;
1673 let mut esc = false;
1674 let mut close = None;
1675 while i < bytes.len() {
1676 match bytes[i] {
1677 b'\\' => esc = !esc,
1678 b'"' if !esc => {
1679 close = Some(i);
1680 break;
1681 }
1682 _ => esc = false,
1683 }
1684 i += 1;
1685 }
1686 let close = close?;
1687 let value = term[1..close].to_string();
1688 let suffix = &term[close + 1..];
1689 if let Some(lang) = suffix.strip_prefix('@') {
1690 Some(Literal {
1691 value,
1692 datatype: RDF_LANGSTRING.to_string(),
1693 lang: lang.to_string(),
1694 })
1695 } else if let Some(dt) = suffix.strip_prefix("^^") {
1696 let dt = dt.trim_start_matches('<').trim_end_matches('>').to_string();
1697 Some(Literal {
1698 value,
1699 datatype: dt,
1700 lang: String::new(),
1701 })
1702 } else {
1703 Some(Literal {
1704 value,
1705 datatype: XSD_STRING.to_string(),
1706 lang: String::new(),
1707 })
1708 }
1709}
1710
1711fn is_temporal_datatype(dt: &str) -> bool {
1715 matches!(
1716 dt,
1717 "http://www.w3.org/2001/XMLSchema#date"
1718 | "http://www.w3.org/2001/XMLSchema#dateTime"
1719 | "http://www.w3.org/2001/XMLSchema#dateTimeStamp"
1720 | "http://www.w3.org/2001/XMLSchema#gYear"
1721 | "http://www.w3.org/2001/XMLSchema#gYearMonth"
1722 )
1723}
1724
1725const DATE_PREDICATES: &[&str] = &[
1730 "<http://purl.org/dc/terms/date>",
1731 "<http://purl.org/dc/terms/issued>",
1732 "<http://purl.org/dc/terms/created>",
1733 "<http://purl.org/dc/terms/modified>",
1734 "<http://purl.org/dc/terms/available>",
1735 "<http://purl.org/dc/terms/dateAccepted>",
1736 "<http://purl.org/dc/terms/dateCopyrighted>",
1737 "<http://purl.org/dc/terms/dateSubmitted>",
1738 "<http://purl.org/dc/elements/1.1/date>",
1739 "<http://schema.org/datePublished>",
1740 "<http://schema.org/dateCreated>",
1741 "<http://schema.org/dateModified>",
1742 "<http://schema.org/startDate>",
1743 "<http://schema.org/endDate>",
1744 "<http://schema.org/birthDate>",
1745 "<http://schema.org/deathDate>",
1746 "<http://schema.org/foundingDate>",
1747 "<http://schema.org/uploadDate>",
1748 "<https://schema.org/datePublished>",
1749 "<https://schema.org/dateCreated>",
1750 "<https://schema.org/dateModified>",
1751 "<https://schema.org/startDate>",
1752 "<https://schema.org/endDate>",
1753 "<https://schema.org/birthDate>",
1754 "<https://schema.org/deathDate>",
1755 "<http://prismstandard.org/namespaces/basic/2.0/publicationDate>",
1756 "<http://prismstandard.org/namespaces/basic/2.0/coverDate>",
1757 "<http://www.w3.org/ns/prov#generatedAtTime>",
1758 "<http://www.w3.org/ns/prov#invalidatedAtTime>",
1759 "<http://www.w3.org/ns/prov#startedAtTime>",
1760 "<http://www.w3.org/ns/prov#endedAtTime>",
1761 "<http://www.w3.org/ns/prov#atTime>",
1762 "<http://www.w3.org/2006/time#inXSDDate>",
1763 "<http://www.w3.org/2006/time#inXSDDateTime>",
1764 "<http://www.w3.org/2006/time#inXSDDateTimeStamp>",
1765 "<http://www.w3.org/2006/time#inXSDgYear>",
1766 "<http://www.w3.org/2006/time#inXSDgYearMonth>",
1767 "<http://dbpedia.org/ontology/birthDate>",
1768 "<http://dbpedia.org/ontology/deathDate>",
1769 "<http://dbpedia.org/ontology/releaseDate>",
1770 "<http://www.wikidata.org/prop/direct/P569>",
1771 "<http://www.wikidata.org/prop/direct/P570>",
1772 "<http://www.wikidata.org/prop/direct/P571>",
1773 "<http://www.wikidata.org/prop/direct/P577>",
1774 "<http://www.wikidata.org/prop/direct/P580>",
1775 "<http://www.wikidata.org/prop/direct/P582>",
1776 "<http://www.wikidata.org/prop/direct/P585>",
1777];
1778
1779fn is_temporal(p: &str, lit: &Literal) -> bool {
1793 if is_temporal_datatype(&lit.datatype) {
1794 return date_shaped(&lit.value);
1795 }
1796 DATE_PREDICATES.contains(&p) && date_shaped(&lit.value)
1797}
1798
1799fn date_shaped(v: &str) -> bool {
1807 let b = v.as_bytes();
1808 let digits =
1809 |at: usize, n: usize| b.len() >= at + n && b[at..at + n].iter().all(u8::is_ascii_digit);
1810 let two_in = |at: usize, lo: u8, hi: u8| {
1811 digits(at, 2) && {
1812 let n = (b[at] - b'0') * 10 + (b[at + 1] - b'0');
1813 (lo..=hi).contains(&n)
1814 }
1815 };
1816 let mut i = usize::from(b.first() == Some(&b'-'));
1817 if !digits(i, 4) {
1818 return false;
1819 }
1820 i += 4;
1821 if b.get(i) == Some(&b'-') {
1822 if !two_in(i + 1, 1, 12) {
1823 return false;
1824 }
1825 i += 3;
1826 if b.get(i) == Some(&b'-') {
1827 if !two_in(i + 1, 1, 31) {
1828 return false;
1829 }
1830 i += 3;
1831 if b.get(i) == Some(&b'T') {
1832 if !(two_in(i + 1, 0, 24) && b.get(i + 3) == Some(&b':') && two_in(i + 4, 0, 59)) {
1833 return false;
1834 }
1835 i += 6;
1836 if b.get(i) == Some(&b':') {
1837 if !two_in(i + 1, 0, 60) {
1838 return false;
1839 }
1840 i += 3;
1841 if b.get(i) == Some(&b'.') {
1842 let start = i + 1;
1843 i = start;
1844 while b.get(i).is_some_and(u8::is_ascii_digit) {
1845 i += 1;
1846 }
1847 if i == start {
1848 return false;
1849 }
1850 }
1851 }
1852 }
1853 }
1854 }
1855 match &b[i..] {
1856 [] | [b'Z'] => true,
1857 [sign, h1, h2, b':', m1, m2] => {
1858 (*sign == b'+' || *sign == b'-') && [h1, h2, m1, m2].iter().all(|c| c.is_ascii_digit())
1859 }
1860 _ => false,
1861 }
1862}
1863
1864fn is_numeric_datatype(dt: &str) -> bool {
1866 matches!(
1867 dt,
1868 "http://www.w3.org/2001/XMLSchema#integer"
1869 | "http://www.w3.org/2001/XMLSchema#decimal"
1870 | "http://www.w3.org/2001/XMLSchema#double"
1871 | "http://www.w3.org/2001/XMLSchema#float"
1872 | "http://www.w3.org/2001/XMLSchema#long"
1873 | "http://www.w3.org/2001/XMLSchema#int"
1874 | "http://www.w3.org/2001/XMLSchema#short"
1875 | "http://www.w3.org/2001/XMLSchema#nonNegativeInteger"
1876 | "http://www.w3.org/2001/XMLSchema#positiveInteger"
1877 )
1878}
1879
1880fn temporal_key(v: &str) -> (i64, &str) {
1885 let (neg, rest) = match v.strip_prefix('-') {
1886 Some(r) => (true, r),
1887 None => (false, v),
1888 };
1889 let n = rest.bytes().take_while(u8::is_ascii_digit).count();
1890 let year: i64 = rest[..n].parse().unwrap_or(0);
1891 (if neg { -year } else { year }, &rest[n..])
1892}
1893
1894fn update_time_extent(pred: &str, value: &str, extent: &mut BTreeMap<String, (String, String)>) {
1900 match extent.get_mut(pred) {
1901 None => {
1902 extent.insert(pred.to_string(), (value.to_string(), value.to_string()));
1903 }
1904 Some((lo, hi)) => {
1905 if temporal_key(value) < temporal_key(lo) {
1906 *lo = value.to_string();
1907 }
1908 if temporal_key(value) > temporal_key(hi) {
1909 *hi = value.to_string();
1910 }
1911 }
1912 }
1913}
1914
1915#[derive(Debug, Clone, Default)]
1943pub struct StreamReport {
1944 pub statements: u64,
1945 pub subjects: u64,
1946 pub literal_objects: u64,
1947 pub classes: u64,
1948 pub class_map_bytes: u64,
1949}
1950
1951enum ClassMap {
1956 U8(Vec<u8>),
1957 U16(Vec<u16>),
1958 U32(Vec<u32>),
1959}
1960
1961impl ClassMap {
1962 fn new(subjects: u64) -> Self {
1963 ClassMap::U8(vec![0; subjects as usize + 1])
1966 }
1967
1968 fn get(&self, id: u32) -> u32 {
1969 let i = id as usize;
1970 match self {
1971 ClassMap::U8(v) => v.get(i).map_or(0, |&c| c as u32),
1972 ClassMap::U16(v) => v.get(i).map_or(0, |&c| c as u32),
1973 ClassMap::U32(v) => v.get(i).copied().unwrap_or(0),
1974 }
1975 }
1976
1977 fn set(&mut self, id: u32, code: u32) {
1978 if code > u8::MAX as u32 {
1979 if let ClassMap::U8(v) = self {
1980 *self = ClassMap::U16(v.iter().map(|&c| c as u16).collect());
1981 }
1982 }
1983 if code > u16::MAX as u32 {
1984 if let ClassMap::U16(v) = self {
1985 *self = ClassMap::U32(v.iter().map(|&c| c as u32).collect());
1986 }
1987 }
1988 let i = id as usize;
1989 match self {
1990 ClassMap::U8(v) => {
1991 if let Some(slot) = v.get_mut(i) {
1992 *slot = code as u8;
1993 }
1994 }
1995 ClassMap::U16(v) => {
1996 if let Some(slot) = v.get_mut(i) {
1997 *slot = code as u16;
1998 }
1999 }
2000 ClassMap::U32(v) => {
2001 if let Some(slot) = v.get_mut(i) {
2002 *slot = code;
2003 }
2004 }
2005 }
2006 }
2007
2008 fn bytes(&self) -> u64 {
2009 match self {
2010 ClassMap::U8(v) => v.len() as u64,
2011 ClassMap::U16(v) => v.len() as u64 * 2,
2012 ClassMap::U32(v) => v.len() as u64 * 4,
2013 }
2014 }
2015}
2016
2017struct TopN {
2023 heap: std::collections::BinaryHeap<std::cmp::Reverse<(u64, std::cmp::Reverse<u32>)>>,
2024 offered: u64,
2025}
2026
2027impl TopN {
2028 fn new() -> Self {
2029 TopN {
2030 heap: std::collections::BinaryHeap::with_capacity(CARD_TOP_N + 1),
2031 offered: 0,
2032 }
2033 }
2034
2035 fn offer(&mut self, id: u32, n: u64) {
2036 use std::cmp::Reverse;
2037 self.offered += 1;
2038 let key = Reverse((n, Reverse(id)));
2039 if self.heap.len() < CARD_TOP_N {
2040 self.heap.push(key);
2041 } else if let Some(worst) = self.heap.peek() {
2042 if (n, Reverse(id)) > worst.0 {
2044 self.heap.pop();
2045 self.heap.push(key);
2046 }
2047 }
2048 }
2049
2050 fn into_parts(self) -> (Vec<(u32, u64)>, bool) {
2052 let cut = self.offered > CARD_TOP_N as u64;
2053 let v = self
2054 .heap
2055 .into_iter()
2056 .map(|std::cmp::Reverse((n, std::cmp::Reverse(id)))| (id, n))
2057 .collect();
2058 (v, cut)
2059 }
2060}
2061
2062fn top_terms(
2066 tops: [TopN; 2],
2067 terms: &dyn Fn(&[u32]) -> Vec<Option<String>>,
2068) -> (Vec<(String, u64)>, bool) {
2069 let mut offered = 0u64;
2070 let mut ids: Vec<(u32, u64)> = Vec::new();
2071 for top in tops {
2072 offered += top.offered;
2073 ids.extend(top.into_parts().0);
2074 }
2075 let names = terms(&ids.iter().map(|&(id, _)| id).collect::<Vec<_>>());
2076 let mut v: Vec<(String, u64)> = ids
2077 .iter()
2078 .zip(names)
2079 .filter_map(|(&(_, n), t)| t.map(|t| (t, n)))
2080 .collect();
2081 v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
2082 v.truncate(CARD_TOP_N);
2083 (v, offered > CARD_TOP_N as u64)
2084}
2085
2086#[derive(Default, Clone, Copy)]
2089struct IdHasher(u64);
2090
2091impl std::hash::Hasher for IdHasher {
2092 fn finish(&self) -> u64 {
2093 self.0
2094 }
2095 fn write(&mut self, bytes: &[u8]) {
2096 for &b in bytes {
2097 self.0 = (self.0.rotate_left(5) ^ b as u64).wrapping_mul(0x517c_c1b7_2722_0a95);
2098 }
2099 }
2100 fn write_u32(&mut self, n: u32) {
2101 self.0 = (self.0.rotate_left(5) ^ n as u64).wrapping_mul(0x517c_c1b7_2722_0a95);
2102 }
2103}
2104
2105type IdBuild = std::hash::BuildHasherDefault<IdHasher>;
2106
2107fn first_at_or_above(lo: u32, hi: u32, key: &str, term: &dyn Fn(u32) -> Option<String>) -> u32 {
2111 let (mut a, mut b) = (lo as u64, hi as u64 + 1);
2112 while a < b {
2113 let mid = a + (b - a) / 2;
2114 match term(mid as u32) {
2115 Some(t) if t.as_str() < key => a = mid + 1,
2116 _ => b = mid,
2117 }
2118 }
2119 a as u32
2120}
2121
2122fn id_range(
2127 lo: u32,
2128 hi: u32,
2129 from: &str,
2130 to: &str,
2131 term: &dyn Fn(u32) -> Option<String>,
2132) -> (u32, u32) {
2133 if hi < lo {
2134 return (lo, lo);
2135 }
2136 (
2137 first_at_or_above(lo, hi, from, term),
2138 first_at_or_above(lo, hi, to, term),
2139 )
2140}
2141
2142fn in_any(id: u32, ranges: &[(u32, u32)]) -> bool {
2143 ranges.iter().any(|&(a, b)| a <= id && id < b)
2144}
2145
2146pub fn derive_card_streaming(
2165 rete: &crate::Rete,
2166 curated: CardInput,
2167 progress: &mut dyn FnMut(&str),
2168) -> Result<(DatasetCard, StreamReport), String> {
2169 use crate::index::IndexPermutation;
2170
2171 let header = rete.header();
2172 let dict = rete.dictionary();
2173 let index = rete.default_index();
2174 let shared = dict.shared_count();
2175 let subjects_end = shared as u64 + dict.subject_only_count() as u64;
2176 let objects_end = shared as u64 + dict.object_only_count() as u64;
2177 let n_preds = dict.predicate_count() as usize;
2178 let pred_terms: Vec<String> = (0..=n_preds as u32)
2179 .map(|p| {
2180 if p == 0 {
2181 String::new()
2182 } else {
2183 dict.predicate_term(p).unwrap_or_default()
2184 }
2185 })
2186 .collect();
2187 let type_id = dict.predicate_id(RDF_TYPE);
2188
2189 let object_term = |id: u32| dict.object_term(id);
2191 let subject_term = |id: u32| dict.subject_term(id);
2192 let s_end = subjects_end.min(u32::MAX as u64) as u32;
2193 let o_end = objects_end.min(u32::MAX as u64) as u32;
2194 let literal_ranges = [
2195 id_range(1, shared, "\"", "#", &object_term),
2196 id_range(shared + 1, o_end, "\"", "#", &object_term),
2197 ];
2198 let is_literal = |o: u32| in_any(o, &literal_ranges);
2199 let quoted = header.has_quoted_triples();
2200 let (quoted_subjects, quoted_objects) = if quoted {
2201 (
2202 vec![
2203 id_range(1, shared, "<<", "<=", &subject_term),
2204 id_range(shared + 1, s_end, "<<", "<=", &subject_term),
2205 ],
2206 vec![
2207 id_range(1, shared, "<<", "<=", &object_term),
2208 id_range(shared + 1, o_end, "<<", "<=", &object_term),
2209 ],
2210 )
2211 } else {
2212 (Vec::new(), Vec::new())
2213 };
2214
2215 let mut report = StreamReport::default();
2216
2217 progress("pass 1/3: classes (the rdf:type range of POS)");
2219 let mut class_map = ClassMap::new(subjects_end);
2220 let mut class_code: std::collections::HashMap<u32, u32, IdBuild> = Default::default();
2221 let mut class_ids: Vec<u32> = vec![0];
2222 let mut class_counts: Vec<u64> = vec![0];
2223 if let Some(t) = type_id {
2224 for (s, _, o) in index.scan_iter((None, Some(t), None)) {
2225 let next = class_ids.len() as u32;
2226 let code = *class_code.entry(o).or_insert_with(|| {
2227 class_ids.push(o);
2228 class_counts.push(0);
2229 next
2230 });
2231 class_counts[code as usize] += 1;
2232 class_map.set(s, code);
2233 }
2234 }
2235 report.classes = class_ids.len() as u64 - 1;
2236 report.class_map_bytes = class_map.bytes();
2237
2238 progress("pass 2/3: subjects (SPO)");
2240 const LITERAL_CODE: u32 = u32::MAX;
2241 const WINDOW: usize = 1 << 16;
2242 let mut prof = Profile::default();
2243 let mut pred_counts = vec![0u64; n_preds + 1];
2244 let mut annotation_hits = vec![0u64; n_preds + 1];
2245 let mut quoting_hits = vec![0u64; n_preds + 1];
2246 let mut links: std::collections::HashMap<(u32, u32, u32), u64, IdBuild> = Default::default();
2247 let mut out_top = [TopN::new(), TopN::new()];
2249 let mut sample: Vec<(u32, u32)> = Vec::new();
2250 let mut window: Vec<(u32, u64)> = Vec::with_capacity(WINDOW);
2251 let mut subject_ns: BTreeMap<String, u64> = BTreeMap::new();
2252 let flush_ns = |window: &mut Vec<(u32, u64)>, subject_ns: &mut BTreeMap<String, u64>| {
2253 let ids: Vec<u32> = window.iter().map(|&(s, _)| s).collect();
2254 let terms = dict.subject_terms(&ids);
2255 let mut run: Option<(String, u64)> = None;
2258 for (term, &(_, n)) in terms.iter().zip(window.iter()) {
2259 let Some(ns) = term.as_deref().and_then(split_namespace) else {
2260 continue;
2261 };
2262 match &mut run {
2263 Some((cur, total)) if *cur == ns => *total += n,
2264 _ => {
2265 if let Some((cur, total)) = run.take() {
2266 *subject_ns.entry(cur).or_default() += total;
2267 }
2268 run = Some((ns, n));
2269 }
2270 }
2271 }
2272 if let Some((cur, total)) = run {
2273 *subject_ns.entry(cur).or_default() += total;
2274 }
2275 window.clear();
2276 };
2277 let mut cur_s = 0u32;
2278 let mut cur_n = 0u64;
2279 let mut ticker = crate::build_progress::Ticker::new();
2281 let stored = index.for_each_in_order(IndexPermutation::Spo, &mut |s, p, o| {
2282 prof.triple_count += 1;
2283 ticker.tick(1);
2284 if let Some(c) = pred_counts.get_mut(p as usize) {
2285 *c += 1;
2286 }
2287 if s != cur_s {
2288 if cur_n > 0 {
2289 out_top[usize::from(cur_s > shared)].offer(cur_s, cur_n);
2290 window.push((cur_s, cur_n));
2291 if window.len() >= WINDOW {
2292 flush_ns(&mut window, &mut subject_ns);
2293 }
2294 }
2295 if quoted && in_any(s, "ed_subjects) && sample.len() < ANNOTATION_WITNESS_SAMPLE {
2296 sample.push((s, p));
2297 }
2298 cur_s = s;
2299 cur_n = 0;
2300 }
2301 cur_n += 1;
2302 if quoted {
2303 if in_any(s, "ed_subjects) {
2304 if let Some(c) = annotation_hits.get_mut(p as usize) {
2305 *c += 1;
2306 }
2307 }
2308 if in_any(o, "ed_objects) {
2309 if let Some(c) = quoting_hits.get_mut(p as usize) {
2310 *c += 1;
2311 }
2312 }
2313 }
2314 if Some(p) == type_id {
2315 return;
2316 }
2317 let sc = class_map.get(s);
2318 let oc = if is_literal(o) {
2321 LITERAL_CODE
2322 } else if o <= shared {
2323 class_map.get(o)
2324 } else {
2325 0
2326 };
2327 *links.entry((sc, p, oc)).or_default() += 1;
2328 });
2329 if !stored {
2330 return Err("the file stores no SPO permutation".to_string());
2331 }
2332 if cur_n > 0 {
2333 out_top[usize::from(cur_s > shared)].offer(cur_s, cur_n);
2334 window.push((cur_s, cur_n));
2335 }
2336 if !window.is_empty() {
2337 flush_ns(&mut window, &mut subject_ns);
2338 }
2339 report.statements = prof.triple_count;
2340 report.subjects = out_top[0].offered + out_top[1].offered;
2341 drop(class_map);
2342
2343 progress("pass 3/3: objects (OSP)");
2345 let mut in_top = [TopN::new(), TopN::new()];
2346 let mut lit_window: Vec<(u32, Vec<(u32, u64)>)> = Vec::with_capacity(WINDOW);
2347 let mut lit_objects = 0u64;
2348 let flush_lits = |lit_window: &mut Vec<(u32, Vec<(u32, u64)>)>, prof: &mut Profile| {
2349 let ids: Vec<u32> = lit_window.iter().map(|(o, _)| *o).collect();
2350 let terms = dict.object_terms(&ids);
2351 for (term, (_, preds)) in terms.iter().zip(lit_window.iter()) {
2352 let Some(lit) = term.as_deref().and_then(parse_literal) else {
2353 continue;
2354 };
2355 for &(p, n) in preds {
2356 if let Some(pt) = pred_terms.get(p as usize) {
2357 prof.literal(pt, &lit, n);
2358 }
2359 }
2360 }
2361 lit_window.clear();
2362 };
2363 let mut cur_o = 0u32;
2364 let mut cur_n = 0u64;
2365 let mut cur_preds: Vec<(u32, u64)> = Vec::new();
2366 index.for_each_in_order(IndexPermutation::Osp, &mut |o, _s, p| {
2367 ticker.tick(1);
2368 if o != cur_o {
2369 if cur_n > 0 {
2370 if is_literal(cur_o) {
2371 lit_objects += 1;
2372 lit_window.push((cur_o, std::mem::take(&mut cur_preds)));
2373 if lit_window.len() >= WINDOW {
2374 flush_lits(&mut lit_window, &mut prof);
2375 }
2376 } else {
2377 in_top[usize::from(cur_o > shared)].offer(cur_o, cur_n);
2378 }
2379 }
2380 cur_o = o;
2381 cur_n = 0;
2382 cur_preds.clear();
2383 }
2384 cur_n += 1;
2385 if is_literal(o) {
2386 match cur_preds.iter_mut().find(|(q, _)| *q == p) {
2387 Some((_, n)) => *n += 1,
2388 None => cur_preds.push((p, 1)),
2389 }
2390 }
2391 });
2392 if cur_n > 0 {
2393 if is_literal(cur_o) {
2394 lit_objects += 1;
2395 lit_window.push((cur_o, std::mem::take(&mut cur_preds)));
2396 } else {
2397 in_top[usize::from(cur_o > shared)].offer(cur_o, cur_n);
2398 }
2399 }
2400 if !lit_window.is_empty() {
2401 flush_lits(&mut lit_window, &mut prof);
2402 }
2403 report.literal_objects = lit_objects;
2404
2405 if rete.index_incomplete() {
2406 return Err("a read failed part-way through the file; the card would be incomplete".into());
2407 }
2408
2409 progress("resolving the profile's terms");
2411 let class_terms: Vec<String> = class_ids
2412 .iter()
2413 .map(|&o| {
2414 if o == 0 {
2415 O_UNTYPED.to_string()
2416 } else {
2417 dict.object_term(o).unwrap_or_default()
2418 }
2419 })
2420 .collect();
2421 let class_name = |code: u32| -> String {
2422 if code == LITERAL_CODE {
2423 O_LITERAL.to_string()
2424 } else {
2425 class_terms
2426 .get(code as usize)
2427 .cloned()
2428 .unwrap_or_else(|| O_UNTYPED.to_string())
2429 }
2430 };
2431 for (p, &n) in pred_counts.iter().enumerate() {
2432 if n > 0 {
2433 prof.pred_counts.insert(pred_terms[p].clone(), n);
2434 }
2435 }
2436 for (code, &n) in class_counts.iter().enumerate().skip(1) {
2437 if n > 0 {
2438 *prof
2439 .class_counts
2440 .entry(class_terms[code].clone())
2441 .or_default() += n;
2442 }
2443 }
2444 for ((sc, p, oc), n) in links {
2445 let Some(pt) = pred_terms.get(p as usize) else {
2446 continue;
2447 };
2448 *prof
2449 .link_rows
2450 .entry((class_name(sc), pt.clone(), class_name(oc)))
2451 .or_default() += n;
2452 }
2453 for (p, &n) in annotation_hits.iter().enumerate() {
2454 if n > 0 {
2455 prof.annotation_hits.insert(pred_terms[p].clone(), n);
2456 }
2457 }
2458 for (p, &n) in quoting_hits.iter().enumerate() {
2459 if n > 0 {
2460 prof.quoting_hits.insert(pred_terms[p].clone(), n);
2461 }
2462 }
2463 prof.subject_ns = subject_ns;
2464 if pred_counts
2465 .iter()
2466 .enumerate()
2467 .any(|(p, &n)| n > 0 && (pred_terms[p] == GEO_ASWKT || pred_terms[p] == GEO_HASGEOMETRY))
2468 {
2469 prof.geo_wkt = true;
2470 }
2471
2472 let sample_ids: Vec<u32> = sample.iter().map(|&(s, _)| s).collect();
2475 for (term, &(_, about)) in dict.subject_terms(&sample_ids).iter().zip(sample.iter()) {
2476 let Some((a, qp, b)) = term.as_deref().and_then(crate::ingest::quoted_triple_parts) else {
2477 continue;
2478 };
2479 if let Some((sid, pid, oid)) = dict.encode(&a, &qp, &b) {
2480 if index
2481 .scan_iter((Some(sid), Some(pid), Some(oid)))
2482 .next()
2483 .is_some()
2484 {
2485 if let Some(about) = pred_terms.get(about as usize) {
2486 *prof.witnessed_pairs.entry((qp, about.clone())).or_default() += 1;
2487 }
2488 }
2489 }
2490 }
2491
2492 let (top_hubs, cut_out) = top_terms(out_top, &|ids| dict.subject_terms(ids));
2493 let (in_hubs, cut_in) = top_terms(in_top, &|ids| dict.object_terms(ids));
2494
2495 let card = finish(
2496 prof,
2497 Hubs {
2498 top_hubs,
2499 in_hubs,
2500 truncated: cut_out || cut_in,
2501 },
2502 header.quad_count,
2503 header.term_count,
2504 rete.named_graph_count() as u64,
2505 curated,
2506 );
2507 Ok((card, report))
2508}
2509
2510fn split_namespace(term: &str) -> Option<String> {
2514 let iri = term.strip_prefix('<')?.strip_suffix('>')?;
2515 let cut = iri.rfind(['#', '/'])?;
2516 Some(iri[..=cut].to_string())
2517}
2518
2519pub fn load_card(bytes: &[u8]) -> Result<Option<DatasetCard>, String> {
2526 let header = Header::from_bytes(bytes).map_err(|e| e.to_string())?;
2527 if header.metadata_len == 0 {
2528 return Ok(None);
2529 }
2530 let start = header.metadata_offset as usize;
2531 let end = start
2532 .checked_add(header.metadata_len as usize)
2533 .filter(|&e| e <= bytes.len())
2534 .ok_or_else(|| "metadata section out of bounds".to_string())?;
2535 Ok(Some(DatasetCard::from_json_bytes(&bytes[start..end])?))
2536}
2537
2538#[cfg(test)]
2539mod tests {
2540 use super::*;
2541 use crate::card_input::{CARD_EXTRA_MAX_BYTES, CARD_EXTRA_MAX_KEYS, CARD_EXTRA_MAX_KEY_BYTES};
2544
2545 const TYPE: &str = RDF_TYPE;
2546
2547 fn q(s: &str, p: &str, o: &str) -> (String, String, String, Option<String>) {
2548 (s.into(), p.into(), o.into(), None)
2549 }
2550
2551 #[test]
2552 fn card_json_round_trips() {
2553 let card = DatasetCard {
2554 title: Some("Demo".into()),
2555 license: Some("CC0".into()),
2556 triple_count: 3,
2557 quad_count: 3,
2558 term_count: 5,
2559 predicates: vec![("<http://ex/p>".into(), 2)],
2560 vocabularies: vec!["http://ex/".into()],
2561 format_version: 1,
2562 ..Default::default()
2563 };
2564 let bytes = card.to_json_bytes();
2565 let back = DatasetCard::from_json_bytes(&bytes).unwrap();
2566 assert_eq!(card, back);
2567 let text = String::from_utf8(bytes).unwrap();
2569 assert!(!text.contains("description"));
2570 assert!(!text.contains("classes"));
2571 }
2572
2573 #[test]
2579 fn the_permutation_signal_is_stripped_from_the_stored_card() {
2580 let measured = PermutationsSignal {
2581 count: 3,
2582 names: vec!["SPO".into(), "POS".into(), "OSP".into()],
2583 merge_join: false,
2584 };
2585 let mut card = DatasetCard {
2586 title: Some("Lean".into()),
2587 triple_count: 3,
2588 ..Default::default()
2589 };
2590 assert_eq!(card.observe_permutations(measured.clone()), None);
2591 assert_eq!(card.signals.permutations, Some(measured.clone()));
2592
2593 let bytes = card.to_json_bytes();
2594 let text = String::from_utf8(bytes.clone()).unwrap();
2595 assert!(
2596 !text.contains("permutations"),
2597 "the signal must not reach the metadata section: {text}"
2598 );
2599 let back = DatasetCard::from_json_bytes(&bytes).unwrap();
2600 assert_eq!(back.signals.permutations, None, "unknown, not `six`");
2601 assert_eq!(back.title, card.title);
2602
2603 let authored = r#"{"title":"x","signals":{"permutations":{"count":6}}}"#;
2606 assert!(
2607 serde_json::from_str::<CardInput>(authored).is_err(),
2608 "a hand-written card must not be able to assert a permutation set"
2609 );
2610 }
2611
2612 #[test]
2616 fn the_permutation_signal_is_derived_from_the_header() {
2617 let quads = vec![
2618 q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>"),
2619 q("<http://ex/b>", "<http://ex/p>", "<http://ex/c>"),
2620 ];
2621 let (six, _) = crate::ingest::assemble_dataset_with_perms(
2622 quads.clone(),
2623 false,
2624 false,
2625 None,
2626 crate::PyramidAlgo::Louvain,
2627 crate::PermSet::ALL,
2628 |_, _| Vec::new(),
2629 );
2630 let (three, _) = crate::ingest::assemble_dataset_with_perms(
2631 quads,
2632 false,
2633 false,
2634 None,
2635 crate::PyramidAlgo::Louvain,
2636 crate::PermSet::CORE,
2637 |_, _| Vec::new(),
2638 );
2639 assert_eq!(six[50], 0);
2642 assert_eq!(three[50], 0b0000_0111);
2643
2644 let h6 = crate::Header::from_bytes(&six).unwrap();
2645 let h3 = crate::Header::from_bytes(&three).unwrap();
2646 let s6 = PermutationsSignal::probe(&h6);
2647 let s3 = PermutationsSignal::probe(&h3);
2648 assert_eq!(s6.count, 6);
2649 assert!(s6.merge_join);
2650 assert_eq!(s3.count, 3);
2651 assert!(!s3.merge_join);
2652 assert_eq!(s3.names, vec!["SPO", "POS", "OSP"]);
2653 assert!(s3.describe().contains("no sort-merge join"));
2654 assert!(three.len() < six.len(), "{} vs {}", three.len(), six.len());
2656 assert_eq!(h3.dictionary_len, h6.dictionary_len);
2657 assert!(h3.root_dir_len < h6.root_dir_len);
2658 }
2659
2660 #[test]
2665 fn the_text_index_signal_is_stripped_from_the_stored_card() {
2666 let measured = TextIndexSignal {
2668 present: true,
2669 bytes: 1_879_287_762,
2670 token_table_bytes: Some(193_295_361),
2671 };
2672 let mut card = DatasetCard {
2673 title: Some("Indexed".into()),
2674 triple_count: 3,
2675 ..Default::default()
2676 };
2677 assert_eq!(card.observe_text_index(measured), None);
2678 assert_eq!(card.signals.text_index, Some(measured));
2679
2680 let bytes = card.to_json_bytes();
2681 let text = String::from_utf8(bytes.clone()).unwrap();
2682 assert!(
2683 !text.contains("text_index"),
2684 "the signal must not reach the metadata section: {text}"
2685 );
2686 let back = DatasetCard::from_json_bytes(&bytes).unwrap();
2689 assert_eq!(back.title, card.title);
2690 assert_eq!(back.signals.text_index, None);
2691
2692 assert_eq!(card.signals.text_index, Some(measured));
2695 }
2696
2697 #[test]
2703 fn the_text_index_signal_round_trips_through_a_card_document() {
2704 let mut card = DatasetCard {
2705 title: Some("Indexed".into()),
2706 ..Default::default()
2707 };
2708 card.observe_text_index(TextIndexSignal {
2709 present: true,
2710 bytes: 80,
2711 token_table_bytes: Some(68),
2712 });
2713 let doc = serde_json::to_vec(&card).unwrap();
2714 let back: DatasetCard = serde_json::from_slice(&doc).unwrap();
2715 assert_eq!(back.signals.text_index, card.signals.text_index);
2716
2717 let mut none = DatasetCard::default();
2721 none.observe_text_index(TextIndexSignal::default());
2722 let text = serde_json::to_string(&none).unwrap();
2723 assert!(text.contains(r#""text_index":{"present":false}"#), "{text}");
2724 let absent = serde_json::to_string(&DatasetCard::default()).unwrap();
2725 assert!(!absent.contains("text_index"), "{absent}");
2726 }
2727
2728 #[test]
2735 fn the_quoted_triple_signal_is_derived_from_the_header() {
2736 let plain = vec![
2737 q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>"),
2738 q("<http://ex/b>", "<http://ex/p>", "<http://ex/c>"),
2739 ];
2740 let quoted = vec![
2741 q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>"),
2742 q(
2743 "<http://ex/claim>",
2744 "<http://ex/states>",
2745 "<<<http://ex/a> <http://ex/p> <http://ex/b>>>",
2746 ),
2747 ];
2748 let (no, _) = crate::ingest::assemble_dataset(plain, &[]);
2749 let (yes, _) = crate::ingest::assemble_dataset(quoted, &[]);
2750
2751 let hn = crate::Header::from_bytes(&no).unwrap();
2752 let hy = crate::Header::from_bytes(&yes).unwrap();
2753 assert!(!hn.has_quoted_triples());
2754 assert!(hy.has_quoted_triples());
2755
2756 let sn = QuotedTriplesSignal::probe(&hn);
2757 let sy = QuotedTriplesSignal::probe(&hy);
2758 assert!(!sn.present);
2759 assert!(sy.present);
2760
2761 assert_eq!(sn.describe(), None);
2764 let line = sy.describe().expect("a file with quoted triples says so");
2768 assert!(line.contains("<<( s p o )>>"), "{line}");
2769 assert!(line.contains(QUOTED_TRIPLE_SURFACE_RDF_STAR), "{line}");
2770 assert_eq!(
2771 sy.export_surfaces,
2772 vec![QUOTED_TRIPLE_SURFACE_RDF12, QUOTED_TRIPLE_SURFACE_RDF_STAR]
2773 );
2774 assert_eq!(
2775 sy.export_default.as_deref(),
2776 Some(QUOTED_TRIPLE_SURFACE_RDF12)
2777 );
2778 assert!(sn.export_surfaces.is_empty());
2780 assert_eq!(sn.export_default, None);
2781 }
2782
2783 #[test]
2788 fn the_quoted_triple_signal_is_stripped_from_the_stored_card() {
2789 let measured = QuotedTriplesSignal {
2790 present: true,
2791 export_surfaces: vec![
2792 QUOTED_TRIPLE_SURFACE_RDF12.to_string(),
2793 QUOTED_TRIPLE_SURFACE_RDF_STAR.to_string(),
2794 ],
2795 export_default: Some(QUOTED_TRIPLE_SURFACE_RDF12.to_string()),
2796 };
2797 let mut card = DatasetCard {
2798 title: Some("Annotated".into()),
2799 triple_count: 3,
2800 ..Default::default()
2801 };
2802 assert_eq!(card.observe_quoted_triples(measured.clone()), None);
2803 assert_eq!(card.signals.quoted_triples, Some(measured.clone()));
2804
2805 let bytes = card.to_json_bytes();
2806 let text = String::from_utf8(bytes.clone()).unwrap();
2807 assert!(
2808 !text.contains("quoted_triples"),
2809 "the signal must not reach the metadata section: {text}"
2810 );
2811 let back = DatasetCard::from_json_bytes(&bytes).unwrap();
2812 assert_eq!(back.title, card.title);
2813 assert_eq!(back.signals.quoted_triples, None);
2814 assert_eq!(card.signals.quoted_triples, Some(measured));
2816
2817 let authored = r#"{"title":"x","signals":{"quoted_triples":{"present":true}}}"#;
2820 assert!(
2821 serde_json::from_str::<CardInput>(authored).is_err(),
2822 "a hand-written card must not be able to assert a quoted-triple set"
2823 );
2824 }
2825
2826 #[test]
2831 fn the_quoted_triple_signal_round_trips_through_a_card_document() {
2832 let mut card = DatasetCard {
2833 title: Some("Annotated".into()),
2834 ..Default::default()
2835 };
2836 card.observe_quoted_triples(QuotedTriplesSignal {
2837 present: true,
2838 export_surfaces: vec![QUOTED_TRIPLE_SURFACE_RDF12.to_string()],
2839 export_default: Some(QUOTED_TRIPLE_SURFACE_RDF12.to_string()),
2840 });
2841 let doc = serde_json::to_vec(&card).unwrap();
2842 let back: DatasetCard = serde_json::from_slice(&doc).unwrap();
2843 assert_eq!(back.signals.quoted_triples, card.signals.quoted_triples);
2844
2845 let mut none = DatasetCard::default();
2846 none.observe_quoted_triples(QuotedTriplesSignal {
2847 present: false,
2848 export_surfaces: Vec::new(),
2849 export_default: None,
2850 });
2851 let text = serde_json::to_string(&none).unwrap();
2852 assert!(
2853 text.contains(r#""quoted_triples":{"present":false}"#),
2854 "{text}"
2855 );
2856 let absent = serde_json::to_string(&DatasetCard::default()).unwrap();
2857 assert!(!absent.contains("quoted_triples"), "{absent}");
2858 }
2859 #[test]
2860 fn split_namespace_handles_hash_slash_and_neither() {
2861 assert_eq!(
2862 split_namespace("<http://xmlns.com/foaf/0.1/name>").as_deref(),
2863 Some("http://xmlns.com/foaf/0.1/")
2864 );
2865 assert_eq!(
2866 split_namespace("<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>").as_deref(),
2867 Some("http://www.w3.org/1999/02/22-rdf-syntax-ns#")
2868 );
2869 assert_eq!(split_namespace("<urn:isbn:12345>"), None);
2871 assert_eq!(split_namespace("\"literal\""), None);
2872 assert_eq!(split_namespace("_:b0"), None);
2873 }
2874
2875 #[test]
2876 fn derive_counts_and_orders() {
2877 let quads = vec![
2878 q("<http://ex/a>", TYPE, "<http://ex/Person>"),
2879 q("<http://ex/b>", TYPE, "<http://ex/Person>"),
2880 q("<http://ex/c>", TYPE, "<http://ex/City>"),
2881 q("<http://ex/a>", "<http://ex/knows>", "<http://ex/b>"),
2882 q("<http://ex/a>", "<http://ex/knows>", "<http://ex/c>"),
2883 q("<http://ex/a>", "<http://ex/name>", "\"A\""),
2884 ];
2885 let card = derive_card(&quads, 9, 0, CardInput::default());
2886 assert_eq!(card.triple_count, 6);
2887 assert_eq!(card.quad_count, 6);
2888 assert_eq!(card.term_count, 9);
2889 assert_eq!(card.predicates[0], (TYPE.to_string(), 3));
2891 assert!(card
2892 .predicates
2893 .contains(&("<http://ex/knows>".to_string(), 2)));
2894 assert_eq!(
2896 card.classes,
2897 vec![
2898 ("<http://ex/Person>".to_string(), 2),
2899 ("<http://ex/City>".to_string(), 1)
2900 ]
2901 );
2902 assert!(card.vocabularies.contains(&"http://ex/".to_string()));
2904 assert!(card
2905 .vocabularies
2906 .iter()
2907 .any(|v| v.ends_with("rdf-syntax-ns#")));
2908 }
2909
2910 #[test]
2911 fn named_graph_triples_excluded_from_default_stats() {
2912 let quads = vec![
2913 q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>"),
2914 (
2915 "<http://ex/c>".into(),
2916 "<http://ex/p>".into(),
2917 "<http://ex/d>".into(),
2918 Some("<http://ex/g1>".into()),
2919 ),
2920 ];
2921 let card = derive_card(&quads, 5, 1, CardInput::default());
2922 assert_eq!(card.triple_count, 1, "only the default-graph triple counts");
2923 assert_eq!(card.quad_count, 2);
2924 assert_eq!(card.named_graph_count, 1);
2925 assert_eq!(card.predicates[0].1, 1);
2926 }
2927
2928 const LABEL: &str = "<http://www.w3.org/2000/01/rdf-schema#label>";
2930 const SAMEAS: &str = "<http://www.w3.org/2002/07/owl#sameAs>";
2931 const XSD_INT: &str = "http://www.w3.org/2001/XMLSchema#integer";
2932 const XSD_GYEAR: &str = "http://www.w3.org/2001/XMLSchema#gYear";
2933
2934 fn enriched_fixture() -> Vec<(String, String, String, Option<String>)> {
2935 vec![
2936 q("<http://ex/a>", TYPE, "<http://ex/Person>"),
2937 q("<http://ex/b>", TYPE, "<http://ex/Person>"),
2938 q("<http://ex/c>", TYPE, "<http://ex/City>"),
2939 q("<http://ex/a>", LABEL, "\"Alice\"@en"),
2940 q("<http://ex/b>", LABEL, "\"Bob\"@en"),
2941 q(
2942 "<http://ex/a>",
2943 "<http://ex/age>",
2944 &format!("\"30\"^^<{XSD_INT}>"),
2945 ),
2946 q(
2947 "<http://ex/a>",
2948 "<http://ex/birthYear>",
2949 &format!("\"1850\"^^<{XSD_GYEAR}>"),
2950 ),
2951 q(
2952 "<http://ex/b>",
2953 "<http://ex/birthYear>",
2954 &format!("\"1875\"^^<{XSD_GYEAR}>"),
2955 ),
2956 q("<http://ex/a>", "<http://ex/knows>", "<http://ex/b>"),
2957 q("<http://ex/a>", "<http://ex/livesIn>", "<http://ex/c>"),
2958 q("<http://ex/a>", SAMEAS, "<http://other/a>"),
2959 ]
2960 }
2961
2962 #[test]
2963 fn enriched_profile_is_populated() {
2964 let quads = enriched_fixture();
2965 let card = derive_card(&quads, 12, 0, CardInput::default());
2966
2967 assert!(!card.datatypes.is_empty(), "datatypes derived");
2968 assert!(!card.languages.is_empty(), "languages derived");
2969 assert!(!card.class_links.is_empty(), "class_links derived");
2970 assert!(!card.top_hubs.is_empty(), "out-degree hubs derived");
2971 assert!(!card.in_hubs.is_empty(), "in-degree hubs derived");
2972
2973 assert!(card
2975 .class_links
2976 .iter()
2977 .any(|l| l.s_class == "<http://ex/Person>"
2978 && l.predicate == "<http://ex/knows>"
2979 && l.o_class == "<http://ex/Person>"
2980 && l.count == 1));
2981 assert!(card
2982 .class_links
2983 .iter()
2984 .any(|l| l.o_class == "<http://ex/City>"));
2985 assert!(card.class_links.iter().any(|l| l.o_class == "(untyped)"));
2987
2988 assert!(card.datatypes.iter().any(|(d, _)| d.contains("integer")));
2990 assert!(card.datatypes.iter().any(|(d, _)| d.contains("gYear")));
2991 assert!(card.datatypes.iter().any(|(d, _)| d.contains("langString")));
2992 assert!(card.languages.iter().any(|(l, _)| l == "en"));
2994
2995 let s = &card.signals;
2997 assert_eq!(s.label_predicate.as_deref(), Some(LABEL));
2998 assert_eq!(s.default_lang.as_deref(), Some("en"));
2999 assert_eq!(s.base_iri.as_deref(), Some("http://ex/"));
3000 assert!(s.link_predicates.iter().any(|p| p.contains("sameAs")));
3001 assert!(s.time_predicates.iter().any(|p| p.contains("birthYear")));
3002 assert_eq!(
3003 s.temporal_extent,
3004 Some(("1850".to_string(), "1875".to_string())),
3005 "extent over the dominant time predicate"
3006 );
3007 assert!(!s.geo_wkt && !s.geo_latlong);
3009 assert!(s.spatial_bbox.is_none());
3010 }
3011
3012 #[test]
3013 fn signals_detect_geometry() {
3014 let wkt = "http://www.opengis.net/ont/geosparql#wktLiteral";
3015 let quads = vec![
3016 q("<http://ex/p>", TYPE, "<http://ex/Place>"),
3017 q(
3018 "<http://ex/p>",
3019 "<http://www.w3.org/2003/01/geo/wgs84_pos#lat>",
3020 "\"41.9\"^^<http://www.w3.org/2001/XMLSchema#double>",
3021 ),
3022 q(
3023 "<http://ex/p>",
3024 "<http://www.w3.org/2003/01/geo/wgs84_pos#long>",
3025 "\"12.5\"^^<http://www.w3.org/2001/XMLSchema#double>",
3026 ),
3027 q(
3028 "<http://ex/p>",
3029 "<http://www.opengis.net/ont/geosparql#asWKT>",
3030 &format!("\"POINT(12.5 41.9)\"^^<{wkt}>"),
3031 ),
3032 ];
3033 let card = derive_card(&quads, 6, 0, CardInput::default());
3034 assert!(card.signals.geo_wkt, "asWKT/wktLiteral detected");
3035 assert!(card.signals.geo_latlong, "wgs84 lat+long detected");
3036 let bbox = card.signals.spatial_bbox.expect("bbox derived");
3037 assert_eq!(
3038 bbox,
3039 [12.5, 41.9, 12.5, 41.9],
3040 "[minLon,minLat,maxLon,maxLat]"
3041 );
3042 }
3043
3044 #[test]
3045 fn class_links_match_schema_summary() {
3046 use crate::{ingest, schema_summary, Rete};
3047 let quads = enriched_fixture();
3048 let card = derive_card(&quads, 12, 0, CardInput::default());
3049
3050 let (bytes, _) =
3052 ingest::assemble_dataset_with_opts(quads, true, false, None, |_, _| Vec::new());
3053 let rete = Rete::open(&bytes).unwrap();
3054 let reference: BTreeSet<(String, String, String, u64)> = schema_summary(&rete)
3055 .into_iter()
3056 .map(|(s, p, o, c)| (s, p, o, c as u64))
3057 .collect();
3058
3059 let derived: BTreeSet<(String, String, String, u64)> = card
3060 .class_links
3061 .iter()
3062 .map(|l| {
3063 (
3064 l.s_class.clone(),
3065 l.predicate.clone(),
3066 l.o_class.clone(),
3067 l.count,
3068 )
3069 })
3070 .collect();
3071
3072 assert_eq!(
3073 derived, reference,
3074 "card class_links must equal schema_summary's quotient row-for-row"
3075 );
3076 }
3077
3078 #[test]
3088 fn card_counts_are_the_deduplicated_counts() {
3089 use crate::ingest::{self, DeferredMetadata};
3090 use crate::Rete;
3091
3092 let g = |s: &str, p: &str, o: &str, graph: &str| {
3093 (
3094 s.to_string(),
3095 p.to_string(),
3096 o.to_string(),
3097 Some(graph.to_string()),
3098 )
3099 };
3100 let quads = vec![
3101 q("<http://ex/a>", "<http://ex/p>", "<http://ex/1>"),
3102 q("<http://ex/b>", "<http://ex/p>", "<http://ex/2>"),
3103 q("<http://ex/a>", "<http://ex/p>", "<http://ex/1>"), g("<http://ex/n>", "<http://ex/q>", "\"v\"", "<http://ex/g>"),
3105 g("<http://ex/n>", "<http://ex/q>", "\"v\"", "<http://ex/g>"), ];
3107 let (bytes, stats) =
3109 ingest::assemble_dataset_with_opts(quads, false, false, None, |stats, quads| {
3110 let card = derive_card(
3111 quads,
3112 stats.terms as u64,
3113 stats.named_graphs as u64,
3114 CardInput::default(),
3115 );
3116 DeferredMetadata::new(move |counts| card.with_final_counts(counts).to_json_bytes())
3117 });
3118
3119 let header = crate::Header::from_bytes(&bytes).unwrap();
3120 let card = load_card(&bytes).unwrap().expect("card embedded");
3121 assert_eq!(header.quad_count, 3, "header records the deduped quads");
3122 assert_eq!(
3123 card.quad_count, header.quad_count,
3124 "card and header must not disagree about the size of the same file"
3125 );
3126 assert_eq!(card.triple_count, 2, "default-graph triples, deduped");
3127
3128 let rete = Rete::open(&bytes).unwrap();
3130 let default_triples = rete.dump(None).len() as u64;
3131 let named: u64 = rete
3132 .graph_names()
3133 .iter()
3134 .map(|name| rete.dump(Some(name)).len() as u64)
3135 .sum();
3136 assert_eq!(default_triples + named, card.quad_count);
3137
3138 assert_eq!(stats.statements, 3);
3141 assert_eq!(stats.default_triples, 2);
3142 }
3143
3144 #[test]
3145 fn derive_is_deterministic() {
3146 let quads = enriched_fixture();
3149 let a = derive_card(&quads, 12, 0, CardInput::default()).to_json_bytes();
3150 let b = derive_card(&quads, 12, 0, CardInput::default()).to_json_bytes();
3151 assert_eq!(a, b, "enriched card derivation must be deterministic");
3152 assert!(!a.is_empty());
3153 }
3154
3155 #[test]
3156 fn old_plain_string_card_still_parses() {
3157 let json = r#"{
3160 "title":"Legacy",
3161 "triple_count":3,"quad_count":3,"named_graph_count":0,"term_count":5,
3162 "predicates":[["<http://ex/p>",2]],
3163 "example_queries":["SELECT * WHERE { ?s ?p ?o }"],
3164 "format_version":1
3165 }"#;
3166 let card = DatasetCard::from_json_bytes(json.as_bytes()).unwrap();
3167 assert_eq!(card.title.as_deref(), Some("Legacy"));
3168 assert_eq!(card.example_queries.len(), 1);
3169 assert!(card.datatypes.is_empty());
3171 assert!(card.languages.is_empty());
3172 assert!(card.class_links.is_empty());
3173 assert!(card.top_hubs.is_empty());
3174 assert!(card.in_hubs.is_empty());
3175 assert!(card.queries.is_empty());
3176 assert!(card.signals.is_empty());
3177 assert!(!card.truncated);
3178 }
3179
3180 #[test]
3184 fn curated_identity_fields_round_trip() {
3185 let input: CardInput = serde_json::from_str(
3186 r#"{
3187 "title": "T",
3188 "version": "2026-08-04",
3189 "creators": [{"name":"Ada","orcid":"https://orcid.org/0000-0002-1825-0097"}],
3190 "publisher": {"name":"EPFL","ror":"https://ror.org/02s376052"},
3191 "canonical_url": "https://data.example.org/t.rete",
3192 "sparql_endpoint": "https://example.org/sparql",
3193 "source_date": "2026-07-01",
3194 "derived_from": ["https://example.org/dump.nt"],
3195 "doi": "https://doi.org/10.5281/zenodo.1",
3196 "cite_as": "Ada (2026). T.",
3197 "keywords": ["graphs", "citations"],
3198 "theme": ["http://publications.europa.eu/resource/authority/data-theme/TECH"]
3199 }"#,
3200 )
3201 .unwrap();
3202 let card = curated_counts_card(5, 9, input);
3203 let bytes = card.to_json_bytes();
3204 let back = DatasetCard::from_json_bytes(&bytes).unwrap();
3205 assert_eq!(back.version.as_deref(), Some("2026-08-04"));
3206 assert_eq!(back.creators[0].name, "Ada");
3207 assert_eq!(
3208 back.creators[0].orcid.as_deref(),
3209 Some("https://orcid.org/0000-0002-1825-0097")
3210 );
3211 assert_eq!(back.publisher.as_ref().unwrap().name, "EPFL");
3212 assert_eq!(
3213 back.canonical_url.as_deref(),
3214 Some("https://data.example.org/t.rete")
3215 );
3216 assert_eq!(back.derived_from, vec!["https://example.org/dump.nt"]);
3217 assert_eq!(
3218 back.doi.as_deref(),
3219 Some("https://doi.org/10.5281/zenodo.1")
3220 );
3221 assert_eq!(back.keywords, vec!["graphs", "citations"]);
3224 assert_eq!(
3225 back.theme,
3226 vec!["http://publications.europa.eu/resource/authority/data-theme/TECH"]
3227 );
3228
3229 let plain = curated_counts_card(1, 2, CardInput::default()).to_json_bytes();
3231 let text = String::from_utf8(plain).unwrap();
3232 for absent in [
3233 "\"creators\"",
3234 "\"publisher\"",
3235 "\"canonical_url\"",
3236 "\"sparql_endpoint\"",
3237 "\"derived_from\"",
3238 "\"doi\"",
3239 "\"cite_as\"",
3240 "\"source_date\"",
3241 "\"version\"",
3242 "\"top_n\"",
3243 "\"keywords\"",
3244 "\"theme\"",
3245 "\"extra\"",
3246 ] {
3247 assert!(
3248 !text.contains(absent),
3249 "{absent} must be omitted when unset"
3250 );
3251 }
3252 }
3253
3254 #[test]
3259 fn extra_round_trips_and_external_writer_cards_surface_it() {
3260 let input: CardInput = serde_json::from_str(
3261 r#"{"title":"T","extra":{"atlas:layer":84,"review":{"by":"dg","ok":true}}}"#,
3262 )
3263 .unwrap();
3264 let card = curated_counts_card(5, 9, input);
3265 let back = DatasetCard::from_json_bytes(&card.to_json_bytes()).unwrap();
3266 assert_eq!(back.extra, card.extra);
3267 assert_eq!(back.extra.get("atlas:layer"), Some(&serde_json::json!(84)));
3268
3269 let python_shaped = r#"{
3272 "title":"From Python","extra":{"pipeline":"nightly"},
3273 "triple_count":1,"quad_count":1,"named_graph_count":0,"term_count":3,
3274 "format_version":5
3275 }"#;
3276 let card = DatasetCard::from_json_bytes(python_shaped.as_bytes()).unwrap();
3277 assert_eq!(
3278 card.extra.get("pipeline"),
3279 Some(&serde_json::json!("nightly"))
3280 );
3281 }
3282
3283 #[test]
3286 fn extra_limits_bite_exactly_at_the_boundary() {
3287 use crate::card_input::normalize_extra;
3288 use serde_json::{json, Map, Value};
3289 let bag = |v: Vec<(String, Value)>| -> Map<String, Value> { v.into_iter().collect() };
3292
3293 let pad = |n: usize| bag(vec![("pad".into(), json!("x".repeat(n)))]);
3295 let at = pad(CARD_EXTRA_MAX_BYTES - 10);
3296 assert_eq!(serde_json::to_vec(&at).unwrap().len(), CARD_EXTRA_MAX_BYTES);
3297 normalize_extra(at).expect("exactly at the byte cap is accepted");
3298 let over = pad(CARD_EXTRA_MAX_BYTES - 9);
3299 assert_eq!(
3300 serde_json::to_vec(&over).unwrap().len(),
3301 CARD_EXTRA_MAX_BYTES + 1
3302 );
3303 let err = normalize_extra(over).unwrap_err().to_string();
3304 assert!(
3305 err.contains(&format!("{} bytes", CARD_EXTRA_MAX_BYTES + 1)),
3306 "the error states the actual size: {err}"
3307 );
3308
3309 let keys = |n: usize| {
3311 bag((0..n)
3312 .map(|i| (format!("k{i:03}"), json!(1)))
3313 .collect::<Vec<_>>())
3314 };
3315 normalize_extra(keys(CARD_EXTRA_MAX_KEYS)).expect("at the key cap");
3316 assert!(normalize_extra(keys(CARD_EXTRA_MAX_KEYS + 1)).is_err());
3317
3318 normalize_extra(bag(vec![("k".repeat(CARD_EXTRA_MAX_KEY_BYTES), json!(1))]))
3320 .expect("at the key-length cap");
3321 assert!(normalize_extra(bag(vec![(
3322 "k".repeat(CARD_EXTRA_MAX_KEY_BYTES + 1),
3323 json!(1)
3324 )]))
3325 .is_err());
3326 assert!(normalize_extra(bag(vec![(String::new(), json!(1))])).is_err());
3327
3328 normalize_extra(bag(vec![("d".into(), json!({"a": {"b": 1}}))])).expect("under the cap");
3332 normalize_extra(bag(vec![("d".into(), json!([{"w": ["x", "y"]}]))]))
3333 .expect("at the depth cap");
3334 assert!(normalize_extra(bag(vec![("d".into(), json!({"a": {"b": {"c": [1]}}}))])).is_err());
3335 assert!(normalize_extra(bag(vec![("d".into(), json!([[[[1]]]]))])).is_err());
3336
3337 let err = normalize_extra(bag(vec![("@context".into(), json!({}))]))
3339 .unwrap_err()
3340 .to_string();
3341 assert!(err.contains("reserved"), "loud reservation: {err}");
3342 }
3343
3344 #[test]
3349 fn extra_fields_keep_the_content_hash_reproducible() {
3350 let build = |json: &str| {
3351 let input: CardInput = serde_json::from_str::<CardInput>(json)
3354 .unwrap()
3355 .normalize()
3356 .unwrap();
3357 let quads = enriched_fixture();
3358 let card = derive_card(&quads, 12, 0, input);
3359 let (image, _) =
3360 crate::ingest::assemble_dataset_with_opts(quads, true, false, None, |_, _| {
3361 card.to_json_bytes()
3362 });
3363 image
3364 };
3365 let a = build(r#"{"title":"T","extra":{"b":{"y":2,"x":1},"a":[1,2]}}"#);
3366 let b = build(r#"{"title":"T","extra":{"a":[1,2],"b":{"x":1,"y":2}}}"#);
3367 assert_eq!(a, b, "reordered-input builds are byte-identical");
3368 let ha = Header::from_bytes(&a).unwrap().content_hash;
3369 assert_eq!(ha, Header::from_bytes(&b).unwrap().content_hash);
3370
3371 let plain = build(r#"{"title":"T"}"#);
3374 assert_ne!(ha, Header::from_bytes(&plain).unwrap().content_hash);
3375 }
3376
3377 #[test]
3381 fn top_level_custom_keys_are_rejected_not_dropped() {
3382 let err = serde_json::from_str::<CardInput>(r#"{"title":"T","my_field":1}"#)
3383 .unwrap_err()
3384 .to_string();
3385 assert!(err.contains("my_field"), "names the stray key: {err}");
3386
3387 let ok: CardInput =
3389 serde_json::from_str(r#"{"title":"T","extra":{"my_field":1}}"#).unwrap();
3390 assert_eq!(ok.extra.get("my_field"), Some(&serde_json::json!(1)));
3391 }
3392
3393 #[test]
3398 fn description_reads_as_a_string_or_as_lines() {
3399 let from_lines: CardInput = serde_json::from_value(serde_json::json!({
3400 "description": ["## Contents", "", "- a", "- b"]
3401 }))
3402 .unwrap();
3403 assert_eq!(
3404 from_lines.description.as_deref(),
3405 Some("## Contents\n\n- a\n- b")
3406 );
3407 let from_string: CardInput =
3408 serde_json::from_str("{\"description\":\"## Contents\\n\\n- a\\n- b\"}").unwrap();
3409 assert_eq!(from_string.description, from_lines.description);
3410
3411 let none: CardInput = serde_json::from_str(r#"{"title":"T"}"#).unwrap();
3413 assert!(none.description.is_none());
3414 let null: CardInput = serde_json::from_str(r#"{"description":null}"#).unwrap();
3415 assert!(null.description.is_none());
3416
3417 let over = "x".repeat(crate::card::CARD_DESCRIPTION_MAX_BYTES + 1);
3420 let err = serde_json::from_str::<CardInput>(&format!(
3421 "{{\"description\":{}}}",
3422 serde_json::to_string(&over).unwrap()
3423 ))
3424 .unwrap_err()
3425 .to_string();
3426 assert!(err.contains("over the"), "{err}");
3427 }
3428
3429 #[test]
3437 fn curated_field_list_matches_the_deny_unknown_fields_struct() {
3438 for f in crate::card::CURATED_CARD_FIELDS {
3439 let doc = format!(r#"{{"{f}":null}}"#);
3443 if let Err(e) = serde_json::from_str::<CardInput>(&doc) {
3444 assert!(
3445 !e.to_string().contains("unknown field"),
3446 "CURATED_CARD_FIELDS lists `{f}`, but CardInput rejects it as unknown: {e}",
3447 );
3448 }
3449 }
3450 let err = serde_json::from_str::<CardInput>(r#"{"nope":1}"#)
3453 .unwrap_err()
3454 .to_string();
3455 for name in err
3456 .split("expected one of ")
3457 .nth(1)
3458 .expect("serde names the expected fields")
3459 .split(" at line ")
3461 .next()
3462 .unwrap()
3463 .split(", ")
3464 .map(|s| s.trim().trim_matches('`'))
3465 .filter(|s| !s.is_empty())
3466 {
3467 assert!(
3468 crate::card::CURATED_CARD_FIELDS.contains(&name),
3469 "CardInput accepts `{name}` but CURATED_CARD_FIELDS does not list it — \
3470 the browser builder would reject a card the CLI accepts",
3471 );
3472 }
3473 }
3474
3475 #[test]
3482 fn a_browser_written_card_deserializes_here() {
3483 let curated = crate::card::validate_curated_card(&serde_json::json!({
3484 "title": "Built in a browser",
3485 "keywords": ["b", "a"],
3486 "theme": ["http://publications.europa.eu/resource/authority/data-theme/GOVE"],
3487 "creators": [{"name": "Ada", "orcid": "https://orcid.org/0000-0002-1825-0097"}],
3488 "extra": {"internal_id": "DS-1"},
3489 }))
3490 .expect("valid curated document");
3491 let composed = crate::card::compose_curated_card(curated, 3, 4, 1, 9, 5);
3492 let text = serde_json::to_string(&composed).unwrap();
3493
3494 let card: DatasetCard = serde_json::from_str(&text).expect("CLI reads a browser card");
3495 assert_eq!(card.title.as_deref(), Some("Built in a browser"));
3496 assert_eq!(card.keywords, vec!["a".to_string(), "b".to_string()]);
3497 assert_eq!(card.creators.len(), 1);
3498 assert_eq!(card.triple_count, 3);
3499 assert_eq!(card.quad_count, 4);
3500 assert_eq!(card.term_count, 9);
3501 assert_eq!(card.format_version, 5);
3502 assert!(card.predicates.is_empty() && card.queries.is_empty());
3505 assert!(!text.contains("\"predicates\""), "{text}");
3506 assert!(!text.contains("\"top_n\""), "{text}");
3507 }
3508
3509 #[test]
3513 fn old_reader_struct_accepts_new_card_json() {
3514 #[derive(serde::Deserialize)]
3515 struct OldCard {
3516 title: Option<String>,
3517 triple_count: u64,
3518 #[serde(default)]
3519 predicates: Vec<(String, u64)>,
3520 format_version: u8,
3521 }
3522 let input: CardInput = serde_json::from_str(
3523 r#"{"title":"New","version":"1","creators":[{"name":"A"}],
3524 "canonical_url":"https://x/y.rete","derived_from":["https://x/d.nt"],
3525 "extra":{"atlas:review":"approved"}}"#,
3526 )
3527 .unwrap();
3528 let quads = vec![q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>")];
3529 let card = derive_card(&quads, 3, 0, input);
3530 assert_eq!(card.top_n, CARD_TOP_N as u32);
3531 let bytes = card.to_json_bytes();
3532 let old: OldCard = serde_json::from_slice(&bytes).expect("old struct parses new JSON");
3533 assert_eq!(old.title.as_deref(), Some("New"));
3534 assert_eq!(old.triple_count, 1);
3535 assert_eq!(old.format_version, card.format_version);
3536 assert!(!old.predicates.is_empty());
3537 }
3538
3539 #[test]
3540 fn coherence_stamp_is_deterministic_and_additive() {
3541 let dis = "<http://www.w3.org/2002/07/owl#disjointWith>";
3542 let rt = "<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>";
3543 let base = vec![
3544 ("<http://ex/C>".into(), dis.into(), "<http://ex/D>".into()),
3545 ("<http://ex/x>".into(), rt.into(), "<http://ex/C>".into()),
3546 ("<http://ex/x>".into(), rt.into(), "<http://ex/D>".into()),
3547 ];
3548 let r = crate::reason(&base);
3549 let c1 = Coherence::from_reasoning(&r, false);
3550 assert_eq!(c1, Coherence::from_reasoning(&r, false));
3552 assert!(!c1.coherent);
3553 assert_eq!(c1.inconsistency_count, 1);
3554 assert_eq!(c1.by_kind, vec![("disjoint-classes".to_string(), 1)]);
3555 assert_eq!(c1.rules, crate::REASON_RULESET);
3556
3557 let json = DatasetCard::default()
3559 .with_coherence(&r, false)
3560 .to_json_bytes();
3561 let text = std::str::from_utf8(&json).unwrap();
3562 assert!(text.contains("coherence"));
3563 assert_eq!(DatasetCard::from_json_bytes(&json).unwrap().coherence, c1);
3564
3565 let plain = DatasetCard::default().to_json_bytes();
3566 assert!(!std::str::from_utf8(&plain).unwrap().contains("coherence"));
3567 assert!(DatasetCard::default().coherence.is_empty());
3568 }
3569
3570 #[test]
3571 fn base_iri_is_common_prefix_with_fallback() {
3572 let mut ns = BTreeMap::new();
3574 ns.insert("http://ex/person/".to_string(), 2000u64);
3575 ns.insert("http://ex/place/".to_string(), 600);
3576 ns.insert("http://ex/org/".to_string(), 120);
3577 assert_eq!(derive_base_iri(&ns).as_deref(), Some("http://ex/"));
3578
3579 let mut one = BTreeMap::new();
3581 one.insert("http://data.example.org/id/".to_string(), 10u64);
3582 assert_eq!(
3583 derive_base_iri(&one).as_deref(),
3584 Some("http://data.example.org/id/")
3585 );
3586
3587 let mut multi = BTreeMap::new();
3590 multi.insert("http://a.example/".to_string(), 100u64);
3591 multi.insert("http://b.other/".to_string(), 5);
3592 assert_eq!(
3593 derive_base_iri(&multi).as_deref(),
3594 Some("http://a.example/")
3595 );
3596
3597 assert_eq!(derive_base_iri(&BTreeMap::new()), None);
3598 }
3599
3600 #[test]
3601 fn parse_literal_decomposes_forms() {
3602 let plain = parse_literal("\"hello\"").unwrap();
3603 assert_eq!(plain.value, "hello");
3604 assert!(plain.datatype.ends_with("XMLSchema#string"));
3605 assert_eq!(plain.lang, "");
3606
3607 let tagged = parse_literal("\"bonjour\"@fr").unwrap();
3608 assert_eq!(tagged.lang, "fr");
3609 assert!(tagged.datatype.ends_with("#langString"));
3610
3611 let typed = parse_literal("\"42\"^^<http://www.w3.org/2001/XMLSchema#integer>").unwrap();
3612 assert_eq!(typed.value, "42");
3613 assert_eq!(typed.datatype, "http://www.w3.org/2001/XMLSchema#integer");
3614
3615 let esc = parse_literal("\"a\\\"b\"@en").unwrap();
3617 assert_eq!(esc.value, "a\\\"b");
3618 assert_eq!(esc.lang, "en");
3619
3620 assert!(parse_literal("<http://ex/x>").is_none());
3622 assert!(parse_literal("_:b0").is_none());
3623 }
3624
3625 #[test]
3630 fn date_shapes_are_strict() {
3631 for ok in [
3632 "2019",
3633 "-0044",
3634 "2019-05",
3635 "2019-05-01",
3636 "2019-05-01Z",
3637 "2019-05-01+02:00",
3638 "2019-05-01T12:30",
3639 "2019-05-01T12:30:15",
3640 "2019-05-01T12:30:15.250Z",
3641 "1950Z",
3642 ] {
3643 assert!(date_shaped(ok), "{ok} is a date");
3644 }
3645 for bad in [
3646 "0000-0001-5001-3721", "9999880", "1234-1240", "123-456",
3650 "2019 annual report", "2019-13",
3652 "2019-05-32",
3653 "2019-05-01T25:00",
3654 "10.1000/182", "",
3656 "-",
3657 "2019-",
3658 ] {
3659 assert!(!date_shaped(bad), "{bad} is not a date");
3660 }
3661 }
3662
3663 #[test]
3669 fn temporal_extent_ignores_identifiers_pages_and_titles() {
3670 let value = "<http://www.essepuntato.it/2010/06/literalreification/hasLiteralValue>";
3671 let start = "<http://prismstandard.org/namespaces/basic/2.0/startingPage>";
3672 let end = "<http://prismstandard.org/namespaces/basic/2.0/endingPage>";
3673 let title = "<http://purl.org/dc/terms/title>";
3674 let pubdate = "<http://prismstandard.org/namespaces/basic/2.0/publicationDate>";
3675 let issued = "<http://purl.org/dc/terms/issued>";
3676 let xsd = |t: &str| format!("http://www.w3.org/2001/XMLSchema#{t}");
3677 let mut quads = Vec::new();
3678 for (i, id) in [
3679 "0000-0001-5001-3721",
3680 "0000-0002-1825-0097",
3681 "10.1000/182",
3682 "9999880",
3683 "2019",
3684 ]
3685 .iter()
3686 .enumerate()
3687 {
3688 quads.push(q(
3689 &format!("<http://ex/id/{i}>"),
3690 value,
3691 &format!("\"{id}\""),
3692 ));
3693 }
3694 quads.push(q("<http://ex/re/1>", start, "\"9999880\""));
3695 quads.push(q("<http://ex/re/1>", end, "\"9999899\""));
3696 quads.push(q("<http://ex/re/2>", start, "\"1234-1240\""));
3697 quads.push(q("<http://ex/br/1>", title, "\"2019 annual report\""));
3698 quads.push(q(
3699 "<http://ex/br/1>",
3700 pubdate,
3701 &format!("\"2019-05-01\"^^<{}>", xsd("date")),
3702 ));
3703 quads.push(q(
3704 "<http://ex/br/2>",
3705 pubdate,
3706 &format!("\"1950\"^^<{}>", xsd("gYear")),
3707 ));
3708 quads.push(q(
3709 "<http://ex/br/3>",
3710 pubdate,
3711 &format!("\"2021-03\"^^<{}>", xsd("gYearMonth")),
3712 ));
3713 quads.push(q(
3715 "<http://ex/br/4>",
3716 pubdate,
3717 &format!("\"0000-0001-5001-3721\"^^<{}>", xsd("date")),
3718 ));
3719 quads.push(q("<http://ex/br/5>", issued, "\"1999-12\""));
3721 let card = derive_card(&quads, 40, 0, CardInput::default());
3722 let s = &card.signals;
3723 assert_eq!(
3724 s.time_predicates,
3725 vec![pubdate.to_string(), issued.to_string()],
3726 "only the date predicates are time predicates"
3727 );
3728 assert_eq!(
3729 s.temporal_extent,
3730 Some(("1950".to_string(), "2021-03".to_string())),
3731 "calendar order across xsd:gYear, xsd:date and xsd:gYearMonth"
3732 );
3733 assert!(
3735 s.numeric_predicates.is_empty(),
3736 "{:?}",
3737 s.numeric_predicates
3738 );
3739 assert!(card
3741 .queries
3742 .iter()
3743 .filter(|q| q.dimension == "time")
3744 .all(|q| q.sparql.contains("publicationDate")));
3745 }
3746
3747 #[test]
3748 fn negative_years_order_by_value_not_by_bytes() {
3749 let mut extent = BTreeMap::new();
3750 for v in ["-0044", "-0100", "0001", "-0044-03-15"] {
3751 update_time_extent("<p>", v, &mut extent);
3752 }
3753 assert_eq!(
3754 extent.get("<p>"),
3755 Some(&("-0100".to_string(), "0001".to_string()))
3756 );
3757 }
3758
3759 #[test]
3762 fn an_integer_year_off_a_date_predicate_is_numeric() {
3763 let int = "http://www.w3.org/2001/XMLSchema#integer";
3764 let quads = vec![
3765 q(
3766 "<http://ex/a>",
3767 "<http://ex/year>",
3768 &format!("\"1492\"^^<{int}>"),
3769 ),
3770 q(
3771 "<http://ex/b>",
3772 "<http://ex/year>",
3773 &format!("\"1500\"^^<{int}>"),
3774 ),
3775 ];
3776 let card = derive_card(&quads, 6, 0, CardInput::default());
3777 assert!(card.signals.time_predicates.is_empty());
3778 assert_eq!(
3779 card.signals.numeric_predicates,
3780 vec!["<http://ex/year>".to_string()]
3781 );
3782 }
3783
3784 fn streaming_fixture() -> Vec<(String, String, String, Option<String>)> {
3793 let xsd = |t: &str| format!("http://www.w3.org/2001/XMLSchema#{t}");
3794 let mut v = enriched_fixture();
3795 for i in 0..5 {
3796 let s = format!("<http://ex/place/{i}>");
3797 v.push(q(&s, TYPE, "<http://ex/Place>"));
3798 v.push(q(&s, LABEL, &format!("\"Lieu {i}\"@fr")));
3799 v.push(q(
3800 &s,
3801 "<http://www.w3.org/2003/01/geo/wgs84_pos#lat>",
3802 &format!("\"4{i}.5\"^^<{}>", xsd("double")),
3803 ));
3804 v.push(q(
3805 &s,
3806 "<http://www.w3.org/2003/01/geo/wgs84_pos#long>",
3807 &format!("\"{i}.25\"^^<{}>", xsd("double")),
3808 ));
3809 v.push(q(
3810 &s,
3811 "<http://www.opengis.net/ont/geosparql#asWKT>",
3812 &format!(
3813 "\"POINT({i}.25 4{i}.5)\"^^<http://www.opengis.net/ont/geosparql#wktLiteral>"
3814 ),
3815 ));
3816 v.push(q(
3817 &s,
3818 "<http://purl.org/dc/terms/created>",
3819 &format!("\"20{i}0-01-0{}\"^^<{}>", i + 1, xsd("date")),
3820 ));
3821 v.push(q(&s, "<http://ex/near>", "<http://elsewhere/undescribed>"));
3822 }
3823 let knows = "<http://ex/a> <http://ex/knows> <http://ex/b>";
3826 let lives = "<http://ex/a> <http://ex/livesIn> <http://ex/c>";
3827 let ghost = "<http://ex/b> <http://ex/knows> <http://ex/c>";
3828 for (t, who) in [(knows, "alice"), (lives, "bob"), (ghost, "carol")] {
3829 v.push(q(
3830 &format!("<<{t}>>"),
3831 "<http://ex/recordedBy>",
3832 &format!("<http://ex/{who}>"),
3833 ));
3834 }
3835 v.push(q(
3836 "<http://ex/claim/1>",
3837 "<http://ex/states>",
3838 &format!("<<{knows}>>"),
3839 ));
3840 v.push((
3841 "<http://ex/note/1>".into(),
3842 "<http://ex/about>".into(),
3843 "<http://ex/a>".into(),
3844 Some("<http://ex/graph/notes>".into()),
3845 ));
3846 v
3847 }
3848
3849 fn both_ways(quads: &[(String, String, String, Option<String>)]) -> (DatasetCard, DatasetCard) {
3852 let (bytes, _) =
3853 crate::ingest::assemble_dataset_with_opts(quads.to_vec(), true, false, None, |_, _| {
3854 Vec::new()
3855 });
3856 let rete = crate::Rete::open(&bytes).unwrap();
3857 let term_count = rete.header().term_count;
3858 let (card, report) =
3859 derive_card_streaming(&rete, CardInput::default(), &mut |_| {}).unwrap();
3860 assert_eq!(report.statements, card.triple_count);
3861 let in_memory = derive_card(
3862 quads,
3863 term_count,
3864 rete.named_graph_count() as u64,
3865 CardInput::default(),
3866 );
3867 (card, in_memory)
3868 }
3869
3870 #[test]
3874 fn streaming_derivation_matches_the_in_memory_card() {
3875 for quads in [enriched_fixture(), streaming_fixture()] {
3876 let (streamed, in_memory) = both_ways(&quads);
3877 assert_eq!(
3878 String::from_utf8(streamed.to_json_bytes()).unwrap(),
3879 String::from_utf8(in_memory.to_json_bytes()).unwrap()
3880 );
3881 }
3882 let (card, _) = both_ways(&streaming_fixture());
3884 assert!(card.signals.annotated_statement.is_some());
3885 assert!(!card.signals.quoting_predicates.is_empty());
3886 assert!(card.signals.spatial_bbox.is_some());
3887 assert!(card.signals.temporal_extent.is_some());
3888 assert_eq!(card.named_graph_count, 1);
3889 }
3890
3891 #[test]
3894 fn streaming_derivation_widens_the_class_map() {
3895 let mut quads = Vec::new();
3896 for i in 0..300 {
3897 let s = format!("<http://ex/thing/{i:03}>");
3898 quads.push(q(&s, TYPE, &format!("<http://ex/Class{i:03}>")));
3899 quads.push(q(
3900 &s,
3901 "<http://ex/next>",
3902 &format!("<http://ex/thing/{:03}>", (i + 1) % 300),
3903 ));
3904 }
3905 let (streamed, in_memory) = both_ways(&quads);
3906 assert!(streamed.truncated);
3907 assert_eq!(streamed.classes, in_memory.classes);
3908 assert_eq!(streamed.class_links, in_memory.class_links);
3909 assert_eq!(
3910 String::from_utf8(streamed.to_json_bytes()).unwrap(),
3911 String::from_utf8(in_memory.to_json_bytes()).unwrap()
3912 );
3913 }
3914
3915 #[test]
3918 fn streaming_derivation_counts_the_deduplicated_file() {
3919 let mut quads = enriched_fixture();
3920 quads.push(quads[0].clone());
3921 let (streamed, in_memory) = both_ways(&quads);
3922 assert_eq!(streamed.triple_count, in_memory.triple_count - 1);
3923 let type_count = |c: &DatasetCard| {
3924 c.predicates
3925 .iter()
3926 .find(|(p, _)| p == TYPE)
3927 .map(|(_, n)| *n)
3928 };
3929 assert_eq!(type_count(&streamed), type_count(&in_memory).map(|n| n - 1));
3930 }
3931
3932 #[test]
3935 fn the_curated_half_of_a_card_round_trips() {
3936 let doc = r##"{"title":"T","description":["# Heading","line"],"license":"CC0-1.0",
3937 "source":"https://ex/src","created":"2026-01-01","version":"1",
3938 "creators":[{"name":"Ada","orcid":"https://orcid.org/0000-0002-1825-0097"}],
3939 "publisher":{"name":"P","ror":"https://ror.org/01ggx4157"},
3940 "canonical_url":"https://ex/f.rete","sparql_endpoint":"https://ex/sparql",
3941 "source_date":"2025-12-31","derived_from":["https://ex/dump"],
3942 "doi":"https://doi.org/10.1/x","cite_as":"Cite me","keywords":["b","a"],
3943 "theme":["http://publications.europa.eu/resource/authority/data-theme/EDUC"],
3944 "extra":{"k":[{"words":["x"]}]},"example_queries":["ASK {}"]}"##;
3945 let curated = CardInput::from_json_str(doc).unwrap();
3946 assert!(!curated.is_empty());
3947 let card = derive_card(&enriched_fixture(), 12, 0, curated);
3948 let again = derive_card(&enriched_fixture(), 12, 0, CardInput::from_card(&card));
3949 assert_eq!(again.to_json_bytes(), card.to_json_bytes());
3950 assert!(CardInput::from_card(&derive_card(
3951 &enriched_fixture(),
3952 12,
3953 0,
3954 CardInput::default()
3955 ))
3956 .is_empty());
3957 }
3958}