codeburn/tests/compare-stats.test.ts
Matthew Kelch a5a0ddaeac
feat(copilot): read per-request input/cache from session-store.db
The Copilot CLI and the GitHub Copilot desktop app both write
~/.copilot/session-store.db unconditionally; its assistant_usage_events
table holds one row per API request. Until now input/cache tokens for
these surfaces came only from the session.shutdown rollups in
events.jsonl, which are written only on clean shutdown (a crash loses
the whole leg's input/cache accounting) and lump each session leg into
one per-model total. The rollup also RESETS its counters at in-session
compaction (traced on a clean single-process 107-request session whose
sole rollup covered exactly its five post-compaction requests), so even
cleanly-closed long sessions were truncated; on a long-history machine
the store recovered ~35% of real Copilot spend lost to crashes and
compaction resets. The DB rows are per-request, crash-proof, and carry
real timestamps.

The store's input_tokens is cache-INCLUSIVE (input + cache_read +
cache_write), the same convention as the shutdown rollups — verified
against each row's token_details_json and by reconciling per-session
sums against the CLI's own footers and rollups across two machines
(1,380+ rows, 8 models, CLI 1.0.70–1.0.79, schema_version 6): every
divergence was a rollup gap. Emitted calls mirror the shutdown-call
contract: input/cache/reasoning only, output 0 — per-turn output stays
owned by the events.jsonl assistant.message calls.

Rollup-vs-store precedence is RECONCILED at serve time, per
(session, model), and only there. Both representations always parse and
cache; parseProviderSources aggregates the cached calls and, wherever
store rows exist for a (session, model), drops the rollup calls and
serves the rows plus per-leg RESIDUAL calls: each rollup leg subtracts
only the rows in its own interval — rows commit strictly before their
leg's shutdown line, so a leg at time T covers exactly the rows in
(previous leg's T, T] — and any remainder (per token component, floored
at zero) serves once at that leg's own timestamp. A store missing
requests a leg covered — adopted mid-session, rows pruned before ever
being read — therefore still serves that tail exactly once ON THAT
LEG'S DAY, a crash-tail row the rollup never saw can never cancel it,
and a complete store serves pure per-request granularity with every
residual retired to zero. The decision reads only cached contents, never discovery:
deleting or resetting the store changes nothing served, so finalized
daily history can never flip on an absence epoch; cached rows of a
deleted store remain the record until the 90-day orphan age-out (which
exempts still-discovered paths). The serve set is the one coherent
snapshot — nothing a writer does between discovery and a parse can
change what one pass sees — and read-time precedence heals persisted
duplication (stale epochs, runtimes without node:sqlite, restored
files) instead of preserving it, following the buildDurablePeriod
pattern.

Store rows and rollups carry supplementary accounting weight. A rollup
(or its residual) is aggregate accounting, never a request: zero
api-call/model-call/turn weight, tokens and cost fully retained. A
store row is one real request, but when it pairs with a served per-turn
call it is supplementary too; rows pair with same-model per-turn calls
by timestamp adjacency (monotone matching, tight 2-minute window — the
two are written at the same completion moment, and a wide window would
let a crash-only row pair against a neighbor whose own row is missing),
computed once over the FULL serve set so a date-range boundary that
separates a row from its call cannot double the request across adjacent
day queries. Only the unpaired rows — store-only requests, exactly
where crash-lost requests sit — count. Supplementary-only turns fold
into the nearest behavioral turn within 30 minutes; with no behavioral
turn to fold into they stay separate weightless turns, each on its own
day, with apiCalls 0 — and the session emission gate admits
usage-bearing zero-call sessions. The weight
propagates into the daily cache: aggregateProjectsIntoDays applies the
same rule to every calls counter and category-turn count it seals, so
v19 history and live summaries can never disagree about what was a
request.

A changed source whose read defers on the busy shape (locked, EACCES,
corrupt mid-replace — discovery still emits the source; only true
absence or a schema mismatch reads as absent) now marks session
hydration incomplete, so the daily backfill holds its watermark instead
of finalizing a day the deferred rows never reached; an unchanged
unreadable store defers nothing. The verdict travels with its result —
the 180s memo and the serve burst-reuse restore the hydration verdict
their cached data was parsed under, so a memoized partial parse cannot
inherit a later parse's complete — and a discovered source whose
FINGERPRINT cannot be read (EACCES on a present file) defers instead of
silently skipping, while a genuinely deleted file stays a silent skip. Copilot reasoning tokens are no longer
double-billed at the report layer: they are a subset of the output the
per-turn calls already price, and copilot joins claude in the
reasoning-inside-output case of the query-time cost recompute.

