Reporting & Compliance Dashboards: Turning Shelf Compliance Scores into Decisions

A shelf analytics platform can compute a flawless compliance_percentage for every fixture in a chain and still change nothing about how stores run. A raw score is a measurement, not a decision: a number stamped 91.4 on a single capture event tells a category manager neither whether that fixture is improving or decaying, nor whether it is one of twelve failing bays in a banner that needs a truck rolled tonight, nor whether the drop is a genuine execution failure or an artifact of a planogram reset that landed hours ago. The reporting layer is what converts the typed compliance payload emitted upstream by Planogram Sync & SKU Mapping Strategies into something an operator acts on — aggregating thousands of per-fixture events into store, banner, and chain rollups, trending them so drift is visible before it becomes a sales loss, routing threshold breaches to the right person in seconds through real-time alerting and webhooks, and distilling the whole picture into a category-manager briefing that lands before the morning reset. Everything this layer produces is exposed through stable compliance score APIs and payload contracts, and the accuracy of every rollup, trend, and alert is inherited directly from the payload it consumes — which is why the ingestion boundary matters more here than anywhere else.

Reporting and compliance dashboard architecture, end to end Typed compliance payloads from the planogram sync tier on the left enter an idempotent ingestion API, which writes to a partitioned time-series store. A rollup and query service reads that store and fans out to three downstream consumers on the right: a score REST API, an alert and webhook fan-out, and a briefing generator. A registry-revision-aware history table below anchors every record to the planogram version effective at capture time. Planogram sync tier typed compliance payloads Ingestion API validate · normalize idempotent upsert key: fixture · ts · rev Time-series store partition by store_id / banner Rollup & query service slot → fixture → store → banner Score REST API dashboards · BI Alert / webhook fan-out Briefing generator Registry-revision-aware history every record anchored to the planogram revision effective at capture_timestamp
The reporting layer end to end: idempotent ingestion feeds a partitioned time-series store, a rollup service fans out to score APIs, alerts, and briefings, and a revision-aware history anchors every record to its planogram version.

Ingestion & Data Boundaries Jump to heading

Everything the reporting layer will ever report is decided at the moment a payload crosses its ingestion boundary. Upstream, the sync tier emits one typed compliance record per capture event; downstream, dashboards and briefings treat those records as ground truth. If a duplicate slips in, a fixture’s history double-counts a bad frame; if an out-of-order frame overwrites a newer one, a resolved out-of-stock reappears; if a payload scored against a superseded registry_revision is trusted, the dashboard argues with the store about a planogram that no longer exists. The ingestion API therefore applies the same edge-validation discipline that Retail Data Ingestion Pipelines for Store Photos applies to raw imagery: validate the shape, reject or quarantine the malformed, and never let an untrusted record reach the store.

The contract is fixed by the sync tier and must be parsed into a typed structure before anything touches the time-series store. A representative inbound payload:

{
  "fixture_id": "BAY-014-SHELF-03",
  "store_id": "STORE-4471",
  "registry_revision": 184,
  "capture_timestamp": "2026-06-28T07:42:11Z",
  "compliance_percentage": 91.4,
  "out_of_stock_flags": ["BAY-014-SHELF-03-POS-07"],
  "misplaced_sku_list": ["00098765432107"],
  "price_tag_mismatch_count": 2
}

The natural idempotency key for this stream is the tuple (fixture_id, capture_timestamp, registry_revision). A capture is uniquely identified by which fixture it saw, when the camera fired, and which planogram version it was scored against; any two payloads sharing that tuple describe the same physical observation and must collapse to one row. Keying on capture_timestamp rather than arrival time is what makes the pipeline safe against the retries and reconnections that define real retail networks — a webhook redelivery, a broker replay, or a delayed upload from a store that was offline all produce the same key and therefore the same idempotent write.

Validation at this boundary rejects three distinct classes of bad record before they can corrupt a rollup. First, structurally malformed payloads — a compliance_percentage outside 0100, a missing store_id, a price_tag_mismatch_count below 0. Second, out-of-order records for a fixture whose latest observation is already newer: a payload with a capture_timestamp older than the newest one already stored for that (fixture_id, registry_revision) is not an error, but it must never overwrite fresher state — it is either dropped as a late duplicate or filed into history for backfill without moving the fixture’s current score. Third, and most subtly, stale-registry records: a payload carrying a registry_revision that the store has already retired must be quarantined rather than trusted, because scoring a fixture against a planogram that was reset yesterday produces violations that no longer correspond to reality.

