Skip to main content

ISONGraph Logo

ison-graph

PyPI Python License: MIT Tests

ISONGraph - A token-efficient, in-memory property graph store built on ISON format.

No external database required. Designed for LLM context windows and agentic AI workflows.

Why ISONGraph?

Challenge ISONGraph Solution
Graph databases are heavy Pure Python, zero dependencies beyond ison-py
JSON graphs waste tokens 70% smaller than JSON-LD
Need multi-hop traversal Built-in N-hop queries
Path finding BFS shortest path, DFS all paths
LLM context limits Token-optimized serialization

Installation

pip install ison-graph

Quick Start

from ison_graph import ISONGraph, Direction

# Create a graph
graph = ISONGraph(name="social")

# Add nodes with properties
graph.add_node('person', 1, name='Alice', age=30)
graph.add_node('person', 2, name='Bob', age=25)
graph.add_node('person', 3, name='Charlie', age=35)
graph.add_node('company', 100, name='Acme', industry='tech')

# Add edges (relationships)
graph.add_edge('KNOWS', ('person', 1), ('person', 2), since=2020)
graph.add_edge('KNOWS', ('person', 2), ('person', 3), since=2021)
graph.add_edge('WORKS_AT', ('person', 1), ('company', 100), role='Engineer')

# Traverse: direct neighbors
friends = graph.neighbors(('person', 1), 'KNOWS')
# [('person', 2)]

# Multi-hop: friends of friends (2 hops)
fof = graph.multi_hop(('person', 1), 'KNOWS', hops=2)
# [('person', 3)]

# Path finding
path = graph.shortest_path(('person', 1), ('person', 3))
print(path)  # Path(:person:1 -> :person:2 -> :person:3)

# Save to file
graph.save('social.isong')

Architecture

┌─────────────────────────────────────────────────────────────────────────┐
│                              ISONGraph                                   │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  Data Model (Property Graph)                                            │
│  ┌──────────────────┐    ┌──────────────────┐    ┌──────────────────┐   │
│  │      Node        │    │      Edge        │    │      Path        │   │
│  │  - type: str     │    │  - rel_type: str │    │  - nodes: []     │   │
│  │  - id: int|str   │    │  - source: ref   │    │  - edges: []     │   │
│  │  - properties:{} │    │  - target: ref   │    │  - length        │   │
│  │  - ref: (t, id)  │    │  - properties:{} │    │  - start, end    │   │
│  └──────────────────┘    └──────────────────┘    └──────────────────┘   │
│                                                                         │
│  In-Memory Storage (O(1) Lookup)                                        │
│  ┌─────────────────────────────────────────────────────────────────┐    │
│  │ _nodes:     Dict[type, Dict[id, Node]]     Node lookup          │    │
│  │ _edges:     Dict[rel_type, List[Edge]]     Edges by type        │    │
│  │ _out_edges: Dict[NodeRef, List[Edge]]      Outgoing index       │    │
│  │ _in_edges:  Dict[NodeRef, List[Edge]]      Incoming index       │    │
│  │ _edge_set:  Set[EdgeKey]                   Uniqueness check     │    │
│  └─────────────────────────────────────────────────────────────────┘    │
│                                                                         │
│  Operations                                                             │
│  ┌────────────────┐ ┌────────────────┐ ┌────────────────────────────┐   │
│  │ CRUD           │ │ Traversal      │ │ Path Finding               │   │
│  │ - add_node     │ │ - neighbors    │ │ - shortest_path (BFS)      │   │
│  │ - get_node     │ │ - multi_hop    │ │ - all_paths (DFS)          │   │
│  │ - add_edge     │ │ - multi_hop_rng│ │ - path_exists              │   │
│  │ - remove_*     │ │ - traverse     │ │                            │   │
│  └────────────────┘ └────────────────┘ └────────────────────────────┘   │
│                                                                         │
│  ┌────────────────┐ ┌────────────────┐ ┌────────────────────────────┐   │
│  │ Analysis       │ │ Query APIs     │ │ Persistence                │   │
│  │ - is_connected │ │ - query()      │ │ - to_ison() / from_ison()  │   │
│  │ - has_cycle    │ │ - start().hop()│ │ - to_isonl() / from_isonl()│   │
│  │ - components   │ │   .filter()    │ │ - save() / load()          │   │
│  │ - degree       │ │   .collect()   │ │                            │   │
│  └────────────────┘ └────────────────┘ └────────────────────────────┘   │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

ISON Graph Format

