Skip to content

Finetuning samples

Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: none, retired with Core.

This page is for somebody fine-tuning a model on the Nerthus corpus. You need a token and a URL; you do not need a checkout of anything, and you should not need to read another page first.

The store is repozytorium-probek, the second sample store. The first, labelled samples, holds span labels over a closed vocabulary and is a different thing — if you are training a span model, that is the page you want.

Get the data

One call. Save it, and keep the stamp that comes with it.

curl -s -H "Authorization: Bearer $NERTHUS_TOKEN" \
  "$NERTHUS/v1/api/training/finetune/export?set=session-summary&grade=confirmed&split=train" \
  > export.json

jq -c '.rows[]' export.json > train.jsonl   # what a trainer eats
jq    '.stamp'  export.json > train.stamp.json

train.jsonl is one sample per line, and nothing but the exchange:

{"messages":[{"role":"system","content":"Jesteś narratorem Nerthus. Piszesz sprawozdanie z sesji…"},{"role":"user","content":"00:14 Batory odebrał niedawno zaklęty młot…"},{"role":"assistant","content":"Batory wypróbował nowy młot w jaskiniach na północy."}]}

The body is JSON rather than newline-delimited because this daemon serialises every response as JSON and sets the content type for all of them; serving NDJSON under application/json would be a lie in the header. jq -c '.rows[]' is the whole difference.

What is in it

Measured 2026-08-31 at repozytorium-fabularne-prodkopia b46b0da0 and repozytorium-transkryptow-dev c995445. These figures move; the census on GET /training/finetune/samples is always current.

set rows grade what it is
session-summary 1 257 confirmed a play transcript in, the session summary a narrator wrote out
api-agent 115 proposed an operator's request in, the plan of nerthusd calls out
lore-qa 2 792 proposed a passage in, a grounded answer out — model-written, nothing ruled on

Only session-summary is trainable today. The other two are proposed by construction: a model wrote them and no human has ruled, so ?grade=confirmed returns none of their rows. That is the queue rulings exist to drain, not an oversight.

Read this before you train on session-summary

The set is biased short, and it is not a sampling artefact you can wash out. A pair exists only where the transcript was archived, so a fetcher selected this population, not a sampler. The pairs run a median 61 words against the whole corpus's 92 — 0.66×. That ratio was 0.45× when the archive was smaller and moved toward the corpus as more transcripts were fetched, which supports the selection reading rather than retiring it.

Concretely: a model trained on this learns to write the short kind of summary, and an evaluation set drawn from the same 1 257 will score it well for doing so. Publish that ratio beside any recall figure you report. It travels in the stamp as lengthProfile and in the set's known_bias so you do not have to remember it.

api-agent — for an agent driving this daemon

If you are fine-tuning a model to call this API through MCP or your own integration, this is the set. Its user turn is an operator's request in Polish and its assistant turn is a JSON plan — an ordered list of calls plus one sentence of reason:

{"calls":[{"method":"POST","path":"/v1/api/resolve","body":{"name":"Riveth"}},
          {"method":"GET","path":"/v1/api/entities/{name}"}],
 "why":"Najpierw /resolve, bo operator podaje imię w formie odmienionej…"}

It exists because the composition between endpoints is documented nowhere. Every one of this daemon's 235 routes has a reference page; none of them says that an inflected Polish name goes through POST /resolve first, that a 202 is an envelope whose result is at /jobs/{id}/result, or that POST /characters/{name}/info appends where PUT overwrites.

Four kinds, and fields.kind says which: single 48, chain 34, trap 14, refusal 19. The refusals matter most — a model that invents DELETE /entities/all is worse than one that says the API cannot do it, and a set with no refusals trains the first behaviour.

The plan is JSON so it is machine-checkable, and it was checked: every route named in every row is matched against the live route table, and a plan naming a route this daemon does not dispatch refuses the whole deposit.

Route coverage is published, not claimed: 114 of 235 routes, 52 of 52 families. A model trained on this will be best on what is covered and confident about what is not, which is what the refusal rows are there to counter.

lore-qa — grounded answering

A passage of the corpus in, an answer out — or a statement that the passage does not settle the question. 2 792 rows over 636 sections, 30 % of them deliberately unanswerable, because a QA set with only answerable questions cannot measure confabulation at all, and that is the failure that matters most for a lore assistant.

