Skip to content

Commit 3959bff

Browse files
authored
feat(tco-feed): serve the tier-weighted per-hardware score via view=scores
1 parent 667baac commit 3959bff

4 files changed

Lines changed: 499 additions & 9 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,9 @@ API routes (`packages/app/src/app/api/v1/`):
7272
- `evaluations` — raw `EvalRow[]`
7373
- `server-log` — retrieve benchmark runtime logs
7474
- `invalidate` — invalidate API cache (admin)
75-
- `tco-feed?model=dsv4&workloads=1024x1024,8192x1024&tiers=30,50,75,100&format=csv` — per-hardware Pareto-frontier output-throughput reads at fixed interactivity tiers, for external spreadsheet TCO models (Excel Power Query)
75+
- `tco-feed?model=dsv4&workloads=1024x1024,8192x1024&tiers=30,50,75,100&format=csv` — per-hardware Pareto-frontier output-throughput reads at fixed interactivity tiers, for external spreadsheet TCO models (Excel Power Query); `view=scores` (optional `weights`, `workload_weights`, `alpha`) folds them into one tier-weighted, workload-blended, output-equivalent score per hardware
7676

77-
**API routes return raw DB data** — no presentation logic. Frontend handles all transformations. Sole exception: `tco-feed`, which runs the calculator's frontier interpolation server-side because its consumers (spreadsheets) cannot execute the TS transforms; it serves reads only — weights/assumptions stay with the consumer.
77+
**API routes return raw DB data** — no presentation logic. Frontend handles all transformations. Sole exception: `tco-feed`, which runs the calculator's frontier interpolation server-side because its consumers (spreadsheets) cannot execute the TS transforms; its assumptions (tier weights, workload mix, α) enter only as explicit query params with documented defaults, so a published sheet's URL fully records its methodology.
7878

7979
Static content routes (no DB):
8080

packages/app/src/app/api/v1/tco-feed/route.ts

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,14 @@ import { loadFixture } from '@/lib/test-fixtures';
1010

