"""TDD — I2: Root distance invariant. root_distance = minimum hops from a node to any human node via any edge type. Humans are always 0. None means no path to any human exists. """ from __future__ import annotations import pytest from collections.abc import Mapping from musehub.graph.dag import EdgeType, GraphEdge, IdentityDAG, NodeType from musehub.graph.depth import RootDistanceIndex S = EdgeType.SPAWNS M = EdgeType.MEMBER_OF def build(nodes: Mapping[str, NodeType], *edges: tuple[str, str, EdgeType]) -> RootDistanceIndex: """Build an index from a node-type map and edge list.""" d = IdentityDAG.from_nodes_and_edges( nodes, [GraphEdge(from_handle=f, to_handle=t, edge_type=e) for f, t, e in edges], ) return RootDistanceIndex.build(d) # ── Humans ──────────────────────────────────────────────────────────────────── class TestHumanDepth: def test_lone_human_is_zero(self) -> None: idx = build({"alice": NodeType.HUMAN}) assert idx.distance("alice") == 0 def test_multiple_humans_all_zero(self) -> None: idx = build({"alice": NodeType.HUMAN, "bob": NodeType.HUMAN}) assert idx.distance("alice") == 0 assert idx.distance("bob") == 0 # ── Agents via SPAWNS ───────────────────────────────────────────────────────── class TestAgentSpawnDepth: def test_agent_spawned_by_human_is_one(self) -> None: idx = build( {"alice": NodeType.HUMAN, "bot-1": NodeType.AGENT}, ("alice", "bot-1", S), ) assert idx.distance("bot-1") == 1 def test_agent_spawned_by_agent_is_two(self) -> None: idx = build( {"alice": NodeType.HUMAN, "a1": NodeType.AGENT, "a2": NodeType.AGENT}, ("alice", "a1", S), ("a1", "a2", S), ) assert idx.distance("a2") == 2 def test_deep_spawn_chain(self) -> None: nodes = {"h": NodeType.HUMAN} | {f"a{i}": NodeType.AGENT for i in range(1, 6)} edges = [("h", "a1", S)] + [(f"a{i}", f"a{i+1}", S) for i in range(1, 5)] idx = build(nodes, *edges) for i in range(1, 6): assert idx.distance(f"a{i}") == i def test_agent_with_no_spawner_is_none(self) -> None: idx = build({"bot-1": NodeType.AGENT}) assert idx.distance("bot-1") is None def test_agent_chain_with_no_human_root_is_none(self) -> None: idx = build( {"a1": NodeType.AGENT, "a2": NodeType.AGENT}, ("a1", "a2", S), ) assert idx.distance("a1") is None assert idx.distance("a2") is None # ── Orgs via MEMBER_OF ──────────────────────────────────────────────────────── class TestOrgMemberDepth: def test_org_with_human_member_is_one(self) -> None: idx = build( {"alice": NodeType.HUMAN, "acme": NodeType.ORG}, ("alice", "acme", M), ) assert idx.distance("acme") == 1 def test_org_with_agent_member_depth_two(self) -> None: idx = build( {"alice": NodeType.HUMAN, "bot": NodeType.AGENT, "acme": NodeType.ORG}, ("alice", "bot", S), ("bot", "acme", M), ) assert idx.distance("acme") == 2 def test_nested_orgs(self) -> None: idx = build( {"alice": NodeType.HUMAN, "org-a": NodeType.ORG, "org-b": NodeType.ORG}, ("alice", "org-a", M), ("org-a", "org-b", M), ) assert idx.distance("org-a") == 1 assert idx.distance("org-b") == 2 def test_org_with_no_human_reachable_is_none(self) -> None: idx = build( {"bot": NodeType.AGENT, "acme": NodeType.ORG}, ("bot", "acme", M), ) assert idx.distance("acme") is None # ── Shortest path wins ──────────────────────────────────────────────────────── class TestShortestPath: def test_diamond_takes_shorter_path(self) -> None: # alice(0) → a1(1) → a3(?); alice(0) → org-x(1) → a3(?) # a3 reachable in 2 hops via either path — should be 2 idx = build( { "alice": NodeType.HUMAN, "a1": NodeType.AGENT, "a3": NodeType.AGENT, "org-x": NodeType.ORG, }, ("alice", "a1", S), ("alice", "org-x", M), ("a1", "a3", S), ("org-x", "a3", M), ) assert idx.distance("a3") == 2 def test_two_human_paths_takes_shorter(self) -> None: # alice(0) → a1(1) → target; bob(0) → target directly # target should be 1 (via bob), not 2 (via alice→a1) idx = build( { "alice": NodeType.HUMAN, "bob": NodeType.HUMAN, "a1": NodeType.AGENT, "target": NodeType.AGENT, }, ("alice", "a1", S), ("a1", "target", S), ("bob", "target", S), ) assert idx.distance("target") == 1 def test_deeply_nested_finds_shortest(self) -> None: # long path: h→a1→a2→a3→target (depth 4) # short path: h→target directly (depth 1) idx = build( { "h": NodeType.HUMAN, "a1": NodeType.AGENT, "a2": NodeType.AGENT, "a3": NodeType.AGENT, "target": NodeType.AGENT, }, ("h", "a1", S), ("a1", "a2", S), ("a2", "a3", S), ("a3", "target", S), ("h", "target", S), ) assert idx.distance("target") == 1 # ── Unknown handle ──────────────────────────────────────────────────────────── class TestUnknownHandle: def test_unknown_handle_raises(self) -> None: idx = build({"alice": NodeType.HUMAN}) with pytest.raises(KeyError): idx.distance("nobody") # ── human_ancestors ────────────────────────────────────────────────────────── class TestHumanAncestors: def test_human_is_own_ancestor(self) -> None: idx = build({"alice": NodeType.HUMAN}) assert idx.human_ancestors("alice") == {"alice"} def test_agent_inherits_spawners_ancestors(self) -> None: idx = build( {"alice": NodeType.HUMAN, "bob": NodeType.HUMAN, "bot": NodeType.AGENT}, ("alice", "bot", S), ) assert idx.human_ancestors("bot") == {"alice"} def test_org_inherits_all_reachable_humans(self) -> None: idx = build( { "alice": NodeType.HUMAN, "bob": NodeType.HUMAN, "acme": NodeType.ORG, }, ("alice", "acme", M), ("bob", "acme", M), ) assert idx.human_ancestors("acme") == {"alice", "bob"} def test_no_ancestors_returns_empty(self) -> None: idx = build({"bot": NodeType.AGENT}) assert idx.human_ancestors("bot") == set()