Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/add-time-based-views.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/media-buy/media-buys/optimization-reporting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/media-buy/task-reference/get_media_buy_delivery.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/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/schema-deprecation-metadata.test.cjs tests/creative-rotation.test.cjs tests/metric-identity-coherence.test.cjs tests/lint-schema-enum-drift.test.cjs tests/synthetic-depiction.test.cjs && npm run test:geo-region-targeting",
"test:schemas": "node tests/schema-validation.test.cjs && node --test 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/schema-deprecation-metadata.test.cjs tests/creative-rotation.test.cjs tests/metric-identity-coherence.test.cjs tests/time-based-views-contract.test.cjs tests/lint-schema-enum-drift.test.cjs tests/synthetic-depiction.test.cjs && 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",
Expand Down
29 changes: 29 additions & 0 deletions static/schemas/source/core/delivery-metrics.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
3 changes: 2 additions & 1 deletion static/schemas/source/enums/available-metric.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -37,6 +37,7 @@
"quartile_50",
"quartile_75",
"quartile_100",
"time_based_views",
"dooh_metrics",
"cost_per_click",
"cost_per_completed_view",
Expand Down
2 changes: 1 addition & 1 deletion static/schemas/source/enums/sort-metric.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions static/schemas/source/enums/view-threshold-basis.json
Original file line number Diff line number Diff line change
@@ -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"]
}
112 changes: 112 additions & 0 deletions tests/time-based-views-contract.test.cjs
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Loading