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
71 changes: 70 additions & 1 deletion internal/computing/earnings_history.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,70 @@ type EarningsSeries struct {
// by the tokens each model has actually served is the closest available
// approximation, and it is why this series is explicitly the node's own
// estimate rather than a statement of earnings.
// historyBlendedRate prices unattributed history from the history itself.
//
// Buckets recorded before the per-model column existed carry token counts but
// no split, so they can only be valued at some average rate. That average used
// to come from the live in-memory metrics — counters that reset on every
// restart. The result was that a finished day was re-priced whenever the
// process bounced or the model mix changed: one day holding a fixed 12,604,819
// tokens was reported at $7.83, $21.74 and $4.07 within a single hour, because
// only the multiplier had moved.
//
// Deriving the blend from the per-model splits stored *in the window* makes it
// a function of persisted data instead. It is still an estimate — the card says
// so — but it is a reproducible one: the same window prices the same way twice
// running, across restarts.
func historyBlendedRate(snapshots []HistoricalDataPoint, rates map[string]ModelPrice) (in, out float64) {
totals := map[string]ModelTokenCounts{}
prev := map[string]ModelTokenCounts{}
for _, s := range snapshots {
if s.ModelTokens == nil {
continue
}
for id, c := range s.ModelTokens {
p, seen := prev[id]
var dIn, dOut int64
switch {
case !seen:
// First sighting sets the baseline, exactly as the main loop
// does — counting the cumulative value here would weight the
// blend by traffic served before the window.
case c.In < p.In || c.Out < p.Out:
dIn, dOut = c.In, c.Out // restart
default:
dIn, dOut = c.In-p.In, c.Out-p.Out
}
t := totals[id]
t.In += dIn
t.Out += dOut
totals[id] = t
}
for id, c := range s.ModelTokens {
prev[id] = c
}
}

var tin, tout int64
for id, t := range totals {
r, ok := rates[id]
if !ok {
continue
}
in += float64(t.In) * r.ProviderInputPrice
out += float64(t.Out) * r.ProviderOutputPrice
tin += t.In
tout += t.Out
}
if tin > 0 {
in /= float64(tin)
}
if tout > 0 {
out /= float64(tout)
}
return in, out
}

func blendedRate(metrics *InferenceMetricsData, rates map[string]ModelPrice) (in, out float64) {
var tin, tout int64
for id, m := range metrics.ModelMetrics {
Expand Down Expand Up @@ -117,7 +181,12 @@ func CalculateEarningsHistory(ctx context.Context, snapshots []HistoricalDataPoi
rates = p
}
}
inRate, outRate := blendedRate(metrics, rates)
inRate, outRate := historyBlendedRate(snapshots, rates)
if inRate == 0 && outRate == 0 {
// No sample in the window carries a per-model split — every bucket
// predates the column. Fall back to the live mix, which is all there is.
inRate, outRate = blendedRate(metrics, rates)
}

var prevIn, prevOut int64
var prevPlatform *float64
Expand Down
42 changes: 42 additions & 0 deletions internal/computing/earnings_history_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,45 @@ func TestHistorySplitNeverExceedsBucketTotal(t *testing.T) {
}
}
}

// A finished bucket must not change value because the live counters moved.
//
// Buckets with no per-model split are priced at an average rate. That average
// used to come from the in-memory metrics, which reset on restart, so the same
// historical day was re-priced whenever the process bounced: one real day of
// 12.6M tokens read $7.83, $21.74 and $4.07 within an hour.
func TestHistoryPricingDoesNotDependOnLiveCounters(t *testing.T) {
withModels := func(min int, in, out int64, split map[string]ModelTokenCounts) HistoricalDataPoint {
p := pt(min, in, out)
p.ModelTokens = split
return p
}
// Two early samples carry no split (the pre-column history), two later ones
// do — the same shape as a real window.
snapshots := []HistoricalDataPoint{
pt(0, 1_000_000, 0),
pt(60, 3_000_000, 0),
withModels(120, 5_000_000, 0, map[string]ModelTokenCounts{"org/a": {In: 1_000_000}}),
withModels(180, 7_000_000, 0, map[string]ModelTokenCounts{"org/a": {In: 3_000_000}}),
}
rates := fakePrices{rates: map[string]ModelPrice{
"org/a": {ProviderInputPrice: 1.0},
"org/b": {ProviderInputPrice: 50.0},
}}

// The same window, priced against two very different live mixes. Before the
// fix the expensive mix inflated every unattributed bucket.
cheap := CalculateEarningsHistory(context.Background(), snapshots,
&InferenceMetricsData{ModelMetrics: map[string]*ModelMetrics{
"org/a": {TotalTokensIn: 1_000_000},
}}, rates, "30d", 0)
expensive := CalculateEarningsHistory(context.Background(), snapshots,
&InferenceMetricsData{ModelMetrics: map[string]*ModelMetrics{
"org/b": {TotalTokensIn: 9_000_000},
}}, rates, "30d", 0)

if cheap.TotalUSD != expensive.TotalUSD {
t.Errorf("the same window priced differently depending on the live model mix: %.6f vs %.6f",
cheap.TotalUSD, expensive.TotalUSD)
}
}
Loading
Loading