You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
On the gemini backend's current default model, cluster-only --backend gemini regularly names only the first 3 of 16 communities, silently keeps label_communities_by_hub filenames for the other 13, prints Done - 16 communities, and exits 0. Measured 6 of 12 runs on the same graph.
The API reports the problem correctly — finish_reason='length' — but _call_llm returns resp.choices[0].message.content and discards it. Every downstream recovery mechanism is then defeated in turn, and the run is reported as a success.
Verified on 0.9.63, and llm.py + cluster.py are byte-identical at v0.9.64; the cli.py merge block at v0.9.64:2265-2272 is unchanged.
Why it is hard to notice
This is the failure mode #2534 was about — a silent downgrade with a success exit code — but the hub labeler makes this instance much quieter than the ones fixed there. The un-labeled state is no longer a conspicuous Community 7; it is a plausible-looking filename. A half-labeled report reads as a slightly disappointing report, not a broken one.
GRAPH_REPORT.md from a bad run (13 of 16 are hub fallbacks, but nothing says so):
A good run on the identical graph names all 16: Testing Infrastructure, Snapshot Serialization, Hono Web Framework, Ledger Migration, ...
Root cause
_label_batch_with_retry budgets min(256 + 48 * n, 8192) output tokens — here 1024 for 16 communities — sized by the comment above it for "a 2-5 word name is ~10 tokens" plus preamble headroom. That predates the backend default moving to a reasoning model. BACKENDS["gemini"]["default_model"] is now gemini-3-flash-preview with reasoning_effort: "low", and reasoning tokens draw down the same completion budget.
Measured directly against the endpoint, with the exact prompt _community_label_lines builds and max_completion_tokens=1024:
Worth noting on its own: this same path already declares a much larger budget for this backend. BACKENDS["gemini"]["max_completion_tokens"] is 16384, and the extraction path honours it via _resolve_max_tokens(cfg.get("max_completion_tokens") or ...). _call_llm reads cfg for base_url, temperature, reasoning_effort and extra_body, but takes the completion cap from its own max_tokens argument and never looks at cfg["max_completion_tokens"] — so the labeling path sends 1024 to a model whose own entry says 16384.
Then five layers each behave reasonably in isolation and compound:
_call_llm drops the signal. It returns resp.choices[0].message.content only. finish_reason is right there on resp.choices[0] and is discarded — while _call_claude, _call_claude_cli and _call_bedrock all set result["finish_reason"] = "length" if ... else "stop" for the extraction path. The labeling path is the one that throws it away.
generate_community_labels reports success. It returns source="llm", and warns only on no-backend or a raised exception. A partial dict is indistinguishable from a complete one to its caller.
cli.py merges without counting.labels.update({cid: v for cid, v in generated_labels.items() if ...}) correctly declines to clobber hub labels with placeholders, but nothing compares len(generated_labels) to len(communities). generated_labels appears exactly twice in cli.py and neither use is a count.
Related: the docstring on _thinking_disabled_via_env justifies leaving thinking on by arguing that a reasoning model's truncation "is caught and re-tried by the adaptive extraction/labeling retry, so it is a rare, recoverable failure" (#1621). For the labeling path that assumption does not hold — step 2 above means the retry does not see it. The reasoning that keeps the default in place depends on a recovery that is bypassed.
Reproducer (deterministic, offline, no API key)
The field failure is intermittent because it depends on how many reasoning tokens the model spends. The defect itself is not — stub the backend with a truncated reply and it is fully deterministic:
importnetworkxasnxfromunittest.mockimportpatchimportgraphify.llmasllmG=nx.Graph(); communities= {}
forcidinrange(16):
members= [f"c{cid}_n{i}"foriinrange(4)]
forminmembers: G.add_node(m, label=m)
G.add_edges_from([(members[0], members[i]) foriin (1, 2, 3)])
communities[cid] =members# A real reply, cut off mid-object at the completion cap.TRUNCATED= ('{"0": "Testing Infrastructure", "1": "Request Body Parsing", ''"2": "Object Store Operations", "3": "End-to-')
withpatch.object(llm, "_call_llm", return_value=TRUNCATED):
labels, source=llm.generate_community_labels(G, communities, backend="gemini")
named= {c: vforc, vinlabels.items() ifnotv.startswith("Community ")}
print(len(named), "of", len(communities), "labeled; source =", source)
3 of 16 labeled; source = 'llm'
No exception, no warning on stderr, source="llm".
Measurements
Same graph (537 nodes / 1628 edges / 16 communities, from a 76-file TypeScript+SQL repo), repeated cluster-only --backend gemini, labels file inspected each run:
config
runs
fully labeled
3-of-16
output tokens
default
12
6
6
133-167 good / 39 bad
GRAPHIFY_MAX_OUTPUT_TOKENS=16384
5
5
0
133-167
The bimodality is the tell: a bad run is always exactly 39 output tokens and exactly 3 labels, because the reply dies at the same point every time.
Workaround
GRAPHIFY_MAX_OUTPUT_TOKENS=16384 — 5 of 5 clean above. It is a blunt global override, but it is the only lever a user has here, since the label budget is not otherwise configurable.
Let the label budget see the backend config, e.g. floor it at cfg.get("max_completion_tokens"), or scale the per-community allowance when the resolved model is a reasoning model. Sending 1024 to a model whose own entry says 16384 looks unintended.
Re-ask for the missing cids. Even without (2), generated_labels missing cids is a sufficient trigger for one narrowed retry before falling back.
Happy to open a PR for (1) and (2) if that is useful — (1) is a few lines and self-contained.
Summary
On the
geminibackend's current default model,cluster-only --backend geminiregularly names only the first 3 of 16 communities, silently keepslabel_communities_by_hubfilenames for the other 13, printsDone - 16 communities, and exits 0. Measured 6 of 12 runs on the same graph.The API reports the problem correctly —
finish_reason='length'— but_call_llmreturnsresp.choices[0].message.contentand discards it. Every downstream recovery mechanism is then defeated in turn, and the run is reported as a success.Verified on 0.9.63, and
llm.py+cluster.pyare byte-identical at v0.9.64; thecli.pymerge block at v0.9.64:2265-2272 is unchanged.Why it is hard to notice
This is the failure mode #2534 was about — a silent downgrade with a success exit code — but the hub labeler makes this instance much quieter than the ones fixed there. The un-labeled state is no longer a conspicuous
Community 7; it is a plausible-looking filename. A half-labeled report reads as a slightly disappointing report, not a broken one.GRAPH_REPORT.mdfrom a bad run (13 of 16 are hub fallbacks, but nothing says so):A good run on the identical graph names all 16:
Testing Infrastructure,Snapshot Serialization,Hono Web Framework,Ledger Migration, ...Root cause
_label_batch_with_retrybudgetsmin(256 + 48 * n, 8192)output tokens — here 1024 for 16 communities — sized by the comment above it for "a 2-5 word name is ~10 tokens" plus preamble headroom. That predates the backend default moving to a reasoning model.BACKENDS["gemini"]["default_model"]is nowgemini-3-flash-previewwithreasoning_effort: "low", and reasoning tokens draw down the same completion budget.Measured directly against the endpoint, with the exact prompt
_community_label_linesbuilds andmax_completion_tokens=1024:Worth noting on its own: this same path already declares a much larger budget for this backend.
BACKENDS["gemini"]["max_completion_tokens"]is16384, and the extraction path honours it via_resolve_max_tokens(cfg.get("max_completion_tokens") or ...)._call_llmreadscfgforbase_url,temperature,reasoning_effortandextra_body, but takes the completion cap from its ownmax_tokensargument and never looks atcfg["max_completion_tokens"]— so the labeling path sends 1024 to a model whose own entry says 16384.Then five layers each behave reasonably in isolation and compound:
_call_llmdrops the signal. It returnsresp.choices[0].message.contentonly.finish_reasonis right there onresp.choices[0]and is discarded — while_call_claude,_call_claude_cliand_call_bedrockall setresult["finish_reason"] = "length" if ... else "stop"for the extraction path. The labeling path is the one that throws it away._parse_label_responsefailsjson.loadson the cut-off object, then recovers the complete"cid": "name"pairs (cluster-only labels fails: Expecting value: line 1 column 6 (char 5) #1690). It returns 3 pairs and does not raise._label_batch_with_retryonly splits and retriesexcept (json.JSONDecodeError, ValueError). Because step 2 returned cleanly, the mechanism built precisely to recover truncation is skipped. The two fixes defeat each other: cluster-only labels fails: Expecting value: line 1 column 6 (char 5) #1690 (salvage partial) pre-emptscluster-onlyskips labeling batch on JSON parse error without retry or chunk split (inconsistent withextract) #1278 (split and retry), and salvaging 3 of 16 is much worse here than re-asking for 8 and 8.generate_community_labelsreports success. It returnssource="llm", and warns only on no-backend or a raised exception. A partial dict is indistinguishable from a complete one to its caller.cli.pymerges without counting.labels.update({cid: v for cid, v in generated_labels.items() if ...})correctly declines to clobber hub labels with placeholders, but nothing compareslen(generated_labels)tolen(communities).generated_labelsappears exactly twice incli.pyand neither use is a count.Related: the docstring on
_thinking_disabled_via_envjustifies leaving thinking on by arguing that a reasoning model's truncation "is caught and re-tried by the adaptive extraction/labeling retry, so it is a rare, recoverable failure" (#1621). For the labeling path that assumption does not hold — step 2 above means the retry does not see it. The reasoning that keeps the default in place depends on a recovery that is bypassed.Reproducer (deterministic, offline, no API key)
The field failure is intermittent because it depends on how many reasoning tokens the model spends. The defect itself is not — stub the backend with a truncated reply and it is fully deterministic:
No exception, no warning on stderr,
source="llm".Measurements
Same graph (537 nodes / 1628 edges / 16 communities, from a 76-file TypeScript+SQL repo), repeated
cluster-only --backend gemini, labels file inspected each run:GRAPHIFY_MAX_OUTPUT_TOKENS=16384The bimodality is the tell: a bad run is always exactly 39 output tokens and exactly 3 labels, because the reply dies at the same point every time.
Workaround
GRAPHIFY_MAX_OUTPUT_TOKENS=16384— 5 of 5 clean above. It is a blunt global override, but it is the only lever a user has here, since the label budget is not otherwise configurable.Suggested fixes
In rough order of value:
cli.py, comparelen(generated_labels)againstlen(label_communities_input)and warn when short —warning: labeled 3 of 16 communities; 13 kept structural fallback names. Re-run cluster-only, or raise GRAPHIFY_MAX_OUTPUT_TOKENS.This alone converts a silent wrong answer into a visible one, and matches what Four silent failures with success exit codes: cluster-only ignores --backend, label prompt collides with theCommunity {cid}sentinel,tree --root <abs>flattens the hierarchy, built_at_commit stamped from cwd #2534 established for this class.finish_reasonin_call_llm. It is already modelled on the other three backends. With it,_label_batch_with_retrycan treatlengthas a truncation and split-retry even when salvage recovered some pairs — which is whatcluster-onlyskips labeling batch on JSON parse error without retry or chunk split (inconsistent withextract) #1278 was for.cfg.get("max_completion_tokens"), or scale the per-community allowance when the resolved model is a reasoning model. Sending 1024 to a model whose own entry says 16384 looks unintended.generated_labelsmissing cids is a sufficient trigger for one narrowed retry before falling back.Happy to open a PR for (1) and (2) if that is useful — (1) is a few lines and self-contained.
Environment
uv tool install "graphifyy[gemini,sql]"), labeling code verified unchanged at v0.9.64gemini, default modelgemini-3-flash-preview, via the OpenAI-compatible endpoint--code-onlyextract, so the only LLM call in the run is community labeling