Linting the layer¶
semantido.lint is a static checker for the seams between claim systems.
Every subsystem in a semantic layer validates itself — SQLAlchemy checks the
schema, the concept registry checks its graph, exporters check their formats.
What nothing checks are the cross-references between them, and those rot
silently: a sql_filter naming a renamed column, a join condition pointing
at a dropped table, a groundings file recorded against a definition that has
since changed.
The linter is Tier-2 static: it never connects to a database and never calls a model. SQL is parsed with sqlglot:
Usage¶
from semantido.lint import lint_layer
findings = lint_layer(layer) # SL001–SL006, SL008–SL010
findings = lint_layer(layer, groundings="groundings.yaml") # adds SL007
for finding in findings:
print(finding)
errors = [f for f in findings if f.severity.value == "error"]
assert not errors, "semantic layer lint failed"
Findings are returned in deterministic order (errors first), so the output diffs cleanly in CI. Errors should gate the build; warnings should not.
Checks¶
| Code | Severity | Meaning |
|---|---|---|
| SL001 | error | sql_filter is not parseable SQL (prose leakage). |
| SL002 | error | sql_filter references a column that does not exist. |
| SL003 | error | Join condition unparseable, unqualified, or unresolvable. |
| SL004 | warning | sample_values inconsistent with the declared type. |
| SL005 | warning | Synonym claimed by multiple tables, or by columns bound to different concepts. |
| SL006 | warning | Registry homonym without a declared DISTINCT_FROM edge. |
| SL007 | error | Groundings staleness: missing anchor or definition_checksum drift. |
| SL008 | error | Grain-mismatched join (see below). |
| SL009 | error | Join equates concepts asserted DISTINCT_FROM (see below). |
| SL010 | error | DISTINCT_FROM concepts claiming the same exactMatch (see below). |
Grain-mismatched joins (SL008)¶
Concepts can declare a grain — the level at which they identify or measure their subject:
isin = registry.concept(
"isin", "Issue-level instrument identifier.", grain="issue")
figi = registry.concept(
"figi", "Venue-level instrument identifier.",
grain="listing", distinct_from=isin)
Grain is free-form: the registry imposes no vocabulary. But declared grains are compared verbatim by the linter: any join equality between columns bound to concepts of different grain is an SL008 error. This is the static form of the classic reference-data trap — joining an issue-level identifier (one ISIN) to a listing-level identifier (many FIGIs) fans out or drops rows, and nothing at the schema tier can see it. The linter can, because the grains are declared.
Joins across a declared distinction (SL009)¶
DISTINCT_FROM asserts that two concepts are not the same thing. SL006
warns when a homonym exists without that edge; SL009 is the other half —
it fires when a join equates two concepts the registry says are distinct.
cp_emir = registry.concept(
"counterparty.emir",
"Either entity party to a derivative contract (EMIR Article 9).",
label="Counterparty", synonyms=["counterparty"], grain="legal_entity")
cp_mifir = registry.concept(
"counterparty.mifir",
"The market-side entity faced in a transaction (MiFIR RTS 22); a client "
"dealt for is NOT a counterparty here.",
label="Counterparty", synonyms=["counterparty"], grain="legal_entity",
distinct_from=cp_emir)
Both concepts have the same grain — each denotes a legal entity — so SL008 has nothing to compare and stays silent. The problem is denotation, not cardinality: the two regimes mean different entities by the same word, and a join equating them produces a query that runs, returns rows, and answers a question nobody asked. SL009 is grain-independent for exactly this reason.
Where both conditions hold — different grain and a DISTINCT_FROM edge —
both findings are reported, since each names a separate reason the join is
wrong.
Contradictory external mappings (SL010)¶
skos:exactMatch is symmetric and transitive (SKOS Reference S44/S45).
So if two concepts both claim an exactMatch to the same external IRI, the
SKOS data model entails that they are interchangeable with each other:
emir = registry.concept(
"counterparty.emir", "...", label="Counterparty",
external=exact_match("fibo", FIBO_COUNTERPARTY))
mifir = registry.concept(
"counterparty.mifir", "...", label="Counterparty",
distinct_from=emir,
external=exact_match("fibo", FIBO_COUNTERPARTY)) # contradiction
The registry now asserts both that the two concepts are distinct and that they are the same thing. An external reasoner over the exported Turtle would reject this; SL010 catches it before export.
skos:closeMatch is deliberately not transitive, precisely to stop
similarity propagating across schemes. Two distinct concepts may legitimately
closeMatch the same external concept, and SL010 stays silent for closeMatch,
broadMatch, narrowMatch and relatedMatch. For a regime-specific reading of a
shared external concept — the EMIR and MiFIR senses of "counterparty" against
FIBO's general one — close_match or broad_match is both the correct
modelling and the non-contradictory one.
What the linter deliberately does not do¶
Coherence of prose descriptions, intent-to-column matching, and definition quality are advisory concerns for an LLM judge — never CI gates. The linter checks only what is mechanically decidable.
There is also one join failure the current checks cannot reach. SL008 and SL009 both require the two sides of an equality to differ — in grain or in denotation. A join fans out when the same concept keys both sides but the tables sit at a finer grain than the key: joining two listing-level feeds on an issue-level ISIN yields the cross product of their venues. Both columns are the same concept at the same grain, so both checks correctly stay silent.
Detecting that needs a table-level grain — "one row per (isin, venue)" — which the layer does not currently declare. Until it does, a fan-out is outside the linter's reach by construction rather than by oversight.