View Markdown

Turn on outcome learning

On this page

What you get at the end: a group name that starts as a fair contest between models and drifts, on its own, toward whichever one actually serves your traffic best - measured by latency, cost, and whatever your application reports back.

The response examples below were recorded in an earlier live run. The Compose invocation now follows the repository-root quickstart; this documentation refresh does not represent a new provider run.

What you are turning on#

With weighted-random you choose the split and the gateway obeys it forever. The bandit strategy inverts that: every response is scored, the scores update a per-model belief, and the next request is routed by sampling from those beliefs. Models that keep doing well get chosen more; models that keep timing out get chosen less; and a small exploration floor (5 percent by default) keeps every model in the game so a recovered model can win its way back.

Three signals feed the score, and two of them cost you nothing: latency and cost are measured on every response automatically. The third - did the answer actually help - only your application knows, and there is an endpoint for telling the gateway. Learning works without it, on speed and price alone; it works better with it.

Prerequisites#

Two processes and one flag:

bash
POINTSMAN_BANDIT_ENABLED=true

and the policy service (the learner) running - the compose stack starts it as policy-service. Run the learner single-worker (WEB_CONCURRENCY=1): its posteriors live in process memory, and two workers would each hold half the truth.

Run the Compose commands below from the repository root after the quickstart.

Define a bandit group#

Same mechanism as any traffic split, different strategy:

bash
SPRING_APPLICATION_JSON='{"pointsman":{"routing":{"groups":{
  "chat-tier":{"strategy":"bandit","candidates":[
    {"provider":"openai","model":"gpt-4o-mini"},
    {"provider":"deepseek","model":"deepseek-chat"},
    {"provider":"anthropic","model":"claude-sonnet-4-5"}]}}}}}' \
  docker compose --env-file docker/.env -f docker/docker-compose.yml up -d gateway

No weights - the whole point is that the gateway finds them. Candidates start as equals (a uniform prior), which means the first stretch of traffic is deliberately a coin flip. Expect exploration, and do not point a latency-sensitive production path at a fresh bandit group; let it learn on traffic that can tolerate variety, or seed it (below).

Send traffic, keep the decision id#

bash
curl -si localhost:8080/v1/chat/completions \
  -H "Authorization: Bearer $YOUR_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"model":"chat-tier","messages":[{"role":"user","content":"say ok"}]}'
text
X-Served-Provider: deepseek
X-Decision-Id: 677b7c2d-af1b-4693-a255-d230d5b77fe9
X-Decision-Receipt: v1.Z3ctZDcKdGVuYW50LTQyOmNvZGUK...

X-Decision-Id appears only when the learning router made the choice - it is the receipt your application holds so it can report how the answer turned out. If you do not see it, the request did not go through a bandit group, and the place to look is the group's strategy.

X-Decision-Receipt is the same decision, signed. Either value works as decision_id below.

Attribute feedback across instances#

: the bare id is correlated from memory held by the instance that decided, so behind a balancer a rating reaches it one time in N, and the rest used to be dropped. The receipt carries what the learner needs, so any instance can attribute it. It is signed rather than encrypted, it is only usable by the key that made the decision, and it expires with the same fifteen-minute window as the id.

Latency and cost for this response are already on their way to the learner at this point. You have participated in learning by doing nothing.

Report how it went#

When your application knows the answer was good or bad - the user accepted the draft, the JSON parsed, the customer clicked retry - post the verdict with the receipt:

bash
curl -s -X POST localhost:8080/v1/feedback \
  -H "Authorization: Bearer $YOUR_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"decision_id":"677b7c2d-af1b-4693-a255-d230d5b77fe9","rating":1}'
json
{"accepted": true}

rating is exactly 1 or -1. There is no scale, deliberately: "on a scale of one to ten" answers are noise between integrations, thumbs are comparable.

Feedback is rate-limited to 5 per key per minute by default, and that is a security control, not a throughput bug. A rating revises the reward of the decision it names, and the quality preference decides how far: at a high preference one rating can swing that decision from a success to a failure. It never makes the decision count as more than one observation, but an unthrottled endpoint would still let a single key re-score every decision it made. If a legitimate integration rates every answer - an agent loop, a batch evaluator - raise POINTSMAN_BANDIT_FEEDBACK_RPM_LIMIT for real, but know that you are raising the poisoning ceiling by the same factor.

