Skip to content

Commit 7f4c622

Browse files
bokelleyclaude
andcommitted
docs(proposals): Phase 1B harness — Q2 falsification (recipe shape)
Adds examples/recipe_falsification/ — the pytest harness for Q2 of the salesagent side-car experiment (PR #506): > Does the recipe shape carry GAM's implementation_config without > escape hatches? Files: - gam_recipe.py — typed Pydantic GAMRecipe model with sub-models (CreativePlaceholder, FrequencyCap), Literal-typed enums for every documented GAM API value, extra="forbid" on every model - fixtures/gam_impl_config_examples.json — five fixture shapes derived from salesagent's GAMProductConfigService: guaranteed_default, non_guaranteed_default, video_with_targeting, native_with_discount, minimal - test_recipe_round_trip.py — runs the four pre-registered Q2 falsifiers from PR #506: (a) any extra: dict[str, Any] field (b) any # type: ignore needed to construct (c) lossy round-trip dict → recipe → dict extra-forbid: smuggled fields rejected - README.md — what's here, how to run, results, caveats Result: 8 tests pass. All Q2 falsifiers refuse to fire. - Round-trip is lossless across all 5 fixture shapes - Zero Any-typed fields; only dict-typed field is custom_targeting_keys typed strictly as dict[str, str | list[str]] per GAM's API contract, NOT an escape hatch - Direct construction with sub-models needs no # type: ignore - Unknown fields are rejected by extra="forbid" Q2 prior holds: a typed Pydantic recipe carries the full GAM implementation_config shape without escape hatches. Combined with Q1.5 (Phase 1A — recipe is adopter-owned, not framework-managed; corrected in this PR's revision of #502), the architecture story is now: - Recipe is typed at framework boundary (Q2 confirmed) - Recipe storage is adopter-owned (Q1.5 confirmed) - Framework's job: type the contract, route transitions, dispatch Caveats documented in README: - Fixtures derived from service code paths, not production DB dumps; dev-DB validation pass would tighten the result - custom_targeting_keys typing follows GAM's documented API; deeper- nested salesagent data would reject (correct against GAM, may surface migration edges) - Literal[...] enums need versioning when GAM adds enum values Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 06245b3 commit 7f4c622

5 files changed

Lines changed: 521 additions & 0 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# Phase 1B — recipe shape falsification (Q2)
2+
3+
The harness for **Q2** of the [salesagent side-car experiment](../../docs/proposals/salesagent-sidecar-experiment.md):
4+
5+
> Does the recipe shape carry GAM's `implementation_config` without escape hatches?
6+
7+
## What's here
8+
9+
- **`gam_recipe.py`** — typed `GAMRecipe` Pydantic model, constructed from
10+
every field salesagent's
11+
[`gam_product_config_service.py`](https://github.com/prebid/salesagent/blob/main/src/services/gam_product_config_service.py)
12+
generates, validates, or parses. `extra="forbid"` on every model.
13+
14+
- **`fixtures/gam_impl_config_examples.json`** — five recorded shapes
15+
derived from `GAMProductConfigService.generate_default_config()` and
16+
`parse_form_config()`:
17+
- `guaranteed_default` — auto-generated guaranteed-mode defaults
18+
- `non_guaranteed_default` — auto-generated non-guaranteed defaults
19+
- `video_with_targeting` — full video config with inventory targeting,
20+
frequency caps, custom targeting, video duration limits
21+
- `native_with_discount` — native ad with percentage discount + style id
22+
- `minimal` — bare-minimum config (only `validate_config()`-required fields)
23+
24+
- **`test_recipe_round_trip.py`** — pytest harness running the
25+
pre-registered Q2 falsifiers from PR #506:
26+
- `(c)` lossy round-trip: `dict → GAMRecipe → dict` not equal
27+
- `(a)` any `extra: dict[str, Any]` field on the recipe
28+
- `(b)` any `# type: ignore` needed to construct
29+
- extra-forbid: smuggled fields fail validation
30+
31+
## Run
32+
33+
```bash
34+
python3 -m pytest examples/recipe_falsification/ -v
35+
```
36+
37+
## Result (current commit)
38+
39+
```
40+
8 passed in 0.18s
41+
```
42+
43+
**All Q2 falsifiers refused to fire.**
44+
45+
- Round-trip is lossless across all five fixture shapes
46+
- Zero `Any`-typed fields on the recipe (the only dict-typed field —
47+
`custom_targeting_keys: dict[str, str | list[str]]` — is strict
48+
typing matching GAM's documented API, not an escape hatch)
49+
- Direct construction with sub-models needs no `# type: ignore`
50+
- Unknown fields are rejected (`extra="forbid"`)
51+
52+
## What this confirms
53+
54+
The Q2 prior held: **a typed Pydantic recipe can carry the full GAM
55+
`implementation_config` shape without escape hatches**, given the
56+
field set salesagent's service code generates and parses.
57+
58+
Combined with Phase 1A's Q1.5 finding (recipe is adopter-owned, not
59+
framework-managed — see [PR #507](https://github.com/adcontextprotocol/adcp-client-python/pull/507)),
60+
the architecture story is now:
61+
62+
* **Recipe is typed at the framework boundary** (Q2 confirmed) —
63+
`recipe_type: ClassVar[type[Recipe]]` on `DecisioningPlatform`
64+
validates shape at dispatch.
65+
* **Recipe storage is adopter-owned** (Q1.5 confirmed) — the framework
66+
doesn't manage session caches, persistence on `finalize`, or
67+
hydration at `create_media_buy`.
68+
69+
The framework's job is small and focused: type the contract, route
70+
transitions, dispatch typed recipes. Storage lives where it always
71+
lived in salesagent's case.
72+
73+
## Caveats
74+
75+
* **Fixtures are derived from `gam_product_config_service.py`'s code
76+
paths**, not pulled from a production salesagent database. Real
77+
deployments might have field combinations the service code
78+
doesn't currently produce. To fully validate, dump actual
79+
`Product.implementation_config` JSON from a salesagent dev DB and
80+
rerun the harness; if any payload fails to round-trip, that's a
81+
finding that revises `GAMRecipe`.
82+
* **`custom_targeting_keys: dict[str, str | list[str]]`** is the
83+
borderline case. Strict typing per GAM's documented API. If
84+
salesagent's own data has deeper-nested values (e.g., admin UI
85+
edge cases) they'd reject — that's "right" against GAM's API
86+
but might bite migrations from existing data. Document the
87+
contract clearly when promoting to a real `GAMPlatform`.
88+
* **The set of fields could grow.** If GAM adds a new line-item type,
89+
cost type, or environment, the `Literal[...]` enums need updating.
90+
Strict typing means versioning the recipe explicitly.
91+
92+
## Where this fits
93+
94+
This harness lives in `examples/` because the recipe model is
95+
adopter-shaped, not SDK-shipped. When the SDK formalizes a `Recipe`
96+
base class (likely `recipe_kind` discriminated union per #502 Path B),
97+
adopters subclass it like this. The example here demonstrates the
98+
pattern with GAM as the worked case.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Phase 1B recipe falsification harness — see README.md."""
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
{
2+
"_source": "salesagent/src/services/gam_product_config_service.py — generate_default_config() outputs + parse_form_config() field set",
3+
"_q2_question": "Does GAMRecipe carry every shape salesagent's service produces without escape hatches?",
4+
5+
"guaranteed_default": {
6+
"_label": "generate_default_config('guaranteed', formats=['display_300x250'])",
7+
"cost_type": "CPM",
8+
"creative_rotation_type": "EVEN",
9+
"primary_goal_unit_type": "IMPRESSIONS",
10+
"include_descendants": true,
11+
"line_item_type": "STANDARD",
12+
"priority": 6,
13+
"primary_goal_type": "DAILY",
14+
"delivery_rate_type": "EVENLY",
15+
"non_guaranteed_automation": "manual",
16+
"creative_placeholders": [
17+
{
18+
"width": 300,
19+
"height": 250,
20+
"expected_creative_count": 1,
21+
"is_native": false
22+
}
23+
]
24+
},
25+
26+
"non_guaranteed_default": {
27+
"_label": "generate_default_config('non_guaranteed', formats=['display_728x90', 'display_300x250'])",
28+
"cost_type": "CPM",
29+
"creative_rotation_type": "OPTIMIZED",
30+
"primary_goal_unit_type": "IMPRESSIONS",
31+
"include_descendants": true,
32+
"line_item_type": "PRICE_PRIORITY",
33+
"priority": 10,
34+
"primary_goal_type": "NONE",
35+
"delivery_rate_type": "AS_FAST_AS_POSSIBLE",
36+
"non_guaranteed_automation": "confirmation_required",
37+
"creative_placeholders": [
38+
{
39+
"width": 728,
40+
"height": 90,
41+
"expected_creative_count": 1,
42+
"is_native": false
43+
},
44+
{
45+
"width": 300,
46+
"height": 250,
47+
"expected_creative_count": 1,
48+
"is_native": false
49+
}
50+
]
51+
},
52+
53+
"video_with_targeting": {
54+
"_label": "Full video config with inventory targeting + frequency caps + custom targeting",
55+
"line_item_type": "SPONSORSHIP",
56+
"priority": 4,
57+
"cost_type": "CPM",
58+
"creative_rotation_type": "MANUAL",
59+
"delivery_rate_type": "FRONTLOADED",
60+
"primary_goal_type": "DAILY",
61+
"primary_goal_unit_type": "VIEWABLE_IMPRESSIONS",
62+
"non_guaranteed_automation": "manual",
63+
"targeted_ad_unit_ids": ["12345678", "12345679"],
64+
"targeted_placement_ids": ["pl_001", "pl_002"],
65+
"include_descendants": true,
66+
"creative_placeholders": [
67+
{"width": 640, "height": 480, "expected_creative_count": 2, "is_native": false}
68+
],
69+
"frequency_caps": [
70+
{"max_impressions": 3, "time_unit": "DAY", "time_range": 1},
71+
{"max_impressions": 10, "time_unit": "WEEK", "time_range": 1}
72+
],
73+
"competitive_exclusion_labels": ["auto_brands", "tobacco"],
74+
"custom_targeting_keys": {
75+
"intent": "auto",
76+
"demo": ["18-24", "25-34", "35-44"],
77+
"geo_metro": "501"
78+
},
79+
"environment_type": "VIDEO_PLAYER",
80+
"allow_overbook": false,
81+
"skip_inventory_check": false,
82+
"disable_viewability_avg_revenue_optimization": false,
83+
"companion_delivery_option": "AT_LEAST_ONE",
84+
"video_max_duration": 30000,
85+
"skip_offset": 5000,
86+
"applied_team_ids": ["team_001"]
87+
},
88+
89+
"native_with_discount": {
90+
"_label": "Native ad with discount + style id",
91+
"line_item_type": "STANDARD",
92+
"priority": 8,
93+
"cost_type": "CPM",
94+
"creative_rotation_type": "EVEN",
95+
"delivery_rate_type": "EVENLY",
96+
"primary_goal_type": "DAILY",
97+
"primary_goal_unit_type": "IMPRESSIONS",
98+
"non_guaranteed_automation": "automatic",
99+
"include_descendants": true,
100+
"creative_placeholders": [
101+
{"width": 1, "height": 1, "expected_creative_count": 1, "is_native": true}
102+
],
103+
"discount_type": "PERCENTAGE",
104+
"discount_value": 15.0,
105+
"environment_type": "BROWSER",
106+
"native_style_id": "native_style_42",
107+
"allow_overbook": true,
108+
"skip_inventory_check": false,
109+
"disable_viewability_avg_revenue_optimization": false
110+
},
111+
112+
"minimal": {
113+
"_label": "Bare-minimum config — only required fields per validate_config()",
114+
"line_item_type": "PRICE_PRIORITY",
115+
"priority": 10,
116+
"creative_placeholders": [
117+
{"width": 300, "height": 250, "expected_creative_count": 1, "is_native": false}
118+
]
119+
}
120+
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""Typed GAMRecipe Pydantic model — Phase 1B falsification target.
2+
3+
Constructed from salesagent's actual implementation_config shape as
4+
specified by GAMProductConfigService (`src/services/gam_product_config_service.py`).
5+
All fields the service generates, validates, or parses are represented;
6+
no escape hatches intended.
7+
8+
Source-of-truth fields enumerated from:
9+
- generate_default_config() — the auto-generated defaults
10+
- validate_config() — the required/validated fields
11+
- parse_form_config() — the full set of user-configurable fields
12+
13+
Q2 question: can this typed shape carry every salesagent-shaped
14+
implementation_config without `extra: dict[str, Any]`,
15+
`__pydantic_extra__`, or `# type: ignore`?
16+
"""
17+
18+
from __future__ import annotations
19+
20+
from typing import Literal
21+
22+
from pydantic import BaseModel, ConfigDict, Field
23+
24+
# ---------------------------------------------------------------------------
25+
# Sub-models
26+
# ---------------------------------------------------------------------------
27+
28+
29+
class CreativePlaceholder(BaseModel):
30+
"""A creative placeholder declares an expected creative size + count."""
31+
32+
model_config = ConfigDict(extra="forbid")
33+
34+
width: int = Field(ge=1)
35+
height: int = Field(ge=1)
36+
expected_creative_count: int = Field(ge=1, default=1)
37+
is_native: bool = False
38+
39+
40+
class FrequencyCap(BaseModel):
41+
"""A frequency cap limits impressions per buyer."""
42+
43+
model_config = ConfigDict(extra="forbid")
44+
45+
max_impressions: int = Field(ge=1)
46+
time_unit: Literal["MINUTE", "HOUR", "DAY", "WEEK", "MONTH", "LIFETIME"]
47+
time_range: int = Field(ge=1)
48+
49+
50+
# ---------------------------------------------------------------------------
51+
# Enums (string-Literal style — strict against GAM API documented values)
52+
# ---------------------------------------------------------------------------
53+
54+
LineItemType = Literal[
55+
"STANDARD",
56+
"SPONSORSHIP",
57+
"NETWORK",
58+
"BULK",
59+
"PRICE_PRIORITY",
60+
"HOUSE",
61+
]
62+
63+
CostType = Literal["CPM", "CPC", "CPD", "VCPM"]
64+
65+
CreativeRotationType = Literal["EVEN", "OPTIMIZED", "MANUAL", "SEQUENTIAL"]
66+
67+
DeliveryRateType = Literal[
68+
"EVENLY",
69+
"FRONTLOADED",
70+
"AS_FAST_AS_POSSIBLE",
71+
]
72+
73+
PrimaryGoalType = Literal["NONE", "DAILY", "LIFETIME"]
74+
75+
PrimaryGoalUnitType = Literal["IMPRESSIONS", "CLICKS", "VIEWABLE_IMPRESSIONS"]
76+
77+
EnvironmentType = Literal["BROWSER", "VIDEO_PLAYER"]
78+
79+
CompanionDeliveryOption = Literal["OPTIONAL", "AT_LEAST_ONE", "ALL"]
80+
81+
NonGuaranteedAutomation = Literal[
82+
"manual",
83+
"confirmation_required",
84+
"automatic",
85+
]
86+
87+
DiscountType = Literal["PERCENTAGE", "ABSOLUTE_VALUE"]
88+
89+
90+
# ---------------------------------------------------------------------------
91+
# The recipe
92+
# ---------------------------------------------------------------------------
93+
94+
95+
class GAMRecipe(BaseModel):
96+
"""Typed GAM implementation_config — the recipe a GAMPlatform consumes.
97+
98+
Maps 1:1 against salesagent's `implementation_config` shape from
99+
`gam_product_config_service.py`. Every field salesagent's service
100+
generates, validates, or parses has a typed slot here.
101+
102+
Test invariant: every implementation_config value salesagent
103+
constructs MUST round-trip through GAMRecipe.model_validate(...) and
104+
.model_dump() without loss.
105+
"""
106+
107+
model_config = ConfigDict(extra="forbid")
108+
109+
# --- Core line-item settings -------------------------------------------
110+
line_item_type: LineItemType
111+
priority: int = Field(ge=1, le=16)
112+
cost_type: CostType = "CPM"
113+
114+
# --- Delivery settings -------------------------------------------------
115+
creative_rotation_type: CreativeRotationType = "EVEN"
116+
delivery_rate_type: DeliveryRateType = "EVENLY"
117+
primary_goal_type: PrimaryGoalType = "DAILY"
118+
primary_goal_unit_type: PrimaryGoalUnitType = "IMPRESSIONS"
119+
120+
# --- Automation policy -------------------------------------------------
121+
non_guaranteed_automation: NonGuaranteedAutomation = "confirmation_required"
122+
123+
# --- Inventory targeting (optional) ------------------------------------
124+
targeted_ad_unit_ids: list[str] | None = None
125+
targeted_placement_ids: list[str] | None = None
126+
include_descendants: bool = True
127+
128+
# --- Creative placeholders (REQUIRED) ----------------------------------
129+
creative_placeholders: list[CreativePlaceholder] = Field(min_length=1)
130+
131+
# --- Frequency caps (optional) -----------------------------------------
132+
frequency_caps: list[FrequencyCap] | None = None
133+
134+
# --- Competition / exclusions ------------------------------------------
135+
competitive_exclusion_labels: list[str] | None = None
136+
137+
# --- Custom targeting (the borderline case) ----------------------------
138+
# GAM's API accepts custom targeting as key→value(s).
139+
# Typed strictly per GAM API docs: keys are strings, values are
140+
# strings or lists of strings. Anything more nested rejects.
141+
# This is "strict typing matching GAM API," NOT an escape hatch.
142+
custom_targeting_keys: dict[str, str | list[str]] | None = None
143+
144+
# --- Environment + advanced --------------------------------------------
145+
environment_type: EnvironmentType = "BROWSER"
146+
discount_type: DiscountType | None = None
147+
discount_value: float | None = None
148+
allow_overbook: bool = False
149+
skip_inventory_check: bool = False
150+
disable_viewability_avg_revenue_optimization: bool = False
151+
152+
# --- Video-specific (only when environment_type == "VIDEO_PLAYER") -----
153+
companion_delivery_option: CompanionDeliveryOption | None = None
154+
video_max_duration: int | None = Field(default=None, ge=0) # milliseconds
155+
skip_offset: int | None = Field(default=None, ge=0) # milliseconds
156+
157+
# --- Native -------------------------------------------------------------
158+
native_style_id: str | None = None
159+
160+
# --- Teams --------------------------------------------------------------
161+
applied_team_ids: list[str] | None = None

0 commit comments

Comments
 (0)