Browse Source

Improve experiment filter ergonomics

pull/10109/head
dolfies 2 years ago
parent
commit
7e5ff57a57
  1. 10
      discord/enums.py
  2. 182
      discord/experiment.py
  3. 28
      discord/types/experiment.py
  4. 38
      docs/api.rst

10
discord/enums.py

@ -1538,16 +1538,6 @@ class ReadStateType(Enum):
onboarding = 4 onboarding = 4
class ExperimentFilterType(Enum):
feature = 1604612045
id_range = 2404720969
member_count_range = 2918402255
ids = 3013771838
hub_type = 4148745523
vanity_url = 188952590
hash_range = 2294888943
def create_unknown_value(cls: Type[E], val: Any) -> E: def create_unknown_value(cls: Type[E], val: Any) -> E:
value_cls = cls._enum_value_cls_ # type: ignore # This is narrowed below value_cls = cls._enum_value_cls_ # type: ignore # This is narrowed below
name = f'unknown_{val}' name = f'unknown_{val}'

182
discord/experiment.py

@ -24,9 +24,8 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Iterator, List, Optional, Sequence, Tuple, Union from typing import TYPE_CHECKING, Dict, Final, Iterator, List, Optional, Sequence, Tuple, Union
from .enums import ExperimentFilterType, try_enum
from .metadata import Metadata from .metadata import Metadata
from .utils import SequenceProxy, SnowflakeList, murmurhash32 from .utils import SequenceProxy, SnowflakeList, murmurhash32
@ -45,7 +44,7 @@ if TYPE_CHECKING:
__all__ = ( __all__ = (
'ExperimentRollout', 'ExperimentRollout',
'ExperimentFilter', 'ExperimentFilters',
'ExperimentPopulation', 'ExperimentPopulation',
'ExperimentOverride', 'ExperimentOverride',
'HoldoutExperiment', 'HoldoutExperiment',
@ -94,8 +93,9 @@ class ExperimentRollout:
return False return False
class ExperimentFilter: class ExperimentFilters:
"""Represents a filter for an experiment population. """Represents a number of filters for an experiment population.
A guild must fulfill all filters to be eligible for the population.
This is a purposefuly very low-level object. This is a purposefuly very low-level object.
@ -111,17 +111,18 @@ class ExperimentFilter:
----------- -----------
population: :class:`ExperimentPopulation` population: :class:`ExperimentPopulation`
The population this filter belongs to. The population this filter belongs to.
type: :class:`ExperimentFilterType`
The type of filter.
options: :class:`Metadata` options: :class:`Metadata`
The parameters for the filter. The parameters for the filter. If known, murmur3-hashed keys are unhashed to their original names.
If known, murmur3-hashed keys are unhashed to their original names.
.. note::
You should query parameters via the properties rather than using this directly.
""" """
__slots__ = ('population', 'type', 'options') __slots__ = ('population', 'options')
# Most of these are taken from the client # Most of these are taken from the client
FILTER_KEYS = { FILTER_KEYS: Final[Dict[int, str]] = {
1604612045: 'guild_has_feature', 1604612045: 'guild_has_feature',
2404720969: 'guild_id_range', 2404720969: 'guild_id_range',
2918402255: 'guild_member_count_range', 2918402255: 'guild_member_count_range',
@ -137,27 +138,33 @@ class ExperimentFilter:
} }
def __init__(self, population: ExperimentPopulation, data: FiltersPayload): def __init__(self, population: ExperimentPopulation, data: FiltersPayload):
type, options = data
self.population = population self.population = population
self.type: ExperimentFilterType = try_enum(ExperimentFilterType, type) self.options: Metadata = self.array_object(data)
def __repr__(self) -> str:
keys = ('features', 'id_range', 'member_count_range', 'ids', 'range_by_hash', 'has_vanity_url')
attrs = [f'{attr}={getattr(self, attr)!r}' for attr in keys if getattr(self, attr) is not None]
if attrs:
return f'<ExperimentFilters {" ".join(attrs)}>'
return '<ExperimentFilters>'
def __contains__(self, guild: Guild, /) -> bool:
return self.is_eligible(guild)
self.options = metadata = Metadata() @classmethod
for key, value in options: def array_object(cls, array: list) -> Metadata:
metadata = Metadata()
for key, value in array:
try: try:
key = self.FILTER_KEYS[int(key)] key = cls.FILTER_KEYS[int(key)]
except (KeyError, ValueError): except (KeyError, ValueError):
pass pass
if isinstance(value, str) and value.isdigit(): if isinstance(value, str) and value.isdigit():
value = int(value) value = int(value)
elif value and isinstance(value, list) and isinstance(value[0], list):
value = cls.array_object(value)
metadata[str(key)] = value metadata[str(key)] = value
return metadata
def __repr__(self) -> str:
return f'<ExperimentFilter type={self.type!r} options={self.options!r}>'
def __contains__(self, guild: Guild, /) -> bool:
return self.is_eligible(guild)
@staticmethod @staticmethod
def in_range(num: int, start: Optional[int], end: Optional[int], /) -> bool: def in_range(num: int, start: Optional[int], end: Optional[int], /) -> bool:
@ -167,6 +174,56 @@ class ExperimentFilter:
return False return False
return True return True
@property
def features(self) -> Optional[List[str]]:
"""Optional[List[:class:`str`]]: The guild features that are eligible for the population."""
features_filter = self.options.guild_has_feature
if features_filter is not None:
return features_filter.guild_features
@property
def id_range(self) -> Optional[Tuple[Optional[int], Optional[int]]]:
"""Optional[Tuple[Optional[:class:`int`], Optional[:class:`int`]]]: The range of guild IDs that are eligible for the population."""
id_range_filter = self.options.guild_id_range
if id_range_filter is not None:
return id_range_filter.min_id, id_range_filter.max_id
@property
def member_count_range(self) -> Optional[Tuple[Optional[int], Optional[int]]]:
"""Optional[Tuple[Optional[:class:`int`], Optional[:class:`int`]]]: The range of guild member counts that are eligible for the population."""
member_count_range_filter = self.options.guild_member_count_range
if member_count_range_filter is not None:
return member_count_range_filter.min_id, member_count_range_filter.max_id
@property
def ids(self) -> Optional[List[int]]:
"""Optional[List[:class:`int`]]: The guild IDs that are eligible for the population."""
ids_filter = self.options.guild_ids
if ids_filter is not None:
return ids_filter.guild_ids
# TODO: Pending hub implementation
# @property
# def hub_types(self) -> Optional[List[HubType]]:
# """Optional[List[:class:`HubType`]]: The hub types that are eligible for the population."""
# hub_types_filter = self.options.guild_hub_types
# if hub_types_filter is not None:
# return [try_enum(HubType, hub_type) for hub_type in hub_types_filter.guild_hub_types]
@property
def range_by_hash(self) -> Optional[Tuple[int, int]]:
"""Optional[Tuple[:class:`int`, :class:`int`]]: The special rollout position limits on the population."""
range_by_hash_filter = self.options.guild_in_range_by_hash
if range_by_hash_filter is not None:
return range_by_hash_filter.hash_key, range_by_hash_filter.target
@property
def has_vanity_url(self) -> Optional[bool]:
"""Optional[:class:`bool`]: Whether a vanity is or is not required to be eligible for the population."""
has_vanity_url_filter = self.options.guild_has_vanity_url
if has_vanity_url_filter is not None:
return has_vanity_url_filter.target
def is_eligible(self, guild: Guild, /) -> bool: def is_eligible(self, guild: Guild, /) -> bool:
"""Checks whether the guild fulfills the filter requirements. """Checks whether the guild fulfills the filter requirements.
@ -184,40 +241,56 @@ class ExperimentFilter:
:class:`bool` :class:`bool`
Whether the guild fulfills the filter requirements. Whether the guild fulfills the filter requirements.
""" """
type = self.type features = self.features
options = self.options if features is not None:
# At least one feature must be present
if type == ExperimentFilterType.feature: if not any(feature in guild.features for feature in features):
# One feature must be present return False
return options.guild_features and any(feature in guild.features for feature in options.guild_features)
elif type == ExperimentFilterType.id_range: id_range = self.id_range
if id_range is not None:
# Guild must be within the range of snowflakes # Guild must be within the range of snowflakes
return self.in_range(guild.id, options.min_id, options.max_id) if not self.in_range(guild.id, *id_range):
elif type == ExperimentFilterType.member_count_range: return False
member_count_range = self.member_count_range
if member_count_range is not None and guild.member_count is not None:
# Guild must be within the range of member counts # Guild must be within the range of member counts
return guild.member_count is not None and self.in_range(guild.member_count, options.min_id, options.max_id) if not self.in_range(guild.member_count, *member_count_range):
elif type == ExperimentFilterType.ids: return False
ids = self.ids
if ids is not None:
# Guild must be in the list of snowflakes, similar to ExperimentOverride # Guild must be in the list of snowflakes, similar to ExperimentOverride
return options.guild_ids is not None and guild.id in options.guild_ids if guild.id not in ids:
elif type == ExperimentFilterType.hub_type: return False
# TODO: Pending hub implementation
# return guild.hub_type and options.guild_hub_types and guild.hub_type.value in options.guild_hub_types # TODO: Pending hub implementation
return False # hub_types = self.hub_types
elif type == ExperimentFilterType.hash_range: # if hub_types is not None:
# Guild must... no idea tbh # # Guild must be in the list of hub types
# Probably for cleanly splitting populations # if not guild.hub_type or guild.hub_type not in hub_types:
result = murmurhash32(f'{options.hash_key}:{guild.id}', signed=False) # return False
range_by_hash = self.range_by_hash
if range_by_hash is not None:
# Guild must fulfill the additional population requirements
hash_key, target = range_by_hash
result = murmurhash32(f'{hash_key}:{guild.id}', signed=False)
if result > 0: if result > 0:
result += result result += result
else: else:
result = (result % 0x100000000) >> 0 result = (result % 0x100000000) >> 0
return options.target and result % 10000 < options.target if target and (result % 10000) >= target:
elif type == ExperimentFilterType.vanity_url: return False
has_vanity_url = self.has_vanity_url
if has_vanity_url is not None:
# Guild must or must not have a vanity URL # Guild must or must not have a vanity URL
return bool(guild.vanity_url_code) == options.guild_has_vanity_url if not bool(guild.vanity_url_code) == has_vanity_url:
else: return False
# TODO: Maybe just return False?
raise NotImplementedError(f'Unknown filter type: {type}') return True
class ExperimentPopulation: class ExperimentPopulation:
@ -235,7 +308,7 @@ class ExperimentPopulation:
----------- -----------
experiment: :class:`GuildExperiment` experiment: :class:`GuildExperiment`
The experiment this population belongs to. The experiment this population belongs to.
filters: List[:class:`ExperimentFilter`] filters: :class:`ExperimentFilters`
The filters that apply to the population. The filters that apply to the population.
rollouts: List[Tuple[:class:`int`, :class:`int`]] rollouts: List[Tuple[:class:`int`, :class:`int`]]
The position-based rollouts of the population. The position-based rollouts of the population.
@ -247,7 +320,7 @@ class ExperimentPopulation:
rollouts, filters = data rollouts, filters = data
self.experiment = experiment self.experiment = experiment
self.filters: List[ExperimentFilter] = [ExperimentFilter(self, x) for x in filters] self.filters: ExperimentFilters = ExperimentFilters(self, filters)
self.rollouts: List[ExperimentRollout] = [ExperimentRollout(self, x) for x in rollouts] self.rollouts: List[ExperimentRollout] = [ExperimentRollout(self, x) for x in rollouts]
def __repr__(self) -> str: def __repr__(self) -> str:
@ -282,9 +355,8 @@ class ExperimentPopulation:
if _result is None: if _result is None:
_result = self.experiment.result_for(guild) _result = self.experiment.result_for(guild)
for filter in self.filters: if not self.filters.is_eligible(guild):
if not filter.is_eligible(guild): return -1
return -1
for rollout in self.rollouts: for rollout in self.rollouts:
for start, end in rollout.ranges: for start, end in rollout.ranges:

28
discord/types/experiment.py

@ -46,24 +46,26 @@ class RolloutData(TypedDict):
Rollout = Tuple[int, List[RolloutData]] Rollout = Tuple[int, List[RolloutData]]
Filters = Union[ Filters = List[
Tuple[Literal[1604612045], Tuple[Tuple[Literal[1183251248], List[str]]]], # FEATURE Union[
Tuple[ Tuple[Literal[1604612045], Tuple[Tuple[Literal[1183251248], List[str]]]], # FEATURE
Literal[2404720969], Tuple[Tuple[Literal[3399957344], Optional[int]], Tuple[Literal[1238858341], int]] Tuple[
], # ID_RANGE Literal[2404720969], Tuple[Tuple[Literal[3399957344], Optional[int]], Tuple[Literal[1238858341], int]]
Tuple[ ], # ID_RANGE
Literal[2918402255], Tuple[Tuple[Literal[3399957344], Optional[int]], Tuple[Literal[1238858341], int]] Tuple[
], # MEMBER_COUNT_RANGE Literal[2918402255], Tuple[Tuple[Literal[3399957344], Optional[int]], Tuple[Literal[1238858341], int]]
Tuple[Literal[3013771838], Tuple[Tuple[Literal[3013771838], List[int]]]], # IDs ], # MEMBER_COUNT_RANGE
Tuple[Literal[4148745523], Tuple[Tuple[Literal[4148745523], List[int]]]], # HUB_TYPE Tuple[Literal[3013771838], Tuple[Tuple[Literal[3013771838], List[int]]]], # IDs
Tuple[Literal[188952590], Tuple[Tuple[Literal[188952590], bool]]], # VANITY_URL Tuple[Literal[4148745523], Tuple[Tuple[Literal[4148745523], List[int]]]], # HUB_TYPE
Tuple[Literal[2294888943], Tuple[Tuple[Literal[2690752156], int], Tuple[Literal[1982804121], int]]], # RANGE_BY_HASH Tuple[Literal[188952590], Tuple[Tuple[Literal[188952590], bool]]], # VANITY_URL
Tuple[Literal[2294888943], Tuple[Tuple[Literal[2690752156], int], Tuple[Literal[1982804121], int]]], # RANGE_BY_HASH
]
] ]
Population = Tuple[ Population = Tuple[
List[Rollout], # rollouts List[Rollout], # rollouts
List[Filters], # filters Filters, # filters
] ]

38
docs/api.rst

@ -5674,40 +5674,6 @@ of :class:`enum.Enum`.
Represents a guild-bound read state for guild onboarding. Only one exists per guild. Represents a guild-bound read state for guild onboarding. Only one exists per guild.
.. class:: ExperimentFilterType
Represents the type of an experiment population filter.
.. versionadded:: 2.1
.. attribute:: feature
The guild must have one of the given features.
.. attribute:: id_range
The guild's ID must be within the given range.
.. attribute:: member_count_range
The guild's member count must be within the given range.
.. attribute:: ids
The guild must be in the given list of IDs.
.. attribute:: hub_type
The guild must be one of the given hub types.
.. attribute:: vanity_url
The guild must or must not have a vanity URL.
.. attribute:: hash_range
The guild's calculated hash must be less than the given value.
.. _discord-api-audit-logs: .. _discord-api-audit-logs:
@ -7803,9 +7769,9 @@ Experiment
.. autoclass:: ExperimentPopulation() .. autoclass:: ExperimentPopulation()
:members: :members:
.. attributetable:: ExperimentFilter .. attributetable:: ExperimentFilters
.. autoclass:: ExperimentFilter() .. autoclass:: ExperimentFilters()
:members: :members:
.. attributetable:: ExperimentRollout .. attributetable:: ExperimentRollout

Loading…
Cancel
Save