Skip to main content

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}