Skip to content

kg

Knowledge graph derived from the tagging pass.

The graph is built from item_tags, not from new content: the tagging pass already extracted the concepts. Two measured facts drive the construction:

  1. 2736 of 3879 canonical tags (70%) are used exactly once and connect to nothing, so a graph including them would be mostly isolated points. Filtering at MIN_TAG_USES is what turns this data into a graph.
  2. Variant spellings split hubs -- machine-learning and machinelearning are one concept counted twice, and aliasing merges them (degree 90 -> 111).

Aliasing is applied BEFORE the frequency filter: merging can lift a variant over MIN_TAG_USES that neither spelling would clear alone, so filtering first would silently drop concepts. On the current corpus both orderings happen to agree, so this ordering is guarded by a synthetic test rather than by the live numbers -- see test_aliases_merge_before_filtering.

No networkx: at ~711 nodes, BFS over an adjacency dict is both fast (0.226s for a full build) and obvious.

build_graph(assignments)

Derive (adjacency, edge_weights) from (item_id, tag) pairs.

Aliases first, then the frequency filter, then co-occurrence -- see the module docstring for why that order is load-bearing.

Takes assignments rather than a connection so that ordering can be tested without a database, and so a caller already holding a graph does not derive it twice -- health() did, via communities().

canonical(tag)

Map a tag to its canonical spelling. Identity for unmapped tags.

Two layers, hand-curated first. ALIASES is consulted before and after folding, so the alias table always wins: it is the only place that can merge things folding cannot see (nlp -> natural-language-processing) and the only place that can pick a canonical spelling folding would get wrong (folding alone would elect huggingface, the more frequent spelling, over the hugging-face the table names).

Folding then handles the open-ended cases no hand-list can enumerate: separator variants (machinelearning, fine-tuning/finetuning) and singular/plural pairs (transformer/transformers). On the live corpus this merges 79 variant pairs the table never listed.

A tag only folds onto a spelling that is already NAMED -- an alias target or a [fold_canonical] entry. An unrecognised fold key is left alone rather than rewritten to a computed singular, because measurement showed that computing one is both useless and harmful: of 588 tags a computed fold touched on the live corpus, 506 had no merge partner at all. Those gained nothing and cost accuracy, renaming established tags (agentic-workflows x457, neural-networks x91) and minting non-words (diabetes -> diabete, stochastic-processes -> stochastic-processe). Worse, a plural is often the term of art -- mixture-of-experts and scaling-laws are not "one expert" or "one law".

So folding is a merging rule, never a renaming rule: it fires only where a canonical spelling is already known, which keeps canonical() a pure function of the tag and leaves the graph's node names stable.

central(conn, metric='degree', limit=10)

Most-connected (degree) or most-bridging (betweenness) concepts.

communities(conn, min_size=3, graph=None)

Topic clusters by modularity (Louvain phase 1), seeded for determinism.

Label propagation was used here first and produced ONE cluster holding 95% of the graph. That was not a corpus artefact: 331 of 347 nodes really are a single connected component (this reading is all machine learning, so every concept links to every other eventually), and label propagation has nothing to stop a dense hub's label spreading over an entire component. Weighting its votes by edge weight made it strictly worse -- the hub's edges are the heaviest, so weighting fed the hub. Raising MIN_EDGE_WEIGHT did not help either: at weight >= 3 the graph is still one component, just with 156 of 347 nodes left.

(Those counts are from a 347-node graph; the corpus has since grown to 721 nodes over 5,222 items and the largest community is 28%. The conclusion held on re-measurement -- the shape is the same -- but the numbers are a snapshot, not a constant.)

Modularity is what actually separates them, because its - k_i * k_c / 2m term penalises joining a community that is already large: a node only joins if its links there beat what random chance would predict. On the same graph this returns 13 communities with the largest at 32% of nodes, and they are coherent -- quantum-chemistry pulls together electronic-structure, solid-state-physics and statistical-mechanics; security groups cryptography with prompt-injection.

Determinism (which the tools depend on) comes from iterating nodes in sorted order, breaking equal modularity gains on the lexically smaller community, and never using randomness. Only phase 1 of Louvain runs -- no graph coarsening -- since at this scale it already resolves topics and the result stays a direct node->community map.

health(conn)

Metrics that say whether the graph is usable, not just how big it is.

These are the numbers that caught real problems on 2026-08-11: singleton_rate showed one tagging model minting 85% one-off tags where another minted 74%, and largest_community_pct at 95% exposed the clustering bug that made kg_communities useless.

distinct_tags and singleton_rate count CANONICAL tags over distinct items -- the same vocabulary build_graph filters. Counting raw rows instead (as this did) made the metric blind to the merging it exists to watch: every alias added left singleton_rate unmoved, because both spellings were still counted separately, and a tag on two copies of one item read as reused when the graph saw it once.

neighbors(conn, node, limit=20)

Direct neighbours of node, strongest edge first. Unknown -> empty list.

Every returned row is adjacent to node, so distance is always 1 and weight is always the real co-occurrence weight of that edge. Ranking is by edge weight descending, name as tie-break, then truncated to limit.

This deliberately does NOT walk further than one hop. A depth parameter existed and was removed: multi-ring traversal produced four successive defects, the last of which reported genuine direct neighbours as distance=2, weight=None whenever a small limit cut them from ring 1 and the walk re-reached them through a survivor. Every honest fix needed a further rule about starvation, ranking proxies, or re-entry, and each rule bought a new edge case. kg_path answers multi-hop questions exactly and cheaply; a truncated breadth-first walk answered them approximately and, as shipped, sometimes wrongly.

resolve_or_raise(name, members, *, kind)

name canonicalised and checked against members, or a ValueError.

One canonicalise-check-raise shape for two callers that ask the same question against two different membership sets: mcp/feed._resolved_tag (every stored tag, since a tag used once is a legitimate search filter even though it never enters the graph) and mcp/knowledge._path (graph concepts only). kind ('tag' or 'concept') parametrises the message so each refusal still names the right recovery tool -- kg.concepts(prefix= ...) for a tag, kg.concepts() for a concept -- rather than flattening the two into one generic wording.

Raises rather than returning a sentinel because both callers already convert a raised error into their own ToolError; this only supplies the kind-specific message once instead of twice.

resolve_query(text, adjacency)

A user's phrasing turned into a node name the graph actually holds.

Distinct from canonical, deliberately. canonical() maps a STORED tag to its canonical spelling and is a pure function used when building the graph; this maps what a person or a model TYPED to a node, and is used only at lookup. Merging them would let a query's spelling rules leak into the graph's node names.

Measured: kg.neighbors refused 'reinforcement learning' and 'Reinforcement Learning' while 'reinforcement-learning' returned 95 neighbours. Both refusals are correct about the stored name and useless to the caller, and gemma4:e2b hit exactly this -- it called kg_path(source="transformers", target="reinforcement learning"), because spaces are how the question was asked.

Falls back to the input unchanged when nothing matches, so an unknown concept is still refused rather than silently resolved to something near.

shortest_path(conn, source, target)

BFS shortest path. None when the two are in different components.

tag_assignments(conn)

Every (item_id, tag) pair. The only storage read the graph needs.