learn-data-modeling-with-phoebe / Builder session 8 of 10
Learn Data Modeling with Phoebe · Builder track · Session 8 of 10

Answering the four questions

This is the payoff session. Seven sessions of design exist so that four questions a marketplace leadership team asks on a Monday can be answered in a few lines of SQL each, with no argument about definitions. You will build the four marts, walk the four questions in order, and watch one plausible hypothesis die on the evidence while another survives. The honest lesson at the end is not about SQL: a rate without its denominator is not evidence, and a model's real job is to make a hypothesis refutable.

🟠 Builder track Analysts · analytics engineers · DE · DS Hands-on · real SQLite 45 min
0-3 · Welcome 3-13 · What a mart actually is 13-42 · The four questions, answered live 42-45 · Q&A
Part 0

The four questions, and why they are the exam

A model is not good because it is normalized, or star-shaped, or documented. It is good because the questions the business actually asks are cheap and unambiguous to answer with it. So this session is the exam, and these are the four questions Bazaar's leadership team asks: which merchant's performance dropped, why sales dropped, why one specific day collapsed, and when the peak transaction hours are.

Three files ship today: sql/20_marts.sql builds the four marts, sql/30_business_questions.sql answers the four questions with the verified numbers written in beside them, and python/answer_questions.py runs the same analyses against bazaar.db, prints readable answers and saves every chart as a 300 DPI PNG plus an SVG for slides.

Live - presented in session Self-study - read after class ▶ Live SQL - editable & runnable Sources covered
★ What you walk out with today Four marts you built, four business answers you can defend, the carts x conversion x AOV decomposition as a reusable habit, and the experience of killing your own hypothesis on a sample size - which is the skill that separates an analyst from a dashboard.
Part 1 · covers Kimball marts, dbt layering, model contracts

A mart is not a smaller star 6 min live

The most common thing built and called a mart is a subset: fewer columns, fewer rows, same shape. That is a copy, and copies rot. A mart is the star pre-aggregated to the grain a specific set of questions is asked at, with the metric definitions baked in so that two people asking the same question get the same number. The grain is the whole point, which is why Bazaar has four of them and not one.

merchant_daily GRAIN: merchant x day 600 rows Serves: which merchant dropped (Q1) funnel_daily GRAIN: one day 90 rows Serves: why sales dropped (Q2, Q3) payment_health GRAIN: day x method 388 rows Serves: decline triage (Q2, Q3) hourly_txns GRAIN: day x hour 753 rows Serves: peak hours and staffing (Q4) Four marts because four grains. Merging them would force a grain that fits none of them and re-introduce exactly the fan-out that separating the facts in b7 existed to prevent. When two marts must be read together, join them on their shared conformed keys. No mart stores a ratio: every rate ships as numerator and denominator and the division happens at read time. That one discipline is what stops an average of averages from ever being available.
🔍 Click to zoom - four marts, four grains, and the two rules all of them follow
MartOne row isRowsBuilt fromServes
mart_merchant_dailyone merchant on one day600fact_order_item where is_paid = 1Q1 · merchant performance, GMV, margin, commission
mart_funnel_dailyone day90all three facts, each aggregated to day firstQ2 and Q3 · the decomposition mart
mart_payment_health_dailyone day, one payment method388fact_transactionQ2 and Q3 · decline triage and reason codes
mart_hourly_transactionsone day, one hour753fact_transactionQ4 · peak hours, staffing, deploy windows
LiveWhat "with the definitions baked in" actually buys you3 min

Look at what mart_merchant_daily decided once, so that nobody downstream has to decide it again:

  • WHERE is_paid = 1. Revenue in this mart is money that settled. That is one line of SQL and it ends a recurring argument, because the alternative is every analyst applying that filter, or not, from memory.
  • COUNT(DISTINCT order_id) AS orders. Not COUNT(*). The grain error from b7 is impossible here because the mart already counted correctly.
  • aov_numerator and aov_denominator, not aov. The AOV of a week is then computable from the days, which is exactly what a stored AOV would have made impossible.
  • The dimension attributes come along - merchant_name, merchant_category, merchant_tier. A mart that only carries ids is a mart people have to join their way out of.
