lantern

pattern-graphnode-anatomy

Pattern: Graphnode Anatomy

Category: Core Architecture
Status: Active
First Implementation: 2025-11-05

Overview

Graphnodes are the composable function units of the Oculus graph. They enable function-calling semantics within markdown documents, allowing one node to invoke another node's executable fence with parameters, transforming the graph into a programmable knowledge network.

Important: Graphnodes are designed to be called remotely, not viewed directly. Think of them like CPU registers and opcodes:

  • Load the registers - Poke parameters into the graphnode's config section
  • Call the opcode - Invoke via graphnode:slug:fetch from another node
  • Read the result - The executed fence returns structured data

Viewing a graphnode directly may show errors like config is not defined because the config injection only happens during remote invocation. This is by design - graphnodes are transform nodes in an instruction set architecture (ISA), not standalone documents.

# The ISA Mental Model

poke('target:config.param1', value1)   # Load register 1
poke('target:config.param2', value2)   # Load register 2  
result = peek('target:fetch')           # Execute opcode, get result

The Mental Model

Think of a graphnode as a function definition embedded in markdown:

┌─────────────────────────────────────────────────┐
│  graphnode-slug (like a function name)          │
├─────────────────────────────────────────────────┤
│  ## config                                      │
│  Parameters with default values (like kwargs)   │
├─────────────────────────────────────────────────┤
│  ## fetch                                       │
│  The executable code (function body)            │
│  Has access to `config` dict                    │
├─────────────────────────────────────────────────┤
│  ## template                                    │
│  Optional Jinja2 template for default render    │
└─────────────────────────────────────────────────┘

Graphnode Structure

Required Sections

## config - YAML fence with default parameters:

base_url: http://localhost:7778
timeout: 5
feature_flag: true

## fetch - Python fence with [execute=true]:

{
  "data": "processed"
}

Optional Sections

## template - Jinja2 template for rendering results:

# Results
{% for item in data %}
- {{ item.name }}: {{ item.value }}
{% endfor %}

Calling a Graphnode

Basic Syntax

```graphnode:target-slug:section-path
param1: value1
param2: value2

MiddlewareConfig:
  option: value
```

Anatomy of the Call

graphnode:fortune:fetch
         │       │
         │       └── Section path (defaults to "fetch")
         │
         └── Target node slug

Parameter Passing (Config Injection)

Parameters in the fence content become the config dict in the target's Python fence:

Caller:

```graphnode:poke-test-data
base_url: http://localhost:8080
timeout: 10
```

Target's fetch fence sees:

config = {
    "base_url": "http://localhost:8080",  # Overridden
    "timeout": 10,                         # Overridden
    "test_node": "poke-test-fixture"       # From default config
}

Execution Flow

The Poke-Peek Pattern

Graphnode execution follows the poke-peek pattern - parameters are actually written to the target node's config section before execution:

┌──────────────────────────────────────────────────────────────┐
│ 1. PARSE                                                     │
│    Parse fence: graphnode:fortune:fetch                      │
│    Extract: slug="fortune", path="fetch"                     │
│    Parse YAML content as params: {source: "stuffy", count: 1}│
└──────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌──────────────────────────────────────────────────────────────┐
│ 2. POKE (Parameter Injection)                                │
│    For each param, actually poke to target node:             │
│    poke("fortune:config.source", "stuffy")                   │
│    poke("fortune:config.count", 1)                           │
│    This makes params inspectable via peek!                   │
└──────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌──────────────────────────────────────────────────────────────┐
│ 3. LOAD TARGET                                               │
│    Load target node's markdown                               │
│    Find section matching path ("fetch")                      │
│    Extract fence within that section                         │
└──────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌──────────────────────────────────────────────────────────────┐
│ 4. CREATE EXECUTION CONTEXT                                  │
│    FenceExecutionContext(                                    │
│        slug="fortune",                                       │
│        mode="rendered",                                      │
│        graph={'config': merged_params}                       │
│    )                                                         │
└──────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌──────────────────────────────────────────────────────────────┐
│ 5. EXECUTE TARGET FENCE                                      │
│    Execute the Python fence with injected config             │
│    Fence code accesses config via `config.get('key')`        │
│    Result stored in `result` variable                        │
└──────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌──────────────────────────────────────────────────────────────┐
│ 6. MIDDLEWARE PIPELINE                                       │
│    Pass result through middleware transformations:           │
│    - TableExtractor (if TableConfig present)                 │
│    - DatetimeNormalizer                                      │
│    - AWSResponseCleaner (for aws:* fences)                   │
│    - FortuneFormatter (for graphnode:fortune:*)              │
└──────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌──────────────────────────────────────────────────────────────┐
│ 7. RETURN                                                    │
│    Return {                                                  │
│        "data": result_data,                                  │
│        "metadata": {                                         │
│            "type": "graphnode",                              │
│            "slug": "fortune",                                │
│            "path": "fetch",                                  │
│            "target_fence": "python[execute=true]"            │
│        }                                                     │
│    }                                                         │
└──────────────────────────────────────────────────────────────┘

