feat: parallel community labeling via --max-concurrency / --batch-size (#1390)

label_communities ran batches one LLM call at a time, so a large graph needed
hundreds of sequential calls even on backends that allow heavy concurrency. It
now fans batches out across a thread pool, mirroring extract_corpus_parallel:
results are returned per batch and merged on the main thread (labels dict is
never mutated concurrently, no lock), and workers==1 keeps the original
sequential path verbatim. ollama and claude-cli are forced serial unless the
matching GRAPHIFY_*_PARALLEL env opt-in is set (same guard as extract).

generate_community_labels threads max_concurrency + batch_size through, and the
cluster-only/label CLI parses --max-concurrency and --batch-size (both `--flag N`
and `--flag=N` forms; the space form is parsed explicitly so the value is not
mistaken for the positional scan path by the arg-walk's catch-all).

Output is deterministic regardless of concurrency (keyed by community id). Tests:
parallel == sequential result, batch-size controls batch count, batches actually
run concurrently, ollama forced serial, and the CLI parses both new flags. Full
suite 2393 passed; ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-06-24 13:53:33 +01:00
co-authored by Claude Opus 4.8
parent 1a14e94e53
commit 22a58ffc20
4 changed files with 162 additions and 20 deletions
+92 -2
View File
@@ -81,9 +81,12 @@ def test_label_cli_passes_model_override(tmp_path, monkeypatch):
captured = {}
def fake_generate(G, communities, *, backend=None, model=None, gods=None, quiet=False):
def fake_generate(G, communities, *, backend=None, model=None, gods=None,
quiet=False, max_concurrency=4, batch_size=100):
captured["backend"] = backend
captured["model"] = model
captured["max_concurrency"] = max_concurrency
captured["batch_size"] = batch_size
return {0: "Orders"}, "llm"
monkeypatch.setattr("graphify.llm.generate_community_labels", fake_generate)
@@ -99,13 +102,22 @@ def test_label_cli_passes_model_override(tmp_path, monkeypatch):
"gemini",
"--model",
"gemini-3.1-flash-lite",
"--max-concurrency",
"8",
"--batch-size",
"50",
"--no-viz",
],
)
cli.main()
assert captured == {"backend": "gemini", "model": "gemini-3.1-flash-lite"}
# Also verifies the space-separated forms parse (the value must not be mistaken
# for the positional path) and reach generate_community_labels.
assert captured == {
"backend": "gemini", "model": "gemini-3.1-flash-lite",
"max_concurrency": 8, "batch_size": 50,
}
def test_label_communities_partial_reply_fills_placeholder(monkeypatch):
@@ -278,3 +290,81 @@ def test_label_communities_max_communities_caps_total(monkeypatch):
label_communities(G, communities, backend="gemini", max_communities=40, batch_size=100)
# Only 40 communities should have been sent to the backend.
assert len(captured_cids) == 40
# --- #1390: parallel labeling (--max-concurrency) + --batch-size --------------
import threading
import time as _time
def _many_communities(n):
G = nx.Graph()
comms = {}
for i in range(n):
nid = f"n{i}"
G.add_node(nid, label=f"sym_{i}")
comms[i] = [nid]
return G, comms
def test_label_communities_parallel_matches_sequential(monkeypatch):
"""Concurrency must not change the result: same cid->name map either way."""
G, communities = _many_communities(6)
def fake_batch(batch_cids, batch_lines, *, backend, model=None):
return {cid: f"name-{cid}" for cid in batch_cids}
monkeypatch.setattr("graphify.llm._label_batch_with_retry", fake_batch)
seq = label_communities(G, communities, backend="gemini", batch_size=1, max_concurrency=1)
par = label_communities(G, communities, backend="gemini", batch_size=1, max_concurrency=4)
assert seq == par == {i: f"name-{i}" for i in range(6)}
def test_label_communities_batch_size_controls_batch_count(monkeypatch):
G, communities = _many_communities(5)
calls = []
def fake_batch(batch_cids, batch_lines, *, backend, model=None):
calls.append(list(batch_cids))
return {cid: f"n-{cid}" for cid in batch_cids}
monkeypatch.setattr("graphify.llm._label_batch_with_retry", fake_batch)
labels = label_communities(G, communities, backend="gemini", batch_size=2, max_concurrency=1)
assert len(calls) == 3 # 5 communities / batch 2 -> 3 batches
assert sum(len(c) for c in calls) == 5
assert labels == {i: f"n-{i}" for i in range(5)}
def _peak_tracker():
lock = threading.Lock()
state = {"now": 0, "peak": 0}
def fake_batch(batch_cids, batch_lines, *, backend, model=None):
with lock:
state["now"] += 1
state["peak"] = max(state["peak"], state["now"])
_time.sleep(0.03)
with lock:
state["now"] -= 1
return {cid: f"n-{cid}" for cid in batch_cids}
return fake_batch, state
def test_label_communities_runs_batches_concurrently(monkeypatch):
G, communities = _many_communities(8)
fake_batch, state = _peak_tracker()
monkeypatch.setattr("graphify.llm._label_batch_with_retry", fake_batch)
label_communities(G, communities, backend="gemini", batch_size=1, max_concurrency=4)
assert state["peak"] > 1, "batches should run in parallel with max_concurrency>1"
def test_label_communities_forces_serial_for_ollama(monkeypatch):
"""ollama/claude-cli must stay serial regardless of --max-concurrency."""
G, communities = _many_communities(8)
fake_batch, state = _peak_tracker()
monkeypatch.setattr("graphify.llm._label_batch_with_retry", fake_batch)
monkeypatch.delenv("GRAPHIFY_OLLAMA_PARALLEL", raising=False)
label_communities(G, communities, backend="ollama", batch_size=1, max_concurrency=8)
assert state["peak"] == 1, "ollama must be forced serial"