To withdraw a rating, send {"decision_id": ..., "retract": true, "revision": <higher>} with no rating: the decision returns to the reward it had before it was rated. A rating of -1 is a rating, not a withdrawal.

Feedback revisions#

The learner applies a revision only above the one it holds, and the decision log does the same. A delayed retry of an older rating, or an older withdrawal that arrives late, changes nothing. A second operation with the same revision is a retry if it says the same thing, and is ignored as a conflict if it says something else. The decision log's feedback_score, feedback_revision and feedback_state (rated or withdrawn; GET /admin/ops/decisions) record what you submitted and the gateway accepted. They do not say the learner applied it: a learner that was down, or that kept a rate decision's only rating (retraction_without_base), keeps its own receipt. Rows rated before 18 October 2026 have no revision or state, and the first revisioned operation replaces them.

A 503 from this endpoint means the bandit is not enabled - the endpoint tells you about the misconfiguration rather than silently swallowing ratings.

Watch it learn#

The gateway's belief surface, live:

bash
curl -s -u "$POINTSMAN_ADMIN_USERNAME:$POINTSMAN_ADMIN_PASSWORD" \
  localhost:8080/admin/bandit/state | jq

Example state values (these are illustrative observations, not comparative model-quality scores):

json
{"arm": "deepseek:deepseek-chat",  "mean": 0.867, "live_observations": 470.7},
{"arm": "openai:gpt-4o-mini",      "mean": 0.867, "live_observations": 445.5},
{"arm": "anthropic:claude-sonnet-4-5", "mean": 0.670, "live_observations": 193.2},
{"arm": "kimi:kimi-k2.6",          "mean": 0.119, "live_observations": 211.7}

(Since the learning semantics ts2, 17 October 2026, each workload also reports learning_semantics, and each arm effective_mass, observed_decisions and rated_decisions: a rating revises its decision, so rated decisions are a subset of the observed ones, never extra evidence. After the upgrade every workload starts from fair coin flips under its new |ls=ts2 key; the old beliefs are kept, frozen, under their ts1 keys.)

Read mean as the learned score and live_observations as how much evidence it rests on (what alpha and beta are, and why the sum matters as much as the ratio: the bandit, explained). This snapshot shows the thing working: two models tied at the top and taking most of the traffic, one clearly losing and being visited just often enough to notice if it improves. Beliefs are kept per tenant and per task type (the sampling_key), so one tenant's code-heavy traffic does not teach the router the wrong lesson about another tenant's chat.

What "fast" means is yours to define#

The latency score is measured against POINTSMAN_BANDIT_REWARD_LATENCY_TARGET_MS, default 2000. At that default, a 120ms model and a 500ms model both score essentially perfect - the learner will see no latency difference between them and decide on cost and feedback alone. If latency differences in that range matter to you, lower the target to the number above which you consider a response slow. This is the single most common reason a bandit "is not learning": it is learning, and the signals genuinely do not differ under your definition.

If the learner dies#

Nothing happens to your traffic. The gateway samples from beliefs it holds in its own process, refreshed from the learner in the background (every 10 seconds by default) - there is no learner call on the request path. With the learner down, routing continues from the last fetched beliefs (or from fair coin flips if it never fetched any), requests keep flowing, and learning resumes when the learner returns. GET /admin/bandit/state reports fetched, version and age_seconds, which is where staleness shows.

Starting warm instead of cold#

If you already know roughly which models are good - from an offline evaluation, or from another environment - the learner accepts seeded priors so the fair-coin-flip phase starts informed instead of ignorant. Seeded evidence is tracked separately from live evidence (seeded_observations vs live_observations above) so you can always see how much of a belief is inherited versus earned. The surface is the learner's own POST /policy/seed - deliberately an operator endpoint rather than a startup step, so seeding is a decision someone makes with data they chose, and strength states how many observations the prior should count for.

Pointsman v1 candidate documentation Source: 0e09f5e7

Search guides, reference pages and architecture.

Documentation

Open full-size image