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
11 changes: 10 additions & 1 deletion internal/computing/earnings_history.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,16 @@ func CalculateEarningsHistory(ctx context.Context, snapshots []HistoricalDataPoi
//
// The consequence is worth being explicit about: the bar's height is
// the platform's number, the division of it is this node's estimate.
if authoritative && attributed > 0 {
//
// A local bucket needs the same treatment whenever its split outruns
// its own total. `usd` differences the aggregate counter while the
// split differences each model's counter, and the two can disagree —
// a restart, or a model removed mid-interval, leaves per-model deltas
// summing above the aggregate one. Clamping `unattributed` at zero
// hid that but left the inflated split in place, so the segments
// summed to more than the bar: on this node one daily bucket showed
// $2.30 of models inside a $1.64 bar.
if attributed > 0 && (authoritative || attributed > usd) {
scale := usd / attributed
for id, m := range perModel {
m.USD *= scale
Expand Down
37 changes: 37 additions & 0 deletions internal/computing/earnings_history_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,40 @@ func TestBucketSizeDoesNotChangeTheTotal(t *testing.T) {
t.Errorf("restarts = %d aggregated vs %d fine", daily.Restarts, fine.Restarts)
}
}

// A bucket's segments must never sum to more than the bar they are drawn in.
//
// The aggregate counter and the per-model counters are differenced separately,
// so they can disagree. Here the aggregate advances by 1M tokens while the
// per-model split advances by 3M — the shape a model removed and re-added
// mid-interval produces. Clamping Unattributed at zero hid the excess but left
// the inflated split in place, so the segments summed above the bucket total.
func TestHistorySplitNeverExceedsBucketTotal(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
}

s := CalculateEarningsHistory(context.Background(),
[]HistoricalDataPoint{
withModels(0, 1_000_000, 0, map[string]ModelTokenCounts{"org/a": {In: 1_000_000}}),
// Aggregate +1M, but the split claims +3M.
withModels(60, 2_000_000, 0, map[string]ModelTokenCounts{"org/a": {In: 4_000_000}}),
},
oneModel(2_000_000, 0),
fakePrices{rates: map[string]ModelPrice{"org/a": {ProviderInputPrice: 1.0}}},
"24h", 0)

for _, p := range s.Points {
var attributed float64
for _, m := range p.Models {
attributed += m.USD
}
// Float arithmetic, so allow a tolerance rather than compare exactly.
if attributed+p.Unattributed > p.USD+1e-9 {
t.Errorf("bucket %s: models %.6f + unattributed %.6f exceeds the bucket total %.6f",
p.Timestamp.Format(time.RFC3339), attributed, p.Unattributed, p.USD)
}
}
}

Large diffs are not rendered by default.

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions internal/dashboard/ui/dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
<meta name="theme-color" content="#020617" />
<meta name="description" content="Swan Chain computing provider operations dashboard" />
<title>Swan Provider Console</title>
<script type="module" crossorigin src="/assets/index-DxUNGQDF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-MZFUSUjx.css">
<script type="module" crossorigin src="/assets/index-DzkPy8Xw.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B0m40DGl.css">
</head>
<body>
<div id="root"></div>
Expand Down
58 changes: 48 additions & 10 deletions internal/dashboard/ui/src/components/EarningsChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
buildModelColours,
colourFor,
} from '../lib/modelPalette';
import type { EarningsPoint, ModelEarnings } from '../types';
import type { EarningsPoint, ModelEarnings, ModelEarningsPoint } from '../types';

interface EarningsChartProps {
/**
Expand Down Expand Up @@ -144,9 +144,46 @@ export function EarningsChart({ models }: EarningsChartProps) {
const authoritativePoints = data?.authoritative_points ?? 0;
const allAuthoritative = points.length > 0 && authoritativePoints === points.length;
const peak = points.reduce((m, p) => Math.max(m, p.usd), 0);
const activeIndex = hovered ?? (points.length > 0 ? points.length - 1 : null);
const activeIndex = hovered;
const active = activeIndex !== null ? points[activeIndex] : null;
const activeSegments = active ? segmentsFor(active, colours) : [];

/**
* Sum every interval in the window into one pseudo-point.
*
* This is what the panel shows when nothing is hovered. It previously fell
* back to the newest bucket, so a 24-hour window described its most recent
* hour: on a node serving six models over the day but two in the last hour,
* four models were missing from a panel that appeared to describe the day.
* The heading already says "in this window", and the figures underneath it
* have to agree with that.
*/
const windowTotals = useMemo(() => {
const models: Record<string, ModelEarningsPoint> = {};
let usd = 0;
let tokensIn = 0;
let tokensOut = 0;
let unattributed = 0;
for (const p of points) {
usd += p.usd;
tokensIn += p.tokens_in;
tokensOut += p.tokens_out;
unattributed += p.unattributed ?? 0;
for (const [model, m] of Object.entries(p.models ?? {})) {
const acc = (models[model] ??= { tokens_in: 0, tokens_out: 0, usd: 0 });
acc.tokens_in += m.tokens_in;
acc.tokens_out += m.tokens_out;
acc.usd += m.usd;
}
}
return { timestamp: '', usd, tokens_in: tokensIn, tokens_out: tokensOut, models, unattributed };
}, [points]);

// Hovering a bar describes that interval; otherwise the whole window.
const summary: EarningsPoint | null = active ?? (points.length > 0 ? windowTotals : null);
const summaryLabel = active
? formatBucket(active.timestamp, bucketSeconds, true)
: (WINDOWS.find((w) => w.id === window_)?.label ?? window_);
const activeSegments = summary ? segmentsFor(summary, colours) : [];

// Which models actually appear anywhere in this window — the legend should
// name what is on screen, not every model the node has ever served.
Expand Down Expand Up @@ -214,11 +251,14 @@ export function EarningsChart({ models }: EarningsChartProps) {
latest interval is selected initially so this space is useful
before the operator interacts with the chart. */}
<div className="mb-2 h-36" aria-live="polite">
{active ? (
{summary ? (
<div className="text-xs">
<div className="flex items-baseline gap-2">
<span className="font-mono text-sm text-white">{formatUSD(active.usd)}</span>
<span className="text-slate-400">{formatBucket(active.timestamp, bucketSeconds, true)}</span>
<span className="font-mono text-sm text-white">{formatUSD(summary.usd)}</span>
<span className="text-slate-400">{summaryLabel}</span>
{!active && points.length > 0 && (
<span className="text-slate-500">· hover a bar for one interval</span>
)}
{hovered === null && <span className="ml-auto text-slate-400">Latest interval</span>}
</div>
{activeSegments.length > 0 ? (
Expand All @@ -242,15 +282,13 @@ export function EarningsChart({ models }: EarningsChartProps) {
</ul>
) : (
<div className="mt-1 text-slate-400">
{formatTokens(active.tokens_in)} in / {formatTokens(active.tokens_out)} out
{formatTokens(summary.tokens_in)} in / {formatTokens(summary.tokens_out)} out
<span className="ml-2 text-slate-400">— recorded before the per-model split</span>
</div>
)}
</div>
) : (
<div className="text-xs text-slate-400">
Hover a bar for its models and usage. {points.length} intervals shown.
</div>
<div className="text-xs text-slate-400">No earnings recorded in this window.</div>
)}
</div>

Expand Down
Loading