fix(models): rank unpriced rows before --top slices them

Filtering before the slice was necessary but not sufficient. Every unpriced row
is $0 on both cost and savings -- findUnpricedModels excludes anything carrying
a local-savings baseline -- so they all tie under aggregateModels' sort key, and
Array#sort is stable. The surviving order was Map insertion order: the order
each model's first assistant call appears in the transcript. So --unpriced
--top N kept the N that showed up earliest, and a model holding almost all of
the unpriced volume was dropped if it appeared late.

findUnpricedModels already sorts by tokens descending, then calls, then model
name, and the dashboard warning renders that order. It is now called once over
the whole row set, its order becomes a rank index, and the rows are ranked
before the slice -- so the CLI and the warning agree on which N, which is what
the README row claims. In the breakdown modes several rows share one model, so
they share that model's rank and N still counts rows.

The previous test could not catch this: its fixture held one unpriced model, so
--top 2 never truncated anything and deleting the slice line left the suite
green. It now uses three unpriced models emitted in an order that differs from
their size order, and asserts which two survive rather than only how many.
This commit is contained in:
ozymandiashh 2026-08-18 05:34:51 +03:00
parent 11173758b5
commit d81fca3066
3 changed files with 26 additions and 19 deletions

View file

@ -18,7 +18,7 @@
- **The resident `codeburn serve` child.** The first real panel request is also the cache warm-up, so startup never runs an artificial warm-up query beside a duplicate one-shot child; each served command carries its own read-only option allowlist, and anything outside it falls back to a normal spawn; the child exits when its stdin closes, so it can never outlive the app. Requests whose response exceeds the 16 MiB frame limit still replace the child, but that deliberate kill no longer spends the resident's unexpected-death budget. (#972)
### Fixed
- **`codeburn models --unpriced --top N` returned nothing.** `--top` is applied inside `aggregateModels`, before the unpriced filter, on rows sorted by cost + savings descending — and unpriced rows are $0 on both, so they sorted last and the slice removed exactly the rows the flag exists to show. A user with unpriced models was told they had none. The slice now runs after the filter. (#969)
- **`codeburn models --unpriced --top N` returned nothing for a `--top N` smaller than the number of priced models.** `--top` is applied inside `aggregateModels`, before the unpriced filter, on rows sorted cost-first — and unpriced rows are $0 on both, so they sorted last and the slice removed exactly the rows the flag exists to show. A user with unpriced models was told they had none. The slice now runs after the filter — and after ranking, because unpriced rows tie at $0 on both keys, so slicing them in aggregate order kept whichever models happened to appear earliest in the transcript rather than the largest. The order now matches the one the unpriced-models warning shows. (#969)
- **Bash command splitting was quadratic on long whitespace-heavy commands.** The separator regex retried its leading `\s*` from every offset; matching the separator alone and widening over whitespace by hand makes cold parse ~24% and warm ~40% faster on large corpora, output unchanged.
- **Cold parse no longer retains full message bodies through cached previews.** `flatSlice` skipped its Buffer round-trip for strings already within the bound, but provider adapters pre-truncate user-message previews with `.slice(0, 500)` before the cache-site call — those pre-sliced views are still V8 SlicedStrings pinning their large parent, so the retention that OOM'd cold parses of large histories survived. The round-trip now always runs.
- **Kiro sessions carry the real `projectPath`** (CLI meta.cwd, v2 `workspacePaths[0]`, workspace sessions' `workspaceDirectory`), so git-repo attribution can resolve them; previously they were attribution-blind. Bumps the kiro parse version, so the first run after upgrade re-parses kiro history once, and kiro sessions in linked git worktrees now group under the main repo.

View file

@ -2105,21 +2105,29 @@ program
byTask: !!opts.byTask,
byAgent: !!opts.byAgent,
taskFilter: opts.task,
// `aggregateModels` slices to topN on rows sorted by cost + savings
// descending. Unpriced rows are $0 on both (findUnpricedModels excludes
// anything with a local-savings baseline), so they always sort last and
// `--top` would remove exactly the rows `--unpriced` exists to show.
// Take the whole set here and slice after filtering instead.
// `aggregateModels` filters and slices before the unpriced filter. Its
// rows are sorted cost-first, so a small --top would remove exactly the
// rows `--unpriced` exists to show. Take the whole set here and slice
// after filtering and ranking instead.
topN: opts.unpriced ? undefined : topN,
minCost: typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) ? opts.minCost : (opts.unpriced ? 0 : 0.01),
})
if (opts.unpriced) {
rows = rows.filter(row => findUnpricedModels([{
const unpriced = findUnpricedModels(rows.map(row => ({
model: row.model,
calls: row.calls,
cost: row.costUSD,
tokens: row.totalTokens,
}]).length > 0)
})))
const unpricedRank = new Map<string, number>()
for (const [rank, usage] of unpriced.entries()) {
// Breakdown modes can emit several rows for one model. Keep the first
// rank so all rows for that model stay together and N still counts rows.
if (!unpricedRank.has(usage.model)) unpricedRank.set(usage.model, rank)
}
rows = rows
.filter(row => unpricedRank.has(row.model))
.sort((a, b) => (unpricedRank.get(a.model)! - unpricedRank.get(b.model)!))
if (topN !== undefined) rows = rows.slice(0, topN)
}

