ison-graph 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ison_graph/__init__.py ADDED
@@ -0,0 +1,1513 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ISONGraph - A Token-Efficient Graph Store
4
+
5
+ A file-based, in-memory graph store built on ISON format.
6
+ Supports property graphs, multi-hop traversal, and path finding.
7
+
8
+ Usage:
9
+ from ison_graph import ISONGraph, NodeRef
10
+
11
+ # Create a graph
12
+ graph = ISONGraph()
13
+
14
+ # Add nodes
15
+ graph.add_node('person', 1, name='Alice', age=30)
16
+ graph.add_node('person', 2, name='Bob', age=25)
17
+ graph.add_node('company', 100, name='Acme')
18
+
19
+ # Add edges
20
+ graph.add_edge('KNOWS', ('person', 1), ('person', 2), since=2020)
21
+ graph.add_edge('WORKS_AT', ('person', 1), ('company', 100), role='Engineer')
22
+
23
+ # Traverse
24
+ friends = graph.neighbors(('person', 1), 'KNOWS')
25
+ fof = graph.multi_hop(('person', 1), 'KNOWS', hops=2)
26
+
27
+ # Path finding
28
+ path = graph.shortest_path(('person', 1), ('person', 3))
29
+
30
+ # Persistence
31
+ graph.save('social.isong')
32
+ graph = ISONGraph.load('social.isong')
33
+
34
+ Author: Mahesh Vaikri
35
+ Version: 1.0.0
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import re
41
+ from dataclasses import dataclass, field
42
+ from typing import (
43
+ Any, Dict, List, Optional, Set, Tuple,
44
+ Iterator, Callable, Union, Generator
45
+ )
46
+ from pathlib import Path as FilePath
47
+ from collections import defaultdict
48
+ from enum import Enum
49
+
50
+ # Import from ison-py
51
+ from ison_parser import (
52
+ Document, Block, Reference,
53
+ loads, dumps, load, dump,
54
+ loads_isonl, dumps_isonl
55
+ )
56
+
57
+ __version__ = "1.0.0"
58
+ __author__ = "Mahesh Vaikri"
59
+
60
+
61
+ # =============================================================================
62
+ # Type Aliases
63
+ # =============================================================================
64
+
65
+ NodeRef = Tuple[str, Union[int, str]] # (type, id) e.g., ('person', 1)
66
+ EdgeKey = Tuple[str, NodeRef, NodeRef] # (rel_type, source, target)
67
+
68
+
69
+ # =============================================================================
70
+ # Data Classes
71
+ # =============================================================================
72
+
73
+ @dataclass
74
+ class Node:
75
+ """Represents a graph node with properties"""
76
+ type: str
77
+ id: Union[int, str]
78
+ properties: Dict[str, Any] = field(default_factory=dict)
79
+
80
+ @property
81
+ def ref(self) -> NodeRef:
82
+ """Get node reference tuple"""
83
+ return (self.type, self.id)
84
+
85
+ def to_ison_ref(self) -> str:
86
+ """Convert to ISON reference string"""
87
+ return f":{self.type}:{self.id}"
88
+
89
+ def __hash__(self) -> int:
90
+ return hash(self.ref)
91
+
92
+ def __eq__(self, other: object) -> bool:
93
+ if isinstance(other, Node):
94
+ return self.ref == other.ref
95
+ return False
96
+
97
+ def __repr__(self) -> str:
98
+ return f"Node({self.type}:{self.id}, {self.properties})"
99
+
100
+
101
+ @dataclass
102
+ class Edge:
103
+ """Represents a graph edge with properties"""
104
+ rel_type: str
105
+ source: NodeRef
106
+ target: NodeRef
107
+ properties: Dict[str, Any] = field(default_factory=dict)
108
+
109
+ @property
110
+ def key(self) -> EdgeKey:
111
+ """Get edge key tuple"""
112
+ return (self.rel_type, self.source, self.target)
113
+
114
+ def __hash__(self) -> int:
115
+ return hash(self.key)
116
+
117
+ def __eq__(self, other: object) -> bool:
118
+ if isinstance(other, Edge):
119
+ return self.key == other.key
120
+ return False
121
+
122
+ def __repr__(self) -> str:
123
+ return f"Edge({self.source} -[{self.rel_type}]-> {self.target})"
124
+
125
+
126
+ @dataclass
127
+ class Path:
128
+ """Represents a path through the graph"""
129
+ nodes: List[NodeRef]
130
+ edges: List[Edge]
131
+
132
+ @property
133
+ def length(self) -> int:
134
+ """Number of hops in the path"""
135
+ return len(self.edges)
136
+
137
+ @property
138
+ def start(self) -> NodeRef:
139
+ """Starting node"""
140
+ return self.nodes[0] if self.nodes else None
141
+
142
+ @property
143
+ def end(self) -> NodeRef:
144
+ """Ending node"""
145
+ return self.nodes[-1] if self.nodes else None
146
+
147
+ def __repr__(self) -> str:
148
+ path_str = " -> ".join([f":{n[0]}:{n[1]}" for n in self.nodes])
149
+ return f"Path({path_str})"
150
+
151
+
152
+ class Direction(Enum):
153
+ """Edge traversal direction"""
154
+ OUT = "out"
155
+ IN = "in"
156
+ BOTH = "both"
157
+
158
+
159
+ # =============================================================================
160
+ # Graph Errors
161
+ # =============================================================================
162
+
163
+ class GraphError(Exception):
164
+ """Base exception for graph errors"""
165
+ pass
166
+
167
+
168
+ class NodeNotFoundError(GraphError):
169
+ """Node does not exist in graph"""
170
+ def __init__(self, node_ref: NodeRef):
171
+ self.node_ref = node_ref
172
+ super().__init__(f"Node not found: :{node_ref[0]}:{node_ref[1]}")
173
+
174
+
175
+ class EdgeNotFoundError(GraphError):
176
+ """Edge does not exist in graph"""
177
+ def __init__(self, edge_key: EdgeKey):
178
+ self.edge_key = edge_key
179
+ super().__init__(f"Edge not found: {edge_key}")
180
+
181
+
182
+ class DuplicateNodeError(GraphError):
183
+ """Node already exists"""
184
+ def __init__(self, node_ref: NodeRef):
185
+ self.node_ref = node_ref
186
+ super().__init__(f"Node already exists: :{node_ref[0]}:{node_ref[1]}")
187
+
188
+
189
+ class DuplicateEdgeError(GraphError):
190
+ """Edge already exists"""
191
+ def __init__(self, edge_key: EdgeKey):
192
+ self.edge_key = edge_key
193
+ super().__init__(f"Edge already exists: {edge_key}")
194
+
195
+
196
+ # =============================================================================
197
+ # ISONGraph - Main Graph Class
198
+ # =============================================================================
199
+
200
+ class ISONGraph:
201
+ """
202
+ In-memory property graph store with ISON persistence.
203
+
204
+ Features:
205
+ - Property graph model (nodes and edges with properties)
206
+ - Multiple node types and relationship types
207
+ - O(1) node lookup by (type, id)
208
+ - Multi-hop traversal
209
+ - Shortest path finding (BFS)
210
+ - All paths finding (DFS)
211
+ - ISON/ISONL persistence
212
+ """
213
+
214
+ def __init__(self, name: str = "graph", directed: bool = True):
215
+ """
216
+ Initialize an empty graph.
217
+
218
+ Args:
219
+ name: Graph name (used in serialization)
220
+ directed: Whether edges are directed (default: True)
221
+ """
222
+ self.name = name
223
+ self.directed = directed
224
+
225
+ # Node storage: {type: {id: Node}}
226
+ self._nodes: Dict[str, Dict[Union[int, str], Node]] = defaultdict(dict)
227
+
228
+ # Edge storage: {rel_type: [Edge]}
229
+ self._edges: Dict[str, List[Edge]] = defaultdict(list)
230
+
231
+ # Index: outgoing edges per node
232
+ self._out_edges: Dict[NodeRef, List[Edge]] = defaultdict(list)
233
+
234
+ # Index: incoming edges per node
235
+ self._in_edges: Dict[NodeRef, List[Edge]] = defaultdict(list)
236
+
237
+ # Edge uniqueness set
238
+ self._edge_set: Set[EdgeKey] = set()
239
+
240
+ # =========================================================================
241
+ # Node Operations
242
+ # =========================================================================
243
+
244
+ def add_node(
245
+ self,
246
+ node_type: str,
247
+ node_id: Union[int, str],
248
+ **properties: Any
249
+ ) -> Node:
250
+ """
251
+ Add a node to the graph.
252
+
253
+ Args:
254
+ node_type: Type of node (e.g., 'person', 'company')
255
+ node_id: Unique ID within the type
256
+ **properties: Node properties
257
+
258
+ Returns:
259
+ The created Node
260
+
261
+ Raises:
262
+ DuplicateNodeError: If node already exists
263
+ """
264
+ if node_id in self._nodes[node_type]:
265
+ raise DuplicateNodeError((node_type, node_id))
266
+
267
+ node = Node(type=node_type, id=node_id, properties=properties)
268
+ self._nodes[node_type][node_id] = node
269
+ return node
270
+
271
+ def get_node(self, node_type: str, node_id: Union[int, str]) -> Node:
272
+ """
273
+ Get a node by type and ID.
274
+
275
+ Args:
276
+ node_type: Type of node
277
+ node_id: Node ID
278
+
279
+ Returns:
280
+ The Node
281
+
282
+ Raises:
283
+ NodeNotFoundError: If node doesn't exist
284
+ """
285
+ if node_type not in self._nodes or node_id not in self._nodes[node_type]:
286
+ raise NodeNotFoundError((node_type, node_id))
287
+ return self._nodes[node_type][node_id]
288
+
289
+ def get_node_by_ref(self, ref: NodeRef) -> Node:
290
+ """Get node by reference tuple"""
291
+ return self.get_node(ref[0], ref[1])
292
+
293
+ def has_node(self, node_type: str, node_id: Union[int, str]) -> bool:
294
+ """Check if node exists"""
295
+ return node_type in self._nodes and node_id in self._nodes[node_type]
296
+
297
+ def remove_node(self, node_type: str, node_id: Union[int, str]) -> None:
298
+ """
299
+ Remove a node and all its edges.
300
+
301
+ Args:
302
+ node_type: Type of node
303
+ node_id: Node ID
304
+
305
+ Raises:
306
+ NodeNotFoundError: If node doesn't exist
307
+ """
308
+ ref = (node_type, node_id)
309
+ if not self.has_node(node_type, node_id):
310
+ raise NodeNotFoundError(ref)
311
+
312
+ # Remove all edges connected to this node
313
+ edges_to_remove = list(self._out_edges[ref]) + list(self._in_edges[ref])
314
+ for edge in edges_to_remove:
315
+ self._remove_edge_internal(edge)
316
+
317
+ # Remove node
318
+ del self._nodes[node_type][node_id]
319
+ if not self._nodes[node_type]:
320
+ del self._nodes[node_type]
321
+
322
+ def update_node(
323
+ self,
324
+ node_type: str,
325
+ node_id: Union[int, str],
326
+ **properties: Any
327
+ ) -> Node:
328
+ """Update node properties"""
329
+ node = self.get_node(node_type, node_id)
330
+ node.properties.update(properties)
331
+ return node
332
+
333
+ def nodes(self, node_type: Optional[str] = None) -> Iterator[Node]:
334
+ """
335
+ Iterate over nodes.
336
+
337
+ Args:
338
+ node_type: Filter by type (optional)
339
+
340
+ Yields:
341
+ Node objects
342
+ """
343
+ if node_type:
344
+ if node_type in self._nodes:
345
+ yield from self._nodes[node_type].values()
346
+ else:
347
+ for type_nodes in self._nodes.values():
348
+ yield from type_nodes.values()
349
+
350
+ def node_count(self, node_type: Optional[str] = None) -> int:
351
+ """Count nodes, optionally filtered by type"""
352
+ if node_type:
353
+ return len(self._nodes.get(node_type, {}))
354
+ return sum(len(nodes) for nodes in self._nodes.values())
355
+
356
+ def node_types(self) -> List[str]:
357
+ """Get all node types in the graph"""
358
+ return list(self._nodes.keys())
359
+
360
+ # =========================================================================
361
+ # Edge Operations
362
+ # =========================================================================
363
+
364
+ def add_edge(
365
+ self,
366
+ rel_type: str,
367
+ source: NodeRef,
368
+ target: NodeRef,
369
+ **properties: Any
370
+ ) -> Edge:
371
+ """
372
+ Add an edge to the graph.
373
+
374
+ Args:
375
+ rel_type: Relationship type (e.g., 'KNOWS', 'WORKS_AT')
376
+ source: Source node reference (type, id)
377
+ target: Target node reference (type, id)
378
+ **properties: Edge properties
379
+
380
+ Returns:
381
+ The created Edge
382
+
383
+ Raises:
384
+ NodeNotFoundError: If source or target node doesn't exist
385
+ DuplicateEdgeError: If edge already exists
386
+ """
387
+ # Validate nodes exist
388
+ if not self.has_node(source[0], source[1]):
389
+ raise NodeNotFoundError(source)
390
+ if not self.has_node(target[0], target[1]):
391
+ raise NodeNotFoundError(target)
392
+
393
+ edge_key = (rel_type, source, target)
394
+ if edge_key in self._edge_set:
395
+ raise DuplicateEdgeError(edge_key)
396
+
397
+ edge = Edge(rel_type=rel_type, source=source, target=target, properties=properties)
398
+
399
+ # Add to storage
400
+ self._edges[rel_type].append(edge)
401
+ self._out_edges[source].append(edge)
402
+ self._in_edges[target].append(edge)
403
+ self._edge_set.add(edge_key)
404
+
405
+ # For undirected graphs, add reverse edge
406
+ if not self.directed:
407
+ reverse_key = (rel_type, target, source)
408
+ if reverse_key not in self._edge_set:
409
+ reverse_edge = Edge(
410
+ rel_type=rel_type,
411
+ source=target,
412
+ target=source,
413
+ properties=properties
414
+ )
415
+ self._edges[rel_type].append(reverse_edge)
416
+ self._out_edges[target].append(reverse_edge)
417
+ self._in_edges[source].append(reverse_edge)
418
+ self._edge_set.add(reverse_key)
419
+
420
+ return edge
421
+
422
+ def _remove_edge_internal(self, edge: Edge) -> None:
423
+ """Internal method to remove an edge"""
424
+ if edge.key in self._edge_set:
425
+ self._edge_set.remove(edge.key)
426
+ self._edges[edge.rel_type].remove(edge)
427
+ self._out_edges[edge.source].remove(edge)
428
+ self._in_edges[edge.target].remove(edge)
429
+
430
+ def remove_edge(
431
+ self,
432
+ rel_type: str,
433
+ source: NodeRef,
434
+ target: NodeRef
435
+ ) -> None:
436
+ """Remove an edge from the graph.
437
+
438
+ On undirected graphs the auto-created reverse edge is removed too.
439
+ """
440
+ edge_key = (rel_type, source, target)
441
+ if edge_key not in self._edge_set:
442
+ raise EdgeNotFoundError(edge_key)
443
+
444
+ keys_to_remove = [edge_key]
445
+ if not self.directed:
446
+ reverse_key = (rel_type, target, source)
447
+ if reverse_key != edge_key and reverse_key in self._edge_set:
448
+ keys_to_remove.append(reverse_key)
449
+
450
+ for key in keys_to_remove:
451
+ for edge in self._edges[rel_type]:
452
+ if edge.key == key:
453
+ self._remove_edge_internal(edge)
454
+ break
455
+
456
+ def has_edge(
457
+ self,
458
+ rel_type: str,
459
+ source: NodeRef,
460
+ target: NodeRef
461
+ ) -> bool:
462
+ """Check if edge exists"""
463
+ return (rel_type, source, target) in self._edge_set
464
+
465
+ def get_edge(
466
+ self,
467
+ rel_type: str,
468
+ source: NodeRef,
469
+ target: NodeRef
470
+ ) -> Edge:
471
+ """Get an edge by its components"""
472
+ edge_key = (rel_type, source, target)
473
+ if edge_key not in self._edge_set:
474
+ raise EdgeNotFoundError(edge_key)
475
+
476
+ for edge in self._edges[rel_type]:
477
+ if edge.key == edge_key:
478
+ return edge
479
+ raise EdgeNotFoundError(edge_key)
480
+
481
+ def edges(
482
+ self,
483
+ rel_type: Optional[str] = None,
484
+ source: Optional[NodeRef] = None,
485
+ target: Optional[NodeRef] = None
486
+ ) -> Iterator[Edge]:
487
+ """
488
+ Iterate over edges with optional filters.
489
+
490
+ Args:
491
+ rel_type: Filter by relationship type
492
+ source: Filter by source node
493
+ target: Filter by target node
494
+
495
+ Yields:
496
+ Edge objects
497
+ """
498
+ if source:
499
+ edge_list = self._out_edges.get(source, [])
500
+ elif target:
501
+ edge_list = self._in_edges.get(target, [])
502
+ elif rel_type:
503
+ edge_list = self._edges.get(rel_type, [])
504
+ else:
505
+ edge_list = [e for edges in self._edges.values() for e in edges]
506
+
507
+ for edge in edge_list:
508
+ if rel_type and edge.rel_type != rel_type:
509
+ continue
510
+ if source and edge.source != source:
511
+ continue
512
+ if target and edge.target != target:
513
+ continue
514
+ yield edge
515
+
516
+ def edge_count(self, rel_type: Optional[str] = None) -> int:
517
+ """Count edges, optionally filtered by type"""
518
+ if rel_type:
519
+ return len(self._edges.get(rel_type, []))
520
+ return len(self._edge_set)
521
+
522
+ def edge_types(self) -> List[str]:
523
+ """Get all edge/relationship types in the graph"""
524
+ return list(self._edges.keys())
525
+
526
+ # =========================================================================
527
+ # Traversal Operations
528
+ # =========================================================================
529
+
530
+ def neighbors(
531
+ self,
532
+ node_ref: NodeRef,
533
+ rel_type: Optional[str] = None,
534
+ direction: Direction = Direction.OUT
535
+ ) -> List[NodeRef]:
536
+ """
537
+ Get neighboring nodes.
538
+
539
+ Args:
540
+ node_ref: Starting node reference
541
+ rel_type: Filter by relationship type (optional)
542
+ direction: OUT (default), IN, or BOTH
543
+
544
+ Returns:
545
+ List of neighbor node references
546
+ """
547
+ neighbors = []
548
+
549
+ if direction in (Direction.OUT, Direction.BOTH):
550
+ for edge in self._out_edges.get(node_ref, []):
551
+ if rel_type is None or edge.rel_type == rel_type:
552
+ neighbors.append(edge.target)
553
+
554
+ if direction in (Direction.IN, Direction.BOTH):
555
+ for edge in self._in_edges.get(node_ref, []):
556
+ if rel_type is None or edge.rel_type == rel_type:
557
+ neighbors.append(edge.source)
558
+
559
+ return neighbors
560
+
561
+ def multi_hop(
562
+ self,
563
+ start: NodeRef,
564
+ rel_type: Optional[str] = None,
565
+ hops: int = 1,
566
+ direction: Direction = Direction.OUT
567
+ ) -> List[NodeRef]:
568
+ """
569
+ Get nodes N hops away.
570
+
571
+ Args:
572
+ start: Starting node reference
573
+ rel_type: Relationship type to follow (optional, any if None)
574
+ hops: Number of hops (default: 1)
575
+ direction: Traversal direction
576
+
577
+ Returns:
578
+ List of nodes exactly N hops away
579
+ """
580
+ if hops < 1:
581
+ return [start]
582
+
583
+ current = {start}
584
+ visited = {start}
585
+
586
+ for _ in range(hops):
587
+ next_level = set()
588
+ for node in current:
589
+ for neighbor in self.neighbors(node, rel_type, direction):
590
+ if neighbor not in visited:
591
+ next_level.add(neighbor)
592
+ visited.update(next_level)
593
+ current = next_level
594
+
595
+ return list(current)
596
+
597
+ def multi_hop_range(
598
+ self,
599
+ start: NodeRef,
600
+ rel_type: Optional[str] = None,
601
+ min_hops: int = 1,
602
+ max_hops: int = 3,
603
+ direction: Direction = Direction.OUT
604
+ ) -> List[NodeRef]:
605
+ """
606
+ Get nodes within a range of hops.
607
+
608
+ Args:
609
+ start: Starting node
610
+ rel_type: Relationship type (optional)
611
+ min_hops: Minimum hops (inclusive)
612
+ max_hops: Maximum hops (inclusive)
613
+ direction: Traversal direction
614
+
615
+ Returns:
616
+ List of nodes within hop range
617
+ """
618
+ result = set()
619
+ current = {start}
620
+ visited = {start}
621
+
622
+ for hop in range(1, max_hops + 1):
623
+ next_level = set()
624
+ for node in current:
625
+ for neighbor in self.neighbors(node, rel_type, direction):
626
+ if neighbor not in visited:
627
+ next_level.add(neighbor)
628
+ if hop >= min_hops:
629
+ result.add(neighbor)
630
+ visited.update(next_level)
631
+ current = next_level
632
+
633
+ if not current:
634
+ break
635
+
636
+ return list(result)
637
+
638
+ def traverse(
639
+ self,
640
+ start: NodeRef,
641
+ pattern: List[Tuple[str, Direction]],
642
+ filter_fn: Optional[Callable[[Node], bool]] = None
643
+ ) -> List[NodeRef]:
644
+ """
645
+ Traverse graph following a pattern of relationship types.
646
+
647
+ Args:
648
+ start: Starting node
649
+ pattern: List of (rel_type, direction) tuples defining the path
650
+ filter_fn: Optional filter function applied at each step
651
+
652
+ Returns:
653
+ List of nodes reached after following the pattern
654
+
655
+ Example:
656
+ # Find companies where friends work
657
+ graph.traverse(
658
+ ('person', 1),
659
+ [('KNOWS', Direction.OUT), ('WORKS_AT', Direction.OUT)]
660
+ )
661
+ """
662
+ current = {start}
663
+
664
+ for rel_type, direction in pattern:
665
+ next_level = set()
666
+ for node_ref in current:
667
+ neighbors = self.neighbors(node_ref, rel_type, direction)
668
+ for neighbor in neighbors:
669
+ if filter_fn is None:
670
+ next_level.add(neighbor)
671
+ else:
672
+ node = self.get_node_by_ref(neighbor)
673
+ if filter_fn(node):
674
+ next_level.add(neighbor)
675
+ current = next_level
676
+ if not current:
677
+ break
678
+
679
+ return list(current)
680
+
681
+ # =========================================================================
682
+ # Path Finding
683
+ # =========================================================================
684
+
685
+ def shortest_path(
686
+ self,
687
+ start: NodeRef,
688
+ end: NodeRef,
689
+ rel_type: Optional[str] = None,
690
+ max_hops: int = 10,
691
+ direction: Direction = Direction.OUT
692
+ ) -> Optional[Path]:
693
+ """
694
+ Find shortest path between two nodes using BFS.
695
+
696
+ Args:
697
+ start: Starting node
698
+ end: Target node
699
+ rel_type: Relationship type to follow (optional)
700
+ max_hops: Maximum path length
701
+ direction: Traversal direction
702
+
703
+ Returns:
704
+ Path object or None if no path exists
705
+ """
706
+ if start == end:
707
+ return Path(nodes=[start], edges=[])
708
+
709
+ # BFS with path tracking
710
+ queue = [(start, [start], [])]
711
+ visited = {start}
712
+
713
+ while queue:
714
+ current, path_nodes, path_edges = queue.pop(0)
715
+
716
+ # A path with N nodes has N-1 hops; expanding adds one more hop,
717
+ # so only expand when the resulting path stays within max_hops.
718
+ if len(path_nodes) > max_hops:
719
+ continue
720
+
721
+ for edge in self._out_edges.get(current, []) if direction != Direction.IN else []:
722
+ if rel_type and edge.rel_type != rel_type:
723
+ continue
724
+ if edge.target == end:
725
+ return Path(
726
+ nodes=path_nodes + [edge.target],
727
+ edges=path_edges + [edge]
728
+ )
729
+ if edge.target not in visited:
730
+ visited.add(edge.target)
731
+ queue.append((
732
+ edge.target,
733
+ path_nodes + [edge.target],
734
+ path_edges + [edge]
735
+ ))
736
+
737
+ if direction in (Direction.IN, Direction.BOTH):
738
+ for edge in self._in_edges.get(current, []):
739
+ if rel_type and edge.rel_type != rel_type:
740
+ continue
741
+ if edge.source == end:
742
+ return Path(
743
+ nodes=path_nodes + [edge.source],
744
+ edges=path_edges + [edge]
745
+ )
746
+ if edge.source not in visited:
747
+ visited.add(edge.source)
748
+ queue.append((
749
+ edge.source,
750
+ path_nodes + [edge.source],
751
+ path_edges + [edge]
752
+ ))
753
+
754
+ return None
755
+
756
+ def all_paths(
757
+ self,
758
+ start: NodeRef,
759
+ end: NodeRef,
760
+ rel_type: Optional[str] = None,
761
+ max_hops: int = 5,
762
+ direction: Direction = Direction.OUT
763
+ ) -> List[Path]:
764
+ """
765
+ Find all paths between two nodes using DFS.
766
+
767
+ Args:
768
+ start: Starting node
769
+ end: Target node
770
+ rel_type: Relationship type (optional)
771
+ max_hops: Maximum path length
772
+ direction: Traversal direction
773
+
774
+ Returns:
775
+ List of all valid paths
776
+ """
777
+ paths: List[Path] = []
778
+
779
+ def edges_from(node: NodeRef) -> List[Edge]:
780
+ edges_to_follow: List[Edge] = []
781
+ if direction in (Direction.OUT, Direction.BOTH):
782
+ edges_to_follow.extend(self._out_edges.get(node, []))
783
+ if direction in (Direction.IN, Direction.BOTH):
784
+ edges_to_follow.extend(
785
+ Edge(e.rel_type, e.target, e.source, e.properties)
786
+ for e in self._in_edges.get(node, [])
787
+ )
788
+ if rel_type:
789
+ edges_to_follow = [e for e in edges_to_follow if e.rel_type == rel_type]
790
+ return edges_to_follow
791
+
792
+ if start == end:
793
+ return [Path(nodes=[start], edges=[])]
794
+
795
+ # Iterative DFS with backtracking: one edge-iterator frame per node
796
+ # on the current path, so deep graphs cannot overflow the call stack.
797
+ path_nodes: List[NodeRef] = [start]
798
+ path_edges: List[Edge] = []
799
+ visited: Set[NodeRef] = {start}
800
+ stack: List[Iterator[Edge]] = [iter(edges_from(start))]
801
+
802
+ while stack:
803
+ edge = next(stack[-1], None)
804
+ if edge is None:
805
+ # Frame exhausted: backtrack (never pop the start node)
806
+ stack.pop()
807
+ if path_edges:
808
+ visited.remove(path_nodes.pop())
809
+ path_edges.pop()
810
+ continue
811
+
812
+ next_node = edge.target
813
+ if next_node in visited:
814
+ continue
815
+
816
+ visited.add(next_node)
817
+ path_nodes.append(next_node)
818
+ path_edges.append(edge)
819
+
820
+ if len(path_nodes) > max_hops + 1:
821
+ # Path too long: undo this step
822
+ visited.remove(path_nodes.pop())
823
+ path_edges.pop()
824
+ continue
825
+
826
+ if next_node == end:
827
+ paths.append(Path(nodes=list(path_nodes), edges=list(path_edges)))
828
+ visited.remove(path_nodes.pop())
829
+ path_edges.pop()
830
+ continue
831
+
832
+ stack.append(iter(edges_from(next_node)))
833
+
834
+ return paths
835
+
836
+ def path_exists(
837
+ self,
838
+ start: NodeRef,
839
+ end: NodeRef,
840
+ rel_type: Optional[str] = None,
841
+ max_hops: int = 10
842
+ ) -> bool:
843
+ """Check if a path exists between two nodes"""
844
+ return self.shortest_path(start, end, rel_type, max_hops) is not None
845
+
846
+ # =========================================================================
847
+ # Graph Analysis
848
+ # =========================================================================
849
+
850
+ def in_degree(self, node_ref: NodeRef) -> int:
851
+ """Count incoming edges"""
852
+ return len(self._in_edges.get(node_ref, []))
853
+
854
+ def out_degree(self, node_ref: NodeRef) -> int:
855
+ """Count outgoing edges"""
856
+ return len(self._out_edges.get(node_ref, []))
857
+
858
+ def degree(self, node_ref: NodeRef) -> int:
859
+ """Total degree (in + out for directed, unique edges for undirected)"""
860
+ if self.directed:
861
+ return self.in_degree(node_ref) + self.out_degree(node_ref)
862
+ else:
863
+ # For undirected, count unique edges
864
+ edges = set()
865
+ for e in self._out_edges.get(node_ref, []):
866
+ edges.add(frozenset([e.source, e.target]))
867
+ for e in self._in_edges.get(node_ref, []):
868
+ edges.add(frozenset([e.source, e.target]))
869
+ return len(edges)
870
+
871
+ def is_connected(self) -> bool:
872
+ """Check if graph is connected (all nodes reachable from any node)"""
873
+ all_nodes = list(self.nodes())
874
+ if not all_nodes:
875
+ return True
876
+
877
+ start = all_nodes[0].ref
878
+ visited = {start}
879
+ queue = [start]
880
+
881
+ while queue:
882
+ current = queue.pop(0)
883
+ for neighbor in self.neighbors(current, direction=Direction.BOTH):
884
+ if neighbor not in visited:
885
+ visited.add(neighbor)
886
+ queue.append(neighbor)
887
+
888
+ return len(visited) == len(all_nodes)
889
+
890
+ def has_cycle(self, rel_type: Optional[str] = None) -> bool:
891
+ """
892
+ Check if graph has cycles.
893
+
894
+ For directed graphs, checks for directed cycles (iterative DFS with a
895
+ recursion stack). For undirected graphs, uses standard undirected
896
+ cycle detection: DFS that tracks the parent node it arrived from, so
897
+ the auto-created reverse edge is not mistaken for a cycle.
898
+ """
899
+ if self.directed:
900
+ return self._has_cycle_directed(rel_type)
901
+ return self._has_cycle_undirected(rel_type)
902
+
903
+ def _has_cycle_directed(self, rel_type: Optional[str]) -> bool:
904
+ """Directed cycle detection using an explicit DFS stack."""
905
+ visited: Set[NodeRef] = set()
906
+
907
+ for node in self.nodes():
908
+ start = node.ref
909
+ if start in visited:
910
+ continue
911
+
912
+ rec_stack: Set[NodeRef] = {start}
913
+ visited.add(start)
914
+ stack: List[Tuple[NodeRef, Iterator[NodeRef]]] = [
915
+ (start, iter(self.neighbors(start, rel_type)))
916
+ ]
917
+
918
+ while stack:
919
+ current, neighbors_iter = stack[-1]
920
+ neighbor = next(neighbors_iter, None)
921
+ if neighbor is None:
922
+ stack.pop()
923
+ rec_stack.discard(current)
924
+ continue
925
+ if neighbor in rec_stack:
926
+ return True
927
+ if neighbor not in visited:
928
+ visited.add(neighbor)
929
+ rec_stack.add(neighbor)
930
+ stack.append((neighbor, iter(self.neighbors(neighbor, rel_type))))
931
+
932
+ return False
933
+
934
+ def _has_cycle_undirected(self, rel_type: Optional[str]) -> bool:
935
+ """Undirected cycle detection: iterative DFS tracking the parent node."""
936
+ visited: Set[NodeRef] = set()
937
+
938
+ for node in self.nodes():
939
+ start = node.ref
940
+ if start in visited:
941
+ continue
942
+
943
+ visited.add(start)
944
+ stack: List[Tuple[NodeRef, Optional[NodeRef], Iterator[NodeRef]]] = [
945
+ (start, None, iter(self.neighbors(start, rel_type)))
946
+ ]
947
+
948
+ while stack:
949
+ current, parent, neighbors_iter = stack[-1]
950
+ neighbor = next(neighbors_iter, None)
951
+ if neighbor is None:
952
+ stack.pop()
953
+ continue
954
+ if neighbor not in visited:
955
+ visited.add(neighbor)
956
+ stack.append(
957
+ (neighbor, current, iter(self.neighbors(neighbor, rel_type)))
958
+ )
959
+ elif neighbor != parent:
960
+ # Reached an already-visited node via a new edge -> cycle
961
+ return True
962
+
963
+ return False
964
+
965
+ def connected_components(self) -> List[Set[NodeRef]]:
966
+ """Get all connected components"""
967
+ visited = set()
968
+ components = []
969
+
970
+ for node in self.nodes():
971
+ if node.ref not in visited:
972
+ component = set()
973
+ queue = [node.ref]
974
+
975
+ while queue:
976
+ current = queue.pop(0)
977
+ if current not in visited:
978
+ visited.add(current)
979
+ component.add(current)
980
+ for neighbor in self.neighbors(current, direction=Direction.BOTH):
981
+ if neighbor not in visited:
982
+ queue.append(neighbor)
983
+
984
+ components.append(component)
985
+
986
+ return components
987
+
988
+ # =========================================================================
989
+ # Serialization (ISON/ISONL)
990
+ # =========================================================================
991
+
992
+ def to_ison(self) -> str:
993
+ """
994
+ Serialize graph to ISON format.
995
+
996
+ Format:
997
+ nodes.{type}
998
+ id {property_fields...}
999
+ {id} {property_values...}
1000
+
1001
+ edges.{rel_type}
1002
+ source target {property_fields...}
1003
+ {source_ref} {target_ref} {property_values...}
1004
+ """
1005
+ blocks = []
1006
+
1007
+ # Serialize nodes by type
1008
+ for node_type in sorted(self._nodes.keys()):
1009
+ nodes = list(self._nodes[node_type].values())
1010
+ if not nodes:
1011
+ continue
1012
+
1013
+ # Collect all property keys
1014
+ prop_keys = set()
1015
+ for node in nodes:
1016
+ prop_keys.update(node.properties.keys())
1017
+ prop_keys = sorted(prop_keys)
1018
+
1019
+ # Build block
1020
+ lines = [f"nodes.{node_type}"]
1021
+ fields = ["id"] + prop_keys
1022
+ lines.append(" ".join(fields))
1023
+
1024
+ for node in nodes:
1025
+ values = [str(node.id)]
1026
+ for key in prop_keys:
1027
+ val = node.properties.get(key)
1028
+ values.append(self._value_to_ison(val))
1029
+ lines.append(" ".join(values))
1030
+
1031
+ blocks.append("\n".join(lines))
1032
+
1033
+ # Serialize edges by type
1034
+ for rel_type in sorted(self._edges.keys()):
1035
+ edges = self._edges[rel_type]
1036
+ if not edges:
1037
+ continue
1038
+
1039
+ # Collect all property keys
1040
+ prop_keys = set()
1041
+ for edge in edges:
1042
+ prop_keys.update(edge.properties.keys())
1043
+ prop_keys = sorted(prop_keys)
1044
+
1045
+ # Build block
1046
+ lines = [f"edges.{rel_type}"]
1047
+ fields = ["source", "target"] + prop_keys
1048
+ lines.append(" ".join(fields))
1049
+
1050
+ for edge in edges:
1051
+ source_ref = f":{edge.source[0]}:{edge.source[1]}"
1052
+ target_ref = f":{edge.target[0]}:{edge.target[1]}"
1053
+ values = [source_ref, target_ref]
1054
+ for key in prop_keys:
1055
+ val = edge.properties.get(key)
1056
+ values.append(self._value_to_ison(val))
1057
+ lines.append(" ".join(values))
1058
+
1059
+ blocks.append("\n".join(lines))
1060
+
1061
+ return "\n\n".join(blocks)
1062
+
1063
+ def _value_to_ison(self, value: Any) -> str:
1064
+ """Convert value to ISON string representation.
1065
+
1066
+ Standardized quoting rule (shared by all ISON language ports): a
1067
+ string is wrapped in double quotes when it contains a space, ``|``,
1068
+ ``"``, a newline, or is the empty string. Embedded ``"`` is escaped
1069
+ as ``\\"`` and newline as ``\\n``; the ISON/ISONL loaders reverse
1070
+ these so round-trips are lossless.
1071
+ """
1072
+ if value is None:
1073
+ return "null"
1074
+ if isinstance(value, bool):
1075
+ return "true" if value else "false"
1076
+ if isinstance(value, str):
1077
+ if value == "" or any(ch in value for ch in (' ', '|', '"', '\n')):
1078
+ escaped = value.replace('"', '\\"').replace('\n', '\\n')
1079
+ return f'"{escaped}"'
1080
+ return value
1081
+ return str(value)
1082
+
1083
+ def to_isonl(self) -> str:
1084
+ """Serialize graph to ISONL streaming format"""
1085
+ lines = []
1086
+
1087
+ # Serialize nodes
1088
+ for node in self.nodes():
1089
+ prop_keys = sorted(node.properties.keys())
1090
+ fields = ["id"] + prop_keys
1091
+ values = [str(node.id)] + [
1092
+ self._value_to_ison(node.properties.get(k))
1093
+ for k in prop_keys
1094
+ ]
1095
+ line = f"nodes.{node.type}|{' '.join(fields)}|{' '.join(values)}"
1096
+ lines.append(line)
1097
+
1098
+ # Serialize edges
1099
+ for rel_type, edges in self._edges.items():
1100
+ for edge in edges:
1101
+ prop_keys = sorted(edge.properties.keys())
1102
+ fields = ["source", "target"] + prop_keys
1103
+ source_ref = f":{edge.source[0]}:{edge.source[1]}"
1104
+ target_ref = f":{edge.target[0]}:{edge.target[1]}"
1105
+ values = [source_ref, target_ref] + [
1106
+ self._value_to_ison(edge.properties.get(k))
1107
+ for k in prop_keys
1108
+ ]
1109
+ line = f"edges.{rel_type}|{' '.join(fields)}|{' '.join(values)}"
1110
+ lines.append(line)
1111
+
1112
+ return "\n".join(lines)
1113
+
1114
+ def save(self, path: Union[str, FilePath], format: str = "auto") -> None:
1115
+ """
1116
+ Save graph to file.
1117
+
1118
+ Args:
1119
+ path: Output file path
1120
+ format: 'ison', 'isonl', or 'auto' (detect from extension)
1121
+ """
1122
+ path = FilePath(path)
1123
+
1124
+ if format == "auto":
1125
+ format = "isonl" if path.suffix == ".isonl" else "ison"
1126
+
1127
+ if format == "isonl":
1128
+ content = self.to_isonl()
1129
+ else:
1130
+ content = self.to_ison()
1131
+
1132
+ path.write_text(content, encoding="utf-8")
1133
+
1134
+ @classmethod
1135
+ def from_ison(cls, text: str, name: str = "graph") -> 'ISONGraph':
1136
+ """
1137
+ Parse graph from ISON format.
1138
+
1139
+ Args:
1140
+ text: ISON formatted string
1141
+ name: Graph name
1142
+
1143
+ Returns:
1144
+ ISONGraph instance
1145
+ """
1146
+ graph = cls(name=name)
1147
+ doc = loads(text)
1148
+
1149
+ for block in doc.blocks:
1150
+ if block.kind == "nodes":
1151
+ node_type = block.name
1152
+ for row in block.rows:
1153
+ node_id = row.get("id")
1154
+ props = {k: v for k, v in row.items() if k != "id"}
1155
+ graph.add_node(node_type, node_id, **props)
1156
+
1157
+ elif block.kind == "edges":
1158
+ rel_type = block.name
1159
+ for row in block.rows:
1160
+ source = row.get("source")
1161
+ target = row.get("target")
1162
+ props = {k: v for k, v in row.items() if k not in ("source", "target")}
1163
+
1164
+ # Convert Reference objects to tuples
1165
+ if isinstance(source, Reference):
1166
+ source = (source.type, int(source.id) if source.id.isdigit() else source.id)
1167
+ if isinstance(target, Reference):
1168
+ target = (target.type, int(target.id) if target.id.isdigit() else target.id)
1169
+
1170
+ graph.add_edge(rel_type, source, target, **props)
1171
+
1172
+ return graph
1173
+
1174
+ @classmethod
1175
+ def from_isonl(cls, text: str, name: str = "graph") -> 'ISONGraph':
1176
+ """Parse graph from ISONL format"""
1177
+ graph = cls(name=name)
1178
+ doc = loads_isonl(text)
1179
+
1180
+ # Same logic as from_ison since loads_isonl returns Document
1181
+ for block in doc.blocks:
1182
+ if block.kind == "nodes":
1183
+ node_type = block.name
1184
+ for row in block.rows:
1185
+ node_id = row.get("id")
1186
+ props = {k: v for k, v in row.items() if k != "id"}
1187
+ graph.add_node(node_type, node_id, **props)
1188
+
1189
+ elif block.kind == "edges":
1190
+ rel_type = block.name
1191
+ for row in block.rows:
1192
+ source = row.get("source")
1193
+ target = row.get("target")
1194
+ props = {k: v for k, v in row.items() if k not in ("source", "target")}
1195
+
1196
+ if isinstance(source, Reference):
1197
+ source = (source.type, int(source.id) if source.id.isdigit() else source.id)
1198
+ if isinstance(target, Reference):
1199
+ target = (target.type, int(target.id) if target.id.isdigit() else target.id)
1200
+
1201
+ graph.add_edge(rel_type, source, target, **props)
1202
+
1203
+ return graph
1204
+
1205
+ def to_dict(self) -> Dict[str, Any]:
1206
+ """
1207
+ Serialize graph to dictionary/JSON format.
1208
+
1209
+ Returns:
1210
+ Dictionary with 'name', 'directed', 'nodes', and 'edges' keys
1211
+ """
1212
+ result = {
1213
+ "name": self.name,
1214
+ "directed": self.directed,
1215
+ "nodes": [],
1216
+ "edges": []
1217
+ }
1218
+
1219
+ # Serialize nodes
1220
+ for node in self.nodes():
1221
+ node_dict = {
1222
+ "type": node.type,
1223
+ "id": node.id,
1224
+ **node.properties
1225
+ }
1226
+ result["nodes"].append(node_dict)
1227
+
1228
+ # Serialize edges
1229
+ for edge in self.edges():
1230
+ edge_dict = {
1231
+ "rel_type": edge.rel_type,
1232
+ "source": {"type": edge.source[0], "id": edge.source[1]},
1233
+ "target": {"type": edge.target[0], "id": edge.target[1]},
1234
+ "properties": edge.properties
1235
+ }
1236
+ result["edges"].append(edge_dict)
1237
+
1238
+ return result
1239
+
1240
+ @classmethod
1241
+ def from_dict(cls, data: Dict[str, Any], name: Optional[str] = None) -> 'ISONGraph':
1242
+ """
1243
+ Parse graph from dictionary/JSON format.
1244
+
1245
+ Args:
1246
+ data: Dictionary with 'nodes' and 'edges' keys
1247
+ name: Graph name (uses data['name'] if not provided)
1248
+
1249
+ Returns:
1250
+ ISONGraph instance
1251
+ """
1252
+ graph_name = name or data.get("name", "graph")
1253
+ directed = data.get("directed", True)
1254
+ graph = cls(name=graph_name, directed=directed)
1255
+
1256
+ # Parse nodes
1257
+ for node_data in data.get("nodes", []):
1258
+ node_type = node_data.get("type", "entity")
1259
+ node_id = node_data.get("id")
1260
+ # Remaining fields are properties
1261
+ props = {k: v for k, v in node_data.items() if k not in ("type", "id")}
1262
+ graph.add_node(node_type, node_id, **props)
1263
+
1264
+ # Parse edges
1265
+ for edge_data in data.get("edges", []):
1266
+ rel_type = edge_data.get("rel_type", edge_data.get("type", "RELATED_TO"))
1267
+
1268
+ # Handle source/target as dict or tuple
1269
+ source = edge_data.get("source")
1270
+ target = edge_data.get("target")
1271
+
1272
+ if isinstance(source, dict):
1273
+ source = (source.get("type", "entity"), source.get("id"))
1274
+ if isinstance(target, dict):
1275
+ target = (target.get("type", "entity"), target.get("id"))
1276
+
1277
+ props = edge_data.get("properties", {})
1278
+ # Also support inline properties
1279
+ for k, v in edge_data.items():
1280
+ if k not in ("rel_type", "type", "source", "target", "properties"):
1281
+ props[k] = v
1282
+
1283
+ graph.add_edge(rel_type, source, target, **props)
1284
+
1285
+ return graph
1286
+
1287
+ @classmethod
1288
+ def load(cls, path: Union[str, FilePath], format: str = "auto") -> 'ISONGraph':
1289
+ """
1290
+ Load graph from file.
1291
+
1292
+ Args:
1293
+ path: Input file path
1294
+ format: 'ison', 'isonl', or 'auto'
1295
+
1296
+ Returns:
1297
+ ISONGraph instance
1298
+ """
1299
+ path = FilePath(path)
1300
+ text = path.read_text(encoding="utf-8")
1301
+
1302
+ if format == "auto":
1303
+ format = "isonl" if path.suffix == ".isonl" else "ison"
1304
+
1305
+ name = path.stem
1306
+
1307
+ if format == "isonl":
1308
+ return cls.from_isonl(text, name)
1309
+ return cls.from_ison(text, name)
1310
+
1311
+ @classmethod
1312
+ def parse(cls, text: str, name: str = "graph") -> 'ISONGraph':
1313
+ """Alias for from_ison"""
1314
+ return cls.from_ison(text, name)
1315
+
1316
+ # =========================================================================
1317
+ # Query Interface (Fluent API)
1318
+ # =========================================================================
1319
+
1320
+ def start(self, node_ref: NodeRef) -> 'GraphTraversal':
1321
+ """
1322
+ Start a fluent traversal from a node.
1323
+
1324
+ Example:
1325
+ graph.start(('person', 1)) \\
1326
+ .hop('KNOWS') \\
1327
+ .hop('WORKS_AT') \\
1328
+ .collect()
1329
+ """
1330
+ return GraphTraversal(self, node_ref)
1331
+
1332
+ def query(self, pattern: str) -> List[NodeRef]:
1333
+ """
1334
+ Execute a simple pattern query.
1335
+
1336
+ Pattern syntax:
1337
+ :type:id -[:REL]-> :type:id
1338
+ :type:id -[:REL*N]-> * (N hops)
1339
+ :type:id -[:REL*1..3]-> * (1-3 hops)
1340
+
1341
+ Example:
1342
+ graph.query(":person:1 -[:KNOWS*2]-> *")
1343
+ """
1344
+ # Simple pattern parsing
1345
+ pattern = pattern.strip()
1346
+
1347
+ # Match: :type:id -[:REL*N]-> *
1348
+ hop_match = re.match(
1349
+ r':(\w+):(\w+)\s*-\[:(\w+)\*(\d+)\]->\s*\*',
1350
+ pattern
1351
+ )
1352
+ if hop_match:
1353
+ node_type, node_id, rel_type, hops = hop_match.groups()
1354
+ node_id = int(node_id) if node_id.isdigit() else node_id
1355
+ return self.multi_hop((node_type, node_id), rel_type, int(hops))
1356
+
1357
+ # Match: :type:id -[:REL*N..M]-> *
1358
+ range_match = re.match(
1359
+ r':(\w+):(\w+)\s*-\[:(\w+)\*(\d+)\.\.(\d+)\]->\s*\*',
1360
+ pattern
1361
+ )
1362
+ if range_match:
1363
+ node_type, node_id, rel_type, min_h, max_h = range_match.groups()
1364
+ node_id = int(node_id) if node_id.isdigit() else node_id
1365
+ return self.multi_hop_range(
1366
+ (node_type, node_id), rel_type,
1367
+ int(min_h), int(max_h)
1368
+ )
1369
+
1370
+ # Match: :type:id -[:REL]-> *
1371
+ simple_match = re.match(
1372
+ r':(\w+):(\w+)\s*-\[:(\w+)\]->\s*\*',
1373
+ pattern
1374
+ )
1375
+ if simple_match:
1376
+ node_type, node_id, rel_type = simple_match.groups()
1377
+ node_id = int(node_id) if node_id.isdigit() else node_id
1378
+ return self.neighbors((node_type, node_id), rel_type)
1379
+
1380
+ raise ValueError(f"Invalid query pattern: {pattern}")
1381
+
1382
+ def __repr__(self) -> str:
1383
+ return f"ISONGraph(name={self.name}, nodes={self.node_count()}, edges={self.edge_count()})"
1384
+
1385
+
1386
+ # =============================================================================
1387
+ # Fluent Traversal API
1388
+ # =============================================================================
1389
+
1390
+ class GraphTraversal:
1391
+ """Fluent API for graph traversal"""
1392
+
1393
+ def __init__(self, graph: ISONGraph, start: NodeRef):
1394
+ self._graph = graph
1395
+ self._current: Set[NodeRef] = {start}
1396
+ self._visited: Set[NodeRef] = {start}
1397
+
1398
+ def hop(
1399
+ self,
1400
+ rel_type: Optional[str] = None,
1401
+ direction: Direction = Direction.OUT,
1402
+ where: Optional[Callable[[Node], bool]] = None
1403
+ ) -> 'GraphTraversal':
1404
+ """
1405
+ Traverse one hop following edges.
1406
+
1407
+ Args:
1408
+ rel_type: Relationship type (optional, any if None)
1409
+ direction: Traversal direction
1410
+ where: Filter function for nodes
1411
+
1412
+ Returns:
1413
+ Self for chaining
1414
+ """
1415
+ next_level = set()
1416
+
1417
+ for node_ref in self._current:
1418
+ neighbors = self._graph.neighbors(node_ref, rel_type, direction)
1419
+ for neighbor in neighbors:
1420
+ if neighbor not in self._visited:
1421
+ if where is None:
1422
+ next_level.add(neighbor)
1423
+ else:
1424
+ node = self._graph.get_node_by_ref(neighbor)
1425
+ if where(node):
1426
+ next_level.add(neighbor)
1427
+
1428
+ self._visited.update(next_level)
1429
+ self._current = next_level
1430
+ return self
1431
+
1432
+ def hops(
1433
+ self,
1434
+ n: int,
1435
+ rel_type: Optional[str] = None,
1436
+ direction: Direction = Direction.OUT
1437
+ ) -> 'GraphTraversal':
1438
+ """Traverse N hops"""
1439
+ for _ in range(n):
1440
+ self.hop(rel_type, direction)
1441
+ return self
1442
+
1443
+ def filter(self, fn: Callable[[Node], bool]) -> 'GraphTraversal':
1444
+ """Filter current nodes"""
1445
+ self._current = {
1446
+ ref for ref in self._current
1447
+ if fn(self._graph.get_node_by_ref(ref))
1448
+ }
1449
+ return self
1450
+
1451
+ def collect(self) -> List[NodeRef]:
1452
+ """Return current nodes as list"""
1453
+ return list(self._current)
1454
+
1455
+ def collect_nodes(self) -> List[Node]:
1456
+ """Return current nodes as Node objects"""
1457
+ return [self._graph.get_node_by_ref(ref) for ref in self._current]
1458
+
1459
+ def count(self) -> int:
1460
+ """Count current nodes"""
1461
+ return len(self._current)
1462
+
1463
+ def first(self) -> Optional[NodeRef]:
1464
+ """Get first node or None"""
1465
+ return next(iter(self._current), None)
1466
+
1467
+
1468
+ # =============================================================================
1469
+ # Exports
1470
+ # =============================================================================
1471
+
1472
+ __all__ = [
1473
+ # Version
1474
+ '__version__',
1475
+
1476
+ # Core Classes
1477
+ 'ISONGraph',
1478
+ 'Node',
1479
+ 'Edge',
1480
+ 'Path',
1481
+ 'GraphTraversal',
1482
+
1483
+ # Types
1484
+ 'NodeRef',
1485
+ 'EdgeKey',
1486
+ 'Direction',
1487
+
1488
+ # Errors
1489
+ 'GraphError',
1490
+ 'NodeNotFoundError',
1491
+ 'EdgeNotFoundError',
1492
+ 'DuplicateNodeError',
1493
+ 'DuplicateEdgeError',
1494
+
1495
+ # Submodules
1496
+ 'query',
1497
+ 'schema',
1498
+ ]
1499
+
1500
+
1501
+ # =============================================================================
1502
+ # Submodule Imports (Lazy)
1503
+ # =============================================================================
1504
+
1505
+ def __getattr__(name: str):
1506
+ """Lazy import for submodules."""
1507
+ if name == 'query':
1508
+ from . import query
1509
+ return query
1510
+ if name == 'schema':
1511
+ from . import schema
1512
+ return schema
1513
+ raise AttributeError(f"module 'ison_graph' has no attribute '{name}'")