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 "ed,
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("ed, 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}