diff --git a/graphify/analyze.py b/graphify/analyze.py index cf534496..6ba2e9d0 100644 --- a/graphify/analyze.py +++ b/graphify/analyze.py @@ -166,6 +166,11 @@ def _surprise_score( score += 1 reasons.append("bridges separate communities") + # 4b. Semantic similarity bonus - non-obvious conceptual links score higher + if data.get("relation") == "semantically_similar_to": + score = int(score * 1.5) + reasons.append("semantically similar concepts with no structural link") + # 5. Peripheral→hub: a low-degree node connecting to a high-degree one deg_u = G.degree(u) deg_v = G.degree(v) diff --git a/graphify/report.py b/graphify/report.py index 3cfe4ad1..06a3fa9d 100644 --- a/graphify/report.py +++ b/graphify/report.py @@ -66,8 +66,9 @@ def generate( conf_tag = f"INFERRED {cscore:.2f}" else: conf_tag = conf + sem_tag = " [semantically similar]" if relation == "semantically_similar_to" else "" lines += [ - f"- `{s['source']}` --{relation}--> `{s['target']}` [{conf_tag}]", + f"- `{s['source']}` --{relation}--> `{s['target']}` [{conf_tag}]{sem_tag}", f" {files[0]} → {files[1]}" + (f" _{note}_" if note else ""), ] else: diff --git a/graphify/skill.md b/graphify/skill.md index 5774d07b..49dbc62a 100644 --- a/graphify/skill.md +++ b/graphify/skill.md @@ -207,6 +207,12 @@ Image files: use vision to understand what the image IS - do not just OCR. DEEP_MODE (if --mode deep was given): be aggressive with INFERRED edges - indirect deps, shared assumptions, latent couplings. Mark uncertain ones AMBIGUOUS instead of omitting. +Semantic similarity: if two concepts in this chunk solve the same problem or represent the same idea without any structural link (no import, no call, no citation), add a `semantically_similar_to` edge marked INFERRED with a confidence_score reflecting how similar they are (0.6-0.95). Examples: +- Two functions that both validate user input but never call each other +- A class in code and a concept in a paper that describe the same algorithm +- 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. + If a file has YAML frontmatter (--- ... ---), copy source_url, captured_at, author, contributor onto every node from that file. @@ -218,7 +224,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","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}],"input_tokens":0,"output_tokens":0} ``` **Step B3 - Collect, cache, and merge** diff --git a/pyproject.toml b/pyproject.toml index 2bf16951..92bab4a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphifyy" -version = "0.1.12" +version = "0.1.13" 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" } diff --git a/skills/graphify/skill.md b/skills/graphify/skill.md index 5774d07b..49dbc62a 100644 --- a/skills/graphify/skill.md +++ b/skills/graphify/skill.md @@ -207,6 +207,12 @@ Image files: use vision to understand what the image IS - do not just OCR. DEEP_MODE (if --mode deep was given): be aggressive with INFERRED edges - indirect deps, shared assumptions, latent couplings. Mark uncertain ones AMBIGUOUS instead of omitting. +Semantic similarity: if two concepts in this chunk solve the same problem or represent the same idea without any structural link (no import, no call, no citation), add a `semantically_similar_to` edge marked INFERRED with a confidence_score reflecting how similar they are (0.6-0.95). Examples: +- Two functions that both validate user input but never call each other +- A class in code and a concept in a paper that describe the same algorithm +- 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. + If a file has YAML frontmatter (--- ... ---), copy source_url, captured_at, author, contributor onto every node from that file. @@ -218,7 +224,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","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}],"input_tokens":0,"output_tokens":0} ``` **Step B3 - Collect, cache, and merge** diff --git a/tests/test_semantic_similarity.py b/tests/test_semantic_similarity.py new file mode 100644 index 00000000..55d9cce3 --- /dev/null +++ b/tests/test_semantic_similarity.py @@ -0,0 +1,194 @@ +"""Tests for semantically_similar_to edge support.""" +import networkx as nx +import pytest +from graphify.build import build_from_json +from graphify.analyze import surprising_connections, _surprise_score +from graphify.report import generate + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_extraction_with_semantic_edge(): + """Two nodes in separate files connected by a semantically_similar_to edge.""" + return { + "nodes": [ + {"id": "a_validate_input", "label": "validate_input", "file_type": "code", + "source_file": "auth/validators.py", "source_location": "L5"}, + {"id": "b_check_input", "label": "check_input", "file_type": "code", + "source_file": "api/checks.py", "source_location": "L12"}, + ], + "edges": [ + { + "source": "a_validate_input", + "target": "b_check_input", + "relation": "semantically_similar_to", + "confidence": "INFERRED", + "confidence_score": 0.82, + "source_file": "auth/validators.py", + "source_location": None, + "weight": 0.82, + } + ], + "input_tokens": 100, + "output_tokens": 50, + } + + +def _make_graph_with_semantic_edge(): + return build_from_json(_make_extraction_with_semantic_edge()) + + +def _make_two_edge_graph(): + """Graph with one semantically_similar_to edge and one references edge, both cross-file.""" + G = nx.Graph() + for nid, label, src in [ + ("a", "ValidateInput", "auth/validators.py"), + ("b", "CheckInput", "api/checks.py"), + ("c", "LoadConfig", "config/loader.py"), + ("d", "ReadConfig", "utils/reader.py"), + ]: + G.add_node(nid, label=label, source_file=src, file_type="code") + # semantically_similar_to edge + G.add_edge("a", "b", relation="semantically_similar_to", confidence="INFERRED", + confidence_score=0.82, source_file="auth/validators.py", weight=0.82, + _src="a", _tgt="b") + # plain references edge (same confidence tier) + G.add_edge("c", "d", relation="references", confidence="INFERRED", + confidence_score=0.7, source_file="config/loader.py", weight=0.7, + _src="c", _tgt="d") + return G + + +# --------------------------------------------------------------------------- +# Test 1: semantically_similar_to passes through build_from_json without being dropped +# --------------------------------------------------------------------------- + +def test_semantic_edge_survives_build_from_json(): + G = _make_graph_with_semantic_edge() + assert G.number_of_edges() == 1 + u, v, data = next(iter(G.edges(data=True))) + assert data["relation"] == "semantically_similar_to" + + +def test_semantic_edge_nodes_present(): + G = _make_graph_with_semantic_edge() + assert "a_validate_input" in G.nodes + assert "b_check_input" in G.nodes + + +# --------------------------------------------------------------------------- +# Test 2: confidence_score is preserved for semantically_similar_to edges +# --------------------------------------------------------------------------- + +def test_semantic_edge_confidence_score_preserved(): + G = _make_graph_with_semantic_edge() + u, v, data = next(iter(G.edges(data=True))) + assert data.get("confidence_score") == pytest.approx(0.82) + assert data.get("confidence") == "INFERRED" + + +# --------------------------------------------------------------------------- +# Test 3: surprising_connections scores semantically_similar_to edges higher +# than references edges with the same community membership +# --------------------------------------------------------------------------- + +def test_semantic_edge_scores_higher_than_references(): + G = _make_two_edge_graph() + communities = {0: ["a", "b"], 1: ["c", "d"]} + node_community = {"a": 0, "b": 0, "c": 1, "d": 1} + + score_sem, reasons_sem = _surprise_score( + G, "a", "b", G.edges["a", "b"], node_community, + "auth/validators.py", "api/checks.py" + ) + score_ref, _ = _surprise_score( + G, "c", "d", G.edges["c", "d"], node_community, + "config/loader.py", "utils/reader.py" + ) + assert score_sem > score_ref + + +def test_semantic_edge_reason_mentions_similarity(): + G = _make_two_edge_graph() + communities = {0: ["a", "b"], 1: ["c", "d"]} + node_community = {"a": 0, "b": 0, "c": 1, "d": 1} + + _, reasons = _surprise_score( + G, "a", "b", G.edges["a", "b"], node_community, + "auth/validators.py", "api/checks.py" + ) + assert any("similar" in r for r in reasons) + + +# --------------------------------------------------------------------------- +# Test 4: report renders [semantically similar] tag for these edges +# --------------------------------------------------------------------------- + +def _make_report_with_semantic_surprise(): + G = _make_graph_with_semantic_edge() + communities = {0: ["a_validate_input", "b_check_input"]} + cohesion = {0: 0.5} + labels = {0: "Validators"} + gods = [] + surprises = [ + { + "source": "validate_input", + "target": "check_input", + "relation": "semantically_similar_to", + "confidence": "INFERRED", + "confidence_score": 0.82, + "source_files": ["auth/validators.py", "api/checks.py"], + "why": "semantically similar concepts with no structural link", + } + ] + detection = {"total_files": 2, "total_words": 500, "needs_graph": True, "warning": None} + tokens = {"input": 100, "output": 50} + return generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, "./project") + + +def test_report_renders_semantically_similar_tag(): + report = _make_report_with_semantic_surprise() + assert "[semantically similar]" in report + + +def test_report_semantic_tag_on_correct_line(): + report = _make_report_with_semantic_surprise() + for line in report.splitlines(): + if "semantically_similar_to" in line: + assert "[semantically similar]" in line + break + else: + pytest.fail("No line with semantically_similar_to found in report") + + +def test_report_no_semantic_tag_for_other_relations(): + """Non-semantic edges must not get the [semantically similar] tag.""" + G = nx.Graph() + for nid, label, src in [ + ("x", "Alpha", "repo1/a.py"), + ("y", "Beta", "repo2/b.py"), + ]: + G.add_node(nid, label=label, source_file=src, file_type="code") + G.add_edge("x", "y", relation="references", confidence="EXTRACTED", + confidence_score=1.0, source_file="repo1/a.py", weight=1.0) + + communities = {0: ["x", "y"]} + cohesion = {0: 0.5} + labels = {0: "Misc"} + gods = [] + surprises = [ + { + "source": "Alpha", + "target": "Beta", + "relation": "references", + "confidence": "EXTRACTED", + "source_files": ["repo1/a.py", "repo2/b.py"], + "why": "cross-file connection", + } + ] + detection = {"total_files": 2, "total_words": 200, "needs_graph": True, "warning": None} + tokens = {"input": 50, "output": 25} + report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, "./project") + assert "[semantically similar]" not in report