Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,9 @@ detectors cover the realistic failure modes; the trigger for adopting one is mul
into Gold, where you can no longer reason about every writer.

**On what the schedules actually process.** Scheduled runs read an explicit
`manifests/published/...` build-success marker, not today's date and not `MAX(dt)`. Presence of a
`manifests/published/...` build-success marker, not today's date and not `MAX(dt)`. Both
`gold_daily_kpi` and `gold_hourly_kpi` receive table-specific markers after the complete lake build.
Presence of a
row proves only that writing started; it does not prove the partition closed. Both lake builders
invalidate the old marker before replacing data and publish a new marker only after their
verification queries succeed.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ stack and cannot be created by the default `cdk deploy --all`.

### KPI / retention anomaly path

1. **[auto] Detect** — EventBridge runs daily DAU/GGR and weekly mature-cohort
retention checks against published Gold data.
1. **[auto] Detect** — EventBridge runs hourly site-usage checks from prepared
same-hour baselines and weekly mature-cohort retention checks against published Gold data.
2. **[auto] First look** — Module 1 publishes an SNS alert; Module 3 builds
baseline, per-game, or retention evidence before an analyst starts.
3. **[human] Investigate** — an analyst checks the evidence, business context,
Expand Down Expand Up @@ -169,7 +169,7 @@ from authenticated identity rather than a request body.

### Verification status

- **145 offline tests** plus Python compilation and CDK assertion coverage.
- **146 offline tests** plus Python compilation and CDK assertion coverage.
- **Eight default stacks** synthesize without Kinesis, NAT Gateway, RDS,
OpenSearch, or provisioned compute.
- The earlier baseline was deployed and exercised against AWS on 2026-07-29.
Expand Down
35 changes: 32 additions & 3 deletions data-foundation/KPI_DEFINITIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ registry) solves at the player-feature layer exists at the business-metric layer
team computes "DAU" or "retention" with a slightly different query, two dashboards showing
different numbers for the same day is not a data problem, it's a definitions problem. This file is
the one place a standard metric's calculation logic is defined. **Gold-layer tables
(`gold_daily_kpi`, `gold_cohort_retention`) and anything built on top of them — dashboards, the
(`gold_daily_kpi`, `gold_hourly_kpi`, `gold_hourly_monitoring_features`, `gold_cohort_retention`) and anything built on top of them — dashboards, the
Module 3 semantic layer's SQL templates, ad hoc analysis — must compute these metrics exactly as
defined here, not re-derive them.**

Expand All @@ -20,8 +20,8 @@ Same governance philosophy, different grain and audience:

| | This file (`KPI_DEFINITIONS.md`) | `FEATURES.md` |
|---|---|---|
| Grain | `date x client_site_id` (business reporting) | `snapshot_date x player_id` (analysis/ML) |
| Table | `gold_daily_kpi`, `gold_cohort_retention` | `gold_player_features` |
| Grain | daily site, hourly site/product/actor, and retention cohort | `snapshot_date x player_id` (analysis/ML) |
| Table | `gold_daily_kpi`, `gold_hourly_kpi`, `gold_hourly_monitoring_features`, `gold_cohort_retention` | `gold_player_features` |
| Consumers | Dashboards, execs, Module 3's semantic layer | Module 1 (arbitrage), Module 2 (experiment analysis) |

Both are built from the same `silver_events` — a metric defined here and a feature defined there
Expand Down Expand Up @@ -94,6 +94,33 @@ later (D7).
- Retention is defined relative to `client_site_id`, not `game_id` — a player who registers via
one game and returns to play a different one on the same site still counts as retained.

## Hourly operational KPIs

**Definition:** metrics use the same event filters and FX-normalized Silver columns as their daily
counterparts, grouped by `event_hour x client_site_id x game_id x player_id` and partitioned by
`dt`.

**Computed in:** `gold_hourly_kpi` (`data-foundation/lake/ddl/05_gold_hourly_kpi.sql`).

**Why the actor dimension remains:** a live experiment guardrail must join only actors with a
recorded treatment exposure. Aggregating away `player_id` before that join would mix treatment,
control, and non-exposed traffic and make the hourly safety check untrustworthy.

**Caveats:** the current PoC refreshes this table during the batch lake build. A production path
would incrementally publish completed hourly partitions and advance the publication marker only
after late-arrival handling and quality checks finish.

### Hourly monitoring features