ISONGraph serializes to a compact, human-readable format:

edges.KNOWS
since source target
2020 :person:1 :person:2
2021 :person:2 :person:3

edges.WORKS_AT
role source target
Engineer :person:1 :company:100

nodes.company
id industry name
100 tech Acme

nodes.person
id age name
1 30 Alice
2 25 Bob
3 35 Charlie

Key syntax:

  • nodes.{type} - Node block header
  • edges.{REL_TYPE} - Edge block header
  • :type:id - Node reference (used in edges)

Block and column order is the canonical writer's, not the caller's: blocks sort by kind.name (so edges precedes nodes) and columns sort within a block. The same logical graph always produces the same bytes whatever order it was built in, which is what makes the output safe for content addressing.

  • Whitespace-separated values

Core Concepts

Node References

Nodes are uniquely identified by a (type, id) tuple:

NodeRef = Tuple[str, Union[int, str]]

# Examples
('person', 1)       # Person with ID 1
('company', 100)    # Company with ID 100
('article', 'abc')  # Article with string ID

Direction

Edges can be traversed in three directions:

from ison_graph import Direction

Direction.OUT   # Follow edge from source to target (default)
Direction.IN    # Follow edge from target to source
Direction.BOTH  # Follow both directions

Node Operations

# Create node with properties
node = graph.add_node('person', 1,
    name='Alice',
    age=30,
    email='alice@example.com'
)

# Get node
node = graph.get_node('person', 1)
print(node.type)        # 'person'
print(node.id)          # 1
print(node.properties)  # {'name': 'Alice', 'age': 30, ...}
print(node.ref)         # ('person', 1)

# Update properties (merges with existing)
graph.update_node('person', 1, age=31, title='Senior Engineer')

# Check existence
if graph.has_node('person', 1):
    ...

# Remove node (also removes all connected edges)
graph.remove_node('person', 1)

# Iterate all nodes
for node in graph.nodes():
    print(node)

# Iterate nodes of specific type
for node in graph.nodes('person'):
    print(f"{node.properties['name']}: {node.properties.get('age')}")

# Count nodes
total = graph.node_count()           # All nodes
people = graph.node_count('person')  # Just people

# Get all node types
types = graph.node_types()  # ['person', 'company', ...]

Edge Operations

# Create edge with properties
edge = graph.add_edge('KNOWS',
    ('person', 1),      # source
    ('person', 2),      # target
    since=2020,
    strength=0.9
)

# Get edge
edge = graph.get_edge('KNOWS', ('person', 1), ('person', 2))
print(edge.rel_type)    # 'KNOWS'
print(edge.source)      # ('person', 1)
print(edge.target)      # ('person', 2)
print(edge.properties)  # {'since': 2020, 'strength': 0.9}

# Check existence
if graph.has_edge('KNOWS', ('person', 1), ('person', 2)):
    ...

# Remove edge
graph.remove_edge('KNOWS', ('person', 1), ('person', 2))

# Iterate edges
for edge in graph.edges():           # All edges
    print(edge)

for edge in graph.edges('KNOWS'):    # By relationship type
    print(f"{edge.source} knows {edge.target}")

for edge in graph.edges(source=('person', 1)):  # By source
    print(f"From person 1: {edge}")

# Count edges
total = graph.edge_count()
knows_count = graph.edge_count('KNOWS')

# Get all relationship types
rel_types = graph.edge_types()  # ['KNOWS', 'WORKS_AT', ...]

ISONGraph is a simple graph: add_edge raises DuplicateEdgeError on a repeated (rel_type, source, target) triple - parallel edges aren't representable, by design (it's what keeps canonical serialization tie-break-free). Converting from a multigraph source? Dedupe/merge in the converter before calling add_edge.

Traversal Operations

Neighbors (1-hop)

# Outgoing neighbors (default)
friends = graph.neighbors(('person', 1), 'KNOWS')
# [('person', 2)]

# Incoming neighbors
followers = graph.neighbors(('person', 1), 'KNOWS', Direction.IN)

# Both directions
connections = graph.neighbors(('person', 1), 'KNOWS', Direction.BOTH)

# Any relationship type
all_neighbors = graph.neighbors(('person', 1))

Multi-Hop Traversal

# Exactly N hops away
two_hops = graph.multi_hop(('person', 1), 'KNOWS', hops=2)
three_hops = graph.multi_hop(('person', 1), 'KNOWS', hops=3)

# Range of hops (1 to 3 inclusive)
reachable = graph.multi_hop_range(('person', 1), 'KNOWS',
    min_hops=1,
    max_hops=3
)

