Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Questions as Data

Chapter 1 sketched seven competency questions (CQs) and called them the litmus test. Chapter 7 ran that test: each question answered in prose against the catalog. Chapter 8 grew the catalog and walked the answers again. Both walks were a person reading prose against a file, and that is the weakness: a prose answer has no tooling behind it. Edit the catalog and nothing tells you an answer went stale.

Noy & McGuinness 2001 — §Step 1, Competency questions

These questions will serve as the litmus test later: Does the ontology contain enough information to answer these types of questions?

This chapter turns the CQs into benchmark data. The vocabulary for the benchmark is cqa, a schema for competency question-and-answer (CQ&A) records: one record pairs a question with the full specification of its correct answer (the ground_truth in prose, the answer_kind the question demands, and the records in the target graph a correct answer must reach and cite). cqa is itself a LinkML ontology, built through the same seven steps as this book, and the two builds are entangled: cqa has no domain graph of its own (a benchmark is meaningless without a graph to benchmark, and a contract’s instances are always a consumer’s), so wine’s benchmark is the worked example that pulled cqa’s initial set of classes and slots into existence. The demand-driven rule that has governed this build since Step 1 ran across the cqa/wine repository boundary.

Six questions, encoded

The benchmark is one file, data/wine-benchmark.yaml. It conforms to cqa, not to wine: the records below are instances of cqa’s CompetencyQuestionAnswer, and every wine record they mention is reached by reference rather than copied in: each expected_anchors value 9.1.3 is a bare id, expanded against the target namespace (target_schema) 9.1.1 the file declares exactly once.

Listing 9.1
# The worked example: wine's competency questions as a cqa benchmark.
#
# Every anchor names a record in wine's published worked-example graph.
# No wine record is copied here — this file states what a correct answer
# must reach, and the graph it reaches into lives in another repository
# at the version named below. Anchors are written short: the target
# schema's namespace is declared once, and each bare id expands against
# it.
#
# CQ-wine 1 is absent on purpose. It asks which characteristics to consider
# when choosing a wine, which wine's schema answers before any instance
# exists, so there is no record to anchor.
id: wine-benchmark
name: Wine competency questions
description: >-
  The seven competency questions of the wine ontology, less the one its
  schema answers, each paired with the specification of its correct answer
  over wine's worked-example graph.

target_schema: https://w3id.org/wine-linkml/
target_schema_version: 0.2.0
target_dataset: worked-example
target_dataset_version: 0.2.0

competency_question_answers:
  - id: cq-02
    name: Bordeaux colour
    question: Is Bordeaux a red or white wine?
    ground_truth: >-
      Red. The bordeaux-wine record carries color: red — a lookup by name,
      one hop to a value.
    answer_kind:
      - lookup
    expected_anchors:
      - bordeaux-wine

  - id: cq-03
    name: Cabernet Sauvignon with seafood
    question: Does Cabernet Sauvignon go well with seafood?
    ground_truth: >-
      None on record. No pairing recommendation links the Cabernet
      Sauvignon to a seafood dish, and none is inferable. Under a
      closed-world reading the absence of a recommendation is not evidence
      against the pairing, only the absence of a claim for it.
    answer_kind:
      - closed-world-negative
    expected_anchors:
      - cabernet-sauvignon
      - seafood-dish
    unconnected_anchors:
      - cabernet-sauvignon
      - seafood-dish
    connecting_class: https://w3id.org/wine-linkml/PairingRecommendation

  - id: cq-04
    name: Wine for grilled meat
    question: What is the best choice of wine for grilled meat?
    ground_truth: >-
      The Cabernet Sauvignon, on the pr-cabsauv-grilledmeat recommendation,
      with confidence 0.9 and the rationale that its tannins bind the fats
      and proteins of grilled red meat.
    answer_kind:
      - attribution
    expected_anchors:
      - grilled-meat
      - cabernet-sauvignon
      - pr-cabsauv-grilledmeat
    expected_citations:
      - pr-cabsauv-grilledmeat

  - id: cq-05
    name: Characteristics affecting a pairing
    question: >-
      Which characteristics of a wine affect its appropriateness for a
      dish?
    ground_truth: >-
      A pattern across the three recommendations' rationales, which no
      single record states: tannin against protein, acidity against
      delicate seafood, and a light body's versatility with white meat.
    answer_kind:
      - synthesis
    expected_anchors:
      - pr-cabsauv-grilledmeat
      - pr-chardonnay-seafood
      - pr-beaujolais-chicken
      - cabernet-sauvignon
      - napa-chardonnay
      - chateau-morgon-beaujolais
    expected_citations:
      - pr-cabsauv-grilledmeat
      - pr-chardonnay-seafood
      - pr-beaujolais-chicken

  - id: cq-06
    name: Vintage variation
    question: Does a bouquet or body of a specific wine change with vintage year?
    ground_truth: >-
      Yes. The 2017 and 2018 Napa Zinfandel share grape, region and maker
      and differ in vintage and in body: medium against full.
    answer_kind:
      - comparison
    expected_anchors:
      - napa-zinfandel-2017
      - napa-zinfandel-2018
      - vy-2017
      - vy-2018

  - id: cq-07
    name: Good vintages for Napa Zinfandel
    question: What were good vintages for Napa Zinfandel?
    ground_truth: >-
      2018. The regional vintage chart rates 2018 good and 2017 average,
      each with its source and confidence. The answer carries attribution:
      not "2018 was good" but "the regional vintage chart rates 2018 good."
    answer_kind:
      - attribution
      - comparison
    expected_anchors:
      - napa-zinfandel-2018
      - napa-zinfandel-2017
      - va-napa-zin-2018
      - va-napa-zin-2017
    expected_citations:
      - va-napa-zin-2018
      - va-napa-zin-2017

