From 4487c130c870830e7785db45d80f6141edba0e72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesper=20Br=C3=A4nn?= Date: Tue, 25 Nov 2025 13:10:52 +0100 Subject: [PATCH] WIP: Event storage --- docs.json | 3 +- .../design-documents/event-storage.mdx | 1031 +++++++++++++++++ .../event-storage/rtabench-comparison.png | Bin 0 -> 86385 bytes 3 files changed, 1033 insertions(+), 1 deletion(-) create mode 100644 engineering/design-documents/event-storage.mdx create mode 100644 engineering/design-documents/images/event-storage/rtabench-comparison.png diff --git a/docs.json b/docs.json index def34b2..3550ce4 100644 --- a/docs.json +++ b/docs.json @@ -53,7 +53,8 @@ "engineering/design-documents/subscription-retries", "engineering/design-documents/wallets", "engineering/design-documents/multi-currency", - "engineering/design-documents/business-entity" + "engineering/design-documents/business-entity", + "engineering/design-documents/event-storage" ] }, "engineering/tech-notes", diff --git a/engineering/design-documents/event-storage.mdx b/engineering/design-documents/event-storage.mdx new file mode 100644 index 0000000..78f36fc --- /dev/null +++ b/engineering/design-documents/event-storage.mdx @@ -0,0 +1,1031 @@ +import { OpenQuestion } from "/snippets/open-question.jsx"; + + +**Status**: Active +**Created**: November 2025 +**Last Updated**: November 2025 + + +## Summary + +To position Polar for ingesting an increased number of events (100-1000x current number of events), and to minimize the number of data migrations that we will need to do the ambition of this document is to outline three alternatives for storing event data based on the current and imagined future query patterns. + +## Goals + +* List and filter events speedily (\<200 ms) independent on number of events associated with organization. + * Filter should be on arbitrary hierarchies of events. + * Aggregate arbitrary fields in event hierarchies, such as duration or cost. +* Generate metrics from event lists + * Window events over time + * Group events by arbitrary properties of events (name, description) + +The workload we have fullfils the following aspects: +* Events are immutable +* Events can be ingested at any point in time (not only realtime) +* We want to be able add more metrics and query patterns in the future +* We want our users to be able to ingest a lot of metadata that they can query in the future + +## Current state + +### Events + +Events are stored in an `events` table. The relationship / hierarchy between events are stored via the self referencing `parent_id` field, which refers to the ID of a different event in the same table. To allow for speedier lookups of events in an hierarchy there is a separate `events_closure` table which stores the relationship between two events (`ancestor` and `descendant` as well as the `depth` of which that relationship is.). + +The `event_types` table exists to allow us to group events that are of the same type and to assign a customizable label to them. If you ingest two events that are called "Support Request", they are semantically identical even though they are separate events that have happened at different points in time (potentially to different customers). + + +```mermaid +erDiagram + Event }|--|{ EventClosure : "contains" + Event }|--|| EventType : "has" + + Event { + uuid id PK + uuid root_id + uuid event_type_id + string parent_id + string name + string external_id + uuid customer_id + jsonb metadata + } + + + EventClosure { + uuid ancestor_id FK + uuid descendant_id FK + int depth + } + + EventType { + uuid id PK + string label + } + +``` + +#### Event Closure + +```mermaid +flowchart + root["id = 1"] + child_1["id = 2"] + child_2["id = 3"] + grandchild_1_1["id = 4"] + grandchild_1_2["id = 5"] + grandchild_2_1["id = 6"] + + + root --ancestor_id=1,
descendant_id=2,
depth=1--> child_1 + + child_1 --ancestor_id=1,
descendant_id=4,
depth=2

ancestor_id=2,
descendant_id=4,
depth=1--> grandchild_1_1 + child_1 --ancestor_id=1,
descendant_id=5,
depth=2

ancestor_id=2,
descendant_id=5,
depth=1--> grandchild_1_2 + + root --ancestor_id=1,
descendant_id=3,
depth=1--> child_2 + child_2 --ancestor_id=1,
descendant_id=6,
depth=2

