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 if header.version == crate::v06::VERSION {
589 return match crate::v06::open::read_signals(reader) {
590 Ok((Some((bytes, tt)), _)) => TextIndexSignal {
591 present: true,
592 bytes,
593 token_table_bytes: Some(tt),
594 },
595 _ => TextIndexSignal {
596 present: true,
597 ..TextIndexSignal::default()
598 },
599 };
600 }
601 TextIndexSignal {
602 present: true,
603 bytes: header.text_index_len,
604 token_table_bytes: crate::read_text_index_token_table_len_ranged(reader, header),
605 }
606 }
607
608 pub fn describe(&self) -> String {
610 if !self.present {
611 return "no TEXT_INDEX section — CONTAINS/regex still answer, by full scan".to_string();
612 }
613 match self.token_table_bytes {
614 Some(tt) => format!(
615 "TEXT_INDEX present — {} bytes ({tt} of them the token table a first search reads)",
616 self.bytes
617 ),
618 None => format!("TEXT_INDEX present — {} bytes", self.bytes),
619 }
620 }
621}
622
623#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
634pub struct LabelIndexSignal {
635 pub present: bool,
638 #[serde(default, skip_serializing_if = "is_zero_u64")]
640 pub bytes: u64,
641 #[serde(default, skip_serializing_if = "Option::is_none")]
644 pub entries: Option<u64>,
645}
646
647impl LabelIndexSignal {
648 pub fn probe<R: crate::RangeReader + ?Sized>(reader: &R, header: &Header) -> Self {
652 let Some(s) = header.label_index_section() else {
653 return LabelIndexSignal::default();
654 };
655 if header.version == crate::v06::VERSION {
656 return match crate::v06::open::read_signals(reader) {
657 Ok((_, Some((bytes, entries)))) => LabelIndexSignal {
658 present: true,
659 bytes,
660 entries,
661 },
662 _ => LabelIndexSignal {
663 present: true,
664 ..LabelIndexSignal::default()
665 },
666 };
667 }
668 LabelIndexSignal {
669 present: true,
670 bytes: s.length,
671 entries: crate::label_index::read_label_index_entries_ranged(reader, header),
672 }
673 }
674
675 pub fn describe(&self) -> String {
677 if !self.present {
678 return "no LABEL_INDEX section — a prefix search finds only what a pyramid's \
679 legacy label block holds, if any"
680 .to_string();
681 }
682 match self.entries {
683 Some(n) => format!("LABEL_INDEX present — {n} labels, {} bytes", self.bytes),
684 None => format!("LABEL_INDEX present — {} bytes", self.bytes),
685 }
686 }
687}
688
689#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
696pub struct Coherence {
697 pub coherent: bool,
699 pub inconsistency_count: u32,
701 #[serde(default, skip_serializing_if = "Vec::is_empty")]
703 pub by_kind: Vec<(String, u32)>,
704 pub scope: String,
706 pub rules: String,
708 pub materialized: bool,
710}
711
712impl Coherence {
713 pub fn is_empty(&self) -> bool {
715 *self == Coherence::default()
716 }
717
718 pub fn from_reasoning(r: &crate::Reasoning, materialized: bool) -> Self {
722 let mut hist: BTreeMap<&str, u32> = BTreeMap::new();
723 for inc in &r.inconsistencies {
724 *hist.entry(inc.kind).or_default() += 1;
725 }
726 Coherence {
727 coherent: r.inconsistencies.is_empty(),
728 inconsistency_count: r.inconsistencies.len() as u32,
729 by_kind: hist.into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
730 scope: "default".to_string(),
731 rules: crate::REASON_RULESET.to_string(),
732 materialized,
733 }
734 }
735}
736
737#[derive(Debug, Clone, Default, Deserialize)]
749#[serde(deny_unknown_fields)]
750pub struct CardInput {
751 pub title: Option<String>,
752 #[serde(default, deserialize_with = "de_description")]
756 pub description: Option<String>,
757 pub license: Option<String>,
758 pub source: Option<String>,
759 pub created: Option<String>,
760 pub version: Option<String>,
761 #[serde(default)]
762 pub creators: Vec<Creator>,
763 pub publisher: Option<Publisher>,
764 pub canonical_url: Option<String>,
765 pub sparql_endpoint: Option<String>,
766 pub source_date: Option<String>,
767 #[serde(default)]
768 pub derived_from: Vec<String>,
769 pub doi: Option<String>,
770 pub cite_as: Option<String>,
771 #[serde(default)]
773 pub keywords: Vec<String>,
774 #[serde(default)]
776 pub theme: Vec<String>,
777 #[serde(default)]
779 pub extra: BTreeMap<String, serde_json::Value>,
780 #[serde(default)]
781 pub example_queries: Vec<String>,
782 pub base_iri: Option<String>,
790}
791
792fn de_description<'de, D>(d: D) -> Result<Option<String>, D::Error>
802where
803 D: serde::Deserializer<'de>,
804{
805 use serde::de::Error as _;
806 let v = Option::<serde_json::Value>::deserialize(d)?;
807 match v {
808 None | Some(serde_json::Value::Null) => Ok(None),
809 Some(v) => crate::card_input::normalize_description(&v)
810 .map(Some)
811 .map_err(D::Error::custom),
812 }
813}
814
815impl CardInput {
816 pub fn from_card(card: &DatasetCard) -> Self {
822 CardInput {
823 title: card.title.clone(),
824 description: card.description.clone(),
825 license: card.license.clone(),
826 source: card.source.clone(),
827 created: card.created.clone(),
828 version: card.version.clone(),
829 creators: card.creators.clone(),
830 publisher: card.publisher.clone(),
831 canonical_url: card.canonical_url.clone(),
832 sparql_endpoint: card.sparql_endpoint.clone(),
833 source_date: card.source_date.clone(),
834 derived_from: card.derived_from.clone(),
835 doi: card.doi.clone(),
836 cite_as: card.cite_as.clone(),
837 keywords: card.keywords.clone(),
838 theme: card.theme.clone(),
839 extra: card.extra.clone(),
840 example_queries: card.example_queries.clone(),
841 base_iri: None,
842 }
843 }
844
845 pub fn is_empty(&self) -> bool {
847 self.title.is_none()
848 && self.description.is_none()
849 && self.license.is_none()
850 && self.source.is_none()
851 && self.created.is_none()
852 && self.version.is_none()
853 && self.creators.is_empty()
854 && self.publisher.is_none()
855 && self.canonical_url.is_none()
856 && self.sparql_endpoint.is_none()
857 && self.source_date.is_none()
858 && self.derived_from.is_empty()
859 && self.doi.is_none()
860 && self.cite_as.is_none()
861 && self.keywords.is_empty()
862 && self.theme.is_empty()
863 && self.extra.is_empty()
864 && self.example_queries.is_empty()
865 && self.base_iri.is_none()
866 }
867
868 fn fill(self, card: &mut DatasetCard) {
871 card.title = self.title;
872 card.description = self.description;
873 card.license = self.license;
874 card.source = self.source;
875 card.created = self.created;
876 card.version = self.version;
877 card.creators = self.creators;
878 card.publisher = self.publisher;
879 card.canonical_url = self.canonical_url;
880 card.sparql_endpoint = self.sparql_endpoint;
881 card.source_date = self.source_date;
882 card.derived_from = self.derived_from;
883 card.doi = self.doi;
884 card.cite_as = self.cite_as;
885 card.keywords = self.keywords;
886 card.theme = self.theme;
887 card.extra = self.extra;
888 card.example_queries = self.example_queries;
889 if self.base_iri.is_some() {
890 card.signals.base_iri = self.base_iri;
891 }
892 }
893
894 pub fn from_json_str(text: &str) -> Result<Self, String> {
908 let parsed: CardInput = serde_json::from_str(text).map_err(|e| {
909 let hint = if e.to_string().contains("unknown field") {
912 crate::card_input::UNKNOWN_FIELD_HINT
913 } else {
914 ""
915 };
916 format!("{e}{hint}")
917 })?;
918 parsed.normalize()
919 }
920
921 pub fn normalize(mut self) -> Result<Self, String> {
930 if let Some(d) = &self.description {
931 crate::card_input::check_description_len(d)?;
932 }
933 self.keywords = crate::card_input::normalize_string_list(
934 "keywords",
935 std::mem::take(&mut self.keywords),
936 )?;
937 self.theme = crate::card_input::normalize_themes(std::mem::take(&mut self.theme))?;
938 let bag: serde_json::Map<String, serde_json::Value> =
939 std::mem::take(&mut self.extra).into_iter().collect();
940 self.extra = crate::card_input::normalize_extra(bag)?
941 .into_iter()
942 .collect();
943 if let Some(b) = &self.base_iri {
944 crate::card_input::check_base_iri(b)?;
945 }
946 Ok(self)
947 }
948}
949
950impl DatasetCard {
951 pub fn to_json_bytes(&self) -> Vec<u8> {
962 if self.signals.text_index.is_none()
963 && self.signals.label_index.is_none()
964 && self.signals.permutations.is_none()
965 && self.signals.quoted_triples.is_none()
966 {
967 return serde_json::to_vec(self).expect("DatasetCard serializes");
968 }
969 let mut stored = self.clone();
970 stored.signals.text_index = None;
971 stored.signals.label_index = None;
972 stored.signals.permutations = None;
973 stored.signals.quoted_triples = None;
974 serde_json::to_vec(&stored).expect("DatasetCard serializes")
975 }
976
977 pub fn observe_text_index(&mut self, measured: TextIndexSignal) -> Option<TextIndexSignal> {
984 self.signals.text_index.replace(measured)
985 }
986
987 pub fn observe_label_index(&mut self, measured: LabelIndexSignal) -> Option<LabelIndexSignal> {
990 self.signals.label_index.replace(measured)
991 }
992
993 pub fn observe_permutations(
998 &mut self,
999 measured: PermutationsSignal,
1000 ) -> Option<PermutationsSignal> {
1001 self.signals.permutations.replace(measured)
1002 }
1003
1004 pub fn observe_quoted_triples(
1009 &mut self,
1010 measured: QuotedTriplesSignal,
1011 ) -> Option<QuotedTriplesSignal> {
1012 self.signals.quoted_triples.replace(measured)
1013 }
1014
1015 pub fn from_json_bytes(b: &[u8]) -> Result<Self, String> {
1021 serde_json::from_slice(b).map_err(|e| format!("malformed dataset card: {e}"))
1022 }
1023
1024 pub fn with_coherence(mut self, r: &crate::Reasoning, materialized: bool) -> Self {
1026 self.coherence = Coherence::from_reasoning(r, materialized);
1027 self
1028 }
1029
1030 pub fn with_final_counts(mut self, counts: crate::ingest::FinalCounts) -> Self {
1044 self.triple_count = counts.default_triples;
1045 self.quad_count = counts.quads;
1046 self
1047 }
1048}
1049
1050pub const CARD_TOP_N: usize = 100;
1056
1057const ANNOTATION_WITNESS_SAMPLE: usize = 4096;
1073
1074const RDFS_LABEL: &str = "<http://www.w3.org/2000/01/rdf-schema#label>";
1076const SKOS_PREFLABEL: &str = "<http://www.w3.org/2004/02/skos/core#prefLabel>";
1077const SCHEMA_NAME: &str = "<http://schema.org/name>";
1078const FOAF_NAME: &str = "<http://xmlns.com/foaf/0.1/name>";
1079const DCT_TITLE: &str = "<http://purl.org/dc/terms/title>";
1080const OWL_SAMEAS: &str = "<http://www.w3.org/2002/07/owl#sameAs>";
1081const SKOS_EXACTMATCH: &str = "<http://www.w3.org/2004/02/skos/core#exactMatch>";
1082const SKOS_CLOSEMATCH: &str = "<http://www.w3.org/2004/02/skos/core#closeMatch>";
1083const RDFS_SEEALSO: &str = "<http://www.w3.org/2000/01/rdf-schema#seeAlso>";
1084pub(crate) const WGS_LAT: &str = "<http://www.w3.org/2003/01/geo/wgs84_pos#lat>";
1085pub(crate) const WGS_LONG: &str = "<http://www.w3.org/2003/01/geo/wgs84_pos#long>";
1086pub const GEO_ASWKT: &str = "<http://www.opengis.net/ont/geosparql#asWKT>";
1089pub const GEO_HASGEOMETRY: &str = "<http://www.opengis.net/ont/geosparql#hasGeometry>";
1090const GEO_WKTLITERAL: &str = "http://www.opengis.net/ont/geosparql#wktLiteral";
1092const XSD_STRING: &str = "http://www.w3.org/2001/XMLSchema#string";
1093const RDF_LANGSTRING: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString";
1094
1095pub const O_LITERAL: &str = "(literal)";
1100
1101const LABEL_PREDICATES: &[&str] = &[
1103 SKOS_PREFLABEL,
1104 RDFS_LABEL,
1105 SCHEMA_NAME,
1106 FOAF_NAME,
1107 DCT_TITLE,
1108];
1109pub(crate) const LINK_PREDICATES: &[&str] =
1111 &[OWL_SAMEAS, SKOS_EXACTMATCH, SKOS_CLOSEMATCH, RDFS_SEEALSO];
1112
1113pub trait CardTripleSource {
1120 fn replay(&self, f: &mut dyn FnMut(&str, &str, &str));
1121}
1122
1123pub fn curated_counts_card(statements: u64, term_count: u64, curated: CardInput) -> DatasetCard {
1132 let mut card = DatasetCard {
1133 triple_count: statements,
1134 quad_count: statements,
1135 named_graph_count: 0,
1136 term_count,
1137 format_version: crate::format::LEGACY_FORMAT_VERSION,
1138 ..DatasetCard::default()
1139 };
1140 curated.fill(&mut card);
1141 card
1142}
1143
1144pub fn derive_card(
1145 quads: &[(String, String, String, Option<String>)],
1146 term_count: u64,
1147 named_graph_count: u64,
1148 curated: CardInput,
1149) -> DatasetCard {
1150 if crate::build_progress::listening() {
1151 let default = quads.iter().filter(|q| q.3.is_none()).count() as u64;
1152 crate::build_progress::set_total(Some(2 * default), crate::build_progress::Unit::Records);
1153 }
1154 struct Quads<'a>(&'a [(String, String, String, Option<String>)]);
1155 impl CardTripleSource for Quads<'_> {
1156 fn replay(&self, f: &mut dyn FnMut(&str, &str, &str)) {
1157 for (s, p, o, g) in self.0 {
1158 if g.is_none() {
1159 f(s, p, o);
1160 }
1161 }
1162 }
1163 }
1164 derive_card_from(
1165 &Quads(quads),
1166 quads.len() as u64,
1167 term_count,
1168 named_graph_count,
1169 curated,
1170 )
1171}
1172
1173pub fn derive_card_encoded(
1179 dict: &crate::Dictionary,
1180 triples: &[(u32, u32, u32)],
1181 quad_count: u64,
1182 term_count: u64,
1183 named_graph_count: u64,
1184 curated: CardInput,
1185) -> DatasetCard {
1186 crate::build_progress::set_total(
1187 Some(2 * triples.len() as u64),
1188 crate::build_progress::Unit::Records,
1189 );
1190 struct Encoded<'a> {
1191 dict: &'a crate::Dictionary,
1192 triples: &'a [(u32, u32, u32)],
1193 }
1194 impl CardTripleSource for Encoded<'_> {
1195 fn replay(&self, f: &mut dyn FnMut(&str, &str, &str)) {
1196 for &(s, p, o) in self.triples {
1197 let st = self.dict.subject_term(s).unwrap_or_default();
1198 let pt = self.dict.predicate_term(p).unwrap_or_default();
1199 let ot = self.dict.object_term(o).unwrap_or_default();
1200 f(&st, &pt, &ot);
1201 }
1202 }
1203 }
1204 derive_card_from(
1205 &Encoded { dict, triples },
1206 quad_count,
1207 term_count,
1208 named_graph_count,
1209 curated,
1210 )
1211}
1212
1213pub fn derive_card_from(
1221 src: &dyn CardTripleSource,
1222 quad_count: u64,
1223 term_count: u64,
1224 named_graph_count: u64,
1225 curated: CardInput,
1226) -> DatasetCard {
1227 let mut class_of: std::collections::HashMap<String, String> = std::collections::HashMap::new();
1233 let mut quoted_subject_preds: BTreeSet<String> = BTreeSet::new();
1234 let mut annotated_tokens: BTreeMap<String, String> = BTreeMap::new();
1235 let mut ticker = crate::build_progress::Ticker::new();
1237 src.replay(&mut |s, p, o| {
1238 ticker.tick(1);
1239 if p == RDF_TYPE {
1240 class_of.insert(s.to_string(), o.to_string());
1241 }
1242 if crate::terms::is_quoted_triple(s) {
1243 if let Some((_, quoted_p, _)) = crate::ingest::quoted_triple_parts(s) {
1244 quoted_subject_preds.insert(quoted_p);
1245 }
1246 if annotated_tokens.len() < ANNOTATION_WITNESS_SAMPLE {
1247 annotated_tokens
1248 .entry(s.to_string())
1249 .or_insert_with(|| p.to_string());
1250 }
1251 }
1252 });
1253 let classify = |t: &str| -> String {
1254 if let Some(c) = class_of.get(t) {
1255 c.to_string()
1256 } else if t.starts_with('"') {
1257 O_LITERAL.to_string()
1258 } else {
1259 O_UNTYPED.to_string()
1260 }
1261 };
1262
1263 let mut prof = Profile::default();
1269 let mut out_degree: BTreeMap<String, u64> = BTreeMap::new();
1270 let mut in_degree: BTreeMap<String, u64> = BTreeMap::new();
1271
1272 src.replay(&mut |s, p, o| {
1273 ticker.tick(1);
1274 prof.triple_count += 1;
1275 *prof.pred_counts.entry(p.to_string()).or_default() += 1;
1276 *out_degree.entry(s.to_string()).or_default() += 1;
1277 if let Some(ns) = split_namespace(s) {
1278 prof.subject_ns.add(ns, 1);
1279 }
1280
1281 if !o.starts_with('"') {
1285 *in_degree.entry(o.to_string()).or_default() += 1;
1286 }
1287
1288 if crate::terms::is_quoted_triple(s) {
1292 *prof.annotation_hits.entry(p.to_string()).or_default() += 1;
1293 }
1294 if crate::terms::is_quoted_triple(o) {
1295 *prof.quoting_hits.entry(p.to_string()).or_default() += 1;
1296 }
1297 if !quoted_subject_preds.is_empty() && quoted_subject_preds.contains(p) {
1304 if let Some(about) = annotated_tokens.get(&format!("<<{s} {p} {o}>>")) {
1305 *prof
1306 .witnessed_pairs
1307 .entry((p.to_string(), about.clone()))
1308 .or_default() += 1;
1309 }
1310 }
1311
1312 if p == RDF_TYPE {
1313 *prof.class_counts.entry(o.to_string()).or_default() += 1;
1314 return; }
1316
1317 *prof
1319 .link_rows
1320 .entry((classify(s), p.to_string(), classify(o)))
1321 .or_default() += 1;
1322
1323 if p == GEO_ASWKT || p == GEO_HASGEOMETRY {
1324 prof.geo_wkt = true;
1325 }
1326
1327 if let Some(lit) = parse_literal(o) {
1329 prof.literal(p, &lit, 1);
1330 }
1331 });
1332
1333 let mut truncated = false;
1334 let top_hubs = cap(sort_desc_owned(out_degree), &mut truncated);
1335 let in_hubs = cap(sort_desc_owned(in_degree), &mut truncated);
1336 finish(
1337 prof,
1338 Hubs {
1339 top_hubs,
1340 in_hubs,
1341 truncated,
1342 },
1343 quad_count,
1344 term_count,
1345 named_graph_count,
1346 curated,
1347 )
1348}
1349
1350const O_UNTYPED: &str = "(untyped)";
1353
1354#[derive(Default)]
1365struct Profile {
1366 triple_count: u64,
1367 pred_counts: BTreeMap<String, u64>,
1368 class_counts: BTreeMap<String, u64>,
1369 datatype_counts: BTreeMap<String, u64>,
1370 lang_counts: BTreeMap<String, u64>,
1371 link_rows: BTreeMap<(String, String, String), u64>,
1372 subject_ns: NsTally,
1373 time_obj_hits: BTreeMap<String, u64>,
1375 num_obj_hits: BTreeMap<String, u64>,
1377 annotation_hits: BTreeMap<String, u64>,
1382 quoting_hits: BTreeMap<String, u64>,
1383 witnessed_pairs: BTreeMap<(String, String), u64>,
1384 time_extent: BTreeMap<String, (String, String)>,
1386 lat: Option<(f64, f64)>,
1388 lon: Option<(f64, f64)>,
1389 geo_wkt: bool,
1390}
1391
1392impl Profile {
1393 fn literal(&mut self, p: &str, lit: &Literal, weight: u64) {
1397 *self
1398 .datatype_counts
1399 .entry(format!("<{}>", lit.datatype))
1400 .or_default() += weight;
1401 *self.lang_counts.entry(lit.lang.clone()).or_default() += weight;
1402 if lit.datatype == GEO_WKTLITERAL {
1403 self.geo_wkt = true;
1404 }
1405 if is_temporal(p, lit) {
1406 *self.time_obj_hits.entry(p.to_string()).or_default() += weight;
1407 update_time_extent(p, &lit.value, &mut self.time_extent);
1408 } else if is_numeric_datatype(&lit.datatype) {
1409 *self.num_obj_hits.entry(p.to_string()).or_default() += weight;
1412 }
1413 if p == WGS_LAT {
1415 if let Ok(v) = lit.value.parse::<f64>() {
1416 widen(&mut self.lat, v);
1417 }
1418 } else if p == WGS_LONG {
1419 if let Ok(v) = lit.value.parse::<f64>() {
1420 widen(&mut self.lon, v);
1421 }
1422 }
1423 }
1424}
1425
1426fn widen(extent: &mut Option<(f64, f64)>, v: f64) {
1428 *extent = Some(match *extent {
1429 None => (v, v),
1430 Some((lo, hi)) => (lo.min(v), hi.max(v)),
1431 });
1432}
1433
1434struct Hubs {
1436 top_hubs: Vec<(String, u64)>,
1437 in_hubs: Vec<(String, u64)>,
1438 truncated: bool,
1439}
1440
1441fn finish(
1447 p: Profile,
1448 hubs: Hubs,
1449 quad_count: u64,
1450 term_count: u64,
1451 named_graph_count: u64,
1452 curated: CardInput,
1453) -> DatasetCard {
1454 let Profile {
1455 triple_count,
1456 pred_counts,
1457 class_counts,
1458 datatype_counts,
1459 lang_counts,
1460 link_rows,
1461 subject_ns,
1462 time_obj_hits,
1463 num_obj_hits,
1464 annotation_hits,
1465 quoting_hits,
1466 witnessed_pairs,
1467 time_extent,
1468 lat,
1469 lon,
1470 geo_wkt,
1471 } = p;
1472 let pred_count = |iri: &str| pred_counts.get(iri).copied().unwrap_or(0);
1475 let label_predicate = LABEL_PREDICATES
1476 .iter()
1477 .filter(|&&iri| pred_count(iri) > 0)
1478 .max_by_key(|&&iri| pred_count(iri))
1479 .map(|s| s.to_string());
1480 let link_predicates: Vec<String> = LINK_PREDICATES
1481 .iter()
1482 .filter(|&&iri| pred_count(iri) > 0)
1483 .map(|s| s.to_string())
1484 .collect();
1485
1486 let mut truncated = hubs.truncated || subject_ns.overflowed();
1487 let predicates = cap(sort_desc_owned(pred_counts), &mut truncated);
1488 let classes = cap(sort_desc_owned(class_counts), &mut truncated);
1489 let datatypes = cap(sort_desc_owned(datatype_counts), &mut truncated);
1490 let languages = cap(sort_desc_owned(lang_counts), &mut truncated);
1491 let class_links = cap(sort_links(link_rows), &mut truncated);
1492
1493 let mut vocab: BTreeSet<String> = BTreeSet::new();
1495 for (iri, _) in predicates.iter().chain(classes.iter()) {
1496 if let Some(ns) = split_namespace(iri) {
1497 vocab.insert(ns);
1498 }
1499 }
1500 let vocabularies = cap(vocab.into_iter().collect(), &mut truncated);
1501
1502 let default_lang = languages
1504 .iter()
1505 .find(|(l, _)| !l.is_empty())
1506 .map(|(l, _)| l.clone());
1507 let base_iri = derive_base_iri(&subject_ns);
1508 let mut time_ranked: Vec<(String, u64)> = time_obj_hits.into_iter().collect();
1509 time_ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1510 let time_predicates = cap(
1512 time_ranked.iter().map(|(p, _)| p.to_string()).collect(),
1513 &mut truncated,
1514 );
1515 let mut num_ranked: Vec<(String, u64)> = num_obj_hits.into_iter().collect();
1516 num_ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1517 let numeric_predicates = cap(
1518 num_ranked.iter().map(|(p, _)| p.to_string()).collect(),
1519 &mut truncated,
1520 );
1521 let annotation_predicates = cap(
1527 sort_desc_owned(annotation_hits)
1528 .into_iter()
1529 .map(|(p, _)| p)
1530 .collect(),
1531 &mut truncated,
1532 );
1533 let quoting_predicates = cap(
1534 sort_desc_owned(quoting_hits)
1535 .into_iter()
1536 .map(|(p, _)| p)
1537 .collect(),
1538 &mut truncated,
1539 );
1540 let annotated_statement = witnessed_pairs
1546 .into_iter()
1547 .max_by(|a, b| a.1.cmp(&b.1).then_with(|| b.0.cmp(&a.0)))
1548 .map(|(pair, _)| pair);
1549 let geo_latlong = lat.is_some() && lon.is_some();
1550 let temporal_extent = time_ranked
1551 .first()
1552 .and_then(|(p, _)| time_extent.get(p))
1553 .cloned();
1554 let spatial_bbox = match (lat, lon) {
1555 (Some((lat_lo, lat_hi)), Some((lon_lo, lon_hi)))
1556 if lon_lo.is_finite() && lat_lo.is_finite() =>
1557 {
1558 Some([lon_lo, lat_lo, lon_hi, lat_hi])
1559 }
1560 _ => None,
1561 };
1562
1563 let signals = Signals {
1564 label_predicate,
1565 default_lang,
1566 base_iri,
1567 time_predicates,
1568 numeric_predicates,
1569 link_predicates,
1570 annotation_predicates,
1571 quoting_predicates,
1572 annotated_statement,
1573 geo_wkt,
1574 geo_latlong,
1575 temporal_extent,
1576 spatial_bbox,
1577 text_index: None,
1581 label_index: None,
1582 permutations: None,
1583 quoted_triples: None,
1584 };
1585
1586 let mut card = DatasetCard {
1587 triple_count,
1588 quad_count,
1589 named_graph_count,
1590 term_count,
1591 predicates,
1592 classes,
1593 vocabularies,
1594 datatypes,
1595 languages,
1596 class_links,
1597 top_hubs: hubs.top_hubs,
1598 in_hubs: hubs.in_hubs,
1599 signals,
1600 queries: Vec::new(),
1601 coherence: Coherence::default(),
1604 truncated,
1605 top_n: CARD_TOP_N as u32,
1608 format_version: crate::LEGACY_FORMAT_VERSION,
1609 ..DatasetCard::default()
1610 };
1611 curated.fill(&mut card);
1612 card.queries = crate::card_queries::generate(&card);
1614 card
1615}
1616
1617fn cap<T>(mut v: Vec<T>, truncated: &mut bool) -> Vec<T> {
1619 if v.len() > CARD_TOP_N {
1620 v.truncate(CARD_TOP_N);
1621 *truncated = true;
1622 }
1623 v
1624}
1625
1626fn sort_desc_owned(counts: BTreeMap<String, u64>) -> Vec<(String, u64)> {
1628 let mut v: Vec<(String, u64)> = counts.into_iter().collect();
1629 v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1630 v
1631}
1632
1633fn sort_links(rows: BTreeMap<(String, String, String), u64>) -> Vec<ClassLink> {
1636 let mut v: Vec<ClassLink> = rows
1637 .into_iter()
1638 .map(|((s, p, o), count)| ClassLink {
1639 s_class: s,
1640 predicate: p,
1641 o_class: o,
1642 count,
1643 })
1644 .collect();
1645 v.sort_by(|a, b| {
1646 b.count
1647 .cmp(&a.count)
1648 .then_with(|| a.s_class.cmp(&b.s_class))
1649 .then_with(|| a.predicate.cmp(&b.predicate))
1650 .then_with(|| a.o_class.cmp(&b.o_class))
1651 });
1652 v
1653}
1654
1655fn derive_base_iri(ns: &NsTally) -> Option<String> {
1662 let dominant = ns.dominant()?;
1664 let lcp = ns.common_prefix().unwrap_or("");
1667 let lcp = &lcp[..lcp.len().min(dominant.len())];
1668 let base = match lcp.rfind(['/', '#']) {
1670 Some(i) => &lcp[..=i],
1671 None => "",
1672 };
1673 if base.matches('/').count() >= 3 {
1676 Some(base.to_string())
1677 } else {
1678 Some(dominant)
1679 }
1680}
1681
1682pub const NS_TALLY_CAP: usize = 65_536;
1687
1688pub const NS_TALLY_ENTRY_BYTES: u64 = 256;
1691
1692pub(crate) struct NsTally {
1700 cap: usize,
1701 counts: BTreeMap<String, u64>,
1702 order: Option<BTreeSet<(u64, String)>>,
1704 lcp: Option<String>,
1705 overflowed: bool,
1706}
1707
1708impl Default for NsTally {
1709 fn default() -> Self {
1710 Self::with_cap(NS_TALLY_CAP)
1711 }
1712}
1713
1714impl NsTally {
1715 pub(crate) fn with_cap(cap: usize) -> Self {
1716 NsTally {
1717 cap: cap.max(1),
1718 counts: BTreeMap::new(),
1719 order: None,
1720 lcp: None,
1721 overflowed: false,
1722 }
1723 }
1724
1725 pub(crate) fn len(&self) -> usize {
1726 self.counts.len()
1727 }
1728
1729 pub(crate) fn overflowed(&self) -> bool {
1731 self.overflowed
1732 }
1733
1734 pub(crate) fn common_prefix(&self) -> Option<&str> {
1735 self.lcp.as_deref()
1736 }
1737
1738 pub(crate) fn dominant(&self) -> Option<String> {
1739 self.counts
1740 .iter()
1741 .max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
1742 .map(|(k, _)| k.clone())
1743 }
1744
1745 pub(crate) fn add(&mut self, ns: String, n: u64) {
1746 match &mut self.lcp {
1747 None => self.lcp = Some(ns.clone()),
1748 Some(l) if !ns.starts_with(l.as_str()) => {
1749 let mut cut = l
1750 .bytes()
1751 .zip(ns.bytes())
1752 .take_while(|(a, b)| a == b)
1753 .count();
1754 while !l.is_char_boundary(cut) {
1755 cut -= 1;
1756 }
1757 l.truncate(cut);
1758 }
1759 Some(_) => {}
1760 }
1761 if let Some(c) = self.counts.get_mut(&ns) {
1762 let old = *c;
1763 *c += n;
1764 if let Some(order) = &mut self.order {
1765 order.remove(&(old, ns.clone()));
1766 order.insert((*c, ns));
1767 }
1768 return;
1769 }
1770 if self.counts.len() < self.cap {
1771 if let Some(order) = &mut self.order {
1772 order.insert((n, ns.clone()));
1773 }
1774 self.counts.insert(ns, n);
1775 return;
1776 }
1777 self.overflowed = true;
1779 let counts = &self.counts;
1780 let order = self
1781 .order
1782 .get_or_insert_with(|| counts.iter().map(|(k, &v)| (v, k.clone())).collect());
1783 let Some((min, victim)) = order.pop_first() else {
1784 return;
1785 };
1786 self.counts.remove(&victim);
1787 let c = min + n;
1788 order.insert((c, ns.clone()));
1789 self.counts.insert(ns, c);
1790 }
1791}
1792
1793struct Literal {
1795 value: String,
1797 datatype: String,
1800 lang: String,
1802}
1803
1804fn parse_literal(term: &str) -> Option<Literal> {
1807 let bytes = term.as_bytes();
1808 if bytes.first() != Some(&b'"') {
1809 return None;
1810 }
1811 let mut i = 1usize;
1813 let mut esc = false;
1814 let mut close = None;
1815 while i < bytes.len() {
1816 match bytes[i] {
1817 b'\\' => esc = !esc,
1818 b'"' if !esc => {
1819 close = Some(i);
1820 break;
1821 }
1822 _ => esc = false,
1823 }
1824 i += 1;
1825 }
1826 let close = close?;
1827 let value = term[1..close].to_string();
1828 let suffix = &term[close + 1..];
1829 if let Some(lang) = suffix.strip_prefix('@') {
1830 Some(Literal {
1831 value,
1832 datatype: RDF_LANGSTRING.to_string(),
1833 lang: lang.to_string(),
1834 })
1835 } else if let Some(dt) = suffix.strip_prefix("^^") {
1836 let dt = dt.trim_start_matches('<').trim_end_matches('>').to_string();
1837 Some(Literal {
1838 value,
1839 datatype: dt,
1840 lang: String::new(),
1841 })
1842 } else {
1843 Some(Literal {
1844 value,
1845 datatype: XSD_STRING.to_string(),
1846 lang: String::new(),
1847 })
1848 }
1849}
1850
1851fn is_temporal_datatype(dt: &str) -> bool {
1855 matches!(
1856 dt,
1857 "http://www.w3.org/2001/XMLSchema#date"
1858 | "http://www.w3.org/2001/XMLSchema#dateTime"
1859 | "http://www.w3.org/2001/XMLSchema#dateTimeStamp"
1860 | "http://www.w3.org/2001/XMLSchema#gYear"
1861 | "http://www.w3.org/2001/XMLSchema#gYearMonth"
1862 )
1863}
1864
1865const DATE_PREDICATES: &[&str] = &[
1870 "<http://purl.org/dc/terms/date>",
1871 "<http://purl.org/dc/terms/issued>",
1872 "<http://purl.org/dc/terms/created>",
1873 "<http://purl.org/dc/terms/modified>",
1874 "<http://purl.org/dc/terms/available>",
1875 "<http://purl.org/dc/terms/dateAccepted>",
1876 "<http://purl.org/dc/terms/dateCopyrighted>",
1877 "<http://purl.org/dc/terms/dateSubmitted>",
1878 "<http://purl.org/dc/elements/1.1/date>",
1879 "<http://schema.org/datePublished>",
1880 "<http://schema.org/dateCreated>",
1881 "<http://schema.org/dateModified>",
1882 "<http://schema.org/startDate>",
1883 "<http://schema.org/endDate>",
1884 "<http://schema.org/birthDate>",
1885 "<http://schema.org/deathDate>",
1886 "<http://schema.org/foundingDate>",
1887 "<http://schema.org/uploadDate>",
1888 "<https://schema.org/datePublished>",
1889 "<https://schema.org/dateCreated>",
1890 "<https://schema.org/dateModified>",
1891 "<https://schema.org/startDate>",
1892 "<https://schema.org/endDate>",
1893 "<https://schema.org/birthDate>",
1894 "<https://schema.org/deathDate>",
1895 "<http://prismstandard.org/namespaces/basic/2.0/publicationDate>",
1896 "<http://prismstandard.org/namespaces/basic/2.0/coverDate>",
1897 "<http://www.w3.org/ns/prov#generatedAtTime>",
1898 "<http://www.w3.org/ns/prov#invalidatedAtTime>",
1899 "<http://www.w3.org/ns/prov#startedAtTime>",
1900 "<http://www.w3.org/ns/prov#endedAtTime>",
1901 "<http://www.w3.org/ns/prov#atTime>",
1902 "<http://www.w3.org/2006/time#inXSDDate>",
1903 "<http://www.w3.org/2006/time#inXSDDateTime>",
1904 "<http://www.w3.org/2006/time#inXSDDateTimeStamp>",
1905 "<http://www.w3.org/2006/time#inXSDgYear>",
1906 "<http://www.w3.org/2006/time#inXSDgYearMonth>",
1907 "<http://dbpedia.org/ontology/birthDate>",
1908 "<http://dbpedia.org/ontology/deathDate>",
1909 "<http://dbpedia.org/ontology/releaseDate>",
1910 "<http://www.wikidata.org/prop/direct/P569>",
1911 "<http://www.wikidata.org/prop/direct/P570>",
1912 "<http://www.wikidata.org/prop/direct/P571>",
1913 "<http://www.wikidata.org/prop/direct/P577>",
1914 "<http://www.wikidata.org/prop/direct/P580>",
1915 "<http://www.wikidata.org/prop/direct/P582>",
1916 "<http://www.wikidata.org/prop/direct/P585>",
1917];
1918
1919fn is_temporal(p: &str, lit: &Literal) -> bool {
1933 if is_temporal_datatype(&lit.datatype) {
1934 return date_shaped(&lit.value);
1935 }
1936 DATE_PREDICATES.contains(&p) && date_shaped(&lit.value)
1937}
1938
1939fn date_shaped(v: &str) -> bool {
1947 let b = v.as_bytes();
1948 let digits =
1949 |at: usize, n: usize| b.len() >= at + n && b[at..at + n].iter().all(u8::is_ascii_digit);
1950 let two_in = |at: usize, lo: u8, hi: u8| {
1951 digits(at, 2) && {
1952 let n = (b[at] - b'0') * 10 + (b[at + 1] - b'0');
1953 (lo..=hi).contains(&n)
1954 }
1955 };
1956 let mut i = usize::from(b.first() == Some(&b'-'));
1957 if !digits(i, 4) {
1958 return false;
1959 }
1960 i += 4;
1961 if b.get(i) == Some(&b'-') {
1962 if !two_in(i + 1, 1, 12) {
1963 return false;
1964 }
1965 i += 3;
1966 if b.get(i) == Some(&b'-') {
1967 if !two_in(i + 1, 1, 31) {
1968 return false;
1969 }
1970 i += 3;
1971 if b.get(i) == Some(&b'T') {
1972 if !(two_in(i + 1, 0, 24) && b.get(i + 3) == Some(&b':') && two_in(i + 4, 0, 59)) {
1973 return false;
1974 }
1975 i += 6;
1976 if b.get(i) == Some(&b':') {
1977 if !two_in(i + 1, 0, 60) {
1978 return false;
1979 }
1980 i += 3;
1981 if b.get(i) == Some(&b'.') {
1982 let start = i + 1;
1983 i = start;
1984 while b.get(i).is_some_and(u8::is_ascii_digit) {
1985 i += 1;
1986 }
1987 if i == start {
1988 return false;
1989 }
1990 }
1991 }
1992 }
1993 }
1994 }
1995 match &b[i..] {
1996 [] | [b'Z'] => true,
1997 [sign, h1, h2, b':', m1, m2] => {
1998 (*sign == b'+' || *sign == b'-') && [h1, h2, m1, m2].iter().all(|c| c.is_ascii_digit())
1999 }
2000 _ => false,
2001 }
2002}
2003
2004fn is_numeric_datatype(dt: &str) -> bool {
2006 matches!(
2007 dt,
2008 "http://www.w3.org/2001/XMLSchema#integer"
2009 | "http://www.w3.org/2001/XMLSchema#decimal"
2010 | "http://www.w3.org/2001/XMLSchema#double"
2011 | "http://www.w3.org/2001/XMLSchema#float"
2012 | "http://www.w3.org/2001/XMLSchema#long"
2013 | "http://www.w3.org/2001/XMLSchema#int"
2014 | "http://www.w3.org/2001/XMLSchema#short"
2015 | "http://www.w3.org/2001/XMLSchema#nonNegativeInteger"
2016 | "http://www.w3.org/2001/XMLSchema#positiveInteger"
2017 )
2018}
2019
2020fn temporal_key(v: &str) -> (i64, &str) {
2025 let (neg, rest) = match v.strip_prefix('-') {
2026 Some(r) => (true, r),
2027 None => (false, v),
2028 };
2029 let n = rest.bytes().take_while(u8::is_ascii_digit).count();
2030 let year: i64 = rest[..n].parse().unwrap_or(0);
2031 (if neg { -year } else { year }, &rest[n..])
2032}
2033
2034fn update_time_extent(pred: &str, value: &str, extent: &mut BTreeMap<String, (String, String)>) {
2040 match extent.get_mut(pred) {
2041 None => {
2042 extent.insert(pred.to_string(), (value.to_string(), value.to_string()));
2043 }
2044 Some((lo, hi)) => {
2045 if temporal_key(value) < temporal_key(lo) {
2046 *lo = value.to_string();
2047 }
2048 if temporal_key(value) > temporal_key(hi) {
2049 *hi = value.to_string();
2050 }
2051 }
2052 }
2053}
2054
2055#[derive(Debug, Clone, Default)]
2083pub struct StreamReport {
2084 pub statements: u64,
2085 pub subjects: u64,
2086 pub literal_objects: u64,
2087 pub classes: u64,
2088 pub class_map_bytes: u64,
2089 pub namespaces_kept: u64,
2093 pub namespaces_overflowed: bool,
2094}
2095
2096struct ClassMap {
2109 data: Vec<u8>,
2110 width: usize,
2112}
2113
2114impl ClassMap {
2115 fn new(subjects: u64) -> Self {
2116 ClassMap {
2119 data: vec![0; subjects as usize + 1],
2120 width: 1,
2121 }
2122 }
2123
2124 fn len(&self) -> usize {
2125 self.data.len() / self.width
2126 }
2127
2128 fn read(data: &[u8], i: usize, width: usize) -> u32 {
2129 let at = i * width;
2130 match width {
2131 1 => data[at] as u32,
2132 2 => u16::from_le_bytes([data[at], data[at + 1]]) as u32,
2133 _ => u32::from_le_bytes([data[at], data[at + 1], data[at + 2], data[at + 3]]),
2134 }
2135 }
2136
2137 fn write(data: &mut [u8], i: usize, width: usize, code: u32) {
2138 let at = i * width;
2139 match width {
2140 1 => data[at] = code as u8,
2141 2 => data[at..at + 2].copy_from_slice(&(code as u16).to_le_bytes()),
2142 _ => data[at..at + 4].copy_from_slice(&code.to_le_bytes()),
2143 }
2144 }
2145
2146 fn get(&self, id: u32) -> u32 {
2147 let i = id as usize;
2148 if i < self.len() {
2149 Self::read(&self.data, i, self.width)
2150 } else {
2151 0
2152 }
2153 }
2154
2155 fn widen(&mut self, width: usize) {
2157 let (n, old) = (self.len(), self.width);
2158 if width <= old {
2159 return;
2160 }
2161 self.data.resize(n * width, 0);
2162 for i in (0..n).rev() {
2163 let v = Self::read(&self.data, i, old);
2164 Self::write(&mut self.data, i, width, v);
2165 }
2166 self.width = width;
2167 }
2168
2169 fn set(&mut self, id: u32, code: u32) {
2170 if code > u16::MAX as u32 {
2171 self.widen(4);
2172 } else if code > u8::MAX as u32 {
2173 self.widen(2);
2174 }
2175 let i = id as usize;
2176 if i < self.len() {
2177 Self::write(&mut self.data, i, self.width, code);
2178 }
2179 }
2180
2181 fn bytes(&self) -> u64 {
2182 self.data.len() as u64
2183 }
2184}
2185
2186struct TopN {
2192 heap: std::collections::BinaryHeap<std::cmp::Reverse<(u64, std::cmp::Reverse<u32>)>>,
2193 offered: u64,
2194}
2195
2196impl TopN {
2197 fn new() -> Self {
2198 TopN {
2199 heap: std::collections::BinaryHeap::with_capacity(CARD_TOP_N + 1),
2200 offered: 0,
2201 }
2202 }
2203
2204 fn offer(&mut self, id: u32, n: u64) {
2205 use std::cmp::Reverse;
2206 self.offered += 1;
2207 let key = Reverse((n, Reverse(id)));
2208 if self.heap.len() < CARD_TOP_N {
2209 self.heap.push(key);
2210 } else if let Some(worst) = self.heap.peek() {
2211 if (n, Reverse(id)) > worst.0 {
2213 self.heap.pop();
2214 self.heap.push(key);
2215 }
2216 }
2217 }
2218
2219 fn into_parts(self) -> (Vec<(u32, u64)>, bool) {
2221 let cut = self.offered > CARD_TOP_N as u64;
2222 let v = self
2223 .heap
2224 .into_iter()
2225 .map(|std::cmp::Reverse((n, std::cmp::Reverse(id)))| (id, n))
2226 .collect();
2227 (v, cut)
2228 }
2229}
2230
2231fn top_terms(
2235 tops: [TopN; 2],
2236 terms: &dyn Fn(&[u32]) -> Vec<Option<String>>,
2237) -> (Vec<(String, u64)>, bool) {
2238 let mut offered = 0u64;
2239 let mut ids: Vec<(u32, u64)> = Vec::new();
2240 for top in tops {
2241 offered += top.offered;
2242 ids.extend(top.into_parts().0);
2243 }
2244 let names = terms(&ids.iter().map(|&(id, _)| id).collect::<Vec<_>>());
2245 let mut v: Vec<(String, u64)> = ids
2246 .iter()
2247 .zip(names)
2248 .filter_map(|(&(_, n), t)| t.map(|t| (t, n)))
2249 .collect();
2250 v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
2251 v.truncate(CARD_TOP_N);
2252 (v, offered > CARD_TOP_N as u64)
2253}
2254
2255#[derive(Default, Clone, Copy)]
2258struct IdHasher(u64);
2259
2260impl std::hash::Hasher for IdHasher {
2261 fn finish(&self) -> u64 {
2262 self.0
2263 }
2264 fn write(&mut self, bytes: &[u8]) {
2265 for &b in bytes {
2266 self.0 = (self.0.rotate_left(5) ^ b as u64).wrapping_mul(0x517c_c1b7_2722_0a95);
2267 }
2268 }
2269 fn write_u32(&mut self, n: u32) {
2270 self.0 = (self.0.rotate_left(5) ^ n as u64).wrapping_mul(0x517c_c1b7_2722_0a95);
2271 }
2272}
2273
2274type IdBuild = std::hash::BuildHasherDefault<IdHasher>;
2275
2276fn first_at_or_above(lo: u32, hi: u32, key: &str, term: &dyn Fn(u32) -> Option<String>) -> u32 {
2280 let (mut a, mut b) = (lo as u64, hi as u64 + 1);
2281 while a < b {
2282 let mid = a + (b - a) / 2;
2283 match term(mid as u32) {
2284 Some(t) if t.as_str() < key => a = mid + 1,
2285 _ => b = mid,
2286 }
2287 }
2288 a as u32
2289}
2290
2291fn id_range(
2296 lo: u32,
2297 hi: u32,
2298 from: &str,
2299 to: &str,
2300 term: &dyn Fn(u32) -> Option<String>,
2301) -> (u32, u32) {
2302 if hi < lo {
2303 return (lo, lo);
2304 }
2305 (
2306 first_at_or_above(lo, hi, from, term),
2307 first_at_or_above(lo, hi, to, term),
2308 )
2309}
2310
2311fn in_any(id: u32, ranges: &[(u32, u32)]) -> bool {
2312 ranges.iter().any(|&(a, b)| a <= id && id < b)
2313}
2314
2315pub fn derive_card_streaming(
2334 rete: &crate::Rete,
2335 curated: CardInput,
2336 progress: &mut dyn FnMut(&str),
2337) -> Result<(DatasetCard, StreamReport), String> {
2338 use crate::index::IndexPermutation;
2339
2340 let header = rete.header();
2341 let dict = rete.dictionary();
2342 let index = rete.default_index();
2343 let shared = dict.shared_count();
2344 let subjects_end = shared as u64 + dict.subject_only_count() as u64;
2345 let objects_end = shared as u64 + dict.object_only_count() as u64;
2346 let n_preds = dict.predicate_count() as usize;
2347 let pred_terms: Vec<String> = (0..=n_preds as u32)
2348 .map(|p| {
2349 if p == 0 {
2350 String::new()
2351 } else {
2352 dict.predicate_term(p).unwrap_or_default()
2353 }
2354 })
2355 .collect();
2356 let type_id = dict.predicate_id(RDF_TYPE);
2357
2358 let object_term = |id: u32| dict.object_term(id);
2360 let subject_term = |id: u32| dict.subject_term(id);
2361 let s_end = subjects_end.min(u32::MAX as u64) as u32;
2362 let o_end = objects_end.min(u32::MAX as u64) as u32;
2363 let literal_ranges = [
2364 id_range(1, shared, "\"", "#", &object_term),
2365 id_range(shared + 1, o_end, "\"", "#", &object_term),
2366 ];
2367 let is_literal = |o: u32| in_any(o, &literal_ranges);
2368 let quoted = header.has_quoted_triples();
2369 let (quoted_subjects, quoted_objects) = if quoted {
2370 (
2371 vec![
2372 id_range(1, shared, "<<", "<=", &subject_term),
2373 id_range(shared + 1, s_end, "<<", "<=", &subject_term),
2374 ],
2375 vec![
2376 id_range(1, shared, "<<", "<=", &object_term),
2377 id_range(shared + 1, o_end, "<<", "<=", &object_term),
2378 ],
2379 )
2380 } else {
2381 (Vec::new(), Vec::new())
2382 };
2383
2384 let mut report = StreamReport::default();
2385
2386 progress("pass 1/3: classes (the rdf:type range of POS)");
2388 let mut class_map = ClassMap::new(subjects_end);
2389 let mut class_code: std::collections::HashMap<u32, u32, IdBuild> = Default::default();
2390 let mut class_ids: Vec<u32> = vec![0];
2391 let mut class_counts: Vec<u64> = vec![0];
2392 if let Some(t) = type_id {
2393 for (s, _, o) in index.scan_iter((None, Some(t), None)) {
2394 let next = class_ids.len() as u32;
2395 let code = *class_code.entry(o).or_insert_with(|| {
2396 class_ids.push(o);
2397 class_counts.push(0);
2398 next
2399 });
2400 class_counts[code as usize] += 1;
2401 class_map.set(s, code);
2402 }
2403 }
2404 report.classes = class_ids.len() as u64 - 1;
2405 report.class_map_bytes = class_map.bytes();
2406
2407 progress("pass 2/3: subjects (SPO)");
2409 const LITERAL_CODE: u32 = u32::MAX;
2410 const WINDOW: usize = 1 << 16;
2411 let mut prof = Profile::default();
2412 let mut pred_counts = vec![0u64; n_preds + 1];
2413 let mut annotation_hits = vec![0u64; n_preds + 1];
2414 let mut quoting_hits = vec![0u64; n_preds + 1];
2415 let mut links: std::collections::HashMap<(u32, u32, u32), u64, IdBuild> = Default::default();
2416 let mut out_top = [TopN::new(), TopN::new()];
2418 let mut sample: Vec<(u32, u32)> = Vec::new();
2419 let mut window: Vec<(u32, u64)> = Vec::with_capacity(WINDOW);
2420 let mut subject_ns = NsTally::default();
2421 let flush_ns = |window: &mut Vec<(u32, u64)>, subject_ns: &mut NsTally| {
2422 let ids: Vec<u32> = window.iter().map(|&(s, _)| s).collect();
2423 let terms = dict.subject_terms(&ids);
2424 let mut run: Option<(String, u64)> = None;
2427 for (term, &(_, n)) in terms.iter().zip(window.iter()) {
2428 let Some(ns) = term.as_deref().and_then(split_namespace) else {
2429 continue;
2430 };
2431 match &mut run {
2432 Some((cur, total)) if *cur == ns => *total += n,
2433 _ => {
2434 if let Some((cur, total)) = run.take() {
2435 subject_ns.add(cur, total);
2436 }
2437 run = Some((ns, n));
2438 }
2439 }
2440 }
2441 if let Some((cur, total)) = run {
2442 subject_ns.add(cur, total);
2443 }
2444 window.clear();
2445 };
2446 let mut cur_s = 0u32;
2447 let mut cur_n = 0u64;
2448 let mut ticker = crate::build_progress::Ticker::new();
2450 let stored = index.for_each_in_order(IndexPermutation::Spo, &mut |s, p, o| {
2451 prof.triple_count += 1;
2452 ticker.tick(1);
2453 if let Some(c) = pred_counts.get_mut(p as usize) {
2454 *c += 1;
2455 }
2456 if s != cur_s {
2457 if cur_n > 0 {
2458 out_top[usize::from(cur_s > shared)].offer(cur_s, cur_n);
2459 window.push((cur_s, cur_n));
2460 if window.len() >= WINDOW {
2461 flush_ns(&mut window, &mut subject_ns);
2462 }
2463 }
2464 if quoted && in_any(s, "ed_subjects) && sample.len() < ANNOTATION_WITNESS_SAMPLE {
2465 sample.push((s, p));
2466 }
2467 cur_s = s;
2468 cur_n = 0;
2469 }
2470 cur_n += 1;
2471 if quoted {
2472 if in_any(s, "ed_subjects) {
2473 if let Some(c) = annotation_hits.get_mut(p as usize) {
2474 *c += 1;
2475 }
2476 }
2477 if in_any(o, "ed_objects) {
2478 if let Some(c) = quoting_hits.get_mut(p as usize) {
2479 *c += 1;
2480 }
2481 }
2482 }
2483 if Some(p) == type_id {
2484 return;
2485 }
2486 let sc = class_map.get(s);
2487 let oc = if is_literal(o) {
2490 LITERAL_CODE
2491 } else if o <= shared {
2492 class_map.get(o)
2493 } else {
2494 0
2495 };
2496 *links.entry((sc, p, oc)).or_default() += 1;
2497 });
2498 if !stored {
2499 return Err("the file stores no SPO permutation".to_string());
2500 }
2501 if cur_n > 0 {
2502 out_top[usize::from(cur_s > shared)].offer(cur_s, cur_n);
2503 window.push((cur_s, cur_n));
2504 }
2505 if !window.is_empty() {
2506 flush_ns(&mut window, &mut subject_ns);
2507 }
2508 report.statements = prof.triple_count;
2509 report.subjects = out_top[0].offered + out_top[1].offered;
2510 drop(class_map);
2511
2512 progress("pass 3/3: objects (OSP)");
2514 let mut in_top = [TopN::new(), TopN::new()];
2515 let mut lit_window: Vec<(u32, Vec<(u32, u64)>)> = Vec::with_capacity(WINDOW);
2516 let mut lit_objects = 0u64;
2517 let flush_lits = |lit_window: &mut Vec<(u32, Vec<(u32, u64)>)>, prof: &mut Profile| {
2518 let ids: Vec<u32> = lit_window.iter().map(|(o, _)| *o).collect();
2519 let terms = dict.object_terms(&ids);
2520 for (term, (_, preds)) in terms.iter().zip(lit_window.iter()) {
2521 let Some(lit) = term.as_deref().and_then(parse_literal) else {
2522 continue;
2523 };
2524 for &(p, n) in preds {
2525 if let Some(pt) = pred_terms.get(p as usize) {
2526 prof.literal(pt, &lit, n);
2527 }
2528 }
2529 }
2530 lit_window.clear();
2531 };
2532 let mut cur_o = 0u32;
2533 let mut cur_n = 0u64;
2534 let mut cur_preds: Vec<(u32, u64)> = Vec::new();
2535 index.for_each_in_order(IndexPermutation::Osp, &mut |o, _s, p| {
2536 ticker.tick(1);
2537 if o != cur_o {
2538 if cur_n > 0 {
2539 if is_literal(cur_o) {
2540 lit_objects += 1;
2541 lit_window.push((cur_o, std::mem::take(&mut cur_preds)));
2542 if lit_window.len() >= WINDOW {
2543 flush_lits(&mut lit_window, &mut prof);
2544 }
2545 } else {
2546 in_top[usize::from(cur_o > shared)].offer(cur_o, cur_n);
2547 }
2548 }
2549 cur_o = o;
2550 cur_n = 0;
2551 cur_preds.clear();
2552 }
2553 cur_n += 1;
2554 if is_literal(o) {
2555 match cur_preds.iter_mut().find(|(q, _)| *q == p) {
2556 Some((_, n)) => *n += 1,
2557 None => cur_preds.push((p, 1)),
2558 }
2559 }
2560 });
2561 if cur_n > 0 {
2562 if is_literal(cur_o) {
2563 lit_objects += 1;
2564 lit_window.push((cur_o, std::mem::take(&mut cur_preds)));
2565 } else {
2566 in_top[usize::from(cur_o > shared)].offer(cur_o, cur_n);
2567 }
2568 }
2569 if !lit_window.is_empty() {
2570 flush_lits(&mut lit_window, &mut prof);
2571 }
2572 report.literal_objects = lit_objects;
2573
2574 if rete.index_incomplete() {
2575 return Err("a read failed part-way through the file; the card would be incomplete".into());
2576 }
2577
2578 progress("resolving the profile's terms");
2580 let class_terms: Vec<String> = class_ids
2581 .iter()
2582 .map(|&o| {
2583 if o == 0 {
2584 O_UNTYPED.to_string()
2585 } else {
2586 dict.object_term(o).unwrap_or_default()
2587 }
2588 })
2589 .collect();
2590 let class_name = |code: u32| -> String {
2591 if code == LITERAL_CODE {
2592 O_LITERAL.to_string()
2593 } else {
2594 class_terms
2595 .get(code as usize)
2596 .cloned()
2597 .unwrap_or_else(|| O_UNTYPED.to_string())
2598 }
2599 };
2600 for (p, &n) in pred_counts.iter().enumerate() {
2601 if n > 0 {
2602 prof.pred_counts.insert(pred_terms[p].clone(), n);
2603 }
2604 }
2605 for (code, &n) in class_counts.iter().enumerate().skip(1) {
2606 if n > 0 {
2607 *prof
2608 .class_counts
2609 .entry(class_terms[code].clone())
2610 .or_default() += n;
2611 }
2612 }
2613 for ((sc, p, oc), n) in links {
2614 let Some(pt) = pred_terms.get(p as usize) else {
2615 continue;
2616 };
2617 *prof
2618 .link_rows
2619 .entry((class_name(sc), pt.clone(), class_name(oc)))
2620 .or_default() += n;
2621 }
2622 for (p, &n) in annotation_hits.iter().enumerate() {
2623 if n > 0 {
2624 prof.annotation_hits.insert(pred_terms[p].clone(), n);
2625 }
2626 }
2627 for (p, &n) in quoting_hits.iter().enumerate() {
2628 if n > 0 {
2629 prof.quoting_hits.insert(pred_terms[p].clone(), n);
2630 }
2631 }
2632 report.namespaces_kept = subject_ns.len() as u64;
2633 report.namespaces_overflowed = subject_ns.overflowed();
2634 prof.subject_ns = subject_ns;
2635 if pred_counts
2636 .iter()
2637 .enumerate()
2638 .any(|(p, &n)| n > 0 && (pred_terms[p] == GEO_ASWKT || pred_terms[p] == GEO_HASGEOMETRY))
2639 {
2640 prof.geo_wkt = true;
2641 }
2642
2643 let sample_ids: Vec<u32> = sample.iter().map(|&(s, _)| s).collect();
2646 for (term, &(_, about)) in dict.subject_terms(&sample_ids).iter().zip(sample.iter()) {
2647 let Some((a, qp, b)) = term.as_deref().and_then(crate::ingest::quoted_triple_parts) else {
2648 continue;
2649 };
2650 if let Some((sid, pid, oid)) = dict.encode(&a, &qp, &b) {
2651 if index
2652 .scan_iter((Some(sid), Some(pid), Some(oid)))
2653 .next()
2654 .is_some()
2655 {
2656 if let Some(about) = pred_terms.get(about as usize) {
2657 *prof.witnessed_pairs.entry((qp, about.clone())).or_default() += 1;
2658 }
2659 }
2660 }
2661 }
2662
2663 let (top_hubs, cut_out) = top_terms(out_top, &|ids| dict.subject_terms(ids));
2664 let (in_hubs, cut_in) = top_terms(in_top, &|ids| dict.object_terms(ids));
2665
2666 let card = finish(
2667 prof,
2668 Hubs {
2669 top_hubs,
2670 in_hubs,
2671 truncated: cut_out || cut_in,
2672 },
2673 header.quad_count,
2674 header.term_count,
2675 rete.named_graph_count() as u64,
2676 curated,
2677 );
2678 Ok((card, report))
2679}
2680
2681fn split_namespace(term: &str) -> Option<String> {
2685 let iri = term.strip_prefix('<')?.strip_suffix('>')?;
2686 let cut = iri.rfind(['#', '/'])?;
2687 Some(iri[..=cut].to_string())
2688}
2689
2690pub fn load_card(bytes: &[u8]) -> Result<Option<DatasetCard>, String> {
2697 if crate::v06::open::is_v06(bytes) {
2698 return match crate::file::read_metadata_ranged(&crate::reader::SliceReader::new(bytes))
2699 .map_err(|e| e.to_string())?
2700 {
2701 Some(b) => Ok(Some(DatasetCard::from_json_bytes(&b)?)),
2702 None => Ok(None),
2703 };
2704 }
2705 let header = Header::from_bytes(bytes).map_err(|e| e.to_string())?;
2706 if header.metadata_len == 0 {
2707 return Ok(None);
2708 }
2709 let start = header.metadata_offset as usize;
2710 let end = start
2711 .checked_add(header.metadata_len as usize)
2712 .filter(|&e| e <= bytes.len())
2713 .ok_or_else(|| "metadata section out of bounds".to_string())?;
2714 Ok(Some(DatasetCard::from_json_bytes(&bytes[start..end])?))
2715}
2716
2717#[cfg(test)]
2718mod tests {
2719 use super::*;
2720 use crate::card_input::{CARD_EXTRA_MAX_BYTES, CARD_EXTRA_MAX_KEYS, CARD_EXTRA_MAX_KEY_BYTES};
2723
2724 const TYPE: &str = RDF_TYPE;
2725
2726 fn q(s: &str, p: &str, o: &str) -> (String, String, String, Option<String>) {
2727 (s.into(), p.into(), o.into(), None)
2728 }
2729
2730 #[test]
2731 fn card_json_round_trips() {
2732 let card = DatasetCard {
2733 title: Some("Demo".into()),
2734 license: Some("CC0".into()),
2735 triple_count: 3,
2736 quad_count: 3,
2737 term_count: 5,
2738 predicates: vec![("<http://ex/p>".into(), 2)],
2739 vocabularies: vec!["http://ex/".into()],
2740 format_version: 1,
2741 ..Default::default()
2742 };
2743 let bytes = card.to_json_bytes();
2744 let back = DatasetCard::from_json_bytes(&bytes).unwrap();
2745 assert_eq!(card, back);
2746 let text = String::from_utf8(bytes).unwrap();
2748 assert!(!text.contains("description"));
2749 assert!(!text.contains("classes"));
2750 }
2751
2752 #[test]
2758 fn the_permutation_signal_is_stripped_from_the_stored_card() {
2759 let measured = PermutationsSignal {
2760 count: 3,
2761 names: vec!["SPO".into(), "POS".into(), "OSP".into()],
2762 merge_join: false,
2763 };
2764 let mut card = DatasetCard {
2765 title: Some("Lean".into()),
2766 triple_count: 3,
2767 ..Default::default()
2768 };
2769 assert_eq!(card.observe_permutations(measured.clone()), None);
2770 assert_eq!(card.signals.permutations, Some(measured.clone()));
2771
2772 let bytes = card.to_json_bytes();
2773 let text = String::from_utf8(bytes.clone()).unwrap();
2774 assert!(
2775 !text.contains("permutations"),
2776 "the signal must not reach the metadata section: {text}"
2777 );
2778 let back = DatasetCard::from_json_bytes(&bytes).unwrap();
2779 assert_eq!(back.signals.permutations, None, "unknown, not `six`");
2780 assert_eq!(back.title, card.title);
2781
2782 let authored = r#"{"title":"x","signals":{"permutations":{"count":6}}}"#;
2785 assert!(
2786 serde_json::from_str::<CardInput>(authored).is_err(),
2787 "a hand-written card must not be able to assert a permutation set"
2788 );
2789 }
2790
2791 #[test]
2795 fn the_permutation_signal_is_derived_from_the_header() {
2796 let quads = vec![
2797 q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>"),
2798 q("<http://ex/b>", "<http://ex/p>", "<http://ex/c>"),
2799 ];
2800 let (six, _) = crate::ingest::assemble_dataset_with_perms(
2801 quads.clone(),
2802 false,
2803 false,
2804 None,
2805 crate::PyramidAlgo::Louvain,
2806 crate::PermSet::ALL,
2807 |_, _| Vec::new(),
2808 );
2809 let (three, _) = crate::ingest::assemble_dataset_with_perms(
2810 quads,
2811 false,
2812 false,
2813 None,
2814 crate::PyramidAlgo::Louvain,
2815 crate::PermSet::CORE,
2816 |_, _| Vec::new(),
2817 );
2818 assert_eq!(six[50], 0);
2821 assert_eq!(three[50], 0b0000_0111);
2822
2823 let h6 = crate::Header::from_bytes(&six).unwrap();
2824 let h3 = crate::Header::from_bytes(&three).unwrap();
2825 let s6 = PermutationsSignal::probe(&h6);
2826 let s3 = PermutationsSignal::probe(&h3);
2827 assert_eq!(s6.count, 6);
2828 assert!(s6.merge_join);
2829 assert_eq!(s3.count, 3);
2830 assert!(!s3.merge_join);
2831 assert_eq!(s3.names, vec!["SPO", "POS", "OSP"]);
2832 assert!(s3.describe().contains("no sort-merge join"));
2833 assert!(three.len() < six.len(), "{} vs {}", three.len(), six.len());
2835 assert_eq!(h3.dictionary_len, h6.dictionary_len);
2836 assert!(h3.root_dir_len < h6.root_dir_len);
2837 }
2838
2839 #[test]
2844 fn the_text_index_signal_is_stripped_from_the_stored_card() {
2845 let measured = TextIndexSignal {
2847 present: true,
2848 bytes: 1_879_287_762,
2849 token_table_bytes: Some(193_295_361),
2850 };
2851 let mut card = DatasetCard {
2852 title: Some("Indexed".into()),
2853 triple_count: 3,
2854 ..Default::default()
2855 };
2856 assert_eq!(card.observe_text_index(measured), None);
2857 assert_eq!(card.signals.text_index, Some(measured));
2858
2859 let bytes = card.to_json_bytes();
2860 let text = String::from_utf8(bytes.clone()).unwrap();
2861 assert!(
2862 !text.contains("text_index"),
2863 "the signal must not reach the metadata section: {text}"
2864 );
2865 let back = DatasetCard::from_json_bytes(&bytes).unwrap();
2868 assert_eq!(back.title, card.title);
2869 assert_eq!(back.signals.text_index, None);
2870
2871 assert_eq!(card.signals.text_index, Some(measured));
2874 }
2875
2876 #[test]
2882 fn the_text_index_signal_round_trips_through_a_card_document() {
2883 let mut card = DatasetCard {
2884 title: Some("Indexed".into()),
2885 ..Default::default()
2886 };
2887 card.observe_text_index(TextIndexSignal {
2888 present: true,
2889 bytes: 80,
2890 token_table_bytes: Some(68),
2891 });
2892 let doc = serde_json::to_vec(&card).unwrap();
2893 let back: DatasetCard = serde_json::from_slice(&doc).unwrap();
2894 assert_eq!(back.signals.text_index, card.signals.text_index);
2895
2896 let mut none = DatasetCard::default();
2900 none.observe_text_index(TextIndexSignal::default());
2901 let text = serde_json::to_string(&none).unwrap();
2902 assert!(text.contains(r#""text_index":{"present":false}"#), "{text}");
2903 let absent = serde_json::to_string(&DatasetCard::default()).unwrap();
2904 assert!(!absent.contains("text_index"), "{absent}");
2905 }
2906
2907 #[test]
2914 fn the_quoted_triple_signal_is_derived_from_the_header() {
2915 let plain = vec![
2916 q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>"),
2917 q("<http://ex/b>", "<http://ex/p>", "<http://ex/c>"),
2918 ];
2919 let quoted = vec![
2920 q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>"),
2921 q(
2922 "<http://ex/claim>",
2923 "<http://ex/states>",
2924 "<<<http://ex/a> <http://ex/p> <http://ex/b>>>",
2925 ),
2926 ];
2927 let (no, _) = crate::ingest::assemble_dataset(plain, &[]);
2928 let (yes, _) = crate::ingest::assemble_dataset(quoted, &[]);
2929
2930 let hn = crate::Header::from_bytes(&no).unwrap();
2931 let hy = crate::Header::from_bytes(&yes).unwrap();
2932 assert!(!hn.has_quoted_triples());
2933 assert!(hy.has_quoted_triples());
2934
2935 let sn = QuotedTriplesSignal::probe(&hn);
2936 let sy = QuotedTriplesSignal::probe(&hy);
2937 assert!(!sn.present);
2938 assert!(sy.present);
2939
2940 assert_eq!(sn.describe(), None);
2943 let line = sy.describe().expect("a file with quoted triples says so");
2947 assert!(line.contains("<<( s p o )>>"), "{line}");
2948 assert!(line.contains(QUOTED_TRIPLE_SURFACE_RDF_STAR), "{line}");
2949 assert_eq!(
2950 sy.export_surfaces,
2951 vec![QUOTED_TRIPLE_SURFACE_RDF12, QUOTED_TRIPLE_SURFACE_RDF_STAR]
2952 );
2953 assert_eq!(
2954 sy.export_default.as_deref(),
2955 Some(QUOTED_TRIPLE_SURFACE_RDF12)
2956 );
2957 assert!(sn.export_surfaces.is_empty());
2959 assert_eq!(sn.export_default, None);
2960 }
2961
2962 #[test]
2967 fn the_quoted_triple_signal_is_stripped_from_the_stored_card() {
2968 let measured = QuotedTriplesSignal {
2969 present: true,
2970 export_surfaces: vec![
2971 QUOTED_TRIPLE_SURFACE_RDF12.to_string(),
2972 QUOTED_TRIPLE_SURFACE_RDF_STAR.to_string(),
2973 ],
2974 export_default: Some(QUOTED_TRIPLE_SURFACE_RDF12.to_string()),
2975 };
2976 let mut card = DatasetCard {
2977 title: Some("Annotated".into()),
2978 triple_count: 3,
2979 ..Default::default()
2980 };
2981 assert_eq!(card.observe_quoted_triples(measured.clone()), None);
2982 assert_eq!(card.signals.quoted_triples, Some(measured.clone()));
2983
2984 let bytes = card.to_json_bytes();
2985 let text = String::from_utf8(bytes.clone()).unwrap();
2986 assert!(
2987 !text.contains("quoted_triples"),
2988 "the signal must not reach the metadata section: {text}"
2989 );
2990 let back = DatasetCard::from_json_bytes(&bytes).unwrap();
2991 assert_eq!(back.title, card.title);
2992 assert_eq!(back.signals.quoted_triples, None);
2993 assert_eq!(card.signals.quoted_triples, Some(measured));
2995
2996 let authored = r#"{"title":"x","signals":{"quoted_triples":{"present":true}}}"#;
2999 assert!(
3000 serde_json::from_str::<CardInput>(authored).is_err(),
3001 "a hand-written card must not be able to assert a quoted-triple set"
3002 );
3003 }
3004
3005 #[test]
3010 fn the_quoted_triple_signal_round_trips_through_a_card_document() {
3011 let mut card = DatasetCard {
3012 title: Some("Annotated".into()),
3013 ..Default::default()
3014 };
3015 card.observe_quoted_triples(QuotedTriplesSignal {
3016 present: true,
3017 export_surfaces: vec![QUOTED_TRIPLE_SURFACE_RDF12.to_string()],
3018 export_default: Some(QUOTED_TRIPLE_SURFACE_RDF12.to_string()),
3019 });
3020 let doc = serde_json::to_vec(&card).unwrap();
3021 let back: DatasetCard = serde_json::from_slice(&doc).unwrap();
3022 assert_eq!(back.signals.quoted_triples, card.signals.quoted_triples);
3023
3024 let mut none = DatasetCard::default();
3025 none.observe_quoted_triples(QuotedTriplesSignal {
3026 present: false,
3027 export_surfaces: Vec::new(),
3028 export_default: None,
3029 });
3030 let text = serde_json::to_string(&none).unwrap();
3031 assert!(
3032 text.contains(r#""quoted_triples":{"present":false}"#),
3033 "{text}"
3034 );
3035 let absent = serde_json::to_string(&DatasetCard::default()).unwrap();
3036 assert!(!absent.contains("quoted_triples"), "{absent}");
3037 }
3038 #[test]
3039 fn split_namespace_handles_hash_slash_and_neither() {
3040 assert_eq!(
3041 split_namespace("<http://xmlns.com/foaf/0.1/name>").as_deref(),
3042 Some("http://xmlns.com/foaf/0.1/")
3043 );
3044 assert_eq!(
3045 split_namespace("<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>").as_deref(),
3046 Some("http://www.w3.org/1999/02/22-rdf-syntax-ns#")
3047 );
3048 assert_eq!(split_namespace("<urn:isbn:12345>"), None);
3050 assert_eq!(split_namespace("\"literal\""), None);
3051 assert_eq!(split_namespace("_:b0"), None);
3052 }
3053
3054 #[test]
3055 fn derive_counts_and_orders() {
3056 let quads = vec![
3057 q("<http://ex/a>", TYPE, "<http://ex/Person>"),
3058 q("<http://ex/b>", TYPE, "<http://ex/Person>"),
3059 q("<http://ex/c>", TYPE, "<http://ex/City>"),
3060 q("<http://ex/a>", "<http://ex/knows>", "<http://ex/b>"),
3061 q("<http://ex/a>", "<http://ex/knows>", "<http://ex/c>"),
3062 q("<http://ex/a>", "<http://ex/name>", "\"A\""),
3063 ];
3064 let card = derive_card(&quads, 9, 0, CardInput::default());
3065 assert_eq!(card.triple_count, 6);
3066 assert_eq!(card.quad_count, 6);
3067 assert_eq!(card.term_count, 9);
3068 assert_eq!(card.predicates[0], (TYPE.to_string(), 3));
3070 assert!(card
3071 .predicates
3072 .contains(&("<http://ex/knows>".to_string(), 2)));
3073 assert_eq!(
3075 card.classes,
3076 vec![
3077 ("<http://ex/Person>".to_string(), 2),
3078 ("<http://ex/City>".to_string(), 1)
3079 ]
3080 );
3081 assert!(card.vocabularies.contains(&"http://ex/".to_string()));
3083 assert!(card
3084 .vocabularies
3085 .iter()
3086 .any(|v| v.ends_with("rdf-syntax-ns#")));
3087 }
3088
3089 #[test]
3090 fn named_graph_triples_excluded_from_default_stats() {
3091 let quads = vec![
3092 q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>"),
3093 (
3094 "<http://ex/c>".into(),
3095 "<http://ex/p>".into(),
3096 "<http://ex/d>".into(),
3097 Some("<http://ex/g1>".into()),
3098 ),
3099 ];
3100 let card = derive_card(&quads, 5, 1, CardInput::default());
3101 assert_eq!(card.triple_count, 1, "only the default-graph triple counts");
3102 assert_eq!(card.quad_count, 2);
3103 assert_eq!(card.named_graph_count, 1);
3104 assert_eq!(card.predicates[0].1, 1);
3105 }
3106
3107 const LABEL: &str = "<http://www.w3.org/2000/01/rdf-schema#label>";
3109 const SAMEAS: &str = "<http://www.w3.org/2002/07/owl#sameAs>";
3110 const XSD_INT: &str = "http://www.w3.org/2001/XMLSchema#integer";
3111 const XSD_GYEAR: &str = "http://www.w3.org/2001/XMLSchema#gYear";
3112
3113 fn enriched_fixture() -> Vec<(String, String, String, Option<String>)> {
3114 vec![
3115 q("<http://ex/a>", TYPE, "<http://ex/Person>"),
3116 q("<http://ex/b>", TYPE, "<http://ex/Person>"),
3117 q("<http://ex/c>", TYPE, "<http://ex/City>"),
3118 q("<http://ex/a>", LABEL, "\"Alice\"@en"),
3119 q("<http://ex/b>", LABEL, "\"Bob\"@en"),
3120 q(
3121 "<http://ex/a>",
3122 "<http://ex/age>",
3123 &format!("\"30\"^^<{XSD_INT}>"),
3124 ),
3125 q(
3126 "<http://ex/a>",
3127 "<http://ex/birthYear>",
3128 &format!("\"1850\"^^<{XSD_GYEAR}>"),
3129 ),
3130 q(
3131 "<http://ex/b>",
3132 "<http://ex/birthYear>",
3133 &format!("\"1875\"^^<{XSD_GYEAR}>"),
3134 ),
3135 q("<http://ex/a>", "<http://ex/knows>", "<http://ex/b>"),
3136 q("<http://ex/a>", "<http://ex/livesIn>", "<http://ex/c>"),
3137 q("<http://ex/a>", SAMEAS, "<http://other/a>"),
3138 ]
3139 }
3140
3141 #[test]
3142 fn enriched_profile_is_populated() {
3143 let quads = enriched_fixture();
3144 let card = derive_card(&quads, 12, 0, CardInput::default());
3145
3146 assert!(!card.datatypes.is_empty(), "datatypes derived");
3147 assert!(!card.languages.is_empty(), "languages derived");
3148 assert!(!card.class_links.is_empty(), "class_links derived");
3149 assert!(!card.top_hubs.is_empty(), "out-degree hubs derived");
3150 assert!(!card.in_hubs.is_empty(), "in-degree hubs derived");
3151
3152 assert!(card
3154 .class_links
3155 .iter()
3156 .any(|l| l.s_class == "<http://ex/Person>"
3157 && l.predicate == "<http://ex/knows>"
3158 && l.o_class == "<http://ex/Person>"
3159 && l.count == 1));
3160 assert!(card
3161 .class_links
3162 .iter()
3163 .any(|l| l.o_class == "<http://ex/City>"));
3164 assert!(card.class_links.iter().any(|l| l.o_class == "(untyped)"));
3166
3167 assert!(card.datatypes.iter().any(|(d, _)| d.contains("integer")));
3169 assert!(card.datatypes.iter().any(|(d, _)| d.contains("gYear")));
3170 assert!(card.datatypes.iter().any(|(d, _)| d.contains("langString")));
3171 assert!(card.languages.iter().any(|(l, _)| l == "en"));
3173
3174 let s = &card.signals;
3176 assert_eq!(s.label_predicate.as_deref(), Some(LABEL));
3177 assert_eq!(s.default_lang.as_deref(), Some("en"));
3178 assert_eq!(s.base_iri.as_deref(), Some("http://ex/"));
3179 assert!(s.link_predicates.iter().any(|p| p.contains("sameAs")));
3180 assert!(s.time_predicates.iter().any(|p| p.contains("birthYear")));
3181 assert_eq!(
3182 s.temporal_extent,
3183 Some(("1850".to_string(), "1875".to_string())),
3184 "extent over the dominant time predicate"
3185 );
3186 assert!(!s.geo_wkt && !s.geo_latlong);
3188 assert!(s.spatial_bbox.is_none());
3189 }
3190
3191 #[test]
3192 fn signals_detect_geometry() {
3193 let wkt = "http://www.opengis.net/ont/geosparql#wktLiteral";
3194 let quads = vec![
3195 q("<http://ex/p>", TYPE, "<http://ex/Place>"),
3196 q(
3197 "<http://ex/p>",
3198 "<http://www.w3.org/2003/01/geo/wgs84_pos#lat>",
3199 "\"41.9\"^^<http://www.w3.org/2001/XMLSchema#double>",
3200 ),
3201 q(
3202 "<http://ex/p>",
3203 "<http://www.w3.org/2003/01/geo/wgs84_pos#long>",
3204 "\"12.5\"^^<http://www.w3.org/2001/XMLSchema#double>",
3205 ),
3206 q(
3207 "<http://ex/p>",
3208 "<http://www.opengis.net/ont/geosparql#asWKT>",
3209 &format!("\"POINT(12.5 41.9)\"^^<{wkt}>"),
3210 ),
3211 ];
3212 let card = derive_card(&quads, 6, 0, CardInput::default());
3213 assert!(card.signals.geo_wkt, "asWKT/wktLiteral detected");
3214 assert!(card.signals.geo_latlong, "wgs84 lat+long detected");
3215 let bbox = card.signals.spatial_bbox.expect("bbox derived");
3216 assert_eq!(
3217 bbox,
3218 [12.5, 41.9, 12.5, 41.9],
3219 "[minLon,minLat,maxLon,maxLat]"
3220 );
3221 }
3222
3223 #[test]
3224 fn class_links_match_schema_summary() {
3225 use crate::{ingest, schema_summary, Rete};
3226 let quads = enriched_fixture();
3227 let card = derive_card(&quads, 12, 0, CardInput::default());
3228
3229 let (bytes, _) =
3231 ingest::assemble_dataset_with_opts(quads, true, false, None, |_, _| Vec::new());
3232 let rete = Rete::open(&bytes).unwrap();
3233 let reference: BTreeSet<(String, String, String, u64)> = schema_summary(&rete)
3234 .into_iter()
3235 .map(|(s, p, o, c)| (s, p, o, c as u64))
3236 .collect();
3237
3238 let derived: BTreeSet<(String, String, String, u64)> = card
3239 .class_links
3240 .iter()
3241 .map(|l| {
3242 (
3243 l.s_class.clone(),
3244 l.predicate.clone(),
3245 l.o_class.clone(),
3246 l.count,
3247 )
3248 })
3249 .collect();
3250
3251 assert_eq!(
3252 derived, reference,
3253 "card class_links must equal schema_summary's quotient row-for-row"
3254 );
3255 }
3256
3257 #[test]
3267 fn card_counts_are_the_deduplicated_counts() {
3268 use crate::ingest::{self, DeferredMetadata};
3269 use crate::Rete;
3270
3271 let g = |s: &str, p: &str, o: &str, graph: &str| {
3272 (
3273 s.to_string(),
3274 p.to_string(),
3275 o.to_string(),
3276 Some(graph.to_string()),
3277 )
3278 };
3279 let quads = vec![
3280 q("<http://ex/a>", "<http://ex/p>", "<http://ex/1>"),
3281 q("<http://ex/b>", "<http://ex/p>", "<http://ex/2>"),
3282 q("<http://ex/a>", "<http://ex/p>", "<http://ex/1>"), g("<http://ex/n>", "<http://ex/q>", "\"v\"", "<http://ex/g>"),
3284 g("<http://ex/n>", "<http://ex/q>", "\"v\"", "<http://ex/g>"), ];
3286 let (bytes, stats) =
3288 ingest::assemble_dataset_with_opts(quads, false, false, None, |stats, quads| {
3289 let card = derive_card(
3290 quads,
3291 stats.terms as u64,
3292 stats.named_graphs as u64,
3293 CardInput::default(),
3294 );
3295 DeferredMetadata::new(move |counts| card.with_final_counts(counts).to_json_bytes())
3296 });
3297
3298 let header = crate::Header::from_bytes(&bytes).unwrap();
3299 let card = load_card(&bytes).unwrap().expect("card embedded");
3300 assert_eq!(header.quad_count, 3, "header records the deduped quads");
3301 assert_eq!(
3302 card.quad_count, header.quad_count,
3303 "card and header must not disagree about the size of the same file"
3304 );
3305 assert_eq!(card.triple_count, 2, "default-graph triples, deduped");
3306
3307 let rete = Rete::open(&bytes).unwrap();
3309 let default_triples = rete.dump(None).len() as u64;
3310 let named: u64 = rete
3311 .graph_names()
3312 .iter()
3313 .map(|name| rete.dump(Some(name)).len() as u64)
3314 .sum();
3315 assert_eq!(default_triples + named, card.quad_count);
3316
3317 assert_eq!(stats.statements, 3);
3320 assert_eq!(stats.default_triples, 2);
3321 }
3322
3323 #[test]
3324 fn derive_is_deterministic() {
3325 let quads = enriched_fixture();
3328 let a = derive_card(&quads, 12, 0, CardInput::default()).to_json_bytes();
3329 let b = derive_card(&quads, 12, 0, CardInput::default()).to_json_bytes();
3330 assert_eq!(a, b, "enriched card derivation must be deterministic");
3331 assert!(!a.is_empty());
3332 }
3333
3334 #[test]
3335 fn old_plain_string_card_still_parses() {
3336 let json = r#"{
3339 "title":"Legacy",
3340 "triple_count":3,"quad_count":3,"named_graph_count":0,"term_count":5,
3341 "predicates":[["<http://ex/p>",2]],
3342 "example_queries":["SELECT * WHERE { ?s ?p ?o }"],
3343 "format_version":1
3344 }"#;
3345 let card = DatasetCard::from_json_bytes(json.as_bytes()).unwrap();
3346 assert_eq!(card.title.as_deref(), Some("Legacy"));
3347 assert_eq!(card.example_queries.len(), 1);
3348 assert!(card.datatypes.is_empty());
3350 assert!(card.languages.is_empty());
3351 assert!(card.class_links.is_empty());
3352 assert!(card.top_hubs.is_empty());
3353 assert!(card.in_hubs.is_empty());
3354 assert!(card.queries.is_empty());
3355 assert!(card.signals.is_empty());
3356 assert!(!card.truncated);
3357 }
3358
3359 #[test]
3363 fn curated_base_iri_overrides_derived_and_is_validated() {
3364 let quads = vec![q(
3365 "<https://doi.org/10.1/a>",
3366 "<http://ex/p>",
3367 "<http://ex/o>",
3368 )];
3369 let derived = derive_card(&quads, 3, 0, CardInput::default());
3370 assert_eq!(
3371 derived.signals.base_iri.as_deref(),
3372 Some("https://doi.org/10.1/")
3373 );
3374 let input = CardInput::from_json_str(r#"{"base_iri": "https://doi.org/"}"#).unwrap();
3375 assert!(!input.is_empty());
3376 let card = derive_card(&quads, 3, 0, input.clone());
3377 assert_eq!(card.signals.base_iri.as_deref(), Some("https://doi.org/"));
3378 let counts = curated_counts_card(1, 3, input);
3379 assert_eq!(counts.signals.base_iri.as_deref(), Some("https://doi.org/"));
3380 for bad in [
3381 "doi.org/",
3382 "",
3383 "https:",
3384 "1http://x/",
3385 "http://a b/",
3386 "http://x/<y>",
3387 ] {
3388 let doc = serde_json::json!({ "base_iri": bad }).to_string();
3389 let err = CardInput::from_json_str(&doc).unwrap_err();
3390 assert!(err.contains("base_iri"), "{bad}: {err}");
3391 }
3392 }
3393
3394 #[test]
3395 fn curated_identity_fields_round_trip() {
3396 let input: CardInput = serde_json::from_str(
3397 r#"{
3398 "title": "T",
3399 "version": "2026-08-04",
3400 "creators": [{"name":"Ada","orcid":"https://orcid.org/0000-0002-1825-0097"}],
3401 "publisher": {"name":"EPFL","ror":"https://ror.org/02s376052"},
3402 "canonical_url": "https://data.example.org/t.rete",
3403 "sparql_endpoint": "https://example.org/sparql",
3404 "source_date": "2026-07-01",
3405 "derived_from": ["https://example.org/dump.nt"],
3406 "doi": "https://doi.org/10.5281/zenodo.1",
3407 "cite_as": "Ada (2026). T.",
3408 "keywords": ["graphs", "citations"],
3409 "theme": ["http://publications.europa.eu/resource/authority/data-theme/TECH"]
3410 }"#,
3411 )
3412 .unwrap();
3413 let card = curated_counts_card(5, 9, input);
3414 let bytes = card.to_json_bytes();
3415 let back = DatasetCard::from_json_bytes(&bytes).unwrap();
3416 assert_eq!(back.version.as_deref(), Some("2026-08-04"));
3417 assert_eq!(back.creators[0].name, "Ada");
3418 assert_eq!(
3419 back.creators[0].orcid.as_deref(),
3420 Some("https://orcid.org/0000-0002-1825-0097")
3421 );
3422 assert_eq!(back.publisher.as_ref().unwrap().name, "EPFL");
3423 assert_eq!(
3424 back.canonical_url.as_deref(),
3425 Some("https://data.example.org/t.rete")
3426 );
3427 assert_eq!(back.derived_from, vec!["https://example.org/dump.nt"]);
3428 assert_eq!(
3429 back.doi.as_deref(),
3430 Some("https://doi.org/10.5281/zenodo.1")
3431 );
3432 assert_eq!(back.keywords, vec!["graphs", "citations"]);
3435 assert_eq!(
3436 back.theme,
3437 vec!["http://publications.europa.eu/resource/authority/data-theme/TECH"]
3438 );
3439
3440 let plain = curated_counts_card(1, 2, CardInput::default()).to_json_bytes();
3442 let text = String::from_utf8(plain).unwrap();
3443 for absent in [
3444 "\"creators\"",
3445 "\"publisher\"",
3446 "\"canonical_url\"",
3447 "\"sparql_endpoint\"",
3448 "\"derived_from\"",
3449 "\"doi\"",
3450 "\"cite_as\"",
3451 "\"source_date\"",
3452 "\"version\"",
3453 "\"top_n\"",
3454 "\"keywords\"",
3455 "\"theme\"",
3456 "\"extra\"",
3457 ] {
3458 assert!(
3459 !text.contains(absent),
3460 "{absent} must be omitted when unset"
3461 );
3462 }
3463 }
3464
3465 #[test]
3470 fn extra_round_trips_and_external_writer_cards_surface_it() {
3471 let input: CardInput = serde_json::from_str(
3472 r#"{"title":"T","extra":{"atlas:layer":84,"review":{"by":"dg","ok":true}}}"#,
3473 )
3474 .unwrap();
3475 let card = curated_counts_card(5, 9, input);
3476 let back = DatasetCard::from_json_bytes(&card.to_json_bytes()).unwrap();
3477 assert_eq!(back.extra, card.extra);
3478 assert_eq!(back.extra.get("atlas:layer"), Some(&serde_json::json!(84)));
3479
3480 let python_shaped = r#"{
3483 "title":"From Python","extra":{"pipeline":"nightly"},
3484 "triple_count":1,"quad_count":1,"named_graph_count":0,"term_count":3,
3485 "format_version":5
3486 }"#;
3487 let card = DatasetCard::from_json_bytes(python_shaped.as_bytes()).unwrap();
3488 assert_eq!(
3489 card.extra.get("pipeline"),
3490 Some(&serde_json::json!("nightly"))
3491 );
3492 }
3493
3494 #[test]
3497 fn extra_limits_bite_exactly_at_the_boundary() {
3498 use crate::card_input::normalize_extra;
3499 use serde_json::{json, Map, Value};
3500 let bag = |v: Vec<(String, Value)>| -> Map<String, Value> { v.into_iter().collect() };
3503
3504 let pad = |n: usize| bag(vec![("pad".into(), json!("x".repeat(n)))]);
3506 let at = pad(CARD_EXTRA_MAX_BYTES - 10);
3507 assert_eq!(serde_json::to_vec(&at).unwrap().len(), CARD_EXTRA_MAX_BYTES);
3508 normalize_extra(at).expect("exactly at the byte cap is accepted");
3509 let over = pad(CARD_EXTRA_MAX_BYTES - 9);
3510 assert_eq!(
3511 serde_json::to_vec(&over).unwrap().len(),
3512 CARD_EXTRA_MAX_BYTES + 1
3513 );
3514 let err = normalize_extra(over).unwrap_err().to_string();
3515 assert!(
3516 err.contains(&format!("{} bytes", CARD_EXTRA_MAX_BYTES + 1)),
3517 "the error states the actual size: {err}"
3518 );
3519
3520 let keys = |n: usize| {
3522 bag((0..n)
3523 .map(|i| (format!("k{i:03}"), json!(1)))
3524 .collect::<Vec<_>>())
3525 };
3526 normalize_extra(keys(CARD_EXTRA_MAX_KEYS)).expect("at the key cap");
3527 assert!(normalize_extra(keys(CARD_EXTRA_MAX_KEYS + 1)).is_err());
3528
3529 normalize_extra(bag(vec![("k".repeat(CARD_EXTRA_MAX_KEY_BYTES), json!(1))]))
3531 .expect("at the key-length cap");
3532 assert!(normalize_extra(bag(vec![(
3533 "k".repeat(CARD_EXTRA_MAX_KEY_BYTES + 1),
3534 json!(1)
3535 )]))
3536 .is_err());
3537 assert!(normalize_extra(bag(vec![(String::new(), json!(1))])).is_err());
3538
3539 normalize_extra(bag(vec![("d".into(), json!({"a": {"b": 1}}))])).expect("under the cap");
3543 normalize_extra(bag(vec![("d".into(), json!([{"w": ["x", "y"]}]))]))
3544 .expect("at the depth cap");
3545 assert!(normalize_extra(bag(vec![("d".into(), json!({"a": {"b": {"c": [1]}}}))])).is_err());
3546 assert!(normalize_extra(bag(vec![("d".into(), json!([[[[1]]]]))])).is_err());
3547
3548 let err = normalize_extra(bag(vec![("@context".into(), json!({}))]))
3550 .unwrap_err()
3551 .to_string();
3552 assert!(err.contains("reserved"), "loud reservation: {err}");
3553 }
3554
3555 #[test]
3560 fn extra_fields_keep_the_content_hash_reproducible() {
3561 let build = |json: &str| {
3562 let input: CardInput = serde_json::from_str::<CardInput>(json)
3565 .unwrap()
3566 .normalize()
3567 .unwrap();
3568 let quads = enriched_fixture();
3569 let card = derive_card(&quads, 12, 0, input);
3570 let (image, _) =
3571 crate::ingest::assemble_dataset_with_opts(quads, true, false, None, |_, _| {
3572 card.to_json_bytes()
3573 });
3574 image
3575 };
3576 let a = build(r#"{"title":"T","extra":{"b":{"y":2,"x":1},"a":[1,2]}}"#);
3577 let b = build(r#"{"title":"T","extra":{"a":[1,2],"b":{"x":1,"y":2}}}"#);
3578 assert_eq!(a, b, "reordered-input builds are byte-identical");
3579 let ha = Header::from_bytes(&a).unwrap().content_hash;
3580 assert_eq!(ha, Header::from_bytes(&b).unwrap().content_hash);
3581
3582 let plain = build(r#"{"title":"T"}"#);
3585 assert_ne!(ha, Header::from_bytes(&plain).unwrap().content_hash);
3586 }
3587
3588 #[test]
3592 fn top_level_custom_keys_are_rejected_not_dropped() {
3593 let err = serde_json::from_str::<CardInput>(r#"{"title":"T","my_field":1}"#)
3594 .unwrap_err()
3595 .to_string();
3596 assert!(err.contains("my_field"), "names the stray key: {err}");
3597
3598 let ok: CardInput =
3600 serde_json::from_str(r#"{"title":"T","extra":{"my_field":1}}"#).unwrap();
3601 assert_eq!(ok.extra.get("my_field"), Some(&serde_json::json!(1)));
3602 }
3603
3604 #[test]
3609 fn description_reads_as_a_string_or_as_lines() {
3610 let from_lines: CardInput = serde_json::from_value(serde_json::json!({
3611 "description": ["## Contents", "", "- a", "- b"]
3612 }))
3613 .unwrap();
3614 assert_eq!(
3615 from_lines.description.as_deref(),
3616 Some("## Contents\n\n- a\n- b")
3617 );
3618 let from_string: CardInput =
3619 serde_json::from_str("{\"description\":\"## Contents\\n\\n- a\\n- b\"}").unwrap();
3620 assert_eq!(from_string.description, from_lines.description);
3621
3622 let none: CardInput = serde_json::from_str(r#"{"title":"T"}"#).unwrap();
3624 assert!(none.description.is_none());
3625 let null: CardInput = serde_json::from_str(r#"{"description":null}"#).unwrap();
3626 assert!(null.description.is_none());
3627
3628 let over = "x".repeat(crate::card::CARD_DESCRIPTION_MAX_BYTES + 1);
3631 let err = serde_json::from_str::<CardInput>(&format!(
3632 "{{\"description\":{}}}",
3633 serde_json::to_string(&over).unwrap()
3634 ))
3635 .unwrap_err()
3636 .to_string();
3637 assert!(err.contains("over the"), "{err}");
3638 }
3639
3640 #[test]
3648 fn curated_field_list_matches_the_deny_unknown_fields_struct() {
3649 for f in crate::card::CURATED_CARD_FIELDS {
3650 let doc = format!(r#"{{"{f}":null}}"#);
3654 if let Err(e) = serde_json::from_str::<CardInput>(&doc) {
3655 assert!(
3656 !e.to_string().contains("unknown field"),
3657 "CURATED_CARD_FIELDS lists `{f}`, but CardInput rejects it as unknown: {e}",
3658 );
3659 }
3660 }
3661 let err = serde_json::from_str::<CardInput>(r#"{"nope":1}"#)
3664 .unwrap_err()
3665 .to_string();
3666 for name in err
3667 .split("expected one of ")
3668 .nth(1)
3669 .expect("serde names the expected fields")
3670 .split(" at line ")
3672 .next()
3673 .unwrap()
3674 .split(", ")
3675 .map(|s| s.trim().trim_matches('`'))
3676 .filter(|s| !s.is_empty())
3677 {
3678 assert!(
3679 crate::card::CURATED_CARD_FIELDS.contains(&name),
3680 "CardInput accepts `{name}` but CURATED_CARD_FIELDS does not list it — \
3681 the browser builder would reject a card the CLI accepts",
3682 );
3683 }
3684 }
3685
3686 #[test]
3693 fn a_browser_written_card_deserializes_here() {
3694 let curated = crate::card::validate_curated_card(&serde_json::json!({
3695 "title": "Built in a browser",
3696 "keywords": ["b", "a"],
3697 "theme": ["http://publications.europa.eu/resource/authority/data-theme/GOVE"],
3698 "creators": [{"name": "Ada", "orcid": "https://orcid.org/0000-0002-1825-0097"}],
3699 "extra": {"internal_id": "DS-1"},
3700 }))
3701 .expect("valid curated document");
3702 let composed = crate::card::compose_curated_card(curated, 3, 4, 1, 9, 5);
3703 let text = serde_json::to_string(&composed).unwrap();
3704
3705 let card: DatasetCard = serde_json::from_str(&text).expect("CLI reads a browser card");
3706 assert_eq!(card.title.as_deref(), Some("Built in a browser"));
3707 assert_eq!(card.keywords, vec!["a".to_string(), "b".to_string()]);
3708 assert_eq!(card.creators.len(), 1);
3709 assert_eq!(card.triple_count, 3);
3710 assert_eq!(card.quad_count, 4);
3711 assert_eq!(card.term_count, 9);
3712 assert_eq!(card.format_version, 5);
3713 assert!(card.predicates.is_empty() && card.queries.is_empty());
3716 assert!(!text.contains("\"predicates\""), "{text}");
3717 assert!(!text.contains("\"top_n\""), "{text}");
3718 }
3719
3720 #[test]
3724 fn old_reader_struct_accepts_new_card_json() {
3725 #[derive(serde::Deserialize)]
3726 struct OldCard {
3727 title: Option<String>,
3728 triple_count: u64,
3729 #[serde(default)]
3730 predicates: Vec<(String, u64)>,
3731 format_version: u8,
3732 }
3733 let input: CardInput = serde_json::from_str(
3734 r#"{"title":"New","version":"1","creators":[{"name":"A"}],
3735 "canonical_url":"https://x/y.rete","derived_from":["https://x/d.nt"],
3736 "extra":{"atlas:review":"approved"}}"#,
3737 )
3738 .unwrap();
3739 let quads = vec![q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>")];
3740 let card = derive_card(&quads, 3, 0, input);
3741 assert_eq!(card.top_n, CARD_TOP_N as u32);
3742 let bytes = card.to_json_bytes();
3743 let old: OldCard = serde_json::from_slice(&bytes).expect("old struct parses new JSON");
3744 assert_eq!(old.title.as_deref(), Some("New"));
3745 assert_eq!(old.triple_count, 1);
3746 assert_eq!(old.format_version, card.format_version);
3747 assert!(!old.predicates.is_empty());
3748 }
3749
3750 #[test]
3751 fn coherence_stamp_is_deterministic_and_additive() {
3752 let dis = "<http://www.w3.org/2002/07/owl#disjointWith>";
3753 let rt = "<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>";
3754 let base = vec![
3755 ("<http://ex/C>".into(), dis.into(), "<http://ex/D>".into()),
3756 ("<http://ex/x>".into(), rt.into(), "<http://ex/C>".into()),
3757 ("<http://ex/x>".into(), rt.into(), "<http://ex/D>".into()),
3758 ];
3759 let r = crate::reason(&base);
3760 let c1 = Coherence::from_reasoning(&r, false);
3761 assert_eq!(c1, Coherence::from_reasoning(&r, false));
3763 assert!(!c1.coherent);
3764 assert_eq!(c1.inconsistency_count, 1);
3765 assert_eq!(c1.by_kind, vec![("disjoint-classes".to_string(), 1)]);
3766 assert_eq!(c1.rules, crate::REASON_RULESET);
3767
3768 let json = DatasetCard::default()
3770 .with_coherence(&r, false)
3771 .to_json_bytes();
3772 let text = std::str::from_utf8(&json).unwrap();
3773 assert!(text.contains("coherence"));
3774 assert_eq!(DatasetCard::from_json_bytes(&json).unwrap().coherence, c1);
3775
3776 let plain = DatasetCard::default().to_json_bytes();
3777 assert!(!std::str::from_utf8(&plain).unwrap().contains("coherence"));
3778 assert!(DatasetCard::default().coherence.is_empty());
3779 }
3780
3781 #[test]
3782 fn base_iri_is_common_prefix_with_fallback() {
3783 let mut ns = NsTally::default();
3785 ns.add("http://ex/person/".to_string(), 2000);
3786 ns.add("http://ex/place/".to_string(), 600);
3787 ns.add("http://ex/org/".to_string(), 120);
3788 assert_eq!(derive_base_iri(&ns).as_deref(), Some("http://ex/"));
3789
3790 let mut one = NsTally::default();
3792 one.add("http://data.example.org/id/".to_string(), 10);
3793 assert_eq!(
3794 derive_base_iri(&one).as_deref(),
3795 Some("http://data.example.org/id/")
3796 );
3797
3798 let mut multi = NsTally::default();
3801 multi.add("http://a.example/".to_string(), 100);
3802 multi.add("http://b.other/".to_string(), 5);
3803 assert_eq!(
3804 derive_base_iri(&multi).as_deref(),
3805 Some("http://a.example/")
3806 );
3807
3808 assert_eq!(derive_base_iri(&NsTally::default()), None);
3809 }
3810
3811 #[test]
3812 fn parse_literal_decomposes_forms() {
3813 let plain = parse_literal("\"hello\"").unwrap();
3814 assert_eq!(plain.value, "hello");
3815 assert!(plain.datatype.ends_with("XMLSchema#string"));
3816 assert_eq!(plain.lang, "");
3817
3818 let tagged = parse_literal("\"bonjour\"@fr").unwrap();
3819 assert_eq!(tagged.lang, "fr");
3820 assert!(tagged.datatype.ends_with("#langString"));
3821
3822 let typed = parse_literal("\"42\"^^<http://www.w3.org/2001/XMLSchema#integer>").unwrap();
3823 assert_eq!(typed.value, "42");
3824 assert_eq!(typed.datatype, "http://www.w3.org/2001/XMLSchema#integer");
3825
3826 let esc = parse_literal("\"a\\\"b\"@en").unwrap();
3828 assert_eq!(esc.value, "a\\\"b");
3829 assert_eq!(esc.lang, "en");
3830
3831 assert!(parse_literal("<http://ex/x>").is_none());
3833 assert!(parse_literal("_:b0").is_none());
3834 }
3835
3836 #[test]
3841 fn date_shapes_are_strict() {
3842 for ok in [
3843 "2019",
3844 "-0044",
3845 "2019-05",
3846 "2019-05-01",
3847 "2019-05-01Z",
3848 "2019-05-01+02:00",
3849 "2019-05-01T12:30",
3850 "2019-05-01T12:30:15",
3851 "2019-05-01T12:30:15.250Z",
3852 "1950Z",
3853 ] {
3854 assert!(date_shaped(ok), "{ok} is a date");
3855 }
3856 for bad in [
3857 "0000-0001-5001-3721", "9999880", "1234-1240", "123-456",
3861 "2019 annual report", "2019-13",
3863 "2019-05-32",
3864 "2019-05-01T25:00",
3865 "10.1000/182", "",
3867 "-",
3868 "2019-",
3869 ] {
3870 assert!(!date_shaped(bad), "{bad} is not a date");
3871 }
3872 }
3873
3874 #[test]
3880 fn temporal_extent_ignores_identifiers_pages_and_titles() {
3881 let value = "<http://www.essepuntato.it/2010/06/literalreification/hasLiteralValue>";
3882 let start = "<http://prismstandard.org/namespaces/basic/2.0/startingPage>";
3883 let end = "<http://prismstandard.org/namespaces/basic/2.0/endingPage>";
3884 let title = "<http://purl.org/dc/terms/title>";
3885 let pubdate = "<http://prismstandard.org/namespaces/basic/2.0/publicationDate>";
3886 let issued = "<http://purl.org/dc/terms/issued>";
3887 let xsd = |t: &str| format!("http://www.w3.org/2001/XMLSchema#{t}");
3888 let mut quads = Vec::new();
3889 for (i, id) in [
3890 "0000-0001-5001-3721",
3891 "0000-0002-1825-0097",
3892 "10.1000/182",
3893 "9999880",
3894 "2019",
3895 ]
3896 .iter()
3897 .enumerate()
3898 {
3899 quads.push(q(
3900 &format!("<http://ex/id/{i}>"),
3901 value,
3902 &format!("\"{id}\""),
3903 ));
3904 }
3905 quads.push(q("<http://ex/re/1>", start, "\"9999880\""));
3906 quads.push(q("<http://ex/re/1>", end, "\"9999899\""));
3907 quads.push(q("<http://ex/re/2>", start, "\"1234-1240\""));
3908 quads.push(q("<http://ex/br/1>", title, "\"2019 annual report\""));
3909 quads.push(q(
3910 "<http://ex/br/1>",
3911 pubdate,
3912 &format!("\"2019-05-01\"^^<{}>", xsd("date")),
3913 ));
3914 quads.push(q(
3915 "<http://ex/br/2>",
3916 pubdate,
3917 &format!("\"1950\"^^<{}>", xsd("gYear")),
3918 ));
3919 quads.push(q(
3920 "<http://ex/br/3>",
3921 pubdate,
3922 &format!("\"2021-03\"^^<{}>", xsd("gYearMonth")),
3923 ));
3924 quads.push(q(
3926 "<http://ex/br/4>",
3927 pubdate,
3928 &format!("\"0000-0001-5001-3721\"^^<{}>", xsd("date")),
3929 ));
3930 quads.push(q("<http://ex/br/5>", issued, "\"1999-12\""));
3932 let card = derive_card(&quads, 40, 0, CardInput::default());
3933 let s = &card.signals;
3934 assert_eq!(
3935 s.time_predicates,
3936 vec![pubdate.to_string(), issued.to_string()],
3937 "only the date predicates are time predicates"
3938 );
3939 assert_eq!(
3940 s.temporal_extent,
3941 Some(("1950".to_string(), "2021-03".to_string())),
3942 "calendar order across xsd:gYear, xsd:date and xsd:gYearMonth"
3943 );
3944 assert!(
3946 s.numeric_predicates.is_empty(),
3947 "{:?}",
3948 s.numeric_predicates
3949 );
3950 assert!(card
3952 .queries
3953 .iter()
3954 .filter(|q| q.dimension == "time")
3955 .all(|q| q.sparql.contains("publicationDate")));
3956 }
3957
3958 #[test]
3959 fn negative_years_order_by_value_not_by_bytes() {
3960 let mut extent = BTreeMap::new();
3961 for v in ["-0044", "-0100", "0001", "-0044-03-15"] {
3962 update_time_extent("<p>", v, &mut extent);
3963 }
3964 assert_eq!(
3965 extent.get("<p>"),
3966 Some(&("-0100".to_string(), "0001".to_string()))
3967 );
3968 }
3969
3970 #[test]
3973 fn an_integer_year_off_a_date_predicate_is_numeric() {
3974 let int = "http://www.w3.org/2001/XMLSchema#integer";
3975 let quads = vec![
3976 q(
3977 "<http://ex/a>",
3978 "<http://ex/year>",
3979 &format!("\"1492\"^^<{int}>"),
3980 ),
3981 q(
3982 "<http://ex/b>",
3983 "<http://ex/year>",
3984 &format!("\"1500\"^^<{int}>"),
3985 ),
3986 ];
3987 let card = derive_card(&quads, 6, 0, CardInput::default());
3988 assert!(card.signals.time_predicates.is_empty());
3989 assert_eq!(
3990 card.signals.numeric_predicates,
3991 vec!["<http://ex/year>".to_string()]
3992 );
3993 }
3994
3995 fn streaming_fixture() -> Vec<(String, String, String, Option<String>)> {
4004 let xsd = |t: &str| format!("http://www.w3.org/2001/XMLSchema#{t}");
4005 let mut v = enriched_fixture();
4006 for i in 0..5 {
4007 let s = format!("<http://ex/place/{i}>");
4008 v.push(q(&s, TYPE, "<http://ex/Place>"));
4009 v.push(q(&s, LABEL, &format!("\"Lieu {i}\"@fr")));
4010 v.push(q(
4011 &s,
4012 "<http://www.w3.org/2003/01/geo/wgs84_pos#lat>",
4013 &format!("\"4{i}.5\"^^<{}>", xsd("double")),
4014 ));
4015 v.push(q(
4016 &s,
4017 "<http://www.w3.org/2003/01/geo/wgs84_pos#long>",
4018 &format!("\"{i}.25\"^^<{}>", xsd("double")),
4019 ));
4020 v.push(q(
4021 &s,
4022 "<http://www.opengis.net/ont/geosparql#asWKT>",
4023 &format!(
4024 "\"POINT({i}.25 4{i}.5)\"^^<http://www.opengis.net/ont/geosparql#wktLiteral>"
4025 ),
4026 ));
4027 v.push(q(
4028 &s,
4029 "<http://purl.org/dc/terms/created>",
4030 &format!("\"20{i}0-01-0{}\"^^<{}>", i + 1, xsd("date")),
4031 ));
4032 v.push(q(&s, "<http://ex/near>", "<http://elsewhere/undescribed>"));
4033 }
4034 let knows = "<http://ex/a> <http://ex/knows> <http://ex/b>";
4037 let lives = "<http://ex/a> <http://ex/livesIn> <http://ex/c>";
4038 let ghost = "<http://ex/b> <http://ex/knows> <http://ex/c>";
4039 for (t, who) in [(knows, "alice"), (lives, "bob"), (ghost, "carol")] {
4040 v.push(q(
4041 &format!("<<{t}>>"),
4042 "<http://ex/recordedBy>",
4043 &format!("<http://ex/{who}>"),
4044 ));
4045 }
4046 v.push(q(
4047 "<http://ex/claim/1>",
4048 "<http://ex/states>",
4049 &format!("<<{knows}>>"),
4050 ));
4051 v.push((
4052 "<http://ex/note/1>".into(),
4053 "<http://ex/about>".into(),
4054 "<http://ex/a>".into(),
4055 Some("<http://ex/graph/notes>".into()),
4056 ));
4057 v
4058 }
4059
4060 fn both_ways(quads: &[(String, String, String, Option<String>)]) -> (DatasetCard, DatasetCard) {
4063 let (bytes, _) =
4064 crate::ingest::assemble_dataset_with_opts(quads.to_vec(), true, false, None, |_, _| {
4065 Vec::new()
4066 });
4067 let rete = crate::Rete::open(&bytes).unwrap();
4068 let term_count = rete.header().term_count;
4069 let (card, report) =
4070 derive_card_streaming(&rete, CardInput::default(), &mut |_| {}).unwrap();
4071 assert_eq!(report.statements, card.triple_count);
4072 let in_memory = derive_card(
4073 quads,
4074 term_count,
4075 rete.named_graph_count() as u64,
4076 CardInput::default(),
4077 );
4078 (card, in_memory)
4079 }
4080
4081 #[test]
4085 fn streaming_derivation_matches_the_in_memory_card() {
4086 for quads in [enriched_fixture(), streaming_fixture()] {
4087 let (streamed, in_memory) = both_ways(&quads);
4088 assert_eq!(
4089 String::from_utf8(streamed.to_json_bytes()).unwrap(),
4090 String::from_utf8(in_memory.to_json_bytes()).unwrap()
4091 );
4092 }
4093 let (card, _) = both_ways(&streaming_fixture());
4095 assert!(card.signals.annotated_statement.is_some());
4096 assert!(!card.signals.quoting_predicates.is_empty());
4097 assert!(card.signals.spatial_bbox.is_some());
4098 assert!(card.signals.temporal_extent.is_some());
4099 assert_eq!(card.named_graph_count, 1);
4100 }
4101
4102 #[test]
4105 fn streaming_derivation_widens_the_class_map() {
4106 let mut quads = Vec::new();
4107 for i in 0..300 {
4108 let s = format!("<http://ex/thing/{i:03}>");
4109 quads.push(q(&s, TYPE, &format!("<http://ex/Class{i:03}>")));
4110 quads.push(q(
4111 &s,
4112 "<http://ex/next>",
4113 &format!("<http://ex/thing/{:03}>", (i + 1) % 300),
4114 ));
4115 }
4116 let (streamed, in_memory) = both_ways(&quads);
4117 assert!(streamed.truncated);
4118 assert_eq!(streamed.classes, in_memory.classes);
4119 assert_eq!(streamed.class_links, in_memory.class_links);
4120 assert_eq!(
4121 String::from_utf8(streamed.to_json_bytes()).unwrap(),
4122 String::from_utf8(in_memory.to_json_bytes()).unwrap()
4123 );
4124 }
4125
4126 #[test]
4129 fn streaming_derivation_counts_the_deduplicated_file() {
4130 let mut quads = enriched_fixture();
4131 quads.push(quads[0].clone());
4132 let (streamed, in_memory) = both_ways(&quads);
4133 assert_eq!(streamed.triple_count, in_memory.triple_count - 1);
4134 let type_count = |c: &DatasetCard| {
4135 c.predicates
4136 .iter()
4137 .find(|(p, _)| p == TYPE)
4138 .map(|(_, n)| *n)
4139 };
4140 assert_eq!(type_count(&streamed), type_count(&in_memory).map(|n| n - 1));
4141 }
4142
4143 #[test]
4146 fn the_curated_half_of_a_card_round_trips() {
4147 let doc = r##"{"title":"T","description":["# Heading","line"],"license":"CC0-1.0",
4148 "source":"https://ex/src","created":"2026-01-01","version":"1",
4149 "creators":[{"name":"Ada","orcid":"https://orcid.org/0000-0002-1825-0097"}],
4150 "publisher":{"name":"P","ror":"https://ror.org/01ggx4157"},
4151 "canonical_url":"https://ex/f.rete","sparql_endpoint":"https://ex/sparql",
4152 "source_date":"2025-12-31","derived_from":["https://ex/dump"],
4153 "doi":"https://doi.org/10.1/x","cite_as":"Cite me","keywords":["b","a"],
4154 "theme":["http://publications.europa.eu/resource/authority/data-theme/EDUC"],
4155 "extra":{"k":[{"words":["x"]}]},"example_queries":["ASK {}"]}"##;
4156 let curated = CardInput::from_json_str(doc).unwrap();
4157 assert!(!curated.is_empty());
4158 let card = derive_card(&enriched_fixture(), 12, 0, curated);
4159 let again = derive_card(&enriched_fixture(), 12, 0, CardInput::from_card(&card));
4160 assert_eq!(again.to_json_bytes(), card.to_json_bytes());
4161 assert!(CardInput::from_card(&derive_card(
4162 &enriched_fixture(),
4163 12,
4164 0,
4165 CardInput::default()
4166 ))
4167 .is_empty());
4168 }
4169}
4170
4171#[cfg(test)]
4172mod ns_tally_tests {
4173 use super::*;
4174
4175 fn feed(t: &mut NsTally, n: usize) {
4176 for i in 0..n {
4177 let ns = if i % 7 == 0 {
4179 "https://doi.org/10.1/".to_string()
4180 } else {
4181 format!("https://doi.org/10.1/a{i}/")
4182 };
4183 t.add(ns, 1);
4184 }
4185 }
4186
4187 #[test]
4188 fn under_the_cap_the_tally_is_exact() {
4189 let mut t = NsTally::with_cap(100);
4190 feed(&mut t, 50);
4191 assert!(!t.overflowed());
4192 assert_eq!(t.len(), 43);
4193 assert_eq!(t.dominant().as_deref(), Some("https://doi.org/10.1/"));
4194 assert_eq!(
4195 derive_base_iri(&t).as_deref(),
4196 Some("https://doi.org/10.1/")
4197 );
4198 }
4199
4200 #[test]
4201 fn past_the_cap_the_tally_stays_bounded_and_deterministic() {
4202 let run = || {
4203 let mut t = NsTally::with_cap(64);
4204 feed(&mut t, 5_000);
4205 assert!(t.len() <= 64);
4206 assert!(t.overflowed());
4207 let snapshot: Vec<(String, u64)> =
4208 t.counts.iter().map(|(k, v)| (k.clone(), *v)).collect();
4209 (snapshot, t.dominant(), derive_base_iri(&t))
4210 };
4211 let (a, b) = (run(), run());
4212 assert_eq!(a, b);
4213 assert_eq!(a.1.as_deref(), Some("https://doi.org/10.1/"));
4216 assert_eq!(a.2.as_deref(), Some("https://doi.org/10.1/"));
4217 }
4218
4219 #[test]
4220 fn common_prefix_spans_all_hosts_even_when_entries_were_evicted() {
4221 let mut t = NsTally::with_cap(2);
4222 t.add("http://a.org/x/".into(), 10);
4223 t.add("http://a.org/y/".into(), 1);
4224 t.add("http://b.org/z/".into(), 1);
4225 assert!(t.overflowed());
4226 assert_eq!(t.common_prefix(), Some("http://"));
4227 assert_eq!(derive_base_iri(&t).as_deref(), Some("http://a.org/x/"));
4229 }
4230
4231 #[test]
4234 fn card_is_truncated_and_reproducible_past_the_cap() {
4235 let n = NS_TALLY_CAP + 2_000;
4236 let quads: Vec<_> = (0..n)
4237 .map(|i| {
4238 (
4239 format!("<https://doi.org/10.1/{i}/x>"),
4240 "<http://ex/p>".to_string(),
4241 "\"v\"".to_string(),
4242 None,
4243 )
4244 })
4245 .collect();
4246 let (bytes, _) =
4247 crate::ingest::assemble_dataset_with_opts(quads.clone(), true, false, None, |_, _| {
4248 Vec::new()
4249 });
4250 let rete = crate::Rete::open(&bytes).unwrap();
4251 let go = || derive_card_streaming(&rete, CardInput::default(), &mut |_| {}).unwrap();
4252 let (c1, r1) = go();
4253 let (c2, _) = go();
4254 assert!(r1.namespaces_overflowed);
4255 assert_eq!(r1.namespaces_kept as usize, NS_TALLY_CAP);
4256 assert!(c1.truncated);
4257 assert_eq!(c1.to_json_bytes(), c2.to_json_bytes());
4258 let mem = derive_card(&quads, rete.header().term_count, 0, CardInput::default());
4259 assert!(mem.truncated);
4260 assert_eq!(c1.signals.base_iri, mem.signals.base_iri);
4261 }
4262}
4263
4264#[cfg(test)]
4265mod class_map_tests {
4266 use super::ClassMap;
4267
4268 #[test]
4269 fn widening_keeps_every_code_and_never_holds_two_maps() {
4270 let mut m = ClassMap::new(999);
4271 for id in 0..1000u32 {
4272 m.set(id, id % 200);
4273 }
4274 assert_eq!(m.bytes(), 1000);
4275 m.set(5, 300);
4276 assert_eq!(m.bytes(), 2000);
4277 assert_eq!(m.get(5), 300);
4278 for id in (0..1000u32).filter(|&i| i != 5) {
4279 assert_eq!(m.get(id), id % 200, "{id}");
4280 }
4281 m.set(7, 70_000);
4282 assert_eq!(m.bytes(), 4000);
4283 assert_eq!((m.get(7), m.get(5), m.get(999)), (70_000, 300, 999 % 200));
4284 assert_eq!(m.get(5000), 0);
4286 m.set(5000, 1);
4287 assert_eq!(m.bytes(), 4000);
4288 }
4289}