Skip to content

[FSSDK-12394] Add local holdouts to swift-sdk (ref sdk)#628

Open
Mat001 wants to merge 8 commits intomasterfrom
mpirnovar-swift-local-holdouts-fssdk-12394
Open

[FSSDK-12394] Add local holdouts to swift-sdk (ref sdk)#628
Mat001 wants to merge 8 commits intomasterfrom
mpirnovar-swift-local-holdouts-fssdk-12394

Conversation

@Mat001
Copy link
Copy Markdown

@Mat001 Mat001 commented Apr 7, 2026

Summary

  • add changes to support local holdouts
  • Remove all legacy flag-level holdout fields and methods
  • Add includedRules to the Holdout model and update HoldoutConfig and ProjectConfig to build and expose a rule-level holdout lookup map.

Test plan

  • add unit tests

Issues

https://optimizely-ext.atlassian.net/browse/FSSDK-12368

Mat001 and others added 4 commits April 7, 2026 14:17
This commit implements Local Holdouts functionality that allows holdouts
to target specific rules instead of all rules within a flag.

Changes:
- Holdout.swift: Replace includedFlags/excludedFlags with includedRules
- HoldoutConfig.swift: Replace flag-level maps with ruleHoldoutsMap
- ProjectConfig.swift: Add getGlobalHoldouts() and getHoldoutsForRule()
- DefaultDecisionService.swift: Update decision logic for global/local holdouts
  * getDecisionForFlag() now uses only global holdouts
  * Added local holdout checks to getVariationFromExperimentRule()
  * Added local holdout checks to getVariationFromDeliveryRule()

Datafile changes:
- Global holdouts: includedRules == nil (applies to all rules)
- Local holdouts: includedRules == [ruleId, ...] (specific rules only)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Updates existing holdout tests to use includedRules instead of
includedFlags/excludedFlags:

- HoldoutTests.swift: Update sample data and decode tests
  * Replace sampleDataWithIncludedFlags with sampleDataWithIncludedRules
  * Replace sampleDataWithExcludedFlags with sampleDataWithDifferentRules
  * Add tests for isGlobal property

- HoldoutConfigTests.swift: Complete rewrite for new model
  * Test getGlobalHoldouts() returns only global holdouts
  * Test getHoldoutsForRule() returns local holdouts for specific rules
  * Test multiple holdouts can target the same rule
  * Test rule-to-holdout mapping is built correctly
  * Remove tests for removed flag-level targeting functionality

All tests verify the new Local Holdouts behavior:
- Global holdouts: includedRules == nil
- Local holdouts: includedRules == [ruleId, ...]

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Added comprehensive integration tests covering:
- Global holdout evaluation before all rules
- Local holdout evaluation at experiment and delivery rule level
- Multiple holdouts targeting same rule
- Cross-flag holdout targeting
- Global and local holdout interaction
- Edge cases (inactive status, non-existent rules, empty includedRules)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…edFlags/excludedFlags

Migrated all test files from flag-level to rule-level holdout targeting:
- Replaced includedFlags/excludedFlags with includedRules
- Updated sample data to use rule IDs instead of flag IDs
- Replaced getHoldoutForFlag() calls with getHoldoutsForRule()
- Updated ProjectConfigTests to test new rule-level mapping logic

Migration strategy:
- includedFlags: [] + excludedFlags: [] → omit includedRules (nil = global)
- includedFlags: [flagId] → includedRules: [all rule IDs in that flag]
- excludedFlags: [flagId] → includedRules: [] (empty = local with no rules)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@Mat001 Mat001 self-assigned this Apr 7, 2026
@Mat001 Mat001 requested a review from muzahidul-opti April 7, 2026 22:28
@Mat001 Mat001 changed the title Mpirnovar swift local holdouts fssdk 12394 [FSSDK-12394] Add local holdouts to swift-sdk (ref sdk) Apr 7, 2026
Mat001 and others added 4 commits April 7, 2026 15:43
…ing holdouts

When tests modify project.holdouts, they must also update holdoutConfig.allHoldouts
to trigger the internal map rebuilding (ruleHoldoutsMap). Without this, local holdouts
are not properly indexed by rule ID and won't be evaluated during decision-making.

Added `config.holdoutConfig.allHoldouts = [...]` after each `config.project.holdouts = [...]`
assignment in all test files.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Fix testDecideAll_with_holdout_excluded_flags: Changed includedRules from
  empty array to all feature_1 rule IDs, and updated feature_3 expectations
  to nil since feature_3 has no rules for local holdouts to target
- Fix testDecideAll_with_multiple_holdouts: Removed excludedHoldout which
  had includedRules=[] (targets no rules), updated feature_3 expectations
  to nil since local holdouts cannot apply to features with no rules
- Fix DecisionListenerTest_Holdouts setUp(): Added missing
  holdoutConfig.allHoldouts assignment to trigger map rebuild

Feature_3 has no experiments or rollout rules, so local holdouts (rule-level
targeting) cannot apply to it. Only global holdouts can apply to flags with
no rules.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Problem: When local holdouts returned variations, they were being
converted to FeatureDecisions with the experiment (not holdout) and
source "feature-test" (not "holdout"), causing tests to fail.

Root cause: VariationDecision struct didn't carry information about
whether the variation came from a holdout.

Solution:
- Added 'holdout' field to VariationDecision struct
- Created DeliveryRuleDecision struct for delivery rules with holdout info
- Updated getVariationFromExperimentRule() to set holdout when returning
  holdout variation
- Updated getVariationFromDeliveryRule() to use DeliveryRuleDecision and
  set holdout field
- Updated getVariationForFeatureExperiments() to check for holdout and
  create FeatureDecision with holdout + source "holdout" instead of
  experiment + source "feature-test"
- Updated getVariationForFeatureRollout() to handle DeliveryRuleDecision
  and check for holdout

This ensures holdout decisions are properly tracked through the decision
flow and returned with correct experiment ID and source.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Problem: testGetVariationForFeatureExperiment_NoExperiments and
testGetVariationForFeatureExperiment_InvalidExperimentIds were failing
because users weren't bucketing into global holdouts.

Root cause: Tests used mockBucketValue: 500 (from setUp) but
sampleHoldoutGlobal has endOfRange: 500, meaning the valid range is
0-499 (exclusive of 500). Bucket value 500 is outside this range.

Solution: Create new MockDecisionService instances in these tests with
mockBucketValue: 400, which is within the global holdout range.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@coveralls
Copy link
Copy Markdown

Coverage Status

coverage: 93.828% (+0.06%) from 93.766% — mpirnovar-swift-local-holdouts-fssdk-12394 into master

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.

2 participants