yield: single-owner commit attribution, ambiguous bucket, heuristic caveat (#692)
Some checks are pending
CI / semgrep (push) Waiting to run

* yield: single-owner commit attribution, ambiguous bucket, heuristic caveat (#641)

* yield: repo-level single-owner attribution, sharper ambiguous rule, determinism pins

---------

Co-authored-by: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com>
This commit is contained in:
Aditya Vikram Singh 2026-07-16 22:09:26 +02:00 committed by GitHub
parent 1b69c83165
commit f2864ff964
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 329 additions and 26 deletions

View file

@ -203,7 +203,7 @@ codeburn yield # last 7 days (default)
codeburn yield -p today # today only
codeburn yield -p 30days # last 30 days
codeburn yield -p month # this calendar month
codeburn yield --format json # productive/reverted/abandoned spend as JSON
codeburn yield --format json # productive/reverted/abandoned/ambiguous spend as JSON
```
Did the spend actually ship? `codeburn yield` correlates AI sessions with git commits by timestamp:
@ -213,6 +213,9 @@ Did the spend actually ship? `codeburn yield` correlates AI sessions with git co
| Productive | Commits from this session landed in main |
| Reverted | Commits were later reverted |
| Abandoned | No commits near session, or commits never merged |
| Ambiguous | Session ran parallel to another and its window's commits were attributed to the tighter one |
Attribution is timestamp-window based (heuristic): each commit is credited to at most one session — the tightest window containing it. The JSON report carries `methodology: "timestamp-window"`.
Requires a git repository. Run from your project directory.
@ -591,7 +594,7 @@ codeburn report --format json | jq '.projects'
codeburn today --format json | jq '.overview.cost'
```
For lighter output, use `status --format json` (today and month totals only), `optimize --format json` (setup health, findings, and copy-paste fixes), `yield --format json` (productive/reverted/abandoned spend), or file exports (`export -f json`).
For lighter output, use `status --format json` (today and month totals only), `optimize --format json` (setup health, findings, and copy-paste fixes), `yield --format json` (productive/reverted/abandoned/ambiguous spend), or file exports (`export -f json`).
</details>

View file

@ -2,7 +2,7 @@ import { execFileSync } from 'child_process'
import { parseAllSessions } from './parser.js'
import type { DateRange, SessionSummary } from './types.js'
export type YieldCategory = 'productive' | 'reverted' | 'abandoned'
export type YieldCategory = 'productive' | 'reverted' | 'abandoned' | 'ambiguous'
export type SessionYield = {
sessionId: string
@ -16,6 +16,7 @@ export type YieldSummary = {
productive: { cost: number; sessions: number }
reverted: { cost: number; sessions: number }
abandoned: { cost: number; sessions: number }
ambiguous: { cost: number; sessions: number }
total: { cost: number; sessions: number }
details: SessionYield[]
}
@ -30,9 +31,11 @@ export type YieldJsonReport = {
productive: YieldBucketJson
reverted: YieldBucketJson
abandoned: YieldBucketJson
ambiguous: YieldBucketJson
total: { costUSD: number; sessions: number }
productiveToRevertedCostRatio: number | null
}
methodology: 'timestamp-window'
details: SessionYieldJson[]
}
@ -82,6 +85,18 @@ type CommitInfo = {
wasReverted: boolean
}
type SessionWindow = {
readonly start: Date
readonly end: Date
readonly sessionId: string
}
type SessionAttribution = {
readonly window: SessionWindow | null
readonly commits: CommitInfo[]
lostCandidacy: boolean
}
/**
* Find SHAs that were the target of a `git revert` ANYWHERE in the repo's
* history (not just the time window). The standard `git revert` body
@ -141,41 +156,88 @@ function getCommitsInRange(cwd: string, since: Date, until: Date, mainBranch: st
})
}
function sessionWindow(session: SessionSummary): SessionWindow | null {
if (!session.firstTimestamp) return null
const start = new Date(session.firstTimestamp)
const lastTs = session.lastTimestamp ?? session.firstTimestamp
const end = new Date(new Date(lastTs).getTime() + 60 * 60 * 1000)
return { start, end, sessionId: session.sessionId }
}
function attributeCommits(
sessions: SessionSummary[],
commits: CommitInfo[],
): SessionAttribution[] {
const attributions: SessionAttribution[] = sessions.map(session => ({
window: sessionWindow(session),
commits: [],
lostCandidacy: false,
}))
for (const commit of commits) {
const candidates = attributions.filter((attribution): attribution is SessionAttribution & { window: SessionWindow } =>
attribution.window !== null &&
commit.timestamp >= attribution.window.start &&
commit.timestamp <= attribution.window.end,
)
const owner = candidates.reduce<SessionAttribution & { window: SessionWindow } | null>((current, candidate) => {
if (current === null) return candidate
const currentSpan = current.window.end.getTime() - current.window.start.getTime()
const candidateSpan = candidate.window.end.getTime() - candidate.window.start.getTime()
if (candidateSpan !== currentSpan) return candidateSpan < currentSpan ? candidate : current
if (candidate.window.start.getTime() !== current.window.start.getTime()) {
return candidate.window.start < current.window.start ? candidate : current
}
// sessionId, not array position: session order is cache-state dependent.
return candidate.window.sessionId < current.window.sessionId ? candidate : current
}, null)
for (const candidate of candidates) {
if (candidate !== owner) candidate.lostCandidacy = true
}
owner?.commits.push(commit)
}
return attributions
}
function categorizeSession(
session: SessionSummary,
commits: CommitInfo[]
commits: CommitInfo[],
lostCandidacy: boolean,
): { category: YieldCategory; commitCount: number } {
if (!session.firstTimestamp) {
return { category: 'abandoned', commitCount: 0 }
}
const sessionStart = new Date(session.firstTimestamp)
const lastTs = session.lastTimestamp ?? session.firstTimestamp
const sessionEnd = new Date(new Date(lastTs).getTime() + 60 * 60 * 1000) // +1 hour
const relevantCommits = commits.filter(c =>
c.timestamp >= sessionStart && c.timestamp <= sessionEnd
)
if (relevantCommits.length === 0) {
return { category: 'abandoned', commitCount: 0 }
if (commits.length === 0) {
// Ambiguous only when this session's window actually contained a commit
// that a tighter window won — mere overlap with a credited session is not
// enough to withhold the abandoned classification.
return {
category: lostCandidacy ? 'ambiguous' : 'abandoned',
commitCount: 0,
}
}
const inMainCount = relevantCommits.filter(c => c.inMain).length
const inMainCount = commits.filter(c => c.inMain).length
// A session is "reverted" when at least half of its in-main commits were
// later reverted out (revert detected via "This reverts commit <sha>"
// anywhere later in history, not in the same time window).
const revertedCount = relevantCommits.filter(c => c.inMain && c.wasReverted).length
const revertedCount = commits.filter(c => c.inMain && c.wasReverted).length
if (revertedCount > 0 && revertedCount >= inMainCount / 2) {
return { category: 'reverted', commitCount: relevantCommits.length }
return { category: 'reverted', commitCount: commits.length }
}
if (inMainCount > 0) {
return { category: 'productive', commitCount: inMainCount }
}
return { category: 'abandoned', commitCount: relevantCommits.length }
return { category: 'abandoned', commitCount: commits.length }
}
export async function computeYield(range: DateRange, cwd: string, provider: string = 'all'): Promise<YieldSummary> {
@ -185,6 +247,7 @@ export async function computeYield(range: DateRange, cwd: string, provider: stri
productive: { cost: 0, sessions: 0 },
reverted: { cost: 0, sessions: 0 },
abandoned: { cost: 0, sessions: 0 },
ambiguous: { cost: 0, sessions: 0 },
total: { cost: 0, sessions: 0 },
details: [],
}
@ -194,18 +257,43 @@ export async function computeYield(range: DateRange, cwd: string, provider: stri
? getCommitsInRange(cwd, range.start, range.end, getMainBranch(cwd))
: []
// Group sessions by resolved repo before attributing: every project entry
// whose projectPath is missing (or not a git repo) falls back to the same
// cwd and shares one commit list, so attribution must run once per repo or
// two such entries could each award the same commit to one of their sessions.
type RepoGroup = { commits: CommitInfo[]; sessions: SessionSummary[]; projectNames: string[] }
const repoGroups = new Map<string, RepoGroup>()
for (const project of projects) {
// Try project-specific git repo first, fall back to cwd
const projectCwd = project.projectPath && isGitRepo(project.projectPath)
? project.projectPath
: cwd
const projectCommits = projectCwd !== cwd && isGitRepo(projectCwd)
? getCommitsInRange(projectCwd, range.start, range.end, getMainBranch(projectCwd))
: commits
let group = repoGroups.get(projectCwd)
if (!group) {
group = {
commits: projectCwd === cwd
? commits
: getCommitsInRange(projectCwd, range.start, range.end, getMainBranch(projectCwd)),
sessions: [],
projectNames: [],
}
repoGroups.set(projectCwd, group)
}
for (const session of project.sessions) {
const { category, commitCount } = categorizeSession(session, projectCommits)
group.sessions.push(session)
group.projectNames.push(project.project)
}
}
for (const group of repoGroups.values()) {
const attributions = attributeCommits(group.sessions, group.commits)
for (const [index, session] of group.sessions.entries()) {
const attribution = attributions[index]
const { category, commitCount } = categorizeSession(
session,
attribution?.commits ?? [],
attribution?.lostCandidacy ?? false,
)
summary[category].cost += session.totalCostUSD
summary[category].sessions += 1
@ -214,7 +302,7 @@ export async function computeYield(range: DateRange, cwd: string, provider: stri
summary.details.push({
sessionId: session.sessionId,
project: project.project,
project: group.projectNames[index] ?? session.project,
cost: session.totalCostUSD,
category,
commitCount,
@ -226,7 +314,7 @@ export async function computeYield(range: DateRange, cwd: string, provider: stri
}
export function formatYieldSummary(summary: YieldSummary): string {
const { productive, reverted, abandoned, total } = summary
const { productive, reverted, abandoned, ambiguous, total } = summary
const pct = (n: number) => total.cost > 0 ? Math.round((n / total.cost) * 100) : 0
const fmt = (n: number) => `$${n.toFixed(2)}`
@ -236,6 +324,9 @@ export function formatYieldSummary(summary: YieldSummary): string {
`Productive: ${fmt(productive.cost).padStart(8)} (${pct(productive.cost)}%) - ${productive.sessions} sessions shipped to main`,
`Reverted: ${fmt(reverted.cost).padStart(8)} (${pct(reverted.cost)}%) - ${reverted.sessions} sessions were reverted`,
`Abandoned: ${fmt(abandoned.cost).padStart(8)} (${pct(abandoned.cost)}%) - ${abandoned.sessions} sessions never committed`,
`Ambiguous: ${fmt(ambiguous.cost).padStart(8)} (${pct(ambiguous.cost)}%) - ${ambiguous.sessions} sessions lost commits to concurrent sessions`,
'',
'Attribution: timestamp-window based (heuristic)',
'',
`Total: ${fmt(total.cost).padStart(8)} - ${total.sessions} sessions`,
'',
@ -270,6 +361,7 @@ export function buildYieldJsonReport(
productive: bucket(summary.productive),
reverted: bucket(summary.reverted),
abandoned: bucket(summary.abandoned),
ambiguous: bucket(summary.ambiguous),
total: {
costUSD: summary.total.cost,
sessions: summary.total.sessions,
@ -278,6 +370,7 @@ export function buildYieldJsonReport(
? Math.round((summary.productive.cost / summary.reverted.cost) * 100) / 100
: null,
},
methodology: 'timestamp-window',
details: summary.details.map(detail => ({
sessionId: detail.sessionId,
project: detail.project,

View file

@ -0,0 +1,198 @@
import { execFileSync } from 'node:child_process'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import type { ProjectSummary, SessionSummary } from '../src/types.js'
import { computeYield } from '../src/yield.js'
const { parseAllSessionsMock } = vi.hoisted(() => ({
parseAllSessionsMock: vi.fn(),
}))
vi.mock('../src/parser.js', () => ({
parseAllSessions: parseAllSessionsMock,
}))
function git(cwd: string, args: string[], env: Record<string, string> = {}): string {
return execFileSync('git', args, {
cwd,
encoding: 'utf-8',
env: { ...process.env, ...env },
}).trim()
}
function makeSession(overrides: Partial<SessionSummary>): SessionSummary {
return {
sessionId: 'session',
project: 'app',
firstTimestamp: '2026-01-01T10:00:00.000Z',
lastTimestamp: '2026-01-01T11:00:00.000Z',
totalCostUSD: 1,
totalSavingsUSD: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
totalReasoningTokens: 0,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
apiCalls: 1,
turns: [],
modelBreakdown: {},
toolBreakdown: {},
mcpBreakdown: {},
bashBreakdown: {},
categoryBreakdown: {} as SessionSummary['categoryBreakdown'],
skillBreakdown: {},
subagentBreakdown: {},
...overrides,
}
}
describe('yield attribution for overlapping sessions (issue #641)', () => {
let repoDir: string
beforeAll(async () => {
repoDir = await mkdtemp(join(tmpdir(), 'codeburn-yield-overlap-'))
git(repoDir, ['init', '-b', 'main'])
git(repoDir, ['config', 'user.email', 'test@example.com'])
git(repoDir, ['config', 'user.name', 'Test'])
await writeFile(join(repoDir, 'file.txt'), 'hello\n')
git(repoDir, ['add', '.'])
// One commit on main at 10:30, inside both sessions' time windows.
git(repoDir, ['commit', '-m', 'feat: shipped by session A'], {
GIT_AUTHOR_DATE: '2026-01-01T10:30:00Z',
GIT_COMMITTER_DATE: '2026-01-01T10:30:00Z',
})
})
afterAll(async () => {
await rm(repoDir, { recursive: true, force: true })
})
it('does not credit a session that made no commits with an overlapping session\'s commit', async () => {
// Session A ran 10:15-10:45 and made the 10:30 commit.
// Session B ran 10:00-11:00 in the same repo and shipped nothing.
const sessionA = makeSession({
sessionId: 'session-a',
firstTimestamp: '2026-01-01T10:15:00.000Z',
lastTimestamp: '2026-01-01T10:45:00.000Z',
totalCostUSD: 5,
})
const sessionB = makeSession({
sessionId: 'session-b',
firstTimestamp: '2026-01-01T10:00:00.000Z',
lastTimestamp: '2026-01-01T11:00:00.000Z',
totalCostUSD: 3,
})
const project: Partial<ProjectSummary> = {
project: 'app',
projectPath: repoDir,
// Keep the broader session first so attribution must use window span,
// not project/session order.
sessions: [sessionB, sessionA],
}
parseAllSessionsMock.mockResolvedValue([project as ProjectSummary])
const summary = await computeYield(
{
start: new Date('2026-01-01T00:00:00.000Z'),
end: new Date('2026-01-02T00:00:00.000Z'),
},
repoDir,
)
const detailB = summary.details.find(d => d.sessionId === 'session-b')
expect(detailB).toBeDefined()
// Session B shipped nothing: it must not land in the "productive" bucket
// on the strength of session A's commit.
expect(detailB!.category).toBe('ambiguous')
expect(detailB!.category).not.toBe('productive')
expect(detailB!.commitCount).toBe(0)
// The single commit should be credited to exactly one session.
const productiveSessions = summary.details.filter(d => d.category === 'productive')
expect(productiveSessions.map(d => d.sessionId)).toEqual(['session-a'])
expect(summary.ambiguous).toEqual({ cost: 3, sessions: 1 })
})
it('classifies a lone session with its commit as productive (single-session pin)', async () => {
const session = makeSession({
sessionId: 'solo',
firstTimestamp: '2026-01-01T10:15:00.000Z',
lastTimestamp: '2026-01-01T10:45:00.000Z',
totalCostUSD: 2,
})
parseAllSessionsMock.mockResolvedValue([
{ project: 'app', projectPath: repoDir, sessions: [session] } as ProjectSummary,
])
const summary = await computeYield(
{ start: new Date('2026-01-01T00:00:00.000Z'), end: new Date('2026-01-02T00:00:00.000Z') },
repoDir,
)
expect(summary.details).toHaveLength(1)
expect(summary.details[0]).toMatchObject({ sessionId: 'solo', category: 'productive', commitCount: 1 })
expect(summary.productive).toEqual({ cost: 2, sessions: 1 })
expect(summary.ambiguous).toEqual({ cost: 0, sessions: 0 })
})
it('breaks equal-window ties deterministically by sessionId, not array order', async () => {
const windowFields = {
firstTimestamp: '2026-01-01T10:15:00.000Z',
lastTimestamp: '2026-01-01T10:45:00.000Z',
}
const first = makeSession({ sessionId: 'aaa-session', ...windowFields, totalCostUSD: 1 })
const second = makeSession({ sessionId: 'zzz-session', ...windowFields, totalCostUSD: 1 })
for (const order of [[first, second], [second, first]]) {
parseAllSessionsMock.mockResolvedValue([
{ project: 'app', projectPath: repoDir, sessions: order } as ProjectSummary,
])
const summary = await computeYield(
{ start: new Date('2026-01-01T00:00:00.000Z'), end: new Date('2026-01-02T00:00:00.000Z') },
repoDir,
)
const productive = summary.details.filter(d => d.category === 'productive')
expect(productive.map(d => d.sessionId)).toEqual(['aaa-session'])
expect(summary.details.find(d => d.sessionId === 'zzz-session')!.category).toBe('ambiguous')
}
})
it('holds the single-owner invariant across project entries sharing the cwd fallback', async () => {
// Two project entries with no usable projectPath both resolve to the same
// cwd and share one commit list; the commit must still be credited once.
const sessionX = makeSession({
sessionId: 'proj1-session',
project: 'proj1',
firstTimestamp: '2026-01-01T10:15:00.000Z',
lastTimestamp: '2026-01-01T10:45:00.000Z',
totalCostUSD: 1,
})
const sessionY = makeSession({
sessionId: 'proj2-session',
project: 'proj2',
firstTimestamp: '2026-01-01T10:00:00.000Z',
lastTimestamp: '2026-01-01T11:00:00.000Z',
totalCostUSD: 1,
})
parseAllSessionsMock.mockResolvedValue([
{ project: 'proj1', projectPath: undefined, sessions: [sessionX] } as unknown as ProjectSummary,
{ project: 'proj2', projectPath: undefined, sessions: [sessionY] } as unknown as ProjectSummary,
])
const summary = await computeYield(
{ start: new Date('2026-01-01T00:00:00.000Z'), end: new Date('2026-01-02T00:00:00.000Z') },
repoDir,
)
const productive = summary.details.filter(d => d.category === 'productive')
expect(productive.map(d => d.sessionId)).toEqual(['proj1-session'])
expect(productive[0]!.commitCount).toBe(1)
expect(summary.details.find(d => d.sessionId === 'proj2-session')!.category).toBe('ambiguous')
})
})

View file

@ -8,6 +8,7 @@ describe('buildYieldJsonReport', () => {
productive: { cost: 8, sessions: 2 },
reverted: { cost: 2, sessions: 1 },
abandoned: { cost: 10, sessions: 1 },
ambiguous: { cost: 0, sessions: 0 },
total: { cost: 20, sessions: 4 },
details: [
{ sessionId: 's1', project: 'app', cost: 8, category: 'productive', commitCount: 2 },
@ -32,8 +33,15 @@ describe('buildYieldJsonReport', () => {
})
expect(report.summary.reverted.costPercent).toBe(10)
expect(report.summary.abandoned.sessionPercent).toBe(25)
expect(report.summary.ambiguous).toEqual({
costUSD: 0,
sessions: 0,
costPercent: 0,
sessionPercent: 0,
})
expect(report.summary.total).toEqual({ costUSD: 20, sessions: 4 })
expect(report.summary.productiveToRevertedCostRatio).toBe(4)
expect(report.methodology).toBe('timestamp-window')
expect(report.details).toHaveLength(2)
expect(report.details[0]).toMatchObject({ sessionId: 's1', costUSD: 8, category: 'productive' })
expect(report.details[0]).not.toHaveProperty('cost')
@ -44,6 +52,7 @@ describe('buildYieldJsonReport', () => {
productive: { cost: 1, sessions: 1 },
reverted: { cost: 0, sessions: 0 },
abandoned: { cost: 0, sessions: 0 },
ambiguous: { cost: 0, sessions: 0 },
total: { cost: 1, sessions: 1 },
details: [],
}