Ontology generator — deterministic emission specification
Context and Problem Statement
The OPDA ontology programme committed at ODR-0004 §Rules.6 to a generator-first policy: the mechanical half of TBox production — slot → DatatypeProperty mapping with dct:source + rdfs:comment drawn from the data dictionary + business glossary — is generated, not hand-authored. Council and module-ODR cycles are reserved for genuinely ambiguous moves (aggregate boundaries, cross-overlay synonymy, oneOf-as-subclass-vs-state).
ODR-0004 §6a (added by Session 004 Q5) operationalised the policy with three layers of discipline:
- Deterministic emission ordering — triples emitted in canonical order (
owl:Classalphabetised, thenowl:DatatypeProperty, thenowl:ObjectProperty, thensh:NodeShape/sh:PropertyShape; within-term order:rdf:typefirst,rdfs:labelsecond,rdfs:commentthird, then predicate-specific lexicographic). Blank nodes skolemised by SHA-256 of canonical N-Triples form. Prefix declarations alphabetised. LF line endings; no trailing whitespace; no BOM; final newline. - Generator version recorded in the ontology header (
owl:versionIRI,owl:versionInfo,opda:generatorVersion). - Byte-identity CI test — build pipeline regenerates, byte-compares against committed TTL, fails on any byte difference.
What is not yet recorded: the engineering specification of the generator itself. ODR-0004 §6a is the contract; this ADR is the implementation specification. Without a concrete spec, downstream engineering work (emitting foundation.ttl, module .ttl files, expected-report.ttl pairings, the BASPI5 round-trip MVP gate) cannot begin.
This is the second ADR in the ontology-implementation programme (after ADR-0006 on the namespace + redirect infrastructure). Subsequent ADRs will specify: module TTL emission (one ADR per ratified module or one cross-module rollup); SHACL shapes-graph generation (downstream of ODR-0013); diagnostic-exemplar harness wiring; expected-report.ttl CI integration; BASPI5 round-trip harness.
Decision Drivers
- Byte-identity CI is non-negotiable. ODR-0004 §6a defines the build pipeline contract: regenerate → byte-compare → fail-on-diff. The generator MUST satisfy this; non-deterministic output breaks the contract.
- Three-graph separation is structural (ODR-0004 §3a). Generator MUST emit three source artefacts (
opda-classes.ttl,opda-shapes.ttl,opda-annotations.ttl) — never one merged file. CI tests on three-graph separation MUST pass on emitted output. - Term sourcing per ODR-0004 §7a five-line precedence (W3C-spec > OPDA TF > business glossary > schema text > external regulator). Generator MUST resolve each term against this precedence and emit
dct:sourceto the authoritative origin. - Input format is the data dictionary + glossary, not the JSON Schema directly. The JSON Schema is a translation artefact; the dictionary + glossary are the authoritative inputs. (The dictionary is itself derived from the schema, but Council ratification operates at the dictionary layer.)
- A9 per-kind discipline applies to generator output. For
kind: patternmodule ODRs, the generator MUST emit the UFO/DOLCE meta-category asdct:sourceorskos:scopeNotetriples on each minted class. - Generator is small enough to be inspected. Prefer ~500-1500 lines of Python or TypeScript over a 5000-line framework. The generator is a build-step utility, not a platform.
Considered Options
- A — Hand-authored TTL with informal generator scripts as helpers (chosen REJECT). Defers the byte-identity CI contract; risks human-edit drift; ODR-0004 §6a contract violated.
- B — Off-the-shelf ontology generator (e.g. OWL API + a templating layer; or RDF4J ConsoleProcessor). Heavy framework; canonical ordering not guaranteed; non-trivial to make output byte-identical.
- C — Bespoke generator in Python with
rdflib+ custom canonical serialiser (chosen). Python is OPDA-friendly (data dictionary already in Python pipelines);rdflibprovides RDF primitives; we own the serialiser (mandatory for byte-identity); ~800-1200 line scope. - D — Bespoke generator in TypeScript / Deno. Plausible alternative — TypeScript pairs with the Astro site (ADR-0003) and node tooling. Slightly more boilerplate for RDF. Defer to D as fallback if Python tooling is unavailable.
Decision Outcome
Chosen option: C — Bespoke generator in Python with rdflib-backed primitives plus a custom canonical N-Triples → Turtle serialiser, because it directly satisfies ODR-0004 §6a’s byte-identity CI contract, fits OPDA’s existing data-dictionary tooling, and stays small enough for one engineer to maintain.
Architecture
┌────────────────────────────────────────────────────────────┐
│ Input layer (read-only) │
├────────────────────────────────────────────────────────────┤
│ source/00-deliverables/semantic-models/ │
│ business-glossary.{md,ttl,json} (~54 terms) │
│ data-dictionary.{md,json} (~1,557 leaves) │
│ data-dictionary-canonical.json (machine input) │
│ │
│ docs/ontology/odr/ODR-*.md (ratified ODRs) │
│ ↳ frontmatter parsed for kind/depends-on/implements │
│ ↳ §Rules parsed for UFO category + IC + artefact realis │
└────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ Generator (~800-1200 LOC Python) │
├────────────────────────────────────────────────────────────┤
│ src/ontology_generator/ │
│ cli.py # `opda-gen emit [--profile X]` │
│ inputs.py # Read + parse glossary + dict + ODRs│
│ term_sourcing.py # 5-line precedence resolver │
│ classes_emitter.py # owl:Class graph (canonical order) │
│ properties_emitter.py # DatatypeProperty + ObjectProperty│
│ shapes_emitter.py # sh:NodeShape + sh:PropertyShape │
│ annotations_emitter.py # opda:aiHint + opda:uiHint │
│ serialiser.py # Canonical N-Triples → Turtle │
│ canonicaliser.py # SHA-256 blank-node skolemisation │
│ header.py # vann + dct + owl:versionIRI │
│ composer.py # Build-step derived profiles │
└────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ Output layer (canonical; CI-checked) │
├────────────────────────────────────────────────────────────┤
│ source/03-standards/ontology/ │
│ foundation.ttl (header-only; class skeleton) │
│ opda-classes.ttl (OWL/RDFS graph) │
│ opda-shapes.ttl (SHACL shapes graph) │
│ opda-annotations.ttl (advisory annotations graph) │
│ │
│ derived/ (build output; never hand-edited) │
│ opda-validation.ttl (classes ⊕ shapes) │
│ opda-ui.ttl (classes ⊕ shapes ⊕ annotations) │
│ opda-inference.ttl (classes alone) │
└────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ CI pipeline (GitHub Actions) │
├────────────────────────────────────────────────────────────┤
│ 1. Regenerate all three source TTLs from inputs │
│ 2. `diff -q` against committed copies → fail on any diff │
│ 3. Run ODR-0004 §3a five-part CI test (SHACL queries) │
│ 4. Run pyshacl validation: exemplars vs shapes graph │
│ 5. Compare against expected-report.ttl pairings │
└────────────────────────────────────────────────────────────┘
Deterministic emission rules (formalising ODR-0004 §6a)
- Prefix declarations — alphabetised by prefix string.
- Term emission order — by RDF type, then alphabetised by URI within type:
owl:Ontology(single, file header)owl:Classblocksowl:DatatypePropertyblocksowl:ObjectPropertyblockssh:NodeShapeblocks (shapes graph only)sh:PropertyShapeblocks (shapes graph only)skos:Conceptblocks (for inline SKOS scheme members; full schemes in module TTLs)- Advisory annotation triples (annotations graph only)
- Within-term triple order:
rdf:type(always first)rdfs:label/skos:prefLabelrdfs:comment/skos:definitiondct:source- Predicate-specific triples lexicographically (predicate URI alphabetised; per-predicate object-list sorted)
- Blank nodes — skolemised by SHA-256 hash of their canonical N-Triples representation (recursive for nested blanks). Prefix:
_:b<hex-digest-first-12-chars>. - String literals — escaped consistently (
\n,\t,\",\\);xsd:stringdatatype implicit (never emit^^xsd:string);@enlanguage tag explicit when present. - File formatting — LF line endings; no trailing whitespace; no BOM; single final newline; 4-space indentation within blocks; one empty line between term blocks.
Term-sourcing five-line precedence (formalising ODR-0004 §7a)
ODR-0004 §7a names five precedence slots, but slot 3 (Other regulatory authorities) is contextual, not authoritative — it produces skos:scopeNote or skos:closeMatch, never the primary dct:source. The resolver therefore distinguishes (a) the primary dct:source lookup (slots 1, 2, 4, 5) from (b) parallel contextual-citation collection (slot 3). Resolver pseudocode:
def resolve_term(term_id: str) -> ResolvedTerm:
"""Returns (primary, contextual_citations) per ODR-0004 §7a five-slot rule.
Slot numbering follows ODR-0004 §7a verbatim:
1. W3C / external spec (authoritative; → dct:source)
2. OPDA Trust Framework (authoritative; → dct:source)
3. Other regulatory authorities (contextual; → skos:scopeNote / skos:closeMatch)
4. OPDA business glossary (project-internal; → dct:source)
5. Schema-leaf annotation (lowest-trust; → dct:source)
The primary dct:source comes from the first of slots {1, 2, 4, 5} that matches.
Slot 3 (regulators) is always collected separately as contextual_citations and
emitted as skos:scopeNote / skos:closeMatch alongside the primary dct:source.
"""
contextual_citations = []
# Slot 3 — collected in parallel (always; never primary dct:source)
if term_id in EXTERNAL_REGULATORS:
contextual_citations.append(SourceRecord(
url=EXTERNAL_REGULATORS[term_id], tier=3, kind="contextual",
))
# Primary dct:source resolution (skips slot 3 by ODR-0004 §7a design)
# Slot 1 — W3C / external spec
if term_id in W3C_REGISTRY:
primary = SourceRecord(url=W3C_REGISTRY[term_id], tier=1, kind="authoritative")
# Slot 2 — OPDA Trust Framework (per Session 003c Item 3)
elif term_id in OPDA_TF_REGISTRY:
primary = SourceRecord(url=OPDA_TF_REGISTRY[term_id], tier=2, kind="authoritative")
# Slot 4 — OPDA business glossary
elif term_id in glossary:
primary = SourceRecord(url=glossary_url(term_id), tier=4, kind="project-internal")
# Slot 5 — Data dictionary canonical leaf path
elif term_id in dictionary:
primary = SourceRecord(url=dictionary_url(term_id), tier=5, kind="lowest-trust")
else:
raise UnsourceableTerm(term_id)
return ResolvedTerm(primary=primary, contextual=contextual_citations)
Conflicts (e.g. glossary and dictionary disagree on definition) produce a ## Change log row in the consuming module ODR per ODR-0004 §7a “Conflict-recording protocol”. Generator MUST fail on unresolved conflicts; the build-pipeline error message names the conflicting sources verbatim.
ADR-0007 amendment history. The original pseudocode in this section had regulators at “tier 5” and glossary at “tier 3” — that mis-ordered ODR-0004 §7a’s slot numbering. Independent validation of ADR-0008 (commit 6439b57) surfaced the discrepancy; queued at ADR-0005 §G item G1. Author-only Council amendment (2026-05-27): the pseudocode above now matches ODR-0004 §7a’s slot ordering and contextual-citation distinction. ADR-0008’s term_sourcing.py implementation predated this clarification and ranks “business glossary” at tier 3 in its Tier IntEnum; that code is amended in lockstep at ADR-0012’s worker brief (the next worker touching term_sourcing.py) so the implementation matches the corrected pseudocode + ODR-0004 §7a.
Three-graph emission constraints (formalising ODR-0004 §3a)
Generator MUST enforce:
| File | MUST contain | MUST NOT contain |
|---|---|---|
opda-classes.ttl | owl:Class, owl:DatatypeProperty, owl:ObjectProperty, rdfs:label, rdfs:comment, skos:prefLabel, skos:definition | sh:* triples; opda:aiHint/opda:uiHint/opda:exampleValue |
opda-shapes.ttl | sh:NodeShape, sh:PropertyShape, sh:targetClass, sh:path, sh:datatype, sh:minCount/maxCount, etc. | owl:Class; owl:imports; advisory annotations |
opda-annotations.ttl | opda:aiHint, opda:uiHint, opda:exampleValue, generator notes; keyed via dct:relation/opda:appliesTo | sh:* triples; owl:Class triples |
Five-part CI test from ODR-0004 §3a runs against emitted output. Generator failure here is a generator bug, not a content bug.
Module pluralism
Generator emits one TTL file per ratified module ODR (opda-property.ttl, opda-agent.ttl, opda-transaction.ttl, opda-claim.ttl, etc.) in addition to the foundation. Each module file:
- Declares only the classes/properties scoped to that module’s ratified ODR.
- Imports
opda-classes.ttl(foundation) viaowl:imports. - Carries
owl:versionIRIpinned to the ratifying ODR’s date + generator version. - Pairs with
opda-<module>-shapes.ttl+opda-<module>-annotations.ttl(three-graph separation preserved per module).
The composer step assembles the derived consumer profiles (opda-validation.ttl, opda-ui.ttl, opda-inference.ttl) from the per-module source graphs.
Generator version vs ontology version-IRI (G15 amendment, 2026-05-27)
Two version numbers track different things and intentionally decouple:
opda_gen.__version__— the generator package’s emitter-capability version. Bumps when the generator gains a new emitter scope (e.g. 0.1.0 = foundation only; 0.2.0 = + vocabularies; 0.3.0 = + module classes; 0.4.0 = + module shapes + DPV annotations). Drives theopda-gen --versionoutput and the source-commit sentinel pin per ADR-0009 §“sentinel pinning convention”.owl:versionIRI <https://w3id.org/opda/<NNN>/>on each emitted ontology — the content version of that specific graph. Bumps only when the graph’s triples change. Foundation’s class-graphowl:versionIRIstayed at0.3.0across the 0.3.0 → 0.4.0 generator bump because the class graph content was unchanged; only the shapes + annotations files (new ontology IRIs<https://w3id.org/opda/shapes/0.3.0/>etc.) gained content.
The decoupling matters for consumers: an ontology consumer importing <https://w3id.org/opda/0.3.0/> should see byte-identical content regardless of which generator version emitted it. A consumer using opda-gen as a build-time tool gets the latest emitter scope. The two-number contract is engineering’s, not Council’s; emitter-scope bumps follow programme-plan ADR phasing (ADR-0009 → 0.1.0; ADR-0010 → 0.2.0; ADR-0011 → 0.3.0; ADR-0012 → 0.4.0; ADR-0013 → 0.5.0; ADR-0014 → 1.0.0 on MVP gate).
A9 per-kind discipline output
For kind: pattern and kind: mapping module ODRs (per Session A9 + ODR-0001 §“What an ODR records”), the generator MUST emit on each minted class:
dct:sourcetriple pointing to the ODR + §Rules subsection that committed the UFO/DOLCE meta-category.skos:scopeNotetriple (literal) restating the UFO/DOLCE category in human-readable form.rdfs:commenttriple naming the IC over hard cases (synthesised from §Rules; verbatim where the source is concise).
Examples:
opda:LegalEstate
a owl:Class ;
rdfs:label "Legal Estate" ;
rdfs:comment "UFO Substance Kind / DOLCE NonPhysicalEndurant. IC: rights-bundle persistence — same individual through grant, transfer, registration, and discharge events; distinguishable from coexisting RegisteredTitle and physical Property by extension of property rights." ;
skos:scopeNote "DOLCE: NonPhysicalEndurant" ;
dct:source <https://opda.org.uk/pdtf/harness/odr/ODR-0005/section-3b> ;
.
Byte-identity CI test (formalising ODR-0004 §6a)
# .github/workflows/ontology-byte-identity.yml
name: Ontology byte-identity
on: [push, pull_request]
jobs:
byte-identity:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: '3.11' }
- run: pip install -e .[generator]
- run: opda-gen emit --output /tmp/ontology
- run: diff -q /tmp/ontology/foundation.ttl source/03-standards/ontology/foundation.ttl
- run: diff -q /tmp/ontology/opda-classes.ttl source/03-standards/ontology/opda-classes.ttl
- run: diff -q /tmp/ontology/opda-shapes.ttl source/03-standards/ontology/opda-shapes.ttl
- run: diff -q /tmp/ontology/opda-annotations.ttl source/03-standards/ontology/opda-annotations.ttl
# Plus: per-module .ttl files iterated similarly
Failure on any diff → PR/push fails. Reviewable PR diff is the consequence, not the primary check.
Consequences
- Good, because ODR-0004 §6a’s byte-identity CI contract is mechanically enforceable from day one.
- Good, because the generator is small (~800-1200 LOC) and inspectable — no opaque framework behaviour.
- Good, because Python pairs with OPDA’s existing data-dictionary tooling — minimal new dependency surface.
- Good, because the three-graph separation is enforced by emission, not by post-hoc lint.
- Good, because per-module emission scales cleanly as new module ODRs ratify — generator scope grows linearly, not combinatorially.
- Good, because the A9 per-kind discipline output is mechanical — no human-author drift in UFO category declarations.
- Good, because emission is fast (deterministic single-pass over inputs; expected sub-second for the full corpus).
- Bad, because byte-identity is brittle: any rdflib version change risks diff churn unless the canonical serialiser is fully version-pinned. Mitigation: lock
rdflibversion; the canonical serialiser bypasses rdflib’s own serialiser entirely for the final output stage. - Bad, because we own the canonical serialiser — maintenance burden on OPDA, not the rdflib community. Mitigation: well-tested + simple; ~150 LOC for the canonicaliser.
- Bad, because the generator is a new artefact OPDA must maintain alongside the dictionary + glossary + ODR corpus. Mitigation: small scope; CI guards against drift.
- Neutral, because the SHACL shapes graph emission requires ratified
kind: patternmodule ODRs — generator scope grows incrementally as Council ratifies each module.
Confirmation
The ADR is honoured when all five hold:
- Repository structure exists.
src/ontology_generator/directory created with the file layout above (cli.py, inputs.py, term_sourcing.py, etc.). - CLI works.
opda-gen emit --output <dir>produces three TTL files matching the deterministic-emission specification. - CI test wired.
.github/workflows/ontology-byte-identity.ymlexists and runs on push + PR. - First emission committed.
source/03-standards/ontology/foundation.ttl+ per-module files committed; subsequent regeneration matches byte-for-byte. - A9 per-kind discipline output verified. Every
owl:Classemitted from akind: patternODR carriesdct:source+skos:scopeNote+rdfs:commentper §“A9 per-kind discipline output” above.
Manual test: regenerate, commit nothing, run git diff → should be empty.
Pros and Cons of the Options
A — Hand-authored TTL
- Good, because immediate output; no engineering investment.
- Bad, because byte-identity CI contract (ODR-0004 §6a) cannot be enforced; human-edit drift inevitable.
- Bad, because three-graph separation depends on author discipline; lint catches violations post-hoc, not at-source.
B — Off-the-shelf generator framework
- Good, because pre-built infrastructure (validation, inference, multiple serialisers).
- Bad, because byte-identity is hard: most frameworks emit non-canonical Turtle by default.
- Bad, because heavy dependency surface — OPDA inherits the framework’s release cadence and breaking changes.
- Bad, because customisation for A9 per-kind output requires framework-specific extension APIs.
C — Bespoke Python generator (chosen)
- Good, because every emission rule is OPDA-owned and inspectable.
- Good, because Python integrates with existing data-dictionary tooling.
- Good, because scope stays small and incrementally growable.
- Bad, because OPDA owns serialiser maintenance; rdflib version pinning required.
D — Bespoke TypeScript generator
- Good, because pairs with Astro site (ADR-0003) and Node tooling.
- Bad, because TS RDF tooling (n3.js, rdflib.js) is less mature than Python’s rdflib for SHACL.
- Bad, because OPDA’s existing tooling skew is Python.
More Information
- Architecture decision provenance: ODR-0004 §Rules.6 (generator-first), §6a (operationalisation), §7a (term-sourcing precedence), §3a (three-graph separation).
- Session A9 ratified the per-kind discipline applied here: session-a9-gandon-guizzardi-methodology-gap.
- Sibling ADR: ADR-0006 — Ontology namespace at w3id.org/opda/ via W3C PICG redirect. Together with this ADR, the two form the bootstrap of the ontology-implementation ADR programme.
- Inputs (read-only):
source/00-deliverables/semantic-models/business-glossary.{md,ttl,json},glossary-merged.json,data-dictionary.{md,json},data-dictionary-canonical.json. Also: every ratified ODR underdocs/ontology/odr/ODR-*.md(forkind: patternUFO category extraction). - Outputs:
source/03-standards/ontology/foundation.ttl,opda-classes.ttl,opda-shapes.ttl,opda-annotations.ttl, plus per-moduleopda-<module>.ttlfiles. Build-step derived:derived/opda-validation.ttl,opda-ui.ttl,opda-inference.ttl. - CI integration:
.github/workflows/ontology-byte-identity.yml(to be authored as part of generator implementation). - Reference implementations to study: rdflib’s serialisers (for canonical patterns); DPV’s
rdfgenpipeline; LinkML’sgen-shacl(for SHACL shape emission from a structured input source). - First emission target:
foundation.ttl— the header + class skeleton. Subsequent emissions add module-by-module as each ratifiedkind: patternODR carries enough §Rules detail. - Out of scope for this ADR (deferred to subsequent ADRs): SHACL profile composition mechanics (ADR-NNNN for ODR-0010 implementation); diagnostic-exemplar harness wiring (ADR-NNNN for ODR-0005 §8a); BASPI5 round-trip harness (ADR-NNNN — the MVP gate); JSON-LD context emission (deferred until a named consumer demands ld-json output).
- Out of scope for the OPDA ADR programme overall: ontology editor UIs (third-party tools — Protégé, TopBraid Composer, VocBench — consume OPDA’s TTL output without OPDA-side build integration).
Comments
Loading comments…
Sign in to post a comment