Skip to content

Fill the vocabulary

Most of the graph comes out of pages for free: titles, links, headings, code blocks, git history. The categorization does not. Which concept a page owns, which product variant it is about, what it is narrower than. Nobody wrote any of that on a page older than the vocabulary.

manni graph fill proposes those fields from each page’s content and writes the ones that clear a confidence bar. Every written value is attributed, so what lands is a review queue rather than a claim that the corpus is now categorized.

This is Maya’s page.

Terminal window
$ manni graph fill --dry-run docs/guides
proposed docs/guides/install.md (label, concepts, type) — dry run, not written
no-op docs/guides/changelog.md (model proposed nothing new)

The dry run is where the inference calls are spent. Proposals are cached before the confidence gate, so the write pass after it is a cache hit. Re-running at a different --confidence therefore costs nothing at all. Read the proposals, decide the bar, then run it again without --dry-run.

StatusMeaning
filledFields were written to the page, and listed.
proposedThe same, under --dry-run: nothing was written.
completeThe page already carries everything in scope. Not asked.
no-opThe model was asked and proposed nothing new.
skippedNot processed, with the reason. It reads turn budget when the budget ran out, and offline when no local model was there to ask.
errorThe page could not be filled, with why.

A line can carry annotations after the fields:

  • [cached], meaning no inference call was made for this page.
  • [low confidence, not written: type 0.45], meaning proposed, below the bar, reported rather than written.
  • [graph check rejected: broader], meaning the guardrail refused it. See below.
  • [no such section: installing], meaning the model addressed a heading that is not there. That usually means the page was edited after it was described.
Terminal window
$ manni graph fill --confidence 0.85 docs/guides

A proposed value carries the model’s own confidence, 0 to 1, and only values above the bar are written. The default is 0.7, set in config as fill.confidenceThreshold.

Raise it when you are filling a corpus nobody will review closely. Lower it when you are reviewing everything anyway and would rather see the marginal proposals than lose them. Re-gating is free, so this is cheap to experiment with.

--fields names what may be proposed at all, comma-separated and given once:

Terminal window
$ manni graph fill --dry-run --fields label,concepts,type docs/guides

The full list is label, alt-labels, broader, narrower, related-concepts, concepts, type, applies-to, about-product-lifecycle, about-product-aspect, not-applicable-to, not-about-product-aspect and sample-questions. An unknown name is a usage error, with the allowed set in the message.

Narrowing is usually the right first move. A pass that proposes only label and concepts produces a review you can actually do. The SKOS hierarchy keys are also the ones a model is worst at.

--sections also proposes per-section metadata. It is opt-in, because it is a great deal more output. One wrinkle is worth knowing before you use it. Section values are not recorded in meta-provenance, since /graph/sections is one of the three hand-curated pointers. The run says so:

Terminal window
manni: Section metadata was written but is NOT recorded in meta-provenance: /graph/sections is one of the three hand-curated pointers graph refuses, with /graph/revision-of and /graph/derived-from, so recording it would make `manni graph check` report an error. Review section values by hand — the review queue will not list them.

sample-questions drafts the questions readers ask of a page, and pins each one to the lines that answer it. It runs beside the graph fields:

Terminal window
$ manni graph fill --dry-run --fields sample-questions docs/guides
proposed docs/guides/retry.md (sample-questions) — dry run, not written
re-pinned "How do I resume a paused upload?" lines 9 to 12
paired "How do I retry a failed upload?" lines 4 (0.77)
drafted "How often does the client try again?" lines 8 (0.90)

An answer whose lines moved is re-pinned with no model and no turn. A question with no answer is paired with the passage the model quotes, and a page with fewer than fill.questions.perPage questions gets drafts. A draft that is not on the page, repeats a question asked elsewhere, or copies its answer’s words is dropped.

Everything fill adds here is written reviewed: false, so graph check reports it at notice until someone confirms it. Read the diff. Set reviewed: true on a question or a pin you agree with, or delete the question. The CLI reference lists every gate and output line.

Terminal window
$ manni graph fill --dry-run --max-turns 50 docs/

