# Trillboards Attribution Data Model Ontology

`version 1.0.0`


Machine-readable schema definitions for the 6-layer DOOH attribution data model.
These schemas map directly to PostgreSQL tables created in migrations 092-096.

## Data Flow: From Signal to Insight

```
Layer 1: Signal Ingestion
  device_signals (Intuizi mobile pings) + poi_reference (brand locations)
      |
Layer 2: Exposure Matching
  exposure_device_matches (PostGIS ST_DWithin: device near screen during ad play)
      |
Layer 3: Attention Enrichment
  attention_exposure_matches (FEIN edge AI: face count, gaze, emotion per exposure)
      |
Layer 4: Identity Resolution
  identity_nodes + identity_edges + household_clusters (cross-device graph)
      |
Layer 5: Attribution
  touchpoint_events + attribution_chains (multi-touch credit across channels)
      |
Layer 6: Measurement
  incrementality_experiments + incrementality_results (Bayesian uplift)
  creative_attribution_performance (creative x outcome correlation)
  campaign_attribution_summary (daily rollup)
```

## Data Handling

- Attribution tables key each mobile device by a hash of its MAID (`maid_hash`). The identity graph links MAIDs to the device keys sensed at screens and, through them, to the person identifiers built from face-identity templates
- FEIN inference runs on the device. The screen sends Trillboards a record for each detected person, speech transcripts, the radio identifiers of nearby devices and its GPS position, and sends selected camera frames for cloud analysis
- Attribution tables join hashed MAIDs, with each device's location at the time of the match, to screen exposures
- Partitioned tables with 90-400 day retention and automatic partition pruning; Trillboards' encrypted archive keeps the data with no scheduled deletion

## Partitioning Strategy

Three tables are range-partitioned by timestamp for performance:
- `device_signals` — partitioned by `observed_at` (monthly, 90-day hot)
- `device_visitation` — partitioned by `visited_at` (monthly, 90-day hot)
- `exposure_device_matches` — partitioned by `matched_at` (monthly, 400-day)
- `attention_exposure_matches` — partitioned by `matched_at` (monthly)
- `touchpoint_events` — partitioned by `occurred_at` (monthly)



---

## Schemas

16 schemas. 
Every one is derived from `docs/openapi/attribution-data-models.yaml` at request time.