1111
import {
1212
computeTcoFeed,
13+
computeTcoScores,
14+
parseAlpha,
1315
parseTiers,
16+
parseTierWeights,
1417
parseWorkloads,
18+
parseWorkloadWeights,
1519
tcoFeedToCsv,
20+
tcoScoresToCsv,
1621
type TcoFeedRow,
1722
type TcoFeedSourceRow,
1823
type TcoFeedWorkload,
@@ -31,9 +36,11 @@ export const dynamic = 'force-dynamic';
3136
* data" convention: it applies the dashboard's frontier interpolation
3237
* (calculator/interpolation.ts) server-side so external consumers get
3338
* numbers identical to what the chart renders, without reimplementing —
34-
* and inevitably drifting from — the spline. Keep all *assumptions*
35-
* (tier weights, workload mix, token-value ratios) out of this route;
36-
* it serves reads, consumers apply weights.
39+
* and inevitably drifting from — the spline. Assumptions (tier weights,
40+
* workload mix, the α token-value ratio) enter ONLY as query params on the
41+
* `scores` view, never as hidden constants beyond the documented defaults —
42+
* a published sheet's URL fully records its methodology, and `view=scores`
43+
* is exactly SUMPRODUCT over the `points` view, reproducible by hand.
3744
*
3845
* Query params (all optional):
3946
* - model — DB key (`dsv4`) or display name (`DeepSeek-V4-Pro`).
@@ -45,6 +52,21 @@ export const dynamic = 'force-dynamic';
4552
* - date — `YYYY-MM-DD`; reads use data as of this date
4653
* (reproducibility of published sheets). Default: latest.
4754
* - format — `json` (default) or `csv` (one-line Power Query import).
55+
* - view — `points` (default): one row per hardware × workload × tier.
56+
* `scores`: one row per hardware — tier-weighted, workload-
57+
* blended, output-equivalent tok/s/GPU (the single number a
58+
* TCO sheet consumes per chip).
59+
*
60+
* `scores`-only params (400 with `view=points`):
61+
* - weights — per-tier traffic-mix weights, comma-separated,
62+
* normalized to sum 1. Default `0.35,0.4,0.2,0.05`
63+
* for the default tiers, equal weights otherwise.
64+
* - workload_weights — per-workload blend weights, normalized to sum 1.
65+
* Default: equal split. Renormalized over covered
66+
* workloads when a chip lacks data for one.
67+
* - alpha — input-token value ratio in the output-equivalent
68+
* conversion `out × (1 + α × ISL/OSL)`. Default 0.25;
69+
* `alpha=0` scores plain output throughput.
4870
*/
4971

5072
const getCachedTcoFeed = cachedQuery(
@@ -104,11 +126,75 @@ export async function GET(request: NextRequest) {
104126
return NextResponse.json({ error: 'Invalid format — expected json or csv' }, { status: 400 });
105127
}
106128

129+
const view = params.get('view') ?? 'points';
130+
if (view !== 'points' && view !== 'scores') {
131+
return NextResponse.json(
132+
{ error: 'Invalid view — expected points or scores' },
133+
{ status: 400 },
134+
);
135+
}
136+
137+
const rawWeights = params.get('weights');
138+
const rawWorkloadWeights = params.get('workload_weights');
139+
const rawAlpha = params.get('alpha');
140+
if (view === 'points' && (rawWeights ?? rawWorkloadWeights ?? rawAlpha) !== null) {
141+
// Reject rather than ignore — a consumer passing weights to the points
142+
// view would silently get unweighted numbers.
143+
return NextResponse.json(
144+
{ error: 'weights, workload_weights, and alpha require view=scores' },
145+
{ status: 400 },
146+
);
147+
}
148+
149+
const tierWeights = parseTierWeights(rawWeights, tiers);
150+
if (!tierWeights) {
151+
return NextResponse.json(
152+
{ error: 'Invalid weights — expected one non-negative number per tier, sum > 0' },
153+
{ status: 400 },
154+
);
155+
}
156+
157+
const workloadWeights = parseWorkloadWeights(rawWorkloadWeights, workloads);
158+
if (!workloadWeights) {
159+
return NextResponse.json(
160+
{
161+
error: 'Invalid workload_weights — expected one non-negative number per workload, sum > 0',
162+
},
163+
{ status: 400 },
164+
);
165+
}
166+
167+
const alpha = parseAlpha(rawAlpha);
168+
if (alpha === null) {
169+
return NextResponse.json(
170+
{ error: 'Invalid alpha — expected a number in [0, 10]' },
171+
{ status: 400 },
172+
);
173+
}
174+
107175
try {
108176
const rows = FIXTURES_MODE
109177
? computeTcoFeed(loadFixture<TcoFeedSourceRow[]>('benchmarks'), workloads, tiers)
110178
: await getCachedTcoFeed(dbModelKeys, date, workloads, tiers);
111179

180+
if (view === 'scores') {
181+
const scores = computeTcoScores(rows, workloads, tiers, tierWeights, workloadWeights, alpha);
182+
if (format === 'csv') {
183+
return cachedText(tcoScoresToCsv(scores, workloads), 'text/csv; charset=utf-8');
184+
}
185+
return cachedJson({
186+
model,
187+
db_model_keys: dbModelKeys,
188+
date: date ?? null,
189+
workloads: workloads.map((w) => `${w.isl}x${w.osl}`),
190+
tiers,
191+
weights: tierWeights,
192+
workload_weights: workloadWeights,
193+
alpha,
194+
rows: scores,
195+
});
196+
}
197+
112198
if (format === 'csv') {
113199
return cachedText(tcoFeedToCsv(rows), 'text/csv; charset=utf-8');
114200
}

packages/app/src/lib/tco-feed.test.ts

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,18 @@ import { describe, expect, it } from 'vitest';
22

33
import {
44
computeTcoFeed,
5+
computeTcoScores,
6+
DEFAULT_ALPHA,
7+
DEFAULT_TIER_WEIGHTS,
58
DEFAULT_TIERS,
69
DEFAULT_WORKLOADS,
10+
parseAlpha,
711
parseTiers,
12+
parseTierWeights,
813
parseWorkloads,
14+
parseWorkloadWeights,
915
tcoFeedToCsv,
16+
tcoScoresToCsv,
1017
type TcoFeedRow,
1118
type TcoFeedSourceRow,
1219
} from './tco-feed';
@@ -219,6 +226,11 @@ describe('parseTiers', () => {
219226
expect(parseTiers('10001')).toBeNull();
220227
expect(parseTiers(Array.from({ length: 21 }, (_, i) => String(i + 1)).join(','))).toBeNull();
221228
});
229+
230+
it('rejects duplicate tiers (would double-count in the scores view)', () => {
231+
expect(parseTiers('50,50')).toBeNull();
232+
expect(parseTiers('50,50.0')).toBeNull(); // same numeric value
233+
});
222234
});
223235

224236
describe('parseWorkloads', () => {
@@ -242,6 +254,68 @@ describe('parseWorkloads', () => {
242254
expect(parseWorkloads('8192x')).toBeNull();
243255
expect(parseWorkloads(Array.from({ length: 9 }, () => '1024x1024').join(','))).toBeNull();
244256
});
257+
258+
it('rejects duplicate workloads (would double-count in the scores view)', () => {
259+
expect(parseWorkloads('8192x1024,8192x1024')).toBeNull();
260+
});
261+
});
262+
263+
describe('parseTierWeights', () => {
264+
it('defaults to the traffic-mix weights for the default tiers', () => {
265+
expect(parseTierWeights(null, DEFAULT_TIERS)).toEqual([...DEFAULT_TIER_WEIGHTS]);
266+
expect(parseTierWeights(' ', [30, 50, 75, 100])).toEqual([...DEFAULT_TIER_WEIGHTS]);
267+
});
268+
269+
it('defaults to equal weights for custom tiers', () => {
270+
expect(parseTierWeights(null, [20, 60])).toEqual([0.5, 0.5]);
271+
});
272+
273+
it('normalizes provided weights to sum 1', () => {
274+
expect(parseTierWeights('2,2,4,2', [30, 50, 75, 100])).toEqual([0.2, 0.2, 0.4, 0.2]);
275+
});
276+
277+
it('rejects count mismatch, negatives, zero sums, and non-numbers', () => {
278+
expect(parseTierWeights('0.5,0.5', [30, 50, 75])).toBeNull();
279+
expect(parseTierWeights('-1,2', [30, 50])).toBeNull();
280+
expect(parseTierWeights('0,0', [30, 50])).toBeNull();
281+
expect(parseTierWeights('a,b', [30, 50])).toBeNull();
282+
expect(parseTierWeights('0.5,,0.5', [30, 50, 75])).toBeNull();
283+
});
284+
});
285+
286+
describe('parseWorkloadWeights', () => {
287+
it('defaults to an equal split', () => {
288+
expect(parseWorkloadWeights(null, [...DEFAULT_WORKLOADS])).toEqual([0.5, 0.5]);
289+
});
290+
291+
it('normalizes provided weights to sum 1', () => {
292+
expect(parseWorkloadWeights('3,1', [...DEFAULT_WORKLOADS])).toEqual([0.75, 0.25]);
293+
});
294+
295+
it('rejects count mismatch, negatives, and zero sums', () => {
296+
expect(parseWorkloadWeights('1', [...DEFAULT_WORKLOADS])).toBeNull();
297+
expect(parseWorkloadWeights('1,-1', [...DEFAULT_WORKLOADS])).toBeNull();
298+
expect(parseWorkloadWeights('0,0', [...DEFAULT_WORKLOADS])).toBeNull();
299+
});
300+
});
301+
302+
describe('parseAlpha', () => {
303+
it('defaults when absent or blank', () => {
304+
expect(parseAlpha(null)).toBe(DEFAULT_ALPHA);
305+
expect(parseAlpha(' ')).toBe(DEFAULT_ALPHA);
306+
});
307+
308+
it('parses valid values including 0 (plain output throughput)', () => {
309+
expect(parseAlpha('0')).toBe(0);
310+
expect(parseAlpha('0.5')).toBe(0.5);
311+
expect(parseAlpha('10')).toBe(10);
312+
});
313+
314+
it('rejects negatives, out-of-range, and non-numbers', () => {
315+
expect(parseAlpha('-0.1')).toBeNull();
316+
expect(parseAlpha('10.1')).toBeNull();
317+
expect(parseAlpha('abc')).toBeNull();
318+
});
245319
});
246320

247321
describe('tcoFeedToCsv', () => {
@@ -259,3 +333,114 @@ describe('tcoFeedToCsv', () => {
259333
expect(csv.endsWith('\n')).toBe(true);
260334
});
261335
});
336+
337+
const BOTH_WORKLOADS = [
338+
{ isl: 1024, osl: 1024 },
339+
{ isl: 8192, osl: 1024 },
340+
];
341+
342+
/**
343+
* b200 covers both workloads (single-knot frontiers read exactly at tier
344+
* 50); gb300 covers only 8k1k.
345+
*/
346+
function twoWorkloadRows(): TcoFeedSourceRow[] {
347+
return [
348+
makeRow({
349+
hardware: 'b200',
350+
isl: 1024,
351+
osl: 1024,
352+
itl: 1 / 50,
353+
otput: 1000,
354+
date: '2026-05-01',
355+
}),
356+
makeRow({ hardware: 'b200', itl: 1 / 50, otput: 400, date: '2026-07-01' }),
357+
makeRow({ hardware: 'gb300', itl: 1 / 50, otput: 5000 }),
358+
];
359+
}
360+
361+
describe('computeTcoScores', () => {
362+
it('is exactly the weighted sum over the points view, times the output-equivalent factor', () => {
363+
// Knots at iv 20/50/100 read exactly at those tiers: 1000/400/100.
364+
const tiers = [20, 50, 100];
365+
const feed = computeTcoFeed(threeKnotRows(), WORKLOAD_8K1K, tiers);
366+
const scores = computeTcoScores(feed, WORKLOAD_8K1K, tiers, [0.5, 0.3, 0.2], [1], 0.25);
367+
expect(scores).toHaveLength(1);
368+
// 0.5·1000 + 0.3·400 + 0.2·100 = 640, ×(1 + 0.25·8192/1024) = ×3.
369+
expect(scores[0].workload_scores['8192x1024']).toBe(640);
370+
expect(scores[0].score).toBe(1920);
371+
// alpha=0 scores plain output throughput.
372+
const plain = computeTcoScores(feed, WORKLOAD_8K1K, tiers, [0.5, 0.3, 0.2], [1], 0);
373+
expect(plain[0].score).toBe(640);
374+
});
375+
376+
it('unreachable tiers contribute 0 at full weight; clamped tiers contribute the clamp', () => {
377+
// Frontier spans iv 40 → 80.
378+
const source = [makeRow({ itl: 1 / 40, otput: 800 }), makeRow({ itl: 1 / 80, otput: 200 })];
379+
const tiers = [30, 40, 100];
380+
const feed = computeTcoFeed(source, WORKLOAD_8K1K, tiers);
381+
const scores = computeTcoScores(feed, WORKLOAD_8K1K, tiers, [0.25, 0.5, 0.25], [1], 0);
382+
// 0.25·800 (clamped) + 0.5·800 + 0.25·0 (unreachable) = 600 — the
383+
// unreachable tier's weight is NOT redistributed to reachable tiers.
384+
expect(scores[0].score).toBe(600);
385+
expect(scores[0].unreachable_tiers).toBe(1);
386+
expect(scores[0].clamped_tiers).toBe(1);
387+
});
388+
389+
it('blends workloads and renormalizes over covered ones for partial coverage', () => {
390+
const feed = computeTcoFeed(twoWorkloadRows(), BOTH_WORKLOADS, [50]);
391+
const scores = computeTcoScores(feed, BOTH_WORKLOADS, [50], [1], [0.5, 0.5], 0);
392+
expect(scores.map((s) => s.hardware)).toEqual(['b200', 'gb300']);
393+
394+
const b200 = scores[0];
395+
expect(b200.workload_scores).toEqual({ '1024x1024': 1000, '8192x1024': 400 });
396+
expect(b200.score).toBe(700); // 0.5·1000 + 0.5·400
397+
expect(b200.workloads_covered).toBe(2);
398+
399+
// gb300 lacks 1k1k → its weight renormalizes onto 8k1k alone.
400+
const gb300 = scores[1];
401+
expect(gb300.workload_scores).toEqual({ '1024x1024': null, '8192x1024': 5000 });
402+
expect(gb300.score).toBe(5000);
403+
expect(gb300.workloads_covered).toBe(1);
404+
});
405+
406+
it('applies per-workload output-equivalent factors before blending', () => {
407+
const feed = computeTcoFeed(twoWorkloadRows(), BOTH_WORKLOADS, [50]);
408+
const scores = computeTcoScores(feed, BOTH_WORKLOADS, [50], [1], [0.5, 0.5], 0.25);
409+
// 0.5·1000·(1 + 0.25·1) + 0.5·400·(1 + 0.25·8) = 625 + 600.
410+
expect(scores[0].score).toBe(1225);
411+
});
412+
413+
it('scores 0 when the only covered workloads carry zero weight', () => {
414+
const feed = computeTcoFeed(twoWorkloadRows(), BOTH_WORKLOADS, [50]);
415+
const scores = computeTcoScores(feed, BOTH_WORKLOADS, [50], [1], [1, 0], 0);
416+
const gb300 = scores.find((s) => s.hardware === 'gb300')!;
417+
expect(gb300.score).toBe(0);
418+
});
419+
420+
it('carries the newest latest_date and oldest frontier date across covered workloads', () => {
421+
const feed = computeTcoFeed(twoWorkloadRows(), BOTH_WORKLOADS, [50]);
422+
const scores = computeTcoScores(feed, BOTH_WORKLOADS, [50], [1], [0.5, 0.5], 0);
423+
expect(scores[0].latest_date).toBe('2026-07-01');
424+
expect(scores[0].oldest_frontier_date).toBe('2026-05-01');
425+
});
426+
427+
it('returns an empty list for an empty feed', () => {
428+
expect(computeTcoScores([], BOTH_WORKLOADS, [50], [1], [0.5, 0.5], 0)).toEqual([]);
429+
});
430+
});
431+
432+
describe('tcoScoresToCsv', () => {
433+
it('emits one score_<workload> column per requested workload, blank when uncovered', () => {
434+
const feed = computeTcoFeed(twoWorkloadRows(), BOTH_WORKLOADS, [50]);
435+
const scores = computeTcoScores(feed, BOTH_WORKLOADS, [50], [1], [0.5, 0.5], 0);
436+
const csv = tcoScoresToCsv(scores, BOTH_WORKLOADS);
437+
const lines = csv.split('\n');
438+
expect(lines[0]).toBe(
439+
'hardware,score,score_1024x1024,score_8192x1024,workloads_covered,' +
440+
'unreachable_tiers,clamped_tiers,latest_date,oldest_frontier_date',
441+
);
442+
expect(lines[1]).toBe('b200,700,1000,400,2,0,0,2026-07-01,2026-05-01');
443+
expect(lines[2]).toBe('gb300,5000,,5000,1,0,0,2026-07-10,2026-07-10');
444+
expect(csv.endsWith('\n')).toBe(true);
445+
});
446+
});

0 commit comments

Comments
 (0)