Context
invariant arrived on chains (#214), because a chain was the only place
that already knew two documents were related and had a rendered page to hang
the reading on. The check itself never needed the chain: invariants.held()
takes a document and a field name, and the edge test is one intersection.
Declaring a chain over a relation that leaves its scheme crashed. _load
builds docs from the chain’s scheme and fills the spine from
relations.edges(), which is cross-scheme aware and returns targets in the
far scheme; lines_of then reads neighbours[other] for a code that was
never loaded and raises KeyError. _load’s own docstring claimed a filter
that would have prevented it and that did not exist.
The crash is the symptom. The thing the consumer wanted was the assertion, not
the page: SOTA source LIT claims a practice and the paper under it are about
the same thing, and there is no sequence there to render — one practice, one
paper, a two-node line per practice, which the practice’s own page already
shows.
Decision
invariant is a key on a declared reference:
schemes:
SOTA:
references:
source:
scheme: LIT
invariant: tags
It asserts the edge: the two documents this relation joins share a value in the named field. It holds whether or not a chain walks the field, and the relation may cross schemes.
chains.<name>.invariant stays, and asserts the edge and the line —
every member of a connected component holding one value in common. That is
the part only a chain can assert, because transitivity is what a chain is. A
relation says something about the pair it joins and nothing about what else
either end is joined to, so the path check does not follow it.
Declared both ways on one field, they are one assertion and report once, attributed to the relation: it is the narrower declaration, and its key is the one a reader would change.
Two checks come with it, both eager, both for the reason every other
declaration in luria.yaml is validated eagerly — a declaration that fires on
everything and one that fires on nothing are equally hard to see:
- The invariant field must be nameable on both schemes. The assertion is symmetric, so a far scheme that cannot hold the field at all makes every edge a finding.
- A chain whose
relationorsiblingpoints at another scheme is refused, naming the key and pointing atreferences.<field>.invariant.
Alternatives considered
- Teach the walker to carry far-scheme nodes.
_loadknows the target scheme from the reference spec and could load it. It buys a rendered page nobody asked for, and it needs a new rule for depth and ordering at a node with no outgoing spine, which a far-scheme leaf always is. A chain would stop being a sequence within one family and become a bipartite graph with sequence vocabulary. - Make
outputoptional, so an invariant can be declared with no view. The cheapest patch, and it was the one this issue guessed at. It leaves the assertion filed under a feature whose whole subject is rendering sequences, so the config would say “walk this as a line, transitively, and render nothing” in order to mean “these two share a tag”. The far-scheme crash would also still have to be solved separately. - Filter the spine to codes that land, as
_load’s docstring promised. This makes the crash go away by making a cross-scheme chain render an empty page, and an empty page is indistinguishable from a correct one (DP-15). Kept only as the reason the refusal is a refusal. - Status quo. The assertion stays unavailable for any relation that crosses a scheme, which is most of what joins a claim to its evidence, and the crash stays a crash.
Consequences
Unbound.chain is now Unbound.declared_by, holding a chain’s name or a
relation as SCHEME.field; the report’s Chain column follows it. Nothing
outside this repository read the field.
A project that declares invariant on both a chain and the relation it walks
pays for the redundancy in config only — the finding still appears once.
Fired once on a real case before being trusted, as the working agreement asks:
anthology-of-the-sota, whose SOTA.source is a cross-scheme relation and
was the crash in #272. With invariant: tags declared on it the report
renders 67 unbound edges over that record’s 220 practices, beside the 22 its
two same-scheme chains already find. Whether 67 is a record to fix or a
default to leave alone is that record’s call, which is the point: it can now
be asked.
Left open: the report shows relation findings and chain findings in one table
distinguished only by the Declared by column, and an edge finding and a path
finding still mean different things to a reader who has not read this
decision.