Gambill Data Engineer Track · Launch · Phase 2 of 4

Core data engineering, one real project.

Weeks 14–30 (Core DE · silver depth) build one running medallion pipeline — the portfolio's Project 1 — on Databricks Free Edition + Delta. Modeled with SCD2 history, deduplicated into golden provider records (MDM), run incrementally with quarantine and tests. Worked in healthcare (CMS + NPPES); every lab tells you how to mirror it on your own target industry.

Databricks Free Edition + DeltaCMS Price TransparencyNPPES Provider RegistryProject 1 · Provider Pricing Integrity
What you'll need: this phase runs on Databricks Free Edition — a no-cost, fully-hosted account you create in minutes with an email or Google/Microsoft login; no AWS/Azure/GCP account or credit card required. It is serverless and includes Unity Catalog, Delta Lake, Lakeflow pipelines, Jobs, streaming, and the CLI under fair-use quotas. A few scale-out topics later need the 14-day full-platform trial or a paid/employer workspace — those are flagged where they appear (see Phase 3).
Each week is collapsed — click to open
W14 Dimensional Modeling I — Facts & Dimensions (kick off the Project Charter; design the pricing star schema)Maria, a hospital revenue-cycle analyst, publishes a monthly cost-and-compliance report that leadership and CMS auditors rely on; it is wrong because prices arrive missing and inconsistent AND the same provider shows up as several records, so charges cannot be summed or attributed. A dimensional model with conformed provider/procedure/facility/payer dimensions is the structure that makes her numbers addable, auditable, and defensible under price-transparency rules. M08 — Dimensional Modeling I · Phase 2 · silver G2G1G9 ~16h
Why this matters Maria, a hospital revenue-cycle analyst, publishes a monthly cost-and-compliance report that leadership and CMS auditors rely on; it is wrong because prices arrive missing and inconsistent AND the same provider shows up as several records, so charges cannot be summed or attributed. A dimensional model with conformed provider/procedure/facility/payer dimensions is the structure that makes her numbers addable, auditable, and defensible under price-transparency rules.
Project 1 · this week This is Week 1 of the running Project 1 build. It BIRTHS the living Project Charter (problem statement + persona=Maria + 4 KPIs), which is added to across the phase and assembled at W30, and it lays the dimensional foundation (fact_charge + four conformed dimensions as empty typed Delta tables) that every later silver/gold week populates. Artifacts added to the portfolio repo this week: Project Charter v0.1, the star-schema diagram, the column-classification table, decisions.md, and the Delta skeleton DDL.
Objectives
  • Given raw CMS Hospital Price Transparency and NPPES fields, classify each column as a FACT MEASURE, a DIMENSION ATTRIBUTE, or a DEGENERATE attribute and defend each call in one sentence (target: correctly classify at least 90% of a 25-column sample).
  • Design a star schema for the pricing domain: one price/charge FACT plus PROVIDER, PROCEDURE, FACILITY, and PAYER dimensions, each with a named surrogate key and at least one candidate business (natural) key.
  • Author Project Charter v0.1 stating Maria's problem in five sentences or fewer, naming the persona, and defining 4+ measurable KPIs, each with a formula and a grain-of-measurement note.
  • Explain, with a concrete CMS/NPPES example, why a flat 'one big table' fails Maria's compliance reporting (duplicate providers, un-attributable measures, no reusable conformed dimension).
  • Produce a Delta-backed dimensional skeleton in Databricks: empty typed Delta tables for fact_charge and the four dimensions with surrogate-key and audit columns, ready for population in later weeks.
Concept lessons · ~7h
  • Lesson 8.1 — Why dimensional modeling exists (1.5h): the analytics failure mode of the 'one big table.' Recognition-before-evaluation framing: a hiring manager reads a README that says 'monthly cost report was wrong because the same hospital appeared 3 ways' and instantly recognizes the problem. Facts = the measurable business events (a charge, a price); dimensions = the who/what/where/how context you filter and group by. Contrast with W13's 3NF source shape: sources normalize to avoid update anomalies; analytics denormalizes into conformed dimensions to make questions cheap and answers auditable. Deliberately DEFER grain and the bus matrix to W15 — this week only establishes the fact/dimension split.
  • Lesson 8.2 — Anatomy of a fact table (2h): additive vs. semi-additive vs. non-additive measures (a charge amount is additive; a list price is non-additive/you cannot sum it across procedures). Foreign keys to dimensions, degenerate dimensions (a claim/line id that lives on the fact with no dimension table), and why a fact row should represent one business event. Worked on CMS MRF: the 'standard_charge|gross', 'standard_charge|negotiated_dollar', and 'standard_charge|min/max' columns are candidate measures; code|1|type, payer_name and plan_name point at dimensions.
  • Lesson 8.3 — Anatomy of a dimension: surrogate vs. natural keys (2h): why analytics uses an integer/hash surrogate key instead of the NPI or hospital EIN directly (stability across source drift, room for SCD2 history later, protection when a natural key is dirty or duplicated). Candidate business keys in the worked data: NPI (provider), CMS Certification Number / EIN (facility), CPT/HCPCS/DRG code (procedure), payer_name+plan_name (payer). Flag that provider identity is dirty (name/address variants, duplicates) — this foreshadows the MDM closer but is NOT solved this week.
  • Lesson 8.4 — The Project Charter as a data product spec (1.5h): translating Maria's pain into a written problem statement, a persona sketch, and 4+ KPIs with explicit formulas and measurement grain. Documentation-as-interview-prep: each KPI is a sentence you can defend in a screen. Introduce the four project KPIs (pricing-completeness pass rate, duplicate providers collapsed, records quarantined with reason, data-freshness SLA) as the charter's north star, and map each KPI to the fact/dimensions it will need.
Hands-on lab · ~9h

Inspect the REAL messy fields in a CMS Hospital Price Transparency machine-readable file and a small NPPES pull, classify every column as fact/dimension/degenerate, design the pricing star schema (fact_charge + dim_provider/procedure/facility/payer), materialize an empty typed Delta skeleton in Databricks Free Edition, and write Project Charter v0.1 with Maria's problem statement and 4+ KPIs. This is a modeling-and-framing week: you land and profile raw data to inform the model, but you do NOT yet load the fact (that comes as silver is built in later weeks).

Step by step
  1. 1. Land real samples. In Databricks Free Edition, run the starter scaffold to download one hospital's CMS machine-readable pricing file (pick a hospital from the CMS index) into a Bronze landing path and pull ~50 provider records from the NPPES API for that hospital's city/state. Confirm both landed with a source column and an ingest timestamp (bronze contract: raw, no transforms).
  2. 2. Profile the mess. Use the scaffold's profiling TODO to print column names, inferred types, null rates, and 5 sample values per column for BOTH sources. Write down the specific messes you observe: pipe-delimited nested charge columns, blank/negative/text-in-numeric prices, payer/plan names as free text, NPI duplicates and name/address variants across NPPES rows, and schema drift between hospital files.
  3. 3. Classify columns. Build a table (in a notebook markdown cell or CSV) with one row per source column and a verdict: FACT MEASURE, DIMENSION ATTRIBUTE, or DEGENERATE, plus a one-sentence justification. Explicitly mark additive vs. non-additive for every candidate measure (e.g., gross charge = additive per line event; list/negotiated rate = non-additive reference price).
  4. 4. Draw the star. Produce a star-schema diagram (draw.io, Excalidraw, or a Markdown table) with fact_charge at center and dim_provider, dim_procedure, dim_facility, dim_payer around it. For each dimension name the surrogate key (e.g., provider_sk) and the candidate business/natural key (NPI, CPT/HCPCS/DRG, CCN/EIN, payer_name+plan_name). Note where provider identity is dirty and flag it as a future MDM concern (do not solve it now).
  5. 5. Materialize the Delta skeleton. Complete the scaffold TODOs to CREATE empty, explicitly-typed Delta tables for fact_charge and the four dimensions in your project schema, each dimension carrying its surrogate key, business key, and audit columns (source, ingest_ts). Fact_charge carries the four dimension surrogate keys, the measure columns, and a degenerate line/charge id. Run the create cell twice to confirm it is safe to re-run (idempotent DDL: CREATE TABLE IF NOT EXISTS or CREATE OR REPLACE with empty data).
  6. 6. Write Project Charter v0.1. Using the charter template, write Maria's problem statement (<=5 sentences), a one-paragraph persona, and the 4+ KPIs each with a formula and a measurement grain. Map each KPI to the fact/dimension columns it will need so the model and the charter agree.
  7. 7. Write the 'why' note. In a decisions.md, record: why you chose surrogate keys over NPI, which columns you rejected as measures and why, one schema-drift risk you already see between hospital files, and one open question you are deferring to W15 (grain) or the MDM closer (duplicate providers). These become interview answers.
  8. 8. Self-check against acceptance. Verify column classification is complete, the diagram has all five tables with keys, all five Delta tables exist and are empty/typed, the create cell re-ran cleanly, and the charter has 4+ KPIs with formulas. Commit charter v0.1, decisions.md, the classification table, and the diagram to the project repo/folder.
Data
CMS Hospital Price Transparency (machine-readable pricing files + hospital index)
Mess to handle Every hospital publishes its own file, so format drifts wildly (CSV vs JSON, different column names/order); charges are packed into pipe-delimited columns like 'standard_charge|negotiated_dollar'; prices are blank, zero, negative, or contain text; payer_name/plan_name are free-text and inconsistent; the same CPT/DRG appears with multiple charge types. Great for practicing fact/measure classification and spotting non-additive measures.
NPPES NPI Registry API
Mess to handle Provider records have name and address variants for the same real provider, inconsistent casing/abbreviations, multiple practice locations, organization vs individual (NPI type 1 vs 2), and duplicate-looking entities. Perfect for identifying the provider dimension's natural key (NPI) and previewing the duplicate/identity mess that the later MDM golden-records week resolves. API docs: https://npiregistry.cms.hhs.gov/api-page
NPPES bulk data files (optional, for scale reference only)
Mess to handle Full monthly dump of millions of provider rows with dozens of address/taxonomy columns and historical churn; used this week only to eyeball real column names and volume so the dim_provider design is realistic. Do not load the full file in Free Edition — sample it.
Reference material
</> Starter scaffold — w14_land_and_skeleton.py python
# Databricks notebook — Week 14 starter scaffold (structure only; fill the TODOs)
# Bronze contract: land raw, add source + ingest timestamp, NO transforms.

import requests, json
from datetime import datetime
from pyspark.sql import functions as F

CATALOG = "workspace"           # Free Edition default
SCHEMA  = "pricing_integrity"    # TODO: create if needed
BRONZE  = "/Volumes/workspace/pricing_integrity/bronze"  # TODO: adjust to your volume/path
spark.sql(f"CREATE SCHEMA IF NOT EXISTS {CATALOG}.{SCHEMA}")

# --- 1. Land one CMS hospital pricing file (raw) --------------------------
# TODO: from the CMS hospital index, paste ONE hospital's machine-readable file URL.
CMS_FILE_URL = "<PASTE_A_HOSPITAL_MRF_URL>"
raw = requests.get(CMS_FILE_URL, timeout=60)
landing = f"{BRONZE}/cms_mrf/ingest_date={datetime.utcnow():%Y-%m-%d}"
dbutils.fs.put(f"{landing}/raw.txt", raw.text, overwrite=True)
# TODO: read it back as text/CSV/JSON depending on the file's real format and
#       add columns: source = 'cms_mrf', ingest_ts = current_timestamp(). Keep it RAW.

# --- 2. Pull NPPES providers (raw) ---------------------------------------
# TODO: set city/state to match your CMS hospital so records overlap.
nppes = requests.get(
    "https://npiregistry.cms.hhs.gov/api/",
    params={"version": "2.1", "city": "<CITY>", "state": "<ST>", "limit": 50},
    timeout=60,
).json()
nppes_rows = nppes.get("results", [])
nppes_df = (spark.createDataFrame([json.dumps(r) for r in nppes_rows], "string")
            .withColumnRenamed("value", "raw_json")
            .withColumn("source", F.lit("nppes_api"))
            .withColumn("ingest_ts", F.current_timestamp()))
nppes_df.write.mode("overwrite").format("delta").save(f"{BRONZE}/nppes_raw")

# --- 3. Profile the mess (fill in) ---------------------------------------
# TODO: for BOTH sources print column names, types, null-rate, and 5 sample values.
#       Write down: nested pipe columns, bad prices, free-text payers, NPI dupes, drift.

# --- 4/5. Dimensional skeleton (empty, typed Delta) ----------------------
# TODO: replace the '...' column lists with the columns YOU chose during design.
#       Keep surrogate key + business key + audit columns on each dimension.
spark.sql(f"""CREATE TABLE IF NOT EXISTS {CATALOG}.{SCHEMA}.dim_provider (
  provider_sk BIGINT,          -- surrogate key (generate later)
  npi STRING,                  -- business/natural key
  -- TODO: provider_name, taxonomy, ... (from your NPPES profiling)
  source STRING, ingest_ts TIMESTAMP
) USING DELTA""")
# TODO: dim_procedure (business key = CPT/HCPCS/DRG code), dim_facility (CCN/EIN),
#       dim_payer (payer_name + plan_name) — same pattern as dim_provider.

# TODO: fact_charge — one row per charge event. Include the 4 dimension surrogate
#       keys, your MEASURE columns (mark additive vs non-additive in a comment),
#       a degenerate charge/line id, and audit columns. Create empty & typed only.
# NOTE: do NOT load the fact this week — population comes as silver is built.

# --- 6. Idempotency check ------------------------------------------------
# TODO: re-run the CREATE cell and confirm no error and row counts stay 0.
for t in ["dim_provider"]:  # TODO: add your other 4 tables
    print(t, spark.table(f"{CATALOG}.{SCHEMA}.{t}").count())
Note STARTER SCAFFOLD for Databricks Free Edition. It lands raw CMS + NPPES samples to a Bronze path and stubs the Delta dimensional skeleton. It deliberately does NOT classify columns, design the star, or load the fact — those are your TODOs. Run cells top to bottom in a notebook.
Go deeper
From Gambill Data — videos & templates
Courses & books
Adapt to your industry Mirror the exact pattern on YOUR target industry: pick a domain with a measurable event plus messy descriptive context and a real duplicate-entity problem, then write your own charter + star schema. Examples: SEC EDGAR (sec.gov/edgar) — fact = a reported financial line item, dimensions = filer/company (duplicate CIK/name variants), period, form type; CoinGecko — fact = a price/volume observation, dimensions = coin, exchange, quote currency; NOAA — fact = a weather reading, dimensions = station, date, metric; data.gov permits/inspections — fact = an inspection event, dimensions = business (duplicate names/addresses), location, violation code. Name your persona and 4 KPIs the same way Maria's are named, and flag your own duplicate-entity mess as a future MDM concern.
Deliverable, acceptance & live session
Deliverable & acceptance
A Project Charter v0.1 (Maria problem statement <=5 sentences + persona + 4+ KPIs each with a formula and measurement grain) AND a pricing star-schema package: a diagram of fact_charge + dim_provider/procedure/facility/payer with surrogate and business keys named, a per-column classification table (fact/dimension/degenerate with justifications), five empty typed Delta tables created in Databricks Free Edition, and a decisions.md 'why' note.
Pass when
  • PASS requires: (1) every column in the 25-column CMS/NPPES sample is classified with a one-sentence justification and each candidate measure marked additive/non-additive
  • The star diagram shows all five tables, each dimension with a named surrogate key AND a candidate business key, and provider-identity dirtiness flagged as a future MDM item
  • All five Delta tables exist, are explicitly typed, carry audit columns (source, ingest_ts), and the CREATE cell re-runs with zero errors and unchanged (0) row counts
  • The charter has 4+ KPIs, each with a written formula and grain, and each KPI is mapped to the fact/dimension columns it depends on
  • Decisions.md records the surrogate-vs-NPI choice, at least one rejected measure with reason, and one deferred question (grain to W15 or duplicates to the MDM closer).
Live session focus

Recap Walk through your star-schema diagram in 3 minutes: name the fact grain in plain words, point to each dimension's business key, and show the one column you found hardest to classify. Then read your charter's problem statement and one KPI formula aloud. Group compares how differently each learner's CMS hospital file was shaped (schema drift is the point).

Misconception The dominant misconception to surface and correct: 'a star schema is just one big denormalized table' (or its twin, 'more normalization is always better'). Show that facts and dimensions are deliberately split so measures stay addable and dimensions stay reusable/auditable — and that the negotiated/list price is a NON-additive measure you must not sum, which the one-big-table instinct gets wrong. Also correct 'use the NPI as the primary key' by connecting to next week's grain discussion and the later duplicate-provider MDM problem.

Hot seat "Defend your decision: 'You put standard_charge|negotiated_dollar in the fact and payer_name in a dimension — why not the reverse, and is that charge additive? Justify your surrogate keys over the NPI given that the same provider appears three times in your NPPES pull, and name the one KPI in your charter you would defend to a CMS auditor and exactly which fact/dimension columns compute it.'"

Portfolio Adds the first, most recognition-triggering artifacts to Project 1's README: a charter whose 30-second skim makes a hiring manager say 'we deal with exactly this' (missing prices + duplicate providers = wrong compliance report), a clean star-schema diagram, and a decisions.md that proves a human reasoned about facts vs. dimensions and additive vs. non-additive measures. These are direct interview answers and the backbone the rest of the phase builds on.
Prereqs & carry-forward W13 relational theory (normalization, keys, ER thinking); working Databricks Free Edition workspace with a Delta-capable schema/volume; Python/PySpark basics and requests; ability to call a public REST API and read CSV/JSON. No Unity Catalog governance, Lakeflow/DLT, or CI-CD required (those arrive in Phase 3). Grain and the bus matrix are intentionally NOT prerequisites — they are next week.
W15 Grain, conformed dimensions & the bus matrixMaria's monthly cost report double-counts because negotiated prices arrive at mixed grains (some files are one row per hospital, some per service code, some per payer), and she can't tie pricing to claims because each process built its own 'provider' table. Locking one fact grain and building conformed dimensions is what lets a single spend number survive both the CFO's cost review and a CMS compliance audit. M08 — Dimensional Modeling II · Phase 2 · silver G2G1 ~12h
Why this matters Maria's monthly cost report double-counts because negotiated prices arrive at mixed grains (some files are one row per hospital, some per service code, some per payer), and she can't tie pricing to claims because each process built its own 'provider' table. Locking one fact grain and building conformed dimensions is what lets a single spend number survive both the CFO's cost review and a CMS compliance audit.
Project 1 · this week Advances Project 1 from 'we have facts and dims' (W14) to 'the fact has a defended, enforced grain and its dimensions are conformed for reuse.' Adds the locked GRAIN DECISION (with rejected-alternatives rationale) to the living PROJECT CHARTER, and produces the BUS MATRIX — the first concrete input to the architecture doc's data-flow map (written in full at W20). Sets up W16, where dim_provider gains SCD2 history.
Objectives
  • Write a one-sentence grain declaration for the pricing fact (e.g. 'one row per hospital CCN x billing code x payer/plan x charge-type') and encode it as an explicit GRAIN_KEYS list in code
  • Prove the fact is at the declared grain by running a grain-uniqueness test that returns exactly 0 duplicate grain keys after quarantine
  • Build an enterprise bus matrix (business processes down the rows: pricing, claims; conformed dimensions across the columns) marking every shared dimension with an X
  • Design and materialize at least 2 conformed dimensions (dim_provider, dim_service) with surrogate keys and identical attributes usable by BOTH the pricing and claims processes
  • Update the project charter with the locked grain decision plus a 'rejected alternatives' note explaining why a coarser and a finer grain were both rejected
Concept lessons · ~5h
  • Declaring the grain (Kimball step 2 of 4): grain = the business event you measure, chosen BEFORE picking facts or dimensions; atomic vs summary grain; why 'the primary key of the source file' is not a grain declaration (1.5h)
  • The double-counting trap: additive / semi-additive / non-additive facts, and how the wrong grain makes Maria's total spend over-count when negotiated prices are stored at mixed grains (1.0h)
  • Conformed dimensions & the enterprise bus matrix: what 'conformed' really means (same key, same attributes, same values), drill-across queries, and why one dim_provider must serve both pricing and claims (1.5h)
  • Surrogate keys & degenerate dimensions for a fact at grain: natural key -> surrogate key mapping, degenerate keys that live on the fact, keeping this Type-1/static for now (SCD2 history is next week, W16) (1.0h)
Hands-on lab · ~7h

Take the W14 silver pricing lines and provider rows, declare and enforce the fact grain, quarantine any row that violates it, then carve out two conformed dimensions (provider, service) that both the pricing process and a future claims process will share — proven by a bus matrix and a repeatable, idempotent rebuild.

Step by step
  1. Write the grain declaration as ONE sentence at the top of your notebook README, then encode the same decision as a GRAIN_KEYS list in code so the sentence and the code can never drift apart
  2. Load silver_pricing_lines from W14 and compose a single grain_key by hashing the GRAIN_KEYS columns with sha2(concat_ws('||', ...), 256), coalescing nulls to a sentinel so a missing payer doesn't silently collide
  3. Run the grain-uniqueness test: group by grain_key, count, filter count>1; capture the violators — this MUST end at 0 for the fact to be trustworthy
  4. Build for failure: append every grain violator to a rejected_pricing Delta table with reason='GRAIN_VIOLATION', rejected_at=current_timestamp(), and the source_file, then print a LOUD alert (row count + sample) so a bad republish is never silent
  5. Build dim_provider conformed on NPI/CCN: select the shared attributes (npi, provider_name, org_name, city, state, taxonomy), assign a surrogate key, and write it as a Delta table — Type-1/overwrite only, no SCD2 yet
  6. Build dim_service conformed on billing_code + code_system (CPT/HCPCS/DRG) the same way, resolving the mess where one code carries several conflicting descriptions by keeping the most frequent description per code
  7. Rewrite the pricing fact to carry the surrogate keys (provider_sk, service_sk) plus its degenerate keys, so a drill-across from pricing to a future claims fact will join on the SAME conformed dimension keys
  8. Author the bus matrix as a small markdown/Delta table: rows = pricing, claims (planned); columns = dim_provider, dim_service, dim_date, dim_payer; mark shared dims with X and write one sentence on what conformance buys you
  9. Prove idempotency: run the whole notebook 10x with mode('overwrite') for the dims/fact and append-only for rejects; assert dim and fact row counts are identical across runs, then paste the counts into the charter
Data
CMS Hospital Price Transparency (machine-readable standard-charge files)
Mess to handle Every hospital publishes at a different grain: some files are one row per service code, some bundle all payers into wide columns, some list gross/cash/min/max in separate rows — so a naive load double-counts and no single natural key is unique. Missing standard charges and inconsistent code systems (CPT vs HCPCS vs DRG) are common.
NPPES NPI Registry API
Mess to handle The same provider appears as multiple records with name/address variations and both individual (Type 1) and organizational (Type 2) NPIs, so building one conformed dim_provider means choosing a stable natural key and collapsing variants.
NPPES bulk data files (full monthly dump)
Mess to handle Millions of rows with deactivated NPIs, blank taxonomy codes, and multiple practice-location addresses per provider — you must pick which attributes are conformed (stable across processes) vs which belong to a later slowly-changing dimension.
Reference material
</> Starter scaffold — w15_grain_and_conformed_dims.py python
# Databricks Free Edition — PySpark/Delta — Week 15 STARTER SCAFFOLD
# Provider Pricing Integrity: lock the fact GRAIN + build CONFORMED dims.
# Fill every TODO. This scaffold intentionally does NOT solve the lab.

from pyspark.sql import functions as F

SCHEMA = "ppi_silver"
spark.sql(f"CREATE SCHEMA IF NOT EXISTS {SCHEMA}")

# --- 0. Grain declared IN CODE as the single source of truth --------------
# TODO: write your grain as ONE sentence in the README, then list the columns
#       that make one fact row unique. Decide and DEFEND this in the charter.
GRAIN_KEYS = [
    # "hospital_ccn", "billing_code", "code_system", "payer_plan", "charge_type",
]  # TODO fill

# --- 1. Load W14 silver pricing lines -------------------------------------
pricing = spark.table(f"{SCHEMA}.silver_pricing_lines")

# --- 2. Compose the grain key ---------------------------------------------
# TODO: coalesce each key col to a sentinel, then sha2(concat_ws('||',...),256)
pricing_keyed = pricing  # TODO .withColumn("grain_key", ...)

# --- 3. GRAIN-UNIQUENESS TEST (must reach 0 after quarantine) --------------
dupes = None  # TODO groupBy("grain_key").count().filter(F.col("count") > 1)

# --- 4. Quarantine grain violators (build for failure, be LOUD) -----------
REJECT = f"{SCHEMA}.rejected_pricing"
# TODO: append violators to REJECT with reason='GRAIN_VIOLATION',
#       rejected_at=current_timestamp(), source_file; then print count+sample.

# --- 5. Conformed dimensions (shared by pricing AND future claims) --------
# NOTE: Type-1 / overwrite only. SCD2 history is next week (W16). Do NOT add it.
providers = spark.table(f"{SCHEMA}.silver_providers")
# TODO dim_provider: select conformed attrs (npi, provider_name, org_name,
#      city, state, taxonomy); add provider_sk; write Delta (overwrite).
# TODO dim_service : conform on billing_code+code_system; keep the most
#      frequent description per code; add service_sk; write Delta (overwrite).

# --- 6. Idempotency: 10 reruns => identical dim/fact counts ----------------
# TODO: rewrite the fact with provider_sk/service_sk + degenerate keys,
#       mode('overwrite'); print counts; assert they don't move across runs.

print("SCAFFOLD ONLY — steps above are not implemented yet")
Note STARTER SCAFFOLD only — lands structure and stubs. Every TODO is where you take over; it does not compute a passing result on its own.
Go deeper
From Gambill Data — videos & templates
Courses & books
Adapt to your industry Mirror the grain-and-conformance pattern on YOUR target industry. SEC EDGAR (sec.gov/edgar): grain = one row per filer(CIK) x filing x financial-line-item x period; conform dim_entity on CIK so it serves both a filings process and an insider-transactions process. CoinGecko: grain = one row per coin x exchange x snapshot-timestamp; conform dim_asset across a price process and a market-cap process. NOAA: grain = one row per station x day x measure; conform dim_station across temperature and precipitation processes. Whatever your domain: declare the grain in one sentence, build a bus matrix with at least two processes, and prove one shared dimension serves both.
Deliverable, acceptance & live session
Deliverable & acceptance
A committed notebook that (a) declares the pricing fact grain as one sentence + a GRAIN_KEYS list, (b) enforces it with a grain-uniqueness test ending at 0 and a rejected_pricing quarantine table (reason + timestamp + alert), (c) materializes dim_provider and dim_service as conformed Delta dimensions with surrogate keys, and (d) a bus-matrix artifact (markdown or table) covering the pricing and planned claims processes — plus a charter update recording the grain decision and rejected finer/coarser alternatives.
Pass when
  • Grain stated in exactly one sentence and mirrored by GRAIN_KEYS in code
  • Grain-uniqueness test returns 0 duplicate grain keys after quarantine
  • Rejected_pricing contains every violator with reason='GRAIN_VIOLATION', a timestamp, and the source file, and the run prints a visible alert
  • At least 2 conformed dimensions exist with surrogate keys and are referenced by the fact via those keys
  • The bus matrix shows >=2 processes and marks at least one shared dimension
  • Running the notebook 10x yields identical dim and fact row counts (pasted into the charter)
  • Charter's grain section names one coarser and one finer grain that were rejected and why.
Live session focus

Recap Each learner reads their one-sentence grain declaration aloud and shows the grain-uniqueness test hitting 0, then displays their bus matrix and points to the single dimension that both pricing and claims will share.

Misconception 'The grain is just the primary key of the source file.' It isn't — grain is the business event you choose to measure, declared before you pick facts or dimensions, and it is frequently FINER than any single messy source row. A related trap: thinking 'conformed' only means the two dimensions have the same name, when it actually requires the same key, same attributes, and same values.

Hot seat "Defend your grain: 'Why one row per hospital x code x payer x charge-type, and not the coarser one-row-per-hospital-x-code (dropping payer)? Show exactly what breaks in Maria's spend total at the coarser grain.' Then defend conformance: 'Why one dim_provider shared with claims instead of a separate provider table per process — and what drill-across query would silently return wrong numbers if you didn't conform it?'"

Portfolio Add to the Project 1 repo: the bus matrix (rendered as a table image or markdown) in the README, the one-sentence grain declaration with its rejected-alternatives note, and the conformed dimension DDL/schema — the exact 30-second recognition signal a hiring manager scans for ('this person declares grain and conforms dimensions on purpose').
Prereqs & carry-forward W14 completed: silver_pricing_lines and silver_providers Delta tables exist from the facts-and-dims week. Databricks Free Edition workspace with a Delta-enabled schema and the CMS price-transparency + NPPES data already landed to bronze. Comfortable with PySpark DataFrame ops and Spark SQL; no SCD2 knowledge required yet (that is W16).
W16 Recoverable History: SCD Type 2 & Effective Dating on the Provider and Price DimensionsA payer disputes a charge four months after service and an auditor asks Maria, "what price did this hospital publish for this billing code on January 15th?" If silver overwrote the price on every CMS republish, that answer is gone — and the compliance report is indefensible. SCD2 with effective dating keeps every version, so any past price or provider attribute is recoverable on demand. M09 — SCD & History Tracking (SCD Type 2) · Phase 2 · silver G3G2 ~11h
Why this matters A payer disputes a charge four months after service and an auditor asks Maria, "what price did this hospital publish for this billing code on January 15th?" If silver overwrote the price on every CMS republish, that answer is gone — and the compliance report is indefensible. SCD2 with effective dating keeps every version, so any past price or provider attribute is recoverable on demand.
Project 1 · this week Advances Project 1 (Provider Pricing Integrity) by turning the W15 grain-correct silver feed into two historized SCD Type 2 Delta dimensions — silver_provider_dim and silver_price_dim — with effective dating, so provider and price history is recoverable. Adds two living artifacts: (1) a written change-tracking policy (Type 2 vs Type 1 per column) that will slot directly into the idempotency-plan section of the 4-part Architecture Doc written in W20, and (2) a 'recoverable history' entry against the Project Charter started in W14, feeding the data-freshness/history KPI line. The audit-log rows started here become the audit trail assembled at W30.
Objectives
  • Implement an SCD Type 2 MERGE in Delta that closes the superseded current row (sets effective_end_ts, is_current=false) and inserts a new version when a tracked attribute changes — verified against two CMS snapshots.
  • Distinguish tracked (Type 2) from overwrite (Type 1) columns and defend the choice per column in a written change-tracking policy tied to Maria's audit needs.
  • Detect real changes with a row hash over tracked columns only, so untracked-column noise does NOT open spurious new versions.
  • Prove idempotency: re-apply an identical snapshot 10x and confirm zero new versions opened and stable row counts.
  • Answer a point-in-time (as-of) query that reconstructs exactly one provider/price version per business key for an arbitrary past date, proving no interval gaps or overlaps.
Concept lessons · ~5h
  • SCD types 0/1/2/3 — what each does to history, and why a data engineer defaults to Type 2 for auditable dimensions while still using Type 1 for corrections you never want preserved (0.75h).
  • The effective-dating model: effective_start_ts, effective_end_ts, is_current, and a surrogate key; half-open intervals [start, end) and why they prevent gaps/overlaps (1.0h).
  • Change detection with row hashes: hashing only tracked columns, null-safe concat (coalesce to a sentinel) so concat_ws doesn't silently collapse nulls, and why this makes reruns cheap and idempotent (0.75h).
  • Delta MERGE mechanics for SCD2 — the two-step 'mark-then-insert' pattern, and why a single MERGE cannot both close the old row and insert its replacement for the same key (1.0h).
  • Point-in-time / as-of queries: reconstructing the state true on a past date, and validating that exactly one version resolves per business key (0.75h).
  • Idempotency and late/re-published snapshots for SCD2: how unchanged rows must be no-ops, and what happens when CMS re-publishes the same file (0.75h).
Hands-on lab · ~6h

Take two CMS price-transparency snapshots (plus NPPES provider attributes) and historize them into two SCD Type 2 Delta dimensions — silver_provider_dim and silver_price_dim — with effective_start_ts / effective_end_ts / is_current / surrogate_key. Build a change-tracking policy (which columns are Type 2 vs Type 1), detect changes with a tracked-column row hash, apply the two-step mark-then-insert MERGE, quarantine key-violating rows, prove idempotency across 10 reruns, and answer a point-in-time audit query for Maria.