Store dedup keys are content-discriminated —
copilot-store:<sid>:<rowId>:<fnv1a64(created_at|tokens|model)> —
because AUTOINCREMENT prevents id reuse only within one database
lifetime: a same-path DB reset reusing row ids now mints new keys
instead of the durable union swallowing the new usage, while a
byte-identical re-insert still collapses (64-bit: 32-bit FNV
collisions between plausible token tuples are constructible). Every
call of a session serves under one project label resolved at serve
time — the session-state-derived label when the serve set knows it,
else the store rows' own — so neither rows cached before events.jsonl
existed nor an events.jsonl orphaned by a session-state prune can
split the session across two grouping keys.

CODEBURN_COPILOT_SESSION_STORE_DB is read but deliberately NOT
fingerprinted, per the #927 ruling (any copilot fingerprint change
drops cached entries whose path still exists, destroying pruned history
only the cache holds); the read is allowlisted in the #927 guard, and
serve-time reconciliation makes repointing safe without a fingerprint —
the new store's rows parse on sight and the old path's entries persist
as durable orphans. The copilot parse version appends session-store-v2
and the daily cache bumps v17 → v19: per-day attribution, call counts
and costs all change against pre-store builds. 19, not 18: an earlier
pushed head of this PR already claimed v18 under different accounting,
and the carry-forward would adopt those days as finalized without
re-deriving them.

Verified by A/B on snapshots of two real stores, a live SIGKILL crash
test (row present, no rollup, tokens recovered exactly), live resumes
whose warm-cache deltas matched new rows to the token, upgrade-healing
at 4,800-session scale, and serve-level regressions pinning every
maintainer finding from six review rounds: the rows-then-shutdown race,
stale-cache healing, age-out exemption, absence-epoch identity,
progressive row landing with residual retirement, behavioral weight
across all four pinned scenarios, the hydration fence, project
unification in both directions, the same-path reset, mixed
coverage (crash tail vs covered-leg gap), multi-leg residual day
attribution, range-invariant pairing, memo-scoped hydration verdicts,
and the fingerprint-failure fence.
2026-08-17 17:52:01 -04:00

683 lines
27 KiB
TypeScript

