diff --git a/src/convert_sdk/context.py b/src/convert_sdk/context.py index a3942ca..a6dcd12 100644 --- a/src/convert_sdk/context.py +++ b/src/convert_sdk/context.py @@ -26,7 +26,7 @@ import logging from dataclasses import dataclass from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Mapping, Optional, TypeVar +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Mapping, Optional, Sequence, TypeVar from convert_sdk._internal.redaction import SafeContext, fingerprint_visitor from convert_sdk.domain.context_state import ContextState @@ -768,6 +768,8 @@ def run_feature( *, attributes: Optional[Mapping[str, Any]] = None, location_attributes: Optional[Mapping[str, Any]] = None, + experience_keys: Optional[Sequence[str]] = None, + type_casting: bool = True, ) -> Optional[FeatureResult]: """Resolve a single feature by key for this visitor. @@ -778,9 +780,24 @@ def run_feature( only. Returns a typed :class:`~convert_sdk.domain.results.FeatureResult` when the feature is declared and the visitor buckets into a variation carrying its change, - or ``None`` for any normal miss (undeclared/unavailable/disabled feature, + or ``None`` for any normal miss (undeclared/unavailable feature, unqualified visitor). Never raises for normal evaluation outcomes and performs no network I/O. + + Args: + feature_key: The feature key to resolve. + attributes: Optional per-call visitor attribute overlay. + location_attributes: Optional per-call location overlay. + experience_keys: Optional filter (CAP-1). ``None``/``[]`` mean + every experience; an unknown key is skipped, all-unknown + omits the feature (never an error). Caller order is + ignored — evaluation follows config order, and the first + experience that resolves wins. + type_casting: When truthy (default), casts variables by + declared type; falsy skips ONLY the cast — same variation + and feature, every other field identical. Uncast is lossy, + not more accurate (e.g. a ``json`` variable returns as its + stored string). """ visitor_attributes = self._state.with_overlay(attributes) location = self._merge(self._location_attributes, location_attributes) @@ -791,6 +808,8 @@ def run_feature( visitor_attributes=visitor_attributes, location_attributes=location, sticky_bucketing=self._state.bucketing, + experience_keys=experience_keys, + type_casting=type_casting, ) def run_features( @@ -798,6 +817,8 @@ def run_features( *, attributes: Optional[Mapping[str, Any]] = None, location_attributes: Optional[Mapping[str, Any]] = None, + experience_keys: Optional[Sequence[str]] = None, + type_casting: bool = True, ) -> List[FeatureResult]: """Resolve all applicable features for this visitor. @@ -805,6 +826,16 @@ def run_features( enabled state; features the visitor does not bucket into are omitted (no ``None`` entries). Evaluation stays local to the snapshot — no network I/O. + + Args: + attributes: Optional per-call visitor attribute overlay (ephemeral). + location_attributes: Optional per-call location attribute overlay + (ephemeral). + experience_keys: Optional filter (CAP-1), applied to each + per-feature resolution — same empty-list/unknown-key + behavior. See :meth:`run_feature`. + type_casting: Forwarded verbatim to every resolved feature — same + skip-only-the-cast behavior. See :meth:`run_feature`. """ visitor_attributes = self._state.with_overlay(attributes) location = self._merge(self._location_attributes, location_attributes) @@ -814,6 +845,8 @@ def run_features( visitor_attributes=visitor_attributes, location_attributes=location, sticky_bucketing=self._state.bucketing, + experience_keys=experience_keys, + type_casting=type_casting, ) # --- conversion tracking ----------------------------------------------- @@ -1097,14 +1130,28 @@ def diagnose_feature( *, attributes: Optional[Mapping[str, Any]] = None, location_attributes: Optional[Mapping[str, Any]] = None, + experience_keys: Optional[Sequence[str]] = None, ) -> FeatureDiagnostic: """Diagnose why a feature did or did not resolve for this visitor (FR50). Returns a typed :class:`~convert_sdk.domain.results.FeatureDiagnostic` naming the closed reason — ``FEATURE_NOT_FOUND`` (no feature matches the key), ``FEATURE_NOT_IN_SELECTED_VARIATIONS`` (the feature is declared but - the visitor's selected variation(s) carry no change for it), or - ``RESOLVED``. Additive to :meth:`run_feature`. + the visitor's selected variation(s) carry no change for it, including + when the reason is the caller's own ``experience_keys`` filter), or + ``RESOLVED``. Additive to :meth:`run_feature`, and CAP-3: agrees with it + under the identical filter. + + Args: + feature_key: The feature key to diagnose. + attributes: Optional per-call visitor attribute overlay (ephemeral). + location_attributes: Optional per-call location attribute overlay + (ephemeral). + experience_keys: Optional filter (CAP-1); same empty-list and + unknown-key semantics as :meth:`run_feature`. + + Does not accept ``type_casting`` (D-7): the diagnostic returns a reason + and no variable map, so the flag has no decision left to change. """ visitor_attributes = self._state.with_overlay(attributes) location = self._merge(self._location_attributes, location_attributes) @@ -1124,6 +1171,7 @@ def diagnose_feature( visitor_attributes=visitor_attributes, location_attributes=location, sticky_bucketing=self._state.bucketing, + experience_keys=experience_keys, ) if result is not None: return self._diagnose( diff --git a/src/convert_sdk/evaluation/features.py b/src/convert_sdk/evaluation/features.py index 56a1de5..ae9ac05 100644 --- a/src/convert_sdk/evaluation/features.py +++ b/src/convert_sdk/evaluation/features.py @@ -30,7 +30,7 @@ from __future__ import annotations import json -from typing import Any, Dict, List, Mapping, Optional +from typing import Any, Dict, List, Mapping, Optional, Sequence, Set from convert_sdk.domain.results import FeatureResult, FeatureStatus from convert_sdk.evaluation.experiences import select_experience @@ -97,8 +97,15 @@ def _variable_types(feature: Mapping[str, Any]) -> Dict[str, str]: def _cast_variables( - raw_variables: Mapping[str, Any], feature: Mapping[str, Any] + raw_variables: Mapping[str, Any], feature: Mapping[str, Any], type_casting: bool = True ) -> Dict[str, Any]: + """Cast ``raw_variables`` by declared type, or pass them through verbatim. + + ``type_casting`` gates casting by plain truthiness (CAP-2, D-6): ``False`` + returns every value exactly as the snapshot stores it, no conversion. + """ + if not type_casting: + return {str(key): value for key, value in (raw_variables or {}).items()} types = _variable_types(feature) return { str(key): _cast_value(value, types.get(str(key))) @@ -106,6 +113,22 @@ def _cast_variables( } +def _normalize_experience_keys( + experience_keys: Optional[Sequence[str]], +) -> Optional[Set[str]]: + """Normalize the ``experience_keys`` filter to an allow-set, or ``None``. + + ``None`` means "consider every experience" (CAP-1 / D-4): absent, an empty + sequence, and a bare ``str`` (guarded explicitly -- a ``str`` satisfies + ``Sequence[str]`` and would otherwise be iterated character-by-character) + all normalize to ``None``. A non-empty sequence dedupes to a set. + """ + if experience_keys is None or isinstance(experience_keys, str): + return None + keys = {str(key) for key in experience_keys} + return keys or None + + def _experiences_declaring_feature(snapshot: Any, feature_id: str) -> List[Mapping[str, Any]]: """Experiences with at least one variation carrying the feature's change.""" matching: List[Mapping[str, Any]] = [] @@ -125,6 +148,8 @@ def resolve_feature( visitor_attributes: Optional[Mapping[str, Any]] = None, location_attributes: Optional[Mapping[str, Any]] = None, sticky_bucketing: Optional[Mapping[str, str]] = None, + experience_keys: Optional[Sequence[str]] = None, + type_casting: bool = True, ) -> Optional[FeatureResult]: """Resolve a single feature by key for ``visitor_id``. @@ -140,6 +165,15 @@ def resolve_feature( feature resolution stays consistent with an already-served/persisted bucketing decision (the shared sticky-read chokepoint, JS parity) instead of re-hashing. This function only reads the map; it persists nothing. + + ``experience_keys`` (CAP-1) narrows which declaring experiences are + considered, by set membership against the config's own experience order -- + the caller's key order never decides precedence. ``None``, absent, an + empty sequence, or a bare ``str`` all mean "every experience" (D-4). + + ``type_casting`` (CAP-2) gates variable casting by plain truthiness. + ``True`` (default) casts by declared type; falsy returns stored values + verbatim. Changes no decision -- only ``variables`` differs. """ if not visitor_id: return None @@ -153,10 +187,16 @@ def resolve_feature( return None feature_id = str(feature_id) + allowed_experience_keys = _normalize_experience_keys(experience_keys) + for experience in _experiences_declaring_feature(snapshot, feature_id): experience_key = experience.get("key") if experience_key is None: continue + if allowed_experience_keys is not None and str(experience_key) not in ( + allowed_experience_keys + ): + continue result = select_experience( str(experience_key), snapshot, @@ -170,7 +210,7 @@ def resolve_feature( change = _feature_change_for(result.variation, feature_id) if change is None: continue - variables = _cast_variables(change.get("variables_data") or {}, feature) + variables = _cast_variables(change.get("variables_data") or {}, feature, type_casting) return FeatureResult( feature_key=str(feature.get("key", feature_key)), feature_id=feature_id, @@ -190,6 +230,8 @@ def resolve_features( visitor_attributes: Optional[Mapping[str, Any]] = None, location_attributes: Optional[Mapping[str, Any]] = None, sticky_bucketing: Optional[Mapping[str, str]] = None, + experience_keys: Optional[Sequence[str]] = None, + type_casting: bool = True, ) -> List[FeatureResult]: """Resolve all applicable features for ``visitor_id``. @@ -198,8 +240,14 @@ def resolve_features( ``None`` entries). Evaluation stays local to the snapshot — no network I/O. ``sticky_bucketing`` (qs-03 PY-5) is forwarded verbatim to each per-feature - :func:`resolve_feature` call, read-only. + :func:`resolve_feature` call, read-only. ``experience_keys`` (CAP-1) is materialised + once, then applied identically to every feature; a feature reachable only through an + excluded experience is omitted from the returned list, never padded ``DISABLED``. + ``type_casting`` (CAP-2) is forwarded verbatim to every resolved feature. """ + if experience_keys is not None and not isinstance(experience_keys, str): + experience_keys = tuple(experience_keys) + results: List[FeatureResult] = [] for feature in snapshot.features: key = feature.get("key") @@ -212,6 +260,8 @@ def resolve_features( visitor_attributes=visitor_attributes, location_attributes=location_attributes, sticky_bucketing=sticky_bucketing, + experience_keys=experience_keys, + type_casting=type_casting, ) if result is not None: results.append(result) diff --git a/tests/test_feature_experience_keys.py b/tests/test_feature_experience_keys.py new file mode 100644 index 0000000..eabcbd6 --- /dev/null +++ b/tests/test_feature_experience_keys.py @@ -0,0 +1,283 @@ +"""RED tests for the `experience_keys` filter on feature resolution. + +Locks in a NEW keyword-only ``experience_keys: Optional[Sequence[str]] = None`` +parameter on the Python SDK's feature entry points: the module-level +``resolve_feature`` / ``resolve_features`` (``evaluation/features.py``) and the +public ``Context.run_feature`` / ``Context.run_features`` / +``Context.diagnose_feature``. None of these accept the keyword today, so every +test below fails with a ``TypeError: unexpected keyword argument +'experience_keys'`` until the capability ships. + +Semantics locked in here (not yet implemented): + +* narrowing which experiences are considered for a feature changes + PRECEDENCE, not just post-hoc filtering (two experiences racing for the + same feature); +* an excluded feature is OMITTED, never padded with ``FeatureStatus.DISABLED``; +* CONFIG order governs evaluation order regardless of the caller's key order; +* edge inputs (``None``, ``[]``, unknown keys, all-unknown, duplicates, a bare + ``str``) are each a deliberate, new choice; +* ``diagnose_feature`` agrees with ``run_feature`` under the identical filter. + +``select_experience`` (``evaluation/experiences.py``) gains no parameter and is +never called directly here. +""" + +from __future__ import annotations + +import pytest + +from convert_sdk import Core, DiagnosticReason, FeatureStatus, SDKConfig +from convert_sdk.config_loader import load_snapshot +from convert_sdk.evaluation.features import resolve_feature, resolve_features + + +def _feature(feature_id, key): + return {"id": feature_id, "key": key, "variables": []} + + +def _experience_with_feature(exp_id, key, feature_id, variation_id, variation_key): + return { + "id": exp_id, + "key": key, + "variations": [ + { + "id": variation_id, + "key": variation_key, + "traffic_allocation": 100.0, + "changes": [ + { + "id": f"c-{exp_id}", + "type": "fullStackFeature", + "data": {"feature_id": feature_id, "variables_data": {}}, + } + ], + } + ], + } + + +def _config(experiences, features): + return { + "account_id": "100123", + "project": {"id": "200456"}, + "features": features, + "experiences": experiences, + } + + +def _ctx(config, visitor_id="visitor-1"): + core = Core(SDKConfig(data=config), transport=None).initialize() + return core.create_context(visitor_id) + + +# Two experiences, each carrying its OWN feature -- CAP-1's narrowing/omission +# fixture. Keys are deliberately multi-character (never single-letter) so a +# naive `experience_keys` guard that iterates a bare string's characters +# cannot coincidentally match either key. +DISTINCT_FEATURES_CONFIG = _config( + experiences=[ + _experience_with_feature( + "e-checkout", "checkout-experiment", "f-a", "v-checkout", "checkout-variant" + ), + _experience_with_feature( + "e-upsell", "upsell-experiment", "f-b", "v-upsell", "upsell-variant" + ), + ], + features=[_feature("f-a", "feature-a"), _feature("f-b", "feature-b")], +) + +# Two experiences that both carry the SAME feature -- the precedence fixture. +# Config order: primary first, secondary second. +SHARED_FEATURE_CONFIG = _config( + experiences=[ + _experience_with_feature( + "e-primary", "primary-experiment", "f-shared", "v-primary", "primary-variant" + ), + _experience_with_feature( + "e-secondary", "secondary-experiment", "f-shared", "v-secondary", "secondary-variant" + ), + ], + features=[_feature("f-shared", "shared-feature")], +) + +# Identical experiences, config order reversed (secondary first). +SHARED_FEATURE_CONFIG_REVERSED = _config( + experiences=[ + _experience_with_feature( + "e-secondary", "secondary-experiment", "f-shared", "v-secondary", "secondary-variant" + ), + _experience_with_feature( + "e-primary", "primary-experiment", "f-shared", "v-primary", "primary-variant" + ), + ], + features=[_feature("f-shared", "shared-feature")], +) + + +# --- module-level resolve_feature / resolve_features accept the keyword ------ + + +def test_resolve_feature_accepts_experience_keys_keyword(): + snapshot = load_snapshot(DISTINCT_FEATURES_CONFIG) + result = resolve_feature( + "feature-a", snapshot, visitor_id="v1", experience_keys=["checkout-experiment"] + ) + assert result is not None + assert result.feature_key == "feature-a" + + +def test_resolve_features_accepts_experience_keys_keyword(): + snapshot = load_snapshot(DISTINCT_FEATURES_CONFIG) + results = resolve_features(snapshot, visitor_id="v1", experience_keys=["checkout-experiment"]) + assert {r.feature_key for r in results} == {"feature-a"} + + +# --- edge-input table: rows 1 (absent) and 2 (empty list) -------------------- + + +@pytest.mark.parametrize("experience_keys", [None, []], ids=["absent", "empty-list"]) +def test_absent_and_empty_list_both_consider_every_experience(experience_keys): + ctx = _ctx(DISTINCT_FEATURES_CONFIG) + results = ctx.run_features(experience_keys=experience_keys) + assert {r.feature_key for r in results} == {"feature-a", "feature-b"} + assert ctx.run_feature("feature-a", experience_keys=experience_keys) is not None + assert ctx.run_feature("feature-b", experience_keys=experience_keys) is not None + + +# --- edge-input table row 3: one unknown key among known --------------------- + + +def test_one_unknown_key_among_known_is_skipped_known_keys_still_resolve(): + ctx = _ctx(DISTINCT_FEATURES_CONFIG) + results = ctx.run_features( + experience_keys=["checkout-experiment", "upsell-experiment", "does-not-exist"] + ) + assert {r.feature_key for r in results} == {"feature-a", "feature-b"} + + +# --- edge-input table row 4: every key unknown ------------------------------- + + +def test_every_key_unknown_omits_every_feature_never_disabled(): + ctx = _ctx(DISTINCT_FEATURES_CONFIG) + results = ctx.run_features(experience_keys=["ghost-one", "ghost-two"]) + assert results == [] + assert all(r.status is not FeatureStatus.DISABLED for r in results) + assert ctx.run_feature("feature-a", experience_keys=["ghost-one", "ghost-two"]) is None + assert ctx.run_feature("feature-b", experience_keys=["ghost-one", "ghost-two"]) is None + + +# --- edge-input table row 6: duplicate keys ---------------------------------- + + +def test_duplicate_keys_are_deduplicated_and_do_not_change_the_result(): + ctx = _ctx(DISTINCT_FEATURES_CONFIG) + single = {r.feature_key for r in ctx.run_features(experience_keys=["checkout-experiment"])} + duplicated = { + r.feature_key + for r in ctx.run_features( + experience_keys=["checkout-experiment", "checkout-experiment", "checkout-experiment"] + ) + } + assert single == duplicated == {"feature-a"} + + +# --- edge-input table row 7: a bare str is treated as absent ----------------- + + +def test_bare_string_is_treated_as_absent_not_iterated_as_characters(): + ctx = _ctx(DISTINCT_FEATURES_CONFIG) + absent = {r.feature_key for r in ctx.run_features(experience_keys=None)} + bare_str = {r.feature_key for r in ctx.run_features(experience_keys="checkout-experiment")} + assert bare_str == absent == {"feature-a", "feature-b"} + + +# --- CAP-2: narrowing omits, never pads DISABLED ----------------------------- + + +def test_narrowing_to_one_experience_omits_the_other_features_result(): + ctx = _ctx(DISTINCT_FEATURES_CONFIG) + filtered = ctx.run_features(experience_keys=["checkout-experiment"]) + assert {r.feature_key for r in filtered} == {"feature-a"} + assert all(r.status is not FeatureStatus.DISABLED for r in filtered) + assert ctx.run_feature("feature-b", experience_keys=["checkout-experiment"]) is None + + +# --- CAP-1: precedence, not just membership ---------------------------------- + + +def test_default_precedence_resolves_the_first_experience_in_config_order(): + ctx = _ctx(SHARED_FEATURE_CONFIG, visitor_id="shared-visitor") + result = ctx.run_feature("shared-feature", experience_keys=None) + assert result is not None + assert result.experience_key == "primary-experiment" + + +def test_experience_keys_filter_shifts_precedence_to_the_named_experience(): + ctx = _ctx(SHARED_FEATURE_CONFIG, visitor_id="shared-visitor") + result = ctx.run_feature("shared-feature", experience_keys=["secondary-experiment"]) + assert result is not None + assert result.experience_key == "secondary-experiment" + + +# --- edge-input table row 5 + point 4: config order governs, caller order doesn't --- + + +def test_reversing_caller_key_order_does_not_change_precedence(): + ctx = _ctx(SHARED_FEATURE_CONFIG, visitor_id="shared-visitor") + result = ctx.run_feature( + "shared-feature", experience_keys=["secondary-experiment", "primary-experiment"] + ) + assert result is not None + assert result.experience_key == "primary-experiment" + + +def test_reversing_config_experience_order_flips_precedence(): + ctx = _ctx(SHARED_FEATURE_CONFIG_REVERSED, visitor_id="shared-visitor") + result = ctx.run_feature("shared-feature", experience_keys=None) + assert result is not None + assert result.experience_key == "secondary-experiment" + + +# --- CAP-3: diagnose_feature agrees with run_feature under the same filter --- + + +@pytest.mark.parametrize( + "feature_key,experience_keys", + [ + ("feature-a", None), + ("feature-a", ["upsell-experiment"]), + ("feature-b", ["checkout-experiment", "upsell-experiment"]), + ("feature-a", ["ghost-key"]), + ], + ids=["resolves-unfiltered", "filtered-out-by-caller", "included-resolves", "unknown-key-omits"], +) +def test_diagnose_feature_agrees_with_run_feature_under_the_identical_filter( + feature_key, experience_keys +): + ctx = _ctx(DISTINCT_FEATURES_CONFIG) + resolved = ctx.run_feature(feature_key, experience_keys=experience_keys) is not None + diagnosis = ctx.diagnose_feature(feature_key, experience_keys=experience_keys) + assert diagnosis.resolved is resolved + if not resolved: + assert diagnosis.reason is DiagnosticReason.FEATURE_NOT_IN_SELECTED_VARIATIONS + + +# --- one-shot iterables must not be exhausted before the second feature ----- + + +def test_run_features_gives_identical_results_for_list_generator_and_map(): + # ``resolve_features`` must materialize a one-shot iterable once, before + # its per-feature loop, or feature #2+ sees it already exhausted. + ctx = _ctx(DISTINCT_FEATURES_CONFIG) + keys_source = ["checkout-experiment"] + + from_list = {r.feature_key for r in ctx.run_features(experience_keys=list(keys_source))} + from_generator = { + r.feature_key + for r in ctx.run_features(experience_keys=(k for k in keys_source)) + } + from_map = {r.feature_key for r in ctx.run_features(experience_keys=map(str, keys_source))} + + assert from_list == from_generator == from_map == {"feature-a"} diff --git a/tests/test_feature_experience_keys_evaluation_scope.py b/tests/test_feature_experience_keys_evaluation_scope.py new file mode 100644 index 0000000..c8e9e99 --- /dev/null +++ b/tests/test_feature_experience_keys_evaluation_scope.py @@ -0,0 +1,25 @@ +"""CAP-1: `experience_keys` narrows evaluation itself, not just the result.""" + +from __future__ import annotations + +from unittest.mock import patch + +import convert_sdk.evaluation.features as features_mod +from tests.test_feature_experience_keys import SHARED_FEATURE_CONFIG, _ctx + + +def test_excluded_experience_is_never_passed_to_select_experience(): + # Patch features_mod's own binding -- context.py imports a separate + # name for the same function, so a spy there misses these calls. + ctx = _ctx(SHARED_FEATURE_CONFIG, visitor_id="shared-visitor") + + with patch.object( + features_mod, "select_experience", wraps=features_mod.select_experience + ) as spy: + result = ctx.run_feature("shared-feature", experience_keys=["secondary-experiment"]) + + assert result is not None + assert result.experience_key == "secondary-experiment" + called_experience_keys = [call.args[0] for call in spy.call_args_list] + assert "primary-experiment" not in called_experience_keys + assert called_experience_keys == ["secondary-experiment"] diff --git a/tests/test_feature_type_casting.py b/tests/test_feature_type_casting.py new file mode 100644 index 0000000..19da78a --- /dev/null +++ b/tests/test_feature_type_casting.py @@ -0,0 +1,311 @@ +"""RED tests for the `type_casting` control on feature resolution (CAP-2). + +Locks in a NEW keyword-only ``type_casting: bool = True`` parameter on +``Context.run_feature`` / ``Context.run_features`` (and the module-level +``resolve_feature`` / ``resolve_features`` they delegate to). Neither accepts +the keyword today, so every test below fails with a ``TypeError: unexpected +keyword argument 'type_casting'`` until the capability ships. + +Semantics locked in here (not yet implemented): + +* ``True`` (the default) reproduces today's cast-unconditionally behaviour, + including the lossy ``boolean`` cast of an uncastable stored value to + ``False`` -- the regression half of CAP-2's success criterion. +* ``False`` returns every variable exactly as the served config stores it: no + boolean coercion, no JSON parse, no int/float conversion. +* The flag changes no decision -- every ``FeatureResult`` field but + ``variables`` is identical either way (D-6, D-7's premise). +* Truthiness decides, matching ``enable_tracking``/``enable_storage``, not JS's + key-presence rule or Ruby's "only literal false" rule (D-6). +* ``run_features`` forwards the flag to every resolved feature, not just the + first. +* ``experience_keys`` and ``type_casting`` compose without interfering. + +``diagnose_feature`` deliberately does NOT gain this keyword (D-7) and is out +of scope here. +""" + +from __future__ import annotations + +import pytest + +from convert_sdk import Core, SDKConfig + + +def _feature(feature_id, key, variables): + return {"id": feature_id, "key": key, "variables": variables} + + +def _change(feature_id, variables_data, change_id): + return { + "id": change_id, + "type": "fullStackFeature", + "data": {"feature_id": feature_id, "variables_data": variables_data}, + } + + +def _experience(exp_id, key, variation_id, variation_key, change): + return { + "id": exp_id, + "key": key, + "variations": [ + { + "id": variation_id, + "key": variation_key, + "traffic_allocation": 100.0, + "changes": [change], + } + ], + } + + +def _config(experiences, features): + return { + "account_id": "100123", + "project": {"id": "200456"}, + "features": features, + "experiences": experiences, + } + + +def _ctx(config, visitor_id="visitor-1"): + core = Core(SDKConfig(data=config), transport=None).initialize() + return core.create_context(visitor_id) + + +# --- boundary 1: CAP-2's success criterion, one feature, every declared type - + +TYPE_CASTING_FEATURE_ID = "f-type" +TYPE_CASTING_FEATURE_KEY = "typed-feature" + +# "on" is the value the boolean cast cannot represent: not "true"/"1"/"yes" +# (case-insensitive), so casting-on coerces it to False -- the CAP-2 defect. +STORED_VARIABLES_DATA = { + "enabled": "on", + "count": "7", + "meta": '{"k": "v"}', + "raw": "unchanged-value", +} + +TYPE_CASTING_CONFIG = _config( + experiences=[ + _experience( + "e-type", + "typed-experiment", + "v-type", + "typed-variant", + _change(TYPE_CASTING_FEATURE_ID, STORED_VARIABLES_DATA, "c-type"), + ) + ], + features=[ + _feature( + TYPE_CASTING_FEATURE_ID, + TYPE_CASTING_FEATURE_KEY, + [ + {"key": "enabled", "type": "boolean"}, + {"key": "count", "type": "integer"}, + {"key": "meta", "type": "json"}, + {"key": "raw"}, # no declared type -> passthrough + ], + ) + ], +) + + +def test_type_casting_default_true_matches_todays_cast_behaviour(): + ctx = _ctx(TYPE_CASTING_CONFIG) + + result = ctx.run_feature(TYPE_CASTING_FEATURE_KEY) + assert result is not None + assert result.variables["enabled"] is False + assert result.variables["count"] == 7 + assert isinstance(result.variables["count"], int) + assert result.variables["meta"] == {"k": "v"} + assert result.variables["raw"] == "unchanged-value" + + all_results = ctx.run_features() + assert len(all_results) == 1 + assert all_results[0].variables["enabled"] is False + assert all_results[0].variables["count"] == 7 + assert all_results[0].variables["meta"] == {"k": "v"} + assert all_results[0].variables["raw"] == "unchanged-value" + + +def test_type_casting_false_returns_stored_values_uncast(): + ctx = _ctx(TYPE_CASTING_CONFIG) + + result = ctx.run_feature(TYPE_CASTING_FEATURE_KEY, type_casting=False) + assert result is not None + assert result.variables["enabled"] == "on" + assert result.variables["count"] == "7" + assert result.variables["meta"] == '{"k": "v"}' + assert result.variables["raw"] == "unchanged-value" + + all_results = ctx.run_features(type_casting=False) + assert len(all_results) == 1 + assert all_results[0].variables["enabled"] == "on" + assert all_results[0].variables["count"] == "7" + assert all_results[0].variables["meta"] == '{"k": "v"}' + assert all_results[0].variables["raw"] == "unchanged-value" + + +# --- boundary 2: the flag changes no decision ------------------------------- + + +def test_type_casting_flag_changes_no_decision_only_variables_differ(): + ctx = _ctx(TYPE_CASTING_CONFIG) + + cast_on = ctx.run_feature(TYPE_CASTING_FEATURE_KEY) + cast_off = ctx.run_feature(TYPE_CASTING_FEATURE_KEY, type_casting=False) + + assert cast_on is not None + assert cast_off is not None + assert cast_on.feature_key == cast_off.feature_key + assert cast_on.feature_id == cast_off.feature_id + assert cast_on.status is cast_off.status + assert cast_on.experience_key == cast_off.experience_key + assert cast_on.variation_key == cast_off.variation_key + assert dict(cast_on.variables) != dict(cast_off.variables) + + +# --- boundary 3: D-6 truthiness, parametrized over falsy and truthy values -- + + +@pytest.mark.parametrize( + "type_casting", [False, None, 0, ""], ids=["false", "none", "zero", "empty-string"] +) +def test_falsy_type_casting_values_all_disable_casting(type_casting): + ctx = _ctx(TYPE_CASTING_CONFIG) + result = ctx.run_feature(TYPE_CASTING_FEATURE_KEY, type_casting=type_casting) + assert result is not None + assert result.variables["enabled"] == "on" + + +@pytest.mark.parametrize("type_casting", [True, 1, "yes"], ids=["true", "one", "truthy-string"]) +def test_truthy_type_casting_values_all_leave_casting_on(type_casting): + ctx = _ctx(TYPE_CASTING_CONFIG) + result = ctx.run_feature(TYPE_CASTING_FEATURE_KEY, type_casting=type_casting) + assert result is not None + assert result.variables["enabled"] is False + + +# --- boundary 4: run_features carries the flag to every resolved feature --- + +TWO_FEATURE_CONFIG = _config( + experiences=[ + _experience( + "e-alpha", + "alpha-experiment", + "v-alpha", + "alpha-variant", + _change("f-alpha", {"enabled": "enabled"}, "c-alpha"), + ), + _experience( + "e-beta", + "beta-experiment", + "v-beta", + "beta-variant", + _change("f-beta", {"enabled": "on"}, "c-beta"), + ), + ], + features=[ + _feature("f-alpha", "feature-alpha", [{"key": "enabled", "type": "boolean"}]), + _feature("f-beta", "feature-beta", [{"key": "enabled", "type": "boolean"}]), + ], +) + + +def test_run_features_carries_type_casting_to_every_resolved_feature(): + ctx = _ctx(TWO_FEATURE_CONFIG) + results = ctx.run_features(type_casting=False) + assert len(results) == 2 + by_key = {r.feature_key: r for r in results} + assert by_key["feature-alpha"].variables["enabled"] == "enabled" + assert by_key["feature-beta"].variables["enabled"] == "on" + + +# --- boundary 5: composition with experience_keys --------------------------- + +# Same feature carried by two experiences (precedence fixture), each storing a +# different uncastable boolean value, so a passing test must prove BOTH that +# experience_keys shifted precedence to the named experience AND that its +# variables came back uncast. +SHARED_FEATURE_ID = "f-shared-typed" +SHARED_FEATURE_KEY = "shared-typed-feature" + +SHARED_TYPE_CASTING_CONFIG = _config( + experiences=[ + _experience( + "e-primary-t", + "primary-typed-experiment", + "v-primary-t", + "primary-typed-variant", + _change(SHARED_FEATURE_ID, {"enabled": "on"}, "c-primary-t"), + ), + _experience( + "e-secondary-t", + "secondary-typed-experiment", + "v-secondary-t", + "secondary-typed-variant", + _change(SHARED_FEATURE_ID, {"enabled": "enabled"}, "c-secondary-t"), + ), + ], + features=[_feature(SHARED_FEATURE_ID, SHARED_FEATURE_KEY, [{"key": "enabled", "type": "boolean"}])], +) + + +def test_type_casting_composes_with_experience_keys_filter(): + ctx = _ctx(SHARED_TYPE_CASTING_CONFIG, visitor_id="shared-typed-visitor") + + result = ctx.run_feature( + SHARED_FEATURE_KEY, + experience_keys=["secondary-typed-experiment"], + type_casting=False, + ) + + assert result is not None + assert result.experience_key == "secondary-typed-experiment" + assert result.variables["enabled"] == "enabled" + + +# --- boundary 6: variables_data keys always normalize to str, on or off ---- + +# A non-str `variables_data` key must not survive into `result.variables` +# verbatim, cast on or off (CAP-2's `Dict[str, Any]` contract). +NON_STR_KEY_FEATURE_ID = "f-non-str-key" +NON_STR_KEY_FEATURE_KEY = "non-str-key-feature" + +NON_STR_KEY_CONFIG = _config( + experiences=[ + _experience( + "e-non-str-key", + "non-str-key-experiment", + "v-non-str-key", + "non-str-key-variant", + _change( + NON_STR_KEY_FEATURE_ID, + {5: "int-keyed-value", "enabled": "on"}, + "c-non-str-key", + ), + ) + ], + features=[ + _feature( + NON_STR_KEY_FEATURE_ID, + NON_STR_KEY_FEATURE_KEY, + [{"key": "enabled", "type": "boolean"}], + ) + ], +) + + +def test_variables_keys_are_always_str_regardless_of_type_casting(): + ctx = _ctx(NON_STR_KEY_CONFIG, visitor_id="non-str-key-visitor") + + cast_on = ctx.run_feature(NON_STR_KEY_FEATURE_KEY) + assert cast_on is not None + assert all(isinstance(key, str) for key in cast_on.variables) + + cast_off = ctx.run_feature(NON_STR_KEY_FEATURE_KEY, type_casting=False) + assert cast_off is not None + assert all(isinstance(key, str) for key in cast_off.variables) diff --git a/tests/test_per_call_control_matrix.py b/tests/test_per_call_control_matrix.py new file mode 100644 index 0000000..a6b67b2 --- /dev/null +++ b/tests/test_per_call_control_matrix.py @@ -0,0 +1,322 @@ +"""Per-call control matrix (CAP-4): the 11 per-call controls against all six +``Context`` evaluation surfaces. + +The declarative table below is the single source of truth for two things a +sibling story each froze independently: which controls a surface accepts +(CAP-1's ``experience_keys`` and CAP-2's ``type_casting`` on the feature pair; +the experience pair's ``enable_tracking``/``enable_storage`` predate this +workflow), and why the five controls no surface accepts stay absent rather +than being added piecemeal. Six signature tests below are DERIVED from the +table rather than hand-written, so a drift between a signature and its +documented disposition fails here first. +""" + +from __future__ import annotations + +import inspect +import re +from dataclasses import dataclass, field +from typing import Any, Dict, List, Mapping, Optional + +import pytest + +import convert_sdk.context as context_module +from convert_sdk.config_loader import load_snapshot +from convert_sdk.context import Context + +HONOURED = "honoured" +ABSENT = "absent" +NA = "n/a" + +R1 = "the feature-resolution path performs no tracking and no persistence, so the flag would be inert on that surface" +R2 = "the control is absent from every entry point in the package; adding it to the feature pair alone would manufacture the very experience/feature asymmetry this work removes" +R3 = "feature-scoped by definition -- the experience surfaces have no feature/variable concept for it to act on" +R4 = "narrower than R3: the diagnostic returns a reason and no variables, so the flag could not change its verdict" + +SURFACES = ( + "run_experience", + "run_experiences", + "run_feature", + "run_features", + "diagnose_experience", + "diagnose_feature", +) + + +@dataclass(frozen=True) +class _Disposition: + state: str + reason: Optional[str] = None + + +@dataclass(frozen=True) +class _ControlRow: + control: str + by_surface: Dict[str, _Disposition] = field(default_factory=dict) + + +def _all(state: str, reason: Optional[str] = None) -> Dict[str, _Disposition]: + return {surface: _Disposition(state, reason) for surface in SURFACES} + + +TABLE: List[_ControlRow] = [ + _ControlRow("attributes", _all(HONOURED)), + _ControlRow("location_attributes", _all(HONOURED)), + _ControlRow( + "enable_tracking", + { + "run_experience": _Disposition(HONOURED), + "run_experiences": _Disposition(HONOURED), + "run_feature": _Disposition(ABSENT, R1), + "run_features": _Disposition(ABSENT, R1), + "diagnose_experience": _Disposition(ABSENT, R1), + "diagnose_feature": _Disposition(ABSENT, R1), + }, + ), + _ControlRow( + "enable_storage", + { + "run_experience": _Disposition(HONOURED), + "run_experiences": _Disposition(HONOURED), + "run_feature": _Disposition(ABSENT, R1), + "run_features": _Disposition(ABSENT, R1), + "diagnose_experience": _Disposition(ABSENT, R1), + "diagnose_feature": _Disposition(ABSENT, R1), + }, + ), + _ControlRow( + "experience_keys", + { + "run_experience": _Disposition(NA, R3), + "run_experiences": _Disposition(NA, R3), + "run_feature": _Disposition(HONOURED), + "run_features": _Disposition(HONOURED), + "diagnose_experience": _Disposition(NA, R3), + "diagnose_feature": _Disposition(HONOURED), + }, + ), + _ControlRow( + "type_casting", + { + "run_experience": _Disposition(NA, R3), + "run_experiences": _Disposition(NA, R3), + "run_feature": _Disposition(HONOURED), + "run_features": _Disposition(HONOURED), + "diagnose_experience": _Disposition(NA, R3), + "diagnose_feature": _Disposition(ABSENT, R4), + }, + ), + _ControlRow("update_visitor_properties", _all(ABSENT, R2)), + _ControlRow("environment", _all(ABSENT, R2)), + _ControlRow("force_variation_id", _all(ABSENT, R2)), + _ControlRow("ignore_location_properties", _all(ABSENT, R2)), + _ControlRow("suppress_events", _all(ABSENT, R2)), +] + +# The positional key parameter each surface takes ahead of its keyword-only +# controls, if any (``None`` for the two "all applicable" surfaces). +_POSITIONAL_KEY: Dict[str, Optional[str]] = { + "run_experience": "experience_key", + "run_experiences": None, + "run_feature": "feature_key", + "run_features": None, + "diagnose_experience": "experience_key", + "diagnose_feature": "feature_key", +} + + +def _expected_params_for(surface: str) -> set[str]: + """Derive a surface's expected parameter set FROM the table above.""" + params = {"self"} + key_param = _POSITIONAL_KEY[surface] + if key_param is not None: + params.add(key_param) + for row in TABLE: + if row.by_surface[surface].state == HONOURED: + params.add(row.control) + return params + + +# --------------------------------------------------------------------------- +# Meta-assertion: every row covers all six surfaces, no cell silently omitted. +# --------------------------------------------------------------------------- + + +def test_every_row_specifies_a_disposition_for_all_six_surfaces(): + for row in TABLE: + assert set(row.by_surface) == set(SURFACES), row.control + + +# --------------------------------------------------------------------------- +# Six signature-set-equality tests, derived from the table. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("surface", SURFACES) +def test_surface_signature_matches_table_disposition(surface): + method = getattr(Context, surface) + actual = set(inspect.signature(method).parameters) + assert actual == _expected_params_for(surface) + + +@pytest.mark.parametrize("name", ["run_feature", "run_features", "diagnose_feature"]) +def test_docstring_args_match_signature(name): + """Every keyword param is documented, and no documented param is stale.""" + method = getattr(Context, name) + sig_params = set(inspect.signature(method).parameters) - {"self"} + entries = method.__doc__.split("Args:\n", 1)[1].split("\n\n", 1)[0] + indent = len(entries) - len(entries.lstrip(" ")) + documented = set(re.findall(rf"^ {{{indent}}}(\w+):", entries, re.MULTILINE)) + assert documented == sig_params + + +# --------------------------------------------------------------------------- +# Shared fixtures for the behavioral cases. +# --------------------------------------------------------------------------- + +_FEATURE_ID = "feat-one" +_FEATURE_KEY = "feature-one" +_EXPERIENCE_ID = "exp-one" +_EXPERIENCE_KEY = "experience-one" +_VARIATION_ID = "var-one" +_VARIATION_KEY = "variant-one" + + +def _change() -> Dict[str, Any]: + return { + "id": "c-one", + "type": "fullStackFeature", + "data": {"feature_id": _FEATURE_ID, "variables_data": {"flag": "on"}}, + } + + +def _config() -> Dict[str, Any]: + return { + "account_id": "100123", + "project": {"id": "200456"}, + "experiences": [ + { + "id": _EXPERIENCE_ID, + "key": _EXPERIENCE_KEY, + "variations": [ + { + "id": _VARIATION_ID, + "key": _VARIATION_KEY, + "traffic_allocation": 100.0, + "changes": [_change()], + } + ], + } + ], + "features": [ + {"id": _FEATURE_ID, "key": _FEATURE_KEY, "variables": [{"key": "flag", "type": "string"}]} + ], + } + + +def _ctx( + visitor_id: str = "visitor-1", + tracker: Any = None, + data_store: Any = None, +) -> Context: + snapshot = load_snapshot(_config()) + return Context(visitor_id, snapshot, tracker=tracker, data_store=data_store) + + +def _spy(monkeypatch: pytest.MonkeyPatch, name: str) -> List[Mapping[str, Any]]: + """Wrap ``convert_sdk.context.`` to record every call's kwargs, + forwarding to the real implementation so evaluation still resolves. + """ + calls: List[Mapping[str, Any]] = [] + original = getattr(context_module, name) + + def _wrapper(*args: Any, **kwargs: Any) -> Any: + calls.append(kwargs) + return original(*args, **kwargs) + + monkeypatch.setattr(context_module, name, _wrapper) + return calls + + +class _FakeTracker: + """Duck-typed tracker double recording ``track_bucketing`` calls only.""" + + def __init__(self) -> None: + self.calls: List[Mapping[str, Any]] = [] + + def track_bucketing(self, **kwargs: Any) -> None: + self.calls.append(kwargs) + + +# --------------------------------------------------------------------------- +# Behavioral cases: an honoured control's value reaches the evaluation seam. +# --------------------------------------------------------------------------- + + +def test_attributes_reaches_select_experience_via_run_experience(monkeypatch): + calls = _spy(monkeypatch, "select_experience") + _ctx().run_experience(_EXPERIENCE_KEY, attributes={"plan": "gold"}) + assert calls and calls[-1]["visitor_attributes"]["plan"] == "gold" + + +def test_location_attributes_reaches_select_experience_via_run_experience(monkeypatch): + calls = _spy(monkeypatch, "select_experience") + _ctx().run_experience(_EXPERIENCE_KEY, location_attributes={"url": "/checkout"}) + assert calls and calls[-1]["location_attributes"]["url"] == "/checkout" + + +def test_attributes_reaches_select_experience_via_run_experiences(monkeypatch): + calls = _spy(monkeypatch, "select_experience") + _ctx().run_experiences(attributes={"plan": "silver"}) + assert calls and all(c["visitor_attributes"]["plan"] == "silver" for c in calls) + + +def test_enable_tracking_false_suppresses_track_bucketing_on_run_experience(): + tracker = _FakeTracker() + ctx = _ctx(tracker=tracker) + result = ctx.run_experience(_EXPERIENCE_KEY, enable_tracking=False) + assert result is not None + assert tracker.calls == [] + + +def test_enable_storage_false_suppresses_data_store_write_on_run_experience(): + from convert_sdk.adapters.storage.in_memory import InMemoryDataStore + from convert_sdk.ports.storage import visitor_state_key + + store = InMemoryDataStore() + ctx = _ctx(data_store=store) + result = ctx.run_experience(_EXPERIENCE_KEY, enable_storage=False) + assert result is not None + assert store.get(visitor_state_key(ctx.visitor_id)) is None + + +def test_attributes_reaches_resolve_feature_via_run_feature(monkeypatch): + calls = _spy(monkeypatch, "resolve_feature") + _ctx().run_feature(_FEATURE_KEY, attributes={"plan": "gold"}) + assert calls and calls[-1]["visitor_attributes"]["plan"] == "gold" + + +def test_location_attributes_reaches_resolve_feature_via_run_feature(monkeypatch): + calls = _spy(monkeypatch, "resolve_feature") + _ctx().run_feature(_FEATURE_KEY, location_attributes={"url": "/checkout"}) + assert calls and calls[-1]["location_attributes"]["url"] == "/checkout" + + +def test_experience_keys_reaches_resolve_feature_via_run_feature(monkeypatch): + calls = _spy(monkeypatch, "resolve_feature") + _ctx().run_feature(_FEATURE_KEY, experience_keys=[_EXPERIENCE_KEY]) + assert calls and list(calls[-1]["experience_keys"]) == [_EXPERIENCE_KEY] + + +def test_type_casting_reaches_resolve_feature_via_run_feature(monkeypatch): + calls = _spy(monkeypatch, "resolve_feature") + _ctx().run_feature(_FEATURE_KEY, type_casting=False) + assert calls and calls[-1]["type_casting"] is False + + +def test_experience_keys_and_type_casting_reach_resolve_features_via_run_features(monkeypatch): + calls = _spy(monkeypatch, "resolve_features") + _ctx().run_features(experience_keys=[_EXPERIENCE_KEY], type_casting=False) + assert calls + assert list(calls[-1]["experience_keys"]) == [_EXPERIENCE_KEY] + assert calls[-1]["type_casting"] is False