diff --git a/examples/seller_agent.py b/examples/seller_agent.py index a296becc7d..29b95497e4 100644 --- a/examples/seller_agent.py +++ b/examples/seller_agent.py @@ -702,21 +702,24 @@ def _package_budget_total(packages: list[dict[str, Any]]) -> Decimal: def _delivery_totals(events: list[dict[str, Any]]) -> dict[str, Any]: totals: dict[str, Any] = { - "impressions": sum(event.get("impressions", 0) for event in events), "spend": float(sum((Decimal(str(event.get("spend", 0))) for event in events), Decimal(0))), } - for metric in ("clicks", "conversions"): + # AdCP 3.3: an impression count nobody measured is absent, never zero. + for metric in ("impressions", "clicks", "conversions"): if any(metric in event for event in events): totals[metric] = sum(event.get(metric, 0) for event in events) views = [event["viewability"] for event in events if event.get("viewability")] - if views: + # AdCP 3.3: viewability reported under more than one standard has no flat + # total; one row is one (metric, qualifier) key. + if views and len({view.get("standard") for view in views}) == 1: measurable = sum(view.get("measurable_impressions", 0) for view in views) viewable = sum(view.get("viewable_impressions", 0) for view in views) totals["viewability"] = { "measurable_impressions": measurable, "viewable_impressions": viewable, - "viewable_rate": viewable / measurable if measurable else 0, } + if measurable: + totals["viewability"]["viewable_rate"] = viewable / measurable timed = [view for view in views if "viewed_seconds" in view] weight = sum(view.get("measurable_impressions", 0) for view in timed) if weight: @@ -756,6 +759,20 @@ def _split_delivery(totals: dict[str, Any], weights: list[Decimal]) -> list[dict return results +def _serves_impressionless_rows(context: Any) -> bool: + """Whether the caller's release (AdCP 3.3+) allows rows without ``impressions``. + + Below 3.3 a dimensional row with no impression count is omitted, never + sent as ``impressions: 0``. + """ + version = getattr(context, "resolved_adcp_version", None) + if not version: + return True + major, _, rest = str(version).partition(".") + minor = rest.split(".", 1)[0].split("-", 1)[0] + return not (major.isdigit() and minor.isdigit()) or (int(major), int(minor)) >= (3, 3) + + def _narrow_metrics(metrics: dict[str, Any], requested: list[str] | None) -> dict[str, Any]: if not requested: return metrics @@ -1813,6 +1830,8 @@ async def get_media_buy_delivery( "currency": mb.get("currency", "USD"), **_narrow_metrics(metrics, requested), } + if not _serves_impressionless_rows(context): + rows = [row for row in rows if "impressions" in row] if format_request is not None and reporting.get("supports_format_breakdown"): sort_by = format_request.get("sort_by", "spend") direction = format_request.get("sort_direction", "desc") diff --git a/tests/test_seller_agent_storyboard.py b/tests/test_seller_agent_storyboard.py index 8d07e217e8..377063fe7d 100644 --- a/tests/test_seller_agent_storyboard.py +++ b/tests/test_seller_agent_storyboard.py @@ -1234,3 +1234,57 @@ async def test_compact_catalog_ignores_outcome_target_and_paginates_fields() -> assert "products" not in unchanged invalid = await _seller().list_products({"cursor": "invalid"}) assert invalid["errors"][0]["code"] == "INVALID_REQUEST" + + +@pytest.mark.asyncio +async def test_delivery_without_impressions_omits_them_and_rates() -> None: + """AdCP 3.3: absence means not measured; never fill zero or derive a rate.""" + store = _store() + await store.seed_media_buy( + media_buy_id="unmeasured", fixture={"packages": [{"package_id": "p", "budget": 10}]} + ) + cumulative = await store.simulate_delivery( + "unmeasured", + reported_spend={"amount": 5, "currency": "USD"}, + viewability={"measurable_impressions": 0, "viewable_impressions": 0, "standard": "mrc"}, + ) + assert "impressions" not in cumulative["cumulative"] + report = await _seller().get_media_buy_delivery({"media_buy_ids": ["unmeasured"]}) + buy = report["media_buy_deliveries"][0] + assert "impressions" not in buy["totals"] + assert "impressions" not in buy["by_package"][0] + assert "viewable_rate" not in buy["totals"]["viewability"] + + +@pytest.mark.asyncio +async def test_viewability_under_two_standards_has_no_flat_total() -> None: + """AdCP 3.3: a metric reported under more than one qualifier omits its flat carrier.""" + store = _store() + await store.seed_media_buy( + media_buy_id="mixed-standards", fixture={"packages": [{"package_id": "p", "budget": 10}]} + ) + for standard in ("mrc", "groupm"): + await store.simulate_delivery( + "mixed-standards", + impressions=100, + reported_spend={"amount": 1, "currency": "USD"}, + viewability={ + "measurable_impressions": 100, + "viewable_impressions": 50, + "standard": standard, + }, + ) + report = await _seller().get_media_buy_delivery({"media_buy_ids": ["mixed-standards"]}) + totals = report["media_buy_deliveries"][0]["totals"] + assert totals["impressions"] == 200 + assert "viewability" not in totals + + +@pytest.mark.parametrize( + ("version", "expect_rows"), [("3.3-beta.1", True), ("3.2", False), (None, True)] +) +def test_impressionless_rows_are_served_only_from_33(version, expect_rows) -> None: + from types import SimpleNamespace + + context = None if version is None else SimpleNamespace(resolved_adcp_version=version) + assert _sa._serves_impressionless_rows(context) is expect_rows