diff --git a/docs/tco-calculator.md b/docs/tco-calculator.md index 00a7a66de..c3cdf9bd1 100644 --- a/docs/tco-calculator.md +++ b/docs/tco-calculator.md @@ -1010,3 +1010,140 @@ The first two default to the comment there. The MW budget is `c_mw`, defaults to 10 MW, and is shared by the calculator and Fleet Lifecycle page so a budget set on either seeds the other. + +## Profit Estimator (`/profit-estimator`, `/profit-estimator-per-gigawatt`) + +> Both hosted by `ProfitEstimatorDisplay.tsx` with the math in `profit-estimator.ts` and +> the bars in `ProfitEstimatorChart.tsx`; the page passes `basis="chip-hour"` or +> `basis="gw-year"`. Mirrored at `/zh/profit-estimator` and +> `/zh/profit-estimator-per-gigawatt`. Per-model routes live at `//` (and +> `/zh/...`), see below. + +Fleet Lifecycle answers "what did a fixed fleet earn over its life". These two tabs +answer a narrower, present-tense question: at one interactivity operating point, what +does each chip earn today, and who keeps it? One vertical stacked bar per SKU. The two +tabs differ only in the denominator (`ProfitBasis`): + +- **Profit Estimator** (`/profit-estimator`, `chip-hour`): one GPU for one hour. The + bar is the $/GPU/hr the chip sells for at that interactivity; compute expense is the + TCO tier's $/chip/hr as published. Figures are dollars and cents (`$2.31`). The y + axis reads "Revenue per chip per hour ($ USD)". +- **Profit Estimator per GW** (`/profit-estimator-per-gigawatt`, `gw-year`): the same + per-chip figures multiplied by the GPU-hours one all-in utility gigawatt-year buys + for that SKU, so chips with very different power draw compare on the same + denominator. Figures are compact (`$135.2B`). The y axis reads "Revenue per all-in + provisioned utility GW per year ($ USD)" (a shorter form on phones). + +In the nav both sit between Inference Performance and Accuracy Evals. TCO +Calculator and Fleet Lifecycle moved out of the tab bar into the footer's "More" column +(`navGroup: 'footer-only'`, `footer-link-calculator` / `footer-link-fleet`); their +pages, `/zh` mirrors and sitemap entries are unchanged. + +### The arithmetic + +``` +gpuHours = 1 # chip-hour basis +gpuHours = (1,000,000 kW ÷ all-in kW per GPU) × 8,760 h # gw-year basis +revenue = $/GPU/hr(sale) × gpuHours × utilization +tco = $/GPU/hr(cost) × gpuHours +grossMargin = revenue − tco +licenseFee = revenue × licenseFeePct # `labCut` in code +profit = revenue − tco − licenseFee +``` + +`formatProfitUsd(value, basis)` picks the formatter: two fixed decimals per chip-hour, +`formatUsdCompact` per GW-year. A SKU with no power figure is skipped (`no-power`) on +the GW-year basis only; per chip-hour power never enters. + +`$/GPU/hr(sale)` comes from `tokenRevenueFromRatesPerGpuHour`, so it reuses the +calculator's input/cached/output token split and the OpenRouter catalog price (or a +custom price pair). `$/GPU/hr(cost)` is the selected TCO tier from `getGpuSpecs`, or, +with the **Custom $/GPU/hr** provider, one number per base chip typed by the reader +(seeded from the hyperscaler tier; an empty box drops that chip as "no cost"), and +the power figure is the same all-in kW per GPU that `tok/s/MW` uses. This is the same +GW-year normalization as the Revenue/Profit-per-GW y-metrics on the calculator; the +helper lives in its own module for now so this page does not depend on that branch, +and the two can be collapsed into one once both are on master. + +### Decisions worth knowing + +- **Utilization scales revenue only.** 60% means the fleet bills 60% of the tokens + the benchmark says it could produce. Chips are paid for whether or not they are + busy, so TCO is untouched. The haircut is not drawn as its own segment; the bar + simply tops out at realized revenue, and the caption states the rate. +- **The model license fee is a share of revenue, not of gross margin.** It is a + royalty on every token sold, so it is owed even when compute alone already exceeds + revenue. The UI calls it "Model License Fee (%)"; code and test ids keep the older + `labCut` name. A loss bar is TCO and license fee above the axis and the shortfall below it, hatched + in the SKU colour. The tooltip still shows gross margin (revenue minus TCO) so + the two deductions can be read separately. +- **Every segment is labelled in place.** Name and dollar amount sit inside each + rectangle when it is tall enough, amount only when it is shorter, nothing when + it would not fit. Revenue and margin (profit ÷ revenue) sit above the bar, with + the vendor's full-color mark above those. +- **Out-of-range reads are excluded, not clamped.** A config the target + interactivity falls outside of (H200 on Kimi K3 at 45 tok/s/user, say) is not + drawn and not offered in the legend. This matches the fleet page, which drops + those points rather than showing an edge value that was never measured. +- **Agentic traces only.** The page pins the sequence to agentic traces, so there is + no scenario selector and no precision selector (precision stays in auto mode, the + densest measured run set). The interactivity target is a typed number in the same + row as utilization and the license fee. +- **Kimi K3 only, for now.** The model selector offers the tab's route allow-list + (`MODEL_ROUTE_TAB_MODELS['profit-estimator']` and + `['profit-estimator-per-gigawatt']` in `model-routes.ts`) intersected with the + models that have an agentic run. Each bare path opens on Kimi K3 + (`defaultRouteModel(tab)`); `/profit-estimator/kimi-k3` and + `/profit-estimator-per-gigawatt/kimi-k3` are the same pages, aliases 308 to the + canonical slug, and any model outside the allow-list 404s. Widening the pages to + more models is one list edit plus fixture rows. +- **Two-line x labels when they fit, slanted when they do not.** `xLabelLayout` + estimates the widest label against the room per tick. With room, each SKU stands + upright as two lines, name over framework and precision (`splitAxisLabel`), and + the bottom margin shrinks to match. Otherwise the label rotates -50° as one line + and `slantedMargins` grows the left and bottom margins so nothing leaves the SVG. +- **Vendor mark above each bar, not on the axis.** The full-color logo + (`getAxisVendorIcon`) sits above the revenue figure and margin line. NVIDIA's mark + is the brand green and is never inverted; AMD publishes no color mark, so its arrow + is black and inverts to white in dark mode. The mark scales with the bar: + `barMarkHeight(bandwidth)` is 30% of the band width clamped to 14–48px, so desktop + bars get a large logo and phone bars the small one. The y domain leaves exactly + `stackHeadroomPx(markHeight)` above the tallest stack for those labels + (`profitYDomain` takes the plot height and that headroom), and the top margin is + 12px, so the grid starts right under the selling-price line. +- **TCO badges track the legend.** The `TCO $/chip/hr:` line lists one badge per base + chip whose bar is currently drawn; isolating a SKU in the legend leaves only its badge. +- **Cost provider names match `/inference`.** The selector reuses `COST_TIER_LABELS[...].option` + (Owning - Hyperscaler, Owning - Neocloud Giant, 3 Year Rental) plus Custom $/GPU/hr. +- **Chart height follows the viewport.** `chartHeightForViewport(window.innerHeight)` is + the full 720px only when the window has room; otherwise it is the viewport minus + `CHART_VIEWPORT_RESERVE` (260px: sticky nav, card header, padding), never below + `CHART_HEIGHT_MIN` (440px). On an 872px laptop viewport the chart is 612px, so the card + title and the x labels fit on one screen. The compact chart caps at 560px the same way. +- **Phone layout.** Below 640px the chart switches to compact margins and height, and + segment labels drop the name, then the amount, when the bar is too narrow + (`segmentLabelLines` with a width); the margin line keeps only the percentage. + Custom token prices always take their own row under the main controls. +- **Legend is the SKU filter.** Same click semantics as the fleet page (click to + isolate, click again to restore), and the same `resolveCalculatorVisibility` + intent so the choice survives a model change when the SKU still exists. +- **Unpriceable SKUs are dropped.** A SKU with no power figure, no TCO for the + chosen tier, or no recorded input/output mix is left out of the chart and the + legend; the legend is the record of what is priced. +- **The heading reads like `/inference`.** Model, workload, and target in the title; + cost tier, utilization, model license fee, run date, and source beneath it; TCO $/chip/hr badges and + the selling prices under that. The TCO source line is omitted when the cost + provider is a custom $/GPU/hr, since there is nothing to cite. Segments are + labelled in place, so there is no separate key and no hover hint under the chart. + The formula sits in a fold under the chart ("Revenue per GigaWatt Formula"), + collapsed by default, and the export button (top right of the card) writes PNG + and CSV. +- **Number inputs ignore the mouse wheel.** A wheel event over a focused number + input blurs it, so scrolling the page never nudges a percentage. + +### Fixtures + +The captured API fixtures carry no `agentic_traces` rows, so the Cypress spec +(`cypress/e2e/profit-estimator.cy.ts`) intercepts availability and benchmarks with +synthetic Kimi K3 curves from `cypress/support/profit-fixtures.ts`. The H200 curve +stops below 45 tok/s/user on purpose so the exclusion path stays covered. diff --git a/packages/app/cypress/component/tab-nav.cy.tsx b/packages/app/cypress/component/tab-nav.cy.tsx index 8a76303e3..9fe17ca5d 100644 --- a/packages/app/cypress/component/tab-nav.cy.tsx +++ b/packages/app/cypress/component/tab-nav.cy.tsx @@ -127,7 +127,14 @@ describe('TabNav — unofficialrun URL preservation (issue #319)', () => { cy.get('[data-testid="tab-trigger-overview"]').should('not.exist'); cy.get('[data-testid="tab-trigger-evaluation"]').should('have.attr', 'href', '/evaluation'); cy.get('[data-testid="tab-trigger-historical"]').should('have.attr', 'href', '/historical'); - cy.get('[data-testid="tab-trigger-calculator"]').should('have.attr', 'href', '/calculator'); + cy.get('[data-testid="tab-trigger-profit-estimator"]').should( + 'have.attr', + 'href', + '/profit-estimator', + ); + // TCO Calculator and Fleet Lifecycle live in the footer, not the tab bar. + cy.get('[data-testid="tab-trigger-calculator"]').should('not.exist'); + cy.get('[data-testid="tab-trigger-fleet"]').should('not.exist'); }); it('appends unofficialruns to every tab href when the URL has the param', () => { diff --git a/packages/app/cypress/e2e/csv-export.cy.ts b/packages/app/cypress/e2e/csv-export.cy.ts index 026d31213..da2e211bc 100644 --- a/packages/app/cypress/e2e/csv-export.cy.ts +++ b/packages/app/cypress/e2e/csv-export.cy.ts @@ -48,7 +48,7 @@ describe('CSV Export', () => { }); it('TCO calculator chart has CSV export option', () => { - cy.get('[data-testid="tab-trigger-calculator"]').click(); + cy.get('[data-testid="footer-link-calculator"]').scrollIntoView().click(); cy.get('[data-testid="calculator-chart-section"]').should('exist'); cy.get('[data-testid="export-button"]').first().click(); diff --git a/packages/app/cypress/e2e/navigation.cy.ts b/packages/app/cypress/e2e/navigation.cy.ts index b0cfe04a4..17642db62 100644 --- a/packages/app/cypress/e2e/navigation.cy.ts +++ b/packages/app/cypress/e2e/navigation.cy.ts @@ -16,8 +16,12 @@ describe('Chart Section Tabs — E2E', () => { cy.get('[data-testid="tab-trigger-historical"]').click(); cy.url().should('include', '/historical'); - cy.get('[data-testid="tab-trigger-calculator"]').click(); - cy.url().should('include', '/calculator'); + cy.get('[data-testid="tab-trigger-profit-estimator-per-gigawatt"]').click(); + cy.url().should('include', '/profit-estimator-per-gigawatt'); + + cy.get('[data-testid="tab-trigger-profit-estimator"]').click(); + cy.url().should('include', '/profit-estimator'); + cy.url().should('not.include', '/profit-estimator-per-gigawatt'); cy.get('[data-testid="tab-trigger-submissions"]').click(); cy.url().should('include', '/submissions'); @@ -26,6 +30,18 @@ describe('Chart Section Tabs — E2E', () => { cy.url().should('include', '/inference'); }); + it('opens the TCO Calculator and Fleet Lifecycle from the footer links', () => { + cy.get('[data-testid="tab-trigger-calculator"]').should('not.exist'); + cy.get('[data-testid="tab-trigger-fleet"]').should('not.exist'); + + cy.get('[data-testid="footer-link-calculator"]').scrollIntoView().click(); + cy.url().should('include', '/calculator'); + cy.get('[data-testid="calculator-controls"]').should('exist'); + + cy.get('[data-testid="footer-link-fleet"]').scrollIntoView().click(); + cy.url().should('include', '/fleet'); + }); + it('opens GPU Reliability from the footer link', () => { cy.get('[data-testid="tab-trigger-reliability"]').should('not.exist'); diff --git a/packages/app/cypress/e2e/profit-estimator.cy.ts b/packages/app/cypress/e2e/profit-estimator.cy.ts new file mode 100644 index 000000000..570da0505 --- /dev/null +++ b/packages/app/cypress/e2e/profit-estimator.cy.ts @@ -0,0 +1,456 @@ +// /profit-estimator: one stacked bar per SKU, US$ per all-in GW per year. +// Behaviours worth locking down: +// - defaults are Kimi K3, 45 tok/s/user, 60% utilization, 30% model license fee; +// - utilization scales revenue only, so the revenue label moves and the +// TCO segment does not; +// - the SKU legend is the filter for which bars are drawn; +// - a loss is drawn below the zero line as a hatched segment, not as a new colour; +// - the OpenRouter catalog is the default price source and a custom triple +// (input, cached input, output) can replace it; +// - the workload is pinned to agentic traces, so there is no scenario or +// precision selector, the model selector offers Kimi K3 only, and the target +// interactivity is a typed number, not a slider; +// - the cost provider has a custom $/GPU/hr option with one input per chip; +// - the heading reads like /inference, the subtitle names the utilization, and +// the formula folds away under the chart; +// - /profit-estimator/ is the per-model route; switching models +// rewrites the address bar in place. + +import { interceptProfitData } from '../support/profit-fixtures'; + +// Kimi K3 is the page default; the DeepSeek row proves the page prices the +// routed model, not the first catalog entry. +const OPENROUTER_MODELS = { + data: [ + { + id: 'moonshotai/kimi-k3', + pricing: { prompt: '0.0000006', completion: '0.0000025', input_cache_read: '0.0000001' }, + }, + { + id: 'deepseek/deepseek-v4-pro-0813', + pricing: { prompt: '0.00000066', completion: '0.00000198', input_cache_read: '0.000000022' }, + }, + ], +}; + +function stubOpenRouter(): void { + interceptProfitData(); + cy.intercept('GET', 'https://openrouter.ai/api/v1/models', OPENROUTER_MODELS).as('openrouter'); +} + +function suppressNudges(win: Cypress.AUTWindow): void { + win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now())); + win.sessionStorage.setItem('inferencex-reproducibility-nudge-shown', '1'); +} + +const chart = () => cy.get('[data-testid="profit-estimator-chart"]'); +/** The plot SVG itself, not the icon SVGs inside the export button. */ +const chartSvg = () => chart().find('svg').filter(':has(.chart-root)').first(); +const bars = () => chart().find('rect.bar'); +// The formula fold is collapsed by default; open it (if it is not already) before +// reading the text. Tests share one page, so a previous test may have opened it. +const openFormulaNotes = () => + cy.get('[data-testid="profit-formula-notes"] button').then(($btn) => { + if ($btn.attr('aria-expanded') === 'false') cy.wrap($btn).click(); + }); +const revenueLabels = () => chart().find('text.revenue-label tspan.revenue-amount'); + +function parseCompactUsd(text: string): number { + const match = /^(?-?)\$(?[\d.]+)(?[kMB]?)$/.exec(text.trim()); + expect(match, `compact USD "${text}"`).to.not.equal(null); + const { sign, digits, unit } = match!.groups!; + const scale = unit === 'B' ? 1e9 : unit === 'M' ? 1e6 : unit === 'k' ? 1e3 : 1; + return (sign === '-' ? -1 : 1) * Number.parseFloat(digits) * scale; +} + +describe('Profit Estimator per GW', () => { + before(() => { + stubOpenRouter(); + cy.visit('/profit-estimator-per-gigawatt', { onBeforeLoad: suppressNudges }); + chart().should('exist'); + }); + + beforeEach(stubOpenRouter); + + it('opens with the documented defaults and a priced chart', () => { + cy.get('[data-testid="profit-target-input"]').should('have.value', '45'); + cy.get('[data-testid="profit-utilization-input"]').should('have.value', '60'); + cy.get('[data-testid="profit-lab-cut-input"]').should('have.value', '30'); + bars().should('have.length.greaterThan', 0); + // Cost tiers carry the same names as the /inference y-axis selector. + cy.get('button#profit-cost').should('contain.text', 'Owning - Hyperscaler').click(); + cy.get('[role="option"]').then(($opts) => { + const labels = [...$opts].map((el) => el.textContent?.trim()); + expect(labels).to.include.members([ + 'Owning - Hyperscaler', + 'Owning - Neocloud Giant', + '3 Year Rental', + 'Custom $/GPU/hr', + ]); + }); + cy.get('body').type('{esc}'); + // Segments are labelled in place; there is no separate key under the title. + cy.get('[data-testid="profit-segment-key"]').should('not.exist'); + chart().should('contain.text', 'Model License Fee').and('contain.text', 'Profit'); + cy.get('[data-testid="profit-caption"] h2').should( + 'contain.text', + 'Revenue & Profit Estimates per GigaWatt Per Year at P90 45 tok/s/user Interactivity', + ); + cy.get('[data-testid="result-context-cost-tier"]').should('contain.text', 'Owning Hyperscaler'); + cy.get('[data-testid="result-context-utilization"]').should('have.text', '60%'); + cy.get('[data-testid="result-context-license-fee"]').should('have.text', '30%'); + cy.get('[data-testid="profit-caption"]').should('contain.text', 'TCO $/chip/hr'); + cy.get('[data-testid="profit-caption"] h2').should('contain.text', 'Kimi K3'); + cy.get('[data-testid="profit-selling-prices"]') + .should('contain.text', 'Input: $0.6') + .and('contain.text', 'Cached Input: $0.1') + .and('contain.text', 'Output: $2.5') + .and('contain.text', '(OpenRouter)') + .and('not.contain.text', 'moonshotai'); + cy.location('pathname').should('eq', '/profit-estimator-per-gigawatt'); + cy.get('[data-testid="profit-precision-selector"]').should('not.exist'); + cy.get('[data-testid="profit-model-selector"]').should('contain.text', 'Kimi K3').click(); + cy.get('[role="option"]').should('have.length', 1).and('contain.text', 'Kimi K3'); + cy.get('body').type('{esc}'); + cy.get('[data-testid="profit-custom-costs"]').should('not.exist'); + // Each x label carries its vendor mark; the H200 curve stops short of 45 + // tok/s/user, so it is absent from the chart rather than extrapolated. + chart().find('.tick image.vendor-mark').should('not.exist'); + // Desktop width: upright two-line labels, SKU name then framework. + chart().find('.tick text tspan').should('have.length', 8); + chart().find('.tick text').first().should('not.have.attr', 'transform'); + chart().find('.tick text').first().should('contain.text', 'GB300 NVL72'); + cy.get('[data-testid="profit-skipped"]').should('not.exist'); + chart().find('image.bar-vendor-mark').should('have.length', 4); + cy.get('[data-testid="profit-high-contrast"]').should('not.exist'); + chart().should('not.contain.text', 'H200'); + cy.get('[data-testid="profit-formula-notes"]') + .should('contain.text', 'Revenue per GigaWatt Formula') + .and('not.contain.text', '60% utilization'); + openFormulaNotes(); + cy.get('[data-testid="profit-formula-notes"]').should('contain.text', '60% utilization'); + cy.get('[data-testid="profit-formula-notes"] button').click(); + cy.get('[data-testid="profit-formula-notes"]').should('not.contain.text', '60% utilization'); + chart().should('not.contain.text', 'Hover a bar'); + cy.get('[data-testid="export-button"]').should('exist'); + cy.get('[data-testid="profit-scenario"]').should('not.exist'); + cy.get('[data-testid="profit-pricing-notice"]').should('not.exist'); + // Both profit tabs sit between Inference Performance and Accuracy Evals; + // the TCO calculator and fleet lifecycle left the nav for the footer. + cy.get('[data-testid^="tab-trigger-"]').then(($tabs) => { + const keys = [...$tabs].map((el) => (el as HTMLElement).dataset.testid); + expect(keys.slice(0, 4)).to.deep.equal([ + 'tab-trigger-inference', + 'tab-trigger-profit-estimator-per-gigawatt', + 'tab-trigger-profit-estimator', + 'tab-trigger-evaluation', + ]); + expect(keys).to.not.include('tab-trigger-calculator'); + expect(keys).to.not.include('tab-trigger-fleet'); + }); + cy.get('[data-testid="tab-trigger-profit-estimator-per-gigawatt"]').should( + 'contain.text', + 'Profit Estimator per GW', + ); + cy.get('[data-testid="footer-link-calculator"]') + .should('have.attr', 'href', '/calculator') + .and('contain.text', 'TCO Calculator'); + cy.get('[data-testid="footer-link-fleet"]') + .should('have.attr', 'href', '/fleet') + .and('contain.text', 'Fleet Lifecycle'); + }); + + it('stacks TCO first and labels every bar with its revenue', () => { + chart() + .find('rect.bar-tco') + .its('length') + .then((tcoCount) => { + revenueLabels().should('have.length', tcoCount); + }); + chart().find('line.zero-line').should('have.length', 1); + }); + + it('scales revenue with utilization but leaves TCO alone', () => { + let revenueAt60 = 0; + let tcoHeightAt60 = ''; + revenueLabels() + .first() + .invoke('text') + .then((text) => { + revenueAt60 = parseCompactUsd(text); + }); + chart() + .find('rect.bar-tco') + .first() + .invoke('attr', 'height') + .then((height) => { + tcoHeightAt60 = String(height); + }); + + openFormulaNotes(); + cy.get('[data-testid="profit-utilization-input"]').clear().type('30').blur(); + cy.get('[data-testid="profit-formula-notes"]').should('contain.text', '30% utilization'); + cy.get('[data-testid="result-context-utilization"]').should('have.text', '30%'); + revenueLabels() + .first() + .invoke('text') + .should((text) => { + // Bars re-sort by revenue, but halving utilization halves every bar, so + // the tallest bar is still the tallest and its label halves. + expect(parseCompactUsd(text)).to.be.closeTo(revenueAt60 / 2, revenueAt60 * 0.02); + }); + // Y domain rescales, so compare TCO in data terms via the tooltip-free + // path: heights change with the axis, but the segment must still be there + // and the pixel height must not have been halved along with revenue. + chart() + .find('rect.bar-tco') + .first() + .invoke('attr', 'height') + .should((height) => { + expect(Number(height)).to.be.greaterThan(Number(tcoHeightAt60) * 0.6); + }); + + cy.get('[data-testid="profit-utilization-input"]').clear().type('60').blur(); + cy.get('[data-testid="profit-formula-notes"]').should('contain.text', '60% utilization'); + }); + + it('clamps utilization and the license fee to 0–100 on blur', () => { + openFormulaNotes(); + cy.get('[data-testid="profit-lab-cut-input"]').clear().type('250').blur(); + cy.get('[data-testid="profit-lab-cut-input"]').should('have.value', '100'); + cy.get('[data-testid="result-context-license-fee"]').should('have.text', '100%'); + cy.get('[data-testid="profit-formula-notes"]').should( + 'contain.text', + 'Model license fee = 100%', + ); + cy.get('[data-testid="profit-lab-cut-input"]').clear().type('30').blur(); + cy.get('[data-testid="profit-lab-cut-input"]').should('have.value', '30'); + }); + + it('retypes the target interactivity and rewrites the heading', () => { + cy.get('[data-testid="profit-target-input"]').clear().type('60').blur(); + cy.get('[data-testid="profit-caption"] h2').should('contain.text', 'at P90 60 tok/s/user'); + cy.get('[data-testid="profit-target-input"]').clear().type('45').blur(); + cy.get('[data-testid="profit-caption"] h2').should('contain.text', 'at P90 45 tok/s/user'); + }); + + it('uses the SKU legend as the bar filter', () => { + bars() + .its('length') + .then((allBars) => { + cy.get('[data-testid="profit-legend"] li').should('have.length.greaterThan', 1); + // One badge per base chip drawn: GB300, B300, B200, MI355X. + cy.get('[data-testid="profit-tco-badge"]').should('have.length', 4); + cy.get('[data-testid="profit-tco-badges"]').should('contain.text', 'GB300: 2.31'); + cy.get('[data-testid="profit-legend"] li').first().click(); + bars().should('have.length.lessThan', allBars); + chart() + .find('rect.bar-tco') + .should('have.length.lessThan', 4) + .and('have.length.greaterThan', 0); + // Clicking a legend item isolates it; the subtitle keeps only that + // SKU's TCO badge. + cy.get('[data-testid="profit-tco-badge"]').should('have.length', 1); + cy.get('[data-testid="profit-tco-badges"]') + .should('contain.text', 'GB300: 2.31') + .and('not.contain.text', 'MI355X'); + cy.get('[data-testid="profit-reset-filter"]').click(); + bars().should('have.length', allBars); + cy.get('[data-testid="profit-tco-badge"]').should('have.length', 4); + cy.get('[data-testid="profit-reset-filter"]').should('not.exist'); + }); + }); + + it('lets a custom price pair replace the OpenRouter catalog', () => { + cy.get('button#profit-price-source').click(); + cy.contains('[role="option"]', 'Custom $/M tok').click(); + cy.get('[data-testid="profit-custom-prices"]').should('exist'); + cy.get('[data-testid="profit-input-price"]').should('have.value', '0.6'); + cy.get('[data-testid="profit-cached-price"]').should('have.value', '0.1'); + cy.get('[data-testid="profit-output-price"]').should('have.value', '2.5'); + cy.get('[data-testid="profit-output-price"]').clear().type('10').blur(); + cy.get('[data-testid="profit-selling-prices"]').should('contain.text', 'Output: $10'); + cy.get('[data-testid="profit-selling-prices"]').should('contain.text', '(custom)'); + + cy.get('button#profit-price-source').click(); + cy.contains('[role="option"]', 'OpenRouter').click(); + cy.get('[data-testid="profit-input-price"]').should('not.exist'); + cy.get('[data-testid="profit-cached-price"]').should('not.exist'); + cy.get('[data-testid="profit-selling-prices"]').should('contain.text', 'Output: $2.5'); + }); + + it('lets a custom $/GPU/hr per chip replace the TCO tier', () => { + cy.get('button#profit-cost').click(); + cy.contains('[role="option"]', 'Custom $/GPU/hr').click(); + cy.get('[data-testid="result-context-cost-tier"]').should('contain.text', 'Custom'); + cy.get('[data-testid="profit-custom-costs"] input').should('have.length.greaterThan', 0); + // A user-entered $/GPU/hr has no external source to cite. + cy.get('[data-testid="profit-tco-source"]').should('not.exist'); + chart() + .find('rect.bar-tco') + .its('length') + .then((tcoBarsBefore) => { + // Inputs are seeded from the hyperscaler tier, so nothing is dropped yet. + cy.get('[data-testid="profit-custom-costs"] input') + .first() + .invoke('val') + .then((seed) => expect(Number(seed)).to.be.greaterThan(0)); + cy.get('[data-testid="profit-custom-costs"] label') + .first() + .invoke('text') + .then((labelText) => { + const chip = labelText.replace(/\s*\$\/GPU\/hr$/u, '').trim(); + cy.get('[data-testid="profit-custom-costs"] input').first().clear().type('9').blur(); + cy.get('[data-testid="profit-caption"]').should('contain.text', `${chip}: 9`); + }); + // An empty custom cost drops that chip instead of pricing it at zero. + cy.get('[data-testid="profit-custom-costs"] input').first().clear().blur(); + chart().find('rect.bar-tco').should('have.length.lessThan', tcoBarsBefore); + cy.get('[data-testid="profit-custom-costs"] input').first().type('1.5').blur(); + chart().find('rect.bar-tco').should('have.length', tcoBarsBefore); + }); + + cy.get('button#profit-cost').click(); + cy.contains('[role="option"]', 'Owning - Hyperscaler').click(); + cy.get('[data-testid="profit-custom-costs"]').should('not.exist'); + cy.get('[data-testid="profit-tco-source"]').should('contain.text', 'TCO Model'); + cy.get('[data-testid="result-context-cost-tier"]').should('contain.text', 'Owning Hyperscaler'); + }); + + it('explains a missing OpenRouter listing instead of drawing an unpriced chart', () => { + interceptProfitData(); + cy.intercept('GET', 'https://openrouter.ai/api/v1/models', { data: [] }).as('openrouter-empty'); + cy.visit('/profit-estimator-per-gigawatt', { onBeforeLoad: suppressNudges }); + cy.get('[data-testid="profit-pricing-notice"]').should( + 'contain.text', + 'OpenRouter has no price', + ); + chart().should('not.exist'); + }); +}); + +describe('Profit Estimator per GW — chart height follows the viewport', () => { + beforeEach(stubOpenRouter); + + it('shrinks on a laptop-height viewport and grows back on a tall one', () => { + // 720px tall (the Cypress default): 720 - 260 reserved = 460px chart, so the + // card title and the x labels share one screen. + cy.viewport(1280, 720); + cy.visit('/profit-estimator-per-gigawatt', { onBeforeLoad: suppressNudges }); + chart().should('exist'); + chartSvg().should('have.attr', 'height', '460'); + // Plenty of room: the chart takes its full 720px. + cy.viewport(1440, 1200); + chartSvg().should('have.attr', 'height', '720'); + // Very short viewports stop at the minimum where in-bar labels collide. + cy.viewport(1280, 600); + chartSvg().should('have.attr', 'height', '440'); + }); +}); + +describe('Profit Estimator per GW — per-model route', () => { + it('serves /profit-estimator-per-gigawatt/kimi-k3 and 308s aliases to the canonical slug', () => { + stubOpenRouter(); + cy.visit('/profit-estimator-per-gigawatt/kimi-k3', { onBeforeLoad: suppressNudges }); + chart().should('exist'); + cy.get('[data-testid="profit-caption"] h2').should('contain.text', 'Kimi K3'); + // A canonical slug is left alone; only aliases and model switches rewrite. + cy.location('pathname').should('eq', '/profit-estimator-per-gigawatt/kimi-k3'); + + cy.request({ url: '/profit-estimator-per-gigawatt/Kimi-K3', followRedirect: false }).then( + (response) => { + expect(response.status).to.eq(308); + expect(response.headers.location).to.match(/\/profit-estimator-per-gigawatt\/kimi-k3$/u); + }, + ); + }); + + it('404s models the estimator does not serve yet', () => { + cy.request({ url: '/profit-estimator-per-gigawatt/deepseek-v4-pro', failOnStatusCode: false }) + .its('status') + .should('eq', 404); + cy.request({ url: '/profit-estimator-per-gigawatt/not-a-model', failOnStatusCode: false }) + .its('status') + .should('eq', 404); + }); +}); + +describe('Profit Estimator per GW — Chinese mirror', () => { + it('renders /zh/profit-estimator-per-gigawatt with translated controls and caption', () => { + stubOpenRouter(); + cy.visit('/zh/profit-estimator-per-gigawatt', { onBeforeLoad: suppressNudges }); + chart().should('exist'); + cy.get('label[for="profit-utilization"]').should('contain.text', '利用率'); + cy.get('label[for="profit-lab-cut"]').should('contain.text', '模型许可费'); + cy.get('[data-testid="result-context-utilization"]').should('have.text', '60%'); + chart().should('contain.text', '模型许可费').and('contain.text', '利润'); + cy.get('[data-testid="profit-caption"] h2').should('contain.text', '每吉瓦每年收入与利润估算'); + openFormulaNotes(); + cy.get('[data-testid="profit-formula-notes"]').should('contain.text', '利用率'); + }); +}); + +describe('Profit Estimator (per chip-hour)', () => { + before(() => { + stubOpenRouter(); + // Tall enough for the 720px chart, so the thin compute-expense segment + // ($2.31 of $32.72) still has room for its name. + cy.viewport(1280, 1000); + cy.visit('/profit-estimator', { onBeforeLoad: suppressNudges }); + chart().should('exist'); + }); + + beforeEach(() => { + stubOpenRouter(); + cy.viewport(1280, 1000); + }); + + it('prices one chip-hour with the same defaults and TCO $/chip/hr as the expense', () => { + cy.location('pathname').should('eq', '/profit-estimator'); + cy.get('[data-testid="profit-target-input"]').should('have.value', '45'); + cy.get('[data-testid="profit-utilization-input"]').should('have.value', '60'); + cy.get('[data-testid="profit-lab-cut-input"]').should('have.value', '30'); + cy.get('[data-testid="profit-caption"] h2').should( + 'contain.text', + 'Kimi K3 2.8T Agentic Revenue & Profit Estimates per Chip per Hour at P90 45 tok/s/user Interactivity', + ); + cy.get('[data-testid="profit-formula-notes"]').should( + 'contain.text', + 'Revenue per Chip-Hour Formula', + ); + openFormulaNotes(); + cy.get('[data-testid="profit-formula-notes"]').should('contain.text', 'TCO $/chip/hr'); + // Revenue labels are dollars and cents, not billions. + revenueLabels().should('have.length', 4); + revenueLabels() + .first() + .invoke('text') + .should('match', /^\$\d+\.\d{2}/u) + .and('not.match', /[BMk]/u); + chart().should('contain.text', 'Compute Expense').and('contain.text', 'Model License Fee'); + chart().find('image.bar-vendor-mark').should('have.length', 4); + cy.get('[data-testid="tab-trigger-profit-estimator"]') + .should('contain.text', 'Profit Estimator') + .and('have.attr', 'data-tab-active'); + }); + + it('serves the Kimi K3 slug, the Chinese mirror, and 404s other models', () => { + cy.request({ url: '/profit-estimator/Kimi-K3', followRedirect: false }).then((response) => { + expect(response.status).to.eq(308); + expect(response.headers.location).to.match(/\/profit-estimator\/kimi-k3$/u); + }); + cy.request({ url: '/profit-estimator/deepseek-v4-pro', failOnStatusCode: false }) + .its('status') + .should('eq', 404); + stubOpenRouter(); + cy.visit('/zh/profit-estimator', { onBeforeLoad: suppressNudges }); + chart().should('exist'); + cy.get('[data-testid="profit-caption"] h2').should( + 'contain.text', + '每芯片每小时收入与利润估算', + ); + cy.get('[data-testid="footer-link-calculator"]') + .should('have.attr', 'href', '/zh/calculator') + .and('contain.text', 'TCO 计算器'); + }); +}); diff --git a/packages/app/cypress/e2e/throughput-calculator.cy.ts b/packages/app/cypress/e2e/throughput-calculator.cy.ts index bd5a77860..52fae89d6 100644 --- a/packages/app/cypress/e2e/throughput-calculator.cy.ts +++ b/packages/app/cypress/e2e/throughput-calculator.cy.ts @@ -19,20 +19,25 @@ describe('TCO Calculator', () => { cy.visit('/inference'); }); - it('shows the TCO Calculator tab trigger', () => { - cy.get('[data-testid="tab-trigger-calculator"]').should('be.visible'); - cy.get('[data-testid="tab-trigger-calculator"]').should('contain.text', 'TCO Calculator'); + it('links the TCO Calculator from the footer, not the tab bar', () => { + cy.get('[data-testid="tab-trigger-calculator"]').should('not.exist'); + cy.get('[data-testid="footer-link-calculator"]') + .scrollIntoView() + .should('be.visible') + .and('contain.text', 'TCO Calculator'); }); - it('clicking the calculator tab navigates to it', () => { - cy.get('[data-testid="tab-trigger-calculator"]').click(); + it('clicking the footer calculator link navigates to it', () => { + cy.get('[data-testid="footer-link-calculator"]').scrollIntoView().click(); cy.url().should('include', '/calculator'); }); it('switches back to inference tab and then returns to calculator', () => { + cy.get('[data-testid="footer-link-calculator"]').scrollIntoView().click(); + cy.url().should('include', '/calculator'); cy.get('[data-testid="tab-trigger-inference"]').click(); cy.url().should('include', '/inference'); - cy.get('[data-testid="tab-trigger-calculator"]').click(); + cy.get('[data-testid="footer-link-calculator"]').scrollIntoView().click(); cy.url().should('include', '/calculator'); cy.get('[data-testid="calculator-controls"]').should('be.visible'); }); diff --git a/packages/app/cypress/support/profit-fixtures.ts b/packages/app/cypress/support/profit-fixtures.ts new file mode 100644 index 000000000..5eb50f576 --- /dev/null +++ b/packages/app/cypress/support/profit-fixtures.ts @@ -0,0 +1,102 @@ +/** + * Synthetic Kimi K3 agentic rows for the `/profit-estimator` e2e spec. + * + * The captured API fixtures carry no `agentic_traces` rows, and the estimator + * is pinned to that workload, so the spec intercepts availability and + * benchmarks with this set instead. Shapes follow `overlay-fixtures.ts`; the + * metric aliases derive interactivity from `p90_itl`, so the tuples below are + * (concurrency, p90 interactivity tok/s/user, tput_per_gpu, p90 e2e latency s). + * + * The H200 curve tops out below the 45 tok/s/user default on purpose: the page + * must list it under "Not priced" rather than extrapolate a bar for it. + */ +import { metricsFor } from './overlay-fixtures'; + +export const PROFIT_MODEL_DB_KEY = 'kimik3'; +export const PROFIT_DATE = '2026-08-31'; + +type Curve = [conc: number, intvty: number, tput: number, e2el: number][]; + +const WIDE_CURVE: Curve = [ + [64, 12, 18_000, 140], + [16, 40, 13_500, 60], + [8, 62, 9_800, 38], + [4, 90, 6_000, 30], + [1, 130, 2_400, 24], +]; + +const H200_CURVE: Curve = [ + [64, 8, 6_000, 180], + [16, 22, 4_200, 80], + [4, 38, 2_100, 44], +]; + +interface ProfitSku { + hardware: string; + framework: string; + precision: string; + curve: Curve; + tputScale: number; +} + +export const PROFIT_SKUS: ProfitSku[] = [ + { hardware: 'h200', framework: 'vllm', precision: 'fp8', curve: H200_CURVE, tputScale: 1 }, + { hardware: 'b200', framework: 'sglang', precision: 'fp4', curve: WIDE_CURVE, tputScale: 1 }, + { hardware: 'b300', framework: 'vllm', precision: 'fp4', curve: WIDE_CURVE, tputScale: 1.25 }, + { hardware: 'gb300', framework: 'sglang', precision: 'fp4', curve: WIDE_CURVE, tputScale: 1.4 }, + { hardware: 'mi355x', framework: 'vllm', precision: 'fp4', curve: WIDE_CURVE, tputScale: 0.9 }, +]; + +let idCursor = 800_000; + +export const profitBenchmarkRows = () => + PROFIT_SKUS.flatMap((sku) => + sku.curve.map(([conc, intvty, tput, e2el]) => ({ + id: idCursor++, + hardware: sku.hardware, + framework: sku.framework, + model: PROFIT_MODEL_DB_KEY, + precision: sku.precision, + spec_method: 'none', + disagg: false, + is_multinode: false, + prefill_tp: 8, + decode_tp: 8, + num_prefill_gpu: 8, + num_decode_gpu: 8, + isl: null, + osl: null, + conc, + offload_mode: 'on', + benchmark_type: 'agentic_traces', + image: `${sku.framework}:test`, + metrics: metricsFor(intvty, Math.round(tput * sku.tputScale), e2el), + workers: null, + date: PROFIT_DATE, + run_url: `https://github.com/SemiAnalysisAI/InferenceX/actions/runs/${idCursor}`, + })), + ); + +export const profitAvailabilityRows = () => + PROFIT_SKUS.map((sku) => ({ + model: PROFIT_MODEL_DB_KEY, + isl: null, + osl: null, + precision: sku.precision, + hardware: sku.hardware, + framework: sku.framework, + spec_method: 'none', + disagg: false, + benchmark_type: 'agentic_traces', + date: PROFIT_DATE, + })); + +/** Intercept availability and benchmarks with the Kimi K3 agentic set. */ +export const interceptProfitData = (): void => { + cy.intercept('GET', '/api/v1/availability*', { body: profitAvailabilityRows() }).as( + 'profit-availability', + ); + cy.intercept('GET', '/api/v1/benchmarks*', { body: profitBenchmarkRows() }).as( + 'profit-benchmarks', + ); +}; diff --git a/packages/app/src/app/(dashboard)/profit-estimator-per-gigawatt/[model]/page.tsx b/packages/app/src/app/(dashboard)/profit-estimator-per-gigawatt/[model]/page.tsx new file mode 100644 index 000000000..9da8988c0 --- /dev/null +++ b/packages/app/src/app/(dashboard)/profit-estimator-per-gigawatt/[model]/page.tsx @@ -0,0 +1,67 @@ +import type { Metadata } from 'next'; +import { notFound, permanentRedirect } from 'next/navigation'; + +import ProfitEstimatorDisplay from '@/components/calculator/ProfitEstimatorDisplay'; +import { resolveCalculatorUrlSeed } from '@/components/calculator/url-seed'; +import { + modelRouteAvailableForTab, + modelRoutePath, + pathWithSearchParams, + resolveModelRouteSlug, +} from '@/lib/model-routes'; +import { modelTabMetadata } from '@/lib/tab-meta'; + +/** + * `/profit-estimator/` — the GW-year profit estimator seeded to a + * specific model, so every model has an indexable URL. Switching models in + * the UI rewrites the pathname in place via `history.replaceState` (no reload, + * no remount), the same mechanism `/calculator/` uses. No + * `generateStaticParams`: the page reads searchParams, so it renders + * dynamically anyway. + */ + +interface Props { + params: Promise<{ model: string }>; + searchParams: Promise>; +} + +export async function generateMetadata({ params }: Props): Promise { + const { model } = await params; + const resolved = resolveModelRouteSlug(model); + if ( + !resolved || + !modelRouteAvailableForTab('profit-estimator-per-gigawatt', resolved.route.model) + ) + return {}; + return modelTabMetadata('profit-estimator-per-gigawatt', resolved.route); +} + +export default async function ProfitEstimatorModelPage({ params, searchParams }: Props) { + const [{ model }, sp] = await Promise.all([params, searchParams]); + const resolved = resolveModelRouteSlug(model); + // Only the models this tab serves (see MODEL_ROUTE_TAB_MODELS) get a page. + if ( + !resolved || + !modelRouteAvailableForTab('profit-estimator-per-gigawatt', resolved.route.model) + ) { + notFound(); + } + // Aliases and non-canonical casing 308 to the canonical slug, keeping any + // share-link params. + if (resolved.isAlias) { + permanentRedirect( + pathWithSearchParams( + modelRoutePath('profit-estimator-per-gigawatt', resolved.route.slug), + sp, + ), + ); + } + const seed = resolveCalculatorUrlSeed(sp); + // A legacy explicit ?g_model= wins over the path, as on /calculator/. + return ( + + ); +} diff --git a/packages/app/src/app/(dashboard)/profit-estimator-per-gigawatt/page.tsx b/packages/app/src/app/(dashboard)/profit-estimator-per-gigawatt/page.tsx new file mode 100644 index 000000000..e31ae0ee9 --- /dev/null +++ b/packages/app/src/app/(dashboard)/profit-estimator-per-gigawatt/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from 'next'; + +import ProfitEstimatorDisplay from '@/components/calculator/ProfitEstimatorDisplay'; +import { resolveCalculatorUrlSeed } from '@/components/calculator/url-seed'; +import { defaultRouteModel } from '@/lib/model-routes'; +import { tabMetadata } from '@/lib/tab-meta'; + +export const metadata: Metadata = tabMetadata('profit-estimator-per-gigawatt'); + +interface Props { + searchParams: Promise>; +} + +/** + * Bare `/profit-estimator` opens on the tab's default model (Kimi K3, see + * `defaultRouteModel`). Switching models in the selector rewrites the address + * bar to `/profit-estimator/` in place; see the `[model]` sibling. + */ +export default async function ProfitEstimatorPage({ searchParams }: Props) { + const sp = await searchParams; + const seed = resolveCalculatorUrlSeed(sp); + return ( + + ); +} diff --git a/packages/app/src/app/(dashboard)/profit-estimator/[model]/page.tsx b/packages/app/src/app/(dashboard)/profit-estimator/[model]/page.tsx new file mode 100644 index 000000000..b01aa0a31 --- /dev/null +++ b/packages/app/src/app/(dashboard)/profit-estimator/[model]/page.tsx @@ -0,0 +1,57 @@ +import type { Metadata } from 'next'; +import { notFound, permanentRedirect } from 'next/navigation'; + +import ProfitEstimatorDisplay from '@/components/calculator/ProfitEstimatorDisplay'; +import { resolveCalculatorUrlSeed } from '@/components/calculator/url-seed'; +import { + modelRouteAvailableForTab, + modelRoutePath, + pathWithSearchParams, + resolveModelRouteSlug, +} from '@/lib/model-routes'; +import { modelTabMetadata } from '@/lib/tab-meta'; + +/** + * `/profit-estimator/` — the GW-year profit estimator seeded to a + * specific model, so every model has an indexable URL. Switching models in + * the UI rewrites the pathname in place via `history.replaceState` (no reload, + * no remount), the same mechanism `/calculator/` uses. No + * `generateStaticParams`: the page reads searchParams, so it renders + * dynamically anyway. + */ + +interface Props { + params: Promise<{ model: string }>; + searchParams: Promise>; +} + +export async function generateMetadata({ params }: Props): Promise { + const { model } = await params; + const resolved = resolveModelRouteSlug(model); + if (!resolved || !modelRouteAvailableForTab('profit-estimator', resolved.route.model)) return {}; + return modelTabMetadata('profit-estimator', resolved.route); +} + +export default async function ProfitEstimatorModelPage({ params, searchParams }: Props) { + const [{ model }, sp] = await Promise.all([params, searchParams]); + const resolved = resolveModelRouteSlug(model); + // Only the models this tab serves (see MODEL_ROUTE_TAB_MODELS) get a page. + if (!resolved || !modelRouteAvailableForTab('profit-estimator', resolved.route.model)) { + notFound(); + } + // Aliases and non-canonical casing 308 to the canonical slug, keeping any + // share-link params. + if (resolved.isAlias) { + permanentRedirect( + pathWithSearchParams(modelRoutePath('profit-estimator', resolved.route.slug), sp), + ); + } + const seed = resolveCalculatorUrlSeed(sp); + // A legacy explicit ?g_model= wins over the path, as on /calculator/. + return ( + + ); +} diff --git a/packages/app/src/app/(dashboard)/profit-estimator/page.tsx b/packages/app/src/app/(dashboard)/profit-estimator/page.tsx new file mode 100644 index 000000000..04a4dd0aa --- /dev/null +++ b/packages/app/src/app/(dashboard)/profit-estimator/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from 'next'; + +import ProfitEstimatorDisplay from '@/components/calculator/ProfitEstimatorDisplay'; +import { resolveCalculatorUrlSeed } from '@/components/calculator/url-seed'; +import { defaultRouteModel } from '@/lib/model-routes'; +import { tabMetadata } from '@/lib/tab-meta'; + +export const metadata: Metadata = tabMetadata('profit-estimator'); + +interface Props { + searchParams: Promise>; +} + +/** + * Bare `/profit-estimator` opens on the tab's default model (Kimi K3, see + * `defaultRouteModel`). Switching models in the selector rewrites the address + * bar to `/profit-estimator/` in place; see the `[model]` sibling. + */ +export default async function ProfitEstimatorPage({ searchParams }: Props) { + const sp = await searchParams; + const seed = resolveCalculatorUrlSeed(sp); + return ( + + ); +} diff --git a/packages/app/src/app/sitemap.test.ts b/packages/app/src/app/sitemap.test.ts index 3153831fe..9be51bac1 100644 --- a/packages/app/src/app/sitemap.test.ts +++ b/packages/app/src/app/sitemap.test.ts @@ -6,6 +6,13 @@ import { getAllChipRouteSlugs } from '@/lib/chip-pages'; import { INFERENCE_MODEL_SLUGS } from '@/lib/inference-model-slug'; import { zhPath } from '@/lib/i18n'; import { getModelPageSlugs } from '@/lib/model-pages'; +import { + defaultRouteModel, + MODEL_ROUTE_TABS, + MODEL_ROUTES, + modelRoutePath, + modelRoutesForTab, +} from '@/lib/model-routes'; const mocks = vi.hoisted(() => ({ fixturesMode: false, getDb: vi.fn(() => ({})), @@ -69,6 +76,23 @@ describe('sitemap locale parity', () => { } }); + it("emits per-model tab pages in both locales for the models each tab serves, skipping the tab's default model", async () => { + const entries = await sitemap(); + const urls = new Set(entries.map((entry) => entry.url)); + for (const tab of MODEL_ROUTE_TABS) { + const served = new Set(modelRoutesForTab(tab).map((route) => route.model)); + for (const route of MODEL_ROUTES) { + // The default model's page canonicalizes to the bare tab path, which + // the dashboard-route loop above already covers. Models a tab does not + // serve (the profit estimator is Kimi K3 only) 404 and stay out. + const expected = served.has(route.model) && route.model !== defaultRouteModel(tab); + const enPath = modelRoutePath(tab, route.slug); + expect(urls.has(`${SITE_URL}${enPath}`)).toBe(expected); + expect(urls.has(`${SITE_URL}${zhPath(enPath)}`)).toBe(expected); + } + } + }); + it('emits both locales for the model index and every model deep-dive page', async () => { const entries = await sitemap(); const urls = new Set(entries.map((entry) => entry.url)); diff --git a/packages/app/src/app/sitemap.ts b/packages/app/src/app/sitemap.ts index 09f2f8335..bcff96aa7 100644 --- a/packages/app/src/app/sitemap.ts +++ b/packages/app/src/app/sitemap.ts @@ -21,10 +21,10 @@ import { getAllGlossaryEntries } from '@/lib/glossary'; import { ACTIVE_INFERENCE_MODEL_SLUGS, INFERENCE_MODEL_SLUGS } from '@/lib/inference-model-slug'; import { languageAlternates, zhPath } from '@/lib/i18n'; import { - DEFAULT_ROUTE_MODEL, + defaultRouteModel, MODEL_ROUTE_TABS, - MODEL_ROUTES, modelRoutePath, + modelRoutesForTab, } from '@/lib/model-routes'; import { getAllRankingPageEntries } from '@/lib/rankings'; import { getAvailableRunEntries } from '@/lib/run-rankings-data.server'; @@ -76,13 +76,15 @@ export default async function sitemap(): Promise { // default model's pages canonicalize to the bare tab paths (already // emitted above), so they stay out of the sitemap. ...MODEL_ROUTE_TABS.flatMap((tab) => - MODEL_ROUTES.filter((route) => route.model !== DEFAULT_ROUTE_MODEL).flatMap((route) => - localizedPair(modelRoutePath(tab, route.slug), { - lastModified: now, - changeFrequency: 'daily' as const, - priority: 0.8, - }), - ), + modelRoutesForTab(tab) + .filter((route) => route.model !== defaultRouteModel(tab)) + .flatMap((route) => + localizedPair(modelRoutePath(tab, route.slug), { + lastModified: now, + changeFrequency: 'daily' as const, + priority: 0.8, + }), + ), ), ...localizedPair('/overview', { lastModified: now, diff --git a/packages/app/src/app/zh/(dashboard)/profit-estimator-per-gigawatt/[model]/page.tsx b/packages/app/src/app/zh/(dashboard)/profit-estimator-per-gigawatt/[model]/page.tsx new file mode 100644 index 000000000..ba1f774cd --- /dev/null +++ b/packages/app/src/app/zh/(dashboard)/profit-estimator-per-gigawatt/[model]/page.tsx @@ -0,0 +1,73 @@ +import type { Metadata } from 'next'; +import { notFound, permanentRedirect } from 'next/navigation'; + +import ProfitEstimatorDisplay from '@/components/calculator/ProfitEstimatorDisplay'; +import { resolveCalculatorUrlSeed } from '@/components/calculator/url-seed'; +import { ZhTabIntro } from '@/components/zh/zh-tab-intro'; +import { + modelRouteAvailableForTab, + modelRoutePath, + pathWithSearchParams, + resolveModelRouteSlug, +} from '@/lib/model-routes'; +import { modelTabCanonicalPath } from '@/lib/tab-meta'; +import { MODEL_TAB_META_ZH, modelTabMetadataZh } from '@/lib/tab-meta-zh'; + +/** Chinese sibling of `/profit-estimator/` (see that page for the + * routing and client-side model-switch behavior). */ + +interface Props { + params: Promise<{ model: string }>; + searchParams: Promise>; +} + +export async function generateMetadata({ params }: Props): Promise { + const { model } = await params; + const resolved = resolveModelRouteSlug(model); + if ( + !resolved || + !modelRouteAvailableForTab('profit-estimator-per-gigawatt', resolved.route.model) + ) + return {}; + return modelTabMetadataZh( + 'profit-estimator-per-gigawatt', + resolved.route, + modelTabCanonicalPath('profit-estimator-per-gigawatt', resolved.route), + ); +} + +export default async function ZhProfitEstimatorModelPage({ params, searchParams }: Props) { + const [{ model }, sp] = await Promise.all([params, searchParams]); + const resolved = resolveModelRouteSlug(model); + // Only the models this tab serves (see MODEL_ROUTE_TAB_MODELS) get a page. + if ( + !resolved || + !modelRouteAvailableForTab('profit-estimator-per-gigawatt', resolved.route.model) + ) { + notFound(); + } + // Keep share-link params through the 308. + if (resolved.isAlias) { + permanentRedirect( + pathWithSearchParams( + `/zh${modelRoutePath('profit-estimator-per-gigawatt', resolved.route.slug)}`, + sp, + ), + ); + } + const seed = resolveCalculatorUrlSeed(sp); + const meta = MODEL_TAB_META_ZH['profit-estimator-per-gigawatt']; + return ( + <> + + + + ); +} diff --git a/packages/app/src/app/zh/(dashboard)/profit-estimator-per-gigawatt/page.tsx b/packages/app/src/app/zh/(dashboard)/profit-estimator-per-gigawatt/page.tsx new file mode 100644 index 000000000..a1b16d795 --- /dev/null +++ b/packages/app/src/app/zh/(dashboard)/profit-estimator-per-gigawatt/page.tsx @@ -0,0 +1,31 @@ +import type { Metadata } from 'next'; + +import ProfitEstimatorDisplay from '@/components/calculator/ProfitEstimatorDisplay'; +import { resolveCalculatorUrlSeed } from '@/components/calculator/url-seed'; +import { ZhTabIntro } from '@/components/zh/zh-tab-intro'; +import { defaultRouteModel } from '@/lib/model-routes'; +import { tabMetadataZh } from '@/lib/tab-meta-zh'; + +export const metadata: Metadata = tabMetadataZh('profit-estimator-per-gigawatt'); + +interface Props { + searchParams: Promise>; +} + +/** Chinese sibling of `/profit-estimator`; opens on the tab's default model. */ +export default async function ZhProfitEstimatorPage({ searchParams }: Props) { + const sp = await searchParams; + const seed = resolveCalculatorUrlSeed(sp); + return ( + <> + + + + ); +} diff --git a/packages/app/src/app/zh/(dashboard)/profit-estimator/[model]/page.tsx b/packages/app/src/app/zh/(dashboard)/profit-estimator/[model]/page.tsx new file mode 100644 index 000000000..d946dfdc1 --- /dev/null +++ b/packages/app/src/app/zh/(dashboard)/profit-estimator/[model]/page.tsx @@ -0,0 +1,63 @@ +import type { Metadata } from 'next'; +import { notFound, permanentRedirect } from 'next/navigation'; + +import ProfitEstimatorDisplay from '@/components/calculator/ProfitEstimatorDisplay'; +import { resolveCalculatorUrlSeed } from '@/components/calculator/url-seed'; +import { ZhTabIntro } from '@/components/zh/zh-tab-intro'; +import { + modelRouteAvailableForTab, + modelRoutePath, + pathWithSearchParams, + resolveModelRouteSlug, +} from '@/lib/model-routes'; +import { modelTabCanonicalPath } from '@/lib/tab-meta'; +import { MODEL_TAB_META_ZH, modelTabMetadataZh } from '@/lib/tab-meta-zh'; + +/** Chinese sibling of `/profit-estimator/` (see that page for the + * routing and client-side model-switch behavior). */ + +interface Props { + params: Promise<{ model: string }>; + searchParams: Promise>; +} + +export async function generateMetadata({ params }: Props): Promise { + const { model } = await params; + const resolved = resolveModelRouteSlug(model); + if (!resolved || !modelRouteAvailableForTab('profit-estimator', resolved.route.model)) return {}; + return modelTabMetadataZh( + 'profit-estimator', + resolved.route, + modelTabCanonicalPath('profit-estimator', resolved.route), + ); +} + +export default async function ZhProfitEstimatorModelPage({ params, searchParams }: Props) { + const [{ model }, sp] = await Promise.all([params, searchParams]); + const resolved = resolveModelRouteSlug(model); + // Only the models this tab serves (see MODEL_ROUTE_TAB_MODELS) get a page. + if (!resolved || !modelRouteAvailableForTab('profit-estimator', resolved.route.model)) { + notFound(); + } + // Keep share-link params through the 308. + if (resolved.isAlias) { + permanentRedirect( + pathWithSearchParams(`/zh${modelRoutePath('profit-estimator', resolved.route.slug)}`, sp), + ); + } + const seed = resolveCalculatorUrlSeed(sp); + const meta = MODEL_TAB_META_ZH['profit-estimator']; + return ( + <> + + + + ); +} diff --git a/packages/app/src/app/zh/(dashboard)/profit-estimator/page.tsx b/packages/app/src/app/zh/(dashboard)/profit-estimator/page.tsx new file mode 100644 index 000000000..c6112399e --- /dev/null +++ b/packages/app/src/app/zh/(dashboard)/profit-estimator/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from 'next'; + +import ProfitEstimatorDisplay from '@/components/calculator/ProfitEstimatorDisplay'; +import { resolveCalculatorUrlSeed } from '@/components/calculator/url-seed'; +import { ZhTabIntro } from '@/components/zh/zh-tab-intro'; +import { defaultRouteModel } from '@/lib/model-routes'; +import { tabMetadataZh } from '@/lib/tab-meta-zh'; + +export const metadata: Metadata = tabMetadataZh('profit-estimator'); + +interface Props { + searchParams: Promise>; +} + +/** Chinese sibling of `/profit-estimator`; opens on the tab's default model. */ +export default async function ZhProfitEstimatorPage({ searchParams }: Props) { + const sp = await searchParams; + const seed = resolveCalculatorUrlSeed(sp); + return ( + <> + + + + ); +} diff --git a/packages/app/src/components/calculator/ProfitEstimatorChart.test.ts b/packages/app/src/components/calculator/ProfitEstimatorChart.test.ts new file mode 100644 index 000000000..1a9cb4fd1 --- /dev/null +++ b/packages/app/src/components/calculator/ProfitEstimatorChart.test.ts @@ -0,0 +1,344 @@ +import { describe, expect, it } from 'vitest'; + +import type { HardwareConfig } from '@/components/inference/types'; + +import type { ProfitEstimatorRow } from './profit-estimator'; +import { + buildProfitSegments, + contrastingTextColor, + generateProfitTooltipHTML, + estimateTextWidth, + slantedMargins, + splitAxisLabel, + xLabelLayout, + operatorMarginLabel, + profitYDomain, + BAR_ICON_MAX_HEIGHT, + BAR_ICON_MIN_HEIGHT, + barMarkHeight, + CHART_HEIGHT, + CHART_HEIGHT_COMPACT, + CHART_HEIGHT_MIN, + CHART_VIEWPORT_RESERVE, + chartHeightForViewport, + STACK_HEADROOM_PX, + stackHeadroomPx, + rowLabel, + segmentLabelLines, +} from './ProfitEstimatorChart'; + +function row(overrides: Partial = {}): ProfitEstimatorRow { + return { + hwKey: 'h200', + resultKey: 'h200', + gpuHours: 6_394_160_584, + revenuePerGpuHour: 3, + revenue: 1000, + tco: 400, + grossMargin: 600, + labCut: 180, + profit: 420, + ...overrides, + }; +} + +const hardwareConfig = { + h200: { name: 'NVIDIA H200', label: 'H200', gpu: 'H200', framework: 'vllm' }, + 'evil-chip': { name: 'Evil', label: '', gpu: 'Evil', framework: 'vllm' }, +} as unknown as HardwareConfig; + +describe('buildProfitSegments', () => { + it('stacks TCO, license fee, and profit from zero upward for a profitable SKU', () => { + const segments = buildProfitSegments([row()]); + expect(segments.map((s) => s.kind)).toEqual(['tco', 'labCut', 'profit']); + expect(segments[0]).toMatchObject({ y0: 0, y1: 400 }); + expect(segments[1]).toMatchObject({ y0: 400, y1: 580 }); + expect(segments[2]).toMatchObject({ y0: 580, y1: 1000 }); + expect(segments[2].y1).toBeCloseTo(1000); + }); + + it('draws a loss below zero and omits the license fee when margin is negative', () => { + const segments = buildProfitSegments([ + row({ revenue: 300, tco: 400, grossMargin: -100, labCut: 0, profit: -100 }), + ]); + expect(segments.map((s) => s.kind)).toEqual(['tco', 'loss']); + expect(segments[1]).toMatchObject({ y0: -100, y1: 0 }); + }); + + it('keys segments by result key so repeated SKUs with different precisions stay distinct', () => { + const segments = buildProfitSegments([ + row({ resultKey: 'h200_fp8', precision: 'fp8' }), + row({ resultKey: 'h200_fp4', precision: 'fp4' }), + ]); + const keys = new Set(segments.map((s) => s.key)); + expect(keys.size).toBe(segments.length); + expect(keys.has('h200_fp8|tco')).toBe(true); + expect(keys.has('h200_fp4|profit')).toBe(true); + }); +}); + +describe('barMarkHeight', () => { + it('grows the vendor mark with the bar between the phone and desktop bounds', () => { + expect(barMarkHeight(0)).toBe(BAR_ICON_MIN_HEIGHT); + expect(barMarkHeight(40)).toBe(BAR_ICON_MIN_HEIGHT); + expect(barMarkHeight(100)).toBeCloseTo(30, 5); + expect(barMarkHeight(400)).toBe(BAR_ICON_MAX_HEIGHT); + // The headroom the domain reserves follows the mark. + expect(stackHeadroomPx(BAR_ICON_MAX_HEIGHT) - STACK_HEADROOM_PX).toBe( + BAR_ICON_MAX_HEIGHT - BAR_ICON_MIN_HEIGHT, + ); + }); +}); + +describe('profitYDomain', () => { + it('falls back to a unit domain with no rows', () => { + expect(profitYDomain([])).toEqual([0, 1]); + }); + + it('always includes zero and leaves headroom above the tallest bar', () => { + const [bottom, top] = profitYDomain([row()]); + expect(bottom).toBe(0); + expect(top).toBeGreaterThan(1000); + }); + + it('sizes the headroom to the label stack in pixels once the plot height is known', () => { + // 1000 of data in (500 - headroom) px; the headroom is then exactly STACK_HEADROOM_PX tall. + const [, top] = profitYDomain([row()], 500); + const pxPerUnit = 500 / top; + expect((top - 1000) * pxPerUnit).toBeCloseTo(STACK_HEADROOM_PX, 5); + // A taller plot needs proportionally less data-space headroom. + expect(profitYDomain([row()], 1000)[1]).toBeLessThan(top); + expect(top).toBeLessThan(1300); + // A bigger vendor mark asks for more headroom at the same plot height. + const [, roomy] = profitYDomain([row()], 500, stackHeadroomPx(BAR_ICON_MAX_HEIGHT)); + expect(roomy).toBeGreaterThan(top); + expect((roomy - 1000) * (500 / roomy)).toBeCloseTo(stackHeadroomPx(BAR_ICON_MAX_HEIGHT), 5); + }); + + it('extends below zero when any SKU loses money, and covers TCO when it exceeds revenue', () => { + const [bottom, top] = profitYDomain([ + row({ revenue: 300, tco: 400, grossMargin: -100, labCut: 0, profit: -100 }), + ]); + expect(bottom).toBeLessThan(-100); + expect(top).toBeGreaterThan(400); + }); + + it('covers the TCO plus license-fee stack of a loss bar, not just revenue or TCO', () => { + const [bottom, top] = profitYDomain([ + row({ revenue: 300, tco: 400, grossMargin: -100, labCut: 90, profit: -190 }), + ]); + expect(bottom).toBeLessThan(-190); + expect(top).toBeGreaterThan(490); + }); +}); + +describe('rowLabel', () => { + it('uses the hardware display label and appends precision in upper case', () => { + expect(rowLabel(row({ precision: 'fp8' }), hardwareConfig)).toBe('H200 (FP8)'); + expect(rowLabel(row(), hardwareConfig)).toBe('H200'); + }); + + it('falls back to the registry entry when the chart config lacks the key', () => { + expect(rowLabel(row(), {} as HardwareConfig)).toBe('H200'); + }); +}); + +describe('generateProfitTooltipHTML', () => { + const assumptions = { utilizationPct: 60, labCutPct: 30, basis: 'gw-year' } as const; + + it('lists revenue, TCO, license fee, and profit with the configured percentages', () => { + const html = generateProfitTooltipHTML(row(), hardwareConfig, assumptions, 'en', false); + expect(html).toContain('$1.0k'); + expect(html).toContain('$400'); + expect(html).toContain('30%'); + expect(html).toContain('60%'); + expect(html).toContain('Profit'); + }); + + it('labels a negative result as a loss and still lists the license fee, which is a share of revenue', () => { + const html = generateProfitTooltipHTML( + row({ revenue: 300, tco: 400, grossMargin: -100, labCut: 90, profit: -190 }), + hardwareConfig, + assumptions, + 'en', + false, + ); + expect(html).toContain('Loss'); + expect(html).toContain('-$190'); + expect(html).toContain('Model License Fee'); + expect(html).toContain('$90'); + }); + + it('formats cents and drops the GPU-hours footer on the chip-hour basis', () => { + const html = generateProfitTooltipHTML( + row({ revenue: 2.16, tco: 1.73, grossMargin: 0.43, labCut: 0.648, profit: -0.218 }), + hardwareConfig, + { ...assumptions, basis: 'chip-hour' }, + 'en', + false, + ); + expect(html).toContain('$2.16'); + expect(html).toContain('-$0.22'); + expect(html).not.toContain('/GPU/hr'); + const perGw = generateProfitTooltipHTML(row(), hardwareConfig, assumptions, 'en', false); + expect(perGw).toContain('/GPU/hr'); + expect(perGw).not.toContain('GPU-hours'); + }); + + it('renders Chinese copy and the pin hint when requested', () => { + const pinned = generateProfitTooltipHTML(row(), hardwareConfig, assumptions, 'zh', true); + const hover = generateProfitTooltipHTML(row(), hardwareConfig, assumptions, 'zh', false); + expect(pinned).toContain('利润'); + expect(pinned).not.toBe(hover); + }); + + it('escapes HTML in the hardware label', () => { + const html = generateProfitTooltipHTML( + row({ hwKey: 'evil-chip', resultKey: 'evil-chip' }), + hardwareConfig, + assumptions, + 'en', + false, + ); + expect(html).not.toContain(''); + expect(html).toContain('<img>'); + }); +}); + +const SEGMENT_WORDS = { + tco: 'Compute Expense', + labCut: 'Model License Fee', + profit: 'Profit', + loss: 'Loss', +}; + +describe('segmentLabelLines', () => { + it('names the segment and its amount when the rectangle is tall enough', () => { + expect(segmentLabelLines('tco', row(), 40, SEGMENT_WORDS)).toEqual(['Compute Expense', '$400']); + expect(segmentLabelLines('labCut', row(), 40, SEGMENT_WORDS)).toEqual([ + 'Model License Fee', + '$180', + ]); + expect(segmentLabelLines('profit', row(), 40, SEGMENT_WORDS)).toEqual(['Profit', '$420']); + }); + + it('drops to the amount alone, then to nothing, as the rectangle shrinks', () => { + expect(segmentLabelLines('profit', row(), 20, SEGMENT_WORDS)).toEqual(['$420']); + expect( + segmentLabelLines('profit', row({ profit: 0.5 }), 40, SEGMENT_WORDS, undefined, 'chip-hour'), + ).toEqual(['Profit', '$0.50']); + expect(segmentLabelLines('profit', row(), 10, SEGMENT_WORDS)).toEqual([]); + }); + + it('labels a loss with the loss word and the negative amount', () => { + const lossRow = row({ tco: 1300, grossMargin: -300, labCut: 300, profit: -600 }); + expect(segmentLabelLines('loss', lossRow, 40, SEGMENT_WORDS)).toEqual(['Loss', '-$600']); + }); + + it('drops the name, then the amount, when the bar is too narrow (phones)', () => { + // "Compute Expense" is 15 glyphs; at 11px that is ~91px plus padding. + expect(segmentLabelLines('tco', row(), 40, SEGMENT_WORDS, 200)).toEqual([ + 'Compute Expense', + '$400', + ]); + expect(segmentLabelLines('tco', row(), 40, SEGMENT_WORDS, 60)).toEqual(['$400']); + expect(segmentLabelLines('tco', row(), 40, SEGMENT_WORDS, 20)).toEqual([]); + }); +}); + +describe('splitAxisLabel', () => { + it('puts the SKU name on the first line and the parenthesised detail on the second', () => { + expect(splitAxisLabel('GB300 NVL72 (Dynamo vLLM) (FP4)')).toEqual([ + 'GB300 NVL72', + '(Dynamo vLLM) (FP4)', + ]); + expect(splitAxisLabel('B200')).toEqual(['B200', '']); + }); +}); + +describe('xLabelLayout', () => { + const labels = ['GB300 NVL72 (Dynamo vLLM) (FP4)', 'B200 (SGLang) (FP4)']; + it('stands labels upright on two lines when every slot has room', () => { + expect(xLabelLayout(labels, 200, 10)).toBe('stacked'); + }); + it('falls back to the slanted single line on narrow slots or with no data', () => { + expect(xLabelLayout(labels, 80, 10)).toBe('slanted'); + expect(xLabelLayout([], 200, 10)).toBe('slanted'); + expect(xLabelLayout(labels, 0, 10)).toBe('slanted'); + }); +}); + +describe('slantedMargins', () => { + const base = { top: 44, right: 8, bottom: 140, left: 64 }; + it('widens the left and bottom margins so a long slanted label stays inside the SVG', () => { + const m = slantedMargins(['GB300 NVL72 (Dynamo vLLM) (FP4)'], 40, 10, base); + expect(m.left).toBeGreaterThan(base.left); + expect(m.bottom).toBeGreaterThan(base.bottom); + expect(m.top).toBe(base.top); + expect(m.right).toBe(base.right); + }); + it('never shrinks below the base margins', () => { + expect(slantedMargins(['B200'], 200, 10, base)).toEqual(base); + expect(slantedMargins([], 200, 10, base)).toEqual(base); + }); +}); + +describe('estimateTextWidth', () => { + it('scales with glyph count and font size', () => { + expect(estimateTextWidth('abcd', 10)).toBeCloseTo(22); + expect(estimateTextWidth('', 10)).toBe(0); + }); +}); + +describe('contrastingTextColor', () => { + it('puts dark text on light fills and light text on dark fills', () => { + expect(contrastingTextColor('#ffffff')).toBe('#111111'); + expect(contrastingTextColor('#f2c94c')).toBe('#111111'); + expect(contrastingTextColor('#000000')).toBe('#ffffff'); + expect(contrastingTextColor('#1f4e79')).toBe('#ffffff'); + }); + + it('falls back to white when the fill cannot be parsed', () => { + expect(contrastingTextColor('var(--primary)')).toBe('#ffffff'); + }); +}); + +describe('operatorMarginLabel', () => { + it('formats profit as a share of revenue with the caller-supplied word', () => { + expect(operatorMarginLabel(row(), 'margin')).toBe('42.0% margin'); + expect(operatorMarginLabel(row({ profit: -600 }), '利润率')).toBe('-60.0% 利润率'); + }); + + it('returns nothing when there is no revenue to divide by', () => { + expect(operatorMarginLabel(row({ revenue: 0 }), 'margin')).toBe(''); + }); + + it('keeps only the percentage when the bar is too narrow for the word', () => { + expect(operatorMarginLabel(row(), 'margin', 40)).toBe('42.0%'); + expect(operatorMarginLabel(row(), 'margin', 200)).toBe('42.0% margin'); + }); +}); + +describe('chartHeightForViewport', () => { + it('uses the full height when the viewport has room', () => { + expect(chartHeightForViewport(1200)).toBe(CHART_HEIGHT); + expect(chartHeightForViewport(CHART_HEIGHT + CHART_VIEWPORT_RESERVE)).toBe(CHART_HEIGHT); + }); + + it('shrinks to fit a laptop viewport so title and x labels share the screen', () => { + // A 13-inch MacBook browser viewport is roughly 870px tall. + expect(chartHeightForViewport(872)).toBe(872 - CHART_VIEWPORT_RESERVE); + expect(chartHeightForViewport(872)).toBeLessThan(CHART_HEIGHT); + }); + + it('never goes below the minimum where in-bar labels collide', () => { + expect(chartHeightForViewport(500)).toBe(CHART_HEIGHT_MIN); + expect(chartHeightForViewport(100)).toBe(CHART_HEIGHT_MIN); + }); + + it('respects the compact maximum and falls back to the maximum before mount', () => { + expect(chartHeightForViewport(2000, CHART_HEIGHT_COMPACT)).toBe(CHART_HEIGHT_COMPACT); + expect(chartHeightForViewport(undefined)).toBe(CHART_HEIGHT); + expect(chartHeightForViewport(Number.NaN, CHART_HEIGHT_COMPACT)).toBe(CHART_HEIGHT_COMPACT); + }); +}); diff --git a/packages/app/src/components/calculator/ProfitEstimatorChart.tsx b/packages/app/src/components/calculator/ProfitEstimatorChart.tsx new file mode 100644 index 000000000..b55e2278d --- /dev/null +++ b/packages/app/src/components/calculator/ProfitEstimatorChart.tsx @@ -0,0 +1,911 @@ +'use client'; + +import * as d3 from 'd3'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { useResponsiveChartDimensions } from '@/hooks/useResponsiveChartDimensions'; + +import type { HardwareConfig } from '@/components/inference/types'; +import { track } from '@/lib/analytics'; +import { getHardwareConfig } from '@/lib/constants'; +import { getChartWatermark } from '@/lib/data-mappings'; +import { D3Chart } from '@/lib/d3-chart/D3Chart'; +import type { + D3ChartHandle, + LayerConfig, + RenderContext, + ScaleConfig, +} from '@/lib/d3-chart/D3Chart/types'; +import { CHART_TYPE, px } from '@/lib/d3-chart/typography'; +import type { Locale } from '@/lib/i18n'; +import { useLocale } from '@/lib/use-locale'; +import { escapeHtml, getDisplayLabel } from '@/lib/utils'; +import { getAxisVendorIcon } from '@/lib/vendor-logos'; + +import { + formatProfitUsd, + type ProfitBasis, + type ProfitEstimatorAssumptions, + type ProfitEstimatorRow, +} from './profit-estimator'; + +export type ProfitSegmentKind = 'tco' | 'labCut' | 'profit' | 'loss'; + +/** One rectangle of the stack. `y0`/`y1` are $ per basis; loss segments run below zero. */ +export interface ProfitSegment { + key: string; + kind: ProfitSegmentKind; + row: ProfitEstimatorRow; + y0: number; + y1: number; +} + +/** + * Bottom and left margins leave room for x labels rotated 50° (the longest + * SKU label is ≈32 chars, ≈190px at the sub-label size): 190·sin50° ≈ 146px + * of height below the axis and 190·cos50° ≈ 122px of reach left of the first + * tick. + */ +const CHART_MARGIN = { top: 12, right: 24, bottom: 172, left: 116 }; +/** Bottom margin when the x labels stand upright on two lines (name / framework). */ +const X_LABEL_STACKED_BOTTOM = 64; +/** Tallest the chart gets, on a viewport with room to spare. */ +export const CHART_HEIGHT = 720; +/** Shortest the chart gets; below this the in-bar labels start colliding. */ +export const CHART_HEIGHT_MIN = 440; +/** + * Viewport height not available to the chart: the sticky site nav (~56px), + * the card header above the plot (title, subtitle, TCO badges and selling + * price, ~170px) and card padding. The chart shrinks to fit what is left so + * the title and the x labels share one laptop screen without scrolling. + */ +export const CHART_VIEWPORT_RESERVE = 260; +/** Below this container width the chart uses the compact margins and height. */ +export const COMPACT_CHART_MAX_WIDTH = 640; +const CHART_MARGIN_COMPACT = { top: 12, right: 8, bottom: 140, left: 64 }; +export const CHART_HEIGHT_COMPACT = 560; + +/** Chart height for a viewport: as tall as `maxHeight`, but never taller than the space under the card header. */ +export function chartHeightForViewport( + viewportHeightPx: number | undefined, + maxHeight: number = CHART_HEIGHT, +): number { + if (!Number.isFinite(viewportHeightPx) || (viewportHeightPx as number) <= 0) return maxHeight; + const available = Math.floor((viewportHeightPx as number) - CHART_VIEWPORT_RESERVE); + return Math.min(maxHeight, Math.max(CHART_HEIGHT_MIN, available)); +} + +/** Window inner height, tracked across resizes; undefined before mount so SSR and first paint agree. */ +function useViewportHeight(): number | undefined { + const [height, setHeight] = useState(undefined); + useEffect(() => { + const update = () => setHeight(window.innerHeight); + update(); + window.addEventListener('resize', update); + return () => window.removeEventListener('resize', update); + }, []); + return height; +} +/** Average glyph advance as a share of font size; used to test whether a label fits a bar. */ +const GLYPH_WIDTH_EM = 0.55; +/** Horizontal breathing room a label needs inside its bar, in px. */ +const LABEL_SIDE_PAD_PX = 4; +/** The margin line above a bar may borrow this much of the gap to each neighbour, in px. */ +const X_GAP_ALLOWANCE = 12; + +/** Rough rendered width of `text` at `fontPx`, for fit tests before anything is drawn. */ +export function estimateTextWidth(text: string, fontPx: number): number { + return text.length * GLYPH_WIDTH_EM * fontPx; +} +const X_LABEL_ROTATION = -50; + +export type XLabelLayout = 'stacked' | 'slanted'; + +/** + * Split "GB300 NVL72 (Dynamo vLLM) (FP4)" into ["GB300 NVL72", "(Dynamo vLLM) (FP4)"] + * so the SKU name and its framework can sit on two lines under the tick. + * A label with no parenthesis stays on one line. + */ +export function splitAxisLabel(label: string): [string, string] { + const at = label.indexOf(' ('); + if (at === -1) return [label, '']; + return [label.slice(0, at), label.slice(at + 1)]; +} + +/** + * Upright two-line labels when every SKU fits in its own slot; otherwise the + * classic slanted single line. `slotPx` is the horizontal room per tick, + * `fontPx` the label size. + */ +export function xLabelLayout(labels: string[], slotPx: number, fontPx: number): XLabelLayout { + if (labels.length === 0 || slotPx <= 0) return 'slanted'; + const widest = Math.max( + ...labels.map((label) => { + const [name, detail] = splitAxisLabel(label); + return Math.max(estimateTextWidth(name, fontPx), estimateTextWidth(detail, fontPx)); + }), + ); + return widest + LABEL_SIDE_PAD_PX * 2 <= slotPx ? 'stacked' : 'slanted'; +} + +/** Tick length plus padding: where d3 starts the label text below the axis, px. */ +const X_TICK_TEXT_OFFSET = 9; + +/** + * Margins a slanted label set needs so no glyph leaves the SVG. The widest + * label projects `cos` of its length left of the first tick and `sin` below + * the axis; `slotPx / 2` of the reach is already inside the plot because the + * first tick sits half a slot in. Never smaller than `base`. + */ +export function slantedMargins( + labels: string[], + slotPx: number, + fontPx: number, + base: { top: number; right: number; bottom: number; left: number }, +): { top: number; right: number; bottom: number; left: number } { + const widest = Math.max( + 0, + ...labels.map((label) => estimateTextWidth(label, fontPx) + X_LABEL_DX_EM * fontPx), + ); + const rad = (Math.abs(X_LABEL_ROTATION) * Math.PI) / 180; + const reach = widest * Math.cos(rad) - Math.max(0, slotPx) / 2 + LABEL_SIDE_PAD_PX; + const drop = widest * Math.sin(rad) + X_TICK_TEXT_OFFSET + fontPx; + return { + ...base, + left: Math.max(base.left, Math.ceil(reach)), + bottom: Math.max(base.bottom, Math.ceil(drop)), + }; +} +/** Distance from the tick to the label's end, in em (the d3 default is 0). */ +const X_LABEL_DX_EM = 0.4; +/** Band-scale padding between bars; also drives the vendor-mark size above each bar. */ +const BAND_PADDING = 0.3; +/** Gap between the top of the stack and the revenue figure's baseline, in px. */ +const REVENUE_LABEL_GAP = 20; +/** Smallest vendor mark above a bar, in px; what a phone-width bar gets. */ +export const BAR_ICON_MIN_HEIGHT = 14; +/** Largest vendor mark above a bar, in px, however wide the bar gets. */ +export const BAR_ICON_MAX_HEIGHT = 48; +/** Share of the bar width the mark grows to between those bounds. */ +const BAR_ICON_WIDTH_SHARE = 0.3; +/** Gap between the vendor mark and the top of the revenue figure, in px. */ +const BAR_ICON_GAP = 4; + +/** Vendor mark height for a bar `bandwidthPx` wide: as big as the bar allows, within bounds. */ +export function barMarkHeight(bandwidthPx: number): number { + if (!Number.isFinite(bandwidthPx) || bandwidthPx <= 0) return BAR_ICON_MIN_HEIGHT; + return Math.min( + BAR_ICON_MAX_HEIGHT, + Math.max(BAR_ICON_MIN_HEIGHT, bandwidthPx * BAR_ICON_WIDTH_SHARE), + ); +} + +function barMarkWidth( + icon: { width: number; height: number } | undefined, + heightPx: number, +): number { + return icon ? (icon.width / icon.height) * heightPx : 0; +} + +/** Opacity of the license-fee segment relative to the SKU's profit colour. */ +/** + * Every segment is drawn in its SKU's colour at a different weight so a bar + * reads as one chip: TCO is the palest tint, the license fee a mid tint, profit + * the solid colour. Loss is a hatch in the same colour below the axis. + */ +const TCO_OPACITY = 0.22; +const LAB_CUT_OPACITY = 0.5; +const LOSS_FILL = 'var(--destructive)'; +/** Segment heights below which the in-bar label drops to one line, then disappears. */ +const SEGMENT_TWO_LINE_MIN_PX = 34; +const SEGMENT_ONE_LINE_MIN_PX = 17; + +/** Stable SVG pattern id for a loss hatch in the SKU's colour. */ +export function lossPatternId(resultKey: string): string { + return `profit-loss-hatch-${resultKey.replaceAll(/[^a-zA-Z0-9_-]/g, '_')}`; +} + +const STRINGS = { + en: { + yAxis: { + 'gw-year': 'Revenue per all-in provisioned utility GW per year ($ USD)', + 'chip-hour': 'Revenue per chip per hour ($ USD)', + }, + yAxisCompact: { + 'gw-year': 'Revenue per provisioned GW-year ($ USD)', + 'chip-hour': 'Revenue per chip-hour ($ USD)', + }, + revenue: 'Revenue', + revenuePerGpuHour: 'Revenue per GPU-hour', + tco: 'Compute Expense', + grossMargin: 'Gross Margin', + labCut: 'Model License Fee', + profit: 'Profit', + loss: 'Loss', + marginPct: 'Margin', + marginShort: 'margin', + utilization: 'Utilization', + labCutShare: 'License fee share', + ofRevenue: 'of revenue', + dismiss: 'Click anywhere to dismiss', + noData: 'No SKU can be priced for the current selection.', + }, + zh: { + yAxis: { + 'gw-year': '每全电源配置吉瓦每年收入(美元)', + 'chip-hour': '每芯片每小时收入(美元)', + }, + yAxisCompact: { + 'gw-year': '每吉瓦每年收入(美元)', + 'chip-hour': '每芯片小时收入(美元)', + }, + revenue: '收入', + revenuePerGpuHour: '每 GPU 小时收入', + tco: '算力支出', + grossMargin: '毛利', + labCut: '模型许可费', + profit: '利润', + loss: '亏损', + marginPct: '利润率', + marginShort: '利润率', + utilization: '利用率', + labCutShare: '许可费比例', + ofRevenue: '(占收入)', + dismiss: '点击任意位置关闭', + noData: '当前选择下没有可定价的 SKU。', + }, +} as const; + +export function profitEstimatorChartStrings(locale: Locale) { + return STRINGS[locale]; +} + +/** Segment label lines that fit a given pixel height: name and amount, amount only, or none. */ +export function segmentLabelLines( + kind: ProfitSegmentKind, + row: ProfitEstimatorRow, + heightPx: number, + t: { tco: string; labCut: string; profit: string; loss: string }, + widthPx: number = Number.POSITIVE_INFINITY, + basis: ProfitBasis = 'gw-year', +): string[] { + // Profit and loss both read `row.profit`; the loss kind only changes the word. + const amount = kind === 'tco' ? row.tco : kind === 'labCut' ? row.labCut : row.profit; + const name = + kind === 'tco' ? t.tco : kind === 'labCut' ? t.labCut : kind === 'profit' ? t.profit : t.loss; + const amountText = formatProfitUsd(amount, basis); + const fits = (text: string) => + estimateTextWidth(text, CHART_TYPE.annotation) + LABEL_SIDE_PAD_PX <= widthPx; + // A narrow bar (phones) drops the name first, then the amount, so text + // never spills into the neighbouring bar. + if (!fits(amountText)) return []; + if (heightPx >= SEGMENT_TWO_LINE_MIN_PX && fits(name)) return [name, amountText]; + if (heightPx >= SEGMENT_ONE_LINE_MIN_PX) return [amountText]; + return []; +} + +/** + * Text colour that reads on a solid SKU fill. Palette colours are hex, so + * relative luminance is available; anything unparseable gets white. + */ +function linearChannel(c: number): number { + const v = c / 255; + return v <= 0.039_28 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; +} + +export function contrastingTextColor(fill: string): string { + const parsed = d3.color(fill)?.rgb(); + if (!parsed) return '#ffffff'; + const channel = linearChannel; + const luminance = + 0.2126 * channel(parsed.r) + 0.7152 * channel(parsed.g) + 0.0722 * channel(parsed.b); + return luminance > 0.45 ? '#111111' : '#ffffff'; +} + +/** + * Margin as a share of revenue, formatted for the label above the bar. When + * the bar is too narrow for the word, only the percentage is shown. + */ +export function operatorMarginLabel( + row: ProfitEstimatorRow, + marginWord: string, + widthPx: number = Number.POSITIVE_INFINITY, +): string { + if (!(row.revenue > 0)) return ''; + const pct = formatPct(row.profit / row.revenue); + const full = `${pct} ${marginWord}`; + return estimateTextWidth(full, CHART_TYPE.annotation) <= widthPx ? full : pct; +} + +/** Turn a priced row into its stacked rectangles. Pure, so it is unit-tested. */ +export function buildProfitSegments(rows: readonly ProfitEstimatorRow[]): ProfitSegment[] { + const segments: ProfitSegment[] = []; + for (const row of rows) { + segments.push({ key: `${row.resultKey}|tco`, kind: 'tco', row, y0: 0, y1: row.tco }); + if (row.labCut > 0) { + segments.push({ + key: `${row.resultKey}|labCut`, + kind: 'labCut', + row, + y0: row.tco, + y1: row.tco + row.labCut, + }); + } + if (row.profit > 0) { + const base = row.tco + row.labCut; + segments.push({ + key: `${row.resultKey}|profit`, + kind: 'profit', + row, + y0: base, + y1: base + row.profit, + }); + } else if (row.profit < 0) { + segments.push({ key: `${row.resultKey}|loss`, kind: 'loss', row, y0: row.profit, y1: 0 }); + } + } + return segments; +} + +/** + * Pixels the tallest stack needs above it: the revenue figure and margin line + * (REVENUE_LABEL_GAP covers both), then a vendor mark `iconHeightPx` tall and its gap. + */ +export function stackHeadroomPx(iconHeightPx = BAR_ICON_MIN_HEIGHT): number { + return REVENUE_LABEL_GAP + CHART_TYPE.axisLabel + BAR_ICON_GAP + iconHeightPx + 8; +} + +/** Headroom with the smallest vendor mark; what a phone-width chart reserves. */ +export const STACK_HEADROOM_PX = stackHeadroomPx(); + +/** + * Y domain for the bars. The top is the tallest positive stack plus exactly the + * pixel headroom its labels need (`headroomPx`), converted to data units through + * `plotHeightPx` (a proportional 30% fallback when the plot has not been measured yet). + */ +export function profitYDomain( + rows: readonly ProfitEstimatorRow[], + plotHeightPx = 0, + headroomPx = STACK_HEADROOM_PX, +): [number, number] { + if (rows.length === 0) return [0, 1]; + // A loss bar stacks TCO and the license fee above the axis, and that sum can + // exceed both revenue and TCO, so size the top to the tallest positive stack. + const top = Math.max(0, ...rows.map((row) => Math.max(row.revenue, row.tco + row.labCut))); + const bottom = Math.min(0, ...rows.map((row) => row.profit)); + const span = top - bottom; + const headroom = + plotHeightPx > headroomPx * 2 ? (span * headroomPx) / (plotHeightPx - headroomPx) : span * 0.3; + return [bottom * 1.12, top === 0 ? 1 : top + headroom]; +} + +export function rowLabel(row: ProfitEstimatorRow, hardwareConfig: HardwareConfig): string { + const config = hardwareConfig[row.hwKey] || getHardwareConfig(row.hwKey); + const base = config ? getDisplayLabel(config) : row.hwKey; + return row.precision ? `${base} (${row.precision.toUpperCase()})` : base; +} + +function formatPct(value: number): string { + return `${(value * 100).toFixed(1)}%`; +} + +export function generateProfitTooltipHTML( + row: ProfitEstimatorRow, + hardwareConfig: HardwareConfig, + assumptions: ProfitEstimatorAssumptions, + locale: Locale, + isPinned: boolean, + skuColor = 'var(--foreground)', +): string { + const t = STRINGS[locale]; + const usd = (value: number) => formatProfitUsd(value, assumptions.basis); + const colon = locale === 'zh' ? ':' : ':'; + const label = escapeHtml(rowLabel(row, hardwareConfig)); + const line = (name: string, value: string, color?: string, opacity = 1) => + `
${ + color + ? `` + : '' + }${name}${colon}${value}
`; + + const operatorMargin = row.revenue > 0 ? row.profit / row.revenue : Number.NaN; + const profitLine = + row.profit >= 0 + ? line(t.profit, usd(row.profit), skuColor) + : line(t.loss, usd(row.profit), LOSS_FILL); + const labCutLine = line( + `${t.labCut} (${assumptions.labCutPct}% ${t.ofRevenue})`, + usd(row.labCut), + skuColor, + LAB_CUT_OPACITY, + ); + return ` +
+ ${isPinned ? `
${t.dismiss}
` : ''} +
${label}
+ ${line(`${t.revenue} (${t.utilization} ${assumptions.utilizationPct}%)`, usd(row.revenue))} + ${line(t.tco, usd(row.tco), skuColor, TCO_OPACITY)} + ${line(t.grossMargin, usd(row.grossMargin))} + ${labCutLine} + ${profitLine} + ${line(t.marginPct, Number.isFinite(operatorMargin) ? formatPct(operatorMargin) : '—')} + ${ + // Per chip-hour the bar already is the $/GPU/hr figure. + assumptions.basis === 'gw-year' + ? `
+ ${line(t.revenuePerGpuHour, `$${row.revenuePerGpuHour.toFixed(2)}/GPU/hr`)} +
` + : '' + } +
`; +} + +interface ProfitEstimatorChartProps { + rows: ProfitEstimatorRow[]; + hardwareConfig: HardwareConfig; + colorResolver: (hwKey: string) => string; + assumptions: ProfitEstimatorAssumptions; + legendElement?: React.ReactNode; + caption?: React.ReactNode; +} + +export default function ProfitEstimatorChart({ + rows, + hardwareConfig, + colorResolver, + assumptions, + legendElement, + caption, +}: ProfitEstimatorChartProps) { + const chartRef = useRef(null); + const { dimensions, setContainerRef } = useResponsiveChartDimensions({ height: CHART_HEIGHT }); + const compact = dimensions.width > 0 && dimensions.width < COMPACT_CHART_MAX_WIDTH; + const viewportHeight = useViewportHeight(); + const chartHeight = chartHeightForViewport( + viewportHeight, + compact ? CHART_HEIGHT_COMPACT : CHART_HEIGHT, + ); + const locale = useLocale(); + const t = STRINGS[locale]; + const strings = t; + const basis = assumptions.basis; + + const segments = useMemo(() => buildProfitSegments(rows), [rows]); + + const xScaleConfig = useMemo( + () => ({ type: 'band', domain: rows.map((row) => row.resultKey), padding: BAND_PADDING }), + [rows], + ); + const labelMap = useMemo( + () => new Map(rows.map((row) => [row.resultKey, rowLabel(row, hardwareConfig)])), + [rows, hardwareConfig], + ); + + const fillFor = useCallback( + (segment: ProfitSegment): string => { + if (segment.kind === 'loss') return `url(#${lossPatternId(segment.row.resultKey)})`; + return colorResolver(segment.row.hwKey); + }, + [colorResolver], + ); + + /** Resting stroke: the tinted segments (TCO, license fee) and loss are outlined in the SKU colour. */ + const outlineFor = useCallback( + (segment: ProfitSegment): string => + segment.kind === 'profit' ? 'none' : colorResolver(segment.row.hwKey), + [colorResolver], + ); + + /** In-segment text colour: neutral on the muted and translucent fills, luminance-picked on solid SKU fills. */ + const labelColorFor = useCallback( + (segment: ProfitSegment): string => { + if (segment.kind === 'profit') return contrastingTextColor(colorResolver(segment.row.hwKey)); + return 'var(--foreground)'; + }, + [colorResolver], + ); + + // Refs so the tooltip closure never goes stale without re-binding handlers. + const tooltipStateRef = useRef({ hardwareConfig, assumptions, locale }); + tooltipStateRef.current = { hardwareConfig, assumptions, locale }; + const hoveredBarXRef = useRef(0); + + const layers = useMemo[]>(() => { + const stackLayer: LayerConfig = { + type: 'custom', + key: 'profit-stack', + render: (group, ctx) => { + const xScale = ctx.xScale as d3.ScaleBand; + const yScale = ctx.yScale as d3.ScaleLinear; + const bandwidth = xScale.bandwidth(); + + // One diagonal-hatch pattern per losing SKU, in that SKU's colour, so a + // loss reads as "same chip, below the line" rather than as a new colour. + const lossRows = segments.filter((d) => d.kind === 'loss').map((d) => d.row); + const defs = group + .selectAll('defs.loss-patterns') + .data([0]) + .join('defs') + .attr('class', 'loss-patterns'); + const patterns = defs + .selectAll('pattern') + .data(lossRows, (d) => d.resultKey) + .join((enter) => { + const pattern = enter + .append('pattern') + .attr('patternUnits', 'userSpaceOnUse') + .attr('width', 6) + .attr('height', 6) + .attr('patternTransform', 'rotate(45)'); + pattern.append('rect').attr('class', 'hatch-bg').attr('width', 6).attr('height', 6); + pattern + .append('line') + .attr('class', 'hatch-line') + .attr('x1', 0) + .attr('y1', 0) + .attr('x2', 0) + .attr('y2', 6) + .attr('stroke-width', 2); + return pattern; + }) + .attr('id', (d) => lossPatternId(d.resultKey)); + patterns + .select('.hatch-bg') + .attr('fill', (d) => colorResolver(d.hwKey)) + .attr('fill-opacity', 0.18); + patterns.select('.hatch-line').attr('stroke', (d) => colorResolver(d.hwKey)); + + // Zero line, drawn before the bars so loss segments sit on top of it. + group + .selectAll('.zero-line') + .data([0]) + .join('line') + .attr('class', 'zero-line') + .attr('x1', 0) + .attr('x2', ctx.width) + .attr('y1', yScale(0)) + .attr('y2', yScale(0)) + .attr('stroke', 'var(--foreground)') + .attr('stroke-opacity', 0.6) + .attr('stroke-width', 1); + + const bars = group + .selectAll('.bar') + .data(segments, (d) => d.key) + .join('rect') + .attr('class', (d) => `bar bar-${d.kind}`) + .attr('data-kind', (d) => d.kind) + .attr('x', (d) => xScale(d.row.resultKey) ?? 0) + .attr('width', bandwidth) + .attr('y', (d) => yScale(d.y1)) + .attr('height', (d) => Math.max(0, yScale(d.y0) - yScale(d.y1))) + .attr('fill', (d) => fillFor(d)) + .attr('fill-opacity', (d) => + d.kind === 'tco' + ? TCO_OPACITY + : d.kind === 'labCut' + ? LAB_CUT_OPACITY + : d.kind === 'loss' + ? 1 + : 0.9, + ) + .attr('stroke', (d) => outlineFor(d)) + .attr('stroke-width', (d) => (d.kind === 'profit' ? 0 : 1)) + .attr('rx', 1) + .attr('cursor', 'pointer'); + + // Name and amount inside every segment tall enough to hold them. + const labelText = { + tco: strings.tco, + labCut: strings.labCut, + profit: strings.profit, + loss: strings.loss, + }; + const segmentLabels = segments.map((d) => { + const height = Math.max(0, yScale(d.y0) - yScale(d.y1)); + return { + segment: d, + lines: segmentLabelLines(d.kind, d.row, height, labelText, bandwidth, basis), + height, + }; + }); + const segmentText = group + .selectAll('.segment-label') + .data(segmentLabels, (d) => d.segment.key) + .join('text') + .attr('class', (d) => `segment-label segment-label-${d.segment.kind}`) + .attr('x', (d) => (xScale(d.segment.row.resultKey) ?? 0) + bandwidth / 2) + .attr('y', (d) => { + const mid = (yScale(d.segment.y0) + yScale(d.segment.y1)) / 2; + return d.lines.length === 2 ? mid - CHART_TYPE.annotation * 0.15 : mid; + }) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'central') + .attr('font-size', px(CHART_TYPE.annotation)) + .attr('pointer-events', 'none') + .style('fill', (d) => labelColorFor(d.segment)); + segmentText + .selectAll('tspan') + .data((d) => d.lines.map((line, index) => ({ line, index, count: d.lines.length }))) + .join('tspan') + .attr('x', function () { + return (this.parentNode as SVGTextElement).getAttribute('x'); + }) + .attr('dy', (d) => (d.count === 2 ? (d.index === 0 ? '-0.55em' : '1.15em') : '0')) + .attr('font-weight', (d) => (d.count === 2 && d.index === 1 ? '600' : '400')) + .text((d) => d.line); + + // Revenue above the stack with the margin under it; the loss figure + // sits below the bar when the stack dips under zero. The vendor's + // full-color mark sits above the revenue figure. + const labelData = segments.filter((d) => d.kind === 'tco'); + const stackTopPx = (d: ProfitSegment) => yScale(Math.max(d.row.revenue, d.row.tco)); + const revenueBaselineY = (d: ProfitSegment) => stackTopPx(d) - REVENUE_LABEL_GAP; + // Same size the y domain reserved headroom for (both derive from the band width). + const iconHeight = barMarkHeight(bandwidth); + group + .selectAll('image.bar-vendor-mark') + .data( + labelData.filter((d) => getAxisVendorIcon(d.row.hwKey)), + (d) => d.row.resultKey, + ) + .join('image') + .attr('class', (d) => + getAxisVendorIcon(d.row.hwKey)?.monochrome + ? 'bar-vendor-mark dark:invert' + : 'bar-vendor-mark', + ) + .attr('aria-hidden', 'true') + .attr('href', (d) => getAxisVendorIcon(d.row.hwKey)?.href ?? '') + .attr('width', (d) => barMarkWidth(getAxisVendorIcon(d.row.hwKey), iconHeight)) + .attr('height', iconHeight) + .attr('x', (d) => { + const w = barMarkWidth(getAxisVendorIcon(d.row.hwKey), iconHeight); + return (xScale(d.row.resultKey) ?? 0) + bandwidth / 2 - w / 2; + }) + .attr('y', (d) => revenueBaselineY(d) - CHART_TYPE.axisLabel - BAR_ICON_GAP - iconHeight) + .attr('pointer-events', 'none'); + const revenueText = group + .selectAll('.revenue-label') + .data(labelData, (d) => d.row.resultKey) + .join('text') + .attr('class', 'revenue-label') + .attr('x', (d) => (xScale(d.row.resultKey) ?? 0) + bandwidth / 2) + .attr('y', revenueBaselineY) + .attr('text-anchor', 'middle') + .attr('font-size', px(CHART_TYPE.axisLabel)) + .attr('font-weight', '700') + .style('fill', 'var(--foreground)'); + revenueText + .selectAll('tspan') + .data((d) => [ + { row: d.row, text: formatProfitUsd(d.row.revenue, basis), sub: false }, + { + row: d.row, + text: operatorMarginLabel(d.row, strings.marginShort, bandwidth + X_GAP_ALLOWANCE), + sub: true, + }, + ]) + .join('tspan') + .attr('class', (d) => (d.sub ? 'revenue-margin' : 'revenue-amount')) + .attr('x', (d) => (xScale(d.row.resultKey) ?? 0) + bandwidth / 2) + .attr('dy', (d) => (d.sub ? '1.2em' : '0')) + .attr('font-size', (d) => (d.sub ? px(CHART_TYPE.annotation) : null)) + .attr('font-weight', (d) => (d.sub ? '500' : '700')) + .style('fill', (d) => + d.sub + ? d.row.profit < 0 + ? LOSS_FILL + : 'var(--muted-foreground)' + : 'var(--foreground)', + ) + .text((d) => d.text); + + group + .selectAll('.loss-label') + .data( + labelData.filter((d) => d.row.profit < 0), + (d) => d.row.resultKey, + ) + .join('text') + .attr('class', 'loss-label') + .attr('x', (d) => (xScale(d.row.resultKey) ?? 0) + bandwidth / 2) + .attr('y', (d) => yScale(d.row.profit) + 14) + .attr('text-anchor', 'middle') + .attr('font-size', px(CHART_TYPE.annotation)) + .attr('font-weight', '600') + .style('fill', LOSS_FILL) + .text((d) => `${strings.loss} ${formatProfitUsd(d.row.profit, basis)}`); + + return bars; + }, + }; + return [stackLayer]; + }, [segments, fillFor, outlineFor, labelColorFor, colorResolver, strings, basis]); + + const tooltip = useMemo( + () => ({ + rulerType: 'vertical' as const, + content: (segment: ProfitSegment, isPinned: boolean) => { + const state = tooltipStateRef.current; + return generateProfitTooltipHTML( + segment.row, + state.hardwareConfig, + state.assumptions, + state.locale, + isPinned, + colorResolver(segment.row.hwKey), + ); + }, + getRulerX: () => hoveredBarXRef.current, + onHoverStart: ( + selection: d3.Selection, + segment: ProfitSegment, + ) => { + hoveredBarXRef.current = + Number.parseFloat(selection.attr('x') || '0') + + Number.parseFloat(selection.attr('width') || '0') / 2; + const svgEl = chartRef.current?.getSvgElement(); + if (!svgEl) return; + d3.select(svgEl) + .selectAll('.bar') + .attr('stroke', (d) => + d.row.resultKey === segment.row.resultKey ? 'var(--foreground)' : outlineFor(d), + ) + .attr('stroke-width', (d) => + d.row.resultKey === segment.row.resultKey ? 1.5 : outlineFor(d) === 'none' ? 0 : 1, + ); + }, + onHoverEnd: () => { + const svgEl = chartRef.current?.getSvgElement(); + if (!svgEl) return; + d3.select(svgEl) + .selectAll('.bar') + .attr('stroke', (d) => outlineFor(d)) + .attr('stroke-width', (d) => (outlineFor(d) === 'none' ? 0 : 1)); + }, + onPointClick: (segment: ProfitSegment) => { + track('profit_bar_selected', { gpu: segment.row.hwKey, segment: segment.kind }); + }, + attachToLayer: 0, + }), + [outlineFor, colorResolver], + ); + + const baseMargin = compact ? CHART_MARGIN_COMPACT : CHART_MARGIN; + // Upright two-line labels when each SKU has room for them; slanted otherwise. + const labelLayout = useMemo(() => { + const plotWidth = dimensions.width - baseMargin.left - baseMargin.right; + const slot = rows.length > 0 ? plotWidth / rows.length : 0; + return xLabelLayout([...labelMap.values()], slot, CHART_TYPE.axisLabelSub); + }, [dimensions.width, baseMargin, rows.length, labelMap]); + const margin = useMemo(() => { + if (labelLayout === 'stacked') return { ...baseMargin, bottom: X_LABEL_STACKED_BOTTOM }; + const plotWidth = dimensions.width - baseMargin.left - baseMargin.right; + const slot = rows.length > 0 ? plotWidth / rows.length : 0; + return slantedMargins([...labelMap.values()], slot, CHART_TYPE.axisLabelSub, baseMargin); + }, [baseMargin, labelLayout, dimensions.width, rows.length, labelMap]); + const plotHeight = chartHeight - margin.top - margin.bottom; + // The vendor mark grows with the bar, so the headroom above the tallest stack + // has to be sized from the same band width the renderer will see. + const bandwidth = useMemo(() => { + const plotWidth = dimensions.width - margin.left - margin.right; + if (plotWidth <= 0 || rows.length === 0) return 0; + return d3 + .scaleBand() + .domain(rows.map((row) => row.resultKey)) + .range([0, plotWidth]) + .padding(BAND_PADDING) + .bandwidth(); + }, [dimensions.width, margin, rows]); + const yDomain = useMemo( + () => profitYDomain(rows, plotHeight, stackHeadroomPx(barMarkHeight(bandwidth))), + [rows, plotHeight, bandwidth], + ); + const yScaleConfig = useMemo( + () => ({ type: 'linear', domain: yDomain, nice: true }), + [yDomain], + ); + + const xAxisConfig = useMemo( + () => ({ + tickFormat: (d: d3.AxisDomain) => labelMap.get(String(d)) ?? String(d), + customize: (axisGroup: d3.Selection) => { + const fontPx = CHART_TYPE.axisLabelSub; + axisGroup.selectAll('.tick').each(function (d) { + const text = d3 + .select(this) + .select('text') + .attr('font-size', px(fontPx)) + .style('fill', 'var(--foreground)'); + if (labelLayout === 'stacked') { + // Two upright lines centred on the tick: SKU name, then the + // framework and precision in parentheses. + const [name, detail] = splitAxisLabel(labelMap.get(String(d)) ?? String(d)); + text + .attr('transform', null) + .attr('text-anchor', 'middle') + .attr('dx', null) + .attr('dy', '0.9em') + .text(null); + text.append('tspan').attr('x', 0).text(name); + if (detail) text.append('tspan').attr('x', 0).attr('dy', '1.2em').text(detail); + return; + } + // One line rotated about the tick and anchored at its end, so the + // text runs away from the axis towards the lower left. + text + .attr('transform', `rotate(${X_LABEL_ROTATION})`) + .attr('text-anchor', 'end') + .attr('dx', `${-(X_LABEL_DX_EM * fontPx)}px`) + .attr('dy', '0.6em'); + }); + }, + }), + [labelMap, labelLayout], + ); + + // The full axis label is longer than a phone-height plot; the compact + // chart uses the short form. + const yAxisConfig = useMemo( + () => ({ + label: compact ? t.yAxisCompact[basis] : t.yAxis[basis], + tickFormat: (d: d3.AxisDomain) => formatProfitUsd(Number(d), basis, 0), + tickCount: 8, + }), + [compact, basis, t.yAxis, t.yAxisCompact], + ); + + const onRender = useMemo( + () => (ctx: RenderContext) => { + // The y-axis title is set by the shared renderer; keep tick text tabular. + ctx.layout.yAxisGroup.selectAll('text').style('font-variant-numeric', 'tabular-nums'); + }, + [], + ); + + const dataIdentity = useMemo(() => rows.map((row) => row.resultKey).join('|'), [rows]); + const metricIdentity = useMemo( + () => rows.map((row) => `${row.revenue}:${row.tco}:${row.labCut}:${row.profit}`).join('|'), + [rows], + ); + + if (rows.length === 0) { + return ( +
+ {t.noData} +
+ ); + } + + return ( +
+ + ref={chartRef} + chartId="profit-estimator-chart" + data={segments} + dataIdentity={dataIdentity} + metricIdentity={metricIdentity} + height={chartHeight} + margin={margin} + watermark={getChartWatermark()} + testId="profit-estimator-chart" + clipContent={false} + xScale={xScaleConfig} + yScale={yScaleConfig} + xAxis={xAxisConfig} + yAxis={yAxisConfig} + layers={layers} + tooltip={tooltip} + onRender={onRender} + // No hover hint: the bars carry their own labels and the tooltip is discoverable. + instructions="" + legendElement={legendElement} + caption={caption} + /> +
+ ); +} diff --git a/packages/app/src/components/calculator/ProfitEstimatorDisplay.tsx b/packages/app/src/components/calculator/ProfitEstimatorDisplay.tsx new file mode 100644 index 000000000..7949a5be9 --- /dev/null +++ b/packages/app/src/components/calculator/ProfitEstimatorDisplay.tsx @@ -0,0 +1,1184 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { + DISPLAY_MODEL_TO_DB, + HW_REGISTRY, + TCO_SOURCE_TITLE, + TCO_SOURCE_URL, +} from '@semianalysisai/inferencex-constants'; +import { Info, Plus, X } from 'lucide-react'; +import Link from 'next/link'; + +import ProfitEstimatorChart from '@/components/calculator/ProfitEstimatorChart'; +import { + resolveCalculatorVisibility, + type CalculatorVisibilityIntent, +} from '@/components/calculator/ThroughputCalculatorDisplay'; +import type { CalculatorUrlSeed } from '@/components/calculator/url-seed'; +import { + GlobalFilterProvider, + useGlobalFilterActions, + useGlobalFilterAvailability, + useGlobalFilterRun, + useGlobalFilterSelection, +} from '@/components/GlobalFilterContext'; +import { cachedInputPricePerMillion, formatTokenPrice } from '@/components/inference/token-revenue'; +import { + COST_TIER_LABELS, + costTierLabel, + type CostTier, +} from '@/components/inference/metric-registry'; +import type { TokenRevenuePricing } from '@/components/inference/types'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { ChartButtons } from '@/components/ui/chart-buttons'; +import ChartLegendItem from '@/components/ui/chart-legend-item'; +import { ChartShareActions } from '@/components/ui/chart-display-helpers'; +import { ModelSelector, PercentileSelector } from '@/components/ui/chart-selectors'; +import { DashboardSectionHeader } from '@/components/ui/dashboard-section-header'; +import { ExternalLinkIcon } from '@/components/ui/external-link-icon'; +import { Heading } from '@/components/ui/heading'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { LabelWithTooltip } from '@/components/ui/label-with-tooltip'; +import { ModelLogo } from '@/components/ui/model-logo'; +import { MultiSelect } from '@/components/ui/multi-select'; +import { ResultContext } from '@/components/ui/result-context'; +import { Skeleton } from '@/components/ui/skeleton'; +import { TooltipProvider } from '@/components/ui/tooltip'; +import { useOpenRouterPricing } from '@/hooks/api/use-openrouter-pricing'; +import { useOpenDropdown } from '@/hooks/useOpenDropdown'; +import { useThemeColors } from '@/hooks/useThemeColors'; +import { useUrlState } from '@/hooks/useUrlState'; +import { track } from '@/lib/analytics'; +import { getGpuSpecs, getModelSortIndex } from '@/lib/constants'; +import { exportToCsv } from '@/lib/csv-export'; +import { + getModelLabel, + getOpenRouterModelId, + getSequenceLabel, + Percentile, + Sequence, + type Model, +} from '@/lib/data-mappings'; +import { modelRoutesForTab, type ModelRouteTab } from '@/lib/model-routes'; +import { useFeatureGate } from '@/lib/use-feature-gate'; +import { useLocale } from '@/lib/use-locale'; +import { getDisplayLabel } from '@/lib/utils'; + +import { + clampPercent, + DEFAULT_LAB_CUT_PCT, + DEFAULT_PROFIT_INTERACTIVITY, + DEFAULT_UTILIZATION_PCT, + estimateProfitRows, + modelsWithAgenticData, + parseTokenPriceInput, + type ProfitBasis, + type ProfitEstimatorSkipReason, +} from './profit-estimator'; +import { profitEstimatorChartStrings, rowLabel } from './ProfitEstimatorChart'; +import type { CostProvider } from './types'; +import { useThroughputData } from './useThroughputData'; + +type PriceSource = 'openrouter' | 'custom'; + +/** The three published TCO tiers plus a per-chip $/GPU/hr the reader types. */ +type ProfitCostProvider = CostProvider | 'custom'; + +const COST_PROVIDER_TIER: Record = { + costh: 'hyperscaler', + costn: 'neocloud', + costr: 'rental', + custom: 'custom', +}; + +// The published tiers use the same option labels as the /inference y-axis +// selector (Owning - Hyperscaler, Owning - Neocloud Giant, 3 Year Rental). +const COST_PROVIDER_OPTIONS: { value: ProfitCostProvider; label: string; labelZh: string }[] = [ + ...(['costh', 'costn', 'costr'] as const).map((value) => ({ + value, + label: COST_TIER_LABELS[COST_PROVIDER_TIER[value]].option, + labelZh: COST_TIER_LABELS[COST_PROVIDER_TIER[value]].optionZh, + })), + { value: 'custom', label: 'Custom $/GPU/hr', labelZh: '自定义 $/GPU/hr' }, +]; + +/** Tier the custom inputs are seeded from, and the tier interpolation runs on. */ +const CUSTOM_COST_SEED: CostProvider = 'costh'; + +/** Base GPU (`h200`, `gb300`) of a legend key like `gb300_dynamo-sglang`. */ +function baseGpuOf(hwKey: string): string { + return hwKey.split('_')[0] ?? hwKey; +} + +/** + * A typed custom cost. Empty or non-numeric → undefined, so the SKU drops out + * with the `no-cost` reason instead of being priced at zero. + */ +export function parseCustomCostInput(raw: string | undefined): number | undefined { + if (raw === undefined || raw.trim() === '') return undefined; + const value = Number(raw); + return Number.isFinite(value) && value >= 0 ? value : undefined; +} + +const PRICE_SOURCE_OPTIONS: { value: PriceSource; label: string; labelZh: string }[] = [ + { value: 'openrouter', label: 'OpenRouter', labelZh: 'OpenRouter' }, + { value: 'custom', label: 'Custom $/M tok', labelZh: '自定义 $/M tok' }, +]; + +const STRINGS = { + en: { + title: { + 'gw-year': 'Revenue & Profit Estimator per GigaWatt', + 'chip-hour': 'Revenue & Profit Estimator', + }, + costProviderLabel: 'Cost Provider', + costProviderTooltip: + 'The TCO tier used for the compute-expense segment: Hyperscaler (e.g. AWS/GCP), Neocloud (e.g. CoreWeave), or 3-year rental, in $/GPU/hr from the SemiAnalysis AI Cloud TCO Model. Custom lets you type your own $/GPU/hr per chip.', + customCostLabel: (gpu: string) => `${gpu} $/GPU/hr`, + costProviderPlaceholder: 'Cost provider', + priceSourceLabel: 'Token Price', + priceSourceTooltip: + 'Where the sale price per million tokens comes from. OpenRouter reads the public catalog price for this model; Custom lets you type your own input and output prices.', + priceSourcePlaceholder: 'Token price', + inputPriceLabel: 'Input $/M tok', + outputPriceLabel: 'Output $/M tok', + cachedPriceLabel: 'Cached input $/M tok', + targetAgenticLabel: (percentile: string) => `Target ${percentile} Interactivity (tok/s/user)`, + targetAgenticTooltip: (percentile: string) => + `The ${percentile} interactivity operating point used for agentic workload interpolation.`, + utilizationLabel: 'Utilization (%)', + utilizationTooltip: + 'Share of benchmarked throughput that is actually sold. 60% means the fleet bills 60% of the tokens it could produce. Revenue scales with it; compute expense does not, since the chips are paid for whether or not they are busy.', + labCutLabel: 'Model License Fee (%)', + labCutTooltip: + 'Share of revenue paid to the model lab as a license fee on every token sold. It is owed even when compute alone exceeds revenue, so the operator can show a loss.', + errorLoading: 'Error loading data. Please try a different selection.', + resetFilter: 'Reset filter', + pricingLoading: 'Loading OpenRouter pricing…', + pricingUnavailable: (modelId: string | null) => + modelId + ? `OpenRouter has no price for ${modelId}. Switch Token Price to Custom to enter one.` + : 'This model has no OpenRouter listing. Switch Token Price to Custom to enter a price.', + chartTitle: { + 'gw-year': (model: string, workload: string, percentile: string, target: number) => + `${model} ${workload} Revenue & Profit Estimates per GigaWatt Per Year at ${percentile} ${target} tok/s/user Interactivity`, + 'chip-hour': (model: string, workload: string, percentile: string, target: number) => + `${model} ${workload} Revenue & Profit Estimates per Chip per Hour at ${percentile} ${target} tok/s/user Interactivity`, + }, + sellingPriceLabel: 'Selling Price per Million Tokens', + sellingPrices: (input: string, cached: string, output: string, source: string) => + `Input: $${input} · Cached Input: $${cached} · Output: $${output} (${source})`, + tcoBadgesLabel: 'TCO $/chip/hr:', + sourceLabel: 'Source:', + formulaTitle: { + 'gw-year': 'Revenue per GigaWatt Formula', + 'chip-hour': 'Revenue per Chip-Hour Formula', + }, + formulaToggle: 'Toggle formula notes', + captionFormula: { + 'gw-year': (util: number, labCut: number) => + `Revenue = $/GPU/hr × GPU-hours per GW-year × ${util}% utilization. GPU-hours = (1,000,000 kW ÷ all-in kW per GPU) × 8,760 h. Model license fee = ${labCut}% of revenue. Profit = revenue − TCO − license fee. Margin above each bar is profit ÷ revenue.`, + 'chip-hour': (util: number, labCut: number) => + `Revenue = $/GPU/hr × ${util}% utilization, where $/GPU/hr = benchmarked tok/s per chip at the target interactivity × the selling price per token. Compute expense = TCO $/chip/hr for the chosen cost tier. Model license fee = ${labCut}% of revenue. Profit = revenue − TCO − license fee. Margin above each bar is profit ÷ revenue.`, + }, + csvHeaders: { + 'gw-year': [ + 'SKU', + 'Precision', + 'Revenue ($/GW/yr)', + 'Compute expense TCO ($/GW/yr)', + 'Gross margin ($/GW/yr)', + 'Model license fee ($/GW/yr)', + 'Profit ($/GW/yr)', + 'Margin', + 'Revenue ($/GPU/hr, 100% util)', + 'GPU-hours per GW-year', + ], + 'chip-hour': [ + 'SKU', + 'Precision', + 'Revenue ($/chip/hr)', + 'Compute expense TCO ($/chip/hr)', + 'Gross margin ($/chip/hr)', + 'Model license fee ($/chip/hr)', + 'Profit ($/chip/hr)', + 'Margin', + 'Revenue ($/GPU/hr, 100% util)', + ], + }, + skipped: (entries: string) => `Not priced: ${entries}.`, + skipReason: { + 'outside-measured-range': 'no measured point at the target interactivity', + 'no-power': 'no all-in power figure', + 'no-cost': 'no TCO for this tier', + 'no-token-mix': 'no input/output token mix recorded', + } satisfies Record, + }, + zh: { + title: { + 'gw-year': '每吉瓦收入与利润估算器', + 'chip-hour': '收入与利润估算器', + }, + costProviderLabel: '成本供应商', + costProviderTooltip: + '算力支出分段采用的 TCO 层级:Hyperscaler(如 AWS/GCP)、Neocloud(如 CoreWeave)或 3 年租赁,单位为 $/GPU/hr,来自 SemiAnalysis AI Cloud TCO 模型。选择自定义可为每种芯片输入自己的 $/GPU/hr。', + customCostLabel: (gpu: string) => `${gpu} $/GPU/hr`, + costProviderPlaceholder: '成本供应商', + priceSourceLabel: 'Token 售价', + priceSourceTooltip: + '每百万 token 售价的来源。OpenRouter 读取该模型的公开目录价格;自定义则可自行输入输入/输出价格。', + priceSourcePlaceholder: 'Token 售价', + inputPriceLabel: '输入 $/M tok', + outputPriceLabel: '输出 $/M tok', + cachedPriceLabel: '缓存输入 $/M tok', + targetAgenticLabel: (percentile: string) => `目标 ${percentile} 交互性 (tok/s/user)`, + targetAgenticTooltip: (percentile: string) => + `用于智能体工作负载插值的 ${percentile} 交互性操作点。`, + utilizationLabel: '利用率 (%)', + utilizationTooltip: + '实际售出的基准吞吐量比例。60% 表示集群只计费其可产出 token 的 60%。收入随之缩放;算力支出不变,因为芯片无论忙闲都要付费。', + labCutLabel: '模型许可费(%)', + labCutTooltip: + '每售出一个 token 以许可费形式支付给模型实验室的收入比例。即使算力支出已超过收入也需支付,因此运营方可能亏损。', + errorLoading: '加载数据出错,请尝试其他选择。', + resetFilter: '重置筛选', + pricingLoading: '正在加载 OpenRouter 价格…', + pricingUnavailable: (modelId: string | null) => + modelId + ? `OpenRouter 没有 ${modelId} 的价格。请将 Token 售价切换为自定义并输入价格。` + : '该模型没有 OpenRouter 条目。请将 Token 售价切换为自定义并输入价格。', + chartTitle: { + 'gw-year': (model: string, workload: string, percentile: string, target: number) => + `${model} ${workload} 每吉瓦每年收入与利润估算(${percentile} 交互性 ${target} tok/s/user)`, + 'chip-hour': (model: string, workload: string, percentile: string, target: number) => + `${model} ${workload} 每芯片每小时收入与利润估算(${percentile} 交互性 ${target} tok/s/user)`, + }, + sellingPriceLabel: '每百万 token 售价', + sellingPrices: (input: string, cached: string, output: string, source: string) => + `输入:$${input} · 缓存输入:$${cached} · 输出:$${output}(${source})`, + tcoBadgesLabel: 'TCO $/chip/hr:', + sourceLabel: '来源:', + formulaTitle: { + 'gw-year': '每吉瓦收入公式', + 'chip-hour': '每芯片小时收入公式', + }, + formulaToggle: '展开或收起公式说明', + captionFormula: { + 'gw-year': (util: number, labCut: number) => + `收入 = $/GPU/hr × 每吉瓦年 GPU 小时数 × ${util}% 利用率。GPU 小时数 = (1,000,000 kW ÷ 每 GPU 全电源配置 kW) × 8,760 h。模型许可费 = 收入的 ${labCut}%。利润 = 收入 − TCO − 许可费。柱形上方的利润率 = 利润 ÷ 收入。`, + 'chip-hour': (util: number, labCut: number) => + `收入 = $/GPU/hr × ${util}% 利用率,其中 $/GPU/hr = 目标交互性下实测的每芯片 tok/s × 每 token 售价。算力支出 = 所选成本层级的 TCO $/chip/hr。模型许可费 = 收入的 ${labCut}%。利润 = 收入 − TCO − 许可费。柱形上方的利润率 = 利润 ÷ 收入。`, + }, + csvHeaders: { + 'gw-year': [ + 'SKU', + '精度', + '收入($/GW/yr)', + '算力支出 TCO($/GW/yr)', + '毛利($/GW/yr)', + '模型许可费($/GW/yr)', + '利润($/GW/yr)', + '利润率', + '收入($/GPU/hr,100% 利用率)', + '每吉瓦年 GPU 小时数', + ], + 'chip-hour': [ + 'SKU', + '精度', + '收入($/chip/hr)', + '算力支出 TCO($/chip/hr)', + '毛利($/chip/hr)', + '模型许可费($/chip/hr)', + '利润($/chip/hr)', + '利润率', + '收入($/GPU/hr,100% 利用率)', + ], + }, + skipped: (entries: string) => `未定价:${entries}。`, + skipReason: { + 'outside-measured-range': '未在该交互性下实测', + 'no-power': '缺少全电源配置功率数据', + 'no-cost': '该层级无 TCO 数据', + 'no-token-mix': '未记录输入/输出 token 比例', + } satisfies Record, + }, +} as const; + +/** A bordered note under the chart that folds behind its title, like the metric notes on /inference. */ +function InfoFold({ + title, + toggleLabel, + children, + testId, +}: { + title: string; + toggleLabel: string; + children: React.ReactNode; + testId: string; +}) { + // Collapsed by default; the formula is reference material, not the result. + const [open, setOpen] = useState(false); + return ( +
+ + {open &&
{children}
} +
+ ); +} + +/** Number inputs step on mouse wheel while focused; drop focus so a scroll over the box only scrolls the page. */ +function blurOnWheel(event: React.WheelEvent): void { + event.currentTarget.blur(); +} + +/** Dashboard tab each basis is served from; drives the model allow-list and the URL rewrite. */ +export const PROFIT_BASIS_TAB: Record = { + 'chip-hour': 'profit-estimator', + 'gw-year': 'profit-estimator-per-gigawatt', +}; + +export default function ProfitEstimatorDisplay({ + urlSeed, + basis, +}: { + urlSeed?: CalculatorUrlSeed; + /** `/profit-estimator` is per chip-hour; `/profit-estimator-per-gigawatt` scales to a GW-year. */ + basis: ProfitBasis; +}) { + return ( + + + + ); +} + +/** A percentage field: the raw string the user is typing plus the clamped number in use. */ +function usePercentField(defaultValue: number, eventName: string) { + const [raw, setRaw] = useState(String(defaultValue)); + const [value, setValue] = useState(defaultValue); + const onChange = useCallback( + (next: string) => { + setRaw(next); + const parsed = Number.parseFloat(next); + if (Number.isFinite(parsed)) setValue(clampPercent(parsed, defaultValue)); + }, + [defaultValue], + ); + const onBlur = useCallback(() => { + const parsed = Number.parseFloat(raw); + const clamped = Number.isFinite(parsed) ? clampPercent(parsed, defaultValue) : defaultValue; + setValue(clamped); + setRaw(String(clamped)); + track(eventName, { value: clamped }); + }, [raw, defaultValue, eventName]); + return { raw, value, onChange, onBlur }; +} + +function ProfitEstimatorInner({ + initialPercentile, + basis, +}: { + initialPercentile: Percentile; + basis: ProfitBasis; +}) { + const locale = useLocale(); + const t = STRINGS[locale]; + const chartStrings = profitEstimatorChartStrings(locale); + const { setUrlParam } = useUrlState(); + const { openDropdown, handleDropdownOpenChange } = useOpenDropdown(); + + // Precision is not a control here: `effectivePrecisions` stays in auto mode, + // which resolves to the densest measured precision per model, so the bars + // always reflect the best-covered run set. + const { + selectedModel, + effectiveSequence, + sequenceResolved, + effectivePrecisions: selectedPrecisions, + } = useGlobalFilterSelection(); + const { setSelectedModel, setSelectedSequence } = useGlobalFilterActions(); + const { selectedRunDate } = useGlobalFilterRun(); + const { availableModels, availabilityRows } = useGlobalFilterAvailability(); + // This page is agentic only: the sequence is pinned, and the model list is + // the tab's route allow-list (Kimi K3 for now) intersected with the models + // that have an agentic-traces run, so the selector never offers a model + // that would draw an empty chart. If the intersection is still loading or + // empty, the allow-list alone is offered so the selector is never blank. + const selectedSequence = Sequence.AgenticTraces; + const agenticModels = useMemo(() => { + const allowed = modelRoutesForTab(PROFIT_BASIS_TAB[basis]).map((route) => route.model); + const withData = modelsWithAgenticData( + availableModels, + availabilityRows, + (m) => DISPLAY_MODEL_TO_DB[m] ?? [m], + ); + const both = allowed.filter((m) => withData.includes(m)); + return both.length > 0 ? both : allowed; + }, [availableModels, availabilityRows, basis]); + const modelAllowed = agenticModels.includes(selectedModel); + useEffect(() => { + if (!modelAllowed && agenticModels[0]) setSelectedModel(agenticModels[0]); + }, [modelAllowed, agenticModels, setSelectedModel]); + useEffect(() => { + if (sequenceResolved && effectiveSequence !== Sequence.AgenticTraces) { + setSelectedSequence(Sequence.AgenticTraces); + } + }, [sequenceResolved, effectiveSequence, setSelectedSequence]); + const mode = 'interactivity_to_throughput' as const; + + const [costProvider, setCostProvider] = useState('costh'); + const [priceSource, setPriceSource] = useState('openrouter'); + const [customInputPrice, setCustomInputPrice] = useState('1'); + const [customCachedPrice, setCustomCachedPrice] = useState('0.1'); + const [customOutputPrice, setCustomOutputPrice] = useState('1'); + const [targetValue, setTargetValue] = useState(DEFAULT_PROFIT_INTERACTIVITY); + const [targetRaw, setTargetRaw] = useState(String(DEFAULT_PROFIT_INTERACTIVITY)); + const [selectedPercentile, setSelectedPercentile] = useState(initialPercentile); + const [visibilityIntent, setVisibilityIntent] = useState(null); + const utilization = usePercentField(DEFAULT_UTILIZATION_PCT, 'profit_utilization_set'); + const labCut = usePercentField(DEFAULT_LAB_CUT_PCT, 'profit_lab_cut_set'); + + const { hardwareConfig, getResults, loading, error, hasData, availableHwKeys } = + useThroughputData( + selectedModel, + selectedSequence, + selectedPrecisions, + selectedRunDate, + undefined, + selectedPercentile, + undefined, + true, + 'total', + ); + + // Per-base-GPU $/GPU/hr typed by the reader; seeded from the hyperscaler + // tier the first time each chip appears so the custom view starts identical + // to the default one. + const [customCosts, setCustomCosts] = useState>({}); + const customCostBases = useMemo( + () => + [...new Set(availableHwKeys.map(baseGpuOf))] + .filter((base) => base in HW_REGISTRY) + .toSorted((a, b) => getModelSortIndex(a) - getModelSortIndex(b) || a.localeCompare(b)), + [availableHwKeys], + ); + useEffect(() => { + setCustomCosts((prev) => { + let changed = false; + const next = { ...prev }; + for (const base of customCostBases) { + if (next[base] === undefined) { + next[base] = String(getGpuSpecs(base)[CUSTOM_COST_SEED]); + changed = true; + } + } + return changed ? next : prev; + }); + }, [customCostBases]); + const costPerGpuHourFor = useCallback( + (hwKey: string): number => { + const base = baseGpuOf(hwKey); + if (costProvider === 'custom') return parseCustomCostInput(customCosts[base]) ?? 0; + return getGpuSpecs(base)[costProvider]; + }, + [costProvider, customCosts], + ); + const interpolationCostProvider: CostProvider = + costProvider === 'custom' ? CUSTOM_COST_SEED : costProvider; + + const featureGateUnlocked = useFeatureGate(); + const percentileLabel = selectedPercentile.toUpperCase(); + + const openRouterModelId = getOpenRouterModelId(selectedModel); + const openRouterQuery = useOpenRouterPricing(openRouterModelId, priceSource === 'openrouter'); + + const pricing = useMemo(() => { + if (priceSource === 'custom') { + const input = parseTokenPriceInput(customInputPrice); + const cached = parseTokenPriceInput(customCachedPrice); + const output = parseTokenPriceInput(customOutputPrice); + if (input === null || cached === null || output === null) return null; + return { + source: 'normalized', + inputPerMillion: input, + cachedInputPerMillion: cached, + outputPerMillion: output, + }; + } + return openRouterQuery.data ?? null; + }, [priceSource, customInputPrice, customCachedPrice, customOutputPrice, openRouterQuery.data]); + + const assumptions = useMemo( + () => ({ utilizationPct: utilization.value, labCutPct: labCut.value, basis }), + [utilization.value, labCut.value, basis], + ); + + // Price every SKU first, then build the legend from the ones that produced a + // bar. A config the target falls outside of (or that cannot be priced) is + // named in the caption instead of being offered as a legend chip. + const fullEstimate = useMemo(() => { + if (!hasData || !pricing) return { rows: [], skipped: [] }; + const results = getResults(targetValue, mode, interpolationCostProvider); + return estimateProfitRows( + results, + (hwKey) => ({ + powerKwPerGpu: getGpuSpecs(hwKey).power, + costPerGpuHour: costPerGpuHourFor(hwKey), + }), + pricing, + assumptions, + ); + }, [ + hasData, + pricing, + getResults, + targetValue, + mode, + interpolationCostProvider, + costPerGpuHourFor, + assumptions, + ]); + + const legendHwKeys = useMemo(() => { + const priced = new Set(fullEstimate.rows.map((row) => row.hwKey)); + return availableHwKeys.filter((key) => priced.has(key)); + }, [fullEstimate.rows, availableHwKeys]); + + const selectionKey = `${selectedModel}|${selectedSequence}|${[...selectedPrecisions] + .toSorted() + .join(',')}|${selectedRunDate}|${[...legendHwKeys].toSorted().join(',')}`; + + const visibleHwKeys = useMemo( + () => resolveCalculatorVisibility(visibilityIntent, selectionKey, legendHwKeys), + [visibilityIntent, selectionKey, legendHwKeys], + ); + const visibleKeysArray = useMemo(() => [...visibleHwKeys], [visibleHwKeys]); + const { resolveColor } = useThemeColors({ highContrast: false, activeKeys: visibleKeysArray }); + + const estimate = useMemo( + () => ({ + rows: fullEstimate.rows.filter((row) => visibleHwKeys.has(row.hwKey)), + skipped: fullEstimate.skipped, + }), + [fullEstimate, visibleHwKeys], + ); + + const handleTargetChange = useCallback((e: React.ChangeEvent) => { + setTargetRaw(e.target.value); + const parsed = Number.parseFloat(e.target.value); + if (Number.isFinite(parsed) && parsed > 0) setTargetValue(parsed); + }, []); + + const handleTargetBlur = useCallback(() => { + const parsed = Number.parseFloat(targetRaw); + const next = Number.isFinite(parsed) && parsed > 0 ? parsed : targetValue; + setTargetValue(next); + setTargetRaw(String(next)); + track('profit_target_set', { mode, value: next }); + }, [targetRaw, targetValue, mode]); + + const handleModelChange = useCallback( + (value: string) => { + setVisibilityIntent(null); + setSelectedModel(value as Model); + track('profit_model_selected', { model: value }); + }, + [setSelectedModel], + ); + + const handlePercentileChange = useCallback( + (value: Percentile) => { + setSelectedPercentile(value); + setUrlParam('i_pctl', value); + track('profit_percentile_selected', { percentile: value }); + }, + [setUrlParam], + ); + + const toggleGpuVisibility = useCallback( + (hwKey: string) => { + const visibleLegendKeys = legendHwKeys.filter((key) => visibleHwKeys.has(key)); + const allVisible = visibleLegendKeys.length === legendHwKeys.length; + const isVisible = visibleHwKeys.has(hwKey); + let next: Set; + if (isVisible && allVisible) { + next = new Set([hwKey]); + } else if (isVisible && visibleLegendKeys.length === 1) { + next = new Set(legendHwKeys); + } else { + next = new Set(visibleHwKeys); + if (isVisible) next.delete(hwKey); + else next.add(hwKey); + } + setVisibilityIntent({ + scopeKey: selectionKey, + visible: next, + known: new Set(legendHwKeys), + }); + track('profit_gpu_toggled', { gpu: hwKey }); + }, + [legendHwKeys, visibleHwKeys, selectionKey], + ); + + const handleResetGpus = useCallback(() => { + setVisibilityIntent({ + scopeKey: selectionKey, + visible: new Set(legendHwKeys), + known: new Set(legendHwKeys), + }); + track('profit_gpu_reset', { gpuCount: legendHwKeys.length }); + }, [legendHwKeys, selectionKey]); + + const legendItems = useMemo( + () => + Object.entries(hardwareConfig) + .filter(([key]) => legendHwKeys.includes(key)) + .toSorted(([a], [b]) => getModelSortIndex(a) - getModelSortIndex(b) || a.localeCompare(b)) + .map(([key, config]) => ({ + name: config.name, + label: getDisplayLabel(config), + color: resolveColor(key), + title: config.gpu, + hw: key, + isActive: visibleHwKeys.has(key), + onClick: () => toggleGpuVisibility(key), + })), + [hardwareConfig, legendHwKeys, visibleHwKeys, resolveColor, toggleGpuVisibility], + ); + + const pricingNotice = useMemo(() => { + if (priceSource !== 'openrouter') return null; + // A model with no OpenRouter listing never starts the query, and TanStack v5 + // leaves a disabled query `isPending`, so check the id before the fetch state. + if (openRouterModelId === null) return t.pricingUnavailable(null); + if (openRouterQuery.isLoading) return t.pricingLoading; + if (!openRouterQuery.data) return t.pricingUnavailable(openRouterModelId); + return null; + }, [priceSource, openRouterQuery.isLoading, openRouterQuery.data, openRouterModelId, t]); + + const costTier = costTierLabel(COST_PROVIDER_TIER[costProvider], locale); + const priceSourceLabel = + pricing?.source === 'openrouter' ? 'OpenRouter' : locale === 'zh' ? '自定义' : 'custom'; + + // Only the SKUs the legend currently shows; hiding a bar drops its badge. + const tcoBadges = useMemo(() => { + const bases = new Set(); + for (const key of legendHwKeys) { + if (!visibleHwKeys.has(key)) continue; + const base = key.split('_')[0]; + if (base in HW_REGISTRY) bases.add(base); + } + return [...bases] + .toSorted((a, b) => getModelSortIndex(a) - getModelSortIndex(b) || a.localeCompare(b)) + .map((base) => ({ + base, + label: HW_REGISTRY[base]?.badgeLabel ?? base.toUpperCase(), + cost: costPerGpuHourFor(base), + })); + }, [legendHwKeys, visibleHwKeys, costPerGpuHourFor]); + + // Rendered as the chart's figcaption so it is part of the PNG export. + const caption = useMemo(() => { + if (!pricing) return null; + // Right padding keeps the title and price lines clear of the export + // button, which sits in the figure corner. + return ( +
+ + + {t.chartTitle[basis]( + getModelLabel(selectedModel), + getSequenceLabel(Sequence.AgenticTraces, locale), + percentileLabel, + targetValue, + )} + + +

+ {t.tcoBadgesLabel}{' '} + {tcoBadges.map((badge) => ( + + {badge.label}: {badge.cost} + + ))} +

+ {costProvider !== 'custom' && ( +

+ + {t.sourceLabel}{' '} + + {TCO_SOURCE_TITLE} + + + +

+ )} +

+ {t.sellingPriceLabel}:{' '} + {t.sellingPrices( + formatTokenPrice(pricing.inputPerMillion), + formatTokenPrice(cachedInputPricePerMillion(pricing)), + formatTokenPrice(pricing.outputPerMillion), + priceSourceLabel, + )} +

+
+ ); + }, [ + pricing, + selectedModel, + locale, + percentileLabel, + targetValue, + costTier, + costProvider, + assumptions.utilizationPct, + assumptions.labCutPct, + basis, + selectedRunDate, + tcoBadges, + priceSourceLabel, + t, + ]); + + const exportFileName = + basis === 'gw-year' + ? `InferenceX_profit_estimator_per_gigawatt_${selectedModel}` + : `InferenceX_profit_estimator_${selectedModel}`; + + const handleExportCsv = useCallback(() => { + // Whole dollars are plenty per GW-year; per chip-hour the cents are the figure. + const usd = (value: number) => (basis === 'gw-year' ? Math.round(value) : value.toFixed(4)); + const rows = estimate.rows.map((row) => [ + rowLabel(row, hardwareConfig), + row.precision?.toUpperCase() ?? '', + usd(row.revenue), + usd(row.tco), + usd(row.grossMargin), + usd(row.labCut), + usd(row.profit), + row.revenue > 0 ? (row.profit / row.revenue).toFixed(4) : '', + row.revenuePerGpuHour.toFixed(4), + // GPU-hours is 1 per chip-hour, so that basis has no column for it. + ...(basis === 'gw-year' ? [Math.round(row.gpuHours)] : []), + ]); + exportToCsv(exportFileName, [...t.csvHeaders[basis]], rows, [ + t.captionFormula[basis](assumptions.utilizationPct, assumptions.labCutPct), + ]); + }, [estimate.rows, hardwareConfig, exportFileName, t, assumptions, basis]); + + if (!loading && error) { + console.error(error); + return ( + +
+ {t.errorLoading} +
+
+ ); + } + + return ( +
+
+ +
+ } /> + + +
+
+ +
+ {featureGateUnlocked && ( + + )} +
+ +
+ ({ + value: provider.value, + label: locale === 'zh' ? provider.labelZh : provider.label, + }))} + value={[costProvider]} + onChange={(values) => { + const next = values[0]; + if (!next) return; + setCostProvider(next as ProfitCostProvider); + track('profit_cost_provider_changed', { provider: next }); + }} + open={openDropdown === 'costProvider'} + onOpenChange={handleDropdownOpenChange('costProvider')} + placeholder={t.costProviderPlaceholder} + minSelections={1} + maxSelections={1} + showClearAll={false} + searchable={false} + plainSelectedText + showSelectionSummary={false} + /> +
+
+
+ +
+
+ + +
+
+ +
+ ({ + value: option.value, + label: locale === 'zh' ? option.labelZh : option.label, + }))} + value={[priceSource]} + onChange={(values) => { + const next = values[0]; + if (!next) return; + // Seed the custom fields from the live catalog so switching + // over starts from a real price instead of $1/M. + if (next === 'custom' && openRouterQuery.data) { + setCustomInputPrice( + formatTokenPrice(openRouterQuery.data.inputPerMillion), + ); + setCustomCachedPrice( + formatTokenPrice(cachedInputPricePerMillion(openRouterQuery.data)), + ); + setCustomOutputPrice( + formatTokenPrice(openRouterQuery.data.outputPerMillion), + ); + } + setPriceSource(next as PriceSource); + track('profit_price_source_changed', { source: next }); + }} + open={openDropdown === 'priceSource'} + onOpenChange={handleDropdownOpenChange('priceSource')} + placeholder={t.priceSourcePlaceholder} + minSelections={1} + maxSelections={1} + showClearAll={false} + searchable={false} + plainSelectedText + showSelectionSummary={false} + /> +
+
+
+ + utilization.onChange(e.target.value)} + onBlur={utilization.onBlur} + /> +
+
+ + labCut.onChange(e.target.value)} + onBlur={labCut.onBlur} + /> +
+
+ + {/* Custom token prices get their own row so the main controls keep their width. */} + {priceSource === 'custom' && ( +
+
+ + setCustomInputPrice(e.target.value)} + onBlur={() => + track('profit_custom_price_set', { + stream: 'input', + value: customInputPrice, + }) + } + /> +
+
+ + setCustomCachedPrice(e.target.value)} + onBlur={() => + track('profit_custom_price_set', { + stream: 'cached', + value: customCachedPrice, + }) + } + /> +
+
+ + setCustomOutputPrice(e.target.value)} + onBlur={() => + track('profit_custom_price_set', { + stream: 'output', + value: customOutputPrice, + }) + } + /> +
+
+ )} + + {costProvider === 'custom' && customCostBases.length > 0 && ( +
+ {customCostBases.map((base) => { + const label = HW_REGISTRY[base]?.badgeLabel ?? base.toUpperCase(); + return ( +
+ + + setCustomCosts((prev) => ({ ...prev, [base]: e.target.value })) + } + onBlur={() => + track('profit_custom_cost_set', { gpu: base, value: customCosts[base] }) + } + /> +
+ ); + })} +
+ )} +
+ + {!loading && legendItems.length > 0 && ( +
+
    + {legendItems.map((item) => ( + + ))} +
+ {visibleHwKeys.size < legendHwKeys.length && ( + + )} +
+ )} +
+
+
+ + {loading && ( + + + + )} + + {!loading && hasData && ( + + {pricingNotice && ( +
+ {pricingNotice} +
+ )} + {pricing ? ( +
+ + +
+ + {t.captionFormula[basis](assumptions.utilizationPct, assumptions.labCutPct)} + +
+
+ ) : ( + !pricingNotice && ( +
+ {chartStrings.noData} +
+ ) + )} +
+ )} +
+ ); +} diff --git a/packages/app/src/components/calculator/profit-estimator.test.ts b/packages/app/src/components/calculator/profit-estimator.test.ts new file mode 100644 index 000000000..3022531b5 --- /dev/null +++ b/packages/app/src/components/calculator/profit-estimator.test.ts @@ -0,0 +1,326 @@ +import { describe, expect, it } from 'vitest'; + +import type { TokenRevenuePricing } from '@/components/inference/types'; + +import { + clampPercent, + estimateProfitRows, + estimateSkuProfit, + formatProfitUsd, + formatUsdCompact, + formatUsdPerChipHour, + gpuHoursPerGwYear, + HOURS_PER_YEAR, + isProfitEstimatorRow, + modelsWithAgenticData, + parseTokenPriceInput, + type ProfitEstimatorRow, +} from './profit-estimator'; + +/** $1/M tok on both streams: revenue collapses to total throughput. */ +const FLAT_PRICING: TokenRevenuePricing = { + source: 'normalized', + inputPerMillion: 1, + cachedInputPerMillion: 0.1, + outputPerMillion: 1, +}; + +/** Asymmetric prices need the measured input share to split the streams. */ +const SPLIT_PRICING: TokenRevenuePricing = { + source: 'openrouter', + inputPerMillion: 0.5, + cachedInputPerMillion: 0.05, + outputPerMillion: 2, + openRouterModelId: 'test/model', +}; + +// B200 figures from HW_REGISTRY: 1.71 kW all-in, $1.73/GPU/hr hyperscaler. +const B200 = { powerKwPerGpu: 1.71, costPerGpuHour: 1.73 }; +const B200_GPU_HOURS = (1_000_000 / 1.71) * HOURS_PER_YEAR; + +const result = { + hwKey: 'b200', + resultKey: 'b200', + value: 1_000, // total tok/s/GPU -> $3.60/GPU/hr at $1/M flat + inputTokenShare: 0.8, + cacheHitRate: undefined, +}; + +function row(value: ReturnType): ProfitEstimatorRow { + if (!isProfitEstimatorRow(value)) throw new Error(`expected a row, got skip: ${value.reason}`); + return value; +} + +describe('gpuHoursPerGwYear', () => { + it('converts all-in kW per GPU into GPU-hours per GW-year', () => { + expect(gpuHoursPerGwYear(1.71)).toBeCloseTo(B200_GPU_HOURS, 3); + expect(gpuHoursPerGwYear(1)).toBe(1_000_000 * 8_760); + }); + + it('is null for a missing or zero power figure', () => { + expect(gpuHoursPerGwYear(0)).toBeNull(); + expect(gpuHoursPerGwYear(Number.NaN)).toBeNull(); + expect(gpuHoursPerGwYear(-1)).toBeNull(); + }); +}); + +describe('clampPercent', () => { + it('clamps to [0, 100] and falls back on garbage', () => { + expect(clampPercent(150, 60)).toBe(100); + expect(clampPercent(-5, 60)).toBe(0); + expect(clampPercent(42.5, 60)).toBe(42.5); + expect(clampPercent(Number.NaN, 60)).toBe(60); + }); +}); + +describe('estimateSkuProfit', () => { + it('stacks TCO, license fee and profit back up to revenue at 100% utilization', () => { + const r = row( + estimateSkuProfit(result, B200, FLAT_PRICING, { + utilizationPct: 100, + labCutPct: 30, + basis: 'gw-year', + }), + ); + expect(r.revenuePerGpuHour).toBeCloseTo(3.6, 9); + expect(r.revenue).toBeCloseTo(3.6 * B200_GPU_HOURS, 0); + expect(r.tco).toBeCloseTo(1.73 * B200_GPU_HOURS, 0); + expect(r.grossMargin).toBeCloseTo((3.6 - 1.73) * B200_GPU_HOURS, 0); + expect(r.labCut).toBeCloseTo(0.3 * r.revenue, 3); + expect(r.profit).toBeCloseTo(r.revenue - r.tco - r.labCut, 3); + // The three segments are exactly the revenue bar. + expect(r.tco + r.labCut + r.profit).toBeCloseTo(r.revenue, 3); + }); + + it('scales revenue by utilization and leaves TCO alone', () => { + const full = row( + estimateSkuProfit(result, B200, FLAT_PRICING, { + utilizationPct: 100, + labCutPct: 30, + basis: 'gw-year', + }), + ); + const partial = row( + estimateSkuProfit(result, B200, FLAT_PRICING, { + utilizationPct: 60, + labCutPct: 30, + basis: 'gw-year', + }), + ); + expect(partial.revenue).toBeCloseTo(full.revenue * 0.6, 3); + expect(partial.tco).toBeCloseTo(full.tco, 9); + expect(partial.revenuePerGpuHour).toBe(full.revenuePerGpuHour); + expect(partial.grossMargin).toBeCloseTo(partial.revenue - partial.tco, 3); + }); + + it('takes the license fee from revenue, not from gross margin', () => { + const r = row( + estimateSkuProfit(result, B200, FLAT_PRICING, { + utilizationPct: 60, + labCutPct: 30, + basis: 'gw-year', + }), + ); + expect(r.labCut).toBeCloseTo(0.3 * r.revenue, 3); + expect(r.labCut).not.toBeCloseTo(0.3 * (r.revenue - r.tco), 0); + }); + + it('still owes the license fee and reports a loss when compute exceeds revenue', () => { + const cheap: TokenRevenuePricing = { + ...FLAT_PRICING, + inputPerMillion: 0.2, + outputPerMillion: 0.2, + }; + const r = row( + estimateSkuProfit(result, B200, cheap, { + utilizationPct: 60, + labCutPct: 30, + basis: 'gw-year', + }), + ); + // 1000 tok/s * 3600 / 1e6 * $0.2 = $0.72/GPU/hr, then 60% -> well below $1.73. + expect(r.grossMargin).toBeLessThan(0); + expect(r.labCut).toBeCloseTo(0.3 * r.revenue, 3); + expect(r.profit).toBeCloseTo(r.grossMargin - r.labCut, 9); + expect(r.profit).toBeLessThan(r.grossMargin); + }); + + it('honours a zero license fee and clamps out-of-range percentages', () => { + const none = row( + estimateSkuProfit(result, B200, FLAT_PRICING, { + utilizationPct: 100, + labCutPct: 0, + basis: 'gw-year', + }), + ); + expect(none.labCut).toBe(0); + expect(none.profit).toBeCloseTo(none.grossMargin, 9); + + const over = row( + estimateSkuProfit(result, B200, FLAT_PRICING, { + utilizationPct: 250, + labCutPct: 130, + basis: 'gw-year', + }), + ); + expect(over.revenue).toBeCloseTo(3.6 * B200_GPU_HOURS, 0); + expect(over.labCut).toBeCloseTo(over.revenue, 3); + expect(over.profit).toBeCloseTo(-over.tco, 3); + }); + + it('prices input and output streams separately when the prices differ', () => { + const r = row( + estimateSkuProfit(result, B200, SPLIT_PRICING, { + utilizationPct: 100, + labCutPct: 0, + basis: 'gw-year', + }), + ); + // 800 input tok/s at $0.5/M + 200 output tok/s at $2/M, per hour. + const expected = ((800 * 0.5 + 200 * 2) * 3_600) / 1_000_000; + expect(r.revenuePerGpuHour).toBeCloseTo(expected, 9); + }); + + it('skips a SKU whose price schedule needs a token mix it does not have', () => { + const skip = estimateSkuProfit({ ...result, inputTokenShare: undefined }, B200, SPLIT_PRICING, { + utilizationPct: 60, + labCutPct: 30, + basis: 'gw-year', + }); + expect(isProfitEstimatorRow(skip)).toBe(false); + if (!isProfitEstimatorRow(skip)) expect(skip.reason).toBe('no-token-mix'); + }); + + it('skips a SKU with no power or no tier cost', () => { + const noPower = estimateSkuProfit( + result, + { powerKwPerGpu: 0, costPerGpuHour: 1 }, + FLAT_PRICING, + { + utilizationPct: 60, + labCutPct: 30, + basis: 'gw-year', + }, + ); + const noCost = estimateSkuProfit( + result, + { powerKwPerGpu: 1.5, costPerGpuHour: 0 }, + FLAT_PRICING, + { + utilizationPct: 60, + labCutPct: 30, + basis: 'gw-year', + }, + ); + expect(isProfitEstimatorRow(noPower) ? null : noPower.reason).toBe('no-power'); + expect(isProfitEstimatorRow(noCost) ? null : noCost.reason).toBe('no-cost'); + }); + + it('prices one chip-hour when the basis is chip-hour: GPU-hours = 1 and power is ignored', () => { + const r = row( + estimateSkuProfit(result, { powerKwPerGpu: 0, costPerGpuHour: 1.73 }, FLAT_PRICING, { + utilizationPct: 60, + labCutPct: 30, + basis: 'chip-hour', + }), + ); + expect(r.gpuHours).toBe(1); + expect(r.revenue).toBeCloseTo(3.6 * 0.6, 9); + expect(r.tco).toBeCloseTo(1.73, 9); + expect(r.labCut).toBeCloseTo(3.6 * 0.6 * 0.3, 9); + expect(r.profit).toBeCloseTo(3.6 * 0.6 - 1.73 - 3.6 * 0.6 * 0.3, 9); + }); + + it('skips a config the target falls outside of instead of pricing its edge point', () => { + const skipped = estimateSkuProfit({ ...result, clamped: true }, B200, FLAT_PRICING, { + utilizationPct: 60, + labCutPct: 30, + basis: 'gw-year', + }); + expect(isProfitEstimatorRow(skipped) ? null : skipped.reason).toBe('outside-measured-range'); + }); +}); + +describe('estimateProfitRows', () => { + it('splits priced rows from skipped SKUs and sorts by revenue descending', () => { + const specs: Record = { + b200: B200, + h200: { powerKwPerGpu: 1.37, costPerGpuHour: 1.22 }, + ghost: { powerKwPerGpu: 0, costPerGpuHour: 1 }, + }; + const { rows, skipped } = estimateProfitRows( + [ + { hwKey: 'h200', resultKey: 'h200', value: 400 }, + { hwKey: 'b200', resultKey: 'b200', value: 1_000 }, + { hwKey: 'ghost', resultKey: 'ghost', value: 5_000 }, + ], + (hwKey) => specs[hwKey]!, + FLAT_PRICING, + { utilizationPct: 60, labCutPct: 30, basis: 'gw-year' }, + ); + expect(rows.map((r) => r.resultKey)).toEqual(['b200', 'h200']); + expect(skipped).toEqual([ + { hwKey: 'ghost', resultKey: 'ghost', precision: undefined, reason: 'no-power' }, + ]); + }); +}); + +describe('formatProfitUsd', () => { + it('uses fixed cents per chip-hour and compact units per GW-year', () => { + expect(formatProfitUsd(2.3, 'chip-hour')).toBe('$2.30'); + expect(formatProfitUsd(-0.456, 'chip-hour')).toBe('-$0.46'); + expect(formatProfitUsd(2.3, 'chip-hour', 0)).toBe('$2.30'); + expect(formatProfitUsd(135.2e9, 'gw-year')).toBe('$135.2B'); + expect(formatProfitUsd(135.2e9, 'gw-year', 0)).toBe('$135B'); + expect(formatUsdPerChipHour(Number.NaN)).toBe('—'); + }); +}); + +describe('formatUsdCompact', () => { + it('picks a unit per magnitude and keeps the sign ahead of the currency', () => { + expect(formatUsdCompact(12_345_678_901)).toBe('$12.3B'); + expect(formatUsdCompact(-2_500_000_000)).toBe('-$2.5B'); + expect(formatUsdCompact(4_200_000)).toBe('$4.2M'); + expect(formatUsdCompact(950_000)).toBe('$950.0k'); + expect(formatUsdCompact(12.34)).toBe('$12.3'); + expect(formatUsdCompact(Number.NaN)).toBe('—'); + }); +}); + +const dbKeysFor = (model: string) => [model.toLowerCase()]; +const aliasedDbKeys = (model: string) => (model === 'Kimi' ? ['kimi-k3', 'deepseek-v4-pro'] : []); + +describe('modelsWithAgenticData', () => { + const rows = [ + { model: 'deepseek-v4-pro', benchmark_type: 'agentic_traces' }, + { model: 'deepseek-v4-pro', benchmark_type: 'fixed' }, + { model: 'llama-3.3-70b', benchmark_type: 'fixed' }, + ]; + + it('keeps only models with an agentic_traces row', () => { + expect(modelsWithAgenticData(['DeepSeek-V4-Pro', 'Llama-3.3-70B'], rows, dbKeysFor)).toEqual([ + 'DeepSeek-V4-Pro', + ]); + }); + + it('matches through any of a model’s DB keys', () => { + expect(modelsWithAgenticData(['Kimi'], rows, aliasedDbKeys)).toEqual(['Kimi']); + }); + + it('returns every model while availability has not loaded', () => { + expect(modelsWithAgenticData(['A', 'B'], undefined, () => [])).toEqual(['A', 'B']); + }); +}); + +describe('parseTokenPriceInput', () => { + it('accepts zero and positive decimals', () => { + expect(parseTokenPriceInput('0')).toBe(0); + expect(parseTokenPriceInput('0.066')).toBeCloseTo(0.066); + }); + + it('rejects empty, negative, and non-numeric input', () => { + expect(parseTokenPriceInput('')).toBeNull(); + expect(parseTokenPriceInput('-1')).toBeNull(); + expect(parseTokenPriceInput('abc')).toBeNull(); + }); +}); diff --git a/packages/app/src/components/calculator/profit-estimator.ts b/packages/app/src/components/calculator/profit-estimator.ts new file mode 100644 index 000000000..4b19930ff --- /dev/null +++ b/packages/app/src/components/calculator/profit-estimator.ts @@ -0,0 +1,253 @@ +/** + * Profit Estimator — economics of one hardware SKU at a chosen interactivity + * operating point, on one of two denominators (`ProfitBasis`): + * + * chip-hour one GPU for one hour (GPU-hours = 1), the raw $/GPU/hr view + * gw-year one all-in utility gigawatt-year, so SKUs with very different + * per-chip power draw compare on the same denominator: + * GPU-hours = (1,000,000 kW / all-in kW per GPU) x 8,760 h + * + * Revenue = $/GPU/hr sale revenue x GPU-hours x utilization + * Compute expense (TCO) = tier $/GPU/hr x GPU-hours + * Gross margin = Revenue - TCO + * Model license fee = Revenue x license fee share + * Profit = Revenue - TCO - license fee + * + * Utilization scales revenue only. The fleet is paid for whether or not it is + * busy, so TCO is unchanged; a 60% utilization simply sells 60% of the tokens + * the benchmark says the chips can produce. The license fee is a revenue share (a + * royalty on every token sold), so it is owed even when compute alone already + * eats the revenue; the operator's profit can therefore go negative. + */ + +import { tokenRevenueFromRatesPerGpuHour } from '@/components/inference/token-revenue'; +import type { TokenRevenuePricing } from '@/components/inference/types'; + +import type { InterpolatedResult } from './types'; + +/** Calendar hours in a year (365 x 24). */ +export const HOURS_PER_YEAR = 8_760; +const KW_PER_GW = 1_000_000; + +export const DEFAULT_PROFIT_INTERACTIVITY = 45; +/** Fraction of benchmarked throughput that is actually sold. */ +export const DEFAULT_UTILIZATION_PCT = 60; +/** Share of revenue paid to the model lab. */ +export const DEFAULT_LAB_CUT_PCT = 30; + +/** + * Denominator every figure is expressed in. `/profit-estimator` is per chip-hour; + * `/profit-estimator-per-gigawatt` scales by the GPU-hours one GW-year buys. + */ +export type ProfitBasis = 'chip-hour' | 'gw-year'; + +export interface ProfitEstimatorAssumptions { + /** 0–100. Revenue is scaled by this share; TCO is not. */ + utilizationPct: number; + /** 0–100. Share of revenue paid to the model lab. */ + labCutPct: number; + basis: ProfitBasis; +} + +export interface ProfitEstimatorRow { + hwKey: string; + resultKey: string; + precision?: string; + /** GPU-hours in the denominator: 1 per chip-hour, or what one GW-year buys for this SKU. */ + gpuHours: number; + /** Gross $/GPU/hr at 100% utilization, before any haircut. */ + revenuePerGpuHour: number; + /** $ per basis after utilization. */ + revenue: number; + /** $ per basis compute expense at the chosen cost tier. */ + tco: number; + /** $ per basis revenue minus TCO. Negative when the sale price does not cover compute. */ + grossMargin: number; + /** $ per basis paid to the model lab as a share of revenue. */ + labCut: number; + /** $ per basis left to the operator after compute and the license fee. */ + profit: number; +} + +/** + * Why a SKU has no bar. `outside-measured-range` is the common one: the + * config was never benchmarked at the target interactivity, so its nearest + * edge point would be a guess, not a read, and the fleet page excludes those + * for the same reason. + */ +export type ProfitEstimatorSkipReason = + | 'outside-measured-range' + | 'no-power' + | 'no-cost' + | 'no-token-mix'; + +export interface ProfitEstimatorSkipped { + hwKey: string; + resultKey: string; + precision?: string; + reason: ProfitEstimatorSkipReason; +} + +export interface ProfitEstimatorOutput { + rows: ProfitEstimatorRow[]; + skipped: ProfitEstimatorSkipped[]; +} + +/** Clamp a percentage input to [0, 100]; anything unparseable becomes the fallback. */ +export function clampPercent(value: number, fallback: number): number { + if (!Number.isFinite(value)) return fallback; + return Math.max(0, Math.min(100, value)); +} + +/** + * GPU-hours one all-in utility gigawatt buys in a year. Null when the SKU has + * no power figure so callers never divide by zero. + */ +export function gpuHoursPerGwYear(powerKwPerGpu: number): number | null { + if (!(powerKwPerGpu > 0)) return null; + return (KW_PER_GW / powerKwPerGpu) * HOURS_PER_YEAR; +} + +export interface ProfitEstimatorSpecs { + /** All-in kW per GPU (chip plus its share of node, network, cooling). */ + powerKwPerGpu: number; + /** Tier $/GPU/hr from the SemiAnalysis AI Cloud TCO Model. */ + costPerGpuHour: number; +} + +/** + * Economics of one SKU at one operating point. Returns a skip reason instead of + * a row when the SKU cannot be priced: the target sits outside the measured + * interactivity range, no power figure, no tier cost, or a price schedule that + * needs an input/output mix the benchmark did not record. + */ +export function estimateSkuProfit( + result: Pick< + InterpolatedResult, + 'hwKey' | 'resultKey' | 'precision' | 'value' | 'inputTokenShare' | 'cacheHitRate' | 'clamped' + >, + specs: ProfitEstimatorSpecs, + pricing: TokenRevenuePricing, + assumptions: ProfitEstimatorAssumptions, +): ProfitEstimatorRow | ProfitEstimatorSkipped { + const base = { hwKey: result.hwKey, resultKey: result.resultKey, precision: result.precision }; + if (result.clamped) return { ...base, reason: 'outside-measured-range' }; + // Per chip-hour the denominator is one GPU-hour, so power never enters. + const gpuHours = assumptions.basis === 'chip-hour' ? 1 : gpuHoursPerGwYear(specs.powerKwPerGpu); + if (gpuHours === null) return { ...base, reason: 'no-power' }; + if (!(specs.costPerGpuHour > 0)) return { ...base, reason: 'no-cost' }; + + const revenuePerGpuHour = tokenRevenueFromRatesPerGpuHour( + result.value, + result.inputTokenShare ?? null, + result.cacheHitRate ?? null, + pricing, + ); + if (revenuePerGpuHour === null) return { ...base, reason: 'no-token-mix' }; + + const utilization = clampPercent(assumptions.utilizationPct, DEFAULT_UTILIZATION_PCT) / 100; + const labShare = clampPercent(assumptions.labCutPct, DEFAULT_LAB_CUT_PCT) / 100; + + const revenue = revenuePerGpuHour * gpuHours * utilization; + const tco = specs.costPerGpuHour * gpuHours; + const grossMargin = revenue - tco; + const labCut = revenue * labShare; + const profit = grossMargin - labCut; + + return { + ...base, + gpuHours, + revenuePerGpuHour, + revenue, + tco, + grossMargin, + labCut, + profit, + }; +} + +export function isProfitEstimatorRow( + value: ProfitEstimatorRow | ProfitEstimatorSkipped, +): value is ProfitEstimatorRow { + return 'revenue' in value; +} + +/** + * Estimate every interpolated SKU, splitting priced rows from the ones that + * had to be skipped. Rows come back sorted by revenue descending so the chart + * reads left to right from the largest top line. + */ +export function estimateProfitRows( + results: readonly Parameters[0][], + specsFor: (hwKey: string) => ProfitEstimatorSpecs, + pricing: TokenRevenuePricing, + assumptions: ProfitEstimatorAssumptions, +): ProfitEstimatorOutput { + const rows: ProfitEstimatorRow[] = []; + const skipped: ProfitEstimatorSkipped[] = []; + for (const result of results) { + const estimate = estimateSkuProfit(result, specsFor(result.hwKey), pricing, assumptions); + if (isProfitEstimatorRow(estimate)) rows.push(estimate); + else skipped.push(estimate); + } + rows.sort((a, b) => b.revenue - a.revenue || a.resultKey.localeCompare(b.resultKey)); + return { rows, skipped }; +} + +/** + * Format a $/GW/yr figure. These run to tens of billions, so the unit is + * chosen per value and negatives keep their sign in front of the currency. + */ +export function formatUsdCompact(value: number, digits = 1): string { + if (!Number.isFinite(value)) return '—'; + const sign = value < 0 ? '-' : ''; + const abs = Math.abs(value); + if (abs >= 1e9) return `${sign}$${(abs / 1e9).toFixed(digits)}B`; + if (abs >= 1e6) return `${sign}$${(abs / 1e6).toFixed(digits)}M`; + if (abs >= 1e3) return `${sign}$${(abs / 1e3).toFixed(digits)}k`; + return `${sign}$${abs.toFixed(abs >= 100 ? 0 : digits)}`; +} + +/** Format a $/GPU/hr figure: cents matter, so a fixed two decimals with the sign in front. */ +export function formatUsdPerChipHour(value: number, digits = 2): string { + if (!Number.isFinite(value)) return '—'; + const sign = value < 0 ? '-' : ''; + return `${sign}$${Math.abs(value).toFixed(digits)}`; +} + +/** + * Formatter for the basis in use. `digits` is the compact-unit precision on + * the GW-year basis (0 for axis ticks, 1 for labels) and is ignored per + * chip-hour, where two decimals always read right. + */ +export function formatProfitUsd(value: number, basis: ProfitBasis, digits?: number): string { + return basis === 'chip-hour' ? formatUsdPerChipHour(value) : formatUsdCompact(value, digits); +} + +/** + * Models that have AgentX (agentic-trace) rows in the availability table, in + * the order of `models`. The estimator only prices agentic workloads: fixed + * ISL/OSL scenarios have no cache-hit telemetry and their token mix is + * synthetic, so a $/GW-year figure built on them would not describe a real + * serving fleet. `dbKeysFor` maps a display model to its DB model keys. + */ +export function modelsWithAgenticData( + models: readonly M[], + rows: readonly { model: string; benchmark_type: string }[] | undefined, + dbKeysFor: (model: M) => readonly string[], +): M[] { + if (!rows) return [...models]; + const agenticDbModels = new Set( + rows.filter((row) => row.benchmark_type === 'agentic_traces').map((row) => row.model), + ); + return models.filter((model) => dbKeysFor(model).some((key) => agenticDbModels.has(key))); +} + +/** + * Parse a custom $/M tok field. Empty or negative input is rejected so a + * half-typed field disables the chart instead of pricing tokens at $0. + */ +export function parseTokenPriceInput(raw: string): number | null { + const value = Number.parseFloat(raw); + return Number.isFinite(value) && value >= 0 ? value : null; +} diff --git a/packages/app/src/components/footer/footer.tsx b/packages/app/src/components/footer/footer.tsx index 8c9c00f36..6407064e1 100644 --- a/packages/app/src/components/footer/footer.tsx +++ b/packages/app/src/components/footer/footer.tsx @@ -31,6 +31,8 @@ const STRINGS = { telemetry: 'Telemetry', articles: 'Articles', apiReference: 'API Reference', + tcoCalculator: 'TCO Calculator', + fleetLifecycle: 'Fleet Lifecycle', gpuReliability: 'Chip Reliability', gpuSpecsDashboard: 'Chip Specs Dashboard', perfPerDollar: 'Performance per Dollar', @@ -62,6 +64,8 @@ const STRINGS = { agentx: 'AgentX', telemetry: '遥测数据', articles: '技术文章', + tcoCalculator: 'TCO 计算器', + fleetLifecycle: '集群生命周期', gpuReliability: '芯片可靠性', gpuSpecsDashboard: '芯片规格仪表板', apiReference: 'API 文档', @@ -255,6 +259,22 @@ export const Footer = ({ starCount }: { starCount?: number | null }) => { > {t.apiReference} + track('footer_calculator_clicked')} + > + {t.tcoCalculator} + + track('footer_fleet_clicked')} + > + {t.fleetLifecycle} + = { historical: 'Historical Trends', calculator: 'TCO Calculator', fleet: 'Fleet Lifecycle', + 'profit-estimator': 'Profit Estimator', + 'profit-estimator-per-gigawatt': 'Profit Estimator per GW', reliability: 'Reliability', 'gpu-specs': 'Chip Specs', submissions: 'Submissions', diff --git a/packages/app/src/components/ui/d3-chart-wrapper.tsx b/packages/app/src/components/ui/d3-chart-wrapper.tsx index 291217208..2fe68efc9 100644 --- a/packages/app/src/components/ui/d3-chart-wrapper.tsx +++ b/packages/app/src/components/ui/d3-chart-wrapper.tsx @@ -135,9 +135,11 @@ export function D3ChartWrapper({ /> {noDataOverlay} -

