Spry LogoOpsfolio
Contributing and Support

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:

  1. Traverses the AST
  2. Identifies related nodes
  3. 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 Phase

Edges 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,
        };
      }
    });
  },
};

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");

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:

  1. Single Responsibility - Each rule should discover one relationship type
  2. Yield Early - Use generators to enable lazy evaluation
  3. Cache Node Data - Build lookup maps for efficiency
  4. Document Semantics - Clearly define what each relationship means
  5. 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

On this page