Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 52 additions & 4 deletions src/convert_sdk/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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)
Expand All @@ -791,20 +808,34 @@ 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(
self,
*,
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.

Returns one typed result per declared feature the visitor resolves to an
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)
Expand All @@ -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 -----------------------------------------------
Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand Down
58 changes: 54 additions & 4 deletions src/convert_sdk/evaluation/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -97,15 +97,38 @@ 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)))
for key, value in (raw_variables or {}).items()
}


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]] = []
Expand All @@ -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``.

Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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``.

Expand All @@ -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")
Expand All @@ -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)
Expand Down
Loading
Loading