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