Skip to content

The model lifecycle

Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: not yet written.

Nerthus.Lang trains a span model from confirmed labelled samples; these routes decide which trained artifact every consumer of the proposer reads. Training is triggered by a person, promotion is performed by a person, and both are recorded. There is no schedule here and no automatic promotion.

Routes

Method Path Cmdlet Cap Write
GET /model/state Get-NerthusModelState entity.read
GET /model/evaluation Get-NerthusModelEvaluation entity.read
GET /model/ledger Get-NerthusModelLedger entity.read
GET /model/runs Get-NerthusModelRun entity.read
POST /model/runs Start-NerthusModelRun admin.index
POST /model/promote Invoke-NerthusModelPromotion admin.index
POST /model/rollback Invoke-NerthusModelRollback admin.index

client/Cmdlets-Model.ps1 wraps all seven. Paths are relative to /v1/api; the cross-cutting contract is on the API reference index. The four reads are replica-safe and poolable; the three writes honour ?dryRun.

/model is its own namespace and deliberately not part of /hygiene. Hygiene is read-only diagnosis that computes a worklist and acts on nothing. These routes own mutable state — which artifact every consumer reads — and carry an audit trail, and a state-changing promote behind a read-only namespace is in the wrong place for anyone to find later.

Why the write half is admin.index

The reads carry entity.read, the same authority as the labelled-sample store all of this is derived from. The writes do not: promotion decides what every consumer of the proposer sees, which is not one - @tag: value line, so entity.write is the wrong shape as well as the wrong weight.

admin.index is the one existing capability whose subject is a derived artifact the whole daemon reads, rebuilt on command — it gates POST /name-index/rebuild. It sits in no seeded role bundle, so it resolves through admin.all alone: Namiestnik, Koordynator, and the machine token.

The capability was picked, not analysed

Capability assignment belongs to a future project, and every route until then takes a fitting existing id rather than minting one. This page records that admin.index was chosen on the fit above and has not been reviewed as an assignment. No new id was minted: role bundles come from a committed roles.json that the seeder writes only when it is absent, and a named token freezes its capability set when it is minted, so a new id would reach nobody until the Rada edited that table and every affected token was re-minted.

Where the artifacts live

.nerthus/models/ under the repository root, written by these routes and read by Nerthus.Lang's frames/registry.py:

.nerthus/models/live.json                        current and previous, per surface
.nerthus/models/promotions.jsonl                 append-only, never rotated
.nerthus/models/runs.jsonl                       every training run a person asked for
.nerthus/models/artifacts/<id>/model/            the spaCy pipeline
.nerthus/models/artifacts/<id>/model.stamp.json  what the trainer stamped it with
.nerthus/models/artifacts/<id>/evaluation.json   what the training run measured

The store is host-local. .nerthus/ is this host's state, so a promotion on the primary does not travel to a replica and a CI job elsewhere sees nothing of it. That is the bound of a design that writes no repository, and the alternative — committing a promotion pointer into the lore corpus — is a write this programme does not make. Distributing an artifact runs the way the pinned Morfeusz wheel already does: a versioned package in the registry, named by a variable an operator sets (the language pipeline).

GET /model/state

?surface= names the engage-gate surface and defaults to governance-transitions; the body lists the surfaces that exist, so a client never hardcodes them. An unknown one is 400 UnknownSurface.

{
  "surface": "governance-transitions",
  "root": "/srv/lore/.nerthus/models",
  "available": true,
  "reason": "",
  "proposer": "frames + model, subject to the engage gate at the consumer",
  "live": { "id": "governance-2026-08-12-b", "grade": "confirmed",
            "heldOutScore": { "precision": 0.5, "recall": 0.0625, "f1": 0.1111 },
            "split": { "documents": { "train": 19, "held_out": 6 },
                       "rows": { "train": 44, "held_out": 16 } },
            "training": { "rows_harvested": 85, "rows_trainable": 60,
                          "excluded_no_document": 25, "excluded_row_ids": [ ... ] },
            "problems": [] },
  "previous": null,
  "candidates": [ ... ],
  "surfaces": [ "governance-transitions", "speaker-vs-narration", "claims" ]
}

training says what the score was computed from. A held-out figure beside a confirmed-label count, with no statement of how many of those rows the model saw, answers a different question than a reader assumes: on the shipped artifact, 85 rows were harvested, 25 carry no locator.document and cannot be placed in either arm, 44 were trained on and 16 scored. The dashboard renders all four beside the score. An artifact whose stamp predates the field answers training: null, and the screen says the numbers are unknown rather than drawing zeros.

proposer: "frames-only" is a normal condition, not an error. Every absence answers with its own reason, because an operator acts on which one it is:

reason What it means
NoModelStore this host has never published an artifact
NoLiveArtifact artifacts exist and none is promoted
LiveArtifactUnusable the pointer names an artifact whose directory, stamp or model/ is missing — the problems list says which
UnknownSurface 400, with the surfaces that do exist

GET /model/evaluation