ancestor_id=3,
descendant_id=6,
depth=1--> grandchild_2_1 +``` + +What the diagram above aims to show is that the further down the hierarchy you go, the more entries into the closure table you will get, since each corresponding parent (or ancestral) relationship will be represented. This means that when you select all of an event which where `ancestor_id = 1` you will get itself and all of its children, no mater which generation, as well as the depth from the parent they have. + +#### Query patterns + +The events are queried by joining the three tables: + +```sql +SELECT + parent.id AS parent_id, + parent.name AS parent_name, + child.id AS child_id, + child.name AS child_name, + et.label + ec.depth +FROM + events parent + JOIN events_closure ec ON ec.ancestor_id = parent.id + JOIN events child ON child.id = ec.descendant_id + JOIN event_types et ON et.id=child.type_id +WHERE + parent.id = '5b300e94-498c-4a94-a85d-2d1b9b218354' +ORDER BY + ec.depth, + child.timestamp; +``` + +And if you wish to only get the direct children you would query for `depth = 1`, etc. + +### Metrics + +Metrics are queried by building up a `series` of intervals based on the queried interval. Additionally we build a number of common table expressions (`CTE`s) all of which are then queried together. + + + +```sql Timeseries +WITH timestamp_series AS ( + SELECT generate_series( + date_trunc('day', '2025-01-01'::timestamptz), + date_trunc('day', '2025-12-31'::timestamptz), + '1 day'::interval + ) AS timestamp +), +``` + +```sql Historical baseline +historical_baseline AS ( + SELECT + COALESCE(SUM(orders.subtotal_amount - orders.discount_amount), 0) AS hist_cumulative_revenue, + COALESCE(SUM((orders.subtotal_amount - orders.discount_amount) - orders.platform_fee_amount - orders.refunded_amount), 0) AS hist_net_cumulative_revenue + FROM orders + WHERE orders.status IN ('paid', 'refunded', 'partially_refunded') + AND orders.created_at < '2025-01-01 00:00:00' +), +``` + +```sql Daily metrics +daily_metrics AS ( + SELECT + date_trunc('day', orders.created_at) AS day, + -- Example metrics: + COALESCE(COUNT(orders.id), 0) AS orders, + COALESCE(SUM(orders.subtotal_amount - orders.discount_amount), 0) AS revenue, + COALESCE(SUM((orders.subtotal_amount - orders.discount_amount) - orders.platform_fee_amount - orders.refunded_amount), 0) AS net_revenue, + COALESCE(SUM(orders.subtotal_amount - orders.discount_amount), 0) AS cumulative_revenue, + COALESCE(SUM((orders.subtotal_amount - orders.discount_amount) - orders.platform_fee_amount - orders.refunded_amount), 0) AS net_cumulative_revenue, + COALESCE(CAST(CEIL(AVG(orders.subtotal_amount - orders.discount_amount)) AS INTEGER), 0) AS average_order_value, + COALESCE(COUNT(orders.id) FILTER (WHERE orders.subscription_id IS NULL), 0) AS one_time_products, + COALESCE(SUM(orders.subtotal_amount - orders.discount_amount) FILTER (WHERE orders.subscription_id IS NULL), 0) AS one_time_products_revenue, + COALESCE(SUM(orders.subtotal_amount - orders.discount_amount) FILTER (WHERE date_trunc('day', subscriptions.started_at) = date_trunc('day', orders.created_at)), 0) AS new_subscriptions_revenue, + COALESCE(SUM(orders.subtotal_amount - orders.discount_amount) FILTER (WHERE date_trunc('day', subscriptions.started_at) != date_trunc('day', orders.created_at)), 0) AS +renewed_subscriptions_revenue + FROM orders + LEFT OUTER JOIN subscriptions ON orders.subscription_id = subscriptions.id + WHERE orders.status IN ('paid', 'refunded', 'partially_refunded') + AND orders.created_at >= '2025-01-01 00:00:00' + AND orders.created_at <= '2025-12-31 00:00:00' + GROUP BY date_trunc('day', orders.created_at) +), + ``` + + ```sql Order metrics +orders_metrics_cte AS ( + SELECT + timestamp_series.timestamp, + COALESCE(daily_metrics.orders, 0) AS orders, + COALESCE(daily_metrics.revenue, 0) AS revenue, + COALESCE(SUM(daily_metrics.cumulative_revenue) OVER (ORDER BY timestamp_series.timestamp), 0) + + historical_baseline.hist_cumulative_revenue AS cumulative_revenue, + COALESCE(SUM(daily_metrics.net_cumulative_revenue) OVER (ORDER BY timestamp_series.timestamp), 0) + + historical_baseline.hist_net_cumulative_revenue AS net_cumulative_revenue, + COALESCE(daily_metrics.average_order_value, 0) AS average_order_value, + COALESCE(daily_metrics.one_time_products, 0) AS one_time_products + FROM timestamp_series + LEFT OUTER JOIN daily_metrics ON daily_metrics.day = timestamp_series.timestamp + CROSS JOIN historical_baseline + ORDER BY timestamp_series.timestamp ASC +), +``` + +```sql Active subscriptions +active_subscriptions_cte AS ( + SELECT + timestamp_series.timestamp, + COALESCE(COUNT(subscriptions.id), 0) AS active_subscriptions, + COALESCE(COUNT(subscriptions.id) FILTER ( + WHERE date_trunc('day', subscriptions.started_at) = date_trunc('day', timestamp_series.timestamp) + ), 0) AS new_subscriptions, + COALESCE(SUM( + CASE + WHEN subscriptions.recurring_interval = 'year' THEN ROUND(subscriptions.amount / 12) + WHEN subscriptions.recurring_interval = 'month' THEN subscriptions.amount + WHEN subscriptions.recurring_interval = 'week' THEN ROUND(subscriptions.amount * 4) + WHEN subscriptions.recurring_interval = 'day' THEN ROUND(subscriptions.amount * 30) + END + ), 0) AS monthly_recurring_revenue, + COALESCE(CAST( + CASE + WHEN COUNT(DISTINCT subscriptions.customer_id) = 0 THEN 0 + ELSE SUM( + CASE + WHEN subscriptions.recurring_interval = 'year' THEN ROUND(subscriptions.amount / 12) + WHEN subscriptions.recurring_interval = 'month' THEN subscriptions.amount + WHEN subscriptions.recurring_interval = 'week' THEN ROUND(subscriptions.amount * 4) + WHEN subscriptions.recurring_interval = 'day' THEN ROUND(subscriptions.amount * 30) + END + ) / COUNT(DISTINCT subscriptions.customer_id) + END AS INTEGER + ), 0) AS average_revenue_per_user + FROM timestamp_series + LEFT OUTER JOIN subscriptions ON ( + (subscriptions.started_at IS NULL + OR date_trunc('day', subscriptions.started_at) <= date_trunc('day', timestamp_series.timestamp)) + AND (COALESCE(subscriptions.ended_at, subscriptions.ends_at) IS NULL + OR date_trunc('day', COALESCE(subscriptions.ended_at, subscriptions.ends_at)) > date_trunc('day', timestamp_series.timestamp)) + ) + GROUP BY timestamp_series.timestamp + ORDER BY timestamp_series.timestamp ASC +), +``` + +```sql Checkouts +checkouts_cte AS ( + SELECT + timestamp_series.timestamp, + COALESCE(COUNT(checkouts.id), 0) AS checkouts, + COALESCE(COUNT(checkouts.id) FILTER (WHERE checkouts.status = 'succeeded'), 0) AS succeeded_checkouts, + COALESCE( + CASE + WHEN COUNT(checkouts.id) = 0 THEN 0 + ELSE CAST(COUNT(checkouts.id) FILTER (WHERE checkouts.status = 'succeeded') AS FLOAT) / COUNT(checkouts.id) + END, + 0 + ) AS checkouts_conversion + FROM timestamp_series + LEFT OUTER JOIN checkouts ON ( + date_trunc('day', checkouts.created_at) = date_trunc('day', timestamp_series.timestamp) + AND checkouts.created_at >= '2025-01-01 00:00:00' + AND checkouts.created_at <= '2025-12-31 00:00:00' + ) + GROUP BY timestamp_series.timestamp + ORDER BY timestamp_series.timestamp ASC +), +``` + +```sql Canceled subscriptions +canceled_subscriptions_cte AS ( + SELECT + timestamp_series.timestamp, + COALESCE(COUNT(subscriptions.id), 0) AS canceled_subscriptions, + COALESCE(COUNT(subscriptions.id) FILTER ( + WHERE subscriptions.customer_cancellation_reason = 'too_expensive' + ), 0) AS canceled_subscriptions_too_expensive, + COALESCE(COUNT(subscriptions.id) FILTER ( + WHERE subscriptions.customer_cancellation_reason = 'missing_features' + ), 0) AS canceled_subscriptions_missing_features, + COALESCE(COUNT(subscriptions.id) FILTER ( + WHERE subscriptions.customer_cancellation_reason = 'low_quality' + ), 0) AS canceled_subscriptions_low_quality, + COALESCE(COUNT(subscriptions.id) FILTER ( + WHERE subscriptions.customer_cancellation_reason = 'unused' + ), 0) AS canceled_subscriptions_unused + -- ... other cancellation reasons + FROM timestamp_series + LEFT OUTER JOIN subscriptions ON ( + subscriptions.canceled_at IS NOT NULL + AND date_trunc('day', subscriptions.canceled_at) = date_trunc('day', timestamp_series.timestamp) + AND subscriptions.canceled_at >= '2025-01-01 00:00:00' + AND subscriptions.canceled_at <= '2025-12-31 00:00:00' + ) + GROUP BY timestamp_series.timestamp + ORDER BY timestamp_series.timestamp ASC +), +``` + +```sql Churned subscriptions +churned_subscriptions_cte AS ( + SELECT + timestamp_series.timestamp, + COALESCE(COUNT(subscriptions.id), 0) AS churned_subscriptions + FROM timestamp_series + LEFT OUTER JOIN subscriptions ON ( + COALESCE(subscriptions.ended_at, subscriptions.ends_at) IS NOT NULL + AND date_trunc('day', COALESCE(subscriptions.ended_at, subscriptions.ends_at)) = date_trunc('day', timestamp_series.timestamp) + AND COALESCE(subscriptions.ended_at, subscriptions.ends_at) >= '2025-01-01 00:00:00' + AND COALESCE(subscriptions.ended_at, subscriptions.ends_at) <= '2025-12-31 00:00:00' + ) + GROUP BY timestamp_series.timestamp + ORDER BY timestamp_series.timestamp ASC +), +``` + +```sql Events daily +events_daily_metrics AS ( + SELECT + date_trunc('day', events.timestamp) AS day, + COALESCE(SUM((events.user_metadata->'_cost'->>'amount')::numeric(17,12)) FILTER ( + WHERE events.user_metadata->'_cost' IS NOT NULL + ), 0) AS costs, + COALESCE(SUM((events.user_metadata->'_cost'->>'amount')::numeric(17,12)) FILTER ( + WHERE events.user_metadata->'_cost' IS NOT NULL + ), 0) AS cumulative_costs, + COALESCE( + COUNT(DISTINCT events.customer_id) + COUNT(DISTINCT events.external_customer_id), + 0 + ) AS active_user_by_event, + COALESCE( + CASE + WHEN (COUNT(DISTINCT events.customer_id) + COUNT(DISTINCT events.external_customer_id)) = 0 THEN 0 + ELSE SUM(COALESCE((events.user_metadata->'_cost'->>'amount')::numeric(17,12), 0)) + / (COUNT(DISTINCT events.customer_id) + COUNT(DISTINCT events.external_customer_id)) + END, + 0 + ) AS cost_per_user + FROM events + WHERE events.timestamp >= '2025-01-01 00:00:00' + AND events.timestamp <= '2025-12-31 00:00:00' + GROUP BY date_trunc('day', events.timestamp) +), +``` + +```sql Events windowed +events_metrics_cte AS ( + SELECT + timestamp_series.timestamp, + COALESCE(events_daily_metrics.costs, 0) AS costs, + -- Cumulative costs using window function + COALESCE( + SUM(events_daily_metrics.cumulative_costs) OVER (ORDER BY timestamp_series.timestamp), + 0 + ) AS cumulative_costs, + COALESCE(events_daily_metrics.active_user_by_event, 0) AS active_user_by_event, + COALESCE(events_daily_metrics.cost_per_user, 0) AS cost_per_user + FROM timestamp_series + LEFT OUTER JOIN events_daily_metrics ON events_daily_metrics.day = timestamp_series.timestamp + ORDER BY timestamp_series.timestamp ASC +) +``` + + +Finally, all of these CTEs are then queried in a JOIN query: + +```sql + SELECT + timestamp_series.timestamp, + + orders_metrics_cte.orders, + orders_metrics_cte.revenue, + orders_metrics_cte.cumulative_revenue, + orders_metrics_cte.net_cumulative_revenue, + orders_metrics_cte.average_order_value, + orders_metrics_cte.one_time_products, + + active_subscriptions_cte.active_subscriptions, + active_subscriptions_cte.new_subscriptions, + active_subscriptions_cte.monthly_recurring_revenue, + active_subscriptions_cte.average_revenue_per_user, + + checkouts_cte.checkouts, + checkouts_cte.succeeded_checkouts, + checkouts_cte.checkouts_conversion, + + canceled_subscriptions_cte.canceled_subscriptions, + canceled_subscriptions_cte.canceled_subscriptions_too_expensive, + canceled_subscriptions_cte.canceled_subscriptions_missing_features, + canceled_subscriptions_cte.canceled_subscriptions_low_quality, + canceled_subscriptions_cte.canceled_subscriptions_unused, + + churned_subscriptions_cte.churned_subscriptions, + + events_metrics_cte.costs, + events_metrics_cte.cumulative_costs, + events_metrics_cte.active_user_by_event, + events_metrics_cte.cost_per_user + FROM timestamp_series + JOIN orders_metrics_cte ON orders_metrics_cte.timestamp = timestamp_series.timestamp + JOIN active_subscriptions_cte ON active_subscriptions_cte.timestamp = timestamp_series.timestamp + JOIN checkouts_cte ON checkouts_cte.timestamp = timestamp_series.timestamp + JOIN canceled_subscriptions_cte ON canceled_subscriptions_cte.timestamp = timestamp_series.timestamp + JOIN churned_subscriptions_cte ON churned_subscriptions_cte.timestamp = timestamp_series.timestamp + JOIN events_metrics_cte ON events_metrics_cte.timestamp = timestamp_series.timestamp + ORDER BY timestamp_series.timestamp ASC; +``` + +The metrics today are a combination of querying the different tables that contain state, as well as summing up data from historical events. + +## Alternatives + +### 1. Keep Postgres and current event, metric structures + +The current setup will hold up for some time forward. Since we are using a combination of querying the state tables as well as the events, and that we are always filtering by `organization_id` in the events filtering, we will most likely not have an entirely unmanageable growth. Some response times will start to creep up but we might be able to be smarter with the way we query data. + +### 2. Expand events to include all data necessary to generate metrics + +Today we are lacking some specific events, and some data in the events to be able to build out the full (or almost full) metrics from purely the events. We would need to add some information to the system events, and probably backfill the existing events so that we can move forward without having to consider before-and-after this migration. + +#### Events + +Enriching `order.paid` +- `currency` +- total_amount information + - `net_amount`, `tax_amount`, `applied_balance_amount` +- discount information + - `discount_amount`, `discount_id` +- `platform_fee` +- subscription information + - `subscription_id`, `subscription_type` (one time, monthly, yearly) + +Adding `subscription.created` +- `amount` +- `recurring_interval`, `recurring_interval_count` +- `started_at`, `product_id` + +Adding `subscription.canceled` +- To abe able to differnate churn and cancelation metrics. +- `cancellation_reason`, `cancellation_comment` +- `canceled_at`, `ends_at` + +Enriching `subscription.revoked` +- `amount` to keep track of the amount reduction +- `recurring_interval`, `recurring_interval_count` + +Adding `checkout.created` + +Adding `checkout.updated` + +#### Metrics + +Given the new events we have added, we should be able to migrate to a fully event-based metrics generation if we wish. Certain metrics might be more involved than others. A metrics query that only looks as events could look something like the following: + + + +```sql +WITH timestamp_series AS ( + SELECT generate_series( + date_trunc('hour', '2025-01-01'::timestamptz), + date_trunc('hour', '2025-12-31'::timestamptz), + '1 hour'::interval + ) AS timestamp + ), + + system_metrics AS ( + SELECT + date_trunc('hour', timestamp) AS period, + timestamp < '2025-01-01 00:00:00' AS is_historical, + + -- Order metrics + COUNT(*) FILTER (WHERE name = 'order.paid') AS orders, + COALESCE(SUM( + (user_metadata->>'subtotal_amount')::int - (user_metadata->>'discount_amount')::int + ) FILTER (WHERE name = 'order.paid'), 0) AS revenue, + COALESCE(SUM( + (user_metadata->>'subtotal_amount')::int + - (user_metadata->>'discount_amount')::int + - (user_metadata->>'platform_fee_amount')::int + ) FILTER (WHERE name = 'order.paid'), 0) + - COALESCE(SUM((user_metadata->>'refunded_amount')::int) FILTER (WHERE name = 'order.refunded'), 0) AS net_revenue, + COALESCE(CAST(CEIL(AVG( + (user_metadata->>'subtotal_amount')::int - (user_metadata->>'discount_amount')::int + ) FILTER (WHERE name = 'order.paid')) AS INTEGER), 0) AS average_order_value, + COALESCE(CAST(CEIL(AVG( + (user_metadata->>'subtotal_amount')::int + - (user_metadata->>'discount_amount')::int + - (user_metadata->>'platform_fee_amount')::int + ) FILTER (WHERE name = 'order.paid')) AS INTEGER), 0) AS net_average_order_value, + + -- One-time products + COUNT(*) FILTER (WHERE name = 'order.paid' AND user_metadata->>'subscription_id' IS NULL) AS one_time_products, + COALESCE(SUM( + (user_metadata->>'subtotal_amount')::int - (user_metadata->>'discount_amount')::int + ) FILTER (WHERE name = 'order.paid' AND user_metadata->>'subscription_id' IS NULL), 0) AS one_time_products_revenue, + COALESCE(SUM( + (user_metadata->>'subtotal_amount')::int + - (user_metadata->>'discount_amount')::int + - (user_metadata->>'platform_fee_amount')::int + ) FILTER (WHERE name = 'order.paid' AND user_metadata->>'subscription_id' IS NULL), 0) AS one_time_products_net_revenue, + + -- New subscriptions revenue + COALESCE(SUM( + (user_metadata->>'subtotal_amount')::int - (user_metadata->>'discount_amount')::int + ) FILTER (WHERE name = 'order.paid' AND user_metadata->>'billing_reason' = 'subscription_create'), 0) AS new_subscriptions_revenue, + COALESCE(SUM( + (user_metadata->>'subtotal_amount')::int + - (user_metadata->>'discount_amount')::int + - (user_metadata->>'platform_fee_amount')::int + ) FILTER (WHERE name = 'order.paid' AND user_metadata->>'billing_reason' = 'subscription_create'), 0) AS new_subscriptions_net_revenue, + + -- Renewed subscriptions + COUNT(DISTINCT user_metadata->>'subscription_id') FILTER ( + WHERE name = 'order.paid' AND user_metadata->>'billing_reason' = 'subscription_cycle' + ) AS renewed_subscriptions, + COALESCE(SUM( + (user_metadata->>'subtotal_amount')::int - (user_metadata->>'discount_amount')::int + ) FILTER (WHERE name = 'order.paid' AND user_metadata->>'billing_reason' = 'subscription_cycle'), 0) AS renewed_subscriptions_revenue, + COALESCE(SUM( + (user_metadata->>'subtotal_amount')::int + - (user_metadata->>'discount_amount')::int + - (user_metadata->>'platform_fee_amount')::int + ) FILTER (WHERE name = 'order.paid' AND user_metadata->>'billing_reason' = 'subscription_cycle'), 0) AS renewed_subscriptions_net_revenue, + + -- Subscription deltas + SUM(CASE + WHEN name = 'subscription.created' THEN 1 + WHEN name = 'subscription.revoked' THEN -1 + ELSE 0 + END) AS subscription_delta, + COUNT(*) FILTER (WHERE name = 'subscription.created') AS new_subscriptions, + + -- MRR delta + SUM(CASE + WHEN name IN ('subscription.created', 'subscription.revoked') THEN + CASE WHEN name = 'subscription.created' THEN 1 ELSE -1 END + * CASE (user_metadata->>'recurring_interval') + WHEN 'year' THEN ROUND((user_metadata->>'amount')::int / 12.0) + WHEN 'month' THEN (user_metadata->>'amount')::int + WHEN 'week' THEN ROUND((user_metadata->>'amount')::int * 4.0) + WHEN 'day' THEN ROUND((user_metadata->>'amount')::int * 30.0) + END + ELSE 0 + END) AS mrr_delta, + + -- Committed MRR delta + SUM(CASE + WHEN name IN ('subscription.created', 'subscription.canceled') THEN + CASE WHEN name = 'subscription.created' THEN 1 ELSE -1 END + * CASE (user_metadata->>'recurring_interval') + WHEN 'year' THEN ROUND((user_metadata->>'amount')::int / 12.0) + WHEN 'month' THEN (user_metadata->>'amount')::int + WHEN 'week' THEN ROUND((user_metadata->>'amount')::int * 4.0) + WHEN 'day' THEN ROUND((user_metadata->>'amount')::int * 30.0) + END + ELSE 0 + END) AS committed_mrr_delta, + + -- Canceled metrics + COUNT(*) FILTER (WHERE name = 'subscription.canceled') AS canceled_subscriptions, + COUNT(*) FILTER (WHERE name = 'subscription.canceled' AND user_metadata->>'customer_cancellation_reason' = 'customer_service') AS canceled_subscriptions_customer_service, + COUNT(*) FILTER (WHERE name = 'subscription.canceled' AND user_metadata->>'customer_cancellation_reason' = 'low_quality') AS canceled_subscriptions_low_quality, + COUNT(*) FILTER (WHERE name = 'subscription.canceled' AND user_metadata->>'customer_cancellation_reason' = 'missing_features') AS canceled_subscriptions_missing_features, + COUNT(*) FILTER (WHERE name = 'subscription.canceled' AND user_metadata->>'customer_cancellation_reason' = 'switched_service') AS canceled_subscriptions_switched_service, + COUNT(*) FILTER (WHERE name = 'subscription.canceled' AND user_metadata->>'customer_cancellation_reason' = 'too_complex') AS canceled_subscriptions_too_complex, + COUNT(*) FILTER (WHERE name = 'subscription.canceled' AND user_metadata->>'customer_cancellation_reason' = 'too_expensive') AS canceled_subscriptions_too_expensive, + COUNT(*) FILTER (WHERE name = 'subscription.canceled' AND user_metadata->>'customer_cancellation_reason' = 'unused') AS canceled_subscriptions_unused, + COUNT(*) FILTER (WHERE name = 'subscription.canceled' AND (user_metadata->>'customer_cancellation_reason' = 'other' OR user_metadata->>'customer_cancellation_reason' IS NULL)) AS + canceled_subscriptions_other, + + -- Churned + COUNT(*) FILTER (WHERE name = 'subscription.revoked') AS churned_subscriptions, + + -- Checkout metrics + COUNT(*) FILTER (WHERE name = 'checkout.created') AS checkouts, + COUNT(*) FILTER (WHERE name = 'checkout.updated' AND user_metadata->>'status' = 'succeeded') AS succeeded_checkouts + + FROM events + WHERE source = 'system' + AND organization_id = '00000000-0000-0000-0000-000000000000' + AND name IN ( + 'order.paid', 'order.refunded', + 'subscription.created', 'subscription.canceled', 'subscription.revoked', + 'checkout.created', 'checkout.updated' + ) + GROUP BY date_trunc('hour', timestamp), timestamp < '2025-01-01 00:00:00' + ), + + user_metrics AS ( + SELECT + date_trunc('hour', timestamp) AS period, + timestamp < '2025-01-01 00:00:00' AS is_historical, + COALESCE(SUM((user_metadata->'_cost'->>'amount')::numeric(17,12)), 0) AS costs, + COUNT(DISTINCT COALESCE(customer_id::text, external_customer_id)) AS active_user_by_event + FROM events + WHERE source = 'user' + AND organization_id = '00000000-0000-0000-0000-000000000000' + GROUP BY date_trunc('hour', timestamp), timestamp < '2025-01-01 00:00:00' + ), + + historical_system AS ( + SELECT + COALESCE(SUM(revenue), 0) AS hist_revenue, + COALESCE(SUM(net_revenue), 0) AS hist_net_revenue, + COALESCE(SUM(subscription_delta), 0) AS hist_active_subscriptions, + COALESCE(SUM(mrr_delta), 0) AS hist_mrr, + COALESCE(SUM(committed_mrr_delta), 0) AS hist_committed_mrr + FROM system_metrics + WHERE is_historical + ), + + historical_user AS ( + SELECT COALESCE(SUM(costs), 0) AS hist_costs + FROM user_metrics + WHERE is_historical + ), + + period_system AS ( + SELECT * FROM system_metrics WHERE NOT is_historical + ), + + period_user AS ( + SELECT * FROM user_metrics WHERE NOT is_historical + ) + + SELECT + ts.timestamp, + + -- Order metrics + COALESCE(ps.orders, 0) AS orders, + COALESCE(ps.revenue, 0) AS revenue, + COALESCE(ps.net_revenue, 0) AS net_revenue, + COALESCE(SUM(ps.revenue) OVER w, 0) + hs.hist_revenue AS cumulative_revenue, + COALESCE(SUM(ps.net_revenue) OVER w, 0) + hs.hist_net_revenue AS net_cumulative_revenue, + COALESCE(ps.average_order_value, 0) AS average_order_value, + COALESCE(ps.net_average_order_value, 0) AS net_average_order_value, + + -- One-time products + COALESCE(ps.one_time_products, 0) AS one_time_products, + COALESCE(ps.one_time_products_revenue, 0) AS one_time_products_revenue, + COALESCE(ps.one_time_products_net_revenue, 0) AS one_time_products_net_revenue, + + -- New subscriptions + COALESCE(ps.new_subscriptions, 0) AS new_subscriptions, + COALESCE(ps.new_subscriptions_revenue, 0) AS new_subscriptions_revenue, + COALESCE(ps.new_subscriptions_net_revenue, 0) AS new_subscriptions_net_revenue, + + -- Renewed subscriptions + COALESCE(ps.renewed_subscriptions, 0) AS renewed_subscriptions, + COALESCE(ps.renewed_subscriptions_revenue, 0) AS renewed_subscriptions_revenue, + COALESCE(ps.renewed_subscriptions_net_revenue, 0) AS renewed_subscriptions_net_revenue, + + -- Active subscriptions & MRR + hs.hist_active_subscriptions + COALESCE(SUM(ps.subscription_delta) OVER w, 0) AS active_subscriptions, + hs.hist_mrr + COALESCE(SUM(ps.mrr_delta) OVER w, 0) AS monthly_recurring_revenue, + hs.hist_committed_mrr + COALESCE(SUM(ps.committed_mrr_delta) OVER w, 0) AS committed_monthly_recurring_revenue, + + -- ARPU + CASE + WHEN hs.hist_active_subscriptions + COALESCE(SUM(ps.subscription_delta) OVER w, 0) = 0 THEN 0.0 + ELSE (hs.hist_mrr + COALESCE(SUM(ps.mrr_delta) OVER w, 0))::float + / (hs.hist_active_subscriptions + COALESCE(SUM(ps.subscription_delta) OVER w, 0)) + END AS average_revenue_per_user, + + -- Checkouts + COALESCE(ps.checkouts, 0) AS checkouts, + COALESCE(ps.succeeded_checkouts, 0) AS succeeded_checkouts, + CASE WHEN COALESCE(ps.checkouts, 0) = 0 THEN 0.0 ELSE ps.succeeded_checkouts::float / ps.checkouts END AS checkouts_conversion, + + -- Cancellations + COALESCE(ps.canceled_subscriptions, 0) AS canceled_subscriptions, + COALESCE(ps.canceled_subscriptions_customer_service, 0) AS canceled_subscriptions_customer_service, + COALESCE(ps.canceled_subscriptions_low_quality, 0) AS canceled_subscriptions_low_quality, + COALESCE(ps.canceled_subscriptions_missing_features, 0) AS canceled_subscriptions_missing_features, + COALESCE(ps.canceled_subscriptions_switched_service, 0) AS canceled_subscriptions_switched_service, + COALESCE(ps.canceled_subscriptions_too_complex, 0) AS canceled_subscriptions_too_complex, + COALESCE(ps.canceled_subscriptions_too_expensive, 0) AS canceled_subscriptions_too_expensive, + COALESCE(ps.canceled_subscriptions_unused, 0) AS canceled_subscriptions_unused, + COALESCE(ps.canceled_subscriptions_other, 0) AS canceled_subscriptions_other, + + -- Churn + COALESCE(ps.churned_subscriptions, 0) AS churned_subscriptions, + + -- Costs + COALESCE(pu.costs, 0) AS costs, + COALESCE(SUM(pu.costs) OVER w, 0) + hu.hist_costs AS cumulative_costs, + COALESCE(pu.active_user_by_event, 0) AS active_user_by_event, + CASE WHEN COALESCE(pu.active_user_by_event, 0) = 0 THEN 0.0 ELSE pu.costs / pu.active_user_by_event END AS cost_per_user, + + -- Meta metrics + (COALESCE(SUM(ps.revenue) OVER w, 0) + hs.hist_revenue) + - (COALESCE(SUM(pu.costs) OVER w, 0) + hu.hist_costs) AS gross_margin, + CASE + WHEN (COALESCE(SUM(ps.revenue) OVER w, 0) + hs.hist_revenue) = 0 THEN 0.0 + ELSE ((COALESCE(SUM(ps.revenue) OVER w, 0) + hs.hist_revenue) - (COALESCE(SUM(pu.costs) OVER w, 0) + hu.hist_costs))::float + / (COALESCE(SUM(ps.revenue) OVER w, 0) + hs.hist_revenue) + END AS gross_margin_percentage, + COALESCE(ps.revenue, 0) - COALESCE(pu.costs, 0) AS cashflow, + + -- Churn rate + CASE + WHEN (hs.hist_active_subscriptions + COALESCE(SUM(ps.subscription_delta) OVER w, 0) + - COALESCE(ps.new_subscriptions, 0) + COALESCE(ps.churned_subscriptions, 0)) = 0 THEN 0.0 + ELSE COALESCE(ps.canceled_subscriptions, 0)::float + / (hs.hist_active_subscriptions + COALESCE(SUM(ps.subscription_delta) OVER w, 0) + - COALESCE(ps.new_subscriptions, 0) + COALESCE(ps.churned_subscriptions, 0)) + END AS churn_rate + + FROM timestamp_series ts + CROSS JOIN historical_system hs + CROSS JOIN historical_user hu + LEFT JOIN period_system ps ON ps.period = ts.timestamp + LEFT JOIN period_user pu ON pu.period = ts.timestamp + WINDOW w AS (ORDER BY ts.timestamp) + ORDER BY ts.timestamp ASC; +``` + + + +### 2A Enabling TimescaleDB + + +#### Metrics + +Building on the work for alternative 2, we can enable `TimescaleDB` as an extension in postgres. Then we can set up materialized views with time buckets, such as: + +```sql System metrics + +CREATE MATERIALIZED VIEW system_metrics_hourly +WITH (TimescaleDB.continuous) AS +SELECT + time_bucket('1 hour', timestamp) AS bucket, + organization_id, + + -- Order metrics + COUNT(*) FILTER (WHERE name = 'order.paid') AS orders, + SUM((user_metadata->>'subtotal_amount')::int + - (user_metadata->>'discount_amount')::int + ) FILTER (WHERE name = 'order.paid') AS revenue, + + SUM((user_metadata->>'subtotal_amount')::int + - (user_metadata->>'discount_amount')::int + - (user_metadata->>'platform_fee_amount')::int + ) FILTER (WHERE name = 'order.paid') AS net_revenue, + + -- Subscription deltas + SUM(CASE + WHEN name = 'subscription.created' THEN 1 + WHEN name = 'subscription.revoked' THEN -1 + ELSE 0 + END) AS subscription_delta, + + -- MRR delta (with proper ELSE clause!) + SUM(CASE + WHEN name IN ('subscription.created', 'subscription.revoked') THEN + (CASE WHEN name = 'subscription.created' THEN 1 ELSE -1 END) + * CASE (user_metadata->>'recurring_interval') + WHEN 'year' THEN ROUND((user_metadata->>'amount')::int / 12.0) + WHEN 'month' THEN (user_metadata->>'amount')::int + WHEN 'week' THEN ROUND((user_metadata->>'amount')::int * 4.0) + WHEN 'day' THEN ROUND((user_metadata->>'amount')::int * 30.0) + ELSE 0 -- Fixed: handle unknown intervals + END + ELSE 0 + END) AS mrr_delta, + + -- Pre-aggregate cancellation reasons + COUNT(*) FILTER (WHERE name = 'subscription.canceled') AS canceled_subscriptions, + COUNT(*) FILTER ( + WHERE name = 'subscription.canceled' + AND user_metadata->>'customer_cancellation_reason' = 'too_expensive' + ) AS canceled_too_expensive, + -- ... other cancellation reasons ... + + COUNT(*) FILTER (WHERE name = 'checkout.created') AS checkouts, + COUNT(*) FILTER ( + WHERE name = 'checkout.updated' AND user_metadata->>'status' = 'succeeded' + ) AS succeeded_checkouts + +FROM events +WHERE source = 'system' + AND name IN ('order.paid', 'order.refunded', 'subscription.created', + 'subscription.canceled', 'subscription.revoked', + 'checkout.created', 'checkout.updated') +GROUP BY time_bucket('1 hour', timestamp), organization_id +``` + +```sql User metrics + +CREATE MATERIALIZED VIEW user_metrics_hourly +WITH (TimescaleDB.continuous) AS +SELECT + time_bucket('1 hour', timestamp) AS bucket, + organization_id, + SUM((user_metadata->'_cost'->>'amount')::numeric(17,12)) AS costs, + COUNT(DISTINCT COALESCE(customer_id::text, external_customer_id)) + FILTER (WHERE customer_id IS NOT NULL OR external_customer_id IS NOT NULL) AS active_users +FROM events +WHERE source = 'user' +GROUP BY time_bucket('1 hour', timestamp), organization_id +``` + +These would then be possible to query easily: + +```sql + +SELECT + time_bucket_gapfill('1 hour', bucket) AS timestamp, + + -- Simple column reads instead of complex aggregations + COALESCE(s.orders, 0) AS orders, + COALESCE(s.revenue, 0) AS revenue, + COALESCE(s.net_revenue, 0) AS net_revenue, + + -- Cumulative values with gapfill + SUM(COALESCE(s.revenue, 0)) OVER w AS cumulative_revenue, + SUM(COALESCE(s.subscription_delta, 0)) OVER w AS active_subscriptions, + SUM(COALESCE(s.mrr_delta, 0)) OVER w AS monthly_recurring_revenue, + + -- Costs + COALESCE(u.costs, 0) AS costs, + SUM(COALESCE(u.costs, 0)) OVER w AS cumulative_costs, + + -- Derived metrics + SUM(COALESCE(s.revenue, 0)) OVER w - SUM(COALESCE(u.costs, 0)) OVER w AS gross_margin + +FROM system_metrics_hourly s +FULL OUTER JOIN user_metrics_hourly u + ON s.bucket = u.bucket AND s.organization_id = u.organization_id +WHERE s.organization_id = '00000000-0000-0000-0000-000000000000' + AND s.bucket >= '2025-01-01' AND s.bucket < '2026-01-01' +WINDOW w AS (ORDER BY COALESCE(s.bucket, u.bucket)) +ORDER BY 1; +``` + +The difference between plain Postgres and Postgres with TimescaleDB is the (easy / simple) pre-aggregation into materialized views that we can query windowed. Inserting data into events would trigger the need to refresh the materialized hourly view. If we want to have full support for :15 and :30 minute timezones, we might need to do 15-minute materialized views. For performance reasons we could determine which view we are querying. + +#### Events + +The querying of events will take a slight hit with TimescaleDB. The following applies to hypertables in TimescaleDB: +- Foreign key constraints from a hypertable referencing a regular table +- Foreign key constraints from a regular table referencing a hypertable +- Foreign keys from a hypertable referencing another hypertable are not supported. + +So we will not be able to have the self referencing `parent_id` and `root_id` foreign keys on the `events` table if we convert it to a TimescaleDB hypertable. + +However we will be able to have a foreign key relationship between `events` and `events_closure`. We probably will want to introduce timestamps on the events closure table since that is where TimescaleDB also shines: + +```sql +ALTER TABLE events_closure + ADD COLUMN ancestor_timestamp timestamptz, + ADD COLUMN descendant_timestamp timestamptz; + +ALTER TABLE events_closure + ADD CONSTRAINT events_closure_ancestor_fkey + FOREIGN KEY (ancestor_id, ancestor_timestamp) + REFERENCES events(id, timestamp) ON DELETE CASCADE; + +ALTER TABLE events_closure + ADD CONSTRAINT events_closure_descendant_fkey + FOREIGN KEY (descendant_id, descendant_timestamp) + REFERENCES events(id, timestamp) ON DELETE CASCADE; +``` + +These can then be queried via: + +```sql + +SELECT + parent.id AS parent_id, + parent.name AS parent_name, + child.id AS child_id, + child.name AS child_name, + et.label, + ec.depth +FROM events parent +JOIN events_closure ec ON ec.ancestor_id = parent.id + AND ec.ancestor_timestamp = parent.timestamp +JOIN events child ON child.id = ec.descendant_id + AND child.timestamp = ec.descendant_timestamp +JOIN event_types et ON et.id = child.event_type_id +WHERE parent.id = '5b300e94-498c-4a94-a85d-2d1b9b218354' + AND parent.timestamp = '2024-01-15T10:30:00Z' -- Known timestamp helps chunk pruning + AND parent.organization_id = 'YOUR_ORG_ID' +ORDER BY ec.depth, child.timestamp; +``` + + +### 2B Moving over to Clickhouse + +Based on us having full event coverage, we could denormalize (or just expand the JSONB metadata column into its own columns). Additionally we would denormalize the event_closure table. The Clickhouse event table would then look something similar to: + +```sql + +CREATE TABLE events ( + id UUID, + external_id Nullable(String), + timestamp DateTime64(3), + name LowCardinality(String), + source LowCardinality(String), + customer_id Nullable(UUID), + external_customer_id Nullable(String), + organization_id UUID, + ingested_at DateTime64(3), + + parent_id Nullable(UUID), + root_id Nullable(UUID), + depth UInt8, + ancestor_path Array(UUID), -- [root_id, ..., parent_id] - full path to this event + + -- JSONB fields extracted at insert time + subtotal_amount Nullable(Int64), + discount_amount Nullable(Int64), + platform_fee_amount Nullable(Int64), + subscription_id Nullable(String), + billing_reason LowCardinality(Nullable(String)), + recurring_interval LowCardinality(Nullable(String)), + amount Nullable(Int64), + customer_cancellation_reason LowCardinality(Nullable(String)), + refunded_amount Nullable(Int64), + checkout_status LowCardinality(Nullable(String)), + + -- Keep raw JSON for flexibility + user_metadata JSON -- or JSON type in newer versions + + -- For deduplication + INDEX idx_external_id external_id TYPE bloom_filter GRANULARITY 4 +) +ENGINE = MergeTree() +PARTITION BY toYYYYMM(timestamp) +ORDER BY (organization_id, timestamp, id); +``` + +#### Events + +Selecting an event and all of its children would look like: + +```sql +SELECT + id, + name, + event_type_label, + depth, + timestamp +FROM events +WHERE organization_id = 'YOUR_ORG_ID' + AND has(ancestor_path, toUUID('5b300e94-498c-4a94-a85d-2d1b9b218354')) +ORDER BY depth, timestamp; +``` + +Selecting an event and all of its parents would look like: +```sql + +SELECT * FROM events +WHERE organization_id = 'YOUR_ORG_ID' + AND id IN ( + SELECT arrayJoin(ancestor_path) + FROM events + WHERE id = '5b300e94-498c-4a94-a85d-2d1b9b218354' + ) +ORDER BY depth; +``` + +#### Metrics + +Querying metric data from Clickhouse could look something similar to: + +```sql + +SELECT + toStartOfHour(timestamp) AS period, + + countIf(name = 'order.paid') AS orders, + sumIf(subtotal_amount - discount_amount, name = 'order.paid') AS revenue, + sumIf( + subtotal_amount - discount_amount - platform_fee_amount, + name = 'order.paid' + ) AS net_revenue, + + sum(revenue) OVER (ORDER BY period) AS cumulative_revenue, + + sumIf(1, name = 'subscription.created') + - sumIf(1, name = 'subscription.revoked') AS subscription_delta, + + sumIf( + multiIf( + recurring_interval = 'year', round(amount / 12.0), + recurring_interval = 'month', amount, + recurring_interval = 'week', round(amount * 4.0), + recurring_interval = 'day', round(amount * 30.0), + 0 + ), + name = 'subscription.created' + ) - sumIf( + multiIf( + recurring_interval = 'year', round(amount / 12.0), + recurring_interval = 'month', amount, + recurring_interval = 'week', round(amount * 4.0), + recurring_interval = 'day', round(amount * 30.0), + 0 + ), + name = 'subscription.revoked' + ) AS mrr_delta, + + sum(subscription_delta) OVER (ORDER BY period) AS active_subscriptions, + sum(mrr_delta) OVER (ORDER BY period) AS monthly_recurring_revenue + +FROM events +WHERE organization_id = '00000000-0000-0000-0000-000000000000' + AND timestamp >= '2025-01-01' + AND timestamp < '2026-01-01' +GROUP BY period +ORDER BY period +``` + +However there are a few caveats to moving events out from Postgres. + +1. More limited SQLAlchemy ORM magic in Clickhouse. There is support to use SQLAlchemy with Clickhouse, but it does not support everything. +2. We can no longer join events and other tables in the same query. Independent on if we continue to store events in Postgres or if we fully migrate them to Clickhouse, we can't join the data in the same query. +3. Dual infrastructure / Operations. We will need to maintain two databases, ensure that they are kept up-to-date, etc. It will add some complexity to our operations. + + +## Comparison + +For certain organizations we are at >5-10 seconds response times for metrics queries today. Similarly for some organizations we are at >2s for event listing today. This hints that the current set up will not continue to scale with our customers for a lot longer, and we will not be able to onboard even larger customers that are ingesting more event data. + +Clickhouse is well understood in the industry to be _extremely_ performant, albeit at a cost of heavier operations. Cloudflare1 has been using it for a long time. Posthog2 are also using it and are very happy about it3: + +> Using ClickHouse is one of the best decisions we've made here at PostHog. It has allowed us to scale performance all while allowing us to build more products on the same set of data. + +There are different comparisons and benchmarks to find. Tiger Data4 and Tinybird5 have done different benchmarks. + +![RTABench](/engineering/design-documents/images/event-storage/rtabench-comparison.png) [link](https://rtabench.com/#eyJzeXN0ZW0iOnsiQ2xpY2tIb3VzZSBDbG91ZCAoYXdzKSI6ZmFsc2UsIkRvcmlzIjpmYWxzZSwiVGltZXNjYWxlIENsb3VkIjpmYWxzZSwiRHVja0RCIjpmYWxzZSwiTW9uZ29EQiI6ZmFsc2UsIlBvc3RncmVzIjp0cnVlLCJUaW1lc2NhbGVEQiI6dHJ1ZSwiTXlTUUwiOmZhbHNlLCJDbGlja0hvdXNlIjp0cnVlfSwidHlwZSI6eyJSZWFsLXRpbWUgQW5hbHl0aWNzIjp0cnVlLCJCYXRjaCBBbmFseXRpY3MiOnRydWUsIkdlbmVyYWwgUHVycG9zZSI6dHJ1ZSwidW5kZWZpbmVkIjp0cnVlfSwibWFjaGluZSI6eyIyNCB2Q1BVIDk2IEdCICgzeDogOHZDUFUgMzJHQikiOmZhbHNlLCJtNS40eGxhcmdlLCA1MDBnYiBncDIiOnRydWUsIjE2IHZDUFUgNjRHQiI6ZmFsc2UsImM2YS40eGxhcmdlLCA1MDBnYiBncDIiOmZhbHNlLCI0IHZDUFUgMTZHQiI6ZmFsc2UsIjYgdkNQVSAyNCBHQiAoM3g6IDJ2Q1BVIDhHQikiOmZhbHNlLCI4IHZDUFUgMzJHQiI6ZmFsc2UsIjEyIHZDUFUgNDggR0IgKDN4OiA0dkNQVSAxNkdCKSI6ZmFsc2V9LCJjbHVzdGVyX3NpemUiOnsiMSI6dHJ1ZSwiMyI6dHJ1ZX0sIm1ldHJpYyI6ImhvdCIsInF1ZXJpZXMiOlt0cnVlLHRydWUsdHJ1ZSx0cnVlLHRydWUsdHJ1ZSx0cnVlLHRydWUsdHJ1ZSx0cnVlLHRydWUsdHJ1ZSx0cnVlLHRydWUsdHJ1ZSx0cnVlLHRydWUsdHJ1ZSx0cnVlLHRydWUsdHJ1ZSx0cnVlLHRydWUsdHJ1ZSx0cnVlLHRydWUsdHJ1ZSx0cnVlLHRydWUsdHJ1ZSx0cnVlXX0=). + +> * TimescaleDB is 1.9x faster than ClickHouse on RTABench, even though it’s 6.8x slower on ClickBench. This is likely because TimescaleDB is optimized for real-time analytics applications, which often rely on normalized schemas and selective aggregations, while ClickHouse shines in denormalized, columnar analytics with large-scale aggregations. +> * Incremental materialized views offer massive speedups. They deliver up to hundreds or even thousands of times faster performance than querying the raw data (from seconds to a few milliseconds), demonstrating their value for real-time analytics. However, among the databases tested, only ClickHouse and TimescaleDB support them. +> * ClickHouse is the leader in data loading and storage efficiency. It’s 4.8x faster at loading data and uses 1.7x less disk than the next best database. + +Quoted from TigerDatas4 comparison, leads us to think about what the main patterns of querying we will be doing. We know that we will want to generate metrics from our events (i.e. aggregate and group a lot of events), and we know that we will want to ingest a lot of events from customers externally. + +1. [Cloudflare](https://blog.cloudflare.com/http-analytics-for-6m-requests-per-second-using-clickhouse) +2. [Posthog](https://clickhouse.com/blog/london-meetup-report-scaling-analytics-with-posthog-and-introducing-housewatch) +3. [Posthog on HackerNews](https://news.ycombinator.com/item?id=41917231) +4. [TigerData](https://www.tigerdata.com/blog/benchmarking-databases-for-real-time-analytics-applications) +5. [Tinybird](https://www.tinybird.co/blog/clickhouse-vs-TimescaleDB) + +## Proposal + +Given our ambition to move closer and make something similar to OpenTelemetry possible when it comes to aggregating and combining events at scale, it probably makes sense for us to take the leap and go toward Clickhouse already today. Even if it is a smaller step to enable TimescaleDB today, it is possible that it will only be performant on data that we control. When our users are ingesting event data in various hierarchies and in various sizes then it becomes more difficult for us to ensure performance. If we were to control all of the data, it is likely that TimescaleDB would support us for a long time forward, even if we were to have magnitudes more data (100x). diff --git a/engineering/design-documents/images/event-storage/rtabench-comparison.png b/engineering/design-documents/images/event-storage/rtabench-comparison.png new file mode 100644 index 0000000000000000000000000000000000000000..8d975c8d7b601a19d4642d3b4e5ba7703b25bfca GIT binary patch literal 86385 zcmeEuXIK;6y0*Q%il~Sv2#AU(2q;PmB`P4&rFRhNCDeo-u!4YqQl(1|gn*O~T0m)$ zE-gSpks6W^kP-rf@(ug!bKXp#|?9K3bl@ZYu{I&dJ=@xYP4AEOB@_y0ZupZ#RNl++PZo~-M4MgINI zK^2`Fzk%xyIqDffjWpCj)@~3HD;u{Lwj#a|_xnvNSMJ*1 zjsOAc`8JD{}Xm{2y1ZUX}Hz(uj~JIk$=CAqOF&; zr=vU6(arVh{&lThxWS-u*RJh1^dEo!cAvJsj(@h~>h<@>0v=Fwe~;)rk-MV*ac$sK z+5N4cXO6zMF2;(E5TMV1HsmFw#AN?={QubVXN&*oOrt-~6nk**?teb@KkfSOr|Nsz zdMdaf4;B3pZuS9{{2K*(fvpNPhIhE{rtDBKu62}AuIYHFHQcBZG~GT;5}Y( zRMgS~mcS_6|2wD;eBJuD<^Fp0%mtBg-vb989#Bzyr008ZVeEL-4W;VV(T3%U*GbhULHB8edAPk%%>YCii=(!F@DI8GJDQ%{yN;o@Q8NITYk&4Z{HogcqH+| z`xA#^PJ2;>caoFS7tzo`7$NQElBhy#aCTz&b^eLa1tC_ME~w*uVF{MQ3vCfvvJ{Qt=t+kXI{jTirF zAK9Wv~8G5B=$6{?%9i>Z?CT!~eeaFT49MyZeu3%D-&M zKkA`B=hy#_ef7oi$W8Unzgj(;D9i=6GVKA#!lEL$qNbPb*^)`XXq1W4X7CFUii+4NFfTdf>RndV+UXAB`#&-Tz1F-G>7Q&!eAw zds3nQIbgARtRO#~^E^k~hi7?pEA2wAm@#j!L<@h%q_(7E2ai1Y`mjX6X)Jhe$Mqe% z!PCl@=e5AUU$IshTEfNsibvA0jB%L-foXEkN^C}zx%UWvkkhY4hErBTOu1c>ZGug} z_GVA8raxRQedf$Ry05E01rFVCqPK-O;X7v&OuNT?Cg7PvpX~bWu*H5rgRp$Ql3Oi@ zm<#36rjEJsjf#fmFm@H!7PC5n@}Zps1V7<=lvI{Z&&z1pmG`Fp9ej=0B{>n;=2W*%a9%uR zx$(LdScxU|`Hb)FGHTUCs+-~K;wdOZ?tIcj#ksjM3>J;89>+ zy?2lbhd|0DUUWfHs#g>df*Au<+$dD~vUN9jzA8Xg!0!EdYR*uJ+N|NFgZT57h760LwPt~=%@WGDsAP2^CFd~?atRFqLdfHS(>?YWO#*w zel#WEjW73lY%(YI#&zW0l*L}vj)1p5jid*u4yZDs2JfioAI~eQ11tjC*IZ@bnznOJ zeZH}%e92!=(^sE34tr$biEa(%5qrDzlHd`(ukOgzr_jIIWCC%(dZlXo<@C0BSAxMla(MF2e(jd96#+V2Wem`QL0k0HZc5*v#9_??< ztlZ+6aIK}e=!Wzp8c@mcn_IWFsfG<(`Wr3;t5EKc?Q~fRBKxs9n}FsOPjhUUa5-Tz z)QM$ztMh)(hk1IZiBCFp3683qIK4OaemA7g?T}ZOc;ddljek}VfE@% zglNs?T#9ItEnpwLwPh+eBXHr1WkvdBPhjcAakd0Cu^$cCiL&RTJd<_+B|+z-sjk zj9WIfyhy=cLSaeraDU~Ly>v#MfJ+Y@Z`uQ&SK#&3&DVqtnfsV4x4I5V3~mWy*ZR7B z4suUDnf72FJ_e)C$0m-xJ|%J~Jw*bNZ0bE?OSRx#JiZ&6ZEE$7{qcREk0mf$6l5sl zb^7n)0iy<{kob{iW4k2&g;N!af(@HpZlg{})Z9g*=dXTm2!J=kh_;F5jXQ;VA=iTv zEQ2<`HuTs3+F1&2U;xV__em@?Z6X<7VvZjF_p679%DCv0#^#cM5j&ULX28Zef z=Gf9G;AS#D98FKJx8x08%jSh;Y|jWZ?REuM`|Q3<>nEwmtd~2oW!&OU$YspQu69mW z$W9#f8s^xrX5$I_+>MC%i;U`Xi!An=`Mo(l`_uSdC0 zm4MeP6`m_Ob@AWERfgk*c?x2td`_vF3qmYrMQ7ME#1E# zT@+hHp=kj6f7;smFFX{4N9qSgWe*&*<15Go$E<9MmmAklVp)8@;^NC<3; z*D;s6)^-jZAyRXzvt)ga9agXqT!EiU_3G|}A>r+HXzyIN{tnfK^q`_*Yoy4;hd?nc z+Cq6xaRHK2mo@9iyi>n1m5c8cz}%qDf!CU3aoj#;8raje@yB@s?_Lfi2&}#nDW6Us zb>Wd45=|PR9MUz6_#sl_01qrPfuzCkw`ZM*>X_Xx3U5lW^`o90gK^(8Y!Q_R4f%s5 zd9I<5bOSdn+2v-F8d8BToK66ISj=oQR^4&T)Z8_Hu3RXv;tXaYD4=Waqq;#pLg3q* zGoV{Znmz-}1T9UCbR;3$Ab(n*qbFFpWM{!1EHuh7FIO~ag!^z=*N?cVh-y}#H$#5z zT1;a|>>6Odm6>Gu*s0CrF$Q=ohN0r$ki_ssWXL%o2WhP7{Io$*?V`FvMC8 zrc41ZEWNYeWhdNXuT`>MyFcDZsb#MUL8dfj`TL@L8nR0v!xDet8P6V03x8FmjLNc| z_YL*`s?%E=v^X27Y|;Vo)|^+d*n?25ZrxDtK<$o&At`9 zh7jK-V)~c+wdf4pXcLe<(sSI8#Igw|v!NfG*Y`*B4GmI>#LDHTmkP^=P>z;F#uB8& zy}4mB;Pwev<4E`-h9JQoeal!m7?_6)r(MCAnwlv2EeBKeD*Z-9WJlSiCSbK{KWro_*>N~A~5zRtTtYY)ue<%%Z{KO`cQUtXpqnDCP@_QV(P@fPsjP6(?&R_O!Pi!2pqymNzp*LA(=H--Bg!!y@KAlbQ};7A)yF`B zmF0oyfh%n`AZ-4FF2F~}$r?&%BTbiMEspbBwJH?fWD%{J4j>@AfG{bEhrC6X64G`C zl9W>?BacQD1gLoz7I`#$wsO6IN#9=En z+Ox(n6lUB+58mVqBy;|19jYbZ=*@by{}%K7Jp%gh;nJ-XL@o_=>LM&qXBO~6S1PSf z-*AfjFzh@e|5gz-8F}3Np|cWhn20tpqI6({OAq%w{ zRRb*NMUAFxv`HYVR5y@}lqZrc=)h?8t`E(&;i=8$J0q?h7c#lpcCwrCoW15!bC1PXWQr*L&_KAcbNd28(MKDcw@jry1jndW_X=!Wl}GvA40g zsWW%OTCV8qnf}~K7V$Mx13xAW)8xJT?YR8M>IGjOW9Hk+pZc(x00$POf=s%9fy4WI zEYp{8sf2u5HY9rzEIyhNav%-9KEjo}$QF=F`KVn!#fENNFDL7jgoK%Tw6N0(+^MWF zZx`$7H6bw_gmlVlqJS2@%W&=CSeDcKZ$*64XzW^jiglDU=aoEWCJ(*7)JiS@@P;jk zQ|eS3qM8D%m=qfGA)>~gVR)QhYd_)aW_)EIWhV*RhR842ZQqEb`>X|F7s@&)>sWtt z55(uSlW;4`TsXAGp#fnf-mwtS2P9td6Dbx=LC>15Z8djwUMib)=B34cTG;O`X8&)L zes~R2AJc%kK+B)8Rbr~ZR7!ffpv%}xA(O#$znJj`M}k2JC;n`XBJNsTyN#a(kMoTSGROEEZ1__E(Owf z56t2W1Cokd%i@3q(W|T^1&MLrX?IzzCL($CB`@kKWBZZ$QHbwP7%q_8S}V>H3kmAs!d=6gB&Dd8aOycv7jq^9ab=3;Hnrsk=}jP+RC zaJ&}t+Spd}$c>PTX6}zj%Fj=b%m#T{61sPshQFJ-R_Ce@3TsV^+IfRQV+=1WnG6|( zk6Q=5J+ZA@Xqz-6tLPkgHU}Iw520+?&+yQ+l@v z4dlT0_(6da(i4s^S2=oRf=hj*NJe^zd5rAr0gWl;ZznVKgPOTWqQ8m*G+3eO4?Pk^ zK*6~^w5oa8&v-2o&l&lRUmLmZmOadKX`Jaj{#m?JmI^}D(TH{O)`$G7KSdAOT--8a zX(-)V5tYkHERI@Mk>@t%$msmlV2?E&uvSC5F6)B}m%{|o9ZU>Irz7TiYb;=c`r_bL z*QUDNrqa3K=Y!jcmo-cbqrkfxA!KBc@@RQvO(;stEy=N;s26FdrA!h25&Y_DLOzr8 zSZ56)(y%Z1m0F&fs4hJxV(*@jX#fpPohwDA_Vqjckjl8KD~Z~h)R9cudu7D%uVEPr zD#{d?=7}3HvdznY_Nj=|IpbWTi*HrWB|FC;yIf`;j9T~;if|s$CdrcAwG^75kn5o7 z$2}dlh*KEZ4XAk_N;iR>YQ72R~z?=dC6#9Nnt{+^|$SHthPB z(ZI#S%cZb@;(2`37{%A97rL8OQ^UicT?!f7I<@Gudu^x@v_ipHcX3wpxh2<;#KYf4 z5og%B+1A1|S#U>D^4yZnxf^Ut)ewk5hSrs~v>^g)KwFuAFix&}h7A}jmOPadjX&8Kp;Pg~G2(@mhIc-RNCrgsI(K`n+g^JZ@3o@Wq=l+Xht#V1^?T>hz<*;*?a}~sUJj=n@*3wy`Fj~w1HrjQ0 zrDBJ+Gx$|!_hg#Z&rL}h*VHBm242)wW7A2KPRtCk2vZ>Gzs-#f5<_dCW` zIg_ouKVQpjczLUcSd)|PU6dWt8?N<(8Wf+2o|3qAPe??DR-v02+H);hfYmaU&Ua}j z$!Eg8O|sK;ZSu2WwA@6SqA>iPc8sD~oI_s`cB}Cs2R!rZ+Y8Q^u{<{Pnv&R(vXbnD zbc&&;t_8L-w8~;<7bS<#_Q_))7Qe^Wzi?%+@Q8lJYt5_U|UD`V^TK1afRkK-w6xXgl=VjtQP@ ztC>M>)^D_kUhIz5*TQ=_7nO$SUv?!XA=nLz_@0+1@PbJth;aJ?eU+#E6&}mj_`zhB zFyxzJ@*BmjbzYBv>K=yaUxRkfZCW1=Dhf|0+WLxpOR|+5rL)@XiXEmXgG;+Mjxrrfczf4LthEeCG%kWwp-zkTrCaxQ; zaFpn72L?_seB+HX5;CVd>KRcXU}u*kMPdHc;Zk03%+tlPDHw=aRPJ(4Jnep%WjWKy zr!L4TU*zGs+^-@2skY*gQfs8wEew{zD-iQzYCK>GRp9k{tyKS&kfU1Kv1Sghxfaus z&3w$5`u(oB=8xkH@=)l?uwZcvB6nRqU3SEdWyw*RY4PTLJK=RhSdnHT2WNM4gS zT@r%q`R;0tVibjm6NOfwj7(Aq;4MQ|J?bT~d5}J78KFW$YC(=ei*o9Tyw6=S`Pr)y z%l1)=rpt0a#Ov~gdVo;4v$8-p8fxxcY)n&Y$ippfmfc|lMFeB>i)PYRyG^vxxsoR0 znGUsO2e%xoZ5LmnV|ysls=N7{)%82|^FHg;AOe5#d=BP$Bi3**Go(#w%2 zi(bGBx>E40gATFpct88~v*1>j^$%{v%!D#J%%h>qzBVmC%ePaTW#`drwupsCr|Aeb z4*2Z|YENy3W>H7Y>l@A%J5?$Igf;)Hg!ASu<_mvZP1;=SjwKDQ>X}WYBW#>Sg?vtD05(;G&$}T?1G8kcm zZTBB{Y7d-bjJ?ODT)%SZj`PRy1kh6xZ;QNXrtn_@e+TZ}6|?@Lnw1b`KkU&Uvs8C~ zwd%FVOvT&78c2(`eWlsc%LY(SxgGy<9O{Nsfxs2##BM$tbM^VqfIn0o;43#aEVWba z_$kmiGz@3O&0Qe5>RAbtdH^46y5pp5;07ko5=#rRtlcEa-cmY2=_Ov*mrrgRh_-5B zP=G!E9nFT`KI@=zl}Y}|}EDw}_C9vg%1#xC+^@H%<(IpVk2 z-#%0kCVNQ|LT@MiP{kt-8z|#Tb=8{tw*4D&iEbWWyb8!ean06BjQ;BxfdDpxY9XCT zPV@P|hIXk2ya3rQO+1H)C*v;S^v|PbteGzS!EFL#nlOWNjCG$j{=^6BvFY}RXkqP+ z%3NOWvPrWJfFCkt)c0)Fm3rT*N6oK3%g9_$cAfpwY$T*adOp1goscgXJgBmTE{xuB zkIFn5B-NTgp*T9}_y zCa|@tzV@AygCDDh(jA%e*!<2K^b@+%*Cdh$-dG_x_fQqYMcT(JhYH7AyJ_kJsscA| zZ5FE%E4Zg-*x-EaTUp$}=Mq(V;~$ZQ63Qs$5_$O=!H#Hh4IBx;JDy{(A$?E%09wZ< zO7Gz1E{OO+ad??pn9Q?v}tfxdWEr6q>1MJ+F;I^J~w zmA)Z?wRG``Hqj40K@m)>dca&p?L4pB`$_WfKhfq3FJH_LekwtIC5AkcK3yyFzl==J z7r@9g7*+5czju`Cp@tJ)@pshOc5o0AvSs^pXVFAupwC^+Ae@jp?SrRwvm2UeQ$9P0 z-uR>Kro3D4!W5L7qddk(T~+S8Q5$jG#5M39JYMlMFlM^f0}w>uaBOdp+EPO zbvX#b(NJtbE1H)(*KmR=l4^sm+a9fhqYuN1tun2cE|viSTe+&RDu|C3xXPS=U%%F+Af1e!%TKK z_q?M5Z%H3eBE}6p>GmP>5E8zu|7^WAS|I71Uw<~$o8Z{Skut-^Uz5#yM>S4Il)H=4 z5illiR%DV^)tw@-Q+K|7Ez?Gcc02Z`wIvUv9$=g5*2GXO?G-~D_jU>*o(I%ZAo=If zalYph?JRA|2x*Je{75PRnq|<-@qGiAK`^L5^K2U7;uHRbZh3yPeb`HdZSkRlz$ zF`r(=u%&bXqrhoa@P6bw?!+P_>y&phWFU~tE3{nU$Cy@3kvb>nw&e=uEwN6$n|~vx z%Hfhsi)CF&@7XMm0NSaNIU&^n&ZrG-9f(;)PZZH&t{!mNj`&DFkT!DuxZ3zi{1Hcf zs7j|k(yKmvJxbr5>x0o%=#Ds&Hbt}W* zd_aXOg-5(t`J^iuwjZQyyjSaGXYn3DM|b&|K&p5LxI8fEFi5yl}~TqhYXicxyg8x-xXqz&sb%-vGqgZB(T&lVw@d zcnK$%!yKyJCo$&8@2Crj#650U>rA-rLu7&+^1yC{B!oVc zU$4%B%v^4DoO3cf=Ezfhk*3h!5yngX78Wn1axXoE8JNNU*VON^>&K=Ph0TAm@0kNk z4PSNhd2gufBOLpRh(UjgzaS{_ncX~UwFeAVx1?`a3x%2JPmM(7$3qMOYc;Y};3FpF z$8i~VlE*kqsnEHJMY6)F5sm~e;bZh3=*mP*&k74rH!Xe}79kKEMNqE6j?bzFS@_TQ ztQ(!TI0T&N5e<=#d&8_h9MhH1rnYcco-V)DD~4q$i2e*1iJia=%LgX&su^ndjl+p!%Fhv#*I9i;Hgjp7;vuI7?l?I zOgw1&8CvsR_7v+Sso<-q+_?>;PbzQy-A*JZVjaETn#FKqg?}`TNhmwT0Dnud*_o=JVH$ovz>>04*L+^hMw7UpGJ#CqC;Xdiw(e96yGrnuT>tNt58r;U{SG_U z&7kx`TcB5Taad^Q6Wpz`a4mv6vZyRP03cmcwcLLs=PXK398O%#hE<8-o=#teXCA#cW*ihua7fD z7Arr|Z_`~a%`YRVx!B3LWm)@XD@Rx6$C{=EEOQ)|;XaJ_;DeMvmsM^r{#0OP(c*>v zd_+#<;^_gBwC?DH^XWsC%WWzw{ZLAn#qS{xLt`EQnH8bUivEVmf9lGOe$;VY-lD+z z?fN){BUlEL&!}I57ot*(mIUHU;56rMfk)yfQ=AjMUYeW}o637Gi@dg6Eu2>SQ~I<} ztbmp`5P}rflJinV{i!I9^vWF~z#gS8nRo2Vl)4!Y=Jlf60o4z6`#op3&#lS*R5*mR zrbUvU&DrNusta`ULzqf?6+19lBT_%~vfTFuti?>{U>|8ugnLyV7pZyH17%Jz6qNQ^S*#wR;0haC`dot7%J889$6A ztJAoi1s=G<%S}P4}*;bG(EiY!swpj%Kr7+TUP)?cJ_ga&h|k} z0swqoc^@z!SkB6+dgrd_L|E8gDu`Z(Ca2#ZplIHsqbz|zto=8jJNn4 z(wChORq9|9weeZq;=Z^>(uRJCj37<6dx}jn#;zc-NR0N?(dEW@wDR@5;Z<*EY>~q- zC_n^`fhhthy#)?jBh(dTTN~msh%N^{mw6MahWx2NOTuupED(T8e)XW zbR|A3-80Nn7EUX#(DA--lz!R)$aSXHDcfP;-4i3L>-D|z%~9sK5@vJvU9GL1!gY*^ zYyIY&%_ja#ZDhcJPQb#ZMI9tpSr`@igtZ1xL~(7{pe%CN0cn{@uXLNH(fp-))e(!Y zMau1K@0FZSxmU`tX;om5wEPczY_K}}l zrju~If?MZ)-S1Wt-2-Ga@9j$*T^6sY!B4a*Svo}I_4sRy;RvBNW{G1%p=t185{#6p z?-ox2II7w5YzRBSAKCnJtwvvEV8!s<06O2HstvkgA*7I>OC%KF>PcUcYZB0+axkHh znMX;yOJkY?s;n{vG<>)@7zpX%JM8u>rds9|t>DjHwztAe(xNhad56MyJMGR+#31x{ zYd>-~Yky_ucY@TA^H`RLiv9zS9az=z@Pk&ttw*@cZeFwuqoW-@(PF?}=I4dz=E>GHS0&2zQ8VY&jp7SE@ z>8F?8DP|5Ulf+!)cAm1pikH`U#GUlUoISYfeZG9lna;GuWNYMm9rG|xtDiWv>-}K~ zh@V`I7zXFjTP+hIFLcE;YBA+lt&ST`-bd=>(i3 zDDC=Jzj${xfw2vX4GKj^-KdoDbc9&c40g6pMd(XNcg@k(A;>TjeD`dkWqsa^5>YZG z-xVEQ0|a6BV|5_A-s1)u11s2>cXL}j<&>$$7wV#v7tFGIndJ5}Fh|mz(EHlRjOhu+ z0gg)ZBt^P+>m}H-jMa&I2gc9-rg-=57)0|vDVS8y;eE)dTm``U9LOpM7i!(+l&E>K zGOxN{zeIqA00MSYBenA^=6ZdJjTqR>tjkB5u(HxeBP(X%pA}cWACiPyg1`0y^ITDQ z2Z0s^IU>uV>9+EaW0C))O zdeD^6(!lQh9@+9-h_QDH5qiTZeow^axJLy%9H2=(XTvBWf?L{d#3`MOmUv}i8B1=_G%AnsLmEJ z5~w-Tp9RSgCA{#p-rw76AI8&OoRY!)WEDUJ$Z2;>i6f8mz}`ORmtPtX-Boqh(|&Dz z1x%}Q6R`zyy(_l4ooh~(sq=BL(i-knMpJpn>gz&F4j*ET9H8~I`a}qII>H-Y1b-4} zCh3-84Kt{YB!c_c&@VVpm~J=68+`nZ#-FhM1`+wR=AA;|Ww)|v$(9yFJUlbHqkXba z-n*VsZ0IfN-JP=tzPhp}DU{~*EX^Rp3ch0xT@Jrc_Wm*EkQlO1R{W9H zdCRy<29>VV%%>#LWb+D%^T2_8bQhxs=6Y`Yas#FW4?wXj;bv7om5I~4r(W~zJR$+meeWp0?B*cf0Q zFIfyN50-a>ghqR6&Ki=WQ7(WFZQDkDZeDE;GockpT0E&;WJN2Z>bwz*Zz{1jFS|hq z6^@YAE>k-Iv}lQsFSW)=RCJv$7@%5=uY|1B4T4hvP~ytoI`&a^IE$?e;Lhl^mkvMo zIae#-b9$@H?Q-MR0iA2ABg?ack@jZLGBB;6bE9fkGm=Hde{DYbd~BRMo2aPkL=!7m zcE$#!dBe#(Xmi%%zKs9*Ai$!kUQ=;aX;Da06Sepu{ubhp%2VsV+c7mLX6_mQaK}xFt=Uh2atYp)V$Lh`lz50t}U7GQ7R;nJc2MpN=-iEEhI0eYRPZT zR8fA;I+Xhpmuu=z6Evk=Uy4VxXct6L0l_ChXS;ccgI_$MlsxxfIfw#?tvg*ZLZE{IdcjIW>2Wu9?uE-y8>LA7 z3SoLn16S+c%&MM*wlfNi@4Kn4DidS|yMEE=$)?e_KZO2@)_FDg{uMA?xgZtV{nO_` z1+Ka6Pr3AmY5|FSNL={}b_rhcd*gF=K2oiRkpF1602xSH$ubLD{SRA!twXV z?k5yG+)9=YqK$g>u*d{3^O5dTL(`Mz=#Nk{#T#_Zi3#TtY>cQo{)ufjre+kF&n@6@ z1AyosO{lk91qJ~gz$9E!GZ3@wZ0H9KX@<{w zo|Cs0mR-ub3<7?=Iv%r5N;AWdY2E_}?Kaut+|XyPI}-3DqBp>UoKfp88xMhw8gkGQ z9h(5LQF2kDW(7xDM6dKo?(2i^)@>=Sqz8W+9fWuUe@4*n=umM}IDL>&pguUEvKvZrF6(f(TP zrg?e#*AfXOv+Vp&qt+CKWlA!4!tvrg20f-r4#|}cHQsf0VJ3t*rR$%Yn-bGKS}#}A zbfR4Z!+9eg0MK!yS?;8iW#Dp)iNTVY@oG|L401~GXOUxyoX_?$F4UW1IcQTA^dT7L zWUa&vdaybPt7kHvp8m?`Z=;|$?l{bGiRNq4_C9jRbea#Rl(eu+RbtSs*aX6E35|#? z`>ZI)wajKkhzOdy^@<_uF9ts{2zYQ2HVAUr0)_-R)W8hn_ZXap&|hoOR+w*TIbG%o zR6VTv_BHapGG6teR0EX*4;&Mx^i-ns69)miy}bMIo(YUwTrSKcG9$F<92lQleY7}8 z7E>mNi{vuYohsTLpnGd`-*6IlWy*@myw9e^(yhViTHq`mV#hXZ+e~VV4Yw0a-`qw1VpEpZDNG4FJ%>b^hjnmv8DXcR*HchtIVx0a^1GuQ#z49xfp6Jz*|Z{A!41rG4bxn8BM!Q-y%s zq3iOkC7H+jOA;@iQKu7sLn<}9qQJ%}3lf@U{YqcBT&t|o!eXrUg}TOcK-g5_SL-J; z3Pk25X<;TS)Z@Ww$BI08?nXrxZ5Y;>N&~ePYPdal=7M$kyXHsq2kZ?Dys>>+GJP@U z*-@t$zYZt_FURIb&lMt3a(H4R;Xa>y=1v`MdhI{essy1JSYAjH=gKPi<&+IfTsya z#=NQ!D(;RUaSc%3mfn14Gm^EM2f!DuVyH!jaj7JO)%M2jk=yd^HzhMX+i$CkIr*5C zbRuuKk+IfFq|=5LezP`d5GMQJklS)ejx)QTY1C_!0A|}@e)7`u>9yajj>C?v5cIMw zL>b9iwLuqT^jq%2#FgSV>DNU?wyx!EPV>4Eqy@9ls@P-$-kUW}t-Jeb?sgc02d+|< zwJ3zFY{~UfnxpZ#sI@Bi06#*cC&e~vnl19N?ls}=uq^jmcn=d z(QWv8<~*7c15YpKZvF%*KWw|qUXW}dS1~)-u{w)6_`ii2o~QMIFe5fi`DbZXorQSZ z5nDMa@}-b)5m0~W?~O~b>7pP_M>%`P8o}AJ4iRT5yR6u;ggeh+P_7hgWggbQ-eXGm4Jg_| zX>ZrvnZ$ebMF6FJC4XgOI$E;XR(8$GKgE4Pc<<$gFd~~x~x-qkor1P7Az-5Bi^~eMn$UfE=@FJw+t)i5ULHP!NHHfqV-Ausg6kKrpln)y@4 zW`#HX5X7FUc?AXV`zcRA1&SGv-#HuB!JlZ>rz5xZt2B$3A7x_f(_5&>i z7G~}p(Hja$vIyZ}1%?t#L2$spKV2<=+@Y4>yglj;Sh)x*GuOK1);!t=V$qui%N#l_ ze%t<{qZn69bb42@wm^1nR&7AdFKa}f6#7S%3K4tz3k~?zr6$Nrre%)%7 zyk1R8M?%np2Jdj6?|kP^FX@Ey``SVz%{vPva5!Oi9~Lq$IaOPs+_83yAKW;lO1ls_dI6l+Yv5bOCDp`RXDUEFG_r z-&2@9)^qz+Vf@OhXs|S9hf?(m2zJbD~y79~~kXeA}wiujR9K(fNY zlfNgo_ERAVtJwl;VbC{oYNmN^qcmGk-S+G(NtcJ%Cx&D}6B&jVrkB^080?8PzOq!W zhjdAqhRhl!-!59T(kp0U3y)D{UKm;CY!IlqaQLCm$r_r>G4t7&@#?~emO`r6%qL|$ zZ7HJu+l9fj-Cxh}PG%uu<7nD*{fwZi>@eKt!PZSuMnkvi7|c$?a88;(yoG_3dQ&o#dc6igId@*<55~s-~=9yU|c$OI1!H2UwF8K8zAI_TVa8%)j5N z9LcFq(6c0z1tIJKoOGXT3*`;+?f1s1C=nztz>TAbNG6#f6lSuw>2f7%qjz0u=A*(C z#$tX@7~8eNgRI@!C_J_EvNHvrF7USucjNhNn$P&ifcI2$7>uZMTpY=Bp!at?A!@%; zqJMToVzzQyqUEnc?t?GB$RC5I8mkxs+DgMJbKl8#q4dvhqr{yzT7Vo95B!8vC{{ZVk+$l7~pE;G3^QvfybPV+`G9fXhccD&4~^NjQ? zejvLCl%~MbUWT@ykX4x@J%MxgWNg-v0FZ;qk0<>DdbT@o4rkd8v-9&+j@kW#OM zNTF6(G-xkL5`BMsV*^>Gc zTpV|cgif?yBIC3zv~LC`fmZUW0f$H_45Ad|H&*@d>&Nvt=NGX}d-O&Z`z8Yp%}&7_ z<2ypOh#auv|Cxj1iz1aWw_d9IAgkMMraUOJ2e6D={S81mSEHP70i>KBVGt(Oi7ztK z)zuGEQ+R{=$H~FiL0ea|9(R7HTx(rR)nt~;0tODC_dWTOMwz-eIzm%YeTA6q^Mbo*dtwy|~nc6R*{kY=H#w>(2zykcG z&u}9Z2p}~`<(t*Q4d$hd;eS=|`u8f>=g(gJ0%&o(Rl9qpf4Oz{n-`7#E4u>6w(cJr z2*^<#?A96{->Jqb4|`i*Ut=D_QVNlcXKwB0TfBT=5NOe(TO&9FaD0*WCmAU)_-Lo< zSg>8Zpd8aiSi#V*8SCVD8Ptnfytf+TI$#}E?~+H-jWz>qrlC{CdW6eH#o@zHKhII*+<}cgv+Nfbo#VFs=EG^J3lNWkGS{xBID$*7b+~vQG6s zs#f1{9Ikmu>`qAqs*8oW1^8W$iMkHYxvW}59Hf4GO!@)TF+Ss&S;1eSiQ_3qUY-;1TxSf_D?8vX-k_;zPj}CW z9a9DAmRtt{b+3I`$V!eLSaZu!O-_piEvI_6Ly#h_gdK6%c*M_i7#lp3He6Ds9(gZy zFi2SUq!VL2z-@SrP;A`q=;vn++wc3)eyR&mbLl958NfZrxgd)eAwfm+p{i!dEZ_vq zuiA!K{mO<(R|I%o*Gxr-K{8$R`Q4wk1K=-K{|k{ zAGtt-e6|nbky8)HZsrd~;FjtQOTWGTKZf|rOSd=TRN&OBxd0^2T{Jk7Qj<&kl9N{m zo6#t_mk}TLQ&rnjH7PT11sJpzZASX4@fPTcJN%3~04CSJcrFhp!3;JPGBE&=a}XHr z$gV4i6_2)!mN^pZ#z&|8YWys%FWB?fHA6Hf47D67k2E~k675tXo01Uxx{!XL8vB;3 z=OmnVczq&x@8fd^Hh`XJuw1g+&Y9rY`c%XxgS^n<8h@`f^t!g1N;yqLRa1>QGCip| zpd&MIcm@z&>iVHG_AA!`flXSiYh~0ZzQJwz+nfKhlYT_Ms$Hs=xq{ToKdkEiO>p85 zE|>SO#={P#Ir{+XsIoaSkV_U ztr7=X9JS&4mJtD~+9g--cRkQGr5XlE3_pK!cl3FkydNX}YnD}hVX<-&*#{`|2+1rc zBV^9J0&s>pPp-IywepOeJ8N0-`XzGtM{;BqtFkM5pKytrhs2Cc=~Td<=wu~Sk^rE~ zuokEuJvG7QKOqaGap*Pow1jx!T07n?&ayiqK;6AfKP@IolEw6LBEVc>b+}bkwGZWm zbae`u@y=9sy8MH<^=3IPjZ~@s$KHR2HJNRH+_)kZ zL`1QmpkhNhD2NEsvC(@AP0)aJ>Cy!iR0LFdN0bsEASLvoprCYt5a}R7q)Q1ggz~I= z&Y3wg{>C}y`v0HndF6}YVlcULXWx6Tz4lt)^;vmZM*WYHvPtbxE!H`urwWL?LLadn z%9?`aI)^W{8o@bS9u|+rKKxS^8Y2iO)A#0mG7DmFv>_a3SdAvsf%>Z3c%q&3?!$W) zkMpY@G4&&L!SoWn!Up!!*5McR(^S%isl0!$o%^G>UF+dSqs82(W?E-sgF^gtW0EW% zia@;VtLu)N)#$?sH>=-034D94`1&iO#<=0?eH7M)t9!rE9k&D7pv(2T#|{uV&RURH zY_WKMVerJ0h1#P<-VwM%QqGzhvzM5Sz5qC*{EI{REXes=$Sfn5Lyt`BK)q@4*OVZ! zB$>%Hm4t~EwFh<(-EZNL$9d*4kSO{2vmj9@I?brHq82*5I@f5jCYViqpx@1peSfPybheUzYPL`y z8mh4RAlEhof|4n(N2P#i7UIJ zrcrxIKOqw$0JfY+HPGXZB7VC1|OiUZ7%E%iKhyzqHi$ zCmHxq18iXLl;<$rg-gO8IfepXvb#Q!sCaXeuc2h?L65}w6dQs_ni4+qH)aa?{E=+`?s%}06tlEt|g%g;1FdZ;_&(H zOMNK)#wzwG7!Un-KFROWkxiV^DP0@v7F~1aiIQYd@}$XnPxI-!HRDp~cl&gmCrpF< z|2wSa_lvbW&A7C;koOf-@s`ew)R%+IA}uEb@*l$Dx*{7H|MrXg9f{_^Y4~zmZ0<6; zxR`iO3yVPNtQ>_MGY97>U?NR9ZY&b;ECED!nxBpGk59u-25>soPl|o$C)a?^Q*QU5 zw&W22W9p%x(_;qmf}u%clK(h6{yYeq1Ypcw2THr&0d4?c6%I?7I2j4+O31R!03xG^ zI?)YCnWh3nt^*a}_GJO;Ooze53Rqd<|@5ym2IQ_o^qYhj| zrh-FOLCZgXgFpXkn>M_stf9H)f1YsC*LESx@-4PS?Ei?WemaWMy6~O}ZV`I9AE(<- zN6}B_*TebsaDJ_vUn}RAS^Kr;{MvJVIRw8Pg8%=#wbMg1Akq&i2ZjIygd{|Un6eQ^ zzO9@FDhFQlg7@6NzBapF27Z2)8>>R&e{l&pxujEcKa$#hdAnJ>Ujl2bqSueQSTx}NvT3MgT+I-a7&GXocKldR0j?p1Smo7*JMTQ)) z(F7C^$MA%PCJ3dx{Oz=#?Mv+)>{}%dnv}(otH_mhP0?pQEXL}(sO@?JCsyyhTM>LH z1mz7z_XCcj5kZl0#m{bH`+leQ_DFDlhpN;xR2>!7Si0w}(m*)-r2zn_gm`>DKji3x zC_(}Sbs?!UunA-cDBaQOHIe`s;?iZ1cpSi6;mQd4M{eMsj}Le)D5sMS%rGWx+Ijvp zLLD2vvA+AtpN)*fhzV63#85&fDjf#j@U=n zinX|G#rVOTPH&6XI~qkkTzDJ^dV)CSbJqaA>FNtqNvHHU(}06;#N;D}cZv`Uol5K3 ze8s(n@oVWe>Ppr=-o{qzBmg=d>-;$G-3sUHT^>Fj5X6u*Q}131SsX`>SRAa=Zn&h6 z#Mi*?kmC(EawFX-ff-bW=oJ2SgaduOzN=2!Ke+aOl6&+(I@Pz|z-Ac1WxX6_;Tngq zp7Ig5k&t8#^m%Ujfh*?Zc{QGpmMO(LA|{b0BCzuY}`($ zTPJxh(S2ZWVEs&0h%5nmO&^kzlQMEcMEy=|K(U-m3}WN#M)YBh%)mDxcwSaN{uxAS z2PH2eD7S!lxFUizmfrz*MYoRe9#KE`9E{5JCb5{NcKR~|Diien}ocn#CD#l#(ZNZ<9 zbiYR!{{6SMVj57PJ*5KBE3Vt28Ir_@T|b$+oG=3gx`zF=tw(NO>o0;MZOHW@HV6n^ zIra)+zcJ*7^DBH+7dQ^}@+fnEJlG3@^8*pIZzji{ebZ+_LJdwLB`9947ggG|U%OkwQ3Is9W5C z@C98#<#@v(0p#)f_x6vwKE4t(X2-f&1=NAx4!*o7MKX)f4ZpE4!nR&AJc=-K?LZUA z&}x0N+_EFB6iByR752X&_+~#^Xe`s)8h%75Rwui)sRE?qm@ik@L---JW7>{HlTU7a zw}6|Je2AJ=0I1Vr)AgKLo*qJ$t`I3C?cxF;{kpuroM5BdW6jD2Y|Ysgy<4;1vz{hb z+uALjF>TUj+x*i*E|7KuUPFP;NYS7VwJhSJ=$6Um%-)BniEGy&+B&GGbVOCJpvezay^f1xFFwdNuHsea5jbX+_ijIZ?OjW8QwEdNFOO2bq?Rzq zl~#km{C<%XyzQg|_Kb^jt?*icdC z9VQXdC4C7_C_6yovu+&m*2k}~xRPO)!Nw`oVvcxxeSYl2JYZ&=q0%VF(}=Oqq}|id zZb-|%Q0cnK;n?P!6MMmPN zKBD9`$hA*93kL_Zb!zkeQ%9Tw2Wb#GV!v+He8ek3F~(aTFEN*bL{Z=%LuTAVlvt=o z4IRflCrw{qCG{a+xGe`%n~2tk+LHoAlD>`@#p(ocyzrICx8*nLUjMLfucX8G`zk5T|^=MXd2B!BxVR%23jp#w>-skEf&haJ*{%?PadP1Pu{k)sa3oAX26R|lkrmj^6#42Z%@e6|#m77+O zwFE$D>$q28OXb=cxtj5WE+0S;O?{YfG@I7fcpy5sZQEwWGR|I@ue;jST=hqxI(!F5 zQ-N`Z6HATC1%1Tiyi|X2`Tq0A#AJD+s!QCw@_lSI5?a1E2UProQ0Tra%ugh8Y zN&*dJc-1)V3P+_XUQ@Fyf#sB{$u82s$6d+DHs61jQBtpjg>AVl5aEsPUlQ#xxmlf> zvaH=OC6!sFEB8)fUUf3}yarRmz`Ay0sL$eywt_g1c8wHmV|4-F<rA)ihb)vi$(i&=?CesF_CN?V9s-SZ9^(T2)y?t(Yhul!MZ{<=~ z?HzNgrY2kf0?m}A-9O;guN~M7D#*lJPf5T9oN5tYP;vt*%h|%qM5~?R-(KGuxOl=* zPgbY)-nw^9i_xTMF%!;tr!4Wf7RX}DzF8dJfF!#`c@oOoDU74HycXgHcMur4oPOnRNJ+ z*(HuTWZ$}FhEEK#*KP%s;%*fh&{tGJp09RwF@0o2_Ex?wKYi2faS~`IQlH6* zSzdFo&X6O0OSQcXHC<&UE1#KLJgPrTswX~bJV#A96? z&_@WxzA(zs-r%7FyR6ksVFr#E{^j-;#siSs3HQOwz_i?1 z)u&-h!vIh$-enMF;!|1<%$BfsdIG-9k@gPR2XU%xi~5(SGVDgFQd;E4BD*B)AVO=48yLxU+VA-&ly*X z!O4_o#lln8#_BY^GFuPtWjYkGVo<~xV=%}Z>#fUWz}9i>2cPLr|NFXav*bFZwHy?I zXSH4~Y#YD9r`Vpq@Kvljd!gylyA~tsSUb?&^*w2M!QCpuJk9e#A%QPkxw8kj<$cW6 z-`~AbI5+X2<^I?B2Y{mX^YdMY{DC#?Jy}3Lu^V|ITy+DdU|eS6tw&6I&faCu`&y+U z#^Wx3p%Ym6IWupIJc`vBPkI<%D1Z&P-g|Y@gka->1|tZniR_faiDjZ%m5|fBC1{*A zAv*P{c4Dkx>qNiehht*LauTY>bOm%265CW-pNTqa=vHDl$GMHd<5T(R(aB<+^wAUQ z0Zr$WEakAck+xda`Iwfno~Po8rXX^UJ^0(AzGB^kr^6Vr$_2AX7KaJT>7= z&H=Ht?<2KEQMr$vrFIGhGkKI5*-+og^T9L+xxV4o%9pEmnaUP@9tACJM5!{&Wsswv zg*PnPXbaa#L4Ec8DJM@UOuab!y$pgtW!h*Ka#QK+`jO>CK z9^HN2Tpu|ObBv!dus0MeBwz7}RRz}oVdBfqJC_wpIz5g`Qd$|ju%EnC!SuAV-^Q@8 zauMCVG_ow-PW-f9;KrerA5K~=X}oyifXuGV`^K4luin^AbNSpRe+Akd`2sXGo3$QD`(J#pOw^Pb6m_9?HEEHZ zojhZS5`n!E{kRHJ1s-3EY4sY6ucsX09AwnDOH0;qiW3#-$c>M}tm#xKf{knv``T&f z8r@3t@Tud##@A!GBdsl3roBY!kMsF5EWr7&%4c9!ijss|Q8idVb)s44TGvZP&s0-y z#Gj?1-L~tf^qmbGfB5Cv9Q~KjPa@DVpzK6xy;=>b@))+yiE}tT#=~gF$RZ~Fcm@tM zH@b*R(4X2A9Uk*sFivfJQpxvXqUUw4+fO~yVq7>&@12#w#=eo_qwz=aaV|`!`rJj? z)<;80u53&^${k)-E`zomOR4 zMBA_gE~ZDnjX>|T?I=w`9FES0q>>k{pb)c@hxwh-g@`s4`wO2RoS0D43t2tEM!9-; zg|qRbR#*cU-L)?P-pjqLOA2kgrhd_1wmAB$G)HQLaydoYqPcM=LdEkbONFgehP)A_b9&2aMdCv$p~4{8P#=CPUC*+NnEv?-2jXcJ6X zS87CZ*Xfoyyp91@(wFIC>B5I^CL!hbgdHW>H#p3H;1D<)VGt#`GhYewJ#3`GIS{Q- z#V>=URdyUOU)j&Ar!8P#_+@x&M% z5dyW4*4Zg>^pJJd&1eOEd(Ylp)Lm75DPNZ-)spYsBkow!xYMbkrxY8BVjb@03ROxk zKG~Gca2$%W3K}w^!I?ou+@wD}5(sj+>xXZv;Br9x9t3U>Jjkb$%mH;WXzy0E{B;SN8k7Jg0L zpuq)juo!{)aW}iK9OpZF=ifbzYoJ@bp@0LQ`P%bKOxxr~v$bw=iIgy-hCZ+Ht#B1Ve%sYroBl8n{k58wzGNWtAy5Op(y1ai86SePwo7s@WzD9w(YYvwg#yI{NwyBNKbah{DXYE2KU*oN|0|Zevs(SI>= z>&La3p%sb-*3L4c96zvULAN7JRcT_-?Sj;)YYlwnnKeaREe-kh5TMKoKG%~WtW^a4 zGjuSq5y?O{6j#&H|9WO)j7Brh#(RgzHrRU-v6%kxu7d#AAiPHvO)l^ZjP+AbR; zwL*kqnI1P?XX1VHUuKDIfj7Y$@myc}?j;2|w@$}!+X=$XyzgD0Lp~NqFxQqkG*~juIcjv$(Fr=D)PX9_);@ROl#j(G(Wwa62^>*=AtSNJSe@4}c|<%(A5#8s2NbKN;Hb;t^B@PucVW=qfV>E zwB#127=URZD#)TJO=%5g+{qEr#+lZnEjS$`7rci;tMge+&m|pm-XdrXVM*#Lu5zhM z?y@oMb8Ha_Ew%AI5b7i$)e*?l89QEX)yP+z&0{=Po-GnD0kHU3!WLECGY2VtgI;t^$GTMGJu`y9l+GNWQSY^A^$0jxR>7BUv;88qpg+H@o8TvM{nQdFh`-Atjj zwcO%H|7+m9`MC z#qd~+k_Iw$(T5sJlN2;DY9$bpCse;3^Wy8c>B?ZmzBm5cwW0`1=7mxLLbR5OHN91Y z^r(_{d;5A!nia2R(30_<0^MSiSj^)4t$?{oD zOym@1hZP!CelJde2X;tp`TJq!Jo|7+7_%(YJy)r<)6lMnh`9>YyBvJ^dUWRDB1d5V z;T}#~{~o%Yk)9W8_o$H9Bs27eLKXz0i*3x3>yd*)4`dQ1tt3k`I5^vE=tf=!AI}ip z(K?SNuxH?!&-H4R>KoJSA3d*l2^*8fi1a_3$UHmuH9t3hTINV&)CjoE8oFcS`N6Pc zCPfF(Ci-bwcN!MFT+PKy3Y4Vysy9SfUPz~|gul8(NY;_vkCBXO^+GMjnFyBA3VTqE zX_$>_9-#ZDr0`zn5P$KR5z|Do$zlUBTTzXN@W@cAyvWd^z^m6K$Bnh)zEQxO{&YW~ zWIf*LG*L0vjm~PckMcUquis0T7uTubf-f`-)0Ds#cKf5GR)!fT74#|Mqh2KtSekUI z&Q7Wa^On$V4=rYL&Ot%vvgeB?(E-C;akk59JiXk+%Q>a&7j>4-MAe(dCpE8;0(N*( zmjL>nxt7>C0*XokbxBr==)}?+zm+m@HQKK2dMnu`lS zU*+)~N|ZXQIw_K^4IXg6IxMLTN6G05)&YeL)}sJF=byD0OR$#^4b$7cD;ql4kWxk~ z^No-6b~Uf&x>ZgZHNO-0W!b*XO8IqisGM4s*8ENS(>JzfYH3()!71G4X|#yV=I3J` z8rW;4FQBa;7-5*!=+RY>sBN4h5Q)(1T_Ew)wa+AN;QMr#W35-t#u#vz3wDNvPZLWt zx=w%S)sW!PHfz7UyOZT#TSa0I$XPS7MLn5i5a*xR&NW(6PK(Q*g4B#ULVqDyVQzNn zJWRK7;tyrdKp6JgN#Each}l||CF@iZZ>J|nl(unaWox826nJF{k9r-}X$-$HnOp61 zIHke)x^4B*Mb=J*!B`?y$uFZjRgo-wmn`rQ>NN`0b&9PJyl+)^hH&Sx zb&Uw7tkYAOlW1tqo+ij(oP*Pss{XbVz)P=?DP+!FSI{b1E;d`S(9lTg^rpmrDTrzv za>hRP^+#il<86$1tk4PbF^vMlbbJh15o^t(D@rD57u-Cl`ZTwpk&JF8SkprCG$u*t z6vig9)m}0eQ$^S@tjzamPCmw87|$2CzR>C5614kg&gqF=a3<)+XdFn{s|QJ-3lrTD z4V8o`LGryaoi1*2PJuD3nVCypBxMUm6HU$Ot*t9%utn$|VqJ;p%nCZfs3v=fiW$iy*2h*nYV7bXcQqyfb%VgEc1;Xgfos})K zqfyVbu=0(2@6+WM;xrEO42Lj%QP4w|waTSQD7}6ev!PW$OO$!pc9j+vsb}^O^0*_>9o|2}*o;I4Xu;nDE@o?S?`_X`xRJt5bKV z3*FzIeeZ1Wv!mfkdqxdpf9XODD=-nZIKxFYDZu5JVjXA2CY>3qY@vEmd!|LovU$S> z(P;ULGFto2^#a@8c+l6XN+*%HHfsRYd-Eg5L%Eo~J<_d6@sU`q0%FG`aqxScNM;|Q z2cltWsbfR6t~nnb;W! z9z(-fozh*E(E9RVH|NXFYgKz4H`>IV;yUJcZ?j;kSy}+=OZLuZBS~CGpG=1c{%aS2 zjfqE7{M2G(vP#vpg-ZrK&Zdv%eHPA$kcId;;%~{iP)4If23dL*yhPL5dl2_i*I_(= zcs{5GhILMUU)H@njvS9rUTdXL81>z$}fm%Nxnh+P3XA4#fu$QsVEcw`<9f7 zrn4@+&c9g#>X{>>`IOWPNx52GirT5;J^bZU8q?|qNVYKGA#@y?g=QQ=;g9@~!*b^1 zcS@#BMOC+hf4VyaOP3O!VFzJQ7H#hee-qUh?Q9T$bYG*<&Bj7)DfU@Iy4}Se?=vqbmXttjCZ^Rf^O8gamKI(1ST074KXKNFTs~>p#MGzo>CCaHipXmyLYJ!AWWubMMTA-l z43~(@OyM*~^-(PJ;}Z%vukZldxYwbQ%6Z)b$V*zVXW7m6YD%dxyB@5waIxYDqR9Je z(y663JFkVC$oBg9@yQSdHt1A-99#>_w5MY{wKsf$&-b|+swR=nI}MF$S`n5t=ymQ; zo~(ZGo)V*=(;O*n?;l_pqmYX|ZZ?3ZJhBYgdUCwc6SYOxo61rnCKY8v91gn1O;Tdk zl-y>{QDYVEWAvgQxl&ZDp*e$rkI8#smjlz#q9s?b-yzK)p6lrO$KKsre&#{8DgTz+ zr5C>u5I??MZwk6!kSgyB_);2!Y@8vJo_ntERxO608O%*S+M)4;edDWkrar5fAkY3SG4L7xHeywh|NK{OL14E;` zeUXAVdeujkSKlHh5snXBtI@CJP&E4yuVb}hr9(JF+n+U5YVEefK9s)ir%68`k zhVUGC4cOe&X0oOw&%R=&Se|`fV`qu( zr86hnVx;x#6{T-QXzO4QW*~P5YY}c`^l}Rx*g2WYD@3X4_s7vn=3;?P!KxY75L-QW zC~&Ov5!!+-ezYL0)jO(sFV`e#;XFhp9*;Qtc7I$^QF=Hi=we-v#C{-(U`{W5jxaf2 zp=3(wZuKczMri8jeT_faR5sFF=YQf^3gLX?bmO(g>J>#7AinbEXD4?ZKWThG6L$VH zhogPUgiY?EnpwZo<$FzCXUj_0;HgiHdxP=HN6@jya`yzo-o|i)#p2^34=NVZ8%O2syv|aK2sSeI19dYh` zrcB4Ly1dfb&ut4`e>3jtyYt$nWeT8U#FzG%eVBJ5#dBpuyq$;Cj()_*RhJB{hZD9C zx6#;)2H8$-`sv2s@=RGsdb=|2;6&?anhy;-rtYi(tGOBvzRz)O)KFy?Pkr1Yv?V4x ze3xn_HdUESe`(cxB#E!+Q_62~iC0(ClBXotSc6;EsSU>|B*wwt7{D6UAU8`aZ?;`nizqLM6c`@?SrKA zNYrjO12yZMB>cej2BHw5UQ2AU_>0wS(ei4k(r7dZq7QH0YrTBb3R|%5=&B$a#o4rL z{$TUjO!veQPON@}Wf_KHfv_VcK$zMjFiPPSHpuCvn`6Hb{q4pq`C_E)mY^IU`(#q0 z4q+ZeS#IC6S1FD!BVrZ0PMKsN=v+c0F$uP4cb98?!(WtQVjN;ECz>$(s9u6Fk7#j@ zhd7`~udHUrsV6bBue^%WNGc~{2o03zxvvSb=Vn_|(Y%6}%!?#6_cfMI)t3DvK!4}8 zroaquqJ>obb(U>X$UKq)@G$xKz zkWhFpn<=Giwu(BD6u17R&XwDEbc+YdUtnBL+<5+3RA;ZsAk+48h(PM(6C#hR9Aosv@oz58;^25J8d&5=(!A>G~Og&FXzWAC-u!kn*`c1>wla&hxJ z7*jLt8YzdS#gJ02#Kr>kXzLPw_$CieUSc zX5uWQW%1DVODE|>^CJUvscHPkcs=CHP3#4UH@T-rD-_tAa?(bH5_icPM5h@Keegvl z>bWA|0*YOYIn#7|jo`bgd$`T(W00{HnIku1&X(Q^&FVzSHeWSYj25nr zCvVACbYE@>e*9rhRi!#=sLdHmk=g)P$vvdllFoFlvCNjJ6SMqEbV>Be9%ID=LdOV> zRbQx65W$TBr`X3khiD;`5g|!Dk7Oo%1K$-G)~C~>BR9D0Jb7%Q8&$>A&>2?1XCPTl zcQKtN?(*-klE2F#Rnft|RlM7J+@3C_$@xS@X-^zrufeDrwXhOr$*o5sdGgjbmF#&?;SI-x1DS5w@_Ie&B^=D%s*~<{sd=(&IIB zcPJj1g6Ea1a(?*Um>GVnVEIeo(-_Hu zZpRQLR*$n@AjKp`9o27{zE{*UW|C;RIzVPJZlol&$rZ2|>gOmOjQXa?y(hJ`1I}qy z1xM;c6DO5Iq~5$7!yR9hKg}`x?w-(|l837HW>t&jTB=s`ec@v}}8W*UxP@F?N7NPRLKZ^0UY*GPg#KAI%WvKk=Lm_Zmo0 zT3aKTE-DN?AB@aok1-bD8_rRB6OpVg#V6yZ6c!Oj<+>mJh}+1Z%LpBzJrUQ5*)Bxb zrA$~=Deg0rNbjBT@~qUlEq`YSvrS%!7?!M_Ca1LRU zDAW^Uj!Ivvp>+j;-vpqwy$|5EbbgB|bi57m@Kq&DTWlZt&8yfM$sM$SYj*Wn z+JdVaJ+CS1ghblqasUO|S>miH_4i5Tf7yyf_M;hiX2NHKlD_jsUJ|b?Xcjb+weElD zjK{ox#?^*Ev9=dBwGJC*SGJUfbIa3B-Qcc5ddCMXT0q;JZ0N`Om0P}nu~(TDcsaQDekyJB4o!s*V-n9^k9rhRX$uJXY{BMRs2p|x z6V_g>D|D+~T@fcN2u-F(ETof(KFfkl=tc)LcU_?pPb}9dMJZ3R_HsY9Ea_*V0>txw zeD_Xr*ZIf(lDm$ccJu?uVXhbwj#v7ygtP?BaXmYq(DUXkUB!fUZsroeN0v)~)*U$k zb>2CG&EwGEph;}1fMD#Y9aAOHp@DRF-K1PjVC;d`dTNybX2@-UZb=i6A|JQVZahbe zNTIBtXdb)CesFE6I15qqCw`t4ZFN_{K?tgAUwh)*V+$=fYj~2NNqR-z*lmf!brgLX zwGpP<4TULmmJsrF#KVbF1EI={+|LpDnUjGWd6#$X>YUirm_oa)$=|J-)6Ls(M^F?fBc7vv#_)IxQiNZ2yJ(q>32|OTByWcHpD(Pnt1BfQR zj)%;^*jo!U4ei*A*LAG+hW&6H#lzbL*~)WU;ep)l*=7SLcf8m34-&Rp>il5k{%o_| zaT31vuDFPQ8k#?yO4^L1)w0SX*#Bux|LNtYpTP8%w$VBM)6f6Yi>2>KyTM6jMkn&* z|Mv3N(jX4ml&Amj9|r5s|J1*k7FTAV|Lo6y`-kbf349QSx905sWFP~?7R z|MBI&2L3OP{?E_k*TDa2TK#2|e$Dtl&y&B5(l1l zzf8@a=gD72>HjxV6NLVdYFsp7)}5R15B}I6GxhQ1q3YQZ@3X8$_`LnQI$0)%G29U5 zWjoq`;!jWMC-=C>1qUULPxpBFGla)B>HDMB7uIcs#w756NR|93yx;l+j0Ka*b7%kZ z<^FT=YsN?rCTo}lWw99-a9K2#fzB$p4zKfv@3$ZB3-zg1^=I!72lwnn=ixyJQw7g{ z>Xg{i$+EDq{r6jVeGiExzLyr;@%J86G&8(?!7MspKwbv>JZz`iU4K+9sZjX$SNpkw zv*^m4`;)=+Q-{k3%et%-bHNx~KZy*)OV5Absvq5MNRK}8b3X9*_kLOcak1Z>W~dDF zN6B#s9jaF7_TI zE^%zr`03yO@W)ku`{kN3+<0@4DT@aOe5i_8AH#Ml20};IAFu8KH@3&9Lg6o;_@8TB zJ_eWHj-MM$OYVveeF{GqpUnKOLO691l7zBb+D-qMXMg$Sbp_mbEOYJIMHTqa1m_ag zy`3yM>Db@jzO9fRRI%l^BYraP9?Qbz_hyb%DFC#ys%dY%; zkfL`X>WG6^17-ex)E1B%H{Q|Fb^&IKbNjHJnwoVD%lgkF{$na2zg*jkEHkyk>$cmif++`JjZ!v@ANltUUXz81eCo#0 z!#~M0Hqpb4yPr{#n|+zw)g~WUE+c~-_UK{x{rgFm-$~Xf57PZ(&Hv@2@4V)O%U^yj zw$=-eY~c*kuLX`wuU`xNZ_VPb1^)la0*8u`XgAG2c9Wl2h2PAa7-aul?!~@(hZrPj z#5DcKD*f#v`q#2=ONT|DbZt+5$v@fa-%Z5tM(vsx>`QfSHj9sc|I6Q-+JE2hf7^n8 zyuZh}U?snY1SkHn7yV`D`tdXWo6-HN9oyhH2YM-%!Mu+`Ke6yX+|Ga5NB_^=RDq2i zxp2;3h1{+ny`;|5x%3%i$B{pHrbUgpQU`292g+h+K~lzROgrV%)BUl0EH z$p8KU{}=Q3H1~q|FRkUWqtjBX6gO* z`ym`=AJQ{LeoJ?PBJy}n^V0y5%*18*KC=!W(#R#w^8c91=GB2+?_ERnZP&G}zTdXu zL4DR+wg9!2C1n7eLJhLkGn*{L610$JAXx}p=1_UMs@(ohAd1Y?t(?%tBXqJ#kc_d- zciGyB{gygLS*Avy2CE>En+cT`RU`=krf2~gz`uBtvMnUC#MW09vXJKS3#q-=2U~rY z(|t3k-|q`}Jzoaoiqz67Ugl@c&$T(aY!DIQLF%KA6yo7$0Rg9rboE`W9|ETNrHDhd znjiC_R6I-yV+_T)UF4U!WfPebK-ZYCCP1rn1*p(b#7}-Po`5`MJ7IZ$q8D=Vl&phd z1{G4h?@B!$kwO8$-Wv}P1+9MI-s5v2>1!Ek0~N{{MNq2TMFx!e+=RgTynw-A-1IvD z7LDSO{3pVt#{+m;toO5=yOfAu5|YG5df}0PRIymuj8sZCoB{APPAG?Zp?cv0X)~pI zom!oRbT+F)SBkRu-J2f(d41Q2*W2A!v_La7d(*Y^(*%4 zLVKN(v@Sa0NVky~-!$UCnSR0=T@_kZWY|d1yB@qOM6Mu8fo|gJ0gRPoQa}5X;tLs(@?ZZMa@10mBb5|-A`|9 zHn-wBpAUtKI#BDf*lD)DWWTPH0O$}+DNos>;L$EDiob83f|*$DG2@g}{(kpK-g&?~ zwBixf_j^zGW0Qzlfxa>ddJbjfpazqVoXsFXu|mEF04|$@)8)S#Hm=8bb}ekr`^Tz`&@`%3a8!PK$P`o);hho};^*5tUnF|pxbFxm&Is&#;4#W*pfP{Yf$RgZrIBAIsq?Yy&Cww4xx2pQYk+0gtw{HPaqtCKl62JW@eSNETB2quTB8gh1{(zK`gZ9S4v{8Xc8T(_!Y-YCk% zql2K1n>U(wX0$>d+9RgCO?-XRvx#Vo%~E+awOreW{C@VJ<-sImsU-xSSa(_giAhNr zFi5h1*I2pWvHz#CgHtBSw78in>db^cJ%5IU?Fa8vbfkV7f96wvp;T`K&a$w3lxQkm z1^mApn-WmAQ351uZUCFp(J^ z&hay4Ymn07 zHW61+Fzs|;HY#os0^@CmT5V5esMqZACF%NVClOS&RW*k#7MewJHDpfh8>vf8of><@ zlXD(_SO&|rU3Gcy)$iPz#5AobgiA;4(RNwR9 z<~CvbM7QD%t_C!B5z?Q>t7V_VM~kd@UeNMMXv5?YIi7=f0s7F)T}TfN$Al^UUwnQqWj7!*`KDik$@_VJTvx$qpT zB^R5MqT^$E8}$ErNh1Xb21qxbgo~=5(i+dVLTQoLIUUf5acjG3P-a2h>9FeV3+wd4 z?qY9a#~kxGO7@tS^8Iw+W#vmQjh~CN#vq?{< zq>}QqOd8jSid3XRQ#VWD8`*b_=({|zG`P|{s09~sG+HKgfLl>GXbpJNAE73xD zy38nksLhQ~Kv5JQj-wQs#wt|N|T1Hmqg!_QL{eF6!#|!1@YyS3XZ0jR=Jkeio{ub_;~aBjY3>}Eyg%< z$OsdQFEY%@qEsGF3g)ji3AeR@hLCumr=6L%gdTYpF(eM45r~2tCyfs(X=d?fU61*I6@1ma%``$yr{$KHimMX_0H{z1Zdckm_~i`6%g_egUTp5| zo(|7}g+ym)`kcmW;*~d?U-2z3)SDM-M}5YDhNy%%Ivr^DIk2YkNI~DIW2ofvO)+rh zcucZ99HCa{YMf2JeC*AXhs%{sJ|V0k{D?C*4ya(aYQpC?p#TZ22t^(^Pn15iy6*rS zWnz^3_u;?}V63v*Hy2A$KB(r8fPA)nmNI`A(3Fx0Bz8P$eZ>N)7jF2OKnICuqelvJNk0yGaU1e3|Aw-sm)-4^KxUvzN3-{Te3HKc;UlgjHyoDu_{ zCh+^>B2iWtXDBsF-@IQh*~H}5j)Yq^bmNL9%NonCw@S7Z^M@25{og?bYvkSfE&`QRgvm8r5haH*(-n9nEWYgQ-sumJHD>BSpF9oW?O8|^< zB!#`(JRXP;uoIy9@OZGu0B6AV@DJMrj}&;wB!gyCsmQgaBP|Humsmk2?|I& zV}bENt*o2#h6=EgIr2!;F)@JUvL2ZPB(5@2G{-~;aD3rgvZbgebe?+Z?MMnbJ*ia^ zVTwL<0T7l%ggw#If5a7(rYfr9a~D6yKdKXcguu?bLht56&{p#5{@0vW6%^`bi&d9sbKu2HBdLm#O z`xl|C?er>8HI45R&xb4P4&cp#_#y|Lp!}VfCwrs`pfm1| zgB%zHY0l()Us-4+W?w8|G;OkibA3yYA)GVELB?xURFWGk<43M2Xx)OlEMK1sr&G%p z!RHimtJgf1Cv>ZO9J5OGl%bqr92V8}4mD<;M}7p?yC^W`+B*&QiuiBz+%kgjN8es^ zxDZU@g`L)2o#yzCj6K;yW`|{70XT_0@O@?%^W8~p8SFO7H%XeKz~o-JqrL0&O^a8h z+G;_&-azyrhR}K+u*pd+%r;9WwyZ#R>CubZhM*sl=cwzq8wIawpeio0YK@dDvcqb( z$N8_xcbEI1ARu)S1mt6*;D(L8rBaR$H$IhF21@yj*RF3jczAt8`Y*|Qb?-_US>1iB z3`FT2i0ia*mTiXPddGZ=UqJo#<`l+~?G! zB>3T^x9JtHuc{#*p4uUYRj02{Zf$<9&=RI2!&l@0s(&T;&v1qe?afZ@Zs!!C;RScq z#CQxTix8;zChPumW9S72D`j}1<4`u+DrI(l3*BvpDyQb5+b3>aeqjP6W_yP36`%LS z7Z;ugIe&bs= z+#5wgghJAFO}vG3?>YsO9hFSw-@nQUmS!YaE%~uMK3j8krELZ2-Q`$7x5>fw{MUp@ z75o{wYNkhzcJW_cc@h$^Pv-MyV!gEk>!9b{cVKR|NX?9#DZcHb(zMnh{HV^2&b#K! z8`52;^UHW4@ zI~QnuU|4slxT8+0@cUNXQmI~jMn8)x)mBjRJA>iEE$oj{|H*QIb>gRfRP7MiBK~*{ zYV^te5yIm+TrVKZ$_&-+&mCnM34Z*JQFkP?@gRN5T;MwFl-gCQ&mTdd(z*5F3sk0& z@6(S%4|F!%TT6eq;YxuF_LQ`X2bcfud#u}5c34cF&Cn)wBc#xdbV(AX=q~D=m6X=a z<9pR@DpWQE1=-;zd3!lC_UZ7-rJl@O)WQvKvr>Qa@XoGjC`D+($2>~9bSH?=9h#YG z%**;FtmHNGARC$P*jaijzJeyfQkLx|URoi?<8^Fk(c83kwH$5Ahna$d^5q~cupb#2R649btME93>Zwt@spVldSl)Qfnt;qM3mzQ4Mp+fD8B@#_5 ziQ9AtHaW7>;J3SX*tWdeUr&Hz`P2PI&L1npbN?Uq-a0DEz5V}{kP?s<0ZEZA=@yhw zk&qc;0EtnQp+S&tL`mJ!UBWN}2ug!UIG`Y%64D?b4bpNxw@-W@_isOb&st}#v(`Cl z|FKzH5$3)>dByvBU2pt?1TR)F!*}_L@1u9c*aK7oLc(=&t8G>Y%Z;M_@>S`kIQ;DB z1QdHFw&kNxwosGN`iUiF=Vq|V?7Ne zYAEmXclWJw&<l3%+Lk36| zbO)ksy(IYW*#k^H1=iFa5Wj6eFT&2_8&^Cr1$k|Ff=x;|gCl{c>HUt&K0l)6SBG~K zsEUFRb@`YBQ=70Si!??5RD?F4O_1iusY z!`vkfxUxMNZ)O1gUw`SA$Db=<5XbkO7ti>JO#OB*pF`*itv_L>ueGw}k-LnE^CdCs zgBk!CyIsmoJ|$!w|8%3kTHFcee!icK)RCE#u^)9&zj-04H@e^{`k9)ph0JIO2}+GD z&Snohcvai@RMT5f{3(^{A{r!t3rCQ*i2z!(r!Q1=4i4}){6ift2q{R$k3gk~T@|?q z6%~8;ZMUhHBIqxDXwfxqomFW1z??niJR$S18UCVhz8bfE| zQ=M>CHYB@E1^-KoPH9g^)H;YDf4#9f?wFIyBQ1YYD1EO9N!mtGa%m#6;0S4kqW z;j^DODch;nc|Yh5ECRFii2u6U9B;8J<3OpH*)qWN^f>lmqBqosdcKBjx}CWneKuO4 zDe@@uxJ9~NEYpRd@eY1H9L_1LL%LR~=+g<6j`>g&*Q~ymtm03klh<&21I1)0u3B!G z^?oB(S|EcLB*Q%;yO=+%II(0<_hVeb?KxRAG^4vO>Ib;@0zN?3OQ<;k+pcK>slR*g zG@BI6b~dqu@4v2+%qxzhbj=8yBD0pJz$SHJSgCAFC9$}Prn7w8E+|g_P=Ee4FDngl z<&%M`7=BCG2mEVFIiIz}N*#g`98>mOc)^IMyQn=M(7=qAbVE&W2uy? z&?(Lw7panjc%!eqPhcO=s~JFZs?9OjwNGKxGr&gTw}8lOz7e<@OubB!R}|kcu;fx7 zHd9D%zYV(`AKN)Qj7-*?r<^AyG=C47Nu01oTM3M=UKEB|s7X+!`_;A%v}y!(Jf0vW zn#6P80jvJ2LWda^c}eM&$Eb~NL?+k57I1V;&O2C*i@inWU^F)No$DVm2}fM>k^OLP zIm*N|v8|jRO+Um^d=qvW)jwTj<=>@za-<${t9t?Rl*&LXd%-mlnJD{uzwvtJQ+`{m z=5tYPu3Fp(yPmVS<-z;Pho8$@m~3Zp*$RRKVOg5a zC(f%K@8XRf7t_9~dk5_p26|%V3no3U=1$oR)P^N9%~?yp3(~6*J?QgO+YfB)|c;PD2r0heb9;j zfz-tkPB-J>6O|J^>)(BPhYaN$$aU+c8V`|QiX{J-7U*rf2wAiNI4h?#3_E#m7i1_V zk&uvkTwNp$A~L;OklkBI-8t2s>oYa}ASbF9Dmj03Loa8CY8C1>ZKGuyv>&`UjvX={yZQ(5^4cMvwg8hFlF=vdhcf8!?UC4_G|=cJJ{=jkUC zj3|sDgMISWq6cQpr=augPUK?aW~Hjep8Jgo^Hp3PjSnBf`|#Xp`lq)@yQw^~s2^~Y z1d8C5SaUQVJ?{0Fqd>)dxw9{tr)(!7dB2XY^IRUEAkonDj(TNCR8_pw_yhM&jp3Ip z{ow(8@G_DxDO*Ce>dtbqP)UIVPFVY$ z!E-<=YTdyGYUgR9O1Z4Z&r3U)DhzD9gSboEo5~WZwR=8!`~uw%x$Wyekju?dKLC}U zPUd=y)={#+$=lkZ3(vA3FM{{b#*3jtgNl*@REn(SB)#o{OMMdx$rP_%je9<2r^gq9 zha$od_a!!klktJHlo6|-Lj`3XTMj36UyItkBI|Z(FCn6#f)BV!rKnRkU`96E^&aV& zD)MnzR!em*x&ploP;c36I;%Goz>%~b<)3kx%t9$rzeh2cW@isV9(OQ(VQ>in{exkp zJT_Z~mZyu&7^T`CSC%kX%945tfY}}fSr9gj*ih4PWM0wbK*0JjWW?9zasbav9x4Rf zh_7(S!KXX(qU6%7RB9BP7_4b}?^45Uhcu#c4}X?7rNxaz+Crb09lb|n22>8{$<~|BKIhW}^PGAa(vu~4gB)Knq=N?nr zDRz*^Gt0TnWGU`2Y7-TNI67rvf(=4IJt8P6D5N}8uEd021BJLZ)A?Vet=Qd9dKr)4 zHzb+yn3BPgF;HAWtF1?zVl0BQV?MjPGxc*y>I@6s2z%{t5Pw1)klUl9)&3b(JMFV5 zfu>(W*4)NeNusFT!6hj33O#u(gYN3JsKMqfLcApKW=xRKrEv( z4xK1mW*QjHw}qB5R7yZHDJYPZ(2#Hrw>X;>qts5tS=_BorfoeHH;jM_N3iY8u?SL+*Hk){zxU<+W~$$EG!!z?=|8=J0EOyz(~Cu096#S|2}yScAND+3q(VDi$&UU3UvbJIH73VPUseldD%B)Bm&q_BSZoIGJH;?DadWr8 zgJGxwPzROGz-{JRt07q-6ECA)9k1V0GKSu6g*}^FB!x1H-?8_OIQHpO&TQCjQaM$x)AANErmPR%lkYyB;~US*2$bp?ov} z!d3^W2;k(q)qvyHir#))7(|_(>cUH@tE`R+^pJzXEmyL~K{Uu0xw~N+`EI=`t@I(V zFBksDzSNp%$rCf=k5gfiBe>M-xBI5?rXE$9ZIEL>xz}hN zZ*2Y1JYdcyKU>ZS8+fR`%(l8fN>9x?(_T|o6s06kKN|)DE*n}#kz{vul#d_g#C!_G z?1J8Eo~Vyd4gZu&T3tg7BY!GIp5DO=s_XEG61stK5~tfQW4?okz67xp=1bq#YV9Bd zbu7{hc5%AZ7{2VTE1>GOO{*U$h1x)3!PUQR!P9y-rz^3$i*|jcRagaXifPibLvFO% zcqV^prj!>nL`)xsqHL%c1lLSh1X49%WBe6W-yv|%zA1mbL0y+5>j~)2H?#=+-bHV| zyqw%N+QU9Hg`uuM6KrnDDoUyH#X^{u&!(~Sw+b@U##x%6u_J;o|F5v}s&p4S7LY5C zi(|xo8TwYOcFs(~u$O7+k^h^nb;98+5@ z7EJGdmsRCeW9MCo#7qWeCm`*}waH7mTJN zp$>0QOS}UihTx;vE3-|SNG7>KSs>1sTkIgA)wO?#0ZTy=C~)%E@7+LS78j>of7A(rJF1#sFTxF#sRTZrO5(GAYzHlAp#q~smY97S8?Y#9leXf4x@0?S2 zjApxE)O21?vSh$TFEDz=@lmpNMU-ey;hWCnV5^P_*`Ei+@)P9iv~XWR^9A*ng8bIh z>UBR3mIb6CKkI*VLtyj@~8>phjugJWo8A zjFR9z;dPK~c}G4>K=kGO5g>a#WxgZW=E$^|$&jHV8bZd`5yYBt(4S9(myKD(H`WNb z6s{tao*#6Upc}?8^A_TP@(Vlz>k4;Y?H*0u3gbdaV)y_`i9yBQe@S-78PkB7yEN+xDeBT`;*cq}cV)L*c0#W3C`V%R#?n9n#Z`t( zCr`03nIVR47D{~m(7Tlpe^occon)V=!;2IF-nKF6E z3@*z3*8~KeuEoPM?}yFhy?Y)MrtVTofv6sz(Uk^_9@6xAHXa;V0K&GeRtgnqQXJv_ zWpB)c#a{zYQ+?3eNJ$9uLbT*o+;gjP-W!`@CjtYxB)nn+X0T+`_bF;^iyIO|1H^sV zoP8j@7N2j9Dvt&b7)4?Wqg5z5R_p=uIeSh#uZ~Nr7hPwWlgY5mL8xASF-U`unq)Lq zAd?()CI~#uph>ROhrWF+gUa+t#S#WPzhwQ5xKH$T$6Q744FT>Tk-8Xre(fO{9G(6w zzwkD7QBT6ztmm@xiCkeqnitMt?`=MRWaYx%tY86Q>g#SVT zwETM02eNsLB0J*@!D@3ZW!vY5~3p)*s{uxlzk zq8rmif-1E)aw2c_TOWg*K(wg_)E%i0pB-$m2eE1wQT#H13o;i3hgeg4g${B)9JALQOw0o=`lYwnZ z=WPp1)rGkjM?d+AmNOjE%$>>r?riW9oeqV}CeKK={DOxyOFc z3WLUAF4x6`i^g6PTqQQAf!fy{H1UK`F@`wOr3z4%Kn-}SZ=Z!fHX>fW0Q`R8q+#Co zsvkmUILAmXT4>BvLxD_BMFr~-t-P*66x3N&n|X^#ip89Tpf01Eaa5Hz*hvSjmW^?v z{9cW|_WfY_)xs$&><&3M8h-CK`Rn)hkxlzFQOF>a>b5bK!BRbb#^Zt1@g1z&h8H%I zVS%f$uplp)pV%2~1amvuXbxnfDzm~B9le)7DUh3}zBAdqx?c}y`+49$;a7hkW$+bK zO+uz0#O~jvL^0v9H^JaSeubrGz&}U3sKN6{kb2Y+Gl^H2wQ*qWPP^eh55ZW~R$G@U z6ZQ^gRK?!`{fcELVW#)@m6X2^$G8_?o!g~sokz zr^`J=T8T!PT6veB%!Fn#29`RM1FIdkMqTk7$R^L)DIZd|TIVbwwcarYUN5dKVj6nq zo-dj!QqbMvd>d|7G;n{v<~OU8SCj^SmL06~`c#oj61CD(I8C4WjGja2P_$vw_YJv1 z0oMs9SRxO&Z5h$nt*-C#uY87scNZ38cVYXT&6!cN>u~N24-xtoi81ji$J6zq4WC$w z5=>pL3(8GO1tB`{nZ?X5I_7cqLNem|oigTr&Qp(onn614kEjSqm8?xaDg@FgV~M+i ziQp^w9nfVo^_YQ+PEAdW1?{935TJV%jtuYxY2CKI5L|$dvuqG??n4u}3#WY@j=^M> zUmB-mY5w&lhD4IAhfdiMy-UipVLW!5uNdxRfi0@I(JowTSA0=FbyNl)AMP&3jQ}_C zN(Sh(?4p(q`ZT|sSamAa3tR$fO)R(??+3-23Fysv}d@I7Q82*{{=c$HJ%MMC) zszO_-39;i_qS43Mc05zb51QTTY00L2BD-%EW>}m(bOM^zIX(s1d8e0T`fw0k#Jh>F zohH?Jzb!$%MDj~lb<2LE>^0s};{mo#kz)Cf{qV!!2_X3Sqs#DnK0L?&D^KyGLPu2z z{34Tg;sX~BP3`PJ8{F}Jr}QWCg_^*IAqMGbZVQ|;W-YEdjN`Od$dGzHTXZ5eRn5tQb4hDl1hH1~B`+IL9Z~0g# z=?Np8k?T~x*yh_*7K~d)I?FF&r#DP}ik6h>;RENf@h@v;fwns}*uzxc?l%tL&(lr~ zSnoM?L+1n1fIei4&JOm~hT%Q>u$ZRBa)cYBSt_yum^e7X8g^!Q?2f=pNTl1~0|Ud?$9HvYJ%0d?Lm8?NgauEjdMXQCbP<5?zP84*f~ zK)vHie+i>m?s|*c^u1V{XUmU=y__-;cpO=zpGhQKRl)WJs*{@eOF-k{xgFm*0rQV=_r&^dK(q5aFy0c*$}BKrl&wnLr*`{ocmX|j zq!OeoO{te&CfsSmQ;A<}*-2Q*0^IMzm4vI7Aa^+An0@; zzElFu5c>)cneN- zq4C$klz)wIAS<0HdLTG$NtmzO7GLY!9>AFM+>+?ICW}_HXwSQnj}La68N?uTahi=Y zyz?>ZIdFj3Us;2W=bBKnpk(W08yBY~NZ6(IVyL5H*ec?uWXcTaJ)j2ZEAtvV+cl2> zB);qeXT&UcOGp0{VIarc2J#aw9kyD~4>%9Qiq6 zI>_DtIWa3as1riTb%)^pWnO5tz`PLV>;}{8X3_?t!?%E5b)ldgw1ge{IBI4PC@9*c zu$2qAypp$o3}j!dl{N~aTvqwTOCSxHWs6{6e~PI+&H$NjU+o21@(+;ctWWifP=Dp6 zgXd(Zo^)UrJOoO|g;RzKhOMLE?r}SV0|AhAR*n4=WH$BdGDU3=?)J0222 zJfAw}doVE6(fI9xZ?vo?Pe8Nv5BkFtZoc*Yc4WFq_C@7`H=xrt8^O#`1d>idU@^Nc z0{x6OXeqrk9CCiny94$I1Wir`I(8ST7$>T}>yT1!5wtVCAn7d~JM)5S8gh%ZftYL? zNcC^6`$BrK)MjH17UyeB%6>ax^%puy zo(G^s`EbvnsS)(AEbg9$nvy>R>fZTq1lQq5zvTaA!gjz4gm?KlWB;`#f4^d(?`DP( zlB0HX@7D;JA*DM!7x{37Pj z>#FwePk>FH{3sXt@+{?dT8{rP{T}jK=wh#yuo{nL8g$5C4np{ky1z_zEco-!cVqGO zQ{8=EttKD7{41lffsk4hEvYihvlqtHpFC;S?L$D0lHA>Z|C(nETs_0szf@nE@iWu> zr!S%vz&wsB`VsvWq-~jEomqJr{EYjF)QJ2~HRNU;7|(|xDOtaKT%;wi z%8^xD<8Hsc-_KtdSWE(5lFW9+5T%QqH@geIT$r8zRkn$|0^B{uuXi8))iYRJ1J4k} z`^a7ibbqz4$@aPmR1>v(g@1kxpz39kwN<|U7q4Mq2cCh6`PlT!&0xg6=gEpvX!sjS zXV*UsCYXI&a`&3r!e8F*AMQst9K561T+vD!1!|ze_RF|vFhX*TU0Uatk7Fc&f*MzN zY9sz*RsGw4;o=7$aIM;Q%aW)Z)ki(!V`6EFc=?<53m2#uRvY}-yHtB zuz826pFg7iTPc_iwE6lc5AD{2XcoJ88 z{_=4Rj8tF+6UluxgZ;~o`j5e4)B_*D)n=A*RvZmaDMo*x3PubFD`9_m3SLncX#I7L z=-mFRU%?EGl)7@lZf~Z6ajta?7}mn8V=TWsu?0DJR;J*zRbhcY?GEUV4?*z8z|hmS z)+HEGzLNY%8mx~F`41iXzkFOv05}N5x<9UF|GHp)K5N4h=mYdtM|=`hibfu)?L21e z$GC48|MC=90-w`@!EhP$`{S>E#V|BdQU}#_2}I>>oRZ#lx>)NA)ARqH^`GO4)P+{~ zw}+36e_1d8nE6aB7>k-VlhHWlevJK4T`jaezNk2)|MGF7M9_f{sO^64PYdSf#Hho- z2Rv~Znr|jY4de-6q~B*6lt*;&|2l4wsbDakzxn>+KPL0vtFPrT_-1KO*W6oBg27Pm z83|VSGL`hN!6(LGXx=RHYukTWFaMl*GkOq+f`1t)1_!8p?ff_eSRY%IF2A1Nfnw~? zMtk_W7X9mj`T4A(+0d$qt(9W4*2PMh>AmG3D#w`l82mbJ7PHV`q?Tvo{?)ILg1#9A zy57E2DSEBh!3u`eIIK$cmnUvu1R)DaBKdJMgJ0ImKSt&&28>1V=Aio=n2J%!H3zUh z{?Cgf5)ayFnJ>oGeqAs>pEZyg42ER(SouCUD#{nbmneV|WM%rzFHaGG&&2})`%-lY-?LC z|3Ik5_ARK^Tmd7=8<0&e$Lg&X0{(r2cQD@`6W6ooEBF7KtLjY^2zo2B0uaz~As^u3tSv)^BD3`qAq^pb@BKyBEsU*Ep`C zKi5Mg0k@Qox1oH#7AUp&j{fW*4f~z-NfP+a`XU%mZ~n3!=G^8~FV zGvfPcEILnZ(|X# zX>LNbP<^}m)5B08pzR=al8DCxeh5f3NvuFt4yhn=mhNTfTj=8ZoQL!ePs0&_Qe`MN z7pqTHo~}J{Eg-ndVPMZ?rrr7CD7pBjXY-&*<~HaqD5Au` zovk-0xm}wt^Brh{sEr7hkKDv{+dylQZ(Ce_$;_$oG{GGKB{3kb$_J`lDw zZ~e`^9!=dsla5RS|IeWe2wt{x+c&G7-^v0`P9GqQn)wv~sWmTqp_=^cv#Y%jo1|^d z=k{+0Fy)k*_;>KLeklVTnLimvWk5TA44T5|+oMP1!88I4%K)%Z-Tlj;rt_0#^1;`uiKjPdAgjkeuB zO#fCK)?;IC7gT+{?~AL{I?*t)&4rMY&jikHc#cz|BuDbuj=yR=Z2)l+b=4hz2-~Fv ziCH+wcYxkidfXx?5HLP_1MbxWtWh2$c2hn9th|?iNGo&lH79oobi<6#;OTeW{K#|G zLqvy)Pyx_nXeKUbWnJeM9&FrieMC_y@nyn6hWA}o@Cjr8fpQ^`GRZ>1&HbmKQfaL^ z`!m=Z&#VD-W{geGavkECLEV{8)}n5$;*|MGY%YMpyIb+w+`oiA{^uzB`G1*8_!?}! z4_Vv!9rP-L5D;au7l?3%cJ-k0znZ=2h^;D6bswHw2-HT4VAoxLw}%rsh@a^MB(=DB zGxyI&EHi$Q%VJz~_JEwzHB%i@{UCBdOv+jx1n79>i8#?XcHzb(Ov^Uh#PO|L`sNe9 zDfO%CKP6J54z>2E3Um13lIu9V4B)%xKsHNIaOhtHYO(r2TlcN#N1Up$Kg(VzU!hKK zoX`CcBQyQPF9Jdh$yPO+<30O!0?DM-+*sF{S`|35%>jyY5nzScN)?zMQM6mc)25+ZCMoN@Gh|1Ji;`Q zh5E}FFeV~8<+w>@eMx6Lpe^%h9w-?DH&^$rT*f=VN?T|XhSBp$4}J_SHAa3h#JqC~ z^at1Y7R=1CZbO}Nup~z0-H}UiH>{iz6ZJ`=Ps{UJoss8bzV}9Kd`1{~>Tb{VMq;c% z&o0Yh1;9Ga?@l#rI@a%7Pb?R$lDE7WkR#smg7p<7+g74{_j;hBYxSr>$H1(5>`sw`-)ur94 zXXdt&*qFYF45a-4sz51I?sn&n$s;RIpmN^avuu%Y41tF)Smw|>JGc}(`;}5w*p^A) zKLIpgTZsG$)mIGkSi9O?0G*TfTgO24M&J70^g-9|m4TRg0Em+=0{?XXElYdU2qZ=} z0IZBLwnD7&Or1HnjJ9Cmv3_%{;u(qn!a2W|Fzrmc43$x*Gno5qn3BaKgKP0$uN=_jR6JAG1$=^zA9hi!%d}rw}UOJ2pP}Ffg^cy`0GcWj8q`Sl(ra%H2H}@BM-HqXb;Tu zAV69~hCZjrq@nkpWF083q3%BbEXWs90HA!dVYdq`K)RIP!4*h5TR%n+SH(!nUjfyM zmA)&pMz-U2J>Km?5#ZNKs~6Eb036N%p#fy;8bV9QNP2w$1hPw?linO@aSY(d9p^Y! zrBUQsYv3@md9KEA9mdQr-NXh9FCd$-D`_OM0XLA~yO&clpnjEW+`{{<>(O{Y3?RyAI)W@^so{_Bj&= zz@;;-KSj%8+y8j_^Mkajp6QFjBx&XHW(-p=A>AopHfR?Of0e%>TMRC4U?r4(Ob}WV zkct+_TdxD~xL$~2>tho%CSri*l2IoKu6J-(c%N{&-3M5G?T6;&8>u;|SP6V4LPqZnky$iO_{ zMn8 zlTr!PzATBeZhm{OeaaHQuLIJU_K`&~itvz9W-{OiS@pTJvu+GR15TKqd0+E2=8SL^ zyZ>40Sx|#0PP~1Ku>7lcF*DoAUhB+?Z>6bnHNY>A*!p(4Eq7YP=-{Iu%ZifM9N5E8 z1DO4A>Q};-X8}R(aJp|_X`;vH(KgjGO2(ccRKge%S^_Sl-d= zn7>K}{L3u~R5>H+uJQa76LNd5#2}>1D)o<2v6c^QQJ|VZdR#);7t~pCx0r_a(YSM= z>EEQUXW2=g=1x3*Dkjjv$tT?SNh0zd{2q_e;peVA$w4D7NAXZQ#x1^R-z|-8@SYZa zY4{?Mo}ZyqQCoB%sBeaRrh{1?? zXM$MfS`LRPn-CI2o21Xy6WdfmRE&5kLBX1ba_}(hVK%k)q^yH6j|wqSU3*js6Fqv* zr7-|?Suo-(tB(>K&Up6~=8gocYx6t#j+nk3wdVBAyFkM0VCzOm1?p|*7!PcLadf>< zfGHZfvndZh0S)s0BBt7lREqisD6tUeg+P=EGgUr-VR-0Uiu0=VY*)f21BJ4jW`L`oo?^tVGmEP>jTD6aJS=+LH5mr8BUW~i(fI+KoO#zcU5ivKj2(;F zd@m;>Mbg=`XSogNfA^7t%tL{|UXnZpg$XOW`)=M;Dw+owTcN%)jQLGQSO)q9rVEC3 z8IyGM?b&g(M|wS2y?d7gt1EglBo6X0bOITTXSFIa?#a}Dr7iXa{^89DZ_l^Jx&jC3 zGtc-Peb#ivgzAwVT52NouWEXR>$4yRRrs+5GihQ^r4O;LWXx9Z(7sabVqOYSIde!9 zDV6J6ZryQ*Q53eQ7XtsBa5Taaet&|>oVF4Sa=Xk*V_D>)86kK@wzk2WF(rGGZe}GC zuSH7)n9+LWwqU{DqwNK5cp8M}^?7&1)*^CV+`1*(3K{v-{$4yi)1mgAKxgMovW>7~ z1?y@9aWVR#mE$k;9%ox`tgYTzTLBBC2%Xt@IuvuMC(+9R?7xn)1u|;fX;#RFq@ijw zRRw#ho`YUb=$Q^uo>Wl_xht4e(GNOCp?K~$XrydQZ>~vH$$jN z0(wS3)ol&wbE@qJ`Mnf>B%Nu6iGmiT7nDEXZwn6{0479K!&bgpS&2G9t;X=PUE= zcGw8#Y~Kn49}1t-N00W7EwJ_6sjWCfuOhwV{}}9%xg(K_&;9ezas9qG3++Xjx_*mG<^4^I(7?7BJSLTNGh3o#wD zHZP{rCzUCuuch-*jwHz-RNk|QQmFGBc~brJWdaJ5AV-*14X6YZ zkdAAhn>Y<0lJEmO z{HDm2Bv8lE2e=7(=i8oM(G_Gi58p^MsUy8gwY_A3tR;Qx6gd?@Tr{4^(5}A2wW=>B zkcS_|#>^r@Xn_GFqIAsQbzK2|9?3J+?>D+)qDkW6z7D14ZI)QfTtpYnE&nsjTdc?g zoO`;|Lre#pH_{k}085fl@8i?%i+E*nQht$8QT?%0(GufAjrXu+7)4dy`<x?a zNDN9(2#ZJKtY6-LR60O2nJaTWFN<@2N;;D-{AH3fQ7iAbV{UK}&+=8vx zWR_+^3M{5{BRCnEHYkW5_!hPTyypynAuc+aNh~0ZP$?c>&H6&QvnNPYUL{QJB`AFm zW_zktG!B2i^?Gg8E=}=_d{lvA>9^bs-;eIjsc^XZ@?A6GKPhv56@^I9@IEdNZ2=X4B%F%N&Ty8I>(rxS5a&Vq0`wV)E2eg(NBPDE(sKzCYa%Yze?H&4|-E z${cU^Vefpe7K8Ltv2I>(G8qV}v?N<&?~2XQ!Hqk?B~$-4*X7K>^(sbDMsvv$)-$bY z1^km`!1K(VOE6b5G~xKri+=u$oBWDof>m6>=4LP{$`>%pH3bSwR4KpdE~5327XAQ{ zTU@9_yY@7bEQYUHGck9Z0gt<0v~3_fd^t0jOy2?`$2{G4jrixn@eOhdd7hXKc#WMX zTY73nzOYkPW^>_6u+c31vw>Kb6SW6HHj{g=&kV&GAoxIg7q9Bxv&?FLB(dMFWSWyK z2+*f||B$BNYBYP3ncj({%xTpry;n~0i-!(4V8KPHV0g`njVZJdT}8LW(dTc@8Fkd9t*(*ustJVx+=xAKN66=rn}!A{yEAcN^B5R zMT#8*0C|Uajt>alX@yjA&cY=}&#%3SQiNRl!Y%-zu})~F6DwhR1)1!g2ldm!4uFp0 zd1v6<9S@GPv**kOCU`_kfPra9$*b?iJs*0H)gX^_Hd|s?DlyXlb`fQe6}Dm{b<4;O z+&fiYFLT3dqi%=6il(w}HDnpj;MW6QtHEQY$D?mPyO(VtVBGv+*yOj5007U2@)AJb zYzM^L4OeP1CcO4woW!BzPM+UgjF=zEIx^b`m*1Dkz;WY`As8;}HOrJ*=TIZ8#c~wj zC`x_gg;ZGxWo6T`&=OFMLFSA@AcQ+RdlA!V3q zKg$;+2pTy}{P{-2vNMY?a(;k2yOm7jH&_qwIZKW9L ztXpR_R^?1!z&yiVw+_0^aGJSxNFK*80^rYOVjo_lD2~d&PLnQt(z!bGw0NJWx(1dX zp_}T7MU9EP>p6*pghDxm?Gb=x^hfOY(~e%sMv| z5p&MesgVVNqPvsLPh}ZT@}yKzOS+}zyq2Ru5q3{@j7>{7B7jb%016n1QJCa_GkqB% zRBJCvAq@9D`MvZ$V_yuS$@-nV++Qog+b@rgfZ|p2{d-2`3SAVBbg?}P>k56SP417S zhJ{Cc-xSm8S`vg*&JXImtf0Gs)Y{u^M<_ z7eZKF-`n zccW9;PnbTyTzSF?KWYPTB_8(WGmON7B!;tMH!eQYZ3PIO7K7u3CcV- zrvC6Hiw)zW0CuD6K*IUE6vBf zd&Gn%&obANh`;>a&`W55VC;-H!*3uW)kH5*BNcqK;YL`%I(}rr)M2Vx&k;`hzJ}H7 zYt}?_^ zyHI7oN=f?hPg}5)6{TwnP?I`m1WT?QjIa46p#SJCbdyt~?W=~;Z#JbkhMQ3E{D9v+ z4%-OxowopNuq#!YZ32BD$p$MQIWi@0k|rB3(be)DwmIbx2RFD5g@}t;rA!OPho5J8 zw8#;8Gn+ptvWr+|Tg)6x_g+nchz=|UPeq!izmtFe@tD~mQE4lwYqpY>bB6e8_0NiD z_U0`Xaf$j^q+IvWwMAvIxCaWihM!E%0z3?hPPs30<9VeR*|e1M>6i#mDa=DX>1Yz-T3UF8*xfdWthKfT1k&6L5VZu5vYD&P~xpu zN<8c_Uon*?dgC|otI+#b9UW3X!0r@sj=Gm$hhtc<5a)3SA~bze(L3K4ZI>HO)sqq(tjIC=aI0`#j5KJ8qEyOE-~i&l*-U^!Hydyk{gD=e z%+n50V(^rJ1xr!M&(H2GtQ#oAgr^qGGXgkBX%2N8Egm)v_ApKX9i0zSRJz=vns6-0 zM7uY5dj`GpncTfrUeLjgeiuuERezGrjU`gulJs7xy0{Gf>w7 zLe<3t(9WW)?n5SPtE~3dqH)Z_-~;xFzY;P3%SK1uU~fST+qBg0lkxFehH290ww*4*Qn{4X3Xc#H-8`yt7SI`T#qcvr4iu>{L#hzrfl05`J2T#Dx)y-BJZ#P_LZg!k_4I#E5m&>Or_!1zg~xRWr!92b+^ zvD@aIO>Mc_ErkdGv&D$R)-rC5~l2&ZXE;}jf_jc}F)?x~5~iCAu!`0>4xWqY>z0kt4!l(r^co?YaSMxT91XY#GnvNMt5 z^97ebi?n6jCX!_Gm!yKf+_ThJufXU{J#$Ucr5tLsieUQ$2iQ3mYSY-KwSKkq)xpiy%#=0w;)HoTp6#v6FM(p9WBKD_6gSazIbnxXKI`*Xneqlvjw#Nvf`qMCx_`6a&nt z{bW39lB$d9(>|2`^A}-Q#YuPpOp?**z;^&VEyvT%MQ32-R?pm+0o*bA4^{5e4z-|( zrYq;$BR^JGMfUd}JzGq>-Ryvmsh!T2!sd09h#l#)%7BHUss}|C?|35KPyg5)BGW3D zb{MN-!3fnq=HjToekmz;YNJ&$Ug^HEYIR}$@bux6WvNj}Pg1?-Qg1G}k8tzNNggWe z(@yd4R<{}6yuq2<>Rb)d!*1NU(mz{z7*RdIl67q$(~svE=h)bf8bqX2YfVLrqS=-_ zz^TQIQzP;Px!{zJ7^Saqhw8se?PT7>=K_(k$j9eXKL|?L&QgEireju8nPbzj090U+=n^4B7@L*b~UOOP? zvxEn3#U2=4-ZE0MY)RJ7_hTYdR59P2D4m&$j6?HCP-(#YSpqtITabw9o~A$QaDH_4thnejWny=>2rBr=Tl&L7GL0TD z(+nLxFuJ`HWn-8EuZ(DmfJx2n@ju4fUk+0IKAf`_W&@%v8Hb=V&g>dj=kkN29sCK6 zG7Q_h(lK2}KtfdqNGSHm>n9BDgEWJ)9$bvA%NrL=#~kWkk!)=$st#lM_Y6$yUmTvi zd+SYFnuFLqbtj1ZgCsjmcVM;*35{eXZO>C0mao?u1GTR`PYif6iOkHTqPu`5A(Pt( zoFq^Y<>Wlin_zJzG$8xQS@LRC8Z{1H4PwfE?;majsrD1&8DDDntPgvtP>Gbo*s%YzRI%%>^H=vgM_l2V z_O3pj>9vhJ&YY|$O(ZFAC#OiMsGK64LPe>QVHi$HHky}knD^J73VAY>w^5X091{^U zr6Ls)7TP41cgkWio9F(W=RBp(uz#LEp3gIX?K9i`yWX$szVB<-H*hk|(Ks$pBwh0! z8DaKrD_+{Z06^8H5Zn=_rh2+f#O8!hGyM$3q;07yb808Y-#;77XUZArLYNVIL_N+^ z-gz04B9EM`)~Z&=u=1HZPmX7mN|^9O>eUi@qYg$FE}^19XA;-f{}b2C)_GIlNCoj= zI0`}xQ&fM-@*8e%8D>!MjDzDEea~z?H6n^=A1W-PqX#$A@Cqm6VNGO2yN6pFnb}w3 zw<<-++z1DUNse|_P{;J2*6n8@r&gh{rD{m&<~po|Q$ipy7=mBo;&-#mjt1?NU4mhL zfc831c}==rA+?wgcqTLb(!&4-t1RS%^EGDpCV0cT_jl62%cyMBo#~OUE6FHg=R9+1 zRSwIJ%#Ksg-P$X2FZfn7$@cCrZRKj@mZzg$Yb0n! z`SHz3*p12$9`UNT+`s15yQ5-dfq|ZZTnoIb_!m};`sq7;>llwyn)Eb)&S|*Iu{k>i$y!6C;wVgvG zeH`v1v%cGNZaYkbjeW9`y;9q&;}F92QM{vmHDv4JHg~m#2A5!)ET!^PX3^nAVKxAR z(k52=4F|%@bZ~dRrSwa`HUhnWr@F??XVGF~_ovhK@Kc)1qyQ33l-#r*sj@2Ew5Xfq zgfBV60evJcA(TG$Eho;zu>FHmMdLm2gQV2N6g#7%aQ&VLURP}leSfi<)r{BdA+2sHyJF^_@ z4LQ7}@i%YU&__YNK3Hw?l(Nj*T!M9#V%5X&c~cd}3raU&c-3eTK}m~yo|~uXm+v3{ z)7eDA!|0&gm@OY_eUeG=UNPqw&rivXDCDC$ogE7P!J@yynH%lsIJs?{anNKjgx`vv zG4oI#EvRTfMA{CRT5KO>1*Q#~@Va$Ez8~{bQNHJ|T;+sqe7%Rv#N@ZM(Q56yM(fWx zz;#LN!uCOLam*vV+>}S!?6j`h-EXgOjCc;xOXB4u@x;x6ZiPhl2)%;>csZ{Z_>m3& z$GS;*>U8V@&AEe|2mnldyj-eTzJ_wU=w0@<1yGyo-zPLW<-Qdt_)m0WP zTRUX63DrM%B54939}`W4~474f9}!Uijqn=g3h*RLSw9+}Ja1Z$X3qB_vW#?59LXpU88_Hy5Ta%TJ zp7=MX+n<&pR>?YdD{1?_gzDdTG%sz)7NvEwm5x(LZjx8A9jzct3VaNi_CgliL9pIo1_X@6+SYEeogmfVeM~1Co3I{8{g?L^FWd(U6YqVAT*G}yMEKqfhOs4 zgL-Zfg9o6aBlNZHy~SvvKd%D(44q|W-!`CStJcd+WLw5P8mRky(YM&YUty^ZYAEFt zS5~5f(!`*9v(wellH68n9F^byNM3$E8%3{X+VO!#< zfPC!gedAozdf+=dPyWE!2LQUQ#Tw@ks? z4Zp4|1!L#S!BtV;`f8AbS_>7>p0Pb-nFH{X)18$;*J;;9 z=1k>KwB;Q(uy{%^2sJ3IY9-l5xt=SQ0NCtRJ4GS?Ag^(L-eDf6+MUP8LqYWTH$L!V z84$2(oo(>{yz%)z{9arM1)_r`yPcbF$uD2L031appIRh6pHY3rDc1T9SQlKoZG~X8 z$XOX!VC_O&e)_z?d@meD0eZRgDe{InfswyrNh=>PTFR?G^p)d(^^sXmtAc^LT&?lM zuR!{cUcN|xuT&lF0HdDVv@`)e8!8e87Z=BvXFR!KD#+TQRi%K@?x|h77N!GQPg+W< zIoaTnd4gcHc#6S_ACLR_F397bw5llq{fDWgfVrp($prd*_hNBO?BM8-AfJVan;8K8 zi<)oWC(zn3p`r7&N}-|ib=feXq4RYsVMG^3bU`D!x$E}JA|fj?_wO}4_Wu^$>rw*2 z@6oK#nPYsT?l&<9sq((ydyduC*RPC0A)$`T%0MzDkFH%fYdS`OWUm3ko+t(1h*bzj zQR%*xOoDp60NLDeL)c6}Ka~?BR-AMc!`@;@s>5sKSo%jKnk-ystOq+h^+?Pa(5u9Z zm1(|RVOXd74O4s=UjY)ljFHaIW)`_)j~8Ps8A{Cg3iZFL#>JrKSez-pMhy^mTnvck zq98(rBok#3yHpwwR~U{W>@f9TxUL8@0+pFjj&Tee5N8b}vN~%w-#GYH6fnpP%|I$2 zyIu|HE>BS1Wmp(kNS z71?LY5M+-WaO9B7VB}o*=KWcAH!F39LCIX+HOqCrCBJ;tq5xzO3gyVEh0{LtkXUdk zpwndf^2-AA<=+f6h?D{N>suGjW)N@k;20m1m2ksw7CUp3Wk3PBc6`FvdEo>LEuyWW zl+$9WY91k&6azr|9~h3U6HG;?_5u|p7$^TQH@UMPI}XgCrDHWgfHDL;zws