Skip to content

feat: add bandits support to precomputed client#98

Open
sameerank wants to merge 1 commit intomainfrom
sameerank/bandits-support
Open

feat: add bandits support to precomputed client#98
sameerank wants to merge 1 commit intomainfrom
sameerank/bandits-support

Conversation

@sameerank
Copy link
Copy Markdown
Contributor

@sameerank sameerank commented Mar 12, 2026

🎟️ Fixes N/A - New feature
📜 Design Doc: N/A

Motivation and Context

Add contextual multi-armed bandit support to the iOS SDK's EppoPrecomputedClient, achieving feature parity with the Android and JS SDKs.

Description

This PR adds the getBanditAction() method and all supporting infrastructure for bandits in precomputed flags:

New Files:

  • Sources/eppo/dto/PrecomputedBandit.swift - Wire format and decoded bandit DTOs
  • Sources/eppo/dto/BanditResult.swift - Public return type with variation and optional action
  • Sources/eppo/dto/BanditEvent.swift - Logging model and BanditLogger type alias

Modified Files:

  • Precompute.swift - Added banditActions parameter for specifying available actions per flag
  • PrecomputedConfiguration.swift - Added bandits parsing and Base64 decoding
  • PrecomputedRequestor.swift - Added bandit_actions to request payload and bandits to response handling
  • PrecomputedConfigurationStore.swift - Added getDecodedBandit() method
  • EppoPrecomputedClient.swift - Added getBanditAction(), banditLogger, and deduplication via banditAssignmentCache

Key Features:

  • getBanditAction(flagKey:defaultValue:) returns BanditResult with variation and optional action
  • Bandits keyed by MD5-hashed flag key (matching existing flag lookup pattern)
  • Base64 decoding for obfuscated bandit data (banditKey, action, modelVersion, attributes)
  • BanditEvent logging with deduplication
  • Falls back to regular string assignment when no bandit exists for a flag
  • Thread-safe concurrent access

How has this been documented?

No external documentation changes required. The public API is straightforward:

  • getBanditAction(flagKey:defaultValue:) - similar to existing assignment methods
  • banditLogger parameter in initialization - similar to existing assignmentLogger

How has this been tested?

  • Unit tests: EppoPrecomputedClientBanditTests.swift - 11 tests covering:

    • Basic bandit action retrieval
    • Non-bandit flag fallback
    • Missing flag handling
    • Bandit logging with all event fields
    • Subject attribute splitting (numeric/categorical)
    • Logging deduplication
    • Concurrent access safety
    • Bandits without actions
  • Wire format tests: PrecomputedConfigurationWireTests.swift - 3 new tests using shared precomputed-v1.json test data:

    • testBanditActionWithSharedTestData
    • testBanditActionWithExtraLoggingSharedTestData
    • testBanditActionForNonBanditFlagSharedTestData
  • End-to-end: Verified with EppoButtonClicker test app against live Eppo backend

        let precompute = Precompute(
            subjectKey: "test-user-123",
            subjectAttributes: [
                "country": .valueOf("US"),
                "age": .valueOf(25),
                "isPremium": .valueOf(true)
            ],
            banditActions: [
                "sameeran-test-1": [
                    "action-a": [
                        "price": .valueOf(9.99),
                        "category": .valueOf("electronics")
                    ],
                    "action-b": [
                        "price": .valueOf(19.99),
                        "category": .valueOf("clothing")
                    ],
                    "action-c": [
                        "price": .valueOf(29.99),
                        "category": .valueOf("home")
                    ]
                ]
            ]
        )
banditdemo.mov

All tests passing: swift test

@sameerank sameerank force-pushed the sameerank/bandits-support branch from 0c65905 to 5b4f090 Compare March 12, 2026 06:38
@sameerank sameerank marked this pull request as ready for review March 12, 2026 06:42
@sameerank sameerank requested a review from aarsilv March 12, 2026 06:42
Add getBanditAction() method to EppoPrecomputedClient for contextual
multi-armed bandit support. Key changes:

- Add PrecomputedBandit DTO with wire format and decoded versions
- Add BanditResult return type with variation and optional action
- Add BanditEvent for logging bandit assignments
- Update Precompute to accept banditActions parameter
- Update PrecomputedRequestor to send bandit_actions in payload
- Add banditLogger parameter to client initialization
- Implement bandit logging with deduplication via banditAssignmentCache
- Add comprehensive tests for bandit functionality
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds contextual multi-armed bandit support to the iOS SDK’s EppoPrecomputedClient so precomputed assignments can return both a variation (model) and a selected action, with optional logging and deduplication.