Six records, not seven. CQ 1 (“which characteristics should I consider?”) has no row because Chapter 7 answered it with the schema itself: the Wine class carries color, body, flavor, and sugar before any instance exists. There is no record to anchor and no honest way to invent one, so a retrieval benchmark cannot express the question. The file says so in a comment; the omission is a finding about what this kind of benchmark covers, not a slip.

The claims are about one graph at one version, and the target_schema and target_dataset fields 9.1.1 pin both. answer_kind 9.1.2 names the check a correct answer must survive (a lookup is one hop to a value, a synthesis is a pattern no single record states), and CQ 7 carries two kinds at once 9.1.7, because “2018 was good” is worse than “the regional vintage chart rates 2018 good,” and dropping either the comparison or the attribution makes the answer weaker.

CQ 3’s answer is an absence, and unconnected_anchors states which absence 9.1.4: it is not enough to anchor the wine and the dish, because a consumer of the answer cannot tell “correctly found no pairing” from “retrieved nothing” unless the claimed gap is named. connecting_class narrows the claim 9.1.5 to exactly what Chapter 7’s prose said: no PairingRecommendation joins them. And expected_citations 9.1.6 only ever names judgment-side records, because the recommendation carries the rationale, source, and confidence an attributed answer rests on; the reference-side entities (wines, foods, vintages) anchor but never cite.

Two schemas, two graphs, one manifest

The benchmark enters panschema.toml as a second schema with its own dataset, beside wine’s. The manifest now composes a system rather than listing outputs: wine’s schema with its catalog, cqa’s with the benchmark, and references crossing from one graph into the other. Verification covers the composition (each graph against its own schema, plus what must hold at the crossing):

Listing 9.2
# panschema consumer manifest: the schemas and datasets this repo
# composes, and what must hold across them. Bare `panschema verify`
# checks everything declared here without writing outputs; `panschema
# generate` emits the machine-readable formats to site/artifacts/
# (deployed with the docs site). HTML documentation comes from
# `panschema publish` + panschema-publish.toml, not here.
#
# `path = "."` references THIS repo's own package (the panschema-publish.toml
# at the root) — the local-package field, distinct from `source`, which is for
# remote `github:` dependencies. Format keys are deny_unknown_fields, so a typo
# is a hard parse error, not a skip: note `json_schema` (underscore) but
# `instance-graph-json` (hyphens). Postgres is deliberately omitted: wine's
# multivalued attributes make that writer skip most classes.
[schemas.wine]
path = "."

[generate.wine]
shacl = "site/artifacts/wine-shapes.ttl"
json_schema = "site/artifacts/wine.schema.json"
ttl = "site/artifacts/wine.ttl"
instance-graph-json = "site/artifacts/wine-instances.graph.json"
instances = ["data/wine-instances.yaml"]

[schemas.cqa]
source = "github:padamson/cqa-schema"
version = "0.1.0"

