Browse Source
* start on experiments Signed-off-by: Ruairi <[email protected]> * Proper experiment types * Small fixes * add experiments to client Signed-off-by: Ruairi <[email protected]> * format using black Signed-off-by: Ruairi <[email protected]> * correct errors in filters Signed-off-by: Ruairi <[email protected]> * add suggested changes Signed-off-by: Ruairi <[email protected]> * oopsies Signed-off-by: Ruairi <[email protected]> * fix some errors hopefully Signed-off-by: Ruairi <[email protected]> * fix even more type errors Signed-off-by: Ruairi <[email protected]> * moved to utils Signed-off-by: Ruairi <[email protected]> * Fix overloads * Update discord/experiment.py Co-authored-by: dolfies <[email protected]> * Update discord/experiment.py Co-authored-by: dolfies <[email protected]> * add suggested changes Signed-off-by: Ruairi <[email protected]> * fixinator Signed-off-by: Ruairi <[email protected]> * properly cast Signed-off-by: Ruairi <[email protected]> * Update discord/experiment.py Co-authored-by: dolfies <[email protected]> * Update discord/client.py Co-authored-by: dolfies <[email protected]> * Update discord/experiment.py Co-authored-by: dolfies <[email protected]> * Update discord/experiment.py Co-authored-by: dolfies <[email protected]> * Update discord/experiment.py Co-authored-by: dolfies <[email protected]> * Update discord/http.py Co-authored-by: dolfies <[email protected]> * Update discord/experiment.py Co-authored-by: dolfies <[email protected]> * Update discord/utils.py Co-authored-by: dolfies <[email protected]> * fix type error Signed-off-by: Ruairi <[email protected]> * rename murmurhash32 Signed-off-by: Ruairi <[email protected]> * fix!: apply suggestions Signed-off-by: Ruairi <[email protected]> * fix quotes Signed-off-by: Ruairi <[email protected]> * big fixesd * logic fixes and simplification * add mmh3 to optional reqs * reqs my beloved * documentation * Proper filters * Export experiment classes * Fix Metadata object parsing * More low-level method warnings * Fix and improve performance of ExperimentOverride * more positional-only markers * fix small isues Signed-off-by: Ruairi <[email protected]> * fix formatting Signed-off-by: Ruairi <[email protected]> * put mmh3 shit without type checker and shit idk * Remove str(exp) --------- Signed-off-by: Ruairi <[email protected]> Co-authored-by: dolfies <[email protected]>pull/10109/head
committed by
GitHub
13 changed files with 1158 additions and 26 deletions
@ -0,0 +1,730 @@ |
|||||
|
""" |
||||
|
The MIT License (MIT) |
||||
|
|
||||
|
Copyright (c) 2021-present Dolfies |
||||
|
|
||||
|
Permission is hereby granted, free of charge, to any person obtaining a |
||||
|
copy of this software and associated documentation files (the "Software"), |
||||
|
to deal in the Software without restriction, including without limitation |
||||
|
the rights to use, copy, modify, merge, publish, distribute, sublicense, |
||||
|
and/or sell copies of the Software, and to permit persons to whom the |
||||
|
Software is furnished to do so, subject to the following conditions: |
||||
|
|
||||
|
The above copyright notice and this permission notice shall be included in |
||||
|
all copies or substantial portions of the Software. |
||||
|
|
||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS |
||||
|
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING |
||||
|
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER |
||||
|
DEALINGS IN THE SOFTWARE. |
||||
|
""" |
||||
|
|
||||
|
from __future__ import annotations |
||||
|
|
||||
|
from typing import TYPE_CHECKING, Iterator, List, Optional, Sequence, Tuple, Union |
||||
|
|
||||
|
from .enums import ExperimentFilterType, try_enum |
||||
|
from .metadata import Metadata |
||||
|
from .utils import SequenceProxy, SnowflakeList, murmurhash32 |
||||
|
|
||||
|
if TYPE_CHECKING: |
||||
|
from .abc import Snowflake |
||||
|
from .guild import Guild |
||||
|
from .state import ConnectionState |
||||
|
from .types.experiment import ( |
||||
|
Filters as FiltersPayload, |
||||
|
GuildExperiment as GuildExperimentPayload, |
||||
|
Override as OverridePayload, |
||||
|
Population as PopulationPayload, |
||||
|
Rollout as RolloutPayload, |
||||
|
UserExperiment as AssignmentPayload, |
||||
|
) |
||||
|
|
||||
|
__all__ = ( |
||||
|
'ExperimentRollout', |
||||
|
'ExperimentFilter', |
||||
|
'ExperimentPopulation', |
||||
|
'ExperimentOverride', |
||||
|
'HoldoutExperiment', |
||||
|
'GuildExperiment', |
||||
|
'UserExperiment', |
||||
|
) |
||||
|
|
||||
|
|
||||
|
class ExperimentRollout: |
||||
|
"""Represents a rollout for an experiment population. |
||||
|
|
||||
|
.. container:: operations |
||||
|
|
||||
|
.. describe:: x in y |
||||
|
|
||||
|
Checks if a position is eligible for the rollout. |
||||
|
|
||||
|
.. versionadded:: 2.1 |
||||
|
|
||||
|
Attributes |
||||
|
----------- |
||||
|
population: :class:`ExperimentPopulation` |
||||
|
The population this rollout belongs to. |
||||
|
bucket: :class:`int` |
||||
|
The bucket the rollout grants. |
||||
|
ranges: List[Tuple[:class:`int`, :class:`int`]] |
||||
|
The position ranges of the rollout. |
||||
|
""" |
||||
|
|
||||
|
__slots__ = ('population', 'bucket', 'ranges') |
||||
|
|
||||
|
def __init__(self, population: ExperimentPopulation, data: RolloutPayload): |
||||
|
bucket, ranges = data |
||||
|
|
||||
|
self.population = population |
||||
|
self.bucket: int = bucket |
||||
|
self.ranges: List[Tuple[int, int]] = [(range['s'], range['e']) for range in ranges] |
||||
|
|
||||
|
def __repr__(self) -> str: |
||||
|
return f'<ExperimentRollout bucket={self.bucket} ranges={self.ranges!r}>' |
||||
|
|
||||
|
def __contains__(self, item: int, /) -> bool: |
||||
|
for start, end in self.ranges: |
||||
|
if start <= item <= end: |
||||
|
return True |
||||
|
return False |
||||
|
|
||||
|
|
||||
|
class ExperimentFilter: |
||||
|
"""Represents a filter for an experiment population. |
||||
|
|
||||
|
This is a purposefuly very low-level object. |
||||
|
|
||||
|
.. container:: operations |
||||
|
|
||||
|
.. describe:: x in y |
||||
|
|
||||
|
Checks if a guild fulfills the filter requirements. |
||||
|
|
||||
|
.. versionadded:: 2.1 |
||||
|
|
||||
|
Attributes |
||||
|
----------- |
||||
|
population: :class:`ExperimentPopulation` |
||||
|
The population this filter belongs to. |
||||
|
type: :class:`ExperimentFilterType` |
||||
|
The type of filter. |
||||
|
options: :class:`Metadata` |
||||
|
The parameters for the filter. |
||||
|
If known, murmur3-hashed keys are unhashed to their original names. |
||||
|
""" |
||||
|
|
||||
|
__slots__ = ('population', 'type', 'options') |
||||
|
|
||||
|
# Most of these are taken from the client |
||||
|
FILTER_KEYS = { |
||||
|
1604612045: 'guild_has_feature', |
||||
|
2404720969: 'guild_id_range', |
||||
|
2918402255: 'guild_member_count_range', |
||||
|
3013771838: 'guild_ids', |
||||
|
4148745523: 'guild_hub_types', |
||||
|
188952590: 'guild_has_vanity_url', |
||||
|
2294888943: 'guild_in_range_by_hash', |
||||
|
3399957344: 'min_id', |
||||
|
1238858341: 'max_id', |
||||
|
2690752156: 'hash_key', |
||||
|
1982804121: 'target', |
||||
|
1183251248: 'guild_features', |
||||
|
} |
||||
|
|
||||
|
def __init__(self, population: ExperimentPopulation, data: FiltersPayload): |
||||
|
type, options = data |
||||
|
|
||||
|
self.population = population |
||||
|
self.type: ExperimentFilterType = try_enum(ExperimentFilterType, type) |
||||
|
|
||||
|
self.options = metadata = Metadata() |
||||
|
for key, value in options: |
||||
|
try: |
||||
|
key = self.FILTER_KEYS[int(key)] |
||||
|
except (KeyError, ValueError): |
||||
|
pass |
||||
|
if isinstance(value, str) and value.isdigit(): |
||||
|
value = int(value) |
||||
|
|
||||
|
metadata[str(key)] = value |
||||
|
|
||||
|
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 |
||||
|
def in_range(num: int, start: Optional[int], end: Optional[int], /) -> bool: |
||||
|
if start is not None and num < start: |
||||
|
return False |
||||
|
if end is not None and num > end: |
||||
|
return False |
||||
|
return True |
||||
|
|
||||
|
def is_eligible(self, guild: Guild, /) -> bool: |
||||
|
"""Checks whether the guild fulfills the filter requirements. |
||||
|
|
||||
|
.. note:: |
||||
|
|
||||
|
This function is not intended to be used directly. Instead, use :func:`GuildExperiment.bucket_for`. |
||||
|
|
||||
|
Parameters |
||||
|
----------- |
||||
|
guild: :class:`Guild` |
||||
|
The guild to check. |
||||
|
|
||||
|
Returns |
||||
|
-------- |
||||
|
:class:`bool` |
||||
|
Whether the guild fulfills the filter requirements. |
||||
|
""" |
||||
|
type = self.type |
||||
|
options = self.options |
||||
|
|
||||
|
if type == ExperimentFilterType.feature: |
||||
|
# One feature must be present |
||||
|
return options.guild_features and any(feature in guild.features for feature in options.guild_features) |
||||
|
elif type == ExperimentFilterType.id_range: |
||||
|
# Guild must be within the range of snowflakes |
||||
|
return self.in_range(guild.id, options.min_id, options.max_id) |
||||
|
elif type == ExperimentFilterType.member_count_range: |
||||
|
# 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) |
||||
|
elif type == ExperimentFilterType.ids: |
||||
|
# 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 |
||||
|
elif type == ExperimentFilterType.hub_type: |
||||
|
# TODO: Pending hub implementation |
||||
|
# return guild.hub_type and options.guild_hub_types and guild.hub_type.value in options.guild_hub_types |
||||
|
return False |
||||
|
elif type == ExperimentFilterType.hash_range: |
||||
|
# Guild must... no idea tbh |
||||
|
# Probably for cleanly splitting populations |
||||
|
result = murmurhash32(f'{options.hash_key}:{guild.id}', signed=False) |
||||
|
if result > 0: |
||||
|
result += result |
||||
|
else: |
||||
|
result = (result % 0x100000000) >> 0 |
||||
|
return options.target and result % 10000 < options.target |
||||
|
elif type == ExperimentFilterType.vanity_url: |
||||
|
# Guild must or must not have a vanity URL |
||||
|
return bool(guild.vanity_url_code) == options.guild_has_vanity_url |
||||
|
else: |
||||
|
# TODO: Maybe just return False? |
||||
|
raise NotImplementedError(f'Unknown filter type: {type}') |
||||
|
|
||||
|
|
||||
|
class ExperimentPopulation: |
||||
|
"""Represents a population of an experiment. |
||||
|
|
||||
|
.. container:: operations |
||||
|
|
||||
|
.. describe:: x in y |
||||
|
|
||||
|
Checks if a guild is present in the population. |
||||
|
|
||||
|
.. versionadded:: 2.1 |
||||
|
|
||||
|
Attributes |
||||
|
----------- |
||||
|
experiment: :class:`GuildExperiment` |
||||
|
The experiment this population belongs to. |
||||
|
filters: List[:class:`ExperimentFilter`] |
||||
|
The filters that apply to the population. |
||||
|
rollouts: List[Tuple[:class:`int`, :class:`int`]] |
||||
|
The position-based rollouts of the population. |
||||
|
""" |
||||
|
|
||||
|
__slots__ = ('experiment', 'filters', 'rollouts') |
||||
|
|
||||
|
def __init__(self, experiment: GuildExperiment, data: PopulationPayload): |
||||
|
rollouts, filters = data |
||||
|
|
||||
|
self.experiment = experiment |
||||
|
self.filters: List[ExperimentFilter] = [ExperimentFilter(self, x) for x in filters] |
||||
|
self.rollouts: List[ExperimentRollout] = [ExperimentRollout(self, x) for x in rollouts] |
||||
|
|
||||
|
def __repr__(self) -> str: |
||||
|
return f'<ExperimentPopulation experiment={self.experiment!r} filters={self.filters!r} rollouts={self.rollouts!r}>' |
||||
|
|
||||
|
def __contains__(self, item: Guild, /) -> bool: |
||||
|
return self.bucket_for(item) != -1 |
||||
|
|
||||
|
def bucket_for(self, guild: Guild, _result: Optional[int] = None, /) -> int: |
||||
|
"""Returns the assigned experiment bucket within a population for a guild. |
||||
|
Defaults to none (-1) if the guild is not in the population. |
||||
|
|
||||
|
.. note:: |
||||
|
|
||||
|
This function is not intended to be used directly. Instead, use :func:`GuildExperiment.bucket_for`. |
||||
|
|
||||
|
Parameters |
||||
|
----------- |
||||
|
guild: :class:`Guild` |
||||
|
The guild to compute experiment eligibility for. |
||||
|
|
||||
|
Raises |
||||
|
------ |
||||
|
:exc:`ValueError` |
||||
|
The experiment name is unset. |
||||
|
|
||||
|
Returns |
||||
|
------- |
||||
|
:class:`int` |
||||
|
The experiment bucket. |
||||
|
""" |
||||
|
if _result is None: |
||||
|
_result = self.experiment.result_for(guild) |
||||
|
|
||||
|
for filter in self.filters: |
||||
|
if not filter.is_eligible(guild): |
||||
|
return -1 |
||||
|
|
||||
|
for rollout in self.rollouts: |
||||
|
for start, end in rollout.ranges: |
||||
|
if start <= _result <= end: |
||||
|
return rollout.bucket |
||||
|
|
||||
|
return -1 |
||||
|
|
||||
|
|
||||
|
class ExperimentOverride: |
||||
|
"""Represents an experiment override. |
||||
|
|
||||
|
.. container:: operations |
||||
|
|
||||
|
.. describe:: len(x) |
||||
|
|
||||
|
Returns the number of resources eligible for the override. |
||||
|
|
||||
|
.. describe:: x in y |
||||
|
|
||||
|
Checks if a resource is eligible for the override. |
||||
|
|
||||
|
.. describe:: iter(x) |
||||
|
|
||||
|
Returns an iterator of the resources eligible for the override. |
||||
|
|
||||
|
.. versionadded:: 2.1 |
||||
|
|
||||
|
Attributes |
||||
|
----------- |
||||
|
experiment: :class:`GuildExperiment` |
||||
|
The experiment this override belongs to. |
||||
|
bucket: :class:`int` |
||||
|
The bucket the override applies. |
||||
|
""" |
||||
|
|
||||
|
__slots__ = ('experiment', 'bucket', '_ids') |
||||
|
|
||||
|
def __init__(self, experiment: GuildExperiment, data: OverridePayload): |
||||
|
self.experiment = experiment |
||||
|
self.bucket: int = data['b'] |
||||
|
self._ids: SnowflakeList = SnowflakeList(map(int, data['k'])) |
||||
|
|
||||
|
def __repr__(self) -> str: |
||||
|
return f'<ExperimentOverride bucket={self.bucket} ids={self.ids!r}>' |
||||
|
|
||||
|
def __len__(self) -> int: |
||||
|
return len(self._ids) |
||||
|
|
||||
|
def __contains__(self, item: Union[int, Snowflake], /) -> bool: |
||||
|
return getattr(item, 'id', item) in self._ids |
||||
|
|
||||
|
def __iter__(self) -> Iterator[int]: |
||||
|
return iter(self._ids) |
||||
|
|
||||
|
@property |
||||
|
def ids(self) -> Sequence[int]: |
||||
|
"""Sequence[:class:`int`]: The eligible guild/user IDs for the override.""" |
||||
|
return SequenceProxy(self._ids) |
||||
|
|
||||
|
|
||||
|
class HoldoutExperiment: |
||||
|
"""Represents an experiment dependency. |
||||
|
|
||||
|
.. container:: operations |
||||
|
|
||||
|
.. describe:: x in y |
||||
|
|
||||
|
Checks if a guild fulfills the dependency. |
||||
|
|
||||
|
.. versionadded:: 2.1 |
||||
|
|
||||
|
Attributes |
||||
|
----------- |
||||
|
dependent: :class:`GuildExperiment` |
||||
|
The experiment that depends on this experiment. |
||||
|
name: :class:`str` |
||||
|
The name of the dependency. |
||||
|
bucket: :class:`int` |
||||
|
The required bucket of the dependency. |
||||
|
""" |
||||
|
|
||||
|
__slots__ = ('dependent', 'name', 'bucket') |
||||
|
|
||||
|
def __init__(self, dependent: GuildExperiment, name: str, bucket: int): |
||||
|
self.dependent = dependent |
||||
|
self.name: str = name |
||||
|
self.bucket: int = bucket |
||||
|
|
||||
|
def __repr__(self) -> str: |
||||
|
return f'<HoldoutExperiment dependent={self.dependent!r} name={self.name!r} bucket={self.bucket}>' |
||||
|
|
||||
|
def __contains__(self, item: Guild) -> bool: |
||||
|
return self.is_eligible(item) |
||||
|
|
||||
|
@property |
||||
|
def experiment(self) -> Optional[GuildExperiment]: |
||||
|
"""Optional[:class:`GuildExperiment`]: The experiment dependency, if found.""" |
||||
|
experiment_hash = murmurhash32(self.name, signed=False) |
||||
|
experiment = self.dependent._state.guild_experiments.get(experiment_hash) |
||||
|
if experiment and not experiment.name: |
||||
|
# Backfill the name |
||||
|
experiment._name = self.name |
||||
|
return experiment |
||||
|
|
||||
|
def is_eligible(self, guild: Guild, /) -> bool: |
||||
|
"""Checks whether the guild fulfills the dependency. |
||||
|
|
||||
|
.. note:: |
||||
|
|
||||
|
This function is not intended to be used directly. Instead, use :func:`GuildExperiment.bucket_for`. |
||||
|
|
||||
|
Parameters |
||||
|
----------- |
||||
|
guild: :class:`Guild` |
||||
|
The guild to check. |
||||
|
|
||||
|
Returns |
||||
|
-------- |
||||
|
:class:`bool` |
||||
|
Whether the guild fulfills the dependency. |
||||
|
""" |
||||
|
experiment = self.experiment |
||||
|
if experiment is None: |
||||
|
# We don't have the experiment, so we can't check |
||||
|
return True |
||||
|
|
||||
|
return experiment.bucket_for(guild) == self.bucket |
||||
|
|
||||
|
|
||||
|
class GuildExperiment: |
||||
|
"""Represents a guild experiment rollout. |
||||
|
|
||||
|
.. container:: operations |
||||
|
|
||||
|
.. describe:: x == y |
||||
|
|
||||
|
Checks if two experiments are equal. |
||||
|
|
||||
|
.. describe:: x != y |
||||
|
|
||||
|
Checks if two experiments are not equal. |
||||
|
|
||||
|
.. describe:: hash(x) |
||||
|
|
||||
|
Returns the experiment's hash. |
||||
|
|
||||
|
.. versionadded:: 2.1 |
||||
|
|
||||
|
Attributes |
||||
|
----------- |
||||
|
hash: :class:`int` |
||||
|
The 32-bit unsigned Murmur3 hash of the experiment's name. |
||||
|
revision: :class:`int` |
||||
|
The current revision of the experiment rollout. |
||||
|
populations: List[:class:`ExperimentPopulation`] |
||||
|
The rollout populations of the experiment. |
||||
|
overrides: List[:class:`ExperimentOverride`] |
||||
|
The explicit bucket overrides of the experiment. |
||||
|
overrides_formatted: List[List[:class:`ExperimentPopulation`]] |
||||
|
Additional rollout populations for the experiment. |
||||
|
holdout: Optional[:class:`HoldoutExperiment`] |
||||
|
The experiment this experiment depends on, if any. |
||||
|
aa_mode: :class:`bool` |
||||
|
Whether the experiment is in A/A mode. |
||||
|
""" |
||||
|
|
||||
|
__slots__ = ( |
||||
|
'_state', |
||||
|
'hash', |
||||
|
'_name', |
||||
|
'revision', |
||||
|
'populations', |
||||
|
'overrides', |
||||
|
'overrides_formatted', |
||||
|
'holdout', |
||||
|
'aa_mode', |
||||
|
) |
||||
|
|
||||
|
def __init__(self, *, state: ConnectionState, data: GuildExperimentPayload): |
||||
|
( |
||||
|
hash, |
||||
|
hash_key, |
||||
|
revision, |
||||
|
populations, |
||||
|
overrides, |
||||
|
overrides_formatted, |
||||
|
holdout_name, |
||||
|
holdout_bucket, |
||||
|
aa_mode, |
||||
|
) = data |
||||
|
|
||||
|
self._state = state |
||||
|
self.hash: int = hash |
||||
|
self._name: Optional[str] = hash_key |
||||
|
self.revision: int = revision |
||||
|
self.populations: List[ExperimentPopulation] = [ExperimentPopulation(self, x) for x in populations] |
||||
|
self.overrides: List[ExperimentOverride] = [ExperimentOverride(self, x) for x in overrides] |
||||
|
self.overrides_formatted: List[List[ExperimentPopulation]] = [ |
||||
|
[ExperimentPopulation(self, y) for y in x] for x in overrides_formatted |
||||
|
] |
||||
|
self.holdout: Optional[HoldoutExperiment] = ( |
||||
|
HoldoutExperiment(self, holdout_name, holdout_bucket) |
||||
|
if holdout_name is not None and holdout_bucket is not None |
||||
|
else None |
||||
|
) |
||||
|
self.aa_mode: bool = aa_mode == 1 |
||||
|
|
||||
|
def __repr__(self) -> str: |
||||
|
return f'<GuildExperiment hash={self.hash}{f" name={self._name!r}" if self._name else ""}>' |
||||
|
|
||||
|
def __hash__(self) -> int: |
||||
|
return self.hash |
||||
|
|
||||
|
def __eq__(self, other: object, /) -> bool: |
||||
|
if isinstance(other, GuildExperiment): |
||||
|
return self.hash == other.hash |
||||
|
return NotImplemented |
||||
|
|
||||
|
@property |
||||
|
def name(self) -> Optional[str]: |
||||
|
"""Optional[:class:`str`]: The unique name of the experiment. |
||||
|
|
||||
|
This data is not always available via the API, and must be set manually for using related functions. |
||||
|
""" |
||||
|
return self._name |
||||
|
|
||||
|
@name.setter |
||||
|
def name(self, value: Optional[str], /) -> None: |
||||
|
if not value: |
||||
|
self._name = None |
||||
|
elif murmurhash32(value, signed=False) != self.hash: |
||||
|
raise ValueError('The name provided does not match the experiment hash') |
||||
|
else: |
||||
|
self._name = value |
||||
|
|
||||
|
def result_for(self, guild: Snowflake, /) -> int: |
||||
|
"""Returns the calulated position of the guild within the experiment (0-9999). |
||||
|
|
||||
|
Parameters |
||||
|
----------- |
||||
|
guild: :class:`abc.Snowflake` |
||||
|
The guild to compute the position for. |
||||
|
|
||||
|
Raises |
||||
|
------ |
||||
|
:exc:`ValueError` |
||||
|
The experiment name is unset. |
||||
|
|
||||
|
Returns |
||||
|
------- |
||||
|
:class:`int` |
||||
|
The position of the guild within the experiment. |
||||
|
""" |
||||
|
if not self.name: |
||||
|
raise ValueError('The experiment name must be set to compute the result') |
||||
|
|
||||
|
return murmurhash32(f'{self.name}:{guild.id}', signed=False) % 10000 |
||||
|
|
||||
|
def bucket_for(self, guild: Guild, /) -> int: |
||||
|
"""Returns the assigned experiment bucket for a guild. |
||||
|
Defaults to none (-1) if the guild is not in the experiment. |
||||
|
|
||||
|
Parameters |
||||
|
----------- |
||||
|
guild: :class:`Guild` |
||||
|
The guild to compute experiment eligibility for. |
||||
|
|
||||
|
Raises |
||||
|
------ |
||||
|
:exc:`ValueError` |
||||
|
The experiment name is unset. |
||||
|
|
||||
|
Returns |
||||
|
------- |
||||
|
:class:`int` |
||||
|
The experiment bucket. |
||||
|
""" |
||||
|
# a/a mode is always -1 |
||||
|
if self.aa_mode: |
||||
|
return -1 |
||||
|
|
||||
|
# Holdout must be fulfilled |
||||
|
if self.holdout and not self.holdout.is_eligible(guild): |
||||
|
return -1 |
||||
|
|
||||
|
hash_result = self.result_for(guild) |
||||
|
|
||||
|
# Overrides take precedence |
||||
|
# And yes, they can be assigned to a user ID |
||||
|
for override in self.overrides: |
||||
|
if guild.id in override.ids or guild.owner_id in override.ids: |
||||
|
return override.bucket |
||||
|
|
||||
|
for overrides in self.overrides_formatted: |
||||
|
for override in overrides: |
||||
|
pop_bucket = override.bucket_for(guild, hash_result) |
||||
|
if pop_bucket != -1: |
||||
|
return pop_bucket |
||||
|
|
||||
|
for population in self.populations: |
||||
|
pop_bucket = population.bucket_for(guild, hash_result) |
||||
|
if pop_bucket != -1: |
||||
|
return pop_bucket |
||||
|
|
||||
|
return -1 |
||||
|
|
||||
|
def guilds_for(self, bucket: int, /) -> List[Guild]: |
||||
|
"""Returns a list of guilds assigned to a specific bucket. |
||||
|
|
||||
|
Parameters |
||||
|
----------- |
||||
|
bucket: :class:`int` |
||||
|
The bucket to get guilds for. |
||||
|
|
||||
|
Raises |
||||
|
------ |
||||
|
:exc:`ValueError` |
||||
|
The experiment name is unset. |
||||
|
|
||||
|
Returns |
||||
|
------- |
||||
|
List[:class:`Guild`] |
||||
|
The guilds assigned to the bucket. |
||||
|
""" |
||||
|
return [x for x in self._state.guilds if self.bucket_for(x) == bucket] |
||||
|
|
||||
|
|
||||
|
class UserExperiment: |
||||
|
"""Represents a user's experiment assignment. |
||||
|
|
||||
|
.. container:: operations |
||||
|
|
||||
|
.. describe:: x == y |
||||
|
|
||||
|
Checks if two experiments are equal. |
||||
|
|
||||
|
.. describe:: x != y |
||||
|
|
||||
|
Checks if two experiments are not equal. |
||||
|
|
||||
|
.. describe:: hash(x) |
||||
|
|
||||
|
Returns the experiment's hash. |
||||
|
|
||||
|
.. versionadded:: 2.1 |
||||
|
|
||||
|
.. note:: |
||||
|
|
||||
|
In contrast to the wide range of data provided for guild experiments, |
||||
|
user experiments do not reveal detailed rollout information, providing only the assigned bucket. |
||||
|
|
||||
|
Attributes |
||||
|
---------- |
||||
|
hash: :class:`int` |
||||
|
The 32-bit unsigned Murmur3 hash of the experiment's name. |
||||
|
revision: :class:`int` |
||||
|
The current revision of the experiment rollout. |
||||
|
assignment: :class:`int` |
||||
|
The assigned bucket for the user. |
||||
|
override: :class:`int` |
||||
|
The overriden bucket for the user, takes precedence over :attr:`assignment`. |
||||
|
population: :class:`int` |
||||
|
The internal population group for the user. |
||||
|
aa_mode: :class:`bool` |
||||
|
Whether the experiment is in A/A mode. |
||||
|
""" |
||||
|
|
||||
|
__slots__ = ( |
||||
|
'_state', |
||||
|
'_name', |
||||
|
'hash', |
||||
|
'revision', |
||||
|
'assignment', |
||||
|
'override', |
||||
|
'population', |
||||
|
'_result', |
||||
|
'aa_mode', |
||||
|
) |
||||
|
|
||||
|
def __init__(self, *, state: ConnectionState, data: AssignmentPayload): |
||||
|
(hash, revision, bucket, override, population, hash_result, aa_mode) = data |
||||
|
|
||||
|
self._state = state |
||||
|
self._name: Optional[str] = None |
||||
|
self.hash: int = hash |
||||
|
self.revision: int = revision |
||||
|
self.assignment: int = bucket |
||||
|
self.override: int = override |
||||
|
self.population: int = population |
||||
|
self._result: int = hash_result |
||||
|
self.aa_mode: bool = True if aa_mode == 1 else False |
||||
|
|
||||
|
def __repr__(self) -> str: |
||||
|
return f'<UserExperiment hash={self.hash}{f" name={self._name!r}" if self._name else ""} bucket={self.bucket}>' |
||||
|
|
||||
|
def __hash__(self) -> int: |
||||
|
return self.hash |
||||
|
|
||||
|
def __eq__(self, other: object, /) -> bool: |
||||
|
if isinstance(other, UserExperiment): |
||||
|
return self.hash == other.hash |
||||
|
return NotImplemented |
||||
|
|
||||
|
@property |
||||
|
def name(self) -> Optional[str]: |
||||
|
"""Optional[:class:`str`]: The unique name of the experiment. |
||||
|
|
||||
|
This data is not always available via the API, and must be set manually for using related functions. |
||||
|
""" |
||||
|
return self._name |
||||
|
|
||||
|
@name.setter |
||||
|
def name(self, value: Optional[str], /) -> None: |
||||
|
if not value: |
||||
|
self._name = None |
||||
|
elif murmurhash32(value, signed=False) != self.hash: |
||||
|
raise ValueError('The name provided does not match the experiment hash') |
||||
|
else: |
||||
|
self._name = value |
||||
|
|
||||
|
@property |
||||
|
def bucket(self) -> int: |
||||
|
""":class:`int`: The assigned bucket for the user.""" |
||||
|
if self.aa_mode: |
||||
|
return -1 |
||||
|
return self.override if self.override != -1 else self.population |
||||
|
|
||||
|
@property |
||||
|
def result(self) -> int: |
||||
|
""":class:`int`: The calulated position of the user within the experiment (0-9999). |
||||
|
|
||||
|
Raises |
||||
|
------ |
||||
|
:exc:`ValueError` |
||||
|
The experiment name is unset without a precomputed result. |
||||
|
""" |
||||
|
if self._result: |
||||
|
return self._result |
||||
|
elif not self.name: |
||||
|
raise ValueError('The experiment name must be set to compute the result') |
||||
|
else: |
||||
|
return murmurhash32(f'{self.name}:{self._state.self_id}', signed=False) % 10000 |
@ -0,0 +1,102 @@ |
|||||
|
""" |
||||
|
The MIT License (MIT) |
||||
|
|
||||
|
Copyright (c) 2021-present Dolfies |
||||
|
|
||||
|
Permission is hereby granted, free of charge, to any person obtaining a |
||||
|
copy of this software and associated documentation files (the "Software"), |
||||
|
to deal in the Software without restriction, including without limitation |
||||
|
the rights to use, copy, modify, merge, publish, distribute, sublicense, |
||||
|
and/or sell copies of the Software, and to permit persons to whom the |
||||
|
Software is furnished to do so, subject to the following conditions: |
||||
|
|
||||
|
The above copyright notice and this permission notice shall be included in |
||||
|
all copies or substantial portions of the Software. |
||||
|
|
||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS |
||||
|
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING |
||||
|
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER |
||||
|
DEALINGS IN THE SOFTWARE. |
||||
|
""" |
||||
|
|
||||
|
from __future__ import annotations |
||||
|
|
||||
|
from typing import List, Literal, Optional, Tuple, TypedDict, Union |
||||
|
|
||||
|
from typing_extensions import NotRequired |
||||
|
|
||||
|
|
||||
|
class ExperimentResponse(TypedDict): |
||||
|
fingerprint: NotRequired[str] |
||||
|
assignments: List[UserExperiment] |
||||
|
|
||||
|
|
||||
|
class ExperimentResponseWithGuild(ExperimentResponse): |
||||
|
guild_experiments: NotRequired[List[GuildExperiment]] |
||||
|
|
||||
|
|
||||
|
class RolloutData(TypedDict): |
||||
|
s: int |
||||
|
e: int |
||||
|
|
||||
|
|
||||
|
Rollout = Tuple[int, List[RolloutData]] |
||||
|
|
||||
|
|
||||
|
Filters = Union[ |
||||
|
Tuple[Literal[1604612045], Tuple[Tuple[Literal[1183251248], List[str]]]], # FEATURE |
||||
|
Tuple[ |
||||
|
Literal[2404720969], Tuple[Tuple[Literal[3399957344], Optional[int]], Tuple[Literal[1238858341], int]] |
||||
|
], # ID_RANGE |
||||
|
Tuple[ |
||||
|
Literal[2918402255], Tuple[Tuple[Literal[3399957344], Optional[int]], Tuple[Literal[1238858341], int]] |
||||
|
], # MEMBER_COUNT_RANGE |
||||
|
Tuple[Literal[3013771838], Tuple[Tuple[Literal[3013771838], List[int]]]], # IDs |
||||
|
Tuple[Literal[4148745523], Tuple[Tuple[Literal[4148745523], List[int]]]], # HUB_TYPE |
||||
|
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[ |
||||
|
List[Rollout], # rollouts |
||||
|
List[Filters], # filters |
||||
|
] |
||||
|
|
||||
|
|
||||
|
class Override(TypedDict): |
||||
|
b: int |
||||
|
k: List[int] |
||||
|
|
||||
|
|
||||
|
Holdout = Tuple[ |
||||
|
int, # bucket |
||||
|
str, # experiment_name |
||||
|
] |
||||
|
|
||||
|
|
||||
|
UserExperiment = Tuple[ |
||||
|
int, # hash |
||||
|
int, # revision |
||||
|
int, # bucket |
||||
|
int, # override |
||||
|
int, # population |
||||
|
int, # hash_result |
||||
|
Literal[0, 1], # aa_mode |
||||
|
] |
||||
|
|
||||
|
|
||||
|
GuildExperiment = Tuple[ |
||||
|
int, # hash |
||||
|
Optional[str], # hash_key |
||||
|
int, # revision |
||||
|
List[Population], # populations |
||||
|
List[Override], # overrides |
||||
|
List[List[Population]], # overrides_formatted |
||||
|
Optional[str], # holdout_name |
||||
|
Optional[int], # holdout_bucket |
||||
|
Literal[0, 1], # aa_mode |
||||
|
] |
Loading…
Reference in new issue