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 struct Quads<'a>(&'a [(String, String, String, Option<String>)]);
1109 impl CardTripleSource for Quads<'_> {
1110 fn replay(&self, f: &mut dyn FnMut(&str, &str, &str)) {
1111 for (s, p, o, g) in self.0 {
1112 if g.is_none() {
1113 f(s, p, o);
1114 }
1115 }
1116 }
1117 }
1118 derive_card_from(
1119 &Quads(quads),
1120 quads.len() as u64,
1121 term_count,
1122 named_graph_count,
1123 curated,
1124 )
1125}
1126
1127pub fn derive_card_encoded(
1133 dict: &crate::Dictionary,
1134 triples: &[(u32, u32, u32)],
1135 quad_count: u64,
1136 term_count: u64,
1137 named_graph_count: u64,
1138 curated: CardInput,
1139) -> DatasetCard {
1140 struct Encoded<'a> {
1141 dict: &'a crate::Dictionary,
1142 triples: &'a [(u32, u32, u32)],
1143 }
1144 impl CardTripleSource for Encoded<'_> {
1145 fn replay(&self, f: &mut dyn FnMut(&str, &str, &str)) {
1146 for &(s, p, o) in self.triples {
1147 let st = self.dict.subject_term(s).unwrap_or_default();
1148 let pt = self.dict.predicate_term(p).unwrap_or_default();
1149 let ot = self.dict.object_term(o).unwrap_or_default();
1150 f(&st, &pt, &ot);
1151 }
1152 }
1153 }
1154 derive_card_from(
1155 &Encoded { dict, triples },
1156 quad_count,
1157 term_count,
1158 named_graph_count,
1159 curated,
1160 )
1161}
1162
1163pub fn derive_card_from(
1171 src: &dyn CardTripleSource,
1172 quad_count: u64,
1173 term_count: u64,
1174 named_graph_count: u64,
1175 curated: CardInput,
1176) -> DatasetCard {
1177 let mut class_of: std::collections::HashMap<String, String> = std::collections::HashMap::new();
1183 let mut quoted_subject_preds: BTreeSet<String> = BTreeSet::new();
1184 let mut annotated_tokens: BTreeMap<String, String> = BTreeMap::new();
1185 src.replay(&mut |s, p, o| {
1186 if p == RDF_TYPE {
1187 class_of.insert(s.to_string(), o.to_string());
1188 }
1189 if crate::terms::is_quoted_triple(s) {
1190 if let Some((_, quoted_p, _)) = crate::ingest::quoted_triple_parts(s) {
1191 quoted_subject_preds.insert(quoted_p);
1192 }
1193 if annotated_tokens.len() < ANNOTATION_WITNESS_SAMPLE {
1194 annotated_tokens
1195 .entry(s.to_string())
1196 .or_insert_with(|| p.to_string());
1197 }
1198 }
1199 });
1200 let classify = |t: &str| -> String {
1201 if let Some(c) = class_of.get(t) {
1202 c.to_string()
1203 } else if t.starts_with('"') {
1204 O_LITERAL.to_string()
1205 } else {
1206 O_UNTYPED.to_string()
1207 }
1208 };
1209
1210 let mut prof = Profile::default();
1216 let mut out_degree: BTreeMap<String, u64> = BTreeMap::new();
1217 let mut in_degree: BTreeMap<String, u64> = BTreeMap::new();
1218
1219 src.replay(&mut |s, p, o| {
1220 prof.triple_count += 1;
1221 *prof.pred_counts.entry(p.to_string()).or_default() += 1;
1222 *out_degree.entry(s.to_string()).or_default() += 1;
1223 if let Some(ns) = split_namespace(s) {
1224 *prof.subject_ns.entry(ns).or_default() += 1;
1225 }
1226
1227 if !o.starts_with('"') {
1231 *in_degree.entry(o.to_string()).or_default() += 1;
1232 }
1233
1234 if crate::terms::is_quoted_triple(s) {
1238 *prof.annotation_hits.entry(p.to_string()).or_default() += 1;
1239 }
1240 if crate::terms::is_quoted_triple(o) {
1241 *prof.quoting_hits.entry(p.to_string()).or_default() += 1;
1242 }
1243 if !quoted_subject_preds.is_empty() && quoted_subject_preds.contains(p) {
1250 if let Some(about) = annotated_tokens.get(&format!("<<{s} {p} {o}>>")) {
1251 *prof
1252 .witnessed_pairs
1253 .entry((p.to_string(), about.clone()))
1254 .or_default() += 1;
1255 }
1256 }
1257
1258 if p == RDF_TYPE {
1259 *prof.class_counts.entry(o.to_string()).or_default() += 1;
1260 return; }
1262
1263 *prof
1265 .link_rows
1266 .entry((classify(s), p.to_string(), classify(o)))
1267 .or_default() += 1;
1268
1269 if p == GEO_ASWKT || p == GEO_HASGEOMETRY {
1270 prof.geo_wkt = true;
1271 }
1272
1273 if let Some(lit) = parse_literal(o) {
1275 prof.literal(p, &lit, 1);
1276 }
1277 });
1278
1279 let mut truncated = false;
1280 let top_hubs = cap(sort_desc_owned(out_degree), &mut truncated);
1281 let in_hubs = cap(sort_desc_owned(in_degree), &mut truncated);
1282 finish(
1283 prof,
1284 Hubs {
1285 top_hubs,
1286 in_hubs,
1287 truncated,
1288 },
1289 quad_count,
1290 term_count,
1291 named_graph_count,
1292 curated,
1293 )
1294}
1295
1296const O_UNTYPED: &str = "(untyped)";
1299
1300#[derive(Default)]
1311struct Profile {
1312 triple_count: u64,
1313 pred_counts: BTreeMap<String, u64>,
1314 class_counts: BTreeMap<String, u64>,
1315 datatype_counts: BTreeMap<String, u64>,
1316 lang_counts: BTreeMap<String, u64>,
1317 link_rows: BTreeMap<(String, String, String), u64>,
1318 subject_ns: BTreeMap<String, u64>,
1319 time_obj_hits: BTreeMap<String, u64>,
1321 num_obj_hits: BTreeMap<String, u64>,
1323 annotation_hits: BTreeMap<String, u64>,
1328 quoting_hits: BTreeMap<String, u64>,
1329 witnessed_pairs: BTreeMap<(String, String), u64>,
1330 time_extent: BTreeMap<String, (String, String)>,
1332 lat: Option<(f64, f64)>,
1334 lon: Option<(f64, f64)>,
1335 geo_wkt: bool,
1336}
1337
1338impl Profile {
1339 fn literal(&mut self, p: &str, lit: &Literal, weight: u64) {
1343 *self
1344 .datatype_counts
1345 .entry(format!("<{}>", lit.datatype))
1346 .or_default() += weight;
1347 *self.lang_counts.entry(lit.lang.clone()).or_default() += weight;
1348 if lit.datatype == GEO_WKTLITERAL {
1349 self.geo_wkt = true;
1350 }
1351 if is_temporal(p, lit) {
1352 *self.time_obj_hits.entry(p.to_string()).or_default() += weight;
1353 update_time_extent(p, &lit.value, &mut self.time_extent);
1354 } else if is_numeric_datatype(&lit.datatype) {
1355 *self.num_obj_hits.entry(p.to_string()).or_default() += weight;
1358 }
1359 if p == WGS_LAT {
1361 if let Ok(v) = lit.value.parse::<f64>() {
1362 widen(&mut self.lat, v);
1363 }
1364 } else if p == WGS_LONG {
1365 if let Ok(v) = lit.value.parse::<f64>() {
1366 widen(&mut self.lon, v);
1367 }
1368 }
1369 }
1370}
1371
1372fn widen(extent: &mut Option<(f64, f64)>, v: f64) {
1374 *extent = Some(match *extent {
1375 None => (v, v),
1376 Some((lo, hi)) => (lo.min(v), hi.max(v)),
1377 });
1378}
1379
1380struct Hubs {
1382 top_hubs: Vec<(String, u64)>,
1383 in_hubs: Vec<(String, u64)>,
1384 truncated: bool,
1385}
1386
1387fn finish(
1393 p: Profile,
1394 hubs: Hubs,
1395 quad_count: u64,
1396 term_count: u64,
1397 named_graph_count: u64,
1398 curated: CardInput,
1399) -> DatasetCard {
1400 let Profile {
1401 triple_count,
1402 pred_counts,
1403 class_counts,
1404 datatype_counts,
1405 lang_counts,
1406 link_rows,
1407 subject_ns,
1408 time_obj_hits,
1409 num_obj_hits,
1410 annotation_hits,
1411 quoting_hits,
1412 witnessed_pairs,
1413 time_extent,
1414 lat,
1415 lon,
1416 geo_wkt,
1417 } = p;
1418 let pred_count = |iri: &str| pred_counts.get(iri).copied().unwrap_or(0);
1421 let label_predicate = LABEL_PREDICATES
1422 .iter()
1423 .filter(|&&iri| pred_count(iri) > 0)
1424 .max_by_key(|&&iri| pred_count(iri))
1425 .map(|s| s.to_string());
1426 let link_predicates: Vec<String> = LINK_PREDICATES
1427 .iter()
1428 .filter(|&&iri| pred_count(iri) > 0)
1429 .map(|s| s.to_string())
1430 .collect();
1431
1432 let mut truncated = hubs.truncated;
1433 let predicates = cap(sort_desc_owned(pred_counts), &mut truncated);
1434 let classes = cap(sort_desc_owned(class_counts), &mut truncated);
1435 let datatypes = cap(sort_desc_owned(datatype_counts), &mut truncated);
1436 let languages = cap(sort_desc_owned(lang_counts), &mut truncated);
1437 let class_links = cap(sort_links(link_rows), &mut truncated);
1438
1439 let mut vocab: BTreeSet<String> = BTreeSet::new();
1441 for (iri, _) in predicates.iter().chain(classes.iter()) {
1442 if let Some(ns) = split_namespace(iri) {
1443 vocab.insert(ns);
1444 }
1445 }
1446 let vocabularies = cap(vocab.into_iter().collect(), &mut truncated);
1447
1448 let default_lang = languages
1450 .iter()
1451 .find(|(l, _)| !l.is_empty())
1452 .map(|(l, _)| l.clone());
1453 let base_iri = derive_base_iri(&subject_ns);
1454 let mut time_ranked: Vec<(String, u64)> = time_obj_hits.into_iter().collect();
1455 time_ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1456 let time_predicates = cap(
1458 time_ranked.iter().map(|(p, _)| p.to_string()).collect(),
1459 &mut truncated,
1460 );
1461 let mut num_ranked: Vec<(String, u64)> = num_obj_hits.into_iter().collect();
1462 num_ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1463 let numeric_predicates = cap(
1464 num_ranked.iter().map(|(p, _)| p.to_string()).collect(),
1465 &mut truncated,
1466 );
1467 let annotation_predicates = cap(
1473 sort_desc_owned(annotation_hits)
1474 .into_iter()
1475 .map(|(p, _)| p)
1476 .collect(),
1477 &mut truncated,
1478 );
1479 let quoting_predicates = cap(
1480 sort_desc_owned(quoting_hits)
1481 .into_iter()
1482 .map(|(p, _)| p)
1483 .collect(),
1484 &mut truncated,
1485 );
1486 let annotated_statement = witnessed_pairs
1492 .into_iter()
1493 .max_by(|a, b| a.1.cmp(&b.1).then_with(|| b.0.cmp(&a.0)))
1494 .map(|(pair, _)| pair);
1495 let geo_latlong = lat.is_some() && lon.is_some();
1496 let temporal_extent = time_ranked
1497 .first()
1498 .and_then(|(p, _)| time_extent.get(p))
1499 .cloned();
1500 let spatial_bbox = match (lat, lon) {
1501 (Some((lat_lo, lat_hi)), Some((lon_lo, lon_hi)))
1502 if lon_lo.is_finite() && lat_lo.is_finite() =>
1503 {
1504 Some([lon_lo, lat_lo, lon_hi, lat_hi])
1505 }
1506 _ => None,
1507 };
1508
1509 let signals = Signals {
1510 label_predicate,
1511 default_lang,
1512 base_iri,
1513 time_predicates,
1514 numeric_predicates,
1515 link_predicates,
1516 annotation_predicates,
1517 quoting_predicates,
1518 annotated_statement,
1519 geo_wkt,
1520 geo_latlong,
1521 temporal_extent,
1522 spatial_bbox,
1523 text_index: None,
1527 label_index: None,
1528 permutations: None,
1529 quoted_triples: None,
1530 };
1531
1532 let mut card = DatasetCard {
1533 triple_count,
1534 quad_count,
1535 named_graph_count,
1536 term_count,
1537 predicates,
1538 classes,
1539 vocabularies,
1540 datatypes,
1541 languages,
1542 class_links,
1543 top_hubs: hubs.top_hubs,
1544 in_hubs: hubs.in_hubs,
1545 signals,
1546 queries: Vec::new(),
1547 coherence: Coherence::default(),
1550 truncated,
1551 top_n: CARD_TOP_N as u32,
1554 format_version: crate::CURRENT_FORMAT_VERSION,
1555 ..DatasetCard::default()
1556 };
1557 curated.fill(&mut card);
1558 card.queries = crate::card_queries::generate(&card);
1560 card
1561}
1562
1563fn cap<T>(mut v: Vec<T>, truncated: &mut bool) -> Vec<T> {
1565 if v.len() > CARD_TOP_N {
1566 v.truncate(CARD_TOP_N);
1567 *truncated = true;
1568 }
1569 v
1570}
1571
1572fn sort_desc_owned(counts: BTreeMap<String, u64>) -> Vec<(String, u64)> {
1574 let mut v: Vec<(String, u64)> = counts.into_iter().collect();
1575 v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1576 v
1577}
1578
1579fn sort_links(rows: BTreeMap<(String, String, String), u64>) -> Vec<ClassLink> {
1582 let mut v: Vec<ClassLink> = rows
1583 .into_iter()
1584 .map(|((s, p, o), count)| ClassLink {
1585 s_class: s,
1586 predicate: p,
1587 o_class: o,
1588 count,
1589 })
1590 .collect();
1591 v.sort_by(|a, b| {
1592 b.count
1593 .cmp(&a.count)
1594 .then_with(|| a.s_class.cmp(&b.s_class))
1595 .then_with(|| a.predicate.cmp(&b.predicate))
1596 .then_with(|| a.o_class.cmp(&b.o_class))
1597 });
1598 v
1599}
1600
1601fn derive_base_iri(ns: &BTreeMap<String, u64>) -> Option<String> {
1608 if ns.is_empty() {
1609 return None;
1610 }
1611 let dominant = ns
1613 .iter()
1614 .max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
1615 .map(|(k, _)| k.clone())?;
1616 let mut lcp_len = dominant.len();
1618 for k in ns.keys() {
1619 let common = dominant
1620 .bytes()
1621 .zip(k.bytes())
1622 .take_while(|(a, b)| a == b)
1623 .count();
1624 lcp_len = lcp_len.min(common);
1625 }
1626 let lcp = &dominant[..lcp_len];
1627 let base = match lcp.rfind(['/', '#']) {
1629 Some(i) => &lcp[..=i],
1630 None => "",
1631 };
1632 if base.matches('/').count() >= 3 {
1635 Some(base.to_string())
1636 } else {
1637 Some(dominant)
1638 }
1639}
1640
1641struct Literal {
1643 value: String,
1645 datatype: String,
1648 lang: String,
1650}
1651
1652fn parse_literal(term: &str) -> Option<Literal> {
1655 let bytes = term.as_bytes();
1656 if bytes.first() != Some(&b'"') {
1657 return None;
1658 }
1659 let mut i = 1usize;
1661 let mut esc = false;
1662 let mut close = None;
1663 while i < bytes.len() {
1664 match bytes[i] {
1665 b'\\' => esc = !esc,
1666 b'"' if !esc => {
1667 close = Some(i);
1668 break;
1669 }
1670 _ => esc = false,
1671 }
1672 i += 1;
1673 }
1674 let close = close?;
1675 let value = term[1..close].to_string();
1676 let suffix = &term[close + 1..];
1677 if let Some(lang) = suffix.strip_prefix('@') {
1678 Some(Literal {
1679 value,
1680 datatype: RDF_LANGSTRING.to_string(),
1681 lang: lang.to_string(),
1682 })
1683 } else if let Some(dt) = suffix.strip_prefix("^^") {
1684 let dt = dt.trim_start_matches('<').trim_end_matches('>').to_string();
1685 Some(Literal {
1686 value,
1687 datatype: dt,
1688 lang: String::new(),
1689 })
1690 } else {
1691 Some(Literal {
1692 value,
1693 datatype: XSD_STRING.to_string(),
1694 lang: String::new(),
1695 })
1696 }
1697}
1698
1699fn is_temporal_datatype(dt: &str) -> bool {
1703 matches!(
1704 dt,
1705 "http://www.w3.org/2001/XMLSchema#date"
1706 | "http://www.w3.org/2001/XMLSchema#dateTime"
1707 | "http://www.w3.org/2001/XMLSchema#dateTimeStamp"
1708 | "http://www.w3.org/2001/XMLSchema#gYear"
1709 | "http://www.w3.org/2001/XMLSchema#gYearMonth"
1710 )
1711}
1712
1713const DATE_PREDICATES: &[&str] = &[
1718 "<http://purl.org/dc/terms/date>",
1719 "<http://purl.org/dc/terms/issued>",
1720 "<http://purl.org/dc/terms/created>",
1721 "<http://purl.org/dc/terms/modified>",
1722 "<http://purl.org/dc/terms/available>",
1723 "<http://purl.org/dc/terms/dateAccepted>",
1724 "<http://purl.org/dc/terms/dateCopyrighted>",
1725 "<http://purl.org/dc/terms/dateSubmitted>",
1726 "<http://purl.org/dc/elements/1.1/date>",
1727 "<http://schema.org/datePublished>",
1728 "<http://schema.org/dateCreated>",
1729 "<http://schema.org/dateModified>",
1730 "<http://schema.org/startDate>",
1731 "<http://schema.org/endDate>",
1732 "<http://schema.org/birthDate>",
1733 "<http://schema.org/deathDate>",
1734 "<http://schema.org/foundingDate>",
1735 "<http://schema.org/uploadDate>",
1736 "<https://schema.org/datePublished>",
1737 "<https://schema.org/dateCreated>",
1738 "<https://schema.org/dateModified>",
1739 "<https://schema.org/startDate>",
1740 "<https://schema.org/endDate>",
1741 "<https://schema.org/birthDate>",
1742 "<https://schema.org/deathDate>",
1743 "<http://prismstandard.org/namespaces/basic/2.0/publicationDate>",
1744 "<http://prismstandard.org/namespaces/basic/2.0/coverDate>",
1745 "<http://www.w3.org/ns/prov#generatedAtTime>",
1746 "<http://www.w3.org/ns/prov#invalidatedAtTime>",
1747 "<http://www.w3.org/ns/prov#startedAtTime>",
1748 "<http://www.w3.org/ns/prov#endedAtTime>",
1749 "<http://www.w3.org/ns/prov#atTime>",
1750 "<http://www.w3.org/2006/time#inXSDDate>",
1751 "<http://www.w3.org/2006/time#inXSDDateTime>",
1752 "<http://www.w3.org/2006/time#inXSDDateTimeStamp>",
1753 "<http://www.w3.org/2006/time#inXSDgYear>",
1754 "<http://www.w3.org/2006/time#inXSDgYearMonth>",
1755 "<http://dbpedia.org/ontology/birthDate>",
1756 "<http://dbpedia.org/ontology/deathDate>",
1757 "<http://dbpedia.org/ontology/releaseDate>",
1758 "<http://www.wikidata.org/prop/direct/P569>",
1759 "<http://www.wikidata.org/prop/direct/P570>",
1760 "<http://www.wikidata.org/prop/direct/P571>",
1761 "<http://www.wikidata.org/prop/direct/P577>",
1762 "<http://www.wikidata.org/prop/direct/P580>",
1763 "<http://www.wikidata.org/prop/direct/P582>",
1764 "<http://www.wikidata.org/prop/direct/P585>",
1765];
1766
1767fn is_temporal(p: &str, lit: &Literal) -> bool {
1781 if is_temporal_datatype(&lit.datatype) {
1782 return date_shaped(&lit.value);
1783 }
1784 DATE_PREDICATES.contains(&p) && date_shaped(&lit.value)
1785}
1786
1787fn date_shaped(v: &str) -> bool {
1795 let b = v.as_bytes();
1796 let digits =
1797 |at: usize, n: usize| b.len() >= at + n && b[at..at + n].iter().all(u8::is_ascii_digit);
1798 let two_in = |at: usize, lo: u8, hi: u8| {
1799 digits(at, 2) && {
1800 let n = (b[at] - b'0') * 10 + (b[at + 1] - b'0');
1801 (lo..=hi).contains(&n)
1802 }
1803 };
1804 let mut i = usize::from(b.first() == Some(&b'-'));
1805 if !digits(i, 4) {
1806 return false;
1807 }
1808 i += 4;
1809 if b.get(i) == Some(&b'-') {
1810 if !two_in(i + 1, 1, 12) {
1811 return false;
1812 }
1813 i += 3;
1814 if b.get(i) == Some(&b'-') {
1815 if !two_in(i + 1, 1, 31) {
1816 return false;
1817 }
1818 i += 3;
1819 if b.get(i) == Some(&b'T') {
1820 if !(two_in(i + 1, 0, 24) && b.get(i + 3) == Some(&b':') && two_in(i + 4, 0, 59)) {
1821 return false;
1822 }
1823 i += 6;
1824 if b.get(i) == Some(&b':') {
1825 if !two_in(i + 1, 0, 60) {
1826 return false;
1827 }
1828 i += 3;
1829 if b.get(i) == Some(&b'.') {
1830 let start = i + 1;
1831 i = start;
1832 while b.get(i).is_some_and(u8::is_ascii_digit) {
1833 i += 1;
1834 }
1835 if i == start {
1836 return false;
1837 }
1838 }
1839 }
1840 }
1841 }
1842 }
1843 match &b[i..] {
1844 [] | [b'Z'] => true,
1845 [sign, h1, h2, b':', m1, m2] => {
1846 (*sign == b'+' || *sign == b'-') && [h1, h2, m1, m2].iter().all(|c| c.is_ascii_digit())
1847 }
1848 _ => false,
1849 }
1850}
1851
1852fn is_numeric_datatype(dt: &str) -> bool {
1854 matches!(
1855 dt,
1856 "http://www.w3.org/2001/XMLSchema#integer"
1857 | "http://www.w3.org/2001/XMLSchema#decimal"
1858 | "http://www.w3.org/2001/XMLSchema#double"
1859 | "http://www.w3.org/2001/XMLSchema#float"
1860 | "http://www.w3.org/2001/XMLSchema#long"
1861 | "http://www.w3.org/2001/XMLSchema#int"
1862 | "http://www.w3.org/2001/XMLSchema#short"
1863 | "http://www.w3.org/2001/XMLSchema#nonNegativeInteger"
1864 | "http://www.w3.org/2001/XMLSchema#positiveInteger"
1865 )
1866}
1867
1868fn temporal_key(v: &str) -> (i64, &str) {
1873 let (neg, rest) = match v.strip_prefix('-') {
1874 Some(r) => (true, r),
1875 None => (false, v),
1876 };
1877 let n = rest.bytes().take_while(u8::is_ascii_digit).count();
1878 let year: i64 = rest[..n].parse().unwrap_or(0);
1879 (if neg { -year } else { year }, &rest[n..])
1880}
1881
1882fn update_time_extent(pred: &str, value: &str, extent: &mut BTreeMap<String, (String, String)>) {
1888 match extent.get_mut(pred) {
1889 None => {
1890 extent.insert(pred.to_string(), (value.to_string(), value.to_string()));
1891 }
1892 Some((lo, hi)) => {
1893 if temporal_key(value) < temporal_key(lo) {
1894 *lo = value.to_string();
1895 }
1896 if temporal_key(value) > temporal_key(hi) {
1897 *hi = value.to_string();
1898 }
1899 }
1900 }
1901}
1902
1903#[derive(Debug, Clone, Default)]
1931pub struct StreamReport {
1932 pub statements: u64,
1933 pub subjects: u64,
1934 pub literal_objects: u64,
1935 pub classes: u64,
1936 pub class_map_bytes: u64,
1937}
1938
1939enum ClassMap {
1944 U8(Vec<u8>),
1945 U16(Vec<u16>),
1946 U32(Vec<u32>),
1947}
1948
1949impl ClassMap {
1950 fn new(subjects: u64) -> Self {
1951 ClassMap::U8(vec![0; subjects as usize + 1])
1954 }
1955
1956 fn get(&self, id: u32) -> u32 {
1957 let i = id as usize;
1958 match self {
1959 ClassMap::U8(v) => v.get(i).map_or(0, |&c| c as u32),
1960 ClassMap::U16(v) => v.get(i).map_or(0, |&c| c as u32),
1961 ClassMap::U32(v) => v.get(i).copied().unwrap_or(0),
1962 }
1963 }
1964
1965 fn set(&mut self, id: u32, code: u32) {
1966 if code > u8::MAX as u32 {
1967 if let ClassMap::U8(v) = self {
1968 *self = ClassMap::U16(v.iter().map(|&c| c as u16).collect());
1969 }
1970 }
1971 if code > u16::MAX as u32 {
1972 if let ClassMap::U16(v) = self {
1973 *self = ClassMap::U32(v.iter().map(|&c| c as u32).collect());
1974 }
1975 }
1976 let i = id as usize;
1977 match self {
1978 ClassMap::U8(v) => {
1979 if let Some(slot) = v.get_mut(i) {
1980 *slot = code as u8;
1981 }
1982 }
1983 ClassMap::U16(v) => {
1984 if let Some(slot) = v.get_mut(i) {
1985 *slot = code as u16;
1986 }
1987 }
1988 ClassMap::U32(v) => {
1989 if let Some(slot) = v.get_mut(i) {
1990 *slot = code;
1991 }
1992 }
1993 }
1994 }
1995
1996 fn bytes(&self) -> u64 {
1997 match self {
1998 ClassMap::U8(v) => v.len() as u64,
1999 ClassMap::U16(v) => v.len() as u64 * 2,
2000 ClassMap::U32(v) => v.len() as u64 * 4,
2001 }
2002 }
2003}
2004
2005struct TopN {
2011 heap: std::collections::BinaryHeap<std::cmp::Reverse<(u64, std::cmp::Reverse<u32>)>>,
2012 offered: u64,
2013}
2014
2015impl TopN {
2016 fn new() -> Self {
2017 TopN {
2018 heap: std::collections::BinaryHeap::with_capacity(CARD_TOP_N + 1),
2019 offered: 0,
2020 }
2021 }
2022
2023 fn offer(&mut self, id: u32, n: u64) {
2024 use std::cmp::Reverse;
2025 self.offered += 1;
2026 let key = Reverse((n, Reverse(id)));
2027 if self.heap.len() < CARD_TOP_N {
2028 self.heap.push(key);
2029 } else if let Some(worst) = self.heap.peek() {
2030 if (n, Reverse(id)) > worst.0 {
2032 self.heap.pop();
2033 self.heap.push(key);
2034 }
2035 }
2036 }
2037
2038 fn into_parts(self) -> (Vec<(u32, u64)>, bool) {
2040 let cut = self.offered > CARD_TOP_N as u64;
2041 let v = self
2042 .heap
2043 .into_iter()
2044 .map(|std::cmp::Reverse((n, std::cmp::Reverse(id)))| (id, n))
2045 .collect();
2046 (v, cut)
2047 }
2048}
2049
2050fn top_terms(
2054 tops: [TopN; 2],
2055 terms: &dyn Fn(&[u32]) -> Vec<Option<String>>,
2056) -> (Vec<(String, u64)>, bool) {
2057 let mut offered = 0u64;
2058 let mut ids: Vec<(u32, u64)> = Vec::new();
2059 for top in tops {
2060 offered += top.offered;
2061 ids.extend(top.into_parts().0);
2062 }
2063 let names = terms(&ids.iter().map(|&(id, _)| id).collect::<Vec<_>>());
2064 let mut v: Vec<(String, u64)> = ids
2065 .iter()
2066 .zip(names)
2067 .filter_map(|(&(_, n), t)| t.map(|t| (t, n)))
2068 .collect();
2069 v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
2070 v.truncate(CARD_TOP_N);
2071 (v, offered > CARD_TOP_N as u64)
2072}
2073
2074#[derive(Default, Clone, Copy)]
2077struct IdHasher(u64);
2078
2079impl std::hash::Hasher for IdHasher {
2080 fn finish(&self) -> u64 {
2081 self.0
2082 }
2083 fn write(&mut self, bytes: &[u8]) {
2084 for &b in bytes {
2085 self.0 = (self.0.rotate_left(5) ^ b as u64).wrapping_mul(0x517c_c1b7_2722_0a95);
2086 }
2087 }
2088 fn write_u32(&mut self, n: u32) {
2089 self.0 = (self.0.rotate_left(5) ^ n as u64).wrapping_mul(0x517c_c1b7_2722_0a95);
2090 }
2091}
2092
2093type IdBuild = std::hash::BuildHasherDefault<IdHasher>;
2094
2095fn first_at_or_above(lo: u32, hi: u32, key: &str, term: &dyn Fn(u32) -> Option<String>) -> u32 {
2099 let (mut a, mut b) = (lo as u64, hi as u64 + 1);
2100 while a < b {
2101 let mid = a + (b - a) / 2;
2102 match term(mid as u32) {
2103 Some(t) if t.as_str() < key => a = mid + 1,
2104 _ => b = mid,
2105 }
2106 }
2107 a as u32
2108}
2109
2110fn id_range(
2115 lo: u32,
2116 hi: u32,
2117 from: &str,
2118 to: &str,
2119 term: &dyn Fn(u32) -> Option<String>,
2120) -> (u32, u32) {
2121 if hi < lo {
2122 return (lo, lo);
2123 }
2124 (
2125 first_at_or_above(lo, hi, from, term),
2126 first_at_or_above(lo, hi, to, term),
2127 )
2128}
2129
2130fn in_any(id: u32, ranges: &[(u32, u32)]) -> bool {
2131 ranges.iter().any(|&(a, b)| a <= id && id < b)
2132}
2133
2134pub fn derive_card_streaming(
2153 rete: &crate::Rete,
2154 curated: CardInput,
2155 progress: &mut dyn FnMut(&str),
2156) -> Result<(DatasetCard, StreamReport), String> {
2157 use crate::index::IndexPermutation;
2158
2159 let header = rete.header();
2160 let dict = rete.dictionary();
2161 let index = rete.default_index();
2162 let shared = dict.shared_count();
2163 let subjects_end = shared as u64 + dict.subject_only_count() as u64;
2164 let objects_end = shared as u64 + dict.object_only_count() as u64;
2165 let n_preds = dict.predicate_count() as usize;
2166 let pred_terms: Vec<String> = (0..=n_preds as u32)
2167 .map(|p| {
2168 if p == 0 {
2169 String::new()
2170 } else {
2171 dict.predicate_term(p).unwrap_or_default()
2172 }
2173 })
2174 .collect();
2175 let type_id = dict.predicate_id(RDF_TYPE);
2176
2177 let object_term = |id: u32| dict.object_term(id);
2179 let subject_term = |id: u32| dict.subject_term(id);
2180 let s_end = subjects_end.min(u32::MAX as u64) as u32;
2181 let o_end = objects_end.min(u32::MAX as u64) as u32;
2182 let literal_ranges = [
2183 id_range(1, shared, "\"", "#", &object_term),
2184 id_range(shared + 1, o_end, "\"", "#", &object_term),
2185 ];
2186 let is_literal = |o: u32| in_any(o, &literal_ranges);
2187 let quoted = header.has_quoted_triples();
2188 let (quoted_subjects, quoted_objects) = if quoted {
2189 (
2190 vec![
2191 id_range(1, shared, "<<", "<=", &subject_term),
2192 id_range(shared + 1, s_end, "<<", "<=", &subject_term),
2193 ],
2194 vec![
2195 id_range(1, shared, "<<", "<=", &object_term),
2196 id_range(shared + 1, o_end, "<<", "<=", &object_term),
2197 ],
2198 )
2199 } else {
2200 (Vec::new(), Vec::new())
2201 };
2202
2203 let mut report = StreamReport::default();
2204
2205 progress("pass 1/3: classes (the rdf:type range of POS)");
2207 let mut class_map = ClassMap::new(subjects_end);
2208 let mut class_code: std::collections::HashMap<u32, u32, IdBuild> = Default::default();
2209 let mut class_ids: Vec<u32> = vec![0];
2210 let mut class_counts: Vec<u64> = vec![0];
2211 if let Some(t) = type_id {
2212 for (s, _, o) in index.scan_iter((None, Some(t), None)) {
2213 let next = class_ids.len() as u32;
2214 let code = *class_code.entry(o).or_insert_with(|| {
2215 class_ids.push(o);
2216 class_counts.push(0);
2217 next
2218 });
2219 class_counts[code as usize] += 1;
2220 class_map.set(s, code);
2221 }
2222 }
2223 report.classes = class_ids.len() as u64 - 1;
2224 report.class_map_bytes = class_map.bytes();
2225
2226 progress("pass 2/3: subjects (SPO)");
2228 const LITERAL_CODE: u32 = u32::MAX;
2229 const WINDOW: usize = 1 << 16;
2230 let mut prof = Profile::default();
2231 let mut pred_counts = vec![0u64; n_preds + 1];
2232 let mut annotation_hits = vec![0u64; n_preds + 1];
2233 let mut quoting_hits = vec![0u64; n_preds + 1];
2234 let mut links: std::collections::HashMap<(u32, u32, u32), u64, IdBuild> = Default::default();
2235 let mut out_top = [TopN::new(), TopN::new()];
2237 let mut sample: Vec<(u32, u32)> = Vec::new();
2238 let mut window: Vec<(u32, u64)> = Vec::with_capacity(WINDOW);
2239 let mut subject_ns: BTreeMap<String, u64> = BTreeMap::new();
2240 let flush_ns = |window: &mut Vec<(u32, u64)>, subject_ns: &mut BTreeMap<String, u64>| {
2241 let ids: Vec<u32> = window.iter().map(|&(s, _)| s).collect();
2242 let terms = dict.subject_terms(&ids);
2243 let mut run: Option<(String, u64)> = None;
2246 for (term, &(_, n)) in terms.iter().zip(window.iter()) {
2247 let Some(ns) = term.as_deref().and_then(split_namespace) else {
2248 continue;
2249 };
2250 match &mut run {
2251 Some((cur, total)) if *cur == ns => *total += n,
2252 _ => {
2253 if let Some((cur, total)) = run.take() {
2254 *subject_ns.entry(cur).or_default() += total;
2255 }
2256 run = Some((ns, n));
2257 }
2258 }
2259 }
2260 if let Some((cur, total)) = run {
2261 *subject_ns.entry(cur).or_default() += total;
2262 }
2263 window.clear();
2264 };
2265 let mut cur_s = 0u32;
2266 let mut cur_n = 0u64;
2267 let stored = index.for_each_in_order(IndexPermutation::Spo, &mut |s, p, o| {
2268 prof.triple_count += 1;
2269 if let Some(c) = pred_counts.get_mut(p as usize) {
2270 *c += 1;
2271 }
2272 if s != cur_s {
2273 if cur_n > 0 {
2274 out_top[usize::from(cur_s > shared)].offer(cur_s, cur_n);
2275 window.push((cur_s, cur_n));
2276 if window.len() >= WINDOW {
2277 flush_ns(&mut window, &mut subject_ns);
2278 }
2279 }
2280 if quoted && in_any(s, "ed_subjects) && sample.len() < ANNOTATION_WITNESS_SAMPLE {
2281 sample.push((s, p));
2282 }
2283 cur_s = s;
2284 cur_n = 0;
2285 }
2286 cur_n += 1;
2287 if quoted {
2288 if in_any(s, "ed_subjects) {
2289 if let Some(c) = annotation_hits.get_mut(p as usize) {
2290 *c += 1;
2291 }
2292 }
2293 if in_any(o, "ed_objects) {
2294 if let Some(c) = quoting_hits.get_mut(p as usize) {
2295 *c += 1;
2296 }
2297 }
2298 }
2299 if Some(p) == type_id {
2300 return;
2301 }
2302 let sc = class_map.get(s);
2303 let oc = if is_literal(o) {
2306 LITERAL_CODE
2307 } else if o <= shared {
2308 class_map.get(o)
2309 } else {
2310 0
2311 };
2312 *links.entry((sc, p, oc)).or_default() += 1;
2313 });
2314 if !stored {
2315 return Err("the file stores no SPO permutation".to_string());
2316 }
2317 if cur_n > 0 {
2318 out_top[usize::from(cur_s > shared)].offer(cur_s, cur_n);
2319 window.push((cur_s, cur_n));
2320 }
2321 if !window.is_empty() {
2322 flush_ns(&mut window, &mut subject_ns);
2323 }
2324 report.statements = prof.triple_count;
2325 report.subjects = out_top[0].offered + out_top[1].offered;
2326 drop(class_map);
2327
2328 progress("pass 3/3: objects (OSP)");
2330 let mut in_top = [TopN::new(), TopN::new()];
2331 let mut lit_window: Vec<(u32, Vec<(u32, u64)>)> = Vec::with_capacity(WINDOW);
2332 let mut lit_objects = 0u64;
2333 let flush_lits = |lit_window: &mut Vec<(u32, Vec<(u32, u64)>)>, prof: &mut Profile| {
2334 let ids: Vec<u32> = lit_window.iter().map(|(o, _)| *o).collect();
2335 let terms = dict.object_terms(&ids);
2336 for (term, (_, preds)) in terms.iter().zip(lit_window.iter()) {
2337 let Some(lit) = term.as_deref().and_then(parse_literal) else {
2338 continue;
2339 };
2340 for &(p, n) in preds {
2341 if let Some(pt) = pred_terms.get(p as usize) {
2342 prof.literal(pt, &lit, n);
2343 }
2344 }
2345 }
2346 lit_window.clear();
2347 };
2348 let mut cur_o = 0u32;
2349 let mut cur_n = 0u64;
2350 let mut cur_preds: Vec<(u32, u64)> = Vec::new();
2351 index.for_each_in_order(IndexPermutation::Osp, &mut |o, _s, p| {
2352 if o != cur_o {
2353 if cur_n > 0 {
2354 if is_literal(cur_o) {
2355 lit_objects += 1;
2356 lit_window.push((cur_o, std::mem::take(&mut cur_preds)));
2357 if lit_window.len() >= WINDOW {
2358 flush_lits(&mut lit_window, &mut prof);
2359 }
2360 } else {
2361 in_top[usize::from(cur_o > shared)].offer(cur_o, cur_n);
2362 }
2363 }
2364 cur_o = o;
2365 cur_n = 0;
2366 cur_preds.clear();
2367 }
2368 cur_n += 1;
2369 if is_literal(o) {
2370 match cur_preds.iter_mut().find(|(q, _)| *q == p) {
2371 Some((_, n)) => *n += 1,
2372 None => cur_preds.push((p, 1)),
2373 }
2374 }
2375 });
2376 if cur_n > 0 {
2377 if is_literal(cur_o) {
2378 lit_objects += 1;
2379 lit_window.push((cur_o, std::mem::take(&mut cur_preds)));
2380 } else {
2381 in_top[usize::from(cur_o > shared)].offer(cur_o, cur_n);
2382 }
2383 }
2384 if !lit_window.is_empty() {
2385 flush_lits(&mut lit_window, &mut prof);
2386 }
2387 report.literal_objects = lit_objects;
2388
2389 if rete.index_incomplete() {
2390 return Err("a read failed part-way through the file; the card would be incomplete".into());
2391 }
2392
2393 progress("resolving the profile's terms");
2395 let class_terms: Vec<String> = class_ids
2396 .iter()
2397 .map(|&o| {
2398 if o == 0 {
2399 O_UNTYPED.to_string()
2400 } else {
2401 dict.object_term(o).unwrap_or_default()
2402 }
2403 })
2404 .collect();
2405 let class_name = |code: u32| -> String {
2406 if code == LITERAL_CODE {
2407 O_LITERAL.to_string()
2408 } else {
2409 class_terms
2410 .get(code as usize)
2411 .cloned()
2412 .unwrap_or_else(|| O_UNTYPED.to_string())
2413 }
2414 };
2415 for (p, &n) in pred_counts.iter().enumerate() {
2416 if n > 0 {
2417 prof.pred_counts.insert(pred_terms[p].clone(), n);
2418 }
2419 }
2420 for (code, &n) in class_counts.iter().enumerate().skip(1) {
2421 if n > 0 {
2422 *prof
2423 .class_counts
2424 .entry(class_terms[code].clone())
2425 .or_default() += n;
2426 }
2427 }
2428 for ((sc, p, oc), n) in links {
2429 let Some(pt) = pred_terms.get(p as usize) else {
2430 continue;
2431 };
2432 *prof
2433 .link_rows
2434 .entry((class_name(sc), pt.clone(), class_name(oc)))
2435 .or_default() += n;
2436 }
2437 for (p, &n) in annotation_hits.iter().enumerate() {
2438 if n > 0 {
2439 prof.annotation_hits.insert(pred_terms[p].clone(), n);
2440 }
2441 }
2442 for (p, &n) in quoting_hits.iter().enumerate() {
2443 if n > 0 {
2444 prof.quoting_hits.insert(pred_terms[p].clone(), n);
2445 }
2446 }
2447 prof.subject_ns = subject_ns;
2448 if pred_counts
2449 .iter()
2450 .enumerate()
2451 .any(|(p, &n)| n > 0 && (pred_terms[p] == GEO_ASWKT || pred_terms[p] == GEO_HASGEOMETRY))
2452 {
2453 prof.geo_wkt = true;
2454 }
2455
2456 let sample_ids: Vec<u32> = sample.iter().map(|&(s, _)| s).collect();
2459 for (term, &(_, about)) in dict.subject_terms(&sample_ids).iter().zip(sample.iter()) {
2460 let Some((a, qp, b)) = term.as_deref().and_then(crate::ingest::quoted_triple_parts) else {
2461 continue;
2462 };
2463 if let Some((sid, pid, oid)) = dict.encode(&a, &qp, &b) {
2464 if index
2465 .scan_iter((Some(sid), Some(pid), Some(oid)))
2466 .next()
2467 .is_some()
2468 {
2469 if let Some(about) = pred_terms.get(about as usize) {
2470 *prof.witnessed_pairs.entry((qp, about.clone())).or_default() += 1;
2471 }
2472 }
2473 }
2474 }
2475
2476 let (top_hubs, cut_out) = top_terms(out_top, &|ids| dict.subject_terms(ids));
2477 let (in_hubs, cut_in) = top_terms(in_top, &|ids| dict.object_terms(ids));
2478
2479 let card = finish(
2480 prof,
2481 Hubs {
2482 top_hubs,
2483 in_hubs,
2484 truncated: cut_out || cut_in,
2485 },
2486 header.quad_count,
2487 header.term_count,
2488 rete.named_graph_count() as u64,
2489 curated,
2490 );
2491 Ok((card, report))
2492}
2493
2494fn split_namespace(term: &str) -> Option<String> {
2498 let iri = term.strip_prefix('<')?.strip_suffix('>')?;
2499 let cut = iri.rfind(['#', '/'])?;
2500 Some(iri[..=cut].to_string())
2501}
2502
2503pub fn load_card(bytes: &[u8]) -> Result<Option<DatasetCard>, String> {
2510 let header = Header::from_bytes(bytes).map_err(|e| e.to_string())?;
2511 if header.metadata_len == 0 {
2512 return Ok(None);
2513 }
2514 let start = header.metadata_offset as usize;
2515 let end = start
2516 .checked_add(header.metadata_len as usize)
2517 .filter(|&e| e <= bytes.len())
2518 .ok_or_else(|| "metadata section out of bounds".to_string())?;
2519 Ok(Some(DatasetCard::from_json_bytes(&bytes[start..end])?))
2520}
2521
2522#[cfg(test)]
2523mod tests {
2524 use super::*;
2525 use crate::card_input::{CARD_EXTRA_MAX_BYTES, CARD_EXTRA_MAX_KEYS, CARD_EXTRA_MAX_KEY_BYTES};
2528
2529 const TYPE: &str = RDF_TYPE;
2530
2531 fn q(s: &str, p: &str, o: &str) -> (String, String, String, Option<String>) {
2532 (s.into(), p.into(), o.into(), None)
2533 }
2534
2535 #[test]
2536 fn card_json_round_trips() {
2537 let card = DatasetCard {
2538 title: Some("Demo".into()),
2539 license: Some("CC0".into()),
2540 triple_count: 3,
2541 quad_count: 3,
2542 term_count: 5,
2543 predicates: vec![("<http://ex/p>".into(), 2)],
2544 vocabularies: vec!["http://ex/".into()],
2545 format_version: 1,
2546 ..Default::default()
2547 };
2548 let bytes = card.to_json_bytes();
2549 let back = DatasetCard::from_json_bytes(&bytes).unwrap();
2550 assert_eq!(card, back);
2551 let text = String::from_utf8(bytes).unwrap();
2553 assert!(!text.contains("description"));
2554 assert!(!text.contains("classes"));
2555 }
2556
2557 #[test]
2563 fn the_permutation_signal_is_stripped_from_the_stored_card() {
2564 let measured = PermutationsSignal {
2565 count: 3,
2566 names: vec!["SPO".into(), "POS".into(), "OSP".into()],
2567 merge_join: false,
2568 };
2569 let mut card = DatasetCard {
2570 title: Some("Lean".into()),
2571 triple_count: 3,
2572 ..Default::default()
2573 };
2574 assert_eq!(card.observe_permutations(measured.clone()), None);
2575 assert_eq!(card.signals.permutations, Some(measured.clone()));
2576
2577 let bytes = card.to_json_bytes();
2578 let text = String::from_utf8(bytes.clone()).unwrap();
2579 assert!(
2580 !text.contains("permutations"),
2581 "the signal must not reach the metadata section: {text}"
2582 );
2583 let back = DatasetCard::from_json_bytes(&bytes).unwrap();
2584 assert_eq!(back.signals.permutations, None, "unknown, not `six`");
2585 assert_eq!(back.title, card.title);
2586
2587 let authored = r#"{"title":"x","signals":{"permutations":{"count":6}}}"#;
2590 assert!(
2591 serde_json::from_str::<CardInput>(authored).is_err(),
2592 "a hand-written card must not be able to assert a permutation set"
2593 );
2594 }
2595
2596 #[test]
2600 fn the_permutation_signal_is_derived_from_the_header() {
2601 let quads = vec![
2602 q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>"),
2603 q("<http://ex/b>", "<http://ex/p>", "<http://ex/c>"),
2604 ];
2605 let (six, _) = crate::ingest::assemble_dataset_with_perms(
2606 quads.clone(),
2607 false,
2608 false,
2609 None,
2610 crate::PyramidAlgo::Louvain,
2611 crate::PermSet::ALL,
2612 |_, _| Vec::new(),
2613 );
2614 let (three, _) = crate::ingest::assemble_dataset_with_perms(
2615 quads,
2616 false,
2617 false,
2618 None,
2619 crate::PyramidAlgo::Louvain,
2620 crate::PermSet::CORE,
2621 |_, _| Vec::new(),
2622 );
2623 assert_eq!(six[50], 0);
2626 assert_eq!(three[50], 0b0000_0111);
2627
2628 let h6 = crate::Header::from_bytes(&six).unwrap();
2629 let h3 = crate::Header::from_bytes(&three).unwrap();
2630 let s6 = PermutationsSignal::probe(&h6);
2631 let s3 = PermutationsSignal::probe(&h3);
2632 assert_eq!(s6.count, 6);
2633 assert!(s6.merge_join);
2634 assert_eq!(s3.count, 3);
2635 assert!(!s3.merge_join);
2636 assert_eq!(s3.names, vec!["SPO", "POS", "OSP"]);
2637 assert!(s3.describe().contains("no sort-merge join"));
2638 assert!(three.len() < six.len(), "{} vs {}", three.len(), six.len());
2640 assert_eq!(h3.dictionary_len, h6.dictionary_len);
2641 assert!(h3.root_dir_len < h6.root_dir_len);
2642 }
2643
2644 #[test]
2649 fn the_text_index_signal_is_stripped_from_the_stored_card() {
2650 let measured = TextIndexSignal {
2652 present: true,
2653 bytes: 1_879_287_762,
2654 token_table_bytes: Some(193_295_361),
2655 };
2656 let mut card = DatasetCard {
2657 title: Some("Indexed".into()),
2658 triple_count: 3,
2659 ..Default::default()
2660 };
2661 assert_eq!(card.observe_text_index(measured), None);
2662 assert_eq!(card.signals.text_index, Some(measured));
2663
2664 let bytes = card.to_json_bytes();
2665 let text = String::from_utf8(bytes.clone()).unwrap();
2666 assert!(
2667 !text.contains("text_index"),
2668 "the signal must not reach the metadata section: {text}"
2669 );
2670 let back = DatasetCard::from_json_bytes(&bytes).unwrap();
2673 assert_eq!(back.title, card.title);
2674 assert_eq!(back.signals.text_index, None);
2675
2676 assert_eq!(card.signals.text_index, Some(measured));
2679 }
2680
2681 #[test]
2687 fn the_text_index_signal_round_trips_through_a_card_document() {
2688 let mut card = DatasetCard {
2689 title: Some("Indexed".into()),
2690 ..Default::default()
2691 };
2692 card.observe_text_index(TextIndexSignal {
2693 present: true,
2694 bytes: 80,
2695 token_table_bytes: Some(68),
2696 });
2697 let doc = serde_json::to_vec(&card).unwrap();
2698 let back: DatasetCard = serde_json::from_slice(&doc).unwrap();
2699 assert_eq!(back.signals.text_index, card.signals.text_index);
2700
2701 let mut none = DatasetCard::default();
2705 none.observe_text_index(TextIndexSignal::default());
2706 let text = serde_json::to_string(&none).unwrap();
2707 assert!(text.contains(r#""text_index":{"present":false}"#), "{text}");
2708 let absent = serde_json::to_string(&DatasetCard::default()).unwrap();
2709 assert!(!absent.contains("text_index"), "{absent}");
2710 }
2711
2712 #[test]
2719 fn the_quoted_triple_signal_is_derived_from_the_header() {
2720 let plain = vec![
2721 q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>"),
2722 q("<http://ex/b>", "<http://ex/p>", "<http://ex/c>"),
2723 ];
2724 let quoted = vec![
2725 q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>"),
2726 q(
2727 "<http://ex/claim>",
2728 "<http://ex/states>",
2729 "<<<http://ex/a> <http://ex/p> <http://ex/b>>>",
2730 ),
2731 ];
2732 let (no, _) = crate::ingest::assemble_dataset(plain, &[]);
2733 let (yes, _) = crate::ingest::assemble_dataset(quoted, &[]);
2734
2735 let hn = crate::Header::from_bytes(&no).unwrap();
2736 let hy = crate::Header::from_bytes(&yes).unwrap();
2737 assert!(!hn.has_quoted_triples());
2738 assert!(hy.has_quoted_triples());
2739
2740 let sn = QuotedTriplesSignal::probe(&hn);
2741 let sy = QuotedTriplesSignal::probe(&hy);
2742 assert!(!sn.present);
2743 assert!(sy.present);
2744
2745 assert_eq!(sn.describe(), None);
2748 let line = sy.describe().expect("a file with quoted triples says so");
2752 assert!(line.contains("<<( s p o )>>"), "{line}");
2753 assert!(line.contains(QUOTED_TRIPLE_SURFACE_RDF_STAR), "{line}");
2754 assert_eq!(
2755 sy.export_surfaces,
2756 vec![QUOTED_TRIPLE_SURFACE_RDF12, QUOTED_TRIPLE_SURFACE_RDF_STAR]
2757 );
2758 assert_eq!(
2759 sy.export_default.as_deref(),
2760 Some(QUOTED_TRIPLE_SURFACE_RDF12)
2761 );
2762 assert!(sn.export_surfaces.is_empty());
2764 assert_eq!(sn.export_default, None);
2765 }
2766
2767 #[test]
2772 fn the_quoted_triple_signal_is_stripped_from_the_stored_card() {
2773 let measured = QuotedTriplesSignal {
2774 present: true,
2775 export_surfaces: vec![
2776 QUOTED_TRIPLE_SURFACE_RDF12.to_string(),
2777 QUOTED_TRIPLE_SURFACE_RDF_STAR.to_string(),
2778 ],
2779 export_default: Some(QUOTED_TRIPLE_SURFACE_RDF12.to_string()),
2780 };
2781 let mut card = DatasetCard {
2782 title: Some("Annotated".into()),
2783 triple_count: 3,
2784 ..Default::default()
2785 };
2786 assert_eq!(card.observe_quoted_triples(measured.clone()), None);
2787 assert_eq!(card.signals.quoted_triples, Some(measured.clone()));
2788
2789 let bytes = card.to_json_bytes();
2790 let text = String::from_utf8(bytes.clone()).unwrap();
2791 assert!(
2792 !text.contains("quoted_triples"),
2793 "the signal must not reach the metadata section: {text}"
2794 );
2795 let back = DatasetCard::from_json_bytes(&bytes).unwrap();
2796 assert_eq!(back.title, card.title);
2797 assert_eq!(back.signals.quoted_triples, None);
2798 assert_eq!(card.signals.quoted_triples, Some(measured));
2800
2801 let authored = r#"{"title":"x","signals":{"quoted_triples":{"present":true}}}"#;
2804 assert!(
2805 serde_json::from_str::<CardInput>(authored).is_err(),
2806 "a hand-written card must not be able to assert a quoted-triple set"
2807 );
2808 }
2809
2810 #[test]
2815 fn the_quoted_triple_signal_round_trips_through_a_card_document() {
2816 let mut card = DatasetCard {
2817 title: Some("Annotated".into()),
2818 ..Default::default()
2819 };
2820 card.observe_quoted_triples(QuotedTriplesSignal {
2821 present: true,
2822 export_surfaces: vec![QUOTED_TRIPLE_SURFACE_RDF12.to_string()],
2823 export_default: Some(QUOTED_TRIPLE_SURFACE_RDF12.to_string()),
2824 });
2825 let doc = serde_json::to_vec(&card).unwrap();
2826 let back: DatasetCard = serde_json::from_slice(&doc).unwrap();
2827 assert_eq!(back.signals.quoted_triples, card.signals.quoted_triples);
2828
2829 let mut none = DatasetCard::default();
2830 none.observe_quoted_triples(QuotedTriplesSignal {
2831 present: false,
2832 export_surfaces: Vec::new(),
2833 export_default: None,
2834 });
2835 let text = serde_json::to_string(&none).unwrap();
2836 assert!(
2837 text.contains(r#""quoted_triples":{"present":false}"#),
2838 "{text}"
2839 );
2840 let absent = serde_json::to_string(&DatasetCard::default()).unwrap();
2841 assert!(!absent.contains("quoted_triples"), "{absent}");
2842 }
2843 #[test]
2844 fn split_namespace_handles_hash_slash_and_neither() {
2845 assert_eq!(
2846 split_namespace("<http://xmlns.com/foaf/0.1/name>").as_deref(),
2847 Some("http://xmlns.com/foaf/0.1/")
2848 );
2849 assert_eq!(
2850 split_namespace("<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>").as_deref(),
2851 Some("http://www.w3.org/1999/02/22-rdf-syntax-ns#")
2852 );
2853 assert_eq!(split_namespace("<urn:isbn:12345>"), None);
2855 assert_eq!(split_namespace("\"literal\""), None);
2856 assert_eq!(split_namespace("_:b0"), None);
2857 }
2858
2859 #[test]
2860 fn derive_counts_and_orders() {
2861 let quads = vec![
2862 q("<http://ex/a>", TYPE, "<http://ex/Person>"),
2863 q("<http://ex/b>", TYPE, "<http://ex/Person>"),
2864 q("<http://ex/c>", TYPE, "<http://ex/City>"),
2865 q("<http://ex/a>", "<http://ex/knows>", "<http://ex/b>"),
2866 q("<http://ex/a>", "<http://ex/knows>", "<http://ex/c>"),
2867 q("<http://ex/a>", "<http://ex/name>", "\"A\""),
2868 ];
2869 let card = derive_card(&quads, 9, 0, CardInput::default());
2870 assert_eq!(card.triple_count, 6);
2871 assert_eq!(card.quad_count, 6);
2872 assert_eq!(card.term_count, 9);
2873 assert_eq!(card.predicates[0], (TYPE.to_string(), 3));
2875 assert!(card
2876 .predicates
2877 .contains(&("<http://ex/knows>".to_string(), 2)));
2878 assert_eq!(
2880 card.classes,
2881 vec![
2882 ("<http://ex/Person>".to_string(), 2),
2883 ("<http://ex/City>".to_string(), 1)
2884 ]
2885 );
2886 assert!(card.vocabularies.contains(&"http://ex/".to_string()));
2888 assert!(card
2889 .vocabularies
2890 .iter()
2891 .any(|v| v.ends_with("rdf-syntax-ns#")));
2892 }
2893
2894 #[test]
2895 fn named_graph_triples_excluded_from_default_stats() {
2896 let quads = vec![
2897 q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>"),
2898 (
2899 "<http://ex/c>".into(),
2900 "<http://ex/p>".into(),
2901 "<http://ex/d>".into(),
2902 Some("<http://ex/g1>".into()),
2903 ),
2904 ];
2905 let card = derive_card(&quads, 5, 1, CardInput::default());
2906 assert_eq!(card.triple_count, 1, "only the default-graph triple counts");
2907 assert_eq!(card.quad_count, 2);
2908 assert_eq!(card.named_graph_count, 1);
2909 assert_eq!(card.predicates[0].1, 1);
2910 }
2911
2912 const LABEL: &str = "<http://www.w3.org/2000/01/rdf-schema#label>";
2914 const SAMEAS: &str = "<http://www.w3.org/2002/07/owl#sameAs>";
2915 const XSD_INT: &str = "http://www.w3.org/2001/XMLSchema#integer";
2916 const XSD_GYEAR: &str = "http://www.w3.org/2001/XMLSchema#gYear";
2917
2918 fn enriched_fixture() -> Vec<(String, String, String, Option<String>)> {
2919 vec![
2920 q("<http://ex/a>", TYPE, "<http://ex/Person>"),
2921 q("<http://ex/b>", TYPE, "<http://ex/Person>"),
2922 q("<http://ex/c>", TYPE, "<http://ex/City>"),
2923 q("<http://ex/a>", LABEL, "\"Alice\"@en"),
2924 q("<http://ex/b>", LABEL, "\"Bob\"@en"),
2925 q(
2926 "<http://ex/a>",
2927 "<http://ex/age>",
2928 &format!("\"30\"^^<{XSD_INT}>"),
2929 ),
2930 q(
2931 "<http://ex/a>",
2932 "<http://ex/birthYear>",
2933 &format!("\"1850\"^^<{XSD_GYEAR}>"),
2934 ),
2935 q(
2936 "<http://ex/b>",
2937 "<http://ex/birthYear>",
2938 &format!("\"1875\"^^<{XSD_GYEAR}>"),
2939 ),
2940 q("<http://ex/a>", "<http://ex/knows>", "<http://ex/b>"),
2941 q("<http://ex/a>", "<http://ex/livesIn>", "<http://ex/c>"),
2942 q("<http://ex/a>", SAMEAS, "<http://other/a>"),
2943 ]
2944 }
2945
2946 #[test]
2947 fn enriched_profile_is_populated() {
2948 let quads = enriched_fixture();
2949 let card = derive_card(&quads, 12, 0, CardInput::default());
2950
2951 assert!(!card.datatypes.is_empty(), "datatypes derived");
2952 assert!(!card.languages.is_empty(), "languages derived");
2953 assert!(!card.class_links.is_empty(), "class_links derived");
2954 assert!(!card.top_hubs.is_empty(), "out-degree hubs derived");
2955 assert!(!card.in_hubs.is_empty(), "in-degree hubs derived");
2956
2957 assert!(card
2959 .class_links
2960 .iter()
2961 .any(|l| l.s_class == "<http://ex/Person>"
2962 && l.predicate == "<http://ex/knows>"
2963 && l.o_class == "<http://ex/Person>"
2964 && l.count == 1));
2965 assert!(card
2966 .class_links
2967 .iter()
2968 .any(|l| l.o_class == "<http://ex/City>"));
2969 assert!(card.class_links.iter().any(|l| l.o_class == "(untyped)"));
2971
2972 assert!(card.datatypes.iter().any(|(d, _)| d.contains("integer")));
2974 assert!(card.datatypes.iter().any(|(d, _)| d.contains("gYear")));
2975 assert!(card.datatypes.iter().any(|(d, _)| d.contains("langString")));
2976 assert!(card.languages.iter().any(|(l, _)| l == "en"));
2978
2979 let s = &card.signals;
2981 assert_eq!(s.label_predicate.as_deref(), Some(LABEL));
2982 assert_eq!(s.default_lang.as_deref(), Some("en"));
2983 assert_eq!(s.base_iri.as_deref(), Some("http://ex/"));
2984 assert!(s.link_predicates.iter().any(|p| p.contains("sameAs")));
2985 assert!(s.time_predicates.iter().any(|p| p.contains("birthYear")));
2986 assert_eq!(
2987 s.temporal_extent,
2988 Some(("1850".to_string(), "1875".to_string())),
2989 "extent over the dominant time predicate"
2990 );
2991 assert!(!s.geo_wkt && !s.geo_latlong);
2993 assert!(s.spatial_bbox.is_none());
2994 }
2995
2996 #[test]
2997 fn signals_detect_geometry() {
2998 let wkt = "http://www.opengis.net/ont/geosparql#wktLiteral";
2999 let quads = vec![
3000 q("<http://ex/p>", TYPE, "<http://ex/Place>"),
3001 q(
3002 "<http://ex/p>",
3003 "<http://www.w3.org/2003/01/geo/wgs84_pos#lat>",
3004 "\"41.9\"^^<http://www.w3.org/2001/XMLSchema#double>",
3005 ),
3006 q(
3007 "<http://ex/p>",
3008 "<http://www.w3.org/2003/01/geo/wgs84_pos#long>",
3009 "\"12.5\"^^<http://www.w3.org/2001/XMLSchema#double>",
3010 ),
3011 q(
3012 "<http://ex/p>",
3013 "<http://www.opengis.net/ont/geosparql#asWKT>",
3014 &format!("\"POINT(12.5 41.9)\"^^<{wkt}>"),
3015 ),
3016 ];
3017 let card = derive_card(&quads, 6, 0, CardInput::default());
3018 assert!(card.signals.geo_wkt, "asWKT/wktLiteral detected");
3019 assert!(card.signals.geo_latlong, "wgs84 lat+long detected");
3020 let bbox = card.signals.spatial_bbox.expect("bbox derived");
3021 assert_eq!(
3022 bbox,
3023 [12.5, 41.9, 12.5, 41.9],
3024 "[minLon,minLat,maxLon,maxLat]"
3025 );
3026 }
3027
3028 #[test]
3029 fn class_links_match_schema_summary() {
3030 use crate::{ingest, schema_summary, Rete};
3031 let quads = enriched_fixture();
3032 let card = derive_card(&quads, 12, 0, CardInput::default());
3033
3034 let (bytes, _) =
3036 ingest::assemble_dataset_with_opts(quads, true, false, None, |_, _| Vec::new());
3037 let rete = Rete::open(&bytes).unwrap();
3038 let reference: BTreeSet<(String, String, String, u64)> = schema_summary(&rete)
3039 .into_iter()
3040 .map(|(s, p, o, c)| (s, p, o, c as u64))
3041 .collect();
3042
3043 let derived: BTreeSet<(String, String, String, u64)> = card
3044 .class_links
3045 .iter()
3046 .map(|l| {
3047 (
3048 l.s_class.clone(),
3049 l.predicate.clone(),
3050 l.o_class.clone(),
3051 l.count,
3052 )
3053 })
3054 .collect();
3055
3056 assert_eq!(
3057 derived, reference,
3058 "card class_links must equal schema_summary's quotient row-for-row"
3059 );
3060 }
3061
3062 #[test]
3072 fn card_counts_are_the_deduplicated_counts() {
3073 use crate::ingest::{self, DeferredMetadata};
3074 use crate::Rete;
3075
3076 let g = |s: &str, p: &str, o: &str, graph: &str| {
3077 (
3078 s.to_string(),
3079 p.to_string(),
3080 o.to_string(),
3081 Some(graph.to_string()),
3082 )
3083 };
3084 let quads = vec![
3085 q("<http://ex/a>", "<http://ex/p>", "<http://ex/1>"),
3086 q("<http://ex/b>", "<http://ex/p>", "<http://ex/2>"),
3087 q("<http://ex/a>", "<http://ex/p>", "<http://ex/1>"), g("<http://ex/n>", "<http://ex/q>", "\"v\"", "<http://ex/g>"),
3089 g("<http://ex/n>", "<http://ex/q>", "\"v\"", "<http://ex/g>"), ];
3091 let (bytes, stats) =
3093 ingest::assemble_dataset_with_opts(quads, false, false, None, |stats, quads| {
3094 let card = derive_card(
3095 quads,
3096 stats.terms as u64,
3097 stats.named_graphs as u64,
3098 CardInput::default(),
3099 );
3100 DeferredMetadata::new(move |counts| card.with_final_counts(counts).to_json_bytes())
3101 });
3102
3103 let header = crate::Header::from_bytes(&bytes).unwrap();
3104 let card = load_card(&bytes).unwrap().expect("card embedded");
3105 assert_eq!(header.quad_count, 3, "header records the deduped quads");
3106 assert_eq!(
3107 card.quad_count, header.quad_count,
3108 "card and header must not disagree about the size of the same file"
3109 );
3110 assert_eq!(card.triple_count, 2, "default-graph triples, deduped");
3111
3112 let rete = Rete::open(&bytes).unwrap();
3114 let default_triples = rete.dump(None).len() as u64;
3115 let named: u64 = rete
3116 .graph_names()
3117 .iter()
3118 .map(|name| rete.dump(Some(name)).len() as u64)
3119 .sum();
3120 assert_eq!(default_triples + named, card.quad_count);
3121
3122 assert_eq!(stats.statements, 3);
3125 assert_eq!(stats.default_triples, 2);
3126 }
3127
3128 #[test]
3129 fn derive_is_deterministic() {
3130 let quads = enriched_fixture();
3133 let a = derive_card(&quads, 12, 0, CardInput::default()).to_json_bytes();
3134 let b = derive_card(&quads, 12, 0, CardInput::default()).to_json_bytes();
3135 assert_eq!(a, b, "enriched card derivation must be deterministic");
3136 assert!(!a.is_empty());
3137 }
3138
3139 #[test]
3140 fn old_plain_string_card_still_parses() {
3141 let json = r#"{
3144 "title":"Legacy",
3145 "triple_count":3,"quad_count":3,"named_graph_count":0,"term_count":5,
3146 "predicates":[["<http://ex/p>",2]],
3147 "example_queries":["SELECT * WHERE { ?s ?p ?o }"],
3148 "format_version":1
3149 }"#;
3150 let card = DatasetCard::from_json_bytes(json.as_bytes()).unwrap();
3151 assert_eq!(card.title.as_deref(), Some("Legacy"));
3152 assert_eq!(card.example_queries.len(), 1);
3153 assert!(card.datatypes.is_empty());
3155 assert!(card.languages.is_empty());
3156 assert!(card.class_links.is_empty());
3157 assert!(card.top_hubs.is_empty());
3158 assert!(card.in_hubs.is_empty());
3159 assert!(card.queries.is_empty());
3160 assert!(card.signals.is_empty());
3161 assert!(!card.truncated);
3162 }
3163
3164 #[test]
3168 fn curated_identity_fields_round_trip() {
3169 let input: CardInput = serde_json::from_str(
3170 r#"{
3171 "title": "T",
3172 "version": "2026-08-04",
3173 "creators": [{"name":"Ada","orcid":"https://orcid.org/0000-0002-1825-0097"}],
3174 "publisher": {"name":"EPFL","ror":"https://ror.org/02s376052"},
3175 "canonical_url": "https://data.example.org/t.rete",
3176 "sparql_endpoint": "https://example.org/sparql",
3177 "source_date": "2026-07-01",
3178 "derived_from": ["https://example.org/dump.nt"],
3179 "doi": "https://doi.org/10.5281/zenodo.1",
3180 "cite_as": "Ada (2026). T.",
3181 "keywords": ["graphs", "citations"],
3182 "theme": ["http://publications.europa.eu/resource/authority/data-theme/TECH"]
3183 }"#,
3184 )
3185 .unwrap();
3186 let card = curated_counts_card(5, 9, input);
3187 let bytes = card.to_json_bytes();
3188 let back = DatasetCard::from_json_bytes(&bytes).unwrap();
3189 assert_eq!(back.version.as_deref(), Some("2026-08-04"));
3190 assert_eq!(back.creators[0].name, "Ada");
3191 assert_eq!(
3192 back.creators[0].orcid.as_deref(),
3193 Some("https://orcid.org/0000-0002-1825-0097")
3194 );
3195 assert_eq!(back.publisher.as_ref().unwrap().name, "EPFL");
3196 assert_eq!(
3197 back.canonical_url.as_deref(),
3198 Some("https://data.example.org/t.rete")
3199 );
3200 assert_eq!(back.derived_from, vec!["https://example.org/dump.nt"]);
3201 assert_eq!(
3202 back.doi.as_deref(),
3203 Some("https://doi.org/10.5281/zenodo.1")
3204 );
3205 assert_eq!(back.keywords, vec!["graphs", "citations"]);
3208 assert_eq!(
3209 back.theme,
3210 vec!["http://publications.europa.eu/resource/authority/data-theme/TECH"]
3211 );
3212
3213 let plain = curated_counts_card(1, 2, CardInput::default()).to_json_bytes();
3215 let text = String::from_utf8(plain).unwrap();
3216 for absent in [
3217 "\"creators\"",
3218 "\"publisher\"",
3219 "\"canonical_url\"",
3220 "\"sparql_endpoint\"",
3221 "\"derived_from\"",
3222 "\"doi\"",
3223 "\"cite_as\"",
3224 "\"source_date\"",
3225 "\"version\"",
3226 "\"top_n\"",
3227 "\"keywords\"",
3228 "\"theme\"",
3229 "\"extra\"",
3230 ] {
3231 assert!(
3232 !text.contains(absent),
3233 "{absent} must be omitted when unset"
3234 );
3235 }
3236 }
3237
3238 #[test]
3243 fn extra_round_trips_and_external_writer_cards_surface_it() {
3244 let input: CardInput = serde_json::from_str(
3245 r#"{"title":"T","extra":{"atlas:layer":84,"review":{"by":"dg","ok":true}}}"#,
3246 )
3247 .unwrap();
3248 let card = curated_counts_card(5, 9, input);
3249 let back = DatasetCard::from_json_bytes(&card.to_json_bytes()).unwrap();
3250 assert_eq!(back.extra, card.extra);
3251 assert_eq!(back.extra.get("atlas:layer"), Some(&serde_json::json!(84)));
3252
3253 let python_shaped = r#"{
3256 "title":"From Python","extra":{"pipeline":"nightly"},
3257 "triple_count":1,"quad_count":1,"named_graph_count":0,"term_count":3,
3258 "format_version":5
3259 }"#;
3260 let card = DatasetCard::from_json_bytes(python_shaped.as_bytes()).unwrap();
3261 assert_eq!(
3262 card.extra.get("pipeline"),
3263 Some(&serde_json::json!("nightly"))
3264 );
3265 }
3266
3267 #[test]
3270 fn extra_limits_bite_exactly_at_the_boundary() {
3271 use crate::card_input::normalize_extra;
3272 use serde_json::{json, Map, Value};
3273 let bag = |v: Vec<(String, Value)>| -> Map<String, Value> { v.into_iter().collect() };
3276
3277 let pad = |n: usize| bag(vec![("pad".into(), json!("x".repeat(n)))]);
3279 let at = pad(CARD_EXTRA_MAX_BYTES - 10);
3280 assert_eq!(serde_json::to_vec(&at).unwrap().len(), CARD_EXTRA_MAX_BYTES);
3281 normalize_extra(at).expect("exactly at the byte cap is accepted");
3282 let over = pad(CARD_EXTRA_MAX_BYTES - 9);
3283 assert_eq!(
3284 serde_json::to_vec(&over).unwrap().len(),
3285 CARD_EXTRA_MAX_BYTES + 1
3286 );
3287 let err = normalize_extra(over).unwrap_err().to_string();
3288 assert!(
3289 err.contains(&format!("{} bytes", CARD_EXTRA_MAX_BYTES + 1)),
3290 "the error states the actual size: {err}"
3291 );
3292
3293 let keys = |n: usize| {
3295 bag((0..n)
3296 .map(|i| (format!("k{i:03}"), json!(1)))
3297 .collect::<Vec<_>>())
3298 };
3299 normalize_extra(keys(CARD_EXTRA_MAX_KEYS)).expect("at the key cap");
3300 assert!(normalize_extra(keys(CARD_EXTRA_MAX_KEYS + 1)).is_err());
3301
3302 normalize_extra(bag(vec![("k".repeat(CARD_EXTRA_MAX_KEY_BYTES), json!(1))]))
3304 .expect("at the key-length cap");
3305 assert!(normalize_extra(bag(vec![(
3306 "k".repeat(CARD_EXTRA_MAX_KEY_BYTES + 1),
3307 json!(1)
3308 )]))
3309 .is_err());
3310 assert!(normalize_extra(bag(vec![(String::new(), json!(1))])).is_err());
3311
3312 normalize_extra(bag(vec![("d".into(), json!({"a": {"b": 1}}))])).expect("under the cap");
3316 normalize_extra(bag(vec![("d".into(), json!([{"w": ["x", "y"]}]))]))
3317 .expect("at the depth cap");
3318 assert!(normalize_extra(bag(vec![("d".into(), json!({"a": {"b": {"c": [1]}}}))])).is_err());
3319 assert!(normalize_extra(bag(vec![("d".into(), json!([[[[1]]]]))])).is_err());
3320
3321 let err = normalize_extra(bag(vec![("@context".into(), json!({}))]))
3323 .unwrap_err()
3324 .to_string();
3325 assert!(err.contains("reserved"), "loud reservation: {err}");
3326 }
3327
3328 #[test]
3333 fn extra_fields_keep_the_content_hash_reproducible() {
3334 let build = |json: &str| {
3335 let input: CardInput = serde_json::from_str::<CardInput>(json)
3338 .unwrap()
3339 .normalize()
3340 .unwrap();
3341 let quads = enriched_fixture();
3342 let card = derive_card(&quads, 12, 0, input);
3343 let (image, _) =
3344 crate::ingest::assemble_dataset_with_opts(quads, true, false, None, |_, _| {
3345 card.to_json_bytes()
3346 });
3347 image
3348 };
3349 let a = build(r#"{"title":"T","extra":{"b":{"y":2,"x":1},"a":[1,2]}}"#);
3350 let b = build(r#"{"title":"T","extra":{"a":[1,2],"b":{"x":1,"y":2}}}"#);
3351 assert_eq!(a, b, "reordered-input builds are byte-identical");
3352 let ha = Header::from_bytes(&a).unwrap().content_hash;
3353 assert_eq!(ha, Header::from_bytes(&b).unwrap().content_hash);
3354
3355 let plain = build(r#"{"title":"T"}"#);
3358 assert_ne!(ha, Header::from_bytes(&plain).unwrap().content_hash);
3359 }
3360
3361 #[test]
3365 fn top_level_custom_keys_are_rejected_not_dropped() {
3366 let err = serde_json::from_str::<CardInput>(r#"{"title":"T","my_field":1}"#)
3367 .unwrap_err()
3368 .to_string();
3369 assert!(err.contains("my_field"), "names the stray key: {err}");
3370
3371 let ok: CardInput =
3373 serde_json::from_str(r#"{"title":"T","extra":{"my_field":1}}"#).unwrap();
3374 assert_eq!(ok.extra.get("my_field"), Some(&serde_json::json!(1)));
3375 }
3376
3377 #[test]
3382 fn description_reads_as_a_string_or_as_lines() {
3383 let from_lines: CardInput = serde_json::from_value(serde_json::json!({
3384 "description": ["## Contents", "", "- a", "- b"]
3385 }))
3386 .unwrap();
3387 assert_eq!(
3388 from_lines.description.as_deref(),
3389 Some("## Contents\n\n- a\n- b")
3390 );
3391 let from_string: CardInput =
3392 serde_json::from_str("{\"description\":\"## Contents\\n\\n- a\\n- b\"}").unwrap();
3393 assert_eq!(from_string.description, from_lines.description);
3394
3395 let none: CardInput = serde_json::from_str(r#"{"title":"T"}"#).unwrap();
3397 assert!(none.description.is_none());
3398 let null: CardInput = serde_json::from_str(r#"{"description":null}"#).unwrap();
3399 assert!(null.description.is_none());
3400
3401 let over = "x".repeat(crate::card::CARD_DESCRIPTION_MAX_BYTES + 1);
3404 let err = serde_json::from_str::<CardInput>(&format!(
3405 "{{\"description\":{}}}",
3406 serde_json::to_string(&over).unwrap()
3407 ))
3408 .unwrap_err()
3409 .to_string();
3410 assert!(err.contains("over the"), "{err}");
3411 }
3412
3413 #[test]
3421 fn curated_field_list_matches_the_deny_unknown_fields_struct() {
3422 for f in crate::card::CURATED_CARD_FIELDS {
3423 let doc = format!(r#"{{"{f}":null}}"#);
3427 if let Err(e) = serde_json::from_str::<CardInput>(&doc) {
3428 assert!(
3429 !e.to_string().contains("unknown field"),
3430 "CURATED_CARD_FIELDS lists `{f}`, but CardInput rejects it as unknown: {e}",
3431 );
3432 }
3433 }
3434 let err = serde_json::from_str::<CardInput>(r#"{"nope":1}"#)
3437 .unwrap_err()
3438 .to_string();
3439 for name in err
3440 .split("expected one of ")
3441 .nth(1)
3442 .expect("serde names the expected fields")
3443 .split(" at line ")
3445 .next()
3446 .unwrap()
3447 .split(", ")
3448 .map(|s| s.trim().trim_matches('`'))
3449 .filter(|s| !s.is_empty())
3450 {
3451 assert!(
3452 crate::card::CURATED_CARD_FIELDS.contains(&name),
3453 "CardInput accepts `{name}` but CURATED_CARD_FIELDS does not list it — \
3454 the browser builder would reject a card the CLI accepts",
3455 );
3456 }
3457 }
3458
3459 #[test]
3466 fn a_browser_written_card_deserializes_here() {
3467 let curated = crate::card::validate_curated_card(&serde_json::json!({
3468 "title": "Built in a browser",
3469 "keywords": ["b", "a"],
3470 "theme": ["http://publications.europa.eu/resource/authority/data-theme/GOVE"],
3471 "creators": [{"name": "Ada", "orcid": "https://orcid.org/0000-0002-1825-0097"}],
3472 "extra": {"internal_id": "DS-1"},
3473 }))
3474 .expect("valid curated document");
3475 let composed = crate::card::compose_curated_card(curated, 3, 4, 1, 9, 5);
3476 let text = serde_json::to_string(&composed).unwrap();
3477
3478 let card: DatasetCard = serde_json::from_str(&text).expect("CLI reads a browser card");
3479 assert_eq!(card.title.as_deref(), Some("Built in a browser"));
3480 assert_eq!(card.keywords, vec!["a".to_string(), "b".to_string()]);
3481 assert_eq!(card.creators.len(), 1);
3482 assert_eq!(card.triple_count, 3);
3483 assert_eq!(card.quad_count, 4);
3484 assert_eq!(card.term_count, 9);
3485 assert_eq!(card.format_version, 5);
3486 assert!(card.predicates.is_empty() && card.queries.is_empty());
3489 assert!(!text.contains("\"predicates\""), "{text}");
3490 assert!(!text.contains("\"top_n\""), "{text}");
3491 }
3492
3493 #[test]
3497 fn old_reader_struct_accepts_new_card_json() {
3498 #[derive(serde::Deserialize)]
3499 struct OldCard {
3500 title: Option<String>,
3501 triple_count: u64,
3502 #[serde(default)]
3503 predicates: Vec<(String, u64)>,
3504 format_version: u8,
3505 }
3506 let input: CardInput = serde_json::from_str(
3507 r#"{"title":"New","version":"1","creators":[{"name":"A"}],
3508 "canonical_url":"https://x/y.rete","derived_from":["https://x/d.nt"],
3509 "extra":{"atlas:review":"approved"}}"#,
3510 )
3511 .unwrap();
3512 let quads = vec![q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>")];
3513 let card = derive_card(&quads, 3, 0, input);
3514 assert_eq!(card.top_n, CARD_TOP_N as u32);
3515 let bytes = card.to_json_bytes();
3516 let old: OldCard = serde_json::from_slice(&bytes).expect("old struct parses new JSON");
3517 assert_eq!(old.title.as_deref(), Some("New"));
3518 assert_eq!(old.triple_count, 1);
3519 assert_eq!(old.format_version, card.format_version);
3520 assert!(!old.predicates.is_empty());
3521 }
3522
3523 #[test]
3524 fn coherence_stamp_is_deterministic_and_additive() {
3525 let dis = "<http://www.w3.org/2002/07/owl#disjointWith>";
3526 let rt = "<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>";
3527 let base = vec![
3528 ("<http://ex/C>".into(), dis.into(), "<http://ex/D>".into()),
3529 ("<http://ex/x>".into(), rt.into(), "<http://ex/C>".into()),
3530 ("<http://ex/x>".into(), rt.into(), "<http://ex/D>".into()),
3531 ];
3532 let r = crate::reason(&base);
3533 let c1 = Coherence::from_reasoning(&r, false);
3534 assert_eq!(c1, Coherence::from_reasoning(&r, false));
3536 assert!(!c1.coherent);
3537 assert_eq!(c1.inconsistency_count, 1);
3538 assert_eq!(c1.by_kind, vec![("disjoint-classes".to_string(), 1)]);
3539 assert_eq!(c1.rules, crate::REASON_RULESET);
3540
3541 let json = DatasetCard::default()
3543 .with_coherence(&r, false)
3544 .to_json_bytes();
3545 let text = std::str::from_utf8(&json).unwrap();
3546 assert!(text.contains("coherence"));
3547 assert_eq!(DatasetCard::from_json_bytes(&json).unwrap().coherence, c1);
3548
3549 let plain = DatasetCard::default().to_json_bytes();
3550 assert!(!std::str::from_utf8(&plain).unwrap().contains("coherence"));
3551 assert!(DatasetCard::default().coherence.is_empty());
3552 }
3553
3554 #[test]
3555 fn base_iri_is_common_prefix_with_fallback() {
3556 let mut ns = BTreeMap::new();
3558 ns.insert("http://ex/person/".to_string(), 2000u64);
3559 ns.insert("http://ex/place/".to_string(), 600);
3560 ns.insert("http://ex/org/".to_string(), 120);
3561 assert_eq!(derive_base_iri(&ns).as_deref(), Some("http://ex/"));
3562
3563 let mut one = BTreeMap::new();
3565 one.insert("http://data.example.org/id/".to_string(), 10u64);
3566 assert_eq!(
3567 derive_base_iri(&one).as_deref(),
3568 Some("http://data.example.org/id/")
3569 );
3570
3571 let mut multi = BTreeMap::new();
3574 multi.insert("http://a.example/".to_string(), 100u64);
3575 multi.insert("http://b.other/".to_string(), 5);
3576 assert_eq!(
3577 derive_base_iri(&multi).as_deref(),
3578 Some("http://a.example/")
3579 );
3580
3581 assert_eq!(derive_base_iri(&BTreeMap::new()), None);
3582 }
3583
3584 #[test]
3585 fn parse_literal_decomposes_forms() {
3586 let plain = parse_literal("\"hello\"").unwrap();
3587 assert_eq!(plain.value, "hello");
3588 assert!(plain.datatype.ends_with("XMLSchema#string"));
3589 assert_eq!(plain.lang, "");
3590
3591 let tagged = parse_literal("\"bonjour\"@fr").unwrap();
3592 assert_eq!(tagged.lang, "fr");
3593 assert!(tagged.datatype.ends_with("#langString"));
3594
3595 let typed = parse_literal("\"42\"^^<http://www.w3.org/2001/XMLSchema#integer>").unwrap();
3596 assert_eq!(typed.value, "42");
3597 assert_eq!(typed.datatype, "http://www.w3.org/2001/XMLSchema#integer");
3598
3599 let esc = parse_literal("\"a\\\"b\"@en").unwrap();
3601 assert_eq!(esc.value, "a\\\"b");
3602 assert_eq!(esc.lang, "en");
3603
3604 assert!(parse_literal("<http://ex/x>").is_none());
3606 assert!(parse_literal("_:b0").is_none());
3607 }
3608
3609 #[test]
3614 fn date_shapes_are_strict() {
3615 for ok in [
3616 "2019",
3617 "-0044",
3618 "2019-05",
3619 "2019-05-01",
3620 "2019-05-01Z",
3621 "2019-05-01+02:00",
3622 "2019-05-01T12:30",
3623 "2019-05-01T12:30:15",
3624 "2019-05-01T12:30:15.250Z",
3625 "1950Z",
3626 ] {
3627 assert!(date_shaped(ok), "{ok} is a date");
3628 }
3629 for bad in [
3630 "0000-0001-5001-3721", "9999880", "1234-1240", "123-456",
3634 "2019 annual report", "2019-13",
3636 "2019-05-32",
3637 "2019-05-01T25:00",
3638 "10.1000/182", "",
3640 "-",
3641 "2019-",
3642 ] {
3643 assert!(!date_shaped(bad), "{bad} is not a date");
3644 }
3645 }
3646
3647 #[test]
3653 fn temporal_extent_ignores_identifiers_pages_and_titles() {
3654 let value = "<http://www.essepuntato.it/2010/06/literalreification/hasLiteralValue>";
3655 let start = "<http://prismstandard.org/namespaces/basic/2.0/startingPage>";
3656 let end = "<http://prismstandard.org/namespaces/basic/2.0/endingPage>";
3657 let title = "<http://purl.org/dc/terms/title>";
3658 let pubdate = "<http://prismstandard.org/namespaces/basic/2.0/publicationDate>";
3659 let issued = "<http://purl.org/dc/terms/issued>";
3660 let xsd = |t: &str| format!("http://www.w3.org/2001/XMLSchema#{t}");
3661 let mut quads = Vec::new();
3662 for (i, id) in [
3663 "0000-0001-5001-3721",
3664 "0000-0002-1825-0097",
3665 "10.1000/182",
3666 "9999880",
3667 "2019",
3668 ]
3669 .iter()
3670 .enumerate()
3671 {
3672 quads.push(q(
3673 &format!("<http://ex/id/{i}>"),
3674 value,
3675 &format!("\"{id}\""),
3676 ));
3677 }
3678 quads.push(q("<http://ex/re/1>", start, "\"9999880\""));
3679 quads.push(q("<http://ex/re/1>", end, "\"9999899\""));
3680 quads.push(q("<http://ex/re/2>", start, "\"1234-1240\""));
3681 quads.push(q("<http://ex/br/1>", title, "\"2019 annual report\""));
3682 quads.push(q(
3683 "<http://ex/br/1>",
3684 pubdate,
3685 &format!("\"2019-05-01\"^^<{}>", xsd("date")),
3686 ));
3687 quads.push(q(
3688 "<http://ex/br/2>",
3689 pubdate,
3690 &format!("\"1950\"^^<{}>", xsd("gYear")),
3691 ));
3692 quads.push(q(
3693 "<http://ex/br/3>",
3694 pubdate,
3695 &format!("\"2021-03\"^^<{}>", xsd("gYearMonth")),
3696 ));
3697 quads.push(q(
3699 "<http://ex/br/4>",
3700 pubdate,
3701 &format!("\"0000-0001-5001-3721\"^^<{}>", xsd("date")),
3702 ));
3703 quads.push(q("<http://ex/br/5>", issued, "\"1999-12\""));
3705 let card = derive_card(&quads, 40, 0, CardInput::default());
3706 let s = &card.signals;
3707 assert_eq!(
3708 s.time_predicates,
3709 vec![pubdate.to_string(), issued.to_string()],
3710 "only the date predicates are time predicates"
3711 );
3712 assert_eq!(
3713 s.temporal_extent,
3714 Some(("1950".to_string(), "2021-03".to_string())),
3715 "calendar order across xsd:gYear, xsd:date and xsd:gYearMonth"
3716 );
3717 assert!(
3719 s.numeric_predicates.is_empty(),
3720 "{:?}",
3721 s.numeric_predicates
3722 );
3723 assert!(card
3725 .queries
3726 .iter()
3727 .filter(|q| q.dimension == "time")
3728 .all(|q| q.sparql.contains("publicationDate")));
3729 }
3730
3731 #[test]
3732 fn negative_years_order_by_value_not_by_bytes() {
3733 let mut extent = BTreeMap::new();
3734 for v in ["-0044", "-0100", "0001", "-0044-03-15"] {
3735 update_time_extent("<p>", v, &mut extent);
3736 }
3737 assert_eq!(
3738 extent.get("<p>"),
3739 Some(&("-0100".to_string(), "0001".to_string()))
3740 );
3741 }
3742
3743 #[test]
3746 fn an_integer_year_off_a_date_predicate_is_numeric() {
3747 let int = "http://www.w3.org/2001/XMLSchema#integer";
3748 let quads = vec![
3749 q(
3750 "<http://ex/a>",
3751 "<http://ex/year>",
3752 &format!("\"1492\"^^<{int}>"),
3753 ),
3754 q(
3755 "<http://ex/b>",
3756 "<http://ex/year>",
3757 &format!("\"1500\"^^<{int}>"),
3758 ),
3759 ];
3760 let card = derive_card(&quads, 6, 0, CardInput::default());
3761 assert!(card.signals.time_predicates.is_empty());
3762 assert_eq!(
3763 card.signals.numeric_predicates,
3764 vec!["<http://ex/year>".to_string()]
3765 );
3766 }
3767
3768 fn streaming_fixture() -> Vec<(String, String, String, Option<String>)> {
3777 let xsd = |t: &str| format!("http://www.w3.org/2001/XMLSchema#{t}");
3778 let mut v = enriched_fixture();
3779 for i in 0..5 {
3780 let s = format!("<http://ex/place/{i}>");
3781 v.push(q(&s, TYPE, "<http://ex/Place>"));
3782 v.push(q(&s, LABEL, &format!("\"Lieu {i}\"@fr")));
3783 v.push(q(
3784 &s,
3785 "<http://www.w3.org/2003/01/geo/wgs84_pos#lat>",
3786 &format!("\"4{i}.5\"^^<{}>", xsd("double")),
3787 ));
3788 v.push(q(
3789 &s,
3790 "<http://www.w3.org/2003/01/geo/wgs84_pos#long>",
3791 &format!("\"{i}.25\"^^<{}>", xsd("double")),
3792 ));
3793 v.push(q(
3794 &s,
3795 "<http://www.opengis.net/ont/geosparql#asWKT>",
3796 &format!(
3797 "\"POINT({i}.25 4{i}.5)\"^^<http://www.opengis.net/ont/geosparql#wktLiteral>"
3798 ),
3799 ));
3800 v.push(q(
3801 &s,
3802 "<http://purl.org/dc/terms/created>",
3803 &format!("\"20{i}0-01-0{}\"^^<{}>", i + 1, xsd("date")),
3804 ));
3805 v.push(q(&s, "<http://ex/near>", "<http://elsewhere/undescribed>"));
3806 }
3807 let knows = "<http://ex/a> <http://ex/knows> <http://ex/b>";
3810 let lives = "<http://ex/a> <http://ex/livesIn> <http://ex/c>";
3811 let ghost = "<http://ex/b> <http://ex/knows> <http://ex/c>";
3812 for (t, who) in [(knows, "alice"), (lives, "bob"), (ghost, "carol")] {
3813 v.push(q(
3814 &format!("<<{t}>>"),
3815 "<http://ex/recordedBy>",
3816 &format!("<http://ex/{who}>"),
3817 ));
3818 }
3819 v.push(q(
3820 "<http://ex/claim/1>",
3821 "<http://ex/states>",
3822 &format!("<<{knows}>>"),
3823 ));
3824 v.push((
3825 "<http://ex/note/1>".into(),
3826 "<http://ex/about>".into(),
3827 "<http://ex/a>".into(),
3828 Some("<http://ex/graph/notes>".into()),
3829 ));
3830 v
3831 }
3832
3833 fn both_ways(quads: &[(String, String, String, Option<String>)]) -> (DatasetCard, DatasetCard) {
3836 let (bytes, _) =
3837 crate::ingest::assemble_dataset_with_opts(quads.to_vec(), true, false, None, |_, _| {
3838 Vec::new()
3839 });
3840 let rete = crate::Rete::open(&bytes).unwrap();
3841 let term_count = rete.header().term_count;
3842 let (card, report) =
3843 derive_card_streaming(&rete, CardInput::default(), &mut |_| {}).unwrap();
3844 assert_eq!(report.statements, card.triple_count);
3845 let in_memory = derive_card(
3846 quads,
3847 term_count,
3848 rete.named_graph_count() as u64,
3849 CardInput::default(),
3850 );
3851 (card, in_memory)
3852 }
3853
3854 #[test]
3858 fn streaming_derivation_matches_the_in_memory_card() {
3859 for quads in [enriched_fixture(), streaming_fixture()] {
3860 let (streamed, in_memory) = both_ways(&quads);
3861 assert_eq!(
3862 String::from_utf8(streamed.to_json_bytes()).unwrap(),
3863 String::from_utf8(in_memory.to_json_bytes()).unwrap()
3864 );
3865 }
3866 let (card, _) = both_ways(&streaming_fixture());
3868 assert!(card.signals.annotated_statement.is_some());
3869 assert!(!card.signals.quoting_predicates.is_empty());
3870 assert!(card.signals.spatial_bbox.is_some());
3871 assert!(card.signals.temporal_extent.is_some());
3872 assert_eq!(card.named_graph_count, 1);
3873 }
3874
3875 #[test]
3878 fn streaming_derivation_widens_the_class_map() {
3879 let mut quads = Vec::new();
3880 for i in 0..300 {
3881 let s = format!("<http://ex/thing/{i:03}>");
3882 quads.push(q(&s, TYPE, &format!("<http://ex/Class{i:03}>")));
3883 quads.push(q(
3884 &s,
3885 "<http://ex/next>",
3886 &format!("<http://ex/thing/{:03}>", (i + 1) % 300),
3887 ));
3888 }
3889 let (streamed, in_memory) = both_ways(&quads);
3890 assert!(streamed.truncated);
3891 assert_eq!(streamed.classes, in_memory.classes);
3892 assert_eq!(streamed.class_links, in_memory.class_links);
3893 assert_eq!(
3894 String::from_utf8(streamed.to_json_bytes()).unwrap(),
3895 String::from_utf8(in_memory.to_json_bytes()).unwrap()
3896 );
3897 }
3898
3899 #[test]
3902 fn streaming_derivation_counts_the_deduplicated_file() {
3903 let mut quads = enriched_fixture();
3904 quads.push(quads[0].clone());
3905 let (streamed, in_memory) = both_ways(&quads);
3906 assert_eq!(streamed.triple_count, in_memory.triple_count - 1);
3907 let type_count = |c: &DatasetCard| {
3908 c.predicates
3909 .iter()
3910 .find(|(p, _)| p == TYPE)
3911 .map(|(_, n)| *n)
3912 };
3913 assert_eq!(type_count(&streamed), type_count(&in_memory).map(|n| n - 1));
3914 }
3915
3916 #[test]
3919 fn the_curated_half_of_a_card_round_trips() {
3920 let doc = r##"{"title":"T","description":["# Heading","line"],"license":"CC0-1.0",
3921 "source":"https://ex/src","created":"2026-01-01","version":"1",
3922 "creators":[{"name":"Ada","orcid":"https://orcid.org/0000-0002-1825-0097"}],
3923 "publisher":{"name":"P","ror":"https://ror.org/01ggx4157"},
3924 "canonical_url":"https://ex/f.rete","sparql_endpoint":"https://ex/sparql",
3925 "source_date":"2025-12-31","derived_from":["https://ex/dump"],
3926 "doi":"https://doi.org/10.1/x","cite_as":"Cite me","keywords":["b","a"],
3927 "theme":["http://publications.europa.eu/resource/authority/data-theme/EDUC"],
3928 "extra":{"k":[{"words":["x"]}]},"example_queries":["ASK {}"]}"##;
3929 let curated = CardInput::from_json_str(doc).unwrap();
3930 assert!(!curated.is_empty());
3931 let card = derive_card(&enriched_fixture(), 12, 0, curated);
3932 let again = derive_card(&enriched_fixture(), 12, 0, CardInput::from_card(&card));
3933 assert_eq!(again.to_json_bytes(), card.to_json_bytes());
3934 assert!(CardInput::from_card(&derive_card(
3935 &enriched_fixture(),
3936 12,
3937 0,
3938 CardInput::default()
3939 ))
3940 .is_empty());
3941 }
3942}