Skip to content

fix(communities): bound the label_propagation loop so build_communities terminates - #1844

Open
Dhimmo wants to merge 1 commit into
getzep:mainfrom
Dhimmo:fix/label-propagation-max-iterations
Open

fix(communities): bound the label_propagation loop so build_communities terminates#1844
Dhimmo wants to merge 1 commit into
getzep:mainfrom
Dhimmo:fix/label-propagation-max-iterations

Conversation

@Dhimmo

@Dhimmo Dhimmo commented Sep 8, 2026

Copy link
Copy Markdown

What's the problem

label_propagation loops while True and exits only on convergence — but label propagation isn't guaranteed to converge. On balanced graph shapes the assignments oscillate indefinitely and the loop never returns.

This isn't theoretical. On a ~1.2k-entity / 649-edge graph, build_communities ran CPU-bound for over four hours producing nothing before I killed it by hand. Because the call is synchronous and CPU-bound it also blocks the event loop, so a client-side cancellation can't be processed while it spins.

Minimal reproduction

Two nodes are enough:

from graphiti_core.driver.operations.graph_utils import Neighbor, label_propagation

# hangs forever on main
label_propagation({
    'x': [Neighbor(node_uuid='y', edge_count=2)],
    'y': [Neighbor(node_uuid='x', edge_count=2)],
})

With edge_count > 1, candidate_rank > 1 holds, so each node adopts its neighbour's community rather than falling back to the monotonic max(candidate, curr) tie-break — and the two labels swap on every pass, forever. A balanced triangle (three nodes, all edge_count=2) behaves the same way: no plurality winner exists, so nothing ever settles.

The fix

Bound the loop at one relabelling pass per node, with a floor of 10 for small graphs. A run that hasn't settled by then is oscillating rather than converging slowly, so we log a warning and return the partition reached so far.

Label propagation is a heuristic, not an exact algorithm, so a partition from a truncated run is a legitimate result — spinning forever is not. The convergence exit path (no_change) is untouched, so graphs that do settle behave exactly as before.

Both copies of the algorithm are fixed:

  • graphiti_core/driver/operations/graph_utils.py — used by all four drivers
  • graphiti_core/utils/maintenance/community_operations.py — reachable from the public Graphiti.build_communities API via get_community_clusters

Tests

tests/test_label_propagation_termination.py, parametrised over both implementations:

  • the two non-converging shapes terminate within a timeout
  • a converging graph still exits via no_change, with no warning logged
  • the warning fires when the cap is reached

Against unpatched main the termination tests hang indefinitely; with this change the suite completes in about 0.1s. ruff format and ruff check are clean.

Notes

Happy to adjust the budget heuristic or make the cap configurable if you'd prefer that shape. I've been running an equivalent fix locally against 0.29.2 and 0.30.1.

…es terminates

`label_propagation` loops `while True` and exits only on convergence, but label
propagation is not guaranteed to converge. On balanced graph shapes the
assignments oscillate indefinitely and the loop never returns.

This is not theoretical. On a ~1.2k-entity / 649-edge graph, `build_communities`
ran CPU-bound for over four hours producing nothing before it was killed by
hand. Because the call is synchronous and CPU-bound, it also blocks the event
loop, so a client-side cancellation cannot be processed while it spins.

The minimal reproduction is two nodes: with `edge_count > 1`, `candidate_rank > 1`
holds, so each node adopts its neighbour's community rather than falling back to
the monotonic `max(candidate, curr)` tie-break, and the two labels swap on every
pass forever. A balanced triangle behaves the same way — no plurality winner
exists, so nothing ever settles.

Bound the loop at one relabelling pass per node (floor of 10 for small graphs).
A run that has not settled by then is oscillating rather than converging slowly,
so we log a warning and return the partition reached so far. Label propagation
is a heuristic, not an exact algorithm, so a partition from a truncated run is a
legitimate result; spinning forever is not. The convergence exit path
(`no_change`) is untouched, so graphs that do settle are unaffected.

Both copies of the algorithm are fixed: the one in
`driver/operations/graph_utils.py` used by all four drivers, and the second in
`utils/maintenance/community_operations.py`, which is reachable from the public
`Graphiti.build_communities` API via `get_community_clusters`.

Tests cover both implementations: the two non-converging shapes terminate within
a timeout, a converging graph still exits via `no_change` without warning, and
the warning fires when the cap is reached. Against unpatched `main` the
termination tests hang indefinitely; with this change the suite completes in
about 0.1s.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zep-cla-assistant

Copy link
Copy Markdown
Contributor


Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution. For privacy information, see our Privacy Notice. You can sign the CLA by just posting a Pull Request Comment same as the below format.


I have read the CLA Document and I hereby sign the CLA behalf on myself, e-mail: example@example.com

or

I have read the CLA Document and I hereby sign the CLA behalf of my company, e-mail: example@example.com

Signature is valid for 6 months.


This bot will be retriggered when the Contributor License Agreement comment has been provided. Posted by the CLA Assistant Lite bot.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant