diff --git a/.changeset/signal-coverage-legacy-scalar.md b/.changeset/signal-coverage-legacy-scalar.md new file mode 100644 index 0000000000..582fba2574 --- /dev/null +++ b/.changeset/signal-coverage-legacy-scalar.md @@ -0,0 +1,11 @@ +--- +"adcontextprotocol": minor +--- + +spec(signals): make deprecated `coverage_percentage` optional on signal responses. + +`get_signals.signals[]` and wholesale feed signal payloads now keep `coverage_percentage` as an optional deprecated legacy scalar instead of a required deprecated field. `coverage_forecast` is the source of truth for detailed signal coverage planning; the scalar remains a backward-compatible fallback for clients that still consume it. + +Adds validation coverage for `coverage_rate.low` and `coverage_rate.high` upper bounds, and pins the intended valid signal forecast shape where `presence: "present"` omits `signal_value`. + +Closes #5089. diff --git a/docs/signals/overview.mdx b/docs/signals/overview.mdx index 974a94de9e..1b9144f46e 100644 --- a/docs/signals/overview.mdx +++ b/docs/signals/overview.mdx @@ -39,7 +39,7 @@ The signal agent searches catalogs from every authorized data provider — autom | Segment taxonomy | Signal catalog (`adagents.json`) | | Data provider | Signal source (`data_provider_domain`) | | Activate on my DSP | `activate_signal` with destination | -| Audience or inventory availability | Required `coverage_percentage`, plus `coverage_forecast` when the seller can disclose scope and value-level availability | +| Audience or inventory availability | `coverage_forecast` for authoritative signal-level coverage; optional deprecated `coverage_percentage` only as a legacy scalar fallback | | Data cost | `pricing_options` in signal response | diff --git a/docs/signals/tasks/get_signals.mdx b/docs/signals/tasks/get_signals.mdx index e82b94d7ff..8775b70b7b 100644 --- a/docs/signals/tasks/get_signals.mdx +++ b/docs/signals/tasks/get_signals.mdx @@ -89,7 +89,7 @@ The response structure is identical across protocols, with only the transport wr "description": "string", "signal_type": "string", "data_provider": "string", - "coverage_percentage": "number", + "coverage_percentage": "number (optional, deprecated)", "coverage_forecast": { "method": "estimate", "forecast_range_unit": "availability", @@ -178,8 +178,8 @@ The response structure is identical across protocols, with only the transport wr - `owned` — first-party segment derived from data the signal source directly owns - `custom` — source-native segment built on demand from models, composites, or buyer inputs (not attributable to a standing upstream provider) - **data_provider**: Human-readable source name when applicable. For `scope: "data_provider"` signals this is the data provider; for `scope: "signal_source"` signals it may identify the signal source or proprietary origin. - - **coverage_percentage**: Legacy scalar percentage of audience coverage. When **coverage_forecast** is also present, this is the seller's total present-coverage estimate for the same signal. If `coverage_forecast` includes an absent bucket over the same denominator, `coverage_percentage` should align with `100 * (1 - absent coverage_rate.mid)`. - - **coverage_forecast**: Optional forecast-shaped availability guidance for the signal. `scope` declares the denominator, `bucket_semantics` declares whether returned value buckets are `exclusive` or `overlapping`, and `bucket_completeness` declares whether the returned buckets are a full denominator partition or a partial histogram. Each point can use a `kind: "signal"` dimension with canonical `signal_ref`, `presence: "present"` for values, or `presence: "absent"` plus `signal_value: null` for the not-present bucket. `metrics.coverage_rate` is a 0.0-1.0 fraction of the declared scope. + - **coverage_percentage**: Optional deprecated legacy scalar percentage of audience coverage. Use only as a fallback for clients that do not consume `coverage_forecast`. When **coverage_forecast** is present, `coverage_forecast` is authoritative for signal-level discovery and this scalar is fallback-only. If `coverage_forecast` includes an absent bucket over the same denominator, `coverage_percentage` should align with `100 * (1 - absent coverage_rate.mid)`. + - **coverage_forecast**: Optional forecast-shaped availability guidance for the signal. `scope` declares the denominator, `bucket_semantics` declares whether returned value buckets are `exclusive` or `overlapping`, and `bucket_completeness` declares whether the returned buckets are a full denominator partition or a partial histogram. Each point can use a `kind: "signal"` dimension with canonical `signal_ref`, `presence: "present"` for any present value, `presence: "present"` plus `signal_value` for a specific value bucket, or `presence: "absent"` plus `signal_value: null` for the not-present bucket. `metrics.coverage_rate` is a 0.0-1.0 fraction of the declared scope. - **deployments**: Array of destination deployments - **agent_url**: URL identifying the destination agent - **account**: Account identifier if applicable @@ -276,15 +276,43 @@ Because the authenticated caller matches the deployment target, the response inc { "signal_ref": { "scope": "data_provider", - "data_provider_domain": "experian.com", + "data_provider_domain": "pinnacle-data.example", "signal_id": "luxury_auto_intenders" }, "signal_agent_segment_id": "luxury_auto_intenders", "name": "Luxury Automotive Intenders", "description": "High-income individuals researching luxury vehicles", "signal_type": "marketplace", - "data_provider": "Experian", - "coverage_percentage": 12, + "data_provider": "Pinnacle Data", + "coverage_forecast": { + "method": "estimate", + "forecast_range_unit": "availability", + "scope": { + "kind": "inventory", + "label": "eligible destination inventory" + }, + "bucket_semantics": "exclusive", + "bucket_completeness": "partial", + "points": [ + { + "label": "present", + "dimensions": [ + { + "kind": "signal", + "signal_ref": { + "scope": "data_provider", + "data_provider_domain": "pinnacle-data.example", + "signal_id": "luxury_auto_intenders" + }, + "presence": "present" + } + ], + "metrics": { + "coverage_rate": { "mid": 0.12 } + } + } + ] + }, "deployments": [ { "type": "agent", @@ -680,10 +708,10 @@ Discover all available deployments across platforms: - **description** (string): Detailed signal description - **signal_type** (string): `marketplace` (resold third-party), `owned` (signal source first-party data), or `custom` (source-native segment built on demand) - **data_provider** (string, optional): Human-readable source/provider name when applicable - - **coverage_percentage** (number): Legacy scalar estimated reach percentage - - **coverage_forecast** (object, optional): Forecast-shaped coverage breakdown with an explicit denominator and per-value availability points. Use `bucket_semantics: "exclusive"` when returned buckets do not overlap, or `"overlapping"` when multi-value signals can make returned rates sum above 1.0. Use `bucket_completeness: "complete"` only when the returned buckets cover the declared denominator; otherwise use `"partial"` and buyers must treat omitted share as undisclosed, other, or unsupported buckets. + - **coverage_percentage** (number, optional, deprecated): Legacy scalar estimated reach percentage. Use only as a fallback when `coverage_forecast` is absent or unsupported by the client. + - **coverage_forecast** (object, optional): Forecast-shaped coverage breakdown with an explicit denominator and availability points. Use `presence: "present"` with omitted `signal_value` for an aggregate "any present value" bucket; add `signal_value` only when the row is for a specific value. Use `bucket_semantics: "exclusive"` when returned buckets do not overlap, or `"overlapping"` when multi-value signals can make returned rates sum above 1.0. Use `bucket_completeness: "complete"` only when the returned buckets cover the declared denominator; otherwise use `"partial"` and buyers must treat omitted share as undisclosed, other, or unsupported buckets. -Use `coverage_forecast` for signal-first discovery: "how much inventory has this signal or its values?" Use product or proposal forecasts with `kind: "signal"` dimensions for product-specific planning: "how does this signal restrict this product's baseline availability?" Bucket-specific filtering is client-side for now; request filters such as `min_coverage_percentage` still use the legacy scalar. +Use `coverage_forecast` as the authoritative field for signal-first discovery: "how much inventory has this signal or its values?" Use product or proposal forecasts with `kind: "signal"` dimensions as the authoritative surface for product-specific planning: "how does this signal restrict this product's baseline availability?" Bucket-specific filtering is client-side for now; request filters such as `min_coverage_percentage` still use the legacy scalar. - **deployments** (array): Platform-specific deployment information - **platform** (string): Target platform name - **account** (string, nullable): Specific account if account-specific @@ -1131,7 +1159,8 @@ def generate_signals_message(signals, request): price_commentary = "" else: price_commentary = "" - return f"I found a perfect match: '{signal.name}' from {signal.data_provider} with {signal.coverage_percentage}% coverage. {deployment_status} {price_commentary}" + coverage_commentary = f" with {signal.coverage_percentage}% coverage" if getattr(signal, "coverage_percentage", None) is not None else "" + return f"I found a perfect match: '{signal.name}' from {signal.data_provider}{coverage_commentary}. {deployment_status} {price_commentary}" else: return f"I found {len(signals)} signals matching your {extract_key_criteria(request.signal_spec)} criteria. {describe_best_option(best_signal)} {get_pricing_range(signals)}." diff --git a/static/compliance/source/specialisms/signal-marketplace/index.yaml b/static/compliance/source/specialisms/signal-marketplace/index.yaml index a4a1a42af3..67246184fe 100644 --- a/static/compliance/source/specialisms/signal-marketplace/index.yaml +++ b/static/compliance/source/specialisms/signal-marketplace/index.yaml @@ -120,7 +120,7 @@ phases: - signal_agent_segment_id for activation - signal_id with source, data_provider_domain, and id - name, description, and value_type (binary, categorical, or numeric) - - coverage_percentage (audience reach estimate) + - coverage_forecast when detailed availability is disclosed; deprecated coverage_percentage may be present as a legacy scalar fallback - pricing_options with at least one pricing model - signal_type: "marketplace" @@ -155,10 +155,6 @@ phases: - check: field_present path: "signals[0].pricing_options" description: "Each signal has pricing options" - - check: field_present - path: "signals[0].coverage_percentage" - description: "Each signal has a coverage estimate" - - check: field_present path: "context" description: "Response echoes back the context object" diff --git a/static/compliance/source/specialisms/signal-owned/index.yaml b/static/compliance/source/specialisms/signal-owned/index.yaml index 226567f3dd..6ea9410423 100644 --- a/static/compliance/source/specialisms/signal-owned/index.yaml +++ b/static/compliance/source/specialisms/signal-owned/index.yaml @@ -115,7 +115,7 @@ phases: - signal_agent_segment_id for activation - signal_id with source: "agent_native" - name, description, and value_type - - coverage_percentage + - coverage_forecast when detailed availability is disclosed; deprecated coverage_percentage may be present as a legacy scalar fallback - pricing_options - signal_type: "owned" diff --git a/static/compliance/source/universal/schema-validation-signals.yaml b/static/compliance/source/universal/schema-validation-signals.yaml index d804536f12..156d0504e8 100644 --- a/static/compliance/source/universal/schema-validation-signals.yaml +++ b/static/compliance/source/universal/schema-validation-signals.yaml @@ -9,10 +9,9 @@ required_tools: narrative: | Every AdCP agent must return responses that match the published JSON schemas. - For signals agents, this means get_signals responses must carry the required - fields on every signal: signal_id, signal_agent_segment_id, name, description, - signal_type, data_provider, coverage_percentage, deployments, and - pricing_options. + For signals agents, this means get_signals responses must carry the schema-required + fields on every signal: signal identity, signal_agent_segment_id, name, description, + signal_type, and deployments. This storyboard validates schema compliance on get_signals responses — confirming that each signal contains the fields buyers need to proceed to @@ -95,16 +94,16 @@ phases: comply_scenario: schema_compliance stateful: false expected: | - Return signals matching the spec. Each signal must have all required fields: + Return signals matching the spec. Each signal must have all schema-required fields: - signal_id: reference to the data provider's catalog - signal_agent_segment_id: opaque identifier used for activation - name: human-readable signal name - description: detailed signal description - signal_type: marketplace, custom, or owned - - data_provider: human-readable name of the data provider - - coverage_percentage: audience coverage percentage (0–100) - deployments: array of deployment targets - - pricing_options: array (minItems: 1) with pricing_option_id + + Optional fields such as data_provider, pricing_options, coverage_forecast, + and deprecated coverage_percentage must still match the schema when present. Responses missing any of these fields fail schema validation. diff --git a/static/schemas/source/core/wholesale-feed-event.json b/static/schemas/source/core/wholesale-feed-event.json index 8765479e5a..030866454c 100644 --- a/static/schemas/source/core/wholesale-feed-event.json +++ b/static/schemas/source/core/wholesale-feed-event.json @@ -582,14 +582,14 @@ }, "coverage_percentage": { "type": "number", - "description": "DEPRECATED for detailed planning. Scalar percentage of audience coverage retained for backwards compatibility. When coverage_forecast is also present, this is the seller's total present-coverage estimate for the same signal.", + "description": "DEPRECATED for detailed planning. Optional legacy scalar percentage of audience coverage retained only as a fallback for clients that do not consume coverage_forecast. When coverage_forecast is present, coverage_forecast is authoritative for signal-level discovery and coverage_percentage is fallback-only.", "minimum": 0, "maximum": 100, "deprecated": true }, "coverage_forecast": { "$ref": "/schemas/core/signal-coverage-forecast.json", - "description": "Optional forecast-shaped signal availability guidance using the same wire shape as get_signals.signals[].coverage_forecast." + "description": "Optional forecast-shaped signal availability guidance using the same wire shape as get_signals.signals[].coverage_forecast. When present, this is authoritative for signal-level discovery coverage." }, "deployments": { "type": "array", @@ -611,7 +611,6 @@ "name", "description", "signal_type", - "coverage_percentage", "deployments" ], "anyOf": [ diff --git a/static/schemas/source/signals/get-signals-response.json b/static/schemas/source/signals/get-signals-response.json index bb9601a787..ace0eee5cb 100644 --- a/static/schemas/source/signals/get-signals-response.json +++ b/static/schemas/source/signals/get-signals-response.json @@ -86,14 +86,14 @@ }, "coverage_percentage": { "type": "number", - "description": "DEPRECATED for detailed planning. Scalar percentage of audience coverage retained for backwards compatibility. When coverage_forecast is also present, this is the seller's total present-coverage estimate for the same signal. If coverage_forecast includes an absent bucket over the same denominator, coverage_percentage SHOULD align with 100 * (1 - absent coverage_rate.mid).", + "description": "DEPRECATED for detailed planning. Optional legacy scalar percentage of audience coverage retained only as a fallback for clients that do not consume coverage_forecast. When coverage_forecast is present, coverage_forecast is authoritative for signal-level discovery and coverage_percentage is fallback-only. If coverage_forecast includes an absent bucket over the same denominator, coverage_percentage SHOULD align with 100 * (1 - absent coverage_rate.mid).", "minimum": 0, "maximum": 100, "deprecated": true }, "coverage_forecast": { "$ref": "/schemas/core/signal-coverage-forecast.json", - "description": "Optional forecast-shaped signal availability guidance. Use this to disclose the denominator, bucket semantics, not-present bucket, and per-value coverage distribution for the signal." + "description": "Optional forecast-shaped signal availability guidance. When present, this is authoritative for signal-level discovery coverage. Use this to disclose the denominator, bucket semantics, not-present bucket, aggregate present bucket, and per-value coverage distribution for the signal." }, "deployments": { "type": "array", @@ -116,7 +116,6 @@ "name", "description", "signal_type", - "coverage_percentage", "deployments" ], "anyOf": [ diff --git a/tests/composed-schema-validation.test.cjs b/tests/composed-schema-validation.test.cjs index b5b93b6e51..373fd13903 100644 --- a/tests/composed-schema-validation.test.cjs +++ b/tests/composed-schema-validation.test.cjs @@ -729,6 +729,8 @@ async function runTests() { { type: 'platform', platform: 'example_dsp', is_live: true } ] }; + const signalListingCoreWithoutLegacyCoverage = { ...signalListingCore }; + delete signalListingCoreWithoutLegacyCoverage.coverage_percentage; const signalCoverageForecast = { method: 'estimate', forecast_range_unit: 'availability', @@ -936,12 +938,12 @@ async function runTests() { applies_to: { scope: 'public' }, signal: { signal_id: legacySignalId, - ...signalListingCore, + ...signalListingCoreWithoutLegacyCoverage, coverage_forecast: signalCoverageForecast } } }, - 'Wholesale signal event accepts deprecated signal_id, relaxed data_provider/pricing_options, and coverage_forecast' + 'Wholesale signal event accepts deprecated signal_id, optional legacy coverage_percentage, relaxed data_provider/pricing_options, and coverage_forecast' ); log('Registry change feed schemas:', 'info'); diff --git a/tests/schema-validation.test.cjs b/tests/schema-validation.test.cjs index f73cb388dd..49271a2289 100644 --- a/tests/schema-validation.test.cjs +++ b/tests/schema-validation.test.cjs @@ -613,11 +613,15 @@ async function runTests() { if (result !== true) return result; } - const coverageRateOutOfRange = { - metrics: { coverage_rate: { mid: 1.2 } } - }; - let result = assertInvalid(validateForecastPoint, coverageRateOutOfRange, 'coverage_rate above 1.0'); - if (result !== true) return result; + let result; + for (const [value, label] of [ + [{ metrics: { coverage_rate: { mid: 1.2 } } }, 'coverage_rate mid above 1.0'], + [{ metrics: { coverage_rate: { low: 1.5, high: 2.0 } } }, 'coverage_rate low/high above 1.0'], + [{ metrics: { coverage_rate: { low: 0.2, high: 1.5 } } }, 'coverage_rate high above 1.0'] + ]) { + result = assertInvalid(validateForecastPoint, value, label); + if (result !== true) return result; + } const signalCoverageForecast = { method: 'estimate', @@ -655,6 +659,27 @@ async function runTests() { result = assertValid(validateSignalCoverageForecast, signalCoverageForecast, 'signal coverage forecast'); if (result !== true) return result; + result = assertValid( + validateSignalCoverageForecast, + { + ...signalCoverageForecast, + points: [ + { + label: 'present', + dimensions: [ + { kind: 'signal', signal_ref: { scope: 'data_provider', data_provider_domain: 'pinnacle-data.example', signal_id: 'weather' }, presence: 'present' } + ], + metrics: { + impressions: { mid: 720000 }, + coverage_rate: { mid: 0.72 } + } + } + ] + }, + 'signal coverage forecast present bucket without signal_value' + ); + if (result !== true) return result; + result = assertInvalid( validateSignalCoverageForecast, { ...signalCoverageForecast, points: [{ metrics: { coverage_rate: { mid: 0.12 } } }] }, @@ -761,9 +786,13 @@ async function runTests() { const completeExclusiveCoverageRatesPartition = (forecast) => { if (forecast.bucket_semantics !== 'exclusive' || forecast.bucket_completeness !== 'complete') return true; - const mids = forecast.points.map(point => point.metrics?.coverage_rate?.mid); - if (mids.some(value => typeof value !== 'number')) return false; - return Math.abs(mids.reduce((sum, value) => sum + value, 0) - 1) < 0.000001; + for (const key of ['low', 'mid', 'high']) { + const values = forecast.points.map(point => point.metrics?.coverage_rate?.[key]); + if (values.every(value => value === undefined)) continue; + if (values.some(value => typeof value !== 'number')) return false; + if (Math.abs(values.reduce((sum, value) => sum + value, 0) - 1) >= 0.000001) return false; + } + return true; }; if (!completeExclusiveCoverageRatesPartition({ @@ -788,6 +817,42 @@ async function runTests() { return 'complete exclusive coverage partition must have coverage_rate mid values summing to 1'; } + if (!completeExclusiveCoverageRatesPartition({ + ...signalCoverageForecast, + bucket_completeness: 'complete', + points: [ + { + ...signalCoverageForecast.points[0], + metrics: { coverage_rate: { low: 0.25, mid: 0.28, high: 0.3 } } + }, + { + ...signalCoverageForecast.points[1], + label: 'present', + metrics: { coverage_rate: { low: 0.75, mid: 0.72, high: 0.7 } } + } + ] + })) { + return 'complete exclusive coverage partition with low/mid/high rates summing to 1 was incorrectly flagged'; + } + + if (completeExclusiveCoverageRatesPartition({ + ...signalCoverageForecast, + bucket_completeness: 'complete', + points: [ + { + ...signalCoverageForecast.points[0], + metrics: { coverage_rate: { low: 0.25, mid: 0.28, high: 0.3 } } + }, + { + ...signalCoverageForecast.points[1], + label: 'present', + metrics: { coverage_rate: { low: 0.7, mid: 0.72, high: 0.7 } } + } + ] + })) { + return 'complete exclusive coverage partition must have coverage_rate low values summing to 1 when lows are supplied'; + } + result = assertValid( validateGetSignalsResponse, { @@ -804,13 +869,12 @@ async function runTests() { name: 'Weather', description: 'Weather context', signal_type: 'marketplace', - coverage_percentage: 72, coverage_forecast: signalCoverageForecast, deployments: [] } ] }, - 'get_signals response with coverage_forecast' + 'get_signals response with coverage_forecast and no legacy coverage_percentage' ); if (result !== true) return result;