diff --git a/.changeset/add-demographic-delivery-reporting.md b/.changeset/add-demographic-delivery-reporting.md new file mode 100644 index 0000000000..5fd06ebe67 --- /dev/null +++ b/.changeset/add-demographic-delivery-reporting.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +Add opt-in demographic delivery breakdowns to `get_media_buy_delivery`, with product-scoped reportable age ranges, measurement systems, and privacy-suppression disclosure kept distinct from targeting execution capability. diff --git a/docs/media-buy/media-buys/optimization-reporting.mdx b/docs/media-buy/media-buys/optimization-reporting.mdx index 8bc1afa49f..5b24d5efcb 100644 --- a/docs/media-buy/media-buys/optimization-reporting.mdx +++ b/docs/media-buy/media-buys/optimization-reporting.mdx @@ -1276,13 +1276,39 @@ Delivery data can be broken down across multiple dimensions within each package. | Device type | `by_device_type` | `device_type`, `impressions`, `spend` | — | `supports_device_type_breakdown` | | Device platform | `by_device_platform` | `device_platform`, `impressions`, `spend` | — | `supports_device_platform_breakdown` | | Audience | `by_audience` | `audience_id`, `audience_source`, `impressions`, `spend` | `audience_name` | `supports_audience_breakdown` | +| Demographic | `by_demographic` | `demographic`, `demographic_system`, `impressions`, `spend` | `age`, other delivery metrics | `supports_demographic_breakdown` | | Placement | `by_placement` | `placement_id`, `impressions`, `spend` | `publisher_domain`, `placement_name` | `supports_placement_breakdown` | -Each breakdown entry inherits all fields from `delivery-metrics` (clicks, conversions, and other optional metrics) plus its dimension-specific fields. Every entry requires the fields shown in the required fields column. Check `reporting_capabilities` on the product to discover which dimensions are available; product-level capabilities are authoritative because different products from the same seller may support different breakdowns. `supports_geo_breakdown` is an object that declares available levels and systems; the other capability declarations in this table are boolean flags. Within `supports_geo_breakdown`, `country` and `region` are booleans, while `metro` is keyed by `metro-system` values and native `postal_area` is keyed by ISO 3166-1 alpha-2 country with arrays of country-local `postal-system` values. Geo rows use `system` for `geo_level: "metro"` and `"postal_area"`; native postal rows also include `country`. Deprecated country-fused postal systems remain accepted for compatibility. +Each breakdown entry inherits all fields from `delivery-metrics` (clicks, conversions, and other optional metrics) plus its dimension-specific fields. Every entry requires the fields shown in the required fields column. Check `reporting_capabilities` on the product to discover which dimensions are available; product-level capabilities are authoritative because different products from the same seller may support different breakdowns. `supports_geo_breakdown` and `supports_demographic_breakdown` are structured declarations; the other capability declarations in this table are boolean flags. Within `supports_geo_breakdown`, `country` and `region` are booleans, while `metro` is keyed by `metro-system` values and native `postal_area` is keyed by ISO 3166-1 alpha-2 country with arrays of country-local `postal-system` values. Geo rows use `system` for `geo_level: "metro"` and `"postal_area"`; native postal rows also include `country`. Deprecated country-fused postal systems remain accepted for compatibility. + +Demographic targeting and demographic reporting are separate product capabilities. A product may execute an exact buyer predicate while its measurement source reports only fixed buckets. For example, a social product might declare continuous targeting for ages 18–65 but the following reporting capability: + +```json test=false +{ + "supports_demographic_breakdown": { + "age": { + "reporting_modes": ["enumerated_intervals"], + "intervals": [ + { + "age": { "min": 25, "max": 34, "include_unknown": false }, + "demographic": "25-34", + "demographic_system": "custom" + } + ] + }, + "demographic_systems": ["custom"], + "may_suppress_small_cells": true + } +} +``` + +That product can target 26–30 if its separate `demographic_targeting` declaration allows it, but it cannot report 26–30. The buyer sees the 25–34 reporting interval before purchase and MUST NOT treat that wider row as delivery against the requested target. `reporting_dimensions.demographic.age_ranges` requests exact output ranges; every range must be supported by `exact_predicates` or equal a declared interval. Omitting `age_ranges` asks for the seller's native declared breakdown. + +Demographic rows pair a free-form `demographic` code with its `demographic_system`, such as `P25-54` with `nielsen`. When the capability or request identifies an age interval, the row also carries canonical `age`; that structured predicate, not a parsed label, is authoritative. `by_demographic_suppressed` discloses privacy or measurement-threshold omissions separately from `by_demographic_truncated`. Placement identity is publisher-scoped. Placement rows MAY carry `publisher_domain`, the publisher namespace from the product's `placements[]` entry, so buyers can treat `{publisher_domain, placement_id}` as the stable placement identity for multi-publisher products when it is present. Sellers SHOULD emit `publisher_domain` whenever the product placement carries it (always true for `kind: "publisher_ref"`); sellers MAY omit it only for `kind: "seller_inline"` placements in a legacy single-publisher context where the seller agent's own domain is the namespace. When `publisher_domain` is omitted, buyers MAY interpret `placement_id` relative to the seller agent's own publisher domain only in that legacy single-publisher context; otherwise buyers should not infer a cross-publisher placement key. `publisher_domain` is single-valued because each placement belongs to exactly one publisher namespace. -Breakdowns are opt-in — no dimension data is returned unless explicitly requested. Sellers that don't support a requested dimension silently omit it. Each breakdown array has a sibling `by_*_truncated` boolean indicating whether additional rows exist beyond the requested `limit`. +Breakdowns are opt-in — no dimension data is returned unless explicitly requested. Sellers that don't support a requested dimension silently omit it. An unsupported `demographic.age_ranges` value on a product that otherwise supports the dimension returns `UNSUPPORTED_FEATURE`; sellers never widen or narrow it silently. Each breakdown array has a sibling `by_*_truncated` boolean indicating whether additional non-suppressed rows exist beyond the requested `limit`. ## Targeting Consistency Reporting aligns with AdCP's [Targeting](/docs/media-buy/advanced-topics/targeting) approach, enabling: diff --git a/docs/media-buy/task-reference/get_media_buy_delivery.mdx b/docs/media-buy/task-reference/get_media_buy_delivery.mdx index 66e320555b..0d926de5a0 100644 --- a/docs/media-buy/task-reference/get_media_buy_delivery.mdx +++ b/docs/media-buy/task-reference/get_media_buy_delivery.mdx @@ -26,7 +26,7 @@ Retrieve comprehensive delivery metrics and performance data for media buy repor | `status_filter` | string \| string[] | No | Status filter: `"pending_creatives"`, `"pending_start"`, `"active"`, `"paused"`, `"completed"`. Defaults to `["active"]` when omitted. | | `start_date` | string | No | Report start date (YYYY-MM-DD), inclusive. Omit for campaign lifetime data. Only accepted when product supports `date_range`. | | `end_date` | string | No | Report end date (YYYY-MM-DD), **exclusive**. Omit for campaign lifetime data. Only accepted when product supports `date_range`. | -| `reporting_dimensions` | object | No | Request dimensional breakdowns within `by_package`. Include a key as an empty object (e.g., `"device_type": {}`) to activate with defaults. Keys: `geo`, `device_type`, `device_platform`, `audience`, `placement`. Each accepts optional `limit` (defaults to 25 for geo, audience, placement) and `sort_by` (sort-metric enum, default: `spend`). Geo requires `geo_level` (one per request); include `system` for metro/postal levels when requesting a specific system. Unsupported dimensions are silently omitted; malformed requests return a validation error. | +| `reporting_dimensions` | object | No | Request dimensional breakdowns within `by_package`. Include a key as an empty object (e.g., `"device_type": {}`) to activate with defaults. Keys: `geo`, `device_type`, `device_platform`, `audience`, `demographic`, `placement`. Most accept optional `limit` and `sort_by`. Demographic also accepts canonical `age_ranges`, each of which must match the product's independent demographic reporting capability exactly. Geo requires `geo_level`; include `system` for metro/postal levels. Unsupported dimensions are silently omitted; unsupported requested demographic ranges return [`UNSUPPORTED_FEATURE`](/docs/building/verification/compliance-catalog#error-code-unsupported-feature); malformed requests return a validation error. | | `time_granularity` | string | No | Per-window slice granularity for pull recovery, matching `reporting_webhook.reporting_frequency` vocabulary (`hourly`, `daily`, `weekly`, `monthly`, `quarterly`, `post_campaign`). When set, the response includes `windows[]` slices shape-aligned with webhook fires at the same granularity. Capability-scoped — value MUST be in the product's `reporting_capabilities.windowed_pull_granularities`. See [Windowed pull recovery](#windowed-pull-recovery). | | `include_window_breakdown` | boolean | No | When `true` (and `time_granularity` is set), include the `windows[]` array on each media buy. Defaults to `false`. Ignored when `time_granularity` is omitted. | @@ -886,12 +886,15 @@ When you include `reporting_dimensions` in the request, the response includes di "reporting_dimensions": { "geo": { "geo_level": "metro", "system": "nielsen_dma", "limit": 10 }, "device_type": {}, + "demographic": { + "age_ranges": [{ "min": 26, "max": 30, "include_unknown": false }] + }, "placement": { "limit": 5, "sort_by": "roas" } } } ``` -Each dimension accepts optional `limit` (max rows; defaults to 25 for geo, audience, and placement) and `sort_by` (any value from the `sort-metric` enum, e.g., `spend`, `impressions`, `clicks`, `roas` — defaults to `spend` descending; falls back to `spend` if the seller does not report the requested metric). Geo requires `geo_level` (`country`, `region`, `metro`, `postal_area`). Include `system` for metro/postal levels when requesting a specific system; native postal requests also include `country` (for example, `{ "geo_level": "postal_area", "country": "US", "system": "zip" }`). Each request uses a single geo_level — for multiple granularities (e.g., country and region), make separate requests. Unsupported dimensions are silently omitted from the response, but malformed requests (e.g., geo without `geo_level`) return a validation error. Breakdowns are per-dimension only — cross-dimensional intersections (e.g., device_type × geo) are not supported. +Most dimensions accept optional `limit` and `sort_by` (any value from the `sort-metric` enum; defaults to `spend`). Geo requires `geo_level` (`country`, `region`, `metro`, `postal_area`). Demographic accepts optional canonical `age_ranges`. Each requested range must be supported by the product's `supports_demographic_breakdown.age`: `exact_predicates` accepts ranges within its declared bounds, while `enumerated_intervals` accepts only exact declared intervals. Unsupported ranges return [`UNSUPPORTED_FEATURE`](/docs/building/verification/compliance-catalog#error-code-unsupported-feature); the seller never substitutes a wider or narrower bucket. Omitting `age_ranges` requests the product's native declared breakdown. Breakdowns are per-dimension only — cross-dimensional intersections are not supported. ### Available dimensions @@ -901,11 +904,14 @@ Each dimension accepts optional `limit` (max rows; defaults to 25 for geo, audie | Device type | `by_device_type` | `device_type`, `impressions`, `spend` | — | `supports_device_type_breakdown` | | Device platform | `by_device_platform` | `device_platform`, `impressions`, `spend` | — | `supports_device_platform_breakdown` | | Audience | `by_audience` | `audience_id`, `audience_source`, `impressions`, `spend` | `audience_name` | `supports_audience_breakdown` | +| Demographic | `by_demographic` | `demographic`, `demographic_system`, `impressions`, `spend` | `age`, other delivery metrics | `supports_demographic_breakdown` | | Placement | `by_placement` | `placement_id`, `impressions`, `spend` | `publisher_domain`, `placement_name` | `supports_placement_breakdown` | Check `reporting_capabilities` on the product to discover which dimensions are available. Product-level capabilities are authoritative since different products from the same seller may support different breakdowns. -`supports_geo_breakdown` is an object that declares available levels and systems; the other capability declarations in this table are boolean flags. Within `supports_geo_breakdown`, `country` and `region` are booleans, while `metro` is keyed by `metro-system` values and native `postal_area` is keyed by ISO 3166-1 alpha-2 country with arrays of country-local `postal-system` values. Geo rows use `system` for `geo_level: "metro"` and `"postal_area"`; native postal rows also include `country`. Deprecated country-fused postal systems remain accepted for compatibility. +`demographic_targeting` declares what the product can execute; `supports_demographic_breakdown` separately declares what it can measure and report. A product can therefore target 26–30 but disclose fixed 25–34 reporting. Buyers MUST NOT infer exact reporting from targeting capability or parse equivalence from a native label. `demographic` uses the notation of its accompanying `demographic_system`; canonical `age`, when present, is the authoritative machine-comparable interval. + +`supports_geo_breakdown` and `supports_demographic_breakdown` are structured capability objects; the other declarations in this table are boolean flags. Demographic capability declares exact predicates versus fixed intervals, available measurement systems, and whether small cells may be suppressed. Placement identity is publisher-scoped. Placement rows MAY carry `publisher_domain` — the publisher namespace from the product's `placements[]` entry — so buyers can treat `{publisher_domain, placement_id}` as the stable placement identity for multi-publisher products when it is present. Sellers SHOULD emit `publisher_domain` whenever the product placement carries it (always true for `kind: "publisher_ref"`); sellers MAY omit it only for `kind: "seller_inline"` placements in a legacy single-publisher context where the seller agent's own domain is the namespace. When `publisher_domain` is omitted, buyers MAY interpret `placement_id` relative to the seller agent's own publisher domain only in that legacy single-publisher context; otherwise buyers should not infer a cross-publisher placement key. `publisher_domain` is single-valued because each placement belongs to exactly one publisher namespace. @@ -913,6 +919,8 @@ Placement identity is publisher-scoped. Placement rows MAY carry `publisher_doma Each breakdown array has a sibling boolean flag (e.g., `by_geo_truncated`). When `true`, additional rows exist beyond the returned set. When `false`, the list is complete. Sellers MUST return the truncated flag whenever the corresponding breakdown array is present. Rows are sorted by the requested `sort_by` metric descending. +Demographic reporting also returns `by_demographic_suppressed`. `true` means privacy, policy, or measurement thresholds omitted otherwise reportable rows, so the visible rows may not reconcile to package totals. This is distinct from truncation caused by a row limit. + ### Audience sources The `audience_source` field indicates where the audience segment originated: diff --git a/static/schemas/source/core/canonical-reporting-capabilities.json b/static/schemas/source/core/canonical-reporting-capabilities.json index 8f5328b700..c2c7b42a5e 100644 --- a/static/schemas/source/core/canonical-reporting-capabilities.json +++ b/static/schemas/source/core/canonical-reporting-capabilities.json @@ -31,6 +31,7 @@ "supports_device_type_breakdown": { "type": "boolean" }, "supports_device_platform_breakdown": { "type": "boolean" }, "supports_audience_breakdown": { "type": "boolean" }, + "supports_demographic_breakdown": { "$ref": "/schemas/core/demographic-reporting-capability.json" }, "supports_placement_breakdown": { "type": "boolean" }, "date_range_support": { "type": "string", "enum": ["date_range", "lifetime_only"] }, "windowed_pull_granularities": { "type": "array", "items": { "$ref": "/schemas/enums/reporting-frequency.json" }, "uniqueItems": true }, diff --git a/static/schemas/source/core/demographic-reporting-capability.json b/static/schemas/source/core/demographic-reporting-capability.json new file mode 100644 index 0000000000..4e7d345a69 --- /dev/null +++ b/static/schemas/source/core/demographic-reporting-capability.json @@ -0,0 +1,125 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/demographic-reporting-capability.json", + "title": "Demographic Reporting Capability", + "description": "Product-scoped demographic breakdown capability. Reporting capability is independent of demographic targeting capability: a product can execute an exact buyer age predicate while its measurement source reports only fixed age intervals. Buyers compare requested reporting ranges with this declaration before requesting by_demographic rows and MUST NOT infer reporting alignment from Product.demographic_targeting.", + "type": "object", + "properties": { + "age": { + "type": "object", + "description": "Machine-comparable age ranges this product can report.", + "properties": { + "reporting_modes": { + "type": "array", + "description": "Ways this product can produce age rows. exact_predicates accepts requested canonical age predicates within the declared bounds. enumerated_intervals reports only the exact intervals declared in intervals; buyers may aggregate adjacent complete intervals but MUST NOT treat a wider or narrower interval as the requested audience.", + "items": { + "type": "string", + "enum": ["exact_predicates", "enumerated_intervals"] + }, + "minItems": 1, + "uniqueItems": true + }, + "min_reportable_age": { + "type": "integer", + "minimum": 0, + "maximum": 150, + "description": "Lowest explicit min or max bound accepted by exact_predicates. Required when that mode is declared." + }, + "max_reportable_age": { + "type": "integer", + "minimum": 0, + "maximum": 150, + "description": "Highest explicit min or max bound accepted by exact_predicates. Required when that mode is declared." + }, + "supports_unbounded_min": { + "type": "boolean", + "description": "Whether exact_predicates accepts a canonical age predicate with min omitted. Required when that mode is declared." + }, + "supports_unbounded_max": { + "type": "boolean", + "description": "Whether exact_predicates accepts a canonical age predicate with max omitted. Required when that mode is declared." + }, + "intervals": { + "type": "array", + "description": "Fixed reportable age intervals for enumerated_intervals. Bounds are authoritative capability data, not a protocol-wide bucket taxonomy. Optional native labels identify the measurement-system code returned with the interval.", + "items": { + "type": "object", + "properties": { + "age": { + "$ref": "/schemas/core/demographic-age-range.json" + }, + "demographic": { + "type": "string", + "minLength": 1, + "pattern": "\\S", + "description": "Optional native demographic label returned for this interval." + }, + "demographic_system": { + "$ref": "/schemas/enums/demographic-system.json" + } + }, + "required": ["age"], + "allOf": [ + { + "if": { "required": ["demographic"] }, + "then": { "required": ["demographic_system"] } + }, + { + "if": { "required": ["demographic_system"] }, + "then": { "required": ["demographic"] } + } + ], + "additionalProperties": false + }, + "minItems": 1, + "uniqueItems": true + } + }, + "required": ["reporting_modes"], + "allOf": [ + { + "if": { + "properties": { + "reporting_modes": { "contains": { "const": "exact_predicates" } } + }, + "required": ["reporting_modes"] + }, + "then": { + "required": ["min_reportable_age", "max_reportable_age", "supports_unbounded_min", "supports_unbounded_max"] + } + }, + { + "if": { + "properties": { + "reporting_modes": { "contains": { "const": "enumerated_intervals" } } + }, + "required": ["reporting_modes"] + }, + "then": { + "required": ["intervals"] + } + } + ], + "additionalProperties": false + }, + "demographic_systems": { + "type": "array", + "description": "Measurement-system notations this product may return. A code remains opaque unless its capability interval and response row also carry a canonical age predicate.", + "items": { + "$ref": "/schemas/enums/demographic-system.json" + }, + "minItems": 1, + "uniqueItems": true + }, + "may_suppress_small_cells": { + "type": "boolean", + "description": "Whether privacy, policy, or measurement thresholds may suppress otherwise reportable demographic rows. When true, buyers must inspect by_demographic_suppressed before testing whether rows reconcile to package totals." + } + }, + "required": ["may_suppress_small_cells"], + "anyOf": [ + { "required": ["age"] }, + { "required": ["demographic_systems"] } + ], + "additionalProperties": false +} diff --git a/static/schemas/source/core/reporting-capabilities.json b/static/schemas/source/core/reporting-capabilities.json index b1d22755cc..11fa75a1f5 100644 --- a/static/schemas/source/core/reporting-capabilities.json +++ b/static/schemas/source/core/reporting-capabilities.json @@ -102,6 +102,10 @@ "type": "boolean", "description": "Whether this product supports audience segment breakdowns in delivery reporting (by_audience within by_package)" }, + "supports_demographic_breakdown": { + "$ref": "/schemas/core/demographic-reporting-capability.json", + "description": "Product-scoped demographic breakdown support for by_demographic reporting. Declares reportable age ranges and measurement systems independently from demographic targeting execution." + }, "supports_placement_breakdown": { "type": "boolean", "description": "Whether this product supports placement breakdowns in delivery reporting (by_placement within by_package)" diff --git a/static/schemas/source/index.json b/static/schemas/source/index.json index c558eb81f8..92b1db213e 100644 --- a/static/schemas/source/index.json +++ b/static/schemas/source/index.json @@ -205,6 +205,10 @@ "$ref": "/schemas/core/demographic-targeting-capability.json", "description": "Product-scoped demographic execution modes and exact interval capabilities" }, + "demographic-reporting-capability": { + "$ref": "/schemas/core/demographic-reporting-capability.json", + "description": "Product-scoped demographic reporting ranges, systems, and suppression posture" + }, "demographic-targeting-resolution": { "$ref": "/schemas/core/demographic-targeting-resolution.json", "description": "Requested, applied, execution, and exact-equivalence demographic readback" diff --git a/static/schemas/source/media-buy/get-media-buy-delivery-request.json b/static/schemas/source/media-buy/get-media-buy-delivery-request.json index 8f65ebf994..abdd74f43e 100644 --- a/static/schemas/source/media-buy/get-media-buy-delivery-request.json +++ b/static/schemas/source/media-buy/get-media-buy-delivery-request.json @@ -254,6 +254,33 @@ }, "additionalProperties": true }, + "demographic": { + "type": "object", + "description": "Request delivery broken down by demographic. Check the product's reporting_capabilities.supports_demographic_breakdown independently from demographic_targeting. When age_ranges is present, every requested range MUST be exactly supported by exact_predicates or equal one of the declared enumerated_intervals; sellers MUST reject unsupported ranges with UNSUPPORTED_FEATURE rather than silently widen or narrow them.", + "properties": { + "age_ranges": { + "type": "array", + "description": "Optional canonical age ranges to return as distinct rows. Copying an applied targeting predicate here requests aligned reporting only when the reporting capability supports that exact predicate. Omit to request the product's declared native demographic breakdown.", + "items": { + "$ref": "/schemas/core/demographic-age-range.json" + }, + "minItems": 1, + "uniqueItems": true + }, + "limit": { + "type": "integer", + "minimum": 1, + "default": 25, + "description": "Maximum number of demographic entries to return. Defaults to 25." + }, + "sort_by": { + "$ref": "/schemas/enums/sort-metric.json", + "description": "Metric to sort breakdown rows by (descending). Falls back to 'spend' if the seller does not report the requested metric.", + "default": "spend" + } + }, + "additionalProperties": false + }, "placement": { "type": "object", "description": "Request placement breakdown.", diff --git a/static/schemas/source/media-buy/get-media-buy-delivery-response.json b/static/schemas/source/media-buy/get-media-buy-delivery-response.json index 8c23bb7eb3..fd7531687d 100644 --- a/static/schemas/source/media-buy/get-media-buy-delivery-response.json +++ b/static/schemas/source/media-buy/get-media-buy-delivery-response.json @@ -515,6 +515,50 @@ "type": "boolean", "description": "Whether by_audience was truncated. Sellers MUST return this flag whenever by_audience is present (false means the list is complete)." }, + "by_demographic": { + "type": "array", + "description": "Delivery by demographic within this package. Available when the buyer requests demographic breakdown and the product declares supports_demographic_breakdown. A free-form measurement code does not prove alignment with buyer targeting. When age is present it is the authoritative machine-comparable interval; for requested age_ranges, sellers MUST echo the exact requested interval and MUST NOT substitute a wider or narrower native bucket.", + "items": { + "allOf": [ + { + "$ref": "/schemas/core/delivery-metrics.json" + }, + { + "type": "object", + "properties": { + "demographic": { + "type": "string", + "minLength": 1, + "pattern": "\\S", + "description": "Demographic code in the declared system's notation (for example, P25-54 for Nielsen)." + }, + "demographic_system": { + "$ref": "/schemas/enums/demographic-system.json", + "description": "Measurement system that defines the demographic code." + }, + "age": { + "$ref": "/schemas/core/demographic-age-range.json", + "description": "Canonical age interval represented by this row. Required by protocol semantics for age ranges requested through reporting_dimensions.demographic.age_ranges and for intervals declared in the product's demographic reporting capability." + } + }, + "required": [ + "demographic", + "demographic_system", + "impressions", + "spend" + ] + } + ] + } + }, + "by_demographic_truncated": { + "type": "boolean", + "description": "Whether non-suppressed by_demographic rows were truncated due to the requested limit or a seller-imposed maximum. Sellers MUST return this flag whenever by_demographic is present. False means every non-suppressed row is present; inspect by_demographic_suppressed separately before reconciling rows to package totals." + }, + "by_demographic_suppressed": { + "type": "boolean", + "description": "Whether one or more otherwise reportable demographic rows were omitted due to privacy, policy, or measurement thresholds. Sellers MUST return this flag whenever by_demographic is present. False means no rows were threshold-suppressed." + }, "by_placement": { "type": "array", "description": "Delivery by placement within this package. Available when the buyer requests placement breakdown via reporting_dimensions and the seller supports it. Placement IDs reference the product's placements array.", diff --git a/tests/demographic-delivery-reporting-contract.test.cjs b/tests/demographic-delivery-reporting-contract.test.cjs new file mode 100644 index 0000000000..bc6fd9f8c8 --- /dev/null +++ b/tests/demographic-delivery-reporting-contract.test.cjs @@ -0,0 +1,158 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const assert = require('node:assert/strict'); +const Ajv = require('ajv'); +const addFormats = require('ajv-formats'); + +const SCHEMA_ROOT = path.join(__dirname, '..', 'static', 'schemas', 'source'); + +function readSchema(uri) { + assert.match(uri, /^\/schemas\//); + return JSON.parse(fs.readFileSync(path.join(SCHEMA_ROOT, uri.slice('/schemas/'.length)), 'utf8')); +} + +async function compile(uri) { + const ajv = new Ajv({ + allErrors: true, + strict: false, + discriminator: true, + loadSchema: async ref => readSchema(ref), + }); + addFormats(ajv); + return ajv.compileAsync(readSchema(uri)); +} + +test('demographic reporting request accepts limits and sort metrics', async () => { + const validate = await compile('/schemas/media-buy/get-media-buy-delivery-request.json'); + + assert.equal(validate({ reporting_dimensions: { demographic: {} } }), true); + assert.equal( + validate({ + reporting_dimensions: { + demographic: { + age_ranges: [{ min: 26, max: 30, include_unknown: false }], + limit: 10, + sort_by: 'impressions', + }, + }, + }), + true, + JSON.stringify(validate.errors), + ); + assert.equal(validate({ reporting_dimensions: { demographic: { limit: 0 } } }), false); + assert.equal( + validate({ reporting_dimensions: { demographic: { age_ranges: [{ min: 26, max: 30 }] } } }), + false, + 'requested age ranges require explicit unknown-age handling', + ); +}); + +test('demographic delivery rows reuse the shared demographic system vocabulary', async () => { + const response = readSchema('/schemas/media-buy/get-media-buy-delivery-response.json'); + const byPackage = response.properties.media_buy_deliveries.items.properties.by_package.items; + const extension = byPackage.allOf.find(part => part.properties?.by_demographic); + assert.ok(extension, 'by_package exposes by_demographic'); + + const validate = await compile('/schemas/media-buy/get-media-buy-delivery-response.json'); + const base = { + status: 'completed', + reporting_period: { + start: '2026-08-01T00:00:00Z', + end: '2026-08-15T23:59:59Z', + }, + currency: 'USD', + media_buy_deliveries: [{ + media_buy_id: 'mb_demo', + buyer_ref: 'buyer_demo', + status: 'active', + currency: 'USD', + total_spend: 14400, + totals: { + spend: 14400, + impressions: 1200000, + }, + by_package: [{ + package_id: 'pkg_demo', + spend: 14400, + impressions: 1200000, + pricing_model: 'cpm', + rate: 12, + currency: 'USD', + by_demographic: [{ + demographic: 'P25-54', + demographic_system: 'nielsen', + age: { min: 25, max: 54, include_unknown: false }, + impressions: 1200000, + spend: 14400, + }], + by_demographic_truncated: false, + by_demographic_suppressed: false, + }], + }], + }; + + assert.equal(validate(base), true, JSON.stringify(validate.errors)); + + const unknownSystem = structuredClone(base); + unknownSystem.media_buy_deliveries[0].by_package[0].by_demographic[0].demographic_system = 'unknown'; + assert.equal(validate(unknownSystem), false, 'unknown demographic systems are rejected'); + + const missingCode = structuredClone(base); + delete missingCode.media_buy_deliveries[0].by_package[0].by_demographic[0].demographic; + assert.equal(validate(missingCode), false, 'demographic code is required'); + + const blankCode = structuredClone(base); + blankCode.media_buy_deliveries[0].by_package[0].by_demographic[0].demographic = ' '; + assert.equal(validate(blankCode), false, 'blank demographic codes are rejected'); +}); + +test('reporting capabilities expose demographic breakdown support on both surfaces', () => { + const legacy = readSchema('/schemas/core/reporting-capabilities.json'); + const canonical = readSchema('/schemas/core/canonical-reporting-capabilities.json'); + + assert.equal( + legacy.properties.supports_demographic_breakdown.$ref, + '/schemas/core/demographic-reporting-capability.json', + ); + assert.equal( + canonical.properties.supports_demographic_breakdown.$ref, + '/schemas/core/demographic-reporting-capability.json', + ); +}); + +test('demographic reporting capability separates exact predicates from fixed social-style intervals', async () => { + const validate = await compile('/schemas/core/reporting-capabilities.json'); + const base = { + available_reporting_frequencies: ['daily'], + expected_delay_minutes: 60, + timezone: 'UTC', + supports_webhooks: true, + available_metrics: ['impressions', 'spend'], + date_range_support: 'date_range', + supports_demographic_breakdown: { + age: { + reporting_modes: ['enumerated_intervals'], + intervals: [{ + age: { min: 25, max: 34, include_unknown: false }, + demographic: '25-34', + demographic_system: 'custom', + }], + }, + demographic_systems: ['custom'], + may_suppress_small_cells: true, + }, + }; + + assert.equal(validate(base), true, JSON.stringify(validate.errors)); + + const exactWithoutBounds = structuredClone(base); + exactWithoutBounds.supports_demographic_breakdown.age = { + reporting_modes: ['exact_predicates'], + }; + assert.equal(validate(exactWithoutBounds), false, 'exact reporting declares its supported bounds'); + + const missingSuppressionPosture = structuredClone(base); + delete missingSuppressionPosture.supports_demographic_breakdown.may_suppress_small_cells; + assert.equal(validate(missingSuppressionPosture), false, 'suppression posture is explicit'); +}); diff --git a/tests/mcp-schema-analysis.test.cjs b/tests/mcp-schema-analysis.test.cjs index 1cc24588d4..74df9b912d 100644 --- a/tests/mcp-schema-analysis.test.cjs +++ b/tests/mcp-schema-analysis.test.cjs @@ -34,7 +34,7 @@ test("input-field weight report attributes the largest transitive schema graphs" const report = analyzeInputSchemaWeights(schemas); assert.equal(report.tool_count, 16); - assert.equal(report.definition_instances, 567); + assert.equal(report.definition_instances, 568); assert.equal(report.unique_definitions, 146); assert.equal(report.repeated_definitions, 106); assert.ok(report.repeated_definition_bytes > 180_000); @@ -215,7 +215,7 @@ test("experiment report keeps all alternatives smaller than standalone model con assert.equal(report.status, "non-normative"); assert.equal(report.prompt_cleanup_adapter.required, true); assert.equal(report.selection.tools.length, 16); - assert.equal(variants.standalone.context_bytes, 284_635); + assert.equal(variants.standalone.context_bytes, 285_144); assert.ok( variants.prompt_cleanup.context_bytes < variants.standalone.context_bytes * 0.82