diff --git a/.changeset/sort-direction-and-applied-sort-echo.md b/.changeset/sort-direction-and-applied-sort-echo.md new file mode 100644 index 0000000000..65d10d5b67 --- /dev/null +++ b/.changeset/sort-direction-and-applied-sort-echo.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +Add `sort_direction` (asc/desc, default desc) to the six sortable delivery breakdown dimensions and a per-breakdown applied-sort echo (`by_X_sorted_by` / `by_X_sort_direction`, MUST whenever the breakdown is present) so the existing silent fallback-to-spend becomes visible to buyers. Ascending sort enables bottom-N optimization queries (worst placements by viewable_rate) that cannot be recovered from a truncated descending pull. diff --git a/docs/building/concepts/managing-response-size.mdx b/docs/building/concepts/managing-response-size.mdx index a20ac4db0a..0f08ab7627 100644 --- a/docs/building/concepts/managing-response-size.mdx +++ b/docs/building/concepts/managing-response-size.mdx @@ -114,7 +114,7 @@ webhook stream. | `false` | All rows are present | | `true` | Additional rows exist beyond what was returned | -When a flag is `true`, the returned rows are sorted by the requested metric descending — you have the most significant breakdowns, and the tail is omitted. This is by design: delivery breakdowns are for optimization decisions, not archival reporting. If you need the full dataset, use the seller's native reporting API. +When a flag is `true`, the returned rows are ordered by the applied sort metric in the applied direction (descending by default — the most significant breakdowns; `sort_direction: "asc"` deliberately returns the least significant, e.g. worst performers) and the tail is omitted. The response echoes the applied sort per breakdown (`by_X_sorted_by` / `by_X_sort_direction`), so a fallback to spend is visible. This is by design: delivery breakdowns are for optimization decisions, not archival reporting. If you need the full dataset, use the seller's native reporting API. ## Putting it together 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 11ee035952..2d83737814 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`, `demographic`, `placement`, `spot`. 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. `spot` accepts only `limit`, returns rows by `aired_at` ascending, and defaults to the complete log for the reporting period. 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. | +| `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`, `spot`. Most accept optional `limit`, `sort_by`, and `sort_direction`. Demographic also accepts canonical `age_ranges`, each of which must match the product's independent demographic reporting capability exactly. `spot` accepts only `limit`, returns rows by `aired_at` ascending, and defaults to the complete log for the reporting period. 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. | @@ -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`). 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. +Geo, device, audience, demographic, and placement dimensions accept optional `limit`, `sort_by` (any value from the `sort-metric` enum; defaults to `spend`), and `sort_direction` (`asc` or `desc`; defaults to `desc`, largest first). 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. Setting `sort_direction: "asc"` enables bottom-N queries — for example, the 25 worst placements by `viewable_rate` — that cannot be recovered from a truncated descending pull. The seller echoes the sort it actually applied on each breakdown: `by_geo_sorted_by`/`by_geo_sort_direction`, `by_device_type_sorted_by`/`by_device_type_sort_direction`, and the equivalent pair for `device_platform`, `audience`, `demographic`, and `placement`. Sellers MUST return both echo fields whenever the corresponding breakdown array is present, including when they fall back to sorting by `spend` because they don't report the requested metric — the echo makes that fallback visible instead of leaving the buyer to misread the rows as ordered by the requested metric. 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 @@ -923,7 +923,7 @@ Delivery metrics on a spot row are optional. Omission means the metric is not ye ### Truncation -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. Metric dimensions are sorted by the requested `sort_by` value descending; spot rows always use `aired_at` ascending. +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. Metric dimensions are sorted by the requested `sort_by` value in the requested `sort_direction` (descending by default); the seller echoes the metric and direction it actually applied in `by_*_sorted_by`/`by_*_sort_direction`, which sellers MUST return whenever the breakdown array is present. Spot rows always use `aired_at` ascending and carry neither field. 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. diff --git a/package.json b/package.json index aa775359a9..27099a00ee 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/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/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/media-buy/get-media-buy-delivery-request.json b/static/schemas/source/media-buy/get-media-buy-delivery-request.json index 52122d4753..610f0b2520 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 @@ -129,8 +129,13 @@ }, "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.", + "description": "Metric to sort breakdown rows by, in `sort_direction` order (descending by default). Falls back to 'spend' when the seller does not report the requested metric at this breakdown's row grain; on fallback the sort direction resets to 'desc'. Rows lacking a value for the applied sort metric order last regardless of direction. The applied sort is echoed in the response.", "default": "spend" + }, + "sort_direction": { + "$ref": "/schemas/enums/sort-direction.json", + "description": "Direction for sort_by ordering. Defaults to 'desc' (largest first). 'asc' enables bottom-N queries (e.g., the 25 worst placements by viewable_rate) that cannot be recovered from a truncated descending pull. Sellers MUST apply the requested direction to the applied sort metric — direction has no availability fallback.", + "default": "desc" } }, "required": [ @@ -213,8 +218,13 @@ }, "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.", + "description": "Metric to sort breakdown rows by, in `sort_direction` order (descending by default). Falls back to 'spend' when the seller does not report the requested metric at this breakdown's row grain; on fallback the sort direction resets to 'desc'. Rows lacking a value for the applied sort metric order last regardless of direction. The applied sort is echoed in the response.", "default": "spend" + }, + "sort_direction": { + "$ref": "/schemas/enums/sort-direction.json", + "description": "Direction for sort_by ordering. Defaults to 'desc' (largest first). 'asc' enables bottom-N queries (e.g., the 25 worst placements by viewable_rate) that cannot be recovered from a truncated descending pull. Sellers MUST apply the requested direction to the applied sort metric — direction has no availability fallback.", + "default": "desc" } }, "additionalProperties": true @@ -230,8 +240,13 @@ }, "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.", + "description": "Metric to sort breakdown rows by, in `sort_direction` order (descending by default). Falls back to 'spend' when the seller does not report the requested metric at this breakdown's row grain; on fallback the sort direction resets to 'desc'. Rows lacking a value for the applied sort metric order last regardless of direction. The applied sort is echoed in the response.", "default": "spend" + }, + "sort_direction": { + "$ref": "/schemas/enums/sort-direction.json", + "description": "Direction for sort_by ordering. Defaults to 'desc' (largest first). 'asc' enables bottom-N queries (e.g., the 25 worst placements by viewable_rate) that cannot be recovered from a truncated descending pull. Sellers MUST apply the requested direction to the applied sort metric — direction has no availability fallback.", + "default": "desc" } }, "additionalProperties": true @@ -248,8 +263,13 @@ }, "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.", + "description": "Metric to sort breakdown rows by, in `sort_direction` order (descending by default). Falls back to 'spend' when the seller does not report the requested metric at this breakdown's row grain; on fallback the sort direction resets to 'desc'. Rows lacking a value for the applied sort metric order last regardless of direction. The applied sort is echoed in the response.", "default": "spend" + }, + "sort_direction": { + "$ref": "/schemas/enums/sort-direction.json", + "description": "Direction for sort_by ordering. Defaults to 'desc' (largest first). 'asc' enables bottom-N queries (e.g., the 25 worst placements by viewable_rate) that cannot be recovered from a truncated descending pull. Sellers MUST apply the requested direction to the applied sort metric — direction has no availability fallback.", + "default": "desc" } }, "additionalProperties": true @@ -275,8 +295,13 @@ }, "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.", + "description": "Metric to sort breakdown rows by, in `sort_direction` order (descending by default). Falls back to 'spend' when the seller does not report the requested metric at this breakdown's row grain; on fallback the sort direction resets to 'desc'. Rows lacking a value for the applied sort metric order last regardless of direction. The applied sort is echoed in the response.", "default": "spend" + }, + "sort_direction": { + "$ref": "/schemas/enums/sort-direction.json", + "description": "Direction for sort_by ordering. Defaults to 'desc' (largest first). 'asc' enables bottom-N queries (e.g., the 25 worst placements by viewable_rate) that cannot be recovered from a truncated descending pull. Sellers MUST apply the requested direction to the applied sort metric — direction has no availability fallback.", + "default": "desc" } }, "additionalProperties": false @@ -305,8 +330,13 @@ }, "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.", + "description": "Metric to sort breakdown rows by, in `sort_direction` order (descending by default). Falls back to 'spend' when the seller does not report the requested metric at this breakdown's row grain; on fallback the sort direction resets to 'desc'. Rows lacking a value for the applied sort metric order last regardless of direction. The applied sort is echoed in the response.", "default": "spend" + }, + "sort_direction": { + "$ref": "/schemas/enums/sort-direction.json", + "description": "Direction for sort_by ordering. Defaults to 'desc' (largest first). 'asc' enables bottom-N queries (e.g., the 25 worst placements by viewable_rate) that cannot be recovered from a truncated descending pull. Sellers MUST apply the requested direction to the applied sort metric — direction has no availability fallback.", + "default": "desc" } }, "additionalProperties": true 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 28d233b76f..d74c3a8459 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 @@ -418,6 +418,14 @@ "type": "boolean", "description": "Whether by_geo was truncated due to the requested limit or a seller-imposed maximum. Sellers MUST return this flag whenever by_geo is present (false means the list is complete)." }, + "by_geo_sorted_by": { + "$ref": "/schemas/enums/sort-metric.json", + "description": "The metric actually used to order by_geo rows. Sellers MUST return this field whenever by_geo is present. When the seller cannot sort by the requested sort_by metric it falls back to 'spend'; this echo makes the fallback visible instead of silently returning rows the buyer will misread as ordered by the requested metric." + }, + "by_geo_sort_direction": { + "$ref": "/schemas/enums/sort-direction.json", + "description": "The direction actually applied to by_geo ordering. Sellers MUST return this field whenever by_geo is present." + }, "by_device_type": { "type": "array", "description": "Delivery by device form factor within this package. Available when the buyer requests device_type breakdown via reporting_dimensions and the seller supports it.", @@ -447,6 +455,14 @@ "type": "boolean", "description": "Whether by_device_type was truncated. Sellers MUST return this flag whenever by_device_type is present (false means the list is complete)." }, + "by_device_type_sorted_by": { + "$ref": "/schemas/enums/sort-metric.json", + "description": "The metric actually used to order by_device_type rows. Sellers MUST return this field whenever by_device_type is present. When the seller cannot sort by the requested sort_by metric it falls back to 'spend'; this echo makes the fallback visible instead of silently returning rows the buyer will misread as ordered by the requested metric." + }, + "by_device_type_sort_direction": { + "$ref": "/schemas/enums/sort-direction.json", + "description": "The direction actually applied to by_device_type ordering. Sellers MUST return this field whenever by_device_type is present." + }, "by_device_platform": { "type": "array", "description": "Delivery by operating system within this package. Available when the buyer requests device_platform breakdown via reporting_dimensions and the seller supports it. Useful for CTV campaigns where tvOS vs Roku OS vs Fire OS matters.", @@ -476,6 +492,14 @@ "type": "boolean", "description": "Whether by_device_platform was truncated. Sellers MUST return this flag whenever by_device_platform is present (false means the list is complete)." }, + "by_device_platform_sorted_by": { + "$ref": "/schemas/enums/sort-metric.json", + "description": "The metric actually used to order by_device_platform rows. Sellers MUST return this field whenever by_device_platform is present. When the seller cannot sort by the requested sort_by metric it falls back to 'spend'; this echo makes the fallback visible instead of silently returning rows the buyer will misread as ordered by the requested metric." + }, + "by_device_platform_sort_direction": { + "$ref": "/schemas/enums/sort-direction.json", + "description": "The direction actually applied to by_device_platform ordering. Sellers MUST return this field whenever by_device_platform is present." + }, "by_audience": { "type": "array", "description": "Delivery by audience segment within this package. Available when the buyer requests audience breakdown via reporting_dimensions and the seller supports it. Only 'synced' audiences are directly targetable via the targeting overlay; other sources are informational.", @@ -515,6 +539,14 @@ "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_audience_sorted_by": { + "$ref": "/schemas/enums/sort-metric.json", + "description": "The metric actually used to order by_audience rows. Sellers MUST return this field whenever by_audience is present. When the seller cannot sort by the requested sort_by metric it falls back to 'spend'; this echo makes the fallback visible instead of silently returning rows the buyer will misread as ordered by the requested metric." + }, + "by_audience_sort_direction": { + "$ref": "/schemas/enums/sort-direction.json", + "description": "The direction actually applied to by_audience ordering. Sellers MUST return this field whenever by_audience is present." + }, "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.", @@ -555,6 +587,14 @@ "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_sorted_by": { + "$ref": "/schemas/enums/sort-metric.json", + "description": "The metric actually used to order by_demographic rows. Sellers MUST return this field whenever by_demographic is present. When the seller cannot sort by the requested sort_by metric it falls back to 'spend'; this echo makes the fallback visible instead of silently returning rows the buyer will misread as ordered by the requested metric." + }, + "by_demographic_sort_direction": { + "$ref": "/schemas/enums/sort-direction.json", + "description": "The direction actually applied to by_demographic ordering. Sellers MUST return this field whenever by_demographic is present." + }, "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." @@ -598,6 +638,14 @@ "type": "boolean", "description": "Whether by_placement was truncated. Sellers MUST return this flag whenever by_placement is present (false means the list is complete)." }, + "by_placement_sorted_by": { + "$ref": "/schemas/enums/sort-metric.json", + "description": "The metric actually used to order by_placement rows. Sellers MUST return this field whenever by_placement is present. When the seller cannot sort by the requested sort_by metric it falls back to 'spend'; this echo makes the fallback visible instead of silently returning rows the buyer will misread as ordered by the requested metric." + }, + "by_placement_sort_direction": { + "$ref": "/schemas/enums/sort-direction.json", + "description": "The direction actually applied to by_placement ordering. Sellers MUST return this field whenever by_placement is present." + }, "by_spot": { "type": "array", "description": "Spot-level as-run airing records for broadcast TV, radio, or other scheduled inventory. Available when the buyer requests spot breakdown and the product declares supports_spot_breakdown. Sellers MUST order rows by aired_at ascending. The same spot_id is reused when a later package measurement_window adds or revises metrics. Network and station are optional so station-direct radio and network-level TV records use the same channel-neutral shape.", diff --git a/tests/mcp-schema-analysis.test.cjs b/tests/mcp-schema-analysis.test.cjs index c4ea17d546..6bedcc11ef 100644 --- a/tests/mcp-schema-analysis.test.cjs +++ b/tests/mcp-schema-analysis.test.cjs @@ -34,8 +34,8 @@ 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, 581); - assert.equal(report.unique_definitions, 151); + assert.equal(report.definition_instances, 582); + assert.equal(report.unique_definitions, 152); assert.equal(report.repeated_definitions, 109); assert.ok(report.repeated_definition_bytes > 180_000); @@ -155,7 +155,7 @@ test("shared dictionary resolves every experimental tool schema when explicitly }); assert.equal(view.dictionary.$id, DICTIONARY_ID); - assert.equal(Object.keys(view.dictionary.$defs).length, 151); + assert.equal(Object.keys(view.dictionary.$defs).length, 152); for (const tool of Object.values(view.tools)) { assert.equal(tool.inputSchema.$defs, undefined); assert.match( diff --git a/tests/sort-contract-delivery-reporting.test.cjs b/tests/sort-contract-delivery-reporting.test.cjs new file mode 100644 index 0000000000..5b8f0c34e6 --- /dev/null +++ b/tests/sort-contract-delivery-reporting.test.cjs @@ -0,0 +1,180 @@ +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"); + +const SORTABLE_DIMENSIONS = [ + "geo", + "device_type", + "device_platform", + "audience", + "demographic", + "placement", +]; + +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); +} + +const BASE_PACKAGE = { + package_id: "pkg_1", + spend: 100, + pricing_model: "cpm", + rate: 12.5, + currency: "USD", +}; + +describe("delivery reporting sort contract", () => { + let validateRequest; + let validateByPackage; + let requestJson; + let byPackageExtension; + + before(async () => { + requestJson = readSchema( + "/schemas/media-buy/get-media-buy-delivery-request.json" + ); + const response = readSchema( + "/schemas/media-buy/get-media-buy-delivery-response.json" + ); + const byPackage = + response.properties.media_buy_deliveries.items.properties.by_package.items; + byPackageExtension = byPackage.allOf.find((schema) => schema.properties); + + [validateRequest, validateByPackage] = await Promise.all([ + compile(requestJson), + compile(byPackage), + ]); + }); + + it("accepts sort_direction on a breakdown request and rejects an invalid value", () => { + // viewable_rate is a leaf metric identity resolving to + // viewability.viewable_rate — the canonical bottom-N optimization query. + assert.equal( + validateRequest({ + reporting_dimensions: { + placement: { sort_by: "viewable_rate", sort_direction: "asc" }, + }, + }), + true, + JSON.stringify(validateRequest.errors) + ); + assert.equal( + validateRequest({ + reporting_dimensions: { + placement: { sort_by: "viewable_rate", sort_direction: "ascending" }, + }, + }), + false + ); + }); + + for (const dimension of SORTABLE_DIMENSIONS) { + it(`request schema declares both sort_by and sort_direction for ${dimension}`, () => { + const dimensionSchema = + requestJson.properties.reporting_dimensions.properties[dimension]; + assert.ok(dimensionSchema, `missing dimension schema for ${dimension}`); + assert.equal( + dimensionSchema.properties.sort_by.$ref, + "/schemas/enums/sort-metric.json" + ); + assert.equal( + dimensionSchema.properties.sort_direction.$ref, + "/schemas/enums/sort-direction.json" + ); + }); + } + + for (const dimension of SORTABLE_DIMENSIONS) { + it(`response echoes the applied sort for by_${dimension}`, () => { + const sortedByField = `by_${dimension}_sorted_by`; + const sortDirectionField = `by_${dimension}_sort_direction`; + const properties = byPackageExtension.properties; + + assert.ok(properties[sortedByField], `missing ${sortedByField}`); + assert.ok(properties[sortDirectionField], `missing ${sortDirectionField}`); + + assert.equal( + validateByPackage({ + ...BASE_PACKAGE, + [sortedByField]: "spend", + [sortDirectionField]: "desc", + }), + true, + JSON.stringify(validateByPackage.errors) + ); + + assert.equal( + validateByPackage({ + ...BASE_PACKAGE, + [sortedByField]: "spend", + [sortDirectionField]: true, + }), + false + ); + + assert.match( + properties[sortedByField].description, + /MUST return this field whenever by_.+ is present/ + ); + }); + } + + it("spot has neither sort_by nor sort_direction", () => { + const spotSchema = + requestJson.properties.reporting_dimensions.properties.spot; + assert.ok(spotSchema, "missing spot dimension schema"); + assert.equal(spotSchema.properties.sort_by, undefined); + assert.equal(spotSchema.properties.sort_direction, undefined); + }); + + // Self-enforcing invariant: any dimension that gains sort_by (present or + // future — e.g. format, creative, keyword, catalog_item) must carry the + // full sort contract, so a new sortable dimension cannot ship with an + // invisible spend fallback. + it("every dimension with sort_by carries sort_direction and both response echoes", () => { + const dimensions = + requestJson.properties.reporting_dimensions.properties; + const responseProperties = byPackageExtension.properties; + for (const [name, schema] of Object.entries(dimensions)) { + if (!schema.properties || !schema.properties.sort_by) continue; + assert.equal( + schema.properties.sort_by.$ref, + "/schemas/enums/sort-metric.json", + `${name}.sort_by $ref` + ); + assert.equal( + schema.properties.sort_direction?.$ref, + "/schemas/enums/sort-direction.json", + `${name} has sort_by but no sort_direction` + ); + assert.equal( + responseProperties[`by_${name}_sorted_by`]?.$ref, + "/schemas/enums/sort-metric.json", + `by_${name}_sorted_by echo missing` + ); + assert.equal( + responseProperties[`by_${name}_sort_direction`]?.$ref, + "/schemas/enums/sort-direction.json", + `by_${name}_sort_direction echo missing` + ); + } + }); +});