attribution: make summary percentages sum to 100 · Entire
attribution: make summary percentages sum to 100
529ec2a→main·
Soph·1mo ago·2 files·+66 added/-5 removed
The summary computed AI/Human/Mixed percentages by independent integer division, so they drifted from a coherent whole — e.g. one line each of AI, Human, and Mixed rendered 33% / 33% / 33% = 99%. Apportion with the largest-remainder (Hamilton) method across all four buckets (including uncommitted, which shares the 100% but is shown only as a count) so the visible figures stay coherent.
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
Sessions
e2b9528e57dbView transcript
Changes
2
cmd/entire/cli
Mattribution.go+48/-5
Mattribution_test.go+18
746 unmodified lines
746 unmodified lines
summary.UncommittedLines++
}
}
if summary.TotalLines > 0 {
summary.AIPercentage = summary.AILines * 100 / summary.TotalLines
summary.HumanPercentage = summary.HumanLines * 100 / summary.TotalLines
summary.MixedPercentage = summary.MixedLines * 100 / summary.TotalLines
}
// Apportion percentages with the largest-remainder method across all four
// buckets so the displayed AI/Human/Mixed figures don't drift (e.g. three
// equal thirds rendering as 33/33/33 = 99). Uncommitted shares the 100% but
// is shown only as a count, so when it is present the three visible
// percentages correctly total less than 100.
pct := largestRemainderPercent(
[]int{summary.AILines, summary.HumanLines, summary.MixedLines, summary.UncommittedLines},
summary.TotalLines,
)
summary.AIPercentage = pct[0]
summary.HumanPercentage = pct[1]
summary.MixedPercentage = pct[2]
return summary
}
// largestRemainderPercent apportions integer percentages that sum to 100 across
// counts whose own sum is total, using the largest-remainder (Hamilton) method.
// It avoids the truncation drift where independently floored shares total 99.
// Returns all-zero when total is non-positive.
func largestRemainderPercent(counts []int, total int) []int {
pct := make([]int, len(counts))
if total <= 0 {
return pct
}
allocated := 0
order := make([]int, len(counts))
for i, c := range counts {
pct[i] = c * 100 / total
allocated += pct[i]
order[i] = i
}
leftover := 100 - allocated
if leftover <= 0 {
return pct
}
// Hand the leftover points to the largest fractional remainders, breaking
// ties by lower index for deterministic output.
remainder := func(i int) int { return (counts[i] * 100) % total }
sort.SliceStable(order, func(a, b int) bool {
ra, rb := remainder(order[a]), remainder(order[b])
if ra == rb {
return order[a] < order[b]
}
return ra > rb
})
for i := 0; i < leftover && i < len(order); i++ {
pct[order[i]]++
}
return pct
}
func renderAttributionBlame(w io.Writer, result *fileAttributionResult, lineFlag string, longOutput bool) {
if longOutput {
renderAttributionBlameLong(w, result, lineFlag)
}
Mcmd/entire/cli/attribution.go+48/-5
428 unmodified lines
428 unmodified lines
require.Contains(t, whyOut.String(), "may have been renamed")
}
func TestSummarizeAttributionLinesPercentagesSumTo100(t *testing.T) {
lines := []attributionLine{
{Authorship: attributionAI},
{Authorship: attributionHuman},
{Authorship: attributionMixed},
}
summary := summarizeAttributionLines(lines)
require.Equal(t, 100, summary.AIPercentage+summary.HumanPercentage+summary.MixedPercentage)
// An uncommitted line shares the 100%, so the three visible percentages
// total less than 100 rather than each independently flooring to a sum
// that drifts away from a coherent whole.
lines = append(lines, attributionLine{Authorship: attributionUncommitted})
summary = summarizeAttributionLines(lines)
visible := summary.AIPercentage + summary.HumanPercentage + summary.MixedPercentage
require.Equal(t, 75, visible)
}
func TestRunGitBlameWrapsExecError(t *testing.T) {
repoRoot := newAttributionRepo(t)