From 297db74fee3296342041cf9f1b3ada103a6e7941 Mon Sep 17 00:00:00 2001 From: justin Date: Sun, 21 Jun 2026 00:12:30 -0500 Subject: [PATCH] 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. --- scripts/build_trucking_campaigns.py | 38 +++++++++++++++++++++-------- scripts/tests/test_coupon_ab.py | 30 +++++++++++++++++++++++ 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/scripts/build_trucking_campaigns.py b/scripts/build_trucking_campaigns.py index 0b013ba..975772a 100644 --- a/scripts/build_trucking_campaigns.py +++ b/scripts/build_trucking_campaigns.py @@ -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::). 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::). 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: diff --git a/scripts/tests/test_coupon_ab.py b/scripts/tests/test_coupon_ab.py index da5fb48..d072ad5 100644 --- a/scripts/tests/test_coupon_ab.py +++ b/scripts/tests/test_coupon_ab.py @@ -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"