Skip to content

Commit 9676fc1

Browse files
authored
fix(entity-resolver): keep every co-occurrence pair when canonicalising order (#2750)
The pair canonicalisation in _link_units_to_entities_batch_impl swapped entity_id_1 and entity_id_2 in place, but entity_id_1 is the outer loop's iterate: for i, entity_id_1 in enumerate(entity_list): for entity_id_2 in entity_list[i + 1:]: if entity_id_1 > entity_id_2: entity_id_1, entity_id_2 = entity_id_2, entity_id_1 Once a swap happens, entity_id_1 stays swapped for the rest of that inner loop, so every later pair in the same outer iteration is built from the wrong first element. Those pairs collide with ones already emitted, so the effect is silently missing edges rather than wrong ones. entity_list comes from a set, so the ordering (and the bug) varies per run. Move the canonicalisation into a _canonical_cooccurrence_pairs() helper that orders each pair into fresh locals, leaving the iterate untouched, and cover it with order-pinned unit tests that need no database.
1 parent 73a5b57 commit 9676fc1

2 files changed

Lines changed: 65 additions & 15 deletions

File tree

hindsight-api-slim/hindsight_api/engine/entity_resolver.py

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import json
1010
import logging
1111
from collections import defaultdict
12+
from collections.abc import Iterator
1213
from dataclasses import dataclass, field
1314
from datetime import UTC, datetime
1415
from difflib import SequenceMatcher
@@ -75,6 +76,22 @@ def _later_date(a: datetime | None, b: datetime | None) -> datetime | None:
7576
return a if a > b else b
7677

7778

79+
def _canonical_cooccurrence_pairs(entity_list: list[str]) -> Iterator[tuple[str, str]]:
80+
"""Yield each distinct pair of ``entity_list`` as ``(a, b)`` with ``a < b``.
81+
82+
Canonical ordering matches the entity_cooccurrences PK and check constraint.
83+
The pair is ordered into fresh locals rather than by swapping the loop
84+
variables: ``entity_id_1`` is the outer iterate, so swapping it would leak
85+
into the remaining inner iterations and build later pairs off the wrong
86+
element.
87+
"""
88+
for i, entity_id_1 in enumerate(entity_list):
89+
for entity_id_2 in entity_list[i + 1 :]:
90+
if entity_id_1 == entity_id_2:
91+
continue
92+
yield (entity_id_1, entity_id_2) if entity_id_1 < entity_id_2 else (entity_id_2, entity_id_1)
93+
94+
7895
@dataclass
7996
class _CooccurrencePair:
8097
"""A (entity_id_1, entity_id_2) pair observed in a retain batch (for post-txn flush)."""
@@ -853,20 +870,12 @@ async def _link_units_to_entities_batch_impl(self, conn, unit_entity_pairs: list
853870
for unit_id, entity_ids in unit_to_entities.items():
854871
entity_list = list(entity_ids)
855872
event_date = unit_event_date.get(unit_id)
856-
for i, entity_id_1 in enumerate(entity_list):
857-
for entity_id_2 in entity_list[i + 1 :]:
858-
if entity_id_1 == entity_id_2:
859-
continue
860-
# Canonical ordering (entity_id_1 < entity_id_2) matches the
861-
# entity_cooccurrences PK and check constraint.
862-
if entity_id_1 > entity_id_2:
863-
entity_id_1, entity_id_2 = entity_id_2, entity_id_1
864-
key = (entity_id_1, entity_id_2)
865-
prev = cooccurrence_pairs.get(key, _SENTINEL_MISSING)
866-
if prev is _SENTINEL_MISSING:
867-
cooccurrence_pairs[key] = event_date
868-
else:
869-
cooccurrence_pairs[key] = _later_date(prev, event_date)
873+
for key in _canonical_cooccurrence_pairs(entity_list):
874+
prev = cooccurrence_pairs.get(key, _SENTINEL_MISSING)
875+
if prev is _SENTINEL_MISSING:
876+
cooccurrence_pairs[key] = event_date
877+
else:
878+
cooccurrence_pairs[key] = _later_date(prev, event_date)
870879

871880
# Accumulate co-occurrence pairs for post-transaction flush.
872881
# The actual INSERT/UPDATE is deferred to flush_pending_stats() to avoid

hindsight-api-slim/tests/test_entity_resolver.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Tests for EntityResolver edge cases.
33
"""
44

5+
import itertools
56
import json
67
import uuid
78
from datetime import datetime, timezone
@@ -11,7 +12,7 @@
1112

1213
from hindsight_api.engine.db import create_database_backend
1314
from hindsight_api.engine.db.result import DictResultRow as ResultRow
14-
from hindsight_api.engine.entity_resolver import EntityResolver
15+
from hindsight_api.engine.entity_resolver import EntityResolver, _canonical_cooccurrence_pairs
1516
from hindsight_api.pg0 import resolve_database_url
1617

1718
# ---------------------------------------------------------------------------
@@ -59,6 +60,46 @@ async def test_discard_pending_stats_does_not_affect_other_task_keys():
5960
assert other_key in resolver._pending_cooccurrences, "other task's cooccurrences must be preserved"
6061

6162

63+
# ---------------------------------------------------------------------------
64+
# Unit tests for _canonical_cooccurrence_pairs() — no database required
65+
# ---------------------------------------------------------------------------
66+
67+
68+
def test_canonical_cooccurrence_pairs_keeps_every_pair_when_input_is_unsorted():
69+
"""Every distinct pair must survive, whatever order the entities arrive in.
70+
71+
["C", "A", "B"] is the minimal witness: ordering the second pair used to
72+
swap the outer loop variable, so ("A", "C") was silently dropped.
73+
"""
74+
pairs = list(_canonical_cooccurrence_pairs(["C", "A", "B"]))
75+
76+
assert set(pairs) == {("A", "B"), ("A", "C"), ("B", "C")}
77+
assert len(pairs) == 3
78+
79+
80+
def test_canonical_cooccurrence_pairs_is_input_order_invariant():
81+
"""entity_list is built from a set, so its order is arbitrary per run: the
82+
emitted pairs must not depend on it."""
83+
expected = {("e1", "e2"), ("e1", "e3"), ("e1", "e4"), ("e2", "e3"), ("e2", "e4"), ("e3", "e4")}
84+
85+
for order in itertools.permutations(["e1", "e2", "e3", "e4"]):
86+
assert set(_canonical_cooccurrence_pairs(list(order))) == expected, f"lost a pair for input order {order}"
87+
88+
89+
def test_canonical_cooccurrence_pairs_emits_canonical_order():
90+
"""Each pair must come out as (a, b) with a < b, the ordering the
91+
entity_cooccurrences PK and check constraint require."""
92+
for order in itertools.permutations(["e1", "e2", "e3"]):
93+
for a, b in _canonical_cooccurrence_pairs(list(order)):
94+
assert a < b, f"non-canonical pair {(a, b)} for input order {order}"
95+
96+
97+
def test_canonical_cooccurrence_pairs_skips_duplicates_and_short_input():
98+
assert list(_canonical_cooccurrence_pairs(["e1", "e1"])) == []
99+
assert list(_canonical_cooccurrence_pairs(["e1"])) == []
100+
assert list(_canonical_cooccurrence_pairs([])) == []
101+
102+
62103
@pytest.mark.asyncio
63104
async def test_resolve_entities_batch_handles_unicode_lower_conflicts(pg0_db_url):
64105
"""

0 commit comments

Comments
 (0)