Skip to content

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.

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.

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:

FormatExtensionsSections come fromLinks come from
Markdown, MDX.md, .markdown, .mdxheadingslinks, link references, and href on any JSX element
HTML.html, .htm, .flsnph1–h6href on <a>, <area> and <MadCap:xref>, and src on <MadCap:snippetBlock> and <MadCap:snippetText>. Never <link>.
DITA, DITA map, DocBook.dita, .ditamap, .xmltopics and sections<xref>, <link>, a map’s <topicref>, DocBook <link> and linkend
Flare TOC, browse sequence.fltoc, .flbrstitled TocEntry entriesLink on each TocEntry
AsciiDoc.adoc, .asciidocsection titlesxref:, link: and URLs
reStructuredText.rstsection titlesembedded 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.

NodeMinted from
DocumentOne per source file. Carries its path and a content hash.
SectionOne per heading, with its level, its order in the page, and its slug.
BlockOne per inline meta statement that is not on a heading, with its lines, its pin and its pin status.
VerificationOne per verified-by entry on a page, a section or a block.
ConceptA SKOS concept, one per term in the termbase, and one per other distinct subject, tag or graph concept reference.
Concept schemeThe set the concepts belong to.
Product variantNamed by applies-to.
AgentA person, an organization, or a software agent, from frontmatter or git.
Audience, persona, journeyOne per strategy record, a page declaring type: audience, type: persona or type: journey.
Activity and attributionThe 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.

build.derive names the sources, and all eight are on by default:

SourceWhat it contributes
frontmatterTitle, description, creator, dates, subjects, and the whole graph: block.
sectionsHeadings, their nesting, their order, and the inline meta statements in the body.
linksInternal references, resolved the way the site resolves them.
tagsPage-level tags, as concepts.
imagesImages a page carries.
codeCode blocks and their languages.
provenanceRevision history and commit agents, from git.
termsThe 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.

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.

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.

RecordClass
type: audienceschema:Audience
type: personaschema:PeopleAudience
type: journeygraph:Journey

The fields that name records become edges, and every edge points away from pages:

FieldTriple
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.

A page says what it is about in one frontmatter key:

---
title: Getting started
description: Install the widget SDK and make your first call.
authors:
- Priya Raman
date: 2026-01-14
updated: 2026-03-02
tags:
- onboarding
graph:
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:

manni.config.yaml
meta:
overrides:
- collection: site
schemas:
- manni:graph:1.0.0
Terminal window
$ 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.

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 keyPage-level fallback
conceptsconcepts
applies-toapplies-to
not-applicable-tonot-applicable-to
revision-ofsupersedes

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.

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:

Terminal window
$ manni graph build
manni: docs/guide.md: page key "applies_to" is not read by manni graph and looks like "applies-to" — nothing was derived from it
manni: docs/guide.md: page type "how to" maps to no iiRDS topic type and looks like "how-to" — no graph.type was derived

It 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.

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.

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.

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.

GoalPage
Ask the graph questionsExplore the graph
Write the house rules the graph is checked againstGovern the graph
Look up a graph: key or a flagReference