trucking: support full-price control arm in coupon A/B (pct 0 = no code)
CAMPAIGN_COUPON_AB_PCTS="20,30,0" now means 20% / 30% / full-price. The 0 arm
mints no code; pick_coupon_for_email returns ("","") so it renders identically
to a normal-price send, while carriers are still deterministically hash-bucketed
into it (re-hash a converter's email to recover their arm). Even ~33/33/33 split
incl. the control verified over 30k. Adds test_full_price_control_arm; 8/8 pass.
This commit is contained in:
parent
2f0753f00e
commit
297db74fee
2 changed files with 58 additions and 10 deletions
|
|
@ -275,9 +275,12 @@ COUPON_PCT = int(os.getenv("CAMPAIGN_COUPON_PCT", "40"))
|
||||||
# arm, getting that arm's own daily code. Because each code stores its own
|
# arm, getting that arm's own daily code. Because each code stores its own
|
||||||
# percent in discount_codes, the discount the email advertises always matches the
|
# percent in discount_codes, the discount the email advertises always matches the
|
||||||
# discount checkout actually applies, and redemptions are measurable per code
|
# discount checkout actually applies, and redemptions are measurable per code
|
||||||
# (description marker campaign-daily:<date>:<pct>). Empty/unset = single-arm test
|
# (description marker campaign-daily:<date>:<pct>). A percent of 0 is a valid
|
||||||
# at COUPON_PCT (legacy behavior). The split is even and stable per carrier, so a
|
# FULL-PRICE control arm (e.g. "20,30,0"): no code is minted and the carrier sees
|
||||||
# given carrier always sees the same percent across re-sends (no arm-hopping).
|
# the normal price, but they're still hash-bucketed so the control is measurable.
|
||||||
|
# Empty/unset = single-arm test at COUPON_PCT (legacy behavior). The split is even
|
||||||
|
# and stable per carrier, so a given carrier always sees the same arm across
|
||||||
|
# re-sends (no arm-hopping).
|
||||||
COUPON_AB_PCTS = tuple(
|
COUPON_AB_PCTS = tuple(
|
||||||
int(p.strip())
|
int(p.strip())
|
||||||
for p in os.getenv("CAMPAIGN_COUPON_AB_PCTS", "").split(",")
|
for p in os.getenv("CAMPAIGN_COUPON_AB_PCTS", "").split(",")
|
||||||
|
|
@ -445,28 +448,43 @@ def get_or_create_daily_coupons(conn, send_date: date) -> dict[int, str]:
|
||||||
Returns a mapping of percent -> code. With no A/B test configured this is a
|
Returns a mapping of percent -> code. With no A/B test configured this is a
|
||||||
single arm {COUPON_PCT: code}; with CAMPAIGN_COUPON_AB_PCTS="20,30,40" it
|
single arm {COUPON_PCT: code}; with CAMPAIGN_COUPON_AB_PCTS="20,30,40" it
|
||||||
returns one code per percent so recipients can be split across arms.
|
returns one code per percent so recipients can be split across arms.
|
||||||
|
|
||||||
|
A percent of 0 is a valid FULL-PRICE control arm: no code is minted (the map
|
||||||
|
value is ""), so carriers bucketed into it see the normal price and no coupon,
|
||||||
|
while still being deterministically assigned (and thus measurable) by email
|
||||||
|
hash. Example: CAMPAIGN_COUPON_AB_PCTS="20,30,0".
|
||||||
"""
|
"""
|
||||||
pcts = list(COUPON_AB_PCTS) if COUPON_AB_PCTS else [COUPON_PCT]
|
pcts = list(COUPON_AB_PCTS) if COUPON_AB_PCTS else [COUPON_PCT]
|
||||||
return {pct: get_or_create_daily_coupon(conn, send_date, pct) for pct in pcts}
|
out: dict[int, str] = {}
|
||||||
|
for pct in pcts:
|
||||||
|
out[pct] = "" if pct <= 0 else get_or_create_daily_coupon(conn, send_date, pct)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def pick_coupon_for_email(email: str, daily_coupons: dict[int, str] | None) -> tuple[str, str]:
|
def pick_coupon_for_email(email: str, daily_coupons: dict[int, str] | None) -> tuple[str, str]:
|
||||||
"""Deterministically assign a carrier to one coupon arm by a stable hash of
|
"""Deterministically assign a carrier to one coupon arm by a stable hash of
|
||||||
their email. Returns (code, pct_str); ("", "") when coupons are off.
|
their email. Returns (code, pct_str); ("", "") when coupons are off OR when
|
||||||
|
the carrier is bucketed into a full-price control arm (pct 0, code "").
|
||||||
|
|
||||||
The hash makes the split even and *stable*: the same carrier always lands in
|
The hash makes the split even and *stable*: the same carrier always lands in
|
||||||
the same arm across re-sends, so an A/B comparison isn't polluted by a carrier
|
the same arm across re-sends, so an A/B comparison isn't polluted by a carrier
|
||||||
seeing 20% one day and 40% the next.
|
seeing 20% one day and 40% the next. A full-price (0%) arm is returned as
|
||||||
|
("", "") so the email renders the normal-price branch, identical to a no-coupon
|
||||||
|
send — but the carrier is still deterministically in that arm, so re-hashing
|
||||||
|
a converter's email recovers which arm they were in for attribution.
|
||||||
"""
|
"""
|
||||||
if not daily_coupons:
|
if not daily_coupons:
|
||||||
return "", ""
|
return "", ""
|
||||||
pcts = sorted(daily_coupons.keys())
|
pcts = sorted(daily_coupons.keys())
|
||||||
if len(pcts) == 1:
|
if len(pcts) == 1:
|
||||||
pct = pcts[0]
|
pct = pcts[0]
|
||||||
return daily_coupons[pct], str(pct)
|
else:
|
||||||
h = hashlib.sha256((email or "").strip().lower().encode()).hexdigest()
|
h = hashlib.sha256((email or "").strip().lower().encode()).hexdigest()
|
||||||
pct = pcts[int(h, 16) % len(pcts)]
|
pct = pcts[int(h, 16) % len(pcts)]
|
||||||
return daily_coupons[pct], str(pct)
|
code = daily_coupons[pct]
|
||||||
|
if not code: # full-price control arm: no code, no deal
|
||||||
|
return "", ""
|
||||||
|
return code, str(pct)
|
||||||
|
|
||||||
|
|
||||||
def coupon_attribs(coupon_code: str | None, coupon_pct: str | None = None) -> dict:
|
def coupon_attribs(coupon_code: str | None, coupon_pct: str | None = None) -> dict:
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,36 @@ def test_single_arm():
|
||||||
assert btc.pick_coupon_for_email("a@b.com", {40: "ZZZZZ"}) == ("ZZZZZ", "40")
|
assert btc.pick_coupon_for_email("a@b.com", {40: "ZZZZZ"}) == ("ZZZZZ", "40")
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_price_control_arm():
|
||||||
|
"""A 0% arm (code "") is a full-price control: returned as ("", "") so the
|
||||||
|
email renders the normal-price branch, but carriers are still hash-bucketed
|
||||||
|
evenly across all three arms (so the control is measurable)."""
|
||||||
|
coupons = {20: "AAAAA", 30: "BBBBB", 0: ""} # 0 = full-price control
|
||||||
|
counts: Counter = Counter()
|
||||||
|
arm_for: dict = {}
|
||||||
|
for i in range(30000):
|
||||||
|
e = f"user{i}@carrier{i % 500}.com"
|
||||||
|
code, pct = btc.pick_coupon_for_email(e, coupons)
|
||||||
|
# The control arm presents identically to a no-coupon send.
|
||||||
|
if code == "":
|
||||||
|
assert pct == ""
|
||||||
|
else:
|
||||||
|
assert code in ("AAAAA", "BBBBB") and pct in ("20", "30")
|
||||||
|
# Bucket label: recover arm even for the silent control via re-hash.
|
||||||
|
h = __import__("hashlib").sha256(e.encode()).hexdigest()
|
||||||
|
arm = sorted(coupons)[int(h, 16) % 3]
|
||||||
|
arm_for[e] = arm
|
||||||
|
counts[arm] += 1
|
||||||
|
# Stable across re-calls.
|
||||||
|
assert btc.pick_coupon_for_email(e, coupons) == (code, pct)
|
||||||
|
total = sum(counts.values())
|
||||||
|
for arm in (0, 20, 30):
|
||||||
|
share = counts[arm] / total
|
||||||
|
assert 0.31 <= share <= 0.353, (arm, share)
|
||||||
|
# The price helper yields no number for the control arm (blank pct).
|
||||||
|
assert btc.discounted_price_attribs("mcs150", None, "")["coupon_priceable"] == ""
|
||||||
|
|
||||||
|
|
||||||
def test_coupon_attribs_reflects_pct():
|
def test_coupon_attribs_reflects_pct():
|
||||||
a = btc.coupon_attribs("BBBBB", "30")
|
a = btc.coupon_attribs("BBBBB", "30")
|
||||||
assert a["coupon_code"] == "BBBBB"
|
assert a["coupon_code"] == "BBBBB"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue