mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-13 02:47:00 +00:00
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:
@@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Feat: community labeling can now run in parallel (#1390). `graphify cluster-only` and `graphify label` accept `--max-concurrency N` (default 4) to fan labeling batches out across a thread pool, and `--batch-size N` (default 100) to tune communities per LLM call. A large graph that previously needed hundreds of sequential calls now runs them in rounds. Mirrors the existing `extract` parallelism, including the safety guards: `ollama` and `claude-cli` are forced serial (set `GRAPHIFY_OLLAMA_PARALLEL=1` / `GRAPHIFY_CLAUDE_CLI_PARALLEL=1` to override). Output is unchanged and deterministic regardless of concurrency, since results are keyed by community id and merged on the main thread.
|
||||
- Fix: `graphify reflect` no longer duplicates lines in the "known dead ends" and "corrections" sections when the same Q&A is saved more than once. Those lists were appended per memory doc with no key (node scoring already dedups by node, but these two did not); they now collapse by question, keeping the most recent entry — so a re-corrected question shows its latest correction. Output stays deterministic (ordered by date then question).
|
||||
- Fix: the work-memory loop no longer depends on the git hook. The skill now tells the agent to run `graphify reflect --if-stale` itself at the start of graph work (cheap, deterministic, a no-op when no outcomes have been saved), then read `LESSONS.md`. Previously a skill-only install (without `graphify hook install`) would keep recording outcomes via `save-result` but never regenerate `LESSONS.md`, so the lessons never surfaced. The post-commit hook is now an optimization for between-session freshness rather than a requirement. The new `--if-stale` flag skips the run when `LESSONS.md` is already newer than every input (the memory docs and the graph), so when the hook just refreshed it the agent's session-start run costs almost nothing.
|
||||
|
||||
|
||||
+16
-1
@@ -2227,9 +2227,13 @@ def main() -> None:
|
||||
print(" --no-label keep 'Community N' placeholders (skip LLM community naming)")
|
||||
print(" --backend=<name> backend to use for community naming (default: auto-detect)")
|
||||
print(" --model=<name> model to use for community naming")
|
||||
print(" --max-concurrency=N parallel community-labeling LLM calls (default 4; forced to 1 for ollama/claude-cli)")
|
||||
print(" --batch-size=N communities per labeling LLM call (default 100)")
|
||||
print(" label <path> (re)name communities with the configured LLM backend, regenerate report")
|
||||
print(" --backend=<name> backend to use (default: auto-detect from API keys)")
|
||||
print(" --model=<name> model to use for community naming")
|
||||
print(" --max-concurrency=N parallel labeling LLM calls (default 4; forced to 1 for ollama/claude-cli)")
|
||||
print(" --batch-size=N communities per labeling LLM call (default 100)")
|
||||
print(" query \"<question>\" BFS traversal of graph.json for a question")
|
||||
print(" --dfs use depth-first instead of breadth-first")
|
||||
print(" --context C explicit edge-context filter (repeatable)")
|
||||
@@ -3309,6 +3313,8 @@ def main() -> None:
|
||||
graph_override: Path | None = None
|
||||
co_resolution: float = 1.0
|
||||
co_exclude_hubs: float | None = None
|
||||
label_max_concurrency: int = 4
|
||||
label_batch_size: int = 100
|
||||
i_arg = 0
|
||||
while i_arg < len(args):
|
||||
a = args[i_arg]
|
||||
@@ -3330,6 +3336,14 @@ def main() -> None:
|
||||
co_exclude_hubs = float(args[i_arg + 1]); i_arg += 2
|
||||
elif a.startswith("--exclude-hubs="):
|
||||
co_exclude_hubs = float(a.split("=", 1)[1]); i_arg += 1
|
||||
elif a == "--max-concurrency" and i_arg + 1 < len(args):
|
||||
label_max_concurrency = int(args[i_arg + 1]); i_arg += 2
|
||||
elif a.startswith("--max-concurrency="):
|
||||
label_max_concurrency = int(a.split("=", 1)[1]); i_arg += 1
|
||||
elif a == "--batch-size" and i_arg + 1 < len(args):
|
||||
label_batch_size = int(args[i_arg + 1]); i_arg += 2
|
||||
elif a.startswith("--batch-size="):
|
||||
label_batch_size = int(a.split("=", 1)[1]); i_arg += 1
|
||||
elif a == "--no-viz" or a.startswith("--min-community-size="):
|
||||
i_arg += 1
|
||||
elif a.startswith("--"):
|
||||
@@ -3419,7 +3433,8 @@ def main() -> None:
|
||||
# The final labels (LLM or placeholder fallback) are persisted to
|
||||
# .graphify_labels.json by the unconditional write below.
|
||||
labels, _ = generate_community_labels(
|
||||
G, communities, backend=label_backend, model=label_model, gods=gods
|
||||
G, communities, backend=label_backend, model=label_model, gods=gods,
|
||||
max_concurrency=label_max_concurrency, batch_size=label_batch_size,
|
||||
)
|
||||
questions = suggest_questions(G, communities, labels)
|
||||
tokens = {"input": 0, "output": 0}
|
||||
|
||||
+53
-17
@@ -2230,6 +2230,7 @@ def label_communities(
|
||||
max_communities: int | None = None,
|
||||
top_k: int = _LABEL_TOP_K,
|
||||
batch_size: int = _LABEL_BATCH_SIZE,
|
||||
max_concurrency: int = 4,
|
||||
) -> dict[int, str]:
|
||||
"""Return a complete ``{cid: name}`` map using ``backend`` for naming.
|
||||
|
||||
@@ -2257,32 +2258,62 @@ def label_communities(
|
||||
return labels
|
||||
|
||||
n_batches = (len(labeled_cids) + batch_size - 1) // batch_size
|
||||
written = 0
|
||||
first_error: Exception | None = None
|
||||
for batch_idx in range(n_batches):
|
||||
|
||||
# Mirror extract_corpus_parallel's backend guards: Ollama serves one request at
|
||||
# a time per loaded model (parallel batches cause VRAM pressure and hollow
|
||||
# replies, #798) and claude-cli shells out to a single Claude Code session that
|
||||
# parallel subprocesses corrupt. Force serial for these unless the user opts in
|
||||
# via the same env switches.
|
||||
if backend == "ollama" and os.environ.get("GRAPHIFY_OLLAMA_PARALLEL", "").strip() != "1":
|
||||
max_concurrency = 1
|
||||
if backend == "claude-cli" and os.environ.get("GRAPHIFY_CLAUDE_CLI_PARALLEL", "").strip() != "1":
|
||||
max_concurrency = 1
|
||||
workers = max(1, min(max_concurrency, n_batches))
|
||||
|
||||
def _run_batch(batch_idx: int):
|
||||
start = batch_idx * batch_size
|
||||
end = min(start + batch_size, len(labeled_cids))
|
||||
batch_lines = lines[start:end]
|
||||
batch_cids = labeled_cids[start:end]
|
||||
try:
|
||||
parsed = _label_batch_with_retry(
|
||||
batch_cids, batch_lines, backend=backend, model=model,
|
||||
labeled_cids[start:end], lines[start:end], backend=backend, model=model,
|
||||
)
|
||||
labels.update(parsed)
|
||||
written += len(parsed)
|
||||
except Exception as exc:
|
||||
if first_error is None:
|
||||
first_error = exc
|
||||
return batch_idx, parsed, None
|
||||
except Exception as exc: # noqa: BLE001 - reported per-batch; surfaced below
|
||||
return batch_idx, None, exc
|
||||
|
||||
written = 0
|
||||
errors: dict[int, Exception] = {}
|
||||
|
||||
def _merge(batch_idx: int, parsed, exc) -> None:
|
||||
nonlocal written
|
||||
if exc is not None:
|
||||
errors[batch_idx] = exc
|
||||
start = batch_idx * batch_size
|
||||
end = min(start + batch_size, len(labeled_cids))
|
||||
print(
|
||||
f"[graphify label] batch {batch_idx + 1}/{n_batches} "
|
||||
f"({len(batch_cids)} communities) failed: {exc}",
|
||||
f"({end - start} communities) failed: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
return
|
||||
labels.update(parsed)
|
||||
written += len(parsed)
|
||||
|
||||
if written == 0 and first_error is not None:
|
||||
# Every batch failed; propagate so generate_community_labels degrades cleanly.
|
||||
raise first_error
|
||||
# Fan out batches; merge on the main thread so `labels` is never mutated
|
||||
# concurrently. workers == 1 keeps the original sequential path verbatim.
|
||||
if workers == 1:
|
||||
for batch_idx in range(n_batches):
|
||||
_merge(*_run_batch(batch_idx))
|
||||
else:
|
||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
futures = [pool.submit(_run_batch, b) for b in range(n_batches)]
|
||||
for future in as_completed(futures):
|
||||
_merge(*future.result())
|
||||
|
||||
if written == 0 and errors:
|
||||
# Every batch failed; propagate the lowest-index error so the message is
|
||||
# deterministic and generate_community_labels degrades cleanly.
|
||||
raise errors[min(errors)]
|
||||
return labels
|
||||
|
||||
|
||||
@@ -2294,6 +2325,8 @@ def generate_community_labels(
|
||||
model: str | None = None,
|
||||
gods=None,
|
||||
quiet: bool = False,
|
||||
max_concurrency: int = 4,
|
||||
batch_size: int = _LABEL_BATCH_SIZE,
|
||||
) -> tuple[dict[int, str], str]:
|
||||
"""CLI entry point: resolve a backend, name communities, and degrade to
|
||||
``Community N`` placeholders on any failure (no backend, API error, malformed
|
||||
@@ -2313,7 +2346,10 @@ def generate_community_labels(
|
||||
)
|
||||
return _placeholder_community_labels(communities), "placeholder"
|
||||
try:
|
||||
labels = label_communities(G, communities, backend=backend, model=model, gods=gods)
|
||||
labels = label_communities(
|
||||
G, communities, backend=backend, model=model, gods=gods,
|
||||
max_concurrency=max_concurrency, batch_size=batch_size,
|
||||
)
|
||||
return labels, "llm"
|
||||
except Exception as exc:
|
||||
if not quiet:
|
||||
|
||||
+92
-2
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user