Spry LogoDocumentation
Contributing and Support

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:

RelationshipDescriptionExample
containedInSectionNode is within a sectionCode block inside a section
parentHeadingNode's parent headingParagraph under "Setup" heading
isDecoratorForDecorator points to target@setup decorator on section
isImportPlaceholderImport generates cellImported code block
hasFrontmatterDocument has frontmatterYAML at document start
sectionHasSemanticIdSection has @id decoratorSection 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 access
  • edges - Immutable array of all relationship edges
  • rels - Set of unique relationship types found in the graph
  • relCounts - 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.png

Visualization 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 data
  • prevEdges - Edges from previous rules in the pipeline

Returns:

  • An iterable of edges to add to the graph
  • false to 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:

  1. Yield previous edges (preserves pipeline)
  2. Visit specific node types
  3. 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 bag
  • MyDataType - TypeScript type for stored data
  • Node - 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 BagKeyPurpose
headingLikeNodeDataBagheadingLikeMark heading-like nodes
docFrontmatterDataBagdocumentFrontmatterDocument frontmatter
graphEdgesVFileDataBagedgesExtra 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

  1. Minimize AST traversals - Cache results from visits
  2. Use specific visitors - Visit only node types you need
  3. Lazy evaluation - Generators don't compute until consumed
  4. 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

On this page