The answers are unverified, and you should size that risk before training on them. The ingest checks that every locator lands on a real section and warns when an answerable answer shares no vocabulary with its span; nothing checks whether an answer is true. That weak signal fired three times over 2 668 authored pairs and was right twice — two fluent, specific answers about a tribe their passage does not describe, now excluded by name. Two is what the only available instrument found, not a total. Every row is proposed for exactly this reason.

The set is also unbalanced by family — Świat gry 2 321, Wątki 295, Organizacje 102, Bestiariusz 74 — because that is the shape of the corpus. A model trained here will know places and factions much better than beasts.

Sources are the descriptive world-lore files — Świat gry, Organizacje, Bestiariusz, Wątki — with session records excluded: they are event logs, they overlap the session-summary set, and a lore assistant is asked about the world rather than about one evening.

No row is cut from a file the daemon generates, and the contract refuses one that tries. The entity index nerthus.entities.md is rewritten whenever a map pass runs — measured 2026-08-31, it changed hash twice inside twenty seconds — so a locator into it can never keep a true content_hash. Fourteen rows drawn from it were removed rather than re-cut a second time. They were the weakest in the set regardless: an index is metadata, so its questions are field lookups.

Nothing here is ruled on. Do not train on it until it is.

Four transcripts will not fit your context

Named so a truncation is your decision rather than a surprise:

transcript characters ≈ tokens
pastebin.com_KBGiDHpn_47D84DF5BCD5A3A1.json 148 843 ~46 500
pastebin.com_raw_vUR1L5d2_522818A317FFF25D.json 147 162 ~46 000
pastebin.com_raw_nWysK8rX_EDEA55F709B2D164.json 123 413 ~38 600
pastebin.com_raw_U4ERU1br_4F912B41C70719AB.json 109 820 ~34 300

The rest fit comfortably: median ~2 460 tokens, p90 ~7 400, and 116 rows over ~8k.

The split is already applied — do not redo it

?split=train and ?split=eval partition by document, deterministically: sha256(seed|document) < evalShare. Same seed, same partition, on any machine and in any order.

Do not split train.jsonl yourself. These corpora are formulaic — one session recurs across locations in near-identical wording — so a row-level split puts near-duplicates on both sides and produces floors that read healthy and are not. The two arms are disjoint by document and reconcile: 1 139 + 118 = 1 257.

An export whose eval arm would be empty is refused (EmptyEvalArm), because an evaluation that runs on nothing reports a pass.

The stamp, and why you want it

Real output, 2026-08-31:

{
  "set": "session-summary", "grade": "confirmed", "split": "train",
  "rows": 1139, "rowsInSet": 1257, "rowsExcluded": 118,
  "storeCommit": "baedd461a0cec6c0a9c24dc607063924bacf25e2",
  "schemaVersion": 1,
  "splitRule": "by document (locator.document), sha256(seed|document) < evalShare",
  "seed": "nerthus-114", "evalShare": 0.1,
  "documents": 1257, "evalDocuments": 118, "evalRows": 118,
  "servedAt": "2026-08-30T23:40:27Z"
}

storeCommit is the field that matters. Without it your adapter cannot be reproduced — not by you and not by us. The store gains rows and rulings; an export from next month is a different corpus, and the commit is the only thing that says which one you trained on. Keep the stamp next to the weights.

?format=stamp returns it alone, if you want to check before pulling 15 MB.

Routes

Method Path Cmdlet Cap Write
GET /training/finetune/samples log.read
GET /training/finetune/export admin.index
POST /training/finetune/samples/confirm entity.write
POST /training/finetune/samples/confirmations entity.write

The Cmdlet column is empty on purpose, and it is a true statement rather than a gap. That column is a promise that a reader can invoke the name, and it is checked against Nerthus.Core.psd1's FunctionsToExport. The four wrappers exist on the branch that adds these routes and are exported by it; until that lands, naming them here would assert something a reader cannot do. regulations.md set the precedent and this page follows it: an empty cell is true where a name would be false.

Use the routes directly in the meantime — every example below is curl for that reason.

Paths are relative to /v1/api; the cross-cutting contract is on the API reference index.

The export costs more than the read, and here is the argument

The paged read previews a turn at 400 characters. The export hands over whole transcripts — median 8 248 characters, max 148 843 — in one call, to somebody outside this estate. So the read is priced at log.read, the price every route over these bytes already pays for what it quotes, and the export at admin.index, the one existing capability whose subject is a derived artifact the whole daemon reads.