# Any relationship type
all_reachable = graph.multi_hop(('person', 1), hops=2)

# Traverse incoming edges
predecessors = graph.multi_hop(('person', 5), 'KNOWS', hops=2,
    direction=Direction.IN
)

Pattern Traversal

Follow a sequence of relationship types:

# "Find companies where Alice's friends work"
# Pattern: person:1 -[:KNOWS]-> person -[:WORKS_AT]-> company
companies = graph.traverse(
    ('person', 1),
    [('KNOWS', Direction.OUT), ('WORKS_AT', Direction.OUT)]
)

# With filter function
# "Find friends over 25 who work at tech companies"
tech_companies = graph.traverse(
    ('person', 1),
    [('KNOWS', Direction.OUT), ('WORKS_AT', Direction.OUT)],
    filter_fn=lambda node: node.properties.get('industry') == 'tech'
)

Path Finding

Shortest Path (BFS)

path = graph.shortest_path(('person', 1), ('person', 5))

if path:
    print(f"Length: {path.length}")      # Number of hops
    print(f"Start: {path.start}")        # First node
    print(f"End: {path.end}")            # Last node
    print(f"Nodes: {path.nodes}")        # All nodes in path
    print(f"Edges: {path.edges}")        # All edges in path
    print(path)  # Path(:person:1 -> :person:2 -> ... -> :person:5)

# With constraints
path = graph.shortest_path(
    ('person', 1),
    ('person', 5),
    rel_type='KNOWS',     # Only follow KNOWS edges
    max_hops=10,          # Maximum path length
    direction=Direction.OUT
)

All Paths (DFS)

# Find all possible paths
paths = graph.all_paths(('person', 1), ('person', 5))

for path in paths:
    print(f"Path of length {path.length}: {path}")

# With constraints
paths = graph.all_paths(
    ('person', 1),
    ('person', 5),
    rel_type='KNOWS',
    max_hops=5
)

Path Existence

if graph.path_exists(('person', 1), ('person', 5)):
    print("Path exists!")

Fluent API

Chain traversal operations for readable queries:

# Find friends of friends who are over 30
result = graph.start(('person', 1)) \
    .hop('KNOWS') \
    .hop('KNOWS') \
    .filter(lambda n: n.properties.get('age', 0) > 30) \
    .collect()

# Multiple hops at once
distant = graph.start(('person', 1)) \
    .hops(3, 'KNOWS') \
    .collect()

# Get Node objects instead of references
nodes = graph.start(('person', 1)) \
    .hop('KNOWS') \
    .collect_nodes()

for node in nodes:
    print(node.properties['name'])

# Count results
count = graph.start(('person', 1)) \
    .hop('KNOWS') \
    .count()

# Get first result
first = graph.start(('person', 1)) \
    .hop('KNOWS') \
    .first()

# Chain with direction control
result = graph.start(('person', 5)) \
    .hop('KNOWS', direction=Direction.IN) \
    .hop('WORKS_AT') \
    .collect()

Query Pattern Syntax

Execute queries using a pattern string:

# Direct neighbors
graph.query(":person:1 -[:KNOWS]-> *")
# Returns: [('person', 2), ('person', 3)]

# Exactly 2 hops
graph.query(":person:1 -[:KNOWS*2]-> *")
# Returns: nodes exactly 2 hops away

# Range: 1 to 3 hops
graph.query(":person:1 -[:KNOWS*1..3]-> *")
# Returns: all nodes within 1-3 hops

Pattern syntax:

  • :type:id - Starting node reference
  • -[:REL]-> - Relationship type
  • -[:REL*N]-> - Exactly N hops
  • -[:REL*M..N]-> - M to N hops (range)
  • * - Match any node

Graph Analysis

Connectivity

# Check if all nodes are reachable from any node
if graph.is_connected():
    print("Graph is connected!")

# Get connected components
components = graph.connected_components()
print(f"Found {len(components)} components")

for i, component in enumerate(components):
    print(f"Component {i}: {len(component)} nodes")

Cycle Detection

# Check for any cycles
if graph.has_cycle():
    print("Graph contains cycles (not a DAG)")

# Check for cycles via specific relationship
if graph.has_cycle('REPORTS_TO'):
    print("Org chart has cycles! (invalid)")

Node Degree

# Incoming edges
in_deg = graph.in_degree(('person', 1))

# Outgoing edges
out_deg = graph.out_degree(('person', 1))