Step by step
  1. Assemble two snapshots: take the W15 grain-correct silver feed as snapshot 1 (snapshot_ts = an earlier CMS republish date), then obtain a second, later CMS price file for the same hospital(s) as snapshot 2. Where a genuine second CMS snapshot isn't available for your facility, deliberately mutate a copy (change a negotiated_rate, a discounted_cash price, a provider's practice_city, and add one brand-new billing code) and document exactly what you changed and why — this simulates real CMS drift.
  2. Write the change-tracking policy: for EACH column in the provider and price dimensions, mark it Type 2 (track history), Type 1 (overwrite in place), or key/attribute. Justify at least three decisions in one sentence each tied to Maria (e.g. negotiated_rate = Type 2 because auditors reconstruct past prices; a fixed data-entry typo in provider_name = Type 1 because you never want the wrong value in history).
  3. Add a null-safe row hash over ONLY the tracked columns (sha2 over concat_ws with each tracked value coalesced to a '<NULL>' sentinel). Confirm two rows differing only in an UNTRACKED column produce the same hash, and two rows differing in a tracked value produce different hashes.
  4. Seed load: if the dimension does not yet exist, write snapshot 1 as all-current versions — effective_start_ts = snapshot_ts, effective_end_ts = NULL, is_current = true, surrogate_key = uuid(). Verify one current row per business key.
  5. Incremental SCD2 MERGE (two steps): join snapshot 2 to the current rows on the business key and classify each incoming row as new-key / changed / unchanged. Step A — MERGE to close changed current rows (set effective_end_ts = snapshot 2's snapshot_ts, is_current = false). Step B — append the changed rows AND the new keys as new current versions. Unchanged rows must be no-ops.
  6. Quarantine + audit: before hashing, route rows with a null/blank business key (e.g. missing NPI, missing billing code) to a *_rejected table with reason + timestamp and log a loud row count; append one audit-log row per run capturing rows_in, versions_closed, versions_opened, rows_rejected.
  7. Idempotency test: re-apply snapshot 2 ten times in a loop and assert versions_opened == 0 after the first application and total row counts are unchanged. If a rerun opens new versions, your unchanged-classification or hash is wrong — fix it before moving on.
  8. Point-in-time validation: run an as-of query for a date between the two snapshots and a date after both. Assert exactly ONE version resolves per business key for any as-of date (no gaps, no overlaps). Show the before-price and after-price for one facility to prove history is recoverable.
  9. Write the why-note (interview prep): document your interval convention ([start, end) half-open), why the MERGE had to be two steps, which columns you tracked vs overwrote and why, and how you proved idempotency. Be specific — these become your interview answers.
Data
CMS Hospital Price Transparency (two snapshots)
Mess to handle Hospitals publish machine-readable pricing files of wildly varying format and quality; between two publish dates, negotiated rates and cash prices change, billing codes appear/disappear, and column names/units drift — the exact temporal change SCD2 must capture. Where a true second snapshot for your facility isn't posted yet, you simulate drift by mutating a documented copy.
NPPES NPI Registry API
Mess to handle Provider attributes (practice address, name spelling, taxonomy) change over time and are inconsistently formatted across records; the same provider can show name/address variations across snapshots — realistic Type 2 change plus the null/blank NPI rows you must quarantine before hashing.
NPPES bulk data files (monthly full dump)
Mess to handle The full monthly provider dump is millions of rows; comparing two monthly files surfaces genuine attribute changes (deactivations, address updates) so you can historize real provider drift instead of only simulated changes. Large volume forces you to hash-diff rather than compare every column.
Reference material
</> Starter scaffold — W16_silver_scd2_scaffold.py python
# W16_silver_scd2_scaffold.py
# SCAFFOLD ONLY - sets up structure + the two-step SCD2 pattern.
# You implement every TODO. Do NOT paste a full solution here; the point is
# that YOU can defend each MERGE clause in the live hot seat.
#
# Runs on Databricks Free Edition (Delta + PySpark). Assumes W15 landed a
# grain-correct silver feed. Here we historize it into an SCD2 dimension.

from pyspark.sql import functions as F
from delta.tables import DeltaTable

# --- 0. Config -------------------------------------------------------------
CATALOG_PATH   = "workspace.default"            # adjust to your Free Edition schema
DIM_TABLE      = f"{CATALOG_PATH}.silver_provider_dim"   # SCD2 target
# For the PRICE dimension, re-run this file with these swapped:
#   DIM_TABLE = f"{CATALOG_PATH}.silver_price_dim"
BUSINESS_KEY   = ["npi"]                          # natural/business key that IDENTIFIES the entity
# TODO: for price_dim the business key is a COMPOSITE (e.g. npi + code_type + billing_code + payer)
#       - decide the key at the grain W15 fixed, and justify it in your why-note.

# Columns whose CHANGE should open a new version (Type 2 / tracked):
TRACKED_COLS   = ["provider_name", "practice_city", "practice_state", "taxonomy_code"]
# TODO: for price_dim, TRACKED_COLS is the price fields (gross_charge, discounted_cash,
#       negotiated_rate...). Decide per column: Type 2 (track) vs Type 1 (overwrite,
#       e.g. a corrected typo you never want in history). Write that decision down.

# --- 1. Load the incoming snapshot ----------------------------------------
# `snapshot_ts` is the CMS republish date for THIS run - the effective date of any change.
def load_snapshot(snapshot_ts: str):
    src = (spark.table(f"{CATALOG_PATH}.silver_provider_clean")  # W15 output
                .withColumn("snapshot_ts", F.lit(snapshot_ts).cast("timestamp")))
    # TODO: drop/repair rows that violate the business key (null NPI) BEFORE hashing -
    #       route them to a *_rejected table with reason + timestamp (build-for-failure).
    return src

# --- 2. Change-detection hash ---------------------------------------------
# A stable hash over ONLY the tracked columns lets us detect a real change
# without comparing every field, and prevents spurious new versions when an
# untracked column wiggles.
def add_row_hash(df):
    # TODO: sha2(concat_ws over TRACKED_COLS, coalescing nulls to a sentinel like '<NULL>')
    #       Why coalesce? concat_ws silently drops nulls -> two different rows could hash equal.
    return df  # TODO: return df.withColumn("row_hash", F.sha2(...))

# --- 3. First (seed) load --------------------------------------------------
# If the dimension doesn't exist yet, every incoming row is a brand-new
# current version. Open the interval, leave the end open, mark is_current.
def seed_dimension(df):
    seeded = (df.withColumn("effective_start_ts", F.col("snapshot_ts"))
                .withColumn("effective_end_ts",   F.lit(None).cast("timestamp"))  # open interval
                .withColumn("is_current",         F.lit(True))
                .withColumn("surrogate_key",      F.expr("uuid()")))
    # TODO: write `seeded` as a Delta table at DIM_TABLE. Guard it: only seed if
    #       the DeltaTable does not already exist. This branch runs ONCE.
    pass

# --- 4. Incremental SCD2 MERGE (the two-step pattern) ---------------------
# You CANNOT both close an old row and insert its replacement in a single
# MERGE, because MERGE matches one target row per source key. So:
#   Step A: MERGE - for keys whose row_hash CHANGED, set end_ts + is_current=False.
#   Step B: INSERT - the changed rows AND the truly-new keys, as new current versions.
def apply_scd2(df):
    dim = DeltaTable.forName(spark, DIM_TABLE)
    current = spark.table(DIM_TABLE).filter("is_current = true")
    # TODO: join df to `current` on BUSINESS_KEY; classify each incoming row as
    #       'new_key' | 'changed' | 'unchanged' by comparing row_hash.
    #       (unchanged -> do NOTHING; that is what makes reruns idempotent.)

    # ----- Step A: close out the superseded current rows -----
    # TODO: dim.alias("t").merge(changed.alias("s"), key match AND t.is_current = true)
    #        .whenMatchedUpdate(set = {"effective_end_ts": "s.snapshot_ts",
    #                                   "is_current": "false"}).execute()
    #       Prove your interval [start, end) is half-open: no gaps, no overlaps.

    # ----- Step B: insert new current versions (changed + new keys) -----
    # TODO: to_insert = changed U new_key, with effective_start_ts = snapshot_ts,
    #       effective_end_ts = NULL, is_current = true, surrogate_key = uuid();
    #       then append to DIM_TABLE.
    pass

# --- 5. Point-in-time (as-of) validation ----------------------------------
def as_of(as_of_ts: str):
    return spark.sql(f"""
        SELECT * FROM {DIM_TABLE}
        WHERE effective_start_ts <= TIMESTAMP('{as_of_ts}')
          AND (effective_end_ts  >  TIMESTAMP('{as_of_ts}') OR effective_end_ts IS NULL)
    """)
    # TODO: assert exactly ONE row per business key for any as_of_ts. Two rows ->
    #       your intervals overlap -> bug is in the Step A/B boundary handling.

# --- 6. Orchestration ------------------------------------------------------
if __name__ == "__main__":
    # TODO: if DIM_TABLE missing -> seed_dimension(add_row_hash(load_snapshot(SNAP1)))
    #        else               -> apply_scd2(add_row_hash(load_snapshot(SNAP2)))
    # TODO: append an audit-log row: run_ts, snapshot_ts, rows_in, versions_closed,
    #        versions_opened, rows_rejected  (append-only _audit table).
    # TODO: IDEMPOTENCY TEST - apply_scd2 on the SAME snapshot 10x and assert
    #        versions_opened == 0 after the first application.
    pass
Note STARTER SCAFFOLD ONLY — it wires up the two-step SCD2 structure (config, snapshot load, hash stub, seed branch, mark-then-insert MERGE skeleton, as-of validator, audit/idempotency hooks) and leaves every transformation as a commented TODO. It does not implement the MERGE clauses, the hash, or the classification — you do, so you can defend each clause in the hot seat.
Go deeper
From Gambill Data — videos & templates
Courses & books
Adapt to your industry Mirror the pattern on your own target industry by picking a source that RE-PUBLISHES over time and historizing two snapshots. SEC EDGAR (sec.gov/edgar): a company's reported facts/address/officers change between filings — SCD2 the company dimension so you can reconstruct 'what did we know on the 10-K date?'. CoinGecko: token metadata/market-cap-rank shifts snapshot to snapshot. NOAA: station attributes/instrument changes over time. data.gov permit or license registries: status and holder change across monthly extracts. Locked-down domain? Use Faker to emit two snapshots where a controlled subset of tracked attributes changed. Same deliverable: effective-dated Type 2 dimension + change-tracking policy + point-in-time query on YOUR entity.
Deliverable, acceptance & live session
Deliverable & acceptance
Two SCD Type 2 Delta dimensions (silver_provider_dim and silver_price_dim) built from two snapshots, each with effective_start_ts / effective_end_ts / is_current / surrogate_key; a written change-tracking policy (Type 2 vs Type 1 per column, with 3+ justified decisions); a *_rejected quarantine table and an append-only audit-log table; a documented 10x idempotency test result; a point-in-time as-of query notebook showing one facility's before/after price; and a why-note capturing the interval convention, the two-step MERGE rationale, and the idempotency proof.
Pass when
  • Applying snapshot 2 closes exactly the changed current rows (effective_end_ts set, is_current=false) and opens new current versions for changed + new keys, with unchanged rows untouched
  • Re-applying snapshot 2 ten times opens zero additional versions and leaves row counts stable (idempotency proven, not asserted)
  • An as-of query for any date returns exactly one version per business key — no gaps, no overlaps — and demonstrably reproduces the pre-change price for a disputed facility
  • Key-violating rows land in *_rejected with reason + timestamp and each run writes an audit row with versions_closed / versions_opened / rows_rejected
  • The change-tracking policy and why-note are present and specific to Maria's audit needs.
Live session focus

Recap Walk the room through your two-step SCD2 MERGE on a real changed facility: show the old version closed and the new version opened, side by side. Then run your as-of query live for a date between the two snapshots and prove exactly one version resolves. State your interval convention out loud and your idempotency result (versions_opened after rerun #2 through #10).

Misconception The big one: trying to do SCD2 in a single MERGE that both updates the old row and inserts the new one — it can't, because MERGE matches one target row per source key, so you either lose the close-out or the insert. Related traps: closing the interval with an overlapping boundary (using <= on both ends) so an as-of query returns two versions; hashing untracked columns (or letting concat_ws drop nulls) so identical business rows look 'changed' and every rerun explodes the table; and treating a Type 1 correction as Type 2, permanently baking a typo into history.

Hot seat "Defend three decisions: (1) Which columns did you make Type 2 vs Type 1, and why — name one column you deliberately overwrite and justify destroying its history to Maria's auditor. (2) Show your interval boundary and prove no as-of date can return two versions (why half-open, not closed-closed). (3) Prove your pipeline is idempotent when CMS re-publishes an identical file — what exactly makes an unchanged row a no-op, and how did your 10x test confirm it?"

Portfolio A README section titled 'Recoverable Price & Provider History (SCD2)' opening with the 1-2 sentence business framing (the 4-month-late payer dispute), a before/after screenshot of one facility's closed and open versions, the change-tracking policy table, the idempotency proof (10x, versions_opened=0), and a link to the as-of query. This is the artifact that lets a hiring manager recognize 'we deal with exactly this — reconstructing what a value was on a past date.'
Prereqs & carry-forward W15 grain (a grain-correct, deduplicated silver feed with a declared business key at the right grain); comfort with Delta MERGE and PySpark DataFrame joins/window basics; Databricks Free Edition workspace with the W14 charter and the bronze/silver tables from prior weeks in place; understanding of append-only bronze and the silver layer contract (clean + conformed) from earlier in the phase.
W17 Lakehouse & gold-layer modeling — the semantic layerMaria's monthly cost analysis and CMS compliance report are only as trustworthy as the numbers she can actually query — right now she copies prices out of raw files by hand and cannot tell a hospital's leadership what % of required services even have a published price. A governed gold mart plus one canonical completeness KPI turns 'I think the data is incomplete' into a defensible, auditable number that revenue-cycle and compliance can both cite. M10 · Phase 2 · silver G2G10 ~11.75h
Why this matters Maria's monthly cost analysis and CMS compliance report are only as trustworthy as the numbers she can actually query — right now she copies prices out of raw files by hand and cannot tell a hospital's leadership what % of required services even have a published price. A governed gold mart plus one canonical completeness KPI turns 'I think the data is incomplete' into a defensible, auditable number that revenue-cycle and compliance can both cite.
Project 1 · this week Adds the GOLD layer to Project 1: fact_price_by_facility_procedure + conformed dimensions + the kpi_pricing_completeness table + the vw_price_transparency semantic view — the first artifacts Maria can query directly. Extends the living PROJECT CHARTER by promoting '% facilities passing the pricing-completeness check per run' from an intended KPI to a computed, reconciled one, and adds the gold layer + its serve-only contract to the ARCHITECTURE DOC's data-flow map and the gold write-strategy (replaceWhere/MERGE) to the idempotency plan.
Objectives
  • Design and declare an explicit GRAIN for fact_price_by_facility_procedure (one row per facility x billing-code x payer x plan x charge-setting) and defend the grain in a written modeling note
  • Build a star schema (1 fact + conformed dim_facility, dim_provider, dim_procedure) from SCD2 silver, deriving CURRENT-view dimensions without history fan-out
  • Author the pricing-completeness KPI as ONE canonical Spark SQL definition, compute it per ingest run, and reconcile the number back to silver within a tolerance you state
  • Expose a semantic VIEW (business-named columns, surrogate keys hidden) that enforces the serve-only contract — no transforms downstream
  • Make the gold build idempotent (replace-partition / MERGE on grain key) and prove identical row counts + identical KPI values across 10 consecutive reruns
Concept lessons · ~5.25h
  • Lakehouse gold layer & the serve-only contract: why gold is not 'SELECT * from silver' — denormalized for consumption, business-named, never re-cleaned, read-optimized (0.75h)
  • Dimensional modeling for the lakehouse: fact vs dimension, declaring grain first, conformed dimensions, degenerate dimensions, surrogate vs business keys, additive/semi-additive/non-additive measures (1.5h)
  • Consuming SCD2 silver into gold: current-view vs as-of snapshots, and how joining full history fans out a fact table and silently double-counts money (1.0h)
  • The semantic layer: governed single-source KPI definitions, business naming, VIEW vs materialized table trade-offs, and why the metric definition must live in exactly one place (1.0h)
  • Gold KPIs as tested metrics: writing the pricing-completeness definition, guarding against grain-driven double counting, and reconciling gold measures back to silver as an audit (1.0h)
Hands-on lab · ~6.5h

Turn the conformed, historized SILVER model (built W14–16, SCD2 finished W16) into the GOLD marts Maria actually uses: a star schema fact_price_by_facility_procedure with conformed dimensions, a per-run pricing-completeness KPI defined once in canonical SQL, and a business-named semantic view that hides silver internals. The build is serve-only (no re-cleaning), idempotent, reconciled back to silver, and any grain-violating rows are quarantined with a reason and timestamp — a mart that breaks loudly, never silently.

Step by step
  1. Write the GRAIN statement first. In a markdown modeling note declare: 'one row of fact_price_by_facility_procedure = one facility x one billing code (CPT/HCPCS/DRG) x one payer x one plan x one charge-setting (inpatient/outpatient).' List which columns are the grain key and why negotiated_price is a NON-additive measure (you cannot sum prices across procedures). This note is your interview answer — be specific.
  2. Build CURRENT-view dimensions from SCD2 silver: dim_provider and dim_facility by filtering silver to is_current = true (do NOT carry all SCD2 versions into gold — prove to yourself with a count why that would fan out the fact). Build dim_procedure from the CMS billing-code set. Assign a surrogate key to each dimension and keep the source business key (npi, ccn, code) alongside it.
  3. Build fact_price_by_facility_procedure by joining silver pricing to the three dimensions on business keys, then keeping ONLY surrogate keys + measures (negotiated_price, gross_charge, discounted_cash_price as decimal(12,2)) + degenerate attributes (payer, plan, setting) + run_id + ingest_ts. Assert this is gold: no cleaning, no null-fixing here — if silver is dirty, that is a silver bug.
  4. Enforce the grain: check for duplicate grain keys in the fact. Route any duplicate-grain rows to gold_rejected with reason = 'grain_violation', the offending key, and a rejected_ts, and print an alert line + count. A silent duplicate here double-counts Maria's spend — fail loudly.
  5. Define the pricing-completeness KPI ONCE. Write a single canonical SQL: per facility, completeness_pass = (facility has a non-null negotiated_price for every code in the required shoppable-services set). Aggregate to kpi_pricing_completeness(run_id, run_ts, facilities_total, facilities_passing, pct_passing). Never redefine this metric anywhere else — the view and any report must read from this table.
  6. Create the semantic view vw_price_transparency with business-named, serve-only columns (facility_name, procedure_description, payer, plan, negotiated_price, pricing_complete_flag) — surrogate keys and silver internals hidden. Write two example analyst queries Maria would run (price for a procedure across facilities; facilities failing completeness) so a reviewer sees the mart is usable.
  7. Make it idempotent and prove it: write the fact with replaceWhere on run_id (or MERGE on the grain key) and the KPI table by overwrite-per-run. Run the whole gold build 10x in a loop and assert identical fact row count AND identical pct_passing every time; capture the run counts in your why-note.
  8. Reconcile gold to silver as an audit: compare distinct facility count and total distinct (facility, code) pairs in the fact against silver, and log any delta to gold_reconciliation(run_id, metric, silver_value, gold_value, delta) with a stated tolerance. Then update the Project Charter KPI list (pricing-completeness %) and add the gold layer to the architecture doc's data-flow map.
Data
CMS Hospital Price Transparency (machine-readable pricing files)
Mess to handle Every hospital publishes its own MRF format — the same measure appears as 'standard_charge|negotiated', 'negotiated_dollar', or 'payer_specific'; codes mix CPT/HCPCS/DRG with no code-type column; negotiated_price is frequently missing or given as a percentage or 'N/A'; payer/plan names are inconsistent strings. This is exactly why a completeness KPI and a declared grain are needed before anything can be summed.
NPPES NPI Registry API
Mess to handle Provider/facility records arrive with name and address variations and duplicate registrations for the same real-world entity, so building dim_facility/dim_provider you will see the same CCN or organization under multiple spellings — for W17 you resolve to the CURRENT SCD2 version only; true duplicate collapse is deferred to the W18 MDM week.
NPPES bulk monthly data file
Mess to handle Millions of rows in a monthly full dump with columns that drift between releases and blank/placeholder values in address and taxonomy fields — reused from silver here to source dimension attributes; do not re-clean it in gold, that belongs in silver.
Reference material
</> Starter scaffold — 17_gold_price_transparency_scaffold.py python
# Databricks Free Edition | PySpark + Spark SQL | Delta
# Week 17 - GOLD: price-by-facility-procedure mart + completeness KPI + semantic view
# CONTRACT: gold = business-ready, SERVE-ONLY. No cleaning here. If silver is dirty, fix silver.

from pyspark.sql import functions as F

CATALOG   = "main"          # TODO: set your schema names
SILVER    = f"{CATALOG}.silver"
GOLD      = f"{CATALOG}.gold"
RUN_ID    = dbutils.widgets.get("run_id") if dbutils.widgets.getAll() else "manual_run"  # TODO

# --- 1. CURRENT-view dimensions from SCD2 silver -----------------------------
# Silver providers/facilities are SCD2 (is_current flag from W16). Gold uses CURRENT only.
silver_fac = spark.table(f"{SILVER}.dim_facility_scd2").where("is_current = true")
# TODO: assign a surrogate key + keep business key (ccn). Build dim_provider, dim_procedure the same way.
# TODO: PROVE (a count) why carrying ALL scd2 versions would fan out the fact. Write it in your note.

# --- 2. Fact: declare grain, then build it -----------------------------------
# GRAIN: one row = facility x billing_code x payer x plan x charge_setting  (write this in the note)
silver_price = spark.table(f"{SILVER}.pricing_clean")
# TODO: join silver_price to the 3 dims on BUSINESS keys; keep ONLY surrogate keys + measures
#       + degenerate attrs (payer, plan, setting) + run_id + ingest_ts.
#       Measures as decimal(12,2). No null-fixing here.
fact = None  # TODO build me

# --- 3. Enforce the grain (fail loudly) --------------------------------------
# TODO: find rows whose grain key is duplicated; route them to GOLD.gold_rejected
#       with reason='grain_violation', the key, and rejected_ts. Print an alert + count.

# --- 4. Idempotent write ------------------------------------------------------
# TODO: write `fact` with .option("replaceWhere", f"run_id = '{RUN_ID}'") (or MERGE on grain key)
# fact.write.format("delta").mode("overwrite").option("replaceWhere", ...).saveAsTable(f"{GOLD}.fact_price_by_facility_procedure")

# --- 5. Canonical completeness KPI (define ONCE) -----------------------------
# The ONLY place this metric is defined. The view and any report must read this table.
kpi_sql = f"""
  -- TODO: per facility, completeness_pass = has non-null negotiated_price for EVERY required code.
  -- Aggregate to: run_id, run_ts, facilities_total, facilities_passing, pct_passing
  SELECT NULL AS run_id  -- replace me
"""
# spark.sql(kpi_sql).write.format("delta").mode("overwrite")\
#      .option("replaceWhere", f"run_id = '{RUN_ID}'").saveAsTable(f"{GOLD}.kpi_pricing_completeness")

# --- 6. Semantic view (business-named, serve-only) ---------------------------
spark.sql(f"""
CREATE OR REPLACE VIEW {GOLD}.vw_price_transparency AS
SELECT
    -- TODO: expose facility_name, procedure_description, payer, plan,
    --       negotiated_price, pricing_complete_flag. HIDE surrogate keys + silver internals.
    NULL AS facility_name
FROM {GOLD}.fact_price_by_facility_procedure f
-- TODO: join dims for the human-readable names; join kpi for the complete flag
""")

# --- 7. Reconcile gold -> silver (audit) -------------------------------------
# TODO: compare distinct facility count (and distinct facility+code pairs) gold vs silver.
#       Log deltas to GOLD.gold_reconciliation(run_id, metric, silver_value, gold_value, delta)
#       with a stated tolerance. A non-zero unexplained delta is a bug, not a rounding note.

# --- 8. Idempotency proof (run this AFTER the build works) --------------------
# TODO: loop the whole build 10x; assert identical fact.count() AND identical pct_passing each time.
#       Capture the 10 identical numbers in your why-note.
Note STARTER SCAFFOLD ONLY — it wires up the gold star schema, the KPI table, the semantic view, an idempotent write, and reconciliation stubs. The joins, the grain-enforcement, the canonical KPI SQL, and the 10x idempotency assertion are left as TODOs for you. Do NOT fill in the full solution here — the decisions are your interview answers.
Go deeper
From Gambill Data — videos & templates
Courses & books
Adapt to your industry Mirror the exact pattern on YOUR target industry's silver model. SEC EDGAR: build fact_metric_by_company_period (grain = company x fiscal-period x metric) from your SCD2 filing silver, a completeness KPI = % companies with all required line items filed, and a vw_financials semantic view. CoinGecko: fact_price_by_asset_exchange (grain = asset x exchange x snapshot) + a coverage KPI = % assets with a price on every tracked exchange. NOAA: fact_reading_by_station_day + a completeness KPI = % stations reporting all required measures. Keep the moves identical: declare the grain first, take CURRENT-view dims from your SCD2 silver, define ONE canonical KPI, expose a business-named serve-only view, quarantine grain violations, and prove 10x idempotency.
Deliverable, acceptance & live session
Deliverable & acceptance
A gold star schema on Delta (fact_price_by_facility_procedure + dim_facility, dim_provider, dim_procedure), a per-run kpi_pricing_completeness table defined by exactly one canonical SQL, a business-named serve-only semantic view vw_price_transparency, a gold_rejected table capturing grain violations (reason + key + timestamp), a gold_reconciliation log, and a written modeling 'why' note covering the grain decision, current-view-vs-history choice, non-additivity of price, and view-vs-table choice — all wired into the running pipeline and reflected in the updated charter + architecture doc.
Pass when
  • The fact table has a declared grain and ZERO duplicate grain keys in the served table — any duplicates are in gold_rejected with reason='grain_violation' + timestamp and an alert was printed. (2) Dimensions use CURRENT SCD2 rows only (is_current=true), verified by count. (3) pricing-completeness pct_passing is produced by ONE canonical SQL, stored per run_id, and reconciles to silver within your stated tolerance (logged in gold_reconciliation). (4) vw_price_transparency returns only business-named columns with surrogate keys hidden, and two working analyst queries are shown. (5) Running the gold build 10x yields identical fact row count AND identical pct_passing, with the 10 numbers captured in the why-note. (6) No cleaning/null-fixing logic exists in gold code.
Live session focus

Recap Walk the gold layer: how it differs from silver (denormalized, business-named, serve-only, never re-cleaned), the star schema you built, and the single canonical completeness KPI. Show vw_price_transparency answering a real Maria question and the 10x-identical idempotency numbers.

Misconception "Gold is just SELECT * from silver (or one giant joined table), and analysts should get the full SCD2 history so they can see everything." Wrong twice: gold is a deliberately modeled, serve-only star with a declared grain, and joining full SCD2 history into a fact fans it out and silently double-counts money — you take current-view dims and put history-as-of logic in silver, not in the mart the analyst sums.

Hot seat "Defend three decisions on the spot: (1) State your fact grain and justify why negotiated_price is non-additive — what breaks if someone SUMs it across procedures? (2) Why did you expose the completeness metric as a VIEW reading one canonical KPI table instead of hard-coding the calculation in the view (or in a BI tool)? (3) You built dimensions from is_current=true only — a reviewer asks 'what about a facility that changed names mid-quarter?' Explain why that as-of question belongs in silver history, not in this gold mart."

Portfolio Add a 'Gold layer & semantic model' section to the Project 1 README: a star-schema diagram (fact + 3 dims with the grain stated in one sentence), the canonical completeness-KPI SQL, the two example analyst queries against vw_price_transparency with result screenshots, the pct_passing KPI trended across runs, and the 10x-identical idempotency evidence — framed by the 1–2 sentence business stakes for Maria's compliance report.
Prereqs & carry-forward W14–16 completed: conformed, typed, quality-gated SILVER with quarantine (W14–15) and finished SCD2 history with is_current flag (W16). Comfort with Databricks Free Edition, Delta table writes, Spark SQL joins/aggregations, and the bronze/silver/gold layer contracts. NO Unity Catalog, DLT/Lakeflow, or DABs required — those arrive in Phase 3.
W18 MDM I — Reference Data, Crosswalks & Match KeysMaria's monthly cost analysis is wrong because the SAME hospital shows up as several provider records with slightly different names and addresses, so her spend and compliance rollups double-count and mislabel facilities. Before anyone can trust a single price per provider, the pipeline needs governed reference data and a defensible way to find which records are candidates for being the same entity — cheaply, at scale, and without comparing every row to every other row. M11 — Master Data Management I · Phase 2 · silver G1G2 ~10.5h
Why this matters Maria's monthly cost analysis is wrong because the SAME hospital shows up as several provider records with slightly different names and addresses, so her spend and compliance rollups double-count and mislabel facilities. Before anyone can trust a single price per provider, the pipeline needs governed reference data and a defensible way to find which records are candidates for being the same entity — cheaply, at scale, and without comparing every row to every other row.
Project 1 · this week This is the FIRST half of the golden-records closer for Project 1. It produces the three inputs W19 needs to actually resolve duplicates: (a) governed provider reference data in silver.dim_provider_ref, (b) the xwalk_provider crosswalk that will bind pricing rows to a canonical provider, and (c) blocked candidate_pairs. It advances the running pipeline's silver layer and adds the MDM/entity-resolution section (reference-data model, crosswalk design, blocking strategy) to the ARCHITECTURE DOC — specifically extending the data-flow map and the tool-selection log started in W20 prep, and adding the new blocking KPI 'candidate-pair reduction ratio' plus 'records quarantined for no block key' to the PROJECT CHARTER's KPI list.
Objectives
  • Build a governed provider REFERENCE table in silver (silver.dim_provider_ref) by standardizing NPPES name/address attributes (casefold, punctuation strip, USPS suffix normalization, entity-type split), persisted as Delta and idempotently MERGE-upserted on NPI.
  • Design and populate a CROSSWALK table (xwalk_provider) linking pricing-file facility rows to NPPES NPI to a canonical facility_key, carrying an explicit match_status column so every downstream mart joins on exactly one key.
  • Generate at least TWO deterministic blocking/match keys and produce candidate provider pairs WITHIN blocks, then measure the reduction from N-squared to actual comparisons and report block coverage (recall proxy).
  • Detect and handle blocking failures loudly: quarantine records with no usable blocking attribute (reason + timestamp) and flag oversized/skewed blocks that would explode the pair count.
  • Prove idempotency: rerun the reference + crosswalk + blocking build 10 times and assert identical dim row counts AND identical candidate-pair counts.
Concept lessons · ~3.5h
  • Reference vs. master vs. transactional data (~0.75h): the three data classes and why they live in different layers — a provider registry is reference/master data that belongs in a silver dimension, while pricing rows are transactional; the layer contract (silver = clean+conformed+historized, gold = serve-only) and where a crosswalk sits.
  • Crosswalks & the shared-key problem (~0.5h): what a crosswalk table is, natural vs. surrogate keys, why CMS price-transparency files almost never carry a clean NPI, and the match_status lifecycle (unmatched → candidate → resolved) that W19 will consume.
  • Standardization & normalization for matching (~0.75h): why raw strings never join — casefolding, punctuation/whitespace stripping, USPS street-suffix abbreviations, and phonetic encodings (soundex/metaphone); splitting NPPES entity type 1 (individual) vs 2 (organization) because they match on different attributes.
  • Blocking & match keys — beating N-squared (~0.75h): the combinatorial cost of all-pairs comparison, blocking predicates as a recall-vs-cost tradeoff, why you run MULTIPLE blocking passes (union of blocks) to protect recall, and how a single bad block key (e.g. state alone) creates skewed mega-blocks.
  • Candidate-pair generation without a cartesian (~0.5h): self-join strictly within a block, deduping symmetric pairs with an a<b ordering rule, and block-size guardrails that cap or quarantine oversized blocks before they detonate the pair count.
Hands-on lab · ~7h

Turn the raw NPPES provider registry into governed reference data, wire it to the pricing facilities with a crosswalk, and lay the entity-resolution groundwork: standardize match attributes, compute two-plus deterministic blocking keys, and emit candidate provider pairs within blocks — quarantining un-blockable records and flagging skewed mega-blocks. You produce the inputs W19 will score and collapse into golden records; you do NOT score or survivor-merge anything yet.

Step by step
  1. FRAME IT: write the 2-sentence business framing at the top of the notebook (duplicate providers corrupt Maria's cost + compliance rollups) and open the NPPES sample to name the specific mess you see — e.g. 'ST. MARY'S' vs 'SAINT MARYS', suite lines in address_1, trailing NPI whitespace, org vs individual rows mixed together.
  2. BUILD THE REFERENCE TABLE: from the silver NPPES landing, standardize match attributes into silver.dim_provider_ref — upper+trim, strip punctuation (regexp_replace), normalize USPS street suffixes (ST->STREET, AVE->AVENUE) via a small mapping, zero-pad/validate ZIP to zip5, and split entity_type_code 1 (individual: last/first name) vs 2 (organization: org name). Persist as Delta partitioned by state.
  3. MERGE-UPSERT ON NPI (idempotent): write dim_provider_ref with Delta MERGE keyed on NPI so a rerun updates in place instead of appending. This is the deliberate idempotency choice for a reference dimension — record WHY (stable natural key) in your why-note.
  4. BUILD THE CROSSWALK: create xwalk_provider linking each pricing-file facility row (source_system, source_record_id) to an NPI where one exists and to a canonical facility_key, with match_status defaulting to 'unmatched' for the (many) pricing rows that carry no clean NPI. This is the table W19 will fill in.
  5. COMPUTE BLOCKING KEYS: add >=2 deterministic block keys to the reference records — e.g. block_key_1 = soundex(org_or_last_name) || state, block_key_2 = zip5 || substring(name,1,3). Explain in the why-note why two passes (union) protects recall vs. any single key.
  6. QUARANTINE UN-BLOCKABLE RECORDS: any record missing every blocking attribute (no name AND no zip) goes to a rejected table with reason='no_usable_block_key' + ingest timestamp + source; count them and emit a loud alert row to the audit log (silent drops are worse than loud).
  7. GENERATE CANDIDATE PAIRS: self-join records WITHIN each block only, keep pairs with an a<b ordering rule to kill symmetric duplicates, and BEFORE the join compute block sizes — cap or flag any block above a threshold (e.g. >500 members) as 'oversized_block' so a bad key can't explode the pair count. Persist candidate_pairs (id_a, id_b, block_key, block_pass).
  8. MEASURE + AUDIT: write run metrics to the audit log — records standardized, records quarantined (by reason), distinct blocks, oversized blocks flagged, candidate pairs generated, and the reduction ratio (candidate_pairs / (N*(N-1)/2)). Then RERUN the whole build 10x and assert dim row count and candidate-pair count are identical each time; capture the proof in the why-note.
Data
NPPES bulk data files (full monthly provider dump)
Mess to handle Millions of rows; the same real provider recurs as multiple NPIs and near-duplicate name/address variants ('ST. MARY'S' vs 'SAINT MARYS MED CTR'), suite/floor text jammed into address_1, inconsistent USPS abbreviations, deactivated records mixed in, and entity_type_code 1 (individual) vs 2 (organization) sharing one flat schema. Take a state slice to keep it Free-Edition-sized.
NPPES NPI Registry API
Mess to handle Great for pulling a targeted set of duplicate/variant provider records to test blocking recall; paginated (limit 200), rate-limited (expect 429s), and returns nested JSON (addresses[], taxonomies[]) that must be flattened before standardization. Docs: https://npiregistry.cms.hhs.gov/api-page
CMS Hospital Price Transparency machine-readable files
Mess to handle Hospitals publish pricing files of wildly varying format/quality that frequently DO NOT carry a clean NPI — which is exactly why a crosswalk + blocking is needed to attach pricing rows to the right provider by name/address instead of a key.
Reference material
</> Starter scaffold — w18_reference_and_blocking.py python
# Week 18 - MDM I: reference data, crosswalks & match keys (STARTER SCAFFOLD)
# BUSINESS FRAMING: duplicate provider records make Maria's cost + compliance
# rollups double-count. This week we build governed reference data and the
# candidate pairs W19 will score. We do NOT score or merge here.

from pyspark.sql import functions as F

SILVER_NPPES   = "silver.nppes_provider"        # landed+typed from earlier weeks
SILVER_PRICING = "silver.pricing_facility"      # pricing facility rows
DIM_REF        = "silver.dim_provider_ref"
XWALK          = "silver.xwalk_provider"
PAIRS          = "silver.candidate_pairs"
REJECTED       = "silver.rejected_records"
AUDIT          = "silver.audit_log"

# ---------------------------------------------------------------
# 1) STANDARDIZE match attributes into the reference dimension
# ---------------------------------------------------------------
src = spark.table(SILVER_NPPES)

# TODO: casefold + trim + strip punctuation on name/address columns.
#       Hint: F.upper(F.trim(F.regexp_replace(col, r"[^A-Za-z0-9 ]", "")))
# TODO: normalize USPS street suffixes (ST->STREET, AVE->AVENUE, ...).
#       Build a small mapping and apply it; do NOT hardcode one example.
# TODO: derive zip5 (validate/zero-pad) and keep state.
# TODO: split entity_type_code: 1 = individual (last/first), 2 = organization
#       (org name) -> a single std_name column chosen by entity type.
ref = (src
    .withColumn("npi", F.trim(F.col("npi")))
    # .withColumn("std_name", ...)   # TODO
    # .withColumn("std_addr", ...)   # TODO
    # .withColumn("zip5",     ...)   # TODO
)

# ---------------------------------------------------------------
# 2) IDEMPOTENT upsert of the reference dimension (MERGE on NPI)
# ---------------------------------------------------------------
# TODO: write `ref` to DIM_REF the first run (Delta, partitionBy state),
#       then convert to a MERGE keyed on NPI so reruns update in place.
#       WHY note: NPI is a stable natural key -> MERGE, not append.

# ---------------------------------------------------------------
# 3) CROSSWALK skeleton (W19 fills match_status)
# ---------------------------------------------------------------
# TODO: build xwalk_provider = one row per pricing facility source record:
#       (source_system, source_record_id, npi_or_null, facility_key,
#        match_status='unmatched'). Most pricing rows have NO clean NPI.

# ---------------------------------------------------------------
# 4) BLOCKING KEYS (>=2 deterministic passes)
# ---------------------------------------------------------------
# TODO: block_key_1 = soundex(std_name) concat state
# TODO: block_key_2 = zip5 concat substring(std_name,1,3)
# blocked = ref.withColumn("block_key_1", ...).withColumn("block_key_2", ...)

# ---------------------------------------------------------------
# 5) QUARANTINE un-blockable records (loud, not silent)
# ---------------------------------------------------------------
# TODO: rows with no name AND no zip -> REJECTED with
#       reason='no_usable_block_key', ingest_ts, source. Count + audit.

# ---------------------------------------------------------------
# 6) CANDIDATE PAIRS within blocks (NO cartesian) + skew guard
# ---------------------------------------------------------------
def pairs_for(df, key):
    # TODO: compute block sizes; flag/cap blocks over a threshold
    #       (e.g. >500) as 'oversized_block' before joining.
    a = df.select(F.col("npi").alias("id_a"), F.col(key).alias("bk"))
    b = df.select(F.col("npi").alias("id_b"), F.col(key).alias("bk"))
    return (a.join(b, "bk")
              .where(F.col("id_a") < F.col("id_b"))   # kill symmetric dups
              .withColumn("block_key", F.lit(key)))
# TODO: union pairs_for(blocked,"block_key_1") and block_key_2, dedup, write PAIRS.

# ---------------------------------------------------------------
# 7) AUDIT + reduction ratio, then rerun 10x to prove idempotency
# ---------------------------------------------------------------
# TODO: log records_standardized, records_quarantined, n_blocks,
#       oversized_blocks, candidate_pairs, reduction_ratio to AUDIT.
# TODO: run this notebook 10x; assert DIM_REF count and PAIRS count are
#       identical every run. Paste the proof into your why-note.
Note STARTER SCAFFOLD ONLY — sets up the reference-table, crosswalk, and blocking structure and lands the shapes; TODO markers are where YOU do the real work. It deliberately does NOT implement standardization logic, the full USPS map, pair scoring, or survivorship (W19). Run in a Databricks Free Edition notebook.
Go deeper
From Gambill Data — videos & templates
Courses & books
Adapt to your industry Mirror the exact pattern on YOUR target industry's duplicate-entity problem, swapping only the sources. Finance/markets: dedupe issuers/filers across SEC EDGAR (https://www.sec.gov/edgar) where the same company appears under CIK variants and messy company names — block on soundex(company_name)+state_of_incorp. Crypto: reconcile the same asset across exchanges via CoinGecko (contract-address vs. ticker collisions). Public sector/aid: dedupe organizations across ReliefWeb or data.gov grantee lists. Locked-down or fraud domains: synthesize duplicate customers with Faker (same person, typo'd name + address). In every case build a reference dimension, a crosswalk with a match_status column, and >=2 deterministic blocking keys — do NOT score or merge yet.
Deliverable, acceptance & live session
Deliverable & acceptance
A committed Databricks notebook + Delta tables that (1) build silver.dim_provider_ref from standardized NPPES attributes via an idempotent MERGE on NPI, (2) create the xwalk_provider crosswalk skeleton with a match_status column, (3) emit silver.candidate_pairs from >=2 blocking passes, (4) quarantine un-blockable records to a rejected table with reason+timestamp, and (5) a short WHY-NOTE (markdown) documenting the standardization rules chosen, why two blocking passes, the measured reduction ratio, and the 10x idempotency proof.
Pass when
  • Reference dim has exactly one row per NPI after a rerun (no duplicates from re-appends). xwalk_provider exists with a populated match_status column (unmatched default). candidate_pairs contains only within-block pairs with id_a < id_b (zero symmetric duplicates, zero cross-block pairs). Every record lacking all blocking attributes appears in the rejected table with reason='no_usable_block_key' + timestamp + source, and its count is echoed to the audit log. The audit log reports the reduction ratio (candidate_pairs vs full N-squared) and oversized-block flags. Running the notebook 10 times yields identical dim row count and identical candidate-pair count. No pair scoring, no survivorship, no golden-record merge is present (those are W19).
Live session focus

Recap Walk the cohort through the entity-resolution funnel drawn on one slide: raw provider rows -> standardized reference dim -> blocking keys -> candidate pairs (this week stops here) -> scoring + survivorship (next week). Demo one duplicate provider ('ST. MARY'S' vs 'SAINT MARYS') falling into the SAME block under block_key_1 but showing why a second pass on zip is needed to catch a third variant that soundex missed. Share the reduction-ratio number each learner got and discuss the spread.

Misconception The two big misconceptions to correct: (1) 'blocking finds the duplicates' — no, blocking only proposes CHEAP candidates; it trades recall for cost, and W19 does the actual matching. If your blocking drops a true duplicate, no downstream scorer can ever recover it, so recall (coverage) matters more than precision here. (2) 'more/bigger blocks are safer' — a lazy key like state-only creates mega-blocks whose self-join is nearly the N-squared you were trying to avoid; skew is the silent killer, which is why you measure block sizes and flag oversized blocks before joining.

Hot seat "Each learner defends: 'Show me your blocking keys. What true duplicate does this scheme MISS, and what is your recall estimate?' Follow-ups: Why two passes instead of one, and what did the union buy you in the reduction ratio? Why MERGE on NPI for the reference dim instead of overwrite or append — what breaks on rerun with each alternative? A pricing row with no NPI just landed — walk me through exactly how your crosswalk represents it today and what W19 will do with it. And: you flagged an oversized block — did you cap it, quarantine it, or split it with a finer key, and why is that the right call for Maria's compliance deadline?"

Portfolio Adds the MDM foundation section to Project 1's README/architecture: an entity-resolution funnel diagram, the standardization decision table (which rules, why), the blocking-key design with its measured N-squared reduction ratio, and the crosswalk/match_status data model. This is strong recognition-test material — a healthcare or any-industry hiring manager skimming the README sees 'governed reference data + blocking-based entity resolution with a recall argument', which reads as real production MDM, not a tutorial.
Prereqs & carry-forward W17 gold marts complete (silver validated/conformed + gold serving layer exist). Comfortable with PySpark DataFrame API and Spark SQL string functions (regexp_replace, soundex, substring), Delta MERGE/upsert semantics (from earlier silver weeks), the quarantine/rejected-table + audit-log pattern established in Phase 2, and a working Databricks Free Edition workspace with the NPPES + CMS pricing data already landed to bronze/silver.
W19 MDM II — Entity Resolution & Golden Records: collapsing duplicate providers into one trustworthy recordMaria's monthly cost analysis and CMS compliance report are wrong because the same hospital and physician group show up as several different provider records — so a facility's prices look 'missing' when they exist under a duplicate, and spend is double-counted. Collapsing duplicates into one trusted golden record per provider is what makes her numbers defensible to auditors and to Finance. M11 — Master Data Management · Phase 2 · silver G2G1G9 ~11.0h
Why this matters Maria's monthly cost analysis and CMS compliance report are wrong because the same hospital and physician group show up as several different provider records — so a facility's prices look 'missing' when they exist under a duplicate, and spend is double-counted. Collapsing duplicates into one trusted golden record per provider is what makes her numbers defensible to auditors and to Finance.
Project 1 · this week This is THE CLOSER of the MDM arc: it converts W18's blocked candidates into published GOLDEN provider records — the trustworthy spine that GOLD marts and Maria's KPIs sit on. It adds three durable artifacts to Project 1: the golden_providers + golden_lineage tables, the provider_review_queue (a governance/stewardship artifact), and a new Charter KPI line ('# duplicate providers collapsed into golden records' per run). Its survivorship logic, threshold decision, and idempotency approach become raw material for next week's (W20) 4-part Architecture Doc — specifically the failure plan and idempotency plan.
Objectives
  • Given a blocked candidate set from W18, generate candidate PAIRS and score each pair with at least three similarity features (edit distance on name, address similarity, phonetic/soundex agreement) — no all-pairs cross joins
  • Combine feature scores into a single match confidence in [0,1] and defend a chosen AUTO-MERGE threshold and a REVIEW band with a labeled sample (precision/recall trade-off stated in words)
  • Cluster matched pairs into entity groups and apply written SURVIVORSHIP rules (most-recent, most-complete, source-priority) to emit exactly ONE golden record per real provider, with a stable golden_provider_id
  • Route every low-confidence pair to a governed human-review QUEUE table (pair, features, score, reason, timestamp, status) instead of silently guessing
  • Make the whole resolution idempotent: rerun 10× and prove golden-record counts, golden IDs, and the review queue are stable (MERGE on deterministic keys, no duplicate golden rows)
Concept lessons · ~3.5h
  • Entity resolution end to end (0.75h): the pipeline blocking → pairwise scoring → clustering → survivorship → golden record; why exact joins fail on real data and why NPI alone is NOT enough (same provider, multiple NPIs; one NPI reused/typo'd across files)
  • Fuzzy matching mechanics (0.75h): Levenshtein/edit distance vs. token-based similarity vs. phonetic (soundex) — what each catches and misses; the classic 'William→Illiam', 'St'→'Saint', 'Dr.'→'Drive', suite-number, and abbreviation problems; using Spark's built-in levenshtein() and soundex() so it runs on Free Edition with no extra installs
  • From feature scores to a decision (0.75h): weighting features into one confidence, choosing an auto-merge threshold and a review band, and the precision/recall trade-off framed as money/risk — a false merge hides a real facility's prices; a false split double-counts a provider in Maria's cost report
  • Survivorship & golden records (0.5h): clustering matched pairs into groups (connected components / transitive closure intuition), then survivorship rules (recency, completeness, source priority) and lineage — every golden field traceable to the source row it came from
  • Human-in-the-loop stewardship (0.5h): the review queue as a governance artifact — schema, statuses (pending/approved/rejected), why a steward's decision must be captured and fed back, and how this closes the ownership gap for a data product
  • Idempotency for MDM (0.25h): why golden IDs must be deterministic (hash of stable cluster members, not a monotonic surrogate that changes every run) so reruns and CMS republishes don't churn golden_provider_id
Hands-on lab · ~7.5h

Take the blocked candidate set your W18 SILVER job produced and turn it into GOLDEN records. Generate candidate pairs inside each block, score each pair with edit-distance, address, and phonetic features, combine them into a confidence, auto-merge high-confidence pairs into clusters, apply written survivorship rules to emit one golden record per provider, and route everything in the ambiguous middle band to a human-review queue. Prove the whole thing reruns identically 10×.

Step by step
  1. 1. RECOGNITION README first: write the 1–2 sentence business framing (duplicate providers break Maria's cost + compliance numbers) and name the SPECIFIC mess in your candidate set — e.g. 'HCA HOUSTON HEALTHCARE' vs 'Hca Houston Health Care', 'ST LUKES' vs 'Saint Lukes', suite-number-only address differences, and one provider holding two NPIs. Paste 5 real duplicate examples pulled from NPPES.
  2. 2. Load the W18 blocked candidates from SILVER (provider records already standardized + assigned a blocking key). Within each block, generate candidate PAIRS with a self-join filtered to id_left < id_right (never a full cross join) so you compare only plausible duplicates. Log the pair count and the largest block size (watch for a runaway block).
  3. 3. Build similarity FEATURES per pair with Spark built-ins only: normalized levenshtein() on standardized name, a token/levenshtein similarity on standardized address line, and a soundex() equality flag on the last name / core name token. Add same-state and same-postal-prefix boolean features. Keep each feature in [0,1].
  4. 4. Combine features into a single match_confidence (start with a documented weighted sum — TODO in the scaffold). Hand-label ~30 pairs as true/false duplicates, then pick an AUTO-MERGE threshold and a REVIEW band (e.g. >=0.90 auto, 0.65–0.90 review, <0.65 non-match). Write ONE paragraph defending the cut in precision/recall + money terms.
  5. 5. Cluster the auto-merge pairs into entity groups (transitive closure: if A~B and B~C then A,B,C are one provider). Use an iterative min-member-id label propagation over the accepted pairs (scaffolded) and assign each cluster a DETERMINISTIC golden_provider_id = hash of the sorted stable source keys — not a monotonically increasing id.
  6. 6. Apply SURVIVORSHIP rules to pick each golden field: most-recent ingest for volatile fields (address, phone), most-complete (non-null, longest) for name, and source-priority for NPI. Emit ONE golden row per cluster to a GOLDEN Delta table, plus a golden_lineage table mapping golden_provider_id → every contributing source row + which field it won.
  7. 7. QUARANTINE + REVIEW QUEUE: write every review-band pair to a review_queue Delta table with pair ids, all feature values, match_confidence, reason ('confidence in review band'), ingest timestamp, and status='pending'. Write clearly-broken inputs (null name, malformed NPI) to the existing rejected table with reason. Emit a one-line alert with counts.
  8. 8. IDEMPOTENCY proof: MERGE golden + review rows on their deterministic keys, then run the whole notebook 10× and assert golden_provider_id set, golden row count, and review_queue count are identical every run. Capture the KPI row: # duplicates collapsed, # golden records, # pairs sent to review, % change vs last run.
  9. 9. Write the 'why' note (interview answers): why levenshtein+soundex over exact join, why THIS threshold, which survivorship rule you chose per field and the failure it prevents, and why golden IDs are hashed. Update the KPI section of the Project Charter with 'duplicate providers collapsed into golden records' for this run.
Data
NPPES NPI Registry API (live provider records — duplicates & name/address variants)
Mess to handle The same real provider appears under multiple NPIs and with inconsistent spellings/casings/punctuation ('Saint' vs 'St', 'Dr.' vs 'Drive'), abbreviated org names, and address differences that are only a suite number — the exact fuzzy-match cases you must resolve. API docs: https://npiregistry.cms.hhs.gov/api-page
NPPES bulk monthly data file (full provider dump for a real dedup challenge set)
Mess to handle Millions of rows with genuine near-duplicate organizations (hospital systems filed slightly differently across locations), 'other name' / 'doing-business-as' variants of the same entity, deactivated-then-reissued NPIs, and mixed formatting — a real (not synthetic) duplicate population to block, pair, and resolve.
CMS Hospital Price Transparency files (the downstream that duplicates corrupt)
Mess to handle Hospitals self-publish pricing files under facility names that don't match NPPES exactly, so a duplicate/unresolved provider causes prices to look missing or spend to double-count — this is WHY the golden record matters, and you'll spot-check that resolved providers now join cleanly to a price file.
Reference material
</> Starter scaffold — silver_to_golden_entity_resolution.py python
# silver_to_golden_entity_resolution.py
# Databricks Free Edition + Delta + PySpark. Run as a notebook.
# W18 produced SILVER provider records with a standardized name/address + a blocking_key.
# This week: pairs -> similarity features -> confidence -> cluster -> golden + review queue.

from pyspark.sql import functions as F, Window

SILVER = "workspace.default.silver_providers"        # standardized + blocking_key from W18
GOLDEN = "workspace.default.golden_providers"
LINEAGE = "workspace.default.golden_lineage"
REVIEW = "workspace.default.provider_review_queue"

silver = spark.table(SILVER)  # cols: src_key, npi, name_std, addr_std, state, postal, ingest_ts, source

# --- 1) Candidate PAIRS within each block (NO full cross join) ---
l = silver.alias("l"); r = silver.alias("r")
pairs = (l.join(r, (F.col("l.blocking_key") == F.col("r.blocking_key")) &
                  (F.col("l.src_key") < F.col("r.src_key")))   # dedupe + no self-pair
         .select(F.col("l.src_key").alias("key_left"), F.col("r.src_key").alias("key_right"),
                 F.col("l.name_std").alias("name_l"), F.col("r.name_std").alias("name_r"),
                 F.col("l.addr_std").alias("addr_l"), F.col("r.addr_std").alias("addr_r"),
                 F.col("l.state").alias("state_l"), F.col("r.state").alias("state_r"),
                 F.col("l.postal").alias("postal_l"), F.col("r.postal").alias("postal_r")))
# TODO: log pairs.count() and the max block size; if one block explodes, tighten the W18 blocking_key.

# --- 2) Similarity FEATURES (Spark built-ins only) ---
def norm_lev(a, b):
    # 1 - (edit_distance / max_len)  -> in [0,1]; higher = more similar
    maxlen = F.greatest(F.length(a), F.length(b), F.lit(1))
    return F.lit(1.0) - (F.levenshtein(a, b) / maxlen)

feat = (pairs
        .withColumn("f_name", norm_lev(F.col("name_l"), F.col("name_r")))
        .withColumn("f_addr", norm_lev(F.col("addr_l"), F.col("addr_r")))
        .withColumn("f_soundex", (F.soundex(F.col("name_l")) == F.soundex(F.col("name_r"))).cast("double"))
        .withColumn("f_state", (F.col("state_l") == F.col("state_r")).cast("double")))
# TODO: add a postal-prefix feature and, if you like, a token-overlap feature for org names.

# --- 3) Combine into ONE confidence (defend your weights) ---
# TODO: replace this naive equal weighting after you hand-label ~30 pairs.
conf = feat.withColumn("match_confidence",
        (F.col("f_name")*0.4 + F.col("f_addr")*0.3 + F.col("f_soundex")*0.2 + F.col("f_state")*0.1))

# --- 4) DECISION bands ---
AUTO, REVIEW_LO = 0.90, 0.65   # TODO: justify these from your labeled sample (precision vs recall).
accepted = conf.filter(F.col("match_confidence") >= AUTO)
to_review = conf.filter((F.col("match_confidence") >= REVIEW_LO) & (F.col("match_confidence") < AUTO))

# --- 5) CLUSTER accepted pairs into entity groups (transitive closure) ---
# TODO: implement iterative label propagation: each node's label = min(own, neighbors');
#       iterate until labels stop changing. Then golden_provider_id = a DETERMINISTIC hash of
#       the sorted src_keys in the cluster (F.sha2), NOT monotonically_increasing_id().

# --- 6) SURVIVORSHIP -> one golden row per cluster ---
# TODO: per golden_provider_id, choose fields by rule:
#       address/phone = most-recent ingest_ts; name = most-complete (longest non-null);
#       npi = source-priority. Write GOLDEN, and write LINEAGE (golden_id -> src_key -> field_won).

# --- 7) REVIEW QUEUE (governance artifact) ---
review_rows = (to_review
    .withColumn("reason", F.lit("confidence_in_review_band"))
    .withColumn("status", F.lit("pending"))
    .withColumn("queued_ts", F.current_timestamp()))
# TODO: MERGE review_rows into REVIEW on (key_left, key_right) so reruns don't duplicate the queue.

# --- 8) IDEMPOTENCY ---
# TODO: MERGE golden + lineage on their deterministic keys; then run this notebook 10x and assert
#       golden count, golden_provider_id set, and REVIEW count are identical every run.

# --- KPI line for the Charter ---
# TODO: print { collapsed = input_rows - golden_rows, golden_rows, review_rows, pct_change_vs_last }
Note STARTER SCAFFOLD for Databricks Free Edition — lands structure and the blocking→pairing→feature skeleton using only Spark built-ins (no pip installs). TODO markers are where the learner does the real thinking: weighting, threshold, clustering, survivorship, idempotent MERGE. Not a solution.
Go deeper
From Gambill Data — videos & templates
Courses & books
Adapt to your industry Mirror the exact pattern on YOUR target industry's duplicate-entity problem, keeping every step (block → pair → fuzzy-score → threshold → cluster → survivorship → golden + review queue). Finance/markets: dedupe issuers/companies across SEC EDGAR filings (same company, many CIK-linked name variants) — https://www.sec.gov/edgar. Crypto: reconcile the same asset across exchanges/aliases via CoinGecko — https://www.coingecko.com/en/api. Public sector/NGO: dedupe organizations across data.gov or ReliefWeb feeds. Weather/IoT: resolve duplicate station identities in NOAA data. Locked-down/fraud domains: generate a labeled duplicate set with Faker (inject name/address noise yourself) so you control ground truth. The William→Illiam problem, the survivorship rules, and the human-review queue are industry-agnostic — only the entity and the source change.
Deliverable, acceptance & live session
Deliverable & acceptance
A working silver→golden entity-resolution notebook on Databricks Free Edition that publishes: (1) golden_providers (exactly one record per resolved provider, deterministic golden_provider_id), (2) golden_lineage (every golden field traced to its winning source row), (3) provider_review_queue (all review-band pairs with features, confidence, reason, timestamp, status), plus a README with the business framing, 5 real NPPES duplicate examples, a defended threshold paragraph, a survivorship-rules table, and a 10×-rerun idempotency proof with the run's KPI line.
Pass when
  • Passes when: (a) pairs are generated via blocked self-join with no full cross join and the largest block is bounded
  • (b) each pair carries >=3 similarity features in [0,1] combined into one confidence
  • (c) auto-merge threshold and review band are stated AND defended against a >=30-pair hand-labeled sample in precision/recall + money terms
  • (d) golden_providers has exactly one row per cluster with a DETERMINISTIC (hashed) golden_provider_id and a survivorship rule named per field
  • (e) every review-band pair lands in provider_review_queue with reason+timestamp+status and clearly-broken inputs land in the rejected table
  • (f) running the notebook 10× yields identical golden count, identical golden_provider_id set, and identical review-queue count
  • (g) README reports the KPI row (# collapsed, # golden, # in review, % change vs last run).
Live session focus

Recap Recap: W18 gave us standardization + blocking (cheap candidate generation); W19 turns candidates into truth. The chain is block → pair → fuzzy-score → threshold → cluster → survivorship → golden, with a human-review queue catching the ambiguous middle. Reinforce: NPI alone is not identity, silent auto-merges are worse than a loud review queue, and golden IDs must be deterministic so reruns don't churn.

Misconception The #1 misconception: 'a higher threshold is always safer.' It isn't — pushing the auto-merge threshold up shrinks false merges (precision) but leaves MORE true duplicates unresolved (recall), which double-counts providers in Maria's cost report and re-hides prices. The threshold is a business trade-off between two different failures, not a knob to max out. Second trap: using monotonically_increasing_id() (or row_number) for golden_provider_id — it changes every run and breaks idempotency and every downstream join.

Hot seat "Defend-your-decision hot seat: 'You set auto-merge at 0.90 and a 0.65–0.90 review band. Walk me through the two pairs on either side of that line and the money consequence of moving it 0.05 in each direction. Then defend ONE survivorship rule — why did the address come from most-recent but the name from most-complete, and what specific wrong number in Maria's report does each rule prevent? Finally: prove your golden_provider_id survives a rerun and a CMS republish unchanged.'"

Portfolio Adds the golden-records centerpiece to Project 1's repo: the entity-resolution notebook, golden_providers / golden_lineage / provider_review_queue tables, a README with real NPPES duplicate examples + defended threshold + survivorship table + idempotency proof, and the updated Charter KPI section. This is the artifact that lets the learner answer 'how did you dedupe entities and decide what to auto-merge vs. review?' — a top-tier data-engineering interview question — with a specific, provable story.
Prereqs & carry-forward W18 (MDM I) complete: SILVER provider records are already standardized (casing/punctuation/abbreviation normalization) and carry a blocking_key; comfort with PySpark joins, window functions, when/otherwise, and Delta MERGE; the existing rejected/quarantine table and audit-log pattern from earlier SILVER weeks; a Databricks Free Edition workspace with the bronze→silver pipeline runnable.
W20 ETL vs ELT, Batch vs Incremental — Write the 4-Part Architecture DocMaria's monthly cost analysis and CMS compliance report are only as trustworthy as the pipeline behind them — and right now she cannot answer an auditor's 'how do you know this number is right, and can you reproduce it?' A written architecture doc (paradigm, processing mode, tool choices, failure and recovery posture) is what turns a pile of notebooks into a defensible data product she can sign her name to. M13 — Architecture & Design Decisions · Phase 2 · silver G9G6G1 ~11.5h
Why this matters Maria's monthly cost analysis and CMS compliance report are only as trustworthy as the pipeline behind them — and right now she cannot answer an auditor's 'how do you know this number is right, and can you reproduce it?' A written architecture doc (paradigm, processing mode, tool choices, failure and recovery posture) is what turns a pile of notebooks into a defensible data product she can sign her name to.
Project 1 · this week This week produces the second LIVING DELIVERABLE: the 4-PART ARCHITECTURE DOC (data-flow map, tool-selection log, failure plan, idempotency plan) for Project 1, committed as ARCHITECTURE.md. It formally decides ELT + medallion + incremental batch and defends each choice against alternatives. It pairs with the Project Charter (started W14) and will be added to across the phase and fully assembled at W30. It sets the contract that W21 (ingestion→bronze) must implement.
Objectives
  • Decide ELT-over-ETL for Project 1 IN WRITING, citing at least 2 project-specific reasons tied to Delta Lake + Databricks Free Edition (e.g., cheap columnar storage lets raw land first; Spark SQL does the transform in-place).
  • Produce a data-flow map that documents, for bronze→silver→gold, the GRAIN, the layer CONTRACT (raw / clean+conformed+historized / serve-only), and the exact transform boundary crossed between each pair of layers.
  • Write a tool-selection log that names at least 3 alternatives considered (e.g., Snowflake, dbt+Postgres, plain Airflow+pandas) and gives a specific, defensible rejection reason for each versus Databricks+Delta.
  • Author a failure plan enumerating at least 4 named failure modes (429 from NPPES API, schema drift in a hospital pricing file, late-arriving CMS republish, duplicate provider explosion in MDM) each with a DETECTION signal and a RESPONSE (quarantine / alert / fail-loud).
  • Author an idempotency plan that assigns a rerun strategy (MERGE-on-key vs replace-partition vs full-overwrite) to EACH layer and specifies the 10x-rerun test that proves identical row counts.
Concept lessons · ~6.5h
  • ETL vs ELT — where transforms live and why the lakehouse pushes work downstream. Extract-Load-Transform lands raw bytes in Delta first (bronze), then transforms with Spark SQL in-place; contrast with classic ETL where transforms happen in flight before landing. Why ELT wins when storage is cheap, sources are messy/re-parseable, and you want replayable raw. Map this directly onto the CMS pricing files (you cannot pre-clean formats you haven't seen yet). (1.5h)
  • Batch vs incremental vs streaming — the reprocessing decision. Full reload vs delta/incremental load vs continuous streaming; latency, cost, and complexity trade-offs. Why CMS republishing monthly + NPPES monthly bulk = INCREMENTAL batch, not streaming; how idempotent MERGE makes incremental safe; when a full overwrite is actually the correct idempotency choice for a small dimension. Closes G6. (1.5h)
  • The medallion architecture as enforceable LAYER CONTRACTS, not folders. Bronze = raw landed, no transforms, append-only, source + ingest timestamp; silver = validated, typed, conformed, historized (SCD2), quality-gated + quarantine; gold = business-ready, serve-only marts/KPIs. What is FORBIDDEN at each layer and why a contract you can point to is what separates a data product from a script. (1.0h)
  • Architecture Decision Records (ADRs) and the tool-selection log — writing a decision so it survives you. Structure: context → options considered → decision → consequences. Why 'we chose Databricks' is worthless without the rejected alternatives; how a tool-selection log IS your interview answer to 'why this stack?'. (1.0h)
  • Failure plans and idempotency plans as first-class design artifacts. Naming failure modes before they happen (429s, schema drift, late records, duplicate explosion); detection vs response; loud-not-silent. Choosing MERGE vs replace-partition vs overwrite per layer and designing the 10x-rerun proof. (1.5h)
Hands-on lab · ~5h

No new data this week — this is a design + decision week. The learner writes the 4-PART ARCHITECTURE DOC for Project 1 (Provider Pricing Integrity): a data-flow map (bronze→silver→gold with grain + contract per layer), a tool-selection log (why Databricks+Delta, with rejected alternatives), a failure plan (named failure modes → detection → response), and an idempotency plan (rerun strategy per layer + the 10x-rerun test). The starter scaffold introspects the Delta tables already built in W15–W19 so the map is grounded in REAL tables and row counts, not a fictional diagram. The doc explicitly DECIDES ELT + medallion + incremental and defends each choice.

Step by step
  1. Run the starter scaffold notebook to inventory your existing Delta tables (bronze/silver/gold + rejected/quarantine) — capture table names, row counts, and partition columns as of today. Paste the output into your doc as the factual basis for the data-flow map; a hiring manager trusts a diagram backed by real numbers.
  2. Draw the DATA-FLOW MAP: for each hop (raw source→bronze, bronze→silver, silver→golden records→gold) write the GRAIN of the output table, the layer CONTRACT, and the single transform boundary being crossed (e.g., 'silver→gold: aggregate SCD2-current rows to facility×billing-code, no history, serve-only'). Use a Mermaid or ASCII diagram plus a table.
  3. Write the ELT-vs-ETL DECISION as an ADR: context (messy, re-parseable CMS files you haven't fully seen), options (ETL pre-clean-then-load vs ELT land-raw-then-transform), decision (ELT), and 2+ project-specific consequences tied to Delta (replayable bronze, cheap columnar storage, Spark SQL transforms in-place).
  4. Write the BATCH-VS-INCREMENTAL DECISION as an ADR: justify incremental batch over full reload and over streaming, citing CMS/NPPES monthly republish cadence and the data-freshness SLA KPI. State explicitly which table (if any) is a full-overwrite exception and why.
  5. Write the TOOL-SELECTION LOG: Databricks Free Edition + Delta chosen; list at least 3 rejected alternatives (e.g., Snowflake, dbt+Postgres, Airflow+pandas) each with a specific rejection reason (cost, no free lakehouse tier, no ACID on files, no SCD2 primitive, etc.). Note the phase constraint: Unity Catalog / DLT / DABs are deferred to Phase 3.
  6. Write the FAILURE PLAN table: at least 4 named failure modes (NPPES 429 rate-limit, hospital pricing-file schema drift, late-arriving CMS republish, duplicate-provider explosion in MDM) each with a DETECTION signal (row-count delta, expectation fail, null-rate spike) and a RESPONSE (retry-with-backoff, quarantine-to-rejected-with-reason+timestamp, alert, fail-loud). Cross-reference your existing rejected/quarantine tables from W16–W19.
  7. Write the IDEMPOTENCY PLAN table: assign a rerun strategy to EACH layer — bronze (append with dedup key or replace-by-ingest-date), silver (MERGE on business key / SCD2), gold (full-overwrite of derived mart or replace-partition) — and justify each. Define the 10x-RERUN TEST: the exact command sequence and the row-count invariant that must hold identical across 10 runs.
  8. Write the 1-page 'WHY' note (decisions = interview answers): the single hardest trade-off you made this week and the alternative you rejected, in language you could say out loud in an interview. Assemble all four parts into ARCHITECTURE.md in the repo and link it from the project README.
Data
CMS Hospital Price Transparency (source being mapped, not re-ingested this week)
Mess to handle Hospitals publish machine-readable pricing files of wildly varying format and quality (CSV/JSON/XML, inconsistent column names, missing prices) — the exact reason the doc must justify ELT (land raw, parse later) over ETL (pre-clean an unknown schema).
NPPES NPI Registry API
Mess to handle Rate-limited (429s) and returns duplicate/variant provider records — drives the failure-plan retry-with-backoff mode and the incremental-vs-full reprocessing decision. API docs: https://npiregistry.cms.hhs.gov/api-page
NPPES Monthly Bulk Data Files
Mess to handle Full monthly provider dump (millions of rows) republished on a monthly cadence — the concrete evidence for choosing INCREMENTAL batch (process the delta) over full reload, and the freshness-SLA argument against streaming.
Reference material
</> Starter scaffold — 20_architecture_table_inventory.py python
# Databricks Free Edition notebook — Week 20 STARTER SCAFFOLD
# Purpose: ground your data-flow map in REAL tables + row counts from W15-W19,
#          not a fictional diagram. This only INVENTORIES; you write the doc.
#
# It does NOT decide ELT/incremental for you and does NOT draw the map.
# Fill every TODO. The prose (ADRs, failure plan, idempotency plan) is yours.

from pyspark.sql import functions as F

# TODO 1: point these at YOUR schema/catalog from earlier weeks.
#         On Free Edition this is likely the default 'workspace' / hive_metastore.
SCHEMA = "provider_pricing"          # TODO: your schema name
LAYER_PREFIXES = {                    # TODO: adjust to how you actually named tables
    "bronze": "bronze_",
    "silver": "silver_",
    "gold":   "gold_",
    "reject": "rejected_",           # quarantine tables from W16-W19
}

# --- Inventory every table and tag it with its intended layer ------------------
rows = []
tables = spark.catalog.listTables(SCHEMA)
for t in tables:
    fqn = f"{SCHEMA}.{t.name}"
    layer = next((L for L, p in LAYER_PREFIXES.items() if t.name.startswith(p)), "UNCLASSIFIED")
    try:
        cnt = spark.table(fqn).count()
    except Exception as e:
        cnt = -1  # e.g., view or permission issue — note it in the doc
    # TODO 2: also capture partition columns so your idempotency plan can
    #         justify 'replace-partition' where you use it.
    part_cols = None  # TODO: read via DESCRIBE DETAIL <fqn> -> partitionColumns
    rows.append((layer, t.name, cnt, part_cols))

inv = spark.createDataFrame(rows, ["layer", "table", "row_count", "partition_cols"]) \
           .orderBy("layer", "table")

print("=== TABLE INVENTORY (paste into ARCHITECTURE.md data-flow map) ===")
inv.show(truncate=False)

# TODO 3: For EACH layer, write the CONTRACT you will defend in the doc:
#   bronze = raw landed, append-only, source + ingest_ts, NO transforms
#   silver = validated + typed + conformed + SCD2 historized + quarantine
#   gold   = business-ready, serve-only (KPIs for Maria), NO history
# Then write the transform BOUNDARY crossed between each pair.

# TODO 4: Flag any UNCLASSIFIED table — an unowned table is a contract gap
#         and a great thing to name in your 'why' note.
unclassified = inv.filter(F.col("layer") == "UNCLASSIFIED")
print("UNCLASSIFIED tables (resolve or justify in the doc):")
unclassified.show(truncate=False)

# NOTE: This scaffold produces EVIDENCE. The four architecture-doc parts
#       (data-flow map, tool-selection log, failure plan, idempotency plan)
#       are written by YOU in ARCHITECTURE.md — that's the deliverable.
Note Starter scaffold only — it inventories the Delta tables you already built so your data-flow map cites real row counts. It deliberately does NOT write any of the four doc sections or make the ELT/incremental decision; those TODOs are where you take over. Table/schema names are guesses you must correct to match W15–W19.
Go deeper
From Gambill Data — videos & templates
Courses & books
Adapt to your industry Mirror the same four-part doc on YOUR target industry's running project — the decision framework is identical, only the mess changes. Fintech/markets: map SEC EDGAR filings (sec.gov/edgar) or CoinGecko price pulls (rate-limited, revised filings = late records) — ELT still wins because filing formats vary and you want replayable raw. Climate: NOAA (schema drift across station generations). Humanitarian: ReliefWeb (duplicate org entities → your own MDM). Locked-down/fraud domains: Faker-generated sources. Whatever your domain, your architecture doc must name YOUR specific failure modes (rate limits, format drift, late republishes, duplicate entities) and justify incremental-vs-full against YOUR source's republish cadence.
Deliverable, acceptance & live session
Deliverable & acceptance
ARCHITECTURE.md committed to the Project 1 repo and linked from the README, containing all four parts: (1) a data-flow map (Mermaid/ASCII + table) with grain + contract + transform boundary per bronze→silver→gold hop, grounded in the real table inventory; (2) two ADRs — ELT-over-ETL and incremental-over-full — plus a tool-selection log naming ≥3 rejected alternatives with specific reasons; (3) a failure plan of ≥4 named failure modes with detection + response; (4) an idempotency plan assigning a rerun strategy per layer with the defined 10x-rerun test. Includes a 1-page 'why' note capturing the hardest trade-off.
Pass when
  • (a) the data-flow map states GRAIN + CONTRACT + transform boundary for all three hops and cites real row counts from the inventory scaffold
  • (b) the ELT and incremental decisions each list options considered AND ≥2 project-specific consequences (generic 'ELT is modern' is rejected)
  • (c) the tool-selection log names ≥3 rejected alternatives with a specific reason each
  • (d) the failure plan has ≥4 named modes each with a distinct detection signal and a concrete response (quarantine/retry/alert/fail-loud) referencing the actual rejected/quarantine tables
  • (e) the idempotency plan assigns MERGE / replace-partition / overwrite per layer with justification and defines the exact 10x-rerun row-count invariant
  • (f) the doc does NOT require Unity Catalog, DLT, or DABs (Phase 3). A peer must be able to read it in ~5 minutes and correctly predict what W21 will build.
Live session focus

Recap Each learner shares their ARCHITECTURE.md on screen for 3 minutes: walk the data-flow map, state the one transform boundary you found hardest to place, and read your ELT ADR's consequences aloud. Group compares idempotency strategies — who chose overwrite vs MERGE for gold, and why the choice differs.

Misconception 'The medallion layers are just folders / naming conventions.' Bronze/silver/gold are ENFORCEABLE CONTRACTS about what is forbidden at each layer (no transforms in bronze; no history in gold), not directories. If a learner cannot state what would VIOLATE a layer's contract, they've drawn folders, not designed an architecture. Second trap: 'incremental is always better than full reload' — a small conformed dimension is often correctly a full-overwrite for idempotency; the decision is per-table, not global.

Hot seat "Defend your decision to a skeptical staff engineer: 'This is overengineered. Just full-overwrite every table from the raw files every night — it's trivially idempotent and you never worry about late records or MERGE bugs. Why ELT, why incremental, why the whole medallion ceremony?' You must (1) justify ELT with a specific CMS-file-format reason, (2) justify incremental against BOTH full reload (cost/SLA on millions of NPPES rows) and streaming (no sub-daily source), and (3) concede where full-overwrite actually IS your chosen idempotency strategy — showing the decision is per-layer, not dogma."

Portfolio ARCHITECTURE.md becomes a headline artifact in the Project 1 repo — the document a hiring manager opens right after the README to answer 'do these people design, or just script?' The two ADRs and the tool-selection log are directly reusable as spoken interview answers to 'why this stack?' and 'ETL or ELT?'. The failure + idempotency plans prove the 'build for failure' habit before a single new line of pipeline code is written.
Prereqs & carry-forward Completed W15–W19: bronze ingestion of CMS pricing + NPPES, silver validation/typing/conforming with quarantine (rejected tables), SCD2 history, and W19 golden-records/MDM producing resolved providers. An existing Databricks Free Edition workspace with the Delta tables from those weeks. The Project Charter (started W14) on hand. Comfort with Spark SQL, Delta MERGE semantics, and the SCD2 pattern already built. No new data ingestion required this week.
W21 Landing the Mess: NPPES (API + bulk) & CMS Price-Transparency Files into an Append-Only BronzeMaria's monthly cost analysis and her hospital's price-transparency compliance filing are only as trustworthy as the day they were pulled — and CMS providers republish pricing files on no fixed schedule while the NPPES API throttles bulk callers, so a run that silently ingested nothing looks identical to a clean one and quietly corrupts the report. This week builds the ingestion edge so every pull is stamped, replayable, and fails loudly instead of lying green. M14 — Ingestion patterns & source systems → bronze · Phase 2 · silver G3G6 ~11h
Why this matters Maria's monthly cost analysis and her hospital's price-transparency compliance filing are only as trustworthy as the day they were pulled — and CMS providers republish pricing files on no fixed schedule while the NPPES API throttles bulk callers, so a run that silently ingested nothing looks identical to a clean one and quietly corrupts the report. This week builds the ingestion edge so every pull is stamped, replayable, and fails loudly instead of lying green.
Project 1 · this week This is the first working code of Project 1's running pipeline: it builds the Bronze landing layer that every later week reads from. NPPES (API + a look at bulk) and CMS MRF pricing now land raw, append-only, and stamped with source + ingest-timestamp, with an ingest audit log capturing every run's fate. It adds a new subsection to the W20 Architecture Doc: the FAILURE PLAN gains its first two concrete entries (API 429 handling and missing-source detection), and the IDEMPOTENCY PLAN gains its first entry (Bronze = append-across-time, partition-overwrite within a run). The Project Charter (started W14) gets its data-freshness-SLA KPI made real by the _ingest_ts / audit-log foundation laid here. No Silver contracts yet — that's W22.
Objectives
  • Land raw NPPES API responses and a CMS machine-readable pricing (MRF) file into two Bronze Delta tables that are append-only and carry ingest metadata (_source, _source_file, _ingest_ts, _batch_id, _ingest_date), verified by SELECTing distinct _source values and confirming zero transformation of the payload.
  • Implement a paged NPPES puller that detects HTTP 429, honors Retry-After (falling back to exponential backoff + jitter), caps retries, classifies transient vs permanent failures, and on exhaustion raises loudly and writes a FAILED row to an ingest audit log instead of returning empty.
  • Implement an 'expected file is absent' guard for the MRF ingest that logs a MISSING_SOURCE row (reason + timestamp) and raises, so a skipped source can never be mistaken for a successful empty run.
  • Demonstrate run-level idempotency: rerun the same day's ingest 10x and show the Bronze count for that _ingest_date partition is unchanged (partition-overwrite within a run), while a new date appends — and articulate why Bronze is append-across-time but idempotent-within-run.
  • Produce a queryable ingest audit log with exactly one row per source per run outcome (SUCCESS / EMPTY / MISSING_SOURCE / FAILED with reason), proving every run's fate is recorded.
Concept lessons · ~5h
  • Ingestion patterns taxonomy: full snapshot vs incremental, push vs pull, batch-file vs API; how source-system realities (NPPES's 200-record page cap and rate limits, CMS's irregular MRF publish cadence, neither offering reliable change-tracking) force the pattern choice — and why you decide this before writing code. (~1h)
  • The Bronze layer contract: raw landed, append-only, NO transforms. Why you keep the ugly original payload (store the API record as a raw JSON string, land MRF rows as-read), and the standard ingest-metadata columns that make Bronze auditable: _source, _source_file, _ingest_ts, _batch_id, _ingest_date. (~1h)
  • Building for failure at ingest: HTTP 429 and rate limiting, the Retry-After header, exponential backoff + jitter, retry caps, and distinguishing transient (429/5xx → retry) from permanent (4xx → raise) failures. The ingest audit-log pattern as your alerting substitute on Free Edition. (~1.5h)
  • The silent-skip problem: an expected source file that never showed up. Landing/freshness checks, loud-vs-silent failure, and why 'the job stayed green but ingested nothing' is the most dangerous outcome for Maria's compliance report. (~1h)
  • Idempotent landing on Databricks Free Edition: append-only Delta partitioned by _ingest_date, overwrite-partition (replaceWhere) vs plain append trade-offs, and run-id lineage — chosen deliberately so a mid-run crash + rerun doesn't double-land today while yesterday stays intact. (~0.5h)
Hands-on lab · ~6h

You will stand up the Bronze ingestion layer of Project 1 on Databricks Free Edition: an append-only Delta landing zone fed by two sources — the live NPPES NPI Registry API (paged, rate-limited) and a CMS hospital MRF pricing file you upload to a Volume. You will make both sources carry source + ingest-timestamp metadata, make the API puller survive a 429, make the MRF path scream when the expected file is missing, prove the whole thing is idempotent within a run, and record every run's outcome in an audit log. No cleaning, typing, or dedup — that is the Bronze contract and it is deliberately deferred to Silver (W22+).

Step by step
  1. Create the Bronze workspace objects: a schema/tables for bronze_nppes_providers and bronze_cms_mrf, plus an ingest_audit_log Delta table (columns: batch_id, source, status, row_count, reason, ingest_ts, ingest_date). Generate a per-run BATCH_ID (uuid) and INGEST_DATE at the top of the notebook so every write shares one lineage id.
  2. Recon the sources by hand before coding: hit https://npiregistry.cms.hhs.gov/api/?version=2.1&state=CA&limit=200 in a browser and note the 200-record cap and the skip-based paging; open the CMS Hospital Price Transparency page, download one hospital's machine-readable file, and eyeball how its format/columns differ from the next hospital's. Write down which ingestion pattern each source needs (API incremental-by-page vs batch file) and why.
  3. Build fetch_nppes_page(): implement the 429/backoff branch in the scaffold — read Retry-After if present, else exponential backoff (base * 2**attempt) with random jitter, cap at max_retries, treat 5xx as retryable and non-429 4xx as permanent (raise). Return resp.json()['results'] on 200. Keep the raw record; do not flatten it.
  4. Land NPPES to Bronze: page several states, wrap each raw record as a _payload JSON string, add the metadata columns, and write append-only but idempotent-per-run using .mode('overwrite').option('replaceWhere', "_ingest_date = '<today>'").partitionBy('_ingest_date'). Log a SUCCESS (or EMPTY) audit row.
  5. Add the missing-file guard and land an MRF: upload your downloaded hospital file to a Volume/DBFS landing path, then in ingest_mrf() check existence with dbutils.fs.ls BEFORE reading — if absent, write a MISSING_SOURCE audit row and raise FileNotFoundError. If present, read it as raw text/CSV/JSON with NO cleaning, add the same metadata, and land it with the same replaceWhere pattern.
  6. Idempotency test: run today's full ingest 10 times in a row. Confirm SELECT count(*) FROM bronze_nppes_providers WHERE _ingest_date = today is identical every time (partition overwrite), and that a simulated 'yesterday' partition is untouched. Record the before/after counts.
  7. Force the two failure modes on purpose: (a) drop max_retries to 1 and hammer the API / point at a bad param to provoke a raised failure, and (b) point ingest_mrf() at a path where the file does NOT exist. Confirm the job goes RED both times and that FAILED and MISSING_SOURCE rows land in the audit log with reason + timestamp.
  8. Query the audit log (SELECT source, status, row_count, reason, ingest_ts ORDER BY ingest_ts) and screenshot it. Write the 150–250 word why-note: why append-only Bronze instead of dedup-at-ingest, why partition-overwrite for run idempotency instead of MERGE, and how a MISSING_SOURCE run is now distinguishable from a clean one.
Data
NPPES NPI Registry API
Mess to handle Hard 200-record cap per call forces skip-based paging; the endpoint throttles (HTTP 429) under repeated/bulk calls; the same provider recurs with name/address/casing variants and multiple practice locations; deactivated NPIs are still returned. Docs: https://npiregistry.cms.hhs.gov/api-page
CMS Hospital Price Transparency — machine-readable files (MRF)
Mess to handle No enforced schema — hospitals publish JSON/CSV/XML of wildly varying shape, nested charge structures, mixed encodings, and huge sizes; the expected file for a given hospital/month may simply never be published, so 'no file today' is a real and dangerous state your pipeline must detect.
NPPES bulk data files (full monthly dump)
Mess to handle Multi-GB monthly full-replacement dump plus weekly incrementals, a 330+ column flat CSV whose header set drifts between releases — the batch-file counterpart to the API and the reason 'full vs incremental' is a real decision, not a textbook one. Used here only to compare patterns; API is the primary land.
Reference material
</> Starter scaffold — bronze_ingest_scaffold.py python
# Week 21 - Bronze ingestion scaffold (Databricks Free Edition + Delta)
# GOAL: land NPPES API + one CMS MRF file into Bronze, append-only, with
# source + ingest metadata, LOUD failures, and idempotent-per-run writes.
# CONTRACT: Bronze = raw landed, NO transforms. Keep the ugly payload.

import time, json, uuid, random, requests
from datetime import datetime, timezone
from pyspark.sql import Row

# ---- run identity (lineage) shared by every write this run ----
BATCH_ID = str(uuid.uuid4())
INGEST_DATE = datetime.now(timezone.utc).strftime("%Y-%m-%d")
CATALOG = "workspace"  # Free Edition default; adjust to your workspace
BRONZE_PROVIDERS = f"{CATALOG}.default.bronze_nppes_providers"
BRONZE_MRF       = f"{CATALOG}.default.bronze_cms_mrf"
AUDIT            = f"{CATALOG}.default.ingest_audit_log"
NPPES_URL = "https://npiregistry.cms.hhs.gov/api/"

def now_utc():
    return datetime.now(timezone.utc).isoformat()

def log_audit(source, status, rows, reason=""):
    """One queryable row per source per run outcome. This is your alerting."""
    row = [Row(batch_id=BATCH_ID, source=source, status=status,
               row_count=int(rows), reason=reason[:500],
               ingest_ts=now_utc(), ingest_date=INGEST_DATE)]
    (spark.createDataFrame(row)
          .write.format("delta").mode("append").saveAsTable(AUDIT))

# -----------------------------------------------------------------
# 1) NPPES API pull  --  handle the 429 (G6)
# -----------------------------------------------------------------
def fetch_nppes_page(params, max_retries=5):
    """Return the raw 'results' list for ONE page.
    TODO(G6): loop with an attempt counter:
      - resp = requests.get(NPPES_URL, params=params, timeout=30)
      - if 200: return resp.json().get('results', [])
      - if 429: sleep for int(Retry-After) if header present,
                else backoff = (2 ** attempt) + random.random(); then retry
      - if 5xx: retryable -> backoff + retry
      - if other 4xx: PERMANENT -> raise (do not swallow)
      - if attempts exhausted: raise (caller logs FAILED and the job goes red)
    """
    raise NotImplementedError("Implement 429/backoff + return results list")

def ingest_nppes(states=("CA",), pages=5, limit=200):
    try:
        records = []
        for st in states:
            for i in range(pages):
                params = {"version": "2.1", "state": st,
                          "limit": limit, "skip": i * limit}
                page = fetch_nppes_page(params)
                if not page:
                    break
                # TODO: tag each raw record with its source state, keep it RAW
                records.extend(page)
        if not records:
            log_audit("nppes_api", "EMPTY", 0, "no results returned")
            return
        # TODO(Bronze contract): build rows with ONE raw payload column +
        #   metadata. Example shape (fill in):
        #   rows = [Row(_payload=json.dumps(r), _source="nppes_api",
        #               _source_file=None, _ingest_ts=now_utc(),
        #               _batch_id=BATCH_ID, _ingest_date=INGEST_DATE)
        #           for r in records]
        #   df = spark.createDataFrame(rows)
        df = None  # TODO build df
        # TODO(idempotency): append across days, overwrite TODAY's partition:
        #   (df.write.format("delta").mode("overwrite")
        #      .option("replaceWhere", f"_ingest_date = '{INGEST_DATE}'")
        #      .partitionBy("_ingest_date").saveAsTable(BRONZE_PROVIDERS))
        log_audit("nppes_api", "SUCCESS", df.count() if df else 0)
    except Exception as e:
        log_audit("nppes_api", "FAILED", 0, reason=str(e))
        raise  # FAIL LOUD

# -----------------------------------------------------------------
# 2) CMS MRF file  --  detect the file that never showed up (G6)
# -----------------------------------------------------------------
def ingest_mrf(landing_path):
    """Land one hospital MRF file already uploaded to a Volume/DBFS path.
    TODO(silent-skip): BEFORE reading, verify the file exists (dbutils.fs.ls).
       If absent:
         log_audit('cms_mrf','MISSING_SOURCE',0,f'expected {landing_path}')
         raise FileNotFoundError(landing_path)   # 'no data' != 'clean run'
    TODO(Bronze): read RAW (text/csv/json per the file) with NO cleaning;
       add _source='cms_mrf', _source_file=landing_path, _ingest_ts,
       _batch_id, _ingest_date; write with the SAME replaceWhere pattern;
       then log SUCCESS with the row count.
    """
    raise NotImplementedError

# ---- entrypoint (uncomment as you implement) ----
# ingest_nppes(states=("CA","NY"), pages=5)
# ingest_mrf("/Volumes/workspace/default/landing/hospital_mrf.json")
# display(spark.table(AUDIT).orderBy("ingest_ts"))
Note STARTER SCAFFOLD for a Databricks Free Edition notebook — it will NOT run clean as-is. Every TODO is where you take over. It only sets up run lineage, the audit-log helper, and the two ingest skeletons; the 429/backoff logic, the Bronze metadata write, the idempotent replaceWhere, and the missing-file guard are yours to implement. Do not add cleaning/typing/dedup here — that violates the Bronze contract and belongs in W22 Silver.
Go deeper
From Gambill Data — videos & templates
Courses & books
Adapt to your industry Do the identical pattern on your own target industry with one rate-limited API source plus one irregularly-published bulk file. Fintech/compliance: SEC EDGAR full-text search + submissions API (real rate limits and a required User-Agent header — a perfect 429/backoff exercise) plus a bulk financial-statement dataset from sec.gov/dera. Crypto: CoinGecko public API (documented rate limits and 429s) plus a periodic CSV export. Public-sector/ESG: any data.gov CKAN API plus a monthly agency file; climate: NOAA APIs. Whatever you pick, land it to YOUR bronze_<source> tables with the same _source/_ingest_ts/_batch_id metadata, make one source a paged API you must back off from, and make one source a file that can fail to appear so your MISSING_SOURCE guard has something real to catch. If your industry's data is locked down, generate a Faker feed with an injected outage instead.
Deliverable, acceptance & live session
Deliverable & acceptance
A Databricks Free Edition notebook (exported to the repo as bronze_ingest_scaffold-completed.py) that lands NPPES API records and one CMS MRF file into two append-only Bronze Delta tables carrying full ingest metadata; a populated ingest_audit_log Delta table showing SUCCESS, MISSING_SOURCE, and FAILED outcomes with reason + timestamp; a screenshot of the audit-log query and of the 10x-rerun idempotency counts; and a 150–250 word why-note (README section) defending append-only Bronze, the partition-overwrite idempotency choice, and how a no-data run is now distinguishable from a clean one.
Pass when
  • Bronze_nppes_providers and bronze_cms_mrf both contain raw payloads plus _source, _source_file, _ingest_ts, _batch_id, _ingest_date — and SELECT proves the payload is untransformed. 2) The API puller demonstrably survives an induced 429 (backoff visible in logs) and, when retries are exhausted, raises AND writes a FAILED audit row. 3) Pointing the MRF ingest at a non-existent path produces a MISSING_SOURCE audit row and a red job — never a green empty run. 4) Re-running today's ingest 10x yields an identical count for today's _ingest_date partition while a prior date is untouched. 5) ingest_audit_log has exactly one row per source per run outcome, each with a non-empty reason where status != SUCCESS. 6) The why-note names specific decisions, not generic ones. No typing, cleaning, or dedup appears anywhere in Bronze.
Live session focus

Recap Open by replaying the two failure demos live: trigger the 429/backoff path and the missing-file path, and show both landing as loud rows in the audit log while the job goes red. Then walk the group through the three decisions that define the week — (a) Bronze keeps the ugly raw payload and adds only metadata, (b) append-across-time but overwrite-today's-partition gives run idempotency without MERGE, (c) an audit log is your alerting substitute on Free Edition. Reinforce the layer contract line: Bronze is raw landed, nothing more; conforming and quality gates are next week's job, not this week's.

Misconception The killer misconception: 'my job ran green, so I have fresh data.' Learners conflate a successful run with a run that actually ingested something — the exact trap that silently corrupts Maria's compliance report when CMS didn't publish this month's file. The fix drilled this week: absence of an expected source is an EVENT that must be logged (MISSING_SOURCE) and must fail loudly; EMPTY, MISSING_SOURCE, and SUCCESS are three different states, and a pipeline that can't tell them apart is worse than one that's obviously broken. Secondary misconception to correct: 'I should dedup the duplicate providers as I ingest' — no, Bronze stays raw; dedup is a Silver/MDM concern later in the phase.

Hot seat "Each learner defends, in 3–4 minutes with their audit log on screen: 'Why is your Bronze append-only and un-deduped when you can already SEE duplicate providers in the NPPES payloads — why not clean at ingest?' Follow-ups the reviewer will push on: 'Defend partition-overwrite (replaceWhere) for run idempotency instead of MERGE-on-key — what does each buy you and what would break if a CMS file arrived mid-run and your job crashed?' and 'Your NPPES pull got a 429 at 2am and gave up — walk me through exactly what a human sees the next morning and how they know it was rate-limiting versus the file never existing.' A generic answer fails the hot seat; they must point at a specific audit row or metadata column."

Portfolio Commit to the Project 1 repo: /notebooks/bronze_ingest_scaffold-completed.py; /docs/why-notes/w21-bronze-ingestion.md (the defended why-note); /screenshots/w21-audit-log.png and /screenshots/w21-idempotency-10x.png; and an update to /docs/architecture-doc.md adding the first Failure-Plan entries (429 backoff, MISSING_SOURCE detection) and the first Idempotency-Plan entry (append-across-time + partition-overwrite). The README's 30-second skim should open with the business framing (untrustworthy price-transparency data → wrong compliance report) so a hiring manager recognizes the problem before evaluating the code.
Prereqs & carry-forward W20 completed (the 4-part Architecture Doc exists — this week writes its first Failure-Plan and Idempotency-Plan entries). A working Databricks Free Edition workspace with the ability to create Delta tables and upload a file to a Volume/DBFS. Comfort with Python functions and the requests library, basic PySpark DataFrame creation and Delta writes (mode append/overwrite) from Phase 1, and SQL SELECT/count for verification. No Unity Catalog, DLT/Lakeflow, or CI/CD — those are Phase 3.
W22 Medallion architecture — enforcing layer contractsMaria's finance team keeps re-cleaning the same hospital pricing data in three different reports because nobody agreed on where cleaning happens — so two dashboards show different total charges for the same provider and compliance can't tell which number is defensible. Enforced layer contracts make one layer the single source of clean truth, so a hospital's reported price is computed once, provably, and the same everywhere. M15 · Phase 2 · silver G3G2 ~10.0h
Why this matters Maria's finance team keeps re-cleaning the same hospital pricing data in three different reports because nobody agreed on where cleaning happens — so two dashboards show different total charges for the same provider and compliance can't tell which number is defensible. Enforced layer contracts make one layer the single source of clean truth, so a hospital's reported price is computed once, provably, and the same everywhere.
Project 1 · this week Advances Project 1 by turning the W21 bronze into a governed, three-layer pipeline: it produces the first conformed silver table and a serve-only gold view for the Provider Pricing Integrity project. It adds the LAYER CONTRACTS section (with the documented silver PK grain and the bronze/silver/gold ALLOWED-FORBIDDEN table) to the 4-part Architecture Doc written in W20, and it establishes the quarantine + reconciliation pattern that later KPIs (# records quarantined, % facilities passing completeness) will read from.
Objectives
  • Write a one-page LAYER CONTRACT for bronze, silver, and gold that states, for each layer, exactly what IS allowed, what is FORBIDDEN, the grain, and the owner — specific enough that a reviewer can point to any transform and say which layer it belongs in.
  • Implement at least 3 automated contract assertions on the W21 project bronze (required ingest-metadata columns present; payload columns still raw/StringType; row count only ever grows append-only) that raise a loud, named error on violation.
  • Build a thin silver-conform step that types, trims, and standardizes at least 4 fields (charge decimal, provider name, 2-letter state, zero-padded ZIP) and declares the silver primary-key grain in writing.
  • Route every row that fails the silver contract to a rejected table with a specific _reject_reason + _reject_ts (zero silent drops), and prove passed+rejected = input count.
  • Expose gold as a serve-only VIEW over silver and demonstrate (by a failing test) that adding any cleaning logic in gold violates the contract.
Concept lessons · ~3.5h
  • Medallion as CONTRACTS, not folders (45 min): why bronze/silver/gold exist, what each layer promises its consumers, and how a leaked transform (casting in bronze, re-cleaning in gold) silently destroys reproducibility and auditability. Frame each layer as a contract with a producer and a consumer.
  • The bronze contract — raw, append-only, provenance (40 min): source file, ingest timestamp, source system; why bronze must stay untyped/unfiltered so you can always replay history and prove what CMS actually published on a given day.
  • The silver contract — clean + conform + (later) historize (55 min): typing, trimming, code/unit standardization, choosing and documenting the PRIMARY-KEY GRAIN, quality gates, and quarantine. We build the clean+conform half now; SCD2/CDC historize is flagged as the W23+ extension so silver stays honest.
  • The gold contract — serve-only (30 min): business-ready marts/KPIs read from silver ONLY; no cleaning allowed downstream of silver; why 'if you need to clean it in gold, it belongs in silver' is the enforcement rule.
  • Enforcing contracts with cheap tests (40 min): schema assertions, StringType checks, row-count monotonicity, and reconciliation (passed + rejected = input) as guardrails that fail loud in Databricks so a broken layer is never silent.
Hands-on lab · ~6.5h

Take the raw hospital-charge + NPPES provider bronze you landed in W21 and put it under contract. You will write a one-page Layer Contract, then encode it: automated assertions that prove bronze is still raw and append-only, a thin silver step that types and conforms the fields (with a documented PK grain) and quarantines contract violations to a rejected table with reasons, and a serve-only gold view. You finish by deliberately breaking a contract (e.g., casting in bronze) to watch the guardrail fail loud, then reverting — proving the contract is enforced, not just described.

Step by step
  1. Write LAYER_CONTRACTS.md: for bronze, silver, gold list ALLOWED / FORBIDDEN / grain / owner. Bronze=raw landed + ingest metadata only; silver=typed+conformed+key-deduped (historize noted as W23+); gold=serve-only over silver. Keep it to one page a hiring manager can skim.
  2. Load the W21 bronze Delta table and run three bronze-contract assertions: (a) required metadata columns _source_file/_ingest_ts/_source_system exist; (b) payload/business columns are still StringType (no casting leaked in); (c) row count today >= row count at last run (append-only). Each assertion raises a NAMED AssertionError on failure.
  3. Build the silver-conform step (typing + standardization only): cast standard_charge to DECIMAL(12,2) and standardize its units/currency; trim + upper-case provider name; normalize state to 2 letters; zero-pad ZIP to 5. Do NOT compute any business aggregate here.
  4. Declare and document the SILVER PRIMARY-KEY GRAIN for one charge row (e.g., npi + payer + plan + billing_code + charge_type) as a comment and in LAYER_CONTRACTS.md; dedupe within the batch on that key, keeping the latest _ingest_ts.
  5. Define the silver quality gate (npi not null, standard_charge not null and > 0, PK components not null). Split the typed frame into passed and rejected; tag rejected rows with a SPECIFIC _reject_reason and _reject_ts, and write them append-only to silver_rejects.
  6. Write passed rows to the silver Delta table with a deliberately chosen idempotency strategy (MERGE on the PK, or overwrite partition). Write a one-line justification comment for WHY you chose it over the alternative.
  7. Run the full notebook 10 times end-to-end and record silver row counts each run; confirm the count is identical every time (idempotent) and that passed + rejected = input rows (reconciliation, zero silent drops).
  8. Create gold as a VIEW over silver only; then run a contract test that FAILS if a gold definition references bronze or introduces a cast/trim/coalesce — proving no cleaning is allowed downstream of silver.
  9. Break-it drill: temporarily add a cast to a bronze column, rerun, capture the loud failure in a screenshot/log, then revert. Write 3-5 sentences ('why note') on which contract broke and how the guardrail caught it — this becomes an interview answer.
Data
CMS Hospital Price Transparency (machine-readable charge files)
Mess to handle Every hospital publishes its own format — charges arrive as strings with $ signs, ranges, 'N/A', and mixed units; the same field means different things across files, so reading a price straight from bronze is unsafe. This is exactly why the silver typing/conform contract exists.
NPPES NPI Registry API
Mess to handle Provider names and addresses vary (abbreviations, casing, suffix/entity-type differences) and the same provider appears under multiple records — bronze must land these raw so silver can conform state/ZIP/name without losing the original for audit.
NPPES bulk data files (full monthly dump)
Mess to handle Millions of rows with schema drift between monthly releases and nullable/blank fields; useful for proving the append-only bronze contract and that silver typing must tolerate missing values by quarantining, not crashing.
Reference material
</> Starter scaffold — w22_layer_contracts.py python
# W22 · Provider Pricing Integrity · Medallion layer-contract enforcement
# Databricks Free Edition | Delta Lake | PySpark. This is a STARTER scaffold.
# Fill every TODO. Do not paste a full solution here.

from pyspark.sql import functions as F, types as T

# --- CONFIG ---------------------------------------------------------------
BRONZE  = "workspace.pricing.bronze_hospital_charges"   # landed in W21 (raw, append-only)
SILVER  = "workspace.pricing.silver_hospital_charges"
REJECT  = "workspace.pricing.silver_rejects"
RUN_TS  = F.current_timestamp()
REQUIRED_BRONZE_META = ["_source_file", "_ingest_ts", "_source_system"]

bronze = spark.read.table(BRONZE)

# === CONTRACT 1: BRONZE IS RAW + APPEND-ONLY =============================
# TODO 1: assert every name in REQUIRED_BRONZE_META is in bronze.columns;
#         raise AssertionError naming the missing column (LOUD failure).
# TODO 2: assert payload/business columns are still StringType (no casting
#         leaked into bronze). Iterate bronze.schema and check .dataType.
# TODO 3: assert append-only: today's count >= the count you logged last run
#         (store last count in a small Delta audit table and compare).

# === CONTRACT 2: SILVER IS CLEAN + CONFORMED (typing half; historize=W23+) =
silver_typed = (
    bronze
    # TODO 4: cast standard_charge -> DECIMAL(12,2); strip '$', handle 'N/A'/ranges
    # TODO 5: trim + upper() provider name; state -> 2 letters; ZIP -> lpad 5
    # TODO 6: keep the ORIGINAL raw columns too, so audit can trace back to bronze
)

# TODO 7: DECLARE THE SILVER PRIMARY-KEY GRAIN in one line, e.g.
# PK = ["npi", "payer", "plan", "billing_code", "charge_type"]
# Document this same key in LAYER_CONTRACTS.md.
PK = []  # <-- fill in

# --- QUALITY GATE + QUARANTINE (no silent drops) -------------------------
gate = (
    F.col("npi").isNotNull()
    & F.col("standard_charge").isNotNull()
    & (F.col("standard_charge") > 0)
    # TODO 8: add NOT-NULL checks for each PK component + any range rules
)

passed = silver_typed.filter(gate)
rejected = (
    silver_typed.filter(~gate)
    .withColumn("_reject_reason", F.lit("TODO: name the SPECIFIC failing rule"))
    .withColumn("_reject_ts", RUN_TS)
)

# TODO 9: dedupe `passed` within batch on PK, keeping latest _ingest_ts.
# TODO 10: write `rejected` (append) to REJECT.
# TODO 11: write `passed` to SILVER with a DELIBERATE idempotency strategy
#          (MERGE on PK  OR  overwrite partition). Add a one-line comment
#          justifying the choice. Rerun 10x -> identical SILVER counts.
# TODO 12: reconcile -> assert passed.count() + rejected.count() == input count.

# === CONTRACT 3: GOLD IS SERVE-ONLY =====================================
# TODO 13: create GOLD as a VIEW over SILVER ONLY (no bronze ref, no cast/
#          trim/coalesce). If you need to clean it, it belongs in silver.
# TODO 14: write a contract test that FAILS if the gold definition text
#          references BRONZE or contains a cleaning function.
Note SCAFFOLD ONLY — sets up the three-layer contract structure and fails loud on violations. Every TODO is where you take over; it deliberately does NOT contain a full silver transform or the finished gold view.
Go deeper
From Gambill Data — videos & templates
Courses & books
Adapt to your industry Apply the identical bronze/silver/gold contract discipline to your own target industry's data. SEC EDGAR filings (sec.gov/edgar): bronze = raw filing text/XBRL as landed with accession-number + ingest timestamp; silver = typed financials, standardized ticker/CIK, one documented grain per line item; gold = serve-only ratio marts. CoinGecko/markets: bronze = raw JSON snapshots; silver = typed prices, standardized symbol + currency; gold = serve-only OHLC views. NOAA/weather: bronze = raw station files; silver = typed, unit-standardized observations keyed by station+timestamp; gold = serve-only climatology. Write the same one-page contract and the same 'if you need to clean it in gold, it belongs in silver' rule for YOUR source, and name YOUR raw-string mess (SEC's mixed 'NM'/blank/parenthesized-negative numbers is a great parallel to CMS's '$'/'N/A'/range charges).
Deliverable, acceptance & live session
Deliverable & acceptance
A repo folder containing: (1) LAYER_CONTRACTS.md — one page defining bronze/silver/gold with ALLOWED/FORBIDDEN/grain/owner and the documented silver PK; (2) the completed w22_layer_contracts.py notebook with the three bronze assertions, silver typing/conform + quarantine, and serve-only gold view; (3) a silver_rejects table with reasons; (4) a short README 'why note' (5-8 sentences) covering the chosen idempotency strategy and the break-it drill (which contract broke, how the guardrail caught it), plus the 10-run identical-count evidence.
Pass when
  • Bronze assertions fail loud when metadata is missing, a payload column is cast, or the row count shrinks. Silver types/standardizes at least 4 fields and has a written PK grain that matches the dedupe key. Every non-passing row lands in silver_rejects with a specific _reject_reason + _reject_ts, and passed + rejected = input count (zero silent drops) is asserted in code. Running the notebook 10x yields identical silver counts. Gold is a VIEW over silver only, and the gold contract test fails when cleaning logic or a bronze reference is added. The README documents the idempotency choice and the break-it drill outcome.
Live session focus

Recap Each learner shows their LAYER_CONTRACTS.md on screen and points to one transform in their notebook, stating which layer it lives in and why. Group compares where people drew the bronze/silver line differently (e.g., is ZIP zero-padding a silver or gold job?) and reconciles to the rule 'clean once, in silver.' Then everyone displays their 10-run count log and their silver_rejects sample to prove the contract is enforced, not just written.

Misconception 'Medallion just means three folders/schemas.' No — the layer names are worthless without enforced contracts. If bronze can be re-typed, or gold can re-clean, you have three copies of ambiguity, not a pipeline. The value is the PROMISE each layer makes to its consumer, backed by a test that fails loud when the promise is broken. Also correct the sibling misconception that 'silver should do everything including business aggregates' — aggregates are gold; silver stops at clean+conform+(later)historize.

Hot seat "Defend your silver primary-key grain: 'Why is THIS the grain of one silver charge row, and what breaks if you picked a coarser or finer key?' Follow-up: 'You put ZIP zero-padding in silver and currency-symbol stripping in silver — justify why neither belongs in bronze or gold.' Then: 'Show the one line where you chose MERGE-on-PK vs overwrite-partition for idempotency and defend it against the alternative.' Be ready to point at the exact code and the LAYER_CONTRACTS.md line that authorizes each decision."

Portfolio Adds a visibly governed pipeline to Project 1's repo: a skimmable LAYER_CONTRACTS.md (recognition in 30s — 'they enforce where cleaning happens'), a rejected table with reasons, and a documented PK grain — the kind of concrete decisions that become strong interview answers. The break-it drill screenshot proves a human enforced the contract, and the layer-contract section is folded into the assembling Architecture Doc (assembled at W30).
Prereqs & carry-forward W21 completed: the project bronze tables (raw hospital charges + NPPES) already landed in Delta with _source_file, _ingest_ts, and _source_system metadata, append-only. Working Databricks Free Edition workspace with the pricing schema, comfort with PySpark DataFrame casting/filtering and basic Delta reads/writes, and Git for the repo. No Unity Catalog, DLT/Lakeflow, or CI/CD required (those are Phase 3).
W23 CDC & Incremental I — Watermarks: load only what changedCMS republishes hospital price-transparency files on a rolling schedule, but Maria's pipeline reprocesses every historical file and all of NPPES on every run — a multi-hour, Free-Edition-melting refresh that misses her month-end compliance deadline. A watermark makes each run touch only the pricing periods that actually changed, so the freshness SLA is met and compute stays inside the budget. M16 — CDC & Incremental Loading I · Phase 2 · silver G3 ~10.5h
Why this matters CMS republishes hospital price-transparency files on a rolling schedule, but Maria's pipeline reprocesses every historical file and all of NPPES on every run — a multi-hour, Free-Edition-melting refresh that misses her month-end compliance deadline. A watermark makes each run touch only the pricing periods that actually changed, so the freshness SLA is met and compute stays inside the budget.
Project 1 · this week Converts the running Provider Pricing Integrity pipeline from W22's one-shot medallion load into a resumable incremental load, directly serving the freshness-SLA KPI and the compute budget. Adds a persistent etl_watermark control table to the project and an 'Incremental / watermark' node to the 4-part ARCHITECTURE DOC's data-flow map; seeds the idempotency-plan section (naming the known append double-count that W24 will close) and adds a bullet to the failure-plan (advance-on-success-only guard). The PROJECT CHARTER's freshness-SLA KPI now has a concrete mechanism behind it.
Objectives
  • Explain, in business terms, why full-refresh reprocessing of CMS pricing + NPPES fails on cost, compute, and freshness-SLA grounds, and define a watermark as the durable high-water mark that lets a run resume instead of restart.
  • Design and create a Delta control/state table (source, layer, watermark_column, watermark_value, run_ts) that survives cluster restarts and is the single source of truth for 'where did we stop'.
  • Implement an incremental bronze->silver read that selects only rows newer than the stored watermark (WHERE ingest_ts > watermark), measured against the full-table row count.
  • Advance the watermark ONLY on successful processing (never on failure or an empty slice), and prove it by running the pipeline across three simulated CMS republish periods.
  • Distinguish ingest-time from event-time watermarks and demonstrate, with a deliberately late/out-of-order record, which one silently drops data — and write the decision down as an interview-ready 'why' note.
Concept lessons · ~3.5h
  • Full refresh vs incremental — the money argument (0.75h): why reprocessing millions of NPPES rows and every hospital pricing file on each CMS republish blows Free-Edition compute budgets and breaks Maria's freshness SLA; when full refresh is actually correct (small dims, first load, schema break) vs when incremental is mandatory.
  • What a watermark IS (0.75h): a durable high-water mark = the maximum value of a monotonic column already processed; the read-process-advance loop; bootstrapping (null/epoch watermark on first run processes everything); backfill vs steady-state incremental.
  • Where the watermark LIVES (0.5h): why a notebook variable / widget / config cell is a bug (lost on restart, not shared, not auditable) and why a Delta control table is the right home — one row per (source, layer), updated transactionally, queryable as an audit trail.
  • Change detection strategies for CMS data (0.75h): file-arrival (new files since last run) vs a timestamp/last_updated column vs Delta ingest_timestamp; why bronze->silver should watermark on INGEST-time (append-only, monotonic, controlled by us) rather than the source's unreliable event/last_updated date.
  • Late & out-of-order data, and the honest limit (0.75h): a record with an old event date but a new ingest time; how an event-time watermark silently skips it while an ingest-time watermark catches it; and the explicit boundary — a watermark tells you WHAT to read, it does NOT make reruns safe (that is idempotency, next week).
Hands-on lab · ~7h

Turn last week's one-shot medallion load into a resumable, watermark-driven incremental load. You will land the CMS pricing source as three simulated republish 'periods' into an append-only bronze (source + ingest_timestamp + period label), stand up a Delta control table that stores the high-water mark, read only the rows newer than that mark into silver, and advance the mark only on success. You will then prove the behavior: a no-new-data rerun processes 0 rows and leaves the watermark untouched; a fresh period flows only its own rows; and a deliberately late record exposes the ingest-time vs event-time trade-off. (Reruns still double-count on append — that is intentional and gets fixed next week with idempotency.)

Step by step
  1. FRAME IT: open the lab README with a 1-2 sentence business framing in Maria's voice (why full refresh misses her compliance deadline and burns compute). State this run's KPI target: data-freshness SLA = new CMS period reflected in silver within one run, and 'rows processed per run << full-table count'.
  2. LAND PERIODS TO BRONZE (append-only): using the W22 pricing ingest, land the same CMS hospital pricing source THREE times as period_id = 2025-07, 2025-08, 2025-09. Simulate a republish: period 2 adds a handful of new facilities and changes prices on a few existing ones; period 3 changes a few more. Stamp every row with source_file, ingest_timestamp (current_timestamp at land), and period_id. Do NOT dedup or transform — bronze stays raw.
  3. BUILD THE CONTROL TABLE: create a Delta table etl_watermark(source STRING, layer STRING, watermark_column STRING, watermark_value TIMESTAMP, run_ts TIMESTAMP, rows_processed LONG). Insert a bootstrap row for (cms_pricing, silver) with watermark_value = TIMESTAMP '1970-01-01' so the first run processes everything. Write get_watermark() and set_watermark() helpers (TODOs in the scaffold).
  4. READ INCREMENTALLY: read the current watermark, then select bronze rows WHERE ingest_timestamp > watermark_value. Print incremental_count and full_count side by side so the compute saving is visible. On the first run all three periods may be new (depending on land timing) — confirm you understand why.
  5. PROCESS TO SILVER: apply your W22 silver conform/type logic to ONLY the incremental slice and write it to silver (append for now). Add a one-line comment: 'append double-counts on rerun — hardened with idempotent MERGE in W24.' Do not solve idempotency here.
  6. ADVANCE ON SUCCESS ONLY: after a successful write, compute new_watermark = max(ingest_timestamp) of the processed slice and update etl_watermark transactionally (also record run_ts and rows_processed). Guard it: if the slice is empty OR the write raised, do NOT advance — wrap in try/except and only call set_watermark() in the success path.
  7. PROVE INCREMENTAL BEHAVIOR (the test): (a) rerun immediately with no new data -> assert incremental_count == 0 and watermark_value unchanged; (b) land a NEW period 2025-10, rerun -> assert only that period's rows flow and the watermark advances to its max ingest_timestamp. Capture the etl_watermark history rows as evidence.
  8. EXPOSE THE LATE-DATA TRADE-OFF: land one record whose event/last_updated date is 2025-06 (older than everything) but whose ingest_timestamp is now. Show that the ingest-time watermark PROCESSES it, then reason (in a code comment + note) about how an event-time watermark on last_updated would have SKIPPED it. Do not switch strategies — document the choice.
  9. WRITE THE 'WHY' NOTE + UPDATE ARCHITECTURE DOC: 200-300 words defending watermark column choice (ingest-time), storage choice (Delta control table vs variable), the advance-on-success-only guard, and the known append limitation. Add an 'Incremental / watermark' component to the architecture doc's data-flow map and seed the idempotency-plan section.
Data
CMS Hospital Price Transparency (machine-readable pricing files)
Mess to handle Hospitals republish pricing files on their own schedules with no reliable change flag; the same file reappears with a new date, columns drift between periods, and 'last_updated' fields are inconsistent or absent — so you cannot trust the source's own timestamp to tell you what changed. This is exactly why you watermark on ingest-time. To simulate 'updated across periods' without waiting a month, land the same source 3x as period snapshots and mutate a subset of rows between periods.
CMS data portal — provider/utilization datasets (period-versioned)
Mess to handle Many datasets are published as dated/period snapshots with overlapping content across releases; there is no per-row change indicator, forcing you to detect 'new/changed since last run' yourself rather than trust a source-provided delta.
NPPES NPI Registry (provider context for the pricing join)
Mess to handle Full monthly dumps re-emit millions of rows with only a small fraction changed; reprocessing the whole file every run is the wasteful full-refresh anti-pattern this week eliminates. Name/address variants and duplicates are deferred to the MDM weeks.
Reference material
</> Starter scaffold — w23_incremental_watermark_scaffold.py python
# Week 23 - CDC & Incremental I: watermark-based bronze->silver load
# Databricks Free Edition + Delta + PySpark. Run cells top to bottom.
from pyspark.sql import functions as F

CATALOG_PATH = "main.default"          # adjust to your schema
BRONZE = f"{CATALOG_PATH}.bronze_cms_pricing"
SILVER = f"{CATALOG_PATH}.silver_cms_pricing"
WATERMARK = f"{CATALOG_PATH}.etl_watermark"
SOURCE, LAYER = "cms_pricing", "silver"

# --- 1. Control / state table (durable high-water mark) -------------------
spark.sql(f"""
CREATE TABLE IF NOT EXISTS {WATERMARK} (
  source STRING, layer STRING, watermark_column STRING,
  watermark_value TIMESTAMP, run_ts TIMESTAMP, rows_processed LONG
) USING DELTA
""")
# Bootstrap one row so the FIRST run processes everything (epoch watermark).
if spark.table(WATERMARK).where(f"source='{SOURCE}' AND layer='{LAYER}'").count() == 0:
    spark.sql(f"""INSERT INTO {WATERMARK} VALUES
      ('{SOURCE}','{LAYER}','ingest_timestamp', TIMESTAMP'1970-01-01', NULL, 0)""")

def get_watermark():
    # TODO: return the current watermark_value for (SOURCE, LAYER) as a Python datetime.
    #       Read from WATERMARK, filter, .collect(). This is your resume point.
    raise NotImplementedError

def set_watermark(new_value, rows):
    # TODO: UPDATE the WATERMARK row for (SOURCE, LAYER):
    #       watermark_value=new_value, run_ts=current_timestamp(), rows_processed=rows.
    #       Call this ONLY on the success path (see step below).
    raise NotImplementedError

# --- 2. Land a CMS pricing 'period' to bronze (append-only) ---------------
def land_period(df_period, period_id, source_file):
    # Bronze contract: raw + provenance ONLY. No dedup, no typing here.
    (df_period
       .withColumn("source_file", F.lit(source_file))
       .withColumn("period_id", F.lit(period_id))
       .withColumn("ingest_timestamp", F.current_timestamp())
       .write.mode("append").saveAsTable(BRONZE))
# TODO: land 3 periods (2025-07/08/09). For 08 add a few new facilities and
#       change prices on a few existing rows; for 09 change a few more. This
#       simulates CMS republishing without waiting a month.

# --- 3. Incremental read: only rows newer than the watermark --------------
wm = get_watermark()                       # TODO returns datetime
bronze = spark.table(BRONZE)
incremental = bronze.where(F.col("ingest_timestamp") > F.lit(wm))
full_count, inc_count = bronze.count(), incremental.count()
print(f"full={full_count}  incremental={inc_count}  (saving={full_count-inc_count})")

# --- 4. Process ONLY the slice into silver, then advance on SUCCESS -------
try:
    if inc_count == 0:
        print("No new data - watermark stays put.")           # do NOT advance
    else:
        # TODO: apply your W22 silver conform/type logic to `incremental` only.
        silver_df = incremental  # <-- replace with real transform
        # NOTE: append double-counts on rerun. Intentional - W24 adds idempotent MERGE.
        silver_df.write.mode("append").saveAsTable(SILVER)
        # Advance watermark to the max ingest_timestamp we actually processed.
        new_wm = incremental.agg(F.max("ingest_timestamp")).first()[0]
        set_watermark(new_wm, inc_count)                       # success path ONLY
        print(f"Advanced watermark -> {new_wm} ({inc_count} rows)")
except Exception as e:
    # Failure path: DO NOT advance the watermark. Next run re-attempts the slice.
    print(f"Run failed, watermark unchanged: {e}")
    raise

# --- 5. TODO: prove it --------------------------------------------------
# (a) rerun with no new data -> assert inc_count == 0, watermark unchanged.
# (b) land period 2025-10 -> rerun -> assert only its rows flow, watermark advances.
# (c) land 1 late record (event date 2025-06, ingest now) -> show it IS processed;
#     comment why an event-time watermark on last_updated would have skipped it.
# Then read `spark.table(WATERMARK)` history as evidence for your 'why' note.
Note STARTER SCAFFOLD ONLY — sets up the control table, period landing, and the read-process-advance skeleton. TODO markers are where YOU implement the incremental logic. It deliberately does NOT solve idempotency (append is intentional; W24 fixes it) and does NOT switch to event-time watermarking.
Go deeper
From Gambill Data — videos & templates
Courses & books
Adapt to your industry Mirror the watermark on your own industry's periodically-republished source. SEC EDGAR: watermark on filing acceptance datetime so each run pulls only newly-accepted filings, not the full history. CoinGecko: watermark on your ingest_timestamp of price snapshots across polling windows. NOAA / data.gov: watermark on the dataset's period/version or your land time. The pattern is identical — land raw with an ingest_timestamp, store a durable high-water mark in a Delta control table, read WHERE ingest_ts > watermark, advance only on success — and the same trap applies: prefer YOUR ingest-time over the source's self-reported 'last_updated', which is often stale, missing, or reused when the publisher re-emits an unchanged file.
Deliverable, acceptance & live session
Deliverable & acceptance
A Databricks notebook that runs the CMS pricing bronze->silver load incrementally, backed by a committed etl_watermark Delta control table, plus: (1) evidence output showing full_count vs incremental_count per run and the etl_watermark history rows across at least three periods; (2) a passing no-new-data rerun (0 rows, watermark unchanged) and a new-period rerun (only new rows flow); (3) a late-record demonstration; and (4) a 200-300 word 'why' note plus the updated architecture-doc data-flow map and seeded idempotency section.
Pass when
  • PASS requires: (1) watermark_value lives in a Delta table, not a notebook variable, and persists across a cluster restart
  • A run with no new data processes exactly 0 rows and leaves watermark_value byte-for-byte unchanged
  • After landing a new period, incremental_count equals ONLY that period's row count and the watermark advances to that period's max(ingest_timestamp)
  • A simulated failure before the write leaves the watermark unadvanced (guard proven)
  • The late-record case is demonstrated AND the ingest-vs-event-time choice is explained in writing
  • The 'why' note names the known append double-count as an explicit W24 follow-up rather than hiding it.
Live session focus

Recap Last week you built the medallion bronze->silver->gold flow as a single full pass. This week you make it resumable: instead of reprocessing everything every time CMS republishes, you store a durable high-water mark and touch only what changed — the first half of incremental loading, before we make reruns safe next week.

Misconception The big one: 'a watermark makes my reruns safe.' It does not. A watermark only tells you WHAT to read; it says nothing about whether writing that slice twice corrupts silver (append double-counts — you saw it). Safe reruns are idempotency, which is next week. The second trap: watermarking on the source's last_updated/event date instead of your own ingest-time — a re-emitted file or a late record with a stale date silently gets skipped. We will pull up a learner's late-record output and watch the event-time approach drop it.

Hot seat "Defend three decisions live: (1) Why did you watermark on ingest_timestamp instead of the source's last_updated column — give the specific CMS failure mode that choice protects against. (2) Why is the watermark in a Delta control table and not a notebook widget or config cell — what breaks if a hiring manager reruns your notebook on a fresh cluster? (3) Show your advance-on-success-only guard and explain what your pipeline does on the NEXT run after a mid-run failure. Bonus: your append still double-counts on rerun — why did you ship it that way this week instead of hiding it?"

Portfolio Adds a visible 'incremental loading' capability to Project 1's README: a before/after of full_count vs incremental_count, the etl_watermark audit trail across periods, and a decision note on ingest-time vs event-time watermarking — concrete, defensible evidence that the candidate understands resumable pipelines, not just tutorials. The honestly-flagged append limitation (with a named next step) signals engineering maturity to a reviewer.
Prereqs & carry-forward W22 completed: a working medallion bronze->silver->gold load for CMS pricing on Databricks Free Edition + Delta, with bronze already append-only and stamped with source + ingest_timestamp. Comfort with PySpark DataFrame filters/aggregations and Spark SQL DDL/DML (CREATE TABLE, INSERT, UPDATE). No Unity Catalog, DLT/Lakeflow, or CI/CD required — those arrive in Phase 3.
W24 CDC & incremental II — idempotency, backfills & the 10× testLast cycle a mid-load cluster failure led Maria's team to re-run the CMS ingest; because the silver load was append-only, the same provider batch landed twice, inflating "facilities analyzed" and skewing average negotiated rates on a report that feeds compliance filings. A silver load that is safe to re-run and safe to backfill is the difference between a defensible number and a public retraction. M16 — CDC & incremental loading · Phase 2 · silver G3G4 ~9.5h
Why this matters Last cycle a mid-load cluster failure led Maria's team to re-run the CMS ingest; because the silver load was append-only, the same provider batch landed twice, inflating "facilities analyzed" and skewing average negotiated rates on a report that feeds compliance filings. A silver load that is safe to re-run and safe to backfill is the difference between a defensible number and a public retraction.
Project 1 · this week Advances Project 1's silver layer from 'loads once' to 'loads safely, forever': the silver dim_provider load becomes idempotent (MERGE-on-NPI) and the pricing fact load becomes month-partition-idempotent (replaceWhere). Adds the IDEMPOTENCY PLAN section to the 4-part architecture doc (started W20) — one row per table naming the chosen strategy, the natural key, and the 10× evidence — plus a backfill runbook. Produces the KPI 'reruns produce identical counts (variance = 0)' as demonstrable proof.
Objectives
  • Implement an idempotent silver load using Delta Lake MERGE on the natural key (NPI) so reprocessing the same source batch yields identical row counts across 10 consecutive runs (count variance = 0, rows == distinct keys).
  • Choose and defend an idempotency strategy per table — MERGE-on-key vs. replaceWhere/dynamic-partition-overwrite vs. full overwrite — and record the tradeoff in the architecture doc's idempotency plan.
  • Backfill one historical CMS/NPPES month into an existing silver table without creating duplicates and without flipping the wrong SCD2 current-flag.
  • Build an automated 10× rerun harness that asserts row count, distinct-key count, and current-flag count are stable and fails LOUD (raises) when they are not.
  • Distinguish deterministic vs. non-deterministic transforms and explain in the why-note why a non-deterministic step (e.g., current_timestamp() in a business column) breaks the 10× test.
Concept lessons · ~3.5h
  • Lesson 1 — What 'idempotent' actually means for data (0.5h): at-least-once delivery, retries after a half-finished job, and why append-only loads silently duplicate. Loud-vs-silent framing: a rerun that doubles Maria's provider count is a silent compliance error.
  • Lesson 2 — Delta MERGE deep dive (1.0h): the ON clause and why it must be the true natural key; whenMatched update-on-change vs. update-all; whenNotMatched insert; why MERGE requires at most one source row per key (in-batch dedupe first); MERGE combined with the SCD2 two-step (close-old + insert-new) pattern from W23.
  • Lesson 3 — Three idempotency strategies compared (0.75h): MERGE-on-key (upsert, best for dimensions/entities), replaceWhere / dynamic partition overwrite (best for partitioned fact reloads of one month), full overwrite (small reference tables). When each is correct, and the cost/blast-radius of each.
  • Lesson 4 — Backfills without regret (0.75h): reprocessing a historical month vs. incremental append; deterministic transforms as the precondition; ordering hazards when backfilling into SCD2 history; how a backfill re-runs an OLD batch_id, not a new one.
  • Lesson 5 — Proving it: idempotency as a testable contract (0.5h): the 10× test, assertion harness, and treating 'safe to re-run' as a claim you can demonstrate to an interviewer, not assert.
Hands-on lab · ~6h

Convert the W23 incremental silver load from append to an idempotent MERGE keyed on the provider natural key (NPI), then backfill one historical NPPES month into the existing silver dim_provider table. Finally, build and run a 10× proof harness that reloads the same batch ten times and asserts row count, distinct-key count, and current-flag count never move — failing loudly if they do. Every strategy choice is written into the idempotency plan section of the architecture doc as an interview answer.

Step by step
  1. 1. Reproduce the bug first (recognition): take the W23 append-based silver load and run it twice on the same bronze batch. Query rows vs. distinct NPI and screenshot the duplication — this is the failure the rest of the lab kills.
  2. 2. Add in-batch dedupe: MERGE requires <=1 source row per key. Read one bronze source_batch, and reduce to one row per NPI by keeping the latest ingest_ts (window row_number). Confirm distinct NPI == row count of the source.
  3. 3. Make transforms deterministic: audit the conform step and remove/relocate any non-deterministic call (e.g., current_timestamp() written into a business column, random ordering). Move audit timestamps to metadata columns that are excluded from the change-detection comparison.
  4. 4. Rewrite the load as a Delta MERGE on t.npi = s.npi with whenMatchedUpdate ONLY when tracked attributes changed, and whenNotMatchedInsertAll. Wire the SCD2 close-old + insert-new pattern from W23 into the MERGE so history stays correct.
  5. 5. Backfill a historical month: point the load at an OLD batch_id (a prior NPPES monthly file) and run it. Verify no duplicate NPIs appear and that the current-flag still marks exactly one current row per provider.
  6. 6. Build the 10× harness: loop the full load 10 times over the same batch, collecting (rows, distinct_keys, current_rows) each pass; assert all three are constant across runs and that rows-with-current-flag == distinct NPI. Raise AssertionError naming the offending run number on any drift.
  7. 7. Choose and log strategy per table: pricing facts are month-partitioned — implement the pricing silver reload with replaceWhere/dynamic partition overwrite instead of MERGE, and write WHY (blast radius, cost, key cardinality) in the idempotency plan.
  8. 8. Quarantine + audit: confirm rejected records still route to the rejected table with reason + timestamp on reruns (a rerun must not resurrect quarantined rows), and append a row to the audit log capturing batch_id, strategy, run count, and the 10× result.
  9. 9. Write the why-note (150–250 words): which strategy per table and why, what made the load non-idempotent originally, and how the 10× test proves the fix.
Data
NPPES bulk monthly data files (backfill source — pick two consecutive months)
Mess to handle Full monthly provider dump (millions of rows); the same NPI reappears month over month with drifting name/address/taxonomy, and a given monthly file can contain the same NPI twice — so you MUST dedupe in-batch before MERGE, and backfilling an older month must not overwrite a newer current-version row.
NPPES NPI Registry API (spot-check / small keyed pulls)
Mess to handle Provider records with name/address variations and duplicate-looking entities; rate-limited and paginated (429s on tight loops), so retries are common — exactly the at-least-once scenario that demands an idempotent keyed load.
CMS Hospital Price Transparency machine-readable files (pricing fact side)
Mess to handle Hospitals republish price files monthly in wildly varying formats and completeness; you reprocess a whole month at a time, which is why the pricing fact table uses month-partition overwrite (replaceWhere) rather than row-level MERGE — a different idempotency strategy for a different grain.
Reference material
</> Starter scaffold — silver_providers_idempotent_load.py python
# Week 24 - Idempotent silver load via Delta MERGE + 10x proof harness
# STARTER SCAFFOLD: complete the TODOs. Do NOT ship this as-is.
from pyspark.sql import functions as F, Window
from delta.tables import DeltaTable

NATURAL_KEY  = "npi"                 # NPPES National Provider Identifier = the true natural key
BRONZE_TABLE = "bronze.nppes_providers"
SILVER_TABLE = "silver.dim_provider"
# A BACKFILL re-runs an OLD batch id. An incremental run uses the newest one.
BATCH_ID     = "2026-08"             # TODO: set to the CMS/NPPES month you are (re)processing
TRACKED_COLS = ["provider_name", "address_line1", "state", "taxonomy_code"]  # attrs that define a 'change'

def read_bronze_batch(spark, batch_id):
    df = spark.table(BRONZE_TABLE).where(F.col("source_batch") == batch_id)
    # MERGE requires <= 1 source row per key. Bronze can hold the same NPI twice in one file.
    # TODO: keep only the latest row per NATURAL_KEY using a window over ingest_ts (row_number == 1).
    return df  # TODO

def conform(df):
    # Must be DETERMINISTIC: same input -> same output every run, or the 10x test will flip.
    # TODO: type-cast, trim, upper-case state, standardize phone.
    # TODO: keep audit timestamps in METADATA columns only, and exclude them from TRACKED_COLS.
    return df  # TODO

def upsert_silver(spark, src):
    if not spark.catalog.tableExists(SILVER_TABLE):
        src.write.format("delta").saveAsTable(SILVER_TABLE)   # first load
        return
    tgt = DeltaTable.forName(spark, SILVER_TABLE)
    change_pred = " OR ".join([f"t.{c} <> s.{c}" for c in TRACKED_COLS])
    (tgt.alias("t")
        .merge(src.alias("s"), f"t.{NATURAL_KEY} = s.{NATURAL_KEY} AND t.is_current = true")
        # TODO: whenMatchedUpdate(condition=change_pred, ...) to CLOSE the old current row (SCD2 step 1)
        # TODO: whenNotMatchedInsertAll() to add brand-new providers
        # NOTE: the 'insert new version of a changed provider' step (SCD2 step 2) is a second write -
        #       decide where it lives and why (write it in your why-note).
        .execute())

def run_load(spark, batch_id):
    upsert_silver(spark, conform(read_bronze_batch(spark, batch_id)))

def prove_idempotent(spark, batch_id, runs=10):
    seen = []
    for i in range(runs):
        run_load(spark, batch_id)
        m = (spark.table(SILVER_TABLE)
               .agg(F.count("*").alias("rows"),
                    F.countDistinct(NATURAL_KEY).alias("keys"),
                    F.sum(F.col("is_current").cast("int")).alias("current_rows"))
               .collect()[0])
        seen.append((m["rows"], m["keys"], m["current_rows"]))
        print(f"run {i+1}: rows={m['rows']} keys={m['keys']} current={m['current_rows']}")
    # TODO: assert every tuple in `seen` is identical to seen[0] -> FAIL LOUD with the run number.
    # TODO: assert current_rows == distinct keys (exactly one current row per provider = no dup collapse).
    return seen
Note STARTER SCAFFOLD for Databricks Free Edition — lands the structure and the 10× harness skeleton only. Fill every TODO; this is deliberately NOT the solution. Runs on Delta + PySpark.
Go deeper
From Gambill Data — videos & templates
Courses & books
Adapt to your industry Run the same pattern on YOUR industry's re-published, keyed source. SEC EDGAR: re-fetch a company's filings and MERGE on accession number so a retry never double-lists a 10-K; backfill a prior quarter. CoinGecko: month-partition overwrite (replaceWhere) daily OHLC by coin_id after a 429 retry. ReliefWeb/NOAA: MERGE reports/stations on their stable id. Locked-down data (Faker): generate a batch with intentional in-batch duplicate keys and prove your MERGE + 10× harness collapses them. Name your natural key and your strategy in your own idempotency plan.
Deliverable, acceptance & live session
Deliverable & acceptance
A committed, idempotent silver load (MERGE-on-NPI for dim_provider, replaceWhere for the pricing fact) plus: (1) a screenshot of the ORIGINAL append-based duplication bug, (2) the 10× harness output showing constant rows/keys/current-rows across all ten runs, (3) evidence of one historical-month backfill that added no duplicates and left one current row per provider, (4) the idempotency-plan section added to the architecture doc, and (5) a 150–250 word why-note defending strategy-per-table.
Pass when
  • Running the silver load 10× on the same batch yields identical row count, distinct-NPI count, and current-flag count (variance = 0), and rows-with-current-flag == distinct NPI. The historical-month backfill introduces zero duplicate NPIs and exactly one current row per provider. The pricing fact reload uses partition overwrite, not MERGE, with a written justification. The 10× harness RAISES (fails loud) when fed a deliberately non-idempotent transform. Quarantined records are not resurrected by a rerun, and each run appends one audit-log row (batch_id, strategy, run count, 10× result).
Live session focus

Recap Each learner shows their before/after: the duplication screenshot from the append load, then the 10× harness printing ten identical count triples. In 3 minutes: name your natural key, your per-table strategy, and one non-deterministic thing you had to remove to make the counts stop moving.

Misconception 'MERGE makes any load idempotent.' It does not — MERGE is only idempotent if (a) the ON clause is the TRUE natural key, (b) there is at most one source row per key (in-batch dedupe done first), and (c) every transform is deterministic. A current_timestamp() written into a business column, or a duplicate key in the batch, will make even a MERGE-based load fail the 10× test. Idempotency is a property of the whole load, not of the MERGE keyword.

Hot seat "Defend your decision: 'You used MERGE-on-key for providers but partition overwrite for pricing — why not the same strategy for both? What is the blast radius if your idempotency assumption is wrong, and prove to me right now that a re-run of last month's backfill won't double-count Maria's facilities.' Learner must justify strategy-per-grain, cite their 10× evidence, and explain what their harness would catch."

Portfolio Adds a 'safe to re-run, safe to backfill' section to the Project 1 README with the before/after duplication proof and the 10× harness output — a recruiter skimming in 30s sees a candidate who treats idempotency as a tested contract. The idempotency plan and backfill runbook become direct interview answers ('how do you handle a failed load re-run?').
Prereqs & carry-forward W23 CDC I (change detection + SCD2 close-old/insert-new pattern on silver dim_provider); a working bronze append-only table with source_batch + ingest_ts; comfort with Delta Lake writes, PySpark window functions, and Spark SQL predicates on Databricks Free Edition.
W25 Spark I — the PySpark DataFrame APIMaria's silver transforms passed on a 5,000-row pandas sample but choke the moment CMS republishes the full multi-million-row NPPES provider dump — so her monthly completeness and duplicate-provider KPIs are computed on a subset, not the real provider universe, which is exactly the gap a compliance auditor would flag. Re-expressing the transforms in the PySpark DataFrame API is what lets the pricing-integrity checks run over the whole population each month instead of a hand-picked slice. M17 — Spark I · Phase 2 · silver G7 ~10h
Why this matters Maria's silver transforms passed on a 5,000-row pandas sample but choke the moment CMS republishes the full multi-million-row NPPES provider dump — so her monthly completeness and duplicate-provider KPIs are computed on a subset, not the real provider universe, which is exactly the gap a compliance auditor would flag. Re-expressing the transforms in the PySpark DataFrame API is what lets the pricing-integrity checks run over the whole population each month instead of a hand-picked slice.
Project 1 · this week Advances Project 1 by replacing the SQL/pandas silver transform with a Spark-native DataFrame notebook that runs over the full-scale bronze Delta tables — the same silver + rejected outputs, now at population scale. Adds to the 4-Part Architecture Doc (started W20): a tool-selection-log entry justifying the PySpark DataFrame API over pandas for the monthly CMS republish, plus the saved explain() physical plans as evidence. Feeds the running KPIs (% completeness, # quarantined) computed on the whole provider universe rather than a sample.
Objectives
  • Given a chain of 6+ DataFrame operations, predict on paper exactly which line triggers a Spark job, then confirm the prediction against the job count in the Spark UI with 100% accuracy.
  • Re-express the Week 19–24 silver transforms (rename/select to contract, type casts, when/otherwise conforming, null handling, quality-gate split) using only the PySpark DataFrame API and produce row counts identical (delta = 0) to the prior SQL/pandas version on the same input.
  • Derive df_valid and df_rejected from ONE validated DataFrame using a single predicate set, write both to Delta, and prove conservation: valid.count() + rejected.count() == input.count() exactly.
  • Read df.explain() output and explain in writing, referencing the physical plan, why an early vs late filter yields the same result but a different scan cost.
  • Demonstrate lazy evaluation empirically: show via wall-clock timing that adding transformations is near-instant while the first action incurs the full scan, and identify one place collect() would have crashed the driver at CMS scale.
Concept lessons · ~4h
  • The Spark execution model (~1.0h): driver vs executors, partitions, the DAG. Lazy evaluation — transformations append to a lineage/plan and return a new DataFrame; nothing runs until an action. Narrow vs wide transformations named only as a preview (the shuffle deep-dive is W26, do not go there yet). Why laziness exists: Spark optimizes the whole plan before running it.
  • The transformation-vs-action catalog (~1.0h): transformations you will use this week — select, filter/where, withColumn, withColumnRenamed, drop, cast, when/otherwise, distinct, na.fill/na.drop. Actions that trigger jobs — count, show, display, collect, take, first, write. The trap: display()/show() in a notebook fire a job, so 'it ran' during exploration hides where real execution happens.
  • From SQL/pandas to the DataFrame API (~1.0h): mapping each silver SQL clause to a DataFrame call (WHERE→filter, CASE→when/otherwise, CAST→cast, COALESCE→na.fill/coalesce, SELECT alias→select+alias). Column expressions with F.col() and pyspark.sql.functions vs raw Python. Why collect() pulls all rows to the driver and will OOM on the full NPPES dump — the single most common scale bug.
  • Reading query plans + Delta I/O in Spark (~1.0h): df.explain() logical vs physical plan, spotting the FileScan and Filter nodes; the Spark UI Jobs/Stages tabs; spark.read.format('delta').load(...) / table(...), write modes (overwrite, append) and how the lazy plan is what makes an idempotent rerun (W24) cheap and deterministic.
Hands-on lab · ~6h

Port the existing silver-layer transforms (built in Spark SQL / pandas across Weeks 19–24) to the PySpark DataFrame API, running against the FULL-scale bronze Delta tables on Databricks Free Edition — and instrument the run to prove you understand Spark's lazy execution model (which line triggers a job, why collect() is dangerous, what the physical plan shows). Output is the same silver + rejected Delta tables as before, produced by DataFrame code, with the transformation/action behavior documented.

Step by step
  1. Read the bronze NPPES-provider and price-transparency Delta tables into DataFrames with spark.read.format('delta'). Run df.printSchema(); then build a lazy df.limit(20) WITHOUT calling display() yet — note that no job appears in the Spark UI. This is your first proof of laziness.
  2. Re-express the silver transforms as DataFrame transformations (fill the scaffold TODOs): select/withColumnRenamed to the silver contract columns, .cast() NPI/price/date types, F.when(...).otherwise(...) to conform enum-like fields (e.g., NPPES entity_type_code 1 vs 2 → 'Individual'/'Organization'), and na handling for missing/blank prices. These are ALL transformations — confirm nothing has executed yet.
  3. Instrument lazy eval: print a timestamp after the transform chain is built, then trigger exactly one action — df.count() — and print a second timestamp. Record the wall-clock delta. Open the Spark UI and confirm the number of jobs fired matches your on-paper prediction from the objective.
  4. Define the quality-gate predicate set ONCE (e.g., valid = NPI is 10 digits AND price is non-null AND >= 0). Derive df_valid = validated.filter(cond) and df_rejected = validated.filter(~cond), adding rejection_reason, ingest_ts, and validation_ts columns to the rejected frame. Assert df_valid.count() + df_rejected.count() == input.count() (conservation).
  5. Call df_valid.explain() and df_rejected.explain(). Save the physical-plan text to /architecture/spark_plans_w25.txt and write one paragraph identifying the FileScan and Filter nodes and where in the plan the quarantine split happens.
  6. Write df_valid to the silver Delta table using the idempotency mode you chose in W24 (overwrite partition / MERGE on key), and APPEND df_rejected to the rejected/quarantine Delta table with reason + timestamp. Do not re-teach idempotency — just apply W24's decision.
  7. Re-run the entire notebook 10 times. Confirm silver row count and rejected row count are byte-identical every run (idempotency holds through the DataFrame rewrite). Capture this run's KPIs: % facilities passing the pricing-completeness check, and # records quarantined by reason.
  8. Write the WHY note (docs/why_spark_w25.md): mark each call as transformation or action, name the ONE place you deliberately avoided collect() and what would have happened at full scale, and state in 2–3 sentences why the DataFrame version is preferable to the pandas version for the monthly CMS republish.
Data
NPPES NPI bulk data files (full monthly provider dump)
Mess to handle Millions of rows — the scale that breaks pandas and forces the DataFrame API. Mixed/blank fields, entity_type_code as an unlabeled numeric code, deactivated/reactivated NPIs, and ~330 columns where most provider records leave the majority null. This is the population the silver completeness KPI must cover, not a sample.
NPPES NPI Registry API
Mess to handle Same providers as the bulk file but with name/address variations and duplicate representations of one real provider — used to sanity-check that the DataFrame casts/conforming don't silently drop or mangle records. API also rate-limits (429s) and paginates, so it is a sample source, not the full-scale input.
CMS Hospital Price Transparency machine-readable files
Mess to handle Hospitals publish wildly inconsistent files — prices as strings with '$'/commas, blank or 'N/A' charges, negative or zero placeholder amounts, schema drift between hospitals. Perfect fuel for the cast + na-handling + quarantine transforms you are re-expressing in the DataFrame API.
Reference material
</> Starter scaffold — silver_transform_spark.py python
# silver_transform_spark.py  (Databricks Free Edition notebook)
# STARTER SCAFFOLD — lands structure + proves lazy eval. You fill the TODOs.
# Goal: re-express the Week 19-24 silver transforms in the PySpark DataFrame API.

from pyspark.sql import functions as F
from datetime import datetime

BRONZE_PROVIDERS = "workspace.bronze.nppes_providers"   # <- adjust to your catalog/schema
SILVER_PROVIDERS = "workspace.silver.providers"
REJECTED_TABLE   = "workspace.silver.providers_rejected"

# 1) READ bronze -> DataFrame (this is a transformation-ish setup; no job yet)
bronze = spark.read.format("delta").table(BRONZE_PROVIDERS)
bronze.printSchema()   # printSchema reads metadata only

# 2) BUILD the transform chain — ALL transformations, still lazy.
#    Map your prior SQL/pandas silver logic onto DataFrame calls.
conformed = (
    bronze
    # TODO: select + withColumnRenamed to the silver CONTRACT columns only
    # .select(...)
    # TODO: cast types — NPI to long/string(10), price to double, dates to date
    # .withColumn("npi", F.col("npi").cast("string"))
    # TODO: conform enum-like field with when/otherwise
    # .withColumn("entity_type",
    #     F.when(F.col("entity_type_code") == 1, "Individual")
    #      .when(F.col("entity_type_code") == 2, "Organization")
    #      .otherwise("Unknown"))
    # TODO: null-handling for missing/blank prices (na.fill or F.coalesce)
)

# 3) PROVE laziness: nothing above ran. Time the first ACTION.
t0 = datetime.now();  print("chain built, no job yet:", t0)
n_in = conformed.count()          # <-- ACTION #1 (triggers the scan)
t1 = datetime.now();  print("after count():", t1, "rows:", n_in, "elapsed:", t1 - t0)
# TODO: open the Spark UI -> Jobs. How many jobs fired so far? Match your prediction.

# 4) QUALITY GATE — define the predicate ONCE, split into valid/rejected.
# TODO: replace with your real rule set (10-digit NPI, price non-null & >= 0, ...)
is_valid = (F.length("npi") == 10) & F.col("price").isNotNull() & (F.col("price") >= 0)

df_valid = conformed.filter(is_valid)
df_rejected = (
    conformed.filter(~is_valid)
    .withColumn("rejection_reason", F.lit("TODO: derive specific reason per rule"))
    .withColumn("validation_ts", F.current_timestamp())
)

# TODO: assert conservation — valid + rejected must equal input (delta == 0)
# assert df_valid.count() + df_rejected.count() == n_in

# 5) READ THE PLAN (do NOT collect() the data to the driver!)
df_valid.explain(True)   # save this output to /architecture/spark_plans_w25.txt

# 6) WRITE — apply YOUR W24 idempotency decision (overwrite partition / MERGE).
# TODO: write df_valid to SILVER_PROVIDERS (idempotent mode)
# TODO: append df_rejected to REJECTED_TABLE

# 7) KPIs for Maria
# TODO: pct_complete = df_valid.count() / n_in; print quarantined count by reason
Note Scaffold only: it reads bronze, stubs the transform chain, and hard-wires the lazy-eval timing + explain() instrumentation so the learner SEES the execution model. Every real transform (select/cast/when/na), the specific quality predicates, per-reason rejection labels, the conservation assert, and the idempotent writes are left as TODOs. Deliberately never calls collect() — the scaffold models the safe habit and the WHY note asks the learner to explain why.
Go deeper
From Gambill Data — videos & templates
Courses & books
Adapt to your industry Do the same re-expression on YOUR target industry's silver transforms. If your Project-1 mirror is SEC EDGAR filings, re-express your issuer-conforming and numeric-cast transforms in the DataFrame API over the full bulk submission dump (not a 100-filing sample); if it's CoinGecko, port your price/volume type-casts and null-handling; if it's NOAA, your station-reading conforms. The pattern is identical: one predicate set → valid/rejected split, prove conservation, read explain(), and document one place collect() would have blown up your driver at full volume. Name YOUR specific scale mess (EDGAR's inconsistent tags, CoinGecko 429s, NOAA missing-value sentinels like -9999).
Deliverable, acceptance & live session
Deliverable & acceptance
A Databricks notebook (silver_transform_spark.py / .ipynb) that re-expresses the full silver transform set in the PySpark DataFrame API against the full-scale bronze Delta tables and writes the silver + rejected Delta tables idempotently — accompanied by (a) /architecture/spark_plans_w25.txt with the saved df.explain() output and a one-paragraph reading of it, and (b) docs/why_spark_w25.md tagging each call as transformation vs action, naming the avoided-collect() decision, and justifying DataFrame-over-pandas at CMS scale. Plus the architecture-doc tool-selection-log entry.
Pass when
  • ["Silver row count from the DataFrame version equals the prior SQL/pandas version on the same input (delta = 0)
  • Conservation assert (valid + rejected == input) passes.", "The WHY note correctly labels every DataFrame call as a transformation or an action, and the learner's on-paper job-count prediction matches the Spark UI (verified live).", "df_rejected rows each carry a specific rejection_reason + validation_ts, and the rejected table is APPENDED while silver is written in the deliberately-chosen W24 idempotent mode.", "Running the notebook 10× yields byte-identical silver and rejected counts every time.", "spark_plans_w25.txt exists and the paragraph correctly points to the FileScan and Filter nodes
  • No collect() of full data to the driver appears anywhere in the notebook.", "This run's KPIs (% completeness, # quarantined by reason) are printed and reflect the full-scale input, not a sample."]
Live session focus

Recap Open by re-running one learner's notebook live and pausing right before the first action: 'Predict — how many Spark jobs have run so far?' Reveal the Spark UI (zero jobs), then fire count() and watch one job appear. Recap the mental model out loud: transformations are a recipe, actions cook the meal. Then show a second learner's explain() output and read the physical plan together (FileScan → Filter → the quarantine split) so the group connects the code to what Spark actually does.

Misconception 'It printed output, so my transform ran when I wrote that line.' Wrong — display()/show()/count() are the actions that ran the whole accumulated chain at once; the withColumn/filter lines built a plan and executed nothing. The corollary trap: 'I'll just collect() to a pandas DataFrame to check it' — on the full NPPES dump that pulls millions of rows to the single driver and OOMs. The fix is to reason about WHERE the action is and to inspect with limit()/explain(), never collect() on full data.

Hot seat "Each learner defends: (1) Point to the exact line in your notebook that triggers the first Spark job and prove it against the Spark UI. (2) Name one place you could have written collect() and explain what it would have done to the driver on the full-scale dump — and what you did instead. (3) In your explain() output, show where the quarantine split happens and argue whether the filter runs before or after the file scan, and why that ordering is fine for your idempotency guarantee."

Portfolio Adds the Spark-native silver transform notebook to the Project-1 repo README as the 'scales to the full provider universe' milestone — a hiring manager skimming the README sees a candidate who understands lazy evaluation and can defend where execution happens (a top DE interview screen). The explain() artifact + the transformation/action-tagged WHY note are direct interview evidence ('walk me through Spark's execution model'), and the tool-selection-log entry shows a documented, defensible pandas→Spark decision rather than cargo-culting.
Prereqs & carry-forward W24 idempotency (the write mode you apply here is the one you chose then). Working bronze NPPES + price-transparency Delta tables and the silver contract/quality rules from Weeks 19–23. Comfort with Spark SQL and/or pandas versions of the silver transforms (this week ports them). A running Databricks Free Edition workspace with a cluster/compute and the Spark UI accessible. Python fundamentals (functions, chaining).
W26 Spark II — Joining CMS Pricing to NPPES Providers at Scale, Aggregating Gold KPIs, and Writing the MDM Matching UDFMaria's monthly cost and compliance report attributes every price line to a provider — but CMS pricing rows and the NPPES provider registry only line up cleanly for a fraction of records, and a naive row-by-row match in Python either takes hours or crashes the driver. A wrong or silently-dropped join means prices land on the wrong provider (or vanish), so the compliance filing and the cost analysis are both wrong — the exact error that draws a CMS penalty and blows up the revenue-cycle forecast. M17 — Spark II: joins, aggregations & UDFs at scale · Phase 2 · silver G7 ~10h
Why this matters Maria's monthly cost and compliance report attributes every price line to a provider — but CMS pricing rows and the NPPES provider registry only line up cleanly for a fraction of records, and a naive row-by-row match in Python either takes hours or crashes the driver. A wrong or silently-dropped join means prices land on the wrong provider (or vanish), so the compliance filing and the cost analysis are both wrong — the exact error that draws a CMS penalty and blows up the revenue-cycle forecast.
Project 1 · this week Advances Project 1 from a cleaned silver layer to the first real gold KPI marts: the CMS-pricing-to-NPPES join produces the enriched, provider-attributed dataset that the gold KPIs are computed from, and the normalize_key/matching UDF written here is the exact component the W29 MDM golden-records step will import to block and score duplicate providers. Adds finalized KPI definitions to the PROJECT CHARTER (started W14) and drafts the idempotency-plan and failure-plan sections of the 4-PART ARCHITECTURE DOC (written W20, assembled W30).
Objectives
  • Given the two multimillion-row silver Delta tables (CMS pricing, NPPES providers), implement inner, left-outer, left-anti, and left-semi joins on a provider key and report the exact match rate for the run.
  • Decide between a shuffle join and a broadcast join based on the size of each side, apply the broadcast() hint correctly, and justify the choice in one written paragraph.
  • Write and register a Python UDF that normalizes a provider name + address into a deterministic match/blocking key, and explain in writing why it executes on the executors and costs more than an equivalent built-in expression.
  • Compute the gold KPIs (pricing-completeness %, quarantined-record counts by reason, data-freshness minutes) using groupBy/agg plus at least one window function.
  • Route pricing rows with no provider match to a rejected Delta table with reason + timestamp, and prove the gold build is idempotent by running it 10x for identical output counts.
Concept lessons · ~4h
  • Join mechanics in Spark SQL/PySpark: inner / left-outer / right / full, plus left-semi and left-anti — and why semi/anti are the correct, scalable replacement for 'filter rows by whether a match exists' (which people wrongly write as a subquery or a collected Python set).
  • Shuffle join vs broadcast join: how Spark moves rows to co-locate keys, when the small side fits in memory so you can broadcast() it and skip the shuffle, and a first driver-vs-executor mental model — what a join, a UDF, and a .collect() each actually ship to the cluster. (Deep shuffle/partition/AQE internals are next week — here we stay at 'which strategy and why'.)
  • Aggregations at scale: groupBy/agg with multiple aggregates, count vs approx_count_distinct, and window functions (row_number, rank, sum-over) for KPI rollups and a first look at ranking duplicate providers ahead of the MDM week.
  • UDFs: plain Python UDF vs vectorized/pandas UDF vs a pure built-in expression; the serialization + row-at-a-time cost of Python UDFs; how to register a UDF once and reuse it in SQL and DataFrame code; and the rule 'reach for a built-in before a UDF' — with the matching UDF as the deliberate exception the MDM step will reuse.
  • The data-quality join pattern: use a left-anti join to isolate unmatched pricing rows into quarantine with a reason, and treat the join match rate itself as an acceptance gate that can fail a run loudly.
Hands-on lab · ~6h

Join the silver CMS pricing table to the silver NPPES provider table at full scale, choosing join type and join strategy deliberately; write a reusable normalization/matching UDF that the MDM step will consume; quarantine unmatched pricing rows with a reason; then aggregate the gold KPIs with groupBy/window functions and prove the whole gold build is idempotent across 10 reruns.

Step by step
  1. Load the two silver Delta tables (CMS pricing, NPPES providers) and profile them: row counts on each side, null rate on the NPI join key in pricing, and how many distinct facility names have no NPI at all. Write the numbers down — this is the 'why the join is hard' evidence and the baseline match rate you must beat.
  2. Do the exact-key join first: inner join pricing to providers on NPI, then re-do it as a left-outer join. Compute the match rate (matched / total pricing rows). Capture the unmatched rows from the left-outer result with a left-anti join into a rejected Delta table carrying reason='no_npi_match', the source file, and an ingest/quarantine timestamp.
  3. Make the broadcast-vs-shuffle decision explicitly: broadcast the smaller conformed provider slice (or a taxonomy/reference lookup) with broadcast() and run the join; then run it without the hint. Note in your why-note which side is small enough to broadcast, and state — in one or two sentences — what ships to the executors versus what stays on the driver.
  4. Write and register a Python UDF normalize_key(name, address) that lowercases, strips corporate suffixes (Inc, LLC, PC), collapses whitespace, and standardizes common address abbreviations (St→Street, Ste→Suite) to produce a deterministic blocking/match key. Add a short comment stating that this UDF runs per-row on the executors and is the deliberate exception to 'prefer built-ins'; this is the exact function the W29 MDM step will import.
  5. Use left-semi and left-anti deliberately: left-semi join providers to pricing to keep only providers that actually appear in the pricing data (shrinks the dimension), and confirm the left-anti unmatched set from step 2 matches your quarantine count. Do NOT collect either side to the driver to 'check membership'.
  6. Aggregate the gold KPIs from the matched, enriched dataset: pricing-completeness % per facility (groupBy facility, share of rows with a non-null price), quarantined-record count by reason, and data-freshness in minutes from the max ingest timestamp. Add one window function — row_number() over provider partitions ordered by data completeness — as a first preview of which duplicate record MDM will later prefer.
  7. Write the gold KPI table and the rejected table as Delta using a deliberate idempotency strategy (overwrite the KPI snapshot / MERGE on the KPI key), then run the entire gold build 10 times and assert identical row counts and identical KPI values each run. Record the run in the audit log (run timestamp, input row counts, match rate, quarantined count).
  8. Write the why-note (5–8 sentences): the join type chosen for each step and why, broadcast vs shuffle decision, why the matching logic is a UDF and not a built-in, the match-rate acceptance threshold you set, and what you'd do if a future CMS republish drops the match rate below it. Add the finalized KPIs to the Project Charter and the idempotency + failure sections to the Architecture Doc draft.
Data
CMS Hospital Price Transparency (machine-readable pricing files)
Mess to handle Hospital-published files vary wildly in format and quality; many pricing rows carry a facility or organization name but no NPI (or an NPI for the org, not the rendering provider), so an exact key join misses a large share of rows and forces a name/address match path.
NPPES bulk data files (full monthly provider dump)
Mess to handle Millions of rows — big enough that a naive collect-to-driver or Python loop match fails or crawls; the same real provider appears with name variants (Inc/LLC suffixes, abbreviated first names) and address formatting differences, which is exactly why a normalization/blocking key is needed.
NPPES NPI Registry API (record lookups & spot checks)
Mess to handle Useful to spot-check individual unmatched rows during the lab (paginated, rate-limited); confirms that name/address variation — not just missing NPIs — is why exact joins under-match. API docs: https://npiregistry.cms.hhs.gov/api-page.
CMS Data portal (provider / utilization reference datasets)
Mess to handle Optional small reference/lookup tables (e.g., taxonomy or facility crosswalks) that are small enough to broadcast — a concrete case for the broadcast() join decision versus the multimillion-row NPPES shuffle join.
Reference material
</> Starter scaffold — 26_spark_join_kpis_scaffold.py python
from pyspark.sql import functions as F
from pyspark.sql.functions import broadcast

# --- config: point these at YOUR silver tables from W21-W25 ---
SILVER_PRICING   = "workspace.silver.cms_pricing"
SILVER_PROVIDERS = "workspace.silver.nppes_providers"
GOLD_KPI         = "workspace.gold.pricing_kpis"
REJECTED         = "workspace.silver.rejected_pricing"

pricing   = spark.table(SILVER_PRICING)
providers = spark.table(SILVER_PROVIDERS)

# 1) PROFILE THE JOIN PROBLEM (evidence for your why-note)
# TODO: print pricing.count(), providers.count(),
#       null rate of the NPI key in pricing, and # distinct facility names with no NPI.

# 2) MATCHING UDF the MDM step (W29) will REUSE.
#    NOTE: this runs row-at-a-time on the EXECUTORS and is the deliberate
#    exception to 'prefer a built-in'. Keep it pure + deterministic.
from pyspark.sql.types import StringType

def _normalize_key(name, address):
    # TODO: lowercase; strip suffixes (inc, llc, pc); collapse whitespace;
    #       standardize address abbreviations (st->street, ste->suite).
    #       Return a stable blocking/match key string.
    return None

normalize_key = F.udf(_normalize_key, StringType())
# TODO: also register for SQL reuse: spark.udf.register("normalize_key", _normalize_key)

# 3) EXACT-KEY JOIN + QUARANTINE
# TODO: choose the join type deliberately (inner vs left-outer) and join on NPI.
# matched = pricing.join(providers, on="npi", how="???")
# TODO: use a LEFT-ANTI join to capture unmatched pricing rows ->
#       add reason="no_npi_match", source_file, quarantined_at=current_timestamp(),
#       then append to REJECTED. (Do NOT .collect() to check membership.)

# 4) BROADCAST DECISION
# TODO: identify which side is small enough to broadcast (a reference/lookup
#       or a filtered provider slice) and wrap it in broadcast(...).
#       In one comment, state what ships to executors vs stays on driver.

# 5) GOLD KPIs (groupBy/agg + one window function)
# TODO: pricing-completeness % per facility; quarantined count by reason;
#       freshness minutes from max ingest ts; row_number() over provider
#       partitions ordered by completeness (MDM preview).

# 6) IDEMPOTENT WRITE + PROOF
# TODO: write GOLD_KPI deliberately (overwrite snapshot OR MERGE on kpi key),
#       then run this build 10x and assert identical counts + KPI values.
#       Append a row to your audit log (run ts, input counts, match rate, quarantined).
Note STARTER SCAFFOLD ONLY. Runs on Databricks Free Edition against your existing silver Delta tables. It sets up the join, the matching-UDF stub, and the KPI aggregation shell; every real decision is a TODO. Do NOT fill in a full solution here — the choices are the interview answers.
Go deeper
From Gambill Data — videos & templates
Courses & books
Adapt to your industry Run the identical pattern on your own target industry's data: join a messy transactional fact to a canonical entity registry at scale, quarantine the no-match rows, and write a normalization UDF you'll reuse for your own dedup/MDM week. Finance/markets: join SEC EDGAR filings (https://www.sec.gov/edgar) to a ticker/CIK entity list — issuer names vary (Inc/Corp/Co, subsidiaries). Crypto: join CoinGecko (https://www.coingecko.com/en/api) trades/markets to a canonical asset list — same coin listed under symbol variants across exchanges. Humanitarian: join ReliefWeb (https://reliefweb.int/help/api) reports to an org/country reference. Locked-down or fraud domain: generate millions of rows with Faker and inject deliberate name/address variants. Keep the same deliverables: chosen join type + strategy, a match-rate gate, a reusable matching UDF, quarantine-with-reason, and a 10x idempotency proof.
Deliverable, acceptance & live session
Deliverable & acceptance
A committed Databricks notebook + Delta tables that: (1) join silver CMS pricing to silver NPPES providers using deliberately chosen join types (inner/left/anti/semi) and a stated broadcast-vs-shuffle decision; (2) define and register a reusable normalize_key/matching UDF; (3) quarantine unmatched pricing rows to a rejected table with reason + timestamp; (4) produce a gold KPI table (pricing-completeness %, quarantined counts by reason, freshness) built with groupBy/agg + a window function; plus a why-note paragraph, an audit-log row per run, updated Charter KPIs, and drafted Architecture-Doc idempotency + failure sections.
Pass when
  • Match rate for the run is computed and printed and meets the learner's stated threshold (or the run fails loudly). Unmatched pricing rows appear in the rejected table with reason + timestamp and their count equals the left-anti count. The matching UDF is registered and callable from both DataFrame and SQL. Gold KPIs are produced via groupBy/agg and at least one window function. The gold build run 10x yields identical row counts AND identical KPI values (idempotency proven), with an audit-log row per run. The why-note names the join type per step, the broadcast decision, why the matching logic is a UDF, and the match-rate acceptance threshold.
Live session focus

Recap Last week (W25, Spark I) you moved from single-table DataFrame transforms to reading and writing your silver Delta tables in Spark; this week you connect two of those tables at full scale — the join is where Spark either quietly shines or quietly drops your data, so we make the join type and join strategy explicit decisions rather than defaults.

Misconception 'A UDF is just a normal Python function, so writing the match logic as a UDF is fine and fast.' Correct it: a Python UDF is serialized and run row-at-a-time on the executors (Spark can't optimize inside it), so it's markedly slower than an equivalent built-in expression — the rule is 'reach for a built-in first,' and the normalization/matching UDF is the deliberate, justified exception because the logic is genuinely custom and the MDM step must reuse the exact same function. Related trap: 'collect the small table to the driver and check membership in a Python set' — that's what left-semi/left-anti joins are for, and collecting defeats the whole point of the cluster.

Hot seat "Defend three decisions in 3 minutes: (1) For each join in your pipeline, which join type did you use and why — and where did you use left-anti or left-semi instead of a filter? (2) Which side did you broadcast, how did you know it was small enough, and what shipped to the executors versus the driver? (3) Why is your matching logic a UDF and not a built-in, and what match-rate threshold would fail a future CMS republish — what happens when it does?"

Portfolio Adds the 'join + gold KPIs' stage to Project 1's README with a recognition-first business framing (provider mis-attribution = wrong compliance filing), a before/after match-rate number, a screenshot of the gold KPI table, the reusable matching UDF, and the why-note that doubles as interview answers on join strategy and UDF cost. Finalized KPIs land in the Project Charter; idempotency + failure plans land in the Architecture Doc draft.
Prereqs & carry-forward W25 Spark I (reading/writing Delta tables and basic DataFrame transforms in Spark on Databricks Free Edition); an existing silver layer for CMS pricing and NPPES providers (W21–W24: validation, typing, conforming, quarantine pattern); comfort with Delta MERGE/overwrite and the quarantine-with-reason + audit-log patterns established earlier in Phase 2; Python functions and basic SQL. Cluster internals (shuffle mechanics, partitions, AQE, physical plans) are NOT required — they come in W27.
W27 Spark internals — partitions, shuffles, skew & the Spark UIMaria's monthly dedup now stalls: the provider-matching join finishes only after one giant task grinds on a handful of mega health systems, and the run risks blowing past the data-freshness SLA — meaning her compliance report ships on stale prices or misses its filing window, and an oversized always-on cluster burns budget to hide the problem. A pipeline that is slow-but-silent on a straggler is exactly how a monthly close quietly starts missing deadlines. M18 · Phase 2 · silver G7G9 ~11.0h
Why this matters Maria's monthly dedup now stalls: the provider-matching join finishes only after one giant task grinds on a handful of mega health systems, and the run risks blowing past the data-freshness SLA — meaning her compliance report ships on stale prices or misses its filing window, and an oversized always-on cluster burns budget to hide the problem. A pipeline that is slow-but-silent on a straggler is exactly how a monthly close quietly starts missing deadlines.
Project 1 · this week Advances Project 1 by making the W26 provider-dedup join actually finishable within the freshness SLA before it feeds the MDM golden-records work in W29-W30 — so the dedup can scale to the full NPPES dump instead of a toy sample. It ADDS to the living 4-part ARCHITECTURE DOC (started W20): a new performance/skew section that extends both the tool-selection log (why salt vs broadcast vs AQE here) and the failure plan (the straggler was a silent SLA risk, now diagnosed and bounded). It also contributes a concrete runtime figure to the data-freshness SLA KPI.
Objectives
  • Read the Spark UI to locate a skewed stage: identify the straggler task whose duration and shuffle-read are >=10x the stage median, and name the exact join/stage causing it (not just 'the job is slow').
  • Explain and predict partitioning behavior: state how many shuffle partitions a join will produce from spark.sql.shuffle.partitions, and compute per-key row counts to prove which blocking keys are hot.
  • Apply and MEASURE two skew remedies on the provider-dedup join — salting the hot blocking key and broadcasting the small side — recording before/after wall-clock and max-task time, and achieving a >=2x reduction in the skewed stage's max-task time.
  • Decide when Adaptive Query Execution (AQE) skew-join handling is sufficient versus when manual salting/broadcast is required, and justify the decision in writing with evidence from an AQE-off vs AQE-on comparison.
  • Prove a performance fix preserved correctness: assert the candidate-pair output count is identical before and after tuning (a faster wrong answer is not a fix).
Concept lessons · ~4.5h
  • Partitions 101 — how Spark splits data into partitions as the unit of parallelism; input partitions vs shuffle partitions; the role of spark.sql.shuffle.partitions; why partition count sets the ceiling on parallelism and why 'too few' and 'too many' both hurt. (1.0h)
  • The shuffle — what operations trigger it (joins, groupBy, distinct, window), why moving data across the cluster is the single most expensive thing Spark does, and how to read shuffle read/write bytes and records in the Spark UI stage detail. (1.0h)
  • Data skew — the difference between 'big' and 'skewed'; how a handful of hot keys (a few giant health systems) send millions of rows to ONE partition and create a straggler task while every other core sits idle; spotting it via the event timeline, the task summary (min/median/max), and the largest-task metrics. (1.0h)
  • Remedies and when each applies — broadcast join and spark.sql.autoBroadcastJoinThreshold (kill the shuffle on the small side); salting a skewed key to spread one hot key across N partitions; and AQE skew-join splitting that Databricks does automatically. Trade-offs: broadcast needs a genuinely small side; salting adds work and must replicate the other side; AQE helps joins but not every case. (1.0h)
  • Measure first — how to baseline a job, isolate the one stage that matters, and read a before/after honestly; why tuning blind (more cores, more partitions, copied Stack Overflow flags) wastes money and hides the real bottleneck. (0.5h)
Hands-on lab · ~6.5h

Take the provider-dedup candidate-pair join you built in W26 and make it FAST without making it WRONG. You will baseline it, use the Spark UI to prove the slowness is data skew from a few dominant health systems, apply salting and broadcast fixes, compare against Databricks' automatic AQE skew handling, and write a one-page before/after diagnosis that goes straight into your architecture doc. The whole lab is an exercise in 'measure first': every claim is backed by a Spark UI screenshot and a timing number, and every fix is proven to leave the join output unchanged.

Step by step
  1. Reproduce your W26 candidate-pair dedup self-join on your silver provider table and run it once end-to-end to establish a BASELINE; force execution (e.g. .count()) and record wall-clock time and the shuffle-partition setting you used.
  2. Profile the blocking key: groupBy(block_key).count().orderBy(desc) and show the top 20. Name the SPECIFIC health systems (e.g. HCA, Kaiser, Ascension, Ascension-affiliated NPIs) whose thousands of provider records create the hot keys — write the counts down.
  3. Turn AQE OFF (spark.sql.adaptive.enabled=false) so the raw skew is visible, open the Spark UI, find the join stage, and identify the straggler: capture max vs median task time, the largest task's shuffle-read bytes, and a screenshot of the event timeline showing one long task while others finish early.
  4. Apply Fix A — salting: add a random salt 0..N-1 to the left side of the hot key and replicate the right side across all N salt values; pick N DELIBERATELY (try a couple of values), re-run, and record the new max-task time and wall-clock.
  5. Apply Fix B — broadcast: identify a genuinely small side (the CMS price-transparency hospital list, or a filtered provider subset under the broadcast threshold), wrap it in F.broadcast(), re-run the join, and record timing — noting that this eliminates the shuffle entirely rather than reshaping it.
  6. Turn AQE back ON and re-run the untouched baseline to see whether Databricks' automatic skew-join splitting alone closes the gap; assemble all four runs (baseline AQE-off, salt, broadcast, baseline AQE-on) into a single before/after runtime table.
  7. Correctness gate: assert the candidate-pair row count is IDENTICAL across the baseline and every fixed version; if a fix changes the count, it changed the join semantics and is rejected regardless of speed.
  8. Write the one-page 'Skew Diagnosis & Fix' note (before/after screenshots, the runtime table, the named hot keys, the chosen remedy and why over the alternatives, and the AQE on/off verdict) and append it as the performance section of your 4-part architecture doc.
Data
NPPES bulk data files (monthly full NPI dump)
Mess to handle Millions of rows (~9GB uncompressed) — big enough to produce REAL skew on Free Edition. Large health systems (HCA, Kaiser, Ascension, CommonSpirit) own thousands of individual NPIs that share the same organization-name token + state, so any blocking key built on org-name+state sends millions of candidate pairs to a single partition. Mixed casing, abbreviations ('DEPT' vs 'DEPARTMENT'), PO-box vs street addresses, and reused org names across affiliates all inflate the hot keys.
CMS Hospital Price Transparency (machine-readable hospital list)
Mess to handle The distinct-hospital list is small (a few thousand facilities) — the perfect BROADCAST candidate to join against the huge NPPES side without a shuffle. But the files themselves vary wildly in format/quality, and facility names rarely match NPPES exactly (abbreviations, DBA vs legal name), which is exactly why the join is needed and why a naive shuffle join is tempting to leave un-tuned.
NPPES NPI Registry API (targeted enrichment/verification)
Mess to handle Useful to spot-check that the hot keys really are one organization spread across many NPIs. Capped at 200 results per query and rate-limited, so it cannot be used for the bulk join — reinforces why the bulk file is the right source for the skew work.
Reference material
</> Starter scaffold — w27_skew_diagnose_fix_starter.py python
# W27 - Spark skew: diagnose in the UI, fix with salt/broadcast, prove it stayed correct.
# STARTER SCAFFOLD. Fill every TODO. Do NOT paste in a full solution - the point is to MEASURE.

from pyspark.sql import functions as F

SILVER = "workspace.default.silver_providers"  # TODO: point at YOUR W25/W26 silver provider table
providers = spark.read.table(SILVER)

# --- 1. Rebuild the SAME blocking key you used in W26 (keep it identical so counts match) --------
def with_block_key(df):
    # TODO: recreate your real W26 blocking key (e.g. org-name token + state). Use YOUR columns.
    return df.withColumn(
        "block_key",
        F.concat_ws("|", F.upper(F.col("org_name_token")), F.col("state"))
    )

blocked = with_block_key(providers)

# --- 2. MEASURE FIRST: how skewed is the key? Name the health systems that dominate ------------
# TODO: show top 20 block_key by count; screenshot it; write down which orgs (HCA/Kaiser/...) are hot.
# blocked.groupBy("block_key").count().orderBy(F.desc("count")).show(20, truncate=False)

# --- 3. BASELINE the candidate-pair self-join with AQE OFF so the skew is visible in the UI -----
# spark.conf.set("spark.sql.adaptive.enabled", False)           # TODO: enable to expose raw skew
# spark.conf.set("spark.sql.shuffle.partitions", 200)           # TODO: record whatever value you use
l = blocked.alias("l")
r = blocked.alias("r")
candidate_pairs = (l.join(r, on="block_key")
                    .where(F.col("l.npi") < F.col("r.npi")))    # keep each unordered pair once
# TODO: force + time it (candidate_pairs.count()). In the Spark UI find the join stage and record:
#       max task time, median task time, largest-task shuffle read. Screenshot the event timeline.
baseline_count = None  # TODO: capture this - you will assert against it later.

# --- 4. FIX A: SALT the hot key --------------------------------------------------------------
SALT_N = 8  # TODO: choose N deliberately; justify with max-task time. Try a few, keep the evidence.
# TODO: left side gets a random salt 0..N-1; right side is REPLICATED across all N salts so salted
#       keys still meet. Join on (block_key, salt). Re-run, re-time, re-read the UI vs baseline.

# --- 5. FIX B: BROADCAST the small side ------------------------------------------------------
# TODO: pick a genuinely small side (CMS hospital list, or a filtered subset under the broadcast
#       threshold), wrap it F.broadcast(small_df), re-run, re-time. Note: this KILLS the shuffle.

# --- 6. AQE ON: does Databricks' automatic skew-join splitting alone fix it? ------------------
# spark.conf.set("spark.sql.adaptive.enabled", True)
# TODO: re-run the untouched baseline with AQE on; record result. Decide AQE-only vs salt vs
#       broadcast and WRITE DOWN why in your one-pager.

# --- 7. CORRECTNESS GATE --------------------------------------------------------------------
# TODO: assert your fixed join produces the SAME count as baseline_count.
#       assert fixed_count == baseline_count, f"fix changed output: {fixed_count} != {baseline_count}"
Note STARTER SCAFFOLD ONLY — it sets up the baseline join and the measurement points, then hands off at every TODO. It deliberately does NOT implement the salt/broadcast fixes or the correctness assertion; that is your work. Runs on Databricks Free Edition (Delta + PySpark). Open the Spark UI from the compute page.
Go deeper
From Gambill Data — videos & templates
Courses & books
Adapt to your industry Do the identical diagnosis on YOUR target industry's biggest join, where a few keys dominate. SEC EDGAR: a company/filing join where a handful of mega-filers (big banks, Berkshire) own thousands of filings under one CIK. CoinGecko: a trades-by-market join where BTC and ETH dwarf every other pair. NOAA: an observations-by-station join where a few busy stations carry most rows. Retail/e-commerce (Faker or public): orders skewed to a few top SKUs or one flagship store. Whatever it is, run it, open the Spark UI, find the straggler, name the hot keys, fix with salt or broadcast, and record before/after — same pattern, your data.
Deliverable, acceptance & live session
Deliverable & acceptance
A Databricks notebook plus a one-page 'Skew Diagnosis & Fix' writeup, committed to the Project 1 repo. The notebook baselines the dedup join, profiles the hot blocking keys, and implements at least one manual remedy (salt or broadcast) alongside the AQE on/off comparison. The one-pager contains: before/after Spark UI screenshots (event timeline + task summary), the named health systems causing the skew, a four-row runtime table (baseline AQE-off, salt, broadcast, baseline AQE-on) with wall-clock and max-task time, the chosen remedy with justification over the alternatives, and a passing correctness assertion. The one-pager is appended as the performance section of the architecture doc.
Pass when
  • The Spark UI evidence names the specific stage and a straggler task with max-task time >=10x the median at baseline
  • The chosen fix reduces the skewed stage's max-task time by >=2x (or cuts job wall-clock materially), shown in the runtime table
  • A correctness assertion proves the candidate-pair count is IDENTICAL before and after the fix
  • The writeup states which hot keys caused the skew, why the chosen remedy beat the alternatives, and the AQE-off vs AQE-on verdict in specific (not generic) language
  • The salt N (if used) is justified by measured max-task time, not picked arbitrarily.
Live session focus

Recap Five-minute recap: partition = the unit of parallelism; the shuffle is the expensive step that joins/groupBy trigger; skew is a FEW hot keys, not overall size, sending one partition way more work than the rest, which shows up as a single straggler task in the Spark UI while other cores idle. Remedies: broadcast (kill the shuffle when one side is small), salt (spread one hot key across N partitions), AQE (Databricks splits skewed join partitions automatically). Rule of the week: measure first — baseline, isolate the one stage that matters, then tune.

Misconception 'Just add more partitions / a bigger cluster and it'll speed up.' Skew is uneven KEY distribution, not total volume — more partitions or more cores does nothing for one hot key, because all its rows still land in a single partition and one task still does all the work while the new cores sit idle. The related trap: 'AQE fixes everything.' AQE's skew-join splitting helps many joins but isn't a guarantee for every pattern, which is exactly why you compare AQE-on against a manual salt/broadcast and keep the evidence.

Hot seat "Defend your decision. You are asked: 'You salted the hot key with N=8 instead of 4 or 32 — show the max-task time that justifies exactly 8, and tell me what over-salting would have cost you. Then explain why you salted at all instead of just broadcasting — what made the small side not broadcast-able here? And prove your fix did not change the join output.' Come with the runtime table, the Spark UI screenshots, and the passing correctness assertion open."

Portfolio Adds to Project 1 a recruiter-legible performance story: a 'Skew Diagnosis & Fix' one-pager with before/after Spark UI evidence and a runtime table, plus the architecture doc's new performance section. It becomes a specific interview talking point — 'I found a ~40x straggler in the Spark UI caused by HCA's thousands of NPIs sharing one blocking key, cut the join's max-task time in half with salting, compared it against AQE, and proved the output row count was unchanged' — which beats generic 'I optimized Spark jobs' every time.
Prereqs & carry-forward W26 completed: the provider-dedup candidate-pair join built and running on the silver provider table, with a defined blocking key. Comfort with PySpark joins, groupBy, and window/aggregation basics. A Databricks Free Edition workspace with the NPPES silver table from prior weeks and access to the compute page's Spark UI. Ability to load a large-enough NPPES slice (a few states or the full bulk file) to reproduce real skew.
W28 Data quality I — expectations & quarantine: making silver break loudlyMaria's monthly cost analysis and the hospital's federally-required price-transparency compliance report are only as trustworthy as the silver table feeding them — and right now a single malformed price or an out-of-range CPT code silently poisons a KPI that a compliance officer signs their name to. This week builds the gate that catches those rows loudly, quarantines them with a reason, and alerts before bad data reaches a regulator-facing number. M24 — Data Quality I: Expectations & Quarantine · Phase 2 · silver G4 ~11h
Why this matters Maria's monthly cost analysis and the hospital's federally-required price-transparency compliance report are only as trustworthy as the silver table feeding them — and right now a single malformed price or an out-of-range CPT code silently poisons a KPI that a compliance officer signs their name to. This week builds the gate that catches those rows loudly, quarantines them with a reason, and alerts before bad data reaches a regulator-facing number.
Project 1 · this week Adds the enforced DQ gate to the SILVER layer of Project 1's running pipeline and produces two new living artifacts: the `silver_rejected` quarantine table (reason + timestamp + run_id) and the `dq_run_summary` that computes the phase KPIs '% facilities passing the pricing-completeness check per run' and '# records quarantined (with reason)'. It appends the expectations catalog + severity/threshold choices to the Project Charter's KPI section and seeds the 'failure plan' quarter of the Architecture Doc (to be formalized W20/assembled W30). Silver now breaks loudly instead of silently, directly advancing Maria's trust problem.
Objectives
  • Given the project silver provider+pricing table, WRITE at least 8 specific, named expectations across four rule classes (schema/type, not-null, uniqueness, domain/accepted-code) and justify each in one sentence tied to a KPI or compliance need — no boilerplate 'column should not be null'.
  • IMPLEMENT a validation pass in PySpark/Spark SQL that TAGS each row pass/fail per rule and SPLITS the DataFrame into a clean stream and a quarantine stream without dropping any input row (row-count in == clean + rejected).
  • PERSIST rejected rows to a Delta `silver_rejected` table carrying failure_reason(s), the rule id, the offending value, source file, and a rejected_at timestamp — queryable so a human can triage why a row failed.
  • COMPUTE per-run DQ metrics (rows in, rows passed, rows quarantined, % pass per rule) and FIRE a threshold alert (raise/log an alert row) when the quarantine rate for any rule exceeds a declared budget (e.g. > 5%).
  • PROVE the DQ step is idempotent by running it 10× on the same input and showing identical clean and rejected counts (MERGE/overwrite-partition keyed on run, not blind append).
Concept lessons · ~3h
  • Expectations as a data contract (0.75h): the difference between a check and a contract — a named rule + severity (warn vs. fail) + owner + a business reason. Map the four rule classes (schema/type conformance, null/completeness, uniqueness/grain, domain/accepted-code) onto the project's NPI, CPT/HCPCS code, and gross/negotiated price columns.
  • Quarantine architecture & the flag-then-split pattern (1.0h): why you TAG rows before you split (so one row can fail multiple rules and you keep the audit), the anatomy of a `silver_rejected` table (natural key + full offending payload + reason array + rule id + rejected_at + source_file + run_id), and why quarantine beats dropping or halting — 'silent is worse than loud'.
  • Severity, thresholds & alerting (0.75h): warn-vs-fail severities, per-rule quarantine budgets, and computing a run-level DQ summary; turning a breached threshold into an ALERT (alert row + printed/loud failure) vs. letting the run continue. Introduces the idea of a data-freshness/completeness SLA feeding the project KPI '% facilities passing pricing-completeness'.
  • Idempotent DQ runs (0.5h): why re-running validation must not double-count rejects — keying rejected/summary writes on run_id and using overwrite-partition or MERGE; how this connects to W27's understanding of Spark actions and shuffle so metrics are computed once, not recomputed inconsistently.
Hands-on lab · ~8h

You extend the project's SILVER layer with an enforced data-quality gate. Starting from your conformed silver provider+pricing table (from earlier weeks) plus a small deck of intentionally bad rows you inject, you declare 8+ named expectations, implement a flag-then-split validator in PySpark, land failures in a Delta `silver_rejected` table with reasons and timestamps, compute a per-run DQ summary, fire a threshold alert when any rule's quarantine rate exceeds its budget, and prove the whole step is idempotent across 10 reruns. The clean stream continues down the pipeline; nothing is silently dropped.

Step by step
  1. Assemble your test input: take your existing silver provider+pricing DataFrame and UNION a small hand-crafted 'bad-row deck' (10–20 rows) that violates each rule class on purpose — a null/blank NPI, a 9-digit NPI, a duplicate (npi, cpt_code) grain collision, a negotiated_price of -1 and one that is a string like 'N/A', a cpt_code not in your accepted-code set, a mangled payer name. Tag these so you can later confirm every planted row was caught.
  2. Write your expectations catalog as a Python list/dict of named rules — each with rule_id, target column(s), rule_class, severity (warn|fail), a Spark boolean expression, and a one-line business reason. Aim for 8+: e.g. NPI is exactly 10 digits (schema), NPI not null (completeness), (npi, cpt_code) unique (grain), cpt_code in accepted set (domain), gross_charge/negotiated_price are numeric and >= 0 (domain), payer_name not blank (completeness).
  3. Implement the flag pass: loop the catalog and add one boolean column per rule (`dq__<rule_id>` = true when the row PASSES). Add a `dq_failed_rules` array column collecting the rule_ids that failed, and a `dq_is_valid` = size(dq_failed_rules)==0. Do NOT filter yet — every input row still present. Assert row_count unchanged.
  4. Split the stream: clean = rows where dq_is_valid; rejected = rows where NOT dq_is_valid. For rejected, build the quarantine payload — original columns + dq_failed_rules (reason array) + first offending rule_id + rejected_at = current_timestamp() + source_file + run_id. Verify clean.count() + rejected.count() == input.count().
  5. Persist rejected rows to Delta table `silver_rejected`, partitioned by (or keyed on) run_id, using overwrite-partition or MERGE on run_id so a rerun replaces that run's rejects rather than appending duplicates. Write the clean stream forward to your silver table for the run.
  6. Compute the DQ run summary: rows_in, rows_passed, rows_quarantined, and per-rule pass_rate (%). Write it to a `dq_run_summary` Delta table keyed on run_id. This % pass feeds the project KPI '% facilities passing the pricing-completeness check per run'.
  7. Implement the threshold alert: for each rule with severity=fail, if its quarantine rate exceeds its declared budget (e.g. > 5%), emit an alert — append an `dq_alerts` row (run_id, rule_id, observed_rate, budget, fired_at) AND raise/print a loud, unmissable message. Decide deliberately: does a breach halt the run or continue with alert? Write your choice in the why-note.
  8. Prove idempotency: wrap steps 3–7 in a run function taking a fixed run_id and execute it 10× on the identical input. Assert that silver_rejected count, clean count, and dq_run_summary for that run_id are identical every time (no drift, no doubling). Capture the 10-run count table as evidence.
  9. Write the WHY-note (README section): list each rule and its business reason, state your severity/threshold choices and the budget numbers, justify overwrite-partition vs MERGE for idempotency, and confirm every planted bad row landed in silver_rejected with the correct reason (recovery/triage story).
Data
Project silver provider+pricing table (your own, from prior weeks)
Mess to handle Hospital machine-readable pricing files are wildly inconsistent: negotiated prices appear as blanks, 'N/A', or negative sentinels; the same CPT/HCPCS service is coded differently; gross vs. negotiated columns are mislabeled — exactly the domain/type failures your expectations must catch.
NPPES NPI Registry API
Mess to handle Provider NPIs arrive with length/format variation and the same provider surfaces as duplicate records with name/address variants, producing null-NPI, wrong-length, and (npi, cpt) grain-collision rows that your uniqueness and schema rules quarantine.
Intentional bad-row deck (you author it)
Mess to handle A hand-crafted 10–20 row deck that violates each rule class on purpose (null NPI, 9-digit NPI, duplicate grain, price=-1, price='N/A', off-list CPT, blank payer) so you can PROVE the gate catches every planted failure and reports the right reason.
Reference material
</> Starter scaffold — silver_dq_gate.py python
# silver_dq_gate.py — Silver Data Quality gate (Databricks Free Edition + Delta + PySpark)
# Run per CMS republish. Flag -> split -> quarantine -> summarize -> alert. Idempotent by run_id.

from pyspark.sql import functions as F

# ---- 0. Run context ---------------------------------------------------------
# run_id makes writes idempotent: a rerun REPLACES this run's rejects/summary,
# it does not append. TODO: derive run_id from the CMS file version / ingest date.
RUN_ID = "TODO_run_id"            # e.g. '2026-09-cms-v1'
SOURCE_FILE = "TODO_source_path"

# silver_conformed = your typed/conformed silver DF from prior weeks (+ your bad-row deck UNIONed in)
silver_conformed = spark.read.table("silver_provider_pricing_conformed")  # TODO: your table
rows_in = silver_conformed.count()

# ---- 1. Expectations catalog ------------------------------------------------
# Each rule: id, class, severity ('warn'|'fail'), a PASS expression (True = good),
# a per-rule quarantine BUDGET (fraction), and a one-line business reason.
# TODO: write 8+ SPECIFIC rules. Two examples are stubbed — replace/extend them.
EXPECTATIONS = [
    {"id": "npi_len10", "cls": "schema", "sev": "fail", "budget": 0.02,
     "expr": F.col("npi").rlike("^[0-9]{10}$"),
     "why": "A non-10-digit NPI cannot join to NPPES -> provider KPI is wrong."},
    {"id": "price_nonneg_numeric", "cls": "domain", "sev": "fail", "budget": 0.05,
     "expr": (F.col("negotiated_price").cast("double").isNotNull()) & (F.col("negotiated_price").cast("double") >= 0),
     "why": "Negative / non-numeric prices corrupt the compliance cost report."},
    # TODO: npi_not_null (completeness), (npi,cpt) uniqueness (grain — needs a window/groupBy),
    #       cpt_in_accepted_set (domain), payer_name_not_blank (completeness), gross_charge rules...
]
ACCEPTED_CPT = ["TODO", "fill", "your", "accepted", "code", "set"]  # TODO domain list

# ---- 2. Flag pass (no filtering — every input row kept) ----------------------
df = silver_conformed
for rule in EXPECTATIONS:
    df = df.withColumn(f"dq__{rule['id']}", rule["expr"])   # True = passed

# Collect failed rule ids into an array + validity flag.
# TODO: build dq_failed_rules as an array of the ids where dq__<id> is False.
#   hint: F.array(*[F.when(~F.col(f"dq__{r['id']}"), F.lit(r['id'])) for r in EXPECTATIONS])
#          then F.array_except(..., F.array(F.lit(None))) to drop nulls.
df = df.withColumn("dq_failed_rules", F.array())            # TODO replace
df = df.withColumn("dq_is_valid", F.size("dq_failed_rules") == 0)
assert df.count() == rows_in, "flag pass must not drop rows"

# ---- 3. Split ---------------------------------------------------------------
clean = df.filter("dq_is_valid")
rejected = df.filter("NOT dq_is_valid")
assert clean.count() + rejected.count() == rows_in, "split must conserve rows"

# ---- 4. Quarantine payload + write (idempotent on run_id) -------------------
rejected_out = (rejected
    .withColumn("dq_failure_reason", F.col("dq_failed_rules"))
    .withColumn("rejected_at", F.current_timestamp())
    .withColumn("source_file", F.lit(SOURCE_FILE))
    .withColumn("run_id", F.lit(RUN_ID)))
# TODO: write to Delta silver_rejected so a RERUN of this run_id REPLACES, not appends.
#   option A: rejected_out.write.format('delta').mode('overwrite')
#             .option('replaceWhere', f"run_id = '{RUN_ID}'").saveAsTable('silver_rejected')
#   option B: MERGE on a natural key + run_id. Pick one; justify in the why-note.

# TODO: write `clean` forward to your silver serving table for this run.

# ---- 5. DQ run summary (feeds KPI: % passing pricing-completeness) -----------
# TODO: build one summary row per rule: run_id, rule_id, rows_in, rows_failed, pass_rate.
#       write to Delta dq_run_summary keyed on (run_id, rule_id), idempotent.

# ---- 6. Threshold alert -----------------------------------------------------
# TODO: for each rule where sev=='fail', compute quarantine_rate = rows_failed/rows_in.
#       if quarantine_rate > rule['budget']: append a dq_alerts row AND print a LOUD message.
#       Decide: does a breach halt the run (raise) or continue with alert logged? Write it down.
Note STARTER SCAFFOLD ONLY — sets up the expectations catalog shape, the flag-then-split skeleton, and the quarantine/summary/alert write points. TODOs are where YOU write the actual rules, thresholds, idempotency key, and alert behavior. Do not fill these in from a tutorial — your rule choices are your interview answers.
Go deeper
From Gambill Data — videos & templates
Courses & books
Adapt to your industry Mirror the exact flag-then-split-quarantine pattern on YOUR target industry's silver table. Fintech (SEC EDGAR): quarantine filings where CIK is malformed, a reported value is non-numeric, or a (CIK, period, tag) grain collides. Crypto (CoinGecko): reject rows where price is null/negative, a coin_id is off your accepted list, or timestamps are out of the expected window. Humanitarian (ReliefWeb) or weather (NOAA): reject out-of-domain country/station codes and impossible measurements. Fraud/locked-down data: use Faker to generate a bad-row deck. Keep the same four rule classes (schema, completeness, uniqueness/grain, domain), the same `<domain>_rejected` table with reason+timestamp, and the same threshold alert — only the columns and accepted-code sets change.
Deliverable, acceptance & live session
Deliverable & acceptance
A committed silver DQ gate on the project pipeline: (1) an expectations catalog of 8+ named rules across all four classes, each with a one-line business reason and a declared severity + quarantine budget; (2) a working flag-then-split validator that conserves rows (in == clean + rejected); (3) a Delta `silver_rejected` table carrying reason array, offending rule, rejected_at, source_file, run_id; (4) a `dq_run_summary` table computing per-rule pass-rate and the KPI %; (5) a threshold alert (alert row + loud message) that fires when a fail-severity rule exceeds its budget; (6) a 10-run idempotency proof table showing identical counts; and (7) a README why-note justifying every rule, threshold, and the overwrite-vs-MERGE idempotency choice, and confirming every planted bad row was caught with the correct reason.
Pass when
  • PASS requires: row conservation asserted (input count == clean + rejected, no silent drops)
  • Every planted bad row from the deck appears in `silver_rejected` with the correct rule_id and a non-null rejected_at
  • `silver_rejected` and `dq_run_summary` for a given run_id are byte-identical in count across 10 reruns (idempotency proven, not claimed)
  • At least 8 rules spanning all four classes, each with a distinct business reason (no duplicated boilerplate)
  • A threshold breach demonstrably fires an alert row AND a loud message
  • The why-note names each rule's reason, states the budget numbers, and justifies the idempotency write strategy. Any rule justified only as 'should not be null' without a business/KPI/compliance reason is a FAIL.
Live session focus

Recap Each learner posts their expectations catalog (rules + business reasons) and their 10-run idempotency count table before the session. Recap: how many rules, spread across the four classes, and one screenshot of a `silver_rejected` row showing its reason. We contrast two learners' NPI/price rules to surface which choices are defensible.

Misconception 'Data quality means dropping the bad rows (or a final SELECT COUNT of NULLs at the end).' We correct this live: dropping is silent data loss you can't audit or recover, and an end-of-pipeline count neither stops bad data nor tells anyone why a row failed. The contract is tag-every-row, quarantine-with-reason, alert-past-a-threshold — loud beats silent, and the reject table is a recoverable, triageable asset, not a trash can.

Hot seat "Defend your rules: 'Walk me through one rule you wrote, the exact business or compliance consequence if it were missing, and why you set its quarantine budget at that number — not lower, not higher.' Then: 'You chose overwrite-partition (or MERGE) for the reject table. Prove it's idempotent and explain what would double-count if you'd blind-appended. And when your threshold breaches — does the run halt or continue? Justify that to a compliance officer.'"

Portfolio Adds to Project 1's repo a `silver_dq_gate` notebook/script, the `silver_rejected` + `dq_run_summary` + `dq_alerts` Delta tables, the expectations-catalog README section with business reasons, the 10-run idempotency evidence table, and a screenshot of a fired alert — all recognition-ready: a hiring manager skimming the README sees named rules tied to compliance dollars, a quarantine table with reasons, and proof of idempotency, i.e. a human who builds for failure.
Prereqs & carry-forward W27 Spark internals (actions vs. transformations, shuffle, why counts/aggregations trigger jobs — needed to reason about computing DQ metrics once, not inconsistently); a working conformed SILVER provider+pricing Delta table from earlier Phase 2 weeks; Delta MERGE / replaceWhere basics; comfort with PySpark when/otherwise, array functions, and current_timestamp; Databricks Free Edition workspace with the project catalog/schema in place.
W29 Data Quality II — Reconciliation & Audit: Proving the Pipeline Didn't Lose or Invent RowsMaria's finance director asked a simple question about last month's cost report — 'are we sure no hospitals fell out of this number?' — and nobody could answer, because the pipeline that produced it kept no receipts. A run that quietly drops 3,000 price rows on a bad CMS republish and still returns a plausible KPI is worse than one that crashes: the wrong compliance number gets filed. This week builds the ledger that lets Maria prove every dollar and every provider is accounted for on every run. M24 · Phase 2 · silver G4G1 ~10.0h
Why this matters Maria's finance director asked a simple question about last month's cost report — 'are we sure no hospitals fell out of this number?' — and nobody could answer, because the pipeline that produced it kept no receipts. A run that quietly drops 3,000 price rows on a bad CMS republish and still returns a plausible KPI is worse than one that crashes: the wrong compliance number gets filed. This week builds the ledger that lets Maria prove every dollar and every provider is accounted for on every run.
Project 1 · this week Advances Project 1 from 'the pipeline cleans data' to 'the pipeline proves what it did.' It adds the audit_log Delta table as a permanent pipeline component and produces two artifacts for the living docs: a reconciliation note (control totals, tolerances, where rows legitimately disappear) that becomes a section of the 4-part Architecture Doc's FAILURE PLAN, and run-level evidence that feeds the KPI panel (records quarantined with reason, % facilities passing, data-freshness/watermark) Maria will read. It directly hardens the idempotency plan by making idempotency observable — you can now prove 10 identical reruns, not just claim it.
Objectives
  • Given a single pipeline run, write a reconciliation query that proves the completeness identity bronze_in = silver_passed + quarantined + dedup_collapsed to the exact row (variance = 0), and explain in writing where each 'lost' row went.
  • Reconcile a numeric control total (sum of standard_charge and distinct provider count) across bronze, silver, and gold, and flag any layer where variance exceeds a declared tolerance (0 for counts, a stated % for money after currency cleaning).
  • Design and populate an idempotent audit_log Delta table with one row per (run_id, layer) capturing rows_in, rows_out, rows_rejected, watermark_value, duration_seconds, status, and run_ts; re-running a run_id MERGEs, never duplicates.
  • Implement a reconciliation CHECK that sets run status to FAIL and raises a loud alert when the identity doesn't balance or a control total drifts beyond tolerance — silent drift becomes a failed run.
  • Query the audit_log across the last N runs to produce a run-over-run trend of pass rate, reject count, and freshness, demonstrating the log answers 'what happened on any past run' without re-reading the data.
Concept lessons · ~3.5h
  • Reconciliation as a control, not a vibe (55 min): the completeness identity (in = out + rejected + collapsed), why 'roughly the same count' is a failed audit, and the difference between count reconciliation (tolerance must be 0) and metric/control-total reconciliation (tolerance is a stated business number after cleaning). Where rows legitimately disappear: quarantine, dedup collapse, inner-join drops — each must be named and counted, never unexplained.
  • Anatomy of an audit log / run ledger (50 min): one row per run per layer; the columns that make it useful (run_id, layer, source, rows_in, rows_out, rows_rejected, watermark_value, duration_seconds, status, error_msg, run_ts); grain and primary key of the log itself; why the log is append-with-merge (idempotent on run_id+layer) and lives beside the data as a first-class Delta table, not a print statement.
  • Control totals & watermarks as reconciliation anchors (45 min): choosing a monetary/volume control total that must be conserved (total standard_charge, distinct NPI count), reconciling it layer to layer, and tying the run to its incremental watermark (max ingest_ts / file version) so freshness and 'what window did this run cover' are auditable — reuses the incremental watermark from earlier weeks, now recorded rather than just used.
  • Turning imbalance into a loud failure (40 min): computing variance, comparing to tolerance, setting status=FAIL, and raising (assert/raise + a written alert row) so a broken accounting stops the run instead of publishing a wrong KPI. Contrast with W28's row-level quarantine: quarantine handles bad records; reconciliation handles bad totals — both loud, different scope.
Hands-on lab · ~6.5h

Instrument the full running Provider Pricing Integrity pipeline with reconciliation and a persistent audit log. For a single incremental run you will count rows in/out/rejected at each hop (bronze→silver→gold), prove the completeness identity balances to the row, reconcile a money control total and a distinct-provider control total across layers, write one audit_log row per layer capturing counts + watermark + duration + status, and force the run to FAIL loudly when the accounting doesn't balance. You are not writing a test framework (that's W30) — you are building and manually verifying the ledger those tests will read.

Step by step
  1. Set up the ledger: create an audit_log Delta table with an explicit schema (run_id STRING, layer STRING, source STRING, rows_in LONG, rows_out LONG, rows_rejected LONG, watermark_value STRING, control_total_num DECIMAL, distinct_key_count LONG, duration_seconds DOUBLE, status STRING, error_msg STRING, run_ts TIMESTAMP) and declare its primary key/grain in a comment: one row per (run_id, layer). Generate a run_id (e.g. uuid or run timestamp) once at the top of the run and thread it through every layer.
  2. Instrument bronze: for the current incremental batch, capture rows_in from the landed source files, the watermark_value (max ingest_ts or source file version this run covered), and start a timer. Write the bronze audit row via MERGE on (run_id, layer) so a rerun of the same run_id overwrites rather than appends — test by running the cell twice and confirming still one row.
  3. Instrument silver: count rows_out (records that passed W28's quality gates) and rows_rejected (records MERGEd to the quarantine/rejected table with reason + timestamp from last week). Prove the row-level identity rows_in == rows_out + rows_rejected + rows_dropped_by_dedup; compute each term with a query, and for any nonzero 'dropped' bucket, add a one-line written explanation of where those rows went. Write the silver audit row.
  4. Reconcile control totals across layers: compute SUM(standard_charge) and COUNT(DISTINCT npi) at bronze (raw, pre-clean), silver (post-clean/typed), and gold (serve). Build a small reconciliation result showing each layer's total and the variance vs the prior layer. Record the gold-layer control_total_num and distinct_key_count into the gold audit row.
  5. Declare tolerances and evaluate them: set count tolerance = 0 (any unexplained row loss fails) and a money tolerance you justify in writing (e.g. variance allowed only for rows whose charge was null/uncleanable and legitimately quarantined — quantify it). Write a reconciliation CHECK that compares each variance to its tolerance and derives a per-run status of PASS or FAIL.
  6. Make failure loud: when status == FAIL, raise an exception (or write a FAIL row + an alert record) that stops the run before gold is published, with error_msg naming which check failed and by how much. Demonstrate it: deliberately corrupt one run (e.g. drop a filter or inject 500 rows into gold) and show the run flips to FAIL and blocks publish; then revert and show PASS.
  7. Prove idempotency of the whole instrumented run: run the full pipeline for the same run_id 3–10 times and confirm (a) audit_log still holds exactly one row per (run_id, layer), (b) counts and control totals are identical every time, (c) gold row counts don't grow. Capture the before/after in a query.
  8. Query the ledger as a human would: write an audit_log summary query over the last N runs showing run_ts, rows_in, rows_out, rows_rejected, reject rate, control total, watermark, duration, and status — the run-over-run trend. Write 4–6 sentences on what run 'went wrong' would look like in this table and how you'd spot silent drift.
  9. Write the reconciliation 'why' note: which control totals you chose and why, your tolerance decisions, where rows legitimately disappear in your pipeline, and one anomaly you actually found while balancing the books (or state you found none and how you know).
Data
CMS Hospital Price Transparency (machine-readable pricing files)
Mess to handle Two republished snapshots of the same hospital rarely have identical row counts — files gain/lose service lines, standard_charge appears as '$1,234.00', '1234', blanks, and 'N/A', and some rows carry no charge at all. This is exactly what makes reconciliation non-trivial: you must decide which vanished rows are legitimate quarantine vs. silent loss, and your money control total won't balance until currency strings are cleaned and null-charge rows are explicitly accounted for.
NPPES NPI Registry (provider records — via API and/or bulk file)
Mess to handle The same provider appears under multiple NPI rows and name/address variants, so a naive COUNT(DISTINCT npi) at bronze overstates true providers, and the dedup/golden-record step (built earlier in the phase) collapses rows — meaning your reconciliation MUST carry a 'dedup_collapsed' bucket or the identity will never balance. Bulk file docs: https://download.cms.gov/nppes/NPI_Files.html
Reference material
</> Starter scaffold — audit_reconcile_scaffold.py python
# audit_reconcile_scaffold.py
# Week 29 - Reconciliation & Audit scaffold for the Provider Pricing Integrity pipeline.
# Databricks Free Edition + Delta + PySpark. This is a SCAFFOLD: it sets up the run_id,
# the audit_log table, and the reconciliation SHAPE. You fill the TODOs with your own
# counts, control totals, tolerance calls, and failure handling. Do NOT expect this to run
# a full pipeline as-is.

import uuid, time
from datetime import datetime
from pyspark.sql import functions as F

AUDIT_TABLE = "pricing.audit_log"   # TODO: point at your schema

# --- 1. One run_id for the whole run, threaded through every layer -------------
RUN_ID = datetime.utcnow().strftime("%Y%m%dT%H%M%S") + "-" + uuid.uuid4().hex[:6]
print(f"RUN_ID = {RUN_ID}")

# --- 2. Create the ledger once (idempotent DDL). Grain: one row per (run_id, layer) ---
spark.sql(f"""
CREATE TABLE IF NOT EXISTS {AUDIT_TABLE} (
  run_id STRING, layer STRING, source STRING,
  rows_in LONG, rows_out LONG, rows_rejected LONG,
  watermark_value STRING, control_total_num DECIMAL(18,2), distinct_key_count LONG,
  duration_seconds DOUBLE, status STRING, error_msg STRING, run_ts TIMESTAMP
) USING DELTA
""")

# --- 3. Idempotent audit writer: MERGE on (run_id, layer), never append blindly ---
def write_audit_row(row: dict):
    src = spark.createDataFrame([row])
    src.createOrReplaceTempView("_audit_src")
    # TODO: complete the MERGE so re-running the same (run_id, layer) UPDATES in place.
    spark.sql(f"""
    MERGE INTO {AUDIT_TABLE} t
    USING _audit_src s
    ON t.run_id = s.run_id AND t.layer = s.layer
    WHEN MATCHED THEN UPDATE SET *
    WHEN NOT MATCHED THEN INSERT *
    """)  # <- confirm this leaves exactly ONE row per (run_id, layer) after 2 runs

# --- 4. Instrument one layer. Call this pattern for bronze, silver, gold ----------
def instrument_layer(layer, source, df_in, df_out, df_rejected, watermark):
    t0 = time.time()
    rows_in       = df_in.count()
    rows_out      = df_out.count()
    rows_rejected = df_rejected.count() if df_rejected is not None else 0
    # TODO: control total(s). Clean currency BEFORE summing or bronze will never balance.
    control_total = df_out.agg(F.sum("standard_charge")).first()[0]  # TODO: cast/clean
    distinct_keys = df_out.agg(F.countDistinct("npi")).first()[0]
    write_audit_row({
        "run_id": RUN_ID, "layer": layer, "source": source,
        "rows_in": rows_in, "rows_out": rows_out, "rows_rejected": rows_rejected,
        "watermark_value": str(watermark),
        "control_total_num": control_total, "distinct_key_count": distinct_keys,
        "duration_seconds": round(time.time() - t0, 2),
        "status": "PENDING", "error_msg": None, "run_ts": datetime.utcnow(),
    })
    return rows_in, rows_out, rows_rejected

# --- 5. The completeness identity check. Counts MUST balance to 0 -----------------
def assert_completeness(rows_in, rows_out, rows_rejected, rows_dropped_by_dedup):
    # TODO: the identity. Any nonzero residual is a FAILED run, not a rounding note.
    residual = rows_in - (rows_out + rows_rejected + rows_dropped_by_dedup)
    if residual != 0:
        # TODO: write a FAIL audit row + raise loudly so gold is NOT published.
        raise ValueError(f"RECONCILE FAIL: {residual} rows unaccounted for in run {RUN_ID}")
    return True

# --- 6. Control-total tolerance. Counts tol=0; money tol is YOUR justified number ---
def check_control_total(bronze_total, silver_total, money_tolerance):
    # TODO: variance = bronze_total - silver_total; the only allowed gap is charges you
    #       quarantined for being null/uncleanable. Quantify it, compare, PASS/FAIL.
    pass

# TODO (lab): wire bronze->silver->gold through instrument_layer(), run assert_completeness()
#   and check_control_total(), flip run status, then run the whole thing 3-10x and prove
#   the audit_log holds one row per (run_id, layer) with identical numbers every time.
Note Scaffold only: it stands up run_id, the audit_log DDL, an idempotent MERGE writer, and the reconciliation function signatures. The learner must clean the currency control total, complete the completeness identity, set and justify the money tolerance, and make FAIL actually block gold. The dedup_collapsed bucket is intentionally left for them to compute from their golden-record step.
Go deeper
From Gambill Data — videos & templates
Courses & books
Adapt to your industry Mirror the reconciliation + audit_log pattern onto your own industry's running pipeline, keeping the same identity (in = out + rejected + collapsed) and one control total that must be conserved. SEC EDGAR: reconcile filing counts and a summed financial line (total revenue) across layers, watermark = latest accession date. CoinGecko: reconcile row counts and a conserved metric (total market cap or 24h volume) per pull, watermark = snapshot timestamp, and account for coins dropped as duplicates/delisted. NOAA: reconcile station-reading counts and summed precipitation, explaining rows quarantined for out-of-range sensor values. Whatever the domain, pick a money/volume total a stakeholder would notice if it silently changed, and prove it doesn't.
Deliverable, acceptance & live session
Deliverable & acceptance
A committed notebook + audit_log Delta table demonstrating, for at least one incremental run of the full bronze→silver→gold pipeline: (1) the completeness identity balancing to exactly zero with each disappearance bucket (quarantined, dedup-collapsed) counted and named; (2) a money control total and distinct-provider count reconciled across all three layers with declared tolerances; (3) one audit_log row per (run_id, layer) with counts/watermark/duration/status, proven idempotent across 3–10 reruns; (4) a demonstrated FAIL run (deliberately corrupted) that blocks gold publish, then reverts to PASS; and (5) a reconciliation 'why' note (~200–300 words) covering control-total choice, tolerance decisions, and one anomaly found (or evidence none exists).
Pass when
  • The completeness identity residual is 0 and every nonzero disappearance bucket has a written explanation
  • Control-total variances are computed at each layer and compared to a tolerance the learner justifies in writing (counts tolerance = 0)
  • The audit_log holds exactly one row per (run_id, layer) after re-running the same run_id (no duplicate ledger rows), and counts/totals are byte-identical across 3+ reruns
  • A corrupted run visibly flips status to FAIL, raises loudly, and prevents gold from being published, then a clean run returns PASS
  • The reconciliation note names a specific control total, a specific tolerance number, and a specific finding — generic 'the data looks fine' is rejected.
Live session focus

Recap Walk through your audit_log across the last few runs on screen and narrate the story it tells: which run had the highest reject count and why, where your completeness identity balanced, and the moment it didn't. Show the before/after of your deliberately-corrupted FAIL run and the exact line that stopped gold from publishing.

Misconception 'The counts are close enough / off by a handful, that's basically balanced.' For count reconciliation the only acceptable variance is zero — a residual of 7 rows isn't rounding, it's 7 rows you can't explain, and in Maria's world that could be 7 hospitals missing from a compliance filing. Tolerance belongs only to cleaned monetary/volume totals, and even then the tolerance is a specific number you justified, not a shrug. 'Close' is a failed audit.

Hot seat "Defend three decisions in the hot seat: (1) Your money control-total tolerance is X — where exactly does that gap come from, and how do you know it's not silent data loss hiding as 'cleaning'? (2) Your completeness identity has a dedup_collapsed bucket of N — prove those N rows were true duplicates and not real providers you wrongly merged. (3) A hiring manager asks 'how do you know run 47 was correct?' — answer using only your audit_log, without re-reading the source data. Expect to be pushed on why silent drift is worse than a crash."

Portfolio Adds a 'Reconciliation & Audit' section to the Project 1 README with a screenshot of the audit_log run-over-run table and the balanced completeness identity, plus the reconciliation 'why' note committed to the repo. This is prime README-skim material: a hiring manager sees a real run ledger with counts in/out/rejected and a FAIL-that-blocked-publish, and immediately recognizes 'this person builds pipelines that break loudly and can prove their numbers' — a concrete interview answer, not a claim.
Prereqs & carry-forward W28 Data Quality I completed: working silver quality gates and a quarantine/rejected Delta table (reason + timestamp) that this week counts against. An incremental watermark from earlier weeks (max ingest_ts or Delta table version) available to record. A functioning bronze→silver→gold pipeline including the duplicate-provider/golden-record collapse step (so a dedup_collapsed bucket exists to reconcile). Comfort with Delta MERGE, PySpark count/sum/countDistinct, and Spark SQL aggregations. Databricks Free Edition workspace with the running project schema.
W30 Testing taxonomy for data — pytest & the Phase 2 portfolioMaria's finance leadership will only act on the cost + compliance numbers if they trust them; a silent regression (a parser that starts dropping every "$1,234.00" price, or a dedupe that quietly stops collapsing providers) would push wrong numbers into a board deck and a CMS compliance filing. Automated tests are how you promise that next month's rerun is still correct without re-inspecting it by hand. M25 · Phase 2 · silver · Phase 2 portfolio G4G6G1 ~10.5h
Why this matters Maria's finance leadership will only act on the cost + compliance numbers if they trust them; a silent regression (a parser that starts dropping every "$1,234.00" price, or a dedupe that quietly stops collapsing providers) would push wrong numbers into a board deck and a CMS compliance filing. Automated tests are how you promise that next month's rerun is still correct without re-inspecting it by hand.
Project 1 · this week This IS the Phase 2 closer for portfolio Project 1. It wraps the full medallion pipeline (bronze→silver→golden MDM→gold KPIs) in an automated four-tier pytest suite that encodes every guarantee the earlier weeks built by hand, then ASSEMBLES the deliverable: the project charter (started W14) and the 4-part architecture doc (written W20) are finalized and locked, cross-linked to the test suite and golden-records table, and the README is shipped with the four KPIs reported as real numbers. Adds two artifacts: the test suite (tests/ + conftest.py + transforms.py refactor) and the assembled, recognition-ready portfolio README.
Objectives
  • Classify every existing check in the Project 1 pipeline into the four-tier data-testing taxonomy (unit / integration / smoke / e2e) and defend each tier choice in one sentence
  • Write and pass at least 8 pytest tests: ≥3 unit tests on pure transform functions, ≥2 integration tests on bronze→silver quarantine + SCD2, 1 smoke test on schema/PK contract, and ≥2 e2e tests asserting KPIs
  • Prove idempotency with an automated test that runs the full pipeline twice on a fixture and asserts identical row counts AND identical golden-record counts (not a prose claim)
  • Report all four Project 1 KPIs as actual numbers from a real full-data run, and assemble a README that passes a 30-second recognition test
  • Finalize and cross-link the living deliverables (project charter + 4-part architecture doc) to the test suite and the golden-records table, completing portfolio Project 1
Concept lessons · ~3.5h
  • The testing pyramid for DATA pipelines (~1.0h): why data tests differ from app tests (the input mutates underneath you), the four tiers and what each buys you, and the data-specific test families layered on top — schema/contract tests, PK-uniqueness, referential integrity, quarantine-reason coverage, and freshness. Map each tier to a piece of your existing pipeline.
  • pytest mechanics on Databricks Free Edition (~1.0h): the anatomy of a test (arrange/act/assert), a session-scoped SparkSession fixture in conftest.py, @pytest.fixture for tiny deterministic data, @pytest.mark.parametrize for table-driven cases, tmp_path for throwaway Delta tables, chispa for DataFrame equality, and running pytest.main() inside a notebook vs. a .py module.
  • Testing for failure & idempotency (~0.75h): writing NEGATIVE tests (feed a bad row, assert it lands in the rejected table with reason+timestamp), asserting SCD2 close/open on an updated record, and the run-twice idempotency assertion pattern — the same discipline you eyeballed in earlier weeks, now encoded so it fails loudly in CI.
  • Portfolio assembly & the recognition test (~0.75h): README structure that front-loads business framing, reporting KPIs as real numbers with a small results table, the ‘prove a human did the work’ why-notes, and cross-linking the charter + 4-part architecture doc + golden records so the whole thing reads as one owned data product.
Hands-on lab · ~7h

Retrofit a real pytest suite onto the complete Provider Pricing Integrity pipeline, organized by the four-tier taxonomy, then assemble the finished Phase 2 portfolio. You refactor silver transforms into importable pure functions, freeze a tiny deterministic fixture that carries the CMS/NPPES mess (null price, "$"-string price, late record, duplicate provider), write unit/integration/smoke/e2e tests that encode your quarantine, SCD2, contract, and idempotency guarantees, run the full pipeline on real data to capture the four KPIs as numbers, and ship a recognition-ready README plus locked charter and architecture doc.

Step by step
  1. Freeze fixtures: extract a ~50-row deterministic sample from your bronze CMS price + NPPES data into tests/fixtures/ (parquet or hardcoded rows) so tests never hit the network. It MUST contain at least one null price, one '$1,234.00'-formatted string price, one late-arriving record (older ingest date than its key already has), and one duplicate provider spelled/addressed two ways for the MDM test.
  2. Refactor for testability: pull your silver logic (price parsing, NPI normalization, address standardization, quarantine-reason tagging) out of notebook cells into pure importable functions in a transforms.py module — functions that take/return values or small DataFrames with no hidden I/O.
  3. Write ≥3 UNIT tests (@pytest.mark.parametrize): '$1,234.00' → 1234.00, ''/null price → quarantine reason 'MISSING_PRICE', and NPI ' 1234567890 ' → '1234567890'. These run in milliseconds with no Spark table.
  4. Write ≥2 INTEGRATION tests on the fixture (bronze→silver): assert bad rows land in the rejected table with reason + timestamp (and are absent from silver), and assert that re-ingesting an updated provider closes the prior SCD2 row (is_current=false, valid_to set) and opens exactly one new current row.
  5. Write 1 SMOKE test: run the pipeline end-to-end on the fixture and assert the silver/gold schema matches your written layer contract, the golden_provider_id PK is unique and non-null, and every output layer is non-empty — the fast 'is it alive and shaped right' check.
  6. Write ≥2 E2E tests: run ingest→bronze→silver→golden→gold on the fixture, assert the 4 KPIs are computed and sane; then run the ENTIRE pipeline a SECOND time and assert identical row counts and identical golden-record count — idempotency proven, not claimed.
  7. Run `pytest -v`, capture the green output, then execute one real full-data pipeline run and record the four KPIs as actual numbers: % facilities passing the completeness check, # duplicate providers collapsed into golden records, # records quarantined broken out by reason, and the data-freshness SLA figure.
  8. Assemble the portfolio: finalize README (business framing → architecture diagram → KPI results table → how-to-run → test suite summary), lock the project charter and the 4-part architecture doc, cross-link both to the test suite and golden-records table, and write the short 'why we test at each tier + why these idempotency choices' note that becomes your interview answers.
Data
CMS Hospital Price Transparency (machine-readable pricing files)
Mess to handle Hospitals self-publish in wildly varying formats/quality: missing prices, prices stored as '$1,234.00' strings, inconsistent code columns, and files that change shape month to month — exactly the cases your unit + quarantine tests must pin down.
NPPES NPI Registry API
Mess to handle The same provider appears as duplicate records with name/address variations and casing/whitespace differences — the fixture's two-spelling duplicate that your MDM golden-record test asserts collapses to one golden_provider_id.
NPPES bulk data files (full monthly dump)
Mess to handle Millions of rows republished monthly, so records arrive late and providers update — the source of the SCD2 close/open and idempotency behavior your integration + e2e tests encode; sample it down to build the deterministic fixture.
Reference material
</> Starter scaffold — test_pipeline.py python
# test_pipeline.py — four-tier test suite for Provider Pricing Integrity (Project 1)
# Assumes a conftest.py with:
#   @pytest.fixture(scope="session")
#   def spark():  # local[2] + Delta configs on your machine; the notebook `spark` on Databricks
#       ...
# and importable code: from transforms import parse_price, normalize_npi, tag_quarantine
# plus your pipeline entrypoints: run_bronze_to_silver(spark, src), run_full_pipeline(spark, src)

import pytest

# ----------------------------------------------------------------------
# TIER 1 — UNIT: pure functions, no Spark table, run in milliseconds
# ----------------------------------------------------------------------
@pytest.mark.parametrize("raw,expected", [
    ("$1,234.00", 1234.00),
    ("1234",      1234.00),
    # TODO: add a null/'' case and assert it is flagged (not silently 0.0)
])
def test_parse_price(raw, expected):
    from transforms import parse_price
    assert parse_price(raw) == expected

def test_normalize_npi_strips_and_cases():
    from transforms import normalize_npi
    # TODO: assert '  1234567890 ' -> '1234567890' (whitespace/dtype normalized)
    ...

# ----------------------------------------------------------------------
# TIER 2 — INTEGRATION: bronze->silver on the frozen fixture (real Spark)
# ----------------------------------------------------------------------
def test_bad_row_is_quarantined_with_reason(spark):
    src = "tests/fixtures/bronze_sample"          # the ~50-row deterministic mess
    silver, rejected = None, None                 # TODO: run_bronze_to_silver(spark, src)
    # TODO: assert the null-price row is ABSENT from silver
    # TODO: assert it is PRESENT in rejected with reason=='MISSING_PRICE' and a non-null quarantine_ts
    ...

def test_scd2_closes_old_and_opens_new(spark):
    # TODO: ingest a provider, then ingest an updated version of the same NPI
    # TODO: assert exactly one row has is_current=true, and the prior row has valid_to set
    ...

# ----------------------------------------------------------------------
# TIER 3 — SMOKE: does it run, and is the OUTPUT CONTRACT intact?
# ----------------------------------------------------------------------
def test_gold_contract_and_pk(spark):
    gold = None                                   # TODO: run_full_pipeline(spark, "tests/fixtures/bronze_sample").gold
    # TODO: assert set(gold.columns) == EXPECTED_GOLD_COLUMNS (your written layer contract)
    # TODO: assert gold PK golden_provider_id is unique and non-null; assert gold is non-empty
    ...

# ----------------------------------------------------------------------
# TIER 4 — E2E: KPIs compute + IDEMPOTENCY (run twice, identical)
# ----------------------------------------------------------------------
def test_pipeline_is_idempotent(spark):
    src = "tests/fixtures/bronze_sample"
    first  = None                                 # TODO: run_full_pipeline(spark, src); capture counts
    second = None                                 # TODO: run_full_pipeline(spark, src) AGAIN (same src)
    # TODO: assert first.silver_count == second.silver_count
    # TODO: assert first.golden_record_count == second.golden_record_count  # dedupe didn't drift
    ...

def test_kpis_are_computed_and_sane(spark):
    kpis = None                                   # TODO: run_full_pipeline(spark, "tests/fixtures/bronze_sample").kpis
    # TODO: assert 0 <= kpis['pct_completeness_pass'] <= 100
    # TODO: assert kpis['duplicates_collapsed'] >= 1  # the fixture's two-spelling provider must merge
    ...
Note STARTER SCAFFOLD, not a solution. Pair it with a conftest.py holding the session-scoped `spark` fixture (see TODO at top). Every tier has one worked skeleton and TODO markers where you write the real assertions against YOUR transforms.py and pipeline functions. Run with `pytest -v` locally, or `pytest.main(['-v'])` in a Databricks notebook cell after `%pip install pytest chispa`.
Go deeper
From Gambill Data — videos & templates
Courses & books
Adapt to your industry Mirror the taxonomy onto your own domain's pipeline, not healthcare. SEC EDGAR: unit-test the CIK/ticker normalizer, quarantine filings with missing financial fields, e2e-test that the same issuer under two name spellings collapses to one golden entity and that a refiled 10-K reruns idempotently. CoinGecko: unit-test price/decimal parsing, smoke-test the market-cap gold contract, e2e-test idempotency across a re-pulled day. ReliefWeb/NOAA/data.gov: same four tiers over your late-arriving, schema-drifting source. Whatever your target industry, your README's business framing must name money/risk/compliance in THAT domain so its hiring managers pass the 30-second recognition test.
Deliverable, acceptance & live session
Deliverable & acceptance
A single portfolio repository/workspace for Project 1 containing: (1) a passing four-tier pytest suite (≥8 tests: ≥3 unit, ≥2 integration, 1 smoke, ≥2 e2e including a run-twice idempotency test) with a frozen tests/fixtures/ carrying the real CMS/NPPES mess; (2) the refactored transforms.py of pure, tested functions; (3) the finalized project charter and locked 4-part architecture doc; (4) the golden-records table; and (5) a README that opens with business framing, shows the architecture, and reports all four KPIs as actual numbers from a real full-data run — plus a short why-notes section covering tier choices and idempotency strategy.
Pass when
  • `pytest -v` runs green with ≥8 tests spanning all four tiers
  • Deleting the price-parser body (or disabling the quarantine route) makes at least one test fail LOUDLY (proving the tests bite). The idempotency test runs the full pipeline twice on the fixture and asserts identical silver row counts and identical golden-record counts. The README states all four KPIs as concrete numbers (% completeness pass, # duplicates collapsed, # quarantined by reason, freshness SLA), a reader with no context can run the pipeline from the README alone, and the charter + architecture doc are cross-linked to the test suite and golden-records table. Every test tier is justified in one sentence in the why-notes.
Live session focus

Recap Open by screen-sharing your green `pytest -v` output and reading your KPI results table out loud in plain business terms: 'X% of facilities pass the pricing-completeness check, we collapsed N duplicate providers into golden records, we quarantined M records — here's the reason breakdown — and our freshness SLA is T.' Then walk one test from each tier and say what real regression it would catch.

Misconception 'More assert statements in a notebook = tested.' The four tiers are not decoration — they trade off speed vs. coverage vs. confidence, and a test only counts if it can FAIL: a green suite that still passes after you break the parser is testing nothing. Likewise, 'idempotent' and 'quarantines bad rows' are properties you must encode as tests that run twice / feed a bad row, not sentences in a README.

Hot seat "Defend your test design: (1) Point to one test in each tier and justify why it lives at that tier and not another. (2) Which single test would you keep if you could keep only one, and why? (3) Show the assertion that proves idempotency — and explain the mechanism (MERGE on key / partition replace / overwrite) it protects and why you chose it here. (4) Name one real regression each of your integration tests would catch that a unit test could not. Expect the reviewer to break your parser live and demand that a test go red."

Portfolio The finished, recognition-ready portfolio Project 1 — 'Provider Pricing Integrity': a governed medallion data product (messy CMS price + NPPES ingest → bronze → silver with quarantine + SCD2 → golden-record MDM → gold KPI marts) backed by an automated four-tier test suite, a project charter, a 4-part architecture doc, and a README reporting live KPIs. This is the flagship artifact a learner links at the top of their resume and walks an interviewer through end to end.
Prereqs & carry-forward Completed W14–W29 of Phase 2: a working bronze→silver→golden→gold pipeline on Databricks Free Edition with quarantine, SCD2 history, MDM golden records, computed KPIs, and the W29 reconciliation; a draft project charter (from W14) and 4-part architecture doc (from W20); comfort with PySpark/Spark SQL, Delta MERGE, and basic Python functions. No Unity Catalog, DLT/Lakeflow, or CI/CD required — those arrive in Phase 3.