souffle-datalog
SkillFiles & storageFor 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 materialization, and the relation-diff CI report. Covers the RDF fact-file format these programs consume, this repo's Soufflé conventions, the check registry, and the required control-ontology self-test for any new or changed rule.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the souffle-datalog skill
What this skill tells your AI
The instructions your AI receives, as published by geneontology/go-ontology in .claude/skills/souffle-datalog/SKILL.md and read by ahel’s review.
Soufflé 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.
Signals
- GitHub stars
- 254
- Forks
- 47
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
souffle-datalog- Source
- github.com/geneontology/go-ontology