from datetime import datetime
from enum import Enum

from pydantic import BaseModel, Field, field_validator


class IngestOutcome(str, Enum):
    ACCEPTED = "accepted"
    DUPLICATE = "duplicate"
    STALE_REGISTRY = "stale_registry"
    OUT_OF_ORDER = "out_of_order"
    REJECTED = "rejected"


class CompliancePayload(BaseModel):
    """Typed compliance record emitted by the planogram sync tier."""

    fixture_id: str
    store_id: str
    registry_revision: int = Field(ge=0)
    capture_timestamp: datetime
    compliance_percentage: float = Field(ge=0.0, le=100.0)
    out_of_stock_flags: list[str] = Field(default_factory=list)
    misplaced_sku_list: list[str] = Field(default_factory=list)
    price_tag_mismatch_count: int = Field(ge=0)

    @field_validator("capture_timestamp")
    @classmethod
    def not_in_future(cls, value: datetime) -> datetime:
        # A clock-skewed capture > 5 min ahead is a device fault, not a real frame.
        from datetime import timezone
        now = datetime.now(timezone.utc)
        if (value - now).total_seconds() > 300:
            raise ValueError("capture_timestamp is implausibly in the future")
        return value

    def idempotency_key(self) -> tuple[str, str, int]:
        return (self.fixture_id, self.capture_timestamp.isoformat(), self.registry_revision)

Anything that fails validation is routed to a quarantine table with the raw body, the failure class, and the effective registry revision attached, so a bad upstream deploy is diagnosable from a single query rather than reconstructed from logs. The disposition — accept, drop, or quarantine — and the exact revision-freshness rules are the substance of Compliance Score APIs & Payload Contracts, which owns the versioning contract in full.

Pipeline Topology & Compute Architecture Jump to heading

The reporting layer decomposes into four services with deliberately different scaling profiles, because a write-heavy ingestion path and a read-heavy query path fight each other if they share a process. The ingestion API is bursty and latency-sensitive during reset windows; the time-series store is write-optimized and retention-bound; the rollup and query service is CPU-bound and cache-friendly; the alert and briefing fan-out is I/O-bound on downstream endpoints. Keeping them separate lets each scale on its own signal.

  • An ingestion API that terminates the payload stream, validates and normalizes each record, and performs the idempotent upsert keyed on (fixture_id, capture_timestamp, registry_revision).
  • A time-series store that persists every accepted record as an append-only observation, partitioned by store_id (or by banner for chains where a single banner spans many stores), so that a store’s entire history lives on one partition and rollup queries never fan across the whole cluster.
  • A rollup and query service that materializes hierarchical aggregates on write and serves point-in-time and windowed reads, backed by an aggregate cache keyed on (scope, window, as_of_revision).
  • An alert and webhook fan-out plus a briefing generator that subscribe to the rollup stream and route breaches and digests to their consumers.

Detection and scoring upstream already flow through a partitioned broker, and the reporting layer subscribes to that same event backbone rather than polling — the broker patterns and partition sizing are owned by Message Broker Patterns for Capture Events. Partitioning by store_id is the load-bearing decision: compliance questions are almost always scoped to a store, a banner, or a chain, and co-locating a store’s records means the common rollup — “what is store STORE-4471 doing right now?” — is a single-partition scan. Autoscaling watches consumer lag on the ingestion subscription and query latency on the rollup service independently; a reset that lands across five hundred stores at 6 a.m. spikes ingestion lag without touching query latency, and only the ingestion tier needs to scale to absorb it. The offline stores that upload a night’s backlog on reconnection arrive as a lag spike, not a data-integrity problem, precisely because the idempotency key is capture-time rather than arrival-time — the same resilience posture that Fallback Routing for Offline Store Scenarios establishes for the capture path.

Core Processing Logic: Rollups, Weighting & Drift Jump to heading

