| """Core contracts for the phase-one personalized product-ad environment.""" |
|
|
| from __future__ import annotations |
|
|
| from dataclasses import dataclass |
| from enum import Enum |
| from pathlib import Path |
| from typing import Any |
|
|
| HEADLINE_MAX_CHARS = 60 |
| BODY_MAX_CHARS = 180 |
| CTA_ALLOWLIST = frozenset({"Shop now", "Explore the style", "View details"}) |
|
|
|
|
| class ValidationError(ValueError): |
| """A domain value cannot be accepted safely.""" |
|
|
|
|
| class DataProvenance(str, Enum): |
| HM_LOCAL = "hm_local" |
| HM_PILOT = "hm_pilot" |
| SYNTHETIC_DEMO = "synthetic_demo" |
|
|
|
|
| class ExecutionMode(str, Enum): |
| LIVE = "live" |
| RECORDED_REPLAY = "recorded_replay" |
| DETERMINISTIC_TEST = "deterministic_test" |
|
|
|
|
| class ActionSource(str, Enum): |
| EXTERNAL_AGENT = "external_agent" |
| LIVE_GENERATOR = "live_generator" |
| RECORDED_REPLAY = "recorded_replay" |
| DETERMINISTIC_TEST = "deterministic_test" |
|
|
|
|
| class ReviewStatus(str, Enum): |
| ACCEPTED = "accepted" |
| NEEDS_REVIEW = "needs_review" |
| FAILED = "failed" |
|
|
|
|
| class StageState(str, Enum): |
| STARTED = "started" |
| COMPLETED = "completed" |
|
|
|
|
| def _clean_required(value: str, field: str) -> str: |
| if not isinstance(value, str): |
| raise ValidationError(f"{field} must be a string") |
| cleaned = " ".join(value.split()) |
| if not cleaned: |
| raise ValidationError(f"{field} must not be empty") |
| return cleaned |
|
|
|
|
| def _clean_tuple(values: tuple[str, ...], field: str) -> tuple[str, ...]: |
| if not isinstance(values, tuple): |
| raise ValidationError(f"{field} must be a tuple") |
| cleaned = tuple(_clean_required(value, field) for value in values) |
| if not cleaned: |
| raise ValidationError(f"{field} must not be empty") |
| return cleaned |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class CustomerContext: |
| top_product_groups: tuple[str, ...] |
| top_product_types: tuple[str, ...] |
| top_colours: tuple[str, ...] |
| recent_purchase_summary: tuple[str, ...] |
|
|
| def __post_init__(self) -> None: |
| for field in ( |
| "top_product_groups", |
| "top_product_types", |
| "top_colours", |
| "recent_purchase_summary", |
| ): |
| object.__setattr__(self, field, _clean_tuple(getattr(self, field), field)) |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class Product: |
| name: str |
| product_type: str |
| colour: str |
| description: str |
| image_id: str |
|
|
| def __post_init__(self) -> None: |
| for field in ("name", "product_type", "colour", "description", "image_id"): |
| object.__setattr__(self, field, _clean_required(getattr(self, field), field)) |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class GraderTarget: |
| expected_intent: str |
| personalization_target: str |
| acceptable_realizations: tuple[str, ...] |
| version: str = "draft-v1" |
|
|
| def __post_init__(self) -> None: |
| object.__setattr__( |
| self, "expected_intent", _clean_required(self.expected_intent, "expected_intent") |
| ) |
| object.__setattr__( |
| self, |
| "personalization_target", |
| _clean_required(self.personalization_target, "personalization_target"), |
| ) |
| object.__setattr__( |
| self, |
| "acceptable_realizations", |
| _clean_tuple(self.acceptable_realizations, "acceptable_realizations"), |
| ) |
| object.__setattr__(self, "version", _clean_required(self.version, "version")) |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class CheckResult: |
| check_id: str |
| passed: bool |
| explanation: str |
|
|
| def __post_init__(self) -> None: |
| object.__setattr__(self, "check_id", _clean_required(self.check_id, "check_id")) |
| if not isinstance(self.passed, bool): |
| raise ValidationError("passed must be a bool") |
| object.__setattr__( |
| self, "explanation", _clean_required(self.explanation, "explanation") |
| ) |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class Observation: |
| customer_context: CustomerContext |
| query: str |
| product: Product |
|
|
| def __post_init__(self) -> None: |
| if not isinstance(self.customer_context, CustomerContext): |
| raise ValidationError("customer_context must be a CustomerContext") |
| if not isinstance(self.product, Product): |
| raise ValidationError("product must be a Product") |
| object.__setattr__(self, "query", _clean_required(self.query, "query")) |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class Scenario: |
| """Internal scenario record. `image_path` and `grader_target` are never observations.""" |
|
|
| scenario_id: str |
| customer_context: CustomerContext |
| query: str |
| product: Product |
| image_path: Path |
| provenance: DataProvenance |
| grader_target: GraderTarget | None = None |
|
|
| def __post_init__(self) -> None: |
| object.__setattr__(self, "scenario_id", _clean_required(self.scenario_id, "scenario_id")) |
| object.__setattr__(self, "query", _clean_required(self.query, "query")) |
| if not isinstance(self.customer_context, CustomerContext): |
| raise ValidationError("customer_context must be a CustomerContext") |
| if not isinstance(self.product, Product): |
| raise ValidationError("product must be a Product") |
| if not isinstance(self.image_path, Path): |
| raise ValidationError("image_path must be a Path") |
| if not isinstance(self.provenance, DataProvenance): |
| raise ValidationError("provenance must be a DataProvenance") |
| if self.grader_target is not None and not isinstance(self.grader_target, GraderTarget): |
| raise ValidationError("grader_target must be a GraderTarget or None") |
|
|
| def to_observation(self) -> Observation: |
| return Observation( |
| customer_context=self.customer_context, |
| query=self.query, |
| product=self.product, |
| ) |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class AdCopy: |
| headline: str |
| body: str |
| cta: str | None = None |
|
|
| def __post_init__(self) -> None: |
| headline = _clean_required(self.headline, "headline") |
| body = _clean_required(self.body, "body") |
| if len(headline) > HEADLINE_MAX_CHARS: |
| raise ValidationError(f"headline must be at most {HEADLINE_MAX_CHARS} characters") |
| if len(body) > BODY_MAX_CHARS: |
| raise ValidationError(f"body must be at most {BODY_MAX_CHARS} characters") |
|
|
| cta = self.cta |
| if cta == "": |
| cta = None |
| elif cta is not None: |
| cta = _clean_required(cta, "cta") |
| if cta not in CTA_ALLOWLIST: |
| raise ValidationError(f"cta must be omitted or one of {sorted(CTA_ALLOWLIST)}") |
|
|
| object.__setattr__(self, "headline", headline) |
| object.__setattr__(self, "body", body) |
| object.__setattr__(self, "cta", cta) |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class ActionProvenance: |
| """Public-safe description of where a submitted copy action came from.""" |
|
|
| source: ActionSource |
| execution_mode: ExecutionMode |
| identity: dict[str, str] | None = None |
|
|
| def __post_init__(self) -> None: |
| if not isinstance(self.source, ActionSource): |
| raise ValidationError("action provenance source must be an ActionSource") |
| if not isinstance(self.execution_mode, ExecutionMode): |
| raise ValidationError("action provenance execution_mode must be an ExecutionMode") |
| if self.identity is None: |
| return |
| if not isinstance(self.identity, dict) or not self.identity: |
| raise ValidationError("action provenance identity must be a non-empty mapping") |
| if len(self.identity) > 8: |
| raise ValidationError("action provenance identity has too many fields") |
| cleaned: dict[str, str] = {} |
| for key, value in self.identity.items(): |
| clean_key = _clean_required(key, "action provenance identity key") |
| clean_value = _clean_required(value, "action provenance identity value") |
| if len(clean_key) > 64 or len(clean_value) > 200: |
| raise ValidationError("action provenance identity fields are too long") |
| cleaned[clean_key] = clean_value |
| object.__setattr__(self, "identity", cleaned) |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class SafeStageEvent: |
| """Public progress notification for an actual environment boundary.""" |
|
|
| stage_id: str |
| label: str |
| state: StageState |
| sequence: int |
| public_data: dict[str, Any] | None = None |
|
|
| def __post_init__(self) -> None: |
| object.__setattr__(self, "stage_id", _clean_required(self.stage_id, "stage_id")) |
| object.__setattr__(self, "label", _clean_required(self.label, "label")) |
| if not isinstance(self.state, StageState): |
| raise ValidationError("stage state must be a StageState") |
| if not isinstance(self.sequence, int) or self.sequence < 1: |
| raise ValidationError("stage sequence must be a positive integer") |
| if self.public_data is not None and not isinstance(self.public_data, dict): |
| raise ValidationError("stage public_data must be a mapping or None") |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class EpisodeResult: |
| """Public-safe result envelope for one complete episode.""" |
|
|
| scenario_id: str |
| action: AdCopy |
| data_provenance: DataProvenance |
| action_provenance: ActionProvenance |
| judge_execution_mode: ExecutionMode |
| safe_stages: tuple[str, ...] = () |
| reward_policy_version: str | None = None |
| reward: float | None = None |
| review_status: ReviewStatus | None = None |
| card_artifact: str | None = None |
| checks: tuple[CheckResult, ...] = () |
| judge_scores: dict[str, float] | None = None |
| judge_explanations: dict[str, str] | None = None |
| weighted_components: dict[str, float] | None = None |
| base_score: float | None = None |
| failed_checks: tuple[str, ...] = () |
| applied_cap: float | None = None |
| judge_identity: dict[str, str] | None = None |
|
|
| def __post_init__(self) -> None: |
| object.__setattr__(self, "scenario_id", _clean_required(self.scenario_id, "scenario_id")) |
| if not isinstance(self.action, AdCopy): |
| raise ValidationError("action must be an AdCopy") |
| if not isinstance(self.data_provenance, DataProvenance): |
| raise ValidationError("data_provenance must be a DataProvenance") |
| if not isinstance(self.action_provenance, ActionProvenance): |
| raise ValidationError("action_provenance must be an ActionProvenance") |
| if not isinstance(self.judge_execution_mode, ExecutionMode): |
| raise ValidationError("judge_execution_mode must be an ExecutionMode") |
| if any(not isinstance(check, CheckResult) for check in self.checks): |
| raise ValidationError("checks must contain CheckResult values") |
|
|