?artifact= is required and ?surface= defaults as above. An artifact this host does not hold is 404 ModelArtifactNotFound. The route scores nothing: the training run already did, and this daemon will never load spaCy. What it adds is the join — the candidate's measurement, the comparison against what is live, and the floors' provenance recomputed from the sample store at the moment of the decision.

{
  "surface": "governance-transitions", "artifact": "governance-2026-08-12-b",
  "available": true,
  "candidate": { "id": "...", "grade": "confirmed", "heldOutScore": {}, "evaluation": {} },
  "live": null,
  "delta": {
    "againstLive": 0.1176,
    "againstLiveReason": "held-out F1, same scorer and same split rule",
    "againstFrames": 0.0316,
    "againstFramesReason": "EVENT recall of the frames-plus-model configuration minus frames-only, both scored by one scorer over the confirmed sample rows. IT DESCRIBES THE SURFACE, NOT THIS ARTIFACT: ...",
    "engages": false,
    "engagesReason": "promoting names an artifact; it does not turn the model layer on"
  },
  "engageGate": { "enabled": false, "minRecallGain": 0.05,
                  "framesOnlyRecall": 0.2342, "modelRecall": 0.2658,
                  "gain": 0.0316, "clearsTheBar": false,
                  "source": "/srv/.nerthus/models/frames-config.json",
                  "measured": { "date": "2026-08-12", "unit": "event",
                                "parts": { "in-scope": { "events": 177, ... }, ... } } },
  "floors": { "available": true, "labels": 247, "confirmed": 85, "proposed": 162,
              "confirmedBy": [ "operator (anward)" ],
              "authentication": { "transcribed": 85, "tokenAuthenticated": 0 },
              "unconfirmedShare": 0.6559 },
  "refusals": [], "disclosures": [ ... ], "promotable": true
}

againstFrames carries a number now, and it is a surface-level one

This field published null for as long as the two things it would subtract were not the same measurement: the committed eval floors measure event recall — did the proposer find a governance transition somebody hand-labelled — while a trained artifact's held-out figure is span recall over held-out quotes. Different denominators, different units, different populations, and subtracting them would publish an instrument difference as a gain.

That reason stopped being true. python -m nerthus_lang frames yardstick runs both configurations all the way to proposals and scores them with one scorer, in events, over the sample store's confirmed rows — see Governance proposals. The comparison it writes into frames-config.json is what this field reads.

Read it as a statement about the surface, not about this artifact. The number is cross-validated over several models — one per fold, each proposing only on documents it never saw — so it says what turning the model layer on would add to this surface, not what promoting this one artifact would add. The response says so in againstFramesReason and the screen repeats it.

engageGate carries the whole row beside it, including measured.parts, and the parts are not decoration: on governance-transitions the two headline recalls are an aggregate over a population where 19 of 222 rows carry a ref no gazetteer emits and neither configuration can reach them, and 26 carry an operation no frame emits where the frames score zero by construction. A screen rendering the difference alone would report the model wins where the mechanism is some rows have no frame.

clearsTheBar is not enabled. It says what the arithmetic would do; engaging the model layer is a separate operator decision that neither this route nor a promotion takes.

Where the file is found, and what null means now

integrations.lang.frames_config first, then a copy at .nerthus/models/frames-config.json. Both are the same file in the same format, so a host with Nerthus.Lang installed points at the package's own data file and a host that downloads a CI artifact drops a copy into the store. source reports which one was read.

null no longer means this cannot be measured. It means one of two things and the reason says which: this host cannot find a frames-config.json at all, or the file's row for this surface records no measurement yet.

The bar the yardstick had to clear first, and how

Project 40's eval key and its training set are the same 85 rows, id for id — so scoring an artifact against those floors would score a model on events it trained on, and no scorer closes that. The yardstick does not use that key. Its gold is the sample store's confirmed rows, divided by document, k-fold, so every row is held out exactly once and no model ever proposes on a document it trained on.

The held-out score is one sample of a training run

held_out_score is what one run measured. Before the trainer's seed was made to reach the initialiser, five runs of one seed over identical data returned F1 of 0.0000, 0.1053, 0.1111, 0.1176 and 0.2222 — the shuffle was seeded and the tok2vec's initial weights were not. ModelWouldRegress compares exactly that number between two artifacts, so it was comparing samples of noise. The run is deterministic now.

Determinism is not comparability: the seed picks the split

One seed gives one answer every time, and a different seed gives a different held-out set as well as a different model. Measured on this store: seed 40 and seed 99 each hold out six documents and share none of them, over 16 rows against 17, and across eight seeds F1 runs 0.0 to 0.3333 on identical data and code.

So againstLive is a quality difference only when both artifacts were trained under the same seed. --seed defaults to 40 and the training job passes none, so every comparison the shipped pipeline makes today is valid — and the moment somebody passes a second seed it stops being, which is what RegressionIncomparable says. The refusal is not withheld when the seeds differ: an override spelled --seed on the only quality gate in the path is the defect the required-field list above exists to prevent.

What the floors rest on, disclosed at the moment of the decision

