first commit
This commit is contained in:
93
backend/app/domain/runtime.py
Normal file
93
backend/app/domain/runtime.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from hashlib import sha256
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RuntimeVariantCandidate:
|
||||
id: str
|
||||
variant_key: str
|
||||
traffic_weight: int
|
||||
content_config: dict[str, Any] | list[Any] | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AssignmentDecision:
|
||||
experiment_id: str
|
||||
experiment_key: str
|
||||
variant_id: str
|
||||
variant_key: str
|
||||
bucket: int
|
||||
reason: str = "weighted_assignment"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RuntimeExperimentContext:
|
||||
experiment_id: str
|
||||
experiment_key: str
|
||||
status: str
|
||||
site_key: str | None = None
|
||||
assignment_salt: str | None = None
|
||||
release_id: str | None = None
|
||||
release_version: int | None = None
|
||||
payload: dict[str, Any] | list[Any] | None = None
|
||||
variants: list[RuntimeVariantCandidate] = field(default_factory=list)
|
||||
|
||||
|
||||
def build_bucket(seed: str) -> int:
|
||||
"""Create a stable 0-9999 bucket from a visitor+experiment seed."""
|
||||
|
||||
digest = sha256(seed.encode("utf-8")).hexdigest()
|
||||
return int(digest[:8], 16) % 10000
|
||||
|
||||
|
||||
def choose_variant(
|
||||
*,
|
||||
experiment_id: str,
|
||||
experiment_key: str,
|
||||
visitor_id: str,
|
||||
site_key: str | None = None,
|
||||
assignment_salt: str | None = None,
|
||||
variants: list[RuntimeVariantCandidate],
|
||||
) -> AssignmentDecision | None:
|
||||
"""Deterministically assign a visitor based on weighted variants.
|
||||
|
||||
This first version intentionally lives in pure Python with no storage
|
||||
dependency so we can review the bucketing rule before wiring persistence.
|
||||
"""
|
||||
|
||||
active_variants = [variant for variant in variants if variant.traffic_weight > 0]
|
||||
if not active_variants:
|
||||
return None
|
||||
|
||||
total_weight = sum(variant.traffic_weight for variant in active_variants)
|
||||
if total_weight <= 0:
|
||||
return None
|
||||
|
||||
seed = f"{site_key or 'global'}:{assignment_salt or experiment_key}:{visitor_id}:{experiment_key}"
|
||||
bucket = build_bucket(seed)
|
||||
scaled_bucket = bucket % total_weight
|
||||
|
||||
running_total = 0
|
||||
for variant in active_variants:
|
||||
running_total += variant.traffic_weight
|
||||
if scaled_bucket < running_total:
|
||||
return AssignmentDecision(
|
||||
experiment_id=experiment_id,
|
||||
experiment_key=experiment_key,
|
||||
variant_id=variant.id,
|
||||
variant_key=variant.variant_key,
|
||||
bucket=bucket,
|
||||
)
|
||||
|
||||
chosen = active_variants[-1]
|
||||
return AssignmentDecision(
|
||||
experiment_id=experiment_id,
|
||||
experiment_key=experiment_key,
|
||||
variant_id=chosen.id,
|
||||
variant_key=chosen.variant_key,
|
||||
bucket=bucket,
|
||||
reason="weighted_assignment_fallback",
|
||||
)
|
||||
Reference in New Issue
Block a user