Compare commits
129
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a1b1973ab | ||
|
|
c27ac500db | ||
|
|
706601950c | ||
|
|
0dd6fe35ed | ||
|
|
59095c952f | ||
|
|
64c772e216 | ||
|
|
c7396a5fd4 | ||
|
|
82f719f734 | ||
|
|
9e323ee4c4 | ||
|
|
127715abfb | ||
|
|
84b2336a18 | ||
|
|
b300f8d733 | ||
|
|
780d24a491 | ||
|
|
28f6c9f89e | ||
|
|
172a9c6965 | ||
|
|
8579907491 | ||
|
|
20c5086509 | ||
|
|
93365efdc4 | ||
|
|
24cba59620 | ||
|
|
2f75dd84cd | ||
|
|
4fa625500d | ||
|
|
a55915a310 | ||
|
|
524a985378 | ||
|
|
af4974de7e | ||
|
|
522c023671 | ||
|
|
338f9f38ef | ||
|
|
5fd6d1dc1e | ||
|
|
96340594db | ||
|
|
b446d243ec | ||
|
|
d72b1f3cf4 | ||
|
|
d3fbb70d4b | ||
|
|
0fb13773a7 | ||
|
|
7b3c44f56d | ||
|
|
259969d252 | ||
|
|
dd37c2b4f7 | ||
|
|
288eaa8ccb | ||
|
|
f20c90a91c | ||
|
|
b107f4958c | ||
|
|
d8742a04b7 | ||
|
|
7aa0b1f40f | ||
|
|
ee5b9f7bcb | ||
|
|
f10ca11257 | ||
|
|
d91a5c7c9e | ||
|
|
260dfabc9c | ||
|
|
376add89a9 | ||
|
|
efb99a6875 | ||
|
|
433d2b6ad4 | ||
|
|
8ff7fb63e6 | ||
|
|
d83f47556f | ||
|
|
70bf848fa3 | ||
|
|
4e7959076e | ||
|
|
51440bc030 | ||
|
|
e47d677360 | ||
|
|
751a7f17fa | ||
|
|
4348215ebd | ||
|
|
b23591be41 | ||
|
|
35ed25f8bb | ||
|
|
cc3aa74b46 | ||
|
|
7280c9a623 | ||
|
|
6848c6ca7e | ||
|
|
c54c12b141 | ||
|
|
0986654ea7 | ||
|
|
04aa189f8a | ||
|
|
82a6d16d59 | ||
|
|
173c1a37e7 | ||
|
|
80ea5b1c2c | ||
|
|
403da45ff1 | ||
|
|
32094e7049 | ||
|
|
475af38bee | ||
|
|
7983a30f4e | ||
|
|
d07972b407 | ||
|
|
f1d4cac578 | ||
|
|
ad6714001b | ||
|
|
b1873be1f3 | ||
|
|
bea242adaf | ||
|
|
10f5f93dc3 | ||
|
|
df80ad6c65 | ||
|
|
2eec0c6dcb | ||
|
|
216ca36215 | ||
|
|
1f15d89f89 | ||
|
|
a9e2cbf36a | ||
|
|
2d1c1b0d58 | ||
|
|
5000bf8e3c | ||
|
|
730c03a868 | ||
|
|
4604e26540 | ||
|
|
ae8fc209be | ||
|
|
421ec655e9 | ||
|
|
7aa4e6711f | ||
|
|
8cac765335 | ||
|
|
df71a6b41c | ||
|
|
55909de2b5 | ||
|
|
32d2c5add9 | ||
|
|
d2279a776e | ||
|
|
e956d0a6bf | ||
|
|
1ad22709b3 | ||
|
|
26d8fff5b2 | ||
|
|
7f5273b800 | ||
|
|
59c77c919d | ||
|
|
1c501fa0dc | ||
|
|
14d8dee519 | ||
|
|
92befd869c | ||
|
|
427d9896f4 | ||
|
|
34303b27e3 | ||
|
|
fbb2a57fbf | ||
|
|
3acd847a4e | ||
|
|
cafce77e62 | ||
|
|
2d918be1ff | ||
|
|
7340422162 | ||
|
|
332539a898 | ||
|
|
6d9984acde | ||
|
|
a5f12ddab1 | ||
|
|
a6803016fa | ||
|
|
e5d5886354 | ||
|
|
a4c5d3fb91 | ||
|
|
2702c8104c | ||
|
|
c683d2aba4 | ||
|
|
81e54db2fc | ||
|
|
9f6a5b44f8 | ||
|
|
02491d3c4e | ||
|
|
c2de0fa819 | ||
|
|
a99440ea2a | ||
|
|
40f9cea0a0 | ||
|
|
fd4a98cb22 | ||
|
|
3f099eb33c | ||
|
|
5a6e2cfdaa | ||
|
|
b76003d28f | ||
|
|
9895018443 | ||
|
|
48ef81f14a | ||
|
|
cbecc2b34d |
+62
-2
@@ -16,6 +16,66 @@ hugo.linux
|
||||
# Temporary lock file while building
|
||||
/.hugo_build.lock
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/hugo
|
||||
# Shared CSS/JS dependencies generated by statdown/depkit
|
||||
/static/libs/
|
||||
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/R
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=R
|
||||
|
||||
### R ###
|
||||
# History files
|
||||
.Rhistory
|
||||
.Rapp.history
|
||||
|
||||
# Session Data files
|
||||
.RData
|
||||
.RDataTmp
|
||||
|
||||
# User-specific files
|
||||
.Ruserdata
|
||||
|
||||
# Example code in package build process
|
||||
*-Ex.R
|
||||
|
||||
# Output files from R CMD build
|
||||
/*.tar.gz
|
||||
|
||||
# Output files from R CMD check
|
||||
/*.Rcheck/
|
||||
|
||||
# RStudio files
|
||||
.Rproj.user/
|
||||
|
||||
# produced vignettes
|
||||
vignettes/*.html
|
||||
vignettes/*.pdf
|
||||
|
||||
# OAuth2 token, see https://github.com/hadley/httr/releases/tag/v0.3
|
||||
.httr-oauth
|
||||
|
||||
# knitr and R markdown default cache directories
|
||||
*_cache/
|
||||
/cache/
|
||||
|
||||
# Temporary files created by R markdown
|
||||
*.utf8.md
|
||||
*.knit.md
|
||||
|
||||
# R Environment Variables
|
||||
.Renviron
|
||||
|
||||
# pkgdown site
|
||||
docs/
|
||||
|
||||
# translation temp files
|
||||
po/*~
|
||||
|
||||
# RStudio Connect folder
|
||||
rsconnect/
|
||||
|
||||
### R.Bookdown Stack ###
|
||||
# R package: bookdown caching files
|
||||
/*_files/
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/R
|
||||
|
||||
.build_sentinel
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
Type: project
|
||||
Package: website
|
||||
Title: Andrew Stryker's Hugo Blog
|
||||
Version: 0.0.1
|
||||
Depends:
|
||||
statdown,
|
||||
knitr,
|
||||
tidyverse,
|
||||
reactable,
|
||||
svglite
|
||||
Remotes:
|
||||
andrewjstryker/statdown,
|
||||
andrewjstryker/depkit
|
||||
@@ -34,7 +34,10 @@ default: build
|
||||
|
||||
build: .build_sentinel #> Build site with Hugo (default)
|
||||
|
||||
publish: build #> Publish site
|
||||
publish: #> Publish site
|
||||
@echo "🏗️ Forcing clean build for publish"
|
||||
@rm -f .build_sentinel
|
||||
@$(MAKE) build
|
||||
@echo "📰Publishing..."
|
||||
@# rsync options:
|
||||
@# verbose: show each operation
|
||||
@@ -42,7 +45,7 @@ publish: build #> Publish site
|
||||
@# safe-links: ignore symlinks that point outside of tree
|
||||
@# times: preserve modification times
|
||||
@# delete: delete extraneous files, i.e., files on destination
|
||||
@# chmod: set permsions
|
||||
@# chmod: set permissions
|
||||
@echo "\t 📡 Copying from public to ${DEST}"
|
||||
@rsync \
|
||||
--verbose \
|
||||
@@ -51,13 +54,12 @@ publish: build #> Publish site
|
||||
--safe-links \
|
||||
--times \
|
||||
--delete \
|
||||
--cvs-exclude \
|
||||
--chmod=D755,F644 \
|
||||
public/ \
|
||||
${DEST}
|
||||
@echo "\t 🛡️ Setting permissions"
|
||||
@ssh axs@sdf.org 'mkhomepg -p'
|
||||
@echo "✓ Publising complete"
|
||||
@echo "✓ Publishing complete"
|
||||
@echo "\nThe site should be available on ${SITE_URL}"
|
||||
|
||||
serve: #> Start a Hugo server
|
||||
@@ -74,17 +76,42 @@ serve: #> Start a Hugo server
|
||||
help: #> Generate this help message
|
||||
@gawk -f ${help_generator} $(MAKEFILE_LIST)
|
||||
|
||||
#-----------------------------------------------------------------------------#
|
||||
#
|
||||
# Rmd rendering via statdown
|
||||
#
|
||||
#-----------------------------------------------------------------------------#
|
||||
|
||||
# Discover all Rmd source files and derive their .md targets
|
||||
RMD_SOURCES := $(wildcard content/posts/*.Rmd content/posts/*/*.Rmd content/demos/*/*.Rmd)
|
||||
MD_TARGETS := $(RMD_SOURCES:.Rmd=.md)
|
||||
|
||||
# Shared asset location for CSS/JS dependencies (via depkit)
|
||||
LIBS_DIR := static/libs
|
||||
LIBS_URL := /libs
|
||||
|
||||
# Pattern rule: render .md from .Rmd using statdown
|
||||
# Re-render when renv.lock changes (package version update)
|
||||
# Assets are written to static/libs/ so they are shared across posts
|
||||
%.md: %.Rmd renv.lock
|
||||
@echo "🔄 Rendering $<"
|
||||
cd $(dir $<) && Rscript -e 'renv::load("$(CURDIR)"); statdown::statdown_render("$(notdir $<)", output_root = "$(CURDIR)/$(LIBS_DIR)", url_root = "$(LIBS_URL)")'
|
||||
|
||||
#-----------------------------------------------------------------------------#
|
||||
#
|
||||
# Define file interface
|
||||
#
|
||||
#-----------------------------------------------------------------------------#
|
||||
|
||||
.build_sentinel: $(wildcard content/*/*)
|
||||
# Rebuild when rendered Rmd targets, markdown, or page bundle assets change
|
||||
CONTENT_FILES := $(shell find content -name '*.md' -o -name '*.html' -o -name '*.svg' -o -name '*.png' -o -name '*.jpg' | grep -v '*~')
|
||||
LAYOUT_FILES := $(shell find layouts -name '*.html' | grep -v '*~')
|
||||
|
||||
.build_sentinel: $(MD_TARGETS) $(CONTENT_FILES) $(LAYOUT_FILES) hugo.yaml
|
||||
@echo "\t 🏗️ Building site"
|
||||
@# We call hugo with two options:
|
||||
@# --cleanDestinationDir, to remove deleted files
|
||||
@# --minify, to compress files my removing extra whitespace
|
||||
@# --minify, to compress files by removing extra whitespace
|
||||
hugo --cleanDestinationDir --minify
|
||||
@touch $@
|
||||
@echo "✓ Building complete"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: "{{ replace .Name "-" " " | title }}"
|
||||
date: {{ .Date }}
|
||||
slug: "{{ .Name }}"
|
||||
tags: []
|
||||
categories: []
|
||||
draft: true
|
||||
---
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
title: "{{ replace .Name "-" " " | title }}"
|
||||
date: {{ .Date }}
|
||||
slug: "{{ .Name }}"
|
||||
tags: [ R ]
|
||||
categories: []
|
||||
draft: true
|
||||
---
|
||||
|
||||
```{r setup, include=FALSE}
|
||||
library(tidyverse)
|
||||
library(reactable)
|
||||
library(htmltools)
|
||||
|
||||
knitr::opts_chunk$set(echo = FALSE)
|
||||
```
|
||||
@@ -0,0 +1,58 @@
|
||||
/* Override reactable defaults to respect PaperMod theme variables */
|
||||
|
||||
.Reactable {
|
||||
background-color: var(--theme) !important;
|
||||
color: var(--content) !important;
|
||||
}
|
||||
|
||||
/* Header row */
|
||||
.Reactable .rt-th {
|
||||
border-bottom-color: var(--primary) !important;
|
||||
color: var(--primary) !important;
|
||||
}
|
||||
|
||||
/* Cell borders */
|
||||
.Reactable .rt-td {
|
||||
border-top-color: var(--border) !important;
|
||||
color: var(--content) !important;
|
||||
}
|
||||
|
||||
/* Table outer border */
|
||||
.Reactable .rt-table {
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
|
||||
/* Group header underline */
|
||||
.Reactable .rt-th-group:after {
|
||||
background-color: var(--border) !important;
|
||||
}
|
||||
|
||||
/* Striped rows */
|
||||
.Reactable .rt-tr-striped {
|
||||
background-color: var(--code-bg) !important;
|
||||
}
|
||||
|
||||
/* Hover highlight */
|
||||
.Reactable .rt-tr-highlight:hover {
|
||||
background-color: var(--code-bg) !important;
|
||||
}
|
||||
|
||||
/* Pagination border */
|
||||
.Reactable .rt-pagination {
|
||||
border-top-color: var(--border) !important;
|
||||
}
|
||||
|
||||
/* Filter and search inputs */
|
||||
.Reactable .rt-filter,
|
||||
.Reactable .rt-search,
|
||||
.Reactable .rt-page-jump,
|
||||
.Reactable .rt-page-size-select {
|
||||
background-color: var(--theme) !important;
|
||||
border-color: var(--border) !important;
|
||||
color: var(--content) !important;
|
||||
}
|
||||
|
||||
/* No-data row border */
|
||||
.Reactable .rt-tbody-no-data .rt-td {
|
||||
border-color: transparent !important;
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
---
|
||||
title: About Me
|
||||
date: 2023-11-08T10:22:23-08:00
|
||||
date: 2026-03-24T22:22:23-08:00
|
||||
draft: false
|
||||
---
|
||||
|
||||
Maybe I will add a brief bio. Until then, this is just a landing page for
|
||||
information about me.
|
||||
|
||||
{{< rawhtml >}}
|
||||
<a rel="me" href="https://mastodon.sdf.org/@axs">Mastodon</a>
|
||||
{{< /rawhtml >}}
|
||||
|
||||
+798
-93
@@ -1,108 +1,813 @@
|
||||
---
|
||||
title: Résumé
|
||||
date: 2023-11-08T10:22:23-08:00
|
||||
draft: false
|
||||
data:
|
||||
jobs:
|
||||
EA:
|
||||
name: Electronic Arts
|
||||
url: 'https://ea.com'
|
||||
Glu:
|
||||
name: Glu Mobile
|
||||
url: 'https://glu.com'
|
||||
title: "Resume"
|
||||
layout: "single"
|
||||
url: /about/resume/
|
||||
---
|
||||
|
||||
<!--
|
||||
Consider placing data in the YAML block. Then insert this in a way that makes
|
||||
sense, given the output format. The advantage is that it makes parsing easier
|
||||
and more robust. The trade-off is the distance between data and text.
|
||||
-->
|
||||
<style>
|
||||
/* resume.css — Classicthesis-inspired résumé stylesheet
|
||||
*
|
||||
* Web-native translation of the LaTeX classicthesis aesthetic:
|
||||
* Palatino serif, maroon accents, spaced small caps headings,
|
||||
* margin-note grid layout for employer/institution names.
|
||||
*
|
||||
* Aligned with resume.sty — geometry, spacing constants, and
|
||||
* typographic commands mirror the LaTeX counterparts.
|
||||
*/
|
||||
|
||||
<!-- revise with text from resume writer -->
|
||||
/* Font Awesome — loaded for contact service icons (fa-envelope, fa-github, etc.)
|
||||
Mirrors \RequirePackage{fontawesome5} in resume.sty */
|
||||
@import url("https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css");
|
||||
|
||||
Business leader with strong analytics, data, and marketing expertise. I have
|
||||
applied data analytics and programming to deliver results across a wide range
|
||||
of industries, including mobile game marketing, web publishing, the electrical
|
||||
grid, and transportation. As a manager, I uncover opportunities, remove
|
||||
impediments, and guide team members toward professional growth. I am known for
|
||||
developing a strong network within an organization, driving innovative
|
||||
technical solutions, and effectively coaching a diverse set of team members.
|
||||
/* ====================================================================
|
||||
Custom properties
|
||||
(mirrors \resumeSectionSkip, \resumeEntrySkip, \resumeBlockSkip,
|
||||
\resumeDescriptionSkip, \resumePositionSkip, \datebox widths, and
|
||||
the geometry: marginparwidth=4cm, marginparsep=0.75cm → ~15em / 1.4em
|
||||
at 10pt base. Numbers scaled to a 16px/1rem web base.)
|
||||
==================================================================== */
|
||||
|
||||
## Work Experience
|
||||
:root {
|
||||
/* Colors — aligned with PaperMod theme-vars.css for light mode */
|
||||
--resume-accent: var(--primary, rgb(30, 30, 30));
|
||||
--resume-halfgray: var(--secondary, rgb(108, 108, 108));
|
||||
--resume-text: var(--content, rgb(31, 31, 31));
|
||||
--resume-text-heading: var(--primary, rgb(30, 30, 30));
|
||||
--resume-bg: var(--theme, rgb(255, 255, 255));
|
||||
--resume-border: var(--border, rgb(238, 238, 238));
|
||||
--resume-muted: var(--secondary, rgb(108, 108, 108));
|
||||
|
||||
### [Electronic Arts (EA)](https://ea.com)
|
||||
_Senior Diector of Growth Analytics and Data Science (2020--2023), including
|
||||
[Glu Mobile](https://glu.com)_
|
||||
/* Font stack — TeX Gyre Pagella / Palatino family */
|
||||
--resume-font: "TeX Gyre Pagella", "Palatino Linotype", Palatino,
|
||||
"Book Antiqua", Georgia, serif;
|
||||
|
||||
Building and leading teams with a focus on growth marketing, including
|
||||
a cross-functional business intelligence team (in partnership with
|
||||
engineering), marking analysts (embedded with the Marketing team), and data
|
||||
science with a focus on user acquisition. I define the vision for growth
|
||||
marketing analytics and the execution the plan. As a member of EA Mobile’s
|
||||
Growth Team senior leadership team, I collaborate across teams to more
|
||||
effectively measure the health of players and games, identify differences in
|
||||
our players, and discover new opportunities to grow revenue revenue.
|
||||
/* Layout — mirrors LaTeX geometry left=5.5cm, marginparwidth=4cm,
|
||||
marginparsep=0.75cm at ~10pt. Converted to em at 16px base. */
|
||||
--resume-margin-width: 10em; /* ~4 cm at 10pt */
|
||||
--resume-margin-gap: 1.2em; /* ~0.75 cm */
|
||||
/* Left page margin — body text indented this far from the content edge.
|
||||
Margin note + gap live within this space (mirrors left=5.5cm in LaTeX). */
|
||||
--resume-left-margin: calc(var(--resume-margin-width) + var(--resume-margin-gap));
|
||||
|
||||
{{< rawhtml >}}
|
||||
<details open="open">
|
||||
<summary>
|
||||
Accomplishments include:
|
||||
</summary>
|
||||
{{< /rawhtml >}}
|
||||
/* \datebox: wide enough for "Sep 2049–May 2049" at \small */
|
||||
--resume-date-width: 8.5em;
|
||||
|
||||
- *Saving the company \$100 million in marketing expenses when launching a
|
||||
new game.* I surfaced and demonstrated that the incumbent forecasting
|
||||
methodology would lead to bad business decisions prior to game launch. I
|
||||
then convened a cross-functional team to tackle the problem. Additionally,
|
||||
I implemented a transition state model for forecasting cohort growth as an
|
||||
\texttt{R} package. In addition to reducing wasteful spend, the approach I
|
||||
drove enabled informed, constructive conversations between the game
|
||||
development and marketing teams.
|
||||
/* Vertical rhythm — mirrors spacing options (normal, not compact) */
|
||||
--resume-section-skip: 1.0em; /* \resumeSectionSkip */
|
||||
--resume-entry-skip: 0.5em; /* \resumeEntrySkip */
|
||||
--resume-block-skip: 1.0em; /* \resumeBlockSkip */
|
||||
--resume-description-skip: 0.8em; /* \resumeDescriptionSkip */
|
||||
--resume-position-skip: 0.35em;/* \resumePositionSkip */
|
||||
|
||||
- *Cutting the effort required for analyzing marketing campaigns in half.*
|
||||
Prior to me joining Glu, we stored player activities in terms game events
|
||||
and did not completely track our marketing activities. Furthermore, the
|
||||
tools we were using (Hive and Hadoop) required specialized computing
|
||||
skills. I assembled a cross-functional team effort to modernize our
|
||||
business intelligence practice. We now use a scalable, cloud-based
|
||||
relational data warehouse with analytics friendly tables of key business
|
||||
processes. We are adopting this approach across the entire EA Mobile
|
||||
portfolio.
|
||||
/* Body */
|
||||
--resume-line-height: 1.05; /* \linespread{1.05} */
|
||||
--resume-max-width: 860px;
|
||||
}
|
||||
|
||||
{{< rawhtml >}}
|
||||
</details>
|
||||
{{< /rawhtml >}}
|
||||
<!--
|
||||
\item \emph{Cutting the effort required for analyzing marketing campaigns in
|
||||
half.} Prior to me joining Glu, we stored player activities in terms game
|
||||
events and did not completely track our marketing activities.
|
||||
Furthermore, the tools we were using (Hive and Hadoop) required
|
||||
specialized computing skills. I assembled a cross-functional team effort
|
||||
to modernize our business intelligence practice. We now use a scalable,
|
||||
cloud-based relational data warehouse with analytics friendly tables of
|
||||
key business processes. We are adopting this approach across the entire EA
|
||||
Mobile portfolio.
|
||||
/* ====================================================================
|
||||
Base
|
||||
==================================================================== */
|
||||
|
||||
\item \emph{Navigating Apple’s App Tracking Transparency initiative to
|
||||
maintain acquisition budgets.} Apple’s
|
||||
\href{https://developer.apple.com/documentation/storekit/skadnetwork}{SKAdNetwork}
|
||||
initiative is a threat to businesses that have relied on performance
|
||||
marketing and use attribution data to manage user acquisition campaigns.
|
||||
I lead our business intelligence and data science teams through our
|
||||
response to this challenge. We built new pipelines and analytical
|
||||
strategies that let us continue advertising budget on iOS while
|
||||
competitors drastically reduced budgets.
|
||||
html { font-size: 100%; }
|
||||
|
||||
\item \emph{Increasing the returns from product marketing.} Building an
|
||||
embedded marketing analysts team. Our Marketing team had operated without
|
||||
dedicated analytical support prior to my arrival. As a result, the team
|
||||
was limited in its ability to design experiments that would result in
|
||||
unbiased measurements. Our move to an embedded model means that analysts
|
||||
now have the full context of marketing initiatives, the authority to
|
||||
design experiments, and responsibility for interpreting results. We set
|
||||
the combined marketing and marketing analysts team on a path of continuous
|
||||
improvement cycles with clear ties between marketing activities and
|
||||
incremental revenue.
|
||||
body {
|
||||
font-family: var(--resume-font);
|
||||
line-height: var(--resume-line-height);
|
||||
color: var(--resume-text);
|
||||
background: var(--resume-bg);
|
||||
max-width: var(--resume-max-width);
|
||||
margin: 2em auto;
|
||||
padding: 0 1.5em;
|
||||
}
|
||||
|
||||
\end{itemize}
|
||||
-->
|
||||
/* ====================================================================
|
||||
Title — \spacedallcaps → uppercase + letter-spacing, Maroon, LARGE
|
||||
(\resumeTitle uses \LARGE\color{Maroon}\spacedallcaps)
|
||||
==================================================================== */
|
||||
|
||||
header h1,
|
||||
h1.title {
|
||||
text-align: center;
|
||||
color: var(--resume-accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em; /* \textls[160] ≈ 0.16 em */
|
||||
font-weight: normal;
|
||||
font-size: 1.75em; /* \LARGE at 10pt ≈ 14.4pt; 1.75em ≈ 28px */
|
||||
margin-bottom: var(--resume-entry-skip);
|
||||
}
|
||||
|
||||
/* Hide Pandoc subtitle/author/date if present */
|
||||
header .subtitle,
|
||||
header .author,
|
||||
header .date { display: none; }
|
||||
|
||||
/* ====================================================================
|
||||
Section headings — \spacedlowsmallcaps + \small + halfgray rule
|
||||
(\resumeGenericSection: \small\spacedlowsmallcaps + 0.4pt gray rule)
|
||||
==================================================================== */
|
||||
|
||||
.resume-section-heading {
|
||||
font-variant: small-caps;
|
||||
text-transform: lowercase; /* \MakeTextLowercase for small-caps */
|
||||
letter-spacing: 0.08em; /* \textls[80] ≈ 0.08 em */
|
||||
font-weight: normal;
|
||||
font-size: 0.95em; /* \small inside body text */
|
||||
color: var(--resume-text-heading);
|
||||
margin-top: var(--resume-section-skip);
|
||||
margin-bottom: 0.2em; /* 0.2em gap before rule (was 0.15→0.2 per CHANGE 1) */
|
||||
}
|
||||
|
||||
/* Ensure spacing between consecutive sections doesn't collapse with
|
||||
bullet-list margins at the end of the previous section. */
|
||||
section + section { padding-top: var(--resume-section-skip); }
|
||||
|
||||
section > hr {
|
||||
border: none;
|
||||
border-bottom: 0.4pt solid var(--resume-border); /* \color{halfgray}\rule{...}{0.4pt} */
|
||||
margin: 0 0 var(--resume-entry-skip);
|
||||
}
|
||||
|
||||
/* ====================================================================
|
||||
Margin-note grid
|
||||
(mirrors \MarginText / \MarginName in the left-margin column)
|
||||
==================================================================== */
|
||||
|
||||
/* Margin-note grid — pulls the entire block left so the margin note
|
||||
occupies the body's left padding, and the main column aligns with
|
||||
all other body text. Mirrors \reversemarginpar + left=5.5cm layout.
|
||||
Multiple grid rows are allowed; each .resume-margin-note and
|
||||
.resume-main-col pair occupies one implicit row. */
|
||||
.resume-employer,
|
||||
.resume-institution {
|
||||
display: grid;
|
||||
grid-template-columns: var(--resume-margin-width) 1fr;
|
||||
gap: 0 var(--resume-margin-gap);
|
||||
margin-left: calc(-1 * var(--resume-left-margin));
|
||||
margin-bottom: var(--resume-block-skip);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* Location-only margin note row — left column only, right column empty */
|
||||
.resume-margin-note--location {
|
||||
grid-column: 1;
|
||||
margin-top: 0.4em;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.resume-margin-note {
|
||||
text-align: right;
|
||||
font-style: italic;
|
||||
font-size: 0.9em;
|
||||
padding-top: 0.15em;
|
||||
color: var(--resume-muted);
|
||||
}
|
||||
|
||||
.resume-margin-note a {
|
||||
color: var(--resume-text-heading);
|
||||
}
|
||||
|
||||
/* Location line inside the margin note — below employer name.
|
||||
Mirrors \footnotesize\upshape in the LaTeX margin text. */
|
||||
.resume-margin-location {
|
||||
display: block;
|
||||
font-style: normal; /* \upshape — cancel the italic of the note */
|
||||
font-size: 0.8em; /* \footnotesize */
|
||||
color: var(--resume-muted);
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
|
||||
/* When a position overrides the employer location, a sibling div appears
|
||||
just before .resume-position inside .resume-main-col. It uses negative
|
||||
margin to pull its content visually back into the left column. */
|
||||
.resume-margin-location-override {
|
||||
margin-left: calc(-1 * (var(--resume-margin-width) + var(--resume-margin-gap)));
|
||||
width: var(--resume-margin-width);
|
||||
text-align: right;
|
||||
margin-bottom: 0.15em;
|
||||
}
|
||||
|
||||
.resume-main-col { min-width: 0; }
|
||||
|
||||
/* ====================================================================
|
||||
Employment / position entries
|
||||
(\resumePosition: \parbox{\datebox}{\small\textit{#2--#3}} + \textbf{#1})
|
||||
CHANGE 2: position title is now bold (\textbf added in .sty)
|
||||
==================================================================== */
|
||||
|
||||
.resume-entry-header {
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.resume-date {
|
||||
display: inline-block;
|
||||
min-width: var(--resume-date-width);
|
||||
font-style: italic;
|
||||
font-size: 0.9em; /* \small in LaTeX */
|
||||
}
|
||||
|
||||
/* CHANGE 2: bold title (was font-weight: normal) */
|
||||
.resume-position-title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.resume-position {
|
||||
margin-bottom: var(--resume-position-skip);
|
||||
}
|
||||
|
||||
/* ====================================================================
|
||||
Education entries
|
||||
(\resumeEducation: margin note = institution; \textbf{degree} CHANGE 3)
|
||||
==================================================================== */
|
||||
|
||||
.resume-institution {
|
||||
margin-bottom: var(--resume-entry-skip);
|
||||
}
|
||||
|
||||
/* CHANGE 3: degree/program title bold (mirrors \textbf added in .sty) */
|
||||
.resume-degree-title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.resume-edu-detail {
|
||||
font-size: 0.9em; /* \small for the detail line */
|
||||
margin-top: 0.15em;
|
||||
padding-left: calc(var(--resume-date-width) + 1.5em); /* aligns with title */
|
||||
}
|
||||
|
||||
/* ====================================================================
|
||||
Links — Maroon
|
||||
==================================================================== */
|
||||
|
||||
a {
|
||||
color: var(--resume-text-heading);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* ====================================================================
|
||||
Contact section
|
||||
(\resumeContactLineOne/Two; \faMapMarker, \faPhone*, icon + separator)
|
||||
==================================================================== */
|
||||
|
||||
.resume-contact address {
|
||||
font-style: normal;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
/* Inline contact items separated by halfgray · (mirrors \cdot) */
|
||||
.resume-contact-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 0.5em;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 0;
|
||||
}
|
||||
|
||||
.resume-contact-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* · separator between items (mirrors \enspace{\color{halfgray}$\cdot$}\enspace) */
|
||||
.resume-contact-item + .resume-contact-item::before {
|
||||
content: "·";
|
||||
color: var(--resume-halfgray);
|
||||
margin: 0 0.45em;
|
||||
}
|
||||
|
||||
.resume-contact-icon {
|
||||
color: var(--resume-halfgray); /* \color{halfgray} on icons */
|
||||
font-size: 0.85em;
|
||||
display: inline-block;
|
||||
width: 1.2em;
|
||||
text-align: center;
|
||||
margin-right: 0.35em;
|
||||
}
|
||||
|
||||
/* FA icons in contact items — halfgray, matching \large\color{halfgray} */
|
||||
.resume-contact-item i {
|
||||
color: var(--resume-halfgray);
|
||||
font-size: 0.9em;
|
||||
width: 1.2em;
|
||||
text-align: center;
|
||||
margin-right: 0.2em;
|
||||
}
|
||||
|
||||
/* Service icons — line 2 (\resumeService: \large\color{halfgray} icon + \quad) */
|
||||
.resume-contact-services {
|
||||
display: flex;
|
||||
gap: 0.9em; /* mirrors \quad between service entries */
|
||||
margin-bottom: var(--resume-entry-skip);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.resume-contact-services a {
|
||||
color: var(--resume-halfgray); /* \large\color{halfgray} icon */
|
||||
font-size: 1.1em; /* \large */
|
||||
}
|
||||
|
||||
.resume-contact-services a:hover {
|
||||
color: var(--resume-text-heading);
|
||||
}
|
||||
|
||||
/* ====================================================================
|
||||
Publications
|
||||
(\resumePublication: author. ``Title.'' \textit{Venue}, Year.)
|
||||
==================================================================== */
|
||||
|
||||
.resume-publication {
|
||||
margin-bottom: var(--resume-description-skip);
|
||||
padding-left: 2em;
|
||||
text-indent: -2em; /* \hangindent=2em\hangafter=0 */
|
||||
}
|
||||
|
||||
.resume-pub-authors { font-variant: normal; }
|
||||
.resume-pub-venue { font-style: italic; }
|
||||
|
||||
/* ====================================================================
|
||||
Projects, Certifications, Awards, Volunteer
|
||||
(\resumeProject etc.: \parbox{\datebox} + \textbf{title} + italic role)
|
||||
==================================================================== */
|
||||
|
||||
.resume-project,
|
||||
.resume-certification,
|
||||
.resume-award,
|
||||
.resume-volunteer-entry {
|
||||
margin-bottom: var(--resume-entry-skip);
|
||||
padding-left: 2em;
|
||||
text-indent: -2em; /* \hangindent=2em */
|
||||
}
|
||||
|
||||
/* Bold name mirrors \textbf{#1} in all misc entry commands */
|
||||
.resume-project-title,
|
||||
.resume-certification-title,
|
||||
.resume-award-title,
|
||||
.resume-volunteer-title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Italic role/issuer mirrors ~--- \textit{#2} */
|
||||
.resume-project-role,
|
||||
.resume-certification-issuer,
|
||||
.resume-award-source,
|
||||
.resume-volunteer-org {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.resume-award-desc {
|
||||
margin: 0.15em 0 0.3em;
|
||||
font-size: 0.95em;
|
||||
text-indent: 0;
|
||||
}
|
||||
|
||||
/* ====================================================================
|
||||
Description lists (Skills, Languages)
|
||||
(\resumeLanguage / \resumeSkillCategory with \MarginText for category)
|
||||
==================================================================== */
|
||||
|
||||
.resume-dl {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0.15em 1em;
|
||||
margin: 0.3em 0;
|
||||
}
|
||||
|
||||
.resume-dl dt {
|
||||
font-weight: bold;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.resume-dl dd { margin: 0; }
|
||||
|
||||
/* ====================================================================
|
||||
Prose lists (bullet points under positions, etc.)
|
||||
(\labelitemi → \textbullet, \labelitemii → \textendash; nosep list)
|
||||
==================================================================== */
|
||||
|
||||
section ul,
|
||||
.resume-main-col ul {
|
||||
list-style-type: disc; /* \textbullet */
|
||||
margin: 0.25em 0 0.4em; /* topsep=0.25em */
|
||||
padding-left: 2em; /* leftmargin=2em */
|
||||
}
|
||||
|
||||
section ul ul,
|
||||
.resume-main-col ul ul {
|
||||
list-style-type: "\2013\00a0"; /* \textendash for level 2 */
|
||||
}
|
||||
|
||||
section li,
|
||||
.resume-main-col li {
|
||||
margin-bottom: 0.1em; /* itemsep=0.1em */
|
||||
line-height: var(--resume-line-height);
|
||||
}
|
||||
|
||||
/* ====================================================================
|
||||
Responsive — collapse margin grid below 48em
|
||||
==================================================================== */
|
||||
|
||||
@media (max-width: 48em) {
|
||||
body {
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.resume-employer,
|
||||
.resume-institution,
|
||||
.resume-dl {
|
||||
display: block;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.resume-margin-note {
|
||||
text-align: left;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.15em;
|
||||
}
|
||||
|
||||
.resume-dl dt {
|
||||
margin-top: 0.3em;
|
||||
}
|
||||
|
||||
.resume-dl dd { margin-left: 1em; }
|
||||
|
||||
.resume-entry-header,
|
||||
.resume-project,
|
||||
.resume-certification,
|
||||
.resume-award,
|
||||
.resume-volunteer-entry,
|
||||
.resume-publication {
|
||||
padding-left: 0;
|
||||
text-indent: 0;
|
||||
}
|
||||
|
||||
.resume-edu-detail { padding-left: 0; }
|
||||
|
||||
.resume-contact-list { flex-direction: column; }
|
||||
.resume-contact-item + .resume-contact-item::before { display: none; }
|
||||
}
|
||||
|
||||
/* ====================================================================
|
||||
Print
|
||||
(\pagestyle{empty}; avoid breaks; link URLs after text)
|
||||
==================================================================== */
|
||||
|
||||
@media print {
|
||||
body {
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 10pt;
|
||||
color: #000;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #000;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Show link URLs (mirrors hyperref behaviour in PDF) */
|
||||
a[href^="http"]::after {
|
||||
content: " (" attr(href) ")";
|
||||
font-size: 0.8em;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* \break-inside avoid on all entry types */
|
||||
section { break-inside: avoid; }
|
||||
.resume-employer,
|
||||
.resume-institution,
|
||||
.resume-position,
|
||||
.resume-project,
|
||||
.resume-certification,
|
||||
.resume-award,
|
||||
.resume-volunteer-entry,
|
||||
.resume-publication { break-inside: avoid; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<section class="resume-introduction">
|
||||
<h2 class="resume-section-heading">Profile</h2>
|
||||
<hr>
|
||||
<p>Data and analytics leader who builds measurement capabilities from
|
||||
the ground up — and then builds the teams to run them. Across gaming,
|
||||
energy, financial services, and media, the pattern is the same: inherit
|
||||
“we have no data,” and leave behind production pipelines, statistical
|
||||
models, and analytical teams that make better decisions at scale.
|
||||
Promoted four levels in three years at EA managing cross-functional
|
||||
analytics organizations. Since leaving EA, completed Wharton’s CTO
|
||||
Program and took on founding-team roles at early-stage companies to stay
|
||||
close to the technical work. Ready to bring that combination of
|
||||
organizational leadership and hands-on depth back to a team that takes
|
||||
measurement seriously.</p>
|
||||
</section>
|
||||
<section class="resume-employment">
|
||||
<h2 class="resume-section-heading">Work Experience</h2>
|
||||
<hr>
|
||||
<div class="resume-employer">
|
||||
<div class="resume-margin-note"><a href="https://quadrant4.com">Quadrant4, Inc.</a><span class="resume-margin-location">Walnut Creek, CA</span></div>
|
||||
<div class="resume-main-col">
|
||||
<div class="resume-position">
|
||||
<div class="resume-entry-header"><span class="resume-position-title"></span></div>
|
||||
<p>Joined as employee number one at a startup whose core product
|
||||
<em>is</em> measurement — permissioned intent signals from engaged users
|
||||
that outperform the lagging behavioral data dominating ad targeting
|
||||
today. Built the entire technical and analytical foundation needed to
|
||||
prove and deliver that value proposition.</p>
|
||||
<ul>
|
||||
<li><em>Built the full backend and analytics stack solo.</em> Backend
|
||||
services platform, analytics infrastructure, dbt/DuckDB data warehouse,
|
||||
Quarto dashboards, and GKE/GCE cloud deployment with CI/CD — all from
|
||||
scratch.</li>
|
||||
<li><em>Rooted company strategy in measurable lifecycle economics.</em>
|
||||
Applied CLV frameworks before founding to turn an abstract thesis into a
|
||||
concrete, testable claim — and defined the metrics needed to prove
|
||||
it.</li>
|
||||
<li><em>Architected a platform that made new services trivial to
|
||||
build.</em> A transport-agnostic FaaS framework with 13+ reusable
|
||||
modules (async PostgreSQL, circuit-breaking, HTTP clients) reduced new
|
||||
service build time to under a day — proven by an SKAN postback ingestion
|
||||
service and an MCP server.</li>
|
||||
<li><em>Delivered early unit economics visibility.</em> Dagster
|
||||
orchestration over a warehouse covering costs, user behavior, and
|
||||
acquisition performance, plus an MCP server giving business users
|
||||
natural-language access to metrics without SQL.</li>
|
||||
<li><em>Prototyped AI conversation guidance via soft state
|
||||
machines.</em> Replaced rigid scripted flows with probabilistic state
|
||||
transitions, producing more realistic conversations and measurably
|
||||
higher engagement.</li>
|
||||
<li><em>Ran Meta user acquisition end-to-end.</em> Expanded into new
|
||||
Comscore geographies, hit industry benchmark CPRs within weeks through
|
||||
systematic creative testing, and fixed upper-funnel conversion
|
||||
leaks.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="resume-employer">
|
||||
<div class="resume-margin-note"><a href="https://www.idenza.ai">Idenza</a><span class="resume-margin-location">Bay Area, CA</span></div>
|
||||
<div class="resume-main-col">
|
||||
<div class="resume-position">
|
||||
<div class="resume-entry-header"><span class="resume-date">Oct 2024 – May 2025</span> <span class="resume-position-title">Founding Engineer</span></div>
|
||||
<p>Joined a fintech startup as a founding engineer to build core fraud
|
||||
detection infrastructure — delivering a production-ready rules engine
|
||||
and API layer before determining the role wasn’t aligned with my
|
||||
trajectory as a data and analytics leader.</p>
|
||||
<ul>
|
||||
<li><em>Built a policy-driven fraud detection engine for enterprise
|
||||
scale.</em> A service that generates Drools DRL rules dynamically from
|
||||
JSON definitions lets financial institutions encode their own policies
|
||||
without engineering involvement; a companion classification service
|
||||
evaluates complex multi-rule transactions in under 10 milliseconds.</li>
|
||||
<li><em>Delivered the backend API gateway and integration layer.</em>
|
||||
Built in Python with PostgreSQL, connecting the front end to the rules
|
||||
engine and leaving the system ready for client onboarding.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="resume-employer">
|
||||
<div class="resume-margin-note"><a href="https://www.astrocade.com">Astrocade</a><span class="resume-margin-location">Los Altos, CA</span></div>
|
||||
<div class="resume-main-col">
|
||||
<div class="resume-position">
|
||||
<div class="resume-entry-header"><span class="resume-date">Feb 2024 – Oct 2024</span> <span class="resume-position-title">Data Scientist</span></div>
|
||||
<p>Sole data scientist at a 20-person AI gaming startup — brought in as
|
||||
a contractor and converted to full-time based on results — responsible
|
||||
for building the company’s measurement capability from zero and
|
||||
translating data into product decisions for the CEO.</p>
|
||||
<ul>
|
||||
<li><em>Built the measurement foundation where none existed.</em>
|
||||
Defined telemetry specifications, selected and onboarded analytics
|
||||
vendors (Amplitude, Statsig), and built the pipelines that gave the
|
||||
company its first visibility into user behavior and product
|
||||
performance.</li>
|
||||
<li><em>Improved first-user experience through systematic
|
||||
experimentation.</em> A/B and bandit-style tests on onboarding and game
|
||||
design led to a simplified FUE flow with materially better completion
|
||||
and engagement rates.</li>
|
||||
<li><em>Identified and activated the core user base.</em> Behavioral
|
||||
analysis surfaced the highest-value users; a user council channeled
|
||||
their feedback directly into the product roadmap.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="resume-employer">
|
||||
<div class="resume-margin-note"><a href="https://ea.com">Glu Mobile / Electronic Arts</a><span class="resume-margin-location">San Francisco, CA</span></div>
|
||||
<div class="resume-main-col">
|
||||
<div class="resume-position">
|
||||
<div class="resume-entry-header"><span class="resume-date">Mar 2022 – May 2023</span> <span class="resume-position-title">Senior Director of Growth Analytics and Data Science</span></div>
|
||||
<p>Promoted four levels in three years (Manager → Senior Director),
|
||||
ultimately leading business intelligence, marketing analytics, and data
|
||||
science while on the leadership team of a 250-person organization —
|
||||
responsible for the measurement systems that shaped product and
|
||||
marketing decisions at EA Mobile scale.</p>
|
||||
<ul>
|
||||
<li><em>Saved $100M in marketing spend at a major game launch.</em>
|
||||
Identified and demonstrated that the incumbent forecasting methodology
|
||||
would produce bad decisions, convened a cross-functional team, and
|
||||
implemented a transition state model for cohort growth forecasting as an
|
||||
R package.</li>
|
||||
<li><em>Turned re-engagement marketing from a cost center into a profit
|
||||
driver.</em> An embedded analytics team transformed campaigns from
|
||||
money-losing giveaways into programs generating 200% ROI through
|
||||
continuous measurement and improvement cycles.</li>
|
||||
<li><em>Grew revenue by 5%+ through portfolio-level budget
|
||||
optimization.</em> Analysis revealed reallocation opportunities across
|
||||
acquisition channels — a significant impact on a budget in the tens of
|
||||
millions.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="resume-position">
|
||||
<div class="resume-entry-header"><span class="resume-date">May 2020 – Mar 2022</span> <span class="resume-position-title">Director of Analytics and Data Science</span></div>
|
||||
<ul>
|
||||
<li><em>Cut marketing analysis effort in half.</em> Led a
|
||||
cross-functional modernization of the BI practice, migrating from
|
||||
Hive/Hadoop to a scalable cloud data warehouse with analytics-friendly
|
||||
schemas.</li>
|
||||
<li><em>Navigated Apple’s ATT initiative to protect acquisition
|
||||
budgets.</em> Built new SKAdNetwork pipelines and analytical strategies
|
||||
that maintained iOS advertising budgets while competitors pulled
|
||||
back.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="resume-position">
|
||||
<div class="resume-entry-header"><span class="resume-date">Mar 2020 – May 2020</span> <span class="resume-position-title">Manager, Analytics</span></div>
|
||||
<ul>
|
||||
<li><em>Transformed analysts from order-takers into strategic
|
||||
partners.</em> Created space for analysts to explore beyond PM requests
|
||||
— leading to techniques like fixed-effects models that surfaced insights
|
||||
PMs would never have thought to request.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="resume-employer">
|
||||
<div class="resume-margin-note"><a href="https://askmediagroup.com">Ask Media Group</a><span class="resume-margin-location">Oakland, CA</span></div>
|
||||
<div class="resume-main-col">
|
||||
<div class="resume-position">
|
||||
<div class="resume-entry-header"><span class="resume-date">2019 – 2020</span> <span class="resume-position-title">Director, Data Science</span></div>
|
||||
<p>Led cross-functional data science teams across product and
|
||||
engineering, steering through written technical leadership — defining
|
||||
scope, acceptance criteria, and strategic direction in prose while
|
||||
delivering measurable improvements to search and content systems.</p>
|
||||
<ul>
|
||||
<li><em>Rescued a broken related search project.</em> Inherited a system
|
||||
returning “iPhone 4” for “iPhone” queries after six months of
|
||||
development. Two months after redefining acceptance criteria, automating
|
||||
the build, and purging stale data, the system was ready for deployment
|
||||
on a product targeted at $500K+ annual revenue.</li>
|
||||
<li><em>Reduced vertical search site build time from three months to
|
||||
three days.</em> Directed development of a tool automating index
|
||||
construction for narrow-topic search sites.</li>
|
||||
<li><em>Introduced Bayesian A/B testing where no testing capability
|
||||
existed.</em> Built the company’s first mechanism for measuring product
|
||||
improvements.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="resume-position">
|
||||
<div class="resume-entry-header"><span class="resume-date">Oct 2018 – Dec 2019</span> <span class="resume-position-title">Data Science Manager</span></div>
|
||||
</div>
|
||||
<div class="resume-position">
|
||||
<div class="resume-entry-header"><span class="resume-date">Aug 2018 – Oct 2018</span> <span class="resume-position-title">Data Evaluation Manager</span></div>
|
||||
<ul>
|
||||
<li><em>Transformed an analytics team into a delivery-oriented data
|
||||
science team.</em> Adopted Scrum and built GitLab CI/CD pipelines to
|
||||
increase delivery cadence.</li>
|
||||
<li><em>Opened digital marketing to data science.</em> Championed a
|
||||
keyword bidding project that produced automated auction data extraction
|
||||
and bid adjustment within two months.</li>
|
||||
<li><em>Built content moderation tooling at scale.</em> A word-vector
|
||||
classifier for out-of-policy text let editors focus attention where it
|
||||
was most needed.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="resume-employer">
|
||||
<div class="resume-margin-note"><a href="https://dnv.com">DNV</a><span class="resume-margin-location">Oakland, CA</span><span class="resume-margin-location">Høvik, Norway</span></div>
|
||||
<div class="resume-main-col">
|
||||
<div class="resume-position">
|
||||
<div class="resume-entry-header"><span class="resume-date">2016 – 2017</span> <span class="resume-position-title">Senior Data Scientist</span></div>
|
||||
<p>Selected for the Analytics Innovation Centre — a small group
|
||||
assembled for rapid iteration on the company’s digital transformation
|
||||
agenda — and worked directly with the executive committee to shape
|
||||
strategy.</p>
|
||||
<ul>
|
||||
<li><em>Defined requirements and built initial prototypes for
|
||||
Veracity,</em> the company’s marquee data platform connecting business
|
||||
consumers and suppliers.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="resume-position">
|
||||
<div class="resume-entry-header"><span class="resume-date">2011 – 2016</span> <span class="resume-position-title">Head of Section for Analytics and Senior Consultant</span></div>
|
||||
<p>Led the west coast analytics group in a first management role,
|
||||
combining technical leadership on large-scale energy data problems with
|
||||
business development and direct client delivery for major utilities and
|
||||
grid operators.</p>
|
||||
<ul>
|
||||
<li><em>Estimated peak savings for a CPUC energy efficiency program</em>
|
||||
across 40,000+ utility customers using years of hourly interval data
|
||||
distributed across a Hadoop cluster.</li>
|
||||
<li><em>Built a short-term renewable generation forecasting model</em>
|
||||
operating at five-minute intervals to reduce grid reliance on
|
||||
fast-response reserves.</li>
|
||||
<li><em>Developed a discrete choice model for light bulb market
|
||||
shares</em> to estimate counter-factual baselines for residential
|
||||
lighting programs — producing rigorous net-to-gross ratios.</li>
|
||||
<li><em>Led EV adopter identification for CenterPoint Energy,</em>
|
||||
combining early adopter characteristics with travel survey data to
|
||||
target geographic concentrations. </li>
|
||||
<li><em>Built a proof-of-concept load forecasting algorithm</em> for a
|
||||
major US utility’s demand response program, demonstrating the viability
|
||||
of daily short-term forecasts from AMI data across 40,000
|
||||
customers.</li>
|
||||
<li><em>Developed load profiles and forecast errors for photovoltaic and
|
||||
electric vehicles</em> for the California Independent System Operator,
|
||||
assessing the benefits of additional visibility into distributed energy
|
||||
resources.</li>
|
||||
<li><em>Managed a propensity-to-act model</em> that combined customer
|
||||
attributes, consumption, and program history into participation
|
||||
likelihood scores — creating a new service offering and a rigorous
|
||||
measure of how program actions influence participation.</li>
|
||||
<li><em>Piloted Apache Spark and Hadoop for large-scale energy data
|
||||
problems.</em> Supported business development through direct sales and
|
||||
proposal strategy.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="resume-employer">
|
||||
<div class="resume-margin-note"><a href="https://www.wsp.com">Parsons Brinckerhoff</a><span class="resume-margin-location">Chicago, IL</span><span class="resume-margin-location">San Francisco, CA</span><span class="resume-margin-location">Portland, OR</span><span class="resume-margin-location">Orange, CA</span></div>
|
||||
<div class="resume-main-col">
|
||||
<div class="resume-position">
|
||||
<div class="resume-entry-header"><span class="resume-date">2001 – 2011</span> <span class="resume-position-title">Senior Consultant</span></div>
|
||||
<p>Built simulation software and estimated behavioral models for
|
||||
regional and statewide transportation planning projects across the US,
|
||||
with several supporting successful federal New Starts funding
|
||||
applications.</p>
|
||||
<ul>
|
||||
<li><em>Developed one of the first discrete choice trip distribution
|
||||
models</em> for the Salt Lake City regional travel demand model,
|
||||
replacing the gravity-based approach and supporting a successful New
|
||||
Starts application.</li>
|
||||
<li><em>Lead programmer on the Ohio statewide travel demand model,</em>
|
||||
simulating long-distance travel for over 20 million persons using
|
||||
distributed computing.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="resume-employer">
|
||||
<div class="resume-margin-note">Chicago Area Transportation Study<span class="resume-margin-location">Chicago, IL</span></div>
|
||||
<div class="resume-main-col">
|
||||
<div class="resume-position">
|
||||
<div class="resume-entry-header"><span class="resume-date">1998 – 2000</span> <span class="resume-position-title">Engineer</span></div>
|
||||
<p>Supported development of the Chicago region’s 20-year long-range
|
||||
transportation plan through database design, GIS visualization, and
|
||||
travel demand model interpretation — an early foundation in the
|
||||
analytical and computational methods that have defined the rest of the
|
||||
career.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="resume-education">
|
||||
<h2 class="resume-section-heading">Education</h2>
|
||||
<hr>
|
||||
<div class="resume-institution">
|
||||
<div class="resume-margin-note">CTO Program</div>
|
||||
<div class="resume-main-col"><div class="resume-entry-header"><span class="resume-date">2023 – 2024</span> <a href="https://wharton.upenn.edu">The Wharton School, University of Pennsylvania</a></div><div class="resume-education-detail">Executive education; completed in 9 months</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="resume-institution">
|
||||
<div class="resume-margin-note">Master of Science</div>
|
||||
<div class="resume-main-col"><div class="resume-entry-header"><span class="resume-date">1999 – 2001</span> <a href="https://uic.edu">The University of Illinois, Chicago</a></div><div class="resume-education-detail">Civil and Materials Engineering, emphasis on Travel demand forecasting and simulation</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="resume-institution">
|
||||
<div class="resume-margin-note">Bachelor of Science</div>
|
||||
<div class="resume-main-col"><div class="resume-entry-header"><span class="resume-date">1993 – 1998</span> <a href="https://marquette.edu">Marquette University</a></div><div class="resume-education-detail">Civil and Environmental Engineering, emphasis on Transportation engineering and planning</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="resume-skills">
|
||||
<h2 class="resume-section-heading">Skills</h2>
|
||||
<hr>
|
||||
<dl class="resume-dl resume-skills-dl"><dt>Frameworks</dt><dd>Apache Spark, Dask, Drools, Flask</dd>
|
||||
<dt>Infrastructure</dt><dd>BigQuery, Docker, GitLab CI, Hadoop, PostgreSQL, Snowflake</dd>
|
||||
<dt>Languages</dt><dd>Java, Python, R, SQL</dd>
|
||||
<dt>Methods</dt><dd>A/B Testing, Bandit Testing, Bayesian Statistics, Behavioral Modeling, Cohort Analysis, Database Design, Discrete Choice Models, Forecasting, Funnel Analysis, GIS, Monte Carlo Simulation, NLP, Scrum, Simulation, Snowflake, Travel Demand Forecasting, Travel Demand Modeling</dd>
|
||||
<dt>Platforms</dt><dd>Amplitude, Statsig</dd></dl>
|
||||
</section>
|
||||
<section class="resume-publications">
|
||||
<h2 class="resume-section-heading">Publications</h2>
|
||||
<hr>
|
||||
<div class="resume-publication"><span class="resume-pub-authors">Siyu Wu, Andrew Stryker, Julia Vetromile</span>. “Tangled: Isolating SEM Savings.” <em class="resume-pub-venue">2015 ACEEE Summer Study on Energy Efficiency in Industry</em>, 2015.</div>
|
||||
<div class="resume-publication"><span class="resume-pub-authors">Andrew Stryker, Kathleen Gaffney</span>. “Why the Light Bulb is No Longer a Textbook Example for Price Elasticity: Results from Choice Experiments and Demand Modeling Research.” <em class="resume-pub-venue">International Energy Program Evaluation Conference</em>, 2013.</div>
|
||||
<div class="resume-publication"><span class="resume-pub-authors">Jay Evans, Andrew Stryker, Richard Pratt</span>. “Traveler Response to System Change, Chapter 17: Transit-Oriented Development.” <em class="resume-pub-venue">Transit Cooperative Research Program, Transportation Research Board</em>, 2007.</div>
|
||||
<div class="resume-publication"><span class="resume-pub-authors">G.D. Erhardt, J. Freedman, A. Stryker, H. Fujioka, R. Anderson</span>. “The Ohio Long Distance Travel Model.” <em class="resume-pub-venue">Transportation Research Record</em>, 2007.</div>
|
||||
<div class="resume-publication"><span class="resume-pub-authors">Andrew Stryker, Joel Freedman, John Britting</span>. “A Practical Evaluation of Alternative Methods to Trip Distribution.” <em class="resume-pub-venue">Transportation Research Board Planning Applications Conference</em>, 2005.</div>
|
||||
</section>
|
||||
|
||||
+16
-42
@@ -1,57 +1,31 @@
|
||||
---
|
||||
title: 'Wharton CTO Program'
|
||||
date: 2024-09-08T10:47:47-07:00
|
||||
draft: true
|
||||
draft: false
|
||||
xparams:
|
||||
social: false
|
||||
---
|
||||
|
||||
The [Chief Technology Officer
|
||||
Program](https://api.accredible.com/v1/credential-net/user_referrals/90db19b55d3d85f1319d31fe7aab9ff1/click)
|
||||
equips technology leaders with business context and skills that they need to effectively
|
||||
contribute to executive teams. I took program from
|
||||
the [University of Pennsylvania's](https://www.upenn.edu) [Wharton Business
|
||||
School](https://www.wharton.upenn.edu) between September 2023 and June 2024.
|
||||
|
||||
The structure was that of a core program plus three electives. Wharton awards a
|
||||
certificate for the successful completion of each of these elements.
|
||||
|
||||
I found this program to very helpful. I learned lots.
|
||||
I completed the [Chief Technology Officer
|
||||
Program](https://online-execed.wharton.upenn.edu/chief-technology-officer-program)
|
||||
from [Wharton Executive Education](https://www.wharton.upenn.edu) between
|
||||
September 2023 and June 2024. The program covers technology strategy, emerging
|
||||
trends, and execution frameworks for senior technology leaders. The program
|
||||
includes a core program and three electives, each earning a certificate upon
|
||||
completion.
|
||||
|
||||
# CTO Core Program
|
||||
|
||||
The core program covered:
|
||||
|
||||
- Technology adoption
|
||||
- Alliances
|
||||
- Thing 3
|
||||
|
||||

|
||||
|
||||
# Scaling a Unicorn
|
||||
## Scaling a Unicorn
|
||||
|
||||
The Scaling a Unicorn elective covered:
|
||||

|
||||
|
||||
- Thing 1
|
||||
- Thing 2
|
||||
- Thing 3
|
||||
## Driving Strategic Innovation
|
||||
|
||||

|
||||

|
||||
|
||||
# Driving Strategic Innovation
|
||||
## Executive Presence and Influence
|
||||
|
||||
The Driving Strategic Innovation elective covered:
|
||||
|
||||
- Thing 1
|
||||
- Thing 2
|
||||
- Thing 3
|
||||
|
||||

|
||||
|
||||
The Es
|
||||
- Thing 1
|
||||
- Thing 2
|
||||
- Thing 3
|
||||
|
||||

|
||||

|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: Demos
|
||||
draft: true
|
||||
---
|
||||
|
||||
Integration tests for site capabilities. Build and visually inspect.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
title: "KaTeX"
|
||||
draft: true
|
||||
xparams:
|
||||
math: true
|
||||
---
|
||||
|
||||
Exercises KaTeX rendering via the math partial.
|
||||
|
||||
## Inline Math
|
||||
|
||||
Euler's identity: \(e^{i\pi} + 1 = 0\)
|
||||
|
||||
The quadratic formula gives \(x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}\).
|
||||
|
||||
## Display Math
|
||||
|
||||
Bayes' theorem:
|
||||
|
||||
\[
|
||||
P(H \mid E) = \frac{P(E \mid H) \, P(H)}{P(E)}
|
||||
\]
|
||||
|
||||
A summation:
|
||||
|
||||
\[
|
||||
\sum_{n=1}^{\infty} \frac{1}{n^2} = \frac{\pi^2}{6}
|
||||
\]
|
||||
|
||||
An integral:
|
||||
|
||||
\[
|
||||
\int_{-\infty}^{\infty} e^{-x^2} \, dx = \sqrt{\pi}
|
||||
\]
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: "Mermaid"
|
||||
draft: true
|
||||
xparams:
|
||||
mermaid: true
|
||||
---
|
||||
|
||||
Exercises the Mermaid code block render hook and footer JS injection.
|
||||
|
||||
## Flowchart
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Rmd Source] --> B[statdown]
|
||||
B --> C[CommonMark .md]
|
||||
B --> D[depkit]
|
||||
D --> E[static/libs/]
|
||||
C --> F[Hugo]
|
||||
E --> F
|
||||
F --> G[HTML Site]
|
||||
```
|
||||
|
||||
## Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Author
|
||||
participant Make
|
||||
participant statdown
|
||||
participant Hugo
|
||||
|
||||
Author->>Make: make build
|
||||
Make->>statdown: render .Rmd
|
||||
statdown-->>Make: .md + assets
|
||||
Make->>Hugo: hugo --minify
|
||||
Hugo-->>Author: public/
|
||||
```
|
||||
|
||||
## State Diagram
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Draft
|
||||
Draft --> Review
|
||||
Review --> Draft: needs revision
|
||||
Review --> Published
|
||||
Published --> [*]
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
index.md
|
||||
figure/
|
||||
libs/
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: "Reactable"
|
||||
draft: true
|
||||
---
|
||||
|
||||
Exercises the htmlwidget pipeline: statdown renders the R chunks,
|
||||
depkit copies CSS/JS assets to `static/libs/`, and Hugo serves them.
|
||||
|
||||
```{r setup, include=FALSE}
|
||||
knitr::opts_chunk$set(
|
||||
echo = FALSE,
|
||||
warning = FALSE,
|
||||
message = FALSE
|
||||
)
|
||||
```
|
||||
|
||||
## Basic Table
|
||||
|
||||
```{r basic-table}
|
||||
library(reactable)
|
||||
|
||||
reactable(mtcars[1:10, ], filterable = TRUE, searchable = TRUE)
|
||||
```
|
||||
|
||||
## Styled Table
|
||||
|
||||
```{r styled-table}
|
||||
reactable(
|
||||
iris[1:15, ],
|
||||
columns = list(
|
||||
Sepal.Length = colDef(name = "Sepal Length"),
|
||||
Sepal.Width = colDef(name = "Sepal Width"),
|
||||
Petal.Length = colDef(name = "Petal Length"),
|
||||
Petal.Width = colDef(name = "Petal Width")
|
||||
),
|
||||
striped = TRUE,
|
||||
highlight = TRUE,
|
||||
bordered = TRUE
|
||||
)
|
||||
```
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
title: "Shortcodes"
|
||||
draft: true
|
||||
toc: true
|
||||
---
|
||||
|
||||
Exercises the custom shortcodes.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
The `toc` shortcode renders a table of contents based on word count
|
||||
threshold or the `toc: true` front matter flag.
|
||||
|
||||
{{< toc >}}
|
||||
|
||||
## Raw HTML
|
||||
|
||||
The `rawhtml` shortcode passes content through without escaping.
|
||||
|
||||
{{< rawhtml >}}
|
||||
<details>
|
||||
<summary>Click to expand</summary>
|
||||
<p>This HTML is rendered directly via the <code>rawhtml</code> shortcode.</p>
|
||||
</details>
|
||||
{{< /rawhtml >}}
|
||||
|
||||
## YouTube Embed
|
||||
|
||||
{{< youtube dQw4w9WgXcQ >}}
|
||||
|
||||
## Vimeo Embed
|
||||
|
||||
{{< vimeo 32001208 >}}
|
||||
@@ -2,6 +2,8 @@
|
||||
title: Notes
|
||||
date: 2024-07-13T10:00:00-00:00
|
||||
draft: false
|
||||
xparams:
|
||||
math: true
|
||||
---
|
||||
|
||||
Public notes and guides for various topics.
|
||||
Public notes and guides.
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
title: Bayes' Theorem Expressed as Odds
|
||||
date: 2025-03-12
|
||||
draft: false
|
||||
summary: |
|
||||
Derives the odds form of Bayes' theorem, where posterior odds equal prior
|
||||
odds times likelihood ratios — a more convenient form for sequential
|
||||
updating.
|
||||
tags:
|
||||
- Coup
|
||||
- US Politics
|
||||
- R
|
||||
- Bayes
|
||||
xparams:
|
||||
math: true
|
||||
share:
|
||||
- mastodon
|
||||
---
|
||||
|
||||
[Bayes' Theorem][wiki-bayes-theorem] is typically stated as:
|
||||
|
||||
\[
|
||||
P(A \mid E) = \frac{P(E \mid A) \, P(A)}{P(E \mid A) \, P(A) + P(E \mid \neg A) \, P(\neg A)}
|
||||
\]
|
||||
|
||||
This form explicitly shows all the pieces of Bayesian reasoning:
|
||||
|
||||
- The _posterior_ probability of the hypothesis \(A\), given evidence \(E\):
|
||||
\(P(A \mid E)\).
|
||||
|
||||
- The probability _prior_ to the evidence: \(P(A)\).
|
||||
|
||||
- The likelihood observing the evidence if the hypothesis is true:
|
||||
\(P(E \mid A)\).
|
||||
|
||||
- The likelihood observing the evidence if the hypothesis is _not_ true.
|
||||
Equivalently, the likelihood of observing the evidence if the
|
||||
_alternative_ hypothesis is true: \(P(E \mid \neg A)\).
|
||||
|
||||
However, the above form is not terribly convenient when making calculations
|
||||
over a series of events:
|
||||
|
||||
\[
|
||||
P(A \mid E_1, \dots, E_n) = \frac{P(E_1, \dots, E_n \mid A)\,P(A)}{P(E_1, \dots, E_n \mid A) \, P(A) + P(E_1, \dots, E_n \mid \neg A) \, P(\neg A)}
|
||||
\]
|
||||
|
||||
While we can calculate \(P(E_1, \dots, E_n)\) via successive substitutions,
|
||||
there is a more convenient approach. This involves transforming
|
||||
Bayes' Theorem as follows:
|
||||
|
||||
We start with the probability of the hypothesis (show above), and the
|
||||
probability of the complement, alternate hypothesis
|
||||
|
||||
\[
|
||||
P( \neg A \mid E_1, \dots, E_n) = \frac{P(E_1, \dots, E_n \mid \neg A)\,P( \neg
|
||||
A)}{P(E_1, \dots, E_n \mid \neg A) \, P(\neg A) + P(E_1, \dots, E_n \mid A) \, P(A)}
|
||||
\]
|
||||
|
||||
Assuming the pieces of evidence are _[conditionally
|
||||
independent][wiki-independence]_ given \(A\) (and similarly given \(\neg A\)),
|
||||
we can factorize the likelihood terms:
|
||||
|
||||
\[ P(E_1, \dots, E_n \mid A) = \prod_{i=1}^{n} P(E_i \mid A) \]
|
||||
|
||||
and
|
||||
|
||||
\[
|
||||
P(E_1, \dots, E_n \mid \neg A) = \prod_{i=1}^{n} P(E_i \mid \neg A).
|
||||
\]
|
||||
|
||||
Substitute these into the posterior odds:
|
||||
|
||||
\[
|
||||
\frac{P(A \mid E_1, \dots, E_n)}{P(\neg A \mid E_1, \dots, E_n)} = \frac{P(A)}{P(\neg A)} \prod_{i=1}^{n} \frac{P(E_i \mid A)}{P(E_i \mid \neg A)}.
|
||||
\]
|
||||
|
||||
Define the _prior [odds][wiki-odds]_, as:
|
||||
|
||||
\[
|
||||
O(A) = \frac{P(A)}{P(\neg A)}
|
||||
\]
|
||||
|
||||
and the [_likelihood ratio_][wiki-lr] for each piece of evidence \(E_i\) as:
|
||||
|
||||
\[
|
||||
\text{LR}_i = \frac{P(E_i \mid A)}{P(E_i \mid \neg A)}.
|
||||
\]
|
||||
|
||||
|
||||
Then, we write the _posterior odds_ and the posterior probability, our desired
|
||||
result, compactly as:
|
||||
|
||||
\[
|
||||
\begin{align}
|
||||
O(A \mid E_1, \dots, E_n) &= O(A) \prod_{i=1}^{n} \text{LR}_i, \\
|
||||
P(A \mid E_1, \dots, E_n) &= \frac{O(A \mid E_1, \dots, E_n)}{1 + O(A \mid E_1, \dots, E_n)}.
|
||||
\end{align}
|
||||
\]
|
||||
|
||||
Thus, we have a two-step process to compute the posterior probability:
|
||||
|
||||
1. Calculate the posterior odds as the prior odds times the product of the
|
||||
likelihood ratios.
|
||||
|
||||
2. Convert the odds to probabilities.
|
||||
|
||||
|
||||
[wiki-bayes-theorem]: https://en.wikipedia.org/wiki/Bayes%27_theorem
|
||||
[wiki-independence]: https://en.wikipedia.org/wiki/Conditional_independence
|
||||
[wiki-odds]: https://en.wikipedia.org/wiki/Odds
|
||||
[wiki-lr]: https://en.wikipedia.org/wiki/Likelihood_function#Likelihood_ratio
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: Using HTML in Markdown
|
||||
date: 2025-02-25T21:21:21-08:00
|
||||
draft: false
|
||||
tags:
|
||||
- Writing
|
||||
- Markdown
|
||||
- HTML
|
||||
- Hugo
|
||||
---
|
||||
|
||||
Markdown uses punctuation-based syntax to format text, drawing inspiration from
|
||||
plain text email conventions. The goal is for Markdown documents to be easy to
|
||||
read. For concerns that the [specification](https://commonmark.org/) does not
|
||||
cover, users are free to use HTML. However, the HTML tags that rendering
|
||||
engines support vary considerably. Further, some rendering engines have their
|
||||
own approach to extensions, like
|
||||
[shortcodes](https://gohugo.io/content-management/shortcodes/) in Hugo.
|
||||
Generally, best practice is to avoid mixing Markdown and HTML, as doing so can
|
||||
detract from Markdown’s intended simplicity and readability.
|
||||
|
||||
The following items are exceptions to this rule—cases where HTML provides
|
||||
functionality or control that Markdown does not.
|
||||
|
||||
| HTML Tag(s) | Description | Notes |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | --------- |
|
||||
| `<details>` and `<summary>` | Create collapsible sections for hiding and revealing content. | [^HUGOSC] |
|
||||
| `<kbd>` | Represent keyboard inputs or shortcuts. | — |
|
||||
| `<abbr>` | Add tooltips to abbreviations for clarity. | [^NOHUGO] |
|
||||
| `<sup>` and `<sub>` | Format text as superscript or subscript. | — |
|
||||
| `<mark>` | Highlight text with a background color. | [^HUGO] |
|
||||
| `<!-- ... -->` (HTML Comments) | Insert comments that won’t appear in the rendered output. | [^HUGO] |
|
||||
| `<img>` | Embed images with enhanced control over attributes like class, style, width, and height. | — |
|
||||
| `<div>` | Apply specific styles elements, such as centering an image, to a section of text | |
|
||||
| `<var>` | Represent variables, parameters, or mathematical symbols to add semantic clarity in technical documentation. | [^GH] |
|
||||
| `<samp>` | Denote sample output from programs or command-line operations, helping readers distinguish between code input and output. | [^GH] |
|
||||
|
||||
[^NOHUGO]: Not supported in Hugo.
|
||||
|
||||
[^HUGO]: Supported in Hugo, but may depend on the theme.
|
||||
|
||||
[^HUGOSC]: Supported in Hugo via shortcodes
|
||||
|
||||
[^GH]: Not supported in GitHub
|
||||
@@ -0,0 +1,3 @@
|
||||
_index.md
|
||||
figure/
|
||||
libs/
|
||||
@@ -0,0 +1,538 @@
|
||||
---
|
||||
title: 'Is This an Autogolpe? A Bayesian Analysis'
|
||||
date: 2025-03-12
|
||||
draft: false
|
||||
description: >
|
||||
A Bayesian analysis of eight events from the first two months of the
|
||||
Trump administration, evaluating the hypothesis that the United States
|
||||
is experiencing an autogolpe (self-coup). Includes an interactive
|
||||
simulator for testing your own assumptions.
|
||||
ShowToc: true
|
||||
TocOpen: false
|
||||
tags:
|
||||
- US Politics
|
||||
- Bayes
|
||||
- R
|
||||
xparams:
|
||||
math: true
|
||||
---
|
||||
|
||||
```{r setup, include=FALSE}
|
||||
library(tidyverse)
|
||||
library(reactable)
|
||||
library(htmltools)
|
||||
|
||||
knitr::opts_chunk$set(echo = FALSE)
|
||||
```
|
||||
|
||||
We are two months into a presidential administration that is clearly making
|
||||
a break with past administrations. Many prominent observers like [Paul
|
||||
Krugman][PK] and [Robert Reich][RR] are calling this an authoritarian
|
||||
_autogolpe_, or self-coup. Of course, both of these observers are left of
|
||||
center, so being skeptical of these claims is a natural---and even
|
||||
prudent---reaction. After all, we have experienced 250 years of democratic
|
||||
government. On the other hand, maybe they are correct. Many of the stories
|
||||
coming out of Washington seem alarming and the sheer number of stories is
|
||||
overwhelming. The Department of Government Efficiency (DOGE) shutting down
|
||||
agencies such as USAID. DOGE asking federal workers to justify the work they
|
||||
performed last week in an email or risk termination. Deciding how to think
|
||||
about this moment in light of emotionally charged claims and commentary is
|
||||
difficult. To not be sure what to think is completely understandable.
|
||||
|
||||
In this post, I will do my best to walk through this moment rationally. To do
|
||||
this, we will take a [Bayesian approach][Wiki-Bayesian]. I'll get into the
|
||||
details below, but the core idea is that Bayesian analysis gives a rigorous
|
||||
framework for combining disparate pieces of information. In this case, we will
|
||||
combine pieces of evidence to evaluate the hypothesis that
|
||||
the administration is staging an autogolpe.
|
||||
|
||||
|
||||
## A Bayesian Framework
|
||||
|
||||
There are a few concepts that we need for this analysis. While the math might
|
||||
look fancy at first, all of this is a series of probabilities---numbers that
|
||||
range between 0 (no chance) and 1 (complete certainty).
|
||||
|
||||
- **Prior probability**, \( P(A) \): the initial probability of an
|
||||
autogolpe in the United States, before looking at any evidence. We label
|
||||
the probability of _not_ an autogolpe as \( P(\neg A) = 1 - P(A) \).
|
||||
|
||||
- **Evidence**: objective, verifiable events. For example, the White House
|
||||
posting a picture of the president as a king on social media.
|
||||
|
||||
- **Likelihood under the hypothesis**, \( P(E \mid A) \): the probability
|
||||
that we would observe this evidence _if_ an autogolpe were underway. This
|
||||
is our _interpretation_ of the evidence. Interpretation is inherently
|
||||
_subjective_---perhaps the king post was a joke, perhaps it was
|
||||
serious. As outside observers we can only estimate. We use probability
|
||||
values to represent this uncertainty.
|
||||
|
||||
- **Likelihood under the alternative**, \( P(E \mid \neg A) \): the
|
||||
probability that we would observe this evidence if an autogolpe were
|
||||
_not_ underway. Crucially, the alternative hypothesis is not "a normal
|
||||
administration." The fairest comparison is against the strongest version
|
||||
of the alternative: an aggressive, norm-breaking administration that is
|
||||
pushing the boundaries of executive power but is _not_ staging a coup.
|
||||
Trump's first term was highly norm-breaking without being an autogolpe,
|
||||
so we know this alternative is plausible. Throughout this analysis, I
|
||||
will benchmark \( P(E \mid \neg A) \) against this stronger alternative.
|
||||
|
||||
- **Posterior probability**, \( P(A \mid E) \): the probability that we are
|
||||
experiencing an autogolpe _after_ accounting for the evidence.
|
||||
|
||||
What we are doing here is defining a framework for explicitly stating our
|
||||
beliefs. Prior to inauguration day, what was the probability that we would have
|
||||
an autogolpe in the United States? Maybe a 1% chance. That is \( P(A) \). How
|
||||
likely is it that a given piece of evidence is consistent with an autogolpe?
|
||||
Maybe 70%. That is \( P(E \mid A) \).
|
||||
|
||||
Our last step is a way to put this information together. [Bayes'
|
||||
theorem][Wiki-Bayesian] tells us:
|
||||
|
||||
\[
|
||||
P(A \mid E) = \frac{P(E \mid A) \, P(A)}{P(E \mid A) \, P(A) + P(E \mid \neg A) \, P(\neg A)}
|
||||
\]
|
||||
|
||||
The numerator is the probability that the evidence is consistent with an
|
||||
autogolpe times our prior. The denominator normalizes by adding the probability
|
||||
that the evidence is consistent with _aggressive-but-not-coup governance_ times
|
||||
the probability that we would _not_ have an autogolpe.
|
||||
|
||||
|
||||
### A Worked Example
|
||||
|
||||
To make this concrete, let's work through one piece of evidence. On February
|
||||
21, the Secretary of Defense fired the Chairman of the Joint Chiefs of Staff,
|
||||
the Chief of Naval Operations, the Air Force Vice Chief of Staff, and several
|
||||
Judge Advocates General---a [mass removal of military leadership without modern
|
||||
precedent][PBS-Military]. There is precedent for firing individual military
|
||||
leaders (Presidents Obama and Truman relieved Generals McChrystal and
|
||||
MacArthur, respectively), but not for a simultaneous purge like this.
|
||||
|
||||
Suppose our prior is \( P(A) = 0.01 \), a 1% chance. Under the hypothesis
|
||||
that an autogolpe is underway, the likelihood of seeing a mass military purge
|
||||
is high---let's say \( P(E \mid A) = 0.9 \). Even under an aggressive
|
||||
administration asserting civilian control of the military, a simultaneous
|
||||
mass firing like this is unusual: \( P(E \mid \neg A) = 0.05 \).
|
||||
|
||||
\[
|
||||
P(A \mid E) = \frac{0.9 \times 0.01}{0.9 \times 0.01 + 0.05 \times 0.99} \approx 0.15
|
||||
\]
|
||||
|
||||
A single piece of evidence moves us from a 1% prior to a 15% posterior. Still
|
||||
unlikely, but a fifteen-fold increase.
|
||||
|
||||
|
||||
### Chaining Evidence with Odds
|
||||
|
||||
When we have multiple pieces of evidence, applying Bayes' theorem repeatedly in
|
||||
probability form gets cumbersome. There is a cleaner approach using _odds_. For
|
||||
each piece of evidence, define the _likelihood ratio_:
|
||||
|
||||
\[
|
||||
\text{LR}_i = \frac{P(E_i \mid A)}{P(E_i \mid \neg A)}
|
||||
\]
|
||||
|
||||
A likelihood ratio greater than 1 means the evidence is more consistent with an
|
||||
autogolpe than with normal governance. The larger the ratio, the stronger the
|
||||
evidence. We can then update our belief sequentially:
|
||||
|
||||
\[
|
||||
O(A \mid E_1, \dots, E_n) = O(A) \times \prod_{i=1}^{n} \text{LR}_i
|
||||
\]
|
||||
|
||||
where \( O(A) = P(A) / P(\neg A) \) is the prior odds. The posterior
|
||||
probability is recovered as \( P = O / (1 + O) \). (For the full derivation,
|
||||
see [Bayes' Theorem as Odds](/notes/bayes-theorem-as-odds/).)
|
||||
|
||||
This is the approach we will use for the rest of the analysis.
|
||||
|
||||
|
||||
## The Evidence
|
||||
|
||||
Below is a chronological account of eight events from the first two months of
|
||||
the administration. For each event, I describe what happened, give my assessment
|
||||
of how the event is consistent and not consistent with an autogolpe, and
|
||||
assign a likelihood ratio.
|
||||
|
||||
|
||||
{{% details summary="**1. Firing Inspectors General (January 24)** --- LR = 9" %}}
|
||||
On a late Friday night, the administration [fired at least 17 independent
|
||||
inspectors general][NPR-IG] across federal agencies, effective immediately.
|
||||
Inspectors general are the government's internal watchdogs, charged with
|
||||
investigating waste, fraud, and abuse within their agencies. Federal law
|
||||
requires 30 days' notice to Congress before removing an IG; that notice was not
|
||||
provided.
|
||||
|
||||
Removing government watchdogs is a classic early move in consolidating
|
||||
power---it eliminates the officials whose job is to expose wrongdoing.
|
||||
There is some precedent: Reagan fired all IGs upon taking office in 1981,
|
||||
though he rehired most of them. An aggressive executive who views IGs as
|
||||
obstructionist might plausibly do this without staging a coup. Still, the
|
||||
mass scale and the failure to provide legally required congressional notice
|
||||
make this more consistent with the autogolpe hypothesis.
|
||||
|
||||
\( P(E \mid A) = 0.90 \), \( P(E \mid \neg A) = 0.10 \), \( \text{LR} = 9 \).
|
||||
|
||||
[NPR-IG]: https://www.npr.org/2025/01/25/g-s1-44771/trump-fires-inspectors-general
|
||||
{{% /details %}}
|
||||
|
||||
{{% details summary="**2. Shutting Down USAID (February 2)** --- LR ≈ 27" %}}
|
||||
The administration [placed all approximately 4,700 USAID employees on
|
||||
administrative leave][WaPo-USAID] and moved to shut down the agency---a
|
||||
Congressionally established agency with a statutory mandate. Approximately 92%
|
||||
of grants were cancelled. Secretary of State Marco Rubio signed an order
|
||||
folding USAID's functions into the State Department, bypassing the
|
||||
Congressional process that would normally be required to abolish a federal
|
||||
agency.
|
||||
|
||||
Eliminating institutions that operate independently of executive control is
|
||||
consistent with power consolidation. An administration ideologically opposed to
|
||||
foreign aid might target USAID through legitimate legislative channels, but
|
||||
unilaterally shutting down a Congressionally authorized agency---bypassing
|
||||
the body that created it---is genuinely difficult to explain as normal
|
||||
governance, even aggressive governance.
|
||||
|
||||
\( P(E \mid A) = 0.80 \), \( P(E \mid \neg A) = 0.03 \), \( \text{LR} \approx 27 \).
|
||||
|
||||
[WaPo-USAID]: https://www.washingtonpost.com/politics/2025/02/02/usaid-trump-musk/
|
||||
{{% /details %}}
|
||||
|
||||
{{% details summary="**3. Accessing Government Computer Systems (February 4)** --- LR ≈ 5" %}}
|
||||
Elon Musk's DOGE team [gained access to the Treasury Department's Bureau of
|
||||
Fiscal Service payment system][NPR-Treasury], which disburses roughly $5.4
|
||||
trillion annually. They also [accessed the Office of Personnel Management's
|
||||
systems][WaPo-OPM] containing personally identifiable information for millions
|
||||
of federal employees. DOGE operatives---many of them young engineers with no
|
||||
government experience or security clearances---were given read (and in some
|
||||
cases write) access to these critical systems.
|
||||
|
||||
Centralizing visibility into the government's financial flows and personnel
|
||||
records is a powerful lever of control. An administration genuinely committed
|
||||
to rooting out waste could plausibly want access to these systems, and
|
||||
government IT modernization is a perennial goal. The unusual aspect is the
|
||||
speed and the lack of vetting of the operatives involved. This is the weakest
|
||||
evidence in the set---it is the most amenable to an innocent explanation.
|
||||
|
||||
\( P(E \mid A) = 0.70 \), \( P(E \mid \neg A) = 0.15 \), \( \text{LR} \approx 5 \).
|
||||
|
||||
[NPR-Treasury]: https://www.npr.org/2025/02/04/nx-s1-5285403/musks-doge-group-has-access-to-the-federal-payments-system-what-does-that-mean
|
||||
[WaPo-OPM]: https://www.washingtonpost.com/national-security/2025/02/06/elon-musk-doge-access-personnel-data-opm-security/
|
||||
{{% /details %}}
|
||||
|
||||
{{% details summary="**4. Mass Firing Federal Workers (February 13)** --- LR = 9" %}}
|
||||
The administration [fired over 24,000 probationary employees][NPR-Layoffs]
|
||||
across agencies including the CDC, Department of Education, Department of
|
||||
Energy, and the National Nuclear Security Administration. Many of these workers
|
||||
had received strong or exceptional performance ratings. The firings were
|
||||
characterized as performance-based, but they were applied in bulk without
|
||||
individual performance review.
|
||||
|
||||
Large-scale purges of the civil service are a hallmark of authoritarian
|
||||
consolidation---they replace institutional knowledge and independence with
|
||||
loyalty. However, an administration committed to shrinking government could
|
||||
pursue mass reductions in force as policy. The distinction is that these
|
||||
firings were framed as performance-based while bypassing individual review,
|
||||
which is more consistent with a loyalty purge than a policy-driven
|
||||
restructuring.
|
||||
|
||||
\( P(E \mid A) = 0.90 \), \( P(E \mid \neg A) = 0.10 \), \( \text{LR} = 9 \).
|
||||
|
||||
[NPR-Layoffs]: https://www.npr.org/2025/02/13/nx-s1-5296928/layoffs-trump-doge-education-energy
|
||||
{{% /details %}}
|
||||
|
||||
{{% details summary="**5. Asserting Executive Authority over Law Interpretation (February 18)** --- LR = 8" %}}
|
||||
The president signed an executive order titled "Ensuring Accountability for All
|
||||
Agencies," [declaring that the president and attorney general "shall provide
|
||||
authoritative interpretations of law for the executive branch."][NPR-EO] The
|
||||
order asserted control over independent regulatory agencies such as the SEC,
|
||||
FDIC, and FEC, requiring them to submit regulations for White House review.
|
||||
|
||||
This directly challenges the independence of regulatory agencies and the
|
||||
judiciary's role as interpreter of law. Independent agencies were deliberately
|
||||
structured by Congress to operate at arm's length from the president. That
|
||||
said, the "unitary executive" theory---which holds that all executive power
|
||||
flows from the president---has been a mainstream conservative legal position
|
||||
since the Reagan era. An aggressive proponent of this theory might issue such
|
||||
an order as a matter of constitutional philosophy, not coup. The distinction
|
||||
is narrow, but it exists.
|
||||
|
||||
\( P(E \mid A) = 0.80 \), \( P(E \mid \neg A) = 0.10 \), \( \text{LR} = 8 \).
|
||||
|
||||
[NPR-EO]: https://www.npr.org/2025/02/19/nx-s1-5302481/trump-independent-agencies
|
||||
{{% /details %}}
|
||||
|
||||
{{% details summary="**6. "Long Live the King" (February 19)** --- LR = 7" %}}
|
||||
The official White House accounts on X, Instagram, and Facebook [posted an
|
||||
AI-generated image][NBC-King] depicting the president wearing a bejeweled
|
||||
golden crown, captioned "LONG LIVE THE KING." The White House Deputy Chief
|
||||
of Staff also posted an AI-generated image of the president in royal ermine
|
||||
robes.
|
||||
|
||||
Using monarchical symbolism on official government channels signals a comfort
|
||||
with authoritarian imagery that is deeply unusual in American democratic
|
||||
tradition. However, this president has a well-established pattern of
|
||||
provocative, trolling communication. His supporters often interpret such
|
||||
posts as humor or "owning the libs." An administration that delights in
|
||||
provocation might post this without any authoritarian intent. Still, when
|
||||
official government accounts use monarchical imagery, the institutional
|
||||
context lends it weight that a personal social media post would not carry.
|
||||
|
||||
\( P(E \mid A) = 0.70 \), \( P(E \mid \neg A) = 0.10 \), \( \text{LR} = 7 \).
|
||||
|
||||
[NBC-King]: https://www.nbcnews.com/politics/donald-trump/king-trump-rcna192912
|
||||
{{% /details %}}
|
||||
|
||||
{{% details summary="**7. Confirming Kash Patel as FBI Director (February 20)** --- LR = 17" %}}
|
||||
The Senate [confirmed Kash Patel as FBI Director][NPR-Patel] on a 51--49
|
||||
vote---the narrowest FBI director confirmation in history. Previous directors
|
||||
received 92 or more votes. Patel authored a book listing over 60 government
|
||||
officials as members of a "deep state" to be targeted. During confirmation
|
||||
hearings, [senators questioned him about this "enemies list."][PBS-Patel]
|
||||
|
||||
Placing a loyalist who has publicly identified political targets at the head of
|
||||
federal law enforcement is consistent with weaponizing the state against
|
||||
political opponents. There is some precedent---Nixon appointed L. Patrick Gray
|
||||
as a loyalist FBI head during Watergate. A president who believes the FBI has
|
||||
been weaponized _against_ him might appoint someone he trusts to reform it.
|
||||
The distinguishing factor is the explicit published list of targets, which goes
|
||||
beyond loyalty to something more closely resembling a plan.
|
||||
|
||||
\( P(E \mid A) = 0.85 \), \( P(E \mid \neg A) = 0.05 \), \( \text{LR} = 17 \).
|
||||
|
||||
[NPR-Patel]: https://www.npr.org/2025/02/20/g-s1-49696/trump-cabinet-picks-kash-patel
|
||||
[PBS-Patel]: https://www.pbs.org/newshour/show/senators-ask-fbi-director-nominee-kash-patel-about-enemies-list-and-politicization
|
||||
{{% /details %}}
|
||||
|
||||
{{% details summary="**8. Firing Military Leadership (February 21)** --- LR = 15" %}}
|
||||
As described in the worked example above, the administration [fired the
|
||||
Chairman of the Joint Chiefs of Staff, the Chief of Naval Operations, the
|
||||
Air Force Vice Chief of Staff, and several Judge Advocates General][PBS-Military]
|
||||
in a single day. An administration asserting strong civilian control of the
|
||||
military could justify individual leadership changes. The mass and
|
||||
simultaneous nature of these firings, however, goes well beyond that.
|
||||
|
||||
\( P(E \mid A) = 0.75 \), \( P(E \mid \neg A) = 0.05 \), \( \text{LR} = 15 \).
|
||||
|
||||
[PBS-Military]: https://www.pbs.org/newshour/politics/trump-fires-gen-cq-brown-as-chairman-of-the-joint-chiefs-of-staff
|
||||
{{% /details %}}
|
||||
|
||||
|
||||
## Analysis
|
||||
|
||||
```{r evidence}
|
||||
prior_prob <- 0.01
|
||||
prior_odds <- prior_prob / (1 - prior_prob)
|
||||
|
||||
evidence <- tribble(
|
||||
~event, ~date, ~p_hyp, ~p_alt,
|
||||
"Firing Inspectors General", ymd("2025-01-24"), 0.90, 0.10,
|
||||
"Shutting Down USAID", ymd("2025-02-02"), 0.80, 0.03,
|
||||
"Accessing Government Systems", ymd("2025-02-04"), 0.70, 0.15,
|
||||
"Mass Firing Federal Workers", ymd("2025-02-13"), 0.90, 0.10,
|
||||
"Executive Law Interpretation", ymd("2025-02-18"), 0.80, 0.10,
|
||||
"\"Long Live the King\" Post", ymd("2025-02-19"), 0.70, 0.10,
|
||||
"Confirming Kash Patel as FBI Dir.", ymd("2025-02-20"), 0.85, 0.05,
|
||||
"Firing Military Leadership", ymd("2025-02-21"), 0.75, 0.05,
|
||||
) |>
|
||||
arrange(date) |>
|
||||
mutate(
|
||||
lr = p_hyp / p_alt,
|
||||
lr_cum = cumprod(lr),
|
||||
odds_post = prior_odds * lr_cum,
|
||||
prob_post = odds_post / (1 + odds_post)
|
||||
)
|
||||
```
|
||||
|
||||
With a prior of `r scales::percent(prior_prob)` and the likelihood ratios
|
||||
assessed above, the following table summarizes the evidence:
|
||||
|
||||
```{r evidence_tbl, results="asis"}
|
||||
options(reactable.static = TRUE)
|
||||
|
||||
evidence |>
|
||||
select(event, date, p_hyp, p_alt, lr) |>
|
||||
reactable(
|
||||
columns = list(
|
||||
event = colDef(name = "Event", minWidth = 200),
|
||||
date = colDef(name = "Date", align = "center", minWidth = 100),
|
||||
p_hyp = colDef(
|
||||
name = "P(E | A)",
|
||||
align = "right",
|
||||
format = colFormat(digits = 2)
|
||||
),
|
||||
p_alt = colDef(
|
||||
name = "P(E | \u00acA)",
|
||||
align = "right",
|
||||
format = colFormat(digits = 2)
|
||||
),
|
||||
lr = colDef(
|
||||
name = "LR",
|
||||
align = "right",
|
||||
format = colFormat(digits = 1)
|
||||
)
|
||||
),
|
||||
sortable = FALSE,
|
||||
fullWidth = TRUE
|
||||
)
|
||||
```
|
||||
|
||||
Each piece of evidence updates our belief. The table below shows the cumulative
|
||||
posterior probability after incorporating each event in sequence:
|
||||
|
||||
```{r posterior_tbl, results="asis"}
|
||||
evidence |>
|
||||
select(event, date, odds_post, prob_post) |>
|
||||
reactable(
|
||||
columns = list(
|
||||
event = colDef(name = "Event", minWidth = 200),
|
||||
date = colDef(name = "Date", align = "center", minWidth = 100),
|
||||
odds_post = colDef(
|
||||
name = "Posterior Odds",
|
||||
align = "right",
|
||||
format = colFormat(digits = 2)
|
||||
),
|
||||
prob_post = colDef(
|
||||
name = "Posterior P(A)",
|
||||
align = "right",
|
||||
cell = function(value) scales::percent(value, accuracy = 0.1)
|
||||
)
|
||||
),
|
||||
sortable = FALSE,
|
||||
fullWidth = TRUE
|
||||
)
|
||||
```
|
||||
|
||||
And as a plot:
|
||||
|
||||
```{r posterior_plot, fig.width=8, fig.height=5}
|
||||
bind_rows(
|
||||
tibble(
|
||||
date = ymd("2025-01-20"),
|
||||
prob_post = prior_prob,
|
||||
event = "Prior (Inauguration)"
|
||||
),
|
||||
evidence |> select(date, prob_post, event)
|
||||
) |>
|
||||
ggplot(aes(x = date, y = prob_post)) +
|
||||
geom_step(linewidth = 0.8, color = "#2c3e50") +
|
||||
geom_point(size = 2.5, color = "#2c3e50") +
|
||||
scale_y_continuous(
|
||||
labels = scales::percent,
|
||||
limits = c(0, 1),
|
||||
breaks = seq(0, 1, 0.1)
|
||||
) +
|
||||
scale_x_date(
|
||||
date_breaks = "1 week",
|
||||
date_labels = "%b %d"
|
||||
) +
|
||||
labs(
|
||||
x = NULL,
|
||||
y = "P(Autogolpe)",
|
||||
title = "Cumulative Posterior Probability of an Autogolpe",
|
||||
subtitle = paste("Prior:", scales::percent(prior_prob))
|
||||
) +
|
||||
theme_minimal(base_size = 14) +
|
||||
theme(
|
||||
plot.background = element_rect(fill = "transparent", color = NA),
|
||||
panel.background = element_rect(fill = "transparent", color = NA),
|
||||
panel.grid.minor = element_blank()
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
## Try Your Own Values
|
||||
|
||||
The likelihood ratios above are my assessments. You may disagree. The
|
||||
[interactive simulator](simulator/) lets you adjust the prior probability
|
||||
and each event's likelihoods to see how your own assumptions change the
|
||||
conclusion.
|
||||
|
||||
|
||||
## Discussion
|
||||
|
||||
Throughout this analysis, I have tried to give the alternative hypothesis
|
||||
every benefit of the doubt. The \( P(E \mid \neg A) \) values are benchmarked
|
||||
not against a normal administration, but against the most aggressive,
|
||||
norm-breaking administration that is _not_ staging a coup. Even so, with a 1%
|
||||
prior, the posterior probability rises steeply. The cumulative weight of
|
||||
eight pieces of evidence across distinct domains of government is difficult to
|
||||
dismiss.
|
||||
|
||||
There are several important caveats:
|
||||
|
||||
**Subjectivity of likelihoods.** The likelihood ratios are my subjective
|
||||
assessments. Reasonable people will disagree. The value of this framework is not
|
||||
that it produces a single "correct" answer, but that it forces us to state our
|
||||
assumptions explicitly and see their consequences. If you think the likelihood
|
||||
of mass IG firings under aggressive-but-not-coup governance is 30% rather than
|
||||
10%, try it in the [simulator](simulator/) and see how the posterior changes.
|
||||
|
||||
**Independence assumption.** The odds form of Bayes' theorem assumes that
|
||||
the pieces of evidence are _conditionally independent_---that is, knowing one
|
||||
event occurred does not change the probability of another, given the hypothesis.
|
||||
In reality, these events are correlated, and it is worth asking which
|
||||
direction that cuts.
|
||||
|
||||
Under the autogolpe hypothesis, the events naturally co-occur---they are
|
||||
parts of a coherent program of power consolidation. The joint probability
|
||||
\( P(E_1, \dots, E_8 \mid A) \) is likely similar to or higher than the
|
||||
product of the marginals.
|
||||
|
||||
Under the alternative, the correlation works differently. An aggressive
|
||||
populist administration might plausibly do two or three of these things. But
|
||||
all eight---spanning military leadership, the civil service, Congressional
|
||||
agencies, financial systems, law enforcement, judicial authority, and official
|
||||
symbolism? The probability of seeing _all_ of them together under \( \neg A \)
|
||||
is much lower than the product of the individual \( P(E_i \mid \neg A) \)
|
||||
values suggests. The independence assumption _overestimates_
|
||||
\( P(E_1, \dots, E_8 \mid \neg A) \), which means it actually favors the
|
||||
alternative hypothesis.
|
||||
|
||||
In other words, dropping the independence assumption would make the evidence
|
||||
_stronger_, not weaker. The analysis as presented is conservative.
|
||||
|
||||
Note also that the conclusion is robust to merging correlated evidence. If you
|
||||
collapse related events---say, combining the IG firings and mass worker firings
|
||||
into a single "government personnel purge," or combining USAID and the
|
||||
executive law interpretation order into "asserting supremacy over
|
||||
Congress"---you still have four or five events with substantial likelihood
|
||||
ratios. The conclusion does not depend on counting finely.
|
||||
|
||||
**Sensitivity to the prior.** Even with a very skeptical prior---say
|
||||
0.1%---the cumulative evidence still pushes the posterior well above 99%. The
|
||||
analysis is robust to a wide range of starting assumptions. Conversely, even
|
||||
setting every \( P(E \mid \neg A) \) to 0.20---an extraordinarily charitable
|
||||
reading---the posterior from a 1% prior still exceeds 99%.
|
||||
|
||||
There is, however, one prior that no amount of evidence can move: zero. If
|
||||
\( P(A) = 0 \), then the prior odds are zero, and zero times any likelihood
|
||||
ratio is still zero. The posterior is always zero, no matter what happens.
|
||||
A prior of zero is not skepticism---it is an axiom. It is "it can't happen
|
||||
here" treated not as an empirical claim to be tested but as an article of
|
||||
faith. Bayesian reasoning has nothing to offer someone in that position,
|
||||
because the conclusion has been defined in advance of the evidence. The same
|
||||
is true in the other direction: a prior of one---complete certainty that a
|
||||
coup is underway before any evidence---is equally immune to updating. Neither
|
||||
extreme can learn.
|
||||
|
||||
**What this does not tell us.** This analysis addresses a single binary
|
||||
question: is the administration staging an autogolpe? It does not tell us
|
||||
whether they will succeed, what the consequences will be, or what anyone should
|
||||
do about it. This post is ultimately a demonstration of how to think rationally
|
||||
in a turbulent situation.
|
||||
|
||||
|
||||
[PK]: https://paulkrugman.substack.com/p/autogolpe
|
||||
[RR]: https://robertreich.substack.com/p/say-what-it-is-a-coup
|
||||
[Wiki-Bayesian]: https://en.wikipedia.org/wiki/Bayesian_inference
|
||||
[NPR-IG]: https://www.npr.org/2025/01/25/g-s1-44771/trump-fires-inspectors-general
|
||||
[WaPo-USAID]: https://www.washingtonpost.com/politics/2025/02/02/usaid-trump-musk/
|
||||
[NPR-Treasury]: https://www.npr.org/2025/02/04/nx-s1-5285403/musks-doge-group-has-access-to-the-federal-payments-system-what-does-that-mean
|
||||
[WaPo-OPM]: https://www.washingtonpost.com/national-security/2025/02/06/elon-musk-doge-access-personnel-data-opm-security/
|
||||
[NPR-Layoffs]: https://www.npr.org/2025/02/13/nx-s1-5296928/layoffs-trump-doge-education-energy
|
||||
[NPR-EO]: https://www.npr.org/2025/02/19/nx-s1-5302481/trump-independent-agencies
|
||||
[NBC-King]: https://www.nbcnews.com/politics/donald-trump/king-trump-rcna192912
|
||||
[NPR-Patel]: https://www.npr.org/2025/02/20/g-s1-49696/trump-cabinet-picks-kash-patel
|
||||
[PBS-Patel]: https://www.pbs.org/newshour/show/senators-ask-fbi-director-nominee-kash-patel-about-enemies-list-and-politicization
|
||||
[PBS-Military]: https://www.pbs.org/newshour/politics/trump-fires-gen-cq-brown-as-chairman-of-the-joint-chiefs-of-staff
|
||||
@@ -0,0 +1,288 @@
|
||||
---
|
||||
title: 'Bayesian Autogolpe Simulator'
|
||||
description: >
|
||||
Adjust the prior probability and each event's likelihoods to see how
|
||||
your own assumptions change the conclusion.
|
||||
ShowToc: false
|
||||
---
|
||||
|
||||
The likelihood ratios in the [analysis](..) are my assessments. You may
|
||||
disagree. The simulator below lets you adjust the prior probability and each
|
||||
event's likelihoods to see how your own assumptions change the conclusion. You
|
||||
can also uncheck events to exclude them entirely.
|
||||
|
||||
<style>
|
||||
#bayes-sim {
|
||||
margin: 1.5em 0;
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
}
|
||||
#bayes-sim .sim-prior-ctl {
|
||||
margin-bottom: 1.5em;
|
||||
padding: 1em;
|
||||
border: 1px solid var(--border, #ddd);
|
||||
border-radius: 6px;
|
||||
background: var(--code-bg, #f6f6f6);
|
||||
}
|
||||
#bayes-sim .sim-prior-ctl label {
|
||||
font-weight: 600;
|
||||
}
|
||||
#bayes-sim .sim-prior-ctl input[type="range"] {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
display: block;
|
||||
margin-top: 0.4em;
|
||||
}
|
||||
#bayes-sim .sim-wrap {
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
#bayes-sim table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
#bayes-sim th,
|
||||
#bayes-sim td {
|
||||
padding: 0.4em 0.6em;
|
||||
border-bottom: 1px solid var(--border, #ddd);
|
||||
}
|
||||
#bayes-sim th {
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#bayes-sim .ev-name {
|
||||
min-width: 150px;
|
||||
}
|
||||
#bayes-sim .sl-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
#bayes-sim .sl-cell input[type="range"] {
|
||||
width: 80px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
#bayes-sim .v {
|
||||
display: inline-block;
|
||||
width: 2.8em;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
#bayes-sim .lr-cell {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
#bayes-sim .bars {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
#bayes-sim .b-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
#bayes-sim .b-lbl {
|
||||
width: 190px;
|
||||
font-size: 0.82em;
|
||||
text-align: right;
|
||||
padding-right: 8px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
#bayes-sim .b-track {
|
||||
flex: 1;
|
||||
height: 18px;
|
||||
background: var(--code-bg, #f0f0f0);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
#bayes-sim .b-fill {
|
||||
height: 100%;
|
||||
background: var(--primary, #2c3e50);
|
||||
border-radius: 3px;
|
||||
transition: width 0.15s ease;
|
||||
min-width: 0;
|
||||
}
|
||||
#bayes-sim .b-pct {
|
||||
width: 4.5em;
|
||||
font-size: 0.82em;
|
||||
text-align: right;
|
||||
padding-left: 6px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#bayes-sim .sim-actions {
|
||||
margin-top: 1em;
|
||||
}
|
||||
#bayes-sim button {
|
||||
padding: 0.35em 0.9em;
|
||||
border: 1px solid var(--border, #ddd);
|
||||
border-radius: 4px;
|
||||
background: var(--code-bg, #f6f6f6);
|
||||
color: var(--content, #333);
|
||||
cursor: pointer;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
#bayes-sim button:hover {
|
||||
background: var(--border, #ddd);
|
||||
}
|
||||
#bayes-sim .cb {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="bayes-sim">
|
||||
<div class="sim-prior-ctl">
|
||||
<label for="sim-prior">Prior probability P(A): <span id="sim-prior-v">1.0%</span></label>
|
||||
<input type="range" id="sim-prior" min="0.001" max="0.500" step="0.001" value="0.010">
|
||||
</div>
|
||||
|
||||
<div class="sim-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Event</th>
|
||||
<th>P(E|A)</th>
|
||||
<th>P(E|¬A)</th>
|
||||
<th style="text-align:right">LR</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sim-tb"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="bars" id="sim-bars"></div>
|
||||
|
||||
<div class="sim-actions">
|
||||
<button id="sim-reset">Reset to defaults</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var E = [
|
||||
{n:"Firing Inspectors General", h:0.90, a:0.10},
|
||||
{n:"Shutting Down USAID", h:0.80, a:0.03},
|
||||
{n:"Accessing Gov. Systems", h:0.70, a:0.15},
|
||||
{n:"Mass Firing Fed. Workers", h:0.90, a:0.10},
|
||||
{n:"Exec. Law Interpretation", h:0.80, a:0.10},
|
||||
{n:"\"Long Live the King\"", h:0.70, a:0.10},
|
||||
{n:"Kash Patel as FBI Dir.", h:0.85, a:0.05},
|
||||
{n:"Firing Military Leadership", h:0.75, a:0.05}
|
||||
];
|
||||
var DP = 0.01;
|
||||
var D = E.map(function(e){return {h:e.h, a:e.a};});
|
||||
|
||||
var ps = document.getElementById("sim-prior");
|
||||
var pv = document.getElementById("sim-prior-v");
|
||||
var tb = document.getElementById("sim-tb");
|
||||
var bd = document.getElementById("sim-bars");
|
||||
|
||||
// Build table rows
|
||||
E.forEach(function(e, i) {
|
||||
var tr = document.createElement("tr");
|
||||
|
||||
var c1 = document.createElement("td");
|
||||
var cb = document.createElement("input");
|
||||
cb.type = "checkbox"; cb.checked = true;
|
||||
cb.className = "cb"; cb.dataset.i = i;
|
||||
cb.addEventListener("change", up);
|
||||
c1.appendChild(cb); tr.appendChild(c1);
|
||||
|
||||
var c2 = document.createElement("td");
|
||||
c2.className = "ev-name"; c2.textContent = e.n;
|
||||
tr.appendChild(c2);
|
||||
|
||||
tr.appendChild(mkSlider(i, "h", e.h));
|
||||
tr.appendChild(mkSlider(i, "a", e.a));
|
||||
|
||||
var c5 = document.createElement("td");
|
||||
c5.className = "lr-cell"; c5.id = "lr"+i;
|
||||
tr.appendChild(c5);
|
||||
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
|
||||
// Build bar chart
|
||||
mkBar("prior", "Prior");
|
||||
E.forEach(function(e, i){ mkBar(i, e.n); });
|
||||
|
||||
function mkSlider(i, t, val) {
|
||||
var td = document.createElement("td");
|
||||
td.className = "sl-cell";
|
||||
var s = document.createElement("input");
|
||||
s.type = "range"; s.min = "0.01"; s.max = "0.99";
|
||||
s.step = "0.01"; s.value = val; s.id = t+i;
|
||||
s.addEventListener("input", up);
|
||||
var v = document.createElement("span");
|
||||
v.className = "v"; v.id = t+"v"+i;
|
||||
td.appendChild(s); td.appendChild(v);
|
||||
return td;
|
||||
}
|
||||
|
||||
function mkBar(id, label) {
|
||||
var r = document.createElement("div");
|
||||
r.className = "b-row"; r.id = "br"+id;
|
||||
r.innerHTML =
|
||||
'<span class="b-lbl">' + label + '</span>' +
|
||||
'<div class="b-track"><div class="b-fill" id="bf'+id+'"></div></div>' +
|
||||
'<span class="b-pct" id="bp'+id+'"></span>';
|
||||
bd.appendChild(r);
|
||||
}
|
||||
|
||||
function fmt(p) {
|
||||
var pct = p * 100;
|
||||
return pct < 99.95 ? pct.toFixed(1) + "%" : ">99.9%";
|
||||
}
|
||||
|
||||
function up() {
|
||||
var pr = parseFloat(ps.value);
|
||||
pv.textContent = fmt(pr);
|
||||
var odds = pr / (1 - pr);
|
||||
|
||||
document.getElementById("bfprior").style.width = (pr*100)+"%";
|
||||
document.getElementById("bpprior").textContent = fmt(pr);
|
||||
|
||||
for (var i = 0; i < E.length; i++) {
|
||||
var h = parseFloat(document.getElementById("h"+i).value);
|
||||
var a = parseFloat(document.getElementById("a"+i).value);
|
||||
document.getElementById("hv"+i).textContent = h.toFixed(2);
|
||||
document.getElementById("av"+i).textContent = a.toFixed(2);
|
||||
var lr = h / a;
|
||||
document.getElementById("lr"+i).textContent = lr.toFixed(1);
|
||||
|
||||
var on = tb.querySelectorAll(".cb")[i].checked;
|
||||
if (on) odds *= lr;
|
||||
|
||||
var p = odds / (1 + odds);
|
||||
document.getElementById("bf"+i).style.width = (p*100)+"%";
|
||||
var row = document.getElementById("br"+i);
|
||||
if (on) {
|
||||
row.style.opacity = "1";
|
||||
document.getElementById("bp"+i).textContent = fmt(p);
|
||||
} else {
|
||||
row.style.opacity = "0.35";
|
||||
document.getElementById("bp"+i).textContent = "\u2014";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("sim-reset").addEventListener("click", function() {
|
||||
ps.value = DP;
|
||||
for (var i = 0; i < E.length; i++) {
|
||||
document.getElementById("h"+i).value = D[i].h;
|
||||
document.getElementById("a"+i).value = D[i].a;
|
||||
tb.querySelectorAll(".cb")[i].checked = true;
|
||||
}
|
||||
up();
|
||||
});
|
||||
|
||||
ps.addEventListener("input", up);
|
||||
up();
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,394 @@
|
||||
---
|
||||
title: An Ontology of Data Warehouse Layers
|
||||
subtitle: What's the Purpose of Your Data Warehouse Layers?
|
||||
date: 2026-03-30
|
||||
draft: false
|
||||
description: >
|
||||
An ontology that assigns each warehouse layer a single correctness
|
||||
guarantee --- from physical ingestion through to visual presentation ---
|
||||
resolving common debates about where tests, transformations, and quality
|
||||
logic belong.
|
||||
ShowToc: true
|
||||
TocOpen: true
|
||||
tags:
|
||||
- Data Warehouse
|
||||
xparams:
|
||||
mermaid: true
|
||||
share:
|
||||
- mastodon
|
||||
- linkedin
|
||||
---
|
||||
|
||||
The canonical texts on data warehousing --- Kimball's [*The Data Warehouse
|
||||
Toolkit*][Kimball], Inmon's [*Building the Data Warehouse*][Inmon], and
|
||||
Linstedt's [*Data Vault 2.0*][DV2] --- all describe warehouse layers in terms
|
||||
of **sequence**: source, staging, integration, presentation. Modern frameworks
|
||||
like [dbt's best practices guide][dbt-guide] continue this tradition. The
|
||||
sequence is correct, but the explanations are lacking. They prescribe the
|
||||
*order* and *scope* of transformation layers, but not the *purpose* of each
|
||||
layer.
|
||||
|
||||
What's missing is an account of **what kind of correctness each layer
|
||||
enforces**. Without that, decisions about what transformations belong in
|
||||
staging versus an intermediate layer feel arbitrary. Something "feels" wrong,
|
||||
but no guiding principle explains *why*.
|
||||
|
||||
This post proposes an ontology that resolves these questions by assigning each
|
||||
layer a single, well-defined correctness guarantee. The organizing principle
|
||||
is simple: correctness is the point --- but which kind of correctness depends
|
||||
on the layer. That distinction, extended from Codd's stratified integrity
|
||||
constraints and Wang and Strong's multidimensional quality framework to the
|
||||
full warehouse pipeline, turns intuition into policy. (For the full theoretical
|
||||
grounding, see the [companion note on foundations]({{< relref
|
||||
"warehouse-ontology-foundations" >}}).)
|
||||
|
||||
---
|
||||
|
||||
## The Core Idea
|
||||
|
||||
Each warehouse layer answers a distinct epistemic question about the data ---
|
||||
a question that *cannot be answered* until the preceding layer's question has
|
||||
been settled. The layers form a chain of preconditions:
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S["<b>Source</b><br/>Physical Correctness"]
|
||||
ST["<b>Staging</b><br/>Vertical Correctness"]
|
||||
ID["<b>Identity</b><br/>Identity Correctness"]
|
||||
INT["<b>Intermediate</b><br/>Horizontal Correctness"]
|
||||
M["<b>Marts</b><br/>Aggregate Correctness"]
|
||||
R["<b>Reports</b><br/>Evaluation Correctness"]
|
||||
D["<b>Dashboards</b><br/>Presentation Correctness"]
|
||||
|
||||
S --> ST --> ID --> INT --> M --> R --> D
|
||||
```
|
||||
|
||||
The ordering is not arbitrary. You cannot validate column types (staging) until
|
||||
the data is physically present (source). You cannot assign stable identity
|
||||
until columns are typed. You cannot assess cross-field consistency
|
||||
(intermediate) until you can uniquely address each entity (identity). Each
|
||||
layer's invariant is a *precondition* for the next.
|
||||
|
||||
This is not about *when* a rule can run. It's about **where it belongs**.
|
||||
|
||||
---
|
||||
|
||||
## Source: Physical Correctness
|
||||
|
||||
> *Is the data physically accessible?*
|
||||
|
||||
The source layer enforces **physical correctness**. Its sole concern is whether
|
||||
the pipeline can ingest the data.
|
||||
|
||||
**Invariant:** Data exists at the expected location in the expected format.
|
||||
|
||||
| Error | Example | Disposition |
|
||||
|:---|:---|:---|
|
||||
| Access | Cannot connect, network timeout | **Build failure** |
|
||||
| Authentication | Missing or expired credentials | **Build failure** |
|
||||
| Location | Data not at expected path | **Build failure** |
|
||||
| Format | CSV where Parquet expected | **Build failure** |
|
||||
| Freshness | Data older than threshold | **Warning or failure** |
|
||||
|
||||
Every failure is a hard stop. If the data cannot arrive, no downstream question
|
||||
is even meaningful.
|
||||
|
||||
---
|
||||
|
||||
## Staging: Vertical Correctness
|
||||
|
||||
> *Does each column individually conform to its declared contract?*
|
||||
|
||||
Staging enforces **column-by-column correctness**. Each field is validated in
|
||||
isolation, without reference to other fields --- hence *vertical*, down the
|
||||
column.
|
||||
|
||||
**Invariant:** Every column conforms to its declared type and constraints.
|
||||
|
||||
| Error | Example | Disposition |
|
||||
|:---|:---|:---|
|
||||
| Type violation | String in an integer column | **Build failure** |
|
||||
| Null violation | Null in a non-nullable column | **Build failure** |
|
||||
| Range violation | Negative value for a positive-only field | **Build failure** |
|
||||
| Enum violation | Unexpected value in a constrained set | **Build failure** |
|
||||
| Encoding error | Malformed UTF-8 | **Build failure** |
|
||||
|
||||
If a rule references two or more columns or values within a column, it belongs
|
||||
downstream.
|
||||
|
||||
---
|
||||
|
||||
## Identity: Identity Correctness
|
||||
|
||||
> *Is every entity uniquely and fully identified?*
|
||||
|
||||
The identity layer establishes that each entity in the warehouse can be
|
||||
**uniquely addressed**. Its justification is a dependency: row identification
|
||||
requires *inspecting and comparing values*, and that inspection is only
|
||||
meaningful once column types are settled. A natural key typed as `VARCHAR`
|
||||
when it should be `INTEGER` may appear unique while hiding duplicates ---
|
||||
`'1'` and `'1 '` are distinct strings but the same entity. The pipeline must
|
||||
trust the type before value-level comparison is sound.
|
||||
|
||||
This is why identity correctness is not simply vertical correctness applied
|
||||
to a key column. Vertical correctness asks: *is this value a valid member of
|
||||
its declared domain?* Identity correctness asks: *are the values across
|
||||
natural key columns unique?* The second question cannot be answered until the
|
||||
first has been settled.
|
||||
|
||||
**Invariant:** Every entity is uniquely identified by its natural key. No
|
||||
natural key is null or duplicated.
|
||||
|
||||
| Error | Example | Disposition |
|
||||
|:---|:---|:---|
|
||||
| Duplicate natural key, conflicting attributes | Two rows claim the same identity with different values | **Build failure** |
|
||||
| Duplicate natural key, identical rows | Re-delivered or fanned-out rows from upstream | **Deduplicate** |
|
||||
| Null natural key | Entity cannot be identified | **Build failure** |
|
||||
|
||||
Conflicting duplicates are a hard stop --- the pipeline has no principled way
|
||||
to resolve them without domain knowledge. Identical duplicates are an expected
|
||||
artifact of sources outside your control and are resolved silently by
|
||||
deduplication.
|
||||
|
||||
---
|
||||
|
||||
## Intermediate: Horizontal Correctness[^intermediate]
|
||||
|
||||
> *Are the columns internally consistent within each row?*
|
||||
|
||||
Intermediate models enforce **cross-field semantics** --- hence *horizontal*,
|
||||
across the row. This is where meaning emerges.
|
||||
|
||||
**Invariant:** Cross-column relationships within a row satisfy the domain's
|
||||
business rules.
|
||||
|
||||
This layer has a critical distinction: **not all errors are build failures**.
|
||||
Some data may be structurally valid but semantically questionable --- an event
|
||||
with an implausible timestamp, a session with contradictory flags. Rather than
|
||||
rejecting this data, the intermediate layer **classifies** it. The pipeline
|
||||
records classified rows with a severity and passes them downstream, where a
|
||||
consumption gate determines whether they reach consumers. (The
|
||||
[implications](#the-intermediate-layer-requires-a-quality-protocol) section
|
||||
below describes this protocol in detail.)
|
||||
|
||||
| Error | Example | Disposition |
|
||||
|:---|:---|:---|
|
||||
| Structural violation | Grain not unique, required FK null | **Build failure** |
|
||||
| Temporal inconsistency | `introduced_at` after `removed_at` | **Classified** |
|
||||
| Cross-field contradiction | Mutually exclusive flags both set | **Classified** |
|
||||
| Implausible value | Event predates its session by hours | **Classified** |
|
||||
| Missing relationship | Orphaned foreign key | **Classified** |
|
||||
|
||||
This is the only layer where quality classification occurs. Upstream layers
|
||||
fail hard. Downstream layers consume the classifications --- they do not
|
||||
reinterpret them.
|
||||
|
||||
---
|
||||
|
||||
## Marts: Aggregate Correctness
|
||||
|
||||
> *Is the analytical surface truthful?*
|
||||
|
||||
Marts are the semantic contract between the warehouse and its consumers. They
|
||||
expose facts, dimensions, and aggregates that downstream systems treat as
|
||||
ground truth.
|
||||
|
||||
**Invariant:** All rows represent truthful business entities at the declared
|
||||
grain. No row that the intermediate layer classified as untrustworthy is
|
||||
present.
|
||||
|
||||
| Error | Example | Disposition |
|
||||
|:---|:---|:---|
|
||||
| Grain violation | Duplicate rows at the declared key | **Build failure** |
|
||||
| FK integrity | Fact references nonexistent dimension | **Build failure** |
|
||||
| FK null | Required foreign key is null | **Build failure** |
|
||||
| Untrustworthy data present | Row classified as blocking by intermediate | **Build failure** |
|
||||
|
||||
Marts consume the intermediate layer's quality classifications. They do not
|
||||
reinterpret or re-derive them.
|
||||
|
||||
---
|
||||
|
||||
## Consumption Layers
|
||||
|
||||
Below the mart, correctness is structural and machine-verifiable. Above it,
|
||||
correctness is semantic and perceptual. The lower layers deal in types,
|
||||
uniqueness, and referential integrity --- properties that automated tests can
|
||||
confirm. Reports and dashboards deal in *meaning* and *perception*, which
|
||||
require human judgment. This section frames correctness in terms of human
|
||||
consumers, but the concepts apply equally to machine consumers; only the
|
||||
vocabulary at the upper layers changes.
|
||||
|
||||
### Reports: Evaluation Correctness
|
||||
|
||||
> *Does the report faithfully answer the business question?*
|
||||
|
||||
Reports are built from marts. They exist to answer specific questions ---
|
||||
cohort retention, campaign ROI, weekly active users. Where marts provide
|
||||
general-purpose analytical surfaces, reports provide purpose-built answers.
|
||||
|
||||
**Invariant:** The computation faithfully serves the business question it
|
||||
claims to answer.
|
||||
|
||||
| Error | Example | Disposition |
|
||||
|:---|:---|:---|
|
||||
| Grain violation | Duplicate rows at report key | **Build failure** |
|
||||
| FK null | Required foreign key is null | **Build failure** |
|
||||
| Incorrect aggregation | SUM where COUNT DISTINCT required | **Logic error** |
|
||||
| Wrong time window | 28-day window when question asks for 30 | **Logic error** |
|
||||
| Misleading metric | "Active users" that includes bots | **Logic error** |
|
||||
|
||||
Grain and null constraints are mechanically enforced. The remaining errors
|
||||
are design errors that require human review.
|
||||
|
||||
### Dashboards: Presentation Correctness
|
||||
|
||||
> *Does the consumer perceive what the data actually says?*
|
||||
|
||||
Dashboards are the final mile. The data is correct; the question is whether
|
||||
the presentation faithfully communicates it.
|
||||
|
||||
**Invariant:** The visual representation does not distort, obscure, or
|
||||
mislead.
|
||||
|
||||
| Error | Example | Disposition |
|
||||
|:---|:---|:---|
|
||||
| Truncated axis | Y-axis starts at 98%, exaggerating a 2% drop | **Design error** |
|
||||
| Missing context | Absolute numbers without normalizing for cohort size | **Design error** |
|
||||
| Stale cache | Yesterday's data displayed as current | **Configuration error** |
|
||||
| Wrong granularity | Daily data shown as monthly without aggregation | **Logic error** |
|
||||
| Label mismatch | Legend says "revenue" but chart shows bookings | **Design error** |
|
||||
|
||||
None of these are build failures. They are communication failures at the
|
||||
human-system boundary, caught through review and user feedback.
|
||||
|
||||
---
|
||||
|
||||
## The Full Ontology
|
||||
|
||||
| Layer | Correctness | Question |
|
||||
|:---|:---|:---|
|
||||
| Source | Physical | Is the data accessible? |
|
||||
| Staging | Vertical | Does each column conform? |
|
||||
| Identity | Identity | Can every entity be addressed? |
|
||||
| Intermediate | Horizontal | Are the columns consistent? |
|
||||
| Marts | Aggregate | Is the analytical surface truthful? |
|
||||
| Reports | Evaluation | Does it answer the right question? |
|
||||
| Dashboards | Presentation | Does the reader perceive the truth? |
|
||||
|
||||
---
|
||||
|
||||
## Implications
|
||||
|
||||
The ontology is not merely a taxonomy. Once each layer has a defined
|
||||
responsibility, several architectural decisions follow as consequences rather
|
||||
than choices.
|
||||
|
||||
### Surrogate keys should be sequential integers
|
||||
|
||||
If the identity layer's job is to establish that each entity is uniquely
|
||||
identified, the simplest correct key mechanism is a sequential integer (e.g.,
|
||||
`BIGINT`). Hashing natural keys (via `md5` or `sha256` truncated to an
|
||||
integer) is tempting because it avoids a registry lookup, but it introduces
|
||||
collision risk --- small for any single key, but compounding across billions of
|
||||
rows and dozens of entity types. UUIDs avoid collisions but are 128-bit,
|
||||
unordered, and poorly suited to range scans and sort-merge joins. Neither
|
||||
mechanism offers a benefit that a sequential integer does not, and both carry
|
||||
costs that a sequential integer avoids. An append-only registry most directly
|
||||
satisfies the identity layer's invariant --- one key per entity, stable across
|
||||
runs.
|
||||
|
||||
### The intermediate layer requires a quality protocol
|
||||
|
||||
The intermediate layer is the only layer where errors are classified rather
|
||||
than causing failures. This requires a structured protocol:
|
||||
|
||||
1. **Classify** --- domain-specific models identify issues and assign a
|
||||
severity (error, warn, info).
|
||||
2. **Aggregate** --- a central function aggregates issues per entity into
|
||||
policy flags: *is the row blocked from consumption?* and *is the row
|
||||
suspect?*
|
||||
3. **Gate** --- marts consume only non-blocked rows.
|
||||
|
||||
The separation is load-bearing. Domains own *what is wrong* (classification).
|
||||
The protocol owns *what that means for consumption* (status). Marts never
|
||||
interpret issues --- they consume the protocol's output. The pipeline never
|
||||
destroys data, only classifies it, and consumption is always explicit.
|
||||
|
||||
### Correctness is incremental and testable
|
||||
|
||||
Because each layer has exactly one invariant, testing follows naturally. Source
|
||||
tests check physical access. Staging tests check types and nullability.
|
||||
Identity tests check uniqueness and key stability. Intermediate tests check
|
||||
cross-field invariants. Mart tests check grain and referential integrity.
|
||||
Failures are localized: a staging failure means a column is wrong, not that a
|
||||
business metric is wrong. The blast radius of any failure is bounded by the
|
||||
layer it occurs in.
|
||||
|
||||
### Layer boundaries constrain transformation logic
|
||||
|
||||
The ontology determines not just *where* logic belongs but *where it cannot*.
|
||||
Format parsing occurs in staging. Surrogate key assignment occurs in identity.
|
||||
Quality classification occurs in intermediate. When a practitioner asks "where
|
||||
does this transformation go?", the answer follows from the kind of correctness
|
||||
the transformation enforces --- not from convention, convenience, or the
|
||||
limitations of the toolchain.
|
||||
|
||||
---
|
||||
|
||||
## The Ontology in Action
|
||||
|
||||
To see how this resolves real disagreements, consider a common debate: where
|
||||
does a timestamp validation belong?
|
||||
|
||||
A team has an `event_at` column that occasionally contains values in the far
|
||||
future --- year 2099. Two developers disagree. One says the check belongs in
|
||||
staging: "it's a column-level validation." The other says intermediate: "it's
|
||||
a business rule."
|
||||
|
||||
Under this ontology, the answer depends on the rule:
|
||||
|
||||
- *Is `event_at` a valid timestamp?* --- staging. This is vertical correctness:
|
||||
does the value belong to its declared domain?
|
||||
- *Is `event_at` before `removed_at`?* --- intermediate. This is horizontal
|
||||
correctness: do two columns make sense together?
|
||||
- *Is `event_at` in the year 2099?* --- this depends on the domain. If no
|
||||
valid event can have a future timestamp, it is a range check (staging). If
|
||||
future timestamps are structurally valid but semantically suspect --- perhaps
|
||||
the system permits scheduled events --- it is a classification decision
|
||||
(intermediate).
|
||||
|
||||
The ontology does not eliminate all judgment. But it narrows the judgment to a
|
||||
precise question: *what kind of correctness does this rule enforce?* The layer
|
||||
follows from the answer.
|
||||
|
||||
---
|
||||
|
||||
## Closing Thought
|
||||
|
||||
Most warehouse architectures fail not because they are wrong, but because
|
||||
they are **underspecified**. Kimball, Inmon, and Linstedt all provide correct
|
||||
sequences. What they do not provide is a *theory of why* the sequence is what
|
||||
it is.
|
||||
|
||||
This ontology succeeds because it is *orthogonal* (each layer has a distinct
|
||||
responsibility), *lossless* (no real rules are excluded), and *actionable*
|
||||
(it tells you where things go and where they cannot). Naming the kind of
|
||||
correctness each layer owns turns bikeshedding into decisions, intuition into
|
||||
policy, and folklore into design.
|
||||
|
||||
When something breaks, the layer tells you what kind of thing broke. When
|
||||
teams disagree, the ontology gives them a shared vocabulary for the
|
||||
disagreement. That is the practical payoff: not a pipeline that merely runs,
|
||||
but one whose guarantees are explicit enough to reason about, debug, and
|
||||
trust.
|
||||
|
||||
|
||||
[^intermediate]: The name "intermediate" describes position, not purpose.
|
||||
Labeling the layer *domain* signals that domain knowledge lives in this
|
||||
layer. However, I am sticking with "intermediate" in this post to avoid
|
||||
unfamiliar terminology alongside an unfamiliar framework.
|
||||
|
||||
[Kimball]: https://www.kimballgroup.com/data-warehouse-business-intelligence-resources/books/data-warehouse-dw-toolkit/
|
||||
[Inmon]: https://www.wiley.com/en-us/Building+the+Data+Warehouse%2C+4th+Edition-p-9780764599446
|
||||
[DV2]: https://danlinstedt.com/solutions-2/data-vault-basics/
|
||||
[dbt-guide]: https://docs.getdbt.com/best-practices/how-we-structure/1-guide-overview
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: Theoretical Foundations of the Warehouse Ontology
|
||||
date: 2026-03-30
|
||||
slug: foundations
|
||||
draft: false
|
||||
tags:
|
||||
- Data Warehouse
|
||||
- Data Quality
|
||||
---
|
||||
|
||||
The [warehouse layer ontology](/2026/03/30/an-ontology-of-data-warehouse-layers/)
|
||||
assigns each layer a single correctness guarantee. The idea is not new --- it
|
||||
extends work that already exists at smaller scales.
|
||||
|
||||
## Codd's Relational Integrity Constraints
|
||||
|
||||
Codd's [relational integrity constraints][Codd] showed that correctness within
|
||||
a single database is not monolithic but *stratified*: domain constraints (each
|
||||
value in range), entity integrity (each row identifiable), and referential
|
||||
integrity (cross-table relationships consistent) form a hierarchy where each
|
||||
level presupposes the one below it.
|
||||
|
||||
The warehouse ontology takes that hierarchy and extends it beyond the
|
||||
boundaries of a single database to the full warehouse pipeline --- from
|
||||
physical ingestion through to human perception. The lower warehouse layers
|
||||
(source, staging, identity, intermediate) recapitulate Codd's constraint
|
||||
hierarchy:
|
||||
|
||||
| Codd's Constraint | Warehouse Layer | Correctness |
|
||||
|:---|:---|:---|
|
||||
| Domain constraints | Staging | Vertical --- each column conforms to its type |
|
||||
| Entity integrity | Identity | Identity --- each entity uniquely addressable |
|
||||
| Referential integrity | Intermediate | Horizontal --- cross-field relationships consistent |
|
||||
|
||||
The upper layers (marts, reports, dashboards) continue into territory Codd did
|
||||
not address because he was working at a different level of abstraction:
|
||||
aggregate truthfulness, faithfulness to business questions, and visual
|
||||
communication.
|
||||
|
||||
## Wang and Strong's Data Quality Framework
|
||||
|
||||
Wang and Strong's [framework for data quality][WangStrong] fills in that upper
|
||||
range. Their work demonstrates that quality is multidimensional --- intrinsic,
|
||||
contextual, representational, accessible --- with dimensions that cannot be
|
||||
reduced to one another.
|
||||
|
||||
The warehouse layers above the relational level are precisely where correctness
|
||||
stops being binary (valid or invalid) and becomes graded and
|
||||
context-dependent:
|
||||
|
||||
- A mart row can be relationally correct but semantically misleading.
|
||||
- A report can be semantically correct but answer the wrong question.
|
||||
- A dashboard can answer the right question and still deceive the reader.
|
||||
|
||||
These are distinct failure modes, not degrees of the same one. The ontology
|
||||
makes this explicit by assigning each failure mode to a different layer.
|
||||
|
||||
## Synthesis
|
||||
|
||||
The contribution of the warehouse ontology is not novelty but synthesis. Codd
|
||||
provides the lower layers. Wang and Strong provide the upper layers. The
|
||||
ontology connects them into a single hierarchy that spans the full pipeline ---
|
||||
from physical ingestion to human perception --- with each layer's invariant
|
||||
serving as a precondition for the next.
|
||||
|
||||
|
||||
[Codd]: https://dl.acm.org/doi/10.1145/16301.16303
|
||||
[WangStrong]: https://doi.org/10.1006/jmis.1996.0004
|
||||
@@ -0,0 +1,282 @@
|
||||
---
|
||||
title: 'MCP for Your Data Warehouse'
|
||||
date: 2026-05-03T18:00:00
|
||||
draft: false
|
||||
tags:
|
||||
- Data Warehouse
|
||||
- MCP
|
||||
- LLM
|
||||
- dbt
|
||||
- Dagster
|
||||
xparams:
|
||||
share:
|
||||
- LinkedIn
|
||||
- Mastodon
|
||||
---
|
||||
|
||||
Business users want data that drives decisions. They want to know what
|
||||
happened, why it happened, and what to do about it --- without learning a data
|
||||
model, mastering dimensional thinking, or writing and debugging SQL. That is
|
||||
the gap a good analyst closes. Analysts use both _domain_ and _technical_
|
||||
knowledge to pull insights out of an organization's data warehouse. LLMs
|
||||
offer a different path: connect the model to the warehouse via an MCP server,
|
||||
and the user gets answers without needing the analyst's technical knowledge.
|
||||
|
||||
The promise is real. A well-connected LLM translates plain questions into
|
||||
queries, interprets results, and returns answers without requiring the user to
|
||||
understand the plumbing. The problem is not the LLM technology. The problem is
|
||||
not even the data. The problem is data _modeling_. That is, the translation of
|
||||
atomic signals into the business domain. dbt Labs' [semantic layer
|
||||
benchmark][dbt-benchmark] bears this out: adding even minimal modeling on top
|
||||
of raw tables improved LLM accuracy across the board.
|
||||
|
||||
---
|
||||
|
||||
## What is MCP?
|
||||
|
||||
[MCP][MCP] (Model Context Protocol) is a standard for connecting an LLM to
|
||||
external systems. When the external system is a data warehouse, MCP lets a
|
||||
user ask a question in plain language and get an answer back --- no query
|
||||
editor, no schema lookup, no SQL to write or debug.
|
||||
|
||||
A business user asks: *"How many users came back this week who weren't in the
|
||||
app last week?"* The LLM reads the warehouse schema via MCP, writes SQL, and
|
||||
executes it:
|
||||
|
||||
```sql
|
||||
SELECT count(DISTINCT user_id)
|
||||
FROM mart_user_activity
|
||||
WHERE activity_week = DATE_TRUNC('week', CURRENT_DATE)
|
||||
AND user_id NOT IN (
|
||||
SELECT user_id
|
||||
FROM mart_user_activity
|
||||
WHERE activity_week = DATE_TRUNC('week', CURRENT_DATE) - 7
|
||||
)
|
||||
```
|
||||
|
||||
The user sees a number and an explanation. The SQL is invisible unless a user
|
||||
requests it. That is the appeal --- and the risk. Most users are not going to
|
||||
inspect the query. Even if they did, they would be unlikely to spot an error.
|
||||
Everything depends on the LLM querying the correct tables, the trustworthiness
|
||||
of the tables, and valid interpretation of the result. An LLM might not notice
|
||||
that three days of data are missing from the monthly report --- an LLM does
|
||||
not natively understand your business domain.
|
||||
|
||||
---
|
||||
|
||||
## What is a Data Warehouse?
|
||||
|
||||
A data warehouse is not just a large database. It is a *measurement system*
|
||||
--- one organized to produce answers to business questions in a form that is
|
||||
truthful and interpretable.
|
||||
|
||||
What makes a warehouse trustworthy is layered correctness. Raw data enters the
|
||||
pipeline unvalidated. Each transformation layer enforces a guarantee --- types
|
||||
conform, entities are uniquely identified, cross-field relationships are
|
||||
consistent --- so that by the time data reaches the analytical surface,
|
||||
a chain of checks has been applied. (See [this post][ontology] for a more
|
||||
comprehensive explanation of using a layered model to build a data warehouse.)
|
||||
The analytical surface --- marts, reports, or however your warehouse organizes
|
||||
its consumption layers --- is where these guarantees culminate.
|
||||
|
||||
The analytical surface is designed for consumption; this is the surface an
|
||||
LLM should see.
|
||||
|
||||
---
|
||||
|
||||
## Problems with LLMs
|
||||
|
||||
At first glance, maybe the only step required is to restrict the LLM's access
|
||||
to the trustworthy consumption layers? Not quite. LLMs have failure modes:
|
||||
|
||||
**Inherited statistical defaults.** LLMs generate responses that reflect common
|
||||
analytical practice, and common practice is often wrong for a given dataset.
|
||||
Taking the average of a revenue distribution is arithmetically valid and
|
||||
semantically meaningless when revenue is log-normally distributed --- as it
|
||||
often is. The average is dominated by outliers and describes no typical user.
|
||||
Yet an LLM asked for "average revenue per user" will compute it dutifully. The
|
||||
failure is not ignorance --- the LLM may well know about log-normal
|
||||
distributions --- but defaults. Without a signal to do otherwise, it reaches
|
||||
for the common method.
|
||||
|
||||
**Absent domain knowledge.** The statistical defaults failure is about *how*
|
||||
the LLM analyzes data. This one is about *what* it analyzes. An LLM only knows
|
||||
what is in its context. If the warehouse does not encode which cohorts are
|
||||
experimental, which events are instrumentation artifacts, which markets are
|
||||
excluded from certain analyses --- the LLM has no basis for applying those
|
||||
constraints. It will answer the question that was asked, not the question that
|
||||
was meant. No amount of statistical sophistication compensates for analyzing
|
||||
the wrong population.
|
||||
|
||||
**Probabilistic non-determinism.** Ask the same question twice and the LLM may
|
||||
write different SQL --- different joins, different filters, a different
|
||||
aggregation window. Both queries might be individually correct. But their
|
||||
results may not be comparable, which means a user cannot track a metric across
|
||||
sessions or verify a prior number. Each query is fine; the system is not.
|
||||
|
||||
**Grain errors.** When an LLM joins tables at different grains --- a fact table
|
||||
to multiple dimension tables, for instance --- it can silently double-count
|
||||
rows. The query executes, returns numbers, and nothing signals that the result
|
||||
is wrong. This is one of the most common errors even experienced analysts make
|
||||
in a dimensional model. The LLM has no intuition for grain and will not stop
|
||||
to ask whether the join is safe. Prose documentation helps, but the more
|
||||
reliable fix is surfacing grain as structured metadata that the LLM encounters
|
||||
in every table description --- not as something it has to remember to look up.
|
||||
|
||||
These are not novel problems. They are the same problems that emerge from any
|
||||
analytical system built without discipline, and the mitigations are the same.
|
||||
|
||||
---
|
||||
|
||||
## Designing for the LLM
|
||||
|
||||
The failure modes above are known. Good design addresses them the way
|
||||
engineering addresses any known failure: not by hoping the system behaves, but
|
||||
by constraining it so that it cannot fail in those ways --- or fails visibly
|
||||
when it does. The following design choices address those failure modes ---
|
||||
along with the operational concerns of query safety and data freshness that
|
||||
come with letting an LLM execute SQL against a production warehouse.
|
||||
|
||||
### Restrict access to the analytical surface
|
||||
|
||||
An MCP connection should have access only to the layers designed for
|
||||
consumption --- marts, reports, or whatever your warehouse calls the surfaces
|
||||
where correctness has been enforced. Giving an LLM access to staging or
|
||||
intermediate tables is like handing an auditor the drafts folder. The data are
|
||||
there, but these are not data for analytical consumption, and drawing
|
||||
conclusions from it produces errors that are difficult to trace.
|
||||
|
||||
### Surface metadata as context
|
||||
|
||||
An LLM writing SQL against an unfamiliar schema needs more than table and
|
||||
column names. It needs grain declarations, primary and foreign keys, valid
|
||||
join paths, and column descriptions that encode business meaning. Without
|
||||
this, the LLM guesses --- and grain errors, wrong joins, and misinterpreted
|
||||
columns follow.
|
||||
|
||||
If you use [dbt][], most of this metadata already exists. The dbt manifest
|
||||
encodes table descriptions, column types, uniqueness and not-null tests (which
|
||||
identify primary keys), and relationship tests (which identify foreign keys
|
||||
and valid join paths). An MCP server can parse the manifest at startup and
|
||||
surface it as structured context --- through resource endpoints, tool
|
||||
responses, or both --- so the LLM encounters the metadata before it writes a
|
||||
query, not after it writes a wrong one.
|
||||
|
||||
Grain is especially important. Every mart should declare its grain --- one row
|
||||
per session, one row per user per day --- and that declaration should appear in
|
||||
every table description the LLM sees. An LLM that encounters grain as
|
||||
structured metadata has less room to construct a dangerous join than one that
|
||||
has to remember to look it up in a separate document.
|
||||
|
||||
### Validate SQL before execution
|
||||
|
||||
An LLM writes SQL. SQL can delete data, create users, grant privileges, and
|
||||
call functions that reach the filesystem or network. These are not
|
||||
hypothetical risks. Anthropic deprecated its own reference Postgres MCP
|
||||
server after [Datadog Security Labs demonstrated][datadog-mcp] a SQL
|
||||
injection that bypassed its application-level read-only check.
|
||||
|
||||
Two layers of defense are worth implementing:
|
||||
|
||||
1. **A read-only database role.** The MCP server's connection should use a
|
||||
database user with only `SELECT` grants on the consumption layers. This is the
|
||||
non-negotiable baseline --- application-level parsing is defense in depth,
|
||||
not a substitute.
|
||||
|
||||
2. **Query validation before execution.** Parse the SQL and reject anything
|
||||
that is not a `SELECT`. Libraries like [sqlglot][] (Python) and
|
||||
[node-sql-parser][] (TypeScript) parse SQL into an AST, letting you
|
||||
inspect statement types and block dangerous functions before a query
|
||||
reaches the database.
|
||||
|
||||
### Make freshness visible
|
||||
|
||||
An LLM has no way to know the data is stale unless something tells it. If the
|
||||
pipeline has not run in three days, the LLM will still answer confidently ---
|
||||
the query succeeds, the numbers look plausible, and nothing signals that the
|
||||
result describes last week, not yesterday.
|
||||
|
||||
Make pipeline run history queryable. If you use Dagster or Airflow, surface
|
||||
run metadata through MCP so the LLM can check when the data was last
|
||||
refreshed. Instruct it to check before answering time-sensitive questions.
|
||||
A stale answer that announces its staleness is useful; one that does not is
|
||||
dangerous.
|
||||
|
||||
### Pre-compute the measurements that matter
|
||||
|
||||
An LLM asked to summarize data will reach for the arithmetic mean. The mean
|
||||
is correct arithmetic and often meaningless measurement. Revenue per user,
|
||||
session duration, time to convert --- these are typically skewed
|
||||
distributions where the average describes no real user and is dominated by
|
||||
outliers.
|
||||
|
||||
The warehouse should not leave this to the LLM's judgment. Pre-compute the
|
||||
summary statistics that actually describe the data: medians, key percentiles,
|
||||
log-transformed values. When the right measurement is already a column, the
|
||||
LLM does not need to invent one.
|
||||
|
||||
### Write a usage guide for the LLM
|
||||
|
||||
The absent domain knowledge failure is the hardest to address structurally.
|
||||
Metadata covers the schema. The usage guide covers everything else: which
|
||||
cohorts are experimental, which metrics have known caveats, which markets are
|
||||
excluded from certain analyses, what business definitions underlie key terms.
|
||||
A markdown document at a well-known location --- queryable via MCP --- can
|
||||
supply this context. Ask users to have the LLM consult the guide at the start
|
||||
of a session.
|
||||
|
||||
The usage guide becomes load-bearing within a session. The LLM may not always
|
||||
follow it --- instrument logging to identify shortcomings. But it is
|
||||
a practical first step, and writing the guide forces the team to articulate
|
||||
institutional knowledge that is otherwise implicit.
|
||||
|
||||
### Build a verified query library
|
||||
|
||||
When a query runs successfully and an analyst confirms the result, save it ---
|
||||
the SQL, a plain-language description, and the tables it references. Hash the
|
||||
SQL for deduplication so the same query is stored once and its use count
|
||||
incremented. Expose a search tool through MCP so the LLM can find saved
|
||||
queries by description, table, or analysis type, ranked by recency and
|
||||
frequency.
|
||||
|
||||
The workflow is: search the library first, reuse a saved query if one fits,
|
||||
write new SQL only when nothing matches. Instruct the LLM to follow this
|
||||
order. When it does, the non-determinism problem shrinks --- comparable
|
||||
questions produce comparable results because the same SQL ran, not a
|
||||
differently-phrased approximation of it.
|
||||
|
||||
Log every query the LLM executes, whether saved or not. The log reveals where
|
||||
the LLM is going wrong --- wrong tables, incorrect aggregations, domain rule
|
||||
violations --- and where the warehouse should grow. Questions that recur often
|
||||
enough are candidates for a dedicated mart. The log is the clearest signal you
|
||||
have for where to invest next.
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
Business users want to ask what happened, why, and what to do about it ---
|
||||
without learning the plumbing. MCP makes that interaction possible. But the
|
||||
interaction is only as trustworthy as the data behind it.
|
||||
|
||||
The work described here is what turns a bare database connection into
|
||||
a measurement instrument that an LLM can use responsibly. None of it is new.
|
||||
All of it is load-bearing.
|
||||
|
||||
As Falconer and O'Keefe [have argued][falconer], AI will not save you from
|
||||
your data modeling problems. This post takes that observation one step further:
|
||||
for data warehouses specifically, the failure modes are known and the design
|
||||
constraints follow from them. An LLM connected to a well-designed warehouse
|
||||
closes the friction gap without introducing new ways to be wrong. The
|
||||
warehouse is the lever. The modeling is the work.
|
||||
|
||||
I would love to hear how this lands for you --- links below.
|
||||
|
||||
[MCP]: https://modelcontextprotocol.io/
|
||||
[ontology]: /posts/2026-03-30-warehouse-ontology/
|
||||
[datadog-mcp]: https://securitylabs.datadoghq.com/articles/mcp-vulnerability-case-study-SQL-injection-in-the-postgresql-mcp-server/
|
||||
[sqlglot]: https://github.com/tobymao/sqlglot
|
||||
[node-sql-parser]: https://github.com/taozhi8833998/node-sql-parser
|
||||
[dbt]: https://www.getdbt.com/
|
||||
[dbt-benchmark]: https://docs.getdbt.com/blog/semantic-layer-vs-text-to-sql-2026
|
||||
[falconer]: https://thenewstack.io/ai-wont-save-you-from-your-data-modeling-problems/
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: Share
|
||||
build:
|
||||
render: never
|
||||
list: never
|
||||
---
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Share on Mastodon"
|
||||
layout: "mastodon-share"
|
||||
robotsNoIndex: true
|
||||
build:
|
||||
list: never
|
||||
sitemap:
|
||||
priority: 0
|
||||
---
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Usage: ./hugo-new.sh [post|rmarkdown] "Awesome Post Title"
|
||||
|
||||
# Ensure Hugo is installed
|
||||
if ! command -v hugo &> /dev/null; then
|
||||
echo "Error: Hugo is not installed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check arguments
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "Usage: $0 [post|rmarkdown] \"Post Title\""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Assign input variables
|
||||
KIND="$1" # post or rmarkdown
|
||||
TITLE="$2"
|
||||
|
||||
# Validate post type and set file extension
|
||||
case "$KIND" in
|
||||
post) EXT="md" ;;
|
||||
rmarkdown) EXT="Rmd" ;;
|
||||
*)
|
||||
echo "Error: Type must be 'post' or 'rmarkdown'."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Generate ISO date prefix (YYYY-MM-DD)
|
||||
DATE=$(date +"%Y-%m-%d")
|
||||
|
||||
# Convert title to a URL-friendly slug (lowercase, dashes)
|
||||
SLUG=$(echo "${TITLE}" | tr '[:upper:]' '[:lower:]' | tr ' ' '-')
|
||||
POST_SLUG="posts/${DATE}-${SLUG}"
|
||||
POST_SLUG_EXT="${POST_SLUG}/index.${EXT}"
|
||||
|
||||
# Construct post directory
|
||||
POST_DIR="content/${POST_SLUG}"
|
||||
|
||||
# Generate the new post with the selected archetype
|
||||
hugo new --kind "$KIND" "${POST_SLUG_EXT}"
|
||||
HUGO_EXIT_CODE=$?
|
||||
|
||||
# Validate Hugo execution
|
||||
if [[ $HUGO_EXIT_CODE -ne 0 ]]; then
|
||||
echo "Error: Hugo command failed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if the expected file was created
|
||||
if [[ ! -f "$POST_DIR/index.${EXT}" ]]; then
|
||||
echo "Error: Expected file was not created: $POST_DIR/index.${EXT}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# place work in a new branch
|
||||
git checkout -b "${POST_SLUG}" || \
|
||||
echo "Failed to create brach ${POST_SLUG}"
|
||||
|
||||
# Add .gitignore for rmarkdown posts to exclude generated output
|
||||
if [[ "$KIND" == "rmarkdown" ]]; then
|
||||
cat > "$POST_DIR/.gitignore" <<'EOF'
|
||||
index.md
|
||||
figure/
|
||||
libs/
|
||||
EOF
|
||||
echo "Created .gitignore for generated output"
|
||||
fi
|
||||
|
||||
# Confirm success
|
||||
echo "New $KIND created at: $POST_DIR/index.${EXT}"
|
||||
@@ -1,5 +1,5 @@
|
||||
baseURL: "https://axs.sdf.org/"
|
||||
languageCode: en
|
||||
locale: en
|
||||
title: Andrew Stryker
|
||||
theme: PaperMod
|
||||
|
||||
@@ -8,6 +8,10 @@ enableRobotsTXT: true
|
||||
params:
|
||||
description: Just a great personal website
|
||||
|
||||
author:
|
||||
name: Andrew Stryker
|
||||
email: andrewjstryker@proton.me
|
||||
|
||||
# PaperMod settings
|
||||
ShowReadingTime: true
|
||||
ShowCodeCopyButtons: true
|
||||
@@ -16,7 +20,7 @@ params:
|
||||
disableFingerprinting: true
|
||||
|
||||
# using Chroma highlighting...
|
||||
disableHLHS: true
|
||||
disableHLJS: true
|
||||
|
||||
# where to find articles
|
||||
# https://gohugo.io/functions/collections/where/#mainsections
|
||||
@@ -63,6 +67,36 @@ params:
|
||||
- name: email
|
||||
url: "mailto:andrewjstryker@proton.me"
|
||||
|
||||
# sharing
|
||||
ShowShareButtons: true
|
||||
share:
|
||||
mastodon:
|
||||
include: true
|
||||
title: "Share on Mastodon"
|
||||
linkedin:
|
||||
include: false
|
||||
title: "Share on LinkedIn"
|
||||
url: "https://www.linkedin.com/shareArticle?mini=true&url={{ .pagePermalink | urlquery }}&title={{ .pageTitle | urlquery }}"
|
||||
twitter:
|
||||
include: false
|
||||
title: "Share on Twitter"
|
||||
url: "https://twitter.com/intent/tweet?text={{ .pageTitle | urlquery }}&url={{ .pagePermalink | urlquery }}"
|
||||
facebook:
|
||||
include: false
|
||||
title: "Share on Facebook"
|
||||
url: "https://www.facebook.com/sharer/sharer.php?u={{ .pagePermalink | urlquery }}"
|
||||
pinterest:
|
||||
include: false
|
||||
title: "Pin this on Pinterest"
|
||||
url: "https://pinterest.com/pin/create/button/?url={{ .pagePermalink | urlquery }}&description={{ .pageTitle | urlquery }}"
|
||||
whatsapp:
|
||||
include: false
|
||||
title: "Share on WhatsApp"
|
||||
url: "https://api.whatsapp.com/send?text={{ .pageTitle | urlquery }}%20{{ .pagePermalink | urlquery }}"
|
||||
|
||||
|
||||
tocWordCountThreshold: 300
|
||||
|
||||
markup:
|
||||
|
||||
# Chroma highlighting
|
||||
@@ -74,10 +108,37 @@ markup:
|
||||
codeFences: true
|
||||
guessSyntax: true
|
||||
lineNos: true
|
||||
style: monokai
|
||||
#style: solarized-dark
|
||||
# noClasses: false
|
||||
# https://gohugo.io/content-management/syntax-highlighting/#generate-syntax-highlighter-css
|
||||
style: solarized-dark
|
||||
# https://gohugo.io/getting-started/configuration-markup/#table-of-contents
|
||||
|
||||
goldmark:
|
||||
renderer:
|
||||
unsafe: true
|
||||
renderhooks:
|
||||
link:
|
||||
useEmbedded: "fallback"
|
||||
extensions:
|
||||
passthrough:
|
||||
enable: true
|
||||
delimiters:
|
||||
block:
|
||||
- - \[
|
||||
- \]
|
||||
inline:
|
||||
- - \(
|
||||
- \)
|
||||
|
||||
permalinks:
|
||||
posts: "/:year/:month/:day/:slug/"
|
||||
|
||||
ignoreFiles:
|
||||
- \.Rmd$
|
||||
- \.Rmarkdown$
|
||||
- _cache$
|
||||
- \.knit\.md$
|
||||
- \.utf8\.md$
|
||||
|
||||
# module:
|
||||
# imports:
|
||||
@@ -91,14 +152,6 @@ menu:
|
||||
name: About
|
||||
url: /about/
|
||||
weight: 10
|
||||
#- identifier: categories
|
||||
# name: Categories
|
||||
# url: /categories/
|
||||
# weight: 20
|
||||
#- identifier: tags
|
||||
# name: Tags
|
||||
# url: /tags/
|
||||
# weight: 30
|
||||
- identifier: posts
|
||||
name: Posts
|
||||
url: /posts/
|
||||
@@ -107,3 +160,11 @@ menu:
|
||||
name: Notes
|
||||
url: /notes/
|
||||
weight: 50
|
||||
- identifier: tags
|
||||
name: Tags
|
||||
url: /tags/
|
||||
weight: 90
|
||||
#- identifier: categories
|
||||
# name: Categories
|
||||
# url: /categories/
|
||||
# weight: 20
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<pre class="mermaid">
|
||||
{{ .Inner | htmlEscape | safeHTML }}
|
||||
</pre>
|
||||
@@ -0,0 +1,123 @@
|
||||
{{- define "main" }}
|
||||
|
||||
{{- if (and site.Params.profileMode.enabled .IsHome) }}
|
||||
{{- partial "index_profile.html" . }}
|
||||
{{- else }} {{/* if not profileMode */}}
|
||||
|
||||
{{- if not .IsHome | and .Title }}
|
||||
<header class="page-header">
|
||||
{{- partial "breadcrumbs.html" . }}
|
||||
<h1>
|
||||
{{ .Title }}
|
||||
{{- if and (or (eq .Kind `term`) (eq .Kind `section`)) (.Param "ShowRssButtonInSectionTermList") }}
|
||||
{{- with .OutputFormats.Get "rss" }}
|
||||
<a href="{{ .RelPermalink }}" title="RSS" aria-label="RSS">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" height="23">
|
||||
<path d="M4 11a9 9 0 0 1 9 9" />
|
||||
<path d="M4 4a16 16 0 0 1 16 16" />
|
||||
<circle cx="5" cy="19" r="1" />
|
||||
</svg>
|
||||
</a>
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
</h1>
|
||||
{{- if .Description }}
|
||||
<div class="post-description">
|
||||
{{ .Description | markdownify }}
|
||||
</div>
|
||||
{{- end }}
|
||||
</header>
|
||||
{{- end }}
|
||||
|
||||
{{- if .Content }}
|
||||
<div class="post-content">
|
||||
{{- if not (.Param "disableAnchoredHeadings") }}
|
||||
{{- partial "anchored_headings.html" .Content -}}
|
||||
{{- else }}{{ .Content }}{{ end }}
|
||||
</div>
|
||||
{{- end }}
|
||||
|
||||
{{- $pages := union .RegularPages .Sections }}
|
||||
|
||||
{{- if .IsHome }}
|
||||
{{- $pages = where site.RegularPages "Type" "in" site.Params.mainSections }}
|
||||
{{- $pages = where $pages "Params.hiddenInHomeList" "!=" "true" }}
|
||||
{{- end }}
|
||||
|
||||
{{- $paginator := .Paginate $pages }}
|
||||
|
||||
{{- if and .IsHome site.Params.homeInfoParams (eq $paginator.PageNumber 1) }}
|
||||
{{- partial "home_info.html" . }}
|
||||
{{- end }}
|
||||
|
||||
{{- $term := .Data.Term }}
|
||||
{{- range $index, $page := $paginator.Pages }}
|
||||
|
||||
{{- $class := "post-entry" }}
|
||||
|
||||
{{- $user_preferred := or site.Params.disableSpecial1stPost site.Params.homeInfoParams }}
|
||||
{{- if (and $.IsHome (eq $paginator.PageNumber 1) (eq $index 0) (not $user_preferred)) }}
|
||||
{{- $class = "first-entry" }}
|
||||
{{- else if $term }}
|
||||
{{- $class = "post-entry tag-entry" }}
|
||||
{{- end }}
|
||||
|
||||
<article class="{{ $class }}">
|
||||
{{- $isHidden := (.Param "cover.hiddenInList") | default (.Param "cover.hidden") | default false }}
|
||||
{{- partial "cover.html" (dict "cxt" . "IsSingle" false "isHidden" $isHidden) }}
|
||||
<header class="entry-header">
|
||||
<h2 class="entry-hint-parent">
|
||||
{{- .Title }}
|
||||
{{- if .Draft }}
|
||||
<span class="entry-hint" title="Draft">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="20" viewBox="0 -960 960 960" fill="currentColor">
|
||||
<path
|
||||
d="M160-410v-60h300v60H160Zm0-165v-60h470v60H160Zm0-165v-60h470v60H160Zm360 580v-123l221-220q9-9 20-13t22-4q12 0 23 4.5t20 13.5l37 37q9 9 13 20t4 22q0 11-4.5 22.5T862.09-380L643-160H520Zm300-263-37-37 37 37ZM580-220h38l121-122-18-19-19-18-122 121v38Zm141-141-19-18 37 37-18-19Z" />
|
||||
</svg>
|
||||
</span>
|
||||
{{- end }}
|
||||
</h2>
|
||||
</header>
|
||||
{{- if (ne (.Param "hideSummary") true) }}
|
||||
<div class="entry-content">
|
||||
<p>{{ .Summary | plainify | htmlUnescape }}{{ if .Truncated }}...{{ end }}</p>
|
||||
</div>
|
||||
{{- end }}
|
||||
{{- if not (.Param "hideMeta") }}
|
||||
<footer class="entry-footer">
|
||||
{{- partial "post_meta.html" . -}}
|
||||
</footer>
|
||||
{{- end }}
|
||||
<a class="entry-link" aria-label="post link to {{ .Title | plainify }}" href="{{ .Permalink }}"></a>
|
||||
</article>
|
||||
{{- end }}
|
||||
|
||||
{{- if gt $paginator.TotalPages 1 }}
|
||||
<footer class="page-footer">
|
||||
<nav class="pagination">
|
||||
{{- if $paginator.HasPrev }}
|
||||
<a class="prev" href="{{ $paginator.Prev.URL | absURL }}">
|
||||
« {{ i18n "prev_page" }}
|
||||
{{- if (.Param "ShowPageNums") }}
|
||||
{{- sub $paginator.PageNumber 1 }}/{{ $paginator.TotalPages }}
|
||||
{{- end }}
|
||||
</a>
|
||||
{{- end }}
|
||||
{{- if $paginator.HasNext }}
|
||||
<a class="next" href="{{ $paginator.Next.URL | absURL }}">
|
||||
{{- i18n "next_page" }}
|
||||
{{- if (.Param "ShowPageNums") }}
|
||||
{{- add 1 $paginator.PageNumber }}/{{ $paginator.TotalPages }}
|
||||
{{- end }} »
|
||||
</a>
|
||||
{{- end }}
|
||||
</nav>
|
||||
</footer>
|
||||
{{- end }}
|
||||
|
||||
{{- partial "share_icons.html" . -}}
|
||||
|
||||
{{- end }}{{/* end profileMode */}}
|
||||
|
||||
{{- end }}{{- /* end main */ -}}
|
||||
@@ -0,0 +1,160 @@
|
||||
{{- define "main" }}
|
||||
<article class="post-single">
|
||||
<header class="post-header">
|
||||
<h1 class="post-title">{{ .Title }}</h1>
|
||||
</header>
|
||||
|
||||
<div class="post-content">
|
||||
<noscript><p>JavaScript is required to use this page.</p></noscript>
|
||||
|
||||
<div id="share-ready" hidden>
|
||||
<form id="share-form" autocomplete="off">
|
||||
<label for="message">Your post</label>
|
||||
<textarea id="message" name="message" rows="4" required></textarea>
|
||||
|
||||
<label for="instance">Your Mastodon instance</label>
|
||||
<div class="mastodon-input-row">
|
||||
<span class="mastodon-prefix">https://</span>
|
||||
<input type="text" id="instance" name="instance"
|
||||
placeholder="mastodon.social" required>
|
||||
<button type="submit">Share</button>
|
||||
</div>
|
||||
<label class="mastodon-remember">
|
||||
<input type="checkbox" id="remember" name="remember">
|
||||
Remember my instance
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.mastodon-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.mastodon-prefix {
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
#message {
|
||||
width: 100%;
|
||||
min-height: 6em;
|
||||
padding: 8px 12px;
|
||||
font: inherit;
|
||||
color: var(--primary);
|
||||
background: var(--theme);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
margin: 8px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#message:focus {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
#instance {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 8px 12px;
|
||||
font: inherit;
|
||||
color: var(--primary);
|
||||
background: var(--theme);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#instance:focus {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
#share-form button[type="submit"] {
|
||||
padding: 8px 20px;
|
||||
font: inherit;
|
||||
color: var(--theme);
|
||||
background: var(--primary);
|
||||
border: 2px solid var(--primary);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#share-form button[type="submit"]:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.mastodon-remember {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var text = params.get('text') || '';
|
||||
var url = params.get('url') || '';
|
||||
|
||||
// Default to sharing the site when visited without params
|
||||
if (!text && !url) {
|
||||
text = {{ site.Title | jsonify | safeJS }};
|
||||
url = {{ site.BaseURL | jsonify | safeJS }};
|
||||
}
|
||||
|
||||
document.getElementById('share-ready').hidden = false;
|
||||
|
||||
// Populate textarea with sensible default
|
||||
var messageEl = document.getElementById('message');
|
||||
var defaultMessage = '';
|
||||
if (text) defaultMessage += text;
|
||||
if (url) defaultMessage += (defaultMessage ? '\n' : '') + url;
|
||||
messageEl.value = defaultMessage;
|
||||
|
||||
// Try to open a local Mastodon app via protocol handler.
|
||||
// If nothing handles it, the page stays as-is and the form below works.
|
||||
window.location.href = 'web+mastodon://share?text='
|
||||
+ encodeURIComponent(messageEl.value);
|
||||
|
||||
var instanceInput = document.getElementById('instance');
|
||||
var rememberCheck = document.getElementById('remember');
|
||||
var saved = localStorage.getItem('mastodon-instance');
|
||||
|
||||
if (saved) {
|
||||
instanceInput.value = saved;
|
||||
rememberCheck.checked = true;
|
||||
}
|
||||
|
||||
document.getElementById('share-form').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
var instance = instanceInput.value.trim()
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
|
||||
if (!instance) return;
|
||||
|
||||
if (rememberCheck.checked) {
|
||||
localStorage.setItem('mastodon-instance', instance);
|
||||
} else {
|
||||
localStorage.removeItem('mastodon-instance');
|
||||
}
|
||||
|
||||
window.open(
|
||||
'https://' + instance + '/share?text=' + encodeURIComponent(messageEl.value),
|
||||
'_blank',
|
||||
'noopener'
|
||||
);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{- end }}
|
||||
@@ -0,0 +1,63 @@
|
||||
{{- define "main" }}
|
||||
|
||||
<article class="post-single">
|
||||
<header class="post-header">
|
||||
{{ partial "breadcrumbs.html" . }}
|
||||
<h1 class="post-title entry-hint-parent">
|
||||
{{ .Title }}
|
||||
{{- if .Draft }}
|
||||
<span class="entry-hint" title="Draft">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="35" viewBox="0 -960 960 960" fill="currentColor">
|
||||
<path
|
||||
d="M160-410v-60h300v60H160Zm0-165v-60h470v60H160Zm0-165v-60h470v60H160Zm360 580v-123l221-220q9-9 20-13t22-4q12 0 23 4.5t20 13.5l37 37q9 9 13 20t4 22q0 11-4.5 22.5T862.09-380L643-160H520Zm300-263-37-37 37 37ZM580-220h38l121-122-18-19-19-18-122 121v38Zm141-141-19-18 37 37-18-19Z" />
|
||||
</svg>
|
||||
</span>
|
||||
{{- end }}
|
||||
</h1>
|
||||
{{- if .Description }}
|
||||
<div class="post-description">
|
||||
{{ .Description }}
|
||||
</div>
|
||||
{{- end }}
|
||||
{{- if not (.Param "hideMeta") }}
|
||||
<div class="post-meta">
|
||||
{{- partial "post_meta.html" . -}}
|
||||
{{- partial "translation_list.html" . -}}
|
||||
{{- partial "edit_post.html" . -}}
|
||||
{{- partial "post_canonical.html" . -}}
|
||||
</div>
|
||||
{{- end }}
|
||||
</header>
|
||||
{{- $isHidden := (.Param "cover.hiddenInSingle") | default (.Param "cover.hidden") | default false }}
|
||||
{{- partial "cover.html" (dict "cxt" . "IsSingle" true "isHidden" $isHidden) }}
|
||||
{{- if (.Param "ShowToc") }}
|
||||
{{- partial "toc.html" . }}
|
||||
{{- end }}
|
||||
|
||||
{{- if .Content }}
|
||||
<div class="post-content">
|
||||
{{- if not (.Param "disableAnchoredHeadings") }}
|
||||
{{- partial "anchored_headings.html" .Content -}}
|
||||
{{- else }}{{ .Content }}{{ end }}
|
||||
</div>
|
||||
{{- end }}
|
||||
|
||||
<footer class="post-footer">
|
||||
{{- $tags := .Language.Params.Taxonomies.tag | default "tags" }}
|
||||
<ul class="post-tags">
|
||||
{{- range ($.GetTerms $tags) }}
|
||||
<li><a href="{{ .Permalink }}">{{ .LinkTitle }}</a></li>
|
||||
{{- end }}
|
||||
</ul>
|
||||
{{- if (.Param "ShowPostNavLinks") }}
|
||||
{{- partial "post_nav_links.html" . }}
|
||||
{{- end }}
|
||||
{{- partial "share_icons.html" . -}}
|
||||
</footer>
|
||||
|
||||
{{- if (.Param "comments") }}
|
||||
{{- partial "comments.html" . }}
|
||||
{{- end }}
|
||||
</article>
|
||||
|
||||
{{- end }}{{/* end main */}}
|
||||
@@ -0,0 +1,10 @@
|
||||
{{- if or .Params.author site.Params.author }}
|
||||
{{- $author := (.Params.author | default site.Params.author) }}
|
||||
{{- if reflect.IsMap $author }}
|
||||
{{- $author.name }}
|
||||
{{- else if (or (eq (printf "%T" $author) "[]string") (eq (printf "%T" $author) "[]interface {}")) }}
|
||||
{{- (delimit $author ", " ) }}
|
||||
{{- else }}
|
||||
{{- $author }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,3 @@
|
||||
{{ if .IsHome }}
|
||||
<a rel="me" href="https://mastodon.sdf.org/@axs" style="position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap;"> </a>
|
||||
{{ end }}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!-- KaTeX math -->
|
||||
{{ if .Params.xparams.math }}
|
||||
{{ partialCached "math.html" . }}
|
||||
{{ end }}
|
||||
|
||||
<!-- Mermaid diagrams -->
|
||||
{{ if .Params.xparams.mermaid }}
|
||||
{{ partialCached "mermaid.html" . }}
|
||||
{{ end }}
|
||||
|
||||
<!-- React components -->
|
||||
{{- partial "react.html" . -}}
|
||||
@@ -0,0 +1,35 @@
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/katex@0.16.42/dist/katex.min.css"
|
||||
integrity="sha384-DVShYR21zvUU4zL2VjLlIbYSeiS43grntDO/Sm1DwmGGXKxGmvBlXWZ9lnyKhota"
|
||||
crossorigin="anonymous"
|
||||
>
|
||||
<script
|
||||
defer
|
||||
src="https://cdn.jsdelivr.net/npm/katex@0.16.42/dist/katex.min.js"
|
||||
integrity="sha384-qrraMcfiHZOij7s14X818B0oe4NpSugmOO0Q0fmDYBWV+6c10vA26yjevqe5zD0D"
|
||||
crossorigin="anonymous">
|
||||
</script>
|
||||
<script
|
||||
defer
|
||||
src="https://cdn.jsdelivr.net/npm/katex@0.16.42/dist/contrib/auto-render.min.js"
|
||||
integrity="sha384-bjyGPfbij8/NDKJhSGZNP/khQVgtHUE5exjm4Ydllo42FwIgYsdLO2lXGmRBf5Mz"
|
||||
crossorigin="anonymous"
|
||||
onload="renderMathInElement(document.body);">
|
||||
</script>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
renderMathInElement(document.body, {
|
||||
delimiters: [
|
||||
{left: '\\[', right: '\\]', display: true}, // block
|
||||
{left: '\\(', right: '\\)', display: false}, // inline
|
||||
],
|
||||
throwOnError : false
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
.katex {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script type="module">
|
||||
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11.13.0/dist/mermaid.esm.min.mjs';
|
||||
|
||||
// Read PaperMod's CSS variables to theme Mermaid
|
||||
const s = getComputedStyle(document.documentElement);
|
||||
const v = (name) => s.getPropertyValue(name).trim();
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: true,
|
||||
theme: 'base',
|
||||
flowchart: {
|
||||
padding: 15,
|
||||
nodeSpacing: 30,
|
||||
useMaxWidth: true,
|
||||
htmlLabels: true,
|
||||
},
|
||||
themeVariables: {
|
||||
primaryColor: v('--code-bg'),
|
||||
primaryTextColor: v('--content'),
|
||||
primaryBorderColor: v('--border'),
|
||||
lineColor: v('--secondary'),
|
||||
secondaryColor: v('--code-bg'),
|
||||
tertiaryColor: v('--theme'),
|
||||
// these are PaperMod's defaults
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif',
|
||||
fontSize: '14px',
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,16 @@
|
||||
{{- /* Check if any React components are requested in the front matter */ -}}
|
||||
{{- $reactParams := .Params.react -}}
|
||||
{{- if $reactParams -}}
|
||||
{{- /* Include the React core partial once */ -}}
|
||||
{{- partialCached "react/core.html" . "react-core" -}}
|
||||
|
||||
{{- /* reactParams is is true (and not a list), skip adding components */ -}}
|
||||
{{- if not (eq $reactParams true) -}}
|
||||
{{- /* Loop over each requested component and include its corresponding partial */ -}}
|
||||
{{- range $component := $reactParams -}}
|
||||
{{- $reactComponent := printf "react/%s" $component -}}
|
||||
{{- $componentPartial := printf "%s.html" $reactComponent -}}
|
||||
{{- partialCached $componentPartial . (string $reactComponent) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,7 @@
|
||||
<!-- React Core Libraries -->
|
||||
<script src="https://unpkg.com/react@16/umd/react.development.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js" crossorigin></script>
|
||||
<!-- Load the htmlwidgets runtime -->
|
||||
{{ with .Resources.GetMatch "htmlwidgets.js" }}
|
||||
<script src="{{ .RelPermalink }}"></script>
|
||||
{{ end }}
|
||||
@@ -0,0 +1,3 @@
|
||||
<!-- Reactable Component Assets -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/reactable@0.2.3/dist/reactable.min.css">
|
||||
<script src="https://unpkg.com/reactable@0.2.3/dist/reactable.min.js"></script>
|
||||
@@ -0,0 +1,23 @@
|
||||
{{- /* layouts/partials/share-platform-defaults.html */ -}}
|
||||
{{- $raw := .Site.Params.share | default (slice) -}}
|
||||
{{- $defaults := slice -}}
|
||||
{{- if reflect.IsSlice $raw -}}
|
||||
{{- /* If .Site.Params.share is a slice, iterate over items */ -}}
|
||||
{{- range $item := $raw -}}
|
||||
{{- if eq (printf "%T" $item) "string" -}}
|
||||
{{- $defaults = $defaults | append $item -}}
|
||||
{{- else -}}
|
||||
{{- /* If the item is a map, extract its "platform" key */ -}}
|
||||
{{- $defaults = $defaults | append $item.platform -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- else -}}
|
||||
{{- /* If .Site.Params.share is a map, iterate over keys */ -}}
|
||||
{{- range $platform, $settings := $raw -}}
|
||||
{{- if $settings.include -}}
|
||||
{{- $defaults = $defaults | append $platform -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- return $defaults -}}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<a href="{{ .url }}" title="{{ .title }}" target="_blank">
|
||||
<span class="icon">{{ partial "svg.html" (dict "name" .platform) }}</span>
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{{- $title := default "Share on LinkedIn" .title -}}
|
||||
{{- $url := printf "https://www.linkedin.com/shareArticle?mini=true&url=%s&title=%s" (.pagePermalink | urlquery) (.pageTitle | urlquery) -}}
|
||||
<a href="{{ $url }}"
|
||||
title="{{ $title }}"
|
||||
target="_blank"
|
||||
rel="noopener">
|
||||
<span class="icon">{{ partialCached "svg.html" (dict "name" "linkedin") "linkedin-icon" }}</span>
|
||||
</a>
|
||||
@@ -0,0 +1,5 @@
|
||||
{{- $mastodonTitle := default "Share on Mastodon" .title -}}
|
||||
<a href="/share/mastodon/?text={{ .pageTitle | urlquery }}&url={{ .pagePermalink | urlquery }}"
|
||||
title="{{ $mastodonTitle }}">
|
||||
<span class="icon">{{ partialCached "svg.html" (dict "name" "mastodon") "mastodon-icon" }}</span>
|
||||
</a>
|
||||
@@ -0,0 +1,27 @@
|
||||
{{- /* layouts/partials/share-render.html */ -}}
|
||||
{{- $parent := . -}}
|
||||
{{- $platforms := .platforms -}}
|
||||
{{- $ctx := .context -}}
|
||||
{{- if gt (len $platforms) 0 -}}
|
||||
<div class="social-sharing">
|
||||
{{- range $platformName := $platforms -}}
|
||||
{{- $cfg := index $ctx.Site.Params.share $platformName | default dict -}}
|
||||
{{- /* Merge dynamic elements with the explicit configuration */ -}}
|
||||
{{- $merged := merge
|
||||
(dict
|
||||
"pageTitle" $ctx.Title
|
||||
"pagePermalink" $ctx.Permalink
|
||||
"platform" $platformName
|
||||
)
|
||||
$cfg
|
||||
-}}
|
||||
{{- $partialName := printf "share-platform-%s.html" $platformName -}}
|
||||
{{- if templates.Exists (printf "partials/%s" $partialName) -}}
|
||||
{{- partial $partialName $merged -}}
|
||||
{{- else -}}
|
||||
{{- partial "share-platform-generic.html" $merged -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{ partialCached "share-style.html" . "share-style" }}
|
||||
</div>
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,20 @@
|
||||
<style>
|
||||
.social-sharing {
|
||||
text-align: center;
|
||||
margin-top: var(--gap);
|
||||
}
|
||||
.social-sharing a {
|
||||
display: inline-flex;
|
||||
padding: 10px;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
.social-sharing .icon {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
fill: currentColor;
|
||||
transition: fill 0.3s;
|
||||
}
|
||||
.social-sharing a:hover .icon {
|
||||
fill: var(--primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
{{- /* layouts/partials/share_icons.html */ -}}
|
||||
{{- /* Global kill switch */ -}}
|
||||
{{- if not site.Params.ShowShareButtons -}}{{- return -}}{{- end -}}
|
||||
{{- /* Per-page kill switches */ -}}
|
||||
{{- if eq .Params.disableShare true -}}{{- return -}}{{- end -}}
|
||||
{{- if eq (.Params.xparams.share | default true) false -}}{{- return -}}{{- end -}}
|
||||
|
||||
{{- /* Get the default list of sharing platforms */ -}}
|
||||
{{- $platforms := partialCached "share-platform-defaults.html" . "defaultPlatforms" -}}
|
||||
|
||||
{{- /* Override default list if the page provides its own list */ -}}
|
||||
{{- $pageSocial := .Params.xparams.share | default (slice) -}}
|
||||
{{- if and $pageSocial (reflect.IsSlice $pageSocial) -}}
|
||||
{{- $platforms = $pageSocial -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- partial "share-render.html" (dict "platforms" $platforms "context" .) -}}
|
||||
@@ -0,0 +1 @@
|
||||
{{ if eq (getenv "HUGO_BLOGDOWN_POST_RELREF") "true" }}{{ .Page.RelPermalink }}{{ else }}{{ .Page.Permalink }}{{ end }}
|
||||
@@ -0,0 +1,18 @@
|
||||
{{/* layouts/shortcodes/commandexample.html */}}
|
||||
|
||||
{{ $src := .Get "src" }}
|
||||
{{ $outPath := replace $src ".sh" ".out" }}
|
||||
|
||||
{{ $sourceContent := readFile $src }}
|
||||
{{ $outputContent := readFile $outPath }}
|
||||
|
||||
<div class="command-example">
|
||||
<div class="command-section command-source">
|
||||
<h4>Command</h4>
|
||||
<pre>{{ highlight $sourceContent "bash" "" }}</pre>
|
||||
</div>
|
||||
<div class="command-section command-output">
|
||||
<h4>Output</h4>
|
||||
<pre><code>{{ $outputContent | safeHTML }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
{{ $align := .Get "align" | default "center" }}
|
||||
<div style="text-align: {{ $align }};">
|
||||
<blockquote class="mastodon-embed" data-lang="en">
|
||||
<a href="{{ .Get 0 }}">View Mastodon Post</a>
|
||||
</blockquote>
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
{{/* toc shortcode - Conditionally renders the Table of Contents based on word count or an explicit front matter flag */}}
|
||||
{{ $threshold := .Site.Params.tocWordCountThreshold | default 300 }}
|
||||
{{ $wordCount := countwords .Page.Content }}
|
||||
{{ $showToc := or (gt $wordCount $threshold) (.Page.Params.toc) }}
|
||||
{{ if $showToc }}
|
||||
{{ partial "toc.html" .Page }}
|
||||
{{ end }}
|
||||
{{ partial "toc.html" .Page }}
|
||||
@@ -0,0 +1,18 @@
|
||||
{{ $align := .Get "align" | default "center" }}
|
||||
{{ $width := .Get "width" | default 640 }}
|
||||
{{ $height := .Get "height" | default 360 }}
|
||||
{{ $aspectRatio := mul (div (float $height) (float $width)) 100 }}
|
||||
|
||||
<div style="text-align: {{ $align }};">
|
||||
<div class="video-container" style="position: relative; padding-bottom: {{ printf "%.2f" $aspectRatio }}%; height: 0; overflow: hidden; max-width: {{ $width }}px;">
|
||||
<iframe
|
||||
src="https://player.vimeo.com/video/{{ .Get 0 }}"
|
||||
width="{{ $width }}"
|
||||
height="{{ $height }}"
|
||||
frameborder="0"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
allowfullscreen
|
||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
|
||||
</iframe>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,19 @@
|
||||
{{ $align := .Get "align" | default "center" }}
|
||||
{{ $width := .Get "width" | default 560 }}
|
||||
{{ $height := .Get "height" | default 315 }}
|
||||
{{/* Calculate aspect ratio as a percentage. Convert to float for proper division. */}}
|
||||
{{ $aspectRatio := mul (div (float $height) (float $width)) 100 }}
|
||||
|
||||
<div style="text-align: {{ $align }};">
|
||||
<div class="video-container" style="position: relative; padding-bottom: {{ printf "%.2f" $aspectRatio }}%; height: 0; overflow: hidden; max-width: {{ $width }}px;">
|
||||
<iframe
|
||||
width="{{ $width }}"
|
||||
height="{{ $height }}"
|
||||
src="https://www.youtube.com/embed/{{ .Get 0 }}"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
|
||||
</iframe>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
library/
|
||||
local/
|
||||
cellar/
|
||||
lock/
|
||||
python/
|
||||
sandbox/
|
||||
staging/
|
||||
+1419
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"bioconductor.version": null,
|
||||
"external.libraries": [],
|
||||
"ignored.packages": [],
|
||||
"package.dependency.fields": [
|
||||
"Imports",
|
||||
"Depends",
|
||||
"LinkingTo"
|
||||
],
|
||||
"ppm.enabled": null,
|
||||
"ppm.ignored.urls": [],
|
||||
"r.version": null,
|
||||
"snapshot.dev": false,
|
||||
"snapshot.type": "explicit",
|
||||
"use.cache": true,
|
||||
"vcs.ignore.cellar": true,
|
||||
"vcs.ignore.library": true,
|
||||
"vcs.ignore.local": true,
|
||||
"vcs.manage.ignores": true
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
pak::pkg_install(
|
||||
c(
|
||||
"knitr",
|
||||
"tidyverse",
|
||||
"reactable",
|
||||
"htmltools",
|
||||
"svglite"
|
||||
)
|
||||
)
|
||||
renv::snapshot()
|
||||
# vim: ft=r
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
WATCH_DIR="content/posts"
|
||||
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
LIBS_DIR="static/libs"
|
||||
LIBS_URL="/libs"
|
||||
|
||||
echo "🚨 Watching ${WATCH_DIR} for changes..."
|
||||
|
||||
inotifywait -m -e close_write --format '%w%f' -r "$WATCH_DIR" | while read FILE; do
|
||||
if [[ "$FILE" == *.Rmd ]]; then
|
||||
POST_DIR=$(dirname "$FILE")
|
||||
|
||||
echo "🔄 Change detected in $FILE. Rendering..."
|
||||
|
||||
(cd "$POST_DIR" && Rscript -e 'renv::load("'"${PROJECT_ROOT}"'"); statdown::statdown_render("index.Rmd", output_root = "'"${PROJECT_ROOT}/${LIBS_DIR}"'", url_root = "'"${LIBS_URL}"'")')
|
||||
|
||||
echo "✅ Rendered ${FILE}"
|
||||
fi
|
||||
done
|
||||
@@ -0,0 +1 @@
|
||||
RedirectMatch 301 ^/share/?$ /
|
||||
@@ -0,0 +1,2 @@
|
||||
RewriteEngine Off
|
||||
DirectoryIndex index.html
|
||||
+1
-1
Submodule themes/PaperMod updated: 9ea3bb0e1f...154d006e01
Reference in New Issue
Block a user