Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
468a853
feat(experimentation): results aggregation query and payload builder
gagantrivedi Jun 15, 2026
9467a33
refactor(experimentation): bundle results aggregates and harden decode
gagantrivedi Jun 15, 2026
4694326
fix(experimentation): move post-exposure attribution out of JOIN ON
gagantrivedi Jun 15, 2026
1092621
refactor(experimentation): share the exposures CTE between queries
gagantrivedi Jun 16, 2026
edb3588
docs(experimentation): regenerate events catalogue for shifted line n…
gagantrivedi Jun 16, 2026
965efc8
perf(experimentation): prune pre-window events from the metric join
gagantrivedi Jun 16, 2026
1cdb491
docs(experimentation): trim the shared CTE comment to non-derivable f…
gagantrivedi Jun 16, 2026
d94bd85
feat(experimentation): experiment results model, task and endpoints
gagantrivedi Jun 16, 2026
4255290
refactor(experimentation): share an ExperimentComputation base
gagantrivedi Jun 16, 2026
e62624f
refactor(experimentation): apply design review feedback
gagantrivedi Jun 16, 2026
7db02c3
refactor(experimentation): share the computation refresh lifecycle
gagantrivedi Jun 17, 2026
441cd76
fix(experimentation): skip SRM when a variant allocation is unattribu…
gagantrivedi Jun 17, 2026
a0a6a28
refactor(experimentation): log compute failures from one call site
gagantrivedi Jun 17, 2026
dc6234f
refactor(experimentation): inline the two compute tasks for readability
gagantrivedi Jun 17, 2026
694bf2c
refactor(experimentation): share the refresh guard as a validator
gagantrivedi Jun 17, 2026
d1535b7
feat(experimentation): log when SRM is skipped for an unkeyed variant
gagantrivedi Jun 17, 2026
d344e63
test(experimentation): drop inaccurate 90-day TTL note from refresh test
gagantrivedi Jun 17, 2026
fab4810
feat(experimentation): expose is_final on the computation serializers
gagantrivedi Jun 17, 2026
17e9032
fix(experimentation): skip SRM when variant allocations exceed 100%
gagantrivedi Jun 17, 2026
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
9 changes: 9 additions & 0 deletions api/experimentation/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,12 @@
EXPOSURE_HOURLY_BUCKET_MAX_WINDOW = timedelta(hours=72)

EXPOSURES_REFRESH_MIN_INTERVAL = timedelta(minutes=5)
RESULTS_REFRESH_MIN_INTERVAL = timedelta(minutes=5)

CONTROL_VARIANT_KEY = "control"

# Below these per-variant floors a metric shows "collecting data" rather than
# inference; sample-ratio is only checked once there is enough traffic to judge.
RESULTS_MIN_IDENTITIES_PER_VARIANT = 50
RESULTS_MIN_CONVERSIONS_PER_VARIANT = 5
SRM_MIN_TOTAL_IDENTITIES = 100
33 changes: 33 additions & 0 deletions api/experimentation/dataclasses.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from dataclasses import dataclass
from datetime import datetime

from experimentation.stats import Inference, VariantStats
from experimentation.types import ExposureGranularity


Expand Down Expand Up @@ -34,3 +35,35 @@ class ExposuresTimeseries:
class ExposuresSummary:
excluded_identities: int
timeseries: ExposuresTimeseries


@dataclass(frozen=True)
class MetricSpec:
metric_id: int
event: str
aggregation: str
lower_is_better: bool


@dataclass(frozen=True)
class ResultsAggregates:
"""Sufficient statistics gathered from the warehouse for one experiment:
the specs they were computed from, per-variant identity counts, and per
metric the per-variant ``VariantStats``. Bundled so the keys can't drift."""

specs: list[MetricSpec]
exposure_counts: dict[str, int]
metric_stats: dict[int, dict[str, VariantStats]]


@dataclass(frozen=True)
class MetricResult:
metric_id: int
variants: dict[str, VariantStats]
inference: dict[str, Inference | None]


@dataclass(frozen=True)
class ResultsSummary:
srm_p_value: float | None
metrics: list[MetricResult]
40 changes: 40 additions & 0 deletions api/experimentation/migrations/0008_experiment_results.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Generated by Django 5.2.14 on 2026-06-16 09:25

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("experimentation", "0007_exposures_refresh_requested_at"),
]

