Skip to content

Commit 8f98a97

Browse files
committed
fix(admin): the cost fold prorates on the requested window, and three what-if honesty fixes (issue #175)
Four corrections to the cost analytics, the first insights slice: - foldCosts gains sinceMs and prorates on the REQUESTED window, never the span the records happen to cover: a sparse window shrank the denominator and flipped plan verdicts to SAVING. COSTS_WINDOWS and costsSinceMs move into costs.mjs beside the fold so the scan cutoff and the denominator cannot drift; window.firstRunMs rides along for renderers that want the observed left edge, and daily buckets still start at the first observed run so the sparkline keeps its density. - byFlow rows carry flowKey (null for the no-flow bucket) and the what-if filters by it: the "(no flow)" display label matched no record, so a fully ledgered bucket rendered the seeded band. - The ledger's other/other overflow row leaves the what-if shortlist: it is unpriceable, so offering it silently degraded the estimate to the seeded band. - buildProvenance counts truncated ledgers (usage.truncated was persisted by the record contract and read by nothing); the TUI and text provenance lines render the count only when nonzero. Specs in the same PR: REQ-COST-ANALYTICS amended, acceptance rows only, the lettered labeling rules (a)-(g) untouched; DES-COST-FOLD-BY-SCAN amended with the first-run derivation moved to Rejected as a refuted correction. REQ-TOKEN-ACCOUNTING-AND-CAPS UNCHANGED, checked (the truncated field was always in the contract; only the reader changed). INT-RUN-HISTORY-FILE-CONTRACT UNCHANGED, checked. docs/costs.md example arithmetic recomputed under the honest denominator. Suite green in the CI posture: 2182 tests, 0 skipped, live Valkey. Signed-off-by: Rob Boerman <robboerman@live.nl>
1 parent ec1d821 commit 8f98a97

9 files changed

Lines changed: 227 additions & 53 deletions

File tree

admin/src/costs.mjs

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,23 @@ export const COST_CLASSES = ["metered", "plan", "zero-rated", "estimated", "seed
3030
const DAY_MS = 24 * 60 * 60 * 1000;
3131
const HOUR_MS = 60 * 60 * 1000;
3232

33+
/** The spend windows every costs surface offers, in `t`-cycle order. */
34+
export const COSTS_WINDOWS = ["7d", "30d", "mtd"];
35+
36+
/**
37+
* A window's inclusive start: 7d/30d count back from now; mtd is the start of the current UTC month.
38+
* This lives HERE, beside the fold, because proration denominates on the requested window: the scan
39+
* cutoff and the fold's `sinceMs` must be the same instant, and two implementations of "where does
40+
* mtd start" (the drift this module's dayKey import already guards against at day grain) once let the
41+
* command and the view disagree by a whole month edge.
42+
*/
43+
export function costsSinceMs(windowKey, nowMs) {
44+
if (windowKey === "7d") return nowMs - 7 * DAY_MS;
45+
if (windowKey === "30d") return nowMs - 30 * DAY_MS;
46+
const now = new Date(nowMs);
47+
return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1);
48+
}
49+
3350
/** The ledger row fields that form a reprice quad -- the full cache split, exactly as recorded. */
3451
const QUAD_KEYS = ["input", "output", "cacheRead", "cacheWrite", "cacheWrite1h"];
3552

@@ -218,7 +235,7 @@ function repriceRows(rows, counterfactual, pricing, { extraExcluded = 0 } = {})
218235
* read-model the costs view renders. Everything below is derived per call and thrown away -- nothing
219236
* classified here is ever written anywhere.
220237
*/
221-
export function foldCosts({ records, subscriptions, pricing, nowMs, piAiPin = null }) {
238+
export function foldCosts({ records, subscriptions, pricing, nowMs, piAiPin = null, sinceMs = null }) {
222239
const subs = Array.isArray(subscriptions) ? subscriptions : [];
223240
const runs = [];
224241
for (const record of Array.isArray(records) ? records : []) {
@@ -229,13 +246,25 @@ export function foldCosts({ records, subscriptions, pricing, nowMs, piAiPin = nu
229246
}
230247
runs.sort((a, b) => a.at - b.at);
231248

232-
const fromMs = runs.length > 0 ? runs[0].at : nowMs;
249+
// The window the caller ASKED about versus the window the records happen to span. Proration must
250+
// denominate on the requested window (`sinceMs`, the same instant the caller cut the scan at):
251+
// deriving days from the first observed run understates plan cost on any sparse window -- two runs
252+
// yesterday against a month-to-date question made every verdict read SAVING -- so that derivation
253+
// survives only for callers that fold an arbitrary record set with no window to ask about.
254+
const firstRunMs = runs.length > 0 ? runs[0].at : null;
255+
const requested = Number.isFinite(sinceMs);
256+
const fromMs = requested ? Math.min(sinceMs, nowMs) : (firstRunMs ?? nowMs);
233257
const toMs = nowMs;
234-
const days = runs.length > 0 ? Math.max(1, Math.ceil((toMs - fromMs) / DAY_MS)) : 0;
258+
const days = requested || runs.length > 0 ? Math.max(1, Math.ceil((toMs - fromMs) / DAY_MS)) : 0;
235259

236260
return {
237-
window: { fromMs, toMs, days },
238-
daily: buildDaily(runs, fromMs, toMs),
261+
// `firstRunMs` rides along (null when nothing ran) so a renderer can pad or trim the daily series
262+
// against the requested edge without re-deriving what the fold already knows.
263+
window: { fromMs, toMs, days, firstRunMs },
264+
// Daily buckets still start at the first observed run, NOT the requested edge: the sparkline's
265+
// gap-day discipline covers interior quiet days, but a month of leading zeros on a young
266+
// deployment would compress the visible history into the last few cells.
267+
daily: buildDaily(runs, firstRunMs ?? fromMs, toMs),
239268
byFlow: buildByFlow(runs, subs, pricing),
240269
byModel: buildByModel(runs),
241270
plans: buildPlans(runs, subs, pricing, days),
@@ -288,6 +317,11 @@ function buildByFlow(runs, subs, pricing) {
288317
}
289318
byFlow.push({
290319
flow,
320+
// The machine key beside the display label: null for the no-flow bucket, the raw flow name
321+
// otherwise. The display label once leaked into the what-if filter, where `"(no flow)"` matches
322+
// no record and a fully ledgered bucket rendered the seeded band -- key and label never share a
323+
// field again (issue #175).
324+
flowKey: flow === "(no flow)" ? null : flow,
291325
runs: members.length,
292326
tokens: members.reduce((sum, m) => sum + (m.record.tokens?.total ?? 0), 0),
293327
cost: combineContributions(members.map((m) => m.contribution)),
@@ -488,11 +522,16 @@ function peakWindow(w, attributed) {
488522
function buildProvenance(runs, piAiPin) {
489523
let runsUnmetered = 0;
490524
let runsUnledgered = 0;
525+
let runsLedgerTruncated = 0;
491526
let ratesDrifted = 0;
492527
for (const { record } of runs) {
493528
if (!record.tokens) runsUnmetered += 1;
494529
// An empty ledger degrades exactly like an absent one -- the flat totals are all the reader has.
495530
else if (!(record.usage && Array.isArray(record.usage.models) && record.usage.models.length > 0)) runsUnledgered += 1;
531+
// The meter caps the ledger at 8 named rows and folds the rest into `other` (usage.truncated,
532+
// INT-RUN-HISTORY-FILE-CONTRACT). Such a run's per-model attribution is partly anonymous, and a
533+
// fold that states unmetered and unledgered runs but not this one would be selectively honest.
534+
if ((record.usage?.truncated ?? 0) > 0) runsLedgerTruncated += 1;
496535
const piAi = record.usage?.piAi ?? null;
497536
// Drift needs BOTH versions: an unknown pin cannot accuse a record of having been priced elsewhere.
498537
if (piAi !== null && piAiPin !== null && piAi !== piAiPin) ratesDrifted += 1;
@@ -502,6 +541,7 @@ function buildProvenance(runs, piAiPin) {
502541
runsTotal: runs.length,
503542
runsUnmetered,
504543
runsUnledgered,
544+
runsLedgerTruncated,
505545
ratesDrifted,
506546
piAiPin,
507547
};

admin/src/dashboard.ts

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import { buildGraphModel } from "./graph-model.mjs";
3636
import { matchesKey } from "./keys.mjs";
3737
import { box, meter, clip, fmtUsd, makeLineInput } from "./panel.mjs";
3838
import { makeStyler, frame, RULE } from "./style.mjs";
39-
import { foldCosts, whatIfFlow } from "./costs.mjs";
39+
import { COSTS_WINDOWS, costsSinceMs, foldCosts, whatIfFlow } from "./costs.mjs";
4040

4141
const KEY_HINTS = "[p]ause [r]esume [q]uit";
4242
// Fetch the read-model's full window (listRuns clamps at 50) but render a cursor-following viewport of
@@ -56,11 +56,11 @@ const MIN_WIDTH = 8;
5656
// Drill-in views (TRIGGER_DETAIL, RUN_DETAIL) are small; they frame to a compact width and center within
5757
// the wider overlay rather than stretching a handful of key/value lines across the full LIST width.
5858
const DRILL_WIDTH = 70;
59-
// COSTS: the spend windows `t` cycles through, and the staleness bound the poll tick refreshes against.
60-
// The fold itself is cheap, but the scan behind fetchCosts reads EVERY run sidecar in the window -- a
61-
// per-second full-directory read is the kind of quiet load a dashboard must not add, so while the view
62-
// is open a poll tick refreshes the fold only once the last fetch is older than this.
63-
const COSTS_WINDOWS = ["7d", "30d", "mtd"];
59+
// COSTS: the staleness bound the poll tick refreshes against (the `t`-cycled windows themselves are
60+
// costs.mjs' COSTS_WINDOWS -- one list beside the fold whose proration they denominate). The fold is
61+
// cheap, but the scan behind fetchCosts reads EVERY run sidecar in the window -- a per-second
62+
// full-directory read is the kind of quiet load a dashboard must not add, so while the view is open a
63+
// poll tick refreshes the fold only once the last fetch is older than this.
6464
const COSTS_STALE_MS = 10_000;
6565
// GRAPH: the cursor-following row window, on the RUNS_VIEWPORT/TAIL_VIEWPORT precedent -- a fixed
6666
// bound with no height dependency, so an unknown terminal height changes nothing. The graph REFRESHES
@@ -133,11 +133,12 @@ export function createDashboardDeps(paths: any) {
133133
*/
134134
fetchCosts({ windowKey }: any) {
135135
const nowMs = Date.now();
136-
const records = scanRunRecords({ logsDir: paths.logsDir, sinceMs: costsWindowSinceMs(windowKey, nowMs), nowMs });
136+
const sinceMs = costsSinceMs(windowKey, nowMs);
137+
const records = scanRunRecords({ logsDir: paths.logsDir, sinceMs, nowMs });
137138
if (!Array.isArray(records)) return { unreachable: (records as any)?.unreachable ?? "scan failed" };
138139
const subsView: any = readSubscriptions({ subscriptionsPath: paths.subscriptionsPath });
139140
const subscriptions = Array.isArray(subsView?.subscriptions) ? subsView.subscriptions : [];
140-
const fold = foldCosts({ records, subscriptions, pricing, nowMs, piAiPin: pricing.piAiVersion() });
141+
const fold = foldCosts({ records, subscriptions, pricing, nowMs, piAiPin: pricing.piAiVersion(), sinceMs });
141142
return { fold, records, subscriptions };
142143
},
143144
/**
@@ -353,7 +354,7 @@ export function makeDashboard({
353354
wi.target = wi.targets[wi.index] ?? null;
354355
wi.result =
355356
wi.target !== null && typeof deps?.whatIf === "function"
356-
? deps.whatIf({ records: costs.data?.records ?? [], flow: wi.flow, target: wi.target })
357+
? deps.whatIf({ records: costs.data?.records ?? [], flow: wi.flowKey, target: wi.target })
357358
: null;
358359
};
359360

@@ -716,7 +717,10 @@ export function makeDashboard({
716717
const row = (costs.data?.fold?.byFlow ?? [])[costsSel];
717718
const targets = whatIfTargets(costs.data);
718719
if (!row || targets.length === 0) return;
719-
costs.whatIf = { flow: row.flow, targets, index: 0, filter: false, input: null };
720+
// flowKey is the MACHINE key (null for the no-flow bucket -- whatIfFlow matches `flow ?? null`,
721+
// and the "(no flow)" display label matches no record); flowLabel is what the header prints.
722+
// The `in` check keeps a fold without flowKey working: for real flows the two are identical.
723+
costs.whatIf = { flowKey: "flowKey" in row ? row.flowKey : row.flow, flowLabel: row.flow, targets, index: 0, filter: false, input: null };
720724
computeWhatIf();
721725
tui?.requestRender?.();
722726
return;
@@ -1789,17 +1793,6 @@ function graphHints(inner: number, styler: any): string {
17891793

17901794
// ── COSTS view (issue #53): full-width like LIST, every body line exactly `inner` visible columns ──────
17911795

1792-
/** The scan cutoff for a COSTS window key: a fixed span for "7d"/"30d", the start of the current UTC
1793-
* month for "mtd" -- the calendar month is the unit subscriptions are priced in, so "how is this month
1794-
* going" needs the month's own left edge, not a sliding 30 days. */
1795-
function costsWindowSinceMs(windowKey: string, nowMs: number): number {
1796-
const day = 24 * 60 * 60 * 1000;
1797-
if (windowKey === "7d") return nowMs - 7 * day;
1798-
if (windowKey === "30d") return nowMs - 30 * day;
1799-
const now = new Date(nowMs);
1800-
return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1);
1801-
}
1802-
18031796
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
18041797

18051798
/** The frame-title window label: `Aug 2026 (mtd)` for the calendar window, `last 7d`/`last 30d` else. */
@@ -1825,6 +1818,9 @@ function whatIfTargets(data: any): any[] {
18251818
const add = (provider: any, id: any) => {
18261819
if (typeof provider !== "string" || typeof id !== "string") return;
18271820
if (provider === "unknown" || id === "unknown") return;
1821+
// The ledger's 8-row overflow bucket is an aggregation artifact, not a model: getPricedModel of
1822+
// ("other","other") is null, so offering it would silently degrade the estimate to the seeded band.
1823+
if (provider === "other" && id === "other") return;
18281824
const key = `${provider}/${id}`;
18291825
if (seen.has(key)) return;
18301826
seen.add(key);
@@ -2043,7 +2039,7 @@ function costsTableLines(fold: any, table: string, costsSel: number, inner: numb
20432039
function whatIfLines(wi: any, data: any, inner: number, styler: any): string[] {
20442040
const out: string[] = [];
20452041
const t = wi.target;
2046-
const header = styler.bold(styler.fg("accent", `WHAT-IF ${wi.flow}${t ? `${t.provider}/${t.id}` : "?"}`)) +
2042+
const header = styler.bold(styler.fg("accent", `WHAT-IF ${wi.flowLabel}${t ? `${t.provider}/${t.id}` : "?"}`)) +
20472043
styler.fg("dim", ` (${wi.index + 1}/${wi.targets.length} · w next · / search)`);
20482044
out.push(fitLine(header, inner, styler));
20492045
const r = wi.result;
@@ -2054,11 +2050,11 @@ function whatIfLines(wi: any, data: any, inner: number, styler: any): string[] {
20542050
// like an answer. The note names the tracking id so the band cannot pass as a measurement.
20552051
out.push(fitLine(styler.fg("warning", `~~${fmtUsd(r.low)}${fmtUsd(r.high)} seeded`) + styler.fg("dim", ` · ${r.note ?? "unmeasured (OQ-002)"}`), inner, styler));
20562052
} else {
2057-
const current = (data?.fold?.byFlow ?? []).find((x: any) => x.flow === wi.flow)?.cost?.usd;
2053+
const current = (data?.fold?.byFlow ?? []).find((x: any) => x.flow === wi.flowLabel)?.cost?.usd;
20582054
const delta = typeof current === "number" ? r.usd - current : null;
20592055
const deltaPart = delta === null ? "" : ` (${delta <= 0 ? "−" : "+"}${fmtUsd(Math.abs(delta))} vs current)`;
20602056
out.push(fitLine(styler.fmtCost({ usd: r.usd, class: "estimated", floor: false }) + styler.fg("dim", `${deltaPart} · rates@${r.ratesVersion ?? "?"}`), inner, styler));
2061-
const dom = dominantProvider(data?.records, wi.flow);
2057+
const dom = dominantProvider(data?.records, wi.flowKey);
20622058
if (t && dom !== null && t.provider !== dom) {
20632059
out.push(fitLine(styler.fg("dim", "cross-provider: same token profile, tokenizers differ — directional only"), inner, styler));
20642060
}
@@ -2105,6 +2101,9 @@ function provenanceLine(fold: any, inner: number, styler: any): string {
21052101
const prov = fold.provenance ?? {};
21062102
let text = `~ estimates at pi-ai ${prov.piAiPin ?? "?"} · ${prov.runsUnmetered ?? 0} runs unmetered · ${prov.runsUnledgered ?? 0} not repriceable`;
21072103
if ((prov.ratesDrifted ?? 0) > 0) text += ` · ${prov.ratesDrifted} priced under older rates`;
2104+
// Only when it happened, like the drift counter: a fanout past the 8-row ledger cap loses per-model
2105+
// attribution, and the by-model table must not look complete while rows hide inside `other`.
2106+
if ((prov.runsLedgerTruncated ?? 0) > 0) text += ` · ${prov.runsLedgerTruncated} ledgers truncated`;
21082107
return fitLine(styler.fg("dim", text), inner, styler);
21092108
}
21102109

admin/src/index.ts

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ import { applyDeploymentPointer, pointerPath, readPointer, takePointerNotice } f
9797
// The only fs use in this module: the skew notice reads one package.json through the wizard's own reader.
9898
// Everything else fs-shaped goes through read-model.mjs by design.
9999
import * as nodeFs from "node:fs";
100-
import { foldCosts, whatIfFlow } from "./costs.mjs";
100+
import { COSTS_WINDOWS, costsSinceMs, foldCosts, whatIfFlow } from "./costs.mjs";
101101
// The REAL pricing façade. costs.mjs may not hold a module-scope worker/pricing import by contract (the
102102
// fold is pure; tests inject a canned fake) -- index.ts is where the fs-adjacent assembly lives, so the
103103
// injection happens here.
@@ -1007,25 +1007,15 @@ async function dispatch(pi: ExtensionAPI, args: string, ctx: any): Promise<void>
10071007
}
10081008

10091009
// ---- the costs command surface (issue #53) ----
1010-
1011-
const COSTS_WINDOWS = ["7d", "30d", "mtd"];
1010+
// COSTS_WINDOWS and costsSinceMs live in costs.mjs beside the fold: proration denominates on the
1011+
// requested window, so the scan cutoff and the fold's sinceMs must come from the one function.
10121012

10131013
const COSTS_USAGE = "usage: /dispatch costs [7d|30d|mtd] | /dispatch costs whatif <provider/model> --flow <flow>";
10141014

1015-
const DAY_MS = 24 * 60 * 60 * 1000;
1016-
10171015
// The pricing façade in the injectable shape the pure fold expects (costs.mjs takes `pricing` as an
10181016
// argument by contract; the tests hand it a canned fake, this object is the real one).
10191017
const PRICING = { listPricedModels, getPricedModel, isZeroRated, reprice, piAiVersion };
10201018

1021-
/** A window's inclusive start: 7d/30d count back from now; mtd is the start of the current UTC month. */
1022-
function costsSinceMs(window: string, nowMs: number): number {
1023-
if (window === "7d") return nowMs - 7 * DAY_MS;
1024-
if (window === "30d") return nowMs - 30 * DAY_MS;
1025-
const now = new Date(nowMs);
1026-
return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1);
1027-
}
1028-
10291019
/**
10301020
* Assemble the costs fold exactly as the dashboard view does: scan the run records for the window, read
10311021
* the declared subscriptions (a missing/invalid file degrades to none -- the fold then simply has no plans
@@ -1035,7 +1025,8 @@ function costsSinceMs(window: string, nowMs: number): number {
10351025
*/
10361026
function assembleCosts(paths: any, window: string, flow?: string): any {
10371027
const nowMs = Date.now();
1038-
const records = scanRunRecords({ logsDir: paths.logsDir, sinceMs: costsSinceMs(window, nowMs), nowMs });
1028+
const sinceMs = costsSinceMs(window, nowMs);
1029+
const records = scanRunRecords({ logsDir: paths.logsDir, sinceMs, nowMs });
10391030
if (!Array.isArray(records)) return records; // { unreachable }
10401031
const subs: any = readSubscriptions({ subscriptionsPath: paths.subscriptionsPath });
10411032
const scoped = typeof flow === "string" && flow !== "" ? records.filter((r: any) => (r?.flow ?? null) === flow) : records;
@@ -1045,6 +1036,7 @@ function assembleCosts(paths: any, window: string, flow?: string): any {
10451036
pricing: PRICING,
10461037
nowMs,
10471038
piAiPin: piAiVersion(),
1039+
sinceMs, // the same instant the scan cut at -- proration denominates on the requested window
10481040
});
10491041
return { fold };
10501042
}

admin/src/render.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,9 @@ function windowFacts(w) {
331331
function provenanceCounts(prov) {
332332
const parts = [`unmetered ${prov.runsUnmetered ?? 0}`, `no ledger (not re-priceable) ${prov.runsUnledgered ?? 0}`];
333333
if ((prov.ratesDrifted ?? 0) > 0) parts.push(`rates drifted ${prov.ratesDrifted}`);
334+
// Only when it happened (the drift pattern): runs whose ledger folded rows into `other` past the
335+
// 8-row cap -- their per-model attribution is partly anonymous, and silence would claim otherwise.
336+
if ((prov.runsLedgerTruncated ?? 0) > 0) parts.push(`ledgers truncated ${prov.runsLedgerTruncated}`);
334337
return parts.join(" · ");
335338
}
336339

0 commit comments

Comments
 (0)