# Total degree
total_deg = graph.degree(('person', 1))

Persistence

Save and Load

# Save to ISON format (default)
graph.save('graph.isong')

# Load from file
graph = ISONGraph.load('graph.isong')

# Save to ISONL streaming format
graph.save('graph.isonl')
graph = ISONGraph.load('graph.isonl')

# Auto-detect format from extension
graph.save('data.isong')   # Uses ISON
graph.save('data.isonl')   # Uses ISONL

Manual Serialization

# To string
ison_str = graph.to_ison()
isonl_str = graph.to_isonl()

# From string
graph = ISONGraph.from_ison(ison_str)
graph = ISONGraph.from_isonl(isonl_str)
graph = ISONGraph.parse(ison_str)  # Alias for from_ison

Directed vs Undirected Graphs

# Directed graph (default)
directed = ISONGraph(name="social", directed=True)

# Undirected graph - edges work both ways
undirected = ISONGraph(name="network", directed=False)

# In undirected graphs, adding A->B also adds B->A
undirected.add_edge('CONNECTED', ('node', 1), ('node', 2))
# Now both directions exist

Error Handling

from ison_graph import (
    GraphError,
    NodeNotFoundError,
    EdgeNotFoundError,
    DuplicateNodeError,
    DuplicateEdgeError
)

# Handle node not found
try:
    node = graph.get_node('person', 999)
except NodeNotFoundError as e:
    print(f"Node not found: {e.node_ref}")

# Handle duplicate node
try:
    graph.add_node('person', 1, name='Alice')
    graph.add_node('person', 1, name='Duplicate')  # Raises
except DuplicateNodeError as e:
    print(f"Node already exists: {e.node_ref}")

# Handle edge errors
try:
    graph.add_edge('KNOWS', ('person', 1), ('person', 999))  # Target missing
except NodeNotFoundError:
    print("Cannot create edge: target node missing")

Complete API Reference

ISONGraph Class

Constructor

Parameter Type Default Description
name str "graph" Graph name (used in serialization)
directed bool True Whether edges are directed

Node Methods

Method Returns Description
add_node(type, id, **props) Node Add node with properties
get_node(type, id) Node Get node (raises if not found)
get_node_by_ref(ref) Node Get node by (type, id) tuple
has_node(type, id) bool Check if node exists
remove_node(type, id) None Remove node and its edges
update_node(type, id, **props) Node Update node properties
nodes(type=None) Iterator Iterate nodes
node_count(type=None) int Count nodes
node_types() List[str] Get all node types

Edge Methods

Method Returns Description
add_edge(rel, src, tgt, **props) Edge Add edge with properties
get_edge(rel, src, tgt) Edge Get edge (raises if not found)
has_edge(rel, src, tgt) bool Check if edge exists
remove_edge(rel, src, tgt) None Remove edge
edges(rel=None, src=None, tgt=None) Iterator Iterate edges
edge_count(rel=None) int Count edges
edge_types() List[str] Get all relationship types

Traversal Methods

Method Returns Description
neighbors(ref, rel, dir) List[NodeRef] Get 1-hop neighbors
multi_hop(ref, rel, hops, dir) List[NodeRef] Get N-hop neighbors
multi_hop_range(ref, rel, min, max, dir) List[NodeRef] Get range of hops
traverse(ref, pattern, filter) List[NodeRef] Follow pattern

Path Finding Methods

Method Returns Description
shortest_path(start, end, rel, max, dir) Path BFS shortest path
all_paths(start, end, rel, max, dir) List[Path] DFS all paths
path_exists(start, end, rel, max) bool Check if path exists

Analysis Methods

Method Returns Description
in_degree(ref) int Count incoming edges
out_degree(ref) int Count outgoing edges
degree(ref) int Total edge count
is_connected() bool Check if graph is connected
has_cycle(rel=None) bool Check for cycles
connected_components() List[Set] Get connected components

Persistence Methods

Method Returns Description
save(path, format='auto') None Save to file
load(path, format='auto') ISONGraph Load from file (classmethod)
to_ison() str Serialize to ISON
to_isonl() str Serialize to ISONL
from_ison(text, name) ISONGraph Parse ISON (classmethod)
from_isonl(text, name) ISONGraph Parse ISONL (classmethod)
parse(text, name) ISONGraph Alias for from_ison

to_ison()/to_isonl() are canonical: node type blocks, node rows, edge type blocks, and edge rows are all sorted (ordinal string comparison), so the same logical graph always serializes to the same bytes regardless of insertion order - safe for content-addressing or prompt caching. Sort order is ordinal, not numeric ("1" < "10" < "2"), and matches every other ISONGraph language port byte-for-byte.

