Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
3895482
fix stale savedGroups in global context: assign before set_features
madhuchavva Sep 1, 2026
4194512
add contextual bandit payload types and rule/experiment/result fields
madhuchavva Sep 1, 2026
7117a82
evaluate contextual bandit rules in core
madhuchavva Sep 1, 2026
4f6c032
plumb contextualBandits through sync and async clients
madhuchavva Sep 1, 2026
d0aa55b
test: sync cases.json to spec 0.8.0 and wire the contextualBandit corpus
madhuchavva Sep 1, 2026
a408c05
test: contextual bandit ingestion, encryption, and tracking coverage
madhuchavva Sep 1, 2026
0db670b
assign maps to global context before features in set_features
madhuchavva Sep 1, 2026
01ab957
async client: partial updates no longer wipe savedGroups/contextualBa…
madhuchavva Sep 1, 2026
5ddfc9f
snapshot user attributes for tracking and feature-usage callbacks
madhuchavva Sep 1, 2026
a08af56
rename CBContext to ContextualBanditAssignment
madhuchavva Sep 2, 2026
81042d9
type the contextual bandit payload map as Dict[str, ContextualBanditD…
madhuchavva Sep 2, 2026
fb25a82
codegen: infer types from contextualVariations, accept contextualBand…
madhuchavva Sep 2, 2026
ec497bf
test: pin contextual bandit fields in the typing suite
madhuchavva Sep 2, 2026
4b4a9ef
preserve absent payload sections across repository refreshes
madhuchavva Sep 2, 2026
fe70718
add set_payload to both clients for seeding full SDK payloads
madhuchavva Sep 2, 2026
d85083b
log contextual bandit fallback paths at debug level (JS parity)
madhuchavva Sep 2, 2026
e95e2d4
snapshot tracking user context in _track, not per-eval in core
madhuchavva Sep 2, 2026
abbd24b
append contextual bandit params after existing signature positions
madhuchavva Sep 4, 2026
1cb73cd
publish one coherent eval snapshot per refresh, even for map-only pay…
madhuchavva Sep 4, 2026
646249b
tolerate malformed contextual bandit definitions and leaves in core
madhuchavva Sep 4, 2026
ccc9a2c
drop encrypted payload keys after failed decryption (JS decryptPayloa…
madhuchavva Sep 4, 2026
ac7e773
set_payload accepts encrypted payload sections in both clients (JS pa…
madhuchavva Sep 4, 2026
d5727e1
send exposure-time user context attributes with experiment ingestor e…
madhuchavva Sep 4, 2026
0c00c70
doc: contextual bandit README section and changelog entries
madhuchavva Sep 4, 2026
06d9dc4
validate bandit leaf weight vectors before substituting them for buck…
madhuchavva Sep 4, 2026
a548ba8
reject unusable bandit leaf weights; report the vector bucketing actu…
madhuchavva Sep 4, 2026
9862a2d
preserve contextual bandit fields through remote-eval rule.tracks res…
madhuchavva Sep 4, 2026
6980754
serialize payload writers so concurrent updates can't publish mixed g…
madhuchavva Sep 4, 2026
6c93f8c
append contextual bandit params after deprecated parameters too
madhuchavva Sep 4, 2026
2cbfb0b
freeze user attributes at the async eval boundary; deep-copy tracking…
madhuchavva Sep 4, 2026
4ee9a1f
doc: changelog entries for the review-round fixes
madhuchavva Sep 4, 2026
e1e07cb
snapshot all eval inputs, and only when the async eval can actually y…
madhuchavva Sep 4, 2026
a3eaf69
clear unusable aggregate and override weights on bandit rules
madhuchavva Sep 4, 2026
ae3db9a
doc: changelog for snapshot gating and weight sanitization
madhuchavva Sep 4, 2026
daec77d
unify weight-vector validation: one rule for bucketing and bandit pro…
madhuchavva Sep 4, 2026
46de7d5
make weight-vector validation total: oversized ints no longer crash b…
madhuchavva Sep 4, 2026
3bbbcbb
validate bandit leafId and banditVersion as integers at the payload b…
madhuchavva Sep 4, 2026
6bccdc8
snapshot the user context in preload_remote_eval via a shared freeze …
madhuchavva Sep 4, 2026
ce7bff8
drop bandit metadata when explicit ranges govern bucketing
madhuchavva Sep 4, 2026
d769d7b
validate rule.tracks bandit metadata with the local evaluation rules
madhuchavva Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# Changelog

## Unreleased

### Features

* Contextual bandit support in both clients, at behavioral parity with the JavaScript SDK:
* Consumes the `contextualBandits` payload section (and `encryptedContextualBandits`), evaluates `contextualBanditRef`/`contextualVariations` rules with per-leaf weight substitution, and reports `leafId`, `variationWeights`, and `banditVersion` on experiment results for exposure logging.
* New `set_payload()` on `GrowthBook` and `GrowthBookClient` for seeding full SDK payloads; only the sections present are overwritten, and encrypted sections are decrypted with the configured `decryption_key` (JS `setPayload` semantics).
* Payload refreshes missing a section (`savedGroups`, `contextualBandits`) preserve the previous value at every layer instead of wiping it; the synchronous client serializes payload writers and publishes one coherent evaluation snapshot per update (evaluations stay lock-free), matching the async client.
* Malformed bandit definitions or leaves degrade to the rule's aggregate weights instead of raising during evaluation; reported `variationWeights` always match the weights bucketing actually used, and a single, total validity rule governs every weight vector: `getBucketRanges` normalizes vectors with negative, non-finite, boolean, non-numeric, or float-overflowing entries (not just wrong length/sum) to equal weights — never raising, even on arbitrary-precision integers, so bucket ranges can never be inverted, and bandit leaf/aggregate/override propensities always describe the vector actually used. Bandit identifiers get the same treatment: a leaf with a non-integer `leafId` is malformed (aggregate-weights fallback), and a non-integer `banditVersion` is omitted, so exposure metadata never carries invalid attribution ids into bandit training. A rule that pairs `contextualBanditRef` with explicit `ranges` buckets on the ranges (unchanged, matching the JS SDK) but drops the bandit metadata, since leaf propensities cannot describe a ranges-governed assignment.
* Contextual bandit exposure metadata survives remote evaluation: `rule.tracks` results from the proxy keep `leafId`, `variationWeights`, and `banditVersion` when replayed through the tracking callback, held to the same validity rules as locally evaluated exposures (invalid identifiers or weight vectors are dropped, not forwarded).
* Async evaluations that perform I/O (remote eval or a sticky bucket service) freeze every mutable evaluation input — attributes, groups, overrides, forced variations and features, nested containers included — before their first await, so mutating a `UserContext` mid-flight cannot change leaf routing, remote payloads, or forced assignments. Plain CDN evaluations never yield and skip the copy entirely; deferred callbacks get fire-time snapshots in both clients. `preload_remote_eval` takes the same call-time snapshot, so mutating the context after preloading can no longer cache one attribute state's response under another's key via the stale-while-revalidate background refresh.

### Bug Fixes

* `savedGroups` from a feature refresh were applied to the evaluation context one refresh late in the synchronous client.
* The built-in tracking plugin now sends the exposure-time user context attributes with experiment events (previously async client events had none).

## [3.0.0](https://github.com/growthbook/growthbook-python/compare/v2.4.0...v3.0.0) (2026-08-26)


Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,34 @@ Behaviors to be aware of:

With `GrowthBookClient`, the `on_experiment_viewed` and `on_feature_usage` options — and callbacks registered via `client.subscribe()` — may be either regular functions or coroutines. Coroutine callbacks are scheduled on the event loop without blocking evaluation, and a tracking callback that raises is retried on the next evaluation of the same experiment/user pair.

## Contextual Bandits

Contextual bandit experiments (GrowthBook Enterprise) learn which variation works best for each kind of user. All learning happens server-side: GrowthBook trains a model that partitions users into leaves and computes per-leaf variation weights. The SDK routes each user to the first leaf whose condition matches their attributes and buckets them with that leaf's weights — there is no model or sampling client-side.

Support is automatic once features are loaded from the GrowthBook API. Payloads for contextual bandit experiments contain:

- A top-level `contextualBandits` map (or `encryptedContextualBandits`, decrypted with your `decryption_key` like the other encrypted sections) holding each bandit's `banditVersion` and its per-leaf `condition`/`weights`.
- Feature rules carrying `contextualBanditRef` (which bandit to use) and `contextualVariations` (the variation values).

Payloads can also be seeded or updated manually. `set_payload` (both clients) overwrites only the sections present, so a payload without `contextualBandits` preserves the current map — the same semantics background refreshes use:

```python
gb.set_payload({
"features": {...},
"contextualBandits": {...}, # omitted sections are preserved
})
```

When a user is exposed through a contextual bandit rule, the experiment `Result` (including the result passed to `on_experiment_viewed`) carries three extra fields:

- **leafId** — the matched leaf (`-1` when no leaf matched and the rule's aggregate weights were used)
- **variationWeights** — the weight vector actually used for bucketing (the assignment propensities)
- **banditVersion** — the version of the bandit model that produced the weights

Log these to your data warehouse in your tracking callback — along with the `user_context` attributes used at exposure time — so GrowthBook can train the bandit and attribute exposures to the right model version. Because weights change as the bandit learns, users may be re-bucketed during the experiment; sticky bucketing is disabled server-side for these rules and analysis attributes each user to their first exposure.

Older SDK versions ignore contextual bandit rules entirely and serve the feature's default value.

## Inline Experiments

Instead of declaring all features up-front and referencing them by ids in your code, you can also just run an experiment directly. This is done with the `run` method:
Expand Down
10 changes: 9 additions & 1 deletion growthbook/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@
feature_repo,
)

from .common_types import AbstractAsyncStickyBucketService
from .common_types import (
AbstractAsyncStickyBucketService,
ContextualBanditAssignment,
ContextualBanditContext,
ContextualBanditDefinition,
)

from .growthbook_client import (
GrowthBookClient,
Expand Down Expand Up @@ -63,6 +68,9 @@
"Feature",
"FeatureResult",
"FeatureRule",
"ContextualBanditAssignment",
"ContextualBanditContext",
"ContextualBanditDefinition",
# Typing helpers
"JSONValue",
"TrackingCallback",
Expand Down
20 changes: 16 additions & 4 deletions growthbook/codegen.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,16 @@ def python_type_for(default_value: Any) -> str:
# endpoint payload from a bare {feature_key: definition} map that happens to
# contain a feature literally named "features".
_ENDPOINT_KEYS = frozenset(
{"features", "savedGroups", "encryptedFeatures", "encryptedSavedGroups", "dateUpdated", "status"}
{
"features",
"savedGroups",
"contextualBandits",
"encryptedFeatures",
"encryptedSavedGroups",
"encryptedContextualBandits",
"dateUpdated",
"status",
}
)


Expand Down Expand Up @@ -93,9 +102,12 @@ def infer_feature_type(definition: Any) -> str:
for rule in definition.get("rules") or []:
if isinstance(rule, dict):
candidates.append(rule.get("force"))
variations = rule.get("variations")
if isinstance(variations, list):
candidates.extend(variations)
# Contextual bandit rules carry their values under
# contextualVariations instead of variations.
for key in ("variations", "contextualVariations"):
variations = rule.get(key)
if isinstance(variations, list):
candidates.extend(variations)
types = {python_type_for(v) for v in candidates if v is not None}
if len(types) == 1:
return types.pop()
Expand Down
109 changes: 108 additions & 1 deletion growthbook/common_types.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env python

from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from typing import (
TYPE_CHECKING,
Any,
Expand Down Expand Up @@ -56,6 +56,33 @@ class Filter(TypedDict, total=False):
hashVersion: int
attribute: str


# Contextual bandit payload types. The server ships a top-level
# "contextualBandits" map keyed by bandit id; each entry holds per-leaf
# weight vectors computed server-side (Thompson sampling within decision-tree
# leaves). The SDK only picks the first leaf whose condition matches and
# buckets with that leaf's weights — no model evaluation happens client-side.

class ContextualBanditContext(TypedDict, total=False):
leafId: Required[int]
condition: Dict[str, Any]
weights: Required[List[float]]


class ContextualBanditDefinition(TypedDict, total=False):
banditVersion: int
contexts: List[ContextualBanditContext]


# Assignment metadata attached to an Experiment/Result for a contextual
# bandit rule. banditVersion is omitted (never None) when the definition
# doesn't carry one — serialization must match the JS SDK byte-for-byte.
class ContextualBanditAssignment(TypedDict, total=False):
leafId: Required[int]
variationWeights: Required[List[float]]
banditVersion: int


class Experiment(Generic[T]):
def __init__(
self,
Expand Down Expand Up @@ -85,6 +112,7 @@ def __init__(
minBucketVersion: Optional[int] = None,
parentConditions: Optional[List[Dict[str, Any]]] = None,
customFields: Optional[Dict[str, Any]] = None,
contextualBandit: Optional[ContextualBanditAssignment] = None,
# NoReturn makes literal unknown kwargs a checker error (like TS excess
# property checks) while **dict payload splats (typed Any) still pass;
# at runtime unknown payload keys are swallowed as before.
Expand Down Expand Up @@ -113,6 +141,7 @@ def __init__(
# Custom Fields defined for the experiment in the GrowthBook UI.
# Arrives from the API as a flat dict (e.g. {"cfl_abc123": "value"}).
self.customFields = customFields or {}
self.contextualBandit = contextualBandit

self.fallbackAttribute = None
if not self.disableStickyBucketing:
Expand Down Expand Up @@ -156,6 +185,8 @@ def to_dict(self) -> Dict[str, Any]:
obj["parentConditions"] = self.parentConditions
if self.customFields:
obj["customFields"] = self.customFields
if self.contextualBandit is not None:
obj["contextualBandit"] = self.contextualBandit

return obj

Expand Down Expand Up @@ -194,6 +225,9 @@ def __init__(
meta: Optional[VariationMeta] = None,
bucket: Optional[float] = None,
stickyBucketUsed: bool = False,
leafId: Optional[int] = None,
variationWeights: Optional[List[float]] = None,
banditVersion: Optional[int] = None,
) -> None:
self.variationId = variationId
self.inExperiment = inExperiment
Expand All @@ -204,6 +238,11 @@ def __init__(
self.featureId = featureId or None
self.bucket = bucket
self.stickyBucketUsed = stickyBucketUsed
# Contextual bandit exposure metadata; set only for real hashed
# exposures of a contextual bandit rule.
self.leafId = leafId
self.variationWeights = variationWeights
self.banditVersion = banditVersion

self.key = str(variationId)
self.name = ""
Expand Down Expand Up @@ -236,6 +275,14 @@ def to_dict(self) -> Dict[str, Any]:
obj["name"] = self.name
if self.passthrough:
obj["passthrough"] = True
# The fallback leafId is -1, so these must be gated on None, not
# truthiness.
if self.leafId is not None:
obj["leafId"] = self.leafId
if self.variationWeights is not None:
obj["variationWeights"] = self.variationWeights
if self.banditVersion is not None:
obj["banditVersion"] = self.banditVersion

return obj

Expand Down Expand Up @@ -312,6 +359,8 @@ def __init__(
minBucketVersion: Optional[int] = None,
parentConditions: Optional[List[Dict[str, Any]]] = None,
tracks: Optional[List[Dict[str, Any]]] = None,
contextualBanditRef: Optional[str] = None,
contextualVariations: Optional[List[Any]] = None,
# See Experiment.__init__: checker-strict, runtime-permissive.
**_ignored: NoReturn,
) -> None:
Expand Down Expand Up @@ -344,6 +393,11 @@ def __init__(
# Remote-eval rules carry pre-evaluated experiment tracking events on
# the force branch; see _fireRuleTracks in core.py.
self.tracks = tracks
# Contextual bandit rules carry their variations under
# contextualVariations (capability-gated key) so bandit-unaware SDKs
# skip the rule instead of bucketing on stale weights.
self.contextualBanditRef = contextualBanditRef
self.contextualVariations = contextualVariations

def to_dict(self) -> Dict[str, Any]:
data: Dict[str, Any] = {}
Expand Down Expand Up @@ -393,6 +447,10 @@ def to_dict(self) -> Dict[str, Any]:
data["parentConditions"] = self.parentConditions
if self.tracks:
data["tracks"] = self.tracks
if self.contextualBanditRef:
data["contextualBanditRef"] = self.contextualBanditRef
if self.contextualVariations is not None:
data["contextualVariations"] = self.contextualVariations

return data

Expand Down Expand Up @@ -529,6 +587,51 @@ def __call__(
]


def snapshot_attributes(attributes: Dict[str, Any]) -> Dict[str, Any]:
"""Recursive copy of a JSON-compatible attributes dict (dicts and lists
are copied, scalars shared). Freezes the values used for bucketing and
contextual bandit leaf routing so later caller mutations — including
nested ones — can't change what an in-flight evaluation or a deferred
callback observes."""
def copy_value(value: Any) -> Any:
if isinstance(value, dict):
return {k: copy_value(v) for k, v in value.items()}
if isinstance(value, list):
return [copy_value(v) for v in value]
return value

return {k: copy_value(v) for k, v in attributes.items()}


def snapshot_user_context(user: "UserContext") -> "UserContext":
"""Call-time snapshot of every mutable evaluation input on a UserContext.

The single freeze operation used wherever a context's data outlives the
caller's synchronous control flow: evaluations that await (remote-eval
fetch, sticky-bucket I/O) and remote-eval cache preloads, whose POST body
can be serialized by a background SWR refresh long after the caller has
moved on. Without it, a later caller mutation could change the request
body while the cache key still describes the original attribute state."""
return replace(
user,
attributes=snapshot_attributes(user.attributes),
groups=snapshot_attributes(user.groups),
forced_variations=snapshot_attributes(user.forced_variations),
forced_features=snapshot_attributes(user.forced_features),
overrides=snapshot_attributes(user.overrides),
)


def tracking_user_context(user: "UserContext") -> "UserContext":
"""Exposure-time snapshot of a user context for tracking and
feature-usage callbacks (JS SDK: getTrackingUserContext).

Attributes are copied recursively (the JS SDK only shallow-spreads;
Python's threaded callers make nested mutation of a deferred callback's
payload a realistic hazard, so this diverges deliberately)."""
return replace(user, attributes=snapshot_attributes(user.attributes))


@dataclass
class Options:
url: Optional[str] = None
Expand Down Expand Up @@ -573,6 +676,10 @@ class GlobalContext:
options: Options
features: Dict[str, "Feature"] = field(default_factory=dict)
saved_groups: Dict[str, Any] = field(default_factory=dict)
# Payload "contextualBandits" map ({bandit id -> definition}), kept as
# dicts (not materialized into classes) like saved_groups; the TypedDict
# value type documents the wire shape for readers and checkers.
contextual_bandits: Dict[str, ContextualBanditDefinition] = field(default_factory=dict)

@dataclass
class EvaluationContext:
Expand Down
Loading
Loading