Result Contract

The result Variable

Python fences must store their output in a result variable:

# Your computation
data = fetch_something()
processed = transform(data)

# THE CONTRACT: Store output in `result`
result = {
    "items": processed,
    "count": len(processed),
    "timestamp": datetime.now().isoformat()
}

❌ Fence Execution Error: name 'fetch_something' is not defined Traceback (most recent call last): File "/app/oculus/providers/python_provider.py", line 468, in execute exec(fence_content, exec_globals, exec_locals) File "", line 2, in NameError: name 'fetch_something' is not defined

Standard Result Structures

For tables (TableExtractor middleware):

result = {
    "headers": ["Name", "Status", "Value"],
    "rows": [
        ["Item1", "Active", 100],
        ["Item2", "Inactive", 0]
    ]
}

For test suites:

result = {
    "tests": [
        {"test": "Test Name", "status": "PASS", "details": "..."},
        {"test": "Test 2", "status": "FAIL", "details": "..."}
    ],
    "summary": {
        "total": 10,
        "passed": 8,
        "failed": 2,
        "success_rate": "80%"
    }
}

Middleware System

TableConfig Middleware

Format results as markdown tables:

```graphnode:my-data-source:fetch
TableConfig:
  array_path: items
  columns:
    Name: name
    Status: status
    Count: metrics.count
  format: markdown
```

Available Middleware

Middleware Pattern Purpose
DatetimeNormalizer * Convert datetime to ISO strings
TableExtractor * (if TableConfig) Extract/format tables
AWSResponseCleaner aws:* Strip ResponseMetadata
FortuneFormatter graphnode:fortune:* Format fortune quotes
XMLParser http:* Parse XML to dict
SingleObjectFormatter * (if single object) Vertical key-value table

Execution Context

FenceExecutionContext Fields

@dataclass
class FenceExecutionContext:
    slug: str              # Current node slug
    mode: str              # Cache level: raw/substituted/interpolated/rendered
    registry: Registry     # Node registry for resolving slugs
    credentials: dict      # AWS/API credentials
    graph: dict            # {'config': {...}} - injected parameters

Accessing Context in Fences

{
  "config": {},
  "api_key": "default",
  "base_url": "http://localhost:7778"
}

Real-World Examples

Example 1: Test Data Provider

Node: poke-test-data

## config

```yaml
base_url: http://localhost:7778
test_node: poke-test-fixture
timeout: 5
```## fetch

```python[execute=true]
import requests

BASE_URL = config.get('base_url')
tests = []

# Run tests...
r = requests.post(f"{BASE_URL}/api/oculus/poke/...")
tests.append({"test": "Prose Append", "status": "PASS"})

result = {"tests": tests, "summary": {...}}
```

Consumer:

```graphnode:poke-test-data:table
TableConfig:
  array_path: tests
  columns:
    Test: test
    Status: status
  format: markdown
```

Example 2: Fortune Provider

Node: fortune

## config

```yaml
source: stuffy
count: 1
```## fetch

```python[execute=true]
import requests

source = config.get('source', 'stuffy')
count = config.get('count', 1)

r = requests.get(f"http://localhost:8080/api/fortune?count={count}")
result = r.json()
```

Consumer:

```graphnode:fortune:fetch
count: 3
```

Anti-Patterns

Don't: Forget the result variable

# BAD - no result variable
data = process()
print(data)  # Lost!

Don't: Modify external state without care

# CAUTION - side effects
result = requests.post(...)  # Creates/modifies data

Don't: Hardcode values that should be config

# BAD - hardcoded
url = "http://localhost:7778"

# GOOD - configurable
url = config.get('base_url', 'http://localhost:7778')

Do: Always provide sensible defaults

timeout = config.get('timeout', 5)  # Default to 5 seconds

Implementation Files

File Purpose
fence_executor.py:2151-2347 GraphNodeProvider class
middleware.py Middleware pipeline
hierarchical_graph.py:29 FenceContent dataclass
fence_metadata.py Dependency extraction

Related Patterns

  • [[pattern-embedded-tests]] - Embed tests as graphnodes
  • [[pattern-fence-provider]] - Custom fence providers
  • [[middleware-pipeline-pattern]] - Middleware architecture
  • [[data-pipeline-pattern]] - Data transformation patterns

Slots

North

slots:
- patterns
- fence-architecture-pattern

South

slots:
- pattern-embedded-tests
- poke-test-data
- hierarchical-addressing-test-suite
- pattern-embedded-tests

East

slots:
- middleware-pipeline-pattern
- pattern-fence-provider
- middleware-pipeline-pattern

West

slots:
- cmd-poke
- oculus-cli