The test for whether you built a mart or a copy Ask what would break if you deleted it and rebuilt it from the star tomorrow. If the answer is "nothing, it is the same query", it is a cache and that is fine. If the answer includes "we would have to re-decide what revenue means", it was never a copy - the decisions were the deliverable.
LiveThe layer stack, and where a mart sits in it3 min

Bazaar's files are the standard modern layering with nothing hidden, and each layer has exactly one job:

  • Source (sql/01, sql/02) - the OLTP tables, normalized, owned by the application. You do not query these for analytics, which was the whole argument of b5.
  • Star (sql/10, sql/11, sql/12) - dimensions and facts at their natural grains. Everything reconciles here, and this is the layer you defend.
  • Marts (sql/20) - pre-aggregated to question grains, definitions baked in. Cheap to read, safe to hand to a BI tool.
  • Access (sql/40, session b9) - flat, self-describing views plus the metric table, which is what an AI agent or a new analyst is allowed to see.

The rule that keeps this honest is one-directional dependency: marts read the star, the star reads the source, and nothing ever reads back up the stack. When a definition changes you change it in exactly one layer, and you can say out loud which reports move.

The other thing worth naming is what a mart is not allowed to be: the only place a number exists. Every number in every mart here reconciles to the star, and Demo 1 makes you check that rather than trust it.

Self-studyTwo other ways to model this, honestly compared4 min read

Dimensional marts are the default recommendation of this course, not the only pattern that works. The two you will be asked about deserve a straight answer:

  • Data Vault (Linstedt). Hubs for business keys, links for relationships, satellites for descriptive attributes and their history. It is genuinely excellent at one thing: absorbing many source systems that change shape and ownership independently, while keeping full auditability of what arrived when. The cost is that nobody queries it directly - you still build dimensional marts on top - so you are maintaining two models. It earns its complexity in a regulated enterprise integrating a dozen upstream systems with real audit requirements. For Bazaar, one source system and one team, it would be pure overhead.
  • One Big Table. Flatten everything into a single wide table per subject area and let a columnar engine handle it. This is a serious pattern on modern warehouses and it is not the same mistake as b1's flat export, because the OBT is a derived, rebuilt artifact rather than a system of record. Its real cost is the one b7 spent a whole session on: an OBT has exactly one grain, so the moment your subject area has two - sold and paid - you are back to choosing which question the table cannot answer.

Graph and document models are a further step away and each is its own topic. The judgement to carry out of here: pick the pattern that makes the questions you are actually asked cheap, and be able to say in one sentence what your choice makes expensive. Any pattern presented as having no downside is being sold to you.

Part 2 · the reusable habit

Never answer "why did sales drop" with one number 5 min live

"Sales dropped 30%" is not an answer, it is the question restated with more precision. Revenue is an identity: revenue = carts x conversion x AOV. So a drop is always one of those three moving, usually one much more than the others, and each of the three sends you to a different team. Compute all three side by side and the cause stops being something people argue about in a meeting.

carts prior 14d: 398 recent 14d: 312 -21.6% x conversion prior 14d: 47.2% recent 14d: 44.9% -2.3pp x AOV prior 14d: 128.68 recent 14d: 118.25 -8.1% = net revenue prior 14d: 22,906 recent 14d: 15,963 -30.3% Carts fell 21.6% and AOV fell 8.1%, while conversion barely moved. So roughly three quarters of the loss is demand that never arrived and the rest is smaller orders. Checkout did not break - the decline rate actually IMPROVED, from 10.3% to 4.1%, because the earlier window is the one containing the 2026-06-10 incident that Q3 is about. "Sales dropped 30%" sends the whole room to the checkout team. The decomposition sends marketing to a paid-search pullback and one category manager to one merchant's stockout. Same starting number, two very different weeks.
🔍 Click to zoom - the identity that turns one alarming number into three checkable ones
LiveWhy the identity has to be computed, not estimated3 min

