Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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/metric-leaf-identities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"adcontextprotocol": minor
---

Add leaf metric identities so nested delivery values are individually declarable, committable, aggregatable, and sortable: `quartile_25`–`quartile_100` (resolving to `quartile_data.q1_views`–`q4_views`) and `viewable_rate`, `viewable_impressions`, `measurable_impressions`, `viewed_seconds` (resolving to the same-named `viewability` fields) join `available-metric` and `sort-metric`. This closes an existing contradiction: `committed-metric` qualifier rules and `delivery-metric-aggregate` conditionals already referenced these metric_ids, and the shipped `committed_metrics` / `metric_aggregates` examples were invalid against their own schemas. Also adds the missing flat transactional scalars (`commissionable_value`, `plays`, `cost_per_completed_view`, `cpm`, `downloads`, `units_sold`, `new_to_brand_units`) to `sort-metric`, with survey/model-based lift scalars documented as intentionally sort-excluded. Leaf identities resolve to the nested canonical values — no duplicate flat response fields are introduced. A metric-identity coherence contract test now enforces enum/schema/example agreement.
2 changes: 2 additions & 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,8 @@ 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)

The numeric values inside `viewability` and `quartile_data` are individually addressable through leaf metric identities — `viewable_rate`, `viewable_impressions`, `measurable_impressions`, `viewed_seconds`, and `quartile_25`/`quartile_50`/`quartile_75`/`quartile_100` (which resolve to `quartile_data.q1_views`–`q4_views`). Leaf identities appear in `available_metrics` declarations, `committed_metrics` contracts, `metric_aggregates` rows, and breakdown `sort_by`; the nested object remains the only carrier of the values in delivery reports. Note that `quartile_100` counts 100%-of-duration completions (VAST `complete`), which is distinct from `completed_views` when the buy defines a shorter billable view threshold.
- **`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
2 changes: 1 addition & 1 deletion docs/media-buy/task-reference/get_media_buy_delivery.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -895,7 +895,7 @@ When you include `reporting_dimensions` in the request, the response includes di
}
```

Geo, device, audience, demographic, and placement 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. The spot log accepts only `limit`; its canonical order is `aired_at` ascending. Breakdowns are per-dimension only — cross-dimensional intersections are not supported.
Geo, device, audience, demographic, and placement dimensions accept optional `limit` and `sort_by` (any value from the `sort-metric` enum; defaults to `spend`). The sort vocabulary includes leaf metric identities that order rows by a value nested inside an object-shaped metric — `sort_by: "quartile_100"` orders by `quartile_data.q4_views`, `sort_by: "viewable_rate"` orders by `viewability.viewable_rate` — so queries like "top placements by in-view rate" need no flat duplicate fields. Survey/model-based lift scalars are excluded from sorting; see the `sort-metric` enum description. 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. The spot log accepts only `limit`; its canonical order is `aired_at` ascending. Breakdowns are per-dimension only — cross-dimensional intersections are not supported.

