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 byobserved_at(monthly, 90-day hot)device_visitation— partitioned byvisited_at(monthly, 90-day hot)exposure_device_matches— partitioned bymatched_at(monthly, 400-day)attention_exposure_matches— partitioned bymatched_at(monthly)touchpoint_events— partitioned byoccurred_at(monthly)
Schemas
16 schemas. Every one is derived from docs/openapi/attribution-data-models.yaml at request time.
AttentionCampaignMetrics · AttentionExposureMatch · AttributionChain · CampaignAttributionSummary · CreativeAttributionPerformanceRecord · DataProviderIngestionLog · DeviceSignal · DeviceVisitation · ExposureDeviceMatch · HouseholdCluster · IdentityEdge · IdentityNode · IncrementalityExperiment · IncrementalityResultRecord · POIReference · 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 |