Skip to content

Commit c8e66a0

Browse files
committed
fix: command hardening — crash guards, empty-state disclosure, metric fidelity, perf caps
Crash fixes: - owner/uses: route unbounded raw `IN (...)` clauses through batched_in so a directory with >999 files, or a bare name resolving to >999 symbols, no longer hits SQLITE_MAX_VARIABLE_NUMBER ("too many SQL variables"). Correctness: - command_advice: `roam <cmd> --help` examples are no longer reported non-executable — `--help` is an eager flag that short-circuits before positional validation. - graph modularity was always 0.0: a NotAPartition over an incomplete cluster partition was swallowed by a bare except. Repair the partition with singletons for uncovered nodes across clusters/fingerprint/simulate; real repos now report Q~0.8 instead of "no community structure". Pattern-2 empty-state disclosure (no more silent clean/PASS/HEALTHY on an empty corpus): cycles, clusters, dashboard, dead, clones, debt, alerts, complexity, and uses now emit an explicit state via the shared resolve.empty_corpus_state helper; clusters also discloses trivial_clustering when modularity Q<=0; verify discloses state=no_changes on an empty diff. Perf guards (defensive, behavior-preserving below the caps): - partition + cut: k-sample (seeded) betweenness on large graphs. - cut: pre-bucket cross-cluster edges in one pass (drops the O(clusters^2*|E|) rescan) and gate max-flow min-cut behind a node-count cap. Tests added/updated for every change; full suite green on the CI loadgroup surface.
1 parent 36b8ccd commit c8e66a0

27 files changed

Lines changed: 872 additions & 323 deletions

src/roam/command_advice.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,16 @@ def _parse_status(tokens: list[str]) -> tuple[str, str | None]:
202202
return "not_checked", load_reason
203203
if command is None:
204204
return "failed", f"unknown roam subcommand: {tokens[idx]}"
205-
return _make_context_status(command, tokens[idx], _strip_global_flags(tokens[idx + 1 :]))
205+
sub_args = tokens[idx + 1 :]
206+
# `--help` / `-h` is an EAGER flag: Click short-circuits and exits 0 before
207+
# any positional/argument validation, so `roam <cmd> --help` is ALWAYS
208+
# executable regardless of required positionals. Because `--help` lives in
209+
# _GLOBAL_FLAGS_NO_VALUE it would otherwise be stripped, degrading
210+
# `roam search --help` to `roam search` and raising a spurious "Missing
211+
# parameter: pattern" — a false FAIL on a 100%-valid copy-paste command.
212+
if "--help" in sub_args or "-h" in sub_args:
213+
return "parsed", None
214+
return _make_context_status(command, tokens[idx], _strip_global_flags(sub_args))
206215

207216

208217
def _is_root_level_invocation(tokens: list[str]) -> bool:

src/roam/commands/cmd_alerts.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
from roam.capability import roam_capability
1919
from roam.commands.metrics_history import collect_metrics, get_snapshots
20-
from roam.commands.resolve import ensure_index
20+
from roam.commands.resolve import empty_corpus_state, ensure_index
2121
from roam.db.connection import find_project_root, open_db
2222
from roam.output._severity import severity_rank
2323
from roam.output.formatter import WarningsOut, json_envelope, to_json
@@ -1350,6 +1350,35 @@ def _run_check_cx(phase, fn, *args, default=None, **kwargs):
13501350
return default
13511351

13521352
with open_db(readonly=True) as conn:
1353+
# B8 (Pattern-2): on a 0-symbol corpus, "no alerts — all metrics within
1354+
# normal ranges" is a silent SAFE — nothing was analyzed. Live metrics
1355+
# are all vacuously healthy because there is no code. Disclose the empty
1356+
# corpus explicitly (canonical state + partial_success + a verdict that
1357+
# names it) instead of an all-clear that an agent reads as "repo is fine".
1358+
_empty = empty_corpus_state(conn)
1359+
if _empty is not None:
1360+
empty_verdict = "no alerts — no code indexed in corpus (0 symbols; run `roam index --force`)"
1361+
if json_mode:
1362+
click.echo(
1363+
to_json(
1364+
json_envelope(
1365+
"alerts",
1366+
summary={
1367+
"verdict": empty_verdict,
1368+
"total": 0,
1369+
"critical": 0,
1370+
"warning": 0,
1371+
"info": 0,
1372+
"snapshots_analyzed": 0,
1373+
**_empty,
1374+
},
1375+
alerts=[],
1376+
)
1377+
)
1378+
)
1379+
else:
1380+
click.echo(f"VERDICT: {empty_verdict}")
1381+
return
13531382
# W607-CX: ``get_snapshots`` substrate -- DB-row ingest.
13541383
snaps_raw = _run_check_cx("get_snapshots", get_snapshots, conn, _trend_snapshot_limit(), default=[])
13551384
if snaps_raw is None:

