Skip to content

Commit 3763a4f

Browse files
committed
Suppress cluster-traversing deep-dependency chains
The depth explainer condenses SCCs and takes the longest path. After the earlier tuning an oversized autoload cluster counted as one node, so chains were honest but low-value: their length came mostly from threading through the one giant cluster (already reported by the cycles explainer), and they were near-duplicates sharing that cluster spine. Make an oversized cluster a weight-0 sink: drop its outgoing edges in the condensation and count it as 0 depth, so a chain earns depth only from genuine, distinct-module layering above/outside the cluster. Real deep layering still reports; cluster-manufactured depth no longer does.
1 parent fa799ba commit 3763a4f

2 files changed

Lines changed: 95 additions & 33 deletions

File tree

internal/explainers/depth/depth.go

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,15 @@ func (e *DepthExplainer) Name() string {
4343
// chain passing through it (a genuine tangle deepens the chain). But an *oversized*
4444
// component (> common.OversizedClusterModules) is an autoload coupling cluster, not
4545
// deep layering — in Ruby/Rails mutual constant references collapse most of the app
46-
// into one giant SCC, and counting its full size would report every chain through it
47-
// as "depth ~100" (the cycle false-positive leaking into depth). Such a cluster is
48-
// therefore weighted as a single logical layer (componentWeight), so depth measures
49-
// real layering rather than cluster size; the cluster itself is already reported by
50-
// the cycles explainer. A module's reported depth is its component's depth; one
51-
// insight is emitted per component (keyed by its smallest member), so a cycle yields
52-
// a single finding rather than one per entangled module.
46+
// into one giant SCC. Such a cluster is therefore (a) weighted 0 (componentWeight)
47+
// and (b) made a sink in the condensation (its outgoing edges are dropped), so a
48+
// chain earns depth only from genuine, distinct-module layering above/outside the
49+
// cluster and never by threading through it. Otherwise every chain reaching the
50+
// cluster would report an inflated depth that merely restates the coupling the
51+
// cycles explainer already reports as a "Highly coupled module cluster". A module's
52+
// reported depth is its component's depth; one insight is emitted per component
53+
// (keyed by its smallest member), so a small cycle yields a single finding rather
54+
// than one per entangled module.
5355
func (e *DepthExplainer) Explain(ctx context.Context, store *facts.Store) ([]facts.Insight, error) {
5456
graph := common.BuildModuleGraph(store)
5557
if len(graph) == 0 {
@@ -69,14 +71,19 @@ func (e *DepthExplainer) Explain(ctx context.Context, store *facts.Store) ([]fac
6971

7072
// Build the condensed adjacency (successor component indices), deduped and
7173
// sorted. Self-edges and intra-component edges are dropped — the condensation
72-
// is acyclic by construction.
74+
// is acyclic by construction. An oversized coupling cluster is made a sink (its
75+
// outgoing edges are dropped) so a chain cannot earn depth by threading through
76+
// it — see the Explain doc comment.
7377
succ := make([][]int, len(sccs))
7478
seen := make([]map[int]bool, len(sccs))
7579
for i := range seen {
7680
seen[i] = map[int]bool{}
7781
}
7882
for mod, neighbors := range graph {
7983
si := sccOf[mod]
84+
if len(sccs[si]) > common.OversizedClusterModules {
85+
continue // oversized cluster is a depth sink
86+
}
8087
for _, n := range neighbors {
8188
sj, ok := sccOf[n]
8289
if !ok || sj == si {
@@ -175,29 +182,30 @@ func (e *DepthExplainer) Explain(ctx context.Context, store *facts.Store) ([]fac
175182
}
176183

177184
// componentWeight is how much a strongly-connected component contributes to a
178-
// dependency-chain's depth: its full member count for a small tangle, but just 1
179-
// for an oversized autoload cluster (which is one logical layer, not deep layering
180-
// — see the Explain doc comment).
185+
// dependency-chain's depth: its full member count for a small tangle, but 0 for an
186+
// oversized autoload cluster. Combined with making the cluster a sink (its outgoing
187+
// edges are dropped), this means a chain earns depth only from genuine layering
188+
// above/outside the cluster, never from threading through it — see the Explain doc
189+
// comment.
181190
func componentWeight(scc []string) int {
182191
if len(scc) > common.OversizedClusterModules {
183-
return 1
192+
return 0
184193
}
185194
return len(scc)
186195
}
187196

188197
// chainFor reconstructs the deepest chain of distinct modules starting at
189-
// component i: for a small component all of its (sorted) members, but for an
190-
// oversized cluster only a single representative (so the evidence chain length
191-
// stays consistent with the reported depth instead of dumping ~100 modules), then
192-
// its best successor component, and so on down the DAG.
198+
// component i: all of each small component's (sorted) members, then its best
199+
// successor, and so on down the DAG. An oversized cluster (weight 0, and a sink so
200+
// it is always terminal) is omitted, keeping the reported chain length equal to the
201+
// reported depth instead of dumping ~100 cluster members.
193202
func chainFor(i int, sccs [][]string, bestSucc []int) []string {
194203
var out []string
195204
for i != -1 {
196205
if len(sccs[i]) > common.OversizedClusterModules {
197-
out = append(out, sccs[i][0])
198-
} else {
199-
out = append(out, sccs[i]...)
206+
break // weight-0 sink; not part of the reported chain
200207
}
208+
out = append(out, sccs[i]...)
201209
i = bestSucc[i]
202210
}
203211
return out

internal/explainers/depth/depth_test.go

Lines changed: 68 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -224,39 +224,93 @@ func TestExplain_SelfImportDoesNotAddDepth(t *testing.T) {
224224
}
225225
}
226226

227-
// TestExplain_OversizedClusterNotDeep: a large autoload cluster (a ring of
228-
// OversizedClusterModules+3 modules) must count as ONE logical layer, not its full
229-
// size, so it does not masquerade as a deep dependency chain. With a short tail
230-
// below it the whole graph's real layering stays under minDepth and nothing is
231-
// reported — matching the cycles explainer already covering the cluster.
232-
func TestExplain_OversizedClusterNotDeep(t *testing.T) {
227+
// oversizedRing builds one big SCC of OversizedClusterModules+3 modules named
228+
// prefix+NN, returning the module list, the dep map, and the ring members.
229+
func oversizedRing(prefix string) ([]string, map[string][]string, []string) {
233230
n := common.OversizedClusterModules + 3
234-
mods := make([]string, 0, n+1)
235-
deps := map[string][]string{}
236231
ring := make([]string, n)
232+
deps := map[string][]string{}
237233
for i := 0; i < n; i++ {
238-
ring[i] = fmt.Sprintf("c/m%02d", i)
239-
mods = append(mods, ring[i])
234+
ring[i] = fmt.Sprintf("%s%02d", prefix, i)
240235
}
241236
for i := 0; i < n; i++ {
242-
deps[ring[i]] = []string{ring[(i+1)%n]} // one big SCC
237+
deps[ring[i]] = []string{ring[(i+1)%n]}
243238
}
239+
return append([]string(nil), ring...), deps, ring
240+
}
241+
242+
// TestExplain_OversizedClusterNotDeep: a large autoload cluster with a short tail
243+
// hanging off it produces no deep-chain finding — the cluster is a weight-0 sink, so
244+
// it neither counts toward depth nor lets a chain thread through it.
245+
func TestExplain_OversizedClusterNotDeep(t *testing.T) {
246+
mods, deps, ring := oversizedRing("c/m")
244247
// A short 2-module tail hanging off the cluster: c/m00 -> t/t0 -> t/t1.
245248
mods = append(mods, "t/t0", "t/t1")
246-
deps["c/m00"] = append(deps["c/m00"], "t/t0")
249+
deps[ring[0]] = append(deps[ring[0]], "t/t0")
247250
deps["t/t0"] = []string{"t/t1"}
248251

249252
insights, err := New().Explain(context.Background(), makeStore(mods, deps))
250253
if err != nil {
251254
t.Fatalf("Explain: %v", err)
252255
}
253-
// Cluster weighted as 1 + tail(2) = depth 3 < minDepth(5) -> no findings, and
254-
// crucially not a "depth ~N" report of the whole cluster.
255256
if len(insights) != 0 {
256257
t.Fatalf("oversized cluster should not produce a deep-chain finding, got %d: %v", len(insights), titles(insights))
257258
}
258259
}
259260

261+
// TestExplain_ClusterIsDepthSink: genuine layering ABOVE a cluster still fires (the
262+
// cluster contributes nothing but the real layers count), while shallow layering
263+
// into a cluster does not — proving depth is earned only outside the cluster.
264+
func TestExplain_ClusterIsDepthSink(t *testing.T) {
265+
mods, deps, ring := oversizedRing("k/m")
266+
// 5 genuine layers a0->a1->a2->a3->a4, then a4 imports into the cluster.
267+
for i := 0; i < 5; i++ {
268+
mods = append(mods, fmt.Sprintf("a/l%d", i))
269+
}
270+
for i := 0; i < 4; i++ {
271+
deps[fmt.Sprintf("a/l%d", i)] = []string{fmt.Sprintf("a/l%d", i+1)}
272+
}
273+
deps["a/l4"] = []string{ring[0]}
274+
// 3 shallow layers b0->b1->b2, then b2 imports into the cluster.
275+
for i := 0; i < 3; i++ {
276+
mods = append(mods, fmt.Sprintf("b/l%d", i))
277+
}
278+
deps["b/l0"] = []string{"b/l1"}
279+
deps["b/l1"] = []string{"b/l2"}
280+
deps["b/l2"] = []string{ring[0]}
281+
282+
insights, err := New().Explain(context.Background(), makeStore(mods, deps))
283+
if err != nil {
284+
t.Fatalf("Explain: %v", err)
285+
}
286+
byMod := map[string]facts.Insight{}
287+
for _, in := range insights {
288+
for _, ev := range in.Evidence {
289+
// first evidence fact is the chain head
290+
byMod[ev.Fact] = in
291+
break
292+
}
293+
}
294+
// a/l0 has 5 real layers into the cluster -> reported at depth 5, and the chain
295+
// must NOT include any cluster member.
296+
aIn, ok := byMod["a/l0"]
297+
if !ok {
298+
t.Fatalf("5-layer chain above the cluster should be reported; got %v", titles(insights))
299+
}
300+
if !strings.Contains(aIn.Title, "depth 5") {
301+
t.Errorf("a/l0 should be depth 5 (cluster contributes 0), got %q", aIn.Title)
302+
}
303+
for _, ev := range aIn.Evidence {
304+
if strings.HasPrefix(ev.Fact, "k/m") {
305+
t.Errorf("cluster member %q must not appear in the chain", ev.Fact)
306+
}
307+
}
308+
// b/l0 has only 3 layers into the cluster -> below minDepth -> not reported.
309+
if _, ok := byMod["b/l0"]; ok {
310+
t.Errorf("3-layer chain into the cluster should be suppressed; got %v", titles(insights))
311+
}
312+
}
313+
260314
func TestExplain_EmptyGraph(t *testing.T) {
261315
insights, err := New().Explain(context.Background(), facts.NewStore())
262316
if err != nil {

0 commit comments

Comments
 (0)