From f76b07161a57d1bc970c5112a8f4e0703f89e2d7 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 31 Oct 2026 13:39:08 +0000 Subject: [PATCH 1/2] fix(canonical-formats): forward typed projection and narrowing repairs to next --- src/adcp/canonical_formats/narrowing.py | 76 ++++- src/adcp/canonical_formats/v1_to_v2.py | 7 +- src/adcp/canonical_formats/v2_to_v1.py | 30 ++ .../canonical-narrowing-vectors-3.2.7.json | 316 ++++++++++++++++++ tests/test_canonical_formats_narrowing.py | 84 +++++ tests/test_canonical_formats_v1_to_v2.py | 59 ++++ tests/test_canonical_formats_v2_to_v1.py | 61 ++++ 7 files changed, 630 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/canonical-narrowing-vectors-3.2.7.json diff --git a/src/adcp/canonical_formats/narrowing.py b/src/adcp/canonical_formats/narrowing.py index edbf8d657..fc9077f32 100644 --- a/src/adcp/canonical_formats/narrowing.py +++ b/src/adcp/canonical_formats/narrowing.py @@ -39,7 +39,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Literal +from typing import Any, Literal, TypeGuard from adcp.canonical_formats.advisory import _echo_identifier, make_sdk_advisory from adcp.types import Error, Format @@ -148,6 +148,7 @@ def to_dict(self) -> dict[str, Any]: "allowed_card_media_asset_types", "vast_versions", "daast_versions", + "pixel_ratios", ) _EXACT_FIELDS: tuple[str, ...] = ( "aspect_ratio", @@ -155,6 +156,15 @@ def to_dict(self) -> dict[str, Any]: "ssl_required", "vast_version", # singular form on video_vast.json "daast_version", # singular form on audio_daast.json + "width", + "height", + "duration_ms_exact", +) +_NUMERIC_EXACT_FIELDS = {"width", "height", "duration_ms_exact"} +_RANGE_FIELDS = ( + ("width", None, "width"), + ("height", None, "height"), + ("duration_ms", "duration_ms_range", "duration_ms_exact"), ) # Per-element cap when echoing seller-controlled allowed-set / declared- @@ -212,6 +222,35 @@ def _is_subset(v2: Any, v1: Any) -> bool: return all(any(item == allowed for allowed in v1) for item in v2) +def _numeric_pair(value: Any) -> TypeGuard[list[Any] | tuple[Any, Any]]: + return ( + isinstance(value, (list, tuple)) + and len(value) == 2 + and all(bound is None or _is_numeric(bound) for bound in value) + and any(_is_numeric(bound) for bound in value) + ) + + +def _interval( + source: dict[str, Any], base: str, range_field: str | None, exact_field: str +) -> tuple[Any, Any, bool, bool]: + # Exact duration wins over the range per video_hosted.json. A null range + # endpoint is explicitly unbounded; a missing bound inherits from v1. + exact = source.get(exact_field) + if _is_numeric(exact): + return exact, exact, True, True + value = source.get(range_field) if range_field is not None else source.get(base) + if _numeric_pair(value): + return value[0], value[1], True, True + minimum, maximum = source.get(f"min_{base}"), source.get(f"max_{base}") + return ( + minimum if _is_numeric(minimum) else None, + maximum if _is_numeric(maximum) else None, + _is_numeric(minimum), + _is_numeric(maximum), + ) + + def _echo_set(value: Any) -> list[Any]: """Cap a list/set value to ``_ECHO_SET_CAP`` items before echoing into details. @@ -253,6 +292,8 @@ def check_narrows( v2_value = v2.get(field_name) if v2_value is None: v2_value = v2.get(field_name.removeprefix("max_")) + if v2_value is None and field_name == "max_duration_ms": + v2_value = v2.get("duration_ms_exact") if v2_value is None: continue # The value-being-capped form: v2 ``width`` against v1 ``max_width`` @@ -269,6 +310,8 @@ def check_narrows( v2_value = v2.get(field_name) if v2_value is None: v2_value = v2.get(field_name.removeprefix("min_")) + if v2_value is None and field_name == "min_duration_ms": + v2_value = v2.get("duration_ms_exact") if v2_value is None: continue if _is_numeric(v2_value) and v2_value < v1_min: @@ -296,11 +339,42 @@ def check_narrows( v2_value = v2.get(field_name) if v1_value is None or v2_value is None: continue + if field_name in _NUMERIC_EXACT_FIELDS and not ( + _is_numeric(v1_value) and _is_numeric(v2_value) + ): + continue if v1_value != v2_value: divergences.append( Divergence(field=field_name, kind="not_equal", cap=v1_value, value=v2_value) ) + for base, range_field, exact_field in _RANGE_FIELDS: + v1_lower, v1_upper, _, _ = _interval(v1, base, range_field, exact_field) + v2_lower, v2_upper, has_lower, has_upper = _interval(v2, base, range_field, exact_field) + if _is_numeric(v2.get(exact_field)): + lower_field = upper_field = exact_field + elif range_field is not None and _numeric_pair(v2.get(range_field)): + lower_field = upper_field = range_field + else: + lower_field, upper_field = f"min_{base}", f"max_{base}" + related_fields = {base, range_field, exact_field, f"min_{base}", f"max_{base}"} + + def add_interval_divergence(field: str, kind: DivergenceKind, cap: Any, value: Any) -> None: + # Keep existing scalar/named-cap diagnostics without duplicates. + if not any( + d.field in related_fields + and ( + d.kind == "not_equal" or (d.kind == kind and d.cap == cap and d.value == value) + ) + for d in divergences + ): + divergences.append(Divergence(field=field, kind=kind, cap=cap, value=value)) + + if v1_lower is not None and has_lower and (v2_lower is None or v2_lower < v1_lower): + add_interval_divergence(lower_field, "below_min", v1_lower, v2_lower) + if v1_upper is not None and has_upper and (v2_upper is None or v2_upper > v1_upper): + add_interval_divergence(upper_field, "exceeds_max", v1_upper, v2_upper) + return divergences diff --git a/src/adcp/canonical_formats/v1_to_v2.py b/src/adcp/canonical_formats/v1_to_v2.py index c8e320515..45f975f5c 100644 --- a/src/adcp/canonical_formats/v1_to_v2.py +++ b/src/adcp/canonical_formats/v1_to_v2.py @@ -49,7 +49,7 @@ from dataclasses import dataclass, field from typing import Any -from pydantic import ValidationError +from pydantic import BaseModel, ValidationError from adcp.canonical_formats.advisory import _echo_identifier, make_sdk_advisory from adcp.canonical_formats.registry import ( @@ -173,10 +173,13 @@ def _v1_version_constraints( """Collect VAST/DAAST version values from a v1 format. The version may live at the top level (a flat catalog entry) or - inside per-asset requirements. Walks both. Returns an empty list + inside per-asset requirements. Walks both dictionary and typed-model + inputs, including root-model version lists. Returns an empty list when none are declared. """ out: list[str] = [] + if isinstance(v1_format, BaseModel): + v1_format = v1_format.model_dump(exclude_none=True) src = v1_format if isinstance(v1_format, dict) else None if src is None: return out diff --git a/src/adcp/canonical_formats/v2_to_v1.py b/src/adcp/canonical_formats/v2_to_v1.py index 5cdd2f6e4..d84454a54 100644 --- a/src/adcp/canonical_formats/v2_to_v1.py +++ b/src/adcp/canonical_formats/v2_to_v1.py @@ -36,10 +36,12 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass, field from typing import Any from adcp.canonical_formats.advisory import _echo_identifier, make_sdk_advisory +from adcp.canonical_formats.narrowing import narrowing_advisory from adcp.types import ( CanonicalFormatKind, Error, @@ -126,6 +128,7 @@ def project_declaration_to_v1( *, field_path: str = "format_options[]", product_id: str | None = None, + v1_requirements_lookup: Callable[[FormatId], dict[str, Any] | None] | None = None, ) -> V2ToV1Projection: """Project a single declaration to v1, emitting advisories per the resolution order documented at module level. @@ -138,6 +141,13 @@ def project_declaration_to_v1( callers wrapping a ``Product`` should pass the indexed form. product_id: Optional product identifier — surfaced in advisory ``details.product_id`` for buyer-side correlation. + v1_requirements_lookup: Optional synchronous lookup of the exact full + format reference in an operator-provisioned catalog snapshot. + Returned requirements are checked for narrowing; missing entries + (``None``) and an omitted lookup leave references unchecked, as + before. The SDK does not fetch a catalog or certify the lookup's + contents. Lookup exceptions propagate rather than hiding a failed + check. References are preserved even when an advisory is emitted. Returns: :class:`V2ToV1Projection` with the projected refs and any @@ -155,6 +165,21 @@ def project_declaration_to_v1( # Step 2: seller-asserted v1 link — emit refs, check multi-size fan-out. if refs: advisories: list[Error] = [] + if v1_requirements_lookup is not None: + for index, ref in enumerate(refs): + requirements = v1_requirements_lookup(ref) + if requirements is None: + continue + advisory = narrowing_advisory( + declaration, + v1_requirements=requirements, + v1_format_id=ref.id, + field_path=f"{field_path}.v1_format_ref[{index}]", + ) + if advisory is not None: + if product_id is not None and advisory.details is not None: + advisory.details["product_id"] = _echo_identifier(product_id) + advisories.append(advisory) sizes_n = _params_sizes_count(declaration) if sizes_n > len(refs): details: dict[str, Any] = { @@ -220,6 +245,7 @@ def project_product_to_v1( product: Any, *, product_index: int | None = None, + v1_requirements_lookup: Callable[[FormatId], dict[str, Any] | None] | None = None, ) -> V2ToV1Projection: """Project every ``format_options[]`` entry on a ``Product`` to v1. @@ -237,6 +263,9 @@ def project_product_to_v1( enclosing ``Products[]`` array. When provided, advisories carry the indexed field path (``products[N].format_options[K]``) so multi-product responses don't collapse to ambiguous pointers. + v1_requirements_lookup: Optional provisioned lookup forwarded to every + declaration. An omitted lookup or a missing catalog entry leaves + its references unchecked; see :func:`project_declaration_to_v1`. Returns: :class:`V2ToV1Projection` with the union of per-declaration results. @@ -255,6 +284,7 @@ def project_product_to_v1( decl, field_path=prefix, product_id=product_id, + v1_requirements_lookup=v1_requirements_lookup, ) out.format_ids.extend(result.format_ids) out.advisories.extend(result.advisories) diff --git a/tests/fixtures/canonical-narrowing-vectors-3.2.7.json b/tests/fixtures/canonical-narrowing-vectors-3.2.7.json new file mode 100644 index 000000000..a2df84949 --- /dev/null +++ b/tests/fixtures/canonical-narrowing-vectors-3.2.7.json @@ -0,0 +1,316 @@ +{ + "version": 3, + "registry": "/schemas/registries/v1-canonical-mapping.json", + "registry_version": "1.5.2", + "description": "Reference vectors for literal v1 format-id projection, seller-authored projection precedence, and the normative v2-narrows-v1 comparison. Resolution uses the first exact format_id_glob match only when no higher-priority seller canonical annotation exists; these vectors do not provide a format definition for structural fallback. Narrowing vectors express the registry-expanded v1 baseline as exact, range, or set constraints so every SDK can apply the same parameter-by-parameter subsumption rules.", + "vectors": [ + { + "id": "parameterized-display-image-template", + "format_id": "display_image", + "expected_v2": { + "canonical": "image", + "parameter_mappings": [ + { "source_field": "width", "target_parameter": "width" }, + { "source_field": "height", "target_parameter": "height" }, + { "source_field": "pixel_ratio", "target_parameter": "pixel_ratios", "transform": "singleton_array" } + ] + }, + "notes": "SDKs consume these machine-readable rules to copy width, height, and pixel_ratio from the full structured format_id." + }, + { + "id": "static-display-with-no-size-constraint", + "format_id": "display_static", + "expected_v2": { "canonical": "image" }, + "notes": "The missing width and height are intentional; the id declares no size constraint." + }, + { + "id": "display-300-by-250", + "format_id": "display_300x250", + "expected_v2": { "canonical": "image", "parameters": { "width": 300, "height": 250 } } + }, + { + "id": "display-728-by-90", + "format_id": "display_728x90", + "expected_v2": { "canonical": "image", "parameters": { "width": 728, "height": 90 } } + }, + { + "id": "display-320-by-50", + "format_id": "display_320x50", + "expected_v2": { "canonical": "image", "parameters": { "width": 320, "height": 50 } } + }, + { + "id": "display-300-by-600", + "format_id": "display_300x600", + "expected_v2": { "canonical": "image", "parameters": { "width": 300, "height": 600 } } + }, + { + "id": "display-160-by-600", + "format_id": "display_160x600", + "expected_v2": { "canonical": "image", "parameters": { "width": 160, "height": 600 } } + }, + { + "id": "display-970-by-250", + "format_id": "display_970x250", + "expected_v2": { "canonical": "image", "parameters": { "width": 970, "height": 250 } } + }, + { + "id": "display-250-by-250", + "format_id": "display_250x250", + "expected_v2": { "canonical": "image", "parameters": { "width": 250, "height": 250 } } + }, + { + "id": "display-200-by-200", + "format_id": "display_200x200", + "expected_v2": { "canonical": "image", "parameters": { "width": 200, "height": 200 } } + }, + { + "id": "display-300-by-50", + "format_id": "display_300x50", + "expected_v2": { "canonical": "image", "parameters": { "width": 300, "height": 50 } } + }, + { + "id": "display-320-by-480", + "format_id": "display_320x480", + "expected_v2": { "canonical": "image", "parameters": { "width": 320, "height": 480 } } + }, + { + "id": "display-320-by-400", + "format_id": "display_320x400", + "expected_v2": { "canonical": "image", "parameters": { "width": 320, "height": 400 } } + }, + { + "id": "display-320-by-320", + "format_id": "display_320x320", + "expected_v2": { "canonical": "image", "parameters": { "width": 320, "height": 320 } } + }, + { + "id": "display-320-by-250", + "format_id": "display_320x250", + "expected_v2": { "canonical": "image", "parameters": { "width": 320, "height": 250 } } + }, + { + "id": "display-300-by-250-2x", + "format_id": "display_300x250_image_2x", + "expected_v2": { "canonical": "image", "parameters": { "width": 300, "height": 250, "pixel_ratios": [2] } } + }, + { + "id": "display-728-by-90-2x", + "format_id": "display_728x90_image_2x", + "expected_v2": { "canonical": "image", "parameters": { "width": 728, "height": 90, "pixel_ratios": [2] } } + }, + { + "id": "display-320-by-50-2x", + "format_id": "display_320x50_image_2x", + "expected_v2": { "canonical": "image", "parameters": { "width": 320, "height": 50, "pixel_ratios": [2] } } + }, + { + "id": "display-160-by-600-2x", + "format_id": "display_160x600_image_2x", + "expected_v2": { "canonical": "image", "parameters": { "width": 160, "height": 600, "pixel_ratios": [2] } } + }, + { + "id": "display-336-by-280-2x", + "format_id": "display_336x280_image_2x", + "expected_v2": { "canonical": "image", "parameters": { "width": 336, "height": 280, "pixel_ratios": [2] } } + }, + { + "id": "display-300-by-600-2x", + "format_id": "display_300x600_image_2x", + "expected_v2": { "canonical": "image", "parameters": { "width": 300, "height": 600, "pixel_ratios": [2] } } + }, + { + "id": "display-970-by-250-2x", + "format_id": "display_970x250_image_2x", + "expected_v2": { "canonical": "image", "parameters": { "width": 970, "height": 250, "pixel_ratios": [2] } } + }, + { + "id": "display-300-by-250-1x-2x", + "format_id": "display_300x250_image_1x_2x", + "expected_v2": { "canonical": "image", "parameters": { "width": 300, "height": 250, "pixel_ratios": [1, 2], "slots": [{ "asset_group_id": "image_main", "asset_type": "image", "required": true, "min": 2, "max": 2, "pixel_ratios": [1, 2], "required_pixel_ratios": [1, 2] }] } } + }, + { + "id": "display-728-by-90-1x-2x", + "format_id": "display_728x90_image_1x_2x", + "expected_v2": { "canonical": "image", "parameters": { "width": 728, "height": 90, "pixel_ratios": [1, 2], "slots": [{ "asset_group_id": "image_main", "asset_type": "image", "required": true, "min": 2, "max": 2, "pixel_ratios": [1, 2], "required_pixel_ratios": [1, 2] }] } } + }, + { + "id": "display-320-by-50-1x-2x", + "format_id": "display_320x50_image_1x_2x", + "expected_v2": { "canonical": "image", "parameters": { "width": 320, "height": 50, "pixel_ratios": [1, 2], "slots": [{ "asset_group_id": "image_main", "asset_type": "image", "required": true, "min": 2, "max": 2, "pixel_ratios": [1, 2], "required_pixel_ratios": [1, 2] }] } } + }, + { + "id": "display-160-by-600-1x-2x", + "format_id": "display_160x600_image_1x_2x", + "expected_v2": { "canonical": "image", "parameters": { "width": 160, "height": 600, "pixel_ratios": [1, 2], "slots": [{ "asset_group_id": "image_main", "asset_type": "image", "required": true, "min": 2, "max": 2, "pixel_ratios": [1, 2], "required_pixel_ratios": [1, 2] }] } } + }, + { + "id": "display-336-by-280-1x-2x", + "format_id": "display_336x280_image_1x_2x", + "expected_v2": { "canonical": "image", "parameters": { "width": 336, "height": 280, "pixel_ratios": [1, 2], "slots": [{ "asset_group_id": "image_main", "asset_type": "image", "required": true, "min": 2, "max": 2, "pixel_ratios": [1, 2], "required_pixel_ratios": [1, 2] }] } } + }, + { + "id": "display-300-by-600-1x-2x", + "format_id": "display_300x600_image_1x_2x", + "expected_v2": { "canonical": "image", "parameters": { "width": 300, "height": 600, "pixel_ratios": [1, 2], "slots": [{ "asset_group_id": "image_main", "asset_type": "image", "required": true, "min": 2, "max": 2, "pixel_ratios": [1, 2], "required_pixel_ratios": [1, 2] }] } } + }, + { + "id": "display-970-by-250-1x-2x", + "format_id": "display_970x250_image_1x_2x", + "expected_v2": { "canonical": "image", "parameters": { "width": 970, "height": 250, "pixel_ratios": [1, 2], "slots": [{ "asset_group_id": "image_main", "asset_type": "image", "required": true, "min": 2, "max": 2, "pixel_ratios": [1, 2], "required_pixel_ratios": [1, 2] }] } } + }, + { + "id": "hosted-video-30-seconds", + "format_id": "video_30s", + "expected_v2": { "canonical": "video_hosted", "parameters": { "duration_ms_exact": 30000 } } + }, + { + "id": "hosted-video-15-seconds", + "format_id": "video_15s", + "expected_v2": { "canonical": "video_hosted", "parameters": { "duration_ms_exact": 15000 } } + }, + { + "id": "pre-roll-video-30-seconds", + "format_id": "video_pre_roll_30s", + "expected_v2": { "canonical": "video_hosted", "parameters": { "duration_ms_exact": 30000 } } + }, + { + "id": "pre-roll-video-15-seconds", + "format_id": "video_pre_roll_15s", + "expected_v2": { "canonical": "video_hosted", "parameters": { "duration_ms_exact": 15000 } } + }, + { + "id": "hosted-video-640-by-480", + "format_id": "video_640x480", + "expected_v2": { "canonical": "video_hosted", "parameters": { "width": 640, "height": 480 } } + }, + { + "id": "hosted-video-300-by-250", + "format_id": "video_300x250", + "expected_v2": { "canonical": "video_hosted", "parameters": { "width": 300, "height": 250 } } + }, + { + "id": "hosted-video-16-to-9-30-seconds", + "format_id": "video_16x9_30s", + "expected_v2": { "canonical": "video_hosted", "parameters": { "aspect_ratio": "16:9", "duration_ms_exact": 30000 } } + }, + { + "id": "vast-token-suffix-640-by-360", + "format_id": "video_640x360_vast", + "expected_v2": { "canonical": "video_vast", "parameters": { "width": 640, "height": 360 } } + }, + { + "id": "hosted-audio-15-seconds", + "format_id": "audio_15s", + "expected_v2": { "canonical": "audio_hosted", "parameters": { "duration_ms_exact": 15000 } } + }, + { + "id": "durationless-pre-roll-fails-closed", + "format_id": "video_pre_roll", + "expected_v2": null, + "expected_outcome": "no_literal_match", + "notes": "Deliberately omitted because video_hosted without duration_ms_exact is untraffickable." + }, + { + "id": "durationless-mid-roll-fails-closed", + "format_id": "video_mid_roll", + "expected_v2": null, + "expected_outcome": "no_literal_match", + "notes": "Deliberately omitted because video_hosted without duration_ms_exact is untraffickable." + } + ], + "resolution_precedence_vectors": [ + { + "id": "seller-slot-override-replaces-registry-retina-defaults", + "format_id": "display_300x250_image_1x_2x", + "requirements": { "width": 300, "height": 250 }, + "seller_canonical": { + "kind": "image", + "asset_source": "agent_synthesized", + "slots_override": [ + { "asset_group_id": "generation_prompt", "asset_type": "text", "required": true } + ] + }, + "expected_v2": { + "canonical": "image", + "parameters": { + "width": 300, + "height": 250, + "asset_source": "agent_synthesized", + "slots": [ + { "asset_group_id": "generation_prompt", "asset_type": "text", "required": true } + ] + } + }, + "ignored_registry_parameters": ["pixel_ratios", "slots"], + "notes": "The seller canonical annotation is selected before the matching registry literal. Requirements still project, but registry pixel-density and rendition-slot defaults MUST NOT merge into the seller-authored declaration." + } + ], + "narrowing_vectors": [ + { + "id": "scalar-inside-v1-range-narrows", + "v1_baseline": { "width": { "kind": "range", "min": 200, "max": 400 } }, + "v2_params": { "width": 300 }, + "expected": { "narrows": true } + }, + { + "id": "scalar-outside-v1-range-conflicts", + "v1_baseline": { "width": { "kind": "range", "min": 200, "max": 400 } }, + "v2_params": { "width": 500 }, + "expected": { "narrows": false, "conflict": "width" } + }, + { + "id": "enum-subset-narrows", + "v1_baseline": { "image_formats": { "kind": "set", "values": ["jpg", "png", "gif", "webp"] } }, + "v2_params": { "image_formats": ["jpg", "png"] }, + "expected": { "narrows": true } + }, + { + "id": "enum-value-outside-v1-set-conflicts", + "v1_baseline": { "image_formats": { "kind": "set", "values": ["jpg", "png"] } }, + "v2_params": { "image_formats": ["jpg", "webp"] }, + "expected": { "narrows": false, "conflict": "image_formats" } + }, + { + "id": "pixel-density-subset-narrows", + "v1_baseline": { "pixel_ratios": { "kind": "set", "values": [1, 1.5, 2] } }, + "v2_params": { "pixel_ratios": [1, 2] }, + "expected": { "narrows": true } + }, + { + "id": "pixel-density-outside-v1-set-conflicts", + "v1_baseline": { "pixel_ratios": { "kind": "set", "values": [1, 2] } }, + "v2_params": { "pixel_ratios": [1, 3] }, + "expected": { "narrows": false, "conflict": "pixel_ratios" } + }, + { + "id": "contained-range-narrows", + "v1_baseline": { "duration_ms_range": { "kind": "range", "min": 3000, "max": 90000 } }, + "v2_params": { "duration_ms_range": [5000, 30000] }, + "expected": { "narrows": true } + }, + { + "id": "range-escaping-v1-upper-bound-conflicts", + "v1_baseline": { "duration_ms_range": { "kind": "range", "min": 3000, "max": 90000 } }, + "v2_params": { "duration_ms_range": [5000, 120000] }, + "expected": { "narrows": false, "conflict": "duration_ms_range" } + }, + { + "id": "absent-v2-parameter-inherits-v1", + "v1_baseline": { "width": { "kind": "exact", "value": 300 } }, + "v2_params": {}, + "expected": { "narrows": true } + }, + { + "id": "v2-constraint-against-open-v1-baseline-narrows", + "v1_baseline": {}, + "v2_params": { "image_formats": ["jpg"] }, + "expected": { "narrows": true } + }, + { + "id": "exact-v1-value-mismatch-conflicts", + "v1_baseline": { "width": { "kind": "exact", "value": 300 } }, + "v2_params": { "width": 320 }, + "expected": { "narrows": false, "conflict": "width" } + } + ] +} diff --git a/tests/test_canonical_formats_narrowing.py b/tests/test_canonical_formats_narrowing.py index cfde2e17e..12a497df1 100644 --- a/tests/test_canonical_formats_narrowing.py +++ b/tests/test_canonical_formats_narrowing.py @@ -8,12 +8,96 @@ from __future__ import annotations +import json +from pathlib import Path + import pytest from adcp.canonical_formats import check_narrows, narrowing_advisory from adcp.canonical_formats.advisory import SDK_ID from adcp.types import CanonicalFormatKind, Format +# Unmodified protocol v3.2.7 static/test-vectors/v1-canonical-mapping.json. +_NARROWING_VECTORS = json.loads( + (Path(__file__).parent / "fixtures/canonical-narrowing-vectors-3.2.7.json").read_text() +)["narrowing_vectors"] + + +@pytest.mark.parametrize("vector", _NARROWING_VECTORS, ids=lambda vector: vector["id"]) +def test_published_narrowing_vector(vector: dict) -> None: + requirements = {} + for field, constraint in vector["v1_baseline"].items(): + if constraint["kind"] == "exact": + requirements[field] = constraint["value"] + elif constraint["kind"] == "set": + requirements[field] = constraint["values"] + elif field.endswith("_range"): + requirements[field] = [constraint["min"], constraint["max"]] + else: + requirements[f"min_{field}"] = constraint["min"] + requirements[f"max_{field}"] = constraint["max"] + divergences = check_narrows(vector["v2_params"], requirements) + assert (not divergences) is vector["expected"]["narrows"] + if divergences: + fields = {d.field.removeprefix("min_").removeprefix("max_") for d in divergences} + assert vector["expected"]["conflict"] in fields + + +@pytest.mark.parametrize( + ("params", "requirements", "kind"), + [ + ( + {"duration_ms_range": [5000, 120000]}, + {"min_duration_ms": 3000, "max_duration_ms": 90000}, + "exceeds_max", + ), + ({"duration_ms_range": [1000, 30000]}, {"duration_ms_range": [3000, 90000]}, "below_min"), + ({"duration_ms_exact": 120000}, {"duration_ms_range": [3000, 90000]}, "exceeds_max"), + ( + {"duration_ms_exact": 120000}, + {"min_duration_ms": 3000, "max_duration_ms": 90000}, + "exceeds_max", + ), + ({"min_width": 200, "max_width": 400}, {"width": 300}, "exceeds_max"), + ({"min_width": 200, "max_width": 400}, {"width": 300}, "below_min"), + ({"height": 240}, {"height": 250}, "not_equal"), + ( + {"duration_ms_range": [None, 120000]}, + {"duration_ms_range": [3000, 90000]}, + "exceeds_max", + ), + ({"duration_ms_range": [None, 30000]}, {"duration_ms_range": [3000, 90000]}, "below_min"), + ({"duration_ms_range": [5000, None]}, {"duration_ms_range": [3000, 90000]}, "exceeds_max"), + ( + {"duration_ms_exact": 120000, "duration_ms_range": [5000, 60000]}, + {"duration_ms_range": [3000, 90000]}, + "exceeds_max", + ), + ], +) +def test_range_and_exact_constraint_conflicts(params: dict, requirements: dict, kind: str) -> None: + assert any(d.kind == kind for d in check_narrows(params, requirements)) + + +@pytest.mark.parametrize( + ("params", "requirements"), + [ + ({"duration_ms_range": [3000, 90000]}, {"min_duration_ms": 3000, "max_duration_ms": 90000}), + ({"duration_ms_exact": 5000}, {"duration_ms_range": [3000, 90000]}), + ({"width": 300}, {"width": 300}), + ({"min_width": 300, "max_width": 300}, {"width": 300}), + ({"duration_ms_range": [None, 30000]}, {"duration_ms_range": [None, 90000]}), + ({"duration_ms_range": [5000, None]}, {"duration_ms_range": [3000, None]}), + ( + {"duration_ms_exact": 5000, "duration_ms_range": [1000, 120000]}, + {"duration_ms_range": [3000, 90000]}, + ), + ], +) +def test_equal_or_contained_constraints_narrow(params: dict, requirements: dict) -> None: + assert check_narrows(params, requirements) == [] + + # --------------------------------------------------------------------------- # check_narrows — no divergence cases # --------------------------------------------------------------------------- diff --git a/tests/test_canonical_formats_v1_to_v2.py b/tests/test_canonical_formats_v1_to_v2.py index 660186f8c..7734a1676 100644 --- a/tests/test_canonical_formats_v1_to_v2.py +++ b/tests/test_canonical_formats_v1_to_v2.py @@ -18,6 +18,7 @@ ) from adcp.canonical_formats.advisory import SDK_ID from adcp.types import CanonicalFormatKind +from adcp.types.legacy import LegacyFormat _FIXTURES = Path(__file__).parent / "fixtures" / "canonical" @@ -247,6 +248,64 @@ def test_retina_rendition_set_registry_mapping_preserves_slot_contract( # --------------------------------------------------------------------------- +@pytest.mark.parametrize( + ("asset_type", "version_key", "version", "expected_kind"), + [ + ("vast", "vast_version", "4.2", CanonicalFormatKind.video_vast), + ("vast", "vast_version", "3.0", CanonicalFormatKind.video_vast), + ("vast", "vast_version", "2.0", CanonicalFormatKind.video_vast), + ("daast", "daast_version", "1.0", CanonicalFormatKind.audio_daast), + ("daast", "daast_versions", ["1.0", "1.1"], CanonicalFormatKind.audio_daast), + ], +) +def test_typed_versioned_format_matches_dict_projection( + asset_type: str, + version_key: str, + version: str | list[str], + expected_kind: CanonicalFormatKind, +) -> None: + raw = { + "format_id": {"agent_url": "https://creative.example", "id": "acme_preroll"}, + "name": "Acme preroll", + "assets": [ + { + "item_type": "individual", + "asset_id": "media", + "asset_type": asset_type, + "required": True, + "requirements": {version_key: version}, + } + ], + } + typed = LegacyFormat.model_validate(raw) + from_dict = project_v1_format_to_declaration(raw) + from_model = project_v1_format_to_declaration(typed) + assert from_dict.declaration is not None + assert from_dict.declaration.format_kind == expected_kind + assert from_model == from_dict + + +@pytest.mark.parametrize("asset_type", ["vast", "daast"]) +def test_typed_versionless_format_still_fails_closed(asset_type: str) -> None: + raw = { + "format_id": {"agent_url": "https://creative.example", "id": "acme_preroll"}, + "name": "Acme preroll", + "assets": [ + { + "item_type": "individual", + "asset_id": "media", + "asset_type": asset_type, + "required": True, + "requirements": {}, + } + ], + } + from_dict = project_v1_format_to_declaration(raw) + from_model = project_v1_format_to_declaration(LegacyFormat.model_validate(raw)) + assert from_dict.declaration is None + assert from_model == from_dict + + def test_structural_match_emits_ambiguous_advisory() -> None: v1 = { "format_id": {"agent_url": "https://x.example", "id": "weird_vast_format"}, diff --git a/tests/test_canonical_formats_v2_to_v1.py b/tests/test_canonical_formats_v2_to_v1.py index 62b66edde..44ca513fc 100644 --- a/tests/test_canonical_formats_v2_to_v1.py +++ b/tests/test_canonical_formats_v2_to_v1.py @@ -8,6 +8,8 @@ from __future__ import annotations +from types import SimpleNamespace + import pytest from adcp.canonical_formats import ( @@ -34,6 +36,65 @@ def _format_schema() -> dict[str, str]: } +def test_seller_refs_check_each_exact_reference_with_operator_lookup() -> None: + refs = [ + _ref("acme_banner"), + FormatId(agent_url="https://other-creative.example", id="acme_banner"), + ] + declaration = Format( + format_kind=CanonicalFormatKind.image, params={"width": 320}, v1_format_ref=refs + ) + looked_up = [] + + def lookup(ref: FormatId) -> dict: + looked_up.append(ref) + return {"width": 300 if ref.agent_url == refs[0].agent_url else 320} + + result = project_declaration_to_v1(declaration, v1_requirements_lookup=lookup) + assert looked_up == refs + assert ( + result.format_ids == refs + ), "advisory emission does not discard seller-authored references" + assert len(result.advisories) == 1 + assert result.advisories[0].code == "FORMAT_DECLARATION_DIVERGENT" + assert result.advisories[0].field == "format_options[].v1_format_ref[0]" + + +def test_product_projection_threads_lookup_and_advisory_location() -> None: + declaration = Format( + format_kind=CanonicalFormatKind.image, params={"width": 320}, v1_format_ref=[_ref()] + ) + product = SimpleNamespace(product_id="acme_display", format_options=[declaration]) + result = project_product_to_v1( + product, product_index=2, v1_requirements_lookup=lambda ref: {"width": 300} + ) + assert len(result.advisories) == 1 + assert result.advisories[0].field == "products[2].format_options[0].v1_format_ref[0]" + assert result.advisories[0].details["product_id"] == "acme_display" + + +def test_missing_requirements_preserve_unchecked_reference_behavior() -> None: + declaration = Format( + format_kind=CanonicalFormatKind.image, params={"width": 320}, v1_format_ref=[_ref()] + ) + assert project_declaration_to_v1(declaration).advisories == [] + result = project_declaration_to_v1(declaration, v1_requirements_lookup=lambda ref: None) + assert result.format_ids == list(declaration.legacy_format_refs) + assert result.advisories == [] + + +def test_requirement_lookup_failure_is_not_silenced() -> None: + declaration = Format( + format_kind=CanonicalFormatKind.image, params={"width": 320}, v1_format_ref=[_ref()] + ) + + def unavailable(ref: FormatId) -> dict: + raise ValueError("catalog snapshot unavailable") + + with pytest.raises(ValueError, match="catalog snapshot unavailable"): + project_declaration_to_v1(declaration, v1_requirements_lookup=unavailable) + + # --------------------------------------------------------------------------- # Step 1 — explicit v1-unreachability is silent (no refs, no advisories) # --------------------------------------------------------------------------- From 656cfed06e9ba9f98a2f17135c30c9a1b7047ec5 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 31 Oct 2026 16:18:14 +0000 Subject: [PATCH 2/2] test(a2a): isolate context ID handler registration --- tests/integration/test_a2a_context_id.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/integration/test_a2a_context_id.py b/tests/integration/test_a2a_context_id.py index 83551b926..28c7fbca6 100644 --- a/tests/integration/test_a2a_context_id.py +++ b/tests/integration/test_a2a_context_id.py @@ -60,7 +60,7 @@ ) -class _EchoHandler(ADCPHandler): +class _ContextIdEchoHandler(ADCPHandler): """Minimal handler for the happy-path tests — the assertions are at the protocol layer, not the handler. Returns spec-compliant empty payloads so the client's strict response validator passes.""" @@ -195,7 +195,7 @@ async def test_two_calls_share_server_assigned_context_id(): assertion that calls[0] == calls[1] catches that regression.""" observer = _Observer() wire_messages: list[dict[str, Any]] = [] - async with _running_server(_EchoHandler(), observer) as base_url: + async with _running_server(_ContextIdEchoHandler(), observer) as base_url: config = AgentConfig( id="ctx-test-agent", agent_uri=base_url, @@ -237,7 +237,7 @@ async def test_reset_context_produces_new_server_side_session(): different server-side context than the one before. If reset were a no-op the two server-observed ids would match and this would fire.""" observer = _Observer() - async with _running_server(_EchoHandler(), observer) as base_url: + async with _running_server(_ContextIdEchoHandler(), observer) as base_url: config = AgentConfig( id="ctx-test-agent", agent_uri=base_url, @@ -262,7 +262,7 @@ async def test_seeded_context_id_reaches_server_on_first_call(): state lands on the same server-side session.""" seed = f"buyer-seeded-{uuid4()}" observer = _Observer() - async with _running_server(_EchoHandler(), observer) as base_url: + async with _running_server(_ContextIdEchoHandler(), observer) as base_url: config = AgentConfig( id="ctx-test-agent", agent_uri=base_url, @@ -505,7 +505,7 @@ async def test_resume_across_simulated_restart_lands_on_same_session(): with the persisted id, and its first call must carry that id so the server can reattach it to the original session.""" observer = _Observer() - async with _running_server(_EchoHandler(), observer) as base_url: + async with _running_server(_ContextIdEchoHandler(), observer) as base_url: config = AgentConfig( id="ctx-test-agent", agent_uri=base_url,