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:
justin 2026-06-21 00:12:30 -05:00
parent 2f0753f00e
commit 297db74fee
2 changed files with 58 additions and 10 deletions

View file

@ -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
# percent in discount_codes, the discount the email advertises always matches the
# discount checkout actually applies, and redemptions are measurable per code
# (description marker campaign-daily:<date>:<pct>). 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 percent across re-sends (no arm-hopping).
# (description marker campaign-daily:<date>:<pct>). A percent of 0 is a valid
# FULL-PRICE control arm (e.g. "20,30,0"): no code is minted and the carrier sees
# 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(
int(p.strip())
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
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.
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]
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]:
"""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 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:
return "", ""
pcts = sorted(daily_coupons.keys())
if len(pcts) == 1:
pct = pcts[0]
return daily_coupons[pct], str(pct)
h = hashlib.sha256((email or "").strip().lower().encode()).hexdigest()
pct = pcts[int(h, 16) % len(pcts)]
return daily_coupons[pct], str(pct)
else:
h = hashlib.sha256((email or "").strip().lower().encode()).hexdigest()
pct = pcts[int(h, 16) % len(pcts)]
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:

View file

@ -59,6 +59,36 @@ def test_single_arm():
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():
a = btc.coupon_attribs("BBBBB", "30")
assert a["coupon_code"] == "BBBBB"