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    /// Whether the file carries a **label index (LABEL_INDEX section, kind 8)**,
349    /// and how large it is. Like [`Signals::text_index`], measured from the
350    /// file at read time and stripped before the card's bytes are written;
351    /// `None` means unknown, never "no index". See [`LabelIndexSignal`].
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub label_index: Option<LabelIndexSignal>,
354    /// Which **index permutations** the file stores. Like
355    /// [`Signals::text_index`], measured from the file at read time and
356    /// stripped before the card's bytes are written; `None` means unknown (a
357    /// card document with no file behind it), never "the default".
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub permutations: Option<PermutationsSignal>,
360    /// Whether the file holds any **quoted triple**, and which export surfaces
361    /// a consumer can ask for. Like the two signals above, measured from the
362    /// file at read time and stripped before the card's bytes are written;
363    /// `None` means unknown (a card document with no file behind it), never
364    /// "no quoted triples". See [`QuotedTriplesSignal`].
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    pub quoted_triples: Option<QuotedTriplesSignal>,
367}
368
369impl Signals {
370    /// True when no affordance was detected (lets the whole block be omitted).
371    pub fn is_empty(&self) -> bool {
372        *self == Signals::default()
373    }
374}
375
376/// The file's full-text-search affordance: does it carry a **TEXT_INDEX section
377/// (kind 6)**, and what does that index cost?
378///
379/// A `.rete` built with `--text-index` answers `FILTER(CONTAINS(…))` by word
380/// lookup; one built without it answers the *same query with the same rows* by
381/// full scan. The capability is invisible from the results, which is exactly how
382/// the playground catalog came to advertise an index two published files never
383/// carried (#189) — so a file has to be able to state it.
384///
385/// **Measured at read time, never stored.** The ground truth is the section
386/// directory in the 1 KiB header that every card read already fetches, so a
387/// stored copy would be a second source of truth for a fact the first source
388/// answers for free — the same reasoning that keeps a curated `language` field
389/// out of the card. Being a projection rather than a stamp also means every
390/// already-published file reports it today, with no re-card, and that
391/// `rete repyramid --text-index` (which rewrites the file's sections) can never
392/// leave it stale.
393///
394/// **What it deliberately does not carry: a token count.** The number of
395/// distinct indexed words is the first varint of the *decompressed* token
396/// table, so quoting it would mean fetching and inflating the whole table —
397/// 193 MB on the published `causenet-full-typed.rete`. [`token_table_bytes`] is
398/// the same question answered for ≤10 bytes, and is the more useful answer
399/// anyway: it is what a first search actually costs.
400///
401/// [`token_table_bytes`]: TextIndexSignal::token_table_bytes
402#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
403pub struct TextIndexSignal {
404    /// True iff the file has a kind-6 section. Always written, including as
405    /// `false`: "measured, and there is none" is the fact #189 was missing.
406    pub present: bool,
407    /// Byte length of the whole TEXT_INDEX section — free, straight from the
408    /// header. Worth stating because it can dominate the download:
409    /// `causenet-full-typed.rete` carries 1.88 GB of it, 29% of the file.
410    #[serde(default, skip_serializing_if = "is_zero_u64")]
411    pub bytes: u64,
412    /// Byte length of the section's leading **token table** — the prefix a
413    /// first search faults, several times smaller than `bytes` (which counts
414    /// the postings blob, fetched one posting at a time). One ≤10-byte range
415    /// read; `None` when there is no index, or its first bytes were unreadable.
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub token_table_bytes: Option<u64>,
418}
419
420/// serde helper: omit a `0` byte count (no section to measure).
421fn is_zero_u64(v: &u64) -> bool {
422    *v == 0
423}
424
425/// The file's **index permutation set**: how many orders of `(s, p, o)` it
426/// stores, and therefore whether its planner can run a sort-merge join.
427///
428/// `rete build --permutations 3` writes SPO, POS and OSP only. Those three tie
429/// the longest bound prefix on all eight triple-pattern shapes, so the file
430/// answers **every query with the same rows, routed to the same tiles** — the
431/// difference is invisible from the results, exactly like a missing
432/// full-text index (#189). What it gives up is the merge join: SOP/PSO/OPS
433/// exist only to hand a join two streams already sorted on the join key, and
434/// they are typically ~40% of a built file. A consumer choosing between two
435/// mirrors of one dataset, or deciding whether a join-heavy workload will hold
436/// up, has to be able to see which it has.
437///
438/// **Measured at read time, never stored**, for the reasons [`TextIndexSignal`]
439/// spells out and one more that is specific to this signal: which permutations
440/// a file carries is a fact about its *bytes*. A stored copy would be an
441/// authored claim about the file's own layout — the one class of statement the
442/// file can always check for itself, in the 1 KiB header every card read has
443/// already fetched. This costs **no extra read at all**.
444#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
445pub struct PermutationsSignal {
446    /// How many permutations are stored: 3 or 6 today.
447    pub count: u8,
448    /// Their names in section order, e.g. `["SPO", "POS", "OSP"]`. Written out
449    /// rather than implied by `count`, because the mask is a set and a future
450    /// build may keep a different three.
451    pub names: Vec<String>,
452    /// Whether the file carries the merge-join orders (SOP, PSO, OPS) — i.e.
453    /// whether a two-pattern join sharing a same-role variable can be answered
454    /// by a linear two-pointer merge instead of a hash join.
455    pub merge_join: bool,
456}
457
458impl PermutationsSignal {
459    /// Read it straight off the parsed header — the mask is one byte at `[50]`,
460    /// and `0` there means all six (every file written before the mask
461    /// existed). No range read of any kind.
462    pub fn probe(header: &Header) -> Self {
463        let perms = header.perms;
464        PermutationsSignal {
465            count: perms.len() as u8,
466            names: perms.names().into_iter().map(str::to_string).collect(),
467            merge_join: perms.has_merge_orders(),
468        }
469    }
470
471    /// One line for the human catalog view and the audit report.
472    pub fn describe(&self) -> String {
473        if self.merge_join {
474            format!(
475                "{} index permutations ({}) — sort-merge joins available",
476                self.count,
477                self.names.join("/")
478            )
479        } else {
480            format!(
481                "{} index permutations ({}) — same rows, same routing; \
482                 no sort-merge join (hash/probe answers instead)",
483                self.count,
484                self.names.join("/")
485            )
486        }
487    }
488}
489
490/// The `--quoted-triple-syntax` value that writes the RDF 1.2 triple term
491/// `<<( s p o )>>`, and `rete export`'s **default**.
492///
493/// Named here, not only in the CLI's flag table, because the dataset card
494/// states which surfaces a consumer can ask for — and a card that says `rdf12`
495/// while the flag has been renamed would be worse than one that says nothing.
496pub const QUOTED_TRIPLE_SURFACE_RDF12: &str = "rdf12";
497
498/// The `--quoted-triple-syntax` value that writes the RDF-star surface
499/// `<<s p o>>`, which is also the token rete stores.
500pub const QUOTED_TRIPLE_SURFACE_RDF_STAR: &str = "rdf-star";
501
502/// Whether the file contains **quoted triples** — statements standing inside
503/// other statements — and, if it does, what a consumer gets when they export it.
504///
505/// This is the question "will I meet triple terms in this dataset, and in which
506/// spelling?", which a consumer needs answered *before* they download a 50 GB
507/// file and run `rete export` on it. The two surfaces are not interchangeable
508/// downstream: an RDF 1.2 parser rejects `<<s p o>>` in N-Quads and silently
509/// reads it as a *reifier* in Turtle/TriG, and an RDF-star parser rejects
510/// `<<( s p o )>>` everywhere. Which one a dump carries decides whether it loads
511/// at all, so it belongs in the card rather than in the reader's memory of a CLI
512/// flag.
513///
514/// **Measured at read time from the header, never stored**, exactly like
515/// [`TextIndexSignal`] and [`PermutationsSignal`], and for the sharpest version
516/// of the same reason: header flag bit 2 (`FLAG_HAS_QUOTED_TRIPLES`) has been
517/// written by every build since quoted triples were supported, so this is an
518/// honest answer for **every file that already exists** — the 44 published
519/// datasets included — with no re-card and no rebuild. A stored copy would be
520/// `null` on all of them, which is worse than nothing: it would assert
521/// "unknown" about files whose own header knows.
522///
523/// `None` therefore means **unknown** — a card read out of a saved JSON
524/// document, with no file to measure — and never "no quoted triples".
525///
526/// There is deliberately **no count**. The header carries presence, not
527/// cardinality, and counting would mean decoding the dictionary — outside the
528/// CARD tier's budget (header + metadata, never the dictionary), and available
529/// only to files built after this existed, which is the failure mode above.
530#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
531pub struct QuotedTriplesSignal {
532    /// Does the file hold at least one quoted triple as a dictionary term?
533    pub present: bool,
534    /// The surfaces `rete export` can write them in, most interoperable first.
535    /// Empty when `present` is false: the question does not arise.
536    #[serde(default, skip_serializing_if = "Vec::is_empty")]
537    pub export_surfaces: Vec<String>,
538    /// Which of `export_surfaces` `rete export` writes with no flag. `None`
539    /// when `present` is false.
540    #[serde(default, skip_serializing_if = "Option::is_none")]
541    pub export_default: Option<String>,
542}
543
544impl QuotedTriplesSignal {
545    /// Read it straight off the parsed header — flag bit 2, in the 1 KiB every
546    /// card read has already fetched. No range read of any kind.
547    pub fn probe(header: &Header) -> Self {
548        if !header.has_quoted_triples() {
549            return QuotedTriplesSignal {
550                present: false,
551                export_surfaces: Vec::new(),
552                export_default: None,
553            };
554        }
555        QuotedTriplesSignal {
556            present: true,
557            export_surfaces: vec![
558                QUOTED_TRIPLE_SURFACE_RDF12.to_string(),
559                QUOTED_TRIPLE_SURFACE_RDF_STAR.to_string(),
560            ],
561            export_default: Some(QUOTED_TRIPLE_SURFACE_RDF12.to_string()),
562        }
563    }
564
565    /// One line for the human catalog view, or `None` when there is nothing to
566    /// say — most datasets have no quoted triples, and a card should not grow a
567    /// line per affordance they do not use.
568    pub fn describe(&self) -> Option<String> {
569        if !self.present {
570            return None;
571        }
572        Some(format!(
573            "present — `rete export` writes RDF 1.2 triple terms `<<( s p o )>>` \
574             by default; `--quoted-triple-syntax {QUOTED_TRIPLE_SURFACE_RDF_STAR}` \
575             writes `<<s p o>>`"
576        ))
577    }
578}
579impl TextIndexSignal {
580    /// Measure the signal from a file's header, through the same
581    /// [`crate::RangeReader`] the card was read with. Costs nothing beyond
582    /// the header already in hand when there is no index, and one ≤10-byte
583    /// range read when there is — never the index itself.
584    pub fn probe<R: crate::RangeReader + ?Sized>(reader: &R, header: &Header) -> Self {
585        if header.text_index_len == 0 {
586            return TextIndexSignal::default();
587        }
588        TextIndexSignal {
589            present: true,
590            bytes: header.text_index_len,
591            token_table_bytes: crate::read_text_index_token_table_len_ranged(reader, header),
592        }
593    }
594
595    /// One line for the human catalog view and the audit report.
596    pub fn describe(&self) -> String {
597        if !self.present {
598            return "no TEXT_INDEX section — CONTAINS/regex still answer, by full scan".to_string();
599        }
600        match self.token_table_bytes {
601            Some(tt) => format!(
602                "TEXT_INDEX present — {} bytes ({tt} of them the token table a first search reads)",
603                self.bytes
604            ),
605            None => format!("TEXT_INDEX present — {} bytes", self.bytes),
606        }
607    }
608}
609
610/// The file's label-prefix-search affordance: does it carry a **LABEL_INDEX
611/// section (kind 8)**, how many labels, and how large is it?
612///
613/// **Measured at read time, never stored**, for the reasons
614/// [`TextIndexSignal`] gives: presence and size come free with the header,
615/// and `entries` is the u64 at bytes 20..28 of the section's fixed preamble —
616/// one 32-byte range read, never the index. A file without the section may
617/// still answer a prefix search from the legacy label block of its
618/// pyramid-meta (at most 8,192 labels); that block is not measured here,
619/// because reading it means fetching the whole pyramid-meta.
620#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
621pub struct LabelIndexSignal {
622    /// True iff the file has a kind-8 section. Always written, including as
623    /// `false`.
624    pub present: bool,
625    /// Byte length of the whole LABEL_INDEX section.
626    #[serde(default, skip_serializing_if = "is_zero_u64")]
627    pub bytes: u64,
628    /// Number of `(subject, label)` entries; `None` when there is no index or
629    /// its preamble was unreadable.
630    #[serde(default, skip_serializing_if = "Option::is_none")]
631    pub entries: Option<u64>,
632}
633
634impl LabelIndexSignal {
635    /// Measure the signal from a file's header, through the reader the card
636    /// was read with: nothing beyond the header when there is no index, one
637    /// 32-byte range read when there is.
638    pub fn probe<R: crate::RangeReader + ?Sized>(reader: &R, header: &Header) -> Self {
639        let Some(s) = header.label_index_section() else {
640            return LabelIndexSignal::default();
641        };
642        LabelIndexSignal {
643            present: true,
644            bytes: s.length,
645            entries: crate::label_index::read_label_index_entries_ranged(reader, header),
646        }
647    }
648
649    /// One line for the human catalog view.
650    pub fn describe(&self) -> String {
651        if !self.present {
652            return "no LABEL_INDEX section — a prefix search finds only what a pyramid's \
653                    legacy label block holds, if any"
654                .to_string();
655        }
656        match self.entries {
657            Some(n) => format!("LABEL_INDEX present — {n} labels, {} bytes", self.bytes),
658            None => format!("LABEL_INDEX present — {} bytes", self.bytes),
659        }
660    }
661}
662
663/// The build-time OWL RL / RDFS coherence verdict, stamped into the card by
664/// `rete build --reason`. Deterministic and free of free-text detail (only a
665/// sorted `by_kind` histogram + short tags), so it folds into the file's content
666/// hash without destabilizing it. `scope` records what was checked and `rules` the
667/// ruleset version, so `coherent: true` can't be misread as a guarantee from a
668/// different scope or rule set.
669#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
670pub struct Coherence {
671    /// True iff the reasoner found no incoherent point.
672    pub coherent: bool,
673    /// Number of incoherent points found.
674    pub inconsistency_count: u32,
675    /// `(kind, count)` histogram of incoherent points, sorted by kind.
676    #[serde(default, skip_serializing_if = "Vec::is_empty")]
677    pub by_kind: Vec<(String, u32)>,
678    /// What was checked, e.g. `"default"` (the default graph).
679    pub scope: String,
680    /// The reasoner ruleset version (`crate::REASON_RULESET`).
681    pub rules: String,
682    /// True iff the inferred triples were also materialized into the file.
683    pub materialized: bool,
684}
685
686impl Coherence {
687    /// An unstamped card has the all-default block (omitted from the JSON).
688    pub fn is_empty(&self) -> bool {
689        *self == Coherence::default()
690    }
691
692    /// Build the verdict from a reasoning result over the default graph. The
693    /// `by_kind` histogram is `BTreeMap`-sourced (sorted) and carries no free-text
694    /// `Inconsistency::detail`, so the stamp is byte-stable across rebuilds.
695    pub fn from_reasoning(r: &crate::Reasoning, materialized: bool) -> Self {
696        let mut hist: BTreeMap<&str, u32> = BTreeMap::new();
697        for inc in &r.inconsistencies {
698            *hist.entry(inc.kind).or_default() += 1;
699        }
700        Coherence {
701            coherent: r.inconsistencies.is_empty(),
702            inconsistency_count: r.inconsistencies.len() as u32,
703            by_kind: hist.into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
704            scope: "default".to_string(),
705            rules: crate::REASON_RULESET.to_string(),
706            materialized,
707        }
708    }
709}
710
711/// The curated subset, as supplied by a `--card-file` JSON document (every field
712/// optional). CLI flags override whatever the file provides. The identity/
713/// provenance fields (version, creators, publisher, canonical_url,
714/// sparql_endpoint, source_date, derived_from, doi, cite_as, keywords, theme)
715/// have no CLI flag — they come from the card file only.
716///
717/// `deny_unknown_fields` keeps the card file's top level **reserved for
718/// rete-defined fields**: a stray key is a loud error (usually a typo, or a
719/// custom field that belongs inside `extra`), never a silent drop — and it is
720/// what makes the collision guarantee real: a publisher's field can only ever
721/// live in the bag, so a future official top-level field cannot capture one.
722#[derive(Debug, Clone, Default, Deserialize)]
723#[serde(deny_unknown_fields)]
724pub struct CardInput {
725    pub title: Option<String>,
726    /// A string, or an array of lines joined with `\n` — see
727    /// [`crate::card_input::normalize_description`]. Markdown is allowed here (raw HTML is not); the
728    /// array shape exists because hand-writing `\n` escapes in JSON is awful.
729    #[serde(default, deserialize_with = "de_description")]
730    pub description: Option<String>,
731    pub license: Option<String>,
732    pub source: Option<String>,
733    pub created: Option<String>,
734    pub version: Option<String>,
735    #[serde(default)]
736    pub creators: Vec<Creator>,
737    pub publisher: Option<Publisher>,
738    pub canonical_url: Option<String>,
739    pub sparql_endpoint: Option<String>,
740    pub source_date: Option<String>,
741    #[serde(default)]
742    pub derived_from: Vec<String>,
743    pub doi: Option<String>,
744    pub cite_as: Option<String>,
745    /// Free-text keywords (see [`DatasetCard::keywords`]).
746    #[serde(default)]
747    pub keywords: Vec<String>,
748    /// Controlled-vocabulary theme IRIs (see [`DatasetCard::theme`]).
749    #[serde(default)]
750    pub theme: Vec<String>,
751    /// Publisher-defined custom fields (see [`DatasetCard::extra`]).
752    #[serde(default)]
753    pub extra: BTreeMap<String, serde_json::Value>,
754    #[serde(default)]
755    pub example_queries: Vec<String>,
756}
757
758/// Deserialize `description` from either a JSON string or an array of lines.
759///
760/// A description may be Markdown, and Markdown needs line breaks; in a JSON
761/// string those are `\n` escapes, which are miserable to write by hand and
762/// worse to review in a diff. An array of lines — joined with `\n` — reads as
763/// the Markdown it is. It is **input sugar only**: the card stores one string
764/// either way, so `rete card --json` output feeds straight back into
765/// `--card-file`. The shared rule lives in `crate::card` so the browser
766/// builder accepts exactly the same two shapes.
767fn de_description<'de, D>(d: D) -> Result<Option<String>, D::Error>
768where
769    D: serde::Deserializer<'de>,
770{
771    use serde::de::Error as _;
772    let v = Option::<serde_json::Value>::deserialize(d)?;
773    match v {
774        None | Some(serde_json::Value::Null) => Ok(None),
775        Some(v) => crate::card_input::normalize_description(&v)
776            .map(Some)
777            .map_err(D::Error::custom),
778    }
779}
780
781impl CardInput {
782    /// The curated half of an existing card, **verbatim** — the inverse of
783    /// `fill`. What `rete recard --keep-curated` hands the new
784    /// card, so a re-card carries a publisher's title, licence and prose
785    /// byte for byte instead of re-typing them (or, as a bare
786    /// `repyramid --card` did, silently dropping them).
787    pub fn from_card(card: &DatasetCard) -> Self {
788        CardInput {
789            title: card.title.clone(),
790            description: card.description.clone(),
791            license: card.license.clone(),
792            source: card.source.clone(),
793            created: card.created.clone(),
794            version: card.version.clone(),
795            creators: card.creators.clone(),
796            publisher: card.publisher.clone(),
797            canonical_url: card.canonical_url.clone(),
798            sparql_endpoint: card.sparql_endpoint.clone(),
799            source_date: card.source_date.clone(),
800            derived_from: card.derived_from.clone(),
801            doi: card.doi.clone(),
802            cite_as: card.cite_as.clone(),
803            keywords: card.keywords.clone(),
804            theme: card.theme.clone(),
805            extra: card.extra.clone(),
806            example_queries: card.example_queries.clone(),
807        }
808    }
809
810    /// True when no curated field is set — a card made of derived facts only.
811    pub fn is_empty(&self) -> bool {
812        self.title.is_none()
813            && self.description.is_none()
814            && self.license.is_none()
815            && self.source.is_none()
816            && self.created.is_none()
817            && self.version.is_none()
818            && self.creators.is_empty()
819            && self.publisher.is_none()
820            && self.canonical_url.is_none()
821            && self.sparql_endpoint.is_none()
822            && self.source_date.is_none()
823            && self.derived_from.is_empty()
824            && self.doi.is_none()
825            && self.cite_as.is_none()
826            && self.keywords.is_empty()
827            && self.theme.is_empty()
828            && self.extra.is_empty()
829            && self.example_queries.is_empty()
830    }
831
832    /// Move every curated field into a card (the shared step of all derivation
833    /// paths, so a new curated field is wired exactly once).
834    fn fill(self, card: &mut DatasetCard) {
835        card.title = self.title;
836        card.description = self.description;
837        card.license = self.license;
838        card.source = self.source;
839        card.created = self.created;
840        card.version = self.version;
841        card.creators = self.creators;
842        card.publisher = self.publisher;
843        card.canonical_url = self.canonical_url;
844        card.sparql_endpoint = self.sparql_endpoint;
845        card.source_date = self.source_date;
846        card.derived_from = self.derived_from;
847        card.doi = self.doi;
848        card.cite_as = self.cite_as;
849        card.keywords = self.keywords;
850        card.theme = self.theme;
851        card.extra = self.extra;
852        card.example_queries = self.example_queries;
853    }
854
855    /// Parse a curated card **document** — the JSON a publisher writes for
856    /// `rete build --card-file`, or a client hands its builder — applying the
857    /// same write-time rules the CLI applies.
858    ///
859    /// This is the client-side twin of `rete-cli`'s `load_curated`, minus the
860    /// file read: same `deny_unknown_fields` top level, same
861    /// [`UNKNOWN_FIELD_HINT`] pointing a stray key at the `extra` bag, same
862    /// [`normalize`](Self::normalize) pass afterwards. A Python/R/JS caller
863    /// that parses its card this way is held to exactly the rules the CLI and
864    /// the browser builder enforce, instead of the "is it an object?" check
865    /// those bindings used to do on their own.
866    ///
867    /// [`UNKNOWN_FIELD_HINT`]: crate::card_input::UNKNOWN_FIELD_HINT
868    pub fn from_json_str(text: &str) -> Result<Self, String> {
869        let parsed: CardInput = serde_json::from_str(text).map_err(|e| {
870            // The top level is reserved for rete-defined fields; point a stray
871            // key at the bag instead of leaving a bare serde error.
872            let hint = if e.to_string().contains("unknown field") {
873                crate::card_input::UNKNOWN_FIELD_HINT
874            } else {
875                ""
876            };
877            format!("{e}{hint}")
878        })?;
879        parsed.normalize()
880    }
881
882    /// Canonicalize and bounds-check every curated field — the write-time gate
883    /// each card-writing path funnels through, applied **after** any per-field
884    /// override a caller layered on top of a card file.
885    ///
886    /// The rules themselves live in [`crate::card_input`]; this is the
887    /// serde-typed face of them, so `rete build --card-file`, the browser
888    /// builder and every language binding reject the same documents with the
889    /// same words.
890    pub fn normalize(mut self) -> Result<Self, String> {
891        if let Some(d) = &self.description {
892            crate::card_input::check_description_len(d)?;
893        }
894        self.keywords = crate::card_input::normalize_string_list(
895            "keywords",
896            std::mem::take(&mut self.keywords),
897        )?;
898        self.theme = crate::card_input::normalize_themes(std::mem::take(&mut self.theme))?;
899        let bag: serde_json::Map<String, serde_json::Value> =
900            std::mem::take(&mut self.extra).into_iter().collect();
901        self.extra = crate::card_input::normalize_extra(bag)?
902            .into_iter()
903            .collect();
904        Ok(self)
905    }
906}
907
908impl DatasetCard {
909    /// Serialize to the JSON bytes stored in the metadata section (compact).
910    ///
911    /// [`Signals::text_index`] is **stripped** on the way out: it is measured
912    /// from the file's section directory at read time, and a copy inside the
913    /// hashed metadata section would be a claim that can outlive the sections it
914    /// describes (`rete repyramid --text-index` rewrites them). Stripping here —
915    /// the single choke point every card-writing command funnels through — is
916    /// what makes "derived, never authored" true of the bytes and not only of
917    /// the intent. (`CardInput` has no `signals` field and rejects unknown keys,
918    /// so a hand-written card file cannot set it either.)
919    pub fn to_json_bytes(&self) -> Vec<u8> {
920        if self.signals.text_index.is_none()
921            && self.signals.label_index.is_none()
922            && self.signals.permutations.is_none()
923            && self.signals.quoted_triples.is_none()
924        {
925            return serde_json::to_vec(self).expect("DatasetCard serializes");
926        }
927        let mut stored = self.clone();
928        stored.signals.text_index = None;
929        stored.signals.label_index = None;
930        stored.signals.permutations = None;
931        stored.signals.quoted_triples = None;
932        serde_json::to_vec(&stored).expect("DatasetCard serializes")
933    }
934
935    /// Attach the read-time [`TextIndexSignal`], returning whatever the card's
936    /// own bytes claimed before it (normally `None` — the writers strip it).
937    ///
938    /// A `Some(_)` return is drift: a card asserting a full-text index that no
939    /// longer has to be believed, because the file itself was just measured.
940    /// `rete card-audit` reports it.
941    pub fn observe_text_index(&mut self, measured: TextIndexSignal) -> Option<TextIndexSignal> {
942        self.signals.text_index.replace(measured)
943    }
944
945    /// Attach the read-time [`LabelIndexSignal`]. Same contract as
946    /// [`observe_text_index`](Self::observe_text_index).
947    pub fn observe_label_index(&mut self, measured: LabelIndexSignal) -> Option<LabelIndexSignal> {
948        self.signals.label_index.replace(measured)
949    }
950
951    /// Attach the read-time [`PermutationsSignal`]. Same contract as
952    /// [`observe_text_index`](Self::observe_text_index): a `Some(_)` return is
953    /// a card whose bytes claimed a permutation set, which the writers strip
954    /// and `CardInput` cannot author.
955    pub fn observe_permutations(
956        &mut self,
957        measured: PermutationsSignal,
958    ) -> Option<PermutationsSignal> {
959        self.signals.permutations.replace(measured)
960    }
961
962    /// Attach the read-time [`QuotedTriplesSignal`]. Same contract as
963    /// [`observe_permutations`](Self::observe_permutations): the writers strip
964    /// it and `CardInput` cannot author it, so a `Some(_)` return is a card
965    /// whose bytes claimed something about the file's own header.
966    pub fn observe_quoted_triples(
967        &mut self,
968        measured: QuotedTriplesSignal,
969    ) -> Option<QuotedTriplesSignal> {
970        self.signals.quoted_triples.replace(measured)
971    }
972
973    /// Parse a card from the metadata-section bytes.
974    ///
975    /// The error is a plain `String`, like every other message in the card
976    /// modules: the CLI wraps it in `anyhow`, the wasm build hands it to
977    /// JavaScript, and neither needs a dependency on the other's error type.
978    pub fn from_json_bytes(b: &[u8]) -> Result<Self, String> {
979        serde_json::from_slice(b).map_err(|e| format!("malformed dataset card: {e}"))
980    }
981
982    /// Stamp the build-time coherence verdict (consuming builder).
983    pub fn with_coherence(mut self, r: &crate::Reasoning, materialized: bool) -> Self {
984        self.coherence = Coherence::from_reasoning(r, materialized);
985        self
986    }
987
988    /// Replace the derived sizes with the counts the file was actually written
989    /// with (consuming builder).
990    ///
991    /// A card is derived from the ingested statements, which is the input
992    /// multiset; the file holds that multiset **deduplicated**, because every
993    /// permutation index sorts and dedups. The two agree for input with no
994    /// duplicates — and only then, which is why `rete card` used to report the
995    /// raw harvest size of any dataset paged with overlapping windows while
996    /// `rete info` reported the real one. The header's count is authoritative;
997    /// this is how it reaches the card. See `crate::FinalCounts`.
998    ///
999    /// The distributions (predicate/class histograms, hub degrees) stay over the
1000    /// derived multiset — they are shapes, not sizes.
1001    pub fn with_final_counts(mut self, counts: crate::ingest::FinalCounts) -> Self {
1002        self.triple_count = counts.default_triples;
1003        self.quad_count = counts.quads;
1004        self
1005    }
1006}
1007
1008/// Cap for every top-N list embedded in the card. The metadata section is
1009/// fetched on **every** overview (it is part of the index-free CARD tier), so an
1010/// unbounded `class_links` (O(classes × predicates × classes)) or predicate list
1011/// would bloat that fetch on a large schema (CIDOC-CRM/MMM). Capping keeps the
1012/// card small and bounded; `truncated` flags when a list was actually cut.
1013pub const CARD_TOP_N: usize = 100;
1014
1015/// How many distinct **annotated statements** the derivation remembers while
1016/// looking for the [`Signals::annotated_statement`] witness.
1017///
1018/// The witness is a statement this graph both *asserts* (`s p o`) and *annotates*
1019/// (`<< s p o >> :about ?v`) — the only thing that proves the coverage query
1020/// (`qt-coverage`) can answer in both of its columns. Finding it means matching
1021/// the two, which is why the star layer is learned in the pass that is already
1022/// walking every statement.
1023///
1024/// It is **sampled**, not exhaustive, because remembering every annotated
1025/// statement would make the derivation's memory scale with the star layer — on
1026/// a fully-annotated graph, three IRIs per statement — and one witness is all a
1027/// body needs. A graph whose first few thousand annotated statements happen to
1028/// be ones it never asserts loses `qt-coverage`; it can never gain a broken
1029/// one, which is the trade the geometry and label capabilities already make.
1030const ANNOTATION_WITNESS_SAMPLE: usize = 4096;
1031
1032// Well-known IRIs (bracketed N-Triples term form, as they appear in the quads).
1033const RDFS_LABEL: &str = "<http://www.w3.org/2000/01/rdf-schema#label>";
1034const SKOS_PREFLABEL: &str = "<http://www.w3.org/2004/02/skos/core#prefLabel>";
1035const SCHEMA_NAME: &str = "<http://schema.org/name>";
1036const FOAF_NAME: &str = "<http://xmlns.com/foaf/0.1/name>";
1037const DCT_TITLE: &str = "<http://purl.org/dc/terms/title>";
1038const OWL_SAMEAS: &str = "<http://www.w3.org/2002/07/owl#sameAs>";
1039const SKOS_EXACTMATCH: &str = "<http://www.w3.org/2004/02/skos/core#exactMatch>";
1040const SKOS_CLOSEMATCH: &str = "<http://www.w3.org/2004/02/skos/core#closeMatch>";
1041const RDFS_SEEALSO: &str = "<http://www.w3.org/2000/01/rdf-schema#seeAlso>";
1042pub(crate) const WGS_LAT: &str = "<http://www.w3.org/2003/01/geo/wgs84_pos#lat>";
1043pub(crate) const WGS_LONG: &str = "<http://www.w3.org/2003/01/geo/wgs84_pos#long>";
1044// `pub`: the query generator gates its geometry template on which of
1045// these two the card actually recorded, so both modules must name the same IRI.
1046pub const GEO_ASWKT: &str = "<http://www.opengis.net/ont/geosparql#asWKT>";
1047pub const GEO_HASGEOMETRY: &str = "<http://www.opengis.net/ont/geosparql#hasGeometry>";
1048// Datatype IRIs (unbracketed — as they appear after `^^` in a literal term).
1049const GEO_WKTLITERAL: &str = "http://www.opengis.net/ont/geosparql#wktLiteral";
1050const XSD_STRING: &str = "http://www.w3.org/2001/XMLSchema#string";
1051const RDF_LANGSTRING: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString";
1052
1053/// The [`ClassLink::o_class`] sentinel for a literal object (the object of that
1054/// row is a literal, so it has no class and no outgoing edges). `pub`:
1055/// the query generator reads it to tell an entity-to-entity relation — one a
1056/// path query can walk — from a literal-valued one.
1057pub const O_LITERAL: &str = "(literal)";
1058
1059/// Label predicates in priority order; the most frequent **present** one wins.
1060const LABEL_PREDICATES: &[&str] = &[
1061    SKOS_PREFLABEL,
1062    RDFS_LABEL,
1063    SCHEMA_NAME,
1064    FOAF_NAME,
1065    DCT_TITLE,
1066];
1067/// Cross-dataset linking predicates (emitted in `signals.link_predicates`).
1068pub(crate) const LINK_PREDICATES: &[&str] =
1069    &[OWL_SAMEAS, SKOS_EXACTMATCH, SKOS_CLOSEMATCH, RDFS_SEEALSO];
1070
1071/// A replayable source of a graph's **default-graph** triples for card
1072/// derivation. `replay` may be called more than once (the derivation makes two
1073/// passes); every call MUST yield the same triples in the same order. Terms are
1074/// passed as borrowed `&str` valid only for that call — so the derivation owns
1075/// (clones) any key it retains, letting one code path serve both an in-memory
1076/// quad slice and a streaming build's dictionary + id-triples.
1077pub trait CardTripleSource {
1078    fn replay(&self, f: &mut dyn FnMut(&str, &str, &str));
1079}
1080
1081/// Derive a full card from the parsed quads plus a few build-time counts — the
1082/// in-memory build path. Statistics are over the **default graph** only
1083/// (named-graph statistics are summarized by `quad_count`/`named_graph_count`),
1084/// matching `rete stats`/`rete predicates`.
1085/// A card holding curated fields + top-line counts only — for the external
1086/// (memory-bounded) build, where deriving the profile lists (predicates/classes/
1087/// hubs/links) would need unbounded RAM. Every derived list is left empty and
1088/// `truncated` is unset (the lists are absent, not cut).
1089pub fn curated_counts_card(statements: u64, term_count: u64, curated: CardInput) -> DatasetCard {
1090    let mut card = DatasetCard {
1091        triple_count: statements,
1092        quad_count: statements,
1093        named_graph_count: 0,
1094        term_count,
1095        format_version: crate::format::CURRENT_FORMAT_VERSION,
1096        ..DatasetCard::default()
1097    };
1098    curated.fill(&mut card);
1099    card
1100}
1101
1102pub fn derive_card(
1103    quads: &[(String, String, String, Option<String>)],
1104    term_count: u64,
1105    named_graph_count: u64,
1106    curated: CardInput,
1107) -> DatasetCard {
1108    if crate::build_progress::listening() {
1109        let default = quads.iter().filter(|q| q.3.is_none()).count() as u64;
1110        crate::build_progress::set_total(Some(2 * default), crate::build_progress::Unit::Records);
1111    }
1112    struct Quads<'a>(&'a [(String, String, String, Option<String>)]);
1113    impl CardTripleSource for Quads<'_> {
1114        fn replay(&self, f: &mut dyn FnMut(&str, &str, &str)) {
1115            for (s, p, o, g) in self.0 {
1116                if g.is_none() {
1117                    f(s, p, o);
1118                }
1119            }
1120        }
1121    }
1122    derive_card_from(
1123        &Quads(quads),
1124        quads.len() as u64,
1125        term_count,
1126        named_graph_count,
1127        curated,
1128    )
1129}
1130
1131/// Derive a card from a built dictionary + default-graph **id-triples** — the
1132/// streaming (low-RAM) build path, where the raw quads were never retained.
1133/// Resolves each id-triple back to its terms through the dictionary, yielding the
1134/// same `(s, p, o)` strings in file order, so the card is **byte-identical** to
1135/// the in-memory derivation on the same graph.
1136pub fn derive_card_encoded(
1137    dict: &crate::Dictionary,
1138    triples: &[(u32, u32, u32)],
1139    quad_count: u64,
1140    term_count: u64,
1141    named_graph_count: u64,
1142    curated: CardInput,
1143) -> DatasetCard {
1144    crate::build_progress::set_total(
1145        Some(2 * triples.len() as u64),
1146        crate::build_progress::Unit::Records,
1147    );
1148    struct Encoded<'a> {
1149        dict: &'a crate::Dictionary,
1150        triples: &'a [(u32, u32, u32)],
1151    }
1152    impl CardTripleSource for Encoded<'_> {
1153        fn replay(&self, f: &mut dyn FnMut(&str, &str, &str)) {
1154            for &(s, p, o) in self.triples {
1155                let st = self.dict.subject_term(s).unwrap_or_default();
1156                let pt = self.dict.predicate_term(p).unwrap_or_default();
1157                let ot = self.dict.object_term(o).unwrap_or_default();
1158                f(&st, &pt, &ot);
1159            }
1160        }
1161    }
1162    derive_card_from(
1163        &Encoded { dict, triples },
1164        quad_count,
1165        term_count,
1166        named_graph_count,
1167        curated,
1168    )
1169}
1170
1171/// The shared card derivation, over any [`CardTripleSource`]. Two passes: the
1172/// first builds the subject→class map needed to classify both endpoints of every
1173/// relation (the `class_links` quotient — the same logic as
1174/// [`crate::schema_summary`], folded in here to avoid a second full
1175/// materialization); the second tallies everything else. All counts are over the
1176/// raw (pre-dedup) default-graph multiset, matching the existing card stats and
1177/// `rete progressive`.
1178pub fn derive_card_from(
1179    src: &dyn CardTripleSource,
1180    quad_count: u64,
1181    term_count: u64,
1182    named_graph_count: u64,
1183    curated: CardInput,
1184) -> DatasetCard {
1185    // --- Pass 1: subject → class (last type wins, matching `schema_summary`),
1186    // plus the shape of the RDF-star layer, learned here because pass 2 needs
1187    // to recognize an *asserted* statement as one this graph also annotates
1188    // and the two can appear in either file order. See
1189    // `ANNOTATION_WITNESS_SAMPLE`. ---
1190    let mut class_of: std::collections::HashMap<String, String> = std::collections::HashMap::new();
1191    let mut quoted_subject_preds: BTreeSet<String> = BTreeSet::new();
1192    let mut annotated_tokens: BTreeMap<String, String> = BTreeMap::new();
1193    // `--progress`: two passes over the default graph.
1194    let mut ticker = crate::build_progress::Ticker::new();
1195    src.replay(&mut |s, p, o| {
1196        ticker.tick(1);
1197        if p == RDF_TYPE {
1198            class_of.insert(s.to_string(), o.to_string());
1199        }
1200        if crate::terms::is_quoted_triple(s) {
1201            if let Some((_, quoted_p, _)) = crate::ingest::quoted_triple_parts(s) {
1202                quoted_subject_preds.insert(quoted_p);
1203            }
1204            if annotated_tokens.len() < ANNOTATION_WITNESS_SAMPLE {
1205                annotated_tokens
1206                    .entry(s.to_string())
1207                    .or_insert_with(|| p.to_string());
1208            }
1209        }
1210    });
1211    let classify = |t: &str| -> String {
1212        if let Some(c) = class_of.get(t) {
1213            c.to_string()
1214        } else if t.starts_with('"') {
1215            O_LITERAL.to_string()
1216        } else {
1217            O_UNTYPED.to_string()
1218        }
1219    };
1220
1221    // --- Pass 2: every other statistic, in one sweep. ---
1222    // Keys are owned `String`s (not `&str` borrowed from a quad slice) so the
1223    // same derivation serves a streaming source whose term strings are transient;
1224    // a `BTreeMap<String, _>` sorts identically to the old `BTreeMap<&str, _>`, so
1225    // the output is byte-identical.
1226    let mut prof = Profile::default();
1227    let mut out_degree: BTreeMap<String, u64> = BTreeMap::new();
1228    let mut in_degree: BTreeMap<String, u64> = BTreeMap::new();
1229
1230    src.replay(&mut |s, p, o| {
1231        ticker.tick(1);
1232        prof.triple_count += 1;
1233        *prof.pred_counts.entry(p.to_string()).or_default() += 1;
1234        *out_degree.entry(s.to_string()).or_default() += 1;
1235        if let Some(ns) = split_namespace(s) {
1236            *prof.subject_ns.entry(ns).or_default() += 1;
1237        }
1238
1239        // In-degree: every non-literal object referenced. Matches the
1240        // `top-in-hubs` query (`FILTER(!isLiteral(?o))`), which does not
1241        // special-case rdf:type, so the card precompute equals the query result.
1242        if !o.starts_with('"') {
1243            *in_degree.entry(o.to_string()).or_default() += 1;
1244        }
1245
1246        // RDF-star, before the `rdf:type` early return below: a quoted triple
1247        // may be typed (`<< s p o >> a :Claim`) like anything else, and that
1248        // statement is still one made about a statement.
1249        if crate::terms::is_quoted_triple(s) {
1250            *prof.annotation_hits.entry(p.to_string()).or_default() += 1;
1251        }
1252        if crate::terms::is_quoted_triple(o) {
1253            *prof.quoting_hits.entry(p.to_string()).or_default() += 1;
1254        }
1255        // The `annotated_statement` witness: THIS statement is asserted, and
1256        // pass 1 saw it quoted and annotated. `quoted_subject_preds` is empty
1257        // on every graph without an RDF-star layer — which is almost all of
1258        // them — so the common path is one set-emptiness check per statement,
1259        // and the token is only ever built for a predicate that really does
1260        // appear inside a quoted triple.
1261        if !quoted_subject_preds.is_empty() && quoted_subject_preds.contains(p) {
1262            if let Some(about) = annotated_tokens.get(&format!("<<{s} {p} {o}>>")) {
1263                *prof
1264                    .witnessed_pairs
1265                    .entry((p.to_string(), about.clone()))
1266                    .or_default() += 1;
1267            }
1268        }
1269
1270        if p == RDF_TYPE {
1271            *prof.class_counts.entry(o.to_string()).or_default() += 1;
1272            return; // type assertions define classes, not data relations
1273        }
1274
1275        // Class-to-class quotient (the effective schema).
1276        *prof
1277            .link_rows
1278            .entry((classify(s), p.to_string(), classify(o)))
1279            .or_default() += 1;
1280
1281        if p == GEO_ASWKT || p == GEO_HASGEOMETRY {
1282            prof.geo_wkt = true;
1283        }
1284
1285        // Literal objects: datatype / language / temporal / geo analysis.
1286        if let Some(lit) = parse_literal(o) {
1287            prof.literal(p, &lit, 1);
1288        }
1289    });
1290
1291    let mut truncated = false;
1292    let top_hubs = cap(sort_desc_owned(out_degree), &mut truncated);
1293    let in_hubs = cap(sort_desc_owned(in_degree), &mut truncated);
1294    finish(
1295        prof,
1296        Hubs {
1297            top_hubs,
1298            in_hubs,
1299            truncated,
1300        },
1301        quad_count,
1302        term_count,
1303        named_graph_count,
1304        curated,
1305    )
1306}
1307
1308/// The [`ClassLink::o_class`] / `s_class` sentinel for a term that carries no
1309/// `rdf:type` (an IRI or blank node nobody typed).
1310const O_UNTYPED: &str = "(untyped)";
1311
1312/// The bounded tallies a card profile is computed from — what both derivation
1313/// paths fill and [`finish`] turns into a card, so the signals, caps and
1314/// orderings have exactly one implementation.
1315///
1316/// "Bounded" is the point: every map here is keyed by *vocabulary* (predicates,
1317/// classes, datatypes, languages, namespaces), never by subject or object, so
1318/// its size does not grow with the graph. The two per-term tallies — out- and
1319/// in-degree — are the exception, and each path keeps them its own way: the
1320/// in-memory one in full maps, the streaming one in a top-N heap
1321/// ([`TopN`]), handing [`finish`] the capped lists through [`Hubs`].
1322#[derive(Default)]
1323struct Profile {
1324    triple_count: u64,
1325    pred_counts: BTreeMap<String, u64>,
1326    class_counts: BTreeMap<String, u64>,
1327    datatype_counts: BTreeMap<String, u64>,
1328    lang_counts: BTreeMap<String, u64>,
1329    link_rows: BTreeMap<(String, String, String), u64>,
1330    subject_ns: BTreeMap<String, u64>,
1331    /// Per-predicate temporal evidence (statements whose object is a date).
1332    time_obj_hits: BTreeMap<String, u64>,
1333    /// Per-predicate numeric-object hits (for value-range queries).
1334    num_obj_hits: BTreeMap<String, u64>,
1335    /// RDF-star: the predicate of a statement whose subject is a quoted triple
1336    /// (`annotation_hits`), or whose object is (`quoting_hits`), and the
1337    /// pairs matched against the sample — the witness a body pinning both
1338    /// needs (see `Signals::annotated_statement`).
1339    annotation_hits: BTreeMap<String, u64>,
1340    quoting_hits: BTreeMap<String, u64>,
1341    witnessed_pairs: BTreeMap<(String, String), u64>,
1342    /// Per-predicate earliest and latest date, in calendar order.
1343    time_extent: BTreeMap<String, (String, String)>,
1344    /// wgs84 extents, `(min, max)`.
1345    lat: Option<(f64, f64)>,
1346    lon: Option<(f64, f64)>,
1347    geo_wkt: bool,
1348}
1349
1350impl Profile {
1351    /// Fold one literal object of predicate `p` in, counted `weight` times —
1352    /// 1 per statement on the in-memory path; the streaming path sees each
1353    /// distinct `(object, predicate)` pair once, with its multiplicity.
1354    fn literal(&mut self, p: &str, lit: &Literal, weight: u64) {
1355        *self
1356            .datatype_counts
1357            .entry(format!("<{}>", lit.datatype))
1358            .or_default() += weight;
1359        *self.lang_counts.entry(lit.lang.clone()).or_default() += weight;
1360        if lit.datatype == GEO_WKTLITERAL {
1361            self.geo_wkt = true;
1362        }
1363        if is_temporal(p, lit) {
1364            *self.time_obj_hits.entry(p.to_string()).or_default() += weight;
1365            update_time_extent(p, &lit.value, &mut self.time_extent);
1366        } else if is_numeric_datatype(&lit.datatype) {
1367            // A date is temporal, not "numeric range" material, so this is an
1368            // `else if` — a year typed `xsd:gYear` is a time predicate.
1369            *self.num_obj_hits.entry(p.to_string()).or_default() += weight;
1370        }
1371        // wgs84 lat/long numeric extent.
1372        if p == WGS_LAT {
1373            if let Ok(v) = lit.value.parse::<f64>() {
1374                widen(&mut self.lat, v);
1375            }
1376        } else if p == WGS_LONG {
1377            if let Ok(v) = lit.value.parse::<f64>() {
1378                widen(&mut self.lon, v);
1379            }
1380        }
1381    }
1382}
1383
1384/// Grow a `(min, max)` extent to cover `v`.
1385fn widen(extent: &mut Option<(f64, f64)>, v: f64) {
1386    *extent = Some(match *extent {
1387        None => (v, v),
1388        Some((lo, hi)) => (lo.min(v), hi.max(v)),
1389    });
1390}
1391
1392/// The two per-term top-N lists, already capped, and whether either was cut.
1393struct Hubs {
1394    top_hubs: Vec<(String, u64)>,
1395    in_hubs: Vec<(String, u64)>,
1396    truncated: bool,
1397}
1398
1399/// Turn the tallies into a card: cap and order every list, derive the
1400/// signals, fill the curated half, and generate the starter queries. The one
1401/// place both derivation paths meet, so a card derived from quads and a card
1402/// derived from a finished `.rete` cannot disagree about anything but the
1403/// counts they were handed.
1404fn finish(
1405    p: Profile,
1406    hubs: Hubs,
1407    quad_count: u64,
1408    term_count: u64,
1409    named_graph_count: u64,
1410    curated: CardInput,
1411) -> DatasetCard {
1412    let Profile {
1413        triple_count,
1414        pred_counts,
1415        class_counts,
1416        datatype_counts,
1417        lang_counts,
1418        link_rows,
1419        subject_ns,
1420        time_obj_hits,
1421        num_obj_hits,
1422        annotation_hits,
1423        quoting_hits,
1424        witnessed_pairs,
1425        time_extent,
1426        lat,
1427        lon,
1428        geo_wkt,
1429    } = p;
1430    // Signal lookups that need the raw per-predicate counts — computed before
1431    // `pred_counts` is consumed by the sort below.
1432    let pred_count = |iri: &str| pred_counts.get(iri).copied().unwrap_or(0);
1433    let label_predicate = LABEL_PREDICATES
1434        .iter()
1435        .filter(|&&iri| pred_count(iri) > 0)
1436        .max_by_key(|&&iri| pred_count(iri))
1437        .map(|s| s.to_string());
1438    let link_predicates: Vec<String> = LINK_PREDICATES
1439        .iter()
1440        .filter(|&&iri| pred_count(iri) > 0)
1441        .map(|s| s.to_string())
1442        .collect();
1443
1444    let mut truncated = hubs.truncated;
1445    let predicates = cap(sort_desc_owned(pred_counts), &mut truncated);
1446    let classes = cap(sort_desc_owned(class_counts), &mut truncated);
1447    let datatypes = cap(sort_desc_owned(datatype_counts), &mut truncated);
1448    let languages = cap(sort_desc_owned(lang_counts), &mut truncated);
1449    let class_links = cap(sort_links(link_rows), &mut truncated);
1450
1451    // Vocabularies: distinct namespaces of the predicate and class IRIs.
1452    let mut vocab: BTreeSet<String> = BTreeSet::new();
1453    for (iri, _) in predicates.iter().chain(classes.iter()) {
1454        if let Some(ns) = split_namespace(iri) {
1455            vocab.insert(ns);
1456        }
1457    }
1458    let vocabularies = cap(vocab.into_iter().collect(), &mut truncated);
1459
1460    // --- Signals (index-free affordances for the query-library generator). ---
1461    let default_lang = languages
1462        .iter()
1463        .find(|(l, _)| !l.is_empty())
1464        .map(|(l, _)| l.clone());
1465    let base_iri = derive_base_iri(&subject_ns);
1466    let mut time_ranked: Vec<(String, u64)> = time_obj_hits.into_iter().collect();
1467    time_ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1468    // Cap through the shared helper so an over-long list also flips `truncated`.
1469    let time_predicates = cap(
1470        time_ranked.iter().map(|(p, _)| p.to_string()).collect(),
1471        &mut truncated,
1472    );
1473    let mut num_ranked: Vec<(String, u64)> = num_obj_hits.into_iter().collect();
1474    num_ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1475    let numeric_predicates = cap(
1476        num_ranked.iter().map(|(p, _)| p.to_string()).collect(),
1477        &mut truncated,
1478    );
1479    // RDF-star vocabulary, ranked the same way (most statements first, ties by
1480    // term) and capped through the same helper, so an over-long list also
1481    // flips `truncated`. All three are empty/`None` on a graph with no quoted
1482    // triples — which is almost every graph — and the whole `qt-*` family then
1483    // does not exist, leaving those cards byte-identical.
1484    let annotation_predicates = cap(
1485        sort_desc_owned(annotation_hits)
1486            .into_iter()
1487            .map(|(p, _)| p)
1488            .collect(),
1489        &mut truncated,
1490    );
1491    let quoting_predicates = cap(
1492        sort_desc_owned(quoting_hits)
1493            .into_iter()
1494            .map(|(p, _)| p)
1495            .collect(),
1496        &mut truncated,
1497    );
1498    // The single most-witnessed pair, not a list: one witness is all a body
1499    // needs, and shipping the whole cross-product would cost the metadata
1500    // section for nothing. Ties break on the key so the choice is
1501    // deterministic (`max_by` keeps the last maximum, so the key comparison is
1502    // reversed to make it the smallest one).
1503    let annotated_statement = witnessed_pairs
1504        .into_iter()
1505        .max_by(|a, b| a.1.cmp(&b.1).then_with(|| b.0.cmp(&a.0)))
1506        .map(|(pair, _)| pair);
1507    let geo_latlong = lat.is_some() && lon.is_some();
1508    let temporal_extent = time_ranked
1509        .first()
1510        .and_then(|(p, _)| time_extent.get(p))
1511        .cloned();
1512    let spatial_bbox = match (lat, lon) {
1513        (Some((lat_lo, lat_hi)), Some((lon_lo, lon_hi)))
1514            if lon_lo.is_finite() && lat_lo.is_finite() =>
1515        {
1516            Some([lon_lo, lat_lo, lon_hi, lat_hi])
1517        }
1518        _ => None,
1519    };
1520
1521    let signals = Signals {
1522        label_predicate,
1523        default_lang,
1524        base_iri,
1525        time_predicates,
1526        numeric_predicates,
1527        link_predicates,
1528        annotation_predicates,
1529        quoting_predicates,
1530        annotated_statement,
1531        geo_wkt,
1532        geo_latlong,
1533        temporal_extent,
1534        spatial_bbox,
1535        // Measured by the READER from the file's header/section directory,
1536        // never derived from the triples and never written — see
1537        // `TextIndexSignal`, `PermutationsSignal` and `QuotedTriplesSignal`.
1538        text_index: None,
1539        label_index: None,
1540        permutations: None,
1541        quoted_triples: None,
1542    };
1543
1544    let mut card = DatasetCard {
1545        triple_count,
1546        quad_count,
1547        named_graph_count,
1548        term_count,
1549        predicates,
1550        classes,
1551        vocabularies,
1552        datatypes,
1553        languages,
1554        class_links,
1555        top_hubs: hubs.top_hubs,
1556        in_hubs: hubs.in_hubs,
1557        signals,
1558        queries: Vec::new(),
1559        // Stamped later by the build pipeline via `with_coherence` when `--reason`
1560        // (or `--materialize`) ran; derive_card itself must not run the reasoner.
1561        coherence: Coherence::default(),
1562        truncated,
1563        // The cap the profile lists above were derived under (deterministic:
1564        // a compile-time constant of this builder, not a per-build fact).
1565        top_n: CARD_TOP_N as u32,
1566        format_version: crate::CURRENT_FORMAT_VERSION,
1567        ..DatasetCard::default()
1568    };
1569    curated.fill(&mut card);
1570    // The tiered starter-query library, instantiated from the profile above.
1571    card.queries = crate::card_queries::generate(&card);
1572    card
1573}
1574
1575/// Cap a top-N list, flagging `truncated` if anything was dropped.
1576fn cap<T>(mut v: Vec<T>, truncated: &mut bool) -> Vec<T> {
1577    if v.len() > CARD_TOP_N {
1578        v.truncate(CARD_TOP_N);
1579        *truncated = true;
1580    }
1581    v
1582}
1583
1584/// Sort an owned-`String`-keyed count map descending by count, then by term.
1585fn sort_desc_owned(counts: BTreeMap<String, u64>) -> Vec<(String, u64)> {
1586    let mut v: Vec<(String, u64)> = counts.into_iter().collect();
1587    v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1588    v
1589}
1590
1591/// Sort the class-link quotient deterministically: descending by count, then by
1592/// the `(s_class, predicate, o_class)` key — a total order for a stable hash.
1593fn sort_links(rows: BTreeMap<(String, String, String), u64>) -> Vec<ClassLink> {
1594    let mut v: Vec<ClassLink> = rows
1595        .into_iter()
1596        .map(|((s, p, o), count)| ClassLink {
1597            s_class: s,
1598            predicate: p,
1599            o_class: o,
1600            count,
1601        })
1602        .collect();
1603    v.sort_by(|a, b| {
1604        b.count
1605            .cmp(&a.count)
1606            .then_with(|| a.s_class.cmp(&b.s_class))
1607            .then_with(|| a.predicate.cmp(&b.predicate))
1608            .then_with(|| a.o_class.cmp(&b.o_class))
1609    });
1610    v
1611}
1612
1613/// The dataset's own base IRI, for the `{{BASE_IRI}}` substitution (and the
1614/// "links pointing outside the dataset" query). The longest common prefix of all
1615/// subject namespaces, truncated to a `/`/`#` boundary — `http://ex/` for
1616/// subjects under `http://ex/person/`, `http://ex/place/`, … When the LCP is
1617/// coarser than `scheme://host/` (subjects span unrelated hosts), fall back to
1618/// the single most-frequent namespace so the value stays a usable prefix.
1619fn derive_base_iri(ns: &BTreeMap<String, u64>) -> Option<String> {
1620    if ns.is_empty() {
1621        return None;
1622    }
1623    // Dominant namespace: highest count, ties broken by the smaller string.
1624    let dominant = ns
1625        .iter()
1626        .max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
1627        .map(|(k, _)| k.clone())?;
1628    // Longest common prefix across every subject namespace.
1629    let mut lcp_len = dominant.len();
1630    for k in ns.keys() {
1631        let common = dominant
1632            .bytes()
1633            .zip(k.bytes())
1634            .take_while(|(a, b)| a == b)
1635            .count();
1636        lcp_len = lcp_len.min(common);
1637    }
1638    let lcp = &dominant[..lcp_len];
1639    // Truncate to the last namespace delimiter so we never cut mid-segment.
1640    let base = match lcp.rfind(['/', '#']) {
1641        Some(i) => &lcp[..=i],
1642        None => "",
1643    };
1644    // Require at least `scheme://host/` (three slashes); else the LCP collapsed
1645    // across hosts — the dominant namespace is the more useful prefix.
1646    if base.matches('/').count() >= 3 {
1647        Some(base.to_string())
1648    } else {
1649        Some(dominant)
1650    }
1651}
1652
1653/// The decomposed parts of an RDF literal term.
1654struct Literal {
1655    /// Lexical value with the surrounding quotes and escapes removed.
1656    value: String,
1657    /// Datatype IRI (unbracketed): `xsd:string` for plain, `rdf:langString` for
1658    /// language-tagged, else the explicit `^^<…>` type.
1659    datatype: String,
1660    /// Language tag (`""` when none).
1661    lang: String,
1662}
1663
1664/// Decompose an N-Triples literal term (`"…"`, `"…"@lang`, `"…"^^<dt>`) into its
1665/// value, datatype, and language. Returns `None` for non-literal terms.
1666fn parse_literal(term: &str) -> Option<Literal> {
1667    let bytes = term.as_bytes();
1668    if bytes.first() != Some(&b'"') {
1669        return None;
1670    }
1671    // Find the closing unescaped quote.
1672    let mut i = 1usize;
1673    let mut esc = false;
1674    let mut close = None;
1675    while i < bytes.len() {
1676        match bytes[i] {
1677            b'\\' => esc = !esc,
1678            b'"' if !esc => {
1679                close = Some(i);
1680                break;
1681            }
1682            _ => esc = false,
1683        }
1684        i += 1;
1685    }
1686    let close = close?;
1687    let value = term[1..close].to_string();
1688    let suffix = &term[close + 1..];
1689    if let Some(lang) = suffix.strip_prefix('@') {
1690        Some(Literal {
1691            value,
1692            datatype: RDF_LANGSTRING.to_string(),
1693            lang: lang.to_string(),
1694        })
1695    } else if let Some(dt) = suffix.strip_prefix("^^") {
1696        let dt = dt.trim_start_matches('<').trim_end_matches('>').to_string();
1697        Some(Literal {
1698            value,
1699            datatype: dt,
1700            lang: String::new(),
1701        })
1702    } else {
1703        Some(Literal {
1704            value,
1705            datatype: XSD_STRING.to_string(),
1706            lang: String::new(),
1707        })
1708    }
1709}
1710
1711/// True for the XSD datatypes whose values are points on a calendar — the
1712/// strong temporal signal. `gMonthDay` and `gDay` are recurring dates with no
1713/// year, so they have no extent and are not counted.
1714fn is_temporal_datatype(dt: &str) -> bool {
1715    matches!(
1716        dt,
1717        "http://www.w3.org/2001/XMLSchema#date"
1718            | "http://www.w3.org/2001/XMLSchema#dateTime"
1719            | "http://www.w3.org/2001/XMLSchema#dateTimeStamp"
1720            | "http://www.w3.org/2001/XMLSchema#gYear"
1721            | "http://www.w3.org/2001/XMLSchema#gYearMonth"
1722    )
1723}
1724
1725/// Predicates whose values are dates by definition, so a date-shaped value
1726/// that arrives as a plain string (or a bare integer year) still counts as
1727/// time. Deliberately a closed list: guessing from a local name (`…Year`,
1728/// `…date…`) is how ORCIDs and page numbers became "years".
1729const DATE_PREDICATES: &[&str] = &[
1730    "<http://purl.org/dc/terms/date>",
1731    "<http://purl.org/dc/terms/issued>",
1732    "<http://purl.org/dc/terms/created>",
1733    "<http://purl.org/dc/terms/modified>",
1734    "<http://purl.org/dc/terms/available>",
1735    "<http://purl.org/dc/terms/dateAccepted>",
1736    "<http://purl.org/dc/terms/dateCopyrighted>",
1737    "<http://purl.org/dc/terms/dateSubmitted>",
1738    "<http://purl.org/dc/elements/1.1/date>",
1739    "<http://schema.org/datePublished>",
1740    "<http://schema.org/dateCreated>",
1741    "<http://schema.org/dateModified>",
1742    "<http://schema.org/startDate>",
1743    "<http://schema.org/endDate>",
1744    "<http://schema.org/birthDate>",
1745    "<http://schema.org/deathDate>",
1746    "<http://schema.org/foundingDate>",
1747    "<http://schema.org/uploadDate>",
1748    "<https://schema.org/datePublished>",
1749    "<https://schema.org/dateCreated>",
1750    "<https://schema.org/dateModified>",
1751    "<https://schema.org/startDate>",
1752    "<https://schema.org/endDate>",
1753    "<https://schema.org/birthDate>",
1754    "<https://schema.org/deathDate>",
1755    "<http://prismstandard.org/namespaces/basic/2.0/publicationDate>",
1756    "<http://prismstandard.org/namespaces/basic/2.0/coverDate>",
1757    "<http://www.w3.org/ns/prov#generatedAtTime>",
1758    "<http://www.w3.org/ns/prov#invalidatedAtTime>",
1759    "<http://www.w3.org/ns/prov#startedAtTime>",
1760    "<http://www.w3.org/ns/prov#endedAtTime>",
1761    "<http://www.w3.org/ns/prov#atTime>",
1762    "<http://www.w3.org/2006/time#inXSDDate>",
1763    "<http://www.w3.org/2006/time#inXSDDateTime>",
1764    "<http://www.w3.org/2006/time#inXSDDateTimeStamp>",
1765    "<http://www.w3.org/2006/time#inXSDgYear>",
1766    "<http://www.w3.org/2006/time#inXSDgYearMonth>",
1767    "<http://dbpedia.org/ontology/birthDate>",
1768    "<http://dbpedia.org/ontology/deathDate>",
1769    "<http://dbpedia.org/ontology/releaseDate>",
1770    "<http://www.wikidata.org/prop/direct/P569>",
1771    "<http://www.wikidata.org/prop/direct/P570>",
1772    "<http://www.wikidata.org/prop/direct/P571>",
1773    "<http://www.wikidata.org/prop/direct/P577>",
1774    "<http://www.wikidata.org/prop/direct/P580>",
1775    "<http://www.wikidata.org/prop/direct/P582>",
1776    "<http://www.wikidata.org/prop/direct/P585>",
1777];
1778
1779/// Is this literal, on this predicate, evidence of time?
1780///
1781/// Two ways in, and both demand a value that is **shaped like a date**:
1782///
1783/// * a calendar datatype ([`is_temporal_datatype`]) — a typed value that is
1784///   not date-shaped is malformed, and malformed values are no evidence;
1785/// * a known date predicate ([`DATE_PREDICATES`]) with an untyped, string or
1786///   integer value.
1787///
1788/// What it no longer accepts is the old weak signal, "any value whose first
1789/// four characters are digits", which read an ORCID (`0000-0001-5001-3721`),
1790/// a page number (`9999880`) and a title starting with a year as years and
1791/// gave the OpenCitations card a "temporal extent" from one to the other.
1792fn is_temporal(p: &str, lit: &Literal) -> bool {
1793    if is_temporal_datatype(&lit.datatype) {
1794        return date_shaped(&lit.value);
1795    }
1796    DATE_PREDICATES.contains(&p) && date_shaped(&lit.value)
1797}
1798
1799/// A strict ISO 8601 / XSD date shape: `[-]YYYY`, `YYYY-MM`, `YYYY-MM-DD` or
1800/// `YYYY-MM-DDThh:mm[:ss[.f+]]`, each with an optional `Z` / `±hh:mm` zone —
1801/// exactly four year digits, month 01–12, day 01–31, and nothing after.
1802///
1803/// Identifier-shaped and page-shaped strings fail it by construction: a fifth
1804/// digit makes a number, not a year (`9999880`); a second four-digit group
1805/// cannot be a month (`0000-0001-5001-3721`, `1234-1240`).
1806fn date_shaped(v: &str) -> bool {
1807    let b = v.as_bytes();
1808    let digits =
1809        |at: usize, n: usize| b.len() >= at + n && b[at..at + n].iter().all(u8::is_ascii_digit);
1810    let two_in = |at: usize, lo: u8, hi: u8| {
1811        digits(at, 2) && {
1812            let n = (b[at] - b'0') * 10 + (b[at + 1] - b'0');
1813            (lo..=hi).contains(&n)
1814        }
1815    };
1816    let mut i = usize::from(b.first() == Some(&b'-'));
1817    if !digits(i, 4) {
1818        return false;
1819    }
1820    i += 4;
1821    if b.get(i) == Some(&b'-') {
1822        if !two_in(i + 1, 1, 12) {
1823            return false;
1824        }
1825        i += 3;
1826        if b.get(i) == Some(&b'-') {
1827            if !two_in(i + 1, 1, 31) {
1828                return false;
1829            }
1830            i += 3;
1831            if b.get(i) == Some(&b'T') {
1832                if !(two_in(i + 1, 0, 24) && b.get(i + 3) == Some(&b':') && two_in(i + 4, 0, 59)) {
1833                    return false;
1834                }
1835                i += 6;
1836                if b.get(i) == Some(&b':') {
1837                    if !two_in(i + 1, 0, 60) {
1838                        return false;
1839                    }
1840                    i += 3;
1841                    if b.get(i) == Some(&b'.') {
1842                        let start = i + 1;
1843                        i = start;
1844                        while b.get(i).is_some_and(u8::is_ascii_digit) {
1845                            i += 1;
1846                        }
1847                        if i == start {
1848                            return false;
1849                        }
1850                    }
1851                }
1852            }
1853        }
1854    }
1855    match &b[i..] {
1856        [] | [b'Z'] => true,
1857        [sign, h1, h2, b':', m1, m2] => {
1858            (*sign == b'+' || *sign == b'-') && [h1, h2, m1, m2].iter().all(|c| c.is_ascii_digit())
1859        }
1860        _ => false,
1861    }
1862}
1863
1864/// True for XSD numeric datatypes — candidates for value-range aggregates.
1865fn is_numeric_datatype(dt: &str) -> bool {
1866    matches!(
1867        dt,
1868        "http://www.w3.org/2001/XMLSchema#integer"
1869            | "http://www.w3.org/2001/XMLSchema#decimal"
1870            | "http://www.w3.org/2001/XMLSchema#double"
1871            | "http://www.w3.org/2001/XMLSchema#float"
1872            | "http://www.w3.org/2001/XMLSchema#long"
1873            | "http://www.w3.org/2001/XMLSchema#int"
1874            | "http://www.w3.org/2001/XMLSchema#short"
1875            | "http://www.w3.org/2001/XMLSchema#nonNegativeInteger"
1876            | "http://www.w3.org/2001/XMLSchema#positiveInteger"
1877    )
1878}
1879
1880/// Calendar order over date-shaped lexical values of mixed precision: the
1881/// signed year first (so `-0100` sorts before `-0044`, which a byte compare
1882/// gets backwards), then the rest of the value lexically (`2019` < `2019-05` <
1883/// `2019-05-01`, the order a reader expects of an extent).
1884fn temporal_key(v: &str) -> (i64, &str) {
1885    let (neg, rest) = match v.strip_prefix('-') {
1886        Some(r) => (true, r),
1887        None => (false, v),
1888    };
1889    let n = rest.bytes().take_while(u8::is_ascii_digit).count();
1890    let year: i64 = rest[..n].parse().unwrap_or(0);
1891    (if neg { -year } else { year }, &rest[n..])
1892}
1893
1894/// Fold one temporal value into its predicate's earliest/latest. Every value
1895/// reaching here passed [`date_shaped`], so the calendar order of
1896/// [`temporal_key`] is meaningful across `xsd:date`, `xsd:gYear` and
1897/// `xsd:gYearMonth` alike — the mix OpenCitations publishes on one
1898/// predicate, which the old per-datatype extent split three ways.
1899fn update_time_extent(pred: &str, value: &str, extent: &mut BTreeMap<String, (String, String)>) {
1900    match extent.get_mut(pred) {
1901        None => {
1902            extent.insert(pred.to_string(), (value.to_string(), value.to_string()));
1903        }
1904        Some((lo, hi)) => {
1905            if temporal_key(value) < temporal_key(lo) {
1906                *lo = value.to_string();
1907            }
1908            if temporal_key(value) > temporal_key(hi) {
1909                *hi = value.to_string();
1910            }
1911        }
1912    }
1913}
1914
1915// ===========================================================================
1916// Deriving a card from a `.rete` that already exists (`rete recard`)
1917// ===========================================================================
1918//
1919// The in-memory derivation above replays the statements as strings twice and
1920// keeps a map from every typed subject to its class, and a degree for every
1921// subject and every object: hundreds of bytes per statement, which is why
1922// `repyramid --card` tops out near 80 M statements. A finished `.rete` already
1923// holds the same statements **sorted six ways and deduplicated**, and sorted
1924// input is what turns every per-term tally into a running one:
1925//
1926//   * SPO groups each subject's statements together — its out-degree is known
1927//     the moment the subject changes, so the top-N hubs need a heap of N, not a
1928//     map of every subject; its namespace is read once per subject;
1929//   * OSP does the same for objects — in-degrees, and each literal object is
1930//     decoded once with the multiplicity of every predicate that uses it;
1931//   * the `rdf:type` range of POS gives every typed subject its class before
1932//     the SPO pass needs it.
1933//
1934// What remains per-term is the subject→class map behind `class_links`, kept as
1935// one small integer per subject id ([`ClassMap`], 1 byte on a graph with fewer
1936// than 255 classes). Every other map is keyed by vocabulary. On the 2.9 B-
1937// statement OpenCitations parts that is the difference between a 3 h rebuild
1938// and a re-card inside a 4 GiB container.
1939
1940/// How many statements a streamed card was derived from, and what the
1941/// per-subject class map cost — for the caller's progress report.
1942#[derive(Debug, Clone, Default)]
1943pub struct StreamReport {
1944    pub statements: u64,
1945    pub subjects: u64,
1946    pub literal_objects: u64,
1947    pub classes: u64,
1948    pub class_map_bytes: u64,
1949}
1950
1951/// One subject's class code, for every subject id: `0` = untyped, `1..` the
1952/// classes in order of first appearance. Starts one byte wide and widens the
1953/// first time a code does not fit, so the common case (tens of classes) costs
1954/// one byte per subject.
1955enum ClassMap {
1956    U8(Vec<u8>),
1957    U16(Vec<u16>),
1958    U32(Vec<u32>),
1959}
1960
1961impl ClassMap {
1962    fn new(subjects: u64) -> Self {
1963        // `vec![0; n]` is a zeroed allocation: pages that never get a class
1964        // are never touched, so an untyped region costs address space only.
1965        ClassMap::U8(vec![0; subjects as usize + 1])
1966    }
1967
1968    fn get(&self, id: u32) -> u32 {
1969        let i = id as usize;
1970        match self {
1971            ClassMap::U8(v) => v.get(i).map_or(0, |&c| c as u32),
1972            ClassMap::U16(v) => v.get(i).map_or(0, |&c| c as u32),
1973            ClassMap::U32(v) => v.get(i).copied().unwrap_or(0),
1974        }
1975    }
1976
1977    fn set(&mut self, id: u32, code: u32) {
1978        if code > u8::MAX as u32 {
1979            if let ClassMap::U8(v) = self {
1980                *self = ClassMap::U16(v.iter().map(|&c| c as u16).collect());
1981            }
1982        }
1983        if code > u16::MAX as u32 {
1984            if let ClassMap::U16(v) = self {
1985                *self = ClassMap::U32(v.iter().map(|&c| c as u32).collect());
1986            }
1987        }
1988        let i = id as usize;
1989        match self {
1990            ClassMap::U8(v) => {
1991                if let Some(slot) = v.get_mut(i) {
1992                    *slot = code as u8;
1993                }
1994            }
1995            ClassMap::U16(v) => {
1996                if let Some(slot) = v.get_mut(i) {
1997                    *slot = code as u16;
1998                }
1999            }
2000            ClassMap::U32(v) => {
2001                if let Some(slot) = v.get_mut(i) {
2002                    *slot = code;
2003                }
2004            }
2005        }
2006    }
2007
2008    fn bytes(&self) -> u64 {
2009        match self {
2010            ClassMap::U8(v) => v.len() as u64,
2011            ClassMap::U16(v) => v.len() as u64 * 2,
2012            ClassMap::U32(v) => v.len() as u64 * 4,
2013        }
2014    }
2015}
2016
2017/// The N terms with the highest counts, kept in a heap of N while a sorted
2018/// pass offers every term once. Ties at the cut go to the lower dictionary id,
2019/// which is the lower **term** within one dictionary section (each is sorted),
2020/// so one `TopN` per section, merged by term afterwards ([`top_terms`]), keeps
2021/// exactly the list the in-memory derivation's sort-then-cap keeps.
2022struct TopN {
2023    heap: std::collections::BinaryHeap<std::cmp::Reverse<(u64, std::cmp::Reverse<u32>)>>,
2024    offered: u64,
2025}
2026
2027impl TopN {
2028    fn new() -> Self {
2029        TopN {
2030            heap: std::collections::BinaryHeap::with_capacity(CARD_TOP_N + 1),
2031            offered: 0,
2032        }
2033    }
2034
2035    fn offer(&mut self, id: u32, n: u64) {
2036        use std::cmp::Reverse;
2037        self.offered += 1;
2038        let key = Reverse((n, Reverse(id)));
2039        if self.heap.len() < CARD_TOP_N {
2040            self.heap.push(key);
2041        } else if let Some(worst) = self.heap.peek() {
2042            // `worst` is the smallest (count, then highest id) kept so far.
2043            if (n, Reverse(id)) > worst.0 {
2044                self.heap.pop();
2045                self.heap.push(key);
2046            }
2047        }
2048    }
2049
2050    /// The kept `(id, count)` pairs, and whether anything was cut.
2051    fn into_parts(self) -> (Vec<(u32, u64)>, bool) {
2052        let cut = self.offered > CARD_TOP_N as u64;
2053        let v = self
2054            .heap
2055            .into_iter()
2056            .map(|std::cmp::Reverse((n, std::cmp::Reverse(id)))| (id, n))
2057            .collect();
2058        (v, cut)
2059    }
2060}
2061
2062/// Merge per-section [`TopN`]s into the card's list: resolve the survivors'
2063/// terms, order by count then term, cut at [`CARD_TOP_N`] — the in-memory
2064/// `cap(sort_desc_owned(…))`, over the only candidates that could reach it.
2065fn top_terms(
2066    tops: [TopN; 2],
2067    terms: &dyn Fn(&[u32]) -> Vec<Option<String>>,
2068) -> (Vec<(String, u64)>, bool) {
2069    let mut offered = 0u64;
2070    let mut ids: Vec<(u32, u64)> = Vec::new();
2071    for top in tops {
2072        offered += top.offered;
2073        ids.extend(top.into_parts().0);
2074    }
2075    let names = terms(&ids.iter().map(|&(id, _)| id).collect::<Vec<_>>());
2076    let mut v: Vec<(String, u64)> = ids
2077        .iter()
2078        .zip(names)
2079        .filter_map(|(&(_, n), t)| t.map(|t| (t, n)))
2080        .collect();
2081    v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
2082    v.truncate(CARD_TOP_N);
2083    (v, offered > CARD_TOP_N as u64)
2084}
2085
2086/// A fast, deterministic hasher for small integer keys — `class_links` is
2087/// tallied once per statement, and SipHash there is measurable at 2.9 B.
2088#[derive(Default, Clone, Copy)]
2089struct IdHasher(u64);
2090
2091impl std::hash::Hasher for IdHasher {
2092    fn finish(&self) -> u64 {
2093        self.0
2094    }
2095    fn write(&mut self, bytes: &[u8]) {
2096        for &b in bytes {
2097            self.0 = (self.0.rotate_left(5) ^ b as u64).wrapping_mul(0x517c_c1b7_2722_0a95);
2098        }
2099    }
2100    fn write_u32(&mut self, n: u32) {
2101        self.0 = (self.0.rotate_left(5) ^ n as u64).wrapping_mul(0x517c_c1b7_2722_0a95);
2102    }
2103}
2104
2105type IdBuild = std::hash::BuildHasherDefault<IdHasher>;
2106
2107/// The smallest id in `lo..=hi` whose term is `>= key`, or `hi + 1` — a binary
2108/// search over one dictionary section, which is sorted bytewise. `O(log n)`
2109/// term decodes; what finds the literal and quoted-triple ranges below.
2110fn first_at_or_above(lo: u32, hi: u32, key: &str, term: &dyn Fn(u32) -> Option<String>) -> u32 {
2111    let (mut a, mut b) = (lo as u64, hi as u64 + 1);
2112    while a < b {
2113        let mid = a + (b - a) / 2;
2114        match term(mid as u32) {
2115            Some(t) if t.as_str() < key => a = mid + 1,
2116            _ => b = mid,
2117        }
2118    }
2119    a as u32
2120}
2121
2122/// The ids in each section whose terms start with `prefix`'s first byte(s),
2123/// as half-open ranges in the role's id space — sorted sections make every
2124/// term kind a contiguous run (`"…` literals before `<<…` quoted triples
2125/// before `<…` IRIs before `_:…` blank nodes).
2126fn id_range(
2127    lo: u32,
2128    hi: u32,
2129    from: &str,
2130    to: &str,
2131    term: &dyn Fn(u32) -> Option<String>,
2132) -> (u32, u32) {
2133    if hi < lo {
2134        return (lo, lo);
2135    }
2136    (
2137        first_at_or_above(lo, hi, from, term),
2138        first_at_or_above(lo, hi, to, term),
2139    )
2140}
2141
2142fn in_any(id: u32, ranges: &[(u32, u32)]) -> bool {
2143    ranges.iter().any(|&(a, b)| a <= id && id < b)
2144}
2145
2146/// Derive a full card — profile, signals and starter queries — from a `.rete`
2147/// that already exists, **streaming** its sections in sorted order with memory
2148/// that does not grow with the statement count (see the section notes above).
2149///
2150/// The result is the card [`derive_card`] would derive from the same
2151/// statements, with two differences that come from reading a finished file
2152/// rather than its input, both documented in `docs/dataset-cards.md`:
2153///
2154/// * counts are over the **deduplicated** statements the file holds (the
2155///   in-memory card counts its input multiset, then corrects only the totals);
2156/// * a subject typed more than once is classified by the class with the
2157///   highest dictionary id ("last type wins" in index order, as
2158///   [`crate::schema_summary`] reads it), not by the last type in input order.
2159///
2160/// On a graph with no duplicates and no multi-typed subjects the two cards are
2161/// equal, which is what the tests hold it to. Named graphs are summarized by
2162/// their counts, as on every other path. `progress` receives one line per
2163/// phase.
2164pub fn derive_card_streaming(
2165    rete: &crate::Rete,
2166    curated: CardInput,
2167    progress: &mut dyn FnMut(&str),
2168) -> Result<(DatasetCard, StreamReport), String> {
2169    use crate::index::IndexPermutation;
2170
2171    let header = rete.header();
2172    let dict = rete.dictionary();
2173    let index = rete.default_index();
2174    let shared = dict.shared_count();
2175    let subjects_end = shared as u64 + dict.subject_only_count() as u64;
2176    let objects_end = shared as u64 + dict.object_only_count() as u64;
2177    let n_preds = dict.predicate_count() as usize;
2178    let pred_terms: Vec<String> = (0..=n_preds as u32)
2179        .map(|p| {
2180            if p == 0 {
2181                String::new()
2182            } else {
2183                dict.predicate_term(p).unwrap_or_default()
2184            }
2185        })
2186        .collect();
2187    let type_id = dict.predicate_id(RDF_TYPE);
2188
2189    // Term-kind ranges, found by binary search instead of decoding every term.
2190    let object_term = |id: u32| dict.object_term(id);
2191    let subject_term = |id: u32| dict.subject_term(id);
2192    let s_end = subjects_end.min(u32::MAX as u64) as u32;
2193    let o_end = objects_end.min(u32::MAX as u64) as u32;
2194    let literal_ranges = [
2195        id_range(1, shared, "\"", "#", &object_term),
2196        id_range(shared + 1, o_end, "\"", "#", &object_term),
2197    ];
2198    let is_literal = |o: u32| in_any(o, &literal_ranges);
2199    let quoted = header.has_quoted_triples();
2200    let (quoted_subjects, quoted_objects) = if quoted {
2201        (
2202            vec![
2203                id_range(1, shared, "<<", "<=", &subject_term),
2204                id_range(shared + 1, s_end, "<<", "<=", &subject_term),
2205            ],
2206            vec![
2207                id_range(1, shared, "<<", "<=", &object_term),
2208                id_range(shared + 1, o_end, "<<", "<=", &object_term),
2209            ],
2210        )
2211    } else {
2212        (Vec::new(), Vec::new())
2213    };
2214
2215    let mut report = StreamReport::default();
2216
2217    // --- Pass 1: the `rdf:type` range of POS → class counts and the class map.
2218    progress("pass 1/3: classes (the rdf:type range of POS)");
2219    let mut class_map = ClassMap::new(subjects_end);
2220    let mut class_code: std::collections::HashMap<u32, u32, IdBuild> = Default::default();
2221    let mut class_ids: Vec<u32> = vec![0];
2222    let mut class_counts: Vec<u64> = vec![0];
2223    if let Some(t) = type_id {
2224        for (s, _, o) in index.scan_iter((None, Some(t), None)) {
2225            let next = class_ids.len() as u32;
2226            let code = *class_code.entry(o).or_insert_with(|| {
2227                class_ids.push(o);
2228                class_counts.push(0);
2229                next
2230            });
2231            class_counts[code as usize] += 1;
2232            class_map.set(s, code);
2233        }
2234    }
2235    report.classes = class_ids.len() as u64 - 1;
2236    report.class_map_bytes = class_map.bytes();
2237
2238    // --- Pass 2: SPO → totals, out-degrees, namespaces, the quotient, RDF-star.
2239    progress("pass 2/3: subjects (SPO)");
2240    const LITERAL_CODE: u32 = u32::MAX;
2241    const WINDOW: usize = 1 << 16;
2242    let mut prof = Profile::default();
2243    let mut pred_counts = vec![0u64; n_preds + 1];
2244    let mut annotation_hits = vec![0u64; n_preds + 1];
2245    let mut quoting_hits = vec![0u64; n_preds + 1];
2246    let mut links: std::collections::HashMap<(u32, u32, u32), u64, IdBuild> = Default::default();
2247    // One per dictionary section: shared ids, then the role's own ids.
2248    let mut out_top = [TopN::new(), TopN::new()];
2249    let mut sample: Vec<(u32, u32)> = Vec::new();
2250    let mut window: Vec<(u32, u64)> = Vec::with_capacity(WINDOW);
2251    let mut subject_ns: BTreeMap<String, u64> = BTreeMap::new();
2252    let flush_ns = |window: &mut Vec<(u32, u64)>, subject_ns: &mut BTreeMap<String, u64>| {
2253        let ids: Vec<u32> = window.iter().map(|&(s, _)| s).collect();
2254        let terms = dict.subject_terms(&ids);
2255        // Sorted subjects mostly share a namespace with their neighbour, so
2256        // one map write per run of equal namespaces, not per subject.
2257        let mut run: Option<(String, u64)> = None;
2258        for (term, &(_, n)) in terms.iter().zip(window.iter()) {
2259            let Some(ns) = term.as_deref().and_then(split_namespace) else {
2260                continue;
2261            };
2262            match &mut run {
2263                Some((cur, total)) if *cur == ns => *total += n,
2264                _ => {
2265                    if let Some((cur, total)) = run.take() {
2266                        *subject_ns.entry(cur).or_default() += total;
2267                    }
2268                    run = Some((ns, n));
2269                }
2270            }
2271        }
2272        if let Some((cur, total)) = run {
2273            *subject_ns.entry(cur).or_default() += total;
2274        }
2275        window.clear();
2276    };
2277    let mut cur_s = 0u32;
2278    let mut cur_n = 0u64;
2279    // `--progress`: passes 2 and 3 read the default graph once each.
2280    let mut ticker = crate::build_progress::Ticker::new();
2281    let stored = index.for_each_in_order(IndexPermutation::Spo, &mut |s, p, o| {
2282        prof.triple_count += 1;
2283        ticker.tick(1);
2284        if let Some(c) = pred_counts.get_mut(p as usize) {
2285            *c += 1;
2286        }
2287        if s != cur_s {
2288            if cur_n > 0 {
2289                out_top[usize::from(cur_s > shared)].offer(cur_s, cur_n);
2290                window.push((cur_s, cur_n));
2291                if window.len() >= WINDOW {
2292                    flush_ns(&mut window, &mut subject_ns);
2293                }
2294            }
2295            if quoted && in_any(s, &quoted_subjects) && sample.len() < ANNOTATION_WITNESS_SAMPLE {
2296                sample.push((s, p));
2297            }
2298            cur_s = s;
2299            cur_n = 0;
2300        }
2301        cur_n += 1;
2302        if quoted {
2303            if in_any(s, &quoted_subjects) {
2304                if let Some(c) = annotation_hits.get_mut(p as usize) {
2305                    *c += 1;
2306                }
2307            }
2308            if in_any(o, &quoted_objects) {
2309                if let Some(c) = quoting_hits.get_mut(p as usize) {
2310                    *c += 1;
2311                }
2312            }
2313        }
2314        if Some(p) == type_id {
2315            return;
2316        }
2317        let sc = class_map.get(s);
2318        // Literal first: a writer may file a frequent literal in the shared
2319        // section (`--frequent-objects`), so a shared id is not a subject.
2320        let oc = if is_literal(o) {
2321            LITERAL_CODE
2322        } else if o <= shared {
2323            class_map.get(o)
2324        } else {
2325            0
2326        };
2327        *links.entry((sc, p, oc)).or_default() += 1;
2328    });
2329    if !stored {
2330        return Err("the file stores no SPO permutation".to_string());
2331    }
2332    if cur_n > 0 {
2333        out_top[usize::from(cur_s > shared)].offer(cur_s, cur_n);
2334        window.push((cur_s, cur_n));
2335    }
2336    if !window.is_empty() {
2337        flush_ns(&mut window, &mut subject_ns);
2338    }
2339    report.statements = prof.triple_count;
2340    report.subjects = out_top[0].offered + out_top[1].offered;
2341    drop(class_map);
2342
2343    // --- Pass 3: OSP → in-degrees and every literal object, decoded once.
2344    progress("pass 3/3: objects (OSP)");
2345    let mut in_top = [TopN::new(), TopN::new()];
2346    let mut lit_window: Vec<(u32, Vec<(u32, u64)>)> = Vec::with_capacity(WINDOW);
2347    let mut lit_objects = 0u64;
2348    let flush_lits = |lit_window: &mut Vec<(u32, Vec<(u32, u64)>)>, prof: &mut Profile| {
2349        let ids: Vec<u32> = lit_window.iter().map(|(o, _)| *o).collect();
2350        let terms = dict.object_terms(&ids);
2351        for (term, (_, preds)) in terms.iter().zip(lit_window.iter()) {
2352            let Some(lit) = term.as_deref().and_then(parse_literal) else {
2353                continue;
2354            };
2355            for &(p, n) in preds {
2356                if let Some(pt) = pred_terms.get(p as usize) {
2357                    prof.literal(pt, &lit, n);
2358                }
2359            }
2360        }
2361        lit_window.clear();
2362    };
2363    let mut cur_o = 0u32;
2364    let mut cur_n = 0u64;
2365    let mut cur_preds: Vec<(u32, u64)> = Vec::new();
2366    index.for_each_in_order(IndexPermutation::Osp, &mut |o, _s, p| {
2367        ticker.tick(1);
2368        if o != cur_o {
2369            if cur_n > 0 {
2370                if is_literal(cur_o) {
2371                    lit_objects += 1;
2372                    lit_window.push((cur_o, std::mem::take(&mut cur_preds)));
2373                    if lit_window.len() >= WINDOW {
2374                        flush_lits(&mut lit_window, &mut prof);
2375                    }
2376                } else {
2377                    in_top[usize::from(cur_o > shared)].offer(cur_o, cur_n);
2378                }
2379            }
2380            cur_o = o;
2381            cur_n = 0;
2382            cur_preds.clear();
2383        }
2384        cur_n += 1;
2385        if is_literal(o) {
2386            match cur_preds.iter_mut().find(|(q, _)| *q == p) {
2387                Some((_, n)) => *n += 1,
2388                None => cur_preds.push((p, 1)),
2389            }
2390        }
2391    });
2392    if cur_n > 0 {
2393        if is_literal(cur_o) {
2394            lit_objects += 1;
2395            lit_window.push((cur_o, std::mem::take(&mut cur_preds)));
2396        } else {
2397            in_top[usize::from(cur_o > shared)].offer(cur_o, cur_n);
2398        }
2399    }
2400    if !lit_window.is_empty() {
2401        flush_lits(&mut lit_window, &mut prof);
2402    }
2403    report.literal_objects = lit_objects;
2404
2405    if rete.index_incomplete() {
2406        return Err("a read failed part-way through the file; the card would be incomplete".into());
2407    }
2408
2409    // --- Resolve the handful of ids the profile keeps back to terms.
2410    progress("resolving the profile's terms");
2411    let class_terms: Vec<String> = class_ids
2412        .iter()
2413        .map(|&o| {
2414            if o == 0 {
2415                O_UNTYPED.to_string()
2416            } else {
2417                dict.object_term(o).unwrap_or_default()
2418            }
2419        })
2420        .collect();
2421    let class_name = |code: u32| -> String {
2422        if code == LITERAL_CODE {
2423            O_LITERAL.to_string()
2424        } else {
2425            class_terms
2426                .get(code as usize)
2427                .cloned()
2428                .unwrap_or_else(|| O_UNTYPED.to_string())
2429        }
2430    };
2431    for (p, &n) in pred_counts.iter().enumerate() {
2432        if n > 0 {
2433            prof.pred_counts.insert(pred_terms[p].clone(), n);
2434        }
2435    }
2436    for (code, &n) in class_counts.iter().enumerate().skip(1) {
2437        if n > 0 {
2438            *prof
2439                .class_counts
2440                .entry(class_terms[code].clone())
2441                .or_default() += n;
2442        }
2443    }
2444    for ((sc, p, oc), n) in links {
2445        let Some(pt) = pred_terms.get(p as usize) else {
2446            continue;
2447        };
2448        *prof
2449            .link_rows
2450            .entry((class_name(sc), pt.clone(), class_name(oc)))
2451            .or_default() += n;
2452    }
2453    for (p, &n) in annotation_hits.iter().enumerate() {
2454        if n > 0 {
2455            prof.annotation_hits.insert(pred_terms[p].clone(), n);
2456        }
2457    }
2458    for (p, &n) in quoting_hits.iter().enumerate() {
2459        if n > 0 {
2460            prof.quoting_hits.insert(pred_terms[p].clone(), n);
2461        }
2462    }
2463    prof.subject_ns = subject_ns;
2464    if pred_counts
2465        .iter()
2466        .enumerate()
2467        .any(|(p, &n)| n > 0 && (pred_terms[p] == GEO_ASWKT || pred_terms[p] == GEO_HASGEOMETRY))
2468    {
2469        prof.geo_wkt = true;
2470    }
2471
2472    // The RDF-star witness: a sampled annotated statement that the graph also
2473    // asserts — one point lookup each.
2474    let sample_ids: Vec<u32> = sample.iter().map(|&(s, _)| s).collect();
2475    for (term, &(_, about)) in dict.subject_terms(&sample_ids).iter().zip(sample.iter()) {
2476        let Some((a, qp, b)) = term.as_deref().and_then(crate::ingest::quoted_triple_parts) else {
2477            continue;
2478        };
2479        if let Some((sid, pid, oid)) = dict.encode(&a, &qp, &b) {
2480            if index
2481                .scan_iter((Some(sid), Some(pid), Some(oid)))
2482                .next()
2483                .is_some()
2484            {
2485                if let Some(about) = pred_terms.get(about as usize) {
2486                    *prof.witnessed_pairs.entry((qp, about.clone())).or_default() += 1;
2487                }
2488            }
2489        }
2490    }
2491
2492    let (top_hubs, cut_out) = top_terms(out_top, &|ids| dict.subject_terms(ids));
2493    let (in_hubs, cut_in) = top_terms(in_top, &|ids| dict.object_terms(ids));
2494
2495    let card = finish(
2496        prof,
2497        Hubs {
2498            top_hubs,
2499            in_hubs,
2500            truncated: cut_out || cut_in,
2501        },
2502        header.quad_count,
2503        header.term_count,
2504        rete.named_graph_count() as u64,
2505        curated,
2506    );
2507    Ok((card, report))
2508}
2509
2510/// The namespace of an IRI term: the prefix up to and including the last `#` or
2511/// `/`. Returns `None` for non-IRI terms (literals/bnodes) or IRIs with neither
2512/// delimiter. Best-effort — adequate for a descriptive vocabulary list.
2513fn split_namespace(term: &str) -> Option<String> {
2514    let iri = term.strip_prefix('<')?.strip_suffix('>')?;
2515    let cut = iri.rfind(['#', '/'])?;
2516    Some(iri[..=cut].to_string())
2517}
2518
2519/// Read the dataset card embedded in a `.rete` file image, or `None` if it has
2520/// no metadata section. Parses only the header and the metadata range — it
2521/// never decodes the dictionary, index or pyramid, so a client that just built
2522/// a file can read back the card it wrote for the price of a slice.
2523///
2524/// The ranged (remote) companion lives in `rete-cli`, which has the HTTP reader.
2525pub fn load_card(bytes: &[u8]) -> Result<Option<DatasetCard>, String> {
2526    let header = Header::from_bytes(bytes).map_err(|e| e.to_string())?;
2527    if header.metadata_len == 0 {
2528        return Ok(None);
2529    }
2530    let start = header.metadata_offset as usize;
2531    let end = start
2532        .checked_add(header.metadata_len as usize)
2533        .filter(|&e| e <= bytes.len())
2534        .ok_or_else(|| "metadata section out of bounds".to_string())?;
2535    Ok(Some(DatasetCard::from_json_bytes(&bytes[start..end])?))
2536}
2537
2538#[cfg(test)]
2539mod tests {
2540    use super::*;
2541    // The bag's bounds are the format's, enforced in `card_input`; the tests
2542    // below assert the derived half honours exactly those numbers.
2543    use crate::card_input::{CARD_EXTRA_MAX_BYTES, CARD_EXTRA_MAX_KEYS, CARD_EXTRA_MAX_KEY_BYTES};
2544
2545    const TYPE: &str = RDF_TYPE;
2546
2547    fn q(s: &str, p: &str, o: &str) -> (String, String, String, Option<String>) {
2548        (s.into(), p.into(), o.into(), None)
2549    }
2550
2551    #[test]
2552    fn card_json_round_trips() {
2553        let card = DatasetCard {
2554            title: Some("Demo".into()),
2555            license: Some("CC0".into()),
2556            triple_count: 3,
2557            quad_count: 3,
2558            term_count: 5,
2559            predicates: vec![("<http://ex/p>".into(), 2)],
2560            vocabularies: vec!["http://ex/".into()],
2561            format_version: 1,
2562            ..Default::default()
2563        };
2564        let bytes = card.to_json_bytes();
2565        let back = DatasetCard::from_json_bytes(&bytes).unwrap();
2566        assert_eq!(card, back);
2567        // Absent optionals/empties are omitted from the JSON.
2568        let text = String::from_utf8(bytes).unwrap();
2569        assert!(!text.contains("description"));
2570        assert!(!text.contains("classes"));
2571    }
2572
2573    /// `signals.permutations` is measured from the header's permutation mask at
2574    /// READ time. It must never reach the metadata section: a card that
2575    /// *claimed* a permutation set would be an authored statement about the
2576    /// file's own bytes — the one class of claim the file always answers for
2577    /// itself, for free, in the 1 KiB header every card read already fetched.
2578    #[test]
2579    fn the_permutation_signal_is_stripped_from_the_stored_card() {
2580        let measured = PermutationsSignal {
2581            count: 3,
2582            names: vec!["SPO".into(), "POS".into(), "OSP".into()],
2583            merge_join: false,
2584        };
2585        let mut card = DatasetCard {
2586            title: Some("Lean".into()),
2587            triple_count: 3,
2588            ..Default::default()
2589        };
2590        assert_eq!(card.observe_permutations(measured.clone()), None);
2591        assert_eq!(card.signals.permutations, Some(measured.clone()));
2592
2593        let bytes = card.to_json_bytes();
2594        let text = String::from_utf8(bytes.clone()).unwrap();
2595        assert!(
2596            !text.contains("permutations"),
2597            "the signal must not reach the metadata section: {text}"
2598        );
2599        let back = DatasetCard::from_json_bytes(&bytes).unwrap();
2600        assert_eq!(back.signals.permutations, None, "unknown, not `six`");
2601        assert_eq!(back.title, card.title);
2602
2603        // And a curated card document cannot author it: `CardInput` is
2604        // `deny_unknown_fields` and has no `signals` field at all.
2605        let authored = r#"{"title":"x","signals":{"permutations":{"count":6}}}"#;
2606        assert!(
2607            serde_json::from_str::<CardInput>(authored).is_err(),
2608            "a hand-written card must not be able to assert a permutation set"
2609        );
2610    }
2611
2612    /// The signal is derived from the header, so it answers for a file with no
2613    /// card at all — and says six for every file written before the mask
2614    /// existed, whose byte 50 is zero.
2615    #[test]
2616    fn the_permutation_signal_is_derived_from_the_header() {
2617        let quads = vec![
2618            q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>"),
2619            q("<http://ex/b>", "<http://ex/p>", "<http://ex/c>"),
2620        ];
2621        let (six, _) = crate::ingest::assemble_dataset_with_perms(
2622            quads.clone(),
2623            false,
2624            false,
2625            None,
2626            crate::PyramidAlgo::Louvain,
2627            crate::PermSet::ALL,
2628            |_, _| Vec::new(),
2629        );
2630        let (three, _) = crate::ingest::assemble_dataset_with_perms(
2631            quads,
2632            false,
2633            false,
2634            None,
2635            crate::PyramidAlgo::Louvain,
2636            crate::PermSet::CORE,
2637            |_, _| Vec::new(),
2638        );
2639        // Byte 50 is the mask; `0` is the canonical spelling of "all six", so a
2640        // default build stays byte-identical to every file written before it.
2641        assert_eq!(six[50], 0);
2642        assert_eq!(three[50], 0b0000_0111);
2643
2644        let h6 = crate::Header::from_bytes(&six).unwrap();
2645        let h3 = crate::Header::from_bytes(&three).unwrap();
2646        let s6 = PermutationsSignal::probe(&h6);
2647        let s3 = PermutationsSignal::probe(&h3);
2648        assert_eq!(s6.count, 6);
2649        assert!(s6.merge_join);
2650        assert_eq!(s3.count, 3);
2651        assert!(!s3.merge_join);
2652        assert_eq!(s3.names, vec!["SPO", "POS", "OSP"]);
2653        assert!(s3.describe().contains("no sort-merge join"));
2654        // The lean file is smaller, and it is the index that shrank.
2655        assert!(three.len() < six.len(), "{} vs {}", three.len(), six.len());
2656        assert_eq!(h3.dictionary_len, h6.dictionary_len);
2657        assert!(h3.root_dir_len < h6.root_dir_len);
2658    }
2659
2660    /// `signals.text_index` is measured from the file's sections at READ time,
2661    /// so the write path must drop it: a stored copy would be a claim that
2662    /// survives the sections it describes (a `repyramid --text-index` rewrites
2663    /// them), which is the class of drift #189 had to repair by hand.
2664    #[test]
2665    fn the_text_index_signal_is_stripped_from_the_stored_card() {
2666        // `causenet-full-typed.rete`'s real figures.
2667        let measured = TextIndexSignal {
2668            present: true,
2669            bytes: 1_879_287_762,
2670            token_table_bytes: Some(193_295_361),
2671        };
2672        let mut card = DatasetCard {
2673            title: Some("Indexed".into()),
2674            triple_count: 3,
2675            ..Default::default()
2676        };
2677        assert_eq!(card.observe_text_index(measured), None);
2678        assert_eq!(card.signals.text_index, Some(measured));
2679
2680        let bytes = card.to_json_bytes();
2681        let text = String::from_utf8(bytes.clone()).unwrap();
2682        assert!(
2683            !text.contains("text_index"),
2684            "the signal must not reach the metadata section: {text}"
2685        );
2686        // Everything else still round-trips, and the reader gets `None` =
2687        // "unknown" rather than a fabricated `false`.
2688        let back = DatasetCard::from_json_bytes(&bytes).unwrap();
2689        assert_eq!(back.title, card.title);
2690        assert_eq!(back.signals.text_index, None);
2691
2692        // Stripping is not mutation: the in-memory card keeps what was measured
2693        // so the SAME card can be rendered and serialized in either order.
2694        assert_eq!(card.signals.text_index, Some(measured));
2695    }
2696
2697    /// The signal survives the card's own JSON envelope — which is what
2698    /// `rete card --json`, the wasm reader and `card-audit`'s document input all
2699    /// speak — even though it never reaches the file. (Plain `serde` here, not
2700    /// the CLI's `card_json` wrapper: that only adds a `schemaVersion` key, and
2701    /// the serialization under test is this struct's.)
2702    #[test]
2703    fn the_text_index_signal_round_trips_through_a_card_document() {
2704        let mut card = DatasetCard {
2705            title: Some("Indexed".into()),
2706            ..Default::default()
2707        };
2708        card.observe_text_index(TextIndexSignal {
2709            present: true,
2710            bytes: 80,
2711            token_table_bytes: Some(68),
2712        });
2713        let doc = serde_json::to_vec(&card).unwrap();
2714        let back: DatasetCard = serde_json::from_slice(&doc).unwrap();
2715        assert_eq!(back.signals.text_index, card.signals.text_index);
2716
2717        // A measured negative serializes as `{"present": false}` — nothing else
2718        // is asserted alongside it, and it is NOT the same as the field's
2719        // absence, which means "nobody measured".
2720        let mut none = DatasetCard::default();
2721        none.observe_text_index(TextIndexSignal::default());
2722        let text = serde_json::to_string(&none).unwrap();
2723        assert!(text.contains(r#""text_index":{"present":false}"#), "{text}");
2724        let absent = serde_json::to_string(&DatasetCard::default()).unwrap();
2725        assert!(!absent.contains("text_index"), "{absent}");
2726    }
2727
2728    /// The whole point of deriving this from the header rather than storing it:
2729    /// it is an honest answer for a file built long before the signal existed.
2730    /// `FLAG_HAS_QUOTED_TRIPLES` has been written by every build since quoted
2731    /// triples were supported, so the 44 published datasets answer it today,
2732    /// with no re-card and no rebuild — and a file with none says `false`, not
2733    /// "unknown".
2734    #[test]
2735    fn the_quoted_triple_signal_is_derived_from_the_header() {
2736        let plain = vec![
2737            q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>"),
2738            q("<http://ex/b>", "<http://ex/p>", "<http://ex/c>"),
2739        ];
2740        let quoted = vec![
2741            q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>"),
2742            q(
2743                "<http://ex/claim>",
2744                "<http://ex/states>",
2745                "<<<http://ex/a> <http://ex/p> <http://ex/b>>>",
2746            ),
2747        ];
2748        let (no, _) = crate::ingest::assemble_dataset(plain, &[]);
2749        let (yes, _) = crate::ingest::assemble_dataset(quoted, &[]);
2750
2751        let hn = crate::Header::from_bytes(&no).unwrap();
2752        let hy = crate::Header::from_bytes(&yes).unwrap();
2753        assert!(!hn.has_quoted_triples());
2754        assert!(hy.has_quoted_triples());
2755
2756        let sn = QuotedTriplesSignal::probe(&hn);
2757        let sy = QuotedTriplesSignal::probe(&hy);
2758        assert!(!sn.present);
2759        assert!(sy.present);
2760
2761        // A file with none says nothing in the human view — most datasets have
2762        // none and a line per unused affordance is noise.
2763        assert_eq!(sn.describe(), None);
2764        // One that has them says which surfaces are on offer, and which is the
2765        // default, so the card answers "what will I get if I export this?"
2766        // without the reader consulting the CLI docs.
2767        let line = sy.describe().expect("a file with quoted triples says so");
2768        assert!(line.contains("<<( s p o )>>"), "{line}");
2769        assert!(line.contains(QUOTED_TRIPLE_SURFACE_RDF_STAR), "{line}");
2770        assert_eq!(
2771            sy.export_surfaces,
2772            vec![QUOTED_TRIPLE_SURFACE_RDF12, QUOTED_TRIPLE_SURFACE_RDF_STAR]
2773        );
2774        assert_eq!(
2775            sy.export_default.as_deref(),
2776            Some(QUOTED_TRIPLE_SURFACE_RDF12)
2777        );
2778        // Nothing to offer when there is nothing to export.
2779        assert!(sn.export_surfaces.is_empty());
2780        assert_eq!(sn.export_default, None);
2781    }
2782
2783    /// Measured at read time, never written — the same contract as
2784    /// `text_index` and `permutations`, and for the sharper reason: a stored
2785    /// copy would be a claim about the file's own header, which the header
2786    /// answers for free.
2787    #[test]
2788    fn the_quoted_triple_signal_is_stripped_from_the_stored_card() {
2789        let measured = QuotedTriplesSignal {
2790            present: true,
2791            export_surfaces: vec![
2792                QUOTED_TRIPLE_SURFACE_RDF12.to_string(),
2793                QUOTED_TRIPLE_SURFACE_RDF_STAR.to_string(),
2794            ],
2795            export_default: Some(QUOTED_TRIPLE_SURFACE_RDF12.to_string()),
2796        };
2797        let mut card = DatasetCard {
2798            title: Some("Annotated".into()),
2799            triple_count: 3,
2800            ..Default::default()
2801        };
2802        assert_eq!(card.observe_quoted_triples(measured.clone()), None);
2803        assert_eq!(card.signals.quoted_triples, Some(measured.clone()));
2804
2805        let bytes = card.to_json_bytes();
2806        let text = String::from_utf8(bytes.clone()).unwrap();
2807        assert!(
2808            !text.contains("quoted_triples"),
2809            "the signal must not reach the metadata section: {text}"
2810        );
2811        let back = DatasetCard::from_json_bytes(&bytes).unwrap();
2812        assert_eq!(back.title, card.title);
2813        assert_eq!(back.signals.quoted_triples, None);
2814        // Stripping is not mutation.
2815        assert_eq!(card.signals.quoted_triples, Some(measured));
2816
2817        // And a hand-written card file cannot author it: `CardInput` has no
2818        // `signals` field and rejects unknown keys.
2819        let authored = r#"{"title":"x","signals":{"quoted_triples":{"present":true}}}"#;
2820        assert!(
2821            serde_json::from_str::<CardInput>(authored).is_err(),
2822            "a hand-written card must not be able to assert a quoted-triple set"
2823        );
2824    }
2825
2826    /// It survives the card's own JSON envelope — what `rete card --json`, the
2827    /// wasm reader and `card-audit`'s document input all speak — even though it
2828    /// never reaches the file. A measured negative is `{"present":false}`,
2829    /// which is NOT the same as the field being absent.
2830    #[test]
2831    fn the_quoted_triple_signal_round_trips_through_a_card_document() {
2832        let mut card = DatasetCard {
2833            title: Some("Annotated".into()),
2834            ..Default::default()
2835        };
2836        card.observe_quoted_triples(QuotedTriplesSignal {
2837            present: true,
2838            export_surfaces: vec![QUOTED_TRIPLE_SURFACE_RDF12.to_string()],
2839            export_default: Some(QUOTED_TRIPLE_SURFACE_RDF12.to_string()),
2840        });
2841        let doc = serde_json::to_vec(&card).unwrap();
2842        let back: DatasetCard = serde_json::from_slice(&doc).unwrap();
2843        assert_eq!(back.signals.quoted_triples, card.signals.quoted_triples);
2844
2845        let mut none = DatasetCard::default();
2846        none.observe_quoted_triples(QuotedTriplesSignal {
2847            present: false,
2848            export_surfaces: Vec::new(),
2849            export_default: None,
2850        });
2851        let text = serde_json::to_string(&none).unwrap();
2852        assert!(
2853            text.contains(r#""quoted_triples":{"present":false}"#),
2854            "{text}"
2855        );
2856        let absent = serde_json::to_string(&DatasetCard::default()).unwrap();
2857        assert!(!absent.contains("quoted_triples"), "{absent}");
2858    }
2859    #[test]
2860    fn split_namespace_handles_hash_slash_and_neither() {
2861        assert_eq!(
2862            split_namespace("<http://xmlns.com/foaf/0.1/name>").as_deref(),
2863            Some("http://xmlns.com/foaf/0.1/")
2864        );
2865        assert_eq!(
2866            split_namespace("<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>").as_deref(),
2867            Some("http://www.w3.org/1999/02/22-rdf-syntax-ns#")
2868        );
2869        // No delimiter, a literal, and a bnode all yield None.
2870        assert_eq!(split_namespace("<urn:isbn:12345>"), None);
2871        assert_eq!(split_namespace("\"literal\""), None);
2872        assert_eq!(split_namespace("_:b0"), None);
2873    }
2874
2875    #[test]
2876    fn derive_counts_and_orders() {
2877        let quads = vec![
2878            q("<http://ex/a>", TYPE, "<http://ex/Person>"),
2879            q("<http://ex/b>", TYPE, "<http://ex/Person>"),
2880            q("<http://ex/c>", TYPE, "<http://ex/City>"),
2881            q("<http://ex/a>", "<http://ex/knows>", "<http://ex/b>"),
2882            q("<http://ex/a>", "<http://ex/knows>", "<http://ex/c>"),
2883            q("<http://ex/a>", "<http://ex/name>", "\"A\""),
2884        ];
2885        let card = derive_card(&quads, 9, 0, CardInput::default());
2886        assert_eq!(card.triple_count, 6);
2887        assert_eq!(card.quad_count, 6);
2888        assert_eq!(card.term_count, 9);
2889        // Predicates desc by count: knows(2) and rdf:type(3) and name(1).
2890        assert_eq!(card.predicates[0], (TYPE.to_string(), 3));
2891        assert!(card
2892            .predicates
2893            .contains(&("<http://ex/knows>".to_string(), 2)));
2894        // Classes from rdf:type objects, desc: Person(2) before City(1).
2895        assert_eq!(
2896            card.classes,
2897            vec![
2898                ("<http://ex/Person>".to_string(), 2),
2899                ("<http://ex/City>".to_string(), 1)
2900            ]
2901        );
2902        // Vocabularies: ex/ namespace + the rdf-syntax-ns# namespace.
2903        assert!(card.vocabularies.contains(&"http://ex/".to_string()));
2904        assert!(card
2905            .vocabularies
2906            .iter()
2907            .any(|v| v.ends_with("rdf-syntax-ns#")));
2908    }
2909
2910    #[test]
2911    fn named_graph_triples_excluded_from_default_stats() {
2912        let quads = vec![
2913            q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>"),
2914            (
2915                "<http://ex/c>".into(),
2916                "<http://ex/p>".into(),
2917                "<http://ex/d>".into(),
2918                Some("<http://ex/g1>".into()),
2919            ),
2920        ];
2921        let card = derive_card(&quads, 5, 1, CardInput::default());
2922        assert_eq!(card.triple_count, 1, "only the default-graph triple counts");
2923        assert_eq!(card.quad_count, 2);
2924        assert_eq!(card.named_graph_count, 1);
2925        assert_eq!(card.predicates[0].1, 1);
2926    }
2927
2928    /// A literal-bearing triple helper for the enriched-profile tests.
2929    const LABEL: &str = "<http://www.w3.org/2000/01/rdf-schema#label>";
2930    const SAMEAS: &str = "<http://www.w3.org/2002/07/owl#sameAs>";
2931    const XSD_INT: &str = "http://www.w3.org/2001/XMLSchema#integer";
2932    const XSD_GYEAR: &str = "http://www.w3.org/2001/XMLSchema#gYear";
2933
2934    fn enriched_fixture() -> Vec<(String, String, String, Option<String>)> {
2935        vec![
2936            q("<http://ex/a>", TYPE, "<http://ex/Person>"),
2937            q("<http://ex/b>", TYPE, "<http://ex/Person>"),
2938            q("<http://ex/c>", TYPE, "<http://ex/City>"),
2939            q("<http://ex/a>", LABEL, "\"Alice\"@en"),
2940            q("<http://ex/b>", LABEL, "\"Bob\"@en"),
2941            q(
2942                "<http://ex/a>",
2943                "<http://ex/age>",
2944                &format!("\"30\"^^<{XSD_INT}>"),
2945            ),
2946            q(
2947                "<http://ex/a>",
2948                "<http://ex/birthYear>",
2949                &format!("\"1850\"^^<{XSD_GYEAR}>"),
2950            ),
2951            q(
2952                "<http://ex/b>",
2953                "<http://ex/birthYear>",
2954                &format!("\"1875\"^^<{XSD_GYEAR}>"),
2955            ),
2956            q("<http://ex/a>", "<http://ex/knows>", "<http://ex/b>"),
2957            q("<http://ex/a>", "<http://ex/livesIn>", "<http://ex/c>"),
2958            q("<http://ex/a>", SAMEAS, "<http://other/a>"),
2959        ]
2960    }
2961
2962    #[test]
2963    fn enriched_profile_is_populated() {
2964        let quads = enriched_fixture();
2965        let card = derive_card(&quads, 12, 0, CardInput::default());
2966
2967        assert!(!card.datatypes.is_empty(), "datatypes derived");
2968        assert!(!card.languages.is_empty(), "languages derived");
2969        assert!(!card.class_links.is_empty(), "class_links derived");
2970        assert!(!card.top_hubs.is_empty(), "out-degree hubs derived");
2971        assert!(!card.in_hubs.is_empty(), "in-degree hubs derived");
2972
2973        // Effective schema: Person --knows--> Person, Person --livesIn--> City.
2974        assert!(card
2975            .class_links
2976            .iter()
2977            .any(|l| l.s_class == "<http://ex/Person>"
2978                && l.predicate == "<http://ex/knows>"
2979                && l.o_class == "<http://ex/Person>"
2980                && l.count == 1));
2981        assert!(card
2982            .class_links
2983            .iter()
2984            .any(|l| l.o_class == "<http://ex/City>"));
2985        // Untyped external object is the `(untyped)` sentinel.
2986        assert!(card.class_links.iter().any(|l| l.o_class == "(untyped)"));
2987
2988        // Datatypes: integer, gYear, and langString from the @en labels.
2989        assert!(card.datatypes.iter().any(|(d, _)| d.contains("integer")));
2990        assert!(card.datatypes.iter().any(|(d, _)| d.contains("gYear")));
2991        assert!(card.datatypes.iter().any(|(d, _)| d.contains("langString")));
2992        // Languages: "en" present, plus untagged "".
2993        assert!(card.languages.iter().any(|(l, _)| l == "en"));
2994
2995        // Signals.
2996        let s = &card.signals;
2997        assert_eq!(s.label_predicate.as_deref(), Some(LABEL));
2998        assert_eq!(s.default_lang.as_deref(), Some("en"));
2999        assert_eq!(s.base_iri.as_deref(), Some("http://ex/"));
3000        assert!(s.link_predicates.iter().any(|p| p.contains("sameAs")));
3001        assert!(s.time_predicates.iter().any(|p| p.contains("birthYear")));
3002        assert_eq!(
3003            s.temporal_extent,
3004            Some(("1850".to_string(), "1875".to_string())),
3005            "extent over the dominant time predicate"
3006        );
3007        // No geometry in this fixture.
3008        assert!(!s.geo_wkt && !s.geo_latlong);
3009        assert!(s.spatial_bbox.is_none());
3010    }
3011
3012    #[test]
3013    fn signals_detect_geometry() {
3014        let wkt = "http://www.opengis.net/ont/geosparql#wktLiteral";
3015        let quads = vec![
3016            q("<http://ex/p>", TYPE, "<http://ex/Place>"),
3017            q(
3018                "<http://ex/p>",
3019                "<http://www.w3.org/2003/01/geo/wgs84_pos#lat>",
3020                "\"41.9\"^^<http://www.w3.org/2001/XMLSchema#double>",
3021            ),
3022            q(
3023                "<http://ex/p>",
3024                "<http://www.w3.org/2003/01/geo/wgs84_pos#long>",
3025                "\"12.5\"^^<http://www.w3.org/2001/XMLSchema#double>",
3026            ),
3027            q(
3028                "<http://ex/p>",
3029                "<http://www.opengis.net/ont/geosparql#asWKT>",
3030                &format!("\"POINT(12.5 41.9)\"^^<{wkt}>"),
3031            ),
3032        ];
3033        let card = derive_card(&quads, 6, 0, CardInput::default());
3034        assert!(card.signals.geo_wkt, "asWKT/wktLiteral detected");
3035        assert!(card.signals.geo_latlong, "wgs84 lat+long detected");
3036        let bbox = card.signals.spatial_bbox.expect("bbox derived");
3037        assert_eq!(
3038            bbox,
3039            [12.5, 41.9, 12.5, 41.9],
3040            "[minLon,minLat,maxLon,maxLat]"
3041        );
3042    }
3043
3044    #[test]
3045    fn class_links_match_schema_summary() {
3046        use crate::{ingest, schema_summary, Rete};
3047        let quads = enriched_fixture();
3048        let card = derive_card(&quads, 12, 0, CardInput::default());
3049
3050        // Build the same data and compute the reference quotient from the index.
3051        let (bytes, _) =
3052            ingest::assemble_dataset_with_opts(quads, true, false, None, |_, _| Vec::new());
3053        let rete = Rete::open(&bytes).unwrap();
3054        let reference: BTreeSet<(String, String, String, u64)> = schema_summary(&rete)
3055            .into_iter()
3056            .map(|(s, p, o, c)| (s, p, o, c as u64))
3057            .collect();
3058
3059        let derived: BTreeSet<(String, String, String, u64)> = card
3060            .class_links
3061            .iter()
3062            .map(|l| {
3063                (
3064                    l.s_class.clone(),
3065                    l.predicate.clone(),
3066                    l.o_class.clone(),
3067                    l.count,
3068                )
3069            })
3070            .collect();
3071
3072        assert_eq!(
3073            derived, reference,
3074            "card class_links must equal schema_summary's quotient row-for-row"
3075        );
3076    }
3077
3078    /// The card must report the size of the FILE, not the size of the input.
3079    ///
3080    /// Regression for #128: the card was derived from the ingested statement
3081    /// count while the header records the count the index kept after dedup, so
3082    /// any dataset built from input containing duplicate statements — which is
3083    /// every harvest that pages with overlapping windows — published an inflated
3084    /// size. The two agree for duplicate-free input, which is why it went
3085    /// unnoticed. Here the input has duplicates in both the default graph and a
3086    /// named graph, so a card taken from the ingest count cannot pass.
3087    #[test]
3088    fn card_counts_are_the_deduplicated_counts() {
3089        use crate::ingest::{self, DeferredMetadata};
3090        use crate::Rete;
3091
3092        let g = |s: &str, p: &str, o: &str, graph: &str| {
3093            (
3094                s.to_string(),
3095                p.to_string(),
3096                o.to_string(),
3097                Some(graph.to_string()),
3098            )
3099        };
3100        let quads = vec![
3101            q("<http://ex/a>", "<http://ex/p>", "<http://ex/1>"),
3102            q("<http://ex/b>", "<http://ex/p>", "<http://ex/2>"),
3103            q("<http://ex/a>", "<http://ex/p>", "<http://ex/1>"), // duplicate
3104            g("<http://ex/n>", "<http://ex/q>", "\"v\"", "<http://ex/g>"),
3105            g("<http://ex/n>", "<http://ex/q>", "\"v\"", "<http://ex/g>"), // duplicate
3106        ];
3107        // 5 statements ingested; 3 unique (2 default + 1 named).
3108        let (bytes, stats) =
3109            ingest::assemble_dataset_with_opts(quads, false, false, None, |stats, quads| {
3110                let card = derive_card(
3111                    quads,
3112                    stats.terms as u64,
3113                    stats.named_graphs as u64,
3114                    CardInput::default(),
3115                );
3116                DeferredMetadata::new(move |counts| card.with_final_counts(counts).to_json_bytes())
3117            });
3118
3119        let header = crate::Header::from_bytes(&bytes).unwrap();
3120        let card = load_card(&bytes).unwrap().expect("card embedded");
3121        assert_eq!(header.quad_count, 3, "header records the deduped quads");
3122        assert_eq!(
3123            card.quad_count, header.quad_count,
3124            "card and header must not disagree about the size of the same file"
3125        );
3126        assert_eq!(card.triple_count, 2, "default-graph triples, deduped");
3127
3128        // And the index agrees with both.
3129        let rete = Rete::open(&bytes).unwrap();
3130        let default_triples = rete.dump(None).len() as u64;
3131        let named: u64 = rete
3132            .graph_names()
3133            .iter()
3134            .map(|name| rete.dump(Some(name)).len() as u64)
3135            .sum();
3136        assert_eq!(default_triples + named, card.quad_count);
3137
3138        // The returned stats describe the file too — they print the `wrote …`
3139        // line, which used to report the raw input size.
3140        assert_eq!(stats.statements, 3);
3141        assert_eq!(stats.default_triples, 2);
3142    }
3143
3144    #[test]
3145    fn derive_is_deterministic() {
3146        // The card folds into the content hash, so the same input must yield
3147        // byte-identical JSON across rebuilds (no map-iteration nondeterminism).
3148        let quads = enriched_fixture();
3149        let a = derive_card(&quads, 12, 0, CardInput::default()).to_json_bytes();
3150        let b = derive_card(&quads, 12, 0, CardInput::default()).to_json_bytes();
3151        assert_eq!(a, b, "enriched card derivation must be deterministic");
3152        assert!(!a.is_empty());
3153    }
3154
3155    #[test]
3156    fn old_plain_string_card_still_parses() {
3157        // A pre-enrichment card: example_queries as plain strings, none of the
3158        // new fields present. Serde `default`s must keep it deserializing.
3159        let json = r#"{
3160            "title":"Legacy",
3161            "triple_count":3,"quad_count":3,"named_graph_count":0,"term_count":5,
3162            "predicates":[["<http://ex/p>",2]],
3163            "example_queries":["SELECT * WHERE { ?s ?p ?o }"],
3164            "format_version":1
3165        }"#;
3166        let card = DatasetCard::from_json_bytes(json.as_bytes()).unwrap();
3167        assert_eq!(card.title.as_deref(), Some("Legacy"));
3168        assert_eq!(card.example_queries.len(), 1);
3169        // Every enriched field defaults to empty/false when absent from the JSON.
3170        assert!(card.datatypes.is_empty());
3171        assert!(card.languages.is_empty());
3172        assert!(card.class_links.is_empty());
3173        assert!(card.top_hubs.is_empty());
3174        assert!(card.in_hubs.is_empty());
3175        assert!(card.queries.is_empty());
3176        assert!(card.signals.is_empty());
3177        assert!(!card.truncated);
3178    }
3179
3180    /// New curated identity/provenance fields round-trip through a card-file
3181    /// JSON document and into the card, and absent ones are omitted from the
3182    /// stored bytes (size discipline).
3183    #[test]
3184    fn curated_identity_fields_round_trip() {
3185        let input: CardInput = serde_json::from_str(
3186            r#"{
3187                "title": "T",
3188                "version": "2026-08-04",
3189                "creators": [{"name":"Ada","orcid":"https://orcid.org/0000-0002-1825-0097"}],
3190                "publisher": {"name":"EPFL","ror":"https://ror.org/02s376052"},
3191                "canonical_url": "https://data.example.org/t.rete",
3192                "sparql_endpoint": "https://example.org/sparql",
3193                "source_date": "2026-07-01",
3194                "derived_from": ["https://example.org/dump.nt"],
3195                "doi": "https://doi.org/10.5281/zenodo.1",
3196                "cite_as": "Ada (2026). T.",
3197                "keywords": ["graphs", "citations"],
3198                "theme": ["http://publications.europa.eu/resource/authority/data-theme/TECH"]
3199            }"#,
3200        )
3201        .unwrap();
3202        let card = curated_counts_card(5, 9, input);
3203        let bytes = card.to_json_bytes();
3204        let back = DatasetCard::from_json_bytes(&bytes).unwrap();
3205        assert_eq!(back.version.as_deref(), Some("2026-08-04"));
3206        assert_eq!(back.creators[0].name, "Ada");
3207        assert_eq!(
3208            back.creators[0].orcid.as_deref(),
3209            Some("https://orcid.org/0000-0002-1825-0097")
3210        );
3211        assert_eq!(back.publisher.as_ref().unwrap().name, "EPFL");
3212        assert_eq!(
3213            back.canonical_url.as_deref(),
3214            Some("https://data.example.org/t.rete")
3215        );
3216        assert_eq!(back.derived_from, vec!["https://example.org/dump.nt"]);
3217        assert_eq!(
3218            back.doi.as_deref(),
3219            Some("https://doi.org/10.5281/zenodo.1")
3220        );
3221        // Round-trip preserves what was stored (normalization is
3222        // `load_curated`'s job, covered by its own test below).
3223        assert_eq!(back.keywords, vec!["graphs", "citations"]);
3224        assert_eq!(
3225            back.theme,
3226            vec!["http://publications.europa.eu/resource/authority/data-theme/TECH"]
3227        );
3228
3229        // A minimal card omits every absent identity field from its bytes.
3230        let plain = curated_counts_card(1, 2, CardInput::default()).to_json_bytes();
3231        let text = String::from_utf8(plain).unwrap();
3232        for absent in [
3233            "\"creators\"",
3234            "\"publisher\"",
3235            "\"canonical_url\"",
3236            "\"sparql_endpoint\"",
3237            "\"derived_from\"",
3238            "\"doi\"",
3239            "\"cite_as\"",
3240            "\"source_date\"",
3241            "\"version\"",
3242            "\"top_n\"",
3243            "\"keywords\"",
3244            "\"theme\"",
3245            "\"extra\"",
3246        ] {
3247            assert!(
3248                !text.contains(absent),
3249                "{absent} must be omitted when unset"
3250            );
3251        }
3252    }
3253
3254    /// The `extra` bag round-trips through the stored bytes, is omitted when
3255    /// empty, and a card written by an EXTERNAL writer (the Python client
3256    /// serializes the caller's dict verbatim, counts spliced in) surfaces its
3257    /// custom fields in this reader — the pass-through the client relies on.
3258    #[test]
3259    fn extra_round_trips_and_external_writer_cards_surface_it() {
3260        let input: CardInput = serde_json::from_str(
3261            r#"{"title":"T","extra":{"atlas:layer":84,"review":{"by":"dg","ok":true}}}"#,
3262        )
3263        .unwrap();
3264        let card = curated_counts_card(5, 9, input);
3265        let back = DatasetCard::from_json_bytes(&card.to_json_bytes()).unwrap();
3266        assert_eq!(back.extra, card.extra);
3267        assert_eq!(back.extra.get("atlas:layer"), Some(&serde_json::json!(84)));
3268
3269        // The exact JSON shape `clients/python` `card_bytes` writes: curated
3270        // fields + the count fields + format_version, `extra` included.
3271        let python_shaped = r#"{
3272            "title":"From Python","extra":{"pipeline":"nightly"},
3273            "triple_count":1,"quad_count":1,"named_graph_count":0,"term_count":3,
3274            "format_version":5
3275        }"#;
3276        let card = DatasetCard::from_json_bytes(python_shaped.as_bytes()).unwrap();
3277        assert_eq!(
3278            card.extra.get("pipeline"),
3279            Some(&serde_json::json!("nightly"))
3280        );
3281    }
3282
3283    /// The bounds bite exactly at the boundary — one byte / key / level over
3284    /// is a loud error, at the limit is accepted.
3285    #[test]
3286    fn extra_limits_bite_exactly_at_the_boundary() {
3287        use crate::card_input::normalize_extra;
3288        use serde_json::{json, Map, Value};
3289        // The gate takes serde_json's `Map` (BTree-backed, so it serializes in
3290        // the same key order the card's `BTreeMap` does).
3291        let bag = |v: Vec<(String, Value)>| -> Map<String, Value> { v.into_iter().collect() };
3292
3293        // Byte cap: `{"pad":"…"}` serializes to 10 + n bytes.
3294        let pad = |n: usize| bag(vec![("pad".into(), json!("x".repeat(n)))]);
3295        let at = pad(CARD_EXTRA_MAX_BYTES - 10);
3296        assert_eq!(serde_json::to_vec(&at).unwrap().len(), CARD_EXTRA_MAX_BYTES);
3297        normalize_extra(at).expect("exactly at the byte cap is accepted");
3298        let over = pad(CARD_EXTRA_MAX_BYTES - 9);
3299        assert_eq!(
3300            serde_json::to_vec(&over).unwrap().len(),
3301            CARD_EXTRA_MAX_BYTES + 1
3302        );
3303        let err = normalize_extra(over).unwrap_err().to_string();
3304        assert!(
3305            err.contains(&format!("{} bytes", CARD_EXTRA_MAX_BYTES + 1)),
3306            "the error states the actual size: {err}"
3307        );
3308
3309        // Key-count cap.
3310        let keys = |n: usize| {
3311            bag((0..n)
3312                .map(|i| (format!("k{i:03}"), json!(1)))
3313                .collect::<Vec<_>>())
3314        };
3315        normalize_extra(keys(CARD_EXTRA_MAX_KEYS)).expect("at the key cap");
3316        assert!(normalize_extra(keys(CARD_EXTRA_MAX_KEYS + 1)).is_err());
3317
3318        // Key-length cap, and the empty key.
3319        normalize_extra(bag(vec![("k".repeat(CARD_EXTRA_MAX_KEY_BYTES), json!(1))]))
3320            .expect("at the key-length cap");
3321        assert!(normalize_extra(bag(vec![(
3322            "k".repeat(CARD_EXTRA_MAX_KEY_BYTES + 1),
3323            json!(1)
3324        )]))
3325        .is_err());
3326        assert!(normalize_extra(bag(vec![(String::new(), json!(1))])).is_err());
3327
3328        // Depth cap: three container levels ok (a list of objects holding a
3329        // list), four rejected — records belong in Parquet companions, not
3330        // the card.
3331        normalize_extra(bag(vec![("d".into(), json!({"a": {"b": 1}}))])).expect("under the cap");
3332        normalize_extra(bag(vec![("d".into(), json!([{"w": ["x", "y"]}]))]))
3333            .expect("at the depth cap");
3334        assert!(normalize_extra(bag(vec![("d".into(), json!({"a": {"b": {"c": [1]}}}))])).is_err());
3335        assert!(normalize_extra(bag(vec![("d".into(), json!([[[[1]]]]))])).is_err());
3336
3337        // "@context" is reserved for a future author-supplied mapping.
3338        let err = normalize_extra(bag(vec![("@context".into(), json!({}))]))
3339            .unwrap_err()
3340            .to_string();
3341        assert!(err.contains("reserved"), "loud reservation: {err}");
3342    }
3343
3344    /// Two builds of identical input — the `extra` bag authored in different
3345    /// (semantically equal) key order — produce byte-identical images, hence
3346    /// equal blake3 content hashes. The bag is curated, so it sits INSIDE the
3347    /// hash; this reproducibility is what makes that placement safe.
3348    #[test]
3349    fn extra_fields_keep_the_content_hash_reproducible() {
3350        let build = |json: &str| {
3351            // The same normalization `rete build --card-file` applies on every
3352            // path — it is literally the call `load_curated` ends with.
3353            let input: CardInput = serde_json::from_str::<CardInput>(json)
3354                .unwrap()
3355                .normalize()
3356                .unwrap();
3357            let quads = enriched_fixture();
3358            let card = derive_card(&quads, 12, 0, input);
3359            let (image, _) =
3360                crate::ingest::assemble_dataset_with_opts(quads, true, false, None, |_, _| {
3361                    card.to_json_bytes()
3362                });
3363            image
3364        };
3365        let a = build(r#"{"title":"T","extra":{"b":{"y":2,"x":1},"a":[1,2]}}"#);
3366        let b = build(r#"{"title":"T","extra":{"a":[1,2],"b":{"x":1,"y":2}}}"#);
3367        assert_eq!(a, b, "reordered-input builds are byte-identical");
3368        let ha = Header::from_bytes(&a).unwrap().content_hash;
3369        assert_eq!(ha, Header::from_bytes(&b).unwrap().content_hash);
3370
3371        // …and the bag IS hashed: dropping it changes the content hash
3372        // (tamper-evident, not cosmetic).
3373        let plain = build(r#"{"title":"T"}"#);
3374        assert_ne!(ha, Header::from_bytes(&plain).unwrap().content_hash);
3375    }
3376
3377    /// A stray top-level key in a card file is a LOUD error naming the key —
3378    /// the enforcement that keeps the top level rete's namespace, so a future
3379    /// official field can never capture (or be shadowed by) a publisher's.
3380    #[test]
3381    fn top_level_custom_keys_are_rejected_not_dropped() {
3382        let err = serde_json::from_str::<CardInput>(r#"{"title":"T","my_field":1}"#)
3383            .unwrap_err()
3384            .to_string();
3385        assert!(err.contains("my_field"), "names the stray key: {err}");
3386
3387        // The same content is accepted once the key moves into the bag.
3388        let ok: CardInput =
3389            serde_json::from_str(r#"{"title":"T","extra":{"my_field":1}}"#).unwrap();
3390        assert_eq!(ok.extra.get("my_field"), Some(&serde_json::json!(1)));
3391    }
3392
3393    /// A card file may write `description` as an array of lines, because a
3394    /// Markdown description in a JSON string means hand-writing `\n` escapes.
3395    /// Both shapes have to land as the same single string — the card stores one
3396    /// string either way, so `rete card --json` feeds straight back in.
3397    #[test]
3398    fn description_reads_as_a_string_or_as_lines() {
3399        let from_lines: CardInput = serde_json::from_value(serde_json::json!({
3400            "description": ["## Contents", "", "- a", "- b"]
3401        }))
3402        .unwrap();
3403        assert_eq!(
3404            from_lines.description.as_deref(),
3405            Some("## Contents\n\n- a\n- b")
3406        );
3407        let from_string: CardInput =
3408            serde_json::from_str("{\"description\":\"## Contents\\n\\n- a\\n- b\"}").unwrap();
3409        assert_eq!(from_string.description, from_lines.description);
3410
3411        // An absent description stays absent (the field is `default`ed).
3412        let none: CardInput = serde_json::from_str(r#"{"title":"T"}"#).unwrap();
3413        assert!(none.description.is_none());
3414        let null: CardInput = serde_json::from_str(r#"{"description":null}"#).unwrap();
3415        assert!(null.description.is_none());
3416
3417        // The cap is the shared one, and it is reported by serde, not silently
3418        // truncated on the way in.
3419        let over = "x".repeat(crate::card::CARD_DESCRIPTION_MAX_BYTES + 1);
3420        let err = serde_json::from_str::<CardInput>(&format!(
3421            "{{\"description\":{}}}",
3422            serde_json::to_string(&over).unwrap()
3423        ))
3424        .unwrap_err()
3425        .to_string();
3426        assert!(err.contains("over the"), "{err}");
3427    }
3428
3429    /// The browser builder validates a card document against
3430    /// `crate::card::CURATED_CARD_FIELDS` — a list, not this struct, because
3431    /// wasm has no serde derive here. A list that drifted from the struct would
3432    /// let one writer accept what the other refuses, which is the whole failure
3433    /// this shared module exists to prevent. So pin them to each other in BOTH
3434    /// directions: every listed field must be accepted by `CardInput`, and every
3435    /// field `CardInput` accepts must be listed.
3436    #[test]
3437    fn curated_field_list_matches_the_deny_unknown_fields_struct() {
3438        for f in crate::card::CURATED_CARD_FIELDS {
3439            // Probe membership, not types: feed the key a `null` and require that
3440            // whatever serde complains about is a TYPE problem, never "unknown
3441            // field" — that is the only verdict this test is about.
3442            let doc = format!(r#"{{"{f}":null}}"#);
3443            if let Err(e) = serde_json::from_str::<CardInput>(&doc) {
3444                assert!(
3445                    !e.to_string().contains("unknown field"),
3446                    "CURATED_CARD_FIELDS lists `{f}`, but CardInput rejects it as unknown: {e}",
3447                );
3448            }
3449        }
3450        // The other direction: serde names the accepted set in its error, so a
3451        // field the struct gained without being listed shows up here.
3452        let err = serde_json::from_str::<CardInput>(r#"{"nope":1}"#)
3453            .unwrap_err()
3454            .to_string();
3455        for name in err
3456            .split("expected one of ")
3457            .nth(1)
3458            .expect("serde names the expected fields")
3459            // serde appends " at line L column C" — not a field name.
3460            .split(" at line ")
3461            .next()
3462            .unwrap()
3463            .split(", ")
3464            .map(|s| s.trim().trim_matches('`'))
3465            .filter(|s| !s.is_empty())
3466        {
3467            assert!(
3468                crate::card::CURATED_CARD_FIELDS.contains(&name),
3469                "CardInput accepts `{name}` but CURATED_CARD_FIELDS does not list it — \
3470                 the browser builder would reject a card the CLI accepts",
3471            );
3472        }
3473    }
3474
3475    /// The playground's in-browser builder writes a card with no access to
3476    /// this crate: curated fields validated by `rete_core::card`, plus the four
3477    /// counts its build measured. Whatever it writes has to be a card THIS
3478    /// reader accepts — otherwise `rete card` on a downloaded browser build
3479    /// fails, and the round trip the builder promises is broken. Pin the shape
3480    /// by deserializing exactly what `rete_wasm::build_with_card` composes.
3481    #[test]
3482    fn a_browser_written_card_deserializes_here() {
3483        let curated = crate::card::validate_curated_card(&serde_json::json!({
3484            "title": "Built in a browser",
3485            "keywords": ["b", "a"],
3486            "theme": ["http://publications.europa.eu/resource/authority/data-theme/GOVE"],
3487            "creators": [{"name": "Ada", "orcid": "https://orcid.org/0000-0002-1825-0097"}],
3488            "extra": {"internal_id": "DS-1"},
3489        }))
3490        .expect("valid curated document");
3491        let composed = crate::card::compose_curated_card(curated, 3, 4, 1, 9, 5);
3492        let text = serde_json::to_string(&composed).unwrap();
3493
3494        let card: DatasetCard = serde_json::from_str(&text).expect("CLI reads a browser card");
3495        assert_eq!(card.title.as_deref(), Some("Built in a browser"));
3496        assert_eq!(card.keywords, vec!["a".to_string(), "b".to_string()]);
3497        assert_eq!(card.creators.len(), 1);
3498        assert_eq!(card.triple_count, 3);
3499        assert_eq!(card.quad_count, 4);
3500        assert_eq!(card.term_count, 9);
3501        assert_eq!(card.format_version, 5);
3502        // The derived half is ABSENT, not zeroed — a browser build never
3503        // measured it and must not look as though it had.
3504        assert!(card.predicates.is_empty() && card.queries.is_empty());
3505        assert!(!text.contains("\"predicates\""), "{text}");
3506        assert!(!text.contains("\"top_n\""), "{text}");
3507    }
3508
3509    /// Forward-compat: an OLD reader's struct (the pre-#153 field set, no
3510    /// `deny_unknown_fields` — mirrored here) must still deserialize a card
3511    /// written by THIS build, new fields and all.
3512    #[test]
3513    fn old_reader_struct_accepts_new_card_json() {
3514        #[derive(serde::Deserialize)]
3515        struct OldCard {
3516            title: Option<String>,
3517            triple_count: u64,
3518            #[serde(default)]
3519            predicates: Vec<(String, u64)>,
3520            format_version: u8,
3521        }
3522        let input: CardInput = serde_json::from_str(
3523            r#"{"title":"New","version":"1","creators":[{"name":"A"}],
3524                "canonical_url":"https://x/y.rete","derived_from":["https://x/d.nt"],
3525                "extra":{"atlas:review":"approved"}}"#,
3526        )
3527        .unwrap();
3528        let quads = vec![q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>")];
3529        let card = derive_card(&quads, 3, 0, input);
3530        assert_eq!(card.top_n, CARD_TOP_N as u32);
3531        let bytes = card.to_json_bytes();
3532        let old: OldCard = serde_json::from_slice(&bytes).expect("old struct parses new JSON");
3533        assert_eq!(old.title.as_deref(), Some("New"));
3534        assert_eq!(old.triple_count, 1);
3535        assert_eq!(old.format_version, card.format_version);
3536        assert!(!old.predicates.is_empty());
3537    }
3538
3539    #[test]
3540    fn coherence_stamp_is_deterministic_and_additive() {
3541        let dis = "<http://www.w3.org/2002/07/owl#disjointWith>";
3542        let rt = "<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>";
3543        let base = vec![
3544            ("<http://ex/C>".into(), dis.into(), "<http://ex/D>".into()),
3545            ("<http://ex/x>".into(), rt.into(), "<http://ex/C>".into()),
3546            ("<http://ex/x>".into(), rt.into(), "<http://ex/D>".into()),
3547        ];
3548        let r = crate::reason(&base);
3549        let c1 = Coherence::from_reasoning(&r, false);
3550        // Deterministic + the right verdict (no free-text detail in the stamp).
3551        assert_eq!(c1, Coherence::from_reasoning(&r, false));
3552        assert!(!c1.coherent);
3553        assert_eq!(c1.inconsistency_count, 1);
3554        assert_eq!(c1.by_kind, vec![("disjoint-classes".to_string(), 1)]);
3555        assert_eq!(c1.rules, crate::REASON_RULESET);
3556
3557        // A stamped card round-trips; an unstamped one omits the block entirely.
3558        let json = DatasetCard::default()
3559            .with_coherence(&r, false)
3560            .to_json_bytes();
3561        let text = std::str::from_utf8(&json).unwrap();
3562        assert!(text.contains("coherence"));
3563        assert_eq!(DatasetCard::from_json_bytes(&json).unwrap().coherence, c1);
3564
3565        let plain = DatasetCard::default().to_json_bytes();
3566        assert!(!std::str::from_utf8(&plain).unwrap().contains("coherence"));
3567        assert!(DatasetCard::default().coherence.is_empty());
3568    }
3569
3570    #[test]
3571    fn base_iri_is_common_prefix_with_fallback() {
3572        // Subjects under several sibling namespaces → their common root.
3573        let mut ns = BTreeMap::new();
3574        ns.insert("http://ex/person/".to_string(), 2000u64);
3575        ns.insert("http://ex/place/".to_string(), 600);
3576        ns.insert("http://ex/org/".to_string(), 120);
3577        assert_eq!(derive_base_iri(&ns).as_deref(), Some("http://ex/"));
3578
3579        // A single namespace is its own base.
3580        let mut one = BTreeMap::new();
3581        one.insert("http://data.example.org/id/".to_string(), 10u64);
3582        assert_eq!(
3583            derive_base_iri(&one).as_deref(),
3584            Some("http://data.example.org/id/")
3585        );
3586
3587        // Subjects across unrelated hosts: the LCP collapses to `http://`, so we
3588        // fall back to the dominant namespace rather than ship a useless prefix.
3589        let mut multi = BTreeMap::new();
3590        multi.insert("http://a.example/".to_string(), 100u64);
3591        multi.insert("http://b.other/".to_string(), 5);
3592        assert_eq!(
3593            derive_base_iri(&multi).as_deref(),
3594            Some("http://a.example/")
3595        );
3596
3597        assert_eq!(derive_base_iri(&BTreeMap::new()), None);
3598    }
3599
3600    #[test]
3601    fn parse_literal_decomposes_forms() {
3602        let plain = parse_literal("\"hello\"").unwrap();
3603        assert_eq!(plain.value, "hello");
3604        assert!(plain.datatype.ends_with("XMLSchema#string"));
3605        assert_eq!(plain.lang, "");
3606
3607        let tagged = parse_literal("\"bonjour\"@fr").unwrap();
3608        assert_eq!(tagged.lang, "fr");
3609        assert!(tagged.datatype.ends_with("#langString"));
3610
3611        let typed = parse_literal("\"42\"^^<http://www.w3.org/2001/XMLSchema#integer>").unwrap();
3612        assert_eq!(typed.value, "42");
3613        assert_eq!(typed.datatype, "http://www.w3.org/2001/XMLSchema#integer");
3614
3615        // Escaped quote inside the value is not mistaken for the terminator.
3616        let esc = parse_literal("\"a\\\"b\"@en").unwrap();
3617        assert_eq!(esc.value, "a\\\"b");
3618        assert_eq!(esc.lang, "en");
3619
3620        // Non-literals decline.
3621        assert!(parse_literal("<http://ex/x>").is_none());
3622        assert!(parse_literal("_:b0").is_none());
3623    }
3624
3625    // ---------------------------------------------------------------------
3626    // D2: the temporal detector reads dates, not digits.
3627    // ---------------------------------------------------------------------
3628
3629    #[test]
3630    fn date_shapes_are_strict() {
3631        for ok in [
3632            "2019",
3633            "-0044",
3634            "2019-05",
3635            "2019-05-01",
3636            "2019-05-01Z",
3637            "2019-05-01+02:00",
3638            "2019-05-01T12:30",
3639            "2019-05-01T12:30:15",
3640            "2019-05-01T12:30:15.250Z",
3641            "1950Z",
3642        ] {
3643            assert!(date_shaped(ok), "{ok} is a date");
3644        }
3645        for bad in [
3646            "0000-0001-5001-3721", // an ORCID
3647            "9999880",             // a page number
3648            "1234-1240",           // a page range
3649            "123-456",
3650            "2019 annual report", // a title that starts with a year
3651            "2019-13",
3652            "2019-05-32",
3653            "2019-05-01T25:00",
3654            "10.1000/182", // a DOI
3655            "",
3656            "-",
3657            "2019-",
3658        ] {
3659            assert!(!date_shaped(bad), "{bad} is not a date");
3660        }
3661    }
3662
3663    /// OpenCitations-shaped literals: identifiers through
3664    /// `literal:hasLiteralValue`, pages as strings, titles that start with a
3665    /// year, and publication dates in three precisions on one predicate. Only
3666    /// the dates may count — and their extent is calendar-ordered across the
3667    /// three datatypes.
3668    #[test]
3669    fn temporal_extent_ignores_identifiers_pages_and_titles() {
3670        let value = "<http://www.essepuntato.it/2010/06/literalreification/hasLiteralValue>";
3671        let start = "<http://prismstandard.org/namespaces/basic/2.0/startingPage>";
3672        let end = "<http://prismstandard.org/namespaces/basic/2.0/endingPage>";
3673        let title = "<http://purl.org/dc/terms/title>";
3674        let pubdate = "<http://prismstandard.org/namespaces/basic/2.0/publicationDate>";
3675        let issued = "<http://purl.org/dc/terms/issued>";
3676        let xsd = |t: &str| format!("http://www.w3.org/2001/XMLSchema#{t}");
3677        let mut quads = Vec::new();
3678        for (i, id) in [
3679            "0000-0001-5001-3721",
3680            "0000-0002-1825-0097",
3681            "10.1000/182",
3682            "9999880",
3683            "2019",
3684        ]
3685        .iter()
3686        .enumerate()
3687        {
3688            quads.push(q(
3689                &format!("<http://ex/id/{i}>"),
3690                value,
3691                &format!("\"{id}\""),
3692            ));
3693        }
3694        quads.push(q("<http://ex/re/1>", start, "\"9999880\""));
3695        quads.push(q("<http://ex/re/1>", end, "\"9999899\""));
3696        quads.push(q("<http://ex/re/2>", start, "\"1234-1240\""));
3697        quads.push(q("<http://ex/br/1>", title, "\"2019 annual report\""));
3698        quads.push(q(
3699            "<http://ex/br/1>",
3700            pubdate,
3701            &format!("\"2019-05-01\"^^<{}>", xsd("date")),
3702        ));
3703        quads.push(q(
3704            "<http://ex/br/2>",
3705            pubdate,
3706            &format!("\"1950\"^^<{}>", xsd("gYear")),
3707        ));
3708        quads.push(q(
3709            "<http://ex/br/3>",
3710            pubdate,
3711            &format!("\"2021-03\"^^<{}>", xsd("gYearMonth")),
3712        ));
3713        // A malformed typed date is no evidence.
3714        quads.push(q(
3715            "<http://ex/br/4>",
3716            pubdate,
3717            &format!("\"0000-0001-5001-3721\"^^<{}>", xsd("date")),
3718        ));
3719        // A plain-string date on a known date predicate counts.
3720        quads.push(q("<http://ex/br/5>", issued, "\"1999-12\""));
3721        let card = derive_card(&quads, 40, 0, CardInput::default());
3722        let s = &card.signals;
3723        assert_eq!(
3724            s.time_predicates,
3725            vec![pubdate.to_string(), issued.to_string()],
3726            "only the date predicates are time predicates"
3727        );
3728        assert_eq!(
3729            s.temporal_extent,
3730            Some(("1950".to_string(), "2021-03".to_string())),
3731            "calendar order across xsd:gYear, xsd:date and xsd:gYearMonth"
3732        );
3733        // Pages and identifiers are strings, not numbers either.
3734        assert!(
3735            s.numeric_predicates.is_empty(),
3736            "{:?}",
3737            s.numeric_predicates
3738        );
3739        // And no time query is written against them.
3740        assert!(card
3741            .queries
3742            .iter()
3743            .filter(|q| q.dimension == "time")
3744            .all(|q| q.sparql.contains("publicationDate")));
3745    }
3746
3747    #[test]
3748    fn negative_years_order_by_value_not_by_bytes() {
3749        let mut extent = BTreeMap::new();
3750        for v in ["-0044", "-0100", "0001", "-0044-03-15"] {
3751            update_time_extent("<p>", v, &mut extent);
3752        }
3753        assert_eq!(
3754            extent.get("<p>"),
3755            Some(&("-0100".to_string(), "0001".to_string()))
3756        );
3757    }
3758
3759    /// A year typed as an integer on a predicate nobody declared temporal is a
3760    /// number — the value-range family takes it, the time family does not.
3761    #[test]
3762    fn an_integer_year_off_a_date_predicate_is_numeric() {
3763        let int = "http://www.w3.org/2001/XMLSchema#integer";
3764        let quads = vec![
3765            q(
3766                "<http://ex/a>",
3767                "<http://ex/year>",
3768                &format!("\"1492\"^^<{int}>"),
3769            ),
3770            q(
3771                "<http://ex/b>",
3772                "<http://ex/year>",
3773                &format!("\"1500\"^^<{int}>"),
3774            ),
3775        ];
3776        let card = derive_card(&quads, 6, 0, CardInput::default());
3777        assert!(card.signals.time_predicates.is_empty());
3778        assert_eq!(
3779            card.signals.numeric_predicates,
3780            vec!["<http://ex/year>".to_string()]
3781        );
3782    }
3783
3784    // ---------------------------------------------------------------------
3785    // B3: deriving the card from the finished file, streaming.
3786    // ---------------------------------------------------------------------
3787
3788    /// A fixture that exercises every tally the profile keeps: types, labels
3789    /// in two languages, numbers, dates, geometry, an alignment link, an
3790    /// undescribed IRI, statements about statements (asserted and not, subject
3791    /// and object position), and a named graph.
3792    fn streaming_fixture() -> Vec<(String, String, String, Option<String>)> {
3793        let xsd = |t: &str| format!("http://www.w3.org/2001/XMLSchema#{t}");
3794        let mut v = enriched_fixture();
3795        for i in 0..5 {
3796            let s = format!("<http://ex/place/{i}>");
3797            v.push(q(&s, TYPE, "<http://ex/Place>"));
3798            v.push(q(&s, LABEL, &format!("\"Lieu {i}\"@fr")));
3799            v.push(q(
3800                &s,
3801                "<http://www.w3.org/2003/01/geo/wgs84_pos#lat>",
3802                &format!("\"4{i}.5\"^^<{}>", xsd("double")),
3803            ));
3804            v.push(q(
3805                &s,
3806                "<http://www.w3.org/2003/01/geo/wgs84_pos#long>",
3807                &format!("\"{i}.25\"^^<{}>", xsd("double")),
3808            ));
3809            v.push(q(
3810                &s,
3811                "<http://www.opengis.net/ont/geosparql#asWKT>",
3812                &format!(
3813                    "\"POINT({i}.25 4{i}.5)\"^^<http://www.opengis.net/ont/geosparql#wktLiteral>"
3814                ),
3815            ));
3816            v.push(q(
3817                &s,
3818                "<http://purl.org/dc/terms/created>",
3819                &format!("\"20{i}0-01-0{}\"^^<{}>", i + 1, xsd("date")),
3820            ));
3821            v.push(q(&s, "<http://ex/near>", "<http://elsewhere/undescribed>"));
3822        }
3823        // RDF-star: two asserted, annotated statements, one annotated but not
3824        // asserted, and one held as a value.
3825        let knows = "<http://ex/a> <http://ex/knows> <http://ex/b>";
3826        let lives = "<http://ex/a> <http://ex/livesIn> <http://ex/c>";
3827        let ghost = "<http://ex/b> <http://ex/knows> <http://ex/c>";
3828        for (t, who) in [(knows, "alice"), (lives, "bob"), (ghost, "carol")] {
3829            v.push(q(
3830                &format!("<<{t}>>"),
3831                "<http://ex/recordedBy>",
3832                &format!("<http://ex/{who}>"),
3833            ));
3834        }
3835        v.push(q(
3836            "<http://ex/claim/1>",
3837            "<http://ex/states>",
3838            &format!("<<{knows}>>"),
3839        ));
3840        v.push((
3841            "<http://ex/note/1>".into(),
3842            "<http://ex/about>".into(),
3843            "<http://ex/a>".into(),
3844            Some("<http://ex/graph/notes>".into()),
3845        ));
3846        v
3847    }
3848
3849    /// Build the fixture, then derive its card both ways: `(streamed,
3850    /// in_memory)`.
3851    fn both_ways(quads: &[(String, String, String, Option<String>)]) -> (DatasetCard, DatasetCard) {
3852        let (bytes, _) =
3853            crate::ingest::assemble_dataset_with_opts(quads.to_vec(), true, false, None, |_, _| {
3854                Vec::new()
3855            });
3856        let rete = crate::Rete::open(&bytes).unwrap();
3857        let term_count = rete.header().term_count;
3858        let (card, report) =
3859            derive_card_streaming(&rete, CardInput::default(), &mut |_| {}).unwrap();
3860        assert_eq!(report.statements, card.triple_count);
3861        let in_memory = derive_card(
3862            quads,
3863            term_count,
3864            rete.named_graph_count() as u64,
3865            CardInput::default(),
3866        );
3867        (card, in_memory)
3868    }
3869
3870    /// On a graph with no duplicate statements and no multi-typed subject, the
3871    /// card derived by streaming the finished file is the card derived from the
3872    /// quads — byte for byte, starter queries included.
3873    #[test]
3874    fn streaming_derivation_matches_the_in_memory_card() {
3875        for quads in [enriched_fixture(), streaming_fixture()] {
3876            let (streamed, in_memory) = both_ways(&quads);
3877            assert_eq!(
3878                String::from_utf8(streamed.to_json_bytes()).unwrap(),
3879                String::from_utf8(in_memory.to_json_bytes()).unwrap()
3880            );
3881        }
3882        // The fixture really exercised the RDF-star and geometry halves.
3883        let (card, _) = both_ways(&streaming_fixture());
3884        assert!(card.signals.annotated_statement.is_some());
3885        assert!(!card.signals.quoting_predicates.is_empty());
3886        assert!(card.signals.spatial_bbox.is_some());
3887        assert!(card.signals.temporal_extent.is_some());
3888        assert_eq!(card.named_graph_count, 1);
3889    }
3890
3891    /// More than 255 classes: the per-subject class map widens from one byte
3892    /// to two without losing a class, and the capped lists still agree.
3893    #[test]
3894    fn streaming_derivation_widens_the_class_map() {
3895        let mut quads = Vec::new();
3896        for i in 0..300 {
3897            let s = format!("<http://ex/thing/{i:03}>");
3898            quads.push(q(&s, TYPE, &format!("<http://ex/Class{i:03}>")));
3899            quads.push(q(
3900                &s,
3901                "<http://ex/next>",
3902                &format!("<http://ex/thing/{:03}>", (i + 1) % 300),
3903            ));
3904        }
3905        let (streamed, in_memory) = both_ways(&quads);
3906        assert!(streamed.truncated);
3907        assert_eq!(streamed.classes, in_memory.classes);
3908        assert_eq!(streamed.class_links, in_memory.class_links);
3909        assert_eq!(
3910            String::from_utf8(streamed.to_json_bytes()).unwrap(),
3911            String::from_utf8(in_memory.to_json_bytes()).unwrap()
3912        );
3913    }
3914
3915    /// Duplicates are where the two paths are allowed to differ: the streamed
3916    /// card counts the statements the file holds, the in-memory card its input.
3917    #[test]
3918    fn streaming_derivation_counts_the_deduplicated_file() {
3919        let mut quads = enriched_fixture();
3920        quads.push(quads[0].clone());
3921        let (streamed, in_memory) = both_ways(&quads);
3922        assert_eq!(streamed.triple_count, in_memory.triple_count - 1);
3923        let type_count = |c: &DatasetCard| {
3924            c.predicates
3925                .iter()
3926                .find(|(p, _)| p == TYPE)
3927                .map(|(_, n)| *n)
3928        };
3929        assert_eq!(type_count(&streamed), type_count(&in_memory).map(|n| n - 1));
3930    }
3931
3932    /// `--keep-curated` is the inverse of `fill`: every curated field of a card
3933    /// comes back out verbatim — and only those, never a derived one.
3934    #[test]
3935    fn the_curated_half_of_a_card_round_trips() {
3936        let doc = r##"{"title":"T","description":["# Heading","line"],"license":"CC0-1.0",
3937            "source":"https://ex/src","created":"2026-01-01","version":"1",
3938            "creators":[{"name":"Ada","orcid":"https://orcid.org/0000-0002-1825-0097"}],
3939            "publisher":{"name":"P","ror":"https://ror.org/01ggx4157"},
3940            "canonical_url":"https://ex/f.rete","sparql_endpoint":"https://ex/sparql",
3941            "source_date":"2025-12-31","derived_from":["https://ex/dump"],
3942            "doi":"https://doi.org/10.1/x","cite_as":"Cite me","keywords":["b","a"],
3943            "theme":["http://publications.europa.eu/resource/authority/data-theme/EDUC"],
3944            "extra":{"k":[{"words":["x"]}]},"example_queries":["ASK {}"]}"##;
3945        let curated = CardInput::from_json_str(doc).unwrap();
3946        assert!(!curated.is_empty());
3947        let card = derive_card(&enriched_fixture(), 12, 0, curated);
3948        let again = derive_card(&enriched_fixture(), 12, 0, CardInput::from_card(&card));
3949        assert_eq!(again.to_json_bytes(), card.to_json_bytes());
3950        assert!(CardInput::from_card(&derive_card(
3951            &enriched_fixture(),
3952            12,
3953            0,
3954            CardInput::default()
3955        ))
3956        .is_empty());
3957    }
3958}