import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { aggregateModelStats, buildCompareJson, computeComparison, computeCategoryComparison, computeWorkingStyle, findModelStat, renderCompareJson, scanSelfCorrections, type ModelStats } from '../src/compare-stats.js'
import type { ProjectSummary, SessionSummary, ClassifiedTurn } from '../src/types.js'
function makeTurn(model: string, cost: number, opts: { hasEdits?: boolean; retries?: number; outputTokens?: number; inputTokens?: number; cacheRead?: number; cacheWrite?: number; timestamp?: string; category?: string; hasAgentSpawn?: boolean; hasPlanMode?: boolean; speed?: 'standard' | 'fast'; tools?: string[] } = {}): ClassifiedTurn {
const defaultTools = opts.tools ?? (opts.hasEdits ? ['Edit'] : ['Read'])
return {
timestamp: opts.timestamp ?? '2026-04-15T10:00:00Z',
category: (opts.category ?? 'coding') as ClassifiedTurn['category'],
retries: opts.retries ?? 0,
hasEdits: opts.hasEdits ?? false,
userMessage: '',
assistantCalls: [{
provider: 'claude',
model,
usage: {
inputTokens: opts.inputTokens ?? 100,
outputTokens: opts.outputTokens ?? 200,
cacheCreationInputTokens: opts.cacheWrite ?? 500,
cacheReadInputTokens: opts.cacheRead ?? 5000,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
costUSD: cost,
tools: defaultTools,
mcpTools: [],
skills: [],
hasAgentSpawn: opts.hasAgentSpawn ?? false,
hasPlanMode: opts.hasPlanMode ?? false,
speed: opts.speed ?? 'standard' as const,
timestamp: opts.timestamp ?? '2026-04-15T10:00:00Z',
bashCommands: [],
deduplicationKey: `key-${Math.random()}`,
}],
}
}
function makeProject(turns: ClassifiedTurn[]): ProjectSummary {
const session: SessionSummary = {
sessionId: 'test-session',
project: 'test-project',
firstTimestamp: turns[0]?.timestamp ?? '',
lastTimestamp: turns[turns.length - 1]?.timestamp ?? '',
totalCostUSD: turns.reduce((s, t) => s + t.assistantCalls.reduce((s2, c) => s2 + c.costUSD, 0), 0),
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
apiCalls: turns.reduce((s, t) => s + t.assistantCalls.length, 0),
turns,
modelBreakdown: {},
toolBreakdown: {},
mcpBreakdown: {},
bashBreakdown: {},
categoryBreakdown: {} as SessionSummary['categoryBreakdown'],
skillBreakdown: {} as SessionSummary['skillBreakdown'],
}
return {
project: 'test-project',
projectPath: '/test',
sessions: [session],
totalCostUSD: session.totalCostUSD,
totalApiCalls: session.apiCalls,
}
}
describe('aggregateModelStats', () => {
it('aggregates calls, cost, and tokens per model', () => {
const project = makeProject([
makeTurn('opus-4-6', 0.10, { outputTokens: 200, inputTokens: 50, cacheRead: 5000, cacheWrite: 500 }),
makeTurn('opus-4-6', 0.15, { outputTokens: 300, inputTokens: 80, cacheRead: 6000, cacheWrite: 600 }),
makeTurn('opus-4-7', 0.25, { outputTokens: 800, inputTokens: 100, cacheRead: 7000, cacheWrite: 700 }),
])
const stats = aggregateModelStats([project])
const m6 = stats.find(s => s.model === 'opus-4-6')!
const m7 = stats.find(s => s.model === 'opus-4-7')!
expect(m6.calls).toBe(2)
expect(m6.cost).toBeCloseTo(0.25)
expect(m6.outputTokens).toBe(500)
expect(m7.calls).toBe(1)
expect(m7.cost).toBeCloseTo(0.25)
expect(m7.outputTokens).toBe(800)
})
it('attributes turn-level metrics to the primary model', () => {
const project = makeProject([
makeTurn('opus-4-6', 0.10, { hasEdits: true, retries: 0 }),
makeTurn('opus-4-6', 0.10, { hasEdits: true, retries: 2 }),
makeTurn('opus-4-7', 0.20, { hasEdits: true, retries: 0 }),
makeTurn('opus-4-7', 0.20, { hasEdits: false }),
])
const stats = aggregateModelStats([project])
const m6 = stats.find(s => s.model === 'opus-4-6')!
const m7 = stats.find(s => s.model === 'opus-4-7')!
expect(m6.editTurns).toBe(2)
expect(m6.oneShotTurns).toBe(1)
expect(m6.retries).toBe(2)
expect(m7.editTurns).toBe(1)
expect(m7.oneShotTurns).toBe(1)
expect(m7.totalTurns).toBe(2)
})
it('tracks firstSeen and lastSeen timestamps', () => {
const project = makeProject([
makeTurn('opus-4-6', 0.10, { timestamp: '2026-04-10T08:00:00Z' }),
makeTurn('opus-4-6', 0.10, { timestamp: '2026-04-15T20:00:00Z' }),
])
const stats = aggregateModelStats([project])
const m = stats.find(s => s.model === 'opus-4-6')!
expect(m.firstSeen).toBe('2026-04-10T08:00:00Z')
expect(m.lastSeen).toBe('2026-04-15T20:00:00Z')
})
it('filters out <synthetic> model entries', () => {
const project = makeProject([
makeTurn('<synthetic>', 0, {}),
makeTurn('opus-4-6', 0.10, {}),
])
const stats = aggregateModelStats([project])
expect(stats.find(s => s.model === '<synthetic>')).toBeUndefined()
expect(stats).toHaveLength(1)
})
it('returns empty array for no projects', () => {
expect(aggregateModelStats([])).toEqual([])
})
it('tracks editCost for edit turns', () => {
const project = makeProject([
makeTurn('opus-4-6', 0.10, { hasEdits: true }),
makeTurn('opus-4-6', 0.20, { hasEdits: true }),
makeTurn('opus-4-6', 0.50, { hasEdits: false }),
])
const stats = aggregateModelStats([project])
const m = stats.find(s => s.model === 'opus-4-6')!
expect(m.editCost).toBeCloseTo(0.30)
})
it('sorts by cost descending', () => {
const project = makeProject([
makeTurn('cheap-model', 0.01),
makeTurn('expensive-model', 5.00),
])
const stats = aggregateModelStats([project])
expect(stats[0].model).toBe('expensive-model')
expect(stats[1].model).toBe('cheap-model')
})
})
function makeStats(overrides: Partial<ModelStats> = {}): ModelStats {
return {
model: 'test-model',
calls: 100,
cost: 10,
outputTokens: 50000,
inputTokens: 10000,
cacheReadTokens: 20000,
cacheWriteTokens: 5000,
totalTurns: 200,
editTurns: 80,
oneShotTurns: 60,
retries: 20,
selfCorrections: 10,
editCost: 8,
firstSeen: '2026-04-01T00:00:00Z',
lastSeen: '2026-04-15T00:00:00Z',
...overrides,
}
}
describe('computeComparison', () => {
it('computes normalized metrics and picks winners correctly', () => {
const a = makeStats({ calls: 100, cost: 10, outputTokens: 50000, inputTokens: 10000, cacheReadTokens: 20000, cacheWriteTokens: 5000, editTurns: 80, oneShotTurns: 60, retries: 20, selfCorrections: 10, totalTurns: 200 })
const b = makeStats({ calls: 100, cost: 8, outputTokens: 40000, inputTokens: 10000, cacheReadTokens: 20000, cacheWriteTokens: 5000, editTurns: 80, oneShotTurns: 60, retries: 20, selfCorrections: 10, totalTurns: 200 })
const rows = computeComparison(a, b)
const costRow = rows.find(r => r.label === 'Cost / call')!
expect(costRow.valueA).toBeCloseTo(0.1)
expect(costRow.valueB).toBeCloseTo(0.08)
expect(costRow.winner).toBe('b')
const outputRow = rows.find(r => r.label === 'Output tok / call')!
expect(outputRow.valueA).toBe(500)
expect(outputRow.valueB).toBe(400)
expect(outputRow.winner).toBe('b')
})
it('returns null values for one-shot rate and retry rate when editTurns is zero', () => {
const a = makeStats({ editTurns: 0, oneShotTurns: 0, retries: 0 })
const b = makeStats({ editTurns: 80, oneShotTurns: 60, retries: 20 })
const rows = computeComparison(a, b)
const oneShotRow = rows.find(r => r.label === 'One-shot rate')!
expect(oneShotRow.valueA).toBeNull()
expect(oneShotRow.winner).toBe('none')
const retryRow = rows.find(r => r.label === 'Retry rate')!
expect(retryRow.valueA).toBeNull()
expect(retryRow.winner).toBe('none')
})
it('returns tie when values are equal', () => {
const a = makeStats({ calls: 100, cost: 10 })
const b = makeStats({ calls: 100, cost: 10 })
const rows = computeComparison(a, b)
const costRow = rows.find(r => r.label === 'Cost / call')!
expect(costRow.winner).toBe('tie')
})
it('computes cost per edit correctly', () => {
const a = makeStats({ editTurns: 40, editCost: 4 })
const b = makeStats({ editTurns: 80, editCost: 4 })
const rows = computeComparison(a, b)
const editRow = rows.find(r => r.label === 'Cost / edit')!
expect(editRow.valueA).toBeCloseTo(0.10)
expect(editRow.valueB).toBeCloseTo(0.05)
expect(editRow.winner).toBe('b')
})
it('picks higher value as winner for cache hit rate', () => {
const a = makeStats({ inputTokens: 5000, cacheReadTokens: 30000, cacheWriteTokens: 5000 })
const b = makeStats({ inputTokens: 10000, cacheReadTokens: 10000, cacheWriteTokens: 5000 })
const rows = computeComparison(a, b)
const cacheRow = rows.find(r => r.label === 'Cache hit rate')!
// Cache writes are excluded from the denominator (reads / reads + fresh
// input), matching src/menubar-json.ts and the desktop app.
const totalA = 5000 + 30000
const totalB = 10000 + 10000
expect(cacheRow.valueA).toBeCloseTo(30000 / totalA * 100)
expect(cacheRow.valueB).toBeCloseTo(10000 / totalB * 100)
expect(cacheRow.winner).toBe('a')
})
})
describe('compare JSON emitter', () => {
it('builds and renders the full comparison shape', () => {
const project = makeProject([
makeTurn('model-a', 0.10, { hasEdits: true, retries: 0 }),
makeTurn('model-b', 0.20, { hasEdits: true, retries: 1 }),
])
const models = aggregateModelStats([project])
const modelA = models.find(model => model.model === 'model-a')!
const modelB = models.find(model => model.model === 'model-b')!
modelA.selfCorrections = 2
modelB.selfCorrections = 1
const parsed = JSON.parse(renderCompareJson(
buildCompareJson([project], modelA, modelB, 'All Time', 'all'),
))
expect(Object.keys(parsed)).toEqual(['period', 'modelA', 'modelB', 'metrics', 'categories', 'workingStyle'])
expect(parsed.period).toEqual({ label: 'All Time', provider: 'all' })
expect(parsed.modelA.selfCorrections).toBe(2)
expect(parsed.metrics.some((row: { section: string }) => row.section === 'Performance')).toBe(true)
expect(parsed.metrics.some((row: { section: string }) => row.section === 'Efficiency')).toBe(true)
expect(parsed.categories).toHaveLength(1)
expect(parsed.workingStyle).toHaveLength(4)
})
})
function jsonlLine(type: string, model: string, text: string, timestamp = '2026-04-15T10:00:00Z'): string {
if (type === 'assistant') {
return JSON.stringify({
type: 'assistant', timestamp,
message: { model, content: [{ type: 'text', text }], id: `msg-${Math.random()}`, usage: { input_tokens: 0, output_tokens: 0 } },
})
}
return JSON.stringify({ type: 'user', timestamp, message: { role: 'user', content: text } })
}
describe('scanSelfCorrections', () => {
let tmpDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'codeburn-test-'))
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
it('counts apology patterns per model', async () => {
const sessionDir = join(tmpDir, 'session-abc')
await mkdir(sessionDir)
const lines = [
jsonlLine('assistant', 'opus-4-6', 'I apologize for the confusion.'),
jsonlLine('assistant', 'opus-4-6', 'Here is the result.'),
jsonlLine('assistant', 'sonnet-4-6', 'I was wrong about that.'),
jsonlLine('user', '', 'Do this'),
]
await writeFile(join(sessionDir, 'session.jsonl'), lines.join('\n') + '\n')
const result = await scanSelfCorrections([tmpDir])
expect(result.get('opus-4-6')).toBe(1)
expect(result.get('sonnet-4-6')).toBe(1)
})
it('does not count non-apology text', async () => {
const sessionDir = join(tmpDir, 'session-xyz')
await mkdir(sessionDir)
const lines = [
jsonlLine('assistant', 'opus-4-6', 'Here is the updated code.'),
jsonlLine('assistant', 'opus-4-6', 'Let me fix that for you.'),
]
await writeFile(join(sessionDir, 'session.jsonl'), lines.join('\n') + '\n')
const result = await scanSelfCorrections([tmpDir])
expect(result.get('opus-4-6')).toBeUndefined()
expect(result.size).toBe(0)
})
it('returns empty map for missing directory', async () => {
const result = await scanSelfCorrections([join(tmpDir, 'nonexistent')])
expect(result.size).toBe(0)
})
it('returns empty map for empty directory', async () => {
const result = await scanSelfCorrections([tmpDir])
expect(result.size).toBe(0)
})
it('scans subagent directories', async () => {
const sessionDir = join(tmpDir, 'session-sub')
const subagentsDir = join(sessionDir, 'subagents')
await mkdir(subagentsDir, { recursive: true })
const lines = [
jsonlLine('assistant', 'haiku-4-6', 'My mistake, let me redo that.'),
]
await writeFile(join(subagentsDir, 'sub.jsonl'), lines.join('\n') + '\n')
const result = await scanSelfCorrections([tmpDir])
expect(result.get('haiku-4-6')).toBe(1)
})
it('skips <synthetic> models', async () => {
const sessionDir = join(tmpDir, 'session-synth')
await mkdir(sessionDir)
const lines = [
jsonlLine('assistant', '<synthetic>', 'I apologize for the error.'),
]
await writeFile(join(sessionDir, 'session.jsonl'), lines.join('\n') + '\n')
const result = await scanSelfCorrections([tmpDir])
expect(result.get('<synthetic>')).toBeUndefined()
expect(result.size).toBe(0)
})
it('accumulates counts across multiple sessions and directories', async () => {
const sessionA = join(tmpDir, 'session-a')
const sessionB = join(tmpDir, 'session-b')
await mkdir(sessionA)
await mkdir(sessionB)
await writeFile(join(sessionA, 'a.jsonl'), [
jsonlLine('assistant', 'opus-4-6', 'I was wrong.', '2026-04-15T10:00:00Z'),
jsonlLine('assistant', 'opus-4-6', 'My bad!', '2026-04-15T10:01:00Z'),
].join('\n') + '\n')
await writeFile(join(sessionB, 'b.jsonl'), [
jsonlLine('assistant', 'opus-4-6', 'I apologize.', '2026-04-15T10:02:00Z'),
].join('\n') + '\n')
const result = await scanSelfCorrections([tmpDir])
expect(result.get('opus-4-6')).toBe(3)
})
it('handles malformed JSON lines gracefully', async () => {
const sessionDir = join(tmpDir, 'session-bad')
await mkdir(sessionDir)
await writeFile(join(sessionDir, 'bad.jsonl'), [
'not valid json',
jsonlLine('assistant', 'opus-4-6', 'I apologize.'),
].join('\n') + '\n')
const result = await scanSelfCorrections([tmpDir])
expect(result.get('opus-4-6')).toBe(1)
})
it('accepts multiple sessionDirs and merges counts', async () => {
const dir2 = await mkdtemp(join(tmpdir(), 'codeburn-test2-'))
try {
const sessionA = join(tmpDir, 'session-a')
const sessionB = join(dir2, 'session-b')
await mkdir(sessionA)
await mkdir(sessionB)
await writeFile(join(sessionA, 'a.jsonl'), [
jsonlLine('assistant', 'sonnet-4-6', 'My mistake.', '2026-04-15T10:00:00Z'),
].join('\n') + '\n')
await writeFile(join(sessionB, 'b.jsonl'), [
jsonlLine('assistant', 'sonnet-4-6', 'I was wrong.', '2026-04-15T10:01:00Z'),
].join('\n') + '\n')
const result = await scanSelfCorrections([tmpDir, dir2])
expect(result.get('sonnet-4-6')).toBe(2)
} finally {
await rm(dir2, { recursive: true, force: true })
}
})
})
describe('computeCategoryComparison', () => {
it('returns per-category one-shot rates for both models', () => {
const project = makeProject([
makeTurn('model-a', 0.10, { hasEdits: true, retries: 0, category: 'coding' }),
makeTurn('model-a', 0.10, { hasEdits: true, retries: 1, category: 'coding' }),
makeTurn('model-b', 0.10, { hasEdits: true, retries: 0, category: 'coding' }),
makeTurn('model-b', 0.10, { hasEdits: true, retries: 0, category: 'coding' }),
makeTurn('model-a', 0.10, { hasEdits: true, retries: 0, category: 'debugging' }),
makeTurn('model-b', 0.10, { hasEdits: true, retries: 1, category: 'debugging' }),
])
const result = computeCategoryComparison([project], 'model-a', 'model-b')
const coding = result.find(r => r.category === 'coding')!
expect(coding.editTurnsA).toBe(2)
expect(coding.oneShotRateA).toBeCloseTo(50)
expect(coding.editTurnsB).toBe(2)
expect(coding.oneShotRateB).toBeCloseTo(100)
expect(coding.winner).toBe('b')
const debugging = result.find(r => r.category === 'debugging')!
expect(debugging.oneShotRateA).toBeCloseTo(100)
expect(debugging.oneShotRateB).toBeCloseTo(0)
expect(debugging.winner).toBe('a')
})
it('skips categories with no edit turns', () => {
const project = makeProject([
makeTurn('model-a', 0.10, { hasEdits: false, category: 'conversation' }),
makeTurn('model-b', 0.10, { hasEdits: false, category: 'conversation' }),
makeTurn('model-a', 0.10, { hasEdits: true, category: 'coding' }),
])
const result = computeCategoryComparison([project], 'model-a', 'model-b')
expect(result.find(r => r.category === 'conversation')).toBeUndefined()
expect(result).toHaveLength(1)
})
it('sorts by total turns descending', () => {
const project = makeProject([
makeTurn('model-a', 0.10, { hasEdits: true, category: 'coding' }),
makeTurn('model-a', 0.10, { hasEdits: true, category: 'coding' }),
makeTurn('model-a', 0.10, { hasEdits: true, category: 'coding' }),
makeTurn('model-b', 0.10, { hasEdits: true, category: 'coding' }),
makeTurn('model-a', 0.10, { hasEdits: true, category: 'debugging' }),
])
const result = computeCategoryComparison([project], 'model-a', 'model-b')
expect(result[0].category).toBe('coding')
})
it('returns null one-shot rate when model has no edits in category', () => {
const project = makeProject([
makeTurn('model-a', 0.10, { hasEdits: true, category: 'coding' }),
makeTurn('model-b', 0.10, { hasEdits: false, category: 'coding' }),
])
const result = computeCategoryComparison([project], 'model-a', 'model-b')
const coding = result.find(r => r.category === 'coding')!
expect(coding.oneShotRateA).toBeCloseTo(100)
expect(coding.oneShotRateB).toBeNull()
expect(coding.winner).toBe('none')
})
})
describe('computeWorkingStyle', () => {
it('computes delegation and planning rates', () => {
const project = makeProject([
makeTurn('model-a', 0.10, { hasAgentSpawn: true }),
makeTurn('model-a', 0.10, {}),
makeTurn('model-a', 0.10, { hasPlanMode: true }),
makeTurn('model-b', 0.10, {}),
makeTurn('model-b', 0.10, {}),
])
const result = computeWorkingStyle([project], 'model-a', 'model-b')
const delegation = result.find(r => r.label === 'Delegation rate')!
expect(delegation.valueA).toBeCloseTo(100 / 3)
expect(delegation.valueB).toBeCloseTo(0)
const planning = result.find(r => r.label === 'Planning rate')!
expect(planning.valueA).toBeCloseTo(100 / 3)
expect(planning.valueB).toBeCloseTo(0)
})
it('computes avg tools per turn', () => {
const project = makeProject([
makeTurn('model-a', 0.10, { hasEdits: true }),
makeTurn('model-a', 0.10, {}),
makeTurn('model-b', 0.10, { hasEdits: true }),
])
const result = computeWorkingStyle([project], 'model-a', 'model-b')
const tools = result.find(r => r.label === 'Avg tools / turn')!
expect(tools.valueA).toBeCloseTo(1)
expect(tools.valueB).toBeCloseTo(1)
})
it('computes fast mode usage', () => {
const project = makeProject([
makeTurn('model-a', 0.10, { speed: 'fast' }),
makeTurn('model-a', 0.10, {}),
makeTurn('model-b', 0.10, { speed: 'fast' }),
makeTurn('model-b', 0.10, { speed: 'fast' }),
])
const result = computeWorkingStyle([project], 'model-a', 'model-b')
const fast = result.find(r => r.label === 'Fast mode usage')!
expect(fast.valueA).toBeCloseTo(50)
expect(fast.valueB).toBeCloseTo(100)
})
it('returns null for models with no turns', () => {
const project = makeProject([
makeTurn('model-a', 0.10, {}),
])
const result = computeWorkingStyle([project], 'model-a', 'model-b')
const delegation = result.find(r => r.label === 'Delegation rate')!
expect(delegation.valueA).toBeCloseTo(0)
expect(delegation.valueB).toBeNull()
})
it('counts TaskCreate as planning', () => {
const project = makeProject([
makeTurn('model-a', 0.10, { tools: ['TaskCreate'] }),
makeTurn('model-a', 0.10, { tools: ['Read'] }),
makeTurn('model-a', 0.10, { tools: ['Edit'] }),
])
const result = computeWorkingStyle([project], 'model-a', 'model-b')
const planning = result.find(r => r.label === 'Planning rate')!
expect(planning.valueA).toBeCloseTo(100 / 3)
})
it('counts TaskUpdate as planning', () => {
const project = makeProject([
makeTurn('model-a', 0.10, { tools: ['TaskUpdate'] }),
makeTurn('model-a', 0.10, { tools: ['Read'] }),
])
const result = computeWorkingStyle([project], 'model-a', 'model-b')
const planning = result.find(r => r.label === 'Planning rate')!
expect(planning.valueA).toBeCloseTo(50)
})
it('counts TodoWrite as planning', () => {
const project = makeProject([
makeTurn('model-a', 0.10, { tools: ['TodoWrite', 'Read'] }),
makeTurn('model-a', 0.10, { tools: ['Bash'] }),
])
const result = computeWorkingStyle([project], 'model-a', 'model-b')
const planning = result.find(r => r.label === 'Planning rate')!
expect(planning.valueA).toBeCloseTo(50)
})
it('counts turn with planning tool + edits as planning', () => {
const project = makeProject([
makeTurn('model-a', 0.10, { tools: ['TaskCreate', 'Edit', 'Read'] }),
makeTurn('model-a', 0.10, { tools: ['Edit'] }),
])
const result = computeWorkingStyle([project], 'model-a', 'model-b')
const planning = result.find(r => r.label === 'Planning rate')!
expect(planning.valueA).toBeCloseTo(50)
})
it('does not count regular tools as planning', () => {
const project = makeProject([
makeTurn('model-a', 0.10, { tools: ['Read', 'Grep', 'Glob'] }),
makeTurn('model-a', 0.10, { tools: ['Edit', 'Bash'] }),
makeTurn('model-a', 0.10, { tools: ['Agent'] }),
])
const result = computeWorkingStyle([project], 'model-a', 'model-b')
const planning = result.find(r => r.label === 'Planning rate')!
expect(planning.valueA).toBeCloseTo(0)
})
it('counts planning once per turn even with multiple planning tools', () => {
const project = makeProject([
makeTurn('model-a', 0.10, { tools: ['TaskCreate', 'TaskUpdate', 'TaskCreate'] }),
makeTurn('model-a', 0.10, { tools: ['Read'] }),
])
const result = computeWorkingStyle([project], 'model-a', 'model-b')
const planning = result.find(r => r.label === 'Planning rate')!
expect(planning.valueA).toBeCloseTo(50)
})
it('hasPlanMode still triggers planning rate', () => {
const project = makeProject([
makeTurn('model-a', 0.10, { hasPlanMode: true, tools: ['Read'] }),
makeTurn('model-a', 0.10, { tools: ['Read'] }),
])
const result = computeWorkingStyle([project], 'model-a', 'model-b')
const planning = result.find(r => r.label === 'Planning rate')!
expect(planning.valueA).toBeCloseTo(50)
})
})
// Issue #767 item 1: `codeburn compare --model-a/--model-b` (both the JSON
// path and the TUI picker) required the canonical model id (e.g.
// claude-opus-4-8) with no way to pass the display name shown in the picker
// itself (e.g. "Opus 4.8"). findModelStat is the shared lookup both surfaces
// use, reusing getShortModelName (the existing canonical -> display mapping)
// instead of a new alias table.
describe('findModelStat', () => {
it('matches the exact canonical model id', () => {
const project = makeProject([makeTurn('claude-opus-4-6', 0.10)])
const stats = aggregateModelStats([project])
expect(findModelStat(stats, 'claude-opus-4-6')?.model).toBe('claude-opus-4-6')
})
it('matches the display name, case-insensitively', () => {
const project = makeProject([makeTurn('claude-opus-4-6', 0.10)])
const stats = aggregateModelStats([project])
expect(findModelStat(stats, 'opus 4.6')?.model).toBe('claude-opus-4-6')
expect(findModelStat(stats, 'Opus 4.6')?.model).toBe('claude-opus-4-6')
})
it('returns undefined for an unknown model', () => {
const project = makeProject([makeTurn('claude-opus-4-6', 0.10)])
const stats = aggregateModelStats([project])
expect(findModelStat(stats, 'claude-opus-9-9')).toBeUndefined()
})
})
// Copilot serve sets carry supplementary accounting calls (shutdown rollups,
// residuals, store rows paired with an already-counted per-turn call). They hold
// real tokens/cost but no behavioral evidence, so the compare report — which is
// entirely per-call/per-turn ratios — must not weigh them.
function supplement(turn: ClassifiedTurn, model: string, cost: number): ClassifiedTurn {
turn.assistantCalls.unshift({
...turn.assistantCalls[0]!,
model,
costUSD: cost,
supplementaryAccounting: true,
deduplicationKey: `supp-${Math.random()}`,
})
return turn
}
describe('supplementary accounting weight', () => {
it('takes the primary model from the first behavioral call, not a leading supplementary one', () => {
const project = makeProject([
supplement(makeTurn('opus-4-6', 0.10, { hasEdits: true }), 'rollup-model', 0.01),
])
const stats = aggregateModelStats([project])
expect(stats.find(s => s.model === 'opus-4-6')!.totalTurns).toBe(1)
expect(stats.find(s => s.model === 'rollup-model')?.totalTurns ?? 0).toBe(0)
})
it('keeps supplementary cost and tokens but does not count them as calls', () => {
const project = makeProject([
supplement(makeTurn('opus-4-6', 0.10), 'opus-4-6', 0.04),
])
const m = aggregateModelStats([project]).find(s => s.model === 'opus-4-6')!
expect(m.calls).toBe(1)
expect(m.cost).toBeCloseTo(0.14)
expect(m.outputTokens).toBe(400)
})
it('excludes an accounting-only turn from every efficiency surface', () => {
const accountingOnly = makeTurn('opus-4-6', 0.09, { hasEdits: true, category: 'debugging', speed: 'fast', hasAgentSpawn: true })
accountingOnly.assistantCalls[0]!.supplementaryAccounting = true
const project = makeProject([
makeTurn('opus-4-6', 0.10, { hasEdits: true, category: 'coding' }),
accountingOnly,
])
const m = aggregateModelStats([project]).find(s => s.model === 'opus-4-6')!
expect(m.totalTurns).toBe(1)
expect(m.editTurns).toBe(1)
const categories = computeCategoryComparison([project], 'opus-4-6', 'other-model')
expect(categories.map(c => c.category)).toEqual(['coding'])
const style = computeWorkingStyle([project], 'opus-4-6', 'other-model')
expect(style.find(r => r.label === 'Delegation rate')!.valueA).toBeCloseTo(0)
expect(style.find(r => r.label === 'Fast mode usage')!.valueA).toBeCloseTo(0)
})
})