Governance proposals¶
Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: not yet written.
The output contract of Nerthus.Lang's frame grammar: the file a consumer reads to find proposed governance transitions, the classes inside it, and the gate that keeps the trained layer switched off. The pipeline that produces it is covered by Work on Nerthus.Lang; the sidecar format the annotation job writes is a different artifact, owned by Annotation sidecars.
There is no route here. The proposer is a Lang-side command, its output is a JSON file, and the daemon consumes it the way it consumes names.tsv.
What produces the file¶
The source is a forum-thread capture: one file, one topic, posts delimited by the forum's own footer bar. Each post becomes one document, identified as <file>#<post id>.
The envelope¶
{
"schema": 1,
"produced": "2026-08-10",
"pipeline": {"spacy": "3.8.13", "spacy_model": "pl_core_news_lg",
"spacy_model_version": "3.8.0", "format": "frames+parser",
"morfeusz_dict": "pl.sgjp.sgjp-2026.06.01"},
"data": {"gazetteer": 1, "lexicon": 1, "config": 1},
"corpus": {"source": "SPRAWOZDANIA.md", "sha256": "e40ac10f…", "posts": 178},
"resolver_names": 3589,
"counts": {"documents": 178, "transitions": 76, "normative": 7,
"unparsed_relevant": 152, "unparsed_documents": 42},
"transitions": [],
"normative": [],
"unparsed_relevant": [],
"unparsed_documents": []
}
| Field | Meaning |
|---|---|
schema |
the row shape's version; a consumer refuses a version it does not read |
pipeline |
the models that produced the analysis, by version rather than by name |
data |
the three data-file versions — gazetteer, frame lexicon, thresholds |
corpus |
the capture and its hash, so a proposal's offsets can be checked |
resolver_names |
how many entity names the run resolved against; 0 means degraded |
resolver_names: 0 means the run had no lore checkout. Proposals are still emitted with their surfaces, and every confidence sits one rung lower than it otherwise would.
The four output classes¶
A run emits four lists, and the second, third and fourth are results rather than leftovers.
| Class | What it holds |
|---|---|
transitions |
a frame matched and the clause records an event |
normative |
a frame matched inside a rule — Rada Fabularna może odwołać Narratora states a power, not an event |
unparsed_relevant |
a sentence naming a governance function and a transition word that no frame matched |
unparsed_documents |
a whole post carrying at least one such sentence and yielding no transition |
The last two exist because silence is forbidden: a document the frames cannot read is flagged whole for a human rather than dropped, and the unparsed sentences are the seed set a trained layer would be built from.
Reading only transitions is the failure mode this contract has, and it is silent. A consumer that builds from transitions and never opens the other three classes is blind in exactly the direction the classes exist to cover, and nothing tells it so: its own output looks complete, and the count it can most easily compute — how much of its work the proposer agrees with — goes up as it gets worse.
What the consumer did not take¶
The reverse comparison. It reads a proposal run and a directory of ledger *.jsonl rows and reports what the machine said that the ledger did not take:
| Route | What it holds |
|---|---|
unread_documents |
a flagged document the ledger cites nowhere |
unread_sentences |
a relevant sentence in a post the ledger cites nowhere |
unread_posts |
a parsed transition in a post the ledger cites nowhere |
unread_facts |
a parsed transition whose (post, ref, subject, op) the ledger has no row for — including in posts it does cite |
unread_facts over-reports by design and its count is not a miss count. A ledger legitimately folds two names to one person, writes one row for a sentence naming two functions, or carries the same fact from a later post. Every row is a question for a human; project 41's own pass through it produced four real rows out of thirty-eight, and one of the four turned out to be a fact the ledger already held under a different post — caught by the replay's own duplicate finding rather than by the route.
The exit code is 1 while anything is unread, the same completed-with-findings code eval uses for a failed floor. A run that reports nothing is the only clean one.
The ledger contract is one line — a row is {"evidence": [{"post": N}]} — so a second consumer of the same proposer output can use this without publishing a schema. The fact key uses the ledger's canonical subject, not the proposer's surface: keying on the surface would report every folded name as uncovered and bury the real ones.
A transition¶
{
"id": "SPRAWOZDANIA.md#46846416/1234/zostac-instrumental/12/9",
"ref": "rada",
"op": "appoint",
"date": "2026-04-01",
"tense": "past",
"register": "realis",
"confidence": "wysoka",
"subject": {"surface": "Daelin", "resolved": "Daelin", "stage": "exact"},
"function": {"surface": "radnym", "lemma": "radny", "display": "Radny"},
"frame": {"id": "zostac-instrumental", "subject_strategy": "dep:nsubj",
"function_strategy": "case:inst"},
"evidence": {"document": "SPRAWOZDANIA.md#46846416", "author": "Sereghith Ranuial",
"quote": "W wyniku wyborów radnym został Daelin [Sereghith Ranuial].",
"start": 123456, "end": 123513}
}
op is one of seven: appoint, resign, remove, suspend, restore, admonish, promote.
ref is the governance domain the function belongs to, or null where the design declares none. A null ref is a worklist row for the consumer, never an invented ref.
register is realis or normative. Only realis rows reach transitions.
confidence walks the normalization ladder — exact, wysoka, srednia, niska. The frame's own confidence is the ceiling; a slot filled by a late strategy and a subject the entity store does not know each cost one rung.
subject.stage says how far the resolution got: exact (the store holds this spelling), names-tsv (a declined form bridged through the annotation dictionary), head-token (one word of a multi-word name resolved), unresolved, or no-store.
frame.subject_strategy and frame.function_strategy name the rule that filled each slot, so a change in recall or precision can be attributed to a strategy rather than to the engine as a whole.
evidence.start and evidence.end index the source file's characters. The quote is the same span with its whitespace collapsed, capped at 300 characters.
An unparsed sentence¶
{
"document": "SPRAWOZDANIA.md#46413980",
"author": "Alyssa Alivolle",
"date": "2024-08-06",
"quote": "Anward złożył beret Radnego, dziękujemy za poświęcony czas.",
"start": 98765, "end": 98824,
"trigger_lemmas": ["złożyć"],
"function_lemmas": ["radny"]
}
The three data files¶
The lexicon is data rather than code, and all three ship inside the package at nerthus_lang/frames/data/.
| File | Holds |
|---|---|
governance-functions.json |
the function nouns, their governance refs, their derived adjectives, and the carrier nouns — with the attestation count each was measured at |
frame-lexicon.json |
one row per frame: trigger lemmas, the slots and their ordered strategies, the transition op, the confidence |
frames-config.json |
every threshold and word list the engine branches on, including the engage gate |
Each declares a version and a loader refuses a version it does not read.
The engage gate¶
The trained layer ships off. It turns on for one surface only when frames-only recall on that surface's eval set has been measured, the model has been measured to beat it, and both numbers are written into frames-config.json:
"engage_gate": {
"surfaces": {
"governance-transitions": {
"enabled": false, "min_recall_gain": 0.05,
"frames_only_recall": 0.2342, "model_recall": 0.2658,
"measured": {"date": "2026-08-12", "unit": "event", "gain": 0.0316,
"opens_the_gate": false, "parts": {}, "controls": {}}
}
}
}
measured is an object rather than a sentence because the two scalars above it are an aggregate over a mixed population, and it carries the parts they are an aggregate of. A surface that cannot be measured at all carries scorable: false there instead, with what would make it scorable — speaker-vs-narration does today.
Writing the numbers does not open the gate. enabled is a separate field and an operator decision; a measured pair with enabled: false is the normal shipped state, and the suite asserts every surface ships off whatever the numbers say. It also asserts a pair is whole or absent: one recall without the other is a number that gets quoted.
Seven refusals, each reported separately: no engage-gate row for the surface, no artifact, no stamp, a stamp whose two copies of the grade disagree with each other, a stamp that no longer describes the installation, a gate left closed, and a gate opened with no measured numbers behind it. Two of them no config edit reaches — a model whose stamp says it was trained on anything other than confirmed rows never proposes, and a stamp that contradicts itself is refused before anything else is read.
The gate runs at the function that consumes a model, not at the command that calls it. nerthus_lang.frames.audit.audit() takes an artifact directory and never a loaded pipeline, so there is no argument a caller can pass that skips the check. A gate placed one layer up, in the command line, protects the command line.
Absence degrades to frames-only, which is the standing behaviour rather than a failure.
The comparison the gate subtracts¶
The gate subtracts two recalls, so the two have to be the same measurement. They were not: the committed floors measure event recall over a corpus — did the pipeline find that governance fact — and a trained artifact measures span recall over held-out quotes, did it mark the right token range. Subtracting one from the other publishes an instrument difference as a gain, which is why both fields sat at null after the model lifecycle shipped.
frames yardstick is the one comparison both configurations can be scored by. It runs the model all the way to proposals rather than stopping at spans:
python -m nerthus_lang frames yardstick <annotation store> <thread capture> \
--corpus sprawozdania-capture --lore <lore checkout> --out comparison.json
- the gold is the sample store's
confirmedrows, not the eval key beside the code — the key and the training set are the same rows, so a model scored against it is scored on what it trained on; - the model-assisted configuration is the frames' proposals plus the model's. A predicted span names a trigger token and an operation, the engine's own slot filling supplies the subject and the function, and the proposal is shaped by the same function the frames use. The frames half is copied rather than recomputed, so the subtraction means what the model added;
- both are scored by the same function, over the same held-out arms, in events.
The divide is by document and k-fold, so every gold row is held out exactly once and no model ever proposes on a document it trained on. A per-label figure therefore carries the label's full count rather than a quarter of it.
The two controls¶
--model none and --model every are the reason a measured gain can be read at all.
| control | what it is | what it must do |
|---|---|---|
none |
a pipeline that predicts no span | reproduce frames-only exactly — otherwise a gain of zero and an assisted path that never ran are one observation |
every |
a pipeline that marks every sentence under every label | move the number — otherwise a gain of zero and a broken seam are one observation |
every is also the per-row ceiling: what choosing triggers differently can buy at all, independent of what any model learned. A row whose ceiling is zero cannot be reached by any trigger choice, so its zero is a property of the gazetteer rather than of the model — which is the difference between unlearned and unreachable.
Report parts, never the total¶
The comparison classifies every gold row by what the two configurations could in principle reach, computed from the shipped data files rather than declared:
| class | meaning |
|---|---|
in-scope |
a ref the gazetteer emits and an operation some frame emits — the genuine head-to-head |
op-has-no-frame |
the label has no frame at all, so frames-only scores zero by construction |
ref-unreachable |
no gazetteer row emits the ref, so neither configuration can file the fact — dead weight in both denominators |
A single recall over the three together would publish the model beats the frames where the mechanism is some rows have no frame, and would drag both figures down by rows nothing can reach.
See also¶
- Work on Nerthus.Lang — running the commands by hand
- Annotation sidecars — the other artifact this repository writes
- The language pipeline — the CI job