On this page

ConceptsContexts

Contexts

Contexts let you pass per-request information to qbrix so that contextual policies (LinUCB, LinTS) can personalize selections. Instead of finding a single best arm for everyone, contextual policies learn which arm works best for each type of request.

You describe a request by naming what you know about itdevice, country, cart_value — against a schema you declare once when you create the experiment. qbrix turns those names into the numeric vector the policy needs. You never build that vector yourself.

When to use contexts

ScenarioWithout ContextWith Context
Homepage heroSame best image for all usersDifferent image by user segment
Pricing pageOne price for everyonePersonalized pricing by geography/behavior
RecommendationsGlobal best itemPer-user recommendations
Ad placementSame ad for allTargeted ads by user features

Use contexts when different users (or different situations) should see different variants. If you just want to find the single best variant overall, stochastic policies without context are simpler and sufficient.

Context object

FieldTypeRequiredDescription
idstringYesIdentifies the request source (e.g., user ID, session ID). Used by feature gates for deterministic rollout.
propertiesobjectFor contextual policiesNamed values encoded server-side against the experiment's declared schema.
metadataobjectNoArbitrary key-value pairs. Used by feature gate rules for targeting. Never seen by the policy.
vectorfloat[]NoPre-encoded feature vector. The escape hatch for callers who already hold their own embeddings — see When the raw vector is the right choice.

properties and vector are two ways to say the same thing, and sending both is an error. Pick one per experiment.

Declaring a context schema

The schema is a list of properties. Each one declares a name, a type, and whatever that type needs to encode a value.

TypeDeclaresWidthEncoding
categoricalvalues — the values you expectlen(values) + 1One-hot, plus a trailing other slot
numericmin and max1Min-max normalized into [0, 1], clamped to the range
booleannothing11.0 or 0.0

Step 1: create an experiment with a schema

CONTEXT_SCHEMA = [
    {"type": "categorical", "name": "device", "values": ["mobile", "desktop"]},
    {"type": "categorical", "name": "country", "values": ["US", "DE", "TR"]},
    {"type": "numeric", "name": "cart_value", "min": 0, "max": 500},
    {"type": "boolean", "name": "returning"},
]
 
experiment = client.experiment.create(
    name="checkout-personalization",
    pool_id=pool.id,
    policy="LinUCBPolicy",
    policy_params={"alpha": 1.5, "context_schema": CONTEXT_SCHEMA},
)

Step 2: send named values on each selection

from qbrix import Context
 
result = client.agent.select(
    experiment_id=experiment.id,
    context=Context(
        id="user-42",
        properties={
            "device": "mobile",
            "country": "US",
            "cart_value": 62.5,
            "returning": False,
        },
        metadata={"plan": "pro"},
    ),
)

Width is derived, never supplied

The width of the vector — dim — is computed from the schema and stored on the experiment. You must not send it yourself. Sending context_schema and dim together is a 400: two sources for one number is how a contextual experiment silently starts training on the wrong shape.

The schema above derives a width of 11:

Width
reserved baseline slot1
device — 2 values + other3
country — 3 values + other4
cart_value1
returning1
total11

The reserved baseline slot

Every schema carries one slot you do not declare and cannot override. Contextual policies fit θₐᵀx with no bias term, so without it an all-zero encoding predicts exactly 0 for every variant however much each has learned, and selection falls through to the exploration term alone.

It is why a schema whose properties sum to 10 reports a width of 11. Nothing is wrong.

The other slot

Every categorical carries a trailing slot for values the schema never declared. A country of "JP" against the schema above is scored as other rather than rejected.

This is deliberate, and it is the reason qbrix uses a declared schema rather than feature hashing: your traffic is allowed to surprise you without invalidating anything. A new device type appearing in production does not change the width, does not break selection, and does not throw away what the model has learned.

Keep the width small

Width is capped at 64. LinUCB and LinTS invert a dim × dim matrix per arm on every selection, so width is paid on the hot path of every request, forever.

A high-cardinality property is where this bites: country with 190 values is 191 slots on its own. Bucket it — continent, or your top five markets plus other — and you keep most of the signal for a fraction of the width. The console's schema builder shows the running total as you add properties.

What the encoder accepts

The type rules are strict in one direction and forgiving in the other, deliberately.

You sendDeclared asResult
"mobile"categoricalok
3categoricalok, encoded as "3" — JSON callers legitimately send category codes as numbers
truecategoricalok, encoded as "true"
3.0categorical400"3.0" and "3" are not obviously the same category
20 or 20.5numericok
"20"numeric400 — a caller bug, not a JSON artifact
600 where max is 500numericclamped to 500, not rejected
a value a categorical never declaredcategoricalencoded into the other slot
a property you declared but omittedtakes its default segment
a name the schema never declared400
Careful

That last row is the one worth internalising. {"devise": "mobile"} — one transposed letter — is rejected with 400 INVALID_CONTEXT_PROPERTIES, naming the undeclared property and listing the ones the schema does declare.

It is strict because the alternative is worse: a typo would otherwise encode to defaults on every request, the model would train against a constant vector, and nothing anywhere would tell you. By the time allocation looked flat, the training data would already be spoiled.

Omitting properties is fine

A schema-backed experiment that receives no properties at all serves the default vector — every categorical at other, every numeric at its declared midpoint, every boolean false. That is a real point in feature space, so it selects and trains normally rather than failing.

Declare ranges at your actual operating range

For numeric properties, declare min and max where your traffic actually sits, not where it theoretically could.

