diff --git a/docs/negative_evidence.md b/docs/negative_evidence.md index 8cabfec..9143216 100644 --- a/docs/negative_evidence.md +++ b/docs/negative_evidence.md @@ -68,7 +68,7 @@ We need negative evidence but cannot afford PARIS's brittleness. The key insight Positive and negative evidence are computed together in each propagation step, feeding into a single score per entity pair. For each pair `(a, b)`, we examine all neighbor pairs `(y, y')` connected via similar relations: - **Positive**: if the neighbor pair's confidence is above 0.5 (likely match), it contributes to `pos_strength`, weighted by inverse functionality — matching neighbors of a functional relation are strong evidence FOR the match. -- **Negative**: if the neighbor pair's confidence is below 0.5 (likely non-match), it contributes to `neg_strength`, weighted by forward functionality — a functional relation whose target doesn't match is evidence AGAINST the match. +- **Negative**: if the neighbor pair's confidence is below 0.5 (likely non-match), it contributes to `neg_strength`, weighted by forward functionality — **but only if neither neighbor has a better counterpart** (see "Best-counterpart gating" below). Both are aggregated via exp-sum and combined with the name-similarity seed: @@ -82,6 +82,18 @@ computed = seed + pos_agg × (1 - seed) - neg_agg × seed The seed serves as the baseline. Positive evidence pushes toward 1.0 (proportional to the room above seed), negative evidence pushes toward 0.0 (proportional to the seed itself). With no structural evidence, the score equals the seed. With strong negative evidence and no positive evidence, the score approaches zero. +### Best-counterpart gating + +A naive all-pairs approach to negative evidence generates a contribution for every low-confidence `(y, y')` cross-pair. This causes a cascade problem (GH issue #30): when entity A has neighbors X and Y connected via relation-similar edges, the cross-pair X↔Y' (which is legitimately low-confidence because X and Y are different entities) generates negative evidence even though X has a high-confidence counterpart X'. The negative evidence drags A's confidence below 0.5, and A then becomes negative evidence for its own neighbors — a self-reinforcing cascade that suppresses all same-name merges in the connected component. + +The fix: before accumulating negative evidence, a first pass finds the best counterpart confidence for each neighbor. A low-confidence pair `(y, y')` only contributes negative evidence if **both** `y` and `y'` lack a better alternative (best counterpart confidence ≤ 0.5). If either neighbor has a good match elsewhere, the low-confidence cross-pair is irrelevant — it reflects expected structural non-correspondence, not evidence of non-identity. + +This preserves negative evidence where it matters (a neighbor with NO good counterpart) while preventing cascade through well-matched neighbors. + +### Merged-neighbor deduplication + +After progressive merging, a single merged neighbor may appear in the canonical adjacency via multiple relation-similar edge entries (e.g. "is CEO of" and "is outgoing CEO of" — different strings, but embedding-similar). The propagation inner loop would count each entry independently, inflating positive evidence from the `ra == rb` (already-merged) case. Since a merged neighbor is a single structural fact regardless of how many surface-form edges survived adjacency dedup, the loop deduplicates by canonical neighbor ID: each merged neighbor contributes at most one positive-evidence entry. + ### The 0.5 threshold as a natural gate The threshold for contributing positive vs negative evidence is 0.5 — the point of maximum uncertainty. A neighbor pair with confidence 0.6 contributes weak positive evidence. One with confidence 0.1 contributes strong negative evidence. One at exactly 0.5 contributes nothing. diff --git a/tests/test_integration.py b/tests/test_integration.py index 2b6b490..c518ce6 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -368,3 +368,97 @@ def test_progressive_merging_no_cascading_false_merges(embedder): assert nt_group is not None and nt_a2.id in nt_group ql_group = _find_group_containing(groups, ql_b1.id) assert ql_group is not None and ql_b2.id in ql_group + + +# --------------------------------------------------------------------------- +# 5. Negative evidence cascade (GH issue #30) +# --------------------------------------------------------------------------- + + +def test_shared_employee_bridge_no_company_merge(embedder): + """A person who held the same role at two different companies should not + cause those companies' same-name merges to be suppressed. + + Teresa Nakamura was CFO of both Cascade Robotics and CloudScale Analytics. + The Cascade≠CloudScale cross-pairs generate negative evidence that cascades + through Nakamura's confidence, suppressing all same-name merges.""" + g1 = Graph(id="g1") + nakamura1 = g1.add_entity("Teresa Nakamura") + cascade1 = g1.add_entity("Cascade Robotics") + cloudscale1 = g1.add_entity("CloudScale Analytics") + g1.add_edge(nakamura1, cascade1, "is CFO of") + g1.add_edge(nakamura1, cloudscale1, "is CFO of") + + g2 = Graph(id="g2") + nakamura2 = g2.add_entity("Teresa Nakamura") + cascade2 = g2.add_entity("Cascade Robotics") + cloudscale2 = g2.add_entity("CloudScale Analytics") + g2.add_edge(nakamura2, cascade2, "is CFO of") + g2.add_edge(nakamura2, cloudscale2, "is CFO of") + + graphs = [g1, g2] + confidence = match_graphs(graphs, embedder) + groups, _ = build_match_groups(graphs, confidence) + + # All three same-name pairs should merge + cascade_group = _find_group_containing(groups, cascade1.id) + assert cascade_group is not None and cascade2.id in cascade_group, ( + "Cascade Robotics same-name merge suppressed by negative evidence cascade" + ) + cloudscale_group = _find_group_containing(groups, cloudscale1.id) + assert cloudscale_group is not None and cloudscale2.id in cloudscale_group, ( + "CloudScale Analytics same-name merge suppressed by negative evidence cascade" + ) + + # Companies must NOT merge with each other + cascade_ids = {cascade1.id, cascade2.id} + cloudscale_ids = {cloudscale1.id, cloudscale2.id} + for group in groups: + assert not (group & cascade_ids and group & cloudscale_ids), ( + "Cascade and CloudScale incorrectly merged" + ) + + +def test_regulator_and_regulated_entity_stay_separate(embedder): + """Two journalist outlets report on both a regulator (DPC) and a company + (Vantara AI) using similar relation phrases. The DPC≠Vantara cross-pairs + should not cascade to suppress same-name merges for the journalists.""" + g1 = Graph(id="g1") + dw1 = g1.add_entity("DataWatch EU") + dpc1 = g1.add_entity("Data Protection Commission") + vantara1 = g1.add_entity("Vantara AI") + g1.add_edge(dw1, dpc1, "reported on") + g1.add_edge(dw1, vantara1, "reported on") + + g2 = Graph(id="g2") + dw2 = g2.add_entity("DataWatch EU") + dpc2 = g2.add_entity("Data Protection Commission") + vantara2 = g2.add_entity("Vantara AI") + g2.add_edge(dw2, dpc2, "published report on") + g2.add_edge(dw2, vantara2, "published report on") + + graphs = [g1, g2] + confidence = match_graphs(graphs, embedder) + groups, _ = build_match_groups(graphs, confidence) + + # All same-name pairs should merge + dw_group = _find_group_containing(groups, dw1.id) + assert dw_group is not None and dw2.id in dw_group, ( + "DataWatch EU same-name merge suppressed by negative evidence cascade" + ) + dpc_group = _find_group_containing(groups, dpc1.id) + assert dpc_group is not None and dpc2.id in dpc_group, ( + "DPC same-name merge suppressed by negative evidence cascade" + ) + vantara_group = _find_group_containing(groups, vantara1.id) + assert vantara_group is not None and vantara2.id in vantara_group, ( + "Vantara AI same-name merge suppressed by negative evidence cascade" + ) + + # DPC and Vantara must NOT merge with each other + dpc_ids = {dpc1.id, dpc2.id} + vantara_ids = {vantara1.id, vantara2.id} + for group in groups: + assert not (group & dpc_ids and group & vantara_ids), ( + "DPC and Vantara AI incorrectly merged" + ) diff --git a/tests/test_propagation.py b/tests/test_propagation.py index 0065c7a..4636bec 100644 --- a/tests/test_propagation.py +++ b/tests/test_propagation.py @@ -820,3 +820,52 @@ def test_negative_evidence_does_not_over_penalize_structurally_matched_neighbors f"Negative evidence over-penalized Meridian: score={meridian_score:.3f} " f"(CEO structural match={ceo_score:.3f})" ) + + +# --------------------------------------------------------------------------- +# Negative evidence cascade (GH issue #30) +# --------------------------------------------------------------------------- + + +def test_predecessor_successor_at_same_company_no_match(embedder): + """CEO succession: outgoing and incoming CEOs both have CEO-type edges + to the same company. The cross-person pairs (Park₁↔Chen₂, Chen₁↔Park₂) + have zero confidence, but each person has a same-name counterpart with + confidence ~1.0. Negative evidence from the cross-person pairs must not + cascade through the shared company neighbor to suppress the legitimate + same-name merges. + + GH issue #30: the formula ``computed = seed + pos*(1-seed) - neg*seed`` + zeroes out positive evidence when seed=1.0, so Nextera's score drops from + the cross-person negative evidence alone. Nextera then falls below 0.5, + becoming negative evidence for the Park and Chen same-name pairs too.""" + g1 = Graph(id="g1") + park1 = g1.add_entity("David Park") + chen1 = g1.add_entity("Sarah Chen") + nextera1 = g1.add_entity("Nextera Energy Solutions") + g1.add_edge(park1, nextera1, "is CEO of") + g1.add_edge(chen1, nextera1, "is former CEO of") + + g2 = Graph(id="g2") + park2 = g2.add_entity("David Park") + chen2 = g2.add_entity("Sarah Chen") + nextera2 = g2.add_entity("Nextera Energy Solutions") + g2.add_edge(park2, nextera2, "is outgoing CEO of") + g2.add_edge(chen2, nextera2, "is CEO of") + + confidence = match_graphs([g1, g2], embedder) + + # Different people must not merge + assert confidence.get((park1.id, chen2.id), 0) < 0.5 + assert confidence.get((chen1.id, park2.id), 0) < 0.5 + + # Same-name pairs must not be suppressed by the cascade + assert confidence[(park1.id, park2.id)] >= 0.8, ( + f"David Park same-name merge suppressed: {confidence[(park1.id, park2.id)]:.3f}" + ) + assert confidence[(chen1.id, chen2.id)] >= 0.8, ( + f"Sarah Chen same-name merge suppressed: {confidence[(chen1.id, chen2.id)]:.3f}" + ) + assert confidence[(nextera1.id, nextera2.id)] >= 0.8, ( + f"Nextera same-name merge suppressed: {confidence[(nextera1.id, nextera2.id)]:.3f}" + ) diff --git a/worldgraph/match.py b/worldgraph/match.py index 8b36742..f3a0042 100644 --- a/worldgraph/match.py +++ b/worldgraph/match.py @@ -305,6 +305,16 @@ def propagate_similarity( toward 1.0, negative evidence pushes toward 0.0. With no structural evidence the fixpoint equals the seed. + Negative evidence is gated by best-counterpart matching: a low-confidence + neighbor pair only contributes negative evidence if neither neighbor has a + better alternative (confidence > 0.5). This prevents cascade where + irrelevant cross-pairs (e.g. Park₁↔Chen₂ when Park₁ already matches + Park₂) suppress same-name merges in the connected component. + + Merged-neighbor contributions (``ra == rb``) are deduplicated by + canonical neighbor ID so that multiple relation-similar edges to the + same merged entity count as a single structural fact. + On merge, the canonical adjacency for the new representative is built by combining and deduplicating the adjacency lists of the merged entities — O(degree) per merge, not O(|edges|). @@ -333,11 +343,39 @@ def propagate_similarity( pos_strength = 0.0 neg_strength = 0.0 - for nbr_a in canonical_adj.get(ca, []): + nbrs_a = canonical_adj.get(ca, []) + nbrs_b = canonical_adj.get(cb, []) + + # First pass: find the best counterpart confidence for each + # neighbor. A neighbor that has a good match (>0.5) elsewhere + # should not contribute negative evidence from a worse cross-pair. + best_for_a: dict[str, float] = {} + best_for_b: dict[str, float] = {} + for nbr_a in nbrs_a: ra = uf.find(nbr_a.entity_id) if ra == ca: continue - for nbr_b in canonical_adj.get(cb, []): + for nbr_b in nbrs_b: + rb = uf.find(nbr_b.entity_id) + if rb == cb: + continue + rs = rel_sim.get((nbr_a.relation, nbr_b.relation), 0.0) + if rs < rel_threshold: + continue + nc = 1.0 if ra == rb else prev.get((ra, rb), 0.0) + best_for_a[ra] = max(best_for_a.get(ra, 0.0), nc) + best_for_b[rb] = max(best_for_b.get(rb, 0.0), nc) + + # Second pass: accumulate positive and negative evidence. + # A merged neighbor (ra == rb) is one structural fact regardless + # of how many relation-similar edges survive adjacency dedup, so + # we count it at most once per canonical neighbor. + merged_pos_seen: set[str] = set() + for nbr_a in nbrs_a: + ra = uf.find(nbr_a.entity_id) + if ra == ca: + continue + for nbr_b in nbrs_b: rb = uf.find(nbr_b.entity_id) if rb == cb: continue @@ -346,8 +384,9 @@ def propagate_similarity( continue if ra == rb: - # Neighbors already merged — strongest positive evidence. - pos_strength += min(nbr_a.pos_weight, nbr_b.pos_weight) + if ra not in merged_pos_seen: + merged_pos_seen.add(ra) + pos_strength += min(nbr_a.pos_weight, nbr_b.pos_weight) continue nc = prev.get((ra, rb), 0.0) @@ -356,7 +395,13 @@ def propagate_similarity( neg_nc = 1.0 - nc if neg_nc > 0.5: - neg_strength += min(nbr_a.neg_weight, nbr_b.neg_weight) * neg_nc + # Only count negative evidence if neither neighbor has + # a better counterpart — prevents cascade from + # irrelevant cross-pairs (GH issue #30). + if best_for_a[ra] <= 0.5 and best_for_b[rb] <= 0.5: + neg_strength += ( + min(nbr_a.neg_weight, nbr_b.neg_weight) * neg_nc + ) # Exp-sum aggregation + seed-as-baseline combination. pos_agg = (