How manni graph works
You have built a graph and asked it a question. This page is the model behind it: what the tool reads, what it refuses to guess, and how a page says what it is about.
It sits after the quickstart on purpose. Concepts first loses the reader with ten minutes to spare; no concepts loses the one committing a team.
Nothing is inferred from prose
Section titled “Nothing is inferred from prose”This is the load-bearing sentence. A triple exists because frontmatter, an inline
statement, a link, a heading, an image, a code block or git history put it
there. No model reads
your pages during build, and no heuristic decides that a paragraph is
“probably about authentication”.
The graph is therefore exactly as good as what the pages declare, and a wrong
answer is traceable to a line in a file. The cost is that categorization has to
be written down, which is what graph fill exists to help
with.
What graph reads
Section titled “What graph reads”A docset is read in its own format. graph uses the same parsers as
manni lint, and the same metadata reader as
manni meta validate:
| Format | Extensions | Sections come from | Links come from |
|---|---|---|---|
| Markdown, MDX | .md, .markdown, .mdx | headings | links, link references, and href on any JSX element |
| HTML | .html, .htm, .flsnp | h1–h6 | href on <a>, <area> and <MadCap:xref>, and src on <MadCap:snippetBlock> and <MadCap:snippetText>. Never <link>. |
| DITA, DITA map, DocBook | .dita, .ditamap, .xml | topics and sections | <xref>, <link>, a map’s <topicref>, DocBook <link> and linkend |
| Flare TOC, browse sequence | .fltoc, .flbrs | titled TocEntry entries | Link on each TocEntry |
| AsciiDoc | .adoc, .asciidoc | section titles | xref:, link: and URLs |
| reStructuredText | .rst | section titles | embedded URIs, named references and :doc: |
A directory walk collects every extension in the table but .xml, which is
read when you name the file. That keeps a pom.xml out of the graph.
A section’s anchor is the one the source declares. A Markdown heading has
none, so its slug is made from its title. An HTML id, a DITA @id, an
AsciiDoc [[id]] and a reStructuredText .. _label: are kept verbatim, so a
link written to topic.dita#GUID-A1B2-C3D4 reaches that section. An id holding
a space or a non-ASCII character is slugged instead.
A MadCap Flare topic reads as its target builds it. Its sections come
from the topic with its snippets expanded in place, its variables resolved and
the content the target’s conditions exclude left out. Its links stay what each
file says, under every condition. A link that starts /Content/ or
/Project/ resolves against the folder holding the project’s .flprj, with
no routes: entry. A link target that names a variable resolves through the
variable. One that cannot resolve is skipped. A topic takes its language from
its own lang, then a route’s language, then the project’s xml:lang.
Check a MadCap Flare project walks through each rule.
Indirection derives nothing. A DITA keyref or conref, a
reStructuredText :ref: and an AsciiDoc include:: produce no edge, and no
broken link either. Includes are never read, so a graph depends only on the
files it was given.
What becomes a node
Section titled “What becomes a node”| Node | Minted from |
|---|---|
| Document | One per source file. Carries its path and a content hash. |
| Section | One per heading, with its level, its order in the page, and its slug. |
| Block | One per inline meta statement that is not on a heading, with its lines, its pin and its pin status. |
| Verification | One per verified-by entry on a page, a section or a block. |
| Concept | A SKOS concept, one per term in the termbase, and one per other distinct subject, tag or graph concept reference. |
| Concept scheme | The set the concepts belong to. |
| Product variant | Named by applies-to. |
| Agent | A person, an organization, or a software agent, from frontmatter or git. |
| Audience, persona, journey | One per strategy record, a page declaring type: audience, type: persona or type: journey. |
| Activity and attribution | The PROV record of what produced the graph and who wrote what. |
Every node gets a deterministic IRI, derived from its path or its slug. There are no blank nodes, which is what makes two graphs diffable and the build byte-identical over unchanged input.
Sections being nodes is what lets a link to page.md#heading be checked rather
than merely counted. It is also why a renamed heading shows up as a broken
section reference.
An inline statement describes part of a page. A meta statement on a
heading gives that section its fields. Its graph field types the section
exactly as a graph.sections entry does. Any other statement mints a block,
page.md#install.2 for the second statement under that heading. A statement
before the first heading mints page.md#.1. Its section, or the page, holds it
through dcterms:hasPart.
A statement’s fields derive through the page’s own front-matter mapping, with
the section or block as subject. A section keeps its heading as its title, and
a statement’s language is its own lang. Each verified-by entry is a
Verification node, such as page.md#install.2/verified-by/1. The sections
source carries every statement triple, and the frontmatter, tags and
provenance sources gate their fields as they do for the page.
Blocks are graph data, not passages. graph traverse never steps into one, and
search still indexes whole sections. graph query reads them, so a query can
find every verified block whose text changed since its pin.
What becomes an edge
Section titled “What becomes an edge”build.derive names the sources, and all eight are on by default:
| Source | What it contributes |
|---|---|
frontmatter | Title, description, creator, dates, subjects, and the whole graph: block. |
sections | Headings, their nesting, their order, and the inline meta statements in the body. |
links | Internal references, resolved the way the site resolves them. |
tags | Page-level tags, as concepts. |
images | Images a page carries. |
code | Code blocks and their languages. |
provenance | Revision history and commit agents, from git. |
terms | The termbase, each term as a concept. |
Narrowing the list makes a smaller graph, not a wrong one. A predicate nothing
derives simply has no triples, and graph stats will show the row at zero.
Frontmatter means the page’s metadata as manni meta validate reads it. A
field kept in an external-metadata manifest
derives exactly as one written on the page. That covers a {page}.meta.yaml
beside each page and a manifest a collection shares. Under --no-config no
collection is declared, so no manifest is read.
Links are resolved, not matched. An extensionless link, a directory route,
and a published site path all resolve to the file that serves them, through the
routes config. That is why a broken internal link means the target does not
exist rather than that it was spelled unusually.
One concept scheme
Section titled “One concept scheme”Every concept belongs to one SKOS concept scheme. Under the terms source, the
termbase feeds it too. That is the set of term entries
manni term check reads, from the same config.
Each term becomes a concept at {baseIri}concept/{id}. A term written in
several languages is one concept, joined on its id. Its prefLabel,
altLabel, hiddenLabel, definition and scopeNote carry the term’s
language tag. A term’s notation becomes skos:notation, untagged. Each of
its match fields becomes the SKOS mapping property of that kind, such as
skos:closeMatch, pointing at the IRI it names. A see entry is no concept of
its own. Its label becomes an altLabel of the term it points to.
A concept label on a page resolves through the termbase first. That covers
concepts, tags, keywords, a section’s concepts, and the graph: block’s
label, broader, narrower and related-concepts. A label matches a term
by its label, then its id, then an alt-label, and case is ignored. The page’s
own language is tried first, then the same base language, then any.
So concepts: [PAL] and a term that lists PAL as an alt-label meet on one
node. A label no term defines is a freeform concept at
{baseIri}concept/{slug}. A term’s id defaults to its label’s slug, so most
IRIs stay put when a term page lands.
The hierarchy is the union of every source. Each broader edge comes with its
narrower, and each related edge runs both ways. A concept with no broader
concept is a top concept of the scheme, linked in both directions. A term page
whose graph: block names no label has its term as its primary topic.
With an explicit graph.label, that label stays the primary topic and
foaf:topic links the document to its term.
From tags to a knowledge graph builds one scheme step by step, from freeform tags to term pages and a checked graph.
The content strategy
Section titled “The content strategy”A strategy record is a page that declares type: audience, type: persona or
type: journey. Each one in the build mints a node at
{baseIri}strategy/{type}/{id}, with the id the record declares, or its file
name. The node’s dcterms:title is the record’s title. The record’s own
document points at it through foaf:primaryTopic. When its graph: block
names a label, that label stays the primary topic and foaf:topic links
the document to its record.
| Record | Class |
|---|---|
type: audience | schema:Audience |
type: persona | schema:PeopleAudience |
type: journey | graph:Journey |
The fields that name records become edges, and every edge points away from pages:
| Field | Triple |
|---|---|
A persona’s audience | <persona> dcterms:isPartOf <audience> |
A journey’s personas | <journey> schema:audience <persona> |
A page’s audiences and personas | <page> schema:audience <audience or persona> |
A page’s journeys | <page> dcterms:isPartOf <journey> |
<https://hawkeyexl.github.io/manni/id/strategy/persona/devin> a schema:PeopleAudience ; dcterms:isPartOf <https://hawkeyexl.github.io/manni/id/strategy/audience/ci-engineers> ; dcterms:title "Devin, Platform / CI Engineer" .An edge needs a record of that type and id in the build, and ids compare
ignoring case. An id no record declares derives nothing.
manni strategy check reports it as undefined-reference.
The fields are read at the page level only.
Records are author-facing content, so a --no-authoring build leaves them
out. A graph for readers says
what else it leaves out.
The graph: block
Section titled “The graph: block”A page says what it is about in one frontmatter key:
---title: Getting starteddescription: Install the widget SDK and make your first call.authors: - Priya Ramandate: 2026-01-14updated: 2026-03-02tags: - onboardinggraph: label: Installation type: task---The block holds fifteen keys. Six are SKOS: label, alt-labels, broader,
narrower, related-concepts and concepts. Six are iiRDS typing and scope:
type, applies-to, about-product-lifecycle, about-product-aspect, and
the two negative ones, not-applicable-to and not-about-product-aspect.
Three are structural and hand-curated: sections, revision-of and
derived-from.
The block is closed, so a key that is not one of those fifteen is a schema
error with a line number. There is no graph validate. A graph: block is
frontmatter, so manni meta validate checks it against the page vocabulary.
That is the command every vocabulary in this family is checked with. Put
manni:graph:1.0.0 in the schema set for those pages, and a bare run finds it:
meta: overrides: - collection: site schemas: - manni:graph:1.0.0$ manni meta validate✗ docs/install.md /graph must NOT have additional property 'bogus' (line 3) [manni:graph:1.0.0] manni:graph:1.0.0 graph Guide: https://hawkeyexl.github.io/manni/meta/reference/schemas/manni-graph/manni:graph:1.0.0 is a built-in schema of manni meta, and its
reference page lists every key.
manni graph build reads x-manni-graph-output from the same set, the one manni meta validate resolves for each page.
Two spellings are deliberate and different. Config keys are camelCase
(baseIri, maxTurns); the page vocabulary is kebab-case (alt-labels,
applies-to). They are different surfaces read by different people.
The harvest rule
Section titled “The harvest rule”Some facts can be stated twice, inside the graph block and as a plain
page-level key your site already uses. The rule is:
Deeper wins, and the page level is the fallback. Per fact, not per page.
A graph block that speaks to a fact owns it outright. Where the block is silent,
the page-level twin feeds the graph. A page whose block sets label and
nothing else still contributes its page-level concepts.
Four facts have a page-level twin:
graph key | Page-level fallback |
|---|---|
concepts | concepts |
applies-to | applies-to |
not-applicable-to | not-applicable-to |
revision-of | supersedes |
type is the one whose two altitudes speak different vocabularies. The page’s
type is open; graph.type is a closed iiRDS enum. A page type with a known
counterpart is mapped; one without derives nothing rather than inventing a
type that means something else.
Page-level prerequisites, next-steps and related-pages belong to another
vocabulary and are deliberately not harvested.
Near misses warn
Section titled “Near misses warn”The asymmetry in the rule above has a cost. Inside the graph block a typo is a
schema error, because the block is closed. At the page level the same typo
derives nothing, silently, because a page may carry any key it likes.
So a near miss warns:
$ manni graph buildmanni: docs/guide.md: page key "applies_to" is not read by manni graph and looks like "applies-to" — nothing was derived from itmanni: docs/guide.md: page type "how to" maps to no iiRDS topic type and looks like "how-to" — no graph.type was derivedIt is a warning and never a build failure. A suspicion must not fail a build. The detection is a heuristic, and a heuristic that can block a merge is a heuristic people work around.
Determinism
Section titled “Determinism”manni graph build twice over unchanged inputs is byte-identical. Three rules
hold that up:
- Canonically sorted output, from a hand-written emitter rather than a library’s writer.
- No wall clock, anywhere. Dates come from frontmatter first, then git committer times. Never from now.
- No blank nodes. Every node has a deterministic IRI, sanitized so the output always parses.
That is why the graph can be committed, cached between CI runs, and diffed in a pull request like any other generated file.
Git is detected, never declared
Section titled “Git is detected, never declared”History is used wherever git can run over a repository. A run that finds neither git nor a repository warns once, with git’s own reason, and builds the rest.
There is no config switch for it, and there will not be one. A switch for a detectable fact is one more way to be wrong. A job that requires history expresses that as a shape over the built graph, where it can actually be checked.
Encrypted values
Section titled “Encrypted values”graph never decrypts. A value encrypted with the family key is harvested like any
other and lands in the graph as its ~… token. The graph records that a value
exists and says nothing about what it is. A required private field can
therefore be checked without being published.
Where next
Section titled “Where next”| Goal | Page |
|---|---|
| Ask the graph questions | Explore the graph |
| Write the house rules the graph is checked against | Govern the graph |
Look up a graph: key or a flag | Reference |