The Axiom Graph Engine
Understanding Spry's semantic graph system
Overview
Axiom is a rule-driven graph engine that transforms Markdown AST into a semantic graph. It discovers and represents relationships between document elements, enabling rich querying and domain-specific projections.
Core Concepts
The Graph
A graph consists of nodes and edges representing relationships between document elements:
interface Graph<Relationship, Edge> {
root: Root; // The MDAST root
edges: Edge[]; // All discovered relationships
}
interface GraphEdge<Rel> {
rel: Rel; // Relationship type
from: Node; // Source node
to: Node; // Target node
}Edge Rules
Rules discover relationships in the AST:
interface EdgeRule<Rel extends string> {
rel: Rel;
apply: (root: Root) => Generator<GraphEdge<Rel>>;
}Each rule:
- Traverses the AST
- Identifies related nodes
- Yields edges representing relationships
Built-in Rules
Spry provides several built-in edge rules for common document relationships.
containedInSection
Discovers hierarchy between headings and content:
# Project
## Setup
This paragraph is contained in "Setup"
```bash task
echo "This task is also in Setup"
```Resulting Edges:
(heading "Project") --containedInSection--> (root)(heading "Setup") --containedInSection--> (heading "Project")(paragraph) --containedInSection--> (heading "Setup")(code "task") --containedInSection--> (heading "Setup")
codeDependsOn
Discovers task dependencies from --dep flags:
```bash a
echo "A"
```
```bash b --dep a
echo "B"
```Resulting Edge:
(code "b") --codeDependsOn--> (code "a")
frontmatterClassification
Applies classifications from document frontmatter:
---
doc-classify:
- select: heading[depth="1"]
role: project
---Resulting Edge:
(heading "Project") --classifiedAs--> ("project")
sectionSemanticId
Assigns semantic identities to sections based on heading text.
# My Project
## Setup PhaseEdges with computed semantic IDs based on heading text.
nodesClassification
Generic node classification based on type and attributes.
Using the Graph
Building a Graph
import { graph, typicalRules } from "@spry/axiom";
// Parse Markdown to MDAST
const root = await parseMarkdown("runbook.md");
// Build graph with typical rules
const g = graph(root, typicalRules());
// Access edges
for (const edge of g.edges) {
console.log(`${edge.from.type} --${edge.rel}--> ${edge.to.type}`);
}Custom Rule Pipeline
import { astGraphEdges, graphEdgesPipeline } from "@spry/axiom";
// Select specific rules
const myRules = [
containedInSection,
codeDependsOn,
// Add custom rules here
];
// Build edges
const edges = Array.from(astGraphEdges(root, graphEdgesPipeline(myRules)));Creating Custom Rules
Basic Rule Structure
import type { EdgeRule, GraphEdge } from "@spry/axiom";
import { visit } from "unist-util-visit";
const myRule: EdgeRule<"myRelationship"> = {
rel: "myRelationship",
apply: function* (root) {
visit(root, "code", (node, index, parent) => {
// Find related nodes and yield edges
if (someCondition(node)) {
yield {
rel: "myRelationship",
from: node,
to: relatedNode,
};
}
});
},
};Example: Link Reference Rule
Discover references between sections:
const linkReferencesRule: EdgeRule<"referencesSection"> = {
rel: "referencesSection",
apply: function* (root) {
const headings = new Map<string, Heading>();
// Collect all headings
visit(root, "heading", (node) => {
const text = toString(node);
headings.set(text.toLowerCase(), node);
});
// Find links to headings
visit(root, "link", (node, index, parent) => {
const href = node.url;
if (href.startsWith("#")) {
const target = headings.get(href.slice(1).toLowerCase());
if (target && parent) {
yield {
rel: "referencesSection",
from: parent,
to: target,
};
}
}
});
},
};Example: Code Import Rule
Track which cells import from others:
const codeImportsRule: EdgeRule<"importsFrom"> = {
rel: "importsFrom",
apply: function* (root) {
const cells = new Map<string, Code>();
// Collect named cells
visit(root, "code", (node) => {
const fm = node.data?.codeFM;
if (fm?.identity) {
cells.set(fm.identity, node);
}
});
// Find import references
visit(root, "code", (node) => {
const value = node.value;
for (const [name, target] of cells) {
if (value.includes(`\${${name}}`) && target !== node) {
yield {
rel: "importsFrom",
from: node,
to: target,
};
}
}
});
},
};Querying the Graph
Filter by Relationship
Find all edges of a specific type:
const dependencies = g.edges.filter(e => e.rel === "codeDependsOn");Find Related Nodes
Get all dependencies for a specific node:
function findDependencies(node: Node, graph: Graph): Node[] {
return graph.edges
.filter(e => e.from === node && e.rel === "codeDependsOn")
.map(e => e.to);
}Traverse Hierarchy
Walk up the containment hierarchy:
function getAncestors(node: Node, graph: Graph): Node[] {
const ancestors: Node[] = [];
let current = node;
while (true) {
const parent = graph.edges.find(
e => e.from === current && e.rel === "containedInSection"
);
if (!parent) break;
ancestors.push(parent.to);
current = parent.to;
}
return ancestors;
}Graph Visualization
DOT Export
Export the graph to Graphviz DOT format for visualization:
import { graphToDot } from "@spry/axiom";
const dot = graphToDot(g);
// Output to Graphviz
console.log(dot);Example Output
digraph {
node_1 [label="heading: Project"];
node_2 [label="heading: Setup"];
node_3 [label="code: install"];
node_4 [label="code: build"];
node_2 -> node_1 [label="containedInSection"];
node_3 -> node_2 [label="containedInSection"];
node_4 -> node_3 [label="codeDependsOn"];
}The Typical Rules Pipeline
The typicalRules() function returns the standard set of edge rules:
export function typicalRules() {
return [
containedInSection,
codeDependsOn,
frontmatterClassification,
sectionSemanticId,
nodesClassification,
// ... additional standard rules
];
}This provides a sensible default for most use cases while remaining customizable.
Performance Considerations
Lazy Evaluation
Edge rules use generators for lazy evaluation, computing edges on demand:
// Edges are computed on demand
for (const edge of astGraphEdges(root, rules)) {
// Processing happens here
}Caching
For repeated queries, materialize the graph once:
// Materialize all edges once
const allEdges = [...astGraphEdges(root, rules)];
// Query multiple times without recomputation
const deps = allEdges.filter(e => e.rel === "codeDependsOn");
const hierarchy = allEdges.filter(e => e.rel === "containedInSection");Best Practices
When creating custom edge rules, follow these guidelines:
- Single Responsibility - Each rule should discover one relationship type
- Yield Early - Use generators to enable lazy evaluation
- Cache Node Data - Build lookup maps for efficiency
- Document Semantics - Clearly define what each relationship means
- Test Edge Cases - Handle empty documents, circular references, and missing targets
Note: Well-designed edge rules are composable and can be combined to create rich semantic models.
Rule Composition Example
Combine multiple rules for comprehensive analysis:
import { graph } from "@spry/axiom";
// Compose custom rules with built-in rules
const myRules = [
...typicalRules(),
linkReferencesRule,
codeImportsRule,
];
const g = graph(root, myRules);
// Now you have all relationships in one graph
console.log(`Total edges: ${g.edges.length}`);
console.log(`Relationship types: ${new Set(g.edges.map(e => e.rel)).size}`);How is this guide?
Last updated on