souffle-datalog

SkillFiles & storage

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 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.

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

ProgramInvoked byReadsWrites
src/util/ontology-qc.dl (+ src/util/qc/)make datalog-check, part of test and travis_testgo-edit.facts, ../resources/obsolete_ec.txtdatalog-violations.tsv
src/util/ontology-qc-selftest.dlmake qc-selftest, part of test and travis_testsrc/util/qc/control.dlqc-missing-expected.tsv, qc-unexpected-violations.tsv, qc-unexpected-checks.tsv, qc-unregistered-checks.tsv
src/util/cycles.dlmake basic-cycles.tsvbasic.factscycle.csvbasic-cycles.tsv
src/util/materialize-taxon-constraints.dlmake imports/go-computed-taxon-constraints.owltaxonconstraintsrelationgraph.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.factslost.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 termAppears 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 the obsolete rule in qc/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.txt stores "EC:1.1.1.1" with the quote characters for exactly this reason.
  • Imports are not followed. ontology-to-facts.sc maps every import to /dev/null, so go-edit.facts contains the axioms of go-edit.obo alone — 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 the annotated, definition_xref and synonym_xref rules in qc/vocabulary.dl show.

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:

FileHolds
src/util/qc/rdf.dlN-Triples punctuation — quote stripping, datatype tails, IRI→CURIE. The only file allowed to mention that syntax.
src/util/qc/vocabulary.dlThe ontology as an editor talks about it: class, obsolete, label, definition, synonym, xref, parent, relationship, merged_term.
src/util/qc/registry.dlcheck/violation/error, plus unregistered_check.
src/util/qc/selftest.dlThe 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/*.dlOne file per topic.
src/util/qc/program.dlThe include list, shared by both entry points.
src/util/qc/control.dlThe 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:

  1. Pick or add a topic file under src/util/qc/checks/.
  2. Write one check(name, summary) fact and one or more violation rules against the vocabulary layer. If a rule needs ontrdf/3 or an escaped literal, add the missing concept to vocabulary.dl instead — every other check gets it too.
  3. Plant a violating term in src/util/qc/control.dl.
  4. Exclude obsolete terms with live_class(t) unless the check is specifically about obsoletes; exclude merged_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:

GateMeans
unregistered_checkA violation under a name no check declares — a typo.
missing_expectedAn expectation that did not fire — the rule, or one arm of it, is dead.
check_without_expectationA registered check with nothing planted for it.
unexpected_violationA 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

  • match is a full-string match, not a search. match("GO_0005", iri) is false for every real IRI. Terminate patterns with .*, or use contains. This is the most common cause of a rule that silently matches nothing.
  • Warning: No rules/facts defined for relation X exits 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.
  • substr is 0-based and clamps or empties on out-of-range input instead of failing.
  • A guard constraint beside a substr does not protect it. Soufflé may evaluate the substr before 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), not substr(l, n-k, k), n-k >= 0.
  • shown/2 is 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, so shown(x, txt) in a rule body drops every bnode binding with no error. An empty literal on a reified axiom annotation has a _:genid subject, so pre-rendering a subject through shown rather than handing violation/3 the raw node loses exactly those cases. The same applies to described/2, which is why registry.dl carries a !described(t, _) fallback arm.
  • Restrictions are blank nodes, so an iri() guard hides them. A rule that walks rdfs:subClassOf and filters targets to IRIs skips every owl:Restriction; anything built on top of it is silently empty. Keep the unfiltered relation and project the named-class view from it, as superclass_expr/parent in vocabulary.dl do.
  • #define does 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, and computed_*_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