Skip to content

Commit cf13190

Browse files
committed
Improving snapshot comparison
1 parent d8e4d41 commit cf13190

4 files changed

Lines changed: 223 additions & 18 deletions

File tree

ARCHITECTURE.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,12 @@ It is a **delta, not a linter**: it judges the current snapshot against the code
358358
- **new and removed coupling edges** (the architecturally interesting structural change);
359359
- **added / removed** modules, symbols, routes, storage; and props-level **changed** facts (line-only shifts are ignored, so an edit above a symbol doesn't churn the diff).
360360

361-
Because the baseline is the project's own prior snapshot, a pattern that was present *before and after* (e.g. an API-first route with no loaded consumer) produces no delta — the diff is structurally immune to that false-signal class. Findings are identified by explainer + cited entities (not by their volatile title/metric text), so a god-class whose fan-in merely ticked up is not reported as resolve+new. `Compute` is pure and deterministic: identical inputs render byte-identically.
361+
Because the baseline is the project's own prior snapshot, a pattern that was present *before and after* (e.g. an API-first route with no loaded consumer) produces no delta — the diff is structurally immune to that false-signal class. `Compute` is pure and deterministic: identical inputs render byte-identically.
362+
363+
Two refinements keep the finding delta honest, both learned from dogfooding on a real backend:
364+
365+
- **Stable finding identity.** A finding is keyed by its explainer plus its number-normalized title (the stable subject), so a god-class whose fan-in merely ticked up — or a whole-codebase summary finding like the `layers` pattern, whose evidence enumerates every module — does not churn as resolve+introduce on an unrelated edit. Cycles are the exception: their title carries only a member count, so they are keyed on their sorted member modules.
366+
- **Structural-cause classification.** A new/resolved finding is reported as a real **regression introduced** / **improvement** only when the change actually touched one of the entities it cites (a fact added/removed/changed, or an edge endpoint — so a finding that flips because a *new caller* changed a symbol's fan-in still counts). Findings that appear or clear with no structural cause — a moving `mean+2σ` threshold, or a top-N list re-ranking after a worse offender left the window — are routed to a separate **incidental finding shifts** section so they never masquerade as something the change caused.
362367

363368
The typical loop is `generate_snapshot → set_baseline → edit → generate_snapshot → diff_snapshot`.
364369

internal/diff/diff.go

Lines changed: 132 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package diff
1616
import (
1717
"encoding/json"
1818
"fmt"
19+
"regexp"
1920
"sort"
2021
"strings"
2122

@@ -58,9 +59,18 @@ type SnapshotDiff struct {
5859
// Findings delta (the ratchet core). FindingsNew are regressions introduced
5960
// by the change; FindingsResolved are issues the change cleared. Each carries
6061
// through its original Confidence and Description (caveats intact) untouched —
61-
// the diff manufactures no verdicts.
62+
// the diff manufactures no verdicts. Only findings with a STRUCTURAL CAUSE in
63+
// this change (an evidence entity that was added/removed/changed) land here.
6264
FindingsNew []facts.Insight `json:"findings_new,omitempty"`
6365
FindingsResolved []facts.Insight `json:"findings_resolved,omitempty"`
66+
67+
// Incidental finding shifts: findings that appeared or cleared with NO
68+
// structural cause in this change — a moving statistical threshold (mean+2σ) or
69+
// a re-ranked top-N list whose membership shifted because some OTHER finding
70+
// left the window. These are surfaced separately so they don't masquerade as
71+
// regressions/improvements the change actually caused.
72+
FindingsNewIncidental []facts.Insight `json:"findings_new_incidental,omitempty"`
73+
FindingsResolvedIncidental []facts.Insight `json:"findings_resolved_incidental,omitempty"`
6474
}
6575

6676
// Compute returns the delta from baseline to current. A nil snapshot is treated
@@ -125,26 +135,96 @@ func Compute(baseline, current *facts.Snapshot) *SnapshotDiff {
125135
for _, in := range snapInsights(current) {
126136
curFind[findingKey(in)] = in
127137
}
138+
139+
// A finding only counts as a real regression/improvement if this change
140+
// structurally touched something it cites — otherwise its appearance/clearance
141+
// is incidental (a moving mean+2σ threshold, or a top-N list re-ranking after
142+
// some other finding left the window). touched is the set of names the change
143+
// added/removed/altered, including edge endpoints (so a finding that flips
144+
// because a NEW caller changed a symbol's fan-in is still counted as real).
145+
touched := d.touchedNames()
128146
for k, in := range curFind {
129-
if _, ok := baseFind[k]; !ok {
147+
if _, ok := baseFind[k]; ok {
148+
continue
149+
}
150+
if findingHasStructuralCause(in, touched) {
130151
d.FindingsNew = append(d.FindingsNew, in)
152+
} else {
153+
d.FindingsNewIncidental = append(d.FindingsNewIncidental, in)
131154
}
132155
}
133156
for k, in := range baseFind {
134-
if _, ok := curFind[k]; !ok {
157+
if _, ok := curFind[k]; ok {
158+
continue
159+
}
160+
if findingHasStructuralCause(in, touched) {
135161
d.FindingsResolved = append(d.FindingsResolved, in)
162+
} else {
163+
d.FindingsResolvedIncidental = append(d.FindingsResolvedIncidental, in)
136164
}
137165
}
138166

139167
d.sortAll()
140168
return d
141169
}
142170

171+
// touchedNames is the set of entity names this change structurally affected:
172+
// added/removed/changed facts plus the endpoints of added/removed edges. A
173+
// finding is attributed to the change when one of its evidence entities is in
174+
// this set.
175+
func (d *SnapshotDiff) touchedNames() map[string]struct{} {
176+
m := make(map[string]struct{})
177+
add := func(n string) {
178+
if n != "" {
179+
m[n] = struct{}{}
180+
}
181+
}
182+
for _, f := range d.FactsAdded {
183+
add(f.Name)
184+
}
185+
for _, f := range d.FactsRemoved {
186+
add(f.Name)
187+
}
188+
for _, c := range d.FactsChanged {
189+
add(c.After.Name)
190+
}
191+
for _, e := range d.EdgesAdded {
192+
add(e.Source)
193+
add(e.Target)
194+
}
195+
for _, e := range d.EdgesRemoved {
196+
add(e.Source)
197+
add(e.Target)
198+
}
199+
return m
200+
}
201+
202+
// findingHasStructuralCause reports whether any entity the finding cites was
203+
// structurally touched by this change. Evidence-less findings can't be attributed,
204+
// so they default to real (never silently hidden).
205+
func findingHasStructuralCause(in facts.Insight, touched map[string]struct{}) bool {
206+
if len(in.Evidence) == 0 {
207+
return true
208+
}
209+
for _, ev := range in.Evidence {
210+
for _, e := range []string{ev.Fact, ev.Symbol, ev.File} {
211+
if e == "" {
212+
continue
213+
}
214+
if _, ok := touched[e]; ok {
215+
return true
216+
}
217+
}
218+
}
219+
return false
220+
}
221+
143222
// Empty reports whether the diff contains no changes of any kind.
144223
func (d *SnapshotDiff) Empty() bool {
145224
return len(d.FactsAdded) == 0 && len(d.FactsRemoved) == 0 && len(d.FactsChanged) == 0 &&
146225
len(d.EdgesAdded) == 0 && len(d.EdgesRemoved) == 0 &&
147-
len(d.FindingsNew) == 0 && len(d.FindingsResolved) == 0
226+
len(d.FindingsNew) == 0 && len(d.FindingsResolved) == 0 &&
227+
len(d.FindingsNewIncidental) == 0 && len(d.FindingsResolvedIncidental) == 0
148228
}
149229

150230
// Focused returns a copy of the diff narrowed to entries that reference focus
@@ -197,6 +277,16 @@ func (d *SnapshotDiff) Focused(focus string) *SnapshotDiff {
197277
out.FindingsResolved = append(out.FindingsResolved, in)
198278
}
199279
}
280+
for _, in := range d.FindingsNewIncidental {
281+
if insightMatches(in, focus) {
282+
out.FindingsNewIncidental = append(out.FindingsNewIncidental, in)
283+
}
284+
}
285+
for _, in := range d.FindingsResolvedIncidental {
286+
if insightMatches(in, focus) {
287+
out.FindingsResolvedIncidental = append(out.FindingsResolvedIncidental, in)
288+
}
289+
}
200290
return out
201291
}
202292

@@ -249,23 +339,46 @@ func edgeKey(e Edge) string {
249339
return e.Repo + "\x00" + e.Source + "\x00" + e.Kind + "\x00" + e.Target
250340
}
251341

252-
// findingKey identifies an insight by its explainer plus the sorted set of
253-
// entities it cites (evidence Fact/Symbol/File). Title and Detail are excluded
254-
// because they often embed volatile metrics (e.g. "fan-in: 13"); keying on the
255-
// entities keeps a finding stable across runs so only a finding about a NEW entity
256-
// counts as new. Evidence-less insights fall back to their title.
342+
// titleNumber matches the volatile metrics embedded in finding titles (counts,
343+
// ratios, percentages) so they can be stripped for a stable identity.
344+
var titleNumber = regexp.MustCompile(`[0-9]+(\.[0-9]+)?`)
345+
346+
// normalizeTitle removes the volatile numbers from a finding title, leaving the
347+
// stable subject. "Large public surface: x/y exports 67 of 67 symbols (100%)"
348+
// and the same line with different counts collapse to one identity.
349+
func normalizeTitle(s string) string {
350+
return titleNumber.ReplaceAllString(s, "#")
351+
}
352+
353+
// findingKey identifies an insight so a finding stays the SAME finding across
354+
// snapshots even as its metrics drift or a ranked list re-orders.
355+
//
356+
// Most explainers name their subject (module/symbol/repo/pattern) in the title
357+
// and vary only by counts, so the number-normalized title is the stable identity.
358+
// This is what stops whole-codebase "summary" findings (e.g. the layers pattern,
359+
// whose evidence enumerates every module) from churning resolve+introduce on any
360+
// edit. Cycles are the exception: their title carries only a member count, so two
361+
// distinct cycles would collide — they are keyed on their sorted member modules
362+
// (the evidence), which is also what makes a cycle stay identified as long as its
363+
// membership holds.
257364
func findingKey(in facts.Insight) string {
365+
if in.Source == "cycles" {
366+
return in.Source + "\x00" + sortedEvidenceEntities(in)
367+
}
368+
return in.Source + "\x00" + normalizeTitle(in.Title)
369+
}
370+
371+
// sortedEvidenceEntities joins a finding's cited entities (Fact/Symbol/File) in
372+
// sorted order — a stable identity for set-defined findings like cycles.
373+
func sortedEvidenceEntities(in facts.Insight) string {
258374
var ents []string
259375
for _, ev := range in.Evidence {
260376
if e := firstNonEmpty(ev.Fact, ev.Symbol, ev.File); e != "" {
261377
ents = append(ents, e)
262378
}
263379
}
264-
if len(ents) == 0 {
265-
return in.Source + "\x00" + in.Title
266-
}
267380
sort.Strings(ents)
268-
return in.Source + "\x00" + strings.Join(ents, "\x1f")
381+
return strings.Join(ents, "\x1f")
269382
}
270383

271384
// --- helpers ---
@@ -362,6 +475,12 @@ func (d *SnapshotDiff) sortAll() {
362475
sort.Slice(d.FindingsResolved, func(i, j int) bool {
363476
return findingKey(d.FindingsResolved[i]) < findingKey(d.FindingsResolved[j])
364477
})
478+
sort.Slice(d.FindingsNewIncidental, func(i, j int) bool {
479+
return findingKey(d.FindingsNewIncidental[i]) < findingKey(d.FindingsNewIncidental[j])
480+
})
481+
sort.Slice(d.FindingsResolvedIncidental, func(i, j int) bool {
482+
return findingKey(d.FindingsResolvedIncidental[i]) < findingKey(d.FindingsResolvedIncidental[j])
483+
})
365484
}
366485

367486
// KindCounts returns counts of facts by kind for the given slice, used by the

internal/diff/diff_test.go

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package diff
22

33
import (
4+
"fmt"
45
"strings"
56
"testing"
67

@@ -84,8 +85,10 @@ func TestCompute_PropsChangeIsAChange(t *testing.T) {
8485
}
8586

8687
func TestCompute_NewFinding(t *testing.T) {
87-
base := snap(nil, nil)
88-
cur := snap(nil, []facts.Insight{cycleInsight("a", "b")})
88+
// A new cycle comes with the structural edges that created it, so its members
89+
// are in the change's touched set and it is a real regression.
90+
base := snap([]facts.Fact{mod("a", "a.go"), mod("b", "b.go")}, nil)
91+
cur := snap([]facts.Fact{mod("a", "a.go", "b"), mod("b", "b.go", "a")}, []facts.Insight{cycleInsight("a", "b")})
8992

9093
d := Compute(base, cur)
9194
if len(d.FindingsNew) != 1 {
@@ -129,15 +132,70 @@ func TestCompute_FindingStableAcrossVolatileTitle(t *testing.T) {
129132
}
130133

131134
func TestCompute_ResolvedFinding(t *testing.T) {
132-
base := snap(nil, []facts.Insight{cycleInsight("a", "b")})
133-
cur := snap(nil, nil)
135+
// Breaking the cycle removes the b→a edge, so the cycle members are touched and
136+
// the cleared finding is a real improvement.
137+
base := snap([]facts.Fact{mod("a", "a.go", "b"), mod("b", "b.go", "a")}, []facts.Insight{cycleInsight("a", "b")})
138+
cur := snap([]facts.Fact{mod("a", "a.go", "b"), mod("b", "b.go")}, nil)
134139

135140
d := Compute(base, cur)
136141
if len(d.FindingsResolved) != 1 {
137142
t.Fatalf("expected 1 resolved finding, got %+v", d.FindingsResolved)
138143
}
139144
}
140145

146+
// TestCompute_RankWindowFindingIsIncidental is the regression guard for the false
147+
// positives found dogfooding: a finding about an UNCHANGED subject — e.g. a module
148+
// that rose into a top-N list only because a worse one was removed — must be
149+
// classified incidental, not a real regression. The genuinely-removed subject's
150+
// finding still resolves as a real improvement.
151+
func TestCompute_RankWindowFindingIsIncidental(t *testing.T) {
152+
surf := func(module, sym string) facts.Insight {
153+
return facts.Insight{Source: "exported-surface", Confidence: 0.6,
154+
Title: "Large public surface: " + module + " exports 67 of 67 symbols (100%)",
155+
Evidence: []facts.Evidence{{Symbol: sym, Detail: "exported"}}}
156+
}
157+
base := snap(
158+
[]facts.Fact{sym("app/golf_rule.Svc", "app/golf_rule/svc.go", 1), sym("db/messaging.Repo", "db/messaging/repo.go", 1)},
159+
[]facts.Insight{surf("app/golf_rule", "app/golf_rule.Svc")}, // only golf_rule reported (messaging below the line)
160+
)
161+
cur := snap(
162+
[]facts.Fact{sym("db/messaging.Repo", "db/messaging/repo.go", 1)}, // golf_rule removed; messaging unchanged
163+
[]facts.Insight{surf("db/messaging", "db/messaging.Repo")}, // messaging rose into the window
164+
)
165+
166+
d := Compute(base, cur)
167+
if len(d.FindingsNew) != 0 {
168+
t.Fatalf("unchanged module rising into top-N must be incidental, got real: %+v", d.FindingsNew)
169+
}
170+
if len(d.FindingsNewIncidental) != 1 {
171+
t.Fatalf("expected 1 incidental new finding (messaging), got %+v", d.FindingsNewIncidental)
172+
}
173+
if len(d.FindingsResolved) != 1 {
174+
t.Fatalf("expected golf_rule (actually removed) as 1 real improvement, got %+v", d.FindingsResolved)
175+
}
176+
}
177+
178+
// TestCompute_SummaryFindingDoesNotChurn guards the layers/summary case: a finding
179+
// whose evidence enumerates the whole codebase keeps its identity when modules
180+
// change, so it never churns resolve+introduce.
181+
func TestCompute_SummaryFindingDoesNotChurn(t *testing.T) {
182+
layers := func(mods ...string) facts.Insight {
183+
in := facts.Insight{Source: "layers", Confidence: 0.89,
184+
Title: fmt.Sprintf("Architecture pattern: go-standard (%d modules)", len(mods))}
185+
for _, m := range mods {
186+
in.Evidence = append(in.Evidence, facts.Evidence{Fact: m})
187+
}
188+
return in
189+
}
190+
base := snap(nil, []facts.Insight{layers("a", "b", "c", "d")})
191+
cur := snap(nil, []facts.Insight{layers("a", "b", "c")})
192+
193+
d := Compute(base, cur)
194+
if !d.Empty() {
195+
t.Fatalf("summary finding churned on a module-count change: %+v", d)
196+
}
197+
}
198+
141199
// TestCompute_Deterministic guards the brand promise: identical inputs render
142200
// byte-identically regardless of input ordering or map iteration.
143201
func TestCompute_Deterministic(t *testing.T) {

internal/diff/render.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ func (d *SnapshotDiff) RenderSummary() string {
4040
sb.WriteString("\n")
4141
}
4242

43+
d.writeIncidentalShifts(&sb)
4344
d.writeStructuralSummary(&sb)
4445
return sb.String()
4546
}
@@ -88,6 +89,7 @@ func (d *SnapshotDiff) RenderCompact() string {
8889
sb.WriteString("\n")
8990
}
9091

92+
d.writeIncidentalShifts(&sb)
9193
d.writeStructuralSummary(&sb)
9294

9395
// New coupling is the architecturally interesting structural change, so list
@@ -115,6 +117,27 @@ func (d *SnapshotDiff) RenderCompact() string {
115117
return sb.String()
116118
}
117119

120+
// writeIncidentalShifts lists findings that appeared or cleared without a
121+
// structural cause in this change (a moving statistical threshold, or a top-N list
122+
// re-ranking after some other finding left the window). They are surfaced so
123+
// nothing is hidden, but kept out of the regression/improvement headline so they
124+
// don't read as something the change caused.
125+
func (d *SnapshotDiff) writeIncidentalShifts(sb *strings.Builder) {
126+
total := len(d.FindingsNewIncidental) + len(d.FindingsResolvedIncidental)
127+
if total == 0 {
128+
return
129+
}
130+
fmt.Fprintf(sb, "## Incidental finding shifts (%d)\n\n", total)
131+
sb.WriteString("_Appeared or cleared with no structural cause in this change — a moving statistical threshold or a re-ranked top-N list. Likely NOT caused by this change; verify only if relevant._\n\n")
132+
for _, in := range d.FindingsNewIncidental {
133+
fmt.Fprintf(sb, "- appeared · [%s] %s\n", insightSource(in), oneLine(in.Title))
134+
}
135+
for _, in := range d.FindingsResolvedIncidental {
136+
fmt.Fprintf(sb, "- cleared · [%s] %s\n", insightSource(in), oneLine(in.Title))
137+
}
138+
sb.WriteString("\n")
139+
}
140+
118141
func (d *SnapshotDiff) writeProvenance(sb *strings.Builder) {
119142
if d.BaselineGeneratedAt != "" || d.CurrentGeneratedAt != "" {
120143
fmt.Fprintf(sb, "_Baseline %s → current %s._\n\n", orDash(d.BaselineGeneratedAt), orDash(d.CurrentGeneratedAt))

0 commit comments

Comments
 (0)