[`AttentionCampaignMetrics`](#attentioncampaignmetrics) · [`AttentionExposureMatch`](#attentionexposurematch) · [`AttributionChain`](#attributionchain) · [`CampaignAttributionSummary`](#campaignattributionsummary) · [`CreativeAttributionPerformanceRecord`](#creativeattributionperformancerecord) · [`DataProviderIngestionLog`](#dataprovideringestionlog) · [`DeviceSignal`](#devicesignal) · [`DeviceVisitation`](#devicevisitation) · [`ExposureDeviceMatch`](#exposuredevicematch) · [`HouseholdCluster`](#householdcluster) · [`IdentityEdge`](#identityedge) · [`IdentityNode`](#identitynode) · [`IncrementalityExperiment`](#incrementalityexperiment) · [`IncrementalityResultRecord`](#incrementalityresultrecord) · [`POIReference`](#poireference) · [`TouchpointEvent`](#touchpointevent)


### AttentionCampaignMetrics


Daily rollup of attention-weighted campaign metrics.
One row per campaign + creative + date. Used for aCPM calculations
and attention timeseries visualizations.

**Table**: `attention_campaign_metrics`
**Migration**: `243_create_attention_attribution_tables.sql`



| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `metric_id` | `integer` |  |  |
| `campaign_id` | `string` |  |  |
| `creative_id` | `string`, nullable |  |  |
| `advertiser_id` | `string`, nullable |  |  |
| `metric_date` | `string` |  |  |
| `total_impressions` | `integer` |  |  |
| `unique_devices` | `integer` |  |  |
| `total_attention_seconds` | `number` |  | Sum of (attention_score * dwell_seconds * face_count) across all impressions |
| `avg_attention_score` | `number` |  |  |
| `median_attention_score` | `number` |  |  |
| `p90_attention_score` | `number` |  |  |
| `avg_dwell_seconds` | `number` |  |  |
| `avg_face_count` | `number` |  |  |
| `standard_cpm` | `number` |  | Traditional CPM (cost / impressions * 1000) |
| `attention_cpm` | `number` |  | aCPM = cost / (total_attention_seconds / 1000) |
| `cost_per_attentive_reach` | `number` |  |  |
| `cost_per_attentive_visit` | `number` |  |  |
| `avg_emotion_engagement` | `number` |  |  |
| `positive_emotion_pct` | `number` |  | Percentage of impressions with happy/surprised/interested emotions |
| `attention_qualified_pct` | `number` |  | Percentage of impressions with attention_score >= 0.5 |
| `daypart_breakdown` | `object` |  |  |
| `emotion_breakdown` | `object` |  |  |
| `venue_type_breakdown` | `object` |  |  |


### AttentionExposureMatch


Exposure match enriched with FEIN edge AI inference signals.
Joins exposure_device_matches with inference_outcomes to attach
attention, emotion, and demographic data to each exposure.

What the row MODELS, stated precisely: a device-to-play match from
`exposure_device_matches`, carrying the edge-AI inference recorded for
that play — face count, gaze, emotion classification and dwell. It is a
join, not an independent observation: the exposure leg and the attention
leg are produced by different subsystems, and a row exists only where
both fired for the same play.

Population is a property of the deployment, not of the schema. Before
building on any field here, count the table for your own environment and
check `matched_at` freshness — a schema contract cannot tell you whether
the pipeline that fills it is running.

**Table**: `attention_exposure_matches` (partitioned monthly by `matched_at`)
**Migration**: `243_create_attention_attribution_tables.sql`



| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `match_id` | `integer` |  |  |
| `play_id` | `string` |  |  |
| `maid_hash` | `string` |  |  |
| `campaign_id` | `string`, nullable |  |  |
| `creative_id` | `string`, nullable |  |  |
| `screen_mongo_id` | `string` |  |  |
| `face_count` | `integer`, nullable |  | Number of faces detected during this ad play |
| `attention_score` | `number`, nullable |  | Fraction of faces looking at screen (0.0-1.0) |
| `emotion_score` | `number`, nullable |  | Emotional engagement intensity (0.0-1.0) |
| `dominant_emotion` | `string`, nullable |  | Most common emotion detected (happy, surprised, neutral, etc.) |
| `dwell_seconds` | `number`, nullable |  | How long faces were visible in the camera FOV |
| `engagement_score` | `number`, nullable |  | Body engagement from FEIN (posture, orientation) |
| `demographics_age` | `string`, nullable |  | Age bucket estimate (e.g., '25-34'). |
| `demographics_gender` | `string`, nullable |  | Gender estimate (male/female/unknown). |
| `base_confidence` | `number` |  | Original confidence from exposure_device_matches |
| `attention_weight` | `number` |  | Attention weight factor (0-1). Derived from attention_score, face_count, and dwell_seconds. Used to compute weighted_confidence. |
| `weighted_confidence` | `number` |  | base_confidence * (1 + attention_weight). This is the DEFINITIVE confidence score for this exposure. Higher = more certain the person saw AND paid attention to the ad. |
| `venue_type` | `string`, nullable |  |  |
| `daypart` | `string`, nullable |  |  |
| `weather` | `string`, nullable |  |  |
| `ambient_lux` | `number`, nullable |  | Ambient light level at the screen location |
| `matched_at` | `string` |  |  |


### AttributionChain


Complete user journey from DOOH exposure to conversion.
Links multiple touchpoint_events into a sequence with
multi-touch credit allocation.

**Table**: `attribution_chains`
**Migration**: 096



| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `chain_id` | `string` |  |  |
| `campaign_id` | `string` |  |  |
| `advertiser_id` | `string` |  |  |
| `maid_hash` | `string`, nullable |  |  |
| `household_id` | `string`, nullable |  |  |
| `touchpoint_count` | `integer` |  | Number of touchpoints in this journey |
| `channels` | array of `string` |  | Ordered list of channels in the journey |
| `first_touch_at` | `string` |  |  |
| `last_touch_at` | `string` |  |  |
| `conversion_at` | `string`, nullable |  | When conversion occurred (store visit, purchase, etc.) |
| `conversion_type` | `string`, nullable |  |  |
| `dooh_credit` | `number` |  | Fraction of conversion credit attributed to DOOH (0-1) |
| `mobile_credit` | `number` |  |  |
| `web_credit` | `number` |  |  |
| `store_credit` | `number` |  |  |
| `attribution_model` | `string` — one of `time_decay`, `position_based`, `attention_weighted`, `shapley` |  |  |
| `conversion_value_cents` | `integer`, nullable |  |  |
| `attributed_revenue_cents` | `integer`, nullable |  |  |
| `total_confidence` | `number`, nullable |  |  |
| `is_deterministic` | `boolean` |  | Whether all identity links are deterministic (vs probabilistic) |


### CampaignAttributionSummary


Daily rollup of campaign-level attribution metrics.
This is the aggregate view consumed by the campaign summary endpoint.

**Table**: `campaign_attribution_summary`
**Migration**: 093



| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `summary_id` | `integer` |  |  |
| `campaign_id` | `string` |  |  |
| `advertiser_id` | `string` |  |  |
| `summary_date` | `string` |  |  |
| `cohort_id` | `string`, nullable |  |  |
| `unique_devices_exposed` | `integer` |  |  |
| `total_exposures` | `integer` |  |  |
| `avg_frequency` | `number` |  |  |
| `total_media_cost` | `number` |  |  |
| `cost_per_unique_reach` | `number` |  |  |
| `exposed_visitors` | `integer` |  |  |
| `control_visitors` | `integer` |  |  |
| `exposed_visit_rate` | `number` |  |  |
| `control_visit_rate` | `number` |  |  |
| `incremental_lift_pct` | `number` |  |  |
| `incremental_visits` | `integer` |  |  |
| `cost_per_incremental_visit` | `number` |  |  |
| `control_group_size` | `integer` |  |  |
| `avg_match_confidence` | `number` |  |  |
| `statistical_significance` | `number` |  |  |
| `avg_time_to_visit_h` | `number` |  |  |
| `venue_breakdown` | `object` |  |  |
| `daypart_breakdown` | `object` |  |  |
| `attribution_window_days` | `integer` |  |  |


### CreativeAttributionPerformanceRecord


Links creative execution to attribution outcomes.
One row per campaign + creative + variant + date.

Answers: "Which creative x venue x daypart x weather combination
drives the most store visits at the lowest cost?"

**Table**: `creative_attribution_performance`
**Migration**: 096



| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `perf_id` | `integer` |  |  |
| `campaign_id` | `string` |  |  |
| `creative_id` | `string` |  |  |
| `variant` | `string`, nullable |  |  |
| `metric_date` | `string` |  |  |
| `avg_attention_score` | `number` |  |  |
| `avg_dwell_seconds` | `number` |  |  |
| `avg_emotion_engagement` | `number` |  |  |
| `dominant_emotion` | `string`, nullable |  |  |
| `store_visits` | `integer` |  |  |
| `visit_rate` | `number` |  |  |
| `incremental_lift_pct` | `number` |  |  |
| `cost_per_visit` | `number` |  |  |
| `best_venue_type` | `string`, nullable |  |  |
| `best_daypart` | `string`, nullable |  |  |
| `best_weather` | `string`, nullable |  |  |
| `context_performance` | `object` |  | Detailed breakdown by context interaction |


### DataProviderIngestionLog


Audit trail for batch data ingestion runs.
Tracks file processing status, row counts, and rejection reasons.

**Table**: `data_provider_ingestion_log`
**Migration**: 092



| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `log_id` | `integer` |  |  |
| `provider` | `string` |  |  |
| `file_type` | `string` |  |  |
| `s3_bucket` | `string` |  |  |
| `s3_key` | `string` |  |  |
| `file_size_bytes` | `integer` |  |  |
| `rows_total` | `integer` |  |  |
| `rows_inserted` | `integer` |  |  |
| `rows_rejected` | `integer` |  |  |
| `rejection_reasons` | `object` |  |  |
| `status` | `string` — one of `pending`, `processing`, `completed`, `failed` |  |  |
| `error_message` | `string`, nullable |  |  |
| `started_at` | `string`, nullable |  |  |
| `completed_at` | `string`, nullable |  |  |


### DeviceSignal


Mobile device location observation from Intuizi.
Each row = one device location ping (MAID + lat/lng + timestamp).
MAIDs are stored as SHA-256 hashes (`maid_hash`).

**Table**: `device_signals` (partitioned monthly by `observed_at`)
**Migration**: 092



| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `signal_id` | `integer` |  | Auto-incrementing primary key |
| `maid_hash` | `string` |  | SHA-256 hash of the mobile advertising ID (IDFA/GAID) |
| `location` | `string` |  | PostGIS GEOGRAPHY(POINT, 4326) — used for ST_DWithin spatial joins |
| `latitude` | `number` |  |  |
| `longitude` | `number` |  |  |
| `accuracy_m` | `number` |  | GPS accuracy in meters (lower = more precise) |
| `geohash_6` | `string` |  | Geohash-6 cell for fast spatial pruning before PostGIS join |
| `observed_at` | `string` |  | When the device was observed at this location (partition key) |
| `provider` | `string` |  | Data provider name |
| `signal_type` | `string` — one of `location`, `visitation`, `beacon` |  |  |
| `device_os` | `string` — one of `ios`, `android`, `unknown` |  |  |
| `dma_code` | `string`, nullable |  | Nielsen DMA code for geo-holdout experiments |
| `ingested_at` | `string` |  |  |
| `batch_id` | `string`, nullable |  | Ingestion batch identifier for audit trail |


### DeviceVisitation


Observed visit to a POI by a mobile device (from Intuizi visitation data).

**This table holds TWO different attribution legs and `visit_role` is the
discriminator. Always filter by it.** `conversion_poi` rows are the
*outcome* leg — the device visited the advertiser's location.
`screen_exposure` rows are the *exposure* leg — the device was near one
of our screens. They answer opposite halves of the question, and a query
that omits the predicate silently mixes them.

The column is `NOT NULL DEFAULT 'conversion_poi'` with a CHECK constraint
(migration `20260618061000_add_device_visitation_visit_role.sql`), and the
table carries a dedicated partial index per role, which is the clearest
statement that both are expected to be queried separately.

`visit_role` was absent from this schema entirely until 2026-09-01, so the
document described the whole table as the outcome leg.

**Table**: `device_visitation` (partitioned monthly by `visited_at`)
**Migration**: 092 (`visit_role` added in
`20260618061000_add_device_visitation_visit_role.sql`)



| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `visit_role` | `string` — one of `conversion_poi`, `screen_exposure` |  | Which attribution leg this row belongs to. `NOT NULL`, defaulting to `conversion_poi`. Filter on it in every query — see the schema description above. |
| `visitation_id` | `integer` |  |  |
| `maid_hash` | `string` |  |  |
| `poi_id` | `string` |  | References poi_reference.poi_id |
| `external_poi_id` | `string` |  |  |
| `distance_m` | `number` |  | Distance from device to POI centroid in meters |
| `dwell_seconds` | `integer` |  | How long the device stayed at the POI |
| `visited_at` | `string` |  | When the visit occurred (partition key) |
| `provider` | `string` |  |  |
| `confidence` | `number` |  | Visit confidence score (0-1) |
| `ingested_at` | `string` |  |  |
| `batch_id` | `string`, nullable |  |  |


### ExposureDeviceMatch


Core truth table: device X was near screen Y during ad play Z.
Created by the exposure matching service using PostGIS ST_DWithin
to join device_signals with play_logs based on spatial and temporal proximity.

**Table**: `exposure_device_matches` (partitioned monthly by `matched_at`)
**Migration**: 093



| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `match_id` | `integer` |  |  |
| `play_id` | `string` |  | References play_logs — which ad was playing |
| `maid_hash` | `string` |  | SHA-256 hashed MAID of the device |
| `screen_mongo_id` | `string` |  | Screen identifier |
| `campaign_id` | `string`, nullable |  |  |
| `creative_id` | `string`, nullable |  |  |
| `advertiser_id` | `string`, nullable |  |  |
| `distance_m` | `number` |  | Distance between device and screen in meters |
| `time_delta_s` | `integer` |  | Seconds between device observation and ad play |
| `device_accuracy_m` | `number`, nullable |  |  |
| `screen_latitude` | `number` |  |  |
| `screen_longitude` | `number` |  |  |
| `device_latitude` | `number` |  |  |
| `device_longitude` | `number` |  |  |
| `confidence_score` | `number` |  | Match confidence (0-1). Based on inverse distance and time delta. Closer + more simultaneous = higher confidence. |
| `match_method` | `string` |  | Matching algorithm used |
| `venue_type` | `string`, nullable |  |  |
| `screen_type` | `string`, nullable |  |  |
| `matched_at` | `string` |  | Timestamp of the match (partition key) |


### HouseholdCluster


Pre-computed household grouping from co-location analysis.
Multiple MAIDs that consistently appear at the same location
(same geohash, same time windows) are clustered into households.

**Table**: `household_clusters`
**Migration**: `243_create_attention_attribution_tables.sql`



| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `cluster_id` | `string` |  |  |
| `household_node_id` | `string` |  | References identity_nodes (node_type = 'household') |
| `maid_hash` | `string` |  |  |
| `cluster_confidence` | `number` |  | Confidence that this MAID belongs to this household (0-1) |
| `colocation_count` | `integer` |  | Number of times this MAID was co-located with household members |
| `colocation_days` | `integer` |  | Number of distinct days with co-location observations |
| `device_os` | `string`, nullable |  |  |
| `dma_code` | `string`, nullable |  |  |
| `first_colocated` | `string`, nullable |  |  |
| `last_colocated` | `string`, nullable |  |  |


### IdentityEdge


Relationship between two identity nodes in the graph.

**Table**: `identity_edges`
**Migration**: `243_create_attention_attribution_tables.sql`



| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `edge_id` | `string` |  |  |
| `source_node_id` | `string` |  |  |
| `target_node_id` | `string` |  |  |
| `edge_type` | `string` — one of `household_member`, `fein_observation`, `cross_channel_link`, `ip_colocation`, `device_graph_partner`, `wifi_colocation`, `device_signal`, `pixel_ip_bridge`, … (18 total) |  | Relationship type. This list is the FULL `identity_edges_edge_type_check` constraint as of migration `20260621000800_add_maid_bridge_edge_type.sql`. It previously declared five values; thirteen were missing, and the omitted set includes the edge types that carry the overwhelming majority of the table. - household_member: Devices in the same household (co-location evidence) - fein_observation: Devices seen together by FEIN cameras - cross_channel_link: MAID matched to pixel visitor or transaction - ip_colocation: Devices sharing the same IP address - device_graph_partner: Third-party device graph match - wifi_colocation: Devices seen on the same Wi-Fi BSSID - device_signal: Link asserted by a device-reported signal - pixel_ip_bridge: Pixel visitor joined to a device by IP - temporal_fingerprint: Devices linked by co-occurrence in time - email_hash_link: Link through a hashed email address - uid2_token: Link through a UID2 token - qr_scan: Link established by a QR scan - ble_proximity: Bluetooth-LE proximity between devices - network_device: Devices on the same network - pixel_match: Direct pixel-to-identity match - csi_proximity: Proximity from the Wi-Fi CSI radio path - maid_bridge: PROBABILISTIC bridge between co-present MAIDs - face_device: Face cluster linked to a device `confidence` is not comparable across edge types. `maid_bridge` in particular is derived as 1/N over co-present MAIDs and capped at 0.5, so a 0.5 there is the CEILING of a probabilistic inference, not the same quantity as a 0.5 on a deterministic edge. |
| `confidence` | `number` |  | Edge confidence (0-1). Higher = stronger evidence of relationship. |
| `evidence_count` | `integer` |  | Number of independent observations supporting this edge |
| `evidence_type` | `string`, nullable |  |  |
| `first_linked_at` | `string` |  |  |
| `last_linked_at` | `string` |  |  |
| `metadata` | `object` |  |  |


### IdentityNode


Vertex in the identity graph. Each node represents a device, household,
FEIN cluster, pixel visitor, or other identity type.

**Table**: `identity_nodes`
**Migration**: `243_create_attention_attribution_tables.sql`



| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `node_id` | `string` |  |  |
| `node_type` | `string` — one of `maid`, `household`, `fein_cluster`, `ip_hash`, `wifi_bssid`, `fingerprint`, `pixel_id`, `transaction_id`, … (15 total) |  | Type of identity. This list is the FULL `identity_nodes_node_type_check` constraint as of migration `20260727191655_canonical_device_extended_identities.sql`. It previously declared six values; nine were missing, including the three that carry most of the table. - maid: Mobile advertising ID (hashed) - household: Inferred household from co-location analysis - fein_cluster: Group of devices observed together by FEIN cameras - ip_hash: Hashed IP for coarse identity linking - wifi_bssid: Hashed Wi-Fi BSSID observed near a screen - fingerprint: Browser/device fingerprint - pixel_id: Web conversion pixel visitor - transaction_id: Purchase event identifier - email_hash: Hashed email address - qr_fingerprint: Fingerprint derived from a QR scan - csi_device: Device sensed by the Wi-Fi CSI radio path - screen: A Trillboards screen, as a node in the graph - canonical_visitor: The resolved visitor a cluster collapses to - face_cluster: Camera-derived face cluster - uid2_token: UID2 token `identifier_hash` is an HMAC-SHA256 keyed by `pepper_kid`, not a bare SHA-256 — you cannot reproduce it from the raw identifier alone. |
| `identifier_hash` | `string` |  | SHA-256 hash of the actual identifier |
| `device_os` | `string` — one of `ios`, `android`, `unknown` |  |  |
| `dma_code` | `string`, nullable |  |  |
| `geohash_6` | `string`, nullable |  |  |
| `first_seen_at` | `string` |  |  |
| `last_seen_at` | `string` |  |  |
| `signal_count` | `integer` |  | Number of times this identity has been observed |
| `metadata` | `object` |  |  |


### IncrementalityExperiment


Experiment definition for causal incrementality measurement.
Supports geo-holdout, ghost ads, PSA control, and matched market designs.

**Table**: `incrementality_experiments`
**Migration**: 096



| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `experiment_id` | `string` |  |  |
| `campaign_id` | `string` |  |  |
| `advertiser_id` | `string` |  |  |
| `experiment_type` | `string` — one of `geo_holdout`, `ghost_ad`, `psa_control`, `matched_market` |  |  |
| `status` | `string` — one of `draft`, `active`, `measuring`, `completed`, `cancelled` |  |  |
| `treatment_dmas` | array of `string` |  | DMA codes where ads are shown |
| `control_dmas` | array of `string` |  | DMA codes where ads are withheld |
| `holdout_pct` | `number` |  |  |
| `required_sample_size` | `integer`, nullable |  |  |
| `target_mde` | `number` |  | Minimum detectable effect (10% = can detect a 10% lift) |
| `target_power` | `number` |  |  |
| `target_alpha` | `number` |  |  |
| `matching_covariates` | `object` |  | Covariates used for propensity score matching |
| `caliper_width` | `number` |  |  |
| `started_at` | `string`, nullable |  |  |
| `ended_at` | `string`, nullable |  |  |


### IncrementalityResultRecord


Daily measurement result for an incrementality experiment.
Contains both Bayesian and frequentist statistical results.

**Table**: `incrementality_results`
**Migration**: 096



| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `result_id` | `integer` |  |  |
| `experiment_id` | `string` |  |  |
| `measurement_date` | `string` |  |  |
| `treatment_devices` | `integer` |  |  |
| `treatment_visitors` | `integer` |  |  |
| `treatment_visit_rate` | `number` |  |  |
| `control_devices` | `integer` |  |  |
| `control_visitors` | `integer` |  |  |
| `control_visit_rate` | `number` |  |  |
| `absolute_lift` | `number` |  |  |
| `relative_lift_pct` | `number` |  |  |
| `incremental_visits` | `integer` |  |  |
| `chi_square` | `number` |  |  |
| `p_value` | `number` |  |  |
| `confidence_interval_lower` | `number` |  |  |
| `confidence_interval_upper` | `number` |  |  |
| `posterior_prob_positive` | `number` |  | Bayesian P(lift > 0) |
| `expected_uplift` | `number` |  |  |
| `credible_interval_lower` | `number` |  |  |
| `credible_interval_upper` | `number` |  |  |
| `avg_propensity_treatment` | `number` |  |  |
| `avg_propensity_control` | `number` |  |  |
| `standardized_mean_diff` | `number` |  | Should be < 0.1 for good covariate balance |


### POIReference


Advertiser/brand point-of-interest location from Intuizi.
Used as the "destination" in footfall attribution — did an exposed device
later visit one of these POIs?

The catalogue is scoped to the brands under active measurement, not to
Intuizi's whole POI universe. A volume figure is deliberately not quoted
here: this document is a schema contract, and the previous "~500K
locations" parenthetical overstated the live table by more than an order
of magnitude. Count `poi_reference` if you need the current size.

**Table**: `poi_reference`
**Migration**: 092



| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `poi_id` | `string` |  |  |
| `external_id` | `string` |  | Provider's unique identifier |
| `name` | `string` |  | Location name (e.g., "Starbucks - 42nd & Broadway") |
| `brand` | `string`, nullable |  | Brand name (e.g., "Starbucks") |
| `category` | `string` |  | Business category (e.g., "coffee_shop", "gym", "retail") |
| `subcategory` | `string`, nullable |  |  |
| `address` | `string`, nullable |  |  |
| `city` | `string`, nullable |  |  |
| `state` | `string`, nullable |  |  |
| `zip` | `string`, nullable |  |  |
| `dma_code` | `string`, nullable |  |  |
| `location` | `string` |  | PostGIS GEOGRAPHY(POINT, 4326) |
| `latitude` | `number` |  |  |
| `longitude` | `number` |  |  |
| `geohash_6` | `string` |  |  |
| `provider` | `string` |  |  |
| `status` | `string` |  |  |
| `metadata` | `object` |  | Flexible JSON metadata |


### TouchpointEvent


Normalized cross-channel event from any attribution source.
All channels (DOOH, mobile, web, app, store, purchase) are
represented in a single table for unified multi-touch attribution.

**Table**: `touchpoint_events` (partitioned monthly by `occurred_at`)
**Migration**: 096



| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `touchpoint_id` | `string` |  |  |
| `maid_hash` | `string`, nullable |  |  |
| `household_id` | `string`, nullable |  |  |
| `pixel_visitor_id` | `string`, nullable |  | SHA-256(IP+UA) for web pixel visitors |
| `channel` | `string` — one of `dooh`, `mobile_ad`, `web_visit`, `app_install`, `store_visit`, `purchase` |  |  |
| `event_type` | `string` |  | Event subtype (impression, click, pixel_fire, transaction, etc.) |
| `play_id` | `string`, nullable |  | DOOH-specific — which ad play triggered this event |
| `screen_mongo_id` | `string`, nullable |  |  |
| `campaign_id` | `string`, nullable |  |  |
| `creative_id` | `string`, nullable |  |  |
| `advertiser_id` | `string`, nullable |  |  |
| `confidence_score` | `number`, nullable |  |  |
| `dsp_partner` | `string`, nullable |  | DSP name for mobile/digital events |
| `placement_id` | `string`, nullable |  |  |
| `click_url` | `string`, nullable |  |  |
| `poi_id` | `string`, nullable |  |  |
| `transaction_amount_cents` | `integer`, nullable |  |  |
| `basket_items` | array of `object` |  |  |
| `latitude` | `number`, nullable |  |  |
| `longitude` | `number`, nullable |  |  |
| `dma_code` | `string`, nullable |  |  |
| `occurred_at` | `string` |  | When the event happened (partition key) |
| `attributed` | `boolean` |  | Whether this event has been assigned to an attribution chain |
| `attribution_chain_id` | `string`, nullable |  |  |