operations = [
migrations.CreateModel(
name="ExperimentResults",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("as_of", models.DateTimeField(blank=True, null=True)),
("payload", models.JSONField(blank=True, null=True)),
("last_error_at", models.DateTimeField(blank=True, null=True)),
("refresh_requested_at", models.DateTimeField(blank=True, null=True)),
(
"experiment",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="results",
to="experimentation.experiment",
),
),
],
),
]
49 changes: 39 additions & 10 deletions api/experimentation/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import typing
from dataclasses import asdict
from datetime import datetime
from typing import TYPE_CHECKING, Generic, TypeVar

from django.db import models
from django.db.models import Q
Expand All @@ -14,10 +14,16 @@

from core.models import SoftDeleteExportableModel
from environments.models import Environment
from experimentation.dataclasses import (
ExposuresSummary,
ResultsSummary,
WarehouseEventStats,
)
from experimentation.types import MetricDefinition

if typing.TYPE_CHECKING:
from experimentation.dataclasses import ExposuresSummary, WarehouseEventStats
# A computation's payload is the serialised form of its summary dataclass; the
# concrete subclass binds which one, so record_refresh stays type-safe per panel.
SummaryT = TypeVar("SummaryT", ExposuresSummary, ResultsSummary)


class WarehouseType(models.TextChoices):
Expand Down Expand Up @@ -132,27 +138,34 @@ class Meta:
]


class ExperimentExposures(models.Model):
experiment = models.OneToOneField(
Experiment,
on_delete=models.CASCADE,
related_name="exposures",
)
class ExperimentComputation(models.Model, Generic[SummaryT]):
"""One cached, refreshable warehouse computation per experiment: a single row
updated in place, frozen once ``is_final``. A failed refresh preserves the
last good payload so the UI keeps showing real data with a staleness note."""

as_of = models.DateTimeField(null=True, blank=True)
payload: models.JSONField[dict[str, object] | None, dict[str, object] | None] = (
models.JSONField(null=True, blank=True)
)
last_error_at = models.DateTimeField(null=True, blank=True)
refresh_requested_at = models.DateTimeField(null=True, blank=True)

if TYPE_CHECKING:
# Each concrete subclass defines this as a OneToOneField; declared here
# so is_final can read the experiment without the field assignment.
experiment: "models.OneToOneField[Experiment, Experiment]"

class Meta:
abstract = True

@property
def is_final(self) -> bool:
ended_at = self.experiment.ended_at
return (
ended_at is not None and self.as_of is not None and self.as_of >= ended_at
)

def record_refresh(self, summary: "ExposuresSummary", as_of: datetime) -> None:
def record_refresh(self, summary: SummaryT, as_of: datetime) -> None:
self.payload = asdict(summary)
self.as_of = as_of
self.last_error_at = None
Expand All @@ -167,6 +180,22 @@ def record_refresh_request(self) -> None:
self.save(update_fields=["refresh_requested_at"])


class ExperimentExposures(ExperimentComputation[ExposuresSummary]):
experiment = models.OneToOneField(
Experiment,
on_delete=models.CASCADE,
related_name="exposures",
)


class ExperimentResults(ExperimentComputation[ResultsSummary]):
experiment = models.OneToOneField(
Experiment,
on_delete=models.CASCADE,
related_name="results",
)


class MetricAggregation(models.TextChoices):
COUNT = "count", "Count"
SUM = "sum", "Sum"
Expand Down
25 changes: 24 additions & 1 deletion api/experimentation/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
Experiment,
ExperimentExposures,
ExperimentMetric,
ExperimentResults,
ExperimentStatus,
Metric,
WarehouseConnection,
Expand Down Expand Up @@ -305,6 +306,28 @@ class ExperimentListSerializer(ExperimentSerializer):


class ExperimentExposuresSerializer(serializers.ModelSerializer): # type: ignore[type-arg]
is_final = serializers.BooleanField(read_only=True)

class Meta:
model = ExperimentExposures
fields = ("as_of", "last_error_at", "refresh_requested_at", "payload")
fields = (
"as_of",
"last_error_at",
"refresh_requested_at",
"payload",
"is_final",
)


class ExperimentResultsSerializer(serializers.ModelSerializer): # type: ignore[type-arg]
is_final = serializers.BooleanField(read_only=True)

class Meta:
model = ExperimentResults
fields = (
"as_of",
"last_error_at",
"refresh_requested_at",
"payload",
"is_final",
)
Loading
Loading