Query Methods

Method Returns Description
start(ref) GraphTraversal Start fluent traversal
query(pattern) List[NodeRef] Execute pattern query

GraphTraversal Class

Method Returns Description
hop(rel, dir, where) self Traverse one hop
hops(n, rel, dir) self Traverse N hops
filter(fn) self Filter current nodes
collect() List[NodeRef] Get current node refs
collect_nodes() List[Node] Get current Node objects
count() int Count current nodes
first() NodeRef Get first node or None

Data Classes

Node

Property Type Description
type str Node type
id int|str Node ID
properties Dict Node properties
ref NodeRef (type, id) tuple

Edge

Property Type Description
rel_type str Relationship type
source NodeRef Source node
target NodeRef Target node
properties Dict Edge properties
key EdgeKey (rel_type, source, target)

Path

Property Type Description
nodes List[NodeRef] Nodes in path
edges List[Edge] Edges in path
length int Number of hops
start NodeRef First node
end NodeRef Last node

Performance Characteristics

Operation Time Complexity Notes
add_node O(1) Hash table insert
get_node O(1) Hash table lookup
add_edge O(1) With index update
has_edge O(1) Set lookup
neighbors O(degree) Iterate edge list
multi_hop(N) O(V + E) per hop BFS traversal
shortest_path O(V + E) BFS
all_paths O(V!) worst DFS with backtracking
is_connected O(V + E) BFS from any node
has_cycle O(V + E) DFS with recursion stack

Token Efficiency

Benchmark Results (50 Graph Questions)

Format Tokens Accuracy Acc/1K Tokens
ISONGraph 639 92.0% 143.97
ISON 685 88.0% 128.47
TOON 856 80.0% 93.46
JSON Compact 1,072 82.0% 76.49
JSON 2,039 84.0% 41.20

ISONGraph provides 3.5x more value per token than JSON.

Format Comparison

ISONGraph (34 tokens)              JSON-LD (120+ tokens)
─────────────────────              ─────────────────────
nodes.person                       {"@context": {...},
id name age                         "nodes": [
1 Alice 30                            {"@type": "Person",
2 Bob 25                               "@id": "person/1",
                                       "name": "Alice",
edges.KNOWS                            "age": 30}, ...
source target                       ],
:person:1 :person:2                 "edges": [...]}

ISONQL Query Language

ISONGraph includes ISONQL, a query language for property graphs:

from ison_graph import ISONGraph
from ison_graph.query import QueryEngine, QueryBuilder

graph = ISONGraph("social")
# ... add nodes and edges ...

engine = QueryEngine(graph)

# Query nodes with conditions
results = engine.execute("NODES person WHERE age > 25")
results = engine.execute("NODES person WHERE name = 'Alice'")
results = engine.execute("NODES company WHERE name STARTS_WITH 'Acme'")

# Query edges
results = engine.execute("EDGES KNOWS WHERE since > 2020")

# Traverse relationships
results = engine.execute("TRAVERSE person:1 -> KNOWS -> person")

# Multi-hop traversal (up to 2 hops)
results = engine.execute("TRAVERSE person:1 -> KNOWS -> person MAX 2")

# Deeper traversal (up to 3 hops)
results = engine.execute("TRAVERSE person:1 -> KNOWS -> person MAX 3")

# Find paths
results = engine.execute("PATH person:1 TO person:5 VIA KNOWS MAX 5")

# Aggregations
results = engine.execute("COUNT person WHERE age > 25")
results = engine.execute("AVG person.age")
results = engine.execute("SUM person.age WHERE city = 'NYC'")
results = engine.execute("MIN person.age")
results = engine.execute("MAX person.age")

Fluent Query Builder

# Node queries: start from engine.match()
result = (engine.match("person")
    .where("age", ">", 25)
    .where("city", "=", "NYC")
    .order_by("name", "DESC")
    .limit(10)
    .return_fields("name", "email")
    .execute())

# Edge queries: start from engine.match_edges()
result = (engine.match_edges("KNOWS")
    .where("since", ">=", 2020)
    .execute())

# Traversals and paths use the string query form
result = engine.execute("TRAVERSE person:1 -> KNOWS -> person MAX 2")

ISONQL Operators

