Skip to main content

rete_core/
card_queries.rs

1//! The standardized exploration query library (Design B of `dev/pyramid-dev.md`).
2//!
3//! A vetted set of starter SPARQL queries, **auto-instantiated at build** with a
4//! dataset's real vocabulary (`{{TOP_CLASS}}`/`{{TOP_PRED}}`/`{{LABEL_PRED}}`/…)
5//! drawn from the enriched [`DatasetCard`] profile, and emitted **only when the
6//! required signal is present** (no geo query without geometry, no time query
7//! without a time predicate) so the shipped set always returns rows. Every query
8//! carries its own `PREFIX` block (the engine injects none) and is tagged with
9//! the cheapest [`Tier`] that can answer it.
10//!
11//! Bodies are **graph-scope aware** (`GraphScope`): a dataset whose statements
12//! live entirely in named graphs (`triple_count == 0`, `named_graph_count > 0`)
13//! gets `GRAPH ?g`-scoped bodies — a bare `{ ?s ?p ?o }` there can only ever
14//! return zero rows — and a dataset with data in **both** the default graph and
15//! named graphs gets `UNION`-scoped overview bodies so neither half is silently
16//! hidden.
17//!
18//! # Why a generated query may not return zero rows
19//!
20//! A starter query that answers nothing is worse than no starter query: the
21//! reader concludes the **file** is broken. So every template carries a
22//! `NonEmpty` claim — the reason its emitted form cannot come back empty —
23//! and `check_body` enforces the one rule that claim rests on:
24//!
25//! > A body may conjoin substituted vocabulary only when the card **proves the
26//! > pieces co-occur**.
27//!
28//! Presence is not co-occurrence. `{{TOP_CLASS}}` is the class with the most
29//! instances and `{{LABEL_PRED}}` the most-used labelling predicate; each is
30//! certainly *present*, and instances of that class need never carry that
31//! predicate. Conjoining them — which `lb-labels` did — is a guaranteed-empty
32//! query whenever the two peaks fall on different parts of the graph, as they
33//! do on `mtg` (top class `mtg:Ruling`, which has no `schema:name`) and on
34//! `hugging-face` (top class `hf:Model`; `rdfs:label` appears only on the
35//! embedded TBox terms). The fix is not to check harder downstream but to make
36//! the conjunction unrepresentable: capabilities that appear together in a body
37//! must be **jointly derived** (`Cap::joint_with`) — chosen *because* they
38//! meet, with a `class_links` row as the witness.
39//!
40//! Where the card genuinely cannot decide emptiness, the template says so
41//! (`NonEmpty::Undecidable`) instead of pretending; `provably_empty` is the
42//! last gate, dropping a template the card can prove would answer nothing.
43//!
44//! This is pure serde generation — no format change, no I/O, no threads. It
45//! lives in `rete-core` (it used to live in the binary-only `rete-cli`, where no
46//! client could reach it — #152) so the CLI, the browser builder and every
47//! language binding instantiate one query library from one table.
48
49use serde::Serialize;
50
51use crate::card_derive::{
52    DatasetCard, ExampleQuery, Tier, CARD_TOP_N, GEO_ASWKT, GEO_HASGEOMETRY, O_LITERAL,
53};
54use crate::RDF_TYPE;
55
56/// Prepended to every generated query — the engine parses with **zero** implicit
57/// prefixes (`Query::parse(q, None)`), so an undeclared prefix is a parse error.
58const PREFIXES: &str = "\
59PREFIX rdf:  <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
60PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
61PREFIX owl:  <http://www.w3.org/2002/07/owl#>
62PREFIX void: <http://rdfs.org/ns/void#>
63PREFIX dct:  <http://purl.org/dc/terms/>
64PREFIX skos: <http://www.w3.org/2004/02/skos/core#>
65PREFIX geo:  <http://www.opengis.net/ont/geosparql#>
66PREFIX geof: <http://www.opengis.net/def/function/geosparql/>
67PREFIX wgs:  <http://www.w3.org/2003/01/geo/wgs84_pos#>
68PREFIX time: <http://www.w3.org/2006/time#>
69PREFIX xsd:  <http://www.w3.org/2001/XMLSchema#>";
70
71/// A capability the dataset must offer for a template to be emitted (and, for the
72/// string-valued ones, the vocabulary substituted into its placeholder).
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74enum Cap {
75    TopClass,
76    TopPred,
77    /// The most populous class the card can **prove** carries [`Cap::LabelPred`].
78    LabeledClass,
79    LabelPred,
80    /// The most frequent predicate the card can **prove** has a non-literal
81    /// object — the only kind a path query can walk past one hop.
82    ObjectPred,
83    TimePred,
84    NumPred,
85    BaseIri,
86    /// An IRI the card records that lies **outside** [`Cap::BaseIri`] — the
87    /// witness that "links pointing out of this dataset" has something to find.
88    ExternalIri,
89    GeoLatLong,
90    /// The path from a subject to its WKT geometry, as the data actually shapes
91    /// it (`geo:asWKT` direct, or through `geo:hasGeometry`).
92    WktPath,
93    /// The query box for the geometry template.
94    Bbox,
95    NamedGraphs,
96    Link,
97    HasLiterals,
98    /// At least one statement in this graph is **about a statement** — its
99    /// subject is a quoted triple. The gate for the bodies that write
100    /// `<< ?s ?p ?o >>` in subject position with nothing substituted into it.
101    AnnotatedStatements,
102    /// The most frequent predicate that annotates a statement — the outer
103    /// predicate of `<< s p o >> :recordedBy :who`.
104    AnnotationPred,
105    /// The predicate **inside** a quoted triple that [`Cap::AnnotationPred`]
106    /// was seen annotating. Jointly derived with it (`annotated_statement` is
107    /// the witness), so a body may pin both.
108    AnnotatedPred,
109    /// The most frequent predicate whose **object** is a quoted triple —
110    /// `:claim :states << s p o >>`, the other position a triple term stands
111    /// in, and a different query from the annotation one.
112    QuotingPred,
113}
114
115/// Every capability, in declaration order — the substitution table and the
116/// placeholder audit both walk it, so a new `Cap` cannot be half-wired.
117const ALL_CAPS: &[Cap] = &[
118    Cap::TopClass,
119    Cap::TopPred,
120    Cap::LabeledClass,
121    Cap::LabelPred,
122    Cap::ObjectPred,
123    Cap::TimePred,
124    Cap::NumPred,
125    Cap::BaseIri,
126    Cap::ExternalIri,
127    Cap::GeoLatLong,
128    Cap::WktPath,
129    Cap::Bbox,
130    Cap::NamedGraphs,
131    Cap::Link,
132    Cap::HasLiterals,
133    Cap::AnnotatedStatements,
134    Cap::AnnotationPred,
135    Cap::AnnotatedPred,
136    Cap::QuotingPred,
137];
138
139impl Cap {
140    /// Stable key recorded in `ExampleQuery.requires`.
141    fn key(self) -> &'static str {
142        match self {
143            Cap::TopClass => "TOP_CLASS",
144            Cap::TopPred => "TOP_PRED",
145            Cap::LabeledClass => "LABELED_CLASS",
146            Cap::LabelPred => "LABEL_PRED",
147            Cap::ObjectPred => "OBJECT_PRED",
148            Cap::TimePred => "TIME_PRED",
149            Cap::NumPred => "NUM_PRED",
150            Cap::BaseIri => "BASE_IRI",
151            Cap::ExternalIri => "EXTERNAL_IRI",
152            Cap::GeoLatLong => "GEO_LATLONG",
153            Cap::WktPath => "WKT_PATH",
154            Cap::Bbox => "BBOX",
155            Cap::NamedGraphs => "NAMED_GRAPHS",
156            Cap::Link => "LINK",
157            Cap::HasLiterals => "HAS_LITERALS",
158            Cap::AnnotatedStatements => "ANNOTATED_STATEMENTS",
159            Cap::AnnotationPred => "ANNOTATION_PRED",
160            Cap::AnnotatedPred => "ANNOTATED_PRED",
161            Cap::QuotingPred => "QUOTING_PRED",
162        }
163    }
164
165    /// The `{{PLACEHOLDER}}` this capability's value is substituted into, for
166    /// the string-valued ones. `None` for the pure gates (they have no text in
167    /// the body — they only decide whether it is emitted).
168    fn placeholder(self) -> Option<&'static str> {
169        Some(match self {
170            Cap::TopClass => "{{TOP_CLASS}}",
171            Cap::TopPred => "{{TOP_PRED}}",
172            Cap::LabeledClass => "{{LABELED_CLASS}}",
173            Cap::LabelPred => "{{LABEL_PRED}}",
174            Cap::ObjectPred => "{{OBJECT_PRED}}",
175            Cap::TimePred => "{{TIME_PRED}}",
176            Cap::NumPred => "{{NUM_PRED}}",
177            Cap::BaseIri => "{{BASE_IRI}}",
178            Cap::WktPath => "{{WKT_PATH}}",
179            Cap::Bbox => "{{BBOX_POLYGON}}",
180            Cap::AnnotationPred => "{{ANNOTATION_PRED}}",
181            Cap::AnnotatedPred => "{{ANNOTATED_PRED}}",
182            Cap::QuotingPred => "{{QUOTING_PRED}}",
183            Cap::ExternalIri
184            | Cap::GeoLatLong
185            | Cap::NamedGraphs
186            | Cap::Link
187            | Cap::HasLiterals
188            | Cap::AnnotatedStatements => return None,
189        })
190    }
191
192    /// The capabilities this one is chosen **together with** — the card holds a
193    /// `class_links` row proving they meet on the same subjects, so a body may
194    /// conjoin them.
195    ///
196    /// This is the whole safety property. Every other pair of substitutions is
197    /// picked from a different ranking (most instances / most statements / most
198    /// labels), and two independent maxima need not describe the same entity;
199    /// see the module docs. The relation is symmetric — `check_body` checks
200    /// both directions.
201    fn joint_with(self) -> &'static [Cap] {
202        match self {
203            // `LABELED_CLASS` *is* "the class that carries `LABEL_PRED`". The
204            // one pair the card can witness, and the one the label queries need.
205            Cap::LabeledClass => &[Cap::LabelPred],
206            Cap::LabelPred => &[Cap::LabeledClass],
207            // `ANNOTATED_PRED` *is* "the predicate inside the statements that
208            // `ANNOTATION_PRED` was seen annotating". `signals
209            // .annotated_statement` holds the two as they occurred together on
210            // one statement, so a body may write them into one pattern. Chosen
211            // apart they would be the two-maxima shape again: the most-used
212            // annotation predicate need not be the one attached to the
213            // most-annotated kind of statement.
214            Cap::AnnotatedPred => &[Cap::AnnotationPred],
215            Cap::AnnotationPred => &[Cap::AnnotatedPred],
216            _ => &[],
217        }
218    }
219}
220
221/// **The** co-occurrence witness: does the card record that some subject of
222/// `class` also carries `pred`?
223///
224/// A `class_links` row is positive evidence — the quotient counts real
225/// statements, so a row exists only because a subject classified as `class`
226/// really made a `pred` statement. Both the generator (choosing
227/// `Cap::LabeledClass`) and the audit ([`audit`]) ask this one question, so
228/// "does the card prove these meet?" has exactly one implementation.
229fn class_carries(card: &DatasetCard, class: &str, pred: &str) -> bool {
230    card.class_links
231        .iter()
232        .any(|l| l.s_class == class && l.predicate == pred)
233}
234
235/// The RDF-star co-occurrence witness, in the one place both halves read it:
236/// `(the predicate inside a quoted triple, the predicate annotating it)` for a
237/// statement this graph was seen to **assert and annotate**.
238///
239/// It plays exactly the role [`class_carries`] plays for `LABELED_CLASS`. The
240/// derivation records the pair only when it matched an asserted statement
241/// against the annotations of that same statement, so a hit proves both halves
242/// of `qt-coverage`'s aggregate are non-zero — `?s p ?o` matches, and the
243/// `OPTIONAL` binds at least once. Both the generator (resolving
244/// [`Cap::AnnotatedPred`]) and [`audit`] ask through here, so "does the card
245/// prove these meet?" keeps having exactly one implementation.
246fn annotated_statement(card: &DatasetCard) -> Option<(&str, &str)> {
247    card.signals
248        .annotated_statement
249        .as_ref()
250        .map(|(quoted, about)| (quoted.as_str(), about.as_str()))
251}
252
253/// Why a template's emitted query cannot come back empty. Declared per template
254/// and enforced by `check_body` plus the fixture tests, which run every
255/// generated query against the very graph it was generated for.
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257enum NonEmpty {
258    /// Any non-empty graph answers: the body has no substitution and no filter
259    /// that could exclude every statement.
260    AnyGraph,
261    /// An aggregate with no `GROUP BY` — SPARQL returns exactly one row even
262    /// over an empty solution sequence, so "zero rows" is not reachable. (The
263    /// row's values may still be unbound; see each template's note.)
264    Aggregate,
265    /// Every substitution came from a count the card recorded as `> 0`, and any
266    /// two that meet in the body are [jointly derived](Cap::joint_with).
267    Witnessed,
268    /// The card cannot decide it. The string is the reason, and it is the text
269    /// a reader deserves when the query does answer nothing.
270    Undecidable(&'static str),
271}
272
273/// One query template: a body with `{{PLACEHOLDER}}`s, the tier that answers it,
274/// and the capabilities it needs (which also gate emission).
275struct Template {
276    id: &'static str,
277    title: &'static str,
278    dimension: &'static str,
279    question: &'static str,
280    /// The default-graph body — used verbatim when the data lives in the
281    /// default graph, so classic (graph-free) cards are byte-identical to
282    /// before scope awareness existed.
283    body: &'static str,
284    /// Body when **every** statement lives in a named graph and the default
285    /// graph is empty. On such a file the default-graph `body` can only return
286    /// zero rows, so a template without a named-scope body is **skipped**
287    /// (unless it is already `GRAPH`-native, i.e. requires [`Cap::NamedGraphs`]).
288    named_body: Option<&'static str>,
289    /// Body when **both** the default graph and named graphs hold data.
290    /// `None` falls back to `body`: sound — the profile that instantiated it
291    /// was derived from the (non-empty) default graph — but scoped to it, so
292    /// the whole-dataset templates (counts, histograms, hubs) provide a
293    /// `UNION`-scoped variant instead of silently hiding the named half.
294    mixed_body: Option<&'static str>,
295    tier: Tier,
296    requires: &'static [Cap],
297    /// Why the emitted query cannot return zero rows.
298    nonempty: NonEmpty,
299    /// A weaker body for when `requires` is **not** met: the template degrades
300    /// to a still-answerable question instead of vanishing. Without it a
301    /// capability that only sharpens a query (`LABELED_CLASS`) would also be
302    /// able to delete it — on `hugging-face` the label query would disappear
303    /// even though the dataset has labels, which is not an improvement over
304    /// shipping one that returns nothing.
305    fallback: Option<Fallback>,
306    /// Decides emptiness from the card, in **default-graph scope only** (the
307    /// card profiles the default graph, so its counts witness nothing about a
308    /// `GRAPH`-scoped body). `Some(f)` and `f(card)` true ⇒ the query would
309    /// answer nothing ⇒ it is not emitted. The generator's last gate.
310    provably_empty: Option<fn(&DatasetCard) -> bool>,
311    /// Is `Template::provably_empty` an **equivalence** in default-graph
312    /// scope — does `false` prove the query answers, and not merely fail to
313    /// prove it empty?
314    ///
315    /// True only where the card's precompute *is* this query's result:
316    /// `top-in-hubs` is `card.in_hubs`, row for row and by construction (see
317    /// the in-degree tally in `derive_card_from`). `top-dangling` shares the
318    /// hook — no IRI object means nothing can dangle — without the converse, so
319    /// it stays honestly undecidable. Nothing in generation reads this; it is
320    /// what lets [`audit`] tell a query the card decides from one it does not,
321    /// instead of counting both as unknown.
322    hook_is_exact: bool,
323}
324
325/// The reduced form of a [`Template`], used when its full `requires` are not
326/// available. Default-graph shaped (no scope variants), so it is skipped on a
327/// named-graph-only file exactly like a template without a `named_body`.
328struct Fallback {
329    body: &'static str,
330    requires: &'static [Cap],
331    nonempty: NonEmpty,
332}
333
334/// The library. Order is fixed → deterministic output (folded into the hash).
335const TEMPLATES: &[Template] = &[
336    // --- Overview ---
337    Template {
338        id: "ov-one-row",
339        title: "One statement, now",
340        dimension: "overview",
341        question: "Return exactly one statement — did this file open and answer?",
342        // The smoke test (issue #153): guaranteed one row on ANY non-empty
343        // file. A COUNT can honestly return 0 (named-graph-only files) and
344        // read as failure; one concrete row is unambiguous.
345        body: "SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 1",
346        named_body: Some("SELECT ?g ?s ?p ?o WHERE { GRAPH ?g { ?s ?p ?o } } LIMIT 1"),
347        mixed_body: Some(
348            "SELECT ?s ?p ?o WHERE { { ?s ?p ?o } UNION { GRAPH ?g { ?s ?p ?o } } } LIMIT 1",
349        ),
350        tier: Tier::Index,
351        requires: &[],
352        nonempty: NonEmpty::AnyGraph,
353        fallback: None,
354        provably_empty: None,
355        hook_is_exact: false,
356    },
357    Template {
358        id: "ov-triples",
359        title: "How many statements?",
360        dimension: "overview",
361        question: "How big is this graph — how many triples?",
362        body: "SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }",
363        named_body: Some("SELECT (COUNT(*) AS ?n) WHERE { GRAPH ?g { ?s ?p ?o } }"),
364        mixed_body: Some(
365            "SELECT (COUNT(*) AS ?n) WHERE { { ?s ?p ?o } UNION { GRAPH ?g { ?s ?p ?o } } }",
366        ),
367        tier: Tier::Summary,
368        requires: &[],
369        nonempty: NonEmpty::Aggregate,
370        fallback: None,
371        provably_empty: None,
372        hook_is_exact: false,
373    },
374    Template {
375        id: "ov-pred-list",
376        title: "What relationships exist?",
377        dimension: "overview",
378        question: "Which predicates (relationships) appear in the data?",
379        body: "SELECT DISTINCT ?p WHERE { ?s ?p ?o }",
380        named_body: Some("SELECT DISTINCT ?p WHERE { GRAPH ?g { ?s ?p ?o } }"),
381        mixed_body: Some(
382            "SELECT DISTINCT ?p WHERE { { ?s ?p ?o } UNION { GRAPH ?g { ?s ?p ?o } } }",
383        ),
384        tier: Tier::Summary,
385        requires: &[],
386        nonempty: NonEmpty::AnyGraph,
387        fallback: None,
388        provably_empty: None,
389        hook_is_exact: false,
390    },
391    Template {
392        id: "ov-pred-hist",
393        title: "How frequent is each relationship?",
394        dimension: "overview",
395        question: "How many statements use each predicate?",
396        // No ORDER BY — keeps it on the index-free summary fast path; sort client-side.
397        body: "SELECT ?p (COUNT(*) AS ?n) WHERE { ?s ?p ?o } GROUP BY ?p",
398        named_body: Some("SELECT ?p (COUNT(*) AS ?n) WHERE { GRAPH ?g { ?s ?p ?o } } GROUP BY ?p"),
399        mixed_body: Some(
400            "SELECT ?p (COUNT(*) AS ?n) WHERE { { ?s ?p ?o } UNION { GRAPH ?g { ?s ?p ?o } } } GROUP BY ?p",
401        ),
402        tier: Tier::Summary,
403        requires: &[],
404        // A GROUP BY yields no groups over an empty sequence — but every
405        // statement has a predicate, so a non-empty graph has at least one.
406        nonempty: NonEmpty::AnyGraph,
407        fallback: None,
408        provably_empty: None,
409        hook_is_exact: false,
410    },
411    Template {
412        id: "ov-ask-pred",
413        title: "Does the top predicate occur?",
414        dimension: "overview",
415        question: "Is the most common predicate present at all?",
416        body: "ASK { ?s {{TOP_PRED}} ?o }",
417        named_body: Some("ASK { GRAPH ?g { ?s {{TOP_PRED}} ?o } }"),
418        mixed_body: None, // the profiled predicate lives in the default graph
419        tier: Tier::Summary,
420        requires: &[Cap::TopPred],
421        // One substitution, from a predicate the card counted; nothing to
422        // conjoin it with, so the ASK cannot be false.
423        nonempty: NonEmpty::Witnessed,
424        fallback: None,
425        provably_empty: None,
426        hook_is_exact: false,
427    },
428    // --- Identity & labels ---
429    Template {
430        id: "id-sample",
431        title: "A real entity to start from",
432        dimension: "identity",
433        question: "Give me one concrete entity of the most common type.",
434        body: "SELECT ?s WHERE { ?s a {{TOP_CLASS}} } LIMIT 1",
435        named_body: None,
436        mixed_body: None,
437        tier: Tier::Index,
438        requires: &[Cap::TopClass],
439        nonempty: NonEmpty::Witnessed,
440        fallback: None,
441        provably_empty: None,
442        hook_is_exact: false,
443    },
444    Template {
445        id: "lb-labels",
446        title: "Readable names for entities",
447        dimension: "labels",
448        question: "What are the human-readable names of some entities?",
449        //
450        // The class is `{{LABELED_CLASS}}`, NOT `{{TOP_CLASS}}`. Both name a
451        // class the dataset certainly has, and only one of them is proven to
452        // carry the label predicate this body joins it to. Picking the two
453        // maxima independently — the class with the most instances, the
454        // predicate with the most uses — ships a guaranteed-empty query
455        // whenever they fall on different parts of the graph:
456        //
457        //   mtg           top class `mtg:Ruling` (76,990), which has no
458        //                 `schema:name` at all — 0 rows, on a plain
459        //                 default-graph file.
460        //   hugging-face  top class `hf:Model` (2.9 M); `rdfs:label` occurs
461        //                 only on the 64 embedded ontology terms — 0 rows.
462        //
463        // `LABELED_CLASS` walks `card.classes` in descending instance order and
464        // takes the first one a `class_links` row proves carries the predicate.
465        // That row is a positive witness (some subject has both statements), so
466        // the join cannot be empty — and on a dataset where the top class *is*
467        // labelled (the common case: geoadmin, lombardi) it resolves to exactly
468        // the top class, leaving those cards byte-identical.
469        //
470        // Two other shapes were considered:
471        //
472        //   Drop the class (`?s {{LABEL_PRED}} ?label`). Simplest, and it also
473        //   cannot fail — but it answers a strictly weaker question, throws away
474        //   the join that is the single most reusable pattern a newcomer copies
475        //   out of a starter query, and would rewrite the query on every card
476        //   that is already correct.
477        //
478        //   Emit only when the top class is labelled. Silent and lossy: a
479        //   dataset whose labels live off the top class loses its label query
480        //   entirely, which is how a reader concludes there are no labels.
481        //
482        // So: prefer the typed form when the card can prove it, and fall back to
483        // the class-free form when it cannot — the case where `class_links` (top
484        // 100 rows) truncated the witness away, or where labels sit on untyped
485        // subjects. `hugging-face` takes the fallback and answers with 50 rows.
486        body: "SELECT ?s ?label WHERE { ?s a {{LABELED_CLASS}} ; {{LABEL_PRED}} ?label } LIMIT 50",
487        named_body: None,
488        mixed_body: None,
489        tier: Tier::Index,
490        requires: &[Cap::LabeledClass, Cap::LabelPred],
491        nonempty: NonEmpty::Witnessed,
492        fallback: Some(Fallback {
493            body: "SELECT ?s ?label WHERE { ?s {{LABEL_PRED}} ?label } LIMIT 50",
494            requires: &[Cap::LabelPred],
495            nonempty: NonEmpty::Witnessed,
496        }),
497        provably_empty: None,
498        hook_is_exact: false,
499    },
500    // --- Types ---
501    Template {
502        id: "ty-class-hist",
503        title: "What kinds of things, how many?",
504        dimension: "types",
505        question: "How many entities of each class are there?",
506        body: "SELECT ?c (COUNT(?s) AS ?n) WHERE { ?s a ?c } GROUP BY ?c ORDER BY DESC(?n) LIMIT 50",
507        named_body: None,
508        mixed_body: None,
509        tier: Tier::Index,
510        requires: &[Cap::TopClass],
511        // Gated on a class existing, and the body substitutes nothing.
512        nonempty: NonEmpty::Witnessed,
513        fallback: None,
514        provably_empty: None,
515        hook_is_exact: false,
516    },
517    Template {
518        id: "ty-entities",
519        title: "How many distinct typed entities?",
520        dimension: "types",
521        question: "How many distinct entities carry a type?",
522        body: "SELECT (COUNT(DISTINCT ?s) AS ?n) WHERE { ?s a [] }",
523        named_body: None,
524        mixed_body: None,
525        tier: Tier::Index,
526        requires: &[Cap::TopClass],
527        nonempty: NonEmpty::Aggregate,
528        fallback: None,
529        provably_empty: None,
530        hook_is_exact: false,
531    },
532    Template {
533        id: "ty-class-shape",
534        title: "What does the top class look like?",
535        dimension: "types",
536        question: "Which predicates describe the most common class?",
537        body: "SELECT ?p (COUNT(*) AS ?n) WHERE { ?s a {{TOP_CLASS}} ; ?p ?o } GROUP BY ?p ORDER BY DESC(?n)",
538        named_body: None,
539        mixed_body: None,
540        tier: Tier::Index,
541        requires: &[Cap::TopClass],
542        // One substitution, conjoined only with a wholly-variable pattern that
543        // the class assertion itself already satisfies (`?p ?o` matches the
544        // `rdf:type` statement) — so this is not the two-maxima shape.
545        nonempty: NonEmpty::Witnessed,
546        fallback: None,
547        provably_empty: None,
548        hook_is_exact: false,
549    },
550    // --- Topology ---
551    Template {
552        id: "top-class-graph",
553        title: "Which classes link to which?",
554        dimension: "topology",
555        question: "What is the class-to-class schema graph?",
556        body: "SELECT ?sC ?p ?oC (COUNT(*) AS ?n) WHERE { ?s a ?sC ; ?p ?o OPTIONAL { ?o a ?oC } } GROUP BY ?sC ?p ?oC ORDER BY DESC(?n)",
557        named_body: None,
558        mixed_body: None,
559        tier: Tier::Index,
560        requires: &[Cap::TopClass],
561        nonempty: NonEmpty::Witnessed,
562        fallback: None,
563        provably_empty: None,
564        hook_is_exact: false,
565    },
566    Template {
567        id: "top-out-hubs",
568        title: "Most connected sources",
569        dimension: "topology",
570        question: "Which subjects make the most statements?",
571        body: "SELECT ?s (COUNT(*) AS ?d) WHERE { ?s ?p ?o } GROUP BY ?s ORDER BY DESC(?d) LIMIT 25",
572        named_body: Some(
573            "SELECT ?s (COUNT(*) AS ?d) WHERE { GRAPH ?g { ?s ?p ?o } } GROUP BY ?s ORDER BY DESC(?d) LIMIT 25",
574        ),
575        mixed_body: Some(
576            "SELECT ?s (COUNT(*) AS ?d) WHERE { { ?s ?p ?o } UNION { GRAPH ?g { ?s ?p ?o } } } GROUP BY ?s ORDER BY DESC(?d) LIMIT 25",
577        ),
578        tier: Tier::Index,
579        requires: &[],
580        nonempty: NonEmpty::AnyGraph,
581        fallback: None,
582        provably_empty: None,
583        hook_is_exact: false,
584    },
585    Template {
586        id: "top-in-hubs",
587        title: "Most referenced entities",
588        dimension: "topology",
589        question: "Which resources are referenced most often?",
590        body: "SELECT ?o (COUNT(*) AS ?d) WHERE { ?s ?p ?o FILTER(!isLiteral(?o)) } GROUP BY ?o ORDER BY DESC(?d) LIMIT 25",
591        named_body: Some(
592            "SELECT ?o (COUNT(*) AS ?d) WHERE { GRAPH ?g { ?s ?p ?o FILTER(!isLiteral(?o)) } } GROUP BY ?o ORDER BY DESC(?d) LIMIT 25",
593        ),
594        mixed_body: Some(
595            "SELECT ?o (COUNT(*) AS ?d) WHERE { { ?s ?p ?o } UNION { GRAPH ?g { ?s ?p ?o } } FILTER(!isLiteral(?o)) } GROUP BY ?o ORDER BY DESC(?d) LIMIT 25",
596        ),
597        tier: Tier::Index,
598        requires: &[],
599        // A graph whose objects are ALL literals (a flat table of values, no
600        // `rdf:type` anywhere) has no group to return. `card.in_hubs` is
601        // exactly this query's precompute, so in default-graph scope the
602        // emptiness is decidable — see `provably_empty`. In `GRAPH` scope the
603        // card profiles the wrong half and cannot say.
604        nonempty: NonEmpty::Undecidable(
605            "no non-literal object exists; decidable (and refused) in default-graph scope only, \
606             since the card profiles the default graph and this body may be GRAPH-scoped",
607        ),
608        fallback: None,
609        provably_empty: Some(|card| card.in_hubs.is_empty()),
610        // `in_hubs` is this query's own precompute, so the hook decides it in
611        // both directions once the body is default-graph scoped.
612        hook_is_exact: true,
613    },
614    Template {
615        id: "top-dangling",
616        title: "Referenced but undescribed IRIs",
617        dimension: "topology",
618        question: "Which IRIs are referenced as objects but never described?",
619        body: "SELECT ?o WHERE { ?s ?p ?o FILTER(isIRI(?o)) FILTER NOT EXISTS { ?o ?p2 ?o2 } } LIMIT 100",
620        named_body: Some(
621            "SELECT ?o WHERE { GRAPH ?g { ?s ?p ?o FILTER(isIRI(?o)) } FILTER NOT EXISTS { GRAPH ?h { ?o ?p2 ?o2 } } } LIMIT 100",
622        ),
623        mixed_body: Some(
624            "SELECT ?o WHERE { { ?s ?p ?o } UNION { GRAPH ?g { ?s ?p ?o } } FILTER(isIRI(?o)) FILTER NOT EXISTS { ?o ?p2 ?o2 } FILTER NOT EXISTS { GRAPH ?h { ?o ?p3 ?o3 } } } LIMIT 100",
625        ),
626        tier: Tier::Index,
627        requires: &[],
628        // The honest one: a fully-described graph legitimately has none, and
629        // "is every referenced IRI described?" is precisely what the card does
630        // not record. Only the trivial half is decidable (no IRI object at all).
631        nonempty: NonEmpty::Undecidable(
632            "a fully-described graph has no dangling IRI — emptiness here is the answer, \
633             not a failure, and the card does not record which objects are also subjects",
634        ),
635        fallback: None,
636        provably_empty: Some(|card| card.in_hubs.is_empty()),
637        hook_is_exact: false,
638    },
639    Template {
640        id: "top-reach",
641        title: "What's reachable by following one relation?",
642        dimension: "connectivity",
643        question: "Following the most common entity-to-entity relation, what is reachable from a node?",
644        //
645        // Was `{{HUB_IRI}} {{TOP_PRED}}+ ?y`: the busiest subject and the most
646        // frequent predicate, chosen independently and then conjoined — the
647        // same defect as `lb-labels`, and live. On `hugging-face` the hub is a
648        // User and the top predicate `schema:keywords` (which only Models
649        // carry): 0 rows. Nothing in the card ties a specific subject to a
650        // specific predicate, so that pair can never be witnessed and the seed
651        // has to come from the relation itself.
652        //
653        // The sub-SELECT picks one real subject OF the relation, so the outer
654        // path always has at least the seed's own edge to return; the seed stays
655        // a single bound term, so the traversal costs what it did before (a
656        // bare `?x p+ ?y` would enumerate the whole closure, and these queries
657        // are measured at build time).
658        //
659        // `OBJECT_PRED`, not `TOP_PRED`: the most frequent predicate is usually
660        // a labelling one, whose objects are literals — a `+` over it can never
661        // walk past one hop. A `class_links` row with a non-literal `o_class`
662        // proves the relation goes entity-to-entity. A self-referential one
663        // (`s_class == o_class`) would chain deeper still, but its closure is
664        // unbounded — `hf:follows` is 886 k edges of social graph — so
665        // frequency, not recursion, picks the winner.
666        body: "SELECT ?x ?y WHERE { { SELECT ?x WHERE { ?x {{OBJECT_PRED}} ?o } LIMIT 1 } ?x {{OBJECT_PRED}}+ ?y } LIMIT 100",
667        named_body: None,
668        mixed_body: None,
669        tier: Tier::Index,
670        requires: &[Cap::ObjectPred],
671        nonempty: NonEmpty::Witnessed,
672        fallback: None,
673        provably_empty: None,
674        hook_is_exact: false,
675    },
676    // --- Connectivity / completeness ---
677    Template {
678        id: "cmp-coverage",
679        title: "Do all entities of the labelled class have a label?",
680        dimension: "connectivity",
681        question: "How complete is labelling on the class that carries labels?",
682        // `{{LABELED_CLASS}}` for the same reason as `lb-labels`, plus one of
683        // its own: with `{{TOP_CLASS}}` this reported `76990 / 0` on mtg — a
684        // row, so never caught by a zero-rows gate, and a measurement of the
685        // wrong class. Pairing it with `lb-labels` also keeps the two label
686        // queries talking about the same entities.
687        body: "SELECT (COUNT(?s) AS ?total) (COUNT(?l) AS ?have) WHERE { ?s a {{LABELED_CLASS}} OPTIONAL { ?s {{LABEL_PRED}} ?l } }",
688        named_body: None,
689        mixed_body: None,
690        tier: Tier::Index,
691        requires: &[Cap::LabeledClass, Cap::LabelPred],
692        nonempty: NonEmpty::Aggregate,
693        fallback: None,
694        provably_empty: None,
695        hook_is_exact: false,
696    },
697    // --- Links ---
698    Template {
699        id: "lk-sameas",
700        title: "Aligned to which external datasets?",
701        dimension: "links",
702        question: "Which entity-alignment predicates are used, and how often?",
703        // The VALUES list must cover the gate exactly. It listed three of the
704        // four predicates `signals.link_predicates` is detected from, so a
705        // dataset aligned only with `skos:closeMatch` passed the gate and then
706        // grouped nothing. Adding the fourth makes emptiness unreachable.
707        body: "SELECT ?p (COUNT(*) AS ?n) WHERE { ?s ?p ?o VALUES ?p { owl:sameAs skos:exactMatch skos:closeMatch rdfs:seeAlso } } GROUP BY ?p",
708        named_body: None,
709        mixed_body: None,
710        tier: Tier::Index,
711        requires: &[Cap::Link],
712        nonempty: NonEmpty::Witnessed,
713        fallback: None,
714        provably_empty: None,
715        hook_is_exact: false,
716    },
717    Template {
718        id: "lk-external",
719        title: "Which predicates link out?",
720        dimension: "links",
721        question: "Which predicates point to IRIs outside this dataset?",
722        // `EXTERNAL_IRI` is the witness that the FILTER keeps something: an IRI
723        // in `classes` or `in_hubs` that does not start with the base IRI.
724        // Without it a self-contained dataset — every object either a literal
725        // or an in-namespace IRI — emitted a query that grouped nothing.
726        body: "SELECT ?p (COUNT(*) AS ?n) WHERE { ?s ?p ?o FILTER(isIRI(?o) && !STRSTARTS(STR(?o), \"{{BASE_IRI}}\")) } GROUP BY ?p ORDER BY DESC(?n) LIMIT 50",
727        named_body: None,
728        mixed_body: None,
729        tier: Tier::Index,
730        requires: &[Cap::BaseIri, Cap::ExternalIri],
731        nonempty: NonEmpty::Witnessed,
732        fallback: None,
733        provably_empty: None,
734        hook_is_exact: false,
735    },
736    // --- Literals ---
737    Template {
738        id: "lt-datatypes",
739        title: "What value types appear?",
740        dimension: "literals",
741        question: "Which literal datatypes are used, and how often?",
742        body: "SELECT ?dt (COUNT(*) AS ?n) WHERE { ?s ?p ?o FILTER(isLiteral(?o)) } GROUP BY (DATATYPE(?o) AS ?dt) ORDER BY DESC(?n)",
743        named_body: None,
744        mixed_body: None,
745        tier: Tier::Index,
746        requires: &[Cap::HasLiterals],
747        nonempty: NonEmpty::Witnessed,
748        fallback: None,
749        provably_empty: None,
750        hook_is_exact: false,
751    },
752    Template {
753        id: "lt-langs",
754        title: "What languages?",
755        dimension: "literals",
756        question: "Which language tags appear on literals, and how often?",
757        body: "SELECT ?l (COUNT(*) AS ?n) WHERE { ?s ?p ?o FILTER(isLiteral(?o)) } GROUP BY (LANG(?o) AS ?l) ORDER BY DESC(?n)",
758        named_body: None,
759        mixed_body: None,
760        tier: Tier::Index,
761        requires: &[Cap::HasLiterals],
762        nonempty: NonEmpty::Witnessed,
763        fallback: None,
764        provably_empty: None,
765        hook_is_exact: false,
766    },
767    Template {
768        id: "lt-numrange",
769        title: "Range of a numeric property",
770        dimension: "literals",
771        question: "What is the min / average / max of the top numeric property?",
772        // BIND the cast first — aggregating over a function expression is rejected.
773        body: "SELECT (MIN(?v) AS ?lo) (AVG(?v) AS ?avg) (MAX(?v) AS ?hi) WHERE { ?s {{NUM_PRED}} ?o BIND(xsd:double(?o) AS ?v) }",
774        named_body: None,
775        mixed_body: None,
776        tier: Tier::Index,
777        requires: &[Cap::NumPred],
778        nonempty: NonEmpty::Aggregate,
779        fallback: None,
780        provably_empty: None,
781        hook_is_exact: false,
782    },
783    // --- Time ---
784    Template {
785        id: "ti-extent",
786        title: "What period does it span?",
787        dimension: "time",
788        question: "What is the earliest and latest value of the top time predicate?",
789        body: "SELECT (MIN(?d) AS ?from) (MAX(?d) AS ?to) WHERE { ?s {{TIME_PRED}} ?d }",
790        named_body: None,
791        mixed_body: None,
792        tier: Tier::Index,
793        requires: &[Cap::TimePred],
794        nonempty: NonEmpty::Aggregate,
795        fallback: None,
796        provably_empty: None,
797        hook_is_exact: false,
798    },
799    Template {
800        id: "ti-histogram",
801        title: "Distribution over time",
802        dimension: "time",
803        question: "How are entities distributed by year?",
804        body: "SELECT ?yr (COUNT(*) AS ?n) WHERE { ?s {{TIME_PRED}} ?d BIND(SUBSTR(STR(?d),1,4) AS ?yr) } GROUP BY ?yr ORDER BY ?yr",
805        named_body: None,
806        mixed_body: None,
807        tier: Tier::Index,
808        requires: &[Cap::TimePred],
809        nonempty: NonEmpty::Witnessed,
810        fallback: None,
811        provably_empty: None,
812        hook_is_exact: false,
813    },
814    // --- Space (WKT is always CRS84 lon/lat) ---
815    Template {
816        id: "sp-bbox",
817        title: "Geographic extent",
818        dimension: "space",
819        question: "What is the bounding box of the wgs84 coordinates? (lon/lat)",
820        // NOTE: the two predicates are conjoined on ONE subject, while the gate
821        // (`geo_latlong`) is `lat seen` AND `long seen`, tallied independently —
822        // the same shape as the `lb-labels` bug. It cannot produce zero rows
823        // (an un-grouped aggregate always returns one), so the worst case is a
824        // row of unbound values rather than a missing answer, and the card
825        // cannot do better: `class_links` collapses every untyped subject into
826        // one `(untyped)` bucket, so rows for `wgs:lat` and `wgs:long` under it
827        // are not evidence that any single subject carries both.
828        body: "SELECT (MIN(?lon) AS ?minLon) (MIN(?lat) AS ?minLat) (MAX(?lon) AS ?maxLon) (MAX(?lat) AS ?maxLat) WHERE { ?s wgs:long ?lon ; wgs:lat ?lat }",
829        named_body: None,
830        mixed_body: None,
831        tier: Tier::Index,
832        requires: &[Cap::GeoLatLong],
833        nonempty: NonEmpty::Aggregate,
834        fallback: None,
835        provably_empty: None,
836        hook_is_exact: false,
837    },
838    Template {
839        id: "sp-within",
840        title: "Features inside a box",
841        dimension: "space",
842        question: "Which features fall inside the dataset's bounding box? (WKT is lon/lat)",
843        //
844        // The access path is `{{WKT_PATH}}`, read off the predicates the card
845        // recorded, because the fixed `geo:hasGeometry/geo:asWKT` was a third
846        // instance of the same bug — and a live one. The gate, `signals.geo_wkt`,
847        // is a three-way OR (`geo:asWKT` seen, OR `geo:hasGeometry` seen, OR a
848        // `wktLiteral` datatype seen) while the body demanded BOTH steps of the
849        // chain: `geoadmin` hangs `geo:asWKT` straight off each District and has
850        // no `geo:hasGeometry` at all, so its published card ships a query that
851        // cannot match — 0 rows on 52,959 geometries. `geo:hasGeometry?/geo:asWKT`
852        // covers both layouts in one path (the optional step binds `?s` to the
853        // feature when there is a geometry node, to the feature itself when the
854        // WKT hangs off it directly).
855        body: "SELECT ?s WHERE { ?s {{WKT_PATH}} ?w FILTER(geof:sfWithin(?w, \"{{BBOX_POLYGON}}\"^^geo:wktLiteral)) } LIMIT 100",
856        named_body: None,
857        mixed_body: None,
858        tier: Tier::Index,
859        requires: &[Cap::WktPath, Cap::Bbox],
860        // The path is witnessed; the FILTER is not, and cannot be. The box is
861        // derived from `wgs84:lat`/`wgs84:long` literals — a different signal
862        // from the geometries this body reads, on possibly different subjects —
863        // and a GeoSPARQL `wktLiteral` may carry a projected CRS whose
864        // coordinates fall outside any lon/lat box, so not even the whole-world
865        // fallback box is a proof. Emptiness here means "nothing in that box",
866        // which is a real answer about the data, and the box is the parameter
867        // the reader is meant to edit.
868        nonempty: NonEmpty::Undecidable(
869            "the box is derived from wgs84 lat/long literals, not from the WKT geometries this \
870             query reads, and the card records no coordinate extent for them",
871        ),
872        fallback: None,
873        provably_empty: None,
874        hook_is_exact: false,
875    },
876    // --- Named graphs ---
877    Template {
878        id: "ng-list",
879        title: "What graphs partition this?",
880        dimension: "graphs",
881        question: "Which named graphs does the dataset contain?",
882        body: "SELECT DISTINCT ?g WHERE { GRAPH ?g { ?s ?p ?o } }",
883        named_body: None,
884        mixed_body: None,
885        tier: Tier::Index,
886        requires: &[Cap::NamedGraphs],
887        nonempty: NonEmpty::Witnessed,
888        fallback: None,
889        provably_empty: None,
890        hook_is_exact: false,
891    },
892    Template {
893        id: "ng-sizes",
894        title: "Which graphs are biggest?",
895        dimension: "graphs",
896        question: "Which named graphs hold the most statements?",
897        // LIMIT keeps this a starter on a file with tens of thousands of graphs.
898        body: "SELECT ?g (COUNT(*) AS ?n) WHERE { GRAPH ?g { ?s ?p ?o } } GROUP BY ?g ORDER BY DESC(?n) LIMIT 25",
899        named_body: None,
900        mixed_body: None,
901        tier: Tier::Index,
902        requires: &[Cap::NamedGraphs],
903        nonempty: NonEmpty::Witnessed,
904        fallback: None,
905        provably_empty: None,
906        hook_is_exact: false,
907    },
908    Template {
909        id: "ng-sample",
910        title: "A peek inside the graphs",
911        dimension: "graphs",
912        question: "What do statements in the named graphs look like?",
913        // `?g` in the projection on purpose: each sample row says where it lives.
914        body: "SELECT ?g ?s ?p ?o WHERE { GRAPH ?g { ?s ?p ?o } } LIMIT 10",
915        named_body: None,
916        mixed_body: None,
917        tier: Tier::Index,
918        requires: &[Cap::NamedGraphs],
919        nonempty: NonEmpty::Witnessed,
920        fallback: None,
921        provably_empty: None,
922        hook_is_exact: false,
923    },
924    // --- Statements about statements (RDF-star) ---
925    //
926    // Emitted only for a dataset whose own statements were seen to quote
927    // other statements, and written in the RDF-star surface `<< s p o >>`,
928    // which is what the SPARQL parser accepts (`docs/sparql.md#rdf-star`).
929    // The RDF 1.2 spelling `<<( s p o )>>` is an EXPORT surface, not a query
930    // one, and a body written in it would not parse — the worst thing a card
931    // can carry.
932    //
933    // None of these has a `named_body`: the profile they are instantiated from
934    // is derived from the default graph, so on a named-graph-only file the
935    // capabilities are simply absent and nothing is emitted.
936    Template {
937        id: "qt-sample",
938        title: "A statement someone made a statement about",
939        dimension: "statements",
940        question: "Which statements does this dataset say something about, and what does it say?",
941        // The family's entry point, and the one shape worth copying: the inner
942        // variables bind the quoted statement's own subject, predicate and
943        // object, so one row shows both the assertion and what qualifies it.
944        // The annotation predicate is pinned rather than left a variable so
945        // the scan routes on a predicate instead of walking the whole index.
946        body: "SELECT ?s ?p ?o ?value WHERE { << ?s ?p ?o >> {{ANNOTATION_PRED}} ?value } LIMIT 25",
947        named_body: None,
948        mixed_body: None,
949        tier: Tier::Index,
950        // One substitution, from a predicate the derivation counted on a
951        // statement whose subject really is a quoted triple.
952        requires: &[Cap::AnnotationPred],
953        nonempty: NonEmpty::Witnessed,
954        fallback: None,
955        provably_empty: None,
956        hook_is_exact: false,
957    },
958    Template {
959        id: "qt-about",
960        title: "What gets said about statements?",
961        dimension: "statements",
962        question: "Which predicates qualify a statement rather than an entity, and how often?",
963        // The annotation schema: the `ov-pred-hist` of the star layer. The
964        // annotation predicate is a variable here on purpose — that IS the
965        // question — so the gate is the pure "such statements exist" bool.
966        body: "SELECT ?about (COUNT(*) AS ?n) WHERE { << ?s ?p ?o >> ?about ?value } GROUP BY ?about ORDER BY DESC(?n) LIMIT 50",
967        named_body: None,
968        mixed_body: None,
969        tier: Tier::Index,
970        requires: &[Cap::AnnotatedStatements],
971        // A GROUP BY yields no group over an empty sequence — but the card
972        // counted at least one statement whose subject is a quoted triple, and
973        // every statement has a predicate to group by.
974        nonempty: NonEmpty::Witnessed,
975        fallback: None,
976        provably_empty: None,
977        hook_is_exact: false,
978    },
979    Template {
980        id: "qt-qualifiers",
981        title: "Who (or what) qualifies the assertions?",
982        dimension: "statements",
983        question: "How many assertions does each distinct qualifier value account for?",
984        // "Count assertions by whatever qualifies them" — the recorder, the
985        // source, the confidence band. Grouping on the VALUE, not on the
986        // predicate, is what makes this a different question from `qt-about`.
987        body: "SELECT ?value (COUNT(*) AS ?n) WHERE { << ?s ?p ?o >> {{ANNOTATION_PRED}} ?value } GROUP BY ?value ORDER BY DESC(?n) LIMIT 50",
988        named_body: None,
989        mixed_body: None,
990        tier: Tier::Index,
991        requires: &[Cap::AnnotationPred],
992        nonempty: NonEmpty::Witnessed,
993        fallback: None,
994        provably_empty: None,
995        hook_is_exact: false,
996    },
997    Template {
998        id: "qt-coverage",
999        title: "How much of the data is qualified?",
1000        dimension: "statements",
1001        question: "Of the statements that get annotated most, how many actually carry an annotation?",
1002        // The `cmp-coverage` of the star layer, and the question a provenance
1003        // graph is really about: 40,000 assertions, 300 of them sourced.
1004        //
1005        // `{{ANNOTATED_PRED}}` is written twice — once in a regular pattern to
1006        // enumerate the assertions, once inside the quoted pattern to reach
1007        // their annotations. `?s`/`?o` are bound by the regular pattern, so
1008        // the quoted occurrence lowers to a join on them (`sameTerm`), not a
1009        // second enumeration. The two predicates conjoined here are the ONE
1010        // pair the derivation saw on a single statement
1011        // (`signals.annotated_statement`), never two independent maxima.
1012        body: "SELECT (COUNT(*) AS ?asserted) (COUNT(?value) AS ?qualified) WHERE { ?s {{ANNOTATED_PRED}} ?o OPTIONAL { << ?s {{ANNOTATED_PRED}} ?o >> {{ANNOTATION_PRED}} ?value } }",
1013        named_body: None,
1014        mixed_body: None,
1015        tier: Tier::Index,
1016        requires: &[Cap::AnnotatedPred, Cap::AnnotationPred],
1017        nonempty: NonEmpty::Aggregate,
1018        fallback: None,
1019        provably_empty: None,
1020        hook_is_exact: false,
1021    },
1022    Template {
1023        id: "qt-quoted-object",
1024        title: "Statements held as values",
1025        dimension: "statements",
1026        question: "Which statements are referenced as the value of another statement?",
1027        // The OTHER position a triple term stands in — `:claim :states
1028        // << s p o >>` — and a separate capability, because a dataset can have
1029        // one shape and not the other: a query written for the subject
1030        // position returns nothing at all on an object-position file (and the
1031        // header's presence bit cannot tell them apart).
1032        body: "SELECT ?s ?qs ?qp ?qo WHERE { ?s {{QUOTING_PRED}} << ?qs ?qp ?qo >> } LIMIT 25",
1033        named_body: None,
1034        mixed_body: None,
1035        tier: Tier::Index,
1036        requires: &[Cap::QuotingPred],
1037        nonempty: NonEmpty::Witnessed,
1038        fallback: None,
1039        provably_empty: None,
1040        hook_is_exact: false,
1041    },
1042];
1043
1044/// What the template behind an emitted query claims about its emptiness — the
1045/// `NonEmpty` declaration, read back by id.
1046#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1047pub enum Claim {
1048    /// The template asserts the emitted query cannot come back empty
1049    /// (`NonEmpty::AnyGraph` / `NonEmpty::Aggregate` / `NonEmpty::Witnessed`).
1050    CannotBeEmpty,
1051    /// The template admits the card cannot decide it
1052    /// (`NonEmpty::Undecidable`).
1053    Undecidable,
1054    /// No template of this revision owns the id — a curated query, or one an
1055    /// older revision wrote. It claims nothing, so nothing is contradicted.
1056    Unknown,
1057}
1058
1059/// The [`Claim`] behind a shipped query id.
1060///
1061/// The build measures every starter query against the finished file
1062/// (`commands::buildinfo`), which makes emptiness a **measured** fact rather
1063/// than a derived one — and where measurement and this claim disagree, the
1064/// claim is wrong. A query the table swears cannot be empty and that then
1065/// answers nothing is a defect in the table, not a fact about the dataset, and
1066/// `commands::build` says so out loud and records it. `Undecidable` is the
1067/// opposite: the template said in advance it could not know, so a measured zero
1068/// is expected news, not a contradiction.
1069pub fn claim_of(id: &str) -> Claim {
1070    match TEMPLATES.iter().find(|t| t.id == id) {
1071        Some(t) if matches!(t.nonempty, NonEmpty::Undecidable(_)) => Claim::Undecidable,
1072        Some(_) => Claim::CannotBeEmpty,
1073        None => Claim::Unknown,
1074    }
1075}
1076
1077/// Where the dataset's statements live, decided from the card's own counts —
1078/// so the emitted queries address the graph(s) that actually hold data instead
1079/// of scanning a default graph the card itself records as empty.
1080#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1081enum GraphScope {
1082    /// No named graphs — bodies are used verbatim, so classic cards are
1083    /// byte-identical to pre-scope-awareness builds.
1084    DefaultOnly,
1085    /// Every statement lives in a named graph and the default graph is empty
1086    /// (`triple_count == 0 && named_graph_count > 0`) — a bare `{ ?s ?p ?o }`
1087    /// can only ever return zero rows here.
1088    NamedOnly,
1089    /// Both the default graph and named graphs hold statements.
1090    Mixed,
1091}
1092
1093impl GraphScope {
1094    fn of(card: &DatasetCard) -> Self {
1095        if card.named_graph_count == 0 {
1096            GraphScope::DefaultOnly
1097        } else if card.triple_count == 0 {
1098            GraphScope::NamedOnly
1099        } else {
1100            GraphScope::Mixed
1101        }
1102    }
1103}
1104
1105/// The dataset's resolved vocabulary, drawn from the card profile.
1106///
1107/// The *jointly derived* fields — [`Caps::labeled_class`], [`Caps::object_pred`],
1108/// [`Caps::wkt_path`], [`Caps::external_iri`] — are the point of this struct: each
1109/// is resolved from a `class_links` row (or a predicate list) that **witnesses**
1110/// the pattern its template will write, rather than from an independent ranking
1111/// that merely says the term exists somewhere in the graph.
1112struct Caps {
1113    top_class: Option<String>,
1114    top_pred: Option<String>,
1115    labeled_class: Option<String>,
1116    label_pred: Option<String>,
1117    object_pred: Option<String>,
1118    time_pred: Option<String>,
1119    num_pred: Option<String>,
1120    base_iri: Option<String>,
1121    external_iri: bool,
1122    wkt_path: Option<String>,
1123    bbox_polygon: Option<String>,
1124    geo_latlong: bool,
1125    named_graphs: bool,
1126    link: bool,
1127    has_literals: bool,
1128    annotated_statements: bool,
1129    annotation_pred: Option<String>,
1130    annotated_pred: Option<String>,
1131    quoting_pred: Option<String>,
1132}
1133
1134impl Caps {
1135    fn from_card(card: &DatasetCard) -> Self {
1136        let s = &card.signals;
1137        // The most frequent predicate that is not rdf:type (the interesting
1138        // relation), falling back to whatever is most frequent.
1139        let top_pred = card
1140            .predicates
1141            .iter()
1142            .map(|(p, _)| p)
1143            .find(|p| p.as_str() != RDF_TYPE)
1144            .or_else(|| card.predicates.first().map(|(p, _)| p))
1145            .cloned();
1146
1147        // LABELED_CLASS — the most populous class the card can PROVE carries the
1148        // label predicate. `card.classes` is ordered by instance count, so the
1149        // first hit is the biggest labelled class, and it equals `top_class`
1150        // whenever the top class is labelled (the common case, which therefore
1151        // produces exactly the query it produced before). A `class_links` row is
1152        // a positive witness — some subject really has both statements — so a
1153        // hit is proof, and a miss (a truncated top-100 list, or labels only on
1154        // untyped subjects) is merely absence of proof, which is why the
1155        // template keeps a class-free fallback rather than vanishing.
1156        let label_pred = s.label_predicate.clone();
1157        let labeled_class = label_pred.as_ref().and_then(|lp| {
1158            card.classes
1159                .iter()
1160                .map(|(c, _)| c)
1161                .find(|c| class_carries(card, c, lp))
1162                .cloned()
1163        });
1164
1165        // OBJECT_PRED — the most frequent relation the card can PROVE points at
1166        // something other than a literal, i.e. one a path query can walk.
1167        // `class_links` is ordered by count and excludes `rdf:type` rows.
1168        let object_pred = card
1169            .class_links
1170            .iter()
1171            .find(|l| l.o_class != O_LITERAL && l.predicate != RDF_TYPE)
1172            .map(|l| l.predicate.clone());
1173
1174        // WKT_PATH — how this dataset actually hangs a geometry off a subject.
1175        // Read from the predicate list (capped at the top 100, so a miss can
1176        // only lose the query, never emit a broken one).
1177        let has_pred = |iri: &str| card.predicates.iter().any(|(p, _)| p == iri);
1178        let wkt_path = has_pred(GEO_ASWKT).then(|| {
1179            if has_pred(GEO_HASGEOMETRY) {
1180                // The optional step covers BOTH layouts at once: `?s` binds to
1181                // the feature when a geometry node sits in between, and to the
1182                // feature itself when the WKT hangs off it directly.
1183                "geo:hasGeometry?/geo:asWKT".to_string()
1184            } else {
1185                "geo:asWKT".to_string()
1186            }
1187        });
1188        let bbox_polygon = wkt_path.is_some().then(|| bbox_polygon(s.spatial_bbox));
1189
1190        // EXTERNAL_IRI — an IRI the card records that does NOT start with the
1191        // dataset's base IRI, so the "links pointing out" filter keeps at least
1192        // one statement. Classes count: `?s a foaf:Person` is an IRI object like
1193        // any other. Blank nodes do not — the query filters on `isIRI`.
1194        let external_iri = s.base_iri.as_deref().is_some_and(|base| {
1195            card.classes
1196                .iter()
1197                .chain(card.in_hubs.iter())
1198                .any(|(term, _)| {
1199                    let iri = term.trim_start_matches('<').trim_end_matches('>');
1200                    !term.starts_with("_:") && !iri.starts_with(base)
1201                })
1202        });
1203
1204        // RDF-star. The presence signal the card *displays* is read off the
1205        // header at display time — true for every file ever built, which is
1206        // what makes it honest about the already-published corpus, and useless
1207        // here: it names no vocabulary and cannot be written into a query. So
1208        // the `qt-*` family is gated on the profile derived from the
1209        // statements instead, which a card only has if it was derived after
1210        // this existed. Presence keeps answering on old files; queries arrive
1211        // with the next build.
1212        //
1213        // `ANNOTATED_PRED` and `ANNOTATION_PRED` come from the ONE pair the
1214        // derivation saw on a single statement, never from the two rankings
1215        // independently — see `Cap::joint_with`.
1216        let witness = annotated_statement(card);
1217        let annotated_pred = witness.map(|(quoted, _)| quoted.to_string());
1218        // The witnessed annotating predicate when there is one — so the pair
1219        // `qt-coverage` writes really met, and so the whole family talks about
1220        // one predicate (the reason `cmp-coverage` shares `lb-labels`' class).
1221        // Otherwise the plain ranking, which is all the single-substitution
1222        // bodies need; `ANNOTATED_PRED` is then absent and nothing conjoins.
1223        let annotation_pred = witness
1224            .map(|(_, about)| about.to_string())
1225            .or_else(|| s.annotation_predicates.first().cloned());
1226
1227        Caps {
1228            top_class: card.classes.first().map(|(c, _)| c.clone()),
1229            top_pred,
1230            labeled_class,
1231            label_pred,
1232            object_pred,
1233            time_pred: s.time_predicates.first().cloned(),
1234            num_pred: s.numeric_predicates.first().cloned(),
1235            base_iri: s.base_iri.clone(),
1236            external_iri,
1237            wkt_path,
1238            bbox_polygon,
1239            geo_latlong: s.geo_latlong,
1240            named_graphs: card.named_graph_count > 0,
1241            link: !s.link_predicates.is_empty(),
1242            has_literals: !card.datatypes.is_empty(),
1243            annotated_statements: !s.annotation_predicates.is_empty(),
1244            annotation_pred,
1245            annotated_pred,
1246            quoting_pred: s.quoting_predicates.first().cloned(),
1247        }
1248    }
1249
1250    /// The text substituted for a capability's placeholder, if it has one.
1251    fn value(&self, cap: Cap) -> Option<&str> {
1252        match cap {
1253            Cap::TopClass => self.top_class.as_deref(),
1254            Cap::TopPred => self.top_pred.as_deref(),
1255            Cap::LabeledClass => self.labeled_class.as_deref(),
1256            Cap::LabelPred => self.label_pred.as_deref(),
1257            Cap::ObjectPred => self.object_pred.as_deref(),
1258            Cap::TimePred => self.time_pred.as_deref(),
1259            Cap::NumPred => self.num_pred.as_deref(),
1260            Cap::BaseIri => self.base_iri.as_deref(),
1261            Cap::WktPath => self.wkt_path.as_deref(),
1262            Cap::Bbox => self.bbox_polygon.as_deref(),
1263            Cap::AnnotationPred => self.annotation_pred.as_deref(),
1264            Cap::AnnotatedPred => self.annotated_pred.as_deref(),
1265            Cap::QuotingPred => self.quoting_pred.as_deref(),
1266            Cap::ExternalIri
1267            | Cap::GeoLatLong
1268            | Cap::NamedGraphs
1269            | Cap::Link
1270            | Cap::HasLiterals
1271            | Cap::AnnotatedStatements => None,
1272        }
1273    }
1274
1275    /// Is a capability available (string caps must resolve; bool caps must hold)?
1276    fn available(&self, cap: Cap) -> bool {
1277        match cap {
1278            Cap::ExternalIri => self.external_iri,
1279            Cap::GeoLatLong => self.geo_latlong,
1280            Cap::NamedGraphs => self.named_graphs,
1281            Cap::Link => self.link,
1282            Cap::HasLiterals => self.has_literals,
1283            Cap::AnnotatedStatements => self.annotated_statements,
1284            other => self.value(other).is_some(),
1285        }
1286    }
1287
1288    /// Substitute every placeholder we have a value for.
1289    fn substitute(&self, body: &str) -> String {
1290        let mut out = body.to_string();
1291        for &cap in ALL_CAPS {
1292            if let (Some(ph), Some(v)) = (cap.placeholder(), self.value(cap)) {
1293                out = out.replace(ph, v);
1294            }
1295        }
1296        out
1297    }
1298}
1299
1300/// Audit one body against the co-occurrence rule (see the module docs).
1301///
1302/// Two things are checked, and both are properties of the **table**, not of any
1303/// dataset — so this runs over every template at test time and, in debug builds,
1304/// over every body the generator actually emits:
1305///
1306/// 1. Every `{{PLACEHOLDER}}` names a capability the template *requires*. A body
1307///    can therefore never be shipped with an unsubstituted hole, whatever the
1308///    card looks like.
1309/// 2. Unless the template declares itself `NonEmpty::Undecidable`, any two
1310///    distinct capabilities appearing in the same body are [jointly
1311///    derived](Cap::joint_with). This is the rule `lb-labels` broke.
1312fn check_body(id: &str, body: &str, requires: &[Cap], nonempty: NonEmpty) -> Result<(), String> {
1313    let used: Vec<Cap> = ALL_CAPS
1314        .iter()
1315        .copied()
1316        .filter(|c| c.placeholder().is_some_and(|ph| body.contains(ph)))
1317        .collect();
1318    // (1) Placeholders are accounted for, in both directions.
1319    for cap in &used {
1320        if !requires.contains(cap) {
1321            return Err(format!(
1322                "{id}: body uses {} but does not require {}",
1323                cap.placeholder().unwrap_or_default(),
1324                cap.key()
1325            ));
1326        }
1327    }
1328    for chunk in body.split("{{").skip(1) {
1329        let name = chunk.split("}}").next().unwrap_or(chunk);
1330        let ph = format!("{{{{{name}}}}}");
1331        if !ALL_CAPS.iter().any(|c| c.placeholder() == Some(&ph[..])) {
1332            return Err(format!("{id}: body has an unknown placeholder {ph}"));
1333        }
1334    }
1335    // (2) Whatever meets in the body must have been chosen together.
1336    if matches!(nonempty, NonEmpty::Undecidable(_)) {
1337        return Ok(());
1338    }
1339    for (i, a) in used.iter().enumerate() {
1340        for b in &used[i + 1..] {
1341            if !a.joint_with().contains(b) {
1342                return Err(format!(
1343                    "{id}: conjoins {} with {}, which are chosen independently — \
1344                     make one jointly derived (Cap::joint_with) or declare NonEmpty::Undecidable",
1345                    a.key(),
1346                    b.key()
1347                ));
1348            }
1349        }
1350    }
1351    Ok(())
1352}
1353
1354/// A CRS84 lon/lat WKT polygon for the bounding box, or the whole world when the
1355/// dataset has geometry but no wgs84 extent was derived.
1356fn bbox_polygon(bbox: Option<[f64; 4]>) -> String {
1357    let [min_lon, min_lat, max_lon, max_lat] = bbox.unwrap_or([-180.0, -90.0, 180.0, 90.0]);
1358    format!(
1359        "POLYGON(({min_lon} {min_lat}, {max_lon} {min_lat}, {max_lon} {max_lat}, {min_lon} {max_lat}, {min_lon} {min_lat}))"
1360    )
1361}
1362
1363/// Generate the tiered starter-query library for a card: emit each template whose
1364/// required capabilities are all present, with the body picked for where the data
1365/// lives (`GraphScope`), placeholders substituted, and the shared PREFIX block
1366/// prepended.
1367pub fn generate(card: &DatasetCard) -> Vec<ExampleQuery> {
1368    let caps = Caps::from_card(card);
1369    let scope = GraphScope::of(card);
1370    let mut out = Vec::new();
1371    for t in TEMPLATES {
1372        // 1. Capability gate — the full template, or its reduced fallback. A
1373        //    fallback carries no scope variants, so it is default-graph shaped.
1374        let all = |rs: &[Cap]| rs.iter().all(|&c| caps.available(c));
1375        let (base, named, mixed, requires, nonempty) = if all(t.requires) {
1376            (t.body, t.named_body, t.mixed_body, t.requires, t.nonempty)
1377        } else if let Some(f) = t.fallback.as_ref().filter(|f| all(f.requires)) {
1378            (f.body, None, None, f.requires, f.nonempty)
1379        } else {
1380            continue;
1381        };
1382
1383        // 2. Pick the body for where the data lives. A scope-variant body is
1384        //    always Index-tier: GRAPH/UNION patterns are not summary-shaped
1385        //    (`summary_query_shape` recognizes only a bare default-graph
1386        //    pattern), and the pyramid summary covers the default graph only.
1387        let (body, tier) = match scope {
1388            GraphScope::DefaultOnly => (base, t.tier),
1389            GraphScope::NamedOnly => match named {
1390                Some(b) => (b, Tier::Index),
1391                // GRAPH-native templates (the ng-* family) already address the
1392                // named graphs; any other default-graph body would be a
1393                // guaranteed-zero-rows query on a file whose default graph the
1394                // card itself records as empty — skip it.
1395                None if requires.contains(&Cap::NamedGraphs) => (base, t.tier),
1396                None => continue,
1397            },
1398            GraphScope::Mixed => match mixed {
1399                Some(b) => (b, Tier::Index),
1400                // The default-graph body is sound here — the profile that
1401                // instantiated it was derived from the (non-empty) default
1402                // graph — and the ng-* family surfaces the named half.
1403                None => (base, t.tier),
1404            },
1405        };
1406
1407        // 3. The last gate: refuse a query the card can PROVE would answer
1408        //    nothing. Only in default-graph scope — the card's counts are over
1409        //    the default graph, so they witness nothing about a GRAPH-scoped
1410        //    body (and a `GRAPH`-native template is never asking about the
1411        //    default graph in the first place).
1412        if scope == GraphScope::DefaultOnly && t.provably_empty.is_some_and(|f| f(card)) {
1413            continue;
1414        }
1415
1416        debug_assert!(
1417            check_body(t.id, body, requires, nonempty).is_ok(),
1418            "{}",
1419            check_body(t.id, body, requires, nonempty).unwrap_err()
1420        );
1421        let body = caps.substitute(body);
1422        debug_assert!(
1423            !body.contains("{{"),
1424            "template {} left an unsubstituted placeholder",
1425            t.id
1426        );
1427        out.push(ExampleQuery {
1428            id: t.id.to_string(),
1429            title: t.title.to_string(),
1430            dimension: t.dimension.to_string(),
1431            question: t.question.to_string(),
1432            sparql: format!("{PREFIXES}\n{body}"),
1433            tier,
1434            requires: requires.iter().map(|c| c.key().to_string()).collect(),
1435        });
1436    }
1437    out
1438}
1439
1440// ===========================================================================
1441// Auditing a card that is ALREADY published
1442// ===========================================================================
1443//
1444// [`generate`] decides what a card *should* ship. The audit answers the other
1445// half of the same question — what does a card that is already on the wire
1446// ship, and can it answer? — for a file whose queries were written by an older
1447// revision of the table above.
1448//
1449// It is deliberately in this module and not beside it: the one judgement it
1450// makes ("does the card prove these two pieces meet?") is
1451// `class_carries`, the same function `Caps::from_card` resolves
1452// `Cap::LabeledClass` with, and the emptiness gates it applies are the
1453// templates' own `Template::provably_empty` hooks and `NonEmpty` claims. A
1454// second, drifting copy of "is this empty" living in a script is how the
1455// original defect survived four releases.
1456
1457/// What a published starter query is worth on the file that carries it.
1458#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1459#[serde(rename_all = "kebab-case")]
1460pub enum Verdict {
1461    /// The card **proves** at least one row comes back.
1462    Answers,
1463    /// The card **proves** none does. Airtight for the shapes that turn on a
1464    /// term's existence (a path step missing from a complete predicate list, a
1465    /// `VALUES` list disjoint from the link predicates, a default-graph body on
1466    /// an empty default graph); for the class-∧-predicate shape it rests on the
1467    /// quotient, whose one blind spot `Refuted` documents and narrows.
1468    Empty,
1469    /// A row comes back (an un-grouped aggregate always returns one) but it is
1470    /// vacuous — the thing being counted is provably zero.
1471    Vacuous,
1472    /// The card cannot prove the join is non-empty, and bounds how much could
1473    /// survive it. Not the same as `Empty`: reported separately, never merged.
1474    Suspect,
1475    /// Honestly undecidable from a card — the template says so, or the query
1476    /// conjoins terms nothing in the card can relate.
1477    Undecidable,
1478    /// The body matches no known revision of its template, so nothing is
1479    /// claimed about it.
1480    Unrecognized,
1481}
1482
1483impl Verdict {
1484    pub fn as_str(self) -> &'static str {
1485        match self {
1486            Verdict::Answers => "answers",
1487            Verdict::Empty => "empty",
1488            Verdict::Vacuous => "vacuous",
1489            Verdict::Suspect => "suspect",
1490            Verdict::Undecidable => "undecidable",
1491            Verdict::Unrecognized => "unrecognized",
1492        }
1493    }
1494}
1495
1496/// One published query's verdict, with the evidence behind it.
1497#[derive(Debug, Clone, Serialize)]
1498pub struct Finding {
1499    pub id: String,
1500    pub verdict: Verdict,
1501    /// The evidence, in the words a maintainer needs to act on it.
1502    pub why: String,
1503    /// What a re-card would do to this query: `current` (byte-identical to what
1504    /// this generator writes for this very card — nothing to fix),
1505    /// `superseded` (the generator writes a different body now), or `dropped`
1506    /// (it would no longer be emitted at all).
1507    pub revision: &'static str,
1508    /// The terms substituted into the body, each tagged with the card ranking
1509    /// it was drawn from. Two terms from **different** rankings conjoined in
1510    /// one body is the #172 shape — recorded for every query, so the census is
1511    /// empirical rather than a re-reading of the table.
1512    pub substitutions: Vec<Substitution>,
1513    /// True when the body conjoins two or more independently-ranked terms.
1514    pub conjoined: bool,
1515    /// What running the query actually did — absent unless `--measure` ran it.
1516    /// Kept **beside** `verdict`, never merged into it: one is what a card can
1517    /// prove about a file, the other is what the file did. Where they disagree
1518    /// the observation wins, and the disagreement is the finding.
1519    #[serde(default, skip_serializing_if = "Option::is_none")]
1520    pub observed: Option<Observation>,
1521}
1522
1523/// One starter query, actually run.
1524#[derive(Debug, Clone, Serialize)]
1525pub struct Observation {
1526    /// `answers` (rows with bindings), `vacuous` (rows that bind nothing),
1527    /// `empty` (no rows at all), or `error` (the run did not finish).
1528    pub outcome: &'static str,
1529    pub rows: u64,
1530    /// Bytes the run read, cold, open included.
1531    pub bytes: u64,
1532    /// Range requests the run made, cold, open included.
1533    pub requests: u64,
1534    /// Wall clock on the measuring machine — a reference, not a property of the
1535    /// file. Read it next to `bytes`/`requests`, never on its own.
1536    pub debug_ms: u64,
1537    #[serde(skip_serializing_if = "Option::is_none")]
1538    pub error: Option<String>,
1539    /// What the file's own build record says this query cost — present only
1540    /// when the file carries one. That is the single case where a
1541    /// re-measurement has a **known answer to check itself against**, so it is
1542    /// checked, and the answer is reported rather than assumed.
1543    #[serde(skip_serializing_if = "Option::is_none")]
1544    pub recorded: Option<Recorded>,
1545}
1546
1547/// The figures a build wrote into the file, beside the ones just measured.
1548#[derive(Debug, Clone, Serialize)]
1549pub struct Recorded {
1550    pub bytes: u64,
1551    pub requests: u64,
1552    pub rows: u64,
1553    /// All three agree with the run. `bytes` and `requests` are properties of
1554    /// layout + query, so they should — through the same transport. A
1555    /// disagreement is worth knowing about: a different reader fan-out, a
1556    /// different engine, or a file that is not the one the record describes.
1557    pub agrees: bool,
1558}
1559
1560impl Observation {
1561    /// Does the observation contradict what the card alone concluded? Only
1562    /// `Answers` and `Empty` are claims strong enough to be wrong; `Suspect`
1563    /// and `Undecidable` say in so many words that they are not claims.
1564    pub fn contradicts(&self, verdict: Verdict) -> bool {
1565        match verdict {
1566            Verdict::Answers => self.outcome != "answers",
1567            Verdict::Empty => self.outcome == "answers",
1568            Verdict::Vacuous => self.outcome == "answers",
1569            _ => false,
1570        }
1571    }
1572}
1573
1574/// A substituted term and where in the card profile it came from.
1575#[derive(Debug, Clone, Serialize)]
1576pub struct Substitution {
1577    pub placeholder: String,
1578    pub value: String,
1579    /// e.g. `classes[0]`, `predicates[3]`, `signals.label_predicate`. Empty
1580    /// when the term is in no ranking the card records (a hard-coded IRI, or a
1581    /// value that fell off a capped list).
1582    pub origin: Vec<String>,
1583}
1584
1585/// Bodies that **earlier revisions** of a template shipped. A published file is
1586/// not re-cardable for free, so the audit has to read the query text that is
1587/// actually out there; only the revisions the current bodies no longer match
1588/// need an entry. Every one is a body `check_body` would reject today — that
1589/// is why it has an entry.
1590struct Legacy {
1591    id: &'static str,
1592    body: &'static str,
1593}
1594
1595const LEGACY_BODIES: &[Legacy] = &[
1596    // pre-#172 `top-reach`: the busiest subject and the most frequent
1597    // predicate, chosen from two independent rankings and then conjoined.
1598    Legacy {
1599        id: "top-reach",
1600        body: "SELECT ?y WHERE { {{HUB_IRI}} {{TOP_PRED}}+ ?y } LIMIT 100",
1601    },
1602    // pre-#172 `lk-sameas`: the VALUES list covered three of the four
1603    // predicates the `signals.link_predicates` gate is detected from.
1604    Legacy {
1605        id: "lk-sameas",
1606        body: "SELECT ?p (COUNT(*) AS ?n) WHERE { ?s ?p ?o VALUES ?p { owl:sameAs skos:exactMatch rdfs:seeAlso } } GROUP BY ?p",
1607    },
1608];
1609
1610/// The effective top-N cap a card's profile lists were derived under. Cards
1611/// built before the field existed omit it; they were built by this same builder
1612/// family, whose cap is a compile-time constant.
1613fn cap_of(card: &DatasetCard) -> usize {
1614    if card.top_n > 0 {
1615        card.top_n as usize
1616    } else {
1617        CARD_TOP_N
1618    }
1619}
1620
1621/// Is a capped profile list **complete** — did nothing fall off the end? A list
1622/// shorter than the cap was never truncated, so absence from it is evidence of
1623/// absence. A list *at* the cap proves nothing either way.
1624fn complete<T>(card: &DatasetCard, list: &[T]) -> bool {
1625    list.len() < cap_of(card)
1626}
1627
1628/// How far the card can go towards proving that no instance of `class` carries
1629/// `pred` — the negative of `class_carries`, which a card can rarely settle
1630/// outright.
1631/// The refutation is exact **for singly-typed data only**, and that is the
1632/// audit's one standing caveat: the quotient gives every subject exactly one
1633/// class (the last `rdf:type` wins), so a subject typed both `class` and
1634/// `other` is counted under `other` alone. `?s a class ; pred ?o` then matches a
1635/// graph whose quotient shows no such row — `vidy` types all 39,723 of its units
1636/// both `schema:ArchiveComponent` and `vidy:Unit`, the quotient files every one
1637/// of them under `vidy:Unit`, and the label query the card ships returns 50 rows
1638/// regardless.
1639///
1640/// A card cannot record multi-typing, but it does leave two tells, and
1641/// [`refute_class_pred`] refuses to claim a refutation when either shows:
1642///
1643///  1. **The class is absent from the quotient.** A class with instances but no
1644///     `class_links` row of its own is a class the quotient is not describing —
1645///     shadowed, or making no statements at all — so its silence about one
1646///     predicate means nothing.
1647///  2. **Another class has exactly the same instance count.** Two classes over
1648///     the same population is what dual typing looks like from the card (a
1649///     schema.org type beside a native one — `vidy` and `arxiu` both), and the
1650///     labelled half may be filed under the twin.
1651///
1652/// Neither is a proof of single-typing; together they are what the card can see.
1653/// Running the query is what closes it.
1654enum Refuted {
1655    /// Every use of `pred` is accounted for in the `class_links` quotient, none
1656    /// of them falls on `class`, and neither multi-typing tell shows.
1657    Proved,
1658    /// The quotient is truncated: at most `budget` uses of `pred` are
1659    /// unaccounted for, so at most that many of the class's `instances` could
1660    /// be joined. Suspicion, not proof.
1661    AtMost { budget: u64, instances: u64 },
1662    /// The quotient accounts for every use of `pred` and none falls on `class`,
1663    /// but a multi-typing tell shows, so that silence proves nothing.
1664    Shadowed { described: bool, twin: bool },
1665    /// `pred` is not in the (capped) predicate list, so there is no total to
1666    /// account against.
1667    Unknown,
1668}
1669
1670fn refute_class_pred(card: &DatasetCard, class: &str, pred: &str) -> Refuted {
1671    let instances = card
1672        .classes
1673        .iter()
1674        .find(|(c, _)| c == class)
1675        .map(|(_, n)| *n)
1676        .unwrap_or(0);
1677    // Every non-`rdf:type` statement contributes exactly one `class_links` row,
1678    // so the rows for a predicate sum to its total use **iff** none was
1679    // truncated away — the completeness test that makes absence conclusive.
1680    let accounted: u64 = card
1681        .class_links
1682        .iter()
1683        .filter(|l| l.predicate == pred)
1684        .map(|l| l.count)
1685        .sum();
1686    let Some((_, total)) = card.predicates.iter().find(|(p, _)| p == pred) else {
1687        return Refuted::Unknown;
1688    };
1689    let accounted_for_all = accounted >= *total || complete(card, &card.class_links);
1690    if !accounted_for_all {
1691        return Refuted::AtMost {
1692            budget: total - accounted,
1693            instances,
1694        };
1695    }
1696    // The two multi-typing tells (see `Refuted`). Either one and the quotient's
1697    // silence about this class stops being evidence.
1698    let described = card.class_links.iter().any(|l| l.s_class == class);
1699    let twin = card
1700        .classes
1701        .iter()
1702        .any(|(c, n)| c != class && *n == instances);
1703    if described && !twin {
1704        Refuted::Proved
1705    } else {
1706        Refuted::Shadowed { described, twin }
1707    }
1708}
1709
1710/// Which card rankings a substituted term appears in. Read off the card rather
1711/// than declared by the template, so it also describes a body this table no
1712/// longer contains.
1713fn origins(card: &DatasetCard, v: &str) -> Vec<String> {
1714    let mut o = Vec::new();
1715    if let Some(i) = card.classes.iter().position(|(c, _)| c == v) {
1716        o.push(format!("classes[{i}]"));
1717    }
1718    if let Some(i) = card.predicates.iter().position(|(p, _)| p == v) {
1719        o.push(format!("predicates[{i}]"));
1720    }
1721    if let Some(i) = card.top_hubs.iter().position(|(h, _)| h == v) {
1722        o.push(format!("top_hubs[{i}]"));
1723    }
1724    let s = &card.signals;
1725    if s.label_predicate.as_deref() == Some(v) {
1726        o.push("signals.label_predicate".to_string());
1727    }
1728    if let Some(i) = s.time_predicates.iter().position(|p| p == v) {
1729        o.push(format!("signals.time_predicates[{i}]"));
1730    }
1731    if let Some(i) = s.numeric_predicates.iter().position(|p| p == v) {
1732        o.push(format!("signals.numeric_predicates[{i}]"));
1733    }
1734    if s.base_iri.as_deref() == Some(v) {
1735        o.push("signals.base_iri".to_string());
1736    }
1737    if let Some(i) = s.annotation_predicates.iter().position(|p| p == v) {
1738        o.push(format!("signals.annotation_predicates[{i}]"));
1739    }
1740    if let Some(i) = s.quoting_predicates.iter().position(|p| p == v) {
1741        o.push(format!("signals.quoting_predicates[{i}]"));
1742    }
1743    if let Some((quoted, about)) = annotated_statement(card) {
1744        if v == quoted {
1745            o.push("signals.annotated_statement.0".to_string());
1746        }
1747        if v == about {
1748            o.push("signals.annotated_statement.1".to_string());
1749        }
1750    }
1751    o
1752}
1753
1754/// The bindings a published body gave a pattern's placeholders.
1755type Binds = Vec<(String, String)>;
1756
1757/// The **distinct** placeholders a body substitutes with a term the card
1758/// ranks. Distinct by name on purpose: the current `top-reach` writes
1759/// `{{OBJECT_PRED}}` twice — once to pick the seed, once to walk from it — and
1760/// a term conjoined with itself is the opposite of the two-maxima shape, it is
1761/// how the template guarantees the seed has an edge.
1762fn distinct_ranked<'a>(card: &DatasetCard, binds: &'a Binds) -> Vec<&'a str> {
1763    let mut names: Vec<&str> = binds
1764        .iter()
1765        .filter(|(_, v)| !origins(card, v).is_empty())
1766        .map(|(n, _)| n.as_str())
1767        .collect();
1768    names.sort_unstable();
1769    names.dedup();
1770    names
1771}
1772
1773fn bound<'a>(binds: &'a Binds, name: &str) -> Option<&'a str> {
1774    binds
1775        .iter()
1776        .find(|(n, _)| n == name)
1777        .map(|(_, v)| v.as_str())
1778}
1779
1780/// Match a published body against a template pattern, recovering the value each
1781/// `{{PLACEHOLDER}}` took. Every literal segment must appear, in order, and the
1782/// body must end exactly where the pattern does — a match is exact, so a
1783/// mismatched revision is reported as such instead of being misread.
1784fn match_pattern(pattern: &str, body: &str) -> Option<Binds> {
1785    let mut binds: Binds = Vec::new();
1786    let mut pat = pattern;
1787    let mut rest = body;
1788    loop {
1789        let Some(i) = pat.find("{{") else {
1790            return (pat == rest).then_some(binds);
1791        };
1792        let (lit, tail) = pat.split_at(i);
1793        rest = rest.strip_prefix(lit)?;
1794        let end = tail.find("}}")?;
1795        let name = &tail[2..end];
1796        pat = &tail[end + 2..];
1797        // The value runs up to the pattern's next literal segment.
1798        let next_lit = &pat[..pat.find("{{").unwrap_or(pat.len())];
1799        let cut = if next_lit.is_empty() {
1800            rest.len()
1801        } else {
1802            rest.find(next_lit)?
1803        };
1804        if cut == 0 {
1805            return None; // an empty substitution is not a substitution
1806        }
1807        binds.push((name.to_string(), rest[..cut].to_string()));
1808        rest = &rest[cut..];
1809    }
1810}
1811
1812/// The `?s a {{CLASS}} … {{PRED}}` shape — a class and a predicate conjoined on
1813/// **one** subject. Recognized from the pattern text, not from the template id,
1814/// so a future template that writes the same shape is audited too.
1815fn class_pred_conjunction(pattern: &str) -> Option<(&str, &str)> {
1816    let after = pattern.split("?s a {{").nth(1)?;
1817    let class = after.split("}}").next()?;
1818    let tail = after.split_once("}}")?.1;
1819    for sep in ["; {{", "?s {{"] {
1820        if let Some(x) = tail.split(sep).nth(1) {
1821            return Some((class, x.split("}}").next()?));
1822        }
1823    }
1824    None
1825}
1826
1827/// The `VALUES ?p { … }` list a body pins itself to, as bracketed IRIs. The
1828/// gate that admits the query counts predicates; the body only matches the ones
1829/// it names, and the two can disagree.
1830fn values_predicates(body: &str) -> Option<Vec<String>> {
1831    let inner = body.split("VALUES ?p {").nth(1)?.split('}').next()?;
1832    Some(
1833        inner
1834            .split_whitespace()
1835            .filter_map(|t| {
1836                let (p, local) = t.split_once(':')?;
1837                let ns = match p {
1838                    "owl" => "http://www.w3.org/2002/07/owl#",
1839                    "skos" => "http://www.w3.org/2004/02/skos/core#",
1840                    "rdfs" => "http://www.w3.org/2000/01/rdf-schema#",
1841                    _ => return None,
1842                };
1843                Some(format!("<{ns}{local}>"))
1844            })
1845            .collect(),
1846    )
1847}
1848
1849/// The predicate IRIs a property path **must** traverse — the steps that are
1850/// not optional. `geo:hasGeometry?/geo:asWKT` requires only the second; the
1851/// fixed `geo:hasGeometry/geo:asWKT` the pre-#172 template wrote requires both,
1852/// which is why it could not match a file that has no `geo:hasGeometry`.
1853fn required_path_steps(path: &str) -> Vec<String> {
1854    path.split('/')
1855        .filter(|step| !step.ends_with('?') && !step.ends_with('*'))
1856        .filter_map(|step| match step.trim_end_matches('+') {
1857            "geo:asWKT" => Some(GEO_ASWKT.to_string()),
1858            "geo:hasGeometry" => Some(GEO_HASGEOMETRY.to_string()),
1859            _ => None,
1860        })
1861        .collect()
1862}
1863
1864/// Audit every starter query a card already ships.
1865///
1866/// Two things happen per query, and they answer different questions. [`generate`]
1867/// is re-run over the same profile — what a re-card would ship — which says
1868/// whether the query is *stale*. The verdict says whether it *answers*, which is
1869/// the thing a reader of the published file experiences today.
1870pub fn audit(card: &DatasetCard) -> Vec<Finding> {
1871    let caps = Caps::from_card(card);
1872    let scope = GraphScope::of(card);
1873    let fresh = generate(card);
1874    card.queries
1875        .iter()
1876        .map(|q| {
1877            let revision = match fresh.iter().find(|r| r.id == q.id) {
1878                Some(r) if r.sparql == q.sparql => "current",
1879                Some(_) => "superseded",
1880                None => "dropped",
1881            };
1882            finding(card, &caps, scope, q, revision)
1883        })
1884        .collect()
1885}
1886
1887fn finding(
1888    card: &DatasetCard,
1889    caps: &Caps,
1890    scope: GraphScope,
1891    q: &ExampleQuery,
1892    revision: &'static str,
1893) -> Finding {
1894    let body = q
1895        .sparql
1896        .lines()
1897        .filter(|l| !l.starts_with("PREFIX"))
1898        .collect::<Vec<_>>()
1899        .join("\n");
1900    let body = body.trim();
1901
1902    let mk = |verdict, why: String, revision, binds: &Binds| {
1903        let substitutions: Vec<Substitution> = binds
1904            .iter()
1905            .map(|(n, v)| Substitution {
1906                placeholder: n.clone(),
1907                value: v.clone(),
1908                origin: origins(card, v),
1909            })
1910            .collect();
1911        Finding {
1912            id: q.id.clone(),
1913            verdict,
1914            why,
1915            revision,
1916            conjoined: distinct_ranked(card, binds).len() > 1,
1917            substitutions,
1918            observed: None,
1919        }
1920    };
1921
1922    let Some(t) = TEMPLATES.iter().find(|t| t.id == q.id) else {
1923        return mk(
1924            Verdict::Unrecognized,
1925            "no template of this id is in the library any more".to_string(),
1926            "retired",
1927            &Vec::new(),
1928        );
1929    };
1930
1931    // Recover the substituted terms: match the body against the template's own
1932    // patterns (every scope variant and the fallback), then against the bodies
1933    // earlier revisions wrote. The *values* decide the verdict, so a pattern
1934    // shared by two revisions is not a problem — `revision` already says which
1935    // one a re-card would replace it with.
1936    let patterns = [
1937        Some(t.body),
1938        t.named_body,
1939        t.mixed_body,
1940        t.fallback.as_ref().map(|f| f.body),
1941    ]
1942    .into_iter()
1943    .flatten()
1944    .chain(
1945        LEGACY_BODIES
1946            .iter()
1947            .filter(|l| l.id == q.id)
1948            .map(|l| l.body),
1949    );
1950    let Some((pattern, binds)) = patterns.into_iter().find_map(|p| {
1951        // Longest pattern first would be nicer, but the bodies of one template
1952        // are not prefixes of each other — an exact match is unambiguous.
1953        match_pattern(p, body).map(|b| (p, b))
1954    }) else {
1955        return mk(
1956            Verdict::Unrecognized,
1957            "the body matches no known revision of this template".to_string(),
1958            "unknown",
1959            &Vec::new(),
1960        );
1961    };
1962
1963    // --- 1. Scope. A default-graph body on a file whose default graph the card
1964    //        itself records as empty cannot return a row (issue #170).
1965    if !body.contains("GRAPH") && card.triple_count == 0 && card.named_graph_count > 0 {
1966        return mk(
1967            Verdict::Empty,
1968            format!(
1969                "the body scans the default graph, which the card records as empty \
1970                 ({} named graph(s) hold all {} statements)",
1971                card.named_graph_count, card.quad_count
1972            ),
1973            revision,
1974            &binds,
1975        );
1976    }
1977
1978    // --- 2. The template's own last gate, unchanged — and, where the table says
1979    //        the hook is an equivalence, its other direction.
1980    if scope == GraphScope::DefaultOnly {
1981        if let Some(hook) = t.provably_empty {
1982            if hook(card) {
1983                return mk(
1984                    Verdict::Empty,
1985                    "the card's own precompute for this query is empty (the generator \
1986                     refuses to emit it today)"
1987                        .to_string(),
1988                    revision,
1989                    &binds,
1990                );
1991            }
1992            if t.hook_is_exact {
1993                return mk(
1994                    Verdict::Answers,
1995                    "the card precomputes this query's own result, and it is not empty".to_string(),
1996                    revision,
1997                    &binds,
1998                );
1999            }
2000        }
2001    }
2002
2003    // --- 3. The co-occurrence rule, applied to the body that shipped.
2004    if let Some((cn, pn)) = class_pred_conjunction(pattern) {
2005        if let (Some(class), Some(pred)) = (bound(&binds, cn), bound(&binds, pn)) {
2006            let aggregate = matches!(t.nonempty, NonEmpty::Aggregate);
2007            if class_carries(card, class, pred) {
2008                return mk(
2009                    Verdict::Answers,
2010                    format!("class_links witnesses {class} carrying {pred}"),
2011                    revision,
2012                    &binds,
2013                );
2014            }
2015            return match refute_class_pred(card, class, pred) {
2016                Refuted::Proved => mk(
2017                    if aggregate {
2018                        Verdict::Vacuous
2019                    } else {
2020                        Verdict::Empty
2021                    },
2022                    format!(
2023                        "the class_links quotient accounts for every use of {pred} and \
2024                         none falls on {class}{}",
2025                        if aggregate {
2026                            " — the aggregate returns one row whose count is 0"
2027                        } else {
2028                            ""
2029                        }
2030                    ),
2031                    revision,
2032                    &binds,
2033                ),
2034                Refuted::AtMost { budget, instances } => mk(
2035                    Verdict::Suspect,
2036                    format!(
2037                        "no class_links row witnesses {class} carrying {pred}; the quotient \
2038                         is truncated, so at most {budget} of the class's {instances} \
2039                         instances could be joined"
2040                    ),
2041                    revision,
2042                    &binds,
2043                ),
2044                Refuted::Shadowed { described, twin } => mk(
2045                    Verdict::Suspect,
2046                    format!(
2047                        "the quotient accounts for every use of {pred} and none falls on \
2048                         {class}, but {} — the quotient files a multi-typed subject under \
2049                         one class only, so its silence is not evidence",
2050                        match (described, twin) {
2051                            (false, true) =>
2052                                "the class has no class_links row of its own AND another class \
2053                                 has exactly its instance count",
2054                            (false, false) => "the class has no class_links row of its own",
2055                            _ => "another class has exactly its instance count",
2056                        }
2057                    ),
2058                    revision,
2059                    &binds,
2060                ),
2061                Refuted::Unknown => mk(
2062                    Verdict::Undecidable,
2063                    format!("{pred} is not in the card's (capped) predicate list"),
2064                    revision,
2065                    &binds,
2066                ),
2067            };
2068        }
2069    }
2070
2071    // --- 4. A property path can only match if the steps it insists on exist.
2072    if let Some(path) = bound(&binds, "WKT_PATH") {
2073        let missing: Vec<String> = required_path_steps(path)
2074            .into_iter()
2075            .filter(|iri| !card.predicates.iter().any(|(p, _)| p == iri))
2076            .collect();
2077        if !missing.is_empty() && complete(card, &card.predicates) {
2078            return mk(
2079                Verdict::Empty,
2080                format!(
2081                    "the path {path} requires {} , which the card's complete predicate \
2082                     list does not contain",
2083                    missing.join(", ")
2084                ),
2085                revision,
2086                &binds,
2087            );
2088        }
2089    }
2090
2091    // --- 5. A pinned VALUES list must cover the gate that admitted the query.
2092    if let Some(listed) = values_predicates(body) {
2093        let present: Vec<&String> = card
2094            .signals
2095            .link_predicates
2096            .iter()
2097            .filter(|p| listed.contains(p))
2098            .collect();
2099        if present.is_empty() {
2100            return mk(
2101                Verdict::Empty,
2102                format!(
2103                    "the body pins VALUES ?p to {} , none of which this dataset uses \
2104                     (it links with {})",
2105                    listed.join(" "),
2106                    card.signals.link_predicates.join(" ")
2107                ),
2108                revision,
2109                &binds,
2110            );
2111        }
2112    }
2113
2114    // --- 6. "Which predicates point OUT?" needs something outside the base IRI.
2115    if q.id == "lk-external" && !caps.external_iri {
2116        let why = "no IRI the card records lies outside the dataset's base IRI".to_string();
2117        return if complete(card, &card.in_hubs) && complete(card, &card.classes) {
2118            mk(
2119                Verdict::Empty,
2120                format!("{why}, and both lists are complete — the FILTER keeps nothing"),
2121                revision,
2122                &binds,
2123            )
2124        } else {
2125            mk(
2126                Verdict::Suspect,
2127                format!("{why}, but the lists are truncated so one could have fallen off"),
2128                revision,
2129                &binds,
2130            )
2131        };
2132    }
2133
2134    // --- 6b. The RDF-star conjunction. `ANNOTATED_PRED` (inside the quoted
2135    //         triple) and `ANNOTATION_PRED` (outside it) are the second pair
2136    //         the card can witness, so step 7 below — which would see two
2137    //         ranked predicates and decline — must not reach them. What the
2138    //         published body actually bound is compared against the card's own
2139    //         witness: a re-derivation can move the pair, and a body carrying
2140    //         the old one is no longer proven.
2141    if let (Some(quoted), Some(about)) = (
2142        bound(&binds, "ANNOTATED_PRED"),
2143        bound(&binds, "ANNOTATION_PRED"),
2144    ) {
2145        return match annotated_statement(card) {
2146            Some((q, a)) if q == quoted && a == about => mk(
2147                Verdict::Answers,
2148                format!(
2149                    "signals.annotated_statement witnesses a {quoted} statement this graph \
2150                     both asserts and annotates with {about}"
2151                ),
2152                revision,
2153                &binds,
2154            ),
2155            Some((q, a)) => mk(
2156                Verdict::Suspect,
2157                format!(
2158                    "the body pins {quoted} / {about}, but the card's witnessed pair is \
2159                     {q} / {a} — nothing here proves these two meet on one statement"
2160                ),
2161                revision,
2162                &binds,
2163            ),
2164            None => mk(
2165                Verdict::Undecidable,
2166                "the card records no annotated-statement witness, so it cannot say whether \
2167                 the quoted predicate and the annotating one ever meet"
2168                    .to_string(),
2169                revision,
2170                &binds,
2171            ),
2172        };
2173    }
2174
2175    // --- 7. Two independently-ranked terms conjoined, with nothing in the card
2176    //        able to relate them: the shape, without a decision.
2177    let ranked = distinct_ranked(card, &binds);
2178    if ranked.len() > 1 {
2179        return mk(
2180            Verdict::Undecidable,
2181            format!(
2182                "conjoins {} , drawn from different rankings — the card relates no \
2183                 specific subject to a specific predicate",
2184                ranked.join(" and ")
2185            ),
2186            revision,
2187            &binds,
2188        );
2189    }
2190
2191    // --- 8. An un-grouped aggregate that joins two predicates on one subject
2192    //        always returns its row; whether the row says anything is another
2193    //        matter, and one no card can settle (`class_links` collapses every
2194    //        untyped subject into one bucket, so rows for the two predicates
2195    //        under it are not evidence that any single subject carries both).
2196    if matches!(t.nonempty, NonEmpty::Aggregate)
2197        && body.contains(" ; ")
2198        && !body.contains("GROUP BY")
2199    {
2200        return mk(
2201            Verdict::Undecidable,
2202            "an un-grouped aggregate always returns one row, but its body conjoins two \
2203             predicates the card only counted separately — the row may be vacuous"
2204                .to_string(),
2205            revision,
2206            &binds,
2207        );
2208    }
2209
2210    // --- 9. Nothing above bit: the template's own claim stands.
2211    match t.nonempty {
2212        NonEmpty::Undecidable(reason) => {
2213            mk(Verdict::Undecidable, reason.to_string(), revision, &binds)
2214        }
2215        NonEmpty::AnyGraph | NonEmpty::Aggregate | NonEmpty::Witnessed => mk(
2216            Verdict::Answers,
2217            match t.nonempty {
2218                NonEmpty::AnyGraph => "any non-empty graph answers this",
2219                NonEmpty::Aggregate => "an un-grouped aggregate always returns one row",
2220                _ => "every substituted term came from a count the card records as > 0",
2221            }
2222            .to_string(),
2223            revision,
2224            &binds,
2225        ),
2226    }
2227}
2228
2229#[cfg(test)]
2230mod tests {
2231    use super::*;
2232    use crate::card_derive::{derive_card, CardInput};
2233    use crate::{eval_query, ingest, summary_query_shape, QueryOutput, Rete};
2234
2235    const TYPE: &str = "<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>";
2236
2237    fn q(s: &str, p: &str, o: &str) -> (String, String, String, Option<String>) {
2238        (s.into(), p.into(), o.into(), None)
2239    }
2240
2241    /// Ids whose template admits it cannot prove non-emptiness. The allow-list
2242    /// is **read off the table** rather than written out here, so a template
2243    /// cannot quietly join it: adding `NonEmpty::Undecidable` to one means
2244    /// writing down the reason, in the source, next to the body.
2245    fn may_be_empty(id: &str) -> bool {
2246        claim_of(id) == Claim::Undecidable
2247    }
2248
2249    /// The property that would have caught the named-graph-only card bug — and
2250    /// the `lb-labels` one: every query the card ships **returns a non-empty
2251    /// result** when run against the very graph it was generated for.
2252    ///
2253    /// The only exemptions are the templates that declare themselves
2254    /// undecidable; everything else answering nothing is a generator bug, not a
2255    /// fact about the dataset.
2256    fn assert_every_query_returns_rows(card: &DatasetCard, rete: &Rete) {
2257        assert!(!card.queries.is_empty(), "card generated no queries at all");
2258        for eq in &card.queries {
2259            let allow_empty = may_be_empty(&eq.id);
2260            match eval_query(rete, &eq.sparql) {
2261                Ok(QueryOutput::Select(_, rows)) => assert!(
2262                    allow_empty || !rows.is_empty(),
2263                    "{}: returned zero rows:\n{}",
2264                    eq.id,
2265                    eq.sparql
2266                ),
2267                Ok(QueryOutput::Ask(b)) => assert!(allow_empty || b, "{}: ASK false", eq.id),
2268                Ok(QueryOutput::Construct(ts)) => {
2269                    assert!(
2270                        allow_empty || !ts.is_empty(),
2271                        "{}: constructed nothing",
2272                        eq.id
2273                    )
2274                }
2275                // No catch-all arm: `QueryOutput`'s `#[non_exhaustive]` does not
2276                // apply inside its own crate, so the three arms above are
2277                // exhaustive here — and a fourth result form should red this
2278                // test until someone decides what the query library does with it.
2279                Err(e) => panic!("{}: failed to run: {e:?}", eq.id),
2280            }
2281        }
2282    }
2283
2284    /// Build the fixture and return `(card, image)` — the pair every
2285    /// returns-rows test needs.
2286    fn card_and_graph(
2287        quads: Vec<(String, String, String, Option<String>)>,
2288        term_count: u64,
2289        named_graphs: u64,
2290    ) -> (DatasetCard, Rete) {
2291        let card = derive_card(&quads, term_count, named_graphs, CardInput::default());
2292        let (bytes, _) =
2293            ingest::assemble_dataset_with_opts(quads, true, false, None, |_, _| Vec::new());
2294        (card, Rete::open(&bytes).unwrap())
2295    }
2296
2297    fn sparql_of<'a>(card: &'a DatasetCard, id: &str) -> Option<&'a str> {
2298        card.queries
2299            .iter()
2300            .find(|q| q.id == id)
2301            .map(|q| q.sparql.as_str())
2302    }
2303
2304    /// The single COUNT value of an aggregate query's one-row result.
2305    fn count_value(rete: &Rete, sparql: &str, var: &str) -> u64 {
2306        match eval_query(rete, sparql).unwrap() {
2307            QueryOutput::Select(_, rows) => {
2308                assert_eq!(rows.len(), 1, "expected one aggregate row");
2309                let lit = rows[0].get(var).expect("aggregate variable bound");
2310                lit.trim_start_matches('"')
2311                    .split('"')
2312                    .next()
2313                    .unwrap()
2314                    .parse()
2315                    .expect("count literal parses")
2316            }
2317            other => panic!("expected a SELECT result, got {other:?}"),
2318        }
2319    }
2320
2321    /// A fixture exercising every signal so (almost) every template emits.
2322    fn rich_quads() -> Vec<(String, String, String, Option<String>)> {
2323        let label = "<http://www.w3.org/2000/01/rdf-schema#label>";
2324        let sameas = "<http://www.w3.org/2002/07/owl#sameAs>";
2325        let lat = "<http://www.w3.org/2003/01/geo/wgs84_pos#lat>";
2326        let long = "<http://www.w3.org/2003/01/geo/wgs84_pos#long>";
2327        let aswkt = "<http://www.opengis.net/ont/geosparql#asWKT>";
2328        let hasgeom = "<http://www.opengis.net/ont/geosparql#hasGeometry>";
2329        let gyear = "http://www.w3.org/2001/XMLSchema#gYear";
2330        let xint = "http://www.w3.org/2001/XMLSchema#integer";
2331        let wkt = "http://www.opengis.net/ont/geosparql#wktLiteral";
2332        let dbl = "http://www.w3.org/2001/XMLSchema#double";
2333        let mut v = Vec::new();
2334        for i in 0..6 {
2335            let s = format!("<http://ex/person/{i}>");
2336            v.push(q(&s, TYPE, "<http://ex/Person>"));
2337            v.push(q(&s, label, &format!("\"Person {i}\"@en")));
2338            v.push(q(&s, label, &format!("\"Personne {i}\"@fr")));
2339            v.push(q(
2340                &s,
2341                "<http://ex/birthYear>",
2342                &format!("\"{}\"^^<{gyear}>", 1800 + i),
2343            ));
2344            v.push(q(
2345                &s,
2346                "<http://ex/friendCount>",
2347                &format!("\"{}\"^^<{xint}>", i * 3),
2348            ));
2349            v.push(q(
2350                &s,
2351                "<http://ex/knows>",
2352                &format!("<http://ex/person/{}>", (i + 1) % 6),
2353            ));
2354            v.push(q(&s, "<http://ex/bornIn>", "<http://ex/place/0>"));
2355            if i % 2 == 0 {
2356                v.push(q(
2357                    &s,
2358                    sameas,
2359                    &format!("<http://www.wikidata.org/entity/Q{}>", 100 + i),
2360                ));
2361            }
2362        }
2363        v.push(q("<http://ex/place/0>", TYPE, "<http://ex/Place>"));
2364        v.push(q("<http://ex/place/0>", label, "\"Rome\"@en"));
2365        v.push(q("<http://ex/place/0>", lat, &format!("\"41.9\"^^<{dbl}>")));
2366        v.push(q(
2367            "<http://ex/place/0>",
2368            long,
2369            &format!("\"12.5\"^^<{dbl}>"),
2370        ));
2371        v.push(q(
2372            "<http://ex/place/0>",
2373            hasgeom,
2374            "<http://ex/place/0/geom>",
2375        ));
2376        v.push(q(
2377            "<http://ex/place/0/geom>",
2378            aswkt,
2379            &format!("\"POINT(12.5 41.9)\"^^<{wkt}>"),
2380        ));
2381        v
2382    }
2383
2384    #[test]
2385    fn generated_queries_parse_run_and_are_tiered() {
2386        let quads = rich_quads();
2387        let card = derive_card(&quads, 50, 0, CardInput::default());
2388        assert!(
2389            card.queries.len() >= 12,
2390            "expected >= 12 starter queries, got {}",
2391            card.queries.len()
2392        );
2393
2394        let (bytes, _) =
2395            ingest::assemble_dataset_with_opts(quads, true, false, None, |_, _| Vec::new());
2396        let rete = Rete::open(&bytes).unwrap();
2397
2398        for eq in &card.queries {
2399            assert!(
2400                eq.sparql.starts_with("PREFIX"),
2401                "{}: no PREFIX block",
2402                eq.id
2403            );
2404            assert!(
2405                !eq.sparql.contains("{{"),
2406                "{}: unsubstituted placeholder",
2407                eq.id
2408            );
2409            assert!(!eq.dimension.is_empty(), "{}: missing dimension", eq.id);
2410
2411            // Parses AND runs against the real engine (>= 0 rows, never an error).
2412            let result = eval_query(&rete, &eq.sparql);
2413            assert!(
2414                result.is_ok(),
2415                "{}: failed to run: {:?}",
2416                eq.id,
2417                result.err()
2418            );
2419
2420            // Every Summary-tagged query is recognized by the index-free summary
2421            // classifier — the tier is truthful, not aspirational.
2422            if eq.tier == Tier::Summary {
2423                let shape = summary_query_shape(&eq.sparql);
2424                assert!(
2425                    matches!(shape, Ok(Some(_))),
2426                    "{}: tagged Summary but not summary-shaped: {:?}",
2427                    eq.id,
2428                    shape
2429                );
2430            }
2431        }
2432
2433        // The default-only scope gets the same returns-rows contract the
2434        // named-only and mixed ones have had since #154 — the gap through which
2435        // `lb-labels`, `top-reach` and `sp-within` shipped broken on
2436        // default-graph files, which is the population most datasets are in.
2437        assert_every_query_returns_rows(&card, &rete);
2438    }
2439
2440    /// Every statement in a named graph, the default graph empty — the shape
2441    /// (e.g. nkod.rete) whose cards used to ship six guaranteed-zero-rows
2442    /// queries. Includes an IRI that is referenced but never described, so
2443    /// `top-dangling` has rows to find.
2444    fn named_only_quads() -> Vec<(String, String, String, Option<String>)> {
2445        let mut v = Vec::new();
2446        for gi in 0..3 {
2447            let g = format!("<http://ex/graph/{gi}>");
2448            for i in 0..4 {
2449                let s = format!("<http://ex/item/{gi}-{i}>");
2450                v.push((
2451                    s.clone(),
2452                    TYPE.to_string(),
2453                    "<http://ex/Item>".to_string(),
2454                    Some(g.clone()),
2455                ));
2456                v.push((
2457                    s.clone(),
2458                    "<http://ex/rel>".to_string(),
2459                    format!("<http://ex/item/{gi}-{}>", (i + 1) % 4),
2460                    Some(g.clone()),
2461                ));
2462                v.push((
2463                    s,
2464                    "<http://ex/ref>".to_string(),
2465                    "<http://other/never-described>".to_string(),
2466                    Some(g.clone()),
2467                ));
2468            }
2469        }
2470        v
2471    }
2472
2473    #[test]
2474    fn named_graph_only_queries_are_scoped_and_return_rows() {
2475        let quads = named_only_quads();
2476        let total = quads.len() as u64;
2477        let card = derive_card(&quads, 20, 3, CardInput::default());
2478        // The precondition of the bug: the card itself knows the default graph
2479        // is empty and the data lives in named graphs.
2480        assert_eq!(card.triple_count, 0);
2481        assert_eq!(card.named_graph_count, 3);
2482
2483        // No emitted query may scan only the (empty) default graph.
2484        for eq in &card.queries {
2485            assert!(
2486                eq.sparql.contains("GRAPH"),
2487                "{}: default-graph-only query on a named-graph-only file:\n{}",
2488                eq.id,
2489                eq.sparql
2490            );
2491            // The pyramid summary covers the default graph only, so no
2492            // GRAPH-scoped query may claim the Summary tier.
2493            assert_ne!(eq.tier, Tier::Summary, "{}: untruthful Summary tier", eq.id);
2494        }
2495
2496        let (bytes, _) =
2497            ingest::assemble_dataset_with_opts(quads, true, false, None, |_, _| Vec::new());
2498        let rete = Rete::open(&bytes).unwrap();
2499        assert_every_query_returns_rows(&card, &rete);
2500
2501        // The headline count now reflects the data, not the empty default graph.
2502        let ov = card.queries.iter().find(|q| q.id == "ov-triples").unwrap();
2503        assert_eq!(count_value(&rete, &ov.sparql, "n"), total);
2504    }
2505
2506    #[test]
2507    fn mixed_graph_queries_cover_both_sides_and_return_rows() {
2508        // Data in BOTH the default graph and a named graph.
2509        let mut quads = Vec::new();
2510        let label = "<http://www.w3.org/2000/01/rdf-schema#label>";
2511        for i in 0..4 {
2512            let s = format!("<http://ex/doc/{i}>");
2513            quads.push(q(&s, TYPE, "<http://ex/Doc>"));
2514            quads.push(q(&s, label, &format!("\"Doc {i}\"@en")));
2515            quads.push(q(
2516                &s,
2517                "<http://ex/rel>",
2518                &format!("<http://ex/doc/{}>", (i + 1) % 4),
2519            ));
2520            quads.push(q(&s, "<http://ex/rel>", "<http://other/never-described>"));
2521        }
2522        let default_count = quads.len() as u64;
2523        let g = "<http://ex/graph/annotations>".to_string();
2524        for i in 0..5 {
2525            quads.push((
2526                format!("<http://ex/note/{i}>"),
2527                "<http://ex/about>".to_string(),
2528                format!("<http://ex/doc/{}>", i % 4),
2529                Some(g.clone()),
2530            ));
2531        }
2532        let total = quads.len() as u64;
2533
2534        let card = derive_card(&quads, 30, 1, CardInput::default());
2535        assert_eq!(card.triple_count, default_count);
2536        assert_eq!(card.quad_count, total);
2537        assert_eq!(card.named_graph_count, 1);
2538
2539        let (bytes, _) =
2540            ingest::assemble_dataset_with_opts(quads, true, false, None, |_, _| Vec::new());
2541        let rete = Rete::open(&bytes).unwrap();
2542        assert_every_query_returns_rows(&card, &rete);
2543
2544        // The overview count covers BOTH sides — neither half silently hidden.
2545        let ov = card.queries.iter().find(|q| q.id == "ov-triples").unwrap();
2546        assert_eq!(count_value(&rete, &ov.sparql, "n"), total);
2547        // And the named-graph family is present alongside the profile queries.
2548        let ids: Vec<&str> = card.queries.iter().map(|q| q.id.as_str()).collect();
2549        for id in ["ng-list", "ng-sizes", "ng-sample", "id-sample", "lb-labels"] {
2550            assert!(ids.contains(&id), "{id} missing from a mixed-graph card");
2551        }
2552    }
2553
2554    #[test]
2555    fn default_graph_bodies_are_unchanged() {
2556        // A graph with no named graphs must get the classic bodies verbatim —
2557        // scope awareness may not perturb existing default-graph cards.
2558        let quads = rich_quads();
2559        let card = derive_card(&quads, 50, 0, CardInput::default());
2560        let ov = card.queries.iter().find(|q| q.id == "ov-triples").unwrap();
2561        assert!(ov
2562            .sparql
2563            .ends_with("SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }"));
2564        assert_eq!(ov.tier, Tier::Summary);
2565        for eq in &card.queries {
2566            assert!(
2567                !eq.sparql.contains("GRAPH ?g") && !eq.sparql.contains("UNION"),
2568                "{}: graph-scoped body leaked into a default-graph card",
2569                eq.id
2570            );
2571        }
2572    }
2573
2574    /// The one-row smoke query returns EXACTLY one row in every graph scope —
2575    /// the unambiguous "did this file open and answer?" probe (a COUNT returns
2576    /// an honest 0 on a named-graph-only file and reads as failure).
2577    #[test]
2578    fn one_row_smoke_query_returns_exactly_one_row_in_every_scope() {
2579        type Quads = Vec<(String, String, String, Option<String>)>;
2580        let cases: Vec<(Quads, u64)> = vec![
2581            (rich_quads(), 0),       // default-only
2582            (named_only_quads(), 3), // named-graph-only
2583        ];
2584        for (quads, ng) in cases {
2585            let card = derive_card(&quads, 50, ng, CardInput::default());
2586            let smoke = card
2587                .queries
2588                .iter()
2589                .find(|q| q.id == "ov-one-row")
2590                .expect("ov-one-row always emits");
2591            let (bytes, _) =
2592                ingest::assemble_dataset_with_opts(quads, true, false, None, |_, _| Vec::new());
2593            let rete = Rete::open(&bytes).unwrap();
2594            match eval_query(&rete, &smoke.sparql).unwrap() {
2595                QueryOutput::Select(_, rows) => {
2596                    assert_eq!(rows.len(), 1, "ov-one-row must return exactly one row")
2597                }
2598                other => panic!("unexpected result form: {other:?}"),
2599            }
2600        }
2601    }
2602
2603    #[test]
2604    fn families_are_gated_by_signal() {
2605        // A bare graph: no labels, no time, no geometry, no links, no named graphs.
2606        let quads = vec![
2607            q("<http://ex/a>", TYPE, "<http://ex/T>"),
2608            q("<http://ex/a>", "<http://ex/rel>", "<http://ex/b>"),
2609        ];
2610        let card = derive_card(&quads, 4, 0, CardInput::default());
2611        let ids: Vec<&str> = card.queries.iter().map(|q| q.id.as_str()).collect();
2612
2613        // Signal-gated families are absent.
2614        for absent in [
2615            "sp-bbox",
2616            "sp-within",
2617            "ti-extent",
2618            "ti-histogram",
2619            "lb-labels",
2620            "ng-list",
2621            "ng-sample",
2622        ] {
2623            assert!(!ids.contains(&absent), "{absent} should be gated out");
2624        }
2625        // Unconditional overview queries are still present.
2626        for present in ["ov-triples", "ov-pred-list", "ov-pred-hist"] {
2627            assert!(ids.contains(&present), "{present} should always emit");
2628        }
2629        // Every object IRI is inside the dataset's own namespace, so "which
2630        // predicates link out?" has nothing to find — and is not emitted.
2631        assert!(
2632            !ids.contains(&"lk-external"),
2633            "lk-external has no external IRI"
2634        );
2635    }
2636
2637    // ---------------------------------------------------------------------
2638    // The co-occurrence property: a body may conjoin only what the card proves
2639    // meets. Three fixtures, each reproducing the shape of a real dataset whose
2640    // published card ships a query that answers nothing.
2641    // ---------------------------------------------------------------------
2642
2643    /// The **mtg** shape: the most populous class is NOT the one that carries
2644    /// the label predicate.
2645    ///
2646    /// `mtg:Ruling` has 76,990 instances and no `schema:name`; `mtg:Card` and
2647    /// its subclasses are fewer and carry all the names. A card generated from
2648    /// this graph must not join the top class to the label predicate, because
2649    /// the two never meet — that query returns zero rows on a plain
2650    /// default-graph file, and a reader concludes the file is broken.
2651    fn mtg_shaped_quads() -> Vec<(String, String, String, Option<String>)> {
2652        let name = "<http://schema.org/name>";
2653        let mut v = Vec::new();
2654        // 30 Rulings: the most populous class, no name anywhere on it.
2655        for i in 0..30 {
2656            let s = format!("<http://ex/ruling/{i}>");
2657            v.push(q(&s, TYPE, "<http://ex/Ruling>"));
2658            v.push(q(&s, "<http://ex/text>", &format!("\"Ruling text {i}\"")));
2659            v.push(q(
2660                &s,
2661                "<http://ex/about>",
2662                &format!("<http://ex/card/{}>", i % 5),
2663            ));
2664        }
2665        // 5 Cards: fewer instances, and the only things with a name.
2666        for i in 0..5 {
2667            let s = format!("<http://ex/card/{i}>");
2668            v.push(q(&s, TYPE, "<http://ex/Card>"));
2669            v.push(q(&s, name, &format!("\"Card {i}\"")));
2670            v.push(q(&s, "<http://ex/legalIn>", "<http://ex/format/standard>"));
2671        }
2672        v
2673    }
2674
2675    #[test]
2676    fn top_class_without_the_label_predicate_still_answers() {
2677        let (card, rete) = card_and_graph(mtg_shaped_quads(), 60, 0);
2678
2679        // The precondition of the bug, asserted so the fixture cannot drift
2680        // into the easy case: the top class is the one WITHOUT the label.
2681        assert_eq!(card.classes[0].0, "<http://ex/Ruling>");
2682        assert_eq!(
2683            card.signals.label_predicate.as_deref(),
2684            Some("<http://schema.org/name>")
2685        );
2686        assert!(
2687            !card
2688                .class_links
2689                .iter()
2690                .any(|l| l.s_class == "<http://ex/Ruling>"
2691                    && l.predicate == "<http://schema.org/name>"),
2692            "fixture precondition: the top class carries no label"
2693        );
2694
2695        // The label query is still emitted — and it names the class that
2696        // actually has labels, not the biggest one.
2697        let lb = sparql_of(&card, "lb-labels").expect("lb-labels emitted");
2698        assert!(
2699            lb.contains("?s a <http://ex/Card> ; <http://schema.org/name> ?label"),
2700            "lb-labels must join the LABELLED class:\n{lb}"
2701        );
2702        assert!(
2703            !lb.contains("Ruling"),
2704            "the unlabelled top class leaked in:\n{lb}"
2705        );
2706
2707        // Same class for the coverage query, so the two label queries are not
2708        // measuring different things (with TOP_CLASS this reported total/0).
2709        let cov = sparql_of(&card, "cmp-coverage").expect("cmp-coverage emitted");
2710        assert!(
2711            cov.contains("<http://ex/Card>"),
2712            "cmp-coverage on the wrong class"
2713        );
2714
2715        // And the whole library answers.
2716        assert_every_query_returns_rows(&card, &rete);
2717    }
2718
2719    /// The **hugging-face** shape: a label predicate exists, but no class the
2720    /// card can see carries it (there, `rdfs:label` sits only on the embedded
2721    /// ontology terms, whose `class_links` rows fall outside the top 100).
2722    /// The label query must degrade to the class-free form, not disappear.
2723    #[test]
2724    fn labels_off_every_known_class_fall_back_to_the_class_free_query() {
2725        let label = "<http://www.w3.org/2000/01/rdf-schema#label>";
2726        let mut quads = Vec::new();
2727        for i in 0..8 {
2728            let s = format!("<http://ex/model/{i}>");
2729            quads.push(q(&s, TYPE, "<http://ex/Model>"));
2730            quads.push(q(&s, "<http://ex/downloads>", &format!("\"{i}\"")));
2731        }
2732        // The only labels in the graph hang off UNTYPED subjects, so no
2733        // `class_links` row can witness a class that carries the predicate.
2734        for i in 0..3 {
2735            quads.push(q(
2736                &format!("<http://ex/term/{i}>"),
2737                label,
2738                &format!("\"Term {i}\"@en"),
2739            ));
2740        }
2741        let (card, rete) = card_and_graph(quads, 30, 0);
2742
2743        assert_eq!(card.signals.label_predicate.as_deref(), Some(label));
2744        let lb = sparql_of(&card, "lb-labels").expect("lb-labels still emitted");
2745        assert!(
2746            lb.ends_with("SELECT ?s ?label WHERE { ?s <http://www.w3.org/2000/01/rdf-schema#label> ?label } LIMIT 50"),
2747            "expected the class-free fallback:\n{lb}"
2748        );
2749        // The fallback advertises the reduced gate it was emitted under.
2750        let req = &card
2751            .queries
2752            .iter()
2753            .find(|x| x.id == "lb-labels")
2754            .unwrap()
2755            .requires;
2756        assert_eq!(req, &["LABEL_PRED".to_string()]);
2757        // Coverage needs a labelled class and honestly has none, so it is gone
2758        // rather than reporting a percentage of the wrong class.
2759        assert!(sparql_of(&card, "cmp-coverage").is_none());
2760        assert_every_query_returns_rows(&card, &rete);
2761    }
2762
2763    /// The **geoadmin** shape: `geo:asWKT` hangs straight off each feature and
2764    /// there is no `geo:hasGeometry` anywhere, so the fixed two-step path the
2765    /// template used to write could not match a single one of its 52,959
2766    /// geometries.
2767    #[test]
2768    fn geometry_hung_directly_off_the_feature_is_reachable() {
2769        let aswkt = "<http://www.opengis.net/ont/geosparql#asWKT>";
2770        let wkt = "http://www.opengis.net/ont/geosparql#wktLiteral";
2771        let mut quads = Vec::new();
2772        for i in 0..5 {
2773            let s = format!("<http://ex/district/{i}>");
2774            quads.push(q(&s, TYPE, "<http://ex/District>"));
2775            quads.push(q(&s, aswkt, &format!("\"POINT({} 4{i})\"^^<{wkt}>", i + 7)));
2776        }
2777        let (card, rete) = card_and_graph(quads, 20, 0);
2778
2779        let sp = sparql_of(&card, "sp-within").expect("sp-within emitted");
2780        assert!(
2781            sp.contains("?s geo:asWKT ?w"),
2782            "expected the direct geometry path:\n{sp}"
2783        );
2784        assert!(
2785            !sp.contains("hasGeometry"),
2786            "a hasGeometry step the data does not have:\n{sp}"
2787        );
2788        match eval_query(&rete, sp).unwrap() {
2789            QueryOutput::Select(_, rows) => {
2790                assert_eq!(rows.len(), 5, "every feature is in the box")
2791            }
2792            other => panic!("unexpected result form: {other:?}"),
2793        }
2794        assert_every_query_returns_rows(&card, &rete);
2795    }
2796
2797    /// Both geometry layouts in one graph: the optional step binds `?s` to the
2798    /// feature either way, so neither half is hidden.
2799    #[test]
2800    fn both_geometry_layouts_are_covered_by_one_path() {
2801        let (card, rete) = card_and_graph(rich_quads(), 50, 0);
2802        let sp = sparql_of(&card, "sp-within").expect("sp-within emitted");
2803        assert!(
2804            sp.contains("?s geo:hasGeometry?/geo:asWKT ?w"),
2805            "expected the optional-step path:\n{sp}"
2806        );
2807        match eval_query(&rete, sp).unwrap() {
2808            QueryOutput::Select(_, rows) => assert!(!rows.is_empty()),
2809            other => panic!("unexpected result form: {other:?}"),
2810        }
2811    }
2812
2813    /// A graph of pure values — every object a literal, no `rdf:type` — has no
2814    /// group for the in-degree query and no IRI for the dangling-IRI one. The
2815    /// card knows (`in_hubs` is that precompute), so the generator refuses to
2816    /// emit either rather than shipping two queries that answer nothing.
2817    #[test]
2818    fn a_literal_only_graph_gets_no_object_queries() {
2819        let quads: Vec<_> = (0..6)
2820            .map(|i| {
2821                q(
2822                    &format!("<http://ex/row/{i}>"),
2823                    "<http://ex/value>",
2824                    &format!("\"v{i}\""),
2825                )
2826            })
2827            .collect();
2828        let (card, rete) = card_and_graph(quads, 12, 0);
2829
2830        assert!(
2831            card.in_hubs.is_empty(),
2832            "fixture precondition: no IRI objects"
2833        );
2834        let ids: Vec<&str> = card.queries.iter().map(|x| x.id.as_str()).collect();
2835        for absent in ["top-in-hubs", "top-dangling"] {
2836            assert!(!ids.contains(&absent), "{absent} would return zero rows");
2837        }
2838        assert_every_query_returns_rows(&card, &rete);
2839    }
2840
2841    // ---------------------------------------------------------------------
2842    // RDF-star: the `qt-*` family.
2843    // ---------------------------------------------------------------------
2844
2845    const RECORDED_BY: &str = "<http://ex/recordedBy>";
2846    const CONFIDENCE: &str = "<http://ex/confidence>";
2847    const STATES: &str = "<http://ex/states>";
2848    const COUNT_OF: &str = "<http://ex/count>";
2849
2850    /// A provenance graph: every occurrence is typed, most of those type
2851    /// assertions are themselves annotated with who recorded them, and two
2852    /// claims hold a statement as their *value* — the object position, which
2853    /// is a different capability from the subject one.
2854    ///
2855    /// Deliberately **partial**: `occ4`'s type assertion carries no annotation,
2856    /// so `qt-coverage` reports a real ratio instead of a vacuous 100 %.
2857    fn annotated_quads() -> Vec<(String, String, String, Option<String>)> {
2858        let mut v = Vec::new();
2859        for i in 0..4 {
2860            let s = format!("<http://ex/occ/{i}>");
2861            let species = if i < 2 {
2862                "<http://ex/Swallow>"
2863            } else {
2864                "<http://ex/Robin>"
2865            };
2866            v.push(q(&s, TYPE, species));
2867            v.push(q(&s, COUNT_OF, &format!("\"{}\"", i + 1)));
2868            // The star layer: a statement about the type assertion above.
2869            if i < 3 {
2870                let quoted = format!("<<{s} {TYPE} {species}>>");
2871                v.push(q(
2872                    &quoted,
2873                    RECORDED_BY,
2874                    if i == 0 {
2875                        "<http://ex/jsmith>"
2876                    } else {
2877                        "<http://ex/adoe>"
2878                    },
2879                ));
2880                if i == 0 {
2881                    v.push(q(&quoted, CONFIDENCE, "\"0.9\""));
2882                }
2883            }
2884            // …and the other position: a claim whose VALUE is a statement.
2885            if i < 2 {
2886                v.push(q(
2887                    &format!("<http://ex/claim/{i}>"),
2888                    STATES,
2889                    &format!("<<{s} {COUNT_OF} \"{}\">>", i + 1),
2890                ));
2891            }
2892        }
2893        v
2894    }
2895
2896    /// The family emits, every query answers on the graph it came from, and
2897    /// the coverage aggregate reports the ratio the fixture really has.
2898    #[test]
2899    fn statement_queries_are_generated_and_return_rows() {
2900        let quads = annotated_quads();
2901        let (card, rete) = card_and_graph(quads, 40, 0);
2902
2903        // The derive-time profile, which is what makes any of this possible:
2904        // the header's presence bit names no vocabulary.
2905        let s = &card.signals;
2906        assert_eq!(s.annotation_predicates, vec![RECORDED_BY, CONFIDENCE]);
2907        assert_eq!(s.quoting_predicates, vec![STATES]);
2908        assert_eq!(
2909            s.annotated_statement,
2910            Some((TYPE.to_string(), RECORDED_BY.to_string())),
2911            "the witness must be a statement the graph BOTH asserts and annotates"
2912        );
2913
2914        let ids: Vec<&str> = card.queries.iter().map(|x| x.id.as_str()).collect();
2915        for want in [
2916            "qt-sample",
2917            "qt-about",
2918            "qt-qualifiers",
2919            "qt-coverage",
2920            "qt-quoted-object",
2921        ] {
2922            assert!(ids.contains(&want), "{want} should be emitted: {ids:?}");
2923        }
2924        for eq in card.queries.iter().filter(|x| x.id.starts_with("qt-")) {
2925            assert_eq!(eq.dimension, "statements");
2926            // The query surface is RDF-star (`<< s p o >>`). The ratified
2927            // RDF 1.2 spelling `<<( s p o )>>` is an EXPORT surface; a body in
2928            // it would not parse, which is the worst content a card can carry.
2929            assert!(
2930                !eq.sparql.contains("<<("),
2931                "{}: RDF 1.2 triple-term syntax in a QUERY",
2932                eq.id
2933            );
2934        }
2935
2936        // The whole point: every one of them answers on this graph.
2937        assert_every_query_returns_rows(&card, &rete);
2938
2939        // And the coverage aggregate counts what the fixture actually holds:
2940        // four type assertions, three of them annotated.
2941        let cov = sparql_of(&card, "qt-coverage").unwrap();
2942        assert_eq!(count_value(&rete, cov, "asserted"), 4);
2943        assert_eq!(count_value(&rete, cov, "qualified"), 3);
2944    }
2945
2946    /// The overwhelmingly common case: no quoted triples, so no `qt-*` query,
2947    /// no empty section, and — because all three new signal fields serialize
2948    /// only when non-empty — a card byte-identical to one built before the
2949    /// family existed.
2950    #[test]
2951    fn a_graph_without_quoted_triples_gains_nothing() {
2952        let quads = rich_quads();
2953        let card = derive_card(&quads, 50, 0, CardInput::default());
2954        let s = &card.signals;
2955        assert!(s.annotation_predicates.is_empty());
2956        assert!(s.quoting_predicates.is_empty());
2957        assert!(s.annotated_statement.is_none());
2958        assert!(
2959            !card.queries.iter().any(|x| x.id.starts_with("qt-")),
2960            "a graph with no quoted triple must gain no statement query"
2961        );
2962        // The card's own bytes say nothing about a layer it does not have.
2963        let json = String::from_utf8(card.to_json_bytes()).unwrap();
2964        for key in [
2965            "annotation_predicates",
2966            "quoting_predicates",
2967            "annotated_statement",
2968        ] {
2969            assert!(!json.contains(key), "{key} reached a card that has none");
2970        }
2971    }
2972
2973    /// Presence is not position. A graph that only ever holds a statement as a
2974    /// *value* has nothing for a subject-position body to match — the header's
2975    /// `FLAG_HAS_QUOTED_TRIPLES` cannot tell the two apart, and a query written
2976    /// for the wrong one is exactly the zero-rows failure this library exists
2977    /// to prevent.
2978    #[test]
2979    fn a_statement_held_only_as_a_value_gets_only_the_object_position_query() {
2980        let quads = vec![
2981            q("<http://ex/a>", TYPE, "<http://ex/T>"),
2982            q("<http://ex/a>", "<http://ex/p>", "<http://ex/b>"),
2983            q(
2984                "<http://ex/claim>",
2985                STATES,
2986                "<<<http://ex/a> <http://ex/p> <http://ex/b>>>",
2987            ),
2988        ];
2989        let (card, rete) = card_and_graph(quads, 12, 0);
2990        assert!(card.signals.annotation_predicates.is_empty());
2991        assert_eq!(card.signals.quoting_predicates, vec![STATES]);
2992
2993        let ids: Vec<&str> = card.queries.iter().map(|x| x.id.as_str()).collect();
2994        assert!(ids.contains(&"qt-quoted-object"));
2995        for absent in ["qt-sample", "qt-about", "qt-qualifiers", "qt-coverage"] {
2996            assert!(
2997                !ids.contains(&absent),
2998                "{absent} has no subject-position statement to match"
2999            );
3000        }
3001        assert_every_query_returns_rows(&card, &rete);
3002    }
3003
3004    /// A statement this graph annotates but never asserts cannot witness the
3005    /// coverage query: `?s p ?o` would match nothing and the aggregate would
3006    /// report `0 / 0`, which is the `mtg` "76990 / 0" failure one level up.
3007    /// The other four still emit — the quoted layer is real, it is only the
3008    /// join to the asserted one that is missing.
3009    #[test]
3010    fn an_annotation_of_an_unasserted_statement_witnesses_no_coverage() {
3011        let quads = vec![
3012            q("<http://ex/a>", TYPE, "<http://ex/T>"),
3013            q(
3014                "<<<http://ex/a> <http://ex/p> <http://ex/b>>>",
3015                RECORDED_BY,
3016                "<http://ex/jsmith>",
3017            ),
3018        ];
3019        let (card, rete) = card_and_graph(quads, 10, 0);
3020        assert_eq!(card.signals.annotation_predicates, vec![RECORDED_BY]);
3021        assert!(
3022            card.signals.annotated_statement.is_none(),
3023            "nothing asserts <http://ex/a> <http://ex/p> <http://ex/b>"
3024        );
3025        let ids: Vec<&str> = card.queries.iter().map(|x| x.id.as_str()).collect();
3026        assert!(
3027            !ids.contains(&"qt-coverage"),
3028            "no witness, no coverage query"
3029        );
3030        for want in ["qt-sample", "qt-about", "qt-qualifiers"] {
3031            assert!(ids.contains(&want), "{want} needs only the star layer");
3032        }
3033        assert_every_query_returns_rows(&card, &rete);
3034    }
3035
3036    /// The audit reaches the RDF-star conjunction through the card's witness
3037    /// rather than through step 7, which would see two ranked predicates and
3038    /// decline — and it stops vouching for a published body whose pair the
3039    /// card no longer witnesses.
3040    #[test]
3041    fn the_audit_decides_the_star_conjunction_from_the_witness() {
3042        let mut card = derive_card(&annotated_quads(), 40, 0, CardInput::default());
3043        let findings = audit(&card);
3044        assert_eq!(verdict_of(&findings, "qt-coverage"), Verdict::Answers);
3045
3046        // The same body on a card whose witness has moved: the pair is no
3047        // longer proven, and the audit says so instead of vouching for it.
3048        card.signals.annotated_statement = Some((COUNT_OF.to_string(), CONFIDENCE.to_string()));
3049        let findings = audit(&card);
3050        assert_eq!(verdict_of(&findings, "qt-coverage"), Verdict::Suspect);
3051    }
3052
3053    /// The table-level audit: nothing in the library may conjoin two
3054    /// independently-chosen substitutions, and every placeholder must be
3055    /// declared. This is the check that makes the bug unrepresentable rather
3056    /// than merely fixed — it runs over every body of every template, in every
3057    /// graph scope, with no dataset involved.
3058    #[test]
3059    fn every_template_body_is_witnessed() {
3060        let mut problems = Vec::new();
3061        for t in TEMPLATES {
3062            for body in [Some(t.body), t.named_body, t.mixed_body]
3063                .into_iter()
3064                .flatten()
3065            {
3066                if let Err(e) = check_body(t.id, body, t.requires, t.nonempty) {
3067                    problems.push(e);
3068                }
3069            }
3070            if let Some(f) = &t.fallback {
3071                if let Err(e) = check_body(t.id, f.body, f.requires, f.nonempty) {
3072                    problems.push(e);
3073                }
3074                assert!(
3075                    f.requires.len() < t.requires.len(),
3076                    "{}: a fallback must need LESS than the template it stands in for",
3077                    t.id
3078                );
3079            }
3080        }
3081        assert!(problems.is_empty(), "{}", problems.join("\n"));
3082    }
3083
3084    // ---------------------------------------------------------------------
3085    // The audit: reading a card that is already published.
3086    // ---------------------------------------------------------------------
3087
3088    /// Replace one starter query's body with the one an older revision wrote —
3089    /// how a fixture stands in for a file that is already on the wire.
3090    fn ship_instead(card: &mut DatasetCard, id: &str, body: &str) {
3091        let q = card
3092            .queries
3093            .iter_mut()
3094            .find(|q| q.id == id)
3095            .unwrap_or_else(|| panic!("{id} is not in this card"));
3096        q.sparql = format!("{PREFIXES}\n{body}");
3097    }
3098
3099    fn verdict_of(findings: &[Finding], id: &str) -> Verdict {
3100        findings
3101            .iter()
3102            .find(|f| f.id == id)
3103            .unwrap_or_else(|| panic!("{id} was not audited"))
3104            .verdict
3105    }
3106
3107    fn rows_of(rete: &Rete, sparql: &str) -> usize {
3108        match eval_query(rete, sparql).unwrap() {
3109            QueryOutput::Select(_, rows) => rows.len(),
3110            QueryOutput::Ask(b) => usize::from(b),
3111            other => panic!("unexpected result form: {other:?}"),
3112        }
3113    }
3114
3115    /// A card this generator just wrote must audit clean: nothing refuted,
3116    /// nothing suspected, nothing unrecognized, and every body current. If the
3117    /// audit and the generator ever disagree about a *fresh* card, one of them
3118    /// is wrong — and they are supposed to share the judgement.
3119    #[test]
3120    fn a_freshly_generated_card_audits_clean() {
3121        for (quads, ng) in [(rich_quads(), 0), (named_only_quads(), 3)] {
3122            let card = derive_card(&quads, 50, ng, CardInput::default());
3123            for f in audit(&card) {
3124                assert_eq!(
3125                    f.revision, "current",
3126                    "{}: the generator would not write this body",
3127                    f.id
3128                );
3129                assert!(
3130                    matches!(f.verdict, Verdict::Answers | Verdict::Undecidable),
3131                    "{}: {} — {}",
3132                    f.id,
3133                    f.verdict.as_str(),
3134                    f.why
3135                );
3136                // The only queries a fresh card may leave undecided are the
3137                // ones the table already says it cannot decide, plus `sp-bbox`:
3138                // its un-grouped aggregate always returns its row (so
3139                // `NonEmpty::Aggregate` is true about *rows*), and whether that
3140                // row is bound turns on two predicates meeting on one subject,
3141                // which is the one thing `class_links` cannot witness.
3142                if f.verdict == Verdict::Undecidable {
3143                    assert!(
3144                        may_be_empty(&f.id) || f.id == "sp-bbox",
3145                        "{}: undecidable but the table claims otherwise — {}",
3146                        f.id,
3147                        f.why
3148                    );
3149                }
3150            }
3151        }
3152    }
3153
3154    /// The **mtg** card as published: the label query joins the most populous
3155    /// class to the most-used label predicate. The audit refutes it, and the
3156    /// engine agrees — the verdict is checked against the graph, not just
3157    /// against the card.
3158    #[test]
3159    fn the_audit_refutes_a_label_query_on_an_unlabelled_top_class() {
3160        let quads = mtg_shaped_quads();
3161        let (mut card, rete) = card_and_graph(quads, 60, 0);
3162        let top = card.classes[0].0.clone();
3163        ship_instead(
3164            &mut card,
3165            "lb-labels",
3166            &format!("SELECT ?s ?label WHERE {{ ?s a {top} ; <http://schema.org/name> ?label }} LIMIT 50"),
3167        );
3168        ship_instead(
3169            &mut card,
3170            "cmp-coverage",
3171            &format!(
3172                "SELECT (COUNT(?s) AS ?total) (COUNT(?l) AS ?have) WHERE {{ ?s a {top} \
3173                 OPTIONAL {{ ?s <http://schema.org/name> ?l }} }}"
3174            ),
3175        );
3176        let findings = audit(&card);
3177        assert_eq!(verdict_of(&findings, "lb-labels"), Verdict::Empty);
3178        // The coverage query is an un-grouped aggregate: it returns its row, and
3179        // the row says `have = 0`. A different failure, reported as one.
3180        assert_eq!(verdict_of(&findings, "cmp-coverage"), Verdict::Vacuous);
3181        assert_eq!(rows_of(&rete, sparql_of(&card, "lb-labels").unwrap()), 0);
3182        assert_eq!(rows_of(&rete, sparql_of(&card, "cmp-coverage").unwrap()), 1);
3183    }
3184
3185    /// The **vidy** shape, and the one false positive this audit was caught
3186    /// making: every unit is typed *twice* — `schema:ArchiveComponent` and a
3187    /// native class — so the `class_links` quotient, which files each subject
3188    /// under its last type only, shows no label row for the top class even
3189    /// though the label query returns rows.
3190    ///
3191    /// The audit must NOT claim a refutation here. Both tells are present (the
3192    /// top class has no row of its own; another class has exactly its instance
3193    /// count), and a survey that called this "provably empty" would have sent
3194    /// someone to re-card a file that was fine.
3195    #[test]
3196    fn the_audit_will_not_refute_a_dually_typed_class() {
3197        let name = "<http://schema.org/name>";
3198        let component = "<http://ex/ArchiveComponent>";
3199        let unit = "<http://ex/Unit>";
3200        let mut quads = Vec::new();
3201        for i in 0..8 {
3202            let s = format!("<http://ex/unit/{i}>");
3203            // Both types, the native one last — so the quotient files every
3204            // statement under `Unit` and says nothing about `ArchiveComponent`.
3205            quads.push(q(&s, TYPE, component));
3206            quads.push(q(&s, TYPE, unit));
3207            quads.push(q(&s, name, &format!("\"Unit {i}\"")));
3208        }
3209        let (mut card, rete) = card_and_graph(quads, 30, 0);
3210        assert_eq!(
3211            card.classes[0].0, component,
3212            "fixture: the twin ranks first"
3213        );
3214        assert!(
3215            !card.class_links.iter().any(|l| l.s_class == component),
3216            "fixture: the quotient never mentions the top class"
3217        );
3218        ship_instead(
3219            &mut card,
3220            "lb-labels",
3221            &format!("SELECT ?s ?label WHERE {{ ?s a {component} ; {name} ?label }} LIMIT 50"),
3222        );
3223        assert_eq!(verdict_of(&audit(&card), "lb-labels"), Verdict::Suspect);
3224        assert_eq!(rows_of(&rete, sparql_of(&card, "lb-labels").unwrap()), 8);
3225    }
3226
3227    /// The **geoadmin** card as published: a fixed two-step geometry path on a
3228    /// file that hangs `geo:asWKT` straight off the feature. Nothing to do with
3229    /// typing — the path names a predicate the card's complete list does not
3230    /// contain, so the refutation is exact.
3231    #[test]
3232    fn the_audit_refutes_a_path_through_a_predicate_that_is_not_there() {
3233        let aswkt = "<http://www.opengis.net/ont/geosparql#asWKT>";
3234        let wkt = "http://www.opengis.net/ont/geosparql#wktLiteral";
3235        let mut quads = Vec::new();
3236        for i in 0..5 {
3237            let s = format!("<http://ex/district/{i}>");
3238            quads.push(q(&s, TYPE, "<http://ex/District>"));
3239            quads.push(q(&s, aswkt, &format!("\"POINT({} 4{i})\"^^<{wkt}>", i + 7)));
3240        }
3241        let (mut card, rete) = card_and_graph(quads, 20, 0);
3242        ship_instead(
3243            &mut card,
3244            "sp-within",
3245            "SELECT ?s WHERE { ?s geo:hasGeometry/geo:asWKT ?w FILTER(geof:sfWithin(?w, \
3246             \"POLYGON((-180 -90, 180 -90, 180 90, -180 90, -180 -90))\"^^geo:wktLiteral)) } LIMIT 100",
3247        );
3248        assert_eq!(verdict_of(&audit(&card), "sp-within"), Verdict::Empty);
3249        assert_eq!(rows_of(&rete, sparql_of(&card, "sp-within").unwrap()), 0);
3250    }
3251
3252    /// A `VALUES` list that does not cover the gate admitting the query: the
3253    /// dataset links only with `skos:closeMatch`, the shipped body names the
3254    /// other three. Refutable exactly — `signals.link_predicates` is computed
3255    /// from the full predicate counts, before any cap.
3256    #[test]
3257    fn the_audit_refutes_a_values_list_that_misses_the_only_link_predicate() {
3258        let close = "<http://www.w3.org/2004/02/skos/core#closeMatch>";
3259        let mut quads = vec![q("<http://ex/a>", TYPE, "<http://ex/T>")];
3260        for i in 0..4 {
3261            quads.push(q(
3262                &format!("<http://ex/a{i}>"),
3263                close,
3264                &format!("<http://other/x{i}>"),
3265            ));
3266        }
3267        let (mut card, rete) = card_and_graph(quads, 20, 0);
3268        assert_eq!(card.signals.link_predicates, vec![close.to_string()]);
3269        ship_instead(
3270            &mut card,
3271            "lk-sameas",
3272            "SELECT ?p (COUNT(*) AS ?n) WHERE { ?s ?p ?o VALUES ?p { owl:sameAs \
3273             skos:exactMatch rdfs:seeAlso } } GROUP BY ?p",
3274        );
3275        assert_eq!(verdict_of(&audit(&card), "lk-sameas"), Verdict::Empty);
3276        assert_eq!(rows_of(&rete, sparql_of(&card, "lk-sameas").unwrap()), 0);
3277    }
3278
3279    /// The pre-#172 `top-reach` — a hub IRI and a top predicate, conjoined —
3280    /// is recognized (so it is not reported as an unreadable body) and honestly
3281    /// declared undecidable: nothing in a card ties a *subject* to a
3282    /// *predicate*. On the published catalog 6 of 26 sampled files answered
3283    /// nothing here, which is exactly what "undecidable" has to mean.
3284    #[test]
3285    fn the_audit_recognizes_the_legacy_reach_query_and_declines_to_decide() {
3286        let (mut card, _) = card_and_graph(rich_quads(), 50, 0);
3287        let hub = card.top_hubs[0].0.clone();
3288        let pred = card.predicates[0].0.clone();
3289        ship_instead(
3290            &mut card,
3291            "top-reach",
3292            &format!("SELECT ?y WHERE {{ {hub} {pred}+ ?y }} LIMIT 100"),
3293        );
3294        let f = audit(&card);
3295        let r = f.iter().find(|f| f.id == "top-reach").unwrap();
3296        assert_eq!(r.verdict, Verdict::Undecidable);
3297        assert_eq!(r.revision, "superseded");
3298        assert!(r.conjoined, "two independently-ranked terms in one body");
3299        assert_eq!(r.substitutions.len(), 2);
3300    }
3301
3302    /// A body from no revision at all is reported as such rather than being
3303    /// force-fitted to the nearest pattern — a wrong binding would produce a
3304    /// confident wrong verdict, which is the failure mode a survey cannot have.
3305    #[test]
3306    fn an_unknown_body_is_not_force_fitted() {
3307        let (mut card, _) = card_and_graph(rich_quads(), 50, 0);
3308        ship_instead(
3309            &mut card,
3310            "lb-labels",
3311            "SELECT ?x WHERE { ?x ?y ?z } LIMIT 3",
3312        );
3313        assert_eq!(
3314            verdict_of(&audit(&card), "lb-labels"),
3315            Verdict::Unrecognized
3316        );
3317    }
3318
3319    /// Ids are unique and every capability the table names is reachable — a
3320    /// `Cap` no template requires is dead weight that will drift out of date.
3321    #[test]
3322    fn the_table_is_well_formed() {
3323        let mut ids: Vec<&str> = TEMPLATES.iter().map(|t| t.id).collect();
3324        let n = ids.len();
3325        ids.sort_unstable();
3326        ids.dedup();
3327        assert_eq!(ids.len(), n, "duplicate template id");
3328        for &cap in ALL_CAPS {
3329            assert!(
3330                TEMPLATES.iter().any(|t| t.requires.contains(&cap)
3331                    || t.fallback
3332                        .as_ref()
3333                        .is_some_and(|f| f.requires.contains(&cap))),
3334                "{} is required by no template",
3335                cap.key()
3336            );
3337        }
3338        for t in TEMPLATES {
3339            assert!(
3340                !t.hook_is_exact || t.provably_empty.is_some(),
3341                "{}: hook_is_exact without a hook to be exact about",
3342                t.id
3343            );
3344        }
3345        // Every legacy body names a template that still exists (otherwise the
3346        // audit could never reach it) and is genuinely different from the ones
3347        // that template writes now (otherwise it is dead weight).
3348        for l in LEGACY_BODIES {
3349            let t = TEMPLATES
3350                .iter()
3351                .find(|t| t.id == l.id)
3352                .unwrap_or_else(|| panic!("legacy body for unknown template {}", l.id));
3353            assert!(
3354                ![Some(t.body), t.named_body, t.mixed_body]
3355                    .into_iter()
3356                    .flatten()
3357                    .any(|b| b == l.body),
3358                "{}: legacy body is still a current one",
3359                l.id
3360            );
3361        }
3362    }
3363}