Files
graphify/tests/test_dedup.py
T
Deepjyoti-Sarmah b3f9af4e74 fix(dedup): keep a same-file reference as a mention (#4281)
A semantic fragment sometimes mints the bare id of a file it only
points at: docs/firmware/protocol.md emits `protocol` for
docs/protocol.md. Path-qualifying that id per file renamed it onto the
mentioning file's own page, so the reference became a link from the file
to itself. A collapse that picked the survivor by label length could
instead drop the real document.

Rule: a node is read as a mention when another node of its own file
points at it with a `references` edge from that same file AND its id is
itself a path slice of that file (it names a document). A mention is
not a rename candidate, and it ranks as a reference in _collision_rank,
so the real document outranks it whatever the label lengths are. One
helper, _mentions, decides both places.

The path-slice gate matters: entity ids such as `x_intro` are the target
of ordinary intra-file `references` edges. Without the gate the rename
skips them and two real documents collapse, and the ranking changes the
survivor of unrelated collisions. A test pins the survivor.

Fixes R (reference to another document) and R_labelswap: no self-link,
and no dropped document decided by label length. Two real documents
(D), a reference from a third file (F) and one entity described in two
documents (G) are unchanged.

Limits:
- The evidence is inferred from the edges an LLM chose to emit. A
  missing or reversed edge gives no evidence, so both files are renamed
  as before.
- A real document whose own node is linked to its file-level id the
  same way reads as a mention and collapses as on v8.
- A top-level protocol.md that also mints `protocol` is a separate
  collision and still drops one document; pinned by a test.
- If two or more other files also mint the mention's id, it stays on
  the bare id and build folds it into its own page.

(cherry picked from commit 2d69f5e541)
2026-10-12 22:38:15 +01:00

2013 lines
94 KiB
Python

"""Tests for graphify/dedup.py entity deduplication pipeline."""
from __future__ import annotations
import pytest
from graphify.dedup import (
deduplicate_entities,
_collision_rank,
_defines_id,
_entropy,
_lifecycle_penalty,
_shingles,
)
# ── entropy gate ─────────────────────────────────────────────────────────────
def test_entropy_short_label_low():
assert _entropy("AI") < 2.5
def test_entropy_normal_label_high():
assert _entropy("AuthenticationManager") >= 2.5
def test_entropy_empty_string():
assert _entropy("") == 0.0
# ── shingles ─────────────────────────────────────────────────────────────────
def test_shingles_produces_trigrams():
s = _shingles("hello")
assert "hel" in s
assert "ell" in s
assert "llo" in s
def test_shingles_short_string():
# strings shorter than 3 chars return single shingle of the string itself
assert _shingles("ab") == {"ab"}
# ── full pipeline ─────────────────────────────────────────────────────────────
def _make_nodes(*labels):
return [{"id": label.lower().replace(" ", "_"), "label": label, "source_file": "test.md"} for label in labels]
def _make_edges(src, tgt, relation="relates_to"):
return [{"source": src, "target": tgt, "relation": relation}]
def test_exact_duplicates_merged():
nodes = _make_nodes("UserService", "userservice", "User Service")
edges = []
result_nodes, result_edges = deduplicate_entities(nodes, edges, communities={})
# All three are the same concept — only one survives
assert len(result_nodes) == 1
def test_typo_merged():
# "GraphExtractor" vs "Graph Extractor" — Jaro-Winkler >= 0.92
nodes = _make_nodes("GraphExtractor", "Graph Extractor")
edges = []
result_nodes, _ = deduplicate_entities(nodes, edges, communities={})
assert len(result_nodes) == 1
def test_unrelated_not_merged():
nodes = _make_nodes("UserService", "OrderService")
edges = []
result_nodes, _ = deduplicate_entities(nodes, edges, communities={})
assert len(result_nodes) == 2
def test_short_low_entropy_not_merged():
# "AI" and "ML" are low-entropy — entropy gate skips them
nodes = _make_nodes("AI", "ML")
edges = []
result_nodes, _ = deduplicate_entities(nodes, edges, communities={})
assert len(result_nodes) == 2
def test_edges_rewired_after_merge():
nodes = _make_nodes("GraphExtractor", "Graph Extractor", "Parser")
# edge from loser to Parser should be rewired to winner
edges = [{"source": "graph_extractor", "target": "parser", "relation": "uses"}]
result_nodes, result_edges = deduplicate_entities(nodes, edges, communities={})
assert len(result_nodes) == 2 # merged + Parser
# edge should still exist (rewired to winner)
assert len(result_edges) == 1
def test_self_loops_dropped_after_merge():
# If both endpoints of an edge get merged into same node, drop the edge
nodes = _make_nodes("GraphExtractor", "Graph Extractor")
edges = [{"source": "graphextractor", "target": "graph_extractor", "relation": "same"}]
_, result_edges = deduplicate_entities(nodes, edges, communities={})
assert result_edges == []
def test_preexisting_self_loops_preserved_after_unrelated_merge():
# #3809: Pre-existing self-loops (e.g. recursive calls, self-referencing FKs)
# must survive entity deduplication even when an unrelated merge occurs.
# Only self-loops created by the merge itself should be dropped.
nodes = [
{"id": "walk", "label": "walk()", "source_file": "src/tree.py", "file_type": "code"},
{"id": "categories", "label": "categories", "source_file": "schema.sql", "file_type": "code"},
{"id": "cache", "label": "Cache", "source_file": "docs/guide.md", "file_type": "concept"},
{"id": "cache_2", "label": "cache", "source_file": "docs/guide.md", "file_type": "concept"},
]
edges = [
{"source": "walk", "target": "walk", "relation": "calls"},
{"source": "categories", "target": "categories", "relation": "references"},
# An edge between the duplicate concepts that collapses upon merge
{"source": "cache_2", "target": "cache", "relation": "references"},
]
_, result_edges = deduplicate_entities(nodes, edges, communities={})
loops = [(e["source"], e["target"], e.get("relation")) for e in result_edges if e["source"] == e["target"]]
assert ("walk", "walk", "calls") in loops
assert ("categories", "categories", "references") in loops
# The collapsed edge cache_2 -> cache must NOT produce a self-loop
assert len(loops) == 2
def test_preexisting_self_loop_on_merged_node_rewires_to_winner():
# If a merged node itself had a pre-existing self-loop, the self-loop
# is rewired to the winner, not dropped.
nodes = [
{"id": "cache", "label": "Cache", "source_file": "docs/guide.md", "file_type": "concept"},
{"id": "cache_2", "label": "cache", "source_file": "docs/guide.md", "file_type": "concept"},
]
edges = [
{"source": "cache_2", "target": "cache_2", "relation": "references"},
]
_, result_edges = deduplicate_entities(nodes, edges, communities={})
assert len(result_edges) == 1
assert result_edges[0]["source"] == "cache"
assert result_edges[0]["target"] == "cache"
def test_community_boost_aids_merge():
# Two nodes in same community with score in 0.75-0.85 zone get boosted
nodes = _make_nodes("AuthManager", "Auth Manager")
edges = []
# Same community → boost → merge
communities = {"authmanager": 1, "auth_manager": 1}
result_with, _ = deduplicate_entities(nodes, edges, communities=communities)
# Different community → no boost
communities_diff = {"authmanager": 1, "auth_manager": 2}
result_without, _ = deduplicate_entities(nodes, edges, communities=communities_diff)
assert len(result_with) <= len(result_without)
def test_empty_inputs():
result_nodes, result_edges = deduplicate_entities([], [], communities={})
assert result_nodes == []
assert result_edges == []
def test_single_node_no_crash():
nodes = _make_nodes("UserService")
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 1
def test_dedup_llm_flag_accepted():
"""deduplicate_entities accepts dedup_llm_backend without crashing when no ambiguous pairs exist."""
nodes = _make_nodes("UserService", "OrderService")
edges = []
result_nodes, _ = deduplicate_entities(nodes, edges, communities={}, dedup_llm_backend=None)
assert len(result_nodes) == 2
# ── build integration ─────────────────────────────────────────────────────────
def test_build_calls_dedup():
"""build() should deduplicate near-identical nodes across extractions."""
from graphify.build import build
chunk1 = {
"nodes": [{"id": "graphextractor", "label": "GraphExtractor", "source_file": "a.py"}],
"edges": [],
}
chunk2 = {
"nodes": [{"id": "graph_extractor", "label": "Graph Extractor", "source_file": "b.py"}],
"edges": [],
}
G = build([chunk1, chunk2])
assert G.number_of_nodes() == 1
def test_build_dedup_preserves_semantic_attributes():
"""The default build path must not discard semantic enrichment (#2091)."""
from graphify.build import build
ast = {
"nodes": [{"id": "src_auth_login", "label": "login", "file_type": "code",
"source_file": "src/auth.py", "_origin": "ast",
"source_location": "L42"}],
"edges": [],
}
semantic = {
"nodes": [{"id": "src_auth_login", "label": "User login handler",
"file_type": "code", "source_file": "src/auth.py",
"summary": "Authenticates a user.", "confidence_score": 0.9}],
"edges": [],
}
node = dict(build([ast, semantic], directed=True, dedup=True).nodes["src_auth_login"])
assert node["label"] == "login"
assert node["source_location"] == "L42"
assert node["summary"] == "Authenticates a user."
assert node["confidence_score"] == 0.9
# --- #878: fuzzy dedup false merges on short/variant labels ---
def test_dedup_does_not_merge_numeric_variants(tmp_path):
"""Chip SKU variants (ASR1603 vs ASR1605) must not be merged (#878)."""
nodes = _make_nodes("ASR1603", "ASR1605")
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 2, "ASR1603 and ASR1605 are distinct chip models, not duplicates"
def test_dedup_does_not_merge_short_insertion_variants(tmp_path):
"""Short labels differing by an insertion (cranel vs cranelr) must not merge (#878)."""
nodes = _make_nodes("cranel", "cranelr")
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 2, "cranel and cranelr are distinct, not a typo"
def test_dedup_does_not_merge_model_with_suffix(tmp_path):
"""M1 vs M1 Pro must not merge (#878)."""
nodes = _make_nodes("M1", "M1 Pro")
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 2, "M1 and M1 Pro are distinct Apple chip variants"
def test_dedup_still_merges_real_typos():
"""Genuine same-length single-char typos should still merge (#878 non-regression)."""
from graphify.dedup import _is_variant_pair, _short_label_blocked
from rapidfuzz.distance import JaroWinkler
a, b = "graphextractor", "graphextractar"
score = JaroWinkler.normalized_similarity(a, b) * 100
assert not _is_variant_pair(a, b), "not a variant pair"
assert not _short_label_blocked(a, b, score), "long-enough label, should not be blocked"
def test_variant_pair_helper():
"""_is_variant_pair correctly identifies chip-model variant pairs (#878)."""
from graphify.dedup import _is_variant_pair
assert _is_variant_pair("asr1603", "asr1605")
assert _is_variant_pair("cortex a55", "cortex a55x")
assert not _is_variant_pair("graphextractor", "graphextracter")
assert not _is_variant_pair("foo", "foo")
def test_prefix_extension_symbols_not_merged():
"""Distinct symbols whose name is a strict prefix-extension of another must not
be merged (#1201). getActiveSession / getActiveSessions score ~98.82 JW but are
different functions; parseConfig / parseConfigFile likewise."""
import networkx as nx
from graphify.dedup import deduplicate_entities
pairs = [
("getActiveSession", "getActiveSessions"),
("parseConfig", "parseConfigFile"),
("load", "loadAll"),
("handleRequest", "handleRequestTimeout"),
]
for a, b in pairs:
nodes = [
{"id": f"{a}_id", "label": a, "type": "CODE", "src_file": "api.py"},
{"id": f"{b}_id", "label": b, "type": "CODE", "src_file": "api.py"},
]
edges = [{"src": f"{a}_id", "tgt": f"{b}_id", "relation": "calls",
"c": 1.0, "weight": 1.0}]
out_nodes, _ = deduplicate_entities(
nodes, edges, communities={f"{a}_id": 0, f"{b}_id": 0}
)
labels = {n["label"] for n in out_nodes}
assert a in labels and b in labels, (
f"#1201 regression: '{a}' and '{b}' were merged — they are distinct symbols"
)
def test_pass2_winner_union_does_not_pull_in_uncompared_same_label_nodes():
"""Pass 2's winner selection must consider only the verified pair (#1247).
Picking the winner from the union of both normalized-label groups pulls
never-compared nodes into the merge: here A ("Session Manager", auth.md)
and B ("Session Manager", billing.md) are deliberately kept distinct by
the cross-file identical-label guards (#1046, #1178), yet when the
A-C fuzzy match ("Session Managr" typo) fires, _pick_winner over
[A, B, C] selects B (shortest id) and unions B with both A and C —
merging B although it was never compared against anything.
"""
nodes = [
{"id": "session_manager_auth", "label": "Session Manager",
"source_file": "auth.md"},
{"id": "sm", "label": "Session Manager",
"source_file": "billing.md"},
{"id": "session_managr_notes", "label": "Session Managr",
"source_file": "notes.md"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
ids = {n["id"] for n in result_nodes}
# B must survive as a distinct node: identical label across different
# source files is exactly what the #1046/#1178 guards keep separate.
assert "sm" in ids, (
"uncompared cross-file node 'sm' was absorbed via pass-2 winner-union"
)
# The verified fuzzy pair (A, C) still merges — only one of them survives.
assert len(result_nodes) == 2
def test_prefix_guard_does_not_block_same_length_typos():
"""The prefix-extension guard must not fire for same-length pairs — only strict
prefix-extensions (one is a substring of the other) should be blocked (#1201).
graphextractor / graphextractar have the same length, so neither starts-with the
other, and the guard must not fire."""
from graphify.dedup import _norm
a = _norm("GraphExtractor") # "graphextractor" — 14 chars
b = _norm("GraphExtractar") # "graphextractar" — 14 chars
lo, hi = sorted((a, b), key=len)
# Same-length pair: startswith only holds when strings are identical
assert not (hi.startswith(lo) and hi != lo), (
f"Prefix guard fires on same-length pair ({a!r}, {b!r}) — should not"
)
def test_prefix_guard_fires_for_extension_pairs():
"""The prefix-extension guard must fire for pairs where one is a strict prefix
of the other, preventing false merges (#1201)."""
from graphify.dedup import _norm
pairs = [
("getActiveSession", "getActiveSessions"),
("parseConfig", "parseConfigFile"),
("load", "loadAll"),
]
for a_raw, b_raw in pairs:
a, b = _norm(a_raw), _norm(b_raw)
lo, hi = sorted((a, b), key=len)
assert hi.startswith(lo) and hi != lo, (
f"Prefix guard should fire for ({a!r}, {b!r}) but did not"
)
# ── #1284: numbered siblings + cross-file file-anchored boilerplate ──────────
def test_numeric_tokens_differ_helper():
"""_numeric_tokens_differ compares digit runs as zero-padding-insensitive
multisets (#1284)."""
from graphify.dedup import _numeric_tokens_differ
assert _numeric_tokens_differ("adr 0011 d5 pipeline placement", "adr 0013 d4 pipeline placement")
assert _numeric_tokens_differ("3 1 product goals", "1 1 product goals")
assert _numeric_tokens_differ("code block3", "code block13")
assert not _numeric_tokens_differ("phase 09 overview", "phase 9 overview") # zero-padding
assert not _numeric_tokens_differ("module layout wave 3", "module layouts wave 3")
assert not _numeric_tokens_differ("graph extractor", "graph extractar") # digitless
def test_dedup_does_not_merge_numbered_siblings():
"""Long labels differing only in embedded numbers (ADR/section/issue ids)
must not merge — numbered siblings, not duplicates (#1284)."""
nodes = [
{"id": "n1", "label": "Pipeline placement — 4 call sites (ADR 0013 D4)",
"file_type": "document", "source_file": "docs/index-activity.md"},
{"id": "n2", "label": "Pipeline placement — 4 call sites (ADR 0011 §D5)",
"file_type": "document", "source_file": "docs/schema-matcher.md"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 2
def test_dedup_does_not_merge_crossfile_rationale_boilerplate():
"""Rationale nodes are file-anchored like code (#1205): parallel modules'
boilerplate docstrings differing by one word must not merge (#1284)."""
boiler = ("Django app config for {}. No business logic here. "
"Domain services live in services.py and adapters in providers.")
nodes = [
{"id": "r1", "label": boiler.format("apps.platform.cards"),
"file_type": "rationale", "source_file": "apps/platform/cards/apps.py"},
{"id": "r2", "label": boiler.format("apps.platform.cores"),
"file_type": "rationale", "source_file": "apps/platform/cores/apps.py"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 2
def test_dedup_does_not_merge_crossfile_document_headings():
"""Document nodes are file-anchored too: near-identical headings in different
files are distinct sections, not duplicates (#1284, extends the rationale guard)."""
nodes = [
{"id": "d1", "label": "Getting Started Installation Guide",
"file_type": "document", "source_file": "docs/a.md"},
{"id": "d2", "label": "Getting Started Installation Setup",
"file_type": "document", "source_file": "docs/b.md"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 2
def test_dedup_still_merges_samefile_rationale_duplicates():
"""The file-anchored guard only blocks cross-file pairs — near-identical
rationale duplicates within one file still merge (#1284 non-regression)."""
nodes = [
{"id": "r1", "label": "Counts-only metrics export, a read-only aggregation service.",
"file_type": "rationale", "source_file": "apps/schemas/metrics.py"},
{"id": "r2", "label": "Counts-only metrics export, the read-only aggregation service.",
"file_type": "rationale", "source_file": "apps/schemas/metrics.py"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 1
# ── #1243: JaroWinkler prefix-bonus over-merge (cross-file) ──────────────────
def test_dedup_does_not_merge_crossfile_shared_prefix_divergence():
"""Cross-file labels sharing a long prefix but diverging in a distinguishing
token ("…jest native" vs "…react native") get JaroWinkler's prefix bonus past
threshold but are distinct entities; scoring them on plain Jaro blocks the
merge (#1243)."""
nodes = [
{"id": "p1", "label": "testing library jest native",
"file_type": "concept", "source_file": "pkg-a/package.json"},
{"id": "p2", "label": "testing library react native",
"file_type": "concept", "source_file": "pkg-b/package.json"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 2
def test_dedup_still_merges_crossfile_true_duplicates():
"""The #1243 guard only drops the prefix bonus — a genuine cross-file
duplicate (high similarity on Jaro alone) must still merge."""
nodes = [
{"id": "g1", "label": "GraphExtractor", "file_type": "concept", "source_file": "a.md"},
{"id": "g2", "label": "Graph Extractor", "file_type": "concept", "source_file": "b.md"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 1
# ── #1504: cross-chunk node ID collision warning ──────────────────────────────
def test_cross_chunk_id_collision_emits_warning(capsys):
"""When two nodes share the same ID but come from different source files
(a cross-chunk LLM ID collision), a WARNING must be printed to stderr
and only the first node survives (#1504). The id is not a shorter-stem mint
of either path, so this covers a collision the path-qualify pass cannot
attribute (#4281)."""
nodes = [
{"id": "booking_service", "label": "Booking Service",
"file_type": "concept", "source_file": "module-a/README.md"},
{"id": "booking_service", "label": "Booking Service",
"file_type": "concept", "source_file": "module-b/README.md"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 1
assert result_nodes[0]["source_file"] == "module-a/README.md"
captured = capsys.readouterr()
assert "WARNING" in captured.err
assert "booking_service" in captured.err
assert "module-b/README.md" in captured.err
assert "module-a/README.md" in captured.err
def test_same_id_same_source_file_no_warning(capsys):
"""When two nodes share both ID and source_file (same-file dedup),
no collision warning should be emitted."""
nodes = [
{"id": "readme_booking_service", "label": "Booking Service",
"file_type": "concept", "source_file": "module-a/README.md"},
{"id": "readme_booking_service", "label": "Booking Service (dupe)",
"file_type": "concept", "source_file": "module-a/README.md"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 1
captured = capsys.readouterr()
assert "WARNING" not in captured.err
# ── #1857: dedup summary log breakdown ────────────────────────────────────────
def test_dedup_summary_prints_fuzzy_count_when_no_exact_merges(capsys):
"""A fuzzy-only run must still report the fuzzy count (#1857).
Two long, high-entropy, non-code labels on different files. Pass 1 (exact,
same-file) finds nothing; Pass 2 (Jaro-Winkler cross-file) merges them.
Previously the fuzzy count was nested inside `if exact_merges`, so this
line printed as `Deduplicated 1 node(s).` with no breakdown.
"""
nodes = [
{"id": "g1", "label": "GraphExtractor", "file_type": "concept", "source_file": "a.md"},
{"id": "g2", "label": "Graph Extractor", "file_type": "concept", "source_file": "b.md"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 1
captured = capsys.readouterr()
assert "Deduplicated" in captured.out
assert "fuzzy" in captured.out
assert "exact" not in captured.out
def test_dedup_summary_still_reports_exact_only(capsys):
"""Non-regression: an exact-only run still prints `(N exact)` and no fuzzy."""
# Same file + same normalized label → Pass 1 exact merge.
nodes = [
{"id": "u1", "label": "User Service", "file_type": "concept", "source_file": "svc.md"},
{"id": "u2", "label": "user service", "file_type": "concept", "source_file": "svc.md"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 1
captured = capsys.readouterr()
assert "exact" in captured.out
assert "fuzzy" not in captured.out
# ── ID collisions: definition vs cross-reference ──────────────────────────────
# The defining node and a doc that merely mentions the entity. Both mint the ID
# encoded from the *defining* file's path, so they collide by construction.
_DEFINING = {"id": "agents_make_batch_fixtures_make_batch_fixtures",
"label": "make-batch-fixtures agent", "file_type": "concept",
"source_file": "agents/make-batch-fixtures.md"}
_REFERENCING = {"id": "agents_make_batch_fixtures_make_batch_fixtures",
"label": "make-batch-fixtures", "file_type": "concept",
"source_file": "available/diagnose-issue/SKILL.md"}
@pytest.mark.parametrize("nodes", [
[_DEFINING, _REFERENCING],
[_REFERENCING, _DEFINING],
], ids=["definition-first", "reference-first"])
def test_defining_file_wins_over_referencing_file(nodes, capsys):
"""The node whose source_file is the file its ID encodes survives, whichever
chunk order the nodes arrive in — the survivor must not depend on it."""
result_nodes, _ = deduplicate_entities(list(nodes), [], communities={})
assert len(result_nodes) == 1
assert result_nodes[0]["source_file"] == "agents/make-batch-fixtures.md"
assert result_nodes[0]["label"] == "make-batch-fixtures agent"
def test_reference_collision_is_silent(capsys):
"""A cross-reference rewires silently without importing foreign-file metadata."""
edges = _make_edges("agents_make_batch_fixtures_make_batch_fixtures", "other")
referencing = dict(_REFERENCING, summary="Reference-local description.")
result_nodes, result_edges = deduplicate_entities(
[_DEFINING, referencing], edges, communities={})
assert len(result_nodes) == 1
assert len(result_edges) == 1
assert "summary" not in result_nodes[0]
captured = capsys.readouterr()
assert "WARNING" not in captured.err
assert "note:" not in captured.err
def test_absolute_source_path_still_defines_id(capsys):
"""source_file is absolute in some pipelines and repo-relative in others; the
defining file is recognised either way."""
absolute = dict(_DEFINING, source_file="/home/u/proj/agents/make-batch-fixtures.md")
result_nodes, _ = deduplicate_entities([_REFERENCING, absolute], [], communities={})
assert len(result_nodes) == 1
assert result_nodes[0]["label"] == "make-batch-fixtures agent"
assert "WARNING" not in capsys.readouterr().err
# ── #3352: non-Latin source paths must still define their own id ─────────────
# _id_prefixes reimplemented id slugification with an ASCII-only regex instead
# of the canonical normalize_id every real extractor mints an id with, so a
# path segment made of Korean, CJK, or Cyrillic characters collapsed to
# nothing instead of being preserved. The reconstructed prefix then never
# matched the id actually minted for that file, so the defining page lost the
# definer-wins tiebreak to a page that merely references the same entity.
def test_id_prefixes_preserves_non_latin_segments():
from graphify.dedup import _id_prefixes
prefixes = _id_prefixes("concepts/작업 단위 폴더 + README 진입점 컨벤션.md")
assert "concepts_작업_단위_폴더_readme_진입점_컨벤션" in prefixes
assert "작업_단위_폴더_readme_진입점_컨벤션" in prefixes
_KOREAN_DEFINING = {
"id": "concepts_작업_단위_폴더_readme_진입점_컨벤션",
"label": "README 진입점 컨벤션", "file_type": "concept",
"source_file": "concepts/작업 단위 폴더 + README 진입점 컨벤션.md",
}
_KOREAN_REFERENCING = {
"id": "concepts_작업_단위_폴더_readme_진입점_컨벤션",
"label": "README 진입점 컨벤션", "file_type": "concept",
"source_file": "concepts/LLM Schema - CLAUDE.md를 가이드로 활용하기.md",
}
def test_defines_id_recognizes_a_non_latin_path():
assert _defines_id(_KOREAN_DEFINING)
assert not _defines_id(_KOREAN_REFERENCING)
@pytest.mark.parametrize("nodes", [
[_KOREAN_DEFINING, _KOREAN_REFERENCING],
[_KOREAN_REFERENCING, _KOREAN_DEFINING],
], ids=["definition-first", "reference-first"])
def test_korean_defining_file_wins_over_referencing_file(nodes):
"""The issue's own repro shape: the page that defines a concept must keep
its own node instead of losing it to a page that merely mentions it."""
result_nodes, _ = deduplicate_entities(list(nodes), [], communities={})
assert len(result_nodes) == 1
assert result_nodes[0]["source_file"] == (
"concepts/작업 단위 폴더 + README 진입점 컨벤션.md"
)
def test_id_prefixes_ascii_path_unchanged():
"""Negative control: an ordinary ASCII path's reconstructed prefixes must
be identical to what the old ASCII only regex produced."""
from graphify.dedup import _id_prefixes
assert _id_prefixes("docs/v1/api/README.md") == {
"readme", "api_readme", "v1_api_readme", "docs_v1_api_readme",
}
def test_same_file_relabel_is_noted(capsys):
"""Two labels for one ID from one file: the loser's label is discarded, which is
the one drop that used to be silent. It is a note, not a collision warning."""
nodes = [
{"id": "agents_make_batch_fixtures_make_batch_fixtures",
"label": "make-batch-fixtures agent", "file_type": "concept",
"source_file": "agents/make-batch-fixtures.md"},
{"id": "agents_make_batch_fixtures_make_batch_fixtures",
"label": "make-batch-fixtures helper agent", "file_type": "concept",
"source_file": "agents/make-batch-fixtures.md"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 1
captured = capsys.readouterr()
assert "note:" in captured.err
assert "make-batch-fixtures helper agent" in captured.err
assert "WARNING" not in captured.err
@pytest.mark.parametrize("nodes", [
[
{"id": "src_auth_login", "label": "login", "file_type": "code",
"source_file": "src/auth.py", "_origin": "ast", "source_location": "L42"},
{"id": "src_auth_login", "label": "User login handler", "file_type": "code",
"source_file": "src/auth.py", "summary": "Authenticates a user.",
"confidence_score": 0.9},
],
[
{"id": "src_auth_login", "label": "User login handler", "file_type": "code",
"source_file": "src/auth.py", "summary": "Authenticates a user.",
"confidence_score": 0.9},
{"id": "src_auth_login", "label": "login", "file_type": "code",
"source_file": "src/auth.py", "_origin": "ast", "source_location": "L42"},
],
], ids=["ast-first", "semantic-first"])
def test_same_id_same_entity_retains_complementary_attributes(nodes):
"""Exact-ID dedup combines AST precision with semantic enrichment (#2091)."""
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 1
assert result_nodes[0] == {
"id": "src_auth_login",
"label": "login",
"file_type": "code",
"source_file": "src/auth.py",
"_origin": "ast",
"source_location": "L42",
"summary": "Authenticates a user.",
"confidence_score": 0.9,
}
def test_cross_file_id_collision_does_not_mix_attributes(capsys):
"""Two files that both mint one ID remain isolated despite exact-ID dedup."""
nodes = [
{"id": "pkg_service_run", "label": "run", "file_type": "code",
"source_file": "pkg/service.py", "source_location": "L10"},
{"id": "pkg_service_run", "label": "run helper", "file_type": "code",
"source_file": "pkg_service.py", "summary": "Different function."},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 1
assert result_nodes[0]["source_file"] == "pkg/service.py"
assert "summary" not in result_nodes[0]
assert "WARNING" in capsys.readouterr().err
def test_collision_survivor_is_order_independent():
"""#1851: definer + same-file relabel + cross-file reference. Across every
insertion order the SAME node (source_file AND label) must survive — the
definer heuristic alone left the label order-dependent among co-definers."""
import itertools
nid = "agents_make_batch_fixtures_make_batch_fixtures"
definer = {"id": nid, "label": "make-batch-fixtures agent",
"file_type": "concept", "source_file": "agents/make-batch-fixtures.md"}
relabel = {"id": nid, "label": "make-batch-fixtures helper agent",
"file_type": "concept", "source_file": "agents/make-batch-fixtures.md"}
xref = {"id": nid, "label": "make-batch-fixtures", "file_type": "concept",
"source_file": "available/diagnose-issue/SKILL.md"}
survivors = set()
for perm in itertools.permutations([definer, relabel, xref]):
out, _ = deduplicate_entities([dict(n) for n in perm], [], communities={})
assert len(out) == 1
survivors.add((out[0]["source_file"], out[0]["label"]))
assert survivors == {("agents/make-batch-fixtures.md", "make-batch-fixtures agent")}, (
f"non-deterministic collision survivor: {survivors}"
)
def test_bare_file_node_defines_its_own_id():
"""A file-level semantic node whose id is exactly the slugified path (no
`_entity` suffix) must be recognised as defining its id (#1851 tweak)."""
assert _defines_id({"id": "agents_make_batch_fixtures",
"source_file": "agents/make-batch-fixtures.md"})
def test_defines_id_helper():
assert _defines_id(_DEFINING)
assert not _defines_id(_REFERENCING)
# Pre-#1504 IDs keyed off the bare filename stem.
assert _defines_id({"id": "readme_booking_service",
"source_file": "module-a/README.md"})
# A path that is merely a string-prefix of the ID's path does not define it.
assert not _defines_id({"id": "agents_foo", "source_file": "agent/foo.md"})
assert not _defines_id({"id": "docs_intro_foo", "source_file": ""})
# ── #2532: lifecycle-aware, environment-stable collision ranking ──────────────
# Active-vs-archived path markers adapted from @michaelxer's PR #2540, judged on
# root-relative segments with a reversed-segment final tiebreak so the survivor
# cannot flip with path form or checkout location.
_DONE_PLAN = {"id": "plans_binding_doctrine", "label": "binding doctrine",
"file_type": "concept",
"source_file": "plans/_done/binding-doctrine.md"}
_ACTIVE_PLAN = {"id": "plans_binding_doctrine", "label": "binding doctrine",
"file_type": "concept",
"source_file": "plans/in-progress/binding-doctrine.md"}
def test_lifecycle_penalty_on_relative_segments():
assert _lifecycle_penalty("plans/_done/x.md") == 2
assert _lifecycle_penalty("plans/in-progress/x.md") == 0
assert _lifecycle_penalty("docs/x.md") == 1
# A FILE named after a marker is not a marker — only directory segments count.
assert _lifecycle_penalty("done.md") == 1
assert _lifecycle_penalty("plans/done.md") == 1
# Mixed markers: the best (most active) marked segment wins.
assert _lifecycle_penalty("archive/in-progress/x.md") == 0
def test_archived_path_ranks_below_active_despite_ascii_order():
"""#2532: `plans/_done` sorts before `plans/in-progress` lexically ('_' < 'i'),
so a raw-string tiebreak picks the archived copy — assert the ASCII trap is
real, then that the rank inverts it."""
assert _DONE_PLAN["source_file"] < _ACTIVE_PLAN["source_file"] # the trap
assert _collision_rank(_ACTIVE_PLAN) < _collision_rank(_DONE_PLAN)
def test_active_plan_survives_archived_copy_order_independent():
"""#2532 reported case: the in-progress copy survives, whichever order the
colliding nodes arrive in."""
import itertools
for perm in itertools.permutations([_DONE_PLAN, _ACTIVE_PLAN]):
out, _ = deduplicate_entities([dict(n) for n in perm], [], communities={})
assert len(out) == 1
assert out[0]["source_file"] == "plans/in-progress/binding-doctrine.md"
def test_checkout_dir_lifecycle_name_does_not_mark_paths(tmp_path):
"""Gap in #2540: markers must be matched on ROOT-RELATIVE segments only. An
absolute source_file stored under a checkout directory named `wip` must not
be scored active by the checkout location — the archived copy still loses."""
import itertools
root = tmp_path / "wip" / "repo"
archived = {"id": "plans_doc", "label": "doc", "file_type": "concept",
"source_file": str(root / "plans" / "_done" / "doc.md")}
active = {"id": "plans_doc", "label": "doc", "file_type": "concept",
"source_file": "plans/roadmap/doc.md"}
for perm in itertools.permutations([archived, active]):
out, _ = deduplicate_entities([dict(n) for n in perm], [],
communities={}, root=root)
assert len(out) == 1
assert out[0]["source_file"] == "plans/roadmap/doc.md"
def test_neutral_collision_survivor_is_stable_across_path_forms(tmp_path):
"""#2532: with no lifecycle markers, the tiebreak must not depend on whether
either path is stored absolute or repo-relative — one root-relative file
identity survives across all four form combinations and both insertion
orders (8 runs)."""
import itertools
root = tmp_path / "repo"
rel_a, rel_b = "plans/q3/doc.md", "plans/roadmap/doc.md"
survivors = set()
for form_a in (rel_a, str(root / rel_a)):
for form_b in (rel_b, str(root / rel_b)):
a = {"id": "plans_doc", "label": "doc", "file_type": "concept",
"source_file": form_a}
b = {"id": "plans_doc", "label": "doc", "file_type": "concept",
"source_file": form_b}
for perm in itertools.permutations([a, b]):
out, _ = deduplicate_entities([dict(n) for n in perm], [],
communities={}, root=root)
assert len(out) == 1
sf = out[0]["source_file"].replace("\\", "/")
prefix = str(root).replace("\\", "/") + "/"
survivors.add(sf.removeprefix(prefix))
assert survivors == {"plans/q3/doc.md"}, (
f"survivor depends on path form or order: {survivors}"
)
def test_archived_definer_beats_active_reference():
"""The definer flag outranks the lifecycle penalty: a node that owns its ID
survives even from an archived folder against a live cross-reference."""
import itertools
nid = "plans_done_spec_spec"
definer = {"id": nid, "label": "spec doc", "file_type": "concept",
"source_file": "plans/_done/spec.md"}
reference = {"id": nid, "label": "spec", "file_type": "concept",
"source_file": "plans/in-progress/roadmap.md"}
assert _defines_id(definer) and not _defines_id(reference)
for perm in itertools.permutations([definer, reference]):
out, _ = deduplicate_entities([dict(n) for n in perm], [], communities={})
assert len(out) == 1
assert out[0]["source_file"] == "plans/_done/spec.md"
# ── #2091 review: attribute-merge correctness (fixes A-D) ─────────────────────
def test_dedup_gapfill_is_order_independent_with_multiple_losers():
"""(fix A) With 3+ same-ID same-source records, the merged attributes must not
depend on arrival order — the best loser by collision rank supplies each
missing key deterministically, preserving the #1851 order-independence."""
import itertools
base = [
{"id": "f", "label": "f", "file_type": "code", "source_file": "m.py",
"source_location": "L1"}, # shortest label -> survivor
{"id": "f", "label": "f helper beta", "file_type": "code",
"source_file": "m.py", "summary": "BETA"},
{"id": "f", "label": "f helper alpha", "file_type": "code",
"source_file": "m.py", "summary": "ALPHA"},
]
seen = set()
for perm in itertools.permutations(base):
nodes, _ = deduplicate_entities([dict(n) for n in perm], [], communities={})
assert len(nodes) == 1
seen.add(nodes[0].get("summary"))
assert len(seen) == 1, f"merged summary depends on arrival order: {seen}"
def test_dedup_no_attribute_merge_when_source_file_missing():
"""(fix B) Two provenance-less records sharing an ID must NOT cross-pollinate
attributes — '' == '' is not proof of the same symbol (#1178)."""
nodes = [
{"id": "c", "label": "c", "file_type": "concept", "summary": "A"},
{"id": "c", "label": "c", "file_type": "concept", "notes": "B"},
]
result, _ = deduplicate_entities([dict(n) for n in nodes], [], communities={})
assert len(result) == 1
surv = result[0]
assert not ("summary" in surv and "notes" in surv), (
"provenance-less same-id records must not merge attributes"
)
def test_dedup_survivor_does_not_inherit_false_origin_ast():
"""(fix C) An LLM survivor must not inherit _origin='ast' from a dropped
same-source AST record — a false authority tag is read by ghost-merge/watch."""
nodes = [
{"id": "x", "label": "run", "file_type": "code", "source_file": "m.py",
"source_location": "L9"}, # shorter label -> survivor (LLM)
{"id": "x", "label": "run() [ast]", "file_type": "code", "source_file": "m.py",
"source_location": "L2", "_origin": "ast"}, # loser carries _origin=ast
]
result, _ = deduplicate_entities([dict(n) for n in nodes], [], communities={})
assert len(result) == 1
assert result[0].get("_origin") != "ast", "survivor must not inherit a false _origin=ast"
def test_dedup_fills_explicit_none_attribute():
"""(fix D) An explicit source_location=None on the survivor is treated as
absent and filled from a same-source record that has a real line (#2091)."""
nodes = [
{"id": "y", "label": "y", "file_type": "code", "source_file": "m.py",
"source_location": None}, # survivor, explicit None
{"id": "y", "label": "y helper", "file_type": "code", "source_file": "m.py",
"source_location": "L7"},
]
result, _ = deduplicate_entities([dict(n) for n in nodes], [], communities={})
assert len(result) == 1
assert result[0].get("source_location") == "L7", "explicit-None must be filled from the loser"
# ── #2182: cross-file exact-duplicate concepts must merge ─────────────────────
def test_crossfile_identical_concepts_merge_and_rewire():
"""Two `concept` nodes whose labels are byte-identical after _norm() but
live in different files must merge (#2182). Pass 1 used to defer them to
Pass 2, whose norm-unique candidate filter (`seen_norms`) structurally
cannot form an equal-norm pair — so exact cross-file duplicates were the
one class of duplicate that never merged, while a one-char-different
fuzzy pair did."""
nodes = [
{"id": "sz_intl", "label": "SHENZHEN INTERNATIONAL",
"file_type": "concept", "source_file": "doc1.md"},
{"id": "shenzhen_international_holdings", "label": "Shenzhen international",
"file_type": "concept", "source_file": "doc2.md"},
{"id": "port_ops", "label": "Port Operations",
"file_type": "concept", "source_file": "doc2.md"},
]
edges = [{"source": "shenzhen_international_holdings", "target": "port_ops",
"relation": "operates"}]
result_nodes, result_edges = deduplicate_entities(nodes, edges, communities={})
ids = {n["id"] for n in result_nodes}
assert len(result_nodes) == 2
# _pick_winner prefers the shorter, non-chunk-suffixed id.
assert "sz_intl" in ids
assert "shenzhen_international_holdings" not in ids
# The loser's edge is rewired to the winner.
assert result_edges == [
{"source": "sz_intl", "target": "port_ops", "relation": "operates"}]
def test_crossfile_one_char_typo_concepts_still_merge():
"""Non-regression: the near-identical (one-char-different) cross-file pair
that already merged via Pass 2 fuzzy matching must keep merging (#2182)."""
nodes = [
{"id": "g1", "label": "Authentication Manager",
"file_type": "concept", "source_file": "a.md"},
{"id": "g2", "label": "Authentication Managr",
"file_type": "concept", "source_file": "b.md"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 1
_RATIONALE_BOILER = ("Django app config for apps.platform.cards. No business "
"logic here. Domain services live in services.py.")
@pytest.mark.parametrize("a,b", [
# #296 narrowed the document/rationale rows here to their boundary case: an
# identical label on two nodes that are provably NOT their files' own nodes
# now merges (that is the bug the concept-only gate was causing). What must
# still never merge is a file's OWN node, so these two rows now carry the
# ids the file nodes themselves would be minted with.
({"id": "docs_a", "label": "Getting Started Installation Guide",
"file_type": "document", "source_file": "docs/a.md"},
{"id": "docs_b", "label": "Getting Started Installation Guide",
"file_type": "document", "source_file": "docs/b.md"}),
({"id": "apps_platform_cards_apps", "label": _RATIONALE_BOILER,
"file_type": "rationale", "source_file": "apps/platform/cards/apps.py"},
{"id": "apps_platform_cores_apps", "label": _RATIONALE_BOILER,
"file_type": "rationale", "source_file": "apps/platform/cores/apps.py"}),
({"id": "backend_a_render_frame", "label": "render_frame",
"file_type": "code", "source_file": "backend_a.py"},
{"id": "backend_b_render_frame", "label": "render_frame",
"file_type": "code", "source_file": "backend_b.py"}),
({"id": "web_logo", "label": "logo.png",
"file_type": "image", "source_file": "web/assets/logo.png"},
{"id": "docs_logo", "label": "logo.png",
"file_type": "image", "source_file": "docs/img/logo.png"}),
({"id": "logo_concept", "label": "logo.png",
"file_type": "concept", "source_file": "doc1.md"},
{"id": "logo_image", "label": "logo.png",
"file_type": "image", "source_file": "assets/logo.png"}),
({"id": "shenzhen_a", "label": "Shenzhen International",
"file_type": "concept", "source_file": ""},
{"id": "shenzhen_b", "label": "Shenzhen International",
"file_type": "concept", "source_file": ""}),
({"id": "api_a", "label": "API",
"file_type": "concept", "source_file": "doc1.md"},
{"id": "api_b", "label": "API",
"file_type": "concept", "source_file": "doc2.md"}),
], ids=["document-own-file-node", "rationale-own-file-node", "code",
"image-basename", "concept-image-mixed", "empty-source-file",
"low-entropy-concept"])
def test_crossfile_identical_labels_stay_distinct_for_guarded_types(a, b):
"""The #2182 cross-file merge requires provenance and high entropy on BOTH
sides, and #296 widened its type gate only as far as nodes provably not
their file's own node. Identical labels must still NOT merge for: a
document/rationale node that IS its file's own node (#296, the boundary of
#1284's file-anchored guard), code (#1205), images sharing a basename in
different dirs, mixed concept+image pairs, provenance-less nodes (#1178),
and low-entropy generic labels."""
result_nodes, _ = deduplicate_entities([dict(a), dict(b)], [], communities={})
assert len(result_nodes) == 2, (
f"guarded pair ({a['id']}, {b['id']}) was merged — #2182 fix leaked "
f"past its concept-only gate"
)
def test_cross_repo_guard_still_raises():
"""The cross-repo guard is untouched by #2182: identical concepts from
different repos must still raise, never merge."""
nodes = [
{"id": "c1", "label": "Shenzhen International", "file_type": "concept",
"source_file": "doc1.md", "repo": "repo-a"},
{"id": "c2", "label": "Shenzhen International", "file_type": "concept",
"source_file": "doc2.md", "repo": "repo-b"},
]
with pytest.raises(ValueError, match="multiple repos"):
deduplicate_entities(nodes, [], communities={})
def test_crossfile_concept_merge_is_order_independent():
"""Three identical-norm concepts across three files: every input order must
yield the same single survivor (#2182). Winner ids differ in length so
_pick_winner has a unique minimum."""
import itertools
base = [
{"id": "shenzhen", "label": "SHENZHEN INTERNATIONAL",
"file_type": "concept", "source_file": "doc1.md"},
{"id": "shenzhen_intl", "label": "Shenzhen international",
"file_type": "concept", "source_file": "doc2.md"},
{"id": "shenzhen_international", "label": "shenzhen-international",
"file_type": "concept", "source_file": "doc3.md"},
]
survivors = set()
for perm in itertools.permutations(base):
out, _ = deduplicate_entities([dict(n) for n in perm], [], communities={})
assert len(out) == 1
survivors.add(out[0]["id"])
assert survivors == {"shenzhen"}, f"non-deterministic survivor: {survivors}"
def test_crossfile_concept_merge_deterministic_across_hash_seeds():
"""#2182 determinism, #1753/#2074 precedent: the survivor must not depend on
PYTHONHASHSEED. pytest fixes the seed per process, so run out-of-process
with shuffled input."""
import os
import subprocess
import sys
script = (
"import random, sys\n"
"from graphify.dedup import deduplicate_entities\n"
"nodes = [\n"
" {'id': 'shenzhen', 'label': 'SHENZHEN INTERNATIONAL',\n"
" 'file_type': 'concept', 'source_file': 'doc1.md'},\n"
" {'id': 'shenzhen_intl', 'label': 'Shenzhen international',\n"
" 'file_type': 'concept', 'source_file': 'doc2.md'},\n"
" {'id': 'shenzhen_international', 'label': 'shenzhen-international',\n"
" 'file_type': 'concept', 'source_file': 'doc3.md'},\n"
"]\n"
"random.Random(int(sys.argv[1])).shuffle(nodes)\n"
"out, _ = deduplicate_entities(nodes, [], communities={})\n"
"print(len(out), sorted(n['id'] for n in out)[0])\n"
)
results = set()
for seed in ("0", "1", "2", "3"):
env = {**os.environ, "PYTHONHASHSEED": seed}
r = subprocess.run(
[sys.executable, "-c", script, seed],
capture_output=True, text=True, env=env,
)
assert r.returncode == 0, r.stderr
results.add(r.stdout.strip().splitlines()[-1])
assert results == {"1 shenzhen"}, (
f"non-deterministic dedup across hash seeds: {results}"
)
def test_crossfile_concept_merge_is_transitive():
"""Exact cross-file matches and a punctuation variant collapse to one
survivor: {'Acme Corp' doc1, 'Acme Corp' doc2, 'Acme Corp.' doc3} all
normalize to 'acme corp' and must transitively union (#2182)."""
nodes = [
{"id": "acme_corp_one", "label": "Acme Corp",
"file_type": "concept", "source_file": "doc1.md"},
{"id": "acme_corp_two", "label": "Acme Corp",
"file_type": "concept", "source_file": "doc2.md"},
{"id": "acme_corp_three", "label": "Acme Corp.",
"file_type": "concept", "source_file": "doc3.md"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 1
# ── #2576: same-file labels differing by a content-word swap ──────────────────
# Guard adopted from @wilyan09007's PR #2587, hardened: any same-position
# distinct-content-word pair blocks (not just exactly one), and the differing
# tokens are judged by _same_word_variant instead of bare token-level JW.
def test_dedup_does_not_merge_samefile_sibling_pipeline_stages():
"""Sibling sections of one document, named from a template, share both
affixes and differ in a single content word, so Jaro-Winkler's prefix bonus
clears the threshold on the same-file path (92.74 with the bonus, 87.90 on
plain Jaro). The absorbed node's edges are re-pointed at the survivor, so
the graph asserts relationships the document never states (#2576)."""
src = "docs/pipeline.md"
nodes = [
{"id": "pipeline_asset_contribution_flow", "label": "Asset Contribution Flow",
"file_type": "concept", "source_file": src},
{"id": "pipeline_asset_consumption_flow", "label": "Asset Consumption Flow",
"file_type": "concept", "source_file": src},
{"id": "pipeline_producer_personas", "label": "Producer Personas",
"file_type": "concept", "source_file": src},
{"id": "pipeline_asset_review_flow", "label": "Asset Review & Approval Flow",
"file_type": "concept", "source_file": src},
]
edges = [
{"source": "pipeline_producer_personas",
"target": "pipeline_asset_contribution_flow", "relation": "references"},
{"source": "pipeline_asset_contribution_flow",
"target": "pipeline_asset_review_flow", "relation": "references"},
]
result_nodes, result_edges = deduplicate_entities(nodes, edges, communities={})
assert len(result_nodes) == 4, (
"contribution and consumption are distinct pipeline stages"
)
assert {(e["source"], e["target"]) for e in result_edges} == {
("pipeline_producer_personas", "pipeline_asset_contribution_flow"),
("pipeline_asset_contribution_flow", "pipeline_asset_review_flow"),
}, "edges must stay on the nodes the document actually connects"
def test_dedup_still_merges_samefile_stopword_insertion():
"""The pair #1243 was scoped around: swapping a function word is how a
restatement of the same entity differs, so a stopword in either differing
position exempts the pair and the merge still happens (#2576)."""
nodes = [
{"id": "m1", "file_type": "concept", "source_file": "docs/metrics.md",
"label": "Counts-only metrics export, a read-only aggregation service"},
{"id": "m2", "file_type": "concept", "source_file": "docs/metrics.md",
"label": "Counts-only metrics export, the read-only aggregation service"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 1
def test_dedup_still_merges_samefile_normalization_variants():
"""Punct/case variants norm-equal in one file (pass 1), and a fused
camelCase symbol vs its spaced spelling (token counts differ, so the
#2576 guard defers to whole-label scoring), both still merge."""
nodes = [
{"id": "am1", "label": "Authentication Manager",
"file_type": "concept", "source_file": "docs/auth.md"},
{"id": "am2", "label": "authentication-manager",
"file_type": "concept", "source_file": "docs/auth.md"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 1
nodes = [
{"id": "g1", "label": "getUserById",
"file_type": "concept", "source_file": "docs/api.md"},
{"id": "g2", "label": "get user by id",
"file_type": "concept", "source_file": "docs/api.md"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 1
def test_dedup_still_merges_samefile_typo_in_one_token():
"""A typo inside the differing token leaves it the same word: manager and
managr score 97.14 against each other, so the pair is not a content-word
swap and still merges (#2576)."""
nodes = [
{"id": "a1", "label": "Authentication Manager",
"file_type": "concept", "source_file": "docs/auth.md"},
{"id": "a2", "label": "Authentication Managr",
"file_type": "concept", "source_file": "docs/auth.md"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 1
def test_dedup_still_merges_single_token_transposition_at_length_boundary():
"""A single-token label at the 12-char _short_label_blocked boundary with a
trailing transposition is a typo: same length, Damerau-Levenshtein 1, so
_same_word_variant exempts it and the merge survives (#2576)."""
nodes = [
{"id": "gb1", "label": "GraphBuilder",
"file_type": "concept", "source_file": "docs/build.md"},
{"id": "gb2", "label": "GraphBuildre",
"file_type": "concept", "source_file": "docs/build.md"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 1
def test_dedup_recovers_samefile_first_letter_typo():
"""DELTA over #2587: token-level JW scores manager/nanager at 84.92 (the
prefix bonus is zero at position 0), so the PR's bare-JW token test would
have blocked a genuine typo. The same-length Damerau-Levenshtein<=1 branch
of _same_word_variant reads it as one word and the merge happens (#2576).
The label carries a third token so the pair clears MinHash/LSH candidacy
(the two-token pair's shingle Jaccard sits at 0.727, on the 0.7 blocking
threshold, and never reaches the comparator either way)."""
nodes = [
{"id": "a1", "label": "Authentication Session Manager",
"file_type": "concept", "source_file": "docs/auth.md"},
{"id": "a2", "label": "Authentication Session Nanager",
"file_type": "concept", "source_file": "docs/auth.md"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 1
def test_dedup_does_not_merge_samefile_short_content_word_swap():
"""DELTA over #2587: pane/plane scores 94.0 on token-level JW, above the
merge threshold, so the PR's bare-JW token test read two distinct short
words as a typo. Tokens under 6 chars never pass the JW fallback (#2576)."""
nodes = [
{"id": "p1", "label": "User Profile Pane",
"file_type": "concept", "source_file": "docs/ui.md"},
{"id": "p2", "label": "User Profile Plane",
"file_type": "concept", "source_file": "docs/ui.md"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 2
def test_dedup_does_not_merge_two_position_content_swap():
"""DELTA over #2587: the PR guard required exactly one differing token, so
a sibling pair that also drifts in a second position (Flow vs Flows) slid
back to whole-label JW (95.44) and merged. Any same-position distinct
content-word pair now blocks (#2576)."""
nodes = [
{"id": "h1", "label": "Customer Asset Contribution Flow Handler",
"file_type": "concept", "source_file": "docs/handlers.md"},
{"id": "h2", "label": "Customer Asset Consumption Flows Handler",
"file_type": "concept", "source_file": "docs/handlers.md"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 2
def test_dedup_does_not_merge_samefile_sibling_packages():
"""#1243's own first example, with the source file its repro used: two
dependencies both come from one package.json, so the cross-file scoring
change never applied to them and they still merged (#2576)."""
nodes = [
{"id": "jest_native", "label": "@testing-library/jest-native",
"file_type": "concept", "source_file": "package.json"},
{"id": "react_native", "label": "@testing-library/react-native",
"file_type": "concept", "source_file": "package.json"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 2
def test_dedup_does_not_merge_same_community_dated_doc_slugs():
"""#1243's fourth example, with the communities its repro used: the pair is
cross-file and scores 87.68 on plain Jaro, but both nodes sit in one
community and the +5 boost lifts it back to 92.68. Comparing the differing
tokens (fe/mob) blocks it before the boost applies (#2576)."""
nodes = [
{"id": "fe_doc", "label": "2026-05-17-phase4-fe-api-integration.md",
"file_type": "concept", "source_file": "a.md"},
{"id": "mob_doc", "label": "2026-05-17-phase4-mob-api-integration.md",
"file_type": "concept", "source_file": "b.md"},
]
result_nodes, _ = deduplicate_entities(
nodes, [], communities={"fe_doc": 1, "mob_doc": 1}
)
assert len(result_nodes) == 2
def test_content_token_swap_helper():
"""_content_token_swap fires when any same-position pair is a swap of two
distinct content words (#2576)."""
from graphify.dedup import _content_token_swap
assert _content_token_swap("asset contribution flow", "asset consumption flow")
assert _content_token_swap("testing library jest native",
"testing library react native")
# A function word is what a restatement swaps, so either side exempts.
assert not _content_token_swap("export a read only", "export the read only")
# A typo leaves the differing token the same word.
assert not _content_token_swap("graph extractor", "graph extractar")
# DELTA over #2587: two differing content-word positions block too (the PR
# exempted anything but exactly one).
assert _content_token_swap("alpha beta gamma", "alpha delta epsilon")
assert _content_token_swap("customer asset contribution flow handler",
"customer asset consumption flows handler")
# Differing token counts belong to the prefix-extension guard.
assert not _content_token_swap("graph extractor", "graph extractor service")
assert not _content_token_swap("asset flow", "asset flow")
def test_same_word_variant_helper():
"""_same_word_variant separates one-word misspellings from two words
(#2576 DELTA over #2587's bare token-JW test)."""
from graphify.dedup import _same_word_variant
assert _same_word_variant("manager", "managr") # JW 97.14, min len 6
assert _same_word_variant("manager", "nanager") # JW 84.92 but same-len DL 1
assert _same_word_variant("builder", "buildre") # trailing transposition
assert not _same_word_variant("pane", "plane") # JW 94.0 but short
assert not _same_word_variant("flow", "flows") # short inflection: under-merge
assert not _same_word_variant("contribution", "consumption")
assert not _same_word_variant("jest", "react")
# Accepted trade: a length-differing 5-char spelling variant reads as two
# words and stays unmerged, per the never-merge-distinct-entities bar.
assert not _same_word_variant("colour", "color")
# -- #296: cross-file merge for entity nodes typed by their file's extension --
#
# Reported shape: per-file extraction over a note vault mints one node per
# mention of the same person/project, and they never merge. Normalization was
# never the problem -- `_norm` already buckets the variants together -- the
# Pass 1 cross-file union was gated to `file_type == "concept"`, and an entity
# pulled out of a `.md` note inherits `document` from the file's extension, not
# from anything about the entity. The @cyrilXBT variant family below is the
# reported corpus's shape, reduced to the spellings that occur in it.
_CYRIL_VARIANTS = ["@cyrilXBT", "@cyrilxbt", "cyrilXBT"]
def test_reads_as_file_entity_helper():
"""A file's own node, a stamped `page`/`heading` node, and any node missing
an id or provenance all stay treated as file-anchored (#296). The own-node
half is a reconstruction and holds for every stored-path spelling; the
structural half rests on the producer stamping `node_kind`."""
from graphify.dedup import _reads_as_file_entity
# An entity extracted from a note: `<path>_<entity>` never equals the path.
assert _reads_as_file_entity(
{"id": "journal_2024_03_01_cyrilxbt", "label": "@cyrilXBT",
"source_file": "journal/2024-03-01.md"})
# The file's own node, in each spelling a stored source_file may take.
assert not _reads_as_file_entity(
{"id": "journal_2024_03_01", "source_file": "journal/2024-03-01.md"})
assert not _reads_as_file_entity(
{"id": "2024_03_01", "source_file": "journal/2024-03-01.md"}) # pre-#1504 bare stem
assert not _reads_as_file_entity(
{"id": "vault_journal_2024_03_01", "source_file": 'C:\\vault\\journal\\2024-03-01.md'})
# The markdown extractor states it outright, for the file and its sections.
assert not _reads_as_file_entity(
{"id": "anything", "node_kind": "page", "source_file": "docs/a.md"})
assert not _reads_as_file_entity(
{"id": "docs_a_decisions", "node_kind": "heading",
"label": "Decisions", "source_file": "docs/a.md"})
# Unprovable -- no id, or no provenance.
assert not _reads_as_file_entity({"id": "", "source_file": "docs/a.md"})
assert not _reads_as_file_entity({"id": "docs_a_thing", "source_file": ""})
def test_dedup_merges_crossfile_document_entity_variants():
"""Unstamped document nodes that share a label stay one node per file.
The exact pass calls the same cross-file block as the fuzzy pass (#3094).
Three notes no longer collapse to one node.
"""
nodes = [
{"id": "journal_2024_03_0%d_cyrilxbt" % i, "label": variant,
"file_type": "document", "source_file": "journal/2024-03-0%d.md" % i}
for i, variant in enumerate(_CYRIL_VARIANTS, start=1)
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 3
def test_dedup_merges_crossfile_rationale_entity_variants():
"""Unstamped rationale nodes that share a label stay one node per file (#3094)."""
nodes = [
{"id": "svc_alpha_py_retention_window", "label": "Retention Window",
"file_type": "rationale", "source_file": "svc/alpha.py"},
{"id": "svc_beta_py_retention_window", "label": "retention window",
"file_type": "rationale", "source_file": "svc/beta.py"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 2
def test_dedup_never_merges_a_files_own_node_away():
"""The boundary the widened gate is defined against (#296): a curated note
whose OWN node carries the entity's label must survive as its own node, even
when a same-label entity node exists in another file."""
nodes = [
# The curated page: its id is exactly the slugified source path.
{"id": "people_cyrilxbt", "label": "@cyrilXBT",
"file_type": "document", "source_file": "people/@cyrilXBT.md"},
# A mention of the same entity, extracted from a journal note.
{"id": "journal_2024_03_01_cyrilxbt", "label": "cyrilXBT",
"file_type": "document", "source_file": "journal/2024-03-01.md"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 2, (
"a file's own node merged away -- the #296 widening must never reach it"
)
assert {n["id"] for n in result_nodes} == {
"people_cyrilxbt", "journal_2024_03_01_cyrilxbt"}
def test_dedup_never_merges_two_files_own_nodes():
"""Two README.md in different folders are genuinely two documents (#1284's
original reasoning), and stay two under #296."""
nodes = [
{"id": "web_readme", "label": "README.md", "file_type": "document",
"node_kind": "page", "source_file": "web/README.md"},
{"id": "api_readme", "label": "README.md", "file_type": "document",
"node_kind": "page", "source_file": "api/README.md"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 2
def test_dedup_crossfile_entity_merge_keeps_the_entropy_gate():
"""Entropy guard untouched (#296): a short generic label stays distinct for
`document` entity nodes exactly as it does for `concept`."""
nodes = [
{"id": "docs_a_api", "label": "API", "file_type": "document",
"source_file": "docs/a.md"},
{"id": "docs_b_api", "label": "API", "file_type": "document",
"source_file": "docs/b.md"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 2
def test_dedup_crossfile_entity_merge_keeps_the_provenance_gate():
"""Provenance guard untouched (#296, #1178): without a source_file the node
cannot be proven to be an entity, so it stays out of the merge."""
nodes = [
{"id": "orphan_cyrilxbt", "label": "@cyrilXBT",
"file_type": "document", "source_file": ""},
{"id": "journal_2024_03_01_cyrilxbt", "label": "cyrilXBT",
"file_type": "document", "source_file": "journal/2024-03-01.md"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 2
def test_dedup_crossfile_fuzzy_fileanchored_block_is_untouched():
"""Pass 2 still refuses near-identical document labels in different files.
The exact pass now calls the same block (#3094). This pair is not an exact
label match, so only the fuzzy pass can see it, and the block keeps both nodes.
"""
nodes = [
{"id": "docs_a_guide", "label": "Getting Started Installation Guide",
"file_type": "document", "source_file": "docs/a.md"},
{"id": "docs_b_setup", "label": "Getting Started Installation Setup",
"file_type": "document", "source_file": "docs/b.md"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 2
def _doc(node_id, label, source_file, file_type="document"):
return {
"id": node_id,
"label": label,
"file_type": file_type,
"source_file": source_file,
"source_location": None,
}
def test_exact_pass_keeps_unstamped_documents_across_files(tmp_path, capsys):
"""The 0.9.66 script: five unstamped document nodes, three source files."""
from graphify.build import build_merge
extraction = {"nodes": [
_doc("a_decisions", "Decisions", "Sessions/session-A.md"),
_doc("b_decisions", "Decisions", "Sessions/session-B.md"),
_doc("c_decisions", "Decisions", "Sessions/session-C.md"),
_doc("a_open_items", "Open items", "Sessions/session-A.md"),
_doc("b_open_items", "Open items", "Sessions/session-B.md"),
], "edges": [], "hyperedges": [], "input_tokens": 0, "output_tokens": 0}
graph = build_merge(
[extraction],
graph_path=tmp_path / "missing.json",
dedup=True,
)
files = {data.get("source_file") for _, data in graph.nodes(data=True)}
assert graph.number_of_nodes() == 5
assert files == {
"Sessions/session-A.md",
"Sessions/session-B.md",
"Sessions/session-C.md",
}
assert "Deduplicated" not in capsys.readouterr().out
def test_exact_pass_still_merges_same_file_document_duplicate():
nodes = [
_doc("a1", "Decisions", "Sessions/session-A.md"),
_doc("a2", "Decisions", "Sessions/session-A.md"),
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 1
def test_exact_pass_still_merges_crossfile_concepts():
nodes = [
_doc("c1", "Decisions", "Sessions/session-A.md", file_type="concept"),
_doc("c2", "Decisions", "Sessions/session-B.md", file_type="concept"),
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 1
def test_exact_pass_merges_concepts_beside_a_blocked_document():
"""A document winner must not leave the two concept nodes unjoined."""
nodes = [
_doc("a", "Decisions", "Sessions/session-A.md"),
_doc("c1", "Decisions", "Sessions/session-B.md", file_type="concept"),
_doc("c2", "Decisions", "Sessions/session-C.md", file_type="concept"),
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
kinds = sorted(node["file_type"] for node in result_nodes)
ids = {node["id"] for node in result_nodes}
assert len(result_nodes) == 2
assert kinds == ["concept", "document"]
assert "a" in ids
def test_dedup_never_merges_repeated_headings_across_files():
"""#3094's verification case must keep holding under #296: sibling documents
that each carry the same `## Decisions` / `## Next steps` sections keep one
heading node per file, attributed to that file."""
nodes = [
{"id": "docs_%s_%s" % (doc, slug), "label": heading,
"file_type": "document", "node_kind": "heading",
"source_file": "docs/%s.md" % doc}
for doc in ("a", "b", "c")
for slug, heading in (("decisions", "Decisions"),
("next_steps", "Next steps"))
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert len(result_nodes) == 6, (
"repeated headings merged across files -- #296 must not reach section "
"nodes (#1284, #3094)"
)
assert {n["source_file"] for n in result_nodes} == {
"docs/a.md", "docs/b.md", "docs/c.md"}
def test_reads_as_file_entity_trusts_the_node_kind_stamp_only():
"""The documented limit of the structural half (#296): `node_kind` is what
marks a sub-file node, so an UNSTAMPED structural node reads as an entity.
Pinned deliberately — the fix for such a producer is to stamp `node_kind`,
and this test is what fails if the predicate is ever quietly changed to
guess instead."""
from graphify.dedup import _reads_as_file_entity
stamped = {"id": "book_xlsx_summary", "label": "Summary (sheet)",
"node_kind": "heading", "source_file": "book.xlsx"}
unstamped = dict(stamped)
del unstamped["node_kind"]
assert not _reads_as_file_entity(stamped)
assert _reads_as_file_entity(unstamped)
# ── #4281: same stem, different extension keeps both nodes ────────────────────
def test_same_stem_different_extension_keeps_both_nodes(capsys):
"""x.py and x.html share an extension-stripped stem; neither may be dropped."""
nodes = [
{"id": "firmware_lib_portal", "label": "portal.py",
"file_type": "code", "source_file": "firmware/lib/portal.py"},
{"id": "firmware_lib_portal", "label": "Brick provisioning portal (portal.html)",
"file_type": "document", "source_file": "firmware/lib/portal.html"},
]
edges = [{"source": "firmware_lib_portal", "target": "firmware_lib_portal",
"relation": "serves", "source_file": "firmware/lib/portal.html"}]
result_nodes, result_edges = deduplicate_entities(nodes, edges, communities={})
ids = {n["source_file"]: n["id"] for n in result_nodes}
assert len(result_nodes) == 2
assert ids["firmware/lib/portal.py"] == "firmware_lib_portal"
assert ids["firmware/lib/portal.html"] == "firmware_lib_portal_html"
assert result_edges[0]["source"] == result_edges[0]["target"] == "firmware_lib_portal_html"
assert "WARNING" not in capsys.readouterr().err
def test_extension_rename_is_order_independent():
a = {"id": "x", "label": "x", "file_type": "code", "source_file": "x.py"}
b = {"id": "x", "label": "x page", "file_type": "document", "source_file": "x.html"}
n1, _ = deduplicate_entities([dict(a), dict(b)], [], communities={})
n2, _ = deduplicate_entities([dict(b), dict(a)], [], communities={})
assert {(n["source_file"], n["id"]) for n in n1} == {(n["source_file"], n["id"]) for n in n2}
def test_bare_id_across_different_paths_is_path_qualified(capsys):
"""A bare `protocol` shared by docs/protocol.md and docs/firmware/protocol.md,
with no protocol.md anywhere: neither file is named by the bare id, so both are
definers and each gets its own path-qualified id (#4281 remaining case).
No own-file `references` edge points at either copy here, so there is no
evidence that one of them is a mention of the other file, and both are renamed.
With that edge the same ids stay one node (a mention is not a second document):
see test_reference_to_another_file_does_not_become_a_second_document."""
nodes = [
{"id": "protocol", "label": "protocol", "file_type": "document",
"source_file": "docs/protocol.md"},
{"id": "protocol", "label": "protocol (top-level)", "file_type": "document",
"source_file": "docs/firmware/protocol.md"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert {n["id"] for n in result_nodes} == {"docs_protocol", "docs_firmware_protocol"}
assert "minted by two different files" not in capsys.readouterr().err
def test_bare_id_naming_an_existing_file_is_not_renamed(capsys):
"""`protocol.md` exists, so a bare `protocol` may be a genuine reference to it:
the group is not path-qualified and takes the definer-wins collapse path."""
nodes = [
{"id": "protocol", "label": "protocol", "file_type": "document",
"source_file": "protocol.md"},
{"id": "protocol", "label": "protocol (docs)", "file_type": "document",
"source_file": "docs/protocol.md"},
{"id": "protocol", "label": "protocol (firmware)", "file_type": "document",
"source_file": "docs/firmware/protocol.md"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert [(n["id"], n["source_file"]) for n in result_nodes] == [("protocol", "protocol.md")]
assert "WARNING" in capsys.readouterr().err
def test_build_with_absolute_edge_paths_moves_edges_to_renamed_node(tmp_path):
"""Semantic edges reach dedup with an ABSOLUTE source_file while nodes are already
root-relative; the renamed node must still receive its edges (#4281)."""
from graphify.build import build
root = tmp_path
py = str(root / "firmware" / "lib" / "portal.py")
html = str(root / "firmware" / "lib" / "portal.html")
ext = {
"nodes": [
{"id": "firmware_lib_portal", "label": "portal.py", "file_type": "code",
"source_file": py},
{"id": "firmware_lib_portal", "label": "Portal page", "file_type": "document",
"source_file": html},
{"id": "card_a", "label": "Card A", "file_type": "document", "source_file": html},
],
"edges": [
{"source": "firmware_lib_portal", "target": "card_a", "relation": "references",
"confidence": "EXTRACTED", "source_file": html},
],
}
G = build([ext], root=str(root))
assert "firmware_lib_portal" in G and "firmware_lib_portal_html" in G
assert G.has_edge("firmware_lib_portal_html", "card_a")
assert not G.has_edge("firmware_lib_portal", "card_a")
# ── combining marks in normalization ─────────────────────────────────────────
# `_norm` used `[\W_]+` as its separator class. Combining marks (Mn/Mc: vowel
# signs, viramas) are not `\w`, so they were deleted as if they were
# punctuation, and distinct Indic-script words collapsed to one key.
def test_norm_keeps_combining_marks():
from graphify.dedup import _norm
assert _norm("हिंदी") == "हिंदी"
assert _norm("தமிழ்") == "தமிழ்"
# Marks are kept, separators still collapse.
assert _norm("हिंदी - भाषा_नोट") == "हिंदी भाषा नोट"
def test_norm_still_separates_punctuation_for_latin():
from graphify.dedup import _norm
assert _norm(" Graph--Extractor__v2! ") == "graph extractor v2"
# NFKC still composes a decomposed accent into the letter, mark included.
assert _norm("Cafe\u0301 Menu") == "caf\u00e9 menu"
def test_distinct_indic_words_are_not_exact_merged():
"""कि / की / के differ only by vowel sign; they used to share the key 'क'."""
nodes = [
{"id": "h1", "label": "कि", "source_file": "a.md", "file_type": "concept"},
{"id": "h2", "label": "की", "source_file": "a.md", "file_type": "concept"},
{"id": "h3", "label": "के", "source_file": "a.md", "file_type": "concept"},
]
out, _ = deduplicate_entities(nodes, [], communities={})
assert sorted(n["id"] for n in out) == ["h1", "h2", "h3"]
def test_identical_indic_labels_still_exact_merge():
nodes = [
{"id": "h1", "label": "हिंदी भाषा", "source_file": "a.md", "file_type": "concept"},
{"id": "h2", "label": "हिंदी भाषा", "source_file": "a.md", "file_type": "concept"},
]
out, _ = deduplicate_entities(nodes, [], communities={})
assert len(out) == 1
def test_build_from_subdirectory_moves_edges_to_renamed_node(tmp_path, monkeypatch):
"""Nodes are root-relative by dedup time; run from a subdirectory of the root, a
relative path must not be resolved against the CWD, or the renamed node's key
stops matching its edges' and they stay on the survivor (#4281)."""
from graphify.build import build
root = tmp_path
(root / "firmware").mkdir()
monkeypatch.chdir(root / "firmware")
py = str(root / "firmware" / "lib" / "portal.py")
html = str(root / "firmware" / "lib" / "portal.html")
ext = {
"nodes": [
{"id": "firmware_lib_portal", "label": "portal.py", "file_type": "code",
"source_file": py},
{"id": "firmware_lib_portal", "label": "Portal page", "file_type": "document",
"source_file": html},
{"id": "card_a", "label": "Card A", "file_type": "document", "source_file": html},
],
"edges": [
{"source": "firmware_lib_portal", "target": "card_a", "relation": "references",
"confidence": "EXTRACTED", "source_file": html},
],
}
G = build([ext], root=str(root))
assert "firmware_lib_portal" in G and "firmware_lib_portal_html" in G
assert G.has_edge("firmware_lib_portal_html", "card_a")
assert not G.has_edge("firmware_lib_portal", "card_a")
# ── #4281: path-dropped doc ids (a/x.md vs b/x.md both mint `x`) ──────────────
def _path_dropped_input():
"""Two docs whose semantic ids dropped the directory: both mint `x`/`x_intro`."""
nodes = [
{"id": "x", "label": "X A", "file_type": "document", "source_file": "a/x.md"},
{"id": "x_intro", "label": "Intro A", "file_type": "document",
"source_file": "a/x.md"},
{"id": "x", "label": "X B", "file_type": "document", "source_file": "b/x.md"},
{"id": "x_intro", "label": "Intro B", "file_type": "document",
"source_file": "b/x.md"},
]
edges = [
{"source": "x", "target": "x_intro", "relation": "references",
"confidence": "EXTRACTED", "source_file": "a/x.md"},
{"source": "x", "target": "x_intro", "relation": "references",
"confidence": "EXTRACTED", "source_file": "b/x.md"},
]
return nodes, edges
def test_path_dropped_doc_id_keeps_both_files(capsys):
"""a/x.md and b/x.md both mint `x`; neither may be dropped, and each file's
edge stays inside its own file (#4281)."""
nodes, edges = _path_dropped_input()
result_nodes, result_edges = deduplicate_entities(nodes, edges, communities={})
assert len(result_nodes) == 4
assert {n["id"] for n in result_nodes} == {"a_x", "a_x_intro", "b_x", "b_x_intro"}
by_file = {e["source_file"]: (e["source"], e["target"]) for e in result_edges}
assert by_file == {"a/x.md": ("a_x", "a_x_intro"), "b/x.md": ("b_x", "b_x_intro")}
assert "minted by two different files" not in capsys.readouterr().err
def test_path_dropped_rename_is_order_independent():
"""Guards determinism of the new pass: reversed input must give the same result.
Expected to pass pre-fix too, since the old collapse is deterministic as well."""
nodes, edges = _path_dropped_input()
n1, e1 = deduplicate_entities([dict(n) for n in nodes], [dict(e) for e in edges],
communities={})
n2, e2 = deduplicate_entities([dict(n) for n in reversed(nodes)],
[dict(e) for e in reversed(edges)], communities={})
assert {(n["source_file"], n["id"]) for n in n1} == {(n["source_file"], n["id"]) for n in n2}
assert {(e["source_file"], e["source"], e["target"]) for e in e1} == \
{(e["source_file"], e["source"], e["target"]) for e in e2}
def test_path_dropped_foreign_reference_is_not_guessed(capsys):
"""c.md points at the bare `x`. It cannot be attributed to a/x.md or b/x.md, so
it is left unresolved with one warning rather than guessed (#4281)."""
nodes, edges = _path_dropped_input()
nodes.append({"id": "c_note", "label": "Note C", "file_type": "document",
"source_file": "c.md"})
edges.append({"source": "c_note", "target": "x", "relation": "references",
"confidence": "INFERRED", "source_file": "c.md"})
result_nodes, result_edges = deduplicate_entities(nodes, edges, communities={})
assert {n["id"] for n in result_nodes} == {
"a_x", "a_x_intro", "b_x", "b_x_intro", "c_note"}
foreign = next(e for e in result_edges if e["source_file"] == "c.md")
assert foreign["target"] == "x"
warning = ("[graphify] WARNING: node 'x' was minted by several files and renamed "
"per file; 1 reference(s) from other files could not be attributed "
"and were left unresolved.")
assert capsys.readouterr().err.count(warning) == 1
@pytest.mark.parametrize("a, b, leaked", [
("/home/u/a/x.md", "/home/u/b/x.md", "home_u"),
("C:\\r\\a\\x.md", "C:\\r\\b\\x.md", "c_r"),
])
def test_path_dropped_absolute_paths_are_not_qualified(a, b, leaked, capsys):
"""With no root to relativize against, a stored absolute path (POSIX or
Windows) must not be baked into an id: the checkout location would leak into
persisted ids. Such files stay on the collapse-and-warn path (#4281)."""
nodes = [
{"id": "x", "label": "X A", "file_type": "document", "source_file": a},
{"id": "x", "label": "X B", "file_type": "document", "source_file": b},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert not any(leaked in n["id"] for n in result_nodes)
assert "minted by two different files" in capsys.readouterr().err
def test_single_path_dropped_id_left_alone():
"""A lone path-dropped id has no collision; build_from_json's remap handles it."""
nodes = [
{"id": "x", "label": "X A", "file_type": "document", "source_file": "a/x.md"},
{"id": "other", "label": "Other", "file_type": "document", "source_file": "a/y.md"},
]
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
assert {n["id"] for n in result_nodes} == {"x", "other"}
def test_path_dropped_through_build(tmp_path):
"""The full build path (dedup runs before build_from_json) keeps both files'
nodes and edges, with absolute semantic paths as real extractions carry (#4281)."""
from graphify.build import build
root = tmp_path
a = str(root / "a" / "x.md")
b = str(root / "b" / "x.md")
ext = {
"nodes": [
{"id": "x", "label": "X A", "file_type": "document", "source_file": a},
{"id": "x_intro", "label": "Intro A", "file_type": "document", "source_file": a},
{"id": "x", "label": "X B", "file_type": "document", "source_file": b},
{"id": "x_intro", "label": "Intro B", "file_type": "document", "source_file": b},
],
"edges": [
{"source": "x", "target": "x_intro", "relation": "references",
"confidence": "EXTRACTED", "source_file": a},
{"source": "x", "target": "x_intro", "relation": "references",
"confidence": "EXTRACTED", "source_file": b},
],
}
G = build([ext], root=str(root))
assert set(G.nodes) == {"a_x", "a_x_intro", "b_x", "b_x_intro"}
assert G.has_edge("a_x", "a_x_intro")
assert G.has_edge("b_x", "b_x_intro")
assert not G.has_edge("a_x", "b_x_intro")
assert not G.has_edge("b_x", "a_x_intro")
def test_path_dropped_rebuild_is_idempotent():
"""Round-tripping a built graph through build() again must not keep renaming
the fix's own output (#4281)."""
import copy
from networkx.readwrite import json_graph
from graphify.build import build
nodes, edges = _path_dropped_input()
G1 = build([copy.deepcopy({"nodes": nodes, "edges": edges})])
G2 = build([json_graph.node_link_data(G1, edges="edges")])
expected = ["a_x", "a_x_intro", "b_x", "b_x_intro"]
assert sorted(G1.nodes) == sorted(G2.nodes) == expected
assert {frozenset(e) for e in G2.edges} == {
frozenset(("a_x", "a_x_intro")), frozenset(("b_x", "b_x_intro"))}
def test_path_dropped_id_stays_qualified_after_collision_disappears():
"""Once b/x.md leaves the corpus there is no collision, but a/x.md must keep
`a_x` rather than flip back to the bare `x` (#4281)."""
from graphify.build import build
nodes, edges = _path_dropped_input()
only_a = {"nodes": [n for n in nodes if n["source_file"] == "a/x.md"],
"edges": [e for e in edges if e["source_file"] == "a/x.md"]}
G = build([only_a])
assert sorted(G.nodes) == ["a_x", "a_x_intro"]
assert G.has_edge("a_x", "a_x_intro")
def test_path_dropped_foreign_reference_edge_is_dropped_by_build(capsys):
"""c.md's reference to the bare `x` cannot be attributed, so build() ends up
with no node `x` and drops the edge, and the warning says so (#4281)."""
from graphify.build import build
nodes, edges = _path_dropped_input()
nodes.append({"id": "c_note", "label": "Note C", "file_type": "document",
"source_file": "c.md"})
edges.append({"source": "c_note", "target": "x", "relation": "references",
"confidence": "INFERRED", "source_file": "c.md"})
G = build([{"nodes": nodes, "edges": edges}])
assert sorted(G.nodes) == ["a_x", "a_x_intro", "b_x", "b_x_intro", "c_note"]
assert G.degree("c_note") == 0
assert not G.has_edge("c_note", "a_x")
assert not G.has_edge("c_note", "b_x")
assert ("node 'x' was minted by several files and renamed per file; "
"1 reference(s)") in capsys.readouterr().err
# ── #4281: a same-file reference is a mention, not a second document ──────────
# Repro cases (notes 03-repro): D -> test_path_dropped_through_build,
# F -> test_path_dropped_foreign_reference_edge_is_dropped_by_build,
# R, R_labelswap, R_noedge, R_toplevel, G -> the tests below.
def _mention_node(nid, label, source_file):
return {"id": nid, "label": label, "file_type": "document", "source_file": source_file}
def _mention_edge(source, target, source_file):
return {"source": source, "target": target, "relation": "references",
"confidence": "EXTRACTED", "source_file": source_file}
def _protocol_reference_input(*, edge=True, docs_label="Protocol",
mention_label="protocol (top-level)", top_level=False):
"""docs/firmware/protocol.md mints its own `docs_firmware_protocol` plus a bare
`protocol` that stands for another file; docs/protocol.md mints `protocol`."""
fw = "docs/firmware/protocol.md"
nodes = [_mention_node("protocol", docs_label, "docs/protocol.md"),
_mention_node("docs_firmware_protocol", "Firmware protocol", fw),
_mention_node("protocol", mention_label, fw)]
if top_level:
nodes.append(_mention_node("protocol", "Protocol (top-level)", "protocol.md"))
edges = [_mention_edge("docs_firmware_protocol", "protocol", fw)] if edge else []
return {"nodes": nodes, "edges": edges}
def _assert_reference_lands_on_the_other_file(G):
assert sorted(G.nodes) == ["docs_firmware_protocol", "docs_protocol"]
# one edge, from the firmware doc to docs/protocol.md; a self-loop would collapse
# to a one-element frozenset and fail this comparison.
assert {frozenset(e) for e in G.edges} == {
frozenset(("docs_firmware_protocol", "docs_protocol"))}
def test_reference_to_another_file_does_not_become_a_second_document():
"""Case R. A bare id that another node of its own file `references`, and that is
itself a path slice (`protocol`), is a mention of a document: it is not renamed
into the mentioning file's own page, so the reference ends on docs/protocol.md.
Renaming it made `docs_firmware_protocol` reference itself (#4281)."""
from graphify.build import build
_assert_reference_lands_on_the_other_file(build([_protocol_reference_input()]))
def test_reference_outcome_does_not_depend_on_label_length():
"""Case R_labelswap. Same graph as above with the labels swapped, so the mention
has the shorter one. v8 kept the shorter label and dropped docs/protocol.md here."""
from graphify.build import build
G = build([_protocol_reference_input(docs_label="Protocol overview", mention_label="protocol")])
_assert_reference_lands_on_the_other_file(G)
def test_mention_without_an_edge_gives_the_same_two_documents():
"""Case R_noedge. With no `references` edge there is no evidence the bare id is a
mention, so both files are renamed per file; the result is still the two real
documents and no edge. The evidence only ever withholds a rename."""
from graphify.build import build
G = build([_protocol_reference_input(edge=False)])
assert sorted(G.nodes) == ["docs_firmware_protocol", "docs_protocol"]
assert list(G.edges) == []
def test_top_level_collision_keeps_todays_result():
"""Case R_toplevel. KNOWN LIMITATION, pinned so a change is noticed: with a
top-level protocol.md that also mints `protocol`, the three files collide and the
label tiebreak drops the top-level document's node. This is a separate genuine
collision from the mention rule and is not fixed here (#4281)."""
from graphify.build import build
G = build([_protocol_reference_input(top_level=True)])
assert sorted(G.nodes) == ["docs_firmware_protocol", "docs_protocol"]
assert {frozenset(e) for e in G.edges} == {
frozenset(("docs_firmware_protocol", "docs_protocol"))}
def test_entity_described_in_two_documents_collapses_to_one_node(capsys):
"""Case G, the shape of the reporter's warnings. `sender` is derived from neither
path and each document points at it, so the two copies are one entity: one node
with both edges, and the merge warning still prints (#4281)."""
from graphify.build import build
ext = {
"nodes": [_mention_node("docs_a_doc", "Doc A", "docs/a.md"),
_mention_node("sender", "Sender service", "docs/a.md"),
_mention_node("docs_b_doc", "Doc B", "docs/b.md"),
_mention_node("sender", "Sender", "docs/b.md")],
"edges": [_mention_edge("docs_a_doc", "sender", "docs/a.md"),
_mention_edge("docs_b_doc", "sender", "docs/b.md")],
}
G = build([ext])
assert sorted(G.nodes) == ["docs_a_doc", "docs_b_doc", "sender"]
assert {frozenset(e) for e in G.edges} == {
frozenset(("docs_a_doc", "sender")), frozenset(("docs_b_doc", "sender"))}
assert "node 'sender' is minted by two different files" in capsys.readouterr().err
def test_reference_evidence_does_not_change_survivors_of_other_collisions():
"""The mention evidence is gated to ids that are themselves a path slice. `x_intro`
is derived from a/x.md (so a/x.md defines it and wins the collision) but is not a
slice. Both copies are the target of an own-file `references` edge. Applied to every
id, that evidence would demote a/x.md's copy and let the shorter label from c.md
win. The survivor must be what v8 picks: a/x.md's node (#4281)."""
nodes = [_mention_node("x", "X", "a/x.md"),
_mention_node("x_intro", "Introduction to X", "a/x.md"),
_mention_node("c_doc", "Doc C", "c.md"),
_mention_node("x_intro", "Intro", "c.md")]
edges = [_mention_edge("x", "x_intro", "a/x.md"),
_mention_edge("c_doc", "x_intro", "c.md")]
kept, _ = deduplicate_entities(nodes, edges, communities={})
survivor = next(n for n in kept if n["id"] == "x_intro")
assert (survivor["label"], survivor["source_file"]) == ("Introduction to X", "a/x.md")