Operator Description Example
= Equals WHERE name = 'Alice'
!= Not equals WHERE status != 'inactive'
> Greater than WHERE age > 25
>= Greater or equal WHERE age >= 18
< Less than WHERE price < 100
<= Less or equal WHERE count <= 10
IN In list WHERE city IN ('NYC', 'LA')
NOT IN Not in list WHERE city NOT IN ('NYC')
CONTAINS List contains WHERE tags CONTAINS 'tech'
STARTS_WITH Prefix match WHERE name STARTS_WITH 'A'
ENDS_WITH Suffix match WHERE email ENDS_WITH '.com'
MATCHES Regex match WHERE email MATCHES '^[a-z]+'
EXISTS Field present WHERE email EXISTS

Schema Validation

Define and validate graph schemas:

from ison_graph.schema import (
    GraphSchema, NodeType, EdgeType,
    String, Int, Float, Bool, Ref,
    Cardinality
)

# Define node types
Person = NodeType("person") \
    .id(Int()) \
    .field("name", String().required().max(100)) \
    .field("age", Int().min(0).max(150)) \
    .field("email", String().email())

Company = NodeType("company") \
    .id(Int()) \
    .field("name", String().required()) \
    .field("founded", Int().min(1800))

# Define edge types
Knows = EdgeType("KNOWS") \
    .from_node(Person) \
    .to_node(Person) \
    .field("since", Int()) \
    .no_self_loop() \
    .unique()

WorksAt = EdgeType("WORKS_AT") \
    .from_node(Person) \
    .to_node(Company) \
    .field("role", String()) \
    .cardinality(Cardinality.MANY_TO_ONE)

# Create schema
schema = GraphSchema("social") \
    .node_types(Person, Company) \
    .edge_types(Knows, WorksAt) \
    .no_orphans()

# Validate graph
result = schema.validate(graph)

if not result.valid:
    for error in result.errors:
        print(f"[{error.location}] {error.code}: {error.message}")

Field Validators

# String fields
String().required()              # Required field
String().min(5).max(100)         # Length constraints
String().pattern(r"^[A-Z]")      # Regex pattern
String().email()                 # Email validation
String().enum("active", "inactive")  # Enum values

# Numeric fields
Int().required().min(0).max(150)
Float().range(0.0, 100.0)

# Boolean fields
Bool().required()

# Reference fields
Ref("person")                    # Reference to person node

Cardinality Constraints

from ison_graph.schema import Cardinality

# One person can work at one company (many employees per company)
EdgeType("WORKS_AT").cardinality(Cardinality.MANY_TO_ONE)

# One company has one CEO
EdgeType("CEO_OF").cardinality(Cardinality.ONE_TO_ONE)

# One manager can manage many employees
EdgeType("MANAGES").cardinality(Cardinality.ONE_TO_MANY)

# Many people can know many people
EdgeType("KNOWS").cardinality(Cardinality.MANY_TO_MANY)

Graph-Level Constraints

schema = GraphSchema("social") \
    .node_types(Person, Company) \
    .edge_types(Knows, WorksAt) \
    .connected()      # Require graph to be connected
    .no_orphans()     # All nodes must have at least one edge

Extensions (experimental, 1.3.0)

Two opt-in modules that lift constraints of the core graph. Importing them changes nothing about ISONGraph itself — existing code is unaffected.

Experimental. Both are Python-only until the JS, TS, Rust, C++ and C# ports implement the same on-disk format. Pin your version if you depend on the serialized output.

MultiGraph — parallel edges

ISONGraph is a simple graph: at most one edge per (rel_type, source, target). MultiGraph widens that to (rel_type, source, target, key), so the same relationship can be recorded more than once between the same pair — different time ranges, different provenance — without inventing synthetic relation types.

from ison_graph.multigraph import MultiGraph

mg = MultiGraph()
mg.add_node('person', 1, name='Alice')
mg.add_node('person', 2, name='Bob')

mg.add_edge('KNOWS', ('person', 1), ('person', 2), since=2015)   # key=0
mg.add_edge('KNOWS', ('person', 1), ('person', 2), since=2020)   # key=1

mg.neighbors(('person', 1), 'KNOWS')                  # queried by real rel_type
mg.edges_between(('person', 1), ('person', 2))        # [(0, edge), (1, edge)]
mg.get_edge('KNOWS', ('person', 1), ('person', 2), key=1)
mg.remove_edge('KNOWS', ('person', 1), ('person', 2), key=0)
  • key defaults to 0, so a single edge looks exactly like the simple-graph case. Omit it and keys are handed out in order.
  • Relation types are stored verbatim — no r_<encoded>_0 rewriting. "Verbatim" means within the identifier rules below; it is not a promise that any string is accepted.
  • edges() and edges_between() iterate in insertion order; serialized output is canonically sorted.
  • Keys are monotonic. Removing one edge does not free its key; removing every edge on a triple resets the counter to 0.

