From 2037b40e27965b8546b8c490b67d64707248c720 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 16 Sep 2026 00:24:10 +0000 Subject: [PATCH 1/4] feat(reporting): support per-metric source availability evidence --- README.md | 1 + docs/reporting-source-adapters.md | 140 ++++ src/adcp/reporting/inline_source.py | 434 ++++++++-- .../test_inline_cell_availability.py | 739 ++++++++++++++++++ tests/test_reporting_metric_evidence.py | 136 ++++ tests/type_checks/reporting_inline_source.py | 118 +++ 6 files changed, 1520 insertions(+), 48 deletions(-) create mode 100644 docs/reporting-source-adapters.md create mode 100644 tests/conformance/reporting/test_inline_cell_availability.py create mode 100644 tests/test_reporting_metric_evidence.py create mode 100644 tests/type_checks/reporting_inline_source.py diff --git a/README.md b/README.md index eb5b48340..544e20492 100644 --- a/README.md +++ b/README.md @@ -356,6 +356,7 @@ forward traffic degrades gracefully rather than failing. - **[Handler authoring](docs/handler-authoring.md)** - Building an AdCP-compliant agent on `adcp.server` - **[Production seller path](docs/production-seller.md)** - Choose the server abstraction and wire durable multi-tenant tasks and webhook delivery - **[Validation contract](docs/validation-contract.md)** - Canonical wire validation versus structural Pydantic models +- **[Reporting source adapters](docs/reporting-source-adapters.md)** - Per-metric availability evidence, coverage, and control totals for inline delivery fetches - **[Migrating from SDK 6 to 7](https://github.com/adcontextprotocol/adcp-client-python/blob/main/MIGRATION_v6_to_v7.md)** - Breaking API, security, concurrency, and webhook changes - **[Migrating from SDK 7 to 8](https://github.com/adcontextprotocol/adcp-client-python/blob/main/MIGRATION_v7_to_v8.md)** - Secure webhook defaults and telemetry changes - **[Migrating from AdCP 3.1 to 3.2 beta](MIGRATION_ADCP_3.1_TO_3.2.md)** - Compact lifecycle adoption and old/new compatibility matrix diff --git a/docs/reporting-source-adapters.md b/docs/reporting-source-adapters.md new file mode 100644 index 000000000..8f62573e2 --- /dev/null +++ b/docs/reporting-source-adapters.md @@ -0,0 +1,140 @@ +# Per-metric evidence for inline reporting sources + +`InlineReportingSource` wraps a synchronous or asynchronous delivery fetch in a +sealed, replayable reporting publication. Return `InlineFetchResult` when metric +availability differs within a constituent. Its `cell_availability` map is keyed +by **request constituent ID, then metric name**. Constituent IDs are not +necessarily media buy IDs; read them from `request.coverage.constituents`. + +```python +from adcp.reporting.inline_source import InlineFetchResult, MetricEvidence + +return InlineFetchResult( + rows=[{"constituent_id": constituent_id, "impressions": 120, "clicks": 0}], + cell_availability={ + constituent_id: { + "impressions": MetricEvidence.present(data_through=watermark), + "clicks": MetricEvidence.explicit_zero(), + "viewability": MetricEvidence.delayed( + "measurement_pending", data_through=viewability_watermark + ), + "completed_views": MetricEvidence.unavailable("not_video_inventory"), + }, + }, +) +``` + +This publishes one `partial` constituent with four independent metric cells. +It can complete a request whose `coverage.expected` is `partial`. A `full` +request receives retryable `PARTIAL_RESULT` and no publication until every +requested cell is available. + +| Constructor | Meaning and required evidence | +| --- | --- | +| `MetricEvidence.present(data_through)` | Measured values through a timezone-aware watermark. Every matched row for the constituent must carry a non-null value for this metric. | +| `MetricEvidence.explicit_zero(data_through=...)` | An observed zero. The watermark defaults to the fetch watermark. Any supplied values must be finite numeric zeros. | +| `MetricEvidence.missing(reason)` | No answer for this metric. A stable reason is required; a watermark is forbidden. | +| `MetricEvidence.delayed(reason, data_through=...)` | Not ready yet. A stable reason is required; retain a known watermark when available. | +| `MetricEvidence.unavailable(reason)` | The source cannot measure this metric for this constituent. Emits the existing wire status `unsupported`; a reason is required and a watermark is forbidden. | + +Evidence is immutable. Direct `MetricEvidence(...)` construction enforces the +same invariants. Reasons use the existing manifest format: bounded ASCII, +at most 512 characters, beginning and ending with an alphanumeric character. +Use redacted explanations such as `not_video_inventory`, not provider error +payloads. Available statuses do not accept reasons. + +The adapter supplies availability, watermarks, and measurements. The SDK alone +copies semantic-contract ID, version, and digest from the **selected offering**. +`MetricEvidence` has no semantic-contract fields. `cell_availability` is an +adapter input; the sealed manifest continues to use its existing +`metric_availability` list and statuses. + +## Defaults and freshness + +Omit `cell_availability` (or pass `None` or `{}`) to retain the existing derived +behavior. Omitted cells inherit their constituent's status, reason, and +watermark. Explicit cells take precedence over `covered_constituent_ids` and +`unavailable_constituents`, including explicit measurements for an otherwise +missing constituent. Coverage is then reconciled from the resolved cells: +uniform statuses stay uniform, present plus explicit-zero is `present`, and +other mixtures are `partial`. + +Explicit watermarks are bounded by period end, source read cutoff, and the +observation instant. A watermark before the period is rejected. An explicit +cell may advance the batch watermark while other cells keep their earlier +evidence. A fully available constituent uses its earliest cell watermark. +For an authoritative publication, an available cell whose bounded watermark +does not reach period end becomes `delayed`, with a reason and its watermark. +Cell evidence cannot bypass that freshness gate. + +Rows can omit missing, unsupported, or delayed metrics; available measurements +for other metrics are retained. A control total is emitted only when every +requested constituent's cell for that metric is present or explicit-zero and +every staged row contains a valid finite numeric value for it. Missing fields, +nulls, booleans, and invalid numbers prevent a total. An omitted explicit-zero +row value is not filled in. A covered constituent with no rows can contribute +an observed zero; a missing constituent cannot. Rows outside the requested +coverage remain staged with a warning and prevent totals from claiming the +requested denominator. + +The existing zero-row wire rule is unchanged: an empty batch must be wholly +explicit-zero or wholly unavailable. It cannot mix available cells with +unavailable cells. To report a measured zero alongside an unavailable metric, +supply the source's normalized zero measurement row. The adapter does not +manufacture rows or silently convert unavailable metrics to zero. + +## Common patterns + +For complete source data, the existing row-list shorthand still works. To +declare its freshness explicitly: + +```python +return InlineFetchResult.all_present(rows, data_through=watermark) +``` + +This uses the constituent defaults: constituents with rows are present and +covered constituents without rows are observed zeros. Bare `[]` still means +an observed zero; `None` still means not ready. Existing positional +`InlineFetchResult` arguments retain their meaning. Totals for incomplete +denominators or unmatched rows are now omitted instead of implying complete +measurements, including for legacy results. + +Bulk helpers return maps to pass to `cell_availability`, leaving the result's +other options available: + +```python +from adcp.reporting.inline_source import ( + metric_delayed_through, + metric_unsupported_everywhere, +) + +return InlineFetchResult( + rows=rows, + cell_availability=metric_unsupported_everywhere( + request, "completed_views", "not_video_inventory" + ), +) + +# Or retain a delayed metric's watermark across every requested constituent: +return InlineFetchResult( + rows=rows, + cell_availability=metric_delayed_through( + request, "viewability", watermark, reason="measurement_pending" + ), +) +``` + +Only keys in the frozen requested matrix are accepted, even if the selected +offering declares additional metrics. Unknown keys, duplicate entries +exposed by a mapping, and non-`MetricEvidence` values raise `ValueError` before +staging or sealing. Evidence construction errors inside a fetch also propagate +as `ValueError`; they are not classified as transient provider failures. +Keys are not coerced or normalized. Ordinary Python dicts already discard +repeated keys; reject duplicates while parsing provider input if it can contain +them. + +See the [sync and async type-check examples](../tests/type_checks/reporting_inline_source.py) +and the [conformance tests](../tests/conformance/reporting/test_inline_cell_availability.py). +Run `run_reporting_source_replay_conformance` against your adapter and staging +store to verify that the same execution key returns the original sealed +evidence even if source measurements or availability later change. diff --git a/src/adcp/reporting/inline_source.py b/src/adcp/reporting/inline_source.py index 4c8eb7550..5ad91d430 100644 --- a/src/adcp/reporting/inline_source.py +++ b/src/adcp/reporting/inline_source.py @@ -12,6 +12,13 @@ conforming ``basic`` manifest, complete with staged objects, coverage evidence, and byte-identical replay. +For uneven metric support, return :class:`InlineFetchResult` with +``cell_availability={constituent_id: {metric_name: MetricEvidence(...)}}``. +Omitted cells retain the constituent defaults. Explicit evidence controls each +cell independently; mixed cells make the constituent partial, and incomplete +metric columns receive no control total. Metric semantics always come from the +selected SDK offering. + The three answers a fetch can give --------------------------------- @@ -58,9 +65,12 @@ from datetime import datetime, timezone from decimal import Decimal, InvalidOperation from pathlib import Path -from typing import Any, Protocol, TypeAlias, runtime_checkable +from typing import Any, Literal, Protocol, TypeAlias, runtime_checkable + +from pydantic import TypeAdapter, ValidationError from adcp.reporting.source import ( + EvidenceReason, MediaBuyConstituentV1, PackageItemConstituentV1, ProductConstituentV1, @@ -92,9 +102,12 @@ "InlineFetch", "InlineFetchResult", "InlineReportingSource", + "MetricEvidence", "ReportingSealStore", "ReportingStagingStore", "SealedSlice", + "metric_delayed_through", + "metric_unsupported_everywhere", ] @@ -103,6 +116,90 @@ # -------------------------------------------------------------------------- +_REASON = TypeAdapter(EvidenceReason) +_AVAILABLE = frozenset({"present", "explicit_zero"}) + + +class _CellEvidenceError(ValueError): + """A deterministic adapter error, never an unclassified provider fault.""" + + +@dataclass(frozen=True) +class MetricEvidence: + """Source evidence for one requested constituent/metric cell. + + This carries availability only. The SDK binds metric semantics from the + selected offering. Prefer the named constructors; direct construction + enforces the same invariants and raises ``ValueError`` for invalid evidence. + + Watermarks must be timezone-aware. Reasons use the manifest's bounded ASCII + evidence format; use stable, redacted explanations such as + ``not_video_inventory`` rather than provider diagnostics or credentials. + """ + + status: Literal["present", "explicit_zero", "missing", "delayed", "unsupported"] + data_through: datetime | None = None + reason: str | None = None + + def __post_init__(self) -> None: + if self.status not in ("present", "explicit_zero", "missing", "delayed", "unsupported"): + raise _CellEvidenceError("MetricEvidence.status is not a supported cell status") + if self.data_through is not None: + if ( + not isinstance(self.data_through, datetime) + or self.data_through.tzinfo is None + or self.data_through.utcoffset() is None + ): + raise _CellEvidenceError( + "MetricEvidence.data_through must be a timezone-aware datetime" + ) + if self.status == "present" and self.data_through is None: + raise _CellEvidenceError("MetricEvidence present requires data_through") + if self.status in _AVAILABLE: + if self.reason is not None: + raise _CellEvidenceError(f"MetricEvidence {self.status} must not carry a reason") + else: + if self.reason is None: + raise _CellEvidenceError(f"MetricEvidence {self.status} requires a stable reason") + try: + _REASON.validate_python(self.reason, strict=True) + except ValidationError as error: + raise _CellEvidenceError( + "MetricEvidence.reason must be a wire-valid stable reason" + ) from error + if self.status in {"missing", "unsupported"} and self.data_through is not None: + raise _CellEvidenceError(f"MetricEvidence {self.status} must not carry data_through") + + @classmethod + def present(cls, data_through: datetime) -> MetricEvidence: + """The source measured this metric through the supplied watermark.""" + return cls("present", data_through=data_through) + + @classmethod + def explicit_zero(cls, *, data_through: datetime | None = None) -> MetricEvidence: + """An observed zero; omit the watermark to inherit the fetch watermark. + + Any supplied row values for this cell must be numeric zeros. An + omitted row value is not converted into a control-total contribution. + """ + return cls("explicit_zero", data_through=data_through) + + @classmethod + def missing(cls, reason: str) -> MetricEvidence: + """The source returned no answer for this cell.""" + return cls("missing", reason=reason) + + @classmethod + def delayed(cls, reason: str, *, data_through: datetime | None = None) -> MetricEvidence: + """The metric is not ready; retain its watermark when one is known.""" + return cls("delayed", data_through=data_through, reason=reason) + + @classmethod + def unavailable(cls, reason: str) -> MetricEvidence: + """The source cannot measure this cell (wire status ``unsupported``).""" + return cls("unsupported", reason=reason) + + @dataclass(frozen=True) class InlineFetchResult: """A fetch answer richer than a bare row list. @@ -149,6 +246,66 @@ class InlineFetchResult: warnings: Sequence[str] = () """Safe, redacted operator notes retained on the publication.""" + cell_availability: Mapping[str, Mapping[str, MetricEvidence]] | None = None + """Optional ``{constituent_id: {metric_name: evidence}}`` overrides. + + Keys are the frozen request's constituent IDs, which need not equal media + buy IDs. Omitted cells retain the derived constituent status and watermark. + Explicit cells override even missing/unsupported constituent defaults; + mixed availability promotes the constituent to ``partial``. The SDK still + applies the source cutoff, observation ceiling, and authoritative freshness + gate. A cell watermark may advance the batch watermark without advancing + other cells. This is an adapter surface, not the manifest's wire-format + ``metric_availability`` list. + + Unknown keys, duplicate mapping entries, invalid evidence, and contradictory + explicit zeros raise ``ValueError`` before staging. A zero-row batch must + be wholly explicit-zero or wholly unavailable under the existing contract. + """ + + @classmethod + def all_present( + cls, rows: Sequence[Mapping[str, Any]], *, data_through: datetime + ) -> InlineFetchResult: + """Cover the whole request through a watermark, using the legacy defaults. + + Constituents with rows are present; covered constituents without rows + are observed zeros. Use ``cell_availability`` for exceptions. + """ + return cls(rows=rows, data_through=data_through) + + +def metric_unsupported_everywhere( + request: ReportingSourceSliceRequestV1, metric_name: str, reason: str +) -> dict[str, dict[str, MetricEvidence]]: + """Build ``cell_availability`` for a metric unsupported across the request. + + Other metrics retain the fetch's constituent defaults. Pass the returned + map to :class:`InlineFetchResult`, alongside rows, watermarks, or warnings. + """ + return _metric_everywhere(request, metric_name, MetricEvidence.unavailable(reason)) + + +def metric_delayed_through( + request: ReportingSourceSliceRequestV1, + metric_name: str, + watermark: datetime, + *, + reason: str = "The reporting source has not finalized this metric yet", +) -> dict[str, dict[str, MetricEvidence]]: + """Build ``cell_availability`` retaining a delayed metric's known watermark.""" + return _metric_everywhere( + request, metric_name, MetricEvidence.delayed(reason, data_through=watermark) + ) + + +def _metric_everywhere( + request: ReportingSourceSliceRequestV1, metric_name: str, evidence: MetricEvidence +) -> dict[str, dict[str, MetricEvidence]]: + if metric_name not in request.requested_metrics: + raise _CellEvidenceError(f"cell_availability has unknown requested metric {metric_name!r}") + return {item.constituent_id: {metric_name: evidence} for item in request.coverage.constituents} + #: What an inline fetch may return. ``None`` is "not ready". InlineFetchReturn: TypeAlias = "InlineFetchResult | Sequence[Mapping[str, Any]] | None | object" @@ -456,6 +613,11 @@ async def execute( answer = _coerce(answer) except ReportingSourceError as error: return ReportingSourceExecutorResult.failed(error.error) + except _CellEvidenceError: + # Constructors/helpers may run inside the fetch. Keep the same + # actionable ValueError as matrix validation after the fetch, + # rather than redacting an adapter bug as a retryable provider fault. + raise except asyncio.CancelledError: raise except Exception as error: @@ -547,6 +709,7 @@ async def _publish( ) -> ReportingSourceExecutorResult: observed_at = self._clock() data_through = _clamp_watermark(request, result.data_through, observed_at) + overrides = _validate_cell_availability(request, result.cell_availability) covered = ( {item.constituent_id for item in request.coverage.constituents} if result.covered_constituent_ids is None @@ -555,7 +718,7 @@ async def _publish( covered -= set(result.unavailable_constituents) rows_by_constituent: dict[str, list[Mapping[str, Any]]] = { - constituent_id: [] for constituent_id in covered + item.constituent_id: [] for item in request.coverage.constituents } unmatched = 0 for row in result.rows: @@ -564,6 +727,8 @@ async def _publish( unmatched += 1 continue rows_by_constituent[constituent_id].append(row) + if constituent_id not in covered and not overrides.get(constituent_id): + unmatched += 1 warnings = list(result.warnings) if unmatched: @@ -575,7 +740,19 @@ async def _publish( statuses = self._derive_statuses( request, result, covered, rows_by_constituent, data_through ) - coverage_status = _roll_up(statuses.values()) + constituents, cells = self._resolve_availability( + request, + result, + overrides=overrides, + statuses=statuses, + rows_by_constituent=rows_by_constituent, + data_through=data_through, + observed_at=observed_at, + ) + # Granular watermarks are bounded by the batch watermark on the wire. + # Advancing it for an explicit cell must not advance fallback cells. + data_through = max(data_through, *(cell.data_through or data_through for cell in cells)) + coverage_status = _roll_up([item.status for item in constituents]) if request.coverage.expected == "full" and coverage_status != "full": # Never publish a partial answer to a full-coverage slice; the @@ -591,6 +768,19 @@ async def _publish( ) ) + # A partial constituent is deliberately excluded: a known zero for + # one metric must never turn unavailable neighbours into a batch zero. + explicit_zero = not result.rows and all(cell.status == "explicit_zero" for cell in cells) + if ( + not result.rows + and not explicit_zero + and any(cell.status in _AVAILABLE for cell in cells) + ): + raise _CellEvidenceError( + "cell_availability cannot mix available and unavailable cells in a zero-row " + "batch; supply rows for observed metrics" + ) + control_totals = [] if unmatched else _control_totals(request, result.rows, cells) payload = _encode_rows(result.rows) object_ref, object_generation = await self._staging.stage( account_id=request.identity.account_id, @@ -609,23 +799,17 @@ async def _publish( row_count=len(result.rows), ) - available = { - constituent_id - for constituent_id, status in statuses.items() - if status in {"present", "explicit_zero"} - } - explicit_zero = bool(available) and not result.rows and available == set(statuses) manifest = self._seal_manifest( request, observed_at=observed_at, data_through=data_through, staged=staged, - statuses=statuses, + constituents=constituents, + cells=cells, coverage_status=coverage_status, - reasons=dict(result.unavailable_constituents), explicit_zero=explicit_zero, row_count=len(result.rows), - control_totals=_control_totals(request, result.rows), + control_totals=control_totals, warnings=warnings, ) @@ -678,6 +862,107 @@ def _derive_statuses( statuses[constituent_id] = "explicit_zero" return statuses + def _resolve_availability( + self, + request: ReportingSourceSliceRequestV1, + result: InlineFetchResult, + *, + overrides: Mapping[str, Mapping[str, MetricEvidence]], + statuses: Mapping[str, ReportingAvailabilityStatus], + rows_by_constituent: Mapping[str, Sequence[Mapping[str, Any]]], + data_through: datetime, + observed_at: datetime, + ) -> tuple[list[ReportingConstituentCoverageV1], list[ReportingMetricAvailabilityV1]]: + """Resolve cells first, then derive coverage without widening their claims.""" + offering = self._capabilities.offering(request.offering_id) + declared = {metric.name: metric for metric in offering.metrics} + constituents: list[ReportingConstituentCoverageV1] = [] + cells: list[ReportingMetricAvailabilityV1] = [] + for item in request.coverage.constituents: + constituent_id = item.constituent_id + fallback = statuses[constituent_id] + fallback_reason = ( + None + if fallback in _AVAILABLE + else result.unavailable_constituents.get(constituent_id) + or _DEFAULT_REASONS[fallback] + ) + constituent_cells: list[ReportingMetricAvailabilityV1] = [] + for metric in request.requested_metrics: + status = fallback + watermark = data_through if status in _AVAILABLE else None + reason = fallback_reason + explicit = overrides.get(constituent_id, {}).get(metric) + if explicit is not None: + _validate_cell_rows( + constituent_id, metric, explicit, rows_by_constituent[constituent_id] + ) + status, reason = explicit.status, explicit.reason + watermark = explicit.data_through + if watermark is not None: + if _utc(watermark) < _utc(request.period.start): + raise _CellEvidenceError( + f"cell_availability ({constituent_id!r}, {metric!r}) " + "data_through must not precede the reporting period" + ) + watermark = _clamp_watermark(request, watermark, observed_at) + elif status == "explicit_zero": + watermark = data_through + if ( + status in _AVAILABLE + and request.publication_class == "AUTHORITATIVE" + and watermark is not None + and _utc(watermark) < _utc(request.period.end) + ): + status, reason = "delayed", _DEFAULT_REASONS["delayed"] + # Evidence follows the cell status, never its constituent's + # roll-up. Only the selected SDK offering supplies semantics. + constituent_cells.append( + ReportingMetricAvailabilityV1( + constituent_id=constituent_id, + metric=metric, + semantic_contract_id=declared[metric].semantic_contract_id, + semantic_contract_version=declared[metric].semantic_contract_version, + semantic_contract_sha256=declared[metric].semantic_contract_sha256, + status=status, + data_through=watermark, + reason=reason, + ) + ) + cells.extend(constituent_cells) + status = fallback + watermark = data_through if status in _AVAILABLE else None + reason = fallback_reason + if overrides.get(constituent_id): + cell_statuses = {cell.status for cell in constituent_cells} + if len(cell_statuses) == 1: + status = constituent_cells[0].status + elif cell_statuses <= _AVAILABLE: + status = "present" + else: + status = "partial" + if status in _AVAILABLE: + watermark = min( + cell.data_through + for cell in constituent_cells + if cell.data_through is not None + ) + reason = None + else: + watermark = None + reasons = {cell.reason for cell in constituent_cells} + reason = ( + constituent_cells[0].reason + if len(reasons) == 1 and constituent_cells[0].reason is not None + else _DEFAULT_REASONS[status] + ) + constituents.append( + ReportingConstituentCoverageV1( + constituent=item, status=status, data_through=watermark, reason=reason + ) + ) + return constituents, cells + def _seal_manifest( self, request: ReportingSourceSliceRequestV1, @@ -685,49 +970,19 @@ def _seal_manifest( observed_at: datetime, data_through: datetime, staged: SourceBatchObjectV1, - statuses: Mapping[str, ReportingAvailabilityStatus], + constituents: list[ReportingConstituentCoverageV1], + cells: list[ReportingMetricAvailabilityV1], coverage_status: str, - reasons: Mapping[str, str], explicit_zero: bool, row_count: int, control_totals: list[SourceControlTotalV1], warnings: list[str], ) -> SourceBatchManifestV1: - available = {"present", "explicit_zero"} - - def evidence(constituent_id: str) -> dict[str, Any]: - status = statuses[constituent_id] - if status in available: - return {"data_through": data_through} - return {"reason": reasons.get(constituent_id) or _DEFAULT_REASONS[status]} - coverage = SourceBatchCoverageV1( denominator_fingerprint=request.coverage.denominator_fingerprint, status=coverage_status, # type: ignore[arg-type] - constituents=[ - ReportingConstituentCoverageV1( - constituent=item, - status=statuses[item.constituent_id], - **evidence(item.constituent_id), - ) - for item in request.coverage.constituents - ], + constituents=constituents, ) - offering = self._capabilities.offering(request.offering_id) - declared = {metric.name: metric for metric in offering.metrics} - cells = [ - ReportingMetricAvailabilityV1( - constituent_id=item.constituent_id, - metric=metric, - semantic_contract_id=declared[metric].semantic_contract_id, - semantic_contract_version=declared[metric].semantic_contract_version, - semantic_contract_sha256=declared[metric].semantic_contract_sha256, - status=statuses[item.constituent_id], - **evidence(item.constituent_id), - ) - for item in request.coverage.constituents - for metric in request.requested_metrics - ] finality_at = ( max(_utc(observed_at), _utc(request.period.end)) if request.publication_class == "AUTHORITATIVE" @@ -797,6 +1052,83 @@ def evidence(constituent_id: str) -> dict[str, Any]: # -------------------------------------------------------------------------- +def _validate_cell_availability( + request: ReportingSourceSliceRequestV1, + availability: Mapping[str, Mapping[str, MetricEvidence]] | None, +) -> dict[str, dict[str, MetricEvidence]]: + """Validate and snapshot overrides before any staging or seal side effects. + + Do not coerce keys or collapse mappings into dicts before checking them: + custom Mapping implementations can expose duplicate logical entries. + Ordinary dicts have already discarded duplicate keys before this boundary. + """ + if availability is None: + return {} + if not isinstance(availability, Mapping): + raise _CellEvidenceError("cell_availability must be a nested mapping") + requested_ids = {item.constituent_id for item in request.coverage.constituents} + requested_metrics = set(request.requested_metrics) + validated: dict[str, dict[str, MetricEvidence]] = {} + for constituent_id, metrics in availability.items(): + if not isinstance(constituent_id, str) or constituent_id not in requested_ids: + raise _CellEvidenceError( + f"cell_availability has unknown constituent_id {constituent_id!r}" + ) + if constituent_id in validated: + raise _CellEvidenceError( + f"cell_availability has duplicate constituent_id {constituent_id!r}" + ) + if not isinstance(metrics, Mapping): + raise _CellEvidenceError( + f"cell_availability for {constituent_id!r} must be a metric mapping" + ) + validated[constituent_id] = {} + for metric, evidence in metrics.items(): + if not isinstance(metric, str) or metric not in requested_metrics: + raise _CellEvidenceError( + f"cell_availability has unknown requested metric {metric!r}" + ) + if metric in validated[constituent_id]: + raise _CellEvidenceError( + f"cell_availability has duplicate cell ({constituent_id!r}, {metric!r})" + ) + if not isinstance(evidence, MetricEvidence): + raise _CellEvidenceError( + f"cell_availability ({constituent_id!r}, {metric!r}) must be MetricEvidence" + ) + validated[constituent_id][metric] = evidence + return validated + + +def _validate_cell_rows( + constituent_id: str, + metric: str, + evidence: MetricEvidence, + rows: Sequence[Mapping[str, Any]], +) -> None: + """Reject explicit assertions contradicted by the supplied measurements. + + Legacy derived cells remain permissive. Metric value types are defined by + the offering, but an explicit present cell needs a value and a claimed zero + must not conceal a nonzero, nonnumeric, or non-finite measurement. + """ + label = f"cell_availability ({constituent_id!r}, {metric!r})" + if evidence.status == "present" and (not rows or any(row.get(metric) is None for row in rows)): + raise _CellEvidenceError(f"{label} present requires a value in every constituent row") + if evidence.status == "explicit_zero": + for row in rows: + if row.get(metric) is None: + continue + try: + value = _decimal(row[metric]) + except (InvalidOperation, TypeError, ValueError) as error: + raise _CellEvidenceError( + f"{label} explicit_zero requires numeric zero row values" + ) from error + if not value.is_finite() or value != 0: + raise _CellEvidenceError(f"{label} explicit_zero requires numeric zero row values") + + def _now() -> datetime: moment = datetime.now(timezone.utc) return moment.replace(microsecond=(moment.microsecond // 1000) * 1000) @@ -857,16 +1189,22 @@ def _encode_rows(rows: Sequence[Mapping[str, Any]]) -> bytes: def _control_totals( - request: ReportingSourceSliceRequestV1, rows: Sequence[Mapping[str, Any]] + request: ReportingSourceSliceRequestV1, + rows: Sequence[Mapping[str, Any]], + cells: Sequence[ReportingMetricAvailabilityV1], ) -> list[SourceControlTotalV1]: - """Sum each requested metric across the rows, exactly. + """Sum only metrics whose entire requested column is available and valid. Money sums through :class:`~decimal.Decimal` and is emitted as a string. A float total would round differently in two languages and turn a - consumer's equality check into a flake. + consumer's equality check into a flake. Partial/delayed measurements may + still have row values, but those are not evidence of a complete total. """ totals: list[SourceControlTotalV1] = [] + incomplete_metrics = {cell.metric for cell in cells if cell.status not in _AVAILABLE} for metric in request.requested_metrics: + if metric in incomplete_metrics: + continue values = [row[metric] for row in rows if row.get(metric) is not None] if len(values) != len(rows): # A metric absent from some rows has no honest total; the diff --git a/tests/conformance/reporting/test_inline_cell_availability.py b/tests/conformance/reporting/test_inline_cell_availability.py new file mode 100644 index 000000000..99053c2ec --- /dev/null +++ b/tests/conformance/reporting/test_inline_cell_availability.py @@ -0,0 +1,739 @@ +"""Adapter evidence must survive sealing, admission, and replay without overclaiming.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Iterator, Mapping +from datetime import datetime, timedelta, timezone +from typing import Any + +import pytest + +from adcp.reporting.conformance import ( + run_reporting_source_replay_conformance, + validate_reporting_source_execution, + validate_reporting_source_failure, +) +from adcp.reporting.fixtures import ( + redacted_authoritative_request, + redacted_capabilities, + redacted_snapshot_request, +) +from adcp.reporting.inline_source import ( + InlineFetchResult, + InlineReportingSource, + InMemorySealStore, + InMemoryStagingStore, + MetricEvidence, + metric_delayed_through, + metric_unsupported_everywhere, +) +from adcp.reporting.source import ( + MediaBuyConstituentV1, + MetricOfferingV1, + ReportingSourceCapabilitiesV1, + ReportingSourceCoverageRequestV1, + ReportingSourceSliceRequestV1, + SourceBatchManifestV1, + coverage_denominator_fingerprint_v1, + reporting_source_capabilities_sha256_v1, +) + +CID = "campaign-redacted-1" # Deliberately different from media_buy_id. +SECOND_CID = "campaign-redacted-2" +METRICS = ["impressions", "clicks", "viewability", "completed_views"] +OBSERVED_AT = datetime(2026, 11, 6, 12, tzinfo=timezone.utc) +ROW = { + "media_buy_id": "media-buy-redacted", + "impressions": 10, + "clicks": 0, + "viewability": "0.75", + "completed_views": 2, +} + + +def _capabilities() -> ReportingSourceCapabilitiesV1: + base = redacted_capabilities() + offerings = [ + offering.model_copy( + update={ + "metrics": [ + MetricOfferingV1( + name=metric, + semantic_contract_id=f"fixture.{offering.offering_id}.{metric}", + semantic_contract_version=str(index + 1), + semantic_contract_sha256=str(index + 1) * 64, + ) + for index, metric in enumerate([*METRICS, "spend"]) + ] + } + ) + for offering in base.offerings + ] + draft = base.model_copy(update={"offerings": offerings}) + return ReportingSourceCapabilitiesV1.model_validate( + { + **draft.model_dump(), + "capabilities_sha256": reporting_source_capabilities_sha256_v1(draft), + } + ) + + +def _request(*, authoritative: bool = False, second: bool = False) -> ReportingSourceSliceRequestV1: + base = redacted_authoritative_request() if authoritative else redacted_snapshot_request() + constituents = list(base.coverage.constituents) + if second: + constituents.append( + MediaBuyConstituentV1( + constituent_id=SECOND_CID, + media_buy_id="media-buy-second", + product_id="fixture-product", + ) + ) + return base.model_copy( + update={ + "requested_metrics": METRICS, + "coverage": ReportingSourceCoverageRequestV1( + expected="partial", + constituents=constituents, + denominator_fingerprint=coverage_denominator_fingerprint_v1(constituents), + ), + } + ) + + +def _source(fetch: Any, **kwargs: Any) -> InlineReportingSource: + return InlineReportingSource( + capabilities=_capabilities(), fetch=fetch, clock=lambda: OBSERVED_AT, **kwargs + ) + + +async def _seal( + answer: Any, request: ReportingSourceSliceRequestV1 | None = None +) -> SourceBatchManifestV1: + request = request or _request() + source = _source(lambda req: answer) + return await validate_reporting_source_execution( + capabilities=source.capabilities, + request=request, + result=await source.execute(request, cancel=asyncio.Event()), + object_reader=source.staging, + ) + + +@pytest.mark.parametrize("authoritative", [False, True], ids=["snapshot", "official"]) +@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) +async def test_one_constituent_has_four_independent_metric_statuses( + authoritative: bool, asynchronous: bool +) -> None: + request = _request(authoritative=authoritative) + watermark = min(request.period.end, request.period.source_read_cutoff_at) + delayed_through = watermark - timedelta(hours=4) + cases = [ + ("impressions", MetricEvidence.present(watermark), "present", watermark, None), + ("clicks", MetricEvidence.explicit_zero(), "explicit_zero", watermark, None), + ( + "viewability", + MetricEvidence.delayed("measurement_pending", data_through=delayed_through), + "delayed", + delayed_through, + "measurement_pending", + ), + ( + "completed_views", + MetricEvidence.unavailable("not_video_inventory"), + "unsupported", + None, + "not_video_inventory", + ), + ] + rows = [{key: value for key, value in ROW.items() if key != "completed_views"}] + answer = InlineFetchResult( + rows=rows, + cell_availability={CID: {metric: evidence for metric, evidence, *_ in cases}}, + ) + + async def fetch(req: ReportingSourceSliceRequestV1) -> InlineFetchResult: + return answer + + source = _source(fetch if asynchronous else lambda req: answer) + manifest = await run_reporting_source_replay_conformance( + executor=source, request=request, object_reader=source.staging + ) + assert manifest.coverage.status == "partial" + assert manifest.coverage.constituents[0].status == "partial" + assert manifest.coverage.constituents[0].reason + assert manifest.explicit_zero is False + assert manifest.row_count == 1 + assert {total.name: total.value for total in manifest.control_totals} == { + "impressions": "10", + "clicks": "0", + } + cells = {cell.metric: cell for cell in manifest.metric_availability} + declared = { + metric.name: metric for metric in source.capabilities.offering(request.offering_id).metrics + } + for metric, _, status, through, reason in cases: + cell = cells[metric] + assert (cell.status, cell.data_through, cell.reason) == (status, through, reason) + contract = declared[metric] + assert ( + cell.semantic_contract_id, + cell.semantic_contract_version, + cell.semantic_contract_sha256, + ) == ( + contract.semantic_contract_id, + contract.semantic_contract_version, + contract.semantic_contract_sha256, + ) + + +@pytest.mark.parametrize( + "fallback", ["present", "missing", "unsupported", "delayed", "stale", "partial"] +) +async def test_explicit_cells_override_constituent_defaults(fallback: str) -> None: + request = _request() + fields: dict[str, Any] = {} + if fallback == "missing": + fields["covered_constituent_ids"] = () + elif fallback != "present": + fields.update( + unavailable_constituents={CID: "constituent_default"}, unavailable_status=fallback + ) + explicit = ( + MetricEvidence.unavailable("metric_override") + if fallback == "present" + else MetricEvidence.present(request.period.source_read_cutoff_at) + ) + manifest = await _seal( + InlineFetchResult(rows=[ROW], cell_availability={CID: {"impressions": explicit}}, **fields), + request, + ) + cells = {cell.metric: cell for cell in manifest.metric_availability} + assert cells["impressions"].status == explicit.status + assert cells["impressions"].reason == explicit.reason + assert {cells[metric].status for metric in METRICS[1:]} == {fallback} + assert manifest.coverage.constituents[0].status == "partial" + + +async def test_explicit_cells_can_replace_all_missing_constituent_defaults() -> None: + request = _request() + manifest = await _seal( + InlineFetchResult( + rows=[ROW], + covered_constituent_ids=(), + cell_availability={ + CID: { + metric: MetricEvidence.present(request.period.source_read_cutoff_at) + for metric in METRICS + } + }, + ), + request, + ) + assert manifest.coverage.status == "full" + assert not manifest.warnings + assert len(manifest.control_totals) == len(METRICS) + + +async def test_present_and_explicit_zero_are_full_coverage() -> None: + manifest = await _seal( + InlineFetchResult( + rows=[ROW], cell_availability={CID: {"clicks": MetricEvidence.explicit_zero()}} + ) + ) + assert manifest.coverage.status == "full" + assert manifest.coverage.constituents[0].status == "present" + assert manifest.explicit_zero is False + + +@pytest.mark.parametrize("status", ["missing", "delayed", "unsupported"]) +async def test_uniform_unavailability_does_not_become_partial_or_zero(status: str) -> None: + evidence = MetricEvidence(status=status, reason="source_reason") + manifest = await _seal( + InlineFetchResult( + rows=[], cell_availability={CID: {metric: evidence for metric in METRICS}} + ) + ) + assert manifest.coverage.status == "none" + assert manifest.coverage.constituents[0].status == status + assert manifest.coverage.constituents[0].reason == "source_reason" + assert not manifest.explicit_zero + assert not manifest.control_totals + + +async def test_zero_row_mixed_unavailability_has_no_manufactured_measurements() -> None: + manifest = await _seal( + InlineFetchResult( + rows=[], + covered_constituent_ids=(), + cell_availability={ + CID: {"completed_views": MetricEvidence.unavailable("not_video_inventory")} + }, + ) + ) + assert manifest.coverage.status == "partial" + assert {cell.status for cell in manifest.metric_availability} == {"missing", "unsupported"} + assert not manifest.control_totals + assert manifest.row_count == 0 + assert not manifest.explicit_zero + + +async def test_full_request_with_mixed_cells_fails_without_staging() -> None: + request = _request() + request = request.model_copy( + update={"coverage": request.coverage.model_copy(update={"expected": "full"})} + ) + source = _source( + lambda req: InlineFetchResult( + rows=[ROW], + cell_availability=metric_unsupported_everywhere( + req, "completed_views", "not_video_inventory" + ), + ), + staging=_NoStaging(), + ) + error = validate_reporting_source_failure( + await source.execute(request, cancel=asyncio.Event()), "PARTIAL_RESULT" + ) + assert error.retry == "retryable" + + +class _NoStaging(InMemoryStagingStore): + async def stage(self, **kwargs: Any) -> tuple[str, str]: + pytest.fail("invalid evidence must be rejected before staging") + + +class _RepeatedMapping(Mapping[str, Any]): + """A mapping that exposes repeated entries before a dict would discard them.""" + + def __init__(self, key: str, value: Any) -> None: + self.key, self.value = key, value + + def __getitem__(self, key: str) -> Any: + return self.value + + def __iter__(self) -> Iterator[str]: + return iter([self.key, self.key]) + + def __len__(self) -> int: + return 2 + + +@pytest.mark.parametrize( + ("availability", "error"), + [ + ({"media-buy-redacted": {}}, "unknown constituent_id 'media-buy-redacted'"), + ({"other": {}}, "unknown constituent_id 'other'"), + ({CID: {"spend": MetricEvidence.explicit_zero()}}, "unknown requested metric 'spend'"), + ({CID: {"other": MetricEvidence.explicit_zero()}}, "unknown requested metric 'other'"), + ({(CID, "clicks"): MetricEvidence.explicit_zero()}, "unknown constituent_id"), + ({CID: {42: MetricEvidence.explicit_zero()}}, "unknown requested metric 42"), + ([], "must be a nested mapping"), + ({CID: []}, "must be a metric mapping"), + ( + { + CID: { + "clicks": { + "status": "explicit_zero", + "semantic_contract_id": "adapter_override", + } + } + }, + "must be MetricEvidence", + ), + ({CID: {"clicks": None}}, "must be MetricEvidence"), + ({CID: _RepeatedMapping("clicks", MetricEvidence.explicit_zero())}, "duplicate cell"), + ( + _RepeatedMapping(CID, {"clicks": MetricEvidence.explicit_zero()}), + "duplicate constituent_id", + ), + ], +) +async def test_invalid_matrices_fail_clearly_before_staging_or_sealing( + availability: Any, error: str +) -> None: + request = _request() + seals = InMemorySealStore() + source = _source( + lambda req: InlineFetchResult(rows=[ROW], cell_availability=availability), + staging=_NoStaging(), + seals=seals, + ) + with pytest.raises(ValueError, match=error): + await source.execute(request, cancel=asyncio.Event()) + assert ( + await seals.get( + account_id=request.identity.account_id, + source_execution_key=request.identity.source_execution_key, + ) + is None + ) + + +@pytest.mark.parametrize("value", [1, -1, "0.1", True, False, "not_numeric", "NaN", "Infinity"]) +async def test_explicit_zero_cannot_hide_a_contradictory_row_value(value: Any) -> None: + source = _source( + lambda req: InlineFetchResult( + rows=[{**ROW, "clicks": value}], + cell_availability={CID: {"clicks": MetricEvidence.explicit_zero()}}, + ), + staging=_NoStaging(), + ) + with pytest.raises(ValueError, match="explicit_zero requires numeric zero row values"): + await source.execute(_request(), cancel=asyncio.Event()) + + +@pytest.mark.parametrize( + "rows", + [ + [], + [{"media_buy_id": "media-buy-redacted"}], + [{**ROW, "impressions": None}], + [ROW, {"media_buy_id": "media-buy-redacted"}], + ], +) +async def test_explicit_present_requires_values_in_its_constituent_rows(rows: Any) -> None: + source = _source( + lambda req: InlineFetchResult( + rows=rows, + cell_availability={ + CID: {"impressions": MetricEvidence.present(req.period.source_read_cutoff_at)} + }, + ), + staging=_NoStaging(), + ) + with pytest.raises(ValueError, match="present requires a value in every constituent row"): + await source.execute(_request(), cancel=asyncio.Event()) + + +async def test_zero_row_available_and_unavailable_mix_is_rejected_without_a_wire_change() -> None: + source = _source( + lambda req: InlineFetchResult( + rows=[], + cell_availability={ + CID: {"completed_views": MetricEvidence.unavailable("not_video_inventory")} + }, + ), + staging=_NoStaging(), + ) + with pytest.raises( + ValueError, match="cannot mix available and unavailable cells in a zero-row batch" + ): + await source.execute(_request(), cancel=asyncio.Event()) + + +@pytest.mark.parametrize("status", ["present", "explicit_zero"]) +async def test_cell_watermarks_do_not_bypass_the_authoritative_freshness_gate(status: str) -> None: + request = _request(authoritative=True) + through = request.period.end - timedelta(hours=4) + manifest = await _seal( + InlineFetchResult( + rows=[ROW], + cell_availability={ + CID: {"clicks": MetricEvidence(status=status, data_through=through)} + }, + ), + request, + ) + cell = next(cell for cell in manifest.metric_availability if cell.metric == "clicks") + assert (cell.status, cell.data_through) == ("delayed", through) + assert cell.reason + assert manifest.coverage.status == "partial" + assert "clicks" not in {total.name for total in manifest.control_totals} + + +@pytest.mark.parametrize("authoritative", [False, True]) +async def test_newer_explicit_watermark_does_not_advance_fallback_cells( + authoritative: bool, +) -> None: + request = _request(authoritative=authoritative) + through = min(request.period.end, request.period.source_read_cutoff_at) + fallback_through = through - timedelta(hours=4) + manifest = await _seal( + InlineFetchResult( + rows=[ROW], + data_through=fallback_through, + cell_availability={CID: {"impressions": MetricEvidence.present(through)}}, + ), + request, + ) + cells = {cell.metric: cell for cell in manifest.metric_availability} + assert manifest.data_through == through + assert cells["impressions"].data_through == through + assert cells["impressions"].status == "present" + assert cells["clicks"].status == ("delayed" if authoritative else "present") + assert cells["clicks"].data_through == (None if authoritative else fallback_through) + assert manifest.coverage.constituents[0].data_through == ( + None if authoritative else fallback_through + ) + + +async def test_explicit_watermarks_are_bounded_by_the_frozen_cutoff_and_observation() -> None: + request = _request() + observed_at = request.period.source_read_cutoff_at - timedelta(hours=1) + source = InlineReportingSource( + capabilities=_capabilities(), + fetch=lambda req: InlineFetchResult( + rows=[ROW], + cell_availability={ + CID: {"impressions": MetricEvidence.present(req.period.end + timedelta(days=1))} + }, + ), + clock=lambda: observed_at, + ) + manifest = await run_reporting_source_replay_conformance( + executor=source, request=request, object_reader=source.staging + ) + assert manifest.data_through == observed_at + assert manifest.metric_availability[0].data_through == observed_at + + +async def test_explicit_watermarks_before_the_period_are_not_promoted_to_fresh_data() -> None: + source = _source( + lambda req: InlineFetchResult( + rows=[ROW], + cell_availability={ + CID: { + "impressions": MetricEvidence.present(req.period.start - timedelta(seconds=1)) + } + }, + ), + staging=_NoStaging(), + ) + with pytest.raises(ValueError, match="data_through must not precede the reporting period"): + await source.execute(_request(), cancel=asyncio.Event()) + + +@pytest.mark.parametrize("status", ["missing", "delayed", "unsupported"]) +async def test_control_total_needs_every_requested_cell_even_when_rows_have_values( + status: str, +) -> None: + request = _request(second=True) + manifest = await _seal( + InlineFetchResult( + rows=[ROW, {**ROW, "media_buy_id": "media-buy-second"}], + cell_availability={ + SECOND_CID: { + "completed_views": MetricEvidence(status=status, reason="source_reason") + } + }, + ), + request, + ) + assert {total.name: total.value for total in manifest.control_totals} == { + "impressions": "20", + "clicks": "0", + "viewability": "1.50", + } + + +@pytest.mark.parametrize("value", [None, True, "NaN", "Infinity", "not_numeric"]) +async def test_complete_cell_status_does_not_make_invalid_values_totalable(value: Any) -> None: + manifest = await _seal(InlineFetchResult(rows=[{**ROW, "viewability": value}])) + assert "viewability" not in {total.name for total in manifest.control_totals} + + +async def test_omitted_unavailable_metrics_do_not_suppress_other_metric_totals() -> None: + request = _request(second=True) + manifest = await _seal( + InlineFetchResult( + rows=[ + ROW, + { + **{key: value for key, value in ROW.items() if key != "completed_views"}, + "media_buy_id": "media-buy-second", + }, + ], + cell_availability={ + SECOND_CID: {"completed_views": MetricEvidence.missing("not_returned")} + }, + ), + request, + ) + assert {total.name for total in manifest.control_totals} == set(METRICS) - {"completed_views"} + + +async def test_unmatched_rows_are_retained_but_cannot_inflate_requested_totals() -> None: + manifest = await _seal([ROW, {**ROW, "media_buy_id": "not_requested"}]) + assert manifest.row_count == 2 + assert manifest.warnings + assert not manifest.control_totals + + +async def test_missing_zero_row_constituent_does_not_contribute_an_invented_zero_total() -> None: + request = _request(second=True) + manifest = await _seal(InlineFetchResult(rows=[ROW], covered_constituent_ids=[CID]), request) + assert manifest.coverage.status == "partial" + assert not manifest.control_totals + + +async def test_a_covered_zero_row_constituent_keeps_zeros_only_for_its_available_metrics() -> None: + request = _request(second=True) + manifest = await _seal( + InlineFetchResult( + rows=[ROW], + cell_availability={ + SECOND_CID: {"completed_views": MetricEvidence.unavailable("not_video_inventory")} + }, + ), + request, + ) + assert manifest.coverage.constituents[1].status == "partial" + assert not manifest.explicit_zero + for cell in manifest.metric_availability: + if cell.constituent_id == SECOND_CID: + assert cell.status == ( + "unsupported" if cell.metric == "completed_views" else "explicit_zero" + ) + assert {total.name: total.value for total in manifest.control_totals} == { + "impressions": "10", + "clicks": "0", + "viewability": "0.75", + } + + +async def test_explicit_zero_evidence_for_every_cell_can_seal_a_real_empty_period() -> None: + manifest = await _seal( + InlineFetchResult( + rows=[], + cell_availability={CID: {metric: MetricEvidence.explicit_zero() for metric in METRICS}}, + ) + ) + assert manifest.explicit_zero + assert manifest.coverage.status == "full" + assert manifest.coverage.constituents[0].status == "explicit_zero" + assert {total.name: total.value for total in manifest.control_totals} == dict.fromkeys( + METRICS, "0" + ) + + +async def test_declared_zero_without_a_row_value_does_not_manufacture_a_total() -> None: + manifest = await _seal( + InlineFetchResult( + rows=[{key: value for key, value in ROW.items() if key != "clicks"}], + cell_availability={CID: {"clicks": MetricEvidence.explicit_zero()}}, + ) + ) + assert manifest.metric_availability[1].status == "explicit_zero" + assert "clicks" not in {total.name for total in manifest.control_totals} + + +@pytest.mark.parametrize("pattern", ["unsupported", "delayed"]) +async def test_bulk_helpers_cover_only_the_requested_metric(pattern: str) -> None: + request = _request(second=True) + through = request.period.source_read_cutoff_at - timedelta(hours=4) + overrides = ( + metric_unsupported_everywhere(request, "completed_views", "not_video_inventory") + if pattern == "unsupported" + else metric_delayed_through(request, "completed_views", through) + ) + manifest = await _seal( + InlineFetchResult( + rows=[ROW, {**ROW, "media_buy_id": "media-buy-second"}], cell_availability=overrides + ), + request, + ) + for cell in manifest.metric_availability: + if cell.metric == "completed_views": + assert cell.status == pattern + assert cell.reason + assert cell.data_through == (through if pattern == "delayed" else None) + else: + assert cell.status == "present" + + +async def test_replay_uses_sealed_cell_evidence_after_the_adapter_changes() -> None: + request = _request() + overrides = metric_unsupported_everywhere(request, "completed_views", "not_video_inventory") + rows = [dict(ROW)] + calls = 0 + + def fetch(req: ReportingSourceSliceRequestV1) -> InlineFetchResult: + nonlocal calls + calls += 1 + return InlineFetchResult(rows=rows, cell_availability=overrides) + + source = _source(fetch) + first = await source.execute(request, cancel=asyncio.Event()) + overrides[CID]["completed_views"] = MetricEvidence.present(request.period.source_read_cutoff_at) + rows[0]["completed_views"] = 999 + cancel = asyncio.Event() + cancel.set() + replay = await source.execute(request, cancel=cancel) + assert first.ok and replay.ok + assert calls == 1 + assert replay.manifest_bytes == first.manifest_bytes + assert replay.response == first.response + manifest = await validate_reporting_source_execution( + capabilities=source.capabilities, + request=request, + result=replay, + object_reader=source.staging, + ) + assert manifest.metric_availability[-1].status == "unsupported" + + +async def test_mapping_order_does_not_change_sealed_identity() -> None: + request = _request(second=True) + overrides = { + CID: { + "clicks": MetricEvidence.explicit_zero(), + "completed_views": MetricEvidence.unavailable("not_video_inventory"), + }, + SECOND_CID: {"clicks": MetricEvidence.explicit_zero()}, + } + rows = [ROW, {**ROW, "media_buy_id": "media-buy-second"}] + first = _source(lambda req: InlineFetchResult(rows=rows, cell_availability=overrides)) + reverse = { + cid: dict(reversed(list(metrics.items()))) + for cid, metrics in reversed(list(overrides.items())) + } + second = _source(lambda req: InlineFetchResult(rows=rows, cell_availability=reverse)) + a = await first.execute(request, cancel=asyncio.Event()) + b = await second.execute(request, cancel=asyncio.Event()) + assert a.ok and b.ok + assert a.manifest_bytes == b.manifest_bytes + + +async def test_status_and_reason_are_bound_by_the_content_fingerprint() -> None: + fingerprints = set() + for evidence in [ + MetricEvidence.unavailable("not_video_inventory"), + MetricEvidence.unavailable("not_measured"), + MetricEvidence.missing("not_measured"), + ]: + manifest = await _seal( + InlineFetchResult(rows=[ROW], cell_availability={CID: {"completed_views": evidence}}) + ) + fingerprints.add(manifest.content_fingerprint) + assert len(fingerprints) == 3 + + +@pytest.mark.parametrize("rows", [[ROW], []]) +async def test_legacy_rows_results_and_empty_overrides_seal_identically(rows: Any) -> None: + request = _request() + results = [] + for answer in [ + rows, + InlineFetchResult(rows), + InlineFetchResult(rows, cell_availability={}), + InlineFetchResult(rows, cell_availability={CID: {}}), + InlineFetchResult.all_present(rows, data_through=request.period.source_read_cutoff_at), + ]: + source = _source(lambda req: answer) + result = await source.execute(request, cancel=asyncio.Event()) + assert result.ok + results.append(result.manifest_bytes) + assert all(payload == results[0] for payload in results) + + +async def test_existing_positional_result_arguments_keep_their_meaning() -> None: + request = _request() + result = InlineFetchResult( + [ROW], request.period.source_read_cutoff_at, None, {}, "unsupported", ("legacy_note",) + ) + assert result.cell_availability is None + manifest = await _seal(result, request) + assert manifest.coverage.status == "full" + assert manifest.warnings == ["legacy_note"] diff --git a/tests/test_reporting_metric_evidence.py b/tests/test_reporting_metric_evidence.py new file mode 100644 index 000000000..c3b860d0b --- /dev/null +++ b/tests/test_reporting_metric_evidence.py @@ -0,0 +1,136 @@ +"""Construction-time contracts for the inline adapter's typed evidence surface.""" + +import asyncio +from dataclasses import FrozenInstanceError +from datetime import datetime, timezone +from typing import Any + +import pytest + +from adcp.reporting.conformance import validate_reporting_source_failure +from adcp.reporting.fixtures import redacted_capabilities, redacted_snapshot_request +from adcp.reporting.inline_source import ( + InlineFetchResult, + InlineReportingSource, + MetricEvidence, + metric_delayed_through, + metric_unsupported_everywhere, +) + +WATERMARK = datetime(2026, 11, 1, 12, tzinfo=timezone.utc) + + +@pytest.mark.parametrize( + ("fields", "message"), + [ + ({"status": "unavailable"}, "status is not a supported cell status"), + ({"status": "partial", "reason": "partial"}, "status is not a supported cell status"), + ({"status": "present"}, "present requires data_through"), + ( + {"status": "present", "data_through": WATERMARK, "reason": "reason"}, + "present must not carry a reason", + ), + ({"status": "explicit_zero", "reason": "reason"}, "explicit_zero must not carry a reason"), + ({"status": "missing"}, "missing requires a stable reason"), + ({"status": "delayed", "data_through": WATERMARK}, "delayed requires a stable reason"), + ({"status": "unsupported"}, "unsupported requires a stable reason"), + ( + {"status": "missing", "reason": "reason", "data_through": WATERMARK}, + "missing must not carry data_through", + ), + ( + {"status": "unsupported", "reason": "reason", "data_through": WATERMARK}, + "unsupported must not carry data_through", + ), + ( + {"status": "present", "data_through": WATERMARK.replace(tzinfo=None)}, + "data_through must be a timezone-aware datetime", + ), + ( + {"status": "explicit_zero", "data_through": "2026-11-01"}, + "data_through must be a timezone-aware datetime", + ), + ], +) +def test_invalid_evidence_is_rejected_at_construction(fields: dict[str, Any], message: str) -> None: + with pytest.raises(ValueError, match=message): + MetricEvidence(**fields) + + +@pytest.mark.parametrize( + "reason", ["", " ", " leading", "trailing ", "not\nsafe", "non_ascii_é", "x" * 513, 42] +) +def test_reasons_use_the_existing_wire_evidence_contract(reason: Any) -> None: + with pytest.raises(ValueError, match="reason must be a wire-valid stable reason"): + MetricEvidence.unavailable(reason) + + +def test_reason_at_the_wire_limit_is_accepted() -> None: + assert MetricEvidence.missing("x" * 512).reason == "x" * 512 + + +@pytest.mark.parametrize( + "evidence", + [ + MetricEvidence.present(WATERMARK), + MetricEvidence.explicit_zero(), + MetricEvidence.explicit_zero(data_through=WATERMARK), + MetricEvidence.missing("not_returned"), + MetricEvidence.delayed("processing"), + MetricEvidence.delayed("processing", data_through=WATERMARK), + MetricEvidence.unavailable("not_video_inventory"), + ], +) +def test_evidence_is_immutable(evidence: MetricEvidence) -> None: + with pytest.raises(FrozenInstanceError): + setattr(evidence, "reason", "changed") + + +@pytest.mark.parametrize( + "field", ["semantic_contract_id", "semantic_contract_version", "semantic_contract_sha256"] +) +def test_adapters_cannot_supply_semantic_contract_fields(field: str) -> None: + fields = {"status": "present", "data_through": WATERMARK, field: "adapter_override"} + with pytest.raises(TypeError, match="unexpected keyword argument"): + MetricEvidence(**fields) + + +@pytest.mark.parametrize("pattern", ["unsupported", "delayed"]) +def test_bulk_helpers_reject_unrequested_metrics(pattern: str) -> None: + request = redacted_snapshot_request() + with pytest.raises(ValueError, match="unknown requested metric 'completed_views'"): + if pattern == "unsupported": + metric_unsupported_everywhere(request, "completed_views", "not_video_inventory") + else: + metric_delayed_through(request, "completed_views", WATERMARK) + + +@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) +async def test_constructor_errors_inside_fetch_remain_actionable(asynchronous: bool) -> None: + def invalid(req: Any) -> InlineFetchResult: + return InlineFetchResult( + rows=[], + cell_availability={"campaign-redacted-1": {"impressions": MetricEvidence("present")}}, + ) + + async def invalid_async(req: Any) -> InlineFetchResult: + return invalid(req) + + source = InlineReportingSource( + capabilities=redacted_capabilities(), fetch=invalid_async if asynchronous else invalid + ) + with pytest.raises(ValueError, match="MetricEvidence present requires data_through"): + await source.execute(redacted_snapshot_request(), cancel=asyncio.Event()) + + +async def test_ordinary_provider_value_errors_keep_their_legacy_classification() -> None: + def fetch(req: Any) -> None: + raise ValueError("provider diagnostic detail") + + source = InlineReportingSource(capabilities=redacted_capabilities(), fetch=fetch) + error = validate_reporting_source_failure( + await source.execute(redacted_snapshot_request(), cancel=asyncio.Event()), + "PROVIDER_TRANSIENT", + ) + assert error.retry == "retryable" + assert error.safe_message == "the reporting source raised ValueError" diff --git a/tests/type_checks/reporting_inline_source.py b/tests/type_checks/reporting_inline_source.py new file mode 100644 index 000000000..57f2d8dc0 --- /dev/null +++ b/tests/type_checks/reporting_inline_source.py @@ -0,0 +1,118 @@ +"""Adopter patterns: typed synchronous/asynchronous fetches and metric evidence. + +These adapters assume an offering requesting impressions, clicks, viewability, +and completed_views. The SDK supplies each metric's semantic-contract identity. +""" + +from collections.abc import Awaitable, Callable, Mapping, Sequence +from datetime import timedelta + +from adcp.reporting.inline_source import ( + InlineFetch, + InlineFetchResult, + InlineReportingSource, + MetricEvidence, + metric_delayed_through, + metric_unsupported_everywhere, +) +from adcp.reporting.source import ( + ReportingSourceCapabilitiesV1, + ReportingSourceExecutor, + ReportingSourceSliceRequestV1, +) + + +def read_rows(request: ReportingSourceSliceRequestV1) -> Sequence[Mapping[str, object]]: + return [ + {"constituent_id": item.constituent_id, "impressions": 120, "clicks": 0} + for item in request.coverage.constituents + ] + + +def fetch_sync(request: ReportingSourceSliceRequestV1) -> InlineFetchResult: + through = min(request.period.end, request.period.source_read_cutoff_at) + evidence: dict[str, dict[str, MetricEvidence]] = { + item.constituent_id: { + "impressions": MetricEvidence.present(through), + "clicks": MetricEvidence.explicit_zero(data_through=through), + "viewability": MetricEvidence.delayed( + "measurement_pending", data_through=through - timedelta(hours=1) + ), + "completed_views": MetricEvidence.unavailable("not_video_inventory"), + } + for item in request.coverage.constituents + } + return InlineFetchResult(rows=read_rows(request), cell_availability=evidence) + + +def async_source( + capabilities: ReportingSourceCapabilitiesV1, + read: Callable[ + [ReportingSourceSliceRequestV1], Awaitable[Sequence[Mapping[str, object]] | None] + ], +) -> ReportingSourceExecutor: + async def fetch(request: ReportingSourceSliceRequestV1) -> InlineFetchResult | None: + rows = await read(request) + if rows is None: + return None + return InlineFetchResult( + rows=rows, + cell_availability={ + item.constituent_id: { + "viewability": MetricEvidence.missing("not_returned"), + "completed_views": MetricEvidence.unavailable("not_video_inventory"), + } + for item in request.coverage.constituents + }, + ) + + async_fetch: InlineFetch = fetch + return InlineReportingSource(capabilities=capabilities, fetch=async_fetch) + + +def unsupported_video(request: ReportingSourceSliceRequestV1) -> InlineFetchResult: + return InlineFetchResult( + rows=[{**row, "viewability": "0.75"} for row in read_rows(request)], + cell_availability=metric_unsupported_everywhere( + request, "completed_views", "not_video_inventory" + ), + ) + + +def delayed_viewability(request: ReportingSourceSliceRequestV1) -> InlineFetchResult: + return InlineFetchResult( + rows=[{**row, "completed_views": 30} for row in read_rows(request)], + cell_availability=metric_delayed_through( + request, + "viewability", + request.period.source_read_cutoff_at - timedelta(hours=1), + reason="measurement_pending", + ), + ) + + +def all_present(request: ReportingSourceSliceRequestV1) -> InlineFetchResult: + return InlineFetchResult.all_present( + read_complete_rows(request), data_through=request.period.source_read_cutoff_at + ) + + +def read_complete_rows(request: ReportingSourceSliceRequestV1) -> Sequence[Mapping[str, object]]: + return [{**row, "viewability": "0.75", "completed_views": 30} for row in read_rows(request)] + + +def configure_sources( + capabilities: ReportingSourceCapabilitiesV1, +) -> tuple[ReportingSourceExecutor, ...]: + sync_fetch: InlineFetch = fetch_sync + # Bare row callbacks and richer results remain accepted. + return tuple( + InlineReportingSource(capabilities=capabilities, fetch=fetch) + for fetch in ( + sync_fetch, + read_complete_rows, + unsupported_video, + delayed_viewability, + all_present, + ) + ) From 7fd88c6710c26211484032bdd0e51fcd1077e6ed Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 16 Sep 2026 01:02:56 +0000 Subject: [PATCH 2/4] fix(reporting): keep legacy control totals when no cell is declared Per-cell evidence withdrew a metric's control total whenever *any* cell for it was unavailable, and dropped every total whenever a staged row matched no requested constituent. Both conditions are reachable with no `cell_availability` at all, so five documented legacy shapes -- a `None` not-ready answer, `covered_constituent_ids`, `unavailable_constituents`, a non-default `unavailable_status`, and bare rows with one unmatched row -- silently changed their sealed bytes, `content_fingerprint`, ledger `control_totals`, and `revision_content_sha256` for adopters who never opted in. A full-coverage manifest whose every cell was `present` lost its totals entirely because one extra row was staged. A control total is a checksum over the staged rows: `reporting_inspection` and `_reconcile` verify it by recomputing from the revision's rows, so unmatched rows belong in it and a derived constituent status says nothing about it. Withdraw a column only when the adapter explicitly declared a cell of that metric unavailable -- the case where row values exist but the adapter has said they are not a measurement. Also stop the zero-row mixing guard from naming `cell_availability`: a derived result reaches it too, and an adopter who never set that field should not be sent looking for it. Verified all twelve representative legacy shapes now seal byte-identical to origin/main, and re-ran 10,800 randomized override/fallback combinations through the execution conformance validator. Co-Authored-By: Claude Opus 5 (1M context) --- docs/reporting-source-adapters.md | 24 +-- src/adcp/reporting/inline_source.py | 40 +++-- .../test_inline_cell_availability.py | 142 +++++++++++++++++- 3 files changed, 175 insertions(+), 31 deletions(-) diff --git a/docs/reporting-source-adapters.md b/docs/reporting-source-adapters.md index 8f62573e2..da2d07ad5 100644 --- a/docs/reporting-source-adapters.md +++ b/docs/reporting-source-adapters.md @@ -68,14 +68,17 @@ does not reach period end becomes `delayed`, with a reason and its watermark. Cell evidence cannot bypass that freshness gate. Rows can omit missing, unsupported, or delayed metrics; available measurements -for other metrics are retained. A control total is emitted only when every -requested constituent's cell for that metric is present or explicit-zero and -every staged row contains a valid finite numeric value for it. Missing fields, -nulls, booleans, and invalid numbers prevent a total. An omitted explicit-zero -row value is not filled in. A covered constituent with no rows can contribute -an observed zero; a missing constituent cannot. Rows outside the requested -coverage remain staged with a warning and prevent totals from claiming the -requested denominator. +for other metrics are retained. A control total is a checksum over the staged +rows -- a consumer recomputes it from the revision's rows -- so it covers every +staged row, including rows outside the requested coverage, which stay staged +with a warning. It is emitted when every staged row carries a valid finite +numeric value for the metric. Missing fields, nulls, booleans, and invalid +numbers prevent a total, and an omitted explicit-zero row value is not filled +in. Declaring any cell of a metric `missing`, `delayed`, or `unsupported` +withdraws that metric's total even when rows still carry values, because the +adapter has said those values are not a measurement. Statuses the SDK derives +on its own withdraw nothing: a result with no `cell_availability` publishes +exactly the totals it published before. The existing zero-row wire rule is unchanged: an empty batch must be wholly explicit-zero or wholly unavailable. It cannot mix available cells with @@ -95,9 +98,8 @@ return InlineFetchResult.all_present(rows, data_through=watermark) This uses the constituent defaults: constituents with rows are present and covered constituents without rows are observed zeros. Bare `[]` still means an observed zero; `None` still means not ready. Existing positional -`InlineFetchResult` arguments retain their meaning. Totals for incomplete -denominators or unmatched rows are now omitted instead of implying complete -measurements, including for legacy results. +`InlineFetchResult` arguments retain their meaning, and so do their control +totals -- only an explicitly declared cell withdraws one. Bulk helpers return maps to pass to `cell_availability`, leaving the result's other options available: diff --git a/src/adcp/reporting/inline_source.py b/src/adcp/reporting/inline_source.py index 5ad91d430..5b9209530 100644 --- a/src/adcp/reporting/inline_source.py +++ b/src/adcp/reporting/inline_source.py @@ -15,9 +15,9 @@ For uneven metric support, return :class:`InlineFetchResult` with ``cell_availability={constituent_id: {metric_name: MetricEvidence(...)}}``. Omitted cells retain the constituent defaults. Explicit evidence controls each -cell independently; mixed cells make the constituent partial, and incomplete -metric columns receive no control total. Metric semantics always come from the -selected SDK offering. +cell independently; mixed cells make the constituent partial, and a metric +column an adapter declares incomplete receives no control total. Metric +semantics always come from the selected SDK offering. The three answers a fetch can give --------------------------------- @@ -776,11 +776,23 @@ async def _publish( and not explicit_zero and any(cell.status in _AVAILABLE for cell in cells) ): + # Reachable from a derived result too, so the message names the + # shape rather than the optional field an adopter may never have set. raise _CellEvidenceError( - "cell_availability cannot mix available and unavailable cells in a zero-row " - "batch; supply rows for observed metrics" + "a zero-row batch cannot mix available and unavailable cells; supply rows " + "for the observed metrics or withdraw their availability" ) - control_totals = [] if unmatched else _control_totals(request, result.rows, cells) + # Only a *declared* exception withdraws a column. A control total is + # a checksum over the staged rows -- consumers recompute it from the + # revision's rows -- so an unmatched row or a legacy-derived + # unavailable constituent leaves it verifiable and unchanged. + declared_incomplete = { + cell.metric + for cell in cells + if cell.status not in _AVAILABLE + and cell.metric in overrides.get(cell.constituent_id, {}) + } + control_totals = _control_totals(request, result.rows, declared_incomplete) payload = _encode_rows(result.rows) object_ref, object_generation = await self._staging.stage( account_id=request.identity.account_id, @@ -1191,19 +1203,23 @@ def _encode_rows(rows: Sequence[Mapping[str, Any]]) -> bytes: def _control_totals( request: ReportingSourceSliceRequestV1, rows: Sequence[Mapping[str, Any]], - cells: Sequence[ReportingMetricAvailabilityV1], + declared_incomplete: Collection[str], ) -> list[SourceControlTotalV1]: - """Sum only metrics whose entire requested column is available and valid. + """Sum each requested metric across the staged rows, exactly. Money sums through :class:`~decimal.Decimal` and is emitted as a string. A float total would round differently in two languages and turn a - consumer's equality check into a flake. Partial/delayed measurements may - still have row values, but those are not evidence of a complete total. + consumer's equality check into a flake. + + ``declared_incomplete`` names the metrics an adapter explicitly withdrew + for at least one cell. Those columns may still carry row values, but the + adapter has said they are not a measurement, so totalling them would + contradict the very evidence it supplied. Every other column keeps the + checksum a consumer recomputes from the revision's rows. """ totals: list[SourceControlTotalV1] = [] - incomplete_metrics = {cell.metric for cell in cells if cell.status not in _AVAILABLE} for metric in request.requested_metrics: - if metric in incomplete_metrics: + if metric in declared_incomplete: continue values = [row[metric] for row in rows if row.get(metric) is not None] if len(values) != len(rows): diff --git a/tests/conformance/reporting/test_inline_cell_availability.py b/tests/conformance/reporting/test_inline_cell_availability.py index 99053c2ec..98014a6e0 100644 --- a/tests/conformance/reporting/test_inline_cell_availability.py +++ b/tests/conformance/reporting/test_inline_cell_availability.py @@ -79,7 +79,12 @@ def _capabilities() -> ReportingSourceCapabilitiesV1: ) -def _request(*, authoritative: bool = False, second: bool = False) -> ReportingSourceSliceRequestV1: +def _request( + *, + authoritative: bool = False, + second: bool = False, + metrics: list[str] | None = None, +) -> ReportingSourceSliceRequestV1: base = redacted_authoritative_request() if authoritative else redacted_snapshot_request() constituents = list(base.coverage.constituents) if second: @@ -92,7 +97,7 @@ def _request(*, authoritative: bool = False, second: bool = False) -> ReportingS ) return base.model_copy( update={ - "requested_metrics": METRICS, + "requested_metrics": metrics or METRICS, "coverage": ReportingSourceCoverageRequestV1( expected="partial", constituents=constituents, @@ -274,9 +279,16 @@ async def test_zero_row_mixed_unavailability_has_no_manufactured_measurements() ) assert manifest.coverage.status == "partial" assert {cell.status for cell in manifest.metric_availability} == {"missing", "unsupported"} - assert not manifest.control_totals assert manifest.row_count == 0 assert not manifest.explicit_zero + # The declared exception withdraws its column. The derived cells keep the + # zero-row totals a legacy unavailable batch has always published, which + # the wire rule constrains to zero rather than forbidding. + assert {total.name: total.value for total in manifest.control_totals} == { + "impressions": "0", + "clicks": "0", + "viewability": "0", + } async def test_full_request_with_mixed_cells_fails_without_staging() -> None: @@ -418,11 +430,22 @@ async def test_zero_row_available_and_unavailable_mix_is_rejected_without_a_wire staging=_NoStaging(), ) with pytest.raises( - ValueError, match="cannot mix available and unavailable cells in a zero-row batch" + ValueError, match="a zero-row batch cannot mix available and unavailable cells" ): await source.execute(_request(), cancel=asyncio.Event()) +async def test_the_zero_row_mixing_error_does_not_blame_an_unused_field() -> None: + """A derived result reaches the same guard without ever naming a cell.""" + source = _source( + lambda req: InlineFetchResult(rows=[], covered_constituent_ids=[CID]), + staging=_NoStaging(), + ) + with pytest.raises(ValueError, match="a zero-row batch cannot mix") as caught: + await source.execute(_request(second=True), cancel=asyncio.Event()) + assert "cell_availability" not in str(caught.value) + + @pytest.mark.parametrize("status", ["present", "explicit_zero"]) async def test_cell_watermarks_do_not_bypass_the_authoritative_freshness_gate(status: str) -> None: request = _request(authoritative=True) @@ -554,18 +577,31 @@ async def test_omitted_unavailable_metrics_do_not_suppress_other_metric_totals() assert {total.name for total in manifest.control_totals} == set(METRICS) - {"completed_views"} -async def test_unmatched_rows_are_retained_but_cannot_inflate_requested_totals() -> None: +async def test_unmatched_rows_are_warned_about_and_stay_in_the_staged_checksum() -> None: manifest = await _seal([ROW, {**ROW, "media_buy_id": "not_requested"}]) assert manifest.row_count == 2 assert manifest.warnings - assert not manifest.control_totals + # A control total is recomputed from the revision's rows, so it must cover + # every staged row -- including one no constituent claimed. + assert {total.name: total.value for total in manifest.control_totals} == { + "impressions": "20", + "clicks": "0", + "viewability": "1.50", + "completed_views": "4", + } -async def test_missing_zero_row_constituent_does_not_contribute_an_invented_zero_total() -> None: +async def test_a_derived_missing_constituent_does_not_withdraw_its_neighbour_totals() -> None: request = _request(second=True) manifest = await _seal(InlineFetchResult(rows=[ROW], covered_constituent_ids=[CID]), request) assert manifest.coverage.status == "partial" - assert not manifest.control_totals + assert manifest.coverage.constituents[1].status == "missing" + assert {total.name: total.value for total in manifest.control_totals} == { + "impressions": "10", + "clicks": "0", + "viewability": "0.75", + "completed_views": "2", + } async def test_a_covered_zero_row_constituent_keeps_zeros_only_for_its_available_metrics() -> None: @@ -728,6 +764,96 @@ async def test_legacy_rows_results_and_empty_overrides_seal_identically(rows: An assert all(payload == results[0] for payload in results) +@pytest.mark.parametrize( + ("answer", "second", "totals"), + [ + pytest.param( + [ROW, {**ROW, "media_buy_id": "not_requested"}], + False, + {"impressions": "20", "clicks": "0", "viewability": "1.50", "completed_views": "4"}, + id="unmatched_row", + ), + pytest.param( + InlineFetchResult(rows=[ROW], covered_constituent_ids=[CID]), + True, + {"impressions": "10", "clicks": "0", "viewability": "0.75", "completed_views": "2"}, + id="uncovered_constituent", + ), + pytest.param( + InlineFetchResult(rows=[ROW], unavailable_constituents={SECOND_CID: "no_data"}), + True, + {"impressions": "10", "clicks": "0", "viewability": "0.75", "completed_views": "2"}, + id="unavailable_constituent", + ), + pytest.param( + InlineFetchResult( + rows=[ROW, {**ROW, "media_buy_id": "media-buy-second"}], + unavailable_constituents={SECOND_CID: "late"}, + unavailable_status="delayed", + ), + True, + {"impressions": "20", "clicks": "0", "viewability": "1.50", "completed_views": "4"}, + id="delayed_constituent", + ), + pytest.param( + None, + False, + {"impressions": "0", "clicks": "0", "viewability": "0", "completed_views": "0"}, + id="not_ready", + ), + ], +) +async def test_results_without_declared_cells_keep_their_legacy_control_totals( + answer: Any, second: bool, totals: dict[str, str] +) -> None: + """A caller that never opted in must not lose evidence it already published. + + These are the shapes where the derived constituent status is not uniformly + available. Withdrawing their totals would change sealed bytes, the content + fingerprint, and the ledger revision content hash for adopters who supplied + no ``cell_availability`` at all. + """ + manifest = await _seal(answer, _request(second=second)) + assert {total.name: total.value for total in manifest.control_totals} == totals + + +async def test_five_independent_statuses_seal_in_one_constituent_matrix() -> None: + metrics = [*METRICS, "spend"] + request = _request(metrics=metrics) + watermark = min(request.period.end, request.period.source_read_cutoff_at) + delayed_through = watermark - timedelta(hours=4) + expected = { + "impressions": ("present", watermark, None), + "clicks": ("explicit_zero", watermark, None), + "viewability": ("delayed", delayed_through, "measurement_pending"), + "completed_views": ("unsupported", None, "not_video_inventory"), + "spend": ("missing", None, "not_returned"), + } + manifest = await _seal( + InlineFetchResult( + rows=[{"media_buy_id": "media-buy-redacted", "impressions": 10, "clicks": 0}], + cell_availability={ + CID: { + "impressions": MetricEvidence.present(watermark), + "clicks": MetricEvidence.explicit_zero(), + "viewability": MetricEvidence.delayed( + "measurement_pending", data_through=delayed_through + ), + "completed_views": MetricEvidence.unavailable("not_video_inventory"), + "spend": MetricEvidence.missing("not_returned"), + } + }, + ), + request, + ) + assert manifest.coverage.constituents[0].status == "partial" + assert { + cell.metric: (cell.status, cell.data_through, cell.reason) + for cell in manifest.metric_availability + } == expected + assert {total.name for total in manifest.control_totals} == {"impressions", "clicks"} + + async def test_existing_positional_result_arguments_keep_their_meaning() -> None: request = _request() result = InlineFetchResult( From d0308e6e22f3329f95ff8d558dff95601f81c6ff Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 16 Sep 2026 06:52:47 +0000 Subject: [PATCH 3/4] test(reporting): verify joined evidence currency and durable records --- src/adcp/reporting/conformance.py | 13 +- src/adcp/reporting/inline_source.py | 10 +- src/adcp/reporting/ledger/delivery_pg.py | 5 +- src/adcp/reporting/ledger/pg.py | 9 +- src/adcp/reporting/ledger/store.py | 10 +- .../reporting/_reliable_support.py | 745 ++++++++++++++ tests/conformance/reporting/conftest.py | 1 + .../reporting/test_reliable_support.py | 143 +++ ...reporting_evidence_currency_integration.py | 920 ++++++++++++++++++ ...reporting_evidence_currency_integration.py | 44 + 10 files changed, 1880 insertions(+), 20 deletions(-) create mode 100644 tests/conformance/reporting/_reliable_support.py create mode 100644 tests/conformance/reporting/test_reliable_support.py create mode 100644 tests/conformance/reporting/test_reporting_evidence_currency_integration.py create mode 100644 tests/type_checks/reporting_evidence_currency_integration.py diff --git a/src/adcp/reporting/conformance.py b/src/adcp/reporting/conformance.py index 6680c8c49..86792f4cd 100644 --- a/src/adcp/reporting/conformance.py +++ b/src/adcp/reporting/conformance.py @@ -110,6 +110,7 @@ async def _with_deadline( *, deadline_at: datetime, cancel: asyncio.Event, + clock: Callable[[], datetime] | None = None, ) -> _T: """Run ``operation`` under the slice deadline, cancelling cooperatively first. @@ -118,7 +119,8 @@ async def _with_deadline( and settle. An executor that ignores the event is then hard-cancelled -- the harness stays bounded even when the thing it is grading does not. """ - remaining = (_utc(deadline_at) - datetime.now(timezone.utc)).total_seconds() + now = clock() if clock is not None else datetime.now(timezone.utc) + remaining = (_utc(deadline_at) - _utc(now)).total_seconds() if cancel.is_set() or remaining <= 0: raise _fail( "EXECUTION_FAILED", @@ -438,12 +440,14 @@ async def validate_reporting_source_execution( result: ReportingSourceExecutorResult, object_reader: ReportingSourceStagedObjectReader, cancel: asyncio.Event | None = None, + clock: Callable[[], datetime] | None = None, ) -> SourceBatchManifestV1: """Validate one execution end to end and return its verified manifest. Reads every staged object the manifest names and checks its bytes against the declared digest and size. A manifest whose objects cannot be read, or read differently than claimed, is not evidence of anything. + ``clock`` permits deterministic deadline checks for retained test slices. """ offering = _validate_request_against_capabilities(capabilities, request) if not result.ok: @@ -477,6 +481,7 @@ async def validate_reporting_source_execution( ), deadline_at=request.deadline_at, cancel=cancel, + clock=clock, ) except ReportingSourceConformanceError: raise @@ -499,13 +504,15 @@ async def run_reporting_source_replay_conformance( request: ReportingSourceSliceRequestV1, object_reader: ReportingSourceStagedObjectReader, cancel: asyncio.Event | None = None, + clock: Callable[[], datetime] | None = None, ) -> SourceBatchManifestV1: """Execute the same slice twice and require an identical immutable publication. Reusing a ``source_execution_key`` must return byte-identical manifest bytes and the same staged object set. This is the check that catches the two most common non-conformances: a ``now()`` timestamp baked into the manifest, and a - fresh UUID minted per attempt. + fresh UUID minted per attempt. ``clock`` supplies the deadline-check instant + for both executions and their staged-object reads. """ capabilities = executor.capabilities cancel = cancel or asyncio.Event() @@ -515,6 +522,7 @@ async def once() -> tuple[ReportingSourceExecutorResult, SourceBatchManifestV1]: lambda: executor.execute(request, cancel=cancel), deadline_at=request.deadline_at, cancel=cancel, + clock=clock, ) manifest = await validate_reporting_source_execution( capabilities=capabilities, @@ -522,6 +530,7 @@ async def once() -> tuple[ReportingSourceExecutorResult, SourceBatchManifestV1]: result=result, object_reader=object_reader, cancel=cancel, + clock=clock, ) return result, manifest diff --git a/src/adcp/reporting/inline_source.py b/src/adcp/reporting/inline_source.py index 0b6224b92..fc6aca914 100644 --- a/src/adcp/reporting/inline_source.py +++ b/src/adcp/reporting/inline_source.py @@ -398,16 +398,14 @@ class InMemoryStagingStore: """ def __init__(self) -> None: - self._objects: dict[tuple[str, str], bytes] = {} - self._scopes: dict[tuple[str, str], str] = {} + self._objects: dict[tuple[str, str, str], bytes] = {} async def stage( self, *, account_id: str, source_execution_key: str, ordinal: int, payload: bytes ) -> tuple[str, str]: digest = hashlib.sha256(payload).hexdigest() object_ref = f"{source_execution_key}.{ordinal}" - self._objects[(object_ref, digest)] = payload - self._scopes[(object_ref, digest)] = account_id + self._objects[(account_id, object_ref, digest)] = payload return object_ref, digest async def read( @@ -419,8 +417,8 @@ async def read( source_scope: Mapping[str, Any], cancel: asyncio.Event, ) -> bytes: - key = (object_ref, object_generation) - if self._scopes.get(key) != account_id: + key = (account_id, object_ref, object_generation) + if key not in self._objects: raise PermissionError("staged object is outside the requested account scope") return self._objects[key] diff --git a/src/adcp/reporting/ledger/delivery_pg.py b/src/adcp/reporting/ledger/delivery_pg.py index 7cdb19617..7e821a661 100644 --- a/src/adcp/reporting/ledger/delivery_pg.py +++ b/src/adcp/reporting/ledger/delivery_pg.py @@ -150,8 +150,8 @@ async def _append_reconciliation_change( await connection.execute( "INSERT INTO reporting_reconciliation_changes" " (account_id, consumer_id, seq, namespace, record_id, record_kind," - " change_id, content_sha256)" - " VALUES (%s, %s, %s, %s, %s, %s, %s, %s)", + " change_id, content_sha256, committed_at)" + " VALUES (%s, %s, %s, %s, %s, %s, %s, %s, COALESCE(%s, clock_timestamp()))", ( who.account_id, who.consumer_id, @@ -160,6 +160,7 @@ async def _append_reconciliation_change( record.kind, change_id(record), fingerprint(record), + self._clock() if self._clock is not None else None, ), ) diff --git a/src/adcp/reporting/ledger/pg.py b/src/adcp/reporting/ledger/pg.py index f5aa7b4f1..b39a90431 100644 --- a/src/adcp/reporting/ledger/pg.py +++ b/src/adcp/reporting/ledger/pg.py @@ -162,7 +162,7 @@ def __init__( if not PG_AVAILABLE: raise ImportError(_INSTALL_HINT) self._pool = pool - # The snapshot observation boundary. Defaults to the *database* clock, + # Change timestamps and the snapshot boundary default to the *database* clock, # which is what makes two readers of one snapshot agree even across # application hosts with drifting clocks -- do not override it in # production for that reason. Overriding is for replay, backfill, and @@ -190,10 +190,11 @@ async def _append_change( self, connection: Any, account_id: str, kind: LedgerRecordKind, record_id: str ) -> None: await connection.execute( - "INSERT INTO reporting_ledger_changes (account_id, record_kind, record_id)" - " VALUES (%s, %s, %s)" + "INSERT INTO reporting_ledger_changes" + " (account_id, record_kind, record_id, committed_at)" + " VALUES (%s, %s, %s, COALESCE(%s, now()))" " ON CONFLICT (account_id, record_kind, record_id) DO NOTHING", - (account_id, kind, record_id), + (account_id, kind, record_id, self._clock() if self._clock is not None else None), ) @staticmethod diff --git a/src/adcp/reporting/ledger/store.py b/src/adcp/reporting/ledger/store.py index cbde8e2a6..accd587b1 100644 --- a/src/adcp/reporting/ledger/store.py +++ b/src/adcp/reporting/ledger/store.py @@ -582,9 +582,9 @@ def check_issue_state_transition(current: str, requested: str) -> None: class InMemoryReportingLedgerStore: """Process-local reference store. Correct, ordered, and not durable. - ``clock`` supplies the snapshot observation boundary, standing in for the - database clock a durable store reads. Override it to place a test's ledger - boundary deliberately rather than wherever wall-clock time happens to fall. + ``clock`` supplies change timestamps and the snapshot observation boundary, + standing in for the database clock a durable store reads. Override it to + place a test's ledger boundary at a deliberate instant. """ def __init__(self, *, clock: Callable[[], datetime] | None = None) -> None: @@ -619,9 +619,7 @@ async def create_schema(self) -> None: def _append(self, account_id: str, kind: LedgerRecordKind, record_id: str) -> None: self._sequence += 1 - self._changes.append( - (self._sequence, account_id, kind, record_id, datetime.now(timezone.utc)) - ) + self._changes.append((self._sequence, account_id, kind, record_id, self._clock())) # -- configurations -------------------------------------------------- diff --git a/tests/conformance/reporting/_reliable_support.py b/tests/conformance/reporting/_reliable_support.py new file mode 100644 index 000000000..4399c628d --- /dev/null +++ b/tests/conformance/reporting/_reliable_support.py @@ -0,0 +1,745 @@ +"""Private deterministic building blocks for Reliable Reporting conformance slices. + +Domain writes always go through the real memory/PostgreSQL stores. The private +publisher seam only prepares typed evidence before Core's revision write; it is +not a public Managed Delivery worker. PostgreSQL artifacts live in the fixture's +isolated schema. Memory restart uses an explicit test image, not SDK durability. +Real time is used only for bounded deadlock watchdogs, never reporting evidence. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import threading +from collections import defaultdict, deque +from collections.abc import AsyncIterator, Callable, Mapping, Sequence +from contextlib import asynccontextmanager +from copy import deepcopy +from dataclasses import dataclass, field, replace +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any, Literal + +import pytest + +from adcp.reporting.canonical_json import canonical_json_utf8_v1 +from adcp.reporting.fixtures import ( + OFFICIAL_OFFERING_ID, + SNAPSHOT_OFFERING_ID, + redacted_capabilities, + redacted_contract_identity, +) +from adcp.reporting.inline_source import ( + InlineFetch, + InlineFetchResult, + InlineReportingSource, + InMemoryStagingStore, + MetricEvidence, + SealedSlice, +) +from adcp.reporting.ledger import ( + InMemoryReportingReconciliationStore, + PgReportingReconciliationStore, + ProducerOfferings, + ReportingCanonicalDigest, + ReportingConfiguration, + ReportingControlTotalRecord, + ReportingDefinitionBinding, + ReportingDeliveryScope, + ReportingDestinationBinding, + ReportingMaterializationAttempt, + ReportingMaterializationRecord, + ReportingObligationDeliveryRecord, + ReportingObligationRecord, + ReportingPhysicalChecksum, + ReportingProducer, + ReportingResourceRecord, + ReportingRevisionReceiptRecord, + ReportingRevisionRecord, + ReportingScheduleSpec, + ReportingVerificationRecord, + WorkerTurn, + revision_content_sha256, +) +from adcp.reporting.source import ( + MetricOfferingV1, + ReportingSourceCapabilitiesV1, + ReportingSourceExecutorResult, + ReportingSourceSliceRequestV1, + SourceBatchManifestReferenceV1, + SourceBatchManifestV1, + parse_verified_source_batch_manifest_v1, + reporting_source_capabilities_sha256_v1, +) + +from ._generation_support import END, NOW, START, isolated_reporting_pool + +if TYPE_CHECKING: + from psycopg_pool import AsyncConnectionPool + +Store = InMemoryReportingReconciliationStore | PgReportingReconciliationStore +Backend = Literal["memory", "postgres"] +METRICS = ("impressions", "clicks", "spend") + + +@dataclass +class ManualClock: + now: datetime = NOW + + def __post_init__(self) -> None: + if self.now.tzinfo is None or self.now.utcoffset() is None: + raise ValueError("ManualClock requires an aware instant") + + def __call__(self) -> datetime: + return self.now + + def advance(self, delta: timedelta = timedelta(milliseconds=1)) -> datetime: + if delta < timedelta(0): + raise ValueError("ManualClock cannot move backwards") + self.now += delta + return self.now + + +@dataclass +class Barrier: + entered: asyncio.Event = field(default_factory=asyncio.Event) + released: asyncio.Event = field(default_factory=asyncio.Event) + + async def pause(self) -> None: + self.entered.set() + await asyncio.wait_for(self.released.wait(), timeout=10) + + async def wait(self) -> None: + await asyncio.wait_for(self.entered.wait(), timeout=10) + + def release(self) -> None: + self.released.set() + + +class FailurePlan: + """Finite, named fault/barrier scripts; no probabilistic failure or sleeps.""" + + def __init__(self) -> None: + self.steps: dict[str, deque[Exception | Barrier]] = defaultdict(deque) + self.hits: list[str] = [] + + def at(self, point: str, *steps: Exception | Barrier) -> None: + self.steps[point].extend(steps) + + async def hit(self, point: str) -> None: + self.hits.append(point) + if self.steps[point]: + step = self.steps[point].popleft() + if isinstance(step, Barrier): + await step.pause() + else: + raise step + + +FetchStep = ( + InlineFetchResult + | None + | Exception + | Callable[[ReportingSourceSliceRequestV1], InlineFetchResult | None] +) + + +class ScriptedSource: + """Account-scoped sync/async callbacks with exactly the scripted read count.""" + + def __init__(self, **scripts: Sequence[FetchStep]) -> None: + self.steps = {account: deque(steps) for account, steps in scripts.items()} + self.requests: list[ReportingSourceSliceRequestV1] = [] + self.thread_ids: list[int] = [] + self.failures = FailurePlan() + self._lock = threading.Lock() + + def sync(self, request: ReportingSourceSliceRequestV1) -> InlineFetchResult | None: + with self._lock: + self.requests.append(request) + self.thread_ids.append(threading.get_ident()) + queue = self.steps.get(request.identity.account_id) + if not queue: + raise AssertionError(f"unscripted source read for {request.identity.account_id}") + step = queue.popleft() + if isinstance(step, Exception): + raise step + return step(request) if callable(step) else step + + async def async_fetch(self, request: ReportingSourceSliceRequestV1) -> InlineFetchResult | None: + await self.failures.hit("fetch.before") + result = self.sync(request) + await self.failures.hit("fetch.after") + return result + + +async def drain_until_idle( + producer: ReportingProducer, + clock: ManualClock, + *, + idle_turns: int = 1, + max_turns: int = 32, +) -> tuple[WorkerTurn, ...]: + """Drain a known number of configurations with a finite retry/lease budget. + + Set idle_turns to the number of configurations: one idle account must not + hide pending work in its neighbour. Moving the clock also orders PG leases. + """ + if not 1 <= idle_turns <= max_turns: + raise ValueError("idle_turns must fit the positive turn budget") + turns: list[WorkerTurn] = [] + idle = 0 + for _ in range(max_turns): + turn = await asyncio.wait_for(producer.run_worker(), timeout=10) + turns.append(turn) + idle = 0 if turn.did_work else idle + 1 + clock.advance() + if idle == idle_turns: + return tuple(turns) + raise AssertionError(f"worker did not become idle within {max_turns} turns") + + +class _BytesStore: + """Immutable test artifacts, independent of the production ledger tables.""" + + def __init__(self, pool: AsyncConnectionPool | None = None) -> None: + self.pool = pool + self.values: dict[tuple[str, str, str, str], bytes] = {} + + async def create_schema(self) -> None: + if self.pool is not None: + async with self.pool.connection() as connection: + await connection.execute( + "CREATE TABLE IF NOT EXISTS reliable_test_bytes (" + "namespace text NOT NULL, account_id text NOT NULL, logical_id text NOT NULL," + "generation text NOT NULL, payload bytea NOT NULL," + "PRIMARY KEY(namespace, account_id, logical_id, generation))" + ) + + async def get( + self, namespace: str, account: str, key: str, generation: str = "" + ) -> bytes | None: + if self.pool is None: + return self.values.get((namespace, account, key, generation)) + async with self.pool.connection() as connection: + row = await ( + await connection.execute( + "SELECT payload FROM reliable_test_bytes WHERE namespace=%s" + " AND account_id=%s AND logical_id=%s AND generation=%s", + (namespace, account, key, generation), + ) + ).fetchone() + return bytes(row[0]) if row else None + + async def put( + self, namespace: str, account: str, key: str, payload: bytes, generation: str = "" + ) -> bytes: + if self.pool is None: + return self.values.setdefault((namespace, account, key, generation), payload) + async with self.pool.connection() as connection: + await connection.execute( + "INSERT INTO reliable_test_bytes VALUES (%s,%s,%s,%s,%s)" " ON CONFLICT DO NOTHING", + (namespace, account, key, generation, payload), + ) + winner = await self.get(namespace, account, key, generation) + assert winner is not None + return winner + + +class _Staging: + def __init__(self, blobs: _BytesStore, failures: FailurePlan) -> None: + self.blobs = blobs + self.failures = failures + self.memory = InMemoryStagingStore() if blobs.pool is None else None + + async def stage( + self, *, account_id: str, source_execution_key: str, ordinal: int, payload: bytes + ) -> tuple[str, str]: + await self.failures.hit("stage.before") + if self.memory is not None: + result = await self.memory.stage( + account_id=account_id, + source_execution_key=source_execution_key, + ordinal=ordinal, + payload=payload, + ) + else: + ref = f"{source_execution_key}.{ordinal}" + digest = hashlib.sha256(payload).hexdigest() + assert await self.blobs.put("stage", account_id, ref, payload, digest) == payload + result = ref, digest + await self.failures.hit("stage.after") + return result + + async def read( + self, + *, + object_ref: str, + object_generation: str, + account_id: str, + source_scope: Mapping[str, Any], + cancel: asyncio.Event, + ) -> bytes: + if self.memory is not None: + return await self.memory.read( + object_ref=object_ref, + object_generation=object_generation, + account_id=account_id, + source_scope=source_scope, + cancel=cancel, + ) + payload = await self.blobs.get("stage", account_id, object_ref, object_generation) + if payload is None: + raise PermissionError("staged object is outside the requested account scope") + return payload + + +class _Seals: + def __init__(self, blobs: _BytesStore, failures: FailurePlan) -> None: + self.blobs = blobs + self.failures = failures + + @staticmethod + def _decode(payload: bytes) -> SealedSlice: + value = json.loads(payload) + return SealedSlice( + SourceBatchManifestReferenceV1.model_validate(value["reference"]), + value["manifest"].encode("utf-8"), + ) + + async def get(self, *, account_id: str, source_execution_key: str) -> SealedSlice | None: + value = await self.blobs.get("seal", account_id, source_execution_key) + return self._decode(value) if value is not None else None + + async def put( + self, *, account_id: str, source_execution_key: str, sealed: SealedSlice + ) -> SealedSlice: + await self.failures.hit("seal.before") + payload = canonical_json_utf8_v1( + { + "reference": sealed.reference.model_dump(mode="json"), + "manifest": sealed.manifest_bytes.decode("utf-8"), + } + ) + winner = await self.blobs.put("seal", account_id, source_execution_key, payload) + await self.failures.hit("seal.after") + return self._decode(winner) + + +class DeterministicDestinationStore: + """Immutable writes keyed by authenticated account and exact revision.""" + + namespace = "destination" + + def __init__(self, blobs: _BytesStore, failures: FailurePlan) -> None: + self.blobs = blobs + self.failures = failures + + async def write(self, account: str, revision_id: str, payload: bytes) -> str: + await self.failures.hit(f"{self.namespace}.before") + winner = await self.blobs.put(self.namespace, account, revision_id, payload) + if winner != payload: + raise ValueError("immutable destination identity has different bytes") + await self.failures.hit(f"{self.namespace}.after") + return hashlib.sha256(payload).hexdigest() + + async def read(self, account: str, revision_id: str) -> bytes | None: + return await self.blobs.get(self.namespace, account, revision_id) + + +class DeterministicReceiverStore(DeterministicDestinationStore): + namespace = "receiver" + + +def configuration(account: str, *, finality: str = "snapshot") -> ReportingConfiguration: + contract = redacted_contract_identity + return ReportingConfiguration( + account_id=account, + delivery_config_id="shared-config", + delivery_config_version=1, + report_definition_id=contract.report_definition_id, + reporting_profile=contract.reporting_profile, + feed_purpose="analytics", + schedule=ReportingScheduleSpec("PT1H", "PT1H", period_anchor=START), + required_finality=finality, + activated_at=START, + deactivated_at=END, + media_buy_ids=("shared-media-buy",), + definition=ReportingDefinitionBinding( + report_definition_uri=contract.report_definition_uri, + report_definition_sha256=contract.report_definition_sha256, + schema_version=contract.schema_version, + schema_uri=contract.schema_uri, + schema_sha256=contract.schema_sha256, + ), + ) + + +def capabilities() -> ReportingSourceCapabilitiesV1: + base = redacted_capabilities() + draft = base.model_copy( + update={ + "offerings": [ + offering.model_copy( + update={ + "source_timezone": "UTC", + "product_ids": [redacted_contract_identity.report_definition_id], + "metrics": [ + MetricOfferingV1( + name=metric, + semantic_contract_id=f"fixture.{metric}", + semantic_contract_version=str(index), + semantic_contract_sha256=str(index) * 64, + ) + for index, metric in enumerate(METRICS, 1) + ], + } + ) + for offering in base.offerings + ] + } + ) + return ReportingSourceCapabilitiesV1.model_validate( + { + **draft.model_dump(), + "capabilities_sha256": reporting_source_capabilities_sha256_v1(draft), + } + ) + + +def complete_fetch(request: ReportingSourceSliceRequestV1) -> InlineFetchResult: + return InlineFetchResult( + rows=[ + { + "media_buy_id": item.media_buy_id, + "impressions": 5, + "clicks": 0, + "spend": amount, + "currency": request.currency, + } + for item in request.coverage.constituents + for amount in ("0.10", "0.20") + ], + currency=request.currency, + cell_availability={ + item.constituent_id: { + "impressions": MetricEvidence.present(request.period.end), + "clicks": MetricEvidence.explicit_zero(data_through=request.period.end), + "spend": MetricEvidence.present(request.period.end), + } + for item in request.coverage.constituents + }, + ) + + +def verified(result: ReportingSourceExecutorResult) -> SourceBatchManifestV1: + assert result.ok, result.error + assert result.response is not None and result.manifest_bytes is not None + return parse_verified_source_batch_manifest_v1(result.response.manifest, result.manifest_bytes) + + +class _RecordingSource: + def __init__( + self, source: InlineReportingSource, manifests: dict[tuple[str, str], Any] + ) -> None: + self.source = source + self.manifests = manifests + + @property + def capabilities(self) -> ReportingSourceCapabilitiesV1: + return self.source.capabilities + + async def execute( + self, + request: ReportingSourceSliceRequestV1, + *, + cancel: asyncio.Event, + heartbeat: Callable[[], None] | None = None, + ) -> ReportingSourceExecutorResult: + result = await self.source.execute(request, cancel=cancel, heartbeat=heartbeat) + if result.ok: + manifest = verified(result) + self.manifests[(request.identity.account_id, manifest.content_fingerprint)] = manifest + return result + + +class _PreparedRevisionStore: + """The test publisher pins typed totals/digest before the real store validates. + + Core's worker deliberately has no Managed Delivery publisher yet. Delegation + keeps its complete worker path and both stores' currency/content validators; + this seam neither changes retained revisions nor bypasses an admission gate. + """ + + def __init__(self, harness: ReliableHarness) -> None: + self.harness = harness + + def __getattr__(self, name: str) -> Any: + return getattr(self.harness.store, name) + + async def commit_revision( + self, revision: ReportingRevisionRecord, rows: Sequence[dict[str, Any]] + ) -> ReportingRevisionRecord: + fingerprint = f"sha256:{revision.source_manifest_sha256}" + manifest = self.harness.manifests[(revision.account_id, fingerprint)] + totals = tuple( + ReportingControlTotalRecord(total.name, total.value, total.value_type, total.unit) + for total in manifest.control_totals + ) + prepared = replace( + revision, + managed_control_totals=totals, + canonical_content_digest=ReportingCanonicalDigest( + value=hashlib.sha256(canonical_json_utf8_v1(list(rows))).hexdigest(), + canonicalization_id="fixture-rows-v1", + canonicalization_uri="https://contracts.example.test/fixture-rows-v1.json", + canonicalization_sha256="a" * 64, + ), + revision_content_sha256=revision_content_sha256( + reporting_revision_id=revision.reporting_revision_id, + row_count=revision.row_count, + control_totals=revision.control_totals, + reporting_rows=rows, + control_total_evidence=totals, + ), + ) + await self.harness.failures.hit("revision.before") + retained = await self.harness.store.commit_revision(prepared, rows) + await self.harness.failures.hit("revision.after") + return retained + + +@dataclass +class ReliableHarness: + store: Store + clock: ManualClock + blobs: _BytesStore + failures: FailurePlan = field(default_factory=FailurePlan) + currencies: dict[str, str] = field(default_factory=lambda: {"eur": "EUR", "usd": "USD"}) + manifests: dict[tuple[str, str], SourceBatchManifestV1] = field(default_factory=dict) + staging: _Staging = field(init=False) + seals: _Seals = field(init=False) + destination: DeterministicDestinationStore = field(init=False) + receiver: DeterministicReceiverStore = field(init=False) + + def __post_init__(self) -> None: + self.staging = _Staging(self.blobs, self.failures) + self.seals = _Seals(self.blobs, self.failures) + self.destination = DeterministicDestinationStore(self.blobs, self.failures) + self.receiver = DeterministicReceiverStore(self.blobs, self.failures) + + def source(self, fetch: InlineFetch) -> InlineReportingSource: + return InlineReportingSource( + capabilities=capabilities(), + fetch=fetch, + staging=self.staging, + seals=self.seals, + clock=self.clock, + ) + + def producer(self, source: InlineReportingSource, *, managed: bool = True) -> ReportingProducer: + return ReportingProducer( + store=_PreparedRevisionStore(self) if managed else self.store, + source=_RecordingSource(source, self.manifests), + object_reader=self.staging, + offerings=ProducerOfferings( + snapshot_offering_id=SNAPSHOT_OFFERING_ID, + official_offering_id=OFFICIAL_OFFERING_ID, + publication_namespace="reporting-source:fixture", + source_scope=dict(redacted_capabilities().source_scope), + requested_metrics=METRICS, + ), + currency_resolver=lambda config, obligation: self.currencies[config.account_id], + clock=self.clock, + ) + + async def commit_slice( + self, + producer: ReportingProducer, + obligation: ReportingObligationRecord, + request: ReportingSourceSliceRequestV1, + result: ReportingSourceExecutorResult, + *, + finality: str = "snapshot", + ) -> ReportingRevisionRecord: + manifest = verified(result) + self.manifests[(obligation.account_id, manifest.content_fingerprint)] = manifest + return await producer.commit_revision_from_manifest( + obligation, + manifest, + rows=await producer._read_rows(request, manifest), + finality=finality, + ) + + async def restart(self) -> None: + """Replace all process-local clients; memory explicitly restores a test image.""" + if self.blobs.pool is not None: + from psycopg_pool import AsyncConnectionPool + + old = self.blobs.pool + await old.close() + pool = AsyncConnectionPool( + old.conninfo, kwargs=old.kwargs, min_size=2, max_size=8, open=False + ) + await pool.open(wait=True) + self.blobs = _BytesStore(pool) + self.store = PgReportingReconciliationStore(pool=pool, clock=self.clock) + self.__post_init__() + else: + # This is an explicit test fixture image, not an SDK persistence API. + state = deepcopy( + { + name: value + for name, value in vars(self.store).items() + if name not in {"_lock", "_clock"} + } + ) + stage = deepcopy(self.staging.memory) + blobs = deepcopy(self.blobs.values) + self.store = InMemoryReportingReconciliationStore(clock=self.clock) + vars(self.store).update(state) + self.blobs = _BytesStore() + self.blobs.values = blobs + self.__post_init__() + self.staging.memory = stage + self.manifests.clear() + await self.store.create_schema() + + +@asynccontextmanager +async def reliable_factory( + backend: Backend, *, initialize: bool = True +) -> AsyncIterator[ReliableHarness]: + clock = ManualClock() + if backend == "memory": + harness = ReliableHarness( + InMemoryReportingReconciliationStore(clock=clock), clock, _BytesStore() + ) + yield harness + else: + async with isolated_reporting_pool() as pool: + harness = ReliableHarness( + PgReportingReconciliationStore(pool=pool, clock=clock), clock, _BytesStore(pool) + ) + if initialize: + await harness.store.create_schema() + await harness.blobs.create_schema() + try: + yield harness + finally: + if harness.blobs.pool is not None and harness.blobs.pool is not pool: + await harness.blobs.pool.close() + + +@pytest.fixture(params=["memory", "postgres"]) +async def reliable(request: pytest.FixtureRequest) -> AsyncIterator[ReliableHarness]: + async with reliable_factory(request.param) as harness: + yield harness + + +@dataclass(frozen=True) +class PublishedRecords: + binding: ReportingDestinationBinding + delivery: ReportingObligationDeliveryRecord + attempt: ReportingMaterializationAttempt + outcome: ReportingMaterializationRecord + receipt: ReportingRevisionReceiptRecord + + +async def publication_records( + harness: ReliableHarness, + obligation: ReportingObligationRecord, + revision: ReportingRevisionRecord, + *, + suffix: str = "shared", +) -> PublishedRecords: + """Prepare immutable destination observations from an exact revision read. + + Callers commit the attempt/outcome/receipt explicitly so tests can interleave + failures and invalid writes without a second copy of the state machine. + """ + scope = ReportingDeliveryScope( + obligation.generation_key, "buyer", obligation.reporting_obligation_id + ) + binding = ReportingDestinationBinding( + generation_key=scope.generation_key, + consumer_id=scope.consumer_id, + destination_ref="shared-destination", + trusted_binding_ref="trusted-fixture-binding", + method="file_transfer", + transport="test-storage", + verification_profile="canonical_digest", + reconciliation_mode="consumer_receipt", + feed_purpose="analytics", + resource_retention_days=400, + created_at=START, + format="jsonl", + reader_compatibility=("jsonl-v1",), + ) + assert obligation.currency is not None + delivery = ReportingObligationDeliveryRecord( + scope, + obligation.currency, + obligation.created_at + timedelta(days=400), + obligation.created_at, + ) + await harness.store.put_destination_binding(binding) + await harness.store.bind_obligation_delivery(delivery) + rows = await harness.store.read_revision_rows( + account_id=obligation.account_id, reporting_revision_id=revision.reporting_revision_id + ) + payload = b"".join(canonical_json_utf8_v1(row) + b"\n" for row in rows.rows) + digest = await harness.destination.write( + obligation.account_id, revision.reporting_revision_id, payload + ) + attempt = ReportingMaterializationAttempt( + scope, revision.reporting_revision_id, f"materialization-{suffix}", 1, harness.clock() + ) + at = harness.clock() + resource = ReportingResourceRecord( + resource_ref=f"resource-{suffix}", + kind="manifest", + location=f"reports/{suffix}/manifest.json", + immutability="immutable_location", + expires_at=at + timedelta(days=400), + manifest_sha256=hashlib.sha256( + canonical_json_utf8_v1({"sha256": digest, "rows": revision.row_count}) + ).hexdigest(), + object_refs=(f"reports/{suffix}/part-000.jsonl",), + reader_compatibility=binding.reader_compatibility, + ) + assert revision.managed_control_totals is not None + verification = ReportingVerificationRecord( + verified_at=at, + verification_path="producer", + verification_profile="canonical_digest", + row_count=revision.row_count, + control_totals=revision.managed_control_totals, + canonical_content_digest=revision.canonical_content_digest, + physical_checksums=(ReportingPhysicalChecksum(resource.object_refs[0], "sha256", digest),), + verified_format="jsonl", + ) + outcome = ReportingMaterializationRecord( + scope, + revision.reporting_revision_id, + attempt.reporting_materialization_id, + "available", + at, + resource=resource, + verification=verification, + ) + receipt = ReportingRevisionReceiptRecord( + scope=scope, + reporting_receipt_id=f"receipt-{suffix}-00000001", + reporting_revision_id=revision.reporting_revision_id, + reporting_materialization_id=attempt.reporting_materialization_id, + status="accepted", + verification_profile="canonical_digest", + observed_row_count=revision.row_count, + observed_control_totals=revision.managed_control_totals, + observed_canonical_content_digest=revision.canonical_content_digest, + observed_at=at, + consumer_commit_ref=f"load-{suffix}", + ) + return PublishedRecords(binding, delivery, attempt, outcome, receipt) diff --git a/tests/conformance/reporting/conftest.py b/tests/conformance/reporting/conftest.py index 85e9ba903..c7bf97e2d 100644 --- a/tests/conformance/reporting/conftest.py +++ b/tests/conformance/reporting/conftest.py @@ -1,3 +1,4 @@ """Shared storage state-machine fixture, registered for reporting conformance.""" from ._reconciliation_support import reconciliation_store as reconciliation_store +from ._reliable_support import reliable as reliable diff --git a/tests/conformance/reporting/test_reliable_support.py b/tests/conformance/reporting/test_reliable_support.py new file mode 100644 index 000000000..31d8b3ab2 --- /dev/null +++ b/tests/conformance/reporting/test_reliable_support.py @@ -0,0 +1,143 @@ +"""The downstream harness controls reporting time, interleaving and retry bounds.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timedelta + +import pytest + +from adcp.reporting.conformance import ( + ReportingSourceConformanceError, + run_reporting_source_replay_conformance, + validate_reporting_source_execution, +) +from adcp.reporting.fixtures import SNAPSHOT_OFFERING_ID +from adcp.reporting.ledger import InMemoryReportingLedgerStore + +from ._generation_support import configuration as ledger_configuration +from ._generation_support import obligation_for, revision_for +from ._reliable_support import ( + ManualClock, + ReliableHarness, + ScriptedSource, + complete_fetch, + configuration, + drain_until_idle, +) + + +async def test_core_change_appends_and_snapshot_use_the_injected_clock( + reliable: ReliableHarness, +) -> None: + h = reliable + config = ledger_configuration() + await h.store.put_configuration(config) + obligation = obligation_for(config) + first_at = h.clock() + await h.store.commit_obligation(obligation) + second_at = h.clock.advance(timedelta(minutes=7)) + revision, rows = revision_for(obligation) + await h.store.commit_revision(revision, rows) + if isinstance(h.store, InMemoryReportingLedgerStore): + # _append previously ignored its clock even though snapshots used it. + timestamps = [entry[4] for entry in h.store._changes] + else: + assert h.blobs.pool is not None + async with h.blobs.pool.connection() as connection: + retained = await ( + await connection.execute( + "SELECT committed_at FROM reporting_ledger_changes" + " WHERE account_id=%s ORDER BY seq", + (config.account_id,), + ) + ).fetchall() + timestamps = [entry[0] for entry in retained] + assert timestamps == [first_at, second_at] + snapshot = await h.store.open_snapshot( + account_id=config.account_id, filters_fingerprint="fixture" + ) + assert snapshot.ledger_as_of == second_at + await h.store.commit_revision(revision, rows) + replay = await h.store.open_snapshot( + account_id=config.account_id, filters_fingerprint="fixture" + ) + assert replay == snapshot + + +async def test_drain_has_a_finite_turn_budget_for_a_source_that_stays_unready( + reliable: ReliableHarness, +) -> None: + h = reliable + script = ScriptedSource(eur=[None, None, None]) + await h.store.put_configuration(configuration("eur")) + started = h.clock() + with pytest.raises(AssertionError, match="within 3 turns"): + await drain_until_idle(h.producer(h.source(script.async_fetch)), h.clock, max_turns=3) + assert len(script.requests) == 3 + assert h.clock() == started + timedelta(milliseconds=3) + assert len({request.identity.source_execution_key for request in script.requests}) == 1 + assert {request.currency for request in script.requests} == {"EUR"} + + +async def test_source_conformance_uses_manual_deadlines_for_execution_and_object_reads( + reliable: ReliableHarness, +) -> None: + h = reliable + script = ScriptedSource(eur=[complete_fetch]) + source = h.source(script.sync) + producer = h.producer(source) + config = configuration("eur") + await h.store.put_configuration(config) + (obligation,) = await producer.close_elapsed_periods(config) + request = producer._build_slice(config, obligation, SNAPSHOT_OFFERING_ID, now=h.clock()) + manifest = await run_reporting_source_replay_conformance( + executor=source, request=request, object_reader=h.staging, clock=h.clock + ) + assert manifest.observed_at == h.clock() + assert len(script.requests) == 1 + result = await source.execute(request, cancel=asyncio.Event()) + h.clock.advance(request.deadline_at - h.clock()) + with pytest.raises(ReportingSourceConformanceError, match="exceeded its deadline"): + await validate_reporting_source_execution( + capabilities=source.capabilities, + request=request, + result=result, + object_reader=h.staging, + clock=h.clock, + ) + with pytest.raises(ReportingSourceConformanceError, match="exceeded its deadline"): + await run_reporting_source_replay_conformance( + executor=source, request=request, object_reader=h.staging, clock=h.clock + ) + assert len(script.requests) == 1 + + +@pytest.mark.parametrize("component", ["destination", "receiver"]) +async def test_deterministic_stores_replay_after_commit_failure_and_keep_accounts_separate( + reliable: ReliableHarness, component: str +) -> None: + h = reliable + store = getattr(h, component) + h.failures.at(f"{component}.after", OSError("lost acknowledgement")) + with pytest.raises(OSError, match="lost acknowledgement"): + await store.write("eur", "colliding-revision", b'{"currency":"EUR"}\n') + digest = await store.write("eur", "colliding-revision", b'{"currency":"EUR"}\n') + assert len(digest) == 64 + with pytest.raises(ValueError, match="different bytes"): + await store.write("eur", "colliding-revision", b'{"currency":"USD"}\n') + await store.write("usd", "colliding-revision", b'{"currency":"USD"}\n') + await h.restart() + store = getattr(h, component) + assert await store.read("eur", "colliding-revision") == b'{"currency":"EUR"}\n' + assert await store.read("usd", "colliding-revision") == b'{"currency":"USD"}\n' + assert await store.read("outsider", "colliding-revision") is None + assert await store.write("eur", "colliding-revision", b'{"currency":"EUR"}\n') == digest + + +def test_manual_clock_requires_aware_monotonic_time() -> None: + with pytest.raises(ValueError, match="aware"): + ManualClock(datetime(2026, 9, 1)) + clock = ManualClock() + with pytest.raises(ValueError, match="backwards"): + clock.advance(timedelta(seconds=-1)) diff --git a/tests/conformance/reporting/test_reporting_evidence_currency_integration.py b/tests/conformance/reporting/test_reporting_evidence_currency_integration.py new file mode 100644 index 000000000..78d199635 --- /dev/null +++ b/tests/conformance/reporting/test_reporting_evidence_currency_integration.py @@ -0,0 +1,920 @@ +"""The reviewed evidence/currency roots composed with #1167A's retained records. + +Essential memory and real-PostgreSQL cases deliberately have no integration +marker. The PostgreSQL parameter skips only when ADCP_PG_TEST_URL is absent. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import inspect +import json +import sys +import threading +from copy import deepcopy +from dataclasses import replace +from datetime import timedelta +from pathlib import Path + +import pytest + +from adcp.reporting.canonical_json import canonical_json_utf8_v1 +from adcp.reporting.conformance import validate_reporting_source_execution +from adcp.reporting.fixtures import OFFICIAL_OFFERING_ID, SNAPSHOT_OFFERING_ID +from adcp.reporting.inline_source import InlineFetchResult, InlineReportingSource, MetricEvidence +from adcp.reporting.ledger import ( + InMemoryReportingLedgerStore, + LedgerConflictError, + ReportingConfiguration, + ReportingControlTotalRecord, + ReportingDeliveryPrincipal, + ReportingDestinationBinding, + ReportingMaterializationKey, + ReportingObligationDeliveryRecord, + ReportingObligationRecord, + ReportingProducer, +) +from adcp.reporting.source import ( + MediaBuyConstituentV1, + ReportingSourceCoverageRequestV1, + ReportingSourceSliceRequestV1, + coverage_denominator_fingerprint_v1, +) + +from ._generation_support import configuration as old_configuration +from ._generation_support import obligation_for, revision_for +from ._reliable_support import ( + METRICS, + Barrier, + PublishedRecords, + ReliableHarness, + ScriptedSource, + complete_fetch, + configuration, + drain_until_idle, + publication_records, + reliable_factory, + verified, +) +from .test_reporting_generation_migration import _retained_rows + + +async def frozen_slice( + harness: ReliableHarness, + account: str = "eur", + *, + config: ReportingConfiguration | None = None, + partial: bool = True, +) -> tuple[ReportingProducer, ReportingObligationRecord, ReportingSourceSliceRequestV1]: + config = config or configuration(account) + await harness.store.put_configuration(config) + producer = harness.producer(harness.source(lambda request: None)) + (obligation,) = await producer.close_elapsed_periods(config) + request = producer._build_slice(config, obligation, SNAPSHOT_OFFERING_ID, now=harness.clock()) + if partial: + request = request.model_copy( + update={"coverage": request.coverage.model_copy(update={"expected": "partial"})} + ) + return producer, obligation, request + + +def sparse_fetch(request: ReportingSourceSliceRequestV1) -> InlineFetchResult: + return InlineFetchResult( + rows=[ + {"media_buy_id": item.media_buy_id, "impressions": 5, "clicks": 0} + for item in request.coverage.constituents + ], + currency=request.currency, + cell_availability={ + item.constituent_id: { + "impressions": MetricEvidence.present(request.period.end), + "clicks": MetricEvidence.explicit_zero(data_through=request.period.end), + "spend": MetricEvidence.unavailable("billing_pending"), + } + for item in request.coverage.constituents + }, + ) + + +async def commit_records(harness: ReliableHarness, records: PublishedRecords) -> None: + assert await harness.store.commit_materialization_attempt(records.attempt) == ( + records.attempt, + True, + ) + assert await harness.store.commit_materialization(records.outcome) == (records.outcome, True) + account = records.attempt.scope.principal.account_id + revision_id = records.attempt.reporting_revision_id + payload = await harness.destination.read(account, revision_id) + assert payload is not None + await harness.receiver.write(account, revision_id, payload) + loaded = await harness.receiver.read(account, revision_id) + assert loaded == payload + rows = [json.loads(line) for line in loaded.splitlines()] + assert len(rows) == records.receipt.observed_row_count + assert records.receipt.observed_canonical_content_digest is not None + assert hashlib.sha256(canonical_json_utf8_v1(rows)).hexdigest() == ( + records.receipt.observed_canonical_content_digest.value + ) + retained, created = await harness.store.record_revision_receipt(records.receipt) + assert created and retained.received_at == harness.clock() + assert await harness.store.record_revision_receipt(records.receipt) == (retained, False) + if harness.blobs.pool is not None: + async with harness.blobs.pool.connection() as connection: + committed = await ( + await connection.execute( + "SELECT committed_at FROM reporting_reconciliation_changes" + " WHERE account_id=%s AND consumer_id=%s ORDER BY seq DESC LIMIT 1", + (account, records.attempt.scope.consumer_id), + ) + ).fetchone() + assert committed == (harness.clock(),) + + +@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) +async def test_colliding_accounts_keep_sparse_metric_evidence_and_staging_isolated( + reliable: ReliableHarness, asynchronous: bool +) -> None: + h = reliable + script = ScriptedSource(eur=[sparse_fetch], usd=[sparse_fetch]) + source = h.source(script.async_fetch if asynchronous else script.sync) + results = [] + for account in ("eur", "usd"): + producer, obligation, request = await frozen_slice(h, account) + # Config, media buy, execution, destination and receipt IDs deliberately collide. + request = request.model_copy( + update={ + "identity": request.identity.model_copy( + update={"source_execution_key": "shared-execution"} + ) + } + ) + result = await source.execute(request, cancel=asyncio.Event()) + manifest = await validate_reporting_source_execution( + capabilities=source.capabilities, + request=request, + result=result, + object_reader=h.staging, + clock=h.clock, + ) + assert manifest.currency == obligation.currency == h.currencies[account] + assert manifest.row_count == 1 and manifest.coverage.status == "partial" + cells = {cell.metric: cell for cell in manifest.metric_availability} + assert {name: cell.status for name, cell in cells.items()} == { + "impressions": "present", + "clicks": "explicit_zero", + "spend": "unsupported", + } + assert cells["spend"].reason == "billing_pending" and cells["spend"].data_through is None + assert [total.model_dump(exclude_none=True) for total in manifest.control_totals] == [ + {"name": "impressions", "value": "5", "value_type": "integer"}, + {"name": "clicks", "value": "0", "value_type": "integer"}, + ] + revision = await h.commit_slice(producer, obligation, request, result) + records = await publication_records(h, obligation, revision) + await commit_records(h, records) + results.append((request, result, revision, records)) + + # Same staged ref AND digest: the second account must not evict the first. + assert verified(results[0][1]).objects == verified(results[1][1]).objects + assert verified(results[0][1]).publication_id != verified(results[1][1]).publication_id + for request, result, revision, records in results: + account = request.identity.account_id + other = "usd" if account == "eur" else "eur" + manifest = verified(result) + obj = manifest.objects[0] + payload = await h.staging.read( + object_ref=obj.object_ref, + object_generation=obj.object_generation, + account_id=account, + source_scope={}, + cancel=asyncio.Event(), + ) + assert json.loads(payload) == { + "media_buy_id": "shared-media-buy", + "impressions": 5, + "clicks": 0, + } + with pytest.raises(PermissionError): + await h.staging.read( + object_ref=obj.object_ref, + object_generation=obj.object_generation, + account_id="outsider", + source_scope={}, + cancel=asyncio.Event(), + ) + assert ( + await h.store.get_revision( + account_id=other, reporting_revision_id=revision.reporting_revision_id + ) + is None + ) + assert await h.receiver.read(other, revision.reporting_revision_id) is None + snapshot = await h.store.read_reconciliation_snapshot( + caller=records.attempt.scope.principal + ) + assert len(snapshot.current_receipts) == 1 + assert ( + snapshot.current_receipts[0].observed_control_totals == revision.managed_control_totals + ) + assert tuple( + item for item in snapshot.records if isinstance(item, ReportingObligationDeliveryRecord) + ) == (records.delivery,) + assert len(script.requests) == 2 + if not asynchronous: + assert threading.get_ident() not in script.thread_ids + + +@pytest.mark.parametrize("zero", [True, False], ids=["explicit-zero", "unavailable"]) +async def test_zero_spend_and_unavailable_spend_have_different_retained_totals( + reliable: ReliableHarness, zero: bool +) -> None: + h = reliable + producer, obligation, request = await frozen_slice(h) + answer = sparse_fetch(request) + rows = [dict(row) for row in answer.rows] + if zero: + rows[0]["spend"] = "0.00" + evidence = deepcopy(answer.cell_availability) + assert evidence is not None + if zero: + evidence[request.coverage.constituents[0].constituent_id][ + "spend" + ] = MetricEvidence.explicit_zero() + answer = replace(answer, rows=rows, cell_availability=evidence) + result = await h.source(lambda req: answer).execute(request, cancel=asyncio.Event()) + manifest = verified(result) + totals = {total.name: total for total in manifest.control_totals} + if zero: + assert totals["spend"].model_dump() == { + "name": "spend", + "value": "0.00", + "value_type": "decimal", + "unit": "EUR", + } + else: + assert "spend" not in totals + assert manifest.explicit_zero is False # These are nonempty, measured rows. + revision = await h.commit_slice(producer, obligation, request, result) + assert dict(revision.control_totals).get("spend") == ("0.00" if zero else None) + await commit_records(h, await publication_records(h, obligation, revision)) + + +async def test_one_unavailable_spend_cell_suppresses_only_its_metric_total( + reliable: ReliableHarness, +) -> None: + h = reliable + _, _, request = await frozen_slice(h) + constituents = [ + *request.coverage.constituents, + MediaBuyConstituentV1( + constituent_id="second", + media_buy_id="second-buy", + product_id=request.contract.report_definition_id, + ), + ] + request = request.model_copy( + update={ + "coverage": ReportingSourceCoverageRequestV1( + expected="partial", + constituents=constituents, + denominator_fingerprint=coverage_denominator_fingerprint_v1(constituents), + ) + } + ) + answer = sparse_fetch(request) + rows = [dict(row) for row in answer.rows] + rows[0]["spend"] = "0.00" + evidence = deepcopy(answer.cell_availability) + assert evidence is not None + evidence[constituents[0].constituent_id]["spend"] = MetricEvidence.explicit_zero() + result = await h.source( + lambda req: replace(answer, rows=rows, cell_availability=evidence) + ).execute(request, cancel=asyncio.Event()) + manifest = verified(result) + assert { + (cell.constituent_id, cell.status) + for cell in manifest.metric_availability + if cell.metric == "spend" + } == {(constituents[0].constituent_id, "explicit_zero"), ("second", "unsupported")} + assert [(t.name, t.value) for t in manifest.control_totals] == [ + ("impressions", "10"), + ("clicks", "0"), + ] + assert [item.status for item in manifest.coverage.constituents] == ["present", "partial"] + + +@pytest.mark.parametrize("mismatch", ["result", "row", "mixed", "invalid"]) +@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) +async def test_currency_failure_with_cell_evidence_precedes_staging_and_sealing( + reliable: ReliableHarness, mismatch: str, asynchronous: bool +) -> None: + h = reliable + _, obligation, request = await frozen_slice(h) + answer = complete_fetch(request) + rows = [dict(row) for row in answer.rows] + if mismatch == "result": + answer = replace(answer, currency="USD") + elif mismatch == "invalid": + answer = replace(answer, currency="eur") + else: + rows[0]["currency"] = "USD" + if mismatch == "row": + rows[1]["currency"] = "USD" + answer = replace(answer, rows=rows) + script = ScriptedSource(eur=[answer]) + result = await h.source(script.async_fetch if asynchronous else script.sync).execute( + request, cancel=asyncio.Event() + ) + assert result.error is not None + assert result.error.code == "INTEGRITY_FAILED" and result.error.retry == "terminal" + assert "stage.before" not in h.failures.hits and "seal.before" not in h.failures.hits + assert ( + await h.seals.get( + account_id="eur", source_execution_key=request.identity.source_execution_key + ) + is None + ) + assert ( + await h.store.list_revisions( + account_id="eur", reporting_obligation_id=obligation.reporting_obligation_id + ) + == () + ) + + +async def test_currency_validation_and_cell_evidence_share_the_immutable_row_snapshot( + reliable: ReliableHarness, +) -> None: + h = reliable + producer, obligation, request = await frozen_slice(h, partial=False) + answer = complete_fetch(request) + rows = [dict(row, annotation={"labels": ["original"]}) for row in answer.rows] + evidence = deepcopy(answer.cell_availability) + original_rows = deepcopy(rows) + barrier = Barrier() + h.failures.at("stage.before", barrier) + task = asyncio.create_task( + h.source(lambda req: replace(answer, rows=rows, cell_availability=evidence)).execute( + request, cancel=asyncio.Event() + ) + ) + await barrier.wait() + rows[0].update(currency="USD", spend="999.00", impressions=999) + rows[0]["annotation"]["labels"].append("mutated") + assert evidence is not None + evidence[request.coverage.constituents[0].constituent_id]["spend"] = MetricEvidence.unavailable( + "changed" + ) + barrier.release() + result = await asyncio.wait_for(task, timeout=10) + manifest = verified(result) + assert manifest.currency == "EUR" + assert [(t.name, t.value, t.unit) for t in manifest.control_totals] == [ + ("impressions", "10", None), + ("clicks", "0", None), + ("spend", "0.30", "EUR"), + ] + assert ( + next(cell for cell in manifest.metric_availability if cell.metric == "spend").status + == "present" + ) + revision = await h.commit_slice(producer, obligation, request, result) + page = await h.store.read_revision_rows( + account_id="eur", reporting_revision_id=revision.reporting_revision_id + ) + assert list(page.rows) == original_rows + + +@pytest.mark.parametrize( + "evidence_error", [False, True], ids=["provider-value-error", "cell-evidence-error"] +) +async def test_adapter_value_error_classification_survives_currency_composition( + reliable: ReliableHarness, evidence_error: bool +) -> None: + h = reliable + _, _, request = await frozen_slice(h) + + def fetch(req: ReportingSourceSliceRequestV1) -> InlineFetchResult: + if evidence_error: + return replace( + sparse_fetch(req), + cell_availability={ + req.coverage.constituents[0].constituent_id: { + "spend": MetricEvidence.unavailable("") + } + }, + ) + raise ValueError("provider private diagnostic") + + source = h.source(fetch) + if evidence_error: + with pytest.raises(ValueError, match="reason"): + await source.execute(request, cancel=asyncio.Event()) + else: + result = await source.execute(request, cancel=asyncio.Event()) + assert result.error is not None and result.error.code == "PROVIDER_TRANSIENT" + assert result.error.retry == "retryable" + assert "private diagnostic" not in result.error.safe_message + assert "stage.before" not in h.failures.hits + + +async def test_same_configuration_id_runs_worker_to_revision_to_exact_records_without_leakage( + reliable: ReliableHarness, +) -> None: + h = reliable + script = ScriptedSource(eur=[complete_fetch], usd=[complete_fetch]) + source = h.source(script.async_fetch) + producer = h.producer(source) + for account in ("eur", "usd"): + await h.store.put_configuration(configuration(account)) + turns = await drain_until_idle(producer, h.clock, idle_turns=2) + assert sum(len(turn.obligations_committed) for turn in turns) == 2 + assert sum(len(turn.revisions_committed) for turn in turns) == 2 + assert not any(turn.slices_failed for turn in turns) + assert {req.currency for req in script.requests} == {"EUR", "USD"} + for request in script.requests: + account = request.identity.account_id + obligation = await h.store.get_obligation( + account_id=account, reporting_obligation_id=request.identity.reporting_obligation_id + ) + assert obligation is not None + (revision,) = await h.store.list_revisions( + account_id=account, reporting_obligation_id=obligation.reporting_obligation_id + ) + assert revision.row_count == 2 + assert revision.managed_control_totals == ( + ReportingControlTotalRecord("impressions", "10", "integer"), + ReportingControlTotalRecord("clicks", "0", "integer"), + ReportingControlTotalRecord("spend", "0.30", "decimal", request.currency), + ) + records = await publication_records(h, obligation, revision) + await h.store.commit_materialization_attempt(records.attempt) + await h.store.commit_materialization(records.outcome) + before = await h.store.read_reconciliation_snapshot(caller=records.attempt.scope.principal) + for wrong_total in ( + replace(revision.managed_control_totals[-1], unit="USD" if account == "eur" else "EUR"), + replace(revision.managed_control_totals[-1], value="0.300"), + ): + with pytest.raises(LedgerConflictError): + await h.store.record_revision_receipt( + replace( + records.receipt, + observed_control_totals=( + *revision.managed_control_totals[:-1], + wrong_total, + ), + ) + ) + with pytest.raises(LedgerConflictError): + await h.store.record_revision_receipt(replace(records.receipt, observed_row_count=1)) + assert ( + await h.store.read_reconciliation_snapshot(caller=records.attempt.scope.principal) + == before + ) + accepted, created = await h.store.record_revision_receipt(records.receipt) + assert created and accepted.observed_control_totals[-1].unit == request.currency + snapshot = await h.store.read_reconciliation_snapshot( + caller=ReportingDeliveryPrincipal(account, "buyer") + ) + assert {item.scope.principal.account_id for item in snapshot.current_receipts} == {account} + assert snapshot.terminal_acceptances == (accepted.key,) + assert tuple( + item for item in snapshot.records if isinstance(item, ReportingDestinationBinding) + ) == (records.binding,) + + +@pytest.mark.parametrize("failure_point", ["seal.after", "revision.before"]) +async def test_retry_and_store_restart_preserve_original_currency_and_exact_evidence( + reliable: ReliableHarness, failure_point: str +) -> None: + h = reliable + script = ScriptedSource(eur=[complete_fetch]) + await h.store.put_configuration(configuration("eur")) + h.failures.at(failure_point, OSError("simulated process loss")) + with pytest.raises(OSError, match="process loss"): + await h.producer(h.source(script.async_fetch)).run_worker() + (request,) = script.requests + first = await h.seals.get( + account_id="eur", source_execution_key=request.identity.source_execution_key + ) + assert first is not None + h.currencies["eur"] = "GBP" + h.clock.advance(timedelta(hours=1)) + await h.restart() + + def changed(req: ReportingSourceSliceRequestV1) -> InlineFetchResult: + return replace(sparse_fetch(req), currency="USD") + + replacement = ScriptedSource(eur=[changed]) + source = h.source(replacement.sync) + turns = await drain_until_idle(h.producer(source), h.clock) + assert sum(len(turn.revisions_committed) for turn in turns) == 1 + assert replacement.requests == [] + cancel = asyncio.Event() + cancel.set() + replay = await source.execute(request, cancel=cancel) + assert replay.manifest_bytes == first.manifest_bytes + assert replay.response is not None and replay.response.manifest == first.reference + manifest = verified(replay) + assert manifest.currency == "EUR" and manifest.row_count == 2 + assert [(t.name, t.value, t.unit) for t in manifest.control_totals][-1] == ( + "spend", + "0.30", + "EUR", + ) + obligation = await h.store.get_obligation( + account_id="eur", reporting_obligation_id=request.identity.reporting_obligation_id + ) + assert obligation is not None and obligation.currency == "EUR" + (revision,) = await h.store.list_revisions( + account_id="eur", reporting_obligation_id=obligation.reporting_obligation_id + ) + records = await publication_records(h, obligation, revision) + await commit_records(h, records) + snapshot = await h.store.read_reconciliation_snapshot(caller=records.attempt.scope.principal) + rows = await h.store.read_revision_rows( + account_id="eur", reporting_revision_id=revision.reporting_revision_id + ) + await h.restart() + assert ( + await h.store.get_revision( + account_id="eur", reporting_revision_id=revision.reporting_revision_id + ) + == revision + ) + assert ( + await h.store.read_revision_rows( + account_id="eur", reporting_revision_id=revision.reporting_revision_id + ) + == rows + ) + assert ( + await h.store.read_reconciliation_snapshot(caller=records.attempt.scope.principal) + == snapshot + ) + assert ( + await h.seals.get( + account_id="eur", source_execution_key=request.identity.source_execution_key + ) + == first + ) + assert ( + await h.source(replacement.sync).execute(request, cancel=cancel) + ).manifest_bytes == first.manifest_bytes + assert replacement.requests == [] + + +async def test_zero_row_snapshot_and_official_publications_coexist_in_the_record_model( + reliable: ReliableHarness, +) -> None: + h = reliable + + def zero(req: ReportingSourceSliceRequestV1) -> InlineFetchResult: + return InlineFetchResult( + rows=[], + currency=req.currency, + cell_availability={ + item.constituent_id: {metric: MetricEvidence.explicit_zero() for metric in METRICS} + for item in req.coverage.constituents + }, + ) + + script = ScriptedSource(eur=[zero, complete_fetch]) + source = h.source(script.async_fetch) + producer = h.producer(source) + config = configuration("eur") + await h.store.put_configuration(config) + await drain_until_idle(producer, h.clock) + request = script.requests[0] + obligation = await h.store.get_obligation( + account_id="eur", reporting_obligation_id=request.identity.reporting_obligation_id + ) + assert obligation is not None + (snapshot_revision,) = await h.store.list_revisions( + account_id="eur", reporting_obligation_id=obligation.reporting_obligation_id + ) + assert snapshot_revision.row_count == 0 + assert snapshot_revision.managed_control_totals[-1] == ReportingControlTotalRecord( + "spend", "0", "integer", "EUR" + ) + snapshot_records = await publication_records( + h, obligation, snapshot_revision, suffix="snapshot" + ) + await commit_records(h, snapshot_records) + assert await h.destination.read("eur", snapshot_revision.reporting_revision_id) == b"" + h.clock.advance(timedelta(hours=1)) + official_request = producer._build_slice( + config, + replace(obligation, required_finality="official"), + OFFICIAL_OFFERING_ID, + now=h.clock(), + ) + result = await source.execute(official_request, cancel=asyncio.Event()) + official = await h.commit_slice( + producer, obligation, official_request, result, finality="official" + ) + assert official.finality == "official" and official.supersedes_reporting_revision_id is None + official_records = await publication_records(h, obligation, official, suffix="official") + await commit_records(h, official_records) + await h.restart() + retained = await h.store.read_reconciliation_snapshot( + caller=snapshot_records.attempt.scope.principal + ) + revisions = await h.store.list_revisions( + account_id="eur", reporting_obligation_id=obligation.reporting_obligation_id + ) + assert {revision.finality for revision in revisions} == {"snapshot", "official"} + assert len(retained.terminal_acceptances) == 2 + for records in (snapshot_records, official_records): + view = await h.store.get_materialization( + ReportingMaterializationKey( + records.attempt.scope.principal, records.attempt.reporting_materialization_id + ) + ) + assert view is not None and view.attempt.attempt == 1 and view.outcome == records.outcome + + +@pytest.mark.parametrize("backend", ["memory", "postgres"]) +async def test_populated_ledger_upgrade_then_granular_publication_and_exact_reconciliation( + backend: str, +) -> None: + async with reliable_factory(backend, initialize=False) as h: + if h.blobs.pool is not None: + fixture = Path(__file__).resolve().parents[2] / "fixtures" + async with h.blobs.pool.connection() as connection: + await connection.execute((fixture / "reporting_ledger_beta15.sql").read_text()) + await connection.execute((fixture / "reporting_ledger_beta15_data.sql").read_text()) + before = await _retained_rows(h.blobs.pool) + await h.store.create_schema() + assert await _retained_rows(h.blobs.pool) == before + legacy_id = "rpr_acct_a_official" + else: + # Explicit image of an old Core memory store: unknown currency stays + # unknown when its retained data is attached to the additive store. + old = InMemoryReportingLedgerStore(clock=h.clock) + config = replace(old_configuration(), required_finality="official") + await old.put_configuration(config) + obligation = obligation_for(config) + await old.commit_obligation(obligation) + revision, rows = revision_for(obligation) + revision = replace( + revision, + finality="official", + finality_basis="source_final", + finality_policy_id="legacy-policy", + finalized_at=obligation.period.end, + ) + await old.commit_revision(revision, rows) + old._obligations[obligation.reporting_obligation_id] = replace( + obligation, currency=None + ) + vars(h.store).update( + deepcopy( + { + key: value + for key, value in vars(old).items() + if key not in {"_lock", "_clock"} + } + ) + ) + legacy_id = revision.reporting_revision_id + legacy = await h.store.get_revision(account_id="acct_a", reporting_revision_id=legacy_id) + assert legacy is not None and legacy.managed_control_totals is None + legacy_rows = await h.store.read_revision_rows( + account_id="acct_a", reporting_revision_id=legacy_id + ) + h.currencies.update(acct_a="EUR") + for account in ("acct_a", "usd"): + config = replace( + configuration(account), delivery_config_id="daily", delivery_config_version=2 + ) + producer, obligation, request = await frozen_slice(h, account, config=config) + result = await h.source(sparse_fetch).execute(request, cancel=asyncio.Event()) + revision = await h.commit_slice(producer, obligation, request, result) + assert revision.managed_control_totals is not None + assert {total.name for total in revision.managed_control_totals} == { + "impressions", + "clicks", + } + records = await publication_records(h, obligation, revision) + await commit_records(h, records) + retained = await h.store.read_reconciliation_snapshot( + caller=records.attempt.scope.principal + ) + assert tuple( + item + for item in retained.records + if isinstance(item, ReportingObligationDeliveryRecord) + ) == (records.delivery,) + assert records.delivery.currency == h.currencies[account] + assert all( + item.scope.principal.account_id == account for item in retained.current_receipts + ) + await h.restart() + assert ( + await h.store.get_revision(account_id="acct_a", reporting_revision_id=legacy_id) + == legacy + ) + assert ( + await h.store.read_revision_rows(account_id="acct_a", reporting_revision_id=legacy_id) + == legacy_rows + ) + assert await h.store.get_revision(account_id="usd", reporting_revision_id=legacy_id) is None + legacy_obligation = await h.store.get_obligation( + account_id="acct_a", reporting_obligation_id=legacy.reporting_obligation_id + ) + assert legacy_obligation is not None and legacy_obligation.currency is None + for account in ("acct_a", "usd"): + retained = await h.store.read_reconciliation_snapshot( + caller=ReportingDeliveryPrincipal(account, "buyer") + ) + assert len(retained.current_receipts) == 1 + (delivery,) = tuple( + item + for item in retained.records + if isinstance(item, ReportingObligationDeliveryRecord) + ) + assert delivery.currency == h.currencies[account] + + +async def test_postgres_fresh_process_reads_exact_seals_currency_rows_and_receipts() -> None: + async with reliable_factory("postgres") as h: + source = h.source(complete_fetch) + await h.store.put_configuration(configuration("eur")) + await drain_until_idle(h.producer(source), h.clock) + (manifest,) = h.manifests.values() + request = h.producer(source)._build_slice( + configuration("eur"), + await h.store.get_obligation( + account_id="eur", reporting_obligation_id=manifest.identity.reporting_obligation_id + ), + SNAPSHOT_OFFERING_ID, + now=h.clock(), + ) + obligation = await h.store.get_obligation( + account_id="eur", reporting_obligation_id=request.identity.reporting_obligation_id + ) + assert obligation is not None + (revision,) = await h.store.list_revisions( + account_id="eur", reporting_obligation_id=obligation.reporting_obligation_id + ) + records = await publication_records(h, obligation, revision) + await commit_records(h, records) + seal = await h.seals.get( + account_id="eur", source_execution_key=request.identity.source_execution_key + ) + assert seal is not None and h.blobs.pool is not None + data = { + "url": h.blobs.pool.conninfo, + "kwargs": h.blobs.pool.kwargs, + "now": h.clock().isoformat(), + "request": request.model_dump(mode="json"), + "revision_id": revision.reporting_revision_id, + "receipt": records.receipt.reporting_receipt_id, + } + await h.blobs.pool.close() + code = """ +import asyncio +import json +import sys +from datetime import datetime +from psycopg_pool import AsyncConnectionPool +from adcp.reporting.inline_source import InlineFetchResult, MetricEvidence +from adcp.reporting.ledger import ( + PgReportingReconciliationStore, ReportingDeliveryPrincipal, ReportingReceiptKey, +) +from adcp.reporting.source import ReportingSourceSliceRequestV1 +from tests.conformance.reporting._reliable_support import ( + ManualClock, ReliableHarness, _BytesStore, verified, +) + +async def main(data): + async with AsyncConnectionPool(data['url'], kwargs=data['kwargs'], open=False) as pool: + clock = ManualClock(datetime.fromisoformat(data['now'])) + h = ReliableHarness( + PgReportingReconciliationStore(pool=pool, clock=clock), clock, _BytesStore(pool) + ) + request = ReportingSourceSliceRequestV1.model_validate(data['request']) + calls = [] + def changed(req): + calls.append(req.identity.account_id) + return InlineFetchResult(rows=[], currency='USD', cell_availability={ + item.constituent_id: { + metric: MetricEvidence.unavailable('changed') + for metric in req.requested_metrics + } for item in req.coverage.constituents + }) + cancel = asyncio.Event() + result = await h.source(changed).execute(request, cancel=cancel) + cancel.set() + assert await h.source(changed).execute(request, cancel=cancel) == result + manifest = verified(result) + revision = await h.store.get_revision( + account_id='eur', reporting_revision_id=data['revision_id'] + ) + rows = await h.store.read_revision_rows( + account_id='eur', reporting_revision_id=data['revision_id'] + ) + receipt = await h.store.get_receipt( + ReportingReceiptKey(ReportingDeliveryPrincipal('eur','buyer'), data['receipt']) + ) + print(json.dumps({'manifest': result.manifest_bytes.decode(), 'currency': manifest.currency, + 'evidence': [cell.model_dump(mode='json') for cell in manifest.metric_availability], + 'totals': [total.to_wire() for total in revision.managed_control_totals], + 'rows': rows.rows, + 'receipt_totals': [total.to_wire() for total in receipt.observed_control_totals], + 'revision_hash': revision.revision_content_sha256, 'calls': calls})) +asyncio.run(main(json.load(sys.stdin))) +""" + process = await asyncio.create_subprocess_exec( + sys.executable, + "-c", + code, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout, stderr = await asyncio.wait_for( + process.communicate(json.dumps(data).encode()), timeout=30 + ) + except BaseException: + if process.returncode is None: + process.kill() + await process.wait() + raise + assert process.returncode == 0, stderr.decode() + result = json.loads(stdout) + assert result["calls"] == [] and result["currency"] == "EUR" + assert result["manifest"].encode() == seal.manifest_bytes + assert result["evidence"] == [ + cell.model_dump(mode="json") for cell in manifest.metric_availability + ] + assert ( + result["totals"] + == result["receipt_totals"] + == [total.to_wire() for total in revision.managed_control_totals] + ) + assert result["revision_hash"] == revision.revision_content_sha256 + assert {row["currency"] for row in result["rows"]} == {"EUR"} + + +def test_inline_result_retains_six_positional_arguments_and_both_additions_are_keyword_only() -> ( + None +): + parameters = inspect.signature(InlineFetchResult).parameters + assert [ + name + for name, parameter in parameters.items() + if parameter.kind == parameter.POSITIONAL_OR_KEYWORD + ] == [ + "rows", + "data_through", + "covered_constituent_ids", + "unavailable_constituents", + "unavailable_status", + "warnings", + ] + assert ( + parameters["currency"].kind + == parameters["cell_availability"].kind + == inspect.Parameter.KEYWORD_ONLY + ) + with pytest.raises(TypeError): + InlineFetchResult([], None, None, {}, "unsupported", (), "EUR") + + +@pytest.mark.parametrize("currency", ["EUR", "USD"]) +async def test_no_opt_in_keeps_manifest_and_control_total_bytes_compatible(currency: str) -> None: + async with reliable_factory("memory") as h: + h.currencies["eur"] = currency + _, _, request = await frozen_slice(h) + answer = complete_fetch(request) + # Separate executors/seal stores ensure replay cannot conceal a byte change. + results = [] + for declared_currency, evidence in ( + (None, None), + (currency, None), + (currency, {}), + (currency, {request.coverage.constituents[0].constituent_id: {}}), + ): + legacy = InlineFetchResult(answer.rows, request.period.end, None, {}, "unsupported", ()) + result = replace(legacy, currency=declared_currency, cell_availability=evidence) + source = InlineReportingSource( + capabilities=h.source(complete_fetch).capabilities, + fetch=lambda req: result, + clock=h.clock, + ) + results.append(await source.execute(request, cancel=asyncio.Event())) + assert {result.manifest_bytes for result in results} == {results[0].manifest_bytes} + manifest = verified(results[0]) + assert canonical_json_utf8_v1( + [total.model_dump(exclude_none=True) for total in manifest.control_totals] + ) == ( + b'[{"name":"impressions","value":"10","value_type":"integer"},' + b'{"name":"clicks","value":"0","value_type":"integer"},' + b'{"name":"spend","unit":"' + + currency.encode() + + b'","value":"0.30","value_type":"decimal"}]' + ) diff --git a/tests/type_checks/reporting_evidence_currency_integration.py b/tests/type_checks/reporting_evidence_currency_integration.py new file mode 100644 index 000000000..66c527bad --- /dev/null +++ b/tests/type_checks/reporting_evidence_currency_integration.py @@ -0,0 +1,44 @@ +"""Strict adopter fixture: the two additive fields compose with the legacy six.""" + +from collections.abc import Mapping, Sequence +from datetime import datetime + +from adcp.reporting.inline_source import InlineFetchResult, InlineReportingSource, MetricEvidence +from adcp.reporting.source import ( + ReportingSourceCapabilitiesV1, + ReportingSourceExecutor, + ReportingSourceSliceRequestV1, +) + + +def fetch(request: ReportingSourceSliceRequestV1) -> InlineFetchResult: + rows: Sequence[Mapping[str, object]] = [ + {"constituent_id": item.constituent_id, "impressions": 5, "clicks": 0} + for item in request.coverage.constituents + ] + through: datetime = request.period.end + evidence: Mapping[str, Mapping[str, MetricEvidence]] = { + item.constituent_id: { + "impressions": MetricEvidence.present(through), + "clicks": MetricEvidence.explicit_zero(data_through=through), + "spend": MetricEvidence.unavailable("billing_pending"), + } + for item in request.coverage.constituents + } + return InlineFetchResult( + rows, + through, + None, + {}, + "unsupported", + (), + currency=request.currency, + cell_availability=evidence, + ) + + +def configure(capabilities: ReportingSourceCapabilitiesV1) -> ReportingSourceExecutor: + async def fetch_async(request: ReportingSourceSliceRequestV1) -> InlineFetchResult: + return fetch(request) + + return InlineReportingSource(capabilities=capabilities, fetch=fetch_async) From 037de4ac822ecefb2f95d32c15c297fb4c45d683 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 16 Sep 2026 07:50:41 +0000 Subject: [PATCH 4/4] fix(reporting): keep an unreachable withdrawal from wedging money totals A cell declaring a metric `missing`/`delayed`/`unsupported` withdrew that metric's control total unconditionally, including when the declaring constituent staged no rows at all. `validate_monetary_content` requires the total for a monetary column every retained row carries, so the composition of #1173's per-metric evidence with #1175's obligation currency sealed an immutable publication the ledger then refused with `MONETARY_TOTAL_MISMATCH` on the replay of every retry -- permanently wedging the obligation for the answer per-metric evidence exists to give: a buy that delivered nothing and whose billing is pending. Distinguish the two shapes rather than exempting money from the withdrawal: - A withdrawal by a constituent that staged rows still removes the total. Those rows carry values the adapter disclaimed, so summing them would contradict its own evidence, and the payload stays staged byte-for-byte. - A withdrawal by a constituent that staged no rows reaches no sum and removes nothing. The checksum over the rows that were measured is retained. A row-empty batch still withdraws, so unavailability is never published as an observed zero. No total ever sums a disclaimed value. Monetary columns have no third option: dropping the total is refused by the ledger and keeping it would falsify the evidence. A withdrawn monetary cell whose own constituent's rows report that metric now raises before anything is staged or sealed. `ReportingDefinitionBinding.to_wire()` deliberately keeps the frozen unit declarations off the wire, so the frozen slice request cannot carry them; `InlineReportingSource(monetary_metrics=...)` lets a trusted caller declare #1171's custom `monetary_metric_units` columns, with `spend` always included. An undeclared custom money column keeps the non-monetary behavior. Manifest and control-total bytes for a result with no `cell_availability` are unchanged, byte-identical to the #1176 base. Co-Authored-By: Claude Opus 5 (1M context) --- docs/reporting-source-adapters.md | 62 ++++- src/adcp/reporting/inline_source.py | 83 +++++- .../test_inline_cell_availability.py | 29 ++ ...reporting_evidence_currency_integration.py | 255 ++++++++++++++++++ ...reporting_evidence_currency_integration.py | 10 +- 5 files changed, 421 insertions(+), 18 deletions(-) diff --git a/docs/reporting-source-adapters.md b/docs/reporting-source-adapters.md index da2d07ad5..7e62e35e7 100644 --- a/docs/reporting-source-adapters.md +++ b/docs/reporting-source-adapters.md @@ -74,11 +74,60 @@ staged row, including rows outside the requested coverage, which stay staged with a warning. It is emitted when every staged row carries a valid finite numeric value for the metric. Missing fields, nulls, booleans, and invalid numbers prevent a total, and an omitted explicit-zero row value is not filled -in. Declaring any cell of a metric `missing`, `delayed`, or `unsupported` -withdraws that metric's total even when rows still carry values, because the -adapter has said those values are not a measurement. Statuses the SDK derives -on its own withdraw nothing: a result with no `cell_availability` publishes -exactly the totals it published before. +in. Statuses the SDK derives on its own withdraw nothing: a result with no +`cell_availability` publishes exactly the totals it published before. + +### The withdrawal invariant + +A declared `missing`, `delayed`, or `unsupported` cell asserts that the source +produced **no measurement** for it. A control total is the exact sum of a metric +over every staged row, so the only question a withdrawal raises is whether it +can put a disclaimed value in that sum. Two shapes follow, and they are +deliberately not treated alike: + +- **A withdrawal by a constituent that staged rows removes that metric's + total.** Its own rows carry values the adapter has disclaimed, so summing + them would contradict the evidence it supplied. The rows stay staged + byte-for-byte -- an adapter may publish a provider payload unchanged and + declare per cell which of its columns are measurements. +- **A withdrawal by a constituent that staged no rows removes nothing.** It + reaches no sum, so the checksum over the rows that *were* measured is + retained. A buy that delivered nothing and whose billing is pending is the + answer per-metric evidence exists to give; withdrawing its neighbours' + subtotal would destroy a checksum the consumer recomputes. + +A batch with no rows at all is the one case where a withdrawal removes a total +on its own: a `0` there would publish unavailability as an observed zero. +Unavailability is otherwise carried by the cell's own evidence, never by a +missing total. + +### Monetary columns have no third option + +`spend`, plus every metric the trusted report definition froze through +`monetary_metric_units` / `monetary_control_total_units`, is reconciled by the +obligation ledger against the rows a revision retains. For those columns the +first shape above has nowhere to go: dropping the total makes the ledger refuse +the revision with `MONETARY_TOTAL_MISMATCH` on the immutable replay of every +retry, and keeping it would sum a value the adapter disclaims. So a withdrawn +monetary cell whose own constituent's rows report that metric raises +`ValueError` before anything is staged or sealed. Omit the metric from those +rows, or declare the cell measured. + +The frozen slice request cannot carry those unit declarations -- +`ReportingDefinitionBinding.to_wire()` keeps them off the wire so retained +contract hashes do not move -- so tell the adapter which columns they are: + +```python +InlineReportingSource( + capabilities=capabilities, + fetch=fetch, + monetary_metrics=[name for name, _ in obligation.definition.monetary_metric_units], +) +``` + +`spend` is always included. A custom money column that is **not** declared here +is treated as non-monetary, which can still wedge the obligation for that +column -- declare it whenever the obligation's definition does. The existing zero-row wire rule is unchanged: an empty batch must be wholly explicit-zero or wholly unavailable. It cannot mix available cells with @@ -99,7 +148,8 @@ This uses the constituent defaults: constituents with rows are present and covered constituents without rows are observed zeros. Bare `[]` still means an observed zero; `None` still means not ready. Existing positional `InlineFetchResult` arguments retain their meaning, and so do their control -totals -- only an explicitly declared cell withdraws one. +totals -- only an explicitly declared cell withdraws one, under the +[withdrawal invariant](#the-withdrawal-invariant). Bulk helpers return maps to pass to `cell_availability`, leaving the result's other options available: diff --git a/src/adcp/reporting/inline_source.py b/src/adcp/reporting/inline_source.py index fc6aca914..cb3bcb7d2 100644 --- a/src/adcp/reporting/inline_source.py +++ b/src/adcp/reporting/inline_source.py @@ -15,9 +15,12 @@ For uneven metric support, return :class:`InlineFetchResult` with ``cell_availability={constituent_id: {metric_name: MetricEvidence(...)}}``. Omitted cells retain the constituent defaults. Explicit evidence controls each -cell independently; mixed cells make the constituent partial, and a metric -column an adapter declares incomplete receives no control total. Metric -semantics always come from the selected SDK offering. +cell independently and mixed cells make the constituent partial. A cell that +withdraws a metric its own constituent's rows report withdraws that metric's +control total, so no total ever sums a disclaimed value; a cell that staged no +rows reaches no sum and leaves its neighbours' subtotal intact. Money has no +such choice -- see ``monetary_metrics`` on :class:`InlineReportingSource`. +Metric semantics always come from the selected SDK offering. The three answers a fetch can give --------------------------------- @@ -124,6 +127,14 @@ _REASON = TypeAdapter(EvidenceReason) _AVAILABLE = frozenset({"present", "explicit_zero"}) +#: The statuses that withdraw a cell: the source gave no measurement for it. +_WITHDRAWN = frozenset({"missing", "delayed", "unsupported"}) +#: The always-monetary column of the single-currency reporting API; a trusted +#: definition may freeze others through ``monetary_metrics``. The obligation +#: ledger reconciles money against the rows a revision retains, which is why a +#: withdrawal contradicted by its own rows cannot just drop the total the way a +#: non-monetary column can. +_MONEY = "spend" class _CellEvidenceError(ValueError): @@ -269,8 +280,9 @@ class InlineFetchResult: other cells. This is an adapter surface, not the manifest's wire-format ``metric_availability`` list. - Unknown keys, duplicate mapping entries, invalid evidence, and contradictory - explicit zeros raise ``ValueError`` before staging. A zero-row batch must + Unknown keys, duplicate mapping entries, invalid evidence, contradictory + explicit zeros, and a withdrawn *monetary* cell whose own constituent's rows + report that metric raise ``ValueError`` before staging. A zero-row batch must be wholly explicit-zero or wholly unavailable under the existing contract. """ @@ -529,6 +541,16 @@ class InlineReportingSource: against the requested denominator, which covers the common shapes. Rows that match nothing are retained in the staged object but excluded from coverage, and a warning names how many were dropped. + monetary_metrics: + The metric names the trusted report definition froze as money, beyond + the built-in ``spend``. Supply + ``ReportingDefinitionBinding.monetary_metric_units`` keys (and any + ``monetary_control_total_units`` keys) when the obligation declares + them: the frozen slice request cannot carry those declarations, because + ``to_wire()`` keeps them off the wire so retained contract hashes do + not move, so the adapter would otherwise not know which columns the + ledger will reconcile. A custom money column that is not declared here + behaves like a non-monetary one. include_exception_detail: Whether an unclassified exception's ``str()`` may enter the retained safe message. **Off by default**: HTTP client exceptions routinely @@ -553,6 +575,7 @@ def __init__( ) = None, staged_commit_prefix: str = "inline", include_exception_detail: bool = False, + monetary_metrics: Collection[str] = (), clock: Callable[[], datetime] | None = None, ) -> None: unsupported = [ @@ -573,6 +596,12 @@ def __init__( self._constituent_of = constituent_of or _default_constituent_of self._staged_commit_prefix = staged_commit_prefix self.include_exception_detail = include_exception_detail + # Mirrors validate_monetary_content exactly: ``spend`` is the built-in + # money column of the single-currency API, plus whatever the trusted + # definition froze. The frozen slice request cannot supply these -- + # ReportingDefinitionBinding.to_wire() deliberately keeps the unit + # declarations off the wire so retained contract hashes do not move. + self._monetary = frozenset(monetary_metrics) | {_MONEY} self._clock = clock or _now @property @@ -814,11 +843,19 @@ async def _publish( # a checksum over the staged rows -- consumers recompute it from the # revision's rows -- so an unmatched row or a legacy-derived # unavailable constituent leaves it verifiable and unchanged. + # A withdrawal only reaches the sum through the rows its own constituent + # staged. A cell that staged none cannot put a disclaimed value in the + # total, so the checksum over the rows that *were* measured is retained + # -- which is also what keeps a monetary column reconcilable when one + # constituent delivered nothing and its money is unavailable. A batch + # with no rows withdraws either way: a "0" there would publish + # unavailability as an observed zero. declared_incomplete = { cell.metric for cell in cells if cell.status not in _AVAILABLE and cell.metric in overrides.get(cell.constituent_id, {}) + and (not result.rows or rows_by_constituent[cell.constituent_id]) } control_totals = _control_totals(request, result.rows, declared_incomplete) payload = _encode_rows(result.rows) @@ -935,7 +972,11 @@ def _resolve_availability( explicit = overrides.get(constituent_id, {}).get(metric) if explicit is not None: _validate_cell_rows( - constituent_id, metric, explicit, rows_by_constituent[constituent_id] + constituent_id, + metric, + explicit, + rows_by_constituent[constituent_id], + monetary=metric in self._monetary, ) status, reason = explicit.status, explicit.reason watermark = explicit.data_through @@ -1145,16 +1186,36 @@ def _validate_cell_rows( metric: str, evidence: MetricEvidence, rows: Sequence[Mapping[str, Any]], + *, + monetary: bool, ) -> None: """Reject explicit assertions contradicted by the supplied measurements. Legacy derived cells remain permissive. Metric value types are defined by the offering, but an explicit present cell needs a value and a claimed zero must not conceal a nonzero, nonnumeric, or non-finite measurement. + + A withdrawn *non-monetary* cell stays permissive on purpose: an adapter may + stage a provider's payload byte-for-byte and declare per cell which of its + columns are measurements, and the withdrawal removes that metric's control + total rather than falsifying it. Money is different, because it is the one + column the obligation ledger reconciles against the rows it retains: a + withdrawal there can neither drop the total (the ledger refuses the + revision, forever, behind an immutable replay) nor keep it (it would sum a + value the adapter disclaims). So say so here, before anything is staged. """ label = f"cell_availability ({constituent_id!r}, {metric!r})" if evidence.status == "present" and (not rows or any(row.get(metric) is None for row in rows)): raise _CellEvidenceError(f"{label} present requires a value in every constituent row") + if ( + monetary + and evidence.status in _WITHDRAWN + and any(row.get(metric) is not None for row in rows) + ): + raise _CellEvidenceError( + f"{label} {evidence.status} contradicts a monetary value this constituent's own rows " + f"report; omit {metric!r} from those rows, or declare the cell measured" + ) if evidence.status == "explicit_zero": for row in rows: if row.get(metric) is None: @@ -1240,10 +1301,12 @@ def _control_totals( consumer's equality check into a flake. ``declared_incomplete`` names the metrics an adapter explicitly withdrew - for at least one cell. Those columns may still carry row values, but the - adapter has said they are not a measurement, so totalling them would - contradict the very evidence it supplied. Every other column keeps the - checksum a consumer recomputes from the revision's rows. + for a cell that can reach this sum -- one whose own constituent staged rows, + or any cell at all when there are no rows to sum. Those columns may still + carry row values, but the adapter has said they are not a measurement, so + totalling them would contradict the very evidence it supplied. A cell that + staged no rows contributes nothing and withdraws nothing: every other column + keeps the checksum a consumer recomputes from the revision's rows. """ totals: list[SourceControlTotalV1] = [] for metric in request.requested_metrics: diff --git a/tests/conformance/reporting/test_inline_cell_availability.py b/tests/conformance/reporting/test_inline_cell_availability.py index 98014a6e0..c078cd582 100644 --- a/tests/conformance/reporting/test_inline_cell_availability.py +++ b/tests/conformance/reporting/test_inline_cell_availability.py @@ -622,13 +622,42 @@ async def test_a_covered_zero_row_constituent_keeps_zeros_only_for_its_available assert cell.status == ( "unsupported" if cell.metric == "completed_views" else "explicit_zero" ) + # SECOND_CID staged no rows, so its withdrawal reaches no sum: the checksum + # over the rows that *were* measured is retained for every metric. Only a + # withdrawal by a constituent that staged rows -- whose values the adapter + # has disclaimed -- removes a total. See + # test_a_withdrawal_only_removes_the_total_its_own_rows_could_corrupt. assert {total.name: total.value for total in manifest.control_totals} == { "impressions": "10", "clicks": "0", "viewability": "0.75", + "completed_views": "2", } +async def test_a_withdrawal_only_removes_the_total_its_own_rows_could_corrupt() -> None: + """The same withdrawal, from the constituent that actually staged the rows. + + Its rows carry ``completed_views``, and the adapter has said those values + are not a measurement, so the total goes -- summing them would contradict + the evidence. The distinction matters because a control total is the exact + sum of the staged rows: a cell with no rows cannot put a disclaimed value + in it, and withdrawing on its behalf would destroy a checksum the consumer + (and, for money, the obligation ledger) reconciles against those rows. + """ + request = _request(second=True) + manifest = await _seal( + InlineFetchResult( + rows=[ROW, {**ROW, "media_buy_id": "media-buy-second"}], + cell_availability={ + CID: {"completed_views": MetricEvidence.unavailable("not_video_inventory")} + }, + ), + request, + ) + assert {total.name for total in manifest.control_totals} == set(METRICS) - {"completed_views"} + + async def test_explicit_zero_evidence_for_every_cell_can_seal_a_real_empty_period() -> None: manifest = await _seal( InlineFetchResult( diff --git a/tests/conformance/reporting/test_reporting_evidence_currency_integration.py b/tests/conformance/reporting/test_reporting_evidence_currency_integration.py index 78d199635..ff3d61f93 100644 --- a/tests/conformance/reporting/test_reporting_evidence_currency_integration.py +++ b/tests/conformance/reporting/test_reporting_evidence_currency_integration.py @@ -304,6 +304,261 @@ async def test_one_unavailable_spend_cell_suppresses_only_its_metric_total( assert [item.status for item in manifest.coverage.constituents] == ["present", "partial"] +def _with_second_constituent( + request: ReportingSourceSliceRequestV1, +) -> ReportingSourceSliceRequestV1: + constituents = [ + *request.coverage.constituents, + MediaBuyConstituentV1( + constituent_id="second", + media_buy_id="second-buy", + product_id=request.contract.report_definition_id, + ), + ] + return request.model_copy( + update={ + "coverage": ReportingSourceCoverageRequestV1( + expected="partial", + constituents=constituents, + denominator_fingerprint=coverage_denominator_fingerprint_v1(constituents), + ) + } + ) + + +@pytest.mark.parametrize( + "second_rows", ["none", "without-spend"], ids=["zero-delivery", "omitted-money"] +) +async def test_a_no_row_unavailable_spend_cell_still_commits_its_neighbour_subtotal( + reliable: ReliableHarness, second_rows: str +) -> None: + """A withdrawn cell that contributes no value must not withdraw the subtotal. + + A buy that delivered nothing and whose billing is pending is the answer + per-metric evidence exists to give. It reaches no sum, so the checksum over + the rows that *were* measured stays exact -- and stays publishable: the + obligation ledger requires the total for a spend column every retained row + carries, and refuses a revision without one with ``MONETARY_TOTAL_MISMATCH`` + on the immutable replay of every retry. A constituent whose rows omit spend + leaves the column sparse instead, which has no honest total either way. + """ + h = reliable + producer, obligation, request = await frozen_slice(h) + request = _with_second_constituent(request) + first, second = request.coverage.constituents + measured = { + "media_buy_id": first.media_buy_id, + "impressions": 5, + "clicks": 0, + "spend": "0.10", + "currency": request.currency, + } + rows = [measured] + if second_rows == "without-spend": + rows.append( + {key: value for key, value in measured.items() if key != "spend"} + | {"media_buy_id": second.media_buy_id} + ) + answer = InlineFetchResult( + rows=rows, + currency=request.currency, + cell_availability={ + item.constituent_id: { + "impressions": ( + MetricEvidence.present(request.period.end) + if item is first or second_rows == "without-spend" + else MetricEvidence.explicit_zero() + ), + "clicks": MetricEvidence.explicit_zero(data_through=request.period.end), + "spend": ( + MetricEvidence.present(request.period.end) + if item is first + else MetricEvidence.unavailable("billing_pending") + ), + } + for item in (first, second) + }, + ) + result = await h.source(lambda req: answer).execute(request, cancel=asyncio.Event()) + manifest = verified(result) + # The withdrawal is carried by the cell's own evidence, not by a gap in the totals. + assert { + (cell.constituent_id, cell.status) + for cell in manifest.metric_availability + if cell.metric == "spend" + } == {(first.constituent_id, "present"), (second.constituent_id, "unsupported")} + spend = [ + (total.value, total.unit, total.value_type) + for total in manifest.control_totals + if total.name == "spend" + ] + assert spend == ([] if second_rows == "without-spend" else [("0.10", "EUR", "decimal")]) + revision = await h.commit_slice(producer, obligation, request, result) + assert revision.managed_control_totals is not None + assert [ + ReportingControlTotalRecord(total.name, total.value, total.value_type, total.unit) + for total in manifest.control_totals + ] == list(revision.managed_control_totals) + await commit_records(h, await publication_records(h, obligation, revision)) + + +@pytest.mark.parametrize("status", ["missing", "delayed", "unsupported"]) +async def test_a_withdrawn_money_cell_its_own_rows_contradict_fails_before_staging( + reliable: ReliableHarness, status: str +) -> None: + """Money is the column the ledger reconciles, so it has nowhere to go. + + Withdrawing a cell whose own matched rows report that metric says both "not + measured" and "here is the measurement". For a non-monetary column the + withdrawal simply removes the total and keeps the provider payload staged + byte-for-byte. Spend has no such out: dropping its total makes the ledger + refuse the revision with ``MONETARY_TOTAL_MISMATCH`` on the immutable replay + of every retry, and keeping it would sum a value the adapter disclaims. So + it fails while nothing is staged, sealed, or retained. + """ + h = reliable + _, obligation, request = await frozen_slice(h) + first = request.coverage.constituents[0] + answer = InlineFetchResult( + rows=[ + { + "media_buy_id": first.media_buy_id, + "impressions": 5, + "clicks": 0, + "spend": "0.10", + "currency": request.currency, + } + ], + currency=request.currency, + cell_availability={ + first.constituent_id: {"spend": MetricEvidence(status=status, reason="billing_pending")} + }, + ) + with pytest.raises(ValueError, match=f"{status} contradicts a monetary value"): + await h.source(lambda req: answer).execute(request, cancel=asyncio.Event()) + assert "stage.before" not in h.failures.hits and "seal.before" not in h.failures.hits + assert ( + await h.seals.get( + account_id="eur", source_execution_key=request.identity.source_execution_key + ) + is None + ) + assert ( + await h.store.list_revisions( + account_id="eur", reporting_obligation_id=obligation.reporting_obligation_id + ) + == () + ) + + +async def test_a_custom_frozen_monetary_metric_is_distinguished_like_spend( + reliable: ReliableHarness, +) -> None: + """#1171's custom ``monetary_metric_units`` wedge for exactly the same reason. + + ``validate_monetary_content`` reconciles every metric the trusted definition + froze as money, not just ``spend``. The frozen slice request cannot carry + those declarations -- ``ReportingDefinitionBinding.to_wire()`` keeps them off + the wire so retained contract hashes do not move -- so the adapter is told + which columns they are through ``monetary_metrics``. A declared custom money + column then behaves exactly like spend on both sides of the distinction; an + undeclared one keeps #1173's non-monetary behavior, which is why this is an + explicit trusted seam rather than a hardcoded metric name. + """ + h = reliable + base = configuration("eur").definition + assert base is not None + config = replace( + configuration("eur"), + definition=replace(base, monetary_metric_units=(("clicks", "EUR"),)), + ) + producer, obligation, request = await frozen_slice(h, config=config) + request = _with_second_constituent(request) + first, second = request.coverage.constituents + assert obligation.definition is not None + frozen_money = [name for name, _ in obligation.definition.monetary_metric_units] + assert frozen_money == ["clicks"] + + def build(*, contradict: bool) -> InlineFetchResult: + rows = [ + { + "media_buy_id": first.media_buy_id, + "impressions": 5, + "clicks": "0.25", + "spend": "0.10", + "currency": request.currency, + } + ] + if contradict: + rows.append(dict(rows[0], media_buy_id=second.media_buy_id)) + available = MetricEvidence.present(request.period.end) + return InlineFetchResult( + rows=rows, + currency=request.currency, + cell_availability={ + item.constituent_id: { + "impressions": ( + available if item is first or contradict else MetricEvidence.explicit_zero() + ), + "spend": ( + available if item is first or contradict else MetricEvidence.explicit_zero() + ), + "clicks": ( + available + if item is first + else MetricEvidence.unavailable("measurement_pending") + ), + } + for item in (first, second) + }, + ) + + def source(*, contradict: bool, declared: bool) -> InlineReportingSource: + answer = build(contradict=contradict) + return InlineReportingSource( + capabilities=h.source(complete_fetch).capabilities, + fetch=lambda req: answer, + staging=h.staging, + seals=h.seals, + monetary_metrics=frozen_money if declared else (), + clock=h.clock, + ) + + def keyed(key: str) -> ReportingSourceSliceRequestV1: + return request.model_copy( + update={"identity": request.identity.model_copy(update={"source_execution_key": key})} + ) + + # The declaring constituent staged no rows, so its neighbour's money subtotal + # survives -- and only then can the ledger admit the revision at all. + surviving = keyed("custom-money-subtotal") + result = await source(contradict=False, declared=True).execute( + surviving, cancel=asyncio.Event() + ) + totals = {total.name: total.value for total in verified(result).control_totals} + assert totals["clicks"] == "0.25" + revision = await h.commit_slice(producer, obligation, surviving, result) + await commit_records(h, await publication_records(h, obligation, revision)) + + # The same withdrawal from a constituent whose own rows report it. Undeclared, + # it keeps #1173's behavior: the column is simply not totalled. + permissive = await source(contradict=True, declared=False).execute( + keyed("custom-money-undeclared"), cancel=asyncio.Event() + ) + assert "clicks" not in {total.name for total in verified(permissive).control_totals} + + # Declared, it is refused before anything is staged or sealed. + refused = keyed("custom-money-contradiction") + with pytest.raises(ValueError, match="'clicks'.*contradicts a monetary value"): + await source(contradict=True, declared=True).execute(refused, cancel=asyncio.Event()) + assert ( + await h.seals.get( + account_id="eur", source_execution_key=refused.identity.source_execution_key + ) + is None + ) + + @pytest.mark.parametrize("mismatch", ["result", "row", "mixed", "invalid"]) @pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) async def test_currency_failure_with_cell_evidence_precedes_staging_and_sealing( diff --git a/tests/type_checks/reporting_evidence_currency_integration.py b/tests/type_checks/reporting_evidence_currency_integration.py index 66c527bad..40aed8c77 100644 --- a/tests/type_checks/reporting_evidence_currency_integration.py +++ b/tests/type_checks/reporting_evidence_currency_integration.py @@ -37,8 +37,14 @@ def fetch(request: ReportingSourceSliceRequestV1) -> InlineFetchResult: ) -def configure(capabilities: ReportingSourceCapabilitiesV1) -> ReportingSourceExecutor: +def configure( + capabilities: ReportingSourceCapabilitiesV1, + monetary_metric_units: Sequence[tuple[str, str]] = (), +) -> ReportingSourceExecutor: async def fetch_async(request: ReportingSourceSliceRequestV1) -> InlineFetchResult: return fetch(request) - return InlineReportingSource(capabilities=capabilities, fetch=fetch_async) + monetary: Sequence[str] = [name for name, _unit in monetary_metric_units] + return InlineReportingSource( + capabilities=capabilities, fetch=fetch_async, monetary_metrics=monetary + )