rete_core/card_derive.rs
1//! Dataset Cards — the **derived** half: the card type itself and the profile
2//! computed from a graph's own statements.
3//!
4//! A card is an embeddable data-catalog record stored in a `.rete` file's
5//! metadata section. It carries **curated** metadata (title, license, source,
6//! description, created, keywords/theme, example queries, plus a bounded bag of
7//! publisher-defined custom fields under `extra` — see [`card_input`]) and an
8//! **auto-derived** profile (counts, top predicates and classes, vocabularies,
9//! datatypes, languages, class links, hubs, signals, and the tiered
10//! starter-query library of [`card_queries`]), serialized as JSON.
11//!
12//! # Why this lives in `rete-core` and not in the CLI
13//!
14//! It used to live in `rete-cli`, which is a **binary-only crate** — no `lib.rs`,
15//! so nothing can link it. A `.rete` built from Python, R, Java, JavaScript or
16//! the in-browser builder could therefore write the curated half of a card and
17//! *never* the derived half, no matter how the CLI was fixed (#152). The
18//! derivation touches nothing but `std::collections`, `serde` and this crate, so
19//! there was never a technical reason for the split: it is here now, and one
20//! correction holds for every surface.
21//!
22//! Everything that is *not* pure derivation stays in the CLI — reading a
23//! `--card-file` off disk, range-reading a card over HTTP, rendering the human
24//! catalog view, measuring query costs. Those are the parts that would have made
25//! this module unusable from wasm.
26//!
27//! [`card_input`]: crate::card_input
28//! [`card_queries`]: crate::card_queries
29
30use std::collections::{BTreeMap, BTreeSet};
31
32use serde::{Deserialize, Serialize};
33
34use crate::{Header, RDF_TYPE};
35/// The embeddable dataset card. Curated fields come from `rete build` flags or a
36/// `--card-file` JSON document; the statistics are derived from the data at build
37/// time. Absent optional/empty fields are omitted from the JSON.
38#[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 // --- Curated identity & provenance (issue #153). All deterministic
52 // (hand-supplied via `--card-file`), so they live INSIDE the content hash;
53 // the per-build volatile facts (timestamp, builder, timings) live in the
54 // separate unhashed build-info section instead. ---
55 /// The publisher's **dataset version** (e.g. a date or semver) — Croissant
56 /// requires one. One of three distinct versions a `.rete` carries, each
57 /// with its own owner, never merged: `version` (the *data*, set by the
58 /// publisher here), `format_version` (the *spec* the file conforms to,
59 /// stamped by the builder), and the builder's own identity
60 /// (`rete-cli 0.3.2`, in the build-info section).
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub version: Option<String>,
63 /// The people who made the dataset. ORCID as an IRI, not a string, so a
64 /// card's creator is joinable against the published ORCID graph.
65 #[serde(default, skip_serializing_if = "Vec::is_empty")]
66 pub creators: Vec<Creator>,
67 /// Publishing organisation, with its ROR IRI for the same joinability.
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub publisher: Option<Publisher>,
70 /// Where the authoritative copy of THIS file lives (`void:dataDump`). A
71 /// `.rete` found on a disk can then say where to verify against.
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub canonical_url: Option<String>,
74 /// A public SPARQL endpoint serving this dataset (`void:sparqlEndpoint`).
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub sparql_endpoint: Option<String>,
77 /// The SOURCE data's own date (harvest/snapshot), distinct from both the
78 /// curated `created` and the build-info timestamp.
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub source_date: Option<String>,
81 /// What this file was derived from (`prov:wasDerivedFrom`): source dumps,
82 /// upstream `.rete` shards a merge folded in, an endpoint harvested.
83 #[serde(default, skip_serializing_if = "Vec::is_empty")]
84 pub derived_from: Vec<String>,
85 /// DOI of the dataset, as an IRI (`https://doi.org/…`).
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub doi: Option<String>,
88 /// Preferred citation text.
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub cite_as: Option<String>,
91 /// Free-text keywords/tags describing the dataset. First-class rather
92 /// than an `extra` entry because keywords carry **agreed meaning**:
93 /// `dcat:keyword` and `schema:keywords` are long-standing terms (DCAT-AP
94 /// catalogs require the former; dataset-search harvesters read the
95 /// latter), and the bag's contract is precisely the *absence* of a term —
96 /// it projects as opaque values. Canonicalized by
97 /// [`crate::card_input::normalize_string_list`] at build time: trimmed, sorted, deduplicated
98 /// — `dcat:keyword` is an unordered repeated property, so sorting loses
99 /// nothing and keeps the card's bytes (hence the reproducible content
100 /// hash) independent of authoring order.
101 #[serde(default, skip_serializing_if = "Vec::is_empty")]
102 pub keywords: Vec<String>,
103 /// Curated **themes** — IRIs into a controlled vocabulary
104 /// (`dcat:theme`, e.g. the EU data-theme authority). IRIs are
105 /// **required** ([`crate::card_input::normalize_themes`]): a free-text theme is a keyword by
106 /// another name and belongs in `keywords` — the IRI into an agreed
107 /// scheme is exactly what makes `dcat:theme` worth a separate field.
108 /// Sorted/deduplicated like `keywords`.
109 #[serde(default, skip_serializing_if = "Vec::is_empty")]
110 pub theme: Vec<String>,
111 // There is deliberately NO curated language field next to these: in RDF
112 // the language rides on each literal (`@lang` / `rdf:langString`), so the
113 // dataset's languages are DERIVABLE — and derived below (`languages`,
114 // `signals.default_lang`). A curated duplicate would be a second source
115 // of truth that can drift from the data with no way to tell which is
116 // right. See "First-class field or the bag?" in docs/dataset-cards.md.
117 /// Publisher-defined **custom fields** — one bounded, reserved bag.
118 /// Anything under `extra` is by definition *not* a rete-defined field:
119 /// official card fields are only ever added at the top level (which
120 /// [`CardInput`] keeps reserved by rejecting unknown keys), so a future
121 /// release can never collide with a publisher's key. Curated input, so
122 /// the bag folds into the content hash; [`crate::card_input::normalize_extra`] canonicalizes
123 /// (sorts) nested object keys and enforces the `CARD_EXTRA_*` bounds at
124 /// build time. Readers accept whatever is present — the limits bite when
125 /// writing, never when reading.
126 #[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 // --- Enriched, auto-derived profile (all additive; `#[serde(default)]` so
144 // older cards without these fields still deserialize). Every list is capped
145 // (see `CARD_TOP_N`) and deterministically ordered so the card folds into a
146 // reproducible content hash; `truncated` records whether any list was cut. ---
147 /// `DATATYPE(o)` histogram over literal objects, descending by count. Keys are
148 /// bracketed datatype IRIs (`<…#integer>`, `<…#langString>` for `@lang`).
149 #[serde(default, skip_serializing_if = "Vec::is_empty")]
150 pub datatypes: Vec<(String, u64)>,
151 /// `LANG(o)` histogram over literal objects, descending by count. The empty
152 /// string `""` counts untagged/typed literals.
153 #[serde(default, skip_serializing_if = "Vec::is_empty")]
154 pub languages: Vec<(String, u64)>,
155 /// The effective schema: `(s_class, predicate, o_class, count)` over the
156 /// default graph, the same quotient as [`crate::schema_summary`]. Object
157 /// classes use the `(literal)`/`(untyped)` sentinels of that function.
158 #[serde(default, skip_serializing_if = "Vec::is_empty")]
159 pub class_links: Vec<ClassLink>,
160 /// Top subjects by out-degree (how many statements they make), descending.
161 #[serde(default, skip_serializing_if = "Vec::is_empty")]
162 pub top_hubs: Vec<(String, u64)>,
163 /// Top non-literal objects by in-degree (how often referenced), descending.
164 #[serde(default, skip_serializing_if = "Vec::is_empty")]
165 pub in_hubs: Vec<(String, u64)>,
166 /// Detected affordances — which exploration query families the data supports
167 /// and the vocabulary to instantiate them with.
168 #[serde(default, skip_serializing_if = "Signals::is_empty")]
169 pub signals: Signals,
170 /// Auto-generated, tiered starter-query library — vetted SPARQL with the
171 /// dataset's own vocabulary substituted in, each tagged with the cheapest
172 /// tier that can answer it. The cold-start "what do I ask?" fix.
173 #[serde(default, skip_serializing_if = "Vec::is_empty")]
174 pub queries: Vec<ExampleQuery>,
175 /// OWL RL / RDFS coherence verdict, stamped at build time by `--reason`. Lets a
176 /// remote reader learn the graph's coherence from the index-free card with zero
177 /// compute; `rete reason --verify-card` recomputes it to guard against drift.
178 #[serde(default, skip_serializing_if = "Coherence::is_empty")]
179 pub coherence: Coherence,
180 /// Set iff any capped list was actually truncated (the profile is partial).
181 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
182 pub truncated: bool,
183 /// The top-N cap the profile lists were derived under — the number
184 /// `truncated: true` was hinting at without stating. 0 (omitted) on cards
185 /// whose profile was never derived (external builds, pre-existing cards).
186 #[serde(default, skip_serializing_if = "is_zero_u32")]
187 pub top_n: u32,
188
189 /// The `.rete` **spec version** the file was written against (currently
190 /// 5). The format's version — not the dataset's (the curated `version`
191 /// above) and not the builder's (`builder` in build-info).
192 pub format_version: u8,
193}
194
195/// serde helper: omit a `0` cap (profile-less cards).
196fn is_zero_u32(v: &u32) -> bool {
197 *v == 0
198}
199
200/// A dataset creator: a person (or team) with an optional ORCID IRI.
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub struct Creator {
203 pub name: String,
204 /// `https://orcid.org/0000-0000-0000-0000`
205 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub orcid: Option<String>,
207}
208
209/// The publishing organisation, with an optional ROR IRI.
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211pub struct Publisher {
212 pub name: String,
213 /// `https://ror.org/…`
214 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub ror: Option<String>,
216}
217
218/// Which tier of the three-tier exploration model can answer a query cheapest:
219/// `Card` (precomputed in this metadata section, index-free), `Summary` (the
220/// pyramid superedge totals, index-free), or `Index` (needs the triple index).
221#[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/// One vetted starter query, instantiated with the dataset's real vocabulary.
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub struct ExampleQuery {
232 /// Stable identifier (e.g. `ov-triples`).
233 pub id: String,
234 /// Short human title.
235 pub title: String,
236 /// Exploration dimension (overview/identity/labels/types/topology/…).
237 pub dimension: String,
238 /// The plain-language question a newcomer would ask.
239 pub question: String,
240 /// Full runnable SPARQL, PREFIX block included, placeholders substituted.
241 pub sparql: String,
242 /// The cheapest tier that can answer it.
243 pub tier: Tier,
244 /// Capability keys that had to be present to emit this query.
245 #[serde(default, skip_serializing_if = "Vec::is_empty")]
246 pub requires: Vec<String>,
247}
248
249/// One edge of the effective schema graph: a `predicate` connecting subjects of
250/// class `s_class` to objects of class `o_class`, with the instance `count`.
251/// Mirrors [`crate::schema_summary`] rows (sentinels `(literal)`/`(untyped)`).
252#[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/// Detected affordances — the index-free hints a newcomer (or the query-library
261/// generator) needs to know what the data supports and how to address it. Every
262/// field is optional/empty when the corresponding signal is absent, so geo/time
263/// query families are emitted only when the data actually has geometry/time.
264#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
265pub struct Signals {
266 /// Most frequent labelling predicate (`rdfs:label`/`skos:prefLabel`/…).
267 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub label_predicate: Option<String>,
269 /// Dominant non-empty language tag over literal objects.
270 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub default_lang: Option<String>,
272 /// Dominant subject namespace — the dataset's own base IRI.
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub base_iri: Option<String>,
275 /// Temporal predicates, ranked by frequency (most-used first).
276 #[serde(default, skip_serializing_if = "Vec::is_empty")]
277 pub time_predicates: Vec<String>,
278 /// Predicates whose objects are numeric (xsd integer/decimal/double/float),
279 /// ranked by frequency — candidates for `MIN/AVG/MAX` value-range queries.
280 #[serde(default, skip_serializing_if = "Vec::is_empty")]
281 pub numeric_predicates: Vec<String>,
282 /// Cross-dataset link predicates present (`owl:sameAs`/`skos:exactMatch`/…).
283 #[serde(default, skip_serializing_if = "Vec::is_empty")]
284 pub link_predicates: Vec<String>,
285 /// Predicates that make a statement **about a statement** — the predicate
286 /// of a statement whose *subject* is a quoted triple
287 /// (`<< s p o >> :recordedBy :who`) — ranked by frequency.
288 ///
289 /// Derived from the triples, and therefore **stored**, unlike the
290 /// [`Signals::quoted_triples`] presence bit below, which is read off the
291 /// header at display time. The two answer different questions and neither
292 /// substitutes for the other: presence says *will I meet triple terms in
293 /// this file*, which every already-published file can answer for free;
294 /// this says *which vocabulary annotates statements here*, which only a
295 /// pass over the statements knows and which is what a starter query has to
296 /// be written in. Empty on the overwhelming majority of datasets, and the
297 /// whole `qt-*` query family is gated on it.
298 #[serde(default, skip_serializing_if = "Vec::is_empty")]
299 pub annotation_predicates: Vec<String>,
300 /// Predicates whose **object** is a quoted triple (`:claim :states
301 /// << s p o >>`) — the other place a triple term can stand — ranked by
302 /// frequency. A dataset can have one shape and not the other, and a query
303 /// written for the wrong one returns nothing, so they are counted apart.
304 #[serde(default, skip_serializing_if = "Vec::is_empty")]
305 pub quoting_predicates: Vec<String>,
306 /// The commonest **annotated statement shape**: `(the predicate inside the
307 /// quoted triple, the predicate that annotates it)`, for a statement this
308 /// graph was seen both to **assert** (`s p o`) and to **annotate**
309 /// (`<< s p o >> :about ?v`).
310 ///
311 /// The pair is the witness — the same role `class_links` plays for
312 /// `LABELED_CLASS`. `annotation_predicates[0]` and the commonest quoted
313 /// predicate are two independent maxima and need not describe the same
314 /// statement, so a body that pins both is only safe when the derivation
315 /// saw them together. Assertedness is part of the witness because RDF-star
316 /// does not entail it: `<< s p o >> :about ?v` says nothing about whether
317 /// `s p o` is in the graph, and a coverage query over a relation the graph
318 /// never asserts would report `0 / 0`.
319 ///
320 /// Matched over a sample of the star layer (`ANNOTATION_WITNESS_SAMPLE`),
321 /// so a miss can drop the query that needs it but can never ship a broken
322 /// one.
323 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub annotated_statement: Option<(String, String)>,
325 /// Any `geo:asWKT` / `geo:wktLiteral` geometry present.
326 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
327 pub geo_wkt: bool,
328 /// Both `wgs84:lat` and `wgs84:long` present.
329 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
330 pub geo_latlong: bool,
331 /// MIN/MAX lexical extent over the dominant time predicate (same datatype).
332 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub temporal_extent: Option<(String, String)>,
334 /// `[minLon, minLat, maxLon, maxLat]` over wgs84 lat/long objects.
335 #[serde(default, skip_serializing_if = "Option::is_none")]
336 pub spatial_bbox: Option<[f64; 4]>,
337 /// Whether the file carries a **full-text (TEXT_INDEX) section**.
338 ///
339 /// Unlike every other signal this one is **not derived from the triples and
340 /// not stored in the card**: it is measured from the file's section
341 /// directory by whoever reads the card, and [`DatasetCard::to_json_bytes`]
342 /// strips it before the bytes are written. See [`TextIndexSignal`] for why.
343 ///
344 /// `None` therefore means **unknown** — a card read out of a saved JSON
345 /// document, with no file to measure — and never "no index".
346 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub text_index: Option<TextIndexSignal>,
348 /// Which **index permutations** the file stores. Like
349 /// [`Signals::text_index`], measured from the file at read time and
350 /// stripped before the card's bytes are written; `None` means unknown (a
351 /// card document with no file behind it), never "the default".
352 #[serde(default, skip_serializing_if = "Option::is_none")]
353 pub permutations: Option<PermutationsSignal>,
354 /// Whether the file holds any **quoted triple**, and which export surfaces
355 /// a consumer can ask for. Like the two signals above, measured from the
356 /// file at read time and stripped before the card's bytes are written;
357 /// `None` means unknown (a card document with no file behind it), never
358 /// "no quoted triples". See [`QuotedTriplesSignal`].
359 #[serde(default, skip_serializing_if = "Option::is_none")]
360 pub quoted_triples: Option<QuotedTriplesSignal>,
361}
362
363impl Signals {
364 /// True when no affordance was detected (lets the whole block be omitted).
365 pub fn is_empty(&self) -> bool {
366 *self == Signals::default()
367 }
368}
369
370/// The file's full-text-search affordance: does it carry a **TEXT_INDEX section
371/// (kind 6)**, and what does that index cost?
372///
373/// A `.rete` built with `--text-index` answers `FILTER(CONTAINS(…))` by word
374/// lookup; one built without it answers the *same query with the same rows* by
375/// full scan. The capability is invisible from the results, which is exactly how
376/// the playground catalog came to advertise an index two published files never
377/// carried (#189) — so a file has to be able to state it.
378///
379/// **Measured at read time, never stored.** The ground truth is the section
380/// directory in the 1 KiB header that every card read already fetches, so a
381/// stored copy would be a second source of truth for a fact the first source
382/// answers for free — the same reasoning that keeps a curated `language` field
383/// out of the card. Being a projection rather than a stamp also means every
384/// already-published file reports it today, with no re-card, and that
385/// `rete repyramid --text-index` (which rewrites the file's sections) can never
386/// leave it stale.
387///
388/// **What it deliberately does not carry: a token count.** The number of
389/// distinct indexed words is the first varint of the *decompressed* token
390/// table, so quoting it would mean fetching and inflating the whole table —
391/// 193 MB on the published `causenet-full-typed.rete`. [`token_table_bytes`] is
392/// the same question answered for ≤10 bytes, and is the more useful answer
393/// anyway: it is what a first search actually costs.
394///
395/// [`token_table_bytes`]: TextIndexSignal::token_table_bytes
396#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
397pub struct TextIndexSignal {
398 /// True iff the file has a kind-6 section. Always written, including as
399 /// `false`: "measured, and there is none" is the fact #189 was missing.
400 pub present: bool,
401 /// Byte length of the whole TEXT_INDEX section — free, straight from the
402 /// header. Worth stating because it can dominate the download:
403 /// `causenet-full-typed.rete` carries 1.88 GB of it, 29% of the file.
404 #[serde(default, skip_serializing_if = "is_zero_u64")]
405 pub bytes: u64,
406 /// Byte length of the section's leading **token table** — the prefix a
407 /// first search faults, several times smaller than `bytes` (which counts
408 /// the postings blob, fetched one posting at a time). One ≤10-byte range
409 /// read; `None` when there is no index, or its first bytes were unreadable.
410 #[serde(default, skip_serializing_if = "Option::is_none")]
411 pub token_table_bytes: Option<u64>,
412}
413
414/// serde helper: omit a `0` byte count (no section to measure).
415fn is_zero_u64(v: &u64) -> bool {
416 *v == 0
417}
418
419/// The file's **index permutation set**: how many orders of `(s, p, o)` it
420/// stores, and therefore whether its planner can run a sort-merge join.
421///
422/// `rete build --permutations 3` writes SPO, POS and OSP only. Those three tie
423/// the longest bound prefix on all eight triple-pattern shapes, so the file
424/// answers **every query with the same rows, routed to the same tiles** — the
425/// difference is invisible from the results, exactly like a missing
426/// full-text index (#189). What it gives up is the merge join: SOP/PSO/OPS
427/// exist only to hand a join two streams already sorted on the join key, and
428/// they are typically ~40% of a built file. A consumer choosing between two
429/// mirrors of one dataset, or deciding whether a join-heavy workload will hold
430/// up, has to be able to see which it has.
431///
432/// **Measured at read time, never stored**, for the reasons [`TextIndexSignal`]
433/// spells out and one more that is specific to this signal: which permutations
434/// a file carries is a fact about its *bytes*. A stored copy would be an
435/// authored claim about the file's own layout — the one class of statement the
436/// file can always check for itself, in the 1 KiB header every card read has
437/// already fetched. This costs **no extra read at all**.
438#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
439pub struct PermutationsSignal {
440 /// How many permutations are stored: 3 or 6 today.
441 pub count: u8,
442 /// Their names in section order, e.g. `["SPO", "POS", "OSP"]`. Written out
443 /// rather than implied by `count`, because the mask is a set and a future
444 /// build may keep a different three.
445 pub names: Vec<String>,
446 /// Whether the file carries the merge-join orders (SOP, PSO, OPS) — i.e.
447 /// whether a two-pattern join sharing a same-role variable can be answered
448 /// by a linear two-pointer merge instead of a hash join.
449 pub merge_join: bool,
450}
451
452impl PermutationsSignal {
453 /// Read it straight off the parsed header — the mask is one byte at `[50]`,
454 /// and `0` there means all six (every file written before the mask
455 /// existed). No range read of any kind.
456 pub fn probe(header: &Header) -> Self {
457 let perms = header.perms;
458 PermutationsSignal {
459 count: perms.len() as u8,
460 names: perms.names().into_iter().map(str::to_string).collect(),
461 merge_join: perms.has_merge_orders(),
462 }
463 }
464
465 /// One line for the human catalog view and the audit report.
466 pub fn describe(&self) -> String {
467 if self.merge_join {
468 format!(
469 "{} index permutations ({}) — sort-merge joins available",
470 self.count,
471 self.names.join("/")
472 )
473 } else {
474 format!(
475 "{} index permutations ({}) — same rows, same routing; \
476 no sort-merge join (hash/probe answers instead)",
477 self.count,
478 self.names.join("/")
479 )
480 }
481 }
482}
483
484/// The `--quoted-triple-syntax` value that writes the RDF 1.2 triple term
485/// `<<( s p o )>>`, and `rete export`'s **default**.
486///
487/// Named here, not only in the CLI's flag table, because the dataset card
488/// states which surfaces a consumer can ask for — and a card that says `rdf12`
489/// while the flag has been renamed would be worse than one that says nothing.
490pub const QUOTED_TRIPLE_SURFACE_RDF12: &str = "rdf12";
491
492/// The `--quoted-triple-syntax` value that writes the RDF-star surface
493/// `<<s p o>>`, which is also the token rete stores.
494pub const QUOTED_TRIPLE_SURFACE_RDF_STAR: &str = "rdf-star";
495
496/// Whether the file contains **quoted triples** — statements standing inside
497/// other statements — and, if it does, what a consumer gets when they export it.
498///
499/// This is the question "will I meet triple terms in this dataset, and in which
500/// spelling?", which a consumer needs answered *before* they download a 50 GB
501/// file and run `rete export` on it. The two surfaces are not interchangeable
502/// downstream: an RDF 1.2 parser rejects `<<s p o>>` in N-Quads and silently
503/// reads it as a *reifier* in Turtle/TriG, and an RDF-star parser rejects
504/// `<<( s p o )>>` everywhere. Which one a dump carries decides whether it loads
505/// at all, so it belongs in the card rather than in the reader's memory of a CLI
506/// flag.
507///
508/// **Measured at read time from the header, never stored**, exactly like
509/// [`TextIndexSignal`] and [`PermutationsSignal`], and for the sharpest version
510/// of the same reason: header flag bit 2 (`FLAG_HAS_QUOTED_TRIPLES`) has been
511/// written by every build since quoted triples were supported, so this is an
512/// honest answer for **every file that already exists** — the 44 published
513/// datasets included — with no re-card and no rebuild. A stored copy would be
514/// `null` on all of them, which is worse than nothing: it would assert
515/// "unknown" about files whose own header knows.
516///
517/// `None` therefore means **unknown** — a card read out of a saved JSON
518/// document, with no file to measure — and never "no quoted triples".
519///
520/// There is deliberately **no count**. The header carries presence, not
521/// cardinality, and counting would mean decoding the dictionary — outside the
522/// CARD tier's budget (header + metadata, never the dictionary), and available
523/// only to files built after this existed, which is the failure mode above.
524#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
525pub struct QuotedTriplesSignal {
526 /// Does the file hold at least one quoted triple as a dictionary term?
527 pub present: bool,
528 /// The surfaces `rete export` can write them in, most interoperable first.
529 /// Empty when `present` is false: the question does not arise.
530 #[serde(default, skip_serializing_if = "Vec::is_empty")]
531 pub export_surfaces: Vec<String>,
532 /// Which of `export_surfaces` `rete export` writes with no flag. `None`
533 /// when `present` is false.
534 #[serde(default, skip_serializing_if = "Option::is_none")]
535 pub export_default: Option<String>,
536}
537
538impl QuotedTriplesSignal {
539 /// Read it straight off the parsed header — flag bit 2, in the 1 KiB every
540 /// card read has already fetched. No range read of any kind.
541 pub fn probe(header: &Header) -> Self {
542 if !header.has_quoted_triples() {
543 return QuotedTriplesSignal {
544 present: false,
545 export_surfaces: Vec::new(),
546 export_default: None,
547 };
548 }
549 QuotedTriplesSignal {
550 present: true,
551 export_surfaces: vec![
552 QUOTED_TRIPLE_SURFACE_RDF12.to_string(),
553 QUOTED_TRIPLE_SURFACE_RDF_STAR.to_string(),
554 ],
555 export_default: Some(QUOTED_TRIPLE_SURFACE_RDF12.to_string()),
556 }
557 }
558
559 /// One line for the human catalog view, or `None` when there is nothing to
560 /// say — most datasets have no quoted triples, and a card should not grow a
561 /// line per affordance they do not use.
562 pub fn describe(&self) -> Option<String> {
563 if !self.present {
564 return None;
565 }
566 Some(format!(
567 "present — `rete export` writes RDF 1.2 triple terms `<<( s p o )>>` \
568 by default; `--quoted-triple-syntax {QUOTED_TRIPLE_SURFACE_RDF_STAR}` \
569 writes `<<s p o>>`"
570 ))
571 }
572}
573impl TextIndexSignal {
574 /// Measure the signal from a file's header, through the same
575 /// [`crate::RangeReader`] the card was read with. Costs nothing beyond
576 /// the header already in hand when there is no index, and one ≤10-byte
577 /// range read when there is — never the index itself.
578 pub fn probe<R: crate::RangeReader + ?Sized>(reader: &R, header: &Header) -> Self {
579 if header.text_index_len == 0 {
580 return TextIndexSignal::default();
581 }
582 TextIndexSignal {
583 present: true,
584 bytes: header.text_index_len,
585 token_table_bytes: crate::read_text_index_token_table_len_ranged(reader, header),
586 }
587 }
588
589 /// One line for the human catalog view and the audit report.
590 pub fn describe(&self) -> String {
591 if !self.present {
592 return "no TEXT_INDEX section — CONTAINS/regex still answer, by full scan".to_string();
593 }
594 match self.token_table_bytes {
595 Some(tt) => format!(
596 "TEXT_INDEX present — {} bytes ({tt} of them the token table a first search reads)",
597 self.bytes
598 ),
599 None => format!("TEXT_INDEX present — {} bytes", self.bytes),
600 }
601 }
602}
603
604/// The build-time OWL RL / RDFS coherence verdict, stamped into the card by
605/// `rete build --reason`. Deterministic and free of free-text detail (only a
606/// sorted `by_kind` histogram + short tags), so it folds into the file's content
607/// hash without destabilizing it. `scope` records what was checked and `rules` the
608/// ruleset version, so `coherent: true` can't be misread as a guarantee from a
609/// different scope or rule set.
610#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
611pub struct Coherence {
612 /// True iff the reasoner found no incoherent point.
613 pub coherent: bool,
614 /// Number of incoherent points found.
615 pub inconsistency_count: u32,
616 /// `(kind, count)` histogram of incoherent points, sorted by kind.
617 #[serde(default, skip_serializing_if = "Vec::is_empty")]
618 pub by_kind: Vec<(String, u32)>,
619 /// What was checked, e.g. `"default"` (the default graph).
620 pub scope: String,
621 /// The reasoner ruleset version (`crate::REASON_RULESET`).
622 pub rules: String,
623 /// True iff the inferred triples were also materialized into the file.
624 pub materialized: bool,
625}
626
627impl Coherence {
628 /// An unstamped card has the all-default block (omitted from the JSON).
629 pub fn is_empty(&self) -> bool {
630 *self == Coherence::default()
631 }
632
633 /// Build the verdict from a reasoning result over the default graph. The
634 /// `by_kind` histogram is `BTreeMap`-sourced (sorted) and carries no free-text
635 /// `Inconsistency::detail`, so the stamp is byte-stable across rebuilds.
636 pub fn from_reasoning(r: &crate::Reasoning, materialized: bool) -> Self {
637 let mut hist: BTreeMap<&str, u32> = BTreeMap::new();
638 for inc in &r.inconsistencies {
639 *hist.entry(inc.kind).or_default() += 1;
640 }
641 Coherence {
642 coherent: r.inconsistencies.is_empty(),
643 inconsistency_count: r.inconsistencies.len() as u32,
644 by_kind: hist.into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
645 scope: "default".to_string(),
646 rules: crate::REASON_RULESET.to_string(),
647 materialized,
648 }
649 }
650}
651
652/// The curated subset, as supplied by a `--card-file` JSON document (every field
653/// optional). CLI flags override whatever the file provides. The identity/
654/// provenance fields (version, creators, publisher, canonical_url,
655/// sparql_endpoint, source_date, derived_from, doi, cite_as, keywords, theme)
656/// have no CLI flag — they come from the card file only.
657///
658/// `deny_unknown_fields` keeps the card file's top level **reserved for
659/// rete-defined fields**: a stray key is a loud error (usually a typo, or a
660/// custom field that belongs inside `extra`), never a silent drop — and it is
661/// what makes the collision guarantee real: a publisher's field can only ever
662/// live in the bag, so a future official top-level field cannot capture one.
663#[derive(Debug, Clone, Default, Deserialize)]
664#[serde(deny_unknown_fields)]
665pub struct CardInput {
666 pub title: Option<String>,
667 /// A string, or an array of lines joined with `\n` — see
668 /// [`crate::card_input::normalize_description`]. Markdown is allowed here (raw HTML is not); the
669 /// array shape exists because hand-writing `\n` escapes in JSON is awful.
670 #[serde(default, deserialize_with = "de_description")]
671 pub description: Option<String>,
672 pub license: Option<String>,
673 pub source: Option<String>,
674 pub created: Option<String>,
675 pub version: Option<String>,
676 #[serde(default)]
677 pub creators: Vec<Creator>,
678 pub publisher: Option<Publisher>,
679 pub canonical_url: Option<String>,
680 pub sparql_endpoint: Option<String>,
681 pub source_date: Option<String>,
682 #[serde(default)]
683 pub derived_from: Vec<String>,
684 pub doi: Option<String>,
685 pub cite_as: Option<String>,
686 /// Free-text keywords (see [`DatasetCard::keywords`]).
687 #[serde(default)]
688 pub keywords: Vec<String>,
689 /// Controlled-vocabulary theme IRIs (see [`DatasetCard::theme`]).
690 #[serde(default)]
691 pub theme: Vec<String>,
692 /// Publisher-defined custom fields (see [`DatasetCard::extra`]).
693 #[serde(default)]
694 pub extra: BTreeMap<String, serde_json::Value>,
695 #[serde(default)]
696 pub example_queries: Vec<String>,
697}
698
699/// Deserialize `description` from either a JSON string or an array of lines.
700///
701/// A description may be Markdown, and Markdown needs line breaks; in a JSON
702/// string those are `\n` escapes, which are miserable to write by hand and
703/// worse to review in a diff. An array of lines — joined with `\n` — reads as
704/// the Markdown it is. It is **input sugar only**: the card stores one string
705/// either way, so `rete card --json` output feeds straight back into
706/// `--card-file`. The shared rule lives in `crate::card` so the browser
707/// builder accepts exactly the same two shapes.
708fn de_description<'de, D>(d: D) -> Result<Option<String>, D::Error>
709where
710 D: serde::Deserializer<'de>,
711{
712 use serde::de::Error as _;
713 let v = Option::<serde_json::Value>::deserialize(d)?;
714 match v {
715 None | Some(serde_json::Value::Null) => Ok(None),
716 Some(v) => crate::card_input::normalize_description(&v)
717 .map(Some)
718 .map_err(D::Error::custom),
719 }
720}
721
722impl CardInput {
723 /// Move every curated field into a card (the shared step of all derivation
724 /// paths, so a new curated field is wired exactly once).
725 fn fill(self, card: &mut DatasetCard) {
726 card.title = self.title;
727 card.description = self.description;
728 card.license = self.license;
729 card.source = self.source;
730 card.created = self.created;
731 card.version = self.version;
732 card.creators = self.creators;
733 card.publisher = self.publisher;
734 card.canonical_url = self.canonical_url;
735 card.sparql_endpoint = self.sparql_endpoint;
736 card.source_date = self.source_date;
737 card.derived_from = self.derived_from;
738 card.doi = self.doi;
739 card.cite_as = self.cite_as;
740 card.keywords = self.keywords;
741 card.theme = self.theme;
742 card.extra = self.extra;
743 card.example_queries = self.example_queries;
744 }
745
746 /// Parse a curated card **document** — the JSON a publisher writes for
747 /// `rete build --card-file`, or a client hands its builder — applying the
748 /// same write-time rules the CLI applies.
749 ///
750 /// This is the client-side twin of `rete-cli`'s `load_curated`, minus the
751 /// file read: same `deny_unknown_fields` top level, same
752 /// [`UNKNOWN_FIELD_HINT`] pointing a stray key at the `extra` bag, same
753 /// [`normalize`](Self::normalize) pass afterwards. A Python/R/JS caller
754 /// that parses its card this way is held to exactly the rules the CLI and
755 /// the browser builder enforce, instead of the "is it an object?" check
756 /// those bindings used to do on their own.
757 ///
758 /// [`UNKNOWN_FIELD_HINT`]: crate::card_input::UNKNOWN_FIELD_HINT
759 pub fn from_json_str(text: &str) -> Result<Self, String> {
760 let parsed: CardInput = serde_json::from_str(text).map_err(|e| {
761 // The top level is reserved for rete-defined fields; point a stray
762 // key at the bag instead of leaving a bare serde error.
763 let hint = if e.to_string().contains("unknown field") {
764 crate::card_input::UNKNOWN_FIELD_HINT
765 } else {
766 ""
767 };
768 format!("{e}{hint}")
769 })?;
770 parsed.normalize()
771 }
772
773 /// Canonicalize and bounds-check every curated field — the write-time gate
774 /// each card-writing path funnels through, applied **after** any per-field
775 /// override a caller layered on top of a card file.
776 ///
777 /// The rules themselves live in [`crate::card_input`]; this is the
778 /// serde-typed face of them, so `rete build --card-file`, the browser
779 /// builder and every language binding reject the same documents with the
780 /// same words.
781 pub fn normalize(mut self) -> Result<Self, String> {
782 if let Some(d) = &self.description {
783 crate::card_input::check_description_len(d)?;
784 }
785 self.keywords = crate::card_input::normalize_string_list(
786 "keywords",
787 std::mem::take(&mut self.keywords),
788 )?;
789 self.theme = crate::card_input::normalize_themes(std::mem::take(&mut self.theme))?;
790 let bag: serde_json::Map<String, serde_json::Value> =
791 std::mem::take(&mut self.extra).into_iter().collect();
792 self.extra = crate::card_input::normalize_extra(bag)?
793 .into_iter()
794 .collect();
795 Ok(self)
796 }
797}
798
799impl DatasetCard {
800 /// Serialize to the JSON bytes stored in the metadata section (compact).
801 ///
802 /// [`Signals::text_index`] is **stripped** on the way out: it is measured
803 /// from the file's section directory at read time, and a copy inside the
804 /// hashed metadata section would be a claim that can outlive the sections it
805 /// describes (`rete repyramid --text-index` rewrites them). Stripping here —
806 /// the single choke point every card-writing command funnels through — is
807 /// what makes "derived, never authored" true of the bytes and not only of
808 /// the intent. (`CardInput` has no `signals` field and rejects unknown keys,
809 /// so a hand-written card file cannot set it either.)
810 pub fn to_json_bytes(&self) -> Vec<u8> {
811 if self.signals.text_index.is_none()
812 && self.signals.permutations.is_none()
813 && self.signals.quoted_triples.is_none()
814 {
815 return serde_json::to_vec(self).expect("DatasetCard serializes");
816 }
817 let mut stored = self.clone();
818 stored.signals.text_index = None;
819 stored.signals.permutations = None;
820 stored.signals.quoted_triples = None;
821 serde_json::to_vec(&stored).expect("DatasetCard serializes")
822 }
823
824 /// Attach the read-time [`TextIndexSignal`], returning whatever the card's
825 /// own bytes claimed before it (normally `None` — the writers strip it).
826 ///
827 /// A `Some(_)` return is drift: a card asserting a full-text index that no
828 /// longer has to be believed, because the file itself was just measured.
829 /// `rete card-audit` reports it.
830 pub fn observe_text_index(&mut self, measured: TextIndexSignal) -> Option<TextIndexSignal> {
831 self.signals.text_index.replace(measured)
832 }
833
834 /// Attach the read-time [`PermutationsSignal`]. Same contract as
835 /// [`observe_text_index`](Self::observe_text_index): a `Some(_)` return is
836 /// a card whose bytes claimed a permutation set, which the writers strip
837 /// and `CardInput` cannot author.
838 pub fn observe_permutations(
839 &mut self,
840 measured: PermutationsSignal,
841 ) -> Option<PermutationsSignal> {
842 self.signals.permutations.replace(measured)
843 }
844
845 /// Attach the read-time [`QuotedTriplesSignal`]. Same contract as
846 /// [`observe_permutations`](Self::observe_permutations): the writers strip
847 /// it and `CardInput` cannot author it, so a `Some(_)` return is a card
848 /// whose bytes claimed something about the file's own header.
849 pub fn observe_quoted_triples(
850 &mut self,
851 measured: QuotedTriplesSignal,
852 ) -> Option<QuotedTriplesSignal> {
853 self.signals.quoted_triples.replace(measured)
854 }
855
856 /// Parse a card from the metadata-section bytes.
857 ///
858 /// The error is a plain `String`, like every other message in the card
859 /// modules: the CLI wraps it in `anyhow`, the wasm build hands it to
860 /// JavaScript, and neither needs a dependency on the other's error type.
861 pub fn from_json_bytes(b: &[u8]) -> Result<Self, String> {
862 serde_json::from_slice(b).map_err(|e| format!("malformed dataset card: {e}"))
863 }
864
865 /// Stamp the build-time coherence verdict (consuming builder).
866 pub fn with_coherence(mut self, r: &crate::Reasoning, materialized: bool) -> Self {
867 self.coherence = Coherence::from_reasoning(r, materialized);
868 self
869 }
870
871 /// Replace the derived sizes with the counts the file was actually written
872 /// with (consuming builder).
873 ///
874 /// A card is derived from the ingested statements, which is the input
875 /// multiset; the file holds that multiset **deduplicated**, because every
876 /// permutation index sorts and dedups. The two agree for input with no
877 /// duplicates — and only then, which is why `rete card` used to report the
878 /// raw harvest size of any dataset paged with overlapping windows while
879 /// `rete info` reported the real one. The header's count is authoritative;
880 /// this is how it reaches the card. See `crate::FinalCounts`.
881 ///
882 /// The distributions (predicate/class histograms, hub degrees) stay over the
883 /// derived multiset — they are shapes, not sizes.
884 pub fn with_final_counts(mut self, counts: crate::ingest::FinalCounts) -> Self {
885 self.triple_count = counts.default_triples;
886 self.quad_count = counts.quads;
887 self
888 }
889}
890
891/// Cap for every top-N list embedded in the card. The metadata section is
892/// fetched on **every** overview (it is part of the index-free CARD tier), so an
893/// unbounded `class_links` (O(classes × predicates × classes)) or predicate list
894/// would bloat that fetch on a large schema (CIDOC-CRM/MMM). Capping keeps the
895/// card small and bounded; `truncated` flags when a list was actually cut.
896pub const CARD_TOP_N: usize = 100;
897
898/// How many distinct **annotated statements** the derivation remembers while
899/// looking for the [`Signals::annotated_statement`] witness.
900///
901/// The witness is a statement this graph both *asserts* (`s p o`) and *annotates*
902/// (`<< s p o >> :about ?v`) — the only thing that proves the coverage query
903/// (`qt-coverage`) can answer in both of its columns. Finding it means matching
904/// the two, which is why the star layer is learned in the pass that is already
905/// walking every statement.
906///
907/// It is **sampled**, not exhaustive, because remembering every annotated
908/// statement would make the derivation's memory scale with the star layer — on
909/// a fully-annotated graph, three IRIs per statement — and one witness is all a
910/// body needs. A graph whose first few thousand annotated statements happen to
911/// be ones it never asserts loses `qt-coverage`; it can never gain a broken
912/// one, which is the trade the geometry and label capabilities already make.
913const ANNOTATION_WITNESS_SAMPLE: usize = 4096;
914
915// Well-known IRIs (bracketed N-Triples term form, as they appear in the quads).
916const RDFS_LABEL: &str = "<http://www.w3.org/2000/01/rdf-schema#label>";
917const SKOS_PREFLABEL: &str = "<http://www.w3.org/2004/02/skos/core#prefLabel>";
918const SCHEMA_NAME: &str = "<http://schema.org/name>";
919const FOAF_NAME: &str = "<http://xmlns.com/foaf/0.1/name>";
920const DCT_TITLE: &str = "<http://purl.org/dc/terms/title>";
921const OWL_SAMEAS: &str = "<http://www.w3.org/2002/07/owl#sameAs>";
922const SKOS_EXACTMATCH: &str = "<http://www.w3.org/2004/02/skos/core#exactMatch>";
923const SKOS_CLOSEMATCH: &str = "<http://www.w3.org/2004/02/skos/core#closeMatch>";
924const RDFS_SEEALSO: &str = "<http://www.w3.org/2000/01/rdf-schema#seeAlso>";
925const WGS_LAT: &str = "<http://www.w3.org/2003/01/geo/wgs84_pos#lat>";
926const WGS_LONG: &str = "<http://www.w3.org/2003/01/geo/wgs84_pos#long>";
927// `pub`: the query generator gates its geometry template on which of
928// these two the card actually recorded, so both modules must name the same IRI.
929pub const GEO_ASWKT: &str = "<http://www.opengis.net/ont/geosparql#asWKT>";
930pub const GEO_HASGEOMETRY: &str = "<http://www.opengis.net/ont/geosparql#hasGeometry>";
931// Datatype IRIs (unbracketed — as they appear after `^^` in a literal term).
932const GEO_WKTLITERAL: &str = "http://www.opengis.net/ont/geosparql#wktLiteral";
933const XSD_STRING: &str = "http://www.w3.org/2001/XMLSchema#string";
934const RDF_LANGSTRING: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString";
935
936/// The [`ClassLink::o_class`] sentinel for a literal object (the object of that
937/// row is a literal, so it has no class and no outgoing edges). `pub`:
938/// the query generator reads it to tell an entity-to-entity relation — one a
939/// path query can walk — from a literal-valued one.
940pub const O_LITERAL: &str = "(literal)";
941
942/// Label predicates in priority order; the most frequent **present** one wins.
943const LABEL_PREDICATES: &[&str] = &[
944 SKOS_PREFLABEL,
945 RDFS_LABEL,
946 SCHEMA_NAME,
947 FOAF_NAME,
948 DCT_TITLE,
949];
950/// Cross-dataset linking predicates (emitted in `signals.link_predicates`).
951const LINK_PREDICATES: &[&str] = &[OWL_SAMEAS, SKOS_EXACTMATCH, SKOS_CLOSEMATCH, RDFS_SEEALSO];
952
953/// A replayable source of a graph's **default-graph** triples for card
954/// derivation. `replay` may be called more than once (the derivation makes two
955/// passes); every call MUST yield the same triples in the same order. Terms are
956/// passed as borrowed `&str` valid only for that call — so the derivation owns
957/// (clones) any key it retains, letting one code path serve both an in-memory
958/// quad slice and a streaming build's dictionary + id-triples.
959pub trait CardTripleSource {
960 fn replay(&self, f: &mut dyn FnMut(&str, &str, &str));
961}
962
963/// Derive a full card from the parsed quads plus a few build-time counts — the
964/// in-memory build path. Statistics are over the **default graph** only
965/// (named-graph statistics are summarized by `quad_count`/`named_graph_count`),
966/// matching `rete stats`/`rete predicates`.
967/// A card holding curated fields + top-line counts only — for the external
968/// (memory-bounded) build, where deriving the profile lists (predicates/classes/
969/// hubs/links) would need unbounded RAM. Every derived list is left empty and
970/// `truncated` is unset (the lists are absent, not cut).
971pub fn curated_counts_card(statements: u64, term_count: u64, curated: CardInput) -> DatasetCard {
972 let mut card = DatasetCard {
973 triple_count: statements,
974 quad_count: statements,
975 named_graph_count: 0,
976 term_count,
977 format_version: crate::format::CURRENT_FORMAT_VERSION,
978 ..DatasetCard::default()
979 };
980 curated.fill(&mut card);
981 card
982}
983
984pub fn derive_card(
985 quads: &[(String, String, String, Option<String>)],
986 term_count: u64,
987 named_graph_count: u64,
988 curated: CardInput,
989) -> DatasetCard {
990 struct Quads<'a>(&'a [(String, String, String, Option<String>)]);
991 impl CardTripleSource for Quads<'_> {
992 fn replay(&self, f: &mut dyn FnMut(&str, &str, &str)) {
993 for (s, p, o, g) in self.0 {
994 if g.is_none() {
995 f(s, p, o);
996 }
997 }
998 }
999 }
1000 derive_card_from(
1001 &Quads(quads),
1002 quads.len() as u64,
1003 term_count,
1004 named_graph_count,
1005 curated,
1006 )
1007}
1008
1009/// Derive a card from a built dictionary + default-graph **id-triples** — the
1010/// streaming (low-RAM) build path, where the raw quads were never retained.
1011/// Resolves each id-triple back to its terms through the dictionary, yielding the
1012/// same `(s, p, o)` strings in file order, so the card is **byte-identical** to
1013/// the in-memory derivation on the same graph.
1014pub fn derive_card_encoded(
1015 dict: &crate::Dictionary,
1016 triples: &[(u32, u32, u32)],
1017 quad_count: u64,
1018 term_count: u64,
1019 named_graph_count: u64,
1020 curated: CardInput,
1021) -> DatasetCard {
1022 struct Encoded<'a> {
1023 dict: &'a crate::Dictionary,
1024 triples: &'a [(u32, u32, u32)],
1025 }
1026 impl CardTripleSource for Encoded<'_> {
1027 fn replay(&self, f: &mut dyn FnMut(&str, &str, &str)) {
1028 for &(s, p, o) in self.triples {
1029 let st = self.dict.subject_term(s).unwrap_or_default();
1030 let pt = self.dict.predicate_term(p).unwrap_or_default();
1031 let ot = self.dict.object_term(o).unwrap_or_default();
1032 f(&st, &pt, &ot);
1033 }
1034 }
1035 }
1036 derive_card_from(
1037 &Encoded { dict, triples },
1038 quad_count,
1039 term_count,
1040 named_graph_count,
1041 curated,
1042 )
1043}
1044
1045/// The shared card derivation, over any [`CardTripleSource`]. Two passes: the
1046/// first builds the subject→class map needed to classify both endpoints of every
1047/// relation (the `class_links` quotient — the same logic as
1048/// [`crate::schema_summary`], folded in here to avoid a second full
1049/// materialization); the second tallies everything else. All counts are over the
1050/// raw (pre-dedup) default-graph multiset, matching the existing card stats and
1051/// `rete progressive`.
1052pub fn derive_card_from(
1053 src: &dyn CardTripleSource,
1054 quad_count: u64,
1055 term_count: u64,
1056 named_graph_count: u64,
1057 curated: CardInput,
1058) -> DatasetCard {
1059 // --- Pass 1: subject → class (last type wins, matching `schema_summary`),
1060 // plus the shape of the RDF-star layer, learned here because pass 2 needs
1061 // to recognize an *asserted* statement as one this graph also annotates
1062 // and the two can appear in either file order. See
1063 // `ANNOTATION_WITNESS_SAMPLE`. ---
1064 let mut class_of: std::collections::HashMap<String, String> = std::collections::HashMap::new();
1065 let mut quoted_subject_preds: BTreeSet<String> = BTreeSet::new();
1066 let mut annotated_tokens: BTreeMap<String, String> = BTreeMap::new();
1067 src.replay(&mut |s, p, o| {
1068 if p == RDF_TYPE {
1069 class_of.insert(s.to_string(), o.to_string());
1070 }
1071 if crate::terms::is_quoted_triple(s) {
1072 if let Some((_, quoted_p, _)) = crate::ingest::quoted_triple_parts(s) {
1073 quoted_subject_preds.insert(quoted_p);
1074 }
1075 if annotated_tokens.len() < ANNOTATION_WITNESS_SAMPLE {
1076 annotated_tokens
1077 .entry(s.to_string())
1078 .or_insert_with(|| p.to_string());
1079 }
1080 }
1081 });
1082 let classify = |t: &str| -> String {
1083 if let Some(c) = class_of.get(t) {
1084 c.to_string()
1085 } else if t.starts_with('"') {
1086 O_LITERAL.to_string()
1087 } else {
1088 "(untyped)".to_string()
1089 }
1090 };
1091
1092 // --- Pass 2: every other statistic, in one sweep. ---
1093 // Keys are owned `String`s (not `&str` borrowed from a quad slice) so the
1094 // same derivation serves a streaming source whose term strings are transient;
1095 // a `BTreeMap<String, _>` sorts identically to the old `BTreeMap<&str, _>`, so
1096 // the output is byte-identical.
1097 let mut pred_counts: BTreeMap<String, u64> = BTreeMap::new();
1098 let mut class_counts: BTreeMap<String, u64> = BTreeMap::new();
1099 let mut datatype_counts: BTreeMap<String, u64> = BTreeMap::new();
1100 let mut lang_counts: BTreeMap<String, u64> = BTreeMap::new();
1101 let mut out_degree: BTreeMap<String, u64> = BTreeMap::new();
1102 let mut in_degree: BTreeMap<String, u64> = BTreeMap::new();
1103 let mut link_rows: BTreeMap<(String, String, String), u64> = BTreeMap::new();
1104 let mut subject_ns: BTreeMap<String, u64> = BTreeMap::new();
1105 // Per-predicate temporal evidence: object-shape hits and a name hint.
1106 let mut time_obj_hits: BTreeMap<String, u64> = BTreeMap::new();
1107 // Per-predicate numeric-object hits (for value-range queries).
1108 let mut num_obj_hits: BTreeMap<String, u64> = BTreeMap::new();
1109 // RDF-star: statements ABOUT statements. `annotation_hits` counts the
1110 // predicate of a statement whose subject is a quoted triple,
1111 // `quoting_hits` the predicate of one whose object is; `witnessed_pairs`
1112 // counts the pairs matched against pass 1's sample — the witness a body
1113 // pinning both needs (see `Signals::annotated_statement`).
1114 let mut annotation_hits: BTreeMap<String, u64> = BTreeMap::new();
1115 let mut quoting_hits: BTreeMap<String, u64> = BTreeMap::new();
1116 let mut witnessed_pairs: BTreeMap<(String, String), u64> = BTreeMap::new();
1117 // Objects of each candidate time predicate, grouped by datatype, for extent.
1118 let mut time_values: BTreeMap<String, (Option<String>, Option<String>)> = BTreeMap::new();
1119 let mut time_value_dt: BTreeMap<String, String> = BTreeMap::new();
1120 let mut lat_lo = f64::INFINITY;
1121 let mut lat_hi = f64::NEG_INFINITY;
1122 let mut lon_lo = f64::INFINITY;
1123 let mut lon_hi = f64::NEG_INFINITY;
1124 let mut have_lat = false;
1125 let mut have_lon = false;
1126 let mut geo_wkt = false;
1127 let mut triple_count = 0u64;
1128
1129 src.replay(&mut |s, p, o| {
1130 triple_count += 1;
1131 *pred_counts.entry(p.to_string()).or_default() += 1;
1132 *out_degree.entry(s.to_string()).or_default() += 1;
1133 if let Some(ns) = split_namespace(s) {
1134 *subject_ns.entry(ns).or_default() += 1;
1135 }
1136
1137 // In-degree: every non-literal object referenced. Matches the
1138 // `top-in-hubs` query (`FILTER(!isLiteral(?o))`), which does not
1139 // special-case rdf:type, so the card precompute equals the query result.
1140 if !o.starts_with('"') {
1141 *in_degree.entry(o.to_string()).or_default() += 1;
1142 }
1143
1144 // RDF-star, before the `rdf:type` early return below: a quoted triple
1145 // may be typed (`<< s p o >> a :Claim`) like anything else, and that
1146 // statement is still one made about a statement.
1147 if crate::terms::is_quoted_triple(s) {
1148 *annotation_hits.entry(p.to_string()).or_default() += 1;
1149 }
1150 if crate::terms::is_quoted_triple(o) {
1151 *quoting_hits.entry(p.to_string()).or_default() += 1;
1152 }
1153 // The `annotated_statement` witness: THIS statement is asserted, and
1154 // pass 1 saw it quoted and annotated. `quoted_subject_preds` is empty
1155 // on every graph without an RDF-star layer — which is almost all of
1156 // them — so the common path is one set-emptiness check per statement,
1157 // and the token is only ever built for a predicate that really does
1158 // appear inside a quoted triple.
1159 if !quoted_subject_preds.is_empty() && quoted_subject_preds.contains(p) {
1160 if let Some(about) = annotated_tokens.get(&format!("<<{s} {p} {o}>>")) {
1161 *witnessed_pairs
1162 .entry((p.to_string(), about.clone()))
1163 .or_default() += 1;
1164 }
1165 }
1166
1167 if p == RDF_TYPE {
1168 *class_counts.entry(o.to_string()).or_default() += 1;
1169 return; // type assertions define classes, not data relations
1170 }
1171
1172 // Class-to-class quotient (the effective schema).
1173 *link_rows
1174 .entry((classify(s), p.to_string(), classify(o)))
1175 .or_default() += 1;
1176
1177 if p == GEO_ASWKT || p == GEO_HASGEOMETRY {
1178 geo_wkt = true;
1179 }
1180
1181 // Literal objects: datatype / language / temporal / geo analysis.
1182 if let Some(lit) = parse_literal(o) {
1183 *datatype_counts
1184 .entry(format!("<{}>", lit.datatype))
1185 .or_default() += 1;
1186 *lang_counts.entry(lit.lang.clone()).or_default() += 1;
1187 if lit.datatype == GEO_WKTLITERAL {
1188 geo_wkt = true;
1189 }
1190 // Temporal object-shape: a date/year datatype or a year-like value.
1191 if is_temporal_datatype(&lit.datatype) || looks_like_year(&lit.value) {
1192 *time_obj_hits.entry(p.to_string()).or_default() += 1;
1193 update_time_extent(p, &lit, &mut time_values, &mut time_value_dt);
1194 } else if is_numeric_datatype(&lit.datatype) {
1195 // A year-shaped value is temporal, not "numeric range" material,
1196 // so this is an `else if` — `birthYear` is a time predicate.
1197 *num_obj_hits.entry(p.to_string()).or_default() += 1;
1198 }
1199 // wgs84 lat/long numeric extent.
1200 if p == WGS_LAT {
1201 if let Ok(v) = lit.value.parse::<f64>() {
1202 have_lat = true;
1203 lat_lo = lat_lo.min(v);
1204 lat_hi = lat_hi.max(v);
1205 }
1206 } else if p == WGS_LONG {
1207 if let Ok(v) = lit.value.parse::<f64>() {
1208 have_lon = true;
1209 lon_lo = lon_lo.min(v);
1210 lon_hi = lon_hi.max(v);
1211 }
1212 }
1213 }
1214 });
1215
1216 // Signal lookups that need the raw per-predicate counts — computed before
1217 // `pred_counts` is consumed by the sort below.
1218 let pred_count = |iri: &str| pred_counts.get(iri).copied().unwrap_or(0);
1219 let label_predicate = LABEL_PREDICATES
1220 .iter()
1221 .filter(|&&iri| pred_count(iri) > 0)
1222 .max_by_key(|&&iri| pred_count(iri))
1223 .map(|s| s.to_string());
1224 let link_predicates: Vec<String> = LINK_PREDICATES
1225 .iter()
1226 .filter(|&&iri| pred_count(iri) > 0)
1227 .map(|s| s.to_string())
1228 .collect();
1229
1230 let mut truncated = false;
1231 let predicates = cap(sort_desc_owned(pred_counts), &mut truncated);
1232 let classes = cap(sort_desc_owned(class_counts), &mut truncated);
1233 let datatypes = cap(sort_desc_owned(datatype_counts), &mut truncated);
1234 let languages = cap(sort_desc_owned(lang_counts), &mut truncated);
1235 let top_hubs = cap(sort_desc_owned(out_degree), &mut truncated);
1236 let in_hubs = cap(sort_desc_owned(in_degree), &mut truncated);
1237 let class_links = cap(sort_links(link_rows), &mut truncated);
1238
1239 // Vocabularies: distinct namespaces of the predicate and class IRIs.
1240 let mut vocab: BTreeSet<String> = BTreeSet::new();
1241 for (iri, _) in predicates.iter().chain(classes.iter()) {
1242 if let Some(ns) = split_namespace(iri) {
1243 vocab.insert(ns);
1244 }
1245 }
1246 let vocabularies = cap(vocab.into_iter().collect(), &mut truncated);
1247
1248 // --- Signals (index-free affordances for the query-library generator). ---
1249 let default_lang = languages
1250 .iter()
1251 .find(|(l, _)| !l.is_empty())
1252 .map(|(l, _)| l.clone());
1253 let base_iri = derive_base_iri(&subject_ns);
1254 let mut time_ranked: Vec<(String, u64)> = time_obj_hits.into_iter().collect();
1255 time_ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1256 // Cap through the shared helper so an over-long list also flips `truncated`.
1257 let time_predicates = cap(
1258 time_ranked.iter().map(|(p, _)| p.to_string()).collect(),
1259 &mut truncated,
1260 );
1261 let mut num_ranked: Vec<(String, u64)> = num_obj_hits.into_iter().collect();
1262 num_ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1263 let numeric_predicates = cap(
1264 num_ranked.iter().map(|(p, _)| p.to_string()).collect(),
1265 &mut truncated,
1266 );
1267 // RDF-star vocabulary, ranked the same way (most statements first, ties by
1268 // term) and capped through the same helper, so an over-long list also
1269 // flips `truncated`. All three are empty/`None` on a graph with no quoted
1270 // triples — which is almost every graph — and the whole `qt-*` family then
1271 // does not exist, leaving those cards byte-identical.
1272 let annotation_predicates = cap(
1273 sort_desc_owned(annotation_hits)
1274 .into_iter()
1275 .map(|(p, _)| p)
1276 .collect(),
1277 &mut truncated,
1278 );
1279 let quoting_predicates = cap(
1280 sort_desc_owned(quoting_hits)
1281 .into_iter()
1282 .map(|(p, _)| p)
1283 .collect(),
1284 &mut truncated,
1285 );
1286 // The single most-witnessed pair, not a list: one witness is all a body
1287 // needs, and shipping the whole cross-product would cost the metadata
1288 // section for nothing. Ties break on the key so the choice is
1289 // deterministic (`max_by` keeps the last maximum, so the key comparison is
1290 // reversed to make it the smallest one).
1291 let annotated_statement = witnessed_pairs
1292 .into_iter()
1293 .max_by(|a, b| a.1.cmp(&b.1).then_with(|| b.0.cmp(&a.0)))
1294 .map(|(pair, _)| pair);
1295 let geo_latlong = have_lat && have_lon;
1296 let temporal_extent = time_ranked
1297 .first()
1298 .and_then(|(p, _)| time_values.get(p))
1299 .and_then(|(lo, hi)| match (lo, hi) {
1300 (Some(lo), Some(hi)) => Some((lo.clone(), hi.clone())),
1301 _ => None,
1302 });
1303 let spatial_bbox = if geo_latlong && lon_lo.is_finite() && lat_lo.is_finite() {
1304 Some([lon_lo, lat_lo, lon_hi, lat_hi])
1305 } else {
1306 None
1307 };
1308
1309 let signals = Signals {
1310 label_predicate,
1311 default_lang,
1312 base_iri,
1313 time_predicates,
1314 numeric_predicates,
1315 link_predicates,
1316 annotation_predicates,
1317 quoting_predicates,
1318 annotated_statement,
1319 geo_wkt,
1320 geo_latlong,
1321 temporal_extent,
1322 spatial_bbox,
1323 // Measured by the READER from the file's header/section directory,
1324 // never derived from the triples and never written — see
1325 // `TextIndexSignal`, `PermutationsSignal` and `QuotedTriplesSignal`.
1326 text_index: None,
1327 permutations: None,
1328 quoted_triples: None,
1329 };
1330
1331 let mut card = DatasetCard {
1332 triple_count,
1333 quad_count,
1334 named_graph_count,
1335 term_count,
1336 predicates,
1337 classes,
1338 vocabularies,
1339 datatypes,
1340 languages,
1341 class_links,
1342 top_hubs,
1343 in_hubs,
1344 signals,
1345 queries: Vec::new(),
1346 // Stamped later by the build pipeline via `with_coherence` when `--reason`
1347 // (or `--materialize`) ran; derive_card itself must not run the reasoner.
1348 coherence: Coherence::default(),
1349 truncated,
1350 // The cap the profile lists above were derived under (deterministic:
1351 // a compile-time constant of this builder, not a per-build fact).
1352 top_n: CARD_TOP_N as u32,
1353 format_version: crate::CURRENT_FORMAT_VERSION,
1354 ..DatasetCard::default()
1355 };
1356 curated.fill(&mut card);
1357 // The tiered starter-query library, instantiated from the profile above.
1358 card.queries = crate::card_queries::generate(&card);
1359 card
1360}
1361
1362/// Cap a top-N list, flagging `truncated` if anything was dropped.
1363fn cap<T>(mut v: Vec<T>, truncated: &mut bool) -> Vec<T> {
1364 if v.len() > CARD_TOP_N {
1365 v.truncate(CARD_TOP_N);
1366 *truncated = true;
1367 }
1368 v
1369}
1370
1371/// Sort an owned-`String`-keyed count map descending by count, then by term.
1372fn sort_desc_owned(counts: BTreeMap<String, u64>) -> Vec<(String, u64)> {
1373 let mut v: Vec<(String, u64)> = counts.into_iter().collect();
1374 v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1375 v
1376}
1377
1378/// Sort the class-link quotient deterministically: descending by count, then by
1379/// the `(s_class, predicate, o_class)` key — a total order for a stable hash.
1380fn sort_links(rows: BTreeMap<(String, String, String), u64>) -> Vec<ClassLink> {
1381 let mut v: Vec<ClassLink> = rows
1382 .into_iter()
1383 .map(|((s, p, o), count)| ClassLink {
1384 s_class: s,
1385 predicate: p,
1386 o_class: o,
1387 count,
1388 })
1389 .collect();
1390 v.sort_by(|a, b| {
1391 b.count
1392 .cmp(&a.count)
1393 .then_with(|| a.s_class.cmp(&b.s_class))
1394 .then_with(|| a.predicate.cmp(&b.predicate))
1395 .then_with(|| a.o_class.cmp(&b.o_class))
1396 });
1397 v
1398}
1399
1400/// The dataset's own base IRI, for the `{{BASE_IRI}}` substitution (and the
1401/// "links pointing outside the dataset" query). The longest common prefix of all
1402/// subject namespaces, truncated to a `/`/`#` boundary — `http://ex/` for
1403/// subjects under `http://ex/person/`, `http://ex/place/`, … When the LCP is
1404/// coarser than `scheme://host/` (subjects span unrelated hosts), fall back to
1405/// the single most-frequent namespace so the value stays a usable prefix.
1406fn derive_base_iri(ns: &BTreeMap<String, u64>) -> Option<String> {
1407 if ns.is_empty() {
1408 return None;
1409 }
1410 // Dominant namespace: highest count, ties broken by the smaller string.
1411 let dominant = ns
1412 .iter()
1413 .max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
1414 .map(|(k, _)| k.clone())?;
1415 // Longest common prefix across every subject namespace.
1416 let mut lcp_len = dominant.len();
1417 for k in ns.keys() {
1418 let common = dominant
1419 .bytes()
1420 .zip(k.bytes())
1421 .take_while(|(a, b)| a == b)
1422 .count();
1423 lcp_len = lcp_len.min(common);
1424 }
1425 let lcp = &dominant[..lcp_len];
1426 // Truncate to the last namespace delimiter so we never cut mid-segment.
1427 let base = match lcp.rfind(['/', '#']) {
1428 Some(i) => &lcp[..=i],
1429 None => "",
1430 };
1431 // Require at least `scheme://host/` (three slashes); else the LCP collapsed
1432 // across hosts — the dominant namespace is the more useful prefix.
1433 if base.matches('/').count() >= 3 {
1434 Some(base.to_string())
1435 } else {
1436 Some(dominant)
1437 }
1438}
1439
1440/// The decomposed parts of an RDF literal term.
1441struct Literal {
1442 /// Lexical value with the surrounding quotes and escapes removed.
1443 value: String,
1444 /// Datatype IRI (unbracketed): `xsd:string` for plain, `rdf:langString` for
1445 /// language-tagged, else the explicit `^^<…>` type.
1446 datatype: String,
1447 /// Language tag (`""` when none).
1448 lang: String,
1449}
1450
1451/// Decompose an N-Triples literal term (`"…"`, `"…"@lang`, `"…"^^<dt>`) into its
1452/// value, datatype, and language. Returns `None` for non-literal terms.
1453fn parse_literal(term: &str) -> Option<Literal> {
1454 let bytes = term.as_bytes();
1455 if bytes.first() != Some(&b'"') {
1456 return None;
1457 }
1458 // Find the closing unescaped quote.
1459 let mut i = 1usize;
1460 let mut esc = false;
1461 let mut close = None;
1462 while i < bytes.len() {
1463 match bytes[i] {
1464 b'\\' => esc = !esc,
1465 b'"' if !esc => {
1466 close = Some(i);
1467 break;
1468 }
1469 _ => esc = false,
1470 }
1471 i += 1;
1472 }
1473 let close = close?;
1474 let value = term[1..close].to_string();
1475 let suffix = &term[close + 1..];
1476 if let Some(lang) = suffix.strip_prefix('@') {
1477 Some(Literal {
1478 value,
1479 datatype: RDF_LANGSTRING.to_string(),
1480 lang: lang.to_string(),
1481 })
1482 } else if let Some(dt) = suffix.strip_prefix("^^") {
1483 let dt = dt.trim_start_matches('<').trim_end_matches('>').to_string();
1484 Some(Literal {
1485 value,
1486 datatype: dt,
1487 lang: String::new(),
1488 })
1489 } else {
1490 Some(Literal {
1491 value,
1492 datatype: XSD_STRING.to_string(),
1493 lang: String::new(),
1494 })
1495 }
1496}
1497
1498/// True for XSD date/time/year datatypes — the strong temporal signal.
1499fn is_temporal_datatype(dt: &str) -> bool {
1500 matches!(
1501 dt,
1502 "http://www.w3.org/2001/XMLSchema#date"
1503 | "http://www.w3.org/2001/XMLSchema#dateTime"
1504 | "http://www.w3.org/2001/XMLSchema#gYear"
1505 | "http://www.w3.org/2001/XMLSchema#gYearMonth"
1506 | "http://www.w3.org/2001/XMLSchema#gMonthDay"
1507 )
1508}
1509
1510/// True for XSD numeric datatypes — candidates for value-range aggregates.
1511fn is_numeric_datatype(dt: &str) -> bool {
1512 matches!(
1513 dt,
1514 "http://www.w3.org/2001/XMLSchema#integer"
1515 | "http://www.w3.org/2001/XMLSchema#decimal"
1516 | "http://www.w3.org/2001/XMLSchema#double"
1517 | "http://www.w3.org/2001/XMLSchema#float"
1518 | "http://www.w3.org/2001/XMLSchema#long"
1519 | "http://www.w3.org/2001/XMLSchema#int"
1520 | "http://www.w3.org/2001/XMLSchema#short"
1521 | "http://www.w3.org/2001/XMLSchema#nonNegativeInteger"
1522 | "http://www.w3.org/2001/XMLSchema#positiveInteger"
1523 )
1524}
1525
1526/// A weak temporal signal: a value whose leading token is a 4-digit year
1527/// (optionally BCE-negative), e.g. `1492`, `1492-10-12`, `-0044-03-15`.
1528fn looks_like_year(value: &str) -> bool {
1529 let v = value.strip_prefix('-').unwrap_or(value);
1530 let head: String = v.chars().take(4).collect();
1531 head.len() == 4 && head.chars().all(|c| c.is_ascii_digit())
1532}
1533
1534/// Fold one temporal literal into the per-predicate MIN/MAX extent, keeping the
1535/// extent within a single datatype (the predicate's first-seen one) so a lexical
1536/// compare never mixes incompatible value spaces (Risk R4).
1537fn update_time_extent(
1538 pred: &str,
1539 lit: &Literal,
1540 values: &mut BTreeMap<String, (Option<String>, Option<String>)>,
1541 value_dt: &mut BTreeMap<String, String>,
1542) {
1543 let dt = value_dt
1544 .entry(pred.to_string())
1545 .or_insert_with(|| lit.datatype.clone());
1546 if *dt != lit.datatype {
1547 return; // a different value space for this predicate — skip for extent
1548 }
1549 let entry = values.entry(pred.to_string()).or_insert((None, None));
1550 match &mut entry.0 {
1551 Some(lo) if lit.value < *lo => *lo = lit.value.clone(),
1552 None => entry.0 = Some(lit.value.clone()),
1553 _ => {}
1554 }
1555 match &mut entry.1 {
1556 Some(hi) if lit.value > *hi => *hi = lit.value.clone(),
1557 None => entry.1 = Some(lit.value.clone()),
1558 _ => {}
1559 }
1560}
1561
1562/// The namespace of an IRI term: the prefix up to and including the last `#` or
1563/// `/`. Returns `None` for non-IRI terms (literals/bnodes) or IRIs with neither
1564/// delimiter. Best-effort — adequate for a descriptive vocabulary list.
1565fn split_namespace(term: &str) -> Option<String> {
1566 let iri = term.strip_prefix('<')?.strip_suffix('>')?;
1567 let cut = iri.rfind(['#', '/'])?;
1568 Some(iri[..=cut].to_string())
1569}
1570
1571/// Read the dataset card embedded in a `.rete` file image, or `None` if it has
1572/// no metadata section. Parses only the header and the metadata range — it
1573/// never decodes the dictionary, index or pyramid, so a client that just built
1574/// a file can read back the card it wrote for the price of a slice.
1575///
1576/// The ranged (remote) companion lives in `rete-cli`, which has the HTTP reader.
1577pub fn load_card(bytes: &[u8]) -> Result<Option<DatasetCard>, String> {
1578 let header = Header::from_bytes(bytes).map_err(|e| e.to_string())?;
1579 if header.metadata_len == 0 {
1580 return Ok(None);
1581 }
1582 let start = header.metadata_offset as usize;
1583 let end = start
1584 .checked_add(header.metadata_len as usize)
1585 .filter(|&e| e <= bytes.len())
1586 .ok_or_else(|| "metadata section out of bounds".to_string())?;
1587 Ok(Some(DatasetCard::from_json_bytes(&bytes[start..end])?))
1588}
1589
1590#[cfg(test)]
1591mod tests {
1592 use super::*;
1593 // The bag's bounds are the format's, enforced in `card_input`; the tests
1594 // below assert the derived half honours exactly those numbers.
1595 use crate::card_input::{CARD_EXTRA_MAX_BYTES, CARD_EXTRA_MAX_KEYS, CARD_EXTRA_MAX_KEY_BYTES};
1596
1597 const TYPE: &str = RDF_TYPE;
1598
1599 fn q(s: &str, p: &str, o: &str) -> (String, String, String, Option<String>) {
1600 (s.into(), p.into(), o.into(), None)
1601 }
1602
1603 #[test]
1604 fn card_json_round_trips() {
1605 let card = DatasetCard {
1606 title: Some("Demo".into()),
1607 license: Some("CC0".into()),
1608 triple_count: 3,
1609 quad_count: 3,
1610 term_count: 5,
1611 predicates: vec![("<http://ex/p>".into(), 2)],
1612 vocabularies: vec!["http://ex/".into()],
1613 format_version: 1,
1614 ..Default::default()
1615 };
1616 let bytes = card.to_json_bytes();
1617 let back = DatasetCard::from_json_bytes(&bytes).unwrap();
1618 assert_eq!(card, back);
1619 // Absent optionals/empties are omitted from the JSON.
1620 let text = String::from_utf8(bytes).unwrap();
1621 assert!(!text.contains("description"));
1622 assert!(!text.contains("classes"));
1623 }
1624
1625 /// `signals.permutations` is measured from the header's permutation mask at
1626 /// READ time. It must never reach the metadata section: a card that
1627 /// *claimed* a permutation set would be an authored statement about the
1628 /// file's own bytes — the one class of claim the file always answers for
1629 /// itself, for free, in the 1 KiB header every card read already fetched.
1630 #[test]
1631 fn the_permutation_signal_is_stripped_from_the_stored_card() {
1632 let measured = PermutationsSignal {
1633 count: 3,
1634 names: vec!["SPO".into(), "POS".into(), "OSP".into()],
1635 merge_join: false,
1636 };
1637 let mut card = DatasetCard {
1638 title: Some("Lean".into()),
1639 triple_count: 3,
1640 ..Default::default()
1641 };
1642 assert_eq!(card.observe_permutations(measured.clone()), None);
1643 assert_eq!(card.signals.permutations, Some(measured.clone()));
1644
1645 let bytes = card.to_json_bytes();
1646 let text = String::from_utf8(bytes.clone()).unwrap();
1647 assert!(
1648 !text.contains("permutations"),
1649 "the signal must not reach the metadata section: {text}"
1650 );
1651 let back = DatasetCard::from_json_bytes(&bytes).unwrap();
1652 assert_eq!(back.signals.permutations, None, "unknown, not `six`");
1653 assert_eq!(back.title, card.title);
1654
1655 // And a curated card document cannot author it: `CardInput` is
1656 // `deny_unknown_fields` and has no `signals` field at all.
1657 let authored = r#"{"title":"x","signals":{"permutations":{"count":6}}}"#;
1658 assert!(
1659 serde_json::from_str::<CardInput>(authored).is_err(),
1660 "a hand-written card must not be able to assert a permutation set"
1661 );
1662 }
1663
1664 /// The signal is derived from the header, so it answers for a file with no
1665 /// card at all — and says six for every file written before the mask
1666 /// existed, whose byte 50 is zero.
1667 #[test]
1668 fn the_permutation_signal_is_derived_from_the_header() {
1669 let quads = vec![
1670 q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>"),
1671 q("<http://ex/b>", "<http://ex/p>", "<http://ex/c>"),
1672 ];
1673 let (six, _) = crate::ingest::assemble_dataset_with_perms(
1674 quads.clone(),
1675 false,
1676 false,
1677 None,
1678 crate::PyramidAlgo::Louvain,
1679 crate::PermSet::ALL,
1680 |_, _| Vec::new(),
1681 );
1682 let (three, _) = crate::ingest::assemble_dataset_with_perms(
1683 quads,
1684 false,
1685 false,
1686 None,
1687 crate::PyramidAlgo::Louvain,
1688 crate::PermSet::CORE,
1689 |_, _| Vec::new(),
1690 );
1691 // Byte 50 is the mask; `0` is the canonical spelling of "all six", so a
1692 // default build stays byte-identical to every file written before it.
1693 assert_eq!(six[50], 0);
1694 assert_eq!(three[50], 0b0000_0111);
1695
1696 let h6 = crate::Header::from_bytes(&six).unwrap();
1697 let h3 = crate::Header::from_bytes(&three).unwrap();
1698 let s6 = PermutationsSignal::probe(&h6);
1699 let s3 = PermutationsSignal::probe(&h3);
1700 assert_eq!(s6.count, 6);
1701 assert!(s6.merge_join);
1702 assert_eq!(s3.count, 3);
1703 assert!(!s3.merge_join);
1704 assert_eq!(s3.names, vec!["SPO", "POS", "OSP"]);
1705 assert!(s3.describe().contains("no sort-merge join"));
1706 // The lean file is smaller, and it is the index that shrank.
1707 assert!(three.len() < six.len(), "{} vs {}", three.len(), six.len());
1708 assert_eq!(h3.dictionary_len, h6.dictionary_len);
1709 assert!(h3.root_dir_len < h6.root_dir_len);
1710 }
1711
1712 /// `signals.text_index` is measured from the file's sections at READ time,
1713 /// so the write path must drop it: a stored copy would be a claim that
1714 /// survives the sections it describes (a `repyramid --text-index` rewrites
1715 /// them), which is the class of drift #189 had to repair by hand.
1716 #[test]
1717 fn the_text_index_signal_is_stripped_from_the_stored_card() {
1718 // `causenet-full-typed.rete`'s real figures.
1719 let measured = TextIndexSignal {
1720 present: true,
1721 bytes: 1_879_287_762,
1722 token_table_bytes: Some(193_295_361),
1723 };
1724 let mut card = DatasetCard {
1725 title: Some("Indexed".into()),
1726 triple_count: 3,
1727 ..Default::default()
1728 };
1729 assert_eq!(card.observe_text_index(measured), None);
1730 assert_eq!(card.signals.text_index, Some(measured));
1731
1732 let bytes = card.to_json_bytes();
1733 let text = String::from_utf8(bytes.clone()).unwrap();
1734 assert!(
1735 !text.contains("text_index"),
1736 "the signal must not reach the metadata section: {text}"
1737 );
1738 // Everything else still round-trips, and the reader gets `None` =
1739 // "unknown" rather than a fabricated `false`.
1740 let back = DatasetCard::from_json_bytes(&bytes).unwrap();
1741 assert_eq!(back.title, card.title);
1742 assert_eq!(back.signals.text_index, None);
1743
1744 // Stripping is not mutation: the in-memory card keeps what was measured
1745 // so the SAME card can be rendered and serialized in either order.
1746 assert_eq!(card.signals.text_index, Some(measured));
1747 }
1748
1749 /// The signal survives the card's own JSON envelope — which is what
1750 /// `rete card --json`, the wasm reader and `card-audit`'s document input all
1751 /// speak — even though it never reaches the file. (Plain `serde` here, not
1752 /// the CLI's `card_json` wrapper: that only adds a `schemaVersion` key, and
1753 /// the serialization under test is this struct's.)
1754 #[test]
1755 fn the_text_index_signal_round_trips_through_a_card_document() {
1756 let mut card = DatasetCard {
1757 title: Some("Indexed".into()),
1758 ..Default::default()
1759 };
1760 card.observe_text_index(TextIndexSignal {
1761 present: true,
1762 bytes: 80,
1763 token_table_bytes: Some(68),
1764 });
1765 let doc = serde_json::to_vec(&card).unwrap();
1766 let back: DatasetCard = serde_json::from_slice(&doc).unwrap();
1767 assert_eq!(back.signals.text_index, card.signals.text_index);
1768
1769 // A measured negative serializes as `{"present": false}` — nothing else
1770 // is asserted alongside it, and it is NOT the same as the field's
1771 // absence, which means "nobody measured".
1772 let mut none = DatasetCard::default();
1773 none.observe_text_index(TextIndexSignal::default());
1774 let text = serde_json::to_string(&none).unwrap();
1775 assert!(text.contains(r#""text_index":{"present":false}"#), "{text}");
1776 let absent = serde_json::to_string(&DatasetCard::default()).unwrap();
1777 assert!(!absent.contains("text_index"), "{absent}");
1778 }
1779
1780 /// The whole point of deriving this from the header rather than storing it:
1781 /// it is an honest answer for a file built long before the signal existed.
1782 /// `FLAG_HAS_QUOTED_TRIPLES` has been written by every build since quoted
1783 /// triples were supported, so the 44 published datasets answer it today,
1784 /// with no re-card and no rebuild — and a file with none says `false`, not
1785 /// "unknown".
1786 #[test]
1787 fn the_quoted_triple_signal_is_derived_from_the_header() {
1788 let plain = vec![
1789 q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>"),
1790 q("<http://ex/b>", "<http://ex/p>", "<http://ex/c>"),
1791 ];
1792 let quoted = vec![
1793 q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>"),
1794 q(
1795 "<http://ex/claim>",
1796 "<http://ex/states>",
1797 "<<<http://ex/a> <http://ex/p> <http://ex/b>>>",
1798 ),
1799 ];
1800 let (no, _) = crate::ingest::assemble_dataset(plain, &[]);
1801 let (yes, _) = crate::ingest::assemble_dataset(quoted, &[]);
1802
1803 let hn = crate::Header::from_bytes(&no).unwrap();
1804 let hy = crate::Header::from_bytes(&yes).unwrap();
1805 assert!(!hn.has_quoted_triples());
1806 assert!(hy.has_quoted_triples());
1807
1808 let sn = QuotedTriplesSignal::probe(&hn);
1809 let sy = QuotedTriplesSignal::probe(&hy);
1810 assert!(!sn.present);
1811 assert!(sy.present);
1812
1813 // A file with none says nothing in the human view — most datasets have
1814 // none and a line per unused affordance is noise.
1815 assert_eq!(sn.describe(), None);
1816 // One that has them says which surfaces are on offer, and which is the
1817 // default, so the card answers "what will I get if I export this?"
1818 // without the reader consulting the CLI docs.
1819 let line = sy.describe().expect("a file with quoted triples says so");
1820 assert!(line.contains("<<( s p o )>>"), "{line}");
1821 assert!(line.contains(QUOTED_TRIPLE_SURFACE_RDF_STAR), "{line}");
1822 assert_eq!(
1823 sy.export_surfaces,
1824 vec![QUOTED_TRIPLE_SURFACE_RDF12, QUOTED_TRIPLE_SURFACE_RDF_STAR]
1825 );
1826 assert_eq!(
1827 sy.export_default.as_deref(),
1828 Some(QUOTED_TRIPLE_SURFACE_RDF12)
1829 );
1830 // Nothing to offer when there is nothing to export.
1831 assert!(sn.export_surfaces.is_empty());
1832 assert_eq!(sn.export_default, None);
1833 }
1834
1835 /// Measured at read time, never written — the same contract as
1836 /// `text_index` and `permutations`, and for the sharper reason: a stored
1837 /// copy would be a claim about the file's own header, which the header
1838 /// answers for free.
1839 #[test]
1840 fn the_quoted_triple_signal_is_stripped_from_the_stored_card() {
1841 let measured = QuotedTriplesSignal {
1842 present: true,
1843 export_surfaces: vec![
1844 QUOTED_TRIPLE_SURFACE_RDF12.to_string(),
1845 QUOTED_TRIPLE_SURFACE_RDF_STAR.to_string(),
1846 ],
1847 export_default: Some(QUOTED_TRIPLE_SURFACE_RDF12.to_string()),
1848 };
1849 let mut card = DatasetCard {
1850 title: Some("Annotated".into()),
1851 triple_count: 3,
1852 ..Default::default()
1853 };
1854 assert_eq!(card.observe_quoted_triples(measured.clone()), None);
1855 assert_eq!(card.signals.quoted_triples, Some(measured.clone()));
1856
1857 let bytes = card.to_json_bytes();
1858 let text = String::from_utf8(bytes.clone()).unwrap();
1859 assert!(
1860 !text.contains("quoted_triples"),
1861 "the signal must not reach the metadata section: {text}"
1862 );
1863 let back = DatasetCard::from_json_bytes(&bytes).unwrap();
1864 assert_eq!(back.title, card.title);
1865 assert_eq!(back.signals.quoted_triples, None);
1866 // Stripping is not mutation.
1867 assert_eq!(card.signals.quoted_triples, Some(measured));
1868
1869 // And a hand-written card file cannot author it: `CardInput` has no
1870 // `signals` field and rejects unknown keys.
1871 let authored = r#"{"title":"x","signals":{"quoted_triples":{"present":true}}}"#;
1872 assert!(
1873 serde_json::from_str::<CardInput>(authored).is_err(),
1874 "a hand-written card must not be able to assert a quoted-triple set"
1875 );
1876 }
1877
1878 /// It survives the card's own JSON envelope — what `rete card --json`, the
1879 /// wasm reader and `card-audit`'s document input all speak — even though it
1880 /// never reaches the file. A measured negative is `{"present":false}`,
1881 /// which is NOT the same as the field being absent.
1882 #[test]
1883 fn the_quoted_triple_signal_round_trips_through_a_card_document() {
1884 let mut card = DatasetCard {
1885 title: Some("Annotated".into()),
1886 ..Default::default()
1887 };
1888 card.observe_quoted_triples(QuotedTriplesSignal {
1889 present: true,
1890 export_surfaces: vec![QUOTED_TRIPLE_SURFACE_RDF12.to_string()],
1891 export_default: Some(QUOTED_TRIPLE_SURFACE_RDF12.to_string()),
1892 });
1893 let doc = serde_json::to_vec(&card).unwrap();
1894 let back: DatasetCard = serde_json::from_slice(&doc).unwrap();
1895 assert_eq!(back.signals.quoted_triples, card.signals.quoted_triples);
1896
1897 let mut none = DatasetCard::default();
1898 none.observe_quoted_triples(QuotedTriplesSignal {
1899 present: false,
1900 export_surfaces: Vec::new(),
1901 export_default: None,
1902 });
1903 let text = serde_json::to_string(&none).unwrap();
1904 assert!(
1905 text.contains(r#""quoted_triples":{"present":false}"#),
1906 "{text}"
1907 );
1908 let absent = serde_json::to_string(&DatasetCard::default()).unwrap();
1909 assert!(!absent.contains("quoted_triples"), "{absent}");
1910 }
1911 #[test]
1912 fn split_namespace_handles_hash_slash_and_neither() {
1913 assert_eq!(
1914 split_namespace("<http://xmlns.com/foaf/0.1/name>").as_deref(),
1915 Some("http://xmlns.com/foaf/0.1/")
1916 );
1917 assert_eq!(
1918 split_namespace("<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>").as_deref(),
1919 Some("http://www.w3.org/1999/02/22-rdf-syntax-ns#")
1920 );
1921 // No delimiter, a literal, and a bnode all yield None.
1922 assert_eq!(split_namespace("<urn:isbn:12345>"), None);
1923 assert_eq!(split_namespace("\"literal\""), None);
1924 assert_eq!(split_namespace("_:b0"), None);
1925 }
1926
1927 #[test]
1928 fn derive_counts_and_orders() {
1929 let quads = vec![
1930 q("<http://ex/a>", TYPE, "<http://ex/Person>"),
1931 q("<http://ex/b>", TYPE, "<http://ex/Person>"),
1932 q("<http://ex/c>", TYPE, "<http://ex/City>"),
1933 q("<http://ex/a>", "<http://ex/knows>", "<http://ex/b>"),
1934 q("<http://ex/a>", "<http://ex/knows>", "<http://ex/c>"),
1935 q("<http://ex/a>", "<http://ex/name>", "\"A\""),
1936 ];
1937 let card = derive_card(&quads, 9, 0, CardInput::default());
1938 assert_eq!(card.triple_count, 6);
1939 assert_eq!(card.quad_count, 6);
1940 assert_eq!(card.term_count, 9);
1941 // Predicates desc by count: knows(2) and rdf:type(3) and name(1).
1942 assert_eq!(card.predicates[0], (TYPE.to_string(), 3));
1943 assert!(card
1944 .predicates
1945 .contains(&("<http://ex/knows>".to_string(), 2)));
1946 // Classes from rdf:type objects, desc: Person(2) before City(1).
1947 assert_eq!(
1948 card.classes,
1949 vec![
1950 ("<http://ex/Person>".to_string(), 2),
1951 ("<http://ex/City>".to_string(), 1)
1952 ]
1953 );
1954 // Vocabularies: ex/ namespace + the rdf-syntax-ns# namespace.
1955 assert!(card.vocabularies.contains(&"http://ex/".to_string()));
1956 assert!(card
1957 .vocabularies
1958 .iter()
1959 .any(|v| v.ends_with("rdf-syntax-ns#")));
1960 }
1961
1962 #[test]
1963 fn named_graph_triples_excluded_from_default_stats() {
1964 let quads = vec![
1965 q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>"),
1966 (
1967 "<http://ex/c>".into(),
1968 "<http://ex/p>".into(),
1969 "<http://ex/d>".into(),
1970 Some("<http://ex/g1>".into()),
1971 ),
1972 ];
1973 let card = derive_card(&quads, 5, 1, CardInput::default());
1974 assert_eq!(card.triple_count, 1, "only the default-graph triple counts");
1975 assert_eq!(card.quad_count, 2);
1976 assert_eq!(card.named_graph_count, 1);
1977 assert_eq!(card.predicates[0].1, 1);
1978 }
1979
1980 /// A literal-bearing triple helper for the enriched-profile tests.
1981 const LABEL: &str = "<http://www.w3.org/2000/01/rdf-schema#label>";
1982 const SAMEAS: &str = "<http://www.w3.org/2002/07/owl#sameAs>";
1983 const XSD_INT: &str = "http://www.w3.org/2001/XMLSchema#integer";
1984 const XSD_GYEAR: &str = "http://www.w3.org/2001/XMLSchema#gYear";
1985
1986 fn enriched_fixture() -> Vec<(String, String, String, Option<String>)> {
1987 vec![
1988 q("<http://ex/a>", TYPE, "<http://ex/Person>"),
1989 q("<http://ex/b>", TYPE, "<http://ex/Person>"),
1990 q("<http://ex/c>", TYPE, "<http://ex/City>"),
1991 q("<http://ex/a>", LABEL, "\"Alice\"@en"),
1992 q("<http://ex/b>", LABEL, "\"Bob\"@en"),
1993 q(
1994 "<http://ex/a>",
1995 "<http://ex/age>",
1996 &format!("\"30\"^^<{XSD_INT}>"),
1997 ),
1998 q(
1999 "<http://ex/a>",
2000 "<http://ex/birthYear>",
2001 &format!("\"1850\"^^<{XSD_GYEAR}>"),
2002 ),
2003 q(
2004 "<http://ex/b>",
2005 "<http://ex/birthYear>",
2006 &format!("\"1875\"^^<{XSD_GYEAR}>"),
2007 ),
2008 q("<http://ex/a>", "<http://ex/knows>", "<http://ex/b>"),
2009 q("<http://ex/a>", "<http://ex/livesIn>", "<http://ex/c>"),
2010 q("<http://ex/a>", SAMEAS, "<http://other/a>"),
2011 ]
2012 }
2013
2014 #[test]
2015 fn enriched_profile_is_populated() {
2016 let quads = enriched_fixture();
2017 let card = derive_card(&quads, 12, 0, CardInput::default());
2018
2019 assert!(!card.datatypes.is_empty(), "datatypes derived");
2020 assert!(!card.languages.is_empty(), "languages derived");
2021 assert!(!card.class_links.is_empty(), "class_links derived");
2022 assert!(!card.top_hubs.is_empty(), "out-degree hubs derived");
2023 assert!(!card.in_hubs.is_empty(), "in-degree hubs derived");
2024
2025 // Effective schema: Person --knows--> Person, Person --livesIn--> City.
2026 assert!(card
2027 .class_links
2028 .iter()
2029 .any(|l| l.s_class == "<http://ex/Person>"
2030 && l.predicate == "<http://ex/knows>"
2031 && l.o_class == "<http://ex/Person>"
2032 && l.count == 1));
2033 assert!(card
2034 .class_links
2035 .iter()
2036 .any(|l| l.o_class == "<http://ex/City>"));
2037 // Untyped external object is the `(untyped)` sentinel.
2038 assert!(card.class_links.iter().any(|l| l.o_class == "(untyped)"));
2039
2040 // Datatypes: integer, gYear, and langString from the @en labels.
2041 assert!(card.datatypes.iter().any(|(d, _)| d.contains("integer")));
2042 assert!(card.datatypes.iter().any(|(d, _)| d.contains("gYear")));
2043 assert!(card.datatypes.iter().any(|(d, _)| d.contains("langString")));
2044 // Languages: "en" present, plus untagged "".
2045 assert!(card.languages.iter().any(|(l, _)| l == "en"));
2046
2047 // Signals.
2048 let s = &card.signals;
2049 assert_eq!(s.label_predicate.as_deref(), Some(LABEL));
2050 assert_eq!(s.default_lang.as_deref(), Some("en"));
2051 assert_eq!(s.base_iri.as_deref(), Some("http://ex/"));
2052 assert!(s.link_predicates.iter().any(|p| p.contains("sameAs")));
2053 assert!(s.time_predicates.iter().any(|p| p.contains("birthYear")));
2054 assert_eq!(
2055 s.temporal_extent,
2056 Some(("1850".to_string(), "1875".to_string())),
2057 "extent over the dominant time predicate"
2058 );
2059 // No geometry in this fixture.
2060 assert!(!s.geo_wkt && !s.geo_latlong);
2061 assert!(s.spatial_bbox.is_none());
2062 }
2063
2064 #[test]
2065 fn signals_detect_geometry() {
2066 let wkt = "http://www.opengis.net/ont/geosparql#wktLiteral";
2067 let quads = vec![
2068 q("<http://ex/p>", TYPE, "<http://ex/Place>"),
2069 q(
2070 "<http://ex/p>",
2071 "<http://www.w3.org/2003/01/geo/wgs84_pos#lat>",
2072 "\"41.9\"^^<http://www.w3.org/2001/XMLSchema#double>",
2073 ),
2074 q(
2075 "<http://ex/p>",
2076 "<http://www.w3.org/2003/01/geo/wgs84_pos#long>",
2077 "\"12.5\"^^<http://www.w3.org/2001/XMLSchema#double>",
2078 ),
2079 q(
2080 "<http://ex/p>",
2081 "<http://www.opengis.net/ont/geosparql#asWKT>",
2082 &format!("\"POINT(12.5 41.9)\"^^<{wkt}>"),
2083 ),
2084 ];
2085 let card = derive_card(&quads, 6, 0, CardInput::default());
2086 assert!(card.signals.geo_wkt, "asWKT/wktLiteral detected");
2087 assert!(card.signals.geo_latlong, "wgs84 lat+long detected");
2088 let bbox = card.signals.spatial_bbox.expect("bbox derived");
2089 assert_eq!(
2090 bbox,
2091 [12.5, 41.9, 12.5, 41.9],
2092 "[minLon,minLat,maxLon,maxLat]"
2093 );
2094 }
2095
2096 #[test]
2097 fn class_links_match_schema_summary() {
2098 use crate::{ingest, schema_summary, Rete};
2099 let quads = enriched_fixture();
2100 let card = derive_card(&quads, 12, 0, CardInput::default());
2101
2102 // Build the same data and compute the reference quotient from the index.
2103 let (bytes, _) =
2104 ingest::assemble_dataset_with_opts(quads, true, false, None, |_, _| Vec::new());
2105 let rete = Rete::open(&bytes).unwrap();
2106 let reference: BTreeSet<(String, String, String, u64)> = schema_summary(&rete)
2107 .into_iter()
2108 .map(|(s, p, o, c)| (s, p, o, c as u64))
2109 .collect();
2110
2111 let derived: BTreeSet<(String, String, String, u64)> = card
2112 .class_links
2113 .iter()
2114 .map(|l| {
2115 (
2116 l.s_class.clone(),
2117 l.predicate.clone(),
2118 l.o_class.clone(),
2119 l.count,
2120 )
2121 })
2122 .collect();
2123
2124 assert_eq!(
2125 derived, reference,
2126 "card class_links must equal schema_summary's quotient row-for-row"
2127 );
2128 }
2129
2130 /// The card must report the size of the FILE, not the size of the input.
2131 ///
2132 /// Regression for #128: the card was derived from the ingested statement
2133 /// count while the header records the count the index kept after dedup, so
2134 /// any dataset built from input containing duplicate statements — which is
2135 /// every harvest that pages with overlapping windows — published an inflated
2136 /// size. The two agree for duplicate-free input, which is why it went
2137 /// unnoticed. Here the input has duplicates in both the default graph and a
2138 /// named graph, so a card taken from the ingest count cannot pass.
2139 #[test]
2140 fn card_counts_are_the_deduplicated_counts() {
2141 use crate::ingest::{self, DeferredMetadata};
2142 use crate::Rete;
2143
2144 let g = |s: &str, p: &str, o: &str, graph: &str| {
2145 (
2146 s.to_string(),
2147 p.to_string(),
2148 o.to_string(),
2149 Some(graph.to_string()),
2150 )
2151 };
2152 let quads = vec![
2153 q("<http://ex/a>", "<http://ex/p>", "<http://ex/1>"),
2154 q("<http://ex/b>", "<http://ex/p>", "<http://ex/2>"),
2155 q("<http://ex/a>", "<http://ex/p>", "<http://ex/1>"), // duplicate
2156 g("<http://ex/n>", "<http://ex/q>", "\"v\"", "<http://ex/g>"),
2157 g("<http://ex/n>", "<http://ex/q>", "\"v\"", "<http://ex/g>"), // duplicate
2158 ];
2159 // 5 statements ingested; 3 unique (2 default + 1 named).
2160 let (bytes, stats) =
2161 ingest::assemble_dataset_with_opts(quads, false, false, None, |stats, quads| {
2162 let card = derive_card(
2163 quads,
2164 stats.terms as u64,
2165 stats.named_graphs as u64,
2166 CardInput::default(),
2167 );
2168 DeferredMetadata::new(move |counts| card.with_final_counts(counts).to_json_bytes())
2169 });
2170
2171 let header = crate::Header::from_bytes(&bytes).unwrap();
2172 let card = load_card(&bytes).unwrap().expect("card embedded");
2173 assert_eq!(header.quad_count, 3, "header records the deduped quads");
2174 assert_eq!(
2175 card.quad_count, header.quad_count,
2176 "card and header must not disagree about the size of the same file"
2177 );
2178 assert_eq!(card.triple_count, 2, "default-graph triples, deduped");
2179
2180 // And the index agrees with both.
2181 let rete = Rete::open(&bytes).unwrap();
2182 let default_triples = rete.dump(None).len() as u64;
2183 let named: u64 = rete
2184 .graph_names()
2185 .iter()
2186 .map(|name| rete.dump(Some(name)).len() as u64)
2187 .sum();
2188 assert_eq!(default_triples + named, card.quad_count);
2189
2190 // The returned stats describe the file too — they print the `wrote …`
2191 // line, which used to report the raw input size.
2192 assert_eq!(stats.statements, 3);
2193 assert_eq!(stats.default_triples, 2);
2194 }
2195
2196 #[test]
2197 fn derive_is_deterministic() {
2198 // The card folds into the content hash, so the same input must yield
2199 // byte-identical JSON across rebuilds (no map-iteration nondeterminism).
2200 let quads = enriched_fixture();
2201 let a = derive_card(&quads, 12, 0, CardInput::default()).to_json_bytes();
2202 let b = derive_card(&quads, 12, 0, CardInput::default()).to_json_bytes();
2203 assert_eq!(a, b, "enriched card derivation must be deterministic");
2204 assert!(!a.is_empty());
2205 }
2206
2207 #[test]
2208 fn old_plain_string_card_still_parses() {
2209 // A pre-enrichment card: example_queries as plain strings, none of the
2210 // new fields present. Serde `default`s must keep it deserializing.
2211 let json = r#"{
2212 "title":"Legacy",
2213 "triple_count":3,"quad_count":3,"named_graph_count":0,"term_count":5,
2214 "predicates":[["<http://ex/p>",2]],
2215 "example_queries":["SELECT * WHERE { ?s ?p ?o }"],
2216 "format_version":1
2217 }"#;
2218 let card = DatasetCard::from_json_bytes(json.as_bytes()).unwrap();
2219 assert_eq!(card.title.as_deref(), Some("Legacy"));
2220 assert_eq!(card.example_queries.len(), 1);
2221 // Every enriched field defaults to empty/false when absent from the JSON.
2222 assert!(card.datatypes.is_empty());
2223 assert!(card.languages.is_empty());
2224 assert!(card.class_links.is_empty());
2225 assert!(card.top_hubs.is_empty());
2226 assert!(card.in_hubs.is_empty());
2227 assert!(card.queries.is_empty());
2228 assert!(card.signals.is_empty());
2229 assert!(!card.truncated);
2230 }
2231
2232 /// New curated identity/provenance fields round-trip through a card-file
2233 /// JSON document and into the card, and absent ones are omitted from the
2234 /// stored bytes (size discipline).
2235 #[test]
2236 fn curated_identity_fields_round_trip() {
2237 let input: CardInput = serde_json::from_str(
2238 r#"{
2239 "title": "T",
2240 "version": "2026-08-04",
2241 "creators": [{"name":"Ada","orcid":"https://orcid.org/0000-0002-1825-0097"}],
2242 "publisher": {"name":"EPFL","ror":"https://ror.org/02s376052"},
2243 "canonical_url": "https://data.example.org/t.rete",
2244 "sparql_endpoint": "https://example.org/sparql",
2245 "source_date": "2026-07-01",
2246 "derived_from": ["https://example.org/dump.nt"],
2247 "doi": "https://doi.org/10.5281/zenodo.1",
2248 "cite_as": "Ada (2026). T.",
2249 "keywords": ["graphs", "citations"],
2250 "theme": ["http://publications.europa.eu/resource/authority/data-theme/TECH"]
2251 }"#,
2252 )
2253 .unwrap();
2254 let card = curated_counts_card(5, 9, input);
2255 let bytes = card.to_json_bytes();
2256 let back = DatasetCard::from_json_bytes(&bytes).unwrap();
2257 assert_eq!(back.version.as_deref(), Some("2026-08-04"));
2258 assert_eq!(back.creators[0].name, "Ada");
2259 assert_eq!(
2260 back.creators[0].orcid.as_deref(),
2261 Some("https://orcid.org/0000-0002-1825-0097")
2262 );
2263 assert_eq!(back.publisher.as_ref().unwrap().name, "EPFL");
2264 assert_eq!(
2265 back.canonical_url.as_deref(),
2266 Some("https://data.example.org/t.rete")
2267 );
2268 assert_eq!(back.derived_from, vec!["https://example.org/dump.nt"]);
2269 assert_eq!(
2270 back.doi.as_deref(),
2271 Some("https://doi.org/10.5281/zenodo.1")
2272 );
2273 // Round-trip preserves what was stored (normalization is
2274 // `load_curated`'s job, covered by its own test below).
2275 assert_eq!(back.keywords, vec!["graphs", "citations"]);
2276 assert_eq!(
2277 back.theme,
2278 vec!["http://publications.europa.eu/resource/authority/data-theme/TECH"]
2279 );
2280
2281 // A minimal card omits every absent identity field from its bytes.
2282 let plain = curated_counts_card(1, 2, CardInput::default()).to_json_bytes();
2283 let text = String::from_utf8(plain).unwrap();
2284 for absent in [
2285 "\"creators\"",
2286 "\"publisher\"",
2287 "\"canonical_url\"",
2288 "\"sparql_endpoint\"",
2289 "\"derived_from\"",
2290 "\"doi\"",
2291 "\"cite_as\"",
2292 "\"source_date\"",
2293 "\"version\"",
2294 "\"top_n\"",
2295 "\"keywords\"",
2296 "\"theme\"",
2297 "\"extra\"",
2298 ] {
2299 assert!(
2300 !text.contains(absent),
2301 "{absent} must be omitted when unset"
2302 );
2303 }
2304 }
2305
2306 /// The `extra` bag round-trips through the stored bytes, is omitted when
2307 /// empty, and a card written by an EXTERNAL writer (the Python client
2308 /// serializes the caller's dict verbatim, counts spliced in) surfaces its
2309 /// custom fields in this reader — the pass-through the client relies on.
2310 #[test]
2311 fn extra_round_trips_and_external_writer_cards_surface_it() {
2312 let input: CardInput = serde_json::from_str(
2313 r#"{"title":"T","extra":{"atlas:layer":84,"review":{"by":"dg","ok":true}}}"#,
2314 )
2315 .unwrap();
2316 let card = curated_counts_card(5, 9, input);
2317 let back = DatasetCard::from_json_bytes(&card.to_json_bytes()).unwrap();
2318 assert_eq!(back.extra, card.extra);
2319 assert_eq!(back.extra.get("atlas:layer"), Some(&serde_json::json!(84)));
2320
2321 // The exact JSON shape `clients/python` `card_bytes` writes: curated
2322 // fields + the count fields + format_version, `extra` included.
2323 let python_shaped = r#"{
2324 "title":"From Python","extra":{"pipeline":"nightly"},
2325 "triple_count":1,"quad_count":1,"named_graph_count":0,"term_count":3,
2326 "format_version":5
2327 }"#;
2328 let card = DatasetCard::from_json_bytes(python_shaped.as_bytes()).unwrap();
2329 assert_eq!(
2330 card.extra.get("pipeline"),
2331 Some(&serde_json::json!("nightly"))
2332 );
2333 }
2334
2335 /// The bounds bite exactly at the boundary — one byte / key / level over
2336 /// is a loud error, at the limit is accepted.
2337 #[test]
2338 fn extra_limits_bite_exactly_at_the_boundary() {
2339 use crate::card_input::normalize_extra;
2340 use serde_json::{json, Map, Value};
2341 // The gate takes serde_json's `Map` (BTree-backed, so it serializes in
2342 // the same key order the card's `BTreeMap` does).
2343 let bag = |v: Vec<(String, Value)>| -> Map<String, Value> { v.into_iter().collect() };
2344
2345 // Byte cap: `{"pad":"…"}` serializes to 10 + n bytes.
2346 let pad = |n: usize| bag(vec![("pad".into(), json!("x".repeat(n)))]);
2347 let at = pad(CARD_EXTRA_MAX_BYTES - 10);
2348 assert_eq!(serde_json::to_vec(&at).unwrap().len(), CARD_EXTRA_MAX_BYTES);
2349 normalize_extra(at).expect("exactly at the byte cap is accepted");
2350 let over = pad(CARD_EXTRA_MAX_BYTES - 9);
2351 assert_eq!(
2352 serde_json::to_vec(&over).unwrap().len(),
2353 CARD_EXTRA_MAX_BYTES + 1
2354 );
2355 let err = normalize_extra(over).unwrap_err().to_string();
2356 assert!(
2357 err.contains(&format!("{} bytes", CARD_EXTRA_MAX_BYTES + 1)),
2358 "the error states the actual size: {err}"
2359 );
2360
2361 // Key-count cap.
2362 let keys = |n: usize| {
2363 bag((0..n)
2364 .map(|i| (format!("k{i:03}"), json!(1)))
2365 .collect::<Vec<_>>())
2366 };
2367 normalize_extra(keys(CARD_EXTRA_MAX_KEYS)).expect("at the key cap");
2368 assert!(normalize_extra(keys(CARD_EXTRA_MAX_KEYS + 1)).is_err());
2369
2370 // Key-length cap, and the empty key.
2371 normalize_extra(bag(vec![("k".repeat(CARD_EXTRA_MAX_KEY_BYTES), json!(1))]))
2372 .expect("at the key-length cap");
2373 assert!(normalize_extra(bag(vec![(
2374 "k".repeat(CARD_EXTRA_MAX_KEY_BYTES + 1),
2375 json!(1)
2376 )]))
2377 .is_err());
2378 assert!(normalize_extra(bag(vec![(String::new(), json!(1))])).is_err());
2379
2380 // Depth cap: an object of objects-of-scalars (2 levels) ok, 3 rejected
2381 // — records belong in Parquet companions, not the card.
2382 normalize_extra(bag(vec![("d".into(), json!({"a": {"b": 1}}))])).expect("at the depth cap");
2383 assert!(normalize_extra(bag(vec![("d".into(), json!({"a": {"b": {"c": 1}}}))])).is_err());
2384 assert!(normalize_extra(bag(vec![("d".into(), json!([[[1]]]))])).is_err());
2385
2386 // "@context" is reserved for a future author-supplied mapping.
2387 let err = normalize_extra(bag(vec![("@context".into(), json!({}))]))
2388 .unwrap_err()
2389 .to_string();
2390 assert!(err.contains("reserved"), "loud reservation: {err}");
2391 }
2392
2393 /// Two builds of identical input — the `extra` bag authored in different
2394 /// (semantically equal) key order — produce byte-identical images, hence
2395 /// equal blake3 content hashes. The bag is curated, so it sits INSIDE the
2396 /// hash; this reproducibility is what makes that placement safe.
2397 #[test]
2398 fn extra_fields_keep_the_content_hash_reproducible() {
2399 let build = |json: &str| {
2400 // The same normalization `rete build --card-file` applies on every
2401 // path — it is literally the call `load_curated` ends with.
2402 let input: CardInput = serde_json::from_str::<CardInput>(json)
2403 .unwrap()
2404 .normalize()
2405 .unwrap();
2406 let quads = enriched_fixture();
2407 let card = derive_card(&quads, 12, 0, input);
2408 let (image, _) =
2409 crate::ingest::assemble_dataset_with_opts(quads, true, false, None, |_, _| {
2410 card.to_json_bytes()
2411 });
2412 image
2413 };
2414 let a = build(r#"{"title":"T","extra":{"b":{"y":2,"x":1},"a":[1,2]}}"#);
2415 let b = build(r#"{"title":"T","extra":{"a":[1,2],"b":{"x":1,"y":2}}}"#);
2416 assert_eq!(a, b, "reordered-input builds are byte-identical");
2417 let ha = Header::from_bytes(&a).unwrap().content_hash;
2418 assert_eq!(ha, Header::from_bytes(&b).unwrap().content_hash);
2419
2420 // …and the bag IS hashed: dropping it changes the content hash
2421 // (tamper-evident, not cosmetic).
2422 let plain = build(r#"{"title":"T"}"#);
2423 assert_ne!(ha, Header::from_bytes(&plain).unwrap().content_hash);
2424 }
2425
2426 /// A stray top-level key in a card file is a LOUD error naming the key —
2427 /// the enforcement that keeps the top level rete's namespace, so a future
2428 /// official field can never capture (or be shadowed by) a publisher's.
2429 #[test]
2430 fn top_level_custom_keys_are_rejected_not_dropped() {
2431 let err = serde_json::from_str::<CardInput>(r#"{"title":"T","my_field":1}"#)
2432 .unwrap_err()
2433 .to_string();
2434 assert!(err.contains("my_field"), "names the stray key: {err}");
2435
2436 // The same content is accepted once the key moves into the bag.
2437 let ok: CardInput =
2438 serde_json::from_str(r#"{"title":"T","extra":{"my_field":1}}"#).unwrap();
2439 assert_eq!(ok.extra.get("my_field"), Some(&serde_json::json!(1)));
2440 }
2441
2442 /// A card file may write `description` as an array of lines, because a
2443 /// Markdown description in a JSON string means hand-writing `\n` escapes.
2444 /// Both shapes have to land as the same single string — the card stores one
2445 /// string either way, so `rete card --json` feeds straight back in.
2446 #[test]
2447 fn description_reads_as_a_string_or_as_lines() {
2448 let from_lines: CardInput = serde_json::from_value(serde_json::json!({
2449 "description": ["## Contents", "", "- a", "- b"]
2450 }))
2451 .unwrap();
2452 assert_eq!(
2453 from_lines.description.as_deref(),
2454 Some("## Contents\n\n- a\n- b")
2455 );
2456 let from_string: CardInput =
2457 serde_json::from_str("{\"description\":\"## Contents\\n\\n- a\\n- b\"}").unwrap();
2458 assert_eq!(from_string.description, from_lines.description);
2459
2460 // An absent description stays absent (the field is `default`ed).
2461 let none: CardInput = serde_json::from_str(r#"{"title":"T"}"#).unwrap();
2462 assert!(none.description.is_none());
2463 let null: CardInput = serde_json::from_str(r#"{"description":null}"#).unwrap();
2464 assert!(null.description.is_none());
2465
2466 // The cap is the shared one, and it is reported by serde, not silently
2467 // truncated on the way in.
2468 let over = "x".repeat(crate::card::CARD_DESCRIPTION_MAX_BYTES + 1);
2469 let err = serde_json::from_str::<CardInput>(&format!(
2470 "{{\"description\":{}}}",
2471 serde_json::to_string(&over).unwrap()
2472 ))
2473 .unwrap_err()
2474 .to_string();
2475 assert!(err.contains("over the"), "{err}");
2476 }
2477
2478 /// The browser builder validates a card document against
2479 /// `crate::card::CURATED_CARD_FIELDS` — a list, not this struct, because
2480 /// wasm has no serde derive here. A list that drifted from the struct would
2481 /// let one writer accept what the other refuses, which is the whole failure
2482 /// this shared module exists to prevent. So pin them to each other in BOTH
2483 /// directions: every listed field must be accepted by `CardInput`, and every
2484 /// field `CardInput` accepts must be listed.
2485 #[test]
2486 fn curated_field_list_matches_the_deny_unknown_fields_struct() {
2487 for f in crate::card::CURATED_CARD_FIELDS {
2488 // Probe membership, not types: feed the key a `null` and require that
2489 // whatever serde complains about is a TYPE problem, never "unknown
2490 // field" — that is the only verdict this test is about.
2491 let doc = format!(r#"{{"{f}":null}}"#);
2492 if let Err(e) = serde_json::from_str::<CardInput>(&doc) {
2493 assert!(
2494 !e.to_string().contains("unknown field"),
2495 "CURATED_CARD_FIELDS lists `{f}`, but CardInput rejects it as unknown: {e}",
2496 );
2497 }
2498 }
2499 // The other direction: serde names the accepted set in its error, so a
2500 // field the struct gained without being listed shows up here.
2501 let err = serde_json::from_str::<CardInput>(r#"{"nope":1}"#)
2502 .unwrap_err()
2503 .to_string();
2504 for name in err
2505 .split("expected one of ")
2506 .nth(1)
2507 .expect("serde names the expected fields")
2508 // serde appends " at line L column C" — not a field name.
2509 .split(" at line ")
2510 .next()
2511 .unwrap()
2512 .split(", ")
2513 .map(|s| s.trim().trim_matches('`'))
2514 .filter(|s| !s.is_empty())
2515 {
2516 assert!(
2517 crate::card::CURATED_CARD_FIELDS.contains(&name),
2518 "CardInput accepts `{name}` but CURATED_CARD_FIELDS does not list it — \
2519 the browser builder would reject a card the CLI accepts",
2520 );
2521 }
2522 }
2523
2524 /// The playground's in-browser builder writes a card with no access to
2525 /// this crate: curated fields validated by `rete_core::card`, plus the four
2526 /// counts its build measured. Whatever it writes has to be a card THIS
2527 /// reader accepts — otherwise `rete card` on a downloaded browser build
2528 /// fails, and the round trip the builder promises is broken. Pin the shape
2529 /// by deserializing exactly what `rete_wasm::build_with_card` composes.
2530 #[test]
2531 fn a_browser_written_card_deserializes_here() {
2532 let curated = crate::card::validate_curated_card(&serde_json::json!({
2533 "title": "Built in a browser",
2534 "keywords": ["b", "a"],
2535 "theme": ["http://publications.europa.eu/resource/authority/data-theme/GOVE"],
2536 "creators": [{"name": "Ada", "orcid": "https://orcid.org/0000-0002-1825-0097"}],
2537 "extra": {"internal_id": "DS-1"},
2538 }))
2539 .expect("valid curated document");
2540 let composed = crate::card::compose_curated_card(curated, 3, 4, 1, 9, 5);
2541 let text = serde_json::to_string(&composed).unwrap();
2542
2543 let card: DatasetCard = serde_json::from_str(&text).expect("CLI reads a browser card");
2544 assert_eq!(card.title.as_deref(), Some("Built in a browser"));
2545 assert_eq!(card.keywords, vec!["a".to_string(), "b".to_string()]);
2546 assert_eq!(card.creators.len(), 1);
2547 assert_eq!(card.triple_count, 3);
2548 assert_eq!(card.quad_count, 4);
2549 assert_eq!(card.term_count, 9);
2550 assert_eq!(card.format_version, 5);
2551 // The derived half is ABSENT, not zeroed — a browser build never
2552 // measured it and must not look as though it had.
2553 assert!(card.predicates.is_empty() && card.queries.is_empty());
2554 assert!(!text.contains("\"predicates\""), "{text}");
2555 assert!(!text.contains("\"top_n\""), "{text}");
2556 }
2557
2558 /// Forward-compat: an OLD reader's struct (the pre-#153 field set, no
2559 /// `deny_unknown_fields` — mirrored here) must still deserialize a card
2560 /// written by THIS build, new fields and all.
2561 #[test]
2562 fn old_reader_struct_accepts_new_card_json() {
2563 #[derive(serde::Deserialize)]
2564 struct OldCard {
2565 title: Option<String>,
2566 triple_count: u64,
2567 #[serde(default)]
2568 predicates: Vec<(String, u64)>,
2569 format_version: u8,
2570 }
2571 let input: CardInput = serde_json::from_str(
2572 r#"{"title":"New","version":"1","creators":[{"name":"A"}],
2573 "canonical_url":"https://x/y.rete","derived_from":["https://x/d.nt"],
2574 "extra":{"atlas:review":"approved"}}"#,
2575 )
2576 .unwrap();
2577 let quads = vec![q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>")];
2578 let card = derive_card(&quads, 3, 0, input);
2579 assert_eq!(card.top_n, CARD_TOP_N as u32);
2580 let bytes = card.to_json_bytes();
2581 let old: OldCard = serde_json::from_slice(&bytes).expect("old struct parses new JSON");
2582 assert_eq!(old.title.as_deref(), Some("New"));
2583 assert_eq!(old.triple_count, 1);
2584 assert_eq!(old.format_version, card.format_version);
2585 assert!(!old.predicates.is_empty());
2586 }
2587
2588 #[test]
2589 fn coherence_stamp_is_deterministic_and_additive() {
2590 let dis = "<http://www.w3.org/2002/07/owl#disjointWith>";
2591 let rt = "<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>";
2592 let base = vec![
2593 ("<http://ex/C>".into(), dis.into(), "<http://ex/D>".into()),
2594 ("<http://ex/x>".into(), rt.into(), "<http://ex/C>".into()),
2595 ("<http://ex/x>".into(), rt.into(), "<http://ex/D>".into()),
2596 ];
2597 let r = crate::reason(&base);
2598 let c1 = Coherence::from_reasoning(&r, false);
2599 // Deterministic + the right verdict (no free-text detail in the stamp).
2600 assert_eq!(c1, Coherence::from_reasoning(&r, false));
2601 assert!(!c1.coherent);
2602 assert_eq!(c1.inconsistency_count, 1);
2603 assert_eq!(c1.by_kind, vec![("disjoint-classes".to_string(), 1)]);
2604 assert_eq!(c1.rules, crate::REASON_RULESET);
2605
2606 // A stamped card round-trips; an unstamped one omits the block entirely.
2607 let json = DatasetCard::default()
2608 .with_coherence(&r, false)
2609 .to_json_bytes();
2610 let text = std::str::from_utf8(&json).unwrap();
2611 assert!(text.contains("coherence"));
2612 assert_eq!(DatasetCard::from_json_bytes(&json).unwrap().coherence, c1);
2613
2614 let plain = DatasetCard::default().to_json_bytes();
2615 assert!(!std::str::from_utf8(&plain).unwrap().contains("coherence"));
2616 assert!(DatasetCard::default().coherence.is_empty());
2617 }
2618
2619 #[test]
2620 fn base_iri_is_common_prefix_with_fallback() {
2621 // Subjects under several sibling namespaces → their common root.
2622 let mut ns = BTreeMap::new();
2623 ns.insert("http://ex/person/".to_string(), 2000u64);
2624 ns.insert("http://ex/place/".to_string(), 600);
2625 ns.insert("http://ex/org/".to_string(), 120);
2626 assert_eq!(derive_base_iri(&ns).as_deref(), Some("http://ex/"));
2627
2628 // A single namespace is its own base.
2629 let mut one = BTreeMap::new();
2630 one.insert("http://data.example.org/id/".to_string(), 10u64);
2631 assert_eq!(
2632 derive_base_iri(&one).as_deref(),
2633 Some("http://data.example.org/id/")
2634 );
2635
2636 // Subjects across unrelated hosts: the LCP collapses to `http://`, so we
2637 // fall back to the dominant namespace rather than ship a useless prefix.
2638 let mut multi = BTreeMap::new();
2639 multi.insert("http://a.example/".to_string(), 100u64);
2640 multi.insert("http://b.other/".to_string(), 5);
2641 assert_eq!(
2642 derive_base_iri(&multi).as_deref(),
2643 Some("http://a.example/")
2644 );
2645
2646 assert_eq!(derive_base_iri(&BTreeMap::new()), None);
2647 }
2648
2649 #[test]
2650 fn parse_literal_decomposes_forms() {
2651 let plain = parse_literal("\"hello\"").unwrap();
2652 assert_eq!(plain.value, "hello");
2653 assert!(plain.datatype.ends_with("XMLSchema#string"));
2654 assert_eq!(plain.lang, "");
2655
2656 let tagged = parse_literal("\"bonjour\"@fr").unwrap();
2657 assert_eq!(tagged.lang, "fr");
2658 assert!(tagged.datatype.ends_with("#langString"));
2659
2660 let typed = parse_literal("\"42\"^^<http://www.w3.org/2001/XMLSchema#integer>").unwrap();
2661 assert_eq!(typed.value, "42");
2662 assert_eq!(typed.datatype, "http://www.w3.org/2001/XMLSchema#integer");
2663
2664 // Escaped quote inside the value is not mistaken for the terminator.
2665 let esc = parse_literal("\"a\\\"b\"@en").unwrap();
2666 assert_eq!(esc.value, "a\\\"b");
2667 assert_eq!(esc.lang, "en");
2668
2669 // Non-literals decline.
2670 assert!(parse_literal("<http://ex/x>").is_none());
2671 assert!(parse_literal("_:b0").is_none());
2672 }
2673}