admin.index sits in no seeded role bundle and resolves through admin.all alone. It was picked on that fit and has not been reviewed as a capability assignment, which is the same posture the labelled-sample page takes about its own — recorded here rather than implied.

A named token freezes its capability set when it is minted. If this price changes, your token does not: it has to be re-minted. Session tokens recompute capabilities per request and do not.

GET /training/finetune/samples

Browse the store. ?set=, ?grade=, ?origin=, ?project=, limit/offset.

Turns are previewed, not served: each carries truncated and fullChars, so a cut summary is never mistaken for a short one. Use the export to get them whole.

{ "count": 2, "total": 1271, "filtered": 1257, "available": true, "set": "session-summary",
  "stats": { "bySet": {"session-summary": 1257, "lore-qa": 14},
             "byGrade": {"confirmed": 1257, "proposed": 14},
             "byOrigin": {"human": 1257, "model:claude-opus-5": 14},
             "byAcceptance": {"acceptance:authorship": 1257, "NO-AT": 1257},
             "split": {"session-summary": {"byDocument": 1257, "undocumented": 0, "reconciles": true}},
             "lengthProfile": { "…": "per set, with the corpus figure beside it" } },
  "previewChars": 400,
  "doesNotCheck": [ "…" ] }

stats counts the whole store; items and filtered describe your filter. A census computed over the caller's filter answers a different question every time it is asked.

byOrigin is there so you can see how much of what you are training on a model wrote. Every model-written row names its model and the hash of the prompt that produced it, or it would not have landed.

An unrecognised parameter is dropped in silence, and the route then answers everything

That is this daemon's behaviour on every route, not a quirk of this one. Driven 2026-08-31 against this route: ?wymyslony=abc answers 200 with filtered equal to totalthe whole store, unfiltered — and nothing in the body says the word was never read. A bad value for a parameter this route knows is a 400 by name; a parameter it does not know is not an error.

So every filter echoes itself. ?set=session-summary comes back as "set": "session-summary" on the body, and the key is absent when you did not ask — an unconditional key would claim a filter ran when none did. Compare the echo against what you sent, and compare filtered against total.

The export refuses instead: an unknown set is a 400, and so is a missing one. On a paged read a dropped filter is misleading; on a bulk export it would hand you the whole store.

Rulings

A proposed row is a work-queue entry; a human ruling is what makes it training data.

POST /v1/api/training/finetune/samples/confirm
{ "id": "114/lore-qa/seed-003", "outcome": "correct",
  "content": "Do grupy Elity 2 — tak notuje pole @grupa.",
  "note": "rozwinięte o źródło pola" }
outcome the assistant turn verdict
affirm unchanged affirmed
deny unchanged denied
correct replaced by yours; the old one kept as proposed_content affirmed

A denial keeps the text, because the text is what was denied. A denied row is a hard negative and it says what it is negative for. Train positives on verdict: affirmed and take denied as negatives.

The author is never in the bodyaccepted_by.who comes from your token's identity. Both routes answer the same shape and items is a list at both, including for one decision. ?dryRun on either changes nothing. The batch caps at 250.

An identical second ruling answers unchanged and writes nothing; a differing one is a 409 and the first acceptance is retained.

What a row carries, if you read the store rather than the export

key means
id <project>/<set>/<slug>
set which set, and therefore which system prompt and which document unit
grade confirmed trains, measured is weak supervision, proposed is a queue
messages system, user, assistant
origin human with who, or model with model and prompt_hash
locator both halves — the lore document the answer came from and the transcript the question came from, each with its content hash
provenance the depositing project, artifact and date
dedupe sha256 over the whitespace-normalised turns; two rows sharing one are refused
method.accepted_by kind is authorship or ruling — see below

authorship and ruling are different evidence. A harvested session summary is confirmed because a narrator wrote it, not because anyone approved it. Lokacje/*/Sesje.md is a generated file, so git dates the regeneration rather than the writing and the authorship instant is not recoverable: those rows carry at_unknown with the reason, and the census counts them as NO-AT rather than letting an absence pass unremarked. All 1 257 summary rows are authorship.

What this surface does not check

doesNotCheck rides on every response because you read the body, not this page. In short: presence is not correctness; a confirmed row is one person's ruling or one person's authorship and not a consensus; the length bias is published rather than corrected; locator hashes are checked by the store's own validator at deposit and not here; and an unrecognised parameter fails open.

See also