A cart_value declared 0–500 whose real traffic sits between 10 and 30 compresses all of the signal into 4% of the range, and the policy has almost nothing to separate. Declaring 10–30 and letting the clamp absorb the tail is strictly better and costs nothing — outliers are clamped, not rejected.

The schema is fixed at creation

Like a pool's arms and an experiment's policy, the schema is bound for the life of the experiment. The learned parameters have its width baked into their shape, so changing it would invalidate everything trained so far.

Changing it on PATCH returns 409 CONTEXT_SCHEMA_IMMUTABLE. So does omitting it — policy_params is replaced wholesale, so silence would drop the schema.

To tune another parameter, resend the schema unchanged alongside it:

client.experiment.update(
    experiment.id,
    policy_params={
        "alpha": 2.5,
        "context_schema": experiment.policy_params["context_schema"],
    },
)

To change the shape, create a new experiment.

Tip

The stored schema is canonical, not verbatim. A min you sent as 0 comes back as 0.0. The API has not mangled your input — it normalized it so that a later comparison is not defeated by JSON number typing.

Context ID

The id field identifies the request source. Every selection carries one, and it drives:

  • Feature gates: Rollout percentage uses a hash of the context ID for deterministic assignment. The same user always gets the same gate decision.
  • Debugging: Trace which user saw which arm.
  • Deduplication: Identify repeated requests from the same source.
# use a stable user identifier
context = Context(id="user-42")
 
# or a session ID if users aren't authenticated
context = Context(id="session-abc123")

Context metadata

Metadata is a free-form JSON object for feature gate targeting. It is not used by the policy — only by the rules engine.

This is the distinction that costs people the most time. Both properties and metadata live on the context object and they go to different places:

FieldWho reads it
propertiesThe policy. This is what the model learns on.
metadataFeature gate rules only. The policy never sees it.

Putting {"device": "mobile"} in metadata and expecting the model to personalize on it does nothing. It has to be a declared property.

context = Context(
    id="user-42",
    properties={"device": "mobile", "country": "US"},
    metadata={"plan": "pro"},
)

Feature gate rules can then target based on the metadata fields:

{
  "key": "plan",
  "operator": "in",
  "value": ["pro", "enterprise"],
  "arm_id": "<arm-id>"
}

See Feature gates for the full targeting operator reference.

When the raw vector is the right choice

vector is not legacy and it is not deprecated. It is the specialist path, permanently supported, and for some inputs it is the only correct answer.

Use properties when

Your features are things you can name and enumerate: device type, country, plan, price, tenure bucket, whether someone is a returning visitor. This is almost everything, and it is what the console, the SDKs and the schema are built around.

Use vector when

CaseWhy properties cannot serve it
You already hold a learned embedding — a sentence-transformer output, a user or item embedding from your own modelThe values are not nameable scalars. There is no schema that describes 384 anonymous dimensions, and one-hot/normalize is the wrong operation for them
The feature is a derived quantity you compute — a similarity score, a model prediction, a PCA component, an RFM scoreYou could declare it numeric, and often should. Reach for vector when you have several and re-declaring each adds nothing
You are migrating an existing contextual experiment and need the encoding to stay byte-identicalAny schema-derived encoding would differ from what the learned parameters were fitted on

What you give up

Choosing vector means qbrix stops doing these things for you:

  • No other slot. An unseen value has nowhere to go; handling it is yours.
  • No reserved baseline slot. If you want an intercept, include a constant yourself.
  • No clamping, no normalization, no defaults. An absent vector is a 400, not a fallback.
  • Slot ordering is yours to keep stable, forever, across every call site. Position 0 must mean the same thing on every call, in every service, indefinitely. Reordering silently invalidates everything learned so far.

That last one is the honest cost, and it is the failure that motivated declared schemas in the first place.

Using a vector

Create the experiment with dim instead of a schema, and send vector instead of properties:

experiment = client.experiment.create(
    name="embedding-personalized",
    pool_id=pool.id,
    policy="LinUCBPolicy",
    policy_params={"alpha": 1.5, "dim": 384},
)
 
result = client.agent.select(
    experiment_id=experiment.id,
    context=Context(id="user-42", vector=embedding),
)
Careful

The vector length must exactly match dim. Anything else is rejected with 400 INVALID_CONTEXT_VECTOR, and the message names both widths: context.vector has width 3, experiment expects 4.

Omitting vector on a dim-only experiment is treated as width 0 and rejected the same way. Without a schema there is no principled default, so the request fails rather than quietly selecting as if it were non-contextual. This differs from a schema-backed experiment, which does have a default — see Omitting properties is fine.

Sending a vector to a non-contextual experiment stays valid; it is ignored.

Using context with stochastic policies

Stochastic policies (BetaTS, UCB1Tuned, etc.) ignore context features — they don't personalize per request. You can still pass id and metadata for feature gate functionality:

# BetaTSPolicy ignores properties, but gates can use id + metadata
result = client.agent.select(
    experiment_id="<experiment-id>",
    context={"id": "user-42", "metadata": {"country": "US"}},
)

Choosing between stochastic and contextual

QuestionStochasticContextual
Do different user types prefer different variants?No / Don't knowYes
Do you know something about the request at selection time?NoYes
How many properties?N/A3-6 is ideal
Data volume?Works with little dataNeeds more data to learn per-context patterns
Complexity?SimpleDeclare a schema once
Tip

Start with a stochastic policy (BetaTSPolicy) to validate your setup. Once you confirm the feedback loop works, switch to a contextual policy to unlock personalization.


What's next