Two transforms define what this layer computes: hierarchical rollups that turn per-slot observations into scores at every level of the retail hierarchy, and drift detection that turns a sequence of those scores over time into an early warning.

Hierarchical rollups and score weighting Jump to heading

Compliance is observed at the slot but consumed at every level above it: slot → fixture → bay → store → banner → chain. A naive rollup averages child scores, but an unweighted mean lies. A bay of twelve slots where one endcap-adjacent hero SKU is out of stock is not equivalent to a bay where one slow-moving tail SKU is empty, and a store’s headline number should weight fixtures by their commercial importance — facings, sales velocity, or a category-manager-assigned priority — rather than treating every fixture as one vote. The rollup therefore carries a weight alongside each child score and computes a weighted mean up the tree, while preserving the raw counts (out_of_stock_flags, price_tag_mismatch_count) as additive sums so a chain-level dashboard can still answer “how many facings are empty across the banner right now?” without re-querying the leaves.

from dataclasses import dataclass, field


@dataclass(frozen=True)
class ScopeScore:
    scope_id: str            # e.g. "STORE-4471" or "BANNER-NE"
    compliance: float        # weighted compliance_percentage, 0..100
    weight: float            # commercial weight of this scope in its parent
    out_of_stock: int        # additive count rolled up from leaves
    price_mismatches: int


def roll_up(children: list[ScopeScore], scope_id: str) -> ScopeScore:
    """Aggregate child scores into a parent scope.

    Compliance is a weight-weighted mean so commercially important fixtures
    dominate the headline; raw fault counts are summed so absolute exposure
    stays visible at every level of slot -> fixture -> bay -> store -> banner.
    """
    if not children:
        raise ValueError(f"cannot roll up empty scope {scope_id}")
    total_weight = sum(c.weight for c in children)
    if total_weight <= 0:
        raise ValueError(f"scope {scope_id} has non-positive total weight")
    weighted = sum(c.compliance * c.weight for c in children) / total_weight
    return ScopeScore(
        scope_id=scope_id,
        compliance=round(weighted, 2),
        weight=total_weight,
        out_of_stock=sum(c.out_of_stock for c in children),
        price_mismatches=sum(c.price_mismatches for c in children),
    )

Rollups are materialized on write rather than computed on every read. When an accepted payload updates a fixture, only the fixtures’s ancestor chain is recomputed — a bounded, logarithmic amount of work — and the result is cached under (scope, window, as_of_revision). A chain of ten thousand fixtures does not re-aggregate ten thousand rows to answer a dashboard query; it reads a materialized store-level row that was refreshed the last time one of its fixtures changed. The weighting scheme itself is not a constant: the weights come from the same commercial inputs that Threshold Tuning for Compliance Accuracy uses to decide which violations matter, so the reporting headline and the upstream scoring engine agree on what “important” means.

Drift detection over the time series Jump to heading

A single score is a point; a decision usually needs a slope. Drift detection reads the per-scope time series and separates three regimes: normal variation, a genuine downward trend that warrants intervention, and a step change that signals an event — a reset, a new packaging rollout, a fixture reconfiguration. A robust approach compares a short trailing window against a longer baseline and flags when the short-window mean falls more than a configured number of standard deviations below baseline, which suppresses the day-to-day noise that would otherwise fire an alert on every capture.

import statistics


def detect_drift(
    series: list[float],          # chronological compliance_percentage values
    short_window: int = 5,
    long_window: int = 30,
    z_threshold: float = 2.5,
) -> bool:
    """Flag sustained negative drift, not single-frame noise.

    Returns True when the recent short-window mean sits more than z_threshold
    baseline standard deviations below the long-window baseline mean.
    """
    if len(series) < long_window:
        return False
    baseline = series[-long_window:-short_window]
    recent = series[-short_window:]
    base_mean = statistics.fmean(baseline)
    base_std = statistics.pstdev(baseline) or 1e-6
    z = (base_mean - statistics.fmean(recent)) / base_std
    return z >= z_threshold

The reporting layer runs this cheaply and continuously to gate alerts, but the full statistical treatment — seasonality decomposition, changepoint detection, and distinguishing a promotional reset from a true decay — belongs to Time-Series Compliance Drift Analysis. Distinguishing a reset-induced step from a decay matters because promotional zones legitimately swing the score: an endcap that changes weekly should be trended against its own promotional baseline, not the ambient planogram, which is why promotional captures are segmented on the way in through Promotional Display Alignment Checks.

