Skip to content

fix(cluster): exclude self-loops from cohesion_score so the ratio sta…fix(cluster): exclude self-loops from cohesion_score so the ratio stays in 0..1 - #3558

Closed
jordanalexanderp18-rgb wants to merge 1 commit into
Graphify-Labs:v8from
jordanalexanderp18-rgb:fix/cohesion-self-loops
Closed

jordanalexanderp18-rgb wants to merge 1 commit into
Graphify-Labs:v8from
jordanalexanderp18-rgb:fix/cohesion-self-loops

Conversation

@jordanalexanderp18-rgb

Copy link
Copy Markdown
Contributor

Problem

cohesion_score() counts self-loops in the numerator (number_of_edges()) but not in
the denominator (n * (n - 1) / 2, distinct node pairs). Recursive calls self-edges
are deliberately preserved by build_from_json (build.py:1294), so they reach this
code in ordinary graphs and push the ratio past the 0..1 range the docstring defines.

G = nx.Graph()
G.add_edge("f", "g", relation="calls")
G.add_edge("f", "f", relation="calls")   # recursion
cohesion_score(G, ["f", "g"])            # 2.0 before, 1.0 after

report.py:289 prints the score unclamped, so GRAPH_REPORT.md could show Cohesion: 2.00.

Change

One line in cohesion_score(): subtract nx.number_of_selfloops(subgraph) from the edge
count, with a comment explaining why the self-loops are there in the first place.

Clamping the output was the other option, but that would hide a miscount rather than fix
it, and it would leave the inflated sub-1.0 scores wrong.

Tests

Three cases added to tests/test_cluster.py, beside the existing cohesion tests:

  • test_cohesion_score_ignores_self_loops — the 2.0 case
  • test_cohesion_score_range_with_self_loops — the 0..1 invariant with every node recursive
  • test_cohesion_score_self_loops_only_is_zero — self-loops alone are not a connection

All three fail on main and pass with the fix. The existing
test_cohesion_score_range already asserts the invariant but runs on a fixture with no
self-loops, so it could not catch this.

Behaviour change

Communities containing recursive functions now report lower, correct cohesion.
cluster() reads this score at cluster.py:320 to decide re-splitting
(< 0.05, communities of 50+ nodes), so a community whose inflated score sat just above
that threshold could now be split. Checked against a real 2048-node graph with 38
self-loops: 15 of 104 communities report corrected scores, none crosses the re-split
threshold, clustering output unchanged.

…ys in 0..1

cohesion_score() counts self-loops in the numerator (number_of_edges) but
not in the denominator (n * (n - 1) / 2, distinct node pairs), so the ratio
its docstring defines over 0..1 can exceed 1.0. Recursive `calls` self-edges
are deliberately preserved by build_from_json, so they reach this code in
ordinary graphs: a two-node community holding one recursive function scored
2.0, and report.py prints the value unclamped.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).

Formal verification. 1 change(s) tested, no difference found (not proven).


Graphify review — findings

Fixes cohesion_score to subtract self-loops from the edge count so recursive calls self-edges no longer inflate the ratio above 1.0 (a two-node community with one recursive function previously scored 2.0), keeping numerator and denominator both measured over distinct node pairs.

No blocking issues surfaced. 1 lower-confidence candidate did not survive cross-model review.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 472 functions depend on the 21 functions this change touches.

Health — this change adds coupling hotspots:

  • new: _rebuild_code() — 116 callers, 51 callees
  • new: dispatch_command() — 2 callers, 124 callees
  • new: _make_graph() — 33 callers, 6 callees
  • new: cluster() — 61 callers, 3 callees
  • new: run_pipeline() — 8 callers, 13 callees
  • new: make_inputs() — 16 callers, 5 callees
  • new: suggest_questions() — 11 callers, 4 callees
  • new: watch() — 5 callers, 7 callees
  • …and 11 more — each is listed as a finding

Verification — 472 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 268 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

13 of 275 test file(s) selected (5%) via static blast radius.

  • tests/test_analyze.py — impact
  • tests/test_build.py — impact
  • tests/test_cli_export.py — impact
  • tests/test_cluster.py — impact
  • tests/test_community_hub_labels.py — impact
  • tests/test_confidence.py — impact
  • tests/test_export.py — impact
  • tests/test_pipeline.py — impact
  • tests/test_reflect.py — impact
  • tests/test_report.py — impact
  • tests/test_serve.py — impact
  • tests/test_serve_http.py — impact
  • tests/test_watch.py — impact

Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.

Formal verification

No difference found (not proven): No behavior difference found in cohesion\_score (not a proof).

The verifier ran both versions of cohesion\_score on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

· 19 more finding(s) on lines outside this diff (see the check run).

safishamsi added a commit that referenced this pull request Sep 15, 2026
…cribed

#3558's fix landed but its diff omitted the 3 tests its description claimed;
add them (self-loop ignored, self-loops-only is 0, 0..1 bound holds). Also drop
a changelog line the #3559 cherry-pick injected into the released 0.9.61 section.
@safishamsi

Copy link
Copy Markdown
Collaborator

Shipped in v0.9.62 (now on PyPI: uv tool install graphifyy==0.9.62). Your commits were cherry-picked with authorship preserved, so this shows up under your GitHub contributions. Thanks @jordanalexanderp18-rgb!

@safishamsi safishamsi closed this Sep 15, 2026
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.

2 participants