mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-26 16:26:42 +00:00
v2: hypergraph support - hyperedges in graph.json, shaded regions in HTML, report section
This commit is contained in:
@@ -25,6 +25,9 @@ def build_from_json(extraction: dict) -> nx.Graph:
|
||||
attrs["_src"] = src
|
||||
attrs["_tgt"] = tgt
|
||||
G.add_edge(src, tgt, **attrs)
|
||||
hyperedges = extraction.get("hyperedges", [])
|
||||
if hyperedges:
|
||||
G.graph["hyperedges"] = hyperedges
|
||||
return G
|
||||
|
||||
|
||||
|
||||
@@ -55,6 +55,58 @@ def _html_styles() -> str:
|
||||
</style>"""
|
||||
|
||||
|
||||
def _hyperedge_script(hyperedges_json: str) -> str:
|
||||
return f"""<script>
|
||||
// Render hyperedges as shaded regions
|
||||
const hyperedges = {hyperedges_json};
|
||||
function drawHyperedges() {{
|
||||
const canvas = network.canvas.frame.canvas;
|
||||
const ctx = canvas.getContext('2d');
|
||||
hyperedges.forEach(h => {{
|
||||
const positions = h.nodes
|
||||
.map(nid => network.getPositions([nid])[nid])
|
||||
.filter(p => p !== undefined);
|
||||
if (positions.length < 2) return;
|
||||
// Draw convex hull as filled polygon
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.12;
|
||||
ctx.fillStyle = '#6366f1';
|
||||
ctx.strokeStyle = '#6366f1';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
const scale = network.getScale();
|
||||
const offset = network.getViewPosition();
|
||||
const toCanvas = (p) => ({{
|
||||
x: (p.x - offset.x) * scale + canvas.width / 2,
|
||||
y: (p.y - offset.y) * scale + canvas.height / 2
|
||||
}});
|
||||
const pts = positions.map(toCanvas);
|
||||
// Expand hull slightly
|
||||
const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length;
|
||||
const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length;
|
||||
const expanded = pts.map(p => ({{
|
||||
x: cx + (p.x - cx) * 1.15,
|
||||
y: cy + (p.y - cy) * 1.15
|
||||
}}));
|
||||
ctx.moveTo(expanded[0].x, expanded[0].y);
|
||||
expanded.slice(1).forEach(p => ctx.lineTo(p.x, p.y));
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 0.4;
|
||||
ctx.stroke();
|
||||
// Label
|
||||
ctx.globalAlpha = 0.8;
|
||||
ctx.fillStyle = '#4f46e5';
|
||||
ctx.font = 'bold 11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(h.label, cx, cy - 5);
|
||||
ctx.restore();
|
||||
}});
|
||||
}}
|
||||
network.on('afterDrawing', drawHyperedges);
|
||||
</script>"""
|
||||
|
||||
|
||||
def _html_script(nodes_json: str, edges_json: str, legend_json: str) -> str:
|
||||
return f"""<script>
|
||||
const RAW_NODES = {nodes_json};
|
||||
@@ -198,6 +250,17 @@ LEGEND.forEach(c => {{
|
||||
_CONFIDENCE_SCORE_DEFAULTS = {"EXTRACTED": 1.0, "INFERRED": 0.5, "AMBIGUOUS": 0.2}
|
||||
|
||||
|
||||
def attach_hyperedges(G: nx.Graph, hyperedges: list) -> None:
|
||||
"""Store hyperedges in the graph's metadata dict."""
|
||||
existing = G.graph.get("hyperedges", [])
|
||||
seen_ids = {h["id"] for h in existing}
|
||||
for h in hyperedges:
|
||||
if h.get("id") and h["id"] not in seen_ids:
|
||||
existing.append(h)
|
||||
seen_ids.add(h["id"])
|
||||
G.graph["hyperedges"] = existing
|
||||
|
||||
|
||||
def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str) -> None:
|
||||
node_community = _node_community_map(communities)
|
||||
data = json_graph.node_link_data(G, edges="links")
|
||||
@@ -207,6 +270,7 @@ def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str) ->
|
||||
if "confidence_score" not in link:
|
||||
conf = link.get("confidence", "EXTRACTED")
|
||||
link["confidence_score"] = _CONFIDENCE_SCORE_DEFAULTS.get(conf, 1.0)
|
||||
data["hyperedges"] = getattr(G, "graph", {}).get("hyperedges", [])
|
||||
with open(output_path, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
@@ -302,6 +366,7 @@ def to_html(
|
||||
nodes_json = json.dumps(vis_nodes)
|
||||
edges_json = json.dumps(vis_edges)
|
||||
legend_json = json.dumps(legend_data)
|
||||
hyperedges_json = json.dumps(getattr(G, "graph", {}).get("hyperedges", []))
|
||||
title = sanitize_label(str(output_path))
|
||||
stats = f"{G.number_of_nodes()} nodes · {G.number_of_edges()} edges · {len(communities)} communities"
|
||||
|
||||
@@ -331,6 +396,7 @@ def to_html(
|
||||
<div id="stats">{stats}</div>
|
||||
</div>
|
||||
{_html_script(nodes_json, edges_json, legend_json)}
|
||||
{_hyperedge_script(hyperedges_json)}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
@@ -74,6 +74,16 @@ def generate(
|
||||
else:
|
||||
lines.append("- None detected - all connections are within the same source files.")
|
||||
|
||||
hyperedges = G.graph.get("hyperedges", [])
|
||||
if hyperedges:
|
||||
lines += ["", "## Hyperedges (group relationships)"]
|
||||
for h in hyperedges:
|
||||
node_labels = ", ".join(h.get("nodes", []))
|
||||
conf = h.get("confidence", "INFERRED")
|
||||
cscore = h.get("confidence_score")
|
||||
conf_tag = f"{conf} {cscore:.2f}" if cscore is not None else conf
|
||||
lines.append(f"- **{h.get('label', h.get('id', ''))}** — {node_labels} [{conf_tag}]")
|
||||
|
||||
lines += ["", "## Communities"]
|
||||
from .analyze import _is_file_node as _ifn
|
||||
for cid, nodes in communities.items():
|
||||
|
||||
+7
-1
@@ -213,6 +213,12 @@ Semantic similarity: if two concepts in this chunk solve the same problem or rep
|
||||
- Two error types that handle the same failure mode differently
|
||||
Only add these when the similarity is genuinely non-obvious and cross-cutting. Do not add them for trivially similar things.
|
||||
|
||||
Hyperedges: if 3 or more nodes clearly participate together in a shared concept, flow, or pattern that is not captured by pairwise edges alone, add a hyperedge to a top-level `hyperedges` array. Examples:
|
||||
- All classes that implement a common protocol or interface
|
||||
- All functions in an authentication flow (even if they don't all call each other)
|
||||
- All concepts from a paper section that form one coherent idea
|
||||
Use sparingly — only when the group relationship adds information beyond the pairwise edges. Maximum 3 hyperedges per chunk.
|
||||
|
||||
If a file has YAML frontmatter (--- ... ---), copy source_url, captured_at, author,
|
||||
contributor onto every node from that file.
|
||||
|
||||
@@ -224,7 +230,7 @@ confidence_score rules:
|
||||
- AMBIGUOUS edges: score 0.1-0.3
|
||||
|
||||
Output exactly this JSON (no other text):
|
||||
{"nodes":[{"id":"filestem_entityname","label":"Human Readable Name","file_type":"code|document|paper|image","source_file":"relative/path","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"relative/path","source_location":null,"weight":1.0}],"input_tokens":0,"output_tokens":0}
|
||||
{"nodes":[{"id":"filestem_entityname","label":"Human Readable Name","file_type":"code|document|paper|image","source_file":"relative/path","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"relative/path","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":"relative/path"}],"input_tokens":0,"output_tokens":0}
|
||||
```
|
||||
|
||||
**Step B3 - Collect, cache, and merge**
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "graphifyy"
|
||||
version = "0.1.13"
|
||||
version = "0.1.14"
|
||||
description = "Claude Code skill - turn any folder of code, docs, papers, images, or tweets into a queryable knowledge graph"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
|
||||
@@ -213,6 +213,12 @@ Semantic similarity: if two concepts in this chunk solve the same problem or rep
|
||||
- Two error types that handle the same failure mode differently
|
||||
Only add these when the similarity is genuinely non-obvious and cross-cutting. Do not add them for trivially similar things.
|
||||
|
||||
Hyperedges: if 3 or more nodes clearly participate together in a shared concept, flow, or pattern that is not captured by pairwise edges alone, add a hyperedge to a top-level `hyperedges` array. Examples:
|
||||
- All classes that implement a common protocol or interface
|
||||
- All functions in an authentication flow (even if they don't all call each other)
|
||||
- All concepts from a paper section that form one coherent idea
|
||||
Use sparingly — only when the group relationship adds information beyond the pairwise edges. Maximum 3 hyperedges per chunk.
|
||||
|
||||
If a file has YAML frontmatter (--- ... ---), copy source_url, captured_at, author,
|
||||
contributor onto every node from that file.
|
||||
|
||||
@@ -224,7 +230,7 @@ confidence_score rules:
|
||||
- AMBIGUOUS edges: score 0.1-0.3
|
||||
|
||||
Output exactly this JSON (no other text):
|
||||
{"nodes":[{"id":"filestem_entityname","label":"Human Readable Name","file_type":"code|document|paper|image","source_file":"relative/path","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"relative/path","source_location":null,"weight":1.0}],"input_tokens":0,"output_tokens":0}
|
||||
{"nodes":[{"id":"filestem_entityname","label":"Human Readable Name","file_type":"code|document|paper|image","source_file":"relative/path","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"relative/path","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":"relative/path"}],"input_tokens":0,"output_tokens":0}
|
||||
```
|
||||
|
||||
**Step B3 - Collect, cache, and merge**
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Tests for hyperedge support in graphify."""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import networkx as nx
|
||||
import pytest
|
||||
|
||||
from graphify.build import build_from_json
|
||||
from graphify.export import attach_hyperedges, to_json
|
||||
from graphify.report import generate
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SAMPLE_EXTRACTION = {
|
||||
"nodes": [
|
||||
{"id": "BasicAuth", "label": "BasicAuth", "file_type": "code", "source_file": "auth.py"},
|
||||
{"id": "DigestAuth", "label": "DigestAuth", "file_type": "code", "source_file": "auth.py"},
|
||||
{"id": "Request", "label": "Request", "file_type": "code", "source_file": "http.py"},
|
||||
{"id": "Response", "label": "Response", "file_type": "code", "source_file": "http.py"},
|
||||
{"id": "BaseClient", "label": "BaseClient", "file_type": "code", "source_file": "client.py"},
|
||||
],
|
||||
"edges": [
|
||||
{"source": "BasicAuth", "target": "Request", "relation": "uses", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "auth.py"},
|
||||
],
|
||||
"hyperedges": [
|
||||
{
|
||||
"id": "auth_flow",
|
||||
"label": "Auth Flow",
|
||||
"nodes": ["BasicAuth", "DigestAuth", "Request", "Response", "BaseClient"],
|
||||
"relation": "participate_in",
|
||||
"confidence": "INFERRED",
|
||||
"confidence_score": 0.75,
|
||||
"source_file": "auth.py",
|
||||
}
|
||||
],
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 5,
|
||||
}
|
||||
|
||||
SAMPLE_DETECTION = {
|
||||
"total_files": 3,
|
||||
"total_words": 500,
|
||||
"files": {"code": ["auth.py", "http.py", "client.py"]},
|
||||
"skipped_sensitive": [],
|
||||
"warning": None,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Hyperedges survive build_from_json round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_build_from_json_stores_hyperedges():
|
||||
G = build_from_json(SAMPLE_EXTRACTION)
|
||||
assert "hyperedges" in G.graph
|
||||
assert len(G.graph["hyperedges"]) == 1
|
||||
assert G.graph["hyperedges"][0]["id"] == "auth_flow"
|
||||
|
||||
|
||||
def test_build_from_json_no_hyperedges():
|
||||
extraction = {**SAMPLE_EXTRACTION, "hyperedges": []}
|
||||
G = build_from_json(extraction)
|
||||
assert G.graph.get("hyperedges", []) == []
|
||||
|
||||
|
||||
def test_build_from_json_missing_hyperedges_key():
|
||||
extraction = {k: v for k, v in SAMPLE_EXTRACTION.items() if k != "hyperedges"}
|
||||
G = build_from_json(extraction)
|
||||
assert G.graph.get("hyperedges", []) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. attach_hyperedges deduplicates by id
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_attach_hyperedges_adds_new():
|
||||
G = nx.Graph()
|
||||
attach_hyperedges(G, [{"id": "auth_flow", "label": "Auth Flow", "nodes": ["A", "B", "C"]}])
|
||||
assert len(G.graph["hyperedges"]) == 1
|
||||
|
||||
|
||||
def test_attach_hyperedges_deduplicates():
|
||||
G = nx.Graph()
|
||||
h = {"id": "auth_flow", "label": "Auth Flow", "nodes": ["A", "B", "C"]}
|
||||
attach_hyperedges(G, [h])
|
||||
attach_hyperedges(G, [h]) # second call with same id should not duplicate
|
||||
assert len(G.graph["hyperedges"]) == 1
|
||||
|
||||
|
||||
def test_attach_hyperedges_multiple_different_ids():
|
||||
G = nx.Graph()
|
||||
attach_hyperedges(G, [
|
||||
{"id": "flow_a", "label": "Flow A", "nodes": ["A", "B", "C"]},
|
||||
{"id": "flow_b", "label": "Flow B", "nodes": ["D", "E", "F"]},
|
||||
])
|
||||
assert len(G.graph["hyperedges"]) == 2
|
||||
|
||||
|
||||
def test_attach_hyperedges_skips_entry_without_id():
|
||||
G = nx.Graph()
|
||||
attach_hyperedges(G, [{"label": "No ID", "nodes": ["A", "B", "C"]}])
|
||||
assert G.graph.get("hyperedges", []) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. to_json includes hyperedges key
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_to_json_includes_hyperedges():
|
||||
G = build_from_json(SAMPLE_EXTRACTION)
|
||||
communities = {0: list(G.nodes())}
|
||||
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
||||
path = f.name
|
||||
to_json(G, communities, path)
|
||||
data = json.loads(Path(path).read_text())
|
||||
assert "hyperedges" in data
|
||||
assert len(data["hyperedges"]) == 1
|
||||
assert data["hyperedges"][0]["id"] == "auth_flow"
|
||||
|
||||
|
||||
def test_to_json_hyperedges_empty_when_none():
|
||||
extraction = {**SAMPLE_EXTRACTION, "hyperedges": []}
|
||||
G = build_from_json(extraction)
|
||||
communities = {0: list(G.nodes())}
|
||||
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
||||
path = f.name
|
||||
to_json(G, communities, path)
|
||||
data = json.loads(Path(path).read_text())
|
||||
assert "hyperedges" in data
|
||||
assert data["hyperedges"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Hyperedges loaded from graph.json via build_from_json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_hyperedges_roundtrip_via_json_file():
|
||||
"""Write graph.json then reload it - hyperedges must survive."""
|
||||
G = build_from_json(SAMPLE_EXTRACTION)
|
||||
communities = {0: list(G.nodes())}
|
||||
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
||||
path = f.name
|
||||
to_json(G, communities, path)
|
||||
|
||||
# Reload the JSON as if build_from_json were called on it
|
||||
data = json.loads(Path(path).read_text())
|
||||
G2 = build_from_json({
|
||||
"nodes": [{"id": n["id"], **{k: v for k, v in n.items() if k != "id"}} for n in data["nodes"]],
|
||||
"edges": [{"source": e["source"], "target": e["target"], **{k: v for k, v in e.items() if k not in ("source", "target")}} for e in data.get("links", [])],
|
||||
"hyperedges": data.get("hyperedges", []),
|
||||
})
|
||||
assert G2.graph.get("hyperedges", []) != []
|
||||
assert G2.graph["hyperedges"][0]["id"] == "auth_flow"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Report includes hyperedges section when hyperedges present
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_report(G):
|
||||
communities = {0: list(G.nodes())}
|
||||
cohesion = {0: 1.0}
|
||||
labels = {0: "All"}
|
||||
gods = [{"label": "BasicAuth", "edges": 2}]
|
||||
surprises = []
|
||||
return generate(G, communities, cohesion, labels, gods, surprises, SAMPLE_DETECTION, {"input": 10, "output": 5}, ".")
|
||||
|
||||
|
||||
def test_report_includes_hyperedges_section():
|
||||
G = build_from_json(SAMPLE_EXTRACTION)
|
||||
report = _make_report(G)
|
||||
assert "## Hyperedges (group relationships)" in report
|
||||
assert "Auth Flow" in report
|
||||
assert "INFERRED 0.75" in report
|
||||
|
||||
|
||||
def test_report_includes_hyperedge_node_list():
|
||||
G = build_from_json(SAMPLE_EXTRACTION)
|
||||
report = _make_report(G)
|
||||
# Node IDs should appear in the report line
|
||||
assert "BasicAuth" in report
|
||||
assert "DigestAuth" in report
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Report skips hyperedges section when none present
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_report_skips_hyperedges_section_when_empty():
|
||||
extraction = {**SAMPLE_EXTRACTION, "hyperedges": []}
|
||||
G = build_from_json(extraction)
|
||||
report = _make_report(G)
|
||||
assert "## Hyperedges" not in report
|
||||
|
||||
|
||||
def test_report_skips_hyperedges_section_when_key_missing():
|
||||
extraction = {k: v for k, v in SAMPLE_EXTRACTION.items() if k != "hyperedges"}
|
||||
G = build_from_json(extraction)
|
||||
report = _make_report(G)
|
||||
assert "## Hyperedges" not in report
|
||||
Reference in New Issue
Block a user