The three factors are not independent, so you cannot reason about them one at a time in your head:

  • Carts is top of funnel. It moves with traffic, marketing spend, seasonality and price perception. A drop here means fewer people showed up wanting to buy, and the owner is marketing or merchandising.
  • Conversion is the middle. It moves with checkout friction, payment failures, stock availability and shipping cost surprises. A drop here means people wanted to buy and something stopped them, and the owner is engineering or ops.
  • AOV is the value per completed order. It moves with mix, discounting and the loss of a high-priced hero product. A drop here means the same number of people bought cheaper things, and the owner is category management.

Three factors, three owners, three completely different weeks of work. Which is why guessing is expensive: send the checkout team after a marketing problem and you lose a fortnight and their goodwill, and the revenue keeps falling while they look.

The mart is what makes the identity checkable mart_funnel_daily holds carts, conversions, paid orders, revenue, attempts and declines at one grain, all built from the three separate facts. That is the only reason the three factors can be compared over the same two windows without anybody re-deriving one of them differently.
LiveThe two-window comparison, and how to pick the windows3 min

Every question today compares the last 14 days to the 14 before them. That choice is not arbitrary and it is worth defending out loud, because the window is where a lot of quiet dishonesty lives in analytics:

  • Equal lengths. 14 against 14. Comparing a fortnight to a month is the oldest way to make a number say what you want.
  • A multiple of seven. Bazaar's weekday and weekend behaviour differs a lot, as Q4 proves. Any window that is not a whole number of weeks bakes a day-of-week effect into the result.
  • Adjacent, not year-over-year. Ninety days of data cannot support a seasonal comparison, so this course does not pretend otherwise. Say what your window can and cannot see.
  • Chosen before you look. Pick the window from the question ("what changed this fortnight"), never from the chart. Sliding the boundary until the story appears is the most common analysis failure there is, and it is undetectable in the output.

The windows in sql/30 are literals - '2026-06-16' and '2026-06-02' - precisely so that anybody can see them and object to them. A window buried in a date function nobody reads is a window nobody can challenge.

Demo 1 of 2 · build the layer

The four marts, and the check you never skip ★ 6 min · everyone builds

The marts are already built in every editor on this page, from the same sql/20_marts.sql you can run on your laptop. Before trusting a single answer, read their grains and reconcile them to the star. A mart that does not tie back is not a mart, it is a second opinion.

Read the four grains and row counts. 600, 90, 388 and 753 rows - four different shapes because four different questions.

Reconcile. Sum the mart, sum the star, and confirm both say 111,906.48 across 957 settled orders. Do this every single time you build a mart.

Then start asking questions. The four sections below are the four questions, in the order the diagnosis actually runs.

SELECT 'mart_merchant_daily' AS mart, 'merchant x day' AS grain,
       COUNT(*) AS rows FROM mart_merchant_daily
UNION ALL SELECT 'mart_funnel_daily',         'one day',
       COUNT(*) FROM mart_funnel_daily
UNION ALL SELECT 'mart_payment_health_daily', 'day x payment method',
       COUNT(*) FROM mart_payment_health_daily
UNION ALL SELECT 'mart_hourly_transactions',  'day x hour',
       COUNT(*) FROM mart_hourly_transactions
UNION ALL SELECT 'v_metric_definitions',      'one metric',
       COUNT(*) FROM v_metric_definitions;
SELECT 'star: v_sales_line (is_paid = 1)' AS source,
       ROUND(SUM(net_revenue), 2)         AS net_revenue,
       COUNT(DISTINCT order_id)           AS orders,
       ROUND(SUM(net_revenue) / COUNT(DISTINCT order_id), 2) AS aov
FROM v_sales_line
WHERE is_paid = 1
UNION ALL
SELECT 'mart_merchant_daily',
       ROUND(SUM(net_revenue), 2),
       SUM(orders),
       ROUND(SUM(aov_numerator) / SUM(aov_denominator), 2)
FROM mart_merchant_daily;
Why SUM(orders) is legal here and usually is not Summing a distinct count across rows is normally wrong, because the same order would be counted in two cells. It is correct in this mart only because every Bazaar order belongs to exactly one merchant on exactly one day, so no order can appear twice. That is a real property of the model, not a coincidence - and it is exactly the kind of assumption that belongs in semantic/contract.yaml rather than in somebody's head.
Question 1 of 4

