# Forecasting spend

A banded month-end projection on the seasonal frame the detector uses, and the fallback it drops to rather than band a thin history.

Source: https://tokentriage.com/docs/build/cost-forecasting

---
import Callout from '../../../components/Callout.astro';
import Details from '../../../components/Details.astro';
import NextSteps from '../../../components/NextSteps.astro';

Somebody needs a number for the month. Fifteen days in, you have spent $95 and the obvious
answer is $190, which assumes the second half of a month looks like the first, that weekends
count the same as Tuesdays, and that a single line is a fair summary of a thing that varies.

TokenTriage forecasts on the same seasonal frame the anomaly detector uses, and reports a band
rather than a line. The interesting engineering here is not the projection. It is what happens
when the history is too thin to support one.

Forecasting is part of `analytics` and is off by default.

## A point is a range

Every series entry is three numbers, not one:

```json
{
  "series": [
    { "ts": "2026-08-10T11:00:00.000Z", "p10": 0.002853908626941494, "p50": 0.03703725, "p90": 0.11708063420609244 },
    { "ts": "2026-08-10T12:00:00.000Z", "p10": 0.0026229359950841816, "p50": 0.03403975, "p90": 0.10760506026275801 }
  ],
  "method": "seasonal-quantile-v1",
  "insufficient": false
}
```

The `p50` is the seasonal point: the median of that same hour of the week over the trailing four
weeks. The `p10` and `p90` are the band around it. A single line for a quantity that genuinely
varies is a claim the data cannot support, so it is not offered.

Month-end is actual-to-date plus the sum of the remaining point forecasts, and it arrives as an
assessed figure with a confidence interval attached:

```json
{
  "month_end": {
    "usd": 95.27025408,
    "verdict": "ESTIMATED",
    "basis": { "method": "seasonal-quantile-v1", "rows": 320 },
    "baseline": { "kind": "projected-spend", "desc": "projected from your recorded traffic patterns; not a savings claim" },
    "ci": { "lo_usd": 0, "hi_usd": 212.33142020445464, "level": 0.8 }
  }
}
```

Note the baseline. `projected-spend` is the one baseline kind in the registry that is explicitly
**not** a savings claim, and the sentence beside the dollar says so. A forecast is a projection
of spend rather than of a saving, and it cannot be `PROVEN`: it is `ESTIMATED` by
construction, because a forecast is a model of traffic that has not happened.

Those figures are from a sandbox against mock upstreams, so the amounts are small; the shape is
what a production ledger produces.

## How the band is built

The obvious way to band a seasonal forecast is to take residual quantiles per hour-of-week. With
four weeks of history that gives each hour-of-week a pool of about four numbers, and the 10th
percentile of four numbers is the smallest of them.

That band would be "the worst of the last four Tuesdays at 9am to the best of them". It is the
observed range rather than an interval, it gets *narrower* as history gets thinner, and it would
be exceeded about a fifth of the time while claiming 80% coverage.

So residuals are **pooled across every hour** in the trailing window, after being scale-
normalised, each residual divided by its own point and floored at a cent. Normalising is what
makes pooling legitimate: a $40 hour and a $0.40 hour have wildly different absolute residuals
and comparable relative ones. The pool is then hundreds of samples instead of four, and its
P10 and P90 mean something.

The floor exists for a specific failure: a relative residual against a near-zero point is
unbounded, and one $2 hour where the seasonal median was a tenth of a cent would blow the band
out for every hour in the horizon.

## Not enough history is a state, not a guess

The banded method needs **4 populated weeks**. Below that it does not narrow its band and it does
not quietly extrapolate. It declines, and a labelled linear fallback answers instead.

```json
{
  "month_end": {
    "usd": 0.003094686616443937,
    "verdict": "ESTIMATED",
    "basis": { "method": "linear-burn-v1", "rows": 1 },
    "baseline": { "kind": "projected-spend", "desc": "projected from your recorded traffic patterns; not a savings claim" }
  },
  "method": "linear-burn-v1",
  "insufficient": true,
  "insufficient_reason": "1 populated day of history (1 week populated); seasonal-quantile-v1 needs 4 populated weeks to band a forecast",
  "burn": { "usd_per_hour": 1.6553571428571427e-06 }
}
```

That is a genuinely new deployment (one day of history, six requests) answering the question
anyway, with a burn rate, a named method, and a sentence saying what it would need to do better.

<Callout type="key">
The fallback announces itself in three places at once: the `method` id changes, `insufficient`
flips true, and the reason names the bar it fell short of. It also carries **no confidence
interval**, because the linear method has no calibrated dispersion. Inventing a band to keep
the response shape uniform would be the dishonest half of a degradation.
</Callout>

A point forecast also needs at least **2** populated same-hour-of-week samples. A median of one
sample is that sample, which is a week-over-week carry-forward wearing a seasonal method's name.

## Priced spend only

A forecast covers spend the ledger could price. Rows it could not are excluded rather than
folded in at zero, and their trend is disclosed alongside rather than merged:

```json
{
  "unpriced_note": {
    "rows_per_day": 0.9285714285714286,
    "trend": "flat",
    "note": "this forecast covers PRICED spend; these rows could not be priced and are excluded, not counted as $0"
  }
}
```

Read the trend. A flat unpriced rate means your forecast's coverage is stable and the projection
is as good as its method. A *rising* unpriced rate means the forecast is covering a shrinking
share of your actual traffic, and the projection is drifting low for a reason that has nothing to
do with the forecasting method.

## What can go wrong

**You quote the `p50` as the forecast.** It is the middle of a band that was published because
the middle alone is not the answer. Quote the range, or quote the month-end figure with its
interval.

**You compare a forecast to an invoice.** The forecast covers priced spend; the invoice covers
everything. If your unpriced row count is non-trivial, they are measuring different populations.
[Reconciliation](/docs/build/cost-reconciliation) is the surface for that comparison.

**The band looks implausibly wide in week five.** Four populated weeks is the minimum for the
banded method rather than a comfortable amount for it. The interval will tighten as the residual
pool grows.

<Details summary="Why the same seasonal frame as the detector">

Both the forecaster and the anomaly detector build a same-hour-of-week baseline over a trailing
four weeks. That is not a coincidence of implementation. It means a spike the detector flags
and an hour the forecaster expected are computed against the same notion of normal.

The two answer opposite questions with one model of your traffic: the detector asks whether an
hour that already happened broke the pattern, and the forecaster asks what the pattern says about
an hour that has not.

</Details>

<NextSteps items={[
  {title: "Anomaly detection", href: "/docs/build/anomaly-detection", description: "The same seasonal baseline pointed backwards: which past hour broke the pattern, and what drove it."},
  {title: "How to read a cost figure", href: "/docs/build/cost-figures", description: "What ESTIMATED licenses, and why a forecast cannot be more than that."},
  {title: "Chargeback, showback and FOCUS", href: "/docs/build/cost-exports", description: "Turn actuals into a report finance can reconcile against their own systems."},
  { title: "Reading cost figures", href: "/docs/build/cost-figures", description: "Check the source, baseline, and limitations behind a displayed cost figure." },
]} />