- {resolvedInstructions} -

+ {resolvedInstructions && ( +

+ {resolvedInstructions} +

+ )}
diff --git a/packages/app/src/components/ui/result-context.test.tsx b/packages/app/src/components/ui/result-context.test.tsx index 90ecf7046..9b8ad9a6b 100644 --- a/packages/app/src/components/ui/result-context.test.tsx +++ b/packages/app/src/components/ui/result-context.test.tsx @@ -71,6 +71,22 @@ describe('ResultContext', () => { ); }); + it('shows utilization and the model license fee when given, in both locales', () => { + const en = document.createElement('div'); + act(() => { + createRoot(en).render(); + }); + expect(en.textContent).toContain('Utilization: 60%'); + expect(en.textContent).toContain('Model License Fee: 30%'); + expect(en.querySelector('[data-testid="result-context-license-fee"]')?.textContent).toBe('30%'); + const zh = document.createElement('div'); + act(() => { + createRoot(zh).render(); + }); + expect(zh.textContent).toContain('利用率: 60%'); + expect(zh.textContent).toContain('模型许可费: 30%'); + }); + it('localizes the Cost Tier label', () => { const container = document.createElement('div'); act(() => { diff --git a/packages/app/src/components/ui/result-context.tsx b/packages/app/src/components/ui/result-context.tsx index 6a1127ca9..6f54363c7 100644 --- a/packages/app/src/components/ui/result-context.tsx +++ b/packages/app/src/components/ui/result-context.tsx @@ -9,6 +9,10 @@ export interface ResultContextProps { metric?: string; /** Pricing basis of a cost or purchasing-power metric (e.g. "Owning Hyperscaler"). */ costTier?: string; + /** Fleet utilization the revenue figures assume (e.g. "60%"). */ + utilization?: string; + /** Share of revenue paid to the model lab (e.g. "30%"). */ + licenseFee?: string; target?: string; date?: string; dates?: readonly string[]; @@ -26,6 +30,8 @@ export function ResultContext({ precision, metric, costTier, + utilization, + licenseFee, target, date, dates, @@ -47,6 +53,8 @@ export function ResultContext({ source: '来源', cost: '成本口径', costTier: '成本层级', + utilization: '利用率', + licenseFee: '模型许可费', } : { model: 'Model', @@ -59,6 +67,8 @@ export function ResultContext({ source: 'Source', cost: 'Cost basis', costTier: 'Cost Tier', + utilization: 'Utilization', + licenseFee: 'Model License Fee', }; const hasRange = Boolean(dateRange?.start && dateRange.end); const selectedDates = dates && dates.length > 1 ? dates.join(', ') : date; @@ -108,6 +118,22 @@ export function ResultContext({ )} + {utilization && ( +
+
{labels.utilization}:
{' '} +
+ {utilization} +
+
+ )} + {licenseFee && ( +
+
{labels.licenseFee}:
{' '} +
+ {licenseFee} +
+
+ )} {target && (
{labels.target}:
{' '} diff --git a/packages/app/src/lib/d3-chart/chart-update.ts b/packages/app/src/lib/d3-chart/chart-update.ts index 77395b504..b7153db24 100644 --- a/packages/app/src/lib/d3-chart/chart-update.ts +++ b/packages/app/src/lib/d3-chart/chart-update.ts @@ -45,9 +45,9 @@ export function renderAxes( if (updateX) { let xAxisGen: d3.Axis; if ('bandwidth' in xScale) { - xAxisGen = d3 - .axisBottom(xScale as d3.ScaleBand) - .tickSize(6) as unknown as d3.Axis; + const bandGen = d3.axisBottom(xScale as d3.ScaleBand).tickSize(6); + if (xTickFormat) bandGen.tickFormat(xTickFormat as any); + xAxisGen = bandGen as unknown as d3.Axis; } else { const gen = d3.axisBottom(xScale as ContinuousScale).tickSize(6); if (xTickCount) gen.ticks(xTickCount); diff --git a/packages/app/src/lib/dashboard-routes.test.ts b/packages/app/src/lib/dashboard-routes.test.ts index dca81c307..cb0770c86 100644 --- a/packages/app/src/lib/dashboard-routes.test.ts +++ b/packages/app/src/lib/dashboard-routes.test.ts @@ -35,6 +35,31 @@ describe('dashboard route registry', () => { expect(getDashboardRoute('feedback').indexable).toBe(false); }); + it('moves the TCO calculator and fleet lifecycle to the footer but keeps them indexed', () => { + expect(getDashboardRoute('calculator').navGroup).toBe('footer-only'); + expect(getDashboardRoute('calculator').indexable).toBe(true); + expect(getDashboardRoute('fleet').navGroup).toBe('footer-only'); + expect(getDashboardRoute('fleet').indexable).toBe(true); + }); + + it('puts both profit estimators between Inference Performance and Accuracy Evals', () => { + const primary = DASHBOARD_ROUTES.filter((route) => route.navGroup === 'primary').map( + (route) => route.key, + ); + expect(primary.slice(0, 4)).toEqual([ + 'inference', + 'profit-estimator-per-gigawatt', + 'profit-estimator', + 'evaluation', + ]); + expect(primary).not.toContain('calculator'); + expect(primary).not.toContain('fleet'); + expect(dashboardRouteForPathname('/profit-estimator-per-gigawatt/kimi-k3')?.key).toBe( + 'profit-estimator-per-gigawatt', + ); + expect(dashboardRouteForPathname('/profit-estimator/kimi-k3')?.key).toBe('profit-estimator'); + }); + it('declares the standalone and self-seeded provider capabilities', () => { expect(getDashboardRoute('collectivex').providers).toEqual({ globalFilters: false, diff --git a/packages/app/src/lib/dashboard-routes.ts b/packages/app/src/lib/dashboard-routes.ts index 48166902f..08a34b5b4 100644 --- a/packages/app/src/lib/dashboard-routes.ts +++ b/packages/app/src/lib/dashboard-routes.ts @@ -82,6 +82,29 @@ export const DASHBOARD_ROUTES = [ providers: FILTERED_DASHBOARD_PROVIDERS, shareParamScopes: ['g_', 'i_'], }, + // The two profit views sit between Inference Performance and Accuracy + // Evals; the TCO calculator and fleet lifecycle moved to the footer's + // "More" column. + { + key: 'profit-estimator-per-gigawatt', + path: '/profit-estimator-per-gigawatt', + canonicalPath: '/profit-estimator-per-gigawatt', + navGroup: 'primary', + indexable: true, + localeMirrored: true, + providers: UNOFFICIAL_ONLY_DASHBOARD_PROVIDERS, + shareParamScopes: ['g_', 'i_', 'c_'], + }, + { + key: 'profit-estimator', + path: '/profit-estimator', + canonicalPath: '/profit-estimator', + navGroup: 'primary', + indexable: true, + localeMirrored: true, + providers: UNOFFICIAL_ONLY_DASHBOARD_PROVIDERS, + shareParamScopes: ['g_', 'i_', 'c_'], + }, { key: 'evaluation', path: '/evaluation', @@ -106,7 +129,7 @@ export const DASHBOARD_ROUTES = [ key: 'calculator', path: '/calculator', canonicalPath: '/calculator', - navGroup: 'primary', + navGroup: 'footer-only', indexable: true, localeMirrored: true, providers: UNOFFICIAL_ONLY_DASHBOARD_PROVIDERS, @@ -116,7 +139,7 @@ export const DASHBOARD_ROUTES = [ key: 'fleet', path: '/fleet', canonicalPath: '/fleet', - navGroup: 'primary', + navGroup: 'footer-only', indexable: true, localeMirrored: true, providers: UNOFFICIAL_ONLY_DASHBOARD_PROVIDERS, diff --git a/packages/app/src/lib/model-routes.test.ts b/packages/app/src/lib/model-routes.test.ts index 48b649c32..83eaeeef4 100644 --- a/packages/app/src/lib/model-routes.test.ts +++ b/packages/app/src/lib/model-routes.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { Model, MODEL_OPTIONS } from './data-mappings'; import { DEFAULT_ROUTE_MODEL, + defaultRouteModel, MODEL_ROUTE_TABS, MODEL_ROUTES, modelRoutePath, @@ -34,6 +35,25 @@ describe('MODEL_ROUTES registry', () => { // would rewrite the URL. expect(PARAM_DEFAULTS.g_model).toBe(DEFAULT_ROUTE_MODEL); }); + + it('gives every tab a default model, with the profit estimator pinned to Kimi K3', () => { + expect(MODEL_ROUTE_TABS).toEqual([ + 'calculator', + 'historical', + 'profit-estimator', + 'profit-estimator-per-gigawatt', + ]); + expect(defaultRouteModel('calculator')).toBe(DEFAULT_ROUTE_MODEL); + expect(defaultRouteModel('historical')).toBe(DEFAULT_ROUTE_MODEL); + // The estimator only plots agentic traces, and Kimi K3 is the model with + // the widest agentic hardware coverage; the page seeds its provider from + // this same helper. + expect(defaultRouteModel('profit-estimator')).toBe(Model.Kimi_K3); + expect(defaultRouteModel('profit-estimator-per-gigawatt')).toBe(Model.Kimi_K3); + for (const tab of MODEL_ROUTE_TABS) { + expect(MODEL_ROUTES.some((route) => route.model === defaultRouteModel(tab))).toBe(true); + } + }); }); describe('resolveModelRouteSlug', () => { @@ -125,6 +145,19 @@ describe('modelRoutePathnameRewrite', () => { it('rewrites bare paths only for non-default models', () => { expect(modelRoutePathnameRewrite('/calculator', DEFAULT_ROUTE_MODEL)).toBeNull(); expect(modelRoutePathnameRewrite('/calculator', Model.Kimi_K3)).toBe('/calculator/kimi-k3'); + // Per-tab default: the profit estimator's bare path means Kimi K3, so the + // app-wide default model is the one that gets a slug there. + expect(modelRoutePathnameRewrite('/profit-estimator', Model.Kimi_K3)).toBeNull(); + expect(modelRoutePathnameRewrite('/zh/profit-estimator', Model.Kimi_K3)).toBeNull(); + expect(modelRoutePathnameRewrite('/profit-estimator', DEFAULT_ROUTE_MODEL)).toBe( + `/profit-estimator/${modelRouteSlug(DEFAULT_ROUTE_MODEL)}`, + ); + expect( + modelRoutePathnameRewrite( + `/profit-estimator/${modelRouteSlug(DEFAULT_ROUTE_MODEL)}`, + Model.Kimi_K3, + ), + ).toBe('/profit-estimator/kimi-k3'); expect(modelRoutePathnameRewrite('/zh/historical', Model.Kimi_K3)).toBe( '/zh/historical/kimi-k3', ); diff --git a/packages/app/src/lib/model-routes.ts b/packages/app/src/lib/model-routes.ts index 4df6e3bc6..58361903c 100644 --- a/packages/app/src/lib/model-routes.ts +++ b/packages/app/src/lib/model-routes.ts @@ -6,7 +6,8 @@ import { import { Model, MODEL_OPTIONS } from '@/lib/data-mappings'; // --------------------------------------------------------------------------- -// Per-model dashboard routes: /calculator/ and /historical/ +// Per-model dashboard routes: /calculator/, /historical/, +// /profit-estimator/, and /profit-estimator-per-gigawatt/ // --------------------------------------------------------------------------- // // The bare /calculator and /historical tabs render the DEFAULT model; the @@ -24,12 +25,19 @@ import { Model, MODEL_OPTIONS } from '@/lib/data-mappings'; // canonical slug, mirroring compare behavior. /** Dashboard tabs that expose per-model child routes. */ -export const MODEL_ROUTE_TABS = ['calculator', 'historical'] as const; +export const MODEL_ROUTE_TABS = [ + 'calculator', + 'historical', + 'profit-estimator', + 'profit-estimator-per-gigawatt', +] as const; export type ModelRouteTab = (typeof MODEL_ROUTE_TABS)[number]; const MODEL_ROUTE_TAB_PATHS: Record = { calculator: '/calculator', historical: '/historical', + 'profit-estimator': '/profit-estimator', + 'profit-estimator-per-gigawatt': '/profit-estimator-per-gigawatt', }; /** @@ -41,6 +49,47 @@ const MODEL_ROUTE_TAB_PATHS: Record = { */ export const DEFAULT_ROUTE_MODEL: Model = Model.DeepSeek_V4_Pro; +/** + * Model each bare tab route renders. Calculator and historical follow the + * app-wide default; the profit estimators are pinned to agentic traces and open + * on Kimi K3, the model that workload is benchmarked most widely on. The page + * component seeds `GlobalFilterProvider` with the same model, and + * `model-routes.test.ts` pins that agreement. + */ +const MODEL_ROUTE_TAB_DEFAULT_MODEL: Record = { + calculator: DEFAULT_ROUTE_MODEL, + historical: DEFAULT_ROUTE_MODEL, + 'profit-estimator': Model.Kimi_K3, + 'profit-estimator-per-gigawatt': Model.Kimi_K3, +}; + +/** Model the bare `tab` route shows; its slugged page canonicalizes to the bare path. */ +export function defaultRouteModel(tab: ModelRouteTab): Model { + return MODEL_ROUTE_TAB_DEFAULT_MODEL[tab]; +} + +/** + * Tabs that expose only a subset of `MODEL_ROUTES`. The profit estimators are + * launching with Kimi K3 alone (the only model whose agentic coverage spans + * every SKU we price); other slugs 404 there and stay out of the sitemap. + * Tabs absent from this map offer every model. + */ +const MODEL_ROUTE_TAB_MODELS: Partial> = { + 'profit-estimator': [Model.Kimi_K3], + 'profit-estimator-per-gigawatt': [Model.Kimi_K3], +}; + +/** Routes a tab actually serves, in `MODEL_ROUTES` order. */ +export function modelRoutesForTab(tab: ModelRouteTab): ModelRoute[] { + const allowed = MODEL_ROUTE_TAB_MODELS[tab]; + return allowed ? MODEL_ROUTES.filter((route) => allowed.includes(route.model)) : MODEL_ROUTES; +} + +/** Whether `tab` serves a page for `model`. */ +export function modelRouteAvailableForTab(tab: ModelRouteTab, model: Model): boolean { + return MODEL_ROUTE_TAB_MODELS[tab]?.includes(model) ?? true; +} + export interface ModelRoute { /** Canonical URL segment, e.g. 'kimi-k3'. Lowercase, hyphen-separated. */ slug: string; @@ -182,7 +231,7 @@ export function modelRoutePathnameRewrite(pathname: string, model: Model): strin const slug = modelRouteSlug(model); if (!slug) return null; if (parsed.slug === slug) return null; - if (!parsed.slug && model === DEFAULT_ROUTE_MODEL) return null; + if (!parsed.slug && model === defaultRouteModel(parsed.tab)) return null; const enPath = modelRoutePath(parsed.tab, slug); return parsed.zh ? `/zh${enPath}` : enPath; } diff --git a/packages/app/src/lib/tab-meta-zh.ts b/packages/app/src/lib/tab-meta-zh.ts index e04319ecf..6b969de6e 100644 --- a/packages/app/src/lib/tab-meta-zh.ts +++ b/packages/app/src/lib/tab-meta-zh.ts @@ -42,6 +42,16 @@ export const TAB_META_ZH: Record = { '本页面提供吞吐量与总拥有成本(TCO)计算器:基于真实基准测试数据,估算不同芯片配置下 LLM 推理服务的每百万 token 成本与性价比。', fleet: '本页面提供集群生命周期经济性分析:按设施功率预算确定固定集群的规模,从模型发布之日起,基于历史基准测试中实测的软件配置改进,测算收入、成本、利润率与回本时间。', + 'profit-estimator': + '本页面提供推理利润估算器:在选定的交互性与利用率下,按每芯片每小时计算各芯片的收入,并以堆叠柱形拆分为算力支出(TCO $/chip/hr)、模型实验室从收入中抽取的许可费,以及运营方所剩利润。', + 'profit-estimator-per-gigawatt': + '本页面提供每吉瓦推理利润估算器:在选定的交互性与利用率下,按每全电源配置吉瓦每年计算各芯片的收入,并以堆叠柱形拆分为算力支出(TCO)、模型实验室从收入中抽取的许可费,以及运营方所剩利润。', reliability: '本页面展示基准测试基础设施的可靠性指标:各芯片集群与服务商的运行成功率、错误率与可用性。', 'gpu-specs': @@ -121,6 +135,8 @@ export const TAB_LABELS_ZH: Record = { historical: '历史趋势', calculator: 'TCO 计算器', fleet: '集群生命周期', + 'profit-estimator': '利润估算', + 'profit-estimator-per-gigawatt': '每吉瓦利润估算', reliability: '可靠性', 'gpu-specs': '芯片规格', 'gpu-metrics': '芯片功耗', @@ -177,6 +193,20 @@ export const MODEL_TAB_META_ZH: Record< intro: (seoName) => `本页面提供 ${seoName} 吞吐量与总拥有成本(TCO)计算器:基于真实基准测试数据,估算不同芯片配置下 ${seoName} 推理服务的每百万 token 成本与性价比。`, }, + 'profit-estimator': { + title: (seoName) => `${seoName} 推理利润估算器`, + description: (seoName) => + `估算每款芯片每小时运行 ${seoName} 智能体推理的收益:在选定交互性与利用率下的收入,拆分为算力支出(TCO $/chip/hr)、模型许可费与运营方利润。`, + intro: (seoName) => + `本页面按芯片估算每小时运行 ${seoName} 智能体推理的收入、算力支出(TCO $/chip/hr)、模型许可费与运营方利润,可自定义交互性、利用率、模型许可费与 token 售价。`, + }, + 'profit-estimator-per-gigawatt': { + title: (seoName) => `${seoName} 每吉瓦推理利润估算器`, + description: (seoName) => + `估算每款芯片一个吉瓦年运行 ${seoName} 智能体推理的收益:在选定交互性与利用率下的收入,拆分为算力支出(TCO)、模型许可费与运营方利润。`, + intro: (seoName) => + `本页面按芯片估算一个全电源配置吉瓦年运行 ${seoName} 智能体推理的收入、算力支出(TCO)、模型许可费与运营方利润,可自定义交互性、利用率、模型许可费与 token 售价。`, + }, }; /** Generate Next.js Metadata for a per-model /zh tab page. Canonical (and diff --git a/packages/app/src/lib/tab-meta.test.ts b/packages/app/src/lib/tab-meta.test.ts index a8d3514ff..8467416eb 100644 --- a/packages/app/src/lib/tab-meta.test.ts +++ b/packages/app/src/lib/tab-meta.test.ts @@ -1,7 +1,18 @@ import { describe, expect, it } from 'vitest'; import { DASHBOARD_ROUTE_KEYS } from './dashboard-routes'; -import { getTabTitle, isValidTab, LANDING_META, TAB_META, tabMetadata } from './tab-meta'; +import { Model } from '@/lib/data-mappings'; +import { defaultRouteModel, MODEL_ROUTES } from './model-routes'; +import { + getTabTitle, + isValidTab, + LANDING_META, + MODEL_TAB_META, + modelTabCanonicalPath, + modelTabMetadata, + TAB_META, + tabMetadata, +} from './tab-meta'; describe('agentic inference positioning', () => { it('uses agentic inference for the category and AgentX for the scenario', () => { @@ -25,6 +36,42 @@ describe('current image metadata', () => { }); }); +describe('per-model profit estimator metadata', () => { + const kimi = MODEL_ROUTES.find((route) => route.model === Model.Kimi_K3)!; + const deepseek = MODEL_ROUTES.find((route) => route.model === Model.DeepSeek_V4_Pro)!; + + it('canonicalizes the default model (Kimi K3) to the bare tab path only', () => { + expect(defaultRouteModel('profit-estimator')).toBe(Model.Kimi_K3); + expect(modelTabCanonicalPath('profit-estimator', kimi)).toBe('/profit-estimator'); + expect(modelTabCanonicalPath('profit-estimator', deepseek)).toBe( + `/profit-estimator/${deepseek.slug}`, + ); + // The app-wide default model is not this tab's default, so it keeps a slug. + expect(modelTabCanonicalPath('calculator', deepseek)).toBe('/calculator'); + }); + + it('gives the per-gigawatt view its own Kimi-defaulted route and GW wording', () => { + expect(defaultRouteModel('profit-estimator-per-gigawatt')).toBe(Model.Kimi_K3); + expect(modelTabCanonicalPath('profit-estimator-per-gigawatt', kimi)).toBe( + '/profit-estimator-per-gigawatt', + ); + expect(MODEL_TAB_META['profit-estimator-per-gigawatt'].title(kimi.seoName)).toContain( + 'per GigaWatt', + ); + expect(MODEL_TAB_META['profit-estimator'].description(kimi.seoName)).toContain('chip-hour'); + }); + + it('names the model in the title and description', () => { + const meta = modelTabMetadata('profit-estimator', deepseek); + expect(meta.title).toBe(MODEL_TAB_META['profit-estimator'].title(deepseek.seoName)); + expect(meta.title).toContain(deepseek.seoName); + expect(meta.description).toContain(deepseek.seoName); + expect(meta.alternates?.canonical).toBe( + `https://inferencex.semianalysis.com/profit-estimator/${deepseek.slug}`, + ); + }); +}); + describe('isValidTab', () => { it.each(DASHBOARD_ROUTE_KEYS)('returns true for valid tab "%s"', (tab) => { expect(isValidTab(tab)).toBe(true); diff --git a/packages/app/src/lib/tab-meta.ts b/packages/app/src/lib/tab-meta.ts index d959a97d6..a57c77cf4 100644 --- a/packages/app/src/lib/tab-meta.ts +++ b/packages/app/src/lib/tab-meta.ts @@ -8,7 +8,7 @@ import { } from '@/lib/dashboard-routes'; import { languageAlternates } from '@/lib/i18n'; import { - DEFAULT_ROUTE_MODEL, + defaultRouteModel, modelRoutePath, type ModelRoute, type ModelRouteTab, @@ -46,6 +46,16 @@ export const TAB_META: Record `Calculate ${seoName} inference throughput and total cost of ownership. Compare chip cost-efficiency for serving ${seoName} across hardware configurations.`, }, + 'profit-estimator': { + title: (seoName) => `${seoName} Inference Profit Estimator`, + description: (seoName) => + `Estimate what one chip-hour of ${seoName} agentic inference earns: revenue at a chosen interactivity and utilization, split into compute expense (TCO $/chip/hr), model license fee, and operator profit.`, + }, + 'profit-estimator-per-gigawatt': { + title: (seoName) => `${seoName} Inference Profit Estimator per GigaWatt`, + description: (seoName) => + `Estimate what one gigawatt-year of ${seoName} agentic inference earns per chip: revenue at a chosen interactivity and utilization, split into compute expense (TCO), model license fee, and operator profit.`, + }, }; /** @@ -122,7 +142,7 @@ export const MODEL_TAB_META: Record< * self-canonical. */ export function modelTabCanonicalPath(tab: ModelRouteTab, route: ModelRoute): string { - return route.model === DEFAULT_ROUTE_MODEL + return route.model === defaultRouteModel(tab) ? getDashboardRoute(tab).canonicalPath : modelRoutePath(tab, route.slug); } diff --git a/packages/app/src/lib/vendor-logos.test.ts b/packages/app/src/lib/vendor-logos.test.ts index f3f0b9872..a264fe40c 100644 --- a/packages/app/src/lib/vendor-logos.test.ts +++ b/packages/app/src/lib/vendor-logos.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { VENDOR_LOGO_ICONS, getLineLabelVendorIcon } from './vendor-logos'; +import { VENDOR_LOGO_ICONS, getAxisVendorIcon, getLineLabelVendorIcon } from './vendor-logos'; describe('vendor logo icons', () => { it('maps NVIDIA hardware keys to the full-color NVIDIA mark', () => { @@ -37,3 +37,19 @@ describe('vendor logo icons', () => { expect(decodeURIComponent(VENDOR_LOGO_ICONS.Teacup.href)).toContain('#ffffff'); }); }); + +describe('getAxisVendorIcon', () => { + it('returns the full-color NVIDIA mark that is never inverted', () => { + const icon = getAxisVendorIcon('b200'); + expect(icon?.monochrome).toBe(false); + expect(decodeURIComponent(icon?.href ?? '')).toContain('#76B900'); + }); + + it('returns the monochrome AMD arrow so dark mode can invert it', () => { + expect(getAxisVendorIcon('mi355x_dsv4')?.monochrome).toBe(true); + }); + + it('has no mark for unknown hardware', () => { + expect(getAxisVendorIcon('tpuv7')).toBeUndefined(); + }); +}); diff --git a/packages/app/src/lib/vendor-logos.ts b/packages/app/src/lib/vendor-logos.ts index 1ab2f0c8b..94ebe39a0 100644 --- a/packages/app/src/lib/vendor-logos.ts +++ b/packages/app/src/lib/vendor-logos.ts @@ -78,6 +78,37 @@ export const VENDOR_LOGO_ICONS: Record = { Teacup: { href: svgDataUri(OPENAI_LOGO_SVG), width: 10, height: 10 }, }; +/** + * Full-color vendor marks for axis labels drawn on the page background + * (not on a coloured pill), where the brand colour is legible. + * + * - NVIDIA: the eye mark in NVIDIA green (#76B900), the same glyph as + * `public/logos/nvidia-color.svg` cropped to its drawn extent. Green reads on + * both themes, so it is never inverted. + * - AMD: the brand permits only the black mark or its white reverse (the old + * green arrow is retired), so the arrow stays black and is inverted to white + * in dark mode. `monochrome: true` tells the chart to apply that invert. + */ +export interface AxisVendorLogoIcon extends VendorLogoIcon { + /** True when the mark is single-colour and should be inverted on dark backgrounds. */ + monochrome: boolean; +} + +/** NVIDIA eye mark in brand green; viewBox cropped to the glyph (24 x 14.01 units). */ +const NVIDIA_COLOR_LOGO_SVG = + ''; + +export const AXIS_VENDOR_LOGO_ICONS: Record = { + NVIDIA: { href: svgDataUri(NVIDIA_COLOR_LOGO_SVG), width: 24, height: 14, monochrome: false }, + AMD: { href: svgDataUri(AMD_LOGO_SVG), width: 10, height: 10, monochrome: true }, +}; + +/** Full-color vendor mark for a hardware key, for labels on the page background. */ +export function getAxisVendorIcon(hwKey: string): AxisVendorLogoIcon | undefined { + const vendor = GPU_VENDORS[hwKey.split('_')[0]]; + return vendor ? AXIS_VENDOR_LOGO_ICONS[vendor] : undefined; +} + /** * Full-color vendor mark for a hardware key (`gb200`, `mi355x_dsv4`, ...). * Uses the same base-key convention as `quickFilters`: everything before the diff --git a/packages/app/timings.json b/packages/app/timings.json index 91daaa71d..76f2da346 100644 --- a/packages/app/timings.json +++ b/packages/app/timings.json @@ -224,6 +224,10 @@ "spec": "cypress/e2e/performance.cy.ts", "duration": 2287 }, + { + "spec": "cypress/e2e/profit-estimator.cy.ts", + "duration": 11000 + }, { "spec": "cypress/e2e/reliability-chart.cy.ts", "duration": 2136