Which merchant's performance dropped? ★ 6 min · everyone builds

Rank the merchants two ways at once, because neither ranking is sufficient alone: absolute dollars is what the business feels, and percent is how bad it is for that merchant. Rank by percent only and you promote tiny merchants; rank by dollars only and you hide a small merchant in freefall. Then split the drop into volume versus order value, because those have different fixes.

WITH windows AS (
  SELECT merchant_id, merchant_name,
    SUM(CASE WHEN activity_date >= '2026-06-16' THEN net_revenue ELSE 0 END) AS rev_recent,
    SUM(CASE WHEN activity_date >= '2026-06-02'
              AND activity_date <  '2026-06-16' THEN net_revenue ELSE 0 END) AS rev_prior,
    SUM(CASE WHEN activity_date >= '2026-06-16' THEN orders ELSE 0 END)      AS ord_recent,
    SUM(CASE WHEN activity_date >= '2026-06-02'
              AND activity_date <  '2026-06-16' THEN orders ELSE 0 END)      AS ord_prior
  FROM mart_merchant_daily
  GROUP BY merchant_id, merchant_name
)
SELECT merchant_id, merchant_name,
       ROUND(rev_prior,  0)             AS revenue_prior_14d,
       ROUND(rev_recent, 0)             AS revenue_recent_14d,
       ROUND(rev_recent - rev_prior, 0) AS dollars_change,
       ROUND(100.0 * (rev_recent - rev_prior) / rev_prior, 1) AS pct_change,
       ROUND(100.0 * (ord_recent - ord_prior) / ord_prior, 1) AS pct_change_orders,
       CASE WHEN rev_recent >= rev_prior THEN 'no drop'
            WHEN ord_recent * 1.0 / ord_prior < 0.85 THEN 'volume driven'
            ELSE 'order-value driven' END AS drop_shape
FROM windows
ORDER BY dollars_change ASC
LIMIT 5;

-- the number the ranking above does not show you
SELECT COUNT(*) AS merchants_down FROM (
  SELECT merchant_id,
         SUM(CASE WHEN activity_date >= '2026-06-16' THEN net_revenue ELSE 0 END)
       - SUM(CASE WHEN activity_date >= '2026-06-02'
                   AND activity_date < '2026-06-16' THEN net_revenue ELSE 0 END) AS delta
  FROM mart_merchant_daily GROUP BY merchant_id) WHERE delta < 0;
MerchantPrior 14dRecent 14dDollarsPercentShape
M07 Nimbus Audio3,063455-2,609-85.2%volume driven
M02 Verdant Skincare2,6551,166-1,489-56.1%volume driven
M04 Pace Athletics3,4182,115-1,303-38.1%volume driven
M12 Fable Toys1,555404-1,151-74.0%volume driven
M11 Orchid Grocer3,1102,104-1,006-32.3%volume driven

The answer, and the trap. M07 Nimbus Audio lost the most in both rankings: 2,609 in dollars and 85.2% in percent, and the split says volume driven, so it sold far fewer orders rather than cheaper ones. But read the second result set: 8 of 12 merchants are down. Stop at "M07 dropped 85%" and you will go and manage M07 while the whole marketplace falls around you. A merchant ranking has no denominator - it cannot tell you whether the cause is one merchant or the platform, because every merchant is measured against itself. M07 is still special, since it falls almost twice as hard as the next one, but it is one of two causes rather than the cause. Finding out which is Q2's job.

Question 2 of 4

Why did sales drop? ★ 9 min · everyone builds

The decomposition from Part 2, run for real. One query, one grain, all three factors, both windows. The ratios are computed here at read time from the numerator and denominator columns the mart stores, which is the only reason the two windows are comparable at all.

