Axiom API
Semantic graph engine for transforming Markdown ASTs into queryable graphs
Overview
Axiom is Spry's semantic graph engine that transforms Markdown Abstract Syntax Trees (ASTs) into queryable graphs through composable edge rules. It provides the foundation for understanding document structure and relationships.
Why Axiom?
Traditional Markdown processors give you a tree structure. Axiom adds semantic relationships - dependencies, containment, references - enabling powerful workflows like task orchestration, content generation, and document analysis.
Core Concepts
Graph Edges
Edges represent relationships between AST nodes:
interface GraphEdge<Relationship extends string> {
readonly rel: Relationship; // Relationship type
readonly from: Node; // Source node
readonly to: Node; // Target node
}Common Relationships
Understanding these built-in relationships helps you navigate and query documents:
| Relationship | Description | Example |
|---|---|---|
containedInSection | Node is within a section | Code block inside a section |
parentHeading | Node's parent heading | Paragraph under "Setup" heading |
isDecoratorFor | Decorator points to target | @setup decorator on section |
isImportPlaceholder | Import generates cell | Imported code block |
hasFrontmatter | Document has frontmatter | YAML at document start |
sectionHasSemanticId | Section has @id decorator | Section marked with @id |
Relationship Direction
Edges flow from dependent to dependency or from child to parent. For example, a code block from points to the section to that contains it.
Rule Context
Rules receive context for processing the AST:
interface RuleContext {
root: Root; // mdast root node
}
interface TypicalRuleCtx extends RuleContext {
root: Root;
// Additional context as needed
}Building Graphs
Basic Usage
Here's how to create a graph from a Markdown file:
import { graph } from "./lib/axiom/graph.ts";
import { markdownASTs } from "./lib/axiom/io/mod.ts";
for await (const md of markdownASTs(["./doc.md"])) {
const g = graph(md.mdastRoot);
console.log("Relationships:", [...g.rels]);
console.log("Edge counts:", g.relCounts);
for (const edge of g.edges) {
console.log(`${edge.rel}: ${edge.from.type} -> ${edge.to.type}`);
}
}Quick Start
This basic example reads a Markdown file, builds its semantic graph, and displays all relationships. It's a great starting point for exploring Axiom's capabilities.
Graph Properties
The graph object provides comprehensive access to relationships:
interface Graph<Relationship, Edge> {
root: Root; // Original AST
edges: readonly Edge[]; // All edges
rels: Set<string>; // Unique relationship types
relCounts: Record<string, number>; // Count per type
}Key Properties:
root- The original mdast Root node for direct AST accessedges- Immutable array of all relationship edgesrels- Set of unique relationship types found in the graphrelCounts- Statistics showing how many edges exist for each relationship type
Graphviz Export
Visualize your document's semantic structure:
import { graphToDot } from "./lib/axiom/graph.ts";
const dot = graphToDot(g, { graphName: "MyDocument" });
await Deno.writeTextFile("graph.dot", dot);
// Generate image with: dot -Tpng graph.dot -o graph.pngVisualization Tips
Graphviz exports help you understand document structure visually. Use different layout engines like dot (hierarchical), neato (force-directed), or fdp (spring model) depending on your needs.
Edge Rules
Rules are the heart of Axiom - they're generator functions that produce edges by analyzing the AST.
Rule Signature
type GraphEdgesRule<Rel, Ctx, Edge> = (
ctx: Ctx,
prevEdges: Iterable<Edge>
) => Iterable<Edge> | false;Parameters:
ctx- Context containing the AST root and additional dataprevEdges- Edges from previous rules in the pipeline
Returns:
- An iterable of edges to add to the graph
falseto clear previous edges (rare, used for filtering)
Rule Pipeline
Rules execute as a pipeline, with each receiving edges from the previous:
function* astGraphEdges(root, { prepareContext, rules }) {
const ctx = prepareContext(root);
let current = [];
for (const rule of rules(ctx)) {
const produced = rule(ctx, current);
if (produced === false) {
current = [];
continue;
}
current = produced;
}
for (const edge of current) {
yield edge;
}
}Pipeline Composition
The pipeline pattern enables composability - rules can extend, filter, or transform edges from previous rules without modifying existing code.
Built-in Rules
containedInSection
Establishes section hierarchy by linking nodes to their containing sections:
// Produces edges like:
{ rel: "containedInSection", from: codeNode, to: sectionNode }Use Cases:
- Finding all code blocks in a section
- Building section hierarchies
- Scoping task dependencies
parentHeading
Links nodes to their immediate parent heading:
// Produces edges like:
{ rel: "parentHeading", from: paragraphNode, to: headingNode }Use Cases:
- Extracting content by heading
- Building table of contents
- Organizing document structure
sectionSemanticId
Links @id decorators to sections for semantic identification:
// Produces edges like:
{ rel: "sectionHasSemanticId", from: sectionNode, to: decoratorNode }Use Cases:
- Named task references
- Cross-document linking
- Semantic annotations
Creating Custom Rules
Custom rules let you add domain-specific relationships to your documents.
Basic Rule
Here's a simple rule that creates relationships for custom language blocks:
import { Root } from "types/mdast";
import { visit } from "unist-util-visit";
import { GraphEdgesRule, RuleContext } from "./lib/axiom/edge/rule/mod.ts";
export function myCustomRule<
Rel extends string,
Edge extends { rel: Rel; from: Node; to: Node }
>(): GraphEdgesRule<Rel, RuleContext, Edge> {
return function* (ctx, prevEdges) {
// Pass through previous edges
for (const edge of prevEdges) {
yield edge;
}
// Generate new edges
visit(ctx.root, "code", (node, _index, parent) => {
if (parent && node.lang === "myLang") {
yield {
rel: "myRelationship" as Rel,
from: parent,
to: node,
} as Edge;
}
});
};
}Rule Template
This basic pattern works for most custom rules:
- Yield previous edges (preserves pipeline)
- Visit specific node types
- Yield new edges based on your criteria
Rule with Custom Context
For rules that need additional data:
interface MyContext extends RuleContext {
root: Root;
metadata: Map<string, unknown>;
}
export function contextualRule(): GraphEdgesRule<string, MyContext, GraphEdge<string>> {
return function* (ctx, prevEdges) {
// Access custom context
const meta = ctx.metadata.get("key");
for (const edge of prevEdges) {
yield edge;
}
// Use context in edge generation
visit(ctx.root, (node) => {
if (meta && shouldCreateEdge(node, meta)) {
yield {
rel: "contextual",
from: node,
to: getRelatedNode(node, meta),
};
}
});
};
}Filtering Rule
Remove or filter edges from previous rules:
export function filterRule<Rel extends string, Edge extends GraphEdge<Rel>>(
keep: (edge: Edge) => boolean
): GraphEdgesRule<Rel, RuleContext, Edge> {
return function* (_ctx, prevEdges) {
for (const edge of prevEdges) {
if (keep(edge)) {
yield edge;
}
}
};
}Example Usage:
// Keep only code-related edges
const codeOnlyRule = filterRule(
edge => edge.from.type === "code" || edge.to.type === "code"
);Transforming Rule
Modify edges from previous rules:
export function transformRule<Rel extends string, Edge extends GraphEdge<Rel>>(
transform: (edge: Edge) => Edge
): GraphEdgesRule<Rel, RuleContext, Edge> {
return function* (_ctx, prevEdges) {
for (const edge of prevEdges) {
yield transform(edge);
}
};
}Example Usage:
// Normalize relationship names
const normalizeRule = transformRule(edge => ({
...edge,
rel: edge.rel.toLowerCase()
}));Registering Custom Rules
Integrate your custom rules into Axiom's pipeline.
Create Custom Rule Pipeline
Build a rule pipeline that includes both standard and custom rules:
import { typicalRules } from "./lib/axiom/edge/pipeline/typical.ts";
import { myCustomRule } from "./my-rules.ts";
export function* customRules() {
// Include standard rules
yield* typicalRules();
// Add custom rules
yield myCustomRule();
}Use Custom Pipeline
Apply your custom rule pipeline to generate edges:
import { astGraphEdges } from "./lib/axiom/edge/orchestrate.ts";
const edges = astGraphEdges(root, {
prepareContext: (root) => ({ root, metadata: new Map() }),
rules: () => customRules(),
});
for (const edge of edges) {
console.log(edge);
}Build and Query Graph
Create a graph using your custom pipeline:
import { graph } from "./lib/axiom/graph.ts";
const g = graph(root, {
prepareContext: (root) => ({ root }),
rules: () => customRules(),
});
// Query your custom relationships
const customEdges = g.edges.filter(e => e.rel === "myRelationship");Rule Order Matters
Rules execute in the order they appear in your pipeline. Ensure dependencies between rules are respected - for example, rules that filter edges should come after rules that create them.
Data Bags
Data bags provide type-safe storage on AST nodes without polluting the global namespace.
Creating a Data Bag
import { dataBag } from "./lib/axiom/mdast/data-bag.ts";
const myDataBag = dataBag<"myKey", MyDataType, Node>(
"myKey",
() => defaultValue
);Parameters:
"myKey"- Unique key for this data bagMyDataType- TypeScript type for stored dataNode- Node types this bag can attach to() => defaultValue- Factory function for default values
Using Data Bags
// Check if node has data
if (myDataBag.is(node)) {
const data = node.data.myKey;
// TypeScript knows data is MyDataType
}// Get existing data or create with default
const data = myDataBag.getOrSet(node);// Set data explicitly
myDataBag.set(node, newValue);Built-in Data Bags
Spry provides several pre-configured data bags:
| Data Bag | Key | Purpose |
|---|---|---|
headingLikeNodeDataBag | headingLike | Mark heading-like nodes |
docFrontmatterDataBag | documentFrontmatter | Document frontmatter |
graphEdgesVFileDataBag | edges | Extra edges |
Type Safety
Data bags provide compile-time type checking and runtime validation, preventing common errors when attaching metadata to AST nodes.
Querying Graphs
Once you have a graph, you can query it in various ways.
Filter by Relationship
Find all edges of a specific type:
const sectionEdges = g.edges.filter(
e => e.rel === "containedInSection"
);Find Nodes
Extract nodes based on edge relationships:
const codeNodes = g.edges
.filter(e => e.to.type === "code")
.map(e => e.to);Build Adjacency Map
Create an adjacency list for graph traversal:
const adjacency = new Map<Node, Node[]>();
for (const edge of g.edges) {
if (!adjacency.has(edge.from)) {
adjacency.set(edge.from, []);
}
adjacency.get(edge.from)!.push(edge.to);
}Traverse Hierarchy
Recursively traverse the document hierarchy:
function* descendants(node: Node, edges: GraphEdge[]): Generator<Node> {
const children = edges
.filter(e => e.rel === "containedInSection" && e.to === node)
.map(e => e.from);
for (const child of children) {
yield child;
yield* descendants(child, edges);
}
}Query Performance
For repeated queries, consider building indices (like adjacency maps) once and reusing them rather than filtering edges multiple times.
Projections
Projections transform graphs into domain-specific models optimized for particular use cases.
FlexibleProjection
Convert graph to relational model for querying and analysis:
import { flexibleProjectionFromFiles } from "./lib/axiom/projection/flexible.ts";
const model = await flexibleProjectionFromFiles(paths);
// Access documents
for (const doc of model.documents) {
console.log(doc.id, doc.label);
}
// Access nodes
for (const [id, node] of Object.entries(model.nodes)) {
console.log(id, node.type, node.label);
}
// Access hierarchies
const hierarchy = model.hierarchies["containedInSection"];Use Cases:
- Document analysis and exploration
- Building custom tooling
- Generating documentation
- Creating visualizations
PlaybookProjection
Convert graph to executable model for runbooks:
import { playbooksFromFiles } from "./lib/axiom/projection/playbook.ts";
const { tasks, directives, issues } = await playbooksFromFiles(paths);
for (const task of tasks) {
console.log(task.taskId(), task.taskDeps());
}Use Cases:
- Executing workflows
- Task orchestration
- Dependency resolution
- Automation pipelines
FlexibleProjection
Best for document analysis, querying, and building custom tools
PlaybookProjection
Best for executing tasks, running workflows, and automation
Best Practices
Rule Design
Pass Through Edges
Always yield previous edges unless you're intentionally filtering
Use Generators
Generators are memory-efficient and support lazy evaluation
Single Responsibility
Each rule should focus on one type of relationship
Document Semantics
Clearly document what relationships mean and when they're created
Performance Tips
- Minimize AST traversals - Cache results from visits
- Use specific visitors - Visit only node types you need
- Lazy evaluation - Generators don't compute until consumed
- Build indices - For repeated queries, create lookup maps
Performance Consideration
Visiting all nodes with visit(root, (node) => {...}) is expensive. Prefer visiting specific types: visit(root, "code", (node) => {...}).
Testing Rules
Write tests to ensure your rules produce correct edges:
Deno.test("myRule produces expected edges", async () => {
const md = `# Test\n\`\`\`myLang\ncode\n\`\`\``;
const root = parse(md);
const edges = [...astGraphEdges(root, {
prepareContext: (r) => ({ root: r }),
rules: () => [myCustomRule()],
})];
assertEquals(edges.length, 1);
assertEquals(edges[0].rel, "myRelationship");
});Testing Best Practices:
- Test both edge presence and absence
- Verify edge direction (from/to)
- Test with various document structures
- Include edge cases and malformed input
Example: Custom Dependency Rule
Let's build a complete custom rule that creates dependency edges between tasks:
Define the Rule
Create a rule that finds task dependencies from code block attributes:
import { visit } from "unist-util-visit";
import { GraphEdgesRule, RuleContext } from "./lib/axiom/edge/rule/mod.ts";
export function taskDependencyRule<
Rel extends string,
Edge extends { rel: Rel; from: Node; to: Node }
>(): GraphEdgesRule<Rel, RuleContext, Edge> {
return function* (ctx, prevEdges) {
// Pass through existing edges
yield* prevEdges;
const tasks = new Map<string, Node>();
// First pass: collect all tasks
visit(ctx.root, "code", (node) => {
const taskId = node.meta?.taskId;
if (taskId) {
tasks.set(taskId, node);
}
});
// Second pass: create dependency edges
visit(ctx.root, "code", (node) => {
const deps = node.meta?.dependsOn || [];
for (const depId of deps) {
const depNode = tasks.get(depId);
if (depNode) {
yield {
rel: "dependsOn" as Rel,
from: node,
to: depNode,
} as Edge;
}
}
});
};
}Register the Rule
Add it to your custom rule pipeline:
export function* myProjectRules() {
yield* typicalRules();
yield taskDependencyRule();
}Use in Your Application
Apply the rule to build graphs with task dependencies:
const g = graph(root, {
prepareContext: (root) => ({ root }),
rules: () => myProjectRules(),
});
// Find all task dependencies
const taskDeps = g.edges.filter(e => e.rel === "dependsOn");How is this guide?
Last updated on