Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 80 additions & 6 deletions .claude/skills/write-inferencex-blog/iso_interactivity.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,20 @@
6. Clamp the interpolated metric value to [min(ys), max(ys)] of the
frontier to prevent cubic-spline overshoots above/below the data.

Reciprocal metrics ($/M tok, J/token) are a special case: they are a per-chip
constant divided by a throughput, so they are NOT splined independently. Two
independent splines need not preserve `metric * throughput = constant` between
measured knots. Pass `reciprocal_of='throughput'` (or the matching output/input
throughput key) to spline that throughput and re-derive the metric, which is what
the dashboard does. See docs/tco-calculator.md for a reproducible measurement.

Usage as a module:
from iso_interactivity import interpolate_metric, pareto_front_upper_left
# points: list of dicts with at least 'interactivity' and 'throughput',
# plus whatever metric you want to interpolate.
cost = interpolate_metric(points, target_iv=18.0, metric_key='cost_per_M')
# cost/energy are reciprocal in throughput — name the throughput they divide
cost = interpolate_metric(points, target_iv=18.0, metric_key='cost_per_M',
reciprocal_of='throughput')

Usage as a script (JSON input on stdin, JSON output on stdout):
echo '{"points":[...], "target_iv":18.0, "metric_key":"throughput"}' \\
Expand Down Expand Up @@ -143,12 +152,43 @@ def hermite_interpolate(
return h00 * ys[lo] + h10 * hh * m[lo] + h01 * ys[hi] + h11 * hh * m[hi]


def recover_reciprocal_numerator(
values: list[Optional[float]], throughputs: list[Optional[float]]
) -> Optional[float]:
"""Recover the constant `c` of a metric defined as `c / throughput`.

1:1 with `recoverReciprocalNumerator` in
packages/app/src/components/calculator/interpolation.ts. Returns None unless
EVERY usable point agrees on the constant: the identity is what licenses
re-deriving the metric, so data whose numerator actually varies per point
must fall back to being splined directly.
"""
# 0.1%, matching the TS side. Deliberately loose: blog tables are assembled
# from costs written to a few decimals, and a tight gate would silently
# reject them and fall back to splining. Still rejects a numerator that
# genuinely varies per point (measured power moves by whole percent).
relative_tolerance = 1e-3
numerator: Optional[float] = None
for value, throughput in zip(values, throughputs):
if value is None or throughput is None:
continue
if not value > 0 or not throughput > 0:
continue
candidate = value * throughput
if numerator is None:
numerator = candidate
elif abs(candidate - numerator) > abs(numerator) * relative_tolerance:
return None
return numerator


def interpolate_metric(
points: list[dict],
target_iv: float,
metric_key: str = 'throughput',
iv_key: str = 'interactivity',
tput_key: str = 'throughput',
reciprocal_of: Optional[str] = None,
) -> Optional[float]:
"""Interpolate `metric_key` at `target_iv` using the chart's algorithm.

Expand All @@ -160,6 +200,12 @@ def interpolate_metric(
of which metric you're interpolating — this matches the chart, where the
frontier is defined by the upper-left throughput envelope and other metrics
(cost, energy, TPOT, ...) are derived values at frontier points.

`reciprocal_of` names the throughput key a metric divides, for metrics of the
form `constant / throughput` ($/M tok, J/token). Set it for those metrics:
that throughput is splined and the metric re-derived, matching the dashboard.
Leave it None for metrics splined directly (throughput itself, tok/s/MW,
latency, and measured energy — whose numerator varies per point).
"""
if not points:
return None
Expand All @@ -185,10 +231,36 @@ def interpolate_metric(
return sorted_front[0].get(metric_key)
return None

# Matches TS `extractMetric(...) ?? 0`: missing metric on a frontier
# point falls back to 0 instead of raising KeyError. Use `.get` so the
# CLI returns null cleanly instead of dying with a traceback.
ys = [(p.get(metric_key) if p.get(metric_key) is not None else 0) for p in sorted_front]
# The TS helper returns null if any frontier point lacks the requested
# metric. Do the same here: coercing a missing value to zero would create a
# synthetic knot and make blog output diverge from the dashboard.
ys: list[float] = []
for point in sorted_front:
value = point.get(metric_key)
if value is None:
return None
ys.append(value)

if reciprocal_of is not None:
tputs: list[float] = []
for point in sorted_front:
throughput = point.get(reciprocal_of)
if throughput is None:
return None
tputs.append(throughput)
numerator = recover_reciprocal_numerator(ys, tputs)
# None means these points do not obey the identity — fall through and
# spline the metric directly, matching the TS fallback.
if numerator is not None:
tput_slopes = monotone_slopes(xs, tputs)
tput = hermite_interpolate(xs, tputs, tput_slopes, target_iv)
# Clamp the throughput, as the TS side does, then derive; cost then
# cannot overshoot the frontier's own cost range either.
tput = max(min(tputs), min(max(tputs), tput))
if tput <= 0:
return 0.0
return numerator / tput

slopes = monotone_slopes(xs, ys)
raw = hermite_interpolate(xs, ys, slopes, target_iv)

Expand All @@ -203,7 +275,8 @@ def interpolate_metric(


def _cli() -> None:
"""Stdin: {"points": [...], "target_iv": N, "metric_key": "..."}
"""Stdin: {"points": [...], "target_iv": N, "metric_key": "...",
"reciprocal_of": "throughput" for $/M tok and J/token}
Stdout: {"value": N or null}"""
req = json.loads(sys.stdin.read())
value = interpolate_metric(
Expand All @@ -212,6 +285,7 @@ def _cli() -> None:
metric_key=req.get('metric_key', 'throughput'),
iv_key=req.get('iv_key', 'interactivity'),
tput_key=req.get('tput_key', 'throughput'),
reciprocal_of=req.get('reciprocal_of'),
)
json.dump({'value': value}, sys.stdout)
sys.stdout.write('\n')
Expand Down
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,14 @@ The Python helper is a 1:1 port of these three TypeScript functions:

Plus the wrapper `interpolateMetricAtInteractivity` in `packages/app/src/components/inference/hooks/useInterpolatedTrendData.ts` which composes them with the "no extrapolation → return null" rule.

Plus `recoverReciprocalNumerator` in `interpolation.ts`, which decides whether a metric is splined directly or derived from the interpolated throughput. $/M tok and J/token are a per-chip constant over a throughput, so independently splining the metric breaks that identity between knots; both TS and Python spline the throughput and re-derive instead. See `docs/tco-calculator.md` for the reproducible measurement.

**Rule: any PR that changes any of those four TypeScript functions MUST also update `.claude/skills/write-inferencex-blog/iso_interactivity.py` in the same commit.** Drift between the TS and Python implementations means the blog tables will silently diverge from the live chart on the very next post — readers will see one number in the table and a different one in the chart they click through to. This includes:

- Changing the Pareto frontier definition (upper-left → lower-left, or adding tie-breaking rules)
- Switching from Steffen's monotone slopes to a different spline construction (Fritsch-Carlson, natural cubic, etc.)
- Loosening or tightening the extrapolation rule (currently: return `null` outside `[min x, max x]`)
- Changing which metrics are derived from throughput rather than splined, or the tolerance that decides whether the data obeys `metric x throughput = constant`
- Adjusting the Y-clamp behavior that prevents spline overshoot

The Python file has a header comment explaining the pipeline and a `_cli()` entrypoint for stdin/stdout JSON usage. When you update it, keep the structure 1:1 with the TS so future readers can diff the two files line by line. Run the helper against a known dataset and confirm the outputs match what the chart renders before merging.
Expand Down
33 changes: 33 additions & 0 deletions docs/tco-calculator.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,36 @@ Agentic interactivity follows the same definition as the main inference chart:
`b300Rows` in `cypress/support/overlay-fixtures.ts` covers the agentic calculator
path; `singleTurnRows` remains the fixture for fixed-sequence visibility and
sequence-switching behavior.

## Reciprocal Metrics Are Derived, Not Splined

`$/M tok` and `J/token` are a per-chip constant divided by a throughput
(`$/GPU-hr x 1e6 / (tok/s x 3600)`, `W / (tok/s)`). `interpolateForGPU`,
`maxInteractivityAtCost` and `interpolateMetricAtInteractivity` therefore spline
the **throughput** those metrics divide and re-derive the metric, rather than
splining the metric itself.

Independently splining the reciprocal metric and throughput creates two curves
that need not satisfy `metric x throughput = constant` between measured knots.
The direction and size of the difference depend on frontier density and can
change as benchmark runs land. Re-deriving the metric preserves its definition
at every interpolated point.

`/inference` plots these metrics only at measured points (`lib/chart-utils.ts`,
`roof: false`), where both methods agree exactly. Leave-one-out measurements can
compare interpolation models on a fixed snapshot, but they must not be presented
as permanent impact figures for the changing live dataset.

### The consistency guard

`recoverReciprocalNumerator` returns the constant only if **every** usable point
agrees on it within 0.1% (`1e-3` relative). That guard is what licenses the
rewrite. The `measured*` energy keys have a numerator measured per point rather
than a constant, so they are excluded from `RECIPROCAL_OF_THROUGHPUT` and still
splined directly. When the guard fails, all three call sites fall back to
splining.

The rate is recovered across **all three token types at once** (`recoverCostRate`).
Checking one family alone and falling back to another would recover a rate from
output tokens and then apply it to total throughput; the existing
`maxInteractivityAtCost` tests caught exactly that mistake.
137 changes: 131 additions & 6 deletions packages/app/src/components/calculator/interpolation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,94 @@ export function getCostField(
return p[costProvider];
}

/**
* Recover the constant numerator `c` of a metric that is defined as
* `c / throughput`, from any frontier point that carries both values.
*
* Cost per million tokens is `$/GPU-hr x 1e6 / (tok/s x 3600)` and energy per
* token is `W / (tok/s)`: both are a per-chip constant divided by a throughput,
* and the constant is identical at every point of a config. Recovering it from
* the points avoids threading the hardware registry into this module, which must
* stay dependency-free for the Python port.
*
* Why this exists: independently splining the reciprocal metric and throughput
* produces two curves that need not satisfy `metric x throughput = c` between
* measured knots. Deriving from the interpolated throughput preserves that
* defining identity. The numerical effect depends on frontier density and can
* move either direction with Steffen splines; see docs/tco-calculator.md for a
* dated, reproducible measurement against the live API.
*
* Returns null unless EVERY usable point agrees on the constant. That check is
* the safety rail: the identity is what licenses re-deriving the metric, so a
* metric whose numerator actually varies per point (measured power, say) must
* fall back to being splined directly rather than have its values silently
* rewritten from one point's ratio.
*/
export function recoverReciprocalNumerator(
values: readonly number[],
throughputs: readonly number[],
): number | null {
// Dashboard values come from one getGpuSpecs(hwKey) lookup, so they agree to
// float rounding (~1e-16). The tolerance is far looser than that on purpose:
// the Python port is fed hand-assembled JSON for blog tables, where costs are
// written to a few decimals and a 1e-9 gate would silently reject them and
// fall back to the worse method. 0.1% still comfortably rejects what this
// guard is for — a numerator that genuinely varies per point, like measured
// power, which moves by whole percent across a sweep. And a numerator varying
// by less than 0.1% makes deriving and splining agree anyway.
const RELATIVE_TOLERANCE = 1e-3;
let numerator: number | null = null;

for (let i = 0; i < values.length; i += 1) {
const value = values[i];
const throughput = throughputs[i];
if (value === undefined || throughput === undefined) continue;
if (!(value > 0) || !(throughput > 0)) continue;

const candidate = value * throughput;
if (numerator === null) {
numerator = candidate;
} else if (Math.abs(candidate - numerator) > Math.abs(numerator) * RELATIVE_TOLERANCE) {
return null;
}
}

return numerator;
}

/** Evaluate a `numerator / throughput` metric at an interpolated throughput. */
export function reciprocalMetricAt(numerator: number | null, throughput: number): number {
if (numerator === null || !(throughput > 0)) return 0;
return numerator / throughput;
}

/**
* The single provider rate that must explain every cost field on these points,
* or null if it does not.
*
* All three token types share one `$/GPU-hr x 1e6/3600` and differ only in the
* throughput they divide, so consistency has to be checked across all of them
* together. Checking one family in isolation and falling back to another would
* recover a rate from output tokens and then apply it to total throughput.
*/
function recoverCostRate(
sorted: readonly GPUDataPoint[],
costProvider: CostProvider,
): number | null {
return recoverReciprocalNumerator(
[
...sorted.map((p) => getCostField(p, costProvider, 'total')),
...sorted.map((p) => getCostField(p, costProvider, 'output')),
...sorted.map((p) => getCostField(p, costProvider, 'input')),
],
[
...sorted.map((p) => p.throughput),
...sorted.map((p) => p.outputThroughput),
...sorted.map((p) => p.inputThroughput),
],
);
}

/**
* Given a set of data points for a single GPU, find the maximum interactivity
* (tok/s/user) whose interpolated cost per million tokens stays at or below
Expand Down Expand Up @@ -195,12 +283,30 @@ export function maxInteractivityAtCost(
}

const xs = sorted.map((p) => p.interactivity);
const ys = sorted.map(getCost);
// Cost is derived from the interpolated throughput of the selected token type,
// exactly as interpolateForGPU does it — otherwise this inverse lookup would
// answer against a different cost curve than the bars the user is reading.
const getTput = (p: GPUDataPoint) =>
costType === 'input'
? p.inputThroughput
: costType === 'output'
? p.outputThroughput
: p.throughput;
const tputs = sorted.map(getTput);
const costs = sorted.map(getCost);
// Same decision as interpolateForGPU, so the inverse lookup and the bars can
// never answer against different cost curves.
const rate = recoverCostRate(sorted, costProvider);
// Spline whichever series the identity licenses: the throughput when cost is
// genuinely `rate / throughput`, otherwise cost itself (previous behaviour).
const ys = rate === null ? costs : tputs;
const slopes = monotoneSlopes(xs, ys);
// Same overshoot clamp as interpolateForGPU's buildMetric
// Same overshoot clamp as interpolateForGPU's buildMetric.
const lo = Math.min(...ys);
const hi = Math.max(...ys);
const costAt = (x: number) => Math.max(lo, Math.min(hi, hermiteInterpolate(xs, ys, slopes, x)));
const splineAt = (x: number) => Math.max(lo, Math.min(hi, hermiteInterpolate(xs, ys, slopes, x)));
const costAt = (x: number) =>
rate === null ? splineAt(x) : reciprocalMetricAt(rate, splineAt(x));

const minX = xs[0];
const maxX = xs.at(-1)!;
Expand Down Expand Up @@ -315,9 +421,28 @@ export function interpolateForGPU(
const value = buildMetric(getOutputValue);
const outputTputValue = buildMetric((p) => p.outputThroughput);
const inputTputValue = buildMetric((p) => p.inputThroughput);
const cost = buildMetric((p) => getCostField(p, costProvider, 'total'));
const costInput = buildMetric((p) => getCostField(p, costProvider, 'input'));
const costOutput = buildMetric((p) => getCostField(p, costProvider, 'output'));

// Cost is `$/GPU-hr / tokens` — a constant over a throughput — so it is
// derived from the interpolated throughput rather than splined itself. See
// `recoverReciprocalNumerator`. In throughput_to_interactivity mode the target
// axis *is* total throughput, so the clamped target is the value to divide by.
const totalTputAtTarget = mode === 'interactivity_to_throughput' ? value : clampedTarget;
const rate = recoverCostRate(sorted, costProvider);
// `rate === null` means these points do not obey the identity, so the metric
// is splined directly as before rather than rewritten from one point's ratio.
const cost =
rate === null
? buildMetric((p) => getCostField(p, costProvider, 'total'))
: reciprocalMetricAt(rate, totalTputAtTarget);
const costInput =
rate === null
? buildMetric((p) => getCostField(p, costProvider, 'input'))
: reciprocalMetricAt(rate, inputTputValue);
const costOutput =
rate === null
? buildMetric((p) => getCostField(p, costProvider, 'output'))
: reciprocalMetricAt(rate, outputTputValue);

const tpPerMw = buildMetric((p) => p.tpPerMw);
const inputTpPerMw = buildMetric((p) => p.inputTpPerMw);
const outputTpPerMw = buildMetric((p) => p.outputTpPerMw);
Expand Down
Loading