Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 23 additions & 4 deletions examples/seller_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
54 changes: 54 additions & 0 deletions tests/test_seller_agent_storyboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading