357 lines
11 KiB
Python
357 lines
11 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Phase 5.1 Pattern Matcher tests."""
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
import sys
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
sys.path.insert(0, str(Path(__file__).parent / "ontology_platform"))
|
||
|
|
|
||
|
|
from ont_platform.core.graph.pattern_matcher import PatternMatcher, PathResult, CycleResult
|
||
|
|
|
||
|
|
|
||
|
|
class MockAdapter:
|
||
|
|
"""Mock Neo4j adapter for testing."""
|
||
|
|
|
||
|
|
async def execute_cypher(self, cypher: str, params=None):
|
||
|
|
"""Mock Cypher execution."""
|
||
|
|
params = params or {}
|
||
|
|
|
||
|
|
# find_paths
|
||
|
|
if "shortestPath" in cypher or "RELATES*" in cypher and "start_id" in params:
|
||
|
|
return [
|
||
|
|
{
|
||
|
|
"result": {
|
||
|
|
"path": [params.get("start_id"), 2, 3, params.get("end_id")],
|
||
|
|
"length": 3,
|
||
|
|
"confidence": 0.87,
|
||
|
|
}
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"result": {
|
||
|
|
"path": [params.get("start_id"), 5, params.get("end_id")],
|
||
|
|
"length": 2,
|
||
|
|
"confidence": 0.90,
|
||
|
|
}
|
||
|
|
},
|
||
|
|
]
|
||
|
|
|
||
|
|
# find_cycles
|
||
|
|
if "start)-[" in cypher or ("RELATES*" in cypher and "end_id" not in params):
|
||
|
|
return [
|
||
|
|
{"result": {"cycle": [1, 2, 3, 1], "length": 3}},
|
||
|
|
{"result": {"cycle": [4, 5, 6, 4], "length": 3}},
|
||
|
|
]
|
||
|
|
|
||
|
|
# find_motifs - detect by type in params
|
||
|
|
motif_type = params.get("motif_type", "")
|
||
|
|
if motif_type == "triangle":
|
||
|
|
return [
|
||
|
|
{
|
||
|
|
"result": {
|
||
|
|
"motif_type": "triangle",
|
||
|
|
"nodes": [1, 2, 3],
|
||
|
|
"labels": ["Entity_1", "Entity_2", "Entity_3"],
|
||
|
|
}
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"result": {
|
||
|
|
"motif_type": "triangle",
|
||
|
|
"nodes": [4, 5, 6],
|
||
|
|
"labels": ["Entity_4", "Entity_5", "Entity_6"],
|
||
|
|
}
|
||
|
|
},
|
||
|
|
]
|
||
|
|
|
||
|
|
if motif_type == "chain":
|
||
|
|
return [
|
||
|
|
{
|
||
|
|
"result": {
|
||
|
|
"motif_type": "chain",
|
||
|
|
"nodes": [1, 2, 3, 4],
|
||
|
|
"labels": ["A", "B", "C", "D"],
|
||
|
|
"length": 4,
|
||
|
|
}
|
||
|
|
},
|
||
|
|
]
|
||
|
|
|
||
|
|
if motif_type == "star":
|
||
|
|
return [
|
||
|
|
{
|
||
|
|
"result": {
|
||
|
|
"motif_type": "star",
|
||
|
|
"hub": 1,
|
||
|
|
"spokes": [2, 3, 4],
|
||
|
|
"hub_label": "Central",
|
||
|
|
"spoke_labels": ["Spoke1", "Spoke2", "Spoke3"],
|
||
|
|
}
|
||
|
|
},
|
||
|
|
]
|
||
|
|
|
||
|
|
# analyze_entity_connectivity
|
||
|
|
if "in_degree" in cypher:
|
||
|
|
return [
|
||
|
|
{
|
||
|
|
"metrics": {
|
||
|
|
"entity_id": params.get("entity_id"),
|
||
|
|
"entity_label": f"Entity_{params.get('entity_id')}",
|
||
|
|
"in_degree": 3,
|
||
|
|
"out_degree": 4,
|
||
|
|
"total_degree": 7,
|
||
|
|
"reachable_entities": 12,
|
||
|
|
"reachability_ratio": 0.857,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
]
|
||
|
|
|
||
|
|
return []
|
||
|
|
|
||
|
|
|
||
|
|
async def test_find_paths():
|
||
|
|
"""Test path finding between entities."""
|
||
|
|
print("\n[TEST 1] Find Paths")
|
||
|
|
|
||
|
|
adapter = MockAdapter()
|
||
|
|
matcher = PatternMatcher(adapter)
|
||
|
|
|
||
|
|
paths = await matcher.find_paths(start_entity_id=1, end_entity_id=4, max_length=5)
|
||
|
|
|
||
|
|
assert len(paths) > 0, "Should find at least one path"
|
||
|
|
assert all("path" in p and "length" in p for p in paths), "All paths should have required fields"
|
||
|
|
|
||
|
|
shortest = min(paths, key=lambda p: p["length"])
|
||
|
|
assert shortest["length"] >= 2, "Path length should be >= 2"
|
||
|
|
|
||
|
|
print(f" [OK] Found {len(paths)} paths from 1 to 4")
|
||
|
|
print(f" [OK] Shortest path: {shortest['path']} (length {shortest['length']})")
|
||
|
|
print(f" [OK] Average confidence: {sum(p['confidence'] for p in paths) / len(paths):.3f}")
|
||
|
|
print(" [PASS]")
|
||
|
|
|
||
|
|
|
||
|
|
async def test_same_entity_path():
|
||
|
|
"""Test path from entity to itself."""
|
||
|
|
print("\n[TEST 2] Same Entity Path")
|
||
|
|
|
||
|
|
adapter = MockAdapter()
|
||
|
|
matcher = PatternMatcher(adapter)
|
||
|
|
|
||
|
|
paths = await matcher.find_paths(start_entity_id=1, end_entity_id=1, max_length=5)
|
||
|
|
|
||
|
|
assert len(paths) == 1, "Should return single path to itself"
|
||
|
|
assert paths[0]["path"] == [1], "Path should be entity itself"
|
||
|
|
assert paths[0]["length"] == 0, "Length to itself should be 0"
|
||
|
|
assert paths[0]["confidence"] == 1.0, "Confidence should be 1.0"
|
||
|
|
|
||
|
|
print(" [OK] Path to self: [1], length 0, confidence 1.0")
|
||
|
|
print(" [PASS]")
|
||
|
|
|
||
|
|
|
||
|
|
async def test_find_cycles():
|
||
|
|
"""Test cycle detection."""
|
||
|
|
print("\n[TEST 3] Find Cycles")
|
||
|
|
|
||
|
|
adapter = MockAdapter()
|
||
|
|
matcher = PatternMatcher(adapter)
|
||
|
|
|
||
|
|
cycles = await matcher.find_cycles(min_length=2, max_length=5)
|
||
|
|
|
||
|
|
assert len(cycles) > 0, "Should find cycles"
|
||
|
|
assert all(
|
||
|
|
"cycle" in c and "length" in c and c["length"] > 1 for c in cycles
|
||
|
|
), "All cycles should have required fields"
|
||
|
|
|
||
|
|
print(f" [OK] Found {len(cycles)} cycles")
|
||
|
|
for i, cycle in enumerate(cycles, 1):
|
||
|
|
print(f" Cycle {i}: {cycle['cycle']} (length {cycle['length']})")
|
||
|
|
print(" [PASS]")
|
||
|
|
|
||
|
|
|
||
|
|
async def test_find_motifs_triangle():
|
||
|
|
"""Test triangle motif detection."""
|
||
|
|
print("\n[TEST 4] Find Motifs (Triangle)")
|
||
|
|
|
||
|
|
adapter = MockAdapter()
|
||
|
|
matcher = PatternMatcher(adapter)
|
||
|
|
|
||
|
|
motifs = await matcher.find_motifs(motif_type="triangle", limit=100)
|
||
|
|
|
||
|
|
assert len(motifs) > 0, "Should find triangle motifs"
|
||
|
|
assert all(m.get("motif_type") == "triangle" for m in motifs), "All should be triangles"
|
||
|
|
|
||
|
|
print(f" [OK] Found {len(motifs)} triangle motifs")
|
||
|
|
for motif in motifs:
|
||
|
|
print(f" Triangle: {motif['nodes']}")
|
||
|
|
print(" [PASS]")
|
||
|
|
|
||
|
|
|
||
|
|
async def test_find_motifs_chain():
|
||
|
|
"""Test chain motif detection."""
|
||
|
|
print("\n[TEST 5] Find Motifs (Chain)")
|
||
|
|
|
||
|
|
adapter = MockAdapter()
|
||
|
|
matcher = PatternMatcher(adapter)
|
||
|
|
|
||
|
|
motifs = await matcher.find_motifs(motif_type="chain", limit=100)
|
||
|
|
|
||
|
|
assert len(motifs) >= 0, "Should return chain motifs (may be empty)"
|
||
|
|
if motifs:
|
||
|
|
assert all(m.get("motif_type") == "chain" for m in motifs), "All should be chains"
|
||
|
|
print(f" [OK] Found {len(motifs)} chain motifs")
|
||
|
|
|
||
|
|
print(" [PASS]")
|
||
|
|
|
||
|
|
|
||
|
|
async def test_find_motifs_star():
|
||
|
|
"""Test star motif detection."""
|
||
|
|
print("\n[TEST 6] Find Motifs (Star)")
|
||
|
|
|
||
|
|
adapter = MockAdapter()
|
||
|
|
matcher = PatternMatcher(adapter)
|
||
|
|
|
||
|
|
motifs = await matcher.find_motifs(motif_type="star", limit=100)
|
||
|
|
|
||
|
|
assert len(motifs) >= 0, "Should return star motifs (may be empty)"
|
||
|
|
if motifs:
|
||
|
|
assert all(m.get("motif_type") == "star" for m in motifs), "All should be stars"
|
||
|
|
assert all("hub" in m for m in motifs), "Stars should have hub"
|
||
|
|
assert all("spokes" in m for m in motifs), "Stars should have spokes"
|
||
|
|
print(f" [OK] Found {len(motifs)} star motifs")
|
||
|
|
for motif in motifs:
|
||
|
|
print(f" Hub: {motif['hub']}, Spokes: {motif['spokes']}")
|
||
|
|
|
||
|
|
print(" [PASS]")
|
||
|
|
|
||
|
|
|
||
|
|
async def test_find_motifs_invalid():
|
||
|
|
"""Test invalid motif type."""
|
||
|
|
print("\n[TEST 7] Invalid Motif Type")
|
||
|
|
|
||
|
|
adapter = MockAdapter()
|
||
|
|
matcher = PatternMatcher(adapter)
|
||
|
|
|
||
|
|
result = await matcher.find_motifs(motif_type="invalid", limit=100)
|
||
|
|
|
||
|
|
assert isinstance(result, dict) and "error" in result, "Should return error for invalid motif"
|
||
|
|
print(f" [OK] Correctly rejects invalid motif: {result['error']}")
|
||
|
|
print(" [PASS]")
|
||
|
|
|
||
|
|
|
||
|
|
async def test_analyze_entity_connectivity():
|
||
|
|
"""Test entity connectivity analysis."""
|
||
|
|
print("\n[TEST 8] Entity Connectivity Analysis")
|
||
|
|
|
||
|
|
adapter = MockAdapter()
|
||
|
|
matcher = PatternMatcher(adapter)
|
||
|
|
|
||
|
|
metrics = await matcher.analyze_entity_connectivity(entity_id=1)
|
||
|
|
|
||
|
|
assert "in_degree" in metrics, "Should have in_degree"
|
||
|
|
assert "out_degree" in metrics, "Should have out_degree"
|
||
|
|
assert "total_degree" in metrics, "Should have total_degree"
|
||
|
|
assert "reachable_entities" in metrics, "Should have reachable_entities"
|
||
|
|
|
||
|
|
assert metrics["total_degree"] == metrics["in_degree"] + metrics["out_degree"]
|
||
|
|
print(f" [OK] Entity 1 metrics:")
|
||
|
|
print(f" In-degree: {metrics['in_degree']}")
|
||
|
|
print(f" Out-degree: {metrics['out_degree']}")
|
||
|
|
print(f" Total degree: {metrics['total_degree']}")
|
||
|
|
print(f" Reachable entities: {metrics['reachable_entities']}")
|
||
|
|
print(f" Reachability ratio: {metrics['reachability_ratio']:.3f}")
|
||
|
|
print(" [PASS]")
|
||
|
|
|
||
|
|
|
||
|
|
async def test_path_validation():
|
||
|
|
"""Test path length validation."""
|
||
|
|
print("\n[TEST 9] Path Length Validation")
|
||
|
|
|
||
|
|
adapter = MockAdapter()
|
||
|
|
matcher = PatternMatcher(adapter)
|
||
|
|
|
||
|
|
try:
|
||
|
|
await matcher.find_paths(start_entity_id=1, end_entity_id=2, max_length=0)
|
||
|
|
assert False, "Should reject max_length < 2"
|
||
|
|
except ValueError as e:
|
||
|
|
assert "max_length must be between 2 and 6" in str(e)
|
||
|
|
print(" [OK] Correctly rejects max_length < 2")
|
||
|
|
|
||
|
|
try:
|
||
|
|
await matcher.find_paths(start_entity_id=1, end_entity_id=2, max_length=7)
|
||
|
|
assert False, "Should reject max_length > 6"
|
||
|
|
except ValueError as e:
|
||
|
|
assert "max_length must be between 2 and 6" in str(e)
|
||
|
|
print(" [OK] Correctly rejects max_length > 6")
|
||
|
|
|
||
|
|
print(" [PASS]")
|
||
|
|
|
||
|
|
|
||
|
|
async def test_cycle_validation():
|
||
|
|
"""Test cycle detection validation."""
|
||
|
|
print("\n[TEST 10] Cycle Detection Validation")
|
||
|
|
|
||
|
|
adapter = MockAdapter()
|
||
|
|
matcher = PatternMatcher(adapter)
|
||
|
|
|
||
|
|
try:
|
||
|
|
await matcher.find_cycles(min_length=1)
|
||
|
|
assert False, "Should reject min_length < 2"
|
||
|
|
except ValueError as e:
|
||
|
|
assert "min_length must be >= 2" in str(e)
|
||
|
|
print(" [OK] Correctly rejects min_length < 2")
|
||
|
|
|
||
|
|
try:
|
||
|
|
await matcher.find_cycles(min_length=5, max_length=3)
|
||
|
|
assert False, "Should reject max_length < min_length"
|
||
|
|
except ValueError as e:
|
||
|
|
assert "max_length must be between min_length and 6" in str(e)
|
||
|
|
print(" [OK] Correctly rejects max_length < min_length")
|
||
|
|
|
||
|
|
print(" [PASS]")
|
||
|
|
|
||
|
|
|
||
|
|
async def main():
|
||
|
|
"""Run all tests."""
|
||
|
|
print("=" * 70)
|
||
|
|
print("Phase 5.1 Pattern Matcher Tests")
|
||
|
|
print("=" * 70)
|
||
|
|
|
||
|
|
try:
|
||
|
|
await test_find_paths()
|
||
|
|
await test_same_entity_path()
|
||
|
|
await test_find_cycles()
|
||
|
|
await test_find_motifs_triangle()
|
||
|
|
await test_find_motifs_chain()
|
||
|
|
await test_find_motifs_star()
|
||
|
|
await test_find_motifs_invalid()
|
||
|
|
await test_analyze_entity_connectivity()
|
||
|
|
await test_path_validation()
|
||
|
|
await test_cycle_validation()
|
||
|
|
|
||
|
|
print("\n" + "=" * 70)
|
||
|
|
print("All tests passed!")
|
||
|
|
print("=" * 70)
|
||
|
|
print("\nPhase 5.1 Pattern Matcher capabilities:")
|
||
|
|
print(" [OK] Path finding between entities")
|
||
|
|
print(" [OK] Cycle detection")
|
||
|
|
print(" [OK] Motif detection (triangle, chain, star)")
|
||
|
|
print(" [OK] Entity connectivity analysis")
|
||
|
|
print(" [OK] Input validation")
|
||
|
|
|
||
|
|
return True
|
||
|
|
except AssertionError as e:
|
||
|
|
print(f"\nTest failed: {e}")
|
||
|
|
import traceback
|
||
|
|
|
||
|
|
traceback.print_exc()
|
||
|
|
return False
|
||
|
|
except Exception as e:
|
||
|
|
print(f"\nUnexpected error: {e}")
|
||
|
|
import traceback
|
||
|
|
|
||
|
|
traceback.print_exc()
|
||
|
|
return False
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
success = asyncio.run(main())
|
||
|
|
sys.exit(0 if success else 1)
|