diff --git a/.changeset/add-time-based-views.md b/.changeset/add-time-based-views.md new file mode 100644 index 0000000000..110930587f --- /dev/null +++ b/.changeset/add-time-based-views.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +Add `time_based_views` to delivery reporting: an array of time-threshold video view counts, each entry keyed by (threshold_seconds, basis). The new `view-threshold-basis` enum distinguishes play-time counting (platform 2s/6s video views) from in-view counting (IAB/MRC viewable video), which are materially different numbers at the same threshold and must not be conflated or summed. Capability-gated via the `time_based_views` token in available-metric. Implements RFC #6430 with the basis discriminator the RFC's open questions pointed toward. diff --git a/docs/media-buy/media-buys/optimization-reporting.mdx b/docs/media-buy/media-buys/optimization-reporting.mdx index 85e0a7ec5e..94c7c48298 100644 --- a/docs/media-buy/media-buys/optimization-reporting.mdx +++ b/docs/media-buy/media-buys/optimization-reporting.mdx @@ -313,6 +313,7 @@ Buyers receive the intersection of both. `impressions` and `spend` are always re - **`profile_visits`**: Visits to the brand's in-platform page - **`viewability`**: Viewability data (measurable_impressions, viewable_impressions, viewable_rate, viewed_seconds, standard, vendor). Separates MRC and GroupM standards. `viewed_seconds` is the average in-view duration per measurable impression — reporting-side counterpart to the `viewed_seconds` optimization goal, governed by the same `standard` threshold as `viewable_rate`. The optional `vendor` field carries a `BrandRef` so the row is self-describing — buyer agents reading delivery in isolation can attribute the numbers to a measurement vendor without joining back to `package.committed_metrics` or `package.performance_standards`. - **`quartile_data`**: Video quartile completion data (q1-q4); `null` when not applicable (e.g. a non-video buy) +- **`time_based_views`**: Time-threshold view counts, one entry per (threshold_seconds, basis). `play_time` (platform-counted playback time, no independent viewability qualification) and `in_view` (IAB/MRC viewable-video clock, 50% of pixels) are not comparable and must not be summed. 2s and 6s are RECOMMENDED cross-platform reporting points; sellers may report other thresholds. Primarily an autoplay/skippable-video metric (social, olv, in-feed video) — completion (`quartile_data`) remains the currency for lean-back CTV/cinema inventory. Disclosure-grade: a `committed_metrics` entry for `time_based_views` contracts the array's presence, not specific thresholds. - **`dooh_metrics`**: DOOH-specific metrics (loop plays, screens, venue breakdown) - **`cost_per_click`**: Cost per click (`spend / clicks`) - **`cost_per_completed_view`**: Cost per completed view (`spend / completed_views`); CPCV pricing scalar for video/audio inventory 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 2d83737814..7c0a6b6663 100644 --- a/docs/media-buy/task-reference/get_media_buy_delivery.mdx +++ b/docs/media-buy/task-reference/get_media_buy_delivery.mdx @@ -614,6 +614,7 @@ asyncio.run(main()) | **Reach Window** | Window semantics for reported reach/frequency: `cumulative` (uniques since campaign start), `period` (uniques within a single non-overlapping reporting period — e.g., daily snapshot), or `rolling` (uniques within a trailing window — e.g., trailing-7-day). Never sum across rows. Optional but strongly recommended when reach is present. | | **Frequency** | Average ad exposures per reach unit, measured over `reach_window` | | **Viewability** | Object with `vendor`, `measurable_impressions` (denominator), `viewable_impressions`, `viewable_rate`, `viewed_seconds` (average in-view duration per measurable impression — pairs with the `viewed_seconds` optimization goal), and `standard` | +| **Time-based views** | Array of `{threshold_seconds, basis, views}`. One entry per (threshold_seconds, basis) pair. `basis` is `play_time` (continuous playback time, e.g. platform 2s/6s video views) or `in_view` (continuous in-view time under the governing viewability standard); the two are not comparable and must not be summed | | **Follows** | New followers, page likes, or free channel/feed subscribes attributed to delivery | | **Pacing Index** | Actual vs. expected delivery rate (1.0 = on track, <1.0 = behind, >1.0 = ahead) | | **CPM** | Cost per thousand impressions (spend/impressions * 1000) | diff --git a/package.json b/package.json index 27099a00ee..8103bf2879 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "deploy:cdn-artifacts-cutover:dry-run": "wrangler deploy --config workers/artifact-cdn/wrangler.cutover.toml --dry-run", "verify:cdn-artifacts-cutover": "node scripts/verify-cdn-artifacts-cutover.mjs", "typecheck": "tsc --project server/tsconfig.json --noEmit", - "test:schemas": "node tests/schema-validation.test.cjs && node --test tests/outcome-target.test.cjs tests/trusted-match-offer-creative-data.test.cjs tests/accessibility-violation-details.test.cjs tests/portfolio-routing-scope.test.cjs tests/catalog-item-availability-updates.test.cjs tests/compact-product-lifecycle-storyboards.test.cjs tests/timezone-resolution-storyboards.test.cjs tests/schema-deprecation-metadata.test.cjs tests/products-only-brief-compatibility.test.cjs tests/async-identity-convergence.test.cjs tests/creative-rotation.test.cjs tests/metric-identity-coherence.test.cjs tests/sort-contract-delivery-reporting.test.cjs tests/lint-schema-enum-drift.test.cjs tests/synthetic-depiction.test.cjs tests/creative-rendering-authority.test.cjs && npm run test:premium-display-formats && npm run test:geo-region-targeting", + "test:schemas": "node tests/schema-validation.test.cjs && node --test tests/outcome-target.test.cjs tests/trusted-match-offer-creative-data.test.cjs tests/accessibility-violation-details.test.cjs tests/portfolio-routing-scope.test.cjs tests/catalog-item-availability-updates.test.cjs tests/compact-product-lifecycle-storyboards.test.cjs tests/timezone-resolution-storyboards.test.cjs tests/schema-deprecation-metadata.test.cjs tests/products-only-brief-compatibility.test.cjs tests/async-identity-convergence.test.cjs tests/creative-rotation.test.cjs tests/metric-identity-coherence.test.cjs tests/sort-contract-delivery-reporting.test.cjs tests/time-based-views-contract.test.cjs tests/lint-schema-enum-drift.test.cjs tests/synthetic-depiction.test.cjs tests/creative-rendering-authority.test.cjs && npm run test:premium-display-formats && npm run test:geo-region-targeting", "test:performance-feedback": "node --test --test-force-exit --test-timeout=30000 tests/performance-feedback-contract.test.cjs", "test:dist-schema-version-ids": "node --test --test-force-exit --test-timeout=30000 tests/dist-schema-version-ids.test.cjs", "test:examples": "node tests/example-validation-simple.test.cjs && npm run test:tmp-context-merge", diff --git a/static/schemas/source/core/delivery-metrics.json b/static/schemas/source/core/delivery-metrics.json index 469ec16c69..eb8d273cc3 100644 --- a/static/schemas/source/core/delivery-metrics.json +++ b/static/schemas/source/core/delivery-metrics.json @@ -213,6 +213,35 @@ } } }, + "time_based_views": { + "type": "array", + "description": "Time-threshold video view counts. Each entry reports views that met a continuous duration threshold under a stated basis, rather than a completion percentage (percentage-based completion is quartile_data). Thresholds of 2 and 6 seconds are RECOMMENDED cross-platform reporting points; any seller-defined threshold is permitted. One entry per (threshold_seconds, basis) pair per reporting period — sellers MUST de-duplicate before emission and MUST NOT emit the same pair twice; buyers MAY treat duplicate pairs as a seller-side conformance bug. Entries under different bases are different metrics and MUST NOT be summed (see view-threshold-basis). Primarily an autoplay/skippable-video metric (social, olv, in-feed video); completion metrics remain the currency for lean-back CTV/cinema inventory. Distinct from `views` (the single billable-threshold scalar) and from `viewability.viewed_seconds` (average in-view duration, not a threshold count). Array entries are not individually sortable in breakdown sort_by. Disclosure-grade surface: (threshold_seconds, basis) is not part of the committed-metric qualifier vocabulary, so a `committed_metrics` entry for `time_based_views` contracts the array's presence, not specific thresholds.", + "items": { + "type": "object", + "properties": { + "threshold_seconds": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Continuous duration threshold in seconds an impression must meet to count as a view in this entry." + }, + "basis": { + "$ref": "/schemas/enums/view-threshold-basis.json", + "description": "Whether the threshold clock runs on playback time or in-view time. Required because play-time and in-view counts at the same threshold are materially different numbers." + }, + "views": { + "type": "number", + "minimum": 0, + "description": "Count of views meeting this entry's threshold and basis." + }, + "standard": { + "$ref": "/schemas/enums/viewability-standard.json", + "description": "Viewability standard governing the in-view clock for this entry. RECOMMENDED when basis is 'in_view' (MRC and GroupM thresholds differ); not applicable to play_time entries." + } + }, + "required": ["threshold_seconds", "basis", "views"], + "additionalProperties": true + } + }, "dooh_metrics": { "type": "object", "description": "DOOH-specific metrics (only included for DOOH campaigns)", diff --git a/static/schemas/source/enums/available-metric.json b/static/schemas/source/enums/available-metric.json index 24e8c1706e..98fdc0e7be 100644 --- a/static/schemas/source/enums/available-metric.json +++ b/static/schemas/source/enums/available-metric.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/enums/available-metric.json", "title": "Available Metric", - "description": "Standard delivery and performance metrics available for reporting. Most values name flat numeric fields of `core/delivery-metrics.json`. Leaf identities address a single numeric value nested inside an object-shaped metric so it can be declared, committed, aggregated, and sorted individually: `quartile_25`/`quartile_50`/`quartile_75`/`quartile_100` resolve to `quartile_data.q1_views`–`q4_views`, and `viewable_rate`/`viewable_impressions`/`measurable_impressions`/`viewed_seconds` resolve to the same-named fields of `viewability`. The nested object remains the canonical carrier of the reported value — leaf identities never introduce duplicate flat response fields. The object-shaped tokens (`viewability`, `quartile_data`, `dooh_metrics`) declare the whole nested surface at once. **Container subsumption**: in every set operation over this enum — capability declaration, `required_metrics` filtering, format `reported_metrics` intersection, and `requested_metrics` selection — a container token subsumes its leaf identities: declaring `viewability` satisfies a requirement or request for `viewable_rate`, and requesting a leaf identity selects its canonical carrier object in the response (never a flat duplicate). A leaf declaration does not imply sibling leaves or the carrier's non-numeric fields.", + "description": "Standard delivery and performance metrics available for reporting. Most values name flat numeric fields of `core/delivery-metrics.json`. Leaf identities address a single numeric value nested inside an object-shaped metric so it can be declared, committed, aggregated, and sorted individually: `quartile_25`/`quartile_50`/`quartile_75`/`quartile_100` resolve to `quartile_data.q1_views`–`q4_views`, and `viewable_rate`/`viewable_impressions`/`measurable_impressions`/`viewed_seconds` resolve to the same-named fields of `viewability`. The nested object remains the canonical carrier of the reported value — leaf identities never introduce duplicate flat response fields. The container tokens (`viewability`, `quartile_data`, `dooh_metrics`, and the `time_based_views` array) declare the whole nested surface at once. **Container subsumption**: in every set operation over this enum — capability declaration, `required_metrics` filtering, format `reported_metrics` intersection, and `requested_metrics` selection — a container token subsumes its leaf identities: declaring `viewability` satisfies a requirement or request for `viewable_rate`, and requesting a leaf identity selects its canonical carrier object in the response (never a flat duplicate). A leaf declaration does not imply sibling leaves or the carrier's non-numeric fields.", "type": "string", "enum": [ "impressions", @@ -37,6 +37,7 @@ "quartile_50", "quartile_75", "quartile_100", + "time_based_views", "dooh_metrics", "cost_per_click", "cost_per_completed_view", diff --git a/static/schemas/source/enums/sort-metric.json b/static/schemas/source/enums/sort-metric.json index 7582d4c769..ce5197963b 100644 --- a/static/schemas/source/enums/sort-metric.json +++ b/static/schemas/source/enums/sort-metric.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/enums/sort-metric.json", "title": "Sort Metric", - "description": "Numeric delivery metrics available for sorting breakdown rows. Contains the flat numeric fields of `core/delivery-metrics.json` plus leaf identities that resolve to a single numeric value nested inside an object-shaped metric: `quartile_25`–`quartile_100` order rows by `quartile_data.q1_views`–`q4_views`; `viewable_rate`, `viewable_impressions`, `measurable_impressions`, and `viewed_seconds` order rows by the same-named fields of `viewability`. Sorting by a leaf identity orders on the nested value — the nested object remains the only carrier of the value in responses; no duplicate flat fields exist. Excludes object/array containers (`quartile_data`, `dooh_metrics`, `viewability`, `by_event_type`, `by_action_source`, `vendor_metric_values`) and the survey/model-based lift scalars (`incremental_sales_lift`, `brand_lift`, `foot_traffic`, `conversion_lift`, `brand_search_lift`), which are package-grain estimates rather than row-grain orderings.", + "description": "Numeric delivery metrics available for sorting breakdown rows. Contains the flat numeric fields of `core/delivery-metrics.json` plus leaf identities that resolve to a single numeric value nested inside an object-shaped metric: `quartile_25`–`quartile_100` order rows by `quartile_data.q1_views`–`q4_views`; `viewable_rate`, `viewable_impressions`, `measurable_impressions`, and `viewed_seconds` order rows by the same-named fields of `viewability`. Sorting by a leaf identity orders on the nested value — the nested object remains the only carrier of the value in responses; no duplicate flat fields exist. Excludes object/array containers (`quartile_data`, `dooh_metrics`, `viewability`, `time_based_views`, `by_event_type`, `by_action_source`, `vendor_metric_values`) and the survey/model-based lift scalars (`incremental_sales_lift`, `brand_lift`, `foot_traffic`, `conversion_lift`, `brand_search_lift`), which are package-grain estimates rather than row-grain orderings.", "type": "string", "enum": [ "impressions", diff --git a/static/schemas/source/enums/view-threshold-basis.json b/static/schemas/source/enums/view-threshold-basis.json new file mode 100644 index 0000000000..365b01b880 --- /dev/null +++ b/static/schemas/source/enums/view-threshold-basis.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/enums/view-threshold-basis.json", + "title": "View Threshold Basis", + "description": "Basis governing when the clock runs for a time-threshold view count. 'play_time' counts continuous playback time as measured by the serving platform's own play definition, with no independent viewability qualification (the platform-counted model behind short-form feed 2-second and 6-second video views; individual platforms may fold additional conditions into their play definition). 'in_view' counts continuous in-view time under an independent viewability standard (the IAB/MRC viewable-video model: 50% of pixels for 2 continuous seconds). Counts under different bases are not comparable and MUST NOT be summed. Extensible: additional bases (e.g., an audible-playback basis for audio verified-listen thresholds) ship explicitly in future minors.", + "type": "string", + "enum": ["play_time", "in_view"] +} diff --git a/tests/time-based-views-contract.test.cjs b/tests/time-based-views-contract.test.cjs new file mode 100644 index 0000000000..69d4319c7e --- /dev/null +++ b/tests/time-based-views-contract.test.cjs @@ -0,0 +1,112 @@ +const fs = require("fs"); +const path = require("path"); +const Ajv = require("ajv"); +const addFormats = require("ajv-formats"); +const { describe, it, before } = require("node:test"); +const assert = require("node:assert/strict"); + +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(schema) { + const ajv = new Ajv({ + allErrors: true, + strict: false, + loadSchema: async (ref) => readSchema(ref), + }); + addFormats(ajv); + return ajv.compileAsync(schema); +} + +describe("time-based video view metrics", () => { + let validateDeliveryMetrics; + let availableMetric; + let sortMetric; + let deliveryMetrics; + let viewThresholdBasis; + + before(async () => { + deliveryMetrics = readSchema("/schemas/core/delivery-metrics.json"); + availableMetric = readSchema("/schemas/enums/available-metric.json"); + sortMetric = readSchema("/schemas/enums/sort-metric.json"); + viewThresholdBasis = readSchema("/schemas/enums/view-threshold-basis.json"); + + validateDeliveryMetrics = await compile(deliveryMetrics); + }); + + it("accepts time_based_views entries for both bases", () => { + const value = { + time_based_views: [ + { threshold_seconds: 2, basis: "play_time", views: 100 }, + { threshold_seconds: 2, basis: "in_view", views: 60 }, + ], + }; + assert.equal( + validateDeliveryMetrics(value), + true, + JSON.stringify(validateDeliveryMetrics.errors) + ); + }); + + it("rejects an entry missing basis", () => { + assert.equal( + validateDeliveryMetrics({ + time_based_views: [{ threshold_seconds: 2, views: 100 }], + }), + false + ); + }); + + it("rejects threshold_seconds of 0", () => { + assert.equal( + validateDeliveryMetrics({ + time_based_views: [ + { threshold_seconds: 0, basis: "play_time", views: 100 }, + ], + }), + false + ); + }); + + it("rejects negative views", () => { + assert.equal( + validateDeliveryMetrics({ + time_based_views: [ + { threshold_seconds: 2, basis: "play_time", views: -1 }, + ], + }), + false + ); + }); + + it("rejects an unknown basis value", () => { + assert.equal( + validateDeliveryMetrics({ + time_based_views: [ + { threshold_seconds: 2, basis: "viewable", views: 100 }, + ], + }), + false + ); + }); + + it("is in available-metric.json's enum and not in sort-metric.json's enum", () => { + assert.ok(availableMetric.enum.includes("time_based_views")); + assert.ok(!sortMetric.enum.includes("time_based_views")); + }); + + it("documents the de-duplication and non-summability contracts", () => { + const timeBasedViews = deliveryMetrics.properties.time_based_views; + assert.match( + timeBasedViews.description, + /MUST NOT emit the same pair twice/ + ); + assert.match(viewThresholdBasis.description, /MUST NOT be summed/); + }); +});