Skip to content

Contextual bandit support - #134

Merged
madhuchavva merged 40 commits into
mainfrom
contextual-bandits
Sep 4, 2026
Merged

madhuchavva merged 40 commits into
mainfrom
contextual-bandits

Conversation

@madhuchavva

@madhuchavva madhuchavva commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Contextual bandit support for both clients (GrowthBook, GrowthBookClient), at behavioral parity with the JS SDK (source of truth: growthbook/growthbook#6479, payload design #6274).

Backward-compatible feature on top of 3.0.0 → minor release (3.1.0).

How it's implemented

All learning is server-side. The server fits a decision tree over context attributes, runs Thompson sampling per leaf, and ships per-leaf weight vectors in a new top-level payload section:

{
  "features": { "my-feature": { "rules": [{
      "contextualBanditRef": "cb_abc123",
      "contextualVariations": ["control", "treatment"],
      "weights": [0.5, 0.5]          // server-computed marginal (fallback) weights
  }]}},
  "contextualBandits": {             // or encryptedContextualBandits
    "cb_abc123": { "banditVersion": 7, "contexts": [
      { "leafId": 0, "condition": { "country": { "$in": ["US"] } }, "weights": [0.82, 0.18] },
      { "leafId": 1, "condition": {}, "weights": [0.31, 0.69] }
    ]}
  }
}

The SDK does no model evaluation: _build_contextual_bandit_experiment (core.py)

  • resolves the rule's contextualBanditRef,
  • picks the first leaf whose condition matches via the existing evalCondition engine,
  • substitutes that leaf's weights onto the experiment, and the untouched standard hash-bucketing path does the rest.

No leaf match / empty contexts / errored selection → bucketing keeps the rule's marginal weights and reports the fallback leafId: -1; a dangling ref runs as a plain experiment with no bandit metadata.

Payloads can also be seeded out-of-band with the new set_payload on both clients (JS setPayload parity, including encrypted sections):

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

Behavioral Changes

  • Bandit exposures add three fields to Result (and to the result your on_experiment_viewed callback receives): leafId, variationWeights, and banditVersion. Log them to your warehouse along with the user attributes — bandit training needs them. They are only set for real hashed exposures, never for forced variations, QA mode, or coverage misses.
  • The user_context passed to callbacks is a snapshot taken at evaluation time, so what you log is exactly what was used for leaf routing.
  • Older SDK versions skip bandit rules and serve the feature's default value — they can never bucket on stale weights.
  • A partial or broken refresh never wipes bandit state: sections missing from a payload keep their previous value, and malformed bandit data falls back to the rule's regular weights instead of raising.
  • Users may be re-bucketed when the bandit updates its weights. This is expected: sticky bucketing is disabled for these rules and analysis counts each user's first exposure.

Perf

  • Valid non-bandit evaluations are behaviorally unchanged — the bandit code only runs for rules with a contextualBanditRef. The one shared change: getBucketRanges now normalizes malformed weight vectors (negative, boolean, non-finite, non-numeric, or float-overflowing entries) to equal weights for every experiment, where the JS SDK checks only length and sum. No valid payload is affected (the conformance corpus exercises no such vectors).
  • Leaf lookup is a linear scan over at most 12 leaves, reusing the existing condition evaluator.
  • The sync client now swaps in one immutable context snapshot per refresh (the async client already worked this way), so evaluations remain lock-free.
  • Fallback logs are debug-level, and the tracking snapshot is only built when a callback actually fires.

Test plan

  • Spec 0.8.0 contextualBandit corpus (35 cases) + feature-section backwards-compat cases green, CB skiplist entries removed
  • New targeted tests: tests/test_contextual_bandit.py (31: ingestion in all refresh paths, encryption, set_payload semantics, snapshot atomicity, malformed-payload degradation, tracking metadata) and exposure-context plugin tests in tests/test_plugins.py

Follow-ups

  • Ingestor events intentionally carry no bandit fields — parity with JS (Experiment Viewed sends only experimentId/variationId/hashAttribute/hashValue);

@greptile-apps

greptile-apps Bot commented Sep 4, 2026 •

Copy link
Copy Markdown

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains from the previously reported contextual-bandit weight, identifier, or ranges-metadata issues.

Reviews (6): Last reviewed commit: "validate rule.tracks bandit metadata wit..." | Re-trigger Greptile

Comment thread growthbook/core.py
@madhuchavva

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread growthbook/core.py Outdated
@madhuchavva

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread growthbook/core.py Outdated
@madhuchavva

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread growthbook/core.py
@madhuchavva

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread growthbook/core.py
@madhuchavva

Copy link
Copy Markdown
Contributor Author

@greptileai

@madhuchavva
madhuchavva merged commit cdd1cee into main Sep 4, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant