Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion docs/negative_evidence.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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.
Expand Down
94 changes: 94 additions & 0 deletions tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
49 changes: 49 additions & 0 deletions tests/test_propagation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
)
55 changes: 50 additions & 5 deletions worldgraph/match.py
Original file line number Diff line number Diff line change
Expand Up @@ -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|).
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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 = (
Expand Down