WITH w AS (
  SELECT CASE WHEN activity_date >= '2026-06-16' THEN 'recent_14d'
              ELSE 'prior_14d' END      AS window_label,
         SUM(carts_created)              AS carts,
         SUM(carts_converted)            AS conversions,
         SUM(paid_orders)                AS paid_orders,
         SUM(net_revenue)                AS net_revenue,
         SUM(payment_attempts)           AS attempts,
         SUM(declines)                   AS declines,
         SUM(abandoned_value)            AS abandoned_value
  FROM mart_funnel_daily
  WHERE activity_date >= '2026-06-02'
  GROUP BY window_label
)
SELECT window_label,
       carts,
       ROUND(100.0 * conversions / carts, 1)            AS conversion_rate_pct,
       paid_orders,
       ROUND(net_revenue, 0)                            AS net_revenue,
       ROUND(net_revenue / NULLIF(paid_orders, 0), 2)   AS aov,
       ROUND(100.0 * declines / NULLIF(attempts, 0), 1) AS decline_rate_pct,
       ROUND(abandoned_value, 0)                        AS abandoned_value
FROM w
ORDER BY window_label DESC;
WindowCartsConversionPaid ordersRevenueAOVDecline rateAbandoned
recent 14d31244.9%13515,963118.254.1%19,010
prior 14d39847.2%17822,906128.6810.3%23,983

The decomposition. Revenue is down 30.3%. Carts are down 21.6%, conversion moved 2.3 percentage points, AOV is down 8.1%. So most of the loss is fewer carts - demand that never arrived - and the remainder is smaller orders. Conversion did not break, and the decline rate actually improved from 10.3% to 4.1%, because the earlier window contains the incident Q3 investigates. That is the whole reason to decompose: the raw headline sends everyone to checkout, and the model says go to marketing and to one merchant.

Two hypotheses survive that far, so test both. One query each, and each one either lives or dies.

-- H1 · declines by merchant, recent 14 days
SELECT m.merchant_id, m.merchant_name,
       COUNT(*)                                        AS attempts,
       SUM(f.is_declined)                              AS declines,
       ROUND(100.0 * SUM(f.is_declined) / COUNT(*), 1) AS decline_rate_pct
FROM fact_transaction f
JOIN dim_date d          ON d.date_key = f.date_key
JOIN fact_order_item foi ON foi.order_id = f.order_id AND foi.line_no = 1
JOIN dim_merchant m      ON m.merchant_sk = foi.merchant_sk
WHERE d.full_date >= '2026-06-16'
GROUP BY m.merchant_id, m.merchant_name
ORDER BY decline_rate_pct DESC
LIMIT 5;

-- H2 · did a product stop selling entirely?
SELECT p.merchant_id, p.product_name, p.status,
       ROUND(SUM(CASE WHEN d.full_date <  '2026-06-15'
                      THEN f.net_amount ELSE 0 END), 0) AS revenue_before,
       ROUND(SUM(CASE WHEN d.full_date >= '2026-06-15'
                      THEN f.net_amount ELSE 0 END), 0) AS revenue_after
FROM fact_order_item f
JOIN dim_product p ON p.product_sk = f.product_sk
JOIN dim_date d    ON d.date_key = f.date_key
WHERE p.status = 'out_of_stock'
GROUP BY p.merchant_id, p.product_name, p.status
ORDER BY revenue_before DESC;

H1 dies. The decline ranking puts M10 first at 18.2% and M07 second at 10.0%, with M03 and M08 next at 8.3%. Now read the denominators: those rates sit on 11, 10, 12 and 12 attempts. At eleven attempts a single decline moves the rate by about nine points, so the ranking is noise wearing a percentage sign, and nothing in it can distinguish one merchant from another. The payments hypothesis is not disproved so much as shown to be untestable at this volume - which is the correct verdict, and one you can only reach because the query printed its denominator next to its rate.

H2 survives. M07's hero product, Studio Headphones, earned 3,646 before 2026-06-15 and exactly 0 after it. Not less. Zero. A revenue series that goes to exactly zero on a date is almost never demand and almost always supply or a switch somebody flipped, and here the product status says out_of_stock. That is a supply failure, and on its own it explains the bulk of one merchant's collapse.

Final diagnosis: two independent causes. A platform-wide demand drop - carts down 21.6%, traced to a paid-search pullback from 2026-06-18 - plus M07 losing its hero SKU to a stockout. Neither is a checkout problem, which is exactly where a room without a model would have started guessing.