State Management & Resilience Jump to heading

The reporting layer’s state is a versioned history, not a mutable snapshot, and its correctness depends on never letting a late payload rewrite settled facts. Every accepted record is stored against the registry_revision it was scored under, and rollups are computed within a revision rather than across a reset boundary. When a store resets from revision 184 to 185, the two are distinct series: a dashboard asking “how did this store trend last week?” reads the revision that was effective during that week, and a frame captured at 07:42 under revision 184 that uploads at 09:15 — after the 08:00 reset to 185 — is scored, stored, and trended against 184, exactly as it was measured. Reconciling by capture_timestamp and registry_revision rather than arrival order is the invariant that keeps a reset from retroactively rewriting yesterday’s audit.

Out-of-order and late payloads are the normal case, not the exception, so the upsert is written to be safe against them. The idempotent write only advances a fixture’s current score when the incoming capture_timestamp is newer than the stored one for that revision; an older payload is still persisted into the append-only history — so backfill and trend queries see a complete series — but it does not move the live number a dashboard reads.

def upsert_observation(store, payload: CompliancePayload) -> IngestOutcome:
    """Idempotent, out-of-order-safe write of one compliance observation.

    History is append-only; the fixture's *current* score only advances for a
    strictly newer capture within the same registry_revision.
    """
    key = payload.idempotency_key()
    if store.history_contains(key):
        return IngestOutcome.DUPLICATE
    if payload.registry_revision < store.active_revision(payload.store_id):
        store.quarantine(payload, IngestOutcome.STALE_REGISTRY)
        return IngestOutcome.STALE_REGISTRY

    store.append_history(key, payload)  # always preserved for backfill/trends

    current = store.current_observation(payload.fixture_id, payload.registry_revision)
    if current is None or payload.capture_timestamp > current.capture_timestamp:
        store.set_current(payload.fixture_id, payload.registry_revision, payload)
        store.refresh_ancestor_rollups(payload.fixture_id, payload.registry_revision)
        return IngestOutcome.ACCEPTED
    return IngestOutcome.OUT_OF_ORDER

Because history is append-only and rollups are materialized, backfill is a first-class operation rather than a schema migration. When a batch of offline stores reconnects and floods the ingestion API with a night’s captures, each record follows the same path — dedup, revision check, history append, conditional current-advance — and only the fixtures whose newest observation actually moved trigger a rollup refresh, so a ten-thousand-record backfill costs work proportional to the fixtures that changed, not to the flood. Backfilling history after an upstream schema change — a new field, a revision-numbering shift — is handled by replaying the append-only log through the current validator, the workflow owned by Backfilling Compliance History After a Schema Change. A circuit breaker protects the rollup service from a downstream cache outage: when the aggregate cache is unavailable, reads fall back to a bounded on-the-fly aggregation and are marked provisional rather than failing, so a cache blip degrades freshness instead of taking the dashboards dark.

Downstream Integration & Observability Jump to heading

The reporting layer exposes three surfaces, each with a stable contract. A REST score API answers point-in-time and windowed queries — a fixture’s current compliance_percentage, a store’s trend over a window, a banner’s ranked list of worst fixtures — and every response carries the registry_revision and an as_of timestamp so a consumer always knows which planogram version and which freshness it is looking at. The full endpoint design, pagination, and versioning strategy live in Designing a Compliance Score REST API. A webhook fan-out pushes threshold breaches to workforce-management systems, Slack or Teams channels, and vendor portals the instant a scope crosses a configured line. A briefing export renders the overnight picture into a human-readable digest.

Alert thresholds belong in configuration, reconciled against audits, not hard-coded — the same principle the scoring engine follows. A minimal threshold config:

alerts:
  store_compliance_floor: 85.0        # page the field team below this
  fixture_drift_z: 2.5                # sustained negative drift trigger
  out_of_stock_absolute: 8            # empty facings in a store before escalation
  price_tag_mismatch_rate: 0.03       # fraction of slots with a price fault
  dedup_window_seconds: 900           # collapse repeat breaches within window
  quiet_hours:
    - "22:00-05:00"                   # suppress non-critical pages overnight

