Part III: Object-Spatial Programming (OSP)#
In this part:
- Introduction to OSP - Concepts, motivation, core example
- Nodes - Node declaration, entry/exit abilities
- Edges - Edge declaration, typed connections
- Walkers - Walker declaration, visit, report, disengage
- Graph Construction - Creating and connecting nodes
- Graph Traversal - Filtered traversal, entry/exit events
- Data Spatial Queries - Edge references, attribute filtering
- Typed Context Blocks - Type-based dispatch
Related Sections:
- Graph Operators - Connection and edge reference syntax
- Pipe Operators - Spawn traversal modes
Introduction to OSP#
1 What is OSP?#
Object-Spatial Programming models data as graphs and computation as mobile agents (walkers) that traverse the graph. Instead of calling functions on objects, walkers visit nodes and perform operations based on location.
2 Why OSP?#
- Natural graph modeling: Social networks, knowledge graphs, state machines
- AI agent architecture: Walkers are natural representations of AI agents
- Separation of concerns: Data (nodes/edges) separate from behavior (walkers)
- Spatial context:
here,visitorprovide natural context
3 Core Concepts#
| Concept | Description | Keyword |
|---|---|---|
| Node | Graph vertex holding data | node |
| Edge | Connection between nodes | edge |
| Walker | Mobile agent that traverses | walker |
| Root | Entry point to graph | root |
| Here | Walker's current location | here |
| Visitor | Reference to visiting walker | visitor |
4 Complete Example#
node Person {
has name: str;
has age: int;
}
edge Knows {
has since: int;
}
walker Greeter {
can greet with Root entry {
visit [-->];
}
can say_hello with Person entry {
print(f"Hello, {here.name}!");
visit [-->];
}
}
with entry {
# Build graph
alice = Person(name="Alice", age=30);
bob = Person(name="Bob", age=25);
root ++> alice;
alice +>: Knows(since=2020) :+> bob;
# Spawn walker
root spawn Greeter();
}
Nodes#
Nodes are the vertices of your graph -- they hold data and can have abilities that execute when walkers visit them. Think of nodes as "smart objects" that know when they're being visited and can react accordingly. Unlike regular objects, nodes can be connected via edges and participate in graph traversals.
1 Node Declaration#
node Person {
has name: str;
has age: int = 0;
can greet with Visitor entry {
print(f"Hello from {self.name}");
}
}
# Node with no data
node Waypoint { }
2 Node Entry/Exit Abilities#
Abilities triggered when walkers enter or exit. The event clause syntax is:
Where TypeExpression is optional - if omitted, the ability triggers for ALL walkers.
node SecureRoom {
has clearance_required: int;
# Generic entry - triggers for ANY walker (no type filter)
can on_enter with entry {
print("Someone entered");
}
# Typed entry - triggers only for Inspector walkers
can check_clearance with Inspector entry {
if visitor.clearance < self.clearance_required {
print("Access denied");
disengage;
}
}
# Typed entry for Root walker - in node abilities, the type in
# 'with Type entry' refers to the *walker* type visiting this node,
# NOT the node type. This triggers when a walker of type Root visits.
can at_root with Root entry {
print("A Root-type walker is visiting this node");
}
# Walker exiting
can on_exit with Inspector exit {
print("Inspector leaving");
}
# Multiple walker types (union)
can process with Walker1 | Walker2 entry {
print("Processing for Walker1 or Walker2");
}
}
Event Clause Forms:
| Form | Triggers When |
|---|---|
with entry |
Any walker enters (no type filter) |
with TypeName entry |
Walker of TypeName enters |
with Root entry |
Walker of type Root visits (in node context, the type refers to the walker type) |
with Type1 \| Type2 entry |
Walker of either type enters |
with exit |
Any walker exits |
with TypeName exit |
Walker of TypeName exits |
3 Node Inheritance#
node Entity {
has id: str;
has created_at: str;
}
node User(Entity) {
has username: str;
has email: str;
}
Edges#
Edges are first-class connections between nodes. Unlike simple object references, edges can carry their own data (like relationship strength or timestamps) and have their own types. This lets you model rich relationships -- "Alice knows Bob since 2020" becomes natural to express. Use typed edges when the relationship itself has meaningful attributes.
1 Edge Declaration#
edge Friend {
has since: int;
has strength: float = 1.0;
}
edge Follows { } # Edge with no data
edge Weighted {
has weight: float;
def get_normalized(max_weight: float) -> float {
return self.weight / max_weight;
}
}
2 Edge Entry/Exit#
Walkers can trigger abilities on edges during traversal:
edge Road {
has distance: float;
can on_traverse with Traveler entry {
visitor.total_distance += self.distance;
}
}
Known Limitation
Edge entry/exit abilities are not currently triggered during walker traversal. This feature is planned but not yet implemented. For now, perform edge-related logic in the walker's node abilities instead.
3 Directed vs Undirected#
Edge direction is determined by connection operators:
node Item {}
with entry {
a = Item();
b = Item();
a ++> b; # Directed: a → b
a <++> b; # Undirected: a ↔ b (creates edges both ways)
}
Walkers#
Walkers are mobile agents that traverse the graph, executing abilities at each node they visit. Unlike functions that you call, walkers go to data. They maintain state throughout their journey, making them ideal for tasks like collecting information across a graph, implementing AI agents that navigate knowledge structures, or processing pipelines where context accumulates. Spawn a walker with root spawn MyWalker() to begin traversal.
1 Walker Declaration#
walker Collector {
has items: list = [];
has max_items: int = 10;
can start with Root entry {
print("Starting collection");
visit [-->];
}
can collect with DataNode entry {
if len(self.items) < self.max_items {
self.items.append(here.value);
}
visit [-->];
}
}
2 Walker State#
Walkers maintain state throughout their traversal:
node DataNode {
has value: int;
}
walker Counter {
has count: int = 0;
can start with Root entry {
self.count += 1;
visit [-->];
}
can count_nodes with DataNode entry {
self.count += 1;
visit [-->];
}
}
with entry {
root ++> DataNode(value=1) ++> DataNode(value=2);
walker_instance = Counter();
root spawn walker_instance;
print(f"Counted {walker_instance.count} nodes"); # Output: 3
}
Note: Walker abilities must specify which node types they handle. Use
Rootfor the root node and specific node types for others. A genericwith entryonly triggers at the spawn location.
3 The visit Statement#
The visit statement tells the walker where to go next. It doesn't immediately move -- it queues nodes for the next step of traversal. This queue-based approach lets you control breadth-first vs depth-first traversal and handle cases where there's nowhere to go (using the else clause).
Traversal Must Be Explicit
Without a visit statement in an ability, the walker stops at the current node. If a walker visits root and then reaches a Person node but the Person ability has no visit [-->], the walker will not continue to the next person. Traversal must be explicitly requested at each step.
Basic Syntax:
node Item {}
walker Visitor {
can go with Item entry {
visit [-->]; # Visit all outgoing nodes
visit [<--]; # Visit all incoming nodes
visit [<-->]; # Visit both directions
}
}
With Type Filters:
node Person {}
edge Friend { has since: int = 2020; }
walker Visitor {
can filter with Person entry {
visit [-->](?:Person); # Visit Person nodes only
visit [->:Friend:->]; # Visit via Friend edges only
visit [->:Friend:since>2020:->]; # Via Friend edges with condition
}
}
With Else Clause:
node Item {}
walker Visitor {
can traverse with Item entry {
visit [-->] else { # Fallback if no nodes to visit
print("No outgoing edges");
}
}
}
Direct Node Visit:
node Item {}
walker Visitor {
has target: Item | None = None;
can direct with Item entry {
visit here; # Visit current node
visit self.target; # Visit node stored in walker field
}
}
Queue Insertion Index:
The visit : index : [-->] syntax controls where in the walker's traversal queue new destinations are inserted. This enables DFS, BFS, and custom traversal strategies:
node Item {}
walker Visitor {
can traverse with Item entry {
visit : 0 : [-->]; # Insert at FRONT of queue (DFS behavior)
visit : -1 : [-->]; # Insert at END of queue (BFS behavior)
visit : 2 : [-->]; # Insert at position 2 in queue
}
}
| Syntax | Queue Position | Effect |
|---|---|---|
visit [-->] |
End (default) | BFS-like -- standard breadth-first traversal |
visit : 0 : [-->] |
Front | DFS-like -- depth-first by inserting at front |
visit : -1 : [-->] |
End | Explicit BFS -- same as default |
visit : N : [-->] |
Position N | Custom insertion point |
Out-of-bounds indices fall back to appending at the end.
4 The report Statement#
Send data back without stopping. Each report appends to the .reports array and also prints the value to stdout.
Note
report value; both adds value to .reports and prints it to stdout. Keep this in mind when reading output from walker examples.
node DataNode {
has value: int = 0;
}
walker DataCollector {
can start with Root entry {
visit [-->];
}
can collect with DataNode entry {
report here.value; # Continues execution
visit [-->];
}
}
with entry {
root ++> DataNode(value=1);
result = root spawn DataCollector();
all_values = result.reports; # List of reported values
}
5 The disengage Statement#
The disengage statement immediately terminates a walker's traversal. Use it when the walker has found what it was looking for (like a search hitting its target) or when a condition means further traversal would be pointless. It's the walker equivalent of return from a recursive function.
walker Searcher {
has target: str;
can search with Person entry {
if here.name == self.target {
report here;
disengage; # Stop traversal
}
visit [-->];
}
}
6 Spawning Walkers#
node Item { has value: int = 0; }
walker MyWalker {
has param: int = 0;
can visit with Root entry {
visit [-->];
}
can collect with Item entry {
report here.value;
visit [-->];
}
}
with entry {
node1 = Item(value=1);
node2 = Item(value=2);
node3 = Item(value=3);
root ++> node1 ++> node2 ++> node3;
# Basic spawn
result = root spawn MyWalker();
# Spawn with parameters
result = root spawn MyWalker(param=10);
# Access results
print(result.reports); # All reported values
}
When to Use Walkers vs Functions#
Jac provides two ways to expose server logic: def:pub functions and walker types. Choose based on your needs:
def:pub Functions |
Walkers | |
|---|---|---|
| Best for | Simple stateless CRUD, quick prototyping | Graph traversal, per-user data, production apps |
| Auth | Shared data (no user isolation) | Per-user root node (walker:priv enforces auth) |
| Data access | Direct: [root -->] |
Traversal: visit [-->], here |
| API style | Function call → HTTP endpoint | Spawn walker at node |
| State | Stateless | Carries state across nodes via has properties |
Rule of Thumb
Start with def:pub to prototype quickly. Switch to walkers when you need authentication, per-user data isolation, or multi-step graph traversal. The walker:priv visibility modifier automatically enforces that the walker runs on the authenticated user's private root node.
Walkers as REST APIs#
Public walkers automatically become HTTP endpoints when you run jac start:
node Todo {
has title: str;
has done: bool = False;
}
walker add_todo {
has title: str;
can create with Root entry {
new_todo = here ++> Todo(title=self.title);
report new_todo;
}
}
walker list_todos {
can list with Root entry {
for todo in [-->](?:Todo) {
report todo;
}
}
}
# Run as API server
jac start app.jac
# Call via HTTP
curl -X POST http://localhost:8000/walker/add_todo \
-H "Content-Type: application/json" \
-d '{"title": "Learn OSP"}'
Walker has properties become the request body. The report values become the response. See Part IV: Full-Stack and jac-scale Reference for full API documentation.
7 Walker Inheritance#
walker BaseVisitor {
can log with entry {
print(f"Visiting: {here}");
visit [-->];
}
}
walker DetailedVisitor(BaseVisitor) {
override can log with entry {
print(f"Detailed visit to: {type(here).__name__}");
visit [-->];
}
}
8 Special References#
These keywords have special meaning in specific contexts:
| Reference | Valid Context | Description | See Also |
|---|---|---|---|
self |
Any method/ability | Current instance (walker, node, object) | Part II: Functions |
here |
Walker ability | Current node the walker is visiting | Walkers |
visitor |
Node ability | The walker that triggered this ability | Nodes |
root |
Anywhere | Root node of the current graph | Graph Construction |
super |
Subclass method | Parent class reference | Part II |
init |
Object body | Constructor method name | Part II |
postinit |
Object body | Post-constructor hook | Part I |
props |
JSX context | Component props reference | Part IV: Full-Stack |
Usage examples:
node SecureRoom {
has required_level: int;
# 'visitor' refers to the walker visiting this node
# 'self' refers to this node instance
can check with Inspector entry {
if visitor.clearance >= self.required_level {
print("Access granted to " + visitor.name);
}
}
}
walker Inspector {
has clearance: int;
has name: str;
# 'here' refers to the current node being visited
# 'self' refers to this walker instance
can inspect with SecureRoom entry {
print(f"{self.name} inspecting room at {here}");
print(f"Room requires level {here.required_level}");
}
can start with Root entry {
# 'root' is always the graph root
print(f"Starting from root: {root}");
visit [-->];
}
}
When each reference is valid:
| Context | self |
here |
visitor |
root |
|---|---|---|---|---|
| Walker ability | Walker instance | Current node | N/A | Graph root |
| Node ability | Node instance | N/A | Visiting walker | Graph root |
| Object method | Object instance | N/A | N/A | Graph root |
| Free code | N/A | N/A | N/A | Graph root |
Graph Construction#
1 Creating Nodes#
node Person {
has name: str;
has age: int;
}
with entry {
# Create and assign
alice = Person(name="Alice", age=30);
bob = Person(name="Bob", age=25);
# Inline creation in connection
root ++> Person(name="Charlie", age=35);
}
The ++> operator returns a list
The ++> operator returns a list containing the created node(s). Access the node with [0] index:
2 Creating Edges#
node Person { has name: str; }
edge Friend { has since: int = 2020; }
edge Colleague { has department: str = ""; }
with entry {
alice = Person(name="Alice");
bob = Person(name="Bob");
# Untyped (generic edge)
alice ++> bob;
# Typed edge
alice +>: Friend(since=2020) :+> bob;
# Bidirectional typed
alice <+: Colleague(department="Engineering") :+> bob;
}
3 Chained Construction#
node Item {}
edge Start {}
edge Next {}
edge End {}
with entry {
a = Item();
b = Item();
c = Item();
d = Item();
# Build chains in one expression
root ++> a ++> b ++> c ++> d;
# With typed edges
root +>: Start :+> a +>: Next :+> b +>: Next :+> c +>: End :+> d;
}
4 Deleting Nodes and Edges#
node Person { has name: str; }
edge Friend {}
with entry {
alice = Person(name="Alice");
bob = Person(name="Bob");
alice +>: Friend :+> bob;
# Delete specific edge
alice del --> bob;
# Delete node
del bob;
}
# Delete current node from within a walker
walker Cleanup {
can check with Todo entry {
if here.completed {
node_id = here.id;
del here;
report {"deleted": node_id};
}
}
}
Cascade Deletion Pattern#
Delete a node and all its related nodes:
walker:priv DeleteWithChildren {
has parent_id: str;
can search with Root entry {
visit [-->];
}
can delete with Todo entry {
# Delete if this is the target or a child of the target
if here.id == self.parent_id or here.parent_id == self.parent_id {
del here;
}
}
}
5 Built-in Graph Functions#
| Function | Description |
|---|---|
jid(node) |
Get unique Jac ID of object |
jobj(node) |
Get Jac object wrapper |
grant(node, user) |
Grant access permission |
revoke(node, user) |
Revoke access permission |
allroots() |
Get all root references |
save(node) |
Persist node to storage |
commit() |
Commit pending changes |
printgraph(root) |
Print graph structure to stdout (output depends on graph size; may require logging configuration to see results) |
node Person { has name: str; }
with entry {
alice = Person(name="Alice");
bob = Person(name="Bob");
secret_node = Person(name="Secret");
id = jid(alice);
save(alice);
printgraph(root);
}
Graph Traversal#
1 Basic Traversal#
Walker traversal is queue-based (BFS-like by default):
walker BFSWalker {
can start with Root entry {
print(f"Starting at: {here}");
visit [-->];
}
can traverse with Person entry {
print(f"Visiting: {here.name}");
visit [-->]; # Queue all outgoing for later visits
}
}
Traversal Semantics: Deferred Exits#
Walker traversal uses recursive post-order exit execution. Entry abilities execute immediately when entering a node, while exit abilities are deferred until all descendants are visited. This means exits execute in LIFO order (last visited node exits first), similar to function call stack unwinding.
node Step { has label: str; }
walker Logger {
can start with Root entry {
visit [-->]; # Begin traversal from root
}
can enter with Step entry {
print(f"ENTER: {here.label}");
visit [-->];
}
can leave with Step exit {
print(f"EXIT: {here.label}");
}
}
# Setup: root -> A -> B -> C
# root spawn Logger();
#
# Output:
# ENTER: A
# ENTER: B
# ENTER: C
# EXIT: C ← innermost exits first
# EXIT: B
# EXIT: A ← outermost exits last
This is useful for aggregation patterns where you need to collect results from children before processing the parent (e.g., calculating subtree totals, building trees bottom-up).
2 Filtered Traversal#
node Person { has age: int = 0; }
edge Friend { has since: int = 2020; }
walker FilteredWalker {
can start with Root entry {
visit [-->]; # Start traversal from root
}
can traverse with Person entry {
# By node type
visit [-->](?:Person);
# By edge type
visit [->:Friend:->];
# Combined: Friend edges to Person nodes since 2020
visit [->:Friend:since > 2020:->](?:Person);
}
}
3 Entry and Exit Events#
node Room {
can on_enter with Visitor entry {
print("Entering room");
}
can on_exit with Visitor exit {
print("Exiting room");
}
}
Data Spatial Queries#
1 Edge Reference Syntax#
node Person {}
edge EdgeType {}
edge Edge { has attr: int = 0; has a: int = 0; has b: int = 0; }
edge Friend {}
walker Traverser {
can query with Person entry {
# Basic forms
outgoing = [-->]; # All outgoing nodes
incoming = [<--]; # All incoming nodes
both = [<-->]; # Both directions
# Typed forms
via_type = [->:EdgeType:->]; # Outgoing via EdgeType
# With conditions
filtered = [->:Edge:attr > 0:->]; # Filter by edge attribute
# Node type filter
people = [-->](?:Person); # Filter result nodes by type
# Get edges vs nodes
edges = [edge -->]; # Get edge objects
friends = [edge ->:Friend:->]; # Typed edge objects
}
}
Use [edge -->] when you need to access edge attributes or visit edges directly.
2 Attribute Filtering#
node User {
has age: int = 0;
has status: str = "";
has verified: bool = False;
}
edge Friend { has since: int = 2020; }
edge Link { has weight: float = 0.0; }
walker Filter {
can query with User entry {
# Filter by node attributes (after traversal)
adults = [-->](?age >= 18);
active = [-->](?status == "active");
# Filter by edge attributes (during traversal)
recent_friends = [->:Friend:since > 2020:->];
strong_connections = [->:Link:weight > 0.8:->];
}
}
3 Complex Queries#
node Person { has age: int = 0; }
edge Friend { has since: int = 2020; }
edge Colleague {}
walker Querier {
can complex with Person entry {
# Chained traversal (multi-hop)
friends_of_friends = [here ->:Friend:-> ->:Friend:->];
# Mixed edge types
path = [here ->:Friend:-> ->:Colleague:->];
# Combined with filters
target = [->:Friend:since < 2020:->](?:Person, age > 30);
}
}
Typed Context Blocks#
1 What are Typed Context Blocks?#
Typed context blocks let you conditionally execute code based on the runtime type of the current node. Instead of writing separate abilities for each node type, you can handle multiple types within a single ability using ->Type{code} blocks. This is especially useful when a walker visits a heterogeneous graph with different node types.
The syntax uses ->Type{code} with no space between the arrow and type name:
walker AnimalVisitor {
can visit with Animal entry {
# Typed context block for Dog (subtype of Animal)
->Dog{print(f"{here.name} is a {here.breed} dog");}
# Typed context block for Cat (subtype of Animal)
->Cat{print(f"{here.name} says meow");}
# Default case (any other Animal type)
->_{print(f"{here.name} is some animal");}
}
}
Syntax Notes:
- No space between
->and the type name:->Dog{not-> Dog { - Opening brace immediately follows the type
- Code typically on same line with closing brace
- Use
->_for default/catch-all case
Known Limitation
The ->_{} wildcard/default case is not currently supported at runtime and will produce a name '_' is not defined error. Use an explicit base type or else branch instead.
2 Tuple-Based Dispatch#
walker Processor {
can process with (Node1, Node2) entry {
# Handle when visiting involves both types
}
}
3 Context Blocks in Nodes#
Nodes reacting to different walker types:
node DataNode {
has value: int;
can handle with Walker entry {
->Reader{print(f"Read value: {self.value}");}
->Writer{
self.value = visitor.new_value;
print(f"Updated to: {self.value}");
}
}
}
4 Complex Typed Context Example#
From the reference examples, showing inheritance-based dispatch:
walker ShoppingCart {
can process_item with Product entry {
print(f"Processing {type(here).__name__}...");
# Each subtype gets its own block
->Book{print(f" -> Book: '{here.title}' by {here.author}");}
->Magazine{print(f" -> Magazine: '{here.title}' Issue #{here.issue}");}
->Electronics{print(f" -> Electronics: {here.name}, warranty {here.warranty_years}yr");}
self.total += here.price;
visit [-->];
}
}
Common Walker Patterns#
CRUD Walker#
# Create
walker:priv CreateItem {
has name: str;
can create with Root entry {
new_item = here ++> Item(name=self.name);
report new_item[0];
}
}
# Read (List)
walker:priv ListItems {
has items: list = [];
can collect with Root entry { visit [-->]; }
can gather with Item entry { self.items.append(here); }
can finish with Root exit { report self.items; }
}
# Update
walker:priv UpdateItem {
has item_id: str;
has new_name: str;
can find with Root entry { visit [-->]; }
can update with Item entry {
if here.id == self.item_id {
here.name = self.new_name;
report here;
}
}
}
# Delete
walker:priv DeleteItem {
has item_id: str;
can find with Root entry { visit [-->]; }
can remove with Item entry {
if here.id == self.item_id {
del here;
report {"deleted": self.item_id};
}
}
}
Search Walker#
node Item {
has id: str;
has name: str;
}
def calculate_relevance(item: Item, query: str) -> int {
return 1;
}
walker:priv SearchItems {
has query: str;
has matches: list = [];
can start with Root entry {
visit [-->];
}
can check with Item entry {
if self.query.lower() in here.name.lower() {
self.matches.append({
"id": here.id,
"name": here.name,
"score": calculate_relevance(here, self.query)
});
}
}
can finish with Root exit {
self.matches.sort(key=lambda x: any: x["score"], reverse=True);
report self.matches;
}
}
Hierarchical Traversal#
] inside a def method is not standard walker traversal syntax. A production implementation would use recursive walker spawning or accumulate results via entry/exit abilities. -->
walker:priv GetTree {
def build_tree(node: any) -> dict {
children = [];
for child in [node -->] {
children.append(self.build_tree(child));
}
return {
"id": node.id,
"name": node.name,
"children": children
};
}
can start with Root entry {
tree = self.build_tree(here);
report tree;
}
}
Pseudocode
The above example illustrates the hierarchical traversal pattern conceptually. The [node -->] syntax inside a def method and the use of here outside a walker ability context may not work as written. In practice, use recursive walker spawning or accumulate results via entry/exit abilities.
Aggregate Walker#
walker:priv GetStats {
has total: int = 0;
has completed: int = 0;
can count with Root entry {
visit [-->];
}
can tally with Todo entry {
self.total += 1;
if here.completed {
self.completed += 1;
}
}
can summarize with Root exit {
report {
"total": self.total,
"completed": self.completed,
"pending": self.total - self.completed,
"completion_rate": (self.completed / self.total * 100) if self.total > 0 else 0
};
}
}
Best Practices#
- Use specific entry points --
with Todo entryis more efficient than genericwith entry - Accumulate then report -- Collect data during traversal, report once at exit
- Handle empty graphs -- Always check if traversal found anything
- Use meaningful node types -- Makes code self-documenting
- Keep walkers focused -- One walker, one responsibility
See Also#
- Walker Responses - Patterns for handling
.reportsarray - Build an AI Day Planner - Full-stack tutorial using OSP concepts
- OSP Tutorial - Hands-on tutorial with exercises
- What Makes Jac Different - Gentle introduction to Jac's core concepts