From 3895482a05818c939ae58a1523c0dcf0a2ec75ae Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Tue, 1 Sep 2026 11:19:39 -0700 Subject: [PATCH 01/40] fix stale savedGroups in global context: assign before set_features --- growthbook/growthbook.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/growthbook/growthbook.py b/growthbook/growthbook.py index d6b619a..9e48d09 100644 --- a/growthbook/growthbook.py +++ b/growthbook/growthbook.py @@ -1025,10 +1025,12 @@ def _remote_eval_payload(self) -> Dict[str, Any]: def _on_feature_update(self, features_data: Dict[str, Any]) -> None: """Callback to handle automatic feature updates from FeatureRepository""" - if features_data and "features" in features_data: - self.set_features(features_data["features"]) + # savedGroups must be assigned before set_features(), which is what + # re-syncs them into the shared global evaluation context. if features_data and "savedGroups" in features_data: self._saved_groups = features_data["savedGroups"] + if features_data and "features" in features_data: + self.set_features(features_data["features"]) def load_features(self, force_refresh: bool = False) -> None: """Load features from the configured endpoint, populating the cache. @@ -1049,12 +1051,12 @@ def load_features(self, force_refresh: bool = False) -> None: cache_key_attributes=self._cacheKeyAttributes, force_refresh=force_refresh, ) - if response is not None and "features" in response.keys(): - self.set_features(response["features"]) - if response is not None and "savedGroups" in response: self._saved_groups = response["savedGroups"] + if response is not None and "features" in response.keys(): + self.set_features(response["features"]) + async def load_features_async(self, force_refresh: bool = False) -> None: if not self._client_key: raise ValueError("Must specify `client_key` to refresh features") @@ -1072,10 +1074,10 @@ async def load_features_async(self, force_refresh: bool = False) -> None: ) if features is not None: - if "features" in features: - self.set_features(features["features"]) if "savedGroups" in features: self._saved_groups = features["savedGroups"] + if "features" in features: + self.set_features(features["features"]) def _features_event_handler(self, features: str) -> None: decoded = json.loads(features) @@ -1086,10 +1088,10 @@ def _features_event_handler(self, features: str) -> None: key = self._api_host + "::" + self._client_key if data is not None: - if "features" in data: - self.set_features(data["features"]) if "savedGroups" in data: self._saved_groups = data["savedGroups"] + if "features" in data: + self.set_features(data["features"]) feature_repo.save_in_cache(key, data, self._cache_ttl) def _dispatch_sse_event(self, event_data: Dict[str, Any]) -> None: From 4194512d31bbd35688835a5f63e73ffc93447a9a Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Tue, 1 Sep 2026 11:22:58 -0700 Subject: [PATCH 02/40] add contextual bandit payload types and rule/experiment/result fields --- growthbook/__init__.py | 10 ++++++- growthbook/common_types.py | 61 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/growthbook/__init__.py b/growthbook/__init__.py index 7ee20d2..ab3a0fd 100644 --- a/growthbook/__init__.py +++ b/growthbook/__init__.py @@ -28,7 +28,12 @@ feature_repo, ) -from .common_types import AbstractAsyncStickyBucketService +from .common_types import ( + AbstractAsyncStickyBucketService, + CBContext, + ContextualBanditContext, + ContextualBanditDefinition, +) from .growthbook_client import ( GrowthBookClient, @@ -63,6 +68,9 @@ "Feature", "FeatureResult", "FeatureRule", + "CBContext", + "ContextualBanditContext", + "ContextualBanditDefinition", # Typing helpers "JSONValue", "TrackingCallback", diff --git a/growthbook/common_types.py b/growthbook/common_types.py index 6125444..ed3a83b 100644 --- a/growthbook/common_types.py +++ b/growthbook/common_types.py @@ -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 CBContext(TypedDict, total=False): + leafId: Required[int] + variationWeights: Required[List[float]] + banditVersion: int + + class Experiment(Generic[T]): def __init__( self, @@ -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[CBContext] = 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. @@ -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: @@ -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 @@ -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 @@ -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 = "" @@ -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 @@ -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: @@ -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] = {} @@ -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 @@ -573,6 +631,9 @@ class GlobalContext: options: Options features: Dict[str, "Feature"] = field(default_factory=dict) saved_groups: Dict[str, Any] = field(default_factory=dict) + # Raw payload "contextualBandits" map ({bandit id -> definition dict}), + # kept unmaterialized like saved_groups. + contextual_bandits: Dict[str, Any] = field(default_factory=dict) @dataclass class EvaluationContext: From 7117a8243238d7e2b7c09ea76be0730faea00672 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Tue, 1 Sep 2026 11:24:59 -0700 Subject: [PATCH 03/40] evaluate contextual bandit rules in core --- growthbook/core.py | 136 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 132 insertions(+), 4 deletions(-) diff --git a/growthbook/core.py b/growthbook/core.py index e6ef51f..43a0494 100644 --- a/growthbook/core.py +++ b/growthbook/core.py @@ -7,11 +7,26 @@ from urllib.parse import urlparse, parse_qs from typing import Callable, Optional, Any, Set, Tuple, List, Dict, cast -from .common_types import EvaluationContext, FeatureResult, Experiment, Filter, Result, UserContext, VariationMeta +from .common_types import ( + CBContext, + ContextualBanditContext, + EvaluationContext, + FeatureResult, + Experiment, + Filter, + Result, + UserContext, + VariationMeta, +) logger = logging.getLogger("growthbook.core") +# leafId reported when a contextual bandit rule falls back to its marginal +# weights (no leaf condition matched, empty contexts, or leaf selection +# errored). Matches CONTEXTUAL_BANDIT_FALLBACK_LEAF_ID in the JS SDK. +CONTEXTUAL_BANDIT_FALLBACK_LEAF_ID = -1 + def evalCondition(attributes: Dict[str, Any], condition: Dict[str, Any], savedGroups: Optional[Dict[str, Any]] = None) -> bool: for key, value in condition.items(): if key == "$or": @@ -564,6 +579,78 @@ def _fire_rule_tracks( logger.exception("Failed to fire rule.tracks tracking event") +def _get_contextual_bandit_leaf( + contexts: List[ContextualBanditContext], + evalContext: EvaluationContext, +) -> Optional[ContextualBanditContext]: + """Return the first leaf whose condition matches the user's attributes. + + Leaf conditions use the regular targeting condition syntax; an empty + condition matches everyone (the catch-all leaf).""" + for context in contexts: + if evalCondition( + evalContext.user.attributes, + context.get("condition") or {}, + evalContext.global_ctx.saved_groups, + ): + return context + return None + + +def _build_contextual_bandit_experiment( + experiment: Experiment[Any], + contextual_bandit_ref: str, + feature_id: str, + evalContext: EvaluationContext, +) -> None: + """Resolve a rule's contextualBanditRef and substitute the matched leaf's + weight vector onto the experiment, mirroring the JS SDK. + + Matched leaf: experiment.weights are replaced with the leaf's weights. + No match / empty contexts / errored selection: bucketing keeps the rule's + server-computed marginal weights and the fallback leafId -1 is reported. + Dangling ref: run as a plain experiment with no bandit metadata at all.""" + cb_definition = evalContext.global_ctx.contextual_bandits.get(contextual_bandit_ref) + if not cb_definition: + logger.warning( + "Contextual bandit %s not found in payload, feature %s falls back to aggregate weights", + contextual_bandit_ref, + feature_id, + ) + return + + leaf = None + contexts = cb_definition.get("contexts") or [] + if contexts: + try: + leaf = _get_contextual_bandit_leaf(contexts, evalContext) + except Exception: + logger.exception( + "Contextual bandit leaf selection failed, feature %s falls back to aggregate weights", + feature_id, + ) + + if leaf is not None: + weights = leaf["weights"] + experiment.weights = weights + cb: CBContext = {"leafId": leaf["leafId"], "variationWeights": weights} + else: + logger.debug( + "Contextual bandit: no matching leaf, feature %s uses aggregate weights", feature_id + ) + cb = { + "leafId": CONTEXTUAL_BANDIT_FALLBACK_LEAF_ID, + "variationWeights": experiment.weights + if experiment.weights is not None + else getEqualWeights(len(experiment.variations)), + } + + bandit_version = cb_definition.get("banditVersion") + if bandit_version is not None: + cb["banditVersion"] = bandit_version + experiment.contextualBandit = cb + + def eval_feature( key: str, evalContext: Optional[EvaluationContext] = None, @@ -641,13 +728,21 @@ def eval_feature( _fire_rule_tracks(rule.tracks, evalContext, tracking_cb) return FeatureResult(rule.force, "force", ruleId=rule.id) - if rule.variations is None: + # Contextual bandit rules carry their variations under + # contextualVariations; a rule with neither is skipped (this is what + # lets bandit-unaware SDKs degrade to the default value). + rule_variations = ( + rule.contextualVariations + if rule.contextualVariations is not None + else rule.variations + ) + if rule_variations is None: logger.warning("Skip invalid rule, feature %s", key) continue exp = Experiment( key=rule.key or key, - variations=rule.variations, + variations=rule_variations, coverage=rule.coverage, weights=rule.weights, hashAttribute=rule.hashAttribute, @@ -666,8 +761,17 @@ def eval_feature( minBucketVersion=rule.minBucketVersion, ) + if rule.contextualBanditRef: + _build_contextual_bandit_experiment(exp, rule.contextualBanditRef, key, evalContext) + result = run_experiment(experiment=exp, featureId=key, evalContext=evalContext, tracking_cb=tracking_cb) + # Bandit metadata is only meaningful for real hashed exposures; strip + # it from the experiment for forced/QA/coverage-miss outcomes so it + # doesn't leak into the returned FeatureResult. + if exp.contextualBandit is not None and not (result.hashUsed and result.inExperiment): + exp.contextualBandit = None + if callback_subscription: callback_subscription(exp, result) @@ -816,6 +920,16 @@ def run_experiment(experiment: Experiment[Any], # 2.5. If the experiment props have been overridden, merge them in if evalContext.user.overrides.get(experiment.key, None): experiment.update(evalContext.user.overrides[experiment.key]) + # Keep reported bandit propensities in sync with the weights actually + # used for bucketing (an override may have replaced them) + if experiment.contextualBandit and experiment.weights: + synced: ContextualBanditAssignment = { + "leafId": experiment.contextualBandit["leafId"], + "variationWeights": experiment.weights, + } + if "banditVersion" in experiment.contextualBandit: + synced["banditVersion"] = experiment.contextualBandit["banditVersion"] + experiment.contextualBandit = synced # 3. If experiment is forced via a querystring in the url qs = getQueryStringOverride( experiment.key, evalContext.user.url, len(experiment.variations) @@ -1096,6 +1210,17 @@ def _getExperimentResult( fallbackAttr=experiment.fallbackAttribute, eval_context=evalContext) + # Contextual bandit exposure metadata is only reported for real hashed + # assignments — never for forced variations, QA skips, or coverage misses. + leaf_id: Optional[int] = None + variation_weights: Optional[List[float]] = None + bandit_version: Optional[int] = None + cb = experiment.contextualBandit + if cb and hashUsed and inExperiment: + leaf_id = cb["leafId"] + variation_weights = cb["variationWeights"] + bandit_version = cb.get("banditVersion") + return Result( featureId=featureId, inExperiment=inExperiment, @@ -1106,5 +1231,8 @@ def _getExperimentResult( hashValue=hashValue, meta=meta, bucket=bucket, - stickyBucketUsed=stickyBucketUsed + stickyBucketUsed=stickyBucketUsed, + leafId=leaf_id, + variationWeights=variation_weights, + banditVersion=bandit_version ) From 4f6c0325e82ea09e503dce621f0def769a1087fe Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Tue, 1 Sep 2026 11:28:47 -0700 Subject: [PATCH 04/40] plumb contextualBandits through sync and async clients --- growthbook/growthbook.py | 39 ++++++++++++++++++++++++++++----- growthbook/growthbook_client.py | 22 ++++++++++++++----- tests/test_growthbook_client.py | 4 +++- 3 files changed, 52 insertions(+), 13 deletions(-) diff --git a/growthbook/growthbook.py b/growthbook/growthbook.py index 9e48d09..eb0a04b 100644 --- a/growthbook/growthbook.py +++ b/growthbook/growthbook.py @@ -705,6 +705,18 @@ def decrypt_response(self, data: Dict[str, Any], decryption_key: str) -> Optiona elif "features" not in data: logger.warning("GrowthBook API response missing features") + if "encryptedContextualBandits" in data: + if not decryption_key: + raise ValueError("Must specify decryption_key") + try: + decrypted = decrypt(data["encryptedContextualBandits"], decryption_key) + data['contextualBandits'] = json.loads(decrypted) + del data['encryptedContextualBandits'] + except Exception: + logger.warning( + "Failed to decrypt contextual bandits from GrowthBook API response" + ) + if "encryptedSavedGroups" in data: if not decryption_key: raise ValueError("Must specify decryption_key") @@ -717,7 +729,7 @@ def decrypt_response(self, data: Dict[str, Any], decryption_key: str) -> Optiona logger.warning( "Failed to decrypt saved groups from GrowthBook API response" ) - + return data # Fetch features from the GrowthBook API @@ -870,6 +882,7 @@ def __init__( sticky_bucket_service: Optional[AbstractStickyBucketService] = None, sticky_bucket_identifier_attributes: Optional[List[str]] = None, saved_groups: Optional[Dict[str, Any]] = None, + contextual_bandits: Optional[Dict[str, Any]] = None, remote_eval: bool = False, cache_key_attributes: Optional[List[str]] = None, streaming: bool = False, @@ -889,11 +902,13 @@ def __init__( http_connect_timeout: Optional[int] = None, http_read_timeout: Optional[int] = None, savedGroups: Optional[Dict[str, Any]] = None, + contextualBandits: Optional[Dict[str, Any]] = None, remoteEval: bool = False, cacheKeyAttributes: Optional[List[str]] = None, ) -> None: remote_eval = remote_eval or remoteEval saved_groups = saved_groups if saved_groups is not None else savedGroups + contextual_bandits = contextual_bandits if contextual_bandits is not None else contextualBandits cache_key_attributes = cache_key_attributes if cache_key_attributes is not None else cacheKeyAttributes self._remoteEval = remote_eval self._cacheKeyAttributes = cache_key_attributes @@ -917,6 +932,7 @@ def __init__( self._url = url self._features: Dict[str, Feature] = {} self._saved_groups = saved_groups if saved_groups is not None else {} + self._contextual_bandits = contextual_bandits if contextual_bandits is not None else {} self._api_host = api_host self._client_key = client_key self._decryption_key = decryption_key @@ -972,8 +988,9 @@ def __init__( qa_mode=self._qaMode ), features={}, - saved_groups=self._saved_groups - ) + saved_groups=self._saved_groups, + contextual_bandits=self._contextual_bandits + ) # Create a user context for the current user self._user_ctx: UserContext = UserContext( url=self._url, @@ -1025,10 +1042,12 @@ def _remote_eval_payload(self) -> Dict[str, Any]: def _on_feature_update(self, features_data: Dict[str, Any]) -> None: """Callback to handle automatic feature updates from FeatureRepository""" - # savedGroups must be assigned before set_features(), which is what - # re-syncs them into the shared global evaluation context. + # savedGroups/contextualBandits must be assigned before set_features(), + # which is what re-syncs them into the shared global evaluation context. if features_data and "savedGroups" in features_data: self._saved_groups = features_data["savedGroups"] + if features_data and "contextualBandits" in features_data: + self._contextual_bandits = features_data["contextualBandits"] if features_data and "features" in features_data: self.set_features(features_data["features"]) @@ -1054,6 +1073,9 @@ def load_features(self, force_refresh: bool = False) -> None: if response is not None and "savedGroups" in response: self._saved_groups = response["savedGroups"] + if response is not None and "contextualBandits" in response: + self._contextual_bandits = response["contextualBandits"] + if response is not None and "features" in response.keys(): self.set_features(response["features"]) @@ -1076,6 +1098,8 @@ async def load_features_async(self, force_refresh: bool = False) -> None: if features is not None: if "savedGroups" in features: self._saved_groups = features["savedGroups"] + if "contextualBandits" in features: + self._contextual_bandits = features["contextualBandits"] if "features" in features: self.set_features(features["features"]) @@ -1090,6 +1114,8 @@ def _features_event_handler(self, features: str) -> None: if data is not None: if "savedGroups" in data: self._saved_groups = data["savedGroups"] + if "contextualBandits" in data: + self._contextual_bandits = data["contextualBandits"] if "features" in data: self.set_features(data["features"]) feature_repo.save_in_cache(key, data, self._cache_ttl) @@ -1163,6 +1189,7 @@ def set_features(self, features: Dict[str, Any]) -> None: # Update the global context with the new features and saved groups self._global_ctx.features = self._features self._global_ctx.saved_groups = self._saved_groups + self._global_ctx.contextual_bandits = self._contextual_bandits self.refresh_sticky_buckets() finally: self._is_updating_features = False @@ -1458,7 +1485,7 @@ def _derive_sticky_bucket_identifier_attributes(self) -> List[str]: attributes = set() for key, feature in self._features.items(): for rule in feature.rules: - if rule.variations: + if rule.variations or rule.contextualVariations: attributes.add(rule.hashAttribute or "id") if rule.fallbackAttribute: attributes.add(rule.fallbackAttribute) diff --git a/growthbook/growthbook_client.py b/growthbook/growthbook_client.py index 04893da..a1e8be8 100644 --- a/growthbook/growthbook_client.py +++ b/growthbook/growthbook_client.py @@ -106,22 +106,26 @@ class FeatureCache: def __init__(self) -> None: self._cache: Dict[str, Dict[str, Any]] = { 'features': {}, - 'savedGroups': {} + 'savedGroups': {}, + 'contextualBandits': {} } self._lock = threading.Lock() - def update(self, features: Dict[str, Any], saved_groups: Dict[str, Any]) -> None: + def update(self, features: Dict[str, Any], saved_groups: Dict[str, Any], + contextual_bandits: Optional[Dict[str, Any]] = None) -> None: """Simple thread-safe update of cache with new API data""" with self._lock: self._cache['features'] = dict(features) self._cache['savedGroups'] = dict(saved_groups) + self._cache['contextualBandits'] = dict(contextual_bandits or {}) def get_current_state(self) -> Dict[str, Any]: """Get current cache state""" with self._lock: return { "features": dict(self._cache['features']), - "savedGroups": self._cache['savedGroups'] + "savedGroups": self._cache['savedGroups'], + "contextualBandits": self._cache['contextualBandits'] } class EnhancedFeatureRepository(FeatureRepository, metaclass=SingletonMeta): @@ -342,7 +346,8 @@ async def _handle_feature_update(self, data: Dict[str, Any]) -> None: # Directly update with new features self._feature_cache.update( data.get("features", {}), - data.get("savedGroups", {}) + data.get("savedGroups", {}), + data.get("contextualBandits", {}) ) # Create a copy of callbacks to avoid modification during iteration @@ -410,7 +415,9 @@ async def _handle_sse_event(self, event_data: Dict[str, Any]) -> None: data = json.loads(data) if self._decryption_key and isinstance(data, dict) and ( - "encryptedFeatures" in data or "encryptedSavedGroups" in data + "encryptedFeatures" in data + or "encryptedSavedGroups" in data + or "encryptedContextualBandits" in data ): logger.debug("Decrypting SSE payload...") data = self.decrypt_response(data, self._decryption_key) @@ -1141,13 +1148,15 @@ async def _feature_update_callback(self, features_data: Dict[str, Any]) -> None: async with self._context_lock: # serializes concurrent updaters only features = features_from_dict(features_data.get("features")) saved_groups = features_data.get("savedGroups", {}) + contextual_bandits = features_data.get("contextualBandits", {}) # Build a NEW immutable snapshot and swap the reference atomically # (single assignment). In-flight evaluations captured the previous # snapshot and finish against it; new evaluations see this one. # This is what lets evaluations run without any lock. self._global_context = GlobalContext( - options=self.options, features=features, saved_groups=saved_groups + options=self.options, features=features, saved_groups=saved_groups, + contextual_bandits=contextual_bandits ) async def __aenter__(self) -> "GrowthBookClient": @@ -1185,6 +1194,7 @@ async def create_evaluation_context(self, user_context: UserContext) -> Evaluati options=self.options, features=features_from_dict(response.get("features")), saved_groups=response.get("savedGroups") or {}, + contextual_bandits=response.get("contextualBandits") or {}, ) return EvaluationContext( user=user_context, diff --git a/tests/test_growthbook_client.py b/tests/test_growthbook_client.py index da492bc..b6fdf13 100644 --- a/tests/test_growthbook_client.py +++ b/tests/test_growthbook_client.py @@ -402,7 +402,9 @@ async def test_callback(features): assert success == True assert callback_called == True - assert features_received == mock_features_response + # Callbacks receive the full cache state, which always carries a + # contextualBandits key alongside features/savedGroups + assert features_received == {**mock_features_response, "contextualBandits": {}} # Convert Feature objects to dict for comparison features_dict = { key: {"defaultValue": feature.defaultValue, "rules": feature.rules} From d0aa55bc9daa8c483b17618399fa0f4e6acb8acc Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Tue, 1 Sep 2026 11:31:45 -0700 Subject: [PATCH 05/40] test: sync cases.json to spec 0.8.0 and wire the contextualBandit corpus --- tests/cases.json | 3357 ++++++++++++++++++++++- tests/scripts/check_corpus_freshness.py | 1 + tests/scripts/corpus_skiplist.json | 15 +- tests/test_growthbook.py | 14 + tests/test_growthbook_client.py | 3 +- 5 files changed, 3375 insertions(+), 15 deletions(-) diff --git a/tests/cases.json b/tests/cases.json index e339e3a..dcd1db1 100644 --- a/tests/cases.json +++ b/tests/cases.json @@ -1,5 +1,5 @@ { - "specVersion": "0.7.1", + "specVersion": "0.8.0", "evalCondition": [ [ "$not - pass", @@ -7086,6 +7086,3361 @@ "bucket": 0.5305 } } + ], + [ + "CB rule with empty contexts (explore) buckets on marginal weights with fallback leaf -1", + { + "attributes": { + "id": "1", + "plan": "pro" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [1, 0], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [] + } + } + }, + "bandit-feature", + { + "value": "control", + "on": true, + "off": false, + "source": "experiment", + "ruleId": "", + "experiment": { + "variations": ["control", "treatment"], + "key": "bandit-exp", + "coverage": 1, + "weights": [1, 0], + "hashAttribute": "id", + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "seed": "bandit-exp", + "hashVersion": 2, + "contextualBandit": { + "leafId": -1, + "variationWeights": [1, 0], + "banditVersion": 7 + } + }, + "experimentResult": { + "key": "0", + "featureId": "bandit-feature", + "inExperiment": true, + "hashUsed": true, + "variationId": 0, + "value": "control", + "hashAttribute": "id", + "hashValue": "1", + "stickyBucketUsed": false, + "bucket": 0.5305, + "leafId": -1, + "variationWeights": [1, 0], + "banditVersion": 7 + } + } + ], + [ + "CB rule with no contexts key falls back to marginal weights (fallback leaf -1)", + { + "attributes": { + "id": "1" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [1, 0], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7 + } + } + }, + "bandit-feature", + { + "value": "control", + "on": true, + "off": false, + "source": "experiment", + "ruleId": "", + "experiment": { + "variations": ["control", "treatment"], + "key": "bandit-exp", + "coverage": 1, + "weights": [1, 0], + "hashAttribute": "id", + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "seed": "bandit-exp", + "hashVersion": 2, + "contextualBandit": { + "leafId": -1, + "variationWeights": [1, 0], + "banditVersion": 7 + } + }, + "experimentResult": { + "key": "0", + "featureId": "bandit-feature", + "inExperiment": true, + "hashUsed": true, + "variationId": 0, + "value": "control", + "hashAttribute": "id", + "hashValue": "1", + "stickyBucketUsed": false, + "bucket": 0.5305, + "leafId": -1, + "variationWeights": [1, 0], + "banditVersion": 7 + } + } + ], + [ + "CB rule with empty contexts uses marginal weights (fallback leaf -1)", + { + "attributes": { + "id": "1" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0, 1], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [] + } + } + }, + "bandit-feature", + { + "value": "treatment", + "on": true, + "off": false, + "source": "experiment", + "ruleId": "", + "experiment": { + "variations": ["control", "treatment"], + "key": "bandit-exp", + "coverage": 1, + "weights": [0, 1], + "hashAttribute": "id", + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "seed": "bandit-exp", + "hashVersion": 2, + "contextualBandit": { + "leafId": -1, + "variationWeights": [0, 1], + "banditVersion": 7 + } + }, + "experimentResult": { + "key": "1", + "featureId": "bandit-feature", + "inExperiment": true, + "hashUsed": true, + "variationId": 1, + "value": "treatment", + "hashAttribute": "id", + "hashValue": "1", + "stickyBucketUsed": false, + "bucket": 0.5305, + "leafId": -1, + "variationWeights": [0, 1], + "banditVersion": 7 + } + } + ], + [ + "CB rule with empty contexts is overridden by forced variation like a normal experiment", + { + "attributes": { + "id": "1", + "plan": "pro" + }, + "forcedVariations": { + "bandit-exp": 1 + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [] + } + } + }, + "bandit-feature", + { + "value": "treatment", + "on": true, + "off": false, + "source": "experiment", + "ruleId": "", + "experiment": { + "variations": ["control", "treatment"], + "key": "bandit-exp", + "coverage": 1, + "weights": [0.5, 0.5], + "hashAttribute": "id", + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "seed": "bandit-exp", + "hashVersion": 2 + }, + "experimentResult": { + "key": "1", + "featureId": "bandit-feature", + "inExperiment": true, + "hashUsed": false, + "variationId": 1, + "value": "treatment", + "hashAttribute": "id", + "hashValue": "1", + "stickyBucketUsed": false + } + } + ] + ], + "contextualBandit": [ + [ + "single catch-all leaf routes and sets CB result fields", + { + "attributes": { + "id": "1", + "plan": "anything" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": {}, + "weights": [1, 0] + } + ] + } + } + }, + "bandit-feature", + { + "value": "control", + "on": true, + "off": false, + "source": "experiment", + "ruleId": "", + "experiment": { + "variations": ["control", "treatment"], + "key": "bandit-exp", + "coverage": 1, + "weights": [1, 0], + "hashAttribute": "id", + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "seed": "bandit-exp", + "hashVersion": 2, + "contextualBandit": { + "leafId": 1, + "variationWeights": [1, 0], + "banditVersion": 7 + } + }, + "experimentResult": { + "key": "0", + "featureId": "bandit-feature", + "inExperiment": true, + "hashUsed": true, + "variationId": 0, + "value": "control", + "hashAttribute": "id", + "hashValue": "1", + "stickyBucketUsed": false, + "bucket": 0.5305, + "leafId": 1, + "variationWeights": [1, 0], + "banditVersion": 7 + } + } + ], + [ + "matches the first (specific) leaf", + { + "attributes": { + "id": "1", + "plan": "enterprise" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": { + "plan": "enterprise" + }, + "weights": [1, 0] + }, + { + "leafId": 2, + "condition": {}, + "weights": [0, 1] + } + ] + } + } + }, + "bandit-feature", + { + "value": "control", + "on": true, + "off": false, + "source": "experiment", + "ruleId": "", + "experiment": { + "variations": ["control", "treatment"], + "key": "bandit-exp", + "coverage": 1, + "weights": [1, 0], + "hashAttribute": "id", + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "seed": "bandit-exp", + "hashVersion": 2, + "contextualBandit": { + "leafId": 1, + "variationWeights": [1, 0], + "banditVersion": 7 + } + }, + "experimentResult": { + "key": "0", + "featureId": "bandit-feature", + "inExperiment": true, + "hashUsed": true, + "variationId": 0, + "value": "control", + "hashAttribute": "id", + "hashValue": "1", + "stickyBucketUsed": false, + "bucket": 0.5305, + "leafId": 1, + "variationWeights": [1, 0], + "banditVersion": 7 + } + } + ], + [ + "falls through to the catch-all leaf", + { + "attributes": { + "id": "1", + "plan": "free" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": { + "plan": "enterprise" + }, + "weights": [1, 0] + }, + { + "leafId": 2, + "condition": {}, + "weights": [0, 1] + } + ] + } + } + }, + "bandit-feature", + { + "value": "treatment", + "on": true, + "off": false, + "source": "experiment", + "ruleId": "", + "experiment": { + "variations": ["control", "treatment"], + "key": "bandit-exp", + "coverage": 1, + "weights": [0, 1], + "hashAttribute": "id", + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "seed": "bandit-exp", + "hashVersion": 2, + "contextualBandit": { + "leafId": 2, + "variationWeights": [0, 1], + "banditVersion": 7 + } + }, + "experimentResult": { + "key": "1", + "featureId": "bandit-feature", + "inExperiment": true, + "hashUsed": true, + "variationId": 1, + "value": "treatment", + "hashAttribute": "id", + "hashValue": "1", + "stickyBucketUsed": false, + "bucket": 0.5305, + "leafId": 2, + "variationWeights": [0, 1], + "banditVersion": 7 + } + } + ], + [ + "leaf condition operators - $in matches", + { + "attributes": { + "id": "1", + "plan": "free", + "cartValue": 100 + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": { + "plan": { + "$in": ["free", "basic"] + } + }, + "weights": [1, 0] + }, + { + "leafId": 2, + "condition": { + "cartValue": { + "$gte": 500 + } + }, + "weights": [0, 1] + }, + { + "leafId": 3, + "condition": {}, + "weights": [0.5, 0.5] + } + ] + } + } + }, + "bandit-feature", + { + "value": "control", + "on": true, + "off": false, + "source": "experiment", + "ruleId": "", + "experiment": { + "variations": ["control", "treatment"], + "key": "bandit-exp", + "coverage": 1, + "weights": [1, 0], + "hashAttribute": "id", + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "seed": "bandit-exp", + "hashVersion": 2, + "contextualBandit": { + "leafId": 1, + "variationWeights": [1, 0], + "banditVersion": 7 + } + }, + "experimentResult": { + "key": "0", + "featureId": "bandit-feature", + "inExperiment": true, + "hashUsed": true, + "variationId": 0, + "value": "control", + "hashAttribute": "id", + "hashValue": "1", + "stickyBucketUsed": false, + "bucket": 0.5305, + "leafId": 1, + "variationWeights": [1, 0], + "banditVersion": 7 + } + } + ], + [ + "leaf condition operators - $gte matches second leaf", + { + "attributes": { + "id": "1", + "plan": "pro", + "cartValue": 600 + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": { + "plan": { + "$in": ["free", "basic"] + } + }, + "weights": [1, 0] + }, + { + "leafId": 2, + "condition": { + "cartValue": { + "$gte": 500 + } + }, + "weights": [0, 1] + }, + { + "leafId": 3, + "condition": {}, + "weights": [0.5, 0.5] + } + ] + } + } + }, + "bandit-feature", + { + "value": "treatment", + "on": true, + "off": false, + "source": "experiment", + "ruleId": "", + "experiment": { + "variations": ["control", "treatment"], + "key": "bandit-exp", + "coverage": 1, + "weights": [0, 1], + "hashAttribute": "id", + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "seed": "bandit-exp", + "hashVersion": 2, + "contextualBandit": { + "leafId": 2, + "variationWeights": [0, 1], + "banditVersion": 7 + } + }, + "experimentResult": { + "key": "1", + "featureId": "bandit-feature", + "inExperiment": true, + "hashUsed": true, + "variationId": 1, + "value": "treatment", + "hashAttribute": "id", + "hashValue": "1", + "stickyBucketUsed": false, + "bucket": 0.5305, + "leafId": 2, + "variationWeights": [0, 1], + "banditVersion": 7 + } + } + ], + [ + "first-match-wins when multiple leaf conditions match", + { + "attributes": { + "id": "1", + "plan": "pro", + "country": "US" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": { + "plan": "pro" + }, + "weights": [1, 0] + }, + { + "leafId": 2, + "condition": { + "country": "US" + }, + "weights": [0, 1] + }, + { + "leafId": 3, + "condition": {}, + "weights": [0.5, 0.5] + } + ] + } + } + }, + "bandit-feature", + { + "value": "control", + "on": true, + "off": false, + "source": "experiment", + "ruleId": "", + "experiment": { + "variations": ["control", "treatment"], + "key": "bandit-exp", + "coverage": 1, + "weights": [1, 0], + "hashAttribute": "id", + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "seed": "bandit-exp", + "hashVersion": 2, + "contextualBandit": { + "leafId": 1, + "variationWeights": [1, 0], + "banditVersion": 7 + } + }, + "experimentResult": { + "key": "0", + "featureId": "bandit-feature", + "inExperiment": true, + "hashUsed": true, + "variationId": 0, + "value": "control", + "hashAttribute": "id", + "hashValue": "1", + "stickyBucketUsed": false, + "bucket": 0.5305, + "leafId": 1, + "variationWeights": [1, 0], + "banditVersion": 7 + } + } + ], + [ + "missing attribute used by a leaf condition falls into the catch-all leaf", + { + "attributes": { + "id": "1" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": { + "plan": "enterprise" + }, + "weights": [1, 0] + }, + { + "leafId": 2, + "condition": {}, + "weights": [0, 1] + } + ] + } + } + }, + "bandit-feature", + { + "value": "treatment", + "on": true, + "off": false, + "source": "experiment", + "ruleId": "", + "experiment": { + "variations": ["control", "treatment"], + "key": "bandit-exp", + "coverage": 1, + "weights": [0, 1], + "hashAttribute": "id", + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "seed": "bandit-exp", + "hashVersion": 2, + "contextualBandit": { + "leafId": 2, + "variationWeights": [0, 1], + "banditVersion": 7 + } + }, + "experimentResult": { + "key": "1", + "featureId": "bandit-feature", + "inExperiment": true, + "hashUsed": true, + "variationId": 1, + "value": "treatment", + "hashAttribute": "id", + "hashValue": "1", + "stickyBucketUsed": false, + "bucket": 0.5305, + "leafId": 2, + "variationWeights": [0, 1], + "banditVersion": 7 + } + } + ], + [ + "CB rule outer condition fails, a following force rule applies", + { + "attributes": { + "id": "1" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "condition": { + "country": "US" + }, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + }, + { + "force": "forced" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": { + "plan": "enterprise" + }, + "weights": [1, 0] + }, + { + "leafId": 2, + "condition": {}, + "weights": [0, 1] + } + ] + } + } + }, + "bandit-feature", + { + "value": "forced", + "on": true, + "off": false, + "source": "force", + "ruleId": "" + } + ], + [ + "outer condition fails - CB rule skipped", + { + "attributes": { + "id": "1", + "plan": "enterprise" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "condition": { + "country": "US" + }, + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": { + "plan": "enterprise" + }, + "weights": [1, 0] + }, + { + "leafId": 2, + "condition": {}, + "weights": [0, 1] + } + ] + } + } + }, + "bandit-feature", + { + "value": "default", + "on": true, + "off": false, + "source": "defaultValue", + "ruleId": "" + } + ], + [ + "coverage excludes user even though a leaf matched", + { + "attributes": { + "id": "1", + "plan": "enterprise" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 0.01, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": { + "plan": "enterprise" + }, + "weights": [1, 0] + }, + { + "leafId": 2, + "condition": {}, + "weights": [0, 1] + } + ] + } + } + }, + "bandit-feature", + { + "value": "default", + "on": true, + "off": false, + "source": "defaultValue", + "ruleId": "" + } + ], + [ + "three-arm CB leaf routes with positional weights", + { + "attributes": { + "id": "1", + "plan": "enterprise" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["c0", "c1", "c2"], + "weights": [0.34, 0.33, 0.33], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + }, + { + "key": "2" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": { + "plan": "enterprise" + }, + "weights": [0, 0, 1] + }, + { + "leafId": 2, + "condition": {}, + "weights": [0.34, 0.33, 0.33] + } + ] + } + } + }, + "bandit-feature", + { + "value": "c2", + "on": true, + "off": false, + "source": "experiment", + "ruleId": "", + "experiment": { + "variations": ["c0", "c1", "c2"], + "key": "bandit-exp", + "coverage": 1, + "weights": [0, 0, 1], + "hashAttribute": "id", + "meta": [ + { + "key": "0" + }, + { + "key": "1" + }, + { + "key": "2" + } + ], + "seed": "bandit-exp", + "hashVersion": 2, + "contextualBandit": { + "leafId": 1, + "variationWeights": [0, 0, 1], + "banditVersion": 7 + } + }, + "experimentResult": { + "key": "2", + "featureId": "bandit-feature", + "inExperiment": true, + "hashUsed": true, + "variationId": 2, + "value": "c2", + "hashAttribute": "id", + "hashValue": "1", + "stickyBucketUsed": false, + "bucket": 0.5305, + "leafId": 1, + "variationWeights": [0, 0, 1], + "banditVersion": 7 + } + } + ], + [ + "CB rule as a later rule in the list", + { + "attributes": { + "id": "1", + "plan": "enterprise", + "country": "US" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "first", + "seed": "first", + "hashVersion": 2, + "coverage": 1, + "variations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "condition": { + "country": "CA" + } + }, + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": { + "plan": "enterprise" + }, + "weights": [1, 0] + }, + { + "leafId": 2, + "condition": {}, + "weights": [0, 1] + } + ] + } + } + }, + "bandit-feature", + { + "value": "control", + "on": true, + "off": false, + "source": "experiment", + "ruleId": "", + "experiment": { + "variations": ["control", "treatment"], + "key": "bandit-exp", + "coverage": 1, + "weights": [1, 0], + "hashAttribute": "id", + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "seed": "bandit-exp", + "hashVersion": 2, + "contextualBandit": { + "leafId": 1, + "variationWeights": [1, 0], + "banditVersion": 7 + } + }, + "experimentResult": { + "key": "0", + "featureId": "bandit-feature", + "inExperiment": true, + "hashUsed": true, + "variationId": 0, + "value": "control", + "hashAttribute": "id", + "hashValue": "1", + "stickyBucketUsed": false, + "bucket": 0.5305, + "leafId": 1, + "variationWeights": [1, 0], + "banditVersion": 7 + } + } + ], + [ + "leaf with even weights distributes users - id 1", + { + "attributes": { + "id": "1" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": {}, + "weights": [0.5, 0.5] + } + ] + } + } + }, + "bandit-feature", + { + "value": "treatment", + "on": true, + "off": false, + "source": "experiment", + "ruleId": "", + "experiment": { + "variations": ["control", "treatment"], + "key": "bandit-exp", + "coverage": 1, + "weights": [0.5, 0.5], + "hashAttribute": "id", + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "seed": "bandit-exp", + "hashVersion": 2, + "contextualBandit": { + "leafId": 1, + "variationWeights": [0.5, 0.5], + "banditVersion": 7 + } + }, + "experimentResult": { + "key": "1", + "featureId": "bandit-feature", + "inExperiment": true, + "hashUsed": true, + "variationId": 1, + "value": "treatment", + "hashAttribute": "id", + "hashValue": "1", + "stickyBucketUsed": false, + "bucket": 0.5305, + "leafId": 1, + "variationWeights": [0.5, 0.5], + "banditVersion": 7 + } + } + ], + [ + "leaf with even weights distributes users - id 2", + { + "attributes": { + "id": "2" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": {}, + "weights": [0.5, 0.5] + } + ] + } + } + }, + "bandit-feature", + { + "value": "treatment", + "on": true, + "off": false, + "source": "experiment", + "ruleId": "", + "experiment": { + "variations": ["control", "treatment"], + "key": "bandit-exp", + "coverage": 1, + "weights": [0.5, 0.5], + "hashAttribute": "id", + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "seed": "bandit-exp", + "hashVersion": 2, + "contextualBandit": { + "leafId": 1, + "variationWeights": [0.5, 0.5], + "banditVersion": 7 + } + }, + "experimentResult": { + "key": "1", + "featureId": "bandit-feature", + "inExperiment": true, + "hashUsed": true, + "variationId": 1, + "value": "treatment", + "hashAttribute": "id", + "hashValue": "2", + "stickyBucketUsed": false, + "bucket": 0.9374, + "leafId": 1, + "variationWeights": [0.5, 0.5], + "banditVersion": 7 + } + } + ], + [ + "leaf with even weights distributes users - id 3", + { + "attributes": { + "id": "3" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": {}, + "weights": [0.5, 0.5] + } + ] + } + } + }, + "bandit-feature", + { + "value": "treatment", + "on": true, + "off": false, + "source": "experiment", + "ruleId": "", + "experiment": { + "variations": ["control", "treatment"], + "key": "bandit-exp", + "coverage": 1, + "weights": [0.5, 0.5], + "hashAttribute": "id", + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "seed": "bandit-exp", + "hashVersion": 2, + "contextualBandit": { + "leafId": 1, + "variationWeights": [0.5, 0.5], + "banditVersion": 7 + } + }, + "experimentResult": { + "key": "1", + "featureId": "bandit-feature", + "inExperiment": true, + "hashUsed": true, + "variationId": 1, + "value": "treatment", + "hashAttribute": "id", + "hashValue": "3", + "stickyBucketUsed": false, + "bucket": 0.8606, + "leafId": 1, + "variationWeights": [0.5, 0.5], + "banditVersion": 7 + } + } + ], + [ + "leaf with uneven weights distributes users - id 1", + { + "attributes": { + "id": "1" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": {}, + "weights": [0.1, 0.9] + } + ] + } + } + }, + "bandit-feature", + { + "value": "treatment", + "on": true, + "off": false, + "source": "experiment", + "ruleId": "", + "experiment": { + "variations": ["control", "treatment"], + "key": "bandit-exp", + "coverage": 1, + "weights": [0.1, 0.9], + "hashAttribute": "id", + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "seed": "bandit-exp", + "hashVersion": 2, + "contextualBandit": { + "leafId": 1, + "variationWeights": [0.1, 0.9], + "banditVersion": 7 + } + }, + "experimentResult": { + "key": "1", + "featureId": "bandit-feature", + "inExperiment": true, + "hashUsed": true, + "variationId": 1, + "value": "treatment", + "hashAttribute": "id", + "hashValue": "1", + "stickyBucketUsed": false, + "bucket": 0.5305, + "leafId": 1, + "variationWeights": [0.1, 0.9], + "banditVersion": 7 + } + } + ], + [ + "leaf with uneven weights distributes users - id 4", + { + "attributes": { + "id": "4" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": {}, + "weights": [0.1, 0.9] + } + ] + } + } + }, + "bandit-feature", + { + "value": "treatment", + "on": true, + "off": false, + "source": "experiment", + "ruleId": "", + "experiment": { + "variations": ["control", "treatment"], + "key": "bandit-exp", + "coverage": 1, + "weights": [0.1, 0.9], + "hashAttribute": "id", + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "seed": "bandit-exp", + "hashVersion": 2, + "contextualBandit": { + "leafId": 1, + "variationWeights": [0.1, 0.9], + "banditVersion": 7 + } + }, + "experimentResult": { + "key": "1", + "featureId": "bandit-feature", + "inExperiment": true, + "hashUsed": true, + "variationId": 1, + "value": "treatment", + "hashAttribute": "id", + "hashValue": "4", + "stickyBucketUsed": false, + "bucket": 0.4818, + "leafId": 1, + "variationWeights": [0.1, 0.9], + "banditVersion": 7 + } + } + ], + [ + "CB empty hashAttribute - not in experiment", + { + "attributes": { + "id": "", + "plan": "enterprise" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": { + "plan": "enterprise" + }, + "weights": [1, 0] + }, + { + "leafId": 2, + "condition": {}, + "weights": [0, 1] + } + ] + } + } + }, + "bandit-feature", + { + "value": "default", + "on": true, + "off": false, + "source": "defaultValue", + "ruleId": "" + } + ], + [ + "CB null hashAttribute - not in experiment", + { + "attributes": { + "id": null, + "plan": "enterprise" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": { + "plan": "enterprise" + }, + "weights": [1, 0] + }, + { + "leafId": 2, + "condition": {}, + "weights": [0, 1] + } + ] + } + } + }, + "bandit-feature", + { + "value": "default", + "on": true, + "off": false, + "source": "defaultValue", + "ruleId": "" + } + ], + [ + "CB missing hashAttribute - not in experiment", + { + "attributes": { + "plan": "enterprise" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": { + "plan": "enterprise" + }, + "weights": [1, 0] + }, + { + "leafId": 2, + "condition": {}, + "weights": [0, 1] + } + ] + } + } + }, + "bandit-feature", + { + "value": "default", + "on": true, + "off": false, + "source": "defaultValue", + "ruleId": "" + } + ], + [ + "CB buckets on a custom hashAttribute", + { + "attributes": { + "anonId": "123", + "plan": "enterprise" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "anonId", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": { + "plan": "enterprise" + }, + "weights": [1, 0] + }, + { + "leafId": 2, + "condition": {}, + "weights": [0, 1] + } + ] + } + } + }, + "bandit-feature", + { + "value": "control", + "on": true, + "off": false, + "source": "experiment", + "ruleId": "", + "experiment": { + "variations": ["control", "treatment"], + "key": "bandit-exp", + "coverage": 1, + "weights": [1, 0], + "hashAttribute": "anonId", + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "seed": "bandit-exp", + "hashVersion": 2, + "contextualBandit": { + "leafId": 1, + "variationWeights": [1, 0], + "banditVersion": 7 + } + }, + "experimentResult": { + "key": "0", + "featureId": "bandit-feature", + "inExperiment": true, + "hashUsed": true, + "variationId": 0, + "value": "control", + "hashAttribute": "anonId", + "hashValue": "123", + "stickyBucketUsed": false, + "bucket": 0.0484, + "leafId": 1, + "variationWeights": [1, 0], + "banditVersion": 7 + } + } + ], + [ + "forcedVariations from context overrides CB routing", + { + "attributes": { + "id": "1", + "plan": "enterprise" + }, + "forcedVariations": { + "bandit-exp": 1 + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": { + "plan": "enterprise" + }, + "weights": [1, 0] + }, + { + "leafId": 2, + "condition": {}, + "weights": [0, 1] + } + ] + } + } + }, + "bandit-feature", + { + "value": "treatment", + "on": true, + "off": false, + "source": "experiment", + "ruleId": "", + "experiment": { + "variations": ["control", "treatment"], + "key": "bandit-exp", + "coverage": 1, + "weights": [1, 0], + "hashAttribute": "id", + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "seed": "bandit-exp", + "hashVersion": 2 + }, + "experimentResult": { + "key": "1", + "featureId": "bandit-feature", + "inExperiment": true, + "hashUsed": false, + "variationId": 1, + "value": "treatment", + "hashAttribute": "id", + "hashValue": "1", + "stickyBucketUsed": false + } + } + ], + [ + "querystring force overrides CB routing", + { + "attributes": { + "id": "1", + "plan": "enterprise" + }, + "url": "https://example.com/?bandit-exp=1", + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": { + "plan": "enterprise" + }, + "weights": [1, 0] + }, + { + "leafId": 2, + "condition": {}, + "weights": [0, 1] + } + ] + } + } + }, + "bandit-feature", + { + "value": "treatment", + "on": true, + "off": false, + "source": "experiment", + "ruleId": "", + "experiment": { + "variations": ["control", "treatment"], + "key": "bandit-exp", + "coverage": 1, + "weights": [1, 0], + "hashAttribute": "id", + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "seed": "bandit-exp", + "hashVersion": 2 + }, + "experimentResult": { + "key": "1", + "featureId": "bandit-feature", + "inExperiment": true, + "hashUsed": false, + "variationId": 1, + "value": "treatment", + "hashAttribute": "id", + "hashValue": "1", + "stickyBucketUsed": false + } + } + ], + [ + "CB skipped in QA mode", + { + "attributes": { + "id": "1", + "plan": "enterprise" + }, + "qaMode": true, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": { + "plan": "enterprise" + }, + "weights": [1, 0] + }, + { + "leafId": 2, + "condition": {}, + "weights": [0, 1] + } + ] + } + } + }, + "bandit-feature", + { + "value": "default", + "on": true, + "off": false, + "source": "defaultValue", + "ruleId": "" + } + ], + [ + "CB in QA mode if forced in context", + { + "attributes": { + "id": "1", + "plan": "enterprise" + }, + "qaMode": true, + "forcedVariations": { + "bandit-exp": 0 + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": { + "plan": "enterprise" + }, + "weights": [1, 0] + }, + { + "leafId": 2, + "condition": {}, + "weights": [0, 1] + } + ] + } + } + }, + "bandit-feature", + { + "value": "control", + "on": true, + "off": false, + "source": "experiment", + "ruleId": "", + "experiment": { + "variations": ["control", "treatment"], + "key": "bandit-exp", + "coverage": 1, + "weights": [1, 0], + "hashAttribute": "id", + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "seed": "bandit-exp", + "hashVersion": 2 + }, + "experimentResult": { + "key": "0", + "featureId": "bandit-feature", + "inExperiment": true, + "hashUsed": false, + "variationId": 0, + "value": "control", + "hashAttribute": "id", + "hashValue": "1", + "stickyBucketUsed": false + } + } + ], + [ + "CB rule when globally disabled", + { + "enabled": false, + "attributes": { + "id": "1", + "plan": "enterprise" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": { + "plan": "enterprise" + }, + "weights": [1, 0] + }, + { + "leafId": 2, + "condition": {}, + "weights": [0, 1] + } + ] + } + } + }, + "bandit-feature", + { + "value": "default", + "on": true, + "off": false, + "source": "defaultValue", + "ruleId": "" + } + ], + [ + "JSON variation values with CB routing", + { + "attributes": { + "id": "1", + "plan": "enterprise" + }, + "features": { + "bandit-feature": { + "defaultValue": { + "color": "none" + }, + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": [ + { + "color": "blue" + }, + { + "color": "green" + } + ], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": { + "plan": "enterprise" + }, + "weights": [1, 0] + }, + { + "leafId": 2, + "condition": {}, + "weights": [0, 1] + } + ] + } + } + }, + "bandit-feature", + { + "value": { + "color": "blue" + }, + "on": true, + "off": false, + "source": "experiment", + "ruleId": "", + "experiment": { + "variations": [ + { + "color": "blue" + }, + { + "color": "green" + } + ], + "key": "bandit-exp", + "coverage": 1, + "weights": [1, 0], + "hashAttribute": "id", + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "seed": "bandit-exp", + "hashVersion": 2, + "contextualBandit": { + "leafId": 1, + "variationWeights": [1, 0], + "banditVersion": 7 + } + }, + "experimentResult": { + "key": "0", + "featureId": "bandit-feature", + "inExperiment": true, + "hashUsed": true, + "variationId": 0, + "value": { + "color": "blue" + }, + "hashAttribute": "id", + "hashValue": "1", + "stickyBucketUsed": false, + "bucket": 0.5305, + "leafId": 1, + "variationWeights": [1, 0], + "banditVersion": 7 + } + } + ], + [ + "single-variation CB rule is invalid - not in experiment", + { + "attributes": { + "id": "1", + "plan": "enterprise" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control"], + "weights": [1], + "meta": [ + { + "key": "0" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": {}, + "weights": [1] + } + ] + } + } + }, + "bandit-feature", + { + "value": "default", + "on": true, + "off": false, + "source": "defaultValue", + "ruleId": "" + } + ], + [ + "outer condition passes - CB routes", + { + "attributes": { + "id": "1", + "plan": "enterprise", + "country": "US" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "condition": { + "country": "US" + }, + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": { + "plan": "enterprise" + }, + "weights": [1, 0] + }, + { + "leafId": 2, + "condition": {}, + "weights": [0, 1] + } + ] + } + } + }, + "bandit-feature", + { + "value": "control", + "on": true, + "off": false, + "source": "experiment", + "ruleId": "", + "experiment": { + "variations": ["control", "treatment"], + "key": "bandit-exp", + "coverage": 1, + "weights": [1, 0], + "hashAttribute": "id", + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "seed": "bandit-exp", + "hashVersion": 2, + "condition": { + "country": "US" + }, + "contextualBandit": { + "leafId": 1, + "variationWeights": [1, 0], + "banditVersion": 7 + } + }, + "experimentResult": { + "key": "0", + "featureId": "bandit-feature", + "inExperiment": true, + "hashUsed": true, + "variationId": 0, + "value": "control", + "hashAttribute": "id", + "hashValue": "1", + "stickyBucketUsed": false, + "bucket": 0.5305, + "leafId": 1, + "variationWeights": [1, 0], + "banditVersion": 7 + } + } + ], + [ + "coverage distribution within a leaf - id 1", + { + "attributes": { + "id": "1", + "plan": "enterprise" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 0.5, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": { + "plan": "enterprise" + }, + "weights": [1, 0] + }, + { + "leafId": 2, + "condition": {}, + "weights": [0, 1] + } + ] + } + } + }, + "bandit-feature", + { + "value": "default", + "on": true, + "off": false, + "source": "defaultValue", + "ruleId": "" + } + ], + [ + "coverage distribution within a leaf - id 8", + { + "attributes": { + "id": "8", + "plan": "enterprise" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 0.5, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": { + "plan": "enterprise" + }, + "weights": [1, 0] + }, + { + "leafId": 2, + "condition": {}, + "weights": [0, 1] + } + ] + } + } + }, + "bandit-feature", + { + "value": "control", + "on": true, + "off": false, + "source": "experiment", + "ruleId": "", + "experiment": { + "variations": ["control", "treatment"], + "key": "bandit-exp", + "coverage": 0.5, + "weights": [1, 0], + "hashAttribute": "id", + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "seed": "bandit-exp", + "hashVersion": 2, + "contextualBandit": { + "leafId": 1, + "variationWeights": [1, 0], + "banditVersion": 7 + } + }, + "experimentResult": { + "key": "0", + "featureId": "bandit-feature", + "inExperiment": true, + "hashUsed": true, + "variationId": 0, + "value": "control", + "hashAttribute": "id", + "hashValue": "8", + "stickyBucketUsed": false, + "bucket": 0.011, + "leafId": 1, + "variationWeights": [1, 0], + "banditVersion": 7 + } + } + ], + [ + "dangling contextualBanditRef uses marginal weights with no CB metadata", + { + "attributes": { + "id": "1", + "plan": "enterprise" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + } + }, + "bandit-feature", + { + "value": "treatment", + "on": true, + "off": false, + "source": "experiment", + "ruleId": "", + "experiment": { + "variations": ["control", "treatment"], + "key": "bandit-exp", + "coverage": 1, + "weights": [0.5, 0.5], + "hashAttribute": "id", + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "seed": "bandit-exp", + "hashVersion": 2 + }, + "experimentResult": { + "key": "1", + "featureId": "bandit-feature", + "inExperiment": true, + "hashUsed": true, + "variationId": 1, + "value": "treatment", + "hashAttribute": "id", + "hashValue": "1", + "stickyBucketUsed": false, + "bucket": 0.5305 + } + } + ], + [ + "empty contexts array uses marginal weights with fallback leaf -1", + { + "attributes": { + "id": "1", + "plan": "enterprise" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [] + } + } + }, + "bandit-feature", + { + "value": "treatment", + "on": true, + "off": false, + "source": "experiment", + "ruleId": "", + "experiment": { + "variations": ["control", "treatment"], + "key": "bandit-exp", + "coverage": 1, + "weights": [0.5, 0.5], + "hashAttribute": "id", + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "seed": "bandit-exp", + "hashVersion": 2, + "contextualBandit": { + "leafId": -1, + "variationWeights": [0.5, 0.5], + "banditVersion": 7 + } + }, + "experimentResult": { + "key": "1", + "featureId": "bandit-feature", + "inExperiment": true, + "hashUsed": true, + "variationId": 1, + "value": "treatment", + "hashAttribute": "id", + "hashValue": "1", + "stickyBucketUsed": false, + "bucket": 0.5305, + "leafId": -1, + "variationWeights": [0.5, 0.5], + "banditVersion": 7 + } + } + ], + [ + "banditVersion omitted from definition is absent from result", + { + "attributes": { + "id": "1", + "plan": "enterprise" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "contexts": [ + { + "leafId": 1, + "condition": {}, + "weights": [1, 0] + } + ] + } + } + }, + "bandit-feature", + { + "value": "control", + "on": true, + "off": false, + "source": "experiment", + "ruleId": "", + "experiment": { + "variations": ["control", "treatment"], + "key": "bandit-exp", + "coverage": 1, + "weights": [1, 0], + "hashAttribute": "id", + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "seed": "bandit-exp", + "hashVersion": 2, + "contextualBandit": { + "leafId": 1, + "variationWeights": [1, 0] + } + }, + "experimentResult": { + "key": "0", + "featureId": "bandit-feature", + "inExperiment": true, + "hashUsed": true, + "variationId": 0, + "value": "control", + "hashAttribute": "id", + "hashValue": "1", + "stickyBucketUsed": false, + "bucket": 0.5305, + "leafId": 1, + "variationWeights": [1, 0] + } + } + ], + [ + "required attribute present but no leaf matches - fallback leaf -1", + { + "attributes": { + "id": "1", + "plan": "free" + }, + "features": { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "contextualBanditRef": "cb-bandit" + } + ] + } + }, + "contextualBandits": { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": { + "plan": "enterprise" + }, + "weights": [1, 0] + } + ] + } + } + }, + "bandit-feature", + { + "value": "treatment", + "on": true, + "off": false, + "source": "experiment", + "ruleId": "", + "experiment": { + "variations": ["control", "treatment"], + "key": "bandit-exp", + "coverage": 1, + "weights": [0.5, 0.5], + "hashAttribute": "id", + "meta": [ + { + "key": "0" + }, + { + "key": "1" + } + ], + "seed": "bandit-exp", + "hashVersion": 2, + "contextualBandit": { + "leafId": -1, + "variationWeights": [0.5, 0.5], + "banditVersion": 7 + } + }, + "experimentResult": { + "key": "1", + "featureId": "bandit-feature", + "inExperiment": true, + "hashUsed": true, + "variationId": 1, + "value": "treatment", + "hashAttribute": "id", + "hashValue": "1", + "stickyBucketUsed": false, + "bucket": 0.5305, + "leafId": -1, + "variationWeights": [0.5, 0.5], + "banditVersion": 7 + } + } ] ], "run": [ diff --git a/tests/scripts/check_corpus_freshness.py b/tests/scripts/check_corpus_freshness.py index 0661fd0..816ca8f 100644 --- a/tests/scripts/check_corpus_freshness.py +++ b/tests/scripts/check_corpus_freshness.py @@ -62,6 +62,7 @@ KEYS_TO_DIFF = ( "evalCondition", "feature", + "contextualBandit", "run", "hash", "getBucketRange", diff --git a/tests/scripts/corpus_skiplist.json b/tests/scripts/corpus_skiplist.json index 59755b1..0aaeef1 100644 --- a/tests/scripts/corpus_skiplist.json +++ b/tests/scripts/corpus_skiplist.json @@ -1,20 +1,9 @@ { "_doc": "Skiplist for the corpus freshness check. Two buckets:\n * \"missing\" \u2014 case names JS has and Python deliberately doesn't. Use when JS adds a case for a feature Python doesn't (yet) support.\n * \"drift\" \u2014 case names where Python deliberately keeps a different body from JS. Use sparingly \u2014 body-drift on a shared case is usually a bug.\nExtras (Python has, JS doesn't) are reported but never fail CI, so they don't need an entry here.\nAdd a brief reason in `_reasons` whenever you add an entry so future maintainers know why.", - "missing": { - "feature": [ - "CB rule with empty contexts (explore) buckets on marginal weights with fallback leaf -1", - "CB rule with no contexts key falls back to marginal weights (fallback leaf -1)", - "CB rule with empty contexts uses marginal weights (fallback leaf -1)", - "CB rule with empty contexts is overridden by forced variation like a normal experiment" - ] - }, + "missing": {}, "drift": {}, "_reasons": { "_example_missing": "evalCondition::case name -> short reason (e.g. 'tests $someOperator that Python doesn't implement yet').", - "_example_drift": "evalCondition::case name -> short reason (e.g. 'JS shipped a behavioral change in case X; Python intentionally kept the prior expectation pending review').", - "feature::CB rule with empty contexts (explore) buckets on marginal weights with fallback leaf -1": "contextual bandit rules (spec 0.8.0: contextualBandits payload + contextualVariations) are not implemented in the Python SDK yet", - "feature::CB rule with no contexts key falls back to marginal weights (fallback leaf -1)": "contextual bandit rules (spec 0.8.0: contextualBandits payload + contextualVariations) are not implemented in the Python SDK yet", - "feature::CB rule with empty contexts uses marginal weights (fallback leaf -1)": "contextual bandit rules (spec 0.8.0: contextualBandits payload + contextualVariations) are not implemented in the Python SDK yet", - "feature::CB rule with empty contexts is overridden by forced variation like a normal experiment": "contextual bandit rules (spec 0.8.0: contextualBandits payload + contextualVariations) are not implemented in the Python SDK yet" + "_example_drift": "evalCondition::case name -> short reason (e.g. 'JS shipped a behavioral change in case X; Python intentionally kept the prior expectation pending review')." } } \ No newline at end of file diff --git a/tests/test_growthbook.py b/tests/test_growthbook.py index fee0bd8..97db6ab 100644 --- a/tests/test_growthbook.py +++ b/tests/test_growthbook.py @@ -167,6 +167,20 @@ def test_feature(feature_data): gb.destroy() +def test_contextual_bandit(contextualBandit_data): + _, ctx, key, expected = contextualBandit_data + gb = GrowthBook(**ctx) + res = gb.eval_feature(key) + + if "experiment" in expected: + expected["experiment"] = Experiment(**expected["experiment"]).to_dict() + + actual = res.to_dict() + + assert actual == expected + gb.destroy() + + def test_run(run_data): _, ctx, exp, value, inExperiment, hashUsed = run_data gb = GrowthBook(**ctx) diff --git a/tests/test_growthbook_client.py b/tests/test_growthbook_client.py index b6fdf13..daa8ea3 100644 --- a/tests/test_growthbook_client.py +++ b/tests/test_growthbook_client.py @@ -737,7 +737,8 @@ def _setup(ctx): # Features data structure features_data = { "features": ctx.get("features", {}), - "savedGroups": ctx.get("savedGroups", {}) + "savedGroups": ctx.get("savedGroups", {}), + "contextualBandits": ctx.get("contextualBandits", {}) } return user_attrs, client_opts, features_data From a408c05a8be0c74604b8f0906a09135171739bb2 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Tue, 1 Sep 2026 11:36:04 -0700 Subject: [PATCH 06/40] test: contextual bandit ingestion, encryption, and tracking coverage --- tests/test_contextual_bandit.py | 243 ++++++++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 tests/test_contextual_bandit.py diff --git a/tests/test_contextual_bandit.py b/tests/test_contextual_bandit.py new file mode 100644 index 0000000..194052d --- /dev/null +++ b/tests/test_contextual_bandit.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python +"""Contextual bandit tests that the shared cases.json corpus can't express: +payload ingestion in both clients, encrypted contextualBandits, and tracking +callback behavior. Evaluation semantics themselves are pinned by the +`contextualBandit` section of tests/cases.json.""" + +import json +import os +from base64 import b64decode, b64encode + +import pytest +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives import padding +from unittest.mock import patch, AsyncMock + +from growthbook import ( + GrowthBook, + GrowthBookClient, + feature_repo, +) +from growthbook.growthbook_client import EnhancedFeatureRepository, FeatureCache +from growthbook.common_types import Options, UserContext + + +CB_FEATURES = { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "meta": [{"key": "0"}, {"key": "1"}], + "contextualBanditRef": "cb-bandit", + } + ], + } +} + +# Single catch-all leaf that sends everyone to variation 0. +CB_MAP = { + "cb-bandit": { + "banditVersion": 7, + "contexts": [{"leafId": 1, "condition": {}, "weights": [1, 0]}], + } +} + + +class MockHttpResp: + def __init__(self, status: int, data: str) -> None: + self.status = status + self.data = data.encode("utf-8") + self.headers: dict = {} + + +def _encrypt(payload: str, key_str: str) -> str: + """Inverse of growthbook.decrypt (AES128-CBC, 'iv.ciphertext' base64).""" + key = b64decode(key_str) + iv = os.urandom(16) + padder = padding.PKCS7(128).padder() + padded = padder.update(payload.encode("utf-8")) + padder.finalize() + cipher = Cipher(algorithms.AES128(key), modes.CBC(iv)) + encryptor = cipher.encryptor() + ct = encryptor.update(padded) + encryptor.finalize() + return b64encode(iv).decode() + "." + b64encode(ct).decode() + + +def test_constructor_and_eval(): + gb = GrowthBook( + attributes={"id": "1"}, + features=CB_FEATURES, + contextualBandits=CB_MAP, + ) + res = gb.eval_feature("bandit-feature") + assert res.value == "control" + assert res.experimentResult is not None + assert res.experimentResult.leafId == 1 + assert res.experimentResult.variationWeights == [1, 0] + assert res.experimentResult.banditVersion == 7 + gb.destroy() + + +def test_on_feature_update_ingestion(): + gb = GrowthBook(attributes={"id": "1"}) + gb._on_feature_update({"features": CB_FEATURES, "contextualBandits": CB_MAP}) + assert gb._global_ctx.contextual_bandits == CB_MAP + res = gb.eval_feature("bandit-feature") + assert res.value == "control" + assert res.experimentResult.leafId == 1 + gb.destroy() + + +def test_load_features_ingestion(mocker): + m = mocker.patch.object(feature_repo, "_get") + m.return_value = MockHttpResp( + 200, json.dumps({"features": CB_FEATURES, "contextualBandits": CB_MAP}) + ) + gb = GrowthBook( + api_host="https://cdn.growthbook.io", + client_key="sdk-cb-ingest", + attributes={"id": "1"}, + ) + gb.load_features() + assert gb._global_ctx.contextual_bandits == CB_MAP + assert gb.eval_feature("bandit-feature").value == "control" + gb.destroy() + feature_repo.clear_cache() + + +def test_decrypt_response_encrypted_contextual_bandits(): + key = "Zvwv/+uhpFDznZ6SX28Yjg==" + data = { + "features": CB_FEATURES, + "encryptedContextualBandits": _encrypt(json.dumps(CB_MAP), key), + } + result = feature_repo.decrypt_response(data, key) + assert result["contextualBandits"] == CB_MAP + assert "encryptedContextualBandits" not in result + + # Missing decryption key raises like the other encrypted payload keys + with pytest.raises(ValueError): + feature_repo.decrypt_response( + {"features": {}, "encryptedContextualBandits": "abc.def"}, "" + ) + + +def test_tracking_callback_receives_bandit_result(): + tracked = [] + + def on_view(experiment, result, user_context): + tracked.append((experiment, result, user_context)) + + gb = GrowthBook( + attributes={"id": "1", "country": "US"}, + features=CB_FEATURES, + contextualBandits=CB_MAP, + on_experiment_viewed=on_view, + ) + gb.eval_feature("bandit-feature") + assert len(tracked) == 1 + experiment, result, user_context = tracked[0] + assert result.leafId == 1 + assert result.variationWeights == [1, 0] + assert result.banditVersion == 7 + # The exact attributes used at bucketing time are available for logging + assert user_context.attributes == {"id": "1", "country": "US"} + gb.destroy() + + +def test_non_bandit_result_has_no_bandit_metadata(): + gb = GrowthBook( + attributes={"id": "1"}, + features={ + "plain": { + "defaultValue": 0, + "rules": [{"key": "plain-exp", "variations": [0, 1]}], + } + }, + ) + res = gb.eval_feature("plain") + assert res.experimentResult is not None + assert res.experimentResult.leafId is None + serialized = res.experimentResult.to_dict() + assert "leafId" not in serialized + assert "variationWeights" not in serialized + assert "banditVersion" not in serialized + gb.destroy() + + +def test_one_bandit_shared_by_two_features(): + features = dict(CB_FEATURES) + features["bandit-feature-2"] = { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp-2", + "seed": "bandit-exp-2", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["a", "b"], + "weights": [0.5, 0.5], + "contextualBanditRef": "cb-bandit", + } + ], + } + gb = GrowthBook( + attributes={"id": "1"}, + features=features, + contextualBandits=CB_MAP, + ) + res1 = gb.eval_feature("bandit-feature") + res2 = gb.eval_feature("bandit-feature-2") + assert res1.value == "control" + assert res2.value == "a" + assert res1.experimentResult.leafId == 1 + assert res2.experimentResult.leafId == 1 + gb.destroy() + + +def test_feature_cache_round_trip(): + cache = FeatureCache() + cache.update({"f": {"defaultValue": 1}}, {"sg": []}, CB_MAP) + state = cache.get_current_state() + assert state["contextualBandits"] == CB_MAP + # Omitting the argument clears the map (payload without the key) + cache.update({}, {}) + assert cache.get_current_state()["contextualBandits"] == {} + + +@pytest.mark.asyncio +async def test_async_client_contextual_bandits(): + EnhancedFeatureRepository._instances = {} + payload = { + "features": CB_FEATURES, + "savedGroups": {}, + "contextualBandits": CB_MAP, + } + with patch( + "growthbook.FeatureRepository.load_features_async", + new_callable=AsyncMock, + return_value=payload, + ), patch( + "growthbook.growthbook_client.EnhancedFeatureRepository.start_feature_refresh", + new_callable=AsyncMock, + ), patch( + "growthbook.growthbook_client.EnhancedFeatureRepository.stop_refresh", + new_callable=AsyncMock, + ): + async with GrowthBookClient( + Options(api_host="https://localhost.growthbook.io", client_key="test-key") + ) as client: + result = await client.eval_feature( + "bandit-feature", UserContext(attributes={"id": "1"}) + ) + assert result.value == "control" + assert result.experimentResult.leafId == 1 + assert result.experimentResult.variationWeights == [1, 0] + assert result.experimentResult.banditVersion == 7 From 0db670b0c3a958a4a9b55344637bd29da5ac71c0 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Tue, 1 Sep 2026 12:51:19 -0700 Subject: [PATCH 07/40] assign maps to global context before features in set_features --- growthbook/growthbook.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/growthbook/growthbook.py b/growthbook/growthbook.py index eb0a04b..4c7aef2 100644 --- a/growthbook/growthbook.py +++ b/growthbook/growthbook.py @@ -1186,10 +1186,14 @@ def set_features(self, features: Dict[str, Any]) -> None: rules=feature.get("rules", []), defaultValue=feature.get("defaultValue", None), ) - # Update the global context with the new features and saved groups - self._global_ctx.features = self._features + # Update the global context with the new features and saved + # groups. The maps go first: evals in other threads key off the + # features dict, so if they observe a torn update it must be old + # features with new maps (harmless) rather than new features + # whose savedGroups/contextualBandits refs aren't loaded yet. self._global_ctx.saved_groups = self._saved_groups self._global_ctx.contextual_bandits = self._contextual_bandits + self._global_ctx.features = self._features self.refresh_sticky_buckets() finally: self._is_updating_features = False From 01ab9570a866e701e6887842b4f5de434b6247f5 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Tue, 1 Sep 2026 12:51:40 -0700 Subject: [PATCH 08/40] async client: partial updates no longer wipe savedGroups/contextualBandits --- growthbook/growthbook_client.py | 19 +++++++++++++++--- tests/test_contextual_bandit.py | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/growthbook/growthbook_client.py b/growthbook/growthbook_client.py index a1e8be8..b63146c 100644 --- a/growthbook/growthbook_client.py +++ b/growthbook/growthbook_client.py @@ -1146,9 +1146,22 @@ async def _feature_update_callback(self, features_data: Dict[str, Any]) -> None: return async with self._context_lock: # serializes concurrent updaters only - features = features_from_dict(features_data.get("features")) - saved_groups = features_data.get("savedGroups", {}) - contextual_bandits = features_data.get("contextualBandits", {}) + prev = self._global_context + # Mirror JS setPayload: sections absent from the update carry + # over from the previous snapshot, so a partial update (e.g. + # set_features) doesn't silently wipe savedGroups or + # contextualBandits. Full refreshes carry all three keys. + features = ( + features_from_dict(features_data["features"]) + if "features" in features_data + else (prev.features if prev else {}) + ) + saved_groups = features_data.get( + "savedGroups", prev.saved_groups if prev else {} + ) + contextual_bandits = features_data.get( + "contextualBandits", prev.contextual_bandits if prev else {} + ) # Build a NEW immutable snapshot and swap the reference atomically # (single assignment). In-flight evaluations captured the previous diff --git a/tests/test_contextual_bandit.py b/tests/test_contextual_bandit.py index 194052d..c7921b9 100644 --- a/tests/test_contextual_bandit.py +++ b/tests/test_contextual_bandit.py @@ -212,6 +212,41 @@ def test_feature_cache_round_trip(): assert cache.get_current_state()["contextualBandits"] == {} +@pytest.mark.asyncio +async def test_async_set_features_preserves_bandit_map(): + """A partial update (set_features) must not wipe the contextualBandits or + savedGroups sections — mirrors JS setPayload, which only overwrites the + sections present in the payload.""" + EnhancedFeatureRepository._instances = {} + payload = { + "features": CB_FEATURES, + "savedGroups": {"sg": ["1"]}, + "contextualBandits": CB_MAP, + } + with patch( + "growthbook.FeatureRepository.load_features_async", + new_callable=AsyncMock, + return_value=payload, + ), patch( + "growthbook.growthbook_client.EnhancedFeatureRepository.start_feature_refresh", + new_callable=AsyncMock, + ), patch( + "growthbook.growthbook_client.EnhancedFeatureRepository.stop_refresh", + new_callable=AsyncMock, + ): + async with GrowthBookClient( + Options(api_host="https://localhost.growthbook.io", client_key="test-key") + ) as client: + await client.set_features(CB_FEATURES) + assert client._global_context.contextual_bandits == CB_MAP + assert client._global_context.saved_groups == {"sg": ["1"]} + result = await client.eval_feature( + "bandit-feature", UserContext(attributes={"id": "1"}) + ) + assert result.value == "control" + assert result.experimentResult.leafId == 1 + + @pytest.mark.asyncio async def test_async_client_contextual_bandits(): EnhancedFeatureRepository._instances = {} From 5ddfc9f9ad3f64ca4d51dfb1ede9bf73ec1d93dc Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Tue, 1 Sep 2026 12:52:36 -0700 Subject: [PATCH 09/40] snapshot user attributes for tracking and feature-usage callbacks --- growthbook/common_types.py | 13 ++++++++++++- growthbook/core.py | 8 +++++--- growthbook/growthbook.py | 3 ++- growthbook/growthbook_client.py | 3 ++- tests/test_contextual_bandit.py | 21 +++++++++++++++++++++ tests/test_growthbook_client.py | 14 ++++++++++---- 6 files changed, 52 insertions(+), 10 deletions(-) diff --git a/growthbook/common_types.py b/growthbook/common_types.py index ed3a83b..ce0e096 100644 --- a/growthbook/common_types.py +++ b/growthbook/common_types.py @@ -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, @@ -587,6 +587,17 @@ def __call__( ] +def tracking_user_context(user: "UserContext") -> "UserContext": + """Exposure-time snapshot of a user context for tracking and + feature-usage callbacks (JS SDK: getTrackingUserContext). + + The attributes dict is shallow-copied so callbacks — including ones that + defer processing — always see the values that were used for bucketing + and contextual bandit leaf routing, even if the caller mutates + attributes afterwards.""" + return replace(user, attributes=dict(user.attributes)) + + @dataclass class Options: url: Optional[str] = None diff --git a/growthbook/core.py b/growthbook/core.py index 43a0494..89d59e1 100644 --- a/growthbook/core.py +++ b/growthbook/core.py @@ -17,6 +17,7 @@ Result, UserContext, VariationMeta, + tracking_user_context, ) @@ -574,7 +575,7 @@ def _fire_rule_tracks( bucket=res_data.get("bucket"), stickyBucketUsed=res_data.get("stickyBucketUsed", False), ) - tracking_cb(experiment, result, eval_context.user) + tracking_cb(experiment, result, tracking_user_context(eval_context.user)) except Exception: logger.exception("Failed to fire rule.tracks tracking event") @@ -1154,9 +1155,10 @@ def run_experiment(experiment: Experiment[Any], "assignment doc was not persisted" ) - # 14. Fire the tracking callback if set + # 14. Fire the tracking callback if set. The user context is snapshotted + # so the logged attributes are exactly the ones used for bucketing. if tracking_cb: - tracking_cb(experiment, result, evalContext.user) + tracking_cb(experiment, result, tracking_user_context(evalContext.user)) # 15. Return the result logger.debug("Assigned variation %d in experiment %s", assigned, experiment.key) diff --git a/growthbook/growthbook.py b/growthbook/growthbook.py index 4c7aef2..8668272 100644 --- a/growthbook/growthbook.py +++ b/growthbook/growthbook.py @@ -37,6 +37,7 @@ FeatureRule, build_remote_eval_payload, features_from_dict, + tracking_user_context, validate_remote_eval_options, ) @@ -1425,7 +1426,7 @@ def eval_feature(self, key: str) -> FeatureResult[Any]: # Call feature usage callback if provided if self._featureUsageCallback: try: - self._featureUsageCallback(key, result, self._user_ctx) + self._featureUsageCallback(key, result, tracking_user_context(self._user_ctx)) except Exception: pass return result diff --git a/growthbook/growthbook_client.py b/growthbook/growthbook_client.py index b63146c..aa9c376 100644 --- a/growthbook/growthbook_client.py +++ b/growthbook/growthbook_client.py @@ -36,6 +36,7 @@ Experiment, build_remote_eval_payload, features_from_dict, + tracking_user_context, validate_remote_eval_options, ) @@ -1246,7 +1247,7 @@ async def eval_feature(self, key: str, user_context: UserContext) -> FeatureResu try: self._run_user_callback( self.options.on_feature_usage, - (key, result, user_context), + (key, result, tracking_user_context(user_context)), "feature usage", ) except Exception: diff --git a/tests/test_contextual_bandit.py b/tests/test_contextual_bandit.py index c7921b9..0568bae 100644 --- a/tests/test_contextual_bandit.py +++ b/tests/test_contextual_bandit.py @@ -247,6 +247,27 @@ async def test_async_set_features_preserves_bandit_map(): assert result.experimentResult.leafId == 1 +def test_tracking_callback_gets_exposure_time_attribute_snapshot(): + """Attributes mutated after evaluation must not leak into the tracked + user context — the warehouse row has to carry the attributes used for + leaf routing (JS SDK: getTrackingUserContext snapshot).""" + tracked = [] + + def on_view(experiment, result, user_context): + tracked.append(user_context) + + gb = GrowthBook( + attributes={"id": "1", "country": "US"}, + features=CB_FEATURES, + contextualBandits=CB_MAP, + on_experiment_viewed=on_view, + ) + gb.eval_feature("bandit-feature") + gb.set_attributes({"id": "1", "country": "DE"}) + assert tracked[0].attributes == {"id": "1", "country": "US"} + gb.destroy() + + @pytest.mark.asyncio async def test_async_client_contextual_bandits(): EnhancedFeatureRepository._instances = {} diff --git a/tests/test_growthbook_client.py b/tests/test_growthbook_client.py index daa8ea3..ec99861 100644 --- a/tests/test_growthbook_client.py +++ b/tests/test_growthbook_client.py @@ -1408,12 +1408,18 @@ async def test_tracking(): user_context.attributes = {"id": "2"} res5 = await client.run(exp2, user_context) - # Verify tracking calls + # Verify tracking calls. The tracked user context is an + # exposure-time snapshot, so the first two calls carry the + # attributes as they were when each experiment was viewed — + # not the later mutation. calls = getMockedCalls() assert len(calls) == 3, "Expected exactly 3 tracking calls" - assert calls[0] == [exp1, res1, user_context], "First tracking call mismatch" - assert calls[1] == [exp2, res4, user_context], "Second tracking call mismatch" - assert calls[2] == [exp2, res5, user_context], "Third tracking call mismatch" + assert calls[0][:2] == [exp1, res1], "First tracking call mismatch" + assert calls[0][2].attributes == {"id": "1"} + assert calls[1][:2] == [exp2, res4], "Second tracking call mismatch" + assert calls[1][2].attributes == {"id": "1"} + assert calls[2][:2] == [exp2, res5], "Third tracking call mismatch" + assert calls[2][2].attributes == {"id": "2"} finally: await client.close() From a08af5615e6bd01075b4e1a5f6961340e8363e32 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Wed, 2 Sep 2026 12:48:21 -0700 Subject: [PATCH 10/40] rename CBContext to ContextualBanditAssignment --- growthbook/__init__.py | 4 ++-- growthbook/common_types.py | 4 ++-- growthbook/core.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/growthbook/__init__.py b/growthbook/__init__.py index ab3a0fd..3c27558 100644 --- a/growthbook/__init__.py +++ b/growthbook/__init__.py @@ -30,7 +30,7 @@ from .common_types import ( AbstractAsyncStickyBucketService, - CBContext, + ContextualBanditAssignment, ContextualBanditContext, ContextualBanditDefinition, ) @@ -68,7 +68,7 @@ "Feature", "FeatureResult", "FeatureRule", - "CBContext", + "ContextualBanditAssignment", "ContextualBanditContext", "ContextualBanditDefinition", # Typing helpers diff --git a/growthbook/common_types.py b/growthbook/common_types.py index ce0e096..12b138f 100644 --- a/growthbook/common_types.py +++ b/growthbook/common_types.py @@ -77,7 +77,7 @@ class ContextualBanditDefinition(TypedDict, total=False): # 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 CBContext(TypedDict, total=False): +class ContextualBanditAssignment(TypedDict, total=False): leafId: Required[int] variationWeights: Required[List[float]] banditVersion: int @@ -112,7 +112,7 @@ def __init__( minBucketVersion: Optional[int] = None, parentConditions: Optional[List[Dict[str, Any]]] = None, customFields: Optional[Dict[str, Any]] = None, - contextualBandit: Optional[CBContext] = 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. diff --git a/growthbook/core.py b/growthbook/core.py index 89d59e1..2671fdd 100644 --- a/growthbook/core.py +++ b/growthbook/core.py @@ -8,7 +8,7 @@ from urllib.parse import urlparse, parse_qs from typing import Callable, Optional, Any, Set, Tuple, List, Dict, cast from .common_types import ( - CBContext, + ContextualBanditAssignment, ContextualBanditContext, EvaluationContext, FeatureResult, @@ -634,7 +634,7 @@ def _build_contextual_bandit_experiment( if leaf is not None: weights = leaf["weights"] experiment.weights = weights - cb: CBContext = {"leafId": leaf["leafId"], "variationWeights": weights} + cb: ContextualBanditAssignment = {"leafId": leaf["leafId"], "variationWeights": weights} else: logger.debug( "Contextual bandit: no matching leaf, feature %s uses aggregate weights", feature_id From 81042d939e75cb55a4e0639d66e79202410f9c80 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Wed, 2 Sep 2026 12:50:17 -0700 Subject: [PATCH 11/40] type the contextual bandit payload map as Dict[str, ContextualBanditDefinition] --- growthbook/common_types.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/growthbook/common_types.py b/growthbook/common_types.py index 12b138f..dc3abbc 100644 --- a/growthbook/common_types.py +++ b/growthbook/common_types.py @@ -642,9 +642,10 @@ class GlobalContext: options: Options features: Dict[str, "Feature"] = field(default_factory=dict) saved_groups: Dict[str, Any] = field(default_factory=dict) - # Raw payload "contextualBandits" map ({bandit id -> definition dict}), - # kept unmaterialized like saved_groups. - contextual_bandits: 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: From fb25a823a1cf8d7401a23ce60512704a2a318852 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Wed, 2 Sep 2026 12:51:55 -0700 Subject: [PATCH 12/40] codegen: infer types from contextualVariations, accept contextualBandits payload keys --- growthbook/codegen.py | 20 ++++++++++++++++---- tests/codegen/expected_output.py | 14 +++++++++++++- tests/codegen/sample_features.json | 1 + 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/growthbook/codegen.py b/growthbook/codegen.py index aacce80..9bf357f 100644 --- a/growthbook/codegen.py +++ b/growthbook/codegen.py @@ -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", + } ) @@ -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() diff --git a/tests/codegen/expected_output.py b/tests/codegen/expected_output.py index 962b8a7..4c86480 100644 --- a/tests/codegen/expected_output.py +++ b/tests/codegen/expected_output.py @@ -8,7 +8,7 @@ from growthbook import FeatureResult, GrowthBook, GrowthBookClient, UserContext -FeatureKey = Literal['banner_text', 'dark_mode', 'donut_price', 'max_items', 'meal_overrides', 'promo_banner', 'recent_tabs'] +FeatureKey = Literal['banner_text', 'dark_mode', 'donut_price', 'hero_layout', 'max_items', 'meal_overrides', 'promo_banner', 'recent_tabs'] class TypedGrowthBook(GrowthBook): @@ -28,6 +28,10 @@ def get_feature_value(self, key: Literal['donut_price'], fallback: None) -> Opti @overload def get_feature_value(self, key: Literal['donut_price'], fallback: Union[int, float]) -> Union[int, float]: ... @overload + def get_feature_value(self, key: Literal['hero_layout'], fallback: None) -> Optional[str]: ... + @overload + def get_feature_value(self, key: Literal['hero_layout'], fallback: str) -> str: ... + @overload def get_feature_value(self, key: Literal['max_items'], fallback: None) -> Optional[Union[int, float]]: ... @overload def get_feature_value(self, key: Literal['max_items'], fallback: Union[int, float]) -> Union[int, float]: ... @@ -53,6 +57,8 @@ def eval_feature(self, key: Literal['dark_mode']) -> "FeatureResult[bool]": ... @overload def eval_feature(self, key: Literal['donut_price']) -> "FeatureResult[Union[int, float]]": ... @overload + def eval_feature(self, key: Literal['hero_layout']) -> "FeatureResult[str]": ... + @overload def eval_feature(self, key: Literal['max_items']) -> "FeatureResult[Union[int, float]]": ... @overload def eval_feature(self, key: Literal['meal_overrides']) -> "FeatureResult[Dict[str, Any]]": ... @@ -87,6 +93,10 @@ async def get_feature_value(self, key: Literal['donut_price'], fallback: None, u @overload async def get_feature_value(self, key: Literal['donut_price'], fallback: Union[int, float], user_context: UserContext) -> Union[int, float]: ... @overload + async def get_feature_value(self, key: Literal['hero_layout'], fallback: None, user_context: UserContext) -> Optional[str]: ... + @overload + async def get_feature_value(self, key: Literal['hero_layout'], fallback: str, user_context: UserContext) -> str: ... + @overload async def get_feature_value(self, key: Literal['max_items'], fallback: None, user_context: UserContext) -> Optional[Union[int, float]]: ... @overload async def get_feature_value(self, key: Literal['max_items'], fallback: Union[int, float], user_context: UserContext) -> Union[int, float]: ... @@ -112,6 +122,8 @@ async def eval_feature(self, key: Literal['dark_mode'], user_context: UserContex @overload async def eval_feature(self, key: Literal['donut_price'], user_context: UserContext) -> "FeatureResult[Union[int, float]]": ... @overload + async def eval_feature(self, key: Literal['hero_layout'], user_context: UserContext) -> "FeatureResult[str]": ... + @overload async def eval_feature(self, key: Literal['max_items'], user_context: UserContext) -> "FeatureResult[Union[int, float]]": ... @overload async def eval_feature(self, key: Literal['meal_overrides'], user_context: UserContext) -> "FeatureResult[Dict[str, Any]]": ... diff --git a/tests/codegen/sample_features.json b/tests/codegen/sample_features.json index bb0d2f9..1e2bb94 100644 --- a/tests/codegen/sample_features.json +++ b/tests/codegen/sample_features.json @@ -5,6 +5,7 @@ "donut_price": {"defaultValue": 2.5}, "max_items": {"defaultValue": 10}, "meal_overrides": {"defaultValue": {"gluten_free": true}}, + "hero_layout": {"rules": [{"contextualVariations": ["control", "wide"], "contextualBanditRef": "cb_1"}]}, "promo_banner": {"rules": [{"force": "SUMMER"}]}, "recent_tabs": {"defaultValue": ["home", "search"]} } From ec497bf864c2cf54ab27f286f7f1f6121b57d6f0 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Wed, 2 Sep 2026 12:51:56 -0700 Subject: [PATCH 13/40] test: pin contextual bandit fields in the typing suite --- tests/typing/bad_usage.py | 1 + tests/typing/good_usage.py | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/tests/typing/bad_usage.py b/tests/typing/bad_usage.py index 95430bf..5526dee 100644 --- a/tests/typing/bad_usage.py +++ b/tests/typing/bad_usage.py @@ -63,6 +63,7 @@ async def async_logger( # Typo'd keyword arguments are no longer silently swallowed by checkers. Experiment(key="t", variations=[1, 2], weigths=[0.5, 0.5]) # expect-error FeatureRule(force="on", coverege=0.5) # expect-error +FeatureRule(contextualVariatons=["a", "b"]) # expect-error # Wrong argument types to public methods. GrowthBook(attributes="not-a-dict") # expect-error diff --git a/tests/typing/good_usage.py b/tests/typing/good_usage.py index de33a0d..95cc1c8 100644 --- a/tests/typing/good_usage.py +++ b/tests/typing/good_usage.py @@ -6,8 +6,11 @@ from typing import Any, Dict, List, Optional from growthbook import ( + ContextualBanditAssignment, + ContextualBanditDefinition, Experiment, FeatureResult, + FeatureRule, GrowthBook, GrowthBookClient, JSONValue, @@ -24,9 +27,27 @@ shouted = gb.get_feature_value("banner", "blue").upper() flag: bool = gb.is_on("dark-mode") +# Contextual bandit payload fields are first-class named kwargs, and the +# assignment/definition shapes are exported TypedDicts. +cb_definition: ContextualBanditDefinition = { + "banditVersion": 7, + "contexts": [{"leafId": 1, "condition": {}, "weights": [1.0, 0.0]}], +} +cb_rule = FeatureRule(contextualBanditRef="cb_1", contextualVariations=["a", "b"]) +cb_exp = Experiment( + key="cb", + variations=["a", "b"], + contextualBandit={"leafId": 1, "variationWeights": [1.0, 0.0]}, +) +cb_assignment: Optional[ContextualBanditAssignment] = cb_exp.contextualBandit + # run() infers Result[T] from the experiment's variations. res: Result[int] = gb.run(Experiment(key="t", variations=[1, 2])) doubled: int = res.value * 2 +# Contextual bandit exposure metadata on Result (None for non-CB results). +res_leaf: Optional[int] = res.leafId +res_weights: Optional[List[float]] = res.variationWeights +res_bandit_version: Optional[int] = res.banditVersion str_res = gb.run(Experiment(key="t2", variations=["a", "b"])) upper: str = str_res.value.upper() From 4b4a9efb84b6739ffcd403469e2d685a9c3fdcdb Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Wed, 2 Sep 2026 12:54:53 -0700 Subject: [PATCH 14/40] preserve absent payload sections across repository refreshes --- growthbook/growthbook_client.py | 27 +++++++++++++------ tests/test_contextual_bandit.py | 47 +++++++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/growthbook/growthbook_client.py b/growthbook/growthbook_client.py index aa9c376..2b6ae54 100644 --- a/growthbook/growthbook_client.py +++ b/growthbook/growthbook_client.py @@ -112,13 +112,22 @@ def __init__(self) -> None: } self._lock = threading.Lock() - def update(self, features: Dict[str, Any], saved_groups: Dict[str, Any], + def update(self, features: Optional[Dict[str, Any]], + saved_groups: Optional[Dict[str, Any]] = None, contextual_bandits: Optional[Dict[str, Any]] = None) -> None: - """Simple thread-safe update of cache with new API data""" + """Thread-safe update of cache with new API data. + + A section passed as None (the payload omitted that key) keeps its + current value — a partial or broken refresh must not wipe state that + evaluations depend on (mirrors JS setPayload). Pass an explicit empty + dict to clear a section.""" with self._lock: - self._cache['features'] = dict(features) - self._cache['savedGroups'] = dict(saved_groups) - self._cache['contextualBandits'] = dict(contextual_bandits or {}) + if features is not None: + self._cache['features'] = dict(features) + if saved_groups is not None: + self._cache['savedGroups'] = dict(saved_groups) + if contextual_bandits is not None: + self._cache['contextualBandits'] = dict(contextual_bandits) def get_current_state(self) -> Dict[str, Any]: """Get current cache state""" @@ -345,10 +354,12 @@ async def refresh_operation(self) -> AsyncIterator[bool]: async def _handle_feature_update(self, data: Dict[str, Any]) -> None: """Update features with memory optimization""" # Directly update with new features + # Sections absent from the payload are passed as None so the cache + # preserves their current values (see FeatureCache.update). self._feature_cache.update( - data.get("features", {}), - data.get("savedGroups", {}), - data.get("contextualBandits", {}) + data.get("features"), + data.get("savedGroups"), + data.get("contextualBandits") ) # Create a copy of callbacks to avoid modification during iteration diff --git a/tests/test_contextual_bandit.py b/tests/test_contextual_bandit.py index 0568bae..a7ccd2f 100644 --- a/tests/test_contextual_bandit.py +++ b/tests/test_contextual_bandit.py @@ -207,9 +207,52 @@ def test_feature_cache_round_trip(): cache.update({"f": {"defaultValue": 1}}, {"sg": []}, CB_MAP) state = cache.get_current_state() assert state["contextualBandits"] == CB_MAP - # Omitting the argument clears the map (payload without the key) - cache.update({}, {}) + # A refresh whose payload omits a section (None) preserves its current + # value; an explicit empty dict clears it. + cache.update({"f": {"defaultValue": 2}}) + assert cache.get_current_state()["contextualBandits"] == CB_MAP + assert cache.get_current_state()["savedGroups"] == {"sg": []} + cache.update(None, {}, {}) assert cache.get_current_state()["contextualBandits"] == {} + assert cache.get_current_state()["features"] == {"f": {"defaultValue": 2}} + + +@pytest.mark.asyncio +async def test_refresh_without_bandit_key_preserves_map(): + """A second refresh whose payload lacks contextualBandits (partial or + broken payload) must not wipe the map mid-flight — evals keep routing + with the last known weights.""" + EnhancedFeatureRepository._instances = {} + full_payload = { + "features": CB_FEATURES, + "savedGroups": {}, + "contextualBandits": CB_MAP, + } + with patch( + "growthbook.FeatureRepository.load_features_async", + new_callable=AsyncMock, + return_value=full_payload, + ), patch( + "growthbook.growthbook_client.EnhancedFeatureRepository.start_feature_refresh", + new_callable=AsyncMock, + ), patch( + "growthbook.growthbook_client.EnhancedFeatureRepository.stop_refresh", + new_callable=AsyncMock, + ): + async with GrowthBookClient( + Options(api_host="https://localhost.growthbook.io", client_key="test-key") + ) as client: + repo = client._features_repository + # A full refresh populates the cache... + await repo._handle_feature_update(full_payload) + # ...then a partial refresh missing the CB section must not wipe it + await repo._handle_feature_update({"features": CB_FEATURES}) + assert repo._feature_cache.get_current_state()["contextualBandits"] == CB_MAP + result = await client.eval_feature( + "bandit-feature", UserContext(attributes={"id": "1"}) + ) + assert result.value == "control" + assert result.experimentResult.leafId == 1 @pytest.mark.asyncio From fe7071840f83af71a6a8fc9791474c593d302244 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Wed, 2 Sep 2026 12:55:41 -0700 Subject: [PATCH 15/40] add set_payload to both clients for seeding full SDK payloads --- growthbook/growthbook.py | 7 ++++++ growthbook/growthbook_client.py | 7 ++++++ tests/test_contextual_bandit.py | 39 +++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/growthbook/growthbook.py b/growthbook/growthbook.py index 8668272..5403da0 100644 --- a/growthbook/growthbook.py +++ b/growthbook/growthbook.py @@ -1052,6 +1052,13 @@ def _on_feature_update(self, features_data: Dict[str, Any]) -> None: if features_data and "features" in features_data: self.set_features(features_data["features"]) + def set_payload(self, payload: Dict[str, Any]) -> None: + """Set features, saved groups, and contextual bandits from a full + (decrypted) SDK payload, e.g. one fetched out-of-band from the + GrowthBook API. Mirrors the JS SDK's setPayload: only the sections + present in the payload are overwritten.""" + self._on_feature_update(payload) + def load_features(self, force_refresh: bool = False) -> None: """Load features from the configured endpoint, populating the cache. diff --git a/growthbook/growthbook_client.py b/growthbook/growthbook_client.py index 2b6ae54..cba8345 100644 --- a/growthbook/growthbook_client.py +++ b/growthbook/growthbook_client.py @@ -855,6 +855,13 @@ async def log_event( async def set_features(self, features: Dict[str, Any]) -> None: await self._feature_update_callback({"features": features}) + + async def set_payload(self, payload: Dict[str, Any]) -> None: + """Set features, saved groups, and contextual bandits from a full + (decrypted) SDK payload, e.g. one fetched out-of-band from the + GrowthBook API. Mirrors the JS SDK's setPayload: only the sections + present in the payload are overwritten.""" + await self._feature_update_callback(payload) async def _refresh_sticky_buckets(self, attributes: Dict[str, Any]) -> Dict[str, Any]: diff --git a/tests/test_contextual_bandit.py b/tests/test_contextual_bandit.py index a7ccd2f..5b07a9f 100644 --- a/tests/test_contextual_bandit.py +++ b/tests/test_contextual_bandit.py @@ -202,6 +202,45 @@ def test_one_bandit_shared_by_two_features(): gb.destroy() +def test_sync_set_payload_seeds_bandit_map(): + gb = GrowthBook(attributes={"id": "1"}) + gb.set_payload({"features": CB_FEATURES, "contextualBandits": CB_MAP}) + res = gb.eval_feature("bandit-feature") + assert res.value == "control" + assert res.experimentResult.leafId == 1 + # A later payload without the section preserves it + gb.set_payload({"features": CB_FEATURES}) + assert gb.eval_feature("bandit-feature").experimentResult.leafId == 1 + gb.destroy() + + +@pytest.mark.asyncio +async def test_async_set_payload_seeds_bandit_map(): + EnhancedFeatureRepository._instances = {} + with patch( + "growthbook.FeatureRepository.load_features_async", + new_callable=AsyncMock, + return_value={"features": {}, "savedGroups": {}}, + ), patch( + "growthbook.growthbook_client.EnhancedFeatureRepository.start_feature_refresh", + new_callable=AsyncMock, + ), patch( + "growthbook.growthbook_client.EnhancedFeatureRepository.stop_refresh", + new_callable=AsyncMock, + ): + async with GrowthBookClient( + Options(api_host="https://localhost.growthbook.io", client_key="test-key") + ) as client: + await client.set_payload( + {"features": CB_FEATURES, "contextualBandits": CB_MAP} + ) + result = await client.eval_feature( + "bandit-feature", UserContext(attributes={"id": "1"}) + ) + assert result.value == "control" + assert result.experimentResult.leafId == 1 + + def test_feature_cache_round_trip(): cache = FeatureCache() cache.update({"f": {"defaultValue": 1}}, {"sg": []}, CB_MAP) From d85083b8522b582598b9c27be3ea2a27ef5fad2c Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Wed, 2 Sep 2026 12:58:02 -0700 Subject: [PATCH 16/40] log contextual bandit fallback paths at debug level (JS parity) --- growthbook/core.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/growthbook/core.py b/growthbook/core.py index 2671fdd..5b6894b 100644 --- a/growthbook/core.py +++ b/growthbook/core.py @@ -613,7 +613,10 @@ def _build_contextual_bandit_experiment( Dangling ref: run as a plain experiment with no bandit metadata at all.""" cb_definition = evalContext.global_ctx.contextual_bandits.get(contextual_bandit_ref) if not cb_definition: - logger.warning( + # debug, not warning: this fires on EVERY evaluation of the feature, + # and a payload-skew window makes it reachable in normal operation. + # The JS SDK logs these only in debug mode for the same reason. + logger.debug( "Contextual bandit %s not found in payload, feature %s falls back to aggregate weights", contextual_bandit_ref, feature_id, @@ -626,9 +629,10 @@ def _build_contextual_bandit_experiment( try: leaf = _get_contextual_bandit_leaf(contexts, evalContext) except Exception: - logger.exception( + logger.debug( "Contextual bandit leaf selection failed, feature %s falls back to aggregate weights", feature_id, + exc_info=True, ) if leaf is not None: From e95e2d45aa45bf4222a76d5bfd850edc88cc8ee1 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Wed, 2 Sep 2026 12:58:02 -0700 Subject: [PATCH 17/40] snapshot tracking user context in _track, not per-eval in core --- growthbook/core.py | 12 +++++++----- growthbook/growthbook.py | 8 +++++++- growthbook/growthbook_client.py | 5 ++++- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/growthbook/core.py b/growthbook/core.py index 5b6894b..7e41765 100644 --- a/growthbook/core.py +++ b/growthbook/core.py @@ -17,7 +17,6 @@ Result, UserContext, VariationMeta, - tracking_user_context, ) @@ -575,7 +574,7 @@ def _fire_rule_tracks( bucket=res_data.get("bucket"), stickyBucketUsed=res_data.get("stickyBucketUsed", False), ) - tracking_cb(experiment, result, tracking_user_context(eval_context.user)) + tracking_cb(experiment, result, eval_context.user) except Exception: logger.exception("Failed to fire rule.tracks tracking event") @@ -1159,10 +1158,13 @@ def run_experiment(experiment: Experiment[Any], "assignment doc was not persisted" ) - # 14. Fire the tracking callback if set. The user context is snapshotted - # so the logged attributes are exactly the ones used for bucketing. + # 14. Fire the tracking callback if set. The clients' _track wrappers + # snapshot the user context (tracking_user_context) before invoking the + # user's callback, so the logged attributes are exactly the ones used + # for bucketing; snapshotting there instead of here keeps evals + # allocation-free when no tracking callback is configured. if tracking_cb: - tracking_cb(experiment, result, tracking_user_context(evalContext.user)) + tracking_cb(experiment, result, evalContext.user) # 15. Return the result logger.debug("Assigned variation %d in experiment %s", assigned, experiment.key) diff --git a/growthbook/growthbook.py b/growthbook/growthbook.py index 5403da0..593bc97 100644 --- a/growthbook/growthbook.py +++ b/growthbook/growthbook.py @@ -1488,7 +1488,13 @@ def _track(self, experiment: Experiment[Any], result: Result[Any], user_context: ) if not self._tracked.get(key): try: - self._trackingCallback(experiment=experiment, result=result, user_context=user_context) + # Snapshot so the logged attributes are exactly the ones used + # for bucketing, even if the caller mutates them afterwards. + self._trackingCallback( + experiment=experiment, + result=result, + user_context=tracking_user_context(user_context), + ) self._tracked[key] = True except Exception as e: logger.exception(e) diff --git a/growthbook/growthbook_client.py b/growthbook/growthbook_client.py index cba8345..44edef0 100644 --- a/growthbook/growthbook_client.py +++ b/growthbook/growthbook_client.py @@ -780,10 +780,13 @@ def _track(self, experiment: Experiment[Any], result: Result[Any], user_context: # Tracking callbacks are invoked by keyword (same # contract as the sync client): implementations must # name their params experiment/result/user_context. + # user_context is snapshotted so the logged attributes + # are exactly the ones used for bucketing, even if the + # caller mutates them afterwards. kwargs={ "experiment": experiment, "result": result, - "user_context": user_context, + "user_context": tracking_user_context(user_context), }, ) self._tracked[key] = True From abbd24b7ba3647198d3a1795ae780cac5de7100f Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 3 Sep 2026 22:32:43 -0700 Subject: [PATCH 18/40] append contextual bandit params after existing signature positions --- growthbook/growthbook.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/growthbook/growthbook.py b/growthbook/growthbook.py index 593bc97..31fc320 100644 --- a/growthbook/growthbook.py +++ b/growthbook/growthbook.py @@ -883,7 +883,6 @@ def __init__( sticky_bucket_service: Optional[AbstractStickyBucketService] = None, sticky_bucket_identifier_attributes: Optional[List[str]] = None, saved_groups: Optional[Dict[str, Any]] = None, - contextual_bandits: Optional[Dict[str, Any]] = None, remote_eval: bool = False, cache_key_attributes: Optional[List[str]] = None, streaming: bool = False, @@ -892,6 +891,9 @@ def __init__( stale_ttl: int = 300, # 5 minutes default plugins: Optional[List["PluginLike"]] = None, skip_all_experiments: bool = False, + # New in 3.1.0 — appended after the 3.0.0 parameters so existing + # positional call sites keep their meaning. + contextual_bandits: Optional[Dict[str, Any]] = None, # Deprecated args (camelCase spellings fold into their snake_case # equivalents above; the snake_case value wins when both are given) trackingCallback: Optional[TrackingCallback] = None, @@ -903,9 +905,9 @@ def __init__( http_connect_timeout: Optional[int] = None, http_read_timeout: Optional[int] = None, savedGroups: Optional[Dict[str, Any]] = None, - contextualBandits: Optional[Dict[str, Any]] = None, remoteEval: bool = False, cacheKeyAttributes: Optional[List[str]] = None, + contextualBandits: Optional[Dict[str, Any]] = None, ) -> None: remote_eval = remote_eval or remoteEval saved_groups = saved_groups if saved_groups is not None else savedGroups From 1cb73cd1ec6e1a92a0254b9dd7212c538664d56f Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 3 Sep 2026 22:43:45 -0700 Subject: [PATCH 19/40] publish one coherent eval snapshot per refresh, even for map-only payloads --- growthbook/growthbook.py | 73 +++++++++++++++++---------------- tests/test_contextual_bandit.py | 45 ++++++++++++++++++++ 2 files changed, 82 insertions(+), 36 deletions(-) diff --git a/growthbook/growthbook.py b/growthbook/growthbook.py index 31fc320..1369e2f 100644 --- a/growthbook/growthbook.py +++ b/growthbook/growthbook.py @@ -11,6 +11,7 @@ import warnings from abc import ABC, abstractmethod +from dataclasses import replace from typing import TYPE_CHECKING, Optional, Any, Set, Tuple, List, Dict, Callable, cast from typing_extensions import deprecated @@ -1045,14 +1046,37 @@ def _remote_eval_payload(self) -> Dict[str, Any]: def _on_feature_update(self, features_data: Dict[str, Any]) -> None: """Callback to handle automatic feature updates from FeatureRepository""" - # savedGroups/contextualBandits must be assigned before set_features(), - # which is what re-syncs them into the shared global evaluation context. - if features_data and "savedGroups" in features_data: - self._saved_groups = features_data["savedGroups"] - if features_data and "contextualBandits" in features_data: - self._contextual_bandits = features_data["contextualBandits"] - if features_data and "features" in features_data: - self.set_features(features_data["features"]) + if features_data: + self._ingest_payload(features_data) + + def _ingest_payload(self, data: Dict[str, Any]) -> None: + """Apply the sections present in a (decrypted) SDK payload. + + Sections absent from the payload are preserved (JS setPayload + semantics), and the evaluation context is republished even for + map-only payloads so a savedGroups/contextualBandits update takes + effect without waiting for the next features update.""" + if "savedGroups" in data: + self._saved_groups = data["savedGroups"] + if "contextualBandits" in data: + self._contextual_bandits = data["contextualBandits"] + if "features" in data: + self.set_features(data["features"]) + elif "savedGroups" in data or "contextualBandits" in data: + self._publish_global_context() + + def _publish_global_context(self) -> None: + # Swap in a complete snapshot with a single reference rebind + # (atomic under the GIL) so concurrent lock-free evals never observe + # features from one payload generation combined with savedGroups or + # contextualBandits from another. In-flight evals keep the previous + # coherent snapshot; the async client works the same way. + self._global_ctx = replace( + self._global_ctx, + features=self._features, + saved_groups=self._saved_groups, + contextual_bandits=self._contextual_bandits, + ) def set_payload(self, payload: Dict[str, Any]) -> None: """Set features, saved groups, and contextual bandits from a full @@ -1080,14 +1104,8 @@ def load_features(self, force_refresh: bool = False) -> None: cache_key_attributes=self._cacheKeyAttributes, force_refresh=force_refresh, ) - if response is not None and "savedGroups" in response: - self._saved_groups = response["savedGroups"] - - if response is not None and "contextualBandits" in response: - self._contextual_bandits = response["contextualBandits"] - - if response is not None and "features" in response.keys(): - self.set_features(response["features"]) + if response is not None: + self._ingest_payload(response) async def load_features_async(self, force_refresh: bool = False) -> None: if not self._client_key: @@ -1106,12 +1124,7 @@ async def load_features_async(self, force_refresh: bool = False) -> None: ) if features is not None: - if "savedGroups" in features: - self._saved_groups = features["savedGroups"] - if "contextualBandits" in features: - self._contextual_bandits = features["contextualBandits"] - if "features" in features: - self.set_features(features["features"]) + self._ingest_payload(features) def _features_event_handler(self, features: str) -> None: decoded = json.loads(features) @@ -1122,12 +1135,7 @@ def _features_event_handler(self, features: str) -> None: key = self._api_host + "::" + self._client_key if data is not None: - if "savedGroups" in data: - self._saved_groups = data["savedGroups"] - if "contextualBandits" in data: - self._contextual_bandits = data["contextualBandits"] - if "features" in data: - self.set_features(data["features"]) + self._ingest_payload(data) feature_repo.save_in_cache(key, data, self._cache_ttl) def _dispatch_sse_event(self, event_data: Dict[str, Any]) -> None: @@ -1196,14 +1204,7 @@ def set_features(self, features: Dict[str, Any]) -> None: rules=feature.get("rules", []), defaultValue=feature.get("defaultValue", None), ) - # Update the global context with the new features and saved - # groups. The maps go first: evals in other threads key off the - # features dict, so if they observe a torn update it must be old - # features with new maps (harmless) rather than new features - # whose savedGroups/contextualBandits refs aren't loaded yet. - self._global_ctx.saved_groups = self._saved_groups - self._global_ctx.contextual_bandits = self._contextual_bandits - self._global_ctx.features = self._features + self._publish_global_context() self.refresh_sticky_buckets() finally: self._is_updating_features = False diff --git a/tests/test_contextual_bandit.py b/tests/test_contextual_bandit.py index 5b07a9f..6363f66 100644 --- a/tests/test_contextual_bandit.py +++ b/tests/test_contextual_bandit.py @@ -379,3 +379,48 @@ async def test_async_client_contextual_bandits(): assert result.experimentResult.leafId == 1 assert result.experimentResult.variationWeights == [1, 0] assert result.experimentResult.banditVersion == 7 + + +def test_sync_set_payload_bandits_only_republishes(): + """A payload carrying only contextualBandits must take effect immediately, + not wait for the next features update to republish the eval context.""" + gb = GrowthBook(attributes={"id": "1"}, features=CB_FEATURES, contextualBandits=CB_MAP) + assert gb.eval_feature("bandit-feature").value == "control" + + flipped = { + "cb-bandit": { + "banditVersion": 8, + "contexts": [{"leafId": 2, "condition": {}, "weights": [0, 1]}], + } + } + gb.set_payload({"contextualBandits": flipped}) + res = gb.eval_feature("bandit-feature") + assert res.value == "treatment" + assert res.experimentResult.leafId == 2 + assert res.experimentResult.banditVersion == 8 + + # An explicit empty map clears it: the ref dangles, so the rule runs as a + # plain experiment on aggregate weights with no bandit metadata. + gb.set_payload({"contextualBandits": {}}) + assert gb.eval_feature("bandit-feature").experimentResult.leafId is None + gb.destroy() + + +def test_sync_refresh_swaps_a_coherent_snapshot(): + """Refreshes rebind one new GlobalContext instead of mutating fields in + place, so a concurrent eval holding the old snapshot never sees features + from one payload generation with bandit weights from another.""" + gb = GrowthBook(attributes={"id": "1"}, features=CB_FEATURES, contextualBandits=CB_MAP) + before = gb._global_ctx + gb.set_payload( + { + "features": CB_FEATURES, + "contextualBandits": { + "cb-bandit": {"contexts": [{"leafId": 2, "condition": {}, "weights": [0, 1]}]} + }, + } + ) + assert gb._global_ctx is not before + # The old snapshot is untouched — in-flight evals stay coherent. + assert before.contextual_bandits == CB_MAP + gb.destroy() From 646249bcfec38b2588f5dd8d097f2ca13e501051 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 3 Sep 2026 22:44:11 -0700 Subject: [PATCH 20/40] tolerate malformed contextual bandit definitions and leaves in core --- growthbook/core.py | 29 ++++++++++++++++++++++++----- tests/test_contextual_bandit.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/growthbook/core.py b/growthbook/core.py index 7e41765..1a832c4 100644 --- a/growthbook/core.py +++ b/growthbook/core.py @@ -610,8 +610,12 @@ def _build_contextual_bandit_experiment( No match / empty contexts / errored selection: bucketing keeps the rule's server-computed marginal weights and the fallback leafId -1 is reported. Dangling ref: run as a plain experiment with no bandit metadata at all.""" - cb_definition = evalContext.global_ctx.contextual_bandits.get(contextual_bandit_ref) - if not cb_definition: + # Typed as Any: the map is payload data, so the definition shape is only + # a promise — the guards below must survive malformed entries at runtime. + cb_definition: Any = evalContext.global_ctx.contextual_bandits.get(contextual_bandit_ref) + # An empty-dict definition counts as found (JS `!cbDefinition` semantics): + # it takes the fallback-leaf path below, not the dangling-ref return. + if not cb_definition and not isinstance(cb_definition, dict): # debug, not warning: this fires on EVERY evaluation of the feature, # and a payload-skew window makes it reachable in normal operation. # The JS SDK logs these only in debug mode for the same reason. @@ -621,6 +625,11 @@ def _build_contextual_bandit_experiment( feature_id, ) return + if not isinstance(cb_definition, dict): + # Malformed payload entry: treat like a definition with no leaves so + # bucketing still degrades to the rule's aggregate weights instead of + # crashing the evaluation. + cb_definition = {} leaf = None contexts = cb_definition.get("contexts") or [] @@ -634,10 +643,20 @@ def _build_contextual_bandit_experiment( exc_info=True, ) - if leaf is not None: - weights = leaf["weights"] + weights = leaf.get("weights") if leaf is not None else None + leaf_id = leaf.get("leafId") if leaf is not None else None + if leaf is not None and (weights is None or leaf_id is None): + # A matched leaf missing its weights or id is a malformed payload; + # degrade to the aggregate-weights fallback rather than bucketing on + # partial data. + logger.debug( + "Contextual bandit leaf is malformed, feature %s falls back to aggregate weights", + feature_id, + ) + + if weights is not None and leaf_id is not None: experiment.weights = weights - cb: ContextualBanditAssignment = {"leafId": leaf["leafId"], "variationWeights": weights} + cb: ContextualBanditAssignment = {"leafId": leaf_id, "variationWeights": weights} else: logger.debug( "Contextual bandit: no matching leaf, feature %s uses aggregate weights", feature_id diff --git a/tests/test_contextual_bandit.py b/tests/test_contextual_bandit.py index 6363f66..15e53bf 100644 --- a/tests/test_contextual_bandit.py +++ b/tests/test_contextual_bandit.py @@ -424,3 +424,33 @@ def test_sync_refresh_swaps_a_coherent_snapshot(): # The old snapshot is untouched — in-flight evals stay coherent. assert before.contextual_bandits == CB_MAP gb.destroy() + + +def test_malformed_bandit_payload_degrades_gracefully(): + """Malformed definitions/leaves must never crash evaluation: a matched + leaf missing weights or leafId falls back to aggregate weights (leaf -1), + like a definition of the wrong shape. Only a null definition is treated + as a dangling ref (plain experiment, no metadata).""" + missing_weights = { + "cb-bandit": {"banditVersion": 7, "contexts": [{"leafId": 1, "condition": {}}]} + } + missing_leaf_id = { + "cb-bandit": {"contexts": [{"condition": {}, "weights": [1, 0]}]} + } + for bad_map in (missing_weights, missing_leaf_id, {"cb-bandit": [1, 2]}, {"cb-bandit": {}}): + gb = GrowthBook(attributes={"id": "1"}, features=CB_FEATURES, contextualBandits=bad_map) + res = gb.eval_feature("bandit-feature") + assert res.experimentResult.leafId == -1 + assert res.experimentResult.variationWeights == [0.5, 0.5] + gb.destroy() + + # banditVersion still reported when the definition carries one + gb = GrowthBook(attributes={"id": "1"}, features=CB_FEATURES, contextualBandits=missing_weights) + assert gb.eval_feature("bandit-feature").experimentResult.banditVersion == 7 + gb.destroy() + + gb = GrowthBook(attributes={"id": "1"}, features=CB_FEATURES, contextualBandits={"cb-bandit": None}) + res = gb.eval_feature("bandit-feature") + assert res.experimentResult is not None + assert res.experimentResult.leafId is None + gb.destroy() From ccc9a2c1831a26fc39e43e180ff7f00faa84ab14 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 3 Sep 2026 22:44:23 -0700 Subject: [PATCH 21/40] drop encrypted payload keys after failed decryption (JS decryptPayload parity) --- growthbook/growthbook.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/growthbook/growthbook.py b/growthbook/growthbook.py index 1369e2f..bd5ecb4 100644 --- a/growthbook/growthbook.py +++ b/growthbook/growthbook.py @@ -715,6 +715,10 @@ def decrypt_response(self, data: Dict[str, Any], decryption_key: str) -> Optiona data['contextualBandits'] = json.loads(decrypted) del data['encryptedContextualBandits'] except Exception: + # Drop the undecryptable section (JS decryptPayload deletes the + # encrypted key either way); absent sections are preserved + # downstream, so the previous coherent map stays active. + del data['encryptedContextualBandits'] logger.warning( "Failed to decrypt contextual bandits from GrowthBook API response" ) @@ -728,6 +732,7 @@ def decrypt_response(self, data: Dict[str, Any], decryption_key: str) -> Optiona del data['encryptedSavedGroups'] return data except Exception: + del data['encryptedSavedGroups'] logger.warning( "Failed to decrypt saved groups from GrowthBook API response" ) From ac7e77347325cfa4ec580acdec8e6f263c89dfb5 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 3 Sep 2026 22:44:52 -0700 Subject: [PATCH 22/40] set_payload accepts encrypted payload sections in both clients (JS parity) --- growthbook/growthbook.py | 32 +++++++++++--- growthbook/growthbook_client.py | 18 +++++--- tests/test_contextual_bandit.py | 75 +++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 10 deletions(-) diff --git a/growthbook/growthbook.py b/growthbook/growthbook.py index bd5ecb4..c115d21 100644 --- a/growthbook/growthbook.py +++ b/growthbook/growthbook.py @@ -739,6 +739,25 @@ def decrypt_response(self, data: Dict[str, Any], decryption_key: str) -> Optiona return data + def decrypt_payload_sections( + self, payload: Dict[str, Any], decryption_key: str + ) -> Optional[Dict[str, Any]]: + """Decrypt any encrypted sections of an SDK payload, returning a copy + with the plaintext sections in place (JS setPayload accepts encrypted + payloads the same way). Payloads with no encrypted sections are + returned as-is; None means the features section failed to decrypt and + the payload should be discarded.""" + if not any( + k in payload + for k in ( + "encryptedFeatures", + "encryptedContextualBandits", + "encryptedSavedGroups", + ) + ): + return payload + return self.decrypt_response(dict(payload), decryption_key) + # Fetch features from the GrowthBook API def _fetch_features( self, api_host: str, client_key: str, decryption_key: str = "" @@ -1084,11 +1103,14 @@ def _publish_global_context(self) -> None: ) def set_payload(self, payload: Dict[str, Any]) -> None: - """Set features, saved groups, and contextual bandits from a full - (decrypted) SDK payload, e.g. one fetched out-of-band from the - GrowthBook API. Mirrors the JS SDK's setPayload: only the sections - present in the payload are overwritten.""" - self._on_feature_update(payload) + """Set features, saved groups, and contextual bandits from a full SDK + payload, e.g. one fetched out-of-band from the GrowthBook API. + Mirrors the JS SDK's setPayload: only the sections present in the + payload are overwritten, and encrypted sections are decrypted with + the configured decryption_key.""" + data = feature_repo.decrypt_payload_sections(payload, self._decryption_key) + if data is not None: + self._on_feature_update(data) def load_features(self, force_refresh: bool = False) -> None: """Load features from the configured endpoint, populating the cache. diff --git a/growthbook/growthbook_client.py b/growthbook/growthbook_client.py index 44edef0..fb4ec3b 100644 --- a/growthbook/growthbook_client.py +++ b/growthbook/growthbook_client.py @@ -860,11 +860,19 @@ async def set_features(self, features: Dict[str, Any]) -> None: await self._feature_update_callback({"features": features}) async def set_payload(self, payload: Dict[str, Any]) -> None: - """Set features, saved groups, and contextual bandits from a full - (decrypted) SDK payload, e.g. one fetched out-of-band from the - GrowthBook API. Mirrors the JS SDK's setPayload: only the sections - present in the payload are overwritten.""" - await self._feature_update_callback(payload) + """Set features, saved groups, and contextual bandits from a full SDK + payload, e.g. one fetched out-of-band from the GrowthBook API. + Mirrors the JS SDK's setPayload: only the sections present in the + payload are overwritten, and encrypted sections are decrypted with + the configured decryption_key.""" + # decrypt_payload_sections is stateless, so the module singleton is a + # safe stand-in when set_payload is called before initialize(). + repo: FeatureRepository = self._features_repository or feature_repo + data = repo.decrypt_payload_sections( + payload, self.options.decryption_key or "" + ) + if data is not None: + await self._feature_update_callback(data) async def _refresh_sticky_buckets(self, attributes: Dict[str, Any]) -> Dict[str, Any]: diff --git a/tests/test_contextual_bandit.py b/tests/test_contextual_bandit.py index 15e53bf..73755d3 100644 --- a/tests/test_contextual_bandit.py +++ b/tests/test_contextual_bandit.py @@ -454,3 +454,78 @@ def test_malformed_bandit_payload_degrades_gracefully(): assert res.experimentResult is not None assert res.experimentResult.leafId is None gb.destroy() + + +def test_sync_set_payload_accepts_encrypted_sections(): + """JS setPayload accepts encrypted payloads; the Python port decrypts + encrypted sections with the configured decryption_key.""" + key = "Zvwv/+uhpFDznZ6SX28Yjg==" + gb = GrowthBook(attributes={"id": "1"}, decryption_key=key) + gb.set_payload( + { + "encryptedFeatures": _encrypt(json.dumps(CB_FEATURES), key), + "encryptedContextualBandits": _encrypt(json.dumps(CB_MAP), key), + } + ) + res = gb.eval_feature("bandit-feature") + assert res.value == "control" + assert res.experimentResult.leafId == 1 + gb.destroy() + + +@pytest.mark.asyncio +async def test_async_set_payload_accepts_encrypted_sections(): + key = "Zvwv/+uhpFDznZ6SX28Yjg==" + EnhancedFeatureRepository._instances = {} + with patch( + "growthbook.FeatureRepository.load_features_async", + new_callable=AsyncMock, + return_value={"features": {}, "savedGroups": {}}, + ), patch( + "growthbook.growthbook_client.EnhancedFeatureRepository.start_feature_refresh", + new_callable=AsyncMock, + ), patch( + "growthbook.growthbook_client.EnhancedFeatureRepository.stop_refresh", + new_callable=AsyncMock, + ): + async with GrowthBookClient( + Options( + api_host="https://localhost.growthbook.io", + client_key="test-key", + decryption_key=key, + ) + ) as client: + await client.set_payload( + { + "encryptedFeatures": _encrypt(json.dumps(CB_FEATURES), key), + "encryptedContextualBandits": _encrypt(json.dumps(CB_MAP), key), + } + ) + result = await client.eval_feature( + "bandit-feature", UserContext(attributes={"id": "1"}) + ) + assert result.value == "control" + assert result.experimentResult.leafId == 1 + + +def test_failed_bandit_decryption_preserves_previous_map(): + """An undecryptable contextualBandits section is dropped (encrypted key + removed, like JS decryptPayload) and, being absent, preserves the previous + coherent map instead of wiping it.""" + key = "Zvwv/+uhpFDznZ6SX28Yjg==" + out = feature_repo.decrypt_response( + {"features": {}, "encryptedContextualBandits": "bad.cipher"}, key + ) + assert out is not None + assert "encryptedContextualBandits" not in out + assert "contextualBandits" not in out + + gb = GrowthBook( + attributes={"id": "1"}, + decryption_key=key, + features=CB_FEATURES, + contextualBandits=CB_MAP, + ) + gb.set_payload({"features": CB_FEATURES, "encryptedContextualBandits": "bad.cipher"}) + assert gb.eval_feature("bandit-feature").experimentResult.leafId == 1 + gb.destroy() From d5727e1a9df7dc595b99b953f7ea128e78e8f4f3 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 3 Sep 2026 22:44:56 -0700 Subject: [PATCH 23/40] send exposure-time user context attributes with experiment ingestor events --- growthbook/plugins/growthbook_tracking.py | 26 ++++++++++--- tests/test_plugins.py | 46 ++++++++++++++++++++++- 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/growthbook/plugins/growthbook_tracking.py b/growthbook/plugins/growthbook_tracking.py index 3020582..a27584a 100644 --- a/growthbook/plugins/growthbook_tracking.py +++ b/growthbook/plugins/growthbook_tracking.py @@ -173,7 +173,7 @@ def cleanup(self) -> None: def _setup_experiment_tracking(self, gb_instance: Any) -> None: def tracking_wrapper(experiment: Any, result: Any, user_context: Any = None) -> None: - self._track_experiment_viewed(experiment, result) + self._track_experiment_viewed(experiment, result, user_context) if self.additional_callback: self._safe_execute(self.additional_callback, experiment, result, user_context) @@ -218,11 +218,25 @@ def eval_feature_wrapper(key: str, *args: Any, **kwargs: Any) -> Any: gb_instance.eval_feature = eval_feature_wrapper - def _track_experiment_viewed(self, experiment: Any, result: Any) -> None: + def _track_experiment_viewed( + self, experiment: Any, result: Any, user_context: Any = None + ) -> None: try: - attrs: Dict[str, Any] = {} - if self._gb_instance is not None: - attrs = getattr(self._gb_instance, "_attributes", {}) or {} + # Prefer the exposure-time user context both clients pass to the + # tracking callback (the JS plugin does the same via userContext). + # The instance fallback only exists for the sync client's legacy + # two-argument invocation paths; the async client has no + # _attributes, so without user_context its events had none. + attrs: Dict[str, Any] = ( + getattr(user_context, "attributes", None) + or getattr(self._gb_instance, "_attributes", None) + or {} + ) + url: str = ( + getattr(user_context, "url", None) + or getattr(self._gb_instance, "_url", None) + or "" + ) payload = _build_event_payload( event_name="$$experiment_viewed", @@ -236,7 +250,7 @@ def _track_experiment_viewed(self, experiment: Any, result: Any) -> None: "hash_value": result.hashValue, }, attributes=attrs, - url=getattr(self._gb_instance, "_url", "") if self._gb_instance else "", + url=url, sdk_version=self._get_sdk_version(), ) self._add_event_to_batch(payload) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 901d475..a13cf7a 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -654,4 +654,48 @@ def test_build_event_payload_device_id_fallback(self): self.assertEqual(payload["device_id"], "anon-1") payload2 = _build_event_payload("e", {}, {"id": "raw-id"}, "", "x") - self.assertEqual(payload2["device_id"], "raw-id") \ No newline at end of file + self.assertEqual(payload2["device_id"], "raw-id") + +class TestExperimentEventContext(unittest.TestCase): + """Experiment-viewed ingestor events must carry the exposure-time user + context (the attributes used for bucketing/leaf routing), matching the JS + plugin's use of userContext. The instance fallback only covers the sync + client's legacy two-argument invocation.""" + + def _make_plugin_and_events(self): + from growthbook.plugins.growthbook_tracking import GrowthBookTrackingPlugin + + plugin = GrowthBookTrackingPlugin(ingestor_host="https://test.growthbook.io") + events = [] + plugin._add_event_to_batch = events.append + return plugin, events + + def _result_stub(self): + return MagicMock( + variationId=1, inExperiment=True, hashUsed=True, + hashAttribute="id", hashValue="u1", key="1", + ) + + def test_event_uses_exposure_time_user_context(self): + plugin, events = self._make_plugin_and_events() + user_context = MagicMock( + attributes={"id": "u1", "country": "US"}, url="https://app.example.com" + ) + plugin._track_experiment_viewed( + Experiment(key="exp", variations=[0, 1]), self._result_stub(), user_context + ) + self.assertEqual(len(events), 1) + self.assertEqual(events[0]["context_json"], {"country": "US"}) + self.assertEqual(events[0]["device_id"], "u1") + self.assertEqual(events[0]["url"], "https://app.example.com") + + def test_event_falls_back_to_instance_attributes(self): + plugin, events = self._make_plugin_and_events() + plugin._gb_instance = MagicMock( + _attributes={"id": "u2"}, _url="https://legacy.example.com" + ) + plugin._track_experiment_viewed( + Experiment(key="exp", variations=[0, 1]), self._result_stub(), None + ) + self.assertEqual(events[0]["device_id"], "u2") + self.assertEqual(events[0]["url"], "https://legacy.example.com") From 0c00c7035ab16602710b357d7bdfd04a9e281bed Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 3 Sep 2026 22:44:56 -0700 Subject: [PATCH 24/40] doc: contextual bandit README section and changelog entries --- CHANGELOG.md | 15 +++++++++++++++ README.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cb74d4..927d949 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # 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; refreshes publish one coherent evaluation snapshot atomically in the synchronous client, matching the async client. + * Malformed bandit definitions or leaves degrade to the rule's aggregate weights instead of raising during evaluation. + +### 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) diff --git a/README.md b/README.md index 3a63b8b..3e45cc2 100644 --- a/README.md +++ b/README.md @@ -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: From 06d9dc4f8d23cfc1b82cd3fb091b2da3d4daeb21 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 3 Sep 2026 23:27:17 -0700 Subject: [PATCH 25/40] validate bandit leaf weight vectors before substituting them for bucketing --- growthbook/core.py | 32 ++++++++++++++++++++++++++++---- tests/test_contextual_bandit.py | 19 ++++++++++++++++++- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/growthbook/core.py b/growthbook/core.py index 1a832c4..dce86f9 100644 --- a/growthbook/core.py +++ b/growthbook/core.py @@ -510,6 +510,13 @@ def getEqualWeights(numVariations: int) -> List[float]: return [1 / numVariations for _ in range(numVariations)] +# Weight vectors whose sum falls outside this tolerance are replaced with +# equal weights at bucketing time (shared with the contextual bandit leaf +# validation, which must reject exactly what bucketing would reject). +WEIGHT_SUM_MIN = 0.99 +WEIGHT_SUM_MAX = 1.01 + + def getBucketRanges( numVariations: int, coverage: float = 1, weights: Optional[List[float]] = None ) -> List[Tuple[float, float]]: @@ -521,7 +528,7 @@ def getBucketRanges( weights = getEqualWeights(numVariations) if len(weights) != numVariations: weights = getEqualWeights(numVariations) - if sum(weights) < 0.99 or sum(weights) > 1.01: + if sum(weights) < WEIGHT_SUM_MIN or sum(weights) > WEIGHT_SUM_MAX: weights = getEqualWeights(numVariations) cumulative: float = 0 @@ -597,6 +604,20 @@ def _get_contextual_bandit_leaf( return None +def _usable_bandit_weights(weights: Any, num_variations: int) -> bool: + """True only for a leaf weight vector that getBucketRanges will honor + as-is: a list of numbers, one per variation, summing to ~1. Anything else + would be silently replaced with equal weights at bucketing time (or crash + len()/sum() on non-list payloads), so reporting it as the assignment + propensities would be wrong — the caller degrades to the + aggregate-weights fallback instead.""" + if not isinstance(weights, list) or len(weights) != num_variations: + return False + if not all(isinstance(w, (int, float)) for w in weights): + return False + return WEIGHT_SUM_MIN <= sum(weights) <= WEIGHT_SUM_MAX + + def _build_contextual_bandit_experiment( experiment: Experiment[Any], contextual_bandit_ref: str, @@ -645,10 +666,13 @@ def _build_contextual_bandit_experiment( weights = leaf.get("weights") if leaf is not None else None leaf_id = leaf.get("leafId") if leaf is not None else None + if weights is not None and not _usable_bandit_weights(weights, len(experiment.variations)): + weights = None if leaf is not None and (weights is None or leaf_id is None): - # A matched leaf missing its weights or id is a malformed payload; - # degrade to the aggregate-weights fallback rather than bucketing on - # partial data. + # A matched leaf missing its id, or whose weight vector bucketing + # would reject (see _usable_bandit_weights), is a malformed payload; + # degrade to the aggregate-weights fallback rather than reporting + # propensities that differ from the weights actually used. logger.debug( "Contextual bandit leaf is malformed, feature %s falls back to aggregate weights", feature_id, diff --git a/tests/test_contextual_bandit.py b/tests/test_contextual_bandit.py index 73755d3..ea50316 100644 --- a/tests/test_contextual_bandit.py +++ b/tests/test_contextual_bandit.py @@ -437,7 +437,24 @@ def test_malformed_bandit_payload_degrades_gracefully(): missing_leaf_id = { "cb-bandit": {"contexts": [{"condition": {}, "weights": [1, 0]}]} } - for bad_map in (missing_weights, missing_leaf_id, {"cb-bandit": [1, 2]}, {"cb-bandit": {}}): + + def leaf_weights(weights): + return {"cb-bandit": {"contexts": [{"leafId": 1, "condition": {}, "weights": weights}]}} + + for bad_map in ( + missing_weights, + missing_leaf_id, + {"cb-bandit": [1, 2]}, + {"cb-bandit": {}}, + # Weight vectors that bucketing would reject (or crash on) are + # treated as malformed leaves, so reported propensities always match + # the weights actually used. + leaf_weights("ab"), # not a list + leaf_weights(5), # not sized + leaf_weights([1, 0, 0]), # wrong length for 2 variations + leaf_weights([1, "x"]), # non-numeric entry + leaf_weights([0.9, 0.9]), # sum outside bucketing tolerance + ): gb = GrowthBook(attributes={"id": "1"}, features=CB_FEATURES, contextualBandits=bad_map) res = gb.eval_feature("bandit-feature") assert res.experimentResult.leafId == -1 From a548ba8ead2bd67d851ce50b1236ba745c1b2427 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 3 Sep 2026 23:51:02 -0700 Subject: [PATCH 26/40] reject unusable bandit leaf weights; report the vector bucketing actually uses --- growthbook/core.py | 65 +++++++++++++++++++++++---------- tests/test_contextual_bandit.py | 48 ++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 20 deletions(-) diff --git a/growthbook/core.py b/growthbook/core.py index dce86f9..6f46614 100644 --- a/growthbook/core.py +++ b/growthbook/core.py @@ -511,12 +511,31 @@ def getEqualWeights(numVariations: int) -> List[float]: # Weight vectors whose sum falls outside this tolerance are replaced with -# equal weights at bucketing time (shared with the contextual bandit leaf -# validation, which must reject exactly what bucketing would reject). +# equal weights at bucketing time. WEIGHT_SUM_MIN = 0.99 WEIGHT_SUM_MAX = 1.01 +def _normalized_weights(numVariations: int, weights: Any) -> List[float]: + """The weight vector bucketing will actually use: the input when it is a + valid list of the right length whose sum is within tolerance, equal + weights otherwise. Single source of truth shared by getBucketRanges and + the contextual bandit metadata paths, so reported propensities can never + differ from the weights used for bucketing.""" + try: + if ( + isinstance(weights, list) + and len(weights) == numVariations + and WEIGHT_SUM_MIN <= sum(weights) <= WEIGHT_SUM_MAX + ): + return weights + except TypeError: + # Non-numeric entries: fall through to equal weights, like the JS SDK + # (whose length/sum checks silently reject garbage instead of raising). + pass + return getEqualWeights(numVariations) + + def getBucketRanges( numVariations: int, coverage: float = 1, weights: Optional[List[float]] = None ) -> List[Tuple[float, float]]: @@ -524,12 +543,7 @@ def getBucketRanges( coverage = 0 if coverage > 1: coverage = 1 - if weights is None: - weights = getEqualWeights(numVariations) - if len(weights) != numVariations: - weights = getEqualWeights(numVariations) - if sum(weights) < WEIGHT_SUM_MIN or sum(weights) > WEIGHT_SUM_MAX: - weights = getEqualWeights(numVariations) + weights = _normalized_weights(numVariations, weights) cumulative: float = 0 ranges = [] @@ -605,15 +619,20 @@ def _get_contextual_bandit_leaf( def _usable_bandit_weights(weights: Any, num_variations: int) -> bool: - """True only for a leaf weight vector that getBucketRanges will honor - as-is: a list of numbers, one per variation, summing to ~1. Anything else - would be silently replaced with equal weights at bucketing time (or crash - len()/sum() on non-list payloads), so reporting it as the assignment - propensities would be wrong — the caller degrades to the - aggregate-weights fallback instead.""" + """True only for a leaf weight vector that is safe to substitute for + bucketing: a list with one finite, non-negative number per variation + (booleans excluded), summing to ~1. Anything else is a malformed leaf + and the caller degrades to the aggregate-weights fallback, so reported + propensities always describe a sane vector.""" if not isinstance(weights, list) or len(weights) != num_variations: return False - if not all(isinstance(w, (int, float)) for w in weights): + if not all( + isinstance(w, (int, float)) + and not isinstance(w, bool) + and math.isfinite(w) + and w >= 0 + for w in weights + ): return False return WEIGHT_SUM_MIN <= sum(weights) <= WEIGHT_SUM_MAX @@ -687,9 +706,12 @@ def _build_contextual_bandit_experiment( ) cb = { "leafId": CONTEXTUAL_BANDIT_FALLBACK_LEAF_ID, - "variationWeights": experiment.weights - if experiment.weights is not None - else getEqualWeights(len(experiment.variations)), + # Report what bucketing will actually use — getBucketRanges + # replaces invalid vectors with equal weights, so raw rule + # weights could misstate the propensities. + "variationWeights": _normalized_weights( + len(experiment.variations), experiment.weights + ), } bandit_version = cb_definition.get("banditVersion") @@ -968,11 +990,14 @@ def run_experiment(experiment: Experiment[Any], if evalContext.user.overrides.get(experiment.key, None): experiment.update(evalContext.user.overrides[experiment.key]) # Keep reported bandit propensities in sync with the weights actually - # used for bucketing (an override may have replaced them) + # used for bucketing: an override may have replaced them, and + # getBucketRanges replaces invalid vectors with equal weights. if experiment.contextualBandit and experiment.weights: synced: ContextualBanditAssignment = { "leafId": experiment.contextualBandit["leafId"], - "variationWeights": experiment.weights, + "variationWeights": _normalized_weights( + len(experiment.variations), experiment.weights + ), } if "banditVersion" in experiment.contextualBandit: synced["banditVersion"] = experiment.contextualBandit["banditVersion"] diff --git a/tests/test_contextual_bandit.py b/tests/test_contextual_bandit.py index ea50316..ed5d754 100644 --- a/tests/test_contextual_bandit.py +++ b/tests/test_contextual_bandit.py @@ -546,3 +546,51 @@ def test_failed_bandit_decryption_preserves_previous_map(): gb.set_payload({"features": CB_FEATURES, "encryptedContextualBandits": "bad.cipher"}) assert gb.eval_feature("bandit-feature").experimentResult.leafId == 1 gb.destroy() + + +def test_bandit_metadata_reports_weights_bucketing_uses(): + """Invalid vectors on the fallback and override paths are normalized the + same way getBucketRanges normalizes them, so Result.variationWeights can + never differ from the weights actually used. Negative, non-finite, and + boolean leaf weights are rejected outright.""" + # Fallback path: rule weights [0.9, 0.9] are invalid (sum 1.8), so + # bucketing uses equal weights — the metadata must say so too. + bad_marginals = { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.9, 0.9], + "contextualBanditRef": "cb-bandit", + } + ], + } + } + gb = GrowthBook( + attributes={"id": "1"}, + features=bad_marginals, + contextualBandits={"cb-bandit": {"contexts": []}}, + ) + res = gb.eval_feature("bandit-feature") + assert res.experimentResult.leafId == -1 + assert res.experimentResult.variationWeights == [0.5, 0.5] + gb.destroy() + + # Matched-leaf path: negative / non-finite / boolean entries are malformed. + for bad_weights in ([1.5, -0.5], [float("inf"), 1.0], [True, False]): + bad_map = { + "cb-bandit": { + "contexts": [{"leafId": 1, "condition": {}, "weights": bad_weights}] + } + } + gb = GrowthBook(attributes={"id": "1"}, features=CB_FEATURES, contextualBandits=bad_map) + res = gb.eval_feature("bandit-feature") + assert res.experimentResult.leafId == -1, bad_weights + assert res.experimentResult.variationWeights == [0.5, 0.5] + gb.destroy() From 9862a2dac6b2a07a036db73d2b1d6b36e2e9797d Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 3 Sep 2026 23:51:03 -0700 Subject: [PATCH 27/40] preserve contextual bandit fields through remote-eval rule.tracks results --- growthbook/core.py | 6 ++ tests/test_contextual_bandit.py | 122 ++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/growthbook/core.py b/growthbook/core.py index 6f46614..7994723 100644 --- a/growthbook/core.py +++ b/growthbook/core.py @@ -594,6 +594,12 @@ def _fire_rule_tracks( meta=meta, bucket=res_data.get("bucket"), stickyBucketUsed=res_data.get("stickyBucketUsed", False), + # Contextual bandit exposure metadata evaluated by the proxy. + # The JS SDK passes the proxy result through verbatim, so + # these must survive the reconstruction too. + leafId=res_data.get("leafId"), + variationWeights=res_data.get("variationWeights"), + banditVersion=res_data.get("banditVersion"), ) tracking_cb(experiment, result, eval_context.user) except Exception: diff --git a/tests/test_contextual_bandit.py b/tests/test_contextual_bandit.py index ed5d754..f76426f 100644 --- a/tests/test_contextual_bandit.py +++ b/tests/test_contextual_bandit.py @@ -548,6 +548,128 @@ def test_failed_bandit_decryption_preserves_previous_map(): gb.destroy() +def test_remote_eval_tracks_preserve_bandit_fields(): + """rule.tracks entries from the remote-eval proxy must reach the tracking + callback with their contextual bandit fields intact — the JS SDK passes + the proxy result through verbatim.""" + tracked = [] + + def on_view(experiment, result, user_context): + tracked.append(result) + + gb = GrowthBook( + attributes={"id": "1"}, + on_experiment_viewed=on_view, + features={ + "remote-feature": { + "defaultValue": "x", + "rules": [ + { + "force": "treatment", + "tracks": [ + { + "experiment": { + "key": "bandit-exp", + "variations": ["control", "treatment"], + }, + "result": { + "variationId": 1, + "inExperiment": True, + "hashUsed": True, + "hashAttribute": "id", + "hashValue": "1", + "value": "treatment", + "key": "1", + "featureId": "remote-feature", + "leafId": 8, + "variationWeights": [0.2, 0.8], + "banditVersion": 7, + }, + } + ], + } + ], + } + }, + ) + assert gb.eval_feature("remote-feature").value == "treatment" + assert len(tracked) == 1 + assert tracked[0].leafId == 8 + assert tracked[0].variationWeights == [0.2, 0.8] + assert tracked[0].banditVersion == 7 + gb.destroy() + + +@pytest.mark.asyncio +async def test_async_remote_eval_tracks_preserve_bandit_fields(): + tracked = [] + + def on_view(experiment, result, user_context): + tracked.append(result) + + EnhancedFeatureRepository._instances = {} + with patch( + "growthbook.FeatureRepository.load_features_async", + new_callable=AsyncMock, + return_value={"features": {}, "savedGroups": {}}, + ), patch( + "growthbook.growthbook_client.EnhancedFeatureRepository.start_feature_refresh", + new_callable=AsyncMock, + ), patch( + "growthbook.growthbook_client.EnhancedFeatureRepository.stop_refresh", + new_callable=AsyncMock, + ): + async with GrowthBookClient( + Options( + api_host="https://localhost.growthbook.io", + client_key="test-key", + on_experiment_viewed=on_view, + ) + ) as client: + await client.set_payload( + { + "features": { + "remote-feature": { + "defaultValue": "x", + "rules": [ + { + "force": "treatment", + "tracks": [ + { + "experiment": { + "key": "bandit-exp", + "variations": ["control", "treatment"], + }, + "result": { + "variationId": 1, + "inExperiment": True, + "hashUsed": True, + "hashAttribute": "id", + "hashValue": "1", + "value": "treatment", + "key": "1", + "leafId": 8, + "variationWeights": [0.2, 0.8], + "banditVersion": 7, + }, + } + ], + } + ], + } + } + } + ) + result = await client.eval_feature( + "remote-feature", UserContext(attributes={"id": "1"}) + ) + assert result.value == "treatment" + assert len(tracked) == 1 + assert tracked[0].leafId == 8 + assert tracked[0].variationWeights == [0.2, 0.8] + assert tracked[0].banditVersion == 7 + + def test_bandit_metadata_reports_weights_bucketing_uses(): """Invalid vectors on the fallback and override paths are normalized the same way getBucketRanges normalizes them, so Result.variationWeights can From 6980754cab564621aa4c8d43757d4a64a2a565f4 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 3 Sep 2026 23:51:03 -0700 Subject: [PATCH 28/40] serialize payload writers so concurrent updates can't publish mixed generations --- growthbook/growthbook.py | 50 +++++++++++++++++------------ tests/test_contextual_bandit.py | 57 +++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 20 deletions(-) diff --git a/growthbook/growthbook.py b/growthbook/growthbook.py index c115d21..25dff77 100644 --- a/growthbook/growthbook.py +++ b/growthbook/growthbook.py @@ -997,6 +997,10 @@ def __init__( self._assigned: Dict[str, Any] = {} self._subscriptions: Set[Callable[[Experiment[Any], Result[Any]], None]] = set() self._is_updating_features = False + # Serializes payload writers (set_features/set_payload/refreshes). + # Re-entrant because _ingest_payload calls set_features while holding + # it. Evals stay lock-free — they read the published snapshot. + self._payload_lock = threading.RLock() self._event_logger: Optional[EventLogger] = None # support plugins @@ -1079,15 +1083,20 @@ def _ingest_payload(self, data: Dict[str, Any]) -> None: Sections absent from the payload are preserved (JS setPayload semantics), and the evaluation context is republished even for map-only payloads so a savedGroups/contextualBandits update takes - effect without waiting for the next features update.""" - if "savedGroups" in data: - self._saved_groups = data["savedGroups"] - if "contextualBandits" in data: - self._contextual_bandits = data["contextualBandits"] - if "features" in data: - self.set_features(data["features"]) - elif "savedGroups" in data or "contextualBandits" in data: - self._publish_global_context() + effect without waiting for the next features update. + + Writers are serialized: without the lock, two concurrent updates + (e.g. set_payload and a background refresh) could interleave their + section writes and publish a snapshot mixing payload generations.""" + with self._payload_lock: + if "savedGroups" in data: + self._saved_groups = data["savedGroups"] + if "contextualBandits" in data: + self._contextual_bandits = data["contextualBandits"] + if "features" in data: + self.set_features(data["features"]) + elif "savedGroups" in data or "contextualBandits" in data: + self._publish_global_context() def _publish_global_context(self) -> None: # Swap in a complete snapshot with a single reference rebind @@ -1222,17 +1231,18 @@ def set_features(self, features: Dict[str, Any]) -> None: # Prevent infinite recursion during feature updates self._is_updating_features = True try: - self._features = {} - for key, feature in features.items(): - if isinstance(feature, Feature): - self._features[key] = feature - else: - self._features[key] = Feature( - rules=feature.get("rules", []), - defaultValue=feature.get("defaultValue", None), - ) - self._publish_global_context() - self.refresh_sticky_buckets() + with self._payload_lock: + self._features = {} + for key, feature in features.items(): + if isinstance(feature, Feature): + self._features[key] = feature + else: + self._features[key] = Feature( + rules=feature.get("rules", []), + defaultValue=feature.get("defaultValue", None), + ) + self._publish_global_context() + self.refresh_sticky_buckets() finally: self._is_updating_features = False diff --git a/tests/test_contextual_bandit.py b/tests/test_contextual_bandit.py index f76426f..83e03bc 100644 --- a/tests/test_contextual_bandit.py +++ b/tests/test_contextual_bandit.py @@ -670,6 +670,63 @@ def on_view(experiment, result, user_context): assert tracked[0].banditVersion == 7 +def test_concurrent_payload_writers_publish_coherent_generations(): + """Two writers (e.g. set_payload and a background refresh) must not + interleave section writes: every published snapshot pairs features and + contextualBandits from the same payload generation. Deterministic: writer + A is held mid-ingest while writer B runs; without writer serialization, A + would then publish its features with B's bandit map.""" + import threading + + gen_a = { + "features": {"gen": {"defaultValue": "A"}}, + "contextualBandits": {"gen": {"banditVersion": 1, "contexts": []}}, + } + gen_b = { + "features": {"gen": {"defaultValue": "B"}}, + "contextualBandits": {"gen": {"banditVersion": 2, "contexts": []}}, + } + + gb = GrowthBook(attributes={"id": "1"}) + published = [] + orig_publish = gb._publish_global_context + + def recording_publish(): + orig_publish() + ctx = gb._global_ctx + feature = ctx.features.get("gen") + bandit = ctx.contextual_bandits.get("gen") or {} + published.append((feature.defaultValue if feature else None, bandit.get("banditVersion"))) + + gb._publish_global_context = recording_publish + + gate = threading.Event() + held = threading.Event() + orig_set_features = gb.set_features + + def slow_set_features(features): + if not held.is_set(): + held.set() + gate.wait(timeout=5) + orig_set_features(features) + + gb.set_features = slow_set_features + + writer_a = threading.Thread(target=gb.set_payload, args=(gen_a,)) + writer_b = threading.Thread(target=gb.set_payload, args=(gen_b,)) + writer_a.start() + assert held.wait(timeout=5) + writer_b.start() + gate.set() + writer_a.join(timeout=5) + writer_b.join(timeout=5) + + assert published, "writers must have published" + for pair in published: + assert pair in (("A", 1), ("B", 2)), f"mixed payload generation published: {pair}" + gb.destroy() + + def test_bandit_metadata_reports_weights_bucketing_uses(): """Invalid vectors on the fallback and override paths are normalized the same way getBucketRanges normalizes them, so Result.variationWeights can From 6c93f8c76fe986c31b63ad942d9571de72bb1211 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 3 Sep 2026 23:51:03 -0700 Subject: [PATCH 29/40] append contextual bandit params after deprecated parameters too --- growthbook/growthbook.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/growthbook/growthbook.py b/growthbook/growthbook.py index 25dff77..ba8c9d5 100644 --- a/growthbook/growthbook.py +++ b/growthbook/growthbook.py @@ -916,9 +916,6 @@ def __init__( stale_ttl: int = 300, # 5 minutes default plugins: Optional[List["PluginLike"]] = None, skip_all_experiments: bool = False, - # New in 3.1.0 — appended after the 3.0.0 parameters so existing - # positional call sites keep their meaning. - contextual_bandits: Optional[Dict[str, Any]] = None, # Deprecated args (camelCase spellings fold into their snake_case # equivalents above; the snake_case value wins when both are given) trackingCallback: Optional[TrackingCallback] = None, @@ -932,6 +929,9 @@ def __init__( savedGroups: Optional[Dict[str, Any]] = None, remoteEval: bool = False, cacheKeyAttributes: Optional[List[str]] = None, + # New in 3.1.0 — appended after ALL 3.0.0 parameters (deprecated ones + # included) so every existing positional call site keeps its meaning. + contextual_bandits: Optional[Dict[str, Any]] = None, contextualBandits: Optional[Dict[str, Any]] = None, ) -> None: remote_eval = remote_eval or remoteEval From 2cbfb0bfe552bcf75eaa9e9733eaf22e645e1c4a Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 3 Sep 2026 23:51:47 -0700 Subject: [PATCH 30/40] freeze user attributes at the async eval boundary; deep-copy tracking snapshots --- growthbook/common_types.py | 25 +++++-- growthbook/growthbook_client.py | 21 +++++- tests/test_contextual_bandit.py | 114 ++++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 8 deletions(-) diff --git a/growthbook/common_types.py b/growthbook/common_types.py index dc3abbc..218e812 100644 --- a/growthbook/common_types.py +++ b/growthbook/common_types.py @@ -587,15 +587,30 @@ 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 tracking_user_context(user: "UserContext") -> "UserContext": """Exposure-time snapshot of a user context for tracking and feature-usage callbacks (JS SDK: getTrackingUserContext). - The attributes dict is shallow-copied so callbacks — including ones that - defer processing — always see the values that were used for bucketing - and contextual bandit leaf routing, even if the caller mutates - attributes afterwards.""" - return replace(user, attributes=dict(user.attributes)) + 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 diff --git a/growthbook/growthbook_client.py b/growthbook/growthbook_client.py index fb4ec3b..e4b63e2 100644 --- a/growthbook/growthbook_client.py +++ b/growthbook/growthbook_client.py @@ -1,7 +1,7 @@ #!/usr/bin/env python import inspect import json -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace import random import logging from types import TracebackType @@ -36,6 +36,7 @@ Experiment, build_remote_eval_payload, features_from_dict, + snapshot_attributes, tracking_user_context, validate_remote_eval_options, ) @@ -1222,6 +1223,16 @@ async def create_evaluation_context(self, user_context: UserContext) -> Evaluati if global_context is None: raise RuntimeError("GrowthBook client not properly initialized") + # Freeze the caller's attributes BEFORE the first await: sticky bucket + # and remote-eval I/O yield the event loop, so another task or thread + # mutating the UserContext mid-evaluation must not change the remote + # payload, leaf routing, or what tracking reports. The caller's own + # context object is kept aside for read-your-writes on sticky docs. + caller_context = user_context + user_context = replace( + user_context, attributes=snapshot_attributes(user_context.attributes) + ) + if self.options.remote_eval and self._features_repository: # Per-user POST + cache: features come from the proxy filtered for # this UserContext, not from self._global_context.features. @@ -1252,8 +1263,10 @@ async def create_evaluation_context(self, user_context: UserContext) -> Evaluati # place when an experiment assigns a new sticky bucket, which is what # gives read-your-writes semantics while persistence happens # asynchronously (same mechanism as the JS SDK's - # stickyBucketAssignmentDocs). + # stickyBucketAssignmentDocs). Assigned to the caller's context too so + # callers keep observing assignments through the object they passed. user_context.sticky_bucket_assignment_docs = sticky_assignments + caller_context.sticky_bucket_assignment_docs = sticky_assignments return EvaluationContext( user=user_context, @@ -1276,7 +1289,9 @@ async def eval_feature(self, key: str, user_context: UserContext) -> FeatureResu try: self._run_user_callback( self.options.on_feature_usage, - (key, result, tracking_user_context(user_context)), + # context.user carries the attributes snapshot frozen at + # the eval boundary — the exact values used for routing. + (key, result, context.user), "feature usage", ) except Exception: diff --git a/tests/test_contextual_bandit.py b/tests/test_contextual_bandit.py index 83e03bc..40ecf92 100644 --- a/tests/test_contextual_bandit.py +++ b/tests/test_contextual_bandit.py @@ -727,6 +727,120 @@ def slow_set_features(features): gb.destroy() +# Leaf routing on both a top-level and a nested attribute, for the +# mutation-freezing tests below. +CB_NESTED_FEATURES = { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": [0.5, 0.5], + "contextualBanditRef": "cb-bandit", + } + ], + } +} +CB_NESTED_MAP = { + "cb-bandit": { + "banditVersion": 7, + "contexts": [ + { + "leafId": 1, + "condition": {"country": "US", "profile.tier": "pro"}, + "weights": [1, 0], + }, + {"leafId": 2, "condition": {}, "weights": [0, 1]}, + ], + } +} + + +@pytest.mark.asyncio +async def test_async_eval_freezes_attributes_before_awaits(): + """Attributes are snapshotted at the async eval boundary, before the + first await: a caller (or another task) mutating the UserContext while + sticky-bucket I/O is pending must not change leaf routing or what the + tracking callback reports — for top-level or nested keys.""" + tracked = [] + + def on_view(experiment, result, user_context): + tracked.append((result, user_context)) + + EnhancedFeatureRepository._instances = {} + with patch( + "growthbook.FeatureRepository.load_features_async", + new_callable=AsyncMock, + return_value={ + "features": CB_NESTED_FEATURES, + "savedGroups": {}, + "contextualBandits": CB_NESTED_MAP, + }, + ), patch( + "growthbook.growthbook_client.EnhancedFeatureRepository.start_feature_refresh", + new_callable=AsyncMock, + ), patch( + "growthbook.growthbook_client.EnhancedFeatureRepository.stop_refresh", + new_callable=AsyncMock, + ): + async with GrowthBookClient( + Options( + api_host="https://localhost.growthbook.io", + client_key="test-key", + on_experiment_viewed=on_view, + ) + ) as client: + caller_attrs = {"id": "1", "country": "US", "profile": {"tier": "pro"}} + user_context = UserContext(attributes=caller_attrs) + + async def mutating_refresh(attributes): + # Simulates a concurrent task mutating the caller's context + # while the eval awaits sticky-bucket I/O. + caller_attrs["country"] = "DE" + caller_attrs["profile"]["tier"] = "basic" + return {} + + client._refresh_sticky_buckets = mutating_refresh + + result = await client.eval_feature("bandit-feature", user_context) + # Routed with the attributes the call started with, not the + # mutated ones (which match only the catch-all leaf 2). + assert result.value == "control" + assert result.experimentResult.leafId == 1 + + (tracked_result, tracked_user) = tracked[0] + assert tracked_result.leafId == 1 + assert tracked_user.attributes["country"] == "US" + assert tracked_user.attributes["profile"]["tier"] == "pro" + + +def test_tracking_snapshot_preserves_nested_attributes(): + """Deferred tracking callbacks must see the nested attribute values used + at exposure time, even after the caller mutates them (the snapshot copies + containers recursively, not just the top level).""" + tracked = [] + + def on_view(experiment, result, user_context): + tracked.append(user_context) + + attrs = {"id": "1", "country": "US", "profile": {"tier": "pro"}} + gb = GrowthBook( + attributes=attrs, + features=CB_NESTED_FEATURES, + contextualBandits=CB_NESTED_MAP, + on_experiment_viewed=on_view, + ) + assert gb.eval_feature("bandit-feature").experimentResult.leafId == 1 + attrs["profile"]["tier"] = "basic" + assert tracked[0].attributes["profile"]["tier"] == "pro" + gb.destroy() + + def test_bandit_metadata_reports_weights_bucketing_uses(): """Invalid vectors on the fallback and override paths are normalized the same way getBucketRanges normalizes them, so Result.variationWeights can From 4ee9a1faa330065c2484fe74b0a77d8ddf45d118 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Thu, 3 Sep 2026 23:51:47 -0700 Subject: [PATCH 31/40] doc: changelog entries for the review-round fixes --- CHANGELOG.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 927d949..e9de842 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,10 @@ * 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; refreshes publish one coherent evaluation snapshot atomically in the synchronous client, matching the async client. - * Malformed bandit definitions or leaves degrade to the rule's aggregate weights instead of raising during evaluation. + * 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 leaf vectors with negative, non-finite, or boolean entries are rejected. + * Contextual bandit exposure metadata survives remote evaluation: `rule.tracks` results from the proxy keep `leafId`, `variationWeights`, and `banditVersion` when replayed through the tracking callback. + * The async client freezes user attributes (including nested containers) before its first await, so mutating a `UserContext` during in-flight I/O cannot change leaf routing, remote payloads, or what tracking reports; tracking snapshots copy nested containers in both clients. ### Bug Fixes From e1e07cb5388f53833d4b49c6d3f0a1704e82a25e Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Fri, 4 Sep 2026 00:11:39 -0700 Subject: [PATCH 32/40] snapshot all eval inputs, and only when the async eval can actually yield --- growthbook/growthbook_client.py | 33 +++++++++----- tests/test_contextual_bandit.py | 78 +++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 11 deletions(-) diff --git a/growthbook/growthbook_client.py b/growthbook/growthbook_client.py index e4b63e2..fb1a5d2 100644 --- a/growthbook/growthbook_client.py +++ b/growthbook/growthbook_client.py @@ -1223,15 +1223,25 @@ async def create_evaluation_context(self, user_context: UserContext) -> Evaluati if global_context is None: raise RuntimeError("GrowthBook client not properly initialized") - # Freeze the caller's attributes BEFORE the first await: sticky bucket - # and remote-eval I/O yield the event loop, so another task or thread - # mutating the UserContext mid-evaluation must not change the remote - # payload, leaf routing, or what tracking reports. The caller's own - # context object is kept aside for read-your-writes on sticky docs. + # Freeze the evaluation inputs BEFORE the first await, but only when + # this call can actually yield the event loop (remote-eval fetch or + # sticky-bucket I/O): only then can another task mutate the + # UserContext mid-evaluation and change the remote payload, leaf + # routing, or forced assignments. Plain CDN evaluations never yield, + # so they skip the copy entirely and stay allocation-free — deferred + # callbacks are protected separately by tracking_user_context at fire + # time. The caller's own context object is kept aside so sticky-doc + # read-your-writes still lands on it. caller_context = user_context - user_context = replace( - user_context, attributes=snapshot_attributes(user_context.attributes) - ) + if self.options.remote_eval or self.options.sticky_bucket_service is not None: + user_context = replace( + user_context, + attributes=snapshot_attributes(user_context.attributes), + groups=snapshot_attributes(user_context.groups), + forced_variations=snapshot_attributes(user_context.forced_variations), + forced_features=snapshot_attributes(user_context.forced_features), + overrides=snapshot_attributes(user_context.overrides), + ) if self.options.remote_eval and self._features_repository: # Per-user POST + cache: features come from the proxy filtered for @@ -1289,9 +1299,10 @@ async def eval_feature(self, key: str, user_context: UserContext) -> FeatureResu try: self._run_user_callback( self.options.on_feature_usage, - # context.user carries the attributes snapshot frozen at - # the eval boundary — the exact values used for routing. - (key, result, context.user), + # Fire-time snapshot: context.user may be the caller's own + # context (plain CDN evals skip the boundary copy), and + # this callback can run deferred on the event loop. + (key, result, tracking_user_context(context.user)), "feature usage", ) except Exception: diff --git a/tests/test_contextual_bandit.py b/tests/test_contextual_bandit.py index 40ecf92..dedcc82 100644 --- a/tests/test_contextual_bandit.py +++ b/tests/test_contextual_bandit.py @@ -16,6 +16,7 @@ from growthbook import ( GrowthBook, GrowthBookClient, + InMemoryStickyBucketService, feature_repo, ) from growthbook.growthbook_client import EnhancedFeatureRepository, FeatureCache @@ -793,6 +794,9 @@ def on_view(experiment, result, user_context): api_host="https://localhost.growthbook.io", client_key="test-key", on_experiment_viewed=on_view, + # Arms the boundary freeze: only evals that can yield + # (sticky/remote I/O) snapshot their inputs up front. + sticky_bucket_service=InMemoryStickyBucketService(), ) ) as client: caller_attrs = {"id": "1", "country": "US", "profile": {"tier": "pro"}} @@ -887,3 +891,77 @@ def test_bandit_metadata_reports_weights_bucketing_uses(): assert res.experimentResult.leafId == -1, bad_weights assert res.experimentResult.variationWeights == [0.5, 0.5] gb.destroy() + + +@pytest.mark.asyncio +async def test_plain_cdn_eval_skips_the_boundary_copy(): + """Without remote eval or a sticky bucket service, create_evaluation_context + has no await that can yield, so it must not pay for an input snapshot — + plain evaluations stay allocation-free (the eval context carries the + caller's own UserContext object).""" + EnhancedFeatureRepository._instances = {} + with patch( + "growthbook.FeatureRepository.load_features_async", + new_callable=AsyncMock, + return_value={"features": CB_FEATURES, "savedGroups": {}, "contextualBandits": CB_MAP}, + ), patch( + "growthbook.growthbook_client.EnhancedFeatureRepository.start_feature_refresh", + new_callable=AsyncMock, + ), patch( + "growthbook.growthbook_client.EnhancedFeatureRepository.stop_refresh", + new_callable=AsyncMock, + ): + async with GrowthBookClient( + Options(api_host="https://localhost.growthbook.io", client_key="test-key") + ) as client: + user_context = UserContext(attributes={"id": "1"}) + context = await client.create_evaluation_context(user_context) + assert context.user is user_context + result = await client.eval_feature("bandit-feature", user_context) + assert result.value == "control" + + +@pytest.mark.asyncio +async def test_async_eval_freezes_forced_inputs_before_awaits(): + """The boundary snapshot covers every mutable evaluation input, not just + attributes: forced variations and overrides mutated while sticky I/O is + pending must not turn a hashed assignment into a forced one.""" + EnhancedFeatureRepository._instances = {} + with patch( + "growthbook.FeatureRepository.load_features_async", + new_callable=AsyncMock, + return_value={"features": CB_FEATURES, "savedGroups": {}, "contextualBandits": CB_MAP}, + ), patch( + "growthbook.growthbook_client.EnhancedFeatureRepository.start_feature_refresh", + new_callable=AsyncMock, + ), patch( + "growthbook.growthbook_client.EnhancedFeatureRepository.stop_refresh", + new_callable=AsyncMock, + ): + async with GrowthBookClient( + Options( + api_host="https://localhost.growthbook.io", + client_key="test-key", + sticky_bucket_service=InMemoryStickyBucketService(), + ) + ) as client: + forced = {} + overrides = {} + user_context = UserContext( + attributes={"id": "1"}, forced_variations=forced, overrides=overrides + ) + + async def mutating_refresh(attributes): + forced["bandit-exp"] = 1 + overrides["bandit-exp"] = {"force": 1} + return {} + + client._refresh_sticky_buckets = mutating_refresh + + result = await client.eval_feature("bandit-feature", user_context) + # The catch-all leaf sends everyone to variation 0; the mid-await + # forced inputs must not flip this eval to variation 1. + assert result.value == "control" + assert result.experimentResult.variationId == 0 + assert result.experimentResult.hashUsed + From a3eaf69b9cc06f470d6068c8f5a4d95a33dc6114 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Fri, 4 Sep 2026 00:11:42 -0700 Subject: [PATCH 33/40] clear unusable aggregate and override weights on bandit rules --- growthbook/core.py | 38 ++++++++++++++++++--------- tests/test_contextual_bandit.py | 46 +++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 12 deletions(-) diff --git a/growthbook/core.py b/growthbook/core.py index 7994723..f957fdb 100644 --- a/growthbook/core.py +++ b/growthbook/core.py @@ -643,6 +643,25 @@ def _usable_bandit_weights(weights: Any, num_variations: int) -> bool: return WEIGHT_SUM_MIN <= sum(weights) <= WEIGHT_SUM_MAX +def _sanitize_bandit_experiment_weights(experiment: Experiment[Any]) -> List[float]: + """Strictly validate a bandit experiment's aggregate weights, clearing + them when unusable, and return the vector bucketing will use. + + Contextual-only guard: ordinary experiments keep getBucketRanges' + looser length/sum normalization (JS parity), but a bandit rule must + never bucket on negative ranges or report them as propensities — an + unusable vector is cleared so bucketing and metadata both fall back to + equal weights.""" + num_variations = len(experiment.variations) + if experiment.weights is not None and not _usable_bandit_weights( + experiment.weights, num_variations + ): + experiment.weights = None + if experiment.weights is None: + return getEqualWeights(num_variations) + return experiment.weights + + def _build_contextual_bandit_experiment( experiment: Experiment[Any], contextual_bandit_ref: str, @@ -712,12 +731,9 @@ def _build_contextual_bandit_experiment( ) cb = { "leafId": CONTEXTUAL_BANDIT_FALLBACK_LEAF_ID, - # Report what bucketing will actually use — getBucketRanges - # replaces invalid vectors with equal weights, so raw rule - # weights could misstate the propensities. - "variationWeights": _normalized_weights( - len(experiment.variations), experiment.weights - ), + # Unusable aggregate weights are cleared so bucketing and the + # reported propensities both degrade to equal weights. + "variationWeights": _sanitize_bandit_experiment_weights(experiment), } bandit_version = cb_definition.get("banditVersion") @@ -996,14 +1012,12 @@ def run_experiment(experiment: Experiment[Any], if evalContext.user.overrides.get(experiment.key, None): experiment.update(evalContext.user.overrides[experiment.key]) # Keep reported bandit propensities in sync with the weights actually - # used for bucketing: an override may have replaced them, and - # getBucketRanges replaces invalid vectors with equal weights. - if experiment.contextualBandit and experiment.weights: + # used for bucketing: an override may have replaced them, and an + # unusable override vector is cleared (equal weights for both). + if experiment.contextualBandit and experiment.weights is not None: synced: ContextualBanditAssignment = { "leafId": experiment.contextualBandit["leafId"], - "variationWeights": _normalized_weights( - len(experiment.variations), experiment.weights - ), + "variationWeights": _sanitize_bandit_experiment_weights(experiment), } if "banditVersion" in experiment.contextualBandit: synced["banditVersion"] = experiment.contextualBandit["banditVersion"] diff --git a/tests/test_contextual_bandit.py b/tests/test_contextual_bandit.py index dedcc82..e2f83c6 100644 --- a/tests/test_contextual_bandit.py +++ b/tests/test_contextual_bandit.py @@ -965,3 +965,49 @@ async def mutating_refresh(attributes): assert result.experimentResult.variationId == 0 assert result.experimentResult.hashUsed + +def test_invalid_aggregate_and_override_weights_are_sanitized(): + """Aggregate (fallback) and override weight vectors get the same strict + validation as matched leaves: negative or boolean entries would bucket on + nonsense ranges and report them as propensities, so the vector is cleared + and both bucketing and metadata use equal weights.""" + for bad_weights in ([1.5, -0.5], [True, False]): + features = { + "bandit-feature": { + "defaultValue": "default", + "rules": [ + { + "key": "bandit-exp", + "seed": "bandit-exp", + "hashAttribute": "id", + "hashVersion": 2, + "coverage": 1, + "contextualVariations": ["control", "treatment"], + "weights": bad_weights, + "contextualBanditRef": "cb-bandit", + } + ], + } + } + gb = GrowthBook( + attributes={"id": "1"}, + features=features, + contextualBandits={"cb-bandit": {"contexts": []}}, + ) + res = gb.eval_feature("bandit-feature") + assert res.experimentResult.leafId == -1, bad_weights + assert res.experimentResult.variationWeights == [0.5, 0.5], bad_weights + gb.destroy() + + # Override path: a context override replacing the weights with an + # unusable vector is cleared during the re-sync. + gb = GrowthBook( + attributes={"id": "1"}, + features=CB_FEATURES, + contextualBandits=CB_MAP, + overrides={"bandit-exp": {"weights": [1.5, -0.5]}}, + ) + res = gb.eval_feature("bandit-feature") + assert res.experimentResult is not None + assert res.experimentResult.variationWeights == [0.5, 0.5] + gb.destroy() From ae3db9a49beb3e35efc5099b2ba69fd4210d6dac Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Fri, 4 Sep 2026 00:11:42 -0700 Subject: [PATCH 34/40] doc: changelog for snapshot gating and weight sanitization --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9de842..49b654f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,9 @@ * 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 leaf vectors with negative, non-finite, or boolean entries are rejected. + * 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 leaf vectors with negative, non-finite, or boolean entries are rejected, and the same strict validation clears unusable aggregate/override vectors on bandit rules (equal weights for both bucketing and metadata). * Contextual bandit exposure metadata survives remote evaluation: `rule.tracks` results from the proxy keep `leafId`, `variationWeights`, and `banditVersion` when replayed through the tracking callback. - * The async client freezes user attributes (including nested containers) before its first await, so mutating a `UserContext` during in-flight I/O cannot change leaf routing, remote payloads, or what tracking reports; tracking snapshots copy nested containers in both clients. + * 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. ### Bug Fixes From daec77d41984a23ee22dd0eabcc5a98f73b7ec7e Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Fri, 4 Sep 2026 00:24:36 -0700 Subject: [PATCH 35/40] unify weight-vector validation: one rule for bucketing and bandit propensities --- CHANGELOG.md | 2 +- growthbook/core.py | 111 +++++++++++++------------------- tests/test_contextual_bandit.py | 25 ++++++- 3 files changed, 70 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49b654f..0a6d5f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ * 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 leaf vectors with negative, non-finite, or boolean entries are rejected, and the same strict validation clears unusable aggregate/override vectors on bandit rules (equal weights for both bucketing and metadata). + * 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 and a single validity rule now governs every weight vector: `getBucketRanges` normalizes vectors with negative, non-finite, boolean, or non-numeric entries (not just wrong length/sum) to equal weights, so bucket ranges can never be inverted, and bandit leaf/aggregate/override propensities always describe the vector actually used. * Contextual bandit exposure metadata survives remote evaluation: `rule.tracks` results from the proxy keep `leafId`, `variationWeights`, and `banditVersion` when replayed through the tracking callback. * 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. diff --git a/growthbook/core.py b/growthbook/core.py index f957fdb..e41c7b7 100644 --- a/growthbook/core.py +++ b/growthbook/core.py @@ -516,23 +516,38 @@ def getEqualWeights(numVariations: int) -> List[float]: WEIGHT_SUM_MAX = 1.01 +def _is_valid_weight_vector(weights: Any, num_variations: int) -> bool: + """The single weight-vector validity rule, shared by bucketing and every + contextual bandit path: a list with one finite, non-negative real number + per variation (booleans excluded), summing to ~1. Anything else is + replaced with equal weights wherever weights are consumed, so bucket + ranges can never be inverted and reported bandit propensities always + describe the vector actually used. + + Deliberately stricter than the JS SDK, which checks only length and sum + and will bucket on inverted ranges for e.g. [1.2, -0.2] — corrupt for + assignments and bandit training alike. The divergence exists only for + invalid payloads the server never produces (the shared conformance + corpus exercises none of them).""" + if not isinstance(weights, list) or len(weights) != num_variations: + return False + if not all( + isinstance(w, (int, float)) + and not isinstance(w, bool) + and math.isfinite(w) + and w >= 0 + for w in weights + ): + return False + return WEIGHT_SUM_MIN <= sum(weights) <= WEIGHT_SUM_MAX + + def _normalized_weights(numVariations: int, weights: Any) -> List[float]: - """The weight vector bucketing will actually use: the input when it is a - valid list of the right length whose sum is within tolerance, equal - weights otherwise. Single source of truth shared by getBucketRanges and - the contextual bandit metadata paths, so reported propensities can never - differ from the weights used for bucketing.""" - try: - if ( - isinstance(weights, list) - and len(weights) == numVariations - and WEIGHT_SUM_MIN <= sum(weights) <= WEIGHT_SUM_MAX - ): - return weights - except TypeError: - # Non-numeric entries: fall through to equal weights, like the JS SDK - # (whose length/sum checks silently reject garbage instead of raising). - pass + """The weight vector bucketing (and bandit metadata) will actually use: + the input when it passes _is_valid_weight_vector, equal weights + otherwise.""" + if _is_valid_weight_vector(weights, numVariations): + return cast(List[float], weights) return getEqualWeights(numVariations) @@ -624,44 +639,6 @@ def _get_contextual_bandit_leaf( return None -def _usable_bandit_weights(weights: Any, num_variations: int) -> bool: - """True only for a leaf weight vector that is safe to substitute for - bucketing: a list with one finite, non-negative number per variation - (booleans excluded), summing to ~1. Anything else is a malformed leaf - and the caller degrades to the aggregate-weights fallback, so reported - propensities always describe a sane vector.""" - if not isinstance(weights, list) or len(weights) != num_variations: - return False - if not all( - isinstance(w, (int, float)) - and not isinstance(w, bool) - and math.isfinite(w) - and w >= 0 - for w in weights - ): - return False - return WEIGHT_SUM_MIN <= sum(weights) <= WEIGHT_SUM_MAX - - -def _sanitize_bandit_experiment_weights(experiment: Experiment[Any]) -> List[float]: - """Strictly validate a bandit experiment's aggregate weights, clearing - them when unusable, and return the vector bucketing will use. - - Contextual-only guard: ordinary experiments keep getBucketRanges' - looser length/sum normalization (JS parity), but a bandit rule must - never bucket on negative ranges or report them as propensities — an - unusable vector is cleared so bucketing and metadata both fall back to - equal weights.""" - num_variations = len(experiment.variations) - if experiment.weights is not None and not _usable_bandit_weights( - experiment.weights, num_variations - ): - experiment.weights = None - if experiment.weights is None: - return getEqualWeights(num_variations) - return experiment.weights - - def _build_contextual_bandit_experiment( experiment: Experiment[Any], contextual_bandit_ref: str, @@ -710,13 +687,13 @@ def _build_contextual_bandit_experiment( weights = leaf.get("weights") if leaf is not None else None leaf_id = leaf.get("leafId") if leaf is not None else None - if weights is not None and not _usable_bandit_weights(weights, len(experiment.variations)): + if weights is not None and not _is_valid_weight_vector(weights, len(experiment.variations)): weights = None if leaf is not None and (weights is None or leaf_id is None): - # A matched leaf missing its id, or whose weight vector bucketing - # would reject (see _usable_bandit_weights), is a malformed payload; - # degrade to the aggregate-weights fallback rather than reporting - # propensities that differ from the weights actually used. + # A matched leaf missing its id, or whose weight vector fails the + # shared validity rule (see _is_valid_weight_vector), is a malformed + # payload; degrade to the aggregate-weights fallback rather than + # reporting propensities that differ from the weights actually used. logger.debug( "Contextual bandit leaf is malformed, feature %s falls back to aggregate weights", feature_id, @@ -731,9 +708,11 @@ def _build_contextual_bandit_experiment( ) cb = { "leafId": CONTEXTUAL_BANDIT_FALLBACK_LEAF_ID, - # Unusable aggregate weights are cleared so bucketing and the - # reported propensities both degrade to equal weights. - "variationWeights": _sanitize_bandit_experiment_weights(experiment), + # getBucketRanges applies the same normalization, so the reported + # propensities always match the vector bucketing will use. + "variationWeights": _normalized_weights( + len(experiment.variations), experiment.weights + ), } bandit_version = cb_definition.get("banditVersion") @@ -1012,12 +991,14 @@ def run_experiment(experiment: Experiment[Any], if evalContext.user.overrides.get(experiment.key, None): experiment.update(evalContext.user.overrides[experiment.key]) # Keep reported bandit propensities in sync with the weights actually - # used for bucketing: an override may have replaced them, and an - # unusable override vector is cleared (equal weights for both). + # used for bucketing: an override may have replaced them, and + # getBucketRanges normalizes unusable vectors to equal weights. if experiment.contextualBandit and experiment.weights is not None: synced: ContextualBanditAssignment = { "leafId": experiment.contextualBandit["leafId"], - "variationWeights": _sanitize_bandit_experiment_weights(experiment), + "variationWeights": _normalized_weights( + len(experiment.variations), experiment.weights + ), } if "banditVersion" in experiment.contextualBandit: synced["banditVersion"] = experiment.contextualBandit["banditVersion"] diff --git a/tests/test_contextual_bandit.py b/tests/test_contextual_bandit.py index e2f83c6..68f4d7b 100644 --- a/tests/test_contextual_bandit.py +++ b/tests/test_contextual_bandit.py @@ -969,8 +969,8 @@ async def mutating_refresh(attributes): def test_invalid_aggregate_and_override_weights_are_sanitized(): """Aggregate (fallback) and override weight vectors get the same strict validation as matched leaves: negative or boolean entries would bucket on - nonsense ranges and report them as propensities, so the vector is cleared - and both bucketing and metadata use equal weights.""" + nonsense ranges and report them as propensities, so both bucketing and + the reported metadata normalize them to equal weights.""" for bad_weights in ([1.5, -0.5], [True, False]): features = { "bandit-feature": { @@ -1011,3 +1011,24 @@ def test_invalid_aggregate_and_override_weights_are_sanitized(): assert res.experimentResult is not None assert res.experimentResult.variationWeights == [0.5, 0.5] gb.destroy() + + +def test_bucket_ranges_reject_invalid_weight_vectors(): + """One weight-validation policy for every experiment: vectors bucketing + cannot honor sanely (negative, non-finite, boolean, or non-numeric + entries — not just wrong length/sum) normalize to equal weights, so + bucket ranges can never be inverted. Deliberately stricter than the JS + SDK, which checks only length and sum, on these invalid payloads.""" + from growthbook.core import getBucketRanges + + equal = getBucketRanges(2, 1, None) + for bad in ( + [1.2, -0.2], + [True, False], + [float("inf"), 1.0], + [float("nan"), 1.0], + [0.5, "x"], + ): + assert getBucketRanges(2, 1, bad) == equal, bad + # Valid vectors are untouched + assert getBucketRanges(2, 1, [0.4, 0.6]) == [(0, 0.4), (0.4, 1.0)] From 46de7d50a65729810d58008ee1a684deebec4e8c Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Fri, 4 Sep 2026 11:03:40 -0700 Subject: [PATCH 36/40] make weight-vector validation total: oversized ints no longer crash bucketing --- CHANGELOG.md | 2 +- growthbook/core.py | 23 +++++++++++++++-------- tests/test_contextual_bandit.py | 5 +++++ 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a6d5f3..00aff21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ * 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 and a single validity rule now governs every weight vector: `getBucketRanges` normalizes vectors with negative, non-finite, boolean, or non-numeric entries (not just wrong length/sum) to equal weights, so bucket ranges can never be inverted, and bandit leaf/aggregate/override propensities always describe the vector actually used. + * 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. * Contextual bandit exposure metadata survives remote evaluation: `rule.tracks` results from the proxy keep `leafId`, `variationWeights`, and `banditVersion` when replayed through the tracking callback. * 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. diff --git a/growthbook/core.py b/growthbook/core.py index e41c7b7..686e989 100644 --- a/growthbook/core.py +++ b/growthbook/core.py @@ -531,15 +531,22 @@ def _is_valid_weight_vector(weights: Any, num_variations: int) -> bool: corpus exercises none of them).""" if not isinstance(weights, list) or len(weights) != num_variations: return False - if not all( - isinstance(w, (int, float)) - and not isinstance(w, bool) - and math.isfinite(w) - and w >= 0 - for w in weights - ): + try: + if not all( + isinstance(w, (int, float)) + and not isinstance(w, bool) + and math.isfinite(w) + and w >= 0 + for w in weights + ): + return False + return WEIGHT_SUM_MIN <= sum(weights) <= WEIGHT_SUM_MAX + except OverflowError: + # math.isfinite (and mixed int/float summation) raise for + # arbitrary-precision ints too large for a float, e.g. 10**1000. + # Validation must be total over payload data: such vectors are + # invalid, never a crash. return False - return WEIGHT_SUM_MIN <= sum(weights) <= WEIGHT_SUM_MAX def _normalized_weights(numVariations: int, weights: Any) -> List[float]: diff --git a/tests/test_contextual_bandit.py b/tests/test_contextual_bandit.py index 68f4d7b..05e1950 100644 --- a/tests/test_contextual_bandit.py +++ b/tests/test_contextual_bandit.py @@ -455,6 +455,7 @@ def leaf_weights(weights): leaf_weights([1, 0, 0]), # wrong length for 2 variations leaf_weights([1, "x"]), # non-numeric entry leaf_weights([0.9, 0.9]), # sum outside bucketing tolerance + leaf_weights([10**1000, 0]), # overflows float conversion — must not crash ): gb = GrowthBook(attributes={"id": "1"}, features=CB_FEATURES, contextualBandits=bad_map) res = gb.eval_feature("bandit-feature") @@ -1028,6 +1029,10 @@ def test_bucket_ranges_reject_invalid_weight_vectors(): [float("inf"), 1.0], [float("nan"), 1.0], [0.5, "x"], + # Arbitrary-precision ints overflow math.isfinite / float summation; + # validation must stay total instead of raising OverflowError. + [10**1000, 0], + [10**1000, 0.5], ): assert getBucketRanges(2, 1, bad) == equal, bad # Valid vectors are untouched From 3bbbcbb13f9c50dc363e85231f5523d3c50f0981 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Fri, 4 Sep 2026 11:03:51 -0700 Subject: [PATCH 37/40] validate bandit leafId and banditVersion as integers at the payload boundary --- CHANGELOG.md | 2 +- growthbook/core.py | 17 ++++++++++++++--- tests/test_contextual_bandit.py | 27 +++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00aff21..3176bf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ * 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. + * 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. * Contextual bandit exposure metadata survives remote evaluation: `rule.tracks` results from the proxy keep `leafId`, `variationWeights`, and `banditVersion` when replayed through the tracking callback. * 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. diff --git a/growthbook/core.py b/growthbook/core.py index 686e989..5b4e819 100644 --- a/growthbook/core.py +++ b/growthbook/core.py @@ -7,6 +7,7 @@ from urllib.parse import urlparse, parse_qs from typing import Callable, Optional, Any, Set, Tuple, List, Dict, cast +from typing_extensions import TypeGuard from .common_types import ( ContextualBanditAssignment, ContextualBanditContext, @@ -646,6 +647,14 @@ def _get_contextual_bandit_leaf( return None +def _is_valid_bandit_id(value: Any) -> TypeGuard[int]: + """The validity rule for the payload's bandit identifiers (leafId, + banditVersion): server-assigned integers, booleans excluded. Invalid + identifiers are treated as absent, so exposure callbacks never feed + corrupt leaf/model ids into bandit attribution and training.""" + return isinstance(value, int) and not isinstance(value, bool) + + def _build_contextual_bandit_experiment( experiment: Experiment[Any], contextual_bandit_ref: str, @@ -696,9 +705,11 @@ def _build_contextual_bandit_experiment( leaf_id = leaf.get("leafId") if leaf is not None else None if weights is not None and not _is_valid_weight_vector(weights, len(experiment.variations)): weights = None + if leaf_id is not None and not _is_valid_bandit_id(leaf_id): + leaf_id = None if leaf is not None and (weights is None or leaf_id is None): - # A matched leaf missing its id, or whose weight vector fails the - # shared validity rule (see _is_valid_weight_vector), is a malformed + # A matched leaf missing its id (or carrying a non-integer one), or + # whose weight vector fails the shared validity rule, is a malformed # payload; degrade to the aggregate-weights fallback rather than # reporting propensities that differ from the weights actually used. logger.debug( @@ -723,7 +734,7 @@ def _build_contextual_bandit_experiment( } bandit_version = cb_definition.get("banditVersion") - if bandit_version is not None: + if _is_valid_bandit_id(bandit_version): cb["banditVersion"] = bandit_version experiment.contextualBandit = cb diff --git a/tests/test_contextual_bandit.py b/tests/test_contextual_bandit.py index 05e1950..bf0b5b2 100644 --- a/tests/test_contextual_bandit.py +++ b/tests/test_contextual_bandit.py @@ -442,6 +442,9 @@ def test_malformed_bandit_payload_degrades_gracefully(): def leaf_weights(weights): return {"cb-bandit": {"contexts": [{"leafId": 1, "condition": {}, "weights": weights}]}} + def bad_leaf_id(leaf_id): + return {"cb-bandit": {"contexts": [{"leafId": leaf_id, "condition": {}, "weights": [1, 0]}]}} + for bad_map in ( missing_weights, missing_leaf_id, @@ -456,6 +459,11 @@ def leaf_weights(weights): leaf_weights([1, "x"]), # non-numeric entry leaf_weights([0.9, 0.9]), # sum outside bucketing tolerance leaf_weights([10**1000, 0]), # overflows float conversion — must not crash + # Non-integer leaf ids are corrupt attribution identifiers: the leaf + # is treated as malformed rather than reported to bandit training. + bad_leaf_id("1"), + bad_leaf_id(1.5), + bad_leaf_id(True), ): gb = GrowthBook(attributes={"id": "1"}, features=CB_FEATURES, contextualBandits=bad_map) res = gb.eval_feature("bandit-feature") @@ -475,6 +483,25 @@ def leaf_weights(weights): gb.destroy() +def test_invalid_bandit_version_is_dropped_from_exposure(): + """A non-integer banditVersion is a corrupt model identifier: it is + treated as absent (key omitted from the exposure metadata) while a valid + matched leaf keeps working normally.""" + for bad_version in ("seven", 7.5, True): + bad_map = { + "cb-bandit": { + "banditVersion": bad_version, + "contexts": [{"leafId": 1, "condition": {}, "weights": [1, 0]}], + } + } + gb = GrowthBook(attributes={"id": "1"}, features=CB_FEATURES, contextualBandits=bad_map) + er = gb.eval_feature("bandit-feature").experimentResult + assert er.leafId == 1 + assert er.variationWeights == [1, 0] + assert er.banditVersion is None, bad_version + gb.destroy() + + def test_sync_set_payload_accepts_encrypted_sections(): """JS setPayload accepts encrypted payloads; the Python port decrypts encrypted sections with the configured decryption_key.""" From 6bccdc88f79651bcfef87b97d5deb35b845aee12 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Fri, 4 Sep 2026 11:04:01 -0700 Subject: [PATCH 38/40] snapshot the user context in preload_remote_eval via a shared freeze helper --- CHANGELOG.md | 2 +- growthbook/common_types.py | 19 +++++++++++++++ growthbook/growthbook_client.py | 19 ++++++++------- tests/test_remote_eval.py | 41 +++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3176bf0..2e048ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ * 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. * Contextual bandit exposure metadata survives remote evaluation: `rule.tracks` results from the proxy keep `leafId`, `variationWeights`, and `banditVersion` when replayed through the tracking callback. - * 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. + * 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 diff --git a/growthbook/common_types.py b/growthbook/common_types.py index 218e812..352659c 100644 --- a/growthbook/common_types.py +++ b/growthbook/common_types.py @@ -603,6 +603,25 @@ def copy_value(value: Any) -> Any: 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). diff --git a/growthbook/growthbook_client.py b/growthbook/growthbook_client.py index fb1a5d2..8fe4fa7 100644 --- a/growthbook/growthbook_client.py +++ b/growthbook/growthbook_client.py @@ -1,7 +1,7 @@ #!/usr/bin/env python import inspect import json -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, field import random import logging from types import TracebackType @@ -36,7 +36,7 @@ Experiment, build_remote_eval_payload, features_from_dict, - snapshot_attributes, + snapshot_user_context, tracking_user_context, validate_remote_eval_options, ) @@ -1161,6 +1161,12 @@ async def preload_remote_eval(self, user_context: UserContext) -> None: network requests.""" if not self.options.remote_eval or not self._features_repository: return + # Same call-time freeze as create_evaluation_context: the cache key is + # computed from these values immediately, but an SWR background + # refresh serializes the POST body later — an unfrozen context mutated + # in between would cache the new attributes' response under the old + # attributes' key. + user_context = snapshot_user_context(user_context) await self._features_repository.fetch_remote_eval( self.options.api_host or "https://cdn.growthbook.io", self.options.client_key or "", @@ -1234,14 +1240,7 @@ async def create_evaluation_context(self, user_context: UserContext) -> Evaluati # read-your-writes still lands on it. caller_context = user_context if self.options.remote_eval or self.options.sticky_bucket_service is not None: - user_context = replace( - user_context, - attributes=snapshot_attributes(user_context.attributes), - groups=snapshot_attributes(user_context.groups), - forced_variations=snapshot_attributes(user_context.forced_variations), - forced_features=snapshot_attributes(user_context.forced_features), - overrides=snapshot_attributes(user_context.overrides), - ) + user_context = snapshot_user_context(user_context) if self.options.remote_eval and self._features_repository: # Per-user POST + cache: features come from the proxy filtered for diff --git a/tests/test_remote_eval.py b/tests/test_remote_eval.py index e3ebebb..e9ccf09 100644 --- a/tests/test_remote_eval.py +++ b/tests/test_remote_eval.py @@ -993,6 +993,47 @@ async def post_handler(api_host, client_key, payload): assert len(calls) == 1 +@pytest.mark.asyncio +async def test_async_preload_snapshots_context_against_swr_cache_poisoning(): + """preload_remote_eval computes the cache key immediately, but an SWR + background refresh serializes the POST body later. Without the same + call-time snapshot create_evaluation_context takes, mutating the + UserContext after preload returns would POST the NEW attributes and cache + that response under the OLD attributes' key. Deterministic probe: the + mocked proxy derives the flag value from the attributes it receives, so a + poisoned cache is directly observable.""" + bodies = [] + posted = asyncio.Event() + + async def post_handler(api_host, client_key, payload): + # Serialize NOW — this is what the wire would carry at POST time. + attrs = json.loads(json.dumps(payload))["attributes"] + bodies.append(attrs) + posted.set() + return { + "features": {"flag1": {"defaultValue": attrs["tier"] == "pro"}}, + "savedGroups": {}, + } + + # stale_ttl=0: every cache hit is in the SWR window and schedules a + # background refetch. + client = await _make_async_client(post_handler, cache_ttl=60, stale_ttl=0) + uc = UserContext(attributes={"id": "u1", "tier": "pro"}) + + await client.preload_remote_eval(uc) # miss -> foreground POST ("pro") + posted.clear() + await client.preload_remote_eval(uc) # SWR hit -> schedules background POST + uc.attributes["tier"] = "free" # caller mutates AFTER preload returned + await posted.wait() + + assert len(bodies) == 2 + # The background POST must describe the attributes its cache key was + # computed from, not the mutated ones. + assert bodies[1]["tier"] == "pro" + # End-to-end: the cache entry for the "pro" context still answers as "pro". + assert await client.is_on("flag1", UserContext(attributes={"id": "u1", "tier": "pro"})) is True + + @pytest.mark.asyncio async def test_async_sse_features_updated_flushes_cache(): calls = [] From ce7bff8b438062df444b3131fc1f1fbf6129954a Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Fri, 4 Sep 2026 11:22:28 -0700 Subject: [PATCH 39/40] drop bandit metadata when explicit ranges govern bucketing --- CHANGELOG.md | 2 +- growthbook/core.py | 10 +++++++++- tests/test_contextual_bandit.py | 18 ++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e048ef..2388b9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ * 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. + * 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. * 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. diff --git a/growthbook/core.py b/growthbook/core.py index 5b4e819..7fc2d80 100644 --- a/growthbook/core.py +++ b/growthbook/core.py @@ -1008,10 +1008,18 @@ def run_experiment(experiment: Experiment[Any], # 2.5. If the experiment props have been overridden, merge them in if evalContext.user.overrides.get(experiment.key, None): experiment.update(evalContext.user.overrides[experiment.key]) + # Explicit bucket ranges take precedence over weights entirely (step 9), + # so a contextual bandit experiment carrying ranges has no truthful + # propensity vector to report: drop the metadata rather than describe a + # distribution bucketing ignored. Bucketing itself is untouched (same + # assignment as the JS SDK); the server never emits ranges on contextual + # bandit rules, so this only fires on hand-crafted payloads. + if experiment.contextualBandit and experiment.ranges: + experiment.contextualBandit = None # Keep reported bandit propensities in sync with the weights actually # used for bucketing: an override may have replaced them, and # getBucketRanges normalizes unusable vectors to equal weights. - if experiment.contextualBandit and experiment.weights is not None: + elif experiment.contextualBandit and experiment.weights is not None: synced: ContextualBanditAssignment = { "leafId": experiment.contextualBandit["leafId"], "variationWeights": _normalized_weights( diff --git a/tests/test_contextual_bandit.py b/tests/test_contextual_bandit.py index bf0b5b2..fd99898 100644 --- a/tests/test_contextual_bandit.py +++ b/tests/test_contextual_bandit.py @@ -502,6 +502,24 @@ def test_invalid_bandit_version_is_dropped_from_exposure(): gb.destroy() +def test_bandit_metadata_dropped_when_explicit_ranges_govern_bucketing(): + """Explicit `ranges` on a rule take precedence over weights in bucketing, + so leaf propensities cannot describe the assignment: the bandit metadata + is dropped while the assignment itself stays untouched (same as the JS + SDK). The server never emits ranges on contextual bandit rules.""" + features = json.loads(json.dumps(CB_FEATURES)) + # Everyone lands in variation 1, regardless of the leaf's [1, 0] weights. + features["bandit-feature"]["rules"][0]["ranges"] = [[0, 0], [0, 1]] + gb = GrowthBook(attributes={"id": "1"}, features=features, contextualBandits=CB_MAP) + res = gb.eval_feature("bandit-feature") + assert res.value == "treatment" # ranges decided, not the leaf's [1, 0] + er = res.experimentResult + assert er.leafId is None + assert er.variationWeights is None + assert er.banditVersion is None + gb.destroy() + + def test_sync_set_payload_accepts_encrypted_sections(): """JS setPayload accepts encrypted payloads; the Python port decrypts encrypted sections with the configured decryption_key.""" From d769d7bda8aabbc9e29e2ae6090e39d0a807a026 Mon Sep 17 00:00:00 2001 From: Madhu Chavva Date: Fri, 4 Sep 2026 11:30:29 -0700 Subject: [PATCH 40/40] validate rule.tracks bandit metadata with the local evaluation rules --- CHANGELOG.md | 2 +- growthbook/core.py | 23 +++++++++--- tests/test_contextual_bandit.py | 65 +++++++++++++++++++++++++++++++-- 3 files changed, 80 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2388b9c..0237a08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ * 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. + * 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 diff --git a/growthbook/core.py b/growthbook/core.py index 7fc2d80..5ec7431 100644 --- a/growthbook/core.py +++ b/growthbook/core.py @@ -606,6 +606,20 @@ def _fire_rule_tracks( try: # Experiment accepts **_ignored, so passing the raw proxy dict is safe. experiment = Experiment(**exp_data) + # Contextual bandit exposure metadata evaluated by the proxy is + # payload data like any other: hold it to the same validity rules + # as local evaluation (the JS SDK passes it through verbatim). An + # invalid leafId or weight vector drops all bandit metadata; an + # invalid banditVersion drops just that field. + leaf_id = res_data.get("leafId") + variation_weights = res_data.get("variationWeights") + bandit_version = res_data.get("banditVersion") + if not _is_valid_bandit_id(leaf_id) or not _is_valid_weight_vector( + variation_weights, len(experiment.variations) + ): + leaf_id = variation_weights = bandit_version = None + elif not _is_valid_bandit_id(bandit_version): + bandit_version = None result = Result( variationId=res_data.get("variationId", 0), inExperiment=res_data.get("inExperiment", False), @@ -617,12 +631,9 @@ def _fire_rule_tracks( meta=meta, bucket=res_data.get("bucket"), stickyBucketUsed=res_data.get("stickyBucketUsed", False), - # Contextual bandit exposure metadata evaluated by the proxy. - # The JS SDK passes the proxy result through verbatim, so - # these must survive the reconstruction too. - leafId=res_data.get("leafId"), - variationWeights=res_data.get("variationWeights"), - banditVersion=res_data.get("banditVersion"), + leafId=leaf_id, + variationWeights=variation_weights, + banditVersion=bandit_version, ) tracking_cb(experiment, result, eval_context.user) except Exception: diff --git a/tests/test_contextual_bandit.py b/tests/test_contextual_bandit.py index fd99898..6225d60 100644 --- a/tests/test_contextual_bandit.py +++ b/tests/test_contextual_bandit.py @@ -596,9 +596,10 @@ def test_failed_bandit_decryption_preserves_previous_map(): def test_remote_eval_tracks_preserve_bandit_fields(): - """rule.tracks entries from the remote-eval proxy must reach the tracking - callback with their contextual bandit fields intact — the JS SDK passes - the proxy result through verbatim.""" + """Valid rule.tracks entries from the remote-eval proxy must reach the + tracking callback with their contextual bandit fields intact (the JS SDK + passes the proxy result through verbatim; Python additionally validates — + see test_remote_eval_tracks_drop_invalid_bandit_fields).""" tracked = [] def on_view(experiment, result, user_context): @@ -647,6 +648,64 @@ def on_view(experiment, result, user_context): gb.destroy() +@pytest.mark.parametrize( + "extras,expected", + [ + # Invalid leafId drops all bandit metadata. + ({"leafId": "8", "variationWeights": [0.2, 0.8], "banditVersion": 7}, + (None, None, None)), + # Invalid weight vector drops all bandit metadata. + ({"leafId": 8, "variationWeights": [1.2, -0.2], "banditVersion": 7}, + (None, None, None)), + # Invalid banditVersion drops just that field. + ({"leafId": 8, "variationWeights": [0.2, 0.8], "banditVersion": True}, + (8, [0.2, 0.8], None)), + ], +) +def test_remote_eval_tracks_drop_invalid_bandit_fields(extras, expected): + """Proxy rule.tracks metadata is payload data and gets the same validity + rules as local evaluation, so remote-eval exposures can't feed corrupt + identifiers or propensities into bandit training either.""" + tracked = [] + + def on_view(experiment, result, user_context): + tracked.append(result) + + result_data = { + "variationId": 1, + "inExperiment": True, + "hashUsed": True, + "hashAttribute": "id", + "hashValue": "1", + "value": "treatment", + **extras, + } + gb = GrowthBook( + attributes={"id": "1"}, + on_experiment_viewed=on_view, + features={ + "remote-feature": { + "defaultValue": "x", + "rules": [{ + "force": "treatment", + "tracks": [{ + "experiment": { + "key": "bandit-exp", + "variations": ["control", "treatment"], + }, + "result": result_data, + }], + }], + } + }, + ) + gb.eval_feature("remote-feature") + assert len(tracked) == 1 + r = tracked[0] + assert (r.leafId, r.variationWeights, r.banditVersion) == expected + gb.destroy() + + @pytest.mark.asyncio async def test_async_remote_eval_tracks_preserve_bandit_fields(): tracked = []