souffle-datalog
For reading, writing, debugging, or extending Soufflé Datalog (`.dl`) programs in this repo — the layered ontology QC program under `src/util/qc/`, the `basic-cycles.tsv` cycle check, taxon-constraint
SKILL.md.claude/skills/souffle-datalog/SKILL.md
GitHubSoufflé is a Datalog engine (https://souffle-lang.github.io/). GO uses it for graph queries that are awkward in SPARQL and too slow in OWL reasoning: transitive closure over the class graph, cycle detection with path reconstruction, and taxon-constraint propagation.
The language is small but has several traps that produce silently empty or
silently wrong results rather than errors. Read reference.md in this skill
directory before writing rules — especially the match and aggregate sections.
Where Soufflé is used
| Program | Invoked by | Reads | Writes |
|---|---|---|---|
src/util/ontology-qc.dl (+ src/util/qc/) |
make datalog-check, part of test and travis_test |
go-edit.facts, ../resources/obsolete_ec.txt |
datalog-violations.tsv |
src/util/ontology-qc-selftest.dl |
make qc-selftest, part of test and travis_test |
src/util/qc/control.dl |
qc-missing-expected.tsv, qc-unexpected-violations.tsv, qc-unexpected-checks.tsv, qc-unregistered-checks.tsv |
src/util/cycles.dl |
make basic-cycles.tsv |
basic.facts |
cycle.csv → basic-cycles.tsv |
src/util/materialize-taxon-constraints.dl |
make imports/go-computed-taxon-constraints.owl |
taxonconstraintsrelationgraph.facts (from relation-graph) |
computed_only_in_taxon.csv, computed_never_in_taxon.csv |
src/util/relation-diff.dl |
.github/workflows/relation-diff.yml (runs from the repo root) |
left.facts, right.facts, ontrdf.facts |
lost.csv, gained.csv, fill.csv |
datalog-check, qc-selftest, and basic-cycles.tsv are QC gates: the build
fails if the output file is non-empty.
The fact-file format
go-edit.facts and basic.facts are generated by src/util/ontology-to-facts.sc,
which renders the ontology as N-Triples and replaces the two field separators with
tabs, dropping the trailing .. The other fact files reach the same format via
equivalent sed pipelines (Makefile:457, relation-diff.yml:13,28,41). So a
fact file is three tab-separated columns
of raw N-Triples terms, with their syntax intact. This is the single thing
to internalize:
| RDF term | Appears in the facts file as |
|---|---|
| IRI | <http://purl.obolibrary.org/obo/GO_0008150> — angle brackets included |
| Plain literal | "neuron differentiation" — double quotes included |
| Typed literal | "true"^^<http://www.w3.org/2001/XMLSchema#boolean> |
| Language literal | "foo"@en |
| Blank node | _:genid1234 |
Consequences that bite:
- Comparisons must include the punctuation.
o = "true"never matches; the boolean-true object is"\"true\"^^<http://www.w3.org/2001/XMLSchema#boolean>"in Soufflé source. Compare with theobsoleterule inqc/vocabulary.dl. match("<.+>", x)is the repo idiom for "x is an IRI" — it excludes blank nodes and literals.match("\".*", o)is the idiom for "o is a literal". Restriction bnodes are everywhere in the class graph, so these guards are not optional.- Resource files compared against fact values must carry the same quoting.
src/resources/obsolete_ec.txtstores"EC:1.1.1.1"with the quote characters for exactly this reason. - Imports are not followed.
ontology-to-facts.scmaps every import to /dev/null, sogo-edit.factscontains the axioms ofgo-edit.oboalone — no CHEBI, no NCBITaxon, no imported relations. A rule that expects an imported label or hierarchy will find nothing. - Axiom annotations are reified. Definition and synonym xrefs are reachable only
via
owl:Axiom/owl:annotatedSource/owl:annotatedProperty, as theannotated,definition_xrefandsynonym_xrefrules inqc/vocabulary.dlshow.
Declare IRIs as #define constants at the top of the file, as every existing
program does, rather than repeating string literals.
Running it
Fact-file generation and the QC targets are Makefile recipes, so per CLAUDE.md
and the /odk-make skill they run in the pinned ODK image:
.claude/skills/odk-make/odk-run.sh make datalog-check
For iterating on a rule, a host souffle against a small synthetic fact file is
far faster and touches no ontology tooling. Host and ODK builds agree on
everything documented in this skill, down to the exact error text; they differ
only in word size (number is 64-bit in ODK, 32-bit in a stock Homebrew build).
Run the final check through odk-run.sh regardless — that is what CI runs.
Soufflé resolves .input/.output paths against the current working
directory unless -F/-D are given, which is why the Makefile runs
souffle ../util/ontology-qc.dl from src/ontology. Outputs land as
<relation>.csv and are tab-separated despite the extension; the recipes
rename and re-head them afterwards.
The QC program's structure
ontology-qc.dl is an entry point only. The program is split so that no check
has to touch RDF:
| File | Holds |
|---|---|
src/util/qc/rdf.dl |
N-Triples punctuation — quote stripping, datatype tails, IRI→CURIE. The only file allowed to mention that syntax. |
src/util/qc/vocabulary.dl |
The ontology as an editor talks about it: class, obsolete, label, definition, synonym, xref, parent, relationship, merged_term. |
src/util/qc/registry.dl |
check/violation/error, plus unregistered_check. |
src/util/qc/selftest.dl |
The self-test gates. Included only by the self-test entry point — in the build program its relations would have no facts, and that warning is this repo's best typo signal. |
src/util/qc/checks/*.dl |
One file per topic. |
src/util/qc/program.dl |
The include list, shared by both entry points. |
src/util/qc/control.dl |
The control ontology for the self-test. |
#include resolves relative to the including file, not the working
directory, so souffle ../util/ontology-qc.dl from src/ontology works
unchanged. Both entry points include qc/program.dl, so the build check and
the self-test can never disagree about which checks exist.
Every check registers itself and reports through violation/3:
check("unknown-ec", "An EC xref must cite an EC entry that exists").
violation("unknown-ec", t, cat("has ", kind, " to an unknown EC: ", e)) :-
ec_citation(t, kind, e), !known_ec(e).
registry.dl projects that into the three-column error relation the build
reads — check name, the subject as a curator would read it (CURIE plus label),
and the message. Write the message as the predicate of a sentence whose subject
is the term; described/2 supplies the subject.
Pass violation/3 the raw node in the term position and let the registry
render it. shown/2 is the manual version, for a node interpolated mid-sentence
— shown(p, prop) in the empty-literal rule turns the offending property into
readable text. Reach for it only where the node is known to be an IRI or a
literal, because it silently drops anything else; see the Gotchas below.
To add a check:
- Pick or add a topic file under
src/util/qc/checks/. - Write one
check(name, summary)fact and one or moreviolationrules against the vocabulary layer. If a rule needsontrdf/3or an escaped literal, add the missing concept tovocabulary.dlinstead — every other check gets it too. - Plant a violating term in
src/util/qc/control.dl. - Exclude obsolete terms with
live_class(t)unless the check is specifically about obsoletes; excludemerged_term(t)for term-level requirements.
A relation declared in two included files is a hard compile error naming both files and lines, so a split tree cannot silently collide.
Required: positive-control test
A wrong QC rule produces an empty relation, and an empty relation means the build passes. A check that never fires is indistinguishable from a clean ontology. So a new or modified rule is not done until it has been shown to fire.
For ontology-qc.dl this is mechanised. src/util/qc/control.dl is a small
hand-built ontology that plants a violation of every arm of every registered
check and declares each one with an expected_violation(check, term) fact.
make qc-selftest runs the identical check set against it:
.claude/skills/odk-make/odk-run.sh make qc-selftest
Four gates, all of which must come out empty:
| Gate | Means |
|---|---|
unregistered_check |
A violation under a name no check declares — a typo. |
missing_expected |
An expectation that did not fire — the rule, or one arm of it, is dead. |
check_without_expectation |
A registered check with nothing planted for it. |
unexpected_violation |
A violation nobody expected — a rule has grown too broad. |
The gate is keyed on (check, term), not on the check name. That matters:
several checks have more than one rule — obsolete-reference has six arms —
and a name-keyed gate stays satisfied as long as any one arm fires, so a dead
arm hides behind a live one. Give every arm its own control term. The last gate
is what lets the control ontology assert that its well-formed terms trigger
nothing, which catches a rule that has become too broad rather than too
narrow. The run takes well under a second.
The control ontology is written as Datalog facts rather than a .facts file
because Soufflé's fact reader rejects comment lines. It uses raw triples rather
than OBO on purpose: an OBO round-trip normalises away several of the
malformations being tested.
For the other programs, which have no registry, build a handful of synthetic facts containing a known violation and run the real program against them:
mkdir -p /tmp/dl/ontology /tmp/dl/resources && cd /tmp/dl/ontology
printf '"EC:1.1.1.2"\ttrue\n' > ../resources/obsolete_ec.txt
printf '<http://purl.obolibrary.org/obo/GO_0000001>\t<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>\t<http://www.w3.org/2002/07/owl#Class>\n' > go-edit.facts
souffle /path/to/repo/src/util/cycles.dl && cat cycle.csv
Confirm both directions: the violating fact does produce the expected message, and a corrected version of the same fact produces none.
Then run the real target through odk-run.sh and check the violation count
against the full ontology. A new check that fires on thousands of existing terms
is a signal about the check, not about the ontology — bring the count to the
issue before committing.
Gotchas
matchis a full-string match, not a search.match("GO_0005", iri)is false for every real IRI. Terminate patterns with.*, or usecontains. This is the most common cause of a rule that silently matches nothing.Warning: No rules/facts defined for relation Xexits 0. It is the clearest sign of a typo'd relation name. Read Soufflé's stderr; never pass-w.- Regex backslashes must be doubled —
"\\d", not"\d", which is a compile error. - Aggregates need the grouping key grounded outside the aggregate, or you
get the global aggregate with no warning. See
reference.md. substris 0-based and clamps or empties on out-of-range input instead of failing.- A guard constraint beside a
substrdoes not protect it. Soufflé may evaluate thesubstrbefore the constraint that was written to keep its index in range, producing a run full of range warnings. Clamp inside the call —substr(l, max(0, n-k), k), notsubstr(l, n-k, k), n-k >= 0. shown/2is a filter as well as a renderer. Its four arms cover OBO CURIEs, OBO fragments, bare IRIs and literal text — a blank node matches none of them, soshown(x, txt)in a rule body drops every bnode binding with no error. An empty literal on a reified axiom annotation has a_:genidsubject, so pre-rendering a subject throughshownrather than handingviolation/3the raw node loses exactly those cases. The same applies todescribed/2, which is whyregistry.dlcarries a!described(t, _)fallback arm.- Restrictions are blank nodes, so an
iri()guard hides them. A rule that walksrdfs:subClassOfand filters targets to IRIs skips everyowl:Restriction; anything built on top of it is silently empty. Keep the unfiltered relation and project the named-class view from it, assuperclass_expr/parentinvocabulary.dldo. #definedoes not concatenate adjacent string literals. C's"a" "b"→"ab"is a preprocessor-plus-C-lexer behaviour that Soufflé's lexer does not share;#define GO(n) "<...GO_" n ">"is a syntax error at the use site. Give each constant its own full-string#define.- Negation cannot appear inside recursion (stratification), and negated literals ground nothing.
- Don't commit generated artifacts.
*.facts,*.csv,datalog-violations.tsv, andcomputed_*_taxon.*are build outputs.
Further detail
reference.md in this directory: full functor and constraint tables, aggregate
syntax, I/O directive options, CLI flags, and a table of Soufflé's error and
warning messages with fixes, verified against both the host and ODK builds.