A turn is one inference call. One page is one turn, and a cached page spends none, so the page count of an uncached batch is its size. The cap is checked before the first call, which is what makes it a budget rather than a regret.

A run that exhausts it stops asking and says so:

Terminal window
skipped docs/guides/upgrading.md (turn budget)
--max-turns reached (50); some pages were not processed.

The tool reports no dollar figure, for one reason. Not every model is in a price table, and a cap that silently reads as zero for an unpriced model is worse than no cap. Converting turns to money is your provider’s rate card.

fill is the only verb that reaches a model. It resolves a provider in this order, later winning: the family’s providers.provider, then graph.provider, then --provider, then --local.

Terminal window
$ manni graph fill --dry-run --local docs/guides

--local runs llama-cpp on this machine, over everything else. It is the answer when the pages must not leave the runner, and it is the whole answer. There is no partial mode where some pages go out.

--offline, or fill.offline: true, goes one step further. Nothing leaves the machine, not even a model download. A local model already on disk runs as under --local. With none, fill re-pins moved answers, skips each page that needed a model as offline, and exits 0 with one warning.

Unset everywhere, the provider is auto, detected exactly as manni meta fill detects: an Anthropic key, then an OpenAI key, then the Claude CLI, then a local model. That default is a fact about the machine rather than a decision. Pin a provider in config for anything that runs more than once.

What a call carries is the page’s content and the schema for the fields in scope. If your organization has a data-egress policy, that sentence and --local are the two halves of the conversation.

Before a proposal is written it is simulated in the derived graph and checked against the SHACL shapes. Anything that would violate them is dropped, and the line says which field:

Terminal window
filled docs/guides/install.md (label, concepts) [graph check rejected: broader]

Accepted proposals fold into the guard’s state as the run goes, so two pages in one run cannot jointly corrupt the graph. The second page’s broader is checked against a graph that already holds the first page’s.

Rejection sits downstream of the cache, so a later corpus change can re-admit a proposal without re-asking the model. --no-validate-graph turns the guard off, and there is very little reason to.

Every written value is recorded in the page’s meta-provenance, in the same edit, naming the model and the fields as JSON Pointers:

---
title: Installing
graph:
label: Installation
concepts: [onboarding]
meta-provenance:
- generated-by: claude-fable-5
fields: ["/graph/label", "/graph/concepts"]
---

The record is the point of the command. A surviving entry means unreviewed machine metadata, and deleting it is how you sign off. That makes review a thing you can measure and finish, rather than a thing you meant to do.

meta-provenance is the metadata tool’s record, shared rather than copied. A page filled by manni graph fill and by manni docevals fill carries one entry per model, and neither run disturbs the other’s. The graph block has no provenance key of its own, so this record is the only one.

Set fill.writeProvenance: false and the record is not written. Do not.

fill writes each page in its own format, where manni meta fill would, so the page’s own tooling reads what was written:

PageWhere graph and meta-provenance go
Markdown, MDXYAML frontmatter, created when the page has none.
HTMLOne <meta name="graph"> in <head>, the block as one line of YAML flow.
DITAAn <othermeta name="graph"> in the topic’s <prolog>.
AsciiDoc, reStructuredTextThe page’s YAML fence, edited in place.

A key an external-metadata manifest owns goes to the page’s entry there, whatever the page’s format.

Two kinds of page cannot take a write. An AsciiDoc or reStructuredText page with no fence has nowhere safe to put one, because a bare --- changes how both render. An HTML page with no <head> has no place for a <meta> tag. Each is reported as an error line in manni meta fill’s words, its bytes are left alone, and the run exits 1. Add a fence or a <head>, or keep the page’s graph block in a manifest. The formats reference has the detail.

A graph field a human set is left alone. --force overwrites it, and it is named that way because it is a deliberate act. The values a person wrote are the ones the corpus is actually built on.

Terminal window
$ manni graph fill --dry-run --fields label,concepts,type --max-turns 100 docs/guides
$ manni graph fill --fields label,concepts,type --confidence 0.8 docs/guides
$ git diff docs/guides
$ manni graph build && manni graph check

One directory at a time. Nobody can review a three-thousand-page pull request, and a review nobody does is the same as no record at all.