Serialized output carries a meta.graph marker. ISONGraph refuses to load it rather than silently discarding the parallel edges:

ISONGraph.from_ison(mg.to_ison())
# ValueError: This is a ison-graph-multigraph-v1 document ...
#             load it with ison_graph.multigraph.MultiGraph instead.

Emission is delegated to ison-py's canonical writer (ISONCS), so MultiGraph decides ordering and the ISON layer decides bytes. Column order is alphabetical and blocks are name-sorted — that is the canonical layout, not a MultiGraph choice.

ProtectedGraph — structural fields cannot be shadowed

A node's identity is (type, id); an edge's is (rel_type, source, target[, key]). In a plain ISONGraph a property of the same name rides along and wins on reload:

g = ISONGraph()
g.add_node('person', 1, type='villain')
ISONGraph.from_dict(g.to_dict()).node_types()   # ['villain'] — identity lost

ProtectedGraph rejects those properties at every boundary and hands out read-only property views, so the only route to a change is a validating update method:

from ison_graph.structural import ProtectedGraph

g = ProtectedGraph()
g.add_node('person', 1, name='Alice')

g.add_node('person', 2, type='villain')     # ReservedFieldError
g.get_node('person', 1).properties['name'] = 'Mallory'   # TypeError

g.update_node_properties(('person', 1), name='Alice Q')  # OK
g.update_edge_properties('KNOWS', ('person', 1), ('person', 2), since=2016)

Reserved names are type/id on nodes and rel_type/source/target/key on edges.

Combining both

from ison_graph.multigraph import MultiGraph
from ison_graph.structural import ProtectedGraph

class SafeMultiGraph(ProtectedGraph, MultiGraph):
    """Parallel edges plus a round-trip-safe identity."""

g = SafeMultiGraph()

Overhead versus core ISONGraph on 2,000 nodes / 10,000 edges: build ~1.3× (MultiGraph), ~1.5× (ProtectedGraph), ~2× combined; to_ison() ~1.15×. Serialization used to be ~3.5× here, when core still hand-rolled its emitter and only the extensions paid for the canonical writer - both go through it now, so the gap is the extensions' own bookkeeping. Traversal is unaffected.

Identifier constraints

Node types, node ids and relation types must be representable in ISON. They may not be empty, and may not contain whitespace, : or |; types additionally may not contain " or \, because they become block names and ISONL cannot quote those:

graph.add_node('person', 'user:1')          # InvalidIdentifierError
graph.add_node('person', 'has space')       # InvalidIdentifierError
graph.add_edge('knows:strong', src, dst)    # InvalidIdentifierError
graph.add_node('a"type', '1')               # InvalidIdentifierError (type only)

Every one of these previously built a graph that corrupted or threw on round-trip rather than failing here: :type:id references have no quoting syntax at all, so an id containing a space splits mid-reference. Hyphens, dots, # and unicode are fine.

Property names are checked too, against a slightly wider set — they become ISON field names, and a leading # turns the header into a comment, losing the whole block:

graph.add_node('person', 1, **{'first name': 'Alice'})  # InvalidPropertyNameError
graph.add_node('person', 1, **{'a.b': 'x'})             # InvalidPropertyNameError

This is deliberate format policy shared by every ISONGraph port, not a Python-side check, and it applies to MultiGraph and ProtectedGraph equally.

If your source data uses such values, project them at your boundary rather than expecting the graph to accept them: keep the original values as your source of truth, map them to graph-safe references for execution, and translate back on the way out. Keep the mapping reversible and keep the raw ISONGraph object private — direct queries against it are only meaningful for graph-safe values.

Escaping these characters in the format itself would be a coordinated change across all six ports, so it is not something a single consumer should work around locally.

Visualization

ison_graph.viz turns a graph into a picture in three stages — data, layout, rendering — with zero extra dependencies:

from ison_graph import ISONGraph
from ison_graph.viz import compute_layout, render_svg, render_html

graph = ISONGraph.load("social.ison")

# Stage 2: deterministic seeded force-directed layout -> {(type, id): (x, y)}
layout = compute_layout(graph, width=900, height=600, seed=42)

# Stage 3: render the same coordinates to SVG or interactive HTML
svg = render_svg(graph, layout, title="My graph")          # standalone image
html = render_html(graph, layout)                          # tooltips, pan/zoom, dark mode