[generate.cqa]
ttl = "site/artifacts/wine-benchmark.ttl"
instances = ["data/wine-benchmark.yaml"]

# What a benchmark record means (which slots carry absence claims, what
# short anchors expand against) is declared in cqa's schema, on the
# slots themselves. This block only says which graphs to hold the claims
# against.
[check.cqa]
resolve_against = ["wine"]
require_namespace_coverage = true

The contract is a released dependency 9.2.1: source says where it is published and version pins the release. panschema fetch resolves the pin once, downloads the tagged release into a local cache, and writes panschema.lock, which records the checksum the pin resolved to and is committed beside the manifest. From then on every run of verify and generate reads the contract the lockfile names, and CI runs fetch --check first: a fetch that drifts from the lockfile fails before the checks start. Moving to a later release is one edit to version and one re-run of fetch, and verification against the new contract says whether the benchmark still conforms to it. The entry also publishes the benchmark as a self-contained knowledge graph 9.2.2 next to wine.ttl.

The [check.cqa] block declares what must hold at the crossing, and wine cannot delegate it. An anchor is an external reference, and panschema deliberately exempts external references from its dangling check: a benchmark’s whole shape is pointing into a graph it does not contain. Whether those references actually resolve is a question about the graph pair, answerable only where both graphs exist, which is this repository and nowhere else. resolve_against 9.2.3 checks every anchor against the IRIs wine’s datasets mint, and require_namespace_coverage 9.2.4 closes the remaining hole: mistype the target base by one character and all twenty-eight references expand into a namespace no entry covers, which is rejected rather than passed as “external, unchecked.”

Nothing in the block configures the absence check. That is on purpose: a consumer does not get to decide what a benchmark record means. cqa’s schema declares, on the slot itself, that unconnected_anchors carries an absence claim and that connecting_class narrows it, the way identifier: true is declared where the slot is defined rather than in every file that uses it. The verifier reads the meaning from the contract, so every consumer runs the same check; there is no per-repo binding to get wrong. The absence claim is then discharged the same way the dangling check tests a positive reference: “this record exists over there” and “no record over there joins these” are both statements about the referenced graph.

The manifest keys themselves know nothing about benchmarks: they would hold any pair of schemas whose graphs reference each other (two catalogs sharing a vocabulary, an inventory pointing into a registry). Cross-graph verification is the general capability; the benchmark is its first customer, the same way this book’s earlier chapters were the first customer of default_range resolution and the rule syntax. One command runs all of it from the manifest, writing nothing:

$ panschema verify --strict
Using manifest: panschema.toml
note: 28 cross-graph reference(s) leave this dataset and are not checked here:
  `cq-02` references `https://w3id.org/wine-linkml/bordeaux-wine` via `expected_anchors`
  `cq-03` references `https://w3id.org/wine-linkml/cabernet-sauvignon` via `expected_anchors`
  ... (26 more lines, one per outbound reference)
note: schema `cqa`: 28 of 28 cross-graph reference(s) into `wine` namespace(s) resolve
note: schema `cqa`: 1 of 1 stated absence claim(s) hold against `wine`
note: schema `cqa`: 2 of 2 version pin(s) agree with `wine`

The output looks like it contradicts itself (“not checked here,” then “28 of 28 resolve”). It comes from two passes with different scopes. The first verifies the benchmark as one dataset against its schema, and a dataset is a single instance file: within it, records reference each other by bare id, and those references are checked for dangling targets. An anchor is the other kind of reference: before anything reads it, the bare id expands against the declared target namespace into an IRI pointing at a record in a different dataset (napa-zinfandel-2017 is a record in data/wine-instances.yaml, minted into that same IRI under wine’s schema). The expansion is also why the console output above shows full IRIs for values the file writes in one word. A file-scoped pass cannot resolve those references, and in the general case never could: a benchmark normally points at a graph that lives in another repository altogether. So it enumerates them instead (the benchmark’s entire claim on the catalog, one line per reference; the same record IRI can appear on several) and hands off. The three notes after it are the [check.cqa] pass, which exists because this repository is the special case where the target graph is on hand: it loads wine’s datasets, mints their IRIs, and discharges the list. Every reference resolves, the stated absence holds, and the two versions the benchmark pins are the ones wine’s package declares.

