Skip to content

Commit 2ff1e98

Browse files
authored
Add replay graph core skeleton
Add a minimal deterministic replay graph utility package with topology, ordering, reachability, and graph-diff evidence helpers. Scope remains limited to graph utilities and tests; no artifacts, fixtures, README/docs, workflows, or package files changed.
1 parent fe8a9b4 commit 2ff1e98

6 files changed

Lines changed: 255 additions & 0 deletions

File tree

src/comptext_v7/graph/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""Replay graph deterministic core helpers."""
2+
3+
from .evidence import ReplayGraphDiff, compare_edges
4+
from .ordering import find_order_violations
5+
from .reachability import has_path, reachable_nodes
6+
from .topology import adjacency_map, nodes_from_edges, normalize_edges
7+
8+
__all__ = [
9+
"ReplayGraphDiff",
10+
"adjacency_map",
11+
"compare_edges",
12+
"find_order_violations",
13+
"has_path",
14+
"nodes_from_edges",
15+
"normalize_edges",
16+
"reachable_nodes",
17+
]

src/comptext_v7/graph/evidence.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Deterministic evidence helpers for replay graph diffs."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Iterable
6+
from dataclasses import dataclass
7+
8+
from .topology import normalize_edges, nodes_from_edges
9+
10+
11+
Edge = tuple[str, str]
12+
13+
14+
@dataclass(frozen=True)
15+
class ReplayGraphDiff:
16+
"""Immutable graph-diff evidence for replay validation."""
17+
18+
missing_edges: tuple[Edge, ...]
19+
added_edges: tuple[Edge, ...]
20+
missing_nodes: tuple[str, ...]
21+
added_nodes: tuple[str, ...]
22+
23+
24+
def compare_edges(
25+
original_edges: Iterable[Edge],
26+
replay_edges: Iterable[Edge],
27+
) -> ReplayGraphDiff:
28+
"""Compare original and replay edges and return deterministic diff evidence."""
29+
original = normalize_edges(original_edges)
30+
replay = normalize_edges(replay_edges)
31+
32+
original_set = set(original)
33+
replay_set = set(replay)
34+
35+
missing_edges = tuple(sorted(original_set - replay_set))
36+
added_edges = tuple(sorted(replay_set - original_set))
37+
38+
original_nodes = set(nodes_from_edges(original))
39+
replay_nodes = set(nodes_from_edges(replay))
40+
41+
missing_nodes = tuple(sorted(original_nodes - replay_nodes))
42+
added_nodes = tuple(sorted(replay_nodes - original_nodes))
43+
44+
return ReplayGraphDiff(
45+
missing_edges=missing_edges,
46+
added_edges=added_edges,
47+
missing_nodes=missing_nodes,
48+
added_nodes=added_nodes,
49+
)

src/comptext_v7/graph/ordering.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Deterministic ordering checks for replay sequences."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Iterable, Sequence
6+
7+
8+
Edge = tuple[str, str]
9+
10+
11+
def find_order_violations(
12+
sequence: Sequence[str],
13+
required_before: Iterable[Edge],
14+
) -> tuple[Edge, ...]:
15+
"""Return lexicographically sorted order violations."""
16+
positions = {node: index for index, node in enumerate(sequence)}
17+
violations: set[Edge] = set()
18+
19+
for before, after in required_before:
20+
if before not in positions or after not in positions:
21+
continue
22+
if positions[before] > positions[after]:
23+
violations.add((before, after))
24+
25+
return tuple(sorted(violations))
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Deterministic reachability helpers for directed graphs."""
2+
3+
from __future__ import annotations
4+
5+
from collections import deque
6+
from collections.abc import Iterable
7+
8+
from .topology import adjacency_map
9+
10+
11+
Edge = tuple[str, str]
12+
13+
14+
def reachable_nodes(edges: Iterable[Edge], start: str) -> tuple[str, ...]:
15+
"""Return sorted reachable nodes from start.
16+
17+
The start node is excluded unless it is reachable through a cycle.
18+
"""
19+
adjacency = adjacency_map(edges)
20+
queue: deque[str] = deque(adjacency.get(start, ()))
21+
seen: set[str] = set()
22+
23+
while queue:
24+
node = queue.popleft()
25+
if node in seen:
26+
continue
27+
seen.add(node)
28+
for neighbor in adjacency.get(node, ()): # deterministic ordering from adjacency_map
29+
if neighbor not in seen:
30+
queue.append(neighbor)
31+
32+
if start in seen:
33+
return tuple(sorted(seen))
34+
return tuple(sorted(node for node in seen if node != start))
35+
36+
37+
def has_path(edges: Iterable[Edge], start: str, target: str) -> bool:
38+
"""Return True when a directed path exists from start to target."""
39+
return target in reachable_nodes(edges, start)