### Available dimensions

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/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/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
4 changes: 2 additions & 2 deletions static/schemas/source/core/delivery-metrics.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@
},
"quartile_data": {
"type": ["object", "null"],
"description": "Audio/video quartile completion data. Null indicates the metric is not applicable to this package/buy (e.g. quartile data on a non-video buy).",
"description": "Audio/video quartile completion data. Null indicates the metric is not applicable to this package/buy (e.g. quartile data on a non-video buy). Individual quartiles are addressable via the leaf metric identities `quartile_25` (q1_views), `quartile_50` (q2_views), `quartile_75` (q3_views), and `quartile_100` (q4_views) for declaration, commitments, aggregates, and breakdown sorting; this object remains the canonical carrier of the values. Quartiles are player-fired events (VAST firstQuartile/midpoint/thirdQuartile/complete). `quartile_100` counts 100%-of-duration completions and is distinct from `completed_views`, which counts completions at the seller's billable view threshold (`view_duration_seconds`) when one is set.",
"properties": {
"q1_views": {
"type": "number",
Expand Down Expand Up @@ -288,7 +288,7 @@
},
"viewability": {
"type": "object",
"description": "Viewability metrics. Viewable rate should be calculated as viewable_impressions / measurable_impressions (not total impressions), since some environments cannot measure viewability. Includes `viewed_seconds` — average in-view duration — since duration is governed by the same viewability threshold (`standard`) and shares the same `measurable_impressions` denominator. Sellers SHOULD include `standard` whenever measured viewability values are reported because MRC and GroupM rows are not interchangeable.",
"description": "Viewability metrics. Viewable rate should be calculated as viewable_impressions / measurable_impressions (not total impressions), since some environments cannot measure viewability. Includes `viewed_seconds` — average in-view duration — since duration is governed by the same viewability threshold (`standard`) and shares the same `measurable_impressions` denominator. Sellers SHOULD include `standard` whenever measured viewability values are reported because MRC and GroupM rows are not interchangeable. The numeric leaves are addressable via the leaf metric identities `viewable_rate`, `viewable_impressions`, `measurable_impressions`, and `viewed_seconds` for declaration, commitments, aggregates, and breakdown sorting; this object remains the canonical carrier of the values. When a buy reports under more than one standard, contract a specific standard via the `viewability_standard` qualifier on `committed_metrics`.",
"properties": {
"vendor": {
"$ref": "/schemas/core/brand-ref.json",
Expand Down
10 changes: 9 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",
"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.",
"type": "string",
"enum": [
"impressions",
Expand All @@ -28,7 +28,15 @@
"saves",
"profile_visits",
"viewability",
"viewable_rate",
"viewable_impressions",
"measurable_impressions",
"viewed_seconds",
"quartile_data",
"quartile_25",
"quartile_50",
"quartile_75",
"quartile_100",
"dooh_metrics",
"cost_per_click",
"cost_per_completed_view",
Expand Down
19 changes: 17 additions & 2 deletions 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. Subset of delivery-metrics fields that are flat numeric values (excludes nested objects like quartile_data, dooh_metrics, viewability, by_event_type, by_action_source).",
"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.",
"type": "string",
"enum": [
"impressions",
Expand All @@ -26,6 +26,21 @@
"saves",
"profile_visits",
"engagement_rate",
"cost_per_click"
"cost_per_click",
"commissionable_value",
"plays",
"cost_per_completed_view",
"cpm",
"downloads",
"units_sold",
"new_to_brand_units",
"viewable_rate",
"viewable_impressions",
"measurable_impressions",
"viewed_seconds",
"quartile_25",
"quartile_50",
"quartile_75",
"quartile_100"
]
}
164 changes: 164 additions & 0 deletions tests/metric-identity-coherence.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
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);
}

// Leaf metric identities address one numeric value nested inside an
// object-shaped delivery metric. The nested object stays the canonical
// carrier; the identity exists so commitments, aggregates, and sorts can
// reference the single value.
const LEAF_IDENTITIES = {
quartile_25: ["quartile_data", "q1_views"],
quartile_50: ["quartile_data", "q2_views"],
quartile_75: ["quartile_data", "q3_views"],
quartile_100: ["quartile_data", "q4_views"],
viewable_rate: ["viewability", "viewable_rate"],
viewable_impressions: ["viewability", "viewable_impressions"],
measurable_impressions: ["viewability", "measurable_impressions"],
viewed_seconds: ["viewability", "viewed_seconds"],
};

// Package-grain survey/model-based estimates: reportable and committable,
// but excluded from row sorting per sort-metric.json's description.
const SORT_EXCLUDED_LIFT_METRICS = [
"incremental_sales_lift",
"brand_lift",
"foot_traffic",
"conversion_lift",
"brand_search_lift",
];

describe("metric identity coherence", () => {
let deliveryMetrics;
let availableMetrics;
let sortMetrics;

before(() => {
deliveryMetrics = readSchema("/schemas/core/delivery-metrics.json");
availableMetrics = new Set(readSchema("/schemas/enums/available-metric.json").enum);
sortMetrics = new Set(readSchema("/schemas/enums/sort-metric.json").enum);
});

function flatNumericDeliveryMetrics() {
return Object.entries(deliveryMetrics.properties)
.filter(([, schema]) => {
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
return types.includes("number") || types.includes("integer");
})
.map(([name]) => name);
}

it("declares every flat numeric delivery metric in available-metric", () => {
for (const name of flatNumericDeliveryMetrics()) {
assert.ok(availableMetrics.has(name), `${name} missing from available-metric.json`);
}
});

it("makes every flat numeric delivery metric sortable except lift estimates", () => {
for (const name of flatNumericDeliveryMetrics()) {
if (SORT_EXCLUDED_LIFT_METRICS.includes(name)) {
assert.ok(
!sortMetrics.has(name),
`${name} is documented as sort-excluded but present in sort-metric.json`
);
continue;
}
assert.ok(sortMetrics.has(name), `${name} missing from sort-metric.json`);
}
});

it("keeps sort-metric a subset of available-metric", () => {
for (const name of sortMetrics) {
assert.ok(availableMetrics.has(name), `${name} sortable but not declarable`);
}
});

it("resolves every leaf identity to an existing nested numeric value", () => {
for (const [leaf, [container, field]] of Object.entries(LEAF_IDENTITIES)) {
assert.ok(availableMetrics.has(leaf), `${leaf} missing from available-metric.json`);
assert.ok(sortMetrics.has(leaf), `${leaf} missing from sort-metric.json`);
const nested = deliveryMetrics.properties[container];
assert.ok(nested, `${container} missing from delivery-metrics.json`);
const target = nested.properties[field];
assert.ok(target, `${container}.${field} missing from delivery-metrics.json`);
const types = Array.isArray(target.type) ? target.type : [target.type];
assert.ok(
types.includes("number") || types.includes("integer"),
`${container}.${field} is not numeric`
);
// No duplicate flat field: the leaf identity must not also exist as a
// top-level delivery-metrics property (the nested value is canonical).
assert.equal(
deliveryMetrics.properties[leaf],
undefined,
`${leaf} must not exist as a flat delivery-metrics field`
);
}
});

it("only conditions delivery-metric-aggregate on representable metric_ids", () => {
const aggregate = readSchema("/schemas/core/delivery-metric-aggregate.json");
const standardBranch = aggregate.oneOf.find(
(branch) => branch.properties.scope.const === "standard"
);
for (const conditional of standardBranch.allOf) {
const metricId = conditional.if.properties.metric_id.const;
assert.ok(
availableMetrics.has(metricId),
`delivery-metric-aggregate conditions on unrepresentable metric_id ${metricId}`
);
}
});

it("validates the shipped committed_metrics example against its own schema", async () => {
const validate = await compile(readSchema("/schemas/core/committed-metric.json"));
const pkg = readSchema("/schemas/core/package.json");
const example = pkg.properties.committed_metrics.examples[0];
for (const entry of example) {
assert.equal(
validate(entry),
true,
`${JSON.stringify(entry)} -> ${JSON.stringify(validate.errors)}`
);
}
});

it("validates the shipped metric_aggregates example against its own schema", async () => {
const validate = await compile(
readSchema("/schemas/core/delivery-metric-aggregate.json")
);
const response = readSchema(
"/schemas/media-buy/get-media-buy-delivery-response.json"
);
const example =
response.properties.aggregated_totals.properties.metric_aggregates.examples[0];
for (const entry of example) {
assert.equal(
validate(entry),
true,
`${JSON.stringify(entry)} -> ${JSON.stringify(validate.errors)}`
);
}
});
});
Loading