diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..48c3e72 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,20 @@ +## Analytic purpose + +What business question or metric does this change address? + +## Changes + +- + +## Validation + +- [ ] Database builds from a clean directory +- [ ] Data-quality queries return zero failures +- [ ] Integration tests pass +- [ ] Saved outputs were regenerated when logic changed +- [ ] Metric definitions and interpretation remain aligned +- [ ] No real or restricted data are included + +## Interpretation impact + +Describe any change to a denominator, business rule, finding, or limitation. diff --git a/.github/workflows/validate-sql.yml b/.github/workflows/validate-sql.yml new file mode 100644 index 0000000..be45c58 --- /dev/null +++ b/.github/workflows/validate-sql.yml @@ -0,0 +1,24 @@ +name: Validate SQL case study + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + integration-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v7 + with: + python-version: "3.13" + - name: Build database and save query outputs + run: | + python scripts/build_demo_database.py + python scripts/run_queries.py + - name: Run integration tests + run: python -m unittest discover -s tests -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..283138d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +data/*.sqlite +__pycache__/ +*.pyc +.coverage diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..855f2f7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Matthew Jeans + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..33e867a --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +.PHONY: build query test all clean + +build: + python scripts/build_demo_database.py + +query: build + python scripts/run_queries.py + +test: + python -m unittest discover -s tests -v + +all: query test + +clean: + rm -f data/analytics_demo.sqlite diff --git a/data/README.md b/data/README.md new file mode 100644 index 0000000..db34778 --- /dev/null +++ b/data/README.md @@ -0,0 +1,12 @@ +# Generated database + +`scripts/build_demo_database.py` creates `analytics_demo.sqlite` in this directory. + +The generated database contains: + +- 8 fictional sites +- 480 fictional members +- 3,076 deterministic service-event records +- 252 deterministic support tickets + +The SQLite file is ignored because it is reproducible from source. Saved aggregate query outputs are committed in `outputs/` for quick review. diff --git a/docs/data-model.md b/docs/data-model.md new file mode 100644 index 0000000..e12e2c4 --- /dev/null +++ b/docs/data-model.md @@ -0,0 +1,39 @@ +# Data model + +## `sites` + +One row per service location. + +| Column | Type | Description | +|---|---|---| +| site_id | text | Primary key | +| site_name | text | Display name | +| region | text | North, South, East, or West | +| launch_date | date text | Site launch date | +| monthly_target | integer | Completed-service target | + +## `members` + +One row per member. + +| Column | Type | Description | +|---|---|---| +| member_id | text | Primary key | +| site_id | text | Foreign key to `sites` | +| signup_date | date text | Enrollment date | +| acquisition_channel | text | Referral, outreach, web, or partner | +| age_band | text | Nonidentifying analytic band | + +## `service_events` + +One row per scheduled service event. `completed` equals one when the service occurred. Duration is populated only for completed services. + +## `support_tickets` + +One row per support contact, with opened date, resolution hours, and optional satisfaction score. + +## Grain and joins + +Metric definitions preserve table grain explicitly. Member outcomes are first aggregated to one row per member, then joined to dimensions. Site-month metrics aggregate events before window calculations so repeated event rows do not inflate denominators. + +Dates use ISO-8601 text because SQLite's built-in date functions operate reliably on that representation. diff --git a/docs/decision-memo.md b/docs/decision-memo.md new file mode 100644 index 0000000..8e2ffa0 --- /dev/null +++ b/docs/decision-memo.md @@ -0,0 +1,49 @@ +# Decision memo + +## Executive summary + +The synthetic results identify three practical priorities: investigate the sustained activity decline at Summit, protect and learn from the stronger referral channel, and review support-resolution processes because slower resolution coincides with substantially weaker early retention. + +These are decision signals from generated data, not claims about a real program. + +## Findings + +### Summit requires operational review + +Summit completed 3 services in September compared with a prior three-month average of 30, a 90% decline. It remained the lowest-ranked site by target attainment in October, November, and December. The persistence of the signal makes a data-timing explanation less likely within this synthetic scenario, but the appropriate next step is still diagnosis rather than immediate corrective attribution. + +Recommended review: + +- confirm staffing, scheduling capacity, and service availability +- reconcile event capture against source-system counts +- examine cancellations and member mix +- document whether the September change reflects a known operational event + +### Referral is the strongest acquisition channel + +Across all signup months, referral members had a 65.5% 14-day activation rate and 58.1% 45-day retention rate. Outreach produced 40.0% activation and 31.8% retention. Partner and web channels fell between those groups. + +The channel mix should not be changed from these descriptive rates alone. Channel populations may differ. The next analysis should adjust for site, signup month, and available member characteristics, then examine cost per activated and retained member. + +### Slow support resolution is a useful risk signal + +Members in the fastest support-resolution quartile averaged 15.2 hours to resolution, 4.22 satisfaction, and 66.7% 45-day retention. The slowest quartile averaged 47.3 hours, 2.09 satisfaction, and 26.0% retention. + +This is an association, not evidence that faster support causes retention. Still, the gradient is large enough to justify an operational SLA review and a more careful analysis of ticket reason, member risk, site, and timing. + +### Longer-term cohort retention remains uneven + +Six-month active-member rates range from 40.7% to 54.9% across signup cohorts. The variation does not show a consistent upward pattern. A standardized onboarding and follow-up protocol could be tested prospectively, with retention definitions and analysis timing agreed before launch. + +## Recommended next actions + +| Priority | Action | Owner | Measure of progress | +|---|---|---|---| +| 1 | Complete a Summit root-cause review | Operations lead | Reconciled counts and documented cause | +| 2 | Model channel performance with adjustment and acquisition cost | Analytics lead | Cost per activated and retained member | +| 3 | Review support SLA and ticket categories | Support lead | Resolution distribution and repeat-contact rate | +| 4 | Design an onboarding improvement test | Program and analytics leads | Prespecified activation and retention outcomes | + +## Caveats + +The database is synthetic and deliberately encodes some performance differences. Results demonstrate SQL reasoning and communication, not causal inference or evidence about an actual organization. diff --git a/docs/metric-definitions.md b/docs/metric-definitions.md new file mode 100644 index 0000000..c3464f1 --- /dev/null +++ b/docs/metric-definitions.md @@ -0,0 +1,24 @@ +# Metric definitions + +| Metric | Definition | Grain | +|---|---|---| +| 14-day activation | At least one completed service from signup through day 14 | Member | +| 45-day retention | At least one completed service from day 15 through day 45 after signup | Member | +| Monthly active member | At least one completed service during a calendar month | Member-month | +| Completion rate | Completed events divided by all scheduled events | Site-month | +| Target attainment | Completed services divided by the site's monthly target | Site-month | +| Cohort retention | Active members at month N divided by original signup-cohort size | Cohort-month | +| Support-resolution quartile | Member's average resolution hours ranked across members with tickets | Member | +| Activity anomaly | Completed services at least 25% below the prior three-month average, with three prior months available | Site-month | + +## Denominator rules + +- Activation and retention denominators include every member in the relevant signup group. +- Event completion uses all scheduled events, not only members with completed events. +- Cohort size is fixed at signup and does not shrink when a member becomes inactive. +- Members with no support ticket are excluded from support quartiles and reported separately when needed. +- Months with zero events are materialized through the calendar view so absence is not mistaken for missing data. + +## Interpretation + +These definitions are operational, not causal. They are written before querying so business logic can be reviewed independently of the code. diff --git a/outputs/02_data_quality.csv b/outputs/02_data_quality.csv new file mode 100644 index 0000000..5b78087 --- /dev/null +++ b/outputs/02_data_quality.csv @@ -0,0 +1,7 @@ +check_name,failed_rows +duplicate_site_key,0 +duplicate_member_key,0 +orphan_service_member,0 +orphan_ticket_member,0 +service_before_signup,0 +invalid_completed_duration,0 diff --git a/outputs/03_activation_funnel.csv b/outputs/03_activation_funnel.csv new file mode 100644 index 0000000..b93b9b8 --- /dev/null +++ b/outputs/03_activation_funnel.csv @@ -0,0 +1,25 @@ +signup_month,acquisition_channel,signups,activated_members,activation_rate,retained_members,retention_rate_45d +2025-01-01,Partner,18,13,0.722,8,0.444 +2025-01-01,Web,20,12,0.6,7,0.35 +2025-01-01,Referral,24,14,0.583,14,0.583 +2025-01-01,Outreach,18,10,0.556,8,0.444 +2025-02-01,Referral,29,20,0.69,18,0.621 +2025-02-01,Partner,19,12,0.632,11,0.579 +2025-02-01,Web,20,10,0.5,5,0.25 +2025-02-01,Outreach,21,6,0.286,3,0.143 +2025-03-01,Referral,30,23,0.767,20,0.667 +2025-03-01,Partner,14,7,0.5,7,0.5 +2025-03-01,Web,25,12,0.48,9,0.36 +2025-03-01,Outreach,16,6,0.375,5,0.313 +2025-04-01,Partner,14,8,0.571,7,0.5 +2025-04-01,Outreach,15,7,0.467,7,0.467 +2025-04-01,Referral,16,7,0.438,5,0.313 +2025-04-01,Web,14,3,0.214,3,0.214 +2025-05-01,Referral,27,19,0.704,16,0.593 +2025-05-01,Partner,17,11,0.647,6,0.353 +2025-05-01,Outreach,16,8,0.5,5,0.313 +2025-05-01,Web,22,9,0.409,8,0.364 +2025-06-01,Referral,22,14,0.636,13,0.591 +2025-06-01,Web,28,13,0.464,11,0.393 +2025-06-01,Partner,11,5,0.455,5,0.455 +2025-06-01,Outreach,24,7,0.292,7,0.292 diff --git a/outputs/04_cohort_retention.csv b/outputs/04_cohort_retention.csv new file mode 100644 index 0000000..ec51d03 --- /dev/null +++ b/outputs/04_cohort_retention.csv @@ -0,0 +1,43 @@ +cohort_month,month_number,cohort_size,active_members,retention_rate +2025-01-01,0,80,47,0.588 +2025-01-01,1,80,58,0.725 +2025-01-01,2,80,48,0.6 +2025-01-01,3,80,45,0.563 +2025-01-01,4,80,41,0.512 +2025-01-01,5,80,35,0.438 +2025-01-01,6,80,35,0.438 +2025-02-01,0,89,36,0.404 +2025-02-01,1,89,62,0.697 +2025-02-01,2,89,48,0.539 +2025-02-01,3,89,46,0.517 +2025-02-01,4,89,48,0.539 +2025-02-01,5,89,52,0.584 +2025-02-01,6,89,42,0.472 +2025-03-01,0,85,47,0.553 +2025-03-01,1,85,68,0.8 +2025-03-01,2,85,47,0.553 +2025-03-01,3,85,50,0.588 +2025-03-01,4,85,40,0.471 +2025-03-01,5,85,50,0.588 +2025-03-01,6,85,40,0.471 +2025-04-01,0,59,28,0.475 +2025-04-01,1,59,48,0.814 +2025-04-01,2,59,27,0.458 +2025-04-01,3,59,27,0.458 +2025-04-01,4,59,29,0.492 +2025-04-01,5,59,25,0.424 +2025-04-01,6,59,24,0.407 +2025-05-01,0,82,43,0.524 +2025-05-01,1,82,58,0.707 +2025-05-01,2,82,49,0.598 +2025-05-01,3,82,44,0.537 +2025-05-01,4,82,34,0.415 +2025-05-01,5,82,46,0.561 +2025-05-01,6,82,45,0.549 +2025-06-01,0,85,38,0.447 +2025-06-01,1,85,65,0.765 +2025-06-01,2,85,46,0.541 +2025-06-01,3,85,44,0.518 +2025-06-01,4,85,43,0.506 +2025-06-01,5,85,40,0.471 +2025-06-01,6,85,37,0.435 diff --git a/outputs/05_site_performance.csv b/outputs/05_site_performance.csv new file mode 100644 index 0000000..6843398 --- /dev/null +++ b/outputs/05_site_performance.csv @@ -0,0 +1,97 @@ +site_id,site_name,region,month_start,scheduled_events,completed_services,active_members,completion_rate,target_attainment,prior_month_services,month_over_month_change,rolling_3m_services,monthly_site_rank +S001,Harbor,North,2025-01-01,15,15,10,1.0,0.273,,,15.0,1 +S005,Meadow,North,2025-01-01,7,7,7,1.0,0.14,,,7.0,2 +S007,Summit,East,2025-01-01,7,7,6,1.0,0.13,,,7.0,3 +S008,Park,West,2025-01-01,6,6,5,1.0,0.105,,,6.0,4 +S002,Riverside,South,2025-01-01,6,6,5,1.0,0.1,,,6.0,5 +S003,Cedar,East,2025-01-01,5,5,5,1.0,0.096,,,5.0,6 +S006,Juniper,South,2025-01-01,5,5,5,1.0,0.089,,,5.0,7 +S004,Lakeside,West,2025-01-01,4,4,4,1.0,0.069,,,4.0,8 +S003,Cedar,East,2025-02-01,25,24,16,0.96,0.462,5,3.8,14.5,1 +S001,Harbor,North,2025-02-01,27,24,17,0.889,0.436,15,0.6,19.5,2 +S005,Meadow,North,2025-02-01,20,18,14,0.9,0.36,7,1.571,12.5,3 +S007,Summit,East,2025-02-01,16,14,11,0.875,0.259,7,1.0,10.5,4 +S008,Park,West,2025-02-01,14,13,11,0.929,0.228,6,1.167,9.5,5 +S004,Lakeside,West,2025-02-01,15,12,10,0.8,0.207,4,2.0,8.0,6 +S006,Juniper,South,2025-02-01,12,11,7,0.917,0.196,5,1.2,8.0,7 +S002,Riverside,South,2025-02-01,11,11,8,1.0,0.183,6,0.833,8.5,8 +S005,Meadow,North,2025-03-01,37,34,21,0.919,0.68,18,0.889,19.7,1 +S007,Summit,East,2025-03-01,33,30,18,0.909,0.556,14,1.143,17.0,2 +S004,Lakeside,West,2025-03-01,34,32,22,0.941,0.552,12,1.667,16.0,3 +S001,Harbor,North,2025-03-01,31,28,21,0.903,0.509,24,0.167,22.3,4 +S003,Cedar,East,2025-03-01,28,25,23,0.893,0.481,24,0.042,18.0,5 +S008,Park,West,2025-03-01,29,26,20,0.897,0.456,13,1.0,15.0,6 +S006,Juniper,South,2025-03-01,23,20,15,0.87,0.357,11,0.818,12.0,7 +S002,Riverside,South,2025-03-01,21,17,17,0.81,0.283,11,0.545,11.3,8 +S008,Park,West,2025-04-01,39,37,25,0.949,0.649,26,0.423,25.3,1 +S007,Summit,East,2025-04-01,36,31,26,0.861,0.574,30,0.033,25.0,2 +S006,Juniper,South,2025-04-01,35,32,26,0.914,0.571,20,0.6,21.0,3 +S002,Riverside,South,2025-04-01,36,34,26,0.944,0.567,17,1.0,20.7,4 +S001,Harbor,North,2025-04-01,33,31,24,0.939,0.564,28,0.107,27.7,5 +S004,Lakeside,West,2025-04-01,35,32,24,0.914,0.552,32,0.0,25.3,6 +S005,Meadow,North,2025-04-01,29,24,20,0.828,0.48,34,-0.294,25.3,7 +S003,Cedar,East,2025-04-01,30,21,18,0.7,0.404,25,-0.16,23.3,8 +S003,Cedar,East,2025-05-01,43,38,31,0.884,0.731,21,0.81,28.0,1 +S002,Riverside,South,2025-05-01,45,41,35,0.911,0.683,34,0.206,30.7,2 +S007,Summit,East,2025-05-01,39,35,29,0.897,0.648,31,0.129,32.0,3 +S006,Juniper,South,2025-05-01,35,32,28,0.914,0.571,32,0.0,28.0,4 +S001,Harbor,North,2025-05-01,35,30,26,0.857,0.545,31,-0.032,29.7,5 +S004,Lakeside,West,2025-05-01,37,31,28,0.838,0.534,32,-0.031,31.7,6 +S008,Park,West,2025-05-01,37,29,28,0.784,0.509,37,-0.216,30.7,7 +S005,Meadow,North,2025-05-01,30,22,20,0.733,0.44,24,-0.083,26.7,8 +S004,Lakeside,West,2025-06-01,61,49,40,0.803,0.845,31,0.581,37.3,1 +S001,Harbor,North,2025-06-01,47,41,36,0.872,0.745,30,0.367,34.0,2 +S002,Riverside,South,2025-06-01,50,44,34,0.88,0.733,41,0.073,39.7,3 +S006,Juniper,South,2025-06-01,45,41,33,0.911,0.732,32,0.281,35.0,4 +S003,Cedar,East,2025-06-01,43,37,28,0.86,0.712,38,-0.026,32.0,5 +S008,Park,West,2025-06-01,41,38,37,0.927,0.667,29,0.31,34.7,6 +S005,Meadow,North,2025-06-01,31,28,24,0.903,0.56,22,0.273,24.7,7 +S007,Summit,East,2025-06-01,39,29,24,0.744,0.537,35,-0.171,31.7,8 +S001,Harbor,North,2025-07-01,60,47,41,0.783,0.855,41,0.146,39.3,1 +S008,Park,West,2025-07-01,50,43,35,0.86,0.754,38,0.132,36.7,2 +S004,Lakeside,West,2025-07-01,45,42,35,0.933,0.724,49,-0.143,40.7,3 +S006,Juniper,South,2025-07-01,48,40,38,0.833,0.714,41,-0.024,37.7,4 +S003,Cedar,East,2025-07-01,43,37,30,0.86,0.712,37,0.0,37.3,5 +S007,Summit,East,2025-07-01,44,35,30,0.795,0.648,29,0.207,33.0,6 +S002,Riverside,South,2025-07-01,42,36,33,0.857,0.6,44,-0.182,40.3,7 +S005,Meadow,North,2025-07-01,38,28,26,0.737,0.56,28,0.0,26.0,8 +S001,Harbor,North,2025-08-01,52,36,34,0.692,0.655,47,-0.234,41.3,1 +S004,Lakeside,West,2025-08-01,43,34,34,0.791,0.586,42,-0.19,41.7,2 +S002,Riverside,South,2025-08-01,43,35,34,0.814,0.583,36,-0.028,38.3,3 +S005,Meadow,North,2025-08-01,32,27,27,0.844,0.54,28,-0.036,27.7,4 +S003,Cedar,East,2025-08-01,39,28,27,0.718,0.538,37,-0.243,34.0,5 +S006,Juniper,South,2025-08-01,41,28,27,0.683,0.5,40,-0.3,36.3,6 +S007,Summit,East,2025-08-01,33,26,26,0.788,0.481,35,-0.257,30.0,7 +S008,Park,West,2025-08-01,30,25,25,0.833,0.439,43,-0.419,35.3,8 +S008,Park,West,2025-09-01,41,33,33,0.805,0.579,25,0.32,33.7,1 +S003,Cedar,East,2025-09-01,34,28,28,0.824,0.538,28,0.0,31.0,2 +S004,Lakeside,West,2025-09-01,36,31,31,0.861,0.534,34,-0.088,35.7,3 +S002,Riverside,South,2025-09-01,38,32,32,0.842,0.533,35,-0.086,34.3,4 +S006,Juniper,South,2025-09-01,39,29,29,0.744,0.518,28,0.036,32.3,5 +S005,Meadow,North,2025-09-01,28,25,25,0.893,0.5,27,-0.074,26.7,6 +S001,Harbor,North,2025-09-01,35,24,24,0.686,0.436,36,-0.333,35.7,7 +S007,Summit,East,2025-09-01,8,3,3,0.375,0.056,26,-0.885,21.3,8 +S001,Harbor,North,2025-10-01,44,35,35,0.795,0.636,24,0.458,31.7,1 +S004,Lakeside,West,2025-10-01,46,35,35,0.761,0.603,31,0.129,33.3,2 +S002,Riverside,South,2025-10-01,47,34,34,0.723,0.567,32,0.063,33.7,3 +S008,Park,West,2025-10-01,36,29,29,0.806,0.509,33,-0.121,29.0,4 +S005,Meadow,North,2025-10-01,32,24,24,0.75,0.48,25,-0.04,25.3,5 +S003,Cedar,East,2025-10-01,40,24,24,0.6,0.462,28,-0.143,26.7,6 +S006,Juniper,South,2025-10-01,31,24,24,0.774,0.429,29,-0.172,27.0,7 +S007,Summit,East,2025-10-01,11,6,6,0.545,0.111,3,1.0,11.7,8 +S008,Park,West,2025-11-01,45,34,34,0.756,0.596,29,0.172,32.0,1 +S004,Lakeside,West,2025-11-01,38,33,33,0.868,0.569,35,-0.057,33.0,2 +S001,Harbor,North,2025-11-01,34,28,28,0.824,0.509,35,-0.2,29.0,3 +S003,Cedar,East,2025-11-01,34,26,26,0.765,0.5,24,0.083,26.0,4 +S002,Riverside,South,2025-11-01,38,28,28,0.737,0.467,34,-0.176,31.3,5 +S005,Meadow,North,2025-11-01,29,22,22,0.759,0.44,24,-0.083,23.7,6 +S006,Juniper,South,2025-11-01,29,24,24,0.828,0.429,24,0.0,25.7,7 +S007,Summit,East,2025-11-01,5,4,4,0.8,0.074,6,-0.333,4.3,8 +S008,Park,West,2025-12-01,37,27,27,0.73,0.474,34,-0.206,30.0,1 +S001,Harbor,North,2025-12-01,34,26,26,0.765,0.473,28,-0.071,29.7,2 +S006,Juniper,South,2025-12-01,31,25,25,0.806,0.446,24,0.042,24.3,3 +S005,Meadow,North,2025-12-01,30,22,22,0.733,0.44,22,0.0,22.7,4 +S003,Cedar,East,2025-12-01,25,22,22,0.88,0.423,26,-0.154,24.0,5 +S004,Lakeside,West,2025-12-01,31,22,22,0.71,0.379,33,-0.333,30.0,6 +S002,Riverside,South,2025-12-01,28,22,22,0.786,0.367,28,-0.214,28.0,7 +S007,Summit,East,2025-12-01,17,5,5,0.294,0.093,4,0.25,5.0,8 diff --git a/outputs/06_support_experience.csv b/outputs/06_support_experience.csv new file mode 100644 index 0000000..b8ec0aa --- /dev/null +++ b/outputs/06_support_experience.csv @@ -0,0 +1,5 @@ +resolution_quartile,members_with_tickets,mean_resolution_hours,mean_satisfaction,mean_ticket_count,retention_rate_45d +1,51,15.2,4.22,1.25,0.667 +2,51,24.5,3.74,1.29,0.588 +3,51,34.0,3.15,1.29,0.353 +4,50,47.3,2.09,1.12,0.26 diff --git a/outputs/07_anomaly_review.csv b/outputs/07_anomaly_review.csv new file mode 100644 index 0000000..3c990a9 --- /dev/null +++ b/outputs/07_anomaly_review.csv @@ -0,0 +1,11 @@ +site_id,site_name,region,month_start,completed_services,prior_3m_average,relative_change,recommended_action +S007,Summit,East,2025-09-01,3,30.0,-0.9,Review operational context +S007,Summit,East,2025-10-01,6,21.3,-0.719,Review operational context +S007,Summit,East,2025-11-01,4,11.7,-0.657,Review operational context +S001,Harbor,North,2025-09-01,24,41.3,-0.419,Review operational context +S004,Lakeside,West,2025-12-01,22,33.0,-0.333,Review operational context +S008,Park,West,2025-08-01,25,36.7,-0.318,Review operational context +S002,Riverside,South,2025-12-01,22,31.3,-0.298,Review operational context +S006,Juniper,South,2025-10-01,24,32.3,-0.258,Review operational context +S006,Juniper,South,2025-08-01,28,37.7,-0.257,Review operational context +S004,Lakeside,West,2025-09-01,31,41.7,-0.256,Review operational context diff --git a/scripts/build_demo_database.py b/scripts/build_demo_database.py new file mode 100644 index 0000000..a38a9d3 --- /dev/null +++ b/scripts/build_demo_database.py @@ -0,0 +1,364 @@ +"""Build a deterministic SQLite database for the SQL case study.""" + +from __future__ import annotations + +import argparse +import random +import sqlite3 +from datetime import date, timedelta +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_DATABASE = ROOT / "data" / "analytics_demo.sqlite" + +SITES = [ + ("S001", "Harbor", "North", "2024-01-15", 55), + ("S002", "Riverside", "South", "2024-02-01", 60), + ("S003", "Cedar", "East", "2024-03-12", 52), + ("S004", "Lakeside", "West", "2024-01-22", 58), + ("S005", "Meadow", "North", "2024-04-05", 50), + ("S006", "Juniper", "South", "2024-02-18", 56), + ("S007", "Summit", "East", "2024-05-10", 54), + ("S008", "Park", "West", "2024-03-30", 57), +] + +CHANNELS = ["Referral", "Outreach", "Web", "Partner"] +AGE_BANDS = ["18-29", "30-44", "45-59", "60+"] +EVENT_TYPES = ["Orientation", "Coaching", "Check-in"] + +ACTIVATION_PROBABILITY = { + "Referral": 0.88, + "Partner": 0.80, + "Web": 0.72, + "Outreach": 0.65, +} +RETENTION_PROBABILITY = { + "Referral": 0.75, + "Partner": 0.69, + "Web": 0.60, + "Outreach": 0.55, +} +SITE_EFFECT = { + "S001": 0.05, + "S002": 0.02, + "S003": 0.00, + "S004": 0.03, + "S005": -0.02, + "S006": 0.01, + "S007": -0.03, + "S008": 0.02, +} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument( + "--database", + type=Path, + default=DEFAULT_DATABASE, + help="SQLite database to create.", + ) + return parser.parse_args() + + +def add_months(value: date, months: int) -> date: + month_index = value.year * 12 + value.month - 1 + months + year, zero_based_month = divmod(month_index, 12) + return date(year, zero_based_month + 1, 1) + + +def clamp_probability(value: float) -> float: + return max(0.05, min(0.95, value)) + + +def build_database(database: Path) -> None: + database.parent.mkdir(parents=True, exist_ok=True) + if database.exists(): + database.unlink() + + connection = sqlite3.connect(database) + connection.execute("PRAGMA foreign_keys = ON") + + schema = (ROOT / "sql" / "00_schema.sql").read_text( + encoding="utf-8" + ) + connection.executescript(schema) + connection.executemany( + """ + INSERT INTO sites ( + site_id, + site_name, + region, + launch_date, + monthly_target + ) + VALUES (?, ?, ?, ?, ?) + """, + SITES, + ) + + rng = random.Random(20260812) + member_rows: list[tuple[str, str, str, str, str]] = [] + event_rows: list[ + tuple[str, str, str, str, int | None, int] + ] = [] + ticket_rows: list[ + tuple[str, str, str, float, int | None] + ] = [] + + event_number = 1 + ticket_number = 1 + observation_end = date(2025, 12, 31) + + for member_number in range(1, 481): + member_id = f"M{member_number:04d}" + site_id = SITES[(member_number - 1) % len(SITES)][0] + signup_date = date(2025, 1, 1) + timedelta( + days=rng.randint(0, 180) + ) + channel = rng.choices( + CHANNELS, + weights=[30, 22, 28, 20], + k=1, + )[0] + age_band = rng.choices( + AGE_BANDS, + weights=[22, 38, 27, 13], + k=1, + )[0] + + member_rows.append( + ( + member_id, + site_id, + signup_date.isoformat(), + channel, + age_band, + ) + ) + + activation_probability = clamp_probability( + ACTIVATION_PROBABILITY[channel] + SITE_EFFECT[site_id] + ) + activated = rng.random() < activation_probability + first_delay = rng.randint(1, 21) + + if activated: + first_date = signup_date + timedelta(days=first_delay) + event_rows.append( + ( + f"E{event_number:06d}", + member_id, + first_date.isoformat(), + "Orientation", + rng.randint(25, 70), + 1, + ) + ) + event_number += 1 + + retention_probability = clamp_probability( + RETENTION_PROBABILITY[channel] + SITE_EFFECT[site_id] + ) + retained_45d = activated and ( + rng.random() < retention_probability + ) + if retained_45d: + retained_date = signup_date + timedelta( + days=rng.randint(15, 45) + ) + event_rows.append( + ( + f"E{event_number:06d}", + member_id, + retained_date.isoformat(), + "Coaching", + rng.randint(30, 90), + 1, + ) + ) + event_number += 1 + + first_month = add_months( + date(signup_date.year, signup_date.month, 1), + 1, + ) + month_start = first_month + month_index = 1 + + while month_start <= date(2025, 12, 1): + activity_probability = ( + 0.62 + + SITE_EFFECT[site_id] + + (0.06 if retained_45d else -0.12) + - 0.025 * month_index + ) + if site_id == "S007" and month_start >= date( + 2025, + 9, + 1, + ): + activity_probability *= 0.22 + + event_date = month_start + timedelta( + days=rng.randint(0, 26) + ) + if ( + event_date <= observation_end + and rng.random() + < clamp_probability(activity_probability) + ): + event_rows.append( + ( + f"E{event_number:06d}", + member_id, + event_date.isoformat(), + rng.choice(["Coaching", "Check-in"]), + rng.randint(20, 100), + 1, + ) + ) + event_number += 1 + + if event_date <= observation_end and rng.random() < 0.14: + event_rows.append( + ( + f"E{event_number:06d}", + member_id, + event_date.isoformat(), + rng.choice(EVENT_TYPES), + None, + 0, + ) + ) + event_number += 1 + + month_start = add_months(month_start, 1) + month_index += 1 + + if rng.random() < 0.40: + for _ in range(1 + int(rng.random() < 0.22)): + max_days = max( + 0, + (observation_end - signup_date).days, + ) + opened_date = signup_date + timedelta( + days=rng.randint(0, min(210, max_days)) + ) + base_resolution = { + "Referral": 17, + "Partner": 21, + "Web": 27, + "Outreach": 31, + }[channel] + resolution = max( + 0.5, + rng.gauss( + base_resolution + + (0 if retained_45d else 13) + + (8 if site_id == "S007" else 0), + 9, + ), + ) + satisfaction = round( + max( + 1, + min( + 5, + 5.2 + - resolution / 16 + + rng.gauss(0, 0.55), + ), + ) + ) + ticket_rows.append( + ( + f"T{ticket_number:05d}", + member_id, + opened_date.isoformat(), + round(resolution, 1), + satisfaction, + ) + ) + ticket_number += 1 + + connection.executemany( + """ + INSERT INTO members ( + member_id, + site_id, + signup_date, + acquisition_channel, + age_band + ) + VALUES (?, ?, ?, ?, ?) + """, + member_rows, + ) + connection.executemany( + """ + INSERT INTO service_events ( + event_id, + member_id, + event_date, + event_type, + duration_minutes, + completed + ) + VALUES (?, ?, ?, ?, ?, ?) + """, + event_rows, + ) + connection.executemany( + """ + INSERT INTO support_tickets ( + ticket_id, + member_id, + opened_date, + resolution_hours, + satisfaction_score + ) + VALUES (?, ?, ?, ?, ?) + """, + ticket_rows, + ) + + metric_views = ( + ROOT / "sql" / "01_metric_views.sql" + ).read_text(encoding="utf-8") + connection.executescript(metric_views) + connection.commit() + + foreign_key_issues = connection.execute( + "PRAGMA foreign_key_check" + ).fetchall() + if foreign_key_issues: + raise RuntimeError( + f"Foreign-key validation failed: {foreign_key_issues!r}" + ) + + counts = { + table: connection.execute( + f"SELECT COUNT(*) FROM {table}" + ).fetchone()[0] + for table in ( + "sites", + "members", + "service_events", + "support_tickets", + ) + } + connection.close() + + print( + "Built database with " + + ", ".join( + f"{count:,} {table}" + for table, count in counts.items() + ) + + "." + ) + + +if __name__ == "__main__": + build_database(parse_args().database) diff --git a/scripts/run_queries.py b/scripts/run_queries.py new file mode 100644 index 0000000..aaca1dc --- /dev/null +++ b/scripts/run_queries.py @@ -0,0 +1,86 @@ +"""Execute analytical SQL files and save deterministic CSV outputs.""" + +from __future__ import annotations + +import argparse +import csv +import sqlite3 +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_DATABASE = ROOT / "data" / "analytics_demo.sqlite" +DEFAULT_OUTPUT = ROOT / "outputs" + +QUERY_FILES = [ + "02_data_quality.sql", + "03_activation_funnel.sql", + "04_cohort_retention.sql", + "05_site_performance.sql", + "06_support_experience.sql", + "07_anomaly_review.sql", +] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument( + "--database", + type=Path, + default=DEFAULT_DATABASE, + ) + parser.add_argument( + "--output", + type=Path, + default=DEFAULT_OUTPUT, + ) + return parser.parse_args() + + +def run_queries(database: Path, output: Path) -> None: + if not database.exists(): + raise FileNotFoundError( + f"Database not found: {database}. Build it first." + ) + + output.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(database) + connection.row_factory = sqlite3.Row + + for query_name in QUERY_FILES: + query_path = ROOT / "sql" / query_name + sql = query_path.read_text(encoding="utf-8") + rows = connection.execute(sql).fetchall() + + output_path = output / query_name.replace(".sql", ".csv") + with output_path.open( + "w", + newline="", + encoding="utf-8", + ) as handle: + writer = csv.writer(handle) + if rows: + writer.writerow(rows[0].keys()) + writer.writerows( + [tuple(row) for row in rows] + ) + else: + description = connection.execute(sql).description + writer.writerow( + column[0] for column in description + ) + + try: + display_path = output_path.relative_to(ROOT) + except ValueError: + display_path = output_path + + print( + f"Wrote {len(rows):,} rows to {display_path}." + ) + + connection.close() + + +if __name__ == "__main__": + arguments = parse_args() + run_queries(arguments.database, arguments.output) diff --git a/sql/00_schema.sql b/sql/00_schema.sql new file mode 100644 index 0000000..3582c0c --- /dev/null +++ b/sql/00_schema.sql @@ -0,0 +1,87 @@ +PRAGMA foreign_keys = ON; + +DROP TABLE IF EXISTS support_tickets; +DROP TABLE IF EXISTS service_events; +DROP TABLE IF EXISTS members; +DROP TABLE IF EXISTS sites; + +CREATE TABLE sites ( + site_id TEXT PRIMARY KEY, + site_name TEXT NOT NULL, + region TEXT NOT NULL + CHECK (region IN ('North', 'South', 'East', 'West')), + launch_date TEXT NOT NULL, + monthly_target INTEGER NOT NULL + CHECK (monthly_target > 0) +); + +CREATE TABLE members ( + member_id TEXT PRIMARY KEY, + site_id TEXT NOT NULL, + signup_date TEXT NOT NULL, + acquisition_channel TEXT NOT NULL + CHECK ( + acquisition_channel IN ( + 'Referral', + 'Outreach', + 'Web', + 'Partner' + ) + ), + age_band TEXT NOT NULL + CHECK ( + age_band IN ( + '18-29', + '30-44', + '45-59', + '60+' + ) + ), + FOREIGN KEY (site_id) REFERENCES sites (site_id) +); + +CREATE TABLE service_events ( + event_id TEXT PRIMARY KEY, + member_id TEXT NOT NULL, + event_date TEXT NOT NULL, + event_type TEXT NOT NULL + CHECK ( + event_type IN ( + 'Orientation', + 'Coaching', + 'Check-in' + ) + ), + duration_minutes INTEGER, + completed INTEGER NOT NULL + CHECK (completed IN (0, 1)), + CHECK ( + (completed = 0 AND duration_minutes IS NULL) + OR + ( + completed = 1 + AND duration_minutes BETWEEN 10 AND 180 + ) + ), + FOREIGN KEY (member_id) REFERENCES members (member_id) +); + +CREATE TABLE support_tickets ( + ticket_id TEXT PRIMARY KEY, + member_id TEXT NOT NULL, + opened_date TEXT NOT NULL, + resolution_hours REAL NOT NULL + CHECK (resolution_hours >= 0), + satisfaction_score INTEGER + CHECK (satisfaction_score BETWEEN 1 AND 5), + FOREIGN KEY (member_id) REFERENCES members (member_id) +); + +CREATE INDEX idx_members_site + ON members (site_id); + +CREATE INDEX idx_events_member_date + ON service_events (member_id, event_date); + +CREATE INDEX idx_tickets_member + ON support_tickets (member_id); diff --git a/sql/01_metric_views.sql b/sql/01_metric_views.sql new file mode 100644 index 0000000..d337b4a --- /dev/null +++ b/sql/01_metric_views.sql @@ -0,0 +1,127 @@ +DROP VIEW IF EXISTS site_monthly_metrics; +DROP VIEW IF EXISTS calendar_months; +DROP VIEW IF EXISTS member_activity_metrics; + +CREATE VIEW member_activity_metrics AS +SELECT + members.member_id, + members.site_id, + members.signup_date, + members.acquisition_channel, + members.age_band, + CASE + WHEN EXISTS ( + SELECT 1 + FROM service_events + WHERE + service_events.member_id = members.member_id + AND service_events.completed = 1 + AND service_events.event_date BETWEEN + members.signup_date + AND date(members.signup_date, '+14 days') + ) + THEN 1 + ELSE 0 + END AS activated_14d, + CASE + WHEN EXISTS ( + SELECT 1 + FROM service_events + WHERE + service_events.member_id = members.member_id + AND service_events.completed = 1 + AND service_events.event_date BETWEEN + members.signup_date + AND date(members.signup_date, '+14 days') + ) + AND EXISTS ( + SELECT 1 + FROM service_events + WHERE + service_events.member_id = members.member_id + AND service_events.completed = 1 + AND service_events.event_date BETWEEN + date(members.signup_date, '+15 days') + AND date(members.signup_date, '+45 days') + ) + THEN 1 + ELSE 0 + END AS retained_45d, + ( + SELECT MIN(service_events.event_date) + FROM service_events + WHERE + service_events.member_id = members.member_id + AND service_events.completed = 1 + ) AS first_completed_service +FROM members; + +CREATE VIEW calendar_months AS +WITH RECURSIVE months(month_start) AS ( + SELECT date('2025-01-01') + UNION ALL + SELECT date(month_start, '+1 month') + FROM months + WHERE month_start < date('2025-12-01') +) +SELECT month_start +FROM months; + +CREATE VIEW site_monthly_metrics AS +WITH event_rollup AS ( + SELECT + members.site_id, + date( + service_events.event_date, + 'start of month' + ) AS month_start, + COUNT(*) AS scheduled_events, + SUM(service_events.completed) AS completed_services, + COUNT( + DISTINCT CASE + WHEN service_events.completed = 1 + THEN service_events.member_id + END + ) AS active_members, + AVG( + CASE + WHEN service_events.completed = 1 + THEN service_events.duration_minutes + END + ) AS avg_completed_minutes + FROM service_events + INNER JOIN members + ON service_events.member_id = members.member_id + GROUP BY + members.site_id, + date(service_events.event_date, 'start of month') +) +SELECT + sites.site_id, + sites.site_name, + sites.region, + calendar_months.month_start, + sites.monthly_target, + COALESCE(event_rollup.scheduled_events, 0) + AS scheduled_events, + COALESCE(event_rollup.completed_services, 0) + AS completed_services, + COALESCE(event_rollup.active_members, 0) + AS active_members, + ROUND(event_rollup.avg_completed_minutes, 1) + AS avg_completed_minutes, + ROUND( + 1.0 * COALESCE(event_rollup.completed_services, 0) + / NULLIF(event_rollup.scheduled_events, 0), + 3 + ) AS completion_rate, + ROUND( + 1.0 * COALESCE(event_rollup.completed_services, 0) + / sites.monthly_target, + 3 + ) AS target_attainment +FROM sites +CROSS JOIN calendar_months +LEFT JOIN event_rollup + ON sites.site_id = event_rollup.site_id + AND calendar_months.month_start = event_rollup.month_start; diff --git a/sql/02_data_quality.sql b/sql/02_data_quality.sql new file mode 100644 index 0000000..7025062 --- /dev/null +++ b/sql/02_data_quality.sql @@ -0,0 +1,64 @@ +SELECT + 'duplicate_site_key' AS check_name, + COUNT(*) AS failed_rows +FROM ( + SELECT site_id + FROM sites + GROUP BY site_id + HAVING COUNT(*) > 1 +) + +UNION ALL + +SELECT + 'duplicate_member_key', + COUNT(*) +FROM ( + SELECT member_id + FROM members + GROUP BY member_id + HAVING COUNT(*) > 1 +) + +UNION ALL + +SELECT + 'orphan_service_member', + COUNT(*) +FROM service_events +LEFT JOIN members + ON service_events.member_id = members.member_id +WHERE members.member_id IS NULL + +UNION ALL + +SELECT + 'orphan_ticket_member', + COUNT(*) +FROM support_tickets +LEFT JOIN members + ON support_tickets.member_id = members.member_id +WHERE members.member_id IS NULL + +UNION ALL + +SELECT + 'service_before_signup', + COUNT(*) +FROM service_events +INNER JOIN members + ON service_events.member_id = members.member_id +WHERE service_events.event_date < members.signup_date + +UNION ALL + +SELECT + 'invalid_completed_duration', + COUNT(*) +FROM service_events +WHERE + completed = 1 + AND ( + duration_minutes IS NULL + OR duration_minutes NOT BETWEEN 10 AND 180 + ); diff --git a/sql/03_activation_funnel.sql b/sql/03_activation_funnel.sql new file mode 100644 index 0000000..3e788b5 --- /dev/null +++ b/sql/03_activation_funnel.sql @@ -0,0 +1,30 @@ +WITH member_metrics AS ( + SELECT + date(signup_date, 'start of month') AS signup_month, + acquisition_channel, + activated_14d, + retained_45d + FROM member_activity_metrics +) +SELECT + signup_month, + acquisition_channel, + COUNT(*) AS signups, + SUM(activated_14d) AS activated_members, + ROUND( + 1.0 * SUM(activated_14d) / COUNT(*), + 3 + ) AS activation_rate, + SUM(retained_45d) AS retained_members, + ROUND( + 1.0 * SUM(retained_45d) / COUNT(*), + 3 + ) AS retention_rate_45d +FROM member_metrics +GROUP BY + signup_month, + acquisition_channel +ORDER BY + signup_month, + activation_rate DESC, + acquisition_channel; diff --git a/sql/04_cohort_retention.sql b/sql/04_cohort_retention.sql new file mode 100644 index 0000000..bea0875 --- /dev/null +++ b/sql/04_cohort_retention.sql @@ -0,0 +1,73 @@ +WITH RECURSIVE month_offsets(month_number) AS ( + SELECT 0 + UNION ALL + SELECT month_number + 1 + FROM month_offsets + WHERE month_number < 6 +), +cohort_members AS ( + SELECT + member_id, + date(signup_date, 'start of month') AS cohort_month + FROM members +), +cohort_sizes AS ( + SELECT + cohort_month, + COUNT(*) AS cohort_size + FROM cohort_members + GROUP BY cohort_month +), +completed_member_months AS ( + SELECT DISTINCT + service_events.member_id, + date( + service_events.event_date, + 'start of month' + ) AS activity_month + FROM service_events + WHERE service_events.completed = 1 +), +cohort_grid AS ( + SELECT + cohort_sizes.cohort_month, + cohort_sizes.cohort_size, + month_offsets.month_number, + date( + cohort_sizes.cohort_month, + printf('+%d months', month_offsets.month_number) + ) AS activity_month + FROM cohort_sizes + CROSS JOIN month_offsets + WHERE + date( + cohort_sizes.cohort_month, + printf('+%d months', month_offsets.month_number) + ) <= date('2025-12-01') +) +SELECT + cohort_grid.cohort_month, + cohort_grid.month_number, + cohort_grid.cohort_size, + COUNT(DISTINCT completed_member_months.member_id) + AS active_members, + ROUND( + 1.0 * COUNT(DISTINCT completed_member_months.member_id) + / cohort_grid.cohort_size, + 3 + ) AS retention_rate +FROM cohort_grid +LEFT JOIN cohort_members + ON cohort_grid.cohort_month = cohort_members.cohort_month +LEFT JOIN completed_member_months + ON cohort_members.member_id = + completed_member_months.member_id + AND cohort_grid.activity_month = + completed_member_months.activity_month +GROUP BY + cohort_grid.cohort_month, + cohort_grid.month_number, + cohort_grid.cohort_size +ORDER BY + cohort_grid.cohort_month, + cohort_grid.month_number; diff --git a/sql/05_site_performance.sql b/sql/05_site_performance.sql new file mode 100644 index 0000000..488e5f0 --- /dev/null +++ b/sql/05_site_performance.sql @@ -0,0 +1,54 @@ +WITH performance AS ( + SELECT + site_id, + site_name, + region, + month_start, + monthly_target, + scheduled_events, + completed_services, + active_members, + completion_rate, + target_attainment, + LAG(completed_services) OVER ( + PARTITION BY site_id + ORDER BY month_start + ) AS prior_month_services, + AVG(completed_services) OVER ( + PARTITION BY site_id + ORDER BY month_start + ROWS BETWEEN 2 PRECEDING AND CURRENT ROW + ) AS rolling_3m_services + FROM site_monthly_metrics +) +SELECT + site_id, + site_name, + region, + month_start, + scheduled_events, + completed_services, + active_members, + completion_rate, + target_attainment, + prior_month_services, + ROUND( + CASE + WHEN prior_month_services > 0 + THEN 1.0 + * (completed_services - prior_month_services) + / prior_month_services + END, + 3 + ) AS month_over_month_change, + ROUND(rolling_3m_services, 1) + AS rolling_3m_services, + RANK() OVER ( + PARTITION BY month_start + ORDER BY target_attainment DESC + ) AS monthly_site_rank +FROM performance +ORDER BY + month_start, + monthly_site_rank, + site_id; diff --git a/sql/06_support_experience.sql b/sql/06_support_experience.sql new file mode 100644 index 0000000..746bab9 --- /dev/null +++ b/sql/06_support_experience.sql @@ -0,0 +1,37 @@ +WITH member_support AS ( + SELECT + support_tickets.member_id, + AVG(support_tickets.resolution_hours) + AS avg_resolution_hours, + AVG(support_tickets.satisfaction_score) + AS avg_satisfaction, + COUNT(*) AS ticket_count + FROM support_tickets + GROUP BY support_tickets.member_id +), +ranked_support AS ( + SELECT + member_support.*, + member_activity_metrics.retained_45d, + NTILE(4) OVER ( + ORDER BY member_support.avg_resolution_hours + ) AS resolution_quartile + FROM member_support + INNER JOIN member_activity_metrics + ON member_support.member_id = + member_activity_metrics.member_id +) +SELECT + resolution_quartile, + COUNT(*) AS members_with_tickets, + ROUND(AVG(avg_resolution_hours), 1) + AS mean_resolution_hours, + ROUND(AVG(avg_satisfaction), 2) + AS mean_satisfaction, + ROUND(AVG(ticket_count), 2) + AS mean_ticket_count, + ROUND(AVG(retained_45d), 3) + AS retention_rate_45d +FROM ranked_support +GROUP BY resolution_quartile +ORDER BY resolution_quartile; diff --git a/sql/07_anomaly_review.sql b/sql/07_anomaly_review.sql new file mode 100644 index 0000000..a39135c --- /dev/null +++ b/sql/07_anomaly_review.sql @@ -0,0 +1,52 @@ +WITH rolling_history AS ( + SELECT + site_id, + site_name, + region, + month_start, + completed_services, + AVG(completed_services) OVER ( + PARTITION BY site_id + ORDER BY month_start + ROWS BETWEEN 3 PRECEDING AND 1 PRECEDING + ) AS prior_3m_average, + COUNT(*) OVER ( + PARTITION BY site_id + ORDER BY month_start + ROWS BETWEEN 3 PRECEDING AND 1 PRECEDING + ) AS prior_month_count + FROM site_monthly_metrics +), +flagged AS ( + SELECT + site_id, + site_name, + region, + month_start, + completed_services, + ROUND(prior_3m_average, 1) + AS prior_3m_average, + ROUND( + 1.0 * (completed_services - prior_3m_average) + / NULLIF(prior_3m_average, 0), + 3 + ) AS relative_change + FROM rolling_history + WHERE + prior_month_count = 3 + AND completed_services < 0.75 * prior_3m_average +) +SELECT + site_id, + site_name, + region, + month_start, + completed_services, + prior_3m_average, + relative_change, + 'Review operational context' AS recommended_action +FROM flagged +ORDER BY + relative_change, + site_id, + month_start; diff --git a/tests/test_case_study.py b/tests/test_case_study.py new file mode 100644 index 0000000..8f09af5 --- /dev/null +++ b/tests/test_case_study.py @@ -0,0 +1,146 @@ +"""Integration tests for the generated database and analytical outputs.""" + +from __future__ import annotations + +import csv +import sqlite3 +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +class SqlCaseStudyTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.temporary_directory = tempfile.TemporaryDirectory() + temporary_root = Path(cls.temporary_directory.name) + cls.database = temporary_root / "case-study.sqlite" + cls.output = temporary_root / "outputs" + + subprocess.run( + [ + sys.executable, + str(ROOT / "scripts" / "build_demo_database.py"), + "--database", + str(cls.database), + ], + check=True, + cwd=ROOT, + ) + subprocess.run( + [ + sys.executable, + str(ROOT / "scripts" / "run_queries.py"), + "--database", + str(cls.database), + "--output", + str(cls.output), + ], + check=True, + cwd=ROOT, + ) + + @classmethod + def tearDownClass(cls) -> None: + cls.temporary_directory.cleanup() + + def test_database_grain_and_integrity(self) -> None: + connection = sqlite3.connect(self.database) + self.assertEqual( + connection.execute( + "SELECT COUNT(*) FROM sites" + ).fetchone()[0], + 8, + ) + self.assertEqual( + connection.execute( + "SELECT COUNT(*) FROM members" + ).fetchone()[0], + 480, + ) + self.assertGreater( + connection.execute( + "SELECT COUNT(*) FROM service_events" + ).fetchone()[0], + 1000, + ) + self.assertEqual( + connection.execute( + "PRAGMA foreign_key_check" + ).fetchall(), + [], + ) + connection.close() + + def test_quality_query_has_zero_failures(self) -> None: + with ( + self.output / "02_data_quality.csv" + ).open(newline="", encoding="utf-8") as handle: + rows = list(csv.DictReader(handle)) + + self.assertTrue(rows) + self.assertTrue( + all(int(row["failed_rows"]) == 0 for row in rows) + ) + + def test_rates_are_bounded(self) -> None: + with ( + self.output / "03_activation_funnel.csv" + ).open(newline="", encoding="utf-8") as handle: + rows = list(csv.DictReader(handle)) + + self.assertTrue(rows) + for row in rows: + self.assertGreaterEqual( + float(row["activation_rate"]), + 0, + ) + self.assertLessEqual( + float(row["activation_rate"]), + 1, + ) + self.assertGreaterEqual( + float(row["retention_rate_45d"]), + 0, + ) + self.assertLessEqual( + float(row["retention_rate_45d"]), + 1, + ) + + def test_anomaly_query_identifies_summit(self) -> None: + with ( + self.output / "07_anomaly_review.csv" + ).open(newline="", encoding="utf-8") as handle: + rows = list(csv.DictReader(handle)) + + self.assertTrue(rows) + self.assertIn( + "S007", + {row["site_id"] for row in rows}, + ) + + def test_saved_output_contract(self) -> None: + expected = { + query.replace(".sql", ".csv") + for query in ( + "02_data_quality.sql", + "03_activation_funnel.sql", + "04_cohort_retention.sql", + "05_site_performance.sql", + "06_support_experience.sql", + "07_anomaly_review.sql", + ) + } + self.assertEqual( + {path.name for path in self.output.glob("*.csv")}, + expected, + ) + + +if __name__ == "__main__": + unittest.main()