**Definition:** `active_users`, `sessions`, and `processed_events` are aggregated at
`event_hour x client_site_id`. For every hour-of-day and site, the baseline and normal range use
up to seven earlier observations at that same hour. This avoids comparing a partial hour with a
complete day or calculating history inside the alerting Lambda.

**Computed in:** `gold_hourly_monitoring_features`
(`data-foundation/lake/ddl/06_gold_hourly_monitoring_features.sql`). Module 1 reads the latest
published row and compares the prepared actual value with its prepared bounds.

---

## Adding or changing a metric
Expand All @@ -106,4 +133,6 @@ later (D7).

### Changelog

- **v1.2** (2026-08-03): added site-level hourly monitoring features and same-hour baselines.
- **v1.1** (2026-08-02): added exposure-joinable hourly operational KPIs.
- **v1** (2026-07-27): initial definitions — GGR, DAU, ARPU, D1/D7 retention.
12 changes: 9 additions & 3 deletions data-foundation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,18 @@ event_simulator/ --(local JSONL)--> S3 bronze/ --(Athena CTAS)--> S3 silver/
3. **Silver** (`s3://<bucket>/silver/events/`): same grain as Bronze, but Parquet/Snappy, with
`event_ts` parsed to a real timestamp and FX converted to USD **once** (`bet_amount_usd`,
`win_amount_usd`, `amount_usd`) so every downstream query reuses it instead of re-deriving it.
4. **Gold**: small, purpose-built aggregate tables (not partitioned — a few hundred rows each,
so partitioning would only add overhead). Column-by-column definitions live in
`KPI_DEFINITIONS.md`, not repeated here:
4. **Gold**: purpose-built aggregate tables. The small daily and cohort summaries are not
partitioned; the larger hourly actor-level table is partitioned by `dt` so scheduled checks
can prune old data. Column-by-column definitions live in `KPI_DEFINITIONS.md`, not repeated here:
- `gold_daily_kpi` — dt x client_site_id grain: DAU, sessions, new players, GGR, deposits,
withdrawals, ARPU (all USD). Also the table `governance/`'s client-isolation demo filters.
- `gold_cohort_retention` — registration_date x client_site_id grain: D1/D7 retention.
- `gold_hourly_kpi` — event_hour x client_site_id x game_id x player_id grain: hourly sessions
and normalized value metrics used by exposure-aware live experiment guardrails.
- `gold_hourly_monitoring_features` — event_hour x client_site_id grain: hourly active users,
sessions and processed events with a trailing same-hour baseline and normal range already
computed for Module 1. The detector reads one prepared row instead of rebuilding history at
alert time.

## Running it

Expand Down
52 changes: 45 additions & 7 deletions data-foundation/lake/build_lake.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
1. Uploads the local simulator output (data-foundation/event_simulator/output)
to S3 as gzip-compressed Bronze JSONL.
2. Runs the DDL in data-foundation/lake/ddl/ in order (Bronze external table,
Silver Parquet CTAS, two Gold Parquet CTAS).
Silver Parquet CTAS, and purpose-built Gold Parquet CTAS tables).
3. Runs the example queries in data-foundation/lake/queries/ and prints the
results, as an end-to-end sanity check.

Expand All @@ -30,6 +30,10 @@
DDL_DIR = LAKE_DIR / "ddl"
QUERIES_DIR = LAKE_DIR / "queries"
DAILY_KPI_PUBLICATION_MANIFEST = "manifests/published/gold_daily_kpi.json"
HOURLY_KPI_PUBLICATION_MANIFEST = "manifests/published/gold_hourly_kpi.json"
HOURLY_MONITORING_PUBLICATION_MANIFEST = (
"manifests/published/gold_hourly_monitoring_features.json"
)
GOVERNANCE_SETUP = (
LAKE_DIR.parent / "governance" / "setup_client_isolation.py"
)
Expand Down Expand Up @@ -113,27 +117,33 @@ def print_query_results(query_id: str, max_rows: int = 20):
print(" " + " | ".join(values))


def publish_completion_manifest(bucket: str, min_date: str, max_date: str):
def publish_completion_manifest(
bucket: str,
key: str,
table: str,
min_date: str,
max_date: str,
):
"""Publish only after every transform and verification query succeeds.

Consumers use this marker instead of guessing completeness from MAX(dt).
A partition existing in Athena says nothing about whether its producer has
finished writing it.
"""
body = {
"table": "gold_daily_kpi",
"table": table,
"published_from": min_date,
"published_through": max_date,
"published_at": datetime.now(timezone.utc).isoformat(),
"source": "fixed_simulator_build",
}
s3.put_object(
Bucket=bucket,
Key=DAILY_KPI_PUBLICATION_MANIFEST,
Key=key,
Body=json.dumps(body, sort_keys=True).encode("utf-8"),
ContentType="application/json",
)
print(f"Published completion marker through {max_date}.")
print(f"Published {table} completion marker through {max_date}.")


def apply_client_isolation():
Expand Down Expand Up @@ -169,8 +179,12 @@ def main():
# failed rebuild must be visibly incomplete, never paired with a stale
# marker that tells scheduled consumers it is safe to query.
s3.delete_object(Bucket=bucket, Key=DAILY_KPI_PUBLICATION_MANIFEST)
s3.delete_object(Bucket=bucket, Key=HOURLY_KPI_PUBLICATION_MANIFEST)
s3.delete_object(Bucket=bucket, Key=HOURLY_MONITORING_PUBLICATION_MANIFEST)
clear_prefix(bucket, "silver/events/")
clear_prefix(bucket, "gold/daily_kpi/")
clear_prefix(bucket, "gold/hourly_kpi/")
clear_prefix(bucket, "gold/hourly_monitoring_features/")
clear_prefix(bucket, "gold/cohort_retention/")
clear_prefix(bucket, "athena-results/tables/") # staging debris from any previously failed CTAS

Expand All @@ -189,7 +203,11 @@ def main():
label = code_lines[0][:60] if code_lines else statement[:60]
print(f" {ddl_file.name}: {label} ...")
run_query(statement, database, workgroup)
print("DDL complete: bronze_events, silver_events, gold_daily_kpi, gold_cohort_retention.")
print(
"DDL complete: bronze_events, silver_events, gold_daily_kpi, "
"gold_cohort_retention, gold_hourly_kpi, "
"gold_hourly_monitoring_features."
)

print("\nRunning example queries ...")
for query_file in sorted(QUERIES_DIR.glob("*.sql")):
Expand All @@ -200,7 +218,27 @@ def main():

print("\nReapplying Lake Formation client isolation ...")
apply_client_isolation()
publish_completion_manifest(bucket, min_date, max_date)
publish_completion_manifest(
bucket,
DAILY_KPI_PUBLICATION_MANIFEST,
"gold_daily_kpi",
min_date,
max_date,
)
publish_completion_manifest(
bucket,
HOURLY_KPI_PUBLICATION_MANIFEST,
"gold_hourly_kpi",
min_date,
max_date,
)
publish_completion_manifest(
bucket,
HOURLY_MONITORING_PUBLICATION_MANIFEST,
"gold_hourly_monitoring_features",
min_date,
max_date,
)


if __name__ == "__main__":
Expand Down
42 changes: 42 additions & 0 deletions data-foundation/lake/ddl/05_gold_hourly_kpi.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
-- Gold: hourly operational KPIs at event_hour x client_site_id x game_id x
-- player_id grain. Keeping the actor dimension is intentional: Module 2 can
-- join an experiment's recorded exposure cohort before aggregating a live
-- guardrail, instead of treating every eligible actor as exposed.
--
-- The table is partitioned by dt because it is materially larger than the
-- daily site summary and live checks always constrain the current date.
DROP TABLE IF EXISTS gold_hourly_kpi;

CREATE TABLE gold_hourly_kpi
WITH (
format = 'PARQUET',
parquet_compression = 'SNAPPY',
partitioned_by = ARRAY['dt'],
external_location = 's3://$bucket/gold/hourly_kpi/'
) AS
SELECT
date_trunc('hour', event_ts) AS event_hour,
client_site_id,
game_id,
player_id,
COUNT(*) AS processed_events,
COUNT_IF(event_type = 'session_start') AS sessions,
COUNT_IF(event_type = 'bet_settled') AS completed_interactions,
ROUND(SUM(CASE WHEN event_type = 'bet_settled' THEN COALESCE(bet_amount_usd, 0.0) ELSE 0.0 END), 2)
AS gross_bets_usd,
ROUND(SUM(CASE WHEN event_type = 'bet_settled' THEN COALESCE(win_amount_usd, 0.0) ELSE 0.0 END), 2)
AS gross_wins_usd,
ROUND(SUM(CASE WHEN event_type = 'bet_settled' THEN COALESCE(bet_amount_usd, 0.0) - COALESCE(win_amount_usd, 0.0) ELSE 0.0 END), 2)
AS ggr_usd,
ROUND(SUM(CASE WHEN event_type = 'deposit' AND payload.status = 'completed' THEN COALESCE(amount_usd, 0.0) ELSE 0.0 END), 2)
AS deposits_usd,
ROUND(SUM(CASE WHEN event_type = 'withdrawal' AND payload.status = 'completed' THEN COALESCE(amount_usd, 0.0) ELSE 0.0 END), 2)
AS withdrawals_usd,
dt
FROM silver_events
GROUP BY
date_trunc('hour', event_ts),
client_site_id,
game_id,
player_id,
dt;
63 changes: 63 additions & 0 deletions data-foundation/lake/ddl/06_gold_hourly_monitoring_features.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
-- Gold monitoring features: one row per site and hour with the actual values
-- and same-hour historical control limits already computed. The detector reads
-- this table; it does not repeatedly rebuild baselines inside Lambda.
DROP TABLE IF EXISTS gold_hourly_monitoring_features;

CREATE TABLE gold_hourly_monitoring_features
WITH (
format = 'PARQUET',
parquet_compression = 'SNAPPY',
partitioned_by = ARRAY['dt'],
external_location = 's3://$bucket/gold/hourly_monitoring_features/'
) AS
WITH hourly AS (
SELECT
event_hour,
client_site_id,
COUNT(DISTINCT player_id) AS active_users,
SUM(sessions) AS sessions,
SUM(processed_events) AS processed_events,
CAST(date(event_hour) AS VARCHAR) AS dt
FROM gold_hourly_kpi
GROUP BY event_hour, client_site_id
), baselines AS (
SELECT
*,
COUNT(*) OVER same_hour_history AS baseline_points,
AVG(active_users) OVER same_hour_history AS active_users_baseline,
STDDEV_POP(active_users) OVER same_hour_history AS active_users_sigma,
AVG(sessions) OVER same_hour_history AS sessions_baseline,
STDDEV_POP(sessions) OVER same_hour_history AS sessions_sigma,
AVG(processed_events) OVER same_hour_history AS processed_events_baseline,
STDDEV_POP(processed_events) OVER same_hour_history AS processed_events_sigma
FROM hourly
WINDOW same_hour_history AS (
PARTITION BY client_site_id, hour(event_hour)
ORDER BY event_hour
ROWS BETWEEN 7 PRECEDING AND 1 PRECEDING
)
)
SELECT
event_hour,
client_site_id,
active_users,
sessions,
processed_events,
baseline_points,
ROUND(active_users_baseline, 4) AS active_users_baseline,
ROUND(GREATEST(0, active_users_baseline - 3 * active_users_sigma), 4)
AS active_users_lower_bound,
ROUND(active_users_baseline + 3 * active_users_sigma, 4)
AS active_users_upper_bound,
ROUND(sessions_baseline, 4) AS sessions_baseline,
ROUND(GREATEST(0, sessions_baseline - 3 * sessions_sigma), 4)
AS sessions_lower_bound,
ROUND(sessions_baseline + 3 * sessions_sigma, 4)
AS sessions_upper_bound,
ROUND(processed_events_baseline, 4) AS processed_events_baseline,
ROUND(GREATEST(0, processed_events_baseline - 3 * processed_events_sigma), 4)
AS processed_events_lower_bound,
ROUND(processed_events_baseline + 3 * processed_events_sigma, 4)
AS processed_events_upper_bound,
dt
FROM baselines;
16 changes: 13 additions & 3 deletions demo_console/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
# Live AWS operation demo console

This is the recording surface for the two-minute interface walkthrough. It is
not a mock: every result is read from or executed against the deployed AWS
stacks. The localhost server keeps SigV4 credentials out of the browser.
This is the presentation surface for the four-module interface walkthrough.
Each module opens on an architecture flow and then switches to its operation
view. M1 uses a deterministic synthetic hourly snapshot to demonstrate the
production design without leaving a schedule running. M4 provides two explicit
demo paths: an in-scope integration question that receives a governed answer,
and an out-of-scope question that is refused before model invocation. The other
operation views can read from or execute against the deployed AWS stacks. The
localhost server keeps SigV4 credentials out of the browser.

On Windows, double-click `START_DEMO.cmd` in the repository root. It starts the
local signing server and opens the console automatically.

The equivalent command is:

```powershell
.\infra\.venv\Scripts\python.exe .\demo_console\server.py
Expand Down
Loading