src/roam/commands/cmd_clones.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import click
2020

2121
from roam.capability import roam_capability
22-
from roam.commands.resolve import ensure_index
22+
from roam.commands.resolve import empty_corpus_state, ensure_index
2323
from roam.db.connection import open_db
2424
from roam.index.file_roles import is_test as _is_test
2525
from roam.output.confidence import (
@@ -803,6 +803,20 @@ def _score_classify_run(_clusters):
803803
default={"state": "DEGRADED", "scanned": 0},
804804
)
805805

806+
# W808 — Pattern-2 empty-corpus disclosure. When there are no
807+
# clusters AND the index holds zero symbols, the "No structural
808+
# clones detected" verdict would be a silent SAFE on an empty
809+
# corpus (vacuously clean — nothing was scanned). Distinguish
810+
# that 0-symbol case from the populated-but-no-clones case (and
811+
# from the below-threshold / pre-filter gate-collapse cases,
812+
# which are a DIFFERENT axis owned by W805-CCCC) via the
813+
# canonical ``empty_corpus_state`` helper. Only the 0-symbol
814+
# corpus yields ``state="empty_corpus"``; if symbols exist but
815+
# no clones surfaced, the verdict stays the populated SAFE line.
816+
_empty = empty_corpus_state(conn) if not clusters else None
817+
if _empty is not None:
818+
verdict_with_conf = "no structural clones — no symbols indexed in corpus (run `roam index --force`)"
819+
806820
summary_payload = {
807821
"verdict": verdict_with_conf,
808822
"clusters": len(clusters),
@@ -824,6 +838,13 @@ def _score_classify_run(_clusters):
824838
"run_state": _score_dict["state"],
825839
**_cap_summary,
826840
}
841+
# W808 — stamp the canonical empty-corpus disclosure
842+
# (``state="empty_corpus"`` + ``partial_success=True``) onto
843+
# the summary so consumers never read the empty-corpus SAFE as
844+
# a clean result. Applied AFTER ``_cap_summary`` so the state
845+
# field is not clobbered.
846+
if _empty is not None:
847+
summary_payload.update(_empty)
827848
# W607-BQ + DC: union the cap-hit disclosure list with the
828849
# substrate-CALL marker list AND the aggregation-phase
829850
# marker list. All three bins flush into the same

src/roam/commands/cmd_clusters.py

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import click
1515

