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