Each of the four gates (resolution, the absence, namespace coverage, the version pins) was made to fail once during authoring and caught each time. The failure modes themselves are the panschema and cqa toolchain’s to test, and its suite pins them; this chapter shows the passing run, which is the state every push must reproduce.

What keeps it true

“Checking the benchmark” can mean two different things, and only one of them happens in this repository. verify --strict checks the answer key itself: every record the key points at exists, and the one claim it makes about the graph’s shape holds. Nobody here answers the questions; that is what the benchmark’s other fields are shaped for, and it is out of scope for this book. The closed-world negative is the boundary case: its correct answer is the key’s claim about the graph, so verifying the claim is as close to answering the question as verification gets. For the other kinds, the ground truth’s content stays a prose claim (nothing checks that bordeaux-wine is in fact red).

Verification and validation

Systems engineering keeps these words apart: verification asks whether the thing was built right (the artifact matches its specification), validation asks whether the right thing was built (it serves the need) — solving the equations right versus solving the right equations (Roache 1997). This book uses the terms strictly, and the tool’s verb agrees: everything panschema verify does is verification. Chapter 7 said “validation,” following the data-engineering convention (SHACL produces “validation reports,” JSON Schema tools are “validators,” LinkML ships linkml-validate) — a parallel, older sense of the word that is fine in tools whose scope holds no validation in the strict sense. This stack holds both activities, so it needs both words: the verb was renamed once the distinction started doing real work, and the neighboring formats keep their own names. Validation in the strict sense is the litmus test itself: the competency questions are the ontology’s requirements, and answering them from the graph is the evidence the right ontology was built. Chapters 7 and 8 ran that test by hand; nothing in this chapter runs it by machine.

CI runs the manifest-wide verify on every push, so the litmus test that was a section of prose is now a gate: an anchor that stops resolving, or an absence that stops holding, fails the build before anything publishes.

The failure that gate cannot see is the quiet one. Removing a record breaks an anchor loudly; changing one breaks nothing visible. Correct the 2018 vintage assessment’s verdict from good to average and CQ 7’s anchors still resolve, its citations are still anchors, verification still passes — and the benchmark’s ground truth is simply wrong. The pinned target versions exist for exactly this failure, and they are checked, not just declared: cqa says on each version slot which sibling it records, and the verifier compares the pin with the version wine’s package actually declares. A pin that falls behind fails the strict run. That sets a standing rule for this repository: any catalog edit that could change an answer bumps the package version, corrections included, and the bump keeps the build red until the benchmark has been re-read against the new graph and re-pinned. A consumer pinning the old version and a consumer reading main may then disagree about what the benchmark asserts, but each knows which claim it holds.

The version, decided

Implementing the benchmark changed nothing in schema/wine.yaml. The demand ran the other way: wine’s questions pulled cqa’s model into existence, and wine’s own model already held every record the answers anchor. So v0.2.0 stands.

A bump was considered anyway, for the package (it now ships a third dataset), and rejected. The schema’s version: field is ontology metadata: it becomes owl:versionInfo in the published artifact, so stamping a new version on an unchanged model would announce a change that did not happen. And the falsification rule above governs edits that change answers; adding the benchmark changes none (the benchmark targets 0.2.0, and every claim in it was verified against that graph). The benchmark rides the bleeding edge until the next release whose model actually changes, which will carry it into a version.

Two pages

The published site now carries two schema-docs pages. Wine’s own page is unchanged. The new page renders the contract’s schema with wine’s benchmark as its instance graph (the records lead, cqa’s reference section follows) under schema/cqa/. Today it exists only at the bleeding edge (schema/cqa/main/), because no released version of the package contains the benchmark; the first release that does will give it the same version dropdown and current/ alias as the main page. The book’s toolbar button becomes a menu with an entry for each.

The pages are meant to be read side by side. Every anchor on the benchmark page is a record on the catalog page, and checking that correspondence by eye is how the benchmark was authored; the [check.cqa] gates re-check it on every push. The pair also shows the division of labor at a glance: one page holds what the catalog says, the other holds what a correct answer about it must reach.

Where it stands

The schema stands at v0.2.0, untouched. Six of the seven competency questions are now records whose answer key a machine keeps sound on every push; the seventh is answered by the schema and stays prose, which is the right home for it. The contract freezes this same file as the worked example in its own book: one benchmark serving two ontologies, each the demand for the other.