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
2096enum ClassMap {
2101 U8(Vec<u8>),
2102 U16(Vec<u16>),
2103 U32(Vec<u32>),
2104}
2105
2106impl ClassMap {
2107 fn new(subjects: u64) -> Self {
2108 ClassMap::U8(vec![0; subjects as usize + 1])
2111 }
2112
2113 fn get(&self, id: u32) -> u32 {
2114 let i = id as usize;
2115 match self {
2116 ClassMap::U8(v) => v.get(i).map_or(0, |&c| c as u32),
2117 ClassMap::U16(v) => v.get(i).map_or(0, |&c| c as u32),
2118 ClassMap::U32(v) => v.get(i).copied().unwrap_or(0),
2119 }
2120 }
2121
2122 fn set(&mut self, id: u32, code: u32) {
2123 if code > u8::MAX as u32 {
2124 if let ClassMap::U8(v) = self {
2125 *self = ClassMap::U16(v.iter().map(|&c| c as u16).collect());
2126 }
2127 }
2128 if code > u16::MAX as u32 {
2129 if let ClassMap::U16(v) = self {
2130 *self = ClassMap::U32(v.iter().map(|&c| c as u32).collect());
2131 }
2132 }
2133 let i = id as usize;
2134 match self {
2135 ClassMap::U8(v) => {
2136 if let Some(slot) = v.get_mut(i) {
2137 *slot = code as u8;
2138 }
2139 }
2140 ClassMap::U16(v) => {
2141 if let Some(slot) = v.get_mut(i) {
2142 *slot = code as u16;
2143 }
2144 }
2145 ClassMap::U32(v) => {
2146 if let Some(slot) = v.get_mut(i) {
2147 *slot = code;
2148 }
2149 }
2150 }
2151 }
2152
2153 fn bytes(&self) -> u64 {
2154 match self {
2155 ClassMap::U8(v) => v.len() as u64,
2156 ClassMap::U16(v) => v.len() as u64 * 2,
2157 ClassMap::U32(v) => v.len() as u64 * 4,
2158 }
2159 }
2160}
2161
2162struct TopN {
2168 heap: std::collections::BinaryHeap<std::cmp::Reverse<(u64, std::cmp::Reverse<u32>)>>,
2169 offered: u64,
2170}
2171
2172impl TopN {
2173 fn new() -> Self {
2174 TopN {
2175 heap: std::collections::BinaryHeap::with_capacity(CARD_TOP_N + 1),
2176 offered: 0,
2177 }
2178 }
2179
2180 fn offer(&mut self, id: u32, n: u64) {
2181 use std::cmp::Reverse;
2182 self.offered += 1;
2183 let key = Reverse((n, Reverse(id)));
2184 if self.heap.len() < CARD_TOP_N {
2185 self.heap.push(key);
2186 } else if let Some(worst) = self.heap.peek() {
2187 if (n, Reverse(id)) > worst.0 {
2189 self.heap.pop();
2190 self.heap.push(key);
2191 }
2192 }
2193 }
2194
2195 fn into_parts(self) -> (Vec<(u32, u64)>, bool) {
2197 let cut = self.offered > CARD_TOP_N as u64;
2198 let v = self
2199 .heap
2200 .into_iter()
2201 .map(|std::cmp::Reverse((n, std::cmp::Reverse(id)))| (id, n))
2202 .collect();
2203 (v, cut)
2204 }
2205}
2206
2207fn top_terms(
2211 tops: [TopN; 2],
2212 terms: &dyn Fn(&[u32]) -> Vec<Option<String>>,
2213) -> (Vec<(String, u64)>, bool) {
2214 let mut offered = 0u64;
2215 let mut ids: Vec<(u32, u64)> = Vec::new();
2216 for top in tops {
2217 offered += top.offered;
2218 ids.extend(top.into_parts().0);
2219 }
2220 let names = terms(&ids.iter().map(|&(id, _)| id).collect::<Vec<_>>());
2221 let mut v: Vec<(String, u64)> = ids
2222 .iter()
2223 .zip(names)
2224 .filter_map(|(&(_, n), t)| t.map(|t| (t, n)))
2225 .collect();
2226 v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
2227 v.truncate(CARD_TOP_N);
2228 (v, offered > CARD_TOP_N as u64)
2229}
2230
2231#[derive(Default, Clone, Copy)]
2234struct IdHasher(u64);
2235
2236impl std::hash::Hasher for IdHasher {
2237 fn finish(&self) -> u64 {
2238 self.0
2239 }
2240 fn write(&mut self, bytes: &[u8]) {
2241 for &b in bytes {
2242 self.0 = (self.0.rotate_left(5) ^ b as u64).wrapping_mul(0x517c_c1b7_2722_0a95);
2243 }
2244 }
2245 fn write_u32(&mut self, n: u32) {
2246 self.0 = (self.0.rotate_left(5) ^ n as u64).wrapping_mul(0x517c_c1b7_2722_0a95);
2247 }
2248}
2249
2250type IdBuild = std::hash::BuildHasherDefault<IdHasher>;
2251
2252fn first_at_or_above(lo: u32, hi: u32, key: &str, term: &dyn Fn(u32) -> Option<String>) -> u32 {
2256 let (mut a, mut b) = (lo as u64, hi as u64 + 1);
2257 while a < b {
2258 let mid = a + (b - a) / 2;
2259 match term(mid as u32) {
2260 Some(t) if t.as_str() < key => a = mid + 1,
2261 _ => b = mid,
2262 }
2263 }
2264 a as u32
2265}
2266
2267fn id_range(
2272 lo: u32,
2273 hi: u32,
2274 from: &str,
2275 to: &str,
2276 term: &dyn Fn(u32) -> Option<String>,
2277) -> (u32, u32) {
2278 if hi < lo {
2279 return (lo, lo);
2280 }
2281 (
2282 first_at_or_above(lo, hi, from, term),
2283 first_at_or_above(lo, hi, to, term),
2284 )
2285}
2286
2287fn in_any(id: u32, ranges: &[(u32, u32)]) -> bool {
2288 ranges.iter().any(|&(a, b)| a <= id && id < b)
2289}
2290
2291pub fn derive_card_streaming(
2310 rete: &crate::Rete,
2311 curated: CardInput,
2312 progress: &mut dyn FnMut(&str),
2313) -> Result<(DatasetCard, StreamReport), String> {
2314 use crate::index::IndexPermutation;
2315
2316 let header = rete.header();
2317 let dict = rete.dictionary();
2318 let index = rete.default_index();
2319 let shared = dict.shared_count();
2320 let subjects_end = shared as u64 + dict.subject_only_count() as u64;
2321 let objects_end = shared as u64 + dict.object_only_count() as u64;
2322 let n_preds = dict.predicate_count() as usize;
2323 let pred_terms: Vec<String> = (0..=n_preds as u32)
2324 .map(|p| {
2325 if p == 0 {
2326 String::new()
2327 } else {
2328 dict.predicate_term(p).unwrap_or_default()
2329 }
2330 })
2331 .collect();
2332 let type_id = dict.predicate_id(RDF_TYPE);
2333
2334 let object_term = |id: u32| dict.object_term(id);
2336 let subject_term = |id: u32| dict.subject_term(id);
2337 let s_end = subjects_end.min(u32::MAX as u64) as u32;
2338 let o_end = objects_end.min(u32::MAX as u64) as u32;
2339 let literal_ranges = [
2340 id_range(1, shared, "\"", "#", &object_term),
2341 id_range(shared + 1, o_end, "\"", "#", &object_term),
2342 ];
2343 let is_literal = |o: u32| in_any(o, &literal_ranges);
2344 let quoted = header.has_quoted_triples();
2345 let (quoted_subjects, quoted_objects) = if quoted {
2346 (
2347 vec![
2348 id_range(1, shared, "<<", "<=", &subject_term),
2349 id_range(shared + 1, s_end, "<<", "<=", &subject_term),
2350 ],
2351 vec![
2352 id_range(1, shared, "<<", "<=", &object_term),
2353 id_range(shared + 1, o_end, "<<", "<=", &object_term),
2354 ],
2355 )
2356 } else {
2357 (Vec::new(), Vec::new())
2358 };
2359
2360 let mut report = StreamReport::default();
2361
2362 progress("pass 1/3: classes (the rdf:type range of POS)");
2364 let mut class_map = ClassMap::new(subjects_end);
2365 let mut class_code: std::collections::HashMap<u32, u32, IdBuild> = Default::default();
2366 let mut class_ids: Vec<u32> = vec![0];
2367 let mut class_counts: Vec<u64> = vec![0];
2368 if let Some(t) = type_id {
2369 for (s, _, o) in index.scan_iter((None, Some(t), None)) {
2370 let next = class_ids.len() as u32;
2371 let code = *class_code.entry(o).or_insert_with(|| {
2372 class_ids.push(o);
2373 class_counts.push(0);
2374 next
2375 });
2376 class_counts[code as usize] += 1;
2377 class_map.set(s, code);
2378 }
2379 }
2380 report.classes = class_ids.len() as u64 - 1;
2381 report.class_map_bytes = class_map.bytes();
2382
2383 progress("pass 2/3: subjects (SPO)");
2385 const LITERAL_CODE: u32 = u32::MAX;
2386 const WINDOW: usize = 1 << 16;
2387 let mut prof = Profile::default();
2388 let mut pred_counts = vec![0u64; n_preds + 1];
2389 let mut annotation_hits = vec![0u64; n_preds + 1];
2390 let mut quoting_hits = vec![0u64; n_preds + 1];
2391 let mut links: std::collections::HashMap<(u32, u32, u32), u64, IdBuild> = Default::default();
2392 let mut out_top = [TopN::new(), TopN::new()];
2394 let mut sample: Vec<(u32, u32)> = Vec::new();
2395 let mut window: Vec<(u32, u64)> = Vec::with_capacity(WINDOW);
2396 let mut subject_ns = NsTally::default();
2397 let flush_ns = |window: &mut Vec<(u32, u64)>, subject_ns: &mut NsTally| {
2398 let ids: Vec<u32> = window.iter().map(|&(s, _)| s).collect();
2399 let terms = dict.subject_terms(&ids);
2400 let mut run: Option<(String, u64)> = None;
2403 for (term, &(_, n)) in terms.iter().zip(window.iter()) {
2404 let Some(ns) = term.as_deref().and_then(split_namespace) else {
2405 continue;
2406 };
2407 match &mut run {
2408 Some((cur, total)) if *cur == ns => *total += n,
2409 _ => {
2410 if let Some((cur, total)) = run.take() {
2411 subject_ns.add(cur, total);
2412 }
2413 run = Some((ns, n));
2414 }
2415 }
2416 }
2417 if let Some((cur, total)) = run {
2418 subject_ns.add(cur, total);
2419 }
2420 window.clear();
2421 };
2422 let mut cur_s = 0u32;
2423 let mut cur_n = 0u64;
2424 let mut ticker = crate::build_progress::Ticker::new();
2426 let stored = index.for_each_in_order(IndexPermutation::Spo, &mut |s, p, o| {
2427 prof.triple_count += 1;
2428 ticker.tick(1);
2429 if let Some(c) = pred_counts.get_mut(p as usize) {
2430 *c += 1;
2431 }
2432 if s != cur_s {
2433 if cur_n > 0 {
2434 out_top[usize::from(cur_s > shared)].offer(cur_s, cur_n);
2435 window.push((cur_s, cur_n));
2436 if window.len() >= WINDOW {
2437 flush_ns(&mut window, &mut subject_ns);
2438 }
2439 }
2440 if quoted && in_any(s, "ed_subjects) && sample.len() < ANNOTATION_WITNESS_SAMPLE {
2441 sample.push((s, p));
2442 }
2443 cur_s = s;
2444 cur_n = 0;
2445 }
2446 cur_n += 1;
2447 if quoted {
2448 if in_any(s, "ed_subjects) {
2449 if let Some(c) = annotation_hits.get_mut(p as usize) {
2450 *c += 1;
2451 }
2452 }
2453 if in_any(o, "ed_objects) {
2454 if let Some(c) = quoting_hits.get_mut(p as usize) {
2455 *c += 1;
2456 }
2457 }
2458 }
2459 if Some(p) == type_id {
2460 return;
2461 }
2462 let sc = class_map.get(s);
2463 let oc = if is_literal(o) {
2466 LITERAL_CODE
2467 } else if o <= shared {
2468 class_map.get(o)
2469 } else {
2470 0
2471 };
2472 *links.entry((sc, p, oc)).or_default() += 1;
2473 });
2474 if !stored {
2475 return Err("the file stores no SPO permutation".to_string());
2476 }
2477 if cur_n > 0 {
2478 out_top[usize::from(cur_s > shared)].offer(cur_s, cur_n);
2479 window.push((cur_s, cur_n));
2480 }
2481 if !window.is_empty() {
2482 flush_ns(&mut window, &mut subject_ns);
2483 }
2484 report.statements = prof.triple_count;
2485 report.subjects = out_top[0].offered + out_top[1].offered;
2486 drop(class_map);
2487
2488 progress("pass 3/3: objects (OSP)");
2490 let mut in_top = [TopN::new(), TopN::new()];
2491 let mut lit_window: Vec<(u32, Vec<(u32, u64)>)> = Vec::with_capacity(WINDOW);
2492 let mut lit_objects = 0u64;
2493 let flush_lits = |lit_window: &mut Vec<(u32, Vec<(u32, u64)>)>, prof: &mut Profile| {
2494 let ids: Vec<u32> = lit_window.iter().map(|(o, _)| *o).collect();
2495 let terms = dict.object_terms(&ids);
2496 for (term, (_, preds)) in terms.iter().zip(lit_window.iter()) {
2497 let Some(lit) = term.as_deref().and_then(parse_literal) else {
2498 continue;
2499 };
2500 for &(p, n) in preds {
2501 if let Some(pt) = pred_terms.get(p as usize) {
2502 prof.literal(pt, &lit, n);
2503 }
2504 }
2505 }
2506 lit_window.clear();
2507 };
2508 let mut cur_o = 0u32;
2509 let mut cur_n = 0u64;
2510 let mut cur_preds: Vec<(u32, u64)> = Vec::new();
2511 index.for_each_in_order(IndexPermutation::Osp, &mut |o, _s, p| {
2512 ticker.tick(1);
2513 if o != cur_o {
2514 if cur_n > 0 {
2515 if is_literal(cur_o) {
2516 lit_objects += 1;
2517 lit_window.push((cur_o, std::mem::take(&mut cur_preds)));
2518 if lit_window.len() >= WINDOW {
2519 flush_lits(&mut lit_window, &mut prof);
2520 }
2521 } else {
2522 in_top[usize::from(cur_o > shared)].offer(cur_o, cur_n);
2523 }
2524 }
2525 cur_o = o;
2526 cur_n = 0;
2527 cur_preds.clear();
2528 }
2529 cur_n += 1;
2530 if is_literal(o) {
2531 match cur_preds.iter_mut().find(|(q, _)| *q == p) {
2532 Some((_, n)) => *n += 1,
2533 None => cur_preds.push((p, 1)),
2534 }
2535 }
2536 });
2537 if cur_n > 0 {
2538 if is_literal(cur_o) {
2539 lit_objects += 1;
2540 lit_window.push((cur_o, std::mem::take(&mut cur_preds)));
2541 } else {
2542 in_top[usize::from(cur_o > shared)].offer(cur_o, cur_n);
2543 }
2544 }
2545 if !lit_window.is_empty() {
2546 flush_lits(&mut lit_window, &mut prof);
2547 }
2548 report.literal_objects = lit_objects;
2549
2550 if rete.index_incomplete() {
2551 return Err("a read failed part-way through the file; the card would be incomplete".into());
2552 }
2553
2554 progress("resolving the profile's terms");
2556 let class_terms: Vec<String> = class_ids
2557 .iter()
2558 .map(|&o| {
2559 if o == 0 {
2560 O_UNTYPED.to_string()
2561 } else {
2562 dict.object_term(o).unwrap_or_default()
2563 }
2564 })
2565 .collect();
2566 let class_name = |code: u32| -> String {
2567 if code == LITERAL_CODE {
2568 O_LITERAL.to_string()
2569 } else {
2570 class_terms
2571 .get(code as usize)
2572 .cloned()
2573 .unwrap_or_else(|| O_UNTYPED.to_string())
2574 }
2575 };
2576 for (p, &n) in pred_counts.iter().enumerate() {
2577 if n > 0 {
2578 prof.pred_counts.insert(pred_terms[p].clone(), n);
2579 }
2580 }
2581 for (code, &n) in class_counts.iter().enumerate().skip(1) {
2582 if n > 0 {
2583 *prof
2584 .class_counts
2585 .entry(class_terms[code].clone())
2586 .or_default() += n;
2587 }
2588 }
2589 for ((sc, p, oc), n) in links {
2590 let Some(pt) = pred_terms.get(p as usize) else {
2591 continue;
2592 };
2593 *prof
2594 .link_rows
2595 .entry((class_name(sc), pt.clone(), class_name(oc)))
2596 .or_default() += n;
2597 }
2598 for (p, &n) in annotation_hits.iter().enumerate() {
2599 if n > 0 {
2600 prof.annotation_hits.insert(pred_terms[p].clone(), n);
2601 }
2602 }
2603 for (p, &n) in quoting_hits.iter().enumerate() {
2604 if n > 0 {
2605 prof.quoting_hits.insert(pred_terms[p].clone(), n);
2606 }
2607 }
2608 report.namespaces_kept = subject_ns.len() as u64;
2609 report.namespaces_overflowed = subject_ns.overflowed();
2610 prof.subject_ns = subject_ns;
2611 if pred_counts
2612 .iter()
2613 .enumerate()
2614 .any(|(p, &n)| n > 0 && (pred_terms[p] == GEO_ASWKT || pred_terms[p] == GEO_HASGEOMETRY))
2615 {
2616 prof.geo_wkt = true;
2617 }
2618
2619 let sample_ids: Vec<u32> = sample.iter().map(|&(s, _)| s).collect();
2622 for (term, &(_, about)) in dict.subject_terms(&sample_ids).iter().zip(sample.iter()) {
2623 let Some((a, qp, b)) = term.as_deref().and_then(crate::ingest::quoted_triple_parts) else {
2624 continue;
2625 };
2626 if let Some((sid, pid, oid)) = dict.encode(&a, &qp, &b) {
2627 if index
2628 .scan_iter((Some(sid), Some(pid), Some(oid)))
2629 .next()
2630 .is_some()
2631 {
2632 if let Some(about) = pred_terms.get(about as usize) {
2633 *prof.witnessed_pairs.entry((qp, about.clone())).or_default() += 1;
2634 }
2635 }
2636 }
2637 }
2638
2639 let (top_hubs, cut_out) = top_terms(out_top, &|ids| dict.subject_terms(ids));
2640 let (in_hubs, cut_in) = top_terms(in_top, &|ids| dict.object_terms(ids));
2641
2642 let card = finish(
2643 prof,
2644 Hubs {
2645 top_hubs,
2646 in_hubs,
2647 truncated: cut_out || cut_in,
2648 },
2649 header.quad_count,
2650 header.term_count,
2651 rete.named_graph_count() as u64,
2652 curated,
2653 );
2654 Ok((card, report))
2655}
2656
2657fn split_namespace(term: &str) -> Option<String> {
2661 let iri = term.strip_prefix('<')?.strip_suffix('>')?;
2662 let cut = iri.rfind(['#', '/'])?;
2663 Some(iri[..=cut].to_string())
2664}
2665
2666pub fn load_card(bytes: &[u8]) -> Result<Option<DatasetCard>, String> {
2673 if crate::v06::open::is_v06(bytes) {
2674 return match crate::file::read_metadata_ranged(&crate::reader::SliceReader::new(bytes))
2675 .map_err(|e| e.to_string())?
2676 {
2677 Some(b) => Ok(Some(DatasetCard::from_json_bytes(&b)?)),
2678 None => Ok(None),
2679 };
2680 }
2681 let header = Header::from_bytes(bytes).map_err(|e| e.to_string())?;
2682 if header.metadata_len == 0 {
2683 return Ok(None);
2684 }
2685 let start = header.metadata_offset as usize;
2686 let end = start
2687 .checked_add(header.metadata_len as usize)
2688 .filter(|&e| e <= bytes.len())
2689 .ok_or_else(|| "metadata section out of bounds".to_string())?;
2690 Ok(Some(DatasetCard::from_json_bytes(&bytes[start..end])?))
2691}
2692
2693#[cfg(test)]
2694mod tests {
2695 use super::*;
2696 use crate::card_input::{CARD_EXTRA_MAX_BYTES, CARD_EXTRA_MAX_KEYS, CARD_EXTRA_MAX_KEY_BYTES};
2699
2700 const TYPE: &str = RDF_TYPE;
2701
2702 fn q(s: &str, p: &str, o: &str) -> (String, String, String, Option<String>) {
2703 (s.into(), p.into(), o.into(), None)
2704 }
2705
2706 #[test]
2707 fn card_json_round_trips() {
2708 let card = DatasetCard {
2709 title: Some("Demo".into()),
2710 license: Some("CC0".into()),
2711 triple_count: 3,
2712 quad_count: 3,
2713 term_count: 5,
2714 predicates: vec![("<http://ex/p>".into(), 2)],
2715 vocabularies: vec!["http://ex/".into()],
2716 format_version: 1,
2717 ..Default::default()
2718 };
2719 let bytes = card.to_json_bytes();
2720 let back = DatasetCard::from_json_bytes(&bytes).unwrap();
2721 assert_eq!(card, back);
2722 let text = String::from_utf8(bytes).unwrap();
2724 assert!(!text.contains("description"));
2725 assert!(!text.contains("classes"));
2726 }
2727
2728 #[test]
2734 fn the_permutation_signal_is_stripped_from_the_stored_card() {
2735 let measured = PermutationsSignal {
2736 count: 3,
2737 names: vec!["SPO".into(), "POS".into(), "OSP".into()],
2738 merge_join: false,
2739 };
2740 let mut card = DatasetCard {
2741 title: Some("Lean".into()),
2742 triple_count: 3,
2743 ..Default::default()
2744 };
2745 assert_eq!(card.observe_permutations(measured.clone()), None);
2746 assert_eq!(card.signals.permutations, Some(measured.clone()));
2747
2748 let bytes = card.to_json_bytes();
2749 let text = String::from_utf8(bytes.clone()).unwrap();
2750 assert!(
2751 !text.contains("permutations"),
2752 "the signal must not reach the metadata section: {text}"
2753 );
2754 let back = DatasetCard::from_json_bytes(&bytes).unwrap();
2755 assert_eq!(back.signals.permutations, None, "unknown, not `six`");
2756 assert_eq!(back.title, card.title);
2757
2758 let authored = r#"{"title":"x","signals":{"permutations":{"count":6}}}"#;
2761 assert!(
2762 serde_json::from_str::<CardInput>(authored).is_err(),
2763 "a hand-written card must not be able to assert a permutation set"
2764 );
2765 }
2766
2767 #[test]
2771 fn the_permutation_signal_is_derived_from_the_header() {
2772 let quads = vec![
2773 q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>"),
2774 q("<http://ex/b>", "<http://ex/p>", "<http://ex/c>"),
2775 ];
2776 let (six, _) = crate::ingest::assemble_dataset_with_perms(
2777 quads.clone(),
2778 false,
2779 false,
2780 None,
2781 crate::PyramidAlgo::Louvain,
2782 crate::PermSet::ALL,
2783 |_, _| Vec::new(),
2784 );
2785 let (three, _) = crate::ingest::assemble_dataset_with_perms(
2786 quads,
2787 false,
2788 false,
2789 None,
2790 crate::PyramidAlgo::Louvain,
2791 crate::PermSet::CORE,
2792 |_, _| Vec::new(),
2793 );
2794 assert_eq!(six[50], 0);
2797 assert_eq!(three[50], 0b0000_0111);
2798
2799 let h6 = crate::Header::from_bytes(&six).unwrap();
2800 let h3 = crate::Header::from_bytes(&three).unwrap();
2801 let s6 = PermutationsSignal::probe(&h6);
2802 let s3 = PermutationsSignal::probe(&h3);
2803 assert_eq!(s6.count, 6);
2804 assert!(s6.merge_join);
2805 assert_eq!(s3.count, 3);
2806 assert!(!s3.merge_join);
2807 assert_eq!(s3.names, vec!["SPO", "POS", "OSP"]);
2808 assert!(s3.describe().contains("no sort-merge join"));
2809 assert!(three.len() < six.len(), "{} vs {}", three.len(), six.len());
2811 assert_eq!(h3.dictionary_len, h6.dictionary_len);
2812 assert!(h3.root_dir_len < h6.root_dir_len);
2813 }
2814
2815 #[test]
2820 fn the_text_index_signal_is_stripped_from_the_stored_card() {
2821 let measured = TextIndexSignal {
2823 present: true,
2824 bytes: 1_879_287_762,
2825 token_table_bytes: Some(193_295_361),
2826 };
2827 let mut card = DatasetCard {
2828 title: Some("Indexed".into()),
2829 triple_count: 3,
2830 ..Default::default()
2831 };
2832 assert_eq!(card.observe_text_index(measured), None);
2833 assert_eq!(card.signals.text_index, Some(measured));
2834
2835 let bytes = card.to_json_bytes();
2836 let text = String::from_utf8(bytes.clone()).unwrap();
2837 assert!(
2838 !text.contains("text_index"),
2839 "the signal must not reach the metadata section: {text}"
2840 );
2841 let back = DatasetCard::from_json_bytes(&bytes).unwrap();
2844 assert_eq!(back.title, card.title);
2845 assert_eq!(back.signals.text_index, None);
2846
2847 assert_eq!(card.signals.text_index, Some(measured));
2850 }
2851
2852 #[test]
2858 fn the_text_index_signal_round_trips_through_a_card_document() {
2859 let mut card = DatasetCard {
2860 title: Some("Indexed".into()),
2861 ..Default::default()
2862 };
2863 card.observe_text_index(TextIndexSignal {
2864 present: true,
2865 bytes: 80,
2866 token_table_bytes: Some(68),
2867 });
2868 let doc = serde_json::to_vec(&card).unwrap();
2869 let back: DatasetCard = serde_json::from_slice(&doc).unwrap();
2870 assert_eq!(back.signals.text_index, card.signals.text_index);
2871
2872 let mut none = DatasetCard::default();
2876 none.observe_text_index(TextIndexSignal::default());
2877 let text = serde_json::to_string(&none).unwrap();
2878 assert!(text.contains(r#""text_index":{"present":false}"#), "{text}");
2879 let absent = serde_json::to_string(&DatasetCard::default()).unwrap();
2880 assert!(!absent.contains("text_index"), "{absent}");
2881 }
2882
2883 #[test]
2890 fn the_quoted_triple_signal_is_derived_from_the_header() {
2891 let plain = vec![
2892 q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>"),
2893 q("<http://ex/b>", "<http://ex/p>", "<http://ex/c>"),
2894 ];
2895 let quoted = vec![
2896 q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>"),
2897 q(
2898 "<http://ex/claim>",
2899 "<http://ex/states>",
2900 "<<<http://ex/a> <http://ex/p> <http://ex/b>>>",
2901 ),
2902 ];
2903 let (no, _) = crate::ingest::assemble_dataset(plain, &[]);
2904 let (yes, _) = crate::ingest::assemble_dataset(quoted, &[]);
2905
2906 let hn = crate::Header::from_bytes(&no).unwrap();
2907 let hy = crate::Header::from_bytes(&yes).unwrap();
2908 assert!(!hn.has_quoted_triples());
2909 assert!(hy.has_quoted_triples());
2910
2911 let sn = QuotedTriplesSignal::probe(&hn);
2912 let sy = QuotedTriplesSignal::probe(&hy);
2913 assert!(!sn.present);
2914 assert!(sy.present);
2915
2916 assert_eq!(sn.describe(), None);
2919 let line = sy.describe().expect("a file with quoted triples says so");
2923 assert!(line.contains("<<( s p o )>>"), "{line}");
2924 assert!(line.contains(QUOTED_TRIPLE_SURFACE_RDF_STAR), "{line}");
2925 assert_eq!(
2926 sy.export_surfaces,
2927 vec![QUOTED_TRIPLE_SURFACE_RDF12, QUOTED_TRIPLE_SURFACE_RDF_STAR]
2928 );
2929 assert_eq!(
2930 sy.export_default.as_deref(),
2931 Some(QUOTED_TRIPLE_SURFACE_RDF12)
2932 );
2933 assert!(sn.export_surfaces.is_empty());
2935 assert_eq!(sn.export_default, None);
2936 }
2937
2938 #[test]
2943 fn the_quoted_triple_signal_is_stripped_from_the_stored_card() {
2944 let measured = QuotedTriplesSignal {
2945 present: true,
2946 export_surfaces: vec![
2947 QUOTED_TRIPLE_SURFACE_RDF12.to_string(),
2948 QUOTED_TRIPLE_SURFACE_RDF_STAR.to_string(),
2949 ],
2950 export_default: Some(QUOTED_TRIPLE_SURFACE_RDF12.to_string()),
2951 };
2952 let mut card = DatasetCard {
2953 title: Some("Annotated".into()),
2954 triple_count: 3,
2955 ..Default::default()
2956 };
2957 assert_eq!(card.observe_quoted_triples(measured.clone()), None);
2958 assert_eq!(card.signals.quoted_triples, Some(measured.clone()));
2959
2960 let bytes = card.to_json_bytes();
2961 let text = String::from_utf8(bytes.clone()).unwrap();
2962 assert!(
2963 !text.contains("quoted_triples"),
2964 "the signal must not reach the metadata section: {text}"
2965 );
2966 let back = DatasetCard::from_json_bytes(&bytes).unwrap();
2967 assert_eq!(back.title, card.title);
2968 assert_eq!(back.signals.quoted_triples, None);
2969 assert_eq!(card.signals.quoted_triples, Some(measured));
2971
2972 let authored = r#"{"title":"x","signals":{"quoted_triples":{"present":true}}}"#;
2975 assert!(
2976 serde_json::from_str::<CardInput>(authored).is_err(),
2977 "a hand-written card must not be able to assert a quoted-triple set"
2978 );
2979 }
2980
2981 #[test]
2986 fn the_quoted_triple_signal_round_trips_through_a_card_document() {
2987 let mut card = DatasetCard {
2988 title: Some("Annotated".into()),
2989 ..Default::default()
2990 };
2991 card.observe_quoted_triples(QuotedTriplesSignal {
2992 present: true,
2993 export_surfaces: vec![QUOTED_TRIPLE_SURFACE_RDF12.to_string()],
2994 export_default: Some(QUOTED_TRIPLE_SURFACE_RDF12.to_string()),
2995 });
2996 let doc = serde_json::to_vec(&card).unwrap();
2997 let back: DatasetCard = serde_json::from_slice(&doc).unwrap();
2998 assert_eq!(back.signals.quoted_triples, card.signals.quoted_triples);
2999
3000 let mut none = DatasetCard::default();
3001 none.observe_quoted_triples(QuotedTriplesSignal {
3002 present: false,
3003 export_surfaces: Vec::new(),
3004 export_default: None,
3005 });
3006 let text = serde_json::to_string(&none).unwrap();
3007 assert!(
3008 text.contains(r#""quoted_triples":{"present":false}"#),
3009 "{text}"
3010 );
3011 let absent = serde_json::to_string(&DatasetCard::default()).unwrap();
3012 assert!(!absent.contains("quoted_triples"), "{absent}");
3013 }
3014 #[test]
3015 fn split_namespace_handles_hash_slash_and_neither() {
3016 assert_eq!(
3017 split_namespace("<http://xmlns.com/foaf/0.1/name>").as_deref(),
3018 Some("http://xmlns.com/foaf/0.1/")
3019 );
3020 assert_eq!(
3021 split_namespace("<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>").as_deref(),
3022 Some("http://www.w3.org/1999/02/22-rdf-syntax-ns#")
3023 );
3024 assert_eq!(split_namespace("<urn:isbn:12345>"), None);
3026 assert_eq!(split_namespace("\"literal\""), None);
3027 assert_eq!(split_namespace("_:b0"), None);
3028 }
3029
3030 #[test]
3031 fn derive_counts_and_orders() {
3032 let quads = vec![
3033 q("<http://ex/a>", TYPE, "<http://ex/Person>"),
3034 q("<http://ex/b>", TYPE, "<http://ex/Person>"),
3035 q("<http://ex/c>", TYPE, "<http://ex/City>"),
3036 q("<http://ex/a>", "<http://ex/knows>", "<http://ex/b>"),
3037 q("<http://ex/a>", "<http://ex/knows>", "<http://ex/c>"),
3038 q("<http://ex/a>", "<http://ex/name>", "\"A\""),
3039 ];
3040 let card = derive_card(&quads, 9, 0, CardInput::default());
3041 assert_eq!(card.triple_count, 6);
3042 assert_eq!(card.quad_count, 6);
3043 assert_eq!(card.term_count, 9);
3044 assert_eq!(card.predicates[0], (TYPE.to_string(), 3));
3046 assert!(card
3047 .predicates
3048 .contains(&("<http://ex/knows>".to_string(), 2)));
3049 assert_eq!(
3051 card.classes,
3052 vec![
3053 ("<http://ex/Person>".to_string(), 2),
3054 ("<http://ex/City>".to_string(), 1)
3055 ]
3056 );
3057 assert!(card.vocabularies.contains(&"http://ex/".to_string()));
3059 assert!(card
3060 .vocabularies
3061 .iter()
3062 .any(|v| v.ends_with("rdf-syntax-ns#")));
3063 }
3064
3065 #[test]
3066 fn named_graph_triples_excluded_from_default_stats() {
3067 let quads = vec![
3068 q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>"),
3069 (
3070 "<http://ex/c>".into(),
3071 "<http://ex/p>".into(),
3072 "<http://ex/d>".into(),
3073 Some("<http://ex/g1>".into()),
3074 ),
3075 ];
3076 let card = derive_card(&quads, 5, 1, CardInput::default());
3077 assert_eq!(card.triple_count, 1, "only the default-graph triple counts");
3078 assert_eq!(card.quad_count, 2);
3079 assert_eq!(card.named_graph_count, 1);
3080 assert_eq!(card.predicates[0].1, 1);
3081 }
3082
3083 const LABEL: &str = "<http://www.w3.org/2000/01/rdf-schema#label>";
3085 const SAMEAS: &str = "<http://www.w3.org/2002/07/owl#sameAs>";
3086 const XSD_INT: &str = "http://www.w3.org/2001/XMLSchema#integer";
3087 const XSD_GYEAR: &str = "http://www.w3.org/2001/XMLSchema#gYear";
3088
3089 fn enriched_fixture() -> Vec<(String, String, String, Option<String>)> {
3090 vec![
3091 q("<http://ex/a>", TYPE, "<http://ex/Person>"),
3092 q("<http://ex/b>", TYPE, "<http://ex/Person>"),
3093 q("<http://ex/c>", TYPE, "<http://ex/City>"),
3094 q("<http://ex/a>", LABEL, "\"Alice\"@en"),
3095 q("<http://ex/b>", LABEL, "\"Bob\"@en"),
3096 q(
3097 "<http://ex/a>",
3098 "<http://ex/age>",
3099 &format!("\"30\"^^<{XSD_INT}>"),
3100 ),
3101 q(
3102 "<http://ex/a>",
3103 "<http://ex/birthYear>",
3104 &format!("\"1850\"^^<{XSD_GYEAR}>"),
3105 ),
3106 q(
3107 "<http://ex/b>",
3108 "<http://ex/birthYear>",
3109 &format!("\"1875\"^^<{XSD_GYEAR}>"),
3110 ),
3111 q("<http://ex/a>", "<http://ex/knows>", "<http://ex/b>"),
3112 q("<http://ex/a>", "<http://ex/livesIn>", "<http://ex/c>"),
3113 q("<http://ex/a>", SAMEAS, "<http://other/a>"),
3114 ]
3115 }
3116
3117 #[test]
3118 fn enriched_profile_is_populated() {
3119 let quads = enriched_fixture();
3120 let card = derive_card(&quads, 12, 0, CardInput::default());
3121
3122 assert!(!card.datatypes.is_empty(), "datatypes derived");
3123 assert!(!card.languages.is_empty(), "languages derived");
3124 assert!(!card.class_links.is_empty(), "class_links derived");
3125 assert!(!card.top_hubs.is_empty(), "out-degree hubs derived");
3126 assert!(!card.in_hubs.is_empty(), "in-degree hubs derived");
3127
3128 assert!(card
3130 .class_links
3131 .iter()
3132 .any(|l| l.s_class == "<http://ex/Person>"
3133 && l.predicate == "<http://ex/knows>"
3134 && l.o_class == "<http://ex/Person>"
3135 && l.count == 1));
3136 assert!(card
3137 .class_links
3138 .iter()
3139 .any(|l| l.o_class == "<http://ex/City>"));
3140 assert!(card.class_links.iter().any(|l| l.o_class == "(untyped)"));
3142
3143 assert!(card.datatypes.iter().any(|(d, _)| d.contains("integer")));
3145 assert!(card.datatypes.iter().any(|(d, _)| d.contains("gYear")));
3146 assert!(card.datatypes.iter().any(|(d, _)| d.contains("langString")));
3147 assert!(card.languages.iter().any(|(l, _)| l == "en"));
3149
3150 let s = &card.signals;
3152 assert_eq!(s.label_predicate.as_deref(), Some(LABEL));
3153 assert_eq!(s.default_lang.as_deref(), Some("en"));
3154 assert_eq!(s.base_iri.as_deref(), Some("http://ex/"));
3155 assert!(s.link_predicates.iter().any(|p| p.contains("sameAs")));
3156 assert!(s.time_predicates.iter().any(|p| p.contains("birthYear")));
3157 assert_eq!(
3158 s.temporal_extent,
3159 Some(("1850".to_string(), "1875".to_string())),
3160 "extent over the dominant time predicate"
3161 );
3162 assert!(!s.geo_wkt && !s.geo_latlong);
3164 assert!(s.spatial_bbox.is_none());
3165 }
3166
3167 #[test]
3168 fn signals_detect_geometry() {
3169 let wkt = "http://www.opengis.net/ont/geosparql#wktLiteral";
3170 let quads = vec![
3171 q("<http://ex/p>", TYPE, "<http://ex/Place>"),
3172 q(
3173 "<http://ex/p>",
3174 "<http://www.w3.org/2003/01/geo/wgs84_pos#lat>",
3175 "\"41.9\"^^<http://www.w3.org/2001/XMLSchema#double>",
3176 ),
3177 q(
3178 "<http://ex/p>",
3179 "<http://www.w3.org/2003/01/geo/wgs84_pos#long>",
3180 "\"12.5\"^^<http://www.w3.org/2001/XMLSchema#double>",
3181 ),
3182 q(
3183 "<http://ex/p>",
3184 "<http://www.opengis.net/ont/geosparql#asWKT>",
3185 &format!("\"POINT(12.5 41.9)\"^^<{wkt}>"),
3186 ),
3187 ];
3188 let card = derive_card(&quads, 6, 0, CardInput::default());
3189 assert!(card.signals.geo_wkt, "asWKT/wktLiteral detected");
3190 assert!(card.signals.geo_latlong, "wgs84 lat+long detected");
3191 let bbox = card.signals.spatial_bbox.expect("bbox derived");
3192 assert_eq!(
3193 bbox,
3194 [12.5, 41.9, 12.5, 41.9],
3195 "[minLon,minLat,maxLon,maxLat]"
3196 );
3197 }
3198
3199 #[test]
3200 fn class_links_match_schema_summary() {
3201 use crate::{ingest, schema_summary, Rete};
3202 let quads = enriched_fixture();
3203 let card = derive_card(&quads, 12, 0, CardInput::default());
3204
3205 let (bytes, _) =
3207 ingest::assemble_dataset_with_opts(quads, true, false, None, |_, _| Vec::new());
3208 let rete = Rete::open(&bytes).unwrap();
3209 let reference: BTreeSet<(String, String, String, u64)> = schema_summary(&rete)
3210 .into_iter()
3211 .map(|(s, p, o, c)| (s, p, o, c as u64))
3212 .collect();
3213
3214 let derived: BTreeSet<(String, String, String, u64)> = card
3215 .class_links
3216 .iter()
3217 .map(|l| {
3218 (
3219 l.s_class.clone(),
3220 l.predicate.clone(),
3221 l.o_class.clone(),
3222 l.count,
3223 )
3224 })
3225 .collect();
3226
3227 assert_eq!(
3228 derived, reference,
3229 "card class_links must equal schema_summary's quotient row-for-row"
3230 );
3231 }
3232
3233 #[test]
3243 fn card_counts_are_the_deduplicated_counts() {
3244 use crate::ingest::{self, DeferredMetadata};
3245 use crate::Rete;
3246
3247 let g = |s: &str, p: &str, o: &str, graph: &str| {
3248 (
3249 s.to_string(),
3250 p.to_string(),
3251 o.to_string(),
3252 Some(graph.to_string()),
3253 )
3254 };
3255 let quads = vec![
3256 q("<http://ex/a>", "<http://ex/p>", "<http://ex/1>"),
3257 q("<http://ex/b>", "<http://ex/p>", "<http://ex/2>"),
3258 q("<http://ex/a>", "<http://ex/p>", "<http://ex/1>"), g("<http://ex/n>", "<http://ex/q>", "\"v\"", "<http://ex/g>"),
3260 g("<http://ex/n>", "<http://ex/q>", "\"v\"", "<http://ex/g>"), ];
3262 let (bytes, stats) =
3264 ingest::assemble_dataset_with_opts(quads, false, false, None, |stats, quads| {
3265 let card = derive_card(
3266 quads,
3267 stats.terms as u64,
3268 stats.named_graphs as u64,
3269 CardInput::default(),
3270 );
3271 DeferredMetadata::new(move |counts| card.with_final_counts(counts).to_json_bytes())
3272 });
3273
3274 let header = crate::Header::from_bytes(&bytes).unwrap();
3275 let card = load_card(&bytes).unwrap().expect("card embedded");
3276 assert_eq!(header.quad_count, 3, "header records the deduped quads");
3277 assert_eq!(
3278 card.quad_count, header.quad_count,
3279 "card and header must not disagree about the size of the same file"
3280 );
3281 assert_eq!(card.triple_count, 2, "default-graph triples, deduped");
3282
3283 let rete = Rete::open(&bytes).unwrap();
3285 let default_triples = rete.dump(None).len() as u64;
3286 let named: u64 = rete
3287 .graph_names()
3288 .iter()
3289 .map(|name| rete.dump(Some(name)).len() as u64)
3290 .sum();
3291 assert_eq!(default_triples + named, card.quad_count);
3292
3293 assert_eq!(stats.statements, 3);
3296 assert_eq!(stats.default_triples, 2);
3297 }
3298
3299 #[test]
3300 fn derive_is_deterministic() {
3301 let quads = enriched_fixture();
3304 let a = derive_card(&quads, 12, 0, CardInput::default()).to_json_bytes();
3305 let b = derive_card(&quads, 12, 0, CardInput::default()).to_json_bytes();
3306 assert_eq!(a, b, "enriched card derivation must be deterministic");
3307 assert!(!a.is_empty());
3308 }
3309
3310 #[test]
3311 fn old_plain_string_card_still_parses() {
3312 let json = r#"{
3315 "title":"Legacy",
3316 "triple_count":3,"quad_count":3,"named_graph_count":0,"term_count":5,
3317 "predicates":[["<http://ex/p>",2]],
3318 "example_queries":["SELECT * WHERE { ?s ?p ?o }"],
3319 "format_version":1
3320 }"#;
3321 let card = DatasetCard::from_json_bytes(json.as_bytes()).unwrap();
3322 assert_eq!(card.title.as_deref(), Some("Legacy"));
3323 assert_eq!(card.example_queries.len(), 1);
3324 assert!(card.datatypes.is_empty());
3326 assert!(card.languages.is_empty());
3327 assert!(card.class_links.is_empty());
3328 assert!(card.top_hubs.is_empty());
3329 assert!(card.in_hubs.is_empty());
3330 assert!(card.queries.is_empty());
3331 assert!(card.signals.is_empty());
3332 assert!(!card.truncated);
3333 }
3334
3335 #[test]
3339 fn curated_base_iri_overrides_derived_and_is_validated() {
3340 let quads = vec![q(
3341 "<https://doi.org/10.1/a>",
3342 "<http://ex/p>",
3343 "<http://ex/o>",
3344 )];
3345 let derived = derive_card(&quads, 3, 0, CardInput::default());
3346 assert_eq!(
3347 derived.signals.base_iri.as_deref(),
3348 Some("https://doi.org/10.1/")
3349 );
3350 let input = CardInput::from_json_str(r#"{"base_iri": "https://doi.org/"}"#).unwrap();
3351 assert!(!input.is_empty());
3352 let card = derive_card(&quads, 3, 0, input.clone());
3353 assert_eq!(card.signals.base_iri.as_deref(), Some("https://doi.org/"));
3354 let counts = curated_counts_card(1, 3, input);
3355 assert_eq!(counts.signals.base_iri.as_deref(), Some("https://doi.org/"));
3356 for bad in [
3357 "doi.org/",
3358 "",
3359 "https:",
3360 "1http://x/",
3361 "http://a b/",
3362 "http://x/<y>",
3363 ] {
3364 let doc = serde_json::json!({ "base_iri": bad }).to_string();
3365 let err = CardInput::from_json_str(&doc).unwrap_err();
3366 assert!(err.contains("base_iri"), "{bad}: {err}");
3367 }
3368 }
3369
3370 #[test]
3371 fn curated_identity_fields_round_trip() {
3372 let input: CardInput = serde_json::from_str(
3373 r#"{
3374 "title": "T",
3375 "version": "2026-08-04",
3376 "creators": [{"name":"Ada","orcid":"https://orcid.org/0000-0002-1825-0097"}],
3377 "publisher": {"name":"EPFL","ror":"https://ror.org/02s376052"},
3378 "canonical_url": "https://data.example.org/t.rete",
3379 "sparql_endpoint": "https://example.org/sparql",
3380 "source_date": "2026-07-01",
3381 "derived_from": ["https://example.org/dump.nt"],
3382 "doi": "https://doi.org/10.5281/zenodo.1",
3383 "cite_as": "Ada (2026). T.",
3384 "keywords": ["graphs", "citations"],
3385 "theme": ["http://publications.europa.eu/resource/authority/data-theme/TECH"]
3386 }"#,
3387 )
3388 .unwrap();
3389 let card = curated_counts_card(5, 9, input);
3390 let bytes = card.to_json_bytes();
3391 let back = DatasetCard::from_json_bytes(&bytes).unwrap();
3392 assert_eq!(back.version.as_deref(), Some("2026-08-04"));
3393 assert_eq!(back.creators[0].name, "Ada");
3394 assert_eq!(
3395 back.creators[0].orcid.as_deref(),
3396 Some("https://orcid.org/0000-0002-1825-0097")
3397 );
3398 assert_eq!(back.publisher.as_ref().unwrap().name, "EPFL");
3399 assert_eq!(
3400 back.canonical_url.as_deref(),
3401 Some("https://data.example.org/t.rete")
3402 );
3403 assert_eq!(back.derived_from, vec!["https://example.org/dump.nt"]);
3404 assert_eq!(
3405 back.doi.as_deref(),
3406 Some("https://doi.org/10.5281/zenodo.1")
3407 );
3408 assert_eq!(back.keywords, vec!["graphs", "citations"]);
3411 assert_eq!(
3412 back.theme,
3413 vec!["http://publications.europa.eu/resource/authority/data-theme/TECH"]
3414 );
3415
3416 let plain = curated_counts_card(1, 2, CardInput::default()).to_json_bytes();
3418 let text = String::from_utf8(plain).unwrap();
3419 for absent in [
3420 "\"creators\"",
3421 "\"publisher\"",
3422 "\"canonical_url\"",
3423 "\"sparql_endpoint\"",
3424 "\"derived_from\"",
3425 "\"doi\"",
3426 "\"cite_as\"",
3427 "\"source_date\"",
3428 "\"version\"",
3429 "\"top_n\"",
3430 "\"keywords\"",
3431 "\"theme\"",
3432 "\"extra\"",
3433 ] {
3434 assert!(
3435 !text.contains(absent),
3436 "{absent} must be omitted when unset"
3437 );
3438 }
3439 }
3440
3441 #[test]
3446 fn extra_round_trips_and_external_writer_cards_surface_it() {
3447 let input: CardInput = serde_json::from_str(
3448 r#"{"title":"T","extra":{"atlas:layer":84,"review":{"by":"dg","ok":true}}}"#,
3449 )
3450 .unwrap();
3451 let card = curated_counts_card(5, 9, input);
3452 let back = DatasetCard::from_json_bytes(&card.to_json_bytes()).unwrap();
3453 assert_eq!(back.extra, card.extra);
3454 assert_eq!(back.extra.get("atlas:layer"), Some(&serde_json::json!(84)));
3455
3456 let python_shaped = r#"{
3459 "title":"From Python","extra":{"pipeline":"nightly"},
3460 "triple_count":1,"quad_count":1,"named_graph_count":0,"term_count":3,
3461 "format_version":5
3462 }"#;
3463 let card = DatasetCard::from_json_bytes(python_shaped.as_bytes()).unwrap();
3464 assert_eq!(
3465 card.extra.get("pipeline"),
3466 Some(&serde_json::json!("nightly"))
3467 );
3468 }
3469
3470 #[test]
3473 fn extra_limits_bite_exactly_at_the_boundary() {
3474 use crate::card_input::normalize_extra;
3475 use serde_json::{json, Map, Value};
3476 let bag = |v: Vec<(String, Value)>| -> Map<String, Value> { v.into_iter().collect() };
3479
3480 let pad = |n: usize| bag(vec![("pad".into(), json!("x".repeat(n)))]);
3482 let at = pad(CARD_EXTRA_MAX_BYTES - 10);
3483 assert_eq!(serde_json::to_vec(&at).unwrap().len(), CARD_EXTRA_MAX_BYTES);
3484 normalize_extra(at).expect("exactly at the byte cap is accepted");
3485 let over = pad(CARD_EXTRA_MAX_BYTES - 9);
3486 assert_eq!(
3487 serde_json::to_vec(&over).unwrap().len(),
3488 CARD_EXTRA_MAX_BYTES + 1
3489 );
3490 let err = normalize_extra(over).unwrap_err().to_string();
3491 assert!(
3492 err.contains(&format!("{} bytes", CARD_EXTRA_MAX_BYTES + 1)),
3493 "the error states the actual size: {err}"
3494 );
3495
3496 let keys = |n: usize| {
3498 bag((0..n)
3499 .map(|i| (format!("k{i:03}"), json!(1)))
3500 .collect::<Vec<_>>())
3501 };
3502 normalize_extra(keys(CARD_EXTRA_MAX_KEYS)).expect("at the key cap");
3503 assert!(normalize_extra(keys(CARD_EXTRA_MAX_KEYS + 1)).is_err());
3504
3505 normalize_extra(bag(vec![("k".repeat(CARD_EXTRA_MAX_KEY_BYTES), json!(1))]))
3507 .expect("at the key-length cap");
3508 assert!(normalize_extra(bag(vec![(
3509 "k".repeat(CARD_EXTRA_MAX_KEY_BYTES + 1),
3510 json!(1)
3511 )]))
3512 .is_err());
3513 assert!(normalize_extra(bag(vec![(String::new(), json!(1))])).is_err());
3514
3515 normalize_extra(bag(vec![("d".into(), json!({"a": {"b": 1}}))])).expect("under the cap");
3519 normalize_extra(bag(vec![("d".into(), json!([{"w": ["x", "y"]}]))]))
3520 .expect("at the depth cap");
3521 assert!(normalize_extra(bag(vec![("d".into(), json!({"a": {"b": {"c": [1]}}}))])).is_err());
3522 assert!(normalize_extra(bag(vec![("d".into(), json!([[[[1]]]]))])).is_err());
3523
3524 let err = normalize_extra(bag(vec![("@context".into(), json!({}))]))
3526 .unwrap_err()
3527 .to_string();
3528 assert!(err.contains("reserved"), "loud reservation: {err}");
3529 }
3530
3531 #[test]
3536 fn extra_fields_keep_the_content_hash_reproducible() {
3537 let build = |json: &str| {
3538 let input: CardInput = serde_json::from_str::<CardInput>(json)
3541 .unwrap()
3542 .normalize()
3543 .unwrap();
3544 let quads = enriched_fixture();
3545 let card = derive_card(&quads, 12, 0, input);
3546 let (image, _) =
3547 crate::ingest::assemble_dataset_with_opts(quads, true, false, None, |_, _| {
3548 card.to_json_bytes()
3549 });
3550 image
3551 };
3552 let a = build(r#"{"title":"T","extra":{"b":{"y":2,"x":1},"a":[1,2]}}"#);
3553 let b = build(r#"{"title":"T","extra":{"a":[1,2],"b":{"x":1,"y":2}}}"#);
3554 assert_eq!(a, b, "reordered-input builds are byte-identical");
3555 let ha = Header::from_bytes(&a).unwrap().content_hash;
3556 assert_eq!(ha, Header::from_bytes(&b).unwrap().content_hash);
3557
3558 let plain = build(r#"{"title":"T"}"#);
3561 assert_ne!(ha, Header::from_bytes(&plain).unwrap().content_hash);
3562 }
3563
3564 #[test]
3568 fn top_level_custom_keys_are_rejected_not_dropped() {
3569 let err = serde_json::from_str::<CardInput>(r#"{"title":"T","my_field":1}"#)
3570 .unwrap_err()
3571 .to_string();
3572 assert!(err.contains("my_field"), "names the stray key: {err}");
3573
3574 let ok: CardInput =
3576 serde_json::from_str(r#"{"title":"T","extra":{"my_field":1}}"#).unwrap();
3577 assert_eq!(ok.extra.get("my_field"), Some(&serde_json::json!(1)));
3578 }
3579
3580 #[test]
3585 fn description_reads_as_a_string_or_as_lines() {
3586 let from_lines: CardInput = serde_json::from_value(serde_json::json!({
3587 "description": ["## Contents", "", "- a", "- b"]
3588 }))
3589 .unwrap();
3590 assert_eq!(
3591 from_lines.description.as_deref(),
3592 Some("## Contents\n\n- a\n- b")
3593 );
3594 let from_string: CardInput =
3595 serde_json::from_str("{\"description\":\"## Contents\\n\\n- a\\n- b\"}").unwrap();
3596 assert_eq!(from_string.description, from_lines.description);
3597
3598 let none: CardInput = serde_json::from_str(r#"{"title":"T"}"#).unwrap();
3600 assert!(none.description.is_none());
3601 let null: CardInput = serde_json::from_str(r#"{"description":null}"#).unwrap();
3602 assert!(null.description.is_none());
3603
3604 let over = "x".repeat(crate::card::CARD_DESCRIPTION_MAX_BYTES + 1);
3607 let err = serde_json::from_str::<CardInput>(&format!(
3608 "{{\"description\":{}}}",
3609 serde_json::to_string(&over).unwrap()
3610 ))
3611 .unwrap_err()
3612 .to_string();
3613 assert!(err.contains("over the"), "{err}");
3614 }
3615
3616 #[test]
3624 fn curated_field_list_matches_the_deny_unknown_fields_struct() {
3625 for f in crate::card::CURATED_CARD_FIELDS {
3626 let doc = format!(r#"{{"{f}":null}}"#);
3630 if let Err(e) = serde_json::from_str::<CardInput>(&doc) {
3631 assert!(
3632 !e.to_string().contains("unknown field"),
3633 "CURATED_CARD_FIELDS lists `{f}`, but CardInput rejects it as unknown: {e}",
3634 );
3635 }
3636 }
3637 let err = serde_json::from_str::<CardInput>(r#"{"nope":1}"#)
3640 .unwrap_err()
3641 .to_string();
3642 for name in err
3643 .split("expected one of ")
3644 .nth(1)
3645 .expect("serde names the expected fields")
3646 .split(" at line ")
3648 .next()
3649 .unwrap()
3650 .split(", ")
3651 .map(|s| s.trim().trim_matches('`'))
3652 .filter(|s| !s.is_empty())
3653 {
3654 assert!(
3655 crate::card::CURATED_CARD_FIELDS.contains(&name),
3656 "CardInput accepts `{name}` but CURATED_CARD_FIELDS does not list it — \
3657 the browser builder would reject a card the CLI accepts",
3658 );
3659 }
3660 }
3661
3662 #[test]
3669 fn a_browser_written_card_deserializes_here() {
3670 let curated = crate::card::validate_curated_card(&serde_json::json!({
3671 "title": "Built in a browser",
3672 "keywords": ["b", "a"],
3673 "theme": ["http://publications.europa.eu/resource/authority/data-theme/GOVE"],
3674 "creators": [{"name": "Ada", "orcid": "https://orcid.org/0000-0002-1825-0097"}],
3675 "extra": {"internal_id": "DS-1"},
3676 }))
3677 .expect("valid curated document");
3678 let composed = crate::card::compose_curated_card(curated, 3, 4, 1, 9, 5);
3679 let text = serde_json::to_string(&composed).unwrap();
3680
3681 let card: DatasetCard = serde_json::from_str(&text).expect("CLI reads a browser card");
3682 assert_eq!(card.title.as_deref(), Some("Built in a browser"));
3683 assert_eq!(card.keywords, vec!["a".to_string(), "b".to_string()]);
3684 assert_eq!(card.creators.len(), 1);
3685 assert_eq!(card.triple_count, 3);
3686 assert_eq!(card.quad_count, 4);
3687 assert_eq!(card.term_count, 9);
3688 assert_eq!(card.format_version, 5);
3689 assert!(card.predicates.is_empty() && card.queries.is_empty());
3692 assert!(!text.contains("\"predicates\""), "{text}");
3693 assert!(!text.contains("\"top_n\""), "{text}");
3694 }
3695
3696 #[test]
3700 fn old_reader_struct_accepts_new_card_json() {
3701 #[derive(serde::Deserialize)]
3702 struct OldCard {
3703 title: Option<String>,
3704 triple_count: u64,
3705 #[serde(default)]
3706 predicates: Vec<(String, u64)>,
3707 format_version: u8,
3708 }
3709 let input: CardInput = serde_json::from_str(
3710 r#"{"title":"New","version":"1","creators":[{"name":"A"}],
3711 "canonical_url":"https://x/y.rete","derived_from":["https://x/d.nt"],
3712 "extra":{"atlas:review":"approved"}}"#,
3713 )
3714 .unwrap();
3715 let quads = vec![q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>")];
3716 let card = derive_card(&quads, 3, 0, input);
3717 assert_eq!(card.top_n, CARD_TOP_N as u32);
3718 let bytes = card.to_json_bytes();
3719 let old: OldCard = serde_json::from_slice(&bytes).expect("old struct parses new JSON");
3720 assert_eq!(old.title.as_deref(), Some("New"));
3721 assert_eq!(old.triple_count, 1);
3722 assert_eq!(old.format_version, card.format_version);
3723 assert!(!old.predicates.is_empty());
3724 }
3725
3726 #[test]
3727 fn coherence_stamp_is_deterministic_and_additive() {
3728 let dis = "<http://www.w3.org/2002/07/owl#disjointWith>";
3729 let rt = "<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>";
3730 let base = vec![
3731 ("<http://ex/C>".into(), dis.into(), "<http://ex/D>".into()),
3732 ("<http://ex/x>".into(), rt.into(), "<http://ex/C>".into()),
3733 ("<http://ex/x>".into(), rt.into(), "<http://ex/D>".into()),
3734 ];
3735 let r = crate::reason(&base);
3736 let c1 = Coherence::from_reasoning(&r, false);
3737 assert_eq!(c1, Coherence::from_reasoning(&r, false));
3739 assert!(!c1.coherent);
3740 assert_eq!(c1.inconsistency_count, 1);
3741 assert_eq!(c1.by_kind, vec![("disjoint-classes".to_string(), 1)]);
3742 assert_eq!(c1.rules, crate::REASON_RULESET);
3743
3744 let json = DatasetCard::default()
3746 .with_coherence(&r, false)
3747 .to_json_bytes();
3748 let text = std::str::from_utf8(&json).unwrap();
3749 assert!(text.contains("coherence"));
3750 assert_eq!(DatasetCard::from_json_bytes(&json).unwrap().coherence, c1);
3751
3752 let plain = DatasetCard::default().to_json_bytes();
3753 assert!(!std::str::from_utf8(&plain).unwrap().contains("coherence"));
3754 assert!(DatasetCard::default().coherence.is_empty());
3755 }
3756
3757 #[test]
3758 fn base_iri_is_common_prefix_with_fallback() {
3759 let mut ns = NsTally::default();
3761 ns.add("http://ex/person/".to_string(), 2000);
3762 ns.add("http://ex/place/".to_string(), 600);
3763 ns.add("http://ex/org/".to_string(), 120);
3764 assert_eq!(derive_base_iri(&ns).as_deref(), Some("http://ex/"));
3765
3766 let mut one = NsTally::default();
3768 one.add("http://data.example.org/id/".to_string(), 10);
3769 assert_eq!(
3770 derive_base_iri(&one).as_deref(),
3771 Some("http://data.example.org/id/")
3772 );
3773
3774 let mut multi = NsTally::default();
3777 multi.add("http://a.example/".to_string(), 100);
3778 multi.add("http://b.other/".to_string(), 5);
3779 assert_eq!(
3780 derive_base_iri(&multi).as_deref(),
3781 Some("http://a.example/")
3782 );
3783
3784 assert_eq!(derive_base_iri(&NsTally::default()), None);
3785 }
3786
3787 #[test]
3788 fn parse_literal_decomposes_forms() {
3789 let plain = parse_literal("\"hello\"").unwrap();
3790 assert_eq!(plain.value, "hello");
3791 assert!(plain.datatype.ends_with("XMLSchema#string"));
3792 assert_eq!(plain.lang, "");
3793
3794 let tagged = parse_literal("\"bonjour\"@fr").unwrap();
3795 assert_eq!(tagged.lang, "fr");
3796 assert!(tagged.datatype.ends_with("#langString"));
3797
3798 let typed = parse_literal("\"42\"^^<http://www.w3.org/2001/XMLSchema#integer>").unwrap();
3799 assert_eq!(typed.value, "42");
3800 assert_eq!(typed.datatype, "http://www.w3.org/2001/XMLSchema#integer");
3801
3802 let esc = parse_literal("\"a\\\"b\"@en").unwrap();
3804 assert_eq!(esc.value, "a\\\"b");
3805 assert_eq!(esc.lang, "en");
3806
3807 assert!(parse_literal("<http://ex/x>").is_none());
3809 assert!(parse_literal("_:b0").is_none());
3810 }
3811
3812 #[test]
3817 fn date_shapes_are_strict() {
3818 for ok in [
3819 "2019",
3820 "-0044",
3821 "2019-05",
3822 "2019-05-01",
3823 "2019-05-01Z",
3824 "2019-05-01+02:00",
3825 "2019-05-01T12:30",
3826 "2019-05-01T12:30:15",
3827 "2019-05-01T12:30:15.250Z",
3828 "1950Z",
3829 ] {
3830 assert!(date_shaped(ok), "{ok} is a date");
3831 }
3832 for bad in [
3833 "0000-0001-5001-3721", "9999880", "1234-1240", "123-456",
3837 "2019 annual report", "2019-13",
3839 "2019-05-32",
3840 "2019-05-01T25:00",
3841 "10.1000/182", "",
3843 "-",
3844 "2019-",
3845 ] {
3846 assert!(!date_shaped(bad), "{bad} is not a date");
3847 }
3848 }
3849
3850 #[test]
3856 fn temporal_extent_ignores_identifiers_pages_and_titles() {
3857 let value = "<http://www.essepuntato.it/2010/06/literalreification/hasLiteralValue>";
3858 let start = "<http://prismstandard.org/namespaces/basic/2.0/startingPage>";
3859 let end = "<http://prismstandard.org/namespaces/basic/2.0/endingPage>";
3860 let title = "<http://purl.org/dc/terms/title>";
3861 let pubdate = "<http://prismstandard.org/namespaces/basic/2.0/publicationDate>";
3862 let issued = "<http://purl.org/dc/terms/issued>";
3863 let xsd = |t: &str| format!("http://www.w3.org/2001/XMLSchema#{t}");
3864 let mut quads = Vec::new();
3865 for (i, id) in [
3866 "0000-0001-5001-3721",
3867 "0000-0002-1825-0097",
3868 "10.1000/182",
3869 "9999880",
3870 "2019",
3871 ]
3872 .iter()
3873 .enumerate()
3874 {
3875 quads.push(q(
3876 &format!("<http://ex/id/{i}>"),
3877 value,
3878 &format!("\"{id}\""),
3879 ));
3880 }
3881 quads.push(q("<http://ex/re/1>", start, "\"9999880\""));
3882 quads.push(q("<http://ex/re/1>", end, "\"9999899\""));
3883 quads.push(q("<http://ex/re/2>", start, "\"1234-1240\""));
3884 quads.push(q("<http://ex/br/1>", title, "\"2019 annual report\""));
3885 quads.push(q(
3886 "<http://ex/br/1>",
3887 pubdate,
3888 &format!("\"2019-05-01\"^^<{}>", xsd("date")),
3889 ));
3890 quads.push(q(
3891 "<http://ex/br/2>",
3892 pubdate,
3893 &format!("\"1950\"^^<{}>", xsd("gYear")),
3894 ));
3895 quads.push(q(
3896 "<http://ex/br/3>",
3897 pubdate,
3898 &format!("\"2021-03\"^^<{}>", xsd("gYearMonth")),
3899 ));
3900 quads.push(q(
3902 "<http://ex/br/4>",
3903 pubdate,
3904 &format!("\"0000-0001-5001-3721\"^^<{}>", xsd("date")),
3905 ));
3906 quads.push(q("<http://ex/br/5>", issued, "\"1999-12\""));
3908 let card = derive_card(&quads, 40, 0, CardInput::default());
3909 let s = &card.signals;
3910 assert_eq!(
3911 s.time_predicates,
3912 vec![pubdate.to_string(), issued.to_string()],
3913 "only the date predicates are time predicates"
3914 );
3915 assert_eq!(
3916 s.temporal_extent,
3917 Some(("1950".to_string(), "2021-03".to_string())),
3918 "calendar order across xsd:gYear, xsd:date and xsd:gYearMonth"
3919 );
3920 assert!(
3922 s.numeric_predicates.is_empty(),
3923 "{:?}",
3924 s.numeric_predicates
3925 );
3926 assert!(card
3928 .queries
3929 .iter()
3930 .filter(|q| q.dimension == "time")
3931 .all(|q| q.sparql.contains("publicationDate")));
3932 }
3933
3934 #[test]
3935 fn negative_years_order_by_value_not_by_bytes() {
3936 let mut extent = BTreeMap::new();
3937 for v in ["-0044", "-0100", "0001", "-0044-03-15"] {
3938 update_time_extent("<p>", v, &mut extent);
3939 }
3940 assert_eq!(
3941 extent.get("<p>"),
3942 Some(&("-0100".to_string(), "0001".to_string()))
3943 );
3944 }
3945
3946 #[test]
3949 fn an_integer_year_off_a_date_predicate_is_numeric() {
3950 let int = "http://www.w3.org/2001/XMLSchema#integer";
3951 let quads = vec![
3952 q(
3953 "<http://ex/a>",
3954 "<http://ex/year>",
3955 &format!("\"1492\"^^<{int}>"),
3956 ),
3957 q(
3958 "<http://ex/b>",
3959 "<http://ex/year>",
3960 &format!("\"1500\"^^<{int}>"),
3961 ),
3962 ];
3963 let card = derive_card(&quads, 6, 0, CardInput::default());
3964 assert!(card.signals.time_predicates.is_empty());
3965 assert_eq!(
3966 card.signals.numeric_predicates,
3967 vec!["<http://ex/year>".to_string()]
3968 );
3969 }
3970
3971 fn streaming_fixture() -> Vec<(String, String, String, Option<String>)> {
3980 let xsd = |t: &str| format!("http://www.w3.org/2001/XMLSchema#{t}");
3981 let mut v = enriched_fixture();
3982 for i in 0..5 {
3983 let s = format!("<http://ex/place/{i}>");
3984 v.push(q(&s, TYPE, "<http://ex/Place>"));
3985 v.push(q(&s, LABEL, &format!("\"Lieu {i}\"@fr")));
3986 v.push(q(
3987 &s,
3988 "<http://www.w3.org/2003/01/geo/wgs84_pos#lat>",
3989 &format!("\"4{i}.5\"^^<{}>", xsd("double")),
3990 ));
3991 v.push(q(
3992 &s,
3993 "<http://www.w3.org/2003/01/geo/wgs84_pos#long>",
3994 &format!("\"{i}.25\"^^<{}>", xsd("double")),
3995 ));
3996 v.push(q(
3997 &s,
3998 "<http://www.opengis.net/ont/geosparql#asWKT>",
3999 &format!(
4000 "\"POINT({i}.25 4{i}.5)\"^^<http://www.opengis.net/ont/geosparql#wktLiteral>"
4001 ),
4002 ));
4003 v.push(q(
4004 &s,
4005 "<http://purl.org/dc/terms/created>",
4006 &format!("\"20{i}0-01-0{}\"^^<{}>", i + 1, xsd("date")),
4007 ));
4008 v.push(q(&s, "<http://ex/near>", "<http://elsewhere/undescribed>"));
4009 }
4010 let knows = "<http://ex/a> <http://ex/knows> <http://ex/b>";
4013 let lives = "<http://ex/a> <http://ex/livesIn> <http://ex/c>";
4014 let ghost = "<http://ex/b> <http://ex/knows> <http://ex/c>";
4015 for (t, who) in [(knows, "alice"), (lives, "bob"), (ghost, "carol")] {
4016 v.push(q(
4017 &format!("<<{t}>>"),
4018 "<http://ex/recordedBy>",
4019 &format!("<http://ex/{who}>"),
4020 ));
4021 }
4022 v.push(q(
4023 "<http://ex/claim/1>",
4024 "<http://ex/states>",
4025 &format!("<<{knows}>>"),
4026 ));
4027 v.push((
4028 "<http://ex/note/1>".into(),
4029 "<http://ex/about>".into(),
4030 "<http://ex/a>".into(),
4031 Some("<http://ex/graph/notes>".into()),
4032 ));
4033 v
4034 }
4035
4036 fn both_ways(quads: &[(String, String, String, Option<String>)]) -> (DatasetCard, DatasetCard) {
4039 let (bytes, _) =
4040 crate::ingest::assemble_dataset_with_opts(quads.to_vec(), true, false, None, |_, _| {
4041 Vec::new()
4042 });
4043 let rete = crate::Rete::open(&bytes).unwrap();
4044 let term_count = rete.header().term_count;
4045 let (card, report) =
4046 derive_card_streaming(&rete, CardInput::default(), &mut |_| {}).unwrap();
4047 assert_eq!(report.statements, card.triple_count);
4048 let in_memory = derive_card(
4049 quads,
4050 term_count,
4051 rete.named_graph_count() as u64,
4052 CardInput::default(),
4053 );
4054 (card, in_memory)
4055 }
4056
4057 #[test]
4061 fn streaming_derivation_matches_the_in_memory_card() {
4062 for quads in [enriched_fixture(), streaming_fixture()] {
4063 let (streamed, in_memory) = both_ways(&quads);
4064 assert_eq!(
4065 String::from_utf8(streamed.to_json_bytes()).unwrap(),
4066 String::from_utf8(in_memory.to_json_bytes()).unwrap()
4067 );
4068 }
4069 let (card, _) = both_ways(&streaming_fixture());
4071 assert!(card.signals.annotated_statement.is_some());
4072 assert!(!card.signals.quoting_predicates.is_empty());
4073 assert!(card.signals.spatial_bbox.is_some());
4074 assert!(card.signals.temporal_extent.is_some());
4075 assert_eq!(card.named_graph_count, 1);
4076 }
4077
4078 #[test]
4081 fn streaming_derivation_widens_the_class_map() {
4082 let mut quads = Vec::new();
4083 for i in 0..300 {
4084 let s = format!("<http://ex/thing/{i:03}>");
4085 quads.push(q(&s, TYPE, &format!("<http://ex/Class{i:03}>")));
4086 quads.push(q(
4087 &s,
4088 "<http://ex/next>",
4089 &format!("<http://ex/thing/{:03}>", (i + 1) % 300),
4090 ));
4091 }
4092 let (streamed, in_memory) = both_ways(&quads);
4093 assert!(streamed.truncated);
4094 assert_eq!(streamed.classes, in_memory.classes);
4095 assert_eq!(streamed.class_links, in_memory.class_links);
4096 assert_eq!(
4097 String::from_utf8(streamed.to_json_bytes()).unwrap(),
4098 String::from_utf8(in_memory.to_json_bytes()).unwrap()
4099 );
4100 }
4101
4102 #[test]
4105 fn streaming_derivation_counts_the_deduplicated_file() {
4106 let mut quads = enriched_fixture();
4107 quads.push(quads[0].clone());
4108 let (streamed, in_memory) = both_ways(&quads);
4109 assert_eq!(streamed.triple_count, in_memory.triple_count - 1);
4110 let type_count = |c: &DatasetCard| {
4111 c.predicates
4112 .iter()
4113 .find(|(p, _)| p == TYPE)
4114 .map(|(_, n)| *n)
4115 };
4116 assert_eq!(type_count(&streamed), type_count(&in_memory).map(|n| n - 1));
4117 }
4118
4119 #[test]
4122 fn the_curated_half_of_a_card_round_trips() {
4123 let doc = r##"{"title":"T","description":["# Heading","line"],"license":"CC0-1.0",
4124 "source":"https://ex/src","created":"2026-01-01","version":"1",
4125 "creators":[{"name":"Ada","orcid":"https://orcid.org/0000-0002-1825-0097"}],
4126 "publisher":{"name":"P","ror":"https://ror.org/01ggx4157"},
4127 "canonical_url":"https://ex/f.rete","sparql_endpoint":"https://ex/sparql",
4128 "source_date":"2025-12-31","derived_from":["https://ex/dump"],
4129 "doi":"https://doi.org/10.1/x","cite_as":"Cite me","keywords":["b","a"],
4130 "theme":["http://publications.europa.eu/resource/authority/data-theme/EDUC"],
4131 "extra":{"k":[{"words":["x"]}]},"example_queries":["ASK {}"]}"##;
4132 let curated = CardInput::from_json_str(doc).unwrap();
4133 assert!(!curated.is_empty());
4134 let card = derive_card(&enriched_fixture(), 12, 0, curated);
4135 let again = derive_card(&enriched_fixture(), 12, 0, CardInput::from_card(&card));
4136 assert_eq!(again.to_json_bytes(), card.to_json_bytes());
4137 assert!(CardInput::from_card(&derive_card(
4138 &enriched_fixture(),
4139 12,
4140 0,
4141 CardInput::default()
4142 ))
4143 .is_empty());
4144 }
4145}
4146
4147#[cfg(test)]
4148mod ns_tally_tests {
4149 use super::*;
4150
4151 fn feed(t: &mut NsTally, n: usize) {
4152 for i in 0..n {
4153 let ns = if i % 7 == 0 {
4155 "https://doi.org/10.1/".to_string()
4156 } else {
4157 format!("https://doi.org/10.1/a{i}/")
4158 };
4159 t.add(ns, 1);
4160 }
4161 }
4162
4163 #[test]
4164 fn under_the_cap_the_tally_is_exact() {
4165 let mut t = NsTally::with_cap(100);
4166 feed(&mut t, 50);
4167 assert!(!t.overflowed());
4168 assert_eq!(t.len(), 43);
4169 assert_eq!(t.dominant().as_deref(), Some("https://doi.org/10.1/"));
4170 assert_eq!(
4171 derive_base_iri(&t).as_deref(),
4172 Some("https://doi.org/10.1/")
4173 );
4174 }
4175
4176 #[test]
4177 fn past_the_cap_the_tally_stays_bounded_and_deterministic() {
4178 let run = || {
4179 let mut t = NsTally::with_cap(64);
4180 feed(&mut t, 5_000);
4181 assert!(t.len() <= 64);
4182 assert!(t.overflowed());
4183 let snapshot: Vec<(String, u64)> =
4184 t.counts.iter().map(|(k, v)| (k.clone(), *v)).collect();
4185 (snapshot, t.dominant(), derive_base_iri(&t))
4186 };
4187 let (a, b) = (run(), run());
4188 assert_eq!(a, b);
4189 assert_eq!(a.1.as_deref(), Some("https://doi.org/10.1/"));
4192 assert_eq!(a.2.as_deref(), Some("https://doi.org/10.1/"));
4193 }
4194
4195 #[test]
4196 fn common_prefix_spans_all_hosts_even_when_entries_were_evicted() {
4197 let mut t = NsTally::with_cap(2);
4198 t.add("http://a.org/x/".into(), 10);
4199 t.add("http://a.org/y/".into(), 1);
4200 t.add("http://b.org/z/".into(), 1);
4201 assert!(t.overflowed());
4202 assert_eq!(t.common_prefix(), Some("http://"));
4203 assert_eq!(derive_base_iri(&t).as_deref(), Some("http://a.org/x/"));
4205 }
4206
4207 #[test]
4210 fn card_is_truncated_and_reproducible_past_the_cap() {
4211 let n = NS_TALLY_CAP + 2_000;
4212 let quads: Vec<_> = (0..n)
4213 .map(|i| {
4214 (
4215 format!("<https://doi.org/10.1/{i}/x>"),
4216 "<http://ex/p>".to_string(),
4217 "\"v\"".to_string(),
4218 None,
4219 )
4220 })
4221 .collect();
4222 let (bytes, _) =
4223 crate::ingest::assemble_dataset_with_opts(quads.clone(), true, false, None, |_, _| {
4224 Vec::new()
4225 });
4226 let rete = crate::Rete::open(&bytes).unwrap();
4227 let go = || derive_card_streaming(&rete, CardInput::default(), &mut |_| {}).unwrap();
4228 let (c1, r1) = go();
4229 let (c2, _) = go();
4230 assert!(r1.namespaces_overflowed);
4231 assert_eq!(r1.namespaces_kept as usize, NS_TALLY_CAP);
4232 assert!(c1.truncated);
4233 assert_eq!(c1.to_json_bytes(), c2.to_json_bytes());
4234 let mem = derive_card(&quads, rete.header().term_count, 0, CardInput::default());
4235 assert!(mem.truncated);
4236 assert_eq!(c1.signals.base_iri, mem.signals.base_iri);
4237 }
4238}