floors is computed from repozytorium-adnotacji on every call and never read out of the artifact, because the artifact is the thing being judged. It carries the label count, the grade split, who confirmed, and the distinction that must not be flattened:

  • tokenAuthenticated — the row was ruled through POST /training/samples/confirm, where the author is the caller's own identity.
  • transcribed — the row records an operator's ruling that was entered from a review file rather than through a token-authenticated session. Both are operator rulings; only one has a caller's identity behind it.

Refusals and disclosures

refusals stop a promotion. disclosures do not, and every one of them is written onto the ledger row, so promoting against them is permitted and attributable rather than invisible.

Refusal Raised when
ModelArtifactUnusable the directory, the stamp or model/ is missing or will not parse
ModelStampIncomplete the stamp omits a field the decision reads. Every missing one is named
ModelGradeRefused the artifact is stamped anything but confirmed. No setting overrides it
ModelStampSelfContradictory the top-level grade and the harvest's grade disagree
ModelSurfaceMismatch the artifact was trained for another surface
ModelWouldRegress its held-out F1 is below the live artifact's. acceptRegression: true proceeds and is recorded
ModelAlreadyLive 409 — it is already the live artifact
UnknownSurface 400

An absent field is a refusal, not agreement

Five stamp fields are requiredsurface, grade, harvest.grade, harvest.digest and held_out_score.f1 — and they are checked from one closed list before any comparison runs.

That list exists because the comparisons were first written if (field and field != expected), which fires on a forged value and does nothing at all on a deleted one. Deleting a key was therefore strictly easier than forging one, and an artifact carrying no held_out_score promoted itself over one scoring F1 0.90 with a clean ledger row — ModelWouldRegress is the only quality comparison a promotion makes, and one absent key switched it off. A new comparison adds its field to that list or it inherits the same hole.

Disclosure Raised when
HarvestDrift the store no longer matches the row set the artifact trained on. The digest is re-derived by the daemon from the store rather than read from the stamp
HarvestUncheckable this host has no annotation tree, so the digest cannot be re-derived
AcceptedRegression a regression was promoted deliberately
RegressionUncheckable the live artifact records no held-out score, so the candidate cannot be compared against it
RegressionIncomparable the two artifacts were trained under different or unrecorded seeds, so their held-out scores describe different eval sets
TranscribedConfirmations some confirmed labels were transcribed rather than token-authenticated
NoConfirmedLabels the floors for this surface rest on no confirmed label at all

One check is deliberately absent and is named rather than implied. Whether an artifact's stamp still describes the installation that would run it is not checked here: this daemon is PowerShell and cannot know. model.gate() on the Lang side knows, and refuses there on grade, on a stamp that contradicts itself, and on a runtime that has moved. Claiming the check here would be worse than not making it.

POST /model/promote

{ "artifact": "governance-2026-08-12-b", "surface": "governance-transitions", "acceptRegression": false }

The author is never in the body: it comes from the caller's token. A refusal answers 422 — or 400/409 for the two above — with every reason at once rather than the first, and writes nothing. ?dryRun computes the whole decision and writes nothing either way.

Promoting does not engage the model layer. It names an artifact. frames-config.json ships every surface enabled: false, and model.gate() re-runs every check at the consumer, so a mistaken promotion cannot put weak supervision in front of a reader — the worst it does is name an artifact the gate then declines.

POST /model/rollback

{ "surface": "governance-transitions" }, and nothing else. The previous artifact is retained by name rather than by copy, so reverting is a pointer move and one action. 409 NoRetainedArtifact when there has been no earlier promotion, 409 RetainedArtifactMissing when the retained id is no longer on disk.

Rollback leaves the artifact that was live as the new previous, so a rollback made by mistake is reverted by a second one. The pair is a toggle; the ledger is the deeper history and carries every id.

POST /model/runs and GET /model/runs

POST asks CI for a training run and answers 202 with a job envelope, the /training/samples/confirmations shape. A daemon route never blocks on a pipeline.

A host with no trigger configured still records the request and answers dispatched: false with the reason and the job a person would press by hand:

reason Means
NoTriggerConfigured integrations.model_training.trigger_url is unset in config.json
NoTriggerToken no .nerthus/local/model-training.json on this host
CIUnreachable: … the request never got an answer
CIRefused: HTTP … GitLab answered and declined

A body carrying a token is 400 TokenNotAccepted: the trigger credential is host-local, and a caller supplying its own is using this route as a proxy.

GET /model/runs lists what was asked for, newest first, undispatched requests included — which is the difference between a surface with a dead end and one that says where the action moved to.

GET /model/ledger

Every promotion and rollback this host recorded, newest first, ?surface= optional, paged like every list route. A row carries the artifact, the artifact it replaced, the delta, the floors' provenance as it stood at that moment, every disclosure the operator promoted against, and the named human.

The provenance is copied into the row rather than re-derived on read, because re-deriving it later answers what the store says today and the question an audit asks is what the person was shown.

doesNotCheck rides on the body:

  • whether the artifact a row names is still on disk — a ledger records a decision and is not an inventory,
  • what another host promoted — the store is host-local state,
  • whether the model layer was ever engaged — promotion names an artifact and the engage gate decides separately.

See also