The same seed always produces the same coordinates, so a server-rendered SVG and a client-rendered page stay pixel-identical. Nodes are colored by type (colorblind-safe palette, max 8 types before folding to a neutral), sized by degree, and labeled from the name property (or label_property= of your choice).

Pass radii={ref: radius} (and optionally spacing=2.6) to enforce minimum center distance (r_a + r_b) * spacing between every pair - useful when node size encodes data and large bubbles must not overlap. Without radii the output is byte-identical to earlier releases.

The same API ships in the npm packages (import { viz } from 'ison-graph-ts' or 'ison-graph-js') with bit-identical layout geometry, verified by cross-language parity tests.

Command line:

python -m ison_graph.viz social.ison -o graph.svg
python -m ison_graph.viz social.ison -o graph.html --edge-labels --title "Social"

Integration with ISON Ecosystem

┌────────────────────────────────────────────────────────────────────┐
│                        ISON Ecosystem                               │
├────────────────────────────────────────────────────────────────────┤
│                                                                    │
│  ison-py          →  Core parser (loads, dumps)                    │
│       ↓                                                            │
│  ison-graph       →  Property graph store (nodes, edges, paths)    │
│       ↓                                                            │
│  isongraphantic   →  Graph schema validation (constraints)         │
│       ↓                                                            │
│  ison-graph-embeddings → Semantic search + graph traversal         │
│                                                                    │
└────────────────────────────────────────────────────────────────────┘
Package Purpose
ison-py Core ISON parser
isonantic Data validation for ISON
isongraphantic Graph schema validation
ison-graph-embeddings Semantic search + embeddings

Use Cases

Knowledge Graphs

graph = ISONGraph(name="knowledge")
graph.add_node('company', 'apple', name='Apple', industry='tech')
graph.add_node('person', 'tim', name='Tim Cook', role='CEO')
graph.add_node('product', 'iphone', name='iPhone')

graph.add_edge('WORKS_AT', ('person', 'tim'), ('company', 'apple'))
graph.add_edge('PRODUCES', ('company', 'apple'), ('product', 'iphone'))

# Query: What products does Tim Cook's company make?
products = graph.traverse(
    ('person', 'tim'),
    [('WORKS_AT', Direction.OUT), ('PRODUCES', Direction.OUT)]
)

Social Networks

graph = ISONGraph(name="social")
# Add users and follow relationships
# Multi-hop: friends of friends
fof = graph.multi_hop(('user', 1), 'FOLLOWS', hops=2)

Org Charts

graph = ISONGraph(name="org")
# REPORTS_TO edges form a DAG
if graph.has_cycle('REPORTS_TO'):
    raise ValueError("Invalid org structure!")

Dependency Graphs

graph = ISONGraph(name="deps")
# Package dependency resolution
path = graph.shortest_path(('pkg', 'app'), ('pkg', 'lodash'))

Testing

# Run all tests
pytest tests/ -v

# Run with coverage
pytest tests/ --cov=ison_graph --cov-report=term-missing

License

MIT License - see LICENSE for details.

Author

Mahesh Vaikri

Metadata

Release files for ison-graph 1.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for ison-graph 1.3.0
File Size Uploaded
ison_graph-1.3.0.tar.gz 104.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for ison-graph 1.3.0
File Interpreter ABI Platform
ison_graph-1.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 163.5 kB

Release files / ison_graph-1.3.0.tar.gz

Download URL ison_graph-1.3.0.tar.gz
Size 104.2 kB
Tags Source
SHA-256 checksum
How to use checksums
ea154433486dbddc41f00924c8113f0756721b4589245e482f1f27d3351079f9
BLAKE2b-256 checksum
How to use checksums
477eab8e6868cb739c4d582ea9f58e7f746b0ab5cca4596818e8624bd555e056
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.14

Release files / ison_graph-1.3.0-py3-none-any.whl

Download URL ison_graph-1.3.0-py3-none-any.whl
Size 59.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
4ace471ec5dcdc091a81e3c6f41858c2a367661713a5ad91fb13c1cf80c3eb1c
BLAKE2b-256 checksum
How to use checksums
9deccd4adfaf95c9d5504fc7f9922d94b571c0193c860c9195c40acc897af779
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.14

Release history Release notifications | RSS feed

1.4.0

2 release files

This release

1.3.0 This release

2 release files

1.2.0

2 release files

1.1.0

2 release files

1.0.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page