View file

@ -776,10 +776,8 @@ describe('models CLI breakdown flags', () => {
}
})
// `--top` is applied inside aggregateModels, before the unpriced filter runs,
// on rows sorted by cost + savings descending. Unpriced rows are $0 on both,
// so they sort last and a small --top removed exactly the rows --unpriced
// exists to surface: the user was told they had no unpriced models.
// Unpriced rows all sort at $0 in aggregateModels, so the old implementation
// preserved transcript/Map order instead of findUnpricedModels' token order.
it('keeps unpriced rows when --unpriced is combined with --top', async () => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-models-unpriced-top-'))
try {
@ -802,13 +800,13 @@ describe('models CLI breakdown flags', () => {
sessionId: 'models-unpriced-top-session',
timestamp: '2026-05-09T00:00:00.000Z',
cwd: '/tmp/models-unpriced-top',
message: { role: 'user', content: 'Two priced models outrank the unpriced one.' },
message: { role: 'user', content: 'Three unpriced models arrive small-first.' },
}),
// Both priced models cost more than the $0 unpriced row, so they take
// both --top slots unless the filter runs first.
assistant('opus', 'claude-opus-4-6', '2026-05-09T00:01:00.000Z', 5000),
assistant('sonnet', 'claude-sonnet-4-6', '2026-05-09T00:02:00.000Z', 3000),
assistant('unpriced', 'zz-unpriced-frontier-model', '2026-05-09T00:03:00.000Z', 2000),
// Transcript order is deliberately different from token order:
// 1.1k, 9.1k, 5.1k total tokens. The two largest must survive --top 2.
assistant('small', 'zz-unpriced-small', '2026-05-09T00:01:00.000Z', 1000),
assistant('largest', 'zz-unpriced-largest', '2026-05-09T00:02:00.000Z', 9000),
assistant('middle', 'zz-unpriced-middle', '2026-05-09T00:03:00.000Z', 5000),
].join('\n') + '\n')
const res = spawnSync(
@ -819,7 +817,8 @@ describe('models CLI breakdown flags', () => {
expect(res.status, `stdout: ${res.stdout}\nstderr: ${res.stderr}`).toBe(0)
const rows = JSON.parse(res.stdout) as Array<{ model: string }>
expect(rows.map(row => row.model)).toEqual(['zz-unpriced-frontier-model'])
expect(rows).toHaveLength(2)
expect(rows.map(row => row.model)).toEqual(['zz-unpriced-largest', 'zz-unpriced-middle'])
} finally {
await rm(home, { recursive: true, force: true })
}