Real world

The percentage that cost a quarter. A subscription business ran a weekly churn-by-plan report. One plan showed 40% churn and became the subject of a company-wide project: pricing review, save offers, an exec sponsor, two engineers reassigned. The plan had five customers, and two had left. The denominator was in the underlying table the whole time and simply was not on the slide. Everything that followed was a rational response to a number that was not evidence.

Two habits prevent it, and both are modeling decisions rather than analyst discipline. Never store a rate - store its numerator and denominator so the count travels wherever the rate goes. And put the sample-size caveat in the model itself: Bazaar's v_metric_definitions carries "always report the attempt count beside it, below about 30 attempts it is noise" as a column value on the decline_rate row, so it arrives with the metric rather than depending on who is reading.

Question 3 of 4

Why did 2026-06-10 collapse? ★ 5 min · everyone builds

Day-level attribution, and the shape of the answer is the lesson. Look at the day with its neighbours, and watch which parts of the funnel are normal and which are not.

SELECT activity_date,
       carts_created,
       carts_converted,
       paid_orders,
       ROUND(net_revenue, 0)                                    AS net_revenue,
       payment_attempts,
       declines,
       ROUND(100.0 * declines / NULLIF(payment_attempts, 0), 1) AS decline_rate_pct,
       ROUND(declined_amount, 0)                                AS money_that_failed
FROM mart_funnel_daily
WHERE activity_date BETWEEN '2026-06-07' AND '2026-06-13'
ORDER BY activity_date;

-- and the reason code, from the payment-health mart
SELECT top_decline_reason,
       SUM(declines)                      AS declines,
       ROUND(SUM(declined_amount), 0)     AS value
FROM mart_payment_health_daily
WHERE activity_date = '2026-06-10'
  AND declines > 0
GROUP BY top_decline_reason
ORDER BY declines DESC;
DateCartsConvertedPaid ordersRevenueAttemptsDeclinesDecline rate
2026-06-092913121,33815213.3%
2026-06-102413848417952.9%
2026-06-113114141,9671616.3%

The answer. Carts were normal at 24, conversions were normal at 13, and orders were placed. Revenue still collapsed to 484 against neighbours of 1,338 and 1,967, because 9 of 17 payment attempts failed - a 52.9% decline rate against 13.3% the day before and 6.3% the day after - taking 740 SGD of money with them. The reason code is gateway_timeout on all nine. So demand was fine and checkout broke: an infrastructure incident, not a market event, and the fix is an on-call ticket rather than a pricing review.

Why this question is answerable at all. The whole diagnosis rests on one sentence: people wanted to buy, they tried to buy, and the money did not settle. That sentence needs three different grains to be true at once - carts (what was wanted), orders (what was placed) and payment attempts (what was paid) - and Bazaar keeps them as three separate facts. Flatten them into one table, or keep a paid boolean on the order instead of an attempt table, and this sentence has nowhere to live. The day would show up as "revenue down 75%" with no way to tell it apart from a bad market, which is the version of this incident most companies actually get.

Question 4 of 4

What are our peak transaction hours? ★ 5 min · everyone builds

The question behind every staffing roster, on-call rotation and deploy-freeze window. Answered across all 90 days at once, which is only cheap because hour is a dimension rather than something parsed out of a timestamp on every read. Then split weekday from weekend, because a single blended peak would staff the wrong hour.

SELECT hour_label,
       daypart,
       SUM(attempts)                                   AS attempts,
       SUM(approvals)                                  AS approvals,
       ROUND(100.0 * SUM(declines) / SUM(attempts), 1) AS decline_rate_pct,
       ROUND(100.0 * SUM(attempts) /
             (SELECT SUM(attempts) FROM mart_hourly_transactions), 1) AS pct_of_all_attempts
FROM mart_hourly_transactions
GROUP BY hour_label, daypart
ORDER BY attempts DESC
LIMIT 6;

