databricks360 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,67 @@
1
+ """Lakehouse Academy — install Databricks course lab environments.
2
+
3
+ Run inside a Databricks notebook:
4
+
5
+ %pip install databricks360
6
+ dbutils.library.restartPython()
7
+
8
+ import databricks360 as academy
9
+ academy.list_courses()
10
+ academy.install('genie-agents')
11
+
12
+ `install` writes the lab notebooks into your workspace. You then run them in
13
+ order. Nothing is executed for you: generating the data is real work on your
14
+ warehouse, and watching it happen is part of the lesson.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from ._catalog import Course, available_courses, get_course
20
+ from ._install import Installation, build_notebook_source, install
21
+
22
+ __version__ = "0.1.0"
23
+ __all__ = [
24
+ "install",
25
+ "list_courses",
26
+ "get_course",
27
+ "available_courses",
28
+ "build_notebook_source",
29
+ "Course",
30
+ "Installation",
31
+ "__version__",
32
+ ]
33
+
34
+
35
+ def list_courses() -> None:
36
+ """Print the available course labs."""
37
+ courses = available_courses()
38
+ if not courses:
39
+ print("No courses bundled in this build.")
40
+ return
41
+
42
+ for course in courses:
43
+ print(f"\n{course.id}")
44
+ print(f" {course.title}")
45
+ if course.description:
46
+ for line in _wrap(course.description, 76):
47
+ print(f" {line}")
48
+ print(f" catalog: {course.default_catalog} notebooks: {len(course.notebooks)}")
49
+ if course.tiers:
50
+ print(" tiers:")
51
+ for name, tier in course.tiers.items():
52
+ marker = " (default)" if name == course.default_tier else ""
53
+ print(f" {name}{marker} — {tier.description}")
54
+ print(f"\nInstall with: academy.install('{courses[0].id}')\n")
55
+
56
+
57
+ def _wrap(text: str, width: int) -> list[str]:
58
+ words, lines, current = text.split(), [], ""
59
+ for word in words:
60
+ if len(current) + len(word) + 1 > width:
61
+ lines.append(current)
62
+ current = word
63
+ else:
64
+ current = f"{current} {word}".strip()
65
+ if current:
66
+ lines.append(current)
67
+ return lines
@@ -0,0 +1,99 @@
1
+ """Course discovery: reads the manifests bundled with the package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from importlib import resources
8
+ from typing import Any
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Notebook:
13
+ """One notebook in a course's lab, in run order."""
14
+
15
+ order: int
16
+ name: str
17
+ sql: str
18
+ title: str
19
+ intro: str = ""
20
+ requires_admin: bool = False
21
+ slow: bool = False
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Tier:
26
+ name: str
27
+ values: dict
28
+ description: str = ""
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Course:
33
+ id: str
34
+ title: str
35
+ description: str
36
+ default_catalog: str
37
+ notebooks: list = field(default_factory=list)
38
+ tiers: dict = field(default_factory=dict)
39
+ default_tier: str = "small"
40
+
41
+ @property
42
+ def package(self) -> str:
43
+ return f"databricks360.courses.{self.id.replace('-', '_')}"
44
+
45
+
46
+ def _course_packages() -> list:
47
+ root = resources.files("databricks360.courses")
48
+ return sorted(
49
+ p.name
50
+ for p in root.iterdir()
51
+ if p.is_dir() and not p.name.startswith("_") and (p / "manifest.json").is_file()
52
+ )
53
+
54
+
55
+ def _load(pkg_name: str) -> Course:
56
+ raw: dict = json.loads(
57
+ (resources.files(f"databricks360.courses.{pkg_name}") / "manifest.json")
58
+ .read_text(encoding="utf-8")
59
+ )
60
+ return Course(
61
+ id=raw["id"],
62
+ title=raw["title"],
63
+ description=raw.get("description", ""),
64
+ default_catalog=raw.get("default_catalog", "main"),
65
+ default_tier=raw.get("default_tier", "small"),
66
+ notebooks=[
67
+ Notebook(
68
+ order=n["order"],
69
+ name=n["name"],
70
+ sql=n["sql"],
71
+ title=n["title"],
72
+ intro=n.get("intro", ""),
73
+ requires_admin=n.get("requires_admin", False),
74
+ slow=n.get("slow", False),
75
+ )
76
+ for n in sorted(raw["notebooks"], key=lambda n: n["order"])
77
+ ],
78
+ tiers={
79
+ name: Tier(name=name, values=t["values"], description=t.get("description", ""))
80
+ for name, t in raw.get("tiers", {}).items()
81
+ },
82
+ )
83
+
84
+
85
+ def available_courses() -> list:
86
+ return [_load(p) for p in _course_packages()]
87
+
88
+
89
+ def get_course(course_id: str) -> Course:
90
+ wanted = course_id.replace("-", "_")
91
+ for pkg in _course_packages():
92
+ if pkg == wanted:
93
+ return _load(pkg)
94
+ known = ", ".join(c.id for c in available_courses()) or "none"
95
+ raise ValueError(f"Unknown course {course_id!r}. Available: {known}")
96
+
97
+
98
+ def read_sql(course: Course, filename: str) -> str:
99
+ return (resources.files(course.package) / filename).read_text(encoding="utf-8")
@@ -0,0 +1,162 @@
1
+ """Materialise a course's lab notebooks into a Databricks workspace.
2
+
3
+ Designed to run from inside a Databricks notebook, where databricks-sdk picks up
4
+ the notebook's own identity — no host, token or profile to configure. It also
5
+ works from a laptop if a Databricks CLI profile is present.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ from dataclasses import dataclass
12
+
13
+ from ._catalog import Course, Notebook, read_sql
14
+ from ._notebook import render_template, sql_to_notebook, unresolved_placeholders
15
+
16
+
17
+ @dataclass
18
+ class InstalledNotebook:
19
+ order: int
20
+ name: str
21
+ path: str
22
+ slow: bool
23
+ requires_admin: bool
24
+
25
+
26
+ @dataclass
27
+ class Installation:
28
+ course_id: str
29
+ folder: str
30
+ catalog: str
31
+ tier: str
32
+ notebooks: list[InstalledNotebook]
33
+
34
+ def __repr__(self) -> str: # what a notebook cell shows
35
+ lines = [
36
+ f"Installed '{self.course_id}' → {self.folder}",
37
+ f" catalog: {self.catalog} tier: {self.tier}",
38
+ "",
39
+ " Run these in order:",
40
+ ]
41
+ for nb in self.notebooks:
42
+ flags = []
43
+ if nb.slow:
44
+ flags.append("slow")
45
+ if nb.requires_admin:
46
+ flags.append("needs admin")
47
+ suffix = f" ({', '.join(flags)})" if flags else ""
48
+ lines.append(f" {nb.order}. {nb.name}{suffix}")
49
+ return "\n".join(lines)
50
+
51
+
52
+ def _workspace_client():
53
+ try:
54
+ from databricks.sdk import WorkspaceClient
55
+ except ImportError as exc: # pragma: no cover
56
+ raise ImportError(
57
+ "databricks-sdk is required. Inside a Databricks notebook run:\n"
58
+ " %pip install databricks360\n"
59
+ " dbutils.library.restartPython()"
60
+ ) from exc
61
+ return WorkspaceClient()
62
+
63
+
64
+ def _default_folder(client, course: Course) -> str:
65
+ """Default to the caller's home folder, matching how dbdemos behaves."""
66
+ try:
67
+ user = client.current_user.me().user_name
68
+ except Exception: # pragma: no cover - offline / unauthenticated
69
+ user = None
70
+ base = f"/Workspace/Users/{user}" if user else "/Workspace/Shared"
71
+ return f"{base}/databricks360/{course.id}"
72
+
73
+
74
+ def build_notebook_source(
75
+ course: Course,
76
+ notebook: Notebook,
77
+ *,
78
+ catalog: str,
79
+ tier: str,
80
+ ) -> str:
81
+ """Render one notebook's source. Pure — no workspace calls, so it is testable."""
82
+ if tier not in course.tiers and course.tiers:
83
+ known = ", ".join(sorted(course.tiers))
84
+ raise ValueError(f"Unknown tier {tier!r} for {course.id}. Available: {known}")
85
+
86
+ values = {"CATALOG": catalog, "TIER": tier}
87
+ if course.tiers:
88
+ values.update(course.tiers[tier].values)
89
+
90
+ sql = render_template(read_sql(course, notebook.sql), values)
91
+ intro = render_template(notebook.intro, values)
92
+ source = sql_to_notebook(sql, title=notebook.title, intro=intro or None)
93
+
94
+ leftover = unresolved_placeholders(source)
95
+ if leftover:
96
+ raise ValueError(
97
+ f"{notebook.sql}: unresolved placeholders {leftover}. "
98
+ "Add them to the tier values in manifest.json, or fix the typo."
99
+ )
100
+ return source
101
+
102
+
103
+ def install(
104
+ course_id: str,
105
+ *,
106
+ path: str | None = None,
107
+ catalog: str | None = None,
108
+ tier: str | None = None,
109
+ overwrite: bool = False,
110
+ dry_run: bool = False,
111
+ ) -> Installation:
112
+ """Create the lab notebooks for a course in the workspace.
113
+
114
+ Notebooks are written but never executed. Data generation is deliberate work
115
+ on the learner's warehouse, and Module 0 of the course is partly about
116
+ watching it happen rather than having it appear.
117
+ """
118
+ from ._catalog import get_course
119
+
120
+ course = get_course(course_id)
121
+ catalog = catalog or course.default_catalog
122
+ tier = tier or course.default_tier
123
+
124
+ client = None if dry_run else _workspace_client()
125
+ folder = path or (
126
+ f"/Workspace/Shared/databricks360/{course.id}"
127
+ if dry_run
128
+ else _default_folder(client, course)
129
+ )
130
+ folder = folder.rstrip("/")
131
+
132
+ installed: list[InstalledNotebook] = []
133
+
134
+ if not dry_run:
135
+ from databricks.sdk.service.workspace import ImportFormat, Language
136
+
137
+ client.workspace.mkdirs(folder)
138
+
139
+ for nb in course.notebooks:
140
+ source = build_notebook_source(course, nb, catalog=catalog, tier=tier)
141
+ target = f"{folder}/{nb.name}"
142
+
143
+ if not dry_run:
144
+ client.workspace.import_(
145
+ path=target,
146
+ content=base64.b64encode(source.encode("utf-8")).decode("ascii"),
147
+ format=ImportFormat.SOURCE,
148
+ language=Language.SQL,
149
+ overwrite=overwrite,
150
+ )
151
+
152
+ installed.append(
153
+ InstalledNotebook(
154
+ order=nb.order, name=nb.name, path=target,
155
+ slow=nb.slow, requires_admin=nb.requires_admin,
156
+ )
157
+ )
158
+
159
+ return Installation(
160
+ course_id=course.id, folder=folder, catalog=catalog,
161
+ tier=tier, notebooks=installed,
162
+ )
@@ -0,0 +1,107 @@
1
+ """Turn a plain .sql file into Databricks notebook source.
2
+
3
+ Databricks notebooks are stored as a flat source file: a header comment naming
4
+ the language, cells separated by a magic delimiter, and markdown cells prefixed
5
+ with a MAGIC marker. Generating that here means the SQL files stay ordinary,
6
+ runnable SQL that anyone can open in a text editor or paste into an editor tab.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ CELL_DELIMITER = "-- COMMAND ----------"
14
+ SQL_HEADER = "-- Databricks notebook source"
15
+
16
+ # A run of dashes at least this long marks a section heading in the SQL files.
17
+ _BANNER = re.compile(r"^-- =={2,}\s*$")
18
+
19
+
20
+ def _md_cell(lines: list[str]) -> str:
21
+ """Render markdown as a %md cell."""
22
+ body = "\n".join(f"-- MAGIC {line}" if line else "-- MAGIC" for line in lines)
23
+ return f"-- MAGIC %md\n{body}"
24
+
25
+
26
+ def split_sql_sections(sql: str) -> list[tuple[str, str]]:
27
+ """Split a lab SQL file into (title, body) sections on its banner comments.
28
+
29
+ The lab files use a banner of the form:
30
+
31
+ -- ====================================
32
+ -- dim_date — FLAW #2: ...
33
+ -- more description
34
+ -- ====================================
35
+
36
+ Each banner starts a new notebook cell, with the banner text becoming a
37
+ markdown cell above the SQL. Files without banners return a single section.
38
+ """
39
+ lines = sql.splitlines()
40
+ sections: list[tuple[str, list[str]]] = []
41
+ current_title = ""
42
+ current_body: list[str] = []
43
+ i = 0
44
+
45
+ while i < len(lines):
46
+ if _BANNER.match(lines[i]):
47
+ # Collect the comment block until the closing banner.
48
+ j = i + 1
49
+ header: list[str] = []
50
+ while j < len(lines) and not _BANNER.match(lines[j]):
51
+ header.append(re.sub(r"^--\s?", "", lines[j]))
52
+ j += 1
53
+ if j < len(lines): # closing banner found
54
+ if current_body or current_title:
55
+ sections.append((current_title, current_body))
56
+ current_title = "\n".join(header).strip()
57
+ current_body = []
58
+ i = j + 1
59
+ continue
60
+ current_body.append(lines[i])
61
+ i += 1
62
+
63
+ if current_body or current_title:
64
+ sections.append((current_title, current_body))
65
+
66
+ return [(t, "\n".join(b).strip()) for t, b in sections]
67
+
68
+
69
+ def sql_to_notebook(
70
+ sql: str,
71
+ *,
72
+ title: str,
73
+ intro: str | None = None,
74
+ ) -> str:
75
+ """Build Databricks SQL notebook source from a lab SQL file."""
76
+ cells: list[str] = []
77
+
78
+ heading = [f"# {title}"]
79
+ if intro:
80
+ heading += ["", *intro.splitlines()]
81
+ cells.append(_md_cell(heading))
82
+
83
+ for section_title, body in split_sql_sections(sql):
84
+ if section_title:
85
+ first, *rest = section_title.splitlines()
86
+ md = [f"## {first.strip()}"]
87
+ if rest:
88
+ md += ["", *[line.strip() for line in rest]]
89
+ cells.append(_md_cell(md))
90
+ if body:
91
+ cells.append(body)
92
+
93
+ return f"{SQL_HEADER}\n" + f"\n\n{CELL_DELIMITER}\n\n".join(cells) + "\n"
94
+
95
+
96
+ def render_template(sql: str, values: dict[str, str]) -> str:
97
+ """Substitute {{PLACEHOLDER}} tokens. Unknown tokens are left untouched so a
98
+ typo shows up in the notebook rather than silently becoming an empty string."""
99
+ out = sql
100
+ for key, value in values.items():
101
+ out = out.replace(f"{{{{{key}}}}}", str(value))
102
+ return out
103
+
104
+
105
+ def unresolved_placeholders(text: str) -> list[str]:
106
+ """Any {{TOKEN}} left after substitution — a bug worth failing loudly on."""
107
+ return sorted(set(re.findall(r"\{\{([A-Z0-9_]+)\}\}", text)))
File without changes
@@ -0,0 +1,31 @@
1
+ -- ============================================================================
2
+ -- Meridian Financial Group — 01. Catalog, schemas, volume
3
+ -- Run first. Idempotent.
4
+ -- ============================================================================
5
+
6
+ CREATE CATALOG IF NOT EXISTS {{CATALOG}}
7
+ COMMENT 'Meridian Financial Group — synthetic teaching dataset for the Genie Agents course. Contains deliberate data-quality flaws; not a reference implementation.';
8
+
9
+ -- Curated business data. Everything a Genie Agent is pointed at lives here.
10
+ CREATE SCHEMA IF NOT EXISTS {{CATALOG}}.core
11
+ COMMENT 'Core banking facts and dimensions for Meridian Financial Group.';
12
+
13
+ -- Reference material: unstructured documents for Agent mode and Knowledge Assistant.
14
+ CREATE SCHEMA IF NOT EXISTS {{CATALOG}}.ref
15
+ COMMENT 'Reference and unstructured material: credit committee memos, branch notes, complaint letters.';
16
+
17
+ -- Staging holds the deliberately awful objects used in Modules 7 and 13.
18
+ -- Kept in a separate schema so the "before" and "after" states are visibly distinct.
19
+ CREATE SCHEMA IF NOT EXISTS {{CATALOG}}.staging
20
+ COMMENT 'Deliberately unfit-for-purpose objects used to teach scoping and latency. Never point a production agent here.';
21
+
22
+ CREATE VOLUME IF NOT EXISTS {{CATALOG}}.ref.documents
23
+ COMMENT 'PDFs attached to the agent for Agent mode: credit committee memos, branch manager notes, customer complaint letters.';
24
+
25
+ -- ----------------------------------------------------------------------------
26
+ -- Verify
27
+ -- ----------------------------------------------------------------------------
28
+ SELECT 'catalog' AS object, '{{CATALOG}}' AS name
29
+ UNION ALL SELECT 'schema', schema_name FROM {{CATALOG}}.information_schema.schemata
30
+ WHERE catalog_name = '{{CATALOG}}'
31
+ ORDER BY object, name;
@@ -0,0 +1,291 @@
1
+ -- ============================================================================
2
+ -- Meridian Financial Group — 02. Dimensions
3
+ --
4
+ -- Plants four of the nine flaws:
5
+ -- #2 fiscal year starts 1 October -> dim_date
6
+ -- #3 region and state are CODES, not names -> dim_branch
7
+ -- #5 two competing product hierarchies -> dim_product
8
+ -- #8 PII columns present and unmasked -> dim_customer
9
+ -- #9 multi-currency needs as-of-date FX -> dim_fx_rate
10
+ --
11
+ -- All values derive from hash() of the row key, never rand(), so every learner
12
+ -- gets byte-identical data and benchmark ground-truth SQL stays valid.
13
+ --
14
+ -- ANCHOR DATE: 2026-09-30 (last day of FY2026). Ages and tenures are computed
15
+ -- from this fixed date rather than current_date, or the data would drift and
16
+ -- benchmark answers would rot.
17
+ -- ============================================================================
18
+
19
+
20
+ -- ============================================================================
21
+ -- dim_date — FLAW #2: fiscal year begins 1 October
22
+ -- FY2025 = 2024-10-01 .. 2025-09-30
23
+ -- FY2026 = 2025-10-01 .. 2026-09-30
24
+ -- Two complete fiscal years, so "this year vs last year" questions work.
25
+ -- ============================================================================
26
+ CREATE OR REPLACE TABLE {{CATALOG}}.core.dim_date
27
+ COMMENT 'Calendar with Meridian fiscal attributes. Meridian fiscal year starts 1 October: FY2026 = 2025-10-01 to 2026-09-30. Fiscal and calendar columns are both present and will disagree.'
28
+ AS
29
+ WITH d AS (
30
+ SELECT explode(sequence(DATE'2024-10-01', DATE'2026-09-30', INTERVAL 1 DAY)) AS date_key
31
+ ),
32
+ calc AS (
33
+ SELECT
34
+ date_key,
35
+ year(date_key) AS cal_y,
36
+ month(date_key) AS cal_m,
37
+ -- Oct-Dec belong to the NEXT fiscal year
38
+ year(date_key) + CASE WHEN month(date_key) >= 10 THEN 1 ELSE 0 END AS fy_num,
39
+ -- Fiscal quarter: Q1=Oct-Dec, Q2=Jan-Mar, Q3=Apr-Jun, Q4=Jul-Sep
40
+ (pmod(month(date_key) + 2, 12) DIV 3) + 1 AS fq_num,
41
+ -- Fiscal month 1..12 where 1 = October
42
+ pmod(month(date_key) + 2, 12) + 1 AS fm_num
43
+ FROM d
44
+ )
45
+ SELECT
46
+ date_key,
47
+ concat('FY', cast(fy_num AS STRING)) AS fiscal_year,
48
+ concat('FY', cast(fy_num AS STRING), '-Q', cast(fq_num AS STRING)) AS fiscal_quarter,
49
+ fm_num AS fiscal_month,
50
+ cal_y AS calendar_year,
51
+ cal_m AS calendar_month,
52
+ date_format(date_key, 'EEEE') AS day_name,
53
+ -- Approximate US banking calendar: weekends plus the fixed-date federal
54
+ -- holidays. Good enough to make "business days" a meaningful filter; not a
55
+ -- substitute for a real holiday calendar.
56
+ CASE
57
+ WHEN dayofweek(date_key) IN (1, 7) THEN false
58
+ WHEN date_format(date_key, 'MM-dd') IN
59
+ ('01-01','06-19','07-04','11-11','12-25') THEN false
60
+ ELSE true
61
+ END AS is_business_day
62
+ FROM calc;
63
+
64
+ ALTER TABLE {{CATALOG}}.core.dim_date ALTER COLUMN date_key
65
+ COMMENT 'Calendar date. One row per day.';
66
+ ALTER TABLE {{CATALOG}}.core.dim_date ALTER COLUMN fiscal_year
67
+ COMMENT 'Meridian fiscal year, format FY2026. The fiscal year STARTS 1 OCTOBER, so FY2026 runs 2025-10-01 to 2026-09-30. "Last year" means the prior FISCAL year unless the user says "calendar year".';
68
+ ALTER TABLE {{CATALOG}}.core.dim_date ALTER COLUMN fiscal_quarter
69
+ COMMENT 'Meridian fiscal quarter, format FY2026-Q3. Q1 = Oct-Dec, Q2 = Jan-Mar, Q3 = Apr-Jun, Q4 = Jul-Sep.';
70
+ ALTER TABLE {{CATALOG}}.core.dim_date ALTER COLUMN calendar_year
71
+ COMMENT 'Calendar year. Use ONLY when the user explicitly asks for calendar periods; otherwise use fiscal_year.';
72
+ ALTER TABLE {{CATALOG}}.core.dim_date ALTER COLUMN is_business_day
73
+ COMMENT 'False for weekends and fixed-date US federal holidays.';
74
+
75
+
76
+ -- ============================================================================
77
+ -- dim_branch — FLAW #3: region and state are stored as CODES
78
+ -- Users say "Northeast", "the West Coast", "California".
79
+ -- The table holds NE, WEST, CA. Without entity matching, Genie filters on the
80
+ -- spoken form, matches nothing, and returns a confident zero.
81
+ -- ============================================================================
82
+ CREATE OR REPLACE TABLE {{CATALOG}}.core.dim_branch
83
+ COMMENT 'Meridian branch network, 340 branches. Region and state are stored as SHORT CODES, not the names people say out loud.'
84
+ AS
85
+ WITH b AS (SELECT id AS n FROM range(1, 341)),
86
+ assigned AS (
87
+ SELECT
88
+ n,
89
+ -- Deterministic region assignment, weighted toward NE and WEST
90
+ CASE pmod(hash(concat('branch-region-', n)), 10)
91
+ WHEN 0 THEN 'NE' WHEN 1 THEN 'NE' WHEN 2 THEN 'NE'
92
+ WHEN 3 THEN 'SE' WHEN 4 THEN 'SE'
93
+ WHEN 5 THEN 'MW' WHEN 6 THEN 'MW'
94
+ ELSE 'WEST'
95
+ END AS region,
96
+ pmod(hash(concat('branch-state-', n)), 4) AS state_pick,
97
+ pmod(hash(concat('branch-chan-', n)), 10) AS chan_pick,
98
+ pmod(hash(concat('branch-open-', n)), 7300) AS open_offset
99
+ FROM b
100
+ )
101
+ SELECT
102
+ concat('BR', lpad(cast(n AS STRING), 4, '0')) AS branch_id,
103
+ concat('Branch ', lpad(cast(n AS STRING), 4, '0')) AS branch_name,
104
+ region,
105
+ -- States are real US codes, mapped to their actual region
106
+ CASE region
107
+ WHEN 'NE' THEN element_at(array('NY','MA','NJ','PA'), state_pick + 1)
108
+ WHEN 'SE' THEN element_at(array('FL','GA','NC','TN'), state_pick + 1)
109
+ WHEN 'MW' THEN element_at(array('IL','OH','MI','MN'), state_pick + 1)
110
+ ELSE element_at(array('CA','WA','AZ','CO'), state_pick + 1)
111
+ END AS state,
112
+ CASE WHEN chan_pick < 7 THEN 'RETAIL'
113
+ WHEN chan_pick < 9 THEN 'COMMERCIAL'
114
+ ELSE 'PRIVATE_CLIENT' END AS channel,
115
+ date_add(DATE'2006-01-01', open_offset) AS opened_date
116
+ FROM assigned;
117
+
118
+ ALTER TABLE {{CATALOG}}.core.dim_branch ALTER COLUMN region
119
+ COMMENT 'Sales region CODE. Values: NE, SE, MW, WEST. Users say "Northeast" (NE), "Southeast" (SE), "Midwest" (MW), "the West" or "West Coast" (WEST).';
120
+ ALTER TABLE {{CATALOG}}.core.dim_branch ALTER COLUMN state
121
+ COMMENT 'Two-letter US state CODE, e.g. CA, NY, TX. Users say the full state name ("California"). Enable entity matching on this column.';
122
+ ALTER TABLE {{CATALOG}}.core.dim_branch ALTER COLUMN channel
123
+ COMMENT 'Branch servicing model. Values: RETAIL, COMMERCIAL, PRIVATE_CLIENT.';
124
+
125
+
126
+ -- ============================================================================
127
+ -- dim_product — FLAW #5: two competing hierarchies
128
+ -- product_category = how the business talks about products
129
+ -- regulatory_product_class = how they roll up for Basel/regulatory reporting
130
+ -- Both are correct. Leaving both visible produces two different answers to
131
+ -- "revenue by product", and nobody can tell which one they got.
132
+ -- ============================================================================
133
+ CREATE OR REPLACE TABLE {{CATALOG}}.core.dim_product
134
+ COMMENT 'Product master. Carries TWO hierarchies that do not align: product_category (business view) and regulatory_product_class (Basel reporting view). Expose only one to an agent.'
135
+ AS
136
+ SELECT * FROM VALUES
137
+ ('P01','Everyday Checking', 'DEPOSITS','RETAIL_DEPOSIT'),
138
+ ('P02','Premier Checking', 'DEPOSITS','RETAIL_DEPOSIT'),
139
+ ('P03','High-Yield Savings', 'DEPOSITS','RETAIL_DEPOSIT'),
140
+ ('P04','12-Month CD', 'DEPOSITS','RETAIL_DEPOSIT'),
141
+ ('P05','Money Market', 'DEPOSITS','RETAIL_DEPOSIT'),
142
+ ('P06','Business Checking', 'DEPOSITS','CORPORATE'),
143
+ ('P07','Rewards Credit Card', 'CARDS','QUALIFYING_REVOLVING'),
144
+ ('P08','Cashback Credit Card', 'CARDS','QUALIFYING_REVOLVING'),
145
+ ('P09','Secured Credit Card', 'CARDS','OTHER_RETAIL'),
146
+ ('P10','Debit Card', 'CARDS','RETAIL_DEPOSIT'),
147
+ ('P11','Commercial Card', 'CARDS','CORPORATE'),
148
+ ('P12','30-Year Fixed Mortgage', 'LENDING','RESIDENTIAL_MORTGAGE'),
149
+ ('P13','15-Year Fixed Mortgage', 'LENDING','RESIDENTIAL_MORTGAGE'),
150
+ ('P14','HELOC', 'LENDING','RESIDENTIAL_MORTGAGE'),
151
+ ('P15','Auto Loan', 'LENDING','OTHER_RETAIL'),
152
+ ('P16','Personal Loan', 'LENDING','OTHER_RETAIL'),
153
+ ('P17','Small Business Term Loan', 'LENDING','CORPORATE'),
154
+ ('P18','Managed Portfolio', 'WEALTH','OFF_BALANCE_SHEET'),
155
+ ('P19','Advisory Account', 'WEALTH','OFF_BALANCE_SHEET'),
156
+ ('P20','Traditional IRA', 'WEALTH','OFF_BALANCE_SHEET')
157
+ AS t(product_id, product_name, product_category, regulatory_product_class);
158
+
159
+ ALTER TABLE {{CATALOG}}.core.dim_product ALTER COLUMN product_category
160
+ COMMENT 'BUSINESS product hierarchy. Values: DEPOSITS, CARDS, LENDING, WEALTH. This is the one business users mean by "product". Prefer this and hide regulatory_product_class.';
161
+ ALTER TABLE {{CATALOG}}.core.dim_product ALTER COLUMN regulatory_product_class
162
+ COMMENT 'REGULATORY (Basel) product hierarchy used for capital reporting. Does NOT align with product_category - a Debit Card is CARDS here but RETAIL_DEPOSIT there. Never mix the two in one rollup.';
163
+
164
+
165
+ -- ============================================================================
166
+ -- dim_customer — FLAW #8: PII columns, deliberately unprotected
167
+ -- ssn_last4, email, dob and annual_income all sit here in the clear so that
168
+ -- Module 6 can demonstrate column masks doing real work, and demonstrate a
169
+ -- text instruction ("never show PII") failing to.
170
+ --
171
+ -- Every value is synthetic. Emails use example.com (RFC 2606 reserved) and
172
+ -- ssn_last4 is a hashed 4-digit string, not derived from anything real.
173
+ -- ============================================================================
174
+ CREATE OR REPLACE TABLE {{CATALOG}}.core.dim_customer
175
+ COMMENT 'Retail and commercial customers, 2.1M rows. CONTAINS PII: ssn_last4, email, dob, annual_income. All values are synthetic. Protect with Unity Catalog column masks, never with agent instructions.'
176
+ AS
177
+ WITH c AS (SELECT id AS n FROM range(1, 2100001)),
178
+ calc AS (
179
+ SELECT
180
+ n,
181
+ pmod(hash(concat('cust-seg-', n)), 100) AS seg_pick,
182
+ pmod(hash(concat('cust-tenure-', n)), 240) AS tenure_m,
183
+ pmod(hash(concat('cust-branch-', n)), 340) AS branch_n,
184
+ pmod(hash(concat('cust-ssn-', n)), 10000) AS ssn4,
185
+ pmod(hash(concat('cust-age-', n)), 67) AS age_offset,
186
+ pmod(hash(concat('cust-inc-', n)), 240) AS inc_pick
187
+ FROM c
188
+ )
189
+ SELECT
190
+ concat('C', lpad(cast(n AS STRING), 8, '0')) AS customer_id,
191
+ CASE WHEN seg_pick < 62 THEN 'MASS'
192
+ WHEN seg_pick < 84 THEN 'AFFLUENT'
193
+ WHEN seg_pick < 94 THEN 'PRIVATE'
194
+ ELSE 'COMMERCIAL' END AS segment,
195
+ tenure_m AS tenure_months,
196
+ concat('BR', lpad(cast(branch_n + 1 AS STRING), 4, '0')) AS home_branch_id,
197
+ lpad(cast(ssn4 AS STRING), 4, '0') AS ssn_last4,
198
+ concat('customer', cast(n AS STRING), '@example.com') AS email,
199
+ -- Ages 18-84 as of the 2026-09-30 anchor
200
+ date_add(DATE'2026-09-30', -1 * ((18 + age_offset) * 365 + pmod(hash(concat('cust-dob-', n)), 365))) AS dob,
201
+ cast(25000 + inc_pick * 1250 AS DECIMAL(12,2)) AS annual_income
202
+ FROM calc;
203
+
204
+ ALTER TABLE {{CATALOG}}.core.dim_customer ALTER COLUMN segment
205
+ COMMENT 'Customer segment. Values: MASS, AFFLUENT, PRIVATE, COMMERCIAL.';
206
+ ALTER TABLE {{CATALOG}}.core.dim_customer ALTER COLUMN tenure_months
207
+ COMMENT 'Months since the customer relationship began, as of 2026-09-30.';
208
+ ALTER TABLE {{CATALOG}}.core.dim_customer ALTER COLUMN ssn_last4
209
+ COMMENT 'PII. Last four digits of tax identifier (synthetic). Must be masked. Never expose in an answer.';
210
+ ALTER TABLE {{CATALOG}}.core.dim_customer ALTER COLUMN email
211
+ COMMENT 'PII. Contact email (synthetic, example.com). Must be masked. Never expose in an answer.';
212
+ ALTER TABLE {{CATALOG}}.core.dim_customer ALTER COLUMN dob
213
+ COMMENT 'PII. Date of birth (synthetic). Must be masked. Use age bands for analysis instead.';
214
+ ALTER TABLE {{CATALOG}}.core.dim_customer ALTER COLUMN annual_income
215
+ COMMENT 'PII. Self-reported annual income in USD (synthetic). Restricted - finance roles only.';
216
+
217
+
218
+ -- ============================================================================
219
+ -- dim_account — the join hub
220
+ -- Every fact joins to a dimension THROUGH this table. Getting its cardinality
221
+ -- wrong is how "revenue by region" quietly multiplies (Module 9).
222
+ -- ============================================================================
223
+ CREATE OR REPLACE TABLE {{CATALOG}}.core.dim_account
224
+ COMMENT 'Account master, ~2.9M rows. The join hub: transactions and balances reach customer, branch and product through this table.'
225
+ AS
226
+ WITH a AS (SELECT id AS n FROM range(1, 2900001)),
227
+ calc AS (
228
+ SELECT
229
+ n,
230
+ pmod(hash(concat('acct-cust-', n)), 2100000) AS cust_n,
231
+ pmod(hash(concat('acct-prod-', n)), 20) AS prod_n,
232
+ pmod(hash(concat('acct-branch-', n)), 340) AS branch_n,
233
+ pmod(hash(concat('acct-open-', n)), 5475) AS open_offset,
234
+ pmod(hash(concat('acct-status-', n)), 100) AS status_pick
235
+ FROM a
236
+ )
237
+ SELECT
238
+ concat('A', lpad(cast(n AS STRING), 9, '0')) AS account_id,
239
+ concat('C', lpad(cast(cust_n + 1 AS STRING), 8, '0')) AS customer_id,
240
+ concat('P', lpad(cast(prod_n + 1 AS STRING), 2, '0')) AS product_id,
241
+ concat('BR', lpad(cast(branch_n + 1 AS STRING), 4, '0')) AS branch_id,
242
+ date_add(DATE'2011-10-01', open_offset) AS opened_date,
243
+ CASE WHEN status_pick < 8
244
+ THEN date_add(DATE'2011-10-01', open_offset + 400 + pmod(hash(concat('acct-close-', n)), 1500))
245
+ ELSE NULL END AS closed_date,
246
+ CASE WHEN status_pick < 8 THEN 'CLOSED' ELSE 'OPEN' END AS status
247
+ FROM calc;
248
+
249
+ ALTER TABLE {{CATALOG}}.core.dim_account ALTER COLUMN status
250
+ COMMENT 'Account status. Values: OPEN, CLOSED. Roughly 8% are CLOSED. Exclude CLOSED accounts from "active customer" style questions.';
251
+ ALTER TABLE {{CATALOG}}.core.dim_account ALTER COLUMN closed_date
252
+ COMMENT 'Date the account was closed, NULL while open.';
253
+
254
+
255
+ -- ============================================================================
256
+ -- dim_fx_rate — FLAW #9: multi-currency needs an as-of-date join
257
+ -- Commercial transactions settle in CAD and GBP. Summing amount across
258
+ -- currencies without converting produces a number that does not tie to finance.
259
+ -- ============================================================================
260
+ CREATE OR REPLACE TABLE {{CATALOG}}.core.dim_fx_rate
261
+ COMMENT 'Daily FX rates to USD. Transactions in CAD or GBP must be converted using the rate AS OF the transaction date, not the latest rate.'
262
+ AS
263
+ WITH d AS (
264
+ SELECT explode(sequence(DATE'2024-10-01', DATE'2026-09-30', INTERVAL 1 DAY)) AS rate_date
265
+ ),
266
+ cur AS (SELECT explode(array('USD','CAD','GBP')) AS currency)
267
+ SELECT
268
+ cur.currency,
269
+ d.rate_date,
270
+ CASE cur.currency
271
+ WHEN 'USD' THEN cast(1.0 AS DECIMAL(12,6))
272
+ -- Small deterministic drift around a plausible central rate
273
+ WHEN 'CAD' THEN cast(0.730 + (pmod(hash(concat('fx-cad-', d.rate_date)), 40) - 20) / 2000.0 AS DECIMAL(12,6))
274
+ ELSE cast(1.265 + (pmod(hash(concat('fx-gbp-', d.rate_date)), 60) - 30) / 2000.0 AS DECIMAL(12,6))
275
+ END AS usd_rate
276
+ FROM d CROSS JOIN cur;
277
+
278
+ ALTER TABLE {{CATALOG}}.core.dim_fx_rate ALTER COLUMN usd_rate
279
+ COMMENT 'Multiply a native-currency amount by this to get USD. Join on BOTH currency AND the transaction date.';
280
+
281
+
282
+ -- ----------------------------------------------------------------------------
283
+ -- Row counts
284
+ -- ----------------------------------------------------------------------------
285
+ SELECT 'dim_date' AS table_name, count(*) AS rows FROM {{CATALOG}}.core.dim_date
286
+ UNION ALL SELECT 'dim_branch', count(*) FROM {{CATALOG}}.core.dim_branch
287
+ UNION ALL SELECT 'dim_product', count(*) FROM {{CATALOG}}.core.dim_product
288
+ UNION ALL SELECT 'dim_customer', count(*) FROM {{CATALOG}}.core.dim_customer
289
+ UNION ALL SELECT 'dim_account', count(*) FROM {{CATALOG}}.core.dim_account
290
+ UNION ALL SELECT 'dim_fx_rate', count(*) FROM {{CATALOG}}.core.dim_fx_rate
291
+ ORDER BY table_name;
@@ -0,0 +1,295 @@
1
+ -- ============================================================================
2
+ -- Meridian Financial Group — 03. Facts
3
+ --
4
+ -- Plants the four remaining flaws:
5
+ -- #1 fee_revenue is GROSS; reversals live in a separate table
6
+ -- #4 DECLINED rows inflate transaction COUNTS while contributing no revenue
7
+ -- #6 fct_loan_balances is a DAILY SNAPSHOT — SUM() across dates is ~30x wrong
8
+ -- #7 "delinquent" / "seriously delinquent" / "default" / "charge-off" are
9
+ -- four different things, all present, all defensible
10
+ --
11
+ -- TIER: the line marked -- << TIER >> controls scale.
12
+ -- Small 20000000 default. Every module except 13.
13
+ -- Large 900000000 Module 13 only (latency). Leave unclustered on purpose.
14
+ -- ============================================================================
15
+
16
+
17
+ -- ============================================================================
18
+ -- fct_transactions — FLAWS #1, #4, #9
19
+ -- ============================================================================
20
+ CREATE OR REPLACE TABLE {{CATALOG}}.core.fct_transactions
21
+ COMMENT 'Transaction-level fact. fee_revenue is GROSS - it does NOT deduct reversals, which live in fct_reversals. Only status = POSTED is revenue; DECLINED rows exist and will inflate transaction counts if not excluded.'
22
+ AS
23
+ WITH t AS (
24
+ SELECT id AS n FROM range(1, {{TXN_COUNT}} + 1) -- << TIER >> set by the tier in manifest.json
25
+ ),
26
+ calc AS (
27
+ SELECT
28
+ n,
29
+ pmod(hash(concat('txn-acct-', n)), 2900000) AS acct_n,
30
+ pmod(hash(concat('txn-day-', n)), 730) AS day_offset,
31
+ pmod(hash(concat('txn-amt-', n)), 100000) AS amt_pick,
32
+ pmod(hash(concat('txn-stat-', n)), 1000) AS stat_pick,
33
+ pmod(hash(concat('txn-mcc-', n)), 12) AS mcc_pick,
34
+ pmod(hash(concat('txn-cur-', n)), 100) AS cur_pick
35
+ FROM t
36
+ ),
37
+ shaped AS (
38
+ SELECT
39
+ n,
40
+ concat('T', lpad(cast(n AS STRING), 12, '0')) AS txn_id,
41
+ concat('A', lpad(cast(acct_n + 1 AS STRING), 9, '0')) AS account_id,
42
+ date_add(DATE'2024-10-01', day_offset) AS txn_date,
43
+ -- Amounts are log-ish: many small, few large
44
+ cast(
45
+ CASE WHEN amt_pick < 70000 THEN 5 + amt_pick / 1000.0
46
+ WHEN amt_pick < 95000 THEN 120 + amt_pick / 200.0
47
+ ELSE 900 + amt_pick / 50.0
48
+ END AS DECIMAL(14,2)) AS amount,
49
+ -- FLAW #4: only POSTED is revenue, but DECLINED is 6% of rows
50
+ CASE WHEN stat_pick < 880 THEN 'POSTED'
51
+ WHEN stat_pick < 920 THEN 'PENDING'
52
+ WHEN stat_pick < 980 THEN 'DECLINED'
53
+ ELSE 'REVERSED'
54
+ END AS status,
55
+ element_at(array(
56
+ 'GROCERY','RESTAURANT','FUEL','AIRLINE','HOTEL','RETAIL',
57
+ 'UTILITIES','HEALTHCARE','ENTERTAINMENT','TRANSFER','ATM','PROFESSIONAL_SERVICES'
58
+ ), mcc_pick + 1) AS merchant_category,
59
+ cur_pick
60
+ FROM calc
61
+ )
62
+ SELECT
63
+ s.txn_id,
64
+ s.account_id,
65
+ s.txn_date,
66
+ s.amount,
67
+ -- FLAW #1: GROSS fee revenue. Reversals are NOT deducted here.
68
+ -- Only POSTED transactions earn anything.
69
+ CASE WHEN s.status = 'POSTED'
70
+ THEN cast(round(s.amount * 0.0185, 2) AS DECIMAL(14,2))
71
+ ELSE cast(0 AS DECIMAL(14,2)) END AS fee_revenue,
72
+ CASE WHEN s.status = 'POSTED'
73
+ THEN cast(round(s.amount * 0.0110, 2) AS DECIMAL(14,2))
74
+ ELSE cast(0 AS DECIMAL(14,2)) END AS interchange,
75
+ s.merchant_category,
76
+ -- FLAW #9: COMMERCIAL customers settle in CAD/GBP. Everyone else is USD.
77
+ -- Summing amount across currencies without converting is silently wrong.
78
+ CASE WHEN c.segment = 'COMMERCIAL' AND s.cur_pick < 40
79
+ THEN CASE WHEN s.cur_pick < 25 THEN 'CAD' ELSE 'GBP' END
80
+ ELSE 'USD' END AS currency,
81
+ s.status
82
+ FROM shaped s
83
+ JOIN {{CATALOG}}.core.dim_account a ON a.account_id = s.account_id
84
+ JOIN {{CATALOG}}.core.dim_customer c ON c.customer_id = a.customer_id;
85
+
86
+ ALTER TABLE {{CATALOG}}.core.fct_transactions ALTER COLUMN fee_revenue
87
+ COMMENT 'GROSS fee revenue in the transaction currency, before reversals and chargebacks. Do NOT sum this column alone and call it revenue - subtract fct_reversals.reversal_amount. Zero for non-POSTED rows.';
88
+ ALTER TABLE {{CATALOG}}.core.fct_transactions ALTER COLUMN status
89
+ COMMENT 'Transaction status. Values: POSTED, PENDING, DECLINED, REVERSED. ONLY POSTED counts as revenue. DECLINED rows are ~6% of the table and will inflate transaction counts if not filtered out.';
90
+ ALTER TABLE {{CATALOG}}.core.fct_transactions ALTER COLUMN currency
91
+ COMMENT 'Settlement currency. Values: USD, CAD, GBP. COMMERCIAL customers transact in all three. Convert with dim_fx_rate joined on currency AND txn_date before summing across currencies.';
92
+ ALTER TABLE {{CATALOG}}.core.fct_transactions ALTER COLUMN amount
93
+ COMMENT 'Transaction amount in the settlement currency (see currency column), not USD.';
94
+ ALTER TABLE {{CATALOG}}.core.fct_transactions ALTER COLUMN merchant_category
95
+ COMMENT 'Merchant category. 12 distinct values - a good candidate for entity matching.';
96
+
97
+
98
+ -- ============================================================================
99
+ -- fct_reversals — the other half of FLAW #1
100
+ -- Deliberately a SEPARATE table. If reversals were a column on
101
+ -- fct_transactions nobody would ever get gross vs net wrong.
102
+ -- ============================================================================
103
+ CREATE OR REPLACE TABLE {{CATALOG}}.core.fct_reversals
104
+ COMMENT 'Reversals and chargebacks. The amount here must be SUBTRACTED from fct_transactions.fee_revenue to get net fee revenue. One row per reversed transaction.'
105
+ AS
106
+ SELECT
107
+ t.txn_id,
108
+ date_add(t.txn_date, 3 + pmod(hash(concat('rev-lag-', t.txn_id)), 25)) AS reversal_date,
109
+ cast(round(t.fee_revenue, 2) AS DECIMAL(14,2)) AS reversal_amount,
110
+ element_at(array('DISPUTE','FRAUD','DUPLICATE','MERCHANT_ERROR','AUTHORISATION_FAIL'),
111
+ pmod(hash(concat('rev-reason-', t.txn_id)), 5) + 1) AS reason_code
112
+ FROM {{CATALOG}}.core.fct_transactions t
113
+ -- ~3.5% of POSTED transactions get reversed, which is why "revenue" is
114
+ -- overstated by a few percent and nobody notices.
115
+ WHERE t.status = 'POSTED'
116
+ AND pmod(hash(concat('rev-flag-', t.txn_id)), 1000) < 35;
117
+
118
+ ALTER TABLE {{CATALOG}}.core.fct_reversals ALTER COLUMN reversal_amount
119
+ COMMENT 'Fee revenue clawed back. Subtract the sum of this from gross fee_revenue to get NET fee revenue.';
120
+ ALTER TABLE {{CATALOG}}.core.fct_reversals ALTER COLUMN reason_code
121
+ COMMENT 'Reversal reason. Values: DISPUTE, FRAUD, DUPLICATE, MERCHANT_ERROR, AUTHORISATION_FAIL.';
122
+
123
+
124
+ -- ============================================================================
125
+ -- fct_loan_balances — FLAWS #6 and #7
126
+ --
127
+ -- ONE ROW PER ACCOUNT PER DAY. This is the single most dangerous table in the
128
+ -- dataset: SUM(principal_balance) over any period longer than a day produces a
129
+ -- number that looks entirely plausible and is roughly 30x too large.
130
+ --
131
+ -- 45,000 lending accounts x 730 days ~ 32.8M rows (~1.4M per month).
132
+ -- ============================================================================
133
+ CREATE OR REPLACE TABLE {{CATALOG}}.core.fct_loan_balances
134
+ COMMENT 'DAILY SNAPSHOT of the loan book: one row per account per day. NEVER SUM principal_balance across dates - use the latest snapshot for a point-in-time balance, or an average for a period. Also carries the four distinct delinquency concepts.'
135
+ AS
136
+ WITH loan_accounts AS (
137
+ SELECT a.account_id, a.opened_date
138
+ FROM {{CATALOG}}.core.dim_account a
139
+ JOIN {{CATALOG}}.core.dim_product p ON p.product_id = a.product_id
140
+ WHERE p.product_category = 'LENDING'
141
+ AND pmod(hash(concat('loan-pick-', a.account_id)), 100) < 6 -- ~6% sample
142
+ ),
143
+ days AS (
144
+ SELECT explode(sequence(DATE'2024-10-01', DATE'2026-09-30', INTERVAL 1 DAY)) AS snapshot_date
145
+ ),
146
+ grid AS (
147
+ SELECT
148
+ la.account_id,
149
+ d.snapshot_date,
150
+ datediff(d.snapshot_date, DATE'2024-10-01') AS day_n,
151
+ pmod(hash(concat('loan-orig-', la.account_id)), 400) AS orig_pick,
152
+ pmod(hash(concat('loan-risk-', la.account_id)), 1000) AS risk_pick
153
+ FROM loan_accounts la CROSS JOIN days d
154
+ WHERE d.snapshot_date >= la.opened_date
155
+ ),
156
+ shaped AS (
157
+ SELECT
158
+ account_id,
159
+ snapshot_date,
160
+ -- Original principal, amortising slowly over the window
161
+ cast(round((15000 + orig_pick * 850) * (1 - day_n / 4000.0), 2) AS DECIMAL(14,2)) AS principal_balance,
162
+ -- Riskier accounts accumulate days past due over time
163
+ CASE
164
+ WHEN risk_pick < 880 THEN 0
165
+ WHEN risk_pick < 940 THEN pmod(day_n, 45)
166
+ WHEN risk_pick < 980 THEN 30 + pmod(day_n, 60)
167
+ ELSE 90 + pmod(day_n, 120)
168
+ END AS days_past_due
169
+ FROM grid
170
+ )
171
+ SELECT
172
+ account_id,
173
+ snapshot_date,
174
+ principal_balance,
175
+ cast(round(principal_balance * 0.0625 / 365 * 30, 2) AS DECIMAL(14,2)) AS interest_accrued,
176
+ days_past_due,
177
+ -- FLAW #7, part 1: the bucket
178
+ CASE WHEN days_past_due = 0 THEN 'CURRENT'
179
+ WHEN days_past_due < 30 THEN '1-29'
180
+ WHEN days_past_due < 60 THEN '30-59'
181
+ WHEN days_past_due < 90 THEN '60-89'
182
+ ELSE '90+'
183
+ END AS dpd_bucket,
184
+ -- FLAW #7, part 2: a SEPARATE lifecycle status. "Default" and "charge-off"
185
+ -- are accounting events, not DPD thresholds - so "how many delinquent
186
+ -- loans?" has four defensible answers depending on which you mean.
187
+ CASE WHEN days_past_due = 0 THEN 'PERFORMING'
188
+ WHEN days_past_due < 90 THEN 'DELINQUENT'
189
+ WHEN days_past_due < 180 THEN 'DEFAULT'
190
+ ELSE 'CHARGED_OFF'
191
+ END AS loan_status
192
+ FROM shaped;
193
+
194
+ ALTER TABLE {{CATALOG}}.core.fct_loan_balances ALTER COLUMN principal_balance
195
+ COMMENT 'Outstanding principal AS OF snapshot_date. This table has ONE ROW PER ACCOUNT PER DAY - never SUM across dates. For a point-in-time loan book, filter to the latest snapshot_date.';
196
+ ALTER TABLE {{CATALOG}}.core.fct_loan_balances ALTER COLUMN days_past_due
197
+ COMMENT 'Days past due as of snapshot_date. 0 means current.';
198
+ ALTER TABLE {{CATALOG}}.core.fct_loan_balances ALTER COLUMN dpd_bucket
199
+ COMMENT 'Delinquency bucket. Values: CURRENT, 1-29, 30-59, 60-89, 90+. Meridian defines "delinquent" as 30+ days past due and "seriously delinquent" as 90+. Ask which one the user means.';
200
+ ALTER TABLE {{CATALOG}}.core.fct_loan_balances ALTER COLUMN loan_status
201
+ COMMENT 'Accounting lifecycle status, DISTINCT from dpd_bucket. Values: PERFORMING, DELINQUENT, DEFAULT, CHARGED_OFF. "Default" and "charge-off" are accounting events and are NOT the same as being 30+ or 90+ days past due.';
202
+
203
+
204
+ -- ============================================================================
205
+ -- fct_applications — approval funnel and cycle time
206
+ -- ============================================================================
207
+ CREATE OR REPLACE TABLE {{CATALOG}}.core.fct_applications
208
+ COMMENT 'Credit application funnel: submitted -> decisioned -> funded. Approval rate is decisions of APPROVED over all applications. Not every approved application is funded.'
209
+ AS
210
+ WITH ap AS (SELECT id AS n FROM range(1, 420001)),
211
+ calc AS (
212
+ SELECT
213
+ n,
214
+ pmod(hash(concat('app-cust-', n)), 2100000) AS cust_n,
215
+ pmod(hash(concat('app-prod-', n)), 20) AS prod_n,
216
+ pmod(hash(concat('app-day-', n)), 730) AS day_offset,
217
+ pmod(hash(concat('app-dec-', n)), 100) AS dec_pick,
218
+ pmod(hash(concat('app-chan-', n)), 100) AS chan_pick,
219
+ pmod(hash(concat('app-lag-', n)), 14) AS decision_lag,
220
+ pmod(hash(concat('app-fund-', n)), 100) AS fund_pick
221
+ FROM ap
222
+ )
223
+ SELECT
224
+ concat('APP', lpad(cast(n AS STRING), 8, '0')) AS app_id,
225
+ concat('C', lpad(cast(cust_n + 1 AS STRING), 8, '0')) AS customer_id,
226
+ concat('P', lpad(cast(prod_n + 1 AS STRING), 2, '0')) AS product_id,
227
+ cast(date_add(DATE'2024-10-01', day_offset) AS TIMESTAMP) AS submitted_ts,
228
+ cast(date_add(DATE'2024-10-01', day_offset + decision_lag) AS TIMESTAMP) AS decision_ts,
229
+ CASE WHEN dec_pick < 58 AND fund_pick < 82
230
+ THEN cast(date_add(DATE'2024-10-01', day_offset + decision_lag + 2) AS TIMESTAMP)
231
+ ELSE NULL END AS funded_ts,
232
+ CASE WHEN dec_pick < 58 THEN 'APPROVED'
233
+ WHEN dec_pick < 88 THEN 'DECLINED'
234
+ ELSE 'WITHDRAWN' END AS decision,
235
+ CASE WHEN chan_pick < 46 THEN 'DIGITAL'
236
+ WHEN chan_pick < 78 THEN 'BRANCH'
237
+ WHEN chan_pick < 92 THEN 'CALL_CENTRE'
238
+ ELSE 'BROKER' END AS channel
239
+ FROM calc;
240
+
241
+ ALTER TABLE {{CATALOG}}.core.fct_applications ALTER COLUMN decision
242
+ COMMENT 'Underwriting decision. Values: APPROVED, DECLINED, WITHDRAWN. Approval rate = APPROVED / all applications, including WITHDRAWN.';
243
+ ALTER TABLE {{CATALOG}}.core.fct_applications ALTER COLUMN funded_ts
244
+ COMMENT 'When the loan was actually funded. NULL if never funded - roughly 18% of APPROVED applications are never drawn down, so funded count < approved count.';
245
+ ALTER TABLE {{CATALOG}}.core.fct_applications ALTER COLUMN channel
246
+ COMMENT 'Origination channel. Values: DIGITAL, BRANCH, CALL_CENTRE, BROKER.';
247
+
248
+
249
+ -- ============================================================================
250
+ -- fct_fraud_cases
251
+ -- ============================================================================
252
+ CREATE OR REPLACE TABLE {{CATALOG}}.core.fct_fraud_cases
253
+ COMMENT 'Fraud and financial-crime cases. loss_amount is the realised loss in USD, zero for cases closed without loss.'
254
+ AS
255
+ WITH f AS (SELECT id AS n FROM range(1, 14001)),
256
+ calc AS (
257
+ SELECT
258
+ n,
259
+ pmod(hash(concat('frd-acct-', n)), 2900000) AS acct_n,
260
+ pmod(hash(concat('frd-day-', n)), 730) AS day_offset,
261
+ pmod(hash(concat('frd-type-', n)), 6) AS type_pick,
262
+ pmod(hash(concat('frd-loss-', n)), 100) AS loss_pick,
263
+ pmod(hash(concat('frd-stat-', n)), 100) AS stat_pick,
264
+ pmod(hash(concat('frd-close-',n)), 60) AS close_lag
265
+ FROM f
266
+ )
267
+ SELECT
268
+ concat('FC', lpad(cast(n AS STRING), 7, '0')) AS case_id,
269
+ concat('A', lpad(cast(acct_n + 1 AS STRING), 9, '0')) AS account_id,
270
+ date_add(DATE'2024-10-01', day_offset) AS opened_date,
271
+ CASE WHEN stat_pick < 84
272
+ THEN date_add(DATE'2024-10-01', day_offset + close_lag)
273
+ ELSE NULL END AS closed_date,
274
+ cast(CASE WHEN loss_pick < 38 THEN 0
275
+ ELSE round(150 + loss_pick * 96.5, 2) END AS DECIMAL(14,2)) AS loss_amount,
276
+ element_at(array('CARD_NOT_PRESENT','ACCOUNT_TAKEOVER','APPLICATION_FRAUD',
277
+ 'CHECK_FRAUD','WIRE_FRAUD','FIRST_PARTY'), type_pick + 1) AS fraud_type,
278
+ CASE WHEN stat_pick < 84 THEN 'CLOSED' ELSE 'OPEN' END AS status
279
+ FROM calc;
280
+
281
+ ALTER TABLE {{CATALOG}}.core.fct_fraud_cases ALTER COLUMN fraud_type
282
+ COMMENT 'Fraud typology. Values: CARD_NOT_PRESENT, ACCOUNT_TAKEOVER, APPLICATION_FRAUD, CHECK_FRAUD, WIRE_FRAUD, FIRST_PARTY.';
283
+ ALTER TABLE {{CATALOG}}.core.fct_fraud_cases ALTER COLUMN loss_amount
284
+ COMMENT 'Realised loss in USD. Zero for cases closed with no loss - about 38% of cases.';
285
+
286
+
287
+ -- ----------------------------------------------------------------------------
288
+ -- Row counts
289
+ -- ----------------------------------------------------------------------------
290
+ SELECT 'fct_transactions' AS table_name, count(*) AS rows FROM {{CATALOG}}.core.fct_transactions
291
+ UNION ALL SELECT 'fct_reversals', count(*) FROM {{CATALOG}}.core.fct_reversals
292
+ UNION ALL SELECT 'fct_loan_balances', count(*) FROM {{CATALOG}}.core.fct_loan_balances
293
+ UNION ALL SELECT 'fct_applications', count(*) FROM {{CATALOG}}.core.fct_applications
294
+ UNION ALL SELECT 'fct_fraud_cases', count(*) FROM {{CATALOG}}.core.fct_fraud_cases
295
+ ORDER BY table_name;
File without changes
@@ -0,0 +1,41 @@
1
+ {
2
+ "id": "genie-agents",
3
+ "title": "Genie Agents — Meridian Financial Group lab",
4
+ "description": "A synthetic US bank with nine deliberate data-quality flaws. Every flaw is the raw material for a specific module: gross vs net revenue, a fiscal year starting 1 October, region codes users never say out loud, DECLINED rows inflating counts, two competing product hierarchies, a daily-snapshot table that invites a 30x fan-out, four meanings of 'delinquent', unmasked PII, and multi-currency needing an as-of-date join.",
5
+ "default_catalog": "mfg",
6
+ "default_tier": "small",
7
+ "tiers": {
8
+ "small": {
9
+ "description": "20M transactions. Every module except 13. Start here.",
10
+ "values": { "TXN_COUNT": "20000000" }
11
+ },
12
+ "large": {
13
+ "description": "900M transactions, left unclustered on purpose. Module 13 (latency) only.",
14
+ "values": { "TXN_COUNT": "900000000" }
15
+ }
16
+ },
17
+ "notebooks": [
18
+ {
19
+ "order": 1,
20
+ "name": "01_catalog_and_schemas",
21
+ "sql": "01_catalog_and_schemas.sql",
22
+ "title": "01 · Catalog and schemas",
23
+ "intro": "Creates the `{{CATALOG}}` catalog, the core / ref / staging schemas, and the documents volume.\n\nRun this first. Everything else depends on it."
24
+ },
25
+ {
26
+ "order": 2,
27
+ "name": "02_dimensions",
28
+ "sql": "02_dimensions.sql",
29
+ "title": "02 · Dimensions",
30
+ "intro": "Six dimensions, planting five of the nine flaws: the 1 October fiscal year, region and state stored as codes, two competing product hierarchies, unmasked PII, and multi-currency FX rates.\n\nEvery value derives from `hash()` of the row key rather than `rand()`, so your data is byte-identical to everyone else's and the benchmark answers in Module 11 stay valid."
31
+ },
32
+ {
33
+ "order": 3,
34
+ "name": "03_facts",
35
+ "sql": "03_facts.sql",
36
+ "title": "03 · Facts",
37
+ "intro": "Five fact tables carrying the four remaining flaws.\n\n**This is the slow one** — it generates {{TXN_COUNT}} transactions plus roughly 35M daily loan-balance snapshots. Expect several minutes on a small warehouse.",
38
+ "slow": true
39
+ }
40
+ ]
41
+ }
@@ -0,0 +1,176 @@
1
+ Metadata-Version: 2.4
2
+ Name: databricks360
3
+ Version: 0.1.0
4
+ Summary: Install Databricks course lab environments: notebooks, catalogs, datasets and governance objects.
5
+ Project-URL: Homepage, https://github.com/databrickslms/dbxdemos
6
+ Project-URL: Source, https://github.com/databrickslms/dbxdemos
7
+ Project-URL: Issues, https://github.com/databrickslms/dbxdemos/issues
8
+ Author: Lakehouse Academy
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: databricks,genie,lakehouse,training,unity-catalog
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Education
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Education
16
+ Requires-Python: >=3.9
17
+ Requires-Dist: databricks-sdk>=0.38.0
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=7; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # databricks360
23
+
24
+ Installs Databricks course lab environments — notebooks, catalogs, datasets and
25
+ governance objects — into your own workspace.
26
+
27
+ Modelled on `dbdemos`: you run it **inside a Databricks notebook**, so
28
+ `databricks-sdk` picks up the notebook's own identity. There is no host, token or
29
+ profile to configure.
30
+
31
+ ## Install
32
+
33
+ Not on PyPI yet, so install from the repo. In a **Databricks notebook**:
34
+
35
+ ```python
36
+ %pip install git+https://github.com/databrickslms/dbxdemos.git
37
+ dbutils.library.restartPython()
38
+ ```
39
+
40
+ Once published, that becomes `%pip install databricks360`.
41
+
42
+ To pin a version, append a tag or commit:
43
+
44
+ ```python
45
+ %pip install git+https://github.com/databrickslms/dbxdemos.git@v0.1.0
46
+ ```
47
+
48
+ ## Usage
49
+
50
+ ```python
51
+ import databricks360 as academy
52
+
53
+ academy.list_courses()
54
+ academy.install('genie-agents')
55
+ ```
56
+
57
+ `install` writes the lab notebooks into your workspace and prints the run order.
58
+ Then you open them and run each in turn.
59
+
60
+ ```
61
+ Installed 'genie-agents' → /Workspace/Users/you@corp.com/databricks360/genie-agents
62
+ catalog: mfg tier: small
63
+
64
+ Run these in order:
65
+ 1. 01_catalog_and_schemas
66
+ 2. 02_dimensions
67
+ 3. 03_facts (slow)
68
+ ```
69
+
70
+ ### Options
71
+
72
+ ```python
73
+ academy.install(
74
+ 'genie-agents',
75
+ path='/Workspace/Shared/labs', # default: your home folder
76
+ catalog='training_v2', # default: the course's own catalog
77
+ tier='large', # default: 'small'
78
+ overwrite=True, # replace existing notebooks
79
+ )
80
+ ```
81
+
82
+ ## Why it does not run the notebooks for you
83
+
84
+ `dbdemos` starts a job and loads the data on your behalf. This deliberately does
85
+ not. Generating the data is the substance of Module 0 — the point is to watch a
86
+ warehouse chew through 20M rows and see the flaws appear, not to have a finished
87
+ catalog materialise. It also means nothing consumes your DBUs without you asking.
88
+
89
+ ## Tiers
90
+
91
+ | Tier | Transactions | Use |
92
+ |---|---|---|
93
+ | `small` | 20M | default. Every module except 13 |
94
+ | `large` | 900M | Module 13 (latency) only. Left unclustered on purpose |
95
+
96
+ Start small. The large tier exists because you cannot measure query latency on a
97
+ toy dataset, and nowhere else needs it.
98
+
99
+ ## Adding a course
100
+
101
+ Each course is a subpackage under `databricks360/courses/`:
102
+
103
+ ```
104
+ databricks360/courses/<course_id>/
105
+ __init__.py
106
+ manifest.json # title, default catalog, tiers, notebooks in run order
107
+ *.sql # synced from content/courses/<id>/assets/lab/
108
+ ```
109
+
110
+ Placeholders available in the SQL: `{{CATALOG}}`, plus anything declared under a
111
+ tier's `values` (currently `{{TXN_COUNT}}`). An unresolved placeholder raises rather
112
+ than silently rendering empty.
113
+
114
+ Dataset documentation lives in [`docs/`](docs/).
115
+
116
+ ## Publishing
117
+
118
+ Releases go to PyPI via **Trusted Publishing** — GitHub Actions authenticates to
119
+ PyPI with a short-lived OIDC identity, so no API token exists in repo secrets or on
120
+ anyone's laptop. A leaked token is the usual way a package supply chain gets
121
+ compromised; the safest token is one that was never created.
122
+
123
+ ### One-time PyPI setup
124
+
125
+ 1. Sign in at [pypi.org](https://pypi.org) → **Your account → Publishing**
126
+ 2. Under *Add a new pending publisher*, choose **GitHub** and enter exactly:
127
+
128
+ | Field | Value |
129
+ |---|---|
130
+ | PyPI Project Name | `databricks360` |
131
+ | Owner | `databrickslms` |
132
+ | Repository name | `dbxdemos` |
133
+ | Workflow name | `publish.yml` |
134
+ | Environment name | `pypi` |
135
+
136
+ 3. In GitHub → **Settings → Environments → New environment** → name it `pypi`.
137
+ Add yourself as a required reviewer if you want to approve each release.
138
+
139
+ "Pending" publisher is correct — the project does not exist on PyPI yet, and the
140
+ first successful run creates it.
141
+
142
+ ### Cutting a release
143
+
144
+ ```bash
145
+ # bump version in pyproject.toml, commit, then:
146
+ git tag v0.1.0
147
+ git push origin v0.1.0
148
+ ```
149
+
150
+ The workflow runs the tests, builds, checks the tag matches `pyproject.toml`, and
151
+ publishes. A mismatched tag fails before anything reaches the index — versions on
152
+ PyPI are immutable, so a wrong number cannot be taken back, only yanked.
153
+
154
+ ### Publishing by hand instead
155
+
156
+ ```bash
157
+ python -m pip install build twine
158
+ python -m build
159
+ twine check dist/*
160
+ twine upload dist/* # prompts for an API token
161
+ ```
162
+
163
+ Test it against TestPyPI first if you want a dry run:
164
+ `twine upload --repository testpypi dist/*`.
165
+
166
+ ## Tests
167
+
168
+ ```bash
169
+ python3 run_tests.py
170
+ ```
171
+
172
+ Eleven tests, no workspace required: manifest loading, notebook cell structure,
173
+ catalog substitution, tier switching, unresolved-placeholder detection, and a
174
+ `dry_run` install. It also asserts the flaw-teaching column comments survive into
175
+ the generated notebooks — those comments are the curriculum, so losing them in
176
+ rendering would be a silent failure.
@@ -0,0 +1,14 @@
1
+ databricks360/__init__.py,sha256=wpwQrBwBMNxoM7msAY5NyCXnDW_9P862E6tzkGVeBLw,2028
2
+ databricks360/_catalog.py,sha256=xBnIho0B8MZfAn-f5ggFnESIiucMOz6U6QNbpBSnfDA,2703
3
+ databricks360/_install.py,sha256=dYKW5JMlwTAX3PQMYyFmeefxVszrRUg0yN3hTndxX3s,5019
4
+ databricks360/_notebook.py,sha256=Y9pezGq_bI4ns1ceqChwY52gvBZU0VmEURYN84dzfN4,3653
5
+ databricks360/courses/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ databricks360/courses/genie_agents/01_catalog_and_schemas.sql,sha256=qLsiR0HmbCNM-jw6e0GRZkl6xmDMiwuBVQA1q8Iv4Sc,1778
7
+ databricks360/courses/genie_agents/02_dimensions.sql,sha256=VsWXSA4QQqW9DUd1au3JyWpZZ_GR8HsEo3-Pa2fStO8,16186
8
+ databricks360/courses/genie_agents/03_facts.sql,sha256=sryXtQDW-ztdDSUZCxwWKJLy6cSW1iRqPJWHjjQmyW0,15877
9
+ databricks360/courses/genie_agents/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ databricks360/courses/genie_agents/manifest.json,sha256=CSta9aQ9ObfuMC2PyjEnk36DDk_m-fnv4FvbAQYaBeE,2129
11
+ databricks360-0.1.0.dist-info/METADATA,sha256=iJjLnmmUdHh-yOlJfmSUBKkJQ5Wd0ao53Lv2OaDaVLI,5549
12
+ databricks360-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
13
+ databricks360-0.1.0.dist-info/licenses/LICENSE,sha256=NVi-ya6tGvg2-QS_3ThB4CzlyPDnWdGSsE09Ru_zn5k,1074
14
+ databricks360-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lakehouse Academy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.