Budgets & Costs

Give each arm a cost, set a budget, and BanditDB spreads the spend across the whole window. Available from 2.3.0.

An unconstrained bandit spends wherever the expected reward is highest, as fast as traffic arrives. When an arm costs money or capacity — a phone call, a discount, a premium model — that front-loads the budget: it is gone by mid-morning and the most valuable requests of the day get nothing.

A pacing block turns a campaign into a budget-constrained one. Each resource has a budget, a horizon (how many decisions the budget must last) and a cost per arm. BanditDB keeps a shadow price for each resource: what one unit of that resource is currently worth. An arm is scored by its expected reward minus its cost at that price, so a costly arm wins only when its expected return clears the bar.

How the price moves

  • Spending faster than the target rate raises the price, so costly arms are chosen less often.
  • Spending slower lowers it, down to zero, where the campaign behaves as if unconstrained.
  • The target rate is the remaining budget over the remaining decisions (adaptive: true, the default), so a quiet morning leaves more for the afternoon. Set adaptive: false for a fixed rate of budget / horizon.
  • An arm that costs more than the remaining budget is masked and is not considered at all.

Scoring stays a single argmax per request: one multiply-add per arm and resource, no solver on the hot path.

Create a paced campaign

curl -X POST http://localhost:8080/campaign \
  -H "X-Api-Key: your-secret-key" \
  -H "Content-Type: application/json" \
  -d '{
    "campaign_id": "ad_channels",
    "arms": ["search", "social", "ai_ads"],
    "feature_dim": 4,
    "pacing": {
      "adaptive": true,
      "resources": [{
        "name": "ad_spend",
        "budget": 5000,
        "horizon": 20000,
        "arm_costs": { "search": 1.20, "social": 0.60, "ai_ads": 2.50 }
      }]
    }
  }'

predict and reward are unchanged. Pacing applies to every request on the campaign.

Several budgets at once

List more than one resource to enforce several limits together — for example total spend plus a cap on one channel. An arm is masked when any resource it uses runs out, and its score subtracts the price of every resource it consumes.

"pacing": {
  "resources": [
    { "name": "ad_spend",   "budget": 5000, "horizon": 20000,
      "arm_costs": { "search": 1.20, "social": 0.60, "ai_ads": 2.50 } },
    { "name": "ai_ads_cap", "budget": 800,  "horizon": 20000,
      "arm_costs": { "ai_ads": 2.50 } }
  ]
}

Resource fields

FieldRequiredDefaultDescription
nameYes—Unique, non-empty name for the resource.
budgetYes—Total amount available over the horizon. Finite and greater than 0.
horizonOptional10000Number of decisions the budget must last. Greater than 0.
arm_costsOptional{}Cost of one decision per arm, finite and ≥ 0. Arms not listed cost 0.
step_sizeOptionalautoHow fast the price reacts to over- or under-spending. Tuned from the horizon by default.
lambda_maxOptionalautoCeiling on the price. Defaults to max(1, 1 / cheapest non-zero cost).
initial_lambdaOptional0Starting price, e.g. carried over from a previous window.

Invalid values return 400 at campaign creation.

Adding an arm later

An arm added to a paced campaign takes its costs in the same request, keyed by resource name:

curl -X POST http://localhost:8080/campaign/ad_channels/arms \
  -H "X-Api-Key: your-secret-key" \
  -H "Content-Type: application/json" \
  -d '{ "arm_id": "newsletter", "costs": { "ad_spend": 0.15 } }'

Watching the budget

GET /campaign/:id/pacing reports each resource:

{
  "adaptive": true,
  "resources": [{
    "name": "ad_spend",
    "budget": 5000.0,
    "consumed": 1840.6,
    "remaining": 3159.4,
    "utilization": 0.368,
    "lambda": 0.41,
    "horizon": 20000,
    "decisions": 7310,
    "is_exhausted": false
  }]
}

The same figures are exported on /metrics, labelled by campaign and resource: banditdb_pacing_budget, banditdb_pacing_consumed, banditdb_pacing_remaining, banditdb_pacing_lambda and banditdb_pacing_utilization. Under multi-tenancy each tenant sees only its own campaigns.

Behaviours worth knowing

Budget is spent at predict, not at reward. A recommendation that is never acted on still consumes budget. Call predict only when you will act on the answer.

Budgets do not reset. Once a resource is spent, its costly arms stay masked until the campaign is recreated, and the pacing config cannot be changed after creation. For a daily budget, create a campaign per window (optionally passing the last window's price as initial_lambda). If every arm is masked, predict returns 400 with “no eligible arms”, so keep at least one zero-cost arm, such as “do nothing”, as a fallback.

Check your arm_costs keys. They are not validated against the campaign's arms. A misspelt arm name leaves the real arm at cost 0, so it is never paced.

Durability. Each paced predict writes one extra WAL record, so spend survives a restart. If the WAL cannot accept it, the predict returns 503. Campaigns without pacing are unaffected.

Do not downgrade below 2.3.0 with paced campaigns. Older binaries ignore the pacing field, so constrained campaigns silently become unconstrained.