src/comptext_v7/graph/topology.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Deterministic topology helpers for replay relation graphs."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Iterable
6+
7+
8+
Edge = tuple[str, str]
9+
10+
11+
def normalize_edges(edges: Iterable[Edge]) -> tuple[Edge, ...]:
12+
"""Return sorted unique edges and reject self-loops."""
13+
unique_edges: set[Edge] = set()
14+
for source, target in edges:
15+
if source == target:
16+
raise ValueError(f"self-loop edge is not allowed: {source!r} -> {target!r}")
17+
unique_edges.add((source, target))
18+
return tuple(sorted(unique_edges))
19+
20+
21+
def nodes_from_edges(edges: Iterable[Edge]) -> tuple[str, ...]:
22+
"""Return sorted unique node ids derived from edges."""
23+
normalized = normalize_edges(edges)
24+
nodes = {source for source, _ in normalized}
25+
nodes.update(target for _, target in normalized)
26+
return tuple(sorted(nodes))
27+
28+
29+
def adjacency_map(edges: Iterable[Edge]) -> dict[str, tuple[str, ...]]:
30+
"""Return deterministic adjacency lists keyed by node id."""
31+
normalized = normalize_edges(edges)
32+
adjacency: dict[str, list[str]] = {}
33+
for source, target in normalized:
34+
adjacency.setdefault(source, []).append(target)
35+
adjacency.setdefault(target, [])
36+
37+
return {
38+
node: tuple(sorted(neighbors))
39+
for node, neighbors in sorted(adjacency.items())
40+
}

tests/test_replay_graph_core.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
5+
from src.comptext_v7.graph import (
6+
ReplayGraphDiff,
7+
adjacency_map,
8+
compare_edges,
9+
find_order_violations,
10+
has_path,
11+
nodes_from_edges,
12+
normalize_edges,
13+
reachable_nodes,
14+
)
15+
16+
17+
def test_normalize_edges_removes_duplicates_and_sorts() -> None:
18+
edges = [("b", "c"), ("a", "b"), ("b", "c")]
19+
assert normalize_edges(edges) == (("a", "b"), ("b", "c"))
20+
21+
22+
def test_normalize_edges_rejects_self_loop() -> None:
23+
with pytest.raises(ValueError):
24+
normalize_edges([("n1", "n1")])
25+
26+
27+
def test_nodes_from_edges_returns_sorted_nodes() -> None:
28+
edges = [("b", "c"), ("a", "b")]
29+
assert nodes_from_edges(edges) == ("a", "b", "c")
30+
31+
32+
def test_adjacency_map_is_deterministic() -> None:
33+
edges = [("b", "c"), ("a", "b"), ("a", "c")]
34+
assert adjacency_map(edges) == {
35+
"a": ("b", "c"),
36+
"b": ("c",),
37+
"c": (),
38+
}
39+
40+
41+
def test_find_order_violations_detects_reversed_and_sorts() -> None:
42+
sequence = ["c", "b", "a"]
43+
required = [("a", "b"), ("b", "c"), ("x", "a")]
44+
assert find_order_violations(sequence, required) == (("a", "b"), ("b", "c"))
45+
46+
47+
def test_find_order_violations_ignores_missing_nodes() -> None:
48+
sequence = ["a", "b"]
49+
required = [("x", "b"), ("a", "y")]
50+
assert find_order_violations(sequence, required) == ()
51+
52+
53+
def test_reachable_nodes_and_path_on_connected_graph() -> None:
54+
edges = [("a", "b"), ("b", "d"), ("a", "c")]
55+
assert reachable_nodes(edges, "a") == ("b", "c", "d")
56+
assert has_path(edges, "a", "d") is True
57+
assert has_path(edges, "c", "d") is False
58+
59+
60+
def test_reachable_nodes_handles_disconnected_graph() -> None:
61+
edges = [("a", "b"), ("x", "y")]
62+
assert reachable_nodes(edges, "a") == ("b",)
63+
assert reachable_nodes(edges, "z") == ()
64+
65+
66+
def test_reachable_nodes_includes_start_when_cycle_exists() -> None:
67+
edges = [("a", "b"), ("b", "a")]
68+
assert reachable_nodes(edges, "a") == ("a", "b")
69+
assert has_path(edges, "a", "a") is True
70+
71+
72+
def test_compare_edges_detects_edge_and_node_diffs_deterministically() -> None:
73+
original = [("a", "b"), ("b", "c"), ("d", "e")]
74+
replay = [("a", "b"), ("b", "d"), ("x", "y")]
75+
76+
diff = compare_edges(original, replay)
77+
78+
assert diff == ReplayGraphDiff(
79+
missing_edges=(("b", "c"), ("d", "e")),
80+
added_edges=(("b", "d"), ("x", "y")),
81+
missing_nodes=("c", "e"),
82+
added_nodes=("x", "y"),
83+
)
84+
assert isinstance(diff.missing_edges, tuple)
85+
assert isinstance(diff.added_nodes, tuple)

0 commit comments

Comments
 (0)