Changes:

  • Introduces bandit DTOs (PrecomputedBandit + decoded variant), public API return type (BanditResult), and logging model (BanditEvent/BanditLogger).
  • Extends the precomputed fetch payload/response to send bandit_actions and parse bandits, including Base64 decoding in PrecomputedConfiguration.
  • Adds getBanditAction() to EppoPrecomputedClient with bandit-event logging + deduplication, plus comprehensive unit/wire tests.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
Sources/eppo/precomputed/EppoPrecomputedClient.swift Adds getBanditAction() and bandit logging/deduplication infrastructure
Sources/eppo/precomputed/Precompute.swift Extends Precompute to accept optional banditActions
Sources/eppo/precomputed/PrecomputedRequestor.swift Adds bandit_actions to request payload and bandits to response decoding
Sources/eppo/precomputed/PrecomputedConfiguration.swift Adds bandits to configuration model and Base64 decoding into decoded config
Sources/eppo/precomputed/PrecomputedConfigurationStore.swift Adds decoded bandit lookup accessor
Sources/eppo/dto/PrecomputedBandit.swift New wire-format and decoded bandit DTOs
Sources/eppo/dto/BanditResult.swift New public return type for bandit action lookups
Sources/eppo/dto/BanditEvent.swift New public logging event model and logger typealias
Sources/eppo/Constants.swift Bumps SDK version constant
Tests/eppo/precomputed/EppoPrecomputedClientBanditTests.swift New unit tests for bandit retrieval, logging, dedupe, concurrency
Tests/eppo/precomputed/PrecomputedConfigurationWireTests.swift Adds wire-format tests for bandits using shared test data
Tests/eppo/precomputed/PrecomputedRequestorTests.swift Updates request payload test to include new initializer arg
Tests/eppo/precomputed/PrecomputedTestHelpers.swift Adds helpers to construct hashed/encoded bandit test fixtures

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

subjectNumericAttributes[key] = doubleValue
}
} else {
if let stringValue = try? value.getStringValue() {
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In logBanditAction, categorical subject attributes are extracted using getStringValue(), which throws for boolean values and arrays (and will silently skip them due to try?). Since Precompute.subjectAttributes can include booleans and string arrays (see EppoValue), this will drop valid attributes from bandit logging. Consider using EppoValue.toEppoString() for non-numeric values (and skipping only nulls) so booleans become "true"/"false" and arrays can be joined consistently.

Suggested change
if let stringValue = try? value.getStringValue() {
if let stringValue = try? value.toEppoString() {

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

@aarsilv aarsilv left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice job bringing precomputed bandits to iOS 💪 I appreciate the clean data models, easy to understand code, and automated tests. I have mostly minor comments but there is one big one for which I'm requesting changes: we still need to log the "regular" string assignment when getting a bandit action. This is is needed for when A/B testing bandit against status quo.

Comment on lines +20 to +21
actionProbability: Double,
optimalityGap: Double
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since action is optional, these should be too. If no action, these should be nil

actionNumericAttributes = try container.decodeIfPresent([String: String].self, forKey: .actionNumericAttributes)
actionCategoricalAttributes = try container.decodeIfPresent([String: String].self, forKey: .actionCategoricalAttributes)
actionProbability = try container.decodeIfPresent(Double.self, forKey: .actionProbability) ?? 0.0
optimalityGap = try container.decodeIfPresent(Double.self, forKey: .optimalityGap) ?? 0.0
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defaulting to 0.0 is a bit misleading for actionProbability and incorrect for optimalityGap, can we make these optional and default to nil?

Comment on lines +61 to +62
let actionProbability: Double
let optimalityGap: Double
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here


// First check if there's a bandit assignment for this flag
guard let bandit = configurationStore.getDecodedBandit(forKey: hashedFlagKey) else {
// No bandit, fall back to regular string assignment
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Comment on lines +436 to +437
// No action to log
return
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Comment on lines +143 to +144
XCTAssertEqual(result.variation, "hello world")
XCTAssertNil(result.action)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Comment on lines +156 to +157
XCTAssertEqual(result.variation, "default")
XCTAssertNil(result.action)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


// MARK: - Concurrent Access Tests

func testConcurrentBanditAccess() throws {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💪

let hashedFlagKey = getMD5Hex(flagKey, salt: decodedConfig.decodedSalt)

// First check if there's a bandit assignment for this flag
guard let bandit = configurationStore.getDecodedBandit(forKey: hashedFlagKey) else {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prior to this, we'll want to log the "regular" assignment as well

return BanditResult(variation: variation, action: nil)
}

// Get the variation from the bandit
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thinking we expand this comment to be even more clear, something like "if the bandit was invoked, the variation value is the same as the bandit key"

Then again if we have the variation (which we'll need to log for "regular" assignment log) maybe just use that to set to not confuse people.

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.

3 participants