The single hardest problem on this boundary is alert volume during a chain-wide reset, when hundreds of stores legitimately drop below the floor at once and a naive alerter pages the field team into fatigue. Deduplication windows, per-scope suppression, and reset-aware muting are what keep alerts actionable, and the full treatment is owned by Throttling Compliance Alert Storms During Chain Resets. The briefing generator closes the loop for the human who acts on all of this: it ranks the fixtures a category manager should walk first, attaches the corrective detail, and lands before the store opens — the morning reset briefing that turns a night of measurements into a prioritized to-do list.

Observability here is not optional instrumentation; it is what makes the numbers trustworthy enough to act on. Three signal families deserve dedicated monitoring: ingestion health (accept, duplicate, out-of-order, and stale-registry rates from the IngestOutcome stream — a spike in stale-registry means a store reset that the reporting layer has not caught up to), rollup freshness (the lag between an accepted payload and the refresh of its ancestor rollups), and alert precision (the fraction of fired alerts that a human confirms as real). That last signal is the reconciliation loop: alert thresholds should be tuned against ground-truth audits through the same Threshold Tuning for Compliance Accuracy discipline that calibrates the upstream scoring engine, so a page from the reporting layer means the same thing a human auditor would call a violation. When the two disagree, the gap is data — logged, aggregated, and fed back into the threshold config rather than silently tolerated.

Frequently Asked Questions Jump to heading

Why not just read compliance scores straight from the sync tier’s database? Because a raw per-capture score answers none of the questions an operator actually has. The sync tier emits one number per fixture per frame; a category manager needs a store trend, a ranked worst-fixtures list, and an alert when something breaks — all of which require aggregating thousands of events over time, weighting them by commercial importance, and detecting drift. Reading the raw table also couples every consumer to the sync tier’s internal schema and load profile, whereas a dedicated reporting layer exposes a stable contract and absorbs query load without threatening ingestion.

What exactly is the idempotency key and why those three fields? The key is (fixture_id, capture_timestamp, registry_revision). A physical observation is uniquely defined by which fixture was seen, when the camera fired, and which planogram version it was scored against. Keying on capture time rather than arrival time makes redeliveries, broker replays, and delayed uploads from offline stores all collapse to the same row, and including the registry revision keeps a pre-reset frame distinct from a post-reset frame of the same fixture at a nearby time.

How do you keep a late upload from corrupting a store’s current score? The write is append-only for history but conditional for the live number. Every accepted payload is stored in history so trends and backfill see a complete series, but a fixture’s current score only advances when the incoming capture_timestamp is strictly newer than the stored one for that revision. A frame captured at 07:42 that uploads at 09:15 is trended correctly in history but never overwrites a 08:30 observation that a dashboard is already reading.

How does the reporting layer avoid paging everyone during a chain-wide reset? Resets legitimately drop hundreds of stores below the compliance floor at once, so the alerter is reset-aware: it recognizes a registry-revision change as an expected step rather than a decay, applies per-scope deduplication windows, and honors quiet hours and suppression rules from config. The detailed strategy is covered in the alerting cluster on throttling alert storms.

Operational Payoff Jump to heading

A compliance score that no one trends, ranks, or routes is a number in a database — accurate, auditable, and operationally inert. The reporting layer is what turns that number into a decision: an idempotent ingestion boundary that survives redeliveries and reconnections, a partitioned time-series store that answers store-scoped questions in a single scan, hierarchical weighted rollups that make a headline reflect commercial reality, drift detection that flags decay before it costs a sale, and revision-aware history that lets a dashboard and a store agree on which planogram they are even discussing. By treating reporting as a first-class service with a stable contract rather than a query bolted onto the scoring engine, an organization converts a stream of per-fixture measurements into ranked briefings, precise alerts, and trend lines that category managers act on instead of arguing with. The payoff compounds across the chain: fewer false pages and less alert fatigue, faster out-of-stock recovery because the right fixture surfaces first, and a compliance signal consistent enough to hold vendors and store teams accountable across thousands of locations.

Back to top