-- weekday vs weekend, because one blended peak would mislead a roster
SELECT hour_label,
       SUM(CASE WHEN is_weekend = 0 THEN attempts ELSE 0 END) AS weekday_attempts,
       SUM(CASE WHEN is_weekend = 1 THEN attempts ELSE 0 END) AS weekend_attempts,
       SUM(attempts)                                         AS all_attempts
FROM mart_hourly_transactions
GROUP BY hour_label
ORDER BY all_attempts DESC
LIMIT 8;
HourDaypartAttemptsShare of all attempts
21:00-21:59evening969.1%
19:00-19:59evening948.9%
13:00-13:59lunch898.4%
20:00-20:59evening898.4%
22:00-22:59evening777.3%
12:00-12:59lunch757.1%

The answer. A broad evening peak from 19:00 to 22:59 carrying about a third of all payment attempts, with a secondary lunch shoulder at 12:00 to 13:59 and a nearly dead overnight. So on-call cover belongs in the evening and deploy windows belong before dawn.

And the split matters. Weekday attempts peak at 20:00 with 64, weekend attempts peak at 21:00 with 41. A single blended answer would have named one hour and staffed the wrong one on two days out of every seven. This is the small, unglamorous version of the same lesson Q1 taught: an aggregate hides the thing you were about to act on, and the fix is always to keep the dimension that separates the cases - here is_weekend, sitting in dim_date since session b6 precisely so that nobody has to compute it.

Homework

Try it yourself - this week ◐ 25-35 min total

Source material

Sources covered

Full source map in materials/official-course-map.md. This page covers:

Kimball & Ross, The Data Warehouse Toolkit - marts, aggregate design, drilling across conformed dimensionsPart 1 + Demo 1 · four marts at four grains, reconciled to the star
dbt documentation - staging / intermediate / marts layering, one-directional dependency, model contractsPart 1 · where a mart sits in the stack and what it is allowed to decide
Linstedt & Olschimke, Data Vault 2.0 - hubs, links, satellitesSelf-study · compared honestly, with the case where the complexity is earned; building one is out of scope
Inmon - normalized core versus dimensional martPart 1 · the layer stack as the practical settlement of that argument; the full case is b5 and a3
One Big Table, graph and document modelingSelf-study · named with the grain problem each one inherits; each is its own topic
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · The merchant ranking shows M07 down 85.2%, and 8 of 12 merchants down. Why is the ranking alone not an answer?

A ranking sorts merchants against their own past, which is useful and insufficient. With 8 of 12 down there is clearly a platform-level cause as well, and no amount of re-sorting the same table will reveal it. That is why Q2 decomposes the platform total instead of ranking merchants again.

2 · The decline ranking puts M10 top at 18.2%. Why does the payments hypothesis die rather than survive?

The number is true and useless. At that sample size the ordering is noise, so the hypothesis is untestable at this volume rather than confirmed or refuted, and acting on it would be acting on nothing. The only reason you can reach that verdict is that the query printed its denominator beside its rate.

3 · Why is "people wanted to buy, they tried to buy, and the money did not settle" answerable for 2026-06-10?

The diagnosis needs demand, intent and settlement measured independently. A single flattened table has one grain, and a paid boolean collapses nine gateway timeouts into one false value. Three facts is what makes the day distinguishable from a bad market.

Builder session 8 cheat sheet · pin this

What a mart isThe star pre-aggregated to a question grain, definitions baked in. Not a smaller copy of the star.
Four marts, four grainsmerchant x day (600) · day (90) · day x method (388) · day x hour (753).
Always reconcileMart total must tie to the star: 111,906.48 across 957 settled orders, to the cent.
The identityRevenue = carts x conversion x AOV. Three factors, three owners, three different weeks of work.
Q1 trap8 of 12 merchants down. A ranking has no denominator, so it cannot separate one merchant from the platform.
Q2 verdictCarts -21.6%, AOV -8.1%, conversion flat. Payments hypothesis dies on 11 attempts; stockout survives on revenue of exactly 0.
Q3 and Q4Three facts make "wanted, tried, did not settle" sayable. Weekday peaks 20:00, weekend 21:00 - never blend them.
The honest lessonA rate without its denominator is not evidence. A model's job is to make a hypothesis refutable. Next: b9, agent-ready.