Skip to content

Commit 55924e5

Browse files
[AI-FSSDK] [FSSDK-12337] Add Feature Rollout support (#499)
* [AI-FSSDK] [FSSDK-12337] Add Feature Rollout support to project config parsing * [AI-FSSDK] [FSSDK-12337] Fix test structure, mypy and ruff compliance - Move feature rollout tests from standalone test_feature_rollout.py into test_config.py following module-level testing convention - Use base.BaseTest instead of unittest.TestCase for consistency - Fix mypy strict type errors in Variation construction using cast - All checks pass: ruff, mypy --strict, pytest (941/941) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * [AI-FSSDK] [FSSDK-12337] Simplify feature rollout config parsing - Inline _get_everyone_else_variation logic, remove unnecessary static method - Use get_rollout_from_id() to match TDD pseudocode - Remove isinstance check (rollout experiments are always dicts) - Remove 3 unit tests for deleted helper method (edge cases already covered by integration-level tests) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * [AI-FSSDK] [FSSDK-12337] Restore _get_everyone_else_variation as method - Restore as instance method matching TDD pseudocode structure - Takes flag (FeatureFlag) param, calls get_rollout_from_id internally - Caller simplified to: everyone_else_variation = self._get_everyone_else_variation(flag) * [AI-FSSDK] [FSSDK-12337] Add ExperimentTypes constants for type field - Add ExperimentTypes enum in helpers/enums.py with AB, MAB, CMAB, FEATURE_ROLLOUT - Use ExperimentTypes.FEATURE_ROLLOUT constant in config parsing instead of raw string * [AI-FSSDK] [FSSDK-12337] Type-restrict Experiment.type to ExperimentType - Add ExperimentType Literal type in helpers/types.py: 'a/b', 'mab', 'cmab', 'feature_rollout' - Change Experiment.type from Optional[str] to Optional[ExperimentType] * [AI-FSSDK] [FSSDK-12337] Remove ExperimentTypes class, simplify type check - Remove redundant ExperimentTypes class from enums.py (ExperimentType Literal suffices) - Simplify getattr(experiment, 'type', None) to experiment.type * [AI-FSSDK] [FSSDK-12337] Return Variation entity from _get_everyone_else_variation - Build Variation entity once in helper, derive dict from it in caller - Addresses PR review comment from jaeopt * [AI-FSSDK] [FSSDK-12337] Remove redundant tests, keep 6 essential ones Removed 7 tests that were covered by other tests: - test_experiment_type_field_parsed (covered by injection test) - test_feature_rollout_with_empty_rollout_experiments (similar to no_rollout) - test_feature_rollout_multiple_experiments_mixed_types (covered by injection + unchanged) - test_feature_rollout_flag_variations_map_includes_injected (subset of maps test) - test_experiment_type_ab (just string assignment) - test_feature_rollout_with_variables_on_everyone_else (edge case) - test_existing_datafile_not_broken (covered by none_when_missing + unchanged) * [AI-FSSDK] [FSSDK-12337] Add ExperimentTypes constant and targeted_delivery type - Add ExperimentTypes class in enums.py with ab, mab, cmab, td, fr - Add 'targeted_delivery' to ExperimentType Literal in types.py - Use enums.ExperimentTypes.fr constant in injection check - Add test for type field parsing from datafile * [AI-FSSDK] [FSSDK-12337] Remove test not in ticket spec Remove test_feature_rollout_everyone_else_is_last_rollout_rule to match updated Jira ticket test requirements. * [FSSDK-12337] Fix experiment type values to match backend Update ExperimentTypes and ExperimentType Literal to use actual backend values: 'multi_armed_bandit' and 'contextual_multi_armed_bandit' instead of shorthand 'mab' and 'cmab'. * Format ExperimentType definition for ruff check * [AI-FSSDK] [FSSDK-12337] Update experiment type values to short-form abbreviations * [AI-FSSDK] [FSSDK-12337] Add validation for experiment type field * trigger CI --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d651911 commit 55924e5

File tree

5 files changed

+461
-1
lines changed

5 files changed

+461
-1
lines changed

optimizely/entities.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
if TYPE_CHECKING:
2424
# prevent circular dependenacy by skipping import at runtime
25-
from .helpers.types import ExperimentDict, TrafficAllocation, VariableDict, VariationDict, CmabDict
25+
from .helpers.types import ExperimentDict, ExperimentType, TrafficAllocation, VariableDict, VariationDict, CmabDict
2626

2727

2828
class BaseEntity:
@@ -87,6 +87,7 @@ def __init__(
8787
groupId: Optional[str] = None,
8888
groupPolicy: Optional[str] = None,
8989
cmab: Optional[CmabDict] = None,
90+
type: Optional[ExperimentType] = None,
9091
**kwargs: Any
9192
):
9293
self.id = id
@@ -101,6 +102,7 @@ def __init__(
101102
self.groupId = groupId
102103
self.groupPolicy = groupPolicy
103104
self.cmab = cmab
105+
self.type = type
104106

105107
def get_audience_conditions_or_ids(self) -> Sequence[str | list[str]]:
106108
""" Returns audienceConditions if present, otherwise audienceIds. """

optimizely/helpers/enums.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,14 @@ class OdpManagerConfig:
229229
EVENT_TYPE: Final = 'fullstack'
230230

231231

232+
class ExperimentTypes:
233+
ab: Final = 'ab'
234+
mab: Final = 'mab'
235+
cmab: Final = 'cmab'
236+
td: Final = 'td'
237+
fr: Final = 'fr'
238+
239+
232240
class OdpSegmentsCacheConfig:
233241
"""ODP Segment Cache configs."""
234242
DEFAULT_CAPACITY: Final = 10_000

optimizely/helpers/types.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,10 @@ class CmabDict(BaseEntity):
117117
trafficAllocation: int
118118

119119

120+
ExperimentType = Literal[
121+
'ab', 'mab', 'cmab', 'td', 'fr'
122+
]
123+
120124
HoldoutStatus = Literal['Draft', 'Running', 'Concluded', 'Archived']
121125

122126

optimizely/project_config.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,24 @@ def __init__(self, datafile: str | bytes, logger: Logger, error_handler: Any):
189189
self.variation_key_map_by_experiment_id: dict[str, dict[str, Union[entities.Variation, VariationDict]]] = {}
190190
self.flag_variations_map: dict[str, list[entities.Variation]] = {}
191191

192+
valid_experiment_types = {
193+
enums.ExperimentTypes.ab,
194+
enums.ExperimentTypes.mab,
195+
enums.ExperimentTypes.cmab,
196+
enums.ExperimentTypes.td,
197+
enums.ExperimentTypes.fr,
198+
}
192199
for experiment in self.experiment_id_map.values():
200+
if experiment.type is not None and experiment.type not in valid_experiment_types:
201+
self.logger.error(
202+
f'Experiment "{experiment.key}" has invalid type "{experiment.type}". '
203+
f'Valid types: {valid_experiment_types}.'
204+
)
205+
self.error_handler.handle_error(
206+
exceptions.InvalidExperimentException(
207+
f'Invalid experiment type: {experiment.type}'
208+
)
209+
)
193210
self.experiment_key_map[experiment.key] = experiment
194211
self.variation_key_map[experiment.key] = self._generate_key_map(
195212
experiment.variations, 'key', entities.Variation
@@ -232,6 +249,37 @@ def __init__(self, datafile: str | bytes, logger: Logger, error_handler: Any):
232249
self.experiment_feature_map[exp_id] = [feature.id]
233250
rules.append(self.experiment_id_map[exp_id])
234251

252+
# Feature Rollout support: inject the "everyone else" variation
253+
# into any experiment with type == "feature_rollout"
254+
everyone_else_variation = self._get_everyone_else_variation(feature)
255+
if everyone_else_variation is not None:
256+
for experiment in rules:
257+
if experiment.type == enums.ExperimentTypes.fr:
258+
experiment.variations.append({
259+
'id': everyone_else_variation.id,
260+
'key': everyone_else_variation.key,
261+
'featureEnabled': everyone_else_variation.featureEnabled,
262+
'variables': cast(
263+
list[types.VariableDict],
264+
everyone_else_variation.variables,
265+
),
266+
})
267+
experiment.trafficAllocation.append({
268+
'entityId': everyone_else_variation.id,
269+
'endOfRange': 10000,
270+
})
271+
self.variation_key_map[experiment.key][everyone_else_variation.key] = everyone_else_variation
272+
self.variation_id_map[experiment.key][everyone_else_variation.id] = everyone_else_variation
273+
self.variation_id_map_by_experiment_id[experiment.id][everyone_else_variation.id] = (
274+
everyone_else_variation
275+
)
276+
self.variation_key_map_by_experiment_id[experiment.id][everyone_else_variation.key] = (
277+
everyone_else_variation
278+
)
279+
self.variation_variable_usage_map[everyone_else_variation.id] = self._generate_key_map(
280+
everyone_else_variation.variables, 'id', entities.Variation.VariableUsage
281+
)
282+
235283
flag_id = feature.id
236284
applicable_holdouts: list[entities.Holdout] = []
237285

@@ -667,6 +715,41 @@ def get_rollout_from_id(self, rollout_id: str) -> Optional[entities.Layer]:
667715
self.logger.error(f'Rollout with ID "{rollout_id}" is not in datafile.')
668716
return None
669717

718+
def _get_everyone_else_variation(self, flag: entities.FeatureFlag) -> Optional[entities.Variation]:
719+
""" Get the "everyone else" variation for a feature flag.
720+
721+
The "everyone else" rule is the last experiment in the flag's rollout,
722+
and its first variation is the "everyone else" variation.
723+
724+
Args:
725+
flag: The feature flag to get the everyone else variation for.
726+
727+
Returns:
728+
The "everyone else" Variation entity, or None if not available.
729+
"""
730+
if not flag.rolloutId:
731+
return None
732+
733+
rollout = self.get_rollout_from_id(flag.rolloutId)
734+
if not rollout or not rollout.experiments:
735+
return None
736+
737+
everyone_else_rule = rollout.experiments[-1]
738+
variations = everyone_else_rule.get('variations', [])
739+
if not variations:
740+
return None
741+
742+
variation_dict = variations[0]
743+
return entities.Variation(
744+
id=variation_dict['id'],
745+
key=variation_dict['key'],
746+
featureEnabled=bool(variation_dict.get('featureEnabled', False)),
747+
variables=cast(
748+
Optional[list[entities.Variable]],
749+
variation_dict.get('variables'),
750+
),
751+
)
752+
670753
def get_variable_value_for_variation(
671754
self, variable: Optional[entities.Variable], variation: Optional[Union[entities.Variation, VariationDict]]
672755
) -> Optional[str]:

0 commit comments

Comments
 (0)