1616
from roam.capability import roam_capability
17-
from roam.commands.resolve import ensure_index
17+
from roam.commands.resolve import empty_corpus_state, ensure_index
1818
from roam.db.connection import open_db
1919
from roam.db.queries import ALL_CLUSTERS
2020
from roam.graph.builder import build_symbol_graph
@@ -253,23 +253,55 @@ def _clusters_json(conn, rows, min_size, quality, mermaid=None, detail=True, tok
253253
if mermaid is not None:
254254
extra["mermaid"] = mermaid
255255

256-
# Build verdict
256+
# Build verdict + a machine-readable state (W805-BB Pattern-2). A bare
257+
# "no clusters detected" conflated three distinct zero-cluster cases; an
258+
# agent could not tell whether to re-index, lower --min-size, or accept
259+
# that there is no modular structure. Disclose which case fired:
260+
# clusters_detected — real decomposition surfaced (Newman Q > 0)
261+
# trivial_clustering — clusters exist but modularity Q<=0 (no real structure)
262+
# empty_corpus — 0 symbols indexed (no graph to cluster)
263+
# no_clusters — symbols exist but Louvain found no structure
264+
# below_min_size — clusters exist but all smaller than --min-size
257265
n_visible = len(visible)
258266
largest = max(visible, key=lambda r: r["size"]) if visible else None
259-
if largest:
260-
verdict = f"{n_visible} clusters, largest: {largest['cluster_label']}({largest['size']} syms)"
267+
mod_q = quality["modularity"]
268+
summary = {
269+
"clusters": n_visible,
270+
"mismatches": sum(1 for m in mismatches if m["cluster_id"] in visible_ids),
271+
"modularity_q": mod_q,
272+
"mean_conductance": quality["mean_conductance"],
273+
}
274+
if largest and mod_q > 0.0:
275+
summary["verdict"] = f"{n_visible} clusters, largest: {largest['cluster_label']}({largest['size']} syms)"
276+
summary["state"] = "clusters_detected"
277+
elif largest:
278+
# Visible clusters exist but Newman modularity Q<=0 means there is no real
279+
# community structure (Q>0.3 is meaningful; Newman 2004). Disclose so an
280+
# agent does not read "N clusters" as a genuine architectural
281+
# decomposition and proceed to refactor a phantom cluster. (Now that
282+
# modularity is computed against a repaired partition, Q<=0 is an honest
283+
# signal — a well-modularized repo reports Q~0.8, not 0.)
284+
summary["verdict"] = f"{n_visible} trivial cluster(s), modularity Q={mod_q} — no community structure detected"
285+
summary["state"] = "trivial_clustering"
286+
summary["partial_success"] = True
261287
else:
262-
verdict = "no clusters detected"
288+
_empty = empty_corpus_state(conn)
289+
if _empty is not None:
290+
# Keep "no clusters" in the verdict (sealed contract) while
291+
# disclosing the empty corpus via the canonical state + flag.
292+
summary["verdict"] = "no clusters — no symbols indexed in corpus (run `roam index --force`)"
293+
summary.update(_empty) # state="empty_corpus", partial_success=True
294+
elif not rows:
295+
summary["verdict"] = "no clusters detected — no community structure found in the symbol graph"
296+
summary["state"] = "no_clusters"
297+
summary["partial_success"] = True
298+
else:
299+
summary["verdict"] = f"no clusters >= min-size {min_size} ({len(rows)} below threshold)"
300+
summary["state"] = "below_min_size"
263301

264302
envelope = json_envelope(
265303
"clusters",
266-
summary={
267-
"verdict": verdict,
268-
"clusters": n_visible,
269-
"mismatches": sum(1 for m in mismatches if m["cluster_id"] in visible_ids),
270-
"modularity_q": quality["modularity"],
271-
"mean_conductance": quality["mean_conductance"],
272-
},
304+
summary=summary,
273305
budget=token_budget,
274306
clusters=[
275307
{

src/roam/commands/cmd_complexity.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -617,7 +617,10 @@ def _merged_warnings() -> list[str]:
617617
# W1298: use "functions" (LAW-4 concrete-noun anchor) over
618618
# "total" so the auto-derived fact terminal is in the
619619
# accepted set (test_w806_complexity_empty_corpus pin).
620-
summary_norows: dict = {"verdict": verdict, "functions": 0}
620+
# C3 (Pattern-2): explicit not_found state, matching the
621+
# world-model siblings (side-effects/idempotency/causal-graph)
622+
# so consumers switch on a field rather than parsing the verdict.
623+
summary_norows: dict = {"verdict": verdict, "functions": 0, "state": "not_found"}
621624
if _all_w:
622625
summary_norows["partial_success"] = True
623626
click.echo(

src/roam/commands/cmd_cut.py

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,25 @@ def _detect_clusters():
240240
# composes against zero-counted cut analysis.
241241
def _compute_min_cuts():
242242
boundaries_local: list[dict] = []
243+
# Pre-bucket cross-cluster edges in ONE pass over G.edges() (was an
244+
# O(clusters^2 * |E|) rescan — the inner `for u,v in G.edges()` ran
245+
# once per cluster pair). node_cluster maps node -> cluster id;
246+
# pair_edges keys on the unordered cluster pair and preserves
247+
# G.edges() order so the src/tgt seed pick below is unchanged.
248+
from collections import defaultdict
249+
250+
node_cluster: dict = {}
251+
for _cid, _nodes in cluster_nodes.items():
252+
for _n in _nodes:
253+
node_cluster[_n] = _cid
254+
pair_edges: dict[tuple, list[tuple]] = defaultdict(list)
255+
for u, v in G.edges():
256+
cu = node_cluster.get(u)
257+
cv = node_cluster.get(v)
258+
if cu is None or cv is None or cu == cv:
259+
continue
260+
pair_edges[(cu, cv) if cu < cv else (cv, cu)].append((u, v))
261+
243262
for i, c1 in enumerate(cluster_ids):
244263
for c2 in cluster_ids[i + 1 :]:
245264
if between:
@@ -252,13 +271,10 @@ def _compute_min_cuts():
252271
):
253272
continue
254273

255-
# Count cross-edges
256-
cross_edges: list[tuple] = []
274+
# Cross-edges from the pre-built bucket (no per-pair rescan).
257275
nodes_c1 = cluster_nodes.get(c1, set())
258276
nodes_c2 = cluster_nodes.get(c2, set())
259-
for u, v in G.edges():
260-
if (u in nodes_c1 and v in nodes_c2) or (u in nodes_c2 and v in nodes_c1):
261-
cross_edges.append((u, v))
277+
cross_edges: list[tuple] = pair_edges.get((c1, c2) if c1 < c2 else (c2, c1), [])
262278

263279
if not cross_edges:
264280
continue
@@ -277,10 +293,19 @@ def _compute_min_cuts():
277293
if src_node is None:
278294
continue
279295

280-
try:
281-
min_cut_edges = nx.minimum_edge_cut(UG, src_node, tgt_node)
282-
min_cut_size = len(min_cut_edges)
283-
except (nx.NetworkXError, nx.NetworkXUnbounded, nx.exception.NetworkXError):
296+
# Gate exact max-flow min-cut behind a subgraph-size cap —
297+
# nx.minimum_edge_cut is ~O(V*E^2). Above the cap, fall back
298+
# to the cross-edge-count heuristic (the same value the
299+
# except branch already uses) so huge cluster pairs do not
300+
# stall the command.
301+
if len(nodes_c1) + len(nodes_c2) <= 2000:
302+
try:
303+
min_cut_edges = nx.minimum_edge_cut(UG, src_node, tgt_node)
304+
min_cut_size = len(min_cut_edges)
305+
except (nx.NetworkXError, nx.NetworkXUnbounded, nx.exception.NetworkXError):
306+
min_cut_edges = set()
307+
min_cut_size = len(cross_edges)
308+
else:
284309
min_cut_edges = set()
285310
min_cut_size = len(cross_edges)
286311

@@ -333,7 +358,15 @@ def _extract_leak_edges():
333358
if not (leak_edges or not between):
334359
return leak_edge_list_local
335360
try:
336-
ebc = nx.edge_betweenness_centrality(UG)
361+
# Speed guard (mirrors graph/cycles.py's size gate): exact edge
362+
# betweenness is O(V*E). k-sample pivots above a node-count cap;
363+
# seed keeps the sampled leak-edge ranking reproducible.
364+
_ug_n = UG.number_of_nodes()
365+
if _ug_n <= 2000:
366+
ebc = nx.edge_betweenness_centrality(UG)
367+
else:
368+
_k = min(_ug_n, max(200, int(_ug_n**0.5 * 5)))
369+
ebc = nx.edge_betweenness_centrality(UG, k=_k, seed=1)
337370
except Exception:
338371
ebc = {}
339372

src/roam/commands/cmd_cycles.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import click
1616

1717
from roam.capability import roam_capability
18-
from roam.commands.resolve import ensure_index
18+
from roam.commands.resolve import empty_corpus_state, ensure_index
1919
from roam.db.connection import open_db
2020
from roam.graph.builder import build_symbol_graph
2121
from roam.graph.cycles import find_cycles, format_cycles, mark_actionable_cycles
@@ -58,6 +58,32 @@ def cycles(ctx, min_size, limit, actionable_only):
5858
token_budget = ctx.obj.get("budget", 0) if ctx.obj else 0
5959
ensure_index()
6060
with open_db(readonly=True) as conn:
61+
# B3 (Pattern-2): a 0-symbol corpus must NOT report "clean dependency
62+
# graph" — there is no graph to analyze. Disclose empty_corpus instead
63+
# of a vacuous clean verdict.
64+
_empty = empty_corpus_state(conn)
65+
if _empty is not None:
66+
empty_verdict = "no symbols indexed — no dependency graph to analyze (run `roam index --force`)"
67+
if json_mode:
68+
click.echo(
69+
to_json(
70+
json_envelope(
71+
"cycles",
72+
summary={
73+
"verdict": empty_verdict,
74+
"cycle_count": 0,
75+
"actionable_count": 0,
76+
**_empty,
77+
},
78+
cycles=[],
79+
budget=token_budget,
80+
)
81+
)
82+
)
83+
else:
84+
click.echo(f"VERDICT: {empty_verdict}")
85+
return
86+
6187
graph = build_symbol_graph(conn)
6288
raw = find_cycles(graph, min_size=min_size)
6389
formatted = format_cycles(raw, conn) if raw else []

src/roam/commands/cmd_dashboard.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,20 @@ def _assemble_sections():
587587
if ai_rot_definition_top is not None:
588588
_summary_block["ai_rot_definition"] = ai_rot_definition_top
589589

590+
# W805-PP (Pattern-2): a 0-symbol corpus must NOT read as a clean
591+
# HEALTHY bill — there is nothing indexed to be healthy about
592+
# (uncoded / not yet written / index broken / wrong cwd). The
593+
# numeric health-band verdict ("Codebase is HEALTHY 100/100") is
594+
# a silent SAFE here. Disclose the empty corpus explicitly via
595+
# the canonical state + partial_success + an empty-naming verdict,
596+
# matching cmd_health's guard and the shared empty_corpus_state.
597+
if overview["symbols"] == 0:
598+
_summary_block["verdict"] = (
599+
"Codebase has 0 symbols indexed (empty corpus — run `roam index --force`)"
600+
)
601+
_summary_block["state"] = "empty_corpus"
602+
_summary_block["partial_success"] = True
603+
590604
_envelope_kwargs: dict = {
591605
"overview": overview,
592606
"health": {

src/roam/commands/cmd_dead.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
# ``roam.index.test_conventions`` — no back-edge into ``cmd_dead``.
2929
from roam.commands.changed_files import is_test_file as _is_test_path
3030
from roam.commands.next_steps import format_next_steps_text, suggest_next_steps
31-
from roam.commands.resolve import ensure_index
31+
from roam.commands.resolve import empty_corpus_state, ensure_index
3232
from roam.db.connection import batched_in, find_project_root, open_db
3333
from roam.db.edge_kinds import CALL_EDGE_KINDS
3434
from roam.output.confidence import (
@@ -2295,6 +2295,23 @@ def _emit_empty_sarif():
22952295
if _combined_warnings_empty:
22962296
summary["warnings_out"] = list(_combined_warnings_empty)
22972297
summary["partial_success"] = True
2298+
# W802 Pattern-2: distinguish a genuinely empty corpus
2299+
# (0 symbols indexed) from a populated corpus that simply
2300+
# has no dead exports. On a 0-symbol corpus "no dead
2301+
# exports" is a vacuous SAFE — disclose the empty state
2302+
# explicitly via the canonical ``empty_corpus_state``
2303+
# helper so machine consumers read it without parsing the
2304+
# verdict. An empty corpus is a fully-resolved "nothing to
2305+
# flag" state, NOT a partial failure, so partial_success
2306+
# stays the literal boolean ``False`` (the helper's
2307+
# ``partial_success: True`` is intended for analysis
2308+
# commands whose graph logic degrades on empty input;
2309+
# ``dead`` produces a complete, correct answer here).
2310+
_empty_corpus = empty_corpus_state(conn)
2311+
if _empty_corpus is not None:
2312+
summary["state"] = _empty_corpus["state"]
2313+
summary["verdict"] = "no dead exports — no symbols indexed in corpus (run `roam index --force`)"
2314+
summary.setdefault("partial_success", False)
22982315
envelope_kwargs: dict = {
22992316
"summary": summary,
23002317
"high_confidence": [],

0 commit comments

Comments
 (0)