@@ -2,11 +2,18 @@ import { describe, expect, it } from 'vitest';
22
33import {
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
224236describe ( '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
247321describe ( '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