# attribution: make summary percentages sum to 100

`529ec2a`→[